react-native-audio-concat 0.6.0 → 0.7.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.
@@ -7,6 +7,7 @@ import com.facebook.react.bridge.ReadableMap
7
7
  import com.facebook.react.module.annotations.ReactModule
8
8
  import android.media.MediaCodec
9
9
  import android.media.MediaCodecInfo
10
+ import android.media.MediaCodecList
10
11
  import android.media.MediaExtractor
11
12
  import android.media.MediaFormat
12
13
  import android.media.MediaMuxer
@@ -165,6 +166,59 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
165
166
  private var decoder: MediaCodec? = null
166
167
  private var currentMimeType: String? = null
167
168
  private var currentFormat: MediaFormat? = null
169
+ private var isHardwareDecoder: Boolean = false
170
+
171
+ /**
172
+ * Try to create a hardware decoder for better performance
173
+ * Hardware decoders are typically 2-10x faster than software decoders
174
+ */
175
+ private fun createHardwareDecoder(mimeType: String, format: MediaFormat): MediaCodec? {
176
+ try {
177
+ val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
178
+
179
+ for (codecInfo in codecList.codecInfos) {
180
+ // Skip encoders
181
+ if (codecInfo.isEncoder) continue
182
+
183
+ // Check if this codec supports our mime type
184
+ if (!codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
185
+ continue
186
+ }
187
+
188
+ // Hardware decoder identification by vendor prefix
189
+ val isHardware = codecInfo.name.let { name ->
190
+ name.startsWith("OMX.qcom") || // Qualcomm (most common)
191
+ name.startsWith("OMX.MTK") || // MediaTek
192
+ name.startsWith("OMX.Exynos") || // Samsung Exynos
193
+ name.startsWith("OMX.SEC") || // Samsung
194
+ name.startsWith("OMX.hisi") || // Huawei HiSilicon
195
+ name.startsWith("c2.qti") || // Qualcomm C2
196
+ name.startsWith("c2.mtk") || // MediaTek C2
197
+ name.startsWith("c2.exynos") || // Samsung C2
198
+ (name.contains("hardware", ignoreCase = true) &&
199
+ !name.contains("google", ignoreCase = true))
200
+ }
201
+
202
+ if (isHardware) {
203
+ try {
204
+ val codec = MediaCodec.createByCodecName(codecInfo.name)
205
+ codec.configure(format, null, null, 0)
206
+ codec.start()
207
+
208
+ Log.d("AudioConcat", " ✓ Created HARDWARE decoder: ${codecInfo.name}")
209
+ return codec
210
+ } catch (e: Exception) {
211
+ Log.w("AudioConcat", " ✗ HW decoder ${codecInfo.name} failed: ${e.message}")
212
+ // Continue to try next hardware decoder
213
+ }
214
+ }
215
+ }
216
+ } catch (e: Exception) {
217
+ Log.w("AudioConcat", " Hardware decoder search failed: ${e.message}")
218
+ }
219
+
220
+ return null
221
+ }
168
222
 
