@siteed/expo-audio-studio 2.4.1 → 2.6.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 +14 -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 +104 -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 +478 -62
- 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 +74 -11
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +390 -74
- 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/utils/writeWavHeader.d.ts +3 -18
- package/build/utils/writeWavHeader.d.ts.map +1 -1
- package/build/utils/writeWavHeader.js +19 -26
- package/build/utils/writeWavHeader.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 +113 -1
- package/src/ExpoAudioStream.web.ts +609 -69
- package/src/ExpoAudioStreamModule.ts +23 -0
- package/src/WebRecorder.web.ts +482 -92
- 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/utils/writeWavHeader.ts +26 -25
- package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
- package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
package/src/WebRecorder.web.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
EmitAudioEventFunction,
|
|
8
8
|
} from './ExpoAudioStream.web'
|
|
9
9
|
import { encodingToBitDepth } from './utils/encodingToBitDepth'
|
|
10
|
+
import { writeWavHeader } from './utils/writeWavHeader'
|
|
10
11
|
import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web'
|
|
11
12
|
import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web'
|
|
12
13
|
|
|
@@ -15,6 +16,8 @@ interface AudioWorkletEvent {
|
|
|
15
16
|
command: string
|
|
16
17
|
recordedData?: Float32Array
|
|
17
18
|
sampleRate?: number
|
|
19
|
+
position?: number
|
|
20
|
+
message?: string // For debug messages
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -33,7 +36,7 @@ const DEFAULT_WEB_NUMBER_OF_CHANNELS = 1
|
|
|
33
36
|
const TAG = 'WebRecorder'
|
|
34
37
|
|
|
35
38
|
export class WebRecorder {
|
|
36
|
-
|
|
39
|
+
public audioContext: AudioContext
|
|
37
40
|
private audioWorkletNode!: AudioWorkletNode
|
|
38
41
|
private featureExtractorWorker?: Worker
|
|
39
42
|
private source: MediaStreamAudioSourceNode
|
|
@@ -45,14 +48,35 @@ export class WebRecorder {
|
|
|
45
48
|
private bitDepth: number // Bit depth of the audio
|
|
46
49
|
private exportBitDepth: number // Bit depth of the audio
|
|
47
50
|
private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
|
|
48
|
-
private packetCount: number = 0
|
|
49
51
|
private logger?: ConsoleLike
|
|
50
52
|
private compressedMediaRecorder: MediaRecorder | null = null
|
|
51
53
|
private compressedChunks: Blob[] = []
|
|
52
54
|
private compressedSize: number = 0
|
|
53
55
|
private pendingCompressedChunk: Blob | null = null
|
|
54
|
-
private readonly wavMimeType = 'audio/wav'
|
|
55
56
|
private dataPointIdCounter: number = 0 // Add this property to track the counter
|
|
57
|
+
private deviceDisconnectionHandler: (() => void) | null = null
|
|
58
|
+
private mediaStream: MediaStream | null = null
|
|
59
|
+
private onInterruptionCallback?: (event: {
|
|
60
|
+
reason: string
|
|
61
|
+
isPaused: boolean
|
|
62
|
+
timestamp: number
|
|
63
|
+
}) => void
|
|
64
|
+
private _isDeviceDisconnected: boolean = false
|
|
65
|
+
private pcmData: Float32Array | null = null // Store original PCM data
|
|
66
|
+
private totalSampleCount: number = 0
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Flag to indicate whether this is the first audio chunk after a device switch
|
|
70
|
+
* Used to maintain proper duration counting
|
|
71
|
+
*/
|
|
72
|
+
public isFirstChunkAfterSwitch: boolean = false
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gets whether the recording device has been disconnected
|
|
76
|
+
*/
|
|
77
|
+
get isDeviceDisconnected(): boolean {
|
|
78
|
+
return this._isDeviceDisconnected
|
|
79
|
+
}
|
|
56
80
|
|
|
57
81
|
/**
|
|
58
82
|
* Initializes a new WebRecorder instance for audio recording and processing
|
|
@@ -61,6 +85,7 @@ export class WebRecorder {
|
|
|
61
85
|
* @param recordingConfig - Configuration options for the recording
|
|
62
86
|
* @param emitAudioEventCallback - Callback function for audio data events
|
|
63
87
|
* @param emitAudioAnalysisCallback - Callback function for audio analysis events
|
|
88
|
+
* @param onInterruption - Callback for recording interruptions
|
|
64
89
|
* @param logger - Optional logger for debugging information
|
|
65
90
|
*/
|
|
66
91
|
constructor({
|
|
@@ -69,6 +94,7 @@ export class WebRecorder {
|
|
|
69
94
|
recordingConfig,
|
|
70
95
|
emitAudioEventCallback,
|
|
71
96
|
emitAudioAnalysisCallback,
|
|
97
|
+
onInterruption,
|
|
72
98
|
logger,
|
|
73
99
|
}: {
|
|
74
100
|
audioContext: AudioContext
|
|
@@ -76,6 +102,11 @@ export class WebRecorder {
|
|
|
76
102
|
recordingConfig: RecordingConfig
|
|
77
103
|
emitAudioEventCallback: EmitAudioEventFunction
|
|
78
104
|
emitAudioAnalysisCallback: EmitAudioAnalysisFunction
|
|
105
|
+
onInterruption?: (event: {
|
|
106
|
+
reason: string
|
|
107
|
+
isPaused: boolean
|
|
108
|
+
timestamp: number
|
|
109
|
+
}) => void
|
|
79
110
|
logger?: ConsoleLike
|
|
80
111
|
}) {
|
|
81
112
|
this.audioContext = audioContext
|
|
@@ -126,6 +157,12 @@ export class WebRecorder {
|
|
|
126
157
|
if (recordingConfig.compression?.enabled) {
|
|
127
158
|
this.initializeCompressedRecorder()
|
|
128
159
|
}
|
|
160
|
+
|
|
161
|
+
this.mediaStream = source.mediaStream
|
|
162
|
+
this.onInterruptionCallback = onInterruption
|
|
163
|
+
|
|
164
|
+
// Setup device disconnection detection
|
|
165
|
+
this.setupDeviceDisconnectionDetection()
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
/**
|
|
@@ -150,6 +187,11 @@ export class WebRecorder {
|
|
|
150
187
|
event: AudioWorkletEvent
|
|
151
188
|
) => {
|
|
152
189
|
const command = event.data.command
|
|
190
|
+
if (command === 'debug') {
|
|
191
|
+
this.logger?.debug(`[AudioWorklet] ${event.data.message}`)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
153
195
|
if (command !== 'newData') return
|
|
154
196
|
|
|
155
197
|
const pcmBufferFloat = event.data.recordedData
|
|
@@ -159,18 +201,26 @@ export class WebRecorder {
|
|
|
159
201
|
}
|
|
160
202
|
|
|
161
203
|
// Process data in smaller chunks and emit immediately
|
|
162
|
-
const chunkSize = this.audioContext.sampleRate * 2 // Reduce to 2 seconds chunks
|
|
163
204
|
const sampleRate =
|
|
164
205
|
event.data.sampleRate ?? this.audioContext.sampleRate
|
|
206
|
+
// Use chunk size from config interval or default to 2 seconds
|
|
207
|
+
const intervalMs = this.config.interval ?? DEFAULT_WEB_INTERVAL
|
|
208
|
+
const chunkSize = Math.floor(sampleRate * (intervalMs / 1000))
|
|
165
209
|
const duration = pcmBufferFloat.length / sampleRate
|
|
166
210
|
|
|
211
|
+
// Use incoming position if provided by worklet, otherwise use our tracked position
|
|
212
|
+
const incomingPosition =
|
|
213
|
+
typeof event.data.position === 'number'
|
|
214
|
+
? event.data.position
|
|
215
|
+
: this.position
|
|
216
|
+
|
|
167
217
|
// Calculate bytes per sample based on bit depth
|
|
168
218
|
const bytesPerSample = this.bitDepth / 8
|
|
169
219
|
|
|
170
220
|
// Emit chunks without storing them
|
|
171
221
|
for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
|
|
172
222
|
const chunk = pcmBufferFloat.slice(i, i + chunkSize)
|
|
173
|
-
const chunkPosition =
|
|
223
|
+
const chunkPosition = incomingPosition + i / sampleRate
|
|
174
224
|
|
|
175
225
|
// Calculate byte positions and samples
|
|
176
226
|
const startPosition = Math.floor(i * bytesPerSample)
|
|
@@ -202,6 +252,17 @@ export class WebRecorder {
|
|
|
202
252
|
})
|
|
203
253
|
}
|
|
204
254
|
|
|
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
|
+
|
|
205
266
|
// Emit chunk immediately
|
|
206
267
|
this.emitAudioEventCallback({
|
|
207
268
|
data: chunk,
|
|
@@ -221,24 +282,47 @@ export class WebRecorder {
|
|
|
221
282
|
})
|
|
222
283
|
}
|
|
223
284
|
|
|
224
|
-
|
|
285
|
+
// Update our position based on the worklet's position if provided
|
|
286
|
+
this.position = incomingPosition + duration
|
|
225
287
|
this.pendingCompressedChunk = null
|
|
226
288
|
}
|
|
227
289
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
290
|
+
// Ensure we use all relevant settings from config
|
|
291
|
+
const recordSampleRate = this.audioContext.sampleRate
|
|
292
|
+
const exportSampleRate =
|
|
293
|
+
this.config.sampleRate ?? this.audioContext.sampleRate
|
|
294
|
+
const channels = this.config.channels ?? this.numberOfChannels
|
|
295
|
+
const interval = this.config.interval ?? DEFAULT_WEB_INTERVAL
|
|
296
|
+
|
|
297
|
+
this.logger?.debug(`WebRecorder initialized with config:`, {
|
|
298
|
+
recordSampleRate,
|
|
299
|
+
exportSampleRate,
|
|
300
|
+
bitDepth: this.bitDepth,
|
|
301
|
+
exportBitDepth: this.exportBitDepth,
|
|
302
|
+
channels,
|
|
303
|
+
interval,
|
|
304
|
+
position: this.position,
|
|
305
|
+
deviceId: this.config.deviceId || 'default',
|
|
306
|
+
compression: this.config.compression
|
|
307
|
+
? {
|
|
308
|
+
enabled: this.config.compression.enabled,
|
|
309
|
+
format: this.config.compression.format,
|
|
310
|
+
bitrate: this.config.compression.bitrate,
|
|
311
|
+
}
|
|
312
|
+
: 'disabled',
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Initialize the worklet with all settings from config
|
|
232
316
|
this.audioWorkletNode.port.postMessage({
|
|
233
317
|
command: 'init',
|
|
234
|
-
recordSampleRate
|
|
235
|
-
exportSampleRate
|
|
236
|
-
this.config.sampleRate ?? this.audioContext.sampleRate,
|
|
318
|
+
recordSampleRate,
|
|
319
|
+
exportSampleRate,
|
|
237
320
|
bitDepth: this.bitDepth,
|
|
238
321
|
exportBitDepth: this.exportBitDepth,
|
|
239
|
-
channels
|
|
240
|
-
interval
|
|
241
|
-
|
|
322
|
+
channels,
|
|
323
|
+
interval,
|
|
324
|
+
position: this.position, // Pass the current position to the processor
|
|
325
|
+
enableLogging: true,
|
|
242
326
|
})
|
|
243
327
|
|
|
244
328
|
// Connect the source to the AudioWorkletNode and start recording
|
|
@@ -249,6 +333,35 @@ export class WebRecorder {
|
|
|
249
333
|
}
|
|
250
334
|
}
|
|
251
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Append new PCM data to the existing buffer
|
|
338
|
+
* @param newData New Float32Array data to append
|
|
339
|
+
*/
|
|
340
|
+
private appendPcmData(newData: Float32Array): void {
|
|
341
|
+
// Clone the incoming data to ensure it's not modified
|
|
342
|
+
const dataToAdd = new Float32Array(newData)
|
|
343
|
+
|
|
344
|
+
if (!this.pcmData) {
|
|
345
|
+
// First chunk - create a copy to avoid references to original data
|
|
346
|
+
this.pcmData = new Float32Array(dataToAdd)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Create a new buffer with increased size
|
|
351
|
+
const newBuffer = new Float32Array(
|
|
352
|
+
this.pcmData.length + dataToAdd.length
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
// Copy existing data
|
|
356
|
+
newBuffer.set(this.pcmData)
|
|
357
|
+
|
|
358
|
+
// Append new data
|
|
359
|
+
newBuffer.set(dataToAdd, this.pcmData.length)
|
|
360
|
+
|
|
361
|
+
// Replace existing buffer
|
|
362
|
+
this.pcmData = newBuffer
|
|
363
|
+
}
|
|
364
|
+
|
|
252
365
|
/**
|
|
253
366
|
* Initializes the feature extractor worker for audio analysis
|
|
254
367
|
* Creates an inline worker from a blob for audio feature extraction
|
|
@@ -265,6 +378,18 @@ export class WebRecorder {
|
|
|
265
378
|
this.featureExtractorWorker.onerror = (error) => {
|
|
266
379
|
console.error(`[${TAG}] Feature extractor worker error:`, error)
|
|
267
380
|
}
|
|
381
|
+
|
|
382
|
+
// Initialize worker with counter if needed
|
|
383
|
+
if (this.dataPointIdCounter > 0) {
|
|
384
|
+
this.featureExtractorWorker.postMessage({
|
|
385
|
+
command: 'resetCounter',
|
|
386
|
+
value: this.dataPointIdCounter,
|
|
387
|
+
})
|
|
388
|
+
this.logger?.debug(
|
|
389
|
+
`Initialized worker with counter value ${this.dataPointIdCounter}`
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
268
393
|
this.logger?.log(
|
|
269
394
|
'Feature extractor worker initialized successfully'
|
|
270
395
|
)
|
|
@@ -285,42 +410,51 @@ export class WebRecorder {
|
|
|
285
410
|
if (event.data.command === 'features') {
|
|
286
411
|
const segmentResult = event.data.result
|
|
287
412
|
|
|
288
|
-
//
|
|
413
|
+
// Track existing IDs to prevent duplicates
|
|
414
|
+
const existingIds = new Set(
|
|
415
|
+
this.audioAnalysisData.dataPoints.map((dp) => dp.id)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
// Filter out datapoints with duplicate IDs
|
|
419
|
+
const uniqueNewDataPoints = segmentResult.dataPoints.filter(
|
|
420
|
+
(dp) => {
|
|
421
|
+
return !existingIds.has(dp.id)
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
// Log filtered duplicates if any
|
|
289
426
|
if (
|
|
290
|
-
segmentResult.dataPoints &&
|
|
291
|
-
|
|
427
|
+
uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
|
|
428
|
+
this.logger?.warn
|
|
292
429
|
) {
|
|
430
|
+
this.logger.warn(
|
|
431
|
+
`Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Update counter based on the highest ID seen
|
|
436
|
+
if (uniqueNewDataPoints.length > 0) {
|
|
293
437
|
const lastDataPoint =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
]
|
|
438
|
+
uniqueNewDataPoints[uniqueNewDataPoints.length - 1]
|
|
439
|
+
|
|
297
440
|
if (lastDataPoint && typeof lastDataPoint.id === 'number') {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
441
|
+
const nextIdValue = lastDataPoint.id + 1
|
|
442
|
+
|
|
443
|
+
if (nextIdValue > this.dataPointIdCounter) {
|
|
444
|
+
this.dataPointIdCounter = nextIdValue
|
|
445
|
+
this.logger?.debug(
|
|
446
|
+
`Counter updated to ${this.dataPointIdCounter}`
|
|
447
|
+
)
|
|
448
|
+
}
|
|
302
449
|
}
|
|
303
450
|
}
|
|
304
451
|
|
|
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)
|
|
452
|
+
// Add unique data points to our analysis data
|
|
453
|
+
this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints)
|
|
318
454
|
this.audioAnalysisData.durationMs += segmentResult.durationMs
|
|
319
|
-
|
|
320
|
-
// Make sure the sample rate is consistent
|
|
321
455
|
this.audioAnalysisData.sampleRate = segmentResult.sampleRate
|
|
322
456
|
|
|
323
|
-
//
|
|
457
|
+
// Merge amplitude ranges
|
|
324
458
|
if (segmentResult.amplitudeRange) {
|
|
325
459
|
if (!this.audioAnalysisData.amplitudeRange) {
|
|
326
460
|
this.audioAnalysisData.amplitudeRange = {
|
|
@@ -340,7 +474,7 @@ export class WebRecorder {
|
|
|
340
474
|
}
|
|
341
475
|
}
|
|
342
476
|
|
|
343
|
-
//
|
|
477
|
+
// Merge RMS ranges
|
|
344
478
|
if (segmentResult.rmsRange) {
|
|
345
479
|
if (!this.audioAnalysisData.rmsRange) {
|
|
346
480
|
this.audioAnalysisData.rmsRange = {
|
|
@@ -360,77 +494,182 @@ export class WebRecorder {
|
|
|
360
494
|
}
|
|
361
495
|
}
|
|
362
496
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
this.emitAudioAnalysisCallback(segmentResult)
|
|
497
|
+
// Send filtered result to avoid duplicate IDs
|
|
498
|
+
const filteredSegmentResult = {
|
|
499
|
+
...segmentResult,
|
|
500
|
+
dataPoints: uniqueNewDataPoints,
|
|
501
|
+
}
|
|
369
502
|
|
|
370
|
-
this.
|
|
371
|
-
dataPointsLength: this.audioAnalysisData.dataPoints.length,
|
|
372
|
-
durationMs: this.audioAnalysisData.durationMs,
|
|
373
|
-
sampleRate: this.audioAnalysisData.sampleRate,
|
|
374
|
-
amplitudeRange: this.audioAnalysisData.amplitudeRange,
|
|
375
|
-
})
|
|
503
|
+
this.emitAudioAnalysisCallback(filteredSegmentResult)
|
|
376
504
|
}
|
|
377
505
|
}
|
|
378
506
|
|
|
379
507
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
508
|
+
* Reset the data point counter to a specific value or zero
|
|
509
|
+
* @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
|
|
382
510
|
*/
|
|
383
|
-
resetDataPointCounter() {
|
|
384
|
-
|
|
511
|
+
resetDataPointCounter(startCounterFrom?: number): void {
|
|
512
|
+
// Set the counter with the passed value or 0
|
|
513
|
+
this.dataPointIdCounter =
|
|
514
|
+
startCounterFrom !== undefined ? startCounterFrom : 0
|
|
515
|
+
this.logger?.debug(
|
|
516
|
+
`Reset data point counter to ${this.dataPointIdCounter}`
|
|
517
|
+
)
|
|
385
518
|
|
|
386
|
-
//
|
|
519
|
+
// Update worker counter if available
|
|
387
520
|
if (this.featureExtractorWorker) {
|
|
388
521
|
this.featureExtractorWorker.postMessage({
|
|
389
522
|
command: 'resetCounter',
|
|
390
|
-
|
|
523
|
+
value: this.dataPointIdCounter,
|
|
391
524
|
})
|
|
525
|
+
} else {
|
|
526
|
+
this.logger?.warn(
|
|
527
|
+
'No feature extractor worker available to update counter'
|
|
528
|
+
)
|
|
392
529
|
}
|
|
393
530
|
}
|
|
394
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Get the current data point counter value
|
|
534
|
+
* @returns The current value of the data point counter
|
|
535
|
+
*/
|
|
536
|
+
getDataPointCounter(): number {
|
|
537
|
+
return this.dataPointIdCounter
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Prepares the recorder for continuity after device switch
|
|
542
|
+
* Sets up all necessary state to maintain proper recording continuity
|
|
543
|
+
*/
|
|
544
|
+
prepareForDeviceSwitch(): void {
|
|
545
|
+
this.isFirstChunkAfterSwitch = true
|
|
546
|
+
this.logger?.debug(
|
|
547
|
+
`Prepared for device switch at position ${this.position}s`
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
|
|
395
551
|
/**
|
|
396
552
|
* Starts the audio recording process
|
|
397
553
|
* Connects the audio nodes and begins capturing audio data
|
|
554
|
+
* @param preserveCounters If true, do not reset the counter (used for device switching)
|
|
398
555
|
*/
|
|
399
|
-
start() {
|
|
556
|
+
start(preserveCounters = false) {
|
|
400
557
|
this.source.connect(this.audioWorkletNode)
|
|
401
558
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
402
|
-
this.packetCount = 0
|
|
403
559
|
|
|
404
|
-
//
|
|
405
|
-
|
|
560
|
+
// Only reset the counter when not preserving state (e.g., for a fresh recording)
|
|
561
|
+
if (!preserveCounters) {
|
|
562
|
+
this.logger?.debug(
|
|
563
|
+
'Starting fresh recording, resetting counter to 0'
|
|
564
|
+
)
|
|
565
|
+
this.resetDataPointCounter(0) // Explicitly reset to 0 for new recordings
|
|
566
|
+
this.isFirstChunkAfterSwitch = false
|
|
567
|
+
|
|
568
|
+
// Clear PCM data for new recording
|
|
569
|
+
this.pcmData = null
|
|
570
|
+
this.totalSampleCount = 0
|
|
571
|
+
} else {
|
|
572
|
+
this.logger?.debug(
|
|
573
|
+
`Preserving counter at ${this.dataPointIdCounter} during device switch`
|
|
574
|
+
)
|
|
575
|
+
}
|
|
406
576
|
|
|
407
577
|
if (this.compressedMediaRecorder) {
|
|
408
578
|
this.compressedMediaRecorder.start(this.config.interval ?? 1000)
|
|
409
579
|
}
|
|
410
580
|
}
|
|
411
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Creates a WAV file from the stored PCM data
|
|
584
|
+
*/
|
|
585
|
+
private createWavFromPcmData(): Blob | null {
|
|
586
|
+
try {
|
|
587
|
+
// Check if we have PCM data
|
|
588
|
+
if (!this.pcmData || this.pcmData.length === 0) {
|
|
589
|
+
this.logger?.warn('No PCM data available to create WAV file')
|
|
590
|
+
return null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const sampleRate =
|
|
594
|
+
this.config.sampleRate || this.audioContext.sampleRate
|
|
595
|
+
const channels = this.numberOfChannels || 1
|
|
596
|
+
|
|
597
|
+
// Convert float32 PCM data to 16-bit PCM for WAV
|
|
598
|
+
const bytesPerSample = 2 // 16-bit = 2 bytes
|
|
599
|
+
const dataLength = this.pcmData.length * bytesPerSample
|
|
600
|
+
const buffer = new ArrayBuffer(dataLength)
|
|
601
|
+
const view = new DataView(buffer)
|
|
602
|
+
|
|
603
|
+
// Convert Float32Array (-1 to 1) to Int16Array (-32768 to 32767)
|
|
604
|
+
for (let i = 0; i < this.pcmData.length; i++) {
|
|
605
|
+
const sample = Math.max(-1, Math.min(1, this.pcmData[i]))
|
|
606
|
+
const int16Value = Math.round(sample * 32767)
|
|
607
|
+
view.setInt16(i * 2, int16Value, true)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Use the existing writeWavHeader utility to add a WAV header
|
|
611
|
+
const wavBuffer = writeWavHeader({
|
|
612
|
+
buffer,
|
|
613
|
+
sampleRate,
|
|
614
|
+
numChannels: channels,
|
|
615
|
+
bitDepth: 16,
|
|
616
|
+
isFloat: false,
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
return new Blob([wavBuffer], { type: 'audio/wav' })
|
|
620
|
+
} catch (error) {
|
|
621
|
+
this.logger?.error('Error creating WAV file from PCM data:', error)
|
|
622
|
+
return null
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
412
626
|
/**
|
|
413
627
|
* Stops the audio recording process and returns the recorded data
|
|
414
|
-
* @returns Promise resolving to an object containing
|
|
628
|
+
* @returns Promise resolving to an object containing compressed and/or uncompressed blobs
|
|
415
629
|
*/
|
|
416
|
-
async stop(): Promise<{
|
|
630
|
+
async stop(): Promise<{ compressedBlob?: Blob; uncompressedBlob?: Blob }> {
|
|
417
631
|
try {
|
|
418
|
-
|
|
632
|
+
// Stop any compressed recording first
|
|
633
|
+
if (
|
|
634
|
+
this.compressedMediaRecorder &&
|
|
635
|
+
this.compressedMediaRecorder.state !== 'inactive'
|
|
636
|
+
) {
|
|
419
637
|
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
638
|
}
|
|
427
|
-
|
|
639
|
+
|
|
640
|
+
// Wait for any pending compressed chunks to be processed
|
|
641
|
+
if (this.compressedMediaRecorder) {
|
|
642
|
+
// Small delay to ensure all data is processed
|
|
643
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Create uncompressed WAV file from the PCM data
|
|
647
|
+
let uncompressedBlob: Blob | undefined
|
|
648
|
+
|
|
649
|
+
// Only create WAV if we have PCM data
|
|
650
|
+
if (this.pcmData && this.pcmData.length > 0) {
|
|
651
|
+
uncompressedBlob =
|
|
652
|
+
(await this.createWavFromPcmData()) || undefined
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Return the compressed and/or uncompressed blobs if available
|
|
656
|
+
return {
|
|
657
|
+
compressedBlob:
|
|
658
|
+
this.compressedChunks.length > 0
|
|
659
|
+
? new Blob(this.compressedChunks, {
|
|
660
|
+
type: 'audio/webm;codecs=opus',
|
|
661
|
+
})
|
|
662
|
+
: undefined,
|
|
663
|
+
uncompressedBlob,
|
|
664
|
+
}
|
|
428
665
|
} finally {
|
|
429
666
|
this.cleanup()
|
|
430
667
|
// Reset the chunks array
|
|
431
668
|
this.compressedChunks = []
|
|
432
669
|
this.compressedSize = 0
|
|
433
670
|
this.pendingCompressedChunk = null
|
|
671
|
+
this.pcmData = null
|
|
672
|
+
this.totalSampleCount = 0
|
|
434
673
|
}
|
|
435
674
|
}
|
|
436
675
|
|
|
@@ -438,17 +677,45 @@ export class WebRecorder {
|
|
|
438
677
|
* Cleans up resources when recording is stopped
|
|
439
678
|
* Closes audio context and disconnects nodes
|
|
440
679
|
*/
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
680
|
+
public cleanup() {
|
|
681
|
+
// Remove device disconnection handler
|
|
682
|
+
if (this.deviceDisconnectionHandler) {
|
|
683
|
+
this.deviceDisconnectionHandler()
|
|
684
|
+
this.deviceDisconnectionHandler = null
|
|
444
685
|
}
|
|
686
|
+
|
|
687
|
+
// Check if AudioContext is already closed before attempting to close it
|
|
688
|
+
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
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Safely disconnect audioWorkletNode if it exists
|
|
445
697
|
if (this.audioWorkletNode) {
|
|
446
|
-
|
|
698
|
+
try {
|
|
699
|
+
this.audioWorkletNode.disconnect()
|
|
700
|
+
} catch (e) {
|
|
701
|
+
// Ignore disconnection errors - node might be already disconnected
|
|
702
|
+
}
|
|
447
703
|
}
|
|
704
|
+
|
|
705
|
+
// Safely disconnect source if it exists
|
|
448
706
|
if (this.source) {
|
|
449
|
-
|
|
707
|
+
try {
|
|
708
|
+
this.source.disconnect()
|
|
709
|
+
} catch (e) {
|
|
710
|
+
// Ignore disconnection errors - source might be already disconnected
|
|
711
|
+
}
|
|
450
712
|
}
|
|
713
|
+
|
|
714
|
+
// Always stop media stream tracks to release hardware resources
|
|
451
715
|
this.stopMediaStreamTracks()
|
|
716
|
+
|
|
717
|
+
// Mark as disconnected to prevent future errors
|
|
718
|
+
this._isDeviceDisconnected = true
|
|
452
719
|
}
|
|
453
720
|
|
|
454
721
|
/**
|
|
@@ -456,20 +723,37 @@ export class WebRecorder {
|
|
|
456
723
|
* Disconnects audio nodes and pauses the media recorder
|
|
457
724
|
*/
|
|
458
725
|
pause() {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
726
|
+
try {
|
|
727
|
+
// Note: We're just pausing, not disconnecting the device
|
|
728
|
+
// Simply disconnect nodes temporarily without marking device as disconnected
|
|
729
|
+
this.source.disconnect(this.audioWorkletNode)
|
|
730
|
+
this.audioWorkletNode.disconnect(this.audioContext.destination)
|
|
731
|
+
this.audioWorkletNode.port.postMessage({ command: 'pause' })
|
|
732
|
+
|
|
733
|
+
if (this.compressedMediaRecorder?.state === 'recording') {
|
|
734
|
+
this.compressedMediaRecorder.pause()
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this.logger?.debug('Recording paused successfully')
|
|
738
|
+
} catch (error) {
|
|
739
|
+
this.logger?.error('Error in pause(): ', error)
|
|
740
|
+
// Already disconnected, just ignore and continue
|
|
741
|
+
}
|
|
463
742
|
}
|
|
464
743
|
|
|
465
744
|
/**
|
|
466
745
|
* Stops all media stream tracks to release hardware resources
|
|
467
746
|
* Ensures recording indicators (like microphone icon) are turned off
|
|
468
747
|
*/
|
|
469
|
-
stopMediaStreamTracks() {
|
|
748
|
+
public stopMediaStreamTracks() {
|
|
470
749
|
// Stop all audio tracks to stop the recording icon
|
|
471
|
-
|
|
472
|
-
|
|
750
|
+
if (this.mediaStream) {
|
|
751
|
+
const tracks = this.mediaStream.getTracks()
|
|
752
|
+
tracks.forEach((track) => track.stop())
|
|
753
|
+
} else if (this.source?.mediaStream) {
|
|
754
|
+
const tracks = this.source.mediaStream.getTracks()
|
|
755
|
+
tracks.forEach((track) => track.stop())
|
|
756
|
+
}
|
|
473
757
|
}
|
|
474
758
|
|
|
475
759
|
/**
|
|
@@ -502,10 +786,20 @@ export class WebRecorder {
|
|
|
502
786
|
* Reconnects audio nodes and resumes the media recorder
|
|
503
787
|
*/
|
|
504
788
|
resume() {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
789
|
+
// If device was disconnected, we can't resume
|
|
790
|
+
if (this._isDeviceDisconnected) {
|
|
791
|
+
this.logger?.warn('Cannot resume recording: device disconnected')
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
this.source.connect(this.audioWorkletNode)
|
|
797
|
+
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
798
|
+
this.audioWorkletNode.port.postMessage({ command: 'resume' })
|
|
799
|
+
this.compressedMediaRecorder?.resume()
|
|
800
|
+
} catch (error) {
|
|
801
|
+
this.logger?.error('Error in resume(): ', error)
|
|
802
|
+
}
|
|
509
803
|
}
|
|
510
804
|
|
|
511
805
|
/**
|
|
@@ -573,8 +867,104 @@ export class WebRecorder {
|
|
|
573
867
|
startPosition,
|
|
574
868
|
endPosition,
|
|
575
869
|
samples,
|
|
576
|
-
startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
|
|
577
870
|
})
|
|
578
871
|
}
|
|
579
872
|
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Sets up detection for device disconnection events
|
|
876
|
+
*/
|
|
877
|
+
private setupDeviceDisconnectionDetection() {
|
|
878
|
+
if (!this.mediaStream) return
|
|
879
|
+
|
|
880
|
+
// Function to handle track ending (which happens on device disconnection)
|
|
881
|
+
const handleTrackEnded = () => {
|
|
882
|
+
this.logger?.warn('Audio track ended - device disconnected')
|
|
883
|
+
this._isDeviceDisconnected = true
|
|
884
|
+
|
|
885
|
+
// Use the callback to notify parent component about device disconnection
|
|
886
|
+
if (this.onInterruptionCallback) {
|
|
887
|
+
this.onInterruptionCallback({
|
|
888
|
+
reason: 'deviceDisconnected',
|
|
889
|
+
isPaused: true,
|
|
890
|
+
timestamp: Date.now(),
|
|
891
|
+
})
|
|
892
|
+
this.logger?.debug('Notified about device disconnection')
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Ensure we disconnect nodes to prevent zombie recordings
|
|
896
|
+
if (this.audioWorkletNode) {
|
|
897
|
+
this.audioWorkletNode.port.postMessage({
|
|
898
|
+
command: 'deviceDisconnected',
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
try {
|
|
902
|
+
this.source.disconnect(this.audioWorkletNode)
|
|
903
|
+
this.audioWorkletNode.disconnect()
|
|
904
|
+
} catch (e) {
|
|
905
|
+
// Ignore disconnection errors as the track might already be gone
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Add listeners to all audio tracks
|
|
911
|
+
const tracks = this.mediaStream.getAudioTracks()
|
|
912
|
+
tracks.forEach((track) => {
|
|
913
|
+
track.addEventListener('ended', handleTrackEnded)
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
// Store the handler for cleanup
|
|
917
|
+
this.deviceDisconnectionHandler = () => {
|
|
918
|
+
tracks.forEach((track) => {
|
|
919
|
+
track.removeEventListener('ended', handleTrackEnded)
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Explicitly set the position for continuous recording across device switches
|
|
926
|
+
* @param position The position in seconds to continue from
|
|
927
|
+
*/
|
|
928
|
+
setPosition(position: number): void {
|
|
929
|
+
if (position >= 0) {
|
|
930
|
+
this.position = position
|
|
931
|
+
this.logger?.debug(`Position explicitly set to ${position} seconds`)
|
|
932
|
+
} else {
|
|
933
|
+
this.logger?.warn(`Invalid position value: ${position}, ignoring`)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Get the current position in seconds
|
|
939
|
+
* @returns The current position
|
|
940
|
+
*/
|
|
941
|
+
getPosition(): number {
|
|
942
|
+
return this.position
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Gets the current compressed chunks
|
|
947
|
+
* @returns Array of current compressed audio chunks
|
|
948
|
+
*/
|
|
949
|
+
getCompressedChunks(): Blob[] {
|
|
950
|
+
return [...this.compressedChunks]
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Sets the compressed chunks from a previous recorder
|
|
955
|
+
* @param chunks Array of compressed chunks from a previous recorder
|
|
956
|
+
*/
|
|
957
|
+
setCompressedChunks(chunks: Blob[]): void {
|
|
958
|
+
if (chunks && chunks.length > 0) {
|
|
959
|
+
this.logger?.debug(
|
|
960
|
+
`Adding ${chunks.length} compressed chunks from previous device`
|
|
961
|
+
)
|
|
962
|
+
this.compressedChunks = [...chunks, ...this.compressedChunks]
|
|
963
|
+
// Update size
|
|
964
|
+
this.compressedSize = this.compressedChunks.reduce(
|
|
965
|
+
(size, chunk) => size + chunk.size,
|
|
966
|
+
0
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
580
970
|
}
|