@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - `streamAudioData(options, callbacks)` — progressive Float32 file decode for
13
+ long compressed recordings without full-PCM materialization. Supports
14
+ `AbortSignal` cancellation, async `onChunk` back-pressure, target sample
15
+ rate, channel downmix, and stable error codes via the new
16
+ `AudioStreamError` class.
17
+ - `getAudioDecodeCapabilities()` for platform feature discovery.
18
+ - iOS: `AVAssetReader`-based decoder with per-`requestId` cancellation.
19
+ - Android: `MediaExtractor` + `MediaCodec` decoder with linear resampling that
20
+ preserves continuity across codec buffer boundaries.
21
+ - Docs: `docs/STREAM_AUDIO_DATA.md`.
22
+
23
+ ### Fixed
24
+
25
+ - iOS `extractRawAudioData` no longer traps on non-finite or out-of-range
26
+ decoded samples. Float-to-`Int16`/`Int32` conversion now replaces NaN/Inf
27
+ with `0` and clamps the scaled value into the integer range before
28
+ conversion.
29
+
10
30
  ## [3.1.1] - 2026-05-08
11
31
  ### Fixed
12
32
  - Trust Android final decoded PCM metadata for range extraction and trimming, including JS `Number` options bridged as Kotlin `Double`.
@@ -0,0 +1,640 @@
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.net.Uri
15
+ import android.os.Bundle
16
+ import androidx.core.os.bundleOf
17
+ import java.io.File
18
+ import java.nio.ByteBuffer
19
+ import java.nio.ByteOrder
20
+ import java.util.concurrent.atomic.AtomicBoolean
21
+ import java.util.concurrent.atomic.AtomicInteger
22
+ import kotlin.concurrent.thread
23
+
24
+ interface AudioStreamDecoderDelegate {
25
+ fun streamDecoderEmit(eventName: String, payload: Bundle)
26
+ }
27
+
28
+ class AudioStreamDecoder(
29
+ private val context: Context,
30
+ private val options: Options,
31
+ private val delegate: AudioStreamDecoderDelegate,
32
+ ) {
33
+ data class Options(
34
+ val requestId: String,
35
+ val fileUri: String,
36
+ val startTimeMs: Long?,
37
+ val endTimeMs: Long?,
38
+ val targetSampleRate: Int?,
39
+ val channels: Int?,
40
+ val normalizeAudio: Boolean,
41
+ val chunkDurationMs: Int,
42
+ val maxChunkBytes: Int?,
43
+ val maxBufferedChunks: Int,
44
+ )
45
+
46
+ companion object {
47
+ private const val TAG = "AudioStreamDecoder"
48
+ private const val TIMEOUT_US = 10_000L
49
+ }
50
+
51
+ private val cancelled = AtomicBoolean(false)
52
+ private val lastAckedIndex = AtomicInteger(-1)
53
+ private val ackLock = Object()
54
+ private var workerThread: Thread? = null
55
+
56
+ fun start() {
57
+ workerThread = thread(
58
+ isDaemon = true,
59
+ name = "AudioStreamDecoder-${options.requestId}"
60
+ ) {
61
+ run()
62
+ }
63
+ }
64
+
65
+ fun cancel() {
66
+ cancelled.set(true)
67
+ synchronized(ackLock) { ackLock.notifyAll() }
68
+ }
69
+
70
+ fun acknowledgeChunk(index: Int) {
71
+ synchronized(ackLock) {
72
+ if (index > lastAckedIndex.get()) {
73
+ lastAckedIndex.set(index)
74
+ }
75
+ ackLock.notifyAll()
76
+ }
77
+ }
78
+
79
+ private fun run() {
80
+ val path = resolveFilePath(options.fileUri)
81
+ if (path == null) {
82
+ emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "Cannot resolve file: ${options.fileUri}")
83
+ return
84
+ }
85
+ if (!File(path).exists()) {
86
+ emitError("ERR_AUDIO_STREAM_FILE_NOT_FOUND", "File not found: $path")
87
+ return
88
+ }
89
+
90
+ val extractor = MediaExtractor()
91
+ var codec: MediaCodec? = null
92
+ var emittedChunks = 0
93
+ var emittedSamples = 0L
94
+ var totalDurationUs: Long = -1
95
+ var outputSampleRate = options.targetSampleRate ?: 0
96
+ var outputChannels = options.channels ?: 0
97
+
98
+ try {
99
+ try {
100
+ extractor.setDataSource(path)
101
+ } catch (e: Exception) {
102
+ emitError(
103
+ "ERR_AUDIO_STREAM_DECODE_FAILED",
104
+ "setDataSource failed: ${e.message}"
105
+ )
106
+ return
107
+ }
108
+
109
+ val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
110
+ val format = extractor.getTrackFormat(idx)
111
+ format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
112
+ }
113
+ if (trackIndex == null) {
114
+ emitError(
115
+ "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
116
+ "No audio track found"
117
+ )
118
+ return
119
+ }
120
+ extractor.selectTrack(trackIndex)
121
+ val format = extractor.getTrackFormat(trackIndex)
122
+ totalDurationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) {
123
+ format.getLong(MediaFormat.KEY_DURATION)
124
+ } else {
125
+ -1
126
+ }
127
+
128
+ val sourceSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
129
+ val sourceChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
130
+ if (outputSampleRate <= 0) outputSampleRate = sourceSampleRate
131
+ if (outputChannels <= 0) outputChannels = minOf(2, maxOf(1, sourceChannels))
132
+
133
+ options.startTimeMs?.let {
134
+ extractor.seekTo(it * 1000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
135
+ }
136
+
137
+ val mime = format.getString(MediaFormat.KEY_MIME)
138
+ ?: run {
139
+ emitError(
140
+ "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
141
+ "Track has no MIME"
142
+ )
143
+ return
144
+ }
145
+ codec = try {
146
+ MediaCodec.createDecoderByType(mime)
147
+ } catch (e: Exception) {
148
+ emitError(
149
+ "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
150
+ "No decoder for $mime: ${e.message}"
151
+ )
152
+ return
153
+ }
154
+ codec.configure(format, null, null, 0)
155
+ codec.start()
156
+
157
+ val endTimeUs = options.endTimeMs?.let { it * 1000L } ?: Long.MAX_VALUE
158
+ val rangeStartMs = options.startTimeMs ?: 0L
159
+ val samplesPerChunk = run {
160
+ val byTime = (options.chunkDurationMs.toLong() *
161
+ outputSampleRate.toLong() / 1000L).toInt() * outputChannels
162
+ val byBytes = options.maxChunkBytes?.let { it / 4 } ?: Int.MAX_VALUE
163
+ maxOf(1, minOf(byTime, byBytes))
164
+ }
165
+ val pending = FloatArray(samplesPerChunk * 2)
166
+ var pendingLen = 0
167
+ val info = MediaCodec.BufferInfo()
168
+ var sawInputEOS = false
169
+ var sawOutputEOS = false
170
+
171
+ // Resampling state across output buffers. We keep the last source
172
+ // sample to interpolate across buffer boundaries.
173
+ val resampler = LinearResampler(outputChannels)
174
+
175
+ while (!sawOutputEOS) {
176
+ if (cancelled.get()) break
177
+
178
+ if (!sawInputEOS) {
179
+ val inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US)
180
+ if (inputBufferIndex >= 0) {
181
+ val inputBuffer = codec.getInputBuffer(inputBufferIndex)
182
+ if (inputBuffer != null) {
183
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
184
+ val sampleTime = extractor.sampleTime
185
+ if (sampleSize < 0 || sampleTime > endTimeUs) {
186
+ codec.queueInputBuffer(
187
+ inputBufferIndex,
188
+ 0,
189
+ 0,
190
+ 0,
191
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM
192
+ )
193
+ sawInputEOS = true
194
+ } else {
195
+ codec.queueInputBuffer(
196
+ inputBufferIndex,
197
+ 0,
198
+ sampleSize,
199
+ sampleTime,
200
+ 0
201
+ )
202
+ extractor.advance()
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ val outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US)
209
+ if (outputIndex >= 0) {
210
+ val outputBuffer = codec.getOutputBuffer(outputIndex)
211
+ if (outputBuffer != null && info.size > 0) {
212
+ outputBuffer.position(info.offset)
213
+ outputBuffer.limit(info.offset + info.size)
214
+ val pcm = ByteArray(info.size)
215
+ outputBuffer.get(pcm)
216
+
217
+ val converted = pcmToFloat(
218
+ pcm,
219
+ sourceChannels,
220
+ outputChannels,
221
+ options.normalizeAudio
222
+ )
223
+ val resampled = resampler.process(
224
+ converted,
225
+ sourceSampleRate,
226
+ outputSampleRate
227
+ )
228
+
229
+ // Append into pending, grow if needed
230
+ val newLen = pendingLen + resampled.size
231
+ val target = if (newLen > pending.size) {
232
+ val grown = FloatArray(newLen.coerceAtLeast(pending.size * 2))
233
+ System.arraycopy(pending, 0, grown, 0, pendingLen)
234
+ grown.also {
235
+ System.arraycopy(it, 0, pending, 0, pendingLen)
236
+ }
237
+ } else {
238
+ pending
239
+ }
240
+ // Note: when grown, we use a transient buffer that we
241
+ // process and never write back into the original.
242
+ val workBuffer: FloatArray
243
+ if (target !== pending) {
244
+ System.arraycopy(resampled, 0, target, pendingLen, resampled.size)
245
+ pendingLen += resampled.size
246
+ workBuffer = target
247
+ } else {
248
+ System.arraycopy(resampled, 0, pending, pendingLen, resampled.size)
249
+ pendingLen += resampled.size
250
+ workBuffer = pending
251
+ }
252
+
253
+ while (pendingLen >= samplesPerChunk) {
254
+ if (cancelled.get()) break
255
+ val chunk = FloatArray(samplesPerChunk)
256
+ System.arraycopy(workBuffer, 0, chunk, 0, samplesPerChunk)
257
+ // Shift remainder forward.
258
+ val remainder = pendingLen - samplesPerChunk
259
+ if (remainder > 0) {
260
+ System.arraycopy(
261
+ workBuffer,
262
+ samplesPerChunk,
263
+ workBuffer,
264
+ 0,
265
+ remainder
266
+ )
267
+ }
268
+ pendingLen = remainder
269
+
270
+ val chunkDurationMs =
271
+ (chunk.size.toDouble() /
272
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
273
+ 1000.0
274
+ val startMs = rangeStartMs +
275
+ (emittedSamples.toDouble() /
276
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
277
+ 1000.0
278
+ emitChunk(
279
+ index = emittedChunks,
280
+ startTimeMs = startMs,
281
+ endTimeMs = startMs + chunkDurationMs,
282
+ startSample = (emittedSamples / outputChannels).toInt(),
283
+ sampleRate = outputSampleRate,
284
+ channels = outputChannels,
285
+ samples = chunk,
286
+ isFinal = false
287
+ )
288
+ emittedChunks += 1
289
+ emittedSamples += chunk.size
290
+ emitProgress(
291
+ processedMs = startMs + chunkDurationMs,
292
+ durationMs = if (totalDurationUs > 0) totalDurationUs / 1000.0 else 0.0,
293
+ emittedChunks = emittedChunks
294
+ )
295
+ if (waitForAckOrCancel(emittedChunks - 1)) {
296
+ cancelled.set(true)
297
+ break
298
+ }
299
+ }
300
+ }
301
+ codec.releaseOutputBuffer(outputIndex, false)
302
+ if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
303
+ sawOutputEOS = true
304
+ }
305
+ } else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
306
+ val newFormat = codec.outputFormat
307
+ if (newFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
308
+ // Source rate from codec output overrides extractor's
309
+ // format when the codec exposes a different value.
310
+ @Suppress("UNUSED_VARIABLE")
311
+ val updatedRate = newFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
312
+ }
313
+ }
314
+ }
315
+
316
+ if (cancelled.get()) {
317
+ emitError("ERR_AUDIO_STREAM_CANCELLED", "Stream cancelled")
318
+ emitComplete(
319
+ durationMs = if (totalDurationUs > 0) totalDurationUs / 1000.0 else 0.0,
320
+ sampleRate = outputSampleRate,
321
+ channels = outputChannels,
322
+ chunks = emittedChunks,
323
+ samples = emittedSamples.toInt(),
324
+ cancelled = true
325
+ )
326
+ return
327
+ }
328
+
329
+ // Flush remainder as final chunk
330
+ if (pendingLen > 0) {
331
+ val tail = FloatArray(pendingLen)
332
+ System.arraycopy(pending, 0, tail, 0, pendingLen)
333
+ val tailDurationMs =
334
+ (tail.size.toDouble() /
335
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
336
+ 1000.0
337
+ val startMs = rangeStartMs +
338
+ (emittedSamples.toDouble() /
339
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
340
+ 1000.0
341
+ emitChunk(
342
+ index = emittedChunks,
343
+ startTimeMs = startMs,
344
+ endTimeMs = startMs + tailDurationMs,
345
+ startSample = (emittedSamples / outputChannels).toInt(),
346
+ sampleRate = outputSampleRate,
347
+ channels = outputChannels,
348
+ samples = tail,
349
+ isFinal = true
350
+ )
351
+ emittedChunks += 1
352
+ emittedSamples += tail.size
353
+ } else if (emittedChunks > 0) {
354
+ emitChunk(
355
+ index = emittedChunks,
356
+ startTimeMs = rangeStartMs +
357
+ (emittedSamples.toDouble() /
358
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
359
+ 1000.0,
360
+ endTimeMs = rangeStartMs +
361
+ (emittedSamples.toDouble() /
362
+ (outputSampleRate.toDouble() * outputChannels.toDouble())) *
363
+ 1000.0,
364
+ startSample = (emittedSamples / outputChannels).toInt(),
365
+ sampleRate = outputSampleRate,
366
+ channels = outputChannels,
367
+ samples = FloatArray(0),
368
+ isFinal = true
369
+ )
370
+ emittedChunks += 1
371
+ }
372
+
373
+ emitComplete(
374
+ durationMs = if (totalDurationUs > 0) totalDurationUs / 1000.0 else 0.0,
375
+ sampleRate = outputSampleRate,
376
+ channels = outputChannels,
377
+ chunks = emittedChunks,
378
+ samples = emittedSamples.toInt(),
379
+ cancelled = false
380
+ )
381
+ } catch (e: Exception) {
382
+ LogUtils.e(TAG, "Decode failed: ${e.message}", e)
383
+ emitError(
384
+ "ERR_AUDIO_STREAM_DECODE_FAILED",
385
+ e.message ?: "Unknown decode error"
386
+ )
387
+ } finally {
388
+ try {
389
+ codec?.stop()
390
+ } catch (_: Exception) { /* noop */ }
391
+ try {
392
+ codec?.release()
393
+ } catch (_: Exception) { /* noop */ }
394
+ try {
395
+ extractor.release()
396
+ } catch (_: Exception) { /* noop */ }
397
+ }
398
+ }
399
+
400
+ private fun resolveFilePath(uri: String): String? {
401
+ if (uri.startsWith("/")) return uri
402
+ return try {
403
+ val parsed = Uri.parse(uri)
404
+ when (parsed.scheme) {
405
+ "file" -> parsed.path
406
+ "content" -> {
407
+ // Copy to cache so MediaExtractor can read by path.
408
+ val temp = File.createTempFile(
409
+ "audiostream_${options.requestId}_",
410
+ null,
411
+ context.cacheDir
412
+ )
413
+ context.contentResolver.openInputStream(parsed)?.use { input ->
414
+ temp.outputStream().use { out -> input.copyTo(out) }
415
+ }
416
+ temp.absolutePath
417
+ }
418
+ null -> uri
419
+ else -> null
420
+ }
421
+ } catch (e: Exception) {
422
+ LogUtils.e(TAG, "resolveFilePath failed: ${e.message}", e)
423
+ null
424
+ }
425
+ }
426
+
427
+ private fun pcmToFloat(
428
+ pcm: ByteArray,
429
+ sourceChannels: Int,
430
+ targetChannels: Int,
431
+ normalize: Boolean,
432
+ ): FloatArray {
433
+ if (pcm.isEmpty() || sourceChannels <= 0) return FloatArray(0)
434
+ val buffer = ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN)
435
+ val totalShorts = pcm.size / 2
436
+ val srcFrames = totalShorts / sourceChannels
437
+ val out = FloatArray(srcFrames * targetChannels)
438
+ var outIdx = 0
439
+ val tempFrame = FloatArray(sourceChannels)
440
+ for (frame in 0 until srcFrames) {
441
+ for (c in 0 until sourceChannels) {
442
+ val s = buffer.short.toFloat() / 32768.0f
443
+ val safe = if (s.isFinite()) s else 0f
444
+ tempFrame[c] = if (normalize) {
445
+ when {
446
+ safe > 1f -> 1f
447
+ safe < -1f -> -1f
448
+ else -> safe
449
+ }
450
+ } else {
451
+ safe
452
+ }
453
+ }
454
+ when {
455
+ sourceChannels == targetChannels -> {
456
+ for (c in 0 until targetChannels) {
457
+ out[outIdx++] = tempFrame[c]
458
+ }
459
+ }
460
+ targetChannels == 1 -> {
461
+ // Downmix average
462
+ var sum = 0f
463
+ for (c in 0 until sourceChannels) sum += tempFrame[c]
464
+ out[outIdx++] = sum / sourceChannels
465
+ }
466
+ sourceChannels == 1 -> {
467
+ // Upmix mono -> N (duplicate)
468
+ for (c in 0 until targetChannels) {
469
+ out[outIdx++] = tempFrame[0]
470
+ }
471
+ }
472
+ else -> {
473
+ // Drop or duplicate channels; for simplicity take first N or zero-pad.
474
+ for (c in 0 until targetChannels) {
475
+ out[outIdx++] = if (c < sourceChannels) tempFrame[c] else 0f
476
+ }
477
+ }
478
+ }
479
+ }
480
+ return out
481
+ }
482
+
483
+ private fun waitForAckOrCancel(emittedIndex: Int): Boolean {
484
+ synchronized(ackLock) {
485
+ while (true) {
486
+ if (cancelled.get()) return true
487
+ val inFlight = emittedIndex - lastAckedIndex.get()
488
+ if (inFlight < options.maxBufferedChunks) return false
489
+ ackLock.wait(50L)
490
+ }
491
+ @Suppress("UNREACHABLE_CODE")
492
+ return false
493
+ }
494
+ }
495
+
496
+ private fun emitChunk(
497
+ index: Int,
498
+ startTimeMs: Double,
499
+ endTimeMs: Double,
500
+ startSample: Int,
501
+ sampleRate: Int,
502
+ channels: Int,
503
+ samples: FloatArray,
504
+ isFinal: Boolean,
505
+ ) {
506
+ // Pass FloatArray directly; expo-modules-core converts to JS array.
507
+ val payload = bundleOf(
508
+ "requestId" to options.requestId,
509
+ "chunkIndex" to index,
510
+ "startTimeMs" to startTimeMs,
511
+ "endTimeMs" to endTimeMs,
512
+ "startSample" to startSample,
513
+ "sampleCount" to samples.size,
514
+ "sampleRate" to sampleRate,
515
+ "channels" to channels,
516
+ "samples" to samples,
517
+ "isFinal" to isFinal,
518
+ )
519
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_CHUNK_EVENT, payload)
520
+ }
521
+
522
+ private fun emitProgress(
523
+ processedMs: Double,
524
+ durationMs: Double,
525
+ emittedChunks: Int,
526
+ ) {
527
+ val progress = if (durationMs > 0) {
528
+ (processedMs / durationMs).coerceIn(0.0, 1.0)
529
+ } else {
530
+ 0.0
531
+ }
532
+ val payload = bundleOf(
533
+ "requestId" to options.requestId,
534
+ "processedMs" to processedMs,
535
+ "durationMs" to durationMs,
536
+ "progress" to progress,
537
+ "emittedChunks" to emittedChunks,
538
+ )
539
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_PROGRESS_EVENT, payload)
540
+ }
541
+
542
+ private fun emitComplete(
543
+ durationMs: Double,
544
+ sampleRate: Int,
545
+ channels: Int,
546
+ chunks: Int,
547
+ samples: Int,
548
+ cancelled: Boolean,
549
+ ) {
550
+ val payload = bundleOf(
551
+ "requestId" to options.requestId,
552
+ "durationMs" to durationMs,
553
+ "sampleRate" to sampleRate,
554
+ "channels" to channels,
555
+ "chunks" to chunks,
556
+ "samples" to samples,
557
+ "cancelled" to cancelled,
558
+ )
559
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_COMPLETE_EVENT, payload)
560
+ }
561
+
562
+ private fun emitError(code: String, message: String) {
563
+ val payload = bundleOf(
564
+ "requestId" to options.requestId,
565
+ "code" to code,
566
+ "message" to message,
567
+ )
568
+ delegate.streamDecoderEmit(Constants.AUDIO_STREAM_ERROR_EVENT, payload)
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Per-stream linear resampler keyed by output channel count. Maintains the
574
+ * last source frame across buffer boundaries so resampling is continuous.
575
+ */
576
+ private class LinearResampler(private val channels: Int) {
577
+ private var lastSrcFrame: FloatArray? = null
578
+ private var fractional: Double = 0.0
579
+
580
+ fun process(input: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
581
+ if (input.isEmpty() || channels <= 0) return FloatArray(0)
582
+ if (sourceRate == targetRate) return input
583
+
584
+ val ratio = sourceRate.toDouble() / targetRate.toDouble()
585
+ val srcFrames = input.size / channels
586
+ val previousFrame = lastSrcFrame
587
+ val totalSrcFrames = srcFrames + if (previousFrame != null) 1 else 0
588
+ if (totalSrcFrames < 2) {
589
+ // Not enough to interpolate yet; stash and return empty.
590
+ lastSrcFrame = FloatArray(channels).also { dst ->
591
+ if (srcFrames >= 1) {
592
+ System.arraycopy(input, (srcFrames - 1) * channels, dst, 0, channels)
593
+ }
594
+ }
595
+ return FloatArray(0)
596
+ }
597
+
598
+ // Build virtual stream: [previousFrame?, input...].
599
+ val virtualLen = totalSrcFrames
600
+ val out = ArrayList<Float>(((virtualLen * targetRate) / sourceRate + 1) * channels)
601
+ var srcPos = fractional
602
+ while (srcPos < virtualLen - 1) {
603
+ val i = srcPos.toInt()
604
+ val frac = (srcPos - i).toFloat()
605
+ for (c in 0 until channels) {
606
+ val a = sampleAt(i, c, previousFrame, input)
607
+ val b = sampleAt(i + 1, c, previousFrame, input)
608
+ out.add(a + (b - a) * frac)
609
+ }
610
+ srcPos += ratio
611
+ }
612
+ fractional = srcPos - (virtualLen - 1)
613
+
614
+ // Stash last source frame for the next call.
615
+ lastSrcFrame = FloatArray(channels).also { dst ->
616
+ System.arraycopy(input, (srcFrames - 1) * channels, dst, 0, channels)
617
+ }
618
+
619
+ val arr = FloatArray(out.size)
620
+ for (i in out.indices) arr[i] = out[i]
621
+ return arr
622
+ }
623
+
624
+ private fun sampleAt(
625
+ frame: Int,
626
+ channel: Int,
627
+ previous: FloatArray?,
628
+ input: FloatArray,
629
+ ): Float {
630
+ return if (previous != null) {
631
+ if (frame == 0) {
632
+ previous[channel]
633
+ } else {
634
+ input[(frame - 1) * channels + channel]
635
+ }
636
+ } else {
637
+ input[frame * channels + channel]
638
+ }
639
+ }
640
+ }