From 12efd12d4389da28524f603409a06c045ca8f9e4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 19 Jul 2026 17:45:52 -0700 Subject: [PATCH] =?UTF-8?q?video:=20player=20mixer=20=E2=80=94=20fader,=20?= =?UTF-8?q?1/=E2=88=9AN=20auto-mix,=20synchronized=20bar=20drop,=20tappabl?= =?UTF-8?q?e=20deck=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🎚️ Left-edge volume fader per player (drag; MatrixChunky8 readout) driving a new bios tape:volume (smooth 80ms ramps). - πŸ›°οΈ BroadcastChannel jam bus: sibling AC windows (Electron panes or browser tabs) heartbeat readiness; volume auto-scales by 1/√windows so a band never clips; '3win' badge top-right. - 🎬 Coordinated drop: panes boot ARMED (silent) until every sibling is ready, then all ramp in on the same UTC bar β€” async waiting with an 8s timeout so a pane that never loads can't hold the band hostage. - πŸ–²οΈ Deck-key legend rows are buttons now: tap ←/β†’ beat-jump, hold the chop rows, tap reset β€” same code paths as the keyboard. - πŸ”™ Back only when arriving from recording; synthtape players have no cap to return to. - 〰️ sine synthtape reworked: plucked 16-step half-beat melody, lower register, octave dips + rests β€” percussive, still seam-perfect. - scrub-jam: per-window virtual cursors (visible arrow + label, machine twin at window.__jamCursor) and nogap pane URLs (flush bottom bar). Co-Authored-By: Claude Fable 5 --- system/public/aesthetic.computer/bios.mjs | 48 ++- .../public/aesthetic.computer/disks/video.mjs | 285 ++++++++++++++++-- toolchain/tapes/scrub-jam.mjs | 116 +++++-- 3 files changed, 392 insertions(+), 57 deletions(-) diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 48078c3f4b..d99c247709 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -11601,19 +11601,33 @@ async function boot(parsed, bpm = 60, resolution, debug) { Math.exp(-inHalf * 12) * 0.42; // Bass stab } } else if (style === "sine") { - // 〰️ Sineline: one continuous legato sine gliding through a - // pentatonic line, with a soft sub octave β€” pure pitch - // material, lovely under a scrub. The glide keeps frequency - // continuous; a 15ms edge fade softens the loop seam. - const noteIdx = Math.floor(tt / BEAT); - const target = - pent[(noteIdx * 2 + Math.floor(noteIdx / 3)) % pent.length] / 2; - sineF += (target - sineF) * 0.0004; // ~50ms portamento + // 〰️ Sineline, plucked: one continuous-phase sine walking a + // 16-step half-beat melody in a lower register, struck with a + // percussive envelope + a tiny thock β€” a bass-y kalimba line + // rather than a drone. Rests and octave dips give it motion. + // Amplitude makes the pluck; the phase never breaks, and all + // pitches stay 0.5Hz-quantized, so the seam is still perfect. + const HALF = BEAT / 2; + const step = Math.floor(tt / HALF) % 16; + const inStep = tt % HALF; + // Pent indices; negative = rest, 10+ = same note an octave down. + const MELODY = [0, 2, 4, 12, 3, 0, -1, 1, 4, 2, 10, 2, 1, -1, 3, 0]; + const m = MELODY[step]; + const rest = m < 0; + const idx = rest ? 0 : m % 10; + const octDown = m >= 10; + const target = (pent[idx] / 2) * (octDown ? 0.5 : 1); + sineF += (target - sineF) * 0.002; // Fast settle β€” pluck pitch ph1 += (TAU * sineF) / sampleRate; ph2 += (TAU * sineF) / 2 / sampleRate; const sineEdge = Math.min(1, tt / 0.015, (duration - tt) / 0.015); - v += Math.sin(ph1) * 0.48 * sineEdge; - v += Math.sin(ph2) * 0.16 * sineEdge; + if (!rest) { + const att = Math.min(1, inStep / 0.004); // 4ms bite + const env = Math.exp(-inStep * 7) * att; + v += Math.sin(ph1) * 0.5 * env * sineEdge; + v += Math.sin(ph2) * 0.2 * Math.exp(-inStep * 4) * att * sineEdge; // Sub body + v += noise * Math.exp(-inStep * 160) * 0.1; // Thock + } } else if (style === "dub") { // 🌫️ Halftime dub: deep kicks on 1 & 3, rim on 3, a sub line // per half-bar, and offbeat skank chords. @@ -12323,6 +12337,20 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + if (type === "tape:volume") { + // 🎚️ Live volume for the playing tape loop (0..1) β€” the per-player + // mix fader and the multi-window auto-mixer both land here. + const tapeAudioId = Object.keys(sfxPlaying).find((id) => + id.startsWith("tape:audio_"), + ); + if (tapeAudioId && sfxPlaying[tapeAudioId]) { + const v = + typeof content === "number" ? Math.max(0, Math.min(1, content)) : 1; + sfxPlaying[tapeAudioId].update({ volume: v, duration: 0.08 }); + } + return; + } + if (type === "tape:telemetry") { // Stash on window so an external jam/test harness can read live // rate + net-time sync offset numerically. diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs index b20a21c237..195fff9591 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -162,6 +162,25 @@ let scrubLastSimTime = 0; // tape so scrub feel can be tuned without recording anything. `auto` runs a // scripted gesture exercise that grades the physics (πŸ§ͺ SYNTHSCRUB logs). let isSynthTape = false; + +// 🎚️ Player mixer + multi-window jam coordination (BroadcastChannel). +// Every AC tape window heartbeats on a shared channel; volume auto-scales +// by 1/√N so a band of panes never clips, and freshly-booted panes hold +// silence ("armed") until every sibling is ready β€” then all drop in on +// the same UTC bar. Timeouts cover a pane that never finishes loading. +let mixVolume = 1; // The local fader, 0..1 +let volBtn = null; // Left-edge fader hit area +let jamChannel = null; +let jamId = null; +let jamPeers = new Map(); // id -> { t, ready } +let jamReady = false; // This window's tape is loaded + presenting +let jamArmed = false; // Holding silence for the coordinated drop +let jamArmedAt = 0; // performance.now() when we became ready +let jamStartAt = 0; // UTC ms of the agreed drop (0 = not yet agreed) +let jamHeartbeat = null; +let lastSentTapeVolume = -1; +const JAM_ARM_TIMEOUT = 8000; // A never-loading sibling can't hold us hostage +let legendBtns = null; // Tappable deck-key legend (bottom-right) let synthAuto = false; let autoScript = null; // Remaining autopilot segments let autoSeg = null; // Segment currently running @@ -205,6 +224,60 @@ async function handleAudioContextAndPlay(sound, rec, triggerRender) { triggerRender(); } +// πŸ›°οΈ Join the local jam: open the shared channel, heartbeat readiness, +// prune silent peers. postMessage-family (BroadcastChannel) β€” works across +// Electron panes and browser tabs on the same origin alike. +function jamJoin() { + if (typeof BroadcastChannel === "undefined" || jamChannel) return; + jamId = Math.random().toString(36).slice(2, 9); + jamPeers = new Map(); + jamReady = false; + jamArmed = true; + jamArmedAt = 0; + jamStartAt = 0; + jamChannel = new BroadcastChannel("ac-tape-jam"); + jamChannel.onmessage = (ev) => { + const m = ev.data || {}; + if (!m.id || m.id === jamId) return; + if (m.type === "bye") { + jamPeers.delete(m.id); + return; + } + jamPeers.set(m.id, { t: performance.now(), ready: !!m.ready }); + // Adopt the earliest announced drop time so the band agrees. + if (m.startAt && (!jamStartAt || m.startAt < jamStartAt)) { + jamStartAt = m.startAt; + } + }; + jamHeartbeat = setInterval(() => { + try { + jamChannel?.postMessage({ + id: jamId, + type: "hb", + ready: jamReady, + startAt: jamStartAt, + }); + } catch {} + const now = performance.now(); + for (const [id, p] of jamPeers) if (now - p.t > 3000) jamPeers.delete(id); + }, 500); +} + +function jamLeave() { + try { + jamChannel?.postMessage({ id: jamId, type: "bye" }); + jamChannel?.close(); + } catch {} + if (jamHeartbeat) clearInterval(jamHeartbeat); + jamHeartbeat = null; + jamChannel = null; + jamPeers = new Map(); + jamReady = false; + jamArmed = false; + jamStartAt = 0; + lastSentTapeVolume = -1; +} + function ensureScrubStripButton(ui, screen, enabled) { if (!enabled) { scrubStripBtn = null; @@ -476,6 +549,7 @@ function boot({ wipe, rec, gizmo, jump, notice, store, params, send, hud }) { tapeLoadProgress = 0; tapeLoadMessage = "SYNTHESIZING TAPE"; hud?.label(`synthtape ${style}${synthAuto ? " auto" : ""}`); + jamJoin(); // Sibling AC windows coordinate volume + a synchronized drop. send({ type: "tape:play-synth", content: { duration, fps, style } }); } else { // Try to restore cached video from IndexedDB @@ -652,13 +726,19 @@ function paint({ zipBtn = undefined; } - // πŸ”™ Back-to-cap button (bottom-left) for the cap β†’ review β†’ re-cap loop. - if (!backBtn) { - backBtn = new ui.TextButton("Back", { left: 6, bottom: 6, screen }); + // πŸ”™ Back-to-cap button (bottom-left) for the cap β†’ review β†’ re-cap + // loop β€” only when we actually came from recording. A synthtape player + // has no cap to go back to, so it gets no Back at all. + if (!isSynthTape) { + if (!backBtn) { + backBtn = new ui.TextButton("Back", { left: 6, bottom: 6, screen }); + } + backBtn.reposition({ left: 6, bottom: 6, screen }, "Back"); + backBtn.disabled = disableExports; + backBtn.paint(api); + } else { + backBtn = undefined; } - backBtn.reposition({ left: 6, bottom: 6, screen }, "Back"); - backBtn.disabled = disableExports; - backBtn.paint(api); } else { postBtn = undefined; gifBtn = undefined; @@ -967,21 +1047,109 @@ function paint({ ); } - // ⌨️ Deck keys legend, bottom-right. - const keyLines = [ - "<- -> beat jump", - "hold ^ 1/4 chop", - "hold v 1/8 chop", - "space reset", + // 🎚️ Left-edge mix fader: drag to set this player's level. The auto + // mixer (1/√windows) rides on top; the number shows what's actually + // sent. Display + control live together on the left, away from the + // display-only top-right. + if (jamChannel) { + const fTop = 28; + const fBottom = screen.height - 34; + const fH = Math.max(24, fBottom - fTop); + if (!volBtn) { + volBtn = new ui.Button(0, fTop - 8, 16, fH + 16); + volBtn.stickyScrubbing = true; + volBtn.noRolloverActivation = true; + } + volBtn.box.x = 0; + volBtn.box.y = fTop - 8; + volBtn.box.w = 16; + volBtn.box.h = fH + 16; + ink(255, 255, 255, 36).box(4, fTop, 3, fH); // Track + const fillH = Math.round(fH * mixVolume); + ink(volBtn.down ? [0, 255, 180] : [255, 255, 0, 180]).box( + 4, + fTop + (fH - fillH), + 3, + fillH, + ); // Level + const shown = Math.round((lastSentTapeVolume < 0 ? mixVolume : lastSentTapeVolume) * 100); + ink(255, 255, 255, 160).write( + `${shown}`, + { x: 2, y: fBottom + 4 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + // Band size, under the beat blink (display-only, top-right). + if (jamPeers.size > 0) { + ink(0, 255, 255, 180).write( + `${jamPeers.size + 1}win`, + { x: screen.width - 6, y: 40, right: true }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + // ⏳ Armed: holding silence until the whole band is ready. + if (jamArmed) { + const ready = [...jamPeers.values()].filter((p) => p.ready).length + (jamReady ? 1 : 0); + ink(255, 255, 0).write( + `ARMED ${ready}/${jamPeers.size + 1}`, + { center: "x", y: screen.height / 2 - 20 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + } + } + + // βŒ¨οΈπŸ–²οΈ Deck keys legend, bottom-right β€” every row is also a button: + // tap ←/β†’ to beat-jump, hold the chop rows, tap reset. Keyboard and + // touch drive the exact same moves. + const keyRows = [ + { key: "jumpL", label: "<-", extra: "-> beat jump" }, + { key: "chop4", label: "hold ^ 1/4 chop" }, + { key: "chop8", label: "hold v 1/8 chop" }, + { key: "reset", label: "space reset" }, ]; - keyLines.forEach((l, i) => { - ink(255, 255, 255, 110).write( - l, - { - x: screen.width - 6, - y: screen.height - 10 - (keyLines.length - i) * 10, - right: true, - }, + if (!legendBtns) legendBtns = {}; + const rowH = 10; + keyRows.forEach((row, i) => { + const y = screen.height - 10 - (keyRows.length - i) * rowH; + const full = row.extra ? `${row.label} ${row.extra}` : row.label; + const w = 78; // Generous hit box for the whole row + if (row.key === "jumpL") { + // Row 1 splits: left half jumps back, right half jumps forward. + for (const [k, x0] of [["jumpL", screen.width - 6 - w], ["jumpR", screen.width - 6 - w / 2]]) { + if (!legendBtns[k]) { + legendBtns[k] = new ui.Button(x0, y - 1, w / 2, rowH); + legendBtns[k].noRolloverActivation = true; + } + legendBtns[k].box.x = x0; + legendBtns[k].box.y = y - 1; + legendBtns[k].box.w = w / 2; + legendBtns[k].box.h = rowH; + } + } else { + if (!legendBtns[row.key]) { + legendBtns[row.key] = new ui.Button(screen.width - 6 - w, y - 1, w, rowH); + legendBtns[row.key].noRolloverActivation = true; + } + legendBtns[row.key].box.x = screen.width - 6 - w; + legendBtns[row.key].box.y = y - 1; + legendBtns[row.key].box.w = w; + legendBtns[row.key].box.h = rowH; + } + const active = + row.key === "jumpL" + ? legendBtns.jumpL?.down || legendBtns.jumpR?.down + : legendBtns[row.key]?.down; + ink(255, 255, 255, active ? 255 : 110).write( + full, + { x: screen.width - 6, y, right: true }, undefined, undefined, false, @@ -1097,6 +1265,38 @@ function sim({ needsPaint, rec, send, clock, sound }) { } } + // πŸŽšοΈπŸ›°οΈ Jam mix: readiness β†’ coordinated drop β†’ live volume. + if (jamChannel && rec?.presenting) { + if (!jamReady && tapeInfo?.totalDuration) { + jamReady = true; + jamArmedAt = performance.now(); + } + if (jamArmed && jamReady) { + const peers = [...jamPeers.values()]; + const allReady = peers.length > 0 && peers.every((p) => p.ready); + const waitedOut = performance.now() - jamArmedAt > JAM_ARM_TIMEOUT; + const solo = peers.length === 0 && performance.now() - jamArmedAt > 1200; + if (!jamStartAt && (allReady || waitedOut || solo)) { + // Everyone applies the same rule against the same UTC clock, so + // the drop is simultaneous by construction: next bar boundary. + const nowU = clock?.time?.()?.getTime?.() ?? Date.now(); + const grid = peers.length ? 2000 : 500; // Band: a bar; solo: a beat + jamStartAt = Math.ceil((nowU + 150) / grid) * grid; + } + if (jamStartAt) { + const nowU = clock?.time?.()?.getTime?.() ?? Date.now(); + if (nowU >= jamStartAt) jamArmed = false; // πŸŽ‰ The drop. + } + } + // Auto-mix: fader Γ— 1/√(windows) β€” more panes, softer each, no clipping. + const autoGain = 1 / Math.sqrt(1 + jamPeers.size); + const target = jamArmed ? 0 : mixVolume * autoGain; + if (Math.abs(target - lastSentTapeVolume) > 0.004) { + lastSentTapeVolume = target; + send({ type: "tape:volume", content: target }); + } + } + // Measured wall-time per sim tick β€” the scrub physics scales by this so // commanded speed means tape-seconds per wall-second at any tick rate. // Sim calls arrive in bursts, so the clamp must stay generous: it only @@ -2413,8 +2613,8 @@ function act({ // ⌨️ Spacebar is the only pause β€” touch never pauses, but a deliberate // keypress can still halt and resume the tape. - if (e.is("keyboard:down:space") && rec.presenting) { - // Spacebar also resets any parked/spinning rate back to normal. + // Full reset/pause β€” spacebar and the legend's "reset" button share it. + const fullReset = () => { isScrubbing = false; inertiaActive = false; brakeHolding = false; @@ -2429,9 +2629,26 @@ function act({ if (rec.playing) rec.pause(); else rec.play(); triggerRender(); + }; + if (e.is("keyboard:down:space") && rec.presenting) { + fullReset(); return; } + // 🎚️ Mix fader (left edge): drag sets this player's level directly. + if (volBtn && rec.presenting && !isPrinting && !isPostingTape) { + const setMixFromY = () => { + if (e.y === undefined) return; + const fTop = 28; + const fBottom = screen.height - 34; + const fH = Math.max(24, fBottom - fTop); + mixVolume = Math.max(0, Math.min(1, 1 - (e.y - fTop) / fH)); + triggerRender(); + }; + volBtn.act(e, { down: setMixFromY, scrub: setMixFromY }); + if (volBtn.down) return; // The fader owns this gesture + } + // πŸŽ›οΈ Deck keys, Pioneer-style: ← β†’ beat-jump the tape (audio included); // holding ↑ chop-repeats a quarter beat, ↓ an eighth β€” release to run on. if (rec.presenting && tapeInfo?.totalDuration) { @@ -2465,6 +2682,29 @@ function act({ if (e.is("keyboard:up:arrowup") || e.is("keyboard:up:arrowdown")) { chopActive = 0; } + + // πŸ–²οΈ The legend rows are buttons too β€” identical moves by touch. + const chopDown = (fraction) => () => { + if (chopActive) return; + ensureDriven(rec, send); + chopActive = fraction; + chopStart = scrubCurrentProgress; + send({ type: "tape:audio-pos", content: chopStart }); + }; + const chopUp = () => { + chopActive = 0; + }; + legendBtns?.jumpL?.act(e, { down: () => beatJump(-1) }); + legendBtns?.jumpR?.act(e, { down: () => beatJump(1) }); + legendBtns?.chop4?.act(e, { down: chopDown(0.25), up: chopUp, cancel: chopUp }); + legendBtns?.chop8?.act(e, { down: chopDown(0.125), up: chopUp, cancel: chopUp }); + legendBtns?.reset?.act(e, { push: fullReset }); + if ( + legendBtns && + Object.values(legendBtns).some((b) => b?.down) + ) { + return; // A deck button owns this gesture β€” not the scrub strip. + } } // πŸ–±οΈ Two-finger scroll scrubs directly β€” no tap-drag needed. Wheel @@ -3238,6 +3478,7 @@ function leave({ send, rec }) { // End the presentation properly β€” otherwise rec.presenting stays true // and the bottom progress bar cursor haunts the next piece (prompt). rec?.unpresent?.(); + jamLeave(); // Tell sibling windows we're gone; stop heartbeating. isScrubbing = false; inertiaActive = false; brakeHolding = false; diff --git a/toolchain/tapes/scrub-jam.mjs b/toolchain/tapes/scrub-jam.mjs index 0a591661b2..c8f3942b82 100644 --- a/toolchain/tapes/scrub-jam.mjs +++ b/toolchain/tapes/scrub-jam.mjs @@ -84,61 +84,119 @@ class Performer { } } + // πŸ‘» Virtual cursor: a visible in-page arrow that mirrors every synthetic + // mouse move, so a human (or a `frame` capture) can SEE each driver's + // hand. Machine-readable twin lives at window.__jamCursor {x,y,down}. + async installCursor() { + const color = CURSOR_COLORS[this.idx % CURSOR_COLORS.length]; + try { + await this.page.evaluate( + ({ label, color }) => { + window.__jamCursorEl?.remove(); + const el = document.createElement("div"); + el.id = "jam-cursor"; + el.setAttribute("role", "img"); + el.setAttribute("aria-label", `jam-cursor-${label}`); + el.style.cssText = + "position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;" + + `font:bold 10px monospace;color:${color};text-shadow:1px 1px 0 #000;`; + el.innerHTML = + `` + + `` + + `
${label}
`; + document.body.appendChild(el); + window.__jamCursorEl = el; + window.__jamCursor = { x: 0, y: 0, down: false }; + window.__jamCursorMove = (x, y, down) => { + window.__jamCursor = { x, y, down }; + el.style.transform = `translate(${x}px,${y}px)`; + el.style.filter = down ? "brightness(1.7) drop-shadow(0 0 3px #fff)" : "none"; + }; + }, + { label: this.tape, color }, + ); + } catch {} + } + + // Mirror a pointer state onto the virtual cursor (fire-and-forget). + cur(x, y, down = this.curDown) { + this.curDown = down; + this.curX = x; + this.curY = y; + this.page + .evaluate((a) => window.__jamCursorMove?.(a.x, a.y, a.down), { x, y, down }) + .catch(() => {}); + } + + // Pointer wrappers: every synthetic move/press also moves the visible + // cursor, so gestures are watchable and frame-capturable. + async mmove(x, y, opts) { + this.cur(x, y); + await this.page.mouse.move(x, y, opts); + } + async mdown() { + this.cur(this.curX ?? this.cx, this.curY ?? this.cy, true); + await this.page.mouse.down(); + } + async mup() { + this.cur(this.curX ?? this.cx, this.curY ?? this.cy, false); + await this.page.mouse.up(); + } + // Unlock audio with a trusted click, then let the loop play a beat. async wake() { - const m = this.page.mouse; - await m.move(this.cx, this.cy); - await m.click(this.cx, this.cy); // Trusted β†’ unlocks AudioContext + await this.installCursor(); + await this.mmove(this.cx, this.cy); + this.cur(this.cx, this.cy, true); + await this.page.mouse.click(this.cx, this.cy); // Trusted β†’ unlocks AudioContext + this.cur(this.cx, this.cy, false); await sleep(400); } async wheelBurst(deltaX, count, gap) { - const m = this.page.mouse; - await m.move(this.cx, this.cy); + await this.mmove(this.cx, this.cy); for (let i = 0; i < count && !this.stopped; i++) { - await m.wheel({ deltaX }); + await this.page.mouse.wheel({ deltaX }); await sleep(gap); } } // A rubber-band drag: grab, displace horizontally (rate), release. async dragScratch(dir, reach, hold) { - const m = this.page.mouse; - await m.move(this.cx, this.cy); - await m.down(); + await this.mmove(this.cx, this.cy); + await this.mdown(); const steps = 8; for (let i = 1; i <= steps && !this.stopped; i++) { - await m.move(this.cx + dir * reach * (i / steps), this.cy); + await this.mmove(this.cx + dir * reach * (i / steps), this.cy); await sleep(hold / steps); } - await m.up(); + await this.mup(); } // Back-and-forth drag scratch β€” the DJ wiggle. async wiggle(times) { - const m = this.page.mouse; - await m.move(this.cx, this.cy); - await m.down(); + await this.mmove(this.cx, this.cy); + await this.mdown(); for (let i = 0; i < times && !this.stopped; i++) { - await m.move(this.cx + 120, this.cy, { steps: 4 }); + await this.mmove(this.cx + 120, this.cy, { steps: 4 }); await sleep(90); - await m.move(this.cx - 120, this.cy, { steps: 4 }); + await this.mmove(this.cx - 120, this.cy, { steps: 4 }); await sleep(90); } - await m.move(this.cx, this.cy, { steps: 3 }); - await m.up(); + await this.mmove(this.cx, this.cy, { steps: 3 }); + await this.mup(); } // Flick: quick throw then release β†’ wheel spins free, friction glides home. async flick(dir) { - const m = this.page.mouse; - await m.move(this.cx, this.cy); - await m.down(); + await this.mmove(this.cx, this.cy); + await this.mdown(); for (let i = 1; i <= 5 && !this.stopped; i++) { - await m.move(this.cx + dir * 30 * i, this.cy); + await this.mmove(this.cx + dir * 30 * i, this.cy); await sleep(12); } - await m.up(); + await this.mup(); } async beatJump(n, key) { @@ -264,8 +322,16 @@ async function adoptPage(browser, url) { return page; } -const tapeURL = (tape) => - `${BASE}/video~scrub~${tape}${DENSITY ? `?density=${DENSITY}` : ""}`; +// nogap always: the AC canvas reaches the window edge, so the tape's +// bottom progress bar sits on the true bottom of the pane. +const tapeURL = (tape) => { + const q = ["nogap=true"]; + if (DENSITY) q.push(`density=${DENSITY}`); + return `${BASE}/video~scrub~${tape}?${q.join("&")}`; +}; + +// Per-window cursor colors β€” sine yellow, house cyan, dub pink, break blue. +const CURSOR_COLORS = ["#ffdd00", "#00ffcc", "#ff66aa", "#66aaff"]; async function launchWindow(tape, idx) { const { browser, launched } = await getBrowser(idx); -- 2.51.2