Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat: wobble/flange FX + snap-release (kill dead silent time)

Two changes on top of the shift-alternates batch:

1. Wobble/flange FX module
C-side modulated-delay effect following the exact pattern of drive:
- 1024-sample ring (power of 2 for cheap mask) covers 0-21 ms
- 0.4 Hz LFO sweeps read head over 2-10 ms range
- 0.45 feedback into the ring for sustained "jet sweep" zing
- dry/wet blend with per-sample smoothing (same 0.00005 coeff as
fx/room/glitch/drive) so fader sweeps don't zipper
- Applied BEFORE drive + master_volume so it colors the tonal
character of the instrument, not the overdriven output
- JS API: sound.wobble.setMix(value) 0..1
Notepat UI: new 7th FX slider row (purple) with X/Y trackpad
bindings matching the rest of the rack. Wavetype buttons move
from sliderH*6 → sliderH*7 accordingly.

2. Release SNAPS back to 0 (was ease-out animation)
The ~20-frame ease-back I added earlier showed the needle visibly
sweeping forward through the wave — but the audio replay voice
already stops on release, so those ~333 ms of sweeping visual
had no sound behind them. User reported this as "extra dead
silent time at the end of every reverse gesture" and noted it
made the snap-back feel unresponsive. Removed the ease and went
back to an immediate jump; the press/hold still drives the
offset in lock-step with replay time, so only the release is
now instant (which is what it should've been given the audio
semantics — the capture ring already has the right data at
offset=0 the moment the replay voice stops).

Queued: sample alternate + composite alternate (both need more
thought), live-ping for UDP MIDI, boot-timing analysis once the
USB comes back with vanadium-nightjar-frost's log.

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

