@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,852 @@
1
+ // packages/audio-studio/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt
2
+ //
3
+ // Progressive file decoder for `streamAudioData`. Uses MediaExtractor +
4
+ // MediaCodec to decode a stored audio file into bounded Float32 PCM chunks
5
+ // without loading the full PCM range into memory. Output is sanitized
6
+ // (NaN/Inf -> 0, clamp to [-1, 1] when normalizeAudio=true) and supports
7
+ // per-request cancellation + back-pressure ack.
8
+ package net.siteed.audiostudio
9
+
10
+ import android.content.Context
11
+ import android.media.MediaCodec
12
+ import android.media.MediaExtractor
13
+ import android.media.MediaFormat
14
+ import android.media.MediaMetadataRetriever
15
+ import android.net.Uri
16
+ import android.os.Bundle
17
+ import androidx.core.os.bundleOf
18
+ import java.io.File
19
+ import java.nio.ByteBuffer
20
+ import java.nio.ByteOrder
21
+ import java.util.concurrent.atomic.AtomicBoolean
22
+ import java.util.concurrent.atomic.AtomicInteger
23
+ import kotlin.concurrent.thread
24
+ import kotlin.math.ceil
25
+
26
+ interface AudioStreamDecoderDelegate {
27
+ fun streamDecoderEmit(eventName: String, payload: Bundle)
28
+ }
29
+
30
+ class AudioStreamDecoder(
31
+ private val context: Context,
32
+ private val options: Options,
33
+ private val delegate: AudioStreamDecoderDelegate,
34
+ ) {
35
+ data class Options(
36
+ val requestId: String,
37
+ val fileUri: String,
38
+ val startTimeMs: Long?,
39
+ val endTimeMs: Long?,
40
+ val targetSampleRate: Int?,
41
+ val channels: Int?,
42
+ val normalizeAudio: Boolean,
43
+ val chunkDurationMs: Int,
44
+ val maxChunkBytes: Int?,
45
+ val maxBufferedChunks: Int,
46
+ val backpressureTimeoutMs: Long?,
47
+ )
48
+
49
+ companion object {
50
+ private const val TAG = "AudioStreamDecoder"
51
+ private const val TIMEOUT_US = 10_000L
52
+ }
53
+
54
+ private val cancelled = AtomicBoolean(false)
55
+ private val lastAckedIndex = AtomicInteger(-1)
56
+ private val ackLock = Object()
57
+ private var workerThread: Thread? = null
58
+
59
+ fun start() {
60
+ workerThread = thread(
61
+ isDaemon = true,
62
+ name = "AudioStreamDecoder-${options.requestId}"
63
+ ) {
64
+ run()
65
+ }
66
+ }
67
+
68
+ fun cancel() {
69
+ cancelled.set(true)
70
+ synchronized(ackLock) { ackLock.notifyAll() }
71
+ }
72
+
73
+ fun acknowledgeChunk(index: Int) {
74
+ synchronized(ackLock) {
75
+ if (index > lastAckedIndex.get()) {
76
+ lastAckedIndex.set(index)
77
+ }
78
+ ackLock.notifyAll()
79
+ }
80
+ }
81
+
82
+ private fun run() {
83
+ val resolved = resolveFilePath(options.fileUri)
84
+ if (resolved == null) {
85
+ emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "Cannot resolve file: ${options.fileUri}")
86
+ return
87
+ }
88
+ val path = resolved.path
89
+ val tempFile = resolved.tempFile
90
+ if (!File(path).exists()) {
91
+ tempFile?.let { runCatching { it.delete() } }
92
+ emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "File not found: $path")
93
+ return
94
+ }
95
+
96
+ val extractor = MediaExtractor()
97
+ var codec: MediaCodec? = null
98
+ var emittedChunks = 0
99
+ var emittedSamples = 0L
100
+ var backpressureTimedOut = false
101
+ var outputSampleRate = options.targetSampleRate ?: 0
102
+ var outputChannels = options.channels ?: 0
103
+
104
+ try {
105
+ try {
106
+ extractor.setDataSource(path)
107
+ } catch (e: Exception) {
108
+ emitError(
109
+ "ERR_AUDIO_STREAM_DECODE_FAILED",
110
+ "setDataSource failed: ${e.message}"
111
+ )
112
+ return
113
+ }
114
+
115
+ val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
116
+ val format = extractor.getTrackFormat(idx)
117
+ format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
118
+ }
119
+ if (trackIndex == null) {
120
+ emitError(
121
+ "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
122
+ "No audio track found"
123
+ )
124
+ return
125
+ }
126
+ extractor.selectTrack(trackIndex)
127
+ val format = extractor.getTrackFormat(trackIndex)
128
+ val extractorDurationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) {
129
+ format.getLong(MediaFormat.KEY_DURATION)
130
+ } else {
131
+ -1L
132
+ }
133
+ val metadataDurationMs = if (extractorDurationUs <= 0) {
134
+ readMetadataDurationMs(path)
135
+ } else {
136
+ -1L
137
+ }
138
+ val assetDurationUs = if (extractorDurationUs > 0) {
139
+ extractorDurationUs
140
+ } else if (metadataDurationMs > 0) {
141
+ metadataDurationMs * 1000L
142
+ } else {
143
+ -1L
144
+ }
145
+ val assetDurationMs = if (assetDurationUs > 0) {
146
+ assetDurationUs / 1000.0
147
+ } else {
148
+ 0.0
149
+ }
150
+ // Duration of the *decoded range*, not the whole file, so progress
151
+ // and completion payloads match what the caller actually receives.
152
+ val rangeDurationMs = when {
153
+ options.startTimeMs != null && options.endTimeMs != null ->
154
+ (options.endTimeMs - options.startTimeMs).toDouble().coerceAtLeast(0.0)
155
+ options.endTimeMs != null -> options.endTimeMs.toDouble().coerceAtLeast(0.0)
156
+ options.startTimeMs != null ->
157
+ (assetDurationMs - options.startTimeMs).coerceAtLeast(0.0)
158
+ else -> assetDurationMs
159
+ }
160
+
161
+ var sourceSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
162
+ var sourceChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
163
+ if (outputSampleRate <= 0) outputSampleRate = sourceSampleRate
164
+ if (outputChannels <= 0) outputChannels = minOf(2, maxOf(1, sourceChannels))
165
+ val maxOutputSamples = options.endTimeMs?.let {
166
+ ((rangeDurationMs / 1000.0) * outputSampleRate.toDouble() * outputChannels.toDouble())
167
+ .toLong()
168
+ .coerceAtLeast(0L)
169
+ } ?: Long.MAX_VALUE
170
+
171
+ options.startTimeMs?.let {
172
+ extractor.seekTo(it * 1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
173
+ }
174
+
175
+ val mime = format.getString(MediaFormat.KEY_MIME)
176
+ ?: run {
177
+ emitError(
178
+ "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
179
+ "Track has no MIME"
180
+ )
181
+ return
182
+ }
183
+ codec = try {
184
+ MediaCodec.createDecoderByType(mime)
185
+ } catch (e: Exception) {
186
+ emitError(
187
+ "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
188
+ "No decoder for $mime: ${e.message}"
189
+ )
190
+ return
191
+ }
192
+ codec.configure(format, null, null, 0)
193
+ codec.start()
194
+
195
+ val endTimeUs = options.endTimeMs?.let { it * 1000L } ?: Long.MAX_VALUE
196
+ val rangeStartMs = options.startTimeMs ?: 0L
197
+ val targetStartUs = rangeStartMs * 1000L
198
+ val samplesPerChunk = run {
199
+ val byTime = (options.chunkDurationMs.toLong() *
200
+ outputSampleRate.toLong() / 1000L).toInt() * outputChannels
201
+ // Round byte-cap down to a multiple of `outputChannels` so a
202
+ // single interleaved frame is never split across chunks.
203
+ val byBytes = options.maxChunkBytes?.let {
204
+ (it / 4 / outputChannels) * outputChannels
205
+ } ?: Int.MAX_VALUE
206
+ val raw = minOf(byTime, byBytes)
207
+ maxOf(outputChannels, (raw / outputChannels) * outputChannels)
208
+ }
209
+ var pending = FloatArray(samplesPerChunk * 2)
210
+ var pendingLen = 0
211
+ var pendingHead = 0
212
+ fun pendingAvailable(): Int = pendingLen - pendingHead
213
+ fun compactPending() {
214
+ val available = pendingAvailable()
215
+ if (pendingHead == 0) return
216
+ if (available > 0) {
217
+ System.arraycopy(pending, pendingHead, pending, 0, available)
218
+ }
219
+ pendingHead = 0
220
+ pendingLen = available
221
+ }
222
+ fun appendPending(samples: FloatArray) {
223
+ if (samples.isEmpty()) return
224
+ val available = pendingAvailable()
225
+ val requiredLen = available + samples.size
226
+ if (requiredLen > pending.size) {
227
+ val grown = FloatArray(requiredLen.coerceAtLeast(pending.size * 2))
228
+ if (available > 0) {
229
+ System.arraycopy(pending, pendingHead, grown, 0, available)
230
+ }
231
+ pending = grown
232
+ pendingHead = 0
233
+ pendingLen = available
234
+ } else if (pending.size - pendingLen < samples.size && pendingHead > 0) {
235
+ compactPending()
236
+ }
237
+ System.arraycopy(samples, 0, pending, pendingLen, samples.size)
238
+ pendingLen += samples.size
239
+ }
240
+ val info = MediaCodec.BufferInfo()
241
+ var sawInputEOS = false
242
+ var sawOutputEOS = false
243
+
244
+ // Resampling state across output buffers. We keep the last source
245
+ // sample to interpolate across buffer boundaries.
246
+ val resampler = LinearResampler(outputChannels)
247
+
248
+ while (!sawOutputEOS) {
249
+ if (cancelled.get()) break
250
+
251
+ if (!sawInputEOS) {
252
+ val inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US)
253
+ if (inputBufferIndex >= 0) {
254
+ val inputBuffer = codec.getInputBuffer(inputBufferIndex)
255
+ if (inputBuffer != null) {
256
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
257
+ val sampleTime = extractor.sampleTime
258
+ if (sampleSize < 0 || sampleTime > endTimeUs) {
259
+ codec.queueInputBuffer(
260
+ inputBufferIndex,
261
+ 0,
262
+ 0,
263
+ 0,
264
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM
265
+ )
266
+ sawInputEOS = true
267
+ } else {
268
+ codec.queueInputBuffer(
269
+ inputBufferIndex,
270
+ 0,
271
+ sampleSize,
272
+ sampleTime,
273
+ 0
274
+ )
275
+ extractor.advance()
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ val outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US)
282
+ if (outputIndex >= 0) {
283
+ val outputBuffer = codec.getOutputBuffer(outputIndex)
284
+ if (outputBuffer != null && info.size > 0) {
285
+ outputBuffer.position(info.offset)
286
+ outputBuffer.limit(info.offset + info.size)
287
+ val pcm = ByteArray(info.size)
288
+ outputBuffer.get(pcm)
289
+
290
+ val converted = pcmToFloat(
291
+ pcm,
292
+ sourceChannels,
293
+ outputChannels,
294
+ options.normalizeAudio
295
+ )
296
+ // SEEK_TO_CLOSEST_SYNC can land before the requested
297
+ // start (encoder priming on AAC/MP3, sync-frame
298
+ // granularity on lossy containers). Trim source-rate
299
+ // frames whose presentation time is before
300
+ // `targetStartUs` so the first emitted sample lines
301
+ // up with `startTimeMs`.
302
+ val bufferStartUs = info.presentationTimeUs
303
+ val trimmed: FloatArray = if (
304
+ targetStartUs > 0 &&
305
+ bufferStartUs in 0L until targetStartUs &&
306
+ converted.isNotEmpty()
307
+ ) {
308
+ val deltaUs = targetStartUs - bufferStartUs
309
+ val skipFrames = (
310
+ deltaUs.toDouble() * sourceSampleRate.toDouble() /
311
+ 1_000_000.0
312
+ ).toInt()
313
+ val totalFrames = converted.size / outputChannels
314
+ val actualSkip = minOf(skipFrames, totalFrames)
315
+ if (actualSkip >= totalFrames) {
316
+ FloatArray(0)
317
+ } else if (actualSkip > 0) {
318
+ val skipFloats = actualSkip * outputChannels
319
+ val out = FloatArray(converted.size - skipFloats)
320
+ System.arraycopy(
321
+ converted,
322
+ skipFloats,
323
+ out,
324
+ 0,
325
+ out.size
326
+ )
327
+ out
328
+ } else {
329
+ converted
330
+ }
331
+ } else {
332
+ converted
333
+ }
334
+ val resampled = resampler.process(
335
+ trimmed,
336
+ sourceSampleRate,
337
+ outputSampleRate
338
+ )
339
+ val remainingOutputSamples =
340
+ maxOutputSamples - emittedSamples - pendingAvailable().toLong()
341
+ val boundedResampled = when {
342
+ remainingOutputSamples <= 0L -> FloatArray(0)
343
+ resampled.size.toLong() > remainingOutputSamples -> {
344
+ val boundedSize = (
345
+ remainingOutputSamples.toInt() / outputChannels
346
+ ) * outputChannels
347
+ resampled.copyOf(boundedSize)
348
+ }
349
+ else -> resampled
350
+ }
351
+
352
+ appendPending(boundedResampled)
353
+
354
+ chunkLoop@ while (pendingAvailable() >= samplesPerChunk) {
355
+ if (cancelled.get()) break
356
+ val chunk = FloatArray(samplesPerChunk)
357
+ System.arraycopy(pending, pendingHead, chunk, 0, samplesPerChunk)
358
+ pendingHead += samplesPerChunk
359
+ if (pendingHead > pending.size / 2) {
360
+ compactPending()
361
+ }
362
+
363
+ val chunkDurationMs =
364
+ (chunk.size.toDouble() /
365
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
366
+ 1000.0
367
+ val startMs = rangeStartMs +
368
+ (emittedSamples.toDouble() /
369
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
370
+ 1000.0
371
+ emitChunk(
372
+ index = emittedChunks,
373
+ startTimeMs = startMs,
374
+ endTimeMs = startMs + chunkDurationMs,
375
+ startSample = emittedSamples / outputChannels,
376
+ sampleRate = outputSampleRate,
377
+ channels = outputChannels,
378
+ samples = chunk,
379
+ isFinal = false
380
+ )
381
+ emittedChunks += 1
382
+ emittedSamples += chunk.size
383
+ // Progress is elapsed decoded time within the
384
+ // requested range so `processedMs / durationMs`
385
+ // stays in [0, 1] regardless of `startTimeMs`.
386
+ // Chunk timestamps stay absolute (rangeStart +
387
+ // offset).
388
+ val elapsedMs = (emittedSamples.toDouble() /
389
+ (outputSampleRate.toDouble() *
390
+ outputChannels.toDouble())) * 1000.0
391
+ emitProgress(
392
+ processedMs = elapsedMs,
393
+ durationMs = rangeDurationMs,
394
+ emittedChunks = emittedChunks
395
+ )
396
+ when (waitForAckOrCancel(emittedChunks - 1)) {
397
+ AckWaitResult.OK -> Unit
398
+ AckWaitResult.CANCELLED -> {
399
+ cancelled.set(true)
400
+ break@chunkLoop
401
+ }
402
+ AckWaitResult.TIMED_OUT -> {
403
+ backpressureTimedOut = true
404
+ break@chunkLoop
405
+ }
406
+ }
407
+ }
408
+ }
409
+ codec.releaseOutputBuffer(outputIndex, false)
410
+ if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
411
+ sawOutputEOS = true
412
+ }
413
+ if (backpressureTimedOut) {
414
+ break
415
+ }
416
+ } else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
417
+ val newFormat = codec.outputFormat
418
+ // Codec output format is authoritative once decoding starts;
419
+ // the resampler is told the new source rate on its next call
420
+ // so the output rate (and chunk timestamps) stay correct.
421
+ if (newFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
422
+ sourceSampleRate = newFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
423
+ }
424
+ if (newFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
425
+ sourceChannels = newFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
426
+ }
427
+ }
428
+ }
429
+
430
+ if (!cancelled.get() && !backpressureTimedOut) {
431
+ val resamplerTail = resampler.flush()
432
+ if (resamplerTail.isNotEmpty()) {
433
+ appendPending(resamplerTail)
434
+ }
435
+ }
436
+
437
+ if (backpressureTimedOut) {
438
+ emitError(
439
+ "ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT",
440
+ "Timed out waiting for JS acknowledgement after ${options.backpressureTimeoutMs}ms"
441
+ )
442
+ return
443
+ }
444
+
445
+ if (cancelled.get()) {
446
+ emitError("ERR_AUDIO_STREAM_CANCELLED", "Stream cancelled")
447
+ emitComplete(
448
+ durationMs = rangeDurationMs,
449
+ sampleRate = outputSampleRate,
450
+ channels = outputChannels,
451
+ chunks = emittedChunks,
452
+ samples = emittedSamples,
453
+ cancelled = true
454
+ )
455
+ return
456
+ }
457
+
458
+ // Flush remainder as final chunk
459
+ if (pendingAvailable() > 0) {
460
+ val tail = FloatArray(pendingAvailable())
461
+ System.arraycopy(pending, pendingHead, tail, 0, tail.size)
462
+ val tailDurationMs =
463
+ (tail.size.toDouble() /
464
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
465
+ 1000.0
466
+ val startMs = rangeStartMs +
467
+ (emittedSamples.toDouble() /
468
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
469
+ 1000.0
470
+ emitChunk(
471
+ index = emittedChunks,
472
+ startTimeMs = startMs,
473
+ endTimeMs = startMs + tailDurationMs,
474
+ startSample = emittedSamples / outputChannels,
475
+ sampleRate = outputSampleRate,
476
+ channels = outputChannels,
477
+ samples = tail,
478
+ isFinal = true
479
+ )
480
+ emittedChunks += 1
481
+ emittedSamples += tail.size
482
+ // Mirror the per-chunk progress emission so consumers always
483
+ // see a final `processedMs / durationMs ≈ 1.0` from
484
+ // `onProgress`.
485
+ val elapsedMs = (emittedSamples.toDouble() /
486
+ (outputSampleRate.toDouble() *
487
+ outputChannels.toDouble())) * 1000.0
488
+ emitProgress(
489
+ processedMs = elapsedMs,
490
+ durationMs = rangeDurationMs,
491
+ emittedChunks = emittedChunks
492
+ )
493
+ } else {
494
+ emitChunk(
495
+ index = emittedChunks,
496
+ startTimeMs = rangeStartMs +
497
+ (emittedSamples.toDouble() /
498
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
499
+ 1000.0,
500
+ endTimeMs = rangeStartMs +
501
+ (emittedSamples.toDouble() /
502
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
503
+ 1000.0,
504
+ startSample = emittedSamples / outputChannels,
505
+ sampleRate = outputSampleRate,
506
+ channels = outputChannels,
507
+ samples = FloatArray(0),
508
+ isFinal = true
509
+ )
510
+ emittedChunks += 1
511
+ }
512
+
513
+ emitComplete(
514
+ durationMs = rangeDurationMs,
515
+ sampleRate = outputSampleRate,
516
+ channels = outputChannels,
517
+ chunks = emittedChunks,
518
+ samples = emittedSamples,
519
+ cancelled = false
520
+ )
521
+ } catch (e: Exception) {
522
+ LogUtils.e(TAG, "Decode failed: ${e.message}", e)
523
+ emitError(
524
+ "ERR_AUDIO_STREAM_DECODE_FAILED",
525
+ e.message ?: "Unknown decode error"
526
+ )
527
+ } finally {
528
+ try {
529
+ codec?.stop()
530
+ } catch (_: Exception) { /* noop */ }
531
+ try {
532
+ codec?.release()
533
+ } catch (_: Exception) { /* noop */ }
534
+ try {
535
+ extractor.release()
536
+ } catch (_: Exception) { /* noop */ }
537
+ tempFile?.let { runCatching { it.delete() } }
538
+ }
539
+ }
540
+
541
+ private data class ResolvedFile(val path: String, val tempFile: File?)
542
+
543
+ private fun readMetadataDurationMs(path: String): Long {
544
+ return try {
545
+ val retriever = MediaMetadataRetriever()
546
+ try {
547
+ retriever.setDataSource(path)
548
+ retriever
549
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
550
+ ?.toLongOrNull()
551
+ ?: -1L
552
+ } finally {
553
+ retriever.release()
554
+ }
555
+ } catch (_: Exception) {
556
+ -1L
557
+ }
558
+ }
559
+
560
+ private fun resolveFilePath(uri: String): ResolvedFile? {
561
+ if (uri.startsWith("/")) return ResolvedFile(uri, null)
562
+ return try {
563
+ val parsed = Uri.parse(uri)
564
+ when (parsed.scheme) {
565
+ "file" -> parsed.path?.let { ResolvedFile(it, null) }
566
+ "content" -> {
567
+ // MediaExtractor needs a real file path; copy the content
568
+ // URI into the cache dir and remember the temp file so we
569
+ // can delete it in `finally` (otherwise every call leaks a
570
+ // copy of the source audio onto disk).
571
+ val temp = File.createTempFile(
572
+ "audiostream_${options.requestId}_",
573
+ null,
574
+ context.cacheDir
575
+ )
576
+ context.contentResolver.openInputStream(parsed)?.use { input ->
577
+ temp.outputStream().use { out -> input.copyTo(out) }
578
+ }
579
+ ResolvedFile(temp.absolutePath, temp)
580
+ }
581
+ null -> ResolvedFile(uri, null)
582
+ else -> null
583
+ }
584
+ } catch (e: Exception) {
585
+ LogUtils.e(TAG, "resolveFilePath failed: ${e.message}", e)
586
+ null
587
+ }
588
+ }
589
+
590
+ private fun pcmToFloat(
591
+ pcm: ByteArray,
592
+ sourceChannels: Int,
593
+ targetChannels: Int,
594
+ normalize: Boolean,
595
+ ): FloatArray {
596
+ if (pcm.isEmpty() || sourceChannels <= 0) return FloatArray(0)
597
+ val buffer = ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN)
598
+ val totalShorts = pcm.size / 2
599
+ val srcFrames = totalShorts / sourceChannels
600
+ val out = FloatArray(srcFrames * targetChannels)
601
+ var outIdx = 0
602
+ val tempFrame = FloatArray(sourceChannels)
603
+ for (frame in 0 until srcFrames) {
604
+ for (c in 0 until sourceChannels) {
605
+ val s = buffer.short.toFloat() / 32768.0f
606
+ val safe = if (s.isFinite()) s else 0f
607
+ tempFrame[c] = if (normalize) {
608
+ when {
609
+ safe > 1f -> 1f
610
+ safe < -1f -> -1f
611
+ else -> safe
612
+ }
613
+ } else {
614
+ safe
615
+ }
616
+ }
617
+ when {
618
+ sourceChannels == targetChannels -> {
619
+ for (c in 0 until targetChannels) {
620
+ out[outIdx++] = tempFrame[c]
621
+ }
622
+ }
623
+ targetChannels == 1 -> {
624
+ // Downmix average
625
+ var sum = 0f
626
+ for (c in 0 until sourceChannels) sum += tempFrame[c]
627
+ out[outIdx++] = sum / sourceChannels
628
+ }
629
+ sourceChannels == 1 -> {
630
+ // Upmix mono -> N (duplicate)
631
+ for (c in 0 until targetChannels) {
632
+ out[outIdx++] = tempFrame[0]
633
+ }
634
+ }
635
+ else -> {
636
+ // Drop or duplicate channels; for simplicity take first N or zero-pad.
637
+ for (c in 0 until targetChannels) {
638
+ out[outIdx++] = if (c < sourceChannels) tempFrame[c] else 0f
639
+ }
640
+ }
641
+ }
642
+ }
643
+ return out
644
+ }
645
+
646
+ private enum class AckWaitResult { OK, CANCELLED, TIMED_OUT }
647
+
648
+ private fun waitForAckOrCancel(emittedIndex: Int): AckWaitResult {
649
+ val deadlineMs = options.backpressureTimeoutMs
650
+ ?.takeIf { it > 0 }
651
+ ?.let { System.currentTimeMillis() + it }
652
+ synchronized(ackLock) {
653
+ while (true) {
654
+ if (cancelled.get()) return AckWaitResult.CANCELLED
655
+ val inFlight = emittedIndex - lastAckedIndex.get()
656
+ if (inFlight < options.maxBufferedChunks) return AckWaitResult.OK
657
+ val remainingMs = deadlineMs?.let { it - System.currentTimeMillis() }
658
+ if (remainingMs != null && remainingMs <= 0L) {
659
+ return AckWaitResult.TIMED_OUT
660
+ }
661
+ ackLock.wait(minOf(50L, remainingMs ?: 50L))
662
+ }
663
+ }
664
+ }
665
+
666
+ private fun emitChunk(
667
+ index: Int,
668
+ startTimeMs: Double,
669
+ endTimeMs: Double,
670
+ startSample: Long,
671
+ sampleRate: Int,
672
+ channels: Int,
673
+ samples: FloatArray,
674
+ isFinal: Boolean,
675
+ ) {
676
+ // Pass FloatArray directly; expo-modules-core converts to JS array.
677
+ val payload = bundleOf(
678
+ "requestId" to options.requestId,
679
+ "chunkIndex" to index,
680
+ "startTimeMs" to startTimeMs,
681
+ "endTimeMs" to endTimeMs,
682
+ "startSample" to startSample,
683
+ "sampleCount" to samples.size,
684
+ "sampleRate" to sampleRate,
685
+ "channels" to channels,
686
+ "samples" to samples,
687
+ "isFinal" to isFinal,
688
+ )
689
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_CHUNK_EVENT, payload)
690
+ }
691
+
692
+ private fun emitProgress(
693
+ processedMs: Double,
694
+ durationMs: Double,
695
+ emittedChunks: Int,
696
+ ) {
697
+ val progress = if (durationMs > 0) {
698
+ (processedMs / durationMs).coerceIn(0.0, 1.0)
699
+ } else {
700
+ 0.0
701
+ }
702
+ val payload = bundleOf(
703
+ "requestId" to options.requestId,
704
+ "processedMs" to processedMs,
705
+ "durationMs" to durationMs,
706
+ "progress" to progress,
707
+ "emittedChunks" to emittedChunks,
708
+ )
709
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_PROGRESS_EVENT, payload)
710
+ }
711
+
712
+ private fun emitComplete(
713
+ durationMs: Double,
714
+ sampleRate: Int,
715
+ channels: Int,
716
+ chunks: Int,
717
+ samples: Long,
718
+ cancelled: Boolean,
719
+ ) {
720
+ val payload = bundleOf(
721
+ "requestId" to options.requestId,
722
+ "durationMs" to durationMs,
723
+ "sampleRate" to sampleRate,
724
+ "channels" to channels,
725
+ "chunks" to chunks,
726
+ "samples" to samples,
727
+ "cancelled" to cancelled,
728
+ )
729
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_COMPLETE_EVENT, payload)
730
+ }
731
+
732
+ private fun emitError(code: String, message: String) {
733
+ val payload = bundleOf(
734
+ "requestId" to options.requestId,
735
+ "code" to code,
736
+ "message" to message,
737
+ )
738
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_ERROR_EVENT, payload)
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Per-stream linear resampler keyed by output channel count. Maintains the
744
+ * last source frame across buffer boundaries so resampling is continuous.
745
+ */
746
+ private class LinearResampler(private val channels: Int) {
747
+ private var lastSrcFrame: FloatArray? = null
748
+ private var fractional: Double = 0.0
749
+ private var lastSourceRate: Int? = null
750
+ private var lastTargetRate: Int? = null
751
+ private var canFlushLastFrame = false
752
+
753
+ fun process(input: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
754
+ if (input.isEmpty() || channels <= 0) return FloatArray(0)
755
+ val srcFrames = input.size / channels
756
+ if (srcFrames <= 0) return FloatArray(0)
757
+
758
+ if (lastSourceRate != null &&
759
+ (lastSourceRate != sourceRate || lastTargetRate != targetRate)
760
+ ) {
761
+ reset()
762
+ }
763
+ lastSourceRate = sourceRate
764
+ lastTargetRate = targetRate
765
+
766
+ if (sourceRate == targetRate) {
767
+ stashLastFrame(input, srcFrames)
768
+ fractional = 0.0
769
+ canFlushLastFrame = false
770
+ return input
771
+ }
772
+
773
+ val ratio = sourceRate.toDouble() / targetRate.toDouble()
774
+ val previousFrame = lastSrcFrame
775
+ val totalSrcFrames = srcFrames + if (previousFrame != null) 1 else 0
776
+ if (totalSrcFrames < 2) {
777
+ // Not enough to interpolate yet; stash and return empty.
778
+ stashLastFrame(input, srcFrames)
779
+ canFlushLastFrame = true
780
+ return FloatArray(0)
781
+ }
782
+
783
+ // Build virtual stream: [previousFrame?, input...].
784
+ val virtualLen = totalSrcFrames
785
+ val outFrames = ceil(((virtualLen - 1) - fractional) / ratio)
786
+ .toInt()
787
+ .coerceAtLeast(0)
788
+ // Add one frame of slack for floating-point boundary rounding; trim
789
+ // below if the conservative capacity was not used.
790
+ val out = FloatArray((outFrames + 1) * channels)
791
+ var outIdx = 0
792
+ var srcPos = fractional
793
+ while (srcPos < virtualLen - 1) {
794
+ val i = srcPos.toInt()
795
+ val frac = (srcPos - i).toFloat()
796
+ for (c in 0 until channels) {
797
+ val a = sampleAt(i, c, previousFrame, input)
798
+ val b = sampleAt(i + 1, c, previousFrame, input)
799
+ out[outIdx++] = a + (b - a) * frac
800
+ }
801
+ srcPos += ratio
802
+ }
803
+ fractional = srcPos - (virtualLen - 1)
804
+
805
+ // Stash last source frame for the next call. The interpolation loop
806
+ // intentionally stops before the final frame so that it can interpolate
807
+ // across the next decoder buffer; flush() emits this tail at EOS.
808
+ stashLastFrame(input, srcFrames)
809
+ canFlushLastFrame = true
810
+
811
+ return if (outIdx == out.size) out else out.copyOf(outIdx)
812
+ }
813
+
814
+ fun flush(): FloatArray {
815
+ val tail = if (canFlushLastFrame) {
816
+ lastSrcFrame?.copyOf() ?: FloatArray(0)
817
+ } else {
818
+ FloatArray(0)
819
+ }
820
+ reset()
821
+ return tail
822
+ }
823
+
824
+ private fun reset() {
825
+ lastSrcFrame = null
826
+ fractional = 0.0
827
+ canFlushLastFrame = false
828
+ }
829
+
830
+ private fun stashLastFrame(input: FloatArray, srcFrames: Int) {
831
+ lastSrcFrame = FloatArray(channels).also { dst ->
832
+ System.arraycopy(input, (srcFrames - 1) * channels, dst, 0, channels)
833
+ }
834
+ }
835
+
836
+ private fun sampleAt(
837
+ frame: Int,
838
+ channel: Int,
839
+ previous: FloatArray?,
840
+ input: FloatArray,
841
+ ): Float {
842
+ return if (previous != null) {
843
+ if (frame == 0) {
844
+ previous[channel]
845
+ } else {
846
+ input[(frame - 1) * channels + channel]
847
+ }
848
+ } else {
849
+ input[frame * channels + channel]
850
+ }
851
+ }
852
+ }