169
223
  fun getOrCreateDecoder(mimeType: String, format: MediaFormat): MediaCodec {
170
224
  // Check if we can reuse the existing decoder
@@ -172,7 +226,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
172
226
  // Flush the decoder to reset its state
173
227
  try {
174
228
  decoder!!.flush()
175
- Log.d("AudioConcat", " Reused decoder for $mimeType")
229
+ val type = if (isHardwareDecoder) "HW" else "SW"
230
+ Log.d("AudioConcat", " ↻ Reused $type decoder for $mimeType")
176
231
  return decoder!!
177
232
  } catch (e: Exception) {
178
233
  Log.w("AudioConcat", "Failed to flush decoder, recreating: ${e.message}")
@@ -183,15 +238,23 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
183
238
  // Need to create a new decoder
184
239
  release() // Release old one if exists
185
240
 
186
- val newDecoder = MediaCodec.createDecoderByType(mimeType)
187
- newDecoder.configure(format, null, null, 0)
188
- newDecoder.start()
241
+ // Try hardware decoder first (2-10x faster)
242
+ var newDecoder = createHardwareDecoder(mimeType, format)
243
+ isHardwareDecoder = (newDecoder != null)
244
+
245
+ // Fallback to software decoder
246
+ if (newDecoder == null) {
247
+ newDecoder = MediaCodec.createDecoderByType(mimeType)
248
+ newDecoder.configure(format, null, null, 0)
249
+ newDecoder.start()
250
+ Log.d("AudioConcat", " ⚠ Created SOFTWARE decoder for $mimeType (no HW available)")
251
+ isHardwareDecoder = false
252
+ }
189
253
 
190
254
  decoder = newDecoder
191
255
  currentMimeType = mimeType
192
256
  currentFormat = format
193
257
 
194
- Log.d("AudioConcat", " Created new decoder for $mimeType")
195
258
  return newDecoder
196
259
  }
197
260
 
@@ -281,6 +344,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
281
344
  private val channelCount: Int
282
345
  private val maxChunkSize: Int
283
346
 
347
+ // Performance tracking
348
+ private var totalBufferWaitTimeMs = 0L
349
+ private var bufferWaitCount = 0
350
+
284
351
  init {
285
352
  this.sampleRate = sampleRate
286
353
  this.channelCount = channelCount
@@ -298,8 +365,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
298
365
  val samplesPerFrame = 1024
299
366
  val bytesPerSample = channelCount * 2 // 16-bit PCM
300
367
  val optimalBufferSize = samplesPerFrame * bytesPerSample
301
- // Use at least the optimal size, but allow for some overhead
302
- val bufferSize = (optimalBufferSize * 1.5).toInt().coerceAtLeast(16384)
368
+ // OPTIMIZATION: Increased buffer size for better throughput
369
+ // Larger buffers reduce dequeue operations and improve encoder efficiency
370
+ val bufferSize = (optimalBufferSize * 4.0).toInt().coerceAtLeast(65536)
303
371
  outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
304
372
 
305
373
  // Store for use in encodePCMChunk
@@ -317,14 +385,23 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
317
385
  fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
318
386
  // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
319
387
  var offset = 0
388
+ var buffersQueued = 0 // Track queued buffers for batch draining
320
389
 
321
390
  while (offset < pcmData.size) {
322
391
  val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
323
392
  val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
324
393
 
325
394
  // Feed PCM data chunk to encoder (reduced timeout for better throughput)
395
+ val bufferWaitStart = System.currentTimeMillis()
326
396
  val inputBufferIndex = encoder.dequeueInputBuffer(1000)
397
+ val bufferWaitTime = System.currentTimeMillis() - bufferWaitStart
398
+
327
399
  if (inputBufferIndex >= 0) {
400
+ if (bufferWaitTime > 5) {
401
+ totalBufferWaitTimeMs += bufferWaitTime
402
+ bufferWaitCount++
403
+ }
404
+
328
405
  val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
329
406
  val bufferCapacity = inputBuffer.capacity()
330
407
 
@@ -341,14 +418,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
341
418
  encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
342
419
 
343
420
  offset += actualChunkSize
421
+ buffersQueued++
344
422
  } else {
423
+ totalBufferWaitTimeMs += bufferWaitTime
424
+ bufferWaitCount++
345
425
  // Buffer not available, drain first
346
426
  drainEncoder(false)
427
+ buffersQueued = 0 // Reset counter after forced drain
347
428
  }
348
429
 
349
- // Drain encoder output periodically
350
- if (offset < pcmData.size || !isLastChunk) {
430
+ // OPTIMIZATION: Batch drain - only drain every 4 buffers instead of every buffer
431
+ // This reduces overhead while keeping encoder pipeline flowing
432
+ if (buffersQueued >= 4 || isLastChunk || offset >= pcmData.size) {
351
433
  drainEncoder(false)
434
+ buffersQueued = 0
352
435
  }
353
436
  }
354
437
 
@@ -407,6 +490,14 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
407
490
  }
408
491
  }
409
492
 
493
+ fun getEncoderStats(): String {
494
+ val avgWaitTime = if (bufferWaitCount > 0) {
495
+ String.format("%.2f", totalBufferWaitTimeMs.toFloat() / bufferWaitCount)
496
+ } else "0.00"
497
+
498
+ return "Buffer waits: $bufferWaitCount, Total wait: ${totalBufferWaitTimeMs}ms, Avg: ${avgWaitTime}ms"
499
+ }
500
+
410
501
  fun finish() {
411
502
  // Signal end of stream (reduced timeout)
412
503
  val inputBufferIndex = encoder.dequeueInputBuffer(1000)
@@ -417,6 +508,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
417
508
  // Drain remaining data
418
509
  drainEncoder(true)
419
510
 
511
+ // Log encoder performance stats
512
+ Log.d("AudioConcat", "Encoder stats: ${getEncoderStats()}")
513
+
420
514
  encoder.stop()
421
515
  encoder.release()
422
516
 
@@ -496,33 +590,107 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
496
590
 
497
591
  when {
498
592
  inputChannels == 1 && outputChannels == 2 -> {
499
- // Mono to Stereo: duplicate the channel
500
- for (i in 0 until sampleCount) {
593
+ // OPTIMIZED: Mono to Stereo using batch copy with unrolled loop
594
+ // Process 4 samples at a time for better cache locality
595
+ val batchSize = 4
596
+ val fullBatches = sampleCount / batchSize
597
+ var i = 0
598
+
599
+ // Process batches of 4 samples
600
+ for (batch in 0 until fullBatches) {
601
+ val baseIdx = i * 2
602
+ val baseDst = i * 4
603
+
604
+ // Sample 1
605
+ output[baseDst] = input[baseIdx]
606
+ output[baseDst + 1] = input[baseIdx + 1]
607
+ output[baseDst + 2] = input[baseIdx]
608
+ output[baseDst + 3] = input[baseIdx + 1]
609
+
610
+ // Sample 2
611
+ output[baseDst + 4] = input[baseIdx + 2]
612
+ output[baseDst + 5] = input[baseIdx + 3]
613
+ output[baseDst + 6] = input[baseIdx + 2]
614
+ output[baseDst + 7] = input[baseIdx + 3]
615
+
616
+ // Sample 3
617
+ output[baseDst + 8] = input[baseIdx + 4]
618
+ output[baseDst + 9] = input[baseIdx + 5]
619
+ output[baseDst + 10] = input[baseIdx + 4]
620
+ output[baseDst + 11] = input[baseIdx + 5]
621
+
622
+ // Sample 4
623
+ output[baseDst + 12] = input[baseIdx + 6]
624
+ output[baseDst + 13] = input[baseIdx + 7]
625
+ output[baseDst + 14] = input[baseIdx + 6]
626
+ output[baseDst + 15] = input[baseIdx + 7]
627
+
628
+ i += batchSize
629
+ }
630
+
631
+ // Process remaining samples
632
+ while (i < sampleCount) {
501
633
  val srcIdx = i * 2
502
634
  val dstIdx = i * 4
503
635
  output[dstIdx] = input[srcIdx]
504
636
  output[dstIdx + 1] = input[srcIdx + 1]
505
637
  output[dstIdx + 2] = input[srcIdx]
506
638
  output[dstIdx + 3] = input[srcIdx + 1]
639
+ i++
507
640
  }
508
641
  }
509
642
  inputChannels == 2 && outputChannels == 1 -> {
510
- // Stereo to Mono: average the channels
511
- for (i in 0 until sampleCount) {
643
+ // OPTIMIZED: Stereo to Mono with unrolled loop
644
+ val batchSize = 4
645
+ val fullBatches = sampleCount / batchSize
646
+ var i = 0
647
+
648
+ // Process batches of 4 samples
649
+ for (batch in 0 until fullBatches) {
650
+ val baseSrc = i * 4
651
+ val baseDst = i * 2
652
+
653
+ // Sample 1
654
+ var left = (input[baseSrc].toInt() and 0xFF) or (input[baseSrc + 1].toInt() shl 8)
655
+ var right = (input[baseSrc + 2].toInt() and 0xFF) or (input[baseSrc + 3].toInt() shl 8)
656
+ var avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
657
+ output[baseDst] = (avg and 0xFF).toByte()
658
+ output[baseDst + 1] = (avg shr 8).toByte()
659
+
660
+ // Sample 2
661
+ left = (input[baseSrc + 4].toInt() and 0xFF) or (input[baseSrc + 5].toInt() shl 8)
662
+ right = (input[baseSrc + 6].toInt() and 0xFF) or (input[baseSrc + 7].toInt() shl 8)
663
+ avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
664
+ output[baseDst + 2] = (avg and 0xFF).toByte()
665
+ output[baseDst + 3] = (avg shr 8).toByte()
666
+
667
+ // Sample 3
668
+ left = (input[baseSrc + 8].toInt() and 0xFF) or (input[baseSrc + 9].toInt() shl 8)
669
+ right = (input[baseSrc + 10].toInt() and 0xFF) or (input[baseSrc + 11].toInt() shl 8)
670
+ avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
671
+ output[baseDst + 4] = (avg and 0xFF).toByte()
672
+ output[baseDst + 5] = (avg shr 8).toByte()
673
+
674
+ // Sample 4
675
+ left = (input[baseSrc + 12].toInt() and 0xFF) or (input[baseSrc + 13].toInt() shl 8)
676
+ right = (input[baseSrc + 14].toInt() and 0xFF) or (input[baseSrc + 15].toInt() shl 8)
677
+ avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
678
+ output[baseDst + 6] = (avg and 0xFF).toByte()
679
+ output[baseDst + 7] = (avg shr 8).toByte()
680
+
681
+ i += batchSize
682
+ }
683
+
684
+ // Process remaining samples
685
+ while (i < sampleCount) {
512
686
  val srcIdx = i * 4
513
687
  val dstIdx = i * 2
514
-
515
688
  val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
516
689
  val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
517
-
518
- val leftSigned = if (left > 32767) left - 65536 else left
519
- val rightSigned = if (right > 32767) right - 65536 else right
520
-
521
- // Use bit shift instead of division for better performance (x / 2 = x >> 1)
522
- val avg = ((leftSigned + rightSigned) shr 1).coerceIn(-32768, 32767)
523
-
690
+ val avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
524
691
  output[dstIdx] = (avg and 0xFF).toByte()
525
692
  output[dstIdx + 1] = (avg shr 8).toByte()
693
+ i++
526
694
  }
527
695
  }
528
696
  else -> {
@@ -806,8 +974,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
806
974
  decoder?.release()
807
975
  }
808
976
  extractor.release()
977
+
978
+ // Performance metrics
809
979
  val elapsedTime = System.currentTimeMillis() - startTime
810
- Log.d("AudioConcat", " Decoded file in ${elapsedTime}ms")
980
+ val fileSize = try { File(filePath).length() } catch (e: Exception) { 0L }
981
+ val fileSizeKB = fileSize / 1024
982
+ val decodingSpeedMBps = if (elapsedTime > 0) {
983
+ (fileSize / 1024.0 / 1024.0) / (elapsedTime / 1000.0)
984
+ } else 0.0
985
+
986
+ Log.d("AudioConcat", " ⚡ Decoded ${fileSizeKB}KB in ${elapsedTime}ms (${String.format("%.2f", decodingSpeedMBps)} MB/s)")
811
987
  }
812
988
  }
813
989
 
@@ -1183,8 +1359,17 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1183
1359
  val analysisTime = System.currentTimeMillis() - analysisStartTime
1184
1360
  Log.d("AudioConcat", "✓ Analyzed duplicates in ${analysisTime}ms")
1185
1361
 
1362
+ // Collect all unique audio files for pre-decode caching
1363
+ val allAudioFiles = parsedData.filterIsInstance<AudioDataOrSilence.AudioFile>()
1364
+ .map { it.filePath }
1365
+ .distinct()
1366
+
1367
+ // Merge duplicate files with all unique files for comprehensive caching
1368
+ // This ensures pre-decoded files are always cached, regardless of occurrence count
1369
+ val filesToCache = (duplicateAnalysis.duplicateFiles + allAudioFiles).toSet()
1370
+
1186
1371
  // Create cache instance with intelligent caching strategy
1187
- val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
1372
+ val cache = PCMCache(filesToCache, duplicateAnalysis.duplicateSilence)
1188
1373
 
1189
1374
  // Delete existing output file
1190
1375
  val outputFile = File(outputPath)
@@ -1216,12 +1401,160 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1216
1401
  }
1217
1402
  }
1218
1403
 
1404
+ // PRE-DECODE: Parallel decode all unique audio files to cache before processing
1405
+ val uniqueAudioFiles = audioFileItems.map { it.second }.distinct()
1406
+ val filesToPreDecode = uniqueAudioFiles.filter { cache.getAudioFile(it) == null }
1407
+
1408
+ if (filesToPreDecode.isNotEmpty()) {
1409
+ val preDecodeStartTime = System.currentTimeMillis()
1410
+ val cpuCores = Runtime.getRuntime().availableProcessors()
1411
+ val preDecodeThreads = getOptimalThreadCount(filesToPreDecode.size)
1412
+
1413
+ Log.d("AudioConcat", "→ PRE-DECODE: ${filesToPreDecode.size} unique files using $preDecodeThreads threads (CPU cores: $cpuCores)")
1414
+
1415
+ val preDecodeExecutor = Executors.newFixedThreadPool(preDecodeThreads)
1416
+ val preDecodeLatch = CountDownLatch(filesToPreDecode.size)
1417
+ val preDecodePool = DecoderPool()
1418
+
1419
+ try {
1420
+ filesToPreDecode.forEach { filePath ->
1421
+ preDecodeExecutor.submit {
1422
+ try {
1423
+ Log.d("AudioConcat", " Pre-decoding: $filePath")
1424
+
1425
+ // Decode directly to cache without intermediate queue
1426
+ val extractor = MediaExtractor()
1427
+ var totalBytes = 0L
1428
+ val decodedChunks = mutableListOf<ByteArray>()
1429
+
1430
+ try {
1431
+ extractor.setDataSource(filePath)
1432
+
1433
+ var audioTrackIndex = -1
1434
+ var audioFormat: MediaFormat? = null
1435
+
1436
+ for (i in 0 until extractor.trackCount) {
1437
+ val format = extractor.getTrackFormat(i)
1438
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
1439
+ if (mime.startsWith("audio/")) {
1440
+ audioTrackIndex = i
1441
+ audioFormat = format
1442
+ break
1443
+ }
1444
+ }
1445
+
1446
+ if (audioTrackIndex == -1 || audioFormat == null) {
1447
+ throw Exception("No audio track found in $filePath")
1448
+ }
1449
+
1450
+ val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
1451
+ val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
1452
+
1453
+ val needsResampling = sourceSampleRate != outputSampleRate
1454
+ val needsChannelConversion = sourceChannelCount != audioConfig.channelCount
1455
+
1456
+ if (needsResampling || needsChannelConversion) {
1457
+ Log.d("AudioConcat", " Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${outputSampleRate}Hz ${audioConfig.channelCount}ch")
1458
+ }
1459
+
1460
+ extractor.selectTrack(audioTrackIndex)
1461
+
1462
+ val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
1463
+ val reusableDecoder = preDecodePool.getDecoderForCurrentThread()
1464
+ val decoder = reusableDecoder.getOrCreateDecoder(mime, audioFormat)
1465
+
1466
+ val bufferInfo = MediaCodec.BufferInfo()
1467
+ var isEOS = false
1468
+
1469
+ while (!isEOS) {
1470
+ // Feed input
1471
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
1472
+ if (inputBufferIndex >= 0) {
1473
+ val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
1474
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
1475
+
1476
+ if (sampleSize < 0) {
1477
+ decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
1478
+ } else {
1479
+ val presentationTimeUs = extractor.sampleTime
1480
+ decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
1481
+ extractor.advance()
1482
+ }
1483
+ }
1484
+
1485
+ // Get output
1486
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
1487
+ if (outputBufferIndex >= 0) {
1488
+ val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
1489
+
1490
+ if (bufferInfo.size > 0) {
1491
+ var pcmData = ByteArray(bufferInfo.size)
1492
+ outputBuffer.get(pcmData)
1493
+
1494
+ // Convert channel count if needed
1495
+ if (needsChannelConversion) {
1496
+ pcmData = convertChannelCount(pcmData, sourceChannelCount, audioConfig.channelCount)
1497
+ }
1498
+
1499
+ // Resample if needed
1500
+ if (needsResampling) {
1501
+ pcmData = resamplePCM16(pcmData, sourceSampleRate, outputSampleRate, audioConfig.channelCount)
1502
+ }
1503
+
1504
+ decodedChunks.add(pcmData)
1505
+ totalBytes += pcmData.size
1506
+ }
1507
+
1508
+ decoder.releaseOutputBuffer(outputBufferIndex, false)
1509
+
1510
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
1511
+ isEOS = true
1512
+ }
1513
+ }
1514
+ }
1515
+
1516
+ // Cache the decoded data
1517
+ if (decodedChunks.isNotEmpty()) {
1518
+ cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
1519
+ }
1520
+
1521
+ } finally {
1522
+ extractor.release()
1523
+ }
1524
+
1525
+ } catch (e: Exception) {
1526
+ Log.e("AudioConcat", "Error pre-decoding $filePath: ${e.message}", e)
1527
+ } finally {
1528
+ preDecodeLatch.countDown()
1529
+ }
1530
+ }
1531
+ }
1532
+
1533
+ // Wait for all pre-decoding to complete
1534
+ preDecodeLatch.await()
1535
+ preDecodePool.releaseAll()
1536
+ preDecodeExecutor.shutdown()
1537
+
1538
+ val preDecodeTime = System.currentTimeMillis() - preDecodeStartTime
1539
+ Log.d("AudioConcat", "✓ Pre-decode completed in ${preDecodeTime}ms")
1540
+
1541
+ } catch (e: Exception) {
1542
+ Log.e("AudioConcat", "Error during pre-decode: ${e.message}", e)
1543
+ preDecodePool.releaseAll()
1544
+ preDecodeExecutor.shutdown()
1545
+ }
1546
+ } else {
1547
+ Log.d("AudioConcat", "→ All audio files already cached, skipping pre-decode")
1548
+ }
1549
+
1219
1550
  // Decide whether to use parallel or sequential processing
