@siteed/expo-audio-studio 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +25 -0
  3. package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
  5. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
  6. package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
  7. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
  8. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +576 -252
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +419 -155
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  13. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  14. package/build/AudioDeviceManager.d.ts +107 -0
  15. package/build/AudioDeviceManager.d.ts.map +1 -0
  16. package/build/AudioDeviceManager.js +493 -0
  17. package/build/AudioDeviceManager.js.map +1 -0
  18. package/build/AudioRecorder.provider.d.ts.map +1 -1
  19. package/build/AudioRecorder.provider.js +3 -0
  20. package/build/AudioRecorder.provider.js.map +1 -1
  21. package/build/ExpoAudioStream.types.d.ts +90 -1
  22. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  23. package/build/ExpoAudioStream.types.js +7 -1
  24. package/build/ExpoAudioStream.types.js.map +1 -1
  25. package/build/ExpoAudioStream.web.d.ts +37 -0
  26. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  27. package/build/ExpoAudioStream.web.js +399 -54
  28. package/build/ExpoAudioStream.web.js.map +1 -1
  29. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  30. package/build/ExpoAudioStreamModule.js +20 -0
  31. package/build/ExpoAudioStreamModule.js.map +1 -1
  32. package/build/WebRecorder.web.d.ts +63 -10
  33. package/build/WebRecorder.web.d.ts.map +1 -1
  34. package/build/WebRecorder.web.js +277 -68
  35. package/build/WebRecorder.web.js.map +1 -1
  36. package/build/hooks/useAudioDevices.d.ts +14 -0
  37. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  38. package/build/hooks/useAudioDevices.js +151 -0
  39. package/build/hooks/useAudioDevices.js.map +1 -0
  40. package/build/index.d.ts +2 -0
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +4 -0
  43. package/build/index.js.map +1 -1
  44. package/build/useAudioRecorder.d.ts +1 -0
  45. package/build/useAudioRecorder.d.ts.map +1 -1
  46. package/build/useAudioRecorder.js +20 -1
  47. package/build/useAudioRecorder.js.map +1 -1
  48. package/build/utils/BlobFix.d.ts.map +1 -1
  49. package/build/utils/BlobFix.js +2 -2
  50. package/build/utils/BlobFix.js.map +1 -1
  51. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  52. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  53. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  54. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  55. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  56. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  57. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  58. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  59. package/ios/AudioDeviceManager.swift +654 -0
  60. package/ios/AudioStreamManager.swift +964 -760
  61. package/ios/ExpoAudioStreamModule.swift +174 -19
  62. package/ios/Features.swift +1 -1
  63. package/ios/ISSUE_IOS.md +45 -0
  64. package/ios/Logger.swift +13 -1
  65. package/ios/RecordingSettings.swift +12 -0
  66. package/package.json +2 -2
  67. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  68. package/src/AudioDeviceManager.ts +571 -0
  69. package/src/AudioRecorder.provider.tsx +3 -0
  70. package/src/ExpoAudioStream.types.ts +97 -1
  71. package/src/ExpoAudioStream.web.ts +513 -63
  72. package/src/ExpoAudioStreamModule.ts +23 -0
  73. package/src/WebRecorder.web.ts +346 -81
  74. package/src/hooks/useAudioDevices.ts +180 -0
  75. package/src/index.ts +6 -0
  76. package/src/types/crc-32.d.ts +6 -6
  77. package/src/useAudioRecorder.tsx +27 -1
  78. package/src/utils/BlobFix.ts +6 -4
  79. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  80. package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
@@ -8,12 +8,24 @@ import {
8
8
  BitDepth,
9
9
  ConsoleLike,
10
10
  RecordingConfig,
11
+ RecordingInterruptionReason,
11
12
  StartRecordingResult,
12
13
  } from './ExpoAudioStream.types'
13
14
  import { WebRecorder } from './WebRecorder.web'
14
15
  import { AudioEventPayload } from './events'
15
16
  import { encodingToBitDepth } from './utils/encodingToBitDepth'
16
17
 
