2722949805
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>
54 lines
2.2 KiB
Python
54 lines
2.2 KiB
Python
"""Resolve the orthogonal Visual × Audio controls into render outputs (audio spec
|
||
§4/§6). The single source of truth for: whether the projector shows video, and
|
||
which audio url plays for a given (audio source, current altitude).
|
||
|
||
Replaces player/content.py's 7-way bundled table. Soundtrack follows the single
|
||
Altitude dial; off is silence. `white_noise` and `music` are deferred sources —
|
||
not in v1 (the live control is a simple Audio on/off toggle where on=soundtrack)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
VISUAL_POSITIONS = frozenset({"on", "off"})
|
||
# v1 audio sources. The live UI is an Audio on/off toggle (on=soundtrack).
|
||
# "white_noise" and "music" are deferred (no live control) and intentionally absent.
|
||
AUDIO_SOURCES = frozenset({"off", "soundtrack"})
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AudioResolution:
|
||
source: str
|
||
url: str | None
|
||
altitude_coupled: bool
|
||
|
||
|
||
def resolve_visual(position: str) -> bool:
|
||
"""Visual on/off → whether the renderer shows video (vs fade-to-black)."""
|
||
if position not in VISUAL_POSITIONS:
|
||
raise ValueError(
|
||
f"unknown visual position {position!r}; expected one of {sorted(VISUAL_POSITIONS)}"
|
||
)
|
||
return position == "on"
|
||
|
||
|
||
def resolve_audio_url(source: str, *, scale_audio: str) -> str | None:
|
||
"""The audio url for (source, current altitude): soundtrack → the current
|
||
scale's asset (or None if none authored); off → None. `scale_audio` is the ring
|
||
scale's `audio` path (relative to /media/audio/)."""
|
||
if source not in AUDIO_SOURCES:
|
||
raise ValueError(
|
||
f"unknown audio source {source!r}; expected one of {sorted(AUDIO_SOURCES)}"
|
||
)
|
||
if source == "off":
|
||
return None
|
||
# soundtrack — couple to the current altitude's asset
|
||
return f"/media/audio/{scale_audio}" if scale_audio else None
|
||
|
||
|
||
def resolve_audio(source: str, *, scale_audio: str) -> AudioResolution:
|
||
"""The full render.audio view: source, resolved url, and whether it follows the
|
||
Altitude dial (true only for soundtrack)."""
|
||
url = resolve_audio_url(source, scale_audio=scale_audio)
|
||
return AudioResolution(source=source, url=url, altitude_coupled=(source == "soundtrack"))
|