@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
  3. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +134 -3
  4. package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
  5. package/build/cjs/errors/AudioStreamError.js +152 -0
  6. package/build/cjs/errors/AudioStreamError.js.map +1 -0
  7. package/build/cjs/errors/AudioStreamError.test.js +61 -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 +467 -0
  12. package/build/cjs/streamAudioData.js.map +1 -0
  13. package/build/esm/errors/AudioStreamError.js +147 -0
  14. package/build/esm/errors/AudioStreamError.js.map +1 -0
  15. package/build/esm/errors/AudioStreamError.test.js +59 -0
  16. package/build/esm/errors/AudioStreamError.test.js.map +1 -0
  17. package/build/esm/index.js +3 -1
  18. package/build/esm/index.js.map +1 -1
  19. package/build/esm/streamAudioData.js +460 -0
  20. package/build/esm/streamAudioData.js.map +1 -0
  21. package/build/types/errors/AudioStreamError.d.ts +25 -0
  22. package/build/types/errors/AudioStreamError.d.ts.map +1 -0
  23. package/build/types/errors/AudioStreamError.test.d.ts +2 -0
  24. package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
  25. package/build/types/index.d.ts +5 -1
  26. package/build/types/index.d.ts.map +1 -1
  27. package/build/types/streamAudioData.d.ts +114 -0
  28. package/build/types/streamAudioData.d.ts.map +1 -0
  29. package/ios/AudioProcessingHelpers.swift +10 -5
  30. package/ios/AudioStreamDecoder.swift +523 -0
  31. package/ios/AudioStudioModule.swift +147 -3
  32. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
  33. package/package.json +1 -1
  34. package/src/errors/AudioStreamError.test.ts +65 -0
  35. package/src/errors/AudioStreamError.ts +185 -0
  36. package/src/index.ts +24 -0
  37. package/src/streamAudioData.ts +654 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * High-level API: stream decoded audio from a stored file as bounded Float32
