@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
547 lines • 24.2 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.useAudioRecorder = useAudioRecorder;
// src/useAudioRecorder.ts
const expo_modules_core_1 = require("expo-modules-core");
const react_1 = require("react");
const AudioDeviceManager_1 = require("./AudioDeviceManager");
const ExpoAudioStreamModule_1 = __importDefault(require("./ExpoAudioStreamModule"));
const events_1 = require("./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,
},
extractionTimeMs: 0,
};
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;
}
}
function useAudioRecorder({ logger, audioWorkletUrl, featuresExtratorUrl, } = {}) {
// Initialize AudioDeviceManager with logger (once)
if (logger) {
AudioDeviceManager_1.audioDeviceManager.setLogger(logger);
}
const [state, dispatch] = (0, react_1.useReducer)(audioRecorderReducer, {
isRecording: false,
isPaused: false,
durationMs: 0,
size: 0,
compression: undefined,
analysisData: undefined,
});
const startResultRef = (0, react_1.useRef)(null);
const analysisListenerRef = (0, react_1.useRef)(null);
// analysisRef is the current analysis data (last 10 seconds by default)
const analysisRef = (0, react_1.useRef)({ ...defaultAnalysis });
// fullAnalysisRef is the full analysis data (all data points)
const fullAnalysisRef = (0, react_1.useRef)({
...defaultAnalysis,
});
// Instantiate the module for web with URLs
const ExpoAudioStream = expo_modules_core_1.Platform.OS === 'web'
? (0, ExpoAudioStreamModule_1.default)({
audioWorkletUrl,
featuresExtratorUrl,
logger,
})
: ExpoAudioStreamModule_1.default;
const onAudioStreamRef = (0, react_1.useRef)(null);
const stateRef = (0, react_1.useRef)({
isRecording: false,
isPaused: false,
durationMs: 0,
size: 0,
compression: undefined,
});
const recordingConfigRef = (0, react_1.useRef)(null);
// Generate unique instance ID for debugging
const instanceId = (0, react_1.useId)().replace(/:/g, '').slice(0, 5);
const handleAudioAnalysis = (0, react_1.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}`);
// 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 = (0, react_1.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 (expo_modules_core_1.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 = (0, react_1.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
(0, react_1.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 = (0, react_1.useCallback)(async (recordingOptions) => {
// Import validation function
const { validateRecordingConfig } = await Promise.resolve().then(() => __importStar(require('./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 = await ExpoAudioStream.startRecording(options);
dispatch({ type: 'START' });
startResultRef.current = startResult;
if (enableProcessing) {
logger?.debug(`Enabling audio analysis listener`);
const listener = (0, events_1.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 = (0, react_1.useCallback)(async (recordingOptions) => {
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 = (0, react_1.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;
// 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 = (0, react_1.useCallback)(async () => {
logger?.debug(`pause recording`);
const pauseResult = await ExpoAudioStream.pauseRecording();
dispatch({ type: 'PAUSE' });
return pauseResult;
}, [dispatch]);
const resumeRecording = (0, react_1.useCallback)(async () => {
logger?.debug(`resume recording`);
const resumeResult = await ExpoAudioStream.resumeRecording();
dispatch({ type: 'RESUME' });
return resumeResult;
}, [dispatch]);
(0, react_1.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]);
(0, react_1.useEffect)(() => {
logger?.debug(`Registering audio event listener`);
const subscribeAudio = (0, events_1.addAudioEventListener)(handleAudioEvent);
logger?.debug(`Subscribed to audio event listener and analysis listener`, {
subscribeAudio,
});
return () => {
logger?.debug(`Removing audio event listener`);
subscribeAudio.remove();
};
}, [handleAudioEvent, handleAudioAnalysis]);
(0, react_1.useEffect)(() => {
// Add event subscription for recording interruptions
logger?.debug(`Setting up recording interruption listener [${instanceId}]`);
const subscription = (0, events_1.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_1.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_1.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_1.audioDeviceManager.markDeviceAsDisconnected(missingDevice.id, false);
});
}
// Notify listeners once with the final filtered state
AudioDeviceManager_1.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_1.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,
};
}
//# sourceMappingURL=useAudioRecorder.js.map