diff --git a/player/audio.py b/player/audio.py new file mode 100644 index 0000000..a2e706a --- /dev/null +++ b/player/audio.py @@ -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")) diff --git a/player/content.py b/player/content.py deleted file mode 100644 index 035808d..0000000 --- a/player/content.py +++ /dev/null @@ -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 diff --git a/player/controls.py b/player/controls.py index 9e66b43..49c43c8 100644 --- a/player/controls.py +++ b/player/controls.py @@ -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) diff --git a/player/state.py b/player/state.py index 85fad1e..d4a70b8 100644 --- a/player/state.py +++ b/player/state.py @@ -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, ) diff --git a/simulator/app.py b/simulator/app.py index 3f718b5..eaa8e6f 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -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") diff --git a/tests/test_player_audio.py b/tests/test_player_audio.py new file mode 100644 index 0000000..c65bc68 --- /dev/null +++ b/tests/test_player_audio.py @@ -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 diff --git a/tests/test_player_content.py b/tests/test_player_content.py deleted file mode 100644 index 70b3d4e..0000000 --- a/tests/test_player_content.py +++ /dev/null @@ -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") diff --git a/tests/test_player_controls.py b/tests/test_player_controls.py index 1c9f910..794723f 100644 --- a/tests/test_player_controls.py +++ b/tests/test_player_controls.py @@ -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}) diff --git a/tests/test_player_state.py b/tests/test_player_state.py index 9611da6..5072352 100644 --- a/tests/test_player_state.py +++ b/tests/test_player_state.py @@ -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 diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index 94f7330..14f094a 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -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