fix(sim): cancel in-flight volume fades so audio isn't silenced after landing

Operator: audio turned off sometimes after landing on a new altitude. Root cause:
fadeVolume started a setInterval ramp that was never cancelled. When restAudio faded
the idle element to 0 and — within FADE_MS — that same element became the newly-landed
ACTIVE scale (assignElements reuses an element per soundtrack url), the stale fade-to-0
kept running and dragged the now-active element back to silence. Fix: cancelFade()
supersedes any in-flight fade when a new fade starts (fadeVolume) or a direct volume
is set (ensurePlaying). E2E proves a new fade supersedes a stale one (fails without
the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 17:07:52 -07:00
parent e862fb498b
commit 4b1d3afe7b
2 changed files with 33 additions and 1 deletions
+16 -1
View File
@@ -1076,19 +1076,33 @@ aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
setInterval(updateAudioStatus, 400);
// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
// if a stale one keeps running after a new fade or a direct volume set, it drags the
// element back toward its old target (e.g. a fade-to-0 on what becomes the ACTIVE
// element silences it — the "audio off after landing" bug).
function cancelFade(el) {
if (el._fadeIv) { clearInterval(el._fadeIv); el._fadeIv = null; }
}
function fadeVolume(el, to, ms, done) {
// setInterval (not requestAnimationFrame): rAF throttles/pauses when the tab is
// backgrounded, which would stall the gain ramp; a timer fires regardless.
cancelFade(el); // supersede any fade already running on this element
const from = el.volume, steps = Math.max(1, Math.round(ms / 30));
let i = 0;
const iv = setInterval(() => {
i += 1;
const k = Math.min(1, i / steps);
el.volume = Math.min(1, Math.max(0, from + (to - from) * k));
if (k >= 1) { clearInterval(iv); if (done) done(); }
if (k >= 1) { clearInterval(iv); el._fadeIv = null; if (done) done(); }
}, 30);
el._fadeIv = iv;
}
// Diagnostic seam (sibling of window.__hefState): expose the fade so E2E can verify a
// new fade supersedes a stale one rather than racing it.
if (typeof window !== "undefined") window.__hefFade = fadeVolume;
// Fallback per-scale soundtrack paths, used when the ring from /api/ring lacks the
// `audio` field (e.g. a server process started before the audio field existed).
// Mirrors simulator/build_pool_manifest.py SCALE_AUDIO — the 5 scales + files are
@@ -1128,6 +1142,7 @@ function ensurePlaying(el, url, vol) {
if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); })
.catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); });
}
cancelFade(el); // a direct volume set supersedes any in-flight fade on this element
el.volume = HEFScrub.clamp01(vol);
}