react-native-audio-concat 0.5.0 → 0.7.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.
|
@@ -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
|
|
@@ -160,6 +161,149 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
// Helper class to manage MediaCodec decoder reuse
|
|
165
|
+
private class ReusableDecoder {
|
|
166
|
+
private var decoder: MediaCodec? = null
|
|
167
|
+
private var currentMimeType: String? = null
|
|
168
|
+
private var currentFormat: MediaFormat? = null
|
|
169
|
+
private var isHardwareDecoder: Boolean = false
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Try to create a hardware decoder for better performance
|
|
173
|
+
* Hardware decoders are typically 2-10x faster than software decoders
|
|
174
|
+
*/
|
|
175
|
+
private fun createHardwareDecoder(mimeType: String, format: MediaFormat): MediaCodec? {
|
|
176
|
+
try {
|
|
177
|
+
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
|
178
|
+
|
|
179
|
+
for (codecInfo in codecList.codecInfos) {
|
|
180
|
+
// Skip encoders
|
|
181
|
+
if (codecInfo.isEncoder) continue
|
|
182
|
+
|
|
183
|
+
// Check if this codec supports our mime type
|
|
184
|
+
if (!codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Hardware decoder identification by vendor prefix
|
|
189
|
+
val isHardware = codecInfo.name.let { name ->
|
|
190
|
+
name.startsWith("OMX.qcom") || // Qualcomm (most common)
|
|
191
|
+
name.startsWith("OMX.MTK") || // MediaTek
|
|
192
|
+
name.startsWith("OMX.Exynos") || // Samsung Exynos
|
|
193
|
+
name.startsWith("OMX.SEC") || // Samsung
|
|
194
|
+
name.startsWith("OMX.hisi") || // Huawei HiSilicon
|
|
195
|
+
name.startsWith("c2.qti") || // Qualcomm C2
|
|
196
|
+
name.startsWith("c2.mtk") || // MediaTek C2
|
|
197
|
+
name.startsWith("c2.exynos") || // Samsung C2
|
|
198
|
+
(name.contains("hardware", ignoreCase = true) &&
|
|
199
|
+
!name.contains("google", ignoreCase = true))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (isHardware) {
|
|
203
|
+
try {
|
|
204
|
+
val codec = MediaCodec.createByCodecName(codecInfo.name)
|
|
205
|
+
codec.configure(format, null, null, 0)
|
|
206
|
+
codec.start()
|
|
207
|
+
|
|
208
|
+
Log.d("AudioConcat", " ✓ Created HARDWARE decoder: ${codecInfo.name}")
|
|
209
|
+
return codec
|
|
210
|
+
} catch (e: Exception) {
|
|
211
|
+
Log.w("AudioConcat", " ✗ HW decoder ${codecInfo.name} failed: ${e.message}")
|
|
212
|
+
// Continue to try next hardware decoder
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch (e: Exception) {
|
|
217
|
+
Log.w("AudioConcat", " Hardware decoder search failed: ${e.message}")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fun getOrCreateDecoder(mimeType: String, format: MediaFormat): MediaCodec {
|
|
224
|
+
// Check if we can reuse the existing decoder
|
|
225
|
+
if (decoder != null && currentMimeType == mimeType && formatsCompatible(currentFormat, format)) {
|
|
226
|
+
// Flush the decoder to reset its state
|
|
227
|
+
try {
|
|
228
|
+
decoder!!.flush()
|
|
229
|
+
val type = if (isHardwareDecoder) "HW" else "SW"
|
|
230
|
+
Log.d("AudioConcat", " ↻ Reused $type decoder for $mimeType")
|
|
231
|
+
return decoder!!
|
|
232
|
+
} catch (e: Exception) {
|
|
233
|
+
Log.w("AudioConcat", "Failed to flush decoder, recreating: ${e.message}")
|
|
234
|
+
release()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Need to create a new decoder
|
|
239
|
+
release() // Release old one if exists
|
|
240
|
+
|
|
241
|
+
// Try hardware decoder first (2-10x faster)
|
|
242
|
+
var newDecoder = createHardwareDecoder(mimeType, format)
|
|
243
|
+
isHardwareDecoder = (newDecoder != null)
|
|
244
|
+
|
|
245
|
+
// Fallback to software decoder
|
|
246
|
+
if (newDecoder == null) {
|
|
247
|
+
newDecoder = MediaCodec.createDecoderByType(mimeType)
|
|
248
|
+
newDecoder.configure(format, null, null, 0)
|
|
249
|
+
newDecoder.start()
|
|
250
|
+
Log.d("AudioConcat", " ⚠ Created SOFTWARE decoder for $mimeType (no HW available)")
|
|
251
|
+
isHardwareDecoder = false
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
decoder = newDecoder
|
|
255
|
+
currentMimeType = mimeType
|
|
256
|
+
currentFormat = format
|
|
257
|
+
|
|
258
|
+
return newDecoder
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private fun formatsCompatible(format1: MediaFormat?, format2: MediaFormat): Boolean {
|
|
262
|
+
if (format1 == null) return false
|
|
263
|
+
|
|
264
|
+
// Check key format properties
|
|
265
|
+
return try {
|
|
266
|
+
format1.getInteger(MediaFormat.KEY_SAMPLE_RATE) == format2.getInteger(MediaFormat.KEY_SAMPLE_RATE) &&
|
|
267
|
+
format1.getInteger(MediaFormat.KEY_CHANNEL_COUNT) == format2.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
268
|
+
} catch (e: Exception) {
|
|
269
|
+
false
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fun release() {
|
|
274
|
+
decoder?.let {
|
|
275
|
+
try {
|
|
276
|
+
it.stop()
|
|
277
|
+
it.release()
|
|
278
|
+
} catch (e: Exception) {
|
|
279
|
+
Log.w("AudioConcat", "Error releasing decoder: ${e.message}")
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
decoder = null
|
|
283
|
+
currentMimeType = null
|
|
284
|
+
currentFormat = null
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Thread-safe decoder pool for parallel processing
|
|
289
|
+
private class DecoderPool {
|
|
290
|
+
private val decoders = ConcurrentHashMap<Long, ReusableDecoder>()
|
|
291
|
+
|
|
292
|
+
fun getDecoderForCurrentThread(): ReusableDecoder {
|
|
293
|
+
val threadId = Thread.currentThread().id
|
|
294
|
+
return decoders.getOrPut(threadId) {
|
|
295
|
+
Log.d("AudioConcat", " Created decoder for thread $threadId")
|
|
296
|
+
ReusableDecoder()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fun releaseAll() {
|
|
301
|
+
decoders.values.forEach { it.release() }
|
|
302
|
+
decoders.clear()
|
|
303
|
+
Log.d("AudioConcat", "Released all pooled decoders")
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
163
307
|
private fun extractAudioConfig(filePath: String): AudioConfig {
|
|
164
308
|
val extractor = MediaExtractor()
|
|
165
309
|
try {
|
|
@@ -200,6 +344,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
200
344
|
private val channelCount: Int
|
|
201
345
|
private val maxChunkSize: Int
|
|
202
346
|
|
|
347
|
+
// Performance tracking
|
|
348
|
+
private var totalBufferWaitTimeMs = 0L
|
|
349
|
+
private var bufferWaitCount = 0
|
|
350
|
+
|
|
203
351
|
init {
|
|
204
352
|
this.sampleRate = sampleRate
|
|
205
353
|
this.channelCount = channelCount
|
|
@@ -217,8 +365,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
217
365
|
val samplesPerFrame = 1024
|
|
218
366
|
val bytesPerSample = channelCount * 2 // 16-bit PCM
|
|
219
367
|
val optimalBufferSize = samplesPerFrame * bytesPerSample
|
|
220
|
-
//
|
|
221
|
-
|
|
368
|
+
// OPTIMIZATION: Increased buffer size for better throughput
|
|
369
|
+
// Larger buffers reduce dequeue operations and improve encoder efficiency
|
|
370
|
+
val bufferSize = (optimalBufferSize * 4.0).toInt().coerceAtLeast(65536)
|
|
222
371
|
outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
|
|
223
372
|
|
|
224
373
|
// Store for use in encodePCMChunk
|
|
@@ -236,14 +385,23 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
236
385
|
fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
|
|
237
386
|
// Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
|
|
238
387
|
var offset = 0
|
|
388
|
+
var buffersQueued = 0 // Track queued buffers for batch draining
|
|
239
389
|
|
|
240
390
|
while (offset < pcmData.size) {
|
|
241
391
|
val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
|
|
242
392
|
val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
|
|
243
393
|
|
|
244
394
|
// Feed PCM data chunk to encoder (reduced timeout for better throughput)
|
|
395
|
+
val bufferWaitStart = System.currentTimeMillis()
|
|
245
396
|
val inputBufferIndex = encoder.dequeueInputBuffer(1000)
|
|
397
|
+
val bufferWaitTime = System.currentTimeMillis() - bufferWaitStart
|
|
398
|
+
|
|
246
399
|
if (inputBufferIndex >= 0) {
|
|
400
|
+
if (bufferWaitTime > 5) {
|
|
401
|
+
totalBufferWaitTimeMs += bufferWaitTime
|
|
402
|
+
bufferWaitCount++
|
|
403
|
+
}
|
|
404
|
+
|
|
247
405
|
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
|
|
248
406
|
val bufferCapacity = inputBuffer.capacity()
|
|
249
407
|
|
|
@@ -260,14 +418,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
260
418
|
encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
|
|
261
419
|
|
|
262
420
|
offset += actualChunkSize
|
|
421
|
+
buffersQueued++
|
|
263
422
|
} else {
|
|
423
|
+
totalBufferWaitTimeMs += bufferWaitTime
|
|
424
|
+
bufferWaitCount++
|
|
264
425
|
// Buffer not available, drain first
|
|
265
426
|
drainEncoder(false)
|
|
427
|
+
buffersQueued = 0 // Reset counter after forced drain
|
|
266
428
|
}
|
|
267
429
|
|
|
268
|
-
//
|
|
269
|
-
|
|
430
|
+
// OPTIMIZATION: Batch drain - only drain every 4 buffers instead of every buffer
|
|
431
|
+
// This reduces overhead while keeping encoder pipeline flowing
|
|
432
|
+
if (buffersQueued >= 4 || isLastChunk || offset >= pcmData.size) {
|
|
270
433
|
drainEncoder(false)
|
|
434
|
+
buffersQueued = 0
|
|
271
435
|
}
|
|
272
436
|
}
|
|
273
437
|
|
|
@@ -326,6 +490,14 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
326
490
|
}
|
|
327
491
|
}
|
|
328
492
|
|
|
493
|
+
fun getEncoderStats(): String {
|
|
494
|
+
val avgWaitTime = if (bufferWaitCount > 0) {
|
|
495
|
+
String.format("%.2f", totalBufferWaitTimeMs.toFloat() / bufferWaitCount)
|
|
496
|
+
} else "0.00"
|
|
497
|
+
|
|
498
|
+
return "Buffer waits: $bufferWaitCount, Total wait: ${totalBufferWaitTimeMs}ms, Avg: ${avgWaitTime}ms"
|
|
499
|
+
}
|
|
500
|
+
|
|
329
501
|
fun finish() {
|
|
330
502
|
// Signal end of stream (reduced timeout)
|
|
331
503
|
val inputBufferIndex = encoder.dequeueInputBuffer(1000)
|
|
@@ -336,6 +508,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
336
508
|
// Drain remaining data
|
|
337
509
|
drainEncoder(true)
|
|
338
510
|
|
|
511
|
+
// Log encoder performance stats
|
|
512
|
+
Log.d("AudioConcat", "Encoder stats: ${getEncoderStats()}")
|
|
513
|
+
|
|
339
514
|
encoder.stop()
|
|
340
515
|
encoder.release()
|
|
341
516
|
|
|
@@ -356,58 +531,48 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
356
531
|
return input
|
|
357
532
|
}
|
|
358
533
|
|
|
534
|
+
val startTime = System.currentTimeMillis()
|
|
359
535
|
val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
|
|
360
536
|
val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
|
|
361
537
|
val output = ByteArray(outputSampleCount * 2 * channelCount)
|
|
362
538
|
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
539
|
+
// Helper function to read a sample with bounds checking
|
|
540
|
+
fun readSample(sampleIndex: Int, channel: Int): Int {
|
|
541
|
+
val clampedIndex = sampleIndex.coerceIn(0, inputSampleCount - 1)
|
|
542
|
+
val idx = (clampedIndex * channelCount + channel) * 2
|
|
543
|
+
val unsigned = (input[idx].toInt() and 0xFF) or (input[idx + 1].toInt() shl 8)
|
|
544
|
+
return if (unsigned > 32767) unsigned - 65536 else unsigned
|
|
545
|
+
}
|
|
367
546
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
val fraction = srcPos and 0xFFFF // Fractional part in 16-bit fixed-point
|
|
547
|
+
// Use floating-point for better accuracy than fixed-point
|
|
548
|
+
val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
|
|
371
549
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
550
|
+
for (i in 0 until outputSampleCount) {
|
|
551
|
+
val srcPos = i * ratio
|
|
552
|
+
val srcIndex = srcPos.toInt()
|
|
553
|
+
val fraction = srcPos - srcIndex // Fractional part (0.0 to 1.0)
|
|
376
554
|
|
|
377
555
|
for (ch in 0 until channelCount) {
|
|
378
|
-
//
|
|
379
|
-
val
|
|
380
|
-
val
|
|
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
|
-
}
|
|
556
|
+
// Linear interpolation with floating-point precision
|
|
557
|
+
val s1 = readSample(srcIndex, ch).toDouble()
|
|
558
|
+
val s2 = readSample(srcIndex + 1, ch).toDouble()
|
|
389
559
|
|
|
390
|
-
//
|
|
391
|
-
val
|
|
392
|
-
val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
|
|
393
|
-
|
|
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)
|
|
560
|
+
// Linear interpolation: s1 + (s2 - s1) * fraction
|
|
561
|
+
val interpolated = s1 + (s2 - s1) * fraction
|
|
398
562
|
|
|
399
563
|
// Clamp to 16-bit range
|
|
400
|
-
val clamped = interpolated.coerceIn(-32768, 32767)
|
|
564
|
+
val clamped = interpolated.toInt().coerceIn(-32768, 32767)
|
|
401
565
|
|
|
402
|
-
//
|
|
566
|
+
// Write to output (little-endian)
|
|
403
567
|
val outIdx = (i * channelCount + ch) * 2
|
|
404
568
|
output[outIdx] = (clamped and 0xFF).toByte()
|
|
405
569
|
output[outIdx + 1] = (clamped shr 8).toByte()
|
|
406
570
|
}
|
|
407
|
-
|
|
408
|
-
srcPos += step
|
|
409
571
|
}
|
|
410
572
|
|
|
573
|
+
val elapsedTime = System.currentTimeMillis() - startTime
|
|
574
|
+
Log.d("AudioConcat", " Resampled ${inputSampleRate}Hz→${outputSampleRate}Hz, ${input.size / 1024}KB→${output.size / 1024}KB in ${elapsedTime}ms")
|
|
575
|
+
|
|
411
576
|
return output
|
|
412
577
|
}
|
|
413
578
|
|
|
@@ -425,33 +590,107 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
425
590
|
|
|
426
591
|
when {
|
|
427
592
|
inputChannels == 1 && outputChannels == 2 -> {
|
|
428
|
-
// Mono to Stereo
|
|
429
|
-
|
|
593
|
+
// OPTIMIZED: Mono to Stereo using batch copy with unrolled loop
|
|
594
|
+
// Process 4 samples at a time for better cache locality
|
|
595
|
+
val batchSize = 4
|
|
596
|
+
val fullBatches = sampleCount / batchSize
|
|
597
|
+
var i = 0
|
|
598
|
+
|
|
599
|
+
// Process batches of 4 samples
|
|
600
|
+
for (batch in 0 until fullBatches) {
|
|
601
|
+
val baseIdx = i * 2
|
|
602
|
+
val baseDst = i * 4
|
|
603
|
+
|
|
604
|
+
// Sample 1
|
|
605
|
+
output[baseDst] = input[baseIdx]
|
|
606
|
+
output[baseDst + 1] = input[baseIdx + 1]
|
|
607
|
+
output[baseDst + 2] = input[baseIdx]
|
|
608
|
+
output[baseDst + 3] = input[baseIdx + 1]
|
|
609
|
+
|
|
610
|
+
// Sample 2
|
|
611
|
+
output[baseDst + 4] = input[baseIdx + 2]
|
|
612
|
+
output[baseDst + 5] = input[baseIdx + 3]
|
|
613
|
+
output[baseDst + 6] = input[baseIdx + 2]
|
|
614
|
+
output[baseDst + 7] = input[baseIdx + 3]
|
|
615
|
+
|
|
616
|
+
// Sample 3
|
|
617
|
+
output[baseDst + 8] = input[baseIdx + 4]
|
|
618
|
+
output[baseDst + 9] = input[baseIdx + 5]
|
|
619
|
+
output[baseDst + 10] = input[baseIdx + 4]
|
|
620
|
+
output[baseDst + 11] = input[baseIdx + 5]
|
|
621
|
+
|
|
622
|
+
// Sample 4
|
|
623
|
+
output[baseDst + 12] = input[baseIdx + 6]
|
|
624
|
+
output[baseDst + 13] = input[baseIdx + 7]
|
|
625
|
+
output[baseDst + 14] = input[baseIdx + 6]
|
|
626
|
+
output[baseDst + 15] = input[baseIdx + 7]
|
|
627
|
+
|
|
628
|
+
i += batchSize
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Process remaining samples
|
|
632
|
+
while (i < sampleCount) {
|
|
430
633
|
val srcIdx = i * 2
|
|
431
634
|
val dstIdx = i * 4
|
|
432
635
|
output[dstIdx] = input[srcIdx]
|
|
433
636
|
output[dstIdx + 1] = input[srcIdx + 1]
|
|
434
637
|
output[dstIdx + 2] = input[srcIdx]
|
|
435
638
|
output[dstIdx + 3] = input[srcIdx + 1]
|
|
639
|
+
i++
|
|
436
640
|
}
|
|
437
641
|
}
|
|
438
642
|
inputChannels == 2 && outputChannels == 1 -> {
|
|
439
|
-
// Stereo to Mono
|
|
440
|
-
|
|
643
|
+
// OPTIMIZED: Stereo to Mono with unrolled loop
|
|
644
|
+
val batchSize = 4
|
|
645
|
+
val fullBatches = sampleCount / batchSize
|
|
646
|
+
var i = 0
|
|
647
|
+
|
|
648
|
+
// Process batches of 4 samples
|
|
649
|
+
for (batch in 0 until fullBatches) {
|
|
650
|
+
val baseSrc = i * 4
|
|
651
|
+
val baseDst = i * 2
|
|
652
|
+
|
|
653
|
+
// Sample 1
|
|
654
|
+
var left = (input[baseSrc].toInt() and 0xFF) or (input[baseSrc + 1].toInt() shl 8)
|
|
655
|
+
var right = (input[baseSrc + 2].toInt() and 0xFF) or (input[baseSrc + 3].toInt() shl 8)
|
|
656
|
+
var avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
|
|
657
|
+
output[baseDst] = (avg and 0xFF).toByte()
|
|
658
|
+
output[baseDst + 1] = (avg shr 8).toByte()
|
|
659
|
+
|
|
660
|
+
// Sample 2
|
|
661
|
+
left = (input[baseSrc + 4].toInt() and 0xFF) or (input[baseSrc + 5].toInt() shl 8)
|
|
662
|
+
right = (input[baseSrc + 6].toInt() and 0xFF) or (input[baseSrc + 7].toInt() shl 8)
|
|
663
|
+
avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
|
|
664
|
+
output[baseDst + 2] = (avg and 0xFF).toByte()
|
|
665
|
+
output[baseDst + 3] = (avg shr 8).toByte()
|
|
666
|
+
|
|
667
|
+
// Sample 3
|
|
668
|
+
left = (input[baseSrc + 8].toInt() and 0xFF) or (input[baseSrc + 9].toInt() shl 8)
|
|
669
|
+
right = (input[baseSrc + 10].toInt() and 0xFF) or (input[baseSrc + 11].toInt() shl 8)
|
|
670
|
+
avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
|
|
671
|
+
output[baseDst + 4] = (avg and 0xFF).toByte()
|
|
672
|
+
output[baseDst + 5] = (avg shr 8).toByte()
|
|
673
|
+
|
|
674
|
+
// Sample 4
|
|
675
|
+
left = (input[baseSrc + 12].toInt() and 0xFF) or (input[baseSrc + 13].toInt() shl 8)
|
|
676
|
+
right = (input[baseSrc + 14].toInt() and 0xFF) or (input[baseSrc + 15].toInt() shl 8)
|
|
677
|
+
avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
|
|
678
|
+
output[baseDst + 6] = (avg and 0xFF).toByte()
|
|
679
|
+
output[baseDst + 7] = (avg shr 8).toByte()
|
|
680
|
+
|
|
681
|
+
i += batchSize
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Process remaining samples
|
|
685
|
+
while (i < sampleCount) {
|
|
441
686
|
val srcIdx = i * 4
|
|
442
687
|
val dstIdx = i * 2
|
|
443
|
-
|
|
444
688
|
val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
|
|
445
689
|
val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
|
|
446
|
-
|
|
447
|
-
val leftSigned = if (left > 32767) left - 65536 else left
|
|
448
|
-
val rightSigned = if (right > 32767) right - 65536 else right
|
|
449
|
-
|
|
450
|
-
// Use bit shift instead of division for better performance (x / 2 = x >> 1)
|
|
451
|
-
val avg = ((leftSigned + rightSigned) shr 1).coerceIn(-32768, 32767)
|
|
452
|
-
|
|
690
|
+
val avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
|
|
453
691
|
output[dstIdx] = (avg and 0xFF).toByte()
|
|
454
692
|
output[dstIdx + 1] = (avg shr 8).toByte()
|
|
693
|
+
i++
|
|
455
694
|
}
|
|
456
695
|
}
|
|
457
696
|
else -> {
|
|
@@ -477,7 +716,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
477
716
|
targetSampleRate: Int,
|
|
478
717
|
targetChannelCount: Int,
|
|
479
718
|
latch: CountDownLatch,
|
|
480
|
-
cache: PCMCache
|
|
719
|
+
cache: PCMCache,
|
|
720
|
+
decoderPool: DecoderPool? = null
|
|
481
721
|
) {
|
|
482
722
|
try {
|
|
483
723
|
// Check cache first
|
|
@@ -497,6 +737,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
497
737
|
var decoder: MediaCodec? = null
|
|
498
738
|
val decodedChunks = mutableListOf<ByteArray>()
|
|
499
739
|
var totalBytes = 0L
|
|
740
|
+
val shouldReleaseDecoder = (decoderPool == null) // Only release if not using pool
|
|
500
741
|
|
|
501
742
|
try {
|
|
502
743
|
extractor.setDataSource(filePath)
|
|
@@ -531,9 +772,17 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
531
772
|
extractor.selectTrack(audioTrackIndex)
|
|
532
773
|
|
|
533
774
|
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
534
|
-
|
|
535
|
-
decoder
|
|
536
|
-
decoder
|
|
775
|
+
|
|
776
|
+
// Use decoder pool if available, otherwise create new decoder
|
|
777
|
+
decoder = if (decoderPool != null) {
|
|
778
|
+
val reusableDecoder = decoderPool.getDecoderForCurrentThread()
|
|
779
|
+
reusableDecoder.getOrCreateDecoder(mime, audioFormat)
|
|
780
|
+
} else {
|
|
781
|
+
val newDecoder = MediaCodec.createDecoderByType(mime)
|
|
782
|
+
newDecoder.configure(audioFormat, null, null, 0)
|
|
783
|
+
newDecoder.start()
|
|
784
|
+
newDecoder
|
|
785
|
+
}
|
|
537
786
|
|
|
538
787
|
val bufferInfo = MediaCodec.BufferInfo()
|
|
539
788
|
var isEOS = false
|
|
@@ -596,8 +845,11 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
596
845
|
}
|
|
597
846
|
|
|
598
847
|
} finally {
|
|
599
|
-
decoder
|
|
600
|
-
|
|
848
|
+
// Only stop/release decoder if not using pool
|
|
849
|
+
if (shouldReleaseDecoder) {
|
|
850
|
+
decoder?.stop()
|
|
851
|
+
decoder?.release()
|
|
852
|
+
}
|
|
601
853
|
extractor.release()
|
|
602
854
|
}
|
|
603
855
|
} catch (e: Exception) {
|
|
@@ -613,10 +865,13 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
613
865
|
encoder: StreamingEncoder,
|
|
614
866
|
isLastFile: Boolean,
|
|
615
867
|
targetSampleRate: Int,
|
|
616
|
-
targetChannelCount: Int
|
|
868
|
+
targetChannelCount: Int,
|
|
869
|
+
reusableDecoder: ReusableDecoder? = null
|
|
617
870
|
) {
|
|
871
|
+
val startTime = System.currentTimeMillis()
|
|
618
872
|
val extractor = MediaExtractor()
|
|
619
873
|
var decoder: MediaCodec? = null
|
|
874
|
+
val shouldReleaseDecoder = (reusableDecoder == null) // Only release if not reusing
|
|
620
875
|
|
|
621
876
|
try {
|
|
622
877
|
extractor.setDataSource(filePath)
|
|
@@ -651,9 +906,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
651
906
|
extractor.selectTrack(audioTrackIndex)
|
|
652
907
|
|
|
653
908
|
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
654
|
-
|
|
655
|
-
decoder
|
|
656
|
-
decoder
|
|
909
|
+
|
|
910
|
+
// Use reusable decoder if provided, otherwise create a new one
|
|
911
|
+
decoder = if (reusableDecoder != null) {
|
|
912
|
+
reusableDecoder.getOrCreateDecoder(mime, audioFormat)
|
|
913
|
+
} else {
|
|
914
|
+
val newDecoder = MediaCodec.createDecoderByType(mime)
|
|
915
|
+
newDecoder.configure(audioFormat, null, null, 0)
|
|
916
|
+
newDecoder.start()
|
|
917
|
+
newDecoder
|
|
918
|
+
}
|
|
657
919
|
|
|
658
920
|
val bufferInfo = MediaCodec.BufferInfo()
|
|
659
921
|
var isEOS = false
|
|
@@ -706,9 +968,22 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
706
968
|
}
|
|
707
969
|
|
|
708
970
|
} finally {
|
|
709
|
-
decoder
|
|
710
|
-
|
|
971
|
+
// Only stop/release decoder if we created it locally (not reusing)
|
|
972
|
+
if (shouldReleaseDecoder) {
|
|
973
|
+
decoder?.stop()
|
|
974
|
+
decoder?.release()
|
|
975
|
+
}
|
|
711
976
|
extractor.release()
|
|
977
|
+
|
|
978
|
+
// Performance metrics
|
|
979
|
+
val elapsedTime = System.currentTimeMillis() - startTime
|
|
980
|
+
val fileSize = try { File(filePath).length() } catch (e: Exception) { 0L }
|
|
981
|
+
val fileSizeKB = fileSize / 1024
|
|
982
|
+
val decodingSpeedMBps = if (elapsedTime > 0) {
|
|
983
|
+
(fileSize / 1024.0 / 1024.0) / (elapsedTime / 1000.0)
|
|
984
|
+
} else 0.0
|
|
985
|
+
|
|
986
|
+
Log.d("AudioConcat", " ⚡ Decoded ${fileSizeKB}KB in ${elapsedTime}ms (${String.format("%.2f", decodingSpeedMBps)} MB/s)")
|
|
712
987
|
}
|
|
713
988
|
}
|
|
714
989
|
|
|
@@ -833,6 +1108,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
833
1108
|
val latch = CountDownLatch(optimizedFiles.size)
|
|
834
1109
|
val sequenceCounter = AtomicInteger(0)
|
|
835
1110
|
|
|
1111
|
+
// Create decoder pool for reuse across threads
|
|
1112
|
+
val decoderPool = DecoderPool()
|
|
1113
|
+
Log.d("AudioConcat", "Created decoder pool for parallel processing ($numThreads threads)")
|
|
1114
|
+
|
|
836
1115
|
try {
|
|
837
1116
|
// Submit decode tasks for unique files only
|
|
838
1117
|
optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
|
|
@@ -842,7 +1121,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
842
1121
|
sequenceCounter.addAndGet(1000000)
|
|
843
1122
|
|
|
844
1123
|
Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
|
|
845
|
-
parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
|
|
1124
|
+
parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache, decoderPool)
|
|
846
1125
|
|
|
847
1126
|
// Mark end with duplicate count
|
|
848
1127
|
val repeatCount = consecutiveDuplicates[optIndex] ?: 1
|
|
@@ -900,6 +1179,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
900
1179
|
Log.d("AudioConcat", "All parallel decode tasks completed")
|
|
901
1180
|
|
|
902
1181
|
} finally {
|
|
1182
|
+
decoderPool.releaseAll()
|
|
903
1183
|
executor.shutdown()
|
|
904
1184
|
}
|
|
905
1185
|
}
|
|
@@ -1032,17 +1312,24 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1032
1312
|
}
|
|
1033
1313
|
|
|
1034
1314
|
override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
|
|
1315
|
+
val totalStartTime = System.currentTimeMillis()
|
|
1316
|
+
Log.d("AudioConcat", "========== Audio Concat Started ==========")
|
|
1317
|
+
|
|
1035
1318
|
try {
|
|
1036
1319
|
if (data.size() == 0) {
|
|
1037
1320
|
promise.reject("EMPTY_DATA", "Data array is empty")
|
|
1038
1321
|
return
|
|
1039
1322
|
}
|
|
1040
1323
|
|
|
1324
|
+
// Parse data
|
|
1325
|
+
val parseStartTime = System.currentTimeMillis()
|
|
1041
1326
|
val parsedData = parseAudioData(data)
|
|
1042
|
-
|
|
1327
|
+
val parseTime = System.currentTimeMillis() - parseStartTime
|
|
1328
|
+
Log.d("AudioConcat", "✓ Parsed ${parsedData.size} items in ${parseTime}ms")
|
|
1043
1329
|
Log.d("AudioConcat", "Output: $outputPath")
|
|
1044
1330
|
|
|
1045
1331
|
// Get audio config from first audio file
|
|
1332
|
+
val configStartTime = System.currentTimeMillis()
|
|
1046
1333
|
var audioConfig: AudioConfig? = null
|
|
1047
1334
|
for (item in parsedData) {
|
|
1048
1335
|
if (item is AudioDataOrSilence.AudioFile) {
|
|
@@ -1056,13 +1343,33 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1056
1343
|
return
|
|
1057
1344
|
}
|
|
1058
1345
|
|
|
1059
|
-
|
|
1346
|
+
val configTime = System.currentTimeMillis() - configStartTime
|
|
1347
|
+
|
|
1348
|
+
// Force output sample rate to 24kHz for optimal performance
|
|
1349
|
+
val outputSampleRate = 24000
|
|
1350
|
+
Log.d("AudioConcat", "✓ Extracted audio config in ${configTime}ms: ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
|
|
1351
|
+
Log.d("AudioConcat", "Output sample rate: ${outputSampleRate}Hz (24kHz optimized)")
|
|
1352
|
+
|
|
1353
|
+
// Create modified config with fixed sample rate
|
|
1354
|
+
val outputAudioConfig = AudioConfig(outputSampleRate, audioConfig.channelCount, audioConfig.bitRate)
|
|
1060
1355
|
|
|
1061
1356
|
// Analyze duplicates to determine cache strategy
|
|
1062
|
-
val
|
|
1357
|
+
val analysisStartTime = System.currentTimeMillis()
|
|
1358
|
+
val duplicateAnalysis = analyzeDuplicates(parsedData, outputAudioConfig)
|
|
1359
|
+
val analysisTime = System.currentTimeMillis() - analysisStartTime
|
|
1360
|
+
Log.d("AudioConcat", "✓ Analyzed duplicates in ${analysisTime}ms")
|
|
1361
|
+
|
|
1362
|
+
// Collect all unique audio files for pre-decode caching
|
|
1363
|
+
val allAudioFiles = parsedData.filterIsInstance<AudioDataOrSilence.AudioFile>()
|
|
1364
|
+
.map { it.filePath }
|
|
1365
|
+
.distinct()
|
|
1366
|
+
|
|
1367
|
+
// Merge duplicate files with all unique files for comprehensive caching
|
|
1368
|
+
// This ensures pre-decoded files are always cached, regardless of occurrence count
|
|
1369
|
+
val filesToCache = (duplicateAnalysis.duplicateFiles + allAudioFiles).toSet()
|
|
1063
1370
|
|
|
1064
1371
|
// Create cache instance with intelligent caching strategy
|
|
1065
|
-
val cache = PCMCache(
|
|
1372
|
+
val cache = PCMCache(filesToCache, duplicateAnalysis.duplicateSilence)
|
|
1066
1373
|
|
|
1067
1374
|
// Delete existing output file
|
|
1068
1375
|
val outputFile = File(outputPath)
|
|
@@ -1070,9 +1377,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1070
1377
|
outputFile.delete()
|
|
1071
1378
|
}
|
|
1072
1379
|
|
|
1073
|
-
// Create streaming encoder
|
|
1380
|
+
// Create streaming encoder with fixed 24kHz sample rate
|
|
1074
1381
|
val encoder = StreamingEncoder(
|
|
1075
|
-
|
|
1382
|
+
outputSampleRate,
|
|
1076
1383
|
audioConfig.channelCount,
|
|
1077
1384
|
audioConfig.bitRate,
|
|
1078
1385
|
outputPath
|
|
@@ -1094,11 +1401,160 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1094
1401
|
}
|
|
1095
1402
|
}
|
|
1096
1403
|
|
|
1404
|
+
// PRE-DECODE: Parallel decode all unique audio files to cache before processing
|
|
1405
|
+
val uniqueAudioFiles = audioFileItems.map { it.second }.distinct()
|
|
1406
|
+
val filesToPreDecode = uniqueAudioFiles.filter { cache.getAudioFile(it) == null }
|
|
1407
|
+
|
|
1408
|
+
if (filesToPreDecode.isNotEmpty()) {
|
|
1409
|
+
val preDecodeStartTime = System.currentTimeMillis()
|
|
1410
|
+
val cpuCores = Runtime.getRuntime().availableProcessors()
|
|
1411
|
+
val preDecodeThreads = getOptimalThreadCount(filesToPreDecode.size)
|
|
1412
|
+
|
|
1413
|
+
Log.d("AudioConcat", "→ PRE-DECODE: ${filesToPreDecode.size} unique files using $preDecodeThreads threads (CPU cores: $cpuCores)")
|
|
1414
|
+
|
|
1415
|
+
val preDecodeExecutor = Executors.newFixedThreadPool(preDecodeThreads)
|
|
1416
|
+
val preDecodeLatch = CountDownLatch(filesToPreDecode.size)
|
|
1417
|
+
val preDecodePool = DecoderPool()
|
|
1418
|
+
|
|
1419
|
+
try {
|
|
1420
|
+
filesToPreDecode.forEach { filePath ->
|
|
1421
|
+
preDecodeExecutor.submit {
|
|
1422
|
+
try {
|
|
1423
|
+
Log.d("AudioConcat", " Pre-decoding: $filePath")
|
|
1424
|
+
|
|
1425
|
+
// Decode directly to cache without intermediate queue
|
|
1426
|
+
val extractor = MediaExtractor()
|
|
1427
|
+
var totalBytes = 0L
|
|
1428
|
+
val decodedChunks = mutableListOf<ByteArray>()
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
extractor.setDataSource(filePath)
|
|
1432
|
+
|
|
1433
|
+
var audioTrackIndex = -1
|
|
1434
|
+
var audioFormat: MediaFormat? = null
|
|
1435
|
+
|
|
1436
|
+
for (i in 0 until extractor.trackCount) {
|
|
1437
|
+
val format = extractor.getTrackFormat(i)
|
|
1438
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
1439
|
+
if (mime.startsWith("audio/")) {
|
|
1440
|
+
audioTrackIndex = i
|
|
1441
|
+
audioFormat = format
|
|
1442
|
+
break
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (audioTrackIndex == -1 || audioFormat == null) {
|
|
1447
|
+
throw Exception("No audio track found in $filePath")
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
1451
|
+
val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
1452
|
+
|
|
1453
|
+
val needsResampling = sourceSampleRate != outputSampleRate
|
|
1454
|
+
val needsChannelConversion = sourceChannelCount != audioConfig.channelCount
|
|
1455
|
+
|
|
1456
|
+
if (needsResampling || needsChannelConversion) {
|
|
1457
|
+
Log.d("AudioConcat", " Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${outputSampleRate}Hz ${audioConfig.channelCount}ch")
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
extractor.selectTrack(audioTrackIndex)
|
|
1461
|
+
|
|
1462
|
+
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
1463
|
+
val reusableDecoder = preDecodePool.getDecoderForCurrentThread()
|
|
1464
|
+
val decoder = reusableDecoder.getOrCreateDecoder(mime, audioFormat)
|
|
1465
|
+
|
|
1466
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
1467
|
+
var isEOS = false
|
|
1468
|
+
|
|
1469
|
+
while (!isEOS) {
|
|
1470
|
+
// Feed input
|
|
1471
|
+
val inputBufferIndex = decoder.dequeueInputBuffer(1000)
|
|
1472
|
+
if (inputBufferIndex >= 0) {
|
|
1473
|
+
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
|
1474
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
1475
|
+
|
|
1476
|
+
if (sampleSize < 0) {
|
|
1477
|
+
decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
1478
|
+
} else {
|
|
1479
|
+
val presentationTimeUs = extractor.sampleTime
|
|
1480
|
+
decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
|
|
1481
|
+
extractor.advance()
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Get output
|
|
1486
|
+
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
|
|
1487
|
+
if (outputBufferIndex >= 0) {
|
|
1488
|
+
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
|
1489
|
+
|
|
1490
|
+
if (bufferInfo.size > 0) {
|
|
1491
|
+
var pcmData = ByteArray(bufferInfo.size)
|
|
1492
|
+
outputBuffer.get(pcmData)
|
|
1493
|
+
|
|
1494
|
+
// Convert channel count if needed
|
|
1495
|
+
if (needsChannelConversion) {
|
|
1496
|
+
pcmData = convertChannelCount(pcmData, sourceChannelCount, audioConfig.channelCount)
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Resample if needed
|
|
1500
|
+
if (needsResampling) {
|
|
1501
|
+
pcmData = resamplePCM16(pcmData, sourceSampleRate, outputSampleRate, audioConfig.channelCount)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
decodedChunks.add(pcmData)
|
|
1505
|
+
totalBytes += pcmData.size
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
decoder.releaseOutputBuffer(outputBufferIndex, false)
|
|
1509
|
+
|
|
1510
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
1511
|
+
isEOS = true
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Cache the decoded data
|
|
1517
|
+
if (decodedChunks.isNotEmpty()) {
|
|
1518
|
+
cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
} finally {
|
|
1522
|
+
extractor.release()
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
} catch (e: Exception) {
|
|
1526
|
+
Log.e("AudioConcat", "Error pre-decoding $filePath: ${e.message}", e)
|
|
1527
|
+
} finally {
|
|
1528
|
+
preDecodeLatch.countDown()
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Wait for all pre-decoding to complete
|
|
1534
|
+
preDecodeLatch.await()
|
|
1535
|
+
preDecodePool.releaseAll()
|
|
1536
|
+
preDecodeExecutor.shutdown()
|
|
1537
|
+
|
|
1538
|
+
val preDecodeTime = System.currentTimeMillis() - preDecodeStartTime
|
|
1539
|
+
Log.d("AudioConcat", "✓ Pre-decode completed in ${preDecodeTime}ms")
|
|
1540
|
+
|
|
1541
|
+
} catch (e: Exception) {
|
|
1542
|
+
Log.e("AudioConcat", "Error during pre-decode: ${e.message}", e)
|
|
1543
|
+
preDecodePool.releaseAll()
|
|
1544
|
+
preDecodeExecutor.shutdown()
|
|
1545
|
+
}
|
|
1546
|
+
} else {
|
|
1547
|
+
Log.d("AudioConcat", "→ All audio files already cached, skipping pre-decode")
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1097
1550
|
// Decide whether to use parallel or sequential processing
|
|
1098
|
-
|
|
1551
|
+
// Parallel processing is beneficial even with few files due to multi-core CPUs
|
|
1552
|
+
val useParallel = audioFileItems.size >= 3 // Use parallel for 3+ files (was 10)
|
|
1553
|
+
val processingStartTime = System.currentTimeMillis()
|
|
1099
1554
|
|
|
1100
1555
|
if (useParallel) {
|
|
1101
|
-
|
|
1556
|
+
val cpuCores = Runtime.getRuntime().availableProcessors()
|
|
1557
|
+
Log.d("AudioConcat", "→ Using PARALLEL processing for ${audioFileItems.size} audio files (CPU cores: $cpuCores)")
|
|
1102
1558
|
|
|
1103
1559
|
// Process interleaved patterns optimally
|
|
1104
1560
|
val processedIndices = mutableSetOf<Int>()
|
|
@@ -1121,7 +1577,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1121
1577
|
val latch = CountDownLatch(1)
|
|
1122
1578
|
val seqStart = AtomicInteger(0)
|
|
1123
1579
|
|
|
1124
|
-
parallelDecodeToQueue(filePath, tempQueue, seqStart,
|
|
1580
|
+
parallelDecodeToQueue(filePath, tempQueue, seqStart, outputSampleRate, audioConfig.channelCount, latch, cache)
|
|
1125
1581
|
|
|
1126
1582
|
// Collect chunks
|
|
1127
1583
|
var collecting = true
|
|
@@ -1151,10 +1607,32 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1151
1607
|
}
|
|
1152
1608
|
|
|
1153
1609
|
// Encode the pattern: file -> silence -> file -> silence -> ...
|
|
1154
|
-
|
|
1155
|
-
|
|
1610
|
+
// OPTIMIZATION: Batch chunks to reduce encoder call overhead
|
|
1611
|
+
val patternStartTime = System.currentTimeMillis()
|
|
1612
|
+
|
|
1613
|
+
// Combine all chunks from the file into a single buffer
|
|
1614
|
+
val combinedFileBuffer = if (pcmChunks.size > 10) {
|
|
1615
|
+
val totalSize = pcmChunks.sumOf { it.size }
|
|
1616
|
+
val buffer = ByteArray(totalSize)
|
|
1617
|
+
var offset = 0
|
|
1156
1618
|
pcmChunks.forEach { chunk ->
|
|
1157
|
-
|
|
1619
|
+
System.arraycopy(chunk, 0, buffer, offset, chunk.size)
|
|
1620
|
+
offset += chunk.size
|
|
1621
|
+
}
|
|
1622
|
+
Log.d("AudioConcat", " Batched ${pcmChunks.size} chunks into single buffer (${totalSize / 1024}KB)")
|
|
1623
|
+
buffer
|
|
1624
|
+
} else {
|
|
1625
|
+
null
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
repeat(pattern.repeatCount) { iteration ->
|
|
1629
|
+
// Encode file (batched or individual chunks)
|
|
1630
|
+
if (combinedFileBuffer != null) {
|
|
1631
|
+
encoder.encodePCMChunk(combinedFileBuffer, false)
|
|
1632
|
+
} else {
|
|
1633
|
+
pcmChunks.forEach { chunk ->
|
|
1634
|
+
encoder.encodePCMChunk(chunk, false)
|
|
1635
|
+
}
|
|
1158
1636
|
}
|
|
1159
1637
|
|
|
1160
1638
|
// Encode silence (except after the last file)
|
|
@@ -1163,6 +1641,9 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1163
1641
|
}
|
|
1164
1642
|
}
|
|
1165
1643
|
|
|
1644
|
+
val patternTime = System.currentTimeMillis() - patternStartTime
|
|
1645
|
+
Log.d("AudioConcat", " Encoded interleaved pattern in ${patternTime}ms")
|
|
1646
|
+
|
|
1166
1647
|
// Mark these indices as processed
|
|
1167
1648
|
pattern.indices.forEach { idx ->
|
|
1168
1649
|
processedIndices.add(idx)
|
|
@@ -1198,16 +1679,71 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1198
1679
|
}
|
|
1199
1680
|
|
|
1200
1681
|
if (consecutiveFiles.isNotEmpty()) {
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1682
|
+
// OPTIMIZATION: Fast path for cached files - avoid thread pool overhead
|
|
1683
|
+
val allCached = consecutiveFiles.all { (_, filePath) -> cache.getAudioFile(filePath) != null }
|
|
1684
|
+
|
|
1685
|
+
if (allCached) {
|
|
1686
|
+
// Direct encoding from cache without parallel processing overhead
|
|
1687
|
+
val startTime = System.currentTimeMillis()
|
|
1688
|
+
Log.d("AudioConcat", "Fast path: encoding ${consecutiveFiles.size} cached files directly")
|
|
1689
|
+
|
|
1690
|
+
consecutiveFiles.forEach { (itemIdx, filePath) ->
|
|
1691
|
+
val cachedData = cache.getAudioFile(filePath)!!
|
|
1692
|
+
val chunkCount = cachedData.chunks.size
|
|
1693
|
+
|
|
1694
|
+
Log.d("AudioConcat", " File[$itemIdx]: ${cachedData.totalBytes / 1024}KB in $chunkCount chunks")
|
|
1695
|
+
|
|
1696
|
+
val encodeStartTime = System.currentTimeMillis()
|
|
1697
|
+
|
|
1698
|
+
// OPTIMIZATION: Batch all chunks into single buffer to reduce encoder call overhead
|
|
1699
|
+
// Instead of 300+ calls at ~2.5ms each, make 1 call
|
|
1700
|
+
if (chunkCount > 10) {
|
|
1701
|
+
// Many small chunks - combine into single buffer for massive speedup
|
|
1702
|
+
val batchStartTime = System.currentTimeMillis()
|
|
1703
|
+
val combinedBuffer = ByteArray(cachedData.totalBytes.toInt())
|
|
1704
|
+
var offset = 0
|
|
1705
|
+
|
|
1706
|
+
cachedData.chunks.forEach { chunk ->
|
|
1707
|
+
System.arraycopy(chunk, 0, combinedBuffer, offset, chunk.size)
|
|
1708
|
+
offset += chunk.size
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
val batchTime = System.currentTimeMillis() - batchStartTime
|
|
1712
|
+
Log.d("AudioConcat", " Batched $chunkCount chunks in ${batchTime}ms")
|
|
1713
|
+
|
|
1714
|
+
// Single encoder call instead of 300+
|
|
1715
|
+
encoder.encodePCMChunk(combinedBuffer, false)
|
|
1716
|
+
} else {
|
|
1717
|
+
// Few chunks - encode directly (rare case)
|
|
1718
|
+
cachedData.chunks.forEach { chunk ->
|
|
1719
|
+
encoder.encodePCMChunk(chunk, false)
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
val encodeTime = System.currentTimeMillis() - encodeStartTime
|
|
1724
|
+
val throughputMBps = if (encodeTime > 0) {
|
|
1725
|
+
(cachedData.totalBytes / 1024.0 / 1024.0) / (encodeTime / 1000.0)
|
|
1726
|
+
} else 0.0
|
|
1727
|
+
|
|
1728
|
+
Log.d("AudioConcat", " Encoded in ${encodeTime}ms (${String.format("%.2f", throughputMBps)} MB/s)")
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
val elapsedTime = System.currentTimeMillis() - startTime
|
|
1732
|
+
val totalKB = consecutiveFiles.sumOf { (_, filePath) -> cache.getAudioFile(filePath)!!.totalBytes } / 1024
|
|
1733
|
+
Log.d("AudioConcat", " ⚡ Encoded ${consecutiveFiles.size} cached files (${totalKB}KB) in ${elapsedTime}ms")
|
|
1734
|
+
} else {
|
|
1735
|
+
// Standard parallel processing for non-cached files
|
|
1736
|
+
val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
|
|
1737
|
+
Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
|
|
1738
|
+
parallelProcessAudioFiles(
|
|
1739
|
+
consecutiveFiles,
|
|
1740
|
+
encoder,
|
|
1741
|
+
outputSampleRate,
|
|
1742
|
+
audioConfig.channelCount,
|
|
1743
|
+
cache,
|
|
1744
|
+
numThreads = optimalThreads
|
|
1745
|
+
)
|
|
1746
|
+
}
|
|
1211
1747
|
audioFileIdx = currentIdx
|
|
1212
1748
|
}
|
|
1213
1749
|
}
|
|
@@ -1218,7 +1754,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1218
1754
|
streamEncodeSilence(
|
|
1219
1755
|
durationMs,
|
|
1220
1756
|
encoder,
|
|
1221
|
-
|
|
1757
|
+
outputSampleRate,
|
|
1222
1758
|
audioConfig.channelCount,
|
|
1223
1759
|
cache
|
|
1224
1760
|
)
|
|
@@ -1226,47 +1762,66 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
1226
1762
|
}
|
|
1227
1763
|
}
|
|
1228
1764
|
} else {
|
|
1229
|
-
Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
|
|
1765
|
+
Log.d("AudioConcat", "→ Using sequential processing for ${audioFileItems.size} audio files")
|
|
1230
1766
|
|
|
1231
|
-
//
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
is AudioDataOrSilence.AudioFile -> {
|
|
1235
|
-
val filePath = item.filePath
|
|
1236
|
-
Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
|
|
1767
|
+
// Create a reusable decoder for sequential processing
|
|
1768
|
+
val reusableDecoder = ReusableDecoder()
|
|
1769
|
+
Log.d("AudioConcat", "Created reusable decoder for sequential processing")
|
|
1237
1770
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1771
|
+
try {
|
|
1772
|
+
// Process each item sequentially (with decoder reuse)
|
|
1773
|
+
for ((index, item) in parsedData.withIndex()) {
|
|
1774
|
+
when (item) {
|
|
1775
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
1776
|
+
val filePath = item.filePath
|
|
1777
|
+
Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
|
|
1778
|
+
|
|
1779
|
+
val isLastFile = (index == parsedData.size - 1)
|
|
1780
|
+
streamDecodeAudioFile(
|
|
1781
|
+
filePath,
|
|
1782
|
+
encoder,
|
|
1783
|
+
isLastFile,
|
|
1784
|
+
outputSampleRate,
|
|
1785
|
+
audioConfig.channelCount,
|
|
1786
|
+
reusableDecoder
|
|
1787
|
+
)
|
|
1788
|
+
}
|
|
1247
1789
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1790
|
+
is AudioDataOrSilence.Silence -> {
|
|
1791
|
+
val durationMs = item.durationMs
|
|
1792
|
+
Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
|
|
1251
1793
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1794
|
+
streamEncodeSilence(
|
|
1795
|
+
durationMs,
|
|
1796
|
+
encoder,
|
|
1797
|
+
outputSampleRate,
|
|
1798
|
+
audioConfig.channelCount,
|
|
1799
|
+
cache
|
|
1800
|
+
)
|
|
1801
|
+
}
|
|
1259
1802
|
}
|
|
1260
1803
|
}
|
|
1804
|
+
} finally {
|
|
1805
|
+
// Release the reusable decoder when done
|
|
1806
|
+
reusableDecoder.release()
|
|
1807
|
+
Log.d("AudioConcat", "Released reusable decoder")
|
|
1261
1808
|
}
|
|
1262
1809
|
}
|
|
1263
1810
|
|
|
1811
|
+
val processingTime = System.currentTimeMillis() - processingStartTime
|
|
1812
|
+
Log.d("AudioConcat", "✓ Processing completed in ${processingTime}ms")
|
|
1813
|
+
|
|
1264
1814
|
// Finish encoding
|
|
1815
|
+
val encodingFinishStartTime = System.currentTimeMillis()
|
|
1265
1816
|
encoder.finish()
|
|
1817
|
+
val encodingFinishTime = System.currentTimeMillis() - encodingFinishStartTime
|
|
1818
|
+
Log.d("AudioConcat", "✓ Encoding finalized in ${encodingFinishTime}ms")
|
|
1266
1819
|
|
|
1267
1820
|
// Log cache statistics
|
|
1268
1821
|
Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
|
|
1269
1822
|
|
|
1823
|
+
val totalTime = System.currentTimeMillis() - totalStartTime
|
|
1824
|
+
Log.d("AudioConcat", "========== Total Time: ${totalTime}ms (${totalTime / 1000.0}s) ==========")
|
|
1270
1825
|
Log.d("AudioConcat", "Successfully merged audio to $outputPath")
|
|
1271
1826
|
promise.resolve(outputPath)
|
|
1272
1827
|
|