UNPKG

expo-av

Version:

Expo universal module for Audio and Video playback

498 lines (455 loc) 19.1 kB
import { PermissionResponse, PermissionStatus, PermissionHookOptions, createPermissionHook, LegacyEventEmitter, type EventSubscription, Platform, } from 'expo-modules-core'; import { isAudioEnabled, throwIfAudioIsDisabled } from './AudioAvailability'; import { RecordingInput, RecordingObject, RecordingOptions, RecordingStatus, } from './Recording.types'; import { RecordingOptionsPresets } from './RecordingConstants'; import { Sound, SoundObject } from './Sound'; import { _DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS, AVPlaybackStatus, AVPlaybackStatusToSet, } from '../AV'; import ExponentAV from '../ExponentAV'; let _recorderExists: boolean = false; const eventEmitter = Platform.OS === 'android' ? new LegacyEventEmitter(ExponentAV) : null; /** * Checks user's permissions for audio recording. * @return A promise that resolves to an object of type `PermissionResponse`. * @platform android * @platform ios */ export async function getPermissionsAsync(): Promise<PermissionResponse> { return ExponentAV.getPermissionsAsync(); } /** * Asks the user to grant permissions for audio recording. * @return A promise that resolves to an object of type `PermissionResponse`. * @platform android * @platform ios */ export async function requestPermissionsAsync(): Promise<PermissionResponse> { return ExponentAV.requestPermissionsAsync(); } /** * Check or request permissions to record audio. * This uses both `requestPermissionAsync` and `getPermissionsAsync` to interact with the permissions. * * @example * ```ts * const [permissionResponse, requestPermission] = Audio.usePermissions(); * ``` */ export const usePermissions = createPermissionHook({ getMethod: getPermissionsAsync, requestMethod: requestPermissionsAsync, }); // @needsAudit /** * > **warning** **Warning**: Experimental for web. * * This class represents an audio recording. After creating an instance of this class, `prepareToRecordAsync` * must be called in order to record audio. Once recording is finished, call `stopAndUnloadAsync`. Note that * only one recorder is allowed to exist in the state between `prepareToRecordAsync` and `stopAndUnloadAsync` * at any given time. * * Note that your experience must request audio recording permissions in order for recording to function. * See the [`Permissions` module](/guides/permissions) for more details. * * Additionally, audio recording is [not supported in the iOS Simulator](/workflow/ios-simulator/#limitations). * * @example * ```ts * const recording = new Audio.Recording(); * try { * await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); * await recording.startAsync(); * // You are now recording! * } catch (error) { * // An error occurred! * } * ``` * * @return A newly constructed instance of `Audio.Recording`. * @platform android * @platform ios */ export class Recording { _subscription: EventSubscription | 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; this._finalDurationMillis = finalStatus?.durationMillis ?? 0; _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 { 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<RecordingStatus> ): Promise<RecordingStatus> { 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.'); } } /** * Creates and starts a recording using the given options, with optional `onRecordingStatusUpdate` and `progressUpdateIntervalMillis`. * * ```ts * const { recording, status } = await Audio.Recording.createAsync( * options, * onRecordingStatusUpdate, * progressUpdateIntervalMillis * ); * * // Which is equivalent to the following: * const recording = new Audio.Recording(); * await recording.prepareToRecordAsync(options); * recording.setOnRecordingStatusUpdate(onRecordingStatusUpdate); * await recording.startAsync(); * ``` * * @param options Options for the recording, including sample rate, bitrate, channels, format, encoder, and extension. If no options are passed to, * the recorder will be created with options `Audio.RecordingOptionsPresets.LOW_QUALITY`. See below for details on `RecordingOptions`. * @param onRecordingStatusUpdate A function taking a single parameter `status` (a dictionary, described in `getStatusAsync`). * @param progressUpdateIntervalMillis The interval between calls of `onRecordingStatusUpdate`. This value defaults to 500 milliseconds. * * @example * ```ts * try { * const { recording: recordingObject, status } = await Audio.Recording.createAsync( * Audio.RecordingOptionsPresets.HIGH_QUALITY * ); * // You are now recording! * } catch (error) { * // An error occurred! * } * ``` * * @return A `Promise` that is rejected if creation failed, or fulfilled with the following dictionary if creation succeeded. */ static createAsync = async ( options: RecordingOptions = RecordingOptionsPresets.LOW_QUALITY, onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null = null, progressUpdateIntervalMillis: number | null = null ): Promise<RecordingObject> => { const recording: Recording = new Recording(); if (progressUpdateIntervalMillis) { recording._progressUpdateIntervalMillis = progressUpdateIntervalMillis; } recording.setOnRecordingStatusUpdate(onRecordingStatusUpdate); await recording.prepareToRecordAsync({ ...options, keepAudioActiveHint: true, }); try { const status = await recording.startAsync(); return { recording, status }; } catch (err) { recording.stopAndUnloadAsync().catch((_e) => { // Since there was an issue with starting, when trying calling stopAndUnloadAsync // the promise is rejected which is unhandled // lets catch it since its expected }); throw err; } }; // Get status API /** * Gets the `status` of the `Recording`. * @return A `Promise` that is resolved with the `RecordingStatus` object. */ getStatusAsync = async (): Promise<RecordingStatus> => { // 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; }; /** * Sets a function to be called regularly with the `RecordingStatus` of the `Recording`. * * `onRecordingStatusUpdate` will be called when another call to the API for this recording completes (such as `prepareToRecordAsync()`, * `startAsync()`, `getStatusAsync()`, or `stopAndUnloadAsync()`), and will also be called at regular intervals while the recording can record. * Call `setProgressUpdateInterval()` to modify the interval with which `onRecordingStatusUpdate` is called while the recording can record. * * @param onRecordingStatusUpdate A function taking a single parameter `RecordingStatus`. */ setOnRecordingStatusUpdate(onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null) { this._onRecordingStatusUpdate = onRecordingStatusUpdate; if (onRecordingStatusUpdate == null) { this._disablePolling(); } else { this._enablePollingIfNecessaryAndPossible(); } this.getStatusAsync(); } /** * Sets the interval with which `onRecordingStatusUpdate` is called while the recording can record. * See `setOnRecordingStatusUpdate` for details. This value defaults to 500 milliseconds. * @param progressUpdateIntervalMillis The new interval between calls of `onRecordingStatusUpdate`. */ setProgressUpdateInterval(progressUpdateIntervalMillis: number) { this._progressUpdateIntervalMillis = progressUpdateIntervalMillis; this.getStatusAsync(); } // Record API /** * Loads the recorder into memory and prepares it for recording. This must be called before calling `startAsync()`. * This method can only be called if the `Recording` instance has never yet been prepared. * * @param options `RecordingOptions` for the recording, including sample rate, bitrate, channels, format, encoder, and extension. * If no options are passed to `prepareToRecordAsync()`, the recorder will be created with options `Audio.RecordingOptionsPresets.LOW_QUALITY`. * * @return A `Promise` that is fulfilled when the recorder is loaded and prepared, or rejects if this failed. If another `Recording` exists * in your experience that is currently prepared to record, the `Promise` will reject. If the `RecordingOptions` provided are invalid, * the `Promise` will also reject. The promise is resolved with the `RecordingStatus` of the recording. */ async prepareToRecordAsync( options: RecordingOptions = RecordingOptionsPresets.LOW_QUALITY ): Promise<RecordingStatus> { 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 | null; // status is of type RecordingStatus, but without the canRecord field populated status: Pick<RecordingStatus, Exclude<keyof RecordingStatus, 'canRecord'>>; } = 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.'); } } /** * Returns a list of available recording inputs. This method can only be called if the `Recording` has been prepared. * @return A `Promise` that is fulfilled with an array of `RecordingInput` objects. */ async getAvailableInputs(): Promise<RecordingInput[]> { return ExponentAV.getAvailableInputs(); } /** * Returns the currently-selected recording input. This method can only be called if the `Recording` has been prepared. * @return A `Promise` that is fulfilled with a `RecordingInput` object. */ async getCurrentInput(): Promise<RecordingInput> { return ExponentAV.getCurrentInput(); } /** * Sets the current recording input. * @param inputUid The uid of a `RecordingInput`. * @return A `Promise` that is resolved if successful or rejected if not. */ async setInput(inputUid: string): Promise<void> { return ExponentAV.setInput(inputUid); } /** * Begins recording. This method can only be called if the `Recording` has been prepared. * @return A `Promise` that is fulfilled when recording has begun, or rejects if recording could not be started. * The promise is resolved with the `RecordingStatus` of the recording. */ async startAsync(): Promise<RecordingStatus> { return this._performOperationAndHandleStatusAsync(() => ExponentAV.startAudioRecording()); } /** * Pauses recording. This method can only be called if the `Recording` has been prepared. * * > This is only available on Android API version 24 and later. * * @return A `Promise` that is fulfilled when recording has paused, or rejects if recording could not be paused. * If the Android API version is less than 24, the `Promise` will reject. The promise is resolved with the * `RecordingStatus` of the recording. */ async pauseAsync(): Promise<RecordingStatus> { return this._performOperationAndHandleStatusAsync(() => ExponentAV.pauseAudioRecording()); } /** * Stops the recording and deallocates the recorder from memory. This reverts the `Recording` instance * to an unprepared state, and another `Recording` instance must be created in order to record again. * This method can only be called if the `Recording` has been prepared. * * > On Android this method may fail with `E_AUDIO_NODATA` when called too soon after `startAsync` and * > no audio data has been recorded yet. In that case the recorded file will be invalid and should be discarded. * * @return A `Promise` that is fulfilled when recording has stopped, or rejects if recording could not be stopped. * The promise is resolved with the `RecordingStatus` of the recording. */ async stopAndUnloadAsync(): Promise<RecordingStatus> { 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) let stopResult: RecordingStatus | undefined; let stopError: Error | undefined; try { stopResult = await ExponentAV.stopAudioRecording(); } catch (err) { stopError = err; } // Web has to return the URI at the end of recording, so needs a little destructuring if (Platform.OS === 'web' && stopResult?.uri !== undefined) { this._uri = stopResult.uri; } // Clean-up and return status await ExponentAV.unloadAudioRecorder(); const status = await this._cleanupForUnloadedRecorder(stopResult); return stopError ? Promise.reject(stopError) : status; } // Read API /** * Gets the local URI of the `Recording`. Note that this will only succeed once the `Recording` is prepared * to record. On web, this will not return the URI until the recording is finished. * @return A `string` with the local URI of the `Recording`, or `null` if the `Recording` is not prepared * to record (or, on Web, if the recording has not finished). */ getURI(): string | null { return this._uri; } /** * @deprecated Use `createNewLoadedSoundAsync()` instead. */ async createNewLoadedSound( initialStatus: AVPlaybackStatusToSet = {}, onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null ): Promise<SoundObject> { console.warn( `createNewLoadedSound is deprecated in favor of createNewLoadedSoundAsync, which has the same API aside from the method name` ); return this.createNewLoadedSoundAsync(initialStatus, onPlaybackStatusUpdate); } /** * Creates and loads a new `Sound` object to play back the `Recording`. Note that this will only succeed once the `Recording` * is done recording and `stopAndUnloadAsync()` has been called. * * @param initialStatus The initial intended `PlaybackStatusToSet` of the sound, whose values will override the default initial playback status. * This value defaults to `{}` if no parameter is passed. See the [AV documentation](/versions/latest/sdk/av) for details on `PlaybackStatusToSet` * and the default initial playback status. * @param onPlaybackStatusUpdate A function taking a single parameter `PlaybackStatus`. This value defaults to `null` if no parameter is passed. * See the [AV documentation](/versions/latest/sdk/av) for details on the functionality provided by `onPlaybackStatusUpdate` * * @return A `Promise` that is rejected if creation failed, or fulfilled with the `SoundObject`. */ async createNewLoadedSoundAsync( initialStatus: AVPlaybackStatusToSet = {}, onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null ): Promise<SoundObject> { 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 ); } } export { PermissionResponse, PermissionStatus, PermissionHookOptions }; export * from './RecordingConstants'; export * from './Recording.types';