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.
This commit is contained in:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

+12 -3
View File
@@ -55,7 +55,10 @@ fn main() {
p.play_local(&file, Some(30.0));
let evs = collect(&rx, 2500);
assert!(saw_playing(&evs), "A: track should start");
let ack = p.pause_acked().recv_timeout(Duration::from_millis(1000)).expect("A: ack");
let ack = p
.pause_acked()
.recv_timeout(Duration::from_millis(1000))
.expect("A: ack");
let pos_at_ack = last_pos(&collect(&rx, 200)).unwrap_or(0.0);
let after = collect(&rx, 800);
let pos_later = last_pos(&after).unwrap_or(pos_at_ack);
@@ -79,7 +82,10 @@ fn main() {
let (p2, rx2) = Player::spawn();
p2.play_local(&file, Some(30.0));
// no waiting: the decoder thread is still setting up
let ack2 = p2.pause_acked().recv_timeout(Duration::from_millis(1000)).expect("B: ack");
let ack2 = p2
.pause_acked()
.recv_timeout(Duration::from_millis(1000))
.expect("B: ack");
let evs = collect(&rx2, 2500);
let became_audible = saw_playing(&evs);
let came_up_paused = evs.iter().any(|e| matches!(e, PlayerEvent::Paused));
@@ -87,7 +93,10 @@ fn main() {
"PROBE B ack-while-buffering: latched={} sink_was_active={} came_up_paused={} became_audible={}",
ack2.latched, ack2.sink_was_active, came_up_paused, became_audible
);
assert!(ack2.latched && !became_audible, "B failed: latch leaked audio");
assert!(
ack2.latched && !became_audible,
"B failed: latch leaked audio"
);
p2.stop();
drop(p2);
let _ = collect(&rx2, 400);
+7 -2
View File
@@ -65,7 +65,10 @@ fn main() {
drain("preview", &prev_rx, 2500, &mut log);
let started = log.iter().any(|l| l.contains("Playing"));
println!("PROBE preview-starts: {started} ({} ms to first events)", t0.elapsed().as_millis());
println!(
"PROBE preview-starts: {started} ({} ms to first events)",
t0.elapsed().as_millis()
);
prev.pause();
drain("preview", &prev_rx, 600, &mut log);
@@ -78,7 +81,9 @@ fn main() {
if let Some((_main, main_rx, ref path)) = main_pair {
let mut main_log = Vec::new();
drain("main", &main_rx, 1200, &mut main_log);
let main_alive = main_log.iter().any(|l| l.contains("Playing") || l.contains("Position"));
let main_alive = main_log
.iter()
.any(|l| l.contains("Playing") || l.contains("Position"));
println!("PROBE main-coexists: {main_alive} ({path})");
// cross-talk check: no preview-channel event may appear on main's
// channel or vice versa — separate receivers make this structural,
+115 -8
View File
@@ -6,10 +6,12 @@
//! short-lived helper threads, tagged with a generation counter so stale
//! decodes are discarded when the user has already moved on.
mod loudness;
mod source;
mod waveform;
pub use source::StreamProgress;
pub use loudness::{analyze_loudness, AnalysisSource, LoudnessMeter};
pub use source::{GrowingFile, StreamProgress};
pub use waveform::compute_waveform;
use std::collections::VecDeque;
@@ -22,7 +24,6 @@ use std::time::{Duration, Instant};
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
use source::GrowingFile;
/// Acknowledgement for [`Player::pause_acked`], sent from the audio thread
/// AFTER the pause has been applied. Semantics, stated honestly:
@@ -69,6 +70,10 @@ enum Cmd {
stream: Option<Arc<StreamProgress>>,
total_bytes: u64,
duration_hint: Option<f64>,
/// Start playback at this offset (seconds): the decoder comes up
/// already positioned (same mechanism as the device-change resume),
/// so there is no play-then-seek race and no audible blip at 0.
start_at_secs: Option<f64>,
},
/// Decode and append the next local track before the active one ends.
Preload {
@@ -94,6 +99,20 @@ enum Cmd {
secs: f64,
},
Volume(f32),
/// Loudness-normalization gain in dB, layered UNDER the user volume
/// (effective sink volume = user volume × 10^(dB/20)). `immediate`
/// jumps (pre-playback, from the loudness cache); otherwise the audio
/// thread slews toward it so a mid-track refinement is a gentle drift,
/// never a step.
SetNormGainDb {
db: f32,
immediate: bool,
},
/// Normalization gain for the NEXT gapless-queued track, applied
/// sample-accurately when the boundary passes — a track change is
/// exactly where loudness may step. Cleared by Play/Stop; an unknown
/// next track falls back to unity at the boundary.
SetNextNormGainDb(f32),
SetOutputDevice {
device: Option<String>,
reply: mpsc::Sender<Result<(), String>>,
@@ -233,11 +252,23 @@ impl Player {
}
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
self.play_local_from(path, duration_hint, None);
}
/// `play_local`, but the decoder comes up at `start_at_secs` — used by
/// the preview session's resume memory. `None`/`0` is a plain start.
pub fn play_local_from(
&self,
path: impl Into<PathBuf>,
duration_hint: Option<f64>,
start_at_secs: Option<f64>,
) {
let _ = self.cmd_tx.send(Cmd::Play {
path: path.into(),
stream: None,
total_bytes: 0,
duration_hint,
start_at_secs: start_at_secs.filter(|s| *s > 0.0),
});
}
@@ -264,6 +295,7 @@ impl Player {
stream: Some(progress),
total_bytes,
duration_hint,
start_at_secs: None,
});
}
@@ -301,6 +333,25 @@ impl Player {
let _ = self.cmd_tx.send(Cmd::Volume(volume.clamp(0.0, 2.0)));
}
/// Loudness-normalization gain (dB), independent of the user volume.
/// `immediate` applies it as a jump — correct before audio starts (a
/// cached measurement); pass false while sounding and the audio thread
/// glides there instead. Reset to 0 dB by every new `Play`.
pub fn set_norm_gain_db(&self, db: f32, immediate: bool) {
let _ = self.cmd_tx.send(Cmd::SetNormGainDb {
db: db.clamp(-18.0, 9.0),
immediate,
});
}
/// Normalization gain for the next GAPLESS-queued track; the audio
/// thread applies it exactly at the boundary. See [`Cmd::SetNextNormGainDb`].
pub fn set_next_norm_gain_db(&self, db: f32) {
let _ = self
.cmd_tx
.send(Cmd::SetNextNormGainDb(db.clamp(-18.0, 9.0)));
}
/// Change devices immediately. The audio thread acknowledges only after
/// the endpoint has opened successfully, so callers can safely persist it.
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
@@ -446,6 +497,15 @@ fn audio_thread(
let mut generation: u64 = 0;
let mut duration: Option<f64> = None;
let mut volume: f32 = 0.7;
// Loudness-normalization gain, layered under the user volume. `norm_db`
// is what the sink currently gets; `norm_target_db` is where the slew in
// the tick arm is heading (~2.5 dB/s — a refinement mid-track drifts,
// it never steps). Every new Play resets both to unity.
let mut norm_db: f32 = 0.0;
let mut norm_target_db: f32 = 0.0;
// Gain waiting for the next gapless boundary (None → unity there).
let mut pending_norm_db: Option<f32> = None;
let eff = |user: f32, ndb: f32| user * 10f32.powf(ndb / 20.0);
let mut playing = false;
// The active stream source, so we can abort its blocking reads.
let mut active_stream: Option<Arc<StreamProgress>> = None;
@@ -486,6 +546,7 @@ fn audio_thread(
stream,
total_bytes,
duration_hint,
start_at_secs,
}) => {
// Abort whatever was playing or buffering.
generation += 1;
@@ -501,6 +562,12 @@ fn audio_thread(
playing = false;
scratching = false;
desired_paused = false; // a new Play is explicit listen intent
// A new track's loudness is unknown until measured — start at
// unity; a cached measurement arrives as an immediate
// SetNormGainDb right behind this command, before any sink.
norm_db = 0.0;
norm_target_db = 0.0;
pending_norm_db = None;
let track = TrackSource {
path,
stream,
@@ -513,7 +580,14 @@ fn audio_thread(
active_stream = track.stream.clone();
active_track = Some(track.clone());
start_decoder(track, generation, false, None, false, decoder_tx.clone());
start_decoder(
track,
generation,
false,
start_at_secs.map(Duration::from_secs_f64),
false,
decoder_tx.clone(),
);
}
Ok(Cmd::Preload {
path,
@@ -598,7 +672,7 @@ fn audio_thread(
match decode_scratch_window(path, secs, rate) {
Ok((samples, speed)) => {
let s = Sink::connect_new(out.mixer());
s.set_volume(volume);
s.set_volume(eff(volume, norm_db));
s.set_speed(speed);
s.append(samples);
s.play();
@@ -626,7 +700,7 @@ fn audio_thread(
match local_decoder(path) {
Ok(decoder) => {
let s = Sink::connect_new(out.mixer());
s.set_volume(volume);
s.set_volume(eff(volume, norm_db));
queued_sources.store(1, Ordering::SeqCst);
s.append(EndNotifier {
inner: decoder,
@@ -657,9 +731,22 @@ fn audio_thread(
Ok(Cmd::Volume(v)) => {
volume = v;
if let Some(s) = &sink {
s.set_volume(v);
s.set_volume(eff(volume, norm_db));
}
}
Ok(Cmd::SetNormGainDb { db, immediate }) => {
norm_target_db = db;
if immediate {
norm_db = db;
if let Some(s) = &sink {
s.set_volume(eff(volume, norm_db));
}
}
// Non-immediate: the tick arm slews norm_db there.
}
Ok(Cmd::SetNextNormGainDb(db)) => {
pending_norm_db = Some(db);
}
Ok(Cmd::SetOutputDevice { device, reply }) => {
output_change = Some((device, Some(reply)));
}
@@ -674,13 +761,25 @@ fn audio_thread(
queued_sources.store(0, Ordering::SeqCst);
waiting_preload = None;
pending_tracks.clear();
pending_norm_db = None;
playing = false;
scratching = false;
local_path = None;
active_track = None;
emit(PlayerEvent::Stopped);
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Timeout) => {
// Normalization slew: glide toward the analyzer's latest
// target at ~2.5 dB/s (0.15 dB per 60 ms tick) — audible as
// a slow drift at worst, never a jump.
let diff = norm_target_db - norm_db;
if diff.abs() > 0.001 {
norm_db += diff.clamp(-0.15, 0.15);
if let Some(s) = &sink {
s.set_volume(eff(volume, norm_db));
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
@@ -783,7 +882,7 @@ fn audio_thread(
let track_duration = ready.track_duration();
if !is_preload {
let s = Sink::connect_new(out.mixer());
s.set_volume(volume);
s.set_volume(eff(volume, norm_db));
sink = Some(s);
active_track = Some(ready.track.clone());
active_stream = ready.track.stream.clone();
@@ -864,6 +963,14 @@ fn audio_thread(
active_track = Some(next.track);
}
}
// Gapless boundary = a track change: apply the next track's
// normalization gain (unity when unmeasured) as a step HERE —
// never mid-track.
norm_db = pending_norm_db.take().unwrap_or(0.0);
norm_target_db = norm_db;
if let Some(s) = &sink {
s.set_volume(eff(volume, norm_db));
}
}
last_queued = q;
+418
View File
@@ -0,0 +1,418 @@
//! 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());
}
}
+6
View File
@@ -42,6 +42,12 @@ impl StreamProgress {
self.cv.notify_all();
}
/// Current (received bytes, done) — diagnostics/observability readout.
pub fn received_done(&self) -> (u64, bool) {
let st = self.state.lock().unwrap();
(st.received, st.done)
}
/// Unblock any waiting reads permanently (track switched / stopped).
pub fn abort(&self) {
let mut st = self.state.lock().unwrap();