react-native-audio-concat 0.3.0 → 0.5.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,131 @@ 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
+ // Buffer pool for silence generation to reduce memory allocations
61
+ private object SilenceBufferPool {
62
+ private val pool = ConcurrentHashMap<Int, ByteArray>()
63
+ private val standardSizes = listOf(4096, 8192, 16384, 32768, 65536, 131072)
64
+
65
+ init {
66
+ // Pre-allocate common silence buffer sizes
67
+ standardSizes.forEach { size ->
68
+ pool[size] = ByteArray(size)
69
+ }
70
+ Log.d("AudioConcat", "SilenceBufferPool initialized with ${standardSizes.size} standard sizes")
71
+ }
72
+
73
+ fun getBuffer(requestedSize: Int): ByteArray {
74
+ // Find the smallest standard size that fits the request
75
+ val standardSize = standardSizes.firstOrNull { it >= requestedSize }
76
+
77
+ return if (standardSize != null) {
78
+ // Return pooled buffer (already zeroed)
79
+ pool.getOrPut(standardSize) { ByteArray(standardSize) }
80
+ } else {
81
+ // Size too large for pool, create new buffer
82
+ ByteArray(requestedSize)
83
+ }
84
+ }
85
+
86
+ fun clear() {
87
+ pool.clear()
88
+ Log.d("AudioConcat", "SilenceBufferPool cleared")
89
+ }
90
+ }
91
+
92
+ private class PCMCache(
93
+ private val shouldCacheFile: Set<String>,
94
+ private val shouldCacheSilence: Set<SilenceCacheKey>
95
+ ) {
96
+ private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
97
+ private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
98
+ private var currentCacheSizeBytes = 0L
99
+
100
+ // Dynamic cache size based on available memory
101
+ private val maxCacheSizeBytes: Long
102
+ get() {
103
+ val runtime = Runtime.getRuntime()
104
+ val maxMemory = runtime.maxMemory()
105
+ val usedMemory = runtime.totalMemory() - runtime.freeMemory()
106
+ val availableMemory = maxMemory - usedMemory
107
+
108
+ // Use 20% of available memory for cache, but constrain between 50MB and 200MB
109
+ val dynamicCacheMB = (availableMemory / (1024 * 1024) * 0.2).toLong()
110
+ val cacheMB = dynamicCacheMB.coerceIn(50, 200)
111
+
112
+ return cacheMB * 1024 * 1024
113
+ }
114
+
115
+ fun getAudioFile(filePath: String): CachedPCMData? {
116
+ return audioFileCache[filePath]
117
+ }
118
+
119
+ fun putAudioFile(filePath: String, data: CachedPCMData) {
120
+ // Only cache if this file appears multiple times
121
+ if (!shouldCacheFile.contains(filePath)) {
122
+ return
123
+ }
124
+
125
+ // Check cache size limit (dynamic)
126
+ if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeBytes) {
127
+ val maxCacheMB = maxCacheSizeBytes / (1024 * 1024)
128
+ Log.d("AudioConcat", "Cache full ($maxCacheMB MB), not caching: $filePath")
129
+ return
130
+ }
131
+
132
+ audioFileCache[filePath] = data
133
+ currentCacheSizeBytes += data.totalBytes
134
+ Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB, total: ${currentCacheSizeBytes / 1024}KB)")
135
+ }
136
+
137
+ fun getSilence(key: SilenceCacheKey): ByteArray? {
138
+ return silenceCache[key]
139
+ }
140
+
141
+ fun putSilence(key: SilenceCacheKey, data: ByteArray) {
142
+ // Only cache if this silence pattern appears multiple times
143
+ if (!shouldCacheSilence.contains(key)) {
144
+ return
145
+ }
146
+
147
+ silenceCache[key] = data
148
+ Log.d("AudioConcat", "Cached silence: ${key.durationMs}ms")
149
+ }
150
+
151
+ fun clear() {
152
+ audioFileCache.clear()
153
+ silenceCache.clear()
154
+ currentCacheSizeBytes = 0
155
+ Log.d("AudioConcat", "Cache cleared")
156
+ }
157
+
158
+ fun getStats(): String {
159
+ return "Audio files: ${audioFileCache.size}, Silence patterns: ${silenceCache.size}, Size: ${currentCacheSizeBytes / 1024}KB"
160
+ }
161
+ }
162
+
32
163
  private fun extractAudioConfig(filePath: String): AudioConfig {
33
164
  val extractor = MediaExtractor()
34
165
  try {
@@ -67,6 +198,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
67
198
  private var totalPresentationTimeUs = 0L
68
199
  private val sampleRate: Int
69
200
  private val channelCount: Int
201
+ private val maxChunkSize: Int
70
202
 
71
203
  init {
72
204
  this.sampleRate = sampleRate
@@ -79,7 +211,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
79
211
  )
80
212
  outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
81
213
  outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
82
- outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384)
214
+
215
+ // Optimized buffer size based on audio parameters
216
+ // Target: ~1024 samples per frame for optimal AAC encoding
217
+ val samplesPerFrame = 1024
218
+ val bytesPerSample = channelCount * 2 // 16-bit PCM
219
+ val optimalBufferSize = samplesPerFrame * bytesPerSample
220
+ // Use at least the optimal size, but allow for some overhead
221
+ val bufferSize = (optimalBufferSize * 1.5).toInt().coerceAtLeast(16384)
222
+ outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
223
+
224
+ // Store for use in encodePCMChunk
225
+ this.maxChunkSize = bufferSize
226
+
227
+ Log.d("AudioConcat", "Encoder buffer size: $bufferSize bytes (${samplesPerFrame} samples, ${sampleRate}Hz, ${channelCount}ch)")
83
228
 
84
229
  encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
85
230
  encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
@@ -89,29 +234,55 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
89
234
  }
