react-native-audio-concat 0.2.3 → 0.4.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 (63) hide show
  1. package/AudioConcat.podspec +2 -21
  2. package/README.md +3 -7
  3. package/android/build.gradle +2 -54
  4. package/android/src/main/java/com/audioconcat/AudioConcatModule.kt +1178 -0
  5. package/android/src/main/java/com/audioconcat/AudioConcatPackage.kt +33 -0
  6. package/ios/AudioConcat.h +5 -0
  7. package/ios/AudioConcat.mm +104 -0
  8. package/lib/module/NativeAudioConcat.js +5 -0
  9. package/lib/module/NativeAudioConcat.js.map +1 -0
  10. package/lib/module/index.js +2 -28
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/typescript/src/NativeAudioConcat.d.ts +12 -0
  13. package/lib/typescript/src/NativeAudioConcat.d.ts.map +1 -0
  14. package/lib/typescript/src/index.d.ts +6 -27
  15. package/lib/typescript/src/index.d.ts.map +1 -1
  16. package/package.json +14 -18
  17. package/src/NativeAudioConcat.ts +12 -0
  18. package/src/index.tsx +4 -32
  19. package/android/CMakeLists.txt +0 -24
  20. package/android/src/main/cpp/cpp-adapter.cpp +0 -6
  21. package/android/src/main/java/com/margelo/nitro/audioconcat/AudioConcat.kt +0 -349
  22. package/android/src/main/java/com/margelo/nitro/audioconcat/AudioConcatPackage.kt +0 -22
  23. package/ios/AudioConcat.swift +0 -75
  24. package/lib/module/AudioConcat.nitro.js +0 -4
  25. package/lib/module/AudioConcat.nitro.js.map +0 -1
  26. package/lib/typescript/src/AudioConcat.nitro.d.ts +0 -16
  27. package/lib/typescript/src/AudioConcat.nitro.d.ts.map +0 -1
  28. package/nitro.json +0 -17
  29. package/nitrogen/generated/android/audioconcat+autolinking.cmake +0 -82
  30. package/nitrogen/generated/android/audioconcat+autolinking.gradle +0 -27
  31. package/nitrogen/generated/android/audioconcatOnLoad.cpp +0 -44
  32. package/nitrogen/generated/android/audioconcatOnLoad.hpp +0 -25
  33. package/nitrogen/generated/android/c++/JAudioData.hpp +0 -53
  34. package/nitrogen/generated/android/c++/JAudioDataOrSilence.cpp +0 -26
  35. package/nitrogen/generated/android/c++/JAudioDataOrSilence.hpp +0 -72
  36. package/nitrogen/generated/android/c++/JHybridAudioConcatSpec.cpp +0 -77
  37. package/nitrogen/generated/android/c++/JHybridAudioConcatSpec.hpp +0 -64
  38. package/nitrogen/generated/android/c++/JSilentData.hpp +0 -53
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audioconcat/AudioData.kt +0 -29
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audioconcat/AudioDataOrSilence.kt +0 -42
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audioconcat/HybridAudioConcatSpec.kt +0 -52
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audioconcat/SilentData.kt +0 -29
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audioconcat/audioconcatOnLoad.kt +0 -35
  44. package/nitrogen/generated/ios/AudioConcat+autolinking.rb +0 -60
  45. package/nitrogen/generated/ios/AudioConcat-Swift-Cxx-Bridge.cpp +0 -48
  46. package/nitrogen/generated/ios/AudioConcat-Swift-Cxx-Bridge.hpp +0 -160
  47. package/nitrogen/generated/ios/AudioConcat-Swift-Cxx-Umbrella.hpp +0 -53
  48. package/nitrogen/generated/ios/AudioConcatAutolinking.mm +0 -33
  49. package/nitrogen/generated/ios/AudioConcatAutolinking.swift +0 -25
  50. package/nitrogen/generated/ios/c++/HybridAudioConcatSpecSwift.cpp +0 -11
  51. package/nitrogen/generated/ios/c++/HybridAudioConcatSpecSwift.hpp +0 -81
  52. package/nitrogen/generated/ios/swift/AudioData.swift +0 -35
  53. package/nitrogen/generated/ios/swift/AudioDataOrSilence.swift +0 -18
  54. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +0 -47
  55. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +0 -47
  56. package/nitrogen/generated/ios/swift/HybridAudioConcatSpec.swift +0 -49
  57. package/nitrogen/generated/ios/swift/HybridAudioConcatSpec_cxx.swift +0 -142
  58. package/nitrogen/generated/ios/swift/SilentData.swift +0 -35
  59. package/nitrogen/generated/shared/c++/AudioData.hpp +0 -67
  60. package/nitrogen/generated/shared/c++/HybridAudioConcatSpec.cpp +0 -21
  61. package/nitrogen/generated/shared/c++/HybridAudioConcatSpec.hpp +0 -70
  62. package/nitrogen/generated/shared/c++/SilentData.hpp +0 -67
  63. package/src/AudioConcat.nitro.ts +0 -19
