@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
|
@@ -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.
|
|
288
|
+
compression: recordingConfig.output?.compressed?.enabled
|
|
289
289
|
? {
|
|
290
|
-
...recordingConfig.
|
|
291
|
-
bitrate:
|
|
290
|
+
...recordingConfig.output.compressed,
|
|
291
|
+
bitrate:
|
|
292
|
+
recordingConfig.output.compressed.bitrate ?? 128000,
|
|
292
293
|
size: 0,
|
|
293
294
|
mimeType: 'audio/webm',
|
|
294
|
-
format:
|
|
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
|
|
464
|
-
this.recordingConfig?.
|
|
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:
|
|
477
|
+
format:
|
|
478
|
+
this.recordingConfig?.output?.compressed?.format ??
|
|
479
|
+
'opus',
|
|
474
480
|
bitrate:
|
|
475
|
-
this.recordingConfig?.
|
|
481
|
+
this.recordingConfig?.output?.compressed?.bitrate ??
|
|
482
|
+
128000,
|
|
476
483
|
}
|
|
477
484
|
|
|
478
|
-
//
|
|
479
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 =
|
|
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?.
|
|
635
|
+
compression: this.recordingConfig?.output?.compressed?.enabled
|
|
634
636
|
? {
|
|
635
637
|
size: this.totalCompressedSize,
|
|
636
638
|
mimeType: 'audio/webm',
|
|
637
|
-
format:
|
|
639
|
+
format:
|
|
640
|
+
this.recordingConfig.output.compressed.format ??
|
|
641
|
+
'opus',
|
|
638
642
|
bitrate:
|
|
639
|
-
this.recordingConfig.
|
|
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
|
}
|
package/src/WebRecorder.web.ts
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
306
|
-
compression: this.config.
|
|
317
|
+
deviceId: this.config.deviceId ?? 'default',
|
|
318
|
+
compression: this.config.output?.compressed
|
|
307
319
|
? {
|
|
308
|
-
enabled: this.config.
|
|
309
|
-
format: this.config.
|
|
310
|
-
bitrate: this.config.
|
|
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
|
|
411
|
-
const segmentResult = event.data.result
|
|
422
|
+
if (event.data.command !== 'features') return
|
|
412
423
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
424
|
+
const segmentResult = event.data.result
|
|
425
|
+
const uniqueNewDataPoints = this.filterUniqueDataPoints(
|
|
426
|
+
segmentResult.dataPoints
|
|
427
|
+
)
|
|
417
428
|
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
435
|
+
// Send filtered result to avoid duplicate IDs
|
|
436
|
+
const filteredSegmentResult = {
|
|
437
|
+
...segmentResult,
|
|
438
|
+
dataPoints: uniqueNewDataPoints,
|
|
439
|
+
}
|
|
439
440
|
|
|
440
|
-
|
|
441
|
-
|
|
441
|
+
this.emitAudioAnalysisCallback(filteredSegmentResult)
|
|
442
|
+
}
|
|
442
443
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
}
|