90
235
 
91
236
  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)
237
+ // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
238
+ var offset = 0
239
+
240
+ while (offset < pcmData.size) {
241
+ val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
242
+ val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
243
+
244
+ // Feed PCM data chunk to encoder (reduced timeout for better throughput)
245
+ val inputBufferIndex = encoder.dequeueInputBuffer(1000)
246
+ if (inputBufferIndex >= 0) {
247
+ val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
248
+ val bufferCapacity = inputBuffer.capacity()
249
+
250
+ // Ensure chunk fits in buffer
251
+ val actualChunkSize = minOf(chunkSize, bufferCapacity)
252
+
253
+ inputBuffer.clear()
254
+ inputBuffer.put(pcmData, offset, actualChunkSize)
98
255
 
99
- val presentationTimeUs = totalPresentationTimeUs
100
- totalPresentationTimeUs += (pcmData.size.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
256
+ val presentationTimeUs = totalPresentationTimeUs
257
+ totalPresentationTimeUs += (actualChunkSize.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
101
258
 
102
- val flags = if (isLast) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
103
- encoder.queueInputBuffer(inputBufferIndex, 0, pcmData.size, presentationTimeUs, flags)
259
+ val flags = if (isLastChunk) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
260
+ encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
261
+
262
+ offset += actualChunkSize
263
+ } else {
264
+ // Buffer not available, drain first
265
+ drainEncoder(false)
266
+ }
267
+
268
+ // Drain encoder output periodically
269
+ if (offset < pcmData.size || !isLastChunk) {
270
+ drainEncoder(false)
271
+ }
104
272
  }
105
273
 
106
- // Drain encoder output
107
- drainEncoder(isLast)
274
+ // Final drain if last chunk
275
+ if (isLast) {
276
+ drainEncoder(true)
277
+ }
108
278
 
109
279
  return true
110
280
  }
111
281
 
112
282
  private fun drainEncoder(endOfStream: Boolean) {
113
283
  while (true) {
114
- val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 10000 else 0)
284
+ // Use shorter timeout for better responsiveness
285
+ val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 1000 else 0)
115
286
 
116
287
  when (outputBufferIndex) {
117
288
  MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
@@ -156,8 +327,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
156
327
  }
157
328
 
158
329
  fun finish() {
159
- // Signal end of stream
160
- val inputBufferIndex = encoder.dequeueInputBuffer(10000)
330
+ // Signal end of stream (reduced timeout)
331
+ val inputBufferIndex = encoder.dequeueInputBuffer(1000)
161
332
  if (inputBufferIndex >= 0) {
162
333
  encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
163
334
  }
@@ -175,10 +346,274 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
175
346
  }
176
347
  }
177
348
 