+163 -15
+70 -15
fedac/native/pieces/notepat.mjs
··· 137 137 let driveMix = 0; 138 138 let driveDragging = false; 139 139 140 + // Wobble / flange dry/wet. 0 = clean, 1 = full flange. Modulated short 141 + // delay with moderate feedback — "jet sweep" character. 142 + let wobbleMix = 0; 143 + let wobbleDragging = false; 144 + 140 145 // Waveform-strip view cursor — how many seconds in the past the playhead 141 146 // sits relative to the live audio edge. 0 = live (wave drifts LEFT as 142 147 // real time advances). Grows when spacebar is held (wave drifts RIGHT, ··· 171 176 crush: { x: false, y: false }, 172 177 volume: { x: false, y: false }, 173 178 drive: { x: false, y: false }, 179 + wobble: { x: false, y: false }, 174 180 }; 175 181 176 182 function clampRange(value, min, max) { ··· 221 227 return changed; 222 228 } 223 229 230 + function setWobbleMixValue(value, sound, commit = true) { 231 + const next = clamp01(value); 232 + const changed = Math.abs(next - wobbleMix) > 0.0005; 233 + wobbleMix = next; 234 + if (commit) sound?.wobble?.setMix?.(wobbleMix); 235 + return changed; 236 + } 237 + 224 238 function applyPitchShiftToActiveSounds(force = false) { 225 239 const ep = effectivePitchShift(); 226 240 if (!force && Math.abs(ep - lastAppliedPitch) <= 0.001) return false; ··· 264 278 if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit); 265 279 if (rowId === "volume") return setMasterVolMixValue(norm, sound, commit); 266 280 if (rowId === "drive") return setDriveMixValue(norm, sound, commit); 281 + if (rowId === "wobble") return setWobbleMixValue(norm, sound, commit); 267 282 return false; 268 283 } 269 284 ··· 2146 2161 sound?.fx?.setMix?.(fxMix); 2147 2162 sound?.volume?.setMix?.(masterVolMix * 2); // slider 0..1 → audio 0..2 2148 2163 sound?.drive?.setMix?.(driveMix); 2164 + sound?.wobble?.setMix?.(wobbleMix); 2149 2165 loadUdpMidiConfig(system); 2150 2166 udpMidiNextHeartbeatFrame = 0; 2151 2167 const mic = sound?.microphone || null; ··· 2921 2937 // FX rows: sliders + per-effect X/Y trackpad assignment toggles 2922 2938 { 2923 2939 const fxRows = globalThis.__fxRows || {}; 2924 - for (const rowId of ["fx", "echo", "pitch", "crush", "volume", "drive"]) { 2940 + for (const rowId of ["fx", "echo", "pitch", "crush", "volume", "drive", "wobble"]) { 2925 2941 const row = fxRows[rowId]; 2926 2942 if (!row) continue; 2927 2943 if (pointInRect(x, y, row.xBox)) { ··· 2939 2955 else if (rowId === "crush") bitcrushDragging = true; 2940 2956 else if (rowId === "volume") masterVolDragging = true; 2941 2957 else if (rowId === "drive") driveDragging = true; 2958 + else if (rowId === "wobble") wobbleDragging = true; 2942 2959 setEffectRowFromPointer(rowId, x, row, sound, true); 2943 2960 return; 2944 2961 } ··· 3175 3192 if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true); 3176 3193 if (masterVolDragging) setEffectRowFromPointer("volume", x, fxRows.volume, sound, true); 3177 3194 if (driveDragging) setEffectRowFromPointer("drive", x, fxRows.drive, sound, true); 3195 + if (wobbleDragging) setEffectRowFromPointer("wobble", x, fxRows.wobble, sound, true); 3178 3196 if (volDragging) { 3179 3197 const vb = globalThis.__volBar; 3180 3198 if (vb) { ··· 3279 3297 if (bitcrushDragging) bitcrushDragging = false; 3280 3298 if (masterVolDragging) masterVolDragging = false; 3281 3299 if (driveDragging) driveDragging = false; 3300 + if (wobbleDragging) wobbleDragging = false; 3282 3301 if (volDragging) volDragging = false; 3283 3302 if (brtDragging) brtDragging = false; 3284 3303 // Release touch-triggered note ··· 3335 3354 let crushDirty = false; 3336 3355 let volDirty = false; 3337 3356 let driveDirty = false; 3357 + let wobbleDirty = false; 3338 3358 if (trackpad.dx !== 0) { 3339 3359 const dxNorm = trackpad.dx / Math.max(1, w); 3340 3360 if (trackpadEffectBindings.echo.x) { ··· 3352 3372 if (trackpadEffectBindings.drive.x) { 3353 3373 driveDirty = setDriveMixValue(driveMix + dxNorm * 3, sound, false) || driveDirty; 3354 3374 } 3375 + if (trackpadEffectBindings.wobble.x) { 3376 + wobbleDirty = setWobbleMixValue(wobbleMix + dxNorm * 3, sound, false) || wobbleDirty; 3377 + } 3355 3378 } 3356 3379 if (trackpad.dy !== 0) { 3357 3380 const dyNorm = -trackpad.dy / Math.max(1, h); ··· 3370 3393 if (trackpadEffectBindings.drive.y) { 3371 3394 driveDirty = setDriveMixValue(driveMix + dyNorm * 3, sound, false) || driveDirty; 3372 3395 } 3396 + if (trackpadEffectBindings.wobble.y) { 3397 + wobbleDirty = setWobbleMixValue(wobbleMix + dyNorm * 3, sound, false) || wobbleDirty; 3398 + } 3373 3399 } 3374 3400 if (echoDirty && frame % 3 === 0) sound?.room?.setMix?.(echoMix); 3375 3401 if (crushDirty && frame % 3 === 0) sound?.glitch?.setMix?.(bitcrushMix); 3376 3402 if (volDirty && frame % 3 === 0) sound?.volume?.setMix?.(masterVolMix * 2); 3377 3403 if (driveDirty && frame % 3 === 0) sound?.drive?.setMix?.(driveMix); 3404 + if (wobbleDirty && frame % 3 === 0) sound?.wobble?.setMix?.(wobbleMix); 3378 3405 // Apply pitch shift to active voices — throttled to every 4th frame 3379 3406 // and only when pitch actually changed 3380 3407 if (frame % 4 === 0) applyPitchShiftToActiveSounds(false); ··· 5205 5232 fxRows.drive = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; 5206 5233 } 5207 5234 5235 + // Wobble slider — LFO-modulated short delay dry/wet (0 = clean, 100% = full flange). 5236 + // Purple palette to distinguish from drive (red/orange) and volume (green). 5237 + { 5238 + const sliderY = settingsY + sliderH * 6; 5239 + const sliderW = w - axisAreaW; 5240 + const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; 5241 + ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), 5242 + dark ? (hov ? 40 : 25) : (hov ? 220 : 235), 5243 + dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); 5244 + box(0, sliderY, w, sliderH, true); 5245 + const fillW = Math.floor(wobbleMix * sliderW); 5246 + if (fillW > 0) { 5247 + ink(150, 100, 220, trackpadFX ? 240 : 180); 5248 + box(0, sliderY, fillW, sliderH, true); 5249 + } 5250 + if (wobbleMix > 0.005) { 5251 + const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(wobbleMix * sliderW))); 5252 + ink(200, 160, 255, 220); 5253 + box(knobX - 1, sliderY, 3, sliderH, true); 5254 + } 5255 + ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); 5256 + write("wobble " + Math.round(wobbleMix * 100) + "%", 5257 + { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); 5258 + const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; 5259 + const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; 5260 + drawAxisToggle(xBox, "x", !!trackpadEffectBindings.wobble.x, [150, 100, 220]); 5261 + drawAxisToggle(yBox, "y", !!trackpadEffectBindings.wobble.y, [150, 100, 220]); 5262 + fxRows.wobble = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; 5263 + } 5264 + 5208 5265 globalThis.__fxRows = fxRows; 5209 - const waveRowY = settingsY + sliderH * 6; 5266 + const waveRowY = settingsY + sliderH * 7; 5210 5267 const waveRowH = 14; 5211 5268 5212 5269 // === WAVE TYPE BUTTONS (below sliders, modular GUI) === ··· 5927 5984 // the visual needle sits on exactly the sample being 5928 5985 // played. Cap at MAX. 5929 5986 // 5930 - // released: animate the cursor back to live over ~20 frames instead 5931 - // of snapping. Feels like the needle "catches up" to the 5932 - // present. The capture ring was paused during hold so 5933 - // live write_pos still marks the press-moment, meaning 5934 - // offset=0 really is "where reversing started" — the 5935 - // animation just makes the visual transition legible. 5936 - const dtSec = 1 / 60; 5987 + // released: snap cursor back to 0 immediately. An earlier iteration 5988 + // eased the offset back over ~20 frames, but the audio 5989 + // replay voice stops on release — those ~300 ms of 5990 + // sweeping visual had no sound behind them, reading as 5991 + // "dead silent time" at the end of every reverse gesture. 5992 + // Snapping eliminates the audio/visual mismatch; the 5993 + // press→reverse→release feels like a clean in-and-out 5994 + // cut with nothing hanging off the back of it. 5937 5995 if (spaceHeld && spacePressStartMs > 0) { 5938 5996 const elapsedSec = (Date.now() - spacePressStartMs) / 1000; 5939 5997 waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC, elapsedSec); 5940 5998 waveDriftSpeed = -1.0; // cosmetic (only needle-color uses it) 5941 - } else if (waveViewOffsetSec > 0) { 5942 - // Ease-out lerp toward 0. At 60 fps with 0.18 factor the offset 5943 - // halves every ~4 frames — settles visually in ~15-20 frames. 5944 - waveViewOffsetSec = Math.max(0, waveViewOffsetSec - waveViewOffsetSec * 0.18 - dtSec * 0.25); 5945 - if (waveViewOffsetSec < 0.005) waveViewOffsetSec = 0; 5946 - waveDriftSpeed = 1.0; 5947 5999 } else { 6000 + waveViewOffsetSec = 0; 5948 6001 waveDriftSpeed = 1.0; 5949 6002 } 5950 6003 // Update dark/light mode via global theme (every ~5 seconds) ··· 6048 6101 fxMix = 1; 6049 6102 masterVolMix = 0.5; // unity (slider 0..1 → audio 0..2) 6050 6103 driveMix = 0; 6104 + wobbleMix = 0; 6051 6105 trackpadFX = false; 6052 6106 soundAPI?.room?.setMix?.(0); 6053 6107 soundAPI?.glitch?.setMix?.(0); 6054 6108 soundAPI?.fx?.setMix?.(1); 6055 6109 soundAPI?.volume?.setMix?.(1); // unity 6056 6110 soundAPI?.drive?.setMix?.(0); // clean 6111 + soundAPI?.wobble?.setMix?.(0); // clean 6057 6112 stopAllSounds(soundAPI, systemAPI, 0.02); 6058 6113 } 6059 6114
+63
fedac/native/src/audio.c
··· 1441 1441 if (audio->drive_mix != audio->target_drive_mix) { 1442 1442 audio->drive_mix += (audio->target_drive_mix - audio->drive_mix) * 0.00005f; 1443 1443 } 1444 + if (audio->wobble_mix != audio->target_wobble_mix) { 1445 + audio->wobble_mix += (audio->target_wobble_mix - audio->wobble_mix) * 0.00005f; 1446 + } 1444 1447 1445 1448 // Save dry signal before FX chain 1446 1449 double dry_l = mix_l, dry_r = mix_r; ··· 1587 1590 double reduction = gain / comp_env; 1588 1591 mix_l *= reduction; 1589 1592 mix_r *= reduction; 1593 + } 1594 + } 1595 + 1596 + // Wobble / flange — modulated short-delay blend. Writes every 1597 + // sample into the ring regardless of wet mix (so the ring 1598 + // stays warm for instant-on when the slider turns up), then 1599 + // reads a sample `delay_samples` behind the write head with 1600 + // the delay itself sweeping via a slow LFO. Tiny feedback 1601 + // (0.45) thickens the tail so moderate mix settings already 1602 + // produce the characteristic jet-sweep coloration. 1603 + if (audio->wobble_buf_l && audio->wobble_buf_size > 0) { 1604 + int size = audio->wobble_buf_size; 1605 + int mask = size - 1; // size is power of two (1024) 1606 + int wp = audio->wobble_write_pos; 1607 + float wmix = audio->wobble_mix; 1608 + // Sweep: 2-10 ms at 48 kHz = 96..480 samples. 1609 + float lfo = (float)sin((double)audio->wobble_lfo_phase); 1610 + float delay_samples = 96.0f + (lfo * 0.5f + 0.5f) * 384.0f; 1611 + audio->wobble_lfo_phase += audio->wobble_lfo_rate; 1612 + if (audio->wobble_lfo_phase > 6.283185307179586f) 1613 + audio->wobble_lfo_phase -= 6.283185307179586f; 1614 + // Fractional read with linear interp. 1615 + float rp = (float)wp - delay_samples; 1616 + while (rp < 0) rp += size; 1617 + int rp_i = (int)rp; 1618 + float rp_f = rp - (float)rp_i; 1619 + float dly_l = audio->wobble_buf_l[rp_i & mask] * (1.0f - rp_f) 1620 + + audio->wobble_buf_l[(rp_i + 1) & mask] * rp_f; 1621 + float dly_r = audio->wobble_buf_r[rp_i & mask] * (1.0f - rp_f) 1622 + + audio->wobble_buf_r[(rp_i + 1) & mask] * rp_f; 1623 + // Feedback into the ring (for sustained "zing"). 1624 + audio->wobble_buf_l[wp & mask] = (float)mix_l + dly_l * 0.45f; 1625 + audio->wobble_buf_r[wp & mask] = (float)mix_r + dly_r * 0.45f; 1626 + audio->wobble_write_pos = (wp + 1) & mask; 1627 + if (wmix > 0.001f) { 1628 + mix_l = mix_l * (1.0 - wmix) + (double)dly_l * wmix; 1629 + mix_r = mix_r * (1.0 - wmix) + (double)dly_r * wmix; 1590 1630 } 1591 1631 } 1592 1632 ··· 1843 1883 audio->target_master_volume = 1.0f; 1844 1884 audio->drive_mix = 0.0f; // Clean bypass until user dials drive 1845 1885 audio->target_drive_mix = 0.0f; 1886 + // Wobble / flange — 1024-sample ring covers up to ~21 ms @ 48 kHz 1887 + // which is well past the flanger sweet spot (1-10 ms). Power-of-two 1888 + // size lets the read index use `& (size-1)` instead of `%`. 1889 + audio->wobble_buf_size = 1024; 1890 + audio->wobble_buf_l = calloc(audio->wobble_buf_size, sizeof(float)); 1891 + audio->wobble_buf_r = calloc(audio->wobble_buf_size, sizeof(float)); 1892 + audio->wobble_write_pos = 0; 1893 + audio->wobble_lfo_phase = 0.0f; 1894 + // LFO rate 0.4 Hz — slow sweep, reads as "wobble" not "chorus" 1895 + audio->wobble_lfo_rate = 2.0f * 3.14159265358979f * 0.4f / 48000.0f; 1896 + audio->wobble_mix = 0.0f; 1897 + audio->target_wobble_mix = 0.0f; 1846 1898 audio->room_buf_l = calloc(ROOM_SIZE, sizeof(float)); 1847 1899 audio->room_buf_r = calloc(ROOM_SIZE, sizeof(float)); 1848 1900 ··· 3043 3095 if (value < 0.0f) value = 0.0f; 3044 3096 if (value > 1.0f) value = 1.0f; 3045 3097 audio->target_drive_mix = value; 3098 + } 3099 + 3100 + // Wobble / flange dry/wet 0..1. LFO-modulated short delay blended with 3101 + // the dry signal. Same exponential smoother as the other mix params so 3102 + // a fader sweep doesn't click. The LFO rate itself is fixed at 0.4 Hz 3103 + // and doesn't need smoothing. 3104 + void audio_set_wobble_mix(ACAudio *audio, float value) { 3105 + if (!audio) return; 3106 + if (value < 0.0f) value = 0.0f; 3107 + if (value > 1.0f) value = 1.0f; 3108 + audio->target_wobble_mix = value; 3046 3109 } 3047 3110 3048 3111 // Pause/resume writes to output_history_buf. Called from notepat while
+14
fedac/native/src/audio.h
··· 265 265 float drive_mix; 266 266 float target_drive_mix; 267 267 268 + // Wobble / flange — modulated short-delay dry/wet blend. 0.0 = bypass, 269 + // 1.0 = fully flanged. LFO sweeps the read head over a 1–10 ms range 270 + // so the audio periodically combs with a time-shifted copy of itself. 271 + // Moderate feedback makes the characteristic "whoosh" zing. 272 + float wobble_mix; 273 + float target_wobble_mix; 274 + float *wobble_buf_l; // stereo delay lines (mono'd from the mix) 275 + float *wobble_buf_r; 276 + int wobble_buf_size; // power of two for cheap modulo 277 + int wobble_write_pos; // integer write cursor into the ring 278 + float wobble_lfo_phase; // 0..2π — advances per sample at wobble_lfo_rate 279 + float wobble_lfo_rate; // radians per sample (≈ 2π * 0.4 Hz / rate) 280 + 268 281 // System mixer volume (0-100 percent) 269 282 int system_volume; 270 283 int card_index; // ALSA card number (0 or 1) ··· 407 420 void audio_set_fx_mix(ACAudio *audio, float mix); 408 421 void audio_set_master_volume(ACAudio *audio, float value); 409 422 void audio_set_drive_mix(ACAudio *audio, float value); 423 + void audio_set_wobble_mix(ACAudio *audio, float value); 410 424 void audio_set_output_history_paused(ACAudio *audio, int paused); 411 425 412 426 // Microphone — hot-mic mode (device stays open, recording toggles buffering)
+16
fedac/native/src/js-bindings.c
··· 1216 1216 return JS_UNDEFINED; 1217 1217 } 1218 1218 1219 + // sound.wobble.setMix(value) — flanger-ish dry/wet (0..1) 1220 + static JSValue js_set_wobble_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1221 + (void)this_val; 1222 + if (argc < 1 || !current_rt->audio) return JS_UNDEFINED; 1223 + double v; 1224 + JS_ToFloat64(ctx, &v, argv[0]); 1225 + audio_set_wobble_mix(current_rt->audio, (float)v); 1226 + return JS_UNDEFINED; 1227 + } 1228 + 1219 1229 // sound.microphone.open() — open device + start hot-mic thread 1220 1230 static JSValue js_mic_open(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1221 1231 (void)this_val; (void)argc; (void)argv; ··· 2916 2926 JS_SetPropertyStr(ctx, drive, "setMix", JS_NewCFunction(ctx, js_set_drive_mix, "setMix", 1)); 2917 2927 JS_SetPropertyStr(ctx, drive, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->drive_mix : 0.0)); 2918 2928 JS_SetPropertyStr(ctx, sound, "drive", drive); 2929 + 2930 + // wobble (modulated-delay flange) 2931 + JSValue wobble = JS_NewObject(ctx); 2932 + JS_SetPropertyStr(ctx, wobble, "setMix", JS_NewCFunction(ctx, js_set_wobble_mix, "setMix", 1)); 2933 + JS_SetPropertyStr(ctx, wobble, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->wobble_mix : 0.0)); 2934 + JS_SetPropertyStr(ctx, sound, "wobble", wobble); 2919 2935 2920 2936 // microphone 2921 2937 JSValue mic = JS_NewObject(ctx);