@@ -169,18 +169,60 @@ function measureLUFS(audioBuffer) {
169169}
170170
171171/**
172- * Find the true peak of an AudioBuffer (maximum absolute sample value)
172+ * Calculate true peak using 4x oversampled Catmull-Rom interpolation
173+ * This finds inter-sample peaks that simple sample-based measurement misses
174+ * ITU-R BS.1770 compliant true-peak detection
175+ */
176+ function calculateTruePeakSample ( prevSamples ) {
177+ const y0 = prevSamples [ 0 ] ;
178+ const y1 = prevSamples [ 1 ] ;
179+ const y2 = prevSamples [ 2 ] ;
180+ const y3 = prevSamples [ 3 ] ;
181+
182+ // Start with current sample
183+ let peak = Math . abs ( y2 ) ;
184+
185+ // Catmull-Rom spline coefficients: y(t) = a0*t³ + a1*t² + a2*t + a3
186+ const a0 = - 0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3 ;
187+ const a1 = y0 - 2.5 * y1 + 2 * y2 - 0.5 * y3 ;
188+ const a2 = - 0.5 * y0 + 0.5 * y2 ;
189+ const a3 = y1 ;
190+
191+ // Check at 4x oversampled points
192+ for ( let i = 1 ; i <= 3 ; i ++ ) {
193+ const t = i * 0.25 ;
194+ const t2 = t * t ;
195+ const t3 = t2 * t ;
196+ const interpolated = a0 * t3 + a1 * t2 + a2 * t + a3 ;
197+ peak = Math . max ( peak , Math . abs ( interpolated ) ) ;
198+ }
199+
200+ return peak ;
201+ }
202+
203+ /**
204+ * Find the true peak of an AudioBuffer using 4x oversampling
173205 * Returns peak in dBTP (decibels relative to full scale)
174206 */
175207function findTruePeak ( audioBuffer ) {
176208 let maxPeak = 0 ;
177209
178210 for ( let ch = 0 ; ch < audioBuffer . numberOfChannels ; ch ++ ) {
179211 const channelData = audioBuffer . getChannelData ( ch ) ;
212+ const prevSamples = [ 0 , 0 , 0 , 0 ] ;
213+
180214 for ( let i = 0 ; i < channelData . length ; i ++ ) {
181- const absSample = Math . abs ( channelData [ i ] ) ;
182- if ( absSample > maxPeak ) {
183- maxPeak = absSample ;
215+ // Shift previous samples
216+ prevSamples [ 0 ] = prevSamples [ 1 ] ;
217+ prevSamples [ 1 ] = prevSamples [ 2 ] ;
218+ prevSamples [ 2 ] = prevSamples [ 3 ] ;
219+ prevSamples [ 3 ] = channelData [ i ] ;
220+
221+ if ( i >= 3 ) {
222+ const truePeak = calculateTruePeakSample ( prevSamples ) ;
223+ if ( truePeak > maxPeak ) {
224+ maxPeak = truePeak ;
225+ }
184226 }
185227 }
186228 }
@@ -189,6 +231,117 @@ function findTruePeak(audioBuffer) {
189231 return maxPeak > 0 ? 20 * Math . log10 ( maxPeak ) : - Infinity ;
190232}
191233
234+ /**
235+ * Apply lookahead limiter to an AudioBuffer
236+ * Uses true-peak detection with 4x oversampling and smooth gain envelope
237+ *
238+ * @param audioBuffer - Input AudioBuffer
239+ * @param ceilingLinear - Ceiling in linear scale (e.g., 0.891 for -1dBTP)
240+ * @param lookaheadMs - Lookahead time in milliseconds (default 3ms)
241+ * @param releaseMs - Release time in milliseconds (default 100ms)
242+ * @returns New AudioBuffer with limiting applied
243+ */
244+ function applyLookaheadLimiter ( audioBuffer , ceilingLinear = 0.891 , lookaheadMs = 3 , releaseMs = 100 ) {
245+ const sampleRate = audioBuffer . sampleRate ;
246+ const numChannels = audioBuffer . numberOfChannels ;
247+ const length = audioBuffer . length ;
248+
249+ const lookaheadSamples = Math . floor ( sampleRate * lookaheadMs / 1000 ) ;
250+ const releaseCoef = Math . exp ( - 1 / ( releaseMs * sampleRate / 1000 ) ) ;
251+
252+ // Get channel data
253+ const channels = [ ] ;
254+ for ( let ch = 0 ; ch < numChannels ; ch ++ ) {
255+ channels . push ( audioBuffer . getChannelData ( ch ) ) ;
256+ }
257+
258+ // First pass: Calculate gain reduction envelope
259+ const gainEnvelope = new Float32Array ( length ) ;
260+ gainEnvelope . fill ( 1.0 ) ;
261+
262+ const prevSamplesL = [ 0 , 0 , 0 , 0 ] ;
263+ const prevSamplesR = numChannels > 1 ? [ 0 , 0 , 0 , 0 ] : null ;
264+
265+ for ( let i = 0 ; i < length ; i ++ ) {
266+ // Update previous samples for true peak calculation
267+ prevSamplesL [ 0 ] = prevSamplesL [ 1 ] ;
268+ prevSamplesL [ 1 ] = prevSamplesL [ 2 ] ;
269+ prevSamplesL [ 2 ] = prevSamplesL [ 3 ] ;
270+ prevSamplesL [ 3 ] = channels [ 0 ] [ i ] ;
271+
272+ let truePeak = 0 ;
273+ if ( i >= 3 ) {
274+ truePeak = calculateTruePeakSample ( prevSamplesL ) ;
275+ }
276+
277+ if ( numChannels > 1 && prevSamplesR ) {
278+ prevSamplesR [ 0 ] = prevSamplesR [ 1 ] ;
279+ prevSamplesR [ 1 ] = prevSamplesR [ 2 ] ;
280+ prevSamplesR [ 2 ] = prevSamplesR [ 3 ] ;
281+ prevSamplesR [ 3 ] = channels [ 1 ] [ i ] ;
282+
283+ if ( i >= 3 ) {
284+ truePeak = Math . max ( truePeak , calculateTruePeakSample ( prevSamplesR ) ) ;
285+ }
286+ }
287+
288+ // Calculate required gain reduction
289+ let requiredGain = 1.0 ;
290+ if ( truePeak > ceilingLinear ) {
291+ requiredGain = ceilingLinear / truePeak ;
292+ }
293+
294+ // Apply lookahead - the gain reduction affects samples BEFORE this point
295+ const targetIndex = Math . max ( 0 , i - lookaheadSamples ) ;
296+ if ( requiredGain < gainEnvelope [ targetIndex ] ) {
297+ // Instant attack - apply gain reduction immediately
298+ for ( let j = targetIndex ; j <= i ; j ++ ) {
299+ gainEnvelope [ j ] = Math . min ( gainEnvelope [ j ] , requiredGain ) ;
300+ }
301+ }
302+ }
303+
304+ // Second pass: Smooth the gain envelope (release)
305+ let currentGain = 1.0 ;
306+ for ( let i = 0 ; i < length ; i ++ ) {
307+ if ( gainEnvelope [ i ] < currentGain ) {
308+ // Instant attack
309+ currentGain = gainEnvelope [ i ] ;
310+ } else {
311+ // Smooth release
312+ currentGain = releaseCoef * currentGain + ( 1 - releaseCoef ) * 1.0 ;
313+ currentGain = Math . min ( currentGain , 1.0 ) ;
314+ }
315+ gainEnvelope [ i ] = currentGain ;
316+ }
317+
318+ // Create output buffer and apply gain envelope
319+ const outputBuffer = new AudioBuffer ( {
320+ numberOfChannels : numChannels ,
321+ length : length ,
322+ sampleRate : sampleRate
323+ } ) ;
324+
325+ for ( let ch = 0 ; ch < numChannels ; ch ++ ) {
326+ const input = channels [ ch ] ;
327+ const output = outputBuffer . getChannelData ( ch ) ;
328+ for ( let i = 0 ; i < length ; i ++ ) {
329+ output [ i ] = input [ i ] * gainEnvelope [ i ] ;
330+ }
331+ }
332+
333+ // Log gain reduction stats
334+ let minGain = 1.0 ;
335+ for ( let i = 0 ; i < length ; i ++ ) {
336+ if ( gainEnvelope [ i ] < minGain ) minGain = gainEnvelope [ i ] ;
337+ }
338+ if ( minGain < 1.0 ) {
339+ console . log ( '[Limiter] Max gain reduction:' , ( 20 * Math . log10 ( minGain ) ) . toFixed ( 2 ) , 'dB' ) ;
340+ }
341+
342+ return outputBuffer ;
343+ }
344+
192345/**
193346 * Normalize an AudioBuffer to target LUFS by applying gain
194347 * Enforces true peak ceiling to prevent clipping
@@ -208,37 +361,43 @@ function normalizeToLUFS(audioBuffer, targetLUFS = -14, ceilingDB = -1) {
208361
209362 // Calculate gain needed to reach target LUFS
210363 const lufsGainDB = targetLUFS - currentLUFS ;
364+ const gainLinear = Math . pow ( 10 , lufsGainDB / 20 ) ;
211365
212- // Calculate maximum gain allowed before peaks hit ceiling
213- const maxGainDB = ceilingDB - currentPeakDB ;
366+ // Calculate what the peak will be after applying gain
367+ const projectedPeakDB = currentPeakDB + lufsGainDB ;
368+ const ceilingLinear = Math . pow ( 10 , ceilingDB / 20 ) ;
214369
215- // Use the smaller of the two gains to prevent clipping
216- const actualGainDB = Math . min ( lufsGainDB , maxGainDB ) ;
217- const gainLinear = Math . pow ( 10 , actualGainDB / 20 ) ;
370+ console . log ( '[LUFS] Applying gain:' , lufsGainDB . toFixed ( 2 ) , 'dB' ) ;
218371
219- if ( actualGainDB < lufsGainDB ) {
220- console . log ( '[LUFS] Gain limited by peak ceiling:' , actualGainDB . toFixed ( 2 ) , 'dB (wanted' , lufsGainDB . toFixed ( 2 ) , 'dB)' ) ;
221- console . log ( '[LUFS] Resulting LUFS will be:' , ( currentLUFS + actualGainDB ) . toFixed ( 2 ) , 'LUFS instead of' , targetLUFS , 'LUFS' ) ;
222- } else {
223- console . log ( '[LUFS] Applying gain:' , actualGainDB . toFixed ( 2 ) , 'dB' ) ;
224- }
225-
226- // Create buffer directly without OfflineAudioContext (more efficient for simple gain)
227- const normalizedBuffer = new AudioBuffer ( {
372+ // Create buffer with gain applied
373+ const gainedBuffer = new AudioBuffer ( {
228374 numberOfChannels : audioBuffer . numberOfChannels ,
229375 length : audioBuffer . length ,
230376 sampleRate : audioBuffer . sampleRate
231377 } ) ;
232378
233379 for ( let ch = 0 ; ch < audioBuffer . numberOfChannels ; ch ++ ) {
234380 const input = audioBuffer . getChannelData ( ch ) ;
235- const output = normalizedBuffer . getChannelData ( ch ) ;
381+ const output = gainedBuffer . getChannelData ( ch ) ;
236382 for ( let i = 0 ; i < input . length ; i ++ ) {
237383 output [ i ] = input [ i ] * gainLinear ;
238384 }
239385 }
240386
241- return normalizedBuffer ;
387+ // If peaks will exceed ceiling, apply lookahead limiter
388+ if ( projectedPeakDB > ceilingDB ) {
389+ console . log ( '[LUFS] Projected peak:' , projectedPeakDB . toFixed ( 2 ) , 'dBTP exceeds ceiling, applying limiter' ) ;
390+ const limitedBuffer = applyLookaheadLimiter ( gainedBuffer , ceilingLinear , 3 , 100 ) ;
391+
392+ // Verify final levels
393+ const finalPeakDB = findTruePeak ( limitedBuffer ) ;
394+ const finalLUFS = measureLUFS ( limitedBuffer ) ;
395+ console . log ( '[LUFS] After limiting - Peak:' , finalPeakDB . toFixed ( 2 ) , 'dBTP, LUFS:' , finalLUFS . toFixed ( 2 ) ) ;
396+
397+ return limitedBuffer ;
398+ }
399+
400+ return gainedBuffer ;
242401}
243402
244403// ============================================================================
0 commit comments