@siteed/expo-audio-stream 1.7.2 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/README.md +6 -1
  3. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +39 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +124 -12
  5. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +26 -2
  6. package/build/AudioRecorder.provider.d.ts.map +1 -1
  7. package/build/AudioRecorder.provider.js +1 -0
  8. package/build/AudioRecorder.provider.js.map +1 -1
  9. package/build/ExpoAudioStream.types.d.ts +22 -1
  10. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  11. package/build/ExpoAudioStream.types.js.map +1 -1
  12. package/build/ExpoAudioStream.web.d.ts +15 -2
  13. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  14. package/build/ExpoAudioStream.web.js +99 -40
  15. package/build/ExpoAudioStream.web.js.map +1 -1
  16. package/build/WebRecorder.web.d.ts +14 -3
  17. package/build/WebRecorder.web.d.ts.map +1 -1
  18. package/build/WebRecorder.web.js +188 -100
  19. package/build/WebRecorder.web.js.map +1 -1
  20. package/build/events.d.ts +6 -0
  21. package/build/events.d.ts.map +1 -1
  22. package/build/events.js.map +1 -1
  23. package/build/useAudioRecorder.d.ts +2 -1
  24. package/build/useAudioRecorder.d.ts.map +1 -1
  25. package/build/useAudioRecorder.js +46 -5
  26. package/build/useAudioRecorder.js.map +1 -1
  27. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  28. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  29. package/build/workers/inlineAudioWebWorker.web.js +65 -160
  30. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  31. package/ios/AudioStreamManager.swift +127 -8
  32. package/ios/AudioStreamManagerDelegate.swift +8 -2
  33. package/ios/ExpoAudioStreamModule.swift +61 -46
  34. package/ios/RecordingResult.swift +2 -0
  35. package/ios/RecordingSettings.swift +63 -3
  36. package/package.json +1 -1
  37. package/src/AudioRecorder.provider.tsx +1 -0
  38. package/src/ExpoAudioStream.types.ts +24 -1
  39. package/src/ExpoAudioStream.web.ts +111 -38
  40. package/src/WebRecorder.web.ts +238 -138
  41. package/src/events.ts +7 -0
  42. package/src/useAudioRecorder.tsx +68 -7
  43. package/src/workers/inlineAudioWebWorker.web.tsx +65 -160
