diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 5a727ed..9dfe98d 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -29,6 +29,12 @@ async function wheelOnStage(page: Page, deltaY: number) { await page.locator("#stage").dispatchEvent("wheel", { deltaY }); } +test("two audio elements exist for crossfade", async ({ page }) => { + await boot(page); + const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length); + expect(n).toBe(2); +}); + test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => { await boot(page); const start = (await page.locator("#scale-name").textContent())!; diff --git a/simulator/static/app.js b/simulator/static/app.js index 9627887..5ea06d9 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -956,32 +956,40 @@ function devLiveReload() { // via the async server roundtrip; once unlocked it can be re-played programmatically // (e.g. on an altitude change). audio on = the current scale's soundtrack. const aud = $("aud"); -let audUrl = null; // soundtrack url currently loaded (null = silent) +const audB = $("aud-b"); // second element: the two crossfade by scrub fraction let audLastErr = ""; // last play() rejection / element error (for the readout) const FADE_MS = 500; +// The element currently carrying the most signal — for the diagnostic readout, +// which used to assume a single `aud`. With two crossfading elements either may be +// the audible one, so report on whichever is louder (ties → aud). +function activeAudEl() { return audB.volume > aud.volume ? audB : aud; } + // --- Live audio status readout (diagnostic) — so a "no sound" report is legible --- const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"]; function audioStatusText() { if (!$("audio").checked) return "audio: off"; const s = ring && ring.scales[ringIndex]; const url = soundtrackUrl(); + const el = activeAudEl(); const head = `ON · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`; if (!url) return "audio: " + head + " ← no soundtrack URL for this scale"; - if (aud.error) return `audio: ${head} · MEDIA ERROR ${aud.error.code} (${AUD_ERR[aud.error.code] || "?"})`; + if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`; if (audLastErr) return `audio: ${head} · ${audLastErr}`; - if (aud.paused) return `audio: ${head} · PAUSED rs=${aud.readyState}`; - return `audio: ${head} · ▶ ${aud.currentTime.toFixed(1)}s vol=${Math.round(aud.volume * 100)}% rs=${aud.readyState}`; + if (el.paused) return `audio: ${head} · PAUSED rs=${el.readyState}`; + return `audio: ${head} · ▶ ${el.currentTime.toFixed(1)}s vol=${Math.round(el.volume * 100)}% rs=${el.readyState}`; } function updateAudioStatus() { const el = $("audio-status"); if (!el) return; el.textContent = audioStatusText(); - const playing = $("audio").checked && !!soundtrackUrl() && !aud.paused && !aud.error && !audLastErr; + const a = activeAudEl(); + const playing = $("audio").checked && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr; el.classList.toggle("playing", playing); - el.classList.toggle("blocked", $("audio").checked && (!soundtrackUrl() || !!aud.error || !!audLastErr)); + el.classList.toggle("blocked", $("audio").checked && (!soundtrackUrl() || !!a.error || !!audLastErr)); } aud.addEventListener("error", updateAudioStatus); +audB.addEventListener("error", updateAudioStatus); setInterval(updateAudioStatus, 400); function fadeVolume(el, to, ms, done) { @@ -1009,43 +1017,79 @@ const SCALE_AUDIO_FALLBACK = { abyss: "abyss/whale.loop.mp3", }; -// The current altitude's soundtrack url: the ring's `audio` field, else the -// scale-id fallback above. -function soundtrackUrl() { - const s = ring && ring.scales[ringIndex]; - if (!s) return null; +// The soundtrack url for ring scale `index` (wrapped): the ring's `audio` field, +// else the scale-id fallback above. `null` when there is no ring/scale. +function scaleAudioUrl(index) { + if (!ring || !ring.scales.length) return null; + const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)]; const a = s.audio || SCALE_AUDIO_FALLBACK[s.id]; return a ? "/media/audio/" + a : null; } +// The current resting altitude's soundtrack url (kept for the status readout). +function soundtrackUrl() { return scaleAudioUrl(ringIndex); } -// Load `url` into the single element and fade it in. Call this SYNCHRONOUSLY from -// the toggle's click the first time so play() is allowed; later calls reuse the -// now-unlocked element. -function playUrl(url) { - aud.src = mediaUrl(url); - aud.volume = 0; - audLastErr = ""; - const pr = aud.play(); - if (pr) { - pr.then(() => { audLastErr = ""; updateAudioStatus(); }) - .catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); }); +// Start (once) and set the volume of one element to a url. Safari needs the first +// play() inside a user gesture; later programmatic plays reuse the unlocked element. +// Only (re)loads `src` when the url actually changes (so a same-url call is a no-op +// seek-wise and doesn't restart the loop). +function ensurePlaying(el, url, vol) { + if (!url) { el.volume = 0; if (!el.paused) el.pause(); return; } + if (el.dataset.url !== url) { + el.dataset.url = url; + el.src = mediaUrl(url); } - fadeVolume(aud, 1, FADE_MS); + if (el.paused) { + audLastErr = ""; + const pr = el.play(); + if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); }) + .catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); }); + } + el.volume = HEFScrub.clamp01(vol); } -// Reconcile audio to the Audio toggle + current altitude: on → the scale's -// soundtrack, off → silence. Idempotent on an unchanged url (so a same-scale pool -// re-roll doesn't restart it). MUST be called synchronously from the toggle gesture -// the first time it plays. -function applyAudio() { - const url = $("audio").checked ? soundtrackUrl() : null; - if (url === audUrl) return; - audUrl = url; - if (!url) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); return; } - if (aud.paused || !aud.src) playUrl(url); // start fresh - else fadeVolume(aud, 0, 250, () => playUrl(url)); // dip out, then swap + fade in +// Pick which physical element carries each of the two needed urls, reusing the +// element that already plays a url so a crossing reloads only the genuinely-new +// scale (no restart click on the scale that persists across the boundary). +function assignElements(urlLo, urlHi) { + if (aud.dataset.url === urlLo) return { elLo: aud, elHi: audB }; + if (audB.dataset.url === urlLo) return { elLo: audB, elHi: aud }; + if (aud.dataset.url === urlHi) return { elLo: audB, elHi: aud }; + if (audB.dataset.url === urlHi) return { elLo: aud, elHi: audB }; + return { elLo: aud, elHi: audB }; } +// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac). +// No-op when the Audio toggle is off. +function blendAudio(loIndex, frac) { + if (!$("audio").checked) return; + const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1); + const g = HEFScrub.crossfadeGains(frac); + const { elLo, elHi } = assignElements(urlLo, urlHi); + ensurePlaying(elLo, urlLo, g.from); + ensurePlaying(elHi, urlHi, g.to); + updateAudioStatus(); +} + +// At rest on a single altitude: the element already holding it stays at full gain, +// the other fades out. Off → both fade to silence. +function restAudio(index) { + if (!$("audio").checked) { + fadeVolume(aud, 0, FADE_MS, () => aud.pause()); + fadeVolume(audB, 0, FADE_MS, () => audB.pause()); + return; + } + const url = scaleAudioUrl(index); + const active = (audB.dataset.url === url) ? audB : aud; + const idle = active === audB ? aud : audB; + ensurePlaying(active, url, 1); + fadeVolume(idle, 0, FADE_MS); + updateAudioStatus(); +} + +// Reconcile audio to the Audio toggle + current resting altitude. MUST be called +// synchronously from the toggle gesture the first time it plays (Safari unlock). +function applyAudio() { restAudio(ringIndex); } + // "Loading Universe…" splash — hidden once media is preloaded; reflects progress. function setLoadingProgress(done, total) { const fill = document.getElementById("loading-fill"); diff --git a/simulator/static/index.html b/simulator/static/index.html index efe32dd..530b09e 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -19,6 +19,7 @@
+
@@ -109,6 +110,7 @@
+ diff --git a/simulator/unit/README.md b/simulator/unit/README.md index e7ce2bf..cc18b42 100644 --- a/simulator/unit/README.md +++ b/simulator/unit/README.md @@ -2,7 +2,7 @@ Pure-logic JS unit tests (no browser), run with Node's built-in test runner: - node --test simulator/unit/ + node --test simulator/unit/*.test.js `scrub.test.js` covers `simulator/static/scrub.js` — the pure altitude-scrub math (position→segment, frac→currentTime, audio gains, integer-crossing detection).