@siteed/expo-audio-studio 2.4.0 → 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 (81) 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 +581 -255
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +435 -158
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +14 -5
  13. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  14. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  15. package/build/AudioDeviceManager.d.ts +107 -0
  16. package/build/AudioDeviceManager.d.ts.map +1 -0
  17. package/build/AudioDeviceManager.js +493 -0
  18. package/build/AudioDeviceManager.js.map +1 -0
  19. package/build/AudioRecorder.provider.d.ts.map +1 -1
  20. package/build/AudioRecorder.provider.js +3 -0
  21. package/build/AudioRecorder.provider.js.map +1 -1
  22. package/build/ExpoAudioStream.types.d.ts +90 -1
  23. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  24. package/build/ExpoAudioStream.types.js +7 -1
  25. package/build/ExpoAudioStream.types.js.map +1 -1
  26. package/build/ExpoAudioStream.web.d.ts +37 -0
  27. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  28. package/build/ExpoAudioStream.web.js +399 -54
  29. package/build/ExpoAudioStream.web.js.map +1 -1
  30. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  31. package/build/ExpoAudioStreamModule.js +20 -0
  32. package/build/ExpoAudioStreamModule.js.map +1 -1
  33. package/build/WebRecorder.web.d.ts +63 -10
  34. package/build/WebRecorder.web.d.ts.map +1 -1
  35. package/build/WebRecorder.web.js +277 -68
  36. package/build/WebRecorder.web.js.map +1 -1
  37. package/build/hooks/useAudioDevices.d.ts +14 -0
  38. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  39. package/build/hooks/useAudioDevices.js +151 -0
  40. package/build/hooks/useAudioDevices.js.map +1 -0
  41. package/build/index.d.ts +2 -0
  42. package/build/index.d.ts.map +1 -1
  43. package/build/index.js +4 -0
  44. package/build/index.js.map +1 -1
  45. package/build/useAudioRecorder.d.ts +1 -0
  46. package/build/useAudioRecorder.d.ts.map +1 -1
  47. package/build/useAudioRecorder.js +20 -1
  48. package/build/useAudioRecorder.js.map +1 -1
  49. package/build/utils/BlobFix.d.ts.map +1 -1
  50. package/build/utils/BlobFix.js +2 -2
  51. package/build/utils/BlobFix.js.map +1 -1
  52. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  53. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  54. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  55. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  56. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  57. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  58. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  59. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  60. package/ios/AudioDeviceManager.swift +654 -0
  61. package/ios/AudioStreamManager.swift +964 -760
  62. package/ios/ExpoAudioStreamModule.swift +174 -19
  63. package/ios/Features.swift +1 -1
  64. package/ios/ISSUE_IOS.md +45 -0
  65. package/ios/Logger.swift +13 -1
  66. package/ios/RecordingSettings.swift +12 -0
  67. package/package.json +2 -2
  68. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  69. package/src/AudioDeviceManager.ts +571 -0
  70. package/src/AudioRecorder.provider.tsx +3 -0
  71. package/src/ExpoAudioStream.types.ts +97 -1
  72. package/src/ExpoAudioStream.web.ts +513 -63
  73. package/src/ExpoAudioStreamModule.ts +23 -0
  74. package/src/WebRecorder.web.ts +346 -81
  75. package/src/hooks/useAudioDevices.ts +180 -0
  76. package/src/index.ts +6 -0
  77. package/src/types/crc-32.d.ts +6 -6
  78. package/src/useAudioRecorder.tsx +27 -1
  79. package/src/utils/BlobFix.ts +6 -4
  80. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  81. package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
@@ -800,6 +800,29 @@ if (Platform.OS === 'web') {
800
800
  delete ExpoAudioStreamModule.listeners[eventName]
801
801
  }
802
802
  }
803
+
804
+ ExpoAudioStreamModule.prepareRecording = async (options: any) => {
805
+ // For web platform, we'll implement a simplified version that just checks permissions
806
+ // and does minimal setup. The actual recording setup will still happen in startRecording.
807
+ try {
808
+ // Check for microphone permissions
809
+ const permissionsResult =
810
+ await ExpoAudioStreamModule.getPermissionsAsync()
811
+ if (!permissionsResult.granted) {
812
+ throw new Error('Microphone permission not granted')
813
+ }
814
+
815
+ // If using a web instance, call its prepareRecording method
816
+ if (instance) {
817
+ return await instance.prepareRecording(options)
818
+ }
819
+
820
+ return true
821
+ } catch (error) {
822
+ console.error('Error preparing recording:', error)
823
+ throw error
824
+ }
825
+ }
803
826
  }
