@siteed/expo-audio-studio 2.5.0 → 2.6.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.
@@ -7,6 +7,7 @@ import {
7
7
  EmitAudioEventFunction,
8
8
  } from './ExpoAudioStream.web'
9
9
  import { encodingToBitDepth } from './utils/encodingToBitDepth'
10
+ import { writeWavHeader } from './utils/writeWavHeader'
10
11
  import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web'
11
12
  import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web'
12
13
 
@@ -16,6 +17,7 @@ interface AudioWorkletEvent {
16
17
  recordedData?: Float32Array
17
18
  sampleRate?: number
18
19
  position?: number
20
+ message?: string // For debug messages
19
21
  }
20
22
  }
21
23
 
@@ -60,6 +62,8 @@ export class WebRecorder {
60
62
  timestamp: number
61
63
  }) => void
62
64
  private _isDeviceDisconnected: boolean = false
65
+ private pcmData: Float32Array | null = null // Store original PCM data
66
+ private totalSampleCount: number = 0
63
67
 
64
68
  /**
65
69
  * Flag to indicate whether this is the first audio chunk after a device switch
@@ -183,6 +187,11 @@ export class WebRecorder {
183
187
  event: AudioWorkletEvent
184
188
  ) => {
185
189
  const command = event.data.command
190
+ if (command === 'debug') {
191
+ this.logger?.debug(`[AudioWorklet] ${event.data.message}`)
192
+ return
193
+ }
194
+
186
195
  if (command !== 'newData') return
187
196
 
188
197
  const pcmBufferFloat = event.data.recordedData
@@ -192,9 +201,11 @@ export class WebRecorder {
192
201
  }
193
202
 
194
203
  // Process data in smaller chunks and emit immediately
195
- const chunkSize = this.audioContext.sampleRate * 2 // Reduce to 2 seconds chunks
196
204
  const sampleRate =
197
205
  event.data.sampleRate ?? this.audioContext.sampleRate
206
+ // Use chunk size from config interval or default to 2 seconds
207
+ const intervalMs = this.config.interval ?? DEFAULT_WEB_INTERVAL
208
+ const chunkSize = Math.floor(sampleRate * (intervalMs / 1000))
198
209
  const duration = pcmBufferFloat.length / sampleRate
199
210
 
200
211
  // Use incoming position if provided by worklet, otherwise use our tracked position
@@ -241,6 +252,17 @@ export class WebRecorder {
241
252
  })
242
253
  }
243
254
 
255
+ // Only store PCM data if web.storeUncompressedAudio is not explicitly false
256
+ const shouldStoreUncompressed =
257
+ this.config.web?.storeUncompressedAudio !== false
258
+
259
+ // Store PCM chunks when needed
260
+ if (shouldStoreUncompressed) {
261
+ // Store the original Float32Array data for later WAV creation
262
+ this.appendPcmData(chunk)
263
+ this.totalSampleCount += chunk.length
264
+ }
265
+
244
266
  // Emit chunk immediately
245
267
  this.emitAudioEventCallback({
246
268
  data: chunk,
@@ -265,21 +287,42 @@ export class WebRecorder {
265
287
  this.pendingCompressedChunk = null
266
288
  }
267
289
 
268
- this.logger?.debug(
269
- `WebRecorder initialized -- recordSampleRate=${this.audioContext.sampleRate}, startPosition=${this.position}`,
270
- this.config
271
- )
290
+ // Ensure we use all relevant settings from config
291
+ const recordSampleRate = this.audioContext.sampleRate
292
+ const exportSampleRate =
293
+ this.config.sampleRate ?? this.audioContext.sampleRate
294
+ const channels = this.config.channels ?? this.numberOfChannels
295
+ const interval = this.config.interval ?? DEFAULT_WEB_INTERVAL
296
+
297
+ this.logger?.debug(`WebRecorder initialized with config:`, {
298
+ recordSampleRate,
299
+ exportSampleRate,
300
+ bitDepth: this.bitDepth,
301
+ exportBitDepth: this.exportBitDepth,
302
+ channels,
303
+ interval,
304
+ position: this.position,
305
+ deviceId: this.config.deviceId || 'default',
306
+ compression: this.config.compression
307
+ ? {
308
+ enabled: this.config.compression.enabled,
309
+ format: this.config.compression.format,
310
+ bitrate: this.config.compression.bitrate,
311
+ }
312
+ : 'disabled',
313
+ })
314
+
315
+ // Initialize the worklet with all settings from config
272
316
  this.audioWorkletNode.port.postMessage({
273
317
  command: 'init',
274
- recordSampleRate: this.audioContext.sampleRate,
275
- exportSampleRate:
276
- this.config.sampleRate ?? this.audioContext.sampleRate,
318
+ recordSampleRate,
319
+ exportSampleRate,
277
320
  bitDepth: this.bitDepth,
278
321
  exportBitDepth: this.exportBitDepth,
279
- channels: this.numberOfChannels,
280
- interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
322
+ channels,
323
+ interval,
281
324
  position: this.position, // Pass the current position to the processor
282
- // enableLogging: !!this.logger,
325
+ enableLogging: true,
283
326
  })
284
327
 
285
328
  // Connect the source to the AudioWorkletNode and start recording
@@ -290,6 +333,35 @@ export class WebRecorder {
290
333
  }
291
334
  }
292
335
 
336
+ /**
337
+ * Append new PCM data to the existing buffer
338
+ * @param newData New Float32Array data to append
339
+ */
340
+ private appendPcmData(newData: Float32Array): void {
341
+ // Clone the incoming data to ensure it's not modified
342
+ const dataToAdd = new Float32Array(newData)
343
+
344
+ if (!this.pcmData) {
345
+ // First chunk - create a copy to avoid references to original data
346
+ this.pcmData = new Float32Array(dataToAdd)
347
+ return
348
+ }
349
+
350
+ // Create a new buffer with increased size
351
+ const newBuffer = new Float32Array(
352
+ this.pcmData.length + dataToAdd.length
353
+ )
354
+
355
+ // Copy existing data
356
+ newBuffer.set(this.pcmData)
357
+
358
+ // Append new data
359
+ newBuffer.set(dataToAdd, this.pcmData.length)
360
+
361
+ // Replace existing buffer
362
+ this.pcmData = newBuffer
363
+ }
364
+
293
365
  /**
294
366
  * Initializes the feature extractor worker for audio analysis
295
367
  * Creates an inline worker from a blob for audio feature extraction
@@ -492,6 +564,10 @@ export class WebRecorder {
492
564
  )
493
565
  this.resetDataPointCounter(0) // Explicitly reset to 0 for new recordings
494
566
  this.isFirstChunkAfterSwitch = false
567
+
568
+ // Clear PCM data for new recording
569
+ this.pcmData = null
570
+ this.totalSampleCount = 0
495
571
  } else {
496
572
  this.logger?.debug(
497
573
  `Preserving counter at ${this.dataPointIdCounter} during device switch`
@@ -504,17 +580,55 @@ export class WebRecorder {
504
580
  }
505
581
 
506
582
  /**
507
- * Stops the audio recording process and returns the recorded data
508
- * @param externalAudioChunks Optional array of Float32Array chunks from previous devices
509
- * @returns Promise resolving to an object containing PCM data and optional compressed blob
583
+ * Creates a WAV file from the stored PCM data
510
584
  */
511
- async stop(
512
- externalAudioChunks?: Float32Array[]
513
- ): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
585
+ private createWavFromPcmData(): Blob | null {
514
586
  try {
515
- // Log what's happening for debugging
516
- this.logger?.debug('Stopping recording and collecting final data')
587
+ // Check if we have PCM data
588
+ if (!this.pcmData || this.pcmData.length === 0) {
589
+ this.logger?.warn('No PCM data available to create WAV file')
590
+ return null
591
+ }
592
+
593
+ const sampleRate =
594
+ this.config.sampleRate || this.audioContext.sampleRate
595
+ const channels = this.numberOfChannels || 1
596
+
597
+ // Convert float32 PCM data to 16-bit PCM for WAV
598
+ const bytesPerSample = 2 // 16-bit = 2 bytes
599
+ const dataLength = this.pcmData.length * bytesPerSample
600
+ const buffer = new ArrayBuffer(dataLength)
601
+ const view = new DataView(buffer)
602
+
603
+ // Convert Float32Array (-1 to 1) to Int16Array (-32768 to 32767)
604
+ for (let i = 0; i < this.pcmData.length; i++) {
605
+ const sample = Math.max(-1, Math.min(1, this.pcmData[i]))
606
+ const int16Value = Math.round(sample * 32767)
607
+ view.setInt16(i * 2, int16Value, true)
608
+ }
609
+
610
+ // Use the existing writeWavHeader utility to add a WAV header
611
+ const wavBuffer = writeWavHeader({
612
+ buffer,
613
+ sampleRate,
614
+ numChannels: channels,
615
+ bitDepth: 16,
616
+ isFloat: false,
617
+ })
517
618
 
619
+ return new Blob([wavBuffer], { type: 'audio/wav' })
620
+ } catch (error) {
621
+ this.logger?.error('Error creating WAV file from PCM data:', error)
622
+ return null
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Stops the audio recording process and returns the recorded data
628
+ * @returns Promise resolving to an object containing compressed and/or uncompressed blobs
629
+ */
630
+ async stop(): Promise<{ compressedBlob?: Blob; uncompressedBlob?: Blob }> {
631
+ try {
518
632
  // Stop any compressed recording first
519
633
  if (
520
634
  this.compressedMediaRecorder &&
@@ -529,15 +643,24 @@ export class WebRecorder {
529
643
  await new Promise((resolve) => setTimeout(resolve, 100))
530
644
  }
531
645
 
532
- // Return the compressed blob if available
646
+ // Create uncompressed WAV file from the PCM data
647
+ let uncompressedBlob: Blob | undefined
648
+
649
+ // Only create WAV if we have PCM data
650
+ if (this.pcmData && this.pcmData.length > 0) {
651
+ uncompressedBlob =
652
+ (await this.createWavFromPcmData()) || undefined
653
+ }
654
+
655
+ // Return the compressed and/or uncompressed blobs if available
533
656
  return {
534
- pcmData: new Float32Array(), // Return empty array since we're streaming
535
657
  compressedBlob:
536
658
  this.compressedChunks.length > 0
537
659
  ? new Blob(this.compressedChunks, {
538
660
  type: 'audio/webm;codecs=opus',
539
661
  })
540
662
  : undefined,
663
+ uncompressedBlob,
541
664
  }
542
665
  } finally {
543
666
  this.cleanup()
@@ -545,6 +668,8 @@ export class WebRecorder {
545
668
  this.compressedChunks = []
546
669
  this.compressedSize = 0
547
670
  this.pendingCompressedChunk = null
671
+ this.pcmData = null
672
+ this.totalSampleCount = 0
548
673
  }
549
674
  }
550
675
 
@@ -12,6 +12,8 @@ export interface WavHeaderOptions {
12
12
  numChannels: number
13
13
  /** The bit depth of the audio (e.g., 16, 24, or 32). */
14
14
  bitDepth: number
15
+ /** Whether the audio data is in float format (only applies to 32-bit) */
16
+ isFloat?: boolean
15
17
  }
16
18
 
17
19
  /**
@@ -30,30 +32,17 @@ export interface WavHeaderOptions {
30
32
  * @returns An ArrayBuffer containing the WAV header, or the header combined with the provided audio data.
31
33
  *
32
34
  * @throws {Error} Throws an error if the provided options are invalid or if the buffer is too small.
33
- *
34
- * @example
35
- * // Create a standalone WAV header
36
- * const header = writeWavHeader({
37
- * sampleRate: 44100,
38
- * numChannels: 2,
39
- * bitDepth: 16
40
- * });
41
- *
42
- * @example
43
- * // Create a WAV header and combine it with audio data
44
- * const completeWav = writeWavHeader({
45
- * buffer: audioData,
46
- * sampleRate: 44100,
47
- * numChannels: 2,
48
- * bitDepth: 16
49
- * });
50
35
  */
51
36
  export const writeWavHeader = ({
52
37
  buffer,
53
38
  sampleRate,
54
39
  numChannels,
55
40
  bitDepth,
41
+ isFloat = bitDepth === 32, // Default to float for 32-bit
56
42
  }: WavHeaderOptions): ArrayBuffer => {
43
+ // For 32-bit float, we use format 3, otherwise format 1 for PCM
44
+ const audioFormat = isFloat ? 3 : 1 // 3 = IEEE float, 1 = PCM
45
+
57
46
  const bytesPerSample = bitDepth / 8
58
47
  const blockAlign = numChannels * bytesPerSample
59
48
  const byteRate = sampleRate * blockAlign
@@ -67,22 +56,30 @@ export const writeWavHeader = ({
67
56
 
68
57
  // Function to write or update the header
69
58
  const writeHeader = (view: DataView, dataSize: number = 0xffffffff) => {
59
+ // RIFF chunk descriptor
70
60
  writeString(view, 0, 'RIFF') // ChunkID
71
- view.setUint32(4, 36 + dataSize, true) // ChunkSize
61
+ view.setUint32(4, 36 + dataSize, true) // ChunkSize: 4 + (8 + 16) + (8 + dataSize)
72
62
  writeString(view, 8, 'WAVE') // Format
63
+
64
+ // "fmt " sub-chunk
73
65
  writeString(view, 12, 'fmt ') // Subchunk1ID
74
- view.setUint32(16, 16, true) // Subchunk1Size (16 for PCM)
75
- view.setUint16(20, bitDepth === 32 ? 3 : 1, true) // AudioFormat (3 for float, 1 for PCM)
66
+ view.setUint32(16, 16, true) // Subchunk1Size (16 for PCM/Float)
67
+ view.setUint16(20, audioFormat, true) // AudioFormat (3 for float, 1 for PCM)
76
68
  view.setUint16(22, numChannels, true) // NumChannels
77
69
  view.setUint32(24, sampleRate, true) // SampleRate
78
- view.setUint32(28, byteRate, true) // ByteRate
79
- view.setUint16(32, blockAlign, true) // BlockAlign
70
+ view.setUint32(28, byteRate, true) // ByteRate = SampleRate * NumChannels * BitsPerSample/8
71
+ view.setUint16(32, blockAlign, true) // BlockAlign = NumChannels * BitsPerSample/8
80
72
  view.setUint16(34, bitDepth, true) // BitsPerSample
73
+
74
+ // "data" sub-chunk
81
75
  writeString(view, 36, 'data') // Subchunk2ID
82
- view.setUint32(40, dataSize, true) // Subchunk2Size
76
+ view.setUint32(40, dataSize, true) // Subchunk2Size = NumSamples * NumChannels * BitsPerSample/8
83
77
  }
84
78
 
85
79
  if (buffer) {
80
+ // Handle existing buffer
81
+
82
+ // Check for minimum size
86
83
  if (buffer.byteLength < 44) {
87
84
  throw new Error('Buffer is too small to contain a valid WAV header')
88
85
  }
@@ -97,15 +94,19 @@ export const writeWavHeader = ({
97
94
  writeHeader(view, buffer.byteLength - 44)
98
95
  return buffer
99
96
  } else {
100
- // Combine the new header with the existing buffer
97
+ // Create a new buffer with header + data
101
98
  const newBuffer = new ArrayBuffer(44 + buffer.byteLength)
102
99
  const newView = new DataView(newBuffer)
100
+
101
+ // Write header to new buffer
103
102
  writeHeader(newView, buffer.byteLength)
103
+
104
+ // Copy audio data after header
104
105
  new Uint8Array(newBuffer).set(new Uint8Array(buffer), 44)
105
106
  return newBuffer
106
107
  }
107
108
  } else {
108
- // Create a standalone header
109
+ // Create standalone header
109
110
  const headerBuffer = new ArrayBuffer(44)
110
111
  const view = new DataView(headerBuffer)
111
112
  writeHeader(view)