@siteed/audio-studio 3.1.0 → 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 (45) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +97 -50
  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/AudioStudioModule.kt +53 -10
  9. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  10. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  11. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  12. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  13. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  14. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  15. package/build/cjs/errors/AudioExtractionError.js +127 -0
  16. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  17. package/build/cjs/index.js +6 -1
  18. package/build/cjs/index.js.map +1 -1
  19. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  20. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  21. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  22. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  23. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  24. package/build/esm/errors/AudioExtractionError.js +122 -0
  25. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  26. package/build/esm/index.js +2 -0
  27. package/build/esm/index.js.map +1 -1
  28. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  29. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  30. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  31. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  32. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  33. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  34. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  35. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  36. package/build/types/index.d.ts +3 -0
  37. package/build/types/index.d.ts.map +1 -1
  38. package/ios/AudioProcessor.swift +99 -0
  39. package/ios/AudioStudioModule.swift +63 -0
  40. package/package.json +7 -7
  41. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  42. package/src/AudioAnalysis/extractPreview.ts +118 -17
  43. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  44. package/src/errors/AudioExtractionError.ts +167 -0
  45. package/src/index.ts +10 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Typed error class for audio extraction failures.
3
+ * Wraps native module errors with stable codes consumers can switch on.
4
+ */
5
+ export type AudioExtractionErrorCode = 'unsupported_codec' | 'malformed_file' | 'decode_failed' | 'permission_denied' | 'file_not_found' | 'unknown';
6
+ export interface AudioExtractionErrorPayload {
7
+ code: AudioExtractionErrorCode;
8
+ message: string;
9
+ nativeMessage?: string;
10
+ fileUri?: string;
11
+ }
12
+ export declare class AudioExtractionError extends Error {
13
+ readonly code: AudioExtractionErrorCode;
14
+ readonly nativeMessage?: string;
15
+ readonly fileUri?: string;
16
+ constructor(payload: AudioExtractionErrorPayload);
17
+ toJSON(): AudioExtractionErrorPayload;
18
+ }
19
+ /**
20
+ * Map a thrown native/JS value into an AudioExtractionError with a stable code.
21
+ * Heuristics inspect message text and known native error codes.
22
+ */
23
+ export declare function mapExtractionError(err: unknown, fileUri?: string): AudioExtractionError;
24
+ //# sourceMappingURL=AudioExtractionError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AudioExtractionError.d.ts","sourceRoot":"","sources":["../../../src/errors/AudioExtractionError.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,MAAM,wBAAwB,GAC9B,mBAAmB,GACnB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,gBAAgB,GAChB,SAAS,CAAA;AAEf,MAAM,WAAW,2BAA2B;IACxC,IAAI,EAAE,wBAAwB,CAAA;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAA;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;gBAEb,OAAO,EAAE,2BAA2B;IAQhD,MAAM,IAAI,2BAA2B;CAQxC;AAkED;;;GAGG;AACH,wBAAgB,kBAAkB,CAC9B,GAAG,EAAE,OAAO,EACZ,OAAO,CAAC,EAAE,MAAM,GACjB,oBAAoB,CAqDtB"}
@@ -14,7 +14,10 @@ export { getPlatformCapabilities, isEncodingSupported, isBitDepthSupported, getF
14
14
  export { AudioDeviceManager, audioDeviceManager } from './AudioDeviceManager';
15
15
  export { useAudioDevices } from './hooks/useAudioDevices';
16
16
  export { setMelSpectrogramWasmUrl } from './AudioAnalysis/wasmConfig';
17
+ export { extractPreviewBars } from './AudioAnalysis/extractPreviewBars';
17
18
  export { AudioRecorderProvider, AudioStudioModule, extractRawWavAnalysis, extractAudioAnalysis, extractPreview, trimAudio, extractAudioData, extractMelSpectrogram, initMelStreamingWasm, computeMelFrameWasm, MAX_DURATION_MS, useAudioRecorder, useSharedAudioRecorder, };
19
+ export { AudioExtractionError, mapExtractionError, } from './errors/AudioExtractionError';
20
+ export type { AudioExtractionErrorCode, AudioExtractionErrorPayload, } from './errors/AudioExtractionError';
18
21
  export type * from './AudioAnalysis/AudioAnalysis.types';
19
22
  export type * from './AudioStudio.types';
20
23
  /** @deprecated Use AudioStudioModule instead */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACH,qBAAqB,EACrB,oBAAoB,EACvB,MAAM,sCAAsC,CAAA;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,EACH,qBAAqB,EACrB,eAAe,EAClB,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EACH,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,oCAAoC,CAAA;AAC3C,OAAO,EACH,qBAAqB,EACrB,sBAAsB,EACzB,MAAM,0BAA0B,CAAA;AACjC,OAAO,iBAAiB,MAAM,qBAAqB,CAAA;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAErD,cAAc,6BAA6B,CAAA;AAC3C,cAAc,wBAAwB,CAAA;AACtC,cAAc,wBAAwB,CAAA;AAGtC,OAAO,EACH,uBAAuB,EACvB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,uBAAuB,EACvB,KAAK,oBAAoB,GAC5B,MAAM,iCAAiC,CAAA;AAGxC,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AAG7E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAEzD,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AAErE,OAAO,EACH,qBAAqB,EACrB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,EACf,gBAAgB,EAChB,sBAAsB,GACzB,CAAA;AAGD,mBAAmB,qCAAqC,CAAA;AACxD,mBAAmB,qBAAqB,CAAA;AAExC,gDAAgD;AAChD,eAAO,MAAM,qBAAqB,KAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACH,qBAAqB,EACrB,oBAAoB,EACvB,MAAM,sCAAsC,CAAA;AAC7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,EACH,qBAAqB,EACrB,eAAe,EAClB,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EACH,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,oCAAoC,CAAA;AAC3C,OAAO,EACH,qBAAqB,EACrB,sBAAsB,EACzB,MAAM,0BAA0B,CAAA;AACjC,OAAO,iBAAiB,MAAM,qBAAqB,CAAA;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAErD,cAAc,6BAA6B,CAAA;AAC3C,cAAc,wBAAwB,CAAA;AACtC,cAAc,wBAAwB,CAAA;AAGtC,OAAO,EACH,uBAAuB,EACvB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,uBAAuB,EACvB,KAAK,oBAAoB,GAC5B,MAAM,iCAAiC,CAAA;AAGxC,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AAG7E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAEzD,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAA;AACrE,OAAO,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAA;AAEvE,OAAO,EACH,qBAAqB,EACrB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,EACf,gBAAgB,EAChB,sBAAsB,GACzB,CAAA;AAED,OAAO,EACH,oBAAoB,EACpB,kBAAkB,GACrB,MAAM,+BAA+B,CAAA;AACtC,YAAY,EACR,wBAAwB,EACxB,2BAA2B,GAC9B,MAAM,+BAA+B,CAAA;AAGtC,mBAAmB,qCAAqC,CAAA;AACxD,mBAAmB,qBAAqB,CAAA;AAExC,gDAAgD;AAChD,eAAO,MAAM,qBAAqB,KAAoB,CAAA"}
@@ -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,
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/audio-studio",
3
- "version": "3.1.0",
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
  /**
@@ -1,34 +1,135 @@
1
- import { PreviewOptions, AudioAnalysis } from './AudioAnalysis.types'
1
+ import { mapExtractionError } from '../errors/AudioExtractionError'
2
+ import { PreviewOptions, AudioAnalysis, DataPoint } from './AudioAnalysis.types'
2
3
  import { extractAudioAnalysis } from './extractAudioAnalysis'
3
4
 
5
+ const DEFAULT_SILENCE_THRESHOLD = 0.01
6
+
7
+ /**
8
+ * Apply a silence threshold to the data points by recomputing the `silent` flag from rms.
9
+ * Returns a new array (does not mutate the source).
10
+ */
11
+ function applySilenceThreshold(
12
+ dataPoints: DataPoint[],
13
+ threshold: number
14
+ ): DataPoint[] {
15
+ return dataPoints.map((p) => ({
16
+ ...p,
17
+ silent: p.rms < threshold,
18
+ }))
19
+ }
20
+
21
+ const SMALL_TOTAL_INSTANT_THRESHOLD = 50
22
+ const PROGRESSIVE_BATCH_DELAY_MS = 30
23
+ const PROGRESSIVE_BATCH_COUNT = 8
24
+
25
+ /**
26
+ * Schedule progressive emission of points after the native one-shot resolve.
27
+ * Native progressive streaming is a future enhancement; today the points are
28
+ * micro-batched on the JS side so consumers (and the agentic recipe runner)
29
+ * can observe an in-flight `pointsReceived < totalPoints` window.
30
+ */
31
+ function emitPointsProgressively(
32
+ dataPoints: DataPoint[],
33
+ onPointReady: NonNullable<PreviewOptions['onPointReady']>,
34
+ signal?: PreviewOptions['signal'],
35
+ logger?: PreviewOptions['logger']
36
+ ): void {
37
+ const total = dataPoints.length
38
+ if (total === 0) return
39
+
40
+ const safeEmit = (point: DataPoint, index: number) => {
41
+ if (signal?.aborted) return
42
+ try {
43
+ onPointReady(point, index, total)
44
+ } catch (err) {
45
+ // Swallow callback errors so a buggy consumer cannot break extraction.
46
+ logger?.warn?.('extractPreview onPointReady callback failed', err)
47
+ }
48
+ }
49
+
50
+ if (signal?.aborted) return
51
+ if (total <= SMALL_TOTAL_INSTANT_THRESHOLD) {
52
+ for (let i = 0; i < total; i++) safeEmit(dataPoints[i], i)
53
+ return
54
+ }
55
+
56
+ // First quarter flushes immediately so the UI shows something within a frame.
57
+ const firstFlushCount = Math.max(1, Math.floor(total / 4))
58
+ for (let i = 0; i < firstFlushCount; i++) safeEmit(dataPoints[i], i)
59
+
60
+ if (firstFlushCount >= total) return
61
+
62
+ const remaining = total - firstFlushCount
63
+ const batchSize = Math.max(
64
+ 1,
65
+ Math.ceil(remaining / PROGRESSIVE_BATCH_COUNT)
66
+ )
67
+ let cursor = firstFlushCount
68
+ const pump = () => {
69
+ if (signal?.aborted) return
70
+ const end = Math.min(total, cursor + batchSize)
71
+ for (let i = cursor; i < end; i++) safeEmit(dataPoints[i], i)
72
+ cursor = end
73
+ if (cursor < total) {
74
+ setTimeout(pump, PROGRESSIVE_BATCH_DELAY_MS)
75
+ }
76
+ }
77
+ setTimeout(pump, PROGRESSIVE_BATCH_DELAY_MS)
78
+ }
79
+
4
80
  /**
5
81
  * Generates a simplified preview of the audio waveform for quick visualization.
6
82
  * Ideal for UI rendering with a specified number of points.
7
83
  *
8
84
  * @param options - The options for the preview, including file URI and time range.
9
85
  * @returns A promise that resolves to the audio preview data.
86
+ * @throws {AudioExtractionError} when the underlying extraction fails.
10
87
  */
11
88
  export async function extractPreview({
12
89
  fileUri,
13
90
  numberOfPoints = 100,
14
91
  startTimeMs = 0,
15
- endTimeMs = 30000, // First 30 seconds
92
+ endTimeMs = 30000,
16
93
  decodingOptions,
17
94
  logger,
95
+ onPointReady,
96
+ signal,
18
97
  }: PreviewOptions): Promise<AudioAnalysis> {
19
- const durationMs = endTimeMs - startTimeMs
20
- const segmentDurationMs = Math.floor(durationMs / numberOfPoints)
21
-
22
- // Call extractAudioAnalysis with calculated parameters
23
- const analysis = await extractAudioAnalysis({
24
- fileUri,
25
- startTimeMs,
26
- endTimeMs,
27
- logger,
28
- segmentDurationMs,
29
- decodingOptions,
30
- })
31
-
32
- // Transform the result into AudioPreview format
33
- return analysis
98
+ const durationMs = Math.max(1, endTimeMs - startTimeMs)
99
+ const segmentDurationMs = Math.max(
100
+ 1,
101
+ Math.floor(durationMs / numberOfPoints)
102
+ )
103
+
104
+ let analysis: AudioAnalysis
105
+ try {
106
+ analysis = await extractAudioAnalysis({
107
+ fileUri,
108
+ startTimeMs,
109
+ endTimeMs,
110
+ logger,
111
+ segmentDurationMs,
112
+ decodingOptions,
113
+ })
114
+ } catch (err) {
115
+ throw mapExtractionError(err, fileUri)
116
+ }
117
+
118
+ const threshold =
119
+ decodingOptions?.silenceRmsThreshold ?? DEFAULT_SILENCE_THRESHOLD
120
+ const adjusted: AudioAnalysis = {
121
+ ...analysis,
122
+ dataPoints: applySilenceThreshold(analysis.dataPoints, threshold),
123
+ }
124
+
125
+ if (onPointReady) {
126
+ emitPointsProgressively(
127
+ adjusted.dataPoints,
128
+ onPointReady,
129
+ signal,
130
+ logger
131
+ )
132
+ }
133
+
134
+ return adjusted
34
135
  }