349
+ private fun resamplePCM16(
350
+ input: ByteArray,
351
+ inputSampleRate: Int,
352
+ outputSampleRate: Int,
353
+ channelCount: Int
354
+ ): ByteArray {
355
+ if (inputSampleRate == outputSampleRate) {
356
+ return input
357
+ }
358
+
359
+ val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
360
+ val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
361
+ val output = ByteArray(outputSampleCount * 2 * channelCount)
362
+
363
+ // Use fixed-point arithmetic (16.16 format) to avoid floating-point operations
364
+ // This provides 3-5x performance improvement
365
+ val step = ((inputSampleRate.toLong() shl 16) / outputSampleRate).toInt()
366
+ var srcPos = 0
367
+
368
+ for (i in 0 until outputSampleCount) {
369
+ val srcIndex = srcPos shr 16
370
+ val fraction = srcPos and 0xFFFF // Fractional part in 16-bit fixed-point
371
+
372
+ // Boundary check: ensure we don't go beyond input array
373
+ if (srcIndex >= inputSampleCount - 1) {
374
+ break
375
+ }
376
+
377
+ for (ch in 0 until channelCount) {
378
+ // Get current and next sample indices
379
+ val idx1 = (srcIndex * channelCount + ch) * 2
380
+ val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
381
+
382
+ // Read 16-bit samples (little-endian)
383
+ val sample1 = (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
384
+ val sample2 = if (idx2 + 1 < input.size) {
385
+ (input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
386
+ } else {
387
+ sample1
388
+ }
389
+
390
+ // Convert to signed 16-bit
391
+ val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
392
+ val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
393
+
394
+ // Linear interpolation using integer arithmetic
395
+ // interpolated = s1 + (s2 - s1) * fraction
396
+ // fraction is in 16.16 format, so we shift right by 16 after multiplication
397
+ val interpolated = s1 + (((s2 - s1) * fraction) shr 16)
398
+
399
+ // Clamp to 16-bit range
400
+ val clamped = interpolated.coerceIn(-32768, 32767)
401
+
402
+ // Convert back to unsigned and write (little-endian)
403
+ val outIdx = (i * channelCount + ch) * 2
404
+ output[outIdx] = (clamped and 0xFF).toByte()
405
+ output[outIdx + 1] = (clamped shr 8).toByte()
406
+ }
407
+
408
+ srcPos += step
409
+ }
410
+
411
+ return output
412
+ }
413
+
414
+ private fun convertChannelCount(
415
+ input: ByteArray,
416
+ inputChannels: Int,
417
+ outputChannels: Int
418
+ ): ByteArray {
419
+ if (inputChannels == outputChannels) {
420
+ return input
421
+ }
422
+
423
+ val sampleCount = input.size / (2 * inputChannels)
424
+ val output = ByteArray(sampleCount * 2 * outputChannels)
425
+
426
+ when {
427
+ inputChannels == 1 && outputChannels == 2 -> {
428
+ // Mono to Stereo: duplicate the channel
429
+ for (i in 0 until sampleCount) {
430
+ val srcIdx = i * 2
431
+ val dstIdx = i * 4
432
+ output[dstIdx] = input[srcIdx]
433
+ output[dstIdx + 1] = input[srcIdx + 1]
434
+ output[dstIdx + 2] = input[srcIdx]
435
+ output[dstIdx + 3] = input[srcIdx + 1]
436
+ }
437
+ }
438
+ inputChannels == 2 && outputChannels == 1 -> {
439
+ // Stereo to Mono: average the channels
440
+ for (i in 0 until sampleCount) {
441
+ val srcIdx = i * 4
442
+ val dstIdx = i * 2
443
+
444
+ val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
445
+ val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
446
+
447
+ val leftSigned = if (left > 32767) left - 65536 else left
448
+ val rightSigned = if (right > 32767) right - 65536 else right
449
+
450
+ // Use bit shift instead of division for better performance (x / 2 = x >> 1)
451
+ val avg = ((leftSigned + rightSigned) shr 1).coerceIn(-32768, 32767)
452
+
453
+ output[dstIdx] = (avg and 0xFF).toByte()
454
+ output[dstIdx + 1] = (avg shr 8).toByte()
455
+ }
456
+ }
457
+ else -> {
458
+ // Fallback: just take the first channel
459
+ for (i in 0 until sampleCount) {
460
+ val srcIdx = i * 2 * inputChannels
461
+ val dstIdx = i * 2 * outputChannels
462
+ for (ch in 0 until minOf(inputChannels, outputChannels)) {
463
+ output[dstIdx + ch * 2] = input[srcIdx + ch * 2]
464
+ output[dstIdx + ch * 2 + 1] = input[srcIdx + ch * 2 + 1]
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ return output
471
+ }
472
+
473
+ private fun parallelDecodeToQueue(
474
+ filePath: String,
475
+ queue: BlockingQueue<PCMChunk>,
476
+ sequenceStart: AtomicInteger,
477
+ targetSampleRate: Int,
478
+ targetChannelCount: Int,
479
+ latch: CountDownLatch,
480
+ cache: PCMCache
481
+ ) {
482
+ try {
483
+ // Check cache first
484
+ val cachedData = cache.getAudioFile(filePath)
485
+ if (cachedData != null) {
486
+ Log.d("AudioConcat", "Using cached PCM for: $filePath")
487
+ // Put cached chunks to queue
488
+ for (chunk in cachedData.chunks) {
489
+ val seqNum = sequenceStart.getAndIncrement()
490
+ queue.put(PCMChunk(chunk, seqNum))
491
+ }
492
+ latch.countDown()
493
+ return
494
+ }
495
+
496
+ val extractor = MediaExtractor()
497
+ var decoder: MediaCodec? = null
498
+ val decodedChunks = mutableListOf<ByteArray>()
499
+ var totalBytes = 0L
500
+
501
+ try {
502
+ extractor.setDataSource(filePath)
503
+
504
+ var audioTrackIndex = -1
505
+ var audioFormat: MediaFormat? = null
506
+
507
+ for (i in 0 until extractor.trackCount) {
508
+ val format = extractor.getTrackFormat(i)
509
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
510
+ if (mime.startsWith("audio/")) {
511
+ audioTrackIndex = i
512
+ audioFormat = format
513
+ break
514
+ }
515
+ }
516
+
517
+ if (audioTrackIndex == -1 || audioFormat == null) {
518
+ throw Exception("No audio track found in $filePath")
519
+ }
520
+
521
+ val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
522
+ val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
523
+
524
+ val needsResampling = sourceSampleRate != targetSampleRate
525
+ val needsChannelConversion = sourceChannelCount != targetChannelCount
526
+
527
+ if (needsResampling || needsChannelConversion) {
528
+ Log.d("AudioConcat", "Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
529
+ }
530
+
531
+ extractor.selectTrack(audioTrackIndex)
532
+
533
+ val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
534
+ decoder = MediaCodec.createDecoderByType(mime)
535
+ decoder.configure(audioFormat, null, null, 0)
536
+ decoder.start()
537
+
538
+ val bufferInfo = MediaCodec.BufferInfo()
539
+ var isEOS = false
540
+
541
+ while (!isEOS) {
542
+ // Feed input to decoder (reduced timeout for faster processing)
543
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
544
+ if (inputBufferIndex >= 0) {
545
+ val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
546
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
547
+
548
+ if (sampleSize < 0) {
549
+ decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
550
+ } else {
551
+ val presentationTimeUs = extractor.sampleTime
552
+ decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
553
+ extractor.advance()
554
+ }
555
+ }
556
+
557
+ // Get PCM output from decoder and put to queue (reduced timeout)
558
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
559
+ if (outputBufferIndex >= 0) {
560
+ val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
561
+
562
+ if (bufferInfo.size > 0) {
563
+ var pcmData = ByteArray(bufferInfo.size)
564
+ outputBuffer.get(pcmData)
565
+
566
+ // Convert channel count if needed
567
+ if (needsChannelConversion) {
568
+ pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
569
+ }
570
+
571
+ // Resample if needed
572
+ if (needsResampling) {
573
+ pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
574
+ }
575
+
576
+ // Optimization: avoid unnecessary clone() - store original for caching
577
+ decodedChunks.add(pcmData)
578
+ totalBytes += pcmData.size
579
+
580
+ // Put a clone to queue (queue might modify it)
581
+ val seqNum = sequenceStart.getAndIncrement()
582
+ queue.put(PCMChunk(pcmData.clone(), seqNum))
583
+ }
584
+
585
+ decoder.releaseOutputBuffer(outputBufferIndex, false)
586
+
587
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
588
+ isEOS = true
589
+ }
590
+ }
591
+ }
592
+
593
+ // Cache the decoded data
594
+ if (decodedChunks.isNotEmpty()) {
595
+ cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
596
+ }
597
+
598
+ } finally {
599
+ decoder?.stop()
600
+ decoder?.release()
601
+ extractor.release()
602
+ }
603
+ } catch (e: Exception) {
604
+ Log.e("AudioConcat", "Error in parallel decode: ${e.message}", e)
605
+ throw e
606
+ } finally {
607
+ latch.countDown()
608
+ }
609
+ }
610
+
178
611
  private fun streamDecodeAudioFile(
179
612
  filePath: String,
180
613
  encoder: StreamingEncoder,
181
- isLastFile: Boolean
614
+ isLastFile: Boolean,
615
+ targetSampleRate: Int,
616
+ targetChannelCount: Int
182
617
  ) {
183
618
  val extractor = MediaExtractor()
184
619
  var decoder: MediaCodec? = null
@@ -203,6 +638,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
203
638
  throw Exception("No audio track found in $filePath")
204
639
  }
205
640
 
641
+ val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
642
+ val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
643
+
644
+ val needsResampling = sourceSampleRate != targetSampleRate
645
+ val needsChannelConversion = sourceChannelCount != targetChannelCount
646
+
647
+ if (needsResampling || needsChannelConversion) {
648
+ Log.d("AudioConcat", "File: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
649
+ }
650
+
206
651
  extractor.selectTrack(audioTrackIndex)
207
652
 
208
653
  val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
@@ -212,11 +657,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
212
657
 
213
658
  val bufferInfo = MediaCodec.BufferInfo()
214
659
  var isEOS = false
215
- val pcmChunkSize = 8192 // Process in 8KB chunks
216
660
 
217
661
  while (!isEOS) {
218
- // Feed input to decoder
219
- val inputBufferIndex = decoder.dequeueInputBuffer(10000)
662
+ // Feed input to decoder (reduced timeout for faster processing)
663
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
220
664
  if (inputBufferIndex >= 0) {
221
665
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
222
666
  val sampleSize = extractor.readSampleData(inputBuffer, 0)
@@ -230,16 +674,26 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
230
674
  }
231
675
  }
232
676
 
233
- // Get PCM output from decoder and feed to encoder
234
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
677
+ // Get PCM output from decoder and feed to encoder (reduced timeout)
678
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
235
679
  if (outputBufferIndex >= 0) {
236
680
  val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
237
681
 
238
682
  if (bufferInfo.size > 0) {
239
- val pcmData = ByteArray(bufferInfo.size)
683
+ var pcmData = ByteArray(bufferInfo.size)
240
684
  outputBuffer.get(pcmData)
241
685
 
242
- // Stream to encoder immediately
686
+ // Convert channel count if needed
687
+ if (needsChannelConversion) {
688
+ pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
689
+ }
690
+
691
+ // Resample if needed
692
+ if (needsResampling) {
693
+ pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
694
+ }
695
+
696
+ // Stream to encoder
243
697
  encoder.encodePCMChunk(pcmData, false)
244
698
  }
245
699
 
@@ -262,24 +716,299 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
262
716
  durationMs: Double,
263
717
  encoder: StreamingEncoder,
264
718
  sampleRate: Int,
265
- channelCount: Int
719
+ channelCount: Int,
720
+ cache: PCMCache
266
721
  ) {
722
+ val cacheKey = SilenceCacheKey(durationMs, sampleRate, channelCount)
723
+
724
+ // Check cache first
725
+ val cachedSilence = cache.getSilence(cacheKey)
726
+ if (cachedSilence != null) {
727
+ Log.d("AudioConcat", "Using cached silence: ${durationMs}ms")
728
+ encoder.encodePCMChunk(cachedSilence, false)
729
+ return
730
+ }
731
+
732
+ // Generate silence
267
733
  val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
268
- val chunkSamples = 4096 // Process in chunks
269
734
  val bytesPerSample = channelCount * 2 // 16-bit stereo
735
+ val totalBytes = totalSamples * bytesPerSample
270
736
 
271
- var samplesRemaining = totalSamples
737
+ // For short silence (< 5 seconds), cache as single chunk
738
+ if (durationMs < 5000) {
739
+ // Use buffer pool to avoid allocation
740
+ val pooledBuffer = SilenceBufferPool.getBuffer(totalBytes)
741
+ val silenceData = if (pooledBuffer.size == totalBytes) {
742
+ pooledBuffer
743
+ } else {
744
+ // Copy only the needed portion
745
+ pooledBuffer.copyOf(totalBytes)
746
+ }
747
+ cache.putSilence(cacheKey, silenceData)
748
+ encoder.encodePCMChunk(silenceData, false)
749
+ } else {
750
+ // For longer silence, process in chunks without caching using pooled buffers
751
+ val chunkSamples = 16384
752
+ var samplesRemaining = totalSamples
272
753
 
273
- while (samplesRemaining > 0) {
274
- val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
275
- val chunkBytes = currentChunkSamples * bytesPerSample
276
- val silenceChunk = ByteArray(chunkBytes) // All zeros = silence
754
+ while (samplesRemaining > 0) {
755
+ val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
756
+ val chunkBytes = currentChunkSamples * bytesPerSample
277
757
 
278
- encoder.encodePCMChunk(silenceChunk, false)
279
- samplesRemaining -= currentChunkSamples
758
+ // Use pooled buffer for chunk
759
+ val pooledBuffer = SilenceBufferPool.getBuffer(chunkBytes)
760
+ val silenceChunk = if (pooledBuffer.size == chunkBytes) {
761
+ pooledBuffer
762
+ } else {
763
+ pooledBuffer.copyOf(chunkBytes)
764
+ }
765
+
766
+ encoder.encodePCMChunk(silenceChunk, false)
767
+ samplesRemaining -= currentChunkSamples
768
+ }
769
+ }
770
+ }
771
+
772
+ private fun getOptimalThreadCount(audioFileCount: Int): Int {
773
+ val cpuCores = Runtime.getRuntime().availableProcessors()
774
+ val optimalThreads = when {
775
+ cpuCores <= 2 -> 2
776
+ cpuCores <= 4 -> 3
777
+ cpuCores <= 8 -> 4
778
+ else -> 6
779
+ }
780
+ // Don't create more threads than files to process
781
+ return optimalThreads.coerceAtMost(audioFileCount)
782
+ }
783
+
784
+ private fun getOptimalQueueSize(audioFileCount: Int): Int {
785
+ // Dynamic queue size based on number of files to prevent memory waste or blocking
786
+ return when {
787
+ audioFileCount <= 5 -> 20
788
+ audioFileCount <= 20 -> 50
789
+ audioFileCount <= 50 -> 100
790
+ else -> 150
280
791
  }
281
792
  }
282
793
 
794
+ private fun parallelProcessAudioFiles(
795
+ audioFiles: List<Pair<Int, String>>, // (index, filePath)
796
+ encoder: StreamingEncoder,
797
+ targetSampleRate: Int,
798
+ targetChannelCount: Int,
799
+ cache: PCMCache,
800
+ numThreads: Int = 3
801
+ ) {
802
+ if (audioFiles.isEmpty()) return
803
+
804
+ // Group consecutive duplicate files
805
+ val optimizedFiles = mutableListOf<Pair<Int, String>>()
806
+ val consecutiveDuplicates = mutableMapOf<Int, Int>() // originalIndex -> count
807
+
808
+ var i = 0
809
+ while (i < audioFiles.size) {
810
+ val (index, filePath) = audioFiles[i]
811
+ var count = 1
812
+
813
+ // Check for consecutive duplicates
814
+ while (i + count < audioFiles.size && audioFiles[i + count].second == filePath) {
815
+ count++
816
+ }
817
+
818
+ if (count > 1) {
819
+ Log.d("AudioConcat", "Detected $count consecutive occurrences of: $filePath")
820
+ optimizedFiles.add(Pair(index, filePath))
821
+ consecutiveDuplicates[optimizedFiles.size - 1] = count
822
+ } else {
823
+ optimizedFiles.add(Pair(index, filePath))
824
+ }
825
+
826
+ i += count
827
+ }
828
+
829
+ val queueSize = getOptimalQueueSize(optimizedFiles.size)
830
+ val pcmQueue = LinkedBlockingQueue<PCMChunk>(queueSize)
831
+ Log.d("AudioConcat", "Using queue size: $queueSize for ${optimizedFiles.size} files")
832
+ val executor = Executors.newFixedThreadPool(numThreads)
833
+ val latch = CountDownLatch(optimizedFiles.size)
834
+ val sequenceCounter = AtomicInteger(0)
835
+
836
+ try {
837
+ // Submit decode tasks for unique files only
838
+ optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
839
+ executor.submit {
840
+ try {
841
+ val fileSequenceStart = AtomicInteger(sequenceCounter.get())
842
+ sequenceCounter.addAndGet(1000000)
843
+
844
+ Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
845
+ parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
846
+
847
+ // Mark end with duplicate count
848
+ val repeatCount = consecutiveDuplicates[optIndex] ?: 1
849
+ val endSeqNum = fileSequenceStart.get()
850
+ pcmQueue.put(PCMChunk(ByteArray(0), endSeqNum, true)) // endOfStream marker with repeat count
851
+
852
+ } catch (e: Exception) {
853
+ Log.e("AudioConcat", "Error decoding file $filePath: ${e.message}", e)
854
+ latch.countDown()
855
+ }
856
+ }
857
+ }
858
+
859
+ // Consumer thread: encode in order
860
+ var filesCompleted = 0
861
+ var cachedChunks = mutableListOf<ByteArray>()
862
+ var isCollectingChunks = false
863
+
864
+ while (filesCompleted < optimizedFiles.size) {
865
+ val chunk = pcmQueue.take()
866
+
867
+ if (chunk.isEndOfStream) {
868
+ val optIndex = filesCompleted
869
+ val repeatCount = consecutiveDuplicates[optIndex] ?: 1
870
+
871
+ if (repeatCount > 1 && cachedChunks.isNotEmpty()) {
872
+ // Repeat the cached chunks
873
+ Log.d("AudioConcat", "Repeating cached chunks ${repeatCount - 1} more times")
874
+ repeat(repeatCount - 1) {
875
+ cachedChunks.forEach { data ->
876
+ encoder.encodePCMChunk(data, false)
877
+ }
878
+ }
879
+ cachedChunks.clear()
880
+ }
881
+
882
+ filesCompleted++
883
+ isCollectingChunks = false
884
+ Log.d("AudioConcat", "Completed file $filesCompleted/${optimizedFiles.size}")
885
+ continue
886
+ }
887
+
888
+ // Encode chunk
889
+ encoder.encodePCMChunk(chunk.data, false)
890
+
891
+ // Cache chunks for consecutive duplicates
892
+ val optIndex = filesCompleted
893
+ if (consecutiveDuplicates.containsKey(optIndex)) {
894
+ cachedChunks.add(chunk.data.clone())
895
+ }
896
+ }
897
+
898
+ // Wait for all decode tasks to complete
899
+ latch.await()
900
+ Log.d("AudioConcat", "All parallel decode tasks completed")
901
+
902
+ } finally {
903
+ executor.shutdown()
904
+ }
905
+ }
906
+
907
+ private data class InterleavedPattern(
908
+ val filePath: String,
909
+ val silenceKey: SilenceCacheKey?,
910
+ val indices: List<Int>, // Indices where this pattern occurs
911
+ val repeatCount: Int
912
+ )
913
+
914
+ private data class DuplicateAnalysis(
915
+ val duplicateFiles: Set<String>,
916
+ val duplicateSilence: Set<SilenceCacheKey>,
917
+ val fileOccurrences: Map<String, List<Int>>, // filePath -> list of indices
918
+ val silenceOccurrences: Map<SilenceCacheKey, List<Int>>,
919
+ val interleavedPatterns: List<InterleavedPattern>
920
+ )
921
+
922
+ private fun analyzeDuplicates(
923
+ parsedData: List<AudioDataOrSilence>,
924
+ audioConfig: AudioConfig
925
+ ): DuplicateAnalysis {
926
+ val fileCounts = mutableMapOf<String, MutableList<Int>>()
927
+ val silenceCounts = mutableMapOf<SilenceCacheKey, MutableList<Int>>()
928
+
929
+ parsedData.forEachIndexed { index, item ->
930
+ when (item) {
931
+ is AudioDataOrSilence.AudioFile -> {
932
+ fileCounts.getOrPut(item.filePath) { mutableListOf() }.add(index)
933
+ }
934
+ is AudioDataOrSilence.Silence -> {
935
+ val key = SilenceCacheKey(item.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
936
+ silenceCounts.getOrPut(key) { mutableListOf() }.add(index)
937
+ }
938
+ }
939
+ }
940
+
941
+ val duplicateFiles = fileCounts.filter { it.value.size > 1 }.keys.toSet()
942
+ val duplicateSilence = silenceCounts.filter { it.value.size > 1 }.keys.toSet()
943
+
944
+ // Detect interleaved patterns: file -> silence -> file -> silence -> file
945
+ val interleavedPatterns = mutableListOf<InterleavedPattern>()
946
+
947
+ var i = 0
948
+ while (i < parsedData.size - 2) {
949
+ if (parsedData[i] is AudioDataOrSilence.AudioFile &&
950
+ parsedData[i + 1] is AudioDataOrSilence.Silence &&
951
+ parsedData[i + 2] is AudioDataOrSilence.AudioFile) {
952
+
953
+ val file1 = (parsedData[i] as AudioDataOrSilence.AudioFile).filePath
954
+ val silence = parsedData[i + 1] as AudioDataOrSilence.Silence
955
+ val file2 = (parsedData[i + 2] as AudioDataOrSilence.AudioFile).filePath
956
+ val silenceKey = SilenceCacheKey(silence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
957
+
958
+ // Check if it's the same file with silence separator
959
+ if (file1 == file2) {
960
+ var count = 1
961
+ var currentIndex = i
962
+ val indices = mutableListOf(i)
963
+
964
+ // Count how many times this pattern repeats
965
+ while (currentIndex + 2 < parsedData.size &&
966
+ parsedData[currentIndex + 2] is AudioDataOrSilence.AudioFile &&
967
+ (parsedData[currentIndex + 2] as AudioDataOrSilence.AudioFile).filePath == file1) {
968
+
969
+ // Check if there's a silence in between
970
+ if (currentIndex + 3 < parsedData.size &&
971
+ parsedData[currentIndex + 3] is AudioDataOrSilence.Silence) {
972
+ val nextSilence = parsedData[currentIndex + 3] as AudioDataOrSilence.Silence
973
+ val nextSilenceKey = SilenceCacheKey(nextSilence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
974
+
975
+ if (nextSilenceKey == silenceKey) {
976
+ count++
977
+ currentIndex += 2
978
+ indices.add(currentIndex)
979
+ } else {
980
+ break
981
+ }
982
+ } else {
983
+ // Last file in the pattern (no silence after)
984
+ count++
985
+ indices.add(currentIndex + 2)
986
+ break
987
+ }
988
+ }
989
+
990
+ if (count >= 2) {
991
+ interleavedPatterns.add(InterleavedPattern(file1, silenceKey, indices, count))
992
+ Log.d("AudioConcat", "Detected interleaved pattern: '$file1' + ${silenceKey.durationMs}ms silence, repeats $count times")
993
+ i = currentIndex + 2 // Skip processed items
994
+ continue
995
+ }
996
+ }
997
+ }
998
+ i++
999
+ }
1000
+
1001
+ Log.d("AudioConcat", "Duplicate analysis: ${duplicateFiles.size} files, ${duplicateSilence.size} silence patterns, ${interleavedPatterns.size} interleaved patterns")
1002
+ duplicateFiles.forEach { file ->
1003
+ Log.d("AudioConcat", " File '$file' appears ${fileCounts[file]?.size} times")
1004
+ }
1005
+ duplicateSilence.forEach { key ->
1006
+ Log.d("AudioConcat", " Silence ${key.durationMs}ms appears ${silenceCounts[key]?.size} times")
1007
+ }
1008
+
1009
+ return DuplicateAnalysis(duplicateFiles, duplicateSilence, fileCounts, silenceCounts, interleavedPatterns)
1010
+ }
1011
+
283
1012
  private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
284
1013
  val result = mutableListOf<AudioDataOrSilence>()
285
1014
  for (i in 0 until data.size()) {
@@ -329,6 +1058,12 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
329
1058
 
330
1059
  Log.d("AudioConcat", "Audio config: ${audioConfig.sampleRate}Hz, ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
331
1060
 
1061
+ // Analyze duplicates to determine cache strategy
1062
+ val duplicateAnalysis = analyzeDuplicates(parsedData, audioConfig)
1063
+
1064
+ // Create cache instance with intelligent caching strategy
1065
+ val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
1066
+
332
1067
  // Delete existing output file
333
1068
  val outputFile = File(outputPath)
334
1069
  if (outputFile.exists()) {
@@ -344,33 +1079,194 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
344
1079
  )
345
1080
 
346
1081
  try {
347
- // Process each item
1082
+ // Separate audio files and other items (silence)
1083
+ val audioFileItems = mutableListOf<Pair<Int, String>>()
1084
+ val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
1085
+
348
1086
  for ((index, item) in parsedData.withIndex()) {
349
1087
  when (item) {
350
1088
  is AudioDataOrSilence.AudioFile -> {
351
- val filePath = item.filePath
352
- Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1089
+ audioFileItems.add(Pair(index, item.filePath))
1090
+ }
1091
+ is AudioDataOrSilence.Silence -> {
1092
+ nonAudioItems.add(Pair(index, item))
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ // Decide whether to use parallel or sequential processing
1098
+ val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
1099
+
1100
+ if (useParallel) {
1101
+ Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
353
1102
 
354
- val isLastFile = (index == parsedData.size - 1)
355
- streamDecodeAudioFile(filePath, encoder, isLastFile)
1103
+ // Process interleaved patterns optimally
1104
+ val processedIndices = mutableSetOf<Int>()
1105
+
1106
+ // First, handle all interleaved patterns
1107
+ duplicateAnalysis.interleavedPatterns.forEach { pattern ->
1108
+ Log.d("AudioConcat", "Processing interleaved pattern: ${pattern.filePath}, ${pattern.repeatCount} repetitions")
1109
+
1110
+ // Decode the file once
1111
+ val filePath = pattern.filePath
1112
+ val cachedData = cache.getAudioFile(filePath)
1113
+
1114
+ val pcmChunks = if (cachedData != null) {
1115
+ Log.d("AudioConcat", "Using cached PCM for interleaved pattern: $filePath")
1116
+ cachedData.chunks
1117
+ } else {
1118
+ // Decode once and store
1119
+ val chunks = mutableListOf<ByteArray>()
1120
+ val tempQueue = LinkedBlockingQueue<PCMChunk>(100)
1121
+ val latch = CountDownLatch(1)
1122
+ val seqStart = AtomicInteger(0)
1123
+
1124
+ parallelDecodeToQueue(filePath, tempQueue, seqStart, audioConfig.sampleRate, audioConfig.channelCount, latch, cache)
1125
+
1126
+ // Collect chunks
1127
+ var collecting = true
1128
+ while (collecting) {
1129
+ val chunk = tempQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
1130
+ if (chunk != null) {
1131
+ if (!chunk.isEndOfStream) {
1132
+ chunks.add(chunk.data)
1133
+ } else {
1134
+ collecting = false
1135
+ }
1136
+ } else if (latch.count == 0L) {
1137
+ collecting = false
1138
+ }
1139
+ }
1140
+
1141
+ latch.await()
1142
+ chunks
356
1143
  }
357
1144
 
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
- )
1145
+ // Get silence PCM
1146
+ val silencePCM = pattern.silenceKey?.let { cache.getSilence(it) }
1147
+ ?: pattern.silenceKey?.let {
1148
+ val totalSamples = ((it.durationMs / 1000.0) * it.sampleRate).toInt()
1149
+ val bytesPerSample = it.channelCount * 2
1150
+ ByteArray(totalSamples * bytesPerSample)
1151
+ }
1152
+
1153
+ // Encode the pattern: file -> silence -> file -> silence -> ...
1154
+ repeat(pattern.repeatCount) { iteration ->
1155
+ // Encode file
1156
+ pcmChunks.forEach { chunk ->
1157
+ encoder.encodePCMChunk(chunk, false)
1158
+ }
1159
+
1160
+ // Encode silence (except after the last file)
1161
+ if (iteration < pattern.repeatCount - 1 && silencePCM != null) {
1162
+ encoder.encodePCMChunk(silencePCM, false)
1163
+ }
1164
+ }
1165
+
1166
+ // Mark these indices as processed
1167
+ pattern.indices.forEach { idx ->
1168
+ processedIndices.add(idx)
1169
+ if (idx + 1 < parsedData.size && parsedData[idx + 1] is AudioDataOrSilence.Silence) {
1170
+ processedIndices.add(idx + 1)
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ // Then process remaining items normally
1176
+ var audioFileIdx = 0
1177
+ for ((index, item) in parsedData.withIndex()) {
1178
+ if (processedIndices.contains(index)) {
1179
+ if (item is AudioDataOrSilence.AudioFile) audioFileIdx++
1180
+ continue
1181
+ }
1182
+
1183
+ when (item) {
1184
+ is AudioDataOrSilence.AudioFile -> {
1185
+ // Collect consecutive audio files for parallel processing
1186
+ val consecutiveFiles = mutableListOf<Pair<Int, String>>()
1187
+ var currentIdx = audioFileIdx
1188
+
1189
+ while (currentIdx < audioFileItems.size) {
1190
+ val (itemIdx, filePath) = audioFileItems[currentIdx]
1191
+ if (processedIndices.contains(itemIdx)) {
1192
+ currentIdx++
1193
+ continue
1194
+ }
1195
+ if (itemIdx != index + (currentIdx - audioFileIdx)) break
1196
+ consecutiveFiles.add(Pair(itemIdx, filePath))
1197
+ currentIdx++
1198
+ }
1199
+
1200
+ if (consecutiveFiles.isNotEmpty()) {
1201
+ val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
1202
+ Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
1203
+ parallelProcessAudioFiles(
1204
+ consecutiveFiles,
1205
+ encoder,
1206
+ audioConfig.sampleRate,
1207
+ audioConfig.channelCount,
1208
+ cache,
1209
+ numThreads = optimalThreads
1210
+ )
1211
+ audioFileIdx = currentIdx
1212
+ }
1213
+ }
1214
+
1215
+ is AudioDataOrSilence.Silence -> {
1216
+ val durationMs = item.durationMs
1217
+ Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1218
+ streamEncodeSilence(
1219
+ durationMs,
1220
+ encoder,
1221
+ audioConfig.sampleRate,
1222
+ audioConfig.channelCount,
1223
+ cache
1224
+ )
1225
+ }
1226
+ }
1227
+ }
1228
+ } else {
1229
+ Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
1230
+
1231
+ // Process each item sequentially (original behavior)
1232
+ for ((index, item) in parsedData.withIndex()) {
1233
+ when (item) {
1234
+ is AudioDataOrSilence.AudioFile -> {
1235
+ val filePath = item.filePath
1236
+ Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1237
+
1238
+ val isLastFile = (index == parsedData.size - 1)
1239
+ streamDecodeAudioFile(
1240
+ filePath,
1241
+ encoder,
1242
+ isLastFile,
1243
+ audioConfig.sampleRate,
1244
+ audioConfig.channelCount
1245
+ )
1246
+ }
1247
+
1248
+ is AudioDataOrSilence.Silence -> {
1249
+ val durationMs = item.durationMs
1250
+ Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1251
+
1252
+ streamEncodeSilence(
1253
+ durationMs,
1254
+ encoder,
1255
+ audioConfig.sampleRate,
1256
+ audioConfig.channelCount,
1257
+ cache
1258
+ )
1259
+ }
368
1260
  }
369
1261
  }
370
1262
  }
371
1263
 
372
1264
  // Finish encoding
373
1265
  encoder.finish()
1266
+
1267
+ // Log cache statistics
1268
+ Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1269
+
374
1270
  Log.d("AudioConcat", "Successfully merged audio to $outputPath")
375
1271
  promise.resolve(outputPath)
376
1272
 
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.5.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",