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:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user