From 4b1d3afe7b59a6afc0ab73ba0780cd503032e6e8 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 17:07:52 -0700 Subject: [PATCH] fix(sim): cancel in-flight volume fades so audio isn't silenced after landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- simulator/e2e/tests/altitude-lock.spec.ts | 17 +++++++++++++++++ simulator/static/app.js | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 92cc164..7cad845 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -55,6 +55,23 @@ test("two audio elements exist for crossfade", async ({ page }) => { expect(n).toBe(2); }); +test("a new volume fade supersedes a stale one (audio not silenced after landing)", async ({ page }) => { + await boot(page); + // Reproduce the race: a fade-to-0 in flight, then the same element is brought back + // up (as happens when a faded idle element becomes the newly-landed active scale). + // The stale fade must NOT drag it back to 0. + const vol = await page.evaluate(async () => { + const a = document.querySelector("#aud") as HTMLAudioElement; + a.volume = 1; + (window as any).__hefFade(a, 0, 600); // start a slow fade to 0 + await new Promise((r) => setTimeout(r, 120)); // let it ramp partway down + (window as any).__hefFade(a, 0.8, 90); // new fade up — must cancel the old one + await new Promise((r) => setTimeout(r, 350)); // well past both fades + return a.volume; + }); + expect(vol).toBeGreaterThan(0.7); // ended near 0.8, not dragged to 0 +}); + test("audio is a 0-10 level dial, defaulting to 0", async ({ page }) => { await boot(page); const ctl = await page.evaluate(() => { diff --git a/simulator/static/app.js b/simulator/static/app.js index a3003c1..3f33058 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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); }