From e862fb498b404d99d04f89d46c13b62768ffdf59 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 17:02:19 -0700 Subject: [PATCH] feat(sim): audio on/off toggle -> 0-10 level dial; first video lifts it to 3/10 Operator request. Replaces the Audio checkbox with a 0-10 range slider that sets the master soundtrack gain (audioVol = level/10), scaling both the per-scale rest volume and the two-track scrub crossfade. Level 0 = silent. Turning Video on for the first time now raises audio to 3/10 (was: full on) so the experience opens gently; raising the slider from 0 unlocks playback in-gesture (Safari). E2E: dial is range 0-10 default 0, and first video-on lifts it to 3/10. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 29 ++++++++++-- simulator/static/app.js | 55 ++++++++++++++--------- simulator/static/index.html | 7 ++- simulator/static/style.css | 5 +++ 4 files changed, 67 insertions(+), 29 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index e526b89..92cc164 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -29,13 +29,13 @@ async function wheelOnStage(page: Page, deltaY: number) { await page.locator("#stage").dispatchEvent("wheel", { deltaY }); } -// The Audio toggle is a visually-hidden checkbox styled as a switch; set it + fire -// `change` directly (headless Chromium relaxes the autoplay gesture requirement). +// The Audio control is a 0–10 level slider; set it to full + fire `input` directly +// (headless Chromium relaxes the autoplay gesture requirement). async function enableAudio(page: Page) { await page.evaluate(() => { const c = document.querySelector("#audio") as HTMLInputElement; - c.checked = true; - c.dispatchEvent(new Event("change", { bubbles: true })); + c.value = "10"; + c.dispatchEvent(new Event("input", { bubbles: true })); }); } @@ -55,6 +55,27 @@ test("two audio elements exist for crossfade", async ({ page }) => { expect(n).toBe(2); }); +test("audio is a 0-10 level dial, defaulting to 0", async ({ page }) => { + await boot(page); + const ctl = await page.evaluate(() => { + const c = document.querySelector("#audio") as HTMLInputElement; + return { type: c.type, min: c.min, max: c.max, value: c.value }; + }); + expect(ctl.type).toBe("range"); + expect(ctl.min).toBe("0"); + expect(ctl.max).toBe("10"); + expect(ctl.value).toBe("0"); // silent until raised +}); + +test("turning video on for the first time lifts audio to 3/10", async ({ page }) => { + await boot(page); + expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0"); + await enableVideo(page); // first video-on couples audio at a gentle level + await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3"); + // The level label reflects it. + expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3"); +}); + test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => { await boot(page); await enableAudio(page); // hidden custom toggle → set + dispatch change diff --git a/simulator/static/app.js b/simulator/static/app.js index 789ddf8..a3003c1 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -585,7 +585,7 @@ function controls() { const mood = +$("mood").value; return { visual: $("visual").checked ? "on" : "off", - audio: $("audio").checked ? "soundtrack" : "off", + audio: audioLevel() > 0 ? "soundtrack" : "off", // Back-compat: a server process predating the visual/audio split still requires // a 7-way `content` and would 422 the new payload. Send both — the current // server ignores `content`, an old one ignores visual/audio. @@ -1034,6 +1034,15 @@ const audB = $("aud-b"); // second element: the two crossfade by let audLastErr = ""; // last play() rejection / element error (for the readout) const FADE_MS = 500; +// Audio is a 0–10 level dial (was an on/off toggle). 0 = silent. The level is the +// MASTER gain the per-scale soundtrack / crossfade is scaled by. +function audioLevel() { return +$("audio").value || 0; } +function audioVol() { return HEFScrub.clamp01(audioLevel() / 10); } +function updateAudioLevelLabel() { + const el = $("audio-level-val"); + if (el) el.textContent = String(audioLevel()); +} + // The element currently carrying the most signal — for the diagnostic readout, // which used to assume a single `aud`. With two crossfading elements either may be // the audible one, so report on whichever is louder (ties → aud). @@ -1042,11 +1051,11 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; } // --- Live audio status readout (diagnostic) — so a "no sound" report is legible --- const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"]; function audioStatusText() { - if (!$("audio").checked) return "audio: off"; + if (audioLevel() === 0) return "audio: off (level 0)"; const s = ring && ring.scales[ringIndex]; const url = soundtrackUrl(); const el = activeAudEl(); - const head = `ON · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`; + const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`; if (!url) return "audio: " + head + " ← no soundtrack URL for this scale"; if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`; if (audLastErr) return `audio: ${head} · ${audLastErr}`; @@ -1058,9 +1067,10 @@ function updateAudioStatus() { if (!el) return; el.textContent = audioStatusText(); const a = activeAudEl(); - const playing = $("audio").checked && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr; + const on = audioLevel() > 0; + const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr; el.classList.toggle("playing", playing); - el.classList.toggle("blocked", $("audio").checked && (!soundtrackUrl() || !!a.error || !!audLastErr)); + el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr)); } aud.addEventListener("error", updateAudioStatus); audB.addEventListener("error", updateAudioStatus); @@ -1132,22 +1142,23 @@ function assignElements(urlLo, urlHi) { return { elLo: aud, elHi: audB }; } -// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac). -// No-op when the Audio toggle is off. +// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac), +// both scaled by the master audio level. No-op when the level is 0. function blendAudio(loIndex, frac) { - if (!$("audio").checked) return; + const lvl = audioVol(); + if (lvl === 0) return; const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1); const g = HEFScrub.crossfadeGains(frac); const { elLo, elHi } = assignElements(urlLo, urlHi); - ensurePlaying(elLo, urlLo, g.from); - ensurePlaying(elHi, urlHi, g.to); + ensurePlaying(elLo, urlLo, g.from * lvl); + ensurePlaying(elHi, urlHi, g.to * lvl); updateAudioStatus(); } -// At rest on a single altitude: the element already holding it stays at full gain, -// the other fades out. Off → both fade to silence. +// At rest on a single altitude: the element holding it plays at the master level, the +// other fades out. Level 0 → both fade to silence. function restAudio(index) { - if (!$("audio").checked) { + if (audioLevel() === 0) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); return; @@ -1155,7 +1166,7 @@ function restAudio(index) { const url = scaleAudioUrl(index); const active = (audB.dataset.url === url) ? audB : aud; const idle = active === audB ? aud : audB; - ensurePlaying(active, url, 1); + ensurePlaying(active, url, audioVol()); fadeVolume(idle, 0, FADE_MS); updateAudioStatus(); } @@ -1188,16 +1199,18 @@ async function main() { renderScaleReadout(); // Sliders stream on "input"; the toggles fire on "change". for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced); - // Audio toggle: start/stop the soundtrack SYNCHRONOUSLY in this gesture so it - // unlocks + plays on Safari (which blocks play() outside a user gesture). - $("audio").addEventListener("change", () => { applyAudio(); debounced(); }); - // Video toggle: the FIRST time video turns on, bring audio with it (one flip = the - // full experience). Audio toggled on its own first stays audio-only. The audio is - // set + played in THIS gesture so it unlocks on Safari. + // Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this + // input gesture so the first raise from 0 unlocks + plays on Safari (which blocks + // play() outside a user gesture). + $("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); }); + updateAudioLevelLabel(); + // Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one + // flip = the full experience at a gentle level). If audio is already raised, leave it. + // Set + played in THIS gesture so it unlocks on Safari. $("visual").addEventListener("change", () => { if ($("visual").checked && !videoEverOn) { videoEverOn = true; - if (!$("audio").checked) { $("audio").checked = true; applyAudio(); } + if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); } } debounced(); }); diff --git a/simulator/static/index.html b/simulator/static/index.html index 4b1d3ed..90de2e9 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -37,10 +37,9 @@ Video -