Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(notepat): space bar = instant-replay reverse loop pedal

User: "i was thinking space bar could play in reverse whatever I just
played... like holding space bar instantly starts playing the reverse
from when i pressed it... but i can still play other stuff on top of
course... so it basically plays the audio stream like instant replay
... and pressing space again right away would technically play the
reverse of the reverse, and in a loop... the length of holding space
to go backwards is a bit like setting the out point on the loop!"

Space is now an instant-replay loop pedal with bounce semantics.

MECHANICS

- Every note and drum hit gets appended to `playbackHistory` as
{ ts, kind, letter, octave, freq, vel, pan, wave, pitch }. Rolling
buffer: 256 events / 12 seconds max.

- On space key-down: startReversePlayback() snapshots the current
history and RESETS `playbackHistory` to empty — the new phase
begins now. The snapshot is translated into `reverseQueue`:
events sorted from most-recent to earliest, each with
`playAtMs = now + (latestTs - itsTs)`. The most recent original
event fires immediately, older events fire at their original
relative intervals.

- Each tick in sim() drains any `reverseQueue` entries whose
playAtMs has arrived. playReverseEvent() fires the voice via
sound.synth / playPercussion AND calls recordPlayback({...ev})
WITHOUT the original timestamp, so the reverse-fired event lands
in the new phase with its CURRENT timestamp. This is what makes
the loop work: the new phase captures events in the order they
reverse-played, and the next space press reverses THAT.

- On space key-up: stopReversePlayback() drains the queue. Whatever
got recorded during the press becomes the current phase. The
duration of the hold determines how much content the next loop
contains — "the length of holding space is a bit like setting the
out point on the loop."

- User-played notes DURING a reverse press layer on top normally
and also go into the new phase history, so layered riffs become
part of the next reverse.

FALLBACK: when history is empty (first press of the session), space
still plays the old kick-drum so it isn't silent.

HOOKED CALL SITES: recordPlayback is called from the keyboard drum
trigger path, keyboard melodic trigger path, touch drum pad tap
path, and drag-rollover drum path. Touch melodic paths aren't hooked
yet (they already flow through a different rememberSound shape) —
can follow up if needed. playReverseEvent is fed by the sim() tick
drain, not by setTimeout, so it doesn't rely on the polyfill and
timing resolution is one frame (~16 ms) which is plenty for percussive
replay.

BOUNCE EXAMPLE:
User taps A B C D across 500 ms → history = [A, B, C, D]
Press + hold space for ~500 ms → reverse plays D C B A; each gets
re-recorded. New history = [D, C, B, A] (new timestamps).
Release, press again → reverse plays A B C D. Bounce.
Loop indefinitely.

