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.
This commit is contained in:
1 parent
28aba3a3b3
commit
86e0d86604
7 files changed
+98
-45
No files matched your search
@@ -79,6 +79,9 @@ pub struct DebugStats {
|
||||
pub normalization_enabled: bool,
|
||||
pub target_lufs: f64,
|
||||
pub loudness: Option<crate::loudness::CurrentTrack>,
|
||||
/// 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<f32>,
|
||||
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
||||
pub preview_volume: Option<f32>,
|
||||
/// 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::<crate::preview::PreviewState>()
|
||||
.map(|s| crate::preview::active_norm_db(&s)),
|
||||
main_fade_gain: app
|
||||
.try_state::<crate::playback::PlayerState>()
|
||||
.map(|p| p.player.fade_gain()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-27
@@ -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::<PlayerState>();
|
||||
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::<PlayerState>();
|
||||
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(())
|
||||
|
||||
Reference in new issue
Block a user