3
+ * chunks without materializing the full PCM range in memory.
4
+ *
5
+ * See `docs/STREAM_AUDIO_DATA.md` for the full contract and rollout notes.
6
+ */
7
+ export interface StreamAudioDataOptions {
8
+ /** URI of the audio file to decode. */
9
+ fileUri: string;
10
+ /** Start time in milliseconds (default: 0). */
11
+ startTimeMs?: number;
12
+ /** End time in milliseconds (default: end-of-file). */
13
+ endTimeMs?: number;
14
+ /**
15
+ * Source sample rate hint. Ignored if `targetSampleRate` is set; native
16
+ * decoders read the actual rate from the file.
17
+ */
18
+ sampleRate?: number;
19
+ /** Output sample rate. Native resamples when this differs from the file. */
20
+ targetSampleRate?: number;
21
+ /** Output channel count (1 = mono downmix, 2 = stereo passthrough). */
22
+ channels?: number;
23
+ /** Clamp samples to [-1, 1] and replace non-finite values with 0. */
24
+ normalizeAudio?: boolean;
25
+ /** Target chunk duration in ms (default: 1000, min: 10, max: 60000). */
26
+ chunkDurationMs?: number;
27
+ /** Soft cap on chunk size in bytes (Float32 = 4 bytes/sample). */
28
+ maxChunkBytes?: number;
29
+ /** Max chunks queued in native before JS ack pauses decode (default: 4). */
30
+ maxBufferedChunks?: number;
31
+ /** Output PCM format; only `'float32'` supported today. */
32
+ streamFormat?: 'float32';
33
+ /** Abort the in-flight request. Resolves promise with `cancelled: true`. */
34
+ signal?: AbortSignal;
35
+ }
36
+ export interface StreamAudioDataChunk {
37
+ /** Native request id; constant across all chunks of one call. */
38
+ requestId: string;
39
+ /** Zero-based monotonic chunk index. */
40
+ chunkIndex: number;
41
+ /** Start time in output-rate ms (rounded to nearest sample). */
42
+ startTimeMs: number;
43
+ /** End time in output-rate ms. */
44
+ endTimeMs: number;
45
+ /** Duration in ms (`endTimeMs - startTimeMs`). */
46
+ durationMs: number;
47
+ /** First sample index in the output timeline. */
48
+ startSample: number;
49
+ /** Sample count in `samples` (interleaved if channels > 1). */
50
+ sampleCount: number;
51
+ /** Output sample rate. */
52
+ sampleRate: number;
53
+ /** Output channel count. */
54
+ channels: number;
55
+ /** Interleaved Float32 samples in [-1, 1]. */
56
+ samples: Float32Array;
57
+ /** True for the last chunk of a non-cancelled run. */
58
+ isFinal: boolean;
59
+ }
60
+ export interface StreamAudioDataProgress {
61
+ requestId: string;
62
+ processedMs: number;
63
+ durationMs: number;
64
+ progress: number;
65
+ emittedChunks: number;
66
+ bufferedChunks?: number;
67
+ }
68
+ export interface StreamAudioDataResult {
69
+ requestId: string;
70
+ durationMs: number;
71
+ sampleRate: number;
72
+ channels: number;
73
+ chunks: number;
74
+ samples: number;
75
+ cancelled: boolean;
76
+ }
77
+ export interface StreamAudioDataCallbacks {
78
+ /**
79
+ * Called with each decoded chunk. If this returns a Promise, native decode
80
+ * pauses until it resolves (backpressure). Throwing aborts the stream with
81
+ * `ERR_AUDIO_STREAM_DECODE_FAILED`.
82
+ */
83
+ onChunk: (chunk: StreamAudioDataChunk) => void | Promise<void>;
84
+ /** Called whenever native reports progress. */
85
+ onProgress?: (progress: StreamAudioDataProgress) => void;
86
+ }
87
+ export interface AudioDecodeCapabilities {
88
+ platform: 'ios' | 'android' | 'web';
89
+ supportedInputFormats: string[];
90
+ supportedOutputFormats: Array<'float32'>;
91
+ supportsCancellation: boolean;
92
+ supportsBackpressure: boolean;
93
+ supportsTimeRange: boolean;
94
+ supportsTargetSampleRate: boolean;
95
+ supportsChannelMixing: boolean;
96
+ knownLimitations?: string[];
97
+ }
98
+ /**
99
+ * Stream decoded audio from a stored file as bounded Float32 PCM chunks.
100
+ *
101
+ * Memory bound:
102
+ * `chunkDurationMs * sampleRate * channels * 4 * maxBufferedChunks` +
103
+ * native decoder buffers.
104
+ *
105
+ * Cancellation: pass `options.signal` and call `abort()`. The returned promise
106
+ * resolves with `cancelled: true` (it does not reject) when cancellation wins.
107
+ *
108
+ * Backpressure: if `onChunk` returns a Promise, native decode is paused until
109
+ * it resolves; if it throws, the stream is aborted with a `decode_failed` error.
110
+ */
111
+ export declare function streamAudioData(options: StreamAudioDataOptions, callbacks: StreamAudioDataCallbacks): Promise<StreamAudioDataResult>;
112
+ /** Discover what the running platform supports. */
113
+ export declare function getAudioDecodeCapabilities(): Promise<AudioDecodeCapabilities>;
114
+ //# sourceMappingURL=streamAudioData.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streamAudioData.d.ts","sourceRoot":"","sources":["../../src/streamAudioData.ts"],"names":[],"mappings":"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"}
@@ -681,15 +681,20 @@ func extractRawAudioData(
681
681
  for channel in 0..<channels {
682
682
  let sample = floatData[channel][frame]
683
683
 
684
- let normalizedSample = decodingConfig.normalizeAudio ?
685
- max(-1.0, min(1.0, sample)) : sample
686
-
684
+ // Sanitize: replace NaN/Inf with 0 then clamp. Skip clamp when
685
+ // normalizeAudio=false so callers see the raw decoded magnitude,
686
+ // but always finite-check to avoid Swift's `Int16(_:)` /
687
+ // `Int32(_:)` trap on non-finite values.
688
+ let safeSample: Float = sample.isFinite ? sample : 0
689
+ let normalizedSample = decodingConfig.normalizeAudio ?
690
+ max(-1.0, min(1.0, safeSample)) : safeSample
691
+
687
692
  switch targetBitDepth {
688
693
  case 16:
689
- let intValue = Int16(normalizedSample * Float(Int16.max))
694
+ let intValue = safeFloatToInt16(normalizedSample)
690
695
  pcmData.append(contentsOf: withUnsafeBytes(of: intValue) { Array($0) })
691
696
  case 32:
692
- let intValue = Int32(normalizedSample * Float(Int32.max))
697
+ let intValue = safeFloatToInt32(normalizedSample)
693
698
  pcmData.append(contentsOf: withUnsafeBytes(of: intValue) { Array($0) })
694
699
  default:
695
700
  throw NSError(domain: "AudioProcessing", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported bit depth \(targetBitDepth)"])
@@ -0,0 +1,523 @@
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
+
42
+ public init(
43
+ requestId: String,
44
+ fileUri: String,
45
+ startTimeMs: Double?,
46
+ endTimeMs: Double?,
47
+ targetSampleRate: Double?,
48
+ channels: Int?,
49
+ normalizeAudio: Bool,
50
+ chunkDurationMs: Int,
51
+ maxChunkBytes: Int?,
52
+ maxBufferedChunks: Int
53
+ ) {
54
+ self.requestId = requestId
55
+ self.fileUri = fileUri
56
+ self.startTimeMs = startTimeMs
57
+ self.endTimeMs = endTimeMs
58
+ self.targetSampleRate = targetSampleRate
59
+ self.channels = channels
60
+ self.normalizeAudio = normalizeAudio
61
+ self.chunkDurationMs = max(10, min(60000, chunkDurationMs))
62
+ self.maxChunkBytes = maxChunkBytes
63
+ self.maxBufferedChunks = max(1, maxBufferedChunks)
64
+ }
65
+ }
66
+
67
+ public weak var delegate: AudioStreamDecoderDelegate?
68
+
69
+ private let options: Options
70
+ private let queue: DispatchQueue
71
+ private let cancelLock = NSLock()
72
+ private var cancelled = false
73
+ private let ackLock = NSLock()
74
+ private let ackCondition = NSCondition()
75
+ private var lastAckedIndex = -1
76
+ private var lastEmittedIndex = -1
77
+
78
+ public init(options: Options) {
79
+ self.options = options
80
+ self.queue = DispatchQueue(
81
+ label: "net.siteed.audiostudio.streamdecoder.\(options.requestId)",
82
+ qos: .userInitiated
83
+ )
84
+ }
85
+
86
+ public func start() {
87
+ queue.async { [weak self] in
88
+ self?.run()
89
+ }
90
+ }
91
+
92
+ public func cancel() {
93
+ cancelLock.lock()
94
+ cancelled = true
95
+ cancelLock.unlock()
96
+ ackCondition.lock()
97
+ ackCondition.broadcast()
98
+ ackCondition.unlock()
99
+ }
100
+
101
+ public func acknowledgeChunk(_ index: Int) {
102
+ ackCondition.lock()
103
+ if index > lastAckedIndex {
104
+ lastAckedIndex = index
105
+ }
106
+ ackCondition.broadcast()
107
+ ackCondition.unlock()
108
+ }
109
+
110
+ private func isCancelled() -> Bool {
111
+ cancelLock.lock()
112
+ defer { cancelLock.unlock() }
113
+ return cancelled
114
+ }
115
+
116
+ private func run() {
117
+ let url: URL
118
+ if let parsed = URL(string: options.fileUri),
119
+ parsed.scheme != nil {
120
+ url = parsed
121
+ } else {
122
+ url = URL(fileURLWithPath: options.fileUri)
123
+ }
124
+
125
+ if !FileManager.default.fileExists(atPath: url.path) {
126
+ emitError(
127
+ code: "ERR_AUDIO_STREAM_FILE_NOT_FOUND",
128
+ message: "File not found: \(url.lastPathComponent)"
129
+ )
130
+ return
131
+ }
132
+
133
+ let asset = AVURLAsset(url: url)
134
+ guard let audioTrack = asset.tracks(withMediaType: .audio).first else {
135
+ emitError(
136
+ code: "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
137
+ message: "No audio track found"
138
+ )
139
+ return
140
+ }
141
+
142
+ let trackDuration = CMTimeGetSeconds(asset.duration) * 1000.0
143
+ let totalDurationMs = trackDuration.isFinite && trackDuration > 0
144
+ ? trackDuration
145
+ : 0.0
146
+
147
+ // Read source sample rate from track.
148
+ let sourceSampleRate: Double = {
149
+ guard let desc = audioTrack.formatDescriptions.first else { return 0 }
150
+ // swiftlint:disable:next force_cast
151
+ let formatDescription = desc as! CMAudioFormatDescription
152
+ guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(
153
+ formatDescription
154
+ )?.pointee else {
155
+ return 0
156
+ }
157
+ return asbd.mSampleRate
158
+ }()
159
+ let sourceChannelCount: Int = {
160
+ guard let desc = audioTrack.formatDescriptions.first else { return 1 }
161
+ // swiftlint:disable:next force_cast
162
+ let formatDescription = desc as! CMAudioFormatDescription
163
+ guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(
164
+ formatDescription
165
+ )?.pointee else {
166
+ return 1
167
+ }
168
+ return Int(asbd.mChannelsPerFrame)
169
+ }()
170
+
171
+ let outputSampleRate = options.targetSampleRate
172
+ ?? (sourceSampleRate > 0 ? sourceSampleRate : 16000)
173
+ let outputChannels = options.channels ?? min(2, max(1, sourceChannelCount))
174
+
175
+ let outputSettings: [String: Any] = [
176
+ AVFormatIDKey: kAudioFormatLinearPCM,
177
+ AVLinearPCMBitDepthKey: 32,
178
+ AVLinearPCMIsFloatKey: true,
179
+ AVLinearPCMIsBigEndianKey: false,
180
+ AVLinearPCMIsNonInterleaved: false,
181
+ AVSampleRateKey: outputSampleRate,
182
+ AVNumberOfChannelsKey: outputChannels,
183
+ ]
184
+
185
+ let reader: AVAssetReader
186
+ do {
187
+ reader = try AVAssetReader(asset: asset)
188
+ } catch {
189
+ emitError(
190
+ code: "ERR_AUDIO_STREAM_DECODE_FAILED",
191
+ message: "Reader init failed: \(error.localizedDescription)"
192
+ )
193
+ return
194
+ }
195
+
196
+ if let startMs = options.startTimeMs, let endMs = options.endTimeMs {
197
+ if startMs < 0 || endMs <= startMs {
198
+ emitError(
199
+ code: "ERR_AUDIO_STREAM_INVALID_RANGE",
200
+ message: "Invalid time range"
201
+ )
202
+ return
203
+ }
204
+ let start = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
205
+ let duration = CMTime(
206
+ seconds: (endMs - startMs) / 1000.0,
207
+ preferredTimescale: 600
208
+ )
209
+ reader.timeRange = CMTimeRange(start: start, duration: duration)
210
+ } else if let startMs = options.startTimeMs {
211
+ let start = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
212
+ reader.timeRange = CMTimeRange(
213
+ start: start,
214
+ duration: CMTime.positiveInfinity
215
+ )
216
+ }
217
+
218
+ let trackOutput = AVAssetReaderTrackOutput(
219
+ track: audioTrack,
220
+ outputSettings: outputSettings
221
+ )
222
+ trackOutput.alwaysCopiesSampleData = false
223
+
224
+ guard reader.canAdd(trackOutput) else {
225
+ emitError(
226
+ code: "ERR_AUDIO_STREAM_DECODE_FAILED",
227
+ message: "Cannot attach decoder output"
228
+ )
229
+ return
230
+ }
231
+ reader.add(trackOutput)
232
+
233
+ guard reader.startReading() else {
234
+ let err = reader.error?.localizedDescription ?? "Unknown reader error"
235
+ emitError(
236
+ code: "ERR_AUDIO_STREAM_DECODE_FAILED",
237
+ message: "startReading failed: \(err)"
238
+ )
239
+ return
240
+ }
241
+
242
+ let samplesPerChunk = max(
243
+ 1,
244
+ Int((Double(options.chunkDurationMs) / 1000.0) * outputSampleRate)
245
+ ) * outputChannels
246
+ let cappedSamplesPerChunk: Int = {
247
+ guard let maxBytes = options.maxChunkBytes else { return samplesPerChunk }
248
+ let maxFloats = max(1, maxBytes / 4)
249
+ return min(samplesPerChunk, maxFloats)
250
+ }()
251
+
252
+ var pending = [Float]()
253
+ pending.reserveCapacity(cappedSamplesPerChunk * 2)
254
+ var chunkIndex = 0
255
+ var totalSamples = 0
256
+ var processedMs: Double = 0
257
+ var cancelledByUser = false
258
+ let rangeStartMs = options.startTimeMs ?? 0
259
+
260
+ while reader.status == .reading {
261
+ if isCancelled() {
262
+ cancelledByUser = true
263
+ break
264
+ }
265
+ guard let sampleBuffer = trackOutput.copyNextSampleBuffer() else {
266
+ break
267
+ }
268
+ guard let block = CMSampleBufferGetDataBuffer(sampleBuffer) else {
269
+ continue
270
+ }
271
+ let frameCount = CMSampleBufferGetNumSamples(sampleBuffer)
272
+ if frameCount <= 0 { continue }
273
+
274
+ var lengthAtOffset = 0
275
+ var totalLength = 0
276
+ var dataPointer: UnsafeMutablePointer<Int8>?
277
+ let status = CMBlockBufferGetDataPointer(
278
+ block,
279
+ atOffset: 0,
280
+ lengthAtOffsetOut: &lengthAtOffset,
281
+ totalLengthOut: &totalLength,
282
+ dataPointerOut: &dataPointer
283
+ )
284
+ guard status == kCMBlockBufferNoErr, let raw = dataPointer else {
285
+ continue
286
+ }
287
+
288
+ let sampleCount = totalLength / MemoryLayout<Float>.size
289
+ let floatPtr = UnsafeRawPointer(raw)
290
+ .assumingMemoryBound(to: Float.self)
291
+ var idx = pending.count
292
+ pending.append(contentsOf: repeatElement(0, count: sampleCount))
293
+ for i in 0..<sampleCount {
294
+ let v = floatPtr[i]
295
+ if v.isNaN || v.isInfinite {
296
+ pending[idx] = 0
297
+ } else if options.normalizeAudio {
298
+ pending[idx] = max(-1.0, min(1.0, v))
299
+ } else {
300
+ pending[idx] = v
301
+ }
302
+ idx += 1
303
+ }
304
+
305
+ while pending.count >= cappedSamplesPerChunk {
306
+ let payload = Array(pending.prefix(cappedSamplesPerChunk))
307
+ pending.removeFirst(cappedSamplesPerChunk)
308
+ let durationOfChunk = Double(cappedSamplesPerChunk)
309
+ / (outputSampleRate * Double(outputChannels))
310
+ let startMs = rangeStartMs
311
+ + (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
312
+ emitChunk(
313
+ index: chunkIndex,
314
+ startTimeMs: startMs,
315
+ endTimeMs: startMs + durationOfChunk * 1000.0,
316
+ startSample: totalSamples / outputChannels,
317
+ sampleRate: outputSampleRate,
318
+ channels: outputChannels,
319
+ samples: payload,
320
+ isFinal: false
321
+ )
322
+ lastEmittedIndex = chunkIndex
323
+ chunkIndex += 1
324
+ totalSamples += cappedSamplesPerChunk
325
+ processedMs = startMs + durationOfChunk * 1000.0
326
+ emitProgress(
327
+ processedMs: processedMs,
328
+ durationMs: totalDurationMs,
329
+ emittedChunks: chunkIndex
330
+ )
331
+
332
+ if waitForAckOrCancel(upTo: chunkIndex - 1) {
333
+ cancelledByUser = true
334
+ break
335
+ }
336
+ }
337
+ if cancelledByUser { break }
338
+ }
339
+
340
+ if cancelledByUser {
341
+ reader.cancelReading()
342
+ emitError(
343
+ code: "ERR_AUDIO_STREAM_CANCELLED",
344
+ message: "Stream cancelled"
345
+ )
346
+ emitComplete(
347
+ durationMs: processedMs,
348
+ sampleRate: outputSampleRate,
349
+ channels: outputChannels,
350
+ chunks: chunkIndex,
351
+ samples: totalSamples,
352
+ cancelled: true
353
+ )
354
+ return
355
+ }
356
+
357
+ if reader.status == .failed {
358
+ let err = reader.error?.localizedDescription ?? "Unknown decode error"
359
+ emitError(
360
+ code: "ERR_AUDIO_STREAM_DECODE_FAILED",
361
+ message: err
362
+ )
363
+ return
364
+ }
365
+
366
+ // Flush remaining samples as final chunk.
367
+ if !pending.isEmpty {
368
+ let durationOfChunk = Double(pending.count)
369
+ / (outputSampleRate * Double(outputChannels))
370
+ let startMs = rangeStartMs
371
+ + (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
372
+ emitChunk(
373
+ index: chunkIndex,
374
+ startTimeMs: startMs,
375
+ endTimeMs: startMs + durationOfChunk * 1000.0,
376
+ startSample: totalSamples / outputChannels,
377
+ sampleRate: outputSampleRate,
378
+ channels: outputChannels,
379
+ samples: pending,
380
+ isFinal: true
381
+ )
382
+ totalSamples += pending.count
383
+ chunkIndex += 1
384
+ processedMs = startMs + durationOfChunk * 1000.0
385
+ pending.removeAll(keepingCapacity: false)
386
+ } else if chunkIndex > 0 {
387
+ // Last emitted chunk wasn't marked final. Emit a zero-sample final
388
+ // tail so consumers that key off isFinal see termination.
389
+ let startMs = rangeStartMs
390
+ + (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
391
+ emitChunk(
392
+ index: chunkIndex,
393
+ startTimeMs: startMs,
394
+ endTimeMs: startMs,
395
+ startSample: totalSamples / outputChannels,
396
+ sampleRate: outputSampleRate,
397
+ channels: outputChannels,
398
+ samples: [Float](),
399
+ isFinal: true
400
+ )
401
+ chunkIndex += 1
402
+ }
403
+
404
+ emitComplete(
405
+ durationMs: totalDurationMs > 0 ? totalDurationMs : processedMs,
406
+ sampleRate: outputSampleRate,
407
+ channels: outputChannels,
408
+ chunks: chunkIndex,
409
+ samples: totalSamples,
410
+ cancelled: false
411
+ )
412
+ }
413
+
414
+ /// Blocks the decoder thread while too many unacked chunks are in flight.
415
+ /// Returns true if cancellation arrived while waiting.
416
+ private func waitForAckOrCancel(upTo index: Int) -> Bool {
417
+ ackCondition.lock()
418
+ defer { ackCondition.unlock() }
419
+ while true {
420
+ if isCancelled() {
421
+ return true
422
+ }
423
+ let inFlight = index - lastAckedIndex
424
+ if inFlight < options.maxBufferedChunks {
425
+ return false
426
+ }
427
+ ackCondition.wait(until: Date().addingTimeInterval(0.05))
428
+ }
429
+ }
430
+
431
+ private func emitChunk(
432
+ index: Int,
433
+ startTimeMs: Double,
434
+ endTimeMs: Double,
435
+ startSample: Int,
436
+ sampleRate: Double,
437
+ channels: Int,
438
+ samples: [Float],
439
+ isFinal: Bool
440
+ ) {
441
+ let payload: [String: Any] = [
442
+ "requestId": options.requestId,
443
+ "chunkIndex": index,
444
+ "startTimeMs": startTimeMs,
445
+ "endTimeMs": endTimeMs,
446
+ "startSample": startSample,
447
+ "sampleCount": samples.count,
448
+ "sampleRate": sampleRate,
449
+ "channels": channels,
450
+ "samples": samples,
451
+ "isFinal": isFinal,
452
+ ]
453
+ delegate?.streamDecoder(self, didEmitChunk: payload)
454
+ }
455
+
456
+ private func emitProgress(
457
+ processedMs: Double,
458
+ durationMs: Double,
459
+ emittedChunks: Int
460
+ ) {
461
+ let progress: Double = durationMs > 0
462
+ ? min(1.0, max(0.0, processedMs / durationMs))
463
+ : 0
464
+ let payload: [String: Any] = [
465
+ "requestId": options.requestId,
466
+ "processedMs": processedMs,
467
+ "durationMs": durationMs,
468
+ "progress": progress,
469
+ "emittedChunks": emittedChunks,
470
+ ]
471
+ delegate?.streamDecoder(self, didReportProgress: payload)
472
+ }
473
+
474
+ private func emitComplete(
475
+ durationMs: Double,
476
+ sampleRate: Double,
477
+ channels: Int,
478
+ chunks: Int,
479
+ samples: Int,
480
+ cancelled: Bool
481
+ ) {
482
+ let payload: [String: Any] = [
483
+ "requestId": options.requestId,
484
+ "durationMs": durationMs,
485
+ "sampleRate": sampleRate,
486
+ "channels": channels,
487
+ "chunks": chunks,
488
+ "samples": samples,
489
+ "cancelled": cancelled,
490
+ ]
491
+ delegate?.streamDecoder(self, didCompleteWith: payload)
492
+ }
493
+
494
+ private func emitError(code: String, message: String) {
495
+ let payload: [String: Any] = [
496
+ "requestId": options.requestId,
497
+ "code": code,
498
+ "message": message,
499
+ ]
500
+ delegate?.streamDecoder(self, didFailWith: payload)
501
+ }
502
+ }
503
+
504
+ /// Safe Float -> integer conversion. Replaces NaN/Inf with 0 and clamps the
505
+ /// scaled value into the integer range before constructing the bounded type,
506
+ /// avoiding the Swift trap that previously crashed the host app inside
507
+ /// `extractRawAudioData` on malformed samples.
508
+ @inline(__always)
509
+ public func safeFloatToInt16(_ sample: Float) -> Int16 {
510
+ let safe = sample.isFinite ? max(-1.0, min(1.0, sample)) : 0
511
+ let scaled = (safe * Float(Int16.max)).rounded()
512
+ let clamped = max(Float(Int16.min), min(Float(Int16.max), scaled))
513
+ return Int16(clamped)
514
+ }
515
+
516
+ @inline(__always)
517
+ public func safeFloatToInt32(_ sample: Float) -> Int32 {
518
+ let safe = sample.isFinite ? max(-1.0, min(1.0, sample)) : 0
519
+ let scaled = Double(safe) * Double(Int32.max)
520
+ let rounded = scaled.rounded()
521
+ let clamped = max(Double(Int32.min), min(Double(Int32.max), rounded))
522
+ return Int32(clamped)
523
+ }