@siteed/expo-audio-stream 1.7.2 → 1.9.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 +34 -1
- package/README.md +6 -1
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +39 -0
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +124 -12
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +26 -2
- package/build/AudioRecorder.provider.d.ts.map +1 -1
- package/build/AudioRecorder.provider.js +1 -0
- package/build/AudioRecorder.provider.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +22 -1
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +15 -2
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +99 -40
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/WebRecorder.web.d.ts +14 -3
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +188 -100
- package/build/WebRecorder.web.js.map +1 -1
- package/build/events.d.ts +6 -0
- package/build/events.d.ts.map +1 -1
- package/build/events.js.map +1 -1
- package/build/useAudioRecorder.d.ts +2 -1
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +46 -5
- package/build/useAudioRecorder.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 +65 -160
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
- package/ios/AudioStreamManager.swift +127 -8
- package/ios/AudioStreamManagerDelegate.swift +8 -2
- package/ios/ExpoAudioStreamModule.swift +61 -46
- package/ios/RecordingResult.swift +2 -0
- package/ios/RecordingSettings.swift +63 -3
- package/package.json +1 -1
- package/src/AudioRecorder.provider.tsx +1 -0
- package/src/ExpoAudioStream.types.ts +24 -1
- package/src/ExpoAudioStream.web.ts +111 -38
- package/src/WebRecorder.web.ts +238 -138
- package/src/events.ts +7 -0
- package/src/useAudioRecorder.tsx +68 -7
- package/src/workers/inlineAudioWebWorker.web.tsx +65 -160
package/src/WebRecorder.web.ts
CHANGED
|
@@ -35,6 +35,14 @@ const DEFAULT_ALGORITHM = 'rms'
|
|
|
35
35
|
|
|
36
36
|
const TAG = 'WebRecorder'
|
|
37
37
|
|
|
38
|
+
const STOP_PERFORMANCE_MARKS = {
|
|
39
|
+
STOP_INITIATED: 'stopInitiated',
|
|
40
|
+
COMPRESSED_RECORDING_STOP: 'compressedRecordingStop',
|
|
41
|
+
AUDIO_WORKLET_STOP: 'audioWorkletStop',
|
|
42
|
+
CLEANUP: 'cleanup',
|
|
43
|
+
TOTAL_STOP_TIME: 'totalStopTime',
|
|
44
|
+
} as const
|
|
45
|
+
|
|
38
46
|
export class WebRecorder {
|
|
39
47
|
private audioContext: AudioContext
|
|
40
48
|
private audioWorkletNode!: AudioWorkletNode
|
|
@@ -44,15 +52,18 @@ export class WebRecorder {
|
|
|
44
52
|
private emitAudioEventCallback: EmitAudioEventFunction
|
|
45
53
|
private emitAudioAnalysisCallback: EmitAudioAnalysisFunction
|
|
46
54
|
private config: RecordingConfig
|
|
47
|
-
private position: number
|
|
55
|
+
private position: number = 0
|
|
48
56
|
private numberOfChannels: number // Number of audio channels
|
|
49
57
|
private bitDepth: number // Bit depth of the audio
|
|
50
58
|
private exportBitDepth: number // Bit depth of the audio
|
|
51
|
-
private audioBuffer: Float32Array // Single buffer to store the audio data
|
|
52
|
-
private audioBufferSize: number // Keep track of the buffer size
|
|
53
59
|
private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
|
|
54
60
|
private packetCount: number = 0
|
|
55
61
|
private logger?: ConsoleLike
|
|
62
|
+
private compressedMediaRecorder: MediaRecorder | null = null
|
|
63
|
+
private compressedChunks: Blob[] = []
|
|
64
|
+
private compressedSize: number = 0
|
|
65
|
+
private pendingCompressedChunk: Blob | null = null
|
|
66
|
+
private readonly wavMimeType = 'audio/wav'
|
|
56
67
|
|
|
57
68
|
constructor({
|
|
58
69
|
audioContext,
|
|
@@ -77,7 +88,6 @@ export class WebRecorder {
|
|
|
77
88
|
this.emitAudioEventCallback = emitAudioEventCallback
|
|
78
89
|
this.emitAudioAnalysisCallback = emitAudioAnalysisCallback
|
|
79
90
|
this.config = recordingConfig
|
|
80
|
-
this.position = 0
|
|
81
91
|
this.logger = logger
|
|
82
92
|
|
|
83
93
|
const audioContextFormat = this.checkAudioContextFormat({
|
|
@@ -100,10 +110,6 @@ export class WebRecorder {
|
|
|
100
110
|
audioContextFormat.bitDepth ||
|
|
101
111
|
DEFAULT_WEB_BITDEPTH
|
|
102
112
|
|
|
103
|
-
// Initialize the audio buffer separately
|
|
104
|
-
this.audioBuffer = new Float32Array(0)
|
|
105
|
-
this.audioBufferSize = 0
|
|
106
|
-
|
|
107
113
|
this.audioAnalysisData = {
|
|
108
114
|
amplitudeRange: { min: 0, max: 0 },
|
|
109
115
|
dataPoints: [],
|
|
@@ -121,6 +127,11 @@ export class WebRecorder {
|
|
|
121
127
|
if (recordingConfig.enableProcessing) {
|
|
122
128
|
this.initFeatureExtractorWorker()
|
|
123
129
|
}
|
|
130
|
+
|
|
131
|
+
// Initialize compressed recording if enabled
|
|
132
|
+
if (recordingConfig.compression?.enabled) {
|
|
133
|
+
this.initializeCompressedRecorder()
|
|
134
|
+
}
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
async init() {
|
|
@@ -145,80 +156,69 @@ export class WebRecorder {
|
|
|
145
156
|
event: AudioWorkletEvent
|
|
146
157
|
) => {
|
|
147
158
|
const command = event.data.command
|
|
148
|
-
if (command !== 'newData')
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
const pcmBufferFloat = event.data.recordedData
|
|
159
|
+
if (command !== 'newData') return
|
|
152
160
|
|
|
161
|
+
const pcmBufferFloat = event.data.recordedData
|
|
153
162
|
if (!pcmBufferFloat) {
|
|
154
163
|
this.logger?.warn('Received empty audio buffer', event)
|
|
155
164
|
return
|
|
156
165
|
}
|
|
157
166
|
|
|
158
|
-
//
|
|
159
|
-
this.
|
|
160
|
-
`Received audio blob from processor len:${pcmBufferFloat?.length}`,
|
|
161
|
-
event
|
|
162
|
-
)
|
|
163
|
-
// Concatenate the incoming Float32Array to the existing buffer
|
|
164
|
-
const newBuffer = new Float32Array(
|
|
165
|
-
this.audioBufferSize + pcmBufferFloat.length
|
|
166
|
-
)
|
|
167
|
-
newBuffer.set(this.audioBuffer, 0)
|
|
168
|
-
newBuffer.set(pcmBufferFloat, this.audioBufferSize)
|
|
169
|
-
this.audioBuffer = newBuffer
|
|
170
|
-
this.audioBufferSize += pcmBufferFloat.length
|
|
171
|
-
|
|
167
|
+
// Process data in smaller chunks and emit immediately
|
|
168
|
+
const chunkSize = this.audioContext.sampleRate * 2 // Reduce to 2 seconds chunks
|
|
172
169
|
const sampleRate =
|
|
173
170
|
event.data.sampleRate ?? this.audioContext.sampleRate
|
|
174
|
-
const duration = pcmBufferFloat.length / sampleRate
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
171
|
+
const duration = pcmBufferFloat.length / sampleRate
|
|
172
|
+
|
|
173
|
+
// Emit chunks without storing them
|
|
174
|
+
for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
|
|
175
|
+
const chunk = pcmBufferFloat.slice(i, i + chunkSize)
|
|
176
|
+
const chunkPosition = this.position + i / sampleRate
|
|
177
|
+
|
|
178
|
+
// Process features if enabled
|
|
179
|
+
if (
|
|
180
|
+
this.config.enableProcessing &&
|
|
181
|
+
this.featureExtractorWorker
|
|
182
|
+
) {
|
|
183
|
+
this.featureExtractorWorker.postMessage(
|
|
184
|
+
{
|
|
185
|
+
command: 'process',
|
|
186
|
+
channelData: chunk,
|
|
187
|
+
sampleRate,
|
|
188
|
+
pointsPerSecond:
|
|
189
|
+
this.config.pointsPerSecond ||
|
|
190
|
+
DEFAULT_WEB_POINTS_PER_SECOND,
|
|
191
|
+
algorithm: this.config.algorithm || 'rms',
|
|
192
|
+
bitDepth: this.bitDepth,
|
|
193
|
+
fullAudioDurationMs: this.position * 1000,
|
|
194
|
+
numberOfChannels: this.numberOfChannels,
|
|
195
|
+
features: this.config.features,
|
|
196
|
+
},
|
|
197
|
+
[]
|
|
198
|
+
)
|
|
199
|
+
}
|
|
184
200
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
// Emit chunk immediately
|
|
202
|
+
this.emitAudioEventCallback({
|
|
203
|
+
data: chunk,
|
|
204
|
+
position: chunkPosition,
|
|
205
|
+
compression: this.pendingCompressedChunk
|
|
206
|
+
? {
|
|
207
|
+
data: this.pendingCompressedChunk,
|
|
208
|
+
size: this.pendingCompressedChunk.size,
|
|
209
|
+
totalSize: this.compressedSize,
|
|
210
|
+
mimeType: 'audio/webm',
|
|
211
|
+
format: 'opus',
|
|
212
|
+
bitrate:
|
|
213
|
+
this.config.compression?.bitrate ??
|
|
214
|
+
128000,
|
|
215
|
+
}
|
|
216
|
+
: undefined,
|
|
217
|
+
})
|
|
195
218
|
}
|
|
196
219
|
|
|
197
|
-
|
|
198
|
-
this.
|
|
199
|
-
|
|
200
|
-
this.emitAudioEventCallback({
|
|
201
|
-
data,
|
|
202
|
-
position: this.position,
|
|
203
|
-
})
|
|
204
|
-
this.position += duration // Update position
|
|
205
|
-
|
|
206
|
-
this.featureExtractorWorker?.postMessage(
|
|
207
|
-
{
|
|
208
|
-
command: 'process',
|
|
209
|
-
channelData: pcmBufferFloat,
|
|
210
|
-
sampleRate,
|
|
211
|
-
pointsPerSecond:
|
|
212
|
-
this.config.pointsPerSecond ||
|
|
213
|
-
DEFAULT_WEB_POINTS_PER_SECOND,
|
|
214
|
-
algorithm: this.config.algorithm || 'rms',
|
|
215
|
-
bitDepth: this.bitDepth,
|
|
216
|
-
fullAudioDurationMs: this.position * 1000,
|
|
217
|
-
numberOfChannels: this.numberOfChannels,
|
|
218
|
-
features: this.config.features,
|
|
219
|
-
},
|
|
220
|
-
[]
|
|
221
|
-
)
|
|
220
|
+
this.position += duration
|
|
221
|
+
this.pendingCompressedChunk = null
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
this.logger?.debug(
|
|
@@ -227,7 +227,7 @@ export class WebRecorder {
|
|
|
227
227
|
)
|
|
228
228
|
this.audioWorkletNode.port.postMessage({
|
|
229
229
|
command: 'init',
|
|
230
|
-
recordSampleRate: this.audioContext.sampleRate,
|
|
230
|
+
recordSampleRate: this.audioContext.sampleRate,
|
|
231
231
|
exportSampleRate:
|
|
232
232
|
this.config.sampleRate ?? this.audioContext.sampleRate,
|
|
233
233
|
bitDepth: this.bitDepth,
|
|
@@ -331,82 +331,146 @@ export class WebRecorder {
|
|
|
331
331
|
this.source.connect(this.audioWorkletNode)
|
|
332
332
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
333
333
|
this.packetCount = 0
|
|
334
|
+
|
|
335
|
+
if (this.compressedMediaRecorder) {
|
|
336
|
+
this.compressedMediaRecorder.start(this.config.interval ?? 1000)
|
|
337
|
+
}
|
|
334
338
|
}
|
|
335
339
|
|
|
336
|
-
stop(): Promise<Float32Array> {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
"Timeout error, audioWorkletNode didn't complete."
|
|
353
|
-
)
|
|
354
|
-
)
|
|
355
|
-
}, 5000)
|
|
356
|
-
|
|
357
|
-
// Listen for the recordedData message to confirm stopping
|
|
358
|
-
const onMessage = async (event: AudioWorkletEvent) => {
|
|
359
|
-
const command = event.data.command
|
|
360
|
-
if (command === 'recordedData') {
|
|
361
|
-
clearTimeout(timeout) // Clear the timeout
|
|
362
|
-
|
|
363
|
-
const rawPCMDataFull =
|
|
364
|
-
event.data.recordedData?.slice(0)
|
|
365
|
-
|
|
366
|
-
if (!rawPCMDataFull) {
|
|
367
|
-
reject(new Error('Failed to get recorded data'))
|
|
368
|
-
return
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Compute duration of the recorded data
|
|
372
|
-
const duration =
|
|
373
|
-
rawPCMDataFull.byteLength /
|
|
374
|
-
(this.audioContext.sampleRate *
|
|
375
|
-
(this.exportBitDepth /
|
|
376
|
-
this.numberOfChannels))
|
|
377
|
-
this.logger?.debug(
|
|
378
|
-
`Received recorded data -- Duration: ${duration} vs ${rawPCMDataFull.byteLength / this.audioContext.sampleRate} seconds`
|
|
379
|
-
)
|
|
380
|
-
this.logger?.debug(
|
|
381
|
-
`recordedData.length=${rawPCMDataFull.byteLength} vs transmittedData.length=${this.audioBufferSize}`
|
|
382
|
-
)
|
|
340
|
+
async stop(): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
|
|
341
|
+
try {
|
|
342
|
+
if (this.compressedMediaRecorder) {
|
|
343
|
+
this.compressedMediaRecorder.stop()
|
|
344
|
+
return {
|
|
345
|
+
pcmData: new Float32Array(), // Return empty array since we're streaming
|
|
346
|
+
compressedBlob: new Blob(this.compressedChunks, {
|
|
347
|
+
type: 'audio/webm;codecs=opus',
|
|
348
|
+
}),
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { pcmData: new Float32Array() }
|
|
352
|
+
} finally {
|
|
353
|
+
this.cleanup()
|
|
354
|
+
}
|
|
355
|
+
}
|
|
383
356
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
357
|
+
private cleanup() {
|
|
358
|
+
if (this.audioContext) {
|
|
359
|
+
this.audioContext.close()
|
|
360
|
+
}
|
|
361
|
+
if (this.audioWorkletNode) {
|
|
362
|
+
this.audioWorkletNode.disconnect()
|
|
363
|
+
}
|
|
364
|
+
if (this.source) {
|
|
365
|
+
this.source.disconnect()
|
|
366
|
+
}
|
|
367
|
+
this.stopMediaStreamTracks()
|
|
368
|
+
}
|
|
389
369
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
370
|
+
// Helper method to process recording stop
|
|
371
|
+
private async processRecordingStop(): Promise<{
|
|
372
|
+
pcmData: Float32Array
|
|
373
|
+
compressedBlob?: Blob
|
|
374
|
+
}> {
|
|
375
|
+
const processStartTime = performance.now()
|
|
376
|
+
this.logger?.debug('[Performance] Starting recording stop process')
|
|
377
|
+
|
|
378
|
+
const [compressedData, workletData] = await Promise.all([
|
|
379
|
+
this.stopCompressedRecording(),
|
|
380
|
+
this.stopAudioWorklet(),
|
|
381
|
+
])
|
|
382
|
+
|
|
383
|
+
this.logger?.debug(
|
|
384
|
+
`[Performance] Recording stop process completed in ${performance.now() - processStartTime}ms`
|
|
385
|
+
)
|
|
386
|
+
return {
|
|
387
|
+
pcmData:
|
|
388
|
+
workletData ??
|
|
389
|
+
new Float32Array(this.audioAnalysisData.dataPoints.length),
|
|
390
|
+
compressedBlob: compressedData,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
395
393
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
394
|
+
// Helper method to stop compressed recording
|
|
395
|
+
private stopCompressedRecording(): Promise<Blob | undefined> {
|
|
396
|
+
const startTime = performance.now()
|
|
397
|
+
this.logger?.debug(
|
|
398
|
+
`[Performance][${STOP_PERFORMANCE_MARKS.COMPRESSED_RECORDING_STOP}] Starting compressed recording stop`
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if (!this.compressedMediaRecorder) {
|
|
402
|
+
this.logger?.debug('[Performance] No compressed recorder to stop')
|
|
403
|
+
return Promise.resolve(undefined)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return new Promise((resolve) => {
|
|
407
|
+
this.compressedMediaRecorder!.onstop = () => {
|
|
408
|
+
const blob = new Blob(this.compressedChunks, {
|
|
409
|
+
type: 'audio/webm;codecs=opus',
|
|
410
|
+
})
|
|
411
|
+
this.logger?.debug(
|
|
412
|
+
`[Performance][${STOP_PERFORMANCE_MARKS.COMPRESSED_RECORDING_STOP}] Compressed recording stopped in ${performance.now() - startTime}ms, size: ${blob.size}`
|
|
413
|
+
)
|
|
414
|
+
resolve(blob)
|
|
415
|
+
}
|
|
416
|
+
this.compressedMediaRecorder!.stop()
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Helper method to stop audio worklet
|
|
421
|
+
private stopAudioWorklet(): Promise<Float32Array | undefined> {
|
|
422
|
+
const startTime = performance.now()
|
|
423
|
+
this.logger?.debug(
|
|
424
|
+
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Starting audio worklet stop`
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
if (!this.audioWorkletNode) {
|
|
428
|
+
this.logger?.debug('[Performance] No audio worklet to stop')
|
|
429
|
+
return Promise.resolve(undefined)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return new Promise((resolve) => {
|
|
433
|
+
const onMessage = (event: AudioWorkletEvent) => {
|
|
434
|
+
if (event.data.command === 'recordedData') {
|
|
435
|
+
this.audioWorkletNode?.port.removeEventListener(
|
|
400
436
|
'message',
|
|
401
437
|
onMessage
|
|
402
438
|
)
|
|
403
|
-
|
|
439
|
+
const rawPCMDataFull = event.data.recordedData?.slice(0)
|
|
440
|
+
|
|
441
|
+
if (!rawPCMDataFull) {
|
|
442
|
+
this.logger?.debug('[Performance] No PCM data received')
|
|
443
|
+
resolve(undefined)
|
|
444
|
+
return
|
|
445
|
+
}
|
|
404
446
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
447
|
+
if (this.exportBitDepth !== this.bitDepth) {
|
|
448
|
+
const conversionStart = performance.now()
|
|
449
|
+
convertPCMToFloat32({
|
|
450
|
+
buffer: rawPCMDataFull.buffer,
|
|
451
|
+
bitDepth: this.exportBitDepth,
|
|
452
|
+
skipWavHeader: true,
|
|
453
|
+
logger: this.logger,
|
|
454
|
+
}).then(({ pcmValues }) => {
|
|
455
|
+
this.logger?.debug(
|
|
456
|
+
`[Performance] PCM conversion completed in ${performance.now() - conversionStart}ms`
|
|
457
|
+
)
|
|
458
|
+
this.logger?.debug(
|
|
459
|
+
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Audio worklet stopped in ${performance.now() - startTime}ms`
|
|
460
|
+
)
|
|
461
|
+
resolve(pcmValues)
|
|
462
|
+
})
|
|
463
|
+
} else {
|
|
464
|
+
this.logger?.debug(
|
|
465
|
+
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Audio worklet stopped in ${performance.now() - startTime}ms`
|
|
466
|
+
)
|
|
467
|
+
resolve(rawPCMDataFull)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
409
470
|
}
|
|
471
|
+
|
|
472
|
+
this.audioWorkletNode.port.addEventListener('message', onMessage)
|
|
473
|
+
this.audioWorkletNode.port.postMessage({ command: 'stop' })
|
|
410
474
|
})
|
|
411
475
|
}
|
|
412
476
|
|
|
@@ -414,6 +478,7 @@ export class WebRecorder {
|
|
|
414
478
|
this.source.disconnect(this.audioWorkletNode) // Disconnect the source from the AudioWorkletNode
|
|
415
479
|
this.audioWorkletNode.disconnect(this.audioContext.destination) // Disconnect the AudioWorkletNode from the destination
|
|
416
480
|
this.audioWorkletNode.port.postMessage({ command: 'pause' })
|
|
481
|
+
this.compressedMediaRecorder?.pause()
|
|
417
482
|
}
|
|
418
483
|
|
|
419
484
|
stopMediaStreamTracks() {
|
|
@@ -484,5 +549,40 @@ export class WebRecorder {
|
|
|
484
549
|
this.source.connect(this.audioWorkletNode)
|
|
485
550
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
486
551
|
this.audioWorkletNode.port.postMessage({ command: 'resume' })
|
|
552
|
+
this.compressedMediaRecorder?.resume()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private initializeCompressedRecorder() {
|
|
556
|
+
try {
|
|
557
|
+
const mimeType = 'audio/webm;codecs=opus'
|
|
558
|
+
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
|
559
|
+
this.logger?.warn(
|
|
560
|
+
'Opus compression not supported in this browser'
|
|
561
|
+
)
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this.compressedMediaRecorder = new MediaRecorder(
|
|
566
|
+
this.source.mediaStream,
|
|
567
|
+
{
|
|
568
|
+
mimeType,
|
|
569
|
+
audioBitsPerSecond:
|
|
570
|
+
this.config.compression?.bitrate ?? 128000,
|
|
571
|
+
}
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
this.compressedMediaRecorder.ondataavailable = (event) => {
|
|
575
|
+
if (event.data.size > 0) {
|
|
576
|
+
this.compressedChunks.push(event.data)
|
|
577
|
+
this.compressedSize += event.data.size
|
|
578
|
+
this.pendingCompressedChunk = event.data
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} catch (error) {
|
|
582
|
+
this.logger?.error(
|
|
583
|
+
'Failed to initialize compressed recorder:',
|
|
584
|
+
error
|
|
585
|
+
)
|
|
586
|
+
}
|
|
487
587
|
}
|
|
488
588
|
}
|
package/src/events.ts
CHANGED
|
@@ -7,6 +7,7 @@ import ExpoAudioStreamModule from './ExpoAudioStreamModule'
|
|
|
7
7
|
|
|
8
8
|
const emitter = new LegacyEventEmitter(ExpoAudioStreamModule)
|
|
9
9
|
|
|
10
|
+
// Internal event payload from native module
|
|
10
11
|
export interface AudioEventPayload {
|
|
11
12
|
encoded?: string
|
|
12
13
|
buffer?: Float32Array
|
|
@@ -17,6 +18,12 @@ export interface AudioEventPayload {
|
|
|
17
18
|
totalSize: number
|
|
18
19
|
mimeType: string
|
|
19
20
|
streamUuid: string
|
|
21
|
+
compression?: {
|
|
22
|
+
data?: string | Blob // Base64 (native) or Float32Array (web) encoded compressed data chunk
|
|
23
|
+
position: number
|
|
24
|
+
eventDataSize: number
|
|
25
|
+
totalSize: number
|
|
26
|
+
}
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export function addAudioEventListener(
|
package/src/useAudioRecorder.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AudioDataEvent,
|
|
8
8
|
AudioRecording,
|
|
9
9
|
AudioStreamStatus,
|
|
10
|
+
CompressionInfo,
|
|
10
11
|
ConsoleLike,
|
|
11
12
|
RecordingConfig,
|
|
12
13
|
StartRecordingResult,
|
|
@@ -31,8 +32,9 @@ export interface UseAudioRecorderState {
|
|
|
31
32
|
resumeRecording: () => Promise<void>
|
|
32
33
|
isRecording: boolean
|
|
33
34
|
isPaused: boolean
|
|
34
|
-
durationMs: number
|
|
35
|
-
size: number
|
|
35
|
+
durationMs: number
|
|
36
|
+
size: number
|
|
37
|
+
compression?: CompressionInfo
|
|
36
38
|
analysisData?: AudioAnalysis
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -41,6 +43,7 @@ interface RecorderReducerState {
|
|
|
41
43
|
isPaused: boolean
|
|
42
44
|
durationMs: number
|
|
43
45
|
size: number
|
|
46
|
+
compression?: CompressionInfo
|
|
44
47
|
analysisData?: AudioAnalysis
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -53,7 +56,14 @@ type RecorderAction =
|
|
|
53
56
|
isPaused: boolean
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
|
-
| {
|
|
59
|
+
| {
|
|
60
|
+
type: 'UPDATE_STATUS'
|
|
61
|
+
payload: {
|
|
62
|
+
durationMs: number
|
|
63
|
+
size: number
|
|
64
|
+
compression?: CompressionInfo
|
|
65
|
+
}
|
|
66
|
+
}
|
|
57
67
|
| { type: 'UPDATE_ANALYSIS'; payload: AudioAnalysis }
|
|
58
68
|
|
|
59
69
|
const defaultAnalysis: AudioAnalysis = {
|
|
@@ -84,7 +94,8 @@ function audioRecorderReducer(
|
|
|
84
94
|
isPaused: false,
|
|
85
95
|
durationMs: 0,
|
|
86
96
|
size: 0,
|
|
87
|
-
|
|
97
|
+
compression: undefined,
|
|
98
|
+
analysisData: defaultAnalysis,
|
|
88
99
|
}
|
|
89
100
|
case 'STOP':
|
|
90
101
|
return { ...state, isRecording: false, isPaused: false }
|
|
@@ -98,12 +109,22 @@ function audioRecorderReducer(
|
|
|
98
109
|
isPaused: action.payload.isPaused,
|
|
99
110
|
isRecording: action.payload.isRecording,
|
|
100
111
|
}
|
|
101
|
-
case 'UPDATE_STATUS':
|
|
102
|
-
|
|
112
|
+
case 'UPDATE_STATUS': {
|
|
113
|
+
const newState = {
|
|
103
114
|
...state,
|
|
104
115
|
durationMs: action.payload.durationMs,
|
|
105
116
|
size: action.payload.size,
|
|
117
|
+
compression: action.payload.compression
|
|
118
|
+
? {
|
|
119
|
+
size: action.payload.compression.size,
|
|
120
|
+
mimeType: action.payload.compression.mimeType,
|
|
121
|
+
bitrate: action.payload.compression.bitrate,
|
|
122
|
+
format: action.payload.compression.format,
|
|
123
|
+
}
|
|
124
|
+
: undefined,
|
|
106
125
|
}
|
|
126
|
+
return newState
|
|
127
|
+
}
|
|
107
128
|
case 'UPDATE_ANALYSIS':
|
|
108
129
|
return {
|
|
109
130
|
...state,
|
|
@@ -129,9 +150,12 @@ export function useAudioRecorder({
|
|
|
129
150
|
isPaused: false,
|
|
130
151
|
durationMs: 0,
|
|
131
152
|
size: 0,
|
|
153
|
+
compression: undefined,
|
|
132
154
|
analysisData: undefined,
|
|
133
155
|
})
|
|
134
156
|
|
|
157
|
+
const startResultRef = useRef<StartRecordingResult | null>(null)
|
|
158
|
+
|
|
135
159
|
const analysisListenerRef = useRef<EventSubscription | null>(null)
|
|
136
160
|
// analysisRef is the current analysis data (last 10 seconds by default)
|
|
137
161
|
const analysisRef = useRef<AudioAnalysis>({ ...defaultAnalysis })
|
|
@@ -261,6 +285,7 @@ export function useAudioRecorder({
|
|
|
261
285
|
encoded,
|
|
262
286
|
mimeType,
|
|
263
287
|
buffer,
|
|
288
|
+
compression,
|
|
264
289
|
} = eventData
|
|
265
290
|
logger?.debug(`[handleAudioEvent] Received audio event:`, {
|
|
266
291
|
fileUri,
|
|
@@ -271,6 +296,7 @@ export function useAudioRecorder({
|
|
|
271
296
|
lastEmittedSize,
|
|
272
297
|
streamUuid,
|
|
273
298
|
encodedLength: encoded?.length,
|
|
299
|
+
compression,
|
|
274
300
|
})
|
|
275
301
|
if (deltaSize === 0) {
|
|
276
302
|
// Ignore packet with no data
|
|
@@ -290,6 +316,21 @@ export function useAudioRecorder({
|
|
|
290
316
|
fileUri,
|
|
291
317
|
eventDataSize: deltaSize,
|
|
292
318
|
totalSize,
|
|
319
|
+
compression:
|
|
320
|
+
compression && startResultRef.current?.compression
|
|
321
|
+
? {
|
|
322
|
+
data: compression.data,
|
|
323
|
+
size: compression.totalSize,
|
|
324
|
+
mimeType:
|
|
325
|
+
startResultRef.current.compression
|
|
326
|
+
?.mimeType,
|
|
327
|
+
bitrate:
|
|
328
|
+
startResultRef.current.compression
|
|
329
|
+
?.bitrate,
|
|
330
|
+
format: startResultRef.current.compression
|
|
331
|
+
?.format,
|
|
332
|
+
}
|
|
333
|
+
: undefined,
|
|
293
334
|
})
|
|
294
335
|
} else if (buffer) {
|
|
295
336
|
// Coming from web
|
|
@@ -299,6 +340,21 @@ export function useAudioRecorder({
|
|
|
299
340
|
fileUri,
|
|
300
341
|
eventDataSize: deltaSize,
|
|
301
342
|
totalSize,
|
|
343
|
+
compression:
|
|
344
|
+
compression && startResultRef.current?.compression
|
|
345
|
+
? {
|
|
346
|
+
data: compression.data,
|
|
347
|
+
size: compression.totalSize,
|
|
348
|
+
mimeType:
|
|
349
|
+
startResultRef.current.compression
|
|
350
|
+
?.mimeType,
|
|
351
|
+
bitrate:
|
|
352
|
+
startResultRef.current.compression
|
|
353
|
+
?.bitrate,
|
|
354
|
+
format: startResultRef.current.compression
|
|
355
|
+
?.format,
|
|
356
|
+
}
|
|
357
|
+
: undefined,
|
|
302
358
|
}
|
|
303
359
|
onAudioStreamRef.current?.(webEvent)
|
|
304
360
|
logger?.debug(
|
|
@@ -317,7 +373,8 @@ export function useAudioRecorder({
|
|
|
317
373
|
try {
|
|
318
374
|
const status: AudioStreamStatus = ExpoAudioStream.status()
|
|
319
375
|
logger?.debug(
|
|
320
|
-
`Status: paused: ${status.isPaused} durationMs: ${status.durationMs} size: ${status.size}
|
|
376
|
+
`Status: paused: ${status.isPaused} durationMs: ${status.durationMs} size: ${status.size}`,
|
|
377
|
+
status.compression
|
|
321
378
|
)
|
|
322
379
|
|
|
323
380
|
// Check and update recording state
|
|
@@ -344,6 +401,7 @@ export function useAudioRecorder({
|
|
|
344
401
|
payload: {
|
|
345
402
|
durationMs: status.durationMs,
|
|
346
403
|
size: status.size,
|
|
404
|
+
compression: status.compression,
|
|
347
405
|
},
|
|
348
406
|
})
|
|
349
407
|
}
|
|
@@ -372,6 +430,8 @@ export function useAudioRecorder({
|
|
|
372
430
|
await ExpoAudioStream.startRecording(options)
|
|
373
431
|
dispatch({ type: 'START' })
|
|
374
432
|
|
|
433
|
+
startResultRef.current = startResult
|
|
434
|
+
|
|
375
435
|
if (enableProcessing) {
|
|
376
436
|
logger?.debug(`Enabling audio analysis listener`)
|
|
377
437
|
const listener = addAudioAnalysisListener(
|
|
@@ -466,6 +526,7 @@ export function useAudioRecorder({
|
|
|
466
526
|
isRecording: state.isRecording,
|
|
467
527
|
durationMs: state.durationMs,
|
|
468
528
|
size: state.size,
|
|
529
|
+
compression: state.compression,
|
|
469
530
|
analysisData: state.analysisData,
|
|
470
531
|
}
|
|
471
532
|
}
|