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:
@@ -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
|
||||
|
||||
+34
-21
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 (0–10): 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. */
|
||||
|
||||
Reference in New Issue
Block a user