diff --git a/simulator/e2e/tests/music-playlist.spec.ts b/simulator/e2e/tests/music-playlist.spec.ts index 8dc5d95..e0f0d66 100644 --- a/simulator/e2e/tests/music-playlist.spec.ts +++ b/simulator/e2e/tests/music-playlist.spec.ts @@ -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 { +// 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(); 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 }); diff --git a/simulator/static/app.js b/simulator/static/app.js index 71729e2..55ca8ab 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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.