UNPKG

@siteed/expo-audio-studio

Version:

Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web

1,006 lines (891 loc) 37.3 kB
// packages/expo-audio-stream/src/WebRecorder.web.ts import { AudioAnalysis } from './AudioAnalysis/AudioAnalysis.types' import { ConsoleLike, RecordingConfig } from './ExpoAudioStream.types' import { EmitAudioAnalysisFunction, EmitAudioEventFunction, } from './ExpoAudioStream.web' import { encodingToBitDepth } from './utils/encodingToBitDepth' import { writeWavHeader } from './utils/writeWavHeader' import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web' import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web' interface AudioWorkletEvent { data: { command: string recordedData?: Float32Array sampleRate?: number position?: number message?: string // For debug messages } } interface AudioFeaturesEvent { data: { command: string result: AudioAnalysis } } const DEFAULT_WEB_BITDEPTH = 32 const DEFAULT_SEGMENT_DURATION_MS = 100 const DEFAULT_WEB_INTERVAL = 500 const DEFAULT_WEB_NUMBER_OF_CHANNELS = 1 const TAG = 'WebRecorder' export class WebRecorder { public audioContext: AudioContext private audioWorkletNode!: AudioWorkletNode private featureExtractorWorker?: Worker private source: MediaStreamAudioSourceNode private emitAudioEventCallback: EmitAudioEventFunction private emitAudioAnalysisCallback: EmitAudioAnalysisFunction private config: RecordingConfig private position: number = 0 private numberOfChannels: number // Number of audio channels private bitDepth: number // Bit depth of the audio private exportBitDepth: number // Bit depth of the audio private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events private readonly logger?: ConsoleLike private compressedMediaRecorder: MediaRecorder | null = null private compressedChunks: Blob[] = [] private compressedSize: number = 0 private pendingCompressedChunk: Blob | null = null private dataPointIdCounter: number = 0 // Add this property to track the counter private deviceDisconnectionHandler: (() => void) | null = null private readonly mediaStream: MediaStream | null = null private readonly onInterruptionCallback?: (event: { reason: string isPaused: boolean timestamp: number }) => void private _isDeviceDisconnected: boolean = false private pcmData: Float32Array | null = null // Store original PCM data private totalSampleCount: number = 0 /** * Flag to indicate whether this is the first audio chunk after a device switch * Used to maintain proper duration counting */ public isFirstChunkAfterSwitch: boolean = false /** * Gets whether the recording device has been disconnected */ get isDeviceDisconnected(): boolean { return this._isDeviceDisconnected } /** * Initializes a new WebRecorder instance for audio recording and processing * @param audioContext - The AudioContext to use for recording * @param source - The MediaStreamAudioSourceNode providing the audio input * @param recordingConfig - Configuration options for the recording * @param emitAudioEventCallback - Callback function for audio data events * @param emitAudioAnalysisCallback - Callback function for audio analysis events * @param onInterruption - Callback for recording interruptions * @param logger - Optional logger for debugging information */ constructor({ audioContext, source, recordingConfig, emitAudioEventCallback, emitAudioAnalysisCallback, onInterruption, logger, }: { audioContext: AudioContext source: MediaStreamAudioSourceNode recordingConfig: RecordingConfig emitAudioEventCallback: EmitAudioEventFunction emitAudioAnalysisCallback: EmitAudioAnalysisFunction onInterruption?: (event: { reason: string isPaused: boolean timestamp: number }) => void logger?: ConsoleLike }) { this.audioContext = audioContext this.source = source this.emitAudioEventCallback = emitAudioEventCallback this.emitAudioAnalysisCallback = emitAudioAnalysisCallback this.config = recordingConfig this.logger = logger const audioContextFormat = this.checkAudioContextFormat({ sampleRate: this.audioContext.sampleRate, }) this.logger?.debug('Initialized WebRecorder with config:', { sampleRate: audioContextFormat.sampleRate, bitDepth: audioContextFormat.bitDepth, numberOfChannels: audioContextFormat.numberOfChannels, }) this.bitDepth = audioContextFormat.bitDepth this.numberOfChannels = audioContextFormat.numberOfChannels || DEFAULT_WEB_NUMBER_OF_CHANNELS // Default to 1 if not available this.exportBitDepth = encodingToBitDepth({ encoding: recordingConfig.encoding ?? 'pcm_32bit', }) || audioContextFormat.bitDepth || DEFAULT_WEB_BITDEPTH this.audioAnalysisData = { amplitudeRange: { min: 0, max: 0 }, rmsRange: { min: 0, max: 0 }, dataPoints: [], durationMs: 0, samples: 0, bitDepth: this.bitDepth, numberOfChannels: this.numberOfChannels, sampleRate: this.config.sampleRate || this.audioContext.sampleRate, segmentDurationMs: this.config.segmentDurationMs ?? DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms segments extractionTimeMs: 0, } if (recordingConfig.enableProcessing) { this.initFeatureExtractorWorker() } // Initialize compressed recording if enabled if (recordingConfig.output?.compressed?.enabled) { this.initializeCompressedRecorder() } this.mediaStream = source.mediaStream this.onInterruptionCallback = onInterruption // Setup device disconnection detection this.setupDeviceDisconnectionDetection() } /** * Initializes the audio worklet using an inline script * Creates and connects the audio processing pipeline */ async init() { try { // Create and use inline audio worklet const blob = new Blob([InlineAudioWebWorker], { type: 'application/javascript', }) const url = URL.createObjectURL(blob) await this.audioContext.audioWorklet.addModule(url) this.audioWorkletNode = new AudioWorkletNode( this.audioContext, 'recorder-processor' ) this.audioWorkletNode.port.onmessage = async ( event: AudioWorkletEvent ) => { const command = event.data.command if (command === 'debug') { this.logger?.debug(`[AudioWorklet] ${event.data.message}`) return } if (command !== 'newData') return const pcmBufferFloat = event.data.recordedData if (!pcmBufferFloat) { this.logger?.warn('Received empty audio buffer', event) return } // Process data in smaller chunks and emit immediately const sampleRate = event.data.sampleRate ?? this.audioContext.sampleRate // Use chunk size from config interval or default to 2 seconds const intervalMs = this.config.interval ?? DEFAULT_WEB_INTERVAL const chunkSize = Math.floor(sampleRate * (intervalMs / 1000)) const duration = pcmBufferFloat.length / sampleRate // Use incoming position if provided by worklet, otherwise use our tracked position const incomingPosition = typeof event.data.position === 'number' ? event.data.position : this.position // Simple position tracking for logging (no duplicate filtering) this.logger?.debug( `Audio chunk: position=${incomingPosition.toFixed(3)}s, size=${pcmBufferFloat.length}` ) // Calculate bytes per sample based on bit depth const bytesPerSample = this.bitDepth / 8 // Emit chunks without storing them for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) { const chunk = pcmBufferFloat.slice(i, i + chunkSize) const chunkPosition = incomingPosition + i / sampleRate // Calculate byte positions and samples const startPosition = Math.floor(i * bytesPerSample) const endPosition = Math.floor( (i + chunk.length) * bytesPerSample ) const samples = chunk.length // Number of samples in this chunk // Only store PCM data if primary output is enabled const shouldStoreUncompressed = this.config.output?.primary?.enabled ?? true // Store PCM chunks when needed - this is for the final WAV file if (shouldStoreUncompressed) { // Store the original Float32Array data for later WAV creation this.appendPcmData(chunk) this.totalSampleCount += chunk.length } // Process features if enabled if ( this.config.enableProcessing && this.featureExtractorWorker ) { this.featureExtractorWorker.postMessage({ command: 'process', channelData: chunk, sampleRate, segmentDurationMs: this.config.segmentDurationMs ?? DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms bitDepth: this.bitDepth, fullAudioDurationMs: chunkPosition * 1000, numberOfChannels: this.numberOfChannels, features: this.config.features, intervalAnalysis: this.config.intervalAnalysis, startPosition, endPosition, samples, }) } // Prepare compression data if available const compression = this.pendingCompressedChunk ? { data: this.pendingCompressedChunk, size: this.pendingCompressedChunk.size, totalSize: this.compressedSize, mimeType: 'audio/webm', format: this.config.output?.compressed?.format ?? 'opus', bitrate: this.config.output?.compressed?.bitrate ?? 128000, } : undefined // Emit chunk immediately - whether compressed or not this.emitAudioEventCallback({ data: chunk, position: chunkPosition, compression, }) // Reset pending compressed chunk after we've used it this.pendingCompressedChunk = null } // Update our position based on the worklet's position if provided this.position = incomingPosition + duration } // Ensure we use all relevant settings from config const recordSampleRate = this.audioContext.sampleRate const exportSampleRate = this.config.sampleRate ?? this.audioContext.sampleRate const channels = this.config.channels ?? this.numberOfChannels const interval = this.config.interval ?? DEFAULT_WEB_INTERVAL this.logger?.debug(`WebRecorder initialized with config:`, { recordSampleRate, exportSampleRate, bitDepth: this.bitDepth, exportBitDepth: this.exportBitDepth, channels, interval, position: this.position, deviceId: this.config.deviceId ?? 'default', compression: this.config.output?.compressed ? { enabled: this.config.output.compressed.enabled, format: this.config.output.compressed.format, bitrate: this.config.output.compressed.bitrate, } : 'disabled', }) // Initialize the worklet with all settings from config this.audioWorkletNode.port.postMessage({ command: 'init', recordSampleRate, exportSampleRate, bitDepth: this.bitDepth, exportBitDepth: this.exportBitDepth, channels, interval, position: this.position, // Pass the current position to the processor enableLogging: true, }) // Connect the source to the AudioWorkletNode and start recording this.source.connect(this.audioWorkletNode) this.audioWorkletNode.connect(this.audioContext.destination) } catch (error) { console.error(`[${TAG}] Failed to initialize WebRecorder`, error) } } /** * Append new PCM data to the existing buffer * @param newData New Float32Array data to append */ private appendPcmData(newData: Float32Array): void { // Clone the incoming data to ensure it's not modified const dataToAdd = new Float32Array(newData) if (!this.pcmData) { // First chunk - create a copy to avoid references to original data this.pcmData = new Float32Array(dataToAdd) return } // Create a new buffer with increased size const newBuffer = new Float32Array( this.pcmData.length + dataToAdd.length ) // Copy existing data newBuffer.set(this.pcmData) // Append new data newBuffer.set(dataToAdd, this.pcmData.length) // Replace existing buffer this.pcmData = newBuffer } /** * Initializes the feature extractor worker for audio analysis * Creates an inline worker from a blob for audio feature extraction */ initFeatureExtractorWorker() { try { const blob = new Blob([InlineFeaturesExtractor], { type: 'application/javascript', }) const url = URL.createObjectURL(blob) this.featureExtractorWorker = new Worker(url) this.featureExtractorWorker.onmessage = this.handleFeatureExtractorMessage.bind(this) this.featureExtractorWorker.onerror = (error) => { console.error(`[${TAG}] Feature extractor worker error:`, error) } // Initialize worker with counter if needed if (this.dataPointIdCounter > 0) { this.featureExtractorWorker.postMessage({ command: 'resetCounter', value: this.dataPointIdCounter, }) this.logger?.debug( `Initialized worker with counter value ${this.dataPointIdCounter}` ) } this.logger?.log( 'Feature extractor worker initialized successfully' ) } catch (error) { console.error( `[${TAG}] Failed to initialize feature extractor worker`, error ) } } /** * Processes audio analysis results from the feature extractor worker * Updates the audio analysis data and emits events * @param event - The event containing audio analysis results */ handleFeatureExtractorMessage(event: AudioFeaturesEvent) { if (event.data.command !== 'features') return const segmentResult = event.data.result const uniqueNewDataPoints = this.filterUniqueDataPoints( segmentResult.dataPoints ) // Update counter based on the highest ID seen this.updateDataPointCounter(uniqueNewDataPoints) // Update analysis data with the new results this.updateAudioAnalysisData(segmentResult, uniqueNewDataPoints) // Send filtered result to avoid duplicate IDs const filteredSegmentResult = { ...segmentResult, dataPoints: uniqueNewDataPoints, } this.emitAudioAnalysisCallback(filteredSegmentResult) } /** * Filters out data points with duplicate IDs */ private filterUniqueDataPoints(dataPoints: any[]): any[] { // Track existing IDs to prevent duplicates const existingIds = new Set( this.audioAnalysisData.dataPoints.map((dp) => dp.id) ) // Filter out datapoints with duplicate IDs const uniquePoints = dataPoints.filter((dp) => !existingIds.has(dp.id)) // Log filtered duplicates if any if (uniquePoints.length < dataPoints.length && this.logger?.warn) { this.logger.warn( `Filtered ${dataPoints.length - uniquePoints.length} duplicate datapoints` ) } return uniquePoints } /** * Updates the counter based on the highest ID in datapoints */ private updateDataPointCounter(dataPoints: any[]): void { if (dataPoints.length === 0) return const lastDataPoint = dataPoints[dataPoints.length - 1] if (lastDataPoint && typeof lastDataPoint.id === 'number') { const nextIdValue = lastDataPoint.id + 1 if (nextIdValue > this.dataPointIdCounter) { this.dataPointIdCounter = nextIdValue this.logger?.debug( `Counter updated to ${this.dataPointIdCounter}` ) } } } /** * Updates audio analysis data with segment results */ private updateAudioAnalysisData( segmentResult: AudioAnalysis, uniqueDataPoints: any[] ): void { // Add unique data points to our analysis data this.audioAnalysisData.dataPoints.push(...uniqueDataPoints) this.audioAnalysisData.durationMs += segmentResult.durationMs this.audioAnalysisData.sampleRate = segmentResult.sampleRate // Update amplitude range if present if (segmentResult.amplitudeRange) { this.audioAnalysisData.amplitudeRange = this.mergeRange( this.audioAnalysisData.amplitudeRange, segmentResult.amplitudeRange ) } // Update RMS range if present if (segmentResult.rmsRange) { this.audioAnalysisData.rmsRange = this.mergeRange( this.audioAnalysisData.rmsRange, segmentResult.rmsRange ) } } /** * Merges value ranges */ private mergeRange( existing: { min: number; max: number } | undefined, newRange: { min: number; max: number } ): { min: number; max: number } { if (!existing) return { ...newRange } return { min: Math.min(existing.min, newRange.min), max: Math.max(existing.max, newRange.max), } } /** * Reset the data point counter to a specific value or zero * @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings) */ resetDataPointCounter(startCounterFrom?: number): void { // Set the counter with the passed value or 0 this.dataPointIdCounter = startCounterFrom ?? 0 this.logger?.debug( `Reset data point counter to ${this.dataPointIdCounter}` ) // Update worker counter if available if (this.featureExtractorWorker) { this.featureExtractorWorker.postMessage({ command: 'resetCounter', value: this.dataPointIdCounter, }) } else { this.logger?.warn( 'No feature extractor worker available to update counter' ) } } /** * Get the current data point counter value * @returns The current value of the data point counter */ getDataPointCounter(): number { return this.dataPointIdCounter } /** * Prepares the recorder for continuity after device switch * Sets up all necessary state to maintain proper recording continuity */ prepareForDeviceSwitch(): void { this.isFirstChunkAfterSwitch = true this.logger?.debug( `Prepared for device switch at position ${this.position}s` ) } /** * Starts the audio recording process * Connects the audio nodes and begins capturing audio data * @param preserveCounters If true, do not reset the counter (used for device switching) */ start(preserveCounters = false) { this.source.connect(this.audioWorkletNode) this.audioWorkletNode.connect(this.audioContext.destination) // Only reset the counter when not preserving state (e.g., for a fresh recording) if (!preserveCounters) { this.logger?.debug( 'Starting fresh recording, resetting counter to 0' ) this.resetDataPointCounter(0) // Explicitly reset to 0 for new recordings this.isFirstChunkAfterSwitch = false // Clear PCM data for new recording this.pcmData = null this.totalSampleCount = 0 } else { this.logger?.debug( `Preserving counter at ${this.dataPointIdCounter} during device switch` ) } if (this.compressedMediaRecorder) { this.compressedMediaRecorder.start(this.config.interval ?? 1000) } } /** * Creates a WAV file from the stored PCM data */ private createWavFromPcmData(): Blob | null { try { // Check if we have PCM data if (!this.pcmData || this.pcmData.length === 0) { this.logger?.warn('No PCM data available to create WAV file') return null } const sampleRate = this.config.sampleRate ?? this.audioContext.sampleRate const channels = this.numberOfChannels || 1 // Convert float32 PCM data to 16-bit PCM for WAV const bytesPerSample = 2 // 16-bit = 2 bytes const dataLength = this.pcmData.length * bytesPerSample const buffer = new ArrayBuffer(dataLength) const view = new DataView(buffer) // Convert Float32Array (-1 to 1) to Int16Array (-32768 to 32767) for (let i = 0; i < this.pcmData.length; i++) { const sample = Math.max(-1, Math.min(1, this.pcmData[i])) const int16Value = Math.round(sample * 32767) view.setInt16(i * 2, int16Value, true) } // Use the existing writeWavHeader utility to add a WAV header const wavBuffer = writeWavHeader({ buffer, sampleRate, numChannels: channels, bitDepth: 16, isFloat: false, }) return new Blob([wavBuffer], { type: 'audio/wav' }) } catch (error) { this.logger?.error('Error creating WAV file from PCM data:', error) return null } } /** * Stops the audio recording process and returns the recorded data * @returns Promise resolving to an object containing compressed and/or uncompressed blobs */ async stop(): Promise<{ compressedBlob?: Blob; uncompressedBlob?: Blob }> { try { // Stop any compressed recording first if ( this.compressedMediaRecorder && this.compressedMediaRecorder.state !== 'inactive' ) { this.compressedMediaRecorder.stop() } // Wait for any pending compressed chunks to be processed if (this.compressedMediaRecorder) { // Small delay to ensure all data is processed await new Promise((resolve) => setTimeout(resolve, 100)) } // Create uncompressed WAV file from the PCM data let uncompressedBlob: Blob | undefined // Only create WAV if we have PCM data if (this.pcmData && this.pcmData.length > 0) { uncompressedBlob = this.createWavFromPcmData() || undefined } // Return the compressed and/or uncompressed blobs if available return { compressedBlob: this.compressedChunks.length > 0 ? new Blob(this.compressedChunks, { type: 'audio/webm;codecs=opus', }) : undefined, uncompressedBlob, } } finally { this.cleanup() // Reset the chunks array this.compressedChunks = [] this.compressedSize = 0 this.pendingCompressedChunk = null this.pcmData = null this.totalSampleCount = 0 this.dataPointIdCounter = 0 // Reset counter } } /** * Cleans up resources when recording is stopped * Closes audio context and disconnects nodes */ public cleanup() { // Remove device disconnection handler if (this.deviceDisconnectionHandler) { this.deviceDisconnectionHandler() this.deviceDisconnectionHandler = null } // Check if AudioContext is already closed before attempting to close it if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close().catch((e) => { // Log closure errors but continue cleanup this.logger?.warn('Error closing AudioContext:', e) }) } // Safely disconnect audioWorkletNode if it exists if (this.audioWorkletNode) { try { this.audioWorkletNode.disconnect() } catch (e) { // Log disconnection errors but continue cleanup this.logger?.warn('Error disconnecting audioWorkletNode:', e) } } // Safely disconnect source if it exists if (this.source) { try { this.source.disconnect() } catch (e) { // Log disconnection errors but continue cleanup this.logger?.warn('Error disconnecting source:', e) } } // Always stop media stream tracks to release hardware resources this.stopMediaStreamTracks() // Mark as disconnected to prevent future errors this._isDeviceDisconnected = true } /** * Pauses the audio recording process * Disconnects audio nodes and pauses the media recorder */ pause() { try { // Note: We're just pausing, not disconnecting the device // Simply disconnect nodes temporarily without marking device as disconnected this.source.disconnect(this.audioWorkletNode) this.audioWorkletNode.disconnect(this.audioContext.destination) this.audioWorkletNode.port.postMessage({ command: 'pause' }) if (this.compressedMediaRecorder?.state === 'recording') { this.compressedMediaRecorder.pause() } this.logger?.debug('Recording paused successfully') } catch (error) { this.logger?.error('Error in pause(): ', error) // Already disconnected, just ignore and continue } } /** * Stops all media stream tracks to release hardware resources * Ensures recording indicators (like microphone icon) are turned off */ public stopMediaStreamTracks() { // Stop all audio tracks to stop the recording icon if (this.mediaStream) { const tracks = this.mediaStream.getTracks() tracks.forEach((track) => track.stop()) } else if (this.source?.mediaStream) { const tracks = this.source.mediaStream.getTracks() tracks.forEach((track) => track.stop()) } } /** * Determines the audio format capabilities of the current audio context * @param sampleRate - The sample rate to check * @returns Object containing format information (sample rate, bit depth, channels) */ private checkAudioContextFormat({ sampleRate }: { sampleRate: number }) { // Create a silent AudioBuffer const frameCount = sampleRate * 1.0 // 1 second buffer const audioBuffer = this.audioContext.createBuffer( 1, frameCount, sampleRate ) // Check the format const channelData = audioBuffer.getChannelData(0) const bitDepth = channelData.BYTES_PER_ELEMENT * 8 // 4 bytes per element means 32-bit return { sampleRate: audioBuffer.sampleRate, bitDepth, numberOfChannels: audioBuffer.numberOfChannels, } } /** * Resumes a paused recording * Reconnects audio nodes and resumes the media recorder */ resume() { // If device was disconnected, we can't resume if (this._isDeviceDisconnected) { this.logger?.warn('Cannot resume recording: device disconnected') return } try { this.source.connect(this.audioWorkletNode) this.audioWorkletNode.connect(this.audioContext.destination) this.audioWorkletNode.port.postMessage({ command: 'resume' }) this.compressedMediaRecorder?.resume() } catch (error: unknown) { this.logger?.error('Error in resume(): ', error) // Rethrow the error to inform callers throw new Error( `Failed to resume recording: ${error instanceof Error ? error.message : 'unknown error'}` ) } } /** * Initializes the compressed media recorder if compression is enabled * Sets up event handlers for compressed audio data */ private initializeCompressedRecorder() { try { const mimeType = 'audio/webm;codecs=opus' if (!MediaRecorder.isTypeSupported(mimeType)) { this.logger?.warn( 'Opus compression not supported in this browser' ) return } this.compressedMediaRecorder = new MediaRecorder( this.source.mediaStream, { mimeType, audioBitsPerSecond: this.config.output?.compressed?.bitrate ?? 128000, } ) this.compressedMediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { // Store the compressed chunk for final blob creation this.compressedChunks.push(event.data) this.compressedSize += event.data.size // Store the pending compressed chunk for the next PCM chunk to use this.pendingCompressedChunk = event.data } } } catch (error) { this.logger?.error( 'Failed to initialize compressed recorder:', error ) // Setting to null to indicate initialization failed this.compressedMediaRecorder = null } } /** * Processes features if enabled */ processFeatures( chunk: Float32Array, sampleRate: number, chunkPosition: number, startPosition: number, endPosition: number, samples: number ) { if (this.config.enableProcessing && this.featureExtractorWorker) { this.featureExtractorWorker.postMessage({ command: 'process', channelData: chunk, sampleRate, segmentDurationMs: this.config.segmentDurationMs ?? DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms bitDepth: this.bitDepth, fullAudioDurationMs: chunkPosition * 1000, numberOfChannels: this.numberOfChannels, features: this.config.features, intervalAnalysis: this.config.intervalAnalysis, startPosition, endPosition, samples, }) } } /** * Sets up detection for device disconnection events */ private setupDeviceDisconnectionDetection() { if (!this.mediaStream) return // Function to handle track ending (which happens on device disconnection) const handleTrackEnded = () => { this.logger?.warn('Audio track ended - device disconnected') this._isDeviceDisconnected = true // Use the callback to notify parent component about device disconnection if (this.onInterruptionCallback) { this.onInterruptionCallback({ reason: 'deviceDisconnected', isPaused: true, timestamp: Date.now(), }) this.logger?.debug('Notified about device disconnection') } // Ensure we disconnect nodes to prevent zombie recordings if (this.audioWorkletNode) { this.audioWorkletNode.port.postMessage({ command: 'deviceDisconnected', }) try { this.source.disconnect(this.audioWorkletNode) this.audioWorkletNode.disconnect() } catch (e) { // Ignore disconnection errors as the track might already be gone this.logger?.warn( 'Error disconnecting audioWorkletNode:', e ) } } } // Add listeners to all audio tracks const tracks = this.mediaStream.getAudioTracks() tracks.forEach((track) => { track.addEventListener('ended', handleTrackEnded) }) // Store the handler for cleanup this.deviceDisconnectionHandler = () => { tracks.forEach((track) => { track.removeEventListener('ended', handleTrackEnded) }) } } /** * Explicitly set the position for continuous recording across device switches * @param position The position in seconds to continue from */ setPosition(position: number): void { if (position >= 0) { this.position = position this.logger?.debug(`Position explicitly set to ${position} seconds`) } else { this.logger?.warn(`Invalid position value: ${position}, ignoring`) } } /** * Get the current position in seconds * @returns The current position */ getPosition(): number { return this.position } /** * Gets the current compressed chunks * @returns Array of current compressed audio chunks */ getCompressedChunks(): Blob[] { return [...this.compressedChunks] } /** * Sets the compressed chunks from a previous recorder * @param chunks Array of compressed chunks from a previous recorder */ setCompressedChunks(chunks: Blob[]): void { if (chunks && chunks.length > 0) { this.logger?.debug( `Adding ${chunks.length} compressed chunks from previous device` ) this.compressedChunks = [...chunks, ...this.compressedChunks] // Update size this.compressedSize = this.compressedChunks.reduce( (size, chunk) => size + chunk.size, 0 ) } } }