@@ -0,0 +1,1178 @@
1
+ package com.audioconcat
2
+
3
+ import com.facebook.react.bridge.ReactApplicationContext
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReadableArray
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.module.annotations.ReactModule
8
+ import android.media.MediaCodec
9
+ import android.media.MediaCodecInfo
10
+ import android.media.MediaExtractor
11
+ import android.media.MediaFormat
12
+ import android.media.MediaMuxer
13
+ import java.io.File
14
+ import java.nio.ByteBuffer
15
+ import android.util.Log
16
+ import java.util.concurrent.Executors
17
+ import java.util.concurrent.BlockingQueue
18
+ import java.util.concurrent.LinkedBlockingQueue
19
+ import java.util.concurrent.CountDownLatch
20
+ import java.util.concurrent.atomic.AtomicInteger
21
+ import java.util.concurrent.ConcurrentHashMap
22
+
23
+ @ReactModule(name = AudioConcatModule.NAME)
24
+ class AudioConcatModule(reactContext: ReactApplicationContext) :
25
+ NativeAudioConcatSpec(reactContext) {
26
+
27
+ private data class AudioConfig(
28
+ val sampleRate: Int,
29
+ val channelCount: Int,
30
+ val bitRate: Int
31
+ )
32
+
33
+ private sealed class AudioDataOrSilence {
34
+ data class AudioFile(val filePath: String) : AudioDataOrSilence()
35
+ data class Silence(val durationMs: Double) : AudioDataOrSilence()
36
+ }
37
+
38
+ private data class PCMChunk(
39
+ val data: ByteArray,
40
+ val sequenceNumber: Int,
41
+ val isEndOfStream: Boolean = false
42
+ ) {
43
+ companion object {
44
+ fun endOfStream(sequenceNumber: Int) = PCMChunk(ByteArray(0), sequenceNumber, true)
45
+ }
46
+ }
47
+
48
+ // Cache for decoded PCM data
49
+ private data class CachedPCMData(
50
+ val chunks: List<ByteArray>,
51
+ val totalBytes: Long
52
+ )
53
+
54
+ private data class SilenceCacheKey(
55
+ val durationMs: Double,
56
+ val sampleRate: Int,
57
+ val channelCount: Int
58
+ )
59
+
60
+ private class PCMCache(
61
+ private val shouldCacheFile: Set<String>,
62
+ private val shouldCacheSilence: Set<SilenceCacheKey>
63
+ ) {
64
+ private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
65
+ private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
66
+ private val maxCacheSizeMB = 100 // Limit cache to 100MB
67
+ private var currentCacheSizeBytes = 0L
68
+
69
+ fun getAudioFile(filePath: String): CachedPCMData? {
70
+ return audioFileCache[filePath]
71
+ }
72
+
73
+ fun putAudioFile(filePath: String, data: CachedPCMData) {
74
+ // Only cache if this file appears multiple times
75
+ if (!shouldCacheFile.contains(filePath)) {
76
+ return
77
+ }
78
+
79
+ // Check cache size limit
80
+ if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeMB * 1024 * 1024) {
81
+ Log.d("AudioConcat", "Cache full, not caching: $filePath")
82
+ return
83
+ }
84
+
85
+ audioFileCache[filePath] = data
86
+ currentCacheSizeBytes += data.totalBytes
87
+ Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB)")
88
+ }
89
+
90
+ fun getSilence(key: SilenceCacheKey): ByteArray? {
91
+ return silenceCache[key]
92
+ }
93
+
94
+ fun putSilence(key: SilenceCacheKey, data: ByteArray) {
95
+ // Only cache if this silence pattern appears multiple times
96
+ if (!shouldCacheSilence.contains(key)) {
97
+ return
98
+ }
99
+
100
+ silenceCache[key] = data
101
+ Log.d("AudioConcat", "Cached silence: ${key.durationMs}ms")
102
+ }
103
+
104
+ fun clear() {
105
+ audioFileCache.clear()
106
+ silenceCache.clear()
107
+ currentCacheSizeBytes = 0
108
+ Log.d("AudioConcat", "Cache cleared")
109
+ }
110
+
111
+ fun getStats(): String {
112
+ return "Audio files: ${audioFileCache.size}, Silence patterns: ${silenceCache.size}, Size: ${currentCacheSizeBytes / 1024}KB"
113
+ }
114
+ }
115
+
116
+ private fun extractAudioConfig(filePath: String): AudioConfig {
117
+ val extractor = MediaExtractor()
118
+ try {
119
+ extractor.setDataSource(filePath)
120
+ for (i in 0 until extractor.trackCount) {
121
+ val format = extractor.getTrackFormat(i)
122
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
123
+ if (mime.startsWith("audio/")) {
124
+ val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
125
+ val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
126
+ val bitRate = if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
127
+ format.getInteger(MediaFormat.KEY_BIT_RATE)
128
+ } else {
129
+ 128000 // Default 128kbps
130
+ }
131
+ return AudioConfig(sampleRate, channelCount, bitRate)
132
+ }
133
+ }
134
+ throw Exception("No audio track found in $filePath")
135
+ } finally {
136
+ extractor.release()
137
+ }
138
+ }
139
+
140
+ private class StreamingEncoder(
141
+ sampleRate: Int,
142
+ channelCount: Int,
143
+ bitRate: Int,
144
+ outputPath: String
145
+ ) {
146
+ private val encoder: MediaCodec
147
+ private val muxer: MediaMuxer
148
+ private var audioTrackIndex = -1
149
+ private var muxerStarted = false
150
+ private val bufferInfo = MediaCodec.BufferInfo()
151
+ private var totalPresentationTimeUs = 0L
152
+ private val sampleRate: Int
153
+ private val channelCount: Int
154
+
155
+ init {
156
+ this.sampleRate = sampleRate
157
+ this.channelCount = channelCount
158
+
159
+ val outputFormat = MediaFormat.createAudioFormat(
160
+ MediaFormat.MIMETYPE_AUDIO_AAC,
161
+ sampleRate,
162
+ channelCount
163
+ )
164
+ outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
165
+ outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
166
+ outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 65536) // Increased from 16384
167
+
168
+ encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
169
+ encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
170
+ encoder.start()
171
+
172
+ muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
173
+ }
174
+
175
+ fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
176
+ // Split large PCM data into smaller chunks that fit in encoder buffer
177
+ val maxChunkSize = 65536 // Match KEY_MAX_INPUT_SIZE
178
+ var offset = 0
179
+
180
+ while (offset < pcmData.size) {
181
+ val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
182
+ val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
183
+
184
+ // Feed PCM data chunk to encoder
185
+ val inputBufferIndex = encoder.dequeueInputBuffer(10000)
186
+ if (inputBufferIndex >= 0) {
187
+ val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
188
+ val bufferCapacity = inputBuffer.capacity()
189
+
190
+ // Ensure chunk fits in buffer
191
+ val actualChunkSize = minOf(chunkSize, bufferCapacity)
192
+
193
+ inputBuffer.clear()
194
+ inputBuffer.put(pcmData, offset, actualChunkSize)
195
+
196
+ val presentationTimeUs = totalPresentationTimeUs
197
+ totalPresentationTimeUs += (actualChunkSize.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
198
+
199
+ val flags = if (isLastChunk) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
200
+ encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
201
+
202
+ offset += actualChunkSize
203
+ } else {
204
+ // Buffer not available, drain first
205
+ drainEncoder(false)
206
+ }
207
+
208
+ // Drain encoder output periodically
209
+ if (offset < pcmData.size || !isLastChunk) {
210
+ drainEncoder(false)
211
+ }
212
+ }
213
+
214
+ // Final drain if last chunk
215
+ if (isLast) {
216
+ drainEncoder(true)
217
+ }
218
+
219
+ return true
220
+ }
221
+
222
+ private fun drainEncoder(endOfStream: Boolean) {
223
+ while (true) {
224
+ val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 10000 else 0)
225
+
226
+ when (outputBufferIndex) {
227
+ MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
228
+ if (muxerStarted) {
229
+ throw RuntimeException("Format changed twice")
230
+ }
231
+ val newFormat = encoder.outputFormat
232
+ audioTrackIndex = muxer.addTrack(newFormat)
233
+ muxer.start()
234
+ muxerStarted = true
235
+ Log.d("AudioConcat", "Encoder started, format: $newFormat")
236
+ }
237
+ MediaCodec.INFO_TRY_AGAIN_LATER -> {
238
+ if (!endOfStream) {
239
+ break
240
+ }
241
+ // Continue draining when end of stream
242
+ }
243
+ else -> {
244
+ if (outputBufferIndex >= 0) {
245
+ val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
246
+
247
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
248
+ bufferInfo.size = 0
249
+ }
250
+
251
+ if (bufferInfo.size > 0 && muxerStarted) {
252
+ outputBuffer.position(bufferInfo.offset)
253
+ outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
254
+ muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
255
+ }
256
+
257
+ encoder.releaseOutputBuffer(outputBufferIndex, false)
258
+
259
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
260
+ break
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ fun finish() {
269
+ // Signal end of stream
270
+ val inputBufferIndex = encoder.dequeueInputBuffer(10000)
271
+ if (inputBufferIndex >= 0) {
272
+ encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
273
+ }
274
+
275
+ // Drain remaining data
276
+ drainEncoder(true)
277
+
278
+ encoder.stop()
279
+ encoder.release()
280
+
281
+ if (muxerStarted) {
282
+ muxer.stop()
283
+ }
284
+ muxer.release()
285
+ }
286
+ }
287
+
288
+ private fun resamplePCM16(
289
+ input: ByteArray,
290
+ inputSampleRate: Int,
291
+ outputSampleRate: Int,
292
+ channelCount: Int
293
+ ): ByteArray {
294
+ if (inputSampleRate == outputSampleRate) {
295
+ return input
296
+ }
297
+
298
+ val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
299
+ val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
300
+ val output = ByteArray(outputSampleCount * 2 * channelCount)
301
+
302
+ val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
303
+
304
+ for (i in 0 until outputSampleCount) {
305
+ val srcPos = i * ratio
306
+ val srcIndex = srcPos.toInt()
307
+ val fraction = srcPos - srcIndex
308
+
309
+ for (ch in 0 until channelCount) {
310
+ // Get current and next sample
311
+ val idx1 = (srcIndex * channelCount + ch) * 2
312
+ val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
313
+
314
+ val sample1 = if (idx1 + 1 < input.size) {
315
+ (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
316
+ } else {
317
+ 0
318
+ }
319
+
320
+ val sample2 = if (idx2 + 1 < input.size) {
321
+ (input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
322
+ } else {
323
+ sample1
324
+ }
325
+
326
+ // Convert to signed 16-bit
327
+ val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
328
+ val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
329
+
330
+ // Linear interpolation
331
+ val interpolated = (s1 + (s2 - s1) * fraction).toInt()
332
+
333
+ // Clamp to 16-bit range
334
+ val clamped = interpolated.coerceIn(-32768, 32767)
335
+
336
+ // Convert back to unsigned and write
337
+ val outIdx = (i * channelCount + ch) * 2
338
+ output[outIdx] = (clamped and 0xFF).toByte()
339
+ output[outIdx + 1] = (clamped shr 8).toByte()
340
+ }
341
+ }
342
+
343
+ return output
344
+ }
345
+
346
+ private fun convertChannelCount(
347
+ input: ByteArray,
348
+ inputChannels: Int,
349
+ outputChannels: Int
350
+ ): ByteArray {
351
+ if (inputChannels == outputChannels) {
352
+ return input
353
+ }
354
+
355
+ val sampleCount = input.size / (2 * inputChannels)
356
+ val output = ByteArray(sampleCount * 2 * outputChannels)
357
+
358
+ when {
359
+ inputChannels == 1 && outputChannels == 2 -> {
360
+ // Mono to Stereo: duplicate the channel
361
+ for (i in 0 until sampleCount) {
362
+ val srcIdx = i * 2
363
+ val dstIdx = i * 4
364
+ output[dstIdx] = input[srcIdx]
365
+ output[dstIdx + 1] = input[srcIdx + 1]
366
+ output[dstIdx + 2] = input[srcIdx]
367
+ output[dstIdx + 3] = input[srcIdx + 1]
368
+ }
369
+ }
370
+ inputChannels == 2 && outputChannels == 1 -> {
371
+ // Stereo to Mono: average the channels
372
+ for (i in 0 until sampleCount) {
373
+ val srcIdx = i * 4
374
+ val dstIdx = i * 2
375
+
376
+ val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
377
+ val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
378
+
379
+ val leftSigned = if (left > 32767) left - 65536 else left
380
+ val rightSigned = if (right > 32767) right - 65536 else right
381
+
382
+ val avg = ((leftSigned + rightSigned) / 2).coerceIn(-32768, 32767)
383
+
384
+ output[dstIdx] = (avg and 0xFF).toByte()
385
+ output[dstIdx + 1] = (avg shr 8).toByte()
386
+ }
387
+ }
388
+ else -> {
389
+ // Fallback: just take the first channel
390
+ for (i in 0 until sampleCount) {
391
+ val srcIdx = i * 2 * inputChannels
392
+ val dstIdx = i * 2 * outputChannels
393
+ for (ch in 0 until minOf(inputChannels, outputChannels)) {
394
+ output[dstIdx + ch * 2] = input[srcIdx + ch * 2]
395
+ output[dstIdx + ch * 2 + 1] = input[srcIdx + ch * 2 + 1]
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ return output
402
+ }
403
+
404
+ private fun parallelDecodeToQueue(
405
+ filePath: String,
406
+ queue: BlockingQueue<PCMChunk>,
407
+ sequenceStart: AtomicInteger,
408
+ targetSampleRate: Int,
409
+ targetChannelCount: Int,
410
+ latch: CountDownLatch,
411
+ cache: PCMCache
412
+ ) {
413
+ try {
414
+ // Check cache first
415
+ val cachedData = cache.getAudioFile(filePath)
416
+ if (cachedData != null) {
417
+ Log.d("AudioConcat", "Using cached PCM for: $filePath")
418
+ // Put cached chunks to queue
419
+ for (chunk in cachedData.chunks) {
420
+ val seqNum = sequenceStart.getAndIncrement()
421
+ queue.put(PCMChunk(chunk, seqNum))
422
+ }
423
+ latch.countDown()
424
+ return
425
+ }
426
+
427
+ val extractor = MediaExtractor()
428
+ var decoder: MediaCodec? = null
429
+ val decodedChunks = mutableListOf<ByteArray>()
430
+ var totalBytes = 0L
431
+
432
+ try {
433
+ extractor.setDataSource(filePath)
434
+
435
+ var audioTrackIndex = -1
436
+ var audioFormat: MediaFormat? = null
437
+
438
+ for (i in 0 until extractor.trackCount) {
439
+ val format = extractor.getTrackFormat(i)
440
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
441
+ if (mime.startsWith("audio/")) {
442
+ audioTrackIndex = i
443
+ audioFormat = format
444
+ break
445
+ }
446
+ }
447
+
448
+ if (audioTrackIndex == -1 || audioFormat == null) {
449
+ throw Exception("No audio track found in $filePath")
450
+ }
451
+
452
+ val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
453
+ val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
454
+
455
+ val needsResampling = sourceSampleRate != targetSampleRate
456
+ val needsChannelConversion = sourceChannelCount != targetChannelCount
457
+
458
+ if (needsResampling || needsChannelConversion) {
459
+ Log.d("AudioConcat", "Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
460
+ }
461
+
462
+ extractor.selectTrack(audioTrackIndex)
463
+
464
+ val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
465
+ decoder = MediaCodec.createDecoderByType(mime)
466
+ decoder.configure(audioFormat, null, null, 0)
467
+ decoder.start()
468
+
469
+ val bufferInfo = MediaCodec.BufferInfo()
470
+ var isEOS = false
471
+
472
+ while (!isEOS) {
473
+ // Feed input to decoder
474
+ val inputBufferIndex = decoder.dequeueInputBuffer(10000)
475
+ if (inputBufferIndex >= 0) {
476
+ val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
477
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
478
+
479
+ if (sampleSize < 0) {
480
+ decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
481
+ } else {
482
+ val presentationTimeUs = extractor.sampleTime
483
+ decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
484
+ extractor.advance()
485
+ }
486
+ }
487
+
488
+ // Get PCM output from decoder and put to queue
489
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
490
+ if (outputBufferIndex >= 0) {
491
+ val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
492
+
493
+ if (bufferInfo.size > 0) {
494
+ var pcmData = ByteArray(bufferInfo.size)
495
+ outputBuffer.get(pcmData)
496
+
497
+ // Convert channel count if needed
498
+ if (needsChannelConversion) {
499
+ pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
500
+ }
501
+
502
+ // Resample if needed
503
+ if (needsResampling) {
504
+ pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
505
+ }
506
+
507
+ // Store for caching
508
+ decodedChunks.add(pcmData.clone())
509
+ totalBytes += pcmData.size
510
+
511
+ // Put to queue with sequence number
512
+ val seqNum = sequenceStart.getAndIncrement()
513
+ queue.put(PCMChunk(pcmData, seqNum))
514
+ }
515
+
516
+ decoder.releaseOutputBuffer(outputBufferIndex, false)
517
+
518
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
519
+ isEOS = true
520
+ }
521
+ }
522
+ }
523
+
524
+ // Cache the decoded data
525
+ if (decodedChunks.isNotEmpty()) {
526
+ cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
527
+ }
528
+
529
+ } finally {
530
+ decoder?.stop()
531
+ decoder?.release()
532
+ extractor.release()
533
+ }
534
+ } catch (e: Exception) {
535
+ Log.e("AudioConcat", "Error in parallel decode: ${e.message}", e)
536
+ throw e
537
+ } finally {
538
+ latch.countDown()
539
+ }
540
+ }
541
+
542
+ private fun streamDecodeAudioFile(
543
+ filePath: String,
544
+ encoder: StreamingEncoder,
545
+ isLastFile: Boolean,
546
+ targetSampleRate: Int,
547
+ targetChannelCount: Int
548
+ ) {
549
+ val extractor = MediaExtractor()
550
+ var decoder: MediaCodec? = null
551
+
552
+ try {
553
+ extractor.setDataSource(filePath)
554
+
555
+ var audioTrackIndex = -1
556
+ var audioFormat: MediaFormat? = null
557
+
558
+ for (i in 0 until extractor.trackCount) {
559
+ val format = extractor.getTrackFormat(i)
560
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
561
+ if (mime.startsWith("audio/")) {
562
+ audioTrackIndex = i
563
+ audioFormat = format
564
+ break
565
+ }
566
+ }
567
+
568
+ if (audioTrackIndex == -1 || audioFormat == null) {
569
+ throw Exception("No audio track found in $filePath")
570
+ }
571
+
572
+ val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
573
+ val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
574
+
575
+ val needsResampling = sourceSampleRate != targetSampleRate
576
+ val needsChannelConversion = sourceChannelCount != targetChannelCount
577
+
578
+ if (needsResampling || needsChannelConversion) {
579
+ Log.d("AudioConcat", "File: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
580
+ }
581
+
582
+ extractor.selectTrack(audioTrackIndex)
583
+
584
+ val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
585
+ decoder = MediaCodec.createDecoderByType(mime)
586
+ decoder.configure(audioFormat, null, null, 0)
587
+ decoder.start()
588
+
589
+ val bufferInfo = MediaCodec.BufferInfo()
590
+ var isEOS = false
591
+
592
+ while (!isEOS) {
593
+ // Feed input to decoder
594
+ val inputBufferIndex = decoder.dequeueInputBuffer(10000)
595
+ if (inputBufferIndex >= 0) {
596
+ val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
597
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
598
+
599
+ if (sampleSize < 0) {
600
+ decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
601
+ } else {
602
+ val presentationTimeUs = extractor.sampleTime
603
+ decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
604
+ extractor.advance()
605
+ }
606
+ }
607
+
608
+ // Get PCM output from decoder and feed to encoder
609
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
610
+ if (outputBufferIndex >= 0) {
611
+ val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
612
+
613
+ if (bufferInfo.size > 0) {
614
+ var pcmData = ByteArray(bufferInfo.size)
615
+ outputBuffer.get(pcmData)
616
+
617
+ // Convert channel count if needed
618
+ if (needsChannelConversion) {
619
+ pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
620
+ }
621
+
622
+ // Resample if needed
623
+ if (needsResampling) {
624
+ pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
625
+ }
626
+
627
+ // Stream to encoder
628
+ encoder.encodePCMChunk(pcmData, false)
629
+ }
630
+
631
+ decoder.releaseOutputBuffer(outputBufferIndex, false)
632
+
633
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
634
+ isEOS = true
635
+ }
636
+ }
637
+ }
638
+
639
+ } finally {
640
+ decoder?.stop()
641
+ decoder?.release()
642
+ extractor.release()
643
+ }
644
+ }
645
+
646
+ private fun streamEncodeSilence(
647
+ durationMs: Double,
648
+ encoder: StreamingEncoder,
649
+ sampleRate: Int,
650
+ channelCount: Int,
651
+ cache: PCMCache
652
+ ) {
653
+ val cacheKey = SilenceCacheKey(durationMs, sampleRate, channelCount)
654
+
655
+ // Check cache first
656
+ val cachedSilence = cache.getSilence(cacheKey)
657
+ if (cachedSilence != null) {
658
+ Log.d("AudioConcat", "Using cached silence: ${durationMs}ms")
659
+ encoder.encodePCMChunk(cachedSilence, false)
660
+ return
661
+ }
662
+
663
+ // Generate silence
664
+ val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
665
+ val bytesPerSample = channelCount * 2 // 16-bit stereo
666
+ val totalBytes = totalSamples * bytesPerSample
667
+
668
+ // For short silence (< 5 seconds), cache as single chunk
669
+ if (durationMs < 5000) {
670
+ val silenceData = ByteArray(totalBytes) // All zeros = silence
671
+ cache.putSilence(cacheKey, silenceData)
672
+ encoder.encodePCMChunk(silenceData, false)
673
+ } else {
674
+ // For longer silence, process in chunks without caching
675
+ val chunkSamples = 16384
676
+ var samplesRemaining = totalSamples
677
+
678
+ while (samplesRemaining > 0) {
679
+ val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
680
+ val chunkBytes = currentChunkSamples * bytesPerSample
681
+ val silenceChunk = ByteArray(chunkBytes)
682
+
683
+ encoder.encodePCMChunk(silenceChunk, false)
684
+ samplesRemaining -= currentChunkSamples
685
+ }
686
+ }
687
+ }
688
+
689
+ private fun parallelProcessAudioFiles(
690
+ audioFiles: List<Pair<Int, String>>, // (index, filePath)
691
+ encoder: StreamingEncoder,
692
+ targetSampleRate: Int,
693
+ targetChannelCount: Int,
694
+ cache: PCMCache,
695
+ numThreads: Int = 3
696
+ ) {
697
+ if (audioFiles.isEmpty()) return
698
+
699
+ // Group consecutive duplicate files
700
+ val optimizedFiles = mutableListOf<Pair<Int, String>>()
701
+ val consecutiveDuplicates = mutableMapOf<Int, Int>() // originalIndex -> count
702
+
703
+ var i = 0
704
+ while (i < audioFiles.size) {
705
+ val (index, filePath) = audioFiles[i]
706
+ var count = 1
707
+
708
+ // Check for consecutive duplicates
709
+ while (i + count < audioFiles.size && audioFiles[i + count].second == filePath) {
710
+ count++
711
+ }
712
+
713
+ if (count > 1) {
714
+ Log.d("AudioConcat", "Detected $count consecutive occurrences of: $filePath")
715
+ optimizedFiles.add(Pair(index, filePath))
716
+ consecutiveDuplicates[optimizedFiles.size - 1] = count
717
+ } else {
718
+ optimizedFiles.add(Pair(index, filePath))
719
+ }
720
+
721
+ i += count
722
+ }
723
+
724
+ val pcmQueue = LinkedBlockingQueue<PCMChunk>(100)
725
+ val executor = Executors.newFixedThreadPool(numThreads)
726
+ val latch = CountDownLatch(optimizedFiles.size)
727
+ val sequenceCounter = AtomicInteger(0)
728
+
729
+ try {
730
+ // Submit decode tasks for unique files only
731
+ optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
732
+ executor.submit {
733
+ try {
734
+ val fileSequenceStart = AtomicInteger(sequenceCounter.get())
735
+ sequenceCounter.addAndGet(1000000)
736
+
737
+ Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
738
+ parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
739
+
740
+ // Mark end with duplicate count
741
+ val repeatCount = consecutiveDuplicates[optIndex] ?: 1
742
+ val endSeqNum = fileSequenceStart.get()
743
+ pcmQueue.put(PCMChunk(ByteArray(0), endSeqNum, true)) // endOfStream marker with repeat count
744
+
745
+ } catch (e: Exception) {
746
+ Log.e("AudioConcat", "Error decoding file $filePath: ${e.message}", e)
747
+ latch.countDown()
748
+ }
749
+ }
750
+ }
751
+
752
+ // Consumer thread: encode in order
753
+ var filesCompleted = 0
754
+ var cachedChunks = mutableListOf<ByteArray>()
755
+ var isCollectingChunks = false
756
+
757
+ while (filesCompleted < optimizedFiles.size) {
758
+ val chunk = pcmQueue.take()
759
+
760
+ if (chunk.isEndOfStream) {
761
+ val optIndex = filesCompleted
762
+ val repeatCount = consecutiveDuplicates[optIndex] ?: 1
763
+
764
+ if (repeatCount > 1 && cachedChunks.isNotEmpty()) {
765
+ // Repeat the cached chunks
766
+ Log.d("AudioConcat", "Repeating cached chunks ${repeatCount - 1} more times")
767
+ repeat(repeatCount - 1) {
768
+ cachedChunks.forEach { data ->
769
+ encoder.encodePCMChunk(data, false)
770
+ }
771
+ }
772
+ cachedChunks.clear()
773
+ }
774
+
775
+ filesCompleted++
776
+ isCollectingChunks = false
777
+ Log.d("AudioConcat", "Completed file $filesCompleted/${optimizedFiles.size}")
778
+ continue
779
+ }
780
+
781
+ // Encode chunk
782
+ encoder.encodePCMChunk(chunk.data, false)
783
+
784
+ // Cache chunks for consecutive duplicates
785
+ val optIndex = filesCompleted
786
+ if (consecutiveDuplicates.containsKey(optIndex)) {
787
+ cachedChunks.add(chunk.data.clone())
788
+ }
789
+ }
790
+
791
+ // Wait for all decode tasks to complete
792
+ latch.await()
793
+ Log.d("AudioConcat", "All parallel decode tasks completed")
794
+
795
+ } finally {
796
+ executor.shutdown()
797
+ }
798
+ }
799
+
800
+ private data class InterleavedPattern(
801
+ val filePath: String,
802
+ val silenceKey: SilenceCacheKey?,
803
+ val indices: List<Int>, // Indices where this pattern occurs
804
+ val repeatCount: Int
805
+ )
806
+
807
+ private data class DuplicateAnalysis(
808
+ val duplicateFiles: Set<String>,
809
+ val duplicateSilence: Set<SilenceCacheKey>,
810
+ val fileOccurrences: Map<String, List<Int>>, // filePath -> list of indices
811
+ val silenceOccurrences: Map<SilenceCacheKey, List<Int>>,
812
+ val interleavedPatterns: List<InterleavedPattern>
813
+ )
814
+
815
+ private fun analyzeDuplicates(
816
+ parsedData: List<AudioDataOrSilence>,
817
+ audioConfig: AudioConfig
818
+ ): DuplicateAnalysis {
819
+ val fileCounts = mutableMapOf<String, MutableList<Int>>()
820
+ val silenceCounts = mutableMapOf<SilenceCacheKey, MutableList<Int>>()
821
+
822
+ parsedData.forEachIndexed { index, item ->
823
+ when (item) {
824
+ is AudioDataOrSilence.AudioFile -> {
825
+ fileCounts.getOrPut(item.filePath) { mutableListOf() }.add(index)
826
+ }
827
+ is AudioDataOrSilence.Silence -> {
828
+ val key = SilenceCacheKey(item.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
829
+ silenceCounts.getOrPut(key) { mutableListOf() }.add(index)
830
+ }
831
+ }
832
+ }
833
+
834
+ val duplicateFiles = fileCounts.filter { it.value.size > 1 }.keys.toSet()
835
+ val duplicateSilence = silenceCounts.filter { it.value.size > 1 }.keys.toSet()
836
+
837
+ // Detect interleaved patterns: file -> silence -> file -> silence -> file
838
+ val interleavedPatterns = mutableListOf<InterleavedPattern>()
839
+
840
+ var i = 0
841
+ while (i < parsedData.size - 2) {
842
+ if (parsedData[i] is AudioDataOrSilence.AudioFile &&
843
+ parsedData[i + 1] is AudioDataOrSilence.Silence &&
844
+ parsedData[i + 2] is AudioDataOrSilence.AudioFile) {
845
+
846
+ val file1 = (parsedData[i] as AudioDataOrSilence.AudioFile).filePath
847
+ val silence = parsedData[i + 1] as AudioDataOrSilence.Silence
848
+ val file2 = (parsedData[i + 2] as AudioDataOrSilence.AudioFile).filePath
849
+ val silenceKey = SilenceCacheKey(silence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
850
+
851
+ // Check if it's the same file with silence separator
852
+ if (file1 == file2) {
853
+ var count = 1
854
+ var currentIndex = i
855
+ val indices = mutableListOf(i)
856
+
857
+ // Count how many times this pattern repeats
858
+ while (currentIndex + 2 < parsedData.size &&
859
+ parsedData[currentIndex + 2] is AudioDataOrSilence.AudioFile &&
860
+ (parsedData[currentIndex + 2] as AudioDataOrSilence.AudioFile).filePath == file1) {
861
+
862
+ // Check if there's a silence in between
863
+ if (currentIndex + 3 < parsedData.size &&
864
+ parsedData[currentIndex + 3] is AudioDataOrSilence.Silence) {
865
+ val nextSilence = parsedData[currentIndex + 3] as AudioDataOrSilence.Silence
866
+ val nextSilenceKey = SilenceCacheKey(nextSilence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
867
+
868
+ if (nextSilenceKey == silenceKey) {
869
+ count++
870
+ currentIndex += 2
871
+ indices.add(currentIndex)
872
+ } else {
873
+ break
874
+ }
875
+ } else {
876
+ // Last file in the pattern (no silence after)
877
+ count++
878
+ indices.add(currentIndex + 2)
879
+ break
880
+ }
881
+ }
882
+
883
+ if (count >= 2) {
884
+ interleavedPatterns.add(InterleavedPattern(file1, silenceKey, indices, count))
885
+ Log.d("AudioConcat", "Detected interleaved pattern: '$file1' + ${silenceKey.durationMs}ms silence, repeats $count times")
886
+ i = currentIndex + 2 // Skip processed items
887
+ continue
888
+ }
889
+ }
890
+ }
891
+ i++
892
+ }
893
+
894
+ Log.d("AudioConcat", "Duplicate analysis: ${duplicateFiles.size} files, ${duplicateSilence.size} silence patterns, ${interleavedPatterns.size} interleaved patterns")
895
+ duplicateFiles.forEach { file ->
896
+ Log.d("AudioConcat", " File '$file' appears ${fileCounts[file]?.size} times")
897
+ }
898
+ duplicateSilence.forEach { key ->
899
+ Log.d("AudioConcat", " Silence ${key.durationMs}ms appears ${silenceCounts[key]?.size} times")
900
+ }
901
+
902
+ return DuplicateAnalysis(duplicateFiles, duplicateSilence, fileCounts, silenceCounts, interleavedPatterns)
903
+ }
904
+
905
+ private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
906
+ val result = mutableListOf<AudioDataOrSilence>()
907
+ for (i in 0 until data.size()) {
908
+ val item = data.getMap(i)
909
+ if (item != null) {
910
+ if (item.hasKey("filePath")) {
911
+ val filePath = item.getString("filePath")
912
+ if (filePath != null) {
913
+ result.add(AudioDataOrSilence.AudioFile(filePath))
914
+ }
915
+ } else if (item.hasKey("durationMs")) {
916
+ result.add(AudioDataOrSilence.Silence(item.getDouble("durationMs")))
917
+ }
918
+ }
919
+ }
920
+ return result
921
+ }
922
+
923
+ override fun getName(): String {
924
+ return NAME
925
+ }
926
+
927
+ override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
928
+ try {
929
+ if (data.size() == 0) {
930
+ promise.reject("EMPTY_DATA", "Data array is empty")
931
+ return
932
+ }
933
+
934
+ val parsedData = parseAudioData(data)
935
+ Log.d("AudioConcat", "Streaming merge of ${parsedData.size} items")
936
+ Log.d("AudioConcat", "Output: $outputPath")
937
+
938
+ // Get audio config from first audio file
939
+ var audioConfig: AudioConfig? = null
940
+ for (item in parsedData) {
941
+ if (item is AudioDataOrSilence.AudioFile) {
942
+ audioConfig = extractAudioConfig(item.filePath)
943
+ break
944
+ }
945
+ }
946
+
947
+ if (audioConfig == null) {
948
+ promise.reject("NO_AUDIO_FILES", "No audio files found in data array")
949
+ return
950
+ }
951
+
952
+ Log.d("AudioConcat", "Audio config: ${audioConfig.sampleRate}Hz, ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
953
+
954
+ // Analyze duplicates to determine cache strategy
955
+ val duplicateAnalysis = analyzeDuplicates(parsedData, audioConfig)
956
+
957
+ // Create cache instance with intelligent caching strategy
958
+ val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
959
+
960
+ // Delete existing output file
961
+ val outputFile = File(outputPath)
962
+ if (outputFile.exists()) {
963
+ outputFile.delete()
964
+ }
965
+
966
+ // Create streaming encoder
967
+ val encoder = StreamingEncoder(
968
+ audioConfig.sampleRate,
969
+ audioConfig.channelCount,
970
+ audioConfig.bitRate,
971
+ outputPath
972
+ )
973
+
974
+ try {
975
+ // Separate audio files and other items (silence)
976
+ val audioFileItems = mutableListOf<Pair<Int, String>>()
977
+ val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
978
+
979
+ for ((index, item) in parsedData.withIndex()) {
980
+ when (item) {
981
+ is AudioDataOrSilence.AudioFile -> {
982
+ audioFileItems.add(Pair(index, item.filePath))
983
+ }
984
+ is AudioDataOrSilence.Silence -> {
985
+ nonAudioItems.add(Pair(index, item))
986
+ }
987
+ }
988
+ }
989
+
990
+ // Decide whether to use parallel or sequential processing
991
+ val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
992
+
993
+ if (useParallel) {
994
+ Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
995
+
996
+ // Process interleaved patterns optimally
997
+ val processedIndices = mutableSetOf<Int>()
998
+
999
+ // First, handle all interleaved patterns
1000
+ duplicateAnalysis.interleavedPatterns.forEach { pattern ->
1001
+ Log.d("AudioConcat", "Processing interleaved pattern: ${pattern.filePath}, ${pattern.repeatCount} repetitions")
1002
+
1003
+ // Decode the file once
1004
+ val filePath = pattern.filePath
1005
+ val cachedData = cache.getAudioFile(filePath)
1006
+
1007
+ val pcmChunks = if (cachedData != null) {
1008
+ Log.d("AudioConcat", "Using cached PCM for interleaved pattern: $filePath")
1009
+ cachedData.chunks
1010
+ } else {
1011
+ // Decode once and store
1012
+ val chunks = mutableListOf<ByteArray>()
1013
+ val tempQueue = LinkedBlockingQueue<PCMChunk>(100)
1014
+ val latch = CountDownLatch(1)
1015
+ val seqStart = AtomicInteger(0)
1016
+
1017
+ parallelDecodeToQueue(filePath, tempQueue, seqStart, audioConfig.sampleRate, audioConfig.channelCount, latch, cache)
1018
+
1019
+ // Collect chunks
1020
+ var collecting = true
1021
+ while (collecting) {
1022
+ val chunk = tempQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
1023
+ if (chunk != null) {
1024
+ if (!chunk.isEndOfStream) {
1025
+ chunks.add(chunk.data)
1026
+ } else {
1027
+ collecting = false
1028
+ }
1029
+ } else if (latch.count == 0L) {
1030
+ collecting = false
1031
+ }
1032
+ }
1033
+
1034
+ latch.await()
1035
+ chunks
1036
+ }
1037
+
1038
+ // Get silence PCM
1039
+ val silencePCM = pattern.silenceKey?.let { cache.getSilence(it) }
1040
+ ?: pattern.silenceKey?.let {
1041
+ val totalSamples = ((it.durationMs / 1000.0) * it.sampleRate).toInt()
1042
+ val bytesPerSample = it.channelCount * 2
1043
+ ByteArray(totalSamples * bytesPerSample)
1044
+ }
1045
+
1046
+ // Encode the pattern: file -> silence -> file -> silence -> ...
1047
+ repeat(pattern.repeatCount) { iteration ->
1048
+ // Encode file
1049
+ pcmChunks.forEach { chunk ->
1050
+ encoder.encodePCMChunk(chunk, false)
1051
+ }
1052
+
1053
+ // Encode silence (except after the last file)
1054
+ if (iteration < pattern.repeatCount - 1 && silencePCM != null) {
1055
+ encoder.encodePCMChunk(silencePCM, false)
1056
+ }
1057
+ }
1058
+
1059
+ // Mark these indices as processed
1060
+ pattern.indices.forEach { idx ->
1061
+ processedIndices.add(idx)
1062
+ if (idx + 1 < parsedData.size && parsedData[idx + 1] is AudioDataOrSilence.Silence) {
1063
+ processedIndices.add(idx + 1)
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ // Then process remaining items normally
1069
+ var audioFileIdx = 0
1070
+ for ((index, item) in parsedData.withIndex()) {
1071
+ if (processedIndices.contains(index)) {
1072
+ if (item is AudioDataOrSilence.AudioFile) audioFileIdx++
1073
+ continue
1074
+ }
1075
+
1076
+ when (item) {
1077
+ is AudioDataOrSilence.AudioFile -> {
1078
+ // Collect consecutive audio files for parallel processing
1079
+ val consecutiveFiles = mutableListOf<Pair<Int, String>>()
1080
+ var currentIdx = audioFileIdx
1081
+
1082
+ while (currentIdx < audioFileItems.size) {
1083
+ val (itemIdx, filePath) = audioFileItems[currentIdx]
1084
+ if (processedIndices.contains(itemIdx)) {
1085
+ currentIdx++
1086
+ continue
1087
+ }
1088
+ if (itemIdx != index + (currentIdx - audioFileIdx)) break
1089
+ consecutiveFiles.add(Pair(itemIdx, filePath))
1090
+ currentIdx++
1091
+ }
1092
+
1093
+ if (consecutiveFiles.isNotEmpty()) {
1094
+ parallelProcessAudioFiles(
1095
+ consecutiveFiles,
1096
+ encoder,
1097
+ audioConfig.sampleRate,
1098
+ audioConfig.channelCount,
1099
+ cache,
1100
+ numThreads = 3
1101
+ )
1102
+ audioFileIdx = currentIdx
1103
+ }
1104
+ }
1105
+
1106
+ is AudioDataOrSilence.Silence -> {
1107
+ val durationMs = item.durationMs
1108
+ Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1109
+ streamEncodeSilence(
1110
+ durationMs,
1111
+ encoder,
1112
+ audioConfig.sampleRate,
1113
+ audioConfig.channelCount,
1114
+ cache
1115
+ )
1116
+ }
1117
+ }
1118
+ }
1119
+ } else {
1120
+ Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
1121
+
1122
+ // Process each item sequentially (original behavior)
1123
+ for ((index, item) in parsedData.withIndex()) {
1124
+ when (item) {
1125
+ is AudioDataOrSilence.AudioFile -> {
1126
+ val filePath = item.filePath
1127
+ Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1128
+
1129
+ val isLastFile = (index == parsedData.size - 1)
1130
+ streamDecodeAudioFile(
1131
+ filePath,
1132
+ encoder,
1133
+ isLastFile,
1134
+ audioConfig.sampleRate,
1135
+ audioConfig.channelCount
1136
+ )
1137
+ }
1138
+
1139
+ is AudioDataOrSilence.Silence -> {
1140
+ val durationMs = item.durationMs
1141
+ Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1142
+
1143
+ streamEncodeSilence(
1144
+ durationMs,
1145
+ encoder,
1146
+ audioConfig.sampleRate,
1147
+ audioConfig.channelCount,
1148
+ cache
1149
+ )
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ // Finish encoding
1156
+ encoder.finish()
1157
+
1158
+ // Log cache statistics
1159
+ Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1160
+
1161
+ Log.d("AudioConcat", "Successfully merged audio to $outputPath")
1162
+ promise.resolve(outputPath)
1163
+
1164
+ } catch (e: Exception) {
1165
+ Log.e("AudioConcat", "Error during streaming merge: ${e.message}", e)
1166
+ promise.reject("MERGE_ERROR", e.message, e)
1167
+ }
1168
+
1169
+ } catch (e: Exception) {
1170
+ Log.e("AudioConcat", "Error parsing data: ${e.message}", e)
1171
+ promise.reject("PARSE_ERROR", e.message, e)
1172
+ }
1173
+ }
1174
+
1175
+ companion object {
1176
+ const val NAME = "AudioConcat"
1177
+ }
1178
+ }