xref: /expo/packages/expo-av/src/Audio/Recording.ts (revision 8a424beb)
1import {
2  PermissionResponse,
3  PermissionStatus,
4  PermissionHookOptions,
5  createPermissionHook,
6  EventEmitter,
7  Subscription,
8  Platform,
9} from 'expo-modules-core';
10
11import { isAudioEnabled, throwIfAudioIsDisabled } from './AudioAvailability';
12import {
13  RecordingInput,
14  RecordingObject,
15  RecordingOptions,
16  RecordingStatus,
17} from './Recording.types';
18import { RecordingOptionsPresets } from './RecordingConstants';
19import { Sound, SoundObject } from './Sound';
20import {
21  _DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS,
22  AVPlaybackStatus,
23  AVPlaybackStatusToSet,
24} from '../AV';
25import ExponentAV from '../ExponentAV';
26
27let _recorderExists: boolean = false;
28const eventEmitter = Platform.OS === 'android' ? new EventEmitter(ExponentAV) : null;
29
30/**
31 * Checks user's permissions for audio recording.
32 * @return A promise that resolves to an object of type `PermissionResponse`.
33 */
34export async function getPermissionsAsync(): Promise<PermissionResponse> {
35  return ExponentAV.getPermissionsAsync();
36}
37
38/**
39 * Asks the user to grant permissions for audio recording.
40 * @return A promise that resolves to an object of type `PermissionResponse`.
41 */
42export async function requestPermissionsAsync(): Promise<PermissionResponse> {
43  return ExponentAV.requestPermissionsAsync();
44}
45
46/**
47 * Check or request permissions to record audio.
48 * This uses both `requestPermissionAsync` and `getPermissionsAsync` to interact with the permissions.
49 *
50 * @example
51 * ```ts
52 * const [permissionResponse, requestPermission] = Audio.usePermissions();
53 * ```
54 */
55export const usePermissions = createPermissionHook({
56  getMethod: getPermissionsAsync,
57  requestMethod: requestPermissionsAsync,
58});
59
60// @needsAudit
61/**
62 * This class represents an audio recording. After creating an instance of this class, `prepareToRecordAsync`
63 * must be called in order to record audio. Once recording is finished, call `stopAndUnloadAsync`. Note that
64 * only one recorder is allowed to exist in the state between `prepareToRecordAsync` and `stopAndUnloadAsync`
65 * at any given time.
66 *
67 * Note that your experience must request audio recording permissions in order for recording to function.
68 * See the [`Permissions` module](/guides/permissions) for more details.
69 *
70 * Additionally, audio recording is [not supported in the iOS Simulator](/workflow/ios-simulator/#limitations).
71 *
72 * @example
73 * ```ts
74 * const recording = new Audio.Recording();
75 * try {
76 *   await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
77 *   await recording.startAsync();
78 *   // You are now recording!
79 * } catch (error) {
80 *   // An error occurred!
81 * }
82 * ```
83 *
84 * @return A newly constructed instance of `Audio.Recording`.
85 */
86export class Recording {
87  _subscription: Subscription | null = null;
88  _canRecord: boolean = false;
89  _isDoneRecording: boolean = false;
90  _finalDurationMillis: number = 0;
91  _uri: string | null = null;
92  _onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null = null;
93  _progressUpdateTimeoutVariable: number | null = null;
94  _progressUpdateIntervalMillis: number = _DEFAULT_PROGRESS_UPDATE_INTERVAL_MILLIS;
95  _options: RecordingOptions | null = null;
96
97  // Internal methods
98
99  _cleanupForUnloadedRecorder = async (finalStatus?: RecordingStatus) => {
100    this._canRecord = false;
101    this._isDoneRecording = true;
102    this._finalDurationMillis = finalStatus?.durationMillis ?? 0;
103    _recorderExists = false;
104    if (this._subscription) {
105      this._subscription.remove();
106      this._subscription = null;
107    }
108    this._disablePolling();
109    return await this.getStatusAsync(); // Automatically calls onRecordingStatusUpdate for the final state.
110  };
111
112  _pollingLoop = async () => {
113    if (isAudioEnabled() && this._canRecord && this._onRecordingStatusUpdate != null) {
114      this._progressUpdateTimeoutVariable = setTimeout(
115        this._pollingLoop,
116        this._progressUpdateIntervalMillis
117      ) as any;
118      try {
119        await this.getStatusAsync();
120      } catch {
121        this._disablePolling();
122      }
123    }
124  };
125
126  _disablePolling() {
127    if (this._progressUpdateTimeoutVariable != null) {
128      clearTimeout(this._progressUpdateTimeoutVariable);
129      this._progressUpdateTimeoutVariable = null;
130    }
131  }
132
133  _enablePollingIfNecessaryAndPossible() {
134    if (isAudioEnabled() && this._canRecord && this._onRecordingStatusUpdate != null) {
135      this._disablePolling();
136      this._pollingLoop();
137    }
138  }
139
140  _callOnRecordingStatusUpdateForNewStatus(status: RecordingStatus) {
141    if (this._onRecordingStatusUpdate != null) {
142      this._onRecordingStatusUpdate(status);
143    }
144  }
145
146  async _performOperationAndHandleStatusAsync(
147    operation: () => Promise<RecordingStatus>
148  ): Promise<RecordingStatus> {
149    throwIfAudioIsDisabled();
150    if (this._canRecord) {
151      const status = await operation();
152      this._callOnRecordingStatusUpdateForNewStatus(status);
153      return status;
154    } else {
155      throw new Error('Cannot complete operation because this recorder is not ready to record.');
156    }
157  }
158
159  /**
160   * Creates and starts a recording using the given options, with optional `onRecordingStatusUpdate` and `progressUpdateIntervalMillis`.
161   *
162   * ```ts
163   * const { recording, status } = await Audio.Recording.createAsync(
164   *   options,
165   *   onRecordingStatusUpdate,
166   *   progressUpdateIntervalMillis
167   * );
168   *
169   * // Which is equivalent to the following:
170   * const recording = new Audio.Recording();
171   * await recording.prepareToRecordAsync(options);
172   * recording.setOnRecordingStatusUpdate(onRecordingStatusUpdate);
173   * await recording.startAsync();
174   * ```
175   *
176   * @param options Options for the recording, including sample rate, bitrate, channels, format, encoder, and extension. If no options are passed to,
177   * the recorder will be created with options `Audio.RecordingOptionsPresets.LOW_QUALITY`. See below for details on `RecordingOptions`.
178   * @param onRecordingStatusUpdate A function taking a single parameter `status` (a dictionary, described in `getStatusAsync`).
179   * @param progressUpdateIntervalMillis The interval between calls of `onRecordingStatusUpdate`. This value defaults to 500 milliseconds.
180   *
181   * @example
182   * ```ts
183   * try {
184   *   const { recording: recordingObject, status } = await Audio.Recording.createAsync(
185   *     Audio.RecordingOptionsPresets.HIGH_QUALITY
186   *   );
187   *   // You are now recording!
188   * } catch (error) {
189   *   // An error occurred!
190   * }
191   * ```
192   *
193   * @return A `Promise` that is rejected if creation failed, or fulfilled with the following dictionary if creation succeeded.
194   */
195  static createAsync = async (
196    options: RecordingOptions = RecordingOptionsPresets.LOW_QUALITY,
197    onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null = null,
198    progressUpdateIntervalMillis: number | null = null
199  ): Promise<RecordingObject> => {
200    const recording: Recording = new Recording();
201    if (progressUpdateIntervalMillis) {
202      recording._progressUpdateIntervalMillis = progressUpdateIntervalMillis;
203    }
204    recording.setOnRecordingStatusUpdate(onRecordingStatusUpdate);
205    await recording.prepareToRecordAsync({
206      ...options,
207      keepAudioActiveHint: true,
208    });
209    try {
210      const status = await recording.startAsync();
211      return { recording, status };
212    } catch (err) {
213      recording.stopAndUnloadAsync();
214      throw err;
215    }
216  };
217
218  // Get status API
219
220  /**
221   * Gets the `status` of the `Recording`.
222   * @return A `Promise` that is resolved with the `RecordingStatus` object.
223   */
224  getStatusAsync = async (): Promise<RecordingStatus> => {
225    // Automatically calls onRecordingStatusUpdate.
226    if (this._canRecord) {
227      return this._performOperationAndHandleStatusAsync(() => ExponentAV.getAudioRecordingStatus());
228    }
229    const status = {
230      canRecord: false,
231      isRecording: false,
232      isDoneRecording: this._isDoneRecording,
233      durationMillis: this._finalDurationMillis,
234    };
235    this._callOnRecordingStatusUpdateForNewStatus(status);
236    return status;
237  };
238
239  /**
240   * Sets a function to be called regularly with the `RecordingStatus` of the `Recording`.
241   *
242   * `onRecordingStatusUpdate` will be called when another call to the API for this recording completes (such as `prepareToRecordAsync()`,
243   * `startAsync()`, `getStatusAsync()`, or `stopAndUnloadAsync()`), and will also be called at regular intervals while the recording can record.
244   * Call `setProgressUpdateInterval()` to modify the interval with which `onRecordingStatusUpdate` is called while the recording can record.
245   *
246   * @param onRecordingStatusUpdate A function taking a single parameter `RecordingStatus`.
247   */
248  setOnRecordingStatusUpdate(onRecordingStatusUpdate: ((status: RecordingStatus) => void) | null) {
249    this._onRecordingStatusUpdate = onRecordingStatusUpdate;
250    if (onRecordingStatusUpdate == null) {
251      this._disablePolling();
252    } else {
253      this._enablePollingIfNecessaryAndPossible();
254    }
255    this.getStatusAsync();
256  }
257
258  /**
259   * Sets the interval with which `onRecordingStatusUpdate` is called while the recording can record.
260   * See `setOnRecordingStatusUpdate` for details. This value defaults to 500 milliseconds.
261   * @param progressUpdateIntervalMillis The new interval between calls of `onRecordingStatusUpdate`.
262   */
263  setProgressUpdateInterval(progressUpdateIntervalMillis: number) {
264    this._progressUpdateIntervalMillis = progressUpdateIntervalMillis;
265    this.getStatusAsync();
266  }
267
268  // Record API
269
270  /**
271   * Loads the recorder into memory and prepares it for recording. This must be called before calling `startAsync()`.
272   * This method can only be called if the `Recording` instance has never yet been prepared.
273   *
274   * @param options `RecordingOptions` for the recording, including sample rate, bitrate, channels, format, encoder, and extension.
275   * If no options are passed to `prepareToRecordAsync()`, the recorder will be created with options `Audio.RecordingOptionsPresets.LOW_QUALITY`.
276   *
277   * @return A `Promise` that is fulfilled when the recorder is loaded and prepared, or rejects if this failed. If another `Recording` exists
278   * in your experience that is currently prepared to record, the `Promise` will reject. If the `RecordingOptions` provided are invalid,
279   * the `Promise` will also reject. The promise is resolved with the `RecordingStatus` of the recording.
280   */
281  async prepareToRecordAsync(
282    options: RecordingOptions = RecordingOptionsPresets.LOW_QUALITY
283  ): Promise<RecordingStatus> {
284    throwIfAudioIsDisabled();
285
286    if (_recorderExists) {
287      throw new Error('Only one Recording object can be prepared at a given time.');
288    }
289
290    if (this._isDoneRecording) {
291      throw new Error('This Recording object is done recording; you must make a new one.');
292    }
293
294    if (!options || !options.android || !options.ios) {
295      throw new Error(
296        'You must provide recording options for android and ios in order to prepare to record.'
297      );
298    }
299
300    const extensionRegex = /^\.\w+$/;
301    if (
302      !options.android.extension ||
303      !options.ios.extension ||
304      !extensionRegex.test(options.android.extension) ||
305      !extensionRegex.test(options.ios.extension)
306    ) {
307      throw new Error(`Your file extensions must match ${extensionRegex.toString()}.`);
308    }
309
310    if (!this._canRecord) {
311      if (eventEmitter) {
312        this._subscription = eventEmitter.addListener(
313          'Expo.Recording.recorderUnloaded',
314          this._cleanupForUnloadedRecorder
315        );
316      }
317
318      const {
319        uri,
320        status,
321      }: {
322        uri: string | null;
323        // status is of type RecordingStatus, but without the canRecord field populated
324        status: Pick<RecordingStatus, Exclude<keyof RecordingStatus, 'canRecord'>>;
325      } = await ExponentAV.prepareAudioRecorder(options);
326      _recorderExists = true;
327      this._uri = uri;
328      this._options = options;
329      this._canRecord = true;
330
331      const currentStatus = { ...status, canRecord: true };
332      this._callOnRecordingStatusUpdateForNewStatus(currentStatus);
333      this._enablePollingIfNecessaryAndPossible();
334      return currentStatus;
335    } else {
336      throw new Error('This Recording object is already prepared to record.');
337    }
338  }
339
340  /**
341   * Returns a list of available recording inputs. This method can only be called if the `Recording` has been prepared.
342   * @return A `Promise` that is fulfilled with an array of `RecordingInput` objects.
343   */
344  async getAvailableInputs(): Promise<RecordingInput[]> {
345    return ExponentAV.getAvailableInputs();
346  }
347
348  /**
349   * Returns the currently-selected recording input. This method can only be called if the `Recording` has been prepared.
350   * @return A `Promise` that is fulfilled with a `RecordingInput` object.
351   */
352  async getCurrentInput(): Promise<RecordingInput> {
353    return ExponentAV.getCurrentInput();
354  }
355
356  /**
357   * Sets the current recording input.
358   * @param inputUid The uid of a `RecordingInput`.
359   * @return A `Promise` that is resolved if successful or rejected if not.
360   */
361  async setInput(inputUid: string): Promise<void> {
362    return ExponentAV.setInput(inputUid);
363  }
364
365  /**
366   * Begins recording. This method can only be called if the `Recording` has been prepared.
367   * @return A `Promise` that is fulfilled when recording has begun, or rejects if recording could not be started.
368   * The promise is resolved with the `RecordingStatus` of the recording.
369   */
370  async startAsync(): Promise<RecordingStatus> {
371    return this._performOperationAndHandleStatusAsync(() => ExponentAV.startAudioRecording());
372  }
373
374  /**
375   * Pauses recording. This method can only be called if the `Recording` has been prepared.
376   *
377   * > This is only available on Android API version 24 and later.
378   *
379   * @return A `Promise` that is fulfilled when recording has paused, or rejects if recording could not be paused.
380   * If the Android API version is less than 24, the `Promise` will reject. The promise is resolved with the
381   * `RecordingStatus` of the recording.
382   */
383  async pauseAsync(): Promise<RecordingStatus> {
384    return this._performOperationAndHandleStatusAsync(() => ExponentAV.pauseAudioRecording());
385  }
386
387  /**
388   * Stops the recording and deallocates the recorder from memory. This reverts the `Recording` instance
389   * to an unprepared state, and another `Recording` instance must be created in order to record again.
390   * This method can only be called if the `Recording` has been prepared.
391   *
392   * > On Android this method may fail with `E_AUDIO_NODATA` when called too soon after `startAsync` and
393   * > no audio data has been recorded yet. In that case the recorded file will be invalid and should be discarded.
394   *
395   * @return A `Promise` that is fulfilled when recording has stopped, or rejects if recording could not be stopped.
396   * The promise is resolved with the `RecordingStatus` of the recording.
397   */
398  async stopAndUnloadAsync(): Promise<RecordingStatus> {
399    if (!this._canRecord) {
400      if (this._isDoneRecording) {
401        throw new Error('Cannot unload a Recording that has already been unloaded.');
402      } else {
403        throw new Error('Cannot unload a Recording that has not been prepared.');
404      }
405    }
406    // We perform a separate native API call so that the state of the Recording can be updated with
407    // the final duration of the recording. (We cast stopStatus as Object to appease Flow)
408    let stopResult: RecordingStatus | undefined;
409    let stopError: Error | undefined;
410    try {
411      stopResult = await ExponentAV.stopAudioRecording();
412    } catch (err) {
413      stopError = err;
414    }
415
416    // Web has to return the URI at the end of recording, so needs a little destructuring
417    if (Platform.OS === 'web' && stopResult?.uri !== undefined) {
418      this._uri = stopResult.uri;
419    }
420
421    // Clean-up and return status
422    await ExponentAV.unloadAudioRecorder();
423    const status = await this._cleanupForUnloadedRecorder(stopResult);
424    return stopError ? Promise.reject(stopError) : status;
425  }
426
427  // Read API
428
429  /**
430   * Gets the local URI of the `Recording`. Note that this will only succeed once the `Recording` is prepared
431   * to record. On web, this will not return the URI until the recording is finished.
432   * @return A `string` with the local URI of the `Recording`, or `null` if the `Recording` is not prepared
433   * to record (or, on Web, if the recording has not finished).
434   */
435  getURI(): string | null {
436    return this._uri;
437  }
438
439  /**
440   * @deprecated Use `createNewLoadedSoundAsync()` instead.
441   */
442  async createNewLoadedSound(
443    initialStatus: AVPlaybackStatusToSet = {},
444    onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null
445  ): Promise<SoundObject> {
446    console.warn(
447      `createNewLoadedSound is deprecated in favor of createNewLoadedSoundAsync, which has the same API aside from the method name`
448    );
449    return this.createNewLoadedSoundAsync(initialStatus, onPlaybackStatusUpdate);
450  }
451
452  /**
453   * Creates and loads a new `Sound` object to play back the `Recording`. Note that this will only succeed once the `Recording`
454   * is done recording and `stopAndUnloadAsync()` has been called.
455   *
456   * @param initialStatus The initial intended `PlaybackStatusToSet` of the sound, whose values will override the default initial playback status.
457   * This value defaults to `{}` if no parameter is passed. See the [AV documentation](/versions/latest/sdk/av) for details on `PlaybackStatusToSet`
458   * and the default initial playback status.
459   * @param onPlaybackStatusUpdate A function taking a single parameter `PlaybackStatus`. This value defaults to `null` if no parameter is passed.
460   * See the [AV documentation](/versions/latest/sdk/av) for details on the functionality provided by `onPlaybackStatusUpdate`
461   *
462   * @return A `Promise` that is rejected if creation failed, or fulfilled with the `SoundObject`.
463   */
464  async createNewLoadedSoundAsync(
465    initialStatus: AVPlaybackStatusToSet = {},
466    onPlaybackStatusUpdate: ((status: AVPlaybackStatus) => void) | null = null
467  ): Promise<SoundObject> {
468    if (this._uri == null || !this._isDoneRecording) {
469      throw new Error('Cannot create sound when the Recording has not finished!');
470    }
471    return Sound.createAsync(
472      // $FlowFixMe: Flow can't distinguish between this literal and Asset
473      { uri: this._uri },
474      initialStatus,
475      onPlaybackStatusUpdate,
476      false
477    );
478  }
479}
480
481export { PermissionResponse, PermissionStatus, PermissionHookOptions };
482
483export * from './RecordingConstants';
484
485export * from './Recording.types';
486