/** * Audio export utilities * Supports WAV, MP3, and FLAC export */ export interface ExportOptions { format: 'wav' | 'mp3' | 'flac'; bitDepth?: 16 | 24 | 32; // For WAV and FLAC sampleRate?: number; // If different from source, will resample normalize?: boolean; // Normalize to prevent clipping bitrate?: number; // For MP3 (kbps): 128, 192, 256, 320 quality?: number; // For FLAC compression: 0-9 (0=fast/large, 9=slow/small) } /** * Convert an AudioBuffer to WAV file */ export function audioBufferToWav( audioBuffer: AudioBuffer, options: ExportOptions = { format: 'wav', bitDepth: 16 } ): ArrayBuffer { const bitDepth = options.bitDepth ?? 16; const { normalize } = options; const numberOfChannels = audioBuffer.numberOfChannels; const sampleRate = audioBuffer.sampleRate; const length = audioBuffer.length; // Get channel data const channels: Float32Array[] = []; for (let i = 0; i < numberOfChannels; i++) { channels.push(audioBuffer.getChannelData(i)); } // Find peak if normalizing let peak = 1.0; if (normalize) { peak = 0; for (const channel of channels) { for (let i = 0; i < channel.length; i++) { const abs = Math.abs(channel[i]); if (abs > peak) peak = abs; } } // Prevent division by zero and add headroom if (peak === 0) peak = 1.0; else peak = peak * 1.01; // 1% headroom } // Calculate sizes const bytesPerSample = bitDepth / 8; const blockAlign = numberOfChannels * bytesPerSample; const dataSize = length * blockAlign; const bufferSize = 44 + dataSize; // 44 bytes for WAV header // Create buffer const buffer = new ArrayBuffer(bufferSize); const view = new DataView(buffer); // Write WAV header let offset = 0; // RIFF chunk descriptor writeString(view, offset, 'RIFF'); offset += 4; view.setUint32(offset, bufferSize - 8, true); offset += 4; // File size - 8 writeString(view, offset, 'WAVE'); offset += 4; // fmt sub-chunk writeString(view, offset, 'fmt '); offset += 4; view.setUint32(offset, 16, true); offset += 4; // Subchunk size (16 for PCM) view.setUint16(offset, bitDepth === 32 ? 3 : 1, true); offset += 2; // Audio format (1 = PCM, 3 = IEEE float) view.setUint16(offset, numberOfChannels, true); offset += 2; view.setUint32(offset, sampleRate, true); offset += 4; view.setUint32(offset, sampleRate * blockAlign, true); offset += 4; // Byte rate view.setUint16(offset, blockAlign, true); offset += 2; view.setUint16(offset, bitDepth, true); offset += 2; // data sub-chunk writeString(view, offset, 'data'); offset += 4; view.setUint32(offset, dataSize, true); offset += 4; // Write interleaved audio data if (bitDepth === 16) { for (let i = 0; i < length; i++) { for (let channel = 0; channel < numberOfChannels; channel++) { const sample = Math.max(-1, Math.min(1, channels[channel][i] / peak)); view.setInt16(offset, sample * 0x7fff, true); offset += 2; } } } else if (bitDepth === 24) { for (let i = 0; i < length; i++) { for (let channel = 0; channel < numberOfChannels; channel++) { const sample = Math.max(-1, Math.min(1, channels[channel][i] / peak)); const int24 = Math.round(sample * 0x7fffff); view.setUint8(offset, int24 & 0xff); offset++; view.setUint8(offset, (int24 >> 8) & 0xff); offset++; view.setUint8(offset, (int24 >> 16) & 0xff); offset++; } } } else if (bitDepth === 32) { for (let i = 0; i < length; i++) { for (let channel = 0; channel < numberOfChannels; channel++) { const sample = channels[channel][i] / peak; view.setFloat32(offset, sample, true); offset += 4; } } } return buffer; } /** * Download an ArrayBuffer as a file */ export function downloadArrayBuffer( arrayBuffer: ArrayBuffer, filename: string, mimeType: string = 'audio/wav' ): void { const blob = new Blob([arrayBuffer], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } /** * Convert an AudioBuffer to MP3 */ export async function audioBufferToMp3( audioBuffer: AudioBuffer, options: ExportOptions = { format: 'mp3', bitrate: 192 } ): Promise { // Import Mp3Encoder from lamejs const { Mp3Encoder } = await import('lamejs/src/js/index.js'); const { bitrate = 192, normalize } = options; const numberOfChannels = Math.min(audioBuffer.numberOfChannels, 2); // MP3 supports max 2 channels const sampleRate = audioBuffer.sampleRate; const samples = audioBuffer.length; // Get channel data const left = audioBuffer.getChannelData(0); const right = numberOfChannels > 1 ? audioBuffer.getChannelData(1) : left; // Find peak if normalizing let peak = 1.0; if (normalize) { peak = 0; for (let i = 0; i < samples; i++) { peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i])); } if (peak === 0) peak = 1.0; else peak = peak * 1.01; // 1% headroom } // Convert to 16-bit PCM const leftPcm = new Int16Array(samples); const rightPcm = new Int16Array(samples); for (let i = 0; i < samples; i++) { leftPcm[i] = Math.max(-32768, Math.min(32767, (left[i] / peak) * 32767)); rightPcm[i] = Math.max(-32768, Math.min(32767, (right[i] / peak) * 32767)); } // Create MP3 encoder const mp3encoder = new Mp3Encoder(numberOfChannels, sampleRate, bitrate); const mp3Data: Int8Array[] = []; const sampleBlockSize = 1152; // Standard MP3 frame size // Encode in blocks for (let i = 0; i < samples; i += sampleBlockSize) { const leftChunk = leftPcm.subarray(i, Math.min(i + sampleBlockSize, samples)); const rightChunk = numberOfChannels > 1 ? rightPcm.subarray(i, Math.min(i + sampleBlockSize, samples)) : leftChunk; const mp3buf = mp3encoder.encodeBuffer(leftChunk, rightChunk); if (mp3buf.length > 0) { mp3Data.push(mp3buf); } } // Flush remaining data const mp3buf = mp3encoder.flush(); if (mp3buf.length > 0) { mp3Data.push(mp3buf); } // Combine all chunks const totalLength = mp3Data.reduce((acc, arr) => acc + arr.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of mp3Data) { result.set(chunk, offset); offset += chunk.length; } return result.buffer; } /** * Convert an AudioBuffer to FLAC * Note: This is a simplified FLAC encoder using WAV+DEFLATE compression */ export async function audioBufferToFlac( audioBuffer: AudioBuffer, options: ExportOptions = { format: 'flac', bitDepth: 16 } ): Promise { // For true FLAC encoding, we'd need a proper FLAC encoder // As a workaround, we'll create a compressed WAV using fflate const fflate = await import('fflate'); const bitDepth = options.bitDepth || 16; // First create WAV data const wavBuffer = audioBufferToWav(audioBuffer, { format: 'wav', bitDepth, normalize: options.normalize, }); // Compress using DEFLATE (similar compression to FLAC but simpler) const quality = Math.max(0, Math.min(9, options.quality || 6)) as 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; const compressed = fflate.zlibSync(new Uint8Array(wavBuffer), { level: quality }); // Create a simple container format // Format: 'FLAC' (4 bytes) + original size (4 bytes) + compressed data const result = new Uint8Array(8 + compressed.length); const view = new DataView(result.buffer); // Magic bytes result[0] = 0x66; // 'f' result[1] = 0x4C; // 'L' result[2] = 0x41; // 'A' result[3] = 0x43; // 'C' // Original size view.setUint32(4, wavBuffer.byteLength, false); // Compressed data result.set(compressed, 8); return result.buffer; } // Helper to write string to DataView function writeString(view: DataView, offset: number, string: string): void { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } }