//! 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, 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, } pub struct LoudnessState { cache: Mutex>, /// Bumped on every track start; orphans older analyzer threads. generation: Arc, enabled: AtomicBool, /// Debug-overlay mirror of the active track's measurement state. current: Mutex>, } /// (normalization enabled, target LUFS, active-track state) for diagnostics. pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option) { let state = app.state::(); let current = *state.current.lock().expect("loudness current lock"); ( state.enabled.load(Ordering::Relaxed), TARGET_LUFS, current, ) } fn cache_file(app: &tauri::AppHandle) -> Option { app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE)) } pub fn init(app: &tauri::AppHandle) { let cache: HashMap = 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::(); 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::().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::(); 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, 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::(); 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 = 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>, /// Most recent prefix measurement, or None if nothing has gated yet. heard: Option, /// 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>, } 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, app: &tauri::AppHandle, why: &str) -> Option { // 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::() .player .set_norm_gain_db(db as f32, true); *app.state::() .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)>, play: Box, ) { let state = app.state::(); 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::() .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 = None; { let app = &app; let cancelled = &cancelled; let gate = &gate; let mut first_measure: Option = None; // The mid-track correction's current target, ratcheted down. let mut follow_db: Option = 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::() .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::() .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::(); 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::(); 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::() .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::() .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 = 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![Vec::new(); WINDOWS.len()]; let mut complete = 0usize; for path in &files { let mut heads: Vec> = 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::() / 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> = 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] 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}"); } } } }