diff --git a/docs/audio-candidate-pool.md b/docs/audio-candidate-pool.md
index 77cd033..140c3b4 100644
--- a/docs/audio-candidate-pool.md
+++ b/docs/audio-candidate-pool.md
@@ -9,8 +9,10 @@ model — random pick per landing, or pairable per-clip).
The "audio + video" setting layers **two** kinds of audio per altitude:
1. **Ambience / audio track** — the soundscape (waves, reef crackle, chorus, etc.).
**This is what we're sourcing now.**
-2. **Music track** — a separate composed musical layer. **Deferred** until the
- ambience tracks are settled across all altitudes.
+2. **Music track** — a separate composed musical layer. **Shipped** (2026-07-01)
+ as the Audio-dropdown **"Music"** option: unlike the earlier per-altitude idea,
+ it is now **one single ethereal loop played the same at every altitude** (see
+ § Music below).
Pools below are the **ambience** layer unless marked `music`. **Distinction (operator):**
the ambience/soundtrack layer is an *environmental, real-world soundscape* (waves, reef,
@@ -48,6 +50,22 @@ 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)
+
+**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
+stay as historical candidates). The operator's brief: *ethereal, relaxing — like
+innerflo.me.* CC0 (operator's sourcing choice), so attribution is courtesy, not required.
+
+| id | clip | source | dur | license |
+|---|---|---|---|---|
+| `music/ethereal` | **✓ KEEP** — "Ambient F Sharp Minor Ethereal Choir Pad 1": blurry, paulstretched layered choir — "peaceful, magical, soothing" | deadrobotmusic — https://freesound.org/people/deadrobotmusic/sounds/808032/ (hq preview: `cdn.freesound.org/previews/808/808032_*-hq.mp3`) | 3:38 | CC0 |
+
+Production: `simulator/build_audio_media.py` `SOURCES["music"]` → seamless loudness-normalized
+loop at `audio/music/ethereal.loop.mp3`, served `/media/audio/music/ethereal.loop.mp3`
+(client fallback: `app.js` `MUSIC_FALLBACK`). Download the sound page's WAV/MP3 to
+`simulator/sample_media/audio/music/ethereal.mp3`, then `python simulator/build_audio_media.py`.
+
## Sourcing rule (learned 2026-06-26)
**Source only from a browsable page the operator can open and whose license is stated.**
@@ -67,7 +85,10 @@ hubs: NASA SVS / Chandra, NOAA PMEL/SanctSound (PD), BigSoundBank (CC0), Pixabay
- **reef** — ✅ round-4 6-layer bake (`reef/soundscape`): NOAA PD biological layers + a CC0
submerged underwater "you're under" bed (DCSFX freesound #366159, hq preview). License clean.
SanctSound clips: `files/SanctSound_*.mp4` at `https://sanctsound.ioos.us/files/` (extract audio).
-- **music layer** — separate composed music track per altitude, deferred (cosmos slot reserved).
+- **music layer** — ✅ SHIPPED 2026-07-01 as the Audio-dropdown "Music" option, but as
+ ONE altitude-independent ethereal loop (`music/ethereal`, CC0), not the per-altitude
+ track once envisioned. See § Music above. (The `cosmos/music` / "Frozen Star" rows are
+ now historical per-altitude candidates, not planned work.)
- **Production (after picks):** loop seams, length normalization, cross-altitude crossfade
(mirror video); cosmos/abyss clips are short → loop.
diff --git a/docs/superpowers/specs/2026-07-01-audio-music-option-design.md b/docs/superpowers/specs/2026-07-01-audio-music-option-design.md
new file mode 100644
index 0000000..a02f9df
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-01-audio-music-option-design.md
@@ -0,0 +1,113 @@
+# Audio "Music" option + "Volume" rename — design
+
+*Date: 2026-07-01 · Status: approved design, awaiting spec review*
+
+## Goal
+
+Give the listener a choice of what to hear. Today the Output panel has a single
+0–10 slider labeled **Audio** that acts as the master gain for the per-altitude
+ambience crossfade. We split that into two controls:
+
+- **Audio** — a dropdown selecting the audio *source*: **None · Soundtrack · Music**
+- **Volume** — the existing 0–10 slider (relabeled), master gain for whatever
+ source is active
+
+**Music** is one ethereal, relaxing ambient loop (the innerflo.me vibe) that
+plays continuously and does **not** change with altitude.
+
+## Non-goals
+
+- No per-altitude music set (Music is a single track, not a musical Soundtrack).
+- No change to the Soundtrack behavior itself (per-altitude crossfade stays exactly as-is).
+- No new audio elements — the existing `#aud` / `#aud-b` pair is reused.
+
+## Control surface
+
+The Output `
` (`simulator/static/index.html`) gains a dropdown above the
+renamed slider:
+
+```
+Output
+ [ Video ] (toggle)
+ Audio [ Soundtrack ▾ ] ← NEW:
+ Volume ●──────── 2/10 ← existing #audio slider, label → "Volume"
+ Speed ●──────── 1.00×
+ 🌐 [ Language ▾ ]
+```
+
+- The dropdown is a `` with options `none` / `soundtrack`
+ / `music`, default **`soundtrack`** (preserves today's launch experience).
+- The slider keeps `id="audio"` (avoids touching every reference) but its visible
+ label becomes **Volume** (new i18n key `output.volume`). The `output.audio` key
+ ("Audio") now labels the dropdown.
+
+## Behavior by mode
+
+| Mode | Behavior |
+|---|---|
+| **None** | Both `#aud`/`#aud-b` fade to silence — the existing `audioLevel()===0` path. The Volume slider is disabled (greyed). |
+| **Soundtrack** | Unchanged: per-altitude ambience crossfading across the two elements as the dial moves (`blendAudio` / `restAudio`). |
+| **Music** | The single music loop plays on `#aud` at master volume; `#aud-b` fades out. Altitude changes do **not** crossfade or reload audio. |
+
+Volume = 0 remains a hard mute in every mode.
+
+## Code shape
+
+All in `simulator/static/app.js`, following the existing audio-layer seams
+(`restAudio`, `blendAudio`, `ensurePlaying`, `scaleAudioUrl`):
+
+- **`audioSource()`** — new helper reading `#audio-source` → `"none" | "soundtrack" | "music"`.
+- **`musicUrl()`** — returns the resolved music loop url via `mediaBase()`
+ (mirrors `scaleAudioUrl`'s R2/local routing), e.g. `mediaBase()+"audio/music/.loop.mp3"`.
+- **`restAudio(index)`** branches:
+ - `none` (or `audioLevel()===0`) → existing fade-to-silence.
+ - `music` → `ensurePlaying(aud, musicUrl(), audioVol())`; `fadeVolume(audB, 0, …)`.
+ - `soundtrack` → unchanged per-altitude logic.
+- **`blendAudio(lo, frac)`** returns early (no crossfade) when source is `music` or
+ `none`; only Soundtrack blends across the altitude boundary.
+- **Dropdown wiring**: `#audio-source` `change` → reconcile synchronously in the
+ gesture (`applyAudio()`), same Safari-unlock discipline the slider already uses;
+ toggles the Volume slider's `disabled` when None.
+- **`beginExperience()`**: unchanged except it snaps **Volume** (`#audio`) to the
+ start level 2; default source stays Soundtrack.
+- **Diagnostic readout** (`audioStatusText`): prefix with the current source
+ (`none`/`soundtrack`/`music`) so a "no sound" report stays legible.
+
+No server/engine change — audio source is a pure client concern.
+
+## Music asset & licensing
+
+- One **CC0 / public-domain** ethereal ambient loop (innerflo.me-style: soft,
+ evolving, no percussion, seamless loop). Sourced by the implementer; reviewed by
+ ear before ship.
+- Stored at `simulator/.../media/audio/music/.loop.mp3` alongside the
+ per-scale soundtracks, and registered in the credits manifest exactly like every
+ other audio asset (this project credits all CC/PD audio — see `credits.html`).
+- Fallback path constant mirrors the `SCALE_AUDIO_FALLBACK` pattern so Music works
+ even against an older server process.
+
+## i18n + a11y
+
+- New key `output.volume` (en: "Volume", es: "Volumen", fr: "Volume", ja: "音量").
+- Dropdown option labels localized in en/es/fr/ja:
+ - None (Ninguno / Aucun / なし)
+ - Soundtrack (Banda sonora / Bande-son / サウンドトラック)
+ - Music (Música / Musique / 音楽)
+- `` gets an `aria-label` tied to the Audio label.
+- Reduced-motion behavior is unaffected (audio is orthogonal to motion).
+
+## Tests
+
+- **Node unit** (`simulator/unit/…`): `audioSource()` routing and the
+ `blendAudio`/`restAudio` branch selection — none→silent, music→single-element +
+ no crossfade on altitude change, soundtrack→unchanged.
+- **E2E** (`simulator/e2e/…`): dropdown switches modes; Music produces a single
+ playing element that persists across an altitude change; None disables the Volume
+ slider and silences audio; the slider label reads "Volume".
+
+## Decisions (confirmed with operator)
+
+1. **Music = one continuous, altitude-independent loop** (not per-altitude, not ducked).
+2. Default Audio source = **Soundtrack**; Music is opt-in.
+3. **None** disables the Volume slider.
+4. Music track is **operator-approved CC0** sourced by the implementer.
diff --git a/simulator/build_audio_media.py b/simulator/build_audio_media.py
index 4a1a3e0..fafc287 100644
--- a/simulator/build_audio_media.py
+++ b/simulator/build_audio_media.py
@@ -1,4 +1,5 @@
-"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). The audio
+"""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.
@@ -22,12 +23,18 @@ AUDIO = Path(__file__).parent / "sample_media" / "audio"
# scale -> (sourced raw file, production output file), both under audio//.
# 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"),
}
diff --git a/simulator/e2e/tests/audio-source.spec.ts b/simulator/e2e/tests/audio-source.spec.ts
new file mode 100644
index 0000000..9df942b
--- /dev/null
+++ b/simulator/e2e/tests/audio-source.spec.ts
@@ -0,0 +1,73 @@
+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 chooses the
+// source — None / Soundtrack / Music. Music is ONE ethereal loop played the same at
+// every altitude (no per-altitude crossfade).
+
+async function enter(page: Page) {
+ 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 });
+}
+
+async function selectSource(page: Page, v: string) {
+ await page.selectOption("#audio-source", v);
+}
+
+// The url the active element is loaded with (dataset.url is the raw path
+// app.js assigns; set regardless of whether play() is allowed to start).
+async function audUrls(page: Page): Promise<{ a: string; b: string }> {
+ return await page.evaluate(() => ({
+ a: (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "",
+ b: (document.querySelector("#aud-b") as HTMLAudioElement).dataset.url || "",
+ }));
+}
+
+async function wheelOnStage(page: Page, deltaY: number) {
+ await page.locator("#stage").dispatchEvent("wheel", { deltaY });
+}
+
+test.describe("Audio source dropdown + Volume rename", () => {
+ test("the level slider is now labelled Volume and the Audio control is a 3-option dropdown", async ({ page }) => {
+ await enter(page);
+ await expect(page.locator('.panel label.audio-level span[data-i18n="output.volume"]')).toHaveText("Volume");
+ await expect(page.locator('.panel label.audio-source span[data-i18n="output.audio"]')).toHaveText("Audio");
+ const opts = page.locator("#audio-source option");
+ await expect(opts).toHaveCount(3);
+ await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
+ });
+
+ test("selecting None disables the Volume slider; a real source re-enables it", async ({ page }) => {
+ await enter(page);
+ await selectSource(page, "none");
+ await expect(page.locator("#audio")).toBeDisabled();
+ await selectSource(page, "soundtrack");
+ await expect(page.locator("#audio")).toBeEnabled();
+ });
+
+ test("Music plays one ethereal loop 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).
+ await wheelOnStage(page, 120);
+ await wheelOnStage(page, 120);
+ await page.waitForTimeout(300);
+ urls = await audUrls(page);
+ expect(urls.a).toContain("music/ethereal.loop.mp3");
+ });
+
+ test("Soundtrack loads a per-altitude ambience, not the music loop", 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");
+ });
+});
diff --git a/simulator/sample_media/audio/music/ethereal.loop.mp3 b/simulator/sample_media/audio/music/ethereal.loop.mp3
new file mode 100644
index 0000000..0c9fbc4
--- /dev/null
+++ b/simulator/sample_media/audio/music/ethereal.loop.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dd4df4eac246061979d4959a95dbefa7405a57d45f6d8d1e06d1d98dad644a26
+size 3742100
diff --git a/simulator/sample_media/audio/music/ethereal.mp3 b/simulator/sample_media/audio/music/ethereal.mp3
new file mode 100644
index 0000000..d76ac08
--- /dev/null
+++ b/simulator/sample_media/audio/music/ethereal.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3829adf9ca64784268b21a061f0b1cf49b2b14139949f8a2ea3f33ffd04c3949
+size 4923905
diff --git a/simulator/static/app.js b/simulator/static/app.js
index 98e1461..18004dc 100644
--- a/simulator/static/app.js
+++ b/simulator/static/app.js
@@ -1369,6 +1369,18 @@ function updateAudioLevelLabel() {
if (el) el.textContent = String(audioLevel());
}
+// The Audio dropdown source: "none" | "soundtrack" | "music". `restAudio`/
+// `blendAudio` consult HEFScrub.audioPlan(source, level, frac) for the mode branch.
+function audioSource() {
+ const el = $("audio-source");
+ return el ? el.value : "soundtrack";
+}
+// Volume is meaningless when there is no source — grey it out on None.
+function syncVolumeDisabled() {
+ const slider = $("audio");
+ if (slider) slider.disabled = audioSource() === "none";
+}
+
// The element currently carrying the most signal — for the diagnostic readout,
// which used to assume a single `aud`. With two crossfading elements either may be
// the audible one, so report on whichever is louder (ties → aud).
@@ -1377,12 +1389,14 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }
// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
function audioStatusText() {
- if (audioLevel() === 0) return "audio: off (level 0)";
+ const src = audioSource();
+ if (src === "none") return "audio: off (source None)";
+ if (audioLevel() === 0) return "audio: off (volume 0)";
const s = ring && ring.scales[ringIndex];
- const url = soundtrackUrl();
+ const url = currentAudioUrl();
const el = activeAudEl();
- const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
- if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
+ const head = `${src} · L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
+ if (!url) return "audio: " + head + " ← no URL for this source/scale";
if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`;
if (audLastErr) return `audio: ${head} · ${audLastErr}`;
if (el.paused) return `audio: ${head} · PAUSED rs=${el.readyState}`;
@@ -1393,10 +1407,11 @@ function updateAudioStatus() {
if (!el) return;
el.textContent = audioStatusText();
const a = activeAudEl();
- const on = audioLevel() > 0;
- const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
+ const on = audioSource() !== "none" && audioLevel() > 0;
+ const url = currentAudioUrl();
+ const playing = on && !!url && !a.paused && !a.error && !audLastErr;
el.classList.toggle("playing", playing);
- el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr));
+ el.classList.toggle("blocked", on && (!url || !!a.error || !!audLastErr));
}
aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
@@ -1455,6 +1470,16 @@ 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; }
+
+// The url the active source would play right now (for the diagnostic readout).
+function currentAudioUrl() { return audioSource() === "music" ? musicUrl() : 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.
// Only (re)loads `src` when the url actually changes (so a same-url call is a no-op
@@ -1486,31 +1511,45 @@ function assignElements(urlLo, urlHi) {
return { elLo: aud, elHi: audB };
}
-// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
-// both scaled by the master audio level. No-op when the level is 0.
-function blendAudio(loIndex, frac) {
- const lvl = audioVol();
- if (lvl === 0) return;
- const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
- const g = HEFScrub.crossfadeGains(frac);
- const { elLo, elHi } = assignElements(urlLo, urlHi);
- ensurePlaying(elLo, urlLo, g.from * lvl);
- ensurePlaying(elHi, urlHi, g.to * lvl);
+// Both elements fade to silence (None, or Volume 0).
+function silenceAll() {
+ 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);
updateAudioStatus();
}
-// At rest on a single altitude: the element holding it plays at the master level, the
-// other fades out. Level 0 → both fade to silence.
+// 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.
+// Silent (None / Volume 0): leave the elements be — restAudio fades them on landing.
+function blendAudio(loIndex, frac) {
+ const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac);
+ if (plan.mode === "silent") return;
+ if (plan.mode === "music") { playMusic(plan.vol); return; }
+ const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
+ const { elLo, elHi } = assignElements(urlLo, urlHi);
+ ensurePlaying(elLo, urlLo, plan.lo);
+ ensurePlaying(elHi, urlHi, plan.hi);
+ updateAudioStatus();
+}
+
+// At rest on a single altitude. Silent → both fade out. Music → the single loop at
+// the master volume. Soundtrack → the element holding this altitude plays at the
+// master volume (frac 0 → lo = level), the other fades out.
function restAudio(index) {
- if (audioLevel() === 0) {
- fadeVolume(aud, 0, FADE_MS, () => aud.pause());
- fadeVolume(audB, 0, FADE_MS, () => audB.pause());
- return;
- }
+ const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0);
+ if (plan.mode === "silent") { silenceAll(); return; }
+ if (plan.mode === "music") { playMusic(plan.vol); return; }
const url = scaleAudioUrl(index);
const active = (audB.dataset.url === url) ? audB : aud;
const idle = active === audB ? aud : audB;
- ensurePlaying(active, url, audioVol());
+ ensurePlaying(active, url, plan.lo);
fadeVolume(idle, 0, FADE_MS);
updateAudioStatus();
}
@@ -1608,6 +1647,10 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
+ // Audio source dropdown: reconcile SYNCHRONOUSLY in this change gesture so
+ // switching to Soundtrack/Music unlocks + plays on Safari; None greys Volume.
+ $("audio-source").addEventListener("change", () => { syncVolumeDisabled(); applyAudio(); debounced(); });
+ syncVolumeDisabled();
// Video toggle (panel): the FIRST time video turns on without having entered via
// Launch, begin the experience too — couple audio in. (Normally Launch entered
// first, so videoEverOn is already true and this is a no-op beyond re-render.)
diff --git a/simulator/static/credits.html b/simulator/static/credits.html
index edb49d5..ec973c5 100644
--- a/simulator/static/credits.html
+++ b/simulator/static/credits.html
@@ -55,6 +55,7 @@
NOAA SanctSound & PMEL hydrophone recordings — reef/abyss biological layers (public domain).
freesound.org contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).
BigSoundBank — sea waves & gull calls (CC0).
+ deadrobotmusic (freesound.org) — “Ambient F Sharp Minor Ethereal Choir Pad” (the Music option) (CC0).
diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js
index 3536734..b9d62ad 100644
--- a/simulator/static/i18n.js
+++ b/simulator/static/i18n.js
@@ -24,6 +24,10 @@
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
+ "output.audio.none": { en: "None", es: "Ninguno", fr: "Aucun", ja: "なし" },
+ "output.audio.soundtrack": { en: "Soundtrack", es: "Banda sonora", fr: "Bande-son", ja: "サウンドトラック" },
+ "output.audio.music": { en: "Music", es: "Música", fr: "Musique", ja: "音楽" },
+ "output.volume": { en: "Volume", es: "Volumen", fr: "Volume", ja: "音量" },
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expérience", ja: "体験つまみ" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
diff --git a/simulator/static/index.html b/simulator/static/index.html
index 86df871..c835324 100644
--- a/simulator/static/index.html
+++ b/simulator/static/index.html
@@ -26,7 +26,7 @@
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
1.00×
- Audio
+ Volume
2 /10
@@ -82,7 +82,14 @@
Video
- Audio
+ Audio
+
+ None
+ Soundtrack
+ Music
+
+
+ Volume
0 /10
diff --git a/simulator/static/scrub.js b/simulator/static/scrub.js
index 171de04..7ea930d 100644
--- a/simulator/static/scrub.js
+++ b/simulator/static/scrub.js
@@ -29,6 +29,21 @@
return { from: 1 - f, to: f };
}
+ // Pure audio-source routing: given the Audio dropdown source ("none" |
+ // "soundtrack" | "music"), the master volume `level` (0..1), and the altitude
+ // crossfade fraction `frac`, decide what the audio layer should do. app.js's
+ // restAudio/blendAudio consult this for the mode branch; the physical
+ // element assignment (which element carries which url) stays in app.js.
+ // - silent: play nothing (None, or level 0 — a hard mute in every mode)
+ // - music : ONE continuous track at the master level, altitude-independent
+ // - blend : the per-altitude soundtrack crossfade (lo = frac 0 scale, hi = next)
+ function audioPlan(source, level, frac) {
+ if (!(level > 0) || source === "none") return { mode: "silent" };
+ if (source === "music") return { mode: "music", vol: level };
+ const g = crossfadeGains(frac);
+ return { mode: "blend", lo: g.from * level, hi: g.to * level };
+ }
+
// 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
@@ -53,5 +68,5 @@
return out;
}
- return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
+ return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, loopLandFrame, integerCrossings };
});
diff --git a/simulator/unit/audio.test.js b/simulator/unit/audio.test.js
new file mode 100644
index 0000000..10fa606
--- /dev/null
+++ b/simulator/unit/audio.test.js
@@ -0,0 +1,36 @@
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const S = require("../static/scrub.js");
+
+// audioPlan decides, purely, what the audio layer should do for a given source
+// mode + master level + crossfade fraction. app.js's restAudio/blendAudio consult
+// it for the mode branch; the physical element assignment stays in app.js.
+
+test("audioPlan: None source is silent regardless of level", () => {
+ assert.deepEqual(S.audioPlan("none", 1, 0.5), { mode: "silent" });
+});
+
+test("audioPlan: level 0 is silent even for soundtrack/music", () => {
+ assert.deepEqual(S.audioPlan("soundtrack", 0, 0.5), { mode: "silent" });
+ assert.deepEqual(S.audioPlan("music", 0, 0), { mode: "silent" });
+});
+
+test("audioPlan: Music plays a single track at the master level, no blend", () => {
+ assert.deepEqual(S.audioPlan("music", 0.8, 0.5), { mode: "music", vol: 0.8 });
+});
+
+test("audioPlan: Music ignores altitude fraction (constant across the dial)", () => {
+ const a = S.audioPlan("music", 0.6, 0);
+ const b = S.audioPlan("music", 0.6, 0.9);
+ assert.deepEqual(a, b);
+});
+
+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 });
+ const mid = S.audioPlan("soundtrack", 0.5, 0.5);
+ assert.equal(mid.mode, "blend");
+ assert.ok(Math.abs(mid.lo - 0.25) < 1e-9);
+ assert.ok(Math.abs(mid.hi - 0.25) < 1e-9);
+});