@siteed/audio-studio 3.2.0-beta.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 (85) hide show
  1. package/CHANGELOG.md +356 -5
  2. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
  3. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +39 -6
  4. package/build/cjs/errors/AudioStreamError.js +9 -0
  5. package/build/cjs/errors/AudioStreamError.js.map +1 -1
  6. package/build/cjs/errors/AudioStreamError.test.js +22 -1
  7. package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
  8. package/build/cjs/streamAudioData.js +99 -32
  9. package/build/cjs/streamAudioData.js.map +1 -1
  10. package/build/cjs/utils/audioProcessing.js +14 -10
  11. package/build/cjs/utils/audioProcessing.js.map +1 -1
  12. package/build/esm/errors/AudioStreamError.js +9 -0
  13. package/build/esm/errors/AudioStreamError.js.map +1 -1
  14. package/build/esm/errors/AudioStreamError.test.js +22 -1
  15. package/build/esm/errors/AudioStreamError.test.js.map +1 -1
  16. package/build/esm/streamAudioData.js +99 -32
  17. package/build/esm/streamAudioData.js.map +1 -1
  18. package/build/esm/utils/audioProcessing.js +14 -10
  19. package/build/esm/utils/audioProcessing.js.map +1 -1
  20. package/build/types/errors/AudioStreamError.d.ts.map +1 -1
  21. package/build/types/streamAudioData.d.ts +5 -0
  22. package/build/types/streamAudioData.d.ts.map +1 -1
  23. package/build/types/utils/audioProcessing.d.ts +2 -2
  24. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  25. package/ios/AudioStreamDecoder.swift +191 -100
  26. package/ios/AudioStudioModule.swift +48 -9
  27. package/package.json +163 -146
  28. package/scripts/README.md +58 -0
  29. package/src/errors/AudioStreamError.test.ts +29 -2
  30. package/src/errors/AudioStreamError.ts +14 -0
  31. package/src/streamAudioData.ts +146 -42
  32. package/src/utils/audioProcessing.ts +25 -14
  33. package/android/src/androidTest/assets/chorus.wav +0 -0
  34. package/android/src/androidTest/assets/jfk.wav +0 -0
  35. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  36. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  37. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  38. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  39. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  40. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  41. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  42. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  43. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  44. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  45. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  46. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  47. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  48. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  49. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  50. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  51. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  52. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  53. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  54. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  55. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  56. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  57. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  58. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  59. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  60. package/android/src/test/resources/chorus.wav +0 -0
  61. package/android/src/test/resources/generate_test_audio.py +0 -94
  62. package/android/src/test/resources/jfk.wav +0 -0
  63. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  64. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  65. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  66. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  67. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
  68. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  69. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  70. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  71. package/ios/AudioStudioTests/Info.plist +0 -22
  72. package/ios/AudioStudioTests/README.md +0 -39
  73. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  74. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  75. package/ios/tests/README.md +0 -41
  76. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  77. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  78. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  79. package/ios/tests/integration/output_control_test.swift +0 -322
  80. package/ios/tests/integration/run_integration_tests.sh +0 -37
  81. package/ios/tests/opus_support_test_macos.swift +0 -154
  82. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  83. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  84. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  85. package/ios/tests/standalone/standalone_test.swift +0 -144
