@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.
- package/CHANGELOG.md +356 -5
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +39 -6
- package/build/cjs/errors/AudioStreamError.js +9 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -1
- package/build/cjs/errors/AudioStreamError.test.js +22 -1
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
- package/build/cjs/streamAudioData.js +99 -32
- package/build/cjs/streamAudioData.js.map +1 -1
- package/build/cjs/utils/audioProcessing.js +14 -10
- package/build/cjs/utils/audioProcessing.js.map +1 -1
- package/build/esm/errors/AudioStreamError.js +9 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -1
- package/build/esm/errors/AudioStreamError.test.js +22 -1
- package/build/esm/errors/AudioStreamError.test.js.map +1 -1
- package/build/esm/streamAudioData.js +99 -32
- package/build/esm/streamAudioData.js.map +1 -1
- package/build/esm/utils/audioProcessing.js +14 -10
- package/build/esm/utils/audioProcessing.js.map +1 -1
- package/build/types/errors/AudioStreamError.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +5 -0
- package/build/types/streamAudioData.d.ts.map +1 -1
- package/build/types/utils/audioProcessing.d.ts +2 -2
- package/build/types/utils/audioProcessing.d.ts.map +1 -1
- package/ios/AudioStreamDecoder.swift +191 -100
- package/ios/AudioStudioModule.swift +48 -9
- package/package.json +163 -146
- package/scripts/README.md +58 -0
- package/src/errors/AudioStreamError.test.ts +29 -2
- package/src/errors/AudioStreamError.ts +14 -0
- package/src/streamAudioData.ts +146 -42
- 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/AudioStreamDecoderTests.swift +0 -128
- 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
|
@@ -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 =
|
|
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
|
|
143
|
-
|
|
144
|
-
?
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
Int((Double(options.chunkDurationMs) / 1000.0) * outputSampleRate)
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
let
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
368
|
-
|
|
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:
|
|
449
|
+
samples: tail,
|
|
380
450
|
isFinal: true
|
|
381
451
|
)
|
|
382
|
-
totalSamples +=
|
|
452
|
+
totalSamples += tailCount
|
|
383
453
|
chunkIndex += 1
|
|
384
|
-
processedMs =
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
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
|
-
|
|
416
|
-
|
|
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
|
|
508
|
+
return .cancelled
|
|
422
509
|
}
|
|
423
510
|
let inFlight = index - lastAckedIndex
|
|
424
511
|
if inFlight < options.maxBufferedChunks {
|
|
425
|
-
return
|
|
512
|
+
return .ok
|
|
513
|
+
}
|
|
514
|
+
if let deadline, Date() >= deadline {
|
|
515
|
+
return .timedOut
|
|
426
516
|
}
|
|
427
|
-
|
|
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:
|
|
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
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
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
|
}
|