@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
@@ -27,15 +27,12 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
27
27
  latestPosition = 0;
28
28
  totalCompressedSize = 0;
29
29
  maxBufferSize;
30
+ eventCallback;
30
31
  constructor({ audioWorkletUrl, featuresExtratorUrl, logger, maxBufferSize = 100, // Default to storing last 100 chunks (1 chunk = 0.5 seconds)
31
32
  }) {
32
33
  const mockNativeModule = {
33
- addListener: () => {
34
- // Not used on web
35
- },
36
- removeListeners: () => {
37
- // Not used on web
38
- },
34
+ addListener: () => { },
35
+ removeListeners: () => { },
39
36
  };
40
37
  super(mockNativeModule); // Pass the mock native module to the parent class
41
38
  this.logger = logger;
@@ -63,21 +60,104 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
63
60
  // Utility to handle user media stream
64
61
  async getMediaStream() {
65
62
  try {
66
- return await navigator.mediaDevices.getUserMedia({ audio: true });
63
+ this.logger?.debug('Requesting user media (microphone)...');
64
+ // First check if the browser supports the necessary audio APIs
65
+ if (!navigator?.mediaDevices?.getUserMedia) {
66
+ this.logger?.error('Browser does not support mediaDevices.getUserMedia');
67
+ throw new Error('Browser does not support audio recording');
68
+ }
69
+ // Get media with detailed audio constraints for better diagnostics
70
+ const constraints = {
71
+ audio: {
72
+ echoCancellation: true,
73
+ noiseSuppression: true,
74
+ autoGainControl: true,
75
+ // Add deviceId constraint if specified
76
+ ...(this.recordingConfig?.deviceId
77
+ ? {
78
+ deviceId: {
79
+ exact: this.recordingConfig.deviceId,
80
+ },
81
+ }
82
+ : {}),
83
+ },
84
+ };
85
+ this.logger?.debug('Media constraints:', constraints);
86
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
87
+ // Get detailed info about the audio track for debugging
88
+ const audioTracks = stream.getAudioTracks();
89
+ if (audioTracks.length > 0) {
90
+ const track = audioTracks[0];
91
+ const settings = track.getSettings();
92
+ this.logger?.debug('Audio track obtained:', {
93
+ label: track.label,
94
+ id: track.id,
95
+ enabled: track.enabled,
96
+ muted: track.muted,
97
+ readyState: track.readyState,
98
+ settings,
99
+ });
100
+ }
101
+ else {
102
+ this.logger?.warn('Stream has no audio tracks!');
103
+ }
104
+ return stream;
67
105
  }
68
106
  catch (error) {
69
107
  this.logger?.error('Failed to get media stream:', error);
70
108
  throw error;
71
109
  }
72
110
  }
111
+ // Prepare recording with options
112
+ async prepareRecording(recordingConfig = {}) {
113
+ if (this.isRecording) {
114
+ this.logger?.warn('Cannot prepare: Recording is already in progress');
115
+ return false;
116
+ }
117
+ try {
118
+ // Check permissions and initialize basic settings
119
+ await this.getMediaStream().then((stream) => {
120
+ // Just verify we can access the microphone by getting a stream, then release it
121
+ stream.getTracks().forEach((track) => track.stop());
122
+ });
123
+ this.bitDepth = encodingToBitDepth({
124
+ encoding: recordingConfig.encoding ?? 'pcm_32bit',
125
+ });
126
+ // Store recording configuration for later use
127
+ this.recordingConfig = recordingConfig;
128
+ // Use custom filename if provided, otherwise fallback to timestamp
129
+ if (recordingConfig.filename) {
130
+ // Remove any existing extension from the filename
131
+ this.streamUuid = recordingConfig.filename.replace(/\.[^/.]+$/, '');
132
+ }
133
+ else {
134
+ this.streamUuid = Date.now().toString();
135
+ }
136
+ this.logger?.debug('Recording preparation completed successfully');
137
+ return true;
138
+ }
139
+ catch (error) {
140
+ this.logger?.error('Error preparing recording:', error);
141
+ return false;
142
+ }
143
+ }
73
144
  // Start recording with options
74
145
  async startRecording(recordingConfig = {}) {
75
146
  if (this.isRecording) {
76
147
  throw new Error('Recording is already in progress');
77
148
  }
78
- this.bitDepth = encodingToBitDepth({
79
- encoding: recordingConfig.encoding ?? 'pcm_32bit',
80
- });
149
+ // If we haven't prepared or have different settings, prepare now
150
+ if (!this.recordingConfig ||
151
+ this.recordingConfig.sampleRate !== recordingConfig.sampleRate ||
152
+ this.recordingConfig.channels !== recordingConfig.channels ||
153
+ this.recordingConfig.encoding !== recordingConfig.encoding) {
154
+ await this.prepareRecording(recordingConfig);
155
+ }
156
+ else {
157
+ this.logger?.debug('Using previously prepared recording configuration');
158
+ }
159
+ // Save recording config for reference
160
+ this.recordingConfig = recordingConfig;
81
161
  const audioContext = new (window.AudioContext ||
82
162
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
83
163
  // @ts-ignore - Allow webkitAudioContext for Safari
@@ -89,33 +169,13 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
89
169
  audioContext,
90
170
  source,
91
171
  recordingConfig,
92
- emitAudioEventCallback: ({ data, position, compression, }) => {
93
- // Keep only the latest chunks based on maxBufferSize
94
- this.audioChunks.push(new Float32Array(data));
95
- if (this.audioChunks.length > this.maxBufferSize) {
96
- this.audioChunks.shift(); // Remove oldest chunk
97
- }
98
- this.currentSize += data.byteLength;
99
- this.emitAudioEvent({ data, position, compression });
100
- this.lastEmittedTime = Date.now();
101
- this.lastEmittedSize = this.currentSize;
102
- this.lastEmittedCompressionSize = compression?.size ?? 0;
103
- },
104
- emitAudioAnalysisCallback: (audioAnalysisData) => {
105
- this.logger?.log(`Emitted AudioAnalysis:`, audioAnalysisData);
106
- this.emit('AudioAnalysis', audioAnalysisData);
107
- },
172
+ emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
173
+ emitAudioAnalysisCallback: this.customRecorderAnalysisCallback.bind(this),
174
+ onInterruption: this.handleRecordingInterruption.bind(this),
108
175
  });
109
176
  await this.customRecorder.init();
110
177
  this.customRecorder.start();
111
- // // Set a timer to stop recording after 5 seconds
112
- // setTimeout(() => {
113
- // logger.log("AUTO Stopping recording");
114
- // this.customRecorder?.stopAndPlay();
115
- // this.isRecording = false;
116
- // }, 3000);
117
178
  this.isRecording = true;
118
- this.recordingConfig = recordingConfig;
119
179
  this.recordingStartTime = Date.now();
120
180
  this.pausedTime = 0;
121
181
  this.isPaused = false;
@@ -153,14 +213,96 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
153
213
  };
