@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
@@ -7,6 +7,7 @@ import {
7
7
  EmitAudioEventFunction,
8
8
  } from './ExpoAudioStream.web'
9
9
  import { encodingToBitDepth } from './utils/encodingToBitDepth'
10
+ import { writeWavHeader } from './utils/writeWavHeader'
10
11
  import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web'
11
12
  import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web'
12
13
 
@@ -15,6 +16,8 @@ interface AudioWorkletEvent {
15
16
  command: string
16
17
  recordedData?: Float32Array
17
18
  sampleRate?: number
19
+ position?: number
20
+ message?: string // For debug messages
18
21
  }
19
22
  }
20
23
 
@@ -33,7 +36,7 @@ const DEFAULT_WEB_NUMBER_OF_CHANNELS = 1
33
36
  const TAG = 'WebRecorder'
34
37
 
35
38
  export class WebRecorder {
36
- private audioContext: AudioContext
39
+ public audioContext: AudioContext
37
40
  private audioWorkletNode!: AudioWorkletNode
38
41
  private featureExtractorWorker?: Worker
39
42
  private source: MediaStreamAudioSourceNode
@@ -45,14 +48,35 @@ export class WebRecorder {
45
48
  private bitDepth: number // Bit depth of the audio
46
49
  private exportBitDepth: number // Bit depth of the audio
47
50
  private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
48
- private packetCount: number = 0
49
51
  private logger?: ConsoleLike
50
52
  private compressedMediaRecorder: MediaRecorder | null = null
51
53
  private compressedChunks: Blob[] = []
52
54
  private compressedSize: number = 0
53
55
  private pendingCompressedChunk: Blob | null = null
54
- private readonly wavMimeType = 'audio/wav'
55
56
  private dataPointIdCounter: number = 0 // Add this property to track the counter
57
+ private deviceDisconnectionHandler: (() => void) | null = null
58
+ private mediaStream: MediaStream | null = null
59
+ private onInterruptionCallback?: (event: {
60
+ reason: string
61
+ isPaused: boolean
62
+ timestamp: number
63
+ }) => void
64
+ private _isDeviceDisconnected: boolean = false
65
+ private pcmData: Float32Array | null = null // Store original PCM data
66
+ private totalSampleCount: number = 0
67
+
68
+ /**
69
+ * Flag to indicate whether this is the first audio chunk after a device switch
70
+ * Used to maintain proper duration counting
71
+ */
72
+ public isFirstChunkAfterSwitch: boolean = false
73
+
74
+ /**
75
+ * Gets whether the recording device has been disconnected
76
+ */
77
+ get isDeviceDisconnected(): boolean {
78
+ return this._isDeviceDisconnected
79
+ }
56
80
 
57
81
  /**
58
82
  * Initializes a new WebRecorder instance for audio recording and processing
@@ -61,6 +85,7 @@ export class WebRecorder {
61
85
  * @param recordingConfig - Configuration options for the recording
62
86
  * @param emitAudioEventCallback - Callback function for audio data events
63
87
  * @param emitAudioAnalysisCallback - Callback function for audio analysis events
88
+ * @param onInterruption - Callback for recording interruptions
64
89
  * @param logger - Optional logger for debugging information
65
90
  */
66
91
  constructor({
@@ -69,6 +94,7 @@ export class WebRecorder {
69
94
  recordingConfig,
70
95
  emitAudioEventCallback,
71
96
  emitAudioAnalysisCallback,
97
+ onInterruption,
72
98
  logger,
73
99
  }: {
74
100
  audioContext: AudioContext
@@ -76,6 +102,11 @@ export class WebRecorder {
76
102
  recordingConfig: RecordingConfig
77
103
  emitAudioEventCallback: EmitAudioEventFunction
78
104
  emitAudioAnalysisCallback: EmitAudioAnalysisFunction
105
+ onInterruption?: (event: {
106
+ reason: string
107
+ isPaused: boolean
108
+ timestamp: number
109
+ }) => void
79
110
  logger?: ConsoleLike
80
111
  }) {
81
112
  this.audioContext = audioContext
@@ -126,6 +157,12 @@ export class WebRecorder {
126
157
  if (recordingConfig.compression?.enabled) {
127
158
  this.initializeCompressedRecorder()
128
159
  }
160
+
161
+ this.mediaStream = source.mediaStream
162
+ this.onInterruptionCallback = onInterruption
163
+
164
+ // Setup device disconnection detection
165
+ this.setupDeviceDisconnectionDetection()
129
166
  }
130
167
 
131
168
  /**
@@ -150,6 +187,11 @@ export class WebRecorder {
150
187
  event: AudioWorkletEvent
151
188
  ) => {
152
189
  const command = event.data.command
190
+ if (command === 'debug') {
191
+ this.logger?.debug(`[AudioWorklet] ${event.data.message}`)
192
+ return
193
+ }
194
+
153
195
  if (command !== 'newData') return
154
196
 
155
197
  const pcmBufferFloat = event.data.recordedData
@@ -159,18 +201,26 @@ export class WebRecorder {
159
201
  }
160
202
 
161
203
  // Process data in smaller chunks and emit immediately
162
- const chunkSize = this.audioContext.sampleRate * 2 // Reduce to 2 seconds chunks
163
204
  const sampleRate =
164
205
  event.data.sampleRate ?? this.audioContext.sampleRate
206
+ // Use chunk size from config interval or default to 2 seconds
207
+ const intervalMs = this.config.interval ?? DEFAULT_WEB_INTERVAL
208
+ const chunkSize = Math.floor(sampleRate * (intervalMs / 1000))
165
209
  const duration = pcmBufferFloat.length / sampleRate
166
210
 
211
+ // Use incoming position if provided by worklet, otherwise use our tracked position
212
+ const incomingPosition =
213
+ typeof event.data.position === 'number'
214
+ ? event.data.position
215
+ : this.position
216
+
167
217
  // Calculate bytes per sample based on bit depth
168
218
  const bytesPerSample = this.bitDepth / 8
169
219
 
170
220
  // Emit chunks without storing them
171
221
  for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
172
222
  const chunk = pcmBufferFloat.slice(i, i + chunkSize)
173
- const chunkPosition = this.position + i / sampleRate
223
+ const chunkPosition = incomingPosition + i / sampleRate
174
224
 
175
225
  // Calculate byte positions and samples
176
226
  const startPosition = Math.floor(i * bytesPerSample)
@@ -202,6 +252,17 @@ export class WebRecorder {
202
252
  })
203
253
  }
204
254
 
255
+ // Only store PCM data if web.storeUncompressedAudio is not explicitly false
256
+ const shouldStoreUncompressed =
257
+ this.config.web?.storeUncompressedAudio !== false
258
+
259
+ // Store PCM chunks when needed
260
+ if (shouldStoreUncompressed) {
261
+ // Store the original Float32Array data for later WAV creation
262
+ this.appendPcmData(chunk)
263
+ this.totalSampleCount += chunk.length
264
+ }
265
+
205
266
  // Emit chunk immediately
206
267
  this.emitAudioEventCallback({
207
268
  data: chunk,
@@ -221,24 +282,47 @@ export class WebRecorder {
221
282
  })
222
283
  }
223
284
 
224
- this.position += duration
285
+ // Update our position based on the worklet's position if provided
286
+ this.position = incomingPosition + duration
225
287
  this.pendingCompressedChunk = null
226
288
  }
227
289
 
228
- this.logger?.debug(
229
- `WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}`,
230
- this.config
231
- )
290
+ // Ensure we use all relevant settings from config
291
+ const recordSampleRate = this.audioContext.sampleRate
292
+ const exportSampleRate =
293
+ this.config.sampleRate ?? this.audioContext.sampleRate
294
+ const channels = this.config.channels ?? this.numberOfChannels
295
+ const interval = this.config.interval ?? DEFAULT_WEB_INTERVAL
296
+
297
+ this.logger?.debug(`WebRecorder initialized with config:`, {
298
+ recordSampleRate,
299
+ exportSampleRate,
300
+ bitDepth: this.bitDepth,
301
+ exportBitDepth: this.exportBitDepth,
302
+ channels,
303
+ interval,
304
+ position: this.position,
305
+ deviceId: this.config.deviceId || 'default',
306
+ compression: this.config.compression
307
+ ? {
308
+ enabled: this.config.compression.enabled,
309
+ format: this.config.compression.format,
310
+ bitrate: this.config.compression.bitrate,
311
+ }
312
+ : 'disabled',
313
+ })
314
+
315
+ // Initialize the worklet with all settings from config
232
316
  this.audioWorkletNode.port.postMessage({
233
317
  command: 'init',
234
- recordSampleRate: this.audioContext.sampleRate,
235
- exportSampleRate:
236
- this.config.sampleRate ?? this.audioContext.sampleRate,
318
+ recordSampleRate,
319
+ exportSampleRate,
237
320
  bitDepth: this.bitDepth,
238
321
  exportBitDepth: this.exportBitDepth,
239
- channels: this.numberOfChannels,
240
- interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
241
- // enableLogging: !!this.logger,
322
+ channels,
323
+ interval,
324
+ position: this.position, // Pass the current position to the processor
325
+ enableLogging: true,
242
326
  })
243
327
 
244
328
  // Connect the source to the AudioWorkletNode and start recording
@@ -249,6 +333,35 @@ export class WebRecorder {
249
333
  }
250
334
  }
251
335
 
336
+ /**
337
+ * Append new PCM data to the existing buffer
338
+ * @param newData New Float32Array data to append
339
+ */
340
+ private appendPcmData(newData: Float32Array): void {
341
+ // Clone the incoming data to ensure it's not modified
342
+ const dataToAdd = new Float32Array(newData)
343
+
344
+ if (!this.pcmData) {
345
+ // First chunk - create a copy to avoid references to original data
346
+ this.pcmData = new Float32Array(dataToAdd)
347
+ return
348
+ }
349
+
350
+ // Create a new buffer with increased size
351
+ const newBuffer = new Float32Array(
352
+ this.pcmData.length + dataToAdd.length
353
+ )
354
+
355
+ // Copy existing data
356
+ newBuffer.set(this.pcmData)
357
+
358
+ // Append new data
359
+ newBuffer.set(dataToAdd, this.pcmData.length)
360
+
361
+ // Replace existing buffer
362
+ this.pcmData = newBuffer
363
+ }
364
+
252
365
  /**
253
366
  * Initializes the feature extractor worker for audio analysis
254
367
  * Creates an inline worker from a blob for audio feature extraction
@@ -265,6 +378,18 @@ export class WebRecorder {
265
378
  this.featureExtractorWorker.onerror = (error) => {
266
379
  console.error(`[${TAG}] Feature extractor worker error:`, error)
267
380
  }
381
+
382
+ // Initialize worker with counter if needed
383
+ if (this.dataPointIdCounter > 0) {
384
+ this.featureExtractorWorker.postMessage({
385
+ command: 'resetCounter',
386
+ value: this.dataPointIdCounter,
387
+ })
388
+ this.logger?.debug(
389
+ `Initialized worker with counter value ${this.dataPointIdCounter}`
390
+ )
391
+ }
392
+
268
393
  this.logger?.log(
269
394
  'Feature extractor worker initialized successfully'
270
395
  )
@@ -285,42 +410,51 @@ export class WebRecorder {
285
410
  if (event.data.command === 'features') {
286
411
  const segmentResult = event.data.result
287
412
 
288
- // Update the dataPointIdCounter based on the last ID received
413
+ // Track existing IDs to prevent duplicates
414
+ const existingIds = new Set(
415
+ this.audioAnalysisData.dataPoints.map((dp) => dp.id)
416
+ )
417
+
418
+ // Filter out datapoints with duplicate IDs
419
+ const uniqueNewDataPoints = segmentResult.dataPoints.filter(
420
+ (dp) => {
421
+ return !existingIds.has(dp.id)
422
+ }
423
+ )
424
+
425
+ // Log filtered duplicates if any
289
426
  if (
290
- segmentResult.dataPoints &&
291
- segmentResult.dataPoints.length > 0
427
+ uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
428
+ this.logger?.warn
292
429
  ) {
430
+ this.logger.warn(
431
+ `Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`
432
+ )
433
+ }
434
+
435
+ // Update counter based on the highest ID seen
436
+ if (uniqueNewDataPoints.length > 0) {
293
437
  const lastDataPoint =
294
- segmentResult.dataPoints[
295
- segmentResult.dataPoints.length - 1
296
- ]
438
+ uniqueNewDataPoints[uniqueNewDataPoints.length - 1]
439
+
297
440
  if (lastDataPoint && typeof lastDataPoint.id === 'number') {
298
- this.dataPointIdCounter = Math.max(
299
- this.dataPointIdCounter,
300
- lastDataPoint.id + 1
301
- )
441
+ const nextIdValue = lastDataPoint.id + 1
442
+
443
+ if (nextIdValue > this.dataPointIdCounter) {
444
+ this.dataPointIdCounter = nextIdValue
445
+ this.logger?.debug(
446
+ `Counter updated to ${this.dataPointIdCounter}`
447
+ )
448
+ }
302
449
  }
303
450
  }
304
451
 
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)
452
+ // Add unique data points to our analysis data
453
+ this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints)
318
454
  this.audioAnalysisData.durationMs += segmentResult.durationMs
319
-
320
- // Make sure the sample rate is consistent
321
455
  this.audioAnalysisData.sampleRate = segmentResult.sampleRate
322
456
 
323
- // Properly merge amplitude ranges
457
+ // Merge amplitude ranges
324
458
  if (segmentResult.amplitudeRange) {
325
459
  if (!this.audioAnalysisData.amplitudeRange) {
326
460
  this.audioAnalysisData.amplitudeRange = {
@@ -340,7 +474,7 @@ export class WebRecorder {
340
474
  }
341
475
  }
342
476
 
343
- // Properly merge RMS ranges
477
+ // Merge RMS ranges
344
478
  if (segmentResult.rmsRange) {
345
479
  if (!this.audioAnalysisData.rmsRange) {
346
480
  this.audioAnalysisData.rmsRange = {
@@ -360,77 +494,182 @@ export class WebRecorder {
360
494
  }
361
495
  }
362
496
 
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)
497
+ // Send filtered result to avoid duplicate IDs
498
+ const filteredSegmentResult = {
499
+ ...segmentResult,
500
+ dataPoints: uniqueNewDataPoints,
501
+ }
369
502
 
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
- })
503
+ this.emitAudioAnalysisCallback(filteredSegmentResult)
376
504
  }
377
505
  }
378
506
 
379
507
  /**
380
- * Resets the data point ID counter
381
- * Used when starting a new recording
508
+ * Reset the data point counter to a specific value or zero
509
+ * @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
382
510
  */
383
- resetDataPointCounter() {
384
- this.dataPointIdCounter = 0
511
+ resetDataPointCounter(startCounterFrom?: number): void {
512
+ // Set the counter with the passed value or 0
513
+ this.dataPointIdCounter =
514
+ startCounterFrom !== undefined ? startCounterFrom : 0
515
+ this.logger?.debug(
516
+ `Reset data point counter to ${this.dataPointIdCounter}`
517
+ )
385
518
 
386
- // Reset the counter in the worker
519
+ // Update worker counter if available
387
520
  if (this.featureExtractorWorker) {
388
521
  this.featureExtractorWorker.postMessage({
389
522
  command: 'resetCounter',
390
- startCounterFrom: 0,
523
+ value: this.dataPointIdCounter,
391
524
  })
525
+ } else {
526
+ this.logger?.warn(
527
+ 'No feature extractor worker available to update counter'
528
+ )
392
529
  }
393
530
  }
394
531
 
532
+ /**
533
+ * Get the current data point counter value
534
+ * @returns The current value of the data point counter
535
+ */
536
+ getDataPointCounter(): number {
537
+ return this.dataPointIdCounter
538
+ }
539
+
540
+ /**
541
+ * Prepares the recorder for continuity after device switch
542
+ * Sets up all necessary state to maintain proper recording continuity
543
+ */
544
+ prepareForDeviceSwitch(): void {
545
+ this.isFirstChunkAfterSwitch = true
546
+ this.logger?.debug(
547
+ `Prepared for device switch at position ${this.position}s`
548
+ )
549
+ }
550
+
395
551
  /**
396
552
  * Starts the audio recording process
397
553
  * Connects the audio nodes and begins capturing audio data
554
+ * @param preserveCounters If true, do not reset the counter (used for device switching)
398
555
  */
399
- start() {
556
+ start(preserveCounters = false) {
400
557
  this.source.connect(this.audioWorkletNode)
401
558
  this.audioWorkletNode.connect(this.audioContext.destination)
402
- this.packetCount = 0
403
559
 
404
- // Reset the counter when starting a new recording
405
- this.resetDataPointCounter()
560
+ // Only reset the counter when not preserving state (e.g., for a fresh recording)
561
+ if (!preserveCounters) {
562
+ this.logger?.debug(
563
+ 'Starting fresh recording, resetting counter to 0'
564
+ )
565
+ this.resetDataPointCounter(0) // Explicitly reset to 0 for new recordings
566
+ this.isFirstChunkAfterSwitch = false
567
+
568
+ // Clear PCM data for new recording
569
+ this.pcmData = null
570
+ this.totalSampleCount = 0
571
+ } else {
572
+ this.logger?.debug(
573
+ `Preserving counter at ${this.dataPointIdCounter} during device switch`
574
+ )
575
+ }
406
576
 
407
577
  if (this.compressedMediaRecorder) {
408
578
  this.compressedMediaRecorder.start(this.config.interval ?? 1000)
409
579
  }
410
580
  }
411
581
 
582
+ /**
583
+ * Creates a WAV file from the stored PCM data
584
+ */
585
+ private createWavFromPcmData(): Blob | null {
586
+ try {
587
+ // Check if we have PCM data
588
+ if (!this.pcmData || this.pcmData.length === 0) {
589
+ this.logger?.warn('No PCM data available to create WAV file')
590
+ return null
591
+ }
592
+
593
+ const sampleRate =
594
+ this.config.sampleRate || this.audioContext.sampleRate
595
+ const channels = this.numberOfChannels || 1
596
+
597
+ // Convert float32 PCM data to 16-bit PCM for WAV
598
+ const bytesPerSample = 2 // 16-bit = 2 bytes
599
+ const dataLength = this.pcmData.length * bytesPerSample
600
+ const buffer = new ArrayBuffer(dataLength)
601
+ const view = new DataView(buffer)
602
+
603
+ // Convert Float32Array (-1 to 1) to Int16Array (-32768 to 32767)
604
+ for (let i = 0; i < this.pcmData.length; i++) {
605
+ const sample = Math.max(-1, Math.min(1, this.pcmData[i]))
606
+ const int16Value = Math.round(sample * 32767)
607
+ view.setInt16(i * 2, int16Value, true)
608
+ }
609
+
610
+ // Use the existing writeWavHeader utility to add a WAV header
611
+ const wavBuffer = writeWavHeader({
612
+ buffer,
613
+ sampleRate,
614
+ numChannels: channels,
615
+ bitDepth: 16,
616
+ isFloat: false,
617
+ })
618
+
619
+ return new Blob([wavBuffer], { type: 'audio/wav' })
620
+ } catch (error) {
621
+ this.logger?.error('Error creating WAV file from PCM data:', error)
622
+ return null
623
+ }
624
+ }
625
+
412
626
  /**
413
627
  * Stops the audio recording process and returns the recorded data
414
- * @returns Promise resolving to an object containing PCM data and optional compressed blob
628
+ * @returns Promise resolving to an object containing compressed and/or uncompressed blobs
415
629
  */
416
- async stop(): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
630
+ async stop(): Promise<{ compressedBlob?: Blob; uncompressedBlob?: Blob }> {
417
631
  try {
418
- if (this.compressedMediaRecorder) {
632
+ // Stop any compressed recording first
633
+ if (
634
+ this.compressedMediaRecorder &&
635
+ this.compressedMediaRecorder.state !== 'inactive'
636
+ ) {
419
637
  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
638
  }
427
- return { pcmData: new Float32Array() }
639
+
640
+ // Wait for any pending compressed chunks to be processed
641
+ if (this.compressedMediaRecorder) {
642
+ // Small delay to ensure all data is processed
643
+ await new Promise((resolve) => setTimeout(resolve, 100))
644
+ }
645
+
646
+ // Create uncompressed WAV file from the PCM data
647
+ let uncompressedBlob: Blob | undefined
648
+
649
+ // Only create WAV if we have PCM data
650
+ if (this.pcmData && this.pcmData.length > 0) {
651
+ uncompressedBlob =
652
+ (await this.createWavFromPcmData()) || undefined
653
+ }
654
+
655
+ // Return the compressed and/or uncompressed blobs if available
656
+ return {
657
+ compressedBlob:
658
+ this.compressedChunks.length > 0
659
+ ? new Blob(this.compressedChunks, {
660
+ type: 'audio/webm;codecs=opus',
661
+ })
662
+ : undefined,
663
+ uncompressedBlob,
664
+ }
428
665
  } finally {
429
666
  this.cleanup()
430
667
  // Reset the chunks array
431
668
  this.compressedChunks = []
432
669
  this.compressedSize = 0
433
670
  this.pendingCompressedChunk = null
671
+ this.pcmData = null
672
+ this.totalSampleCount = 0
434
673
  }
435
674
  }
436
675
 
@@ -438,17 +677,45 @@ export class WebRecorder {
438
677
  * Cleans up resources when recording is stopped
439
678
  * Closes audio context and disconnects nodes
440
679
  */
441
- private cleanup() {
442
- if (this.audioContext) {
443
- this.audioContext.close()
680
+ public cleanup() {
681
+ // Remove device disconnection handler
682
+ if (this.deviceDisconnectionHandler) {
683
+ this.deviceDisconnectionHandler()
684
+ this.deviceDisconnectionHandler = null
444
685
  }
686
+
687
+ // Check if AudioContext is already closed before attempting to close it
688
+ if (this.audioContext && this.audioContext.state !== 'closed') {
689
+ try {
690
+ this.audioContext.close()
691
+ } catch (e) {
692
+ // Ignore closure errors - this happens if already closed
693
+ }
694
+ }
695
+
696
+ // Safely disconnect audioWorkletNode if it exists
445
697
  if (this.audioWorkletNode) {
446
- this.audioWorkletNode.disconnect()
698
+ try {
699
+ this.audioWorkletNode.disconnect()
700
+ } catch (e) {
701
+ // Ignore disconnection errors - node might be already disconnected
702
+ }
447
703
  }
704
+
705
+ // Safely disconnect source if it exists
448
706
  if (this.source) {
449
- this.source.disconnect()
707
+ try {
708
+ this.source.disconnect()
709
+ } catch (e) {
710
+ // Ignore disconnection errors - source might be already disconnected
711
+ }
450
712
  }
713
+
714
+ // Always stop media stream tracks to release hardware resources
451
715
  this.stopMediaStreamTracks()
716
+
717
+ // Mark as disconnected to prevent future errors
718
+ this._isDeviceDisconnected = true
452
719
  }
453
720
 
454
721
  /**
@@ -456,20 +723,37 @@ export class WebRecorder {
456
723
  * Disconnects audio nodes and pauses the media recorder
457
724
  */
458
725
  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()
726
+ try {
727
+ // Note: We're just pausing, not disconnecting the device
728
+ // Simply disconnect nodes temporarily without marking device as disconnected
729
+ this.source.disconnect(this.audioWorkletNode)
730
+ this.audioWorkletNode.disconnect(this.audioContext.destination)
731
+ this.audioWorkletNode.port.postMessage({ command: 'pause' })
732
+
733
+ if (this.compressedMediaRecorder?.state === 'recording') {
734
+ this.compressedMediaRecorder.pause()
735
+ }
736
+
737
+ this.logger?.debug('Recording paused successfully')
738
+ } catch (error) {
739
+ this.logger?.error('Error in pause(): ', error)
740
+ // Already disconnected, just ignore and continue
741
+ }
463
742
  }
464
743
 
465
744
  /**
466
745
  * Stops all media stream tracks to release hardware resources
467
746
  * Ensures recording indicators (like microphone icon) are turned off
468
747
  */
469
- stopMediaStreamTracks() {
748
+ public stopMediaStreamTracks() {
470
749
  // Stop all audio tracks to stop the recording icon
471
- const tracks = this.source.mediaStream.getTracks()
472
- tracks.forEach((track) => track.stop())
750
+ if (this.mediaStream) {
751
+ const tracks = this.mediaStream.getTracks()
752
+ tracks.forEach((track) => track.stop())
753
+ } else if (this.source?.mediaStream) {
754
+ const tracks = this.source.mediaStream.getTracks()
755
+ tracks.forEach((track) => track.stop())
756
+ }
473
757
  }
474
758
 
475
759
  /**
@@ -502,10 +786,20 @@ export class WebRecorder {
502
786
  * Reconnects audio nodes and resumes the media recorder
503
787
  */
504
788
  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()
789
+ // If device was disconnected, we can't resume
790
+ if (this._isDeviceDisconnected) {
791
+ this.logger?.warn('Cannot resume recording: device disconnected')
792
+ return
793
+ }
794
+
795
+ try {
796
+ this.source.connect(this.audioWorkletNode)
797
+ this.audioWorkletNode.connect(this.audioContext.destination)
798
+ this.audioWorkletNode.port.postMessage({ command: 'resume' })
799
+ this.compressedMediaRecorder?.resume()
800
+ } catch (error) {
801
+ this.logger?.error('Error in resume(): ', error)
802
+ }
509
803
  }
510
804
 
511
805
  /**
@@ -573,8 +867,104 @@ export class WebRecorder {
573
867
  startPosition,
574
868
  endPosition,
575
869
  samples,
576
- startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
577
870
  })
578
871
  }
579
872
  }
873
+
874
+ /**
875
+ * Sets up detection for device disconnection events
876
+ */
877
+ private setupDeviceDisconnectionDetection() {
878
+ if (!this.mediaStream) return
879
+
880
+ // Function to handle track ending (which happens on device disconnection)
881
+ const handleTrackEnded = () => {
882
+ this.logger?.warn('Audio track ended - device disconnected')
883
+ this._isDeviceDisconnected = true
884
+
885
+ // Use the callback to notify parent component about device disconnection
886
+ if (this.onInterruptionCallback) {
887
+ this.onInterruptionCallback({
888
+ reason: 'deviceDisconnected',
889
+ isPaused: true,
890
+ timestamp: Date.now(),
891
+ })
892
+ this.logger?.debug('Notified about device disconnection')
893
+ }
894
+
895
+ // Ensure we disconnect nodes to prevent zombie recordings
896
+ if (this.audioWorkletNode) {
897
+ this.audioWorkletNode.port.postMessage({
898
+ command: 'deviceDisconnected',
899
+ })
900
+
901
+ try {
902
+ this.source.disconnect(this.audioWorkletNode)
903
+ this.audioWorkletNode.disconnect()
904
+ } catch (e) {
905
+ // Ignore disconnection errors as the track might already be gone
906
+ }
907
+ }
908
+ }
909
+
910
+ // Add listeners to all audio tracks
911
+ const tracks = this.mediaStream.getAudioTracks()
912
+ tracks.forEach((track) => {
913
+ track.addEventListener('ended', handleTrackEnded)
914
+ })
915
+
916
+ // Store the handler for cleanup
917
+ this.deviceDisconnectionHandler = () => {
918
+ tracks.forEach((track) => {
919
+ track.removeEventListener('ended', handleTrackEnded)
920
+ })
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Explicitly set the position for continuous recording across device switches
926
+ * @param position The position in seconds to continue from
927
+ */
928
+ setPosition(position: number): void {
929
+ if (position >= 0) {
930
+ this.position = position
931
+ this.logger?.debug(`Position explicitly set to ${position} seconds`)
932
+ } else {
933
+ this.logger?.warn(`Invalid position value: ${position}, ignoring`)
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Get the current position in seconds
939
+ * @returns The current position
940
+ */
941
+ getPosition(): number {
942
+ return this.position
943
+ }
944
+
945
+ /**
946
+ * Gets the current compressed chunks
947
+ * @returns Array of current compressed audio chunks
948
+ */
949
+ getCompressedChunks(): Blob[] {
950
+ return [...this.compressedChunks]
951
+ }
952
+
953
+ /**
954
+ * Sets the compressed chunks from a previous recorder
955
+ * @param chunks Array of compressed chunks from a previous recorder
956
+ */
957
+ setCompressedChunks(chunks: Blob[]): void {
958
+ if (chunks && chunks.length > 0) {
959
+ this.logger?.debug(
960
+ `Adding ${chunks.length} compressed chunks from previous device`
961
+ )
962
+ this.compressedChunks = [...chunks, ...this.compressedChunks]
963
+ // Update size
964
+ this.compressedSize = this.compressedChunks.reduce(
965
+ (size, chunk) => size + chunk.size,
966
+ 0
967
+ )
968
+ }
969
+ }
580
970
  }