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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user