fix(audio): Loading Universe splash; single-element in-gesture audio (Safari); first-video-on couples audio

Operator follow-ups:
- Remove the tap-to-start wall; show a 'Loading Universe…' splash until all media
  preloaded (main() awaits preloadAllMedia + a progress bar), then reveal the app.
- Both Video + Audio toggles default OFF (black + silent at start).
- FIX audio still silent on real Safari: the A/B-crossfade-after-gesture didn't
  unlock on Safari (priming srcless elements doesn't unlock them). Use ONE <audio>
  element played SYNCHRONOUSLY inside the toggle's click — the reliable Safari
  unlock; later altitude swaps reuse the unlocked element.
- First time Video turns on, also turn Audio on (one flip = full experience), set+
  played in the same gesture; Audio-on-first stays audio-only.
- Soundtrack follows Altitude via the single element (brief fade swap).
- Verified in headless Chromium AND WebKit: coupling works, soundtrack plays,
  video-off blanks, zero JS errors. 290 tests + 5 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 13:19:15 -07:00
parent fddb6d65d4
commit 30ec0c9c26
5 changed files with 163 additions and 119 deletions
@@ -13,6 +13,16 @@
> hide), and the soundtrack now plays on Safari/iOS (audio elements unlocked
> synchronously inside the start gesture). Sections below describe the fuller
> off/soundtrack/white-noise design; v1 live = off/soundtrack.
>
> **Startup revision (session 0022):** the tap-to-start wall is **removed** — the app
> opens behind a **"Loading Universe…"** splash (shown until all media is preloaded
> and the experience can run smoothly), then both toggles start **off** (black +
> silent). Autoplay is satisfied without a wall: a **single** `<audio>` element is
> played **synchronously inside the toggle's own click** (the reliable Safari unlock),
> replacing the A/B-crossfade-after-gesture approach that failed on real Safari. The
> first time **Video** is turned on, **Audio** turns on with it (one flip = the full
> experience); turning Audio on first stays audio-only. Altitude changes swap the
> single element's source with a brief fade.
**Extends / reframes:** [`2026-06-26-networked-control-surface-design.md`](./2026-06-26-networked-control-surface-design.md)
§3 (control inventory) and §5 (server contract) — its bundled **Content** selector
is split into two orthogonal controls. Everything else in that spec (server-of-truth
+75 -75
View File
@@ -141,7 +141,10 @@ async function preloadAllMedia(concurrency = 4) {
let done = 0;
const total = files.length;
const chip = preloadChip();
const tick = () => { if (chip) chip.textContent = `⬇ caching clips ${done}/${total}`; };
const tick = () => {
if (chip) chip.textContent = `⬇ caching clips ${done}/${total}`;
setLoadingProgress(done, total); // drive the "Loading Universe…" bar
};
tick();
let i = 0;
async function worker() {
@@ -519,7 +522,6 @@ async function update() {
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
readout.textContent = JSON.stringify(data, null, 2);
applyAudio(data.render.audio); // reconcile the audio layer
if (!data.render.video.shown) {
// Blank the screen. Cover with #black AND hide the video layers themselves —
// the <video>/<canvas> are GPU-composited and can otherwise show through the
@@ -637,6 +639,7 @@ async function advance(delta) {
} finally {
busy = false;
update();
applyAudio(); // soundtrack follows the Altitude dial (crossfade to the new scale)
}
}
@@ -909,107 +912,104 @@ function devLiveReload() {
}, 1000);
}
// --- Audio layer: two <audio> elements, gain-crossfaded (audio spec §7/§8) ---
const audA = $("audA"), audB = $("audB");
let audActive = audA, audIdle = audB; // which element is currently audible
let audUrl = null; // the url currently playing (null = silence)
let audioReady = false; // unlocked after the first user gesture (autoplay policy)
const XFADE_MS = 600; // ~0.6s gain crossfade — "live", no busy ack (§7)
// --- Audio layer: ONE <audio> element, started in-gesture (Safari-safe) ---
// Real Safari/iOS only "unlocks" an <audio> element when play() is called
// SYNCHRONOUSLY inside a user gesture (Chrome is lenient; Safari is not). So we use
// a single element and start it directly from the Video/Audio toggle's click — not
// via the async server roundtrip; once unlocked it can be re-played programmatically
// (e.g. on an altitude change). audio on = the current scale's soundtrack.
const aud = $("aud");
let audUrl = null; // soundtrack url currently loaded (null = silent)
const FADE_MS = 500;
function fadeVolume(el, to, ms) {
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.
const from = el.volume, steps = Math.max(1, Math.round(ms / 30));
let i = 0;
return new Promise((resolve) => {
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); resolve(); }
}, 30);
});
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(); }
}, 30);
}
// Reconcile the audio layer to render.audio: crossfade the active element to the
// new url (or fade to silence for off). White-noise and soundtrack are the same
// mechanism — only the url differs; an unchanged url is a no-op so altitude
// re-rolls on the same scale don't restart the bed.
async function applyAudio(audio) {
const url = audio ? audio.url : null;
if (url === audUrl) return;
audUrl = url;
if (!audioReady) return; // deferred until the start gesture (§8)
if (!url) { // fade current out to silence
await fadeVolume(audActive, 0, XFADE_MS);
audActive.pause();
return;
}
audIdle.src = mediaUrl(url); // absolute /media/... passes through mediaUrl
audIdle.volume = 0;
audIdle.play().catch(() => {}); // start (fire-and-forget — awaiting can hang)
await Promise.all([fadeVolume(audIdle, 1, XFADE_MS), fadeVolume(audActive, 0, XFADE_MS)]);
audActive.pause();
[audActive, audIdle] = [audIdle, audActive]; // swap roles
}
// The current altitude's soundtrack url (the ring carries each scale's `audio`),
// resolved client-side so the start gesture can unlock the elements synchronously.
// The current altitude's soundtrack url (the ring carries each scale's `audio`).
function soundtrackUrl() {
const s = ring && ring.scales[ringIndex];
return s && s.audio ? "/media/audio/" + s.audio : null;
}
// Browsers block autoplay until a user gesture. The renderer is launched
// full-screen by the operator; a one-time tap unlocks audio, then it follows
// state (audio spec §8 — the documented launch step).
function showStartGesture() {
if (audioReady || document.getElementById("start-gesture")) return;
const ov = document.createElement("div");
ov.id = "start-gesture";
ov.textContent = "▶ tap to start sound";
ov.addEventListener("click", () => {
audioReady = true;
ov.remove();
// Safari unlocks an <audio> element ONLY when play() is called inside the
// user gesture itself — not in a later async continuation (Chrome is lenient;
// Safari/iOS is not). Prime BOTH A/B elements now, synchronously, at volume 0,
// so the async applyAudio() crossfades that follow are allowed to play.
const url = soundtrackUrl();
if (url) for (const el of [audA, audB]) {
try {
el.src = mediaUrl(url);
el.volume = 0;
const pr = el.play();
if (pr) pr.then(() => el.pause()).catch(() => {});
} catch (_) { /* priming is best-effort */ }
}
audUrl = null; // force the next applyAudio to (re)load
update(); // re-apply current state, now with audio
}, { once: true });
document.body.appendChild(ov);
// Load `url` into the single element and fade it in. Call this SYNCHRONOUSLY from
// the toggle's click the first time so play() is allowed; later calls reuse the
// now-unlocked element.
function playUrl(url) {
aud.src = mediaUrl(url);
aud.volume = 0;
const pr = aud.play();
if (pr) pr.catch(() => {});
fadeVolume(aud, 1, FADE_MS);
}
// Reconcile audio to the Audio toggle + current altitude: on → the scale's
// soundtrack, off → silence. Idempotent on an unchanged url (so a same-scale pool
// re-roll doesn't restart it). MUST be called synchronously from the toggle gesture
// the first time it plays.
function applyAudio() {
const url = $("audio").checked ? soundtrackUrl() : null;
if (url === audUrl) return;
audUrl = url;
if (!url) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); return; }
if (aud.paused || !aud.src) playUrl(url); // start fresh
else fadeVolume(aud, 0, 250, () => playUrl(url)); // dip out, then swap + fade in
}
// "Loading Universe…" splash — hidden once media is preloaded; reflects progress.
function setLoadingProgress(done, total) {
const fill = document.getElementById("loading-fill");
if (fill && total) fill.style.width = Math.round((done / total) * 100) + "%";
}
function hideLoading() {
const el = document.getElementById("loading");
if (!el) return;
el.classList.add("done");
setTimeout(() => el.remove(), 700);
}
let videoEverOn = false; // has Video ever been switched on this session?
async function main() {
devLiveReload();
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
await loadData();
await landScale(); // pick the initial scale's pool member before first render
preloadAllMedia(); // background: cache every clip in memory for instant altitude swaps
buildDial(); // draw the altitude knob from the ring's scales
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
renderScaleReadout();
// Sliders stream on "input"; the Video/Audio toggles fire on "change" (matches
// the Dev Mode switch and is the reliable checkbox event across browsers).
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
for (const id of ["visual", "audio"]) $(id).addEventListener("change", debounced);
showStartGesture(); // one-time tap unlocks audio (browser autoplay policy, §8)
// 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.
$("visual").addEventListener("change", () => {
if ($("visual").checked && !videoEverOn) {
videoEverOn = true;
if (!$("audio").checked) { $("audio").checked = true; applyAudio(); }
}
debounced();
});
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
dial.addEventListener("pointerdown", onDialDown);
window.addEventListener("pointermove", onDialMove);
window.addEventListener("pointerup", onDialUp);
dial.addEventListener("wheel", onWheel, { passive: false });
$("stage").addEventListener("wheel", onWheel, { passive: false });
update();
update(); // render the initial state (both toggles off → black, silent)
await preloadAllMedia(); // download all media, updating the loading bar
hideLoading(); // experience is ready to run smoothly
}
main();
+8 -3
View File
@@ -7,13 +7,18 @@
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="loading">
<div class="loading-inner">
<div class="loading-title">Loading Universe<span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
<main>
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<audio id="audA" loop preload="auto"></audio>
<audio id="audB" loop preload="auto"></audio>
<audio id="aud" loop preload="auto"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
@@ -26,7 +31,7 @@
<fieldset>
<legend>Output</legend>
<label class="dev-switch" for="visual">
<input type="checkbox" id="visual" checked />
<input type="checkbox" id="visual" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Video</span>
</label>
+22 -6
View File
@@ -115,12 +115,28 @@ input[type=range], select { width: 100%; }
.dev-anno .anno-meta { color: #678; }
.dev-anno .anno-empty { color: #567; font-style: italic; }
/* One-time tap-to-start overlay unlocks audio under the browser autoplay
policy (audio spec §8). Removed after the first gesture. */
#start-gesture {
/* "Loading Universe…" splash shown until all media is preloaded and the
experience is ready to run smoothly, then faded out. */
#loading {
position: fixed; inset: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.82); color: #fff;
font: 600 28px/1.2 system-ui, sans-serif; letter-spacing: 0.02em;
cursor: pointer; user-select: none;
background: radial-gradient(ellipse at center, #0a1230 0%, #02030a 70%);
color: #cfe3ff; user-select: none;
transition: opacity 0.6s ease;
}
#loading.done { opacity: 0; pointer-events: none; }
.loading-inner { display: flex; flex-direction: column; align-items: center; gap: 1.1rem; }
.loading-title { font: 600 30px/1.2 system-ui, sans-serif; letter-spacing: 0.04em; }
.loading-dots::after {
content: ""; animation: loading-dots 1.4s steps(4, end) infinite;
}
@keyframes loading-dots { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } 100% { content: ""; } }
.loading-bar {
width: 260px; height: 4px; border-radius: 2px;
background: rgba(255, 255, 255, 0.12); overflow: hidden;
}
#loading-fill {
height: 100%; width: 0%; border-radius: 2px;
background: linear-gradient(90deg, #4e9cff, #9af);
transition: width 0.25s ease;
}
+48 -35
View File
@@ -1,7 +1,9 @@
"""Playwright E2E for the audio layer (audio spec §9). Skips cleanly when
Playwright or its browser binary is absent (the headless dev box) the unit +
contract tiers cover the logic; this asserts the rendered <audio> element src and
playback survives a Visual fade. Runs at the PPE stage where a browser exists.
"""Playwright E2E for the audio + Video/Audio toggles (audio spec §9). Skips
cleanly when Playwright or its browser binary is absent.
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
the WIRING (toggleplay, the first-video-onaudio coupling, video-off blanking)
real Safari/iOS autoplay and real-GPU compositing still need a device by-ear/eye check.
Install: pip install -e '.[e2e]' && python -m playwright install chromium
"""
@@ -12,7 +14,6 @@ from contextlib import closing
import pytest
# Skip the whole module unless Playwright is importable.
pytest.importorskip("playwright.sync_api")
@@ -50,50 +51,62 @@ def page(app_url):
with sync_playwright() as p:
try:
# Headless Chromium blocks media playback without a real audio device;
# this flag lets play() actually start so `paused` is meaningful (the
# spec §9 asserts playback via element src/paused, not a waveform).
# headless blocks media playback without a device; this flag lets play()
# actually start so `paused` is meaningful (spec §9 asserts src/paused)
browser = p.chromium.launch(args=["--autoplay-policy=no-user-gesture-required"])
except Exception as exc: # no browser binary installed
pytest.skip(f"chromium not available: {exc}")
pg = browser.new_page()
pg.goto(app_url)
pg.click("#start-gesture") # unlock autoplay
# wait out the "Loading Universe…" splash (it overlays + blocks clicks)
pg.wait_for_selector("#loading", state="detached", timeout=60000)
yield pg
browser.close()
def _toggle(page, which):
"""Click the visible toggle track for #visual / #audio (the input is hidden)."""
page.click(f"label[for='{which}'] .dev-switch-track")
def test_app_starts_blanked_and_silent(page):
# both toggles default off → black screen, no audio (no tap-to-start wall)
assert page.is_checked("#visual") is False
assert page.is_checked("#audio") is False
page.wait_for_selector("#black:not(.hidden)")
assert page.evaluate("document.getElementById('aud').paused") is True
def test_audio_toggle_plays_the_current_scale_soundtrack(page):
# Audio on (toggle) → the current altitude's soundtrack (cosmos at index 0) plays
page.click("label[for='audio'] .dev-switch-track")
_toggle(page, "audio")
page.wait_for_function(
"['audA','audB'].some(id => {"
" const a = document.getElementById(id);"
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused;"
"})"
"(() => { const a = document.getElementById('aud');"
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused; })()"
)
def test_video_toggle_off_blanks_the_screen_and_hides_layers(page):
page.click("label[for='visual'] .dev-switch-track")
page.wait_for_selector("#black:not(.hidden)") # black cover shown
# the video layers themselves are hidden too (GPU-composite-proof blanking);
# wait for the opacity transition to settle to 0
def test_first_video_on_also_enables_audio(page):
_toggle(page, "visual") # first video-on couples audio
page.wait_for_function("document.getElementById('audio').checked === true")
page.wait_for_function(
"(() => { const a = document.getElementById('aud'); return !!a.src && !a.paused; })()"
)
page.wait_for_selector("#black.hidden", state="attached") # video showing (black hidden=display:none)
def test_audio_before_video_stays_audio_only(page):
_toggle(page, "audio") # audio on first
page.wait_for_function("!document.getElementById('aud').paused")
assert page.is_checked("#visual") is False # video untouched
page.wait_for_selector("#black:not(.hidden)") # still blanked
def test_video_off_blanks_after_being_on(page):
_toggle(page, "visual") # on
page.wait_for_selector("#black.hidden", state="attached")
_toggle(page, "visual") # off
page.wait_for_selector("#black:not(.hidden)")
# the GPU-composited video layers are hidden too (settle the opacity transition)
page.wait_for_function(
"['vid','paint'].every(id => getComputedStyle(document.getElementById(id)).opacity === '0')"
)
def test_audio_survives_video_off(page):
page.click("label[for='audio'] .dev-switch-track")
page.wait_for_function(
"['audA','audB'].some(id => !document.getElementById(id).paused "
"&& document.getElementById(id).src)"
)
page.click("label[for='visual'] .dev-switch-track")
page.wait_for_selector("#black:not(.hidden)")
playing = page.evaluate(
"['audA','audB'].some(id => "
"{const a = document.getElementById(id); return !a.paused && !!a.src;})"
)
assert playing is True # audio keeps playing with video off