react-native-audio-concat 0.4.0 → 0.6.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.
@@ -57,15 +57,61 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
57
57
  val channelCount: Int
58
58
  )
59
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
+
60
92
  private class PCMCache(
61
93
  private val shouldCacheFile: Set<String>,
62
94
  private val shouldCacheSilence: Set<SilenceCacheKey>
63
95
  ) {
64
96
  private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
65
97
  private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
66
- private val maxCacheSizeMB = 100 // Limit cache to 100MB
67
98
  private var currentCacheSizeBytes = 0L
68
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
+
69
115
  fun getAudioFile(filePath: String): CachedPCMData? {
70
116
  return audioFileCache[filePath]
71
117
  }
@@ -76,15 +122,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
76
122
  return
77
123
  }
78
124
 
79
- // Check cache size limit
80
- if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeMB * 1024 * 1024) {
81
- Log.d("AudioConcat", "Cache full, not caching: $filePath")
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")
82
129
  return
83
130
  }
84
131
 
85
132
  audioFileCache[filePath] = data
86
133
  currentCacheSizeBytes += data.totalBytes
87
- Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB)")
134
+ Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB, total: ${currentCacheSizeBytes / 1024}KB)")
88
135
  }
89
136
 
90
137
  fun getSilence(key: SilenceCacheKey): ByteArray? {
@@ -113,6 +160,87 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
113
160
  }
114
161
  }
115
162
 
163
+ // Helper class to manage MediaCodec decoder reuse
164
+ private class ReusableDecoder {
165
+ private var decoder: MediaCodec? = null
166
+ private var currentMimeType: String? = null
167
+ private var currentFormat: MediaFormat? = null
168
+
169
+ fun getOrCreateDecoder(mimeType: String, format: MediaFormat): MediaCodec {
170
+ // Check if we can reuse the existing decoder
171
+ if (decoder != null && currentMimeType == mimeType && formatsCompatible(currentFormat, format)) {
172
+ // Flush the decoder to reset its state
173
+ try {
174
+ decoder!!.flush()
175
+ Log.d("AudioConcat", " Reused decoder for $mimeType")
176
+ return decoder!!
177
+ } catch (e: Exception) {
178
+ Log.w("AudioConcat", "Failed to flush decoder, recreating: ${e.message}")
179
+ release()
180
+ }
181
+ }
182
+
183
+ // Need to create a new decoder
184
+ release() // Release old one if exists
185
+
186
+ val newDecoder = MediaCodec.createDecoderByType(mimeType)
187
+ newDecoder.configure(format, null, null, 0)
188
+ newDecoder.start()
189
+
190
+ decoder = newDecoder
191
+ currentMimeType = mimeType
192
+ currentFormat = format
193
+
194
+ Log.d("AudioConcat", " Created new decoder for $mimeType")
195
+ return newDecoder
196
+ }
197
+
198
+ private fun formatsCompatible(format1: MediaFormat?, format2: MediaFormat): Boolean {
199
+ if (format1 == null) return false
200
+
201
+ // Check key format properties
202
+ return try {
203
+ format1.getInteger(MediaFormat.KEY_SAMPLE_RATE) == format2.getInteger(MediaFormat.KEY_SAMPLE_RATE) &&
204
+ format1.getInteger(MediaFormat.KEY_CHANNEL_COUNT) == format2.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
205
+ } catch (e: Exception) {
206
+ false
207
+ }
208
+ }
209
+
210
+ fun release() {
211
+ decoder?.let {
212
+ try {
213
+ it.stop()
214
+ it.release()
215
+ } catch (e: Exception) {
216
+ Log.w("AudioConcat", "Error releasing decoder: ${e.message}")
217
+ }
218
+ }
219
+ decoder = null
220
+ currentMimeType = null
221
+ currentFormat = null
222
+ }
223
+ }
224
+
225
+ // Thread-safe decoder pool for parallel processing
226
+ private class DecoderPool {
227
+ private val decoders = ConcurrentHashMap<Long, ReusableDecoder>()
228
+
229
+ fun getDecoderForCurrentThread(): ReusableDecoder {
230
+ val threadId = Thread.currentThread().id
231
+ return decoders.getOrPut(threadId) {
232
+ Log.d("AudioConcat", " Created decoder for thread $threadId")
233
+ ReusableDecoder()
234
+ }
235
+ }
236
+
237
+ fun releaseAll() {
238
+ decoders.values.forEach { it.release() }
239
+ decoders.clear()
240
+ Log.d("AudioConcat", "Released all pooled decoders")
241
+ }
242
+ }
243
+
116
244
  private fun extractAudioConfig(filePath: String): AudioConfig {
117
245
  val extractor = MediaExtractor()
118
246
  try {
@@ -151,6 +279,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
151
279
  private var totalPresentationTimeUs = 0L
152
280
  private val sampleRate: Int
153
281
  private val channelCount: Int
282
+ private val maxChunkSize: Int
154
283
 
155
284
  init {
156
285
  this.sampleRate = sampleRate
@@ -163,7 +292,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
163
292
  )
164
293
  outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
165
294
  outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
166
- outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 65536) // Increased from 16384
295
+
296
+ // Optimized buffer size based on audio parameters
297
+ // Target: ~1024 samples per frame for optimal AAC encoding
298
+ val samplesPerFrame = 1024
299
+ val bytesPerSample = channelCount * 2 // 16-bit PCM
300
+ 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)
303
+ outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
304
+
305
+ // Store for use in encodePCMChunk
306
+ this.maxChunkSize = bufferSize
307
+
308
+ Log.d("AudioConcat", "Encoder buffer size: $bufferSize bytes (${samplesPerFrame} samples, ${sampleRate}Hz, ${channelCount}ch)")
167
309
 