@@ -38,6 +38,7 @@ public final class AudioStreamDecoder {
38
38
  public let chunkDurationMs: Int
39
39
  public let maxChunkBytes: Int?
40
40
  public let maxBufferedChunks: Int
41
+ public let backpressureTimeoutMs: Double?
41
42
 
42
43
  public init(
43
44
  requestId: String,
@@ -49,7 +50,8 @@ public final class AudioStreamDecoder {
49
50
  normalizeAudio: Bool,
50
51
  chunkDurationMs: Int,
51
52
  maxChunkBytes: Int?,
52
- maxBufferedChunks: Int
53
+ maxBufferedChunks: Int,
54
+ backpressureTimeoutMs: Double? = nil
53
55
  ) {
54
56
  self.requestId = requestId
55
57
  self.fileUri = fileUri
@@ -58,9 +60,10 @@ public final class AudioStreamDecoder {
58
60
  self.targetSampleRate = targetSampleRate
59
61
  self.channels = channels
60
62
  self.normalizeAudio = normalizeAudio
61
- self.chunkDurationMs = max(10, min(60000, chunkDurationMs))
63
+ self.chunkDurationMs = chunkDurationMs
62
64
  self.maxChunkBytes = maxChunkBytes
63
65
  self.maxBufferedChunks = max(1, maxBufferedChunks)
66
+ self.backpressureTimeoutMs = backpressureTimeoutMs
64
67
  }
65
68
  }
66
69
 
@@ -70,10 +73,8 @@ public final class AudioStreamDecoder {
70
73
  private let queue: DispatchQueue
71
74
  private let cancelLock = NSLock()
72
75
  private var cancelled = false
73
- private let ackLock = NSLock()
74
76
  private let ackCondition = NSCondition()
75
77
  private var lastAckedIndex = -1
76
- private var lastEmittedIndex = -1
77
78
 
78
79
  public init(options: Options) {
79
80
  self.options = options
@@ -139,10 +140,22 @@ public final class AudioStreamDecoder {
139
140
  return
140
141
  }
141
142
 
142
- let trackDuration = CMTimeGetSeconds(asset.duration) * 1000.0
143
- let totalDurationMs = trackDuration.isFinite && trackDuration > 0
144
- ? trackDuration
145
- : 0.0
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
+ }()
146
159
 
147
160
  // Read source sample rate from track.
148
161
  let sourceSampleRate: Double = {
@@ -170,7 +183,13 @@ public final class AudioStreamDecoder {
170
183
 
171
184
  let outputSampleRate = options.targetSampleRate
172
185
  ?? (sourceSampleRate > 0 ? sourceSampleRate : 16000)
173
- let outputChannels = options.channels ?? min(2, max(1, sourceChannelCount))
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
+ }()
174
193
 
175
194
  let outputSettings: [String: Any] = [
176
195
  AVFormatIDKey: kAudioFormatLinearPCM,
@@ -213,6 +232,12 @@ public final class AudioStreamDecoder {
213
232
  start: start,
214
233
  duration: CMTime.positiveInfinity
215
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)
216
241
  }
217
242
 
218
243
  let trackOutput = AVAssetReaderTrackOutput(
@@ -240,101 +265,142 @@ public final class AudioStreamDecoder {
240
265
  }
241
266
 
242
267
  let samplesPerChunk = max(
243
- 1,
244
- Int((Double(options.chunkDurationMs) / 1000.0) * outputSampleRate)
245
- ) * outputChannels
268
+ outputChannels,
269
+ Int((Double(options.chunkDurationMs) / 1000.0) * outputSampleRate) *
270
+ outputChannels
271
+ )
246
272
  let cappedSamplesPerChunk: Int = {
247
273
  guard let maxBytes = options.maxChunkBytes else { return samplesPerChunk }
248
- let maxFloats = max(1, maxBytes / 4)
249
- return min(samplesPerChunk, maxFloats)
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))
250
283
  }()
251
284
 
252
285
  var pending = [Float]()
253
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)
254
292
  var chunkIndex = 0
255
293
  var totalSamples = 0
256
294
  var processedMs: Double = 0
257
295
  var cancelledByUser = false
296
+ var backpressureTimedOut = false
258
297
  let rangeStartMs = options.startTimeMs ?? 0
259
298
 
260
299
  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
300
+ let shouldContinue = autoreleasepool { () -> Bool in
301
+ if isCancelled() {
302
+ cancelledByUser = true
303
+ return false
301
304
  }
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
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
330
323
  )
324
+ guard status == kCMBlockBufferNoErr, let raw = dataPointer else {
325
+ return true
326
+ }
331
327
 
332
- if waitForAckOrCancel(upTo: chunkIndex - 1) {
333
- cancelledByUser = true
334
- break
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 }
335
391
  }
392
+ return true
336
393
  }
337
- if cancelledByUser { break }
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
338
404
  }
339
405
 
