From 86e0d86604dbe319642d88b971cdb0fd565b02ad Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:31:06 -0500 Subject: [PATCH] fix: preview crossfade no longer strands the main volume (silent playback) Clicking a track could play silently: the store showed status=playing with the position advancing and no error, but the sink was at volume 0. The open/close crossfade ramped the main player's USER VOLUME as its fade channel and left it at 0 on an ack timeout (or when a track change suppressed the close resume). User volume persists across tracks and a new Play doesn't reset it, so every later click played at 0 while the slider still read 0.7. Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink = volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close fades ramp fade_gain instead of set_volume (their volume arg is gone, JS callers too), so the user volume is never touched -- store and backend can't desync -- and a clicked track always resets fade_gain to 1, so it can never be stranded silent. fade_resume lands exactly at 1. Player::fade_gain() mirrors it into a report atomic; debug overlay shows main fade + this proved the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7). 39 app + 12 player + 85 JS tests pass. --- crates/player/src/lib.rs | 61 +++++++++++++++++++++++++----- src-tauri/src/diagnostics.rs | 6 +++ src-tauri/src/preview.rs | 56 ++++++++++++++------------- src/components/DebugOverlay.svelte | 5 ++- src/lib/api.js | 8 ++-- src/lib/preview/focusBridge.js | 5 ++- src/lib/state.svelte.js | 2 +- 7 files changed, 98 insertions(+), 45 deletions(-) diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 82207b7..22f4c2b 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -113,6 +113,10 @@ enum Cmd { /// exactly where loudness may step. Cleared by Play/Stop; an unknown /// next track falls back to unity at the boundary. SetNextNormGainDb(f32), + /// Transient fade multiplier (0..1) for the preview open/close crossfade, + /// SEPARATE from the user volume so a fade can never strand the volume + /// setting. Reset to 1 by every Play/Stop. + SetFadeGain(f32), SetOutputDevice { device: Option, reply: mpsc::Sender>, @@ -227,6 +231,9 @@ where #[derive(Clone)] pub struct Player { cmd_tx: mpsc::Sender, + /// f32 bits; the audio thread mirrors its live fade-gain here for + /// observability (the debug overlay / crossfade diagnostics). 1.0 = full. + fade_report: Arc, } impl Player { @@ -242,13 +249,20 @@ impl Player { ) -> (Player, mpsc::Receiver) { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); + let fade_report = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); + let thread_report = fade_report.clone(); std::thread::Builder::new() .name("audio".into()) - .spawn(move || audio_thread(cmd_rx, event_tx, output_device)) + .spawn(move || audio_thread(cmd_rx, event_tx, output_device, thread_report)) .expect("spawn audio thread"); - (Player { cmd_tx }, event_rx) + (Player { cmd_tx, fade_report }, event_rx) + } + + /// The audio thread's current fade-gain (0..1). Observability only. + pub fn fade_gain(&self) -> f32 { + f32::from_bits(self.fade_report.load(Ordering::Relaxed)) } pub fn play_local(&self, path: impl Into, duration_hint: Option) { @@ -352,6 +366,13 @@ impl Player { .send(Cmd::SetNextNormGainDb(db.clamp(-18.0, 9.0))); } + /// Transient fade multiplier (0..1) for the preview open/close crossfade. + /// A separate channel from the user volume — reset to 1 by every Play/Stop + /// — so a fade can never leave a later track silent. See [`Cmd::SetFadeGain`]. + pub fn set_fade_gain(&self, gain: f32) { + let _ = self.cmd_tx.send(Cmd::SetFadeGain(gain.clamp(0.0, 1.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) -> Result<(), String> { @@ -476,6 +497,7 @@ fn audio_thread( cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, initial_device: Option, + fade_report: Arc, ) { // Open the output device lazily and keep it across tracks. If the device // is momentarily busy (e.g. another instance still releasing it), we @@ -505,7 +527,12 @@ fn audio_thread( let mut norm_target_db: f32 = 0.0; // Gain waiting for the next gapless boundary (None → unity there). let mut pending_norm_db: Option = None; - let eff = |user: f32, ndb: f32| user * 10f32.powf(ndb / 20.0); + // Transient fade multiplier (0..1) for the preview session's open/close + // crossfade — a SEPARATE channel from the user volume, so a fade can + // never corrupt the volume setting (leaving a track silent after the + // fade or across the next Play). Reset to 1 by every Play/Stop. + let mut fade_gain: f32 = 1.0; + let eff = |user: f32, ndb: f32, fg: f32| user * 10f32.powf(ndb / 20.0) * fg; let mut playing = false; // The active stream source, so we can abort its blocking reads. let mut active_stream: Option> = None; @@ -568,6 +595,11 @@ fn audio_thread( norm_db = 0.0; norm_target_db = 0.0; pending_norm_db = None; + // A clicked/queued track always plays at full: never inherit a + // preview crossfade's residual fade level (that was the + // "audio broken after a track click" bug). + fade_gain = 1.0; + fade_report.store(fade_gain.to_bits(), Ordering::Relaxed); let track = TrackSource { path, stream, @@ -672,7 +704,7 @@ fn audio_thread( match decode_scratch_window(path, secs, rate) { Ok((samples, speed)) => { let s = Sink::connect_new(out.mixer()); - s.set_volume(eff(volume, norm_db)); + s.set_volume(eff(volume, norm_db, fade_gain)); s.set_speed(speed); s.append(samples); s.play(); @@ -700,7 +732,7 @@ fn audio_thread( match local_decoder(path) { Ok(decoder) => { let s = Sink::connect_new(out.mixer()); - s.set_volume(eff(volume, norm_db)); + s.set_volume(eff(volume, norm_db, fade_gain)); queued_sources.store(1, Ordering::SeqCst); s.append(EndNotifier { inner: decoder, @@ -731,7 +763,7 @@ fn audio_thread( Ok(Cmd::Volume(v)) => { volume = v; if let Some(s) = &sink { - s.set_volume(eff(volume, norm_db)); + s.set_volume(eff(volume, norm_db, fade_gain)); } } Ok(Cmd::SetNormGainDb { db, immediate }) => { @@ -739,7 +771,7 @@ fn audio_thread( if immediate { norm_db = db; if let Some(s) = &sink { - s.set_volume(eff(volume, norm_db)); + s.set_volume(eff(volume, norm_db, fade_gain)); } } // Non-immediate: the tick arm slews norm_db there. @@ -747,6 +779,13 @@ fn audio_thread( Ok(Cmd::SetNextNormGainDb(db)) => { pending_norm_db = Some(db); } + Ok(Cmd::SetFadeGain(fg)) => { + fade_gain = fg.clamp(0.0, 1.0); + fade_report.store(fade_gain.to_bits(), Ordering::Relaxed); + if let Some(s) = &sink { + s.set_volume(eff(volume, norm_db, fade_gain)); + } + } Ok(Cmd::SetOutputDevice { device, reply }) => { output_change = Some((device, Some(reply))); } @@ -762,6 +801,8 @@ fn audio_thread( waiting_preload = None; pending_tracks.clear(); pending_norm_db = None; + fade_gain = 1.0; + fade_report.store(fade_gain.to_bits(), Ordering::Relaxed); playing = false; scratching = false; local_path = None; @@ -776,7 +817,7 @@ fn audio_thread( 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)); + s.set_volume(eff(volume, norm_db, fade_gain)); } } } @@ -882,7 +923,7 @@ fn audio_thread( let track_duration = ready.track_duration(); if !is_preload { let s = Sink::connect_new(out.mixer()); - s.set_volume(eff(volume, norm_db)); + s.set_volume(eff(volume, norm_db, fade_gain)); sink = Some(s); active_track = Some(ready.track.clone()); active_stream = ready.track.stream.clone(); @@ -969,7 +1010,7 @@ fn audio_thread( 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)); + s.set_volume(eff(volume, norm_db, fade_gain)); } } last_queued = q; diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 6a950c4..81559b4 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -79,6 +79,9 @@ pub struct DebugStats { pub normalization_enabled: bool, pub target_lufs: f64, pub loudness: Option, + /// Main player's live fade-gain (1.0 = full; <1 only during a preview + /// open/close crossfade). Proves a fade never strands the volume. + pub main_fade_gain: Option, /// The preview voices' resting volume (mirrors the app volume; 0 = muted). pub preview_volume: Option, /// The active preview clip's loudness-normalization gain in dB. @@ -173,6 +176,9 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS preview_norm_db: app .try_state::() .map(|s| crate::preview::active_norm_db(&s)), + main_fade_gain: app + .try_state::() + .map(|p| p.player.fade_gain()), } } diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index 1f11ab3..fd748d3 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -688,27 +688,26 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { /// the preview voices' `fade_gen`. static MAIN_FADE_GEN: AtomicU64 = AtomicU64::new(0); -/// Session-open crossfade, main side: ramp the main player to silence over -/// `fade_ms` (equal-power cos, so the preview's sin ramp-in sums level), -/// then pause it WITH the desired-paused latch, then restore `volume` on the -/// now-silent sink so the later resume has its level back. Returns as soon -/// as the fade thread is RUNNING — the caller starts the preview's ramp-in -/// over it; the overlap is the point (deliberate relaxation of the Phase -/// 0.4 "acknowledged silence before any preview audio" rule, user decision -/// 2026-07-23). If the ack never lands, the volume stays at 0 — silent is -/// the safe failure — and the close-side fade-resume self-heals it. +/// Session-open crossfade, main side: ramp the main player's FADE-GAIN (a +/// channel separate from the user volume) 1→0 over `fade_ms` (equal-power +/// cos, so the preview's sin ramp-in sums level), then pause with the +/// desired-paused latch. Returns as soon as the fade thread is RUNNING — the +/// caller starts the preview's ramp-in over it; the overlap is the point +/// (deliberate relaxation of the Phase 0.4 "acknowledged silence before any +/// preview audio" rule, user decision 2026-07-23). +/// +/// Fade-gain is used INSTEAD of the user volume precisely so a fade can never +/// strand the volume: fade-gain resets to 1 on the next Play, and the +/// close-side fade-resume ramps it back — so even if the ack times out or a +/// track change suppresses the resume, a later clicked track still plays at +/// full (the "audio broken after a track click" bug this replaces). #[tauri::command] -pub fn player_fade_pause_acked( - app: tauri::AppHandle, - fade_ms: u64, - volume: f64, -) -> Result<(), String> { +pub fn player_fade_pause_acked(app: tauri::AppHandle, fade_ms: u64) -> Result<(), String> { let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1; std::thread::Builder::new() .name("main-fade-out".into()) .spawn(move || { let players = app.state::(); - let v = volume as f32; let dur = fade_ms.max(1); let t0 = Instant::now(); loop { @@ -718,7 +717,7 @@ pub fn player_fade_pause_acked( let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0); players .player - .set_volume(v * (t * std::f32::consts::FRAC_PI_2).cos()); + .set_fade_gain((t * std::f32::consts::FRAC_PI_2).cos()); if t >= 1.0 { break; } @@ -727,28 +726,27 @@ pub fn player_fade_pause_acked( if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen { return; } + // Pause with the latch. Fade-gain stays 0 while paused (silent), + // but the user volume is untouched, so nothing is stranded. let rx = players.player.pause_acked(); - match rx.recv_timeout(Duration::from_millis(1_500)) { - Ok(_) => players.player.set_volume(v), - Err(_) => eprintln!("preview: main fade-pause ack timed out; volume left silent"), - } + let _ = rx.recv_timeout(Duration::from_millis(1_500)); }) .map_err(|e| e.to_string())?; Ok(()) } -/// Session-close crossfade, main side: come back from the paused snapshot at -/// volume 0 and ramp to `volume` while the preview's fade-out rings down. -/// Resume also clears the desired-paused latch (player-crate contract). +/// Session-close crossfade, main side: resume the paused main and ramp its +/// FADE-GAIN 0→1 while the preview's fade-out rings down. Resume clears the +/// desired-paused latch (player-crate contract); the user volume is never +/// touched, so the track returns at exactly the level it left. #[tauri::command] -pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> Result<(), String> { +pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64) -> Result<(), String> { let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1; std::thread::Builder::new() .name("main-fade-in".into()) .spawn(move || { let players = app.state::(); - let v = volume as f32; - players.player.set_volume(0.0); + players.player.set_fade_gain(0.0); players.player.resume(); let dur = fade_ms.max(1); let t0 = Instant::now(); @@ -759,12 +757,16 @@ pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> R let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0); players .player - .set_volume(v * (t * std::f32::consts::FRAC_PI_2).sin()); + .set_fade_gain((t * std::f32::consts::FRAC_PI_2).sin()); if t >= 1.0 { break; } std::thread::sleep(FADE_TICK); } + // Land exactly at unity so a stale tick can't leave it short. + if MAIN_FADE_GEN.load(Ordering::Relaxed) == gen { + players.player.set_fade_gain(1.0); + } }) .map_err(|e| e.to_string())?; Ok(()) diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 6c44b6a..84dddae 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -92,7 +92,10 @@
FPS {fps}
-
PLAYER {player.status}{player.track ? ` · ${player.track.title}` : ""}
+
+ PLAYER {player.status}{player.track ? ` · ${player.track.title}` : ""} + {#if stats.mainFadeGain != null && stats.mainFadeGain < 0.999}· fade {stats.mainFadeGain.toFixed(2)}{/if} +
{#if dlTotal > 0}
DL diff --git a/src/lib/api.js b/src/lib/api.js index a8ef09a..45fa250 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -138,10 +138,10 @@ export const streamWaveform = (username, path, size, buckets) => export const previewResolve = (trackId) => invoke("preview_resolve", { trackId }); export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) => invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs }); -export const playerFadePauseAcked = (fadeMs, volume) => - invoke("player_fade_pause_acked", { fadeMs, volume }); -export const playerFadeResume = (fadeMs, volume) => - invoke("player_fade_resume", { fadeMs, volume }); +export const playerFadePauseAcked = (fadeMs) => + invoke("player_fade_pause_acked", { fadeMs }); +export const playerFadeResume = (fadeMs) => + invoke("player_fade_resume", { fadeMs }); export const debugStats = () => invoke("debug_stats"); export const previewControl = (action, value = null) => invoke("preview_control", { action, value }); diff --git a/src/lib/preview/focusBridge.js b/src/lib/preview/focusBridge.js index 38cb782..757974f 100644 --- a/src/lib/preview/focusBridge.js +++ b/src/lib/preview/focusBridge.js @@ -105,14 +105,15 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu // Deferred open crossfade: fade the main track out NOW, synchronised with // the preview's fade-in. Idempotent, and only fades if the main is // actually sounding (guards a commitment that leaked past a handoff or an - // idle-main open). Fresh volume read at fire time. + // idle-main open). The fade rides a separate fade-gain channel — the user + // volume is never touched. coordinator.beginMainSilence = () => { if (!mainSilenceArmed) return; mainSilenceArmed = false; const p = readPlayer(); const audible = p.status === "playing" || p.status === "buffering"; if (!audible) return; - void api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7)); + void api.playerFadePauseAcked(CROSSFADE_MS); }; return { coordinator, gateway }; diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index e969e40..747d14d 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -3570,7 +3570,7 @@ export function initEvents() { resumeMain: () => { // Close crossfade: come back from the preview at volume 0 and // ramp to the user's level while the preview rings down. - api.playerFadeResume(PREVIEW_CROSSFADE_MS, player.muted ? 0 : player.volume); + api.playerFadeResume(PREVIEW_CROSSFADE_MS); }, });