Merge pull request 'feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename' (#32) from worktree-audio-music-option into main
This commit was merged in pull request #32.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 `<fieldset>` (`simulator/static/index.html`) gains a dropdown above the
|
||||
renamed slider:
|
||||
|
||||
```
|
||||
Output
|
||||
[ Video ] (toggle)
|
||||
Audio [ Soundtrack ▾ ] ← NEW: <select id="audio-source">
|
||||
Volume ●──────── 2/10 ← existing #audio slider, label → "Volume"
|
||||
Speed ●──────── 1.00×
|
||||
🌐 [ Language ▾ ]
|
||||
```
|
||||
|
||||
- The dropdown is a `<select id="audio-source">` 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/<name>.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/<name>.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 / 音楽)
|
||||
- `<select id="audio-source">` 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.
|
||||
@@ -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/<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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 <select> 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 <audio> 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");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
+68
-25
@@ -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.)
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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: "考える" },
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
|
||||
<span id="welcome-play-speed-val">1.00×</span>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<label class="audio-level"><span data-i18n="output.volume">Volume</span>
|
||||
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
|
||||
<span id="welcome-audio-val">2</span>/10
|
||||
</label>
|
||||
@@ -82,7 +82,14 @@
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<label class="audio-source"><span data-i18n="output.audio">Audio</span>
|
||||
<select id="audio-source" aria-label="Audio source">
|
||||
<option value="none" data-i18n="output.audio.none">None</option>
|
||||
<option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option>
|
||||
<option value="music" data-i18n="output.audio.music">Music</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.volume">Volume</span>
|
||||
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
|
||||
@@ -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 <audio>
|
||||
// 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 };
|
||||
});
|
||||
|
||||
@@ -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 <audio> 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);
|
||||
});
|
||||
Reference in New Issue
Block a user