@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +375 -4
  2. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +852 -0
  3. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +167 -3
  4. package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
  5. package/build/cjs/errors/AudioStreamError.js +161 -0
  6. package/build/cjs/errors/AudioStreamError.js.map +1 -0
  7. package/build/cjs/errors/AudioStreamError.test.js +82 -0
  8. package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
  9. package/build/cjs/index.js +7 -1
  10. package/build/cjs/index.js.map +1 -1
  11. package/build/cjs/streamAudioData.js +534 -0
  12. package/build/cjs/streamAudioData.js.map +1 -0
  13. package/build/cjs/utils/audioProcessing.js +14 -10
  14. package/build/cjs/utils/audioProcessing.js.map +1 -1
  15. package/build/esm/errors/AudioStreamError.js +156 -0
  16. package/build/esm/errors/AudioStreamError.js.map +1 -0
  17. package/build/esm/errors/AudioStreamError.test.js +80 -0
  18. package/build/esm/errors/AudioStreamError.test.js.map +1 -0
  19. package/build/esm/index.js +3 -1
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/streamAudioData.js +527 -0
  22. package/build/esm/streamAudioData.js.map +1 -0
  23. package/build/esm/utils/audioProcessing.js +14 -10
  24. package/build/esm/utils/audioProcessing.js.map +1 -1
  25. package/build/types/errors/AudioStreamError.d.ts +25 -0
  26. package/build/types/errors/AudioStreamError.d.ts.map +1 -0
  27. package/build/types/errors/AudioStreamError.test.d.ts +2 -0
  28. package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
  29. package/build/types/index.d.ts +5 -1
  30. package/build/types/index.d.ts.map +1 -1
  31. package/build/types/streamAudioData.d.ts +119 -0
  32. package/build/types/streamAudioData.d.ts.map +1 -0
  33. package/build/types/utils/audioProcessing.d.ts +2 -2
  34. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  35. package/ios/AudioProcessingHelpers.swift +10 -5
  36. package/ios/AudioStreamDecoder.swift +614 -0
  37. package/ios/AudioStudioModule.swift +186 -3
  38. package/package.json +163 -146
  39. package/scripts/README.md +58 -0
  40. package/src/errors/AudioStreamError.test.ts +92 -0
  41. package/src/errors/AudioStreamError.ts +199 -0
  42. package/src/index.ts +24 -0
  43. package/src/streamAudioData.ts +758 -0
  44. package/src/utils/audioProcessing.ts +25 -14
  45. package/android/src/androidTest/assets/chorus.wav +0 -0
  46. package/android/src/androidTest/assets/jfk.wav +0 -0
  47. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  48. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  49. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  50. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  51. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  52. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  53. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  54. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  55. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  56. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  57. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  58. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  59. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  60. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  61. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  62. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  63. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  64. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  65. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  66. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  67. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  68. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  69. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  70. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  71. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  72. package/android/src/test/resources/chorus.wav +0 -0
  73. package/android/src/test/resources/generate_test_audio.py +0 -94
  74. package/android/src/test/resources/jfk.wav +0 -0
  75. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  76. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  77. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  78. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  79. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  80. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  81. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  82. package/ios/AudioStudioTests/Info.plist +0 -22
  83. package/ios/AudioStudioTests/README.md +0 -39
  84. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  85. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  86. package/ios/tests/README.md +0 -41
  87. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  88. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  89. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  90. package/ios/tests/integration/output_control_test.swift +0 -322
  91. package/ios/tests/integration/run_integration_tests.sh +0 -37
  92. package/ios/tests/opus_support_test_macos.swift +0 -154
  93. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  94. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  95. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  96. 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
+ }