@siteed/expo-audio-stream 2.0.1 → 2.1.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 (55) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +202 -1
  3. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +300 -1
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +16 -2
  5. package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +1099 -0
  6. package/android/src/main/java/net/siteed/audiostream/Constants.kt +1 -0
  7. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +274 -44
  8. package/build/AudioAnalysis/AudioAnalysis.types.d.ts +35 -0
  9. package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  10. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  11. package/build/AudioAnalysis/extractAudioAnalysis.d.ts +2 -12
  12. package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -1
  13. package/build/AudioAnalysis/extractAudioAnalysis.js +0 -26
  14. package/build/AudioAnalysis/extractAudioAnalysis.js.map +1 -1
  15. package/build/AudioAnalysis/extractAudioData.d.ts +3 -0
  16. package/build/AudioAnalysis/extractAudioData.d.ts.map +1 -0
  17. package/build/AudioAnalysis/extractAudioData.js +5 -0
  18. package/build/AudioAnalysis/extractAudioData.js.map +1 -0
  19. package/build/AudioAnalysis/extractMelSpectrogram.d.ts +14 -0
  20. package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +1 -0
  21. package/build/AudioAnalysis/extractMelSpectrogram.js +85 -0
  22. package/build/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
  23. package/build/AudioAnalysis/extractPreview.d.ts +11 -0
  24. package/build/AudioAnalysis/extractPreview.d.ts.map +1 -0
  25. package/build/AudioAnalysis/extractPreview.js +25 -0
  26. package/build/AudioAnalysis/extractPreview.js.map +1 -0
  27. package/build/ExpoAudioStream.types.d.ts +329 -3
  28. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  29. package/build/ExpoAudioStream.types.js.map +1 -1
  30. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  31. package/build/ExpoAudioStreamModule.js +455 -1
  32. package/build/ExpoAudioStreamModule.js.map +1 -1
  33. package/build/WebRecorder.web.js +2 -2
  34. package/build/WebRecorder.web.js.map +1 -1
  35. package/build/index.d.ts +6 -3
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +6 -2
  38. package/build/index.js.map +1 -1
  39. package/build/trimAudio.d.ts +25 -0
  40. package/build/trimAudio.d.ts.map +1 -0
  41. package/build/trimAudio.js +67 -0
  42. package/build/trimAudio.js.map +1 -0
  43. package/ios/AudioProcessor.swift +536 -81
  44. package/ios/ExpoAudioStreamModule.swift +125 -18
  45. package/package.json +1 -1
  46. package/src/AudioAnalysis/AudioAnalysis.types.ts +38 -1
  47. package/src/AudioAnalysis/extractAudioAnalysis.ts +1 -38
  48. package/src/AudioAnalysis/extractAudioData.ts +6 -0
  49. package/src/AudioAnalysis/extractMelSpectrogram.ts +144 -0
  50. package/src/AudioAnalysis/extractPreview.ts +34 -0
  51. package/src/ExpoAudioStream.types.ts +354 -42
  52. package/src/ExpoAudioStreamModule.ts +682 -1
  53. package/src/WebRecorder.web.ts +2 -2
  54. package/src/index.ts +7 -8
  55. package/src/trimAudio.ts +90 -0
@@ -6,6 +6,8 @@ import {
6
6
  ExtractAudioDataOptions,
7
7
  ExtractedAudioData,
8
8
  BitDepth,
9
+ TrimAudioOptions,
10
+ TrimAudioResult,
9
11
  } from './ExpoAudioStream.types'
10
12
  import {
11
13
  ExpoAudioStreamWeb,
@@ -279,7 +281,686 @@ if (Platform.OS === 'web') {
279
281
  throw error
280
282
  }
281
283
  }
