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:
@@ -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, ...]:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user