From 20f49e936a3525617f54fd11c1bdd7476c1dfadd Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 15:43:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(sim):=20scrub=20engine=20=E2=80=94=20drag?= =?UTF-8?q?=20drives=20pos,=20seeks=20morph,=20commits+locks+re-rolls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per plan Task 3. A continuous float pos is the knob: setPos() seeks the segment's morph currentTime by frac (throttled one seek/rAF), crossfades audio, sweeps the needle live, and commits+locks+re-rolls on each integer crossing. Drag live-scrubs (no more commit-on-release); release holds the blend (no auto-complete). Client-side pool pick replaces the server round-trip for drag. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 31 +++++++ simulator/static/app.js | 99 ++++++++++++++++++++--- 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 9dfe98d..8e0372c 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -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())!; diff --git a/simulator/static/app.js b/simulator/static/app.js index 5ea06d9..85b8a5a 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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.