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

+421
View File
@@ -0,0 +1,421 @@
//! Volume normalization — the app-side orchestration over the player
//! crate's BS.1770 meter. Every main-player track is measured and played
//! with a gain that brings it to one target loudness (−14 LUFS, Spotify's
//! reference), so track-to-track volume stops being all over the place.
//!
//! Streaming-first: an unmeasured track is analyzed DURING its first play,
//! reading the same growing partial file the player streams from — analysis
//! runs at download speed, so the true integrated loudness usually lands
//! within seconds and the gain glides there (the player slew-limits it).
//! Measured tracks live in `loudness-cache.json`; their exact gain is
//! applied before the first sample on every later play.
//!
//! Toggleable via config `volume_normalization` (default on). Preview clips
//! are deliberately NOT normalized — Deezer masters them consistently.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use tauri::Manager;
use player::{analyze_loudness, AnalysisSource, StreamProgress};
use crate::playback::PlayerState;
/// Playback target (Spotify's default reference level).
pub const TARGET_LUFS: f64 = -14.0;
/// Gain clamp: quiet-track boosts are the risky direction (headroom), loud
/// tracks can always be cut.
const MIN_GAIN_DB: f64 = -15.0;
const MAX_GAIN_DB: f64 = 9.0;
/// Boosts additionally respect the track's own sample peak up to this
/// ceiling — normalization must never introduce clipping.
const PEAK_CEILING: f64 = 0.98;
const CACHE_FILE: &str = "loudness-cache.json";
/// Entries beyond this are dropped oldest-first at save time.
const CACHE_CAP: usize = 4000;
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Measured {
pub lufs: f64,
pub peak: f32,
/// File size when measured — a different size is a different file.
pub size: u64,
pub at_epoch_ms: i64,
}
/// Live view of the ACTIVE track's normalization, for the debug overlay.
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct CurrentTrack {
pub lufs: Option<f64>,
pub peak: f32,
pub gain_db: f64,
/// The gain came from the cache (exact, applied pre-sample).
pub cached: bool,
/// The analyzer is still measuring this play.
pub analyzing: bool,
}
pub struct LoudnessState {
cache: Mutex<HashMap<String, Measured>>,
/// Bumped on every track start; orphans older analyzer threads.
generation: Arc<AtomicU64>,
enabled: AtomicBool,
/// Debug-overlay mirror of the active track's measurement state.
current: Mutex<Option<CurrentTrack>>,
}
/// (normalization enabled, target LUFS, active-track state) for diagnostics.
pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option<CurrentTrack>) {
let state = app.state::<LoudnessState>();
let current = *state.current.lock().expect("loudness current lock");
(
state.enabled.load(Ordering::Relaxed),
TARGET_LUFS,
current,
)
}
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE))
}
pub fn init(app: &tauri::AppHandle) {
let cache: HashMap<String, Measured> = cache_file(app)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let enabled = crate::config::volume_normalization(&crate::config::load(app));
app.manage(LoudnessState {
cache: Mutex::new(cache),
generation: Arc::new(AtomicU64::new(0)),
enabled: AtomicBool::new(enabled),
current: Mutex::new(None),
});
}
fn save_cache(app: &tauri::AppHandle, state: &LoudnessState) {
let Some(path) = cache_file(app) else { return };
let mut entries: Vec<(String, Measured)> = state
.cache
.lock()
.expect("loudness cache lock")
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
if entries.len() > CACHE_CAP {
entries.sort_by_key(|(_, m)| std::cmp::Reverse(m.at_epoch_ms));
entries.truncate(CACHE_CAP);
}
let map: HashMap<&String, &Measured> = entries.iter().map(|(k, v)| (k, v)).collect();
if let Ok(json) = serde_json::to_string(&map) {
let _ = std::fs::write(path, json);
}
}
/// gain = target − measured, clamped, and boosts capped so the track's own
/// peak never exceeds the ceiling.
fn gain_db(m: &Measured) -> f64 {
let mut g = (TARGET_LUFS - m.lufs).clamp(MIN_GAIN_DB, MAX_GAIN_DB);
if g > 0.0 && m.peak > 0.0 {
let headroom = 20.0 * (PEAK_CEILING / m.peak as f64).log10();
g = g.min(headroom.max(0.0));
}
g
}
/// Toggle from settings: applies to the CURRENT track immediately (gain to
/// unity when turning off; the cached gain back on when turning on is left
/// to the next track — the analyzer generation for the current one is gone).
pub fn set_enabled(app: &tauri::AppHandle, enabled: bool) {
let state = app.state::<LoudnessState>();
state.enabled.store(enabled, Ordering::Relaxed);
if !enabled {
state.generation.fetch_add(1, Ordering::Relaxed); // orphan analyzers
*state.current.lock().expect("loudness current lock") = None;
app.state::<PlayerState>().player.set_norm_gain_db(0.0, false);
}
}
/// Pre-playback measurement window: the audible start is HELD until this
/// much audio is analyzed (at download/disk speed — normally well under a
/// second of wall time)…
const PRELIM_MIN_SECS: f64 = 10.0;
/// …or until this much wall time passes after the FIRST measurement lands
/// (slow peer): then we commit to what we have. Either way the gain is set
/// BEFORE the first audible sample and NEVER changes mid-track (user
/// decision 2026-07-23 — the earlier mid-play glide was audible and bad).
const PRELIM_WALL_BUDGET: std::time::Duration = std::time::Duration::from_millis(800);
/// A head estimate can under-read a track that opens quietly, so
/// preliminary BOOSTS are capped harder than cached ones; cuts are trusted
/// (a loud head is reliably loud).
const PRELIM_MAX_BOOST_DB: f64 = 3.0;
fn prelim_gain_db(m: &Measured) -> f64 {
gain_db(m).min(PRELIM_MAX_BOOST_DB)
}
fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, size_hint: u64) {
let final_size = std::fs::metadata(key).map(|m| m.len()).unwrap_or(size_hint);
let measured = Measured {
lufs,
peak,
size: final_size,
at_epoch_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
};
let state = app.state::<LoudnessState>();
state
.cache
.lock()
.expect("loudness cache lock")
.insert(key.to_owned(), measured);
save_cache(app, &state);
}
/// The hook every main-player track start funnels through. `play` is the
/// DEFERRED start of playback: a cache hit or disabled normalization fires
/// it immediately; an unmeasured track holds it for the preliminary
/// measurement window so the gain is right from the very first sample.
/// The analyzer then keeps running silently to EOF — only to fill the
/// cache, never to touch the volume of the play in progress.
pub fn on_track_started(
app: &tauri::AppHandle,
cache_path: &Path,
size: u64,
stream: Option<(PathBuf, Arc<StreamProgress>)>,
play: Box<dyn FnOnce() + Send>,
) {
let state = app.state::<LoudnessState>();
let generation = state.generation.fetch_add(1, Ordering::Relaxed) + 1;
if !state.enabled.load(Ordering::Relaxed) {
*state.current.lock().expect("loudness current lock") = None;
play();
return; // player resets to unity on Play
}
let key = cache_path.to_string_lossy().into_owned();
// Known file → play at once with the exact gain (the set lands behind
// Play on the ordered command channel, before any sink exists).
let known_size = if size > 0 {
size
} else {
std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0)
};
if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) {
if m.size == known_size || known_size == 0 {
let g = gain_db(m);
play();
app.state::<PlayerState>()
.player
.set_norm_gain_db(g as f32, true);
*state.current.lock().expect("loudness current lock") = Some(CurrentTrack {
lufs: Some(m.lufs),
peak: m.peak,
gain_db: g,
cached: true,
analyzing: false,
});
return;
}
}
*state.current.lock().expect("loudness current lock") = Some(CurrentTrack {
lufs: None,
peak: 0.0,
gain_db: 0.0,
cached: false,
analyzing: true,
});
// Unmeasured → the analyzer races ahead of playback (download/disk
// speed) and RELEASES the held play once the preliminary window is met.
let app = app.clone();
let gen_ctr = state.generation.clone();
std::thread::Builder::new()
.name("loudness-analysis".into())
.spawn(move || {
let source = match stream {
Some((partial, progress)) => AnalysisSource::Growing {
path: partial,
total: size,
progress,
},
None => AnalysisSource::Local(PathBuf::from(&key)),
};
let cancelled = || gen_ctr.load(Ordering::Relaxed) != generation;
let mut play_slot = Some(play);
let mut applied_db: Option<f64> = None;
let mut first_measure: Option<std::time::Instant> = None;
{
let app = &app;
let cancelled = &cancelled;
let mut on_update = |lufs: f64, peak: f32, secs: f64| {
if cancelled() || play_slot.is_none() {
return; // started already: NO further volume touches
}
let first = *first_measure.get_or_insert_with(std::time::Instant::now);
if secs < PRELIM_MIN_SECS && first.elapsed() < PRELIM_WALL_BUDGET {
return; // keep listening a moment longer
}
let m = Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
};
let g = prelim_gain_db(&m);
(play_slot.take().expect("gate fires once"))();
app.state::<PlayerState>()
.player
.set_norm_gain_db(g as f32, true);
applied_db = Some(g);
*app.state::<LoudnessState>()
.current
.lock()
.expect("loudness current lock") = Some(CurrentTrack {
lufs: Some(lufs),
peak,
gain_db: g,
cached: false,
analyzing: true,
});
};
let done = analyze_loudness(source, cancelled, &mut on_update);
// Gate never fired (clip shorter than a gating block, decode
// failure, dead transfer): start at unity — unless a newer
// track superseded this one, then never start it at all.
if let Some(play) = play_slot.take() {
if !cancelled() {
play();
}
}
let Some((lufs, peak)) = done else { return };
// The full measurement is valid for every future play of
// this file, whatever happened to this one.
store_measurement(app, &key, lufs, peak, size);
if !cancelled() {
*app.state::<LoudnessState>()
.current
.lock()
.expect("loudness current lock") = Some(CurrentTrack {
lufs: Some(lufs),
peak,
gain_db: applied_db.unwrap_or(0.0),
cached: false,
analyzing: false,
});
}
}
})
.expect("spawn loudness analysis");
}
/// Gapless normalization: called when the NEXT track's preload is complete
/// (or was already cached on disk). Hands the boundary gain to the audio
/// thread, which applies it sample-accurately when the gapless switch
/// happens — a track change is the one place loudness may step. Guarded by
/// the loudness generation: a user-initiated play supersedes any pending
/// preload analysis (the player also clears its pending gain on Play/Stop).
pub fn on_preload_ready(app: &tauri::AppHandle, cache_path: &Path) {
let state = app.state::<LoudnessState>();
if !state.enabled.load(Ordering::Relaxed) {
return;
}
let generation = state.generation.load(Ordering::Relaxed);
let key = cache_path.to_string_lossy().into_owned();
let known_size = std::fs::metadata(cache_path).map(|m| m.len()).unwrap_or(0);
if let Some(m) = state.cache.lock().expect("loudness cache lock").get(&key) {
if m.size == known_size || known_size == 0 {
app.state::<PlayerState>()
.player
.set_next_norm_gain_db(gain_db(m) as f32);
return;
}
}
let app = app.clone();
let gen_ctr = state.generation.clone();
std::thread::Builder::new()
.name("loudness-preload".into())
.spawn(move || {
let done = analyze_loudness(
AnalysisSource::Local(PathBuf::from(&key)),
&|| false,
&mut |_, _, _| {},
);
let Some((lufs, peak)) = done else { return };
store_measurement(&app, &key, lufs, peak, 0);
// Only hand the boundary gain over if no new track started
// since — a stale preload must not color a different queue.
if gen_ctr.load(Ordering::Relaxed) == generation {
let m = Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
};
app.state::<PlayerState>()
.player
.set_next_norm_gain_db(gain_db(&m) as f32);
}
})
.expect("spawn preload loudness analysis");
}
#[cfg(test)]
mod tests {
use super::*;
fn m(lufs: f64, peak: f32) -> Measured {
Measured {
lufs,
peak,
size: 0,
at_epoch_ms: 0,
}
}
#[test]
fn quiet_tracks_boost_toward_target() {
assert!((gain_db(&m(-20.0, 0.3)) - 6.0).abs() < 1e-9);
}
#[test]
fn loud_tracks_cut_toward_target() {
assert!((gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9);
}
#[test]
fn boost_is_capped_by_peak_headroom() {
// Target wants +9 (clamped), but a 0.9 peak only has ~0.73 dB of
// headroom to the 0.98 ceiling.
let g = gain_db(&m(-30.0, 0.9));
assert!(g < 0.8 && g > 0.6, "got {g}");
}
#[test]
fn cuts_ignore_peak() {
assert!((gain_db(&m(-8.0, 0.2)) - (-6.0)).abs() < 1e-9);
}
#[test]
fn extreme_measurements_stay_clamped() {
assert_eq!(gain_db(&m(-60.0, 0.0001)), MAX_GAIN_DB);
assert_eq!(gain_db(&m(20.0, 1.0)), MIN_GAIN_DB);
}
#[test]
fn preliminary_boosts_are_capped_but_cuts_pass_through() {
// A quiet head would ask +6 dB; the head estimate only gets +3.
assert!((prelim_gain_db(&m(-20.0, 0.3)) - PRELIM_MAX_BOOST_DB).abs() < 1e-9);
// Cuts are trusted at full strength — a loud head is reliably loud.
assert!((prelim_gain_db(&m(-8.0, 1.0)) - (-6.0)).abs() < 1e-9);
}
}