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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user