@siteed/audio-studio 3.1.1 → 3.2.0-beta.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 +20 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +134 -3
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/errors/AudioStreamError.js +152 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +61 -0
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
- package/build/cjs/index.js +7 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/streamAudioData.js +467 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- package/build/esm/errors/AudioStreamError.js +147 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +59 -0
- package/build/esm/errors/AudioStreamError.test.js.map +1 -0
- package/build/esm/index.js +3 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/streamAudioData.js +460 -0
- package/build/esm/streamAudioData.js.map +1 -0
- package/build/types/errors/AudioStreamError.d.ts +25 -0
- package/build/types/errors/AudioStreamError.d.ts.map +1 -0
- package/build/types/errors/AudioStreamError.test.d.ts +2 -0
- package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
- package/build/types/index.d.ts +5 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +114 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioStreamDecoder.swift +523 -0
- package/ios/AudioStudioModule.swift +147 -3
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
- package/package.json +1 -1
- package/src/errors/AudioStreamError.test.ts +65 -0
- package/src/errors/AudioStreamError.ts +185 -0
- package/src/index.ts +24 -0
- package/src/streamAudioData.ts +654 -0
|
@@ -9,6 +9,10 @@ private let recordingInterruptedEvent: String = "onRecordingInterrupted"
|
|
|
9
9
|
private let deviceChangedEvent: String = "deviceChangedEvent"
|
|
10
10
|
private let trimProgressEvent: String = "TrimProgress"
|
|
11
11
|
private let errorEvent: String = "error"
|
|
12
|
+
private let audioStreamChunkEvent: String = "AudioDataStreamChunk"
|
|
13
|
+
private let audioStreamProgressEvent: String = "AudioDataStreamProgress"
|
|
14
|
+
private let audioStreamCompleteEvent: String = "AudioDataStreamComplete"
|
|
15
|
+
private let audioStreamErrorEvent: String = "AudioDataStreamError"
|
|
12
16
|
private let DEFAULT_SEGMENT_DURATION_MS = 100
|
|
13
17
|
private let audioDeviceTypeBuiltinMic = "builtin_mic"
|
|
14
18
|
private let audioDeviceTypeBluetooth = "bluetooth"
|
|
@@ -18,13 +22,16 @@ private let audioDeviceTypeWiredHeadphones = "wired_headphones"
|
|
|
18
22
|
private let audioDeviceTypeSpeaker = "speaker"
|
|
19
23
|
private let audioDeviceTypeUnknown = "unknown"
|
|
20
24
|
|
|
21
|
-
public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceManagerDelegate {
|
|
25
|
+
public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceManagerDelegate, AudioStreamDecoderDelegate {
|
|
22
26
|
private var streamManager = AudioStreamManager()
|
|
23
27
|
private let notificationCenter = UNUserNotificationCenter.current()
|
|
24
28
|
private let notificationIdentifier = "audio_recording_notification"
|
|
25
29
|
private var deviceManager = AudioDeviceManager()
|
|
26
30
|
private var deviceChangeObserver: Any?
|
|
27
31
|
|
|
32
|
+
private let streamDecodersLock = NSLock()
|
|
33
|
+
private var streamDecoders: [String: AudioStreamDecoder] = [:]
|
|
34
|
+
|
|
28
35
|
// Serial queue for AVAudioEngine lifecycle ops (prepare/start/stop).
|
|
29
36
|
// Prevents concurrent mutation of shared engine state and keeps callers
|
|
30
37
|
// off the main thread to avoid UI freezes during heavy native init.
|
|
@@ -43,7 +50,11 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
|
|
|
43
50
|
recordingInterruptedEvent,
|
|
44
51
|
deviceChangedEvent,
|
|
45
52
|
trimProgressEvent,
|
|
46
|
-
errorEvent
|
|
53
|
+
errorEvent,
|
|
54
|
+
audioStreamChunkEvent,
|
|
55
|
+
audioStreamProgressEvent,
|
|
56
|
+
audioStreamCompleteEvent,
|
|
57
|
+
audioStreamErrorEvent
|
|
47
58
|
])
|
|
48
59
|
|
|
49
60
|
OnCreate {
|
|
@@ -901,8 +912,141 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
|
|
|
901
912
|
}
|
|
902
913
|
}
|
|
903
914
|
}
|
|
915
|
+
|
|
916
|
+
AsyncFunction("streamAudioData") { (options: [String: Any], promise: Promise) in
|
|
917
|
+
guard let requestId = options["requestId"] as? String,
|
|
918
|
+
let fileUri = options["fileUri"] as? String else {
|
|
919
|
+
promise.reject(
|
|
920
|
+
"ERR_AUDIO_STREAM_INVALID_RANGE",
|
|
921
|
+
"fileUri and requestId are required"
|
|
922
|
+
)
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let streamFormat = options["streamFormat"] as? String ?? "float32"
|
|
927
|
+
guard streamFormat == "float32" else {
|
|
928
|
+
promise.reject(
|
|
929
|
+
"ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
930
|
+
"Only streamFormat='float32' is supported"
|
|
931
|
+
)
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
let opts = AudioStreamDecoder.Options(
|
|
936
|
+
requestId: requestId,
|
|
937
|
+
fileUri: fileUri,
|
|
938
|
+
startTimeMs: options["startTimeMs"] as? Double,
|
|
939
|
+
endTimeMs: options["endTimeMs"] as? Double,
|
|
940
|
+
targetSampleRate: options["targetSampleRate"] as? Double
|
|
941
|
+
?? (options["sampleRate"] as? Double),
|
|
942
|
+
channels: options["channels"] as? Int,
|
|
943
|
+
normalizeAudio: options["normalizeAudio"] as? Bool ?? true,
|
|
944
|
+
chunkDurationMs: options["chunkDurationMs"] as? Int ?? 1000,
|
|
945
|
+
maxChunkBytes: options["maxChunkBytes"] as? Int,
|
|
946
|
+
maxBufferedChunks: options["maxBufferedChunks"] as? Int ?? 4
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
let decoder = AudioStreamDecoder(options: opts)
|
|
950
|
+
decoder.delegate = self
|
|
951
|
+
self.streamDecodersLock.lock()
|
|
952
|
+
if self.streamDecoders[requestId] != nil {
|
|
953
|
+
self.streamDecodersLock.unlock()
|
|
954
|
+
promise.reject(
|
|
955
|
+
"ERR_AUDIO_STREAM_BUSY",
|
|
956
|
+
"requestId already in use"
|
|
957
|
+
)
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
self.streamDecoders[requestId] = decoder
|
|
961
|
+
self.streamDecodersLock.unlock()
|
|
962
|
+
decoder.start()
|
|
963
|
+
promise.resolve(["requestId": requestId])
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
AsyncFunction("cancelStreamAudioData") { (requestId: String, promise: Promise) in
|
|
967
|
+
self.streamDecodersLock.lock()
|
|
968
|
+
let decoder = self.streamDecoders[requestId]
|
|
969
|
+
self.streamDecodersLock.unlock()
|
|
970
|
+
decoder?.cancel()
|
|
971
|
+
promise.resolve(["requestId": requestId, "cancelled": decoder != nil])
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
Function("acknowledgeStreamAudioChunk") { (requestId: String, chunkIndex: Int) in
|
|
975
|
+
self.streamDecodersLock.lock()
|
|
976
|
+
let decoder = self.streamDecoders[requestId]
|
|
977
|
+
self.streamDecodersLock.unlock()
|
|
978
|
+
decoder?.acknowledgeChunk(chunkIndex)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
AsyncFunction("getAudioDecodeCapabilities") { (promise: Promise) in
|
|
982
|
+
promise.resolve([
|
|
983
|
+
"platform": "ios",
|
|
984
|
+
"supportedInputFormats": [
|
|
985
|
+
"audio/wav",
|
|
986
|
+
"audio/aac",
|
|
987
|
+
"audio/mp4",
|
|
988
|
+
"audio/mpeg",
|
|
989
|
+
"audio/x-m4a",
|
|
990
|
+
"audio/caf",
|
|
991
|
+
"audio/aiff",
|
|
992
|
+
],
|
|
993
|
+
"supportedOutputFormats": ["float32"],
|
|
994
|
+
"supportsCancellation": true,
|
|
995
|
+
"supportsBackpressure": true,
|
|
996
|
+
"supportsTimeRange": true,
|
|
997
|
+
"supportsTargetSampleRate": true,
|
|
998
|
+
"supportsChannelMixing": true,
|
|
999
|
+
"knownLimitations": [
|
|
1000
|
+
"Opus/WebM input depends on AVFoundation codec availability for the iOS version."
|
|
1001
|
+
],
|
|
1002
|
+
])
|
|
1003
|
+
}
|
|
904
1004
|
}
|
|
905
|
-
|
|
1005
|
+
|
|
1006
|
+
private func releaseStreamDecoder(_ requestId: String) {
|
|
1007
|
+
streamDecodersLock.lock()
|
|
1008
|
+
streamDecoders.removeValue(forKey: requestId)
|
|
1009
|
+
streamDecodersLock.unlock()
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// MARK: - AudioStreamDecoderDelegate
|
|
1013
|
+
|
|
1014
|
+
public func streamDecoder(
|
|
1015
|
+
_ decoder: AudioStreamDecoder,
|
|
1016
|
+
didEmitChunk payload: [String: Any]
|
|
1017
|
+
) {
|
|
1018
|
+
sendEvent(audioStreamChunkEvent, payload)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
public func streamDecoder(
|
|
1022
|
+
_ decoder: AudioStreamDecoder,
|
|
1023
|
+
didReportProgress payload: [String: Any]
|
|
1024
|
+
) {
|
|
1025
|
+
sendEvent(audioStreamProgressEvent, payload)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
public func streamDecoder(
|
|
1029
|
+
_ decoder: AudioStreamDecoder,
|
|
1030
|
+
didCompleteWith payload: [String: Any]
|
|
1031
|
+
) {
|
|
1032
|
+
if let requestId = payload["requestId"] as? String {
|
|
1033
|
+
releaseStreamDecoder(requestId)
|
|
1034
|
+
}
|
|
1035
|
+
sendEvent(audioStreamCompleteEvent, payload)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
public func streamDecoder(
|
|
1039
|
+
_ decoder: AudioStreamDecoder,
|
|
1040
|
+
didFailWith payload: [String: Any]
|
|
1041
|
+
) {
|
|
1042
|
+
if let requestId = payload["requestId"] as? String,
|
|
1043
|
+
let code = payload["code"] as? String,
|
|
1044
|
+
code != "ERR_AUDIO_STREAM_CANCELLED" {
|
|
1045
|
+
releaseStreamDecoder(requestId)
|
|
1046
|
+
}
|
|
1047
|
+
sendEvent(audioStreamErrorEvent, payload)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
906
1050
|
func audioStreamManager(_ manager: AudioStreamManager, didReceiveInterruption info: [String: Any]) {
|
|
907
1051
|
Logger.debug("AudioStudioModule", "Delegate: didReceiveInterruption: \(info)")
|
|
908
1052
|
// Convert iOS interruption events to match the TypeScript types
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import AudioStudio
|
|
3
|
+
|
|
4
|
+
final class AudioStreamDecoderTests: XCTestCase {
|
|
5
|
+
|
|
6
|
+
// MARK: - Sample sanitization
|
|
7
|
+
|
|
8
|
+
func testSafeFloatToInt16ReplacesNonFinite() {
|
|
9
|
+
XCTAssertEqual(safeFloatToInt16(Float.nan), 0)
|
|
10
|
+
XCTAssertEqual(safeFloatToInt16(Float.infinity), Int16.max)
|
|
11
|
+
XCTAssertEqual(safeFloatToInt16(-Float.infinity), Int16.min)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func testSafeFloatToInt16ClampsOutOfRange() {
|
|
15
|
+
XCTAssertEqual(safeFloatToInt16(2.0), Int16.max)
|
|
16
|
+
XCTAssertEqual(safeFloatToInt16(-2.0), Int16.min)
|
|
17
|
+
XCTAssertEqual(safeFloatToInt16(0.0), 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func testSafeFloatToInt16IdentityAtUnityIsBounded() {
|
|
21
|
+
// The previous Swift `Int16(1.0 * Float(Int16.max))` trap requires
|
|
22
|
+
// the result of the multiplication to fit Int16. The new helper
|
|
23
|
+
// must produce Int16.max for sample == 1.0 without trapping.
|
|
24
|
+
XCTAssertEqual(safeFloatToInt16(1.0), Int16.max)
|
|
25
|
+
XCTAssertEqual(safeFloatToInt16(-1.0), -Int16.max)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testSafeFloatToInt32ReplacesNonFinite() {
|
|
29
|
+
XCTAssertEqual(safeFloatToInt32(Float.nan), 0)
|
|
30
|
+
XCTAssertEqual(safeFloatToInt32(Float.infinity), Int32.max)
|
|
31
|
+
XCTAssertEqual(safeFloatToInt32(-Float.infinity), Int32.min)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testSafeFloatToInt32ClampsOutOfRange() {
|
|
35
|
+
XCTAssertEqual(safeFloatToInt32(5.0), Int32.max)
|
|
36
|
+
XCTAssertEqual(safeFloatToInt32(-5.0), Int32.min)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MARK: - Decoder option bounds
|
|
40
|
+
|
|
41
|
+
func testDecoderOptionsClampsChunkDuration() {
|
|
42
|
+
let opts = AudioStreamDecoder.Options(
|
|
43
|
+
requestId: "test",
|
|
44
|
+
fileUri: "/dev/null",
|
|
45
|
+
startTimeMs: nil,
|
|
46
|
+
endTimeMs: nil,
|
|
47
|
+
targetSampleRate: nil,
|
|
48
|
+
channels: nil,
|
|
49
|
+
normalizeAudio: true,
|
|
50
|
+
chunkDurationMs: 5,
|
|
51
|
+
maxChunkBytes: nil,
|
|
52
|
+
maxBufferedChunks: 0
|
|
53
|
+
)
|
|
54
|
+
XCTAssertEqual(opts.chunkDurationMs, 10, "chunkDurationMs floor is 10ms")
|
|
55
|
+
XCTAssertEqual(opts.maxBufferedChunks, 1, "maxBufferedChunks floor is 1")
|
|
56
|
+
|
|
57
|
+
let bigOpts = AudioStreamDecoder.Options(
|
|
58
|
+
requestId: "big",
|
|
59
|
+
fileUri: "/dev/null",
|
|
60
|
+
startTimeMs: nil,
|
|
61
|
+
endTimeMs: nil,
|
|
62
|
+
targetSampleRate: nil,
|
|
63
|
+
channels: nil,
|
|
64
|
+
normalizeAudio: true,
|
|
65
|
+
chunkDurationMs: 999_999,
|
|
66
|
+
maxChunkBytes: nil,
|
|
67
|
+
maxBufferedChunks: 99
|
|
68
|
+
)
|
|
69
|
+
XCTAssertEqual(bigOpts.chunkDurationMs, 60_000, "chunkDurationMs ceiling is 60s")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - Decoder event contract
|
|
73
|
+
|
|
74
|
+
final class CaptureDelegate: AudioStreamDecoderDelegate {
|
|
75
|
+
var chunks: [[String: Any]] = []
|
|
76
|
+
var progressEvents: [[String: Any]] = []
|
|
77
|
+
var completePayload: [String: Any]?
|
|
78
|
+
var errorPayload: [String: Any]?
|
|
79
|
+
let done = XCTestExpectation(description: "decoder terminal event")
|
|
80
|
+
|
|
81
|
+
func streamDecoder(_ decoder: AudioStreamDecoder, didEmitChunk payload: [String: Any]) {
|
|
82
|
+
chunks.append(payload)
|
|
83
|
+
if let idx = payload["chunkIndex"] as? Int {
|
|
84
|
+
decoder.acknowledgeChunk(idx)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
func streamDecoder(_ decoder: AudioStreamDecoder, didReportProgress payload: [String: Any]) {
|
|
88
|
+
progressEvents.append(payload)
|
|
89
|
+
}
|
|
90
|
+
func streamDecoder(_ decoder: AudioStreamDecoder, didCompleteWith payload: [String: Any]) {
|
|
91
|
+
completePayload = payload
|
|
92
|
+
done.fulfill()
|
|
93
|
+
}
|
|
94
|
+
func streamDecoder(_ decoder: AudioStreamDecoder, didFailWith payload: [String: Any]) {
|
|
95
|
+
errorPayload = payload
|
|
96
|
+
// Some flows emit an error then a complete; let complete fulfill.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func testDecoderEmitsFileNotFoundForMissingPath() {
|
|
101
|
+
let delegate = CaptureDelegate()
|
|
102
|
+
let opts = AudioStreamDecoder.Options(
|
|
103
|
+
requestId: "missing",
|
|
104
|
+
fileUri: "/tmp/this-file-does-not-exist-\(UUID().uuidString).wav",
|
|
105
|
+
startTimeMs: nil,
|
|
106
|
+
endTimeMs: nil,
|
|
107
|
+
targetSampleRate: nil,
|
|
108
|
+
channels: nil,
|
|
109
|
+
normalizeAudio: true,
|
|
110
|
+
chunkDurationMs: 100,
|
|
111
|
+
maxChunkBytes: nil,
|
|
112
|
+
maxBufferedChunks: 2
|
|
113
|
+
)
|
|
114
|
+
let decoder = AudioStreamDecoder(options: opts)
|
|
115
|
+
decoder.delegate = delegate
|
|
116
|
+
decoder.start()
|
|
117
|
+
// Error path never calls complete, so wait directly on the error.
|
|
118
|
+
let exp = XCTestExpectation(description: "error received")
|
|
119
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
|
|
120
|
+
if delegate.errorPayload != nil {
|
|
121
|
+
exp.fulfill()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
wait(for: [exp], timeout: 2.0)
|
|
125
|
+
XCTAssertEqual(delegate.errorPayload?["code"] as? String, "ERR_AUDIO_STREAM_FILE_NOT_FOUND")
|
|
126
|
+
XCTAssertEqual(delegate.errorPayload?["requestId"] as? String, "missing")
|
|
127
|
+
}
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siteed/audio-studio",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0-beta.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",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AudioStreamError, mapStreamError } from './AudioStreamError'
|
|
2
|
+
|
|
3
|
+
describe('AudioStreamError', () => {
|
|
4
|
+
it('passes through an existing AudioStreamError unchanged', () => {
|
|
5
|
+
const original = new AudioStreamError({
|
|
6
|
+
code: 'ERR_AUDIO_STREAM_CANCELLED',
|
|
7
|
+
message: 'aborted',
|
|
8
|
+
recoverable: true,
|
|
9
|
+
})
|
|
10
|
+
expect(mapStreamError(original)).toBe(original)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('maps native FILE_NOT_FOUND code', () => {
|
|
14
|
+
const mapped = mapStreamError({ code: 'FILE_NOT_FOUND', message: 'gone' })
|
|
15
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_FILE_NOT_FOUND')
|
|
16
|
+
expect(mapped.recoverable).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('maps unsupported codec text', () => {
|
|
20
|
+
const mapped = mapStreamError(new Error('No suitable codec for audio/opus'))
|
|
21
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('marks cancellation as recoverable', () => {
|
|
25
|
+
const mapped = mapStreamError({
|
|
26
|
+
code: 'ERR_AUDIO_STREAM_CANCELLED',
|
|
27
|
+
message: 'user cancelled',
|
|
28
|
+
})
|
|
29
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_CANCELLED')
|
|
30
|
+
expect(mapped.recoverable).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('falls back to UNKNOWN', () => {
|
|
34
|
+
const mapped = mapStreamError({})
|
|
35
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNKNOWN')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('preserves nativeCode and nativeMessage', () => {
|
|
39
|
+
const mapped = mapStreamError({
|
|
40
|
+
code: 'WEIRD_NATIVE_CODE',
|
|
41
|
+
message: 'something went wrong on the bridge',
|
|
42
|
+
})
|
|
43
|
+
expect(mapped.nativeCode).toBe('WEIRD_NATIVE_CODE')
|
|
44
|
+
expect(mapped.nativeMessage).toContain('bridge')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('serialises to a stable JSON payload', () => {
|
|
48
|
+
const err = new AudioStreamError({
|
|
49
|
+
code: 'ERR_AUDIO_STREAM_DECODE_FAILED',
|
|
50
|
+
message: 'decoder bust',
|
|
51
|
+
recoverable: false,
|
|
52
|
+
fileUri: 'file:///a.m4a',
|
|
53
|
+
platform: 'ios',
|
|
54
|
+
})
|
|
55
|
+
expect(err.toJSON()).toEqual({
|
|
56
|
+
code: 'ERR_AUDIO_STREAM_DECODE_FAILED',
|
|
57
|
+
message: 'decoder bust',
|
|
58
|
+
recoverable: false,
|
|
59
|
+
fileUri: 'file:///a.m4a',
|
|
60
|
+
platform: 'ios',
|
|
61
|
+
nativeCode: undefined,
|
|
62
|
+
nativeMessage: undefined,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable typed errors for `streamAudioData`. Callers can switch on `code`.
|
|
3
|
+
*/
|
|
4
|
+
export type AudioStreamErrorCode =
|
|
5
|
+
| 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT'
|
|
6
|
+
| 'ERR_AUDIO_STREAM_INVALID_RANGE'
|
|
7
|
+
| 'ERR_AUDIO_STREAM_DECODE_FAILED'
|
|
8
|
+
| 'ERR_AUDIO_STREAM_CANCELLED'
|
|
9
|
+
| 'ERR_AUDIO_STREAM_PERMISSION_DENIED'
|
|
10
|
+
| 'ERR_AUDIO_STREAM_FILE_NOT_FOUND'
|
|
11
|
+
| 'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT'
|
|
12
|
+
| 'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE'
|
|
13
|
+
| 'ERR_AUDIO_STREAM_BUSY'
|
|
14
|
+
| 'ERR_AUDIO_STREAM_UNKNOWN'
|
|
15
|
+
|
|
16
|
+
export interface AudioStreamErrorPayload {
|
|
17
|
+
code: AudioStreamErrorCode
|
|
18
|
+
message: string
|
|
19
|
+
recoverable: boolean
|
|
20
|
+
fileUri?: string
|
|
21
|
+
platform?: string
|
|
22
|
+
nativeCode?: string
|
|
23
|
+
nativeMessage?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RECOVERABLE: AudioStreamErrorCode[] = [
|
|
27
|
+
'ERR_AUDIO_STREAM_CANCELLED',
|
|
28
|
+
'ERR_AUDIO_STREAM_BUSY',
|
|
29
|
+
'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT',
|
|
30
|
+
'ERR_AUDIO_STREAM_PERMISSION_DENIED',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export class AudioStreamError extends Error {
|
|
34
|
+
readonly code: AudioStreamErrorCode
|
|
35
|
+
readonly recoverable: boolean
|
|
36
|
+
readonly fileUri?: string
|
|
37
|
+
readonly platform?: string
|
|
38
|
+
readonly nativeCode?: string
|
|
39
|
+
readonly nativeMessage?: string
|
|
40
|
+
|
|
41
|
+
constructor(payload: AudioStreamErrorPayload) {
|
|
42
|
+
super(payload.message)
|
|
43
|
+
this.name = 'AudioStreamError'
|
|
44
|
+
this.code = payload.code
|
|
45
|
+
this.recoverable = payload.recoverable
|
|
46
|
+
this.fileUri = payload.fileUri
|
|
47
|
+
this.platform = payload.platform
|
|
48
|
+
this.nativeCode = payload.nativeCode
|
|
49
|
+
this.nativeMessage = payload.nativeMessage
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toJSON(): AudioStreamErrorPayload {
|
|
53
|
+
return {
|
|
54
|
+
code: this.code,
|
|
55
|
+
message: this.message,
|
|
56
|
+
recoverable: this.recoverable,
|
|
57
|
+
fileUri: this.fileUri,
|
|
58
|
+
platform: this.platform,
|
|
59
|
+
nativeCode: this.nativeCode,
|
|
60
|
+
nativeMessage: this.nativeMessage,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getNativeMessage(err: unknown): string {
|
|
66
|
+
if (err instanceof Error) return err.message
|
|
67
|
+
if (typeof err === 'string') return err
|
|
68
|
+
try {
|
|
69
|
+
return JSON.stringify(err) ?? String(err)
|
|
70
|
+
} catch {
|
|
71
|
+
return String(err)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getNativeCode(err: unknown): string | undefined {
|
|
76
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
77
|
+
const code = (err as { code?: unknown }).code
|
|
78
|
+
if (typeof code === 'string') return code
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeCode(raw: string | undefined): AudioStreamErrorCode | null {
|
|
84
|
+
if (!raw) return null
|
|
85
|
+
const upper = raw.toUpperCase()
|
|
86
|
+
if (upper.startsWith('ERR_AUDIO_STREAM_')) {
|
|
87
|
+
const known: AudioStreamErrorCode[] = [
|
|
88
|
+
'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT',
|
|
89
|
+
'ERR_AUDIO_STREAM_INVALID_RANGE',
|
|
90
|
+
'ERR_AUDIO_STREAM_DECODE_FAILED',
|
|
91
|
+
'ERR_AUDIO_STREAM_CANCELLED',
|
|
92
|
+
'ERR_AUDIO_STREAM_PERMISSION_DENIED',
|
|
93
|
+
'ERR_AUDIO_STREAM_FILE_NOT_FOUND',
|
|
94
|
+
'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT',
|
|
95
|
+
'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE',
|
|
96
|
+
'ERR_AUDIO_STREAM_BUSY',
|
|
97
|
+
'ERR_AUDIO_STREAM_UNKNOWN',
|
|
98
|
+
]
|
|
99
|
+
if ((known as string[]).includes(upper)) {
|
|
100
|
+
return upper as AudioStreamErrorCode
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (upper.includes('FILE_NOT_FOUND') || upper === 'ENOENT') {
|
|
104
|
+
return 'ERR_AUDIO_STREAM_FILE_NOT_FOUND'
|
|
105
|
+
}
|
|
106
|
+
if (upper.includes('PERMISSION') || upper === 'EACCES') {
|
|
107
|
+
return 'ERR_AUDIO_STREAM_PERMISSION_DENIED'
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
upper.includes('UNSUPPORTED') ||
|
|
111
|
+
upper.includes('NO_SUITABLE_CODEC') ||
|
|
112
|
+
upper.includes('NO SUITABLE CODEC') ||
|
|
113
|
+
upper.includes('NOT SUPPORTED')
|
|
114
|
+
) {
|
|
115
|
+
return 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT'
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
upper.includes('INVALID_RANGE') ||
|
|
119
|
+
upper.includes('OUT_OF_RANGE') ||
|
|
120
|
+
upper.includes('INVALID_TIME')
|
|
121
|
+
) {
|
|
122
|
+
return 'ERR_AUDIO_STREAM_INVALID_RANGE'
|
|
123
|
+
}
|
|
124
|
+
if (upper.includes('CANCELLED') || upper.includes('CANCELED')) {
|
|
125
|
+
return 'ERR_AUDIO_STREAM_CANCELLED'
|
|
126
|
+
}
|
|
127
|
+
if (upper.includes('BUSY')) {
|
|
128
|
+
return 'ERR_AUDIO_STREAM_BUSY'
|
|
129
|
+
}
|
|
130
|
+
if (upper.includes('BACKPRESSURE')) {
|
|
131
|
+
return 'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT'
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
upper.includes('DECODE') ||
|
|
135
|
+
upper.includes('CODEC') ||
|
|
136
|
+
upper.includes('MALFORMED')
|
|
137
|
+
) {
|
|
138
|
+
return 'ERR_AUDIO_STREAM_DECODE_FAILED'
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function mapStreamError(
|
|
144
|
+
err: unknown,
|
|
145
|
+
fileUri?: string,
|
|
146
|
+
platform?: string
|
|
147
|
+
): AudioStreamError {
|
|
148
|
+
if (err instanceof AudioStreamError) return err
|
|
149
|
+
|
|
150
|
+
const nativeMessage = getNativeMessage(err)
|
|
151
|
+
const nativeCode = getNativeCode(err)
|
|
152
|
+
const lower = nativeMessage.toLowerCase()
|
|
153
|
+
|
|
154
|
+
let code =
|
|
155
|
+
normalizeCode(nativeCode) ??
|
|
156
|
+
normalizeCode(nativeMessage) ??
|
|
157
|
+
'ERR_AUDIO_STREAM_UNKNOWN'
|
|
158
|
+
|
|
159
|
+
if (code === 'ERR_AUDIO_STREAM_UNKNOWN') {
|
|
160
|
+
if (lower.includes('not found') || lower.includes('does not exist')) {
|
|
161
|
+
code = 'ERR_AUDIO_STREAM_FILE_NOT_FOUND'
|
|
162
|
+
} else if (
|
|
163
|
+
lower.includes('unsupported') ||
|
|
164
|
+
lower.includes('no suitable codec')
|
|
165
|
+
) {
|
|
166
|
+
code = 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT'
|
|
167
|
+
} else if (lower.includes('permission') || lower.includes('denied')) {
|
|
168
|
+
code = 'ERR_AUDIO_STREAM_PERMISSION_DENIED'
|
|
169
|
+
} else if (lower.includes('decode') || lower.includes('codec')) {
|
|
170
|
+
code = 'ERR_AUDIO_STREAM_DECODE_FAILED'
|
|
171
|
+
} else if (lower.includes('cancel')) {
|
|
172
|
+
code = 'ERR_AUDIO_STREAM_CANCELLED'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return new AudioStreamError({
|
|
177
|
+
code,
|
|
178
|
+
message: `Audio stream failed (${code}): ${nativeMessage}`,
|
|
179
|
+
recoverable: RECOVERABLE.includes(code),
|
|
180
|
+
fileUri,
|
|
181
|
+
platform,
|
|
182
|
+
nativeCode,
|
|
183
|
+
nativeMessage,
|
|
184
|
+
})
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
useSharedAudioRecorder,
|
|
20
20
|
} from './AudioRecorder.provider'
|
|
21
21
|
import AudioStudioModule from './AudioStudioModule'
|
|
22
|
+
import {
|
|
23
|
+
getAudioDecodeCapabilities,
|
|
24
|
+
streamAudioData,
|
|
25
|
+
} from './streamAudioData'
|
|
22
26
|
import { trimAudio } from './trimAudio'
|
|
23
27
|
import { useAudioRecorder } from './useAudioRecorder'
|
|
24
28
|
|
|
@@ -54,6 +58,8 @@ export {
|
|
|
54
58
|
extractPreview,
|
|
55
59
|
trimAudio,
|
|
56
60
|
extractAudioData,
|
|
61
|
+
streamAudioData,
|
|
62
|
+
getAudioDecodeCapabilities,
|
|
57
63
|
extractMelSpectrogram,
|
|
58
64
|
initMelStreamingWasm,
|
|
59
65
|
computeMelFrameWasm,
|
|
@@ -71,6 +77,24 @@ export type {
|
|
|
71
77
|
AudioExtractionErrorPayload,
|
|
72
78
|
} from './errors/AudioExtractionError'
|
|
73
79
|
|
|
80
|
+
export {
|
|
81
|
+
AudioStreamError,
|
|
82
|
+
mapStreamError,
|
|
83
|
+
} from './errors/AudioStreamError'
|
|
84
|
+
export type {
|
|
85
|
+
AudioStreamErrorCode,
|
|
86
|
+
AudioStreamErrorPayload,
|
|
87
|
+
} from './errors/AudioStreamError'
|
|
88
|
+
|
|
89
|
+
export type {
|
|
90
|
+
StreamAudioDataOptions,
|
|
91
|
+
StreamAudioDataChunk,
|
|
92
|
+
StreamAudioDataProgress,
|
|
93
|
+
StreamAudioDataResult,
|
|
94
|
+
StreamAudioDataCallbacks,
|
|
95
|
+
AudioDecodeCapabilities,
|
|
96
|
+
} from './streamAudioData'
|
|
97
|
+
|
|
74
98
|
// Export all types
|
|
75
99
|
export type * from './AudioAnalysis/AudioAnalysis.types'
|
|
76
100
|
export type * from './AudioStudio.types'
|