From 68777fd00e20b860b6f2fc02502185e534f39462 Mon Sep 17 00:00:00 2001 From: Dreamodus Date: Sun, 12 Jul 2026 20:47:01 -0700 Subject: [PATCH] Synth: eliminate crackling - limiter, LFO clamp, click-free envelopes The crackle was software (our audio graph), not hardware: - Peaks past 0 dBFS were being hard-clipped by the DAC (resonance up to +27 dB and delay feedback easily exceed full scale). Added a brickwall-style DynamicsCompressor (-3 dB, 20:1, 2 ms attack) before the destination; worst-case output now peaks around -6 dB RMS. - LFO cutoff modulation is clamped below the cutoff value so the filter frequency cannot be driven negative (high-Q biquad near 0 Hz sputters audibly). - Envelope retriggers use cancelAndHoldAtTime (with fallback) instead of cancel+set, removing step-discontinuity clicks on fast replays. - Synth meter clip threshold moved to -1 dBFS: the LED now reads as a limiter-activity indicator since true output clipping is gone. --- apps/docs/src/sections/Synth.tsx | 38 ++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/sections/Synth.tsx b/apps/docs/src/sections/Synth.tsx index 480d03d..29029d2 100644 --- a/apps/docs/src/sections/Synth.tsx +++ b/apps/docs/src/sections/Synth.tsx @@ -90,9 +90,20 @@ interface Engine { delay: DelayNode; fb: GainNode; master: GainNode; + limiter: DynamicsCompressorNode; analyser: AnalyserNode; } +/** Click-free ramp restart: hold the param at its current value first. */ +const holdParam = (p: AudioParam, t: number) => { + const anyP = p as AudioParam & { cancelAndHoldAtTime?: (t: number) => AudioParam }; + if (anyP.cancelAndHoldAtTime) anyP.cancelAndHoldAtTime(t); + else { + p.cancelScheduledValues(t); + p.setValueAtTime(p.value, t); + } +}; + const driveCurve = (amount: number) => { const k = amount * 0.6; const n = 256; @@ -168,6 +179,14 @@ export const Synth: React.FC = () => { wet.gain.value = p.delayMix / 100; const master = ctx.createGain(); master.gain.value = Math.pow(10, p.master / 20); + // Safety limiter: high resonance + delay feedback can push peaks well + // past 0 dBFS, which the DAC hard-clips (audible crackle). Catch it here. + const limiter = ctx.createDynamicsCompressor(); + limiter.threshold.value = -3; + limiter.knee.value = 0; + limiter.ratio.value = 20; + limiter.attack.value = 0.002; + limiter.release.value = 0.12; const analyser = ctx.createAnalyser(); analyser.fftSize = 1024; @@ -181,13 +200,14 @@ export const Synth: React.FC = () => { delay.connect(wet); dry.connect(master); wet.connect(master); - master.connect(analyser); + master.connect(limiter); + limiter.connect(analyser); analyser.connect(ctx.destination); lfo.connect(lfoGain); // Initial LFO routing happens in the params effect below. osc.start(); lfo.start(); - engine.current = { ctx, osc, shaper, env, filter, lfo, lfoGain, dry, wet, delay, fb, master, analyser }; + engine.current = { ctx, osc, shaper, env, filter, lfo, lfoGain, dry, wet, delay, fb, master, limiter, analyser }; const buf = new Float32Array(analyser.fftSize); let raf = 0; @@ -248,7 +268,12 @@ export const Synth: React.FC = () => { else e.lfoGain.connect(e.osc.detune); lfoTargetRef.current = params.lfoTarget; } - const depth = params.lfoTarget === 0 ? params.lfoDepth * 14 : params.lfoDepth * 0.6; + // Cap cutoff modulation below the cutoff itself so the filter frequency + // can't be driven negative (a high-Q biquad near 0 Hz sputters audibly). + const depth = + params.lfoTarget === 0 + ? Math.min(params.lfoDepth * 14, params.cutoff * 0.85) + : params.lfoDepth * 0.6; e.lfoGain.gain.setTargetAtTime(depth, t, 0.015); // Retune a held note when octave/detune change. if (baseFreq.current !== null) applyPitch(baseFreq.current); @@ -272,8 +297,7 @@ export const Synth: React.FC = () => { const p = paramsRef.current; const t = e.ctx.currentTime; const g = e.env.gain; - g.cancelScheduledValues(t); - g.setValueAtTime(g.value, t); + holdParam(g, t); g.linearRampToValueAtTime(0.55, t + p.attack / 1000); g.linearRampToValueAtTime(0.55 * (p.sustain / 100), t + p.attack / 1000 + p.decay / 1000); setNote(name); @@ -284,8 +308,7 @@ export const Synth: React.FC = () => { if (!e) return; const t = e.ctx.currentTime; const g = e.env.gain; - g.cancelScheduledValues(t); - g.setValueAtTime(g.value, t); + holdParam(g, t); g.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000); baseFreq.current = null; setNote(null); @@ -599,6 +622,7 @@ export const Synth: React.FC = () => {