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
+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