react-native-audio-concat 0.3.0 → 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.
@@ -13,6 +13,12 @@ import android.media.MediaMuxer
13
13
  import java.io.File
14
14
  import java.nio.ByteBuffer
15
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
16
22
 
17
23
  @ReactModule(name = AudioConcatModule.NAME)
18
24
  class AudioConcatModule(reactContext: ReactApplicationContext) :
@@ -29,6 +35,84 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
29
35
  data class Silence(val durationMs: Double) : AudioDataOrSilence()
30
36
  }
31
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
+
32
116
  private fun extractAudioConfig(filePath: String): AudioConfig {
33
117
  val extractor = MediaExtractor()
34
118
  try {
@@ -79,7 +163,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
79
163
  )
80
164
  outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
81
165
  outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
82
- outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384)
166
+ outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 65536) // Increased from 16384
83
167
 
84
168
  encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
85
169
  encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
@@ -89,22 +173,48 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
89
173
  }
90
174
 
91
175
  fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
92
- // Feed PCM data to encoder
93
- val inputBufferIndex = encoder.dequeueInputBuffer(10000)
94
- if (inputBufferIndex >= 0) {
95
- val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
96
- inputBuffer.clear()
97
- inputBuffer.put(pcmData)
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()
98
189
 
99
- val presentationTimeUs = totalPresentationTimeUs
100
- totalPresentationTimeUs += (pcmData.size.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
190
+ // Ensure chunk fits in buffer
191
+ val actualChunkSize = minOf(chunkSize, bufferCapacity)
101
192
 
102
- val flags = if (isLast) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
103
- encoder.queueInputBuffer(inputBufferIndex, 0, pcmData.size, presentationTimeUs, flags)
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
+ }
104
212
  }
105
213
 
106
- // Drain encoder output
107
- drainEncoder(isLast)
214
+ // Final drain if last chunk
215
+ if (isLast) {
216
+ drainEncoder(true)
217
+ }
108
218
 
109
219
  return true
110
220
  }
@@ -175,10 +285,266 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
175
285
  }
176
286
  }
177
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
+
178
542
  private fun streamDecodeAudioFile(
179
543
  filePath: String,
180
544
  encoder: StreamingEncoder,
181
- isLastFile: Boolean
545
+ isLastFile: Boolean,
546
+ targetSampleRate: Int,
547
+ targetChannelCount: Int
182
548
  ) {
183
549
  val extractor = MediaExtractor()
184
550
  var decoder: MediaCodec? = null
@@ -203,6 +569,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
203
569
  throw Exception("No audio track found in $filePath")
204
570
  }
205
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
+
206
582
  extractor.selectTrack(audioTrackIndex)
207
583
 
208
584
  val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
@@ -212,7 +588,6 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
212
588
 
213
589
  val bufferInfo = MediaCodec.BufferInfo()
214
590
  var isEOS = false
215
- val pcmChunkSize = 8192 // Process in 8KB chunks
216
591
 
217
592
  while (!isEOS) {
218
593
  // Feed input to decoder
@@ -236,10 +611,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
236
611
  val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
237
612
 
238
613
  if (bufferInfo.size > 0) {
239
- val pcmData = ByteArray(bufferInfo.size)
614
+ var pcmData = ByteArray(bufferInfo.size)
240
615
  outputBuffer.get(pcmData)
241
616
 
242
- // Stream to encoder immediately
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
243
628
  encoder.encodePCMChunk(pcmData, false)
244
629
  }
245
630
 
@@ -262,24 +647,261 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
262
647
  durationMs: Double,
263
648
  encoder: StreamingEncoder,
264
649
  sampleRate: Int,
265
- channelCount: Int
650
+ channelCount: Int,
651
+ cache: PCMCache
266
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
267
664
  val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
268
- val chunkSamples = 4096 // Process in chunks
269
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)
270
736
 
271
- var samplesRemaining = totalSamples
737
+ Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
738
+ parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
272
739
 
273
- while (samplesRemaining > 0) {
274
- val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
275
- val chunkBytes = currentChunkSamples * bytesPerSample
276
- val silenceChunk = ByteArray(chunkBytes) // All zeros = silence
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
277
744
 
278
- encoder.encodePCMChunk(silenceChunk, false)
279
- samplesRemaining -= currentChunkSamples
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()
280
797
  }
281
798
  }
282
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
+
283
905
  private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
284
906
  val result = mutableListOf<AudioDataOrSilence>()
285
907
  for (i in 0 until data.size()) {
@@ -329,6 +951,12 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
329
951
 
330
952
  Log.d("AudioConcat", "Audio config: ${audioConfig.sampleRate}Hz, ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
331
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
+
332
960
  // Delete existing output file
333
961
  val outputFile = File(outputPath)
334
962
  if (outputFile.exists()) {
@@ -344,33 +972,192 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
344
972
  )
345
973
 
346
974
  try {
347
- // Process each item
975
+ // Separate audio files and other items (silence)
976
+ val audioFileItems = mutableListOf<Pair<Int, String>>()
977
+ val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
978
+
348
979
  for ((index, item) in parsedData.withIndex()) {
349
980
  when (item) {
350
981
  is AudioDataOrSilence.AudioFile -> {
351
- val filePath = item.filePath
352
- Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
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)
353
1016
 
354
- val isLastFile = (index == parsedData.size - 1)
355
- streamDecodeAudioFile(filePath, encoder, isLastFile)
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
356
1036
  }
357
1037
 
358
- is AudioDataOrSilence.Silence -> {
359
- val durationMs = item.durationMs
360
- Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
361
-
362
- streamEncodeSilence(
363
- durationMs,
364
- encoder,
365
- audioConfig.sampleRate,
366
- audioConfig.channelCount
367
- )
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
+ }
368
1151
  }
369
1152
  }
370
1153
  }
371
1154
 
372
1155
  // Finish encoding
373
1156
  encoder.finish()
1157
+
1158
+ // Log cache statistics
1159
+ Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1160
+
374
1161
  Log.d("AudioConcat", "Successfully merged audio to $outputPath")
375
1162
  promise.resolve(outputPath)
376
1163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-concat",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "audio-concat for react-native",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",