feat(audio): Music shuffles to a new random track when each finishes (gapless) #35

Merged
benstull merged 1 commits from feat/music-shuffle-on-end into main 2026-07-03 13:00:47 +00:00
2 changed files with 83 additions and 53 deletions
Showing only changes of commit e666465d62 - Show all commits
+34 -28
View File
@@ -1,57 +1,63 @@
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.
// Music is a shuffle: each track plays to its END, then a random OTHER track plays
// (never an immediate repeat), gaplessly, for as long as Music is selected. The e2e
// injects a short crossfade via window.__hefMusicXfadeMs and simulates a track finishing
// by dispatching its "ended" event (real tracks are minutes long).
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]);
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
async function boot(page: Page, xfadeMs = 300) {
await page.addInitScript((x) => { (window as any).__hefMusicXfadeMs = x; }, 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> {
// id ("aud"/"aud-b") of the louder (active) music element, and the track it holds.
async function active(page: Page): Promise<{ id: string; url: 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 || "";
return { id: el.id, url: el.dataset.url || "" };
});
}
// Simulate the active track finishing.
async function endActive(page: Page, id: string) {
await page.evaluate((i) => (document.querySelector("#" + i) as HTMLAudioElement).dispatchEvent(new Event("ended")), id);
}
test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => {
await boot(page, 1200, 300);
test("when the current track ends, a new RANDOM track plays", async ({ page }) => {
await boot(page);
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.
const first = await active(page);
expect(first.url).toMatch(MUSIC_RE);
await endActive(page, first.id);
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
return (el.dataset.url || "").includes("/audio/music/") && (el.dataset.url || "") !== f;
}, first.url, { timeout: 6000 });
const second = await active(page);
expect(second.url).toMatch(MUSIC_RE);
expect(second.url).not.toBe(first.url); // never an immediate repeat
});
test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => {
await boot(page, 900, 250);
test("Music keeps playing across several track-ends", async ({ page }) => {
await boot(page);
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));
await page.waitForTimeout(400);
const cur = await active(page);
seen.add(cur.url);
await endActive(page, cur.id);
await page.waitForTimeout(400); // let the crossfade settle
}
// 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);
expect([...seen].every((u) => MUSIC_RE.test(u))).toBe(true);
expect(seen.size).toBeGreaterThan(1); // it moved through multiple tracks
});
+49 -25
View File
@@ -1466,7 +1466,7 @@ function updateAudioStatus() {
}
aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
setInterval(updateAudioStatus, 400);
setInterval(() => { updateAudioStatus(); musicWatchdog(); }, 400);
// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
// if a stale one keeps running after a new fade or a direct volume set, it drags the
@@ -1523,20 +1523,22 @@ function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each
// other (not one looping track), altitude-independent — the same relaxing rotation at
// every altitude. Every MUSIC_ROTATE_MS it crossfades to a random OTHER track (never an
// immediate repeat), reusing the two audio elements. Intervals are injectable for tests.
// every altitude. Each track plays to its END, then a random OTHER one plays (never an
// immediate repeat); a short crossfade a few seconds before the end keeps it GAPLESS, so
// Music is always playing while selected. Reuses the two audio elements. The crossfade
// duration is injectable for tests.
const MUSIC_PLAYLIST = [
"music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3",
"music/overheat.mp3", "music/concentration.mp3",
];
const MUSIC_ROTATE_MS = (typeof window !== "undefined" && window.__hefMusicRotateMs) || 5 * 60 * 1000;
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
const MUSIC_XFADE_S = MUSIC_XFADE_MS / 1000;
function musicTrackUrl(i) {
return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)];
}
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
let musicEl = null; // the element currently carrying the active music track
let musicTimer = null; // rotation timer
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
let musicEl = null; // the element currently carrying the active music track
let musicXfading = false; // true during a track→track crossfade (guards re-entry)
// The url the active source would play right now (for the diagnostic readout).
function currentAudioUrl() {
@@ -1582,40 +1584,62 @@ function silenceAll() {
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
}
function scheduleMusicRotate() {
clearTimeout(musicTimer);
musicTimer = setTimeout(rotateMusic, MUSIC_ROTATE_MS);
}
// Crossfade the active music element out and a random OTHER track in on the other element.
function rotateMusic() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) { scheduleMusicRotate(); return; }
// Crossfade the finishing track out and a random OTHER track in on the other element.
function advanceMusic() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) return;
musicXfading = true;
const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random());
const nextEl = musicEl === aud ? audB : aud;
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS); // ...fade it in,
fadeVolume(musicEl, 0, MUSIC_XFADE_MS, () => musicEl.pause()); // ...fade the old out.
const prevEl = musicEl;
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS, () => { musicXfading = false; }); // ...fade it in,
fadeVolume(prevEl, 0, MUSIC_XFADE_MS, () => prevEl.pause()); // ...fade the finishing one out.
musicEl = nextEl; musicIdx = next;
scheduleMusicRotate();
updateAudioStatus();
}
// Start the rotation (first call) or just re-apply the master volume to the active track.
// The FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock
// them on Safari (the timer-driven crossfade later plays the 2nd element outside a gesture).
// timeupdate: a few seconds before the active track ends, crossfade to a random next
// (gapless). Only the active element, in Music mode, once per track.
function onMusicProgress(e) {
if (musicXfading || audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
const d = musicEl.duration;
if (d && isFinite(d) && d > MUSIC_XFADE_S * 2 && musicEl.currentTime >= d - MUSIC_XFADE_S) advanceMusic();
}
// ended: safety net — a track that finishes without a crossfade (short track / missed
// timeupdate) immediately rolls a new random one, so Music never stops while selected.
function onMusicEnded(e) {
if (audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
musicXfading = false;
advanceMusic();
}
// Start playback (first call) or just re-apply the master volume to the active track. The
// FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock them
// on Safari (later crossfades play the 2nd element outside a gesture).
function startMusic(vol) {
if (musicIdx < 0) {
musicXfading = false;
musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length);
musicEl = aud;
ensurePlaying(aud, musicTrackUrl(musicIdx), vol);
ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent)
scheduleMusicRotate();
} else {
cancelFade(musicEl);
musicEl.volume = HEFScrub.clamp01(vol);
}
updateAudioStatus();
}
// Stop the rotation and reset (on leaving Music, or going silent).
function stopMusic() { clearTimeout(musicTimer); musicTimer = null; musicIdx = -1; musicEl = null; }
// Reset (on leaving Music, or going silent).
function stopMusic() { musicXfading = false; musicIdx = -1; musicEl = null; }
// Keep Music playing while selected: resume the active track if it stalls/pauses unexpectedly.
function musicWatchdog() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0 || !musicEl) return;
if (musicEl.paused && !musicXfading) { const pr = musicEl.play(); if (pr) pr.catch(() => {}); }
}
for (const el of [aud, audB]) {
el.addEventListener("timeupdate", onMusicProgress);
el.addEventListener("ended", onMusicEnded);
}
// 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.