@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.
- package/CHANGELOG.md +34 -1
- package/README.md +6 -1
- package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +39 -0
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +124 -12
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +26 -2
- package/build/AudioRecorder.provider.d.ts.map +1 -1
- package/build/AudioRecorder.provider.js +1 -0
- package/build/AudioRecorder.provider.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +22 -1
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +15 -2
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +99 -40
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/WebRecorder.web.d.ts +14 -3
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +188 -100
- package/build/WebRecorder.web.js.map +1 -1
- package/build/events.d.ts +6 -0
- package/build/events.d.ts.map +1 -1
- package/build/events.js.map +1 -1
- package/build/useAudioRecorder.d.ts +2 -1
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +46 -5
- package/build/useAudioRecorder.js.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.js +65 -160
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
- package/ios/AudioStreamManager.swift +127 -8
- package/ios/AudioStreamManagerDelegate.swift +8 -2
- package/ios/ExpoAudioStreamModule.swift +61 -46
- package/ios/RecordingResult.swift +2 -0
- package/ios/RecordingSettings.swift +63 -3
- package/package.json +1 -1
- package/src/AudioRecorder.provider.tsx +1 -0
- package/src/ExpoAudioStream.types.ts +24 -1
- package/src/ExpoAudioStream.web.ts +111 -38
- package/src/WebRecorder.web.ts +238 -138
- package/src/events.ts +7 -0
- package/src/useAudioRecorder.tsx +68 -7
- 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
|
|
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
|
-
|
|
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:
|
|
581
|
-
bitDepth:
|
|
582
|
-
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
|
-
//
|
|
1112
|
-
|
|
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(
|
|
3
|
-
|
|
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
|
-
|
|
139
|
+
// Create settings with validation
|
|
140
|
+
let settingsResult = RecordingSettings.fromDictionary(options)
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
}
|