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
|
-
|
|
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
|
-
|
|
187
|
-
newDecoder
|
|
188
|
-
newDecoder
|
|
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
|
-
//
|
|
302
|
-
|
|
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
|
-
//
|
|
350
|
-
|
|
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
|
|
500
|
-
|
|
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
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
|
1417
|
-
promise.reject("
|
|
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.
|
|
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
|
|
41
|
+
"release": "release-it"
|
|
42
42
|
},
|
|
43
43
|
"keywords": [
|
|
44
44
|
"react-native",
|