@siteed/expo-audio-stream 1.8.0 → 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.
@@ -1,7 +1,7 @@
1
1
  // src/WebRecorder.ts
2
2
 
3
3
  import { AudioAnalysis } from './AudioAnalysis/AudioAnalysis.types'
4
- import { ConsoleLike, RecordingConfig, WebRecordingOptions } from './ExpoAudioStream.types'
4
+ import { ConsoleLike, RecordingConfig } from './ExpoAudioStream.types'
5
5
  import {
6
6
  EmitAudioAnalysisFunction,
7
7
  EmitAudioEventFunction,
@@ -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,12 +52,10 @@ export class WebRecorder {
44
52
  private emitAudioEventCallback: EmitAudioEventFunction
45
53
  private emitAudioAnalysisCallback: EmitAudioAnalysisFunction
46
54
  private config: RecordingConfig
47
- private position: number // Track the cumulative position
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
@@ -57,7 +63,7 @@ export class WebRecorder {
57
63
  private compressedChunks: Blob[] = []
58
64
  private compressedSize: number = 0
59
65
  private pendingCompressedChunk: Blob | null = null
60
- private audioChunks: Float32Array[] = []
66
+ private readonly wavMimeType = 'audio/wav'
61
67
 
62
68
  constructor({
63
69
  audioContext,
@@ -82,7 +88,6 @@ export class WebRecorder {
82
88
  this.emitAudioEventCallback = emitAudioEventCallback
83
89
  this.emitAudioAnalysisCallback = emitAudioAnalysisCallback
84
90
  this.config = recordingConfig
85
- this.position = 0
86
91
  this.logger = logger
87
92
 
88
93
  const audioContextFormat = this.checkAudioContextFormat({
@@ -105,10 +110,6 @@ export class WebRecorder {
105
110
  audioContextFormat.bitDepth ||
106
111
  DEFAULT_WEB_BITDEPTH
107
112
 
108
- // Initialize the audio buffer separately
109
- this.audioBuffer = new Float32Array(0)
110
- this.audioBufferSize = 0
111
-
112
113
  this.audioAnalysisData = {
113
114
  amplitudeRange: { min: 0, max: 0 },
114
115
  dataPoints: [],
@@ -155,85 +156,69 @@ export class WebRecorder {
155
156
  event: AudioWorkletEvent
156
157
  ) => {
157
158
  const command = event.data.command
158
- if (command !== 'newData') {
159
- return
160
- }
161
- const pcmBufferFloat = event.data.recordedData
159
+ if (command !== 'newData') return
162
160
 
161
+ const pcmBufferFloat = event.data.recordedData
163
162
  if (!pcmBufferFloat) {
164
163
  this.logger?.warn('Received empty audio buffer', event)
165
164
  return
166
165
  }
167
166
 
168
- // Store chunks instead of concatenating
169
- this.audioChunks.push(pcmBufferFloat)
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 // Calculate duration of the current buffer
175
-
176
- let data: Float32Array
177
- if (this.packetCount === 0) {
178
- // Initialize WAV header
179
- const wavHeaderBuffer = writeWavHeader({
180
- sampleRate: this.audioContext.sampleRate,
181
- numChannels: this.numberOfChannels,
182
- bitDepth: this.exportBitDepth,
183
- })
184
-
185
- // For the first packet, combine WAV header with audio data
186
- const headerFloatArray = new Float32Array(wavHeaderBuffer)
187
- data = new Float32Array(
188
- headerFloatArray.length + this.audioBuffer.length
189
- )
190
- data.set(headerFloatArray, 0)
191
- data.set(this.audioBuffer, headerFloatArray.length)
192
- } else {
193
- // For subsequent packets, just send the new audio data
194
- data = pcmBufferFloat
195
- }
196
-
197
- // Track the number of packets
198
- this.packetCount += 1
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,
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
+ )
210
199
  }
211
- this.pendingCompressedChunk = null
200
+
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
+ })
212
218
  }
213
219
 
214
- this.emitAudioEventCallback({
215
- data: pcmBufferFloat,
216
- position: this.position,
217
- compression: compressionData,
218
- })
219
- this.position += duration // Update position
220
-
221
- this.featureExtractorWorker?.postMessage(
222
- {
223
- command: 'process',
224
- channelData: pcmBufferFloat,
225
- sampleRate,
226
- pointsPerSecond:
227
- this.config.pointsPerSecond ||
228
- DEFAULT_WEB_POINTS_PER_SECOND,
229
- algorithm: this.config.algorithm || 'rms',
230
- bitDepth: this.bitDepth,
231
- fullAudioDurationMs: this.position * 1000,
232
- numberOfChannels: this.numberOfChannels,
233
- features: this.config.features,
234
- },
235
- []
236
- )
220
+ this.position += duration
221
+ this.pendingCompressedChunk = null
237
222
  }
238
223
 
239
224
  this.logger?.debug(
@@ -242,7 +227,7 @@ export class WebRecorder {
242
227
  )
243
228
  this.audioWorkletNode.port.postMessage({
244
229
  command: 'init',
245
- recordSampleRate: this.audioContext.sampleRate, // Pass the original sample rate
230
+ recordSampleRate: this.audioContext.sampleRate,
246
231
  exportSampleRate:
247
232
  this.config.sampleRate ?? this.audioContext.sampleRate,
248
233
  bitDepth: this.bitDepth,
@@ -352,171 +337,140 @@ export class WebRecorder {
352
337
  }
353
338
  }
354
339
 
355
- async stop(options?: WebRecordingOptions): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
356
- return new Promise((resolve, reject) => {
357
- let isCleanupDone = false
358
-
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()
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
+ }),
371
349
  }
372
-
373
- this.stopMediaStreamTracks()
374
350
  }
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
- }
351
+ return { pcmData: new Float32Array() }
352
+ } finally {
353
+ this.cleanup()
354
+ }
355
+ }
394
356
 
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
- })
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
+ }
401
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
+ // 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
+ }
408
393
 
409
- this.logger?.debug(
410
- `Processing ${this.audioChunks.length} chunks in batches of ${CHUNKS_PER_BATCH}`
411
- )
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
+ )
412
400
 
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
- }
401
+ if (!this.compressedMediaRecorder) {
402
+ this.logger?.debug('[Performance] No compressed recorder to stop')
403
+ return Promise.resolve(undefined)
404
+ }
446
405
 
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(
452
- 'message',
453
- onMessage
454
- )
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
+ }
455
419
 
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
- }
493
- })
494
- }
495
- }
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
+ }
496
431
 
497
- this.audioWorkletNode.port.addEventListener(
432
+ return new Promise((resolve) => {
433
+ const onMessage = (event: AudioWorkletEvent) => {
434
+ if (event.data.command === 'recordedData') {
435
+ this.audioWorkletNode?.port.removeEventListener(
498
436
  'message',
499
437
  onMessage
500
438
  )
501
- this.audioWorkletNode.port.postMessage({ command: 'stop' })
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
+ }
502
446
 
503
- // Safety timeout
504
- setTimeout(() => {
505
- this.audioWorkletNode?.port.removeEventListener(
506
- 'message',
507
- onMessage
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`
508
466
  )
509
- cleanup()
510
- reject(new Error('Timeout while stopping recording'))
511
- }, 5000)
512
- } else if (!this.compressedMediaRecorder) {
513
- cleanup()
514
- resolve({ pcmData: this.audioBuffer })
467
+ resolve(rawPCMDataFull)
468
+ }
515
469
  }
516
- } catch (error) {
517
- cleanup()
518
- reject(error)
519
470
  }
471
+
472
+ this.audioWorkletNode.port.addEventListener('message', onMessage)
473
+ this.audioWorkletNode.port.postMessage({ command: 'stop' })
520
474
  })
521
475
  }
522
476
 
@@ -11,7 +11,6 @@ import {
11
11
  ConsoleLike,
12
12
  RecordingConfig,
13
13
  StartRecordingResult,
14
- WebRecordingOptions,
15
14
  } from './ExpoAudioStream.types'
16
15
  import ExpoAudioStreamModule from './ExpoAudioStreamModule'
17
16
  import {
@@ -28,7 +27,7 @@ export interface UseAudioRecorderProps {
28
27
 
29
28
  export interface UseAudioRecorderState {
30
29
  startRecording: (_: RecordingConfig) => Promise<StartRecordingResult>
31
- stopRecording: (options?: WebRecordingOptions) => Promise<AudioRecording>
30
+ stopRecording: () => Promise<AudioRecording>
32
31
  pauseRecording: () => Promise<void>
33
32
  resumeRecording: () => Promise<void>
34
33
  isRecording: boolean