Files
SoulStream/crates/player/src/loudness.rs
T
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00

419 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integrated-loudness measurement (ITU-R BS.1770-4 / EBU R128), the basis
//! of the player's volume normalization: every track is measured in LUFS
//! and played back with a gain that brings it to one target loudness, so a
//! quiet 90s master and a brickwalled modern one come out level.
//!
//! Dependency-free implementation of the standard: per-channel K-weighting
//! (a high-shelf that models head diffraction, then a high-pass), energy
//! collected in 400 ms blocks at 75 % overlap, and two-stage gating
//! (absolute −70 LUFS, then relative −10 LU below the ungated mean). The
//! meter is INCREMENTAL — `push` accepts interleaved frames as they are
//! decoded, and `integrated_lufs()` can be read at any point for the
//! loudness of everything seen so far — which is what lets a Soulseek
//! stream be normalized during its very first play instead of after it.
/// A biquad in direct form 1, processing one channel.
#[derive(Clone, Copy, Default)]
struct Biquad {
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
x1: f64,
x2: f64,
y1: f64,
y2: f64,
}
impl Biquad {
#[inline]
fn process(&mut self, x: f64) -> f64 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
}
/// Stage-1 K-weighting shelf (BS.1770 spherical-head model), designed for
/// an arbitrary sample rate with the reference parameters the standard's
/// 48 kHz table was derived from (the libebur128 derivation).
fn shelf(fs: f64) -> Biquad {
let f0 = 1681.974450955533;
let gain_db = 3.999843853973347;
let q = 0.7071752369554196;
let k = (std::f64::consts::PI * f0 / fs).tan();
let vh = 10f64.powf(gain_db / 20.0);
let vb = vh.powf(0.4996667741545416);
let a0 = 1.0 + k / q + k * k;
Biquad {
b0: (vh + vb * k / q + k * k) / a0,
b1: 2.0 * (k * k - vh) / a0,
b2: (vh - vb * k / q + k * k) / a0,
a1: 2.0 * (k * k - 1.0) / a0,
a2: (1.0 - k / q + k * k) / a0,
..Default::default()
}
}
/// Stage-2 K-weighting high-pass (rumble removal below ~38 Hz).
fn highpass(fs: f64) -> Biquad {
let f0 = 38.13547087602444;
let q = 0.5003270373238773;
let k = (std::f64::consts::PI * f0 / fs).tan();
let a0 = 1.0 + k / q + k * k;
Biquad {
b0: 1.0,
b1: -2.0,
b2: 1.0,
a1: 2.0 * (k * k - 1.0) / a0,
a2: (1.0 - k / q + k * k) / a0,
..Default::default()
}
}
const MAX_CHANNELS: usize = 8;
/// Gating block = 400 ms as 4 overlapping 100 ms sub-blocks (75 % overlap).
const SUBBLOCKS_PER_BLOCK: usize = 4;
const ABSOLUTE_GATE_LUFS: f64 = -70.0;
pub struct LoudnessMeter {
sample_rate: u32,
channels: usize,
shelves: [Biquad; MAX_CHANNELS],
highpasses: [Biquad; MAX_CHANNELS],
/// Samples per 100 ms sub-block (per channel).
subblock_len: usize,
/// Running energy sum of the current sub-block (all channels, weighted).
cur_energy: f64,
cur_count: usize,
/// Completed 100 ms sub-block mean energies.
subblocks: Vec<f64>,
/// Pre-filter sample peak, for clipping headroom when boosting.
peak: f32,
}
impl LoudnessMeter {
pub fn new(sample_rate: u32, channels: usize) -> Self {
let fs = sample_rate.max(1) as f64;
let ch = channels.clamp(1, MAX_CHANNELS);
LoudnessMeter {
sample_rate: sample_rate.max(1),
channels: ch,
shelves: [shelf(fs); MAX_CHANNELS],
highpasses: [highpass(fs); MAX_CHANNELS],
subblock_len: (fs / 10.0).round().max(1.0) as usize,
cur_energy: 0.0,
cur_count: 0,
subblocks: Vec::new(),
peak: 0.0,
}
}
/// Re-arm the filters for a new spec mid-stream (rare); accumulated
/// blocks are kept — they are already energies, spec-independent.
pub fn rearm(&mut self, sample_rate: u32, channels: usize) {
if sample_rate == self.sample_rate && channels.clamp(1, MAX_CHANNELS) == self.channels {
return;
}
let keep = std::mem::take(&mut self.subblocks);
*self = LoudnessMeter::new(sample_rate, channels);
self.subblocks = keep;
}
/// Feed interleaved f32 frames.
pub fn push(&mut self, interleaved: &[f32]) {
let ch = self.channels;
for frame in interleaved.chunks_exact(ch) {
let mut frame_energy = 0.0f64;
for (c, &s) in frame.iter().enumerate() {
self.peak = self.peak.max(s.abs());
let w = self.shelves[c].process(s as f64);
let k = self.highpasses[c].process(w);
// Channel weights: 1.0 (stereo/mono; surround rears would be
// 1.41 — practically all content here is stereo).
frame_energy += k * k;
}
self.cur_energy += frame_energy;
self.cur_count += 1;
if self.cur_count >= self.subblock_len {
self.subblocks.push(self.cur_energy / self.cur_count as f64);
self.cur_energy = 0.0;
self.cur_count = 0;
}
}
}
/// Seconds of audio consumed so far.
pub fn seconds_seen(&self) -> f64 {
(self.subblocks.len() as f64) * 0.1 + self.cur_count as f64 / self.sample_rate as f64
}
pub fn sample_peak(&self) -> f32 {
self.peak
}
/// Gated integrated loudness of everything pushed so far, or None while
/// there is not yet one full 400 ms block above the absolute gate.
pub fn integrated_lufs(&self) -> Option<f64> {
if self.subblocks.len() < SUBBLOCKS_PER_BLOCK {
return None;
}
let block_energy = |i: usize| -> f64 {
self.subblocks[i..i + SUBBLOCKS_PER_BLOCK]
.iter()
.sum::<f64>()
/ SUBBLOCKS_PER_BLOCK as f64
};
let lufs = |e: f64| -0.691 + 10.0 * e.max(f64::MIN_POSITIVE).log10();
let n_blocks = self.subblocks.len() - SUBBLOCKS_PER_BLOCK + 1;
// Absolute gate.
let mut sum = 0.0;
let mut count = 0usize;
for i in 0..n_blocks {
let e = block_energy(i);
if lufs(e) > ABSOLUTE_GATE_LUFS {
sum += e;
count += 1;
}
}
if count == 0 {
return None;
}
// Relative gate: −10 LU below the absolute-gated mean.
let relative_gate = lufs(sum / count as f64) - 10.0;
let mut sum2 = 0.0;
let mut count2 = 0usize;
for i in 0..n_blocks {
let e = block_energy(i);
let l = lufs(e);
if l > ABSOLUTE_GATE_LUFS && l > relative_gate {
sum2 += e;
count2 += 1;
}
}
if count2 == 0 {
return None;
}
Some(lufs(sum2 / count2 as f64))
}
}
// ------------------------------------------------------------ analyzer ----
use std::path::PathBuf;
use std::sync::Arc;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use crate::source::{GrowingFile, StreamProgress};
/// What the loudness analyzer reads. `Growing` is the streaming case: the
/// same blocking growing-file reader playback uses, so analysis proceeds at
/// download speed — normally far ahead of the play cursor — and the FIRST
/// play of a stream converges on its true loudness within seconds.
pub enum AnalysisSource {
Local(PathBuf),
Growing {
path: PathBuf,
total: u64,
progress: Arc<StreamProgress>,
},
}
/// How often (in analyzed audio time) partial measurements are published.
/// 1 s keeps the play-gate responsive — the caller decides when it has
/// heard enough to commit a pre-playback gain.
const PUBLISH_EVERY_SECS: f64 = 1.0;
/// Decode `source` from the top and measure it. `on_update` receives
/// (partial LUFS, peak, seconds analyzed) roughly every 5 s of audio;
/// returns the final (integrated LUFS, peak) at EOF, or None on decode
/// failure / cancellation / too little audio. Blocking — run on a worker
/// thread. An aborted stream unblocks the reader and lands here as EOF.
pub fn analyze_loudness(
source: AnalysisSource,
cancelled: &dyn Fn() -> bool,
on_update: &mut dyn FnMut(f64, f32, f64),
) -> Option<(f64, f32)> {
let (mss, path) = match source {
AnalysisSource::Local(path) => {
let file = std::fs::File::open(&path).ok()?;
(
MediaSourceStream::new(Box::new(file), Default::default()),
path,
)
}
AnalysisSource::Growing {
path,
total,
progress,
} => {
let growing = GrowingFile::open(&path, total, progress).ok()?;
(
MediaSourceStream::new(Box::new(ReadOnlySource::new(growing)), Default::default()),
path,
)
}
};
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
// Stream partials are "<name>.<ext>.partial" — hint the codec ext.
let ext = if ext.eq_ignore_ascii_case("partial") {
path.file_stem()
.map(std::path::Path::new)
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or(ext)
} else {
ext
};
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.ok()?;
let mut format = probed.format;
let track = format.default_track()?;
let track_id = track.id;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.ok()?;
let mut sample_buf: Option<SampleBuffer<f32>> = None;
let mut meter: Option<LoudnessMeter> = None;
let mut last_published = 0.0f64;
loop {
if cancelled() {
return None;
}
let packet = match format.next_packet() {
Ok(p) => p,
Err(_) => break, // EOF (or aborted stream) → finish with what we have
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(_) => break,
};
let spec = *decoded.spec();
let channels = spec.channels.count().max(1);
match &mut meter {
None => meter = Some(LoudnessMeter::new(spec.rate, channels)),
Some(m) => m.rearm(spec.rate, channels),
}
if sample_buf.is_none() {
sample_buf = Some(SampleBuffer::new(decoded.capacity() as u64, spec));
}
let sb = sample_buf.as_mut().unwrap();
sb.copy_interleaved_ref(decoded);
let m = meter.as_mut().unwrap();
m.push(sb.samples());
let secs = m.seconds_seen();
if secs - last_published >= PUBLISH_EVERY_SECS {
if let Some(lufs) = m.integrated_lufs() {
on_update(lufs, m.sample_peak(), secs);
}
last_published = secs;
}
}
let m = meter?;
m.integrated_lufs().map(|l| (l, m.sample_peak()))
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(freq: f64, fs: u32, secs: f64, amplitude: f64, channels: usize) -> Vec<f32> {
let frames = (fs as f64 * secs) as usize;
let mut out = Vec::with_capacity(frames * channels);
for n in 0..frames {
let s = (amplitude
* (2.0 * std::f64::consts::PI * freq * n as f64 / fs as f64).sin())
as f32;
for _ in 0..channels {
out.push(s);
}
}
out
}
/// BS.1770's own reference point: a full-scale 997 Hz sine reads
/// −3.01 LUFS (per channel of a stereo pair it sums +3 dB, so use mono).
#[test]
fn full_scale_sine_reads_reference_level() {
let mut m = LoudnessMeter::new(48_000, 1);
m.push(&sine(997.0, 48_000, 5.0, 1.0, 1));
let l = m.integrated_lufs().expect("enough audio");
assert!((l - (-3.01)).abs() < 0.1, "got {l}");
}
/// −20 dB quieter input must read 20 LU lower — the meter is linear in
/// level, which is what makes gain = target − measured valid.
#[test]
fn level_linearity() {
let mut loud = LoudnessMeter::new(44_100, 2);
loud.push(&sine(997.0, 44_100, 4.0, 0.5, 2));
let mut quiet = LoudnessMeter::new(44_100, 2);
quiet.push(&sine(997.0, 44_100, 4.0, 0.05, 2));
let dl = loud.integrated_lufs().unwrap();
let dq = quiet.integrated_lufs().unwrap();
assert!(((dl - dq) - 20.0).abs() < 0.2, "loud {dl} quiet {dq}");
}
/// Silence padding must not drag the integrated value down — that is
/// the absolute gate doing its job.
#[test]
fn silence_is_gated_out() {
let mut padded = LoudnessMeter::new(48_000, 1);
padded.push(&sine(997.0, 48_000, 3.0, 0.25, 1));
padded.push(&vec![0.0f32; 48_000 * 3]);
let mut bare = LoudnessMeter::new(48_000, 1);
bare.push(&sine(997.0, 48_000, 3.0, 0.25, 1));
let lp = padded.integrated_lufs().unwrap();
let lb = bare.integrated_lufs().unwrap();
assert!((lp - lb).abs() < 0.3, "padded {lp} vs bare {lb}");
}
#[test]
fn peak_tracks_prefilter_samples() {
let mut m = LoudnessMeter::new(48_000, 2);
m.push(&sine(440.0, 48_000, 1.0, 0.8, 2));
assert!((m.sample_peak() - 0.8).abs() < 0.01);
}
#[test]
fn too_little_audio_reads_none() {
let mut m = LoudnessMeter::new(48_000, 2);
m.push(&sine(997.0, 48_000, 0.2, 0.5, 2));
assert!(m.integrated_lufs().is_none());
}
}