Skip to content

Commit 81e6628

Browse files
Add optional 16-bit noise-shaped dithering mode
1 parent 8097602 commit 81e6628

6 files changed

Lines changed: 101 additions & 19 deletions

File tree

web/app.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ const miniFormat = document.getElementById('mini-format');
158158
const normalizeLoudness = document.getElementById('normalizeLoudness');
159159
const sampleRate = document.getElementById('sampleRate');
160160
const bitDepth = document.getElementById('bitDepth');
161+
const ditherNoiseShaping = document.getElementById('ditherNoiseShaping');
162+
const ditherNoiseShapingRow = document.getElementById('ditherNoiseShapingRow');
161163
const targetLufsSlider = document.getElementById('targetLufs');
162164
const targetLufsValue = document.getElementById('targetLufsValue');
163165
const stereoWidthSlider = document.getElementById('stereoWidth');
@@ -192,6 +194,14 @@ if (spectroBtn && spectrogramContainer) {
192194
});
193195
}
194196

197+
function updateDitherControlState() {
198+
if (!bitDepth || !ditherNoiseShaping || !ditherNoiseShapingRow) return;
199+
200+
const is16Bit = (parseInt(bitDepth.value) || 16) === 16;
201+
ditherNoiseShaping.disabled = !is16Bit;
202+
ditherNoiseShapingRow.classList.toggle('disabled', !is16Bit);
203+
}
204+
195205
async function cleanupAudioContext() {
196206
// Stop spectrogram
197207
spectrogram.stop();
@@ -1372,6 +1382,9 @@ async function processAudio() {
13721382
addPunch: addPunch.checked,
13731383
sampleRate: parsedSampleRate,
13741384
bitDepth: parsedBitDepth,
1385+
ditherMode: parsedBitDepth === 16
1386+
? (ditherNoiseShaping?.checked ? 'noise-shaped' : 'tpdf')
1387+
: 'none',
13751388
inputGain: inputGainValue,
13761389
eqLow: eqValues.low,
13771390
eqLowMid: eqValues.lowMid,
@@ -1437,7 +1450,8 @@ async function processAudio() {
14371450

14381451
outputData = await encodeWAVAsync(exportBuffer, parsedSampleRate, parsedBitDepth, {
14391452
onProgress: (p) => updateProgress(Math.round(encodeProgressStart + p * encodeProgressSpan), 'Encoding WAV...'),
1440-
shouldCancel: () => processingCancelled
1453+
shouldCancel: () => processingCancelled,
1454+
ditherMode: settings.ditherMode
14411455
});
14421456
} catch (workerErr) {
14431457
if (processingCancelled || workerErr?.message === 'Cancelled') {
@@ -1656,6 +1670,7 @@ document.querySelectorAll('.output-preset-btn').forEach(btn => {
16561670
if (preset) {
16571671
sampleRate.value = preset.sampleRate;
16581672
bitDepth.value = preset.bitDepth;
1673+
updateDitherControlState();
16591674

16601675
document.querySelectorAll('.output-preset-btn').forEach(b => b.classList.remove('active'));
16611676
btn.classList.add('active');
@@ -1665,6 +1680,7 @@ document.querySelectorAll('.output-preset-btn').forEach(btn => {
16651680

16661681
[sampleRate, bitDepth].forEach(el => {
16671682
el.addEventListener('change', () => {
1683+
updateDitherControlState();
16681684
const currentRate = parseInt(sampleRate.value);
16691685
const currentDepth = parseInt(bitDepth.value);
16701686

@@ -1804,6 +1820,7 @@ setupEQPresets(eqPresets, updateEQ);
18041820

18051821
// Setup output format presets
18061822
setupOutputPresets(outputPresets);
1823+
updateDitherControlState();
18071824

18081825
updateChecklist();
18091826

web/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,12 @@ <h3>Output</h3>
258258
<option value="24" selected>24-bit</option>
259259
</select>
260260
</div>
261+
<label class="toggle-row" id="ditherNoiseShapingRow"
262+
data-tip="16-bit only: shapes dither noise toward higher frequencies for cleaner low-level detail.">
263+
<span>Noise-Shaped Dither</span>
264+
<input type="checkbox" id="ditherNoiseShaping">
265+
<span class="toggle"></span>
266+
</label>
261267
</div>
262268
</div>
263269

web/styles.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,11 @@ body {
621621
color: var(--text-primary);
622622
}
623623

624+
.toggle-row.disabled {
625+
opacity: 0.55;
626+
cursor: not-allowed;
627+
}
628+
624629
.toggle-row:last-child {
625630
border-bottom: none;
626631
}
@@ -1246,4 +1251,4 @@ body {
12461251
overflow: auto;
12471252
height: auto;
12481253
min-height: auto;
1249-
}
1254+
}

web/ui/controls.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const autoLevel = document.getElementById('autoLevel');
5353
const addPunch = document.getElementById('addPunch');
5454
const sampleRate = document.getElementById('sampleRate');
5555
const bitDepth = document.getElementById('bitDepth');
56+
const ditherNoiseShaping = document.getElementById('ditherNoiseShaping');
5657
const targetLufsSlider = document.getElementById('targetLufs');
5758
const targetLufsValueEl = document.getElementById('targetLufsValue');
5859

@@ -98,7 +99,10 @@ export function getExportSettings() {
9899
return {
99100
...base,
100101
sampleRate: parseInt(sampleRate.value) || 44100,
101-
bitDepth: parseInt(bitDepth.value) || 16
102+
bitDepth: parseInt(bitDepth.value) || 16,
103+
ditherMode: (parseInt(bitDepth.value) || 16) === 16
104+
? (ditherNoiseShaping?.checked ? 'noise-shaped' : 'tpdf')
105+
: 'none'
102106
};
103107
}
104108

web/ui/encoder.js

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,46 @@ for (let i = 0; i < DITHER_BUFFER_SIZE; i++) {
1515
ditherBuffer[i] = (Math.random() + Math.random()) - 1;
1616
}
1717
let ditherIndex = 0;
18+
const NOISE_SHAPING_COEFF = 0.85;
1819

1920
function triangularDither() {
2021
// TPDF in integer (LSB) domain: [-1, 1]
2122
const dither = ditherBuffer[ditherIndex];
22-
ditherIndex = (ditherIndex + 1) % DITHER_BUFFER_SIZE;
23+
ditherIndex += 1;
24+
if (ditherIndex >= DITHER_BUFFER_SIZE) {
25+
// Refresh to avoid repeating a short deterministic noise pattern.
26+
for (let i = 0; i < DITHER_BUFFER_SIZE; i++) {
27+
ditherBuffer[i] = (Math.random() + Math.random()) - 1;
28+
}
29+
ditherIndex = 0;
30+
}
2331
return dither;
2432
}
2533

34+
function clamp(value, min, max) {
35+
return Math.max(min, Math.min(max, value));
36+
}
37+
38+
function resolveDitherMode(requestedMode, safeBitDepth) {
39+
if (safeBitDepth !== 16) return 'none';
40+
return requestedMode === 'noise-shaped' ? 'noise-shaped' : 'tpdf';
41+
}
42+
2643
/**
2744
* Encode an AudioBuffer to WAV format (supports 16-bit and 24-bit)
2845
* @param {AudioBuffer} audioBuffer - Source audio buffer
2946
* @param {number} targetSampleRate - Target sample rate
3047
* @param {number} bitDepth - Bit depth (16 or 24)
48+
* @param {Object} [options]
49+
* @param {'tpdf'|'noise-shaped'|'none'} [options.ditherMode]
3150
* @returns {Uint8Array} WAV file data
3251
*/
33-
export function encodeWAV(audioBuffer, targetSampleRate, bitDepth) {
52+
export function encodeWAV(audioBuffer, targetSampleRate, bitDepth, options = {}) {
3453
const numChannels = audioBuffer.numberOfChannels;
3554
const safeBitDepth = bitDepth === 24 ? 24 : 16;
3655
const sampleRate = targetSampleRate || audioBuffer.sampleRate;
3756
const bytesPerSample = safeBitDepth / 8;
57+
const ditherMode = resolveDitherMode(options?.ditherMode, safeBitDepth);
3858

3959
const channelData = [];
4060
for (let ch = 0; ch < numChannels; ch++) {
@@ -68,23 +88,36 @@ export function encodeWAV(audioBuffer, targetSampleRate, bitDepth) {
6888

6989
let offset = 44;
7090
const maxVal = safeBitDepth === 16 ? 32767 : 8388607;
91+
const noiseShapeError = ditherMode === 'noise-shaped' ? new Float32Array(numChannels) : null;
7192

7293
for (let i = 0; i < numSamples; i++) {
7394
for (let ch = 0; ch < numChannels; ch++) {
7495
const sample = Math.max(-1, Math.min(1, channelData[ch][i]));
7596
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);
8097

8198
if (safeBitDepth === 16) {
82-
const clampedSample = Math.max(-32768, Math.min(32767, intSample));
99+
let quantizeInput = scaled;
100+
if (ditherMode === 'noise-shaped' && noiseShapeError) {
101+
quantizeInput += noiseShapeError[ch];
102+
}
103+
if (ditherMode !== 'none') {
104+
quantizeInput += triangularDither();
105+
}
106+
107+
const intSample = Math.round(quantizeInput);
108+
const clampedSample = clamp(intSample, -32768, 32767);
83109
view.setInt16(offset, clampedSample, true);
110+
111+
if (ditherMode === 'noise-shaped' && noiseShapeError) {
112+
const quantError = quantizeInput - clampedSample;
113+
noiseShapeError[ch] = clamp(quantError * NOISE_SHAPING_COEFF, -2, 2);
114+
}
115+
84116
offset += 2;
85117
} else if (safeBitDepth === 24) {
86118
// Clamp to prevent overflow in bitwise operations
87-
const clampedSample = Math.max(-8388607, Math.min(8388607, intSample));
119+
const intSample = Math.round(scaled);
120+
const clampedSample = clamp(intSample, -8388607, 8388607);
88121
view.setUint8(offset, clampedSample & 0xFF);
89122
view.setUint8(offset + 1, (clampedSample >> 8) & 0xFF);
90123
view.setUint8(offset + 2, (clampedSample >> 16) & 0xFF);
@@ -105,19 +138,22 @@ export function encodeWAV(audioBuffer, targetSampleRate, bitDepth) {
105138
* @param {(progress: number) => void} [options.onProgress] - Progress callback (0..1)
106139
* @param {() => boolean} [options.shouldCancel] - Return true to abort encoding
107140
* @param {number} [options.chunkSize] - Samples per chunk before yielding
141+
* @param {'tpdf'|'noise-shaped'|'none'} [options.ditherMode] - 16-bit quantization mode
108142
* @returns {Promise<Uint8Array>}
109143
*/
110144
export async function encodeWAVAsync(audioBuffer, targetSampleRate, bitDepth, options = {}) {
111145
const {
112146
onProgress = null,
113147
shouldCancel = null,
114-
chunkSize = 65536
148+
chunkSize = 65536,
149+
ditherMode: requestedDitherMode = 'tpdf'
115150
} = options || {};
116151

117152
const safeBitDepth = bitDepth === 24 ? 24 : 16;
118153
const numChannels = audioBuffer.numberOfChannels;
119154
const sampleRate = targetSampleRate || audioBuffer.sampleRate;
120155
const bytesPerSample = safeBitDepth / 8;
156+
const ditherMode = resolveDitherMode(requestedDitherMode, safeBitDepth);
121157

122158
const channelData = [];
123159
for (let ch = 0; ch < numChannels; ch++) {
@@ -152,6 +188,7 @@ export async function encodeWAVAsync(audioBuffer, targetSampleRate, bitDepth, op
152188
const maxVal = safeBitDepth === 16 ? 32767 : 8388607;
153189
let offset = 44;
154190
const safeChunkSize = Math.max(1024, Number(chunkSize) || 65536);
191+
const noiseShapeError = ditherMode === 'noise-shaped' ? new Float32Array(numChannels) : null;
155192

156193
const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0));
157194

@@ -166,17 +203,29 @@ export async function encodeWAVAsync(audioBuffer, targetSampleRate, bitDepth, op
166203
for (let ch = 0; ch < numChannels; ch++) {
167204
const sample = Math.max(-1, Math.min(1, channelData[ch][s]));
168205
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);
173206

174207
if (safeBitDepth === 16) {
175-
const clampedSample = Math.max(-32768, Math.min(32767, intSample));
208+
let quantizeInput = scaled;
209+
if (ditherMode === 'noise-shaped' && noiseShapeError) {
210+
quantizeInput += noiseShapeError[ch];
211+
}
212+
if (ditherMode !== 'none') {
213+
quantizeInput += triangularDither();
214+
}
215+
216+
const intSample = Math.round(quantizeInput);
217+
const clampedSample = clamp(intSample, -32768, 32767);
176218
view.setInt16(offset, clampedSample, true);
219+
220+
if (ditherMode === 'noise-shaped' && noiseShapeError) {
221+
const quantError = quantizeInput - clampedSample;
222+
noiseShapeError[ch] = clamp(quantError * NOISE_SHAPING_COEFF, -2, 2);
223+
}
224+
177225
offset += 2;
178226
} else {
179-
const clampedSample = Math.max(-8388607, Math.min(8388607, intSample));
227+
const intSample = Math.round(scaled);
228+
const clampedSample = clamp(intSample, -8388607, 8388607);
180229
view.setUint8(offset, clampedSample & 0xFF);
181230
view.setUint8(offset + 1, (clampedSample >> 8) & 0xFF);
182231
view.setUint8(offset + 2, (clampedSample >> 16) & 0xFF);

web/ui/renderer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ export async function renderOffline(sourceBuffer, settings, onProgress) {
249249
const wavData = await encodeWAVAsync(renderedBuffer, targetSampleRate, settings.bitDepth || 16, {
250250
onProgress: (p) => {
251251
if (onProgress) onProgress(75 + p * 15);
252-
}
252+
},
253+
ditherMode: settings.ditherMode
253254
});
254255
if (onProgress) onProgress(90);
255256

0 commit comments

Comments
 (0)