feat(sim): scrub-driven altitude transitions #28
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user