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

+79 -9
View File
@@ -75,6 +75,7 @@ pub struct Settings {
pub stream_cache_max_mb: u64,
pub stream_source_prefs: config::SourcePrefs,
pub download_source_prefs: config::SourcePrefs,
pub volume_normalization: bool,
}
#[tauri::command]
@@ -112,6 +113,7 @@ pub async fn get_settings(
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
stream_source_prefs: cfg.stream_source_prefs,
download_source_prefs: cfg.download_source_prefs,
volume_normalization: config::volume_normalization(&cfg),
username: cfg.username,
})
}
@@ -138,6 +140,7 @@ pub async fn set_settings(
stream_cache_max_mb: Option<u64>,
stream_source_prefs: Option<config::SourcePrefs>,
download_source_prefs: Option<config::SourcePrefs>,
volume_normalization: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
if let Some(d) = download_dir {
@@ -197,6 +200,12 @@ pub async fn set_settings(
if stream_cache_max_mb.is_some() {
cfg.stream_cache_max_mb = stream_cache_max_mb;
}
if let Some(enabled) = volume_normalization {
cfg.volume_normalization = Some(enabled);
// Applies live: off glides the current track back to unity; on
// takes effect from the next track.
crate::loudness::set_enabled(&app, enabled);
}
config::save(&app, &cfg);
// Tighter limits take effect right away, not at the next stream/startup.
@@ -538,6 +547,12 @@ pub async fn connect_and_login(
}),
);
if matches!(&update.state, DS::Complete { .. }) {
// Gapless normalization: measure the next
// track now so its gain is ready at the
// boundary.
if let Some(c) = &cache {
crate::loudness::on_preload_ready(&event_app, c);
}
// Only explicit saves reach the library —
// unless streamed tracks are configured to
// be kept forever.
@@ -3272,7 +3287,20 @@ pub async fn play_stream(
if let Ok(md) = std::fs::metadata(&cache) {
if size > 0 && md.len() == size {
playback::touch(&cache);
players.player.play_local(&cache, duration);
crate::diagnostics::clear_stream(&app);
let app2 = app.clone();
let cache2 = cache.clone();
crate::loudness::on_track_started(
&app,
&cache,
size,
None,
Box::new(move || {
app2.state::<PlayerState>()
.player
.play_local(&cache2, duration);
}),
);
return Ok(());
}
let _ = std::fs::remove_file(&cache);
@@ -3300,9 +3328,21 @@ pub async fn play_stream(
.lock()
.unwrap()
.insert(id, cache.clone());
players
.player
.play_stream(&partial, progress, size, duration);
crate::diagnostics::note_stream(&app, size, progress.clone());
let app2 = app.clone();
let partial2 = partial.clone();
let progress2 = progress.clone();
crate::loudness::on_track_started(
&app,
&cache,
size,
Some((partial, progress)),
Box::new(move || {
app2.state::<PlayerState>()
.player
.play_stream(&partial2, progress2, size, duration);
}),
);
return Ok(());
}
@@ -3321,9 +3361,21 @@ pub async fn play_stream(
.unwrap()
.insert(id, cache.clone());
players
.player
.play_stream(&partial, progress, size, duration);
crate::diagnostics::note_stream(&app, size, progress.clone());
let app2 = app.clone();
let partial2 = partial.clone();
let progress2 = progress.clone();
crate::loudness::on_track_started(
&app,
&cache,
size,
Some((partial, progress)),
Box::new(move || {
app2.state::<PlayerState>()
.player
.play_stream(&partial2, progress2, size, duration);
}),
);
Ok(())
}
@@ -3342,6 +3394,8 @@ pub async fn preload_stream(
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
if let Ok(md) = std::fs::metadata(&cache) {
if size > 0 && md.len() == size {
// Gapless normalization: boundary gain ready before the switch.
crate::loudness::on_preload_ready(&app, &cache);
return Ok(()); // already fully cached
}
let _ = std::fs::remove_file(&cache);
@@ -3383,6 +3437,8 @@ pub fn preload_cached_stream(
Ok(md) if size == 0 || md.len() == size => {
playback::touch(&cache);
players.player.preload_local(&cache, duration);
// Gapless normalization: boundary gain ready before the switch.
crate::loudness::on_preload_ready(&app, &cache);
Ok(true)
}
_ => Ok(false),
@@ -3485,14 +3541,25 @@ pub async fn stream_waveform(
/// Play an already-downloaded local file.
#[tauri::command]
pub fn play_local(
players: State<'_, PlayerState>,
app: tauri::AppHandle,
path: String,
duration: Option<f64>,
) -> Result<(), String> {
if std::fs::metadata(&path).is_err() {
return Err("file not found".into());
}
players.player.play_local(&path, duration);
crate::diagnostics::clear_stream(&app);
let app2 = app.clone();
let path2 = path.clone();
crate::loudness::on_track_started(
&app,
std::path::Path::new(&path),
0,
None,
Box::new(move || {
app2.state::<PlayerState>().player.play_local(&path2, duration);
}),
);
Ok(())
}
@@ -3500,6 +3567,7 @@ pub fn play_local(
/// used for gapless album playback; it has no effect on streaming downloads.
#[tauri::command]
pub fn preload_local(
app: tauri::AppHandle,
players: State<'_, PlayerState>,
path: String,
duration: Option<f64>,
@@ -3508,6 +3576,8 @@ pub fn preload_local(
return Err("file not found".into());
}
players.player.preload_local(&path, duration);
// Gapless normalization: have the boundary gain ready before the switch.
crate::loudness::on_preload_ready(&app, std::path::Path::new(&path));
Ok(())
}