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
+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 (toggle→play, the first-video-on→audio 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