Compare commits

...

6 Commits

Author SHA1 Message Date
benstull f6b45b6cd7 Merge pull request 'feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename' (#32) from worktree-audio-music-option into main 2026-07-02 05:28:11 +00:00
BenStullsBets d161db8110 feat(audio): add CC0 ethereal Music loop (freesound #808032, via LFS)
The Music-option asset produced by build_audio_media.py: deadrobotmusic
'Ambient F Sharp Minor Ethereal Choir Pad' (CC0), sourced from the freesound
hq preview and looped/loudness-normalized to ethereal.loop.mp3 (3:34). Both
the raw source and the served loop are committed via git-LFS, matching the
per-scale soundtracks; served at /media/audio/music/ethereal.loop.mp3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:26:14 -07:00
BenStullsBets ee7190bb4a feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename
Split the single 0–10 'Audio' slider into two controls:
- Audio  — a dropdown selecting the source: None / Soundtrack / Music
- Volume — the renamed 0–10 slider (master gain for the active source)

Music is one ethereal, relaxing ambient loop played the same at every
altitude (no per-altitude crossfade), per the innerflo.me brief. Sourced
CC0: deadrobotmusic 'Ambient F Sharp Minor Ethereal Choir Pad' (freesound
#808032); added to build_audio_media.py, the candidate-pool doc, and the
public credits colophon. Media stays gitignored; run build_audio_media.py.

Routing lives in a pure, node-tested HEFScrub.audioPlan(source, level, frac);
app.js's restAudio/blendAudio consult it. None greys out Volume. The welcome
screen's mirror level slider is relabeled Volume too. i18n for all 4 langs.

Spec: docs/superpowers/specs/2026-07-01-audio-music-option-design.md
Tests: +5 node unit (audioPlan), +4 e2e (audio-source.spec.ts); 32 related
e2e green, 38 node unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:55:23 -07:00
BenStullsBets 6816e4ce75 docs(spec): Audio source dropdown (None/Soundtrack/Music) + Volume rename
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:41:40 -07:00
BenStullsBets 5226a1ebd5 add sessions/0039/SESSION-0039.0-TRANSCRIPT-2026-06-30T10-40--2026-06-30T23-52.md + replace placeholder/variant SESSION-0039.0-TRANSCRIPT-2026-06-30T10-40--INPROGRESS.md 2026-07-01 00:02:22 -07:00
BenStullsBets 6695f32b74 claim human-experience-filter-art session 0039 (placeholder) + sessions.json entry 2026-06-30 23:55:54 -07:00
14 changed files with 429 additions and 32 deletions
+24 -3
View File
@@ -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
010 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 010 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.
@@ -0,0 +1,68 @@
# Session 0039.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T10-40 (PST)
> End: 2026-06-30T23-52 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 9c986be3-c1f9-4123-bf7a-d62dd34810dd
> Checkout: worktree `welcome-screen-controls` (branch `worktree-welcome-screen-controls`, off origin/main)
**Date:** 2026-06-30
**Goal:** Build a pre-load "Welcome" screen for the simulator, then iterate it live per operator feedback.
**Outcome:** Shipped a large run of frontend work to production and **merged to `main` (`7ec8b60`, fast-forward)**. The project was renamed **Human/Machine Strata** and moved to **benstull.art/human-machine-strata/** (old URL 301-redirects). Live == main at close.
## Plan
Emergent, operator-driven (not a single up-front plan). The through-line:
1. Welcome screen: hide the right pane during load; put Video / Reduce-motion / Audio / Language controls on the pre-load screen; heads-up before "Loading Universe"; two states (welcome → centered loader); "Launch Simulator" entry.
2. Reconcile two `main` updates that landed mid-session (Playback Speed slider, 2..2 then 0×–4×).
3. Rework welcome controls → Language / Speed / Audio; drop the video toggle (video assumed on); re-add a Reduce-motion toggle that turns OFF morph transitions only (loop stays on Speed).
4. Publish to Cloudflare; then a stream of live tweaks: cap speed at 2×, cache hardening, panel reorder, rename, URL move.
5. Merge to `main` + final deploy.
## Pre-session state
- Simulator is a fully-static Cloudflare deployment (Pages + R2) — exempt from the Wiggleverse §9/flotilla pipeline; deploys straight to benstull.art. Was live at `/human-experience-simulator/`.
- Entry flow was: bare "Loading Universe" splash → fade → "Run simulation" button over a black stage → first-visit "Heads up" modal → begin (video on, audio→2). Controls (Video/Audio/Reduce-motion/Language) lived in the always-visible right panel.
- Parallel sessions were active on the shared checkout; this session ran in an isolated worktree per the isolation rule.
## Turn-by-turn arc
1. **Brainstorm + spec.** Explored the loading screen, right pane, controls, and i18n via an Explore agent. Wrote/committed the design spec (`05328b1`, `docs/superpowers/specs/2026-06-30-welcome-screen-design.md`). Operator refined it live (State B centered loader; "Launch Simulator" as the single entry button).
2. **Build the welcome screen** (`f077193`): folded `#loading` + `#motion-warning` + `#run-sim` into one `#welcome` overlay; State A (heads-up + controls + Launch, loader at bottom) → State B (Launch-while-loading: messaging gone, controls stay, loader centered) → auto-enter; panel hidden until entry via `body:has(#welcome)`; added `window.__hefReady` e2e seam; i18n `welcome.launch` + localized the previously en-only `warn.*`. Discovered + fixed a pre-existing i18n parity failure in passing. Node 35, e2e 27, static 2, pytest 301. Untracked stray `test-results/` artifacts (`7cdf594`).
3. **Reconcile main (twice).** Mid-session `main` gained a Playbook Speed slider (`df8b245`, 2..2) then narrowed it to 0×–4× forward, dropping reverse (`e064a2e`). Merged both into the branch; resolved conflicts in app.js/i18n/a11y; generalized the speed machinery to drive both the welcome and panel sliders.
4. **Welcome controls reworked** → Language / Speed / Audio (video assumed on at entry).
5. **Reduce-motion returns** (`5f9ae6b`). Operator: "we actually do need Reduced motion to turn off the morph videos." Speed only drives the resting loop, so re-added a visible Reduce-motion toggle (welcome + panel, default from OS pref) that makes altitude changes **instant cuts** (no morph tween) while the loop keeps following Speed. Decoupled the loop from reduce-motion (main had them coupled).
6. **Publish to benstull.art.** Verified wrangler auth, ran the frontend-only deploy; verified live + a headless smoke.
7. **Live tweaks (a stream):** "Video speed" rename (`79f64e3`); cap speed at 02× — >2× stutters, same all-intra reason reverse was dropped (`ccd4010`); panel: Experience knobs above Altitude, drop the dial hint + "(04)" (`fafb498`); page title + h1 → then renamed to **Human/Machine Strata** (`958c306`); move Altitude to the TOP of the panel for mobile (`c36f995`).
8. **Debugged "video + audio not playing on launch"** (systematic-debugging). Could not reproduce in Chromium headless, Chromium strict-autoplay, OR WebKit — everything played. Turned out to be a **stale Safari app-shell** (worked in a private window). Also found a stale uvicorn from another checkout squatting on :8099 serving old HTML, and `| tail` masking playwright's exit code — both wasted time.
9. **Cache hardening.** app-shell was `no-cache`; a per-HTML `no-store` override did NOT stick (Cloudflare Pages let `/seg/*` win), so switched the whole app path to `no-store` (`8c63b14`).
10. **Reduce-motion regression fixed** (`ecd444b`). Operator: "video doesn't change when a new altitude is selected with Reduce motion on." TDD: reproduced with a failing e2e, root-caused (the reduced-motion jump skips the segment that re-rolls the destination clip → `activeClipId` stayed on the old scale's clip), fixed with a self-heal at the `setPos` frac-0 landing (re-roll if the landed pool doesn't contain `activeClipId`).
11. **Rename + URL move.** Named the project **Human/Machine Strata**; moved the URL to `/human-machine-strata/` (`950c1e6`) with a permanent 301 from the legacy path (`LEGACY_APP_PATH`); the bare-root case needed its own rule (`b135cb5`). R2 bucket + Pages project names kept (not in the URL → no media re-upload). Updated the build test, static-build e2e path, and README paths.
12. **Merge + final deploy.** `main` had diverged by 2 commits (session-transcript files only) — merged origin/main in cleanly, then fast-forwarded `main` to the branch (`7ec8b60`). Deployed; verified new URL + old-URL 301.
## Cut state (end of session)
- **`main` = `origin/main` = branch `worktree-welcome-screen-controls` = `7ec8b60`.** Working tree clean; everything pushed and merged.
- **Live: https://benstull.art/human-machine-strata/** serves "Human/Machine Strata"; old `/human-experience-simulator/` (root + subpaths) 301-redirects; app-shell `Cache-Control: no-store`.
- Verification at close: node 35/35, Playwright dynamic e2e 33/33 (incl. new reduce-motion instant-cut clip test), static-build 2/2, pytest 301 passed (3 pre-existing ffmpeg/cv2 env-only failures, unrelated).
- Key commits (all on `main` via `7ec8b60`): `05328b1` spec · `f077193` welcome screen · reconcile merges of main's speed feature · `5f9ae6b` reduce-motion toggle · `ccd4010` 2× cap · `8c63b14` no-store · `fafb498` knobs-above-altitude · `ecd444b` reduce-motion clip re-roll · `c36f995` altitude-on-top · `958c306` rename · `950c1e6` URL move · `b135cb5` bare-root redirect · `7ec8b60` merge.
## What lands on the operator's plate
- **By-eye review on real Safari** of the shipped experience (headless can't exercise real autoplay/GPU; the reduce-motion instant-cut and 2× cap are logic/test-verified only).
- **Optional cleanup:** the dev-panel `#aud-test` element has a hardcoded `/media/...` src that 404s on the R2 site (dev-only, no user impact) — route it through `mediaBase()` if desired.
- **Possible follow-up:** "more welcome copy beyond the heads-up" (operator flagged early; the welcome-message block is structured to take more `data-i18n` paragraphs).
## Prompt the operator can paste into the next session
```
/goal Human/Machine Strata follow-ups. The welcome screen + rename + URL move are live and merged to main (benstull.art/human-machine-strata/). Next: (1) add the intro/framing welcome copy beyond the "Heads up" notice (welcome-message block is data-i18n-ready, 4 langs); (2) optionally clean up the dev-panel #aud-test hardcoded /media 404 by routing it through mediaBase(). Reminder env gotchas: only python3 on this box (start uvicorn by hand, __hefReady e2e seam replaces the old #loading wait); a stale uvicorn from another checkout can squat on :8099 (curl :8099 | grep welcome to confirm); don't trust `… | tail` exit codes.
```
## Deferred decisions
- Kept "Human/Machine Strata" as an **untranslated proper title** across all 4 languages (app.title), rather than localizing it — treated as the work's title. Low-confidence; easy to localize if the operator prefers.
- Renamed the app **URL path** but **not** the Pages project / R2 bucket names (they aren't in the benstull.art URL; renaming them would force a 2.3 GB media re-upload). Judged not worth it.
- Session started `careful`? No — no init ran; posture defaulted to `yolo` (planning-and-executing). All merges/deploys were operator-directed step by step.
+3
View File
@@ -112,5 +112,8 @@
},
"0038": {
"title": ""
},
"0039": {
"title": ""
}
}
+8 -1
View File
@@ -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"),
}
+73
View File
@@ -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 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
// 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
View File
@@ -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.)
+1
View File
@@ -55,6 +55,7 @@
<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>
</ul>
</section>
</main>
+4
View File
@@ -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 dexpérience", ja: "体験つまみ" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
+9 -2
View File
@@ -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>
+16 -1
View File
@@ -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 };
});
+36
View File
@@ -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);
});