feat(audio): Visual×Audio Python contract — resolver, controls, state, API

- player/audio.py: pure resolve_visual / resolve_audio_url / resolve_audio (§4/§6)
- retire player/content.py (7-way table) + its test
- player/controls.py: serial contract content -> visual + audio
- player/state.py: Playback carries video:bool + audio_source:str
- simulator/app.py: /api/alteration validates visual+audio, returns render.video
  + server-resolved render.audio.url from altitude_index; NOISE_URL constant
- tests reframed; full suite 285 passed, 2 skipped

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 07:46:22 -07:00
parent 1f9b2a0bec
commit 29c6d822bd
10 changed files with 274 additions and 150 deletions
+54
View File
@@ -0,0 +1,54 @@
"""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. White-noise is a global,
altitude-independent bed; soundtrack follows the single Altitude dial; off is
silence. `music` is a reserved, deferred source — not in v1."""
from __future__ import annotations
from dataclasses import dataclass
VISUAL_POSITIONS = frozenset({"on", "off"})
# v1 audio sources. "music" is reserved/deferred (no assets) and intentionally absent.
AUDIO_SOURCES = frozenset({"off", "soundtrack", "white_noise"})
@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, noise_url: str) -> str | None:
"""The audio url for (source, current altitude): soundtrack → the current
scale's asset (or None if none authored); white_noise → the global bed; 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
if source == "white_noise":
return noise_url
# 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, noise_url: 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, noise_url=noise_url)
return AudioResolution(source=source, url=url, altitude_coupled=(source == "soundtrack"))