import { EventEmitter, Subscription, Platform } from '@unimodules/core'; import { PermissionResponse, PermissionStatus } from 'unimodules-permissions-interface'; import { _DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS, AVPlaybackStatus, AVPlaybackStatusToSet, } from '../AV'; import ExponentAV from '../ExponentAV'; import { isAudioEnabled, throwIfAudioIsDisabled } from './AudioAvailability'; import { Sound } from './Sound'; export type RecordingOptions = { android: { extension: string; outputFormat: number; audioEncoder: number; sampleRate?: number; numberOfChannels?: number; bitRate?: number; maxFileSize?: number; }; ios: { extension: string; outputFormat?: string | number; audioQuality: number; sampleRate: number; numberOfChannels: number; bitRate: number; bitRateStrategy?: number; bitDepthHint?: number; linearPCMBitDepth?: number; linearPCMIsBigEndian?: boolean; linearPCMIsFloat?: boolean; }; }; // TODO: consider changing these to enums export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_DEFAULT = 0; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_THREE_GPP = 1; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_MPEG_4 = 2; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AMR_NB = 3; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AMR_WB = 4; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AAC_ADIF = 5; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AAC_ADTS = 6; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_RTP_AVP = 7; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_MPEG2TS = 8; export const RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_WEBM = 9; export const RECORDING_OPTION_ANDROID_AUDIO_ENCODER_DEFAULT = 0; export const RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AMR_NB = 1; export const RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AMR_WB = 2; export const RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC = 3; export const RECORDING_OPTION_ANDROID_AUDIO_ENCODER_HE_AAC = 4; export const RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC_ELD = 5; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_LINEARPCM = 'lpcm'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_AC3 = 'ac-3'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_60958AC3 = 'cac3'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_APPLEIMA4 = 'ima4'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC = 'aac '; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4CELP = 'celp'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4HVXC = 'hvxc'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4TWINVQ = 'twvq'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MACE3 = 'MAC3'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MACE6 = 'MAC6'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_ULAW = 'ulaw'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_ALAW = 'alaw'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_QDESIGN = 'QDMC'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_QDESIGN2 = 'QDM2'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_QUALCOMM = 'Qclp'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEGLAYER1 = '.mp1'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEGLAYER2 = '.mp2'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEGLAYER3 = '.mp3'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_APPLELOSSLESS = 'alac'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_HE = 'aach'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_LD = 'aacl'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_ELD = 'aace'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_ELD_SBR = 'aacf'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_ELD_V2 = 'aacg'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_HE_V2 = 'aacp'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC_SPATIAL = 'aacs'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_AMR = 'samr'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_AMR_WB = 'sawb'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_AUDIBLE = 'AUDB'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_ILBC = 'ilbc'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_DVIINTELIMA = 0x6d730011; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_MICROSOFTGSM = 0x6d730031; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_AES3 = 'aes3'; export const RECORDING_OPTION_IOS_OUTPUT_FORMAT_ENHANCEDAC3 = 'ec-3'; export const RECORDING_OPTION_IOS_AUDIO_QUALITY_MIN = 0; export const RECORDING_OPTION_IOS_AUDIO_QUALITY_LOW = 0x20; export const RECORDING_OPTION_IOS_AUDIO_QUALITY_MEDIUM = 0x40; export const RECORDING_OPTION_IOS_AUDIO_QUALITY_HIGH = 0x60; export const RECORDING_OPTION_IOS_AUDIO_QUALITY_MAX = 0x7f; export const RECORDING_OPTION_IOS_BIT_RATE_STRATEGY_CONSTANT = 0; export const RECORDING_OPTION_IOS_BIT_RATE_STRATEGY_LONG_TERM_AVERAGE = 1; export const RECORDING_OPTION_IOS_BIT_RATE_STRATEGY_VARIABLE_CONSTRAINED = 2; export const RECORDING_OPTION_IOS_BIT_RATE_STRATEGY_VARIABLE = 3; // TODO : maybe make presets for music and speech, or lossy / lossless. export const RECORDING_OPTIONS_PRESET_HIGH_QUALITY: RecordingOptions = { android: { extension: '.m4a', outputFormat: RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_MPEG_4, audioEncoder: RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC, sampleRate: 44100, numberOfChannels: 2, bitRate: 128000, }, ios: { extension: '.caf', audioQuality: RECORDING_OPTION_IOS_AUDIO_QUALITY_MAX, sampleRate: 44100, numberOfChannels: 2, bitRate: 128000, linearPCMBitDepth: 16, linearPCMIsBigEndian: false, linearPCMIsFloat: false, }, }; export const RECORDING_OPTIONS_PRESET_LOW_QUALITY: RecordingOptions = { android: { extension: '.3gp', outputFormat: RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_THREE_GPP, audioEncoder: RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AMR_NB, sampleRate: 44100, numberOfChannels: 2, bitRate: 128000, }, ios: { extension: '.caf', audioQuality: RECORDING_OPTION_IOS_AUDIO_QUALITY_MIN, sampleRate: 44100, numberOfChannels: 2, bitRate: 128000, linearPCMBitDepth: 16, linearPCMIsBigEndian: false, linearPCMIsFloat: false, }, }; // TODO: For consistency with PlaybackStatus, should we include progressUpdateIntervalMillis here as // well? export type RecordingStatus = { canRecord: boolean; isRecording: boolean; isDoneRecording: boolean; durationMillis: number; }; export { PermissionResponse, PermissionStatus }; let _recorderExists: boolean = false; const eventEmitter = Platform.OS === 'android' ? new EventEmitter(ExponentAV) : null; export async function getPermissionsAsync(): Promise { return ExponentAV.getPermissionsAsync(); } export async function requestPermissionsAsync(): Promise { return ExponentAV.requestPermissionsAsync(); } export class Recording { _subscription: Subscription | null = null; _canRecord: boolean = false; _isDoneRecording: boolean = false; _finalDurationMillis: number = 0; _uri: string | null = null; _onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null = null; _progressUpdateTimeoutVariable: number | null = null; _progressUpdateIntervalMillis: number = _DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS; _options: RecordingOptions | null = null; // Internal methods _cleanupForUnloadedRecorder = async (finalStatus: RecordingStatus) => { this._canRecord = false; this._isDoneRecording = true; // $FlowFixMe(greg): durationMillis is not always defined this._finalDurationMillis = finalStatus.durationMillis; _recorderExists = false; if (this._subscription) { this._subscription.remove(); this._subscription = null; } this._disablePolling(); return await this.getStatusAsync(); // Automatically calls onRecordingStatusUpdate for the final state. }; _pollingLoop = async () => { if (isAudioEnabled() && this._canRecord && this._onRecordingStatusUpdate != null) { this._progressUpdateTimeoutVariable = setTimeout( this._pollingLoop, this._progressUpdateIntervalMillis ) as any; try { await this.getStatusAsync(); } catch (error) { this._disablePolling(); } } }; _disablePolling() { if (this._progressUpdateTimeoutVariable != null) { clearTimeout(this._progressUpdateTimeoutVariable); this._progressUpdateTimeoutVariable = null; } } _enablePollingIfNecessaryAndPossible() { if (isAudioEnabled() && this._canRecord && this._onRecordingStatusUpdate != null) { this._disablePolling(); this._pollingLoop(); } } _callOnRecordingStatusUpdateForNewStatus(status: RecordingStatus) { if (this._onRecordingStatusUpdate != null) { this._onRecordingStatusUpdate(status); } } async _performOperationAndHandleStatusAsync( operation: () => Promise ): Promise { throwIfAudioIsDisabled(); if (this._canRecord) { const status = await operation(); this._callOnRecordingStatusUpdateForNewStatus(status); return status; } else { throw new Error('Cannot complete operation because this recorder is not ready to record.'); } } // Note that all calls automatically call onRecordingStatusUpdate as a side effect. // Get status API getStatusAsync = async (): Promise => { // Automatically calls onRecordingStatusUpdate. if (this._canRecord) { return this._performOperationAndHandleStatusAsync(() => ExponentAV.getAudioRecordingStatus()); } const status = { canRecord: false, isRecording: false, isDoneRecording: this._isDoneRecording, durationMillis: this._finalDurationMillis, }; this._callOnRecordingStatusUpdateForNewStatus(status); return status; }; setOnRecordingStatusUpdate(onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null) { this._onRecordingStatusUpdate = onRecordingStatusUpdate; if (onRecordingStatusUpdate == null) { this._disablePolling(); } else { this._enablePollingIfNecessaryAndPossible(); } this.getStatusAsync(); } setProgressUpdateInterval(progressUpdateIntervalMillis: number) { this._progressUpdateIntervalMillis = progressUpdateIntervalMillis; this.getStatusAsync(); } // Record API async prepareToRecordAsync( options: RecordingOptions = RECORDING_OPTIONS_PRESET_LOW_QUALITY ): Promise { throwIfAudioIsDisabled(); if (_recorderExists) { throw new Error('Only one Recording object can be prepared at a given time.'); } if (this._isDoneRecording) { throw new Error('This Recording object is done recording; you must make a new one.'); } if (!options || !options.android || !options.ios) { throw new Error( 'You must provide recording options for android and ios in order to prepare to record.' ); } const extensionRegex = /^\.\w+$/; if ( !options.android.extension || !options.ios.extension || !extensionRegex.test(options.android.extension) || !extensionRegex.test(options.ios.extension) ) { throw new Error(`Your file extensions must match ${extensionRegex.toString()}.`); } if (!this._canRecord) { if (eventEmitter) { this._subscription = eventEmitter.addListener( 'Expo.Recording.recorderUnloaded', this._cleanupForUnloadedRecorder ); } const { uri, status, }: { uri: string; // status is of type RecordingStatus, but without the canRecord field populated status: Pick>; } = await ExponentAV.prepareAudioRecorder(options); _recorderExists = true; this._uri = uri; this._options = options; this._canRecord = true; const currentStatus = { ...status, canRecord: true }; this._callOnRecordingStatusUpdateForNewStatus(currentStatus); this._enablePollingIfNecessaryAndPossible(); return currentStatus; } else { throw new Error('This Recording object is already prepared to record.'); } } async startAsync(): Promise { return this._performOperationAndHandleStatusAsync(() => ExponentAV.startAudioRecording()); } async pauseAsync(): Promise { return this._performOperationAndHandleStatusAsync(() => ExponentAV.pauseAudioRecording()); } async stopAndUnloadAsync(): Promise { if (!this._canRecord) { if (this._isDoneRecording) { throw new Error('Cannot unload a Recording that has already been unloaded.'); } else { throw new Error('Cannot unload a Recording that has not been prepared.'); } } // We perform a separate native API call so that the state of the Recording can be updated with // the final duration of the recording. (We cast stopStatus as Object to appease Flow) const finalStatus = await ExponentAV.stopAudioRecording(); await ExponentAV.unloadAudioRecorder(); return this._cleanupForUnloadedRecorder(finalStatus); } // Read API getURI(): string | null { return this._uri; } async createNewLoadedSound( initialStatus: AVPlaybackStatusToSet = {}, onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null ): Promise<{ sound: Sound; status: AVPlaybackStatus }> { console.warn( `createNewLoadedSound is deprecated in favor of createNewLoadedSoundAsync, which has the same API aside from the method name` ); return this.createNewLoadedSoundAsync(initialStatus, onPlaybackStatusUpdate); } async createNewLoadedSoundAsync( initialStatus: AVPlaybackStatusToSet = {}, onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null ): Promise<{ sound: Sound; status: AVPlaybackStatus }> { if (this._uri == null || !this._isDoneRecording) { throw new Error('Cannot create sound when the Recording has not finished!'); } return Sound.createAsync( // $FlowFixMe: Flow can't distinguish between this literal and Asset { uri: this._uri }, initialStatus, onPlaybackStatusUpdate, false ); } }