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:
+25
-7
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user