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>
This commit is contained in:
BenStullsBets
2026-07-01 21:55:23 -07:00
parent 6816e4ce75
commit ee7190bb4a
9 changed files with 239 additions and 32 deletions
+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");
});
});