From 66452b605263bac0d851514aeead299ca41e1918 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 21 Apr 2026 14:12:34 -0700 Subject: [PATCH] notepat: wobble/flange FX + snap-release (kill dead silent time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fedac/native/pieces/notepat.mjs | 85 +++++++++++++++++++++++++++------ fedac/native/src/audio.c | 63 ++++++++++++++++++++++++ fedac/native/src/audio.h | 14 ++++++ fedac/native/src/js-bindings.c | 16 +++++++ 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/fedac/native/pieces/notepat.mjs b/fedac/native/pieces/notepat.mjs index bd34ebe3f1..fa1614a0a2 100644 --- a/fedac/native/pieces/notepat.mjs +++ b/fedac/native/pieces/notepat.mjs @@ -137,6 +137,11 @@ let masterVolDragging = false; let driveMix = 0; let driveDragging = false; +// Wobble / flange dry/wet. 0 = clean, 1 = full flange. Modulated short +// delay with moderate feedback — "jet sweep" character. +let wobbleMix = 0; +let wobbleDragging = false; + // Waveform-strip view cursor — how many seconds in the past the playhead // sits relative to the live audio edge. 0 = live (wave drifts LEFT as // real time advances). Grows when spacebar is held (wave drifts RIGHT, @@ -171,6 +176,7 @@ let trackpadEffectBindings = { crush: { x: false, y: false }, volume: { x: false, y: false }, drive: { x: false, y: false }, + wobble: { x: false, y: false }, }; function clampRange(value, min, max) { @@ -221,6 +227,14 @@ function setDriveMixValue(value, sound, commit = true) { return changed; } +function setWobbleMixValue(value, sound, commit = true) { + const next = clamp01(value); + const changed = Math.abs(next - wobbleMix) > 0.0005; + wobbleMix = next; + if (commit) sound?.wobble?.setMix?.(wobbleMix); + return changed; +} + function applyPitchShiftToActiveSounds(force = false) { const ep = effectivePitchShift(); if (!force && Math.abs(ep - lastAppliedPitch) <= 0.001) return false; @@ -264,6 +278,7 @@ function setEffectRowFromPointer(rowId, x, row, sound, commit = true) { if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit); if (rowId === "volume") return setMasterVolMixValue(norm, sound, commit); if (rowId === "drive") return setDriveMixValue(norm, sound, commit); + if (rowId === "wobble") return setWobbleMixValue(norm, sound, commit); return false; } @@ -2146,6 +2161,7 @@ function boot({ wipe, system, sound }) { sound?.fx?.setMix?.(fxMix); sound?.volume?.setMix?.(masterVolMix * 2); // slider 0..1 → audio 0..2 sound?.drive?.setMix?.(driveMix); + sound?.wobble?.setMix?.(wobbleMix); loadUdpMidiConfig(system); udpMidiNextHeartbeatFrame = 0; const mic = sound?.microphone || null; @@ -2921,7 +2937,7 @@ function act({ event: e, sound, wifi, system }) { // FX rows: sliders + per-effect X/Y trackpad assignment toggles { const fxRows = globalThis.__fxRows || {}; - for (const rowId of ["fx", "echo", "pitch", "crush", "volume", "drive"]) { + for (const rowId of ["fx", "echo", "pitch", "crush", "volume", "drive", "wobble"]) { const row = fxRows[rowId]; if (!row) continue; if (pointInRect(x, y, row.xBox)) { @@ -2939,6 +2955,7 @@ function act({ event: e, sound, wifi, system }) { else if (rowId === "crush") bitcrushDragging = true; else if (rowId === "volume") masterVolDragging = true; else if (rowId === "drive") driveDragging = true; + else if (rowId === "wobble") wobbleDragging = true; setEffectRowFromPointer(rowId, x, row, sound, true); return; } @@ -3175,6 +3192,7 @@ function act({ event: e, sound, wifi, system }) { if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true); if (masterVolDragging) setEffectRowFromPointer("volume", x, fxRows.volume, sound, true); if (driveDragging) setEffectRowFromPointer("drive", x, fxRows.drive, sound, true); + if (wobbleDragging) setEffectRowFromPointer("wobble", x, fxRows.wobble, sound, true); if (volDragging) { const vb = globalThis.__volBar; if (vb) { @@ -3279,6 +3297,7 @@ function act({ event: e, sound, wifi, system }) { if (bitcrushDragging) bitcrushDragging = false; if (masterVolDragging) masterVolDragging = false; if (driveDragging) driveDragging = false; + if (wobbleDragging) wobbleDragging = false; if (volDragging) volDragging = false; if (brtDragging) brtDragging = false; // Release touch-triggered note @@ -3335,6 +3354,7 @@ function paint({ wipe, ink, box, line, write, screen, sound, system, trackpad, p let crushDirty = false; let volDirty = false; let driveDirty = false; + let wobbleDirty = false; if (trackpad.dx !== 0) { const dxNorm = trackpad.dx / Math.max(1, w); if (trackpadEffectBindings.echo.x) { @@ -3352,6 +3372,9 @@ function paint({ wipe, ink, box, line, write, screen, sound, system, trackpad, p if (trackpadEffectBindings.drive.x) { driveDirty = setDriveMixValue(driveMix + dxNorm * 3, sound, false) || driveDirty; } + if (trackpadEffectBindings.wobble.x) { + wobbleDirty = setWobbleMixValue(wobbleMix + dxNorm * 3, sound, false) || wobbleDirty; + } } if (trackpad.dy !== 0) { const dyNorm = -trackpad.dy / Math.max(1, h); @@ -3370,11 +3393,15 @@ function paint({ wipe, ink, box, line, write, screen, sound, system, trackpad, p if (trackpadEffectBindings.drive.y) { driveDirty = setDriveMixValue(driveMix + dyNorm * 3, sound, false) || driveDirty; } + if (trackpadEffectBindings.wobble.y) { + wobbleDirty = setWobbleMixValue(wobbleMix + dyNorm * 3, sound, false) || wobbleDirty; + } } if (echoDirty && frame % 3 === 0) sound?.room?.setMix?.(echoMix); if (crushDirty && frame % 3 === 0) sound?.glitch?.setMix?.(bitcrushMix); if (volDirty && frame % 3 === 0) sound?.volume?.setMix?.(masterVolMix * 2); if (driveDirty && frame % 3 === 0) sound?.drive?.setMix?.(driveMix); + if (wobbleDirty && frame % 3 === 0) sound?.wobble?.setMix?.(wobbleMix); // Apply pitch shift to active voices — throttled to every 4th frame // and only when pitch actually changed if (frame % 4 === 0) applyPitchShiftToActiveSounds(false); @@ -5205,8 +5232,38 @@ function paint({ wipe, ink, box, line, write, screen, sound, system, trackpad, p fxRows.drive = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; } + // Wobble slider — LFO-modulated short delay dry/wet (0 = clean, 100% = full flange). + // Purple palette to distinguish from drive (red/orange) and volume (green). + { + const sliderY = settingsY + sliderH * 6; + const sliderW = w - axisAreaW; + const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; + ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), + dark ? (hov ? 40 : 25) : (hov ? 220 : 235), + dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); + box(0, sliderY, w, sliderH, true); + const fillW = Math.floor(wobbleMix * sliderW); + if (fillW > 0) { + ink(150, 100, 220, trackpadFX ? 240 : 180); + box(0, sliderY, fillW, sliderH, true); + } + if (wobbleMix > 0.005) { + const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(wobbleMix * sliderW))); + ink(200, 160, 255, 220); + box(knobX - 1, sliderY, 3, sliderH, true); + } + ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); + write("wobble " + Math.round(wobbleMix * 100) + "%", + { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); + const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; + const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; + drawAxisToggle(xBox, "x", !!trackpadEffectBindings.wobble.x, [150, 100, 220]); + drawAxisToggle(yBox, "y", !!trackpadEffectBindings.wobble.y, [150, 100, 220]); + fxRows.wobble = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; + } + globalThis.__fxRows = fxRows; - const waveRowY = settingsY + sliderH * 6; + const waveRowY = settingsY + sliderH * 7; const waveRowH = 14; // === WAVE TYPE BUTTONS (below sliders, modular GUI) === @@ -5927,24 +5984,20 @@ function sim({ pressures, sound }) { // the visual needle sits on exactly the sample being // played. Cap at MAX. // - // released: animate the cursor back to live over ~20 frames instead - // of snapping. Feels like the needle "catches up" to the - // present. The capture ring was paused during hold so - // live write_pos still marks the press-moment, meaning - // offset=0 really is "where reversing started" — the - // animation just makes the visual transition legible. - const dtSec = 1 / 60; + // released: snap cursor back to 0 immediately. An earlier iteration + // eased the offset back over ~20 frames, but the audio + // replay voice stops on release — those ~300 ms of + // sweeping visual had no sound behind them, reading as + // "dead silent time" at the end of every reverse gesture. + // Snapping eliminates the audio/visual mismatch; the + // press→reverse→release feels like a clean in-and-out + // cut with nothing hanging off the back of it. if (spaceHeld && spacePressStartMs > 0) { const elapsedSec = (Date.now() - spacePressStartMs) / 1000; waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC, elapsedSec); waveDriftSpeed = -1.0; // cosmetic (only needle-color uses it) - } else if (waveViewOffsetSec > 0) { - // Ease-out lerp toward 0. At 60 fps with 0.18 factor the offset - // halves every ~4 frames — settles visually in ~15-20 frames. - waveViewOffsetSec = Math.max(0, waveViewOffsetSec - waveViewOffsetSec * 0.18 - dtSec * 0.25); - if (waveViewOffsetSec < 0.005) waveViewOffsetSec = 0; - waveDriftSpeed = 1.0; } else { + waveViewOffsetSec = 0; waveDriftSpeed = 1.0; } // Update dark/light mode via global theme (every ~5 seconds) @@ -6048,12 +6101,14 @@ function leave() { fxMix = 1; masterVolMix = 0.5; // unity (slider 0..1 → audio 0..2) driveMix = 0; + wobbleMix = 0; trackpadFX = false; soundAPI?.room?.setMix?.(0); soundAPI?.glitch?.setMix?.(0); soundAPI?.fx?.setMix?.(1); soundAPI?.volume?.setMix?.(1); // unity soundAPI?.drive?.setMix?.(0); // clean + soundAPI?.wobble?.setMix?.(0); // clean stopAllSounds(soundAPI, systemAPI, 0.02); } diff --git a/fedac/native/src/audio.c b/fedac/native/src/audio.c index 68f45ffdde..a438a6a3be 100644 --- a/fedac/native/src/audio.c +++ b/fedac/native/src/audio.c @@ -1441,6 +1441,9 @@ static void *audio_thread_fn(void *arg) { if (audio->drive_mix != audio->target_drive_mix) { audio->drive_mix += (audio->target_drive_mix - audio->drive_mix) * 0.00005f; } + if (audio->wobble_mix != audio->target_wobble_mix) { + audio->wobble_mix += (audio->target_wobble_mix - audio->wobble_mix) * 0.00005f; + } // Save dry signal before FX chain double dry_l = mix_l, dry_r = mix_r; @@ -1590,6 +1593,43 @@ static void *audio_thread_fn(void *arg) { } } + // Wobble / flange — modulated short-delay blend. Writes every + // sample into the ring regardless of wet mix (so the ring + // stays warm for instant-on when the slider turns up), then + // reads a sample `delay_samples` behind the write head with + // the delay itself sweeping via a slow LFO. Tiny feedback + // (0.45) thickens the tail so moderate mix settings already + // produce the characteristic jet-sweep coloration. + if (audio->wobble_buf_l && audio->wobble_buf_size > 0) { + int size = audio->wobble_buf_size; + int mask = size - 1; // size is power of two (1024) + int wp = audio->wobble_write_pos; + float wmix = audio->wobble_mix; + // Sweep: 2-10 ms at 48 kHz = 96..480 samples. + float lfo = (float)sin((double)audio->wobble_lfo_phase); + float delay_samples = 96.0f + (lfo * 0.5f + 0.5f) * 384.0f; + audio->wobble_lfo_phase += audio->wobble_lfo_rate; + if (audio->wobble_lfo_phase > 6.283185307179586f) + audio->wobble_lfo_phase -= 6.283185307179586f; + // Fractional read with linear interp. + float rp = (float)wp - delay_samples; + while (rp < 0) rp += size; + int rp_i = (int)rp; + float rp_f = rp - (float)rp_i; + float dly_l = audio->wobble_buf_l[rp_i & mask] * (1.0f - rp_f) + + audio->wobble_buf_l[(rp_i + 1) & mask] * rp_f; + float dly_r = audio->wobble_buf_r[rp_i & mask] * (1.0f - rp_f) + + audio->wobble_buf_r[(rp_i + 1) & mask] * rp_f; + // Feedback into the ring (for sustained "zing"). + audio->wobble_buf_l[wp & mask] = (float)mix_l + dly_l * 0.45f; + audio->wobble_buf_r[wp & mask] = (float)mix_r + dly_r * 0.45f; + audio->wobble_write_pos = (wp + 1) & mask; + if (wmix > 0.001f) { + mix_l = mix_l * (1.0 - wmix) + (double)dly_l * wmix; + mix_r = mix_r * (1.0 - wmix) + (double)dly_r * wmix; + } + } + // User-controlled drive (tanh soft-saturation) BEFORE system // volume so the harmonic character is independent of hardware // gain. drive_mix is a dry/wet blend: 0 = pure bypass, 1 = fully @@ -1843,6 +1883,18 @@ ACAudio *audio_init(void) { audio->target_master_volume = 1.0f; audio->drive_mix = 0.0f; // Clean bypass until user dials drive audio->target_drive_mix = 0.0f; + // Wobble / flange — 1024-sample ring covers up to ~21 ms @ 48 kHz + // which is well past the flanger sweet spot (1-10 ms). Power-of-two + // size lets the read index use `& (size-1)` instead of `%`. + audio->wobble_buf_size = 1024; + audio->wobble_buf_l = calloc(audio->wobble_buf_size, sizeof(float)); + audio->wobble_buf_r = calloc(audio->wobble_buf_size, sizeof(float)); + audio->wobble_write_pos = 0; + audio->wobble_lfo_phase = 0.0f; + // LFO rate 0.4 Hz — slow sweep, reads as "wobble" not "chorus" + audio->wobble_lfo_rate = 2.0f * 3.14159265358979f * 0.4f / 48000.0f; + audio->wobble_mix = 0.0f; + audio->target_wobble_mix = 0.0f; audio->room_buf_l = calloc(ROOM_SIZE, sizeof(float)); audio->room_buf_r = calloc(ROOM_SIZE, sizeof(float)); @@ -3045,6 +3097,17 @@ void audio_set_drive_mix(ACAudio *audio, float value) { audio->target_drive_mix = value; } +// Wobble / flange dry/wet 0..1. LFO-modulated short delay blended with +// the dry signal. Same exponential smoother as the other mix params so +// a fader sweep doesn't click. The LFO rate itself is fixed at 0.4 Hz +// and doesn't need smoothing. +void audio_set_wobble_mix(ACAudio *audio, float value) { + if (!audio) return; + if (value < 0.0f) value = 0.0f; + if (value > 1.0f) value = 1.0f; + audio->target_wobble_mix = value; +} + // Pause/resume writes to output_history_buf. Called from notepat while // the spacebar is held for reverse-replay: with writes paused the reverse // playback echo (re-captured from the speaker mix) can't double-layer diff --git a/fedac/native/src/audio.h b/fedac/native/src/audio.h index f12579e79b..8da56d7308 100644 --- a/fedac/native/src/audio.h +++ b/fedac/native/src/audio.h @@ -265,6 +265,19 @@ typedef struct { float drive_mix; float target_drive_mix; + // Wobble / flange — modulated short-delay dry/wet blend. 0.0 = bypass, + // 1.0 = fully flanged. LFO sweeps the read head over a 1–10 ms range + // so the audio periodically combs with a time-shifted copy of itself. + // Moderate feedback makes the characteristic "whoosh" zing. + float wobble_mix; + float target_wobble_mix; + float *wobble_buf_l; // stereo delay lines (mono'd from the mix) + float *wobble_buf_r; + int wobble_buf_size; // power of two for cheap modulo + int wobble_write_pos; // integer write cursor into the ring + float wobble_lfo_phase; // 0..2π — advances per sample at wobble_lfo_rate + float wobble_lfo_rate; // radians per sample (≈ 2π * 0.4 Hz / rate) + // System mixer volume (0-100 percent) int system_volume; int card_index; // ALSA card number (0 or 1) @@ -407,6 +420,7 @@ void audio_set_glitch_mix(ACAudio *audio, float mix); void audio_set_fx_mix(ACAudio *audio, float mix); void audio_set_master_volume(ACAudio *audio, float value); void audio_set_drive_mix(ACAudio *audio, float value); +void audio_set_wobble_mix(ACAudio *audio, float value); void audio_set_output_history_paused(ACAudio *audio, int paused); // Microphone — hot-mic mode (device stays open, recording toggles buffering) diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index afc565f96f..6faa508f18 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -1216,6 +1216,16 @@ static JSValue js_speaker_set_capture_paused(JSContext *ctx, JSValueConst this_v return JS_UNDEFINED; } +// sound.wobble.setMix(value) — flanger-ish dry/wet (0..1) +static JSValue js_set_wobble_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (argc < 1 || !current_rt->audio) return JS_UNDEFINED; + double v; + JS_ToFloat64(ctx, &v, argv[0]); + audio_set_wobble_mix(current_rt->audio, (float)v); + return JS_UNDEFINED; +} + // sound.microphone.open() — open device + start hot-mic thread static JSValue js_mic_open(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { (void)this_val; (void)argc; (void)argv; @@ -2917,6 +2927,12 @@ static JSValue build_sound_obj(JSContext *ctx, ACRuntime *rt) { JS_SetPropertyStr(ctx, drive, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->drive_mix : 0.0)); JS_SetPropertyStr(ctx, sound, "drive", drive); + // wobble (modulated-delay flange) + JSValue wobble = JS_NewObject(ctx); + JS_SetPropertyStr(ctx, wobble, "setMix", JS_NewCFunction(ctx, js_set_wobble_mix, "setMix", 1)); + JS_SetPropertyStr(ctx, wobble, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->wobble_mix : 0.0)); + JS_SetPropertyStr(ctx, sound, "wobble", wobble); + // microphone JSValue mic = JS_NewObject(ctx); JS_SetPropertyStr(ctx, mic, "open", JS_NewCFunction(ctx, js_mic_open, "open", 0)); -- 2.51.2