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
+25 -7
View File
@@ -26,8 +26,7 @@ from player.alteration import (
plan_alteration,
render_plan_to_dict,
)
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
from player.ring import (
DEFAULT_FAST_SPIN_THRESHOLD,
advance_ring,
@@ -40,6 +39,9 @@ STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
DEFAULT_MANIFEST = MEDIA_DIR / "manifest.json"
# The global white-noise bed (audio spec §5.2): synthesized, altitude-independent.
NOISE_URL = "/media/audio/noise/pink.mp3"
# Per-process boot token: changes on every server restart so the dev live-reload
# (below) also fires when Python code changes (which needs a restart), not just
# when a static asset's mtime changes.
@@ -95,7 +97,8 @@ def _rev_file(file: str) -> str:
class ControlsModel(BaseModel):
content: str
visual: str
audio: str
left: int = Field(ge=0, le=4)
right: int = Field(ge=0, le=4)
dark: int = Field(ge=0, le=4)
@@ -112,6 +115,7 @@ class CalibrationModel(BaseModel):
class AlterationRequest(BaseModel):
controls: ControlsModel
altitude_index: int = 0
calibration: Optional[CalibrationModel] = None
@@ -166,8 +170,10 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
@app.post("/api/alteration")
def api_alteration(req: AlterationRequest):
c = req.controls
if c.content not in CONTENT_POSITIONS:
raise HTTPException(status_code=422, detail=f"invalid content {c.content!r}")
if c.visual not in VISUAL_POSITIONS:
raise HTTPException(status_code=422, detail=f"invalid visual {c.visual!r}")
if c.audio not in AUDIO_SOURCES:
raise HTTPException(status_code=422, detail=f"invalid audio {c.audio!r}")
coord = Coordinate(c.left, c.right, c.dark, c.light)
cal = (
Calibration(
@@ -179,10 +185,22 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
else DEFAULT_CALIBRATION
)
plan = plan_alteration(coord, cal)
content = resolve_content(c.content)
# Resolve the soundtrack url against the CURRENT altitude (server-side, audio
# spec §6.1). White-noise/off ignore the scale; soundtrack follows it.
scale_audio = ""
if app.state.ring is not None:
scale_audio = scale_at(app.state.ring, req.altitude_index).audio
audio = resolve_audio(c.audio, scale_audio=scale_audio, noise_url=NOISE_URL)
return {
"plan": render_plan_to_dict(plan),
"content": {"audio_source": content.audio_source, "video": content.video},
"render": {
"video": {"shown": resolve_visual(c.visual)},
"audio": {
"source": audio.source,
"url": audio.url,
"altitude_coupled": audio.altitude_coupled,
},
},
}
@app.get("/dev/version")