Skip to content

Commit d6f3540

Browse files
Enhance limiter with soft-knee saturation and transient preservation
Two-Stage Limiter Architecture: - Stage 1: Lookahead gain reduction with 4x oversampled true peak detection - Stage 2: Soft-knee tanh saturation as safety net (no hard clipping) Soft-Knee Curve (applySoftKneeCurve): - Below knee: Linear passthrough - In knee: Smoothstep polynomial blend - Above ceiling: tanh sigmoid saturation - Output asymptotically approaches but never exceeds 1.0 Transient Preservation (preserveTransients): - Calculates crest factor (peak/RMS ratio) - High crest = transient, allows ~1dB extra headroom - Soft-knee catches the overs transparently - Preserves punch on drums and percussive elements Oversampled Soft-Knee (applySoftKneeOversampled): - Uses Catmull-Rom interpolation for inter-sample peak detection - Ensures true-peak compliance even with saturation curve Additional improvements: - Smoother attack via lookahead interpolation (prevents pumping) - Configurable knee width (default 3dB) - Console logging shows both stage activity Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5efad97 commit d6f3540

2 files changed

Lines changed: 577 additions & 26 deletions

File tree

src/renderer.js

Lines changed: 288 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,153 @@ function measureLUFS(audioBuffer) {
168168
return LUFS_CONSTANTS.LOUDNESS_OFFSET + 10 * Math.log10(gatedMean);
169169
}
170170

