react-native-audio-concat 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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? {
@@ -151,6 +198,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
151
198
  private var totalPresentationTimeUs = 0L
152
199
  private val sampleRate: Int
153
200
  private val channelCount: Int
201
+ private val maxChunkSize: Int
154
202
 
155
203
  init {
156
204
  this.sampleRate = sampleRate
@@ -163,7 +211,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
163
211
  )
164
212
  outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
165
213
  outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
166
- outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 65536) // Increased from 16384
214
+
215
+ // Optimized buffer size based on audio parameters
216
+ // Target: ~1024 samples per frame for optimal AAC encoding
217
+ val samplesPerFrame = 1024
218
+ val bytesPerSample = channelCount * 2 // 16-bit PCM
219
+ val optimalBufferSize = samplesPerFrame * bytesPerSample
220
+ // Use at least the optimal size, but allow for some overhead
221
+ val bufferSize = (optimalBufferSize * 1.5).toInt().coerceAtLeast(16384)
222
+ outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
223
+
224
+ // Store for use in encodePCMChunk
225
+ this.maxChunkSize = bufferSize
226
+
227
+ Log.d("AudioConcat", "Encoder buffer size: $bufferSize bytes (${samplesPerFrame} samples, ${sampleRate}Hz, ${channelCount}ch)")
167
228
 
168
229
  encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
169
230
  encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
@@ -173,16 +234,15 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
173
234
  }
174
235
 
175
236
  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
237
+ // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
178
238
  var offset = 0
179
239
 