1220
- val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
1551
+ // Parallel processing is beneficial even with few files due to multi-core CPUs
1552
+ val useParallel = audioFileItems.size >= 3 // Use parallel for 3+ files (was 10)
1221
1553
  val processingStartTime = System.currentTimeMillis()
1222
1554
 
1223
1555
  if (useParallel) {
1224
- Log.d("AudioConcat", "→ Using parallel processing for ${audioFileItems.size} audio files")
1556
+ val cpuCores = Runtime.getRuntime().availableProcessors()
1557
+ Log.d("AudioConcat", "→ Using PARALLEL processing for ${audioFileItems.size} audio files (CPU cores: $cpuCores)")
1225
1558
 
1226
1559
  // Process interleaved patterns optimally
1227
1560
  val processedIndices = mutableSetOf<Int>()
@@ -1274,10 +1607,32 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1274
1607
  }
1275
1608
 
1276
1609
  // Encode the pattern: file -> silence -> file -> silence -> ...
1277
- repeat(pattern.repeatCount) { iteration ->
1278
- // Encode file
1610
+ // OPTIMIZATION: Batch chunks to reduce encoder call overhead
1611
+ val patternStartTime = System.currentTimeMillis()
1612
+
1613
+ // Combine all chunks from the file into a single buffer
1614
+ val combinedFileBuffer = if (pcmChunks.size > 10) {
1615
+ val totalSize = pcmChunks.sumOf { it.size }
1616
+ val buffer = ByteArray(totalSize)
1617
+ var offset = 0
1279
1618
  pcmChunks.forEach { chunk ->
1280
- encoder.encodePCMChunk(chunk, false)
1619
+ System.arraycopy(chunk, 0, buffer, offset, chunk.size)
1620
+ offset += chunk.size
1621
+ }
1622
+ Log.d("AudioConcat", " Batched ${pcmChunks.size} chunks into single buffer (${totalSize / 1024}KB)")
1623
+ buffer
1624
+ } else {
1625
+ null
1626
+ }
1627
+
1628
+ repeat(pattern.repeatCount) { iteration ->
1629
+ // Encode file (batched or individual chunks)
1630
+ if (combinedFileBuffer != null) {
1631
+ encoder.encodePCMChunk(combinedFileBuffer, false)
1632
+ } else {
1633
+ pcmChunks.forEach { chunk ->
1634
+ encoder.encodePCMChunk(chunk, false)
1635
+ }
1281
1636
  }
1282
1637
 
1283
1638
  // Encode silence (except after the last file)
@@ -1286,6 +1641,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1286
1641
  }
1287
1642
  }
