Skip to content

Commit 5d6f5ba

Browse files
Enforce -1 dBTP ceiling during LUFS normalization
- Add findTruePeak() function to measure peak level of audio buffer - Limit normalization gain to prevent peaks from exceeding -1 dBTP - If target LUFS would cause clipping, reduce gain to hit ceiling instead - Log when gain is limited and resulting LUFS level - Apply to both Electron and web versions This prevents audio from clipping when normalizing to loud targets like -6 or -9 LUFS on already-loud source material. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4a7550c commit 5d6f5ba

2 files changed

Lines changed: 86 additions & 10 deletions

File tree

src/renderer.js

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

171+
/**
172+
* Find the true peak of an AudioBuffer (maximum absolute sample value)
173+
* Returns peak in dBTP (decibels relative to full scale)
174+
*/
175+
function findTruePeak(audioBuffer) {
176+
let maxPeak = 0;
177+
178+
for (let ch = 0; ch < audioBuffer.numberOfChannels; ch++) {
179+
const channelData = audioBuffer.getChannelData(ch);
180+
for (let i = 0; i < channelData.length; i++) {
181+
const absSample = Math.abs(channelData[i]);
182+
if (absSample > maxPeak) {
183+
maxPeak = absSample;
184+
}
185+
}
186+
}
187+
188+
// Convert to dBTP (0 dBTP = 1.0 linear)
189+
return maxPeak > 0 ? 20 * Math.log10(maxPeak) : -Infinity;
190+
}
191+
171192
/**
172193
* Normalize an AudioBuffer to target LUFS by applying gain
194+
* Enforces true peak ceiling to prevent clipping
173195
* Uses AudioBuffer constructor directly (no OfflineAudioContext overhead)
174196
*/
175-
function normalizeToLUFS(audioBuffer, targetLUFS = -14) {
197+
function normalizeToLUFS(audioBuffer, targetLUFS = -14, ceilingDB = -1) {
176198
const currentLUFS = measureLUFS(audioBuffer);
177-
console.log('[LUFS] Current:', currentLUFS.toFixed(2), 'LUFS, Target:', targetLUFS, 'LUFS');
199+
const currentPeakDB = findTruePeak(audioBuffer);
200+
201+
console.log('[LUFS] Current:', currentLUFS.toFixed(2), 'LUFS, Peak:', currentPeakDB.toFixed(2), 'dBTP');
202+
console.log('[LUFS] Target:', targetLUFS, 'LUFS, Ceiling:', ceilingDB, 'dBTP');
178203

179204
if (!isFinite(currentLUFS)) {
180205
console.warn('[LUFS] Could not measure loudness, skipping normalization');
181206
return audioBuffer;
182207
}
183208

184-
const gainDB = targetLUFS - currentLUFS;
185-
const gainLinear = Math.pow(10, gainDB / 20);
186-
console.log('[LUFS] Applying gain:', gainDB.toFixed(2), 'dB');
209+
// Calculate gain needed to reach target LUFS
210+
const lufsGainDB = targetLUFS - currentLUFS;
211+
212+
// Calculate maximum gain allowed before peaks hit ceiling
213+
const maxGainDB = ceilingDB - currentPeakDB;
214+
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);
218+
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+
}
187225

188226
// Create buffer directly without OfflineAudioContext (more efficient for simple gain)
189227
const normalizedBuffer = new AudioBuffer({

web/app.js

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,22 +169,60 @@ function measureLUFS(audioBuffer) {
169169
return LUFS_CONSTANTS.LOUDNESS_OFFSET + 10 * Math.log10(gatedMean);
170170
}
171171

172+
/**
173+
* Find the true peak of an AudioBuffer (maximum absolute sample value)
174+
* Returns peak in dBTP (decibels relative to full scale)
175+
*/
176+
function findTruePeak(audioBuffer) {
177+
let maxPeak = 0;
178+
179+
for (let ch = 0; ch < audioBuffer.numberOfChannels; ch++) {
180+
const channelData = audioBuffer.getChannelData(ch);
181+
for (let i = 0; i < channelData.length; i++) {
182+
const absSample = Math.abs(channelData[i]);
183+
if (absSample > maxPeak) {
184+
maxPeak = absSample;
185+
}
186+
}
187+
}
188+
189+
// Convert to dBTP (0 dBTP = 1.0 linear)
190+
return maxPeak > 0 ? 20 * Math.log10(maxPeak) : -Infinity;
191+
}
192+
172193
/**
173194
* Normalize an AudioBuffer to target LUFS by applying gain
195+
* Enforces true peak ceiling to prevent clipping
174196
* Uses AudioBuffer constructor directly (no OfflineAudioContext overhead)
175197
*/
176-
function normalizeToLUFS(audioBuffer, targetLUFS = -14) {
198+
function normalizeToLUFS(audioBuffer, targetLUFS = -14, ceilingDB = -1) {
177199
const currentLUFS = measureLUFS(audioBuffer);
178-
console.log('[LUFS] Current:', currentLUFS.toFixed(2), 'LUFS, Target:', targetLUFS, 'LUFS');
200+
const currentPeakDB = findTruePeak(audioBuffer);
201+
202+
console.log('[LUFS] Current:', currentLUFS.toFixed(2), 'LUFS, Peak:', currentPeakDB.toFixed(2), 'dBTP');
203+
console.log('[LUFS] Target:', targetLUFS, 'LUFS, Ceiling:', ceilingDB, 'dBTP');
179204

180205
if (!isFinite(currentLUFS)) {
181206
console.warn('[LUFS] Could not measure loudness, skipping normalization');
182207
return audioBuffer;
183208
}
184209

185-
const gainDB = targetLUFS - currentLUFS;
186-
const gainLinear = Math.pow(10, gainDB / 20);
187-
console.log('[LUFS] Applying gain:', gainDB.toFixed(2), 'dB');
210+
// Calculate gain needed to reach target LUFS
211+
const lufsGainDB = targetLUFS - currentLUFS;
212+
213+
// Calculate maximum gain allowed before peaks hit ceiling
214+
const maxGainDB = ceilingDB - currentPeakDB;
215+
216+
// Use the smaller of the two gains to prevent clipping
217+
const actualGainDB = Math.min(lufsGainDB, maxGainDB);
218+
const gainLinear = Math.pow(10, actualGainDB / 20);
219+
220+
if (actualGainDB < lufsGainDB) {
221+
console.log('[LUFS] Gain limited by peak ceiling:', actualGainDB.toFixed(2), 'dB (wanted', lufsGainDB.toFixed(2), 'dB)');
222+
console.log('[LUFS] Resulting LUFS will be:', (currentLUFS + actualGainDB).toFixed(2), 'LUFS instead of', targetLUFS, 'LUFS');
223+
} else {
224+
console.log('[LUFS] Applying gain:', actualGainDB.toFixed(2), 'dB');
225+
}
188226

189227
// Create buffer directly without OfflineAudioContext (more efficient for simple gain)
190228
const normalizedBuffer = new AudioBuffer({

0 commit comments

Comments
 (0)