player bar: the name sets the width, and the waveform stops flattering the clamp

The source chips are gone — the rail already tells that story — and with
them the measured slot, its effects, and the bar's only container query.
The now-playing cluster hugs the song name with no cap, giving ground only
once the scrubber hits its 260px floor; the artist's 55% ceiling applies
only when an album sits beside it, so a lone loading artist no longer
renders as two letters in a twelve-pixel box.

The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms
per bucket — at 400, every display bar was ~2s of audio averaged twice),
and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp
rule, which flat-topped 53.8% of the bars on a real decode and was propped
up by per-bar noise in the rail — deleted; the lane shows the record, not a
hash of the bar index. Bars fold with a mean+peak blend and stretch across
the track's own p05–p97, so a loud master stops pinning to the ceiling and
a quiet one stops vanishing.

The playbar copies the rail's fade verbatim: whole bars blend accent→grey
with cuts landing in the 4px gap, and the one bar under the playhead
crossfades toward whatever its neighbours are — the wash while hovering,
the muted grey otherwise. The redraw gate watches that blend instead of a
0.75px playhead move, so both lanes now step on every position event.

Loading is drawn, not implied: the solid fallback bar is gone and the lane
is always the bars, always seekable. Empty, it rests as the rail's dots; a
swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a
streaming track's bars stand up left→right on a 40ms cadence, each rising
over 2s. The rise frontier is the waveform's OWN coverage, reported by
stream_waveform beside the peaks — the old contract zero-padded partial
peaks to full length and let the UI fold them against a fresher byte
count, which stretched the whole shape between refreshes. Where the bars
end is how much of the track has actually loaded; cached and local tracks
arrive at coverage 1 and pop in complete. The first partial is also
requested eagerly (fresh refresh clock per track, 700ms retries until
peaks exist), closing most of the audio-before-lane gap.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:08:26 -05:00
1 parent 3aaec30dc6
commit b33d403634
9 files changed
+740 -323

No files matched your search

+2
View File
@@ -12,3 +12,5 @@ log.workspace = true
[dev-dependencies]
env_logger = "0.11"
# Test-only: dumps bucketing variants for the waveform shaping mockup.
serde_json.workspace = true
+89 -1
View File
@@ -32,6 +32,15 @@ pub fn compute_waveform(path: &Path, buckets: usize) -> Vec<f32> {
}
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
Ok(resample(&coarse_rms(path)?, buckets))
}
/// Decode `path` into one RMS value per [`WINDOW`] frames (~23ms at 44.1kHz).
///
/// Split out from [`try_compute`] so the bucketing strategy can be measured
/// against a real decode without re-reading the file per variant — see
/// `bucketing_strategies_on_a_real_track`.
fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -111,7 +120,7 @@ fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
coarse.push((win_energy / win_count as f64).sqrt() as f32);
}
Ok(resample(&coarse, buckets))
Ok(coarse)
}
/// Downsample `coarse` RMS values to exactly `buckets`, averaging each
@@ -152,4 +161,83 @@ mod tests {
fn empty_input() {
assert_eq!(resample(&[], 5), vec![0.0; 5]);
}
/// How much of the track's dynamics survive the trip to the UI?
///
/// The lane looked flat no matter what the frontend did with the numbers,
/// so this measures where the range actually goes. It decodes a real file
/// once and re-buckets that single RMS pass four ways, printing the spread
/// of each and dumping them as JSON for the shaping mockup.
///
/// ```text
/// $env:SOULSTREAM_WF_FILE = "C:\path\to\track.flac"
/// $env:SOULSTREAM_WF_DUMP = "C:\path\to\out.json"
/// cargo test -p player bucketing_strategies -- --ignored --nocapture
/// ```
#[test]
#[ignore = "decodes a real audio file named by SOULSTREAM_WF_FILE"]
fn bucketing_strategies_on_a_real_track() {
let Ok(file) = std::env::var("SOULSTREAM_WF_FILE") else {
panic!("set SOULSTREAM_WF_FILE to an audio file");
};
let coarse = coarse_rms(Path::new(&file)).expect("decode");
println!(
"{} RMS windows of {WINDOW} frames (~{:.1}s of audio)",
coarse.len(),
coarse.len() as f32 * WINDOW as f32 / 44100.0,
);
// Each strategy takes the same spans; only the statistic differs.
fn fold(coarse: &[f32], buckets: usize, pick: impl Fn(&[f32]) -> f32) -> Vec<f32> {
let mut out = vec![0f32; buckets];
for (b, slot) in out.iter_mut().enumerate() {
let start = b * coarse.len() / buckets;
let end = (((b + 1) * coarse.len() / buckets).max(start + 1)).min(coarse.len());
*slot = pick(&coarse[start..end]);
}
let max = out.iter().fold(0f32, |m, &v| m.max(v));
if max > 0.0 {
for v in out.iter_mut() {
*v /= max;
}
}
out
}
let mean = |s: &[f32]| s.iter().sum::<f32>() / s.len() as f32;
let peak = |s: &[f32]| s.iter().fold(0f32, |m, &v| m.max(v));
let blend = move |s: &[f32]| mean(s) * 0.35 + peak(s) * 0.65;
let mut dump = serde_json::Map::new();
for (buckets, label) in [(400usize, "400"), (2000usize, "2000")] {
for (name, values) in [
("mean", fold(&coarse, buckets, mean)),
("peak", fold(&coarse, buckets, peak)),
("blend", fold(&coarse, buckets, blend)),
] {
let mut sorted = values.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let q = |f: f32| sorted[((f * (sorted.len() - 1) as f32) as usize).min(sorted.len() - 1)];
let avg = values.iter().sum::<f32>() / values.len() as f32;
let sd = (values.iter().map(|v| (v - avg).powi(2)).sum::<f32>()
/ values.len() as f32)
.sqrt();
println!(
"{label:>5} {name:<6} p05 {:.3} p50 {:.3} p95 {:.3} IQR {:.3} sigma {:.3}",
q(0.05),
q(0.50),
q(0.95),
q(0.75) - q(0.25),
sd,
);
dump.insert(
format!("{name}{label}"),
serde_json::json!(values.iter().map(|v| (v * 10000.0).round() / 10000.0).collect::<Vec<_>>()),
);
}
}
if let Ok(out) = std::env::var("SOULSTREAM_WF_DUMP") {
std::fs::write(&out, serde_json::to_string(&dump).unwrap()).expect("write dump");
println!("wrote {out}");
}
}
}