react-native-audio-concat 0.5.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.
@@ -160,6 +160,87 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
160
160
  }
161
161
  }
162
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
+
163
244
  private fun extractAudioConfig(filePath: String): AudioConfig {
164
245
  val extractor = MediaExtractor()
165
246
  try {
@@ -356,58 +437,48 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
356
437
  return input
357
438
  }
358
439
 
440
+ val startTime = System.currentTimeMillis()
359
441
  val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
360
442
  val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
361
443
  val output = ByteArray(outputSampleCount * 2 * channelCount)
362
444
 
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
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
+ }
367
452
 
368
- for (i in 0 until outputSampleCount) {
369
- val srcIndex = srcPos shr 16
370
- val fraction = srcPos and 0xFFFF // Fractional part in 16-bit fixed-point
453
+ // Use floating-point for better accuracy than fixed-point
454
+ val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
371
455
 
372
- // Boundary check: ensure we don't go beyond input array
373
- if (srcIndex >= inputSampleCount - 1) {
374
- break
375
- }
456
+ for (i in 0 until outputSampleCount) {
457
+ val srcPos = i * ratio
458
+ val srcIndex = srcPos.toInt()
459
+ val fraction = srcPos - srcIndex // Fractional part (0.0 to 1.0)
376
460
 
377
461
  for (ch in 0 until channelCount) {
378
- // Get current and next sample indices
379
- val idx1 = (srcIndex * channelCount + ch) * 2
380
- val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
381
-
382
- // Read 16-bit samples (little-endian)
383
- val sample1 = (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
384
- val sample2 = if (idx2 + 1 < input.size) {
385
- (input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
386
- } else {
387
- sample1
388
- }
389
-
390
- // Convert to signed 16-bit
391
- val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
392
- val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
462
+ // Linear interpolation with floating-point precision
463
+ val s1 = readSample(srcIndex, ch).toDouble()
464
+ val s2 = readSample(srcIndex + 1, ch).toDouble()
393
465
 
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)
466
+ // Linear interpolation: s1 + (s2 - s1) * fraction
467
+ val interpolated = s1 + (s2 - s1) * fraction
398
468
 
399
469
  // Clamp to 16-bit range
400
- val clamped = interpolated.coerceIn(-32768, 32767)
470
+ val clamped = interpolated.toInt().coerceIn(-32768, 32767)
401
471
 
402
- // Convert back to unsigned and write (little-endian)
472
+ // Write to output (little-endian)
403
473
  val outIdx = (i * channelCount + ch) * 2
404
474
  output[outIdx] = (clamped and 0xFF).toByte()
405
475
  output[outIdx + 1] = (clamped shr 8).toByte()
406
476
  }
407
-
408
- srcPos += step
409
477
  }
410
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
+
411
482
  return output
412
483
  }
413
484
 
@@ -477,7 +548,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
477
548
  targetSampleRate: Int,
478
549
  targetChannelCount: Int,
479
550
  latch: CountDownLatch,
480
- cache: PCMCache
551
+ cache: PCMCache,
552
+ decoderPool: DecoderPool? = null
481
553
  ) {
482
554
  try {
483
555
  // Check cache first
@@ -497,6 +569,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
497
569
  var decoder: MediaCodec? = null
498
570
  val decodedChunks = mutableListOf<ByteArray>()
499
571
  var totalBytes = 0L
572
+ val shouldReleaseDecoder = (decoderPool == null) // Only release if not using pool
500
573
 
501
574
  try {
502
575
  extractor.setDataSource(filePath)
@@ -531,9 +604,17 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
531
604
  extractor.selectTrack(audioTrackIndex)
532
605
 
533
606
  val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
534
- decoder = MediaCodec.createDecoderByType(mime)
535
- decoder.configure(audioFormat, null, null, 0)
536
- 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
+ }
537
618
 
538
619
  val bufferInfo = MediaCodec.BufferInfo()
539
620
  var isEOS = false
@@ -596,8 +677,11 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
596
677
  }
597
678
 
598
679
  } finally {
599
- decoder?.stop()
600
- decoder?.release()
680
+ // Only stop/release decoder if not using pool
681
+ if (shouldReleaseDecoder) {
682
+ decoder?.stop()
683
+ decoder?.release()
684
+ }
601
685
  extractor.release()
602
686
  }
