feat(sim): two-element audio crossfade keyed by scrub fraction

Per plan Task 2. Adds <audio id=aud-b>; blendAudio(lo,frac) mixes the two
adjacent scale soundtracks by knob fraction (element reuse across crossings so
only the genuinely-new scale reloads); restAudio settles a single scale at rest.
Diagnostic readout now reports whichever element is audible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 15:40:04 -07:00
parent 3dc3e3491f
commit 0548e9a82b
4 changed files with 87 additions and 35 deletions
@@ -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())!;
+78 -34
View File
@@ -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");
+2
View File
@@ -19,6 +19,7 @@
<div class="screen">
<video id="vid" loop muted playsinline></video>
<audio id="aud" loop preload="auto"></audio>
<audio id="aud-b" loop preload="auto"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
@@ -109,6 +110,7 @@
</div>
</section>
</main>
<script src="/scrub.js"></script>
<script src="/app.js"></script>
</body>
</html>
+1 -1
View File
@@ -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).