@siteed/expo-audio-studio 2.4.1 → 2.6.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 (85) hide show
  1. package/CHANGELOG.md +14 -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 +104 -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 +478 -62
  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 +74 -11
  33. package/build/WebRecorder.web.d.ts.map +1 -1
  34. package/build/WebRecorder.web.js +390 -74
  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/utils/writeWavHeader.d.ts +3 -18
  52. package/build/utils/writeWavHeader.d.ts.map +1 -1
  53. package/build/utils/writeWavHeader.js +19 -26
  54. package/build/utils/writeWavHeader.js.map +1 -1
  55. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  56. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  57. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  58. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  59. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  60. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  61. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  62. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  63. package/ios/AudioDeviceManager.swift +654 -0
  64. package/ios/AudioStreamManager.swift +964 -760
  65. package/ios/ExpoAudioStreamModule.swift +174 -19
  66. package/ios/Features.swift +1 -1
  67. package/ios/ISSUE_IOS.md +45 -0
  68. package/ios/Logger.swift +13 -1
  69. package/ios/RecordingSettings.swift +12 -0
  70. package/package.json +2 -2
  71. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  72. package/src/AudioDeviceManager.ts +571 -0
  73. package/src/AudioRecorder.provider.tsx +3 -0
  74. package/src/ExpoAudioStream.types.ts +113 -1
  75. package/src/ExpoAudioStream.web.ts +609 -69
  76. package/src/ExpoAudioStreamModule.ts +23 -0
  77. package/src/WebRecorder.web.ts +482 -92
  78. package/src/hooks/useAudioDevices.ts +180 -0
  79. package/src/index.ts +6 -0
  80. package/src/types/crc-32.d.ts +6 -6
  81. package/src/useAudioRecorder.tsx +27 -1
  82. package/src/utils/BlobFix.ts +6 -4
  83. package/src/utils/writeWavHeader.ts +26 -25
  84. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  85. 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
 
@@ -104,13 +113,106 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
104
113
  // Utility to handle user media stream
105
114
  async getMediaStream() {
106
115
  try {
107
- return await navigator.mediaDevices.getUserMedia({ audio: true })
116
+ this.logger?.debug('Requesting user media (microphone)...')
117
+
118
+ // First check if the browser supports the necessary audio APIs
119
+ if (!navigator?.mediaDevices?.getUserMedia) {
120
+ this.logger?.error(
121
+ 'Browser does not support mediaDevices.getUserMedia'
122
+ )
123
+ throw new Error('Browser does not support audio recording')
124
+ }
125
+
126
+ // Get media with detailed audio constraints for better diagnostics
127
+ const constraints = {
128
+ audio: {
129
+ echoCancellation: true,
130
+ noiseSuppression: true,
131
+ autoGainControl: true,
132
+ // Add deviceId constraint if specified
133
+ ...(this.recordingConfig?.deviceId
134
+ ? {
135
+ deviceId: {
136
+ exact: this.recordingConfig.deviceId,
137
+ },
138
+ }
139
+ : {}),
140
+ },
141
+ }
142
+
143
+ this.logger?.debug('Media constraints:', constraints)
144
+
145
+ const stream =
146
+ await navigator.mediaDevices.getUserMedia(constraints)
147
+
148
+ // Get detailed info about the audio track for debugging
149
+ const audioTracks = stream.getAudioTracks()
150
+ if (audioTracks.length > 0) {
151
+ const track = audioTracks[0]
152
+ const settings = track.getSettings()
153
+ this.logger?.debug('Audio track obtained:', {
154
+ label: track.label,
155
+ id: track.id,
156
+ enabled: track.enabled,
157
+ muted: track.muted,
158
+ readyState: track.readyState,
159
+ settings,
160
+ })
161
+ } else {
162
+ this.logger?.warn('Stream has no audio tracks!')
163
+ }
164
+
165
+ return stream
108
166
  } catch (error) {
109
167
  this.logger?.error('Failed to get media stream:', error)
110
168
  throw error
111
169
  }
112
170
  }
113
171
 
