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

427 lines 17.5 kB
// src/useAudioRecorder.ts import { Platform } from 'expo-modules-core'; import { useCallback, useEffect, useReducer, useRef } from 'react'; import ExpoAudioStreamModule from './ExpoAudioStreamModule'; import { addAudioAnalysisListener, addAudioEventListener, addRecordingInterruptionListener, } from './events'; const defaultAnalysis = { 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, }, }; function audioRecorderReducer(state, action) { 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; } } export function useAudioRecorder({ logger, audioWorkletUrl, featuresExtratorUrl, } = {}) { 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(null); const stateRef = useRef({ isRecording: false, isPaused: false, durationMs: 0, size: 0, compression: undefined, }); const recordingConfigRef = useRef(null); const handleAudioAnalysis = useCallback(async ({ analysis, visualizationDuration, }) => { 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}`, analysis); // 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}`, savedAnalysisData); // 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) => { 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 = { 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 = 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) => { recordingConfigRef.current = recordingOptions; logger?.debug(`start recoding`, recordingOptions); analysisRef.current = { ...defaultAnalysis }; // Reset analysis data fullAnalysisRef.current = { ...defaultAnalysis }; const { onAudioStream, ...options } = recordingOptions; 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 = 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 stopRecording = useCallback(async () => { logger?.debug(`stoping recording`); const stopResult = await ExpoAudioStream.stopRecording(); stopResult.analysisData = fullAnalysisRef.current; if (analysisListenerRef.current) { analysisListenerRef.current.remove(); analysisListenerRef.current = null; } onAudioStreamRef.current = null; 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; 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'); const subscription = addRecordingInterruptionListener((event) => { logger?.debug('Received recording interruption event:', event); // Check if we have a callback configured if (recordingConfigRef.current?.onRecordingInterrupted) { try { recordingConfigRef.current.onRecordingInterrupted(event); } catch (error) { logger?.error('Error in recording interruption callback:', error); } } else { logger?.warn('No recording interruption callback configured'); } }); return () => { logger?.debug('Removing recording interruption listener'); subscription.remove(); }; }, []); // Empty dependency array since we want this to run once return { startRecording, stopRecording, pauseRecording, resumeRecording, isPaused: state.isPaused, isRecording: state.isRecording, durationMs: state.durationMs, size: state.size, compression: state.compression, analysisData: state.analysisData, }; } //# sourceMappingURL=useAudioRecorder.js.map