diff --git a/pyproject.toml b/pyproject.toml index 332425d..f1c8d88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,9 @@ sim = [ "uvicorn[standard]>=0.29", "httpx>=0.27", ] +# Playwright E2E browser tests (audio spec §9). Install with the browser binary: +# pip install -e '.[e2e]' && python -m playwright install chromium +# The E2E suite skips cleanly when Playwright/Chromium is absent (headless box). +e2e = [ + "pytest-playwright>=0.4", +] diff --git a/simulator/static/app.js b/simulator/static/app.js index e08f09b..82a97d8 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -908,15 +908,17 @@ let audioReady = false; // unlocked after the first user gesture const XFADE_MS = 600; // ~0.6s gain crossfade — "live", no busy ack (§7) function fadeVolume(el, to, ms) { - const from = el.volume, t0 = performance.now(); + // 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) => { - function tick(now) { - const k = Math.min(1, (now - t0) / ms); - el.volume = from + (to - from) * k; - if (k < 1) requestAnimationFrame(tick); - else resolve(); - } - requestAnimationFrame(tick); + 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); }); } @@ -936,7 +938,7 @@ async function applyAudio(audio) { } audIdle.src = mediaUrl(url); // absolute /media/... passes through mediaUrl audIdle.volume = 0; - await audIdle.play().catch(() => {}); + 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 @@ -946,13 +948,15 @@ async function applyAudio(audio) { // 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", async () => { + ov.addEventListener("click", () => { + // A click anywhere on the page grants Chrome's autoplay permission + // document-wide for the session, so later programmatic play() is allowed — + // no per-element priming needed (priming srcless elements only pollutes state). audioReady = true; - // prime both elements within the gesture so later play() calls are allowed - for (const el of [audA, audB]) { try { await el.play(); el.pause(); } catch (_) {} } ov.remove(); audUrl = null; // force the next applyAudio to (re)load update(); // re-apply current state, now with audio diff --git a/tests/test_e2e_audio.py b/tests/test_e2e_audio.py new file mode 100644 index 0000000..7798584 --- /dev/null +++ b/tests/test_e2e_audio.py @@ -0,0 +1,98 @@ +"""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