172
+ // Prepare recording with options
173
+ async prepareRecording(
174
+ recordingConfig: RecordingConfig = {}
175
+ ): Promise<boolean> {
176
+ if (this.isRecording) {
177
+ this.logger?.warn(
178
+ 'Cannot prepare: Recording is already in progress'
179
+ )
180
+ return false
181
+ }
182
+
183
+ try {
184
+ // Check permissions and initialize basic settings
185
+ await this.getMediaStream().then((stream) => {
186
+ // Just verify we can access the microphone by getting a stream, then release it
187
+ stream.getTracks().forEach((track) => track.stop())
188
+ })
189
+
190
+ this.bitDepth = encodingToBitDepth({
191
+ encoding: recordingConfig.encoding ?? 'pcm_32bit',
192
+ })
193
+
194
+ // Store recording configuration for later use
195
+ this.recordingConfig = recordingConfig
196
+
197
+ // Use custom filename if provided, otherwise fallback to timestamp
198
+ if (recordingConfig.filename) {
199
+ // Remove any existing extension from the filename
200
+ this.streamUuid = recordingConfig.filename.replace(
201
+ /\.[^/.]+$/,
202
+ ''
203
+ )
204
+ } else {
205
+ this.streamUuid = Date.now().toString()
206
+ }
207
+
208
+ this.logger?.debug('Recording preparation completed successfully')
209
+ return true
210
+ } catch (error) {
211
+ this.logger?.error('Error preparing recording:', error)
212
+ return false
213
+ }
214
+ }
215
+
114
216
  // Start recording with options
115
217
  async startRecording(
116
218
  recordingConfig: RecordingConfig = {}
@@ -119,9 +221,22 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
119
221
  throw new Error('Recording is already in progress')
120
222
  }
121
223
 
122
- this.bitDepth = encodingToBitDepth({
123
- encoding: recordingConfig.encoding ?? 'pcm_32bit',
124
- })
224
+ // If we haven't prepared or have different settings, prepare now
225
+ if (
226
+ !this.recordingConfig ||
227
+ this.recordingConfig.sampleRate !== recordingConfig.sampleRate ||
228
+ this.recordingConfig.channels !== recordingConfig.channels ||
229
+ this.recordingConfig.encoding !== recordingConfig.encoding
230
+ ) {
231
+ await this.prepareRecording(recordingConfig)
232
+ } else {
233
+ this.logger?.debug(
234
+ 'Using previously prepared recording configuration'
235
+ )
236
+ }
237
+
238
+ // Save recording config for reference
239
+ this.recordingConfig = recordingConfig
125
240
 
126
241
  const audioContext = new (window.AudioContext ||
127
242
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -136,39 +251,15 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
136
251
  audioContext,
137
252
  source,
138
253
  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
- },
254
+ emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
255
+ emitAudioAnalysisCallback:
256
+ this.customRecorderAnalysisCallback.bind(this),
257
+ onInterruption: this.handleRecordingInterruption.bind(this),
159
258
  })
160
259
  await this.customRecorder.init()
161
260
  this.customRecorder.start()
162
261
 
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
262
  this.isRecording = true
171
- this.recordingConfig = recordingConfig
172
263
  this.recordingStartTime = Date.now()
173
264
  this.pausedTime = 0
174
265
  this.isPaused = false
@@ -208,14 +299,124 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
208
299
  return streamConfig
209
300
  }
210
301
 