171+
/**
172+
* Apply soft-knee limiting curve using tanh sigmoid
173+
* Provides transparent peak control without hard clipping distortion
174+
*
175+
* The curve has two regions:
176+
* 1. Below ceiling: Linear passthrough (unity gain)
177+
* 2. Above ceiling: Soft saturation using tanh (asymptotic approach to 1.0)
178+
*
179+
* The tanh function ensures:
180+
* - Smooth, continuous transition at the ceiling
181+
* - Output asymptotically approaches but never exceeds 1.0
182+
* - No discontinuities that cause audible distortion
183+
*
184+
* @param {number} sample - Input sample value
185+
* @param {number} ceiling - Target ceiling in linear (e.g., 0.891 for -1dBTP)
186+
* @param {number} kneeDB - Soft knee width in dB (default 3dB, range 0-12)
187+
* @returns {number} Limited sample value
188+
*/
189+
function applySoftKneeCurve(sample, ceiling, kneeDB = 3) {
190+
const absSample = Math.abs(sample);
191+
192+
// Fast path: signal well below ceiling
193+
if (absSample <= ceiling * 0.9) {
194+
return sample;
195+
}
196+
197+
// Calculate knee boundaries
198+
// Knee width in linear: ratio between knee start and ceiling
199+
const kneeRatio = Math.pow(10, kneeDB / 20);
200+
const kneeStart = ceiling / kneeRatio;
201+
202+
// Region 1: Below knee start - pure linear passthrough
203+
if (absSample <= kneeStart) {
204+
return sample;
205+
}
206+
207+
// Region 2: In the knee (between kneeStart and ceiling)
208+
// Smooth polynomial blend from linear to limited
209+
if (absSample <= ceiling) {
210+
// Normalized position in knee (0 = knee start, 1 = ceiling)
211+
const t = (absSample - kneeStart) / (ceiling - kneeStart);
212+
213+
// Smoothstep function for gradual transition: 3t² - 2t³
214+
const blend = t * t * (3 - 2 * t);
215+
216+
// Blend between linear output and ceiling
217+
// At t=0: output = absSample (linear)
218+
// At t=1: output = ceiling (just touching limit)
219+
const output = absSample + (ceiling - absSample) * blend * 0.5;
220+
221+
return Math.sign(sample) * output;
222+
}
223+
224+
// Region 3: Above ceiling - tanh soft saturation
225+
// This is where the magic happens: smooth compression using tanh
226+
const excess = absSample - ceiling;
227+
const headroom = 1.0 - ceiling; // Available space between ceiling and 0dBFS
228+
229+
// Prevent division by zero if ceiling is at 1.0
230+
if (headroom <= 0.001) {
231+
return Math.sign(sample) * ceiling;
232+
}
233+
234+
// tanh(x) approaches 1 asymptotically as x → ∞
235+
// Scale the excess so that moderate overs use ~50% of headroom
236+
// and extreme overs approach but never reach 1.0
237+
const scaledExcess = excess / headroom;
238+
const saturation = Math.tanh(scaledExcess);
239+
240+
// Output: ceiling + portion of remaining headroom
241+
// As input → ∞, output → ceiling + headroom = 1.0
242+
const output = ceiling + headroom * saturation;
243+
244+
return Math.sign(sample) * output;
245+
}
246+
247+
/**
248+
* Apply soft-knee curve with 4x oversampling to handle inter-sample peaks
249+
* Uses Catmull-Rom interpolation for upsampling and proper filtering
250+
*
251+
* @param {Float32Array} input - Input sample array
252+
* @param {number} ceiling - Ceiling in linear
253+
* @param {number} kneeDB - Knee width in dB
254+
* @returns {Float32Array} Processed output (same length as input)
255+
*/
256+
function applySoftKneeOversampled(input, ceiling, kneeDB = 3) {
257+
const length = input.length;
258+
const output = new Float32Array(length);
259+
260+
// Process with 4x oversampling using Catmull-Rom interpolation
261+
const prevSamples = [0, 0, 0, 0];
262+
263+
for (let i = 0; i < length; i++) {
264+
// Shift sample history
265+
prevSamples[0] = prevSamples[1];
266+
prevSamples[1] = prevSamples[2];
267+
prevSamples[2] = prevSamples[3];
268+
prevSamples[3] = input[i];
269+
270+
if (i < 3) {
271+
// Not enough samples for interpolation yet
272+
output[i] = applySoftKneeCurve(input[i], ceiling, kneeDB);
273+
continue;
274+
}
275+
276+
// Calculate Catmull-Rom coefficients
277+
const y0 = prevSamples[0];
278+
const y1 = prevSamples[1];
279+
const y2 = prevSamples[2]; // Current sample position
280+
const y3 = prevSamples[3];
281+
282+
const a0 = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3;
283+
const a1 = y0 - 2.5 * y1 + 2 * y2 - 0.5 * y3;
284+
const a2 = -0.5 * y0 + 0.5 * y2;
285+
const a3 = y1;
286+
287+
// Find the maximum oversampled value and apply limiting
288+
let maxInterpolated = Math.abs(y2);
289+
let maxT = 0;
290+
291+
// Check 4x oversampled points between y1 and y2
292+
for (let j = 1; j <= 3; j++) {
293+
const t = j * 0.25;
294+
const t2 = t * t;
295+
const t3 = t2 * t;
296+
const interpolated = Math.abs(a0 * t3 + a1 * t2 + a2 * t + a3);
297+
if (interpolated > maxInterpolated) {
298+
maxInterpolated = interpolated;
299+
maxT = t;
300+
}
301+
}
302+
303+
// If inter-sample peak exceeds ceiling, we need to reduce the sample
304+
if (maxInterpolated > ceiling) {
305+
// Calculate gain reduction needed to bring inter-sample peak to ceiling
306+
const gainReduction = ceiling / maxInterpolated;
307+
// Apply soft-knee to the reduced signal
308+
output[i] = applySoftKneeCurve(input[i] * gainReduction, ceiling, kneeDB);
309+
} else {
310+
// No inter-sample peak issue, just apply soft-knee to sample
311+
output[i] = applySoftKneeCurve(input[i], ceiling, kneeDB);
312+
}
313+
}
314+
315+
return output;
316+
}
317+
171318
/**
172319
* Calculate true peak using 4x oversampled Catmull-Rom interpolation
173320
* This finds inter-sample peaks that simple sample-based measurement misses
@@ -232,16 +379,31 @@ function findTruePeak(audioBuffer) {
232379
}
233380

234381
/**
235-
* Apply lookahead limiter to an AudioBuffer
236-
* Uses true-peak detection with 4x oversampling and smooth gain envelope
382+
* Two-Stage Lookahead Limiter with Soft-Knee Safety
383+
*
384+
* Stage 1: Lookahead gain reduction
385+
* - Uses true-peak detection with 4x oversampling
386+
* - Sees peaks coming and reduces gain before they hit
387+
* - Smooth attack/release envelope for transparency
388+
*
389+
* Stage 2: Soft-knee saturation safety net
390+
* - Catches any remaining peaks that slip through
391+
* - Uses tanh sigmoid curve - no hard clipping
392+
* - Oversampled to handle inter-sample peaks
393+
*
394+
* Stage 3: Transient-aware gain adjustment (optional)
395+
* - Detects high crest factor (transient) regions
396+
* - Applies gentler limiting to preserve transient punch
237397
*
238398
* @param audioBuffer - Input AudioBuffer
239399
* @param ceilingLinear - Ceiling in linear scale (e.g., 0.891 for -1dBTP)
240400
* @param lookaheadMs - Lookahead time in milliseconds (default 3ms)
241401
* @param releaseMs - Release time in milliseconds (default 100ms)
402+
* @param kneeDB - Soft knee width in dB (default 3dB)
403+
* @param preserveTransients - Apply gentler limiting on transients (default true)
242404
* @returns New AudioBuffer with limiting applied
243405
*/
244-
function applyLookaheadLimiter(audioBuffer, ceilingLinear = 0.891, lookaheadMs = 3, releaseMs = 100) {
406+
function applyLookaheadLimiter(audioBuffer, ceilingLinear = 0.891, lookaheadMs = 3, releaseMs = 100, kneeDB = 3, preserveTransients = true) {
245407
const sampleRate = audioBuffer.sampleRate;
246408
const numChannels = audioBuffer.numberOfChannels;
247409
const length = audioBuffer.length;
@@ -255,7 +417,78 @@ function applyLookaheadLimiter(audioBuffer, ceilingLinear = 0.891, lookaheadMs =
255417
channels.push(audioBuffer.getChannelData(ch));
256418
}
257419

258-
// First pass: Calculate gain reduction envelope
420+
// =========================================================================
421+
// Stage 0 (optional): Analyze transients for crest factor detection
422+
// High crest factor = transient punch, needs gentler limiting
423+
// =========================================================================
424+
let transientMap = null;
425+
if (preserveTransients) {
426+
transientMap = new Float32Array(length);
427+
const windowMs = 10; // 10ms RMS window
428+
const windowSamples = Math.floor(sampleRate * windowMs / 1000);
429+
430+
// Calculate RMS envelope
431+
const rmsEnvelope = new Float32Array(length);
432+
let rmsSum = 0;
433+
434+
for (let i = 0; i < length; i++) {
435+
// Sum of squares for all channels
436+
let sampleSum = 0;
437+
for (let ch = 0; ch < numChannels; ch++) {
438+
sampleSum += channels[ch][i] * channels[ch][i];
439+
}
440+
sampleSum /= numChannels;
441+
442+
rmsSum += sampleSum;
443+
if (i >= windowSamples) {
444+
let oldSum = 0;
445+
for (let ch = 0; ch < numChannels; ch++) {
446+
oldSum += channels[ch][i - windowSamples] * channels[ch][i - windowSamples];
447+
}
448+
rmsSum -= oldSum / numChannels;
449+
}
450+
451+
const windowSize = Math.min(i + 1, windowSamples);
452+
rmsEnvelope[i] = Math.sqrt(rmsSum / windowSize);
453+
}
454+
455+
// Calculate peak envelope (fast attack, slow release)
456+
const peakEnvelope = new Float32Array(length);
457+
const peakAttack = Math.exp(-1 / (0.001 * sampleRate)); // 1ms attack
458+
const peakRelease = Math.exp(-1 / (0.050 * sampleRate)); // 50ms release
459+
let peakLevel = 0;
460+
461+
for (let i = 0; i < length; i++) {
462+
let peak = 0;
463+
for (let ch = 0; ch < numChannels; ch++) {
464+
peak = Math.max(peak, Math.abs(channels[ch][i]));
465+
}
466+
467+
if (peak > peakLevel) {
468+
peakLevel = peakAttack * peakLevel + (1 - peakAttack) * peak;
469+
} else {
470+
peakLevel = peakRelease * peakLevel + (1 - peakRelease) * peak;
471+
}
472+
peakEnvelope[i] = peakLevel;
473+
}
474+
475+
// Calculate crest factor and transient map
476+
// Crest factor > 10dB indicates transient, allow more headroom
477+
const transientThresholdDB = 10;
478+
for (let i = 0; i < length; i++) {
479+
if (rmsEnvelope[i] > 0.0001) {
480+
const crestFactorDB = 20 * Math.log10(peakEnvelope[i] / rmsEnvelope[i]);
481+
// Map: 0dB crest = 0 (sustain), 10+dB crest = 1 (transient)
482+
transientMap[i] = Math.min(1, Math.max(0, (crestFactorDB - 6) / (transientThresholdDB - 6)));
483+
} else {
484+
transientMap[i] = 0;
485+
}
486+
}
487+
}
488+
489+
// =========================================================================
490+
// Stage 1: Calculate gain reduction envelope with lookahead
491+
// =========================================================================
259492
const gainEnvelope = new Float32Array(length);
260493
gainEnvelope.fill(1.0);
261494

@@ -286,26 +519,39 @@ function applyLookaheadLimiter(audioBuffer, ceilingLinear = 0.891, lookaheadMs =
286519
}
287520

288521
// Calculate required gain reduction
522+
// For transients, use slightly higher effective ceiling to preserve punch
523+
let effectiveCeiling = ceilingLinear;
524+
if (preserveTransients && transientMap && transientMap[i] > 0.5) {
525+
// Allow up to 1dB more headroom for strong transients
526+
// The soft-knee stage will catch any overs
527+
effectiveCeiling = ceilingLinear * Math.pow(10, transientMap[i] * 0.5 / 20);
528+
}
529+
289530
let requiredGain = 1.0;
290-
if (truePeak > ceilingLinear) {
291-
requiredGain = ceilingLinear / truePeak;
531+
if (truePeak > effectiveCeiling) {
532+
requiredGain = effectiveCeiling / truePeak;
292533
}
293534

294535
// Apply lookahead - the gain reduction affects samples BEFORE this point
295536
const targetIndex = Math.max(0, i - lookaheadSamples);
296537
if (requiredGain < gainEnvelope[targetIndex]) {
297-
// Instant attack - apply gain reduction immediately
538+
// Smooth attack over lookahead period instead of instant
539+
// This prevents pumping artifacts
298540
for (let j = targetIndex; j <= i; j++) {
299-
gainEnvelope[j] = Math.min(gainEnvelope[j], requiredGain);
541+
const progress = (j - targetIndex) / lookaheadSamples;
542+
const smoothedGain = gainEnvelope[targetIndex] + (requiredGain - gainEnvelope[targetIndex]) * progress;
543+
gainEnvelope[j] = Math.min(gainEnvelope[j], smoothedGain);
300544
}
301545
}
302546
}
303547

304-
// Second pass: Smooth the gain envelope (release)
548+
// =========================================================================
549+
// Stage 1b: Smooth the gain envelope (release)
550+
// =========================================================================
305551
let currentGain = 1.0;
306552
for (let i = 0; i < length; i++) {
307553
if (gainEnvelope[i] < currentGain) {
308-
// Instant attack
554+
// Instant attack (already smoothed by lookahead)
309555
currentGain = gainEnvelope[i];
310556
} else {
311557
// Smooth release
@@ -315,7 +561,9 @@ function applyLookaheadLimiter(audioBuffer, ceilingLinear = 0.891, lookaheadMs =
315561
gainEnvelope[i] = currentGain;
316562
}
317563

318-
// Create output buffer and apply gain envelope
564+
// =========================================================================
565+
// Stage 2: Apply gain envelope and soft-knee saturation
566+
// =========================================================================
319567
const outputBuffer = new AudioBuffer({
320568
numberOfChannels: numChannels,
321569
length: length,
@@ -325,18 +573,45 @@ function applyLookaheadLimiter(audioBuffer, ceilingLinear = 0.891, lookaheadMs =
325573
for (let ch = 0; ch < numChannels; ch++) {
326574
const input = channels[ch];
327575
const output = outputBuffer.getChannelData(ch);
576+
577+
// First apply gain reduction
328578
for (let i = 0; i < length; i++) {
329579
output[i] = input[i] * gainEnvelope[i];
330580
}
581+
582+
// Then apply soft-knee curve with oversampling as final safety
583+
const softKneeOutput = applySoftKneeOversampled(output, ceilingLinear, kneeDB);
584+
for (let i = 0; i < length; i++) {
585+
output[i] = softKneeOutput[i];
586+
}
331587
}
332588

333-
// Log gain reduction stats
589+
// =========================================================================
590+
// Logging
591+
// =========================================================================
334592
let minGain = 1.0;
593+
let softKneeActive = false;
335594
for (let i = 0; i < length; i++) {
336595
if (gainEnvelope[i] < minGain) minGain = gainEnvelope[i];
337596
}
597+
598+
// Check if soft-knee did any work
599+
for (let ch = 0; ch < numChannels; ch++) {
600+
const output = outputBuffer.getChannelData(ch);
601+
for (let i = 0; i < length; i++) {
602+
if (Math.abs(output[i]) > ceilingLinear * 0.99) {
603+
softKneeActive = true;
604+
break;
605+
}
606+
}
607+
if (softKneeActive) break;
608+
}
609+
338610
if (minGain < 1.0) {
339-
console.log('[Limiter] Max gain reduction:', (20 * Math.log10(minGain)).toFixed(2), 'dB');
611+
console.log('[Limiter] Stage 1 - Max gain reduction:', (20 * Math.log10(minGain)).toFixed(2), 'dB');
612+
}
613+
if (softKneeActive) {
614+
console.log('[Limiter] Stage 2 - Soft-knee saturation active');
340615
}
341616

342617
return outputBuffer;

0 commit comments

Comments
 (0)