@siteed/audio-studio 3.0.5 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +108 -41
  3. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
  4. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
  5. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
  6. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
  7. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
  8. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +74 -22
  9. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +86 -19
  10. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  11. package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
  12. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
  13. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
  14. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
  15. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  16. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  17. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  18. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  19. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  20. package/build/cjs/AudioStudio.types.js.map +1 -1
  21. package/build/cjs/errors/AudioExtractionError.js +127 -0
  22. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  23. package/build/cjs/index.js +6 -1
  24. package/build/cjs/index.js.map +1 -1
  25. package/build/cjs/useAudioRecorder.js +36 -18
  26. package/build/cjs/useAudioRecorder.js.map +1 -1
  27. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  28. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  29. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  30. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  31. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  32. package/build/esm/AudioStudio.types.js.map +1 -1
  33. package/build/esm/errors/AudioExtractionError.js +122 -0
  34. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  35. package/build/esm/index.js +2 -0
  36. package/build/esm/index.js.map +1 -1
  37. package/build/esm/useAudioRecorder.js +36 -18
  38. package/build/esm/useAudioRecorder.js.map +1 -1
  39. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  40. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  41. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  42. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  43. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  44. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  45. package/build/types/AudioStudio.types.d.ts +14 -1
  46. package/build/types/AudioStudio.types.d.ts.map +1 -1
  47. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  48. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -1
  51. package/build/types/useAudioRecorder.d.ts.map +1 -1
  52. package/ios/AudioProcessor.swift +99 -0
  53. package/ios/AudioStreamManager.swift +79 -15
  54. package/ios/AudioStudioModule.swift +63 -0
  55. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
  56. package/package.json +7 -7
  57. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  58. package/src/AudioAnalysis/extractPreview.ts +118 -17
  59. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  60. package/src/AudioStudio.types.ts +15 -1
  61. package/src/errors/AudioExtractionError.ts +167 -0
  62. package/src/index.ts +10 -0
  63. package/src/useAudioRecorder.tsx +36 -14
