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>
72 lines
2.3 KiB
Python
72 lines
2.3 KiB
Python
"""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 the audio spec §3: a Visual toggle (on/off) + an
|
|
Audio source selector (off/soundtrack; white-noise/music deferred), 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
|
|
|
|
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS
|
|
|
|
KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness")
|
|
KNOB_MIN = 0
|
|
KNOB_MAX = 4
|
|
|
|
|
|
class ControlsError(ValueError):
|
|
"""Raised when a Controls payload is structurally invalid."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Controls:
|
|
visual: str
|
|
audio: str
|
|
left: int
|
|
right: int
|
|
dark: int
|
|
light: int
|
|
volume: int
|
|
brightness: int
|
|
|
|
|
|
def validate_controls(c: Controls) -> None:
|
|
"""Raise ControlsError if the payload is structurally invalid."""
|
|
if c.visual not in VISUAL_POSITIONS:
|
|
raise ControlsError(
|
|
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)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ControlsError(f"knob {name} must be an int, got {value!r}")
|
|
if not (KNOB_MIN <= value <= KNOB_MAX):
|
|
raise ControlsError(
|
|
f"knob {name}={value} out of range {KNOB_MIN}..{KNOB_MAX}"
|
|
)
|
|
|
|
|
|
def parse_controls(data: dict) -> Controls:
|
|
"""Build a validated Controls from a decoded mapping, rejecting unknown or
|
|
missing keys."""
|
|
known = {f.name for f in fields(Controls)}
|
|
unknown = set(data) - known
|
|
if unknown:
|
|
raise ControlsError(f"unknown keys: {sorted(unknown)}")
|
|
missing = known - set(data)
|
|
if missing:
|
|
raise ControlsError(f"missing keys: {sorted(missing)}")
|
|
c = Controls(**data)
|
|
validate_controls(c)
|
|
return c
|