@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
@@ -285,13 +285,15 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
285
285
  bitDepth: this.bitDepth,
286
286
  channels: recordingConfig.channels ?? 1,
287
287
  sampleRate: recordingConfig.sampleRate ?? 44100,
288
- compression: recordingConfig.compression
288
+ compression: recordingConfig.output?.compressed?.enabled
289
289
  ? {
290
- ...recordingConfig.compression,
291
- bitrate: recordingConfig.compression?.bitrate ?? 128000,
290
+ ...recordingConfig.output.compressed,
291
+ bitrate:
292
+ recordingConfig.output.compressed.bitrate ?? 128000,
292
293
  size: 0,
293
294
  mimeType: 'audio/webm',
294
- format: recordingConfig.compression?.format ?? 'opus',
295
+ format:
296
+ recordingConfig.output.compressed.format ?? 'opus',
295
297
  compressedFileUri: '',
296
298
  }
297
299
  : undefined,
@@ -459,56 +461,56 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
459
461
  let fileUri = `${this.streamUuid}.${this.extension}`
460
462
  let mimeType = `audio/${this.extension}`
461
463
 
462
- // Handle both compressed and uncompressed blobs according to configuration
463
- const compressionEnabled =
464
- this.recordingConfig?.compression?.enabled ?? false
464
+ // Handle both compressed and uncompressed blobs according to new output configuration
465
+ const primaryEnabled =
466
+ this.recordingConfig?.output?.primary?.enabled ?? true
467
+ const compressedEnabled =
468
+ this.recordingConfig?.output?.compressed?.enabled ?? false
465
469
 
466
- // Process compressed blob if available
467
- if (compressedBlob) {
470
+ // Process compressed blob if available and enabled
471
+ if (compressedBlob && compressedEnabled) {
468
472
  const compressedUri = URL.createObjectURL(compressedBlob)
469
473
  const compressedInfo = {
470
474
  compressedFileUri: compressedUri,
471
475
  size: compressedBlob.size,
472
476
  mimeType: 'audio/webm',
473
- format: 'opus',
477
+ format:
478
+ this.recordingConfig?.output?.compressed?.format ??
479
+ 'opus',
474
480
  bitrate:
475
- this.recordingConfig?.compression?.bitrate ?? 128000,
481
+ this.recordingConfig?.output?.compressed?.bitrate ??
482
+ 128000,
476
483
  }
477
484
 
478
- // If compression is enabled, use compressed blob as primary format
479
- if (compressionEnabled) {
485
+ // Store compression info
486
+ compression = compressedInfo
487
+
488
+ // If primary is disabled, use compressed as main file
489
+ if (!primaryEnabled) {
480
490
  this.logger?.debug(
481
- 'Using compressed audio as primary output'
491
+ 'Using compressed audio as primary output (primary disabled)'
482
492
  )
483
493
  fileUri = compressedUri
484
494
  mimeType = 'audio/webm'
485
-
486
- // Store compression info
487
- compression = compressedInfo
488
- } else {
489
- // Compression was enabled during recording but not set as primary
490
- // Store as alternate format
491
- compression = compressedInfo
492
495
  }
493
496
  }
494
497
 
495
- // Process uncompressed WAV if available
496
- if (uncompressedBlob) {
498
+ // Process uncompressed WAV if available and primary is enabled
499
+ if (uncompressedBlob && primaryEnabled) {
497
500
  const wavUri = URL.createObjectURL(uncompressedBlob)
498
-
499
- // If compression is disabled or no compressed blob is available,
500
- // use WAV as primary format
501
- if (!compressionEnabled || !compressedBlob) {
502
- this.logger?.debug(
503
- 'Using uncompressed WAV as primary output'
504
- )
505
- fileUri = wavUri
506
- mimeType = 'audio/wav'
507
- }
501
+ fileUri = wavUri
502
+ mimeType = 'audio/wav'
503
+ } else if (!primaryEnabled && !compressedEnabled) {
504
+ // No outputs enabled - streaming only mode
505
+ this.logger?.debug('No outputs enabled - streaming only mode')
506
+ fileUri = ''
507
+ mimeType = 'audio/wav'
508
508
  }
509
509
 
510
510
  // Use the stored streamUuid for the final filename
511
- const filename = `${this.streamUuid}.${this.extension}`
511
+ const filename = fileUri
512
+ ? `${this.streamUuid}.${this.extension}`
513
+ : 'stream-only'
512
514
  const result: AudioRecording = {
513
515
  fileUri,
514
516
  filename,
@@ -517,7 +519,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
517
519
  channels: this.recordingConfig?.channels ?? 1,
518
520
  sampleRate: this.recordingConfig?.sampleRate ?? 44100,
519
521
  durationMs: this.currentDurationMs,
520
- size: this.currentSize,
522
+ size: primaryEnabled ? this.currentSize : 0,
521
523
  mimeType,
522
524
  compression,
523
525
  }
@@ -630,13 +632,16 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
630
632
  interval: this.currentInterval,
631
633
  intervalAnalysis: this.currentIntervalAnalysis,
632
634
  mimeType: `audio/${this.extension}`,
633
- compression: this.recordingConfig?.compression?.enabled
635
+ compression: this.recordingConfig?.output?.compressed?.enabled
634
636
  ? {
635
637
  size: this.totalCompressedSize,
636
638
  mimeType: 'audio/webm',
637
- format: this.recordingConfig.compression.format ?? 'opus',
639
+ format:
640
+ this.recordingConfig.output.compressed.format ??
641
+ 'opus',
638
642
  bitrate:
639
- this.recordingConfig.compression.bitrate ?? 128000,
643
+ this.recordingConfig.output.compressed.bitrate ??
644
+ 128000,
640
645
  compressedFileUri: `${this.streamUuid}.webm`,
641
646
  }
642
647
  : undefined,
@@ -850,7 +855,8 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
850
855
  }
851
856
  } catch (err) {
852
857
  this.logger?.warn(
853
- 'Error determining current device, using default'
858
+ 'Error determining current device, using default',
859
+ err
854
860
  )
855
861
  }
856
862
  }