@@ -1022,6 +1022,105 @@ public class AudioProcessor {
1022
1022
  /// - endTimeMs: Optional end time in milliseconds
1023
1023
  /// - featureOptions: The features to extract
1024
1024
  /// - Returns: An `AudioAnalysisData` object containing the extracted features
1025
+ public func extractPreviewBars(
1026
+ numberOfBars: Int,
1027
+ startTimeMs: Double? = nil,
1028
+ endTimeMs: Double? = nil,
1029
+ silenceRmsThreshold: Float = 0.01
1030
+ ) -> [String: Any]? {
1031
+ guard let audioFile = audioFile else {
1032
+ reject("FILE_NOT_INITIALIZED", "Audio file is not initialized.")
1033
+ return nil
1034
+ }
1035
+
1036
+ let requestedBars = max(1, numberOfBars)
1037
+ let sampleRate = audioFile.fileFormat.sampleRate
1038
+ let totalDurationMs = Double(audioFile.length) / sampleRate * 1000
1039
+ let effectiveStartMs = max(0, startTimeMs ?? 0)
1040
+ let effectiveEndMs = min(endTimeMs ?? totalDurationMs, totalDurationMs)
1041
+ let durationMs = max(1, effectiveEndMs - effectiveStartMs)
1042
+ let startFrame = AVAudioFramePosition(effectiveStartMs * sampleRate / 1000.0)
1043
+ let endFrame = AVAudioFramePosition(effectiveEndMs * sampleRate / 1000.0)
1044
+ let samplesInRange = Int(endFrame - startFrame)
1045
+
1046
+ guard samplesInRange > 0 else {
1047
+ reject("INVALID_RANGE", "Invalid sample range: contains no samples")
1048
+ return nil
1049
+ }
1050
+
1051
+ let framesPerBar = max(1, samplesInRange / requestedBars)
1052
+ let startTime = CACurrentMediaTime()
1053
+ var bars: [[String: Any]] = []
1054
+ bars.reserveCapacity(requestedBars)
1055
+ var minAmplitude: Float = .greatestFiniteMagnitude
1056
+ var maxAmplitude: Float = -.greatestFiniteMagnitude
1057
+ var minRms: Float = .greatestFiniteMagnitude
1058
+ var maxRms: Float = -.greatestFiniteMagnitude
1059
+
1060
+ for index in 0..<requestedBars {
1061
+ let barStartFrame = startFrame + AVAudioFramePosition(index * framesPerBar)
1062
+ let barEndFrame = min(startFrame + AVAudioFramePosition((index + 1) * framesPerBar), endFrame)
1063
+ let framesToRead = AVAudioFrameCount(barEndFrame - barStartFrame)
1064
+ if framesToRead == 0 { break }
1065
+
1066
+ do {
1067
+ audioFile.framePosition = barStartFrame
1068
+ guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: framesToRead) else { continue }
1069
+ try audioFile.read(into: buffer, frameCount: framesToRead)
1070
+ guard let floatData = buffer.floatChannelData else { continue }
1071
+
1072
+ var sumSquares: Float = 0
1073
+ var amplitude: Float = 0
1074
+ for frame in 0..<Int(buffer.frameLength) {
1075
+ let value = floatData[0][frame]
1076
+ sumSquares += value * value
1077
+ amplitude = max(amplitude, abs(value))
1078
+ }
1079
+ let frameLength = max(1, Int(buffer.frameLength))
1080
+ let rms = sqrt(sumSquares / Float(frameLength))
1081
+ minAmplitude = min(minAmplitude, amplitude)
1082
+ maxAmplitude = max(maxAmplitude, amplitude)
1083
+ minRms = min(minRms, rms)
1084
+ maxRms = max(maxRms, rms)
1085
+
1086
+ let startBarTimeMs = Double(barStartFrame - startFrame) / Double(samplesInRange) * durationMs
1087
+ let endBarTimeMs = Double(barEndFrame - startFrame) / Double(samplesInRange) * durationMs
1088
+ bars.append([
1089
+ "id": index,
1090
+ "amplitude": min(max(amplitude, 0), 1),
1091
+ "rms": min(max(rms, 0), 1),
1092
+ "silent": rms < silenceRmsThreshold,
1093
+ "startTimeMs": startBarTimeMs,
1094
+ "endTimeMs": max(startBarTimeMs, endBarTimeMs)
1095
+ ])
1096
+ } catch {
1097
+ reject("AUDIO_READ_ERROR", "Error reading audio data: \(error.localizedDescription)")
1098
+ return nil
1099
+ }
1100
+ }
1101
+
1102
+ guard !bars.isEmpty else {
1103
+ reject("PROCESSING_ERROR", "No preview bars were generated")
1104
+ return nil
1105
+ }
1106
+
1107
+ let bitDepth = audioFile.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int ?? 16
1108
+ let extractionTimeMs = Float((CACurrentMediaTime() - startTime) * 1000)
1109
+ return [
1110
+ "bars": bars,
1111
+ "durationMs": durationMs,
1112
+ "sampleRate": Int(sampleRate),
1113
+ "numberOfChannels": Int(audioFile.processingFormat.channelCount),
1114
+ "bitDepth": bitDepth,
1115
+ "samples": samplesInRange,
1116
+ "requestedNumberOfBars": requestedBars,
1117
+ "barDurationMs": durationMs / Double(bars.count),
1118
+ "amplitudeRange": ["min": minAmplitude, "max": maxAmplitude],
1119
+ "rmsRange": ["min": minRms, "max": maxRms],
1120
+ "extractionTimeMs": extractionTimeMs
1121
+ ]
1122
+ }
1123
+
1025
1124
  public func extractPreview(
1026
1125
  numberOfPoints: Int,
1027
1126
  startTimeMs: Double? = nil,
@@ -14,6 +14,7 @@ import UserNotifications
14
14
 
15
15
  // Constants
16
16
  internal let WAV_HEADER_SIZE: Int64 = 44 // Standard WAV header is 44 bytes
17
+ internal let MIN_AAC_COMPRESSED_SAMPLE_RATE: Double = 44100.0
17
18
 
18
19
  // Helper to convert to little-endian byte array
19
20
  extension UInt32 {
@@ -152,6 +153,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
152
153
 
153
154
  // Add property to track auto-resume preference
154
155
  private var autoResumeAfterInterruption: Bool = false
156
+ private var pausedBySystemInterruption: Bool = false
155
157
 
156
158
  // Add these properties
157
159
  private var emissionInterval: TimeInterval = 1.0 // Default 1 second
@@ -167,6 +169,28 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
167
169
  private var cachedWavFileSize: Int64 = 0
168
170
  private var cachedCompressedFileSize: Int64 = 0
169
171
 
172
+ /// Returns an AVAudioRecorder-compatible sample rate for AAC sidecar files.
173
+ ///
174
+ /// The primary WAV path can resample emitted PCM to `settings.sampleRate`,
175
+ /// but the compressed sidecar is produced by AVAudioRecorder directly from
176
+ /// the active audio session. AVAudioRecorder fails to prepare AAC/M4A files
177
+ /// below 44.1 kHz, so keep valid requested rates and otherwise use the
178
+ /// active session rate when available, falling back to 44.1 kHz.
179
+ internal static func compatibleAACCompressedSampleRate(
180
+ requestedSampleRate: Double,
181
+ sessionSampleRate: Double
182
+ ) -> Double {
183
+ if requestedSampleRate >= MIN_AAC_COMPRESSED_SAMPLE_RATE {
184
+ return requestedSampleRate
185
+ }
186
+
187
+ if sessionSampleRate.isFinite && sessionSampleRate >= MIN_AAC_COMPRESSED_SAMPLE_RATE {
188
+ return sessionSampleRate
189
+ }
190
+
191
+ return MIN_AAC_COMPRESSED_SAMPLE_RATE
192
+ }
193
+
170
194
  /// Initializes the AudioStreamManager
171
195
  override init() {
172
196
  super.init()
@@ -231,7 +255,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
231
255
  // Store the pause start time if not already paused
232
256
  if !wasSuspended {
233
257
  currentPauseStart = Date()
234
- pauseRecording()
258
+ pauseRecording(isSystemInterruption: true)
235
259
  }
236
260
 
237
261
  // Always notify delegate of interruption
@@ -248,17 +272,17 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
248
272
  if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
249
273
  let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
250
274
  Logger.debug("AudioStreamManager", "Interruption options - shouldResume: \(options.contains(.shouldResume))")
251
-
252
- // Calculate pause duration if we have a pause start time
253
- if let pauseStart = currentPauseStart {
254
- let pauseDuration = Date().timeIntervalSince(pauseStart)
255
- totalPausedDuration += pauseDuration
256
- currentPauseStart = nil
257
- Logger.debug("AudioStreamManager", "Added interruption pause duration: \(pauseDuration), total paused: \(totalPausedDuration)")
258
- }
259
-
260
- // For phone calls, we should auto-resume if enabled, regardless of previous pause state
261
- if autoResumeAfterInterruption && isRecording {
275
+
276
+ // Auto-resume only if this interruption paused the recording.
277
+ // If the user had already paused, preserve that intent.
278
+ // Keep currentPauseStart active until the actual resume so duration accounting
279
+ // excludes the full paused interval, including any post-interruption delay.
280
+ if AutoResumePolicy.shouldAutoResume(
281
+ autoResumeAfterInterruption: autoResumeAfterInterruption,
282
+ isRecording: isRecording,
283
+ isPaused: isPaused,
284
+ pausedBySystemInterruption: pausedBySystemInterruption
285
+ ) {
262
286
  // Add a longer delay for phone calls and ensure proper session setup
263
287
  DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
264
288
  guard let self = self else { return }
@@ -484,6 +508,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
484
508
  // If we can't restart, officially pause the recording
485
509
  if !isPaused {
486
510
  isPaused = true
511
+ pausedBySystemInterruption = false
487
512
  // Notify delegate
488
513
  delegate?.audioStreamManager(self, didPauseRecording: Date())
489
514
  }
@@ -906,6 +931,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
906
931
  lastEmittedCompressedSize = 0
907
932
  lastEmittedCompressedSizeAnalysis = 0
908
933
  isPaused = false
934
+ pausedBySystemInterruption = false
909
935
 
910
936
  // Create recording file first (unless primary output is disabled)
911
937
  if settings.output.primary.enabled {
@@ -995,10 +1021,27 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
995
1021
 
996
1022
  // Setup compressed recording if enabled
997
1023
  if settings.output.compressed.enabled {
1024
+ let isAACCompressedOutput = settings.output.compressed.format == "aac"
1025
+ let compressedSampleRate: Double
1026
+ if isAACCompressedOutput {
1027
+ compressedSampleRate = Self.compatibleAACCompressedSampleRate(
1028
+ requestedSampleRate: settings.sampleRate,
1029
+ sessionSampleRate: session.sampleRate
1030
+ )
1031
+ } else {
1032
+ compressedSampleRate = settings.sampleRate
1033
+ }
1034
+ if isAACCompressedOutput && compressedSampleRate != settings.sampleRate {
1035
+ Logger.debug(
1036
+ "AudioStreamManager",
1037
+ "Adjusted compressed AAC sample rate from \(settings.sampleRate)Hz to \(compressedSampleRate)Hz for AVAudioRecorder compatibility"
1038
+ )
1039
+ }
1040
+
998
1041
  // Create compressed settings
999
1042
  let compressedSettings: [String: Any] = [
1000
- AVFormatIDKey: settings.output.compressed.format == "aac" ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
1001
- AVSampleRateKey: Float64(settings.sampleRate),
1043
+ AVFormatIDKey: isAACCompressedOutput ? kAudioFormatMPEG4AAC : kAudioFormatOpus,
1044
+ AVSampleRateKey: compressedSampleRate,
1002
1045
  AVNumberOfChannelsKey: settings.numberOfChannels,
1003
1046
  AVEncoderBitRateKey: settings.output.compressed.bitrate,
1004
1047
  AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
@@ -1135,6 +1178,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1135
1178
  lastEmissionTimeAnalysis = Date()
1136
1179
  isRecording = true
1137
1180
  isPaused = false
1181
+ pausedBySystemInterruption = false
1138
1182
 
1139
1183
  // Start the audio engine
1140
1184
  try audioEngine.start()
@@ -1221,6 +1265,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1221
1265
  compressedFileURL = nil // Restore
1222
1266
  audioProcessor = nil // Restore
1223
1267
  recordingSettings = nil
1268
+ pausedBySystemInterruption = false
1224
1269
  isPrepared = false // Restore
1225
1270
  // --- End restored lines and removed log ---
1226
1271
 
@@ -1228,7 +1273,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1228
1273
  }
1229
1274
 
1230
1275
  /// Pauses the current audio recording.
1231
- func pauseRecording() {
1276
+ func pauseRecording(isSystemInterruption: Bool = false) {
1232
1277
  guard isRecording, !isPaused else { return }
1233
1278
 
1234
1279
  Logger.debug("Pausing recording...")
@@ -1254,6 +1299,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1254
1299
 
1255
1300
  // Update state
1256
1301
  isPaused = true
1302
+ pausedBySystemInterruption = isSystemInterruption
1257
1303
 
1258
1304
  // Stop the engine but don't remove the tap
1259
1305
  audioEngine.pause()
@@ -1302,6 +1348,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1302
1348
 
1303
1349
  // Update state
1304
1350
  isPaused = false
1351
+ pausedBySystemInterruption = false
1305
1352
 
1306
1353
  // Update notification state if enabled
1307
1354
  if recordingSettings?.showNotification == true {
@@ -1850,6 +1897,7 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
1850
1897
  let wasRecording = isRecording
1851
1898
  isRecording = false
1852
1899
  isPaused = false
1900
+ pausedBySystemInterruption = false
1853
1901
  isPrepared = false // Reset preparation state
1854
1902
 
1855
1903
  // If we were only prepared but never started recording, clean up and return nil
@@ -2352,6 +2400,22 @@ class AudioStreamManager: NSObject, AudioDeviceManagerDelegate {
2352
2400
  }
2353
2401
  }
2354
2402
 
2403
+ internal struct AutoResumePolicy {
2404
+ /// Auto-resume only when a system interruption caused the pause.
2405
+ /// User-initiated pauses must remain paused after the interruption ends.
2406
+ static func shouldAutoResume(
2407
+ autoResumeAfterInterruption: Bool,
2408
+ isRecording: Bool,
2409
+ isPaused: Bool,
2410
+ pausedBySystemInterruption: Bool
2411
+ ) -> Bool {
2412
+ return autoResumeAfterInterruption &&
2413
+ isRecording &&
2414
+ isPaused &&
2415
+ pausedBySystemInterruption
2416
+ }
2417
+ }
2418
+
2355
2419
  extension AudioStreamManager: UNUserNotificationCenterDelegate {
2356
2420
  func userNotificationCenter(
2357
2421
  _ center: UNUserNotificationCenter,
@@ -163,6 +163,10 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
163
163
  }
164
164
  })
165
165
  }
166
+ AsyncFunction("extractPreviewBars") { (options: [String: Any], promise: Promise) in
167
+ extractPreviewBars(options: options, promise: promise)
168
+ }
169
+
166
170
 
167
171
 
168
172
  /// Asynchronously starts audio recording with the given settings.
@@ -1052,6 +1056,65 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
1052
1056
  }
1053
1057
 
1054
1058
  /// Clears all audio files stored in the document directory.
1059
+ private func extractPreviewBars(options: [String: Any], promise: Promise) {
1060
+ Logger.debug("AudioStudioModule", "extractPreviewBars called with options: \(options)")
1061
+ guard let fileUri = options["fileUri"] as? String else {
1062
+ promise.reject("INVALID_ARGUMENTS", "Invalid file URI provided")
1063
+ return
1064
+ }
1065
+
1066
+ let url = URL(string: fileUri) ?? URL(fileURLWithPath: fileUri.replacingOccurrences(of: "file://", with: ""))
1067
+ let numberOfBars = (options["numberOfBars"] as? NSNumber)?.intValue ?? 100
1068
+ let startTimeMs = (options["startTimeMs"] as? NSNumber)?.doubleValue
1069
+ let endTimeMs = (options["endTimeMs"] as? NSNumber)?.doubleValue
1070
+ let decodingOptions = options["decodingOptions"] as? [String: Any]
1071
+ let silenceRmsThreshold = (decodingOptions?["silenceRmsThreshold"] as? NSNumber)?.floatValue ?? 0.01
1072
+
1073
+ DispatchQueue.global().async {
1074
+ self.resolvePreviewBars(
1075
+ url: url,
1076
+ numberOfBars: numberOfBars,
1077
+ startTimeMs: startTimeMs,
1078
+ endTimeMs: endTimeMs,
1079
+ silenceRmsThreshold: silenceRmsThreshold,
1080
+ promise: promise
1081
+ )
1082
+ }
1083
+ }
1084
+
1085
+ private func resolvePreviewBars(
1086
+ url: URL,
1087
+ numberOfBars: Int,
1088
+ startTimeMs: Double?,
1089
+ endTimeMs: Double?,
1090
+ silenceRmsThreshold: Float,
1091
+ promise: Promise
1092
+ ) {
1093
+ do {
1094
+ let audioProcessor = try previewBarsProcessor(for: url)
1095
+ guard let result = audioProcessor.extractPreviewBars(
1096
+ numberOfBars: numberOfBars,
1097
+ startTimeMs: startTimeMs,
1098
+ endTimeMs: endTimeMs,
1099
+ silenceRmsThreshold: silenceRmsThreshold
1100
+ ) else {
1101
+ promise.reject("PROCESSING_ERROR", "Failed to extract preview bars")
1102
+ return
1103
+ }
1104
+ promise.resolve(result)
1105
+ } catch {
1106
+ promise.reject("PROCESSING_ERROR", "Failed to initialize audio processor: \(error.localizedDescription)")
1107
+ }
1108
+ }
1109
+
1110
+ private func previewBarsProcessor(for url: URL) throws -> AudioProcessor {
1111
+ return try AudioProcessor(url: url, resolve: { _ in
1112
+ Logger.warn("AudioStudioModule", "extractPreviewBars: AudioProcessor resolve called unexpectedly.")
1113
+ }, reject: { code, message in
1114
+ Logger.warn("AudioStudioModule", "extractPreviewBars: AudioProcessor reject called unexpectedly: \(code) - \(message)")
1115
+ })
1116
+ }
1117
+
1055
1118
  private func clearAudioFiles() {
1056
1119
  let fileURLs = listAudioFiles() // This now returns full URLs as strings
1057
1120
  fileURLs.forEach { fileURLString in
@@ -22,6 +22,46 @@ class CompressedOnlyOutputTests: XCTestCase {
22
22
  }
23
23
 
24
24
  // MARK: - Test Compressed-Only Output (Issue #244)
25
+
26
+ func testAACCompressedSampleRateFallsBackForLowRequestedRate() {
27
+ XCTAssertEqual(
28
+ AudioStreamManager.compatibleAACCompressedSampleRate(
29
+ requestedSampleRate: 16000,
30
+ sessionSampleRate: 48000
31
+ ),
32
+ 48000,
33
+ "Low requested sample rates should use the active session rate when it is AAC-compatible"
34
+ )
35
+
36
+ XCTAssertEqual(
37
+ AudioStreamManager.compatibleAACCompressedSampleRate(
38
+ requestedSampleRate: 16000,
39
+ sessionSampleRate: 0
40
+ ),
41
+ 44100,
42
+ "Low requested sample rates should never be passed directly to AVAudioRecorder for AAC"
43
+ )
44
+ }
45
+
46
+ func testAACCompressedSampleRateKeepsCompatibleRequestedRate() {
47
+ XCTAssertEqual(
48
+ AudioStreamManager.compatibleAACCompressedSampleRate(
49
+ requestedSampleRate: 44100,
50
+ sessionSampleRate: 48000
51
+ ),
52
+ 44100,
53
+ "Already-compatible requested sample rates should preserve existing behavior"
54
+ )
55
+
56
+ XCTAssertEqual(
57
+ AudioStreamManager.compatibleAACCompressedSampleRate(
58
+ requestedSampleRate: 48000,
59
+ sessionSampleRate: 44100
60
+ ),
61
+ 48000,
62
+ "High requested sample rates should not be reduced to the session rate"
63
+ )
64
+ }
25
65
 
26
66
  func testCompressedOnlyOutputWithAAC() {
27
67
  // Given: Recording settings with primary disabled and compressed enabled (AAC)
@@ -291,4 +331,4 @@ class TestAudioStreamDelegate: AudioStreamManagerDelegate {
291
331
  func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String) {
292
332
  onError?(error)
293
333
  }
294
- }
334
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/audio-studio",
3
- "version": "3.0.5",
3
+ "version": "3.1.1",
4
4
  "description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -84,11 +84,11 @@
84
84
  "lint:fix": "expo-module lint --fix",
85
85
  "test": "expo-module test",
86
86
  "test:android": "yarn test:android:unit && yarn test:android:instrumented",
87
- "test:android:unit": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:test",
88
- "test:android:instrumented": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:connectedAndroidTest",
89
- "test:android:unit:watch": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:test --continuous",
87
+ "test:android:unit": "cd ../../apps/playground/android && ./gradlew :siteed-audio-studio:test",
88
+ "test:android:instrumented": "cd ../../apps/playground/android && ./gradlew :siteed-audio-studio:connectedAndroidTest",
89
+ "test:android:unit:watch": "cd ../../apps/playground/android && ./gradlew :siteed-audio-studio:test --continuous",
90
90
  "test:ios": "cd ../../apps/playground/ios && xcodebuild -workspace AudioDevPlayground.xcworkspace -scheme AudioDevPlayground -destination 'platform=iOS Simulator,name=iPhone 15' build",
91
- "test:coverage": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:jacocoTestReport",
91
+ "test:coverage": "cd ../../apps/playground/android && ./gradlew :siteed-audio-studio:jacocoTestReport",
92
92
  "typecheck": "tsc --noEmit",
93
93
  "docgen": "typedoc src/index.ts --plugin typedoc-plugin-markdown --readme none --out ../../documentation_site/docs/api-reference/API && node ../../scripts/escape-mdx-generics.js ../../documentation_site/docs/api-reference",
94
94
  "prepare": "yarn build && node -e \"require('fs').renameSync('./plugin/build/index.d.ts', './plugin/build/index.d.cts')\"",
@@ -125,8 +125,8 @@
125
125
  "expo-modules-core": "~3.0.0",
126
126
  "jest": "^29.7.0",
127
127
  "prettier": "^3.2.5",
128
- "react": "19.1.0",
129
- "react-native": "0.81.5",
128
+ "react": "19.2.0",
129
+ "react-native": "0.83.6",
130
130
  "rimraf": "^6.0.1",
131
131
  "size-limit": "^11.1.4",
132
132
  "ts-node": "^10.9.2",
@@ -14,6 +14,12 @@ export interface DecodingConfig {
14
14
  targetBitDepth?: BitDepth
15
15
  /** Whether to normalize audio levels (Android and Web) */
16
16
  normalizeAudio?: boolean
17
+ /**
18
+ * RMS threshold below which a segment is flagged silent.
19
+ * Range 0..1. Default 0.01.
20
+ * Currently applied as a JS post-process so the same behavior holds across iOS/Android/Web.
21
+ */
22
+ silenceRmsThreshold?: number
17
23
  }
18
24
 
19
25
  /**
@@ -163,6 +169,69 @@ export interface AudioRangeOptions {
163
169
  * Options for generating a quick preview of audio waveform.
164
170
  * This is optimized for UI rendering with a specified number of points.
165
171
  */
172
+ export interface PreviewBar {
173
+ /** Stable zero-based bar identifier. */
174
+ id: number
175
+ /** Peak amplitude for this bar, normalized to 0..1. */
176
+ amplitude: number
177
+ /** Root mean square amplitude for this bar, normalized to 0..1. */
178
+ rms: number
179
+ /** Whether this bar is below the configured silence RMS threshold. */
180
+ silent: boolean
181
+ /** Bar start time in milliseconds from the extracted range start. */
182
+ startTimeMs: number
183
+ /** Bar end time in milliseconds from the extracted range start. */
184
+ endTimeMs: number
185
+ }
186
+
187
+ /**
188
+ * Compact preview-bars result for UI waveform rendering.
189
+ * Unlike `AudioAnalysis`, this intentionally omits full `DataPoint` feature data.
190
+ */
191
+ export interface PreviewBarsResult {
192
+ bars: PreviewBar[]
193
+ durationMs: number
194
+ sampleRate: number
195
+ numberOfChannels: number
196
+ bitDepth: number
197
+ samples: number
198
+ /** Requested bar count before native/platform clamping. */
199
+ requestedNumberOfBars: number
200
+ /** Approximate duration represented by each bar. */
201
+ barDurationMs: number
202
+ amplitudeRange: {
203
+ min: number
204
+ max: number
205
+ }
206
+ rmsRange: {
207
+ min: number
208
+ max: number
209
+ }
210
+ extractionTimeMs: number
211
+ }
212
+
213
+ /**
214
+ * Options for extracting compact waveform preview bars for UI rendering.
215
+ */
216
+ export interface PreviewBarsOptions extends AudioRangeOptions {
217
+ /** URI of the audio file to analyze */
218
+ fileUri: string
219
+ /**
220
+ * Total number of bars to generate for the preview.
221
+ * @default 100
222
+ */
223
+ numberOfBars?: number
224
+ /** Optional logger for debugging. */
225
+ logger?: ConsoleLike
226
+ /** Optional configuration for decoding the audio file. */
227
+ decodingOptions?: DecodingConfig
228
+ /**
229
+ * Optional callback fired once per compact bar after extraction resolves.
230
+ * Native progressive streaming is not implied by this callback.
231
+ */
232
+ onBarReady?: (bar: PreviewBar, index: number, total: number) => void
233
+ }
234
+
166
235
  export interface PreviewOptions extends AudioRangeOptions {
167
236
  /** URI of the audio file to analyze */
168
237
  fileUri: string
@@ -184,6 +253,19 @@ export interface PreviewOptions extends AudioRangeOptions {
184
253
  * - normalizeAudio: false
185
254
  */
186
255
  decodingOptions?: DecodingConfig
256
+ /**
257
+ * Optional callback fired once per data point as the preview becomes available.
258
+ * Today the native module returns the full analysis in one shot; the points are then
259
+ * micro-batched on the JS side so consumers can render bars incrementally.
260
+ * Native progressive streaming is a future enhancement.
261
+ */
262
+ onPointReady?: (point: DataPoint, index: number, total: number) => void
263
+ /**
264
+ * Optional cancellation signal for JS-side progressive point emission.
265
+ * Aborting does not cancel native extraction after it has started, but it
266
+ * stops any queued `onPointReady` callbacks from an older request.
267
+ */
268
+ signal?: AbortSignal
187
269
  }
188
270
 
189
271
  /**