603
687
  } catch (e: Exception) {
@@ -613,10 +697,13 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
613
697
  encoder: StreamingEncoder,
614
698
  isLastFile: Boolean,
615
699
  targetSampleRate: Int,
616
- targetChannelCount: Int
700
+ targetChannelCount: Int,
701
+ reusableDecoder: ReusableDecoder? = null
617
702
  ) {
703
+ val startTime = System.currentTimeMillis()
618
704
  val extractor = MediaExtractor()
619
705
  var decoder: MediaCodec? = null
706
+ val shouldReleaseDecoder = (reusableDecoder == null) // Only release if not reusing
620
707
 
621
708
  try {
622
709
  extractor.setDataSource(filePath)
@@ -651,9 +738,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
651
738
  extractor.selectTrack(audioTrackIndex)
652
739
 
653
740
  val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
654
- decoder = MediaCodec.createDecoderByType(mime)
655
- decoder.configure(audioFormat, null, null, 0)
656
- 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
+ }
657
751
 
658
752
  val bufferInfo = MediaCodec.BufferInfo()
659
753
  var isEOS = false
@@ -706,9 +800,14 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
706
800
  }
707
801
 
708
802
  } finally {
709
- decoder?.stop()
710
- decoder?.release()
803
+ // Only stop/release decoder if we created it locally (not reusing)
804
+ if (shouldReleaseDecoder) {
805
+ decoder?.stop()
806
+ decoder?.release()
807
+ }
711
808
  extractor.release()
809
+ val elapsedTime = System.currentTimeMillis() - startTime
810
+ Log.d("AudioConcat", " Decoded file in ${elapsedTime}ms")
712
811
  }
713
812
  }
714
813
 
@@ -833,6 +932,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
833
932
  val latch = CountDownLatch(optimizedFiles.size)
834
933
  val sequenceCounter = AtomicInteger(0)
835
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
+
836
939
  try {
837
940
  // Submit decode tasks for unique files only
838
941
  optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
@@ -842,7 +945,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
842
945
  sequenceCounter.addAndGet(1000000)
843
946
 
844
947
  Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
845
- parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
948
+ parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache, decoderPool)
846
949
 
847
950
  // Mark end with duplicate count
848
951
  val repeatCount = consecutiveDuplicates[optIndex] ?: 1
@@ -900,6 +1003,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
900
1003
  Log.d("AudioConcat", "All parallel decode tasks completed")
901
1004
 
902
1005
  } finally {
1006
+ decoderPool.releaseAll()
903
1007
  executor.shutdown()
904
1008
  }
905
1009
  }
@@ -1032,17 +1136,24 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1032
1136
  }
1033
1137
 
1034
1138
  override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
1139
+ val totalStartTime = System.currentTimeMillis()
1140
+ Log.d("AudioConcat", "========== Audio Concat Started ==========")
1141
+
1035
1142
  try {
1036
1143
  if (data.size() == 0) {
1037
1144
  promise.reject("EMPTY_DATA", "Data array is empty")
1038
1145
  return
1039
1146
  }
1040
1147
 
1148
+ // Parse data
1149
+ val parseStartTime = System.currentTimeMillis()
1041
1150
  val parsedData = parseAudioData(data)
1042
- 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")
1043
1153
  Log.d("AudioConcat", "Output: $outputPath")
1044
1154
 
1045
1155
  // Get audio config from first audio file
1156
+ val configStartTime = System.currentTimeMillis()
1046
1157
  var audioConfig: AudioConfig? = null
1047
1158
  for (item in parsedData) {
1048
1159
  if (item is AudioDataOrSilence.AudioFile) {
@@ -1056,10 +1167,21 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1056
1167
  return
1057
1168
  }
1058
1169
 
1059
- 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)
1060
1179
 
1061
1180
  // Analyze duplicates to determine cache strategy
1062
- 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")
1063
1185
 
1064
1186
  // Create cache instance with intelligent caching strategy
1065
1187
  val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
@@ -1070,9 +1192,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1070
1192
  outputFile.delete()
1071
1193
  }
1072
1194
 
