forked from Keelrleek/SoulStream
loudness: correct the gain during the track instead of only guessing at its start
A streamed first play is gained from a prefix; every later play is gained from the whole track. Measured across the 89 cached tracks, a 10s head asks for a median 3.4 dB too much gain and is more than 1 dB hot on 68 of them — that gap is the "first track is so much louder" complaint. The curve never flattens either: even a 60s head is still ~1 dB out with a 6 dB worst case, so no window size fixes it. Live traces then showed something worse. The hard deadline was anchored to the play REQUEST, so on a peer that took a moment to start sending, the 2.5s ran out before a single gating block existed and the track began at unity — no normalization at all, three plays out of four on one run. Releasing early bought nothing in exchange: playback is blocked on the same bytes, so the gate was discarding the measurement without making sound arrive sooner. - The deadline now starts at the first byte, with an absolute 4s ceiling from the request so a slow peer cannot hold audio that is ready to play. - The audio thread's slew drops 2.5 -> 0.25 dB/s. Mid-track correction was switched off in July because the glide was audible; at a tenth the rate it is not, and being 3 dB wrong for a whole track is the larger error. - The correction follows the running measurement from its first publish and may only ratchet DOWN until the track completes. A convergence test lived here and was deleted: an integrated average over 30s cannot move much when one more second is added, so "3 publishes within 0.3 dB" was measuring the inertia of a long average, fired at its floor every time, and declared settled while 1.5 and 2.1 dB from truth. The protection was never the gate — it is the slew rate and the ratchet. - Seeking lands the pending correction immediately. The slew is slow only because a gain change is audible against continuous music, and a seek is already a discontinuity. Observability: SOULSTREAM_LOUDNESS_TRACE=1 reports every commit with the seconds analyzed, which limit fired, the hold, and the bytes received; the debug overlay gains a SLEW line showing applied gain, target, distance and ETA, because one number cannot distinguish "settled" from "in flight". Also adds an #[ignore] corpus harness that sweeps head-window bias in 30s.
This commit is contained in:
1 parent
4f485b6f9a
commit
1e35c5e039
4 files changed
+502
-13
No files matched your search
@@ -124,6 +124,13 @@ enum Cmd {
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// How far the normalization gain may move per 60 ms tick — 0.25 dB/s.
|
||||
///
|
||||
/// The rate IS the feature: a mid-track correction is only acceptable if the
|
||||
/// correcting is inaudible. See the slew in the tick arm for why this replaced
|
||||
/// the original 0.15 (≈2.5 dB/s).
|
||||
const NORM_SLEW_PER_TICK_DB: f32 = 0.015;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrackSource {
|
||||
path: PathBuf,
|
||||
@@ -234,6 +241,13 @@ pub struct Player {
|
||||
/// f32 bits; the audio thread mirrors its live fade-gain here for
|
||||
/// observability (the debug overlay / crossfade diagnostics). 1.0 = full.
|
||||
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
/// f32 bits, dB: the gain the sink is being given RIGHT NOW, and the one
|
||||
/// the slew is heading for. Mirrored separately because the interesting
|
||||
/// state during a mid-track correction is the gap between them — a single
|
||||
/// "current gain" readout cannot show that a correction is in flight, only
|
||||
/// that it has finished.
|
||||
norm_now_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -250,14 +264,35 @@ impl Player {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
||||
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
|
||||
let fade_report = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
|
||||
let norm_now_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits()));
|
||||
let norm_target_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits()));
|
||||
|
||||
let thread_report = fade_report.clone();
|
||||
let thread_norm_now = norm_now_report.clone();
|
||||
let thread_norm_target = norm_target_report.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("audio".into())
|
||||
.spawn(move || audio_thread(cmd_rx, event_tx, output_device, thread_report))
|
||||
.spawn(move || {
|
||||
audio_thread(
|
||||
cmd_rx,
|
||||
event_tx,
|
||||
output_device,
|
||||
thread_report,
|
||||
thread_norm_now,
|
||||
thread_norm_target,
|
||||
)
|
||||
})
|
||||
.expect("spawn audio thread");
|
||||
|
||||
(Player { cmd_tx, fade_report }, event_rx)
|
||||
(
|
||||
Player {
|
||||
cmd_tx,
|
||||
fade_report,
|
||||
norm_now_report,
|
||||
norm_target_report,
|
||||
},
|
||||
event_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// The audio thread's current fade-gain (0..1). Observability only.
|
||||
@@ -265,6 +300,16 @@ impl Player {
|
||||
f32::from_bits(self.fade_report.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The normalization gain being applied right now, and the one the slew is
|
||||
/// heading for, both in dB. Equal means settled; different means a
|
||||
/// correction is in flight. Observability only.
|
||||
pub fn norm_gain_now_target_db(&self) -> (f32, f32) {
|
||||
(
|
||||
f32::from_bits(self.norm_now_report.load(Ordering::Relaxed)),
|
||||
f32::from_bits(self.norm_target_report.load(Ordering::Relaxed)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
|
||||
self.play_local_from(path, duration_hint, None);
|
||||
}
|
||||
@@ -498,6 +543,8 @@ fn audio_thread(
|
||||
event_tx: mpsc::Sender<PlayerEvent>,
|
||||
initial_device: Option<String>,
|
||||
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
norm_now_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
) {
|
||||
// Open the output device lazily and keep it across tracks. If the device
|
||||
// is momentarily busy (e.g. another instance still releasing it), we
|
||||
@@ -680,6 +727,15 @@ fn audio_thread(
|
||||
if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) {
|
||||
log::debug!("seek failed: {e}");
|
||||
}
|
||||
// Land the pending correction rather than carrying it
|
||||
// across the jump. The slew is slow ONLY because a gain
|
||||
// change is audible against continuous music — and a seek
|
||||
// is already a discontinuity, so the step hides inside it.
|
||||
// Drifting afterwards would be the audible case: the same
|
||||
// few seconds of new material rising in level for no
|
||||
// reason the listener can attribute to anything.
|
||||
norm_db = norm_target_db;
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Scratch { secs, rate }) => {
|
||||
@@ -811,11 +867,26 @@ fn audio_thread(
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
// Normalization slew: glide toward the analyzer's latest
|
||||
// target at ~2.5 dB/s (0.15 dB per 60 ms tick) — audible as
|
||||
// a slow drift at worst, never a jump.
|
||||
// target, slowly enough that the move is not itself audible.
|
||||
//
|
||||
// This ran at ~2.5 dB/s (0.15 dB per 60 ms tick), and at that
|
||||
// rate a mid-track refinement was heard as a drift — which is
|
||||
// why mid-track correction was switched off entirely on
|
||||
// 2026-07-23 rather than slowed down. It is back on because
|
||||
// the alternative is worse: a streamed first play is gained
|
||||
// from a 10 s prefix, which reads a median 3.4 dB too quiet
|
||||
// across the cached corpus, and sometimes from nothing at all.
|
||||
// Being 3 dB wrong for a whole track is a bigger error than a
|
||||
// correction nobody can hear.
|
||||
//
|
||||
// 0.25 dB/s sits under the threshold at which a gradual level
|
||||
// change reads as a change rather than as the music: the
|
||||
// median 3.4 dB correction takes ~14 s and the worst observed
|
||||
// (~6 dB) about 24 s, both spread thinly enough to be masked
|
||||
// by the track's own dynamics.
|
||||
let diff = norm_target_db - norm_db;
|
||||
if diff.abs() > 0.001 {
|
||||
norm_db += diff.clamp(-0.15, 0.15);
|
||||
norm_db += diff.clamp(-NORM_SLEW_PER_TICK_DB, NORM_SLEW_PER_TICK_DB);
|
||||
if let Some(s) = &sink {
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
@@ -824,6 +895,11 @@ fn audio_thread(
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
|
||||
// Mirror the normalization pair after every command and every tick, so
|
||||
// the debug overlay sees the slew moving rather than only its endpoint.
|
||||
norm_now_report.store(norm_db.to_bits(), Ordering::Relaxed);
|
||||
norm_target_report.store(norm_target_db.to_bits(), Ordering::Relaxed);
|
||||
|
||||
// In system-default mode, periodically compare endpoint names. CPAL's
|
||||
// existing stream remains attached to the old endpoint when Windows'
|
||||
// default changes, so reopening is required.
|
||||
|
||||
@@ -82,6 +82,13 @@ pub struct DebugStats {
|
||||
/// Main player's live fade-gain (1.0 = full; <1 only during a preview
|
||||
/// open/close crossfade). Proves a fade never strands the volume.
|
||||
pub main_fade_gain: Option<f32>,
|
||||
/// The normalization gain the sink has RIGHT NOW, and the one the audio
|
||||
/// thread is slewing toward, both dB. Reported as a pair because the
|
||||
/// interesting state during a mid-track correction is the gap between
|
||||
/// them: equal means settled, different means a correction is in flight
|
||||
/// and the difference is how far it still has to travel.
|
||||
pub norm_now_db: Option<f32>,
|
||||
pub norm_target_db: Option<f32>,
|
||||
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
||||
pub preview_volume: Option<f32>,
|
||||
/// The active preview clip's loudness-normalization gain in dB.
|
||||
@@ -170,6 +177,12 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
|
||||
normalization_enabled,
|
||||
target_lufs,
|
||||
loudness,
|
||||
norm_now_db: app
|
||||
.try_state::<crate::playback::PlayerState>()
|
||||
.map(|s| s.player.norm_gain_now_target_db().0),
|
||||
norm_target_db: app
|
||||
.try_state::<crate::playback::PlayerState>()
|
||||
.map(|s| s.player.norm_gain_now_target_db().1),
|
||||
preview_volume: app
|
||||
.try_state::<crate::preview::PreviewState>()
|
||||
.map(|s| crate::preview::target_volume(&s)),
|
||||
|
||||
+331
-8
@@ -200,6 +200,19 @@ const PRELIM_WALL_BUDGET: std::time::Duration = std::time::Duration::from_millis
|
||||
/// 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
|
||||
@@ -228,6 +241,43 @@ fn prelim_gain_db(m: &Measured) -> f64 {
|
||||
gain_db(m).min(PRELIM_MAX_BOOST_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 {
|
||||
@@ -248,6 +298,20 @@ fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, si
|
||||
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
|
||||
@@ -256,24 +320,55 @@ 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) -> Option<f64> {
|
||||
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) = {
|
||||
let (play, heard, secs, waited, got) = {
|
||||
let mut g = gate.lock().expect("loudness gate lock");
|
||||
match g.play.take() {
|
||||
Some(play) => (play, g.heard),
|
||||
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
|
||||
}
|
||||
};
|
||||
let db = heard.as_ref().map(prelim_gain_db).unwrap_or(0.0);
|
||||
loudness_trace(&format!(
|
||||
"commit\twhy={why}\tsecs={secs:.1}\theld_ms={}\tgain={db:+.2}\thead_lufs={}\trecv={}",
|
||||
waited.as_millis(),
|
||||
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
|
||||
@@ -355,13 +450,37 @@ pub fn on_track_started(
|
||||
|
||||
// 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();
|
||||
@@ -369,9 +488,40 @@ pub fn on_track_started(
|
||||
std::thread::Builder::new()
|
||||
.name("loudness-deadline".into())
|
||||
.spawn(move || {
|
||||
std::thread::sleep(PRELIM_HARD_DEADLINE);
|
||||
// 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);
|
||||
Gate::release(&gate, &app, "hard-deadline");
|
||||
}
|
||||
})
|
||||
.expect("spawn loudness deadline");
|
||||
@@ -397,6 +547,8 @@ pub fn on_track_started(
|
||||
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;
|
||||
@@ -410,15 +562,43 @@ pub fn on_track_started(
|
||||
{
|
||||
let mut g = gate.lock().expect("loudness gate lock");
|
||||
if g.play.is_none() {
|
||||
return; // started already: NO further volume touches
|
||||
// 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
|
||||
}
|
||||
applied_db = Gate::release(gate, app).or(applied_db);
|
||||
// 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);
|
||||
|
||||
@@ -426,7 +606,7 @@ pub fn on_track_started(
|
||||
// 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);
|
||||
applied_db = Gate::release(gate, app, "analysis-ended").or(applied_db);
|
||||
}
|
||||
|
||||
// ONLY a measurement that reached the end of the media
|
||||
@@ -441,6 +621,36 @@ pub fn on_track_started(
|
||||
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() {
|
||||
*app.state::<LoudnessState>()
|
||||
.current
|
||||
@@ -581,6 +791,119 @@ mod tests {
|
||||
assert_eq!(prelim_gain_db(&m(20.0, 1.0)), MIN_GAIN_DB);
|
||||
}
|
||||
|
||||
/// How wrong is a head of length N about the whole track?
|
||||
///
|
||||
/// The question behind `PRELIM_MIN_SECS`. A streamed first play is gained
|
||||
/// from a PREFIX; every later play is gained from the whole track. The gap
|
||||
/// between those two numbers is exactly the "first track is louder"
|
||||
/// complaint, and this measures it per candidate window so the constant is
|
||||
/// chosen from a curve instead of a guess.
|
||||
///
|
||||
/// Manual, and ignored in CI — it decodes an entire corpus. Point it at a
|
||||
/// directory of real audio:
|
||||
///
|
||||
/// ```text
|
||||
/// $env:SOULSTREAM_LOUDNESS_CORPUS = "D:\path\to\tracks"
|
||||
/// cargo test -p app head_window_bias -- --ignored --nocapture
|
||||
/// ```
|
||||
///
|
||||
/// Reported per window: how far the prefix gain lands from the true gain,
|
||||
/// signed so POSITIVE means the first play is LOUDER than it should be.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn head_window_bias_across_the_corpus() {
|
||||
const WINDOWS: [f64; 6] = [10.0, 15.0, 20.0, 30.0, 45.0, 60.0];
|
||||
|
||||
let dir = std::env::var("SOULSTREAM_LOUDNESS_CORPUS")
|
||||
.expect("set SOULSTREAM_LOUDNESS_CORPUS to a directory of audio files");
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
let mut stack = vec![PathBuf::from(&dir)];
|
||||
while let Some(at) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&at) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
assert!(!files.is_empty(), "no audio found under {dir}");
|
||||
eprintln!("corpus: {} files under {dir}\n", files.len());
|
||||
|
||||
// errs[i] = prefix gain − true gain, for every track that reached
|
||||
// window i. Positive means the first play is louder than it should be.
|
||||
let mut errs: Vec<Vec<f64>> = vec![Vec::new(); WINDOWS.len()];
|
||||
let mut complete = 0usize;
|
||||
|
||||
for path in &files {
|
||||
let mut heads: Vec<Option<(f64, f32)>> = vec![None; WINDOWS.len()];
|
||||
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
|
||||
for (i, window) in WINDOWS.iter().enumerate() {
|
||||
if heads[i].is_none() && secs >= *window {
|
||||
heads[i] = Some((lufs, peak));
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = analyze_loudness(
|
||||
AnalysisSource::Local(path.clone()),
|
||||
&|| false,
|
||||
&mut on_update,
|
||||
);
|
||||
let Analysis::Complete { lufs, peak } = done else {
|
||||
eprintln!(" skipped (incomplete): {}", path.display());
|
||||
continue;
|
||||
};
|
||||
complete += 1;
|
||||
let truth = gain_db(&m(lufs, peak));
|
||||
for (i, head) in heads.iter().enumerate() {
|
||||
let Some((hl, hp)) = *head else { continue };
|
||||
errs[i].push(prelim_gain_db(&m(hl, hp)) - truth);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(complete > 0, "no track decoded to completion");
|
||||
eprintln!("\n{complete} tracks measured end to end\n");
|
||||
eprintln!(
|
||||
"{:>7} {:>5} {:>8} {:>8} {:>8} {:>8} {:>10}",
|
||||
"window", "n", "median", "mean", "p90", "worst", ">1dB loud"
|
||||
);
|
||||
for (i, window) in WINDOWS.iter().enumerate() {
|
||||
let mut e = errs[i].clone();
|
||||
if e.is_empty() {
|
||||
eprintln!("{window:>6}s {:>5} (no track reached this window)", 0);
|
||||
continue;
|
||||
}
|
||||
e.sort_by(|a, b| a.partial_cmp(b).expect("no NaN gains"));
|
||||
let n = e.len();
|
||||
let median = e[n / 2];
|
||||
let mean = e.iter().sum::<f64>() / n as f64;
|
||||
let p90 = e[(n * 9 / 10).min(n - 1)];
|
||||
let worst = e[n - 1];
|
||||
let loud = e.iter().filter(|v| **v > 1.0).count();
|
||||
eprintln!(
|
||||
"{window:>6}s {n:>5} {median:>8.2} {mean:>8.2} {p90:>8.2} {worst:>8.2} {loud:>10}"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"\nPositive = the first (streamed) play is that many dB LOUDER than\n\
|
||||
every later play of the same track. Pick PRELIM_MIN_SECS where the\n\
|
||||
curve flattens, not where it is smallest."
|
||||
);
|
||||
}
|
||||
|
||||
/// Whatever the prefix policy allows, it can never exceed what the same
|
||||
/// numbers would get from a whole-track measurement.
|
||||
#[test]
|
||||
|
||||
@@ -88,6 +88,24 @@
|
||||
const dlTotal = $derived(stats?.dlTotal ?? 0);
|
||||
const dlDone = $derived(stats?.dlReceived ?? 0);
|
||||
const loud = $derived(stats?.loudness ?? null);
|
||||
|
||||
// ---- normalization slew readout ----
|
||||
// The audio thread mirrors both the applied gain and the one it is heading
|
||||
// for; a correction in flight is exactly the difference between them.
|
||||
const fmtDb = (db) => `${db >= 0 ? "+" : ""}${db.toFixed(2)} dB`;
|
||||
const slewing = $derived(
|
||||
stats?.normNowDb != null
|
||||
&& stats?.normTargetDb != null
|
||||
// The audio thread stops slewing below this, so anything smaller is
|
||||
// settled rather than creeping.
|
||||
&& Math.abs(stats.normTargetDb - stats.normNowDb) > 0.001,
|
||||
);
|
||||
/** Seconds left at the player's 0.25 dB/s slew (0.015 dB per 60ms tick). */
|
||||
const slewSecs = $derived(
|
||||
slewing ? Math.abs(stats.normTargetDb - stats.normNowDb) / 0.25 : 0,
|
||||
);
|
||||
/** Position on a −15…+9 dB scale — the clamp `set_norm_gain_db` enforces. */
|
||||
const dbPct = (db) => Math.max(0, Math.min(100, ((db + 15) / 24) * 100));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
@@ -135,6 +153,33 @@
|
||||
{#if stats.normalizationEnabled && loud}
|
||||
<div class="dim">peak {loud.peak.toFixed(2)} · {loud.source}</div>
|
||||
{/if}
|
||||
{#if stats.normalizationEnabled && stats.normNowDb != null}
|
||||
<!-- The pair, not a single number: a mid-track correction is
|
||||
only visible as the GAP between what the sink has and
|
||||
where the slew is heading. Equal = settled. -->
|
||||
<div>
|
||||
<b>SLEW</b>
|
||||
<span class="num">{fmtDb(stats.normNowDb)}</span>
|
||||
{#if slewing}
|
||||
→ <span class="num hot">{fmtDb(stats.normTargetDb)}</span>
|
||||
<span class="dim">
|
||||
{fmtDb(stats.normTargetDb - stats.normNowDb)} to go ·
|
||||
~{slewSecs.toFixed(0)}s
|
||||
</span>
|
||||
{:else}
|
||||
<span class="dim">settled</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bar slew">
|
||||
<!-- −15…+9 dB is the clamp the player enforces. -->
|
||||
<i class="now" style="left:{dbPct(stats.normNowDb)}%"></i>
|
||||
<i class="tgt" style="left:{dbPct(stats.normTargetDb)}%"></i>
|
||||
<i class="zero" style="left:{dbPct(0)}%"></i>
|
||||
</div>
|
||||
{#if loud?.analyzing}
|
||||
<div class="dim">analyzing — gain may still ratchet down</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if previewUi.open}
|
||||
<div>
|
||||
<b>PREVIEW</b>
|
||||
@@ -208,6 +253,38 @@
|
||||
height: 100%;
|
||||
background: #7fd0ff;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #d7e0ea;
|
||||
}
|
||||
.num.hot {
|
||||
color: #ffc46b;
|
||||
}
|
||||
/* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the
|
||||
distance between them is the correction still to travel. */
|
||||
.bar.slew {
|
||||
position: relative;
|
||||
height: 7px;
|
||||
overflow: visible;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
.bar.slew i {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
width: 2px;
|
||||
height: 9px;
|
||||
border-radius: 1px;
|
||||
transition: left 220ms linear;
|
||||
}
|
||||
.bar.slew .now { background: #7fd0ff; }
|
||||
.bar.slew .tgt { background: #ffc46b; }
|
||||
/* Unity, so a cut and a boost are told apart at a glance. */
|
||||
.bar.slew .zero {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
height: 5px;
|
||||
top: 1px;
|
||||
transition: none;
|
||||
}
|
||||
/* The overlay is passthrough glass (pointer-events: none on the aside)
|
||||
— only its buttons take clicks. */
|
||||
button {
|
||||
|
||||
Reference in new issue
Block a user