@siteed/expo-audio-studio 2.8.6 → 2.10.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 (70) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/android/build.gradle +9 -0
  3. package/android/src/androidTest/assets/chorus.wav +0 -0
  4. package/android/src/androidTest/assets/jfk.wav +0 -0
  5. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  6. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  7. package/android/src/androidTest/java/net/siteed/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
  8. package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
  9. package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
  10. package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
  11. package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
  12. package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
  13. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
  14. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -15
  15. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
  16. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
  17. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
  18. package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
  19. package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
  20. package/android/src/test/resources/chorus.wav +0 -0
  21. package/android/src/test/resources/generate_test_audio.py +94 -0
  22. package/android/src/test/resources/jfk.wav +0 -0
  23. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  24. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  25. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  26. package/build/cjs/ExpoAudioStream.types.js.map +1 -1
  27. package/build/cjs/ExpoAudioStream.web.js +38 -35
  28. package/build/cjs/ExpoAudioStream.web.js.map +1 -1
  29. package/build/cjs/WebRecorder.web.js +122 -102
  30. package/build/cjs/WebRecorder.web.js.map +1 -1
  31. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  32. package/build/esm/ExpoAudioStream.types.js.map +1 -1
  33. package/build/esm/ExpoAudioStream.web.js +38 -35
  34. package/build/esm/ExpoAudioStream.web.js.map +1 -1
  35. package/build/esm/WebRecorder.web.js +122 -102
  36. package/build/esm/WebRecorder.web.js.map +1 -1
  37. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +3 -1
  38. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  39. package/build/types/ExpoAudioStream.types.d.ts +54 -22
  40. package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
  41. package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
  42. package/build/types/WebRecorder.web.d.ts +19 -3
  43. package/build/types/WebRecorder.web.d.ts.map +1 -1
  44. package/ios/AudioNotificationManager.swift +2 -6
  45. package/ios/AudioStreamManager.swift +116 -50
  46. package/ios/ExpoAudioStream.podspec +6 -0
  47. package/ios/ExpoAudioStreamModule.swift +11 -8
  48. package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
  49. package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
  50. package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
  51. package/ios/ExpoAudioStudioTests/Info.plist +22 -0
  52. package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
  53. package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
  54. package/ios/RecordingSettings.swift +53 -22
  55. package/ios/tests/integration/buffer_duration_test.swift +185 -0
  56. package/ios/tests/integration/output_control_test.swift +322 -0
  57. package/ios/tests/integration/run_integration_tests.sh +27 -0
  58. package/ios/tests/standalone/audio_processing_test.swift +144 -0
  59. package/ios/tests/standalone/audio_recording_test.swift +277 -0
  60. package/ios/tests/standalone/audio_streaming_test.swift +249 -0
  61. package/ios/tests/standalone/standalone_test.swift +144 -0
  62. package/package.json +140 -133
  63. package/src/AudioAnalysis/AudioAnalysis.types.ts +8 -1
  64. package/src/ExpoAudioStream.types.ts +66 -22
  65. package/src/ExpoAudioStream.web.ts +45 -39
  66. package/src/WebRecorder.web.ts +164 -130
  67. package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
  68. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  69. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  70. /package/plugin/build/{index.d.ts → index.d.cts} +0 -0
@@ -97,7 +97,7 @@ class WebRecorder {
97
97
  this.initFeatureExtractorWorker();
98
98
  }
99
99
  // Initialize compressed recording if enabled
