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:
@@ -50,7 +50,13 @@ recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal.
|
||||
| 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 |
|
||||
| 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) |
|
||||
|
||||
## Music (the Audio-dropdown "Music" option, shipped 2026-07-01)
|
||||
## Music (the Audio-dropdown "Music" option)
|
||||
|
||||
> **SUPERSEDED 2026-07-02:** the single ethereal loop below was replaced by a
|
||||
> **rotating crossfading playlist of mellow drumless synth** (Kevin MacLeod, CC BY) —
|
||||
> see `docs/music-candidate-pool.md`. The ethereal-loop record is kept for history.
|
||||
|
||||
### (history) ethereal loop — shipped 2026-07-01, then replaced
|
||||
|
||||
**One** ethereal ambient loop, played the same at every altitude (altitude-independent —
|
||||
NOT the per-altitude idea the "cosmos/music" / "Frozen Star" rows above reserved; those
|
||||
|
||||
@@ -12,13 +12,22 @@ URL). Local review gallery (gitignored, like the other review pages):
|
||||
**Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from
|
||||
genre/reputation, not a by-ear check — audition before final selection.
|
||||
|
||||
## Operator picks & refined brief (2026-07-02)
|
||||
## FINAL selection — WIRED 2026-07-02
|
||||
|
||||
Operator auditioned and **kept `dreamcult` (Dream Culture) + `silverblue` (Silver Blue Light)**
|
||||
— both Kevin MacLeod, CC BY, drumless cinematic-calm synth. **Rejected everything else** as
|
||||
"boring OR too much snare drum." So the refined target is **drumless, evolving-chord ambient
|
||||
synth** in that exact lane — the HoliznaCC0 lo-fi (beat-driven) is OUT. A Round-4 search is
|
||||
sourcing ~8–12 more like the two keepers (Kevin MacLeod's Calming/Ambient catalog first).
|
||||
After a Round-4 drumless pass, the operator's **final Music set = 5 Kevin MacLeod tracks**
|
||||
(CC BY 4.0), now **shipped as a rotating crossfading playlist**:
|
||||
|
||||
**Music for Manatees · Dewdrop Fantasy · Eastminster · Overheat · Concentration**
|
||||
|
||||
Wiring: `app.js` `MUSIC_PLAYLIST` — the Music source plays one track and every
|
||||
`MUSIC_ROTATE_MS` (5 min) crossfades to a random OTHER track (`pickNextIndex`, never an
|
||||
immediate repeat), altitude-independent, on the two `#aud`/`#aud-b` elements. Files
|
||||
loudness-normalized + transcoded under the 25MB LFS limit by `build_audio_media.py`
|
||||
(`MUSIC`), committed via LFS; the single ethereal loop was **retired**. Credited on
|
||||
`credits.html` (CC BY attribution). Earlier candidates below kept for record.
|
||||
|
||||
*(History: the operator first kept Dream Culture + Silver Blue Light, then — after the
|
||||
drumless Round-4 pass — chose the 5 long-form/floaty tracks above instead.)*
|
||||
|
||||
## Two license families
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 0–10 "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
@@ -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;
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user