@@ -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