18
+ export interface AudioStreamEvent {
19
+ type: string
20
+ device?: string
21
+ timestamp: Date
22
+ }
23
+
24
+ export interface ExpoAudioStreamOptions {
25
+ logger?: ConsoleLike
26
+ eventCallback?: (event: AudioStreamEvent) => void
27
+ }
28
+
17
29
  export interface EmitAudioEventProps {
18
30
  data: Float32Array
19
31
  position: number
@@ -61,6 +73,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
61
73
  latestPosition: number = 0
62
74
  totalCompressedSize: number = 0
63
75
  private readonly maxBufferSize: number
76
+ private eventCallback?: (event: AudioStreamEvent) => void
64
77
 
65
78
  constructor({
66
79
  audioWorkletUrl,
@@ -69,12 +82,8 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
69
82
  maxBufferSize = 100, // Default to storing last 100 chunks (1 chunk = 0.5 seconds)
70
83
  }: ExpoAudioStreamWebProps) {
71
84
  const mockNativeModule = {
72
- addListener: () => {
73
- // Not used on web
74
- },
75
- removeListeners: () => {
76
- // Not used on web
77
- },
85
+ addListener: () => {},
86
+ removeListeners: () => {},
78
87
  }
79
88
  super(mockNativeModule) // Pass the mock native module to the parent class
80
89
 
@@ -111,6 +120,50 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
111
120
  }
112
121
  }
113
122
 
123
+ // Prepare recording with options
124
+ async prepareRecording(
125
+ recordingConfig: RecordingConfig = {}
126
+ ): Promise<boolean> {
127
+ if (this.isRecording) {
128
+ this.logger?.warn(
129
+ 'Cannot prepare: Recording is already in progress'
130
+ )
131
+ return false
132
+ }
133
+
134
+ try {
135
+ // Check permissions and initialize basic settings
136
+ await this.getMediaStream().then((stream) => {
137
+ // Just verify we can access the microphone by getting a stream, then release it
138
+ stream.getTracks().forEach((track) => track.stop())
139
+ })
140
+
141
+ this.bitDepth = encodingToBitDepth({
142
+ encoding: recordingConfig.encoding ?? 'pcm_32bit',
143
+ })
144
+
145
+ // Store recording configuration for later use
146
+ this.recordingConfig = recordingConfig
147
+
148
+ // Use custom filename if provided, otherwise fallback to timestamp
149
+ if (recordingConfig.filename) {
150
+ // Remove any existing extension from the filename
151
+ this.streamUuid = recordingConfig.filename.replace(
152
+ /\.[^/.]+$/,
153
+ ''
154
+ )
155
+ } else {
156
+ this.streamUuid = Date.now().toString()
157
+ }
158
+
159
+ this.logger?.debug('Recording preparation completed successfully')
160
+ return true
161
+ } catch (error) {
162
+ this.logger?.error('Error preparing recording:', error)
163
+ return false
164
+ }
165
+ }
166
+
114
167
  // Start recording with options
115
168
  async startRecording(
116
169
  recordingConfig: RecordingConfig = {}
@@ -119,9 +172,22 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
119
172
  throw new Error('Recording is already in progress')
120
173
  }
121
174
 
122
- this.bitDepth = encodingToBitDepth({
123
- encoding: recordingConfig.encoding ?? 'pcm_32bit',
124
- })
175
+ // If we haven't prepared or have different settings, prepare now
176
+ if (
177
+ !this.recordingConfig ||
178
+ this.recordingConfig.sampleRate !== recordingConfig.sampleRate ||
179
+ this.recordingConfig.channels !== recordingConfig.channels ||
180
+ this.recordingConfig.encoding !== recordingConfig.encoding
181
+ ) {
182
+ await this.prepareRecording(recordingConfig)
183
+ } else {
184
+ this.logger?.debug(
185
+ 'Using previously prepared recording configuration'
186
+ )
187
+ }
188
+
189
+ // Save recording config for reference
190
+ this.recordingConfig = recordingConfig
125
191
 
