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) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 17:02:19 -07:00
parent ea530b3df6
commit e862fb498b
4 changed files with 67 additions and 29 deletions
+25 -4
View File
@@ -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 010 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
+34 -21
View File
@@ -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 010 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();
});
+3 -4
View File
@@ -37,10 +37,9 @@
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Video</span>
</label>
<label class="dev-switch" for="audio">
<input type="checkbox" id="audio" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Audio</span>
<label class="audio-level">Audio
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
</fieldset>
+5
View File
@@ -97,6 +97,11 @@ input[type=range], select { width: 100%; }
.dev-switch input:checked + .dev-switch-track .dev-switch-thumb { transform: translateX(16px); background: #4e9; }
.dev-switch input:focus-visible + .dev-switch-track { outline: 2px solid #9af; outline-offset: 1px; }
.dev-switch-label { color: #9af; font-weight: 600; letter-spacing: 0.3px; }
/* Audio level dial (010): label · slider · value on one row, matching the switch look. */
.audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem;
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
win, so the panel truly collapses when Dev Mode is off. */