/** * Web Audio API integration module * Handles AudioContext, buffer processing, and stream management */ import { PitchWorklet, createPitchWorklet } from './worklet'; export interface AudioProcessorOptions { sampleRate?: number; frameSize?: number; hopSize?: number; useWorklet?: boolean; workletPath?: string; } /** * Manages Web Audio API context and audio processing */ export class AudioProcessor { private context: AudioContext | null = null; private analyser: AnalyserNode | null = null; private source: MediaStreamAudioSourceNode | null = null; private worklet: PitchWorklet | null = null; private readonly options: Required; constructor(options: AudioProcessorOptions = {}) { this.options = { sampleRate: options.sampleRate ?? 44100, frameSize: options.frameSize ?? 2048, hopSize: options.hopSize ?? 1024, useWorklet: options.useWorklet ?? false, workletPath: options.workletPath ?? '/src/pitch-worklet.js', }; } /** * Initialize AudioContext (must be called after user gesture in browsers) */ async initialize(): Promise { if (this.context) { return; } // Create AudioContext with specified sample rate this.context = new AudioContext({ sampleRate: this.options.sampleRate, }); // Resume context if it's suspended (browser autoplay policy) if (this.context.state === 'suspended') { await this.context.resume(); } // Initialize worklet if enabled if (this.options.useWorklet) { this.worklet = await createPitchWorklet(this.context, { frameSize: this.options.frameSize, hopSize: this.options.hopSize, workletPath: this.options.workletPath, }); } } /** * Get the current AudioContext */ getContext(): AudioContext { if (!this.context) { throw new Error('AudioProcessor not initialized. Call initialize() first.'); } return this.context; } /** * Extract audio frames from an AudioBuffer */ extractFrames(buffer: AudioBuffer): Float32Array[] { const frames: Float32Array[] = []; const channelData = buffer.getChannelData(0); // Use first channel (mono) const { frameSize, hopSize } = this.options; for (let offset = 0; offset + frameSize <= channelData.length; offset += hopSize) { const frame = new Float32Array(frameSize); frame.set(channelData.subarray(offset, offset + frameSize)); frames.push(frame); } return frames; } /** * Resample audio buffer to target sample rate if needed */ async resampleBuffer(buffer: AudioBuffer, targetSampleRate: number): Promise { if (buffer.sampleRate === targetSampleRate) { return buffer; } if (!this.context) { throw new Error('AudioProcessor not initialized'); } // Create offline context for resampling const offlineContext = new OfflineAudioContext( buffer.numberOfChannels, Math.ceil(buffer.duration * targetSampleRate), targetSampleRate ); const source = offlineContext.createBufferSource(); source.buffer = buffer; source.connect(offlineContext.destination); source.start(0); return await offlineContext.startRendering(); } /** * Create an AnalyserNode for real-time frequency analysis */ createAnalyser(): AnalyserNode { const context = this.getContext(); if (!this.analyser) { this.analyser = context.createAnalyser(); this.analyser.fftSize = this.options.frameSize; this.analyser.smoothingTimeConstant = 0; // No smoothing for pitch detection } return this.analyser; } /** * Connect a MediaStream to the audio processor */ async connectStream(stream: MediaStream): Promise { const context = this.getContext(); // Clean up existing source if (this.source) { this.source.disconnect(); } this.source = context.createMediaStreamSource(stream); return this.source; } /** * Process audio from MediaStream in real-time */ async *processStream(stream: MediaStream): AsyncGenerator { const context = this.getContext(); const source = await this.connectStream(stream); // Use worklet if available for better performance if (this.worklet) { this.worklet.connect(source); const frameQueue: Float32Array[] = []; let resolveNext: (() => void) | null = null; const unsubscribe = this.worklet.onFrame((frame) => { frameQueue.push(frame); if (resolveNext) { resolveNext(); resolveNext = null; } }); try { while (true) { if (frameQueue.length > 0) { yield frameQueue.shift()!; } else { await new Promise(resolve => { resolveNext = resolve; }); } } } finally { unsubscribe(); } } else { // Fallback to analyser-based processing const analyser = this.createAnalyser(); source.connect(analyser); const bufferSize = this.options.frameSize; const buffer = new Float32Array(bufferSize); // Use requestAnimationFrame for regular sampling while (true) { await new Promise(resolve => requestAnimationFrame(resolve)); // Get time-domain data analyser.getFloatTimeDomainData(buffer); // Yield a copy of the buffer yield new Float32Array(buffer); } } } /** * Load audio from URL */ async loadFromUrl(url: string): Promise { const context = this.getContext(); const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); return await context.decodeAudioData(arrayBuffer); } /** * Load audio from File */ async loadFromFile(file: File): Promise { const context = this.getContext(); const arrayBuffer = await file.arrayBuffer(); return await context.decodeAudioData(arrayBuffer); } /** * Convert mono channel data to AudioBuffer */ createBufferFromChannelData(channelData: Float32Array): AudioBuffer { const context = this.getContext(); const buffer = context.createBuffer( 1, // mono channelData.length, this.options.sampleRate ); // Use getChannelData and copy manually to avoid TypeScript typing issues const bufferData = buffer.getChannelData(0); bufferData.set(channelData); return buffer; } /** * Get current sample rate */ getSampleRate(): number { return this.context?.sampleRate ?? this.options.sampleRate; } /** * Get the worklet instance (if enabled) */ getWorklet(): PitchWorklet | null { return this.worklet; } /** * Clean up resources */ dispose(): void { if (this.worklet) { this.worklet.dispose(); this.worklet = null; } if (this.source) { this.source.disconnect(); this.source = null; } if (this.analyser) { this.analyser.disconnect(); this.analyser = null; } if (this.context) { this.context.close(); this.context = null; } } /** * Check if processor is initialized */ isInitialized(): boolean { return this.context !== null; } } /** * Utility: Mix stereo to mono */ export function mixToMono(leftChannel: Float32Array, rightChannel: Float32Array): Float32Array { const mono = new Float32Array(leftChannel.length); for (let i = 0; i < mono.length; i++) { mono[i] = (leftChannel[i] + rightChannel[i]) * 0.5; } return mono; } /** * Utility: Calculate RMS (Root Mean Square) of audio signal */ export function calculateRMS(samples: Float32Array): number { let sum = 0; for (let i = 0; i < samples.length; i++) { sum += samples[i] * samples[i]; } return Math.sqrt(sum / samples.length); } /** * Utility: Normalize audio to [-1, 1] range */ export function normalizeAudio(samples: Float32Array): Float32Array { const normalized = new Float32Array(samples.length); let max = 0; // Find peak amplitude for (let i = 0; i < samples.length; i++) { const abs = Math.abs(samples[i]); if (abs > max) { max = abs; } } // Normalize if (max > 0) { for (let i = 0; i < samples.length; i++) { normalized[i] = samples[i] / max; } } else { // Copy samples if max is 0 for (let i = 0; i < samples.length; i++) { normalized[i] = samples[i]; } } return normalized; } /** * Utility: Apply pre-emphasis filter (boosts high frequencies) */ export function applyPreEmphasis(samples: Float32Array, coefficient: number = 0.97): Float32Array { const filtered = new Float32Array(samples.length); filtered[0] = samples[0]; for (let i = 1; i < samples.length; i++) { filtered[i] = samples[i] - coefficient * samples[i - 1]; } return filtered; }