/** * Create WAV buffer from audio data */ export function createWAVBuffer(audioData: Float32Array, sampleRate: number): ArrayBuffer { const length = audioData.length const buffer = new ArrayBuffer(44 + length * 2) const view = new DataView(buffer) // WAV header writeString(view, 0, 'RIFF') view.setUint32(4, 36 + length * 2, true) // file length - 8 writeString(view, 8, 'WAVE') writeString(view, 12, 'fmt ') view.setUint32(16, 16, true) // format chunk length view.setUint16(20, 1, true) // PCM format view.setUint16(22, 1, true) // mono view.setUint32(24, sampleRate, true) view.setUint32(28, sampleRate * 2, true) // byte rate view.setUint16(32, 2, true) // block align view.setUint16(34, 16, true) // bits per sample writeString(view, 36, 'data') view.setUint32(40, length * 2, true) // data chunk length // Convert float samples to 16-bit PCM let offset = 44 for (let i = 0; i < length; i++) { const sample = Math.max(-1, Math.min(1, audioData[i])) view.setInt16(offset, sample * 0x7FFF, true) offset += 2 } return buffer } function writeString(view: DataView, offset: number, string: string) { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)) } } /** * Download audio as WAV file */ export function downloadWAV(audioData: Float32Array, sampleRate: number, filename: string) { const buffer = createWAVBuffer(audioData, sampleRate) const blob = new Blob([buffer], { type: 'audio/wav' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.click() URL.revokeObjectURL(url) } export interface AudioPlayer { play(): void pause(): void stop(): void setVolume(volume: number): void isPlaying(): boolean onStateChange(callback: (isPlaying: boolean) => void): void } /** * Create an audio player with playback controls */ export function createAudioPlayer(audioData: Float32Array, sampleRate: number): AudioPlayer { let audioContext: AudioContext | null = null let source: AudioBufferSourceNode | null = null let gainNode: GainNode | null = null let isCurrentlyPlaying = false let isPaused = false let pausedAt = 0 let startedAt = 0 let stateCallback: ((isPlaying: boolean) => void) | null = null const initAudioContext = () => { if (!audioContext) { audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() gainNode = audioContext.createGain() gainNode.connect(audioContext.destination) if (audioContext.sampleRate !== sampleRate) { console.warn(`Audio context sample rate (${audioContext.sampleRate}) differs from data sample rate (${sampleRate})`) } } } const updateState = (playing: boolean) => { isCurrentlyPlaying = playing if (stateCallback) { stateCallback(playing) } } return { play() { initAudioContext() if (!audioContext || !gainNode) return if (isPaused) { // Resume from pause is not supported with AudioBufferSource // We need to restart from the beginning isPaused = false pausedAt = 0 } if (source) { source.stop() } const buffer = audioContext.createBuffer(1, audioData.length, sampleRate) buffer.copyToChannel(audioData, 0) source = audioContext.createBufferSource() source.buffer = buffer source.connect(gainNode) source.onended = () => { updateState(false) isPaused = false pausedAt = 0 startedAt = 0 } source.start() startedAt = audioContext.currentTime updateState(true) }, pause() { if (source && isCurrentlyPlaying) { source.stop() source = null isPaused = true pausedAt = audioContext ? audioContext.currentTime - startedAt : 0 updateState(false) } }, stop() { if (source) { source.stop() source = null } isPaused = false pausedAt = 0 startedAt = 0 updateState(false) }, setVolume(volume: number) { if (gainNode) { gainNode.gain.value = Math.max(0, Math.min(1, volume)) } }, isPlaying() { return isCurrentlyPlaying }, onStateChange(callback: (isPlaying: boolean) => void) { stateCallback = callback } } } /** * Play audio in browser (legacy function for backward compatibility) */ export async function playAudio(audioData: Float32Array, sampleRate: number): Promise { const player = createAudioPlayer(audioData, sampleRate) return new Promise(resolve => { player.onStateChange((isPlaying) => { if (!isPlaying) { resolve() } }) player.play() }) }