@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/build/WebRecorder.web.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// packages/expo-audio-stream/src/WebRecorder.web.ts
|
|
2
2
|
import { encodingToBitDepth } from './utils/encodingToBitDepth';
|
|
3
|
+
import { writeWavHeader } from './utils/writeWavHeader';
|
|
3
4
|
import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web';
|
|
4
5
|
import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web';
|
|
5
6
|
const DEFAULT_WEB_BITDEPTH = 32;
|
|
@@ -20,14 +21,29 @@ export class WebRecorder {
|
|
|
20
21
|
bitDepth; // Bit depth of the audio
|
|
21
22
|
exportBitDepth; // Bit depth of the audio
|
|
22
23
|
audioAnalysisData; // Keep updating the full audio analysis data with latest events
|
|
23
|
-
packetCount = 0;
|
|
24
24
|
logger;
|
|
25
25
|
compressedMediaRecorder = null;
|
|
26
26
|
compressedChunks = [];
|
|
27
27
|
compressedSize = 0;
|
|
28
28
|
pendingCompressedChunk = null;
|
|
29
|
-
wavMimeType = 'audio/wav';
|
|
30
29
|
dataPointIdCounter = 0; // Add this property to track the counter
|
|
30
|
+
deviceDisconnectionHandler = null;
|
|
31
|
+
mediaStream = null;
|
|
32
|
+
onInterruptionCallback;
|
|
33
|
+
_isDeviceDisconnected = false;
|
|
34
|
+
pcmData = null; // Store original PCM data
|
|
35
|
+
totalSampleCount = 0;
|
|
36
|
+
/**
|
|
37
|
+
* Flag to indicate whether this is the first audio chunk after a device switch
|
|
38
|
+
* Used to maintain proper duration counting
|
|
39
|
+
*/
|
|
40
|
+
isFirstChunkAfterSwitch = false;
|
|
41
|
+
/**
|
|
42
|
+
* Gets whether the recording device has been disconnected
|
|
43
|
+
*/
|
|
44
|
+
get isDeviceDisconnected() {
|
|
45
|
+
return this._isDeviceDisconnected;
|
|
46
|
+
}
|
|
31
47
|
/**
|
|
32
48
|
* Initializes a new WebRecorder instance for audio recording and processing
|
|
33
49
|
* @param audioContext - The AudioContext to use for recording
|
|
@@ -35,9 +51,10 @@ export class WebRecorder {
|
|
|
35
51
|
* @param recordingConfig - Configuration options for the recording
|
|
36
52
|
* @param emitAudioEventCallback - Callback function for audio data events
|
|
37
53
|
* @param emitAudioAnalysisCallback - Callback function for audio analysis events
|
|
54
|
+
* @param onInterruption - Callback for recording interruptions
|
|
38
55
|
* @param logger - Optional logger for debugging information
|
|
39
56
|
*/
|
|
40
|
-
constructor({ audioContext, source, recordingConfig, emitAudioEventCallback, emitAudioAnalysisCallback, logger, }) {
|
|
57
|
+
constructor({ audioContext, source, recordingConfig, emitAudioEventCallback, emitAudioAnalysisCallback, onInterruption, logger, }) {
|
|
41
58
|
this.audioContext = audioContext;
|
|
42
59
|
this.source = source;
|
|
43
60
|
this.emitAudioEventCallback = emitAudioEventCallback;
|
|
@@ -80,6 +97,10 @@ export class WebRecorder {
|
|
|
80
97
|
if (recordingConfig.compression?.enabled) {
|
|
81
98
|
this.initializeCompressedRecorder();
|
|
82
99
|
}
|
|
100
|
+
this.mediaStream = source.mediaStream;
|
|
101
|
+
this.onInterruptionCallback = onInterruption;
|
|
102
|
+
// Setup device disconnection detection
|
|
103
|
+
this.setupDeviceDisconnectionDetection();
|
|
83
104
|
}
|
|
84
105
|
/**
|
|
85
106
|
* Initializes the audio worklet using an inline script
|
|
@@ -96,6 +117,10 @@ export class WebRecorder {
|
|
|
96
117
|
this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor');
|
|
97
118
|
this.audioWorkletNode.port.onmessage = async (event) => {
|
|
98
119
|
const command = event.data.command;
|
|
120
|
+
if (command === 'debug') {
|
|
121
|
+
this.logger?.debug(`[AudioWorklet] ${event.data.message}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
99
124
|
if (command !== 'newData')
|
|
100
125
|
return;
|
|
101
126
|
const pcmBufferFloat = event.data.recordedData;
|
|
@@ -104,15 +129,21 @@ export class WebRecorder {
|
|
|
104
129
|
return;
|
|
105
130
|
}
|
|
106
131
|
// Process data in smaller chunks and emit immediately
|
|
107
|
-
const chunkSize = this.audioContext.sampleRate * 2; // Reduce to 2 seconds chunks
|
|
108
132
|
const sampleRate = event.data.sampleRate ?? this.audioContext.sampleRate;
|
|
133
|
+
// Use chunk size from config interval or default to 2 seconds
|
|
134
|
+
const intervalMs = this.config.interval ?? DEFAULT_WEB_INTERVAL;
|
|
135
|
+
const chunkSize = Math.floor(sampleRate * (intervalMs / 1000));
|
|
109
136
|
const duration = pcmBufferFloat.length / sampleRate;
|
|
137
|
+
// Use incoming position if provided by worklet, otherwise use our tracked position
|
|
138
|
+
const incomingPosition = typeof event.data.position === 'number'
|
|
139
|
+
? event.data.position
|
|
140
|
+
: this.position;
|
|
110
141
|
// Calculate bytes per sample based on bit depth
|
|
111
142
|
const bytesPerSample = this.bitDepth / 8;
|
|
112
143
|
// Emit chunks without storing them
|
|
113
144
|
for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
|
|
114
145
|
const chunk = pcmBufferFloat.slice(i, i + chunkSize);
|
|
115
|
-
const chunkPosition =
|
|
146
|
+
const chunkPosition = incomingPosition + i / sampleRate;
|
|
116
147
|
// Calculate byte positions and samples
|
|
117
148
|
const startPosition = Math.floor(i * bytesPerSample);
|
|
118
149
|
const endPosition = Math.floor((i + chunk.length) * bytesPerSample);
|
|
@@ -136,6 +167,14 @@ export class WebRecorder {
|
|
|
136
167
|
samples,
|
|
137
168
|
});
|
|
138
169
|
}
|
|
170
|
+
// Only store PCM data if web.storeUncompressedAudio is not explicitly false
|
|
171
|
+
const shouldStoreUncompressed = this.config.web?.storeUncompressedAudio !== false;
|
|
172
|
+
// Store PCM chunks when needed
|
|
173
|
+
if (shouldStoreUncompressed) {
|
|
174
|
+
// Store the original Float32Array data for later WAV creation
|
|
175
|
+
this.appendPcmData(chunk);
|
|
176
|
+
this.totalSampleCount += chunk.length;
|
|
177
|
+
}
|
|
139
178
|
// Emit chunk immediately
|
|
140
179
|
this.emitAudioEventCallback({
|
|
141
180
|
data: chunk,
|
|
@@ -153,19 +192,43 @@ export class WebRecorder {
|
|
|
153
192
|
: undefined,
|
|
154
193
|
});
|
|
155
194
|
}
|
|
156
|
-
|
|
195
|
+
// Update our position based on the worklet's position if provided
|
|
196
|
+
this.position = incomingPosition + duration;
|
|
157
197
|
this.pendingCompressedChunk = null;
|
|
158
198
|
};
|
|
159
|
-
|
|
199
|
+
// Ensure we use all relevant settings from config
|
|
200
|
+
const recordSampleRate = this.audioContext.sampleRate;
|
|
201
|
+
const exportSampleRate = this.config.sampleRate ?? this.audioContext.sampleRate;
|
|
202
|
+
const channels = this.config.channels ?? this.numberOfChannels;
|
|
203
|
+
const interval = this.config.interval ?? DEFAULT_WEB_INTERVAL;
|
|
204
|
+
this.logger?.debug(`WebRecorder initialized with config:`, {
|
|
205
|
+
recordSampleRate,
|
|
206
|
+
exportSampleRate,
|
|
207
|
+
bitDepth: this.bitDepth,
|
|
208
|
+
exportBitDepth: this.exportBitDepth,
|
|
209
|
+
channels,
|
|
210
|
+
interval,
|
|
211
|
+
position: this.position,
|
|
212
|
+
deviceId: this.config.deviceId || 'default',
|
|
213
|
+
compression: this.config.compression
|
|
214
|
+
? {
|
|
215
|
+
enabled: this.config.compression.enabled,
|
|
216
|
+
format: this.config.compression.format,
|
|
217
|
+
bitrate: this.config.compression.bitrate,
|
|
218
|
+
}
|
|
219
|
+
: 'disabled',
|
|
220
|
+
});
|
|
221
|
+
// Initialize the worklet with all settings from config
|
|
160
222
|
this.audioWorkletNode.port.postMessage({
|
|
161
223
|
command: 'init',
|
|
162
|
-
recordSampleRate
|
|
163
|
-
exportSampleRate
|
|
224
|
+
recordSampleRate,
|
|
225
|
+
exportSampleRate,
|
|
164
226
|
bitDepth: this.bitDepth,
|
|
165
227
|
exportBitDepth: this.exportBitDepth,
|
|
166
|
-
channels
|
|
167
|
-
interval
|
|
168
|
-
|
|
228
|
+
channels,
|
|
229
|
+
interval,
|
|
230
|
+
position: this.position, // Pass the current position to the processor
|
|
231
|
+
enableLogging: true,
|
|
169
232
|
});
|
|
170
233
|
// Connect the source to the AudioWorkletNode and start recording
|
|
171
234
|
this.source.connect(this.audioWorkletNode);
|
|
@@ -175,6 +238,27 @@ export class WebRecorder {
|
|
|
175
238
|
console.error(`[${TAG}] Failed to initialize WebRecorder`, error);
|
|
176
239
|
}
|
|
177
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Append new PCM data to the existing buffer
|
|
243
|
+
* @param newData New Float32Array data to append
|
|
244
|
+
*/
|
|
245
|
+
appendPcmData(newData) {
|
|
246
|
+
// Clone the incoming data to ensure it's not modified
|
|
247
|
+
const dataToAdd = new Float32Array(newData);
|
|
248
|
+
if (!this.pcmData) {
|
|
249
|
+
// First chunk - create a copy to avoid references to original data
|
|
250
|
+
this.pcmData = new Float32Array(dataToAdd);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Create a new buffer with increased size
|
|
254
|
+
const newBuffer = new Float32Array(this.pcmData.length + dataToAdd.length);
|
|
255
|
+
// Copy existing data
|
|
256
|
+
newBuffer.set(this.pcmData);
|
|
257
|
+
// Append new data
|
|
258
|
+
newBuffer.set(dataToAdd, this.pcmData.length);
|
|
259
|
+
// Replace existing buffer
|
|
260
|
+
this.pcmData = newBuffer;
|
|
261
|
+
}
|
|
178
262
|
/**
|
|
179
263
|
* Initializes the feature extractor worker for audio analysis
|
|
180
264
|
* Creates an inline worker from a blob for audio feature extraction
|
|
@@ -191,6 +275,14 @@ export class WebRecorder {
|
|
|
191
275
|
this.featureExtractorWorker.onerror = (error) => {
|
|
192
276
|
console.error(`[${TAG}] Feature extractor worker error:`, error);
|
|
193
277
|
};
|
|
278
|
+
// Initialize worker with counter if needed
|
|
279
|
+
if (this.dataPointIdCounter > 0) {
|
|
280
|
+
this.featureExtractorWorker.postMessage({
|
|
281
|
+
command: 'resetCounter',
|
|
282
|
+
value: this.dataPointIdCounter,
|
|
283
|
+
});
|
|
284
|
+
this.logger?.debug(`Initialized worker with counter value ${this.dataPointIdCounter}`);
|
|
285
|
+
}
|
|
194
286
|
this.logger?.log('Feature extractor worker initialized successfully');
|
|
195
287
|
}
|
|
196
288
|
catch (error) {
|
|
@@ -205,29 +297,33 @@ export class WebRecorder {
|
|
|
205
297
|
handleFeatureExtractorMessage(event) {
|
|
206
298
|
if (event.data.command === 'features') {
|
|
207
299
|
const segmentResult = event.data.result;
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
300
|
+
// Track existing IDs to prevent duplicates
|
|
301
|
+
const existingIds = new Set(this.audioAnalysisData.dataPoints.map((dp) => dp.id));
|
|
302
|
+
// Filter out datapoints with duplicate IDs
|
|
303
|
+
const uniqueNewDataPoints = segmentResult.dataPoints.filter((dp) => {
|
|
304
|
+
return !existingIds.has(dp.id);
|
|
305
|
+
});
|
|
306
|
+
// Log filtered duplicates if any
|
|
307
|
+
if (uniqueNewDataPoints.length < segmentResult.dataPoints.length &&
|
|
308
|
+
this.logger?.warn) {
|
|
309
|
+
this.logger.warn(`Filtered ${segmentResult.dataPoints.length - uniqueNewDataPoints.length} duplicate datapoints`);
|
|
310
|
+
}
|
|
311
|
+
// Update counter based on the highest ID seen
|
|
312
|
+
if (uniqueNewDataPoints.length > 0) {
|
|
313
|
+
const lastDataPoint = uniqueNewDataPoints[uniqueNewDataPoints.length - 1];
|
|
212
314
|
if (lastDataPoint && typeof lastDataPoint.id === 'number') {
|
|
213
|
-
|
|
315
|
+
const nextIdValue = lastDataPoint.id + 1;
|
|
316
|
+
if (nextIdValue > this.dataPointIdCounter) {
|
|
317
|
+
this.dataPointIdCounter = nextIdValue;
|
|
318
|
+
this.logger?.debug(`Counter updated to ${this.dataPointIdCounter}`);
|
|
319
|
+
}
|
|
214
320
|
}
|
|
215
321
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
durationMs: segmentResult.durationMs,
|
|
219
|
-
sampleRate: segmentResult.sampleRate,
|
|
220
|
-
amplitudeRange: segmentResult.amplitudeRange,
|
|
221
|
-
});
|
|
222
|
-
// Ensure consistent sample rate in the result
|
|
223
|
-
segmentResult.sampleRate =
|
|
224
|
-
this.config.sampleRate || this.audioContext.sampleRate;
|
|
225
|
-
// Update the full audio analysis data with proper range merging
|
|
226
|
-
this.audioAnalysisData.dataPoints.push(...segmentResult.dataPoints);
|
|
322
|
+
// Add unique data points to our analysis data
|
|
323
|
+
this.audioAnalysisData.dataPoints.push(...uniqueNewDataPoints);
|
|
227
324
|
this.audioAnalysisData.durationMs += segmentResult.durationMs;
|
|
228
|
-
// Make sure the sample rate is consistent
|
|
229
325
|
this.audioAnalysisData.sampleRate = segmentResult.sampleRate;
|
|
230
|
-
//
|
|
326
|
+
// Merge amplitude ranges
|
|
231
327
|
if (segmentResult.amplitudeRange) {
|
|
232
328
|
if (!this.audioAnalysisData.amplitudeRange) {
|
|
233
329
|
this.audioAnalysisData.amplitudeRange = {
|
|
@@ -241,7 +337,7 @@ export class WebRecorder {
|
|
|
241
337
|
};
|
|
242
338
|
}
|
|
243
339
|
}
|
|
244
|
-
//
|
|
340
|
+
// Merge RMS ranges
|
|
245
341
|
if (segmentResult.rmsRange) {
|
|
246
342
|
if (!this.audioAnalysisData.rmsRange) {
|
|
247
343
|
this.audioAnalysisData.rmsRange = {
|
|
@@ -255,61 +351,143 @@ export class WebRecorder {
|
|
|
255
351
|
};
|
|
256
352
|
}
|
|
257
353
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
sampleRate: this.audioAnalysisData.sampleRate,
|
|
265
|
-
amplitudeRange: this.audioAnalysisData.amplitudeRange,
|
|
266
|
-
});
|
|
354
|
+
// Send filtered result to avoid duplicate IDs
|
|
355
|
+
const filteredSegmentResult = {
|
|
356
|
+
...segmentResult,
|
|
357
|
+
dataPoints: uniqueNewDataPoints,
|
|
358
|
+
};
|
|
359
|
+
this.emitAudioAnalysisCallback(filteredSegmentResult);
|
|
267
360
|
}
|
|
268
361
|
}
|
|
269
362
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
363
|
+
* Reset the data point counter to a specific value or zero
|
|
364
|
+
* @param startCounterFrom Optional value to start the counter from (for continuing from previous recordings)
|
|
272
365
|
*/
|
|
273
|
-
resetDataPointCounter() {
|
|
274
|
-
|
|
275
|
-
|
|
366
|
+
resetDataPointCounter(startCounterFrom) {
|
|
367
|
+
// Set the counter with the passed value or 0
|
|
368
|
+
this.dataPointIdCounter =
|
|
369
|
+
startCounterFrom !== undefined ? startCounterFrom : 0;
|
|
370
|
+
this.logger?.debug(`Reset data point counter to ${this.dataPointIdCounter}`);
|
|
371
|
+
// Update worker counter if available
|
|
276
372
|
if (this.featureExtractorWorker) {
|
|
277
373
|
this.featureExtractorWorker.postMessage({
|
|
278
374
|
command: 'resetCounter',
|
|
279
|
-
|
|
375
|
+
value: this.dataPointIdCounter,
|
|
280
376
|
});
|
|
281
377
|
}
|
|
378
|
+
else {
|
|
379
|
+
this.logger?.warn('No feature extractor worker available to update counter');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Get the current data point counter value
|
|
384
|
+
* @returns The current value of the data point counter
|
|
385
|
+
*/
|
|
386
|
+
getDataPointCounter() {
|
|
387
|
+
return this.dataPointIdCounter;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Prepares the recorder for continuity after device switch
|
|
391
|
+
* Sets up all necessary state to maintain proper recording continuity
|
|
392
|
+
*/
|
|
393
|
+
prepareForDeviceSwitch() {
|
|
394
|
+
this.isFirstChunkAfterSwitch = true;
|
|
395
|
+
this.logger?.debug(`Prepared for device switch at position ${this.position}s`);
|
|
282
396
|
}
|
|
283
397
|
/**
|
|
284
398
|
* Starts the audio recording process
|
|
285
399
|
* Connects the audio nodes and begins capturing audio data
|
|
400
|
+
* @param preserveCounters If true, do not reset the counter (used for device switching)
|
|
286
401
|
*/
|
|
287
|
-
start() {
|
|
402
|
+
start(preserveCounters = false) {
|
|
288
403
|
this.source.connect(this.audioWorkletNode);
|
|
289
404
|
this.audioWorkletNode.connect(this.audioContext.destination);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
405
|
+
// Only reset the counter when not preserving state (e.g., for a fresh recording)
|
|
406
|
+
if (!preserveCounters) {
|
|
407
|
+
this.logger?.debug('Starting fresh recording, resetting counter to 0');
|
|
408
|
+
this.resetDataPointCounter(0); // Explicitly reset to 0 for new recordings
|
|
409
|
+
this.isFirstChunkAfterSwitch = false;
|
|
410
|
+
// Clear PCM data for new recording
|
|
411
|
+
this.pcmData = null;
|
|
412
|
+
this.totalSampleCount = 0;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
this.logger?.debug(`Preserving counter at ${this.dataPointIdCounter} during device switch`);
|
|
416
|
+
}
|
|
293
417
|
if (this.compressedMediaRecorder) {
|
|
294
418
|
this.compressedMediaRecorder.start(this.config.interval ?? 1000);
|
|
295
419
|
}
|
|
296
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Creates a WAV file from the stored PCM data
|
|
423
|
+
*/
|
|
424
|
+
createWavFromPcmData() {
|
|
425
|
+
try {
|
|
426
|
+
// Check if we have PCM data
|
|
427
|
+
if (!this.pcmData || this.pcmData.length === 0) {
|
|
428
|
+
this.logger?.warn('No PCM data available to create WAV file');
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const sampleRate = this.config.sampleRate || this.audioContext.sampleRate;
|
|
432
|
+
const channels = this.numberOfChannels || 1;
|
|
433
|
+
// Convert float32 PCM data to 16-bit PCM for WAV
|
|
434
|
+
const bytesPerSample = 2; // 16-bit = 2 bytes
|
|
435
|
+
const dataLength = this.pcmData.length * bytesPerSample;
|
|
436
|
+
const buffer = new ArrayBuffer(dataLength);
|
|
437
|
+
const view = new DataView(buffer);
|
|
438
|
+
// Convert Float32Array (-1 to 1) to Int16Array (-32768 to 32767)
|
|
439
|
+
for (let i = 0; i < this.pcmData.length; i++) {
|
|
440
|
+
const sample = Math.max(-1, Math.min(1, this.pcmData[i]));
|
|
441
|
+
const int16Value = Math.round(sample * 32767);
|
|
442
|
+
view.setInt16(i * 2, int16Value, true);
|
|
443
|
+
}
|
|
444
|
+
// Use the existing writeWavHeader utility to add a WAV header
|
|
445
|
+
const wavBuffer = writeWavHeader({
|
|
446
|
+
buffer,
|
|
447
|
+
sampleRate,
|
|
448
|
+
numChannels: channels,
|
|
449
|
+
bitDepth: 16,
|
|
450
|
+
isFloat: false,
|
|
451
|
+
});
|
|
452
|
+
return new Blob([wavBuffer], { type: 'audio/wav' });
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
this.logger?.error('Error creating WAV file from PCM data:', error);
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
297
459
|
/**
|
|
298
460
|
* Stops the audio recording process and returns the recorded data
|
|
299
|
-
* @returns Promise resolving to an object containing
|
|
461
|
+
* @returns Promise resolving to an object containing compressed and/or uncompressed blobs
|
|
300
462
|
*/
|
|
301
463
|
async stop() {
|
|
302
464
|
try {
|
|
303
|
-
|
|
465
|
+
// Stop any compressed recording first
|
|
466
|
+
if (this.compressedMediaRecorder &&
|
|
467
|
+
this.compressedMediaRecorder.state !== 'inactive') {
|
|
304
468
|
this.compressedMediaRecorder.stop();
|
|
305
|
-
return {
|
|
306
|
-
pcmData: new Float32Array(), // Return empty array since we're streaming
|
|
307
|
-
compressedBlob: new Blob(this.compressedChunks, {
|
|
308
|
-
type: 'audio/webm;codecs=opus',
|
|
309
|
-
}),
|
|
310
|
-
};
|
|
311
469
|
}
|
|
312
|
-
|
|
470
|
+
// Wait for any pending compressed chunks to be processed
|
|
471
|
+
if (this.compressedMediaRecorder) {
|
|
472
|
+
// Small delay to ensure all data is processed
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
474
|
+
}
|
|
475
|
+
// Create uncompressed WAV file from the PCM data
|
|
476
|
+
let uncompressedBlob;
|
|
477
|
+
// Only create WAV if we have PCM data
|
|
478
|
+
if (this.pcmData && this.pcmData.length > 0) {
|
|
479
|
+
uncompressedBlob =
|
|
480
|
+
(await this.createWavFromPcmData()) || undefined;
|
|
481
|
+
}
|
|
482
|
+
// Return the compressed and/or uncompressed blobs if available
|
|
483
|
+
return {
|
|
484
|
+
compressedBlob: this.compressedChunks.length > 0
|
|
485
|
+
? new Blob(this.compressedChunks, {
|
|
486
|
+
type: 'audio/webm;codecs=opus',
|
|
487
|
+
})
|
|
488
|
+
: undefined,
|
|
489
|
+
uncompressedBlob,
|
|
490
|
+
};
|
|
313
491
|
}
|
|
314
492
|
finally {
|
|
315
493
|
this.cleanup();
|
|
@@ -317,6 +495,8 @@ export class WebRecorder {
|
|
|
317
495
|
this.compressedChunks = [];
|
|
318
496
|
this.compressedSize = 0;
|
|
319
497
|
this.pendingCompressedChunk = null;
|
|
498
|
+
this.pcmData = null;
|
|
499
|
+
this.totalSampleCount = 0;
|
|
320
500
|
}
|
|
321
501
|
}
|
|
322
502
|
/**
|
|
@@ -324,26 +504,63 @@ export class WebRecorder {
|
|
|
324
504
|
* Closes audio context and disconnects nodes
|
|
325
505
|
*/
|
|
326
506
|
cleanup() {
|
|
327
|
-
|
|
328
|
-
|
|
507
|
+
// Remove device disconnection handler
|
|
508
|
+
if (this.deviceDisconnectionHandler) {
|
|
509
|
+
this.deviceDisconnectionHandler();
|
|
510
|
+
this.deviceDisconnectionHandler = null;
|
|
329
511
|
}
|
|
512
|
+
// Check if AudioContext is already closed before attempting to close it
|
|
513
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
514
|
+
try {
|
|
515
|
+
this.audioContext.close();
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
// Ignore closure errors - this happens if already closed
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Safely disconnect audioWorkletNode if it exists
|
|
330
522
|
if (this.audioWorkletNode) {
|
|
331
|
-
|
|
523
|
+
try {
|
|
524
|
+
this.audioWorkletNode.disconnect();
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
// Ignore disconnection errors - node might be already disconnected
|
|
528
|
+
}
|
|
332
529
|
}
|
|
530
|
+
// Safely disconnect source if it exists
|
|
333
531
|
if (this.source) {
|
|
334
|
-
|
|
532
|
+
try {
|
|
533
|
+
this.source.disconnect();
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
// Ignore disconnection errors - source might be already disconnected
|
|
537
|
+
}
|
|
335
538
|
}
|
|
539
|
+
// Always stop media stream tracks to release hardware resources
|
|
336
540
|
this.stopMediaStreamTracks();
|
|
541
|
+
// Mark as disconnected to prevent future errors
|
|
542
|
+
this._isDeviceDisconnected = true;
|
|
337
543
|
}
|
|
338
544
|
/**
|
|
339
545
|
* Pauses the audio recording process
|
|
340
546
|
* Disconnects audio nodes and pauses the media recorder
|
|
341
547
|
*/
|
|
342
548
|
pause() {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
549
|
+
try {
|
|
550
|
+
// Note: We're just pausing, not disconnecting the device
|
|
551
|
+
// Simply disconnect nodes temporarily without marking device as disconnected
|
|
552
|
+
this.source.disconnect(this.audioWorkletNode);
|
|
553
|
+
this.audioWorkletNode.disconnect(this.audioContext.destination);
|
|
554
|
+
this.audioWorkletNode.port.postMessage({ command: 'pause' });
|
|
555
|
+
if (this.compressedMediaRecorder?.state === 'recording') {
|
|
556
|
+
this.compressedMediaRecorder.pause();
|
|
557
|
+
}
|
|
558
|
+
this.logger?.debug('Recording paused successfully');
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
this.logger?.error('Error in pause(): ', error);
|
|
562
|
+
// Already disconnected, just ignore and continue
|
|
563
|
+
}
|
|
347
564
|
}
|
|
348
565
|
/**
|
|
349
566
|
* Stops all media stream tracks to release hardware resources
|
|
@@ -351,8 +568,14 @@ export class WebRecorder {
|
|
|
351
568
|
*/
|
|
352
569
|
stopMediaStreamTracks() {
|
|
353
570
|
// Stop all audio tracks to stop the recording icon
|
|
354
|
-
|
|
355
|
-
|
|
571
|
+
if (this.mediaStream) {
|
|
572
|
+
const tracks = this.mediaStream.getTracks();
|
|
573
|
+
tracks.forEach((track) => track.stop());
|
|
574
|
+
}
|
|
575
|
+
else if (this.source?.mediaStream) {
|
|
576
|
+
const tracks = this.source.mediaStream.getTracks();
|
|
577
|
+
tracks.forEach((track) => track.stop());
|
|
578
|
+
}
|
|
356
579
|
}
|
|
357
580
|
/**
|
|
358
581
|
* Determines the audio format capabilities of the current audio context
|
|
@@ -377,10 +600,20 @@ export class WebRecorder {
|
|
|
377
600
|
* Reconnects audio nodes and resumes the media recorder
|
|
378
601
|
*/
|
|
379
602
|
resume() {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
603
|
+
// If device was disconnected, we can't resume
|
|
604
|
+
if (this._isDeviceDisconnected) {
|
|
605
|
+
this.logger?.warn('Cannot resume recording: device disconnected');
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
this.source.connect(this.audioWorkletNode);
|
|
610
|
+
this.audioWorkletNode.connect(this.audioContext.destination);
|
|
611
|
+
this.audioWorkletNode.port.postMessage({ command: 'resume' });
|
|
612
|
+
this.compressedMediaRecorder?.resume();
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
this.logger?.error('Error in resume(): ', error);
|
|
616
|
+
}
|
|
384
617
|
}
|
|
385
618
|
/**
|
|
386
619
|
* Initializes the compressed media recorder if compression is enabled
|
|
@@ -428,9 +661,92 @@ export class WebRecorder {
|
|
|
428
661
|
startPosition,
|
|
429
662
|
endPosition,
|
|
430
663
|
samples,
|
|
431
|
-
startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
|
|
432
664
|
});
|
|
433
665
|
}
|
|
434
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* Sets up detection for device disconnection events
|
|
669
|
+
*/
|
|
670
|
+
setupDeviceDisconnectionDetection() {
|
|
671
|
+
if (!this.mediaStream)
|
|
672
|
+
return;
|
|
673
|
+
// Function to handle track ending (which happens on device disconnection)
|
|
674
|
+
const handleTrackEnded = () => {
|
|
675
|
+
this.logger?.warn('Audio track ended - device disconnected');
|
|
676
|
+
this._isDeviceDisconnected = true;
|
|
677
|
+
// Use the callback to notify parent component about device disconnection
|
|
678
|
+
if (this.onInterruptionCallback) {
|
|
679
|
+
this.onInterruptionCallback({
|
|
680
|
+
reason: 'deviceDisconnected',
|
|
681
|
+
isPaused: true,
|
|
682
|
+
timestamp: Date.now(),
|
|
683
|
+
});
|
|
684
|
+
this.logger?.debug('Notified about device disconnection');
|
|
685
|
+
}
|
|
686
|
+
// Ensure we disconnect nodes to prevent zombie recordings
|
|
687
|
+
if (this.audioWorkletNode) {
|
|
688
|
+
this.audioWorkletNode.port.postMessage({
|
|
689
|
+
command: 'deviceDisconnected',
|
|
690
|
+
});
|
|
691
|
+
try {
|
|
692
|
+
this.source.disconnect(this.audioWorkletNode);
|
|
693
|
+
this.audioWorkletNode.disconnect();
|
|
694
|
+
}
|
|
695
|
+
catch (e) {
|
|
696
|
+
// Ignore disconnection errors as the track might already be gone
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
// Add listeners to all audio tracks
|
|
701
|
+
const tracks = this.mediaStream.getAudioTracks();
|
|
702
|
+
tracks.forEach((track) => {
|
|
703
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
704
|
+
});
|
|
705
|
+
// Store the handler for cleanup
|
|
706
|
+
this.deviceDisconnectionHandler = () => {
|
|
707
|
+
tracks.forEach((track) => {
|
|
708
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
709
|
+
});
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Explicitly set the position for continuous recording across device switches
|
|
714
|
+
* @param position The position in seconds to continue from
|
|
715
|
+
*/
|
|
716
|
+
setPosition(position) {
|
|
717
|
+
if (position >= 0) {
|
|
718
|
+
this.position = position;
|
|
719
|
+
this.logger?.debug(`Position explicitly set to ${position} seconds`);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
this.logger?.warn(`Invalid position value: ${position}, ignoring`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Get the current position in seconds
|
|
727
|
+
* @returns The current position
|
|
728
|
+
*/
|
|
729
|
+
getPosition() {
|
|
730
|
+
return this.position;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Gets the current compressed chunks
|
|
734
|
+
* @returns Array of current compressed audio chunks
|
|
735
|
+
*/
|
|
736
|
+
getCompressedChunks() {
|
|
737
|
+
return [...this.compressedChunks];
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Sets the compressed chunks from a previous recorder
|
|
741
|
+
* @param chunks Array of compressed chunks from a previous recorder
|
|
742
|
+
*/
|
|
743
|
+
setCompressedChunks(chunks) {
|
|
744
|
+
if (chunks && chunks.length > 0) {
|
|
745
|
+
this.logger?.debug(`Adding ${chunks.length} compressed chunks from previous device`);
|
|
746
|
+
this.compressedChunks = [...chunks, ...this.compressedChunks];
|
|
747
|
+
// Update size
|
|
748
|
+
this.compressedSize = this.compressedChunks.reduce((size, chunk) => size + chunk.size, 0);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
435
751
|
}
|
|
436
752
|
//# sourceMappingURL=WebRecorder.web.js.map
|