1288
1643
 
1644
+ val patternTime = System.currentTimeMillis() - patternStartTime
1645
+ Log.d("AudioConcat", " Encoded interleaved pattern in ${patternTime}ms")
1646
+
1289
1647
  // Mark these indices as processed
1290
1648
  pattern.indices.forEach { idx ->
1291
1649
  processedIndices.add(idx)
@@ -1321,16 +1679,71 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1321
1679
  }
1322
1680
 
1323
1681
  if (consecutiveFiles.isNotEmpty()) {
1324
- val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
1325
- Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
1326
- parallelProcessAudioFiles(
1327
- consecutiveFiles,
1328
- encoder,
1329
- outputSampleRate,
1330
- audioConfig.channelCount,
1331
- cache,
1332
- numThreads = optimalThreads
1333
- )
1682
+ // OPTIMIZATION: Fast path for cached files - avoid thread pool overhead
1683
+ val allCached = consecutiveFiles.all { (_, filePath) -> cache.getAudioFile(filePath) != null }
1684
+
1685
+ if (allCached) {
1686
+ // Direct encoding from cache without parallel processing overhead
1687
+ val startTime = System.currentTimeMillis()
1688
+ Log.d("AudioConcat", "Fast path: encoding ${consecutiveFiles.size} cached files directly")
1689
+
1690
+ consecutiveFiles.forEach { (itemIdx, filePath) ->
1691
+ val cachedData = cache.getAudioFile(filePath)!!
1692
+ val chunkCount = cachedData.chunks.size
1693
+
1694
+ Log.d("AudioConcat", " File[$itemIdx]: ${cachedData.totalBytes / 1024}KB in $chunkCount chunks")
1695
+
1696
+ val encodeStartTime = System.currentTimeMillis()
1697
+
1698
+ // OPTIMIZATION: Batch all chunks into single buffer to reduce encoder call overhead
1699
+ // Instead of 300+ calls at ~2.5ms each, make 1 call
1700
+ if (chunkCount > 10) {
1701
+ // Many small chunks - combine into single buffer for massive speedup
1702
+ val batchStartTime = System.currentTimeMillis()
1703
+ val combinedBuffer = ByteArray(cachedData.totalBytes.toInt())
1704
+ var offset = 0
1705
+
1706
+ cachedData.chunks.forEach { chunk ->
1707
+ System.arraycopy(chunk, 0, combinedBuffer, offset, chunk.size)
1708
+ offset += chunk.size
1709
+ }
1710
+
1711
+ val batchTime = System.currentTimeMillis() - batchStartTime
1712
+ Log.d("AudioConcat", " Batched $chunkCount chunks in ${batchTime}ms")
1713
+
1714
+ // Single encoder call instead of 300+
1715
+ encoder.encodePCMChunk(combinedBuffer, false)
1716
+ } else {
1717
+ // Few chunks - encode directly (rare case)
1718
+ cachedData.chunks.forEach { chunk ->
1719
+ encoder.encodePCMChunk(chunk, false)
1720
+ }
1721
+ }
1722
+
1723
+ val encodeTime = System.currentTimeMillis() - encodeStartTime
1724
+ val throughputMBps = if (encodeTime > 0) {
1725
+ (cachedData.totalBytes / 1024.0 / 1024.0) / (encodeTime / 1000.0)
1726
+ } else 0.0
1727
+
1728
+ Log.d("AudioConcat", " Encoded in ${encodeTime}ms (${String.format("%.2f", throughputMBps)} MB/s)")
1729
+ }
1730
+
1731
+ val elapsedTime = System.currentTimeMillis() - startTime
1732
+ val totalKB = consecutiveFiles.sumOf { (_, filePath) -> cache.getAudioFile(filePath)!!.totalBytes } / 1024
1733
+ Log.d("AudioConcat", " ⚡ Encoded ${consecutiveFiles.size} cached files (${totalKB}KB) in ${elapsedTime}ms")
1734
+ } else {
1735
+ // Standard parallel processing for non-cached files
1736
+ val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
1737
+ Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
1738
+ parallelProcessAudioFiles(
1739
+ consecutiveFiles,
1740
+ encoder,
1741
+ outputSampleRate,
1742
+ audioConfig.channelCount,
1743
+ cache,
1744
+ numThreads = optimalThreads
1745
+ )
1746
+ }
1334
1747
  audioFileIdx = currentIdx
1335
1748
  }
1336
1749
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-concat",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",