@@ -15,26 +15,46 @@ for (let i = 0; i < DITHER_BUFFER_SIZE; i++) {
1515 ditherBuffer [ i ] = ( Math . random ( ) + Math . random ( ) ) - 1 ;
1616}
1717let ditherIndex = 0 ;
18+ const NOISE_SHAPING_COEFF = 0.85 ;
1819
1920function 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 */
110144export 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 ) ;
0 commit comments