@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.
- package/CHANGELOG.md +17 -1
- package/android/build.gradle +9 -0
- package/android/src/androidTest/assets/chorus.wav +0 -0
- package/android/src/androidTest/assets/jfk.wav +0 -0
- package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
- package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
- package/android/src/androidTest/java/net/siteed/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
- package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -15
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
- package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
- package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +94 -0
- package/android/src/test/resources/jfk.wav +0 -0
- package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
- package/android/src/test/resources/recorder_hello_world.wav +0 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.web.js +38 -35
- package/build/cjs/ExpoAudioStream.web.js.map +1 -1
- package/build/cjs/WebRecorder.web.js +122 -102
- package/build/cjs/WebRecorder.web.js.map +1 -1
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.web.js +38 -35
- package/build/esm/ExpoAudioStream.web.js.map +1 -1
- package/build/esm/WebRecorder.web.js +122 -102
- package/build/esm/WebRecorder.web.js.map +1 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +3 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/ExpoAudioStream.types.d.ts +54 -22
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/types/WebRecorder.web.d.ts +19 -3
- package/build/types/WebRecorder.web.d.ts.map +1 -1
- package/ios/AudioNotificationManager.swift +2 -6
- package/ios/AudioStreamManager.swift +116 -50
- package/ios/ExpoAudioStream.podspec +6 -0
- package/ios/ExpoAudioStreamModule.swift +11 -8
- package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
- package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
- package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
- package/ios/ExpoAudioStudioTests/Info.plist +22 -0
- package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
- package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
- package/ios/RecordingSettings.swift +53 -22
- package/ios/tests/integration/buffer_duration_test.swift +185 -0
- package/ios/tests/integration/output_control_test.swift +322 -0
- package/ios/tests/integration/run_integration_tests.sh +27 -0
- package/ios/tests/standalone/audio_processing_test.swift +144 -0
- package/ios/tests/standalone/audio_recording_test.swift +277 -0
- package/ios/tests/standalone/audio_streaming_test.swift +249 -0
- package/ios/tests/standalone/standalone_test.swift +144 -0
- package/package.json +140 -133
- package/src/AudioAnalysis/AudioAnalysis.types.ts +8 -1
- package/src/ExpoAudioStream.types.ts +66 -22
- package/src/ExpoAudioStream.web.ts +45 -39
- package/src/WebRecorder.web.ts +164 -130
- package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
- package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
- package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
- /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.
|
|
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
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
216
|
-
compression: this.config.
|
|
221
|
+
deviceId: this.config.deviceId ?? 'default',
|
|
222
|
+
compression: this.config.output?.compressed
|
|
217
223
|
? {
|
|
218
|
-
enabled: this.config.
|
|
219
|
-
format: this.config.
|
|
220
|
-
bitrate: this.config.
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
this.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
};
|