135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
|
|
/**
|
||
|
|
* Audio export utilities
|
||
|
|
* Supports WAV export with various bit depths
|
||
|
|
*/
|
||
|
|
|
||
|
|
export interface ExportOptions {
|
||
|
|
format: 'wav';
|
||
|
|
bitDepth: 16 | 24 | 32;
|
||
|
|
sampleRate?: number; // If different from source, will resample
|
||
|
|
normalize?: boolean; // Normalize to prevent clipping
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert an AudioBuffer to WAV file
|
||
|
|
*/
|
||
|
|
export function audioBufferToWav(
|
||
|
|
audioBuffer: AudioBuffer,
|
||
|
|
options: ExportOptions = { format: 'wav', bitDepth: 16 }
|
||
|
|
): ArrayBuffer {
|
||
|
|
const { bitDepth, 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);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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));
|
||
|
|
}
|
||
|
|
}
|