302
+ /**
303
+ * Centralized handler for recording interruptions
304
+ */
305
+ private handleRecordingInterruption(event: {
306
+ reason: RecordingInterruptionReason | string
307
+ isPaused: boolean
308
+ timestamp: number
309
+ message?: string
310
+ }): void {
311
+ this.logger?.debug(`Received recording interruption: ${event.reason}`)
312
+
313
+ // Update local state if the interruption should pause recording
314
+ if (event.isPaused) {
315
+ this.isPaused = true
316
+
317
+ // If this is a device disconnection, handle according to behavior setting
318
+ if (event.reason === 'deviceDisconnected') {
319
+ this.pausedTime = Date.now()
320
+
321
+ // Check if we should try fallback to another device
322
+ if (
323
+ this.recordingConfig?.deviceDisconnectionBehavior ===
324
+ 'fallback'
325
+ ) {
326
+ this.logger?.debug(
327
+ 'Device disconnected with fallback behavior - attempting to switch to default device'
328
+ )
329
+
330
+ // Try to restart with default device
331
+ this.handleDeviceFallback().catch((error) => {
332
+ // If fallback fails, emit warning
333
+ this.logger?.error('Device fallback failed:', error)
334
+ this.emit('onRecordingInterrupted', {
335
+ reason: 'deviceSwitchFailed',
336
+ isPaused: true,
337
+ timestamp: Date.now(),
338
+ message:
339
+ 'Failed to switch to fallback device. Recording paused.',
340
+ })
341
+ })
342
+ } else {
343
+ // Just warn about disconnection if fallback not enabled
344
+ this.logger?.warn(
345
+ 'Device disconnected - recording paused automatically'
346
+ )
347
+ this.emit('onRecordingInterrupted', event)
348
+ }
349
+ } else {
350
+ // For other interruption types, just emit the event
351
+ this.emit('onRecordingInterrupted', event)
352
+ }
353
+ } else {
354
+ // If not causing a pause, just forward the event
355
+ this.emit('onRecordingInterrupted', event)
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Handler for audio events from the WebRecorder
361
+ */
362
+ private customRecorderEventCallback({
363
+ data,
364
+ position,
365
+ compression,
366
+ }: EmitAudioEventProps): void {
367
+ // Keep only the latest chunks based on maxBufferSize
368
+ this.audioChunks.push(new Float32Array(data))
369
+ if (this.audioChunks.length > this.maxBufferSize) {
370
+ this.audioChunks.shift() // Remove oldest chunk
371
+ }
372
+ this.currentSize += data.byteLength
373
+ this.emitAudioEvent({ data, position, compression })
374
+ this.lastEmittedTime = Date.now()
375
+ this.lastEmittedSize = this.currentSize
376
+ this.lastEmittedCompressionSize = compression?.size ?? 0
377
+ }
378
+
379
+ /**
380
+ * Handler for audio analysis events from the WebRecorder
381
+ */
382
+ private customRecorderAnalysisCallback(
383
+ audioAnalysisData: AudioAnalysis
384
+ ): void {
385
+ this.emit('AudioAnalysis', audioAnalysisData)
386
+ }
387
+
388
+ // Get recording duration
389
+ private getRecordingDuration(): number {
390
+ if (!this.isRecording) {
391
+ return 0
392
+ }
393
+
394
+ return this.currentDurationMs
395
+ }
396
+
211
397
  emitAudioEvent({ data, position, compression }: EmitAudioEventProps) {
212
398
  const fileUri = `${this.streamUuid}.${this.extension}`
213
399
  if (compression?.size) {
214
400
  this.lastEmittedCompressionSize = compression.size
215
401
  this.totalCompressedSize = compression.totalSize
216
402
  }
403
+
404
+ // Update latest position for tracking
217
405
  this.latestPosition = position
218
- this.currentDurationMs = position * 1000 // Convert position (in seconds) to ms
406
+
407
+ // Calculate duration of this chunk in ms
408
+ const sampleRate = this.recordingConfig?.sampleRate || 44100
409
+ const chunkDurationMs = (data.length / sampleRate) * 1000
410
+
411
+ // Handle duration calculation
412
+ if (this.customRecorder?.isFirstChunkAfterSwitch) {
413
+ this.logger?.debug(
414
+ `Processing first chunk after device switch, duration preserved at ${this.currentDurationMs}ms`
415
+ )
416
+ this.customRecorder.isFirstChunkAfterSwitch = false
417
+ } else {
418
+ this.currentDurationMs += chunkDurationMs
419
+ }
219
420
 
220
421
  const audioEventPayload: AudioEventPayload = {
221
422
  fileUri,
@@ -245,48 +446,72 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
245
446
  throw new Error('Recorder is not initialized')
246
447
  }
247
448
 
248
- this.logger?.debug('[Stop] Starting stop process')
249
- const startTime = performance.now()
449
+ this.logger?.debug('Starting stop process')
250
450
 
251
451
  try {
252
- this.logger?.debug('[Stop] Stopping recorder')
253
- const { compressedBlob } = await this.customRecorder.stop()
452
+ const { compressedBlob, uncompressedBlob } =
453
+ await this.customRecorder.stop()
254
454
 
255
455
  this.isRecording = false
256
456
  this.isPaused = false
257
- this.currentDurationMs = Date.now() - this.recordingStartTime
258
457
 
259
458
  let compression: AudioRecording['compression']
260
459
  let fileUri = `${this.streamUuid}.${this.extension}`
261
460
  let mimeType = `audio/${this.extension}`
262
461
 
263
- if (compressedBlob && this.recordingConfig?.compression?.enabled) {
462
+ // Handle both compressed and uncompressed blobs according to configuration
463
+ const compressionEnabled =
464
+ this.recordingConfig?.compression?.enabled ?? false
465
+
466
+ // Process compressed blob if available
467
+ if (compressedBlob) {
264
468
  const compressedUri = URL.createObjectURL(compressedBlob)
265
- compression = {
469
+ const compressedInfo = {
266
470
  compressedFileUri: compressedUri,
267
471
  size: compressedBlob.size,
268
472
  mimeType: 'audio/webm',
269
473
  format: 'opus',
270
- bitrate: this.recordingConfig.compression.bitrate ?? 128000,
474
+ bitrate:
475
+ this.recordingConfig?.compression?.bitrate ?? 128000,
476
+ }
477
+
478
+ // If compression is enabled, use compressed blob as primary format
479
+ if (compressionEnabled) {
480
+ this.logger?.debug(
481
+ 'Using compressed audio as primary output'
482
+ )
483
+ fileUri = compressedUri
484
+ mimeType = 'audio/webm'
485
+
486
+ // Store compression info
487
+ compression = compressedInfo
488
+ } else {
489
+ // Compression was enabled during recording but not set as primary
490
+ // Store as alternate format
491
+ compression = compressedInfo
271
492
  }
272
- // Use compressed values when compression is enabled
273
- fileUri = compressedUri
274
- mimeType = 'audio/webm'
275
493
  }
276
494
 
277
- this.logger?.debug(
278
- `[Stop] Completed stop process in ${performance.now() - startTime}ms`,
279
- {
280
- durationMs: this.currentDurationMs,
281
- compressedSize: compression?.size,
495
+ // Process uncompressed WAV if available
496
+ if (uncompressedBlob) {
497
+ const wavUri = URL.createObjectURL(uncompressedBlob)
498
+
499
+ // If compression is disabled or no compressed blob is available,
500
+ // use WAV as primary format
501
+ if (!compressionEnabled || !compressedBlob) {
502
+ this.logger?.debug(
503
+ 'Using uncompressed WAV as primary output'
504
+ )
505
+ fileUri = wavUri
506
+ mimeType = 'audio/wav'
282
507
  }
283
- )
508
+ }
284
509
 
285
- // Use the stored streamUuid (which contains our custom filename) for the final filename
510
+ // Use the stored streamUuid for the final filename
286
511
  const filename = `${this.streamUuid}.${this.extension}`
287
512
  const result: AudioRecording = {
288
513
  fileUri,
289
- filename, // This will now use our custom filename
514
+ filename,
290
515
  bitDepth: this.bitDepth,
291
516
  createdAt: this.recordingStartTime,
292
517
  channels: this.recordingConfig?.channels ?? 1,
@@ -300,24 +525,44 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
300
525
  // Reset after creating the result
301
526
  this.streamUuid = null
302
527
 
528
+ // Reset recording state variables to prepare for next recording
529
+ this.currentDurationMs = 0
530
+ this.currentSize = 0
531
+ this.lastEmittedSize = 0
532
+ this.totalCompressedSize = 0
533
+ this.lastEmittedCompressionSize = 0
534
+ this.audioChunks = []
535
+
303
536
  return result
304
537
  } catch (error) {
305
- this.logger?.error('[Stop] Error stopping recording:', error)
538
+ this.logger?.error('Error stopping recording:', error)
306
539
  throw error
307
540
  }
308
541
  }
309
542
 
310
543
  // Pause recording
311
544
  async pauseRecording() {
312
- if (!this.isRecording || this.isPaused) {
313
- throw new Error('Recording is not active or already paused')
545
+ if (!this.isRecording) {
546
+ throw new Error('Recording is not active')
314
547
  }
315
548
 
316
- if (this.customRecorder) {
317
- this.customRecorder.pause()
549
+ if (this.isPaused) {
550
+ this.logger?.debug('Recording already paused, skipping')
551
+ return
552
+ }
553
+
554
+ try {
555
+ if (this.customRecorder) {
556
+ this.customRecorder.pause()
557
+ }
558
+ this.isPaused = true
559
+ this.pausedTime = Date.now()
560
+ } catch (error) {
561
+ this.logger?.error('Error in pauseRecording', error)
562
+ // Even if the pause operation failed, make sure our state is consistent
563
+ this.isPaused = true
564
+ this.pausedTime = Date.now()
318
565
  }
319
- this.isPaused = true
320
- this.pausedTime = Date.now()
321
566
  }
322
567
 
323
568
  // Resume recording
@@ -326,19 +571,61 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
326
571
  throw new Error('Recording is not paused')
327
572
  }
328
573
 
329
- if (this.customRecorder) {
574
+ this.logger?.debug('Resuming recording', {
575
+ deviceDisconnectionBehavior:
576
+ this.recordingConfig?.deviceDisconnectionBehavior,
577
+ isDeviceDisconnected: this.customRecorder?.isDeviceDisconnected,
578
+ })
579
+
580
+ try {
581
+ // If we have no recorder, or if the device is disconnected, always attempt fallback
582
+ if (
583
+ !this.customRecorder ||
584
+ this.customRecorder.isDeviceDisconnected
585
+ ) {
586
+ this.logger?.debug(
587
+ 'No recorder exists or device disconnected - attempting fallback on resume'
588
+ )
589
+ await this.handleDeviceFallback()
590
+ // handleDeviceFallback will manage resuming if successful, or emit error if failed.
591
+ return
592
+ }
593
+
594
+ // Normal resume path - device is still connected
330
595
  this.customRecorder.resume()
596
+ this.isPaused = false
597
+
598
+ // Adjust the recording start time to account for the pause duration
599
+ const pauseDuration = Date.now() - this.pausedTime
600
+ this.recordingStartTime += pauseDuration
601
+ this.pausedTime = 0
602
+
603
+ this.emit('onRecordingInterrupted', {
604
+ reason: 'userResumed',
605
+ isPaused: false,
606
+ timestamp: Date.now(),
607
+ })
608
+ } catch (error) {
609
+ this.logger?.error('Resume failed:', error)
610
+ // Fallback to emitting a general failure if resume fails unexpectedly
611
+ this.emit('onRecordingInterrupted', {
612
+ reason: 'resumeFailed', // Use a more specific reason
613
+ isPaused: true, // Remain paused if resume fails
614
+ timestamp: Date.now(),
615
+ message:
616
+ 'Failed to resume recording. Please stop and start again.',
617
+ })
331
618
  }
332
- this.isPaused = false
333
- this.recordingStartTime += Date.now() - this.pausedTime
334
619
  }
335
620
 
336
621
  // Get current status
337
622
  status() {
623
+ const durationMs = this.getRecordingDuration()
624
+
338
625
  const status: AudioStreamStatus = {
339
626
  isRecording: this.isRecording,
340
627
  isPaused: this.isPaused,
341
- durationMs: this.currentDurationMs,
628
+ durationMs,
342
629
  size: this.currentSize,
343
630
  interval: this.currentInterval,
344
631
  intervalAnalysis: this.currentIntervalAnalysis,
@@ -356,4 +643,257 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
356
643
  }
357
644
  return status
358
645
  }
646
+
647
+ /**
648
+ * Handles device fallback when the current device is disconnected
649
+ */
650
+ private async handleDeviceFallback(): Promise<boolean> {
651
+ this.logger?.debug('Starting device fallback procedure')
652
+
653
+ if (!this.isRecording) {
654
+ return false
655
+ }
656
+
657
+ try {
658
+ // Save important state before switching
659
+ const currentPosition = this.latestPosition
660
+ const existingAudioChunks = [...this.audioChunks]
661
+
662
+ // Save compressed chunks if available
663
+ let compressedChunks: Blob[] = []
664
+ if (this.customRecorder) {
665
+ try {
666
+ compressedChunks = this.customRecorder.getCompressedChunks()
667
+ } catch (err) {
668
+ this.logger?.warn('Failed to get compressed chunks:', err)
669
+ }
670
+ }
671
+
672
+ // Save the current counter value for continuity
673
+ let currentDataPointCounter = 0
674
+ if (this.customRecorder) {
675
+ currentDataPointCounter =
676
+ this.customRecorder.getDataPointCounter()
677
+ }
678
+
679
+ // Clean up existing recorder
680
+ if (this.customRecorder) {
681
+ try {
682
+ this.customRecorder.cleanup()
683
+ } catch (cleanupError) {
684
+ this.logger?.warn('Error during cleanup:', cleanupError)
685
+ }
686
+ }
687
+
688
+ // Keep recording state true but mark as paused
689
+ this.isPaused = true
690
+ this.pausedTime = Date.now()
691
+
692
+ // Store current size and other stats
693
+ const previousTotalSize = this.currentSize
694
+ const previousLastEmittedSize = this.lastEmittedSize
695
+ const previousCompressedSize = this.totalCompressedSize
696
+
697
+ // Try to get a fallback device
698
+ const fallbackDeviceInfo = await this.getFallbackDevice()
699
+ if (!fallbackDeviceInfo) {
700
+ this.emit('onRecordingInterrupted', {
701
+ reason: 'deviceSwitchFailed',
702
+ isPaused: true,
703
+ timestamp: Date.now(),
704
+ message:
705
+ 'Failed to switch to fallback device. Recording paused.',
706
+ })
707
+ return false
708
+ }
709
+
710
+ // Start recording with the new device
711
+ try {
712
+ const stream = await this.requestPermissionsAndGetUserMedia(
713
+ fallbackDeviceInfo.deviceId
714
+ )
715
+ const audioContext = new (window.AudioContext ||
716
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
717
+ // @ts-ignore - Allow webkitAudioContext for Safari
718
+ window.webkitAudioContext)()
719
+
720
+ const source = audioContext.createMediaStreamSource(stream)
721
+
722
+ // Create a new recorder with the fallback device
723
+ this.customRecorder = new WebRecorder({
724
+ logger: this.logger,
725
+ audioContext,
726
+ source,
727
+ recordingConfig: this.recordingConfig || {},
728
+ emitAudioEventCallback:
729
+ this.customRecorderEventCallback.bind(this),
730
+ emitAudioAnalysisCallback:
731
+ this.customRecorderAnalysisCallback.bind(this),
732
+ onInterruption: this.handleRecordingInterruption.bind(this),
733
+ })
734
+
735
+ await this.customRecorder.init()
736
+
737
+ // Set the initial position to continue from the previous device
738
+ this.customRecorder.setPosition(currentPosition)
739
+
740
+ // Reset the data point counter to continue from where the previous device left off
741
+ if (currentDataPointCounter > 0) {
742
+ this.customRecorder.resetDataPointCounter(
743
+ currentDataPointCounter
744
+ )
745
+ }
746
+
747
+ // Prepare the recorder to handle the device switch properly
748
+ this.customRecorder.prepareForDeviceSwitch()
749
+
750
+ // Restore the existing audio chunks
751
+ if (existingAudioChunks.length > 0) {
752
+ this.audioChunks = existingAudioChunks
753
+ }
754
+
755
+ // Restore compressed chunks if available
756
+ if (compressedChunks.length > 0) {
757
+ this.customRecorder.setCompressedChunks(compressedChunks)
758
+ }
759
+
760
+ // Start the new recorder while preserving counters
761
+ this.customRecorder.start(true)
762
+
763
+ // Update recording state
764
+ this.isPaused = false
765
+ this.recordingStartTime = Date.now()
766
+
767
+ // Restore size counters to maintain continuity
768
+ this.currentSize = previousTotalSize
769
+ this.lastEmittedSize = previousLastEmittedSize
770
+ this.totalCompressedSize = previousCompressedSize
771
+
772
+ // Notify that we switched to a fallback device
773
+ if (this.eventCallback) {
774
+ this.eventCallback({
775
+ type: 'deviceFallback',
776
+ device: fallbackDeviceInfo.deviceId,
777
+ timestamp: new Date(),
778
+ })
779
+ }
780
+ return true
781
+ } catch (error) {
782
+ this.logger?.error(
783
+ 'Failed to start recording with fallback device',
784
+ error
785
+ )
786
+ this.isPaused = true
787
+ this.emit('onRecordingInterrupted', {
788
+ reason: 'deviceSwitchFailed',
789
+ isPaused: true,
790
+ timestamp: Date.now(),
791
+ message:
792
+ 'Failed to switch to fallback device. Recording paused.',
793
+ })
794
+ return false
795
+ }
796
+ } catch (error) {
797
+ this.logger?.error('Failed to use fallback device', error)
798
+ this.isPaused = true
799
+ this.emit('onRecordingInterrupted', {
800
+ reason: 'deviceSwitchFailed',
801
+ isPaused: true,
802
+ timestamp: Date.now(),
803
+ message:
804
+ 'Failed to switch to fallback device. Recording paused.',
805
+ })
806
+ return false
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Attempts to get a fallback audio device
812
+ */
813
+ private async getFallbackDevice(): Promise<MediaDeviceInfo | null> {
814
+ try {
815
+ // Get list of available audio input devices
816
+ const devices = await navigator.mediaDevices.enumerateDevices()
817
+ const audioInputDevices = devices.filter(
818
+ (device) => device.kind === 'audioinput'
819
+ )
820
+
821
+ if (audioInputDevices.length === 0) {
822
+ return null
823
+ }
824
+
825
+ // Try to find a device that's not the current one
826
+ if (this.customRecorder) {
827
+ try {
828
+ // Use mediaDevices.enumerateDevices to find the current active device
829
+ const tracks = navigator.mediaDevices
830
+ .getUserMedia({ audio: true })
831
+ .then((stream) => {
832
+ const track = stream.getAudioTracks()[0]
833
+ return track ? track.label : ''
834
+ })
835
+ .catch(() => '')
836
+
837
+ const currentTrackLabel = await tracks
838
+
839
+ if (currentTrackLabel) {
840
+ // Find a device with a different label
841
+ const differentDevice = audioInputDevices.find(
842
+ (device) =>
843
+ device.label &&
844
+ device.label !== currentTrackLabel
845
+ )
846
+
847
+ if (differentDevice) {
848
+ return differentDevice
849
+ }
850
+ }
851
+ } catch (err) {
852
+ this.logger?.warn(
853
+ 'Error determining current device, using default'
854
+ )
855
+ }
856
+ }
857
+
858
+ // Return the first available device (default device)
859
+ return audioInputDevices[0]
860
+ } catch (error) {
861
+ this.logger?.error('Error finding fallback device:', error)
862
+ return null
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Gets user media with specific device ID
868
+ */
869
+ private async requestPermissionsAndGetUserMedia(
870
+ deviceId: string
871
+ ): Promise<MediaStream> {
872
+ try {
873
+ // Request the specific device
874
+ return await navigator.mediaDevices.getUserMedia({
875
+ audio: {
876
+ deviceId: { exact: deviceId },
877
+ },
878
+ })
879
+ } catch (error) {
880
+ this.logger?.error(
881
+ `Failed to get media for device ${deviceId}`,
882
+ error
883
+ )
884
+ // Try with default constraints as fallback
885
+ return await navigator.mediaDevices.getUserMedia({ audio: true })
886
+ }
887
+ }
888
+
889
+ init(options?: ExpoAudioStreamOptions): Promise<void> {
890
+ try {
891
+ this.logger = options?.logger
892
+ this.eventCallback = options?.eventCallback
893
+ return Promise.resolve()
894
+ } catch (error) {
895
+ this.logger?.error('Error initializing ExpoAudioStream', error)
896
+ return Promise.reject(error)
897
+ }
898
+ }
359
899
  }