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

+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;