feat(audio): Music becomes a crossfading playlist of 5 mellow synth tracks

Replace the single ethereal loop with the operator's chosen set — Music for
Manatees, Dewdrop Fantasy, Eastminster, Overheat, Concentration (Kevin MacLeod,
CC BY 4.0, drumless). The Music source now plays one track and every 5 min
crossfades to a random OTHER track (pure HEFScrub.pickNextIndex, never an
immediate repeat), altitude-independent, reusing the two audio elements; both
primed in-gesture for Safari. Intervals injectable via window.__hefMusicRotateMs
/ __hefMusicXfadeMs.

Media loudness-normalized + transcoded under the 25MB LFS limit (build_audio_media
MUSIC), committed via LFS; ethereal loop retired. CC-BY attribution added to
credits.html. Docs updated (music-candidate-pool = final set; audio-candidate-pool
ethereal section superseded).

+2 node unit (pickNextIndex), +2 e2e (music-playlist), audio-source spec updated.
40 unit + 49 e2e green; live probe confirms playback + rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 16:22:28 -07:00
parent 969236b3f7
commit 4f4f21a767
16 changed files with 246 additions and 56 deletions
+46 -17
View File
@@ -1,42 +1,62 @@
"""Production pass for the 5 per-altitude soundtracks + the single Music loop
(audio spec §5.3; Music layer per docs/audio-candidate-pool.md). The audio
analogue of build_pool_manifest.py --media: read the sourced ambience clips
(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless
loudness-normalized loop.
"""Production pass for the 5 per-altitude soundtracks + the Music playlist
(audio spec §5.3; Music per docs/music-candidate-pool.md). The audio analogue of
build_pool_manifest.py --media: read the sourced clips (re-downloadable per the
candidate-pool docs) and make each web-ready.
Media is gitignored; this script is the reproducible record. Outputs land under
simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/...
Run: python simulator/build_audio_media.py
- Soundtracks: seamless loudness-normalized loops -> audio/<scale>/<name>.loop.mp3.
- Music: the mellow drumless synth playlist (Kevin MacLeod, CC BY 4.0) — NOT looped
(the client crossfades between tracks). Each is loudness-normalized and transcoded
to a bitrate that keeps it under the ~25MB git-LFS/nginx limit (ambient tolerates
it) -> audio/music/<name>.mp3. Put the downloaded original at audio/music/<name>.src.mp3
(URLs below) first; this transcodes it.
(White-noise is deferred — the live Audio control is a simple on/off soundtrack
toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's
wanted, but no noise bed is built here.)
Media is committed via git-LFS; this script is the reproducible record.
Run: python -m simulator.build_audio_media
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from tools.pipeline.audio_run import process_soundtrack
from tools.pipeline.run import resolve_ffmpeg
AUDIO = Path(__file__).parent / "sample_media" / "audio"
# scale -> (sourced raw file, production output file), both under audio/<scale>/.
# Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks).
#
# The `music` entry is the Audio-dropdown "Music" layer (docs/audio-candidate-pool.md
# § Music): ONE ethereal ambient loop played the same at every altitude — not a
# per-altitude soundtrack. It lands at audio/music/ethereal.loop.mp3, served at
# /media/audio/music/ethereal.loop.mp3 (client fallback: app.js MUSIC_FALLBACK).
SOURCES: dict[str, tuple[str, str]] = {
"cosmos": ("pillars.mp3", "pillars.loop.mp3"),
"orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"),
"coast": ("waves.mp3", "waves.loop.mp3"),
"reef": ("soundscape.mp3", "soundscape.loop.mp3"),
"abyss": ("whale.wav", "whale.loop.mp3"),
"music": ("ethereal.mp3", "ethereal.loop.mp3"),
}
# Music playlist (docs/music-candidate-pool.md § Round 4): name -> (incompetech source
# URL, target bitrate). Kevin MacLeod, CC BY 4.0 — credited on credits.html. Drumless,
# chord-moving ambient; the operator's picks. Longer tracks get a lower bitrate to stay
# under the LFS size limit. Client playlist: app.js MUSIC_PLAYLIST.
_KM = "https://incompetech.com/music/royalty-free/mp3-royaltyfree/"
MUSIC: dict[str, tuple[str, str]] = {
"manatees": (_KM + "Music%20for%20Manatees.mp3", "96k"),
"dewdrop": (_KM + "Dewdrop%20Fantasy.mp3", "80k"),
"eastminster": (_KM + "Eastminster.mp3", "128k"),
"overheat": (_KM + "Overheat.mp3", "128k"),
"concentration": (_KM + "Concentration.mp3", "80k"),
}
def _transcode(src: Path, dst: Path, bitrate: str, ff: str) -> Path:
"""Loudness-normalize + transcode to mp3 at `bitrate` (no looping)."""
subprocess.run(
[ff, "-y", "-hide_banner", "-loglevel", "error", "-i", str(src),
"-af", "loudnorm", "-c:a", "libmp3lame", "-b:a", bitrate, "-ar", "44100", str(dst)],
check=True,
)
return dst
def main() -> None:
for scale, (raw, out) in SOURCES.items():
@@ -47,6 +67,15 @@ def main() -> None:
dst = process_soundtrack(src, AUDIO / scale / out)
print(f" produced {dst.relative_to(AUDIO.parent)}")
ff = resolve_ffmpeg()
for name, (url, bitrate) in MUSIC.items():
src = AUDIO / "music" / f"{name}.src.mp3"
if not src.exists():
print(f" SKIP music/{name}: source missing ({src}) — download {url}")
continue
dst = _transcode(src, AUDIO / "music" / f"{name}.mp3", bitrate, ff)
print(f" produced {dst.relative_to(AUDIO.parent)} @ {bitrate}")
if __name__ == "__main__":
main()
+11 -8
View File
@@ -2,7 +2,7 @@ import { test, expect, Page } from "@playwright/test";
// The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md):
// the old 010 "Audio" slider becomes "Volume", and a new Audio <select> chooses the
// source — None / Soundtrack / Music. Music is ONE ethereal loop played the same at
// source — None / Soundtrack / Music. Music is a rotating playlist played the same at
// every altitude (no per-altitude crossfade).
async function enter(page: Page) {
@@ -47,27 +47,30 @@ test.describe("Audio source dropdown + Volume rename", () => {
await expect(page.locator("#audio")).toBeEnabled();
});
test("Music plays one ethereal loop that persists across an altitude change", async ({ page }) => {
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
test("Music plays a playlist track that persists across an altitude change", async ({ page }) => {
await enter(page);
await selectSource(page, "music");
let urls = await audUrls(page);
expect(urls.a).toContain("music/ethereal.loop.mp3");
// Move the dial: Music is altitude-independent, so #aud stays on the music loop
// (it does NOT switch to a per-altitude soundtrack).
expect(urls.a).toMatch(MUSIC_RE);
// Move the dial: Music is altitude-independent, so #aud stays on a music track
// (it does NOT switch to a per-altitude soundtrack). Default rotate interval is
// 5 min, so it's the SAME track here.
await wheelOnStage(page, 120);
await wheelOnStage(page, 120);
await page.waitForTimeout(300);
urls = await audUrls(page);
expect(urls.a).toContain("music/ethereal.loop.mp3");
expect(urls.a).toMatch(MUSIC_RE);
});
test("Soundtrack loads a per-altitude ambience, not the music loop", async ({ page }) => {
test("Soundtrack loads a per-altitude ambience, not a music track", async ({ page }) => {
await enter(page);
await selectSource(page, "soundtrack");
await page.waitForTimeout(200);
const urls = await audUrls(page);
const loaded = urls.a + "|" + urls.b;
expect(loaded).toContain("/audio/");
expect(loaded).not.toContain("music/ethereal.loop.mp3");
expect(loaded).not.toContain("/audio/music/");
});
});
@@ -0,0 +1,57 @@
import { test, expect, Page } from "@playwright/test";
// Music is a rotating playlist that crossfades to a random OTHER track on a timer
// (docs/music-candidate-pool.md). The e2e injects short rotate/xfade intervals via
// window.__hefMusicRotateMs / __hefMusicXfadeMs before load.
async function boot(page: Page, rotateMs: number, xfadeMs: number) {
await page.addInitScript(([r, x]) => {
(window as any).__hefMusicRotateMs = r;
(window as any).__hefMusicXfadeMs = x;
}, [rotateMs, xfadeMs]);
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
// The track on the louder (active) music element.
async function activeTrack(page: Page): Promise<string> {
return await page.evaluate(() => {
const a = document.querySelector("#aud") as HTMLAudioElement;
const b = document.querySelector("#aud-b") as HTMLAudioElement;
const el = b.volume > a.volume ? b : a;
return el.dataset.url || "";
});
}
test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => {
await boot(page, 1200, 300);
await page.selectOption("#audio-source", "music");
await page.waitForTimeout(200);
const first = await activeTrack(page);
expect(first).toMatch(/audio\/music\/\w+\.mp3/);
// Wait past one rotation (1200ms) + the crossfade (300ms) + buffer.
await page.waitForFunction((f) => {
const a = document.querySelector("#aud") as HTMLAudioElement;
const b = document.querySelector("#aud-b") as HTMLAudioElement;
const el = b.volume > a.volume ? b : a;
return (el.dataset.url || "") !== f && (el.dataset.url || "").includes("/audio/music/");
}, first, { timeout: 6000 });
const second = await activeTrack(page);
expect(second).toMatch(/audio\/music\/\w+\.mp3/);
expect(second).not.toBe(first); // rotated to another track
});
test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => {
await boot(page, 900, 250);
await page.selectOption("#audio-source", "music");
const seen = new Set<string>();
for (let i = 0; i < 3; i++) {
await page.waitForTimeout(1300);
seen.add(await activeTrack(page));
}
// Over 3 rotations we should have heard more than one distinct track.
expect([...seen].every((u) => /audio\/music\/\w+\.mp3/.test(u))).toBe(true);
expect(seen.size).toBeGreaterThan(1);
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+58 -15
View File
@@ -1520,15 +1520,28 @@ function scaleAudioUrl(index) {
// The current resting altitude's soundtrack url (kept for the status readout).
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
// The single continuous "Music" loop — one ethereal ambient track, played the
// same at every altitude. Fallback constant mirrors SCALE_AUDIO_FALLBACK so it
// works even against a server predating the manifest's music entry; routed through
// mediaBase() for R2 (static) / local /media (dev), like scaleAudioUrl.
const MUSIC_FALLBACK = "music/ethereal.loop.mp3";
function musicUrl() { return mediaBase() + "audio/" + MUSIC_FALLBACK; }
// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each
// other (not one looping track), altitude-independent — the same relaxing rotation at
// every altitude. Every MUSIC_ROTATE_MS it crossfades to a random OTHER track (never an
// immediate repeat), reusing the two audio elements. Intervals are injectable for tests.
const MUSIC_PLAYLIST = [
"music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3",
"music/overheat.mp3", "music/concentration.mp3",
];
const MUSIC_ROTATE_MS = (typeof window !== "undefined" && window.__hefMusicRotateMs) || 5 * 60 * 1000;
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
function musicTrackUrl(i) {
return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)];
}
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
let musicEl = null; // the element currently carrying the active music track
let musicTimer = null; // rotation timer
// The url the active source would play right now (for the diagnostic readout).
function currentAudioUrl() { return audioSource() === "music" ? musicUrl() : soundtrackUrl(); }
function currentAudioUrl() {
if (audioSource() === "music") return musicTrackUrl(musicIdx < 0 ? 0 : musicIdx);
return soundtrackUrl();
}
// Start (once) and set the volume of one element to a url. Safari needs the first
// play() inside a user gesture; later programmatic plays reuse the unlocked element.
@@ -1561,19 +1574,47 @@ function assignElements(urlLo, urlHi) {
return { elLo: aud, elHi: audB };
}
// Both elements fade to silence (None, or Volume 0).
// Both elements fade to silence (None, or Volume 0). Also ends any music rotation.
function silenceAll() {
stopMusic();
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
}
// Play the single continuous Music loop on `aud` at `vol`; fade the other out. No
// altitude crossfade — the same track plays the same at every altitude.
function playMusic(vol) {
ensurePlaying(aud, musicUrl(), vol);
fadeVolume(audB, 0, FADE_MS);
function scheduleMusicRotate() {
clearTimeout(musicTimer);
musicTimer = setTimeout(rotateMusic, MUSIC_ROTATE_MS);
}
// Crossfade the active music element out and a random OTHER track in on the other element.
function rotateMusic() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) { scheduleMusicRotate(); return; }
const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random());
const nextEl = musicEl === aud ? audB : aud;
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS); // ...fade it in,
fadeVolume(musicEl, 0, MUSIC_XFADE_MS, () => musicEl.pause()); // ...fade the old out.
musicEl = nextEl; musicIdx = next;
scheduleMusicRotate();
updateAudioStatus();
}
// Start the rotation (first call) or just re-apply the master volume to the active track.
// The FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock
// them on Safari (the timer-driven crossfade later plays the 2nd element outside a gesture).
function startMusic(vol) {
if (musicIdx < 0) {
musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length);
musicEl = aud;
ensurePlaying(aud, musicTrackUrl(musicIdx), vol);
ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent)
scheduleMusicRotate();
} else {
cancelFade(musicEl);
musicEl.volume = HEFScrub.clamp01(vol);
}
updateAudioStatus();
}
// Stop the rotation and reset (on leaving Music, or going silent).
function stopMusic() { clearTimeout(musicTimer); musicTimer = null; musicIdx = -1; musicEl = null; }
// Mid-segment scrub. Soundtrack: crossfade scale `loIndex` (gain 1-frac) against
// loIndex+1 (gain frac), scaled by the master volume. Music: hold the single loop.
@@ -1581,7 +1622,8 @@ function playMusic(vol) {
function blendAudio(loIndex, frac) {
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac);
if (plan.mode === "silent") return;
if (plan.mode === "music") { playMusic(plan.vol); return; }
if (plan.mode === "music") { startMusic(plan.vol); return; }
stopMusic(); // leaving Music for the soundtrack crossfade
const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
const { elLo, elHi } = assignElements(urlLo, urlHi);
ensurePlaying(elLo, urlLo, plan.lo);
@@ -1595,7 +1637,8 @@ function blendAudio(loIndex, frac) {
function restAudio(index) {
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0);
if (plan.mode === "silent") { silenceAll(); return; }
if (plan.mode === "music") { playMusic(plan.vol); return; }
if (plan.mode === "music") { startMusic(plan.vol); return; }
stopMusic(); // soundtrack → ensure the music rotation is stopped
const url = scaleAudioUrl(index);
const active = (audB.dataset.url === url) ? audB : aud;
const idle = active === audB ? aud : audB;
+6 -2
View File
@@ -48,14 +48,18 @@
<section>
<h2>Sound</h2>
<p class="note">
Soundtracks are public domain or CC0 — credit is courtesy, not required.
Per-altitude soundtracks are public domain or CC0 — credit is courtesy. The
<em>Music</em> option is Creative Commons <strong>BY</strong> — attribution required, given below.
</p>
<ul class="audio-credits">
<li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li>
<li><strong>NOAA</strong> SanctSound &amp; PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
<li><strong>BigSoundBank</strong> — sea waves &amp; gull calls (CC0).</li>
<li><strong>deadrobotmusic</strong> (freesound.org) — “Ambient F Sharp Minor Ethereal Choir Pad” (the <em>Music</em> option) (CC0).</li>
<li><strong>Kevin MacLeod</strong> (<a href="https://incompetech.com" rel="noopener">incompetech.com</a>) — the
<em>Music</em> option: “Music for Manatees”, “Dewdrop Fantasy”, “Eastminster”, “Overheat”, and
“Concentration”. Licensed under
<a href="https://creativecommons.org/licenses/by/4.0/" rel="license">Creative Commons: By Attribution 4.0</a>.</li>
</ul>
</section>
</main>
+11 -1
View File
@@ -44,6 +44,16 @@
return { mode: "blend", lo: g.from * level, hi: g.to * level };
}
// Pick the next track in a rotating playlist: a random index in [0,count) that is
// NEVER `current` (so a rotation always changes track), given r in [0,1). Maps r
// onto the (count-1) other slots. count<=1 stays on 0. Pure so the shuffle is
// node-testable; the caller passes Math.random().
function pickNextIndex(current, count, r) {
if (count <= 1) return 0;
const k = Math.min(count - 2, Math.floor(clamp01(r) * (count - 1)));
return k >= current ? k + 1 : k;
}
// The base-loop frame a landing must continue from so the steady loop picks up
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
@@ -68,5 +78,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, loopLandFrame, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings };
});
+20
View File
@@ -26,6 +26,26 @@ test("audioPlan: Music ignores altitude fraction (constant across the dial)", ()
assert.deepEqual(a, b);
});
test("pickNextIndex: never returns the current track; covers the rest", () => {
// r in [0,1) maps onto the (count-1) other indices, skipping `current`.
assert.equal(S.pickNextIndex(0, 5, 0), 1);
assert.equal(S.pickNextIndex(0, 5, 0.999), 4);
assert.equal(S.pickNextIndex(2, 5, 0), 0);
assert.equal(S.pickNextIndex(2, 5, 0.5), 3);
assert.equal(S.pickNextIndex(4, 5, 0.999), 3);
// sweep: for each current, every r lands somewhere valid and never on current
for (let cur = 0; cur < 5; cur++) {
for (let i = 0; i < 20; i++) {
const n = S.pickNextIndex(cur, 5, i / 20);
assert.ok(n >= 0 && n < 5 && n !== cur, `cur=${cur} r=${i / 20} -> ${n}`);
}
}
});
test("pickNextIndex: single-track playlist stays on 0", () => {
assert.equal(S.pickNextIndex(0, 1, 0.7), 0);
});
test("audioPlan: Soundtrack blends lo/hi by fraction, scaled by level", () => {
assert.deepEqual(S.audioPlan("soundtrack", 1, 0), { mode: "blend", lo: 1, hi: 0 });
assert.deepEqual(S.audioPlan("soundtrack", 1, 1), { mode: "blend", lo: 0, hi: 1 });