@@ -48,15 +48,15 @@ export class WebRecorder {
48
48
  private bitDepth: number // Bit depth of the audio
49
49
  private exportBitDepth: number // Bit depth of the audio
50
50
  private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
51
- private logger?: ConsoleLike
51
+ private readonly logger?: ConsoleLike
52
52
  private compressedMediaRecorder: MediaRecorder | null = null
53
53
  private compressedChunks: Blob[] = []
54
54
  private compressedSize: number = 0
55
55
  private pendingCompressedChunk: Blob | null = null
56
56
  private dataPointIdCounter: number = 0 // Add this property to track the counter
57
57
  private deviceDisconnectionHandler: (() => void) | null = null
58
- private mediaStream: MediaStream | null = null
59
- private onInterruptionCallback?: (event: {
58
+ private readonly mediaStream: MediaStream | null = null
59
+ private readonly onInterruptionCallback?: (event: {
60
60
  reason: string
61
61
  isPaused: boolean
62
62
  timestamp: number
@@ -154,7 +154,7 @@ export class WebRecorder {
154
154
  }
155
155
 
156
156
  // Initialize compressed recording if enabled
157
- if (recordingConfig.compression?.enabled) {
157
+ if (recordingConfig.output?.compressed?.enabled) {
158
158
  this.initializeCompressedRecorder()
159
159
  }
160
160
 
@@ -214,6 +214,11 @@ export class WebRecorder {
214
214
  ? event.data.position
215
215
  : this.position
216
216
 
217
+ // Simple position tracking for logging (no duplicate filtering)
218
+ this.logger?.debug(
219
+ `Audio chunk: position=${incomingPosition.toFixed(3)}s, size=${pcmBufferFloat.length}`
220
+ )
221
+
217
222
  // Calculate bytes per sample based on bit depth
218
223
  const bytesPerSample = this.bitDepth / 8
219
224
 
@@ -229,6 +234,17 @@ export class WebRecorder {
229
234
  )
230
235
  const samples = chunk.length // Number of samples in this chunk
231
236
 
237
+ // Only store PCM data if primary output is enabled
238
+ const shouldStoreUncompressed =
239
+ this.config.output?.primary?.enabled ?? true
240
+
241
+ // Store PCM chunks when needed - this is for the final WAV file
242
+ if (shouldStoreUncompressed) {
243
+ // Store the original Float32Array data for later WAV creation
244
+ this.appendPcmData(chunk)
245
+ this.totalSampleCount += chunk.length
246
+ }
247
+
232
248
  // Process features if enabled
233
249
  if (
234
250
  this.config.enableProcessing &&
@@ -252,39 +268,35 @@ export class WebRecorder {
252
268
  })
253
269
  }
254
270
 
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
-
266
- // Emit chunk immediately
271
+ // Prepare compression data if available
272
+ const compression = this.pendingCompressedChunk
273
+ ? {
274
+ data: this.pendingCompressedChunk,
275
+ size: this.pendingCompressedChunk.size,
276
+ totalSize: this.compressedSize,
277
+ mimeType: 'audio/webm',
278
+ format:
279
+ this.config.output?.compressed?.format ??
280
+ 'opus',
281
+ bitrate:
282
+ this.config.output?.compressed?.bitrate ??
283
+ 128000,
284
+ }
285
+ : undefined
286
+
287
+ // Emit chunk immediately - whether compressed or not
267
288
  this.emitAudioEventCallback({
268
289
  data: chunk,
269
290
  position: chunkPosition,
270
- compression: this.pendingCompressedChunk
271
- ? {
272
- data: this.pendingCompressedChunk,
273
- size: this.pendingCompressedChunk.size,
274
- totalSize: this.compressedSize,
275
- mimeType: 'audio/webm',
276
- format: 'opus',
277
- bitrate:
278
- this.config.compression?.bitrate ??
279
- 128000,
280
- }
281
- : undefined,
291
+ compression,
282
292
  })
293
+
294
+ // Reset pending compressed chunk after we've used it
295
+ this.pendingCompressedChunk = null
283
296
  }
284
297
 
285
298
  // Update our position based on the worklet's position if provided
286
299
  this.position = incomingPosition + duration
287
- this.pendingCompressedChunk = null
288
300
  }
289
301
 
290
302
  // Ensure we use all relevant settings from config
@@ -302,12 +314,12 @@ export class WebRecorder {
302
314
  channels,
303
315
  interval,
304
316
  position: this.position,
305
- deviceId: this.config.deviceId || 'default',
306
- compression: this.config.compression
317
+ deviceId: this.config.deviceId ?? 'default',
318
+ compression: this.config.output?.compressed
307
319
  ? {
308
- enabled: this.config.compression.enabled,
309
- format: this.config.compression.format,
310
- bitrate: this.config.compression.bitrate,
320
+ enabled: this.config.output.compressed.enabled,
321
+ format: this.config.output.compressed.format,
322
+ bitrate: this.config.output.compressed.bitrate,
311
323
  }
312
324
  : 'disabled',
313
325
  })
@@ -407,100 +419,109 @@ export class WebRecorder {
407
419
  * @param event - The event containing audio analysis results
408
420
  */
409
421
  handleFeatureExtractorMessage(event: AudioFeaturesEvent) {
410
- if (event.data.command === 'features') {
411
- const segmentResult = event.data.result
422
+ if (event.data.command !== 'features') return
412
423
 
413
- // Track existing IDs to prevent duplicates
414
- const existingIds = new Set(
415
- this.audioAnalysisData.dataPoints.map((dp) => dp.id)
416
- )
424
+ const segmentResult = event.data.result
425
+ const uniqueNewDataPoints = this.filterUniqueDataPoints(
426
+ segmentResult.dataPoints
427
+ )
417
428
 
418
- // Filter out datapoints with duplicate IDs
419
- const uniqueNewDataPoints = segmentResult.dataPoints.filter(
420
- (dp) => {
421
- return !existingIds.has(dp.id)
422
- }
423
- )
429
+ // Update counter based on the highest ID seen
430
+ this.updateDataPointCounter(uniqueNewDataPoints)
424
431
 
425
- // Log filtered duplicates if any
426
- if (
427
- uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
428
- this.logger?.warn
429
- ) {
430
- this.logger.warn(
431
- `Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`
432
- )
433
- }
432
+ // Update analysis data with the new results
433
+ this.updateAudioAnalysisData(segmentResult, uniqueNewDataPoints)
434
434
 
435
- // Update counter based on the highest ID seen
436
- if (uniqueNewDataPoints.length > 0) {
437
- const lastDataPoint =
438
- uniqueNewDataPoints[uniqueNewDataPoints.length - 1]
435
+ // Send filtered result to avoid duplicate IDs
436
+ const filteredSegmentResult = {
437
+ ...segmentResult,
438
+ dataPoints: uniqueNewDataPoints,
439
+ }
439
440
 
440
- if (lastDataPoint && typeof lastDataPoint.id === 'number') {
441
- const nextIdValue = lastDataPoint.id + 1
441
+ this.emitAudioAnalysisCallback(filteredSegmentResult)
442
+ }
442
443
 
443
- if (nextIdValue > this.dataPointIdCounter) {
444
- this.dataPointIdCounter = nextIdValue
445
- this.logger?.debug(
446
- `Counter updated to ${this.dataPointIdCounter}`
447
- )
448
- }
449
- }
450
- }
444
+ /**
445
+ * Filters out data points with duplicate IDs
446
+ */
447
+ private filterUniqueDataPoints(dataPoints: any[]): any[] {
448
+ // Track existing IDs to prevent duplicates
449
+ const existingIds = new Set(
450
+ this.audioAnalysisData.dataPoints.map((dp) => dp.id)
451
+ )
451
452
 
452
- // Add unique data points to our analysis data
453
- this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints)
454
- this.audioAnalysisData.durationMs += segmentResult.durationMs
455
- this.audioAnalysisData.sampleRate = segmentResult.sampleRate
453
+ // Filter out datapoints with duplicate IDs
454
+ const uniquePoints = dataPoints.filter((dp) => !existingIds.has(dp.id))
456
455
 
457
- // Merge amplitude ranges
458
- if (segmentResult.amplitudeRange) {
459
- if (!this.audioAnalysisData.amplitudeRange) {
460
- this.audioAnalysisData.amplitudeRange = {
461
- ...segmentResult.amplitudeRange,
462
- }
463
- } else {
464
- this.audioAnalysisData.amplitudeRange = {
465
- min: Math.min(
466
- this.audioAnalysisData.amplitudeRange.min,
467
- segmentResult.amplitudeRange.min
468
- ),
469
- max: Math.max(
470
- this.audioAnalysisData.amplitudeRange.max,
471
- segmentResult.amplitudeRange.max
472
- ),
473
- }
474
- }
475
- }
456
+ // Log filtered duplicates if any
457
+ if (uniquePoints.length < dataPoints.length && this.logger?.warn) {
458
+ this.logger.warn(
459
+ `Filtered ${dataPoints.length - uniquePoints.length} duplicate datapoints`
460
+ )
461
+ }
476
462
 
477
- // Merge RMS ranges
478
- if (segmentResult.rmsRange) {
479
- if (!this.audioAnalysisData.rmsRange) {
480
- this.audioAnalysisData.rmsRange = {
481
- ...segmentResult.rmsRange,
482
- }
483
- } else {
484
- this.audioAnalysisData.rmsRange = {
485
- min: Math.min(
486
- this.audioAnalysisData.rmsRange.min,
487
- segmentResult.rmsRange.min
488
- ),
489
- max: Math.max(
490
- this.audioAnalysisData.rmsRange.max,
491
- segmentResult.rmsRange.max
492
- ),
493
- }
494
- }
495
- }
463
+ return uniquePoints
464
+ }
496
465
 
497
- // Send filtered result to avoid duplicate IDs
498
- const filteredSegmentResult = {
499
- ...segmentResult,
500
- dataPoints: uniqueNewDataPoints,
466
+ /**
467
+ * Updates the counter based on the highest ID in datapoints
468
+ */
469
+ private updateDataPointCounter(dataPoints: any[]): void {
470
+ if (dataPoints.length === 0) return
471
+
472
+ const lastDataPoint = dataPoints[dataPoints.length - 1]
473
+ if (lastDataPoint && typeof lastDataPoint.id === 'number') {
474
+ const nextIdValue = lastDataPoint.id + 1
475
+ if (nextIdValue > this.dataPointIdCounter) {
476
+ this.dataPointIdCounter = nextIdValue
477
+ this.logger?.debug(
478
+ `Counter updated to ${this.dataPointIdCounter}`
479
+ )
501
480
  }
481
+ }
482
+ }
502
483
 
503
- this.emitAudioAnalysisCallback(filteredSegmentResult)
484
+ /**
485
+ * Updates audio analysis data with segment results
486
+ */
487
+ private updateAudioAnalysisData(
488
+ segmentResult: AudioAnalysis,
489
+ uniqueDataPoints: any[]
490
+ ): void {
491
+ // Add unique data points to our analysis data
492
+ this.audioAnalysisData.dataPoints.push(...uniqueDataPoints)
493
+ this.audioAnalysisData.durationMs += segmentResult.durationMs
494
+ this.audioAnalysisData.sampleRate = segmentResult.sampleRate
495
+
496
+ // Update amplitude range if present
497
+ if (segmentResult.amplitudeRange) {
498
+ this.audioAnalysisData.amplitudeRange = this.mergeRange(
499
+ this.audioAnalysisData.amplitudeRange,
500
+ segmentResult.amplitudeRange
501
+ )
502
+ }
503
+
504
+ // Update RMS range if present
505
+ if (segmentResult.rmsRange) {
506
+ this.audioAnalysisData.rmsRange = this.mergeRange(
507
+ this.audioAnalysisData.rmsRange,
508
+ segmentResult.rmsRange
509
+ )
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Merges value ranges
515
+ */
516
+ private mergeRange(
517
+ existing: { min: number; max: number } | undefined,
518
+ newRange: { min: number; max: number }
519
+ ): { min: number; max: number } {
520
+ if (!existing) return { ...newRange }
521
+
522
+ return {
523
+ min: Math.min(existing.min, newRange.min),
524
+ max: Math.max(existing.max, newRange.max),
504
525
  }
505
526
  }
506
527
 
@@ -510,8 +531,7 @@ export class WebRecorder {
510
531
  */
511
532
  resetDataPointCounter(startCounterFrom?: number): void {
512
533
  // Set the counter with the passed value or 0
513
- this.dataPointIdCounter =
514
- startCounterFrom !== undefined ? startCounterFrom : 0
534
+ this.dataPointIdCounter = startCounterFrom ?? 0
515
535
  this.logger?.debug(
516
536
  `Reset data point counter to ${this.dataPointIdCounter}`
517
537
  )
@@ -591,7 +611,7 @@ export class WebRecorder {
591
611
  }
592
612
 
593
613
  const sampleRate =
594
- this.config.sampleRate || this.audioContext.sampleRate
614
+ this.config.sampleRate ?? this.audioContext.sampleRate
595
615
  const channels = this.numberOfChannels || 1
596
616
 
597
617
  // Convert float32 PCM data to 16-bit PCM for WAV
@@ -648,8 +668,7 @@ export class WebRecorder {
648
668
 
649
669
  // Only create WAV if we have PCM data
650
670
  if (this.pcmData && this.pcmData.length > 0) {
651
- uncompressedBlob =
652
- (await this.createWavFromPcmData()) || undefined
671
+ uncompressedBlob = this.createWavFromPcmData() || undefined
653
672
  }
654
673
 
655
674
  // Return the compressed and/or uncompressed blobs if available
@@ -670,6 +689,7 @@ export class WebRecorder {
670
689
  this.pendingCompressedChunk = null
671
690
  this.pcmData = null
672
691
  this.totalSampleCount = 0
692
+ this.dataPointIdCounter = 0 // Reset counter
673
693
  }
674
694
  }
675
695
 
@@ -686,11 +706,10 @@ export class WebRecorder {
686
706
 
687
707
  // Check if AudioContext is already closed before attempting to close it
688
708
  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
- }
709
+ this.audioContext.close().catch((e) => {
710
+ // Log closure errors but continue cleanup
711
+ this.logger?.warn('Error closing AudioContext:', e)
712
+ })
694
713
  }
695
714
 
696
715
  // Safely disconnect audioWorkletNode if it exists
@@ -698,7 +717,8 @@ export class WebRecorder {
698
717
  try {
699
718
  this.audioWorkletNode.disconnect()
700
719
  } catch (e) {
701
- // Ignore disconnection errors - node might be already disconnected
720
+ // Log disconnection errors but continue cleanup
721
+ this.logger?.warn('Error disconnecting audioWorkletNode:', e)
702
722
  }
703
723
  }
704
724
 
@@ -707,7 +727,8 @@ export class WebRecorder {
707
727
  try {
708
728
  this.source.disconnect()
709
729
  } catch (e) {
710
- // Ignore disconnection errors - source might be already disconnected
730
+ // Log disconnection errors but continue cleanup
731
+ this.logger?.warn('Error disconnecting source:', e)
711
732
  }
712
733
  }
713
734
 
@@ -797,8 +818,12 @@ export class WebRecorder {
797
818
  this.audioWorkletNode.connect(this.audioContext.destination)
798
819
  this.audioWorkletNode.port.postMessage({ command: 'resume' })
799
820
  this.compressedMediaRecorder?.resume()
800
- } catch (error) {
821
+ } catch (error: unknown) {
801
822
  this.logger?.error('Error in resume(): ', error)
823
+ // Rethrow the error to inform callers
824
+ throw new Error(
825
+ `Failed to resume recording: ${error instanceof Error ? error.message : 'unknown error'}`
826
+ )
802
827
  }
803
828
  }
804
829
 
@@ -821,14 +846,17 @@ export class WebRecorder {
821
846
  {
822
847
  mimeType,
823
848
  audioBitsPerSecond:
824
- this.config.compression?.bitrate ?? 128000,
849
+ this.config.output?.compressed?.bitrate ?? 128000,
825
850
  }
826
851
  )
827
852
 
828
853
  this.compressedMediaRecorder.ondataavailable = (event) => {
829
854
  if (event.data.size > 0) {
855
+ // Store the compressed chunk for final blob creation
830
856
  this.compressedChunks.push(event.data)
831
857
  this.compressedSize += event.data.size
858
+
859
+ // Store the pending compressed chunk for the next PCM chunk to use
832
860
  this.pendingCompressedChunk = event.data
833
861
  }
834
862
  }
@@ -837,6 +865,8 @@ export class WebRecorder {
837
865
  'Failed to initialize compressed recorder:',
838
866
  error
839
867
  )
868
+ // Setting to null to indicate initialization failed
869
+ this.compressedMediaRecorder = null
840
870
  }
841
871
  }
842
872
 
@@ -903,6 +933,10 @@ export class WebRecorder {
903
933
  this.audioWorkletNode.disconnect()
904
934
  } catch (e) {
905
935
  // Ignore disconnection errors as the track might already be gone
936
+ this.logger?.warn(
937
+ 'Error disconnecting audioWorkletNode:',
938
+ e
939
+ )
906
940
  }
907
941
  }
908
942
  }
@@ -1,56 +0,0 @@
1
- package net.siteed.audiostream
2
-
3
- import org.junit.Test
4
- import org.junit.Assert.*
5
-
6
- class AudioProcessorTest {
7
-
8
- private val sampleRate = 44100
9
- private val channels = 1
10
- private val encoding = "pcm_16bit"
11
- private val pointsPerSecond = 1000
12
- private val algorithm = "rms"
13
- private val features = mapOf("rms" to true, "zcr" to true)
14
-
15
- private val recordingConfig = RecordingConfig(
16
- sampleRate = sampleRate,
17
- channels = channels,
18
- encoding = encoding,
19
- interval = 1000,
20
- enableProcessing = true,
21
- pointsPerSecond = pointsPerSecond,
22
- algorithm = algorithm,
23
- features = features
24
- )
25
-
26
- private val audioProcessor = AudioProcessor()
27
-
28
- @Test
29
- fun testProcessAudioData() {
30
- val data = generateSineWave(440.0, sampleRate, 2.0)
31
-
32
- val result = audioProcessor.processAudioData(data, recordingConfig)
33
-
34
- assertNotNull(result)
35
- assertEquals(pointsPerSecond, result.pointsPerSecond)
36
- assertEquals((data.size / sampleRate) * 1000, result.durationMs.toInt())
37
- assertEquals(16, result.bitDepth)
38
- assertEquals(channels, result.numberOfChannels)
39
- assertEquals(sampleRate, result.sampleRate.toInt())
40
- }
41
-
42
- // Helper function to generate a sine wave
43
- private fun generateSineWave(frequency: Double, sampleRate: Int, durationSeconds: Double): ByteArray {
44
- val numSamples = (sampleRate * durationSeconds).toInt()
45
- val output = ByteArray(numSamples * 2) // 16-bit PCM
46
-
47
- for (i in 0 until numSamples) {
48
- val time = i / sampleRate.toDouble()
49
- val amplitude = (Math.sin(2.0 * Math.PI * frequency * time) * 32767).toInt()
50
- output[i * 2] = (amplitude and 0xff).toByte()
51
- output[i * 2 + 1] = ((amplitude shr 8) and 0xff).toByte()
52
- }
53
-
54
- return output
55
- }
56
- }
@@ -1,7 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <Workspace
3
- version = "1.0">
4
- <FileRef
5
- location = "self:">
6
- </FileRef>
7
- </Workspace>
@@ -1,8 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
- <plist version="1.0">
4
- <dict>
5
- <key>IDEDidComputeMac32BitWarning</key>
6
- <true/>
7
- </dict>
8
- </plist>