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.
This commit is contained in:
parent
509032f257
commit
68777fd00e
1 changed files with 31 additions and 7 deletions
|
|
@ -90,9 +90,20 @@ interface Engine {
|
||||||
delay: DelayNode;
|
delay: DelayNode;
|
||||||
fb: GainNode;
|
fb: GainNode;
|
||||||
master: GainNode;
|
master: GainNode;
|
||||||
|
limiter: DynamicsCompressorNode;
|
||||||
analyser: AnalyserNode;
|
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 driveCurve = (amount: number) => {
|
||||||
const k = amount * 0.6;
|
const k = amount * 0.6;
|
||||||
const n = 256;
|
const n = 256;
|
||||||
|
|
@ -168,6 +179,14 @@ export const Synth: React.FC = () => {
|
||||||
wet.gain.value = p.delayMix / 100;
|
wet.gain.value = p.delayMix / 100;
|
||||||
const master = ctx.createGain();
|
const master = ctx.createGain();
|
||||||
master.gain.value = Math.pow(10, p.master / 20);
|
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();
|
const analyser = ctx.createAnalyser();
|
||||||
analyser.fftSize = 1024;
|
analyser.fftSize = 1024;
|
||||||
|
|
||||||
|
|
@ -181,13 +200,14 @@ export const Synth: React.FC = () => {
|
||||||
delay.connect(wet);
|
delay.connect(wet);
|
||||||
dry.connect(master);
|
dry.connect(master);
|
||||||
wet.connect(master);
|
wet.connect(master);
|
||||||
master.connect(analyser);
|
master.connect(limiter);
|
||||||
|
limiter.connect(analyser);
|
||||||
analyser.connect(ctx.destination);
|
analyser.connect(ctx.destination);
|
||||||
lfo.connect(lfoGain);
|
lfo.connect(lfoGain);
|
||||||
// Initial LFO routing happens in the params effect below.
|
// Initial LFO routing happens in the params effect below.
|
||||||
osc.start();
|
osc.start();
|
||||||
lfo.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);
|
const buf = new Float32Array(analyser.fftSize);
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
|
|
@ -248,7 +268,12 @@ export const Synth: React.FC = () => {
|
||||||
else e.lfoGain.connect(e.osc.detune);
|
else e.lfoGain.connect(e.osc.detune);
|
||||||
lfoTargetRef.current = params.lfoTarget;
|
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);
|
e.lfoGain.gain.setTargetAtTime(depth, t, 0.015);
|
||||||
// Retune a held note when octave/detune change.
|
// Retune a held note when octave/detune change.
|
||||||
if (baseFreq.current !== null) applyPitch(baseFreq.current);
|
if (baseFreq.current !== null) applyPitch(baseFreq.current);
|
||||||
|
|
@ -272,8 +297,7 @@ export const Synth: React.FC = () => {
|
||||||
const p = paramsRef.current;
|
const p = paramsRef.current;
|
||||||
const t = e.ctx.currentTime;
|
const t = e.ctx.currentTime;
|
||||||
const g = e.env.gain;
|
const g = e.env.gain;
|
||||||
g.cancelScheduledValues(t);
|
holdParam(g, t);
|
||||||
g.setValueAtTime(g.value, t);
|
|
||||||
g.linearRampToValueAtTime(0.55, t + p.attack / 1000);
|
g.linearRampToValueAtTime(0.55, t + p.attack / 1000);
|
||||||
g.linearRampToValueAtTime(0.55 * (p.sustain / 100), t + p.attack / 1000 + p.decay / 1000);
|
g.linearRampToValueAtTime(0.55 * (p.sustain / 100), t + p.attack / 1000 + p.decay / 1000);
|
||||||
setNote(name);
|
setNote(name);
|
||||||
|
|
@ -284,8 +308,7 @@ export const Synth: React.FC = () => {
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
const t = e.ctx.currentTime;
|
const t = e.ctx.currentTime;
|
||||||
const g = e.env.gain;
|
const g = e.env.gain;
|
||||||
g.cancelScheduledValues(t);
|
holdParam(g, t);
|
||||||
g.setValueAtTime(g.value, t);
|
|
||||||
g.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000);
|
g.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000);
|
||||||
baseFreq.current = null;
|
baseFreq.current = null;
|
||||||
setNote(null);
|
setNote(null);
|
||||||
|
|
@ -599,6 +622,7 @@ export const Synth: React.FC = () => {
|
||||||
<Meter
|
<Meter
|
||||||
value={meterDb.rms}
|
value={meterDb.rms}
|
||||||
clipValue={meterDb.peak}
|
clipValue={meterDb.peak}
|
||||||
|
clipThreshold={-1}
|
||||||
min={-60}
|
min={-60}
|
||||||
max={0}
|
max={0}
|
||||||
length={120}
|
length={120}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue