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));