@siteed/expo-audio-studio 2.4.1 → 2.5.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 +10 -1
- package/README.md +25 -0
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
- package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
- package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +576 -252
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +419 -155
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/AudioDeviceManager.d.ts +107 -0
- package/build/AudioDeviceManager.d.ts.map +1 -0
- package/build/AudioDeviceManager.js +493 -0
- package/build/AudioDeviceManager.js.map +1 -0
- package/build/AudioRecorder.provider.d.ts.map +1 -1
- package/build/AudioRecorder.provider.js +3 -0
- package/build/AudioRecorder.provider.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +90 -1
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js +7 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +37 -0
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +399 -54
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/ExpoAudioStreamModule.d.ts.map +1 -1
- package/build/ExpoAudioStreamModule.js +20 -0
- package/build/ExpoAudioStreamModule.js.map +1 -1
- package/build/WebRecorder.web.d.ts +63 -10
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +277 -68
- package/build/WebRecorder.web.js.map +1 -1
- package/build/hooks/useAudioDevices.d.ts +14 -0
- package/build/hooks/useAudioDevices.d.ts.map +1 -0
- package/build/hooks/useAudioDevices.js +151 -0
- package/build/hooks/useAudioDevices.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/useAudioRecorder.d.ts +1 -0
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +20 -1
- package/build/useAudioRecorder.js.map +1 -1
- package/build/utils/BlobFix.d.ts.map +1 -1
- package/build/utils/BlobFix.js +2 -2
- package/build/utils/BlobFix.js.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.js +27 -26
- package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.js +25 -1
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
- package/ios/AudioDeviceManager.swift +654 -0
- package/ios/AudioStreamManager.swift +964 -760
- package/ios/ExpoAudioStreamModule.swift +174 -19
- package/ios/Features.swift +1 -1
- package/ios/ISSUE_IOS.md +45 -0
- package/ios/Logger.swift +13 -1
- package/ios/RecordingSettings.swift +12 -0
- package/package.json +2 -2
- package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
- package/src/AudioDeviceManager.ts +571 -0
- package/src/AudioRecorder.provider.tsx +3 -0
- package/src/ExpoAudioStream.types.ts +97 -1
- package/src/ExpoAudioStream.web.ts +513 -63
- package/src/ExpoAudioStreamModule.ts +23 -0
- package/src/WebRecorder.web.ts +346 -81
- package/src/hooks/useAudioDevices.ts +180 -0
- package/src/index.ts +6 -0
- package/src/types/crc-32.d.ts +6 -6
- package/src/useAudioRecorder.tsx +27 -1
- package/src/utils/BlobFix.ts +6 -4
- package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
- package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
|
@@ -800,6 +800,29 @@ if (Platform.OS === 'web') {
|
|
|
800
800
|
delete ExpoAudioStreamModule.listeners[eventName]
|
|
801
801
|
}
|
|
802
802
|
}
|
|
803
|
+
|
|
804
|
+
ExpoAudioStreamModule.prepareRecording = async (options: any) => {
|
|
805
|
+
// For web platform, we'll implement a simplified version that just checks permissions
|
|
806
|
+
// and does minimal setup. The actual recording setup will still happen in startRecording.
|
|
807
|
+
try {
|
|
808
|
+
// Check for microphone permissions
|
|
809
|
+
const permissionsResult =
|
|
810
|
+
await ExpoAudioStreamModule.getPermissionsAsync()
|
|
811
|
+
if (!permissionsResult.granted) {
|
|
812
|
+
throw new Error('Microphone permission not granted')
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// If using a web instance, call its prepareRecording method
|
|
816
|
+
if (instance) {
|
|
817
|
+
return await instance.prepareRecording(options)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return true
|
|
821
|
+
} catch (error) {
|
|
822
|
+
console.error('Error preparing recording:', error)
|
|
823
|
+
throw error
|
|
824
|
+
}
|
|
825
|
+
}
|
|
803
826
|
}
|
|
804
827
|
|
|
805
828
|
// Move the encodeCompressedAudio function outside the if block to fix the ESLint error
|
package/src/WebRecorder.web.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface AudioWorkletEvent {
|
|
|
15
15
|
command: string
|
|
16
16
|
recordedData?: Float32Array
|
|
17
17
|
sampleRate?: number
|
|
18
|
+
position?: number
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -33,7 +34,7 @@ const DEFAULT_WEB_NUMBER_OF_CHANNELS = 1
|
|
|
33
34
|
const TAG = 'WebRecorder'
|
|
34
35
|
|
|
35
36
|
export class WebRecorder {
|
|
36
|
-
|
|
37
|
+
public audioContext: AudioContext
|
|
37
38
|
private audioWorkletNode!: AudioWorkletNode
|
|
38
39
|
private featureExtractorWorker?: Worker
|
|
39
40
|
private source: MediaStreamAudioSourceNode
|
|
@@ -45,14 +46,33 @@ export class WebRecorder {
|
|
|
45
46
|
private bitDepth: number // Bit depth of the audio
|
|
46
47
|
private exportBitDepth: number // Bit depth of the audio
|
|
47
48
|
private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
|
|
48
|
-
private packetCount: number = 0
|
|
49
49
|
private logger?: ConsoleLike
|
|
50
50
|
private compressedMediaRecorder: MediaRecorder | null = null
|
|
51
51
|
private compressedChunks: Blob[] = []
|
|
52
52
|
private compressedSize: number = 0
|
|
53
53
|
private pendingCompressedChunk: Blob | null = null
|
|
54
|
-
private readonly wavMimeType = 'audio/wav'
|
|
55
54
|
private dataPointIdCounter: number = 0 // Add this property to track the counter
|
|
55
|
+
private deviceDisconnectionHandler: (() => void) | null = null
|
|
56
|
+
private mediaStream: MediaStream | null = null
|
|
57
|
+
private onInterruptionCallback?: (event: {
|
|
58
|
+
reason: string
|
|
59
|
+
isPaused: boolean
|
|
60
|
+
timestamp: number
|
|
61
|
+
}) => void
|
|
62
|
+
private _isDeviceDisconnected: boolean = false
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Flag to indicate whether this is the first audio chunk after a device switch
|
|
66
|
+
* Used to maintain proper duration counting
|
|
67
|
+
*/
|
|
68
|
+
public isFirstChunkAfterSwitch: boolean = false
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Gets whether the recording device has been disconnected
|
|
72
|
+
*/
|
|
73
|
+
get isDeviceDisconnected(): boolean {
|
|
74
|
+
return this._isDeviceDisconnected
|
|
75
|
+
}
|
|
56
76
|
|
|
57
77
|
/**
|
|
58
78
|
* Initializes a new WebRecorder instance for audio recording and processing
|
|
@@ -61,6 +81,7 @@ export class WebRecorder {
|
|
|
61
81
|
* @param recordingConfig - Configuration options for the recording
|
|
62
82
|
* @param emitAudioEventCallback - Callback function for audio data events
|
|
63
83
|
* @param emitAudioAnalysisCallback - Callback function for audio analysis events
|
|
84
|
+
* @param onInterruption - Callback for recording interruptions
|
|
64
85
|
* @param logger - Optional logger for debugging information
|
|
65
86
|
*/
|
|
66
87
|
constructor({
|
|
@@ -69,6 +90,7 @@ export class WebRecorder {
|
|
|
69
90
|
recordingConfig,
|
|
70
91
|
emitAudioEventCallback,
|
|
71
92
|
emitAudioAnalysisCallback,
|
|
93
|
+
onInterruption,
|
|
72
94
|
logger,
|
|
73
95
|
}: {
|
|
74
96
|
audioContext: AudioContext
|
|
@@ -76,6 +98,11 @@ export class WebRecorder {
|
|
|
76
98
|
recordingConfig: RecordingConfig
|
|
77
99
|
emitAudioEventCallback: EmitAudioEventFunction
|
|
78
100
|
emitAudioAnalysisCallback: EmitAudioAnalysisFunction
|
|
101
|
+
onInterruption?: (event: {
|
|
102
|
+
reason: string
|
|
103
|
+
isPaused: boolean
|
|
104
|
+
timestamp: number
|
|
105
|
+
}) => void
|
|
79
106
|
logger?: ConsoleLike
|
|
80
107
|
}) {
|
|
81
108
|
this.audioContext = audioContext
|
|
@@ -126,6 +153,12 @@ export class WebRecorder {
|
|
|
126
153
|
if (recordingConfig.compression?.enabled) {
|
|
127
154
|
this.initializeCompressedRecorder()
|
|
128
155
|
}
|
|
156
|
+
|
|
157
|
+
this.mediaStream = source.mediaStream
|
|
158
|
+
this.onInterruptionCallback = onInterruption
|
|
159
|
+
|
|
160
|
+
// Setup device disconnection detection
|
|
161
|
+
this.setupDeviceDisconnectionDetection()
|
|
129
162
|
}
|
|
130
163
|
|
|
131
164
|
/**
|
|
@@ -164,13 +197,19 @@ export class WebRecorder {
|
|
|
164
197
|
event.data.sampleRate ?? this.audioContext.sampleRate
|
|
165
198
|
const duration = pcmBufferFloat.length / sampleRate
|
|
166
199
|
|
|
200
|
+
// Use incoming position if provided by worklet, otherwise use our tracked position
|
|
201
|
+
const incomingPosition =
|
|
202
|
+
typeof event.data.position === 'number'
|
|
203
|
+
? event.data.position
|
|
204
|
+
: this.position
|
|
205
|
+
|
|
167
206
|
// Calculate bytes per sample based on bit depth
|
|
168
207
|
const bytesPerSample = this.bitDepth / 8
|
|
169
208
|
|
|
170
209
|
// Emit chunks without storing them
|
|
171
210
|
for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
|
|
172
211
|
const chunk = pcmBufferFloat.slice(i, i + chunkSize)
|
|
173
|
-
const chunkPosition =
|
|
212
|
+
const chunkPosition = incomingPosition + i / sampleRate
|
|
174
213
|
|
|
175
214
|
// Calculate byte positions and samples
|
|
176
215
|
const startPosition = Math.floor(i * bytesPerSample)
|
|
@@ -221,12 +260,13 @@ export class WebRecorder {
|
|
|
221
260
|
})
|
|
222
261
|
}
|
|
223
262
|
|
|
224
|
-
|
|
263
|
+
// Update our position based on the worklet's position if provided
|
|
264
|
+
this.position = incomingPosition + duration
|
|
225
265
|
this.pendingCompressedChunk = null
|
|
226
266
|
}
|
|
227
267
|
|
|
228
268
|
this.logger?.debug(
|
|
229
|
-
`WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}`,
|
|
269
|
+
`WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}, startPosition=${this.position}`,
|
|
230
270
|
this.config
|
|
231
271
|
)
|
|
232
272
|
this.audioWorkletNode.port.postMessage({
|
|
@@ -238,6 +278,7 @@ export class WebRecorder {
|
|
|
238
278
|
exportBitDepth: this.exportBitDepth,
|
|
239
279
|
channels: this.numberOfChannels,
|
|
240
280
|
interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
|
|
281
|
+
position: this.position, // Pass the current position to the processor
|
|
241
282
|
// enableLogging: !!this.logger,
|
|
242
283
|
})
|
|
243
284
|
|
|
@@ -265,6 +306,18 @@ export class WebRecorder {
|
|
|
265
306
|
this.featureExtractorWorker.onerror = (error) => {
|
|
266
307
|
console.error(`[${TAG}] Feature extractor worker error:`, error)
|
|
267
308
|
}
|
|
309
|
+
|
|
310
|
+
// Initialize worker with counter if needed
|
|
311
|
+
if (this.dataPointIdCounter > 0) {
|
|
312
|
+
this.featureExtractorWorker.postMessage({
|
|
313
|
+
command: 'resetCounter',
|
|
314
|
+
value: this.dataPointIdCounter,
|
|
315
|
+
})
|
|
316
|
+
this.logger?.debug(
|
|
317
|
+
`Initialized worker with counter value ${this.dataPointIdCounter}`
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
268
321
|
this.logger?.log(
|
|
269
322
|
'Feature extractor worker initialized successfully'
|
|
270
323
|
)
|
|
@@ -285,42 +338,51 @@ export class WebRecorder {
|
|
|
285
338
|
if (event.data.command === 'features') {
|
|
286
339
|
const segmentResult = event.data.result
|
|
287
340
|
|
|
288
|
-
//
|
|
341
|
+
// Track existing IDs to prevent duplicates
|
|
342
|
+
const existingIds = new Set(
|
|
343
|
+
this.audioAnalysisData.dataPoints.map((dp) => dp.id)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
// Filter out datapoints with duplicate IDs
|
|
347
|
+
const uniqueNewDataPoints = segmentResult.dataPoints.filter(
|
|
348
|
+
(dp) => {
|
|
349
|
+
return !existingIds.has(dp.id)
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
// Log filtered duplicates if any
|
|
289
354
|
if (
|
|
290
|
-
segmentResult.dataPoints &&
|
|
291
|
-
|
|
355
|
+
uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
|
|
356
|
+
this.logger?.warn
|
|
292
357
|
) {
|
|
358
|
+
this.logger.warn(
|
|
359
|
+
`Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Update counter based on the highest ID seen
|
|
364
|
+
if (uniqueNewDataPoints.length > 0) {
|
|
293
365
|
const lastDataPoint =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
]
|
|
366
|
+
uniqueNewDataPoints[uniqueNewDataPoints.length - 1]
|
|
367
|
+
|
|
297
368
|
if (lastDataPoint && typeof lastDataPoint.id === 'number') {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
369
|
+
const nextIdValue = lastDataPoint.id + 1
|
|
370
|
+
|
|
371
|
+
if (nextIdValue > this.dataPointIdCounter) {
|
|
372
|
+
this.dataPointIdCounter = nextIdValue
|
|
373
|
+
this.logger?.debug(
|
|
374
|
+
`Counter updated to ${this.dataPointIdCounter}`
|
|
375
|
+
)
|
|
376
|
+
}
|
|
302
377
|
}
|
|
303
378
|
}
|
|
304
379
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
durationMs: segmentResult.durationMs,
|
|
308
|
-
sampleRate: segmentResult.sampleRate,
|
|
309
|
-
amplitudeRange: segmentResult.amplitudeRange,
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
// Ensure consistent sample rate in the result
|
|
313
|
-
segmentResult.sampleRate =
|
|
314
|
-
this.config.sampleRate || this.audioContext.sampleRate
|
|
315
|
-
|
|
316
|
-
// Update the full audio analysis data with proper range merging
|
|
317
|
-
this.audioAnalysisData.dataPoints.push(...segmentResult.dataPoints)
|
|
380
|
+
// Add unique data points to our analysis data
|
|
381
|
+
this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints)
|
|
318
382
|
this.audioAnalysisData.durationMs += segmentResult.durationMs
|
|
319
|
-
|
|
320
|
-
// Make sure the sample rate is consistent
|
|
321
383
|
this.audioAnalysisData.sampleRate = segmentResult.sampleRate
|
|
322
384
|
|
|
323
|
-
//
|
|
385
|
+
// Merge amplitude ranges
|
|
324
386
|
if (segmentResult.amplitudeRange) {
|
|
325
387
|
if (!this.audioAnalysisData.amplitudeRange) {
|
|
326
388
|
this.audioAnalysisData.amplitudeRange = {
|
|
@@ -340,7 +402,7 @@ export class WebRecorder {
|
|
|
340
402
|
}
|
|
341
403
|
}
|
|
342
404
|
|
|
343
|
-
//
|
|
405
|
+
// Merge RMS ranges
|
|
344
406
|
if (segmentResult.rmsRange) {
|
|
345
407
|
if (!this.audioAnalysisData.rmsRange) {
|
|
346
408
|
this.audioAnalysisData.rmsRange = {
|
|
@@ -360,49 +422,81 @@ export class WebRecorder {
|
|
|
360
422
|
}
|
|
361
423
|
}
|
|
362
424
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
this.emitAudioAnalysisCallback(segmentResult)
|
|
425
|
+
// Send filtered result to avoid duplicate IDs
|
|
426
|
+
const filteredSegmentResult = {
|
|
427
|
+
...segmentResult,
|
|
428
|
+
dataPoints: uniqueNewDataPoints,
|
|
429
|
+
}
|
|
369
430
|
|
|
370
|
-
this.
|
|
371
|
-
dataPointsLength: this.audioAnalysisData.dataPoints.length,
|
|
372
|
-
durationMs: this.audioAnalysisData.durationMs,
|
|
373
|
-
sampleRate: this.audioAnalysisData.sampleRate,
|
|
374
|
-
amplitudeRange: this.audioAnalysisData.amplitudeRange,
|
|
375
|
-
})
|
|
431
|
+
this.emitAudioAnalysisCallback(filteredSegmentResult)
|
|
376
432
|
}
|
|
377
433
|
}
|
|
378
434
|
|
|
379
435
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
436
|
+
* Reset the data point counter to a specific value or zero
|
|
437
|
+
* @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
|
|
382
438
|
*/
|
|
383
|
-
resetDataPointCounter() {
|
|
384
|
-
|
|
439
|
+
resetDataPointCounter(startCounterFrom?: number): void {
|
|
440
|
+
// Set the counter with the passed value or 0
|
|
441
|
+
this.dataPointIdCounter =
|
|
442
|
+
startCounterFrom !== undefined ? startCounterFrom : 0
|
|
443
|
+
this.logger?.debug(
|
|
444
|
+
`Reset data point counter to ${this.dataPointIdCounter}`
|
|
445
|
+
)
|
|
385
446
|
|
|
386
|
-
//
|
|
447
|
+
// Update worker counter if available
|
|
387
448
|
if (this.featureExtractorWorker) {
|
|
388
449
|
this.featureExtractorWorker.postMessage({
|
|
389
450
|
command: 'resetCounter',
|
|
390
|
-
|
|
451
|
+
value: this.dataPointIdCounter,
|
|
391
452
|
})
|
|
453
|
+
} else {
|
|
454
|
+
this.logger?.warn(
|
|
455
|
+
'No feature extractor worker available to update counter'
|
|
456
|
+
)
|
|
392
457
|
}
|
|
393
458
|
}
|
|
394
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Get the current data point counter value
|
|
462
|
+
* @returns The current value of the data point counter
|
|
463
|
+
*/
|
|
464
|
+
getDataPointCounter(): number {
|
|
465
|
+
return this.dataPointIdCounter
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Prepares the recorder for continuity after device switch
|
|
470
|
+
* Sets up all necessary state to maintain proper recording continuity
|
|
471
|
+
*/
|
|
472
|
+
prepareForDeviceSwitch(): void {
|
|
473
|
+
this.isFirstChunkAfterSwitch = true
|
|
474
|
+
this.logger?.debug(
|
|
475
|
+
`Prepared for device switch at position ${this.position}s`
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
395
479
|
/**
|
|
396
480
|
* Starts the audio recording process
|
|
397
481
|
* Connects the audio nodes and begins capturing audio data
|
|
482
|
+
* @param preserveCounters If true, do not reset the counter (used for device switching)
|
|
398
483
|
*/
|
|
399
|
-
start() {
|
|
484
|
+
start(preserveCounters = false) {
|
|
400
485
|
this.source.connect(this.audioWorkletNode)
|
|
401
486
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
402
|
-
this.packetCount = 0
|
|
403
487
|
|
|
404
|
-
//
|
|
405
|
-
|
|
488
|
+
// Only reset the counter when not preserving state (e.g., for a fresh recording)
|
|
489
|
+
if (!preserveCounters) {
|
|
490
|
+
this.logger?.debug(
|
|
491
|
+
'Starting fresh recording, resetting counter to 0'
|
|
492
|
+
)
|
|
493
|
+
this.resetDataPointCounter(0) // Explicitly reset to 0 for new recordings
|
|
494
|
+
this.isFirstChunkAfterSwitch = false
|
|
495
|
+
} else {
|
|
496
|
+
this.logger?.debug(
|
|
497
|
+
`Preserving counter at ${this.dataPointIdCounter} during device switch`
|
|
498
|
+
)
|
|
499
|
+
}
|
|
406
500
|
|
|
407
501
|
if (this.compressedMediaRecorder) {
|
|
408
502
|
this.compressedMediaRecorder.start(this.config.interval ?? 1000)
|
|
@@ -411,20 +505,40 @@ export class WebRecorder {
|
|
|
411
505
|
|
|
412
506
|
/**
|
|
413
507
|
* Stops the audio recording process and returns the recorded data
|
|
508
|
+
* @param externalAudioChunks Optional array of Float32Array chunks from previous devices
|
|
414
509
|
* @returns Promise resolving to an object containing PCM data and optional compressed blob
|
|
415
510
|
*/
|
|
416
|
-
async stop(
|
|
511
|
+
async stop(
|
|
512
|
+
externalAudioChunks?: Float32Array[]
|
|
513
|
+
): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
|
|
417
514
|
try {
|
|
418
|
-
|
|
515
|
+
// Log what's happening for debugging
|
|
516
|
+
this.logger?.debug('Stopping recording and collecting final data')
|
|
517
|
+
|
|
518
|
+
// Stop any compressed recording first
|
|
519
|
+
if (
|
|
520
|
+
this.compressedMediaRecorder &&
|
|
521
|
+
this.compressedMediaRecorder.state !== 'inactive'
|
|
522
|
+
) {
|
|
419
523
|
this.compressedMediaRecorder.stop()
|
|
420
|
-
return {
|
|
421
|
-
pcmData: new Float32Array(), // Return empty array since we're streaming
|
|
422
|
-
compressedBlob: new Blob(this.compressedChunks, {
|
|
423
|
-
type: 'audio/webm;codecs=opus',
|
|
424
|
-
}),
|
|
425
|
-
}
|
|
426
524
|
}
|
|
427
|
-
|
|
525
|
+
|
|
526
|
+
// Wait for any pending compressed chunks to be processed
|
|
527
|
+
if (this.compressedMediaRecorder) {
|
|
528
|
+
// Small delay to ensure all data is processed
|
|
529
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Return the compressed blob if available
|
|
533
|
+
return {
|
|
534
|
+
pcmData: new Float32Array(), // Return empty array since we're streaming
|
|
535
|
+
compressedBlob:
|
|
536
|
+
this.compressedChunks.length > 0
|
|
537
|
+
? new Blob(this.compressedChunks, {
|
|
538
|
+
type: 'audio/webm;codecs=opus',
|
|
539
|
+
})
|
|
540
|
+
: undefined,
|
|
541
|
+
}
|
|
428
542
|
} finally {
|
|
429
543
|
this.cleanup()
|
|
430
544
|
// Reset the chunks array
|
|
@@ -438,17 +552,45 @@ export class WebRecorder {
|
|
|
438
552
|
* Cleans up resources when recording is stopped
|
|
439
553
|
* Closes audio context and disconnects nodes
|
|
440
554
|
*/
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
555
|
+
public cleanup() {
|
|
556
|
+
// Remove device disconnection handler
|
|
557
|
+
if (this.deviceDisconnectionHandler) {
|
|
558
|
+
this.deviceDisconnectionHandler()
|
|
559
|
+
this.deviceDisconnectionHandler = null
|
|
444
560
|
}
|
|
561
|
+
|
|
562
|
+
// Check if AudioContext is already closed before attempting to close it
|
|
563
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
564
|
+
try {
|
|
565
|
+
this.audioContext.close()
|
|
566
|
+
} catch (e) {
|
|
567
|
+
// Ignore closure errors - this happens if already closed
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Safely disconnect audioWorkletNode if it exists
|
|
445
572
|
if (this.audioWorkletNode) {
|
|
446
|
-
|
|
573
|
+
try {
|
|
574
|
+
this.audioWorkletNode.disconnect()
|
|
575
|
+
} catch (e) {
|
|
576
|
+
// Ignore disconnection errors - node might be already disconnected
|
|
577
|
+
}
|
|
447
578
|
}
|
|
579
|
+
|
|
580
|
+
// Safely disconnect source if it exists
|
|
448
581
|
if (this.source) {
|
|
449
|
-
|
|
582
|
+
try {
|
|
583
|
+
this.source.disconnect()
|
|
584
|
+
} catch (e) {
|
|
585
|
+
// Ignore disconnection errors - source might be already disconnected
|
|
586
|
+
}
|
|
450
587
|
}
|
|
588
|
+
|
|
589
|
+
// Always stop media stream tracks to release hardware resources
|
|
451
590
|
this.stopMediaStreamTracks()
|
|
591
|
+
|
|
592
|
+
// Mark as disconnected to prevent future errors
|
|
593
|
+
this._isDeviceDisconnected = true
|
|
452
594
|
}
|
|
453
595
|
|
|
454
596
|
/**
|
|
@@ -456,20 +598,37 @@ export class WebRecorder {
|
|
|
456
598
|
* Disconnects audio nodes and pauses the media recorder
|
|
457
599
|
*/
|
|
458
600
|
pause() {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
601
|
+
try {
|
|
602
|
+
// Note: We're just pausing, not disconnecting the device
|
|
603
|
+
// Simply disconnect nodes temporarily without marking device as disconnected
|
|
604
|
+
this.source.disconnect(this.audioWorkletNode)
|
|
605
|
+
this.audioWorkletNode.disconnect(this.audioContext.destination)
|
|
606
|
+
this.audioWorkletNode.port.postMessage({ command: 'pause' })
|
|
607
|
+
|
|
608
|
+
if (this.compressedMediaRecorder?.state === 'recording') {
|
|
609
|
+
this.compressedMediaRecorder.pause()
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
this.logger?.debug('Recording paused successfully')
|
|
613
|
+
} catch (error) {
|
|
614
|
+
this.logger?.error('Error in pause(): ', error)
|
|
615
|
+
// Already disconnected, just ignore and continue
|
|
616
|
+
}
|
|
463
617
|
}
|
|
464
618
|
|
|
465
619
|
/**
|
|
466
620
|
* Stops all media stream tracks to release hardware resources
|
|
467
621
|
* Ensures recording indicators (like microphone icon) are turned off
|
|
468
622
|
*/
|
|
469
|
-
stopMediaStreamTracks() {
|
|
623
|
+
public stopMediaStreamTracks() {
|
|
470
624
|
// Stop all audio tracks to stop the recording icon
|
|
471
|
-
|
|
472
|
-
|
|
625
|
+
if (this.mediaStream) {
|
|
626
|
+
const tracks = this.mediaStream.getTracks()
|
|
627
|
+
tracks.forEach((track) => track.stop())
|
|
628
|
+
} else if (this.source?.mediaStream) {
|
|
629
|
+
const tracks = this.source.mediaStream.getTracks()
|
|
630
|
+
tracks.forEach((track) => track.stop())
|
|
631
|
+
}
|
|
473
632
|
}
|
|
474
633
|
|
|
475
634
|
/**
|
|
@@ -502,10 +661,20 @@ export class WebRecorder {
|
|
|
502
661
|
* Reconnects audio nodes and resumes the media recorder
|
|
503
662
|
*/
|
|
504
663
|
resume() {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
664
|
+
// If device was disconnected, we can't resume
|
|
665
|
+
if (this._isDeviceDisconnected) {
|
|
666
|
+
this.logger?.warn('Cannot resume recording: device disconnected')
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
this.source.connect(this.audioWorkletNode)
|
|
672
|
+
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
673
|
+
this.audioWorkletNode.port.postMessage({ command: 'resume' })
|
|
674
|
+
this.compressedMediaRecorder?.resume()
|
|
675
|
+
} catch (error) {
|
|
676
|
+
this.logger?.error('Error in resume(): ', error)
|
|
677
|
+
}
|
|
509
678
|
}
|
|
510
679
|
|
|
511
680
|
/**
|
|
@@ -573,8 +742,104 @@ export class WebRecorder {
|
|
|
573
742
|
startPosition,
|
|
574
743
|
endPosition,
|
|
575
744
|
samples,
|
|
576
|
-
startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
|
|
577
745
|
})
|
|
578
746
|
}
|
|
579
747
|
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Sets up detection for device disconnection events
|
|
751
|
+
*/
|
|
752
|
+
private setupDeviceDisconnectionDetection() {
|
|
753
|
+
if (!this.mediaStream) return
|
|
754
|
+
|
|
755
|
+
// Function to handle track ending (which happens on device disconnection)
|
|
756
|
+
const handleTrackEnded = () => {
|
|
757
|
+
this.logger?.warn('Audio track ended - device disconnected')
|
|
758
|
+
this._isDeviceDisconnected = true
|
|
759
|
+
|
|
760
|
+
// Use the callback to notify parent component about device disconnection
|
|
761
|
+
if (this.onInterruptionCallback) {
|
|
762
|
+
this.onInterruptionCallback({
|
|
763
|
+
reason: 'deviceDisconnected',
|
|
764
|
+
isPaused: true,
|
|
765
|
+
timestamp: Date.now(),
|
|
766
|
+
})
|
|
767
|
+
this.logger?.debug('Notified about device disconnection')
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Ensure we disconnect nodes to prevent zombie recordings
|
|
771
|
+
if (this.audioWorkletNode) {
|
|
772
|
+
this.audioWorkletNode.port.postMessage({
|
|
773
|
+
command: 'deviceDisconnected',
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
try {
|
|
777
|
+
this.source.disconnect(this.audioWorkletNode)
|
|
778
|
+
this.audioWorkletNode.disconnect()
|
|
779
|
+
} catch (e) {
|
|
780
|
+
// Ignore disconnection errors as the track might already be gone
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Add listeners to all audio tracks
|
|
786
|
+
const tracks = this.mediaStream.getAudioTracks()
|
|
787
|
+
tracks.forEach((track) => {
|
|
788
|
+
track.addEventListener('ended', handleTrackEnded)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
// Store the handler for cleanup
|
|
792
|
+
this.deviceDisconnectionHandler = () => {
|
|
793
|
+
tracks.forEach((track) => {
|
|
794
|
+
track.removeEventListener('ended', handleTrackEnded)
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Explicitly set the position for continuous recording across device switches
|
|
801
|
+
* @param position The position in seconds to continue from
|
|
802
|
+
*/
|
|
803
|
+
setPosition(position: number): void {
|
|
804
|
+
if (position >= 0) {
|
|
805
|
+
this.position = position
|
|
806
|
+
this.logger?.debug(`Position explicitly set to ${position} seconds`)
|
|
807
|
+
} else {
|
|
808
|
+
this.logger?.warn(`Invalid position value: ${position}, ignoring`)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Get the current position in seconds
|
|
814
|
+
* @returns The current position
|
|
815
|
+
*/
|
|
816
|
+
getPosition(): number {
|
|
817
|
+
return this.position
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Gets the current compressed chunks
|
|
822
|
+
* @returns Array of current compressed audio chunks
|
|
823
|
+
*/
|
|
824
|
+
getCompressedChunks(): Blob[] {
|
|
825
|
+
return [...this.compressedChunks]
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Sets the compressed chunks from a previous recorder
|
|
830
|
+
* @param chunks Array of compressed chunks from a previous recorder
|
|
831
|
+
*/
|
|
832
|
+
setCompressedChunks(chunks: Blob[]): void {
|
|
833
|
+
if (chunks && chunks.length > 0) {
|
|
834
|
+
this.logger?.debug(
|
|
835
|
+
`Adding ${chunks.length} compressed chunks from previous device`
|
|
836
|
+
)
|
|
837
|
+
this.compressedChunks = [...chunks, ...this.compressedChunks]
|
|
838
|
+
// Update size
|
|
839
|
+
this.compressedSize = this.compressedChunks.reduce(
|
|
840
|
+
(size, chunk) => size + chunk.size,
|
|
841
|
+
0
|
|
842
|
+
)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
580
845
|
}
|