1276 lines
57 KiB
Rust
1276 lines
57 KiB
Rust
//! 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,
|
||
)
|
||
}
|
||
|
||
/// Keyed by track path, so it describes THIS library's files and belongs
|
||
/// beside them. Measuring a whole library again on another machine is hours
|
||
/// of work that the drive was already carrying the answer to.
|
||
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||
let cfg = crate::config::load(app);
|
||
crate::config::ensure_library_data_dir(app, &cfg)
|
||
.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();
|
||
// Measuring means decoding, so this is the PLAYABLE table, not
|
||
// the recognised one — an `.opus` or `.wv` here only produces a
|
||
// failed decode (or, for WavPack, NaN).
|
||
if audio_formats::is_playable_ext(&ext) {
|
||
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 !audio_formats::is_playable_ext(&ext) {
|
||
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}");
|
||
}
|
||
}
|
||
}
|
||
}
|