126
192
  const audioContext = new (window.AudioContext ||
127
193
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -136,39 +202,15 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
136
202
  audioContext,
137
203
  source,
138
204
  recordingConfig,
139
- emitAudioEventCallback: ({
140
- data,
141
- position,
142
- compression,
143
- }: EmitAudioEventProps) => {
144
- // Keep only the latest chunks based on maxBufferSize
145
- this.audioChunks.push(new Float32Array(data))
146
- if (this.audioChunks.length > this.maxBufferSize) {
147
- this.audioChunks.shift() // Remove oldest chunk
148
- }
149
- this.currentSize += data.byteLength
150
- this.emitAudioEvent({ data, position, compression })
151
- this.lastEmittedTime = Date.now()
152
- this.lastEmittedSize = this.currentSize
153
- this.lastEmittedCompressionSize = compression?.size ?? 0
154
- },
155
- emitAudioAnalysisCallback: (audioAnalysisData: AudioAnalysis) => {
156
- this.logger?.log(`Emitted AudioAnalysis:`, audioAnalysisData)
157
- this.emit('AudioAnalysis', audioAnalysisData)
158
- },
205
+ emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
206
+ emitAudioAnalysisCallback:
207
+ this.customRecorderAnalysisCallback.bind(this),
208
+ onInterruption: this.handleRecordingInterruption.bind(this),
159
209
  })
160
210
  await this.customRecorder.init()
161
211
  this.customRecorder.start()
162
212
 
163
- // // Set a timer to stop recording after 5 seconds
164
- // setTimeout(() => {
165
- // logger.log("AUTO Stopping recording");
166
- // this.customRecorder?.stopAndPlay();
167
- // this.isRecording = false;
168
- // }, 3000);
169
-
170
213
  this.isRecording = true
171
- this.recordingConfig = recordingConfig
172
214
  this.recordingStartTime = Date.now()
173
215
  this.pausedTime = 0
174
216
  this.isPaused = false
@@ -208,14 +250,124 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
208
250
  return streamConfig
209
251
  }
210
252
 
