// src/useAudioRecorder.ts import { EventSubscription, Platform } from 'expo-modules-core' import { useCallback, useEffect, useReducer, useRef, useId } from 'react' import { AudioAnalysis } from './AudioAnalysis/AudioAnalysis.types' import { audioDeviceManager } from './AudioDeviceManager' import { AudioDataEvent, AudioRecording, AudioStreamStatus, CompressionInfo, ConsoleLike, RecordingConfig, StartRecordingResult, } from './ExpoAudioStream.types' import ExpoAudioStreamModule from './ExpoAudioStreamModule' import { addAudioAnalysisListener, addAudioEventListener, AudioEventPayload, addRecordingInterruptionListener, } from './events' export interface UseAudioRecorderProps { logger?: ConsoleLike audioWorkletUrl?: string featuresExtratorUrl?: string } export interface UseAudioRecorderState { prepareRecording: (_: RecordingConfig) => Promise startRecording: (_: RecordingConfig) => Promise stopRecording: () => Promise pauseRecording: () => Promise resumeRecording: () => Promise isRecording: boolean isPaused: boolean durationMs: number size: number compression?: CompressionInfo analysisData?: AudioAnalysis } interface RecorderReducerState { isRecording: boolean isPaused: boolean durationMs: number size: number compression?: CompressionInfo analysisData?: AudioAnalysis } type RecorderAction = | { type: 'START' | 'STOP' | 'PAUSE' | 'RESUME' } | { type: 'UPDATE_RECORDING_STATE' payload: { isRecording: boolean isPaused: boolean } } | { type: 'UPDATE_STATUS' payload: { durationMs: number size: number compression?: CompressionInfo } } | { type: 'UPDATE_ANALYSIS'; payload: AudioAnalysis } const defaultAnalysis: AudioAnalysis = { segmentDurationMs: 100, bitDepth: 32, numberOfChannels: 1, durationMs: 0, sampleRate: 44100, samples: 0, dataPoints: [], rmsRange: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }, amplitudeRange: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }, extractionTimeMs: 0, } function audioRecorderReducer( state: RecorderReducerState, action: RecorderAction ): RecorderReducerState { switch (action.type) { case 'START': return { ...state, isRecording: true, isPaused: false, durationMs: 0, size: 0, compression: undefined, analysisData: defaultAnalysis, } case 'STOP': return { ...state, isRecording: false, isPaused: false, durationMs: 0, size: 0, compression: undefined, analysisData: undefined, } case 'PAUSE': return { ...state, isPaused: true, isRecording: false } case 'RESUME': return { ...state, isPaused: false, isRecording: true } case 'UPDATE_RECORDING_STATE': return { ...state, isPaused: action.payload.isPaused, isRecording: action.payload.isRecording, } case 'UPDATE_STATUS': { const newState = { ...state, durationMs: action.payload.durationMs, size: action.payload.size, compression: action.payload.compression ? { size: action.payload.compression.size, mimeType: action.payload.compression.mimeType, bitrate: action.payload.compression.bitrate, format: action.payload.compression.format, } : undefined, } return newState } case 'UPDATE_ANALYSIS': return { ...state, analysisData: action.payload, } default: return state } } interface HandleAudioAnalysisProps { analysis: AudioAnalysis visualizationDuration: number } export function useAudioRecorder({ logger, audioWorkletUrl, featuresExtratorUrl, }: UseAudioRecorderProps = {}): UseAudioRecorderState { // Initialize AudioDeviceManager with logger (once) if (logger) { audioDeviceManager.setLogger(logger) } const [state, dispatch] = useReducer(audioRecorderReducer, { isRecording: false, isPaused: false, durationMs: 0, size: 0, compression: undefined, analysisData: undefined, }) const startResultRef = useRef(null) const analysisListenerRef = useRef(null) // analysisRef is the current analysis data (last 10 seconds by default) const analysisRef = useRef({ ...defaultAnalysis }) // fullAnalysisRef is the full analysis data (all data points) const fullAnalysisRef = useRef({ ...defaultAnalysis, }) // Instantiate the module for web with URLs const ExpoAudioStream = Platform.OS === 'web' ? ExpoAudioStreamModule({ audioWorkletUrl, featuresExtratorUrl, logger, }) : ExpoAudioStreamModule const onAudioStreamRef = useRef< ((_: AudioDataEvent) => Promise) | null >(null) const stateRef = useRef({ isRecording: false, isPaused: false, durationMs: 0, size: 0, compression: undefined as CompressionInfo | undefined, }) const recordingConfigRef = useRef(null) // Generate unique instance ID for debugging const instanceId = useId().replace(/:/g, '').slice(0, 5) const handleAudioAnalysis = useCallback( async ({ analysis, visualizationDuration, }: HandleAudioAnalysisProps) => { const savedAnalysisData = analysisRef.current || { ...defaultAnalysis, } const maxDuration = visualizationDuration logger?.debug( `[handleAudioAnalysis] Received audio analysis: maxDuration=${maxDuration} analysis.dataPoints=${analysis.dataPoints.length} analysisData.dataPoints=${savedAnalysisData.dataPoints.length}` ) // Combine data points const combinedDataPoints = [ ...savedAnalysisData.dataPoints, ...analysis.dataPoints, ] const fullCombinedDataPoints = [ ...(fullAnalysisRef.current?.dataPoints ?? []), ...analysis.dataPoints, ] // Calculate the new duration // The number of segments is based on how many segments of segmentDurationMs can fit in visualizationDuration const numberOfSegments = Math.ceil( visualizationDuration / analysis.segmentDurationMs ) // maxDataPoints should be the number of data points, not milliseconds const maxDataPoints = numberOfSegments logger?.debug( `[handleAudioAnalysis] Combined data points before trimming: numberOfSegments=${numberOfSegments} visualizationDuration=${visualizationDuration} combinedDataPointsLength=${combinedDataPoints.length} vs maxDataPoints=${maxDataPoints}` ) // Trim data points to keep within the maximum number of data points if (combinedDataPoints.length > maxDataPoints) { combinedDataPoints.splice( 0, combinedDataPoints.length - maxDataPoints ) } // Keep the full data points fullAnalysisRef.current = { ...fullAnalysisRef.current, dataPoints: fullCombinedDataPoints, } fullAnalysisRef.current.durationMs = fullCombinedDataPoints.length * analysis.segmentDurationMs savedAnalysisData.dataPoints = combinedDataPoints savedAnalysisData.bitDepth = analysis.bitDepth || savedAnalysisData.bitDepth savedAnalysisData.durationMs = combinedDataPoints.length * analysis.segmentDurationMs // Update amplitude range const newMin = Math.min( savedAnalysisData.amplitudeRange.min, analysis.amplitudeRange.min ) const newMax = Math.max( savedAnalysisData.amplitudeRange.max, analysis.amplitudeRange.max ) savedAnalysisData.amplitudeRange = { min: newMin, max: newMax, } fullAnalysisRef.current.amplitudeRange = { min: newMin, max: newMax, } logger?.debug( `[handleAudioAnalysis] Updated analysis data: durationMs=${savedAnalysisData.durationMs}`, { dataPoints: savedAnalysisData.dataPoints.length } ) // Call the onAudioAnalysis callback if it exists in the recording config if (recordingConfigRef.current?.onAudioAnalysis) { recordingConfigRef.current .onAudioAnalysis(analysis) .catch((error) => { logger?.warn(`Error processing audio analysis:`, error) }) } // Update the ref analysisRef.current = savedAnalysisData // Dispatch the updated analysis data to state to trigger re-render dispatch({ type: 'UPDATE_ANALYSIS', payload: { ...savedAnalysisData }, }) }, [dispatch] ) const handleAudioEvent = useCallback( async (eventData: AudioEventPayload) => { const { fileUri, deltaSize, totalSize, lastEmittedSize, position, streamUuid, encoded, mimeType, buffer, compression, } = eventData logger?.debug(`[handleAudioEvent] Received audio event:`, { fileUri, deltaSize, totalSize, position, mimeType, lastEmittedSize, streamUuid, encodedLength: encoded?.length, compression, }) if (deltaSize === 0) { // Ignore packet with no data return } try { // Coming from native ( ios / android ) otherwise buffer is set if (Platform.OS !== 'web') { // Read the audio file as a base64 string for comparison if (!encoded) { logger?.error(`Encoded audio data is missing`) throw new Error('Encoded audio data is missing') } onAudioStreamRef.current?.({ data: encoded, position, fileUri, eventDataSize: deltaSize, totalSize, compression: compression && startResultRef.current?.compression ? { data: compression.data, size: compression.totalSize, mimeType: startResultRef.current.compression ?.mimeType, bitrate: startResultRef.current.compression ?.bitrate, format: startResultRef.current.compression ?.format, } : undefined, }) } else if (buffer) { // Coming from web const webEvent: AudioDataEvent = { data: buffer, position, fileUri, eventDataSize: deltaSize, totalSize, compression: compression && startResultRef.current?.compression ? { data: compression.data, size: compression.totalSize, mimeType: startResultRef.current.compression ?.mimeType, bitrate: startResultRef.current.compression ?.bitrate, format: startResultRef.current.compression ?.format, } : undefined, } onAudioStreamRef.current?.(webEvent) logger?.debug( `[handleAudioEvent] Audio data sent to onAudioStream`, webEvent ) } } catch (error) { logger?.error(`Error processing audio event:`, error) } }, [] ) const checkStatus = useCallback(async () => { try { const status: AudioStreamStatus = ExpoAudioStream.status() logger?.debug( `Status: paused: ${status.isPaused} isRecording: ${status.isRecording} durationMs: ${status.durationMs} size: ${status.size}`, status.compression ) // Only dispatch if values actually changed if ( status.isRecording !== stateRef.current.isRecording || status.isPaused !== stateRef.current.isPaused ) { stateRef.current.isRecording = status.isRecording stateRef.current.isPaused = status.isPaused dispatch({ type: 'UPDATE_RECORDING_STATE', payload: { isRecording: status.isRecording, isPaused: status.isPaused, }, }) } if ( status.durationMs !== stateRef.current.durationMs || status.size !== stateRef.current.size ) { stateRef.current.durationMs = status.durationMs stateRef.current.size = status.size stateRef.current.compression = status.compression dispatch({ type: 'UPDATE_STATUS', payload: { durationMs: status.durationMs, size: status.size, compression: status.compression, }, }) } } catch (error) { logger?.error(`Error getting status:`, error) } }, [ExpoAudioStream, logger]) // Only depend on ExpoAudioStream and logger // Update ref when state changes useEffect(() => { stateRef.current = { isRecording: state.isRecording, isPaused: state.isPaused, durationMs: state.durationMs, size: state.size, compression: state.compression, } }, [ state.isRecording, state.isPaused, state.durationMs, state.size, state.compression, ]) const startRecording = useCallback( async (recordingOptions: RecordingConfig) => { // Import validation function const { validateRecordingConfig } = await import( './constants/platformLimitations' ) // Validate the encoding configuration const validationResult = validateRecordingConfig({ encoding: recordingOptions.encoding, }) // Log warnings if any if (validationResult.warnings.length > 0) { validationResult.warnings.forEach((warning) => { logger?.warn(warning) }) } // Update recording options with validated values const validatedOptions = { ...recordingOptions, encoding: validationResult.encoding, } recordingConfigRef.current = validatedOptions logger?.debug( `start recording with validated config`, validatedOptions ) analysisRef.current = { ...defaultAnalysis } // Reset analysis data fullAnalysisRef.current = { ...defaultAnalysis } const { onAudioStream, onRecordingInterrupted, onAudioAnalysis, ...options } = validatedOptions const { enableProcessing } = options const maxRecentDataDuration = 10000 // TODO compute maxRecentDataDuration based on screen dimensions if (typeof onAudioStream === 'function') { onAudioStreamRef.current = onAudioStream } else { logger?.warn(`onAudioStream is not a function`, onAudioStream) onAudioStreamRef.current = null } const startResult: StartRecordingResult = await ExpoAudioStream.startRecording(options) dispatch({ type: 'START' }) startResultRef.current = startResult if (enableProcessing) { logger?.debug(`Enabling audio analysis listener`) const listener = addAudioAnalysisListener( async (analysisData) => { try { await handleAudioAnalysis({ analysis: analysisData, visualizationDuration: maxRecentDataDuration, }) } catch (error) { logger?.warn( `Error processing audio analysis:`, error ) } } ) analysisListenerRef.current = listener } return startResult }, [handleAudioAnalysis, dispatch] ) const prepareRecording = useCallback( async (recordingOptions: RecordingConfig) => { recordingConfigRef.current = recordingOptions logger?.debug(`preparing recording`, recordingOptions) analysisRef.current = { ...defaultAnalysis } // Reset analysis data fullAnalysisRef.current = { ...defaultAnalysis } const { onAudioStream, onRecordingInterrupted, onAudioAnalysis, ...options } = recordingOptions // Store onAudioStream for later use when recording starts if (typeof onAudioStream === 'function') { onAudioStreamRef.current = onAudioStream } else { logger?.warn(`onAudioStream is not a function`, onAudioStream) onAudioStreamRef.current = null } // Call the native prepareRecording method await ExpoAudioStream.prepareRecording(options) logger?.debug(`recording prepared successfully`) }, [] ) const stopRecording = useCallback(async () => { logger?.debug(`stoping recording`) const stopResult: AudioRecording = await ExpoAudioStream.stopRecording() stopResult.analysisData = fullAnalysisRef.current if (analysisListenerRef.current) { analysisListenerRef.current.remove() analysisListenerRef.current = null } onAudioStreamRef.current = null // Note: We deliberately DON'T clear recordingConfigRef here to preserve interruption callback logger?.debug(`recording stopped`, stopResult) dispatch({ type: 'STOP' }) return stopResult }, [dispatch]) const pauseRecording = useCallback(async () => { logger?.debug(`pause recording`) const pauseResult = await ExpoAudioStream.pauseRecording() dispatch({ type: 'PAUSE' }) return pauseResult }, [dispatch]) const resumeRecording = useCallback(async () => { logger?.debug(`resume recording`) const resumeResult = await ExpoAudioStream.resumeRecording() dispatch({ type: 'RESUME' }) return resumeResult }, [dispatch]) useEffect(() => { let intervalId: ReturnType | undefined if (state.isRecording || state.isPaused) { // Immediately check status when starting checkStatus() // Start interval intervalId = setInterval(checkStatus, 1000) } return () => { if (intervalId) { clearInterval(intervalId) intervalId = undefined } } }, [checkStatus, state.isRecording, state.isPaused]) useEffect(() => { logger?.debug(`Registering audio event listener`) const subscribeAudio = addAudioEventListener(handleAudioEvent) logger?.debug( `Subscribed to audio event listener and analysis listener`, { subscribeAudio, } ) return () => { logger?.debug(`Removing audio event listener`) subscribeAudio.remove() } }, [handleAudioEvent, handleAudioAnalysis]) useEffect(() => { // Add event subscription for recording interruptions logger?.debug( `Setting up recording interruption listener [${instanceId}]` ) const subscription = addRecordingInterruptionListener((event) => { logger?.debug( `[${instanceId}] Received recording interruption event:`, event ) // Handle device disconnection for UI updates if (event.reason === 'deviceDisconnected') { logger?.debug( `[${instanceId}] Device disconnected - temporarily hiding last device from UI` ) // Get current device list before the native layer updates const currentDevices = audioDeviceManager.getRawDevices() // Wait a moment for native layer to update, then compare setTimeout(async () => { try { // Get updated devices without notifying yet const updatedDevices = await audioDeviceManager.getAvailableDevices({ refresh: true, }) // Find missing devices by comparing lists const missingDevices = currentDevices.filter( (oldDevice) => !updatedDevices.some( (newDevice) => newDevice.id === oldDevice.id ) ) if (missingDevices.length > 0) { // Mark all missing devices as disconnected (silently) missingDevices.forEach((missingDevice) => { logger?.debug( `[${instanceId}] Confirmed disconnected device: ${missingDevice.name} (${missingDevice.id})` ) audioDeviceManager.markDeviceAsDisconnected( missingDevice.id, false ) }) } // Notify listeners once with the final filtered state audioDeviceManager.notifyListeners() } catch (error) { logger?.warn( `[${instanceId}] Error in delayed device disconnection handling:`, error ) } }, 500) // 500ms delay to let native layer update } else if (event.reason === 'deviceConnected') { // Device reconnected - force refresh to show it immediately logger?.debug( `[${instanceId}] Device connected, forcing refresh` ) audioDeviceManager.forceRefreshDevices() } // Check if we have a callback configured logger?.debug( `[${instanceId}] recordingConfigRef.current exists:`, !!recordingConfigRef.current ) if (recordingConfigRef.current?.onRecordingInterrupted) { try { logger?.debug( `[${instanceId}] Calling recording interruption callback` ) recordingConfigRef.current.onRecordingInterrupted(event) } catch (error) { logger?.error( `[${instanceId}] Error in recording interruption callback:`, error ) } } else { logger?.debug( `[${instanceId}] No recording interruption callback configured` ) } }) return () => { logger?.debug( `[${instanceId}] Removing recording interruption listener` ) subscription.remove() } }, [instanceId, logger]) // Include instanceId and logger in dependencies return { prepareRecording, startRecording, stopRecording, pauseRecording, resumeRecording, isPaused: state.isPaused, isRecording: state.isRecording, durationMs: state.durationMs, size: state.size, compression: state.compression, analysisData: state.analysisData, } }