import type * as ExpoAudio from 'expo-audio'; import type * as ExpoAV from 'expo-av'; import { Platform } from 'react-native'; import { Logger, matchesOneOf, sleep } from '@sendbird/uikit-utils'; import VoiceMessageConfig from '../libs/VoiceMessageConfig'; import expoBackwardUtils from '../utils/expoBackwardUtils'; import type { ExpoAudioModule } from '../utils/expoBackwardUtils'; import expoPermissionGranted from '../utils/expoPermissionGranted'; import type { RecorderServiceInterface, Unsubscribe } from './types'; type RecordingListener = Parameters[number]; type StateListener = Parameters[number]; type Modules = { avModule: ExpoAudioModule; }; interface AudioRecorderAdapter { requestPermission(): Promise; record(): Promise; stop(): Promise; reset(): Promise; addRecordingListener(callback: RecordingListener): Unsubscribe; addStateListener(callback: StateListener): Unsubscribe; convertRecordPath(uri: string): string; readonly state: RecorderServiceInterface['state']; readonly options: RecorderServiceInterface['options']; uri?: string; } abstract class BaseAudioRecorderAdapter implements AudioRecorderAdapter { public uri: RecorderServiceInterface['uri'] = undefined; public state: RecorderServiceInterface['state'] = 'idle'; public options: RecorderServiceInterface['options'] = { minDuration: VoiceMessageConfig.DEFAULT.RECORDER.MIN_DURATION, maxDuration: VoiceMessageConfig.DEFAULT.RECORDER.MAX_DURATION, extension: VoiceMessageConfig.DEFAULT.RECORDER.EXTENSION, }; protected readonly _audioSettings = { sampleRate: VoiceMessageConfig.DEFAULT.RECORDER.SAMPLE_RATE, bitRate: VoiceMessageConfig.DEFAULT.RECORDER.BIT_RATE, numberOfChannels: VoiceMessageConfig.DEFAULT.RECORDER.CHANNELS, // encoding: mpeg4_aac }; protected readonly _recordingSubscribers = new Set(); protected readonly _stateSubscribers = new Set(); // NOTE: In Android, even when startRecorder() is awaited, if stop() is executed immediately afterward, an error occurs protected _recordStartedAt = 0; protected _getRecorderStopSafeBuffer = () => { const minWaitingTime = 500; const elapsedTime = Date.now() - this._recordStartedAt; if (elapsedTime > minWaitingTime) return 0; else return minWaitingTime - elapsedTime; }; protected setState = (state: RecorderServiceInterface['state']) => { this.state = state; this._stateSubscribers.forEach((callback) => { callback(state); }); }; public addRecordingListener = (callback: RecordingListener): Unsubscribe => { this._recordingSubscribers.add(callback); return () => { this._recordingSubscribers.delete(callback); }; }; public addStateListener = (callback: StateListener): Unsubscribe => { this._stateSubscribers.add(callback); return () => { this._stateSubscribers.delete(callback); }; }; public convertRecordPath = (uri: string): string => { return uri; }; abstract requestPermission(): Promise; abstract record(): Promise; abstract stop(): Promise; abstract reset(): Promise; } class LegacyExpoAVRecorderAdapter extends BaseAudioRecorderAdapter { private readonly avModule: typeof ExpoAV; private _recorder: ExpoAV.Audio.Recording; private readonly _audioOptions: ExpoAV.Audio.RecordingOptions; constructor(avModule: typeof ExpoAV) { super(); this.avModule = avModule; this._recorder = new avModule.Audio.Recording(); this._audioOptions = { android: { ...this._audioSettings, extension: `.${this.options.extension}`, audioEncoder: avModule.Audio.AndroidAudioEncoder.AAC, outputFormat: avModule.Audio.AndroidOutputFormat.MPEG_4, }, ios: { ...this._audioSettings, extension: `.${this.options.extension}`, outputFormat: avModule.Audio.IOSOutputFormat.MPEG4AAC, audioQuality: avModule.Audio.IOSAudioQuality.HIGH, }, web: {}, }; } private prepare = async () => { this.setState('preparing'); if (Platform.OS === 'ios') { await this.avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); } if (this._recorder._isDoneRecording) { this._recorder = new this.avModule.Audio.Recording(); } this._recorder.setProgressUpdateInterval(100); this._recorder.setOnRecordingStatusUpdate((status) => { const completed = status.durationMillis >= this.options.maxDuration; if (completed) this.stop(); if (status.isRecording) { this._recordingSubscribers.forEach((callback) => { callback({ currentTime: status.durationMillis, completed: completed }); }); } }); await this._recorder.prepareToRecordAsync(this._audioOptions); }; public requestPermission = async (): Promise => { const status = await this.avModule.Audio.getPermissionsAsync(); if (expoPermissionGranted([status])) { return true; } else { const status = await this.avModule.Audio.requestPermissionsAsync(); return expoPermissionGranted([status]); } }; public record = async (): Promise => { if (matchesOneOf(this.state, ['idle', 'completed'])) { try { await this.prepare(); await this._recorder.startAsync(); if (Platform.OS === 'android') { this._recordStartedAt = Date.now(); } const uri = this._recorder.getURI(); if (uri) this.uri = uri; this.setState('recording'); } catch (e) { this.setState('idle'); throw e; } } }; public stop = async (): Promise => { if (matchesOneOf(this.state, ['recording'])) { if (Platform.OS === 'android') { const buffer = this._getRecorderStopSafeBuffer(); if (buffer > 0) await sleep(buffer); } await this._recorder.stopAndUnloadAsync(); if (Platform.OS === 'ios') { await this.avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: false }); } this.setState('completed'); } }; public reset = async (): Promise => { await this.stop(); this.uri = undefined; this._recordingSubscribers.clear(); this._recorder = new this.avModule.Audio.Recording(); this.setState('idle'); }; } class ExpoAudioRecorderAdapter extends BaseAudioRecorderAdapter { private readonly audioModule: typeof ExpoAudio; private recorder: ExpoAudio.AudioRecorder | null = null; private recordingUpdateInterval: NodeJS.Timeout | null = null; constructor(audioModule: typeof ExpoAudio) { super(); this.audioModule = audioModule; } private setListener = () => { if (!this.recorder) return; this.recordingUpdateInterval = setInterval(() => { if (this.recorder && this.recorder.isRecording) { const currentTime = this.recorder.currentTime * 1000; const completed = currentTime >= this.options.maxDuration; if (completed) { this.stop().catch((error) => { Logger.warn('[RecorderService.Expo] Failed to stop in update interval', error); }); } this._recordingSubscribers.forEach((callback) => { callback({ currentTime, completed }); }); } }, 100); }; private removeListener = () => { if (this.recordingUpdateInterval) { clearInterval(this.recordingUpdateInterval); this.recordingUpdateInterval = null; } }; private prepare = async () => { this.setState('preparing'); if (Platform.OS === 'ios') { await this.audioModule.setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true, }); } const recordingOptions = { ...this._audioSettings, extension: `.${this.options.extension}`, }; this.recorder = new this.audioModule.AudioModule.AudioRecorder(recordingOptions); await this.recorder.prepareToRecordAsync(); }; public requestPermission = async (): Promise => { const status = await this.audioModule.getRecordingPermissionsAsync(); if (expoPermissionGranted([status])) { return true; } else { const status = await this.audioModule.requestRecordingPermissionsAsync(); return expoPermissionGranted([status]); } }; public record = async (): Promise => { if (matchesOneOf(this.state, ['idle', 'completed'])) { try { await this.prepare(); this.setListener(); this.recorder?.record(); if (Platform.OS === 'android') { this._recordStartedAt = Date.now(); } const uri = this.recorder?.uri; if (uri) this.uri = uri; this.setState('recording'); } catch (e) { this.setState('idle'); this.removeListener(); throw e; } } }; public stop = async (): Promise => { if (matchesOneOf(this.state, ['recording'])) { if (Platform.OS === 'android') { const buffer = this._getRecorderStopSafeBuffer(); if (buffer > 0) await sleep(buffer); } await this.recorder?.stop(); this.removeListener(); if (Platform.OS === 'ios') { await this.audioModule.setAudioModeAsync({ allowsRecording: false, playsInSilentMode: false, }); } this.setState('completed'); } }; public reset = async (): Promise => { await this.stop(); this.recorder = null; this.uri = undefined; this._recordingSubscribers.clear(); this._stateSubscribers.clear(); this.setState('idle'); }; } const createExpoRecorderService = ({ avModule }: Modules): RecorderServiceInterface => { if (expoBackwardUtils.expoAV.isLegacyAVModule(avModule)) { Logger.warn( '[RecorderService.Expo] expo-av is deprecated and will be removed in Expo 54. Please migrate to expo-audio.', ); } return expoBackwardUtils.expoAV.isAudioModule(avModule) ? new ExpoAudioRecorderAdapter(avModule) : new LegacyExpoAVRecorderAdapter(avModule); }; export default createExpoRecorderService;