Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat: reverse replay is locked to visual cursor + capture pauses

Fixes the "hjanky" reverse-replay timing where the needle and the audio
were drifting out of sync, and the capture ring was eating the reverse
echo + any overdubs during the hold.

Key change: during spacebar-held reverse playback the output history
ring is PAUSED at the C level (new audio->output_history_paused flag,
skipped in the per-sample capture block in audio.c:1448). No new data
enters the ring while space is held — the reverse voice's mix and any
notes played "on top" are audible but invisible to the visualizer and
won't re-enter the captured buffer.

Visual cursor is now driven directly by elapsed-since-press wall-clock
time instead of the lerped waveDriftSpeed model:

spaceHeld: waveViewOffsetSec = min(MAX, (now - spacePressStartMs)/1000)
the needle sits on exactly the sample the reverse voice
is reading — no drift physics, no ramp, no desync.
released: waveViewOffsetSec = 0 immediately; space handler also
calls speaker.setCapturePaused(false) to re-open the
ring, and resets spacePressStartMs. Since write_pos
didn't advance during the hold, "release" literally
snaps the cursor back to the moment of press, and
recording continues seamlessly from there.

New JS API: sound.speaker.setCapturePaused(bool)
→ audio_set_output_history_paused(audio, paused) in C

Also consolidated the DJ-deck empty-state title so "no usb" and
"no tracks" are sibling states in one readout instead of being
split between an inline label and a transient djMsg overlay —
USB mounted + 0 tracks → "no tracks"; no USB → "no usb"; scan
pending → "tap scan".

