fix(audio): off/on Audio + Video toggles; fix Safari autoplay + GPU video-off

Operator-driven live-UI revision:
- Video + Audio are now Dev-Mode-style on/off toggles (Audio on = soundtrack);
  white-noise deferred (removed from the live control; pure machinery kept).
- FIX video-off not blanking: the <video>/<canvas> are GPU-composited and could
  paint above the auto-z #black on a real GPU — give #black z-index + its own
  layer AND hide the video layers (opacity 0) on video-off.
- FIX no soundtrack on Safari/iOS: Safari unlocks <audio> only when play() runs
  INSIDE the user gesture; the start gesture now primes both A/B elements
  synchronously, so the async crossfades are allowed to play.
- Toggles wired on 'change' (matches the Dev Mode switch).
- player/audio.py AUDIO_SOURCES -> {off, soundtrack}; tests + E2E updated.
- Verified in headless Chromium AND WebKit: audio plays, video-off blanks both
  layers, zero JS errors. 288 tests + 3 Playwright E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 12:55:50 -07:00
parent aa76cd1c9f
commit 2722949805
16 changed files with 146 additions and 106 deletions
+22 -21
View File
@@ -63,36 +63,37 @@ def page(app_url):
browser.close()
def test_white_noise_loads_the_noise_asset(page):
page.select_option("#audio", "white_noise")
page.wait_for_function(
"['audA','audB'].some(id => "
"document.getElementById(id).src.includes('/media/audio/noise/pink.mp3'))"
)
def test_soundtrack_loads_the_current_scale_asset(page):
# initial altitude is cosmos (index 0) → soundtrack resolves its scale asset,
# not the noise bed (the altitude-coupling itself is covered at the API tier)
page.select_option("#audio", "soundtrack")
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")
page.wait_for_function(
"['audA','audB'].some(id => {"
" const s = document.getElementById(id).src;"
" return /\\/media\\/audio\\/.+\\.mp3/.test(s) && !s.includes('noise');"
" const a = document.getElementById(id);"
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused;"
"})"
)
def test_visual_off_keeps_audio_and_fades_video(page):
page.select_option("#audio", "white_noise")
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
page.wait_for_function(
"['audA','audB'].some(id => "
"document.getElementById(id).src.includes('/media/audio/noise/pink.mp3'))"
"['vid','paint'].every(id => getComputedStyle(document.getElementById(id)).opacity === '0')"
)
page.uncheck("#visual")
page.wait_for_selector("#black:not(.hidden)") # video faded to black
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 survives the video fade
assert playing is True # audio keeps playing with video off
+12 -16
View File
@@ -10,9 +10,6 @@ from player.audio import (
resolve_visual,
)
NOISE = "/media/audio/noise/pink.mp3"
def test_visual_on_off():
assert resolve_visual("on") is True
assert resolve_visual("off") is False
@@ -27,37 +24,36 @@ def test_visual_positions_are_on_off():
assert VISUAL_POSITIONS == frozenset({"on", "off"})
def test_audio_sources_are_off_and_soundtrack():
assert AUDIO_SOURCES == frozenset({"off", "soundtrack"})
def test_soundtrack_resolves_to_the_current_scales_asset():
url = resolve_audio_url("soundtrack", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
url = resolve_audio_url("soundtrack", scale_audio="coast/waves.loop.mp3")
assert url == "/media/audio/coast/waves.loop.mp3"
def test_white_noise_resolves_to_the_global_bed_independent_of_altitude():
url = resolve_audio_url("white_noise", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
assert url == NOISE
def test_off_resolves_to_silence():
assert resolve_audio_url("off", scale_audio="coast/waves.loop.mp3", noise_url=NOISE) is None
assert resolve_audio_url("off", scale_audio="coast/waves.loop.mp3") is None
def test_soundtrack_with_no_scale_asset_is_silent():
assert resolve_audio_url("soundtrack", scale_audio="", noise_url=NOISE) is None
assert resolve_audio_url("soundtrack", scale_audio="") is None
def test_resolve_audio_marks_only_soundtrack_altitude_coupled():
r = resolve_audio("soundtrack", scale_audio="reef/soundscape.loop.mp3", noise_url=NOISE)
r = resolve_audio("soundtrack", scale_audio="reef/soundscape.loop.mp3")
assert isinstance(r, AudioResolution)
assert r.source == "soundtrack" and r.altitude_coupled is True
assert r.url == "/media/audio/reef/soundscape.loop.mp3"
assert resolve_audio("white_noise", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
assert resolve_audio("off", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
assert resolve_audio("off", scale_audio="x/y.mp3").altitude_coupled is False
def test_resolve_audio_url_rejects_unknown_source():
with pytest.raises(ValueError):
resolve_audio_url("podcast", scale_audio="", noise_url=NOISE)
resolve_audio_url("white_noise", scale_audio="") # deferred, not a v1 source
def test_music_is_not_a_v1_source():
def test_music_and_white_noise_are_not_v1_sources():
assert "music" not in AUDIO_SOURCES
assert "white_noise" not in AUDIO_SOURCES
+3 -3
View File
@@ -11,7 +11,7 @@ from player.controls import (
def test_visual_and_audio_enums_are_the_v1_positions():
assert VISUAL_POSITIONS == frozenset({"on", "off"})
assert AUDIO_SOURCES == frozenset({"off", "soundtrack", "white_noise"})
assert AUDIO_SOURCES == frozenset({"off", "soundtrack"})
def test_valid_controls_pass_validation():
@@ -47,10 +47,10 @@ def test_out_of_range_or_non_int_knob_rejected(field, bad):
def test_parse_controls_from_mapping():
c = parse_controls(
{"visual": "on", "audio": "white_noise", "left": 1, "right": 2, "dark": 3,
{"visual": "on", "audio": "soundtrack", "left": 1, "right": 2, "dark": 3,
"light": 0, "volume": 2, "brightness": 1}
)
assert c == Controls("on", "white_noise", 1, 2, 3, 0, 2, 1)
assert c == Controls("on", "soundtrack", 1, 2, 3, 0, 2, 1)
def test_parse_controls_rejects_unknown_keys():
+4 -4
View File
@@ -38,11 +38,11 @@ def test_visual_off_from_video_fades_to_black():
def test_audio_survives_visual_off():
# white noise + black is now reachable (the gap the bundled dial omitted)
# soundtrack + black is reachable (a gap the bundled dial omitted)
p = Player(LIB)
t = p.update(_controls(visual="off", audio="white_noise"))
t = p.update(_controls(visual="off", audio="soundtrack"))
assert t.playback.video is False
assert t.playback.audio_source == "white_noise"
assert t.playback.audio_source == "soundtrack"
def test_no_change_yields_none_transition():
@@ -90,7 +90,7 @@ def test_volume_only_change_is_a_live_update():
def test_audio_source_change_while_black_is_a_live_update():
p = Player(LIB)
p.update(_controls(visual="off", audio="white_noise"))
p.update(_controls(visual="off", audio="off"))
t = p.update(_controls(visual="off", audio="soundtrack"))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.audio_source == "soundtrack"
+5 -7
View File
@@ -56,13 +56,11 @@ def test_alteration_visual_off_hides_video(client):
assert body["render"]["video"]["shown"] is False
def test_alteration_white_noise_is_altitude_independent(ring_client):
for idx in (0, 1, 2):
body = ring_client.post("/api/alteration", json={
"controls": _controls(audio="white_noise"), "altitude_index": idx,
}).json()
assert body["render"]["audio"]["url"] == "/media/audio/noise/pink.mp3"
assert body["render"]["audio"]["altitude_coupled"] is False
def test_alteration_off_is_silent(ring_client):
body = ring_client.post("/api/alteration", json={
"controls": _controls(audio="off"), "altitude_index": 0,
}).json()
assert body["render"]["audio"] == {"source": "off", "url": None, "altitude_coupled": False}
def test_alteration_soundtrack_couples_to_the_given_altitude(ring_client):