feat(audio): Scale.audio field + per-scale soundtrack wiring through the manifest

Scale gains audio:str='' (back-compat). clips.py reads/serializes it; ring_to_dict
exposes it; build_pool_manifest emits SCALE_AUDIO per scale. Manifest regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 07:42:40 -07:00
parent 39fb9108db
commit 1f9b2a0bec
6 changed files with 46 additions and 10 deletions
+1
View File
@@ -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, ...]:
+14 -1
View File
@@ -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/<path>.
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):
+2 -1
View File
@@ -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
+10 -5
View File
@@ -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": [
+7
View File
@@ -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 == ""
+12 -3
View File
@@ -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