Skip to content

Commit d7c5442

Browse files
Merge pull request #4 from entrepeneur4lyf/fix/export-44100-resample-dither
Fix 44.1k export resampling and add 16-bit TPDF dithering
2 parents d5a438d + 30a1bd1 commit d7c5442

3 files changed

Lines changed: 75 additions & 13 deletions

File tree

web/app.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
// Renderer
4141
renderOffline,
4242
renderToAudioBuffer,
43+
resampleAudioBuffer,
4344
// Waveform
4445
initWaveSurfer,
4546
destroyWaveSurfer,
@@ -1418,12 +1419,24 @@ async function processAudio() {
14181419
throw new Error('Cancelled');
14191420
}
14201421

1421-
updateProgress(85, 'Encoding WAV...');
1422+
let exportBuffer = result.audioBuffer;
1423+
if (exportBuffer.sampleRate !== parsedSampleRate) {
1424+
updateProgress(83, `Resampling to ${parsedSampleRate / 1000}kHz...`);
1425+
let exportBuffer = result.audioBuffer;
1426+
if (exportBuffer.sampleRate !== parsedSampleRate) {
1427+
updateProgress(86, `Resampling to ${parsedSampleRate / 1000}kHz...`);
1428+
exportBuffer = await resampleAudioBuffer(exportBuffer, parsedSampleRate);
1429+
}
1430+
if (processingCancelled) {
1431+
throw new Error('Cancelled');
1432+
}
1433+
1434+
updateProgress(88, 'Encoding WAV...');
14221435
// Yield so the UI can repaint before encoding begins.
14231436
await new Promise(resolve => setTimeout(resolve, 0));
14241437

1425-
outputData = await encodeWAVAsync(result.audioBuffer, parsedSampleRate, parsedBitDepth, {
1426-
onProgress: (p) => updateProgress(Math.round(85 + p * 10), 'Encoding WAV...'),
1438+
outputData = await encodeWAVAsync(exportBuffer, parsedSampleRate, parsedBitDepth, {
1439+
onProgress: (p) => updateProgress(Math.round(88 + p * 10), 'Encoding WAV...'),
14271440
shouldCancel: () => processingCancelled
14281441
});
14291442
} catch (workerErr) {

web/ui/encoder.js

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import { applyFinalFilters } from '../lib/dsp/final-filters.js';
99
// WAV Encoding
1010
// ============================================================================
1111

12+
const DITHER_BUFFER_SIZE = 256;
13+
const ditherBuffer = new Float32Array(DITHER_BUFFER_SIZE);
14+
for (let i = 0; i < DITHER_BUFFER_SIZE; i++) {
15+
ditherBuffer[i] = (Math.random() + Math.random()) - 1;
16+
}
17+
let ditherIndex = 0;
18+
19+
function triangularDither() {
20+
// TPDF in integer (LSB) domain: [-1, 1]
21+
const dither = ditherBuffer[ditherIndex];
22+
ditherIndex = (ditherIndex + 1) % DITHER_BUFFER_SIZE;
23+
return dither;
24+
}
25+
1226
/**
1327
* Encode an AudioBuffer to WAV format (supports 16-bit and 24-bit)
1428
* @param {AudioBuffer} audioBuffer - Source audio buffer
@@ -18,8 +32,9 @@ import { applyFinalFilters } from '../lib/dsp/final-filters.js';
1832
*/
1933
export function encodeWAV(audioBuffer, targetSampleRate, bitDepth) {
2034
const numChannels = audioBuffer.numberOfChannels;
35+
const safeBitDepth = bitDepth === 24 ? 24 : 16;
2136
const sampleRate = targetSampleRate || audioBuffer.sampleRate;
22-
const bytesPerSample = bitDepth / 8;
37+
const bytesPerSample = safeBitDepth / 8;
2338

2439
const channelData = [];
2540
for (let ch = 0; ch < numChannels; ch++) {
@@ -47,22 +62,27 @@ export function encodeWAV(audioBuffer, targetSampleRate, bitDepth) {
4762
view.setUint32(24, sampleRate, true);
4863
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
4964
view.setUint16(32, numChannels * bytesPerSample, true);
50-
view.setUint16(34, bitDepth, true);
65+
view.setUint16(34, safeBitDepth, true);
5166
writeString(36, 'data');
5267
view.setUint32(40, dataSize, true);
5368

5469
let offset = 44;
55-
const maxVal = bitDepth === 16 ? 32767 : 8388607;
70+
const maxVal = safeBitDepth === 16 ? 32767 : 8388607;
5671

5772
for (let i = 0; i < numSamples; i++) {
5873
for (let ch = 0; ch < numChannels; ch++) {
5974
const sample = Math.max(-1, Math.min(1, channelData[ch][i]));
60-
const intSample = Math.round(sample * maxVal);
61-
62-
if (bitDepth === 16) {
63-
view.setInt16(offset, intSample, true);
75+
const scaled = sample * maxVal;
76+
// Dither is only needed for 16-bit export where quantization noise is audible.
77+
const intSample = safeBitDepth === 16
78+
? Math.round(scaled + triangularDither())
79+
: Math.round(scaled);
80+
81+
if (safeBitDepth === 16) {
82+
const clampedSample = Math.max(-32768, Math.min(32767, intSample));
83+
view.setInt16(offset, clampedSample, true);
6484
offset += 2;
65-
} else if (bitDepth === 24) {
85+
} else if (safeBitDepth === 24) {
6686
// Clamp to prevent overflow in bitwise operations
6787
const clampedSample = Math.max(-8388607, Math.min(8388607, intSample));
6888
view.setUint8(offset, clampedSample & 0xFF);
@@ -145,10 +165,15 @@ export async function encodeWAVAsync(audioBuffer, targetSampleRate, bitDepth, op
145165
for (let s = i; s < end; s++) {
146166
for (let ch = 0; ch < numChannels; ch++) {
147167
const sample = Math.max(-1, Math.min(1, channelData[ch][s]));
148-
const intSample = Math.round(sample * maxVal);
168+
const scaled = sample * maxVal;
169+
// Dither is only needed for 16-bit export where quantization noise is audible.
170+
const intSample = safeBitDepth === 16
171+
? Math.round(scaled + triangularDither())
172+
: Math.round(scaled);
149173

150174
if (safeBitDepth === 16) {
151-
view.setInt16(offset, intSample, true);
175+
const clampedSample = Math.max(-32768, Math.min(32767, intSample));
176+
view.setInt16(offset, clampedSample, true);
152177
offset += 2;
153178
} else {
154179
const clampedSample = Math.max(-8388607, Math.min(8388607, intSample));

web/ui/renderer.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,30 @@ function applyDSPChain(buffer, settings, onProgress = null, logPrefix = '[DSP]')
162162
return { buffer: renderedBuffer, measuredLufs };
163163
}
164164

165+
/**
166+
* Resample an AudioBuffer to a target sample rate.
167+
* Uses OfflineAudioContext so output sample data and WAV header stay aligned.
168+
* @param {AudioBuffer} sourceBuffer - Source audio buffer
169+
* @param {number} targetSampleRate - Target sample rate in Hz
170+
* @returns {Promise<AudioBuffer>} Resampled buffer (or original if unchanged)
171+
*/
172+
export async function resampleAudioBuffer(sourceBuffer, targetSampleRate) {
173+
if (!sourceBuffer || !targetSampleRate || sourceBuffer.sampleRate === targetSampleRate) {
174+
return sourceBuffer;
175+
}
176+
177+
const numChannels = sourceBuffer.numberOfChannels;
178+
const numSamples = Math.ceil(sourceBuffer.duration * targetSampleRate);
179+
const offlineCtx = new OfflineAudioContext(numChannels, numSamples, targetSampleRate);
180+
const source = offlineCtx.createBufferSource();
181+
182+
source.buffer = sourceBuffer;
183+
source.connect(offlineCtx.destination);
184+
source.start(0);
185+
186+
return offlineCtx.startRendering();
187+
}
188+
165189
// ============================================================================
166190
// Offline Rendering (Export)
167191
// ============================================================================

0 commit comments

Comments
 (0)