@siteed/expo-audio-studio 2.5.0 → 2.6.1
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 +9 -1
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +3 -3
- 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 +19 -1
- 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/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +1 -1
- package/build/useAudioRecorder.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/ios/AudioDeviceManager.swift +65 -65
- package/ios/AudioProcessor.swift +32 -32
- package/ios/AudioStreamManager.swift +323 -158
- package/ios/ExpoAudioStreamModule.swift +92 -75
- package/ios/ISSUE_IOS.md +26 -3
- package/ios/Logger.swift +27 -7
- package/package.json +1 -1
- package/src/AudioDeviceManager.ts +1 -1
- package/src/ExpoAudioStream.types.ts +21 -1
- package/src/ExpoAudioStream.web.ts +99 -9
- package/src/WebRecorder.web.ts +146 -21
- package/src/useAudioRecorder.tsx +1 -2
- package/src/utils/writeWavHeader.ts +26 -25
|
@@ -207,6 +207,19 @@ export interface IOSConfig {
|
|
|
207
207
|
audioSession?: AudioSessionConfig
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** Web platform specific configuration options */
|
|
211
|
+
export interface WebConfig {
|
|
212
|
+
/**
|
|
213
|
+
* Whether to store uncompressed audio data for WAV generation
|
|
214
|
+
*
|
|
215
|
+
* When true, all PCM chunks are stored in memory to create a WAV file when compression is disabled
|
|
216
|
+
* When false, uncompressed audio won't be available, but memory usage will be lower
|
|
217
|
+
*
|
|
218
|
+
* Default: true (for backward compatibility)
|
|
219
|
+
*/
|
|
220
|
+
storeUncompressedAudio?: boolean
|
|
221
|
+
}
|
|
222
|
+
|
|
210
223
|
// Add new type for interruption reasons
|
|
211
224
|
export type RecordingInterruptionReason =
|
|
212
225
|
/** Audio focus was lost to another app */
|
|
@@ -312,6 +325,9 @@ export interface RecordingConfig {
|
|
|
312
325
|
/** iOS-specific configuration */
|
|
313
326
|
ios?: IOSConfig
|
|
314
327
|
|
|
328
|
+
/** Web-specific configuration options */
|
|
329
|
+
web?: WebConfig
|
|
330
|
+
|
|
315
331
|
/** Duration of each segment in milliseconds for analysis (default: 100) */
|
|
316
332
|
segmentDurationMs?: number
|
|
317
333
|
|
|
@@ -328,7 +344,11 @@ export interface RecordingConfig {
|
|
|
328
344
|
compression?: {
|
|
329
345
|
/** Enable audio compression */
|
|
330
346
|
enabled: boolean
|
|
331
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Format for compression
|
|
349
|
+
* - 'aac': Advanced Audio Coding - supported on all platforms
|
|
350
|
+
* - 'opus': Opus encoding - supported on Android and Web; on iOS will automatically fall back to AAC
|
|
351
|
+
*/
|
|
332
352
|
format: 'aac' | 'opus'
|
|
333
353
|
/** Bitrate for compression in bits per second */
|
|
334
354
|
bitrate?: number
|
|
@@ -113,7 +113,56 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
113
113
|
// Utility to handle user media stream
|
|
114
114
|
async getMediaStream() {
|
|
115
115
|
try {
|
|
116
|
-
|
|
116
|
+
this.logger?.debug('Requesting user media (microphone)...')
|
|
117
|
+
|
|
118
|
+
// First check if the browser supports the necessary audio APIs
|
|
119
|
+
if (!navigator?.mediaDevices?.getUserMedia) {
|
|
120
|
+
this.logger?.error(
|
|
121
|
+
'Browser does not support mediaDevices.getUserMedia'
|
|
122
|
+
)
|
|
123
|
+
throw new Error('Browser does not support audio recording')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Get media with detailed audio constraints for better diagnostics
|
|
127
|
+
const constraints = {
|
|
128
|
+
audio: {
|
|
129
|
+
echoCancellation: true,
|
|
130
|
+
noiseSuppression: true,
|
|
131
|
+
autoGainControl: true,
|
|
132
|
+
// Add deviceId constraint if specified
|
|
133
|
+
...(this.recordingConfig?.deviceId
|
|
134
|
+
? {
|
|
135
|
+
deviceId: {
|
|
136
|
+
exact: this.recordingConfig.deviceId,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.logger?.debug('Media constraints:', constraints)
|
|
144
|
+
|
|
145
|
+
const stream =
|
|
146
|
+
await navigator.mediaDevices.getUserMedia(constraints)
|
|
147
|
+
|
|
148
|
+
// Get detailed info about the audio track for debugging
|
|
149
|
+
const audioTracks = stream.getAudioTracks()
|
|
150
|
+
if (audioTracks.length > 0) {
|
|
151
|
+
const track = audioTracks[0]
|
|
152
|
+
const settings = track.getSettings()
|
|
153
|
+
this.logger?.debug('Audio track obtained:', {
|
|
154
|
+
label: track.label,
|
|
155
|
+
id: track.id,
|
|
156
|
+
enabled: track.enabled,
|
|
157
|
+
muted: track.muted,
|
|
158
|
+
readyState: track.readyState,
|
|
159
|
+
settings,
|
|
160
|
+
})
|
|
161
|
+
} else {
|
|
162
|
+
this.logger?.warn('Stream has no audio tracks!')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return stream
|
|
117
166
|
} catch (error) {
|
|
118
167
|
this.logger?.error('Failed to get media stream:', error)
|
|
119
168
|
throw error
|
|
@@ -400,7 +449,8 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
400
449
|
this.logger?.debug('Starting stop process')
|
|
401
450
|
|
|
402
451
|
try {
|
|
403
|
-
const { compressedBlob } =
|
|
452
|
+
const { compressedBlob, uncompressedBlob } =
|
|
453
|
+
await this.customRecorder.stop()
|
|
404
454
|
|
|
405
455
|
this.isRecording = false
|
|
406
456
|
this.isPaused = false
|
|
@@ -409,20 +459,52 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
409
459
|
let fileUri = `${this.streamUuid}.${this.extension}`
|
|
410
460
|
let mimeType = `audio/${this.extension}`
|
|
411
461
|
|
|
412
|
-
//
|
|
413
|
-
|
|
462
|
+
// Handle both compressed and uncompressed blobs according to configuration
|
|
463
|
+
const compressionEnabled =
|
|
464
|
+
this.recordingConfig?.compression?.enabled ?? false
|
|
465
|
+
|
|
466
|
+
// Process compressed blob if available
|
|
467
|
+
if (compressedBlob) {
|
|
414
468
|
const compressedUri = URL.createObjectURL(compressedBlob)
|
|
415
|
-
|
|
469
|
+
const compressedInfo = {
|
|
416
470
|
compressedFileUri: compressedUri,
|
|
417
471
|
size: compressedBlob.size,
|
|
418
472
|
mimeType: 'audio/webm',
|
|
419
473
|
format: 'opus',
|
|
420
|
-
bitrate:
|
|
474
|
+
bitrate:
|
|
475
|
+
this.recordingConfig?.compression?.bitrate ?? 128000,
|
|
421
476
|
}
|
|
422
477
|
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
478
|
+
// If compression is enabled, use compressed blob as primary format
|
|
479
|
+
if (compressionEnabled) {
|
|
480
|
+
this.logger?.debug(
|
|
481
|
+
'Using compressed audio as primary output'
|
|
482
|
+
)
|
|
483
|
+
fileUri = compressedUri
|
|
484
|
+
mimeType = 'audio/webm'
|
|
485
|
+
|
|
486
|
+
// Store compression info
|
|
487
|
+
compression = compressedInfo
|
|
488
|
+
} else {
|
|
489
|
+
// Compression was enabled during recording but not set as primary
|
|
490
|
+
// Store as alternate format
|
|
491
|
+
compression = compressedInfo
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Process uncompressed WAV if available
|
|
496
|
+
if (uncompressedBlob) {
|
|
497
|
+
const wavUri = URL.createObjectURL(uncompressedBlob)
|
|
498
|
+
|
|
499
|
+
// If compression is disabled or no compressed blob is available,
|
|
500
|
+
// use WAV as primary format
|
|
501
|
+
if (!compressionEnabled || !compressedBlob) {
|
|
502
|
+
this.logger?.debug(
|
|
503
|
+
'Using uncompressed WAV as primary output'
|
|
504
|
+
)
|
|
505
|
+
fileUri = wavUri
|
|
506
|
+
mimeType = 'audio/wav'
|
|
507
|
+
}
|
|
426
508
|
}
|
|
427
509
|
|
|
428
510
|
// Use the stored streamUuid for the final filename
|
|
@@ -443,6 +525,14 @@ export class ExpoAudioStreamWeb extends LegacyEventEmitter {
|
|
|
443
525
|
// Reset after creating the result
|
|
444
526
|
this.streamUuid = null
|
|
445
527
|
|
|
528
|
+
// Reset recording state variables to prepare for next recording
|
|
529
|
+
this.currentDurationMs = 0
|
|
530
|
+
this.currentSize = 0
|
|
531
|
+
this.lastEmittedSize = 0
|
|
532
|
+
this.totalCompressedSize = 0
|
|
533
|
+
this.lastEmittedCompressionSize = 0
|
|
534
|
+
this.audioChunks = []
|
|
535
|
+
|
|
446
536
|
return result
|
|
447
537
|
} catch (error) {
|
|
448
538
|
this.logger?.error('Error stopping recording:', error)
|
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
|
|
package/src/useAudioRecorder.tsx
CHANGED
|
@@ -212,8 +212,7 @@ export function useAudioRecorder({
|
|
|
212
212
|
const maxDuration = visualizationDuration
|
|
213
213
|
|
|
214
214
|
logger?.debug(
|
|
215
|
-
`[handleAudioAnalysis] Received audio analysis: maxDuration=${maxDuration} analysis.dataPoints=${analysis.dataPoints.length} analysisData.dataPoints=${savedAnalysisData.dataPoints.length}
|
|
216
|
-
analysis
|
|
215
|
+
`[handleAudioAnalysis] Received audio analysis: maxDuration=${maxDuration} analysis.dataPoints=${analysis.dataPoints.length} analysisData.dataPoints=${savedAnalysisData.dataPoints.length}`
|
|
217
216
|
)
|
|
218
217
|
|
|
219
218
|
// Combine data points
|
|
@@ -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)
|