feat(audio): Music becomes a crossfading playlist of 5 mellow synth tracks
Replace the single ethereal loop with the operator's chosen set — Music for Manatees, Dewdrop Fantasy, Eastminster, Overheat, Concentration (Kevin MacLeod, CC BY 4.0, drumless). The Music source now plays one track and every 5 min crossfades to a random OTHER track (pure HEFScrub.pickNextIndex, never an immediate repeat), altitude-independent, reusing the two audio elements; both primed in-gesture for Safari. Intervals injectable via window.__hefMusicRotateMs / __hefMusicXfadeMs. Media loudness-normalized + transcoded under the 25MB LFS limit (build_audio_media MUSIC), committed via LFS; ethereal loop retired. CC-BY attribution added to credits.html. Docs updated (music-candidate-pool = final set; audio-candidate-pool ethereal section superseded). +2 node unit (pickNextIndex), +2 e2e (music-playlist), audio-source spec updated. 40 unit + 49 e2e green; live probe confirms playback + rotation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ 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
|
||||
// source — None / Soundtrack / Music. Music is a rotating playlist played the same at
|
||||
// every altitude (no per-altitude crossfade).
|
||||
|
||||
async function enter(page: Page) {
|
||||
@@ -47,27 +47,30 @@ test.describe("Audio source dropdown + Volume rename", () => {
|
||||
await expect(page.locator("#audio")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("Music plays one ethereal loop that persists across an altitude change", async ({ page }) => {
|
||||
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
|
||||
|
||||
test("Music plays a playlist track 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).
|
||||
expect(urls.a).toMatch(MUSIC_RE);
|
||||
// Move the dial: Music is altitude-independent, so #aud stays on a music track
|
||||
// (it does NOT switch to a per-altitude soundtrack). Default rotate interval is
|
||||
// 5 min, so it's the SAME track here.
|
||||
await wheelOnStage(page, 120);
|
||||
await wheelOnStage(page, 120);
|
||||
await page.waitForTimeout(300);
|
||||
urls = await audUrls(page);
|
||||
expect(urls.a).toContain("music/ethereal.loop.mp3");
|
||||
expect(urls.a).toMatch(MUSIC_RE);
|
||||
});
|
||||
|
||||
test("Soundtrack loads a per-altitude ambience, not the music loop", async ({ page }) => {
|
||||
test("Soundtrack loads a per-altitude ambience, not a music track", 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");
|
||||
expect(loaded).not.toContain("/audio/music/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Music is a rotating playlist that crossfades to a random OTHER track on a timer
|
||||
// (docs/music-candidate-pool.md). The e2e injects short rotate/xfade intervals via
|
||||
// window.__hefMusicRotateMs / __hefMusicXfadeMs before load.
|
||||
|
||||
async function boot(page: Page, rotateMs: number, xfadeMs: number) {
|
||||
await page.addInitScript(([r, x]) => {
|
||||
(window as any).__hefMusicRotateMs = r;
|
||||
(window as any).__hefMusicXfadeMs = x;
|
||||
}, [rotateMs, xfadeMs]);
|
||||
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 });
|
||||
}
|
||||
|
||||
// The track on the louder (active) music element.
|
||||
async function activeTrack(page: Page): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
const b = document.querySelector("#aud-b") as HTMLAudioElement;
|
||||
const el = b.volume > a.volume ? b : a;
|
||||
return el.dataset.url || "";
|
||||
});
|
||||
}
|
||||
|
||||
test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => {
|
||||
await boot(page, 1200, 300);
|
||||
await page.selectOption("#audio-source", "music");
|
||||
await page.waitForTimeout(200);
|
||||
const first = await activeTrack(page);
|
||||
expect(first).toMatch(/audio\/music\/\w+\.mp3/);
|
||||
// Wait past one rotation (1200ms) + the crossfade (300ms) + buffer.
|
||||
await page.waitForFunction((f) => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
const b = document.querySelector("#aud-b") as HTMLAudioElement;
|
||||
const el = b.volume > a.volume ? b : a;
|
||||
return (el.dataset.url || "") !== f && (el.dataset.url || "").includes("/audio/music/");
|
||||
}, first, { timeout: 6000 });
|
||||
const second = await activeTrack(page);
|
||||
expect(second).toMatch(/audio\/music\/\w+\.mp3/);
|
||||
expect(second).not.toBe(first); // rotated to another track
|
||||
});
|
||||
|
||||
test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => {
|
||||
await boot(page, 900, 250);
|
||||
await page.selectOption("#audio-source", "music");
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.waitForTimeout(1300);
|
||||
seen.add(await activeTrack(page));
|
||||
}
|
||||
// Over 3 rotations we should have heard more than one distinct track.
|
||||
expect([...seen].every((u) => /audio\/music\/\w+\.mp3/.test(u))).toBe(true);
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
});
|
||||
Reference in New Issue
Block a user