react-native-audio-concat 0.3.0 → 0.4.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.
|
@@ -13,6 +13,12 @@ import android.media.MediaMuxer
|
|
|
13
13
|
import java.io.File
|
|
14
14
|
import java.nio.ByteBuffer
|
|
15
15
|
import android.util.Log
|
|
16
|
+
import java.util.concurrent.Executors
|
|
17
|
+
import java.util.concurrent.BlockingQueue
|
|
18
|
+
import java.util.concurrent.LinkedBlockingQueue
|
|
19
|
+
import java.util.concurrent.CountDownLatch
|
|
20
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
21
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
16
22
|
|
|
17
23
|
@ReactModule(name = AudioConcatModule.NAME)
|
|
18
24
|
class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
@@ -29,6 +35,84 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
29
35
|
data class Silence(val durationMs: Double) : AudioDataOrSilence()
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
private data class PCMChunk(
|
|
39
|
+
val data: ByteArray,
|
|
40
|
+
val sequenceNumber: Int,
|
|
41
|
+
val isEndOfStream: Boolean = false
|
|
42
|
+
) {
|
|
43
|
+
companion object {
|
|
44
|
+
fun endOfStream(sequenceNumber: Int) = PCMChunk(ByteArray(0), sequenceNumber, true)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Cache for decoded PCM data
|
|
49
|
+
private data class CachedPCMData(
|
|
50
|
+
val chunks: List<ByteArray>,
|
|
51
|
+
val totalBytes: Long
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
private data class SilenceCacheKey(
|
|
55
|
+
val durationMs: Double,
|
|
56
|
+
val sampleRate: Int,
|
|
57
|
+
val channelCount: Int
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
private class PCMCache(
|
|
61
|
+
private val shouldCacheFile: Set<String>,
|
|
62
|
+
private val shouldCacheSilence: Set<SilenceCacheKey>
|
|
63
|
+
) {
|
|
64
|
+
private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
|
|
65
|
+
private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
|
|
66
|
+
private val maxCacheSizeMB = 100 // Limit cache to 100MB
|
|
67
|
+
private var currentCacheSizeBytes = 0L
|
|
68
|
+
|
|
69
|
+
fun getAudioFile(filePath: String): CachedPCMData? {
|
|
70
|
+
return audioFileCache[filePath]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun putAudioFile(filePath: String, data: CachedPCMData) {
|
|
74
|
+
// Only cache if this file appears multiple times
|
|
75
|
+
if (!shouldCacheFile.contains(filePath)) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check cache size limit
|
|
80
|
+
if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeMB * 1024 * 1024) {
|
|
81
|
+
Log.d("AudioConcat", "Cache full, not caching: $filePath")
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
audioFileCache[filePath] = data
|
|
86
|
+
currentCacheSizeBytes += data.totalBytes
|
|
87
|
+
Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB)")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fun getSilence(key: SilenceCacheKey): ByteArray? {
|
|
91
|
+
return silenceCache[key]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fun putSilence(key: SilenceCacheKey, data: ByteArray) {
|
|
95
|
+
// Only cache if this silence pattern appears multiple times
|
|
96
|
+
if (!shouldCacheSilence.contains(key)) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
silenceCache[key] = data
|
|
101
|
+
Log.d("AudioConcat", "Cached silence: ${key.durationMs}ms")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun clear() {
|
|
105
|
+
audioFileCache.clear()
|
|
106
|
+
silenceCache.clear()
|
|
107
|
+
currentCacheSizeBytes = 0
|
|
108
|
+
Log.d("AudioConcat", "Cache cleared")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fun getStats(): String {
|
|
112
|
+
return "Audio files: ${audioFileCache.size}, Silence patterns: ${silenceCache.size}, Size: ${currentCacheSizeBytes / 1024}KB"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
32
116
|
private fun extractAudioConfig(filePath: String): AudioConfig {
|
|
33
117
|
val extractor = MediaExtractor()
|
|
34
118
|
try {
|
|
@@ -79,7 +163,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
79
163
|
)
|
|
80
164
|
outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
|
81
165
|
outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
|
82
|
-
outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384
|
|
166
|
+
outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 65536) // Increased from 16384
|
|
83
167
|
|
|
84
168
|
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
|
85
169
|
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
@@ -89,22 +173,48 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
89
173
|
}
|
|
90
174
|
|
|
91
175
|
fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
|
|
92
|
-
//
|
|
93
|
-
val
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
176
|
+
// Split large PCM data into smaller chunks that fit in encoder buffer
|
|
177
|
+
val maxChunkSize = 65536 // Match KEY_MAX_INPUT_SIZE
|
|
178
|
+
var offset = 0
|
|
179
|
+
|
|
180
|
+
while (offset < pcmData.size) {
|
|
181
|
+
val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
|
|
182
|
+
val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
|
|
183
|
+
|
|
184
|
+
// Feed PCM data chunk to encoder
|
|
185
|
+
val inputBufferIndex = encoder.dequeueInputBuffer(10000)
|
|
186
|
+
if (inputBufferIndex >= 0) {
|
|
187
|
+
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
|
|
188
|
+
val bufferCapacity = inputBuffer.capacity()
|
|
98
189
|
|
|
99
|
-
|
|
100
|
-
|
|
190
|
+
// Ensure chunk fits in buffer
|
|
191
|
+
val actualChunkSize = minOf(chunkSize, bufferCapacity)
|
|
101
192
|
|
|
102
|
-
|
|
103
|
-
|
|
193
|
+
inputBuffer.clear()
|
|
194
|
+
inputBuffer.put(pcmData, offset, actualChunkSize)
|
|
195
|
+
|
|
196
|
+
val presentationTimeUs = totalPresentationTimeUs
|
|
197
|
+
totalPresentationTimeUs += (actualChunkSize.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
|
|
198
|
+
|
|
199
|
+
val flags = if (isLastChunk) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
|
200
|
+
encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
|
|
201
|
+
|
|
202
|
+
offset += actualChunkSize
|
|
203
|
+
} else {
|
|
204
|
+
// Buffer not available, drain first
|
|
205
|
+
drainEncoder(false)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Drain encoder output periodically
|
|
209
|
+
if (offset < pcmData.size || !isLastChunk) {
|
|
210
|
+
drainEncoder(false)
|
|
211
|
+
}
|
|
104
212
|
}
|
|
105
213
|
|
|
106
|
-
//
|
|
107
|
-
|
|
214
|
+
// Final drain if last chunk
|
|
215
|
+
if (isLast) {
|
|
216
|
+
drainEncoder(true)
|
|
217
|
+
}
|
|
108
218
|
|
|
109
219
|
return true
|
|
110
220
|
}
|
|
@@ -175,10 +285,266 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
175
285
|
}
|
|
176
286
|
}
|
|
177
287
|
|
|
288
|
+
private fun resamplePCM16(
|
|
289
|
+
input: ByteArray,
|
|
290
|
+
inputSampleRate: Int,
|
|
291
|
+
outputSampleRate: Int,
|
|
292
|
+
channelCount: Int
|
|
293
|
+
): ByteArray {
|
|
294
|
+
if (inputSampleRate == outputSampleRate) {
|
|
295
|
+
return input
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
|
|
299
|
+
val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
|
|
300
|
+
val output = ByteArray(outputSampleCount * 2 * channelCount)
|
|
301
|
+
|
|
302
|
+
val ratio = inputSampleRate.toDouble() / outputSampleRate.toDouble()
|
|
303
|
+
|
|
304
|
+
for (i in 0 until outputSampleCount) {
|
|
305
|
+
val srcPos = i * ratio
|
|
306
|
+
val srcIndex = srcPos.toInt()
|
|
307
|
+
val fraction = srcPos - srcIndex
|
|
308
|
+
|
|
309
|
+
for (ch in 0 until channelCount) {
|
|
310
|
+
// Get current and next sample
|
|
311
|
+
val idx1 = (srcIndex * channelCount + ch) * 2
|
|
312
|
+
val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
|
|
313
|
+
|
|
314
|
+
val sample1 = if (idx1 + 1 < input.size) {
|
|
315
|
+
(input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
|
|
316
|
+
} else {
|
|
317
|
+
0
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
val sample2 = if (idx2 + 1 < input.size) {
|
|
321
|
+
(input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
|
|
322
|
+
} else {
|
|
323
|
+
sample1
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Convert to signed 16-bit
|
|
327
|
+
val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
|
|
328
|
+
val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
|
|
329
|
+
|
|
330
|
+
// Linear interpolation
|
|
331
|
+
val interpolated = (s1 + (s2 - s1) * fraction).toInt()
|
|
332
|
+
|
|
333
|
+
// Clamp to 16-bit range
|
|
334
|
+
val clamped = interpolated.coerceIn(-32768, 32767)
|
|
335
|
+
|
|
336
|
+
// Convert back to unsigned and write
|
|
337
|
+
val outIdx = (i * channelCount + ch) * 2
|
|
338
|
+
output[outIdx] = (clamped and 0xFF).toByte()
|
|
339
|
+
output[outIdx + 1] = (clamped shr 8).toByte()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return output
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private fun convertChannelCount(
|
|
347
|
+
input: ByteArray,
|
|
348
|
+
inputChannels: Int,
|
|
349
|
+
outputChannels: Int
|
|
350
|
+
): ByteArray {
|
|
351
|
+
if (inputChannels == outputChannels) {
|
|
352
|
+
return input
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
val sampleCount = input.size / (2 * inputChannels)
|
|
356
|
+
val output = ByteArray(sampleCount * 2 * outputChannels)
|
|
357
|
+
|
|
358
|
+
when {
|
|
359
|
+
inputChannels == 1 && outputChannels == 2 -> {
|
|
360
|
+
// Mono to Stereo: duplicate the channel
|
|
361
|
+
for (i in 0 until sampleCount) {
|
|
362
|
+
val srcIdx = i * 2
|
|
363
|
+
val dstIdx = i * 4
|
|
364
|
+
output[dstIdx] = input[srcIdx]
|
|
365
|
+
output[dstIdx + 1] = input[srcIdx + 1]
|
|
366
|
+
output[dstIdx + 2] = input[srcIdx]
|
|
367
|
+
output[dstIdx + 3] = input[srcIdx + 1]
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
inputChannels == 2 && outputChannels == 1 -> {
|
|
371
|
+
// Stereo to Mono: average the channels
|
|
372
|
+
for (i in 0 until sampleCount) {
|
|
373
|
+
val srcIdx = i * 4
|
|
374
|
+
val dstIdx = i * 2
|
|
375
|
+
|
|
376
|
+
val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
|
|
377
|
+
val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
|
|
378
|
+
|
|
379
|
+
val leftSigned = if (left > 32767) left - 65536 else left
|
|
380
|
+
val rightSigned = if (right > 32767) right - 65536 else right
|
|
381
|
+
|
|
382
|
+
val avg = ((leftSigned + rightSigned) / 2).coerceIn(-32768, 32767)
|
|
383
|
+
|
|
384
|
+
output[dstIdx] = (avg and 0xFF).toByte()
|
|
385
|
+
output[dstIdx + 1] = (avg shr 8).toByte()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else -> {
|
|
389
|
+
// Fallback: just take the first channel
|
|
390
|
+
for (i in 0 until sampleCount) {
|
|
391
|
+
val srcIdx = i * 2 * inputChannels
|
|
392
|
+
val dstIdx = i * 2 * outputChannels
|
|
393
|
+
for (ch in 0 until minOf(inputChannels, outputChannels)) {
|
|
394
|
+
output[dstIdx + ch * 2] = input[srcIdx + ch * 2]
|
|
395
|
+
output[dstIdx + ch * 2 + 1] = input[srcIdx + ch * 2 + 1]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return output
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private fun parallelDecodeToQueue(
|
|
405
|
+
filePath: String,
|
|
406
|
+
queue: BlockingQueue<PCMChunk>,
|
|
407
|
+
sequenceStart: AtomicInteger,
|
|
408
|
+
targetSampleRate: Int,
|
|
409
|
+
targetChannelCount: Int,
|
|
410
|
+
latch: CountDownLatch,
|
|
411
|
+
cache: PCMCache
|
|
412
|
+
) {
|
|
413
|
+
try {
|
|
414
|
+
// Check cache first
|
|
415
|
+
val cachedData = cache.getAudioFile(filePath)
|
|
416
|
+
if (cachedData != null) {
|
|
417
|
+
Log.d("AudioConcat", "Using cached PCM for: $filePath")
|
|
418
|
+
// Put cached chunks to queue
|
|
419
|
+
for (chunk in cachedData.chunks) {
|
|
420
|
+
val seqNum = sequenceStart.getAndIncrement()
|
|
421
|
+
queue.put(PCMChunk(chunk, seqNum))
|
|
422
|
+
}
|
|
423
|
+
latch.countDown()
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
val extractor = MediaExtractor()
|
|
428
|
+
var decoder: MediaCodec? = null
|
|
429
|
+
val decodedChunks = mutableListOf<ByteArray>()
|
|
430
|
+
var totalBytes = 0L
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
extractor.setDataSource(filePath)
|
|
434
|
+
|
|
435
|
+
var audioTrackIndex = -1
|
|
436
|
+
var audioFormat: MediaFormat? = null
|
|
437
|
+
|
|
438
|
+
for (i in 0 until extractor.trackCount) {
|
|
439
|
+
val format = extractor.getTrackFormat(i)
|
|
440
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
441
|
+
if (mime.startsWith("audio/")) {
|
|
442
|
+
audioTrackIndex = i
|
|
443
|
+
audioFormat = format
|
|
444
|
+
break
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (audioTrackIndex == -1 || audioFormat == null) {
|
|
449
|
+
throw Exception("No audio track found in $filePath")
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
453
|
+
val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
454
|
+
|
|
455
|
+
val needsResampling = sourceSampleRate != targetSampleRate
|
|
456
|
+
val needsChannelConversion = sourceChannelCount != targetChannelCount
|
|
457
|
+
|
|
458
|
+
if (needsResampling || needsChannelConversion) {
|
|
459
|
+
Log.d("AudioConcat", "Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
extractor.selectTrack(audioTrackIndex)
|
|
463
|
+
|
|
464
|
+
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
465
|
+
decoder = MediaCodec.createDecoderByType(mime)
|
|
466
|
+
decoder.configure(audioFormat, null, null, 0)
|
|
467
|
+
decoder.start()
|
|
468
|
+
|
|
469
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
470
|
+
var isEOS = false
|
|
471
|
+
|
|
472
|
+
while (!isEOS) {
|
|
473
|
+
// Feed input to decoder
|
|
474
|
+
val inputBufferIndex = decoder.dequeueInputBuffer(10000)
|
|
475
|
+
if (inputBufferIndex >= 0) {
|
|
476
|
+
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
|
477
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
478
|
+
|
|
479
|
+
if (sampleSize < 0) {
|
|
480
|
+
decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
481
|
+
} else {
|
|
482
|
+
val presentationTimeUs = extractor.sampleTime
|
|
483
|
+
decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
|
|
484
|
+
extractor.advance()
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Get PCM output from decoder and put to queue
|
|
489
|
+
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
|
|
490
|
+
if (outputBufferIndex >= 0) {
|
|
491
|
+
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
|
492
|
+
|
|
493
|
+
if (bufferInfo.size > 0) {
|
|
494
|
+
var pcmData = ByteArray(bufferInfo.size)
|
|
495
|
+
outputBuffer.get(pcmData)
|
|
496
|
+
|
|
497
|
+
// Convert channel count if needed
|
|
498
|
+
if (needsChannelConversion) {
|
|
499
|
+
pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Resample if needed
|
|
503
|
+
if (needsResampling) {
|
|
504
|
+
pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Store for caching
|
|
508
|
+
decodedChunks.add(pcmData.clone())
|
|
509
|
+
totalBytes += pcmData.size
|
|
510
|
+
|
|
511
|
+
// Put to queue with sequence number
|
|
512
|
+
val seqNum = sequenceStart.getAndIncrement()
|
|
513
|
+
queue.put(PCMChunk(pcmData, seqNum))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
decoder.releaseOutputBuffer(outputBufferIndex, false)
|
|
517
|
+
|
|
518
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
519
|
+
isEOS = true
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Cache the decoded data
|
|
525
|
+
if (decodedChunks.isNotEmpty()) {
|
|
526
|
+
cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
} finally {
|
|
530
|
+
decoder?.stop()
|
|
531
|
+
decoder?.release()
|
|
532
|
+
extractor.release()
|
|
533
|
+
}
|
|
534
|
+
} catch (e: Exception) {
|
|
535
|
+
Log.e("AudioConcat", "Error in parallel decode: ${e.message}", e)
|
|
536
|
+
throw e
|
|
537
|
+
} finally {
|
|
538
|
+
latch.countDown()
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
178
542
|
private fun streamDecodeAudioFile(
|
|
179
543
|
filePath: String,
|
|
180
544
|
encoder: StreamingEncoder,
|
|
181
|
-
isLastFile: Boolean
|
|
545
|
+
isLastFile: Boolean,
|
|
546
|
+
targetSampleRate: Int,
|
|
547
|
+
targetChannelCount: Int
|
|
182
548
|
) {
|
|
183
549
|
val extractor = MediaExtractor()
|
|
184
550
|
var decoder: MediaCodec? = null
|
|
@@ -203,6 +569,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
203
569
|
throw Exception("No audio track found in $filePath")
|
|
204
570
|
}
|
|
205
571
|
|
|
572
|
+
val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
573
|
+
val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
574
|
+
|
|
575
|
+
val needsResampling = sourceSampleRate != targetSampleRate
|
|
576
|
+
val needsChannelConversion = sourceChannelCount != targetChannelCount
|
|
577
|
+
|
|
578
|
+
if (needsResampling || needsChannelConversion) {
|
|
579
|
+
Log.d("AudioConcat", "File: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
|
|
580
|
+
}
|
|
581
|
+
|
|
206
582
|
extractor.selectTrack(audioTrackIndex)
|
|
207
583
|
|
|
208
584
|
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
@@ -212,7 +588,6 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
212
588
|
|
|
213
589
|
val bufferInfo = MediaCodec.BufferInfo()
|
|
214
590
|
var isEOS = false
|
|
215
|
-
val pcmChunkSize = 8192 // Process in 8KB chunks
|
|
216
591
|
|
|
217
592
|
while (!isEOS) {
|
|
218
593
|
// Feed input to decoder
|
|
@@ -236,10 +611,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
236
611
|
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
|
237
612
|
|
|
238
613
|
if (bufferInfo.size > 0) {
|
|
239
|
-
|
|
614
|
+
var pcmData = ByteArray(bufferInfo.size)
|
|
240
615
|
outputBuffer.get(pcmData)
|
|
241
616
|
|
|
242
|
-
//
|
|
617
|
+
// Convert channel count if needed
|
|
618
|
+
if (needsChannelConversion) {
|
|
619
|
+
pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Resample if needed
|
|
623
|
+
if (needsResampling) {
|
|
624
|
+
pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Stream to encoder
|
|
243
628
|
encoder.encodePCMChunk(pcmData, false)
|
|
244
629
|
}
|
|
245
630
|
|
|
@@ -262,24 +647,261 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
262
647
|
durationMs: Double,
|
|
263
648
|
encoder: StreamingEncoder,
|
|
264
649
|
sampleRate: Int,
|
|
265
|
-
channelCount: Int
|
|
650
|
+
channelCount: Int,
|
|
651
|
+
cache: PCMCache
|
|
266
652
|
) {
|
|
653
|
+
val cacheKey = SilenceCacheKey(durationMs, sampleRate, channelCount)
|
|
654
|
+
|
|
655
|
+
// Check cache first
|
|
656
|
+
val cachedSilence = cache.getSilence(cacheKey)
|
|
657
|
+
if (cachedSilence != null) {
|
|
658
|
+
Log.d("AudioConcat", "Using cached silence: ${durationMs}ms")
|
|
659
|
+
encoder.encodePCMChunk(cachedSilence, false)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Generate silence
|
|
267
664
|
val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
|
|
268
|
-
val chunkSamples = 4096 // Process in chunks
|
|
269
665
|
val bytesPerSample = channelCount * 2 // 16-bit stereo
|
|
666
|
+
val totalBytes = totalSamples * bytesPerSample
|
|
667
|
+
|
|
668
|
+
// For short silence (< 5 seconds), cache as single chunk
|
|
669
|
+
if (durationMs < 5000) {
|
|
670
|
+
val silenceData = ByteArray(totalBytes) // All zeros = silence
|
|
671
|
+
cache.putSilence(cacheKey, silenceData)
|
|
672
|
+
encoder.encodePCMChunk(silenceData, false)
|
|
673
|
+
} else {
|
|
674
|
+
// For longer silence, process in chunks without caching
|
|
675
|
+
val chunkSamples = 16384
|
|
676
|
+
var samplesRemaining = totalSamples
|
|
677
|
+
|
|
678
|
+
while (samplesRemaining > 0) {
|
|
679
|
+
val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
|
|
680
|
+
val chunkBytes = currentChunkSamples * bytesPerSample
|
|
681
|
+
val silenceChunk = ByteArray(chunkBytes)
|
|
682
|
+
|
|
683
|
+
encoder.encodePCMChunk(silenceChunk, false)
|
|
684
|
+
samplesRemaining -= currentChunkSamples
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private fun parallelProcessAudioFiles(
|
|
690
|
+
audioFiles: List<Pair<Int, String>>, // (index, filePath)
|
|
691
|
+
encoder: StreamingEncoder,
|
|
692
|
+
targetSampleRate: Int,
|
|
693
|
+
targetChannelCount: Int,
|
|
694
|
+
cache: PCMCache,
|
|
695
|
+
numThreads: Int = 3
|
|
696
|
+
) {
|
|
697
|
+
if (audioFiles.isEmpty()) return
|
|
698
|
+
|
|
699
|
+
// Group consecutive duplicate files
|
|
700
|
+
val optimizedFiles = mutableListOf<Pair<Int, String>>()
|
|
701
|
+
val consecutiveDuplicates = mutableMapOf<Int, Int>() // originalIndex -> count
|
|
702
|
+
|
|
703
|
+
var i = 0
|
|
704
|
+
while (i < audioFiles.size) {
|
|
705
|
+
val (index, filePath) = audioFiles[i]
|
|
706
|
+
var count = 1
|
|
707
|
+
|
|
708
|
+
// Check for consecutive duplicates
|
|
709
|
+
while (i + count < audioFiles.size && audioFiles[i + count].second == filePath) {
|
|
710
|
+
count++
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (count > 1) {
|
|
714
|
+
Log.d("AudioConcat", "Detected $count consecutive occurrences of: $filePath")
|
|
715
|
+
optimizedFiles.add(Pair(index, filePath))
|
|
716
|
+
consecutiveDuplicates[optimizedFiles.size - 1] = count
|
|
717
|
+
} else {
|
|
718
|
+
optimizedFiles.add(Pair(index, filePath))
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
i += count
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
val pcmQueue = LinkedBlockingQueue<PCMChunk>(100)
|
|
725
|
+
val executor = Executors.newFixedThreadPool(numThreads)
|
|
726
|
+
val latch = CountDownLatch(optimizedFiles.size)
|
|
727
|
+
val sequenceCounter = AtomicInteger(0)
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
// Submit decode tasks for unique files only
|
|
731
|
+
optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
|
|
732
|
+
executor.submit {
|
|
733
|
+
try {
|
|
734
|
+
val fileSequenceStart = AtomicInteger(sequenceCounter.get())
|
|
735
|
+
sequenceCounter.addAndGet(1000000)
|
|
270
736
|
|
|
271
|
-
|
|
737
|
+
Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
|
|
738
|
+
parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
|
|
272
739
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
740
|
+
// Mark end with duplicate count
|
|
741
|
+
val repeatCount = consecutiveDuplicates[optIndex] ?: 1
|
|
742
|
+
val endSeqNum = fileSequenceStart.get()
|
|
743
|
+
pcmQueue.put(PCMChunk(ByteArray(0), endSeqNum, true)) // endOfStream marker with repeat count
|
|
277
744
|
|
|
278
|
-
|
|
279
|
-
|
|
745
|
+
} catch (e: Exception) {
|
|
746
|
+
Log.e("AudioConcat", "Error decoding file $filePath: ${e.message}", e)
|
|
747
|
+
latch.countDown()
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Consumer thread: encode in order
|
|
753
|
+
var filesCompleted = 0
|
|
754
|
+
var cachedChunks = mutableListOf<ByteArray>()
|
|
755
|
+
var isCollectingChunks = false
|
|
756
|
+
|
|
757
|
+
while (filesCompleted < optimizedFiles.size) {
|
|
758
|
+
val chunk = pcmQueue.take()
|
|
759
|
+
|
|
760
|
+
if (chunk.isEndOfStream) {
|
|
761
|
+
val optIndex = filesCompleted
|
|
762
|
+
val repeatCount = consecutiveDuplicates[optIndex] ?: 1
|
|
763
|
+
|
|
764
|
+
if (repeatCount > 1 && cachedChunks.isNotEmpty()) {
|
|
765
|
+
// Repeat the cached chunks
|
|
766
|
+
Log.d("AudioConcat", "Repeating cached chunks ${repeatCount - 1} more times")
|
|
767
|
+
repeat(repeatCount - 1) {
|
|
768
|
+
cachedChunks.forEach { data ->
|
|
769
|
+
encoder.encodePCMChunk(data, false)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
cachedChunks.clear()
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
filesCompleted++
|
|
776
|
+
isCollectingChunks = false
|
|
777
|
+
Log.d("AudioConcat", "Completed file $filesCompleted/${optimizedFiles.size}")
|
|
778
|
+
continue
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Encode chunk
|
|
782
|
+
encoder.encodePCMChunk(chunk.data, false)
|
|
783
|
+
|
|
784
|
+
// Cache chunks for consecutive duplicates
|
|
785
|
+
val optIndex = filesCompleted
|
|
786
|
+
if (consecutiveDuplicates.containsKey(optIndex)) {
|
|
787
|
+
cachedChunks.add(chunk.data.clone())
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Wait for all decode tasks to complete
|
|
792
|
+
latch.await()
|
|
793
|
+
Log.d("AudioConcat", "All parallel decode tasks completed")
|
|
794
|
+
|
|
795
|
+
} finally {
|
|
796
|
+
executor.shutdown()
|
|
280
797
|
}
|
|
281
798
|
}
|
|
282
799
|
|
|
800
|
+
private data class InterleavedPattern(
|
|
801
|
+
val filePath: String,
|
|
802
|
+
val silenceKey: SilenceCacheKey?,
|
|
803
|
+
val indices: List<Int>, // Indices where this pattern occurs
|
|
804
|
+
val repeatCount: Int
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
private data class DuplicateAnalysis(
|
|
808
|
+
val duplicateFiles: Set<String>,
|
|
809
|
+
val duplicateSilence: Set<SilenceCacheKey>,
|
|
810
|
+
val fileOccurrences: Map<String, List<Int>>, // filePath -> list of indices
|
|
811
|
+
val silenceOccurrences: Map<SilenceCacheKey, List<Int>>,
|
|
812
|
+
val interleavedPatterns: List<InterleavedPattern>
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
private fun analyzeDuplicates(
|
|
816
|
+
parsedData: List<AudioDataOrSilence>,
|
|
817
|
+
audioConfig: AudioConfig
|
|
818
|
+
): DuplicateAnalysis {
|
|
819
|
+
val fileCounts = mutableMapOf<String, MutableList<Int>>()
|
|
820
|
+
val silenceCounts = mutableMapOf<SilenceCacheKey, MutableList<Int>>()
|
|
821
|
+
|
|
822
|
+
parsedData.forEachIndexed { index, item ->
|
|
823
|
+
when (item) {
|
|
824
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
825
|
+
fileCounts.getOrPut(item.filePath) { mutableListOf() }.add(index)
|
|
826
|
+
}
|
|
827
|
+
is AudioDataOrSilence.Silence -> {
|
|
828
|
+
val key = SilenceCacheKey(item.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
|
|
829
|
+
silenceCounts.getOrPut(key) { mutableListOf() }.add(index)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
val duplicateFiles = fileCounts.filter { it.value.size > 1 }.keys.toSet()
|
|
835
|
+
val duplicateSilence = silenceCounts.filter { it.value.size > 1 }.keys.toSet()
|
|
836
|
+
|
|
837
|
+
// Detect interleaved patterns: file -> silence -> file -> silence -> file
|
|
838
|
+
val interleavedPatterns = mutableListOf<InterleavedPattern>()
|
|
839
|
+
|
|
840
|
+
var i = 0
|
|
841
|
+
while (i < parsedData.size - 2) {
|
|
842
|
+
if (parsedData[i] is AudioDataOrSilence.AudioFile &&
|
|
843
|
+
parsedData[i + 1] is AudioDataOrSilence.Silence &&
|
|
844
|
+
parsedData[i + 2] is AudioDataOrSilence.AudioFile) {
|
|
845
|
+
|
|
846
|
+
val file1 = (parsedData[i] as AudioDataOrSilence.AudioFile).filePath
|
|
847
|
+
val silence = parsedData[i + 1] as AudioDataOrSilence.Silence
|
|
848
|
+
val file2 = (parsedData[i + 2] as AudioDataOrSilence.AudioFile).filePath
|
|
849
|
+
val silenceKey = SilenceCacheKey(silence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
|
|
850
|
+
|
|
851
|
+
// Check if it's the same file with silence separator
|
|
852
|
+
if (file1 == file2) {
|
|
853
|
+
var count = 1
|
|
854
|
+
var currentIndex = i
|
|
855
|
+
val indices = mutableListOf(i)
|
|
856
|
+
|
|
857
|
+
// Count how many times this pattern repeats
|
|
858
|
+
while (currentIndex + 2 < parsedData.size &&
|
|
859
|
+
parsedData[currentIndex + 2] is AudioDataOrSilence.AudioFile &&
|
|
860
|
+
(parsedData[currentIndex + 2] as AudioDataOrSilence.AudioFile).filePath == file1) {
|
|
861
|
+
|
|
862
|
+
// Check if there's a silence in between
|
|
863
|
+
if (currentIndex + 3 < parsedData.size &&
|
|
864
|
+
parsedData[currentIndex + 3] is AudioDataOrSilence.Silence) {
|
|
865
|
+
val nextSilence = parsedData[currentIndex + 3] as AudioDataOrSilence.Silence
|
|
866
|
+
val nextSilenceKey = SilenceCacheKey(nextSilence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
|
|
867
|
+
|
|
868
|
+
if (nextSilenceKey == silenceKey) {
|
|
869
|
+
count++
|
|
870
|
+
currentIndex += 2
|
|
871
|
+
indices.add(currentIndex)
|
|
872
|
+
} else {
|
|
873
|
+
break
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
// Last file in the pattern (no silence after)
|
|
877
|
+
count++
|
|
878
|
+
indices.add(currentIndex + 2)
|
|
879
|
+
break
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (count >= 2) {
|
|
884
|
+
interleavedPatterns.add(InterleavedPattern(file1, silenceKey, indices, count))
|
|
885
|
+
Log.d("AudioConcat", "Detected interleaved pattern: '$file1' + ${silenceKey.durationMs}ms silence, repeats $count times")
|
|
886
|
+
i = currentIndex + 2 // Skip processed items
|
|
887
|
+
continue
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
i++
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
Log.d("AudioConcat", "Duplicate analysis: ${duplicateFiles.size} files, ${duplicateSilence.size} silence patterns, ${interleavedPatterns.size} interleaved patterns")
|
|
895
|
+
duplicateFiles.forEach { file ->
|
|
896
|
+
Log.d("AudioConcat", " File '$file' appears ${fileCounts[file]?.size} times")
|
|
897
|
+
}
|
|
898
|
+
duplicateSilence.forEach { key ->
|
|
899
|
+
Log.d("AudioConcat", " Silence ${key.durationMs}ms appears ${silenceCounts[key]?.size} times")
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return DuplicateAnalysis(duplicateFiles, duplicateSilence, fileCounts, silenceCounts, interleavedPatterns)
|
|
903
|
+
}
|
|
904
|
+
|
|
283
905
|
private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
|
|
284
906
|
val result = mutableListOf<AudioDataOrSilence>()
|
|
285
907
|
for (i in 0 until data.size()) {
|
|
@@ -329,6 +951,12 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
329
951
|
|
|
330
952
|
Log.d("AudioConcat", "Audio config: ${audioConfig.sampleRate}Hz, ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
|
|
331
953
|
|
|
954
|
+
// Analyze duplicates to determine cache strategy
|
|
955
|
+
val duplicateAnalysis = analyzeDuplicates(parsedData, audioConfig)
|
|
956
|
+
|
|
957
|
+
// Create cache instance with intelligent caching strategy
|
|
958
|
+
val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
|
|
959
|
+
|
|
332
960
|
// Delete existing output file
|
|
333
961
|
val outputFile = File(outputPath)
|
|
334
962
|
if (outputFile.exists()) {
|
|
@@ -344,33 +972,192 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
344
972
|
)
|
|
345
973
|
|
|
346
974
|
try {
|
|
347
|
-
//
|
|
975
|
+
// Separate audio files and other items (silence)
|
|
976
|
+
val audioFileItems = mutableListOf<Pair<Int, String>>()
|
|
977
|
+
val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
|
|
978
|
+
|
|
348
979
|
for ((index, item) in parsedData.withIndex()) {
|
|
349
980
|
when (item) {
|
|
350
981
|
is AudioDataOrSilence.AudioFile -> {
|
|
351
|
-
|
|
352
|
-
|
|
982
|
+
audioFileItems.add(Pair(index, item.filePath))
|
|
983
|
+
}
|
|
984
|
+
is AudioDataOrSilence.Silence -> {
|
|
985
|
+
nonAudioItems.add(Pair(index, item))
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Decide whether to use parallel or sequential processing
|
|
991
|
+
val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
|
|
992
|
+
|
|
993
|
+
if (useParallel) {
|
|
994
|
+
Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
|
|
995
|
+
|
|
996
|
+
// Process interleaved patterns optimally
|
|
997
|
+
val processedIndices = mutableSetOf<Int>()
|
|
998
|
+
|
|
999
|
+
// First, handle all interleaved patterns
|
|
1000
|
+
duplicateAnalysis.interleavedPatterns.forEach { pattern ->
|
|
1001
|
+
Log.d("AudioConcat", "Processing interleaved pattern: ${pattern.filePath}, ${pattern.repeatCount} repetitions")
|
|
1002
|
+
|
|
1003
|
+
// Decode the file once
|
|
1004
|
+
val filePath = pattern.filePath
|
|
1005
|
+
val cachedData = cache.getAudioFile(filePath)
|
|
1006
|
+
|
|
1007
|
+
val pcmChunks = if (cachedData != null) {
|
|
1008
|
+
Log.d("AudioConcat", "Using cached PCM for interleaved pattern: $filePath")
|
|
1009
|
+
cachedData.chunks
|
|
1010
|
+
} else {
|
|
1011
|
+
// Decode once and store
|
|
1012
|
+
val chunks = mutableListOf<ByteArray>()
|
|
1013
|
+
val tempQueue = LinkedBlockingQueue<PCMChunk>(100)
|
|
1014
|
+
val latch = CountDownLatch(1)
|
|
1015
|
+
val seqStart = AtomicInteger(0)
|
|
353
1016
|
|
|
354
|
-
|
|
355
|
-
|
|
1017
|
+
parallelDecodeToQueue(filePath, tempQueue, seqStart, audioConfig.sampleRate, audioConfig.channelCount, latch, cache)
|
|
1018
|
+
|
|
1019
|
+
// Collect chunks
|
|
1020
|
+
var collecting = true
|
|
1021
|
+
while (collecting) {
|
|
1022
|
+
val chunk = tempQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
1023
|
+
if (chunk != null) {
|
|
1024
|
+
if (!chunk.isEndOfStream) {
|
|
1025
|
+
chunks.add(chunk.data)
|
|
1026
|
+
} else {
|
|
1027
|
+
collecting = false
|
|
1028
|
+
}
|
|
1029
|
+
} else if (latch.count == 0L) {
|
|
1030
|
+
collecting = false
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
latch.await()
|
|
1035
|
+
chunks
|
|
356
1036
|
}
|
|
357
1037
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
1038
|
+
// Get silence PCM
|
|
1039
|
+
val silencePCM = pattern.silenceKey?.let { cache.getSilence(it) }
|
|
1040
|
+
?: pattern.silenceKey?.let {
|
|
1041
|
+
val totalSamples = ((it.durationMs / 1000.0) * it.sampleRate).toInt()
|
|
1042
|
+
val bytesPerSample = it.channelCount * 2
|
|
1043
|
+
ByteArray(totalSamples * bytesPerSample)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Encode the pattern: file -> silence -> file -> silence -> ...
|
|
1047
|
+
repeat(pattern.repeatCount) { iteration ->
|
|
1048
|
+
// Encode file
|
|
1049
|
+
pcmChunks.forEach { chunk ->
|
|
1050
|
+
encoder.encodePCMChunk(chunk, false)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Encode silence (except after the last file)
|
|
1054
|
+
if (iteration < pattern.repeatCount - 1 && silencePCM != null) {
|
|
1055
|
+
encoder.encodePCMChunk(silencePCM, false)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Mark these indices as processed
|
|
1060
|
+
pattern.indices.forEach { idx ->
|
|
1061
|
+
processedIndices.add(idx)
|
|
1062
|
+
if (idx + 1 < parsedData.size && parsedData[idx + 1] is AudioDataOrSilence.Silence) {
|
|
1063
|
+
processedIndices.add(idx + 1)
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Then process remaining items normally
|
|
1069
|
+
var audioFileIdx = 0
|
|
1070
|
+
for ((index, item) in parsedData.withIndex()) {
|
|
1071
|
+
if (processedIndices.contains(index)) {
|
|
1072
|
+
if (item is AudioDataOrSilence.AudioFile) audioFileIdx++
|
|
1073
|
+
continue
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
when (item) {
|
|
1077
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
1078
|
+
// Collect consecutive audio files for parallel processing
|
|
1079
|
+
val consecutiveFiles = mutableListOf<Pair<Int, String>>()
|
|
1080
|
+
var currentIdx = audioFileIdx
|
|
1081
|
+
|
|
1082
|
+
while (currentIdx < audioFileItems.size) {
|
|
1083
|
+
val (itemIdx, filePath) = audioFileItems[currentIdx]
|
|
1084
|
+
if (processedIndices.contains(itemIdx)) {
|
|
1085
|
+
currentIdx++
|
|
1086
|
+
continue
|
|
1087
|
+
}
|
|
1088
|
+
if (itemIdx != index + (currentIdx - audioFileIdx)) break
|
|
1089
|
+
consecutiveFiles.add(Pair(itemIdx, filePath))
|
|
1090
|
+
currentIdx++
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (consecutiveFiles.isNotEmpty()) {
|
|
1094
|
+
parallelProcessAudioFiles(
|
|
1095
|
+
consecutiveFiles,
|
|
1096
|
+
encoder,
|
|
1097
|
+
audioConfig.sampleRate,
|
|
1098
|
+
audioConfig.channelCount,
|
|
1099
|
+
cache,
|
|
1100
|
+
numThreads = 3
|
|
1101
|
+
)
|
|
1102
|
+
audioFileIdx = currentIdx
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
is AudioDataOrSilence.Silence -> {
|
|
1107
|
+
val durationMs = item.durationMs
|
|
1108
|
+
Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
|
|
1109
|
+
streamEncodeSilence(
|
|
1110
|
+
durationMs,
|
|
1111
|
+
encoder,
|
|
1112
|
+
audioConfig.sampleRate,
|
|
1113
|
+
audioConfig.channelCount,
|
|
1114
|
+
cache
|
|
1115
|
+
)
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} else {
|
|
1120
|
+
Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
|
|
1121
|
+
|
|
1122
|
+
// Process each item sequentially (original behavior)
|
|
1123
|
+
for ((index, item) in parsedData.withIndex()) {
|
|
1124
|
+
when (item) {
|
|
1125
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
1126
|
+
val filePath = item.filePath
|
|
1127
|
+
Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
|
|
1128
|
+
|
|
1129
|
+
val isLastFile = (index == parsedData.size - 1)
|
|
1130
|
+
streamDecodeAudioFile(
|
|
1131
|
+
filePath,
|
|
1132
|
+
encoder,
|
|
1133
|
+
isLastFile,
|
|
1134
|
+
audioConfig.sampleRate,
|
|
1135
|
+
audioConfig.channelCount
|
|
1136
|
+
)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
is AudioDataOrSilence.Silence -> {
|
|
1140
|
+
val durationMs = item.durationMs
|
|
1141
|
+
Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
|
|
1142
|
+
|
|
1143
|
+
streamEncodeSilence(
|
|
1144
|
+
durationMs,
|
|
1145
|
+
encoder,
|
|
1146
|
+
audioConfig.sampleRate,
|
|
1147
|
+
audioConfig.channelCount,
|
|
1148
|
+
cache
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
368
1151
|
}
|
|
369
1152
|
}
|
|
370
1153
|
}
|
|
371
1154
|
|
|
372
1155
|
// Finish encoding
|
|
373
1156
|
encoder.finish()
|
|
1157
|
+
|
|
1158
|
+
// Log cache statistics
|
|
1159
|
+
Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
|
|
1160
|
+
|
|
374
1161
|
Log.d("AudioConcat", "Successfully merged audio to $outputPath")
|
|
375
1162
|
promise.resolve(outputPath)
|
|
376
1163
|
|