@@ -1 +1 @@
1
- {"version":3,"file":"inlineAudioWebWorker.web.js","sourceRoot":"","sources":["../../src/workers/inlineAudioWebWorker.web.tsx"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyPnC,CAAA","sourcesContent":["export const InlineAudioWebWorker = `\nconst DEFAULT_BIT_DEPTH = 32\nconst DEFAULT_SAMPLE_RATE = 44100\n\nclass RecorderProcessor extends AudioWorkletProcessor {\n constructor() {\n super()\n this.recordedBuffers = [] // Float32Array\n this.newRecBuffer = [] // Float32Array\n this.resampledBuffer = [] // Float32Array\n this.exportIntervalSamples = 0\n this.samplesSinceLastExport = 0\n this.recordSampleRate = DEFAULT_SAMPLE_RATE // To be overwritten\n this.exportSampleRate = DEFAULT_SAMPLE_RATE // To be overwritten\n this.recordBitDepth = DEFAULT_BIT_DEPTH // Default to 32-bit depth\n this.exportBitDepth = DEFAULT_BIT_DEPTH // To be overwritten\n this.numberOfChannels = 1 // Default to 1 channel (mono)\n this.isRecording = true\n this.port.onmessage = this.handleMessage.bind(this)\n }\n\n handleMessage(event) {\n switch (event.data.command) {\n case 'init':\n this.recordSampleRate = event.data.recordSampleRate\n this.exportSampleRate =\n event.data.exportSampleRate || event.data.recordSampleRate\n this.exportIntervalSamples =\n this.recordSampleRate * (event.data.interval / 1000)\n if (event.data.numberOfChannels) {\n this.numberOfChannels = event.data.numberOfChannels\n }\n if (event.data.recordBitDepth) {\n this.recordBitDepth = event.data.recordBitDepth\n }\n this.exportBitDepth =\n event.data.exportBitDepth ||\n this.recordBitDepth ||\n DEFAULT_BIT_DEPTH\n break\n case 'stop':\n this.isRecording = false\n this.getAllRecordedData()\n .then((fullRecordedData) => {\n this.port.postMessage({\n command: 'recordedData',\n recordedData: fullRecordedData,\n bitDepth: this.exportBitDepth,\n sampleRate: this.exportSampleRate,\n })\n return fullRecordedData\n })\n .catch((error) => {\n console.error(\n 'RecorderProcessor Error extracting recorded data:',\n error\n )\n })\n break\n }\n }\n\n process(inputs, _outputs, _parameters) {\n if (!this.isRecording) return true\n const input = inputs[0]\n if (input.length > 0) {\n const newBuffer = new Float32Array(input[0])\n this.newRecBuffer.push(newBuffer)\n this.recordedBuffers.push(newBuffer)\n this.samplesSinceLastExport += newBuffer.length\n\n if (this.samplesSinceLastExport >= this.exportIntervalSamples) {\n this.exportNewData()\n this.samplesSinceLastExport = 0\n }\n }\n return true\n }\n\n mergeBuffers(bufferArray, recLength) {\n const result = new Float32Array(recLength)\n let offset = 0\n for (let i = 0; i < bufferArray.length; i++) {\n result.set(bufferArray[i], offset)\n offset += bufferArray[i].length\n }\n return result\n }\n\n floatTo16BitPCM(input) {\n const output = new Int16Array(input.length)\n for (let i = 0; i < input.length; i++) {\n const s = Math.max(-1, Math.min(1, input[i]))\n output[i] = s < 0 ? s * 0x8000 : s * 0x7fff\n }\n console.debug(\n 'RecorderProcessor Float to 16-bit PCM conversion complete. Output byte length:',\n output.byteLength\n )\n return output\n }\n\n floatTo32BitPCM(input) {\n const output = new Int32Array(input.length)\n for (let i = 0; i < input.length; i++) {\n const s = Math.max(-1, Math.min(1, input[i]))\n output[i] = s < 0 ? s * 0x80000000 : s * 0x7fffffff\n }\n console.debug(\n 'RecorderProcessor Float to 32-bit PCM conversion complete. Output byte length:',\n output.byteLength\n )\n return output\n }\n\n resample(samples, targetSampleRate) {\n if (this.recordSampleRate === targetSampleRate) {\n return samples\n }\n const resampledBuffer = new Float32Array(\n (samples.length * targetSampleRate) / this.recordSampleRate\n )\n const ratio = this.recordSampleRate / targetSampleRate\n let offset = 0\n for (let i = 0; i < resampledBuffer.length; i++) {\n const nextOffset = Math.floor((i + 1) * ratio)\n let accum = 0\n let count = 0\n for (let j = offset; j < nextOffset && j < samples.length; j++) {\n accum += samples[j]\n count++\n }\n resampledBuffer[i] = accum / count\n offset = nextOffset\n }\n return resampledBuffer\n }\n\n async resampleBuffer(buffer, targetSampleRate) {\n if (typeof OfflineAudioContext === 'undefined') {\n return this.resample(buffer, targetSampleRate)\n }\n\n if (this.recordSampleRate === targetSampleRate) {\n return buffer\n }\n const offlineContext = new OfflineAudioContext(\n this.numberOfChannels,\n buffer.length,\n this.recordSampleRate\n )\n const sourceBuffer = offlineContext.createBuffer(\n this.numberOfChannels,\n buffer.length,\n this.recordSampleRate\n )\n sourceBuffer.copyToChannel(buffer, 0)\n\n const bufferSource = offlineContext.createBufferSource()\n bufferSource.buffer = sourceBuffer\n bufferSource.connect(offlineContext.destination)\n bufferSource.start()\n\n const renderedBuffer = await offlineContext.startRendering()\n\n const resampledBuffer = new Float32Array(renderedBuffer.length)\n renderedBuffer.copyFromChannel(resampledBuffer, 0)\n\n return resampledBuffer\n }\n\n async exportNewData() {\n // Calculate the total length of the new recorded buffers\n const length = this.newRecBuffer.reduce(\n (acc, buffer) => acc + buffer.length,\n 0\n )\n\n // Merge all new recorded buffers into a single buffer\n const mergedBuffer = this.mergeBuffers(this.newRecBuffer, length)\n\n const resampledBuffer = await this.resampleBuffer(\n mergedBuffer,\n this.exportSampleRate\n )\n\n let finalBuffer = resampledBuffer // Float32Array\n if (this.recordBitDepth !== this.exportBitDepth) {\n if (this.exportBitDepth === 16) {\n finalBuffer = this.floatTo16BitPCM(resampledBuffer)\n } else if (this.exportBitDepth === 32) {\n finalBuffer = this.floatTo32BitPCM(resampledBuffer)\n }\n }\n\n const originalSize = mergedBuffer.byteLength\n const resampledSize = resampledBuffer.byteLength\n const finalSize = finalBuffer.byteLength\n\n // Clear the new recorded buffers after they have been processed\n this.newRecBuffer.length = 0\n\n // Post the message to the main thread\n // The first argument is the message data, containing the encoded WAV buffer\n // The second argument is the transfer list, which transfers ownership of the ArrayBuffer\n // to the main thread, avoiding the need to copy the buffer and improving performance\n // this.port.postMessage({ recordedData: encodedWav.buffer, sampleRate: this.recordSampleRate }, [encodedWav.buffer]);\n this.port.postMessage(\n {\n command: 'newData',\n recordedData: finalBuffer,\n sampleRate: this.exportSampleRate,\n bitDepth: this.exportBitDepth,\n },\n []\n )\n }\n\n async getAllRecordedData() {\n const length = this.recordedBuffers.reduce(\n (acc, buffer) => acc + buffer.length,\n 0\n )\n const mergedBuffer = this.mergeBuffers(this.recordedBuffers, length)\n const resampledBuffer = await this.resampleBuffer(\n mergedBuffer,\n this.exportSampleRate\n )\n // Convert to the desired bit depth if necessary\n let finalBuffer = resampledBuffer\n if (this.recordBitDepth !== this.exportBitDepth) {\n if (this.exportBitDepth === 16) {\n finalBuffer = this.floatTo16BitPCM(resampledBuffer)\n } else if (this.exportBitDepth === 32) {\n finalBuffer = this.floatTo32BitPCM(resampledBuffer)\n }\n }\n\n const originalSize = mergedBuffer.byteLength\n const resampledSize = resampledBuffer.byteLength\n const finalSize = finalBuffer.byteLength\n\n this.recordedBuffers.length = 0 // Clear the buffers after extraction\n\n return finalBuffer\n }\n}\n\nregisterProcessor('recorder-processor', RecorderProcessor)\n`\n"]}
1
+ {"version":3,"file":"inlineAudioWebWorker.web.js","sourceRoot":"","sources":["../../src/workers/inlineAudioWebWorker.web.tsx"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0JnC,CAAA","sourcesContent":["export const InlineAudioWebWorker = `\nconst DEFAULT_BIT_DEPTH = 32\nconst DEFAULT_SAMPLE_RATE = 44100\n\nclass RecorderProcessor extends AudioWorkletProcessor {\n constructor() {\n super()\n this.currentChunk = [] // Float32Array\n this.samplesSinceLastExport = 0\n this.recordSampleRate = DEFAULT_SAMPLE_RATE\n this.exportSampleRate = DEFAULT_SAMPLE_RATE\n this.recordBitDepth = DEFAULT_BIT_DEPTH\n this.exportBitDepth = DEFAULT_BIT_DEPTH\n this.numberOfChannels = 1\n this.isRecording = true\n this.port.onmessage = this.handleMessage.bind(this)\n this.logger = undefined\n this.exportIntervalSamples = 0\n }\n\n handleMessage(event) {\n switch (event.data.command) {\n case 'init':\n this.logger = event.data.logger\n this.recordSampleRate = event.data.recordSampleRate\n this.exportSampleRate =\n event.data.exportSampleRate || event.data.recordSampleRate\n this.exportIntervalSamples =\n this.recordSampleRate * (event.data.interval / 1000)\n if (event.data.numberOfChannels) {\n this.numberOfChannels = event.data.numberOfChannels\n }\n if (event.data.recordBitDepth) {\n this.recordBitDepth = event.data.recordBitDepth\n }\n this.exportBitDepth =\n event.data.exportBitDepth || this.recordBitDepth\n break\n\n case 'stop':\n this.isRecording = false\n if (this.currentChunk.length > 0) {\n this.processChunk()\n }\n break\n }\n }\n\n process(inputs, _outputs, _parameters) {\n if (!this.isRecording) return true\n const input = inputs[0]\n if (input.length > 0) {\n const newBuffer = new Float32Array(input[0])\n this.currentChunk.push(newBuffer)\n this.samplesSinceLastExport += newBuffer.length\n\n if (this.samplesSinceLastExport >= this.exportIntervalSamples) {\n this.processChunk()\n this.samplesSinceLastExport = 0\n }\n }\n return true\n }\n\n mergeBuffers(bufferArray, recLength) {\n const result = new Float32Array(recLength)\n let offset = 0\n for (let i = 0; i < bufferArray.length; i++) {\n result.set(bufferArray[i], offset)\n offset += bufferArray[i].length\n }\n return result\n }\n\n // Keep basic resampling for sample rate conversion\n resample(samples, targetSampleRate) {\n if (this.recordSampleRate === targetSampleRate) {\n return samples\n }\n const resampledBuffer = new Float32Array(\n Math.ceil(\n (samples.length * targetSampleRate) / this.recordSampleRate\n )\n )\n const ratio = this.recordSampleRate / targetSampleRate\n let offset = 0\n for (let i = 0; i < resampledBuffer.length; i++) {\n const nextOffset = Math.floor((i + 1) * ratio)\n let accum = 0\n let count = 0\n for (let j = offset; j < nextOffset && j < samples.length; j++) {\n accum += samples[j]\n count++\n }\n resampledBuffer[i] = count > 0 ? accum / count : 0\n offset = nextOffset\n }\n return resampledBuffer\n }\n\n // Keep bit depth conversion if needed\n convertBitDepth(input, targetBitDepth) {\n if (targetBitDepth === 32) {\n const output = new Int32Array(input.length)\n for (let i = 0; i < input.length; i++) {\n const s = Math.max(-1, Math.min(1, input[i]))\n output[i] = s < 0 ? s * 0x80000000 : s * 0x7fffffff\n }\n return output\n } else if (targetBitDepth === 16) {\n const output = new Int16Array(input.length)\n for (let i = 0; i < input.length; i++) {\n const s = Math.max(-1, Math.min(1, input[i]))\n output[i] = s < 0 ? s * 0x8000 : s * 0x7fff\n }\n return output\n }\n return input\n }\n\n processChunk() {\n if (this.currentChunk.length === 0) return\n\n // Merge buffers\n const chunkLength = this.currentChunk.reduce(\n (acc, buf) => acc + buf.length,\n 0\n )\n const mergedChunk = this.mergeBuffers(this.currentChunk, chunkLength)\n\n // Resample if needed\n const resampledChunk = this.resample(mergedChunk, this.exportSampleRate)\n\n // Convert bit depth if needed\n const finalBuffer =\n this.recordBitDepth !== this.exportBitDepth\n ? this.convertBitDepth(resampledChunk, this.exportBitDepth)\n : resampledChunk\n\n // Send processed chunk\n this.port.postMessage({\n command: 'newData',\n recordedData: finalBuffer,\n sampleRate: this.exportSampleRate,\n bitDepth: this.exportBitDepth,\n numberOfChannels: this.numberOfChannels,\n })\n\n // Clear the current chunk\n this.currentChunk = []\n }\n}\n\nregisterProcessor('recorder-processor', RecorderProcessor)\n`\n"]}
@@ -46,6 +46,7 @@ class AudioStreamManager: NSObject {
46
46
 
47
47
  internal var lastEmissionTime: Date?
48
48
  internal var lastEmittedSize: Int64 = 0
49
+ internal var lastEmittedCompressedSize: Int64 = 0
49
50
  private var emissionInterval: TimeInterval = 1.0 // Default to 1 second
50
51
  private var totalDataSize: Int64 = 0
51
52
  private var fileManager = FileManager.default
@@ -68,6 +69,11 @@ class AudioStreamManager: NSObject {
68
69
 
69
70
  private var lastValidDuration: TimeInterval? // Add this property
70
71
 
72
+ private var compressedRecorder: AVAudioRecorder?
73
+ private var compressedFileURL: URL?
74
+ private var compressedFormat: String = "aac"
75
+ private var compressedBitRate: Int = 128000
76
+
71
77
  /// Initializes the AudioStreamManager
72
78
  override init() {
73
79
  super.init()
@@ -394,8 +400,8 @@ class AudioStreamManager: NSObject {
394
400
 
395
401
  let durationInSeconds = currentRecordingDuration()
396
402
  let durationInMilliseconds = Int(durationInSeconds * 1000)
397
-
398
- return [
403
+
404
+ var status: [String: Any] = [
399
405
  "durationMs": durationInMilliseconds,
400
406
  "isRecording": isRecording,
401
407
  "isPaused": isPaused,
@@ -403,6 +409,27 @@ class AudioStreamManager: NSObject {
403
409
  "size": totalDataSize,
404
410
  "interval": emissionInterval
405
411
  ]
412
+
413
+ // Add compression info if enabled
414
+ if settings.enableCompressedOutput, let compressedURL = compressedFileURL {
415
+ do {
416
+ let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
417
+ if let compressedSize = compressedAttributes[.size] as? Int64 {
418
+ let compressionBundle: [String: Any] = [
419
+ "fileUri": compressedURL.absoluteString,
420
+ "mimeType": compressedFormat == "aac" ? "audio/aac" : "audio/opus",
421
+ "size": compressedSize,
422
+ "format": compressedFormat,
423
+ "bitrate": compressedBitRate
424
+ ]
425
+ status["compression"] = compressionBundle
426
+ }
427
+ } catch {
428
+ Logger.debug("Error getting compressed file attributes: \(error)")
429
+ }
430
+ }
431
+
432
+ return status
406
433
  }
407
434
 
408
435
  /// Starts a new audio recording with the specified settings and interval.
@@ -521,6 +548,28 @@ class AudioStreamManager: NSObject {
521
548
  self.lastBufferTime = time
522
549
  }
523
550
 
551
+ // Setup compressed recording if enabled
552
+ if settings.enableCompressedOutput {
553
+ let compressedSettings: [String: Any] = [
554
+ AVFormatIDKey: settings.compressedFormat == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
555
+ AVSampleRateKey: settings.sampleRate,
556
+ AVNumberOfChannelsKey: settings.numberOfChannels,
557
+ AVEncoderBitRateKey: settings.compressedBitRate,
558
+ AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
559
+ ]
560
+
561
+ compressedFileURL = FileManager.default.temporaryDirectory
562
+ .appendingPathComponent(UUID().uuidString)
563
+ .appendingPathExtension(settings.compressedFormat)
564
+
565
+ if let url = compressedFileURL {
566
+ compressedRecorder = try AVAudioRecorder(url: url, settings: compressedSettings)
567
+ compressedRecorder?.record()
568
+ compressedFormat = settings.compressedFormat
569
+ compressedBitRate = settings.compressedBitRate
570
+ }
571
+ }
572
+
524
573
  } catch {
525
574
  Logger.debug("Error: Failed to set up audio session with preferred settings: \(error.localizedDescription)")
526
575
  return nil
@@ -574,13 +623,21 @@ class AudioStreamManager: NSObject {
574
623
  isRecording = true
575
624
  isPaused = false
576
625
  Logger.debug("Debug: Recording started successfully.")
626
+
577
627
  return StartRecordingResult(
578
628
  fileUri: recordingFileURL!.path,
579
629
  mimeType: mimeType,
580
- channels: newSettings.numberOfChannels,
581
- bitDepth: newSettings.bitDepth,
582
- sampleRate: newSettings.sampleRate
630
+ channels: settings.numberOfChannels,
631
+ bitDepth: settings.bitDepth,
632
+ sampleRate: settings.sampleRate,
633
+ compression: settings.enableCompressedOutput && compressedFileURL != nil ? CompressedRecordingInfo(
634
+ fileUri: compressedFileURL!.absoluteString,
635
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
636
+ bitrate: compressedBitRate,
637
+ format: compressedFormat
638
+ ) : nil
583
639
  )
640
+
584
641
  } catch {
585
642
  Logger.debug("Error: Could not start the audio engine: \(error.localizedDescription)")
586
643
  isRecording = false
@@ -604,6 +661,9 @@ class AudioStreamManager: NSObject {
604
661
  notificationManager?.updateState(isPaused: true)
605
662
  delegate?.audioStreamManager(self, didPauseRecording: Date())
606
663
  delegate?.audioStreamManager(self, didUpdateNotificationState: true)
664
+
665
+ // Pause compressed recording if active
666
+ compressedRecorder?.pause()
607
667
  }
608
668
 
609
669
  private func initializeNotifications() {
@@ -684,6 +744,10 @@ class AudioStreamManager: NSObject {
684
744
  notificationManager?.updateState(isPaused: false)
685
745
  delegate?.audioStreamManager(self, didResumeRecording: Date())
686
746
  delegate?.audioStreamManager(self, didUpdateNotificationState: false)
747
+
748
+ // Resume compressed recording if active
749
+ compressedRecorder?.record()
750
+
687
751
  } catch {
688
752
  Logger.debug("Error: Failed to resume recording: \(error.localizedDescription)")
689
753
  }
@@ -720,10 +784,15 @@ class AudioStreamManager: NSObject {
720
784
  /// Stops the current audio recording.
721
785
  /// - Returns: A RecordingResult object if the recording stopped successfully, or nil otherwise.
722
786
  func stopRecording() -> RecordingResult? {
787
+ guard isRecording else { return nil }
788
+
723
789
  disableWakeLock()
724
790
  audioEngine.stop()
725
791
  audioEngine.inputNode.removeTap(onBus: 0)
726
792
 
793
+ // Stop compressed recording if active
794
+ compressedRecorder?.stop()
795
+
727
796
  // Get the final duration before changing state
728
797
  let finalDuration = currentRecordingDuration()
729
798
 
@@ -783,13 +852,24 @@ class AudioStreamManager: NSObject {
783
852
  size: fileSize,
784
853
  channels: settings.numberOfChannels,
785
854
  bitDepth: settings.bitDepth,
786
- sampleRate: settings.sampleRate
855
+ sampleRate: settings.sampleRate,
856
+ compression: settings.enableCompressedOutput && compressedFileURL != nil ? CompressedRecordingInfo(
857
+ fileUri: compressedFileURL!.absoluteString,
858
+ mimeType: compressedFormat == "aac" ? "audio/aac" : "audio/opus",
859
+ bitrate: compressedBitRate,
860
+ format: compressedFormat
861
+ ) : nil
787
862
  )
863
+
864
+ // Cleanup
788
865
  recordingFileURL = nil
789
866
  lastBufferTime = nil
790
867
  lastValidDuration = nil
868
+ compressedRecorder = nil
869
+ compressedFileURL = nil
791
870
 
792
871
  return result
872
+
793
873
  } catch {
794
874
  Logger.debug("Failed to fetch file attributes: \(error)")
795
875
  return nil
@@ -1108,8 +1188,47 @@ class AudioStreamManager: NSObject {
1108
1188
  let recordingTime = currentTime.timeIntervalSince(startTime)
1109
1189
  let dataToProcess = accumulatedData
1110
1190
 
1111
- // Emit the audio data
1112
- delegate?.audioStreamManager(self, didReceiveAudioData: dataToProcess, recordingTime: recordingTime, totalDataSize: totalDataSize)
1191
+ // Prepare compression info if enabled
1192
+ var compressionInfo: [String: Any]? = nil
1193
+ if settings.enableCompressedOutput, let compressedURL = compressedFileURL {
1194
+ do {
1195
+ let compressedAttributes = try FileManager.default.attributesOfItem(atPath: compressedURL.path)
1196
+ if let compressedSize = compressedAttributes[.size] as? Int64 {
1197
+ let eventDataSize = compressedSize - lastEmittedCompressedSize
1198
+
1199
+ // Read the new compressed data if there's new data
1200
+ var compressedData: String? = nil
1201
+ if eventDataSize > 0 {
1202
+ let fileHandle = try FileHandle(forReadingFrom: compressedURL)
1203
+ fileHandle.seek(toFileOffset: UInt64(lastEmittedCompressedSize))
1204
+ let data = fileHandle.readData(ofLength: Int(eventDataSize))
1205
+ compressedData = data.base64EncodedString()
1206
+ fileHandle.closeFile()
1207
+ }
1208
+
1209
+ lastEmittedCompressedSize = compressedSize
1210
+
1211
+ compressionInfo = [
1212
+ "position": recordingTime * 1000, // Convert to milliseconds
1213
+ "fileUri": compressedURL.absoluteString,
1214
+ "eventDataSize": eventDataSize,
1215
+ "totalSize": compressedSize,
1216
+ "data": compressedData ?? ""
1217
+ ]
1218
+ }
1219
+ } catch {
1220
+ Logger.debug("Failed to read compressed data: \(error)")
1221
+ }
1222
+ }
1223
+
1224
+ // Emit the audio data with compression info
1225
+ delegate?.audioStreamManager(
1226
+ self,
1227
+ didReceiveAudioData: dataToProcess,
1228
+ recordingTime: recordingTime,
1229
+ totalDataSize: totalDataSize,
1230
+ compressionInfo: compressionInfo
1231
+ )
1113
1232
 
1114
1233
  // Process audio if enabled
1115
1234
  if settings.enableProcessing {
@@ -1,7 +1,13 @@
1
1
  protocol AudioStreamManagerDelegate: AnyObject {
2
- func audioStreamManager(_ manager: AudioStreamManager, didReceiveAudioData data: Data, recordingTime: TimeInterval, totalDataSize: Int64)
3
- func audioStreamManager(_ manager: AudioStreamManager, didReceiveProcessingResult result: AudioAnalysisData?)
2
+ func audioStreamManager(
3
+ _ manager: AudioStreamManager,
4
+ didReceiveAudioData data: Data,
5
+ recordingTime: TimeInterval,
6
+ totalDataSize: Int64,
7
+ compressionInfo: [String: Any]?
8
+ )
4
9
 
10
+ func audioStreamManager(_ manager: AudioStreamManager, didReceiveProcessingResult result: AudioAnalysisData?)
5
11
  func audioStreamManager(_ manager: AudioStreamManager, didPauseRecording pauseTime: Date)
6
12
  func audioStreamManager(_ manager: AudioStreamManager, didResumeRecording resumeTime: Date)
7
13
  func audioStreamManager(_ manager: AudioStreamManager, didUpdateNotificationState isPaused: Bool)
@@ -124,6 +124,10 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
124
124
  /// - `algorithm`: The algorithm to use for extraction (default is "rms").
125
125
  /// - `featureOptions`: A dictionary of feature options to extract (default is empty).
126
126
  /// - `maxRecentDataDuration`: The maximum duration of recent data to keep for processing (default is 10.0 seconds).
127
+ /// - `compression`: A dictionary containing:
128
+ /// - `enabled`: Boolean to enable/disable compression (default is false).
129
+ /// - `format`: The compression format (default is "aac").
130
+ /// - `bitrate`: The compression bitrate in bps (default is 128000).
127
131
  /// - promise: A promise to resolve with the recording settings or reject with an error.
128
132
  AsyncFunction("startRecording") { (options: [String: Any], promise: Promise) in
129
133
  self.checkMicrophonePermission { granted in
@@ -132,30 +136,48 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
132
136
  return
133
137
  }
134
138
 
135
- let settings = RecordingSettings.fromDictionary(options)
139
+ // Create settings with validation
140
+ let settingsResult = RecordingSettings.fromDictionary(options)
136
141
 
137
- // Initialize notification if enabled
138
- if settings.showNotification {
139
- Task {
140
- let notificationGranted = await self.requestNotificationPermissions()
141
- if !notificationGranted {
142
- Logger.debug("Notification permissions not granted")
142
+ switch settingsResult {
143
+ case .success(let settings):
144
+ // Initialize notification if enabled
145
+ if settings.showNotification {
146
+ Task {
147
+ let notificationGranted = await self.requestNotificationPermissions()
148
+ if !notificationGranted {
149
+ Logger.debug("Notification permissions not granted")
150
+ }
143
151
  }
144
152
  }
153
+
154
+ if let result = self.streamManager.startRecording(settings: settings, intervalMilliseconds: settings.interval ?? 1000) {
155
+ var resultDict: [String: Any] = [
156
+ "fileUri": result.fileUri,
157
+ "channels": result.channels,
158
+ "bitDepth": result.bitDepth,
159
+ "sampleRate": result.sampleRate,
160
+ "mimeType": result.mimeType,
161
+ ]
162
+
163
+ // Add compression info if available
164
+ if let compression = result.compression {
165
+ resultDict["compression"] = [
166
+ "fileUri": compression.fileUri,
167
+ "mimeType": compression.mimeType,
168
+ "bitrate": compression.bitrate,
169
+ "format": compression.format
170
+ ]
171
+ }
172
+
173
+ promise.resolve(resultDict)
174
+ } else {
175
+ promise.reject("ERROR", "Failed to start recording.")
176
+ }
177
+
178
+ case .failure(let error):
179
+ promise.reject("INVALID_SETTINGS", error.localizedDescription)
145
180
  }
146
-
147
- if let result = self.streamManager.startRecording(settings: settings, intervalMilliseconds: settings.interval ?? 1000) {
148
- let resultDict: [String: Any] = [
149
- "fileUri": result.fileUri,
150
- "channels": result.channels,
151
- "bitDepth": result.bitDepth,
152
- "sampleRate": result.sampleRate,
153
- "mimeType": result.mimeType,
154
- ]
155
- promise.resolve(resultDict)
156
- } else {
157
- promise.reject("ERROR", "Failed to start recording.")
158
- }
159
181
  }
160
182
  }
161
183
 
@@ -303,36 +325,29 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
303
325
  /// - data: The received audio data.
304
326
  /// - recordingTime: The current recording time.
305
327
  /// - totalDataSize: The total size of the received audio data.
306
- func audioStreamManager(_ manager: AudioStreamManager, didReceiveAudioData data: Data, recordingTime: TimeInterval, totalDataSize: Int64) {
307
- guard let fileURL = manager.recordingFileURL,
308
- let settings = manager.recordingSettings else { return }
309
-
310
- let encodedData = data.base64EncodedString()
311
-
312
- // Assuming `lastEmittedSize` and `streamUuid` are tracked within `AudioStreamManager`
313
- let deltaSize = data.count // This needs to be calculated based on what was last sent if using chunks
314
- let fileSize = totalDataSize // Total data size in bytes
315
-
316
- // Calculate the position in milliseconds using the lastEmittedSize
317
- let sampleRate = settings.sampleRate
318
- let channels = Double(settings.numberOfChannels)
319
- let bitDepth = Double(settings.bitDepth)
320
- let position = Int(manager.currentRecordingDuration() * 1000)
321
-
322
- // Construct the event payload similar to Android
323
- let eventBody: [String: Any] = [
324
- "fileUri": fileURL.absoluteString,
325
- "lastEmittedSize": manager.lastEmittedSize, // Needs to be maintained within AudioStreamManager
326
- "position": position, // time in ms based on pause-aware duration
327
- "encoded": encodedData,
328
- "deltaSize": deltaSize,
329
- "totalSize": fileSize,
328
+ func audioStreamManager(
329
+ _ manager: AudioStreamManager,
330
+ didReceiveAudioData data: Data,
331
+ recordingTime: TimeInterval,
332
+ totalDataSize: Int64,
333
+ compressionInfo: [String: Any]?
334
+ ) {
335
+ var resultDict: [String: Any] = [
336
+ "fileUri": manager.recordingFileURL?.absoluteString ?? "",
337
+ "lastEmittedSize": totalDataSize,
338
+ "encoded": data.base64EncodedString(),
339
+ "deltaSize": data.count,
340
+ "position": Int64(recordingTime * 1000),
330
341
  "mimeType": manager.mimeType,
342
+ "totalSize": totalDataSize,
331
343
  "streamUuid": manager.recordingUUID?.uuidString ?? UUID().uuidString
332
344
  ]
333
345
 
334
- // Emit the event to JavaScript
335
- sendEvent(audioDataEvent, eventBody)
346
+ if let compressionInfo = compressionInfo {
347
+ resultDict["compression"] = compressionInfo
348
+ }
349
+
350
+ sendEvent(audioDataEvent, resultDict)
336
351
  }
337
352
 
338
353
  private func requestNotificationPermissions() async -> Bool {
@@ -9,6 +9,7 @@ struct RecordingResult {
9
9
  var channels: Int
10
10
  var bitDepth: Int
11
11
  var sampleRate: Double
12
+ var compression: CompressedRecordingInfo?
12
13
  }
13
14
 
14
15
  struct StartRecordingResult {
@@ -17,4 +18,5 @@ struct StartRecordingResult {
17
18
  var channels: Int
18
19
  var bitDepth: Int
19
20
  var sampleRate: Double
21
+ var compression: CompressedRecordingInfo?
20
22
  }
@@ -17,6 +17,27 @@ struct IOSNotificationConfig {
17
17
  var categoryIdentifier: String?
18
18
  }
19
19
 
20
+ struct CompressedRecordingInfo {
21
+ var fileUri: String
22
+ var mimeType: String
23
+ var bitrate: Int
24
+ var format: String
25
+
26
+ static func validate(format: String, bitrate: Int) -> Result<Void, Error> {
27
+ // Validate format
28
+ guard ["aac", "opus"].contains(format.lowercased()) else {
29
+ return .failure(RecordingError.unsupportedFormat(format))
30
+ }
31
+
32
+ // Validate bitrate
33
+ guard (8000...960000).contains(bitrate) else {
34
+ return .failure(RecordingError.invalidBitrate(bitrate))
35
+ }
36
+
37
+ return .success(())
38
+ }
39
+ }
40
+
20
41
  struct NotificationConfig {
21
42
  var title: String?
22
43
  var text: String?
@@ -28,6 +49,20 @@ struct IOSConfig {
28
49
  var audioSession: IOSAudioSessionConfig?
29
50
  }
30
51
 
52
+ enum RecordingError: Error {
53
+ case unsupportedFormat(String)
54
+ case invalidBitrate(Int)
55
+
56
+ var localizedDescription: String {
57
+ switch self {
58
+ case .unsupportedFormat(let format):
59
+ return "Unsupported compression format: \(format). iOS only supports AAC."
60
+ case .invalidBitrate(let bitrate):
61
+ return "Invalid bitrate: \(bitrate). Must be between 8000 and 960000 bps."
62
+ }
63
+ }
64
+ }
65
+
31
66
  struct RecordingSettings {
32
67
  // Core recording settings
33
68
  var sampleRate: Double
@@ -52,10 +87,35 @@ struct RecordingSettings {
52
87
  // Notification configuration
53
88
  var notification: NotificationConfig?
54
89
 
55
- static func fromDictionary(_ dict: [String: Any]) -> RecordingSettings {
90
+ let enableCompressedOutput: Bool
91
+ let compressedFormat: String // "aac" or "opus"
92
+ let compressedBitRate: Int
93
+
94
+ static func fromDictionary(_ dict: [String: Any]) -> Result<RecordingSettings, Error> {
95
+ // Extract compression settings
96
+ let compression = dict["compression"] as? [String: Any]
97
+ let enableCompressedOutput = compression?["enabled"] as? Bool ?? false
98
+ let compressedFormat = (compression?["format"] as? String)?.lowercased() ?? "opus"
99
+ let compressedBitRate = compression?["bitrate"] as? Int ?? 24000
100
+
101
+ // Validate compression settings if enabled
102
+ if enableCompressedOutput {
103
+ // Validate format and bitrate
104
+ if case .failure(let error) = CompressedRecordingInfo.validate(
105
+ format: compressedFormat,
106
+ bitrate: compressedBitRate
107
+ ) {
108
+ return .failure(error)
109
+ }
110
+ }
111
+
112
+ // Create settings
56
113
  var settings = RecordingSettings(
57
114
  sampleRate: dict["sampleRate"] as? Double ?? 44100.0,
58
- desiredSampleRate: dict["desiredSampleRate"] as? Double ?? 44100.0
115
+ desiredSampleRate: dict["desiredSampleRate"] as? Double ?? 44100.0,
116
+ enableCompressedOutput: enableCompressedOutput,
117
+ compressedFormat: compressedFormat,
118
+ compressedBitRate: compressedBitRate
59
119
  )
60
120
 
61
121
  // Parse core settings
@@ -152,6 +212,6 @@ struct RecordingSettings {
152
212
  settings.notification = notificationConfig
153
213
  }
154
214
 
155
- return settings
215
+ return .success(settings)
156
216
  }
157
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-stream",
3
- "version": "1.7.2",
3
+ "version": "1.9.0",
4
4
  "description": "stream audio crossplatform",
5
5
  "license": "MIT",
6
6
  "main": "build/index.js",
@@ -9,6 +9,7 @@ const initContext: UseAudioRecorderState = {
9
9
  isPaused: false,
10
10
  durationMs: 0,
11
11
  size: 0,
12
+ compression: undefined,
12
13
  startRecording: async () => {
13
14
  throw new Error('AudioRecorderProvider not found')
14
15
  },
@@ -6,6 +6,13 @@ import {
6
6
  } from './AudioAnalysis/AudioAnalysis.types'
7
7
  import { AudioAnalysisEvent } from './events'
8
8
 
9
+ export interface CompressionInfo {
10
+ size: number
11
+ mimeType: string
12
+ bitrate: number
13
+ format: string
14
+ }
15
+
9
16
  export interface AudioStreamStatus {
10
17
  isRecording: boolean
11
18
  isPaused: boolean
@@ -13,6 +20,7 @@ export interface AudioStreamStatus {
13
20
  size: number
14
21
  interval: number
15
22
  mimeType: string
23
+ compression?: CompressionInfo
16
24
  }
17
25
 
18
26
  export interface AudioDataEvent {
@@ -21,6 +29,9 @@ export interface AudioDataEvent {
21
29
  fileUri: string
22
30
  eventDataSize: number
23
31
  totalSize: number
32
+ compression?: CompressionInfo & {
33
+ data?: string | Blob // Base64 (native) or Float32Array (web) encoded compressed data chunk
34
+ }
24
35
  }
25
36
 
26
37
  export type EncodingType = 'pcm_32bit' | 'pcm_16bit' | 'pcm_8bit'
@@ -58,8 +69,10 @@ export interface AudioRecording {
58
69
  bitDepth: BitDepth
59
70
  sampleRate: SampleRate
60
71
  transcripts?: TranscriberData[]
61
- wavPCMData?: Float32Array // Full PCM data for the recording in WAV format (only on web, for native use the fileUri)
62
72
  analysisData?: AudioAnalysis // Analysis data for the recording depending on enableProcessing flag
73
+ compression?: CompressionInfo & {
74
+ compressedFileUri: string
75
+ }
63
76
  }
64
77
 
65
78
  export interface StartRecordingResult {
@@ -68,6 +81,9 @@ export interface StartRecordingResult {
68
81
  channels?: number
69
82
  bitDepth?: BitDepth
70
83
  sampleRate?: SampleRate
84
+ compression?: CompressionInfo & {
85
+ compressedFileUri: string
86
+ }
71
87
  }
72
88
 
73
89
  export interface AudioSessionConfig {
@@ -147,6 +163,12 @@ export interface RecordingConfig {
147
163
 
148
164
  // Callback function to handle audio features extraction results
149
165
  onAudioAnalysis?: (_: AudioAnalysisEvent) => Promise<void>
166
+
167
+ compression?: {
168
+ enabled: boolean
169
+ format: 'aac' | 'opus' | 'mp3'
170
+ bitrate?: number
171
+ }
150
172
  }
151
173
 
152
174
  export interface NotificationConfig {
@@ -225,5 +247,6 @@ export interface UseAudioRecorderState {
225
247
  isPaused: boolean
226
248
  durationMs: number // Duration of the recording
227
249
  size: number // Size in bytes of the recorded audio
250
+ compression?: CompressionInfo
228
251
  analysisData?: AudioAnalysis // Analysis data for the recording depending on enableProcessing flag
229
252
  }