diff --git a/player/ring.py b/player/ring.py index 88cdf51..4fbecbe 100644 --- a/player/ring.py +++ b/player/ring.py @@ -63,6 +63,7 @@ class Scale: id: str clip_id: str pool: tuple[str, ...] = () + audio: str = "" # per-scale soundtrack, relative to /media/audio/ (audio spec §5.1) @property def members(self) -> tuple[str, ...]: diff --git a/simulator/build_pool_manifest.py b/simulator/build_pool_manifest.py index ef3d35d..38148d7 100644 --- a/simulator/build_pool_manifest.py +++ b/simulator/build_pool_manifest.py @@ -48,6 +48,16 @@ POOLS: dict[str, list[str]] = { # Ring order (large -> small, wraps): cosmos -> orbit -> coast -> reef -> abyss -> cosmos. RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"] +# Per-scale soundtrack (audio spec §5.1) — the production-pass output of +# simulator/build_audio_media.py, served at /media/audio/. +SCALE_AUDIO: dict[str, str] = { + "cosmos": "cosmos/pillars.loop.mp3", + "orbit": "orbit/spaceamb.loop.mp3", + "coast": "coast/waves.loop.mp3", + "reef": "reef/soundscape.loop.mp3", + "abyss": "abyss/whale.loop.mp3", +} + PD = "public-domain (US Gov, 17 U.S.C. §105)" PD_NPS = "public-domain (NPS, no © — 17 U.S.C. §105)" CCBY_STSCI = "CC-BY-class — credit NASA, ESA, STScI" @@ -357,7 +367,10 @@ def build_manifest() -> dict: for scale in RING_ORDER: for clip_id in POOLS[scale]: clips.append(_clip_entry(scale, clip_id)) - scales = [{"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s]} for s in RING_ORDER] + scales = [ + {"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s], "audio": SCALE_AUDIO[s]} + for s in RING_ORDER + ] transitions = [] n = len(RING_ORDER) for i in range(n): diff --git a/simulator/clips.py b/simulator/clips.py index 00d2b49..33b2db1 100644 --- a/simulator/clips.py +++ b/simulator/clips.py @@ -82,7 +82,7 @@ def _scale_from_dict(s: dict[str, Any]) -> Scale: """ pool = tuple(s.get("pool", [])) clip_id = s.get("clip_id") or (pool[0] if pool else "") - return Scale(id=s["id"], clip_id=clip_id, pool=pool) + return Scale(id=s["id"], clip_id=clip_id, pool=pool, audio=s.get("audio", "")) def load_ring(path: str | Path) -> ScaleRing | None: @@ -119,6 +119,7 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict: "id": s.id, "clip_id": s.clip_id, "title": titles.get(s.clip_id, s.id), + "audio": s.audio, "pool": [ {"clip_id": cid, "title": titles.get(cid, cid)} for cid in s.members diff --git a/simulator/sample_media/manifest.json b/simulator/sample_media/manifest.json index bf62d17..24a8c13 100644 --- a/simulator/sample_media/manifest.json +++ b/simulator/sample_media/manifest.json @@ -2371,7 +2371,8 @@ "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf" - ] + ], + "audio": "cosmos/pillars.loop.mp3" }, { "id": "orbit", @@ -2380,7 +2381,8 @@ "orbit_planetearth", "orbit_crewobs", "orbit_bluemarble" - ] + ], + "audio": "orbit/spaceamb.loop.mp3" }, { "id": "coast", @@ -2390,7 +2392,8 @@ "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach" - ] + ], + "audio": "coast/waves.loop.mp3" }, { "id": "reef", @@ -2401,7 +2404,8 @@ "reef_hawkfish", "reef_snapper", "reef_coralspacific" - ] + ], + "audio": "reef/soundscape.loop.mp3" }, { "id": "abyss", @@ -2410,7 +2414,8 @@ "abyss_wow", "abyss_midwaterexp", "abyss_hiding" - ] + ], + "audio": "abyss/whale.loop.mp3" } ], "transitions": [ diff --git a/tests/test_player_ring.py b/tests/test_player_ring.py index ddb1a87..4841234 100644 --- a/tests/test_player_ring.py +++ b/tests/test_player_ring.py @@ -240,3 +240,10 @@ def test_pick_clip_id_clamps_out_of_range_r(): assert pick_clip_id(s, -5.0) == "a" # below 0 clamps to first assert pick_clip_id(s, 1.0) == "c" # 1.0 must not index past the end assert pick_clip_id(s, 999.0) == "c" + + +def test_scale_carries_an_optional_audio_path(): + s = Scale(id="coast", clip_id="coast_birdrock", audio="coast/waves.loop.mp3") + assert s.audio == "coast/waves.loop.mp3" + # back-compat: audio defaults to empty + assert Scale(id="x", clip_id="x").audio == "" diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index 0e39a70..94f7330 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -162,9 +162,9 @@ def ring_manifest_path(tmp_path): ], "ring": { "scales": [ - {"id": "cosmos", "clip_id": "cosmos"}, - {"id": "forest", "clip_id": "forest"}, - {"id": "abyss", "clip_id": "abyss"}, + {"id": "cosmos", "clip_id": "cosmos", "audio": "cosmos/pillars.loop.mp3"}, + {"id": "forest", "clip_id": "forest", "audio": "forest/amb.loop.mp3"}, + {"id": "abyss", "clip_id": "abyss", "audio": "abyss/whale.loop.mp3"}, ], "transitions": [ {"file": "transitions/cosmos-forest.mp4", "model": "placeholder"}, @@ -190,6 +190,15 @@ def test_ring_returns_scales_and_transitions(ring_client): assert len(data["transitions"]) == 3 +def test_ring_exposes_each_scales_audio(ring_client): + body = ring_client.get("/api/ring").json() + by_id = {s["id"]: s for s in body["scales"]} + # every scale advertises its soundtrack path (audio spec §5.1) + assert by_id["cosmos"]["audio"] == "cosmos/pillars.loop.mp3" + assert by_id["abyss"]["audio"].endswith(".mp3") + assert "audio" in by_id["forest"] + + def test_ring_advance_inward_one_step(ring_client): resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}) assert resp.status_code == 200