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