+144 -10
+144 -10
fedac/native/pieces/notepat.mjs
··· 389 389 let leftVolDragging = false; 390 390 let rightVolDragging = false; 391 391 392 + // === REVERSE PLAYBACK / INSTANT REPLAY LOOP === 393 + // Hold space to instantly play the CURRENT phase of history in reverse. 394 + // Release space to stop mid-reverse — the duration you held space defines 395 + // the length of the next loop. Pressing space starts a NEW phase: previous 396 + // history is snapshotted as the source, history resets to empty, and every 397 + // reverse event that fires during the press gets RECORDED BACK into the new 398 + // history. That means pressing space twice in a row bounces the sequence 399 + // forward → reverse → forward → reverse, creating a natural loop pedal. 400 + // 401 + // User-played notes during a reverse phase also go into the new history, so 402 + // you can layer over the reverse and the layers get captured into the next 403 + // pass. "The length of holding space is a bit like setting the out point on 404 + // the loop" (your words). 405 + const PLAYBACK_MAX_EVENTS = 256; 406 + const PLAYBACK_MAX_AGE_MS = 12000; 407 + let playbackHistory = []; // [{ts, kind:'note'|'drum', letter, octave, freq, vel, pan, wave, pitch}] 408 + let spaceHeld = false; 409 + // Queue of pending reverse events, sorted by playAtMs (monotonic clock). 410 + let reverseQueue = []; // [{playAtMs, ev}] 411 + 412 + function recordPlayback(entry) { 413 + const now = Date.now(); 414 + playbackHistory.push({ ...entry, ts: now }); 415 + if (playbackHistory.length > PLAYBACK_MAX_EVENTS) { 416 + playbackHistory.splice(0, playbackHistory.length - PLAYBACK_MAX_EVENTS); 417 + } 418 + const cutoff = now - PLAYBACK_MAX_AGE_MS; 419 + while (playbackHistory.length && playbackHistory[0].ts < cutoff) { 420 + playbackHistory.shift(); 421 + } 422 + } 423 + 424 + function startReversePlayback() { 425 + // Snapshot the CURRENT phase as the source, then RESET the phase history 426 + // so reverse events and any new user notes populate a fresh buffer. 427 + const snapshot = playbackHistory.slice(); 428 + playbackHistory = []; 429 + if (snapshot.length === 0) return; 430 + const now = Date.now(); 431 + const latestTs = snapshot[snapshot.length - 1].ts; 432 + reverseQueue = []; 433 + // Walk events from most-recent to earliest. Most recent plays at delay 0; 434 + // each older event plays at (latest - itsTs) ms later, so the original 435 + // intervals are preserved but the sequence is reversed. 436 + for (let i = snapshot.length - 1; i >= 0; i--) { 437 + const e = snapshot[i]; 438 + const delay = latestTs - e.ts; 439 + reverseQueue.push({ playAtMs: now + delay, ev: e }); 440 + } 441 + } 442 + 443 + function stopReversePlayback() { 444 + // Clear pending reverse events — anything not yet fired is dropped. 445 + // playbackHistory keeps whatever got recorded during this phase (reverse 446 + // events that DID fire + user-played notes), and that becomes the source 447 + // for the next space press. 448 + reverseQueue = []; 449 + } 450 + 451 + // Fire one reverse-playback event via the sound API AND record it back into 452 + // history so the next space press reverses THIS phase. 453 + function playReverseEvent(ev, sound) { 454 + if (!sound) return; 455 + if (ev.kind === "drum") { 456 + playPercussion(sound, ev.letter, (ev.vel || 1) * 1.5, ev.pan || 0, ev.pitch || 1); 457 + } else { 458 + const playFreq = (ev.freq || 440) * (ev.pitch || 1); 459 + sound?.synth?.({ 460 + type: ev.wave || "sine", 461 + tone: playFreq, 462 + duration: 0.12, 463 + volume: (ev.vel || 0.7) * 0.7, 464 + attack: 0.003, 465 + decay: 0.09, 466 + pan: ev.pan || 0, 467 + }); 468 + } 469 + // Record the fired event back into the new phase's history (with the 470 + // CURRENT timestamp, not the original one — this is what makes the 471 + // bounce loop work: the new phase captures events in the order they 472 + // played, then the next reverse unwinds them back to the original order). 473 + recordPlayback({ 474 + kind: ev.kind, 475 + letter: ev.letter, 476 + octave: ev.octave, 477 + freq: ev.freq, 478 + vel: ev.vel, 479 + pan: ev.pan, 480 + wave: ev.wave, 481 + pitch: ev.pitch, 482 + }); 483 + } 484 + 392 485 // Returns the master volume for the grid that produced a note, based on the 393 486 // gridOffset we pass through from parseNote / hitTestGrid (0 = left, 1 = right). 394 487 function masterForSide(gridOffset) { ··· 1004 1097 return; 1005 1098 } 1006 1099 if (key === "space") { 1007 - // Kick drum — short sine burst with pitch drop 1008 - if (sound && sound.synth) { 1009 - sound.synth({ type: "sine", tone: 150, duration: 0.15, volume: 0.9, attack: 0.001, decay: 0.14, pan: 0.0 }); 1010 - sound.synth({ type: "sine", tone: 60, duration: 0.2, volume: 0.7, attack: 0.001, decay: 0.19, pan: 0.0 }); 1100 + // Space = INSTANT REPLAY IN REVERSE (loop pedal semantics). 1101 + // Press: snapshot current history, reset the phase, start firing events 1102 + // backwards. The fallback kick drum only plays if there's nothing to 1103 + // reverse (empty history — first-press behavior). 1104 + // Release: stop the reverse queue. What got recorded during the press 1105 + // becomes the next phase's source. 1106 + if (!spaceHeld) { 1107 + spaceHeld = true; 1108 + if (playbackHistory.length > 0) { 1109 + startReversePlayback(); 1110 + } else if (sound && sound.synth) { 1111 + // Fallback kick drum when there's no history to reverse. 1112 + sound.synth({ type: "sine", tone: 150, duration: 0.15, volume: 0.9, attack: 0.001, decay: 0.14, pan: 0.0 }); 1113 + sound.synth({ type: "sine", tone: 60, duration: 0.2, volume: 0.7, attack: 0.001, decay: 0.19, pan: 0.0 }); 1114 + } 1011 1115 } 1012 1116 return; 1013 1117 } ··· 1207 1311 // each melody at ~0.17 — roughly 3× prominence. Then scale the 1208 1312 // whole thing by the per-side master so the user can balance L/R. 1209 1313 const drumVol = (1.0 + velocity * 0.8) * master; 1314 + const drumPitch = Math.pow(2, effectivePitchShift()); 1210 1315 const bankSample = percussionSampleBank[drumName]; 1211 1316 if (wave === "sample" && bankSample) { 1212 1317 if (bankSample !== lastLoadedSample) { ··· 1214 1319 lastLoadedSample = bankSample; 1215 1320 } 1216 1321 sound.sample.play({ 1217 - tone: SAMPLE_BASE_FREQ * Math.pow(2, effectivePitchShift()), 1322 + tone: SAMPLE_BASE_FREQ * drumPitch, 1218 1323 base: SAMPLE_BASE_FREQ, 1219 1324 volume: drumVol, pan, loop: false, 1220 1325 }); 1221 1326 } else { 1222 - playPercussion(sound, letter, drumVol, pan, Math.pow(2, effectivePitchShift())); 1327 + playPercussion(sound, letter, drumVol, pan, drumPitch); 1223 1328 } 1329 + recordPlayback({ kind: "drum", letter, octave: noteOctave, vel: drumVol, pan, pitch: drumPitch }); 1224 1330 trail[key] = { note: letter, octave: noteOctave, brightness: velocity }; 1225 1331 return; 1226 1332 } ··· 1253 1359 }); 1254 1360 rememberSound(key, { synth, note: letter, octave: noteOctave, baseFreq: freq, gridOffset: offset, baseVol }, system, velocity); 1255 1361 } 1362 + // Record for the reverse-playback loop. Captures the parameters of 1363 + // the user's hit so the next space press can replay it backwards. 1364 + recordPlayback({ 1365 + kind: "note", letter, octave: noteOctave, freq, vel: velocity, pan, 1366 + wave, pitch: Math.pow(2, effectivePitchShift()), 1367 + }); 1256 1368 trail[key] = { note: letter, octave: noteOctave, brightness: velocity }; 1257 1369 } 1258 1370 } ··· 1260 1372 if (e.is("keyboard:up")) { 1261 1373 const key = e.key?.toLowerCase(); 1262 1374 if (!key) return; 1375 + // Space release: stop the reverse playback queue. The duration of the 1376 + // press ends up defining the next loop's length. 1377 + if (key === "space") { 1378 + spaceHeld = false; 1379 + stopReversePlayback(); 1380 + return; 1381 + } 1263 1382 // F11 release — exit flourish mode (new notes will auto-latch again) 1264 1383 if (key === "f11") { f11Held = false; return; } 1265 1384 // Home key release: stop global recording + save to global sample ··· 1523 1642 // Percussion pad tap: fire drum (or drum sample) and flash trail. 1524 1643 const touchDrum = percussionDrumFor(hitNote.letter, hitNote.gridOffset); 1525 1644 if (touchDrum) { 1645 + const touchDrumPitch = Math.pow(2, effectivePitchShift()); 1526 1646 const bankSample = percussionSampleBank[touchDrum]; 1527 1647 if (wave === "sample" && bankSample) { 1528 1648 if (bankSample !== lastLoadedSample) { ··· 1530 1650 lastLoadedSample = bankSample; 1531 1651 } 1532 1652 sound.sample.play({ 1533 - tone: SAMPLE_BASE_FREQ * Math.pow(2, effectivePitchShift()), 1653 + tone: SAMPLE_BASE_FREQ * touchDrumPitch, 1534 1654 base: SAMPLE_BASE_FREQ, 1535 1655 volume: 1.6, pan, loop: false, 1536 1656 }); 1537 1657 } else { 1538 - playPercussion(sound, hitNote.letter, 1.8, pan, Math.pow(2, effectivePitchShift())); 1658 + playPercussion(sound, hitNote.letter, 1.8, pan, touchDrumPitch); 1539 1659 } 1660 + recordPlayback({ kind: "drum", letter: hitNote.letter, octave: hitNote.octave, vel: 1.8, pan, pitch: touchDrumPitch }); 1540 1661 trail[hitNote.key] = { note: hitNote.letter, octave: hitNote.octave, brightness: 1.0 }; 1541 1662 touchNotes[pid] = { key: hitNote.key }; 1542 1663 return; ··· 1670 1791 // Drag-to-drum: drum pads fire as one-shots on rollover too. 1671 1792 const rollDrum = percussionDrumFor(hitNote.letter, hitNote.gridOffset); 1672 1793 if (rollDrum) { 1794 + const rollDrumPitch = Math.pow(2, effectivePitchShift()); 1673 1795 const bankSample = percussionSampleBank[rollDrum]; 1674 1796 if (wave === "sample" && bankSample) { 1675 1797 if (bankSample !== lastLoadedSample) { ··· 1677 1799 lastLoadedSample = bankSample; 1678 1800 } 1679 1801 sound.sample.play({ 1680 - tone: SAMPLE_BASE_FREQ * Math.pow(2, effectivePitchShift()), 1802 + tone: SAMPLE_BASE_FREQ * rollDrumPitch, 1681 1803 base: SAMPLE_BASE_FREQ, 1682 1804 volume: 1.6, pan, loop: false, 1683 1805 }); 1684 1806 } else { 1685 - playPercussion(sound, hitNote.letter, 1.8, pan, Math.pow(2, effectivePitchShift())); 1807 + playPercussion(sound, hitNote.letter, 1.8, pan, rollDrumPitch); 1686 1808 } 1809 + recordPlayback({ kind: "drum", letter: hitNote.letter, octave: hitNote.octave, vel: 1.8, pan, pitch: rollDrumPitch }); 1687 1810 trail[hitNote.key] = { note: hitNote.letter, octave: hitNote.octave, brightness: 1.0 }; 1688 1811 touchNotes[pid] = { key: hitNote.key }; 1689 1812 return; ··· 3565 3688 function sim({ pressures, sound }) { 3566 3689 // Flush any due setTimeout callbacks (polyfill for missing QuickJS timer) 3567 3690 __tickPendingTimeouts(); 3691 + // Fire any reverse-playback events whose scheduled time has arrived. This 3692 + // runs every frame so the timing resolution is ~16 ms (fine for percussive 3693 + // replay). We drain in a while loop so multiple due events in the same 3694 + // frame all fire. 3695 + if (reverseQueue.length > 0) { 3696 + const nowMs = Date.now(); 3697 + while (reverseQueue.length > 0 && reverseQueue[0].playAtMs <= nowMs) { 3698 + const { ev } = reverseQueue.shift(); 3699 + playReverseEvent(ev, sound); 3700 + } 3701 + } 3568 3702 // Auto-stop recording at max duration 3569 3703 if (recording && (Date.now() - recStartTime) / 1000 >= MAX_REC_SECS) { 3570 3704 stopSampleRecording(sound, "max-duration");