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,
)
+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")
+63
View File
@@ -0,0 +1,63 @@
"""Unit tests for the pure Visual×Audio resolver (audio spec §4/§6)."""
import pytest
from player.audio import (
AUDIO_SOURCES,
VISUAL_POSITIONS,
AudioResolution,
resolve_audio,
resolve_audio_url,
resolve_visual,
)
NOISE = "/media/audio/noise/pink.mp3"
def test_visual_on_off():
assert resolve_visual("on") is True
assert resolve_visual("off") is False
def test_visual_rejects_unknown():
with pytest.raises(ValueError):
resolve_visual("flicker")
def test_visual_positions_are_on_off():
assert VISUAL_POSITIONS == frozenset({"on", "off"})
def test_soundtrack_resolves_to_the_current_scales_asset():
url = resolve_audio_url("soundtrack", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
assert url == "/media/audio/coast/waves.loop.mp3"
def test_white_noise_resolves_to_the_global_bed_independent_of_altitude():
url = resolve_audio_url("white_noise", scale_audio="coast/waves.loop.mp3", noise_url=NOISE)
assert url == NOISE
def test_off_resolves_to_silence():
assert resolve_audio_url("off", scale_audio="coast/waves.loop.mp3", noise_url=NOISE) is None
def test_soundtrack_with_no_scale_asset_is_silent():
assert resolve_audio_url("soundtrack", scale_audio="", noise_url=NOISE) is None
def test_resolve_audio_marks_only_soundtrack_altitude_coupled():
r = resolve_audio("soundtrack", scale_audio="reef/soundscape.loop.mp3", noise_url=NOISE)
assert isinstance(r, AudioResolution)
assert r.source == "soundtrack" and r.altitude_coupled is True
assert r.url == "/media/audio/reef/soundscape.loop.mp3"
assert resolve_audio("white_noise", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
assert resolve_audio("off", scale_audio="x/y.mp3", noise_url=NOISE).altitude_coupled is False
def test_resolve_audio_url_rejects_unknown_source():
with pytest.raises(ValueError):
resolve_audio_url("podcast", scale_audio="", noise_url=NOISE)
def test_music_is_not_a_v1_source():
assert "music" not in AUDIO_SOURCES
-34
View File
@@ -1,34 +0,0 @@
import pytest
from player.content import AUDIO_SOURCES, ContentResolution, resolve_content
def test_audio_sources_are_the_four_distinct_sources():
assert AUDIO_SOURCES == frozenset({"none", "white_noise", "music", "audio_track"})
# The §6 table, row by row: position -> (audio_source, video).
@pytest.mark.parametrize(
"position,audio_source,video",
[
("off", "none", False),
("white_noise", "white_noise", False),
("music", "music", False),
("audio_track", "audio_track", False),
("video", "none", True),
("music_video", "music", True),
("audio_video", "audio_track", True),
],
)
def test_resolve_content_matches_spec_table(position, audio_source, video):
assert resolve_content(position) == ContentResolution(audio_source=audio_source, video=video)
def test_off_is_void_state_black_and_silent():
r = resolve_content("off")
assert r.video is False and r.audio_source == "none"
def test_resolve_content_rejects_unknown_position():
with pytest.raises(ValueError):
resolve_content("bogus")
+24 -13
View File
@@ -1,27 +1,36 @@
import pytest
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS
from player.controls import (
Controls,
CONTENT_POSITIONS,
ControlsError,
validate_controls,
parse_controls,
)
def test_content_positions_are_the_seven_from_the_spec():
assert CONTENT_POSITIONS == frozenset(
{"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"}
)
def test_visual_and_audio_enums_are_the_v1_positions():
assert VISUAL_POSITIONS == frozenset({"on", "off"})
assert AUDIO_SOURCES == frozenset({"off", "soundtrack", "white_noise"})
def test_valid_controls_pass_validation():
c = Controls(content="video", left=0, right=4, dark=2, light=2, volume=3, brightness=4)
c = Controls(visual="on", audio="soundtrack", left=0, right=4, dark=2, light=2,
volume=3, brightness=4)
validate_controls(c) # does not raise
def test_invalid_content_position_rejected():
c = Controls(content="bogus", left=0, right=0, dark=0, light=0, volume=0, brightness=0)
def test_invalid_visual_position_rejected():
c = Controls(visual="flicker", audio="off", left=0, right=0, dark=0, light=0,
volume=0, brightness=0)
with pytest.raises(ControlsError):
validate_controls(c)
def test_invalid_audio_source_rejected():
# "music" is a reserved/deferred dial position, not a v1 source
c = Controls(visual="on", audio="music", left=0, right=0, dark=0, light=0,
volume=0, brightness=0)
with pytest.raises(ControlsError):
validate_controls(c)
@@ -29,7 +38,8 @@ def test_invalid_content_position_rejected():
@pytest.mark.parametrize("field", ["left", "right", "dark", "light", "volume", "brightness"])
@pytest.mark.parametrize("bad", [-1, 5, True])
def test_out_of_range_or_non_int_knob_rejected(field, bad):
kwargs = dict(content="off", left=0, right=0, dark=0, light=0, volume=0, brightness=0)
kwargs = dict(visual="off", audio="off", left=0, right=0, dark=0, light=0,
volume=0, brightness=0)
kwargs[field] = bad
with pytest.raises(ControlsError):
validate_controls(Controls(**kwargs))
@@ -37,17 +47,18 @@ def test_out_of_range_or_non_int_knob_rejected(field, bad):
def test_parse_controls_from_mapping():
c = parse_controls(
{"content": "music_video", "left": 1, "right": 2, "dark": 3, "light": 0, "volume": 2, "brightness": 1}
{"visual": "on", "audio": "white_noise", "left": 1, "right": 2, "dark": 3,
"light": 0, "volume": 2, "brightness": 1}
)
assert c == Controls("music_video", 1, 2, 3, 0, 2, 1)
assert c == Controls("on", "white_noise", 1, 2, 3, 0, 2, 1)
def test_parse_controls_rejects_unknown_keys():
with pytest.raises(ControlsError):
parse_controls({"content": "off", "left": 0, "right": 0, "dark": 0,
parse_controls({"visual": "off", "audio": "off", "left": 0, "right": 0, "dark": 0,
"light": 0, "volume": 0, "brightness": 0, "bogus": 1})
def test_parse_controls_rejects_missing_keys():
with pytest.raises(ControlsError):
parse_controls({"content": "off", "left": 0})
parse_controls({"visual": "off", "left": 0})
+35 -25
View File
@@ -14,47 +14,57 @@ class FakeClip:
LIB = [FakeClip("base-a"), FakeClip("base-b")]
def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2):
return Controls(content, left, right, dark, light, volume, brightness)
def _controls(visual="on", audio="off", left=0, right=0, dark=0, light=0,
volume=2, brightness=2):
return Controls(visual, audio, left, right, dark, light, volume, brightness)
def test_first_update_to_video_fades_in_from_black():
p = Player(LIB)
t = p.update(_controls(content="video"))
t = p.update(_controls(visual="on"))
assert t.kind == TransitionKind.FADE_FROM_BLACK
assert t.playback.clip_id == "base-a"
assert t.playback.content.video is True
assert t.playback.video is True
def test_off_from_video_fades_to_black_and_silences():
def test_visual_off_from_video_fades_to_black():
p = Player(LIB)
p.update(_controls(content="video"))
t = p.update(_controls(content="off"))
p.update(_controls(visual="on"))
t = p.update(_controls(visual="off"))
assert t.kind == TransitionKind.FADE_TO_BLACK
assert t.playback.clip_id is None
assert t.playback.content.audio_source == "none"
assert t.playback.video is False
assert t.playback.audio_source == "off"
def test_audio_survives_visual_off():
# white noise + black is now reachable (the gap the bundled dial omitted)
p = Player(LIB)
t = p.update(_controls(visual="off", audio="white_noise"))
assert t.playback.video is False
assert t.playback.audio_source == "white_noise"
def test_no_change_yields_none_transition():
p = Player(LIB)
p.update(_controls(content="video", left=1))
t = p.update(_controls(content="video", left=1))
p.update(_controls(visual="on", left=1))
t = p.update(_controls(visual="on", left=1))
assert t.kind == TransitionKind.NONE
def test_grade_change_is_a_live_update_not_a_crossfade():
# design §4.3: the Dark/Light grade is a continuous runtime op
p = Player(LIB)
p.update(_controls(content="video", dark=0, light=0))
t = p.update(_controls(content="video", dark=4, light=0))
p.update(_controls(visual="on", dark=0, light=0))
t = p.update(_controls(visual="on", dark=4, light=0))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.plan.grade.tone == -1.0
def test_overlay_change_is_a_live_update():
p = Player(LIB)
p.update(_controls(content="video", left=0))
t = p.update(_controls(content="video", left=4))
p.update(_controls(visual="on", left=0))
t = p.update(_controls(visual="on", left=4))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.plan.overlay.level == 4
@@ -63,8 +73,8 @@ def test_dream_change_is_a_live_update_not_a_crossfade():
# Right-axis dream reframe (session 0013): the Right dream is a deterministic
# LIVE filter now, so changing it no longer crossfades — only a clip swap does.
p = Player(LIB)
p.update(_controls(content="video", right=0))
t = p.update(_controls(content="video", right=4))
p.update(_controls(visual="on", right=0))
t = p.update(_controls(visual="on", right=4))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.plan.dream.strength == 4
assert t.playback.plan.dream.intensity == 1.0
@@ -72,43 +82,43 @@ def test_dream_change_is_a_live_update_not_a_crossfade():
def test_volume_only_change_is_a_live_update():
p = Player(LIB)
p.update(_controls(content="video", volume=1))
t = p.update(_controls(content="video", volume=4))
p.update(_controls(visual="on", volume=1))
t = p.update(_controls(visual="on", volume=4))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.volume == 4
def test_audio_source_change_while_black_is_a_live_update():
p = Player(LIB)
p.update(_controls(content="white_noise"))
t = p.update(_controls(content="music"))
p.update(_controls(visual="off", audio="white_noise"))
t = p.update(_controls(visual="off", audio="soundtrack"))
assert t.kind == TransitionKind.LIVE_UPDATE
assert t.playback.content.audio_source == "music"
assert t.playback.audio_source == "soundtrack"
def test_injected_base_chooser_is_used():
p = Player(LIB, choose_base=lambda lib: lib[1])
t = p.update(_controls(content="video"))
t = p.update(_controls(visual="on"))
assert t.playback.clip_id == "base-b"
def test_empty_library_with_video_raises():
p = Player([])
with pytest.raises(ValueError):
p.update(_controls(content="video"))
p.update(_controls(visual="on"))
def test_off_with_empty_library_is_fine():
p = Player([])
# levels at 0 match the initial black state, so this is a no-op transition
t = p.update(_controls(content="off", volume=0, brightness=0))
t = p.update(_controls(visual="off", volume=0, brightness=0))
assert t.kind == TransitionKind.NONE # already black at init
assert t.playback.clip_id is None
def test_off_with_levels_from_black_is_a_live_update():
p = Player([])
t = p.update(_controls(content="off", volume=3, brightness=2))
t = p.update(_controls(visual="off", volume=3, brightness=2))
assert t.kind == TransitionKind.LIVE_UPDATE # black->black, levels set
assert t.playback.clip_id is None
assert t.playback.volume == 3
+44 -9
View File
@@ -28,8 +28,9 @@ def client(manifest_path):
return TestClient(create_app(manifest_path=manifest_path))
def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2):
return dict(content=content, left=left, right=right, dark=dark,
def _controls(visual="on", audio="off", left=0, right=0, dark=0, light=0,
volume=2, brightness=2):
return dict(visual=visual, audio=audio, left=left, right=right, dark=dark,
light=light, volume=volume, brightness=brightness)
@@ -41,13 +42,42 @@ def test_alteration_returns_the_engine_plan(client):
assert data["plan"]["dream"]["strength"] == 2
assert data["plan"]["dream"]["intensity"] == 0.5
assert data["plan"]["grade"]["tone"] == -1.0
assert data["content"]["video"] is True
assert data["render"]["video"]["shown"] is True
def test_alteration_honors_off_as_black(client):
resp = client.post("/api/alteration", json={"controls": _controls(content="off")})
data = resp.json()
assert data["content"]["video"] is False
def test_alteration_returns_render_video_and_audio(client):
body = client.post("/api/alteration", json={"controls": _controls()}).json()
assert body["render"]["video"]["shown"] is True
assert body["render"]["audio"] == {"source": "off", "url": None, "altitude_coupled": False}
def test_alteration_visual_off_hides_video(client):
body = client.post("/api/alteration", json={"controls": _controls(visual="off")}).json()
assert body["render"]["video"]["shown"] is False
def test_alteration_white_noise_is_altitude_independent(ring_client):
for idx in (0, 1, 2):
body = ring_client.post("/api/alteration", json={
"controls": _controls(audio="white_noise"), "altitude_index": idx,
}).json()
assert body["render"]["audio"]["url"] == "/media/audio/noise/pink.mp3"
assert body["render"]["audio"]["altitude_coupled"] is False
def test_alteration_soundtrack_couples_to_the_given_altitude(ring_client):
# ring fixture order: cosmos(0), forest(1), abyss(2) — each carries its audio
body = ring_client.post("/api/alteration", json={
"controls": _controls(audio="soundtrack"), "altitude_index": 0,
}).json()
a = body["render"]["audio"]
assert a["source"] == "soundtrack" and a["altitude_coupled"] is True
assert a["url"] == "/media/audio/cosmos/pillars.loop.mp3"
# a different altitude resolves a different soundtrack url
body2 = ring_client.post("/api/alteration", json={
"controls": _controls(audio="soundtrack"), "altitude_index": 2,
}).json()
assert body2["render"]["audio"]["url"] == "/media/audio/abyss/whale.loop.mp3"
def test_alteration_accepts_calibration(client):
@@ -62,8 +92,13 @@ def test_alteration_rejects_out_of_range_knob(client):
assert resp.status_code == 422
def test_alteration_rejects_bad_content(client):
resp = client.post("/api/alteration", json={"controls": _controls(content="banana")})
def test_alteration_rejects_bad_audio(client):
resp = client.post("/api/alteration", json={"controls": _controls(audio="music")})
assert resp.status_code == 422
def test_alteration_rejects_bad_visual(client):
resp = client.post("/api/alteration", json={"controls": _controls(visual="dim")})
assert resp.status_code == 422