feat(sim): scrub-driven altitude transitions #28

Merged
benstull merged 14 commits from session-0026 into main 2026-06-28 01:46:55 +00:00
2 changed files with 120 additions and 10 deletions
Showing only changes of commit 20f49e936a - Show all commits
+31
View File
@@ -29,12 +29,43 @@ async function wheelOnStage(page: Page, deltaY: number) {
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
}
// The Audio toggle is a visually-hidden checkbox styled as a switch; set it + fire
// `change` directly (headless Chromium relaxes the autoplay gesture requirement).
async function enableAudio(page: Page) {
await page.evaluate(() => {
const c = document.querySelector("#audio") as HTMLInputElement;
c.checked = true;
c.dispatchEvent(new Event("change", { bubbles: true }));
});
}
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("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
await boot(page);
await enableAudio(page); // hidden custom toggle → set + dispatch change
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // partial clockwise turn (~0.5 detent)
// The currentTime seek is throttled to one rAF; wait for it to land.
await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
const state = await page.evaluate(() => ({
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
}));
expect(state.t).toBeGreaterThan(0); // morph scrubbed off frame 0
expect(state.gb).toBeGreaterThan(0); // next scale fading in
expect(state.ga).toBeLessThan(1); // current scale fading out
await page.mouse.up();
});
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())!;
+89 -10
View File
@@ -699,7 +699,12 @@ function onWheel(e) {
const dial = $("dial");
const DIAL_C = 50, DIAL_LABEL_R = 41, DIAL_BODY_R = 27;
let needleDeg = 0;
let dialDrag = null; // {lastAng, accum, moved} while turning
let dialDrag = null; // {lastAng, accum, moved, restPos, target} while turning
// --- Scrub engine state: the knob IS a continuous position ---
let pos = 0; // continuous knob position; rest value == ringIndex
let activeSeg = null; // { lo, clipLo, clipHi, file } for the segment being scrubbed
let seekPending = false; // throttle: at most one currentTime seek per animation frame
function dialStep() { return ring && ring.scales.length ? 360 / ring.scales.length : 360; }
@@ -763,10 +768,88 @@ function angDelta(a, b) {
return d;
}
// A random pool member's clip_id for ring scale `index` (wrapped) — the client-side
// destination pick (was the server's job in /api/ring/advance; moved here so the
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
// canonical pure helper for the Pi player).
function pickPoolClip(index) {
const n = ring.scales.length;
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
// (`enteredFrom`) keeps the locked clip; the OTHER end is a fresh random pick
// (re-roll on fresh approach). The canonical file is always the descend/forward
// morph clip@lo -> clip@lo+1, scrubbed bidirectionally (turn-back seeks it backward).
function rebuildSegment(lo, enteredFrom) {
const n = ring.scales.length;
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
const file = morphByPair[`${loId}${hiId}`] || null;
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
if (file) {
overlay.style.opacity = "0";
affectLayer.style.opacity = "0";
tint.style.opacity = "0";
vid.style.filter = "none";
vid.loop = false;
vid.style.opacity = "1";
if (vid.dataset.morph !== file) {
vid.dataset.morph = file;
vid.src = mediaUrl(file);
vid.pause();
}
// Diagnostic seam (matches advance()'s old record): the logical morph path
// played, reliable even when served from an in-memory blob.
(window.__hefMorphs || (window.__hefMorphs = [])).push(file);
}
}
// Drive the continuous position. Commits every integer crossed since the last
// position (lock + re-roll), then either settles on an altitude (frac 0) or scrubs
// the active segment's morph + audio by the fraction.
function setPos(next) {
if (!ring || ring.scales.length < 2) return;
const n = ring.scales.length;
for (const c of HEFScrub.integerCrossings(pos, next)) {
ringIndex = HEFScrub.wrapIndex(c.index, n);
if (activeSeg) {
activeClipId = (HEFScrub.wrapIndex(activeSeg.lo, n) === ringIndex) ? activeSeg.clipLo : activeSeg.clipHi;
}
activeSeg = null; // crossing ends the segment → next entry is a fresh approach (re-roll)
}
pos = next;
const { lo, frac } = HEFScrub.segmentOf(pos);
if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack
busy = false;
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
setNeedle(ringIndex * dialStep());
renderScaleReadout();
update();
restAudio(ringIndex);
return;
}
busy = true; // mid-morph: block update() from reloading the base clip under us
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
setNeedle(pos * dialStep());
blendAudio(lo, frac);
if (activeSeg.file && !seekPending) { // throttle seeks to one per frame (avoid seek thrash)
seekPending = true;
requestAnimationFrame(() => {
seekPending = false;
if (vid.dataset.morph === activeSeg.file) vid.currentTime = HEFScrub.fracToTime(frac, vid.duration);
});
}
}
function onDialDown(e) {
if (busy || !ring || ring.scales.length < 2) return;
if (!ring || ring.scales.length < 2) return;
e.preventDefault();
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target };
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos };
}
function onDialMove(e) {
if (!dialDrag) return;
@@ -775,23 +858,19 @@ function onDialMove(e) {
dialDrag.lastAng = a;
dialDrag.accum += d;
dialDrag.moved += Math.abs(d);
// No live finger-follow: the needle should move ONLY with the transition (it
// sweeps in sync with the morph on commit — see advance()), so we don't rotate
// it ahead of the zoom here. We still accumulate `accum` to score the detents.
setPos(HEFScrub.accumToPos(dialDrag.restPos, dialDrag.accum, dialStep())); // LIVE scrub: knob position IS the transition
}
function onDialUp(e) {
if (!dialDrag) return;
const { accum, moved, target } = dialDrag;
const { moved, target } = dialDrag;
dialDrag = null;
if (moved < 6) { // a tap, not a turn
if (target && target.classList && target.classList.contains("dial-label")) {
jumpToScale(+target.getAttribute("data-index"));
}
renderDial();
return;
}
const detents = Math.round(accum / dialStep());
if (detents) advance(detents); else renderDial(); // snap back if it didn't cross a detent
// No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
}
// Click a label → travel the SHORTEST signed way around the ring to that scale.