154
214
  return streamConfig;
155
215
  }
216
+ /**
217
+ * Centralized handler for recording interruptions
218
+ */
219
+ handleRecordingInterruption(event) {
220
+ this.logger?.debug(`Received recording interruption: ${event.reason}`);
221
+ // Update local state if the interruption should pause recording
222
+ if (event.isPaused) {
223
+ this.isPaused = true;
224
+ // If this is a device disconnection, handle according to behavior setting
225
+ if (event.reason === 'deviceDisconnected') {
226
+ this.pausedTime = Date.now();
227
+ // Check if we should try fallback to another device
228
+ if (this.recordingConfig?.deviceDisconnectionBehavior ===
229
+ 'fallback') {
230
+ this.logger?.debug('Device disconnected with fallback behavior - attempting to switch to default device');
231
+ // Try to restart with default device
232
+ this.handleDeviceFallback().catch((error) => {
233
+ // If fallback fails, emit warning
234
+ this.logger?.error('Device fallback failed:', error);
235
+ this.emit('onRecordingInterrupted', {
236
+ reason: 'deviceSwitchFailed',
237
+ isPaused: true,
238
+ timestamp: Date.now(),
239
+ message: 'Failed to switch to fallback device. Recording paused.',
240
+ });
241
+ });
242
+ }
243
+ else {
244
+ // Just warn about disconnection if fallback not enabled
245
+ this.logger?.warn('Device disconnected - recording paused automatically');
246
+ this.emit('onRecordingInterrupted', event);
247
+ }
248
+ }
249
+ else {
250
+ // For other interruption types, just emit the event
251
+ this.emit('onRecordingInterrupted', event);
252
+ }
253
+ }
254
+ else {
255
+ // If not causing a pause, just forward the event
256
+ this.emit('onRecordingInterrupted', event);
257
+ }
258
+ }
259
+ /**
260
+ * Handler for audio events from the WebRecorder
261
+ */
262
+ customRecorderEventCallback({ data, position, compression, }) {
263
+ // Keep only the latest chunks based on maxBufferSize
264
+ this.audioChunks.push(new Float32Array(data));
265
+ if (this.audioChunks.length > this.maxBufferSize) {
266
+ this.audioChunks.shift(); // Remove oldest chunk
267
+ }
268
+ this.currentSize += data.byteLength;
269
+ this.emitAudioEvent({ data, position, compression });
270
+ this.lastEmittedTime = Date.now();
271
+ this.lastEmittedSize = this.currentSize;
272
+ this.lastEmittedCompressionSize = compression?.size ?? 0;
273
+ }
274
+ /**
275
+ * Handler for audio analysis events from the WebRecorder
276
+ */
277
+ customRecorderAnalysisCallback(audioAnalysisData) {
278
+ this.emit('AudioAnalysis', audioAnalysisData);
279
+ }
280
+ // Get recording duration
281
+ getRecordingDuration() {
282
+ if (!this.isRecording) {
283
+ return 0;
284
+ }
285
+ return this.currentDurationMs;
286
+ }
156
287
  emitAudioEvent({ data, position, compression }) {
157
288
  const fileUri = `${this.streamUuid}.${this.extension}`;
158
289
  if (compression?.size) {
159
290
  this.lastEmittedCompressionSize = compression.size;
160
291
  this.totalCompressedSize = compression.totalSize;
161
292
  }
293
+ // Update latest position for tracking
162
294
  this.latestPosition = position;
163
- this.currentDurationMs = position * 1000; // Convert position (in seconds) to ms
295
+ // Calculate duration of this chunk in ms
296
+ const sampleRate = this.recordingConfig?.sampleRate || 44100;
297
+ const chunkDurationMs = (data.length / sampleRate) * 1000;
298
+ // Handle duration calculation
299
+ if (this.customRecorder?.isFirstChunkAfterSwitch) {
300
+ this.logger?.debug(`Processing first chunk after device switch, duration preserved at ${this.currentDurationMs}ms`);
301
+ this.customRecorder.isFirstChunkAfterSwitch = false;
302
+ }
303
+ else {
304
+ this.currentDurationMs += chunkDurationMs;
305
+ }
164
306
  const audioEventPayload = {
165
307
  fileUri,
166
308
  mimeType: `audio/${this.extension}`,
@@ -186,39 +328,56 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
186
328
  if (!this.customRecorder) {
187
329
  throw new Error('Recorder is not initialized');
188
330
  }
189
- this.logger?.debug('[Stop] Starting stop process');
190
- const startTime = performance.now();
331
+ this.logger?.debug('Starting stop process');
191
332
  try {
192
- this.logger?.debug('[Stop] Stopping recorder');
193
- const { compressedBlob } = await this.customRecorder.stop();
333
+ const { compressedBlob, uncompressedBlob } = await this.customRecorder.stop();
194
334
  this.isRecording = false;
195
335
  this.isPaused = false;
196
- this.currentDurationMs = Date.now() - this.recordingStartTime;
197
336
  let compression;
198
337
  let fileUri = `${this.streamUuid}.${this.extension}`;
199
338
  let mimeType = `audio/${this.extension}`;
200
- if (compressedBlob && this.recordingConfig?.compression?.enabled) {
339
+ // Handle both compressed and uncompressed blobs according to configuration
340
+ const compressionEnabled = this.recordingConfig?.compression?.enabled ?? false;
341
+ // Process compressed blob if available
342
+ if (compressedBlob) {
201
343
  const compressedUri = URL.createObjectURL(compressedBlob);
202
- compression = {
344
+ const compressedInfo = {
203
345
  compressedFileUri: compressedUri,
204
346
  size: compressedBlob.size,
205
347
  mimeType: 'audio/webm',
206
348
  format: 'opus',
207
- bitrate: this.recordingConfig.compression.bitrate ?? 128000,
349
+ bitrate: this.recordingConfig?.compression?.bitrate ?? 128000,
208
350
  };
209
- // Use compressed values when compression is enabled
210
- fileUri = compressedUri;
211
- mimeType = 'audio/webm';
351
+ // If compression is enabled, use compressed blob as primary format
352
+ if (compressionEnabled) {
353
+ this.logger?.debug('Using compressed audio as primary output');
354
+ fileUri = compressedUri;
355
+ mimeType = 'audio/webm';
356
+ // Store compression info
357
+ compression = compressedInfo;
358
+ }
359
+ else {
360
+ // Compression was enabled during recording but not set as primary
361
+ // Store as alternate format
362
+ compression = compressedInfo;
363
+ }
212
364
  }
213
- this.logger?.debug(`[Stop] Completed stop process in ${performance.now() - startTime}ms`, {
214
- durationMs: this.currentDurationMs,
215
- compressedSize: compression?.size,
216
- });
217
- // Use the stored streamUuid (which contains our custom filename) for the final filename
365
+ // Process uncompressed WAV if available
366
+ if (uncompressedBlob) {
367
+ const wavUri = URL.createObjectURL(uncompressedBlob);
368
+ // If compression is disabled or no compressed blob is available,
369
+ // use WAV as primary format
370
+ if (!compressionEnabled || !compressedBlob) {
371
+ this.logger?.debug('Using uncompressed WAV as primary output');
372
+ fileUri = wavUri;
373
+ mimeType = 'audio/wav';
374
+ }
375
+ }
376
+ // Use the stored streamUuid for the final filename
218
377
  const filename = `${this.streamUuid}.${this.extension}`;
219
378
  const result = {
220
379
  fileUri,
221
- filename, // This will now use our custom filename
380
+ filename,
222
381
  bitDepth: this.bitDepth,
223
382
  createdAt: this.recordingStartTime,
224
383
  channels: this.recordingConfig?.channels ?? 1,
@@ -230,41 +389,92 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
230
389
  };
231
390
  // Reset after creating the result
232
391
  this.streamUuid = null;
392
+ // Reset recording state variables to prepare for next recording
393
+ this.currentDurationMs = 0;
394
+ this.currentSize = 0;
395
+ this.lastEmittedSize = 0;
396
+ this.totalCompressedSize = 0;
397
+ this.lastEmittedCompressionSize = 0;
398
+ this.audioChunks = [];
233
399
  return result;
234
400
  }
235
401
  catch (error) {
236
- this.logger?.error('[Stop] Error stopping recording:', error);
402
+ this.logger?.error('Error stopping recording:', error);
237
403
  throw error;
238
404
  }
239
405
  }
240
406
  // Pause recording
241
407
  async pauseRecording() {
242
- if (!this.isRecording || this.isPaused) {
243
- throw new Error('Recording is not active or already paused');
408
+ if (!this.isRecording) {
409
+ throw new Error('Recording is not active');
410
+ }
411
+ if (this.isPaused) {
412
+ this.logger?.debug('Recording already paused, skipping');
413
+ return;
414
+ }
415
+ try {
416
+ if (this.customRecorder) {
417
+ this.customRecorder.pause();
418
+ }
419
+ this.isPaused = true;
420
+ this.pausedTime = Date.now();
244
421
  }
245
- if (this.customRecorder) {
246
- this.customRecorder.pause();
422
+ catch (error) {
423
+ this.logger?.error('Error in pauseRecording', error);
424
+ // Even if the pause operation failed, make sure our state is consistent
425
+ this.isPaused = true;
426
+ this.pausedTime = Date.now();
247
427
  }
248
- this.isPaused = true;
249
- this.pausedTime = Date.now();
250
428
  }
251
429
  // Resume recording
252
430
  async resumeRecording() {
253
431
  if (!this.isPaused) {
254
432
  throw new Error('Recording is not paused');
255
433
  }
256
- if (this.customRecorder) {
434
+ this.logger?.debug('Resuming recording', {
435
+ deviceDisconnectionBehavior: this.recordingConfig?.deviceDisconnectionBehavior,
436
+ isDeviceDisconnected: this.customRecorder?.isDeviceDisconnected,
437
+ });
438
+ try {
439
+ // If we have no recorder, or if the device is disconnected, always attempt fallback
440
+ if (!this.customRecorder ||
441
+ this.customRecorder.isDeviceDisconnected) {
442
+ this.logger?.debug('No recorder exists or device disconnected - attempting fallback on resume');
443
+ await this.handleDeviceFallback();
444
+ // handleDeviceFallback will manage resuming if successful, or emit error if failed.
445
+ return;
446
+ }
447
+ // Normal resume path - device is still connected
257
448
  this.customRecorder.resume();
449
+ this.isPaused = false;
450
+ // Adjust the recording start time to account for the pause duration
451
+ const pauseDuration = Date.now() - this.pausedTime;
452
+ this.recordingStartTime += pauseDuration;
453
+ this.pausedTime = 0;
454
+ this.emit('onRecordingInterrupted', {
455
+ reason: 'userResumed',
456
+ isPaused: false,
457
+ timestamp: Date.now(),
458
+ });
459
+ }
460
+ catch (error) {
461
+ this.logger?.error('Resume failed:', error);
462
+ // Fallback to emitting a general failure if resume fails unexpectedly
463
+ this.emit('onRecordingInterrupted', {
464
+ reason: 'resumeFailed', // Use a more specific reason
465
+ isPaused: true, // Remain paused if resume fails
466
+ timestamp: Date.now(),
467
+ message: 'Failed to resume recording. Please stop and start again.',
468
+ });
258
469
  }
259
- this.isPaused = false;
260
- this.recordingStartTime += Date.now() - this.pausedTime;
261
470
  }
262
471
  // Get current status
263
472
  status() {
473
+ const durationMs = this.getRecordingDuration();
264
474
  const status = {
265
475
  isRecording: this.isRecording,
266
476
  isPaused: this.isPaused,
267
- durationMs: this.currentDurationMs,
477
+ durationMs,
268
478
  size: this.currentSize,
269
479
  interval: this.currentInterval,
270
480
  intervalAnalysis: this.currentIntervalAnalysis,
@@ -281,5 +491,211 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
281
491
  };
282
492
  return status;
283
493
  }
494
+ /**
495
+ * Handles device fallback when the current device is disconnected
496
+ */
497
+ async handleDeviceFallback() {
498
+ this.logger?.debug('Starting device fallback procedure');
499
+ if (!this.isRecording) {
500
+ return false;
501
+ }
502
+ try {
503
+ // Save important state before switching
504
+ const currentPosition = this.latestPosition;
505
+ const existingAudioChunks = [...this.audioChunks];
506
+ // Save compressed chunks if available
507
+ let compressedChunks = [];
508
+ if (this.customRecorder) {
509
+ try {
510
+ compressedChunks = this.customRecorder.getCompressedChunks();
511
+ }
512
+ catch (err) {
513
+ this.logger?.warn('Failed to get compressed chunks:', err);
514
+ }
515
+ }
516
+ // Save the current counter value for continuity
517
+ let currentDataPointCounter = 0;
518
+ if (this.customRecorder) {
519
+ currentDataPointCounter =
520
+ this.customRecorder.getDataPointCounter();
521
+ }
522
+ // Clean up existing recorder
523
+ if (this.customRecorder) {
524
+ try {
525
+ this.customRecorder.cleanup();
526
+ }
527
+ catch (cleanupError) {
528
+ this.logger?.warn('Error during cleanup:', cleanupError);
529
+ }
530
+ }
531
+ // Keep recording state true but mark as paused
532
+ this.isPaused = true;
533
+ this.pausedTime = Date.now();
534
+ // Store current size and other stats
535
+ const previousTotalSize = this.currentSize;
536
+ const previousLastEmittedSize = this.lastEmittedSize;
537
+ const previousCompressedSize = this.totalCompressedSize;
538
+ // Try to get a fallback device
539
+ const fallbackDeviceInfo = await this.getFallbackDevice();
540
+ if (!fallbackDeviceInfo) {
541
+ this.emit('onRecordingInterrupted', {
542
+ reason: 'deviceSwitchFailed',
543
+ isPaused: true,
544
+ timestamp: Date.now(),
545
+ message: 'Failed to switch to fallback device. Recording paused.',
546
+ });
547
+ return false;
548
+ }
549
+ // Start recording with the new device
550
+ try {
551
+ const stream = await this.requestPermissionsAndGetUserMedia(fallbackDeviceInfo.deviceId);
552
+ const audioContext = new (window.AudioContext ||
553
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
554
+ // @ts-ignore - Allow webkitAudioContext for Safari
555
+ window.webkitAudioContext)();
556
+ const source = audioContext.createMediaStreamSource(stream);
557
+ // Create a new recorder with the fallback device
558
+ this.customRecorder = new WebRecorder({
559
+ logger: this.logger,
560
+ audioContext,
561
+ source,
562
+ recordingConfig: this.recordingConfig || {},
563
+ emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
564
+ emitAudioAnalysisCallback: this.customRecorderAnalysisCallback.bind(this),
565
+ onInterruption: this.handleRecordingInterruption.bind(this),
566
+ });
567
+ await this.customRecorder.init();
568
+ // Set the initial position to continue from the previous device
569
+ this.customRecorder.setPosition(currentPosition);
570
+ // Reset the data point counter to continue from where the previous device left off
571
+ if (currentDataPointCounter > 0) {
572
+ this.customRecorder.resetDataPointCounter(currentDataPointCounter);
573
+ }
574
+ // Prepare the recorder to handle the device switch properly
575
+ this.customRecorder.prepareForDeviceSwitch();
576
+ // Restore the existing audio chunks
577
+ if (existingAudioChunks.length > 0) {
578
+ this.audioChunks = existingAudioChunks;
579
+ }
580
+ // Restore compressed chunks if available
581
+ if (compressedChunks.length > 0) {
582
+ this.customRecorder.setCompressedChunks(compressedChunks);
583
+ }
584
+ // Start the new recorder while preserving counters
585
+ this.customRecorder.start(true);
586
+ // Update recording state
587
+ this.isPaused = false;
588
+ this.recordingStartTime = Date.now();
589
+ // Restore size counters to maintain continuity
590
+ this.currentSize = previousTotalSize;
591
+ this.lastEmittedSize = previousLastEmittedSize;
592
+ this.totalCompressedSize = previousCompressedSize;
593
+ // Notify that we switched to a fallback device
594
+ if (this.eventCallback) {
595
+ this.eventCallback({
596
+ type: 'deviceFallback',
597
+ device: fallbackDeviceInfo.deviceId,
598
+ timestamp: new Date(),
599
+ });
600
+ }
601
+ return true;
602
+ }
603
+ catch (error) {
604
+ this.logger?.error('Failed to start recording with fallback device', error);
605
+ this.isPaused = true;
606
+ this.emit('onRecordingInterrupted', {
607
+ reason: 'deviceSwitchFailed',
608
+ isPaused: true,
609
+ timestamp: Date.now(),
610
+ message: 'Failed to switch to fallback device. Recording paused.',
611
+ });
612
+ return false;
613
+ }
614
+ }
615
+ catch (error) {
616
+ this.logger?.error('Failed to use fallback device', error);
617
+ this.isPaused = true;
618
+ this.emit('onRecordingInterrupted', {
619
+ reason: 'deviceSwitchFailed',
620
+ isPaused: true,
621
+ timestamp: Date.now(),
622
+ message: 'Failed to switch to fallback device. Recording paused.',
623
+ });
624
+ return false;
625
+ }
626
+ }
627
+ /**
628
+ * Attempts to get a fallback audio device
629
+ */
630
+ async getFallbackDevice() {
631
+ try {
632
+ // Get list of available audio input devices
633
+ const devices = await navigator.mediaDevices.enumerateDevices();
634
+ const audioInputDevices = devices.filter((device) => device.kind === 'audioinput');
635
+ if (audioInputDevices.length === 0) {
636
+ return null;
637
+ }
638
+ // Try to find a device that's not the current one
639
+ if (this.customRecorder) {
640
+ try {
641
+ // Use mediaDevices.enumerateDevices to find the current active device
642
+ const tracks = navigator.mediaDevices
643
+ .getUserMedia({ audio: true })
644
+ .then((stream) => {
645
+ const track = stream.getAudioTracks()[0];
646
+ return track ? track.label : '';
647
+ })
648
+ .catch(() => '');
649
+ const currentTrackLabel = await tracks;
650
+ if (currentTrackLabel) {
651
+ // Find a device with a different label
652
+ const differentDevice = audioInputDevices.find((device) => device.label &&
653
+ device.label !== currentTrackLabel);
654
+ if (differentDevice) {
655
+ return differentDevice;
656
+ }
657
+ }
658
+ }
659
+ catch (err) {
660
+ this.logger?.warn('Error determining current device, using default');
661
+ }
662
+ }
663
+ // Return the first available device (default device)
664
+ return audioInputDevices[0];
665
+ }
666
+ catch (error) {
667
+ this.logger?.error('Error finding fallback device:', error);
668
+ return null;
669
+ }
670
+ }
671
+ /**
672
+ * Gets user media with specific device ID
673
+ */
674
+ async requestPermissionsAndGetUserMedia(deviceId) {
675
+ try {
676
+ // Request the specific device
677
+ return await navigator.mediaDevices.getUserMedia({
678
+ audio: {
679
+ deviceId: { exact: deviceId },
680
+ },
681
+ });
682
+ }
683
+ catch (error) {
684
+ this.logger?.error(`Failed to get media for device ${deviceId}`, error);
685
+ // Try with default constraints as fallback
686
+ return await navigator.mediaDevices.getUserMedia({ audio: true });
687
+ }
688
+ }
689
+ init(options) {
690
+ try {
691
+ this.logger = options?.logger;
692
+ this.eventCallback = options?.eventCallback;
693
+ return Promise.resolve();
694
+ }
695
+ catch (error) {
696
+ this.logger?.error('Error initializing ExpoAudioStream', error);
697
+ return Promise.reject(error);
698
+ }
699
+ }
284
700
  }
285
701
  //# sourceMappingURL=ExpoAudioStream.web.js.map