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
+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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user