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"))
-42
View File
@@ -1,42 +0,0 @@
"""Resolve the 7-way content dial into an audio source + video on/off (§6).
The single source of truth for design §6's table: which audio source plays and
whether the projector shows video, for each dial position.
"""
from __future__ import annotations
from dataclasses import dataclass
# The four distinct audio sources. "white_noise" is generated at runtime;
# "music" is the public-domain classical pool; "audio_track" is the clip's own
# field audio; "none" is silence.
AUDIO_SOURCES = frozenset({"none", "white_noise", "music", "audio_track"})
@dataclass(frozen=True)
class ContentResolution:
audio_source: str
video: bool
# Design §6, one row per dial position.
_TABLE = {
"off": ContentResolution("none", False),
"white_noise": ContentResolution("white_noise", False),
"music": ContentResolution("music", False),
"audio_track": ContentResolution("audio_track", False),
"video": ContentResolution("none", True),
"music_video": ContentResolution("music", True),
"audio_video": ContentResolution("audio_track", True),
}
def resolve_content(position: str) -> ContentResolution:
"""Map a content-dial position to its audio source and video flag (§6)."""
try:
return _TABLE[position]
except KeyError:
raise ValueError(
f"unknown content position {position!r}; expected one of {sorted(_TABLE)}"
) from None
+15 -12
View File
@@ -1,20 +1,17 @@
"""Control-panel state: the data shape read from the Arduino over serial.
This is the serial-contract payload shared with sub-project 4 (firmware). It
models the full panel from design §6/§7: the 7-way content dial, the four
experience knobs (0..4), and the two intensity levels (volume, brightness).
The wire framing itself is the separate 3<->4 serial contract; this module is
the *decoded* form.
models the full panel from the audio spec §3: a Visual toggle (on/off) + an
Audio source selector (off/soundtrack/white_noise), the four experience knobs
(0..4), and the two intensity levels (volume, brightness). The wire framing
itself is the separate 3<->4 serial contract; this module is the *decoded* form.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
# The seven positions of the content dial (design §6).
CONTENT_POSITIONS = frozenset(
{"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"}
)
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS
KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness")
KNOB_MIN = 0
@@ -27,7 +24,8 @@ class ControlsError(ValueError):
@dataclass(frozen=True)
class Controls:
content: str
visual: str
audio: str
left: int
right: int
dark: int
@@ -38,10 +36,15 @@ class Controls:
def validate_controls(c: Controls) -> None:
"""Raise ControlsError if the payload is structurally invalid."""
if c.content not in CONTENT_POSITIONS:
if c.visual not in VISUAL_POSITIONS:
raise ControlsError(
f"invalid content position {c.content!r}; "
f"expected one of {sorted(CONTENT_POSITIONS)}"
f"invalid visual position {c.visual!r}; "
f"expected one of {sorted(VISUAL_POSITIONS)}"
)
if c.audio not in AUDIO_SOURCES:
raise ControlsError(
f"invalid audio source {c.audio!r}; "
f"expected one of {sorted(AUDIO_SOURCES)}"
)
for name in KNOB_FIELDS:
value = getattr(c, name)
+14 -8
View File
@@ -20,7 +20,7 @@ from typing import Callable, Optional
from hef.selection import Coordinate
from player.alteration import RenderPlan, plan_alteration
from player.content import ContentResolution, resolve_content
from player.audio import resolve_visual
from player.controls import Controls
@@ -34,11 +34,14 @@ class TransitionKind:
@dataclass(frozen=True)
class Playback:
"""What should currently be playing."""
"""What should currently be playing. Visual and audio are orthogonal (audio
spec §2): `video` is whether the projector shows footage, `audio_source` is
the independent audio dial (off / soundtrack / white_noise)."""
clip_id: Optional[str] # None = black walls
plan: Optional[RenderPlan] # None when black
content: ContentResolution
video: bool # whether footage is shown (Visual on/off)
audio_source: str # the Audio dial source (off/soundtrack/white_noise)
volume: int
brightness: int
@@ -58,7 +61,8 @@ def _first(library):
_BLACK = Playback(
clip_id=None,
plan=None,
content=ContentResolution("none", False),
video=False,
audio_source="off",
volume=0,
brightness=0,
)
@@ -78,12 +82,13 @@ class Player:
self._current = _BLACK
def _resolve(self, controls: Controls) -> Playback:
content = resolve_content(controls.content)
if not content.video:
video = resolve_visual(controls.visual)
if not video:
return Playback(
clip_id=None,
plan=None,
content=content,
video=False,
audio_source=controls.audio,
volume=controls.volume,
brightness=controls.brightness,
)
@@ -92,7 +97,8 @@ class Player:
return Playback(
clip_id=clip.id,
plan=plan_alteration(coord),
content=content,
video=True,
audio_source=controls.audio,
volume=controls.volume,
brightness=controls.brightness,
)