loudness: a first play starts three quarters of a decibel under the prefix
The prefix estimator is BIASED, not merely noisy, and the sign is known: a quiet opening under-reads the track, so the head asks for too much gain far more often than too little. Subtracting a constant moves the whole distribution toward the truth, so a first play begins nearer its real level and the correction that follows has less ground to cover. Measured by a new `head_start_offset_sweep` over 1046 tracks sampled across 182 artists — at most six per artist, spread across each discography rather than taken from the front, since openers are where intros are least typical and intros are the whole source of the bias. The cached corpus it replaces was a dozen artists and put the median at +3.48 dB. Sampled diversely it is +1.55, so the old number was overstating the bias by more than double. The size comes from the MARGINAL trade, not the smallest mean error: 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. mean|err| bottoms out at 1.5 dB instead and is the wrong objective: it weights an error that begins decaying at once the same as one that waits for Analysis::Complete, which on the streamed first plays this path exists for is download-bound and therefore late. A constant is deliberately a weak lever. The per-artist median error runs from -4.58 dB (Boards Of Canada) to +7.03 dB (Ponytail); no single number fits everyone, and an offset shifts that spread without narrowing it. The levers that would are letting the follow recover UPWARD, and predicting the offset per track from the prefix. Both still open. The overlay reports it as a MOVE — what the prefix asked for, where the track actually started, and a ghost tick on the dB scale at the prefix position, so the gap to the applied gain IS the head start. Gated on `analyzing`: at Analysis::Complete the player releases to the whole-track gain while loudness.gainDb goes on reporting the prefix value, which it keeps for the next play. Without that condition the line advertised a correction that had already been released.
This commit is contained in:
1 parent
4179247203
commit
52f5878297
2 files changed
+402
-6
No files matched your search
+355
-6
@@ -71,6 +71,14 @@ pub struct CurrentTrack {
|
||||
/// 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 {
|
||||
@@ -241,6 +249,56 @@ 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.
|
||||
@@ -358,10 +416,19 @@ impl Gate {
|
||||
None => return None, // someone else already started it
|
||||
}
|
||||
};
|
||||
let db = heard.as_ref().map(prelim_gain_db).unwrap_or(0.0);
|
||||
// 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}\thead_lufs={}\trecv={}",
|
||||
"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))
|
||||
@@ -387,6 +454,8 @@ impl Gate {
|
||||
} 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)
|
||||
}
|
||||
@@ -435,6 +504,8 @@ pub fn on_track_started(
|
||||
cached: true,
|
||||
analyzing: false,
|
||||
source: "whole-track measurement (cache)",
|
||||
head_start_db: 0.0,
|
||||
prefix_gain_db: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -446,6 +517,8 @@ pub fn on_track_started(
|
||||
cached: false,
|
||||
analyzing: true,
|
||||
source: "measuring",
|
||||
head_start_db: 0.0,
|
||||
prefix_gain_db: None,
|
||||
});
|
||||
|
||||
// Unmeasured → the analyzer races ahead of playback (download/disk
|
||||
@@ -652,10 +725,17 @@ pub fn on_track_started(
|
||||
.unwrap_or_else(|| "n/a".into()),
|
||||
));
|
||||
if !cancelled() {
|
||||
*app.state::<LoudnessState>()
|
||||
.current
|
||||
.lock()
|
||||
.expect("loudness current lock") = Some(CurrentTrack {
|
||||
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),
|
||||
@@ -664,6 +744,8 @@ pub fn on_track_started(
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -791,6 +873,38 @@ mod tests {
|
||||
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
|
||||
@@ -904,6 +1018,241 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 !matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let artist = path
|
||||
.strip_prefix(&root)
|
||||
.ok()
|
||||
.and_then(|rel| rel.components().next().map(|c| c.as_os_str().to_owned()))
|
||||
.and_then(|c| c.into_string().ok())
|
||||
.filter(|_| path.parent() != Some(root.as_path()))
|
||||
.unwrap_or_else(|| "(root)".to_string());
|
||||
by_artist.entry(artist).or_default().push(path);
|
||||
}
|
||||
}
|
||||
assert!(!by_artist.is_empty(), "no audio found under {dir}");
|
||||
|
||||
// Spread the picks across each artist's discography: taking the first
|
||||
// N would take track 1 of one album every time, and track 1 is exactly
|
||||
// where intros — the whole source of the bias — are least typical.
|
||||
let mut picks: Vec<(String, PathBuf)> = Vec::new();
|
||||
let total_available: usize = by_artist.values().map(|v| v.len()).sum();
|
||||
for (artist, tracks) in &mut by_artist {
|
||||
tracks.sort();
|
||||
let take = per_artist.min(tracks.len());
|
||||
for i in 0..take {
|
||||
let idx = if take == 1 { 0 } else { i * (tracks.len() - 1) / (take - 1) };
|
||||
picks.push((artist.clone(), tracks[idx].clone()));
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"corpus: {} artists, {total_available} tracks available, \
|
||||
sampling <= {per_artist}/artist -> {} picks\n",
|
||||
by_artist.len(),
|
||||
picks.len()
|
||||
);
|
||||
|
||||
// err0 = prefix gain − true gain, before any offset. Positive = starts loud.
|
||||
let mut rows: Vec<(String, f64)> = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
for (artist, path) in &picks {
|
||||
let mut head: Option<(f64, f32)> = None;
|
||||
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
|
||||
if head.is_none() && secs >= WINDOW {
|
||||
head = Some((lufs, peak));
|
||||
}
|
||||
};
|
||||
let done = analyze_loudness(
|
||||
AnalysisSource::Local(path.clone()),
|
||||
&|| false,
|
||||
&mut on_update,
|
||||
);
|
||||
let Analysis::Complete { lufs, peak } = done else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let Some((hl, hp)) = head else {
|
||||
skipped += 1; // shorter than the window
|
||||
continue;
|
||||
};
|
||||
rows.push((
|
||||
artist.clone(),
|
||||
prelim_gain_db(&m(hl, hp)) - gain_db(&m(lufs, peak)),
|
||||
));
|
||||
}
|
||||
assert!(!rows.is_empty(), "no track decoded to completion");
|
||||
|
||||
let mut artists: std::collections::BTreeMap<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]
|
||||
|
||||
@@ -106,6 +106,25 @@
|
||||
);
|
||||
/** 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));
|
||||
/**
|
||||
* The head start is only meaningful while a prefix-derived gain is the one
|
||||
* in force, and only visible as the gap between what the prefix asked for
|
||||
* and where the track actually started — so both ends are required before
|
||||
* it is drawn at all. A stale or absent value must read as "not applied"
|
||||
* rather than as zero decibels of correction.
|
||||
*
|
||||
* `analyzing` is the third condition and not an optional one. At
|
||||
* `Analysis::Complete` the backend retires the cut-only rule and slews to
|
||||
* the whole-track gain, so the head start stops being what the player is
|
||||
* doing — while `loudness.gainDb` still reports the prefix value, being
|
||||
* kept for the NEXT play. Without this the line goes on claiming a
|
||||
* correction that has already been released.
|
||||
*/
|
||||
const headStart = $derived(
|
||||
loud?.headStartDb > 0 && loud?.prefixGainDb != null && loud?.analyzing
|
||||
? { db: loud.headStartDb, from: loud.prefixGainDb }
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
@@ -170,8 +189,28 @@
|
||||
<span class="dim">settled</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if headStart}
|
||||
<!-- The correction as a MOVE: where the prefix alone
|
||||
would have started this play, and where it actually
|
||||
did. A single number cannot show a bias correction
|
||||
working. -->
|
||||
<div class="dim">
|
||||
head start <span class="num hot"
|
||||
>−{headStart.db.toFixed(2)} dB</span
|
||||
>
|
||||
· prefix asked {fmtDb(headStart.from)} → started
|
||||
{fmtDb(headStart.from - headStart.db)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bar slew">
|
||||
<!-- −15…+9 dB is the clamp the player enforces. -->
|
||||
{#if headStart}
|
||||
<i
|
||||
class="prefix"
|
||||
style="left:{dbPct(headStart.from)}%"
|
||||
title="where the prefix alone would have started"
|
||||
></i>
|
||||
{/if}
|
||||
<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>
|
||||
@@ -278,6 +317,14 @@
|
||||
}
|
||||
.bar.slew .now { background: #7fd0ff; }
|
||||
.bar.slew .tgt { background: #ffc46b; }
|
||||
/* Where the prefix alone would have started: a ghost, because it is the
|
||||
counterfactual rather than a gain anything is heading for. The distance
|
||||
from here to .now IS the head start. */
|
||||
.bar.slew .prefix {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
height: 11px;
|
||||
top: -2px;
|
||||
}
|
||||
/* Unity, so a cut and a boost are told apart at a glance. */
|
||||
.bar.slew .zero {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
|
||||
Reference in new issue
Block a user