diff --git a/system/public/aesthetic.computer/disks/cap.mjs b/system/public/aesthetic.computer/disks/cap.mjs index 49ec752893..f1b98c249e 100644 --- a/system/public/aesthetic.computer/disks/cap.mjs +++ b/system/public/aesthetic.computer/disks/cap.mjs @@ -19,11 +19,7 @@ - [x] Jump to video piece on completion #endregion */ -import { - RecordingTimer, - MicLevel, - sounds, -} from "./common/cap-ui.mjs"; +import { RecordingTimer, MicLevel } from "./common/cap-ui.mjs"; const { floor, min, max, sqrt } = Math; @@ -312,7 +308,7 @@ function sim({ sound: { microphone }, rec }) { } } -function startRecording(rec, sound, notice) { +function startRecording(rec, notice) { if (isRecording) return; // If mic isn't connected yet, queue the recording start @@ -326,7 +322,6 @@ function startRecording(rec, sound, notice) { isRecording = true; recordingStarted = true; pendingRecordStart = false; - sounds.recordStart(sound); // Start the visual timer timer.start(MAX_DURATION); @@ -357,21 +352,19 @@ function startRecording(rec, sound, notice) { // Recording status is shown via recording UI overlay (not captured in tape) } -function stopRecording(rec, sound, jump) { +function stopRecording(rec, jump) { if (!isRecording) return; isRecording = false; timer.stop(); - if (sound) sounds.recordStop(sound); - // Cut the recording and jump to video piece rec.cut(() => { if (jump) jump("video"); }); } -function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud }) { +function act({ event: e, jump, video, cameras, rec, notice, leaving, hud }) { // πŸ“Š Camera diagnostics from bios.mjs β†’ piece-runs via console.log. if (e.is("camera:debug")) { console.log("πŸ“· cam/init:", JSON.stringify(e)); @@ -410,7 +403,7 @@ function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud // start it now. if (pendingRecordStart && !isRecording) { console.log("🎀 Mic ready - starting queued recording"); - startRecording(rec, sound, notice); + startRecording(rec, notice); } } @@ -424,9 +417,7 @@ function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud const flashWasDown = flashBtn?.down === true; if (flashBtn && facing === "environment" && torchAvailable) { flashBtn.act(e, { - down: () => sounds.down(sound), push: () => { - sounds.push(sound); torchPending = true; video("camera:update", { torch: !torchEnabled }); }, @@ -437,9 +428,7 @@ function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud // itself, so it doesn't compete with the fullscreen hold-to-record gesture. if (cameras > 1 && swapBtn && !isRecording && !pendingRecordStart) { swapBtn.act(e, { - down: () => sounds.down(sound), push: () => { - sounds.push(sound); swapBtn.disabled = true; const faceTo = facing === "user" ? "environment" : "user"; vid = video("camera:update", { facing: faceTo }); @@ -457,7 +446,7 @@ function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud // Anchor zoom slider at the touch-down Y so the very first drag // pixel is treated as zoom = 1, no jumps. zoomStartY = e.y; - startRecording(rec, sound, notice); + startRecording(rec, notice); } } @@ -473,7 +462,7 @@ function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud if (e.is("lift") && !leaving()) { if (isRecording && !flashWasDown) { - stopRecording(rec, sound, jump); + stopRecording(rec, jump); } else if (pendingRecordStart && !flashWasDown) { // User released before the mic was ready β€” cancel the queued start. pendingRecordStart = false; @@ -486,15 +475,15 @@ function act({ event: e, jump, video, cameras, sound, rec, notice, leaving, hud // Keyboard shortcut: space toggles record (useful for desktop testing). if (e.is("keyboard:down: ")) { if (!isRecording && !pendingRecordStart) { - startRecording(rec, sound, notice); + startRecording(rec, notice); } else { - stopRecording(rec, sound, jump); + stopRecording(rec, jump); } } if (e.is("keyboard:down:escape")) { if (isRecording) { - stopRecording(rec, sound, jump); + stopRecording(rec, jump); } else { jump("prompt"); } @@ -555,7 +544,7 @@ function beat({ sound: { microphone } }) { function leave({ rec, jump, video }) { if (torchEnabled) video("camera:update", { torch: false }); if (isRecording) { - stopRecording(rec, null, jump); + stopRecording(rec, jump); } } diff --git a/system/public/aesthetic.computer/disks/common/cap-ui.mjs b/system/public/aesthetic.computer/disks/common/cap-ui.mjs index 34c587d5f0..b77c6d8511 100644 --- a/system/public/aesthetic.computer/disks/common/cap-ui.mjs +++ b/system/public/aesthetic.computer/disks/common/cap-ui.mjs @@ -48,29 +48,6 @@ export const sounds = { }); }, - recordStart(sound) { - // Beep to indicate recording started - sound.synth({ - type: "sine", - tone: 1200, - attack: 0.01, - decay: 0.3, - volume: 0.4, - duration: 0.1, - }); - }, - - recordStop(sound) { - // Double beep to indicate recording stopped - sound.synth({ - type: "sine", - tone: 800, - attack: 0.01, - decay: 0.5, - volume: 0.3, - duration: 0.08, - }); - }, }; /** diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs index 4a1bf4a133..f718db053d 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -1,6 +1,13 @@ // Video, 23.1.26.12.01 // Play back and be able to export / upload a recorded video. +import { + directionalCruiseTarget, + easeRateToward, + scratchProgressDelta, + scratchRateFromMotion, +} from "../lib/scratch-physics.mjs"; + /* #region ✏️ todo + Now - [-] Add stamp on export. @@ -132,9 +139,12 @@ const PARK_SNAP = 0.05; // Within this of 1Γ—, a release ends the scrub cleanly // 🎰 Wheel: a FLICK release lets the platter run free, then it eases down // like a prize wheel to the pre-flick rate. A gentle release parks instead. let wheelActive = false; -let flickVel = 0; // Low-passed drag velocity, for telling flicks from lets-go -const FLICK_THRESHOLD = 5; // px/event of recent drag velocity -const FLICK_KICK = 0.12; // Extra rate per px of flick velocity +let flickVel = 0; // Low-passed platter rate at the end of the gesture +let scratchVelocity = 0; +let lastScratchEventAt = 0; +let driveDirection = 1; +const FLICK_THRESHOLD = 1.2; // Playback-rate magnitude that counts as a throw +const FLICK_KICK = 0.2; // πŸ“Ÿ Steady-rate dial: dragging the top-right rate readout vertically sets // a held steady rate β€” friction leaves it alone until it's brought back to @@ -194,8 +204,8 @@ const DESKTOP_HOST = const SAFE_R = DESKTOP_HOST ? 20 : 6; // Right-edge inset for chrome const SAFE_T = DESKTOP_HOST ? 8 : 3; // Top inset (rounded corners bite here) const SAFE_B = DESKTOP_HOST ? 16 : 12; // Bottom inset for the pad cluster +const CONTROL_RAIL_HEIGHT = 28; let audioDiag = { audioRate: 0, ctxRate: 0, latencyMs: 0 }; // From bios -let fpsTimes = []; // Overlay paint timestamps for the fps meter 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; @@ -305,8 +315,7 @@ function ensureScrubStripButton(ui, screen, enabled) { scrubStripBtn = null; return; } - const bottomControlsHeight = 28; - const h = Math.max(1, screen.height - bottomControlsHeight); + const h = Math.max(1, screen.height - CONTROL_RAIL_HEIGHT); if (!scrubStripBtn) { scrubStripBtn = new ui.Button(0, 0, screen.width, h); scrubStripBtn.stickyScrubbing = true; @@ -430,6 +439,9 @@ function boot({ wipe, rec, gizmo, jump, notice, store, params, send, hud }) { sustained = false; chopActive = 0; flickVel = 0; + scratchVelocity = 0; + lastScratchEventAt = 0; + driveDirection = 1; resumeTarget = 1; dipBase = 1; scrubSpeed = 0; @@ -645,11 +657,9 @@ function paint({ const hasRecording = rec?.recorded ?? false; const exportAvailable = presenting || hasRecording || recPresentProgress > 0; - // Map playback progress to VHS tape progress bar (during playback only, not export) - if (playing && !isPrinting && recPresentProgress > 0 && recPresentProgress < 1) { - rec.tapeProgress = recPresentProgress; - } else if (!isPrinting) { - // Clear tape progress when not playing + // Playback draws its own timeline above the control rail. The system VHS + // bar remains reserved for recording/export progress. + if (!isPrinting) { rec.tapeProgress = 0; } @@ -669,9 +679,15 @@ function paint({ ink(255, 200).write("||", { center: "xy" }); ink(255, 75).box(0, 0, screen.width, screen.height, "inline"); } - // Thin progress bar along the bottom. + ink(0, 0, 0, 105).box( + 0, + screen.height - CONTROL_RAIL_HEIGHT, + screen.width, + CONTROL_RAIL_HEIGHT, + ); + // Thin progress bar above the control rail. const barH = 2; - const barY = screen.height - barH; + const barY = screen.height - CONTROL_RAIL_HEIGHT - barH; ink(255, 40).box(0, barY, screen.width, barH); ink(255, 200, 0).box(0, barY, Math.floor(mp4Progress * screen.width), barH); @@ -691,6 +707,15 @@ function paint({ ink(255, 75).box(0, 0, screen.width, screen.height, "inline"); } + if (exportAvailable && !isPrinting) { + ink(0, 0, 0, 105).box( + 0, + screen.height - CONTROL_RAIL_HEIGHT, + screen.width, + CONTROL_RAIL_HEIGHT, + ); + } + // Draw export buttons - reposition every frame (simple!) if (exportAvailable) { const disableExports = isPrinting || isPostingTape; @@ -1005,7 +1030,7 @@ function paint({ "MatrixChunky8", ); - // πŸ”΄ The red marker rides the bottom edge at the actual playback + // πŸ”΄ One playback timeline sits above the Back/Done control rail. // position β€” drawn from live state so it never lies. It blinks white // on every UTC bar boundary, a silent visual click for timing up. const livePos = scrubDriven @@ -1019,41 +1044,21 @@ function paint({ (audioDiag.latencyMs || 0) / 1000 / (tapeInfo?.totalDuration || 8); let needlePos = livePos - latComp; needlePos = ((needlePos % 1) + 1) % 1; + const timelineY = screen.height - CONTROL_RAIL_HEIGHT - 2; + ink(255, 255, 255, 45).box(0, timelineY, screen.width, 2); + ink(255, 51, 68, 170).box( + 0, + timelineY, + Math.max(1, Math.floor(needlePos * screen.width)), + 2, + ); ink(barFlash ? [255, 255, 255] : [255, 51, 68]).box( - Math.floor(needlePos * (screen.width - 6)), - screen.height - (barFlash ? 6 : 4), - 6, + Math.floor(needlePos * (screen.width - 4)), + timelineY - (barFlash ? 2 : 1), + 4, barFlash ? 6 : 4, ); - // πŸ”¬ Diagnostics row (bottom-left): overlay fps Β· tape fps Β· audio - // kHz (tapeβ†’context) Β· output latency. All live, all honest. - fpsTimes.push(performance.now()); - if (fpsTimes.length > 24) fpsTimes.shift(); - const fpsNow = - fpsTimes.length > 1 - ? Math.round( - (1000 * (fpsTimes.length - 1)) / - (fpsTimes[fpsTimes.length - 1] - fpsTimes[0]), - ) - : 0; - const tfps = - tapeInfo?.fps || - (tapeInfo?.frameCount && tapeInfo?.totalDuration - ? Math.round(tapeInfo.frameCount / tapeInfo.totalDuration) - : 0); - const diagY = screen.height - 16; - const diagWrite = (txt, x, color) => - ink(...color).write(txt, { x, y: diagY }, undefined, undefined, false, "MatrixChunky8"); - diagWrite(`${fpsNow}fps`, 6, fpsNow >= 28 ? [0, 255, 120] : [255, 170, 0]); - diagWrite(`${tfps}tf`, 48, [0, 200, 255]); - diagWrite( - `${(audioDiag.audioRate / 1000).toFixed(1)}k>${(audioDiag.ctxRate / 1000).toFixed(1)}k`, - 78, - [200, 160, 255], - ); - diagWrite(`${audioDiag.latencyMs}ms`, 152, [255, 200, 80]); - // βœ‚οΈ Chop region: while a chop is held, show the exact in/out slice // looping on the tape β€” split across the seam when it wraps. if (chopActive > 0 && tapeInfo?.totalDuration) { @@ -1609,23 +1614,22 @@ function sim({ needsPaint, rec, send, clock, sound }) { // measured tick so the feel is identical at any sim rate. const rate = simDt * 60; - // βœŠπŸ“Œ Sticky drag: while the finger is down, position is PINNED to - // it β€” the rate is whatever closes the gap this tick, so audio still - // rides the rate pipeline but the tape never slides under the hand. - // Slip belongs to release inertia and flicks only. - if (isScrubbing && stickyTargetProgress !== null) { - let dp = stickyTargetProgress - scrubCurrentProgress; - if (dp > 0.5) dp -= 1; - else if (dp < -0.5) dp += 1; - scrubSpeed = Math.max( - -24, - Math.min(24, (dp * totalDuration) / Math.max(simDt, 1 / 240)), - ); - nudgeTapeAudioSpeed(send, scrubSpeed); + // βœŠπŸ“Œ The hand owns position, while a damped velocity drives sound. + // Holding still settles to silence instead of alternating between a + // one-tick speed spike and a stop as touch events arrive. + const fingerPinned = isScrubbing && stickyTargetProgress !== null; + if (fingerPinned) { + const idleSeconds = Math.max(0, (performance.now() - lastScratchEventAt) / 1000); + const targetVelocity = + scratchVelocity * Math.exp(-Math.max(0, idleSeconds - 0.04) * 22); + const velocityBlend = 1 - Math.exp(-28 * simDt); + scrubSpeed += (targetVelocity - scrubSpeed) * velocityBlend; + scrubSpeed = Math.max(-24, Math.min(24, scrubSpeed)); + scrubCurrentProgress = stickyTargetProgress; + } else { + // Free platter: integrate rate into position after the hand lets go. + scrubCurrentProgress += scrubSpeed * simDt / totalDuration; } - - // Advance position at current speed (like stample's pitch-shifted sample) - scrubCurrentProgress += scrubSpeed * simDt / totalDuration; autoDtSum += simDt; // πŸ§ͺ Physics wall-time integration, for autopilot grading autoPhysTicks += 1; autoSpeedMin = Math.min(autoSpeedMin, scrubSpeed); @@ -1659,14 +1663,18 @@ function sim({ needsPaint, rec, send, clock, sound }) { tapDipTime = -1; isScrubbing = false; if (Math.abs(scrubSpeed - 1) < PARK_SNAP) scrubSpeed = 1; + driveDirection = directionalCruiseTarget(scrubSpeed); sustained = true; } // 🎰 Wheel spin-down: after a flick the platter runs free, then eases // down like a prize wheel to the pre-flick rate. if (wheelActive && !isScrubbing) { - scrubSpeed = - resumeTarget + (scrubSpeed - resumeTarget) * Math.pow(0.975, rate); + scrubSpeed = easeRateToward( + scrubSpeed, + resumeTarget, + Math.pow(0.94, rate), + ); if (Math.abs(scrubSpeed - resumeTarget) < 0.04) { // Lands as a seamless park β€” the drive keeps running, no handoff. wheelActive = false; @@ -1682,36 +1690,30 @@ function sim({ needsPaint, rec, send, clock, sound }) { // it's always the same gesture: let go, the wheel finds the groove and // locks. Never a jump. (A held chop owns position β€” it opts out.) if (sustained && !isScrubbing && !chopActive) { - const durMs = totalDuration * 1000; - const nowMs = clock?.time?.()?.getTime?.() ?? Date.now(); - // 🎯 Time-space beat lock: how far the playhead's absolute time - // sits from the nearest UTC beat line. The grid is BEAT LINES, not - // one phase β€” a deliberate ←/β†’ beat jump is a valid parking spot, - // and any landing sits at most half a beat from the pocket. Works - // for ANY duration: synthtapes (bar-multiple loops) fall into full - // unison, and real recorded tapes ride the same 120 BPM grid. - const beatMs = BEAT_SEC * 1000; - let beatResid = (nowMs - scrubCurrentProgress * durMs) % beatMs; - if (beatResid < 0) beatResid += beatMs; - if (beatResid > beatMs / 2) beatResid -= beatMs; - const phaseErr = beatResid / durMs; // Position-space, for the lean - - // Β±8% reads as the tape breathing into the pocket, not warping. - const lean = Math.max(-0.08, Math.min(0.08, phaseErr * 1.4)); - const targetRate = 1 + lean; - scrubSpeed += (targetRate - scrubSpeed) * (1 - Math.pow(0.94, rate)); - // Locked: at 1Γ— and in phase β†’ the drive RETIRES. Playback falls - // back to the bios RAF loop (UTC-anchored at seams), which ends the - // 120Hz seek/message loop β€” an idle pane costs what an untouched - // one does, and the seam stops flashing seek/progress mismatches. - if (Math.abs(scrubSpeed - 1) < 0.004 && Math.abs(phaseErr) < 0.0015) { - scrubSpeed = 1; - sustained = false; - nudgeTapeAudioSpeed(send, 1); - send({ - type: "recorder:present:seek", - content: { progress: scrubCurrentProgress, speedScrub: true }, - }); + if (driveDirection < 0) { + // A reverse throw settles at reverse 1Γ— and never crosses zero. + scrubSpeed += (-1 - scrubSpeed) * (1 - Math.pow(0.94, rate)); + if (Math.abs(scrubSpeed + 1) < 0.004) scrubSpeed = -1; + } else { + const durMs = totalDuration * 1000; + const nowMs = clock?.time?.()?.getTime?.() ?? Date.now(); + const beatMs = BEAT_SEC * 1000; + let beatResid = (nowMs - scrubCurrentProgress * durMs) % beatMs; + if (beatResid < 0) beatResid += beatMs; + if (beatResid > beatMs / 2) beatResid -= beatMs; + const phaseErr = beatResid / durMs; + const lean = Math.max(-0.08, Math.min(0.08, phaseErr * 1.4)); + const targetRate = 1 + lean; + scrubSpeed += (targetRate - scrubSpeed) * (1 - Math.pow(0.94, rate)); + if (Math.abs(scrubSpeed - 1) < 0.004 && Math.abs(phaseErr) < 0.0015) { + scrubSpeed = 1; + sustained = false; + nudgeTapeAudioSpeed(send, 1); + send({ + type: "recorder:present:seek", + content: { progress: scrubCurrentProgress, speedScrub: true }, + }); + } } } @@ -1739,6 +1741,7 @@ function sim({ needsPaint, rec, send, clock, sound }) { // Lands as a seamless park β€” the drive keeps running, no handoff. brakeResume = false; scrubSpeed = resumeTarget; + driveDirection = directionalCruiseTarget(resumeTarget); sustained = true; } } @@ -1807,7 +1810,6 @@ function act({ mp4BackBtn.act(e, { push: () => { handledBack = true; - synth({ type: "sine", tone: 700, attack: 0.1, decay: 0.5, volume: 0.5, duration: 0.005 }); send({ type: "tape:stop" }); jump("prompt"); }, @@ -1834,6 +1836,9 @@ function act({ sustained = false; holdTime = 0; flickVel = 0; + scratchVelocity = 0; + lastScratchEventAt = 0; + driveDirection = 1; scrubSpeed = 0; scrubMoved = false; nudgeTapeAudioSpeed(send, 1); @@ -1844,25 +1849,7 @@ function act({ // πŸ”™ Back to cap so the user can immediately re-shoot. backBtn?.act(e, { - down: () => { - synth({ - type: "sine", - tone: 500, - attack: 0.1, - decay: 0.99, - volume: 0.6, - duration: 0.001, - }); - }, push: () => { - synth({ - type: "sine", - tone: 700, - attack: 0.1, - decay: 0.5, - volume: 0.5, - duration: 0.005, - }); // Drop the current tape and any cached export / playback state // so cap.mjs starts clean and stale UI (progress bar, scrub // strip, post button) doesn't bleed through to the next visit. @@ -2304,16 +2291,6 @@ function act({ // POST button - Upload tape to cloud (ZIP + MongoDB + ATProto) postBtn?.act(e, { - down: () => { - synth({ - type: "sine", - tone: 600, - attack: 0.1, - decay: 0.99, - volume: 0.75, - duration: 0.001, - }); - }, push: async () => { // If tape was already posted, button is disabled - do nothing if (postedTapeCode) { @@ -2433,14 +2410,6 @@ function act({ triggerRender(); } - synth({ - type: "sine", - tone: 1000, - attack: 0.1, - decay: 0.99, - volume: 0.75, - duration: 0.005, - }); }, }); @@ -2833,8 +2802,8 @@ function act({ // ⌨️ Spacebar is the only pause β€” touch never pauses, but a deliberate // keypress can still halt and resume the tape. - // Full reset/pause β€” spacebar and the legend's "reset" button share it. - const fullReset = () => { + // Reset clears performance state; only spacebar toggles pause. + const fullReset = (togglePlayback = false) => { isScrubbing = false; inertiaActive = false; brakeHolding = false; @@ -2844,14 +2813,20 @@ function act({ scrollScrubbing = false; tapDipTime = -1; chopActive = 0; - scrubSpeed = 0; + scrubSpeed = 1; + scratchVelocity = 0; + driveDirection = 1; nudgeTapeAudioSpeed(send, 1); - if (rec.playing) rec.pause(); - else rec.play(); + if (togglePlayback) { + if (rec.playing) rec.pause(); + else rec.play(); + } else if (!rec.playing) { + rec.play(); + } triggerRender(); }; if (e.is("keyboard:down:space") && rec.presenting) { - fullReset(); + fullReset(true); return; } @@ -3001,6 +2976,8 @@ function act({ tapDipTime = -1; holdTime = 0; flickVel = 0; + scratchVelocity = 0; + lastScratchEventAt = performance.now(); elasticAnchorX = e.x ?? null; elasticAnchorY = e.y ?? null; penX = e.x ?? null; @@ -3051,11 +3028,28 @@ function act({ // elastic slide. (Sim closes the gap each tick; release inertia // is where slip lives.) const dx = e.x - elasticAnchorX; + const eventAt = performance.now(); + const priorX = penX ?? e.x; + const eventDt = Math.max( + 1 / 240, + Math.min(0.08, (eventAt - lastScratchEventAt) / 1000 || 1 / 60), + ); + const instantVelocity = scratchRateFromMotion( + e.x - priorX, + eventDt, + screen.width, + ); + scratchVelocity = scratchVelocity * 0.62 + instantVelocity * 0.38; + flickVel = scratchVelocity; + lastScratchEventAt = eventAt; penX = e.x; penY = e.y ?? penY; if (Math.abs(dx) > 2) scrubMoved = true; - flickVel = flickVel * 0.6 + (e.delta?.x || 0) * 0.4; - let sp = stickyGrabProgress + dx / screen.width; + let sp = stickyGrabProgress + scratchProgressDelta( + dx, + screen.width, + tapeInfo?.totalDuration || 10, + ); sp = ((sp % 1) + 1) % 1; stickyTargetProgress = sp; triggerRender(); @@ -3063,6 +3057,12 @@ function act({ up: () => { stickyTargetProgress = null; // Unpin β€” inertia may now slip if (!isScrubbing) return true; + const releaseIdle = Math.max( + 0, + (performance.now() - lastScratchEventAt) / 1000 - 0.04, + ); + flickVel *= Math.exp(-releaseIdle * 22); + scratchVelocity = flickVel; isScrubbing = false; elasticAnchorX = null; elasticBase = 1; @@ -3072,18 +3072,22 @@ function act({ brakeResume = true; } else if (Math.abs(flickVel) > FLICK_THRESHOLD) { // 🎰 Flick: the platter runs free with the throw's momentum, - // then eases down like a prize wheel to the pre-flick rate. + // then eases to 1Γ— without changing the throw's direction. scrubSpeed = Math.max( -24, Math.min(24, scrubSpeed + flickVel * FLICK_KICK), ); + resumeTarget = directionalCruiseTarget(scrubSpeed, flickVel); + driveDirection = resumeTarget; nudgeTapeAudioSpeed(send, scrubSpeed); wheelActive = true; + sustained = false; } else { // πŸ…ΏοΈ Release parks where you left it. Near 1Γ— it pins to // exactly 1 β€” the scrub drive at 1.0 IS normal playback, so // there's no handoff and no jump, ever. if (Math.abs(scrubSpeed - 1) < PARK_SNAP) scrubSpeed = 1; + driveDirection = directionalCruiseTarget(scrubSpeed); sustained = true; nudgeTapeAudioSpeed(send, scrubSpeed); } @@ -3746,6 +3750,9 @@ function leave({ send, rec }) { wheelActive = false; sustained = false; flickVel = 0; + scratchVelocity = 0; + lastScratchEventAt = 0; + driveDirection = 1; scrubSpeed = 0; scrubCurrentProgress = 0; scrubStripBtn = null; @@ -3781,6 +3788,7 @@ function ensureDriven(rec, send) { } scrubCurrentProgress = rec.presentProgress || 0; scrubSpeed = 1; + driveDirection = 1; sustained = true; if (!rec.playing) rec.play(); send({ diff --git a/system/public/aesthetic.computer/lib/scratch-physics.mjs b/system/public/aesthetic.computer/lib/scratch-physics.mjs new file mode 100644 index 0000000000..96963dce32 --- /dev/null +++ b/system/public/aesthetic.computer/lib/scratch-physics.mjs @@ -0,0 +1,30 @@ +export const SCRATCH_SECONDS_PER_SCREEN = 1.75; + +export function scratchProgressDelta( + deltaX, + screenWidth, + tapeDuration, + secondsPerScreen = SCRATCH_SECONDS_PER_SCREEN, +) { + if (!(screenWidth > 0) || !(tapeDuration > 0)) return 0; + return (deltaX / screenWidth) * (secondsPerScreen / tapeDuration); +} + +export function scratchRateFromMotion( + deltaX, + deltaSeconds, + screenWidth, + secondsPerScreen = SCRATCH_SECONDS_PER_SCREEN, +) { + if (!(deltaSeconds > 0) || !(screenWidth > 0)) return 0; + return (deltaX / screenWidth) * (secondsPerScreen / deltaSeconds); +} + +export function directionalCruiseTarget(rate, motion = 0) { + const signal = Math.abs(motion) > 0.01 ? motion : rate; + return signal < 0 ? -1 : 1; +} + +export function easeRateToward(rate, target, retention) { + return target + (rate - target) * retention; +} diff --git a/tests/scratch-physics.test.mjs b/tests/scratch-physics.test.mjs new file mode 100644 index 0000000000..620fabd547 --- /dev/null +++ b/tests/scratch-physics.test.mjs @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + directionalCruiseTarget, + easeRateToward, + scratchProgressDelta, + scratchRateFromMotion, +} from "../system/public/aesthetic.computer/lib/scratch-physics.mjs"; + +test("scratch travel represents a physical time span, not the whole tape", () => { + assert.equal(scratchProgressDelta(200, 200, 10), 0.175); + assert.equal(scratchProgressDelta(-200, 200, 10), -0.175); +}); + +test("a released platter approaches signed cruise without crossing direction", () => { + let reverse = -8; + let forward = 8; + for (let i = 0; i < 120; i += 1) { + reverse = easeRateToward(reverse, -1, 0.94); + forward = easeRateToward(forward, 1, 0.94); + assert.ok(reverse <= -1); + assert.ok(forward >= 1); + } + assert.ok(Math.abs(reverse + 1) < 0.01); + assert.ok(Math.abs(forward - 1) < 0.01); +}); + +test("scratch velocity has stable direction and event-rate-independent units", () => { + assert.equal(scratchRateFromMotion(20, 0.1, 200), 1.75); + assert.equal(scratchRateFromMotion(-10, 0.05, 200), -1.75); +}); + +test("release cruise preserves the direction of the throw", () => { + assert.equal(directionalCruiseTarget(4, -2), -1); + assert.equal(directionalCruiseTarget(-4, 2), 1); + assert.equal(directionalCruiseTarget(-0.2, 0), -1); + assert.equal(directionalCruiseTarget(0.2, 0), 1); +});