Switched from npm package to GitHub repo (github:zhuker/lamejs) which includes the proper browser build. Reverted to simple dynamic import. Fixes MP3 export MPEGMode reference error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
/**
|
|
* 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<ArrayBuffer> {
|
|
// Dynamically import lamejs from GitHub repo (has proper browser build)
|
|
const lamejs = await import('lamejs');
|
|
|
|
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));
|
|
}
|
|
|
|
// Encode
|
|
const mp3encoder = new lamejs.Mp3Encoder(numberOfChannels, sampleRate, bitrate);
|
|
const mp3Data: Int8Array[] = [];
|
|
const sampleBlockSize = 1152; // Standard MP3 frame size
|
|
|
|
for (let i = 0; i < samples; i += sampleBlockSize) {
|
|
const leftChunk = leftPcm.subarray(i, i + sampleBlockSize);
|
|
const rightChunk = numberOfChannels > 1 ? rightPcm.subarray(i, i + sampleBlockSize) : 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<ArrayBuffer> {
|
|
// 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));
|
|
}
|
|
}
|