168
310
  encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
169
311
  encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
@@ -173,16 +315,15 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
173
315
  }
174
316
 
175
317
  fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
176
- // Split large PCM data into smaller chunks that fit in encoder buffer
177
- val maxChunkSize = 65536 // Match KEY_MAX_INPUT_SIZE
318
+ // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
178
319
  var offset = 0
179
320
 
180
321
  while (offset < pcmData.size) {
181
322
  val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
182
323
  val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
183
324
 
184
- // Feed PCM data chunk to encoder
185
- val inputBufferIndex = encoder.dequeueInputBuffer(10000)
325
+ // Feed PCM data chunk to encoder (reduced timeout for better throughput)
326
+ val inputBufferIndex = encoder.dequeueInputBuffer(1000)
186
327
  if (inputBufferIndex >= 0) {
187
328
  val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
188
329
  val bufferCapacity = inputBuffer.capacity()
@@ -221,7 +362,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
221
362
 
222
363
  private fun drainEncoder(endOfStream: Boolean) {
223
364
  while (true) {
224
- val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 10000 else 0)
365
+ // Use shorter timeout for better responsiveness
366
+ val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 1000 else 0)
225
367
 
226
368
  when (outputBufferIndex) {
227
369
  MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
@@ -266,8 +408,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
266
408
  }
267
409
 
268
410
  fun finish() {
269
- // Signal end of stream
270
- val inputBufferIndex = encoder.dequeueInputBuffer(10000)
411
+ // Signal end of stream (reduced timeout)
412
+ val inputBufferIndex = encoder.dequeueInputBuffer(1000)
271
413
  if (inputBufferIndex >= 0) {
272
414
  encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
273
415
  }
@@ -295,51 +437,48 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
295
437
  return input
296
438
  }
297
439
 
440
+ val startTime = System.currentTimeMillis()
298
441
  val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
299
442
  val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
300
443
  val output = ByteArray(outputSampleCount * 2 * channelCount)
301
444
 
445
+ // Helper function to read a sample with bounds checking
446
+ fun readSample(sampleIndex: Int, channel: Int): Int {
447
+ val clampedIndex = sampleIndex.coerceIn(0, inputSampleCount - 1)
448
+ val idx = (clampedIndex * channelCount + channel) * 2
449
+ val unsigned = (input[idx].toInt() and 0xFF) or (input[idx + 1].toInt() shl 8)
450
+ return if (unsigned > 32767) unsigned - 65536 else unsigned
451
+ }
452
+
453
+ // Use floating-point for better accuracy than fixed-point
302
454
  val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
303
455
 
304
456
  for (i in 0 until outputSampleCount) {
305
457
  val srcPos = i * ratio
306
458
  val srcIndex = srcPos.toInt()
307
- val fraction = srcPos - srcIndex
459
+ val fraction = srcPos - srcIndex // Fractional part (0.0 to 1.0)
308
460
 
309
461
  for (ch in 0 until channelCount) {
310
- // Get current and next sample
311
- val idx1 = (srcIndex * channelCount + ch) * 2
312
- val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
313
-
314
- val sample1 = if (idx1 + 1 < input.size) {
315
- (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
316
- } else {
317
- 0
318
- }
462
+ // Linear interpolation with floating-point precision
463
+ val s1 = readSample(srcIndex, ch).toDouble()
464
+ val s2 = readSample(srcIndex + 1, ch).toDouble()
319
465
 
320
- val sample2 = if (idx2 + 1 < input.size) {
321
- (input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
322
- } else {
323
- sample1
324
- }
325
-
326
- // Convert to signed 16-bit
327
- val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
328
- val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
329
-
330
- // Linear interpolation
331
- val interpolated = (s1 + (s2 - s1) * fraction).toInt()
466
+ // Linear interpolation: s1 + (s2 - s1) * fraction
467
+ val interpolated = s1 + (s2 - s1) * fraction
332
468
 
333
469
  // Clamp to 16-bit range
334
- val clamped = interpolated.coerceIn(-32768, 32767)
470
+ val clamped = interpolated.toInt().coerceIn(-32768, 32767)
335
471
 
336
- // Convert back to unsigned and write
472
+ // Write to output (little-endian)
337
473
  val outIdx = (i * channelCount + ch) * 2
338
474
  output[outIdx] = (clamped and 0xFF).toByte()
339
475
  output[outIdx + 1] = (clamped shr 8).toByte()
340
476
  }
341
477
  }
342
478
 
479
+ val elapsedTime = System.currentTimeMillis() - startTime
480
+ Log.d("AudioConcat", " Resampled ${inputSampleRate}Hz→${outputSampleRate}Hz, ${input.size / 1024}KB→${output.size / 1024}KB in ${elapsedTime}ms")
481
+
343
482
  return output
344
483
  }
345
484
 
@@ -379,7 +518,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
379
518
  val leftSigned = if (left > 32767) left - 65536 else left
380
519
  val rightSigned = if (right > 32767) right - 65536 else right
381
520
 
382
- val avg = ((leftSigned + rightSigned) / 2).coerceIn(-32768, 32767)
521
+ // Use bit shift instead of division for better performance (x / 2 = x >> 1)
522
+ val avg = ((leftSigned + rightSigned) shr 1).coerceIn(-32768, 32767)
383
523
 
384
524
  output[dstIdx] = (avg and 0xFF).toByte()
385
525
  output[dstIdx + 1] = (avg shr 8).toByte()
@@ -408,7 +548,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
408
548
  targetSampleRate: Int,
409
549
  targetChannelCount: Int,
410
550
  latch: CountDownLatch,
411
- cache: PCMCache
551
+ cache: PCMCache,
552
+ decoderPool: DecoderPool? = null
412
553
  ) {
413
554
  try {
414
555
  // Check cache first
@@ -428,6 +569,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
428
569
  var decoder: MediaCodec? = null
429
570
  val decodedChunks = mutableListOf<ByteArray>()
430
571
  var totalBytes = 0L
572
+ val shouldReleaseDecoder = (decoderPool == null) // Only release if not using pool
431
573
 
432
574
  try {
433
575
  extractor.setDataSource(filePath)
@@ -462,16 +604,24 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
462
604
  extractor.selectTrack(audioTrackIndex)
463
605
 
464
606
  val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
465
- decoder = MediaCodec.createDecoderByType(mime)
466
- decoder.configure(audioFormat, null, null, 0)
467
- decoder.start()
607
+
608
+ // Use decoder pool if available, otherwise create new decoder
609
+ decoder = if (decoderPool != null) {
610
+ val reusableDecoder = decoderPool.getDecoderForCurrentThread()
611
+ reusableDecoder.getOrCreateDecoder(mime, audioFormat)
612
+ } else {
613
+ val newDecoder = MediaCodec.createDecoderByType(mime)
614
+ newDecoder.configure(audioFormat, null, null, 0)
615
+ newDecoder.start()
616
+ newDecoder
617
+ }
468
618
 
469
619
  val bufferInfo = MediaCodec.BufferInfo()
470
620
  var isEOS = false
471
621
 
472
622
  while (!isEOS) {
473
- // Feed input to decoder
474
- val inputBufferIndex = decoder.dequeueInputBuffer(10000)
623
+ // Feed input to decoder (reduced timeout for faster processing)
624
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
475
625
  if (inputBufferIndex >= 0) {
476
626
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
477
627
  val sampleSize = extractor.readSampleData(inputBuffer, 0)
@@ -485,8 +635,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
485
635
  }
486
636
  }
487
637
 
488
- // Get PCM output from decoder and put to queue
489
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
638
+ // Get PCM output from decoder and put to queue (reduced timeout)
639
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
490
640
  if (outputBufferIndex >= 0) {
491
641
  val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
492
642
 
@@ -504,13 +654,13 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
504
654
  pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
505
655
  }
506
656
 
507
- // Store for caching
508
- decodedChunks.add(pcmData.clone())
657
+ // Optimization: avoid unnecessary clone() - store original for caching
658
+ decodedChunks.add(pcmData)
509
659
  totalBytes += pcmData.size
510
660
 
511
- // Put to queue with sequence number
661
+ // Put a clone to queue (queue might modify it)
512
662
  val seqNum = sequenceStart.getAndIncrement()
513
- queue.put(PCMChunk(pcmData, seqNum))
663
+ queue.put(PCMChunk(pcmData.clone(), seqNum))
514
664
  }
515
665
 
516
666
  decoder.releaseOutputBuffer(outputBufferIndex, false)
@@ -527,8 +677,11 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
527
677
  }
528
678
 
529
679
  } finally {
530
- decoder?.stop()
531
- decoder?.release()
680
+ // Only stop/release decoder if not using pool
681
+ if (shouldReleaseDecoder) {
682
+ decoder?.stop()
683
+ decoder?.release()
684
+ }
532
685
  extractor.release()
533
686
  }
534
687
  } catch (e: Exception) {
@@ -544,10 +697,13 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
544
697
  encoder: StreamingEncoder,
545
698
  isLastFile: Boolean,
546
699
  targetSampleRate: Int,
547
- targetChannelCount: Int
700
+ targetChannelCount: Int,
701
+ reusableDecoder: ReusableDecoder? = null
548
702
  ) {
703
+ val startTime = System.currentTimeMillis()
549
704
  val extractor = MediaExtractor()
550
705
  var decoder: MediaCodec? = null
706
+ val shouldReleaseDecoder = (reusableDecoder == null) // Only release if not reusing
551
707
 
552
708
  try {
553
709
  extractor.setDataSource(filePath)
@@ -582,16 +738,23 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
582
738
  extractor.selectTrack(audioTrackIndex)
583
739
 
584
740
  val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
585
- decoder = MediaCodec.createDecoderByType(mime)
586
- decoder.configure(audioFormat, null, null, 0)
587
- decoder.start()
741
+
742
+ // Use reusable decoder if provided, otherwise create a new one
743
+ decoder = if (reusableDecoder != null) {
744
+ reusableDecoder.getOrCreateDecoder(mime, audioFormat)
745
+ } else {
746
+ val newDecoder = MediaCodec.createDecoderByType(mime)
747
+ newDecoder.configure(audioFormat, null, null, 0)
748
+ newDecoder.start()
749
+ newDecoder
750
+ }
588
751
 
589
752
  val bufferInfo = MediaCodec.BufferInfo()
590
753
  var isEOS = false
591
754
 
592
755
  while (!isEOS) {
593
- // Feed input to decoder
594
- val inputBufferIndex = decoder.dequeueInputBuffer(10000)
756
+ // Feed input to decoder (reduced timeout for faster processing)
757
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
595
758
  if (inputBufferIndex >= 0) {
596
759
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
597
760
  val sampleSize = extractor.readSampleData(inputBuffer, 0)
@@ -605,8 +768,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
605
768
  }
606
769
  }
607
770
 
608
- // Get PCM output from decoder and feed to encoder
609
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
771
+ // Get PCM output from decoder and feed to encoder (reduced timeout)
772
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
610
773
  if (outputBufferIndex >= 0) {
611
774
  val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
612
775
 
@@ -637,9 +800,14 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
637
800
  }
638
801
 
639
802
  } finally {
640
- decoder?.stop()
641
- decoder?.release()
803
+ // Only stop/release decoder if we created it locally (not reusing)
804
+ if (shouldReleaseDecoder) {
805
+ decoder?.stop()
806
+ decoder?.release()
807
+ }
642
808
  extractor.release()
809
+ val elapsedTime = System.currentTimeMillis() - startTime
810
+ Log.d("AudioConcat", " Decoded file in ${elapsedTime}ms")
643
811
  }
644
812
  }
645
813
 
@@ -667,18 +835,32 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
667
835
 
668
836
  // For short silence (< 5 seconds), cache as single chunk
669
837
  if (durationMs < 5000) {
670
- val silenceData = ByteArray(totalBytes) // All zeros = silence
838
+ // Use buffer pool to avoid allocation
839
+ val pooledBuffer = SilenceBufferPool.getBuffer(totalBytes)
840
+ val silenceData = if (pooledBuffer.size == totalBytes) {
841
+ pooledBuffer
842
+ } else {
843
+ // Copy only the needed portion
844
+ pooledBuffer.copyOf(totalBytes)
845
+ }
671
846
  cache.putSilence(cacheKey, silenceData)
672
847
  encoder.encodePCMChunk(silenceData, false)
673
848
  } else {
674
- // For longer silence, process in chunks without caching
849
+ // For longer silence, process in chunks without caching using pooled buffers
675
850
  val chunkSamples = 16384
676
851
  var samplesRemaining = totalSamples
677
852
 
678
853
  while (samplesRemaining > 0) {
679
854
  val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
680
855
  val chunkBytes = currentChunkSamples * bytesPerSample
681
- val silenceChunk = ByteArray(chunkBytes)
856
+
857
+ // Use pooled buffer for chunk
858
+ val pooledBuffer = SilenceBufferPool.getBuffer(chunkBytes)
859
+ val silenceChunk = if (pooledBuffer.size == chunkBytes) {
860
+ pooledBuffer
861
+ } else {
862
+ pooledBuffer.copyOf(chunkBytes)
863
+ }
682
864
 
683
865
  encoder.encodePCMChunk(silenceChunk, false)
684
866
  samplesRemaining -= currentChunkSamples
@@ -686,6 +868,28 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
686
868
  }
687
869
  }
688
870
 
871
+ private fun getOptimalThreadCount(audioFileCount: Int): Int {
872
+ val cpuCores = Runtime.getRuntime().availableProcessors()
873
+ val optimalThreads = when {
874
+ cpuCores <= 2 -> 2
875
+ cpuCores <= 4 -> 3
876
+ cpuCores <= 8 -> 4
877
+ else -> 6
878
+ }
879
+ // Don't create more threads than files to process
880
+ return optimalThreads.coerceAtMost(audioFileCount)
881
+ }
882
+
883
+ private fun getOptimalQueueSize(audioFileCount: Int): Int {
884
+ // Dynamic queue size based on number of files to prevent memory waste or blocking
885
+ return when {
886
+ audioFileCount <= 5 -> 20
887
+ audioFileCount <= 20 -> 50
888
+ audioFileCount <= 50 -> 100
889
+ else -> 150
890
+ }
891
+ }
892
+
689
893
  private fun parallelProcessAudioFiles(
690
894
  audioFiles: List<Pair<Int, String>>, // (index, filePath)
691
895
  encoder: StreamingEncoder,
@@ -721,11 +925,17 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
721
925
  i += count
722
926
  }
723
927
 
724
- val pcmQueue = LinkedBlockingQueue<PCMChunk>(100)
928
+ val queueSize = getOptimalQueueSize(optimizedFiles.size)
929
+ val pcmQueue = LinkedBlockingQueue<PCMChunk>(queueSize)
930
+ Log.d("AudioConcat", "Using queue size: $queueSize for ${optimizedFiles.size} files")
725
931
  val executor = Executors.newFixedThreadPool(numThreads)
726
932
  val latch = CountDownLatch(optimizedFiles.size)
727
933
  val sequenceCounter = AtomicInteger(0)
728
934
 
935
+ // Create decoder pool for reuse across threads
936
+ val decoderPool = DecoderPool()
937
+ Log.d("AudioConcat", "Created decoder pool for parallel processing ($numThreads threads)")
938
+
729
939
  try {
730
940
  // Submit decode tasks for unique files only
731
941
  optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
@@ -735,7 +945,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
735
945
  sequenceCounter.addAndGet(1000000)
736
946
 
737
947
  Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
738
- parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
948
+ parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache, decoderPool)
739
949
 
740
950
  // Mark end with duplicate count
741
951
  val repeatCount = consecutiveDuplicates[optIndex] ?: 1
@@ -793,6 +1003,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
793
1003
  Log.d("AudioConcat", "All parallel decode tasks completed")
794
1004
 
795
1005
  } finally {
1006
+ decoderPool.releaseAll()
796
1007
  executor.shutdown()
797
1008
  }
798
1009
  }
@@ -925,17 +1136,24 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
925
1136
  }
926
1137
 
927
1138
  override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
1139
+ val totalStartTime = System.currentTimeMillis()
1140
+ Log.d("AudioConcat", "========== Audio Concat Started ==========")
1141
+
928
1142
  try {
929
1143
  if (data.size() == 0) {
930
1144
  promise.reject("EMPTY_DATA", "Data array is empty")
931
1145
  return
932
1146
  }
933
1147
 
1148
+ // Parse data
1149
+ val parseStartTime = System.currentTimeMillis()
934
1150
  val parsedData = parseAudioData(data)
935
- Log.d("AudioConcat", "Streaming merge of ${parsedData.size} items")
1151
+ val parseTime = System.currentTimeMillis() - parseStartTime
1152
+ Log.d("AudioConcat", "✓ Parsed ${parsedData.size} items in ${parseTime}ms")
936
1153
  Log.d("AudioConcat", "Output: $outputPath")
937
1154
 
938
1155
  // Get audio config from first audio file
1156
+ val configStartTime = System.currentTimeMillis()
939
1157
  var audioConfig: AudioConfig? = null
940
1158
  for (item in parsedData) {
941
1159
  if (item is AudioDataOrSilence.AudioFile) {
@@ -949,10 +1167,21 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
949
1167
  return
950
1168
  }
951
1169
 
952
- Log.d("AudioConcat", "Audio config: ${audioConfig.sampleRate}Hz, ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
1170
+ val configTime = System.currentTimeMillis() - configStartTime
1171
+
1172
+ // Force output sample rate to 24kHz for optimal performance
1173
+ val outputSampleRate = 24000
1174
+ Log.d("AudioConcat", "✓ Extracted audio config in ${configTime}ms: ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
1175
+ Log.d("AudioConcat", "Output sample rate: ${outputSampleRate}Hz (24kHz optimized)")
1176
+
1177
+ // Create modified config with fixed sample rate
1178
+ val outputAudioConfig = AudioConfig(outputSampleRate, audioConfig.channelCount, audioConfig.bitRate)
953
1179
 
954
1180
  // Analyze duplicates to determine cache strategy
955
- val duplicateAnalysis = analyzeDuplicates(parsedData, audioConfig)
1181
+ val analysisStartTime = System.currentTimeMillis()
1182
+ val duplicateAnalysis = analyzeDuplicates(parsedData, outputAudioConfig)
1183
+ val analysisTime = System.currentTimeMillis() - analysisStartTime
1184
+ Log.d("AudioConcat", "✓ Analyzed duplicates in ${analysisTime}ms")
956
1185
 
957
1186
  // Create cache instance with intelligent caching strategy
958
1187
  val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
@@ -963,9 +1192,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
963
1192
  outputFile.delete()
964
1193
  }
965
1194
 
966
- // Create streaming encoder
1195
+ // Create streaming encoder with fixed 24kHz sample rate
967
1196
  val encoder = StreamingEncoder(
968
- audioConfig.sampleRate,
1197
+ outputSampleRate,
969
1198
  audioConfig.channelCount,
970
1199
  audioConfig.bitRate,
971
1200
  outputPath
@@ -989,9 +1218,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
989
1218
 
990
1219
  // Decide whether to use parallel or sequential processing
991
1220
  val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
1221
+ val processingStartTime = System.currentTimeMillis()
992
1222
 
993
1223
  if (useParallel) {
994
- Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
1224
+ Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
995
1225
 
996
1226
  // Process interleaved patterns optimally
997
1227
  val processedIndices = mutableSetOf<Int>()
@@ -1014,7 +1244,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1014
1244
  val latch = CountDownLatch(1)
1015
1245
  val seqStart = AtomicInteger(0)
1016
1246
 
1017
- parallelDecodeToQueue(filePath, tempQueue, seqStart, audioConfig.sampleRate, audioConfig.channelCount, latch, cache)
1247
+ parallelDecodeToQueue(filePath, tempQueue, seqStart, outputSampleRate, audioConfig.channelCount, latch, cache)
1018
1248
 
1019
1249
  // Collect chunks
1020
1250
  var collecting = true
@@ -1091,13 +1321,15 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1091
1321
  }
1092
1322
 
1093
1323
  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()})")
1094
1326
  parallelProcessAudioFiles(
1095
1327
  consecutiveFiles,
1096
1328
  encoder,
1097
- audioConfig.sampleRate,
1329
+ outputSampleRate,
1098
1330
  audioConfig.channelCount,
1099
1331
  cache,
1100
- numThreads = 3
1332
+ numThreads = optimalThreads
1101
1333
  )
1102
1334
  audioFileIdx = currentIdx
1103
1335
  }
@@ -1109,7 +1341,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1109
1341
  streamEncodeSilence(
1110
1342
  durationMs,
1111
1343
  encoder,
1112
- audioConfig.sampleRate,
1344
+ outputSampleRate,
1113
1345
  audioConfig.channelCount,
1114
1346
  cache
1115
1347
  )
@@ -1117,47 +1349,66 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1117
1349
  }
1118
1350
  }
1119
1351
  } else {
1120
- Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
1352
+ Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
1121
1353
 
1122
- // Process each item sequentially (original behavior)
1123
- for ((index, item) in parsedData.withIndex()) {
1124
- when (item) {
1125
- is AudioDataOrSilence.AudioFile -> {
1126
- val filePath = item.filePath
1127
- Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1354
+ // Create a reusable decoder for sequential processing
1355
+ val reusableDecoder = ReusableDecoder()
1356
+ Log.d("AudioConcat", "Created reusable decoder for sequential processing")
1128
1357
 
1129
- val isLastFile = (index == parsedData.size - 1)
1130
- streamDecodeAudioFile(
1131
- filePath,
1132
- encoder,
1133
- isLastFile,
1134
- audioConfig.sampleRate,
1135
- audioConfig.channelCount
1136
- )
1137
- }
1358
+ try {
1359
+ // Process each item sequentially (with decoder reuse)
1360
+ for ((index, item) in parsedData.withIndex()) {
1361
+ when (item) {
1362
+ is AudioDataOrSilence.AudioFile -> {
1363
+ val filePath = item.filePath
1364
+ Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1365
+
1366
+ val isLastFile = (index == parsedData.size - 1)
1367
+ streamDecodeAudioFile(
1368
+ filePath,
1369
+ encoder,
1370
+ isLastFile,
1371
+ outputSampleRate,
1372
+ audioConfig.channelCount,
1373
+ reusableDecoder
1374
+ )
1375
+ }
1138
1376
 
1139
- is AudioDataOrSilence.Silence -> {
1140
- val durationMs = item.durationMs
1141
- Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1377
+ is AudioDataOrSilence.Silence -> {
1378
+ val durationMs = item.durationMs
1379
+ Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1142
1380
 
1143
- streamEncodeSilence(
1144
- durationMs,
1145
- encoder,
1146
- audioConfig.sampleRate,
1147
- audioConfig.channelCount,
1148
- cache
1149
- )
1381
+ streamEncodeSilence(
1382
+ durationMs,
1383
+ encoder,
1384
+ outputSampleRate,
1385
+ audioConfig.channelCount,
1386
+ cache
1387
+ )
1388
+ }
1150
1389
  }
1151
1390
  }
1391
+ } finally {
1392
+ // Release the reusable decoder when done
1393
+ reusableDecoder.release()
1394
+ Log.d("AudioConcat", "Released reusable decoder")
1152
1395
  }
1153
1396
  }
1154
1397
 
1398
+ val processingTime = System.currentTimeMillis() - processingStartTime
1399
+ Log.d("AudioConcat", "✓ Processing completed in ${processingTime}ms")
1400
+
1155
1401
  // Finish encoding
1402
+ val encodingFinishStartTime = System.currentTimeMillis()
1156
1403
  encoder.finish()
1404
+ val encodingFinishTime = System.currentTimeMillis() - encodingFinishStartTime
1405
+ Log.d("AudioConcat", "✓ Encoding finalized in ${encodingFinishTime}ms")
1157
1406
 
1158
1407
  // Log cache statistics
1159
1408
  Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1160
1409
 
1410
+ val totalTime = System.currentTimeMillis() - totalStartTime
1411
+ Log.d("AudioConcat", "========== Total Time: ${totalTime}ms (${totalTime / 1000.0}s) ==========")
1161
1412
  Log.d("AudioConcat", "Successfully merged audio to $outputPath")
1162
1413
  promise.resolve(outputPath)
1163
1414
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-concat",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",