100
- if (recordingConfig.compression?.enabled) {
100
+ if (recordingConfig.output?.compressed?.enabled) {
101
101
  this.initializeCompressedRecorder();
102
102
  }
103
103
  this.mediaStream = source.mediaStream;
@@ -141,6 +141,8 @@ class WebRecorder {
141
141
  const incomingPosition = typeof event.data.position === 'number'
142
142
  ? event.data.position
143
143
  : this.position;
144
+ // Simple position tracking for logging (no duplicate filtering)
145
+ this.logger?.debug(`Audio chunk: position=${incomingPosition.toFixed(3)}s, size=${pcmBufferFloat.length}`);
144
146
  // Calculate bytes per sample based on bit depth
145
147
  const bytesPerSample = this.bitDepth / 8;
146
148
  // Emit chunks without storing them
@@ -151,6 +153,14 @@ class WebRecorder {
151
153
  const startPosition = Math.floor(i * bytesPerSample);
152
154
  const endPosition = Math.floor((i + chunk.length) * bytesPerSample);
153
155
  const samples = chunk.length; // Number of samples in this chunk
156
+ // Only store PCM data if primary output is enabled
157
+ const shouldStoreUncompressed = this.config.output?.primary?.enabled ?? true;
158
+ // Store PCM chunks when needed - this is for the final WAV file
159
+ if (shouldStoreUncompressed) {
160
+ // Store the original Float32Array data for later WAV creation
161
+ this.appendPcmData(chunk);
162
+ this.totalSampleCount += chunk.length;
163
+ }
154
164
  // Process features if enabled
155
165
  if (this.config.enableProcessing &&
156
166
  this.featureExtractorWorker) {
@@ -170,34 +180,30 @@ class WebRecorder {
170
180
  samples,
171
181
  });
172
182
  }
173
- // Only store PCM data if web.storeUncompressedAudio is not explicitly false
174
- const shouldStoreUncompressed = this.config.web?.storeUncompressedAudio !== false;
175
- // Store PCM chunks when needed
176
- if (shouldStoreUncompressed) {
177
- // Store the original Float32Array data for later WAV creation
178
- this.appendPcmData(chunk);
179
- this.totalSampleCount += chunk.length;
180
- }
181
- // Emit chunk immediately
183
+ // Prepare compression data if available
184
+ const compression = this.pendingCompressedChunk
185
+ ? {
186
+ data: this.pendingCompressedChunk,
187
+ size: this.pendingCompressedChunk.size,
188
+ totalSize: this.compressedSize,
189
+ mimeType: 'audio/webm',
190
+ format: this.config.output?.compressed?.format ??
191
+ 'opus',
192
+ bitrate: this.config.output?.compressed?.bitrate ??
193
+ 128000,
194
+ }
195
+ : undefined;
196
+ // Emit chunk immediately - whether compressed or not
182
197
  this.emitAudioEventCallback({
183
198
  data: chunk,
184
199
  position: chunkPosition,
185
- compression: this.pendingCompressedChunk
186
- ? {
187
- data: this.pendingCompressedChunk,
188
- size: this.pendingCompressedChunk.size,
189
- totalSize: this.compressedSize,
190
- mimeType: 'audio/webm',
191
- format: 'opus',
192
- bitrate: this.config.compression?.bitrate ??
193
- 128000,
194
- }
195
- : undefined,
200
+ compression,
196
201
  });
202
+ // Reset pending compressed chunk after we've used it
203
+ this.pendingCompressedChunk = null;
197
204
  }
198
205
  // Update our position based on the worklet's position if provided
199
206
  this.position = incomingPosition + duration;
200
- this.pendingCompressedChunk = null;
201
207
  };
202
208
  // Ensure we use all relevant settings from config
203
209
  const recordSampleRate = this.audioContext.sampleRate;
@@ -212,12 +218,12 @@ class WebRecorder {
212
218
  channels,
213
219
  interval,
214
220
  position: this.position,
215
- deviceId: this.config.deviceId || 'default',
216
- compression: this.config.compression
221
+ deviceId: this.config.deviceId ?? 'default',
222
+ compression: this.config.output?.compressed
217
223
  ? {
218
- enabled: this.config.compression.enabled,
219
- format: this.config.compression.format,
220
- bitrate: this.config.compression.bitrate,
224
+ enabled: this.config.output.compressed.enabled,
225
+ format: this.config.output.compressed.format,
226
+ bitrate: this.config.output.compressed.bitrate,
221
227
  }
222
228
  : 'disabled',
223
229
  });
