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:
KeelrLeek committed 2026-07-23 17:31:06 -05:00
1 parent 28aba3a3b3
commit 86e0d86604
7 files changed
+98 -45

No files matched your search

+4 -1
View File
@@ -92,7 +92,10 @@
</div>
<div class="sec">
<div><b>FPS</b> {fps}</div>
<div><b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}</div>
<div>
<b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}
{#if stats.mainFadeGain != null && stats.mainFadeGain < 0.999}· fade {stats.mainFadeGain.toFixed(2)}{/if}
</div>
{#if dlTotal > 0}
<div>
<b>DL</b>
+4 -4
View File
@@ -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 });
+3 -2
View File
@@ -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 };
+1 -1
View File
@@ -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);
},
});