@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
|
@@ -27,15 +27,12 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
27
27
|
latestPosition = 0;
|
|
28
28
|
totalCompressedSize = 0;
|
|
29
29
|
maxBufferSize;
|
|
30
|
+
eventCallback;
|
|
30
31
|
constructor({ audioWorkletUrl, featuresExtratorUrl, logger, maxBufferSize = 100, // Default to storing last 100 chunks (1 chunk = 0.5 seconds)
|
|
31
32
|
}) {
|
|
32
33
|
const mockNativeModule = {
|
|
33
|
-
addListener: () => {
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
removeListeners: () => {
|
|
37
|
-
// Not used on web
|
|
38
|
-
},
|
|
34
|
+
addListener: () => { },
|
|
35
|
+
removeListeners: () => { },
|
|
39
36
|
};
|
|
40
37
|
super(mockNativeModule); // Pass the mock native module to the parent class
|
|
41
38
|
this.logger = logger;
|
|
@@ -70,14 +67,56 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
70
67
|
throw error;
|
|
71
68
|
}
|
|
72
69
|
}
|
|
70
|
+
// Prepare recording with options
|
|
71
|
+
async prepareRecording(recordingConfig = {}) {
|
|
72
|
+
if (this.isRecording) {
|
|
73
|
+
this.logger?.warn('Cannot prepare: Recording is already in progress');
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
// Check permissions and initialize basic settings
|
|
78
|
+
await this.getMediaStream().then((stream) => {
|
|
79
|
+
// Just verify we can access the microphone by getting a stream, then release it
|
|
80
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
81
|
+
});
|
|
82
|
+
this.bitDepth = encodingToBitDepth({
|
|
83
|
+
encoding: recordingConfig.encoding ?? 'pcm_32bit',
|
|
84
|
+
});
|
|
85
|
+
// Store recording configuration for later use
|
|
86
|
+
this.recordingConfig = recordingConfig;
|
|
87
|
+
// Use custom filename if provided, otherwise fallback to timestamp
|
|
88
|
+
if (recordingConfig.filename) {
|
|
89
|
+
// Remove any existing extension from the filename
|
|
90
|
+
this.streamUuid = recordingConfig.filename.replace(/\.[^/.]+$/, '');
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.streamUuid = Date.now().toString();
|
|
94
|
+
}
|
|
95
|
+
this.logger?.debug('Recording preparation completed successfully');
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
this.logger?.error('Error preparing recording:', error);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
73
103
|
// Start recording with options
|
|
74
104
|
async startRecording(recordingConfig = {}) {
|
|
75
105
|
if (this.isRecording) {
|
|
76
106
|
throw new Error('Recording is already in progress');
|
|
77
107
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
// If we haven't prepared or have different settings, prepare now
|
|
109
|
+
if (!this.recordingConfig ||
|
|
110
|
+
this.recordingConfig.sampleRate !== recordingConfig.sampleRate ||
|
|
111
|
+
this.recordingConfig.channels !== recordingConfig.channels ||
|
|
112
|
+
this.recordingConfig.encoding !== recordingConfig.encoding) {
|
|
113
|
+
await this.prepareRecording(recordingConfig);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.logger?.debug('Using previously prepared recording configuration');
|
|
117
|
+
}
|
|
118
|
+
// Save recording config for reference
|
|
119
|
+
this.recordingConfig = recordingConfig;
|
|
81
120
|
const audioContext = new (window.AudioContext ||
|
|
82
121
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
83
122
|
// @ts-ignore - Allow webkitAudioContext for Safari
|
|
@@ -89,33 +128,13 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
89
128
|
audioContext,
|
|
90
129
|
source,
|
|
91
130
|
recordingConfig,
|
|
92
|
-
emitAudioEventCallback: (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (this.audioChunks.length > this.maxBufferSize) {
|
|
96
|
-
this.audioChunks.shift(); // Remove oldest chunk
|
|
97
|
-
}
|
|
98
|
-
this.currentSize += data.byteLength;
|
|
99
|
-
this.emitAudioEvent({ data, position, compression });
|
|
100
|
-
this.lastEmittedTime = Date.now();
|
|
101
|
-
this.lastEmittedSize = this.currentSize;
|
|
102
|
-
this.lastEmittedCompressionSize = compression?.size ?? 0;
|
|
103
|
-
},
|
|
104
|
-
emitAudioAnalysisCallback: (audioAnalysisData) => {
|
|
105
|
-
this.logger?.log(`Emitted AudioAnalysis:`, audioAnalysisData);
|
|
106
|
-
this.emit('AudioAnalysis', audioAnalysisData);
|
|
107
|
-
},
|
|
131
|
+
emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
|
|
132
|
+
emitAudioAnalysisCallback: this.customRecorderAnalysisCallback.bind(this),
|
|
133
|
+
onInterruption: this.handleRecordingInterruption.bind(this),
|
|
108
134
|
});
|
|
109
135
|
await this.customRecorder.init();
|
|
110
136
|
this.customRecorder.start();
|
|
111
|
-
// // Set a timer to stop recording after 5 seconds
|
|
112
|
-
// setTimeout(() => {
|
|
113
|
-
// logger.log("AUTO Stopping recording");
|
|
114
|
-
// this.customRecorder?.stopAndPlay();
|
|
115
|
-
// this.isRecording = false;
|
|
116
|
-
// }, 3000);
|
|
117
137
|
this.isRecording = true;
|
|
118
|
-
this.recordingConfig = recordingConfig;
|
|
119
138
|
this.recordingStartTime = Date.now();
|
|
120
139
|
this.pausedTime = 0;
|
|
121
140
|
this.isPaused = false;
|
|
@@ -153,14 +172,96 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
153
172
|
};
|
|
154
173
|
return streamConfig;
|
|
155
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Centralized handler for recording interruptions
|
|
177
|
+
*/
|
|
178
|
+
handleRecordingInterruption(event) {
|
|
179
|
+
this.logger?.debug(`Received recording interruption: ${event.reason}`);
|
|
180
|
+
// Update local state if the interruption should pause recording
|
|
181
|
+
if (event.isPaused) {
|
|
182
|
+
this.isPaused = true;
|
|
183
|
+
// If this is a device disconnection, handle according to behavior setting
|
|
184
|
+
if (event.reason === 'deviceDisconnected') {
|
|
185
|
+
this.pausedTime = Date.now();
|
|
186
|
+
// Check if we should try fallback to another device
|
|
187
|
+
if (this.recordingConfig?.deviceDisconnectionBehavior ===
|
|
188
|
+
'fallback') {
|
|
189
|
+
this.logger?.debug('Device disconnected with fallback behavior - attempting to switch to default device');
|
|
190
|
+
// Try to restart with default device
|
|
191
|
+
this.handleDeviceFallback().catch((error) => {
|
|
192
|
+
// If fallback fails, emit warning
|
|
193
|
+
this.logger?.error('Device fallback failed:', error);
|
|
194
|
+
this.emit('onRecordingInterrupted', {
|
|
195
|
+
reason: 'deviceSwitchFailed',
|
|
196
|
+
isPaused: true,
|
|
197
|
+
timestamp: Date.now(),
|
|
198
|
+
message: 'Failed to switch to fallback device. Recording paused.',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// Just warn about disconnection if fallback not enabled
|
|
204
|
+
this.logger?.warn('Device disconnected - recording paused automatically');
|
|
205
|
+
this.emit('onRecordingInterrupted', event);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// For other interruption types, just emit the event
|
|
210
|
+
this.emit('onRecordingInterrupted', event);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// If not causing a pause, just forward the event
|
|
215
|
+
this.emit('onRecordingInterrupted', event);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Handler for audio events from the WebRecorder
|
|
220
|
+
*/
|
|
221
|
+
customRecorderEventCallback({ data, position, compression, }) {
|
|
222
|
+
// Keep only the latest chunks based on maxBufferSize
|
|
223
|
+
this.audioChunks.push(new Float32Array(data));
|
|
224
|
+
if (this.audioChunks.length > this.maxBufferSize) {
|
|
225
|
+
this.audioChunks.shift(); // Remove oldest chunk
|
|
226
|
+
}
|
|
227
|
+
this.currentSize += data.byteLength;
|
|
228
|
+
this.emitAudioEvent({ data, position, compression });
|
|
229
|
+
this.lastEmittedTime = Date.now();
|
|
230
|
+
this.lastEmittedSize = this.currentSize;
|
|
231
|
+
this.lastEmittedCompressionSize = compression?.size ?? 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Handler for audio analysis events from the WebRecorder
|
|
235
|
+
*/
|
|
236
|
+
customRecorderAnalysisCallback(audioAnalysisData) {
|
|
237
|
+
this.emit('AudioAnalysis', audioAnalysisData);
|
|
238
|
+
}
|
|
239
|
+
// Get recording duration
|
|
240
|
+
getRecordingDuration() {
|
|
241
|
+
if (!this.isRecording) {
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
return this.currentDurationMs;
|
|
245
|
+
}
|
|
156
246
|
emitAudioEvent({ data, position, compression }) {
|
|
157
247
|
const fileUri = `${this.streamUuid}.${this.extension}`;
|
|
158
248
|
if (compression?.size) {
|
|
159
249
|
this.lastEmittedCompressionSize = compression.size;
|
|
160
250
|
this.totalCompressedSize = compression.totalSize;
|
|
161
251
|
}
|
|
252
|
+
// Update latest position for tracking
|
|
162
253
|
this.latestPosition = position;
|
|
163
|
-
|
|
254
|
+
// Calculate duration of this chunk in ms
|
|
255
|
+
const sampleRate = this.recordingConfig?.sampleRate || 44100;
|
|
256
|
+
const chunkDurationMs = (data.length / sampleRate) * 1000;
|
|
257
|
+
// Handle duration calculation
|
|
258
|
+
if (this.customRecorder?.isFirstChunkAfterSwitch) {
|
|
259
|
+
this.logger?.debug(`Processing first chunk after device switch, duration preserved at ${this.currentDurationMs}ms`);
|
|
260
|
+
this.customRecorder.isFirstChunkAfterSwitch = false;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
this.currentDurationMs += chunkDurationMs;
|
|
264
|
+
}
|
|
164
265
|
const audioEventPayload = {
|
|
165
266
|
fileUri,
|
|
166
267
|
mimeType: `audio/${this.extension}`,
|
|
@@ -186,17 +287,15 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
186
287
|
if (!this.customRecorder) {
|
|
187
288
|
throw new Error('Recorder is not initialized');
|
|
188
289
|
}
|
|
189
|
-
this.logger?.debug('
|
|
190
|
-
const startTime = performance.now();
|
|
290
|
+
this.logger?.debug('Starting stop process');
|
|
191
291
|
try {
|
|
192
|
-
this.logger?.debug('[Stop] Stopping recorder');
|
|
193
292
|
const { compressedBlob } = await this.customRecorder.stop();
|
|
194
293
|
this.isRecording = false;
|
|
195
294
|
this.isPaused = false;
|
|
196
|
-
this.currentDurationMs = Date.now() - this.recordingStartTime;
|
|
197
295
|
let compression;
|
|
198
296
|
let fileUri = `${this.streamUuid}.${this.extension}`;
|
|
199
297
|
let mimeType = `audio/${this.extension}`;
|
|
298
|
+
// Process compressed audio if available
|
|
200
299
|
if (compressedBlob && this.recordingConfig?.compression?.enabled) {
|
|
201
300
|
const compressedUri = URL.createObjectURL(compressedBlob);
|
|
202
301
|
compression = {
|
|
@@ -210,15 +309,11 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
210
309
|
fileUri = compressedUri;
|
|
211
310
|
mimeType = 'audio/webm';
|
|
212
311
|
}
|
|
213
|
-
|
|
214
|
-
durationMs: this.currentDurationMs,
|
|
215
|
-
compressedSize: compression?.size,
|
|
216
|
-
});
|
|
217
|
-
// Use the stored streamUuid (which contains our custom filename) for the final filename
|
|
312
|
+
// Use the stored streamUuid for the final filename
|
|
218
313
|
const filename = `${this.streamUuid}.${this.extension}`;
|
|
219
314
|
const result = {
|
|
220
315
|
fileUri,
|
|
221
|
-
filename,
|
|
316
|
+
filename,
|
|
222
317
|
bitDepth: this.bitDepth,
|
|
223
318
|
createdAt: this.recordingStartTime,
|
|
224
319
|
channels: this.recordingConfig?.channels ?? 1,
|
|
@@ -233,38 +328,82 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
233
328
|
return result;
|
|
234
329
|
}
|
|
235
330
|
catch (error) {
|
|
236
|
-
this.logger?.error('
|
|
331
|
+
this.logger?.error('Error stopping recording:', error);
|
|
237
332
|
throw error;
|
|
238
333
|
}
|
|
239
334
|
}
|
|
240
335
|
// Pause recording
|
|
241
336
|
async pauseRecording() {
|
|
242
|
-
if (!this.isRecording
|
|
243
|
-
throw new Error('Recording is not active
|
|
337
|
+
if (!this.isRecording) {
|
|
338
|
+
throw new Error('Recording is not active');
|
|
339
|
+
}
|
|
340
|
+
if (this.isPaused) {
|
|
341
|
+
this.logger?.debug('Recording already paused, skipping');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
if (this.customRecorder) {
|
|
346
|
+
this.customRecorder.pause();
|
|
347
|
+
}
|
|
348
|
+
this.isPaused = true;
|
|
349
|
+
this.pausedTime = Date.now();
|
|
244
350
|
}
|
|
245
|
-
|
|
246
|
-
this.
|
|
351
|
+
catch (error) {
|
|
352
|
+
this.logger?.error('Error in pauseRecording', error);
|
|
353
|
+
// Even if the pause operation failed, make sure our state is consistent
|
|
354
|
+
this.isPaused = true;
|
|
355
|
+
this.pausedTime = Date.now();
|
|
247
356
|
}
|
|
248
|
-
this.isPaused = true;
|
|
249
|
-
this.pausedTime = Date.now();
|
|
250
357
|
}
|
|
251
358
|
// Resume recording
|
|
252
359
|
async resumeRecording() {
|
|
253
360
|
if (!this.isPaused) {
|
|
254
361
|
throw new Error('Recording is not paused');
|
|
255
362
|
}
|
|
256
|
-
|
|
363
|
+
this.logger?.debug('Resuming recording', {
|
|
364
|
+
deviceDisconnectionBehavior: this.recordingConfig?.deviceDisconnectionBehavior,
|
|
365
|
+
isDeviceDisconnected: this.customRecorder?.isDeviceDisconnected,
|
|
366
|
+
});
|
|
367
|
+
try {
|
|
368
|
+
// If we have no recorder, or if the device is disconnected, always attempt fallback
|
|
369
|
+
if (!this.customRecorder ||
|
|
370
|
+
this.customRecorder.isDeviceDisconnected) {
|
|
371
|
+
this.logger?.debug('No recorder exists or device disconnected - attempting fallback on resume');
|
|
372
|
+
await this.handleDeviceFallback();
|
|
373
|
+
// handleDeviceFallback will manage resuming if successful, or emit error if failed.
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Normal resume path - device is still connected
|
|
257
377
|
this.customRecorder.resume();
|
|
378
|
+
this.isPaused = false;
|
|
379
|
+
// Adjust the recording start time to account for the pause duration
|
|
380
|
+
const pauseDuration = Date.now() - this.pausedTime;
|
|
381
|
+
this.recordingStartTime += pauseDuration;
|
|
382
|
+
this.pausedTime = 0;
|
|
383
|
+
this.emit('onRecordingInterrupted', {
|
|
384
|
+
reason: 'userResumed',
|
|
385
|
+
isPaused: false,
|
|
386
|
+
timestamp: Date.now(),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
this.logger?.error('Resume failed:', error);
|
|
391
|
+
// Fallback to emitting a general failure if resume fails unexpectedly
|
|
392
|
+
this.emit('onRecordingInterrupted', {
|
|
393
|
+
reason: 'resumeFailed', // Use a more specific reason
|
|
394
|
+
isPaused: true, // Remain paused if resume fails
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
message: 'Failed to resume recording. Please stop and start again.',
|
|
397
|
+
});
|
|
258
398
|
}
|
|
259
|
-
this.isPaused = false;
|
|
260
|
-
this.recordingStartTime += Date.now() - this.pausedTime;
|
|
261
399
|
}
|
|
262
400
|
// Get current status
|
|
263
401
|
status() {
|
|
402
|
+
const durationMs = this.getRecordingDuration();
|
|
264
403
|
const status = {
|
|
265
404
|
isRecording: this.isRecording,
|
|
266
405
|
isPaused: this.isPaused,
|
|
267
|
-
durationMs
|
|
406
|
+
durationMs,
|
|
268
407
|
size: this.currentSize,
|
|
269
408
|
interval: this.currentInterval,
|
|
270
409
|
intervalAnalysis: this.currentIntervalAnalysis,
|
|
@@ -281,5 +420,211 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
281
420
|
};
|
|
282
421
|
return status;
|
|
283
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Handles device fallback when the current device is disconnected
|
|
425
|
+
*/
|
|
426
|
+
async handleDeviceFallback() {
|
|
427
|
+
this.logger?.debug('Starting device fallback procedure');
|
|
428
|
+
if (!this.isRecording) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
// Save important state before switching
|
|
433
|
+
const currentPosition = this.latestPosition;
|
|
434
|
+
const existingAudioChunks = [...this.audioChunks];
|
|
435
|
+
// Save compressed chunks if available
|
|
436
|
+
let compressedChunks = [];
|
|
437
|
+
if (this.customRecorder) {
|
|
438
|
+
try {
|
|
439
|
+
compressedChunks = this.customRecorder.getCompressedChunks();
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
this.logger?.warn('Failed to get compressed chunks:', err);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Save the current counter value for continuity
|
|
446
|
+
let currentDataPointCounter = 0;
|
|
447
|
+
if (this.customRecorder) {
|
|
448
|
+
currentDataPointCounter =
|
|
449
|
+
this.customRecorder.getDataPointCounter();
|
|
450
|
+
}
|
|
451
|
+
// Clean up existing recorder
|
|
452
|
+
if (this.customRecorder) {
|
|
453
|
+
try {
|
|
454
|
+
this.customRecorder.cleanup();
|
|
455
|
+
}
|
|
456
|
+
catch (cleanupError) {
|
|
457
|
+
this.logger?.warn('Error during cleanup:', cleanupError);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Keep recording state true but mark as paused
|
|
461
|
+
this.isPaused = true;
|
|
462
|
+
this.pausedTime = Date.now();
|
|
463
|
+
// Store current size and other stats
|
|
464
|
+
const previousTotalSize = this.currentSize;
|
|
465
|
+
const previousLastEmittedSize = this.lastEmittedSize;
|
|
466
|
+
const previousCompressedSize = this.totalCompressedSize;
|
|
467
|
+
// Try to get a fallback device
|
|
468
|
+
const fallbackDeviceInfo = await this.getFallbackDevice();
|
|
469
|
+
if (!fallbackDeviceInfo) {
|
|
470
|
+
this.emit('onRecordingInterrupted', {
|
|
471
|
+
reason: 'deviceSwitchFailed',
|
|
472
|
+
isPaused: true,
|
|
473
|
+
timestamp: Date.now(),
|
|
474
|
+
message: 'Failed to switch to fallback device. Recording paused.',
|
|
475
|
+
});
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
// Start recording with the new device
|
|
479
|
+
try {
|
|
480
|
+
const stream = await this.requestPermissionsAndGetUserMedia(fallbackDeviceInfo.deviceId);
|
|
481
|
+
const audioContext = new (window.AudioContext ||
|
|
482
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
483
|
+
// @ts-ignore - Allow webkitAudioContext for Safari
|
|
484
|
+
window.webkitAudioContext)();
|
|
485
|
+
const source = audioContext.createMediaStreamSource(stream);
|
|
486
|
+
// Create a new recorder with the fallback device
|
|
487
|
+
this.customRecorder = new WebRecorder({
|
|
488
|
+
logger: this.logger,
|
|
489
|
+
audioContext,
|
|
490
|
+
source,
|
|
491
|
+
recordingConfig: this.recordingConfig || {},
|
|
492
|
+
emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
|
|
493
|
+
emitAudioAnalysisCallback: this.customRecorderAnalysisCallback.bind(this),
|
|
494
|
+
onInterruption: this.handleRecordingInterruption.bind(this),
|
|
495
|
+
});
|
|
496
|
+
await this.customRecorder.init();
|
|
497
|
+
// Set the initial position to continue from the previous device
|
|
498
|
+
this.customRecorder.setPosition(currentPosition);
|
|
499
|
+
// Reset the data point counter to continue from where the previous device left off
|
|
500
|
+
if (currentDataPointCounter > 0) {
|
|
501
|
+
this.customRecorder.resetDataPointCounter(currentDataPointCounter);
|
|
502
|
+
}
|
|
503
|
+
// Prepare the recorder to handle the device switch properly
|
|
504
|
+
this.customRecorder.prepareForDeviceSwitch();
|
|
505
|
+
// Restore the existing audio chunks
|
|
506
|
+
if (existingAudioChunks.length > 0) {
|
|
507
|
+
this.audioChunks = existingAudioChunks;
|
|
508
|
+
}
|
|
509
|
+
// Restore compressed chunks if available
|
|
510
|
+
if (compressedChunks.length > 0) {
|
|
511
|
+
this.customRecorder.setCompressedChunks(compressedChunks);
|
|
512
|
+
}
|
|
513
|
+
// Start the new recorder while preserving counters
|
|
514
|
+
this.customRecorder.start(true);
|
|
515
|
+
// Update recording state
|
|
516
|
+
this.isPaused = false;
|
|
517
|
+
this.recordingStartTime = Date.now();
|
|
518
|
+
// Restore size counters to maintain continuity
|
|
519
|
+
this.currentSize = previousTotalSize;
|
|
520
|
+
this.lastEmittedSize = previousLastEmittedSize;
|
|
521
|
+
this.totalCompressedSize = previousCompressedSize;
|
|
522
|
+
// Notify that we switched to a fallback device
|
|
523
|
+
if (this.eventCallback) {
|
|
524
|
+
this.eventCallback({
|
|
525
|
+
type: 'deviceFallback',
|
|
526
|
+
device: fallbackDeviceInfo.deviceId,
|
|
527
|
+
timestamp: new Date(),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
this.logger?.error('Failed to start recording with fallback device', error);
|
|
534
|
+
this.isPaused = true;
|
|
535
|
+
this.emit('onRecordingInterrupted', {
|
|
536
|
+
reason: 'deviceSwitchFailed',
|
|
537
|
+
isPaused: true,
|
|
538
|
+
timestamp: Date.now(),
|
|
539
|
+
message: 'Failed to switch to fallback device. Recording paused.',
|
|
540
|
+
});
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
this.logger?.error('Failed to use fallback device', error);
|
|
546
|
+
this.isPaused = true;
|
|
547
|
+
this.emit('onRecordingInterrupted', {
|
|
548
|
+
reason: 'deviceSwitchFailed',
|
|
549
|
+
isPaused: true,
|
|
550
|
+
timestamp: Date.now(),
|
|
551
|
+
message: 'Failed to switch to fallback device. Recording paused.',
|
|
552
|
+
});
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Attempts to get a fallback audio device
|
|
558
|
+
*/
|
|
559
|
+
async getFallbackDevice() {
|
|
560
|
+
try {
|
|
561
|
+
// Get list of available audio input devices
|
|
562
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
563
|
+
const audioInputDevices = devices.filter((device) => device.kind === 'audioinput');
|
|
564
|
+
if (audioInputDevices.length === 0) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
// Try to find a device that's not the current one
|
|
568
|
+
if (this.customRecorder) {
|
|
569
|
+
try {
|
|
570
|
+
// Use mediaDevices.enumerateDevices to find the current active device
|
|
571
|
+
const tracks = navigator.mediaDevices
|
|
572
|
+
.getUserMedia({ audio: true })
|
|
573
|
+
.then((stream) => {
|
|
574
|
+
const track = stream.getAudioTracks()[0];
|
|
575
|
+
return track ? track.label : '';
|
|
576
|
+
})
|
|
577
|
+
.catch(() => '');
|
|
578
|
+
const currentTrackLabel = await tracks;
|
|
579
|
+
if (currentTrackLabel) {
|
|
580
|
+
// Find a device with a different label
|
|
581
|
+
const differentDevice = audioInputDevices.find((device) => device.label &&
|
|
582
|
+
device.label !== currentTrackLabel);
|
|
583
|
+
if (differentDevice) {
|
|
584
|
+
return differentDevice;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
this.logger?.warn('Error determining current device, using default');
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Return the first available device (default device)
|
|
593
|
+
return audioInputDevices[0];
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
this.logger?.error('Error finding fallback device:', error);
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Gets user media with specific device ID
|
|
602
|
+
*/
|
|
603
|
+
async requestPermissionsAndGetUserMedia(deviceId) {
|
|
604
|
+
try {
|
|
605
|
+
// Request the specific device
|
|
606
|
+
return await navigator.mediaDevices.getUserMedia({
|
|
607
|
+
audio: {
|
|
608
|
+
deviceId: { exact: deviceId },
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
this.logger?.error(`Failed to get media for device ${deviceId}`, error);
|
|
614
|
+
// Try with default constraints as fallback
|
|
615
|
+
return await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
init(options) {
|
|
619
|
+
try {
|
|
620
|
+
this.logger = options?.logger;
|
|
621
|
+
this.eventCallback = options?.eventCallback;
|
|
622
|
+
return Promise.resolve();
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
this.logger?.error('Error initializing ExpoAudioStream', error);
|
|
626
|
+
return Promise.reject(error);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
284
629
|
}
|
|
285
630
|
//# sourceMappingURL=ExpoAudioStream.web.js.map
|