282
- } else {
284
+
285
+ ExpoAudioStreamModule.trimAudio = async (
286
+ options: TrimAudioOptions
287
+ ): Promise<TrimAudioResult> => {
288
+ try {
289
+ const startTime = performance.now()
290
+ const {
291
+ fileUri,
292
+ mode = 'single',
293
+ startTimeMs,
294
+ endTimeMs,
295
+ ranges,
296
+ outputFileName,
297
+ outputFormat,
298
+ } = options
299
+
300
+ // Validate inputs
301
+ if (!fileUri) {
302
+ throw new Error('fileUri is required')
303
+ }
304
+
305
+ if (
306
+ mode === 'single' &&
307
+ startTimeMs === undefined &&
308
+ endTimeMs === undefined
309
+ ) {
310
+ throw new Error(
311
+ 'At least one of startTimeMs or endTimeMs must be provided in single mode'
312
+ )
313
+ }
314
+
315
+ if (
316
+ (mode === 'keep' || mode === 'remove') &&
317
+ (!ranges || ranges.length === 0)
318
+ ) {
319
+ throw new Error(
320
+ 'ranges must be provided and non-empty for keep or remove modes'
321
+ )
322
+ }
323
+
324
+ // Create AudioContext
325
+ const audioContext = new (window.AudioContext ||
326
+ (window as any).webkitAudioContext)()
327
+
328
+ // First, load the entire audio file to get its properties
329
+ const response = await fetch(fileUri)
330
+ const arrayBuffer = await response.arrayBuffer()
331
+ const originalAudioBuffer =
332
+ await audioContext.decodeAudioData(arrayBuffer)
333
+
334
+ // Get original audio properties
335
+ const originalSampleRate = originalAudioBuffer.sampleRate
336
+ const originalChannels = originalAudioBuffer.numberOfChannels
337
+
338
+ // Add more detailed logging
339
+ console.log(`Original audio details:`, {
340
+ sampleRate: originalSampleRate,
341
+ channels: originalChannels,
342
+ duration: originalAudioBuffer.duration,
343
+ length: originalAudioBuffer.length,
344
+ // Log a few samples to verify content
345
+ firstSamples: Array.from(
346
+ originalAudioBuffer.getChannelData(0).slice(0, 5)
347
+ ),
348
+ })
349
+
350
+ // Determine output format - use original values as defaults if not specified
351
+ let format = outputFormat?.format || 'wav'
352
+ const targetSampleRate =
353
+ outputFormat?.sampleRate || originalSampleRate
354
+ const targetChannels = outputFormat?.channels || originalChannels
355
+ const targetBitDepth = outputFormat?.bitDepth || 16
356
+
357
+ // Get file info from the URL
358
+ const filename =
359
+ outputFileName ||
360
+ fileUri.split('/').pop() ||
361
+ 'trimmed-audio.wav'
362
+
363
+ // Process based on mode
364
+ let resultBuffer: AudioBuffer
365
+
366
+ // Report initial progress
367
+ ExpoAudioStreamModule.sendEvent('TrimProgress', {
368
+ progress: 10,
369
+ })
370
+
371
+ if (mode === 'single') {
372
+ // Single mode: extract a single range
373
+ // Use original sample rate and channels for extraction to preserve quality
374
+ const { buffer } = await processAudioBuffer({
375
+ fileUri,
376
+ targetSampleRate, // Use the requested sample rate
377
+ targetChannels,
378
+ normalizeAudio: false,
379
+ startTimeMs,
380
+ endTimeMs,
381
+ audioContext,
382
+ })
383
+
384
+ console.log(`Processed buffer details:`, {
385
+ sampleRate: buffer.sampleRate,
386
+ channels: buffer.numberOfChannels,
387
+ duration: buffer.duration,
388
+ length: buffer.length,
389
+ // Log a few samples to verify content
390
+ firstSamples: Array.from(
391
+ buffer.getChannelData(0).slice(0, 5)
392
+ ),
393
+ })
394
+
395
+ resultBuffer = buffer
396
+
397
+ // If we need to change sample rate or channels, do it after extraction
398
+ if (
399
+ targetSampleRate !== originalSampleRate ||
400
+ targetChannels !== originalChannels
401
+ ) {
402
+ console.log(
403
+ `Resampling from ${originalSampleRate}Hz to ${targetSampleRate}Hz`
404
+ )
405
+ resultBuffer = await resampleAudioBuffer(
406
+ audioContext,
407
+ buffer,
408
+ targetSampleRate,
409
+ targetChannels
410
+ )
411
+ }
412
+ } else {
413
+ // For keep or remove modes
414
+ const fullDuration = originalAudioBuffer.duration * 1000 // in ms
415
+
416
+ type ProcessSegment = {
417
+ startTimeMs: number
418
+ endTimeMs: number
419
+ }
420
+
421
+ let segmentsToProcess: ProcessSegment[] = []
422
+
423
+ if (mode === 'keep') {
424
+ // For keep mode, use the ranges directly
425
+ segmentsToProcess = ranges!
426
+ } else {
427
+ // mode === 'remove'
428
+ // For remove mode, invert the ranges
429
+ const sortedRanges = [...ranges!].sort(
430
+ (a, b) => a.startTimeMs - b.startTimeMs
431
+ )
432
+
433
+ // Add segment from start to first range if needed
434
+ if (
435
+ sortedRanges.length > 0 &&
436
+ sortedRanges[0].startTimeMs > 0
437
+ ) {
438
+ segmentsToProcess.push({
439
+ startTimeMs: 0,
440
+ endTimeMs: sortedRanges[0].startTimeMs,
441
+ })
442
+ }
443
+
444
+ // Add segments between ranges
445
+ for (let i = 0; i < sortedRanges.length - 1; i++) {
446
+ segmentsToProcess.push({
447
+ startTimeMs: sortedRanges[i].endTimeMs,
448
+ endTimeMs: sortedRanges[i + 1].startTimeMs,
449
+ })
450
+ }
451
+
452
+ // Add segment from last range to end if needed
453
+ if (
454
+ sortedRanges.length > 0 &&
455
+ sortedRanges[sortedRanges.length - 1].endTimeMs <
456
+ fullDuration
457
+ ) {
458
+ segmentsToProcess.push({
459
+ startTimeMs:
460
+ sortedRanges[sortedRanges.length - 1].endTimeMs,
461
+ endTimeMs: fullDuration,
462
+ })
463
+ }
464
+ }
465
+
466
+ // Filter out empty or invalid segments
467
+ segmentsToProcess = segmentsToProcess.filter(
468
+ (segment) =>
469
+ segment.startTimeMs < segment.endTimeMs &&
470
+ segment.endTimeMs - segment.startTimeMs > 1
471
+ ) // 1ms minimum
472
+
473
+ if (segmentsToProcess.length === 0) {
474
+ throw new Error(
475
+ 'No valid segments to process after filtering ranges'
476
+ )
477
+ }
478
+
479
+ // Process each segment using original sample rate and channels
480
+ const segmentBuffers: AudioBuffer[] = []
481
+
482
+ for (let i = 0; i < segmentsToProcess.length; i++) {
483
+ const segment = segmentsToProcess[i]
484
+
485
+ // Report progress for each segment
486
+ ExpoAudioStreamModule.sendEvent('TrimProgress', {
487
+ progress:
488
+ 10 +
489
+ Math.round((i / segmentsToProcess.length) * 40),
490
+ })
491
+
492
+ // Use processAudioBuffer to extract this segment
493
+ const { buffer: segmentBuffer } = await processAudioBuffer({
494
+ fileUri,
495
+ targetSampleRate: originalSampleRate, // Use original sample rate
496
+ targetChannels: originalChannels, // Use original channels
497
+ normalizeAudio: false,
498
+ startTimeMs: segment.startTimeMs,
499
+ endTimeMs: segment.endTimeMs,
500
+ audioContext,
501
+ })
502
+
503
+ segmentBuffers.push(segmentBuffer)
504
+ }
505
+
506
+ // Concatenate all segments
507
+ const totalSamples = segmentBuffers.reduce(
508
+ (sum, buffer) => sum + buffer.length,
509
+ 0
510
+ )
511
+
512
+ // Create buffer with original properties first
513
+ const concatenatedBuffer = audioContext.createBuffer(
514
+ originalChannels,
515
+ totalSamples,
516
+ originalSampleRate
517
+ )
518
+
519
+ let offset = 0
520
+ for (const segmentBuffer of segmentBuffers) {
521
+ for (
522
+ let channel = 0;
523
+ channel < originalChannels;
524
+ channel++
525
+ ) {
526
+ const outputData =
527
+ concatenatedBuffer.getChannelData(channel)
528
+ const segmentData =
529
+ segmentBuffer.getChannelData(channel)
530
+
531
+ for (let i = 0; i < segmentBuffer.length; i++) {
532
+ outputData[offset + i] = segmentData[i]
533
+ }
534
+ }
535
+ offset += segmentBuffer.length
536
+ }
537
+
538
+ resultBuffer = concatenatedBuffer
539
+
540
+ // If we need to change sample rate or channels, do it after concatenation
541
+ if (
542
+ targetSampleRate !== originalSampleRate ||
543
+ targetChannels !== originalChannels
544
+ ) {
545
+ console.log(
546
+ `Resampling concatenated buffer from ${originalSampleRate}Hz to ${targetSampleRate}Hz`
547
+ )
548
+ resultBuffer = await resampleAudioBuffer(
549
+ audioContext,
550
+ concatenatedBuffer,
551
+ targetSampleRate,
552
+ targetChannels
553
+ )
554
+ }
555
+ }
556
+
557
+ // Report progress (50% - processing complete)
558
+ ExpoAudioStreamModule.sendEvent('TrimProgress', {
559
+ progress: 50,
560
+ })
561
+
562
+ // Encode the result based on the requested format
563
+ let outputData: ArrayBuffer
564
+ let outputMimeType: string
565
+ let compressionInfo: any = null
566
+
567
+ // Check if AAC was requested on web and show a warning
568
+ if (format === 'aac' && Platform.OS === 'web') {
569
+ console.warn(
570
+ 'AAC format is not supported on web platforms. Falling back to OPUS format.'
571
+ )
572
+ format = 'opus'
573
+ }
574
+
575
+ if (format === 'wav') {
576
+ // Create a properly interleaved buffer for WAV format
577
+ // For WAV, we need to convert Float32Array to Int16Array (for 16-bit audio)
578
+ const numSamples =
579
+ resultBuffer.length * resultBuffer.numberOfChannels
580
+ const interleavedData = new Int16Array(numSamples)
581
+
582
+ // Log detailed information about the buffer before encoding
583
+ console.log(`Creating WAV file:`, {
584
+ bufferSampleRate: resultBuffer.sampleRate,
585
+ bufferChannels: resultBuffer.numberOfChannels,
586
+ bufferLength: resultBuffer.length,
587
+ targetSampleRate,
588
+ targetChannels,
589
+ targetBitDepth,
590
+ // Log a few samples to verify content
591
+ firstSamples: Array.from(
592
+ resultBuffer.getChannelData(0).slice(0, 5)
593
+ ),
594
+ })
595
+
596
+ // Interleave channels properly
597
+ for (let i = 0; i < resultBuffer.length; i++) {
598
+ for (
599
+ let channel = 0;
600
+ channel < resultBuffer.numberOfChannels;
601
+ channel++
602
+ ) {
603
+ // Convert float (-1.0 to 1.0) to int16 (-32768 to 32767)
604
+ const floatSample =
605
+ resultBuffer.getChannelData(channel)[i]
606
+ // Clamp the value to -1.0 to 1.0
607
+ const clampedSample = Math.max(
608
+ -1.0,
609
+ Math.min(1.0, floatSample)
610
+ )
611
+ // Convert to int16
612
+ const intSample = Math.round(clampedSample * 32767)
613
+ // Store in interleaved buffer
614
+ interleavedData[
615
+ i * resultBuffer.numberOfChannels + channel
616
+ ] = intSample
617
+ }
618
+ }
619
+
620
+ // Convert Int16Array to ArrayBuffer for WAV header
621
+ const rawBuffer = interleavedData.buffer
622
+
623
+ // IMPORTANT: Make sure we're using the ACTUAL sample rate of the buffer
624
+ // not just what was requested in the options
625
+ console.log(
626
+ `Creating WAV with ${resultBuffer.numberOfChannels} channels at ${resultBuffer.sampleRate}Hz`
627
+ )
628
+
629
+ outputData = writeWavHeader({
630
+ buffer: rawBuffer as ArrayBuffer,
631
+ sampleRate: resultBuffer.sampleRate, // Use the actual buffer's sample rate
632
+ numChannels: resultBuffer.numberOfChannels,
633
+ bitDepth: targetBitDepth as BitDepth,
634
+ })
635
+ outputMimeType = 'audio/wav'
636
+ } else if (format === 'opus' || format === 'aac') {
637
+ try {
638
+ // Try to use MediaRecorder for compressed formats
639
+ const { data, bitrate } = await encodeCompressedAudio(
640
+ resultBuffer,
641
+ format,
642
+ outputFormat?.bitrate
643
+ )
644
+
645
+ outputData = data
646
+ outputMimeType =
647
+ format === 'opus' ? 'audio/webm' : 'audio/aac'
648
+ compressionInfo = {
649
+ format,
650
+ bitrate,
651
+ size: data.byteLength,
652
+ }
653
+ } catch (error) {
654
+ console.warn(
655
+ `Failed to encode to ${format}, falling back to WAV: ${error}`
656
+ )
657
+
658
+ // Same WAV encoding as above
659
+ const wavData = new Float32Array(
660
+ resultBuffer.length * resultBuffer.numberOfChannels
661
+ )
662
+
663
+ for (let i = 0; i < resultBuffer.length; i++) {
664
+ for (
665
+ let channel = 0;
666
+ channel < resultBuffer.numberOfChannels;
667
+ channel++
668
+ ) {
669
+ wavData[
670
+ i * resultBuffer.numberOfChannels + channel
671
+ ] = resultBuffer.getChannelData(channel)[i]
672
+ }
673
+ }
674
+
675
+ outputData = writeWavHeader({
676
+ buffer: wavData.buffer as ArrayBuffer,
677
+ sampleRate: resultBuffer.sampleRate,
678
+ numChannels: resultBuffer.numberOfChannels,
679
+ bitDepth: targetBitDepth as BitDepth,
680
+ })
681
+ outputMimeType = 'audio/wav'
682
+ }
683
+ } else {
684
+ // Default to WAV for unsupported formats
685
+ console.warn(
686
+ `Format ${format} not supported on web, using WAV instead`
687
+ )
688
+
689
+ // Same WAV encoding as above
690
+ const wavData = new Float32Array(
691
+ resultBuffer.length * resultBuffer.numberOfChannels
692
+ )
693
+
694
+ for (let i = 0; i < resultBuffer.length; i++) {
695
+ for (
696
+ let channel = 0;
697
+ channel < resultBuffer.numberOfChannels;
698
+ channel++
699
+ ) {
700
+ wavData[i * resultBuffer.numberOfChannels + channel] =
701
+ resultBuffer.getChannelData(channel)[i]
702
+ }
703
+ }
704
+
705
+ outputData = writeWavHeader({
706
+ buffer: wavData.buffer as ArrayBuffer,
707
+ sampleRate: resultBuffer.sampleRate,
708
+ numChannels: resultBuffer.numberOfChannels,
709
+ bitDepth: targetBitDepth as BitDepth,
710
+ })
711
+ outputMimeType = 'audio/wav'
712
+ }
713
+
714
+ // Report progress (90% - encoding complete)
715
+ ExpoAudioStreamModule.sendEvent('TrimProgress', {
716
+ progress: 90,
717
+ })
718
+
719
+ // Create a blob and URL for the result
720
+ const blob = new Blob([outputData], { type: outputMimeType })
721
+ const outputUri = URL.createObjectURL(blob)
722
+
723
+ // Calculate processing time
724
+ const processingTimeMs = performance.now() - startTime
725
+
726
+ // Report progress (100% - complete)
727
+ ExpoAudioStreamModule.sendEvent('TrimProgress', {
728
+ progress: 100,
729
+ })
730
+
731
+ // Create result object
732
+ const result: TrimAudioResult = {
733
+ uri: outputUri,
734
+ filename,
735
+ durationMs: Math.round(resultBuffer.duration * 1000),
736
+ size: outputData.byteLength,
737
+ sampleRate: resultBuffer.sampleRate,
738
+ channels: resultBuffer.numberOfChannels,
739
+ bitDepth: targetBitDepth,
740
+ mimeType: outputMimeType,
741
+ processingInfo: {
742
+ durationMs: processingTimeMs,
743
+ },
744
+ }
745
+
746
+ // Add compression info if available
747
+ if (compressionInfo) {
748
+ result.compression = compressionInfo
749
+ }
750
+
751
+ return result
752
+ } catch (error) {
753
+ console.error('Error in trimAudio:', error)
754
+ throw error
755
+ }
756
+ }
757
+
758
+ // Add a sendEvent method for web
759
+ ExpoAudioStreamModule.sendEvent = (eventName: string, params: any) => {
760
+ // This will be picked up by the LegacyEventEmitter in trimAudio.ts
761
+ if (
762
+ ExpoAudioStreamModule.listeners &&
763
+ ExpoAudioStreamModule.listeners[eventName]
764
+ ) {
765
+ ExpoAudioStreamModule.listeners[eventName].forEach(
766
+ (listener: Function) => {
767
+ listener(params)
768
+ }
769
+ )
770
+ }
771
+ }
772
+
773
+ // Initialize listeners object
774
+ ExpoAudioStreamModule.listeners = {}
775
+
776
+ // Add methods for event listeners that LegacyEventEmitter will use
777
+ ExpoAudioStreamModule.addListener = (
778
+ eventName: string,
779
+ listener: Function
780
+ ) => {
781
+ if (!ExpoAudioStreamModule.listeners[eventName]) {
782
+ ExpoAudioStreamModule.listeners[eventName] = []
783
+ }
784
+ ExpoAudioStreamModule.listeners[eventName].push(listener)
785
+
786
+ // Return an object with a remove method
787
+ return {
788
+ remove: () => {
789
+ const index =
790
+ ExpoAudioStreamModule.listeners[eventName].indexOf(listener)
791
+ if (index !== -1) {
792
+ ExpoAudioStreamModule.listeners[eventName].splice(index, 1)
793
+ }
794
+ },
795
+ }
796
+ }
797
+
798
+ ExpoAudioStreamModule.removeAllListeners = (eventName: string) => {
799
+ if (ExpoAudioStreamModule.listeners[eventName]) {
800
+ delete ExpoAudioStreamModule.listeners[eventName]
801
+ }
802
+ }
803
+ }
804
+
805
+ // Move the encodeCompressedAudio function outside the if block to fix the ESLint error
806
+ async function encodeCompressedAudio(
807
+ buffer: AudioBuffer,
808
+ format: 'opus' | 'aac',
809
+ bitrate?: number
810
+ ): Promise<{ data: ArrayBuffer; bitrate: number }> {
811
+ return new Promise((resolve, reject) => {
812
+ try {
813
+ // On web, always use opus if aac is requested
814
+ const actualFormat =
815
+ Platform.OS === 'web' && format === 'aac' ? 'opus' : format
816
+
817
+ // Check if MediaRecorder supports the requested format
818
+ const mimeType =
819
+ actualFormat === 'opus' ? 'audio/webm;codecs=opus' : 'audio/aac'
820
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
821
+ throw new Error(`MediaRecorder does not support ${mimeType}`)
822
+ }
823
+
824
+ // Create a new AudioContext and source
825
+ const ctx = new (window.AudioContext ||
826
+ (window as any).webkitAudioContext)()
827
+ const source = ctx.createBufferSource()
828
+ source.buffer = buffer
829
+
830
+ // Create a MediaStreamDestination to capture the audio
831
+ const destination = ctx.createMediaStreamDestination()
832
+ source.connect(destination)
833
+
834
+ // Create a MediaRecorder with the requested format
835
+ const recorder = new MediaRecorder(destination.stream, {
836
+ mimeType,
837
+ audioBitsPerSecond:
838
+ bitrate || (actualFormat === 'opus' ? 32000 : 64000),
839
+ })
840
+
841
+ const chunks: Blob[] = []
842
+
843
+ recorder.ondataavailable = (e) => {
844
+ if (e.data.size > 0) {
845
+ chunks.push(e.data)
846
+ }
847
+ }
848
+
849
+ recorder.onstop = async () => {
850
+ try {
851
+ const blob = new Blob(chunks, { type: mimeType })
852
+ const arrayBuffer = await blob.arrayBuffer()
853
+
854
+ // Get the actual bitrate used
855
+ const actualBitrate = Math.round(
856
+ (arrayBuffer.byteLength * 8) / buffer.duration
857
+ )
858
+
859
+ resolve({
860
+ data: arrayBuffer,
861
+ bitrate: actualBitrate / 1000, // Convert to kbps
862
+ })
863
+
864
+ // Clean up
865
+ ctx.close()
866
+ } catch (error) {
867
+ reject(error)
868
+ }
869
+ }
870
+
871
+ // Start recording and playback
872
+ recorder.start()
873
+ source.start(0)
874
+
875
+ // Stop recording when the buffer finishes playing
876
+ setTimeout(() => {
877
+ recorder.stop()
878
+ source.stop()
879
+ }, buffer.duration * 1000)
880
+ } catch (error) {
881
+ reject(error)
882
+ }
883
+ })
884
+ }
885
+
886
+ // Improved resampleAudioBuffer function
887
+ async function resampleAudioBuffer(
888
+ context: AudioContext,
889
+ buffer: AudioBuffer,
890
+ targetSampleRate: number,
891
+ targetChannels: number
892
+ ): Promise<AudioBuffer> {
893
+ // If no change needed, return the original buffer
894
+ if (
895
+ buffer.sampleRate === targetSampleRate &&
896
+ buffer.numberOfChannels === targetChannels
897
+ ) {
898
+ return buffer
899
+ }
900
+
901
+ console.log(
902
+ `Resampling: ${buffer.sampleRate}Hz → ${targetSampleRate}Hz, ${buffer.numberOfChannels} → ${targetChannels} channels`
903
+ )
904
+
905
+ // Calculate the new length based on the sample rate change
906
+ const newLength = Math.round(
907
+ (buffer.length * targetSampleRate) / buffer.sampleRate
908
+ )
909
+
910
+ // Create an offline context for resampling
911
+ const offlineContext = new OfflineAudioContext(
912
+ targetChannels,
913
+ newLength,
914
+ targetSampleRate
915
+ )
916
+
917
+ // Create a source node
918
+ const source = offlineContext.createBufferSource()
919
+ source.buffer = buffer
920
+
921
+ // If we need to change channel count
922
+ if (buffer.numberOfChannels !== targetChannels) {
923
+ if (targetChannels === 1 && buffer.numberOfChannels > 1) {
924
+ // Downmix to mono
925
+ const merger = offlineContext.createChannelMerger(1)
926
+
927
+ // Create a gain node to reduce volume when downmixing to prevent clipping
928
+ const gainNode = offlineContext.createGain()
929
+ gainNode.gain.value = 1.0 / buffer.numberOfChannels
930
+
931
+ source.connect(gainNode)
932
+ gainNode.connect(merger)
933
+ merger.connect(offlineContext.destination)
934
+ } else if (targetChannels === 2 && buffer.numberOfChannels === 1) {
935
+ // Upmix mono to stereo (duplicate the channel)
936
+ const splitter = offlineContext.createChannelSplitter(1)
937
+ const merger = offlineContext.createChannelMerger(2)
938
+
939
+ source.connect(splitter)
940
+ splitter.connect(merger, 0, 0)
941
+ splitter.connect(merger, 0, 1)
942
+ merger.connect(offlineContext.destination)
943
+ } else {
944
+ // For other cases, just connect and let the system handle it
945
+ source.connect(offlineContext.destination)
946
+ }
947
+ } else {
948
+ // No channel conversion needed
949
+ source.connect(offlineContext.destination)
950
+ }
951
+
952
+ // Start rendering
953
+ source.start(0)
954
+ const resampledBuffer = await offlineContext.startRendering()
955
+
956
+ console.log(
957
+ `Resampling complete: ${resampledBuffer.length} samples at ${resampledBuffer.sampleRate}Hz`
958
+ )
959
+
960
+ return resampledBuffer
961
+ }
962
+
963
+ if (Platform.OS !== 'web') {
283
964
  ExpoAudioStreamModule = requireNativeModule('ExpoAudioStream')
284
965
  }
285
966