react-native-audio-concat 0.7.1 → 0.9.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,1295 +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
- // CONCURRENCY PROTECTION: Serial executor to handle concurrent calls safely
29
- // This prevents MediaCodec resource conflicts and ensures operations don't interfere
30
- private val serialExecutor = Executors.newSingleThreadExecutor()
31
- private val activeOperations = AtomicInteger(0)
32
-
33
- private data class AudioConfig(
34
- val sampleRate: Int,
35
- val channelCount: Int,
36
- val bitRate: Int
37
- )
38
-
39
17
  private sealed class AudioDataOrSilence {
40
18
  data class AudioFile(val filePath: String) : AudioDataOrSilence()
41
19
  data class Silence(val durationMs: Double) : AudioDataOrSilence()
42
20
  }
43
21
 
44
- private data class PCMChunk(
45
- val data: ByteArray,
46
- val sequenceNumber: Int,
47
- val isEndOfStream: Boolean = false
48
- ) {
49
- companion object {
50
- fun endOfStream(sequenceNumber: Int) = PCMChunk(ByteArray(0), sequenceNumber, true)
51
- }
52
- }
53
-
54
- // Cache for decoded PCM data
55
- private data class CachedPCMData(
56
- val chunks: List<ByteArray>,
57
- val totalBytes: Long
58
- )
59
-
60
- private data class SilenceCacheKey(
61
- val durationMs: Double,
62
- val sampleRate: Int,
63
- val channelCount: Int
64
- )
65
-
66
- // Buffer pool for silence generation to reduce memory allocations
67
- private object SilenceBufferPool {
68
- private val pool = ConcurrentHashMap<Int, ByteArray>()
69
- private val standardSizes = listOf(4096, 8192, 16384, 32768, 65536, 131072)
70
-
71
- init {
72
- // Pre-allocate common silence buffer sizes
73
- standardSizes.forEach { size ->
74
- pool[size] = ByteArray(size)
75
- }
76
- Log.d("AudioConcat", "SilenceBufferPool initialized with ${standardSizes.size} standard sizes")
77
- }
78
-
79
- fun getBuffer(requestedSize: Int): ByteArray {
80
- // Find the smallest standard size that fits the request
81
- val standardSize = standardSizes.firstOrNull { it >= requestedSize }
82
-
83
- return if (standardSize != null) {
84
- // Return pooled buffer (already zeroed)
85
- pool.getOrPut(standardSize) { ByteArray(standardSize) }
86
- } else {
87
- // Size too large for pool, create new buffer
88
- ByteArray(requestedSize)
89
- }
90
- }
91
-
92
- fun clear() {
93
- pool.clear()
94
- Log.d("AudioConcat", "SilenceBufferPool cleared")
95
- }
96
- }
97
-
98
- private class PCMCache(
99
- private val shouldCacheFile: Set<String>,
100
- private val shouldCacheSilence: Set<SilenceCacheKey>
101
- ) {
102
- private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
103
- private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
104
- private var currentCacheSizeBytes = 0L
105
-
106
- // Dynamic cache size based on available memory
107
- private val maxCacheSizeBytes: Long
108
- get() {
109
- val runtime = Runtime.getRuntime()
110
- val maxMemory = runtime.maxMemory()
111
- val usedMemory = runtime.totalMemory() - runtime.freeMemory()
112
- val availableMemory = maxMemory - usedMemory
113
-
114
- // Use 20% of available memory for cache, but constrain between 50MB and 200MB
115
- val dynamicCacheMB = (availableMemory / (1024 * 1024) * 0.2).toLong()
116
- val cacheMB = dynamicCacheMB.coerceIn(50, 200)
117
-
118
- return cacheMB * 1024 * 1024
119
- }
120
-
121
- fun getAudioFile(filePath: String): CachedPCMData? {
122
- return audioFileCache[filePath]
123
- }
124
-
125
- fun putAudioFile(filePath: String, data: CachedPCMData) {
126
- // Only cache if this file appears multiple times
127
- if (!shouldCacheFile.contains(filePath)) {
128
- return
129
- }
130
-
131
- // Check cache size limit (dynamic)
132
- if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeBytes) {
133
- val maxCacheMB = maxCacheSizeBytes / (1024 * 1024)
134
- Log.d("AudioConcat", "Cache full ($maxCacheMB MB), not caching: $filePath")
135
- return
136
- }
137
-
138
- audioFileCache[filePath] = data
139
- currentCacheSizeBytes += data.totalBytes
140
- Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB, total: ${currentCacheSizeBytes / 1024}KB)")
141
- }
142
-
143
- fun getSilence(key: SilenceCacheKey): ByteArray? {
144
- return silenceCache[key]
145
- }
146
-
147
- fun putSilence(key: SilenceCacheKey, data: ByteArray) {
148
- // Only cache if this silence pattern appears multiple times
149
- if (!shouldCacheSilence.contains(key)) {
150
- return
151
- }
152
-
153
- silenceCache[key] = data
154
- Log.d("AudioConcat", "Cached silence: ${key.durationMs}ms")
155
- }
156
-
157
- fun clear() {
158
- audioFileCache.clear()
159
- silenceCache.clear()
160
- currentCacheSizeBytes = 0
161
- Log.d("AudioConcat", "Cache cleared")
162
- }
163
-
164
- fun getStats(): String {
165
- return "Audio files: ${audioFileCache.size}, Silence patterns: ${silenceCache.size}, Size: ${currentCacheSizeBytes / 1024}KB"
166
- }
167
- }
168
-
169
- // Helper class to manage MediaCodec decoder reuse
170
- private class ReusableDecoder {
171
- private var decoder: MediaCodec? = null
172
- private var currentMimeType: String? = null
173
- private var currentFormat: MediaFormat? = null
174
- private var isHardwareDecoder: Boolean = false
175
-
176
- /**
177
- * Try to create a hardware decoder for better performance
178
- * Hardware decoders are typically 2-10x faster than software decoders
179
- */
180
- private fun createHardwareDecoder(mimeType: String, format: MediaFormat): MediaCodec? {
181
- try {
182
- val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
183
-
184
- for (codecInfo in codecList.codecInfos) {
185
- // Skip encoders
186
- if (codecInfo.isEncoder) continue
187
-
188
- // Check if this codec supports our mime type
189
- if (!codecInfo.supportedTypes.any { it.equals(mimeType, ignoreCase = true) }) {
190
- continue
191
- }
192
-
193
- // Hardware decoder identification by vendor prefix
194
- val isHardware = codecInfo.name.let { name ->
195
- name.startsWith("OMX.qcom") || // Qualcomm (most common)
196
- name.startsWith("OMX.MTK") || // MediaTek
197
- name.startsWith("OMX.Exynos") || // Samsung Exynos
198
- name.startsWith("OMX.SEC") || // Samsung
199
- name.startsWith("OMX.hisi") || // Huawei HiSilicon
200
- name.startsWith("c2.qti") || // Qualcomm C2
201
- name.startsWith("c2.mtk") || // MediaTek C2
202
- name.startsWith("c2.exynos") || // Samsung C2
203
- (name.contains("hardware", ignoreCase = true) &&
204
- !name.contains("google", ignoreCase = true))
205
- }
206
-
207
- if (isHardware) {
208
- try {
209
- val codec = MediaCodec.createByCodecName(codecInfo.name)
210
- codec.configure(format, null, null, 0)
211
- codec.start()
212
-
213
- Log.d("AudioConcat", " ✓ Created HARDWARE decoder: ${codecInfo.name}")
214
- return codec
215
- } catch (e: Exception) {
216
- Log.w("AudioConcat", " ✗ HW decoder ${codecInfo.name} failed: ${e.message}")
217
- // Continue to try next hardware decoder
218
- }
219
- }
220
- }
221
- } catch (e: Exception) {
222
- Log.w("AudioConcat", " Hardware decoder search failed: ${e.message}")
223
- }
224
-
225
- return null
226
- }
227
-
228
- fun getOrCreateDecoder(mimeType: String, format: MediaFormat): MediaCodec {
229
- // Check if we can reuse the existing decoder
230
- if (decoder != null && currentMimeType == mimeType && formatsCompatible(currentFormat, format)) {
231
- // Flush the decoder to reset its state
232
- try {
233
- decoder!!.flush()
234
- val type = if (isHardwareDecoder) "HW" else "SW"
235
- Log.d("AudioConcat", " ↻ Reused $type decoder for $mimeType")
236
- return decoder!!
237
- } catch (e: Exception) {
238
- Log.w("AudioConcat", "Failed to flush decoder, recreating: ${e.message}")
239
- release()
240
- }
241
- }
242
-
243
- // Need to create a new decoder
244
- release() // Release old one if exists
245
-
246
- // Try hardware decoder first (2-10x faster)
247
- var newDecoder = createHardwareDecoder(mimeType, format)
248
- isHardwareDecoder = (newDecoder != null)
249
-
250
- // Fallback to software decoder
251
- if (newDecoder == null) {
252
- newDecoder = MediaCodec.createDecoderByType(mimeType)
253
- newDecoder.configure(format, null, null, 0)
254
- newDecoder.start()
255
- Log.d("AudioConcat", " ⚠ Created SOFTWARE decoder for $mimeType (no HW available)")
256
- isHardwareDecoder = false
257
- }
258
-
259
- decoder = newDecoder
260
- currentMimeType = mimeType
261
- currentFormat = format
262
-
263
- return newDecoder
264
- }
265
-
266
- private fun formatsCompatible(format1: MediaFormat?, format2: MediaFormat): Boolean {
267
- if (format1 == null) return false
268
-
269
- // Check key format properties
270
- return try {
271
- format1.getInteger(MediaFormat.KEY_SAMPLE_RATE) == format2.getInteger(MediaFormat.KEY_SAMPLE_RATE) &&
272
- format1.getInteger(MediaFormat.KEY_CHANNEL_COUNT) == format2.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
273
- } catch (e: Exception) {
274
- false
275
- }
276
- }
277
-
278
- fun release() {
279
- decoder?.let {
280
- try {
281
- it.stop()
282
- it.release()
283
- } catch (e: Exception) {
284
- Log.w("AudioConcat", "Error releasing decoder: ${e.message}")
285
- }
286
- }
287
- decoder = null
288
- currentMimeType = null
289
- currentFormat = null
290
- }
291
- }
292
-
293
- // Thread-safe decoder pool for parallel processing
294
- private class DecoderPool {
295
- private val decoders = ConcurrentHashMap<Long, ReusableDecoder>()
296
-
297
- fun getDecoderForCurrentThread(): ReusableDecoder {
298
- val threadId = Thread.currentThread().id
299
- return decoders.getOrPut(threadId) {
300
- Log.d("AudioConcat", " Created decoder for thread $threadId")
301
- ReusableDecoder()
302
- }
303
- }
304
-
305
- fun releaseAll() {
306
- decoders.values.forEach { it.release() }
307
- decoders.clear()
308
- Log.d("AudioConcat", "Released all pooled decoders")
309
- }
310
- }
311
-
312
- private fun extractAudioConfig(filePath: String): AudioConfig {
313
- val extractor = MediaExtractor()
314
- try {
315
- extractor.setDataSource(filePath)
316
- for (i in 0 until extractor.trackCount) {
317
- val format = extractor.getTrackFormat(i)
318
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
319
- if (mime.startsWith("audio/")) {
320
- val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
321
- val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
322
- val bitRate = if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
323
- format.getInteger(MediaFormat.KEY_BIT_RATE)
324
- } else {
325
- 128000 // Default 128kbps
326
- }
327
- return AudioConfig(sampleRate, channelCount, bitRate)
328
- }
329
- }
330
- throw Exception("No audio track found in $filePath")
331
- } finally {
332
- extractor.release()
333
- }
334
- }
335
-
336
- private class StreamingEncoder(
337
- sampleRate: Int,
338
- channelCount: Int,
339
- bitRate: Int,
340
- outputPath: String
341
- ) {
342
- private val encoder: MediaCodec
343
- private val muxer: MediaMuxer
344
- private var audioTrackIndex = -1
345
- private var muxerStarted = false
346
- private val bufferInfo = MediaCodec.BufferInfo()
347
- private var totalPresentationTimeUs = 0L
348
- private val sampleRate: Int
349
- private val channelCount: Int
350
- private val maxChunkSize: Int
351
-
352
- // Performance tracking
353
- private var totalBufferWaitTimeMs = 0L
354
- private var bufferWaitCount = 0
355
-
356
- init {
357
- this.sampleRate = sampleRate
358
- this.channelCount = channelCount
359
-
360
- val outputFormat = MediaFormat.createAudioFormat(
361
- MediaFormat.MIMETYPE_AUDIO_AAC,
362
- sampleRate,
363
- channelCount
364
- )
365
- outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
366
- outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
367
-
368
- // Optimized buffer size based on audio parameters
369
- // Target: ~1024 samples per frame for optimal AAC encoding
370
- val samplesPerFrame = 1024
371
- val bytesPerSample = channelCount * 2 // 16-bit PCM
372
- val optimalBufferSize = samplesPerFrame * bytesPerSample
373
- // OPTIMIZATION: Increased buffer size for better throughput
374
- // Larger buffers reduce dequeue operations and improve encoder efficiency
375
- val bufferSize = (optimalBufferSize * 4.0).toInt().coerceAtLeast(65536)
376
- outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
377
-
378
- // Store for use in encodePCMChunk
379
- this.maxChunkSize = bufferSize
380
-
381
- Log.d("AudioConcat", "Encoder buffer size: $bufferSize bytes (${samplesPerFrame} samples, ${sampleRate}Hz, ${channelCount}ch)")
382
-
383
- encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
384
- encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
385
- encoder.start()
386
-
387
- muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
388
- }
389
-
390
- fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
391
- // Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
392
- var offset = 0
393
- var buffersQueued = 0 // Track queued buffers for batch draining
394
-
395
- while (offset < pcmData.size) {
396
- val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
397
- val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
398
-
399
- // Feed PCM data chunk to encoder (reduced timeout for better throughput)
400
- val bufferWaitStart = System.currentTimeMillis()
401
- val inputBufferIndex = encoder.dequeueInputBuffer(1000)
402
- val bufferWaitTime = System.currentTimeMillis() - bufferWaitStart
403
-
404
- if (inputBufferIndex >= 0) {
405
- if (bufferWaitTime > 5) {
406
- totalBufferWaitTimeMs += bufferWaitTime
407
- bufferWaitCount++
408
- }
409
-
410
- val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
411
- val bufferCapacity = inputBuffer.capacity()
412
-
413
- // Ensure chunk fits in buffer
414
- val actualChunkSize = minOf(chunkSize, bufferCapacity)
415
-
416
- inputBuffer.clear()
417
- inputBuffer.put(pcmData, offset, actualChunkSize)
418
-
419
- val presentationTimeUs = totalPresentationTimeUs
420
- totalPresentationTimeUs += (actualChunkSize.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
421
-
422
- val flags = if (isLastChunk) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
423
- encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
424
-
425
- offset += actualChunkSize
426
- buffersQueued++
427
- } else {
428
- totalBufferWaitTimeMs += bufferWaitTime
429
- bufferWaitCount++
430
- // Buffer not available, drain first
431
- drainEncoder(false)
432
- buffersQueued = 0 // Reset counter after forced drain
433
- }
434
-
435
- // OPTIMIZATION: Batch drain - only drain every 4 buffers instead of every buffer
436
- // This reduces overhead while keeping encoder pipeline flowing
437
- if (buffersQueued >= 4 || isLastChunk || offset >= pcmData.size) {
438
- drainEncoder(false)
439
- buffersQueued = 0
440
- }
441
- }
442
-
443
- // Final drain if last chunk
444
- if (isLast) {
445
- drainEncoder(true)
446
- }
447
-
448
- return true
449
- }
450
-
451
- private fun drainEncoder(endOfStream: Boolean) {
452
- while (true) {
453
- // Use shorter timeout for better responsiveness
454
- val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 1000 else 0)
455
-
456
- when (outputBufferIndex) {
457
- MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
458
- if (muxerStarted) {
459
- throw RuntimeException("Format changed twice")
460
- }
461
- val newFormat = encoder.outputFormat
462
- audioTrackIndex = muxer.addTrack(newFormat)
463
- muxer.start()
464
- muxerStarted = true
465
- Log.d("AudioConcat", "Encoder started, format: $newFormat")
466
- }
467
- MediaCodec.INFO_TRY_AGAIN_LATER -> {
468
- if (!endOfStream) {
469
- break
470
- }
471
- // Continue draining when end of stream
472
- }
473
- else -> {
474
- if (outputBufferIndex >= 0) {
475
- val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
476
-
477
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
478
- bufferInfo.size = 0
479
- }
480
-
481
- if (bufferInfo.size > 0 && muxerStarted) {
482
- outputBuffer.position(bufferInfo.offset)
483
- outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
484
- muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
485
- }
486
-
487
- encoder.releaseOutputBuffer(outputBufferIndex, false)
488
-
489
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
490
- break
491
- }
492
- }
493
- }
494
- }
495
- }
496
- }
497
-
498
- fun getEncoderStats(): String {
499
- val avgWaitTime = if (bufferWaitCount > 0) {
500
- String.format("%.2f", totalBufferWaitTimeMs.toFloat() / bufferWaitCount)
501
- } else "0.00"
502
-
503
- return "Buffer waits: $bufferWaitCount, Total wait: ${totalBufferWaitTimeMs}ms, Avg: ${avgWaitTime}ms"
504
- }
505
-
506
- fun finish() {
507
- // Signal end of stream (reduced timeout)
508
- val inputBufferIndex = encoder.dequeueInputBuffer(1000)
509
- if (inputBufferIndex >= 0) {
510
- encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
511
- }
512
-
513
- // Drain remaining data
514
- drainEncoder(true)
515
-
516
- // Log encoder performance stats
517
- Log.d("AudioConcat", "Encoder stats: ${getEncoderStats()}")
518
-
519
- encoder.stop()
520
- encoder.release()
521
-
522
- if (muxerStarted) {
523
- muxer.stop()
524
- }
525
- muxer.release()
526
- }
527
- }
528
-
529
- private fun resamplePCM16(
530
- input: ByteArray,
531
- inputSampleRate: Int,
532
- outputSampleRate: Int,
533
- channelCount: Int
534
- ): ByteArray {
535
- if (inputSampleRate == outputSampleRate) {
536
- return input
537
- }
538
-
539
- val startTime = System.currentTimeMillis()
540
- val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
541
- val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
542
- val output = ByteArray(outputSampleCount * 2 * channelCount)
543
-
544
- // Helper function to read a sample with bounds checking
545
- fun readSample(sampleIndex: Int, channel: Int): Int {
546
- val clampedIndex = sampleIndex.coerceIn(0, inputSampleCount - 1)
547
- val idx = (clampedIndex * channelCount + channel) * 2
548
- val unsigned = (input[idx].toInt() and 0xFF) or (input[idx + 1].toInt() shl 8)
549
- return if (unsigned > 32767) unsigned - 65536 else unsigned
550
- }
551
-
552
- // Use floating-point for better accuracy than fixed-point
553
- val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
554
-
555
- for (i in 0 until outputSampleCount) {
556
- val srcPos = i * ratio
557
- val srcIndex = srcPos.toInt()
558
- val fraction = srcPos - srcIndex // Fractional part (0.0 to 1.0)
559
-
560
- for (ch in 0 until channelCount) {
561
- // Linear interpolation with floating-point precision
562
- val s1 = readSample(srcIndex, ch).toDouble()
563
- val s2 = readSample(srcIndex + 1, ch).toDouble()
564
-
565
- // Linear interpolation: s1 + (s2 - s1) * fraction
566
- val interpolated = s1 + (s2 - s1) * fraction
567
-
568
- // Clamp to 16-bit range
569
- val clamped = interpolated.toInt().coerceIn(-32768, 32767)
570
-
571
- // Write to output (little-endian)
572
- val outIdx = (i * channelCount + ch) * 2
573
- output[outIdx] = (clamped and 0xFF).toByte()
574
- output[outIdx + 1] = (clamped shr 8).toByte()
575
- }
576
- }
577
-
578
- val elapsedTime = System.currentTimeMillis() - startTime
579
- Log.d("AudioConcat", " Resampled ${inputSampleRate}Hz→${outputSampleRate}Hz, ${input.size / 1024}KB→${output.size / 1024}KB in ${elapsedTime}ms")
580
-
581
- return output
582
- }
583
-
584
- private fun convertChannelCount(
585
- input: ByteArray,
586
- inputChannels: Int,
587
- outputChannels: Int
588
- ): ByteArray {
589
- if (inputChannels == outputChannels) {
590
- return input
591
- }
592
-
593
- val sampleCount = input.size / (2 * inputChannels)
594
- val output = ByteArray(sampleCount * 2 * outputChannels)
595
-
596
- when {
597
- inputChannels == 1 && outputChannels == 2 -> {
598
- // OPTIMIZED: Mono to Stereo using batch copy with unrolled loop
599
- // Process 4 samples at a time for better cache locality
600
- val batchSize = 4
601
- val fullBatches = sampleCount / batchSize
602
- var i = 0
603
-
604
- // Process batches of 4 samples
605
- for (batch in 0 until fullBatches) {
606
- val baseIdx = i * 2
607
- val baseDst = i * 4
608
-
609
- // Sample 1
610
- output[baseDst] = input[baseIdx]
611
- output[baseDst + 1] = input[baseIdx + 1]
612
- output[baseDst + 2] = input[baseIdx]
613
- output[baseDst + 3] = input[baseIdx + 1]
614
-
615
- // Sample 2
616
- output[baseDst + 4] = input[baseIdx + 2]
617
- output[baseDst + 5] = input[baseIdx + 3]
618
- output[baseDst + 6] = input[baseIdx + 2]
619
- output[baseDst + 7] = input[baseIdx + 3]
620
-
621
- // Sample 3
622
- output[baseDst + 8] = input[baseIdx + 4]
623
- output[baseDst + 9] = input[baseIdx + 5]
624
- output[baseDst + 10] = input[baseIdx + 4]
625
- output[baseDst + 11] = input[baseIdx + 5]
626
-
627
- // Sample 4
628
- output[baseDst + 12] = input[baseIdx + 6]
629
- output[baseDst + 13] = input[baseIdx + 7]
630
- output[baseDst + 14] = input[baseIdx + 6]
631
- output[baseDst + 15] = input[baseIdx + 7]
632
-
633
- i += batchSize
634
- }
635
-
636
- // Process remaining samples
637
- while (i < sampleCount) {
638
- val srcIdx = i * 2
639
- val dstIdx = i * 4
640
- output[dstIdx] = input[srcIdx]
641
- output[dstIdx + 1] = input[srcIdx + 1]
642
- output[dstIdx + 2] = input[srcIdx]
643
- output[dstIdx + 3] = input[srcIdx + 1]
644
- i++
645
- }
646
- }
647
- inputChannels == 2 && outputChannels == 1 -> {
648
- // OPTIMIZED: Stereo to Mono with unrolled loop
649
- val batchSize = 4
650
- val fullBatches = sampleCount / batchSize
651
- var i = 0
652
-
653
- // Process batches of 4 samples
654
- for (batch in 0 until fullBatches) {
655
- val baseSrc = i * 4
656
- val baseDst = i * 2
657
-
658
- // Sample 1
659
- var left = (input[baseSrc].toInt() and 0xFF) or (input[baseSrc + 1].toInt() shl 8)
660
- var right = (input[baseSrc + 2].toInt() and 0xFF) or (input[baseSrc + 3].toInt() shl 8)
661
- var avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
662
- output[baseDst] = (avg and 0xFF).toByte()
663
- output[baseDst + 1] = (avg shr 8).toByte()
664
-
665
- // Sample 2
666
- left = (input[baseSrc + 4].toInt() and 0xFF) or (input[baseSrc + 5].toInt() shl 8)
667
- right = (input[baseSrc + 6].toInt() and 0xFF) or (input[baseSrc + 7].toInt() shl 8)
668
- avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
669
- output[baseDst + 2] = (avg and 0xFF).toByte()
670
- output[baseDst + 3] = (avg shr 8).toByte()
671
-
672
- // Sample 3
673
- left = (input[baseSrc + 8].toInt() and 0xFF) or (input[baseSrc + 9].toInt() shl 8)
674
- right = (input[baseSrc + 10].toInt() and 0xFF) or (input[baseSrc + 11].toInt() shl 8)
675
- avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
676
- output[baseDst + 4] = (avg and 0xFF).toByte()
677
- output[baseDst + 5] = (avg shr 8).toByte()
678
-
679
- // Sample 4
680
- left = (input[baseSrc + 12].toInt() and 0xFF) or (input[baseSrc + 13].toInt() shl 8)
681
- right = (input[baseSrc + 14].toInt() and 0xFF) or (input[baseSrc + 15].toInt() shl 8)
682
- avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
683
- output[baseDst + 6] = (avg and 0xFF).toByte()
684
- output[baseDst + 7] = (avg shr 8).toByte()
685
-
686
- i += batchSize
687
- }
688
-
689
- // Process remaining samples
690
- while (i < sampleCount) {
691
- val srcIdx = i * 4
692
- val dstIdx = i * 2
693
- val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
694
- val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
695
- val avg = (((if (left > 32767) left - 65536 else left) + (if (right > 32767) right - 65536 else right)) shr 1)
696
- output[dstIdx] = (avg and 0xFF).toByte()
697
- output[dstIdx + 1] = (avg shr 8).toByte()
698
- i++
699
- }
700
- }
701
- else -> {
702
- // Fallback: just take the first channel
703
- for (i in 0 until sampleCount) {
704
- val srcIdx = i * 2 * inputChannels
705
- val dstIdx = i * 2 * outputChannels
706
- for (ch in 0 until minOf(inputChannels, outputChannels)) {
707
- output[dstIdx + ch * 2] = input[srcIdx + ch * 2]
708
- output[dstIdx + ch * 2 + 1] = input[srcIdx + ch * 2 + 1]
709
- }
710
- }
711
- }
712
- }
713
-
714
- return output
715
- }
716
-
717
- private fun parallelDecodeToQueue(
718
- filePath: String,
719
- queue: BlockingQueue<PCMChunk>,
720
- sequenceStart: AtomicInteger,
721
- targetSampleRate: Int,
722
- targetChannelCount: Int,
723
- latch: CountDownLatch,
724
- cache: PCMCache,
725
- decoderPool: DecoderPool? = null
726
- ) {
727
- try {
728
- // Check cache first
729
- val cachedData = cache.getAudioFile(filePath)
730
- if (cachedData != null) {
731
- Log.d("AudioConcat", "Using cached PCM for: $filePath")
732
- // Put cached chunks to queue
733
- for (chunk in cachedData.chunks) {
734
- val seqNum = sequenceStart.getAndIncrement()
735
- queue.put(PCMChunk(chunk, seqNum))
736
- }
737
- latch.countDown()
738
- return
739
- }
740
-
741
- val extractor = MediaExtractor()
742
- var decoder: MediaCodec? = null
743
- val decodedChunks = mutableListOf<ByteArray>()
744
- var totalBytes = 0L
745
- val shouldReleaseDecoder = (decoderPool == null) // Only release if not using pool
746
-
747
- try {
748
- extractor.setDataSource(filePath)
749
-
750
- var audioTrackIndex = -1
751
- var audioFormat: MediaFormat? = null
752
-
753
- for (i in 0 until extractor.trackCount) {
754
- val format = extractor.getTrackFormat(i)
755
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
756
- if (mime.startsWith("audio/")) {
757
- audioTrackIndex = i
758
- audioFormat = format
759
- break
760
- }
761
- }
762
-
763
- if (audioTrackIndex == -1 || audioFormat == null) {
764
- throw Exception("No audio track found in $filePath")
765
- }
766
-
767
- val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
768
- val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
769
-
770
- val needsResampling = sourceSampleRate != targetSampleRate
771
- val needsChannelConversion = sourceChannelCount != targetChannelCount
772
-
773
- if (needsResampling || needsChannelConversion) {
774
- Log.d("AudioConcat", "Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
775
- }
776
-
777
- extractor.selectTrack(audioTrackIndex)
778
-
779
- val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
780
-
781
- // Use decoder pool if available, otherwise create new decoder
782
- decoder = if (decoderPool != null) {
783
- val reusableDecoder = decoderPool.getDecoderForCurrentThread()
784
- reusableDecoder.getOrCreateDecoder(mime, audioFormat)
785
- } else {
786
- val newDecoder = MediaCodec.createDecoderByType(mime)
787
- newDecoder.configure(audioFormat, null, null, 0)
788
- newDecoder.start()
789
- newDecoder
790
- }
791
-
792
- val bufferInfo = MediaCodec.BufferInfo()
793
- var isEOS = false
794
-
795
- while (!isEOS) {
796
- // Feed input to decoder (reduced timeout for faster processing)
797
- val inputBufferIndex = decoder.dequeueInputBuffer(1000)
798
- if (inputBufferIndex >= 0) {
799
- val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
800
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
801
-
802
- if (sampleSize < 0) {
803
- decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
804
- } else {
805
- val presentationTimeUs = extractor.sampleTime
806
- decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
807
- extractor.advance()
808
- }
809
- }
810
-
811
- // Get PCM output from decoder and put to queue (reduced timeout)
812
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
813
- if (outputBufferIndex >= 0) {
814
- val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
815
-
816
- if (bufferInfo.size > 0) {
817
- var pcmData = ByteArray(bufferInfo.size)
818
- outputBuffer.get(pcmData)
819
-
820
- // Convert channel count if needed
821
- if (needsChannelConversion) {
822
- pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
823
- }
824
-
825
- // Resample if needed
826
- if (needsResampling) {
827
- pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
828
- }
829
-
830
- // Optimization: avoid unnecessary clone() - store original for caching
831
- decodedChunks.add(pcmData)
832
- totalBytes += pcmData.size
833
-
834
- // Put a clone to queue (queue might modify it)
835
- val seqNum = sequenceStart.getAndIncrement()
836
- queue.put(PCMChunk(pcmData.clone(), seqNum))
837
- }
838
-
839
- decoder.releaseOutputBuffer(outputBufferIndex, false)
840
-
841
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
842
- isEOS = true
843
- }
844
- }
845
- }
846
-
847
- // Cache the decoded data
848
- if (decodedChunks.isNotEmpty()) {
849
- cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
850
- }
851
-
852
- } finally {
853
- // Only stop/release decoder if not using pool
854
- if (shouldReleaseDecoder) {
855
- decoder?.stop()
856
- decoder?.release()
857
- }
858
- extractor.release()
859
- }
860
- } catch (e: Exception) {
861
- Log.e("AudioConcat", "Error in parallel decode: ${e.message}", e)
862
- throw e
863
- } finally {
864
- latch.countDown()
865
- }
866
- }
867
-
868
- private fun streamDecodeAudioFile(
869
- filePath: String,
870
- encoder: StreamingEncoder,
871
- isLastFile: Boolean,
872
- targetSampleRate: Int,
873
- targetChannelCount: Int,
874
- reusableDecoder: ReusableDecoder? = null
875
- ) {
876
- val startTime = System.currentTimeMillis()
877
- val extractor = MediaExtractor()
878
- var decoder: MediaCodec? = null
879
- val shouldReleaseDecoder = (reusableDecoder == null) // Only release if not reusing
880
-
881
- try {
882
- extractor.setDataSource(filePath)
883
-
884
- var audioTrackIndex = -1
885
- var audioFormat: MediaFormat? = null
886
-
887
- for (i in 0 until extractor.trackCount) {
888
- val format = extractor.getTrackFormat(i)
889
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
890
- if (mime.startsWith("audio/")) {
891
- audioTrackIndex = i
892
- audioFormat = format
893
- break
894
- }
895
- }
896
-
897
- if (audioTrackIndex == -1 || audioFormat == null) {
898
- throw Exception("No audio track found in $filePath")
899
- }
900
-
901
- val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
902
- val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
903
-
904
- val needsResampling = sourceSampleRate != targetSampleRate
905
- val needsChannelConversion = sourceChannelCount != targetChannelCount
906
-
907
- if (needsResampling || needsChannelConversion) {
908
- Log.d("AudioConcat", "File: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
909
- }
910
-
911
- extractor.selectTrack(audioTrackIndex)
912
-
913
- val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
914
-
915
- // Use reusable decoder if provided, otherwise create a new one
916
- decoder = if (reusableDecoder != null) {
917
- reusableDecoder.getOrCreateDecoder(mime, audioFormat)
918
- } else {
919
- val newDecoder = MediaCodec.createDecoderByType(mime)
920
- newDecoder.configure(audioFormat, null, null, 0)
921
- newDecoder.start()
922
- newDecoder
923
- }
924
-
925
- val bufferInfo = MediaCodec.BufferInfo()
926
- var isEOS = false
927
-
928
- while (!isEOS) {
929
- // Feed input to decoder (reduced timeout for faster processing)
930
- val inputBufferIndex = decoder.dequeueInputBuffer(1000)
931
- if (inputBufferIndex >= 0) {
932
- val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
933
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
934
-
935
- if (sampleSize < 0) {
936
- decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
937
- } else {
938
- val presentationTimeUs = extractor.sampleTime
939
- decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
940
- extractor.advance()
941
- }
942
- }
943
-
944
- // Get PCM output from decoder and feed to encoder (reduced timeout)
945
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
946
- if (outputBufferIndex >= 0) {
947
- val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
948
-
949
- if (bufferInfo.size > 0) {
950
- var pcmData = ByteArray(bufferInfo.size)
951
- outputBuffer.get(pcmData)
952
-
953
- // Convert channel count if needed
954
- if (needsChannelConversion) {
955
- pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
956
- }
957
-
958
- // Resample if needed
959
- if (needsResampling) {
960
- pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
961
- }
962
-
963
- // Stream to encoder
964
- encoder.encodePCMChunk(pcmData, false)
965
- }
966
-
967
- decoder.releaseOutputBuffer(outputBufferIndex, false)
968
-
969
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
970
- isEOS = true
971
- }
972
- }
973
- }
974
-
975
- } finally {
976
- // Only stop/release decoder if we created it locally (not reusing)
977
- if (shouldReleaseDecoder) {
978
- decoder?.stop()
979
- decoder?.release()
980
- }
981
- extractor.release()
982
-
983
- // Performance metrics
984
- val elapsedTime = System.currentTimeMillis() - startTime
985
- val fileSize = try { File(filePath).length() } catch (e: Exception) { 0L }
986
- val fileSizeKB = fileSize / 1024
987
- val decodingSpeedMBps = if (elapsedTime > 0) {
988
- (fileSize / 1024.0 / 1024.0) / (elapsedTime / 1000.0)
989
- } else 0.0
990
-
991
- Log.d("AudioConcat", " ⚡ Decoded ${fileSizeKB}KB in ${elapsedTime}ms (${String.format("%.2f", decodingSpeedMBps)} MB/s)")
992
- }
993
- }
994
-
995
- private fun streamEncodeSilence(
996
- durationMs: Double,
997
- encoder: StreamingEncoder,
998
- sampleRate: Int,
999
- channelCount: Int,
1000
- cache: PCMCache
1001
- ) {
1002
- val cacheKey = SilenceCacheKey(durationMs, sampleRate, channelCount)
1003
-
1004
- // Check cache first
1005
- val cachedSilence = cache.getSilence(cacheKey)
1006
- if (cachedSilence != null) {
1007
- Log.d("AudioConcat", "Using cached silence: ${durationMs}ms")
1008
- encoder.encodePCMChunk(cachedSilence, false)
1009
- return
1010
- }
1011
-
1012
- // Generate silence
1013
- val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
1014
- val bytesPerSample = channelCount * 2 // 16-bit stereo
1015
- val totalBytes = totalSamples * bytesPerSample
1016
-
1017
- // For short silence (< 5 seconds), cache as single chunk
1018
- if (durationMs < 5000) {
1019
- // Use buffer pool to avoid allocation
1020
- val pooledBuffer = SilenceBufferPool.getBuffer(totalBytes)
1021
- val silenceData = if (pooledBuffer.size == totalBytes) {
1022
- pooledBuffer
1023
- } else {
1024
- // Copy only the needed portion
1025
- pooledBuffer.copyOf(totalBytes)
1026
- }
1027
- cache.putSilence(cacheKey, silenceData)
1028
- encoder.encodePCMChunk(silenceData, false)
1029
- } else {
1030
- // For longer silence, process in chunks without caching using pooled buffers
1031
- val chunkSamples = 16384
1032
- var samplesRemaining = totalSamples
1033
-
1034
- while (samplesRemaining > 0) {
1035
- val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
1036
- val chunkBytes = currentChunkSamples * bytesPerSample
1037
-
1038
- // Use pooled buffer for chunk
1039
- val pooledBuffer = SilenceBufferPool.getBuffer(chunkBytes)
1040
- val silenceChunk = if (pooledBuffer.size == chunkBytes) {
1041
- pooledBuffer
1042
- } else {
1043
- pooledBuffer.copyOf(chunkBytes)
1044
- }
1045
-
1046
- encoder.encodePCMChunk(silenceChunk, false)
1047
- samplesRemaining -= currentChunkSamples
1048
- }
1049
- }
1050
- }
1051
-
1052
- private fun getOptimalThreadCount(audioFileCount: Int): Int {
1053
- val cpuCores = Runtime.getRuntime().availableProcessors()
1054
- val optimalThreads = when {
1055
- cpuCores <= 2 -> 2
1056
- cpuCores <= 4 -> 3
1057
- cpuCores <= 8 -> 4
1058
- else -> 6
1059
- }
1060
- // Don't create more threads than files to process
1061
- return optimalThreads.coerceAtMost(audioFileCount)
1062
- }
1063
-
1064
- private fun getOptimalQueueSize(audioFileCount: Int): Int {
1065
- // Dynamic queue size based on number of files to prevent memory waste or blocking
1066
- return when {
1067
- audioFileCount <= 5 -> 20
1068
- audioFileCount <= 20 -> 50
1069
- audioFileCount <= 50 -> 100
1070
- else -> 150
1071
- }
1072
- }
1073
-
1074
- private fun parallelProcessAudioFiles(
1075
- audioFiles: List<Pair<Int, String>>, // (index, filePath)
1076
- encoder: StreamingEncoder,
1077
- targetSampleRate: Int,
1078
- targetChannelCount: Int,
1079
- cache: PCMCache,
1080
- numThreads: Int = 3
1081
- ) {
1082
- if (audioFiles.isEmpty()) return
1083
-
1084
- // Group consecutive duplicate files
1085
- val optimizedFiles = mutableListOf<Pair<Int, String>>()
1086
- val consecutiveDuplicates = mutableMapOf<Int, Int>() // originalIndex -> count
1087
-
1088
- var i = 0
1089
- while (i < audioFiles.size) {
1090
- val (index, filePath) = audioFiles[i]
1091
- var count = 1
1092
-
1093
- // Check for consecutive duplicates
1094
- while (i + count < audioFiles.size && audioFiles[i + count].second == filePath) {
1095
- count++
1096
- }
1097
-
1098
- if (count > 1) {
1099
- Log.d("AudioConcat", "Detected $count consecutive occurrences of: $filePath")
1100
- optimizedFiles.add(Pair(index, filePath))
1101
- consecutiveDuplicates[optimizedFiles.size - 1] = count
1102
- } else {
1103
- optimizedFiles.add(Pair(index, filePath))
1104
- }
1105
-
1106
- i += count
1107
- }
1108
-
1109
- val queueSize = getOptimalQueueSize(optimizedFiles.size)
1110
- val pcmQueue = LinkedBlockingQueue<PCMChunk>(queueSize)
1111
- Log.d("AudioConcat", "Using queue size: $queueSize for ${optimizedFiles.size} files")
1112
- val executor = Executors.newFixedThreadPool(numThreads)
1113
- val latch = CountDownLatch(optimizedFiles.size)
1114
- val sequenceCounter = AtomicInteger(0)
1115
-
1116
- // Create decoder pool for reuse across threads
1117
- val decoderPool = DecoderPool()
1118
- Log.d("AudioConcat", "Created decoder pool for parallel processing ($numThreads threads)")
1119
-
1120
- try {
1121
- // Submit decode tasks for unique files only
1122
- optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
1123
- executor.submit {
1124
- try {
1125
- val fileSequenceStart = AtomicInteger(sequenceCounter.get())
1126
- sequenceCounter.addAndGet(1000000)
1127
-
1128
- Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
1129
- parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache, decoderPool)
1130
-
1131
- // Mark end with duplicate count
1132
- val repeatCount = consecutiveDuplicates[optIndex] ?: 1
1133
- val endSeqNum = fileSequenceStart.get()
1134
- pcmQueue.put(PCMChunk(ByteArray(0), endSeqNum, true)) // endOfStream marker with repeat count
1135
-
1136
- } catch (e: Exception) {
1137
- Log.e("AudioConcat", "Error decoding file $filePath: ${e.message}", e)
1138
- latch.countDown()
1139
- }
1140
- }
1141
- }
1142
-
1143
- // Consumer thread: encode in order
1144
- var filesCompleted = 0
1145
- var cachedChunks = mutableListOf<ByteArray>()
1146
- var isCollectingChunks = false
1147
-
1148
- while (filesCompleted < optimizedFiles.size) {
1149
- val chunk = pcmQueue.take()
1150
-
1151
- if (chunk.isEndOfStream) {
1152
- val optIndex = filesCompleted
1153
- val repeatCount = consecutiveDuplicates[optIndex] ?: 1
1154
-
1155
- if (repeatCount > 1 && cachedChunks.isNotEmpty()) {
1156
- // Repeat the cached chunks
1157
- Log.d("AudioConcat", "Repeating cached chunks ${repeatCount - 1} more times")
1158
- repeat(repeatCount - 1) {
1159
- cachedChunks.forEach { data ->
1160
- encoder.encodePCMChunk(data, false)
1161
- }
1162
- }
1163
- cachedChunks.clear()
1164
- }
1165
-
1166
- filesCompleted++
1167
- isCollectingChunks = false
1168
- Log.d("AudioConcat", "Completed file $filesCompleted/${optimizedFiles.size}")
1169
- continue
1170
- }
1171
-
1172
- // Encode chunk
1173
- encoder.encodePCMChunk(chunk.data, false)
1174
-
1175
- // Cache chunks for consecutive duplicates
1176
- val optIndex = filesCompleted
1177
- if (consecutiveDuplicates.containsKey(optIndex)) {
1178
- cachedChunks.add(chunk.data.clone())
1179
- }
1180
- }
1181
-
1182
- // Wait for all decode tasks to complete
1183
- latch.await()
1184
- Log.d("AudioConcat", "All parallel decode tasks completed")
1185
-
1186
- } finally {
1187
- decoderPool.releaseAll()
1188
- executor.shutdown()
1189
- }
1190
- }
1191
-
1192
- private data class InterleavedPattern(
1193
- val filePath: String,
1194
- val silenceKey: SilenceCacheKey?,
1195
- val indices: List<Int>, // Indices where this pattern occurs
1196
- val repeatCount: Int
1197
- )
1198
-
1199
- private data class DuplicateAnalysis(
1200
- val duplicateFiles: Set<String>,
1201
- val duplicateSilence: Set<SilenceCacheKey>,
1202
- val fileOccurrences: Map<String, List<Int>>, // filePath -> list of indices
1203
- val silenceOccurrences: Map<SilenceCacheKey, List<Int>>,
1204
- val interleavedPatterns: List<InterleavedPattern>
1205
- )
1206
-
1207
- private fun analyzeDuplicates(
1208
- parsedData: List<AudioDataOrSilence>,
1209
- audioConfig: AudioConfig
1210
- ): DuplicateAnalysis {
1211
- val fileCounts = mutableMapOf<String, MutableList<Int>>()
1212
- val silenceCounts = mutableMapOf<SilenceCacheKey, MutableList<Int>>()
1213
-
1214
- parsedData.forEachIndexed { index, item ->
1215
- when (item) {
1216
- is AudioDataOrSilence.AudioFile -> {
1217
- fileCounts.getOrPut(item.filePath) { mutableListOf() }.add(index)
1218
- }
1219
- is AudioDataOrSilence.Silence -> {
1220
- val key = SilenceCacheKey(item.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
1221
- silenceCounts.getOrPut(key) { mutableListOf() }.add(index)
1222
- }
1223
- }
1224
- }
1225
-
1226
- val duplicateFiles = fileCounts.filter { it.value.size > 1 }.keys.toSet()
1227
- val duplicateSilence = silenceCounts.filter { it.value.size > 1 }.keys.toSet()
1228
-
1229
- // Detect interleaved patterns: file -> silence -> file -> silence -> file
1230
- val interleavedPatterns = mutableListOf<InterleavedPattern>()
1231
-
1232
- var i = 0
1233
- while (i < parsedData.size - 2) {
1234
- if (parsedData[i] is AudioDataOrSilence.AudioFile &&
1235
- parsedData[i + 1] is AudioDataOrSilence.Silence &&
1236
- parsedData[i + 2] is AudioDataOrSilence.AudioFile) {
1237
-
1238
- val file1 = (parsedData[i] as AudioDataOrSilence.AudioFile).filePath
1239
- val silence = parsedData[i + 1] as AudioDataOrSilence.Silence
1240
- val file2 = (parsedData[i + 2] as AudioDataOrSilence.AudioFile).filePath
1241
- val silenceKey = SilenceCacheKey(silence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
1242
-
1243
- // Check if it's the same file with silence separator
1244
- if (file1 == file2) {
1245
- var count = 1
1246
- var currentIndex = i
1247
- val indices = mutableListOf(i)
1248
-
1249
- // Count how many times this pattern repeats
1250
- while (currentIndex + 2 < parsedData.size &&
1251
- parsedData[currentIndex + 2] is AudioDataOrSilence.AudioFile &&
1252
- (parsedData[currentIndex + 2] as AudioDataOrSilence.AudioFile).filePath == file1) {
1253
-
1254
- // Check if there's a silence in between
1255
- if (currentIndex + 3 < parsedData.size &&
1256
- parsedData[currentIndex + 3] is AudioDataOrSilence.Silence) {
1257
- val nextSilence = parsedData[currentIndex + 3] as AudioDataOrSilence.Silence
1258
- val nextSilenceKey = SilenceCacheKey(nextSilence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
1259
-
1260
- if (nextSilenceKey == silenceKey) {
1261
- count++
1262
- currentIndex += 2
1263
- indices.add(currentIndex)
1264
- } else {
1265
- break
1266
- }
1267
- } else {
1268
- // Last file in the pattern (no silence after)
1269
- count++
1270
- indices.add(currentIndex + 2)
1271
- break
1272
- }
1273
- }
1274
-
1275
- if (count >= 2) {
1276
- interleavedPatterns.add(InterleavedPattern(file1, silenceKey, indices, count))
1277
- Log.d("AudioConcat", "Detected interleaved pattern: '$file1' + ${silenceKey.durationMs}ms silence, repeats $count times")
1278
- i = currentIndex + 2 // Skip processed items
1279
- continue
1280
- }
1281
- }
1282
- }
1283
- i++
1284
- }
1285
-
1286
- Log.d("AudioConcat", "Duplicate analysis: ${duplicateFiles.size} files, ${duplicateSilence.size} silence patterns, ${interleavedPatterns.size} interleaved patterns")
1287
- duplicateFiles.forEach { file ->
1288
- Log.d("AudioConcat", " File '$file' appears ${fileCounts[file]?.size} times")
1289
- }
1290
- duplicateSilence.forEach { key ->
1291
- Log.d("AudioConcat", " Silence ${key.durationMs}ms appears ${silenceCounts[key]?.size} times")
1292
- }
1293
-
1294
- return DuplicateAnalysis(duplicateFiles, duplicateSilence, fileCounts, silenceCounts, interleavedPatterns)
1295
- }
1296
-
1297
22
  private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
1298
23
  val result = mutableListOf<AudioDataOrSilence>()
1299
24
  for (i in 0 until data.size()) {
@@ -1312,548 +37,124 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
1312
37
  return result
1313
38
  }
1314
39
 
1315
- override fun getName(): String {
1316
- return NAME
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"
1317
70
  }
1318
71
 
1319
- override fun onCatalystInstanceDestroy() {
1320
- super.onCatalystInstanceDestroy()
1321
- // Clean up executor when module is destroyed
1322
- serialExecutor.shutdown()
1323
- Log.d("AudioConcat", "AudioConcat module destroyed, executor shutdown")
72
+ override fun getName(): String {
73
+ return NAME
1324
74
  }
1325
75
 
1326
76
  override fun concatAudioFiles(data: ReadableArray, outputPath: String, promise: Promise) {
1327
- // CONCURRENCY PROTECTION: Queue all operations to run serially
1328
- // This prevents MediaCodec resource conflicts when multiple calls happen simultaneously
1329
- val operationId = activeOperations.incrementAndGet()
1330
- Log.d("AudioConcat", "========== Audio Concat Queued (Operation #$operationId) ==========")
1331
-
1332
- serialExecutor.submit {
1333
- val totalStartTime = System.currentTimeMillis()
1334
- Log.d("AudioConcat", "========== Audio Concat Started (Operation #$operationId) ==========")
1335
-
1336
- try {
1337
- if (data.size() == 0) {
1338
- promise.reject("EMPTY_DATA", "Data array is empty")
1339
- return@submit
1340
- }
77
+ try {
78
+ if (data.size() == 0) {
79
+ promise.reject("EMPTY_DATA", "Data array is empty")
80
+ return
81
+ }
1341
82
 
1342
- // Parse data
1343
- val parseStartTime = System.currentTimeMillis()
1344
83
  val parsedData = parseAudioData(data)
1345
- val parseTime = System.currentTimeMillis() - parseStartTime
1346
- Log.d("AudioConcat", "✓ Parsed ${parsedData.size} items in ${parseTime}ms")
84
+ Log.d("AudioConcat", "FFmpeg merge of ${parsedData.size} items")
1347
85
  Log.d("AudioConcat", "Output: $outputPath")
1348
86
 
1349
- // Get audio config from first audio file
1350
- val configStartTime = System.currentTimeMillis()
1351
- var audioConfig: AudioConfig? = null
1352
- for (item in parsedData) {
1353
- if (item is AudioDataOrSilence.AudioFile) {
1354
- audioConfig = extractAudioConfig(item.filePath)
1355
- break
1356
- }
1357
- }
1358
-
1359
- if (audioConfig == null) {
1360
- promise.reject("NO_AUDIO_FILES", "No audio files found in data array")
1361
- return@submit
1362
- }
1363
-
1364
- val configTime = System.currentTimeMillis() - configStartTime
1365
-
1366
- // Force output sample rate to 24kHz for optimal performance
1367
- val outputSampleRate = 24000
1368
- Log.d("AudioConcat", "✓ Extracted audio config in ${configTime}ms: ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
1369
- Log.d("AudioConcat", "Output sample rate: ${outputSampleRate}Hz (24kHz optimized)")
1370
-
1371
- // Create modified config with fixed sample rate
1372
- val outputAudioConfig = AudioConfig(outputSampleRate, audioConfig.channelCount, audioConfig.bitRate)
1373
-
1374
- // Analyze duplicates to determine cache strategy
1375
- val analysisStartTime = System.currentTimeMillis()
1376
- val duplicateAnalysis = analyzeDuplicates(parsedData, outputAudioConfig)
1377
- val analysisTime = System.currentTimeMillis() - analysisStartTime
1378
- Log.d("AudioConcat", "✓ Analyzed duplicates in ${analysisTime}ms")
1379
-
1380
- // Collect all unique audio files for pre-decode caching
1381
- val allAudioFiles = parsedData.filterIsInstance<AudioDataOrSilence.AudioFile>()
1382
- .map { it.filePath }
1383
- .distinct()
1384
-
1385
- // Merge duplicate files with all unique files for comprehensive caching
1386
- // This ensures pre-decoded files are always cached, regardless of occurrence count
1387
- val filesToCache = (duplicateAnalysis.duplicateFiles + allAudioFiles).toSet()
1388
-
1389
- // Create cache instance with intelligent caching strategy
1390
- val cache = PCMCache(filesToCache, duplicateAnalysis.duplicateSilence)
1391
-
1392
87
  // Delete existing output file
1393
88
  val outputFile = File(outputPath)
1394
89
  if (outputFile.exists()) {
1395
90
  outputFile.delete()
1396
91
  }
1397
92
 
1398
- // Create streaming encoder with fixed 24kHz sample rate
1399
- val encoder = StreamingEncoder(
1400
- outputSampleRate,
1401
- audioConfig.channelCount,
1402
- audioConfig.bitRate,
1403
- outputPath
1404
- )
1405
-
1406
- try {
1407
- // Separate audio files and other items (silence)
1408
- val audioFileItems = mutableListOf<Pair<Int, String>>()
1409
- val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
1410
-
1411
- for ((index, item) in parsedData.withIndex()) {
1412
- when (item) {
1413
- is AudioDataOrSilence.AudioFile -> {
1414
- audioFileItems.add(Pair(index, item.filePath))
1415
- }
1416
- is AudioDataOrSilence.Silence -> {
1417
- nonAudioItems.add(Pair(index, item))
1418
- }
1419
- }
1420
- }
1421
-
1422
- // PRE-DECODE: Parallel decode all unique audio files to cache before processing
1423
- val uniqueAudioFiles = audioFileItems.map { it.second }.distinct()
1424
- val filesToPreDecode = uniqueAudioFiles.filter { cache.getAudioFile(it) == null }
1425
-
1426
- if (filesToPreDecode.isNotEmpty()) {
1427
- val preDecodeStartTime = System.currentTimeMillis()
1428
- val cpuCores = Runtime.getRuntime().availableProcessors()
1429
- val preDecodeThreads = getOptimalThreadCount(filesToPreDecode.size)
1430
-
1431
- Log.d("AudioConcat", "→ PRE-DECODE: ${filesToPreDecode.size} unique files using $preDecodeThreads threads (CPU cores: $cpuCores)")
1432
-
1433
- val preDecodeExecutor = Executors.newFixedThreadPool(preDecodeThreads)
1434
- val preDecodeLatch = CountDownLatch(filesToPreDecode.size)
1435
- val preDecodePool = DecoderPool()
1436
-
1437
- try {
1438
- filesToPreDecode.forEach { filePath ->
1439
- preDecodeExecutor.submit {
1440
- try {
1441
- Log.d("AudioConcat", " Pre-decoding: $filePath")
1442
-
1443
- // Decode directly to cache without intermediate queue
1444
- val extractor = MediaExtractor()
1445
- var totalBytes = 0L
1446
- val decodedChunks = mutableListOf<ByteArray>()
1447
-
1448
- try {
1449
- extractor.setDataSource(filePath)
1450
-
1451
- var audioTrackIndex = -1
1452
- var audioFormat: MediaFormat? = null
1453
-
1454
- for (i in 0 until extractor.trackCount) {
1455
- val format = extractor.getTrackFormat(i)
1456
- val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
1457
- if (mime.startsWith("audio/")) {
1458
- audioTrackIndex = i
1459
- audioFormat = format
1460
- break
1461
- }
1462
- }
1463
-
1464
- if (audioTrackIndex == -1 || audioFormat == null) {
1465
- throw Exception("No audio track found in $filePath")
1466
- }
1467
-
1468
- val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
1469
- val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
1470
-
1471
- val needsResampling = sourceSampleRate != outputSampleRate
1472
- val needsChannelConversion = sourceChannelCount != audioConfig.channelCount
1473
-
1474
- if (needsResampling || needsChannelConversion) {
1475
- Log.d("AudioConcat", " Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${outputSampleRate}Hz ${audioConfig.channelCount}ch")
1476
- }
1477
-
1478
- extractor.selectTrack(audioTrackIndex)
1479
-
1480
- val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
1481
- val reusableDecoder = preDecodePool.getDecoderForCurrentThread()
1482
- val decoder = reusableDecoder.getOrCreateDecoder(mime, audioFormat)
1483
-
1484
- val bufferInfo = MediaCodec.BufferInfo()
1485
- var isEOS = false
1486
-
1487
- while (!isEOS) {
1488
- // Feed input
1489
- val inputBufferIndex = decoder.dequeueInputBuffer(1000)
1490
- if (inputBufferIndex >= 0) {
1491
- val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
1492
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
1493
-
1494
- if (sampleSize < 0) {
1495
- decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
1496
- } else {
1497
- val presentationTimeUs = extractor.sampleTime
1498
- decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
1499
- extractor.advance()
1500
- }
1501
- }
1502
-
1503
- // Get output
1504
- val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
1505
- if (outputBufferIndex >= 0) {
1506
- val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
1507
-
1508
- if (bufferInfo.size > 0) {
1509
- var pcmData = ByteArray(bufferInfo.size)
1510
- outputBuffer.get(pcmData)
1511
-
1512
- // Convert channel count if needed
1513
- if (needsChannelConversion) {
1514
- pcmData = convertChannelCount(pcmData, sourceChannelCount, audioConfig.channelCount)
1515
- }
1516
-
1517
- // Resample if needed
1518
- if (needsResampling) {
1519
- pcmData = resamplePCM16(pcmData, sourceSampleRate, outputSampleRate, audioConfig.channelCount)
1520
- }
1521
-
1522
- decodedChunks.add(pcmData)
1523
- totalBytes += pcmData.size
1524
- }
1525
-
1526
- decoder.releaseOutputBuffer(outputBufferIndex, false)
1527
-
1528
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
1529
- isEOS = true
1530
- }
1531
- }
1532
- }
1533
-
1534
- // Cache the decoded data
1535
- if (decodedChunks.isNotEmpty()) {
1536
- cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
1537
- }
1538
-
1539
- } finally {
1540
- extractor.release()
1541
- }
93
+ // Build FFmpeg command using filter_complex
94
+ val command = buildFFmpegCommand(parsedData, outputPath)
95
+ Log.d("AudioConcat", "FFmpeg command: $command")
1542
96
 
1543
- } catch (e: Exception) {
1544
- Log.e("AudioConcat", "Error pre-decoding $filePath: ${e.message}", e)
1545
- } finally {
1546
- preDecodeLatch.countDown()
1547
- }
1548
- }
1549
- }
1550
-
1551
- // Wait for all pre-decoding to complete
1552
- preDecodeLatch.await()
1553
- preDecodePool.releaseAll()
1554
- preDecodeExecutor.shutdown()
1555
-
1556
- val preDecodeTime = System.currentTimeMillis() - preDecodeStartTime
1557
- Log.d("AudioConcat", "✓ Pre-decode completed in ${preDecodeTime}ms")
1558
-
1559
- } catch (e: Exception) {
1560
- Log.e("AudioConcat", "Error during pre-decode: ${e.message}", e)
1561
- preDecodePool.releaseAll()
1562
- preDecodeExecutor.shutdown()
1563
- }
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)
1564
103
  } else {
1565
- Log.d("AudioConcat", "→ All audio files already cached, skipping pre-decode")
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))
1566
109
  }
110
+ }
1567
111
 
1568
- // Decide whether to use parallel or sequential processing
1569
- // Parallel processing is beneficial even with few files due to multi-core CPUs
1570
- val useParallel = audioFileItems.size >= 3 // Use parallel for 3+ files (was 10)
1571
- val processingStartTime = System.currentTimeMillis()
1572
-
1573
- if (useParallel) {
1574
- val cpuCores = Runtime.getRuntime().availableProcessors()
1575
- Log.d("AudioConcat", "→ Using PARALLEL processing for ${audioFileItems.size} audio files (CPU cores: $cpuCores)")
1576
-
1577
- // Process interleaved patterns optimally
1578
- val processedIndices = mutableSetOf<Int>()
1579
-
1580
- // First, handle all interleaved patterns
1581
- duplicateAnalysis.interleavedPatterns.forEach { pattern ->
1582
- Log.d("AudioConcat", "Processing interleaved pattern: ${pattern.filePath}, ${pattern.repeatCount} repetitions")
1583
-
1584
- // Decode the file once
1585
- val filePath = pattern.filePath
1586
- val cachedData = cache.getAudioFile(filePath)
1587
-
1588
- val pcmChunks = if (cachedData != null) {
1589
- Log.d("AudioConcat", "Using cached PCM for interleaved pattern: $filePath")
1590
- cachedData.chunks
1591
- } else {
1592
- // Decode once and store
1593
- val chunks = mutableListOf<ByteArray>()
1594
- val tempQueue = LinkedBlockingQueue<PCMChunk>(100)
1595
- val latch = CountDownLatch(1)
1596
- val seqStart = AtomicInteger(0)
1597
-
1598
- parallelDecodeToQueue(filePath, tempQueue, seqStart, outputSampleRate, audioConfig.channelCount, latch, cache)
1599
-
1600
- // Collect chunks
1601
- var collecting = true
1602
- while (collecting) {
1603
- val chunk = tempQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
1604
- if (chunk != null) {
1605
- if (!chunk.isEndOfStream) {
1606
- chunks.add(chunk.data)
1607
- } else {
1608
- collecting = false
1609
- }
1610
- } else if (latch.count == 0L) {
1611
- collecting = false
1612
- }
1613
- }
1614
-
1615
- latch.await()
1616
- chunks
1617
- }
1618
-
1619
- // Get silence PCM
1620
- val silencePCM = pattern.silenceKey?.let { cache.getSilence(it) }
1621
- ?: pattern.silenceKey?.let {
1622
- val totalSamples = ((it.durationMs / 1000.0) * it.sampleRate).toInt()
1623
- val bytesPerSample = it.channelCount * 2
1624
- ByteArray(totalSamples * bytesPerSample)
1625
- }
1626
-
1627
- // Encode the pattern: file -> silence -> file -> silence -> ...
1628
- // OPTIMIZATION: Batch chunks to reduce encoder call overhead
1629
- val patternStartTime = System.currentTimeMillis()
1630
-
1631
- // Combine all chunks from the file into a single buffer
1632
- val combinedFileBuffer = if (pcmChunks.size > 10) {
1633
- val totalSize = pcmChunks.sumOf { it.size }
1634
- val buffer = ByteArray(totalSize)
1635
- var offset = 0
1636
- pcmChunks.forEach { chunk ->
1637
- System.arraycopy(chunk, 0, buffer, offset, chunk.size)
1638
- offset += chunk.size
1639
- }
1640
- Log.d("AudioConcat", " Batched ${pcmChunks.size} chunks into single buffer (${totalSize / 1024}KB)")
1641
- buffer
1642
- } else {
1643
- null
1644
- }
1645
-
1646
- repeat(pattern.repeatCount) { iteration ->
1647
- // Encode file (batched or individual chunks)
1648
- if (combinedFileBuffer != null) {
1649
- encoder.encodePCMChunk(combinedFileBuffer, false)
1650
- } else {
1651
- pcmChunks.forEach { chunk ->
1652
- encoder.encodePCMChunk(chunk, false)
1653
- }
1654
- }
1655
-
1656
- // Encode silence (except after the last file)
1657
- if (iteration < pattern.repeatCount - 1 && silencePCM != null) {
1658
- encoder.encodePCMChunk(silencePCM, false)
1659
- }
1660
- }
1661
-
1662
- val patternTime = System.currentTimeMillis() - patternStartTime
1663
- Log.d("AudioConcat", " Encoded interleaved pattern in ${patternTime}ms")
1664
-
1665
- // Mark these indices as processed
1666
- pattern.indices.forEach { idx ->
1667
- processedIndices.add(idx)
1668
- if (idx + 1 < parsedData.size && parsedData[idx + 1] is AudioDataOrSilence.Silence) {
1669
- processedIndices.add(idx + 1)
1670
- }
1671
- }
1672
- }
1673
-
1674
- // Then process remaining items normally
1675
- var audioFileIdx = 0
1676
- for ((index, item) in parsedData.withIndex()) {
1677
- if (processedIndices.contains(index)) {
1678
- if (item is AudioDataOrSilence.AudioFile) audioFileIdx++
1679
- continue
1680
- }
1681
-
1682
- when (item) {
1683
- is AudioDataOrSilence.AudioFile -> {
1684
- // Collect consecutive audio files for parallel processing
1685
- val consecutiveFiles = mutableListOf<Pair<Int, String>>()
1686
- var currentIdx = audioFileIdx
1687
-
1688
- while (currentIdx < audioFileItems.size) {
1689
- val (itemIdx, filePath) = audioFileItems[currentIdx]
1690
- if (processedIndices.contains(itemIdx)) {
1691
- currentIdx++
1692
- continue
1693
- }
1694
- if (itemIdx != index + (currentIdx - audioFileIdx)) break
1695
- consecutiveFiles.add(Pair(itemIdx, filePath))
1696
- currentIdx++
1697
- }
1698
-
1699
- if (consecutiveFiles.isNotEmpty()) {
1700
- // OPTIMIZATION: Fast path for cached files - avoid thread pool overhead
1701
- val allCached = consecutiveFiles.all { (_, filePath) -> cache.getAudioFile(filePath) != null }
1702
-
1703
- if (allCached) {
1704
- // Direct encoding from cache without parallel processing overhead
1705
- val startTime = System.currentTimeMillis()
1706
- Log.d("AudioConcat", "Fast path: encoding ${consecutiveFiles.size} cached files directly")
1707
-
1708
- consecutiveFiles.forEach { (itemIdx, filePath) ->
1709
- val cachedData = cache.getAudioFile(filePath)!!
1710
- val chunkCount = cachedData.chunks.size
1711
-
1712
- Log.d("AudioConcat", " File[$itemIdx]: ${cachedData.totalBytes / 1024}KB in $chunkCount chunks")
1713
-
1714
- val encodeStartTime = System.currentTimeMillis()
1715
-
1716
- // OPTIMIZATION: Batch all chunks into single buffer to reduce encoder call overhead
1717
- // Instead of 300+ calls at ~2.5ms each, make 1 call
1718
- if (chunkCount > 10) {
1719
- // Many small chunks - combine into single buffer for massive speedup
1720
- val batchStartTime = System.currentTimeMillis()
1721
- val combinedBuffer = ByteArray(cachedData.totalBytes.toInt())
1722
- var offset = 0
1723
-
1724
- cachedData.chunks.forEach { chunk ->
1725
- System.arraycopy(chunk, 0, combinedBuffer, offset, chunk.size)
1726
- offset += chunk.size
1727
- }
1728
-
1729
- val batchTime = System.currentTimeMillis() - batchStartTime
1730
- Log.d("AudioConcat", " Batched $chunkCount chunks in ${batchTime}ms")
112
+ } catch (e: Exception) {
113
+ Log.e("AudioConcat", "Error parsing data: ${e.message}", e)
114
+ promise.reject("PARSE_ERROR", e.message, e)
115
+ }
116
+ }
1731
117
 
1732
- // Single encoder call instead of 300+
1733
- encoder.encodePCMChunk(combinedBuffer, false)
1734
- } else {
1735
- // Few chunks - encode directly (rare case)
1736
- cachedData.chunks.forEach { chunk ->
1737
- encoder.encodePCMChunk(chunk, false)
1738
- }
1739
- }
118
+ override fun convertToM4a(inputPath: String, outputPath: String, promise: Promise) {
119
+ try {
120
+ // Check if input file exists
121
+ val inputFile = File(inputPath)
122
+ if (!inputFile.exists()) {
123
+ promise.reject("INPUT_NOT_FOUND", "Input file does not exist: $inputPath")
124
+ return
125
+ }
1740
126
 
1741
- val encodeTime = System.currentTimeMillis() - encodeStartTime
1742
- val throughputMBps = if (encodeTime > 0) {
1743
- (cachedData.totalBytes / 1024.0 / 1024.0) / (encodeTime / 1000.0)
1744
- } else 0.0
127
+ Log.d("AudioConcat", "Converting to M4A: $inputPath")
128
+ Log.d("AudioConcat", "Output: $outputPath")
1745
129
 
1746
- Log.d("AudioConcat", " Encoded in ${encodeTime}ms (${String.format("%.2f", throughputMBps)} MB/s)")
1747
- }
130
+ // Delete existing output file
131
+ val outputFile = File(outputPath)
132
+ if (outputFile.exists()) {
133
+ outputFile.delete()
134
+ }
1748
135
 
1749
- val elapsedTime = System.currentTimeMillis() - startTime
1750
- val totalKB = consecutiveFiles.sumOf { (_, filePath) -> cache.getAudioFile(filePath)!!.totalBytes } / 1024
1751
- Log.d("AudioConcat", " Encoded ${consecutiveFiles.size} cached files (${totalKB}KB) in ${elapsedTime}ms")
1752
- } else {
1753
- // Standard parallel processing for non-cached files
1754
- val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
1755
- Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
1756
- parallelProcessAudioFiles(
1757
- consecutiveFiles,
1758
- encoder,
1759
- outputSampleRate,
1760
- audioConfig.channelCount,
1761
- cache,
1762
- numThreads = optimalThreads
1763
- )
1764
- }
1765
- audioFileIdx = currentIdx
1766
- }
1767
- }
136
+ // Build FFmpeg command for M4A conversion with AAC codec
137
+ val command = "-y -i \"$inputPath\" -c:a aac -b:a 128k -f mp4 \"$outputPath\" -loglevel level+error"
138
+ Log.d("AudioConcat", "FFmpeg command: $command")
1768
139
 
1769
- is AudioDataOrSilence.Silence -> {
1770
- val durationMs = item.durationMs
1771
- Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1772
- streamEncodeSilence(
1773
- durationMs,
1774
- encoder,
1775
- outputSampleRate,
1776
- audioConfig.channelCount,
1777
- cache
1778
- )
1779
- }
1780
- }
1781
- }
140
+ // Execute FFmpeg command
141
+ FFmpegKit.executeAsync(command) { session ->
142
+ val returnCode = session.returnCode
143
+ if (ReturnCode.isSuccess(returnCode)) {
144
+ Log.d("AudioConcat", "Successfully converted to M4A: $outputPath")
145
+ promise.resolve(outputPath)
1782
146
  } else {
1783
- Log.d("AudioConcat", "→ Using sequential processing for ${audioFileItems.size} audio files")
1784
-
1785
- // Create a reusable decoder for sequential processing
1786
- val reusableDecoder = ReusableDecoder()
1787
- Log.d("AudioConcat", "Created reusable decoder for sequential processing")
1788
-
1789
- try {
1790
- // Process each item sequentially (with decoder reuse)
1791
- for ((index, item) in parsedData.withIndex()) {
1792
- when (item) {
1793
- is AudioDataOrSilence.AudioFile -> {
1794
- val filePath = item.filePath
1795
- Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
1796
-
1797
- val isLastFile = (index == parsedData.size - 1)
1798
- streamDecodeAudioFile(
1799
- filePath,
1800
- encoder,
1801
- isLastFile,
1802
- outputSampleRate,
1803
- audioConfig.channelCount,
1804
- reusableDecoder
1805
- )
1806
- }
1807
-
1808
- is AudioDataOrSilence.Silence -> {
1809
- val durationMs = item.durationMs
1810
- Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
1811
-
1812
- streamEncodeSilence(
1813
- durationMs,
1814
- encoder,
1815
- outputSampleRate,
1816
- audioConfig.channelCount,
1817
- cache
1818
- )
1819
- }
1820
- }
1821
- }
1822
- } finally {
1823
- // Release the reusable decoder when done
1824
- reusableDecoder.release()
1825
- Log.d("AudioConcat", "Released reusable decoder")
1826
- }
147
+ val output = session.output
148
+ val error = session.failStackTrace
149
+ Log.e("AudioConcat", "FFmpeg failed: $output")
150
+ Log.e("AudioConcat", "Error: $error")
151
+ promise.reject("FFMPEG_ERROR", "FFmpeg conversion failed: $output", Exception(error))
1827
152
  }
1828
-
1829
- val processingTime = System.currentTimeMillis() - processingStartTime
1830
- Log.d("AudioConcat", "✓ Processing completed in ${processingTime}ms")
1831
-
1832
- // Finish encoding
1833
- val encodingFinishStartTime = System.currentTimeMillis()
1834
- encoder.finish()
1835
- val encodingFinishTime = System.currentTimeMillis() - encodingFinishStartTime
1836
- Log.d("AudioConcat", "✓ Encoding finalized in ${encodingFinishTime}ms")
1837
-
1838
- // Log cache statistics
1839
- Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
1840
-
1841
- val totalTime = System.currentTimeMillis() - totalStartTime
1842
- Log.d("AudioConcat", "========== Total Time: ${totalTime}ms (${totalTime / 1000.0}s) ==========")
1843
- Log.d("AudioConcat", "Successfully merged audio to $outputPath")
1844
- promise.resolve(outputPath)
1845
-
1846
- } catch (e: Exception) {
1847
- Log.e("AudioConcat", "Error during streaming merge: ${e.message}", e)
1848
- promise.reject("MERGE_ERROR", e.message, e)
1849
- }
1850
-
1851
- } catch (e: Exception) {
1852
- Log.e("AudioConcat", "Error parsing data: ${e.message}", e)
1853
- promise.reject("PARSE_ERROR", e.message, e)
1854
- } finally {
1855
- Log.d("AudioConcat", "========== Audio Concat Finished (Operation #$operationId) ==========")
1856
153
  }
154
+
155
+ } catch (e: Exception) {
156
+ Log.e("AudioConcat", "Error converting to M4A: ${e.message}", e)
157
+ promise.reject("CONVERSION_ERROR", e.message, e)
1857
158
  }
1858
159
  }
1859
160