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