@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.
- package/CHANGELOG.md +19 -1
- package/README.md +108 -41
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
- package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +74 -22
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +86 -19
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
- package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
- package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
- package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/cjs/AudioStudio.types.js.map +1 -1
- package/build/cjs/errors/AudioExtractionError.js +127 -0
- package/build/cjs/errors/AudioExtractionError.js.map +1 -0
- package/build/cjs/index.js +6 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/useAudioRecorder.js +36 -18
- package/build/cjs/useAudioRecorder.js.map +1 -1
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreview.js +92 -15
- package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
- package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/esm/AudioStudio.types.js.map +1 -1
- package/build/esm/errors/AudioExtractionError.js +122 -0
- package/build/esm/errors/AudioExtractionError.js.map +1 -0
- package/build/esm/index.js +2 -0
- package/build/esm/index.js.map +1 -1
- package/build/esm/useAudioRecorder.js +36 -18
- package/build/esm/useAudioRecorder.js.map +1 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
- package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
- package/build/types/AudioStudio.types.d.ts +14 -1
- package/build/types/AudioStudio.types.d.ts.map +1 -1
- package/build/types/errors/AudioExtractionError.d.ts +24 -0
- package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -1
- package/build/types/useAudioRecorder.d.ts.map +1 -1
- package/ios/AudioProcessor.swift +99 -0
- package/ios/AudioStreamManager.swift +79 -15
- package/ios/AudioStudioModule.swift +63 -0
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
- package/package.json +7 -7
- package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
- package/src/AudioAnalysis/extractPreview.ts +118 -17
- package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
- package/src/AudioStudio.types.ts +15 -1
- package/src/errors/AudioExtractionError.ts +167 -0
- package/src/index.ts +10 -0
- package/src/useAudioRecorder.tsx +36 -14
package/ios/AudioProcessor.swift
CHANGED
|
@@ -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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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:
|
|
1001
|
-
AVSampleRateKey:
|
|
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.
|
|
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-
|
|
88
|
-
"test:android:instrumented": "cd ../../apps/playground/android && ./gradlew :siteed-
|
|
89
|
-
"test:android:unit:watch": "cd ../../apps/playground/android && ./gradlew :siteed-
|
|
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-
|
|
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.
|
|
129
|
-
"react-native": "0.
|
|
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
|
/**
|