Skip to content

Commit 5efad97

Browse files
Add look-ahead limiter for transparent LUFS normalization
- Add calculateTruePeakSample() with 4x oversampling using Catmull-Rom interpolation (ITU-R BS.1770-4) - Update findTruePeak() to use 4x oversampled true peak detection - Add applyLookaheadLimiter() with smooth gain envelope (3ms lookahead, 100ms release) - Update normalizeToLUFS() to apply full LUFS gain then limit, enabling loud targets (-9 LUFS) without clipping This allows achieving -9 LUFS at -1 dBTP even when source is at -14 LUFS and -1 dBTP. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5d6f5ba commit 5efad97

2 files changed

Lines changed: 359 additions & 40 deletions

File tree

src/renderer.js

Lines changed: 179 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
175207
function 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

Comments
 (0)