340
406
  if cancelledByUser {
@@ -343,8 +409,10 @@ public final class AudioStreamDecoder {
343
409
  code: "ERR_AUDIO_STREAM_CANCELLED",
344
410
  message: "Stream cancelled"
345
411
  )
412
+ // `durationMs` reports the requested range (matches Android),
413
+ // `samples` reports what was actually decoded before cancel.
346
414
  emitComplete(
347
- durationMs: processedMs,
415
+ durationMs: totalDurationMs,
348
416
  sampleRate: outputSampleRate,
349
417
  channels: outputChannels,
350
418
  chunks: chunkIndex,
@@ -364,8 +432,10 @@ public final class AudioStreamDecoder {
364
432
  }
365
433
 
366
434
  // Flush remaining samples as final chunk.
367
- if !pending.isEmpty {
368
- let durationOfChunk = Double(pending.count)
435
+ let tailCount = pending.count - pendingHead
436
+ if tailCount > 0 {
437
+ let tail = Array(pending[pendingHead..<pending.count])
438
+ let durationOfChunk = Double(tailCount)
369
439
  / (outputSampleRate * Double(outputChannels))
370
440
  let startMs = rangeStartMs
371
441
  + (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
@@ -376,16 +446,26 @@ public final class AudioStreamDecoder {
376
446
  startSample: totalSamples / outputChannels,
377
447
  sampleRate: outputSampleRate,
378
448
  channels: outputChannels,
379
- samples: pending,
449
+ samples: tail,
380
450
  isFinal: true
381
451
  )
382
- totalSamples += pending.count
452
+ totalSamples += tailCount
383
453
  chunkIndex += 1
384
- processedMs = startMs + durationOfChunk * 1000.0
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
+ )
385
463
  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.
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.
389
469
  let startMs = rangeStartMs
390
470
  + (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
391
471
  emitChunk(
@@ -411,20 +491,31 @@ public final class AudioStreamDecoder {
411
491
  )
412
492
  }
413
493
 
494
+ private enum AckWaitResult {
495
+ case ok
496
+ case cancelled
497
+ case timedOut
498
+ }
499
+
414
500
  /// 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 {
501
+ private func waitForAckOrCancel(upTo index: Int) -> AckWaitResult {
502
+ let deadline = options.backpressureTimeoutMs
503
+ .flatMap { $0 > 0 ? Date().addingTimeInterval($0 / 1000.0) : nil }
417
504
  ackCondition.lock()
418
505
  defer { ackCondition.unlock() }
419
506
  while true {
420
507
  if isCancelled() {
421
- return true
508
+ return .cancelled
422
509
  }
423
510
  let inFlight = index - lastAckedIndex
424
511
  if inFlight < options.maxBufferedChunks {
425
- return false
512
+ return .ok
513
+ }
514
+ if let deadline, Date() >= deadline {
515
+ return .timedOut
426
516
  }
427
- ackCondition.wait(until: Date().addingTimeInterval(0.05))
517
+ let nextWake = min(0.05, max(0.001, deadline?.timeIntervalSinceNow ?? 0.05))
518
+ ackCondition.wait(until: Date().addingTimeInterval(nextWake))
428
519
  }
429
520
  }
430
521
 
@@ -82,6 +82,20 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
82
82
  OnDestroy {
83
83
  Logger.debug("AudioStudioModule", "Module destroyed, stopping device monitoring.")
84
84
  _ = streamManager.stopRecording()
85
+ // Cancel any in-flight streamAudioData decoders before the module
86
+ // is torn down. Without this, decoder threads can outlive the
87
+ // module and try to emit events through a destroyed instance.
88
+ // Detach each decoder's delegate *before* cancelling so the
89
+ // terminal "cancelled" events the worker emits after observing
90
+ // `cancel()` are dropped instead of being forwarded.
91
+ self.streamDecodersLock.lock()
92
+ let inflight = self.streamDecoders
93
+ self.streamDecoders.removeAll()
94
+ self.streamDecodersLock.unlock()
95
+ for (_, decoder) in inflight {
96
+ decoder.delegate = nil
97
+ decoder.cancel()
98
+ }
85
99
  // Clear device manager delegate
86
100
  deviceManager.delegate = nil
87
101
  }
@@ -932,18 +946,27 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
932
946
  return
933
947
  }
934
948
 
949
+ let chunkDurationMs = options["chunkDurationMs"] as? Int ?? 1000
950
+ guard (10...60000).contains(chunkDurationMs) else {
951
+ promise.reject(
952
+ "ERR_AUDIO_STREAM_INVALID_RANGE",
953
+ "chunkDurationMs must be in [10, 60000]"
954
+ )
955
+ return
956
+ }
957
+
935
958
  let opts = AudioStreamDecoder.Options(
936
959
  requestId: requestId,
937
960
  fileUri: fileUri,
938
961
  startTimeMs: options["startTimeMs"] as? Double,
939
962
  endTimeMs: options["endTimeMs"] as? Double,
940
- targetSampleRate: options["targetSampleRate"] as? Double
941
- ?? (options["sampleRate"] as? Double),
963
+ targetSampleRate: options["targetSampleRate"] as? Double,
942
964
  channels: options["channels"] as? Int,
943
965
  normalizeAudio: options["normalizeAudio"] as? Bool ?? true,
944
- chunkDurationMs: options["chunkDurationMs"] as? Int ?? 1000,
966
+ chunkDurationMs: chunkDurationMs,
945
967
  maxChunkBytes: options["maxChunkBytes"] as? Int,
946
- maxBufferedChunks: options["maxBufferedChunks"] as? Int ?? 4
968
+ maxBufferedChunks: options["maxBufferedChunks"] as? Int ?? 4,
969
+ backpressureTimeoutMs: options["backpressureTimeoutMs"] as? Double
947
970
  )
948
971
 
949
972
  let decoder = AudioStreamDecoder(options: opts)
@@ -1009,12 +1032,25 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
1009
1032
  streamDecodersLock.unlock()
1010
1033
  }
1011
1034
 
1035
+ /// Returns true when `requestId` is still tracked by the module. Used as
1036
+ /// the lifecycle gate for delegate sends: callbacks that race with
1037
+ /// `OnDestroy` (which clears the map *before* cancelling) see `false`
1038
+ /// and skip `sendEvent`, so we never push events through a destroyed
1039
+ /// React context.
1040
+ private func isActiveStreamDecoder(_ requestId: String) -> Bool {
1041
+ streamDecodersLock.lock()
1042
+ defer { streamDecodersLock.unlock() }
1043
+ return streamDecoders[requestId] != nil
1044
+ }
1045
+
1012
1046
  // MARK: - AudioStreamDecoderDelegate
1013
1047
 
1014
1048
  public func streamDecoder(
1015
1049
  _ decoder: AudioStreamDecoder,
1016
1050
  didEmitChunk payload: [String: Any]
1017
1051
  ) {
1052
+ guard let requestId = payload["requestId"] as? String,
1053
+ isActiveStreamDecoder(requestId) else { return }
1018
1054
  sendEvent(audioStreamChunkEvent, payload)
1019
1055
  }
1020
1056
 
@@ -1022,6 +1058,8 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
1022
1058
  _ decoder: AudioStreamDecoder,
1023
1059
  didReportProgress payload: [String: Any]
1024
1060
  ) {
1061
+ guard let requestId = payload["requestId"] as? String,
1062
+ isActiveStreamDecoder(requestId) else { return }
1025
1063
  sendEvent(audioStreamProgressEvent, payload)
1026
1064
  }
1027
1065
 
@@ -1029,9 +1067,9 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
1029
1067
  _ decoder: AudioStreamDecoder,
1030
1068
  didCompleteWith payload: [String: Any]
1031
1069
  ) {
1032
- if let requestId = payload["requestId"] as? String {
1033
- releaseStreamDecoder(requestId)
1034
- }
1070
+ guard let requestId = payload["requestId"] as? String,
1071
+ isActiveStreamDecoder(requestId) else { return }
1072
+ releaseStreamDecoder(requestId)
1035
1073
  sendEvent(audioStreamCompleteEvent, payload)
1036
1074
  }
1037
1075
 
@@ -1039,8 +1077,9 @@ public class AudioStudioModule: Module, AudioStreamManagerDelegate, AudioDeviceM
1039
1077
  _ decoder: AudioStreamDecoder,
1040
1078
  didFailWith payload: [String: Any]
1041
1079
  ) {
1042
- if let requestId = payload["requestId"] as? String,
1043
- let code = payload["code"] as? String,
1080
+ guard let requestId = payload["requestId"] as? String,
1081
+ isActiveStreamDecoder(requestId) else { return }
1082
+ if let code = payload["code"] as? String,
1044
1083
  code != "ERR_AUDIO_STREAM_CANCELLED" {
1045
1084
  releaseStreamDecoder(requestId)
1046
1085
  }