forked from Keelrleek/SoulStream
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:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user