Files
SoulStream/src-tauri/src/loudness.rs
T
KeelrLeek 52f5878297 loudness: a first play starts three quarters of a decibel under the prefix
The prefix estimator is BIASED, not merely noisy, and the sign is known: a
quiet opening under-reads the track, so the head asks for too much gain far
more often than too little. Subtracting a constant moves the whole
distribution toward the truth, so a first play begins nearer its real level
and the correction that follows has less ground to cover.

Measured by a new `head_start_offset_sweep` over 1046 tracks sampled across
182 artists — at most six per artist, spread across each discography rather
than taken from the front, since openers are where intros are least typical
and intros are the whole source of the bias. The cached corpus it replaces
was a dozen artists and put the median at +3.48 dB. Sampled diversely it is
+1.55, so the old number was overstating the bias by more than double.

The size comes from the MARGINAL trade, not the smallest mean error:

  step         loud fixed   quiet created   ratio
  0.0 -> 0.5          -89             +41   2.2 : 1
  0.5 -> 1.0          -83             +85   1.0 : 1
  1.0 -> 1.5          -61            +132   0.5 : 1

Only the first step is clearly worth taking. mean|err| bottoms out at 1.5 dB
instead and is the wrong objective: it weights an error that begins decaying
at once the same as one that waits for Analysis::Complete, which on the
streamed first plays this path exists for is download-bound and therefore
late.

A constant is deliberately a weak lever. The per-artist median error runs
from -4.58 dB (Boards Of Canada) to +7.03 dB (Ponytail); no single number
fits everyone, and an offset shifts that spread without narrowing it. The
levers that would are letting the follow recover UPWARD, and predicting the
offset per track from the prefix. Both still open.

The overlay reports it as a MOVE — what the prefix asked for, where the
track actually started, and a ghost tick on the dB scale at the prefix
position, so the gap to the applied gain IS the head start. Gated on
`analyzing`: at Analysis::Complete the player releases to the whole-track
gain while loudness.gainDb goes on reporting the prefix value, which it
keeps for the next play. Without that condition the line advertised a
correction that had already been released.
2026-07-30 07:39:44 -05:00

1269 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Volume normalization — the app-side orchestration over the player
//! crate's BS.1770 meter. Every main-player track is measured and played
//! with a gain that brings it to one target loudness (−14 LUFS, Spotify's
//! reference), so track-to-track volume stops being all over the place.
//!
//! Streaming-first: an unmeasured track is analyzed DURING its first play,
//! reading the same growing partial file the player streams from, and the
//! finished measurement lands in `loudness-cache.json` for every later play,
//! where the exact gain is applied before the first sample.
//!
//! The rule that shapes everything here is that a PREFIX IS NOT THE TRACK.
//! What the opening seconds measure is only ever evidence about the opening
//! seconds, so the head of an unmeasured track may lower the volume but
//! never raise it, and a measurement that did not reach the end of the media
//! is never written to the cache. Both are one-directional protections
//! against the same mistake: turning audio up on the strength of a quiet
//! passage, and meeting the loud one still turned up.
//!
//! Toggleable via config `volume_normalization` (default on). Preview clips
//! are normalized too, from a complete measurement of the downloaded clip
//! (see `preview.rs`) — they are short and arrive whole, so they never need
//! the provisional path.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use tauri::Manager;
use player::{analyze_loudness, Analysis, AnalysisSource, StreamProgress};
use crate::playback::PlayerState;
/// Playback target (Spotify's default reference level).
pub const TARGET_LUFS: f64 = -14.0;
/// Gain clamp: quiet-track boosts are the risky direction (headroom), loud
/// tracks can always be cut.
const MIN_GAIN_DB: f64 = -15.0;
const MAX_GAIN_DB: f64 = 9.0;
/// Boosts additionally respect the track's own sample peak up to this
/// ceiling — normalization must never introduce clipping.
const PEAK_CEILING: f64 = 0.98;
const CACHE_FILE: &str = "loudness-cache.json";
/// Entries beyond this are dropped oldest-first at save time.
const CACHE_CAP: usize = 4000;
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Measured {
pub lufs: f64,
pub peak: f32,
/// File size when measured — a different size is a different file.
pub size: u64,
pub at_epoch_ms: i64,
}
/// Live view of the ACTIVE track's normalization, for the debug overlay.
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct CurrentTrack {
pub lufs: Option<f64>,
pub peak: f32,
pub gain_db: f64,
/// The gain came from the cache (exact, applied pre-sample).
pub cached: bool,
/// The analyzer is still measuring this play.
pub analyzing: bool,
/// What the applied gain was actually decided from — the difference
/// between a whole-track measurement and a prefix is invisible in the
/// numbers alone, which is how the over-boost went unnoticed.
pub source: &'static str,
/// How much [`PRELIM_HEAD_START_DB`] took off this start, 0.0 when the
/// gain came from anywhere but a prefix.
pub head_start_db: f64,
/// What the prefix alone asked for, before the head start. Carried so the
/// overlay can show the correction as a MOVE rather than a single number —
/// a bias correction that cannot be seen working is indistinguishable from
/// one that silently stopped being applied.
pub prefix_gain_db: Option<f64>,
}
pub struct LoudnessState {
cache: Mutex<HashMap<String, Measured>>,
/// Bumped on every track start; orphans older analyzer threads.
generation: Arc<AtomicU64>,
enabled: AtomicBool,
/// Debug-overlay mirror of the active track's measurement state.
current: Mutex<Option<CurrentTrack>>,
}
/// (normalization enabled, target LUFS, active-track state) for diagnostics.
pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option<CurrentTrack>) {
let state = app.state::<LoudnessState>();
let current = *state.current.lock().expect("loudness current lock");
(
state.enabled.load(Ordering::Relaxed),
TARGET_LUFS,
current,
)
}
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE))
}
pub fn init(app: &tauri::AppHandle) {
let cache: HashMap<String, Measured> = cache_file(app)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let enabled = crate::config::volume_normalization(&crate::config::load(app));
app.manage(LoudnessState {
cache: Mutex::new(cache),
generation: Arc::new(AtomicU64::new(0)),
enabled: AtomicBool::new(enabled),
current: Mutex::new(None),
});
}
fn save_cache(app: &tauri::AppHandle, state: &LoudnessState) {
let Some(path) = cache_file(app) else { return };
let mut entries: Vec<(String, Measured)> = state
.cache
.lock()
.expect("loudness cache lock")
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
if entries.len() > CACHE_CAP {
entries.sort_by_key(|(_, m)| std::cmp::Reverse(m.at_epoch_ms));
entries.truncate(CACHE_CAP);
}
let map: HashMap<&String, &Measured> = entries.iter().map(|(k, v)| (k, v)).collect();
if let Ok(json) = serde_json::to_string(&map) {
let _ = std::fs::write(path, json);
}
}
/// gain = target − measured, clamped, and boosts capped so the track's own
/// peak never exceeds the ceiling.
fn gain_db(m: &Measured) -> f64 {
let mut g = (TARGET_LUFS - m.lufs).clamp(MIN_GAIN_DB, MAX_GAIN_DB);
if g > 0.0 && m.peak > 0.0 {
let headroom = 20.0 * (PEAK_CEILING / m.peak as f64).log10();
g = g.min(headroom.max(0.0));
}
g
}
/// The same −14 LUFS normalization gain (dB) the main player uses, exposed
/// for the preview backend so Deezer clips are brought to the SAME loudness
/// as the user's own normalized tracks instead of playing at their raw
/// (much louder) master level.
pub fn normalization_gain_db(lufs: f64, peak: f32) -> f64 {
gain_db(&Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
})
}
/// Toggle from settings: applies to the CURRENT track immediately (gain to
/// unity when turning off; the cached gain back on when turning on is left
/// to the next track — the analyzer generation for the current one is gone).
pub fn set_enabled(app: &tauri::AppHandle, enabled: bool) {
let state = app.state::<LoudnessState>();
state.enabled.store(enabled, Ordering::Relaxed);
if !enabled {
state.generation.fetch_add(1, Ordering::Relaxed); // orphan analyzers
*state.current.lock().expect("loudness current lock") = None;
app.state::<PlayerState>().player.set_norm_gain_db(0.0, false);
}
}
/// Playback stopped: orphan this track's analyzer and, with it, the held
/// start that the measurement window is still sitting on.
///
/// Stop used to leave the loudness generation alone, so a track stopped
/// during its measurement window could be handed its deferred `play` a
/// moment later and start playing again after the user had stopped it.
pub fn on_playback_stopped(app: &tauri::AppHandle) {
let state = app.state::<LoudnessState>();
state.generation.fetch_add(1, Ordering::Relaxed);
*state.current.lock().expect("loudness current lock") = None;
}
/// Pre-playback measurement window: the audible start is HELD until this
/// much audio is analyzed (at download/disk speed — normally well under a
/// second of wall time)…
const PRELIM_MIN_SECS: f64 = 10.0;
/// …or until this much wall time passes after the FIRST measurement lands
/// (slow peer): then we commit to what we have. Either way the gain is set
/// BEFORE the first audible sample and NEVER changes mid-track (user
/// decision 2026-07-23 — the earlier mid-play glide was audible and bad).
const PRELIM_WALL_BUDGET: std::time::Duration = std::time::Duration::from_millis(800);
/// …and no later than this after the play was REQUESTED, whatever the
/// analyzer has to say.
///
/// Both windows above are measured from the first measurement, and a track
/// that opens below the −70 LUFS gate never produces one — silence, a long
/// fade-in, a pre-gap. Those held the audible start until the analyzer found
/// audio loud enough to measure, which on a slow stream is a wait with no
/// upper bound. Releasing early costs nothing that matters: the player
/// blocks on the growing file anyway, so this hands it the start rather than
/// making it wait, and the gain it starts with is the best cut the evidence
/// so far justifies.
const PRELIM_HARD_DEADLINE: std::time::Duration = std::time::Duration::from_millis(2500);
/// The most silence a play may ever cost, measured from the REQUEST.
///
/// Anchoring [`PRELIM_HARD_DEADLINE`] to the first byte fixed tracks starting
/// un-normalized, but uncapped it also moved the cost rather than removing it:
/// a peer that starts slowly and then delivers quickly had the gate holding
/// audio that was ready to play (measured: `held_ms` 3743 and 4098 with 3.4 MB
/// and 1.4 MB already received). Before the first byte, holding is free —
/// playback is blocked on those same bytes. After it, holding is a real wait.
///
/// So the two clocks run together and whichever expires first wins: the
/// measurement gets its window from the moment data starts flowing, and the
/// listener never waits longer than this whatever happens.
const PRELIM_ABSOLUTE_CEILING: std::time::Duration = std::time::Duration::from_millis(4000);
/// A prefix may only ever CUT, never boost.
///
/// The head of a track is not evidence about the rest of it, and the error is
/// one-directional: a quiet opening under-reads the track, so the estimator
/// asks to turn it UP, and then the song arrives at its real level already
/// amplified. A track that opens on a lone guitar and swells into a full
/// chorus was being boosted off the intro and playing the chorus far too loud.
/// The old +3 dB ceiling bounded that mistake without preventing it — 3 dB is
/// a doubling of power, and it lands exactly on the loudest part of the song.
///
/// A ceiling of zero rather than a flat unity gain, because the error is not
/// symmetric. Measured over 69 real cached tracks, the 10 s head asks for too
/// much gain on 57 of them (median +2.9 dB, worst +17.0) and too little on 2,
/// by at most 1.5 dB. So a prefix is a bad witness for turning a track UP and
/// a serviceable one for turning it down, and refusing its cuts would be a
/// regression in the direction being complained about: a track whose opening
/// is already at −3.4 LUFS gets −10.6 dB today, and unity would hand it back
/// every one of those decibels on its first play.
///
/// A real boost still happens — it just has to be earned by a whole-track
/// measurement, which arrives from the cache, from a preload, or from this
/// track's own analyzer in time for the NEXT play.
const PRELIM_MAX_BOOST_DB: f64 = 0.0;
fn prelim_gain_db(m: &Measured) -> f64 {
gain_db(m).min(PRELIM_MAX_BOOST_DB)
}
/// How far BELOW the prefix's own answer a first play starts.
///
/// The prefix estimator is biased, not merely noisy, and the bias has a known
/// sign: a quiet opening under-reads the track, so the head asks for too much
/// gain far more often than too little. Measured by `head_start_offset_sweep`
/// over 1046 tracks sampled across 182 artists, the 10 s head starts a track
/// LOUDER than its true level on 618 of them and quieter on 87 — median
/// +1.55 dB. Subtracting a constant moves the whole distribution toward zero,
/// so the first play begins nearer the truth and the slew has less to cover.
///
/// The size is chosen from the MARGINAL trade, not from the smallest mean
/// error, because the two directions do not cost the same:
///
/// * too loud starts correcting AT ONCE — [`following_target_db`] ratchets
/// down from the first running publish, so the error decays throughout;
/// * too quiet is stuck until `Analysis::Complete`, because the ratchet is
/// monotone-down and only a whole-track measurement earns a boost. Complete
/// arrives when the analyzer reaches the end of the MEDIA, which on the
/// streamed first plays this path exists for is download-bound — i.e. late.
///
/// So both directions do eventually reach the truth; they do not reach it on
/// the same timescale, and that gap is the asymmetry.
///
/// So each half-decibel has to fix meaningfully more tracks than it strands:
///
/// ```text
/// step loud fixed quiet created ratio
/// 0.0 → 0.5 −89 +41 2.2 : 1
/// 0.5 → 1.0 −83 +85 1.0 : 1
/// 1.0 → 1.5 −61 +132 0.5 : 1
/// ```
///
/// Only the first step is clearly worth taking, which is what puts this
/// constant below 1 dB. `mean|err|` bottoms out at 1.5 dB instead, and is the
/// wrong objective: it weights an error that decays in seconds the same as one
/// that lasts the whole track.
///
/// A constant is a weak lever and deliberately a small one. The per-artist
/// median error spans −4.58 dB (Boards Of Canada) to +7.03 dB (Ponytail), so
/// no single number fits everyone — an offset shifts that distribution but
/// cannot narrow it. The real fixes are to let the follow recover UPWARD, or
/// to predict the offset per track from the prefix; both are open.
const PRELIM_HEAD_START_DB: f64 = 0.75;
/// The prefix's answer, moved down by the measured head-start bias and held
/// above the same floor a full measurement gets.
fn prelim_start_gain_db(m: &Measured) -> f64 {
(prelim_gain_db(m) - PRELIM_HEAD_START_DB).max(MIN_GAIN_DB)
}
/// The mid-track correction follows the running measurement from its FIRST
/// publish, and may only ever cut further — never give gain back — until the
/// whole track has been measured.
///
/// There was a convergence detector here: "the last 3 publishes are within
/// 0.3 dB, after 30 s". It did not work, and the traces showed why. An
/// INTEGRATED average over 30 s cannot move much when one more second is added
/// to it — the test was detecting the arithmetic inertia of a long average,
/// not convergence, so it fired at the 30 s floor every time and declared
/// settled while still 1.5 dB and 2.1 dB from the true value on two measured
/// tracks. A test that is always true is not a test.
///
/// Following from the start needs no such test, because the protection was
/// never the gate:
///
/// * the audio thread slews at 0.25 dB/s, so the applied gain physically
/// cannot chase a noisy target — the slew IS a low-pass filter, and an early
/// wild estimate is smoothed rather than obeyed;
/// * the ratchet below blocks the one direction that goes badly wrong.
///
/// The ratchet matters more than it looks. Capping the TARGET at 0 dB is not
/// enough while following: a quiet passage reads high, asks for a boost, gets
/// clamped to 0 — and slewing from an existing −5 dB cut up to 0 turns the
/// track UP during its quietest part. Monotone-down forbids that, and it
/// matches how the estimate actually moves: measured across 89 cached tracks
/// the head under-reads on 57 of them (median +2.9 dB too much gain), so the
/// running value converges downward in gain almost every time.
///
/// Released at `Analysis::Complete`, where a real boost becomes earnable.
fn following_target_db(current: Option<f64>, m: &Measured) -> f64 {
let wanted = prelim_gain_db(m); // cut-only
match current {
Some(prev) => prev.min(wanted),
None => wanted,
}
}
fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, size_hint: u64) {
let final_size = std::fs::metadata(key).map(|m| m.len()).unwrap_or(size_hint);
let measured = Measured {
lufs,
peak,
size: final_size,
at_epoch_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
};
let state = app.state::<LoudnessState>();
state
.cache
.lock()
.expect("loudness cache lock")
.insert(key.to_owned(), measured);
save_cache(app, &state);
}
/// What the preliminary window actually bought, per track.
///
/// `PRELIM_MIN_SECS` and `PRELIM_WALL_BUDGET` are coupled — the gate commits at
/// whichever arrives first — so the constant that binds in practice cannot be
/// read off either one. This reports it: how much audio was analyzed, which
/// condition fired, and what it cost in wall time. Enabled with
/// `SOULSTREAM_LOUDNESS_TRACE=1`; zero-cost when off.
fn loudness_trace(msg: &str) {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *ON.get_or_init(|| std::env::var("SOULSTREAM_LOUDNESS_TRACE").is_ok_and(|v| v == "1")) {
eprintln!("LOUDNESS_TRACE\t{msg}");
}
}
/// The held start of an unmeasured track, and the best evidence anyone has
/// found for it so far. Two things race to open it — the analyzer, once it
/// has heard enough, and the startup deadline — so both go through
/// [`Gate::release`] and exactly one of them wins.
struct Gate {
play: Option<Box<dyn FnOnce() + Send>>,
/// Most recent prefix measurement, or None if nothing has gated yet.
heard: Option<Measured>,
/// Seconds of audio behind `heard`, for the trace.
heard_secs: f64,
/// When the play was REQUESTED, so the trace can report the delay the
/// listener actually experienced rather than analyzer-relative time.
requested_at: std::time::Instant,
/// The transfer behind a streamed track, read at commit time.
///
/// This is the number that says WHY a commit measured nothing. If the
/// deadline fires with only a few KB received, the analyzer was starved of
/// bytes and the fix is upstream in the transfer. If it fires with
/// megabytes in hand, the bytes were there and something between the file
/// and the first gating block ate the time — a format probe walking a
/// large ID3 tag full of embedded artwork being the obvious suspect on
/// Soulseek MP3s. The two have completely different fixes.
progress: Option<Arc<StreamProgress>>,
}
impl Gate {
/// Start playback at the gain the evidence so far justifies — which for
/// a prefix is a cut or nothing. Returns the committed gain if this call
/// was the one that opened the gate, and None if it had already opened.
fn release(gate: &Mutex<Gate>, app: &tauri::AppHandle, why: &str) -> Option<f64> {
// The closure is taken under the lock and called outside it: `play`
// reaches into the player, and nothing here should hold a lock that
// the audio side might want.
let (play, heard, secs, waited, got) = {
let mut g = gate.lock().expect("loudness gate lock");
match g.play.take() {
Some(play) => (
play,
g.heard,
g.heard_secs,
g.requested_at.elapsed(),
g.progress.as_ref().map(|p| p.received_done()),
),
None => return None, // someone else already started it
}
};
// The prefix's own answer and the one actually committed: the head
// start is the gap, and the trace reports both so a run can be checked
// for whether the correction was applied at all.
let prefix_db = heard.as_ref().map(prelim_gain_db);
let db = heard.as_ref().map(prelim_start_gain_db).unwrap_or(0.0);
loudness_trace(&format!(
"commit\twhy={why}\tsecs={secs:.1}\theld_ms={}\tgain={db:+.2}\tprefix={}\t\
head_start={:.2}\thead_lufs={}\trecv={}",
waited.as_millis(),
prefix_db
.map(|d| format!("{d:+.2}"))
.unwrap_or_else(|| "none".into()),
prefix_db.map(|p| p - db).unwrap_or(0.0),
heard
.as_ref()
.map(|m| format!("{:.1}", m.lufs))
.unwrap_or_else(|| "none".into()),
got.map(|(bytes, done)| format!("{}KB done={done}", bytes / 1024))
.unwrap_or_else(|| "local".into()),
));
play();
app.state::<PlayerState>()
.player
.set_norm_gain_db(db as f32, true);
*app.state::<LoudnessState>()
.current
.lock()
.expect("loudness current lock") = Some(CurrentTrack {
lufs: heard.map(|m| m.lufs),
peak: heard.map(|m| m.peak).unwrap_or(0.0),
gain_db: db,
cached: false,
analyzing: true,
source: if heard.is_some() {
"prefix (cut only)"
} else {
"unity (nothing measured yet)"
},
head_start_db: prefix_db.map(|p| p - db).unwrap_or(0.0),
prefix_gain_db: prefix_db,
});
Some(db)
}
}
/// The hook every main-player track start funnels through. `play` is the
/// DEFERRED start of playback: a cache hit or disabled normalization fires
/// it immediately; an unmeasured track holds it for the preliminary
/// measurement window so the gain is right from the very first sample.
/// The analyzer then keeps running silently to the end of the media — only
/// to fill the cache, never to touch the volume of the play in progress.
pub fn on_track_started(
app: &tauri::AppHandle,
cache_path: &Path,
size: u64,
stream: Option<(PathBuf, Arc<StreamProgress>)>,
play: Box<dyn FnOnce() + Send>,
) {
let state = app.state::<LoudnessState>();
let generation = state.generation.fetch_add(1, Ordering::Relaxed) + 1;
if !state.enabled.load(Ordering::Relaxed) {
*state.current.lock().expect("loudness current lock") = None;
play();
return; // player resets to unity on Play
}
let key = cache_path.to_string_lossy().into_owned();
// Known file → play at once with the exact gain (the set lands behind
// Play on the ordered command channel, before any sink exists).
let known_size = if size > 0 {
size
} else {
std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0)
};
if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) {
if m.size == known_size || known_size == 0 {
let g = gain_db(m);
play();
app.state::<PlayerState>()
.player
.set_norm_gain_db(g as f32, true);
*state.current.lock().expect("loudness current lock") = Some(CurrentTrack {
lufs: Some(m.lufs),
peak: m.peak,
gain_db: g,
cached: true,
analyzing: false,
source: "whole-track measurement (cache)",
head_start_db: 0.0,
prefix_gain_db: None,
});
return;
}
}
*state.current.lock().expect("loudness current lock") = Some(CurrentTrack {
lufs: None,
peak: 0.0,
gain_db: 0.0,
cached: false,
analyzing: true,
source: "measuring",
head_start_db: 0.0,
prefix_gain_db: None,
});
// Unmeasured → the analyzer races ahead of playback (download/disk
// speed) and RELEASES the held play once the preliminary window is met.
// One request clock, shared by the gate's trace and the deadline's ceiling.
let requested_at = std::time::Instant::now();
let gate = Arc::new(Mutex::new(Gate {
play: Some(play),
heard: None,
heard_secs: 0.0,
requested_at,
progress: stream.as_ref().map(|(_, p)| p.clone()),
}));
// The other end of the race: the audible start is never held longer than
// the deadline, however little the analyzer has managed to hear.
//
// The clock starts at the FIRST BYTE of a stream, not at the play request.
// Anchoring it to the request measured the wrong thing: on a peer that
// takes a second to start sending, most of the 2.5 s was spent waiting for
// data that had not arrived, and the deadline then fired with nothing
// analyzed and started the track at unity — measured, three plays out of
// four on one run and zero out of three on the next, the only difference
// being how quickly the peers began.
//
// Firing early bought nothing in exchange. Playback is blocked on the same
// bytes — `GrowingFile::open` waits for the first one before the decoder
// can even open the file — so releasing the gate while the transfer is
// still silent does not make sound arrive sooner. It only discards the
// measurement.
//
// The case the deadline genuinely exists for still works: a track opening
// below the −70 LUFS gate (a fade-in, a pre-gap) never produces a first
// measurement, and this now bounds it from the moment data starts flowing.
let deadline_progress = stream.as_ref().map(|(_, p)| p.clone());
{
let app = app.clone();
let gate = gate.clone();
let gen_ctr = state.generation.clone();
std::thread::Builder::new()
.name("loudness-deadline".into())
.spawn(move || {
// Never past this, whatever the transfer does.
let ceiling = requested_at + PRELIM_ABSOLUTE_CEILING;
// A local file has every byte already; a stream starts its
// clock when the transfer does. Polled rather than waiting on
// the transfer's condvar, because that primitive is private to
// the player crate and this is a once-per-track thread where
// 25 ms of granularity costs nothing.
if let Some(progress) = deadline_progress {
loop {
let (received, done) = progress.received_done();
if received > 0 || done {
break;
}
if gen_ctr.load(Ordering::Relaxed) != generation {
return; // a newer track owns the player
}
// The peer has still sent nothing and the ceiling is
// up. Stop waiting and release on it: a gate that
// never opens is a track that never plays.
if std::time::Instant::now() >= ceiling {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
let now = std::time::Instant::now();
let until = std::cmp::min(now + PRELIM_HARD_DEADLINE, ceiling);
if let Some(wait) = until.checked_duration_since(now) {
std::thread::sleep(wait);
}
if gen_ctr.load(Ordering::Relaxed) == generation {
Gate::release(&gate, &app, "hard-deadline");
}
})
.expect("spawn loudness deadline");
}
let app = app.clone();
let gen_ctr = state.generation.clone();
std::thread::Builder::new()
.name("loudness-analysis".into())
.spawn(move || {
let source = match stream {
Some((partial, progress)) => AnalysisSource::Growing {
path: partial,
total: size,
progress,
},
None => AnalysisSource::Local(PathBuf::from(&key)),
};
let cancelled = || gen_ctr.load(Ordering::Relaxed) != generation;
let mut applied_db: Option<f64> = None;
{
let app = &app;
let cancelled = &cancelled;
let gate = &gate;
let mut first_measure: Option<std::time::Instant> = None;
// The mid-track correction's current target, ratcheted down.
let mut follow_db: Option<f64> = None;
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
if cancelled() {
return;
}
let m = Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
};
{
let mut g = gate.lock().expect("loudness gate lock");
if g.play.is_none() {
// The gate is open: this is the refinement path,
// followed from the first publish. See
// `following_target_db` for why there is no
// convergence test guarding it.
drop(g);
let next = following_target_db(follow_db, &m);
if follow_db != Some(next) {
if follow_db.is_none() {
loudness_trace(&format!(
"follow-start\tsecs={secs:.1}\tlufs={lufs:.1}\tgain={next:+.2}"
));
}
follow_db = Some(next);
app.state::<PlayerState>()
.player
.set_norm_gain_db(next as f32, false);
}
return;
}
g.heard = Some(m);
g.heard_secs = secs;
}
let first = *first_measure.get_or_insert_with(std::time::Instant::now);
if secs < PRELIM_MIN_SECS && first.elapsed() < PRELIM_WALL_BUDGET {
return; // keep listening a moment longer
}
// Which of the two coupled limits actually ended the wait.
// This is the number the whole question turns on: if it is
// always `min-secs`, the window can grow for free; if it is
// `wall-budget`, growing it needs the budget raised too and
// that is startup latency the listener pays.
let why = if secs >= PRELIM_MIN_SECS {
"min-secs"
} else {
"wall-budget"
};
applied_db = Gate::release(gate, app, why).or(applied_db);
};
let outcome = analyze_loudness(source, cancelled, &mut on_update);
// Gate never fired (clip shorter than a gating block, decode
// failure, dead transfer): start at unity — unless a newer
// track superseded this one, then never start it at all.
if !cancelled() {
applied_db = Gate::release(gate, app, "analysis-ended").or(applied_db);
}
// ONLY a measurement that reached the end of the media
// describes the track. Anything else describes however much
// of the opening happened to arrive, and caching it would
// hand a later play a boost earned by a quiet intro — the
// provisional cap above, but permanent and up to MAX_GAIN_DB.
let Analysis::Complete { lufs, peak } = outcome else {
if let Analysis::Incomplete(why) = outcome {
log::debug!("loudness: no measurement for {key}: {why:?}");
}
return;
};
store_measurement(app, &key, lufs, peak, size);
// The whole track is in, so the cut-only rule is retired: this
// measurement has earned a boost if it needs one. Still slewed,
// never stepped — `false` hands it to the audio thread's
// 0.25 dB/s glide. Guarded by `cancelled()` so a track that has
// already been superseded never touches the new one's volume.
if !cancelled() {
let m = Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
};
app.state::<PlayerState>()
.player
.set_norm_gain_db(gain_db(&m) as f32, false);
}
// The gap between what this play used and what every later
// play will use — i.e. exactly how far a stage-2 correction
// would have to travel, and how long it would take at a given
// dB/s. Negative means this play was LOUDER than it should be.
let final_db = normalization_gain_db(lufs, peak);
loudness_trace(&format!(
"final\tlufs={lufs:.1}\tgain={final_db:+.2}\tprelim={}\tcorrection={}",
applied_db
.map(|d| format!("{d:+.2}"))
.unwrap_or_else(|| "none".into()),
applied_db
.map(|d| format!("{:+.2}", final_db - d))
.unwrap_or_else(|| "n/a".into()),
));
if !cancelled() {
let state = app.state::<LoudnessState>();
let mut cur = state.current.lock().expect("loudness current lock");
// The gain in force is still the one committed at the
// start, head start and all, so carry that provenance
// rather than resetting it — the overlay would otherwise
// report the correction as gone while it is still applied.
let (head_start_db, prefix_gain_db) = cur
.as_ref()
.map(|c| (c.head_start_db, c.prefix_gain_db))
.unwrap_or((0.0, None));
*cur = Some(CurrentTrack {
lufs: Some(lufs),
peak,
gain_db: applied_db.unwrap_or(0.0),
cached: false,
analyzing: false,
// The gain in force is still whatever the prefix
// justified; this measurement is for the next play.
source: "prefix (cut only) · measured for next play",
head_start_db,
prefix_gain_db,
});
}
}
})
.expect("spawn loudness analysis");
}
/// Gapless normalization: called when the NEXT track's preload is complete
/// (or was already cached on disk). Hands the boundary gain to the audio
/// thread, which applies it sample-accurately when the gapless switch
/// happens — a track change is the one place loudness may step. Guarded by
/// the loudness generation: a user-initiated play supersedes any pending
/// preload analysis (the player also clears its pending gain on Play/Stop).
pub fn on_preload_ready(app: &tauri::AppHandle, cache_path: &Path) {
let state = app.state::<LoudnessState>();
if !state.enabled.load(Ordering::Relaxed) {
return;
}
let generation = state.generation.load(Ordering::Relaxed);
let key = cache_path.to_string_lossy().into_owned();
let known_size = std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0);
if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) {
if m.size == known_size || known_size == 0 {
app.state::<PlayerState>()
.player
.set_next_norm_gain_db(gain_db(m) as f32);
return;
}
}
let app = app.clone();
let gen_ctr = state.generation.clone();
std::thread::Builder::new()
.name("loudness-preload".into())
.spawn(move || {
// A preload is a finished file on disk, so this is a whole-track
// measurement or nothing — a truncated or corrupt download must
// not arm a boundary gain, let alone be cached as the track's.
let Analysis::Complete { lufs, peak } = analyze_loudness(
AnalysisSource::Local(PathBuf::from(&key)),
&|| false,
&mut |_, _, _| {},
) else {
return;
};
store_measurement(&app, &key, lufs, peak, 0);
// Only hand the boundary gain over if no new track started
// since — a stale preload must not color a different queue.
if gen_ctr.load(Ordering::Relaxed) == generation {
let m = Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
};
app.state::<PlayerState>()
.player
.set_next_norm_gain_db(gain_db(&m) as f32);
}
})
.expect("spawn preload loudness analysis");
}
#[cfg(test)]
mod tests {
use super::*;
fn m(lufs: f64, peak: f32) -> Measured {
Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
}
}
#[test]
fn quiet_tracks_boost_toward_target() {
assert!((gain_db(&m(-20.0, 0.3)) - 6.0).abs() < 1e-9);
}
#[test]
fn loud_tracks_cut_toward_target() {
assert!((gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9);
}
#[test]
fn boost_is_capped_by_peak_headroom() {
// Target wants +9 (clamped), but a 0.9 peak only has ~0.73 dB of
// headroom to the 0.98 ceiling.
let g = gain_db(&m(-30.0, 0.9));
assert!(g < 0.8 && g > 0.6, "got {g}");
}
#[test]
fn cuts_ignore_peak() {
assert!((gain_db(&m(-8.0, 0.2)) - (-6.0)).abs() < 1e-9);
}
#[test]
fn extreme_measurements_stay_clamped() {
assert_eq!(gain_db(&m(-60.0, 0.0001)), MAX_GAIN_DB);
assert_eq!(gain_db(&m(20.0, 1.0)), MIN_GAIN_DB);
}
/// The reported bug, as an assertion: a track that opens quietly and
/// swells asks the prefix policy for a large boost, and must get none.
#[test]
fn a_quiet_intro_never_earns_a_boost() {
// Measured off "01 - Ain't Got Nobody": the first 10 s read −27.4
// LUFS against a whole-track −6.0, the worst head in the corpus.
assert_eq!(prelim_gain_db(&m(-27.4, 0.31)), 0.0);
// And the merely-quiet openings either side of it.
assert_eq!(prelim_gain_db(&m(-20.0, 0.3)), 0.0);
assert_eq!(prelim_gain_db(&m(-14.1, 0.5)), 0.0);
}
/// The other half of the rule: a prefix may still turn a track DOWN, so
/// the first play of a brickwalled master is not left at full level.
#[test]
fn a_loud_intro_still_earns_its_cut() {
assert!((prelim_gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9);
// "03 Spread Eagle Cross the Block" — head and track both −3.4.
assert!((prelim_gain_db(&m(-3.4, 1.0)) - (-10.6)).abs() < 1e-9);
// A cut is bounded by the same floor a full measurement gets.
assert_eq!(prelim_gain_db(&m(20.0, 1.0)), MIN_GAIN_DB);
}
/// The head start comes off every prefix-derived start, including the
/// ones the cut-only rule already clamped to unity.
#[test]
fn a_prefix_start_takes_the_head_start_off() {
// A quiet opening earns no boost, so the prefix asks for 0 — and the
// head start is exactly what stops that first play sitting at unity
// when the track is about to swell.
assert!((prelim_start_gain_db(&m(-27.4, 0.31)) - (-0.75)).abs() < 1e-9);
// A loud opening earns its cut, and starts three quarters of a dB below it.
assert!((prelim_start_gain_db(&m(-8.0, 1.0)) - (-6.75)).abs() < 1e-9);
}
/// The head start may not push a start through the floor a full
/// measurement is held to.
#[test]
fn the_head_start_stops_at_the_gain_floor() {
assert_eq!(prelim_start_gain_db(&m(20.0, 1.0)), MIN_GAIN_DB);
}
/// It only ever makes a first play quieter — the direction the corpus says
/// the error runs.
#[test]
fn the_head_start_never_turns_a_track_up() {
for lufs in [-40.0, -27.4, -20.0, -14.0, -8.0, -3.4, 0.0, 20.0] {
for peak in [0.01f32, 0.3, 0.9, 1.0] {
let (start, prefix) =
(prelim_start_gain_db(&m(lufs, peak)), prelim_gain_db(&m(lufs, peak)));
assert!(start <= prefix + 1e-9, "{lufs} LUFS peak {peak}: {start} > {prefix}");
}
}
}
/// How wrong is a head of length N about the whole track?
///
/// The question behind `PRELIM_MIN_SECS`. A streamed first play is gained
/// from a PREFIX; every later play is gained from the whole track. The gap
/// between those two numbers is exactly the "first track is louder"
/// complaint, and this measures it per candidate window so the constant is
/// chosen from a curve instead of a guess.
///
/// Manual, and ignored in CI — it decodes an entire corpus. Point it at a
/// directory of real audio:
///
/// ```text
/// $env:SOULSTREAM_LOUDNESS_CORPUS = "D:\path\to\tracks"
/// cargo test -p app head_window_bias -- --ignored --nocapture
/// ```
///
/// Reported per window: how far the prefix gain lands from the true gain,
/// signed so POSITIVE means the first play is LOUDER than it should be.
#[test]
#[ignore]
fn head_window_bias_across_the_corpus() {
const WINDOWS: [f64; 6] = [10.0, 15.0, 20.0, 30.0, 45.0, 60.0];
let dir = std::env::var("SOULSTREAM_LOUDNESS_CORPUS")
.expect("set SOULSTREAM_LOUDNESS_CORPUS to a directory of audio files");
let mut files: Vec<PathBuf> = Vec::new();
let mut stack = vec![PathBuf::from(&dir)];
while let Some(at) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&at) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
{
files.push(path);
}
}
}
files.sort();
assert!(!files.is_empty(), "no audio found under {dir}");
eprintln!("corpus: {} files under {dir}\n", files.len());
// errs[i] = prefix gain − true gain, for every track that reached
// window i. Positive means the first play is louder than it should be.
let mut errs: Vec<Vec<f64>> = vec![Vec::new(); WINDOWS.len()];
let mut complete = 0usize;
for path in &files {
let mut heads: Vec<Option<(f64, f32)>> = vec![None; WINDOWS.len()];
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
for (i, window) in WINDOWS.iter().enumerate() {
if heads[i].is_none() && secs >= *window {
heads[i] = Some((lufs, peak));
}
}
};
let done = analyze_loudness(
AnalysisSource::Local(path.clone()),
&|| false,
&mut on_update,
);
let Analysis::Complete { lufs, peak } = done else {
eprintln!(" skipped (incomplete): {}", path.display());
continue;
};
complete += 1;
let truth = gain_db(&m(lufs, peak));
for (i, head) in heads.iter().enumerate() {
let Some((hl, hp)) = *head else { continue };
errs[i].push(prelim_gain_db(&m(hl, hp)) - truth);
}
}
assert!(complete > 0, "no track decoded to completion");
eprintln!("\n{complete} tracks measured end to end\n");
eprintln!(
"{:>7} {:>5} {:>8} {:>8} {:>8} {:>8} {:>10}",
"window", "n", "median", "mean", "p90", "worst", ">1dB loud"
);
for (i, window) in WINDOWS.iter().enumerate() {
let mut e = errs[i].clone();
if e.is_empty() {
eprintln!("{window:>6}s {:>5} (no track reached this window)", 0);
continue;
}
e.sort_by(|a, b| a.partial_cmp(b).expect("no NaN gains"));
let n = e.len();
let median = e[n / 2];
let mean = e.iter().sum::<f64>() / n as f64;
let p90 = e[(n * 9 / 10).min(n - 1)];
let worst = e[n - 1];
let loud = e.iter().filter(|v| **v > 1.0).count();
eprintln!(
"{window:>6}s {n:>5} {median:>8.2} {mean:>8.2} {p90:>8.2} {worst:>8.2} {loud:>10}"
);
}
eprintln!(
"\nPositive = the first (streamed) play is that many dB LOUDER than\n\
every later play of the same track. Pick PRELIM_MIN_SECS where the\n\
curve flattens, not where it is smallest."
);
}
/// Should the first play START further down than the prefix asks for?
///
/// The prefix estimator is BIASED, not merely noisy: a quiet opening
/// under-reads the track, so the head asks for too much gain far more often
/// than too little. A biased estimator with a known sign can be corrected
/// by subtracting that bias — the first play begins nearer the truth and
/// the slew has less ground to cover.
///
/// The cost is not symmetric with the benefit, which is the whole reason
/// this sweep exists rather than a chosen constant:
///
/// * Starting too LOUD is temporary. [`following_target_db`] ratchets down
/// as the running measurement arrives, so the error decays at the audio
/// thread's 0.25 dB/s.
/// * Starting too QUIET is PERMANENT for that play. The ratchet is
/// monotone-down and a real boost is only earned at `Analysis::Complete`,
/// so gain given away at the start is never handed back.
///
/// So the offset must be read against how many tracks it strands below
/// their true level, not just how much it improves the median.
///
/// The corpus is sampled BY ARTIST, because a pooled median over whatever
/// happens to be cached is not a measurement of the estimator — it is a
/// measurement of listening habits. Forty Weezer tracks and one Aphex Twin
/// track pool into "the bias is what Weezer's mastering does". Each artist
/// contributes at most `SOULSTREAM_LOUDNESS_PER_ARTIST` tracks, spread
/// evenly across their discography rather than taken from the front, and
/// the sweep is reported twice: pooled per-track, and as a median over
/// per-artist medians, which one loud discography cannot drag.
///
/// ```text
/// $env:SOULSTREAM_LOUDNESS_CORPUS = "$env:USERPROFILE\Music\Music"
/// $env:SOULSTREAM_LOUDNESS_PER_ARTIST = "6"
/// cargo test -p app head_start_offset -- --ignored --nocapture
/// ```
#[test]
#[ignore]
fn head_start_offset_sweep() {
const WINDOW: f64 = PRELIM_MIN_SECS;
const OFFSETS: [f64; 11] = [
0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0,
];
/// The audio thread's correction rate, for turning an error into the
/// "~Ns to go" the debug overlay shows.
const SLEW_DB_PER_SEC: f64 = 0.25;
let dir = std::env::var("SOULSTREAM_LOUDNESS_CORPUS")
.expect("set SOULSTREAM_LOUDNESS_CORPUS to a directory of audio files");
let per_artist: usize = std::env::var("SOULSTREAM_LOUDNESS_PER_ARTIST")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(6);
let root = PathBuf::from(&dir);
// Artist = the first directory under the root. A file sitting directly
// in the root is its own bucket, so a flat folder still works.
let mut by_artist: std::collections::BTreeMap<String, Vec<PathBuf>> =
std::collections::BTreeMap::new();
let mut stack = vec![root.clone()];
while let Some(at) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&at) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if !matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
{
continue;
}
let artist = path
.strip_prefix(&root)
.ok()
.and_then(|rel| rel.components().next().map(|c| c.as_os_str().to_owned()))
.and_then(|c| c.into_string().ok())
.filter(|_| path.parent() != Some(root.as_path()))
.unwrap_or_else(|| "(root)".to_string());
by_artist.entry(artist).or_default().push(path);
}
}
assert!(!by_artist.is_empty(), "no audio found under {dir}");
// Spread the picks across each artist's discography: taking the first
// N would take track 1 of one album every time, and track 1 is exactly
// where intros — the whole source of the bias — are least typical.
let mut picks: Vec<(String, PathBuf)> = Vec::new();
let total_available: usize = by_artist.values().map(|v| v.len()).sum();
for (artist, tracks) in &mut by_artist {
tracks.sort();
let take = per_artist.min(tracks.len());
for i in 0..take {
let idx = if take == 1 { 0 } else { i * (tracks.len() - 1) / (take - 1) };
picks.push((artist.clone(), tracks[idx].clone()));
}
}
eprintln!(
"corpus: {} artists, {total_available} tracks available, \
sampling <= {per_artist}/artist -> {} picks\n",
by_artist.len(),
picks.len()
);
// err0 = prefix gain − true gain, before any offset. Positive = starts loud.
let mut rows: Vec<(String, f64)> = Vec::new();
let mut skipped = 0usize;
for (artist, path) in &picks {
let mut head: Option<(f64, f32)> = None;
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
if head.is_none() && secs >= WINDOW {
head = Some((lufs, peak));
}
};
let done = analyze_loudness(
AnalysisSource::Local(path.clone()),
&|| false,
&mut on_update,
);
let Analysis::Complete { lufs, peak } = done else {
skipped += 1;
continue;
};
let Some((hl, hp)) = head else {
skipped += 1; // shorter than the window
continue;
};
rows.push((
artist.clone(),
prelim_gain_db(&m(hl, hp)) - gain_db(&m(lufs, peak)),
));
}
assert!(!rows.is_empty(), "no track decoded to completion");
let mut artists: std::collections::BTreeMap<String, Vec<f64>> =
std::collections::BTreeMap::new();
for (a, e) in &rows {
artists.entry(a.clone()).or_default().push(*e);
}
eprintln!(
"measured {} tracks across {} artists ({skipped} skipped: too short or undecodable)\n",
rows.len(),
artists.len()
);
let median_of = |v: &mut Vec<f64>| -> f64 {
v.sort_by(|a, b| a.partial_cmp(b).expect("no NaN gains"));
v[v.len() / 2]
};
eprintln!("== pooled over every sampled track ==");
eprintln!(
"{:>6} {:>7} {:>7} {:>9} {:>10} {:>11} {:>8} {:>8}",
"offset", "median", "mean|e|", ">1dB loud", ">1dB quiet", "worst quiet", "med ~s", "mean ~s"
);
for off in OFFSETS {
let mut errs: Vec<f64> = rows.iter().map(|(_, e0)| e0 - off).collect();
errs.sort_by(|a, b| a.partial_cmp(b).expect("no NaN gains"));
let n = errs.len();
let median = errs[n / 2];
let mean_abs = errs.iter().map(|e| e.abs()).sum::<f64>() / n as f64;
let loud = errs.iter().filter(|e| **e > 1.0).count();
let quiet = errs.iter().filter(|e| **e < -1.0).count();
let worst_quiet = errs[0].min(0.0);
let mut secs: Vec<f64> = errs
.iter()
.map(|e| if *e > 0.0 { e / SLEW_DB_PER_SEC } else { 0.0 })
.collect();
secs.sort_by(|a, b| a.partial_cmp(b).expect("no NaN"));
let med_s = secs[n / 2];
let mean_s = secs.iter().sum::<f64>() / n as f64;
eprintln!(
"{off:>5.1} {median:>7.2} {mean_abs:>7.2} {loud:>9} {quiet:>10} \
{worst_quiet:>11.2} {med_s:>8.1} {mean_s:>8.1}"
);
}
eprintln!("\n== one vote per artist (median of per-artist medians) ==");
eprintln!(
"{:>6} {:>7} {:>11} {:>12}",
"offset", "median", "artists loud", "artists quiet"
);
for off in OFFSETS {
let mut per: Vec<f64> = artists
.values()
.map(|es| {
let mut v: Vec<f64> = es.iter().map(|e| e - off).collect();
median_of(&mut v)
})
.collect();
let loud = per.iter().filter(|e| **e > 1.0).count();
let quiet = per.iter().filter(|e| **e < -1.0).count();
let median = median_of(&mut per);
eprintln!("{off:>5.1} {median:>7.2} {loud:>11} {quiet:>12}");
}
// Does one constant even fit? If the per-artist medians are spread far
// apart, no single offset is right for all of them and the rule needs a
// signal, not a number.
let mut per_artist_median: Vec<(String, f64)> = artists
.iter()
.map(|(a, es)| (a.clone(), median_of(&mut es.clone())))
.collect();
per_artist_median.sort_by(|a, b| a.1.partial_cmp(&b.1).expect("no NaN"));
let n = per_artist_median.len();
eprintln!(
"\nper-artist median err0 spread: p10 {:.2} p25 {:.2} p50 {:.2} p75 {:.2} p90 {:.2}",
per_artist_median[n / 10].1,
per_artist_median[n / 4].1,
per_artist_median[n / 2].1,
per_artist_median[n * 3 / 4].1,
per_artist_median[(n * 9 / 10).min(n - 1)].1,
);
eprintln!("\nquietest-starting artists (a head start hurts these most):");
for (a, e) in per_artist_median.iter().take(8) {
eprintln!(" {e:>7.2} {a}");
}
eprintln!("\nloudest-starting artists (a head start helps these most):");
for (a, e) in per_artist_median.iter().rev().take(8) {
eprintln!(" {e:>7.2} {a}");
}
eprintln!(
"\nPositive error = starts LOUDER than the truth: temporary, the ratchet\n\
walks it down at {SLEW_DB_PER_SEC} dB/s (the '~Ns to go' on the overlay).\n\
Negative error = starts QUIETER than the truth: PERMANENT for this play,\n\
because the ratchet is monotone-down. Read '>1dB quiet' as the cost."
);
}
/// Whatever the prefix policy allows, it can never exceed what the same
/// numbers would get from a whole-track measurement.
#[test]
fn a_prefix_is_never_louder_than_the_full_measurement() {
for lufs in [-40.0, -27.4, -20.0, -14.0, -8.0, -3.4, 0.0, 20.0] {
for peak in [0.01f32, 0.3, 0.9, 1.0] {
let (pre, full) = (prelim_gain_db(&m(lufs, peak)), gain_db(&m(lufs, peak)));
assert!(pre <= full + 1e-9, "{lufs} LUFS peak {peak}: {pre} > {full}");
assert!(pre <= 0.0 + 1e-9, "{lufs} LUFS peak {peak}: boosted {pre}");
}
}
}
}