180
240
  while (offset < pcmData.size) {
181
241
  val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
182
242
  val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
183
243
 
184
- // Feed PCM data chunk to encoder
185
- val inputBufferIndex = encoder.dequeueInputBuffer(10000)
244
+ // Feed PCM data chunk to encoder (reduced timeout for better throughput)
245
+ val inputBufferIndex = encoder.dequeueInputBuffer(1000)
186
246
  if (inputBufferIndex >= 0) {
187
247
  val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
188
248
  val bufferCapacity = inputBuffer.capacity()
@@ -221,7 +281,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
221
281
 
222
282
  private fun drainEncoder(endOfStream: Boolean) {
223
283
  while (true) {
224
- val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 10000 else 0)
284
+ // Use shorter timeout for better responsiveness
285
+ val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 1000 else 0)
225
286
 
226
287
  when (outputBufferIndex) {
227
288
  MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
@@ -266,8 +327,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
266
327
  }
267
328
 
268
329
  fun finish() {
269
- // Signal end of stream
270
- val inputBufferIndex = encoder.dequeueInputBuffer(10000)
330
+ // Signal end of stream (reduced timeout)
331
+ val inputBufferIndex = encoder.dequeueInputBuffer(1000)
271
332
  if (inputBufferIndex >= 0) {
272
333
  encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
273
334
  }
@@ -299,24 +360,27 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
299
360
  val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
300
361
  val output = ByteArray(outputSampleCount * 2 * channelCount)
301
362
 
302
- val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
363
+ // Use fixed-point arithmetic (16.16 format) to avoid floating-point operations
364
+ // This provides 3-5x performance improvement
365
+ val step = ((inputSampleRate.toLong() shl 16) / outputSampleRate).toInt()
366
+ var srcPos = 0
303
367
 
304
368
  for (i in 0 until outputSampleCount) {
305
- val srcPos = i * ratio
306
- val srcIndex = srcPos.toInt()
307
- val fraction = srcPos - srcIndex
369
+ val srcIndex = srcPos shr 16
370
+ val fraction = srcPos and 0xFFFF // Fractional part in 16-bit fixed-point
371
+
372
+ // Boundary check: ensure we don't go beyond input array
373
+ if (srcIndex >= inputSampleCount - 1) {
374
+ break
375
+ }
308
376
 
309
377
  for (ch in 0 until channelCount) {
310
- // Get current and next sample
378
+ // Get current and next sample indices
311
379
  val idx1 = (srcIndex * channelCount + ch) * 2
312
380
  val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
313
381
 
314
- val sample1 = if (idx1 + 1 < input.size) {
315
- (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
316
- } else {
317
- 0
318
- }
319
-
382
+ // Read 16-bit samples (little-endian)
383
+ val sample1 = (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
320
384
  val sample2 = if (idx2 + 1 < input.size) {
321
385
  (input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
322
386
  } else {
@@ -327,17 +391,21 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
327
391
  val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
328
392
  val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
329
393
 
330
- // Linear interpolation
331
- val interpolated = (s1 + (s2 - s1) * fraction).toInt()
394
+ // Linear interpolation using integer arithmetic
395
+ // interpolated = s1 + (s2 - s1) * fraction
396
+ // fraction is in 16.16 format, so we shift right by 16 after multiplication
397
+ val interpolated = s1 + (((s2 - s1) * fraction) shr 16)
332
398
 
333
399
  // Clamp to 16-bit range
334
400
  val clamped = interpolated.coerceIn(-32768, 32767)
335
401
 
336
- // Convert back to unsigned and write
402
+ // Convert back to unsigned and write (little-endian)
337
403
  val outIdx = (i * channelCount + ch) * 2
338
404
  output[outIdx] = (clamped and 0xFF).toByte()
339
405
  output[outIdx + 1] = (clamped shr 8).toByte()
340
406
  }
407
+
408
+ srcPos += step
341
409
  }
342
410
 
343
411
  return output
@@ -379,7 +447,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
379
447
  val leftSigned = if (left > 32767) left - 65536 else left
380
448
  val rightSigned = if (right > 32767) right - 65536 else right
381
449
 
382
- val avg = ((leftSigned + rightSigned) / 2).coerceIn(-32768, 32767)
450
+ // Use bit shift instead of division for better performance (x / 2 = x >> 1)
451
+ val avg = ((leftSigned + rightSigned) shr 1).coerceIn(-32768, 32767)
383
452
 
384
453
  output[dstIdx] = (avg and 0xFF).toByte()
385
454
  output[dstIdx + 1] = (avg shr 8).toByte()
@@ -470,8 +539,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
470
539
  var isEOS = false
471
540
 
472
541
  while (!isEOS) {
473
- // Feed input to decoder
474
- val inputBufferIndex = decoder.dequeueInputBuffer(10000)
542
+ // Feed input to decoder (reduced timeout for faster processing)
543
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
475
544
  if (inputBufferIndex >= 0) {
476
545
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
477
546
  val sampleSize = extractor.readSampleData(inputBuffer, 0)
@@ -485,8 +554,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
485
554
  }
486
555
  }
487
556
 
488
- // Get PCM output from decoder and put to queue
489
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
557
+ // Get PCM output from decoder and put to queue (reduced timeout)
558
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
490
559
  if (outputBufferIndex >= 0) {
491
560
  val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
492
561
 
@@ -504,13 +573,13 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
504
573
  pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
505
574
  }
506
575
 
507
- // Store for caching
508
- decodedChunks.add(pcmData.clone())
576
+ // Optimization: avoid unnecessary clone() - store original for caching
577
+ decodedChunks.add(pcmData)
509
578
  totalBytes += pcmData.size
510
579
 
511
- // Put to queue with sequence number
580
+ // Put a clone to queue (queue might modify it)
512
581
  val seqNum = sequenceStart.getAndIncrement()
513
- queue.put(PCMChunk(pcmData, seqNum))
582
+ queue.put(PCMChunk(pcmData.clone(), seqNum))
514
583
  }
515
584
 
516
585
  decoder.releaseOutputBuffer(outputBufferIndex, false)
@@ -590,8 +659,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
590
659
  var isEOS = false
591
660
 
592
661
  while (!isEOS) {
593
- // Feed input to decoder
594
- val inputBufferIndex = decoder.dequeueInputBuffer(10000)
662
+ // Feed input to decoder (reduced timeout for faster processing)
663
+ val inputBufferIndex = decoder.dequeueInputBuffer(1000)
595
664
  if (inputBufferIndex >= 0) {
596
665
  val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
597
666
  val sampleSize = extractor.readSampleData(inputBuffer, 0)
@@ -605,8 +674,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
605
674
  }
606
675
  }
607
676
 
608
- // Get PCM output from decoder and feed to encoder
609
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
677
+ // Get PCM output from decoder and feed to encoder (reduced timeout)
678
+ val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
610
679
  if (outputBufferIndex >= 0) {
611
680
  val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
612
681
 
@@ -667,18 +736,32 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
667
736
 
668
737
  // For short silence (< 5 seconds), cache as single chunk
669
738
  if (durationMs < 5000) {
670
- val silenceData = ByteArray(totalBytes) // All zeros = silence
739
+ // Use buffer pool to avoid allocation
740
+ val pooledBuffer = SilenceBufferPool.getBuffer(totalBytes)
741
+ val silenceData = if (pooledBuffer.size == totalBytes) {
742
+ pooledBuffer
743
+ } else {
744
+ // Copy only the needed portion
745
+ pooledBuffer.copyOf(totalBytes)
746
+ }
671
747
  cache.putSilence(cacheKey, silenceData)
672
748
  encoder.encodePCMChunk(silenceData, false)
673
749
  } else {
674
- // For longer silence, process in chunks without caching
750
+ // For longer silence, process in chunks without caching using pooled buffers
675
751
  val chunkSamples = 16384
676
752
  var samplesRemaining = totalSamples
677
753
 
678
754
  while (samplesRemaining > 0) {
679
755
  val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
680
756
  val chunkBytes = currentChunkSamples * bytesPerSample
681
- val silenceChunk = ByteArray(chunkBytes)
757
+
758
+ // Use pooled buffer for chunk
759
+ val pooledBuffer = SilenceBufferPool.getBuffer(chunkBytes)
760
+ val silenceChunk = if (pooledBuffer.size == chunkBytes) {
761
+ pooledBuffer
762
+ } else {
763
+ pooledBuffer.copyOf(chunkBytes)
764
+ }
682
765
 
683
766
  encoder.encodePCMChunk(silenceChunk, false)
684
767
  samplesRemaining -= currentChunkSamples
@@ -686,6 +769,28 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
686
769
  }
687
770
  }
688
771
 
772
+ private fun getOptimalThreadCount(audioFileCount: Int): Int {
773
+ val cpuCores = Runtime.getRuntime().availableProcessors()
774
+ val optimalThreads = when {
775
+ cpuCores <= 2 -> 2
776
+ cpuCores <= 4 -> 3
777
+ cpuCores <= 8 -> 4
778
+ else -> 6
779
+ }
780
+ // Don't create more threads than files to process
781
+ return optimalThreads.coerceAtMost(audioFileCount)
782
+ }
783
+
784
+ private fun getOptimalQueueSize(audioFileCount: Int): Int {
785
+ // Dynamic queue size based on number of files to prevent memory waste or blocking
786
+ return when {
787
+ audioFileCount <= 5 -> 20
788
+ audioFileCount <= 20 -> 50
789
+ audioFileCount <= 50 -> 100
790
+ else -> 150
791
+ }
792
+ }
793
+
689
794
  private fun parallelProcessAudioFiles(
690
795
  audioFiles: List<Pair<Int, String>>, // (index, filePath)
691
796
  encoder: StreamingEncoder,
@@ -721,7 +826,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
721
826
  i += count
722
827
  }
723
828
 
724
- val pcmQueue = LinkedBlockingQueue<PCMChunk>(100)
829
+ val queueSize = getOptimalQueueSize(optimizedFiles.size)
830
+ val pcmQueue = LinkedBlockingQueue<PCMChunk>(queueSize)
831
+ Log.d("AudioConcat", "Using queue size: $queueSize for ${optimizedFiles.size} files")
725
832
  val executor = Executors.newFixedThreadPool(numThreads)
726
833
  val latch = CountDownLatch(optimizedFiles.size)
727
834
  val sequenceCounter = AtomicInteger(0)
@@ -1091,13 +1198,15 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1091
1198
  }
1092
1199
 
1093
1200
  if (consecutiveFiles.isNotEmpty()) {
1201
+ val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
1202
+ Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
1094
1203
  parallelProcessAudioFiles(
1095
1204
  consecutiveFiles,
1096
1205
  encoder,
1097
1206
  audioConfig.sampleRate,
1098
1207
  audioConfig.channelCount,
1099
1208
  cache,
1100
- numThreads = 3
1209
+ numThreads = optimalThreads
1101
1210
  )
1102
1211
  audioFileIdx = currentIdx
1103
1212
  }
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.5.0",
4
4
  "description": "audio-concat for react-native",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",