Queued follow-ups (tracked): whistle blow / per-instrument Shift
alternates, wobble/flange FX, aux-pad legend, side-key mapping,
drop noise voice from sampler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+97 -30
+60 -29
fedac/native/pieces/notepat.mjs
··· 150 150 // = +1.0 → display advances at 1× real-time → wave drifts LEFT (live) 151 151 // = 0.0 → display frozen → wave doesn't drift 152 152 // = -1.0 → display retreats at 1× real-time → wave drifts RIGHT (replay) 153 - // On spacebar press/release we smoothly lerp this between +1 and -1 so 154 - // the drift DECELERATES, momentarily stops, then REVERSES — instead of 155 - // snapping. The relationship to view offset: 156 - // d_offset/dt = 1.0 - waveDriftSpeed 153 + // Used only in LIVE mode for smooth drift; during spacebar-held reverse 154 + // we drive waveViewOffsetSec directly from elapsed-since-press so the 155 + // visual needle aligns with the replay voice's actual position. 157 156 let waveDriftSpeed = 1.0; 157 + // Wall-clock timestamp (ms) when spacebar was last pressed. 0 when not 158 + // in reverse mode. Used to compute the exact offset so the cursor in 159 + // the visualizer matches what the replay voice is playing right now. 160 + let spacePressStartMs = 0; 158 161 159 162 // Pitch shift — assignable to either trackpad axis 160 163 let pitchShift = 0; // -1 to +1, 0 = no shift ··· 2388 2391 // Space = true reverse replay from the recent audio buffer. 2389 2392 if (!spaceHeld) { 2390 2393 spaceHeld = true; 2394 + // Freeze the output-history ring while reversing. Anything played 2395 + // on top during the hold is heard but NOT captured, so letting go 2396 + // of space resumes recording from exactly where reversing started 2397 + // instead of snapshotting the reverse echo back into the buffer. 2398 + sound?.speaker?.setCapturePaused?.(true); 2399 + // Lock the visual cursor's start time to now — the strip will 2400 + // drive waveViewOffsetSec directly from elapsed-since-press so 2401 + // the needle is perfectly aligned with whatever the reverse 2402 + // playback voice is currently emitting. 2403 + spacePressStartMs = Date.now(); 2391 2404 if (!startReversePlayback(sound) && sound && sound.synth) { 2392 2405 // Fallback kick drum when there's no recent audio to reverse. 2393 2406 sound.synth({ type: "sine", tone: 150, duration: 0.15, volume: 0.9, attack: 0.001, decay: 0.14, pan: 0.0 }); ··· 2714 2727 if (key === "space") { 2715 2728 spaceHeld = false; 2716 2729 stopReversePlayback(sound); 2730 + // Unfreeze the output-history ring and snap the visual cursor back 2731 + // to the live edge. The ring's write_pos resumes from exactly where 2732 + // it paused — so the wave picks up recording at the press-moment, 2733 + // and the needle jumps to "now" in one frame (matches the user's 2734 + // mental model of "release = snap back to present"). 2735 + sound?.speaker?.setCapturePaused?.(false); 2736 + waveViewOffsetSec = 0; 2737 + waveDriftSpeed = 1.0; 2738 + spacePressStartMs = 0; 2717 2739 return; 2718 2740 } 2719 2741 // Home key release: stop global recording + save to global sample ··· 5230 5252 } 5231 5253 5232 5254 // Text: title / pos / dur / speed / BPM 5233 - const title = dk0.loaded ? (dk0.title || "?").replace(/\.[^.]+$/, "") : (djFiles.length > 0 ? "tap scan" : "no usb"); 5255 + // Empty-state title reflects the WHOLE DJ module state together — 5256 + // "no usb" and "no tracks" are treated as siblings in one readout 5257 + // rather than scattered between inline label and transient djMsg. 5258 + // USB mounted + 0 tracks scanned → "no tracks"; USB disconnected → 5259 + // "no usb". Track loaded → title from filename. 5260 + const title = dk0.loaded 5261 + ? (dk0.title || "?").replace(/\.[^.]+$/, "") 5262 + : (djFiles.length > 0 5263 + ? "tap scan" 5264 + : (djUsbConnected ? "no tracks" : "no usb")); 5234 5265 const pos = djFmt(dk0.position || 0); 5235 5266 const dur = djFmt(dk0.duration || 0); 5236 5267 const spd = `${(dk0.speed || 1).toFixed(2)}x`; ··· 5713 5744 stopSampleRecording(sound, "max-duration"); 5714 5745 } 5715 5746 5716 - // Smoothly lerp waveDriftSpeed toward target so the visible wave 5717 - // decelerates → stops → reverses → accelerates instead of snapping. 5718 - // Drift-speed → display velocity: 5719 - // = +2 → display advances at 2× real-time (wave drifts LEFT fast, 5720 - // used during release to catch up to live) 5721 - // = +1 → display advances at 1× (live) 5722 - // = 0 → display frozen 5723 - // = -1 → display retreats at 1× (wave drifts RIGHT, replay) 5724 - // d_offset/dt = 1 - waveDriftSpeed 5725 - const dtSec = 1 / 60; 5726 - const driftTarget = spaceHeld ? -1.0 : 2.0; 5727 - // 0.10 ≈ 6-frame time constant — visibly smooth but quick to engage. 5728 - waveDriftSpeed += (driftTarget - waveDriftSpeed) * 0.10; 5729 - const dOffsetDt = 1.0 - waveDriftSpeed; 5730 - waveViewOffsetSec += dOffsetDt * dtSec; 5731 - if (waveViewOffsetSec <= 0) { 5732 - waveViewOffsetSec = 0; 5733 - // Caught back up to live — snap drift back to +1 so we don't keep 5734 - // pushing offset negative every frame after release. Re-engages 5735 - // smoothly if the user presses space again. 5736 - if (!spaceHeld) waveDriftSpeed = 1.0; 5737 - } 5738 - if (waveViewOffsetSec > WAVE_VIEW_MAX_OFFSET_SEC) { 5739 - waveViewOffsetSec = WAVE_VIEW_MAX_OFFSET_SEC; 5747 + // Waveform cursor behavior: 5748 + // 5749 + // spaceHeld: drive offset directly from elapsed-since-press. The 5750 + // reverse-replay voice is reading the captured buffer 5751 + // backwards at 1× rate (since Date.now() returns wall 5752 + // clock and the buffer is mono @ the history rate), so 5753 + // setting offset = elapsed guarantees the visual needle 5754 + // sits on exactly the sample being played. Cap at MAX so 5755 + // the display stops retreating once we're at the end of 5756 + // the visible window (audio still ping-pongs beyond). 5757 + // 5758 + // released: snap the cursor back to 0 immediately — the press- 5759 + // release handler already does this + re-arms the capture 5760 + // ring. No smooth catch-up, because the user explicitly 5761 + // wants "release = back to the present where reversing 5762 + // started" (capture was paused during hold, so live 5763 + // write_pos IS the moment of press). 5764 + if (spaceHeld && spacePressStartMs > 0) { 5765 + const elapsedSec = (Date.now() - spacePressStartMs) / 1000; 5766 + waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC, elapsedSec); 5767 + waveDriftSpeed = -1.0; // cosmetic (only needle-color uses it) 5768 + } else { 5769 + waveDriftSpeed = 1.0; 5770 + // waveViewOffsetSec is reset to 0 in the space-release handler. 5740 5771 } 5741 5772 // Update dark/light mode via global theme (every ~5 seconds) 5742 5773 if (frame % 300 === 0) {
+21 -1
fedac/native/src/audio.c
··· 1448 1448 // Capture recent dry output for true reverse replay. This stores 1449 1449 // the actual mixed audio (not note events) before room/glitch/TTS 1450 1450 // so the reverse replay can run back through the live FX chain. 1451 - if (audio->output_history_buf && audio->output_history_size > 0) { 1451 + // 1452 + // When `output_history_paused` is set (by notepat while spacebar 1453 + // is held), we skip this write entirely — the reverse-playback 1454 + // voice being fed back through the speaker mix would otherwise 1455 + // re-enter the ring and double-layer on the original audio. The 1456 + // pause ONLY affects capture; the ring contents and read_pos 1457 + // are untouched so replay continues from the existing snapshot. 1458 + if (audio->output_history_buf && audio->output_history_size > 0 1459 + && !audio->output_history_paused) { 1452 1460 unsigned int stride = audio->output_history_downsample_n; 1453 1461 if (stride == 0) stride = 1; 1454 1462 audio->output_history_downsample_pos++; ··· 3035 3043 if (value < 0.0f) value = 0.0f; 3036 3044 if (value > 1.0f) value = 1.0f; 3037 3045 audio->target_drive_mix = value; 3046 + } 3047 + 3048 + // Pause/resume writes to output_history_buf. Called from notepat while 3049 + // the spacebar is held for reverse-replay: with writes paused the reverse 3050 + // playback echo (re-captured from the speaker mix) can't double-layer 3051 + // over the original wave in the visualizer AND any overdub played during 3052 + // the hold is purely monitored — not written into the capture ring. 3053 + // Release un-pauses; writes pick back up exactly where they left off so 3054 + // the buffer boundary is invisible to downstream consumers. 3055 + void audio_set_output_history_paused(ACAudio *audio, int paused) { 3056 + if (!audio) return; 3057 + audio->output_history_paused = paused ? 1 : 0; 3038 3058 } 3039 3059 3040 3060 // --- Hot-mic capture thread ---
+2
fedac/native/src/audio.h
··· 325 325 unsigned int output_history_downsample_n; // output-rate -> history-rate stride 326 326 unsigned int output_history_downsample_pos; // current stride counter 327 327 uint64_t output_history_write_pos; // monotonic write position 328 + int output_history_paused; // when set, skip ring writes (reverse-replay hold) 328 329 329 330 // DJ deck audio (persistent across piece switches) 330 331 ACDeck decks[AUDIO_MAX_DECKS]; ··· 406 407 void audio_set_fx_mix(ACAudio *audio, float mix); 407 408 void audio_set_master_volume(ACAudio *audio, float value); 408 409 void audio_set_drive_mix(ACAudio *audio, float value); 410 + void audio_set_output_history_paused(ACAudio *audio, int paused); 409 411 410 412 // Microphone — hot-mic mode (device stays open, recording toggles buffering) 411 413 int audio_mic_open(ACAudio *audio); // open device + start hot-mic thread
+14
fedac/native/src/js-bindings.c
··· 1204 1204 return JS_UNDEFINED; 1205 1205 } 1206 1206 1207 + // sound.speaker.setCapturePaused(bool) — pause/resume writes to the 1208 + // output history ring. Used by notepat to freeze the buffer during 1209 + // reverse-replay hold so the replay echo + overdubs don't contaminate 1210 + // the captured wave. 1211 + static JSValue js_speaker_set_capture_paused(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1212 + (void)this_val; 1213 + if (argc < 1 || !current_rt || !current_rt->audio) return JS_UNDEFINED; 1214 + int paused = JS_ToBool(ctx, argv[0]); 1215 + audio_set_output_history_paused(current_rt->audio, paused); 1216 + return JS_UNDEFINED; 1217 + } 1218 + 1207 1219 // sound.microphone.open() — open device + start hot-mic thread 1208 1220 static JSValue js_mic_open(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1209 1221 (void)this_val; (void)argc; (void)argv; ··· 2813 2825 JS_SetPropertyStr(ctx, speaker, "poll", JS_NewCFunction(ctx, js_noop, "poll", 0)); 2814 2826 JS_SetPropertyStr(ctx, speaker, "getRecentBuffer", 2815 2827 JS_NewCFunction(ctx, js_speaker_get_recent_buffer, "getRecentBuffer", 1)); 2828 + JS_SetPropertyStr(ctx, speaker, "setCapturePaused", 2829 + JS_NewCFunction(ctx, js_speaker_set_capture_paused, "setCapturePaused", 1)); 2816 2830 JS_SetPropertyStr(ctx, speaker, "drawStrip", 2817 2831 JS_NewCFunction(ctx, js_speaker_draw_strip, "drawStrip", 7)); 2818 2832 JS_SetPropertyStr(ctx, speaker, "sampleRate",