804
827
 
805
828
  // Move the encodeCompressedAudio function outside the if block to fix the ESLint error
@@ -15,6 +15,7 @@ interface AudioWorkletEvent {
15
15
  command: string
16
16
  recordedData?: Float32Array
17
17
  sampleRate?: number
18
+ position?: number
18
19
  }
19
20
  }
20
21
 
@@ -33,7 +34,7 @@ const DEFAULT_WEB_NUMBER_OF_CHANNELS = 1
33
34
  const TAG = 'WebRecorder'
34
35
 
35
36
  export class WebRecorder {
36
- private audioContext: AudioContext
37
+ public audioContext: AudioContext
37
38
  private audioWorkletNode!: AudioWorkletNode
38
39
  private featureExtractorWorker?: Worker
39
40
  private source: MediaStreamAudioSourceNode
@@ -45,14 +46,33 @@ export class WebRecorder {
45
46
  private bitDepth: number // Bit depth of the audio
46
47
  private exportBitDepth: number // Bit depth of the audio
47
48
  private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
48
- private packetCount: number = 0
49
49
  private logger?: ConsoleLike
50
50
  private compressedMediaRecorder: MediaRecorder | null = null
51
51
  private compressedChunks: Blob[] = []
52
52
  private compressedSize: number = 0
53
53
  private pendingCompressedChunk: Blob | null = null
54
- private readonly wavMimeType = 'audio/wav'
55
54
  private dataPointIdCounter: number = 0 // Add this property to track the counter
55
+ private deviceDisconnectionHandler: (() => void) | null = null
56
+ private mediaStream: MediaStream | null = null
57
+ private onInterruptionCallback?: (event: {
58
+ reason: string
59
+ isPaused: boolean
60
+ timestamp: number
61
+ }) => void
62
+ private _isDeviceDisconnected: boolean = false
63
+
64
+ /**
65
+ * Flag to indicate whether this is the first audio chunk after a device switch
66
+ * Used to maintain proper duration counting
67
+ */
68
+ public isFirstChunkAfterSwitch: boolean = false
69
+
70
+ /**
71
+ * Gets whether the recording device has been disconnected
72
+ */
73
+ get isDeviceDisconnected(): boolean {
74
+ return this._isDeviceDisconnected
75
+ }
56
76
 
57
77
  /**
58
78
  * Initializes a new WebRecorder instance for audio recording and processing
@@ -61,6 +81,7 @@ export class WebRecorder {
61
81
  * @param recordingConfig - Configuration options for the recording
62
82
  * @param emitAudioEventCallback - Callback function for audio data events
63
83
  * @param emitAudioAnalysisCallback - Callback function for audio analysis events
84
+ * @param onInterruption - Callback for recording interruptions
64
85
  * @param logger - Optional logger for debugging information
65
86
  */
66
87
  constructor({
@@ -69,6 +90,7 @@ export class WebRecorder {
69
90
  recordingConfig,
70
91
  emitAudioEventCallback,
71
92
  emitAudioAnalysisCallback,
93
+ onInterruption,
72
94
  logger,
73
95
  }: {
74
96
  audioContext: AudioContext
@@ -76,6 +98,11 @@ export class WebRecorder {
76
98
  recordingConfig: RecordingConfig
77
99
  emitAudioEventCallback: EmitAudioEventFunction
78
100
  emitAudioAnalysisCallback: EmitAudioAnalysisFunction
101
+ onInterruption?: (event: {
102
+ reason: string
103
+ isPaused: boolean
104
+ timestamp: number
105
+ }) => void
79
106
  logger?: ConsoleLike
80
107
  }) {
81
108
  this.audioContext = audioContext
@@ -126,6 +153,12 @@ export class WebRecorder {
126
153
  if (recordingConfig.compression?.enabled) {
127
154
  this.initializeCompressedRecorder()
128
155
  }
156
+
157
+ this.mediaStream = source.mediaStream
158
+ this.onInterruptionCallback = onInterruption
159
+
160
+ // Setup device disconnection detection
161
+ this.setupDeviceDisconnectionDetection()
129
162
  }
130
163
 
131
164
  /**
@@ -164,13 +197,19 @@ export class WebRecorder {
164
197
  event.data.sampleRate ?? this.audioContext.sampleRate
165
198
  const duration = pcmBufferFloat.length / sampleRate
166
199
 
200
+ // Use incoming position if provided by worklet, otherwise use our tracked position
201
+ const incomingPosition =
202
+ typeof event.data.position === 'number'
203
+ ? event.data.position
204
+ : this.position
205
+
167
206
  // Calculate bytes per sample based on bit depth
168
207
  const bytesPerSample = this.bitDepth / 8
169
208
 
170
209
  // Emit chunks without storing them
171
210
  for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
172
211
  const chunk = pcmBufferFloat.slice(i, i + chunkSize)
173
- const chunkPosition = this.position + i / sampleRate
212
+ const chunkPosition = incomingPosition + i / sampleRate
174
213
 
175
214
  // Calculate byte positions and samples
176
215
  const startPosition = Math.floor(i * bytesPerSample)
@@ -221,12 +260,13 @@ export class WebRecorder {
221
260
  })
222
261
  }
223
262
 
224
- this.position += duration
263
+ // Update our position based on the worklet's position if provided
264
+ this.position = incomingPosition + duration
225
265
  this.pendingCompressedChunk = null
226
266
  }
227
267
 
228
268
  this.logger?.debug(
229
- `WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}`,
269
+ `WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}, startPosition=${this.position}`,
230
270
  this.config
231
271
  )
232
272
  this.audioWorkletNode.port.postMessage({
@@ -238,6 +278,7 @@ export class WebRecorder {
238
278
  exportBitDepth: this.exportBitDepth,
239
279
  channels: this.numberOfChannels,
240
280
  interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
281
+ position: this.position, // Pass the current position to the processor
241
282
  // enableLogging: !!this.logger,
242
283
  })
243
284
 
@@ -265,6 +306,18 @@ export class WebRecorder {
265
306
  this.featureExtractorWorker.onerror = (error) => {
266
307
  console.error(`[${TAG}] Feature extractor worker error:`, error)
267
308
  }
309
+
310
+ // Initialize worker with counter if needed
311
+ if (this.dataPointIdCounter > 0) {
312
+ this.featureExtractorWorker.postMessage({
313
+ command: 'resetCounter',
314
+ value: this.dataPointIdCounter,
315
+ })
316
+ this.logger?.debug(
317
+ `Initialized worker with counter value ${this.dataPointIdCounter}`
318
+ )
319
+ }
320
+
268
321
  this.logger?.log(
269
322
  'Feature extractor worker initialized successfully'
270
323
  )
@@ -285,42 +338,51 @@ export class WebRecorder {
285
338
  if (event.data.command === 'features') {
286
339
  const segmentResult = event.data.result
287
340
 
288
- // Update the dataPointIdCounter based on the last ID received
341
+ // Track existing IDs to prevent duplicates
342
+ const existingIds = new Set(
343
+ this.audioAnalysisData.dataPoints.map((dp) => dp.id)
344
+ )
345
+
346
+ // Filter out datapoints with duplicate IDs
347
+ const uniqueNewDataPoints = segmentResult.dataPoints.filter(
348
+ (dp) => {
349
+ return !existingIds.has(dp.id)
350
+ }
351
+ )
352
+
353
+ // Log filtered duplicates if any
289
354
  if (
290
- segmentResult.dataPoints &&
291
- segmentResult.dataPoints.length > 0
355
+ uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
356
+ this.logger?.warn
292
357
  ) {
358
+ this.logger.warn(
359
+ `Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`
360
+ )
361
+ }
362
+
363
+ // Update counter based on the highest ID seen
364
+ if (uniqueNewDataPoints.length > 0) {
293
365
  const lastDataPoint =
294
- segmentResult.dataPoints[
295
- segmentResult.dataPoints.length - 1
296
- ]
366
+ uniqueNewDataPoints[uniqueNewDataPoints.length - 1]
367
+
297
368
  if (lastDataPoint && typeof lastDataPoint.id === 'number') {
298
- this.dataPointIdCounter = Math.max(
299
- this.dataPointIdCounter,
300
- lastDataPoint.id + 1
301
- )
369
+ const nextIdValue = lastDataPoint.id + 1
370
+
371
+ if (nextIdValue > this.dataPointIdCounter) {
372
+ this.dataPointIdCounter = nextIdValue
373
+ this.logger?.debug(
374
+ `Counter updated to ${this.dataPointIdCounter}`
375
+ )
376
+ }
302
377
  }
303
378
  }
304
379
 
305
- this.logger?.debug('[WebRecorder] Raw segment result:', {
306
- dataPointsLength: segmentResult.dataPoints.length,
307
- durationMs: segmentResult.durationMs,
308
- sampleRate: segmentResult.sampleRate,
309
- amplitudeRange: segmentResult.amplitudeRange,
310
- })
311
-
312
- // Ensure consistent sample rate in the result
313
- segmentResult.sampleRate =
314
- this.config.sampleRate || this.audioContext.sampleRate
315
-
316
- // Update the full audio analysis data with proper range merging
317
- this.audioAnalysisData.dataPoints.push(...segmentResult.dataPoints)
380
+ // Add unique data points to our analysis data
381
+ this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints)
318
382
  this.audioAnalysisData.durationMs += segmentResult.durationMs
319
-
320
- // Make sure the sample rate is consistent
321
383
  this.audioAnalysisData.sampleRate = segmentResult.sampleRate
322
384
 
323
- // Properly merge amplitude ranges
385
+ // Merge amplitude ranges
324
386
  if (segmentResult.amplitudeRange) {
325
387
  if (!this.audioAnalysisData.amplitudeRange) {
326
388
  this.audioAnalysisData.amplitudeRange = {
@@ -340,7 +402,7 @@ export class WebRecorder {
340
402
  }
341
403
  }
342
404
 
343
- // Properly merge RMS ranges
405
+ // Merge RMS ranges
344
406
  if (segmentResult.rmsRange) {
345
407
  if (!this.audioAnalysisData.rmsRange) {
346
408
  this.audioAnalysisData.rmsRange = {
@@ -360,49 +422,81 @@ export class WebRecorder {
360
422
  }
361
423
  }
362
424
 
363
- this.logger?.debug('features event segmentResult', segmentResult)
364
- this.logger?.debug(
365
- `features event audioAnalysisData duration=${this.audioAnalysisData.durationMs}`,
366
- this.audioAnalysisData
367
- )
368
- this.emitAudioAnalysisCallback(segmentResult)
425
+ // Send filtered result to avoid duplicate IDs
426
+ const filteredSegmentResult = {
427
+ ...segmentResult,
428
+ dataPoints: uniqueNewDataPoints,
429
+ }
369
430
 
370
- this.logger?.debug('[WebRecorder] Updated audioAnalysisData:', {
371
- dataPointsLength: this.audioAnalysisData.dataPoints.length,
372
- durationMs: this.audioAnalysisData.durationMs,
373
- sampleRate: this.audioAnalysisData.sampleRate,
374
- amplitudeRange: this.audioAnalysisData.amplitudeRange,
375
- })
431
+ this.emitAudioAnalysisCallback(filteredSegmentResult)
376
432
  }
377
433
  }
378
434
 
379
435
  /**
380
- * Resets the data point ID counter
381
- * Used when starting a new recording
436
+ * Reset the data point counter to a specific value or zero
437
+ * @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
382
438
  */
383
- resetDataPointCounter() {
384
- this.dataPointIdCounter = 0
439
+ resetDataPointCounter(startCounterFrom?: number): void {
440
+ // Set the counter with the passed value or 0
441
+ this.dataPointIdCounter =
442
+ startCounterFrom !== undefined ? startCounterFrom : 0
443
+ this.logger?.debug(
444
+ `Reset data point counter to ${this.dataPointIdCounter}`
445
+ )
385
446
 
386
- // Reset the counter in the worker
447
+ // Update worker counter if available
387
448
  if (this.featureExtractorWorker) {
388
449
  this.featureExtractorWorker.postMessage({
389
450
  command: 'resetCounter',
390
- startCounterFrom: 0,
451
+ value: this.dataPointIdCounter,
391
452
  })
453
+ } else {
454
+ this.logger?.warn(
455
+ 'No feature extractor worker available to update counter'
456
+ )
392
457
  }
393
458
  }
394
459
 
460
+ /**
461
+ * Get the current data point counter value
462
+ * @returns The current value of the data point counter
463
+ */
464
+ getDataPointCounter(): number {
465
+ return this.dataPointIdCounter
466
+ }
467
+
468
+ /**
469
+ * Prepares the recorder for continuity after device switch
470
+ * Sets up all necessary state to maintain proper recording continuity
471
+ */
472
+ prepareForDeviceSwitch(): void {
473
+ this.isFirstChunkAfterSwitch = true
474
+ this.logger?.debug(
475
+ `Prepared for device switch at position ${this.position}s`
476
+ )
477
+ }
478
+
395
479
  /**
396
480
  * Starts the audio recording process
397
481
  * Connects the audio nodes and begins capturing audio data
482
+ * @param preserveCounters If true, do not reset the counter (used for device switching)
398
483
  */
399
- start() {
484
+ start(preserveCounters = false) {
400
485
  this.source.connect(this.audioWorkletNode)
401
486
  this.audioWorkletNode.connect(this.audioContext.destination)
402
- this.packetCount = 0
403
487
 
404
- // Reset the counter when starting a new recording
405
- this.resetDataPointCounter()
488
+ // Only reset the counter when not preserving state (e.g., for a fresh recording)
489
+ if (!preserveCounters) {
490
+ this.logger?.debug(
491
+ 'Starting fresh recording, resetting counter to 0'
492
+ )
493
+ this.resetDataPointCounter(0) // Explicitly reset to 0 for new recordings
494
+ this.isFirstChunkAfterSwitch = false
495
+ } else {
496
+ this.logger?.debug(
497
+ `Preserving counter at ${this.dataPointIdCounter} during device switch`
498
+ )
499
+ }
406
500
 
407
501
  if (this.compressedMediaRecorder) {
408
502
  this.compressedMediaRecorder.start(this.config.interval ?? 1000)
@@ -411,20 +505,40 @@ export class WebRecorder {
411
505
 
412
506
  /**
413
507
  * Stops the audio recording process and returns the recorded data
508
+ * @param externalAudioChunks Optional array of Float32Array chunks from previous devices
414
509
  * @returns Promise resolving to an object containing PCM data and optional compressed blob
415
510
  */
416
- async stop(): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
511
+ async stop(
512
+ externalAudioChunks?: Float32Array[]
513
+ ): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
417
514
  try {
418
- if (this.compressedMediaRecorder) {
515
+ // Log what's happening for debugging
516
+ this.logger?.debug('Stopping recording and collecting final data')
517
+
518
+ // Stop any compressed recording first
519
+ if (
520
+ this.compressedMediaRecorder &&
521
+ this.compressedMediaRecorder.state !== 'inactive'
522
+ ) {
419
523
  this.compressedMediaRecorder.stop()
420
- return {
421
- pcmData: new Float32Array(), // Return empty array since we're streaming
422
- compressedBlob: new Blob(this.compressedChunks, {
423
- type: 'audio/webm;codecs=opus',
424
- }),
425
- }
426
524
  }
427
- return { pcmData: new Float32Array() }
525
+
526
+ // Wait for any pending compressed chunks to be processed
527
+ if (this.compressedMediaRecorder) {
528
+ // Small delay to ensure all data is processed
529
+ await new Promise((resolve) => setTimeout(resolve, 100))
530
+ }
531
+
532
+ // Return the compressed blob if available
533
+ return {
534
+ pcmData: new Float32Array(), // Return empty array since we're streaming
535
+ compressedBlob:
536
+ this.compressedChunks.length > 0
537
+ ? new Blob(this.compressedChunks, {
538
+ type: 'audio/webm;codecs=opus',
539
+ })
540
+ : undefined,
541
+ }
428
542
  } finally {
429
543
  this.cleanup()
430
544
  // Reset the chunks array
@@ -438,17 +552,45 @@ export class WebRecorder {
438
552
  * Cleans up resources when recording is stopped
439
553
  * Closes audio context and disconnects nodes
440
554
  */
441
- private cleanup() {
442
- if (this.audioContext) {
443
- this.audioContext.close()
555
+ public cleanup() {
556
+ // Remove device disconnection handler
557
+ if (this.deviceDisconnectionHandler) {
558
+ this.deviceDisconnectionHandler()
559
+ this.deviceDisconnectionHandler = null
444
560
  }
561
+
562
+ // Check if AudioContext is already closed before attempting to close it
563
+ if (this.audioContext && this.audioContext.state !== 'closed') {
564
+ try {
565
+ this.audioContext.close()
566
+ } catch (e) {
567
+ // Ignore closure errors - this happens if already closed
568
+ }
569
+ }
570
+
571
+ // Safely disconnect audioWorkletNode if it exists
445
572
  if (this.audioWorkletNode) {
446
- this.audioWorkletNode.disconnect()
573
+ try {
574
+ this.audioWorkletNode.disconnect()
575
+ } catch (e) {
576
+ // Ignore disconnection errors - node might be already disconnected
577
+ }
447
578
  }
579
+
580
+ // Safely disconnect source if it exists
448
581
  if (this.source) {
449
- this.source.disconnect()
582
+ try {
583
+ this.source.disconnect()
584
+ } catch (e) {
585
+ // Ignore disconnection errors - source might be already disconnected
586
+ }
450
587
  }
588
+
589
+ // Always stop media stream tracks to release hardware resources
451
590
  this.stopMediaStreamTracks()
591
+
592
+ // Mark as disconnected to prevent future errors
593
+ this._isDeviceDisconnected = true
452
594
  }
453
595
 
454
596
  /**
@@ -456,20 +598,37 @@ export class WebRecorder {
456
598
  * Disconnects audio nodes and pauses the media recorder
457
599
  */
458
600
  pause() {
459
- this.source.disconnect(this.audioWorkletNode) // Disconnect the source from the AudioWorkletNode
460
- this.audioWorkletNode.disconnect(this.audioContext.destination) // Disconnect the AudioWorkletNode from the destination
461
- this.audioWorkletNode.port.postMessage({ command: 'pause' })
462
- this.compressedMediaRecorder?.pause()
601
+ try {
602
+ // Note: We're just pausing, not disconnecting the device
603
+ // Simply disconnect nodes temporarily without marking device as disconnected
604
+ this.source.disconnect(this.audioWorkletNode)
605
+ this.audioWorkletNode.disconnect(this.audioContext.destination)
606
+ this.audioWorkletNode.port.postMessage({ command: 'pause' })
607
+
608
+ if (this.compressedMediaRecorder?.state === 'recording') {
609
+ this.compressedMediaRecorder.pause()
610
+ }
611
+
612
+ this.logger?.debug('Recording paused successfully')
613
+ } catch (error) {
614
+ this.logger?.error('Error in pause(): ', error)
615
+ // Already disconnected, just ignore and continue
616
+ }
463
617
  }
464
618
 
465
619
  /**
466
620
  * Stops all media stream tracks to release hardware resources
467
621
  * Ensures recording indicators (like microphone icon) are turned off
468
622
  */
469
- stopMediaStreamTracks() {
623
+ public stopMediaStreamTracks() {
470
624
  // Stop all audio tracks to stop the recording icon
471
- const tracks = this.source.mediaStream.getTracks()
472
- tracks.forEach((track) => track.stop())
625
+ if (this.mediaStream) {
626
+ const tracks = this.mediaStream.getTracks()
627
+ tracks.forEach((track) => track.stop())
628
+ } else if (this.source?.mediaStream) {
629
+ const tracks = this.source.mediaStream.getTracks()
630
+ tracks.forEach((track) => track.stop())
631
+ }
473
632
  }
474
633
 
475
634
  /**
@@ -502,10 +661,20 @@ export class WebRecorder {
502
661
  * Reconnects audio nodes and resumes the media recorder
503
662
  */
504
663
  resume() {
505
- this.source.connect(this.audioWorkletNode)
506
- this.audioWorkletNode.connect(this.audioContext.destination)
507
- this.audioWorkletNode.port.postMessage({ command: 'resume' })
508
- this.compressedMediaRecorder?.resume()
664
+ // If device was disconnected, we can't resume
665
+ if (this._isDeviceDisconnected) {
666
+ this.logger?.warn('Cannot resume recording: device disconnected')
667
+ return
668
+ }
669
+
670
+ try {
671
+ this.source.connect(this.audioWorkletNode)
672
+ this.audioWorkletNode.connect(this.audioContext.destination)
673
+ this.audioWorkletNode.port.postMessage({ command: 'resume' })
674
+ this.compressedMediaRecorder?.resume()
675
+ } catch (error) {
676
+ this.logger?.error('Error in resume(): ', error)
677
+ }
509
678
  }
510
679
 
511
680
  /**
@@ -573,8 +742,104 @@ export class WebRecorder {
573
742
  startPosition,
574
743
  endPosition,
575
744
  samples,
576
- startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
577
745
  })
578
746
  }
579
747
  }
748
+
749
+ /**
750
+ * Sets up detection for device disconnection events
751
+ */
752
+ private setupDeviceDisconnectionDetection() {
753
+ if (!this.mediaStream) return
754
+
755
+ // Function to handle track ending (which happens on device disconnection)
756
+ const handleTrackEnded = () => {
757
+ this.logger?.warn('Audio track ended - device disconnected')
758
+ this._isDeviceDisconnected = true
759
+
760
+ // Use the callback to notify parent component about device disconnection
761
+ if (this.onInterruptionCallback) {
762
+ this.onInterruptionCallback({
763
+ reason: 'deviceDisconnected',
764
+ isPaused: true,
765
+ timestamp: Date.now(),
766
+ })
767
+ this.logger?.debug('Notified about device disconnection')
768
+ }
769
+
770
+ // Ensure we disconnect nodes to prevent zombie recordings
771
+ if (this.audioWorkletNode) {
772
+ this.audioWorkletNode.port.postMessage({
773
+ command: 'deviceDisconnected',
774
+ })
775
+
776
+ try {
777
+ this.source.disconnect(this.audioWorkletNode)
778
+ this.audioWorkletNode.disconnect()
779
+ } catch (e) {
780
+ // Ignore disconnection errors as the track might already be gone
781
+ }
782
+ }
783
+ }
784
+
785
+ // Add listeners to all audio tracks
786
+ const tracks = this.mediaStream.getAudioTracks()
787
+ tracks.forEach((track) => {
788
+ track.addEventListener('ended', handleTrackEnded)
789
+ })
790
+
791
+ // Store the handler for cleanup
792
+ this.deviceDisconnectionHandler = () => {
793
+ tracks.forEach((track) => {
794
+ track.removeEventListener('ended', handleTrackEnded)
795
+ })
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Explicitly set the position for continuous recording across device switches
801
+ * @param position The position in seconds to continue from
802
+ */
803
+ setPosition(position: number): void {
804
+ if (position >= 0) {
805
+ this.position = position
806
+ this.logger?.debug(`Position explicitly set to ${position} seconds`)
807
+ } else {
808
+ this.logger?.warn(`Invalid position value: ${position}, ignoring`)
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Get the current position in seconds
814
+ * @returns The current position
815
+ */
816
+ getPosition(): number {
817
+ return this.position
818
+ }
819
+
820
+ /**
821
+ * Gets the current compressed chunks
822
+ * @returns Array of current compressed audio chunks
823
+ */
824
+ getCompressedChunks(): Blob[] {
825
+ return [...this.compressedChunks]
826
+ }
827
+
828
+ /**
829
+ * Sets the compressed chunks from a previous recorder
830
+ * @param chunks Array of compressed chunks from a previous recorder
831
+ */
832
+ setCompressedChunks(chunks: Blob[]): void {
833
+ if (chunks && chunks.length > 0) {
834
+ this.logger?.debug(
835
+ `Adding ${chunks.length} compressed chunks from previous device`
836
+ )
837
+ this.compressedChunks = [...chunks, ...this.compressedChunks]
838
+ // Update size
839
+ this.compressedSize = this.compressedChunks.reduce(
840
+ (size, chunk) => size + chunk.size,
841
+ 0
842
+ )
843
+ }
844
+ }
580
845
  }