1073
- // Create streaming encoder
1195
+ // Create streaming encoder with fixed 24kHz sample rate
1074
1196
  val encoder = StreamingEncoder(
1075
- audioConfig.sampleRate,
1197
+ outputSampleRate,
1076
1198
  audioConfig.channelCount,
1077
1199
  audioConfig.bitRate,
1078
1200
  outputPath
@@ -1096,9 +1218,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1096
1218
 
1097
1219
  // Decide whether to use parallel or sequential processing
1098
1220
  val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
1221
+ val processingStartTime = System.currentTimeMillis()
1099
1222
 
1100
1223
  if (useParallel) {
1101
- Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
1224
+ Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
1102
1225
 
1103
1226
  // Process interleaved patterns optimally
1104
1227
  val processedIndices = mutableSetOf<Int>()
@@ -1121,7 +1244,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1121
1244
  val latch = CountDownLatch(1)
1122
1245
  val seqStart = AtomicInteger(0)
1123
1246
 
1124
- parallelDecodeToQueue(filePath, tempQueue, seqStart, audioConfig.sampleRate, audioConfig.channelCount, latch, cache)
1247
+ parallelDecodeToQueue(filePath, tempQueue, seqStart, outputSampleRate, audioConfig.channelCount, latch, cache)
1125
1248
 
1126
1249
  // Collect chunks
1127
1250
  var collecting = true
@@ -1203,7 +1326,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1203
1326
  parallelProcessAudioFiles(
1204
1327
  consecutiveFiles,
1205
1328
  encoder,
1206
- audioConfig.sampleRate,
1329
+ outputSampleRate,
1207
1330
  audioConfig.channelCount,
1208
1331
  cache,
1209
1332
  numThreads = optimalThreads
@@ -1218,7 +1341,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1218
1341
  streamEncodeSilence(
1219
1342
  durationMs,
1220
1343
  encoder,
1221
- audioConfig.sampleRate,
1344
+ outputSampleRate,
1222
1345
  audioConfig.channelCount,
1223
1346
  cache
1224
1347
  )
@@ -1226,47 +1349,66 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1226
1349
  }
1227
1350
  }
1228
1351
  } else {
1229
- Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
1352
+ Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
1230
1353
 
1231
- // Process each item sequentially (original behavior)
1232
- for ((index, item) in parsedData.withIndex()) {
1233
- when (item) {
1234
- is AudioDataOrSilence.AudioFile -> {
1235
- val filePath = item.filePath
1236
- Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1354
+ // Create a reusable decoder for sequential processing
1355
+ val reusableDecoder = ReusableDecoder()
1356
+ Log.d("AudioConcat", "Created reusable decoder for sequential processing")
1237
1357
 
1238
- val isLastFile = (index == parsedData.size - 1)
1239
- streamDecodeAudioFile(
1240
- filePath,
1241
- encoder,
1242
- isLastFile,
1243
- audioConfig.sampleRate,
1244
- audioConfig.channelCount
1245
- )
1246
- }
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
+ }
1247
1376
 
1248
- is AudioDataOrSilence.Silence -> {
1249
- val durationMs = item.durationMs
1250
- 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")
1251
1380
 
1252
- streamEncodeSilence(
1253
- durationMs,
1254
- encoder,
1255
- audioConfig.sampleRate,
1256
- audioConfig.channelCount,
1257
- cache
1258
- )
1381
+ streamEncodeSilence(
1382
+ durationMs,
1383
+ encoder,
1384
+ outputSampleRate,
1385
+ audioConfig.channelCount,
1386
+ cache
1387
+ )
1388
+ }
1259
1389
  }
1260
1390
  }
1391
+ } finally {
1392
+ // Release the reusable decoder when done
1393
+ reusableDecoder.release()
1394
+ Log.d("AudioConcat", "Released reusable decoder")
1261
1395
  }
1262
1396
  }
1263
1397
 
1398
+ val processingTime = System.currentTimeMillis() - processingStartTime
1399
+ Log.d("AudioConcat", "✓ Processing completed in ${processingTime}ms")
1400
+
1264
1401
  // Finish encoding
1402
+ val encodingFinishStartTime = System.currentTimeMillis()
1265
1403
  encoder.finish()
1404
+ val encodingFinishTime = System.currentTimeMillis() - encodingFinishStartTime
1405
+ Log.d("AudioConcat", "✓ Encoding finalized in ${encodingFinishTime}ms")
1266
1406
 
1267
1407
  // Log cache statistics
1268
1408
  Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1269
1409
 
1410
+ val totalTime = System.currentTimeMillis() - totalStartTime
1411
+ Log.d("AudioConcat", "========== Total Time: ${totalTime}ms (${totalTime / 1000.0}s) ==========")
1270
1412
  Log.d("AudioConcat", "Successfully merged audio to $outputPath")
1271
1413
  promise.resolve(outputPath)
1272
1414
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-concat",
3
- "version": "0.5.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",