253
+ /**
254
+ * Centralized handler for recording interruptions
255
+ */
256
+ private handleRecordingInterruption(event: {
257
+ reason: RecordingInterruptionReason | string
258
+ isPaused: boolean
259
+ timestamp: number
260
+ message?: string
261
+ }): void {
262
+ this.logger?.debug(`Received recording interruption: ${event.reason}`)
263
+
264
+ // Update local state if the interruption should pause recording
265
+ if (event.isPaused) {
266
+ this.isPaused = true
267
+
268
+ // If this is a device disconnection, handle according to behavior setting
269
+ if (event.reason === 'deviceDisconnected') {
270
+ this.pausedTime = Date.now()
271
+
272
+ // Check if we should try fallback to another device
273
+ if (
274
+ this.recordingConfig?.deviceDisconnectionBehavior ===
275
+ 'fallback'
276
+ ) {
277
+ this.logger?.debug(
278
+ 'Device disconnected with fallback behavior - attempting to switch to default device'
279
+ )
280
+
281
+ // Try to restart with default device
282
+ this.handleDeviceFallback().catch((error) => {
283
+ // If fallback fails, emit warning
284
+ this.logger?.error('Device fallback failed:', error)
285
+ this.emit('onRecordingInterrupted', {
286
+ reason: 'deviceSwitchFailed',
287
+ isPaused: true,
288
+ timestamp: Date.now(),
289
+ message:
290
+ 'Failed to switch to fallback device. Recording paused.',
291
+ })
292
+ })
293
+ } else {
294
+ // Just warn about disconnection if fallback not enabled
295
+ this.logger?.warn(
296
+ 'Device disconnected - recording paused automatically'
297
+ )
298
+ this.emit('onRecordingInterrupted', event)
299
+ }
300
+ } else {
301
+ // For other interruption types, just emit the event
302
+ this.emit('onRecordingInterrupted', event)
303
+ }
304
+ } else {
305
+ // If not causing a pause, just forward the event
306
+ this.emit('onRecordingInterrupted', event)
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Handler for audio events from the WebRecorder
312
+ */
313
+ private customRecorderEventCallback({
314
+ data,
315
+ position,
316
+ compression,
317
+ }: EmitAudioEventProps): void {
318
+ // Keep only the latest chunks based on maxBufferSize
319
+ this.audioChunks.push(new Float32Array(data))
320
+ if (this.audioChunks.length > this.maxBufferSize) {
321
+ this.audioChunks.shift() // Remove oldest chunk
322
+ }
323
+ this.currentSize += data.byteLength
324
+ this.emitAudioEvent({ data, position, compression })
325
+ this.lastEmittedTime = Date.now()
326
+ this.lastEmittedSize = this.currentSize
327
+ this.lastEmittedCompressionSize = compression?.size ?? 0
328
+ }
329
+
330
+ /**
331
+ * Handler for audio analysis events from the WebRecorder
332
+ */
333
+ private customRecorderAnalysisCallback(
334
+ audioAnalysisData: AudioAnalysis
335
+ ): void {
336
+ this.emit('AudioAnalysis', audioAnalysisData)
337
+ }
338
+
339
+ // Get recording duration
340
+ private getRecordingDuration(): number {
341
+ if (!this.isRecording) {
342
+ return 0
343
+ }
344
+
345
+ return this.currentDurationMs
346
+ }
347
+
211
348
  emitAudioEvent({ data, position, compression }: EmitAudioEventProps) {
212
349
  const fileUri = `${this.streamUuid}.${this.extension}`
213
350
  if (compression?.size) {
214
351
  this.lastEmittedCompressionSize = compression.size
215
352
  this.totalCompressedSize = compression.totalSize
216
353
  }
354
+
355
+ // Update latest position for tracking
217
356
  this.latestPosition = position
218
- this.currentDurationMs = position * 1000 // Convert position (in seconds) to ms
357
+
358
+ // Calculate duration of this chunk in ms
359
+ const sampleRate = this.recordingConfig?.sampleRate || 44100
360
+ const chunkDurationMs = (data.length / sampleRate) * 1000
361
+
362
+ // Handle duration calculation
363
+ if (this.customRecorder?.isFirstChunkAfterSwitch) {
364
+ this.logger?.debug(
365
+ `Processing first chunk after device switch, duration preserved at ${this.currentDurationMs}ms`
366
+ )
367
+ this.customRecorder.isFirstChunkAfterSwitch = false
368
+ } else {
369
+ this.currentDurationMs += chunkDurationMs
370
+ }
219
371
 
220
372
  const audioEventPayload: AudioEventPayload = {
221
373
  fileUri,
@@ -245,21 +397,19 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
245
397
  throw new Error('Recorder is not initialized')
246
398
  }
247
399
 
248
- this.logger?.debug('[Stop] Starting stop process')
249
- const startTime = performance.now()
400
+ this.logger?.debug('Starting stop process')
250
401
 
251
402
  try {
252
- this.logger?.debug('[Stop] Stopping recorder')
253
403
  const { compressedBlob } = await this.customRecorder.stop()
254
404
 
255
405
  this.isRecording = false
256
406
  this.isPaused = false
257
- this.currentDurationMs = Date.now() - this.recordingStartTime
258
407
 
259
408
  let compression: AudioRecording['compression']
260
409
  let fileUri = `${this.streamUuid}.${this.extension}`
261
410
  let mimeType = `audio/${this.extension}`
262
411
 
412
+ // Process compressed audio if available
263
413
  if (compressedBlob && this.recordingConfig?.compression?.enabled) {
264
414
  const compressedUri = URL.createObjectURL(compressedBlob)
265
415
  compression = {
@@ -269,24 +419,17 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
269
419
  format: 'opus',
270
420
  bitrate: this.recordingConfig.compression.bitrate ?? 128000,
271
421
  }
422
+
272
423
  // Use compressed values when compression is enabled
273
424
  fileUri = compressedUri
274
425
  mimeType = 'audio/webm'
275
426
  }
276
427
 
277
- this.logger?.debug(
278
- `[Stop] Completed stop process in ${performance.now() - startTime}ms`,
279
- {
280
- durationMs: this.currentDurationMs,
281
- compressedSize: compression?.size,
282
- }
283
- )
284
-
285
- // Use the stored streamUuid (which contains our custom filename) for the final filename
428
+ // Use the stored streamUuid for the final filename
286
429
  const filename = `${this.streamUuid}.${this.extension}`
287
430
  const result: AudioRecording = {
288
431
  fileUri,
289
- filename, // This will now use our custom filename
432
+ filename,
290
433
  bitDepth: this.bitDepth,
291
434
  createdAt: this.recordingStartTime,
292
435
  channels: this.recordingConfig?.channels ?? 1,
@@ -302,22 +445,34 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
302
445
 
303
446
  return result
304
447
  } catch (error) {
305
- this.logger?.error('[Stop] Error stopping recording:', error)
448
+ this.logger?.error('Error stopping recording:', error)
306
449
  throw error
307
450
  }
308
451
  }
309
452
 
310
453
  // Pause recording
311
454
  async pauseRecording() {
312
- if (!this.isRecording || this.isPaused) {
313
- throw new Error('Recording is not active or already paused')
455
+ if (!this.isRecording) {
456
+ throw new Error('Recording is not active')
314
457
  }
315
458
 
316
- if (this.customRecorder) {
317
- this.customRecorder.pause()
459
+ if (this.isPaused) {
460
+ this.logger?.debug('Recording already paused, skipping')
461
+ return
462
+ }
463
+
464
+ try {
465
+ if (this.customRecorder) {
466
+ this.customRecorder.pause()
467
+ }
468
+ this.isPaused = true
469
+ this.pausedTime = Date.now()
470
+ } catch (error) {
471
+ this.logger?.error('Error in pauseRecording', error)
472
+ // Even if the pause operation failed, make sure our state is consistent
473
+ this.isPaused = true
474
+ this.pausedTime = Date.now()
318
475
  }
319
- this.isPaused = true
320
- this.pausedTime = Date.now()
321
476
  }
322
477
 
323
478
  // Resume recording
@@ -326,19 +481,61 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
326
481
  throw new Error('Recording is not paused')
327
482
  }
328
483
 
329
- if (this.customRecorder) {
484
+ this.logger?.debug('Resuming recording', {
485
+ deviceDisconnectionBehavior:
486
+ this.recordingConfig?.deviceDisconnectionBehavior,
487
+ isDeviceDisconnected: this.customRecorder?.isDeviceDisconnected,
488
+ })
489
+
490
+ try {
491
+ // If we have no recorder, or if the device is disconnected, always attempt fallback
492
+ if (
493
+ !this.customRecorder ||
494
+ this.customRecorder.isDeviceDisconnected
495
+ ) {
496
+ this.logger?.debug(
497
+ 'No recorder exists or device disconnected - attempting fallback on resume'
498
+ )
499
+ await this.handleDeviceFallback()
500
+ // handleDeviceFallback will manage resuming if successful, or emit error if failed.
501
+ return
502
+ }
503
+
504
+ // Normal resume path - device is still connected
330
505
  this.customRecorder.resume()
506
+ this.isPaused = false
507
+
508
+ // Adjust the recording start time to account for the pause duration
509
+ const pauseDuration = Date.now() - this.pausedTime
510
+ this.recordingStartTime += pauseDuration
511
+ this.pausedTime = 0
512
+
513
+ this.emit('onRecordingInterrupted', {
514
+ reason: 'userResumed',
515
+ isPaused: false,
516
+ timestamp: Date.now(),
517
+ })
518
+ } catch (error) {
519
+ this.logger?.error('Resume failed:', error)
520
+ // Fallback to emitting a general failure if resume fails unexpectedly
521
+ this.emit('onRecordingInterrupted', {
522
+ reason: 'resumeFailed', // Use a more specific reason
523
+ isPaused: true, // Remain paused if resume fails
524
+ timestamp: Date.now(),
525
+ message:
526
+ 'Failed to resume recording. Please stop and start again.',
527
+ })
331
528
  }
332
- this.isPaused = false
333
- this.recordingStartTime += Date.now() - this.pausedTime
334
529
  }
335
530
 
336
531
  // Get current status
337
532
  status() {
533
+ const durationMs = this.getRecordingDuration()
534
+
338
535
  const status: AudioStreamStatus = {
339
536
  isRecording: this.isRecording,
340
537
  isPaused: this.isPaused,
341
- durationMs: this.currentDurationMs,
538
+ durationMs,
342
539
  size: this.currentSize,
343
540
  interval: this.currentInterval,
344
541
  intervalAnalysis: this.currentIntervalAnalysis,
@@ -356,4 +553,257 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
356
553
  }
357
554
  return status
358
555
  }
556
+
557
+ /**
558
+ * Handles device fallback when the current device is disconnected
559
+ */
560
+ private async handleDeviceFallback(): Promise<boolean> {
561
+ this.logger?.debug('Starting device fallback procedure')
562
+
563
+ if (!this.isRecording) {
564
+ return false
565
+ }
566
+
567
+ try {
568
+ // Save important state before switching
569
+ const currentPosition = this.latestPosition
570
+ const existingAudioChunks = [...this.audioChunks]
571
+
572
+ // Save compressed chunks if available
573
+ let compressedChunks: Blob[] = []
574
+ if (this.customRecorder) {
575
+ try {
576
+ compressedChunks = this.customRecorder.getCompressedChunks()
577
+ } catch (err) {
578
+ this.logger?.warn('Failed to get compressed chunks:', err)
579
+ }
580
+ }
581
+
582
+ // Save the current counter value for continuity
583
+ let currentDataPointCounter = 0
584
+ if (this.customRecorder) {
585
+ currentDataPointCounter =
586
+ this.customRecorder.getDataPointCounter()
587
+ }
588
+
589
+ // Clean up existing recorder
590
+ if (this.customRecorder) {
591
+ try {
592
+ this.customRecorder.cleanup()
593
+ } catch (cleanupError) {
594
+ this.logger?.warn('Error during cleanup:', cleanupError)
595
+ }
596
+ }
597
+
598
+ // Keep recording state true but mark as paused
599
+ this.isPaused = true
600
+ this.pausedTime = Date.now()
601
+
602
+ // Store current size and other stats
603
+ const previousTotalSize = this.currentSize
604
+ const previousLastEmittedSize = this.lastEmittedSize
605
+ const previousCompressedSize = this.totalCompressedSize
606
+
607
+ // Try to get a fallback device
608
+ const fallbackDeviceInfo = await this.getFallbackDevice()
609
+ if (!fallbackDeviceInfo) {
610
+ this.emit('onRecordingInterrupted', {
611
+ reason: 'deviceSwitchFailed',
612
+ isPaused: true,
613
+ timestamp: Date.now(),
614
+ message:
615
+ 'Failed to switch to fallback device. Recording paused.',
616
+ })
617
+ return false
618
+ }
619
+
620
+ // Start recording with the new device
621
+ try {
622
+ const stream = await this.requestPermissionsAndGetUserMedia(
623
+ fallbackDeviceInfo.deviceId
624
+ )
625
+ const audioContext = new (window.AudioContext ||
626
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
627
+ // @ts-ignore - Allow webkitAudioContext for Safari
628
+ window.webkitAudioContext)()
629
+
630
+ const source = audioContext.createMediaStreamSource(stream)
631
+
632
+ // Create a new recorder with the fallback device
633
+ this.customRecorder = new WebRecorder({
634
+ logger: this.logger,
635
+ audioContext,
636
+ source,
637
+ recordingConfig: this.recordingConfig || {},
638
+ emitAudioEventCallback:
639
+ this.customRecorderEventCallback.bind(this),
640
+ emitAudioAnalysisCallback:
641
+ this.customRecorderAnalysisCallback.bind(this),
642
+ onInterruption: this.handleRecordingInterruption.bind(this),
643
+ })
644
+
645
+ await this.customRecorder.init()
646
+
647
+ // Set the initial position to continue from the previous device
648
+ this.customRecorder.setPosition(currentPosition)
649
+
650
+ // Reset the data point counter to continue from where the previous device left off
651
+ if (currentDataPointCounter > 0) {
652
+ this.customRecorder.resetDataPointCounter(
653
+ currentDataPointCounter
654
+ )
655
+ }
656
+
657
+ // Prepare the recorder to handle the device switch properly
658
+ this.customRecorder.prepareForDeviceSwitch()
659
+
660
+ // Restore the existing audio chunks
661
+ if (existingAudioChunks.length > 0) {
662
+ this.audioChunks = existingAudioChunks
663
+ }
664
+
665
+ // Restore compressed chunks if available
666
+ if (compressedChunks.length > 0) {
667
+ this.customRecorder.setCompressedChunks(compressedChunks)
668
+ }
669
+
670
+ // Start the new recorder while preserving counters
671
+ this.customRecorder.start(true)
672
+
673
+ // Update recording state
674
+ this.isPaused = false
675
+ this.recordingStartTime = Date.now()
676
+
677
+ // Restore size counters to maintain continuity
678
+ this.currentSize = previousTotalSize
679
+ this.lastEmittedSize = previousLastEmittedSize
680
+ this.totalCompressedSize = previousCompressedSize
681
+
682
+ // Notify that we switched to a fallback device
683
+ if (this.eventCallback) {
684
+ this.eventCallback({
685
+ type: 'deviceFallback',
686
+ device: fallbackDeviceInfo.deviceId,
687
+ timestamp: new Date(),
688
+ })
689
+ }
690
+ return true
691
+ } catch (error) {
692
+ this.logger?.error(
693
+ 'Failed to start recording with fallback device',
694
+ error
695
+ )
696
+ this.isPaused = true
697
+ this.emit('onRecordingInterrupted', {
698
+ reason: 'deviceSwitchFailed',
699
+ isPaused: true,
700
+ timestamp: Date.now(),
701
+ message:
702
+ 'Failed to switch to fallback device. Recording paused.',
703
+ })
704
+ return false
705
+ }
706
+ } catch (error) {
707
+ this.logger?.error('Failed to use fallback device', error)
708
+ this.isPaused = true
709
+ this.emit('onRecordingInterrupted', {
710
+ reason: 'deviceSwitchFailed',
711
+ isPaused: true,
712
+ timestamp: Date.now(),
713
+ message:
714
+ 'Failed to switch to fallback device. Recording paused.',
715
+ })
716
+ return false
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Attempts to get a fallback audio device
722
+ */
723
+ private async getFallbackDevice(): Promise<MediaDeviceInfo | null> {
724
+ try {
725
+ // Get list of available audio input devices
726
+ const devices = await navigator.mediaDevices.enumerateDevices()
727
+ const audioInputDevices = devices.filter(
728
+ (device) => device.kind === 'audioinput'
729
+ )
730
+
731
+ if (audioInputDevices.length === 0) {
732
+ return null
733
+ }
734
+
735
+ // Try to find a device that's not the current one
736
+ if (this.customRecorder) {
737
+ try {
738
+ // Use mediaDevices.enumerateDevices to find the current active device
739
+ const tracks = navigator.mediaDevices
740
+ .getUserMedia({ audio: true })
741
+ .then((stream) => {
742
+ const track = stream.getAudioTracks()[0]
743
+ return track ? track.label : ''
744
+ })
745
+ .catch(() => '')
746
+
747
+ const currentTrackLabel = await tracks
748
+
749
+ if (currentTrackLabel) {
750
+ // Find a device with a different label
751
+ const differentDevice = audioInputDevices.find(
752
+ (device) =>
753
+ device.label &&
754
+ device.label !== currentTrackLabel
755
+ )
756
+
757
+ if (differentDevice) {
758
+ return differentDevice
759
+ }
760
+ }
761
+ } catch (err) {
762
+ this.logger?.warn(
763
+ 'Error determining current device, using default'
764
+ )
765
+ }
766
+ }
767
+
768
+ // Return the first available device (default device)
769
+ return audioInputDevices[0]
770
+ } catch (error) {
771
+ this.logger?.error('Error finding fallback device:', error)
772
+ return null
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Gets user media with specific device ID
778
+ */
779
+ private async requestPermissionsAndGetUserMedia(
780
+ deviceId: string
781
+ ): Promise<MediaStream> {
782
+ try {
783
+ // Request the specific device
784
+ return await navigator.mediaDevices.getUserMedia({
785
+ audio: {
786
+ deviceId: { exact: deviceId },
787
+ },
788
+ })
789
+ } catch (error) {
790
+ this.logger?.error(
791
+ `Failed to get media for device ${deviceId}`,
792
+ error
793
+ )
794
+ // Try with default constraints as fallback
795
+ return await navigator.mediaDevices.getUserMedia({ audio: true })
796
+ }
797
+ }
798
+
799
+ init(options?: ExpoAudioStreamOptions): Promise<void> {
800
+ try {
801
+ this.logger = options?.logger
802
+ this.eventCallback = options?.eventCallback
803
+ return Promise.resolve()
804
+ } catch (error) {
805
+ this.logger?.error('Error initializing ExpoAudioStream', error)
806
+ return Promise.reject(error)
807
+ }
808
+ }
359
809
  }