forked from Keelrleek/SoulStream
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:
1 parent
3aaec30dc6
commit
b33d403634
9 files changed
+740
-323
No files matched your search
@@ -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
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user