@@ -298,78 +304,85 @@ class WebRecorder {
298
304
  * @param event - The event containing audio analysis results
299
305
  */
300
306
  handleFeatureExtractorMessage(event) {
301
- if (event.data.command === 'features') {
302
- const segmentResult = event.data.result;
303
- // Track existing IDs to prevent duplicates
304
- const existingIds = new Set(this.audioAnalysisData.dataPoints.map((dp) => dp.id));
305
- // Filter out datapoints with duplicate IDs
306
- const uniqueNewDataPoints = segmentResult.dataPoints.filter((dp) => {
307
- return !existingIds.has(dp.id);
308
- });
309
- // Log filtered duplicates if any
310
- if (uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
311
- this.logger?.warn) {
312
- this.logger.warn(`Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`);
313
- }
314
- // Update counter based on the highest ID seen
315
- if (uniqueNewDataPoints.length > 0) {
316
- const lastDataPoint = uniqueNewDataPoints[uniqueNewDataPoints.length - 1];
317
- if (lastDataPoint && typeof lastDataPoint.id === 'number') {
318
- const nextIdValue = lastDataPoint.id + 1;
319
- if (nextIdValue > this.dataPointIdCounter) {
320
- this.dataPointIdCounter = nextIdValue;
321
- this.logger?.debug(`Counter updated to ${this.dataPointIdCounter}`);
322
- }
323
- }
324
- }
325
- // Add unique data points to our analysis data
326
- this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints);
327
- this.audioAnalysisData.durationMs += segmentResult.durationMs;
328
- this.audioAnalysisData.sampleRate = segmentResult.sampleRate;
329
- // Merge amplitude ranges
330
- if (segmentResult.amplitudeRange) {
331
- if (!this.audioAnalysisData.amplitudeRange) {
332
- this.audioAnalysisData.amplitudeRange = {
333
- ...segmentResult.amplitudeRange,
334
- };
335
- }
336
- else {
337
- this.audioAnalysisData.amplitudeRange = {
338
- min: Math.min(this.audioAnalysisData.amplitudeRange.min, segmentResult.amplitudeRange.min),
339
- max: Math.max(this.audioAnalysisData.amplitudeRange.max, segmentResult.amplitudeRange.max),
340
- };
341
- }
342
- }
343
- // Merge RMS ranges
344
- if (segmentResult.rmsRange) {
345
- if (!this.audioAnalysisData.rmsRange) {
346
- this.audioAnalysisData.rmsRange = {
347
- ...segmentResult.rmsRange,
348
- };
349
- }
350
- else {
351
- this.audioAnalysisData.rmsRange = {
352
- min: Math.min(this.audioAnalysisData.rmsRange.min, segmentResult.rmsRange.min),
353
- max: Math.max(this.audioAnalysisData.rmsRange.max, segmentResult.rmsRange.max),
354
- };
355
- }
307
+ if (event.data.command !== 'features')
308
+ return;
309
+ const segmentResult = event.data.result;
310
+ const uniqueNewDataPoints = this.filterUniqueDataPoints(segmentResult.dataPoints);
311
+ // Update counter based on the highest ID seen
312
+ this.updateDataPointCounter(uniqueNewDataPoints);
313
+ // Update analysis data with the new results
314
+ this.updateAudioAnalysisData(segmentResult, uniqueNewDataPoints);
315
+ // Send filtered result to avoid duplicate IDs
316
+ const filteredSegmentResult = {
317
+ ...segmentResult,
318
+ dataPoints: uniqueNewDataPoints,
319
+ };
320
+ this.emitAudioAnalysisCallback(filteredSegmentResult);
321
+ }
322
+ /**
323
+ * Filters out data points with duplicate IDs
324
+ */
325
+ filterUniqueDataPoints(dataPoints) {
326
+ // Track existing IDs to prevent duplicates
327
+ const existingIds = new Set(this.audioAnalysisData.dataPoints.map((dp) => dp.id));
328
+ // Filter out datapoints with duplicate IDs
329
+ const uniquePoints = dataPoints.filter((dp) => !existingIds.has(dp.id));
330
+ // Log filtered duplicates if any
331
+ if (uniquePoints.length < dataPoints.length && this.logger?.warn) {
332
+ this.logger.warn(`Filtered ${dataPoints.length - uniquePoints.length} duplicate datapoints`);
333
+ }
334
+ return uniquePoints;
335
+ }
336
+ /**
337
+ * Updates the counter based on the highest ID in datapoints
338
+ */
339
+ updateDataPointCounter(dataPoints) {
340
+ if (dataPoints.length === 0)
341
+ return;
342
+ const lastDataPoint = dataPoints[dataPoints.length - 1];
343
+ if (lastDataPoint && typeof lastDataPoint.id === 'number') {
344
+ const nextIdValue = lastDataPoint.id + 1;
345
+ if (nextIdValue > this.dataPointIdCounter) {
346
+ this.dataPointIdCounter = nextIdValue;
347
+ this.logger?.debug(`Counter updated to ${this.dataPointIdCounter}`);
356
348
  }
357
- // Send filtered result to avoid duplicate IDs
358
- const filteredSegmentResult = {
359
- ...segmentResult,
360
- dataPoints: uniqueNewDataPoints,
361
- };
362
- this.emitAudioAnalysisCallback(filteredSegmentResult);
363
349
  }
364
350
  }
351
+ /**
352
+ * Updates audio analysis data with segment results
353
+ */
354
+ updateAudioAnalysisData(segmentResult, uniqueDataPoints) {
355
+ // Add unique data points to our analysis data
356
+ this.audioAnalysisData.dataPoints.push(...uniqueDataPoints);
357
+ this.audioAnalysisData.durationMs += segmentResult.durationMs;
358
+ this.audioAnalysisData.sampleRate = segmentResult.sampleRate;
359
+ // Update amplitude range if present
360
+ if (segmentResult.amplitudeRange) {
361
+ this.audioAnalysisData.amplitudeRange = this.mergeRange(this.audioAnalysisData.amplitudeRange, segmentResult.amplitudeRange);
362
+ }
363
+ // Update RMS range if present
364
+ if (segmentResult.rmsRange) {
365
+ this.audioAnalysisData.rmsRange = this.mergeRange(this.audioAnalysisData.rmsRange, segmentResult.rmsRange);
366
+ }
367
+ }
368
+ /**
369
+ * Merges value ranges
370
+ */
371
+ mergeRange(existing, newRange) {
372
+ if (!existing)
373
+ return { ...newRange };
374
+ return {
375
+ min: Math.min(existing.min, newRange.min),
376
+ max: Math.max(existing.max, newRange.max),
377
+ };
378
+ }
365
379
  /**
366
380
  * Reset the data point counter to a specific value or zero
367
381
  * @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
368
382
  */
369
383
  resetDataPointCounter(startCounterFrom) {
370
384
  // Set the counter with the passed value or 0
371
- this.dataPointIdCounter =
372
- startCounterFrom !== undefined ? startCounterFrom : 0;
385
+ this.dataPointIdCounter = startCounterFrom ?? 0;
373
386
  this.logger?.debug(`Reset data point counter to ${this.dataPointIdCounter}`);
374
387
  // Update worker counter if available
375
388
  if (this.featureExtractorWorker) {
@@ -431,7 +444,7 @@ class WebRecorder {
431
444
  this.logger?.warn('No PCM data available to create WAV file');
432
445
  return null;
433
446
  }
434
- const sampleRate = this.config.sampleRate || this.audioContext.sampleRate;
447
+ const sampleRate = this.config.sampleRate ?? this.audioContext.sampleRate;
435
448
  const channels = this.numberOfChannels || 1;
436
449
  // Convert float32 PCM data to 16-bit PCM for WAV
437
450
  const bytesPerSample = 2; // 16-bit = 2 bytes
@@ -479,8 +492,7 @@ class WebRecorder {
479
492
  let uncompressedBlob;
480
493
  // Only create WAV if we have PCM data
481
494
  if (this.pcmData && this.pcmData.length > 0) {
482
- uncompressedBlob =
483
- (await this.createWavFromPcmData()) || undefined;
495
+ uncompressedBlob = this.createWavFromPcmData() || undefined;
484
496
  }
485
497
  // Return the compressed and/or uncompressed blobs if available
486
498
  return {
@@ -500,6 +512,7 @@ class WebRecorder {
500
512
  this.pendingCompressedChunk = null;
501
513
  this.pcmData = null;
502
514
  this.totalSampleCount = 0;
515
+ this.dataPointIdCounter = 0; // Reset counter
503
516
  }
504
517
  }
505
518
  /**
@@ -514,12 +527,10 @@ class WebRecorder {
514
527
  }
515
528
  // Check if AudioContext is already closed before attempting to close it
516
529
  if (this.audioContext && this.audioContext.state !== 'closed') {
517
- try {
518
- this.audioContext.close();
519
- }
520
- catch (e) {
521
- // Ignore closure errors - this happens if already closed
522
- }
530
+ this.audioContext.close().catch((e) => {
531
+ // Log closure errors but continue cleanup
532
+ this.logger?.warn('Error closing AudioContext:', e);
533
+ });
523
534
  }
524
535
  // Safely disconnect audioWorkletNode if it exists
525
536
  if (this.audioWorkletNode) {
@@ -527,7 +538,8 @@ class WebRecorder {
527
538
  this.audioWorkletNode.disconnect();
528
539
  }
529
540
  catch (e) {
530
- // Ignore disconnection errors - node might be already disconnected
541
+ // Log disconnection errors but continue cleanup
542
+ this.logger?.warn('Error disconnecting audioWorkletNode:', e);
531
543
  }
532
544
  }
533
545
  // Safely disconnect source if it exists
@@ -536,7 +548,8 @@ class WebRecorder {
536
548
  this.source.disconnect();
537
549
  }
538
550
  catch (e) {
539
- // Ignore disconnection errors - source might be already disconnected
551
+ // Log disconnection errors but continue cleanup
552
+ this.logger?.warn('Error disconnecting source:', e);
540
553
  }
541
554
  }
542
555
  // Always stop media stream tracks to release hardware resources
@@ -616,6 +629,8 @@ class WebRecorder {
616
629
  }
617
630
  catch (error) {
618
631
  this.logger?.error('Error in resume(): ', error);
632
+ // Rethrow the error to inform callers
633
+ throw new Error(`Failed to resume recording: ${error instanceof Error ? error.message : 'unknown error'}`);
619
634
  }
620
635
  }
621
636
  /**
@@ -631,18 +646,22 @@ class WebRecorder {
631
646
  }
632
647
  this.compressedMediaRecorder = new MediaRecorder(this.source.mediaStream, {
633
648
  mimeType,
634
- audioBitsPerSecond: this.config.compression?.bitrate ?? 128000,
649
+ audioBitsPerSecond: this.config.output?.compressed?.bitrate ?? 128000,
635
650
  });
636
651
  this.compressedMediaRecorder.ondataavailable = (event) => {
637
652
  if (event.data.size > 0) {
653
+ // Store the compressed chunk for final blob creation
638
654
  this.compressedChunks.push(event.data);
639
655
  this.compressedSize += event.data.size;
656
+ // Store the pending compressed chunk for the next PCM chunk to use
640
657
  this.pendingCompressedChunk = event.data;
641
658
  }
642
659
  };
643
660
  }
644
661
  catch (error) {
645
662
  this.logger?.error('Failed to initialize compressed recorder:', error);
663
+ // Setting to null to indicate initialization failed
664
+ this.compressedMediaRecorder = null;
646
665
  }
647
666
  }
648
667
  /**
@@ -697,6 +716,7 @@ class WebRecorder {
697
716
  }
698
717
  catch (e) {
699
718
  // Ignore disconnection errors as the track might already be gone
719
+ this.logger?.warn('Error disconnecting audioWorkletNode:', e);
700
720
  }
701
721
  }
702
722
  };