react-native-audio-concat 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -24,6 +25,11 @@ import java.util.concurrent.ConcurrentHashMap
24
25
  class AudioConcatModule(reactContext: ReactApplicationContext) :
25
26
  NativeAudioConcatSpec(reactContext) {
26
27
 
28
+ // CONCURRENCY PROTECTION: Serial executor to handle concurrent calls safely
29
+ // This prevents MediaCodec resource conflicts and ensures operations don't interfere
30
+ private val serialExecutor = Executors.newSingleThreadExecutor()
31
+ private val activeOperations = AtomicInteger(0)
32
+
27
33
  private data class AudioConfig(
28
34
  val sampleRate: Int,
29
35
  val channelCount: Int,
@@ -165,6 +171,59 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
165
171
  private var decoder: MediaCodec? = null
166
172
  private var currentMimeType: String? = null
167
173
  private var currentFormat: MediaFormat? = null
174
+ private var isHardwareDecoder: Boolean = false
175
+
176
+ /**
177
+ * Try to create a hardware decoder for better performance
178
+ * Hardware decoders are typically 2-10x faster than software decoders
179
+ */
180
+ private fun createHardwareDecoder(mimeType: String, format: MediaFormat): MediaCodec? {
181
+ try {
182
+ val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
183
+
184
+ for (codecInfo in codecList.codecInfos) {
185
+ // Skip encoders
186
+ if (codecInfo.isEncoder) continue
187
+
188
+ // Check if this codec supports our mime type
189
+ if (!codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
190
+ continue
191
+ }
192
+
193
+ // Hardware decoder identification by vendor prefix
194
+ val isHardware = codecInfo.name.let { name ->
195
+ name.startsWith("OMX.qcom") || // Qualcomm (most common)
196
+ name.startsWith("OMX.MTK") || // MediaTek
197
+ name.startsWith("OMX.Exynos") || // Samsung Exynos
198
+ name.startsWith("OMX.SEC") || // Samsung
199
+ name.startsWith("OMX.hisi") || // Huawei HiSilicon
200
+ name.startsWith("c2.qti") || // Qualcomm C2
201
+ name.startsWith("c2.mtk") || // MediaTek C2
202
+ name.startsWith("c2.exynos") || // Samsung C2
203
+ (name.contains("hardware", ignoreCase = true) &&
204
+ !name.contains("google", ignoreCase = true))
205
+ }
206
+
207
+ if (isHardware) {
208
+ try {
209
+ val codec = MediaCodec.createByCodecName(codecInfo.name)
210
+ codec.configure(format, null, null, 0)
211
+ codec.start()
212
+
213
+ Log.d("AudioConcat", " ✓ Created HARDWARE decoder: ${codecInfo.name}")
214
+ return codec
215
+ } catch (e: Exception) {
216
+ Log.w("AudioConcat", " ✗ HW decoder ${codecInfo.name} failed: ${e.message}")
217
+ // Continue to try next hardware decoder
218
+ }
219
+ }
220
+ }
221
+ } catch (e: Exception) {
222
+ Log.w("AudioConcat", " Hardware decoder search failed: ${e.message}")
223
+ }
224
+
225
+ return null
226
+ }
168
227
 
169
228
  fun getOrCreateDecoder(mimeType: String, format: MediaFormat): MediaCodec {
170
229
  // Check if we can reuse the existing decoder
@@ -172,7 +231,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
172
231
  // Flush the decoder to reset its state
173
232
  try {
174
233
  decoder!!.flush()
175
- Log.d("AudioConcat", " Reused decoder for $mimeType")
234
+ val type = if (isHardwareDecoder) "HW" else "SW"
235
+ Log.d("AudioConcat", " ↻ Reused $type decoder for $mimeType")
176
236
  return decoder!!
177
237
  } catch (e: Exception) {
178
238
  Log.w("AudioConcat", "Failed to flush decoder, recreating: ${e.message}")
@@ -183,15 +243,23 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
183
243
  // Need to create a new decoder
184
244
  release() // Release old one if exists
185
245
 
186
- val newDecoder = MediaCodec.createDecoderByType(mimeType)
187
- newDecoder.configure(format, null, null, 0)
188
- newDecoder.start()
246
+ // Try hardware decoder first (2-10x faster)
247
+ var newDecoder = createHardwareDecoder(mimeType, format)
248
+ isHardwareDecoder = (newDecoder != null)
249
+
250
+ // Fallback to software decoder
251
+ if (newDecoder == null) {
252
+ newDecoder = MediaCodec.createDecoderByType(mimeType)
253
+ newDecoder.configure(format, null, null, 0)
254
+ newDecoder.start()
255
+ Log.d("AudioConcat", " ⚠ Created SOFTWARE decoder for $mimeType (no HW available)")
256
+ isHardwareDecoder = false
257
+ }
189
258
 
190
259
  decoder = newDecoder
191
260
  currentMimeType = mimeType
192
261
  currentFormat = format
193
262
 
194
- Log.d("AudioConcat", " Created new decoder for $mimeType")
195
263
  return newDecoder
196
264
  }
197
265
 
@@ -281,6 +349,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
281
349
  private val channelCount: Int
282
350
  private val maxChunkSize: Int
283
351
 
352
+ // Performance tracking
353
+ private var totalBufferWaitTimeMs = 0L
354
+ private var bufferWaitCount = 0
355
+
284
356
  init {
285
357
  this.sampleRate = sampleRate
286
358
  this.channelCount = channelCount
@@ -298,8 +370,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
298
370
  val samplesPerFrame = 1024
299
371
  val bytesPerSample = channelCount * 2 // 16-bit PCM
300
372
  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)
373
+ // OPTIMIZATION: Increased buffer size for better throughput
374
+ // Larger buffers reduce dequeue operations and improve encoder efficiency
375
+ val bufferSize = (optimalBufferSize * 4.0).toInt().coerceAtLeast(65536)
303
376
  outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
304
377
 
305
378
  // Store for use in encodePCMChunk
@@ -317,14 +390,23 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
317
390
  fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
318
391
  // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
319
392
  var offset = 0
393
+ var buffersQueued = 0 // Track queued buffers for batch draining
320
394
 
321
395
  while (offset < pcmData.size) {
322
396
  val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
323
397
  val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
324
398
 
325
399
  // Feed PCM data chunk to encoder (reduced timeout for better throughput)
400
+ val bufferWaitStart = System.currentTimeMillis()
326
401
  val inputBufferIndex = encoder.dequeueInputBuffer(1000)
402
+ val bufferWaitTime = System.currentTimeMillis() - bufferWaitStart
403
+
327
404
  if (inputBufferIndex >= 0) {
405
+ if (bufferWaitTime > 5) {
406
+ totalBufferWaitTimeMs += bufferWaitTime
407
+ bufferWaitCount++
408
+ }
409
+
328
410
  val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
329
411
  val bufferCapacity = inputBuffer.capacity()
330
412
 
@@ -341,14 +423,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
341
423
  encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
342
424
 
343
425
  offset += actualChunkSize
426
+ buffersQueued++
344
427
  } else {
428
+ totalBufferWaitTimeMs += bufferWaitTime
429
+ bufferWaitCount++
345
430
  // Buffer not available, drain first
346
431
  drainEncoder(false)
432
+ buffersQueued = 0 // Reset counter after forced drain
347
433
  }
348
434
 
349
- // Drain encoder output periodically
350
- if (offset < pcmData.size || !isLastChunk) {
435
+ // OPTIMIZATION: Batch drain - only drain every 4 buffers instead of every buffer
436
+ // This reduces overhead while keeping encoder pipeline flowing
437
+ if (buffersQueued >= 4 || isLastChunk || offset >= pcmData.size) {
351
438
  drainEncoder(false)
439
+ buffersQueued = 0
352
440
  }
353
441
  }
354
442
 
@@ -407,6 +495,14 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
407
495
  }
408
496
  }
409
497
 
498
+ fun getEncoderStats(): String {
499
+ val avgWaitTime = if (bufferWaitCount > 0) {
500
+ String.format("%.2f", totalBufferWaitTimeMs.toFloat() / bufferWaitCount)
501
+ } else "0.00"
502
+
503
+ return "Buffer waits: $bufferWaitCount, Total wait: ${totalBufferWaitTimeMs}ms, Avg: ${avgWaitTime}ms"
504
+ }
505
+
410
506
  fun finish() {
411
507
  // Signal end of stream (reduced timeout)
412
508
  val inputBufferIndex = encoder.dequeueInputBuffer(1000)
@@ -417,6 +513,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
417
513
  // Drain remaining data
418
514
  drainEncoder(true)
419
515
 
516
+ // Log encoder performance stats
517
+ Log.d("AudioConcat", "Encoder stats: ${getEncoderStats()}")
518
+
420
519
  encoder.stop()
421
520
  encoder.release()
422
521
 
@@ -496,33 +595,107 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
496
595
 
497
596
  when {
498
597
  inputChannels == 1 && outputChannels == 2 -> {
499
- // Mono to Stereo: duplicate the channel
500
- for (i in 0 until sampleCount) {
598
+ // OPTIMIZED: Mono to Stereo using batch copy with unrolled loop
599
+ // Process 4 samples at a time for better cache locality
600
+ val batchSize = 4
601
+ val fullBatches = sampleCount / batchSize
602
+ var i = 0
603
+
604
+ // Process batches of 4 samples
605
+ for (batch in 0 until fullBatches) {
606
+ val baseIdx = i * 2
607
+ val baseDst = i * 4
608
+
609
+ // Sample 1
610
+ output[baseDst] = input[baseIdx]
611
+ output[baseDst + 1] = input[baseIdx + 1]
612
+ output[baseDst + 2] = input[baseIdx]
613
+ output[baseDst + 3] = input[baseIdx + 1]
614
+
615
+ // Sample 2
616
+ output[baseDst + 4] = input[baseIdx + 2]
617
+ output[baseDst + 5] = input[baseIdx + 3]
618
+ output[baseDst + 6] = input[baseIdx + 2]
619
+ output[baseDst + 7] = input[baseIdx + 3]
620
+
621
+ // Sample 3
622
+ output[baseDst + 8] = input[baseIdx + 4]
623
+ output[baseDst + 9] = input[baseIdx + 5]
624
+ output[baseDst + 10] = input[baseIdx + 4]
625
+ output[baseDst + 11] = input[baseIdx + 5]
626
+
627
+ // Sample 4
628
+ output[baseDst + 12] = input[baseIdx + 6]
629
+ output[baseDst + 13] = input[baseIdx + 7]
630
+ output[baseDst + 14] = input[baseIdx + 6]
631
+ output[baseDst + 15] = input[baseIdx + 7]
632
+
633
+ i += batchSize
634
+ }
635
+
636
+ // Process remaining samples
637
+ while (i < sampleCount) {
501
638
  val srcIdx = i * 2
502
639
  val dstIdx = i * 4
503
640
  output[dstIdx] = input[srcIdx]
504
641
  output[dstIdx + 1] = input[srcIdx + 1]
505
642
  output[dstIdx + 2] = input[srcIdx]
506
643
  output[dstIdx + 3] = input[srcIdx + 1]
644
+ i++
507
645
  }
508
646
  }
509
647
  inputChannels == 2 && outputChannels == 1 -> {
510
- // Stereo to Mono: average the channels
511
- for (i in 0 until sampleCount) {
648
+ // OPTIMIZED: Stereo to Mono with unrolled loop
649
+ val batchSize = 4
650
+ val fullBatches = sampleCount / batchSize
651
+ var i = 0
652
+
653
+ // Process batches of 4 samples
654
+ for (batch in 0 until fullBatches) {
655
+ val baseSrc = i * 4
656
+ val baseDst = i * 2
657
+
658
+ // Sample 1
659
+ var left = (input[baseSrc].toInt() and 0xFF) or (input[baseSrc + 1].toInt() shl 8)
660
+ var right = (input[baseSrc + 2].toInt() and 0xFF) or (input[baseSrc + 3].toInt() shl 8)
661
+ var avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
662
+ output[baseDst] = (avg and 0xFF).toByte()
663
+ output[baseDst + 1] = (avg shr 8).toByte()
664
+
665
+ // Sample 2
666
+ left = (input[baseSrc + 4].toInt() and 0xFF) or (input[baseSrc + 5].toInt() shl 8)
667
+ right = (input[baseSrc + 6].toInt() and 0xFF) or (input[baseSrc + 7].toInt() shl 8)
668
+ avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
669
+ output[baseDst + 2] = (avg and 0xFF).toByte()
670
+ output[baseDst + 3] = (avg shr 8).toByte()
671
+
672
+ // Sample 3
673
+ left = (input[baseSrc + 8].toInt() and 0xFF) or (input[baseSrc + 9].toInt() shl 8)
674
+ right = (input[baseSrc + 10].toInt() and 0xFF) or (input[baseSrc + 11].toInt() shl 8)
675
+ avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
676
+ output[baseDst + 4] = (avg and 0xFF).toByte()
677
+ output[baseDst + 5] = (avg shr 8).toByte()
678
+
679
+ // Sample 4
680
+ left = (input[baseSrc + 12].toInt() and 0xFF) or (input[baseSrc + 13].toInt() shl 8)
681
+ right = (input[baseSrc + 14].toInt() and 0xFF) or (input[baseSrc + 15].toInt() shl 8)
682
+ avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
683
+ output[baseDst + 6] = (avg and 0xFF).toByte()
684
+ output[baseDst + 7] = (avg shr 8).toByte()
685
+
686
+ i += batchSize
687
+ }
688
+
689
+ // Process remaining samples
690
+ while (i < sampleCount) {
512
691
  val srcIdx = i * 4
513
692
  val dstIdx = i * 2
514
-
515
693
  val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
516
694
  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
-
695
+ val avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
524
696
  output[dstIdx] = (avg and 0xFF).toByte()
525
697
  output[dstIdx + 1] = (avg shr 8).toByte()
698
+ i++
526
699
  }
527
700
  }
528
701
  else -> {
@@ -806,8 +979,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
806
979
  decoder?.release()
807
980
  }
808
981
  extractor.release()
982
+
983
+ // Performance metrics
809
984
  val elapsedTime = System.currentTimeMillis() - startTime
810
- Log.d("AudioConcat", " Decoded file in ${elapsedTime}ms")
985
+ val fileSize = try { File(filePath).length() } catch (e: Exception) { 0L }
986
+ val fileSizeKB = fileSize / 1024
987
+ val decodingSpeedMBps = if (elapsedTime > 0) {
988
+ (fileSize / 1024.0 / 1024.0) / (elapsedTime / 1000.0)
989
+ } else 0.0
990
+
991
+ Log.d("AudioConcat", " ⚡ Decoded ${fileSizeKB}KB in ${elapsedTime}ms (${String.format("%.2f", decodingSpeedMBps)} MB/s)")
811
992
  }
812
993
  }
813
994
 
@@ -1135,15 +1316,28 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1135
1316
  return NAME
1136
1317
  }
1137
1318
 
1319
+ override fun onCatalystInstanceDestroy() {
1320
+ super.onCatalystInstanceDestroy()
1321
+ // Clean up executor when module is destroyed
1322
+ serialExecutor.shutdown()
1323
+ Log.d("AudioConcat", "AudioConcat module destroyed, executor shutdown")
1324
+ }
1325
+
1138
1326
  override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
1139
- val totalStartTime = System.currentTimeMillis()
1140
- Log.d("AudioConcat", "========== Audio Concat Started ==========")
1327
+ // CONCURRENCY PROTECTION: Queue all operations to run serially
1328
+ // This prevents MediaCodec resource conflicts when multiple calls happen simultaneously
1329
+ val operationId = activeOperations.incrementAndGet()
1330
+ Log.d("AudioConcat", "========== Audio Concat Queued (Operation #$operationId) ==========")
1141
1331
 
1142
- try {
1143
- if (data.size() == 0) {
1144
- promise.reject("EMPTY_DATA", "Data array is empty")
1145
- return
1146
- }
1332
+ serialExecutor.submit {
1333
+ val totalStartTime = System.currentTimeMillis()
1334
+ Log.d("AudioConcat", "========== Audio Concat Started (Operation #$operationId) ==========")
1335
+
1336
+ try {
1337
+ if (data.size() == 0) {
1338
+ promise.reject("EMPTY_DATA", "Data array is empty")
1339
+ return@submit
1340
+ }
1147
1341
 
1148
1342
  // Parse data
1149
1343
  val parseStartTime = System.currentTimeMillis()
@@ -1164,7 +1358,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1164
1358
 
1165
1359
  if (audioConfig == null) {
1166
1360
  promise.reject("NO_AUDIO_FILES", "No audio files found in data array")
1167
- return
1361
+ return@submit
1168
1362
  }
1169
1363
 
1170
1364
  val configTime = System.currentTimeMillis() - configStartTime
@@ -1183,8 +1377,17 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1183
1377
  val analysisTime = System.currentTimeMillis() - analysisStartTime
1184
1378
  Log.d("AudioConcat", "✓ Analyzed duplicates in ${analysisTime}ms")
1185
1379
 
1380
+ // Collect all unique audio files for pre-decode caching
1381
+ val allAudioFiles = parsedData.filterIsInstance<AudioDataOrSilence.AudioFile>()
1382
+ .map { it.filePath }
1383
+ .distinct()
1384
+
1385
+ // Merge duplicate files with all unique files for comprehensive caching
1386
+ // This ensures pre-decoded files are always cached, regardless of occurrence count
1387
+ val filesToCache = (duplicateAnalysis.duplicateFiles + allAudioFiles).toSet()
1388
+
1186
1389
  // Create cache instance with intelligent caching strategy
1187
- val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
1390
+ val cache = PCMCache(filesToCache, duplicateAnalysis.duplicateSilence)
1188
1391
 
1189
1392
  // Delete existing output file
1190
1393
  val outputFile = File(outputPath)
@@ -1216,12 +1419,160 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1216
1419
  }
1217
1420
  }
1218
1421
 
1422
+ // PRE-DECODE: Parallel decode all unique audio files to cache before processing
1423
+ val uniqueAudioFiles = audioFileItems.map { it.second }.distinct()
1424
+ val filesToPreDecode = uniqueAudioFiles.filter { cache.getAudioFile(it) == null }
1425
+
1426
+ if (filesToPreDecode.isNotEmpty()) {
1427
+ val preDecodeStartTime = System.currentTimeMillis()
1428
+ val cpuCores = Runtime.getRuntime().availableProcessors()
1429
+ val preDecodeThreads = getOptimalThreadCount(filesToPreDecode.size)
1430
+
1431
+ Log.d("AudioConcat", "→ PRE-DECODE: ${filesToPreDecode.size} unique files using $preDecodeThreads threads (CPU cores: $cpuCores)")
1432
+
1433
+ val preDecodeExecutor = Executors.newFixedThreadPool(preDecodeThreads)
1434
+ val preDecodeLatch = CountDownLatch(filesToPreDecode.size)
1435
+ val preDecodePool = DecoderPool()
1436
+
1437
+ try {
1438
+ filesToPreDecode.forEach { filePath ->
1439
+ preDecodeExecutor.submit {
1440
+ try {
1441
+ Log.d("AudioConcat", " Pre-decoding: $filePath")
1442
+
1443
+ // Decode directly to cache without intermediate queue
1444
+ val extractor = MediaExtractor()
1445
+ var totalBytes = 0L
1446
+ val decodedChunks = mutableListOf<ByteArray>()
1447
+
1448
+ try {
1449
+ extractor.setDataSource(filePath)
1450
+
1451
+ var audioTrackIndex = -1
1452
+ var audioFormat: MediaFormat? = null
1453
+
1454
+ for (i in 0 until extractor.trackCount) {
1455
+ val format = extractor.getTrackFormat(i)
1456
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
1457
+ if (mime.startsWith("audio/")) {
1458
+ audioTrackIndex = i
1459
+ audioFormat = format
1460
+ break
1461
+ }
1462
+ }
1463
+
1464
+ if (audioTrackIndex == -1 || audioFormat == null) {
1465
+ throw Exception("No audio track found in $filePath")
1466
+ }
1467
+
1468
+ val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
1469
+ val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
1470
+
1471
+ val needsResampling = sourceSampleRate != outputSampleRate
1472
+ val needsChannelConversion = sourceChannelCount != audioConfig.channelCount
1473
+
1474
+ if (needsResampling || needsChannelConversion) {
1475
+ Log.d("AudioConcat", " Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${outputSampleRate}Hz ${audioConfig.channelCount}ch")
1476
+ }
1477
+
1478
+ extractor.selectTrack(audioTrackIndex)
1479
+
1480
+ val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
1481
+ val reusableDecoder = preDecodePool.getDecoderForCurrentThread()
1482
+ val decoder = reusableDecoder.getOrCreateDecoder(mime, audioFormat)
1483
+
1484
+ val bufferInfo = MediaCodec.BufferInfo()
1485
+ var isEOS = false
1486
+
1487
+ while (!isEOS) {
1488
+ // Feed input
1489
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
1490
+ if (inputBufferIndex >= 0) {
1491
+ val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
1492
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
1493
+
1494
+ if (sampleSize < 0) {
1495
+ decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
1496
+ } else {
1497
+ val presentationTimeUs = extractor.sampleTime
1498
+ decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
1499
+ extractor.advance()
1500
+ }
1501
+ }
1502
+
1503
+ // Get output
1504
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
1505
+ if (outputBufferIndex >= 0) {
1506
+ val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
1507
+
1508
+ if (bufferInfo.size > 0) {
1509
+ var pcmData = ByteArray(bufferInfo.size)
1510
+ outputBuffer.get(pcmData)
1511
+
1512
+ // Convert channel count if needed
1513
+ if (needsChannelConversion) {
1514
+ pcmData = convertChannelCount(pcmData, sourceChannelCount, audioConfig.channelCount)
1515
+ }
1516
+
1517
+ // Resample if needed
1518
+ if (needsResampling) {
1519
+ pcmData = resamplePCM16(pcmData, sourceSampleRate, outputSampleRate, audioConfig.channelCount)
1520
+ }
1521
+
1522
+ decodedChunks.add(pcmData)
1523
+ totalBytes += pcmData.size
1524
+ }
1525
+
1526
+ decoder.releaseOutputBuffer(outputBufferIndex, false)
1527
+
1528
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
1529
+ isEOS = true
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ // Cache the decoded data
1535
+ if (decodedChunks.isNotEmpty()) {
1536
+ cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
1537
+ }
1538
+
1539
+ } finally {
1540
+ extractor.release()
1541
+ }
1542
+
1543
+ } catch (e: Exception) {
1544
+ Log.e("AudioConcat", "Error pre-decoding $filePath: ${e.message}", e)
1545
+ } finally {
1546
+ preDecodeLatch.countDown()
1547
+ }
1548
+ }
1549
+ }
1550
+
1551
+ // Wait for all pre-decoding to complete
1552
+ preDecodeLatch.await()
1553
+ preDecodePool.releaseAll()
1554
+ preDecodeExecutor.shutdown()
1555
+
1556
+ val preDecodeTime = System.currentTimeMillis() - preDecodeStartTime
1557
+ Log.d("AudioConcat", "✓ Pre-decode completed in ${preDecodeTime}ms")
1558
+
1559
+ } catch (e: Exception) {
1560
+ Log.e("AudioConcat", "Error during pre-decode: ${e.message}", e)
1561
+ preDecodePool.releaseAll()
1562
+ preDecodeExecutor.shutdown()
1563
+ }
1564
+ } else {
1565
+ Log.d("AudioConcat", "→ All audio files already cached, skipping pre-decode")
1566
+ }
1567
+
1219
1568
  // Decide whether to use parallel or sequential processing
1220
- val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
1569
+ // Parallel processing is beneficial even with few files due to multi-core CPUs
1570
+ val useParallel = audioFileItems.size >= 3 // Use parallel for 3+ files (was 10)
1221
1571
  val processingStartTime = System.currentTimeMillis()
1222
1572
 
1223
1573
  if (useParallel) {
1224
- Log.d("AudioConcat", "→ Using parallel processing for ${audioFileItems.size} audio files")
1574
+ val cpuCores = Runtime.getRuntime().availableProcessors()
1575
+ Log.d("AudioConcat", "→ Using PARALLEL processing for ${audioFileItems.size} audio files (CPU cores: $cpuCores)")
1225
1576
 
1226
1577
  // Process interleaved patterns optimally
1227
1578
  val processedIndices = mutableSetOf<Int>()
@@ -1274,10 +1625,32 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1274
1625
  }
1275
1626
 
1276
1627
  // Encode the pattern: file -> silence -> file -> silence -> ...
1277
- repeat(pattern.repeatCount) { iteration ->
1278
- // Encode file
1628
+ // OPTIMIZATION: Batch chunks to reduce encoder call overhead
1629
+ val patternStartTime = System.currentTimeMillis()
1630
+
1631
+ // Combine all chunks from the file into a single buffer
1632
+ val combinedFileBuffer = if (pcmChunks.size > 10) {
1633
+ val totalSize = pcmChunks.sumOf { it.size }
1634
+ val buffer = ByteArray(totalSize)
1635
+ var offset = 0
1279
1636
  pcmChunks.forEach { chunk ->
1280
- encoder.encodePCMChunk(chunk, false)
1637
+ System.arraycopy(chunk, 0, buffer, offset, chunk.size)
1638
+ offset += chunk.size
1639
+ }
1640
+ Log.d("AudioConcat", " Batched ${pcmChunks.size} chunks into single buffer (${totalSize / 1024}KB)")
1641
+ buffer
1642
+ } else {
1643
+ null
1644
+ }
1645
+
1646
+ repeat(pattern.repeatCount) { iteration ->
1647
+ // Encode file (batched or individual chunks)
1648
+ if (combinedFileBuffer != null) {
1649
+ encoder.encodePCMChunk(combinedFileBuffer, false)
1650
+ } else {
1651
+ pcmChunks.forEach { chunk ->
1652
+ encoder.encodePCMChunk(chunk, false)
1653
+ }
1281
1654
  }
1282
1655
 
1283
1656
  // Encode silence (except after the last file)
@@ -1286,6 +1659,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1286
1659
  }
1287
1660
  }
1288
1661
 
1662
+ val patternTime = System.currentTimeMillis() - patternStartTime
1663
+ Log.d("AudioConcat", " Encoded interleaved pattern in ${patternTime}ms")
1664
+
1289
1665
  // Mark these indices as processed
1290
1666
  pattern.indices.forEach { idx ->
1291
1667
  processedIndices.add(idx)
@@ -1321,16 +1697,71 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1321
1697
  }
1322
1698
 
1323
1699
  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
- )
1700
+ // OPTIMIZATION: Fast path for cached files - avoid thread pool overhead
1701
+ val allCached = consecutiveFiles.all { (_, filePath) -> cache.getAudioFile(filePath) != null }
1702
+
1703
+ if (allCached) {
1704
+ // Direct encoding from cache without parallel processing overhead
1705
+ val startTime = System.currentTimeMillis()
1706
+ Log.d("AudioConcat", "Fast path: encoding ${consecutiveFiles.size} cached files directly")
1707
+
1708
+ consecutiveFiles.forEach { (itemIdx, filePath) ->
1709
+ val cachedData = cache.getAudioFile(filePath)!!
1710
+ val chunkCount = cachedData.chunks.size
1711
+
1712
+ Log.d("AudioConcat", " File[$itemIdx]: ${cachedData.totalBytes / 1024}KB in $chunkCount chunks")
1713
+
1714
+ val encodeStartTime = System.currentTimeMillis()
1715
+
1716
+ // OPTIMIZATION: Batch all chunks into single buffer to reduce encoder call overhead
1717
+ // Instead of 300+ calls at ~2.5ms each, make 1 call
1718
+ if (chunkCount > 10) {
1719
+ // Many small chunks - combine into single buffer for massive speedup
1720
+ val batchStartTime = System.currentTimeMillis()
1721
+ val combinedBuffer = ByteArray(cachedData.totalBytes.toInt())
1722
+ var offset = 0
1723
+
1724
+ cachedData.chunks.forEach { chunk ->
1725
+ System.arraycopy(chunk, 0, combinedBuffer, offset, chunk.size)
1726
+ offset += chunk.size
1727
+ }
1728
+
1729
+ val batchTime = System.currentTimeMillis() - batchStartTime
1730
+ Log.d("AudioConcat", " Batched $chunkCount chunks in ${batchTime}ms")
1731
+
1732
+ // Single encoder call instead of 300+
1733
+ encoder.encodePCMChunk(combinedBuffer, false)
1734
+ } else {
1735
+ // Few chunks - encode directly (rare case)
1736
+ cachedData.chunks.forEach { chunk ->
1737
+ encoder.encodePCMChunk(chunk, false)
1738
+ }
1739
+ }
1740
+
1741
+ val encodeTime = System.currentTimeMillis() - encodeStartTime
1742
+ val throughputMBps = if (encodeTime > 0) {
1743
+ (cachedData.totalBytes / 1024.0 / 1024.0) / (encodeTime / 1000.0)
1744
+ } else 0.0
1745
+
1746
+ Log.d("AudioConcat", " Encoded in ${encodeTime}ms (${String.format("%.2f", throughputMBps)} MB/s)")
1747
+ }
1748
+
1749
+ val elapsedTime = System.currentTimeMillis() - startTime
1750
+ val totalKB = consecutiveFiles.sumOf { (_, filePath) -> cache.getAudioFile(filePath)!!.totalBytes } / 1024
1751
+ Log.d("AudioConcat", " ⚡ Encoded ${consecutiveFiles.size} cached files (${totalKB}KB) in ${elapsedTime}ms")
1752
+ } else {
1753
+ // Standard parallel processing for non-cached files
1754
+ val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
1755
+ Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
1756
+ parallelProcessAudioFiles(
1757
+ consecutiveFiles,
1758
+ encoder,
1759
+ outputSampleRate,
1760
+ audioConfig.channelCount,
1761
+ cache,
1762
+ numThreads = optimalThreads
1763
+ )
1764
+ }
1334
1765
  audioFileIdx = currentIdx
1335
1766
  }
1336
1767
  }
@@ -1412,14 +1843,17 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1412
1843
  Log.d("AudioConcat", "Successfully merged audio to $outputPath")
1413
1844
  promise.resolve(outputPath)
1414
1845
 
1846
+ } catch (e: Exception) {
1847
+ Log.e("AudioConcat", "Error during streaming merge: ${e.message}", e)
1848
+ promise.reject("MERGE_ERROR", e.message, e)
1849
+ }
1850
+
1415
1851
  } catch (e: Exception) {
1416
- Log.e("AudioConcat", "Error during streaming merge: ${e.message}", e)
1417
- promise.reject("MERGE_ERROR", e.message, e)
1852
+ Log.e("AudioConcat", "Error parsing data: ${e.message}", e)
1853
+ promise.reject("PARSE_ERROR", e.message, e)
1854
+ } finally {
1855
+ Log.d("AudioConcat", "========== Audio Concat Finished (Operation #$operationId) ==========")
1418
1856
  }
1419
-
1420
- } catch (e: Exception) {
1421
- Log.e("AudioConcat", "Error parsing data: ${e.message}", e)
1422
- promise.reject("PARSE_ERROR", e.message, e)
1423
1857
  }
1424
1858
  }
1425
1859
 
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.1",
4
4
  "description": "audio-concat for react-native",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -38,7 +38,7 @@
38
38
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
39
39
  "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
40
40
  "prepare": "bob build",
41
- "release": "release-it --only-version"
41
+ "release": "release-it"
42
42
  },
43
43
  "keywords": [
44
44
  "react-native",