react-native-audio-concat 0.7.0 → 0.8.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.
@@ -5,1290 +5,20 @@ import com.facebook.react.bridge.Promise
5
5
  import com.facebook.react.bridge.ReadableArray
6
6
  import com.facebook.react.bridge.ReadableMap
7
7
  import com.facebook.react.module.annotations.ReactModule
8
- import android.media.MediaCodec
9
- import android.media.MediaCodecInfo
10
- import android.media.MediaCodecList
11
- import android.media.MediaExtractor
12
- import android.media.MediaFormat
13
- import android.media.MediaMuxer
8
+ import com.arthenica.ffmpegkit.FFmpegKit
9
+ import com.arthenica.ffmpegkit.ReturnCode
14
10
  import java.io.File
15
- import java.nio.ByteBuffer
16
11
  import android.util.Log
17
- import java.util.concurrent.Executors
18
- import java.util.concurrent.BlockingQueue
19
- import java.util.concurrent.LinkedBlockingQueue
20
- import java.util.concurrent.CountDownLatch
21
- import java.util.concurrent.atomic.AtomicInteger
22
- import java.util.concurrent.ConcurrentHashMap
23
12
 
24
13
  @ReactModule(name = AudioConcatModule.NAME)
25
14
  class AudioConcatModule(reactContext: ReactApplicationContext) :
26
15
  NativeAudioConcatSpec(reactContext) {
27
16
 
28
- private data class AudioConfig(
29
- val sampleRate: Int,
30
- val channelCount: Int,
31
- val bitRate: Int
32
- )
33
-
34
17
  private sealed class AudioDataOrSilence {
35
18
  data class AudioFile(val filePath: String) : AudioDataOrSilence()
36
19
  data class Silence(val durationMs: Double) : AudioDataOrSilence()
37
20
  }
38
21
 
39
- private data class PCMChunk(
40
- val data: ByteArray,
41
- val sequenceNumber: Int,
42
- val isEndOfStream: Boolean = false
43
- ) {
44
- companion object {
45
- fun endOfStream(sequenceNumber: Int) = PCMChunk(ByteArray(0), sequenceNumber, true)
46
- }
47
- }
48
-
49
- // Cache for decoded PCM data
50
- private data class CachedPCMData(
51
- val chunks: List<ByteArray>,
52
- val totalBytes: Long
53
- )
54
-
55
- private data class SilenceCacheKey(
56
- val durationMs: Double,
57
- val sampleRate: Int,
58
- val channelCount: Int
59
- )
60
-
61
- // Buffer pool for silence generation to reduce memory allocations
62
- private object SilenceBufferPool {
63
- private val pool = ConcurrentHashMap<Int, ByteArray>()
64
- private val standardSizes = listOf(4096, 8192, 16384, 32768, 65536, 131072)
65
-
66
- init {
67
- // Pre-allocate common silence buffer sizes
68
- standardSizes.forEach { size ->
69
- pool[size] = ByteArray(size)
70
- }
71
- Log.d("AudioConcat", "SilenceBufferPool initialized with ${standardSizes.size} standard sizes")
72
- }
73
-
74
- fun getBuffer(requestedSize: Int): ByteArray {
75
- // Find the smallest standard size that fits the request
76
- val standardSize = standardSizes.firstOrNull { it >= requestedSize }
77
-
78
- return if (standardSize != null) {
79
- // Return pooled buffer (already zeroed)
80
- pool.getOrPut(standardSize) { ByteArray(standardSize) }
81
- } else {
82
- // Size too large for pool, create new buffer
83
- ByteArray(requestedSize)
84
- }
85
- }
86
-
87
- fun clear() {
88
- pool.clear()
89
- Log.d("AudioConcat", "SilenceBufferPool cleared")
90
- }
91
- }
92
-
93
- private class PCMCache(
94
- private val shouldCacheFile: Set<String>,
95
- private val shouldCacheSilence: Set<SilenceCacheKey>
96
- ) {
97
- private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
98
- private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
99
- private var currentCacheSizeBytes = 0L
100
-
101
- // Dynamic cache size based on available memory
102
- private val maxCacheSizeBytes: Long
103
- get() {
104
- val runtime = Runtime.getRuntime()
105
- val maxMemory = runtime.maxMemory()
106
- val usedMemory = runtime.totalMemory() - runtime.freeMemory()
107
- val availableMemory = maxMemory - usedMemory
108
-
109
- // Use 20% of available memory for cache, but constrain between 50MB and 200MB
110
- val dynamicCacheMB = (availableMemory / (1024 * 1024) * 0.2).toLong()
111
- val cacheMB = dynamicCacheMB.coerceIn(50, 200)
112
-
113
- return cacheMB * 1024 * 1024
114
- }
115
-
116
- fun getAudioFile(filePath: String): CachedPCMData? {
117
- return audioFileCache[filePath]
118
- }
119
-
120
- fun putAudioFile(filePath: String, data: CachedPCMData) {
121
- // Only cache if this file appears multiple times
122
- if (!shouldCacheFile.contains(filePath)) {
123
- return
124
- }
125
-
126
- // Check cache size limit (dynamic)
127
- if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeBytes) {
128
- val maxCacheMB = maxCacheSizeBytes / (1024 * 1024)
129
- Log.d("AudioConcat", "Cache full ($maxCacheMB MB), not caching: $filePath")
130
- return
131
- }
132
-
133
- audioFileCache[filePath] = data
134
- currentCacheSizeBytes += data.totalBytes
135
- Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB, total: ${currentCacheSizeBytes / 1024}KB)")
136
- }
137
-
138
- fun getSilence(key: SilenceCacheKey): ByteArray? {
139
- return silenceCache[key]
140
- }
141
-
142
- fun putSilence(key: SilenceCacheKey, data: ByteArray) {
143
- // Only cache if this silence pattern appears multiple times
144
- if (!shouldCacheSilence.contains(key)) {
145
- return
146
- }
147
-
148
- silenceCache[key] = data
149
- Log.d("AudioConcat", "Cached silence: ${key.durationMs}ms")
150
- }
151
-
152
- fun clear() {
153
- audioFileCache.clear()
154
- silenceCache.clear()
155
- currentCacheSizeBytes = 0
156
- Log.d("AudioConcat", "Cache cleared")
157
- }
158
-
159
- fun getStats(): String {
160
- return "Audio files: ${audioFileCache.size}, Silence patterns: ${silenceCache.size}, Size: ${currentCacheSizeBytes / 1024}KB"
161
- }
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
-
307
- private fun extractAudioConfig(filePath: String): AudioConfig {
308
- val extractor = MediaExtractor()
309
- try {
310
- extractor.setDataSource(filePath)
311
- for (i in 0 until extractor.trackCount) {
312
- val format = extractor.getTrackFormat(i)
313
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
314
- if (mime.startsWith("audio/")) {
315
- val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
316
- val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
317
- val bitRate = if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
318
- format.getInteger(MediaFormat.KEY_BIT_RATE)
319
- } else {
320
- 128000 // Default 128kbps
321
- }
322
- return AudioConfig(sampleRate, channelCount, bitRate)
323
- }
324
- }
325
- throw Exception("No audio track found in $filePath")
326
- } finally {
327
- extractor.release()
328
- }
329
- }
330
-
331
- private class StreamingEncoder(
332
- sampleRate: Int,
333
- channelCount: Int,
334
- bitRate: Int,
335
- outputPath: String
336
- ) {
337
- private val encoder: MediaCodec
338
- private val muxer: MediaMuxer
339
- private var audioTrackIndex = -1
340
- private var muxerStarted = false
341
- private val bufferInfo = MediaCodec.BufferInfo()
342
- private var totalPresentationTimeUs = 0L
343
- private val sampleRate: Int
344
- private val channelCount: Int
345
- private val maxChunkSize: Int
346
-
347
- // Performance tracking
348
- private var totalBufferWaitTimeMs = 0L
349
- private var bufferWaitCount = 0
350
-
351
- init {
352
- this.sampleRate = sampleRate
353
- this.channelCount = channelCount
354
-
355
- val outputFormat = MediaFormat.createAudioFormat(
356
- MediaFormat.MIMETYPE_AUDIO_AAC,
357
- sampleRate,
358
- channelCount
359
- )
360
- outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
361
- outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
362
-
363
- // Optimized buffer size based on audio parameters
364
- // Target: ~1024 samples per frame for optimal AAC encoding
365
- val samplesPerFrame = 1024
366
- val bytesPerSample = channelCount * 2 // 16-bit PCM
367
- val optimalBufferSize = samplesPerFrame * bytesPerSample
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)
371
- outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
372
-
373
- // Store for use in encodePCMChunk
374
- this.maxChunkSize = bufferSize
375
-
376
- Log.d("AudioConcat", "Encoder buffer size: $bufferSize bytes (${samplesPerFrame} samples, ${sampleRate}Hz, ${channelCount}ch)")
377
-
378
- encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
379
- encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
380
- encoder.start()
381
-
382
- muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
383
- }
384
-
385
- fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
386
- // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
387
- var offset = 0
388
- var buffersQueued = 0 // Track queued buffers for batch draining
389
-
390
- while (offset < pcmData.size) {
391
- val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
392
- val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
393
-
394
- // Feed PCM data chunk to encoder (reduced timeout for better throughput)
395
- val bufferWaitStart = System.currentTimeMillis()
396
- val inputBufferIndex = encoder.dequeueInputBuffer(1000)
397
- val bufferWaitTime = System.currentTimeMillis() - bufferWaitStart
398
-
399
- if (inputBufferIndex >= 0) {
400
- if (bufferWaitTime > 5) {
401
- totalBufferWaitTimeMs += bufferWaitTime
402
- bufferWaitCount++
403
- }
404
-
405
- val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
406
- val bufferCapacity = inputBuffer.capacity()
407
-
408
- // Ensure chunk fits in buffer
409
- val actualChunkSize = minOf(chunkSize, bufferCapacity)
410
-
411
- inputBuffer.clear()
412
- inputBuffer.put(pcmData, offset, actualChunkSize)
413
-
414
- val presentationTimeUs = totalPresentationTimeUs
415
- totalPresentationTimeUs += (actualChunkSize.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
416
-
417
- val flags = if (isLastChunk) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
418
- encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
419
-
420
- offset += actualChunkSize
421
- buffersQueued++
422
- } else {
423
- totalBufferWaitTimeMs += bufferWaitTime
424
- bufferWaitCount++
425
- // Buffer not available, drain first
426
- drainEncoder(false)
427
- buffersQueued = 0 // Reset counter after forced drain
428
- }
429
-
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) {
433
- drainEncoder(false)
434
- buffersQueued = 0
435
- }
436
- }
437
-
438
- // Final drain if last chunk
439
- if (isLast) {
440
- drainEncoder(true)
441
- }
442
-
443
- return true
444
- }
445
-
446
- private fun drainEncoder(endOfStream: Boolean) {
447
- while (true) {
448
- // Use shorter timeout for better responsiveness
449
- val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 1000 else 0)
450
-
451
- when (outputBufferIndex) {
452
- MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
453
- if (muxerStarted) {
454
- throw RuntimeException("Format changed twice")
455
- }
456
- val newFormat = encoder.outputFormat
457
- audioTrackIndex = muxer.addTrack(newFormat)
458
- muxer.start()
459
- muxerStarted = true
460
- Log.d("AudioConcat", "Encoder started, format: $newFormat")
461
- }
462
- MediaCodec.INFO_TRY_AGAIN_LATER -> {
463
- if (!endOfStream) {
464
- break
465
- }
466
- // Continue draining when end of stream
467
- }
468
- else -> {
469
- if (outputBufferIndex >= 0) {
470
- val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
471
-
472
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
473
- bufferInfo.size = 0
474
- }
475
-
476
- if (bufferInfo.size > 0 && muxerStarted) {
477
- outputBuffer.position(bufferInfo.offset)
478
- outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
479
- muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
480
- }
481
-
482
- encoder.releaseOutputBuffer(outputBufferIndex, false)
483
-
484
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
485
- break
486
- }
487
- }
488
- }
489
- }
490
- }
491
- }
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
-
501
- fun finish() {
502
- // Signal end of stream (reduced timeout)
503
- val inputBufferIndex = encoder.dequeueInputBuffer(1000)
504
- if (inputBufferIndex >= 0) {
505
- encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
506
- }
507
-
508
- // Drain remaining data
509
- drainEncoder(true)
510
-
511
- // Log encoder performance stats
512
- Log.d("AudioConcat", "Encoder stats: ${getEncoderStats()}")
513
-
514
- encoder.stop()
515
- encoder.release()
516
-
517
- if (muxerStarted) {
518
- muxer.stop()
519
- }
520
- muxer.release()
521
- }
522
- }
523
-
524
- private fun resamplePCM16(
525
- input: ByteArray,
526
- inputSampleRate: Int,
527
- outputSampleRate: Int,
528
- channelCount: Int
529
- ): ByteArray {
530
- if (inputSampleRate == outputSampleRate) {
531
- return input
532
- }
533
-
534
- val startTime = System.currentTimeMillis()
535
- val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
536
- val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
537
- val output = ByteArray(outputSampleCount * 2 * channelCount)
538
-
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
- }
546
-
547
- // Use floating-point for better accuracy than fixed-point
548
- val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
549
-
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)
554
-
555
- for (ch in 0 until channelCount) {
556
- // Linear interpolation with floating-point precision
557
- val s1 = readSample(srcIndex, ch).toDouble()
558
- val s2 = readSample(srcIndex + 1, ch).toDouble()
559
-
560
- // Linear interpolation: s1 + (s2 - s1) * fraction
561
- val interpolated = s1 + (s2 - s1) * fraction
562
-
563
- // Clamp to 16-bit range
564
- val clamped = interpolated.toInt().coerceIn(-32768, 32767)
565
-
566
- // Write to output (little-endian)
567
- val outIdx = (i * channelCount + ch) * 2
568
- output[outIdx] = (clamped and 0xFF).toByte()
569
- output[outIdx + 1] = (clamped shr 8).toByte()
570
- }
571
- }
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
-
576
- return output
577
- }
578
-
579
- private fun convertChannelCount(
580
- input: ByteArray,
581
- inputChannels: Int,
582
- outputChannels: Int
583
- ): ByteArray {
584
- if (inputChannels == outputChannels) {
585
- return input
586
- }
587
-
588
- val sampleCount = input.size / (2 * inputChannels)
589
- val output = ByteArray(sampleCount * 2 * outputChannels)
590
-
591
- when {
592
- inputChannels == 1 && outputChannels == 2 -> {
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) {
633
- val srcIdx = i * 2
634
- val dstIdx = i * 4
635
- output[dstIdx] = input[srcIdx]
636
- output[dstIdx + 1] = input[srcIdx + 1]
637
- output[dstIdx + 2] = input[srcIdx]
638
- output[dstIdx + 3] = input[srcIdx + 1]
639
- i++
640
- }
641
- }
642
- inputChannels == 2 && outputChannels == 1 -> {
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) {
686
- val srcIdx = i * 4
687
- val dstIdx = i * 2
688
- val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
689
- val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
690
- val avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
691
- output[dstIdx] = (avg and 0xFF).toByte()
692
- output[dstIdx + 1] = (avg shr 8).toByte()
693
- i++
694
- }
695
- }
696
- else -> {
697
- // Fallback: just take the first channel
698
- for (i in 0 until sampleCount) {
699
- val srcIdx = i * 2 * inputChannels
700
- val dstIdx = i * 2 * outputChannels
701
- for (ch in 0 until minOf(inputChannels, outputChannels)) {
702
- output[dstIdx + ch * 2] = input[srcIdx + ch * 2]
703
- output[dstIdx + ch * 2 + 1] = input[srcIdx + ch * 2 + 1]
704
- }
705
- }
706
- }
707
- }
708
-
709
- return output
710
- }
711
-
712
- private fun parallelDecodeToQueue(
713
- filePath: String,
714
- queue: BlockingQueue<PCMChunk>,
715
- sequenceStart: AtomicInteger,
716
- targetSampleRate: Int,
717
- targetChannelCount: Int,
718
- latch: CountDownLatch,
719
- cache: PCMCache,
720
- decoderPool: DecoderPool? = null
721
- ) {
722
- try {
723
- // Check cache first
724
- val cachedData = cache.getAudioFile(filePath)
725
- if (cachedData != null) {
726
- Log.d("AudioConcat", "Using cached PCM for: $filePath")
727
- // Put cached chunks to queue
728
- for (chunk in cachedData.chunks) {
729
- val seqNum = sequenceStart.getAndIncrement()
730
- queue.put(PCMChunk(chunk, seqNum))
731
- }
732
- latch.countDown()
733
- return
734
- }
735
-
736
- val extractor = MediaExtractor()
737
- var decoder: MediaCodec? = null
738
- val decodedChunks = mutableListOf<ByteArray>()
739
- var totalBytes = 0L
740
- val shouldReleaseDecoder = (decoderPool == null) // Only release if not using pool
741
-
742
- try {
743
- extractor.setDataSource(filePath)
744
-
745
- var audioTrackIndex = -1
746
- var audioFormat: MediaFormat? = null
747
-
748
- for (i in 0 until extractor.trackCount) {
749
- val format = extractor.getTrackFormat(i)
750
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
751
- if (mime.startsWith("audio/")) {
752
- audioTrackIndex = i
753
- audioFormat = format
754
- break
755
- }
756
- }
757
-
758
- if (audioTrackIndex == -1 || audioFormat == null) {
759
- throw Exception("No audio track found in $filePath")
760
- }
761
-
762
- val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
763
- val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
764
-
765
- val needsResampling = sourceSampleRate != targetSampleRate
766
- val needsChannelConversion = sourceChannelCount != targetChannelCount
767
-
768
- if (needsResampling || needsChannelConversion) {
769
- Log.d("AudioConcat", "Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
770
- }
771
-
772
- extractor.selectTrack(audioTrackIndex)
773
-
774
- val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
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
- }
786
-
787
- val bufferInfo = MediaCodec.BufferInfo()
788
- var isEOS = false
789
-
790
- while (!isEOS) {
791
- // Feed input to decoder (reduced timeout for faster processing)
792
- val inputBufferIndex = decoder.dequeueInputBuffer(1000)
793
- if (inputBufferIndex >= 0) {
794
- val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
795
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
796
-
797
- if (sampleSize < 0) {
798
- decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
799
- } else {
800
- val presentationTimeUs = extractor.sampleTime
801
- decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
802
- extractor.advance()
803
- }
804
- }
805
-
806
- // Get PCM output from decoder and put to queue (reduced timeout)
807
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
808
- if (outputBufferIndex >= 0) {
809
- val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
810
-
811
- if (bufferInfo.size > 0) {
812
- var pcmData = ByteArray(bufferInfo.size)
813
- outputBuffer.get(pcmData)
814
-
815
- // Convert channel count if needed
816
- if (needsChannelConversion) {
817
- pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
818
- }
819
-
820
- // Resample if needed
821
- if (needsResampling) {
822
- pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
823
- }
824
-
825
- // Optimization: avoid unnecessary clone() - store original for caching
826
- decodedChunks.add(pcmData)
827
- totalBytes += pcmData.size
828
-
829
- // Put a clone to queue (queue might modify it)
830
- val seqNum = sequenceStart.getAndIncrement()
831
- queue.put(PCMChunk(pcmData.clone(), seqNum))
832
- }
833
-
834
- decoder.releaseOutputBuffer(outputBufferIndex, false)
835
-
836
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
837
- isEOS = true
838
- }
839
- }
840
- }
841
-
842
- // Cache the decoded data
843
- if (decodedChunks.isNotEmpty()) {
844
- cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
845
- }
846
-
847
- } finally {
848
- // Only stop/release decoder if not using pool
849
- if (shouldReleaseDecoder) {
850
- decoder?.stop()
851
- decoder?.release()
852
- }
853
- extractor.release()
854
- }
855
- } catch (e: Exception) {
856
- Log.e("AudioConcat", "Error in parallel decode: ${e.message}", e)
857
- throw e
858
- } finally {
859
- latch.countDown()
860
- }
861
- }
862
-
863
- private fun streamDecodeAudioFile(
864
- filePath: String,
865
- encoder: StreamingEncoder,
866
- isLastFile: Boolean,
867
- targetSampleRate: Int,
868
- targetChannelCount: Int,
869
- reusableDecoder: ReusableDecoder? = null
870
- ) {
871
- val startTime = System.currentTimeMillis()
872
- val extractor = MediaExtractor()
873
- var decoder: MediaCodec? = null
874
- val shouldReleaseDecoder = (reusableDecoder == null) // Only release if not reusing
875
-
876
- try {
877
- extractor.setDataSource(filePath)
878
-
879
- var audioTrackIndex = -1
880
- var audioFormat: MediaFormat? = null
881
-
882
- for (i in 0 until extractor.trackCount) {
883
- val format = extractor.getTrackFormat(i)
884
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
885
- if (mime.startsWith("audio/")) {
886
- audioTrackIndex = i
887
- audioFormat = format
888
- break
889
- }
890
- }
891
-
892
- if (audioTrackIndex == -1 || audioFormat == null) {
893
- throw Exception("No audio track found in $filePath")
894
- }
895
-
896
- val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
897
- val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
898
-
899
- val needsResampling = sourceSampleRate != targetSampleRate
900
- val needsChannelConversion = sourceChannelCount != targetChannelCount
901
-
902
- if (needsResampling || needsChannelConversion) {
903
- Log.d("AudioConcat", "File: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
904
- }
905
-
906
- extractor.selectTrack(audioTrackIndex)
907
-
908
- val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
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
- }
919
-
920
- val bufferInfo = MediaCodec.BufferInfo()
921
- var isEOS = false
922
-
923
- while (!isEOS) {
924
- // Feed input to decoder (reduced timeout for faster processing)
925
- val inputBufferIndex = decoder.dequeueInputBuffer(1000)
926
- if (inputBufferIndex >= 0) {
927
- val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
928
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
929
-
930
- if (sampleSize < 0) {
931
- decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
932
- } else {
933
- val presentationTimeUs = extractor.sampleTime
934
- decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
935
- extractor.advance()
936
- }
937
- }
938
-
939
- // Get PCM output from decoder and feed to encoder (reduced timeout)
940
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
941
- if (outputBufferIndex >= 0) {
942
- val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
943
-
944
- if (bufferInfo.size > 0) {
945
- var pcmData = ByteArray(bufferInfo.size)
946
- outputBuffer.get(pcmData)
947
-
948
- // Convert channel count if needed
949
- if (needsChannelConversion) {
950
- pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
951
- }
952
-
953
- // Resample if needed
954
- if (needsResampling) {
955
- pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
956
- }
957
-
958
- // Stream to encoder
959
- encoder.encodePCMChunk(pcmData, false)
960
- }
961
-
962
- decoder.releaseOutputBuffer(outputBufferIndex, false)
963
-
964
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
965
- isEOS = true
966
- }
967
- }
968
- }
969
-
970
- } finally {
971
- // Only stop/release decoder if we created it locally (not reusing)
972
- if (shouldReleaseDecoder) {
973
- decoder?.stop()
974
- decoder?.release()
975
- }
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)")
987
- }
988
- }
989
-
990
- private fun streamEncodeSilence(
991
- durationMs: Double,
992
- encoder: StreamingEncoder,
993
- sampleRate: Int,
994
- channelCount: Int,
995
- cache: PCMCache
996
- ) {
997
- val cacheKey = SilenceCacheKey(durationMs, sampleRate, channelCount)
998
-
999
- // Check cache first
1000
- val cachedSilence = cache.getSilence(cacheKey)
1001
- if (cachedSilence != null) {
1002
- Log.d("AudioConcat", "Using cached silence: ${durationMs}ms")
1003
- encoder.encodePCMChunk(cachedSilence, false)
1004
- return
1005
- }
1006
-
1007
- // Generate silence
1008
- val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
1009
- val bytesPerSample = channelCount * 2 // 16-bit stereo
1010
- val totalBytes = totalSamples * bytesPerSample
1011
-
1012
- // For short silence (< 5 seconds), cache as single chunk
1013
- if (durationMs < 5000) {
1014
- // Use buffer pool to avoid allocation
1015
- val pooledBuffer = SilenceBufferPool.getBuffer(totalBytes)
1016
- val silenceData = if (pooledBuffer.size == totalBytes) {
1017
- pooledBuffer
1018
- } else {
1019
- // Copy only the needed portion
1020
- pooledBuffer.copyOf(totalBytes)
1021
- }
1022
- cache.putSilence(cacheKey, silenceData)
1023
- encoder.encodePCMChunk(silenceData, false)
1024
- } else {
1025
- // For longer silence, process in chunks without caching using pooled buffers
1026
- val chunkSamples = 16384
1027
- var samplesRemaining = totalSamples
1028
-
1029
- while (samplesRemaining > 0) {
1030
- val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
1031
- val chunkBytes = currentChunkSamples * bytesPerSample
1032
-
1033
- // Use pooled buffer for chunk
1034
- val pooledBuffer = SilenceBufferPool.getBuffer(chunkBytes)
1035
- val silenceChunk = if (pooledBuffer.size == chunkBytes) {
1036
- pooledBuffer
1037
- } else {
1038
- pooledBuffer.copyOf(chunkBytes)
1039
- }
1040
-
1041
- encoder.encodePCMChunk(silenceChunk, false)
1042
- samplesRemaining -= currentChunkSamples
1043
- }
1044
- }
1045
- }
1046
-
1047
- private fun getOptimalThreadCount(audioFileCount: Int): Int {
1048
- val cpuCores = Runtime.getRuntime().availableProcessors()
1049
- val optimalThreads = when {
1050
- cpuCores <= 2 -> 2
1051
- cpuCores <= 4 -> 3
1052
- cpuCores <= 8 -> 4
1053
- else -> 6
1054
- }
1055
- // Don't create more threads than files to process
1056
- return optimalThreads.coerceAtMost(audioFileCount)
1057
- }
1058
-
1059
- private fun getOptimalQueueSize(audioFileCount: Int): Int {
1060
- // Dynamic queue size based on number of files to prevent memory waste or blocking
1061
- return when {
1062
- audioFileCount <= 5 -> 20
1063
- audioFileCount <= 20 -> 50
1064
- audioFileCount <= 50 -> 100
1065
- else -> 150
1066
- }
1067
- }
1068
-
1069
- private fun parallelProcessAudioFiles(
1070
- audioFiles: List<Pair<Int, String>>, // (index, filePath)
1071
- encoder: StreamingEncoder,
1072
- targetSampleRate: Int,
1073
- targetChannelCount: Int,
1074
- cache: PCMCache,
1075
- numThreads: Int = 3
1076
- ) {
1077
- if (audioFiles.isEmpty()) return
1078
-
1079
- // Group consecutive duplicate files
1080
- val optimizedFiles = mutableListOf<Pair<Int, String>>()
1081
- val consecutiveDuplicates = mutableMapOf<Int, Int>() // originalIndex -> count
1082
-
1083
- var i = 0
1084
- while (i < audioFiles.size) {
1085
- val (index, filePath) = audioFiles[i]
1086
- var count = 1
1087
-
1088
- // Check for consecutive duplicates
1089
- while (i + count < audioFiles.size && audioFiles[i + count].second == filePath) {
1090
- count++
1091
- }
1092
-
1093
- if (count > 1) {
1094
- Log.d("AudioConcat", "Detected $count consecutive occurrences of: $filePath")
1095
- optimizedFiles.add(Pair(index, filePath))
1096
- consecutiveDuplicates[optimizedFiles.size - 1] = count
1097
- } else {
1098
- optimizedFiles.add(Pair(index, filePath))
1099
- }
1100
-
1101
- i += count
1102
- }
1103
-
1104
- val queueSize = getOptimalQueueSize(optimizedFiles.size)
1105
- val pcmQueue = LinkedBlockingQueue<PCMChunk>(queueSize)
1106
- Log.d("AudioConcat", "Using queue size: $queueSize for ${optimizedFiles.size} files")
1107
- val executor = Executors.newFixedThreadPool(numThreads)
1108
- val latch = CountDownLatch(optimizedFiles.size)
1109
- val sequenceCounter = AtomicInteger(0)
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
-
1115
- try {
1116
- // Submit decode tasks for unique files only
1117
- optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
1118
- executor.submit {
1119
- try {
1120
- val fileSequenceStart = AtomicInteger(sequenceCounter.get())
1121
- sequenceCounter.addAndGet(1000000)
1122
-
1123
- Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
1124
- parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache, decoderPool)
1125
-
1126
- // Mark end with duplicate count
1127
- val repeatCount = consecutiveDuplicates[optIndex] ?: 1
1128
- val endSeqNum = fileSequenceStart.get()
1129
- pcmQueue.put(PCMChunk(ByteArray(0), endSeqNum, true)) // endOfStream marker with repeat count
1130
-
1131
- } catch (e: Exception) {
1132
- Log.e("AudioConcat", "Error decoding file $filePath: ${e.message}", e)
1133
- latch.countDown()
1134
- }
1135
- }
1136
- }
1137
-
1138
- // Consumer thread: encode in order
1139
- var filesCompleted = 0
1140
- var cachedChunks = mutableListOf<ByteArray>()
1141
- var isCollectingChunks = false
1142
-
1143
- while (filesCompleted < optimizedFiles.size) {
1144
- val chunk = pcmQueue.take()
1145
-
1146
- if (chunk.isEndOfStream) {
1147
- val optIndex = filesCompleted
1148
- val repeatCount = consecutiveDuplicates[optIndex] ?: 1
1149
-
1150
- if (repeatCount > 1 && cachedChunks.isNotEmpty()) {
1151
- // Repeat the cached chunks
1152
- Log.d("AudioConcat", "Repeating cached chunks ${repeatCount - 1} more times")
1153
- repeat(repeatCount - 1) {
1154
- cachedChunks.forEach { data ->
1155
- encoder.encodePCMChunk(data, false)
1156
- }
1157
- }
1158
- cachedChunks.clear()
1159
- }
1160
-
1161
- filesCompleted++
1162
- isCollectingChunks = false
1163
- Log.d("AudioConcat", "Completed file $filesCompleted/${optimizedFiles.size}")
1164
- continue
1165
- }
1166
-
1167
- // Encode chunk
1168
- encoder.encodePCMChunk(chunk.data, false)
1169
-
1170
- // Cache chunks for consecutive duplicates
1171
- val optIndex = filesCompleted
1172
- if (consecutiveDuplicates.containsKey(optIndex)) {
1173
- cachedChunks.add(chunk.data.clone())
1174
- }
1175
- }
1176
-
1177
- // Wait for all decode tasks to complete
1178
- latch.await()
1179
- Log.d("AudioConcat", "All parallel decode tasks completed")
1180
-
1181
- } finally {
1182
- decoderPool.releaseAll()
1183
- executor.shutdown()
1184
- }
1185
- }
1186
-
1187
- private data class InterleavedPattern(
1188
- val filePath: String,
1189
- val silenceKey: SilenceCacheKey?,
1190
- val indices: List<Int>, // Indices where this pattern occurs
1191
- val repeatCount: Int
1192
- )
1193
-
1194
- private data class DuplicateAnalysis(
1195
- val duplicateFiles: Set<String>,
1196
- val duplicateSilence: Set<SilenceCacheKey>,
1197
- val fileOccurrences: Map<String, List<Int>>, // filePath -> list of indices
1198
- val silenceOccurrences: Map<SilenceCacheKey, List<Int>>,
1199
- val interleavedPatterns: List<InterleavedPattern>
1200
- )
1201
-
1202
- private fun analyzeDuplicates(
1203
- parsedData: List<AudioDataOrSilence>,
1204
- audioConfig: AudioConfig
1205
- ): DuplicateAnalysis {
1206
- val fileCounts = mutableMapOf<String, MutableList<Int>>()
1207
- val silenceCounts = mutableMapOf<SilenceCacheKey, MutableList<Int>>()
1208
-
1209
- parsedData.forEachIndexed { index, item ->
1210
- when (item) {
1211
- is AudioDataOrSilence.AudioFile -> {
1212
- fileCounts.getOrPut(item.filePath) { mutableListOf() }.add(index)
1213
- }
1214
- is AudioDataOrSilence.Silence -> {
1215
- val key = SilenceCacheKey(item.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
1216
- silenceCounts.getOrPut(key) { mutableListOf() }.add(index)
1217
- }
1218
- }
1219
- }
1220
-
1221
- val duplicateFiles = fileCounts.filter { it.value.size > 1 }.keys.toSet()
1222
- val duplicateSilence = silenceCounts.filter { it.value.size > 1 }.keys.toSet()
1223
-
1224
- // Detect interleaved patterns: file -> silence -> file -> silence -> file
1225
- val interleavedPatterns = mutableListOf<InterleavedPattern>()
1226
-
1227
- var i = 0
1228
- while (i < parsedData.size - 2) {
1229
- if (parsedData[i] is AudioDataOrSilence.AudioFile &&
1230
- parsedData[i + 1] is AudioDataOrSilence.Silence &&
1231
- parsedData[i + 2] is AudioDataOrSilence.AudioFile) {
1232
-
1233
- val file1 = (parsedData[i] as AudioDataOrSilence.AudioFile).filePath
1234
- val silence = parsedData[i + 1] as AudioDataOrSilence.Silence
1235
- val file2 = (parsedData[i + 2] as AudioDataOrSilence.AudioFile).filePath
1236
- val silenceKey = SilenceCacheKey(silence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
1237
-
1238
- // Check if it's the same file with silence separator
1239
- if (file1 == file2) {
1240
- var count = 1
1241
- var currentIndex = i
1242
- val indices = mutableListOf(i)
1243
-
1244
- // Count how many times this pattern repeats
1245
- while (currentIndex + 2 < parsedData.size &&
1246
- parsedData[currentIndex + 2] is AudioDataOrSilence.AudioFile &&
1247
- (parsedData[currentIndex + 2] as AudioDataOrSilence.AudioFile).filePath == file1) {
1248
-
1249
- // Check if there's a silence in between
1250
- if (currentIndex + 3 < parsedData.size &&
1251
- parsedData[currentIndex + 3] is AudioDataOrSilence.Silence) {
1252
- val nextSilence = parsedData[currentIndex + 3] as AudioDataOrSilence.Silence
1253
- val nextSilenceKey = SilenceCacheKey(nextSilence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
1254
-
1255
- if (nextSilenceKey == silenceKey) {
1256
- count++
1257
- currentIndex += 2
1258
- indices.add(currentIndex)
1259
- } else {
1260
- break
1261
- }
1262
- } else {
1263
- // Last file in the pattern (no silence after)
1264
- count++
1265
- indices.add(currentIndex + 2)
1266
- break
1267
- }
1268
- }
1269
-
1270
- if (count >= 2) {
1271
- interleavedPatterns.add(InterleavedPattern(file1, silenceKey, indices, count))
1272
- Log.d("AudioConcat", "Detected interleaved pattern: '$file1' + ${silenceKey.durationMs}ms silence, repeats $count times")
1273
- i = currentIndex + 2 // Skip processed items
1274
- continue
1275
- }
1276
- }
1277
- }
1278
- i++
1279
- }
1280
-
1281
- Log.d("AudioConcat", "Duplicate analysis: ${duplicateFiles.size} files, ${duplicateSilence.size} silence patterns, ${interleavedPatterns.size} interleaved patterns")
1282
- duplicateFiles.forEach { file ->
1283
- Log.d("AudioConcat", " File '$file' appears ${fileCounts[file]?.size} times")
1284
- }
1285
- duplicateSilence.forEach { key ->
1286
- Log.d("AudioConcat", " Silence ${key.durationMs}ms appears ${silenceCounts[key]?.size} times")
1287
- }
1288
-
1289
- return DuplicateAnalysis(duplicateFiles, duplicateSilence, fileCounts, silenceCounts, interleavedPatterns)
1290
- }
1291
-
1292
22
  private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
1293
23
  val result = mutableListOf<AudioDataOrSilence>()
1294
24
  for (i in 0 until data.size()) {
@@ -1307,527 +37,76 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1307
37
  return result
1308
38
  }
1309
39
 
40
+ private fun buildFFmpegCommand(
41
+ parsedData: List<AudioDataOrSilence>,
42
+ outputPath: String
43
+ ): String {
44
+ val filesStr = StringBuilder()
45
+ val filterComplexStr = StringBuilder()
46
+ var inputCount = 0
47
+
48
+ // Build input files string and filter complex string
49
+ for (item in parsedData) {
50
+ when (item) {
51
+ is AudioDataOrSilence.AudioFile -> {
52
+ filesStr.append("-i \"${item.filePath}\" ")
53
+ filterComplexStr.append("[$inputCount:a]")
54
+ inputCount++
55
+ }
56
+ is AudioDataOrSilence.Silence -> {
57
+ val duration = item.durationMs / 1000.0
58
+ filesStr.append("-f lavfi -t $duration -i anullsrc=r=44100:cl=stereo ")
59
+ filterComplexStr.append("[$inputCount:a]")
60
+ inputCount++
61
+ }
62
+ }
63
+ }
64
+
65
+ // Complete the filter complex string
66
+ filterComplexStr.append("concat=n=$inputCount:v=0:a=1[out]")
67
+
68
+ // Build the complete command
69
+ return "-y ${filesStr.toString()}-filter_complex \"${filterComplexStr.toString()}\" -map \"[out]\" \"$outputPath\" -loglevel level+error"
70
+ }
71
+
1310
72
  override fun getName(): String {
1311
73
  return NAME
1312
74
  }
1313
75
 
1314
76
  override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
1315
- val totalStartTime = System.currentTimeMillis()
1316
- Log.d("AudioConcat", "========== Audio Concat Started ==========")
1317
-
1318
77
  try {
1319
78
  if (data.size() == 0) {
1320
79
  promise.reject("EMPTY_DATA", "Data array is empty")
1321
80
  return
1322
81
  }
1323
82
 
1324
- // Parse data
1325
- val parseStartTime = System.currentTimeMillis()
1326
83
  val parsedData = parseAudioData(data)
1327
- val parseTime = System.currentTimeMillis() - parseStartTime
1328
- Log.d("AudioConcat", "✓ Parsed ${parsedData.size} items in ${parseTime}ms")
84
+ Log.d("AudioConcat", "FFmpeg merge of ${parsedData.size} items")
1329
85
  Log.d("AudioConcat", "Output: $outputPath")
1330
86
 
1331
- // Get audio config from first audio file
1332
- val configStartTime = System.currentTimeMillis()
1333
- var audioConfig: AudioConfig? = null
1334
- for (item in parsedData) {
1335
- if (item is AudioDataOrSilence.AudioFile) {
1336
- audioConfig = extractAudioConfig(item.filePath)
1337
- break
1338
- }
1339
- }
1340
-
1341
- if (audioConfig == null) {
1342
- promise.reject("NO_AUDIO_FILES", "No audio files found in data array")
1343
- return
1344
- }
1345
-
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)
1355
-
1356
- // Analyze duplicates to determine cache strategy
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()
1370
-
1371
- // Create cache instance with intelligent caching strategy
1372
- val cache = PCMCache(filesToCache, duplicateAnalysis.duplicateSilence)
1373
-
1374
87
  // Delete existing output file
1375
88
  val outputFile = File(outputPath)
1376
89
  if (outputFile.exists()) {
1377
90
  outputFile.delete()
1378
91
  }
1379
92
 
1380
- // Create streaming encoder with fixed 24kHz sample rate
1381
- val encoder = StreamingEncoder(
1382
- outputSampleRate,
1383
- audioConfig.channelCount,
1384
- audioConfig.bitRate,
1385
- outputPath
1386
- )
1387
-
1388
- try {
1389
- // Separate audio files and other items (silence)
1390
- val audioFileItems = mutableListOf<Pair<Int, String>>()
1391
- val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
1392
-
1393
- for ((index, item) in parsedData.withIndex()) {
1394
- when (item) {
1395
- is AudioDataOrSilence.AudioFile -> {
1396
- audioFileItems.add(Pair(index, item.filePath))
1397
- }
1398
- is AudioDataOrSilence.Silence -> {
1399
- nonAudioItems.add(Pair(index, item))
1400
- }
1401
- }
1402
- }
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)
93
+ // Build FFmpeg command using filter_complex
94
+ val command = buildFFmpegCommand(parsedData, outputPath)
95
+ Log.d("AudioConcat", "FFmpeg command: $command")
1493
96
 
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
-
1550
- // Decide whether to use parallel or sequential processing
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()
1554
-
1555
- if (useParallel) {
1556
- val cpuCores = Runtime.getRuntime().availableProcessors()
1557
- Log.d("AudioConcat", "→ Using PARALLEL processing for ${audioFileItems.size} audio files (CPU cores: $cpuCores)")
1558
-
1559
- // Process interleaved patterns optimally
1560
- val processedIndices = mutableSetOf<Int>()
1561
-
1562
- // First, handle all interleaved patterns
1563
- duplicateAnalysis.interleavedPatterns.forEach { pattern ->
1564
- Log.d("AudioConcat", "Processing interleaved pattern: ${pattern.filePath}, ${pattern.repeatCount} repetitions")
1565
-
1566
- // Decode the file once
1567
- val filePath = pattern.filePath
1568
- val cachedData = cache.getAudioFile(filePath)
1569
-
1570
- val pcmChunks = if (cachedData != null) {
1571
- Log.d("AudioConcat", "Using cached PCM for interleaved pattern: $filePath")
1572
- cachedData.chunks
1573
- } else {
1574
- // Decode once and store
1575
- val chunks = mutableListOf<ByteArray>()
1576
- val tempQueue = LinkedBlockingQueue<PCMChunk>(100)
1577
- val latch = CountDownLatch(1)
1578
- val seqStart = AtomicInteger(0)
1579
-
1580
- parallelDecodeToQueue(filePath, tempQueue, seqStart, outputSampleRate, audioConfig.channelCount, latch, cache)
1581
-
1582
- // Collect chunks
1583
- var collecting = true
1584
- while (collecting) {
1585
- val chunk = tempQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
1586
- if (chunk != null) {
1587
- if (!chunk.isEndOfStream) {
1588
- chunks.add(chunk.data)
1589
- } else {
1590
- collecting = false
1591
- }
1592
- } else if (latch.count == 0L) {
1593
- collecting = false
1594
- }
1595
- }
1596
-
1597
- latch.await()
1598
- chunks
1599
- }
1600
-
1601
- // Get silence PCM
1602
- val silencePCM = pattern.silenceKey?.let { cache.getSilence(it) }
1603
- ?: pattern.silenceKey?.let {
1604
- val totalSamples = ((it.durationMs / 1000.0) * it.sampleRate).toInt()
1605
- val bytesPerSample = it.channelCount * 2
1606
- ByteArray(totalSamples * bytesPerSample)
1607
- }
1608
-
1609
- // Encode the pattern: file -> silence -> file -> silence -> ...
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
1618
- pcmChunks.forEach { chunk ->
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
- }
1636
- }
1637
-
1638
- // Encode silence (except after the last file)
1639
- if (iteration < pattern.repeatCount - 1 && silencePCM != null) {
1640
- encoder.encodePCMChunk(silencePCM, false)
1641
- }
1642
- }
1643
-
1644
- val patternTime = System.currentTimeMillis() - patternStartTime
1645
- Log.d("AudioConcat", " Encoded interleaved pattern in ${patternTime}ms")
1646
-
1647
- // Mark these indices as processed
1648
- pattern.indices.forEach { idx ->
1649
- processedIndices.add(idx)
1650
- if (idx + 1 < parsedData.size && parsedData[idx + 1] is AudioDataOrSilence.Silence) {
1651
- processedIndices.add(idx + 1)
1652
- }
1653
- }
1654
- }
1655
-
1656
- // Then process remaining items normally
1657
- var audioFileIdx = 0
1658
- for ((index, item) in parsedData.withIndex()) {
1659
- if (processedIndices.contains(index)) {
1660
- if (item is AudioDataOrSilence.AudioFile) audioFileIdx++
1661
- continue
1662
- }
1663
-
1664
- when (item) {
1665
- is AudioDataOrSilence.AudioFile -> {
1666
- // Collect consecutive audio files for parallel processing
1667
- val consecutiveFiles = mutableListOf<Pair<Int, String>>()
1668
- var currentIdx = audioFileIdx
1669
-
1670
- while (currentIdx < audioFileItems.size) {
1671
- val (itemIdx, filePath) = audioFileItems[currentIdx]
1672
- if (processedIndices.contains(itemIdx)) {
1673
- currentIdx++
1674
- continue
1675
- }
1676
- if (itemIdx != index + (currentIdx - audioFileIdx)) break
1677
- consecutiveFiles.add(Pair(itemIdx, filePath))
1678
- currentIdx++
1679
- }
1680
-
1681
- if (consecutiveFiles.isNotEmpty()) {
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
- }
1747
- audioFileIdx = currentIdx
1748
- }
1749
- }
1750
-
1751
- is AudioDataOrSilence.Silence -> {
1752
- val durationMs = item.durationMs
1753
- Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1754
- streamEncodeSilence(
1755
- durationMs,
1756
- encoder,
1757
- outputSampleRate,
1758
- audioConfig.channelCount,
1759
- cache
1760
- )
1761
- }
1762
- }
1763
- }
97
+ // Execute FFmpeg command
98
+ FFmpegKit.executeAsync(command) { session ->
99
+ val returnCode = session.returnCode
100
+ if (ReturnCode.isSuccess(returnCode)) {
101
+ Log.d("AudioConcat", "Successfully merged audio to $outputPath")
102
+ promise.resolve(outputPath)
1764
103
  } else {
1765
- Log.d("AudioConcat", "→ Using sequential processing for ${audioFileItems.size} audio files")
1766
-
1767
- // Create a reusable decoder for sequential processing
1768
- val reusableDecoder = ReusableDecoder()
1769
- Log.d("AudioConcat", "Created reusable decoder for sequential processing")
1770
-
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
- }
1789
-
1790
- is AudioDataOrSilence.Silence -> {
1791
- val durationMs = item.durationMs
1792
- Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1793
-
1794
- streamEncodeSilence(
1795
- durationMs,
1796
- encoder,
1797
- outputSampleRate,
1798
- audioConfig.channelCount,
1799
- cache
1800
- )
1801
- }
1802
- }
1803
- }
1804
- } finally {
1805
- // Release the reusable decoder when done
1806
- reusableDecoder.release()
1807
- Log.d("AudioConcat", "Released reusable decoder")
1808
- }
104
+ val output = session.output
105
+ val error = session.failStackTrace
106
+ Log.e("AudioConcat", "FFmpeg failed: $output")
107
+ Log.e("AudioConcat", "Error: $error")
108
+ promise.reject("FFMPEG_ERROR", "FFmpeg execution failed: $output", Exception(error))
1809
109
  }
1810
-
1811
- val processingTime = System.currentTimeMillis() - processingStartTime
1812
- Log.d("AudioConcat", "✓ Processing completed in ${processingTime}ms")
1813
-
1814
- // Finish encoding
1815
- val encodingFinishStartTime = System.currentTimeMillis()
1816
- encoder.finish()
1817
- val encodingFinishTime = System.currentTimeMillis() - encodingFinishStartTime
1818
- Log.d("AudioConcat", "✓ Encoding finalized in ${encodingFinishTime}ms")
1819
-
1820
- // Log cache statistics
1821
- Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1822
-
1823
- val totalTime = System.currentTimeMillis() - totalStartTime
1824
- Log.d("AudioConcat", "========== Total Time: ${totalTime}ms (${totalTime / 1000.0}s) ==========")
1825
- Log.d("AudioConcat", "Successfully merged audio to $outputPath")
1826
- promise.resolve(outputPath)
1827
-
1828
- } catch (e: Exception) {
1829
- Log.e("AudioConcat", "Error during streaming merge: ${e.message}", e)
1830
- promise.reject("MERGE_ERROR", e.message, e)
1831
110
  }
1832
111
 
1833
112
  } catch (e: Exception) {