//! 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, } 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); /// 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) } 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); } /// 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, } 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) -> 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) = { let mut g = gate.lock().expect("loudness gate lock"); match g.play.take() { Some(play) => (play, g.heard), None => return None, // someone else already started it } }; let db = heard.as_ref().map(prelim_gain_db).unwrap_or(0.0); 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)" }, }); 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)", }); 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", }); // Unmeasured → the analyzer races ahead of playback (download/disk // speed) and RELEASES the held play once the preliminary window is met. let gate = Arc::new(Mutex::new(Gate { play: Some(play), heard: None, })); // The other end of the race: the audible start is never held longer than // the deadline, however little the analyzer has managed to hear. { let app = app.clone(); let gate = gate.clone(); let gen_ctr = state.generation.clone(); std::thread::Builder::new() .name("loudness-deadline".into()) .spawn(move || { std::thread::sleep(PRELIM_HARD_DEADLINE); if gen_ctr.load(Ordering::Relaxed) == generation { Gate::release(&gate, &app); } }) .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; 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() { return; // started already: NO further volume touches } g.heard = Some(m); } 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 } applied_db = Gate::release(gate, app).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).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); if !cancelled() { *app.state::() .current .lock() .expect("loudness current lock") = 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", }); } } }) .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); } /// 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}"); } } } }