@siteed/audio-studio 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -4
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +852 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +167 -3
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/errors/AudioStreamError.js +161 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +82 -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 +534 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- package/build/cjs/utils/audioProcessing.js +14 -10
- package/build/cjs/utils/audioProcessing.js.map +1 -1
- package/build/esm/errors/AudioStreamError.js +156 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +80 -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 +527 -0
- package/build/esm/streamAudioData.js.map +1 -0
- package/build/esm/utils/audioProcessing.js +14 -10
- package/build/esm/utils/audioProcessing.js.map +1 -1
- 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 +119 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/build/types/utils/audioProcessing.d.ts +2 -2
- package/build/types/utils/audioProcessing.d.ts.map +1 -1
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioStreamDecoder.swift +614 -0
- package/ios/AudioStudioModule.swift +186 -3
- package/package.json +163 -146
- package/scripts/README.md +58 -0
- package/src/errors/AudioStreamError.test.ts +92 -0
- package/src/errors/AudioStreamError.ts +199 -0
- package/src/index.ts +24 -0
- package/src/streamAudioData.ts +758 -0
- package/src/utils/audioProcessing.ts +25 -14
- package/android/src/androidTest/assets/chorus.wav +0 -0
- package/android/src/androidTest/assets/jfk.wav +0 -0
- package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
- package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
- package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
- package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
- package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +0 -94
- package/android/src/test/resources/jfk.wav +0 -0
- package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
- package/android/src/test/resources/recorder_hello_world.wav +0 -0
- package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
- package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
- package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
- package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
- package/ios/AudioStudioTests/Info.plist +0 -22
- package/ios/AudioStudioTests/README.md +0 -39
- package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
- package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
- package/ios/tests/README.md +0 -41
- package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
- package/ios/tests/integration/buffer_duration_test.swift +0 -185
- package/ios/tests/integration/compressed_only_output_test.swift +0 -271
- package/ios/tests/integration/output_control_test.swift +0 -322
- package/ios/tests/integration/run_integration_tests.sh +0 -37
- package/ios/tests/opus_support_test_macos.swift +0 -154
- package/ios/tests/standalone/audio_processing_test.swift +0 -144
- package/ios/tests/standalone/audio_recording_test.swift +0 -277
- package/ios/tests/standalone/audio_streaming_test.swift +0 -249
- package/ios/tests/standalone/standalone_test.swift +0 -144
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// packages/audio-studio/ios/AudioStreamDecoder.swift
|
|
2
|
+
//
|
|
3
|
+
// Progressive file decoder for `streamAudioData`. Decodes a stored audio
|
|
4
|
+
// file into bounded Float32 chunks via AVAssetReader without materializing
|
|
5
|
+
// the full PCM range. Sanitizes samples (NaN/Inf -> 0, clamp to [-1, 1])
|
|
6
|
+
// and honors per-request cancellation + backpressure ack.
|
|
7
|
+
import AVFoundation
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
public protocol AudioStreamDecoderDelegate: AnyObject {
|
|
11
|
+
func streamDecoder(
|
|
12
|
+
_ decoder: AudioStreamDecoder,
|
|
13
|
+
didEmitChunk payload: [String: Any]
|
|
14
|
+
)
|
|
15
|
+
func streamDecoder(
|
|
16
|
+
_ decoder: AudioStreamDecoder,
|
|
17
|
+
didReportProgress payload: [String: Any]
|
|
18
|
+
)
|
|
19
|
+
func streamDecoder(
|
|
20
|
+
_ decoder: AudioStreamDecoder,
|
|
21
|
+
didCompleteWith payload: [String: Any]
|
|
22
|
+
)
|
|
23
|
+
func streamDecoder(
|
|
24
|
+
_ decoder: AudioStreamDecoder,
|
|
25
|
+
didFailWith payload: [String: Any]
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public final class AudioStreamDecoder {
|
|
30
|
+
public struct Options {
|
|
31
|
+
public let requestId: String
|
|
32
|
+
public let fileUri: String
|
|
33
|
+
public let startTimeMs: Double?
|
|
34
|
+
public let endTimeMs: Double?
|
|
35
|
+
public let targetSampleRate: Double?
|
|
36
|
+
public let channels: Int?
|
|
37
|
+
public let normalizeAudio: Bool
|
|
38
|
+
public let chunkDurationMs: Int
|
|
39
|
+
public let maxChunkBytes: Int?
|
|
40
|
+
public let maxBufferedChunks: Int
|
|
41
|
+
public let backpressureTimeoutMs: Double?
|
|
42
|
+
|
|
43
|
+
public init(
|
|
44
|
+
requestId: String,
|
|
45
|
+
fileUri: String,
|
|
46
|
+
startTimeMs: Double?,
|
|
47
|
+
endTimeMs: Double?,
|
|
48
|
+
targetSampleRate: Double?,
|
|
49
|
+
channels: Int?,
|
|
50
|
+
normalizeAudio: Bool,
|
|
51
|
+
chunkDurationMs: Int,
|
|
52
|
+
maxChunkBytes: Int?,
|
|
53
|
+
maxBufferedChunks: Int,
|
|
54
|
+
backpressureTimeoutMs: Double? = nil
|
|
55
|
+
) {
|
|
56
|
+
self.requestId = requestId
|
|
57
|
+
self.fileUri = fileUri
|
|
58
|
+
self.startTimeMs = startTimeMs
|
|
59
|
+
self.endTimeMs = endTimeMs
|
|
60
|
+
self.targetSampleRate = targetSampleRate
|
|
61
|
+
self.channels = channels
|
|
62
|
+
self.normalizeAudio = normalizeAudio
|
|
63
|
+
self.chunkDurationMs = chunkDurationMs
|
|
64
|
+
self.maxChunkBytes = maxChunkBytes
|
|
65
|
+
self.maxBufferedChunks = max(1, maxBufferedChunks)
|
|
66
|
+
self.backpressureTimeoutMs = backpressureTimeoutMs
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public weak var delegate: AudioStreamDecoderDelegate?
|
|
71
|
+
|
|
72
|
+
private let options: Options
|
|
73
|
+
private let queue: DispatchQueue
|
|
74
|
+
private let cancelLock = NSLock()
|
|
75
|
+
private var cancelled = false
|
|
76
|
+
private let ackCondition = NSCondition()
|
|
77
|
+
private var lastAckedIndex = -1
|
|
78
|
+
|
|
79
|
+
public init(options: Options) {
|
|
80
|
+
self.options = options
|
|
81
|
+
self.queue = DispatchQueue(
|
|
82
|
+
label: "net.siteed.audiostudio.streamdecoder.\(options.requestId)",
|
|
83
|
+
qos: .userInitiated
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public func start() {
|
|
88
|
+
queue.async { [weak self] in
|
|
89
|
+
self?.run()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public func cancel() {
|
|
94
|
+
cancelLock.lock()
|
|
95
|
+
cancelled = true
|
|
96
|
+
cancelLock.unlock()
|
|
97
|
+
ackCondition.lock()
|
|
98
|
+
ackCondition.broadcast()
|
|
99
|
+
ackCondition.unlock()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public func acknowledgeChunk(_ index: Int) {
|
|
103
|
+
ackCondition.lock()
|
|
104
|
+
if index > lastAckedIndex {
|
|
105
|
+
lastAckedIndex = index
|
|
106
|
+
}
|
|
107
|
+
ackCondition.broadcast()
|
|
108
|
+
ackCondition.unlock()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func isCancelled() -> Bool {
|
|
112
|
+
cancelLock.lock()
|
|
113
|
+
defer { cancelLock.unlock() }
|
|
114
|
+
return cancelled
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func run() {
|
|
118
|
+
let url: URL
|
|
119
|
+
if let parsed = URL(string: options.fileUri),
|
|
120
|
+
parsed.scheme != nil {
|
|
121
|
+
url = parsed
|
|
122
|
+
} else {
|
|
123
|
+
url = URL(fileURLWithPath: options.fileUri)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if !FileManager.default.fileExists(atPath: url.path) {
|
|
127
|
+
emitError(
|
|
128
|
+
code: "ERR_AUDIO_STREAM_FILE_NOT_FOUND",
|
|
129
|
+
message: "File not found: \(url.lastPathComponent)"
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let asset = AVURLAsset(url: url)
|
|
135
|
+
guard let audioTrack = asset.tracks(withMediaType: .audio).first else {
|
|
136
|
+
emitError(
|
|
137
|
+
code: "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
138
|
+
message: "No audio track found"
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let assetDurationMs: Double = {
|
|
144
|
+
let raw = CMTimeGetSeconds(asset.duration) * 1000.0
|
|
145
|
+
return raw.isFinite && raw > 0 ? raw : 0.0
|
|
146
|
+
}()
|
|
147
|
+
// Duration of the *decoded range*, not the whole asset, so progress
|
|
148
|
+
// and completion payloads match what the caller actually receives.
|
|
149
|
+
let totalDurationMs: Double = {
|
|
150
|
+
if let s = options.startTimeMs, let e = options.endTimeMs {
|
|
151
|
+
return max(0, e - s)
|
|
152
|
+
}
|
|
153
|
+
if let e = options.endTimeMs {
|
|
154
|
+
return max(0, e)
|
|
155
|
+
}
|
|
156
|
+
let start = options.startTimeMs ?? 0
|
|
157
|
+
return max(0, assetDurationMs - start)
|
|
158
|
+
}()
|
|
159
|
+
|
|
160
|
+
// Read source sample rate from track.
|
|
161
|
+
let sourceSampleRate: Double = {
|
|
162
|
+
guard let desc = audioTrack.formatDescriptions.first else { return 0 }
|
|
163
|
+
// swiftlint:disable:next force_cast
|
|
164
|
+
let formatDescription = desc as! CMAudioFormatDescription
|
|
165
|
+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(
|
|
166
|
+
formatDescription
|
|
167
|
+
)?.pointee else {
|
|
168
|
+
return 0
|
|
169
|
+
}
|
|
170
|
+
return asbd.mSampleRate
|
|
171
|
+
}()
|
|
172
|
+
let sourceChannelCount: Int = {
|
|
173
|
+
guard let desc = audioTrack.formatDescriptions.first else { return 1 }
|
|
174
|
+
// swiftlint:disable:next force_cast
|
|
175
|
+
let formatDescription = desc as! CMAudioFormatDescription
|
|
176
|
+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(
|
|
177
|
+
formatDescription
|
|
178
|
+
)?.pointee else {
|
|
179
|
+
return 1
|
|
180
|
+
}
|
|
181
|
+
return Int(asbd.mChannelsPerFrame)
|
|
182
|
+
}()
|
|
183
|
+
|
|
184
|
+
let outputSampleRate = options.targetSampleRate
|
|
185
|
+
?? (sourceSampleRate > 0 ? sourceSampleRate : 16000)
|
|
186
|
+
// Accept the caller's value only when it's positive; explicit `0` or
|
|
187
|
+
// negative input falls back to the source channel count (mirrors the
|
|
188
|
+
// Android guard) so we never produce empty chunks forever.
|
|
189
|
+
let outputChannels: Int = {
|
|
190
|
+
if let c = options.channels, c > 0 { return c }
|
|
191
|
+
return min(2, max(1, sourceChannelCount))
|
|
192
|
+
}()
|
|
193
|
+
|
|
194
|
+
let outputSettings: [String: Any] = [
|
|
195
|
+
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
196
|
+
AVLinearPCMBitDepthKey: 32,
|
|
197
|
+
AVLinearPCMIsFloatKey: true,
|
|
198
|
+
AVLinearPCMIsBigEndianKey: false,
|
|
199
|
+
AVLinearPCMIsNonInterleaved: false,
|
|
200
|
+
AVSampleRateKey: outputSampleRate,
|
|
201
|
+
AVNumberOfChannelsKey: outputChannels,
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
let reader: AVAssetReader
|
|
205
|
+
do {
|
|
206
|
+
reader = try AVAssetReader(asset: asset)
|
|
207
|
+
} catch {
|
|
208
|
+
emitError(
|
|
209
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
210
|
+
message: "Reader init failed: \(error.localizedDescription)"
|
|
211
|
+
)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if let startMs = options.startTimeMs, let endMs = options.endTimeMs {
|
|
216
|
+
if startMs < 0 || endMs <= startMs {
|
|
217
|
+
emitError(
|
|
218
|
+
code: "ERR_AUDIO_STREAM_INVALID_RANGE",
|
|
219
|
+
message: "Invalid time range"
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
let start = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
|
|
224
|
+
let duration = CMTime(
|
|
225
|
+
seconds: (endMs - startMs) / 1000.0,
|
|
226
|
+
preferredTimescale: 600
|
|
227
|
+
)
|
|
228
|
+
reader.timeRange = CMTimeRange(start: start, duration: duration)
|
|
229
|
+
} else if let startMs = options.startTimeMs {
|
|
230
|
+
let start = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
|
|
231
|
+
reader.timeRange = CMTimeRange(
|
|
232
|
+
start: start,
|
|
233
|
+
duration: CMTime.positiveInfinity
|
|
234
|
+
)
|
|
235
|
+
} else if let endMs = options.endTimeMs {
|
|
236
|
+
// endMs-only ranges are documented; treat missing startMs as 0 so
|
|
237
|
+
// iOS matches Android/Web behavior instead of silently decoding the
|
|
238
|
+
// full file.
|
|
239
|
+
let duration = CMTime(seconds: endMs / 1000.0, preferredTimescale: 600)
|
|
240
|
+
reader.timeRange = CMTimeRange(start: .zero, duration: duration)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let trackOutput = AVAssetReaderTrackOutput(
|
|
244
|
+
track: audioTrack,
|
|
245
|
+
outputSettings: outputSettings
|
|
246
|
+
)
|
|
247
|
+
trackOutput.alwaysCopiesSampleData = false
|
|
248
|
+
|
|
249
|
+
guard reader.canAdd(trackOutput) else {
|
|
250
|
+
emitError(
|
|
251
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
252
|
+
message: "Cannot attach decoder output"
|
|
253
|
+
)
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
reader.add(trackOutput)
|
|
257
|
+
|
|
258
|
+
guard reader.startReading() else {
|
|
259
|
+
let err = reader.error?.localizedDescription ?? "Unknown reader error"
|
|
260
|
+
emitError(
|
|
261
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
262
|
+
message: "startReading failed: \(err)"
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let samplesPerChunk = max(
|
|
268
|
+
outputChannels,
|
|
269
|
+
Int((Double(options.chunkDurationMs) / 1000.0) * outputSampleRate) *
|
|
270
|
+
outputChannels
|
|
271
|
+
)
|
|
272
|
+
let cappedSamplesPerChunk: Int = {
|
|
273
|
+
guard let maxBytes = options.maxChunkBytes else { return samplesPerChunk }
|
|
274
|
+
// Round to a multiple of `outputChannels` so a single interleaved
|
|
275
|
+
// frame is never split across chunks (which would yield a
|
|
276
|
+
// fractional `startSample` for the next chunk).
|
|
277
|
+
let rawMax = max(outputChannels, maxBytes / 4)
|
|
278
|
+
let maxFloats = max(
|
|
279
|
+
outputChannels,
|
|
280
|
+
(rawMax / outputChannels) * outputChannels
|
|
281
|
+
)
|
|
282
|
+
return max(outputChannels, min(samplesPerChunk, maxFloats))
|
|
283
|
+
}()
|
|
284
|
+
|
|
285
|
+
var pending = [Float]()
|
|
286
|
+
pending.reserveCapacity(cappedSamplesPerChunk * 2)
|
|
287
|
+
// Head-index cursor over `pending`. Avoids `removeFirst(n)` (which is
|
|
288
|
+
// O(remaining)) on every emitted chunk; we periodically compact when
|
|
289
|
+
// the head drifts far enough that the unused prefix becomes wasteful.
|
|
290
|
+
var pendingHead = 0
|
|
291
|
+
let compactThreshold = max(cappedSamplesPerChunk * 4, 4096)
|
|
292
|
+
var chunkIndex = 0
|
|
293
|
+
var totalSamples = 0
|
|
294
|
+
var processedMs: Double = 0
|
|
295
|
+
var cancelledByUser = false
|
|
296
|
+
var backpressureTimedOut = false
|
|
297
|
+
let rangeStartMs = options.startTimeMs ?? 0
|
|
298
|
+
|
|
299
|
+
while reader.status == .reading {
|
|
300
|
+
let shouldContinue = autoreleasepool { () -> Bool in
|
|
301
|
+
if isCancelled() {
|
|
302
|
+
cancelledByUser = true
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
guard let sampleBuffer = trackOutput.copyNextSampleBuffer() else {
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
guard let block = CMSampleBufferGetDataBuffer(sampleBuffer) else {
|
|
309
|
+
return true
|
|
310
|
+
}
|
|
311
|
+
let frameCount = CMSampleBufferGetNumSamples(sampleBuffer)
|
|
312
|
+
if frameCount <= 0 { return true }
|
|
313
|
+
|
|
314
|
+
var lengthAtOffset = 0
|
|
315
|
+
var totalLength = 0
|
|
316
|
+
var dataPointer: UnsafeMutablePointer<Int8>?
|
|
317
|
+
let status = CMBlockBufferGetDataPointer(
|
|
318
|
+
block,
|
|
319
|
+
atOffset: 0,
|
|
320
|
+
lengthAtOffsetOut: &lengthAtOffset,
|
|
321
|
+
totalLengthOut: &totalLength,
|
|
322
|
+
dataPointerOut: &dataPointer
|
|
323
|
+
)
|
|
324
|
+
guard status == kCMBlockBufferNoErr, let raw = dataPointer else {
|
|
325
|
+
return true
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let sampleCount = totalLength / MemoryLayout<Float>.size
|
|
329
|
+
let floatPtr = UnsafeRawPointer(raw)
|
|
330
|
+
.assumingMemoryBound(to: Float.self)
|
|
331
|
+
var idx = pending.count
|
|
332
|
+
pending.append(contentsOf: repeatElement(0, count: sampleCount))
|
|
333
|
+
for i in 0..<sampleCount {
|
|
334
|
+
let v = floatPtr[i]
|
|
335
|
+
if v.isNaN || v.isInfinite {
|
|
336
|
+
pending[idx] = 0
|
|
337
|
+
} else if options.normalizeAudio {
|
|
338
|
+
pending[idx] = max(-1.0, min(1.0, v))
|
|
339
|
+
} else {
|
|
340
|
+
pending[idx] = v
|
|
341
|
+
}
|
|
342
|
+
idx += 1
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
while (pending.count - pendingHead) >= cappedSamplesPerChunk {
|
|
346
|
+
let payload = Array(
|
|
347
|
+
pending[pendingHead..<(pendingHead + cappedSamplesPerChunk)]
|
|
348
|
+
)
|
|
349
|
+
pendingHead += cappedSamplesPerChunk
|
|
350
|
+
if pendingHead >= compactThreshold {
|
|
351
|
+
pending.removeFirst(pendingHead)
|
|
352
|
+
pendingHead = 0
|
|
353
|
+
}
|
|
354
|
+
let durationOfChunk = Double(cappedSamplesPerChunk)
|
|
355
|
+
/ (outputSampleRate * Double(outputChannels))
|
|
356
|
+
let startMs = rangeStartMs
|
|
357
|
+
+ (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
|
|
358
|
+
emitChunk(
|
|
359
|
+
index: chunkIndex,
|
|
360
|
+
startTimeMs: startMs,
|
|
361
|
+
endTimeMs: startMs + durationOfChunk * 1000.0,
|
|
362
|
+
startSample: totalSamples / outputChannels,
|
|
363
|
+
sampleRate: outputSampleRate,
|
|
364
|
+
channels: outputChannels,
|
|
365
|
+
samples: payload,
|
|
366
|
+
isFinal: false
|
|
367
|
+
)
|
|
368
|
+
chunkIndex += 1
|
|
369
|
+
totalSamples += cappedSamplesPerChunk
|
|
370
|
+
// Progress is *elapsed decoded time within the requested
|
|
371
|
+
// range* so `processedMs / durationMs` stays in [0, 1]
|
|
372
|
+
// regardless of `startTimeMs`. Chunk timestamps stay
|
|
373
|
+
// absolute (rangeStart + offset).
|
|
374
|
+
processedMs = (Double(totalSamples) /
|
|
375
|
+
(outputSampleRate * Double(outputChannels))) * 1000.0
|
|
376
|
+
emitProgress(
|
|
377
|
+
processedMs: processedMs,
|
|
378
|
+
durationMs: totalDurationMs,
|
|
379
|
+
emittedChunks: chunkIndex
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
switch waitForAckOrCancel(upTo: chunkIndex - 1) {
|
|
383
|
+
case .ok:
|
|
384
|
+
()
|
|
385
|
+
case .cancelled:
|
|
386
|
+
cancelledByUser = true
|
|
387
|
+
case .timedOut:
|
|
388
|
+
backpressureTimedOut = true
|
|
389
|
+
}
|
|
390
|
+
if cancelledByUser || backpressureTimedOut { return false }
|
|
391
|
+
}
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
if !shouldContinue { break }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if backpressureTimedOut {
|
|
398
|
+
reader.cancelReading()
|
|
399
|
+
emitError(
|
|
400
|
+
code: "ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT",
|
|
401
|
+
message: "Timed out waiting for JS acknowledgement after \(Int(options.backpressureTimeoutMs ?? 0))ms"
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if cancelledByUser {
|
|
407
|
+
reader.cancelReading()
|
|
408
|
+
emitError(
|
|
409
|
+
code: "ERR_AUDIO_STREAM_CANCELLED",
|
|
410
|
+
message: "Stream cancelled"
|
|
411
|
+
)
|
|
412
|
+
// `durationMs` reports the requested range (matches Android),
|
|
413
|
+
// `samples` reports what was actually decoded before cancel.
|
|
414
|
+
emitComplete(
|
|
415
|
+
durationMs: totalDurationMs,
|
|
416
|
+
sampleRate: outputSampleRate,
|
|
417
|
+
channels: outputChannels,
|
|
418
|
+
chunks: chunkIndex,
|
|
419
|
+
samples: totalSamples,
|
|
420
|
+
cancelled: true
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if reader.status == .failed {
|
|
426
|
+
let err = reader.error?.localizedDescription ?? "Unknown decode error"
|
|
427
|
+
emitError(
|
|
428
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
429
|
+
message: err
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Flush remaining samples as final chunk.
|
|
435
|
+
let tailCount = pending.count - pendingHead
|
|
436
|
+
if tailCount > 0 {
|
|
437
|
+
let tail = Array(pending[pendingHead..<pending.count])
|
|
438
|
+
let durationOfChunk = Double(tailCount)
|
|
439
|
+
/ (outputSampleRate * Double(outputChannels))
|
|
440
|
+
let startMs = rangeStartMs
|
|
441
|
+
+ (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
|
|
442
|
+
emitChunk(
|
|
443
|
+
index: chunkIndex,
|
|
444
|
+
startTimeMs: startMs,
|
|
445
|
+
endTimeMs: startMs + durationOfChunk * 1000.0,
|
|
446
|
+
startSample: totalSamples / outputChannels,
|
|
447
|
+
sampleRate: outputSampleRate,
|
|
448
|
+
channels: outputChannels,
|
|
449
|
+
samples: tail,
|
|
450
|
+
isFinal: true
|
|
451
|
+
)
|
|
452
|
+
totalSamples += tailCount
|
|
453
|
+
chunkIndex += 1
|
|
454
|
+
processedMs = (Double(totalSamples) /
|
|
455
|
+
(outputSampleRate * Double(outputChannels))) * 1000.0
|
|
456
|
+
// Mirror the per-chunk progress emission so consumers always see
|
|
457
|
+
// a final `processedMs / durationMs ≈ 1.0` from `onProgress`.
|
|
458
|
+
emitProgress(
|
|
459
|
+
processedMs: processedMs,
|
|
460
|
+
durationMs: totalDurationMs,
|
|
461
|
+
emittedChunks: chunkIndex
|
|
462
|
+
)
|
|
463
|
+
pending.removeAll(keepingCapacity: false)
|
|
464
|
+
pendingHead = 0
|
|
465
|
+
} else {
|
|
466
|
+
// Last emitted chunk wasn't marked final (or no data was decoded).
|
|
467
|
+
// Emit a zero-sample final tail so consumers that key off isFinal
|
|
468
|
+
// see termination even for empty-but-valid ranges.
|
|
469
|
+
let startMs = rangeStartMs
|
|
470
|
+
+ (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
|
|
471
|
+
emitChunk(
|
|
472
|
+
index: chunkIndex,
|
|
473
|
+
startTimeMs: startMs,
|
|
474
|
+
endTimeMs: startMs,
|
|
475
|
+
startSample: totalSamples / outputChannels,
|
|
476
|
+
sampleRate: outputSampleRate,
|
|
477
|
+
channels: outputChannels,
|
|
478
|
+
samples: [Float](),
|
|
479
|
+
isFinal: true
|
|
480
|
+
)
|
|
481
|
+
chunkIndex += 1
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
emitComplete(
|
|
485
|
+
durationMs: totalDurationMs > 0 ? totalDurationMs : processedMs,
|
|
486
|
+
sampleRate: outputSampleRate,
|
|
487
|
+
channels: outputChannels,
|
|
488
|
+
chunks: chunkIndex,
|
|
489
|
+
samples: totalSamples,
|
|
490
|
+
cancelled: false
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private enum AckWaitResult {
|
|
495
|
+
case ok
|
|
496
|
+
case cancelled
|
|
497
|
+
case timedOut
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// Blocks the decoder thread while too many unacked chunks are in flight.
|
|
501
|
+
private func waitForAckOrCancel(upTo index: Int) -> AckWaitResult {
|
|
502
|
+
let deadline = options.backpressureTimeoutMs
|
|
503
|
+
.flatMap { $0 > 0 ? Date().addingTimeInterval($0 / 1000.0) : nil }
|
|
504
|
+
ackCondition.lock()
|
|
505
|
+
defer { ackCondition.unlock() }
|
|
506
|
+
while true {
|
|
507
|
+
if isCancelled() {
|
|
508
|
+
return .cancelled
|
|
509
|
+
}
|
|
510
|
+
let inFlight = index - lastAckedIndex
|
|
511
|
+
if inFlight < options.maxBufferedChunks {
|
|
512
|
+
return .ok
|
|
513
|
+
}
|
|
514
|
+
if let deadline, Date() >= deadline {
|
|
515
|
+
return .timedOut
|
|
516
|
+
}
|
|
517
|
+
let nextWake = min(0.05, max(0.001, deadline?.timeIntervalSinceNow ?? 0.05))
|
|
518
|
+
ackCondition.wait(until: Date().addingTimeInterval(nextWake))
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private func emitChunk(
|
|
523
|
+
index: Int,
|
|
524
|
+
startTimeMs: Double,
|
|
525
|
+
endTimeMs: Double,
|
|
526
|
+
startSample: Int,
|
|
527
|
+
sampleRate: Double,
|
|
528
|
+
channels: Int,
|
|
529
|
+
samples: [Float],
|
|
530
|
+
isFinal: Bool
|
|
531
|
+
) {
|
|
532
|
+
let payload: [String: Any] = [
|
|
533
|
+
"requestId": options.requestId,
|
|
534
|
+
"chunkIndex": index,
|
|
535
|
+
"startTimeMs": startTimeMs,
|
|
536
|
+
"endTimeMs": endTimeMs,
|
|
537
|
+
"startSample": startSample,
|
|
538
|
+
"sampleCount": samples.count,
|
|
539
|
+
"sampleRate": sampleRate,
|
|
540
|
+
"channels": channels,
|
|
541
|
+
"samples": samples,
|
|
542
|
+
"isFinal": isFinal,
|
|
543
|
+
]
|
|
544
|
+
delegate?.streamDecoder(self, didEmitChunk: payload)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private func emitProgress(
|
|
548
|
+
processedMs: Double,
|
|
549
|
+
durationMs: Double,
|
|
550
|
+
emittedChunks: Int
|
|
551
|
+
) {
|
|
552
|
+
let progress: Double = durationMs > 0
|
|
553
|
+
? min(1.0, max(0.0, processedMs / durationMs))
|
|
554
|
+
: 0
|
|
555
|
+
let payload: [String: Any] = [
|
|
556
|
+
"requestId": options.requestId,
|
|
557
|
+
"processedMs": processedMs,
|
|
558
|
+
"durationMs": durationMs,
|
|
559
|
+
"progress": progress,
|
|
560
|
+
"emittedChunks": emittedChunks,
|
|
561
|
+
]
|
|
562
|
+
delegate?.streamDecoder(self, didReportProgress: payload)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private func emitComplete(
|
|
566
|
+
durationMs: Double,
|
|
567
|
+
sampleRate: Double,
|
|
568
|
+
channels: Int,
|
|
569
|
+
chunks: Int,
|
|
570
|
+
samples: Int,
|
|
571
|
+
cancelled: Bool
|
|
572
|
+
) {
|
|
573
|
+
let payload: [String: Any] = [
|
|
574
|
+
"requestId": options.requestId,
|
|
575
|
+
"durationMs": durationMs,
|
|
576
|
+
"sampleRate": sampleRate,
|
|
577
|
+
"channels": channels,
|
|
578
|
+
"chunks": chunks,
|
|
579
|
+
"samples": samples,
|
|
580
|
+
"cancelled": cancelled,
|
|
581
|
+
]
|
|
582
|
+
delegate?.streamDecoder(self, didCompleteWith: payload)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private func emitError(code: String, message: String) {
|
|
586
|
+
let payload: [String: Any] = [
|
|
587
|
+
"requestId": options.requestId,
|
|
588
|
+
"code": code,
|
|
589
|
+
"message": message,
|
|
590
|
+
]
|
|
591
|
+
delegate?.streamDecoder(self, didFailWith: payload)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Safe Float -> integer conversion. Replaces NaN/Inf with 0 and clamps the
|
|
596
|
+
/// scaled value into the integer range before constructing the bounded type,
|
|
597
|
+
/// avoiding the Swift trap that previously crashed the host app inside
|
|
598
|
+
/// `extractRawAudioData` on malformed samples.
|
|
599
|
+
@inline(__always)
|
|
600
|
+
public func safeFloatToInt16(_ sample: Float) -> Int16 {
|
|
601
|
+
let safe = sample.isFinite ? max(-1.0, min(1.0, sample)) : 0
|
|
602
|
+
let scaled = (safe * Float(Int16.max)).rounded()
|
|
603
|
+
let clamped = max(Float(Int16.min), min(Float(Int16.max), scaled))
|
|
604
|
+
return Int16(clamped)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
@inline(__always)
|
|
608
|
+
public func safeFloatToInt32(_ sample: Float) -> Int32 {
|
|
609
|
+
let safe = sample.isFinite ? max(-1.0, min(1.0, sample)) : 0
|
|
610
|
+
let scaled = Double(safe) * Double(Int32.max)
|
|
611
|
+
let rounded = scaled.rounded()
|
|
612
|
+
let clamped = max(Double(Int32.min), min(Double(Int32.max), rounded))
|
|
613
|
+
return Int32(clamped)
|
|
614
|
+
}
|