@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
@@ -1,5 +1,6 @@
1
1
  // packages/expo-audio-stream/src/WebRecorder.web.ts
2
2
  import { encodingToBitDepth } from './utils/encodingToBitDepth';
3
+ import { writeWavHeader } from './utils/writeWavHeader';
3
4
  import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web';
4
5
  import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web';
5
6
  const DEFAULT_WEB_BITDEPTH = 32;
@@ -20,14 +21,29 @@ export class WebRecorder {
20
21
  bitDepth; // Bit depth of the audio
21
22
  exportBitDepth; // Bit depth of the audio
22
23
  audioAnalysisData; // Keep updating the full audio analysis data with latest events
23
- packetCount = 0;
24
24
  logger;
25
25
  compressedMediaRecorder = null;
26
26
  compressedChunks = [];
27
27
  compressedSize = 0;
28
28
  pendingCompressedChunk = null;
29
- wavMimeType = 'audio/wav';
30
29
  dataPointIdCounter = 0; // Add this property to track the counter
30
+ deviceDisconnectionHandler = null;
31
+ mediaStream = null;
32
+ onInterruptionCallback;
33
+ _isDeviceDisconnected = false;
34
+ pcmData = null; // Store original PCM data
35
+ totalSampleCount = 0;
36
+ /**
37
+ * Flag to indicate whether this is the first audio chunk after a device switch
38
+ * Used to maintain proper duration counting
39
+ */
40
+ isFirstChunkAfterSwitch = false;
41
+ /**
42
+ * Gets whether the recording device has been disconnected
43
+ */
44
+ get isDeviceDisconnected() {
45
+ return this._isDeviceDisconnected;
46
+ }
31
47
  /**
32
48
  * Initializes a new WebRecorder instance for audio recording and processing
33
49
  * @param audioContext - The AudioContext to use for recording
@@ -35,9 +51,10 @@ export class WebRecorder {
35
51
  * @param recordingConfig - Configuration options for the recording
36
52
  * @param emitAudioEventCallback - Callback function for audio data events
37
53
  * @param emitAudioAnalysisCallback - Callback function for audio analysis events
54
+ * @param onInterruption - Callback for recording interruptions
38
55
  * @param logger - Optional logger for debugging information
39
56
  */
40
- constructor({ audioContext, source, recordingConfig, emitAudioEventCallback, emitAudioAnalysisCallback, logger, }) {
57
+ constructor({ audioContext, source, recordingConfig, emitAudioEventCallback, emitAudioAnalysisCallback, onInterruption, logger, }) {
41
58
  this.audioContext = audioContext;
42
59
  this.source = source;
43
60
  this.emitAudioEventCallback = emitAudioEventCallback;
@@ -80,6 +97,10 @@ export class WebRecorder {
80
97
  if (recordingConfig.compression?.enabled) {
81
98
  this.initializeCompressedRecorder();
82
99
  }
100
+ this.mediaStream = source.mediaStream;
101
+ this.onInterruptionCallback = onInterruption;
102
+ // Setup device disconnection detection
103
+ this.setupDeviceDisconnectionDetection();
83
104
  }
84
105
  /**
85
106
  * Initializes the audio worklet using an inline script
@@ -96,6 +117,10 @@ export class WebRecorder {
96
117
  this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor');
97
118
  this.audioWorkletNode.port.onmessage = async (event) => {
98
119
  const command = event.data.command;
120
+ if (command === 'debug') {
121
+ this.logger?.debug(`[AudioWorklet] ${event.data.message}`);
122
+ return;
123
+ }
99
124
  if (command !== 'newData')
100
125
  return;
101
126
  const pcmBufferFloat = event.data.recordedData;
@@ -104,15 +129,21 @@ export class WebRecorder {
104
129
  return;
105
130
  }
106
131
  // Process data in smaller chunks and emit immediately
107
- const chunkSize = this.audioContext.sampleRate * 2; // Reduce to 2 seconds chunks
108
132
  const sampleRate = event.data.sampleRate ?? this.audioContext.sampleRate;
133
+ // Use chunk size from config interval or default to 2 seconds
134
+ const intervalMs = this.config.interval ?? DEFAULT_WEB_INTERVAL;
135
+ const chunkSize = Math.floor(sampleRate * (intervalMs / 1000));
109
136
  const duration = pcmBufferFloat.length / sampleRate;
137
+ // Use incoming position if provided by worklet, otherwise use our tracked position
138
+ const incomingPosition = typeof event.data.position === 'number'
139
+ ? event.data.position
140
+ : this.position;
110
141
  // Calculate bytes per sample based on bit depth
111
142
  const bytesPerSample = this.bitDepth / 8;
112
143
  // Emit chunks without storing them
113
144
  for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
114
145
  const chunk = pcmBufferFloat.slice(i, i + chunkSize);
115
- const chunkPosition = this.position + i / sampleRate;
146
+ const chunkPosition = incomingPosition + i / sampleRate;
116
147
  // Calculate byte positions and samples
117
148
  const startPosition = Math.floor(i * bytesPerSample);
118
149
  const endPosition = Math.floor((i + chunk.length) * bytesPerSample);
@@ -136,6 +167,14 @@ export class WebRecorder {
136
167
  samples,
137
168
  });
138
169
  }
170
+ // Only store PCM data if web.storeUncompressedAudio is not explicitly false
171
+ const shouldStoreUncompressed = this.config.web?.storeUncompressedAudio !== false;
172
+ // Store PCM chunks when needed
173
+ if (shouldStoreUncompressed) {
174
+ // Store the original Float32Array data for later WAV creation
175
+ this.appendPcmData(chunk);
176
+ this.totalSampleCount += chunk.length;
177
+ }
139
178
  // Emit chunk immediately
140
179
  this.emitAudioEventCallback({
141
180
  data: chunk,
@@ -153,19 +192,43 @@ export class WebRecorder {
153
192
  : undefined,
154
193
  });
155
194
  }
156
- this.position += duration;
195
+ // Update our position based on the worklet's position if provided
196
+ this.position = incomingPosition + duration;
157
197
  this.pendingCompressedChunk = null;
158
198
  };
159
- this.logger?.debug(`WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}`, this.config);
199
+ // Ensure we use all relevant settings from config
200
+ const recordSampleRate = this.audioContext.sampleRate;
201
+ const exportSampleRate = this.config.sampleRate ?? this.audioContext.sampleRate;
202
+ const channels = this.config.channels ?? this.numberOfChannels;
203
+ const interval = this.config.interval ?? DEFAULT_WEB_INTERVAL;
204
+ this.logger?.debug(`WebRecorder initialized with config:`, {
205
+ recordSampleRate,
206
+ exportSampleRate,
207
+ bitDepth: this.bitDepth,
208
+ exportBitDepth: this.exportBitDepth,
209
+ channels,
210
+ interval,
211
+ position: this.position,
212
+ deviceId: this.config.deviceId || 'default',
213
+ compression: this.config.compression
214
+ ? {
215
+ enabled: this.config.compression.enabled,
216
+ format: this.config.compression.format,
217
+ bitrate: this.config.compression.bitrate,
218
+ }
219
+ : 'disabled',
220
+ });
221
+ // Initialize the worklet with all settings from config
160
222
  this.audioWorkletNode.port.postMessage({
161
223
  command: 'init',
162
- recordSampleRate: this.audioContext.sampleRate,
163
- exportSampleRate: this.config.sampleRate ?? this.audioContext.sampleRate,
224
+ recordSampleRate,
225
+ exportSampleRate,
164
226
  bitDepth: this.bitDepth,
165
227
  exportBitDepth: this.exportBitDepth,
166
- channels: this.numberOfChannels,
167
- interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
168
- // enableLogging: !!this.logger,
228
+ channels,
229
+ interval,
230
+ position: this.position, // Pass the current position to the processor
231
+ enableLogging: true,
169
232
  });
170
233
  // Connect the source to the AudioWorkletNode and start recording
171
234
  this.source.connect(this.audioWorkletNode);
@@ -175,6 +238,27 @@ export class WebRecorder {
175
238
  console.error(`[${TAG}] Failed to initialize WebRecorder`, error);
176
239
  }
177
240
  }
241
+ /**
242
+ * Append new PCM data to the existing buffer
243
+ * @param newData New Float32Array data to append
244
+ */
245
+ appendPcmData(newData) {
246
+ // Clone the incoming data to ensure it's not modified
247
+ const dataToAdd = new Float32Array(newData);
248
+ if (!this.pcmData) {
249
+ // First chunk - create a copy to avoid references to original data
250
+ this.pcmData = new Float32Array(dataToAdd);
251
+ return;
252
+ }
253
+ // Create a new buffer with increased size
254
+ const newBuffer = new Float32Array(this.pcmData.length + dataToAdd.length);
255
+ // Copy existing data
256
+ newBuffer.set(this.pcmData);
257
+ // Append new data
258
+ newBuffer.set(dataToAdd, this.pcmData.length);
259
+ // Replace existing buffer
260
+ this.pcmData = newBuffer;
261
+ }
178
262
  /**
179
263
  * Initializes the feature extractor worker for audio analysis
180
264
  * Creates an inline worker from a blob for audio feature extraction
@@ -191,6 +275,14 @@ export class WebRecorder {
191
275
  this.featureExtractorWorker.onerror = (error) => {
192
276
  console.error(`[${TAG}] Feature extractor worker error:`, error);
193
277
  };
278
+ // Initialize worker with counter if needed
279
+ if (this.dataPointIdCounter > 0) {
280
+ this.featureExtractorWorker.postMessage({
281
+ command: 'resetCounter',
282
+ value: this.dataPointIdCounter,
283
+ });
284
+ this.logger?.debug(`Initialized worker with counter value ${this.dataPointIdCounter}`);
285
+ }
194
286
  this.logger?.log('Feature extractor worker initialized successfully');
195
287
  }
196
288
  catch (error) {
@@ -205,29 +297,33 @@ export class WebRecorder {
205
297
  handleFeatureExtractorMessage(event) {
206
298
  if (event.data.command === 'features') {
207
299
  const segmentResult = event.data.result;
208
- // Update the dataPointIdCounter based on the last ID received
209
- if (segmentResult.dataPoints &&
210
- segmentResult.dataPoints.length > 0) {
211
- const lastDataPoint = segmentResult.dataPoints[segmentResult.dataPoints.length - 1];
300
+ // Track existing IDs to prevent duplicates
301
+ const existingIds = new Set(this.audioAnalysisData.dataPoints.map((dp) => dp.id));
302
+ // Filter out datapoints with duplicate IDs
303
+ const uniqueNewDataPoints = segmentResult.dataPoints.filter((dp) => {
304
+ return !existingIds.has(dp.id);
305
+ });
306
+ // Log filtered duplicates if any
307
+ if (uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
308
+ this.logger?.warn) {
309
+ this.logger.warn(`Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`);
310
+ }
311
+ // Update counter based on the highest ID seen
312
+ if (uniqueNewDataPoints.length > 0) {
313
+ const lastDataPoint = uniqueNewDataPoints[uniqueNewDataPoints.length - 1];
212
314
  if (lastDataPoint && typeof lastDataPoint.id === 'number') {
213
- this.dataPointIdCounter = Math.max(this.dataPointIdCounter, lastDataPoint.id + 1);
315
+ const nextIdValue = lastDataPoint.id + 1;
316
+ if (nextIdValue > this.dataPointIdCounter) {
317
+ this.dataPointIdCounter = nextIdValue;
318
+ this.logger?.debug(`Counter updated to ${this.dataPointIdCounter}`);
319
+ }
214
320
  }
215
321
  }
216
- this.logger?.debug('[WebRecorder] Raw segment result:', {
217
- dataPointsLength: segmentResult.dataPoints.length,
218
- durationMs: segmentResult.durationMs,
219
- sampleRate: segmentResult.sampleRate,
220
- amplitudeRange: segmentResult.amplitudeRange,
221
- });
222
- // Ensure consistent sample rate in the result
223
- segmentResult.sampleRate =
224
- this.config.sampleRate || this.audioContext.sampleRate;
225
- // Update the full audio analysis data with proper range merging
226
- this.audioAnalysisData.dataPoints.push(...segmentResult.dataPoints);
322
+ // Add unique data points to our analysis data
323
+ this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints);
227
324
  this.audioAnalysisData.durationMs += segmentResult.durationMs;
228
- // Make sure the sample rate is consistent
229
325
  this.audioAnalysisData.sampleRate = segmentResult.sampleRate;
230
- // Properly merge amplitude ranges
326
+ // Merge amplitude ranges
231
327
  if (segmentResult.amplitudeRange) {
232
328
  if (!this.audioAnalysisData.amplitudeRange) {
233
329
  this.audioAnalysisData.amplitudeRange = {
@@ -241,7 +337,7 @@ export class WebRecorder {
241
337
  };
242
338
  }
243
339
  }
244
- // Properly merge RMS ranges
340
+ // Merge RMS ranges
245
341
  if (segmentResult.rmsRange) {
246
342
  if (!this.audioAnalysisData.rmsRange) {
247
343
  this.audioAnalysisData.rmsRange = {
@@ -255,61 +351,143 @@ export class WebRecorder {
255
351
  };
256
352
  }
257
353
  }
258
- this.logger?.debug('features event segmentResult', segmentResult);
259
- this.logger?.debug(`features event audioAnalysisData duration=${this.audioAnalysisData.durationMs}`, this.audioAnalysisData);
260
- this.emitAudioAnalysisCallback(segmentResult);
261
- this.logger?.debug('[WebRecorder] Updated audioAnalysisData:', {
262
- dataPointsLength: this.audioAnalysisData.dataPoints.length,
263
- durationMs: this.audioAnalysisData.durationMs,
264
- sampleRate: this.audioAnalysisData.sampleRate,
265
- amplitudeRange: this.audioAnalysisData.amplitudeRange,
266
- });
354
+ // Send filtered result to avoid duplicate IDs
355
+ const filteredSegmentResult = {
356
+ ...segmentResult,
357
+ dataPoints: uniqueNewDataPoints,
358
+ };
359
+ this.emitAudioAnalysisCallback(filteredSegmentResult);
267
360
  }
268
361
  }
269
362
  /**
270
- * Resets the data point ID counter
271
- * Used when starting a new recording
363
+ * Reset the data point counter to a specific value or zero
364
+ * @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
272
365
  */
273
- resetDataPointCounter() {
274
- this.dataPointIdCounter = 0;
275
- // Reset the counter in the worker
366
+ resetDataPointCounter(startCounterFrom) {
367
+ // Set the counter with the passed value or 0
368
+ this.dataPointIdCounter =
369
+ startCounterFrom !== undefined ? startCounterFrom : 0;
370
+ this.logger?.debug(`Reset data point counter to ${this.dataPointIdCounter}`);
371
+ // Update worker counter if available
276
372
  if (this.featureExtractorWorker) {
277
373
  this.featureExtractorWorker.postMessage({
278
374
  command: 'resetCounter',
279
- startCounterFrom: 0,
375
+ value: this.dataPointIdCounter,
280
376
  });
281
377
  }
378
+ else {
379
+ this.logger?.warn('No feature extractor worker available to update counter');
380
+ }
381
+ }
382
+ /**
383
+ * Get the current data point counter value
384
+ * @returns The current value of the data point counter
385
+ */
386
+ getDataPointCounter() {
387
+ return this.dataPointIdCounter;
388
+ }
389
+ /**
390
+ * Prepares the recorder for continuity after device switch
391
+ * Sets up all necessary state to maintain proper recording continuity
392
+ */
393
+ prepareForDeviceSwitch() {
394
+ this.isFirstChunkAfterSwitch = true;
395
+ this.logger?.debug(`Prepared for device switch at position ${this.position}s`);
282
396
  }
283
397
  /**
284
398
  * Starts the audio recording process
285
399
  * Connects the audio nodes and begins capturing audio data
400
+ * @param preserveCounters If true, do not reset the counter (used for device switching)
286
401
  */
287
- start() {
402
+ start(preserveCounters = false) {
288
403
  this.source.connect(this.audioWorkletNode);
289
404
  this.audioWorkletNode.connect(this.audioContext.destination);
290
- this.packetCount = 0;
291
- // Reset the counter when starting a new recording
292
- this.resetDataPointCounter();
405
+ // Only reset the counter when not preserving state (e.g., for a fresh recording)
406
+ if (!preserveCounters) {
407
+ this.logger?.debug('Starting fresh recording, resetting counter to 0');
408
+ this.resetDataPointCounter(0); // Explicitly reset to 0 for new recordings
409
+ this.isFirstChunkAfterSwitch = false;
410
+ // Clear PCM data for new recording
411
+ this.pcmData = null;
412
+ this.totalSampleCount = 0;
413
+ }
414
+ else {
415
+ this.logger?.debug(`Preserving counter at ${this.dataPointIdCounter} during device switch`);
416
+ }
293
417
  if (this.compressedMediaRecorder) {
294
418
  this.compressedMediaRecorder.start(this.config.interval ?? 1000);
295
419
  }
296
420
  }
421
+ /**
422
+ * Creates a WAV file from the stored PCM data
423
+ */
424
+ createWavFromPcmData() {
425
+ try {
426
+ // Check if we have PCM data
427
+ if (!this.pcmData || this.pcmData.length === 0) {
428
+ this.logger?.warn('No PCM data available to create WAV file');
429
+ return null;
430
+ }
431
+ const sampleRate = this.config.sampleRate || this.audioContext.sampleRate;
432
+ const channels = this.numberOfChannels || 1;
433
+ // Convert float32 PCM data to 16-bit PCM for WAV
434
+ const bytesPerSample = 2; // 16-bit = 2 bytes
435
+ const dataLength = this.pcmData.length * bytesPerSample;
436
+ const buffer = new ArrayBuffer(dataLength);
437
+ const view = new DataView(buffer);
438
+ // Convert Float32Array (-1 to 1) to Int16Array (-32768 to 32767)
439
+ for (let i = 0; i < this.pcmData.length; i++) {
440
+ const sample = Math.max(-1, Math.min(1, this.pcmData[i]));
441
+ const int16Value = Math.round(sample * 32767);
442
+ view.setInt16(i * 2, int16Value, true);
443
+ }
444
+ // Use the existing writeWavHeader utility to add a WAV header
445
+ const wavBuffer = writeWavHeader({
446
+ buffer,
447
+ sampleRate,
448
+ numChannels: channels,
449
+ bitDepth: 16,
450
+ isFloat: false,
451
+ });
452
+ return new Blob([wavBuffer], { type: 'audio/wav' });
453
+ }
454
+ catch (error) {
455
+ this.logger?.error('Error creating WAV file from PCM data:', error);
456
+ return null;
457
+ }
458
+ }
297
459
  /**
298
460
  * Stops the audio recording process and returns the recorded data
299
- * @returns Promise resolving to an object containing PCM data and optional compressed blob
461
+ * @returns Promise resolving to an object containing compressed and/or uncompressed blobs
300
462
  */
301
463
  async stop() {
302
464
  try {
303
- if (this.compressedMediaRecorder) {
465
+ // Stop any compressed recording first
466
+ if (this.compressedMediaRecorder &&
467
+ this.compressedMediaRecorder.state !== 'inactive') {
304
468
  this.compressedMediaRecorder.stop();
305
- return {
306
- pcmData: new Float32Array(), // Return empty array since we're streaming
307
- compressedBlob: new Blob(this.compressedChunks, {
308
- type: 'audio/webm;codecs=opus',
309
- }),
310
- };
311
469
  }
312
- return { pcmData: new Float32Array() };
470
+ // Wait for any pending compressed chunks to be processed
471
+ if (this.compressedMediaRecorder) {
472
+ // Small delay to ensure all data is processed
473
+ await new Promise((resolve) => setTimeout(resolve, 100));
474
+ }
475
+ // Create uncompressed WAV file from the PCM data
476
+ let uncompressedBlob;
477
+ // Only create WAV if we have PCM data
478
+ if (this.pcmData && this.pcmData.length > 0) {
479
+ uncompressedBlob =
480
+ (await this.createWavFromPcmData()) || undefined;
481
+ }
482
+ // Return the compressed and/or uncompressed blobs if available
483
+ return {
484
+ compressedBlob: this.compressedChunks.length > 0
485
+ ? new Blob(this.compressedChunks, {
486
+ type: 'audio/webm;codecs=opus',
487
+ })
488
+ : undefined,
489
+ uncompressedBlob,
490
+ };
313
491
  }
314
492
  finally {
315
493
  this.cleanup();
@@ -317,6 +495,8 @@ export class WebRecorder {
317
495
  this.compressedChunks = [];
318
496
  this.compressedSize = 0;
319
497
  this.pendingCompressedChunk = null;
498
+ this.pcmData = null;
499
+ this.totalSampleCount = 0;
320
500
  }
321
501
  }
322
502
  /**
@@ -324,26 +504,63 @@ export class WebRecorder {
324
504
  * Closes audio context and disconnects nodes
325
505
  */
326
506
  cleanup() {
327
- if (this.audioContext) {
328
- this.audioContext.close();
507
+ // Remove device disconnection handler
508
+ if (this.deviceDisconnectionHandler) {
509
+ this.deviceDisconnectionHandler();
510
+ this.deviceDisconnectionHandler = null;
329
511
  }
512
+ // Check if AudioContext is already closed before attempting to close it
513
+ if (this.audioContext && this.audioContext.state !== 'closed') {
514
+ try {
515
+ this.audioContext.close();
516
+ }
517
+ catch (e) {
518
+ // Ignore closure errors - this happens if already closed
519
+ }
520
+ }
521
+ // Safely disconnect audioWorkletNode if it exists
330
522
  if (this.audioWorkletNode) {
331
- this.audioWorkletNode.disconnect();
523
+ try {
524
+ this.audioWorkletNode.disconnect();
525
+ }
526
+ catch (e) {
527
+ // Ignore disconnection errors - node might be already disconnected
528
+ }
332
529
  }
530
+ // Safely disconnect source if it exists
333
531
  if (this.source) {
334
- this.source.disconnect();
532
+ try {
533
+ this.source.disconnect();
534
+ }
535
+ catch (e) {
536
+ // Ignore disconnection errors - source might be already disconnected
537
+ }
335
538
  }
539
+ // Always stop media stream tracks to release hardware resources
336
540
  this.stopMediaStreamTracks();
541
+ // Mark as disconnected to prevent future errors
542
+ this._isDeviceDisconnected = true;
337
543
  }
338
544
  /**
339
545
  * Pauses the audio recording process
340
546
  * Disconnects audio nodes and pauses the media recorder
341
547
  */
342
548
  pause() {
343
- this.source.disconnect(this.audioWorkletNode); // Disconnect the source from the AudioWorkletNode
344
- this.audioWorkletNode.disconnect(this.audioContext.destination); // Disconnect the AudioWorkletNode from the destination
345
- this.audioWorkletNode.port.postMessage({ command: 'pause' });
346
- this.compressedMediaRecorder?.pause();
549
+ try {
550
+ // Note: We're just pausing, not disconnecting the device
551
+ // Simply disconnect nodes temporarily without marking device as disconnected
552
+ this.source.disconnect(this.audioWorkletNode);
553
+ this.audioWorkletNode.disconnect(this.audioContext.destination);
554
+ this.audioWorkletNode.port.postMessage({ command: 'pause' });
555
+ if (this.compressedMediaRecorder?.state === 'recording') {
556
+ this.compressedMediaRecorder.pause();
557
+ }
558
+ this.logger?.debug('Recording paused successfully');
559
+ }
560
+ catch (error) {
561
+ this.logger?.error('Error in pause(): ', error);
562
+ // Already disconnected, just ignore and continue
563
+ }
347
564
  }
348
565
  /**
349
566
  * Stops all media stream tracks to release hardware resources
@@ -351,8 +568,14 @@ export class WebRecorder {
351
568
  */
352
569
  stopMediaStreamTracks() {
353
570
  // Stop all audio tracks to stop the recording icon
354
- const tracks = this.source.mediaStream.getTracks();
355
- tracks.forEach((track) => track.stop());
571
+ if (this.mediaStream) {
572
+ const tracks = this.mediaStream.getTracks();
573
+ tracks.forEach((track) => track.stop());
574
+ }
575
+ else if (this.source?.mediaStream) {
576
+ const tracks = this.source.mediaStream.getTracks();
577
+ tracks.forEach((track) => track.stop());
578
+ }
356
579
  }
357
580
  /**
358
581
  * Determines the audio format capabilities of the current audio context
@@ -377,10 +600,20 @@ export class WebRecorder {
377
600
  * Reconnects audio nodes and resumes the media recorder
378
601
  */
379
602
  resume() {
380
- this.source.connect(this.audioWorkletNode);
381
- this.audioWorkletNode.connect(this.audioContext.destination);
382
- this.audioWorkletNode.port.postMessage({ command: 'resume' });
383
- this.compressedMediaRecorder?.resume();
603
+ // If device was disconnected, we can't resume
604
+ if (this._isDeviceDisconnected) {
605
+ this.logger?.warn('Cannot resume recording: device disconnected');
606
+ return;
607
+ }
608
+ try {
609
+ this.source.connect(this.audioWorkletNode);
610
+ this.audioWorkletNode.connect(this.audioContext.destination);
611
+ this.audioWorkletNode.port.postMessage({ command: 'resume' });
612
+ this.compressedMediaRecorder?.resume();
613
+ }
614
+ catch (error) {
615
+ this.logger?.error('Error in resume(): ', error);
616
+ }
384
617
  }
385
618
  /**
386
619
  * Initializes the compressed media recorder if compression is enabled
@@ -428,9 +661,92 @@ export class WebRecorder {
428
661
  startPosition,
429
662
  endPosition,
430
663
  samples,
431
- startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
432
664
  });
433
665
  }
434
666
  }
667
+ /**
668
+ * Sets up detection for device disconnection events
669
+ */
670
+ setupDeviceDisconnectionDetection() {
671
+ if (!this.mediaStream)
672
+ return;
673
+ // Function to handle track ending (which happens on device disconnection)
674
+ const handleTrackEnded = () => {
675
+ this.logger?.warn('Audio track ended - device disconnected');
676
+ this._isDeviceDisconnected = true;
677
+ // Use the callback to notify parent component about device disconnection
678
+ if (this.onInterruptionCallback) {
679
+ this.onInterruptionCallback({
680
+ reason: 'deviceDisconnected',
681
+ isPaused: true,
682
+ timestamp: Date.now(),
683
+ });
684
+ this.logger?.debug('Notified about device disconnection');
685
+ }
686
+ // Ensure we disconnect nodes to prevent zombie recordings
687
+ if (this.audioWorkletNode) {
688
+ this.audioWorkletNode.port.postMessage({
689
+ command: 'deviceDisconnected',
690
+ });
691
+ try {
692
+ this.source.disconnect(this.audioWorkletNode);
693
+ this.audioWorkletNode.disconnect();
694
+ }
695
+ catch (e) {
696
+ // Ignore disconnection errors as the track might already be gone
697
+ }
698
+ }
699
+ };
700
+ // Add listeners to all audio tracks
701
+ const tracks = this.mediaStream.getAudioTracks();
702
+ tracks.forEach((track) => {
703
+ track.addEventListener('ended', handleTrackEnded);
704
+ });
705
+ // Store the handler for cleanup
706
+ this.deviceDisconnectionHandler = () => {
707
+ tracks.forEach((track) => {
708
+ track.removeEventListener('ended', handleTrackEnded);
709
+ });
710
+ };
711
+ }
712
+ /**
713
+ * Explicitly set the position for continuous recording across device switches
714
+ * @param position The position in seconds to continue from
715
+ */
716
+ setPosition(position) {
717
+ if (position >= 0) {
718
+ this.position = position;
719
+ this.logger?.debug(`Position explicitly set to ${position} seconds`);
720
+ }
721
+ else {
722
+ this.logger?.warn(`Invalid position value: ${position}, ignoring`);
723
+ }
724
+ }
725
+ /**
726
+ * Get the current position in seconds
727
+ * @returns The current position
728
+ */
729
+ getPosition() {
730
+ return this.position;
731
+ }
732
+ /**
733
+ * Gets the current compressed chunks
734
+ * @returns Array of current compressed audio chunks
735
+ */
736
+ getCompressedChunks() {
737
+ return [...this.compressedChunks];
738
+ }
739
+ /**
740
+ * Sets the compressed chunks from a previous recorder
741
+ * @param chunks Array of compressed chunks from a previous recorder
742
+ */
743
+ setCompressedChunks(chunks) {
744
+ if (chunks && chunks.length > 0) {
745
+ this.logger?.debug(`Adding ${chunks.length} compressed chunks from previous device`);
746
+ this.compressedChunks = [...chunks, ...this.compressedChunks];
747
+ // Update size
748
+ this.compressedSize = this.compressedChunks.reduce((size, chunk) => size + chunk.size, 0);
749
+ }
750
+ }
435
751
  }
436
752
  //# sourceMappingURL=WebRecorder.web.js.map