@siteed/expo-audio-studio 2.4.0 → 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 +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 +581 -255
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +435 -158
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
- package/android/src/main/java/net/siteed/audiostream/PermissionUtils.kt +14 -5
- 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
|
@@ -8,12 +8,24 @@ import {
|
|
|
8
8
|
BitDepth,
|
|
9
9
|
ConsoleLike,
|
|
10
10
|
RecordingConfig,
|
|
11
|
+
RecordingInterruptionReason,
|
|
11
12
|
StartRecordingResult,
|
|
12
13
|
} from './ExpoAudioStream.types'
|
|
13
14
|
import { WebRecorder } from './WebRecorder.web'
|
|
14
15
|
import { AudioEventPayload } from './events'
|
|
15
16
|
import { encodingToBitDepth } from './utils/encodingToBitDepth'
|
|
16
17
|
|
|
18
|
+
export interface AudioStreamEvent {
|
|
19
|
+
type: string
|
|
20
|
+
device?: string
|
|
21
|
+
timestamp: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExpoAudioStreamOptions {
|
|
25
|
+
logger?: ConsoleLike
|
|
26
|
+
eventCallback?: (event: AudioStreamEvent) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export interface EmitAudioEventProps {
|
|
18
30
|
data: Float32Array
|
|
19
31
|
position: number
|
|
@@ -61,6 +73,7 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
61
73
|
latestPosition: number = 0
|
|
62
74
|
totalCompressedSize: number = 0
|
|
63
75
|
private readonly maxBufferSize: number
|
|
76
|
+
private eventCallback?: (event: AudioStreamEvent) => void
|
|
64
77
|
|
|
65
78
|
constructor({
|
|
66
79
|
audioWorkletUrl,
|
|
@@ -69,12 +82,8 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
69
82
|
maxBufferSize = 100, // Default to storing last 100 chunks (1 chunk = 0.5 seconds)
|
|
70
83
|
}: ExpoAudioStreamWebProps) {
|
|
71
84
|
const mockNativeModule = {
|
|
72
|
-
addListener: () => {
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
removeListeners: () => {
|
|
76
|
-
// Not used on web
|
|
77
|
-
},
|
|
85
|
+
addListener: () => {},
|
|
86
|
+
removeListeners: () => {},
|
|
78
87
|
}
|
|
79
88
|
super(mockNativeModule) // Pass the mock native module to the parent class
|
|
80
89
|
|
|
@@ -111,6 +120,50 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
111
120
|
}
|
|
112
121
|
}
|
|
113
122
|
|
|
123
|
+
// Prepare recording with options
|
|
124
|
+
async prepareRecording(
|
|
125
|
+
recordingConfig: RecordingConfig = {}
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
if (this.isRecording) {
|
|
128
|
+
this.logger?.warn(
|
|
129
|
+
'Cannot prepare: Recording is already in progress'
|
|
130
|
+
)
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// Check permissions and initialize basic settings
|
|
136
|
+
await this.getMediaStream().then((stream) => {
|
|
137
|
+
// Just verify we can access the microphone by getting a stream, then release it
|
|
138
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
this.bitDepth = encodingToBitDepth({
|
|
142
|
+
encoding: recordingConfig.encoding ?? 'pcm_32bit',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Store recording configuration for later use
|
|
146
|
+
this.recordingConfig = recordingConfig
|
|
147
|
+
|
|
148
|
+
// Use custom filename if provided, otherwise fallback to timestamp
|
|
149
|
+
if (recordingConfig.filename) {
|
|
150
|
+
// Remove any existing extension from the filename
|
|
151
|
+
this.streamUuid = recordingConfig.filename.replace(
|
|
152
|
+
/\.[^/.]+$/,
|
|
153
|
+
''
|
|
154
|
+
)
|
|
155
|
+
} else {
|
|
156
|
+
this.streamUuid = Date.now().toString()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.logger?.debug('Recording preparation completed successfully')
|
|
160
|
+
return true
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.logger?.error('Error preparing recording:', error)
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
114
167
|
// Start recording with options
|
|
115
168
|
async startRecording(
|
|
116
169
|
recordingConfig: RecordingConfig = {}
|
|
@@ -119,9 +172,22 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
119
172
|
throw new Error('Recording is already in progress')
|
|
120
173
|
}
|
|
121
174
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
175
|
+
// If we haven't prepared or have different settings, prepare now
|
|
176
|
+
if (
|
|
177
|
+
!this.recordingConfig ||
|
|
178
|
+
this.recordingConfig.sampleRate !== recordingConfig.sampleRate ||
|
|
179
|
+
this.recordingConfig.channels !== recordingConfig.channels ||
|
|
180
|
+
this.recordingConfig.encoding !== recordingConfig.encoding
|
|
181
|
+
) {
|
|
182
|
+
await this.prepareRecording(recordingConfig)
|
|
183
|
+
} else {
|
|
184
|
+
this.logger?.debug(
|
|
185
|
+
'Using previously prepared recording configuration'
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Save recording config for reference
|
|
190
|
+
this.recordingConfig = recordingConfig
|
|
125
191
|
|
|
126
192
|
const audioContext = new (window.AudioContext ||
|
|
127
193
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
@@ -136,39 +202,15 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
136
202
|
audioContext,
|
|
137
203
|
source,
|
|
138
204
|
recordingConfig,
|
|
139
|
-
emitAudioEventCallback: (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}: EmitAudioEventProps) => {
|
|
144
|
-
// Keep only the latest chunks based on maxBufferSize
|
|
145
|
-
this.audioChunks.push(new Float32Array(data))
|
|
146
|
-
if (this.audioChunks.length > this.maxBufferSize) {
|
|
147
|
-
this.audioChunks.shift() // Remove oldest chunk
|
|
148
|
-
}
|
|
149
|
-
this.currentSize += data.byteLength
|
|
150
|
-
this.emitAudioEvent({ data, position, compression })
|
|
151
|
-
this.lastEmittedTime = Date.now()
|
|
152
|
-
this.lastEmittedSize = this.currentSize
|
|
153
|
-
this.lastEmittedCompressionSize = compression?.size ?? 0
|
|
154
|
-
},
|
|
155
|
-
emitAudioAnalysisCallback: (audioAnalysisData: AudioAnalysis) => {
|
|
156
|
-
this.logger?.log(`Emitted AudioAnalysis:`, audioAnalysisData)
|
|
157
|
-
this.emit('AudioAnalysis', audioAnalysisData)
|
|
158
|
-
},
|
|
205
|
+
emitAudioEventCallback: this.customRecorderEventCallback.bind(this),
|
|
206
|
+
emitAudioAnalysisCallback:
|
|
207
|
+
this.customRecorderAnalysisCallback.bind(this),
|
|
208
|
+
onInterruption: this.handleRecordingInterruption.bind(this),
|
|
159
209
|
})
|
|
160
210
|
await this.customRecorder.init()
|
|
161
211
|
this.customRecorder.start()
|
|
162
212
|
|
|
163
|
-
// // Set a timer to stop recording after 5 seconds
|
|
164
|
-
// setTimeout(() => {
|
|
165
|
-
// logger.log("AUTO Stopping recording");
|
|
166
|
-
// this.customRecorder?.stopAndPlay();
|
|
167
|
-
// this.isRecording = false;
|
|
168
|
-
// }, 3000);
|
|
169
|
-
|
|
170
213
|
this.isRecording = true
|
|
171
|
-
this.recordingConfig = recordingConfig
|
|
172
214
|
this.recordingStartTime = Date.now()
|
|
173
215
|
this.pausedTime = 0
|
|
174
216
|
this.isPaused = false
|
|
@@ -208,14 +250,124 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
208
250
|
return streamConfig
|
|
209
251
|
}
|
|
210
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Centralized handler for recording interruptions
|
|
255
|
+
*/
|
|
256
|
+
private handleRecordingInterruption(event: {
|
|
257
|
+
reason: RecordingInterruptionReason | string
|
|
258
|
+
isPaused: boolean
|
|
259
|
+
timestamp: number
|
|
260
|
+
message?: string
|
|
261
|
+
}): void {
|
|
262
|
+
this.logger?.debug(`Received recording interruption: ${event.reason}`)
|
|
263
|
+
|
|
264
|
+
// Update local state if the interruption should pause recording
|
|
265
|
+
if (event.isPaused) {
|
|
266
|
+
this.isPaused = true
|
|
267
|
+
|
|
268
|
+
// If this is a device disconnection, handle according to behavior setting
|
|
269
|
+
if (event.reason === 'deviceDisconnected') {
|
|
270
|
+
this.pausedTime = Date.now()
|
|
271
|
+
|
|
272
|
+
// Check if we should try fallback to another device
|
|
273
|
+
if (
|
|
274
|
+
this.recordingConfig?.deviceDisconnectionBehavior ===
|
|
275
|
+
'fallback'
|
|
276
|
+
) {
|
|
277
|
+
this.logger?.debug(
|
|
278
|
+
'Device disconnected with fallback behavior - attempting to switch to default device'
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Try to restart with default device
|
|
282
|
+
this.handleDeviceFallback().catch((error) => {
|
|
283
|
+
// If fallback fails, emit warning
|
|
284
|
+
this.logger?.error('Device fallback failed:', error)
|
|
285
|
+
this.emit('onRecordingInterrupted', {
|
|
286
|
+
reason: 'deviceSwitchFailed',
|
|
287
|
+
isPaused: true,
|
|
288
|
+
timestamp: Date.now(),
|
|
289
|
+
message:
|
|
290
|
+
'Failed to switch to fallback device. Recording paused.',
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
} else {
|
|
294
|
+
// Just warn about disconnection if fallback not enabled
|
|
295
|
+
this.logger?.warn(
|
|
296
|
+
'Device disconnected - recording paused automatically'
|
|
297
|
+
)
|
|
298
|
+
this.emit('onRecordingInterrupted', event)
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
// For other interruption types, just emit the event
|
|
302
|
+
this.emit('onRecordingInterrupted', event)
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
// If not causing a pause, just forward the event
|
|
306
|
+
this.emit('onRecordingInterrupted', event)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Handler for audio events from the WebRecorder
|
|
312
|
+
*/
|
|
313
|
+
private customRecorderEventCallback({
|
|
314
|
+
data,
|
|
315
|
+
position,
|
|
316
|
+
compression,
|
|
317
|
+
}: EmitAudioEventProps): void {
|
|
318
|
+
// Keep only the latest chunks based on maxBufferSize
|
|
319
|
+
this.audioChunks.push(new Float32Array(data))
|
|
320
|
+
if (this.audioChunks.length > this.maxBufferSize) {
|
|
321
|
+
this.audioChunks.shift() // Remove oldest chunk
|
|
322
|
+
}
|
|
323
|
+
this.currentSize += data.byteLength
|
|
324
|
+
this.emitAudioEvent({ data, position, compression })
|
|
325
|
+
this.lastEmittedTime = Date.now()
|
|
326
|
+
this.lastEmittedSize = this.currentSize
|
|
327
|
+
this.lastEmittedCompressionSize = compression?.size ?? 0
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Handler for audio analysis events from the WebRecorder
|
|
332
|
+
*/
|
|
333
|
+
private customRecorderAnalysisCallback(
|
|
334
|
+
audioAnalysisData: AudioAnalysis
|
|
335
|
+
): void {
|
|
336
|
+
this.emit('AudioAnalysis', audioAnalysisData)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Get recording duration
|
|
340
|
+
private getRecordingDuration(): number {
|
|
341
|
+
if (!this.isRecording) {
|
|
342
|
+
return 0
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return this.currentDurationMs
|
|
346
|
+
}
|
|
347
|
+
|
|
211
348
|
emitAudioEvent({ data, position, compression }: EmitAudioEventProps) {
|
|
212
349
|
const fileUri = `${this.streamUuid}.${this.extension}`
|
|
213
350
|
if (compression?.size) {
|
|
214
351
|
this.lastEmittedCompressionSize = compression.size
|
|
215
352
|
this.totalCompressedSize = compression.totalSize
|
|
216
353
|
}
|
|
354
|
+
|
|
355
|
+
// Update latest position for tracking
|
|
217
356
|
this.latestPosition = position
|
|
218
|
-
|
|
357
|
+
|
|
358
|
+
// Calculate duration of this chunk in ms
|
|
359
|
+
const sampleRate = this.recordingConfig?.sampleRate || 44100
|
|
360
|
+
const chunkDurationMs = (data.length / sampleRate) * 1000
|
|
361
|
+
|
|
362
|
+
// Handle duration calculation
|
|
363
|
+
if (this.customRecorder?.isFirstChunkAfterSwitch) {
|
|
364
|
+
this.logger?.debug(
|
|
365
|
+
`Processing first chunk after device switch, duration preserved at ${this.currentDurationMs}ms`
|
|
366
|
+
)
|
|
367
|
+
this.customRecorder.isFirstChunkAfterSwitch = false
|
|
368
|
+
} else {
|
|
369
|
+
this.currentDurationMs += chunkDurationMs
|
|
370
|
+
}
|
|
219
371
|
|
|
220
372
|
const audioEventPayload: AudioEventPayload = {
|
|
221
373
|
fileUri,
|
|
@@ -245,21 +397,19 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
245
397
|
throw new Error('Recorder is not initialized')
|
|
246
398
|
}
|
|
247
399
|
|
|
248
|
-
this.logger?.debug('
|
|
249
|
-
const startTime = performance.now()
|
|
400
|
+
this.logger?.debug('Starting stop process')
|
|
250
401
|
|
|
251
402
|
try {
|
|
252
|
-
this.logger?.debug('[Stop] Stopping recorder')
|
|
253
403
|
const { compressedBlob } = await this.customRecorder.stop()
|
|
254
404
|
|
|
255
405
|
this.isRecording = false
|
|
256
406
|
this.isPaused = false
|
|
257
|
-
this.currentDurationMs = Date.now() - this.recordingStartTime
|
|
258
407
|
|
|
259
408
|
let compression: AudioRecording['compression']
|
|
260
409
|
let fileUri = `${this.streamUuid}.${this.extension}`
|
|
261
410
|
let mimeType = `audio/${this.extension}`
|
|
262
411
|
|
|
412
|
+
// Process compressed audio if available
|
|
263
413
|
if (compressedBlob && this.recordingConfig?.compression?.enabled) {
|
|
264
414
|
const compressedUri = URL.createObjectURL(compressedBlob)
|
|
265
415
|
compression = {
|
|
@@ -269,24 +419,17 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
269
419
|
format: 'opus',
|
|
270
420
|
bitrate: this.recordingConfig.compression.bitrate ?? 128000,
|
|
271
421
|
}
|
|
422
|
+
|
|
272
423
|
// Use compressed values when compression is enabled
|
|
273
424
|
fileUri = compressedUri
|
|
274
425
|
mimeType = 'audio/webm'
|
|
275
426
|
}
|
|
276
427
|
|
|
277
|
-
|
|
278
|
-
`[Stop] Completed stop process in ${performance.now() - startTime}ms`,
|
|
279
|
-
{
|
|
280
|
-
durationMs: this.currentDurationMs,
|
|
281
|
-
compressedSize: compression?.size,
|
|
282
|
-
}
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
// Use the stored streamUuid (which contains our custom filename) for the final filename
|
|
428
|
+
// Use the stored streamUuid for the final filename
|
|
286
429
|
const filename = `${this.streamUuid}.${this.extension}`
|
|
287
430
|
const result: AudioRecording = {
|
|
288
431
|
fileUri,
|
|
289
|
-
filename,
|
|
432
|
+
filename,
|
|
290
433
|
bitDepth: this.bitDepth,
|
|
291
434
|
createdAt: this.recordingStartTime,
|
|
292
435
|
channels: this.recordingConfig?.channels ?? 1,
|
|
@@ -302,22 +445,34 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
302
445
|
|
|
303
446
|
return result
|
|
304
447
|
} catch (error) {
|
|
305
|
-
this.logger?.error('
|
|
448
|
+
this.logger?.error('Error stopping recording:', error)
|
|
306
449
|
throw error
|
|
307
450
|
}
|
|
308
451
|
}
|
|
309
452
|
|
|
310
453
|
// Pause recording
|
|
311
454
|
async pauseRecording() {
|
|
312
|
-
if (!this.isRecording
|
|
313
|
-
throw new Error('Recording is not active
|
|
455
|
+
if (!this.isRecording) {
|
|
456
|
+
throw new Error('Recording is not active')
|
|
314
457
|
}
|
|
315
458
|
|
|
316
|
-
if (this.
|
|
317
|
-
this.
|
|
459
|
+
if (this.isPaused) {
|
|
460
|
+
this.logger?.debug('Recording already paused, skipping')
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
if (this.customRecorder) {
|
|
466
|
+
this.customRecorder.pause()
|
|
467
|
+
}
|
|
468
|
+
this.isPaused = true
|
|
469
|
+
this.pausedTime = Date.now()
|
|
470
|
+
} catch (error) {
|
|
471
|
+
this.logger?.error('Error in pauseRecording', error)
|
|
472
|
+
// Even if the pause operation failed, make sure our state is consistent
|
|
473
|
+
this.isPaused = true
|
|
474
|
+
this.pausedTime = Date.now()
|
|
318
475
|
}
|
|
319
|
-
this.isPaused = true
|
|
320
|
-
this.pausedTime = Date.now()
|
|
321
476
|
}
|
|
322
477
|
|
|
323
478
|
// Resume recording
|
|
@@ -326,19 +481,61 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
326
481
|
throw new Error('Recording is not paused')
|
|
327
482
|
}
|
|
328
483
|
|
|
329
|
-
|
|
484
|
+
this.logger?.debug('Resuming recording', {
|
|
485
|
+
deviceDisconnectionBehavior:
|
|
486
|
+
this.recordingConfig?.deviceDisconnectionBehavior,
|
|
487
|
+
isDeviceDisconnected: this.customRecorder?.isDeviceDisconnected,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
// If we have no recorder, or if the device is disconnected, always attempt fallback
|
|
492
|
+
if (
|
|
493
|
+
!this.customRecorder ||
|
|
494
|
+
this.customRecorder.isDeviceDisconnected
|
|
495
|
+
) {
|
|
496
|
+
this.logger?.debug(
|
|
497
|
+
'No recorder exists or device disconnected - attempting fallback on resume'
|
|
498
|
+
)
|
|
499
|
+
await this.handleDeviceFallback()
|
|
500
|
+
// handleDeviceFallback will manage resuming if successful, or emit error if failed.
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Normal resume path - device is still connected
|
|
330
505
|
this.customRecorder.resume()
|
|
506
|
+
this.isPaused = false
|
|
507
|
+
|
|
508
|
+
// Adjust the recording start time to account for the pause duration
|
|
509
|
+
const pauseDuration = Date.now() - this.pausedTime
|
|
510
|
+
this.recordingStartTime += pauseDuration
|
|
511
|
+
this.pausedTime = 0
|
|
512
|
+
|
|
513
|
+
this.emit('onRecordingInterrupted', {
|
|
514
|
+
reason: 'userResumed',
|
|
515
|
+
isPaused: false,
|
|
516
|
+
timestamp: Date.now(),
|
|
517
|
+
})
|
|
518
|
+
} catch (error) {
|
|
519
|
+
this.logger?.error('Resume failed:', error)
|
|
520
|
+
// Fallback to emitting a general failure if resume fails unexpectedly
|
|
521
|
+
this.emit('onRecordingInterrupted', {
|
|
522
|
+
reason: 'resumeFailed', // Use a more specific reason
|
|
523
|
+
isPaused: true, // Remain paused if resume fails
|
|
524
|
+
timestamp: Date.now(),
|
|
525
|
+
message:
|
|
526
|
+
'Failed to resume recording. Please stop and start again.',
|
|
527
|
+
})
|
|
331
528
|
}
|
|
332
|
-
this.isPaused = false
|
|
333
|
-
this.recordingStartTime += Date.now() - this.pausedTime
|
|
334
529
|
}
|
|
335
530
|
|
|
336
531
|
// Get current status
|
|
337
532
|
status() {
|
|
533
|
+
const durationMs = this.getRecordingDuration()
|
|
534
|
+
|
|
338
535
|
const status: AudioStreamStatus = {
|
|
339
536
|
isRecording: this.isRecording,
|
|
340
537
|
isPaused: this.isPaused,
|
|
341
|
-
durationMs
|
|
538
|
+
durationMs,
|
|
342
539
|
size: this.currentSize,
|
|
343
540
|
interval: this.currentInterval,
|
|
344
541
|
intervalAnalysis: this.currentIntervalAnalysis,
|
|
@@ -356,4 +553,257 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
356
553
|
}
|
|
357
554
|
return status
|
|
358
555
|
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Handles device fallback when the current device is disconnected
|
|
559
|
+
*/
|
|
560
|
+
private async handleDeviceFallback(): Promise<boolean> {
|
|
561
|
+
this.logger?.debug('Starting device fallback procedure')
|
|
562
|
+
|
|
563
|
+
if (!this.isRecording) {
|
|
564
|
+
return false
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
// Save important state before switching
|
|
569
|
+
const currentPosition = this.latestPosition
|
|
570
|
+
const existingAudioChunks = [...this.audioChunks]
|
|
571
|
+
|
|
572
|
+
// Save compressed chunks if available
|
|
573
|
+
let compressedChunks: Blob[] = []
|
|
574
|
+
if (this.customRecorder) {
|
|
575
|
+
try {
|
|
576
|
+
compressedChunks = this.customRecorder.getCompressedChunks()
|
|
577
|
+
} catch (err) {
|
|
578
|
+
this.logger?.warn('Failed to get compressed chunks:', err)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Save the current counter value for continuity
|
|
583
|
+
let currentDataPointCounter = 0
|
|
584
|
+
if (this.customRecorder) {
|
|
585
|
+
currentDataPointCounter =
|
|
586
|
+
this.customRecorder.getDataPointCounter()
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Clean up existing recorder
|
|
590
|
+
if (this.customRecorder) {
|
|
591
|
+
try {
|
|
592
|
+
this.customRecorder.cleanup()
|
|
593
|
+
} catch (cleanupError) {
|
|
594
|
+
this.logger?.warn('Error during cleanup:', cleanupError)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Keep recording state true but mark as paused
|
|
599
|
+
this.isPaused = true
|
|
600
|
+
this.pausedTime = Date.now()
|
|
601
|
+
|
|
602
|
+
// Store current size and other stats
|
|
603
|
+
const previousTotalSize = this.currentSize
|
|
604
|
+
const previousLastEmittedSize = this.lastEmittedSize
|
|
605
|
+
const previousCompressedSize = this.totalCompressedSize
|
|
606
|
+
|
|
607
|
+
// Try to get a fallback device
|
|
608
|
+
const fallbackDeviceInfo = await this.getFallbackDevice()
|
|
609
|
+
if (!fallbackDeviceInfo) {
|
|
610
|
+
this.emit('onRecordingInterrupted', {
|
|
611
|
+
reason: 'deviceSwitchFailed',
|
|
612
|
+
isPaused: true,
|
|
613
|
+
timestamp: Date.now(),
|
|
614
|
+
message:
|
|
615
|
+
'Failed to switch to fallback device. Recording paused.',
|
|
616
|
+
})
|
|
617
|
+
return false
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Start recording with the new device
|
|
621
|
+
try {
|
|
622
|
+
const stream = await this.requestPermissionsAndGetUserMedia(
|
|
623
|
+
fallbackDeviceInfo.deviceId
|
|
624
|
+
)
|
|
625
|
+
const audioContext = new (window.AudioContext ||
|
|
626
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
627
|
+
// @ts-ignore - Allow webkitAudioContext for Safari
|
|
628
|
+
window.webkitAudioContext)()
|
|
629
|
+
|
|
630
|
+
const source = audioContext.createMediaStreamSource(stream)
|
|
631
|
+
|
|
632
|
+
// Create a new recorder with the fallback device
|
|
633
|
+
this.customRecorder = new WebRecorder({
|
|
634
|
+
logger: this.logger,
|
|
635
|
+
audioContext,
|
|
636
|
+
source,
|
|
637
|
+
recordingConfig: this.recordingConfig || {},
|
|
638
|
+
emitAudioEventCallback:
|
|
639
|
+
this.customRecorderEventCallback.bind(this),
|
|
640
|
+
emitAudioAnalysisCallback:
|
|
641
|
+
this.customRecorderAnalysisCallback.bind(this),
|
|
642
|
+
onInterruption: this.handleRecordingInterruption.bind(this),
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
await this.customRecorder.init()
|
|
646
|
+
|
|
647
|
+
// Set the initial position to continue from the previous device
|
|
648
|
+
this.customRecorder.setPosition(currentPosition)
|
|
649
|
+
|
|
650
|
+
// Reset the data point counter to continue from where the previous device left off
|
|
651
|
+
if (currentDataPointCounter > 0) {
|
|
652
|
+
this.customRecorder.resetDataPointCounter(
|
|
653
|
+
currentDataPointCounter
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Prepare the recorder to handle the device switch properly
|
|
658
|
+
this.customRecorder.prepareForDeviceSwitch()
|
|
659
|
+
|
|
660
|
+
// Restore the existing audio chunks
|
|
661
|
+
if (existingAudioChunks.length > 0) {
|
|
662
|
+
this.audioChunks = existingAudioChunks
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Restore compressed chunks if available
|
|
666
|
+
if (compressedChunks.length > 0) {
|
|
667
|
+
this.customRecorder.setCompressedChunks(compressedChunks)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Start the new recorder while preserving counters
|
|
671
|
+
this.customRecorder.start(true)
|
|
672
|
+
|
|
673
|
+
// Update recording state
|
|
674
|
+
this.isPaused = false
|
|
675
|
+
this.recordingStartTime = Date.now()
|
|
676
|
+
|
|
677
|
+
// Restore size counters to maintain continuity
|
|
678
|
+
this.currentSize = previousTotalSize
|
|
679
|
+
this.lastEmittedSize = previousLastEmittedSize
|
|
680
|
+
this.totalCompressedSize = previousCompressedSize
|
|
681
|
+
|
|
682
|
+
// Notify that we switched to a fallback device
|
|
683
|
+
if (this.eventCallback) {
|
|
684
|
+
this.eventCallback({
|
|
685
|
+
type: 'deviceFallback',
|
|
686
|
+
device: fallbackDeviceInfo.deviceId,
|
|
687
|
+
timestamp: new Date(),
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
return true
|
|
691
|
+
} catch (error) {
|
|
692
|
+
this.logger?.error(
|
|
693
|
+
'Failed to start recording with fallback device',
|
|
694
|
+
error
|
|
695
|
+
)
|
|
696
|
+
this.isPaused = true
|
|
697
|
+
this.emit('onRecordingInterrupted', {
|
|
698
|
+
reason: 'deviceSwitchFailed',
|
|
699
|
+
isPaused: true,
|
|
700
|
+
timestamp: Date.now(),
|
|
701
|
+
message:
|
|
702
|
+
'Failed to switch to fallback device. Recording paused.',
|
|
703
|
+
})
|
|
704
|
+
return false
|
|
705
|
+
}
|
|
706
|
+
} catch (error) {
|
|
707
|
+
this.logger?.error('Failed to use fallback device', error)
|
|
708
|
+
this.isPaused = true
|
|
709
|
+
this.emit('onRecordingInterrupted', {
|
|
710
|
+
reason: 'deviceSwitchFailed',
|
|
711
|
+
isPaused: true,
|
|
712
|
+
timestamp: Date.now(),
|
|
713
|
+
message:
|
|
714
|
+
'Failed to switch to fallback device. Recording paused.',
|
|
715
|
+
})
|
|
716
|
+
return false
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Attempts to get a fallback audio device
|
|
722
|
+
*/
|
|
723
|
+
private async getFallbackDevice(): Promise<MediaDeviceInfo | null> {
|
|
724
|
+
try {
|
|
725
|
+
// Get list of available audio input devices
|
|
726
|
+
const devices = await navigator.mediaDevices.enumerateDevices()
|
|
727
|
+
const audioInputDevices = devices.filter(
|
|
728
|
+
(device) => device.kind === 'audioinput'
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if (audioInputDevices.length === 0) {
|
|
732
|
+
return null
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Try to find a device that's not the current one
|
|
736
|
+
if (this.customRecorder) {
|
|
737
|
+
try {
|
|
738
|
+
// Use mediaDevices.enumerateDevices to find the current active device
|
|
739
|
+
const tracks = navigator.mediaDevices
|
|
740
|
+
.getUserMedia({ audio: true })
|
|
741
|
+
.then((stream) => {
|
|
742
|
+
const track = stream.getAudioTracks()[0]
|
|
743
|
+
return track ? track.label : ''
|
|
744
|
+
})
|
|
745
|
+
.catch(() => '')
|
|
746
|
+
|
|
747
|
+
const currentTrackLabel = await tracks
|
|
748
|
+
|
|
749
|
+
if (currentTrackLabel) {
|
|
750
|
+
// Find a device with a different label
|
|
751
|
+
const differentDevice = audioInputDevices.find(
|
|
752
|
+
(device) =>
|
|
753
|
+
device.label &&
|
|
754
|
+
device.label !== currentTrackLabel
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
if (differentDevice) {
|
|
758
|
+
return differentDevice
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
} catch (err) {
|
|
762
|
+
this.logger?.warn(
|
|
763
|
+
'Error determining current device, using default'
|
|
764
|
+
)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Return the first available device (default device)
|
|
769
|
+
return audioInputDevices[0]
|
|
770
|
+
} catch (error) {
|
|
771
|
+
this.logger?.error('Error finding fallback device:', error)
|
|
772
|
+
return null
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Gets user media with specific device ID
|
|
778
|
+
*/
|
|
779
|
+
private async requestPermissionsAndGetUserMedia(
|
|
780
|
+
deviceId: string
|
|
781
|
+
): Promise<MediaStream> {
|
|
782
|
+
try {
|
|
783
|
+
// Request the specific device
|
|
784
|
+
return await navigator.mediaDevices.getUserMedia({
|
|
785
|
+
audio: {
|
|
786
|
+
deviceId: { exact: deviceId },
|
|
787
|
+
},
|
|
788
|
+
})
|
|
789
|
+
} catch (error) {
|
|
790
|
+
this.logger?.error(
|
|
791
|
+
`Failed to get media for device ${deviceId}`,
|
|
792
|
+
error
|
|
793
|
+
)
|
|
794
|
+
// Try with default constraints as fallback
|
|
795
|
+
return await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
init(options?: ExpoAudioStreamOptions): Promise<void> {
|
|
800
|
+
try {
|
|
801
|
+
this.logger = options?.logger
|
|
802
|
+
this.eventCallback = options?.eventCallback
|
|
803
|
+
return Promise.resolve()
|
|
804
|
+
} catch (error) {
|
|
805
|
+
this.logger?.error('Error initializing ExpoAudioStream', error)
|
|
806
|
+
return Promise.reject(error)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
359
809
|
}
|