diff --git a/src-tauri/src/loudness.rs b/src-tauri/src/loudness.rs index 7abc648..dbbe2e8 100644 --- a/src-tauri/src/loudness.rs +++ b/src-tauri/src/loudness.rs @@ -71,6 +71,14 @@ pub struct CurrentTrack { /// 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, } pub struct LoudnessState { @@ -241,6 +249,56 @@ 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. @@ -358,10 +416,19 @@ impl Gate { None => return None, // someone else already started it } }; - let db = heard.as_ref().map(prelim_gain_db).unwrap_or(0.0); + // 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}\thead_lufs={}\trecv={}", + "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)) @@ -387,6 +454,8 @@ impl Gate { } 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) } @@ -435,6 +504,8 @@ pub fn on_track_started( cached: true, analyzing: false, source: "whole-track measurement (cache)", + head_start_db: 0.0, + prefix_gain_db: None, }); return; } @@ -446,6 +517,8 @@ pub fn on_track_started( cached: false, analyzing: true, source: "measuring", + head_start_db: 0.0, + prefix_gain_db: None, }); // Unmeasured → the analyzer races ahead of playback (download/disk @@ -652,10 +725,17 @@ pub fn on_track_started( .unwrap_or_else(|| "n/a".into()), )); if !cancelled() { - *app.state::() - .current - .lock() - .expect("loudness current lock") = Some(CurrentTrack { + let state = app.state::(); + 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), @@ -664,6 +744,8 @@ pub fn on_track_started( // 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, }); } } @@ -791,6 +873,38 @@ mod tests { 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 @@ -904,6 +1018,241 @@ mod tests { ); } + /// 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> = + 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> = + 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 { + 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 = 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::() / 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 = 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::() / 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 = artists + .values() + .map(|es| { + let mut v: Vec = 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] diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 9055dfb..912aa51 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -106,6 +106,25 @@ ); /** Position on a −15…+9 dB scale — the clamp `set_norm_gain_db` enforces. */ const dbPct = (db) => Math.max(0, Math.min(100, ((db + 15) / 24) * 100)); + /** + * The head start is only meaningful while a prefix-derived gain is the one + * in force, and only visible as the gap between what the prefix asked for + * and where the track actually started — so both ends are required before + * it is drawn at all. A stale or absent value must read as "not applied" + * rather than as zero decibels of correction. + * + * `analyzing` is the third condition and not an optional one. At + * `Analysis::Complete` the backend retires the cut-only rule and slews to + * the whole-track gain, so the head start stops being what the player is + * doing — while `loudness.gainDb` still reports the prefix value, being + * kept for the NEXT play. Without this the line goes on claiming a + * correction that has already been released. + */ + const headStart = $derived( + loud?.headStartDb > 0 && loud?.prefixGainDb != null && loud?.analyzing + ? { db: loud.headStartDb, from: loud.prefixGainDb } + : null, + ); @@ -170,8 +189,28 @@ settled {/if} + {#if headStart} + +
+ head start −{headStart.db.toFixed(2)} dB + · prefix asked {fmtDb(headStart.from)} → started + {fmtDb(headStart.from - headStart.db)} +
+ {/if}
+ {#if headStart} + + {/if} @@ -278,6 +317,14 @@ } .bar.slew .now { background: #7fd0ff; } .bar.slew .tgt { background: #ffc46b; } + /* Where the prefix alone would have started: a ghost, because it is the + counterfactual rather than a gain anything is heading for. The distance + from here to .now IS the head start. */ + .bar.slew .prefix { + background: rgba(255, 255, 255, 0.28); + height: 11px; + top: -2px; + } /* Unity, so a cut and a boost are told apart at a glance. */ .bar.slew .zero { background: rgba(255, 255, 255, 0.3);