@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.
- package/CHANGELOG.md +5 -1
- package/build/AudioDeviceManager.d.ts +1 -1
- package/build/AudioDeviceManager.js +1 -1
- package/build/AudioDeviceManager.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +14 -0
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +80 -9
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/WebRecorder.web.d.ts +14 -4
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +121 -14
- package/build/WebRecorder.web.js.map +1 -1
- package/build/utils/writeWavHeader.d.ts +3 -18
- package/build/utils/writeWavHeader.d.ts.map +1 -1
- package/build/utils/writeWavHeader.js +19 -26
- package/build/utils/writeWavHeader.js.map +1 -1
- package/package.json +1 -1
- package/src/AudioDeviceManager.ts +1 -1
- package/src/ExpoAudioStream.types.ts +16 -0
- package/src/ExpoAudioStream.web.ts +99 -9
- package/src/WebRecorder.web.ts +146 -21
- package/src/utils/writeWavHeader.ts +26 -25
package/src/WebRecorder.web.ts
CHANGED
|
@@ -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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
|
280
|
-
interval
|
|
322
|
+
channels,
|
|
323
|
+
interval,
|
|
281
324
|
position: this.position, // Pass the current position to the processor
|
|
282
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
512
|
-
externalAudioChunks?: Float32Array[]
|
|
513
|
-
): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
|
|
585
|
+
private createWavFromPcmData(): Blob | null {
|
|
514
586
|
try {
|
|
515
|
-
//
|
|
516
|
-
this.
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
109
|
+
// Create standalone header
|
|
109
110
|
const headerBuffer = new ArrayBuffer(44)
|
|
110
111
|
const view = new DataView(headerBuffer)
|
|
111
112
|
writeHeader(view)
|