@siteed/expo-audio-stream 1.7.2 → 1.8.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +6 -1
  3. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +39 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +124 -12
  5. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +26 -2
  6. package/build/AudioRecorder.provider.d.ts.map +1 -1
  7. package/build/AudioRecorder.provider.js +1 -0
  8. package/build/AudioRecorder.provider.js.map +1 -1
  9. package/build/ExpoAudioStream.types.d.ts +35 -1
  10. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  11. package/build/ExpoAudioStream.types.js.map +1 -1
  12. package/build/ExpoAudioStream.web.d.ts +14 -3
  13. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  14. package/build/ExpoAudioStream.web.js +102 -38
  15. package/build/ExpoAudioStream.web.js.map +1 -1
  16. package/build/WebRecorder.web.d.ts +11 -2
  17. package/build/WebRecorder.web.d.ts.map +1 -1
  18. package/build/WebRecorder.web.js +178 -43
  19. package/build/WebRecorder.web.js.map +1 -1
  20. package/build/events.d.ts +6 -0
  21. package/build/events.d.ts.map +1 -1
  22. package/build/events.js.map +1 -1
  23. package/build/useAudioRecorder.d.ts +3 -2
  24. package/build/useAudioRecorder.d.ts.map +1 -1
  25. package/build/useAudioRecorder.js +46 -5
  26. package/build/useAudioRecorder.js.map +1 -1
  27. package/ios/AudioStreamManager.swift +127 -8
  28. package/ios/AudioStreamManagerDelegate.swift +8 -2
  29. package/ios/ExpoAudioStreamModule.swift +61 -46
  30. package/ios/RecordingResult.swift +2 -0
  31. package/ios/RecordingSettings.swift +63 -3
  32. package/package.json +1 -1
  33. package/src/AudioRecorder.provider.tsx +1 -0
  34. package/src/ExpoAudioStream.types.ts +38 -1
  35. package/src/ExpoAudioStream.web.ts +114 -38
  36. package/src/WebRecorder.web.ts +210 -64
  37. package/src/events.ts +7 -0
  38. package/src/useAudioRecorder.tsx +70 -8
@@ -1,7 +1,7 @@
1
1
  // src/WebRecorder.ts
2
2
 
3
3
  import { AudioAnalysis } from './AudioAnalysis/AudioAnalysis.types'
4
- import { ConsoleLike, RecordingConfig } from './ExpoAudioStream.types'
4
+ import { ConsoleLike, RecordingConfig, WebRecordingOptions } from './ExpoAudioStream.types'
5
5
  import {
6
6
  EmitAudioAnalysisFunction,
7
7
  EmitAudioEventFunction,
@@ -53,6 +53,11 @@ export class WebRecorder {
53
53
  private audioAnalysisData: AudioAnalysis // Keep updating the full audio analysis data with latest events
54
54
  private packetCount: number = 0
55
55
  private logger?: ConsoleLike
56
+ private compressedMediaRecorder: MediaRecorder | null = null
57
+ private compressedChunks: Blob[] = []
58
+ private compressedSize: number = 0
59
+ private pendingCompressedChunk: Blob | null = null
60
+ private audioChunks: Float32Array[] = []
56
61
 
57
62
  constructor({
58
63
  audioContext,
@@ -121,6 +126,11 @@ export class WebRecorder {
121
126
  if (recordingConfig.enableProcessing) {
122
127
  this.initFeatureExtractorWorker()
123
128
  }
129
+
130
+ // Initialize compressed recording if enabled
131
+ if (recordingConfig.compression?.enabled) {
132
+ this.initializeCompressedRecorder()
133
+ }
124
134
  }
125
135
 
126
136
  async init() {
@@ -155,18 +165,8 @@ export class WebRecorder {
155
165
  return
156
166
  }
157
167
 
158
- // Handle the audio blob (e.g., send it to the server or process it further)
159
- this.logger?.debug(
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
168
+ // Store chunks instead of concatenating
169
+ this.audioChunks.push(pcmBufferFloat)
170
170
  this.audioBufferSize += pcmBufferFloat.length
171
171
 
172
172
  const sampleRate =
@@ -197,9 +197,24 @@ export class WebRecorder {
197
197
  // Track the number of packets
198
198
  this.packetCount += 1
199
199
 
200
+ // Prepare compression data if available
201
+ let compressionData
202
+ if (this.pendingCompressedChunk) {
203
+ compressionData = {
204
+ data: this.pendingCompressedChunk,
205
+ size: this.pendingCompressedChunk.size,
206
+ totalSize: this.compressedSize,
207
+ mimeType: 'audio/webm',
208
+ format: 'opus',
209
+ bitrate: this.config.compression?.bitrate ?? 128000,
210
+ }
211
+ this.pendingCompressedChunk = null
212
+ }
213
+
200
214
  this.emitAudioEventCallback({
201
- data,
215
+ data: pcmBufferFloat,
202
216
  position: this.position,
217
+ compression: compressionData,
203
218
  })
204
219
  this.position += duration // Update position
205
220
 
@@ -331,80 +346,175 @@ export class WebRecorder {
331
346
  this.source.connect(this.audioWorkletNode)
332
347
  this.audioWorkletNode.connect(this.audioContext.destination)
333
348
  this.packetCount = 0
349
+
350
+ if (this.compressedMediaRecorder) {
351
+ this.compressedMediaRecorder.start(this.config.interval ?? 1000)
352
+ }
334
353
  }
335
354
 
336
- stop(): Promise<Float32Array> {
355
+ async stop(options?: WebRecordingOptions): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
337
356
  return new Promise((resolve, reject) => {
338
- try {
339
- if (this.audioWorkletNode) {
340
- // this.source.disconnect(this.audioWorkletNode);
341
- // this.audioWorkletNode.disconnect(this.audioContext.destination);
342
- this.audioWorkletNode.port.postMessage({ command: 'stop' })
357
+ let isCleanupDone = false
343
358
 
344
- // Set a timeout to reject the promise if no message is received within 5 seconds
345
- const timeout = setTimeout(() => {
346
- this.audioWorkletNode.port.removeEventListener(
347
- 'message',
348
- onMessage
349
- )
350
- reject(
351
- new Error(
352
- "Timeout error, audioWorkletNode didn't complete."
353
- )
354
- )
355
- }, 5000)
359
+ const cleanup = () => {
360
+ if (isCleanupDone) return
361
+ isCleanupDone = true
362
+
363
+ if (this.audioContext) {
364
+ this.audioContext.close()
365
+ }
366
+ if (this.audioWorkletNode) {
367
+ this.audioWorkletNode.disconnect()
368
+ }
369
+ if (this.source) {
370
+ this.source.disconnect()
371
+ }
356
372
 
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
373
+ this.stopMediaStreamTracks()
374
+ }
375
+ try {
376
+ this.logger?.debug(`Stopping WebRecorder ${this.audioChunks.length} chunks - this.compressedChunks.length=${this.compressedChunks.length}`)
377
+
378
+ // If skipFinalConsolidation is true and we have compressed data, return early
379
+ if (options?.skipFinalConsolidation && this.compressedMediaRecorder) {
380
+ this.compressedMediaRecorder.onstop = () => {
381
+ const compressedBlob = new Blob(this.compressedChunks, {
382
+ type: 'audio/webm;codecs=opus',
383
+ })
384
+ cleanup()
385
+ // Return the last chunk as pcmData to maintain interface compatibility
386
+ resolve({
387
+ pcmData: this.audioChunks[this.audioChunks.length - 1] || new Float32Array(),
388
+ compressedBlob,
389
+ })
390
+ }
391
+ this.compressedMediaRecorder.stop()
392
+ return
393
+ }
362
394
 
363
- const rawPCMDataFull =
364
- event.data.recordedData?.slice(0)
395
+ // Handle compressed recording first
396
+ if (this.compressedMediaRecorder) {
397
+ this.compressedMediaRecorder.onstop = () => {
398
+ const compressedBlob = new Blob(this.compressedChunks, {
399
+ type: 'audio/webm;codecs=opus',
400
+ })
365
401
 
366
- if (!rawPCMDataFull) {
367
- reject(new Error('Failed to get recorded data'))
368
- return
369
- }
402
+ // Process chunks in batches
403
+ requestAnimationFrame(() => {
404
+ const combinedBuffer = new Float32Array(this.audioBufferSize)
405
+ let offset = 0
406
+ let chunkIndex = 0
407
+ const CHUNKS_PER_BATCH = 10 // Process 5 seconds worth of chunks at a time (10 * 500ms = 5s)
370
408
 
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
409
  this.logger?.debug(
381
- `recordedData.length=${rawPCMDataFull.byteLength} vs transmittedData.length=${this.audioBufferSize}`
410
+ `Processing ${this.audioChunks.length} chunks in batches of ${CHUNKS_PER_BATCH}`
382
411
  )
383
412
 
384
- // Remove the event listener after receiving the final data
385
- this.audioWorkletNode.port.removeEventListener(
413
+ const processNextBatch = () => {
414
+ const endIndex = Math.min(chunkIndex + CHUNKS_PER_BATCH, this.audioChunks.length)
415
+
416
+ // Process a batch of chunks
417
+ for (let i = chunkIndex; i < endIndex; i++) {
418
+ const chunk = this.audioChunks[i]
419
+ combinedBuffer.set(chunk, offset)
420
+ offset += chunk.length
421
+ }
422
+
423
+ chunkIndex = endIndex
424
+
425
+ if (chunkIndex < this.audioChunks.length) {
426
+ // Process next batch in next frame
427
+ requestAnimationFrame(processNextBatch)
428
+ } else {
429
+ // All chunks processed
430
+ this.audioBuffer = combinedBuffer
431
+ queueMicrotask(() => {
432
+ cleanup()
433
+ resolve({
434
+ pcmData: this.audioBuffer,
435
+ compressedBlob,
436
+ })
437
+ })
438
+ }
439
+ }
440
+
441
+ processNextBatch()
442
+ })
443
+ }
444
+ this.compressedMediaRecorder.stop()
445
+ }
446
+
447
+ // Handle audio worklet data
448
+ if (this.audioWorkletNode) {
449
+ const onMessage = (event: AudioWorkletEvent) => {
450
+ if (event.data.command === 'recordedData') {
451
+ this.audioWorkletNode?.port.removeEventListener(
386
452
  'message',
387
453
  onMessage
388
454
  )
389
455
 
390
- const convertedPCM = await convertPCMToFloat32({
391
- buffer: rawPCMDataFull.buffer,
392
- skipWavHeader: true,
393
- bitDepth: this.exportBitDepth,
456
+ queueMicrotask(async () => {
457
+ try {
458
+ const rawPCMDataFull =
459
+ event.data.recordedData?.slice(0)
460
+ if (!rawPCMDataFull) {
461
+ cleanup()
462
+ reject(
463
+ new Error(
464
+ 'Failed to get recorded data'
465
+ )
466
+ )
467
+ return
468
+ }
469
+
470
+ // Convert PCM data if needed based on bit depth
471
+ if (this.exportBitDepth !== this.bitDepth) {
472
+ const convertedData =
473
+ await convertPCMToFloat32({
474
+ buffer: rawPCMDataFull.buffer,
475
+ bitDepth: this.exportBitDepth,
476
+ skipWavHeader: true,
477
+ logger: this.logger,
478
+ })
479
+ this.audioBuffer =
480
+ convertedData.pcmValues
481
+ } else {
482
+ this.audioBuffer = rawPCMDataFull
483
+ }
484
+
485
+ if (!this.compressedMediaRecorder) {
486
+ cleanup()
487
+ resolve({ pcmData: this.audioBuffer })
488
+ }
489
+ } catch (error) {
490
+ cleanup()
491
+ reject(error)
492
+ }
394
493
  })
395
-
396
- resolve(convertedPCM.pcmValues) // Resolve the promise with the collected buffers
397
494
  }
398
495
  }
496
+
399
497
  this.audioWorkletNode.port.addEventListener(
400
498
  'message',
401
499
  onMessage
402
500
  )
403
- }
501
+ this.audioWorkletNode.port.postMessage({ command: 'stop' })
404
502
 
405
- // Stop all media stream tracks to stop the browser recording
406
- this.stopMediaStreamTracks()
503
+ // Safety timeout
504
+ setTimeout(() => {
505
+ this.audioWorkletNode?.port.removeEventListener(
506
+ 'message',
507
+ onMessage
508
+ )
509
+ cleanup()
510
+ reject(new Error('Timeout while stopping recording'))
511
+ }, 5000)
512
+ } else if (!this.compressedMediaRecorder) {
513
+ cleanup()
514
+ resolve({ pcmData: this.audioBuffer })
515
+ }
407
516
  } catch (error) {
517
+ cleanup()
408
518
  reject(error)
409
519
  }
410
520
  })
@@ -414,6 +524,7 @@ export class WebRecorder {
414
524
  this.source.disconnect(this.audioWorkletNode) // Disconnect the source from the AudioWorkletNode
415
525
  this.audioWorkletNode.disconnect(this.audioContext.destination) // Disconnect the AudioWorkletNode from the destination
416
526
  this.audioWorkletNode.port.postMessage({ command: 'pause' })
527
+ this.compressedMediaRecorder?.pause()
417
528
  }
418
529
 
419
530
  stopMediaStreamTracks() {
@@ -484,5 +595,40 @@ export class WebRecorder {
484
595
  this.source.connect(this.audioWorkletNode)
485
596
  this.audioWorkletNode.connect(this.audioContext.destination)
486
597
  this.audioWorkletNode.port.postMessage({ command: 'resume' })
598
+ this.compressedMediaRecorder?.resume()
599
+ }
600
+
601
+ private initializeCompressedRecorder() {
602
+ try {
603
+ const mimeType = 'audio/webm;codecs=opus'
604
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
605
+ this.logger?.warn(
606
+ 'Opus compression not supported in this browser'
607
+ )
608
+ return
609
+ }
610
+
611
+ this.compressedMediaRecorder = new MediaRecorder(
612
+ this.source.mediaStream,
613
+ {
614
+ mimeType,
615
+ audioBitsPerSecond:
616
+ this.config.compression?.bitrate ?? 128000,
617
+ }
618
+ )
619
+
620
+ this.compressedMediaRecorder.ondataavailable = (event) => {
621
+ if (event.data.size > 0) {
622
+ this.compressedChunks.push(event.data)
623
+ this.compressedSize += event.data.size
624
+ this.pendingCompressedChunk = event.data
625
+ }
626
+ }
627
+ } catch (error) {
628
+ this.logger?.error(
629
+ 'Failed to initialize compressed recorder:',
630
+ error
631
+ )
632
+ }
487
633
  }
488
634
  }
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(
@@ -7,9 +7,11 @@ import {
7
7
  AudioDataEvent,
8
8
  AudioRecording,
9
9
  AudioStreamStatus,
10
+ CompressionInfo,
10
11
  ConsoleLike,
11
12
  RecordingConfig,
12
13
  StartRecordingResult,
14
+ WebRecordingOptions,
13
15
  } from './ExpoAudioStream.types'
14
16
  import ExpoAudioStreamModule from './ExpoAudioStreamModule'
15
17
  import {
@@ -26,13 +28,14 @@ export interface UseAudioRecorderProps {
26
28
 
27
29
  export interface UseAudioRecorderState {
28
30
  startRecording: (_: RecordingConfig) => Promise<StartRecordingResult>
29
- stopRecording: () => Promise<AudioRecording>
31
+ stopRecording: (options?: WebRecordingOptions) => Promise<AudioRecording>
30
32
  pauseRecording: () => Promise<void>
31
33
  resumeRecording: () => Promise<void>
32
34
  isRecording: boolean
33
35
  isPaused: boolean
34
- durationMs: number // Duration of the recording
35
- size: number // Size in bytes of the recorded audio
36
+ durationMs: number
37
+ size: number
38
+ compression?: CompressionInfo
36
39
  analysisData?: AudioAnalysis
37
40
  }
38
41
 
@@ -41,6 +44,7 @@ interface RecorderReducerState {
41
44
  isPaused: boolean
42
45
  durationMs: number
43
46
  size: number
47
+ compression?: CompressionInfo
44
48
  analysisData?: AudioAnalysis
45
49
  }
46
50
 
@@ -53,7 +57,14 @@ type RecorderAction =
53
57
  isPaused: boolean
54
58
  }
55
59
  }
56
- | { type: 'UPDATE_STATUS'; payload: { durationMs: number; size: number } }
60
+ | {
61
+ type: 'UPDATE_STATUS'
62
+ payload: {
63
+ durationMs: number
64
+ size: number
65
+ compression?: CompressionInfo
66
+ }
67
+ }
57
68
  | { type: 'UPDATE_ANALYSIS'; payload: AudioAnalysis }
58
69
 
59
70
  const defaultAnalysis: AudioAnalysis = {
@@ -84,7 +95,8 @@ function audioRecorderReducer(
84
95
  isPaused: false,
85
96
  durationMs: 0,
86
97
  size: 0,
87
- analysisData: defaultAnalysis, // Reset analysis data
98
+ compression: undefined,
99
+ analysisData: defaultAnalysis,
88
100
  }
89
101
  case 'STOP':
90
102
  return { ...state, isRecording: false, isPaused: false }
@@ -98,12 +110,22 @@ function audioRecorderReducer(
98
110
  isPaused: action.payload.isPaused,
99
111
  isRecording: action.payload.isRecording,
100
112
  }
101
- case 'UPDATE_STATUS':
102
- return {
113
+ case 'UPDATE_STATUS': {
114
+ const newState = {
103
115
  ...state,
104
116
  durationMs: action.payload.durationMs,
105
117
  size: action.payload.size,
118
+ compression: action.payload.compression
119
+ ? {
120
+ size: action.payload.compression.size,
121
+ mimeType: action.payload.compression.mimeType,
122
+ bitrate: action.payload.compression.bitrate,
123
+ format: action.payload.compression.format,
124
+ }
125
+ : undefined,
106
126
  }
127
+ return newState
128
+ }
107
129
  case 'UPDATE_ANALYSIS':
108
130
  return {
109
131
  ...state,
@@ -129,9 +151,12 @@ export function useAudioRecorder({
129
151
  isPaused: false,
130
152
  durationMs: 0,
131
153
  size: 0,
154
+ compression: undefined,
132
155
  analysisData: undefined,
133
156
  })
134
157
 
158
+ const startResultRef = useRef<StartRecordingResult | null>(null)
159
+
135
160
  const analysisListenerRef = useRef<EventSubscription | null>(null)
136
161
  // analysisRef is the current analysis data (last 10 seconds by default)
137
162
  const analysisRef = useRef<AudioAnalysis>({ ...defaultAnalysis })
@@ -261,6 +286,7 @@ export function useAudioRecorder({
261
286
  encoded,
262
287
  mimeType,
263
288
  buffer,
289
+ compression,
264
290
  } = eventData
265
291
  logger?.debug(`[handleAudioEvent] Received audio event:`, {
266
292
  fileUri,
@@ -271,6 +297,7 @@ export function useAudioRecorder({
271
297
  lastEmittedSize,
272
298
  streamUuid,
273
299
  encodedLength: encoded?.length,
300
+ compression,
274
301
  })
275
302
  if (deltaSize === 0) {
276
303
  // Ignore packet with no data
@@ -290,6 +317,21 @@ export function useAudioRecorder({
290
317
  fileUri,
291
318
  eventDataSize: deltaSize,
292
319
  totalSize,
320
+ compression:
321
+ compression && startResultRef.current?.compression
322
+ ? {
323
+ data: compression.data,
324
+ size: compression.totalSize,
325
+ mimeType:
326
+ startResultRef.current.compression
327
+ ?.mimeType,
328
+ bitrate:
329
+ startResultRef.current.compression
330
+ ?.bitrate,
331
+ format: startResultRef.current.compression
332
+ ?.format,
333
+ }
334
+ : undefined,
293
335
  })
294
336
  } else if (buffer) {
295
337
  // Coming from web
@@ -299,6 +341,21 @@ export function useAudioRecorder({
299
341
  fileUri,
300
342
  eventDataSize: deltaSize,
301
343
  totalSize,
344
+ compression:
345
+ compression && startResultRef.current?.compression
346
+ ? {
347
+ data: compression.data,
348
+ size: compression.totalSize,
349
+ mimeType:
350
+ startResultRef.current.compression
351
+ ?.mimeType,
352
+ bitrate:
353
+ startResultRef.current.compression
354
+ ?.bitrate,
355
+ format: startResultRef.current.compression
356
+ ?.format,
357
+ }
358
+ : undefined,
302
359
  }
303
360
  onAudioStreamRef.current?.(webEvent)
304
361
  logger?.debug(
@@ -317,7 +374,8 @@ export function useAudioRecorder({
317
374
  try {
318
375
  const status: AudioStreamStatus = ExpoAudioStream.status()
319
376
  logger?.debug(
320
- `Status: paused: ${status.isPaused} durationMs: ${status.durationMs} size: ${status.size}`
377
+ `Status: paused: ${status.isPaused} durationMs: ${status.durationMs} size: ${status.size}`,
378
+ status.compression
321
379
  )
322
380
 
323
381
  // Check and update recording state
@@ -344,6 +402,7 @@ export function useAudioRecorder({
344
402
  payload: {
345
403
  durationMs: status.durationMs,
346
404
  size: status.size,
405
+ compression: status.compression,
347
406
  },
348
407
  })
349
408
  }
@@ -372,6 +431,8 @@ export function useAudioRecorder({
372
431
  await ExpoAudioStream.startRecording(options)
373
432
  dispatch({ type: 'START' })
374
433
 
434
+ startResultRef.current = startResult
435
+
375
436
  if (enableProcessing) {
376
437
  logger?.debug(`Enabling audio analysis listener`)
377
438
  const listener = addAudioAnalysisListener(
@@ -466,6 +527,7 @@ export function useAudioRecorder({
466
527
  isRecording: state.isRecording,
467
528
  durationMs: state.durationMs,
468
529
  size: state.size,
530
+ compression: state.compression,
469
531
  analysisData: state.analysisData,
470
532
  }
471
533
  }