react-native-audio-concat 0.3.0 → 0.5.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,131 @@ 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
|
+
// Buffer pool for silence generation to reduce memory allocations
|
|
61
|
+
private object SilenceBufferPool {
|
|
62
|
+
private val pool = ConcurrentHashMap<Int, ByteArray>()
|
|
63
|
+
private val standardSizes = listOf(4096, 8192, 16384, 32768, 65536, 131072)
|
|
64
|
+
|
|
65
|
+
init {
|
|
66
|
+
// Pre-allocate common silence buffer sizes
|
|
67
|
+
standardSizes.forEach { size ->
|
|
68
|
+
pool[size] = ByteArray(size)
|
|
69
|
+
}
|
|
70
|
+
Log.d("AudioConcat", "SilenceBufferPool initialized with ${standardSizes.size} standard sizes")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun getBuffer(requestedSize: Int): ByteArray {
|
|
74
|
+
// Find the smallest standard size that fits the request
|
|
75
|
+
val standardSize = standardSizes.firstOrNull { it >= requestedSize }
|
|
76
|
+
|
|
77
|
+
return if (standardSize != null) {
|
|
78
|
+
// Return pooled buffer (already zeroed)
|
|
79
|
+
pool.getOrPut(standardSize) { ByteArray(standardSize) }
|
|
80
|
+
} else {
|
|
81
|
+
// Size too large for pool, create new buffer
|
|
82
|
+
ByteArray(requestedSize)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun clear() {
|
|
87
|
+
pool.clear()
|
|
88
|
+
Log.d("AudioConcat", "SilenceBufferPool cleared")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private class PCMCache(
|
|
93
|
+
private val shouldCacheFile: Set<String>,
|
|
94
|
+
private val shouldCacheSilence: Set<SilenceCacheKey>
|
|
95
|
+
) {
|
|
96
|
+
private val audioFileCache = ConcurrentHashMap<String, CachedPCMData>()
|
|
97
|
+
private val silenceCache = ConcurrentHashMap<SilenceCacheKey, ByteArray>()
|
|
98
|
+
private var currentCacheSizeBytes = 0L
|
|
99
|
+
|
|
100
|
+
// Dynamic cache size based on available memory
|
|
101
|
+
private val maxCacheSizeBytes: Long
|
|
102
|
+
get() {
|
|
103
|
+
val runtime = Runtime.getRuntime()
|
|
104
|
+
val maxMemory = runtime.maxMemory()
|
|
105
|
+
val usedMemory = runtime.totalMemory() - runtime.freeMemory()
|
|
106
|
+
val availableMemory = maxMemory - usedMemory
|
|
107
|
+
|
|
108
|
+
// Use 20% of available memory for cache, but constrain between 50MB and 200MB
|
|
109
|
+
val dynamicCacheMB = (availableMemory / (1024 * 1024) * 0.2).toLong()
|
|
110
|
+
val cacheMB = dynamicCacheMB.coerceIn(50, 200)
|
|
111
|
+
|
|
112
|
+
return cacheMB * 1024 * 1024
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fun getAudioFile(filePath: String): CachedPCMData? {
|
|
116
|
+
return audioFileCache[filePath]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fun putAudioFile(filePath: String, data: CachedPCMData) {
|
|
120
|
+
// Only cache if this file appears multiple times
|
|
121
|
+
if (!shouldCacheFile.contains(filePath)) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check cache size limit (dynamic)
|
|
126
|
+
if (currentCacheSizeBytes + data.totalBytes > maxCacheSizeBytes) {
|
|
127
|
+
val maxCacheMB = maxCacheSizeBytes / (1024 * 1024)
|
|
128
|
+
Log.d("AudioConcat", "Cache full ($maxCacheMB MB), not caching: $filePath")
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
audioFileCache[filePath] = data
|
|
133
|
+
currentCacheSizeBytes += data.totalBytes
|
|
134
|
+
Log.d("AudioConcat", "Cached audio file: $filePath (${data.totalBytes / 1024}KB, total: ${currentCacheSizeBytes / 1024}KB)")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun getSilence(key: SilenceCacheKey): ByteArray? {
|
|
138
|
+
return silenceCache[key]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fun putSilence(key: SilenceCacheKey, data: ByteArray) {
|
|
142
|
+
// Only cache if this silence pattern appears multiple times
|
|
143
|
+
if (!shouldCacheSilence.contains(key)) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
silenceCache[key] = data
|
|
148
|
+
Log.d("AudioConcat", "Cached silence: ${key.durationMs}ms")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fun clear() {
|
|
152
|
+
audioFileCache.clear()
|
|
153
|
+
silenceCache.clear()
|
|
154
|
+
currentCacheSizeBytes = 0
|
|
155
|
+
Log.d("AudioConcat", "Cache cleared")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fun getStats(): String {
|
|
159
|
+
return "Audio files: ${audioFileCache.size}, Silence patterns: ${silenceCache.size}, Size: ${currentCacheSizeBytes / 1024}KB"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
32
163
|
private fun extractAudioConfig(filePath: String): AudioConfig {
|
|
33
164
|
val extractor = MediaExtractor()
|
|
34
165
|
try {
|
|
@@ -67,6 +198,7 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
67
198
|
private var totalPresentationTimeUs = 0L
|
|
68
199
|
private val sampleRate: Int
|
|
69
200
|
private val channelCount: Int
|
|
201
|
+
private val maxChunkSize: Int
|
|
70
202
|
|
|
71
203
|
init {
|
|
72
204
|
this.sampleRate = sampleRate
|
|
@@ -79,7 +211,20 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
79
211
|
)
|
|
80
212
|
outputFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
|
81
213
|
outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
|
82
|
-
|
|
214
|
+
|
|
215
|
+
// Optimized buffer size based on audio parameters
|
|
216
|
+
// Target: ~1024 samples per frame for optimal AAC encoding
|
|
217
|
+
val samplesPerFrame = 1024
|
|
218
|
+
val bytesPerSample = channelCount * 2 // 16-bit PCM
|
|
219
|
+
val optimalBufferSize = samplesPerFrame * bytesPerSample
|
|
220
|
+
// Use at least the optimal size, but allow for some overhead
|
|
221
|
+
val bufferSize = (optimalBufferSize * 1.5).toInt().coerceAtLeast(16384)
|
|
222
|
+
outputFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize)
|
|
223
|
+
|
|
224
|
+
// Store for use in encodePCMChunk
|
|
225
|
+
this.maxChunkSize = bufferSize
|
|
226
|
+
|
|
227
|
+
Log.d("AudioConcat", "Encoder buffer size: $bufferSize bytes (${samplesPerFrame} samples, ${sampleRate}Hz, ${channelCount}ch)")
|
|
83
228
|
|
|
84
229
|
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
|
85
230
|
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
@@ -89,29 +234,55 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
89
234
|
}
|
|
90
235
|
|
|
91
236
|
fun encodePCMChunk(pcmData: ByteArray, isLast: Boolean = false): Boolean {
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
237
|
+
// Split large PCM data into smaller chunks that fit in encoder buffer (use configured size)
|
|
238
|
+
var offset = 0
|
|
239
|
+
|
|
240
|
+
while (offset < pcmData.size) {
|
|
241
|
+
val chunkSize = minOf(maxChunkSize, pcmData.size - offset)
|
|
242
|
+
val isLastChunk = (offset + chunkSize >= pcmData.size) && isLast
|
|
243
|
+
|
|
244
|
+
// Feed PCM data chunk to encoder (reduced timeout for better throughput)
|
|
245
|
+
val inputBufferIndex = encoder.dequeueInputBuffer(1000)
|
|
246
|
+
if (inputBufferIndex >= 0) {
|
|
247
|
+
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
|
|
248
|
+
val bufferCapacity = inputBuffer.capacity()
|
|
249
|
+
|
|
250
|
+
// Ensure chunk fits in buffer
|
|
251
|
+
val actualChunkSize = minOf(chunkSize, bufferCapacity)
|
|
252
|
+
|
|
253
|
+
inputBuffer.clear()
|
|
254
|
+
inputBuffer.put(pcmData, offset, actualChunkSize)
|
|
98
255
|
|
|
99
|
-
|
|
100
|
-
|
|
256
|
+
val presentationTimeUs = totalPresentationTimeUs
|
|
257
|
+
totalPresentationTimeUs += (actualChunkSize.toLong() * 1_000_000) / (sampleRate * channelCount * 2)
|
|
101
258
|
|
|
102
|
-
|
|
103
|
-
|
|
259
|
+
val flags = if (isLastChunk) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
|
260
|
+
encoder.queueInputBuffer(inputBufferIndex, 0, actualChunkSize, presentationTimeUs, flags)
|
|
261
|
+
|
|
262
|
+
offset += actualChunkSize
|
|
263
|
+
} else {
|
|
264
|
+
// Buffer not available, drain first
|
|
265
|
+
drainEncoder(false)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Drain encoder output periodically
|
|
269
|
+
if (offset < pcmData.size || !isLastChunk) {
|
|
270
|
+
drainEncoder(false)
|
|
271
|
+
}
|
|
104
272
|
}
|
|
105
273
|
|
|
106
|
-
//
|
|
107
|
-
|
|
274
|
+
// Final drain if last chunk
|
|
275
|
+
if (isLast) {
|
|
276
|
+
drainEncoder(true)
|
|
277
|
+
}
|
|
108
278
|
|
|
109
279
|
return true
|
|
110
280
|
}
|
|
111
281
|
|
|
112
282
|
private fun drainEncoder(endOfStream: Boolean) {
|
|
113
283
|
while (true) {
|
|
114
|
-
|
|
284
|
+
// Use shorter timeout for better responsiveness
|
|
285
|
+
val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 1000 else 0)
|
|
115
286
|
|
|
116
287
|
when (outputBufferIndex) {
|
|
117
288
|
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
|
@@ -156,8 +327,8 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
156
327
|
}
|
|
157
328
|
|
|
158
329
|
fun finish() {
|
|
159
|
-
// Signal end of stream
|
|
160
|
-
val inputBufferIndex = encoder.dequeueInputBuffer(
|
|
330
|
+
// Signal end of stream (reduced timeout)
|
|
331
|
+
val inputBufferIndex = encoder.dequeueInputBuffer(1000)
|
|
161
332
|
if (inputBufferIndex >= 0) {
|
|
162
333
|
encoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
163
334
|
}
|
|
@@ -175,10 +346,274 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
175
346
|
}
|
|
176
347
|
}
|
|
177
348
|
|
|
349
|
+
private fun resamplePCM16(
|
|
350
|
+
input: ByteArray,
|
|
351
|
+
inputSampleRate: Int,
|
|
352
|
+
outputSampleRate: Int,
|
|
353
|
+
channelCount: Int
|
|
354
|
+
): ByteArray {
|
|
355
|
+
if (inputSampleRate == outputSampleRate) {
|
|
356
|
+
return input
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
val inputSampleCount = input.size / (2 * channelCount) // 16-bit = 2 bytes per sample
|
|
360
|
+
val outputSampleCount = (inputSampleCount.toLong() * outputSampleRate / inputSampleRate).toInt()
|
|
361
|
+
val output = ByteArray(outputSampleCount * 2 * channelCount)
|
|
362
|
+
|
|
363
|
+
// Use fixed-point arithmetic (16.16 format) to avoid floating-point operations
|
|
364
|
+
// This provides 3-5x performance improvement
|
|
365
|
+
val step = ((inputSampleRate.toLong() shl 16) / outputSampleRate).toInt()
|
|
366
|
+
var srcPos = 0
|
|
367
|
+
|
|
368
|
+
for (i in 0 until outputSampleCount) {
|
|
369
|
+
val srcIndex = srcPos shr 16
|
|
370
|
+
val fraction = srcPos and 0xFFFF // Fractional part in 16-bit fixed-point
|
|
371
|
+
|
|
372
|
+
// Boundary check: ensure we don't go beyond input array
|
|
373
|
+
if (srcIndex >= inputSampleCount - 1) {
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (ch in 0 until channelCount) {
|
|
378
|
+
// Get current and next sample indices
|
|
379
|
+
val idx1 = (srcIndex * channelCount + ch) * 2
|
|
380
|
+
val idx2 = ((srcIndex + 1) * channelCount + ch) * 2
|
|
381
|
+
|
|
382
|
+
// Read 16-bit samples (little-endian)
|
|
383
|
+
val sample1 = (input[idx1].toInt() and 0xFF) or (input[idx1 + 1].toInt() shl 8)
|
|
384
|
+
val sample2 = if (idx2 + 1 < input.size) {
|
|
385
|
+
(input[idx2].toInt() and 0xFF) or (input[idx2 + 1].toInt() shl 8)
|
|
386
|
+
} else {
|
|
387
|
+
sample1
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Convert to signed 16-bit
|
|
391
|
+
val s1 = if (sample1 > 32767) sample1 - 65536 else sample1
|
|
392
|
+
val s2 = if (sample2 > 32767) sample2 - 65536 else sample2
|
|
393
|
+
|
|
394
|
+
// Linear interpolation using integer arithmetic
|
|
395
|
+
// interpolated = s1 + (s2 - s1) * fraction
|
|
396
|
+
// fraction is in 16.16 format, so we shift right by 16 after multiplication
|
|
397
|
+
val interpolated = s1 + (((s2 - s1) * fraction) shr 16)
|
|
398
|
+
|
|
399
|
+
// Clamp to 16-bit range
|
|
400
|
+
val clamped = interpolated.coerceIn(-32768, 32767)
|
|
401
|
+
|
|
402
|
+
// Convert back to unsigned and write (little-endian)
|
|
403
|
+
val outIdx = (i * channelCount + ch) * 2
|
|
404
|
+
output[outIdx] = (clamped and 0xFF).toByte()
|
|
405
|
+
output[outIdx + 1] = (clamped shr 8).toByte()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
srcPos += step
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return output
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fun convertChannelCount(
|
|
415
|
+
input: ByteArray,
|
|
416
|
+
inputChannels: Int,
|
|
417
|
+
outputChannels: Int
|
|
418
|
+
): ByteArray {
|
|
419
|
+
if (inputChannels == outputChannels) {
|
|
420
|
+
return input
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
val sampleCount = input.size / (2 * inputChannels)
|
|
424
|
+
val output = ByteArray(sampleCount * 2 * outputChannels)
|
|
425
|
+
|
|
426
|
+
when {
|
|
427
|
+
inputChannels == 1 && outputChannels == 2 -> {
|
|
428
|
+
// Mono to Stereo: duplicate the channel
|
|
429
|
+
for (i in 0 until sampleCount) {
|
|
430
|
+
val srcIdx = i * 2
|
|
431
|
+
val dstIdx = i * 4
|
|
432
|
+
output[dstIdx] = input[srcIdx]
|
|
433
|
+
output[dstIdx + 1] = input[srcIdx + 1]
|
|
434
|
+
output[dstIdx + 2] = input[srcIdx]
|
|
435
|
+
output[dstIdx + 3] = input[srcIdx + 1]
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
inputChannels == 2 && outputChannels == 1 -> {
|
|
439
|
+
// Stereo to Mono: average the channels
|
|
440
|
+
for (i in 0 until sampleCount) {
|
|
441
|
+
val srcIdx = i * 4
|
|
442
|
+
val dstIdx = i * 2
|
|
443
|
+
|
|
444
|
+
val left = (input[srcIdx].toInt() and 0xFF) or (input[srcIdx + 1].toInt() shl 8)
|
|
445
|
+
val right = (input[srcIdx + 2].toInt() and 0xFF) or (input[srcIdx + 3].toInt() shl 8)
|
|
446
|
+
|
|
447
|
+
val leftSigned = if (left > 32767) left - 65536 else left
|
|
448
|
+
val rightSigned = if (right > 32767) right - 65536 else right
|
|
449
|
+
|
|
450
|
+
// Use bit shift instead of division for better performance (x / 2 = x >> 1)
|
|
451
|
+
val avg = ((leftSigned + rightSigned) shr 1).coerceIn(-32768, 32767)
|
|
452
|
+
|
|
453
|
+
output[dstIdx] = (avg and 0xFF).toByte()
|
|
454
|
+
output[dstIdx + 1] = (avg shr 8).toByte()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else -> {
|
|
458
|
+
// Fallback: just take the first channel
|
|
459
|
+
for (i in 0 until sampleCount) {
|
|
460
|
+
val srcIdx = i * 2 * inputChannels
|
|
461
|
+
val dstIdx = i * 2 * outputChannels
|
|
462
|
+
for (ch in 0 until minOf(inputChannels, outputChannels)) {
|
|
463
|
+
output[dstIdx + ch * 2] = input[srcIdx + ch * 2]
|
|
464
|
+
output[dstIdx + ch * 2 + 1] = input[srcIdx + ch * 2 + 1]
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return output
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private fun parallelDecodeToQueue(
|
|
474
|
+
filePath: String,
|
|
475
|
+
queue: BlockingQueue<PCMChunk>,
|
|
476
|
+
sequenceStart: AtomicInteger,
|
|
477
|
+
targetSampleRate: Int,
|
|
478
|
+
targetChannelCount: Int,
|
|
479
|
+
latch: CountDownLatch,
|
|
480
|
+
cache: PCMCache
|
|
481
|
+
) {
|
|
482
|
+
try {
|
|
483
|
+
// Check cache first
|
|
484
|
+
val cachedData = cache.getAudioFile(filePath)
|
|
485
|
+
if (cachedData != null) {
|
|
486
|
+
Log.d("AudioConcat", "Using cached PCM for: $filePath")
|
|
487
|
+
// Put cached chunks to queue
|
|
488
|
+
for (chunk in cachedData.chunks) {
|
|
489
|
+
val seqNum = sequenceStart.getAndIncrement()
|
|
490
|
+
queue.put(PCMChunk(chunk, seqNum))
|
|
491
|
+
}
|
|
492
|
+
latch.countDown()
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
val extractor = MediaExtractor()
|
|
497
|
+
var decoder: MediaCodec? = null
|
|
498
|
+
val decodedChunks = mutableListOf<ByteArray>()
|
|
499
|
+
var totalBytes = 0L
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
extractor.setDataSource(filePath)
|
|
503
|
+
|
|
504
|
+
var audioTrackIndex = -1
|
|
505
|
+
var audioFormat: MediaFormat? = null
|
|
506
|
+
|
|
507
|
+
for (i in 0 until extractor.trackCount) {
|
|
508
|
+
val format = extractor.getTrackFormat(i)
|
|
509
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
510
|
+
if (mime.startsWith("audio/")) {
|
|
511
|
+
audioTrackIndex = i
|
|
512
|
+
audioFormat = format
|
|
513
|
+
break
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (audioTrackIndex == -1 || audioFormat == null) {
|
|
518
|
+
throw Exception("No audio track found in $filePath")
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
522
|
+
val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
523
|
+
|
|
524
|
+
val needsResampling = sourceSampleRate != targetSampleRate
|
|
525
|
+
val needsChannelConversion = sourceChannelCount != targetChannelCount
|
|
526
|
+
|
|
527
|
+
if (needsResampling || needsChannelConversion) {
|
|
528
|
+
Log.d("AudioConcat", "Parallel decode: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
extractor.selectTrack(audioTrackIndex)
|
|
532
|
+
|
|
533
|
+
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
534
|
+
decoder = MediaCodec.createDecoderByType(mime)
|
|
535
|
+
decoder.configure(audioFormat, null, null, 0)
|
|
536
|
+
decoder.start()
|
|
537
|
+
|
|
538
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
539
|
+
var isEOS = false
|
|
540
|
+
|
|
541
|
+
while (!isEOS) {
|
|
542
|
+
// Feed input to decoder (reduced timeout for faster processing)
|
|
543
|
+
val inputBufferIndex = decoder.dequeueInputBuffer(1000)
|
|
544
|
+
if (inputBufferIndex >= 0) {
|
|
545
|
+
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
|
546
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
547
|
+
|
|
548
|
+
if (sampleSize < 0) {
|
|
549
|
+
decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
550
|
+
} else {
|
|
551
|
+
val presentationTimeUs = extractor.sampleTime
|
|
552
|
+
decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0)
|
|
553
|
+
extractor.advance()
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Get PCM output from decoder and put to queue (reduced timeout)
|
|
558
|
+
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
|
|
559
|
+
if (outputBufferIndex >= 0) {
|
|
560
|
+
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
|
561
|
+
|
|
562
|
+
if (bufferInfo.size > 0) {
|
|
563
|
+
var pcmData = ByteArray(bufferInfo.size)
|
|
564
|
+
outputBuffer.get(pcmData)
|
|
565
|
+
|
|
566
|
+
// Convert channel count if needed
|
|
567
|
+
if (needsChannelConversion) {
|
|
568
|
+
pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Resample if needed
|
|
572
|
+
if (needsResampling) {
|
|
573
|
+
pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Optimization: avoid unnecessary clone() - store original for caching
|
|
577
|
+
decodedChunks.add(pcmData)
|
|
578
|
+
totalBytes += pcmData.size
|
|
579
|
+
|
|
580
|
+
// Put a clone to queue (queue might modify it)
|
|
581
|
+
val seqNum = sequenceStart.getAndIncrement()
|
|
582
|
+
queue.put(PCMChunk(pcmData.clone(), seqNum))
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
decoder.releaseOutputBuffer(outputBufferIndex, false)
|
|
586
|
+
|
|
587
|
+
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
|
588
|
+
isEOS = true
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Cache the decoded data
|
|
594
|
+
if (decodedChunks.isNotEmpty()) {
|
|
595
|
+
cache.putAudioFile(filePath, CachedPCMData(decodedChunks, totalBytes))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
} finally {
|
|
599
|
+
decoder?.stop()
|
|
600
|
+
decoder?.release()
|
|
601
|
+
extractor.release()
|
|
602
|
+
}
|
|
603
|
+
} catch (e: Exception) {
|
|
604
|
+
Log.e("AudioConcat", "Error in parallel decode: ${e.message}", e)
|
|
605
|
+
throw e
|
|
606
|
+
} finally {
|
|
607
|
+
latch.countDown()
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
178
611
|
private fun streamDecodeAudioFile(
|
|
179
612
|
filePath: String,
|
|
180
613
|
encoder: StreamingEncoder,
|
|
181
|
-
isLastFile: Boolean
|
|
614
|
+
isLastFile: Boolean,
|
|
615
|
+
targetSampleRate: Int,
|
|
616
|
+
targetChannelCount: Int
|
|
182
617
|
) {
|
|
183
618
|
val extractor = MediaExtractor()
|
|
184
619
|
var decoder: MediaCodec? = null
|
|
@@ -203,6 +638,16 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
203
638
|
throw Exception("No audio track found in $filePath")
|
|
204
639
|
}
|
|
205
640
|
|
|
641
|
+
val sourceSampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
|
642
|
+
val sourceChannelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
|
643
|
+
|
|
644
|
+
val needsResampling = sourceSampleRate != targetSampleRate
|
|
645
|
+
val needsChannelConversion = sourceChannelCount != targetChannelCount
|
|
646
|
+
|
|
647
|
+
if (needsResampling || needsChannelConversion) {
|
|
648
|
+
Log.d("AudioConcat", "File: $filePath - ${sourceSampleRate}Hz ${sourceChannelCount}ch -> ${targetSampleRate}Hz ${targetChannelCount}ch")
|
|
649
|
+
}
|
|
650
|
+
|
|
206
651
|
extractor.selectTrack(audioTrackIndex)
|
|
207
652
|
|
|
208
653
|
val mime = audioFormat.getString(MediaFormat.KEY_MIME)!!
|
|
@@ -212,11 +657,10 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
212
657
|
|
|
213
658
|
val bufferInfo = MediaCodec.BufferInfo()
|
|
214
659
|
var isEOS = false
|
|
215
|
-
val pcmChunkSize = 8192 // Process in 8KB chunks
|
|
216
660
|
|
|
217
661
|
while (!isEOS) {
|
|
218
|
-
// Feed input to decoder
|
|
219
|
-
val inputBufferIndex = decoder.dequeueInputBuffer(
|
|
662
|
+
// Feed input to decoder (reduced timeout for faster processing)
|
|
663
|
+
val inputBufferIndex = decoder.dequeueInputBuffer(1000)
|
|
220
664
|
if (inputBufferIndex >= 0) {
|
|
221
665
|
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
|
|
222
666
|
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
@@ -230,16 +674,26 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
230
674
|
}
|
|
231
675
|
}
|
|
232
676
|
|
|
233
|
-
// Get PCM output from decoder and feed to encoder
|
|
234
|
-
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo,
|
|
677
|
+
// Get PCM output from decoder and feed to encoder (reduced timeout)
|
|
678
|
+
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 1000)
|
|
235
679
|
if (outputBufferIndex >= 0) {
|
|
236
680
|
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
|
|
237
681
|
|
|
238
682
|
if (bufferInfo.size > 0) {
|
|
239
|
-
|
|
683
|
+
var pcmData = ByteArray(bufferInfo.size)
|
|
240
684
|
outputBuffer.get(pcmData)
|
|
241
685
|
|
|
242
|
-
//
|
|
686
|
+
// Convert channel count if needed
|
|
687
|
+
if (needsChannelConversion) {
|
|
688
|
+
pcmData = convertChannelCount(pcmData, sourceChannelCount, targetChannelCount)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Resample if needed
|
|
692
|
+
if (needsResampling) {
|
|
693
|
+
pcmData = resamplePCM16(pcmData, sourceSampleRate, targetSampleRate, targetChannelCount)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Stream to encoder
|
|
243
697
|
encoder.encodePCMChunk(pcmData, false)
|
|
244
698
|
}
|
|
245
699
|
|
|
@@ -262,24 +716,299 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
262
716
|
durationMs: Double,
|
|
263
717
|
encoder: StreamingEncoder,
|
|
264
718
|
sampleRate: Int,
|
|
265
|
-
channelCount: Int
|
|
719
|
+
channelCount: Int,
|
|
720
|
+
cache: PCMCache
|
|
266
721
|
) {
|
|
722
|
+
val cacheKey = SilenceCacheKey(durationMs, sampleRate, channelCount)
|
|
723
|
+
|
|
724
|
+
// Check cache first
|
|
725
|
+
val cachedSilence = cache.getSilence(cacheKey)
|
|
726
|
+
if (cachedSilence != null) {
|
|
727
|
+
Log.d("AudioConcat", "Using cached silence: ${durationMs}ms")
|
|
728
|
+
encoder.encodePCMChunk(cachedSilence, false)
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Generate silence
|
|
267
733
|
val totalSamples = ((durationMs / 1000.0) * sampleRate).toInt()
|
|
268
|
-
val chunkSamples = 4096 // Process in chunks
|
|
269
734
|
val bytesPerSample = channelCount * 2 // 16-bit stereo
|
|
735
|
+
val totalBytes = totalSamples * bytesPerSample
|
|
270
736
|
|
|
271
|
-
|
|
737
|
+
// For short silence (< 5 seconds), cache as single chunk
|
|
738
|
+
if (durationMs < 5000) {
|
|
739
|
+
// Use buffer pool to avoid allocation
|
|
740
|
+
val pooledBuffer = SilenceBufferPool.getBuffer(totalBytes)
|
|
741
|
+
val silenceData = if (pooledBuffer.size == totalBytes) {
|
|
742
|
+
pooledBuffer
|
|
743
|
+
} else {
|
|
744
|
+
// Copy only the needed portion
|
|
745
|
+
pooledBuffer.copyOf(totalBytes)
|
|
746
|
+
}
|
|
747
|
+
cache.putSilence(cacheKey, silenceData)
|
|
748
|
+
encoder.encodePCMChunk(silenceData, false)
|
|
749
|
+
} else {
|
|
750
|
+
// For longer silence, process in chunks without caching using pooled buffers
|
|
751
|
+
val chunkSamples = 16384
|
|
752
|
+
var samplesRemaining = totalSamples
|
|
272
753
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
val silenceChunk = ByteArray(chunkBytes) // All zeros = silence
|
|
754
|
+
while (samplesRemaining > 0) {
|
|
755
|
+
val currentChunkSamples = minOf(chunkSamples, samplesRemaining)
|
|
756
|
+
val chunkBytes = currentChunkSamples * bytesPerSample
|
|
277
757
|
|
|
278
|
-
|
|
279
|
-
|
|
758
|
+
// Use pooled buffer for chunk
|
|
759
|
+
val pooledBuffer = SilenceBufferPool.getBuffer(chunkBytes)
|
|
760
|
+
val silenceChunk = if (pooledBuffer.size == chunkBytes) {
|
|
761
|
+
pooledBuffer
|
|
762
|
+
} else {
|
|
763
|
+
pooledBuffer.copyOf(chunkBytes)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
encoder.encodePCMChunk(silenceChunk, false)
|
|
767
|
+
samplesRemaining -= currentChunkSamples
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private fun getOptimalThreadCount(audioFileCount: Int): Int {
|
|
773
|
+
val cpuCores = Runtime.getRuntime().availableProcessors()
|
|
774
|
+
val optimalThreads = when {
|
|
775
|
+
cpuCores <= 2 -> 2
|
|
776
|
+
cpuCores <= 4 -> 3
|
|
777
|
+
cpuCores <= 8 -> 4
|
|
778
|
+
else -> 6
|
|
779
|
+
}
|
|
780
|
+
// Don't create more threads than files to process
|
|
781
|
+
return optimalThreads.coerceAtMost(audioFileCount)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private fun getOptimalQueueSize(audioFileCount: Int): Int {
|
|
785
|
+
// Dynamic queue size based on number of files to prevent memory waste or blocking
|
|
786
|
+
return when {
|
|
787
|
+
audioFileCount <= 5 -> 20
|
|
788
|
+
audioFileCount <= 20 -> 50
|
|
789
|
+
audioFileCount <= 50 -> 100
|
|
790
|
+
else -> 150
|
|
280
791
|
}
|
|
281
792
|
}
|
|
282
793
|
|
|
794
|
+
private fun parallelProcessAudioFiles(
|
|
795
|
+
audioFiles: List<Pair<Int, String>>, // (index, filePath)
|
|
796
|
+
encoder: StreamingEncoder,
|
|
797
|
+
targetSampleRate: Int,
|
|
798
|
+
targetChannelCount: Int,
|
|
799
|
+
cache: PCMCache,
|
|
800
|
+
numThreads: Int = 3
|
|
801
|
+
) {
|
|
802
|
+
if (audioFiles.isEmpty()) return
|
|
803
|
+
|
|
804
|
+
// Group consecutive duplicate files
|
|
805
|
+
val optimizedFiles = mutableListOf<Pair<Int, String>>()
|
|
806
|
+
val consecutiveDuplicates = mutableMapOf<Int, Int>() // originalIndex -> count
|
|
807
|
+
|
|
808
|
+
var i = 0
|
|
809
|
+
while (i < audioFiles.size) {
|
|
810
|
+
val (index, filePath) = audioFiles[i]
|
|
811
|
+
var count = 1
|
|
812
|
+
|
|
813
|
+
// Check for consecutive duplicates
|
|
814
|
+
while (i + count < audioFiles.size && audioFiles[i + count].second == filePath) {
|
|
815
|
+
count++
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (count > 1) {
|
|
819
|
+
Log.d("AudioConcat", "Detected $count consecutive occurrences of: $filePath")
|
|
820
|
+
optimizedFiles.add(Pair(index, filePath))
|
|
821
|
+
consecutiveDuplicates[optimizedFiles.size - 1] = count
|
|
822
|
+
} else {
|
|
823
|
+
optimizedFiles.add(Pair(index, filePath))
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
i += count
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
val queueSize = getOptimalQueueSize(optimizedFiles.size)
|
|
830
|
+
val pcmQueue = LinkedBlockingQueue<PCMChunk>(queueSize)
|
|
831
|
+
Log.d("AudioConcat", "Using queue size: $queueSize for ${optimizedFiles.size} files")
|
|
832
|
+
val executor = Executors.newFixedThreadPool(numThreads)
|
|
833
|
+
val latch = CountDownLatch(optimizedFiles.size)
|
|
834
|
+
val sequenceCounter = AtomicInteger(0)
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
// Submit decode tasks for unique files only
|
|
838
|
+
optimizedFiles.forEachIndexed { optIndex, (index, filePath) ->
|
|
839
|
+
executor.submit {
|
|
840
|
+
try {
|
|
841
|
+
val fileSequenceStart = AtomicInteger(sequenceCounter.get())
|
|
842
|
+
sequenceCounter.addAndGet(1000000)
|
|
843
|
+
|
|
844
|
+
Log.d("AudioConcat", "Starting parallel decode [$index]: $filePath")
|
|
845
|
+
parallelDecodeToQueue(filePath, pcmQueue, fileSequenceStart, targetSampleRate, targetChannelCount, latch, cache)
|
|
846
|
+
|
|
847
|
+
// Mark end with duplicate count
|
|
848
|
+
val repeatCount = consecutiveDuplicates[optIndex] ?: 1
|
|
849
|
+
val endSeqNum = fileSequenceStart.get()
|
|
850
|
+
pcmQueue.put(PCMChunk(ByteArray(0), endSeqNum, true)) // endOfStream marker with repeat count
|
|
851
|
+
|
|
852
|
+
} catch (e: Exception) {
|
|
853
|
+
Log.e("AudioConcat", "Error decoding file $filePath: ${e.message}", e)
|
|
854
|
+
latch.countDown()
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Consumer thread: encode in order
|
|
860
|
+
var filesCompleted = 0
|
|
861
|
+
var cachedChunks = mutableListOf<ByteArray>()
|
|
862
|
+
var isCollectingChunks = false
|
|
863
|
+
|
|
864
|
+
while (filesCompleted < optimizedFiles.size) {
|
|
865
|
+
val chunk = pcmQueue.take()
|
|
866
|
+
|
|
867
|
+
if (chunk.isEndOfStream) {
|
|
868
|
+
val optIndex = filesCompleted
|
|
869
|
+
val repeatCount = consecutiveDuplicates[optIndex] ?: 1
|
|
870
|
+
|
|
871
|
+
if (repeatCount > 1 && cachedChunks.isNotEmpty()) {
|
|
872
|
+
// Repeat the cached chunks
|
|
873
|
+
Log.d("AudioConcat", "Repeating cached chunks ${repeatCount - 1} more times")
|
|
874
|
+
repeat(repeatCount - 1) {
|
|
875
|
+
cachedChunks.forEach { data ->
|
|
876
|
+
encoder.encodePCMChunk(data, false)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
cachedChunks.clear()
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
filesCompleted++
|
|
883
|
+
isCollectingChunks = false
|
|
884
|
+
Log.d("AudioConcat", "Completed file $filesCompleted/${optimizedFiles.size}")
|
|
885
|
+
continue
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Encode chunk
|
|
889
|
+
encoder.encodePCMChunk(chunk.data, false)
|
|
890
|
+
|
|
891
|
+
// Cache chunks for consecutive duplicates
|
|
892
|
+
val optIndex = filesCompleted
|
|
893
|
+
if (consecutiveDuplicates.containsKey(optIndex)) {
|
|
894
|
+
cachedChunks.add(chunk.data.clone())
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Wait for all decode tasks to complete
|
|
899
|
+
latch.await()
|
|
900
|
+
Log.d("AudioConcat", "All parallel decode tasks completed")
|
|
901
|
+
|
|
902
|
+
} finally {
|
|
903
|
+
executor.shutdown()
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private data class InterleavedPattern(
|
|
908
|
+
val filePath: String,
|
|
909
|
+
val silenceKey: SilenceCacheKey?,
|
|
910
|
+
val indices: List<Int>, // Indices where this pattern occurs
|
|
911
|
+
val repeatCount: Int
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
private data class DuplicateAnalysis(
|
|
915
|
+
val duplicateFiles: Set<String>,
|
|
916
|
+
val duplicateSilence: Set<SilenceCacheKey>,
|
|
917
|
+
val fileOccurrences: Map<String, List<Int>>, // filePath -> list of indices
|
|
918
|
+
val silenceOccurrences: Map<SilenceCacheKey, List<Int>>,
|
|
919
|
+
val interleavedPatterns: List<InterleavedPattern>
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
private fun analyzeDuplicates(
|
|
923
|
+
parsedData: List<AudioDataOrSilence>,
|
|
924
|
+
audioConfig: AudioConfig
|
|
925
|
+
): DuplicateAnalysis {
|
|
926
|
+
val fileCounts = mutableMapOf<String, MutableList<Int>>()
|
|
927
|
+
val silenceCounts = mutableMapOf<SilenceCacheKey, MutableList<Int>>()
|
|
928
|
+
|
|
929
|
+
parsedData.forEachIndexed { index, item ->
|
|
930
|
+
when (item) {
|
|
931
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
932
|
+
fileCounts.getOrPut(item.filePath) { mutableListOf() }.add(index)
|
|
933
|
+
}
|
|
934
|
+
is AudioDataOrSilence.Silence -> {
|
|
935
|
+
val key = SilenceCacheKey(item.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
|
|
936
|
+
silenceCounts.getOrPut(key) { mutableListOf() }.add(index)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
val duplicateFiles = fileCounts.filter { it.value.size > 1 }.keys.toSet()
|
|
942
|
+
val duplicateSilence = silenceCounts.filter { it.value.size > 1 }.keys.toSet()
|
|
943
|
+
|
|
944
|
+
// Detect interleaved patterns: file -> silence -> file -> silence -> file
|
|
945
|
+
val interleavedPatterns = mutableListOf<InterleavedPattern>()
|
|
946
|
+
|
|
947
|
+
var i = 0
|
|
948
|
+
while (i < parsedData.size - 2) {
|
|
949
|
+
if (parsedData[i] is AudioDataOrSilence.AudioFile &&
|
|
950
|
+
parsedData[i + 1] is AudioDataOrSilence.Silence &&
|
|
951
|
+
parsedData[i + 2] is AudioDataOrSilence.AudioFile) {
|
|
952
|
+
|
|
953
|
+
val file1 = (parsedData[i] as AudioDataOrSilence.AudioFile).filePath
|
|
954
|
+
val silence = parsedData[i + 1] as AudioDataOrSilence.Silence
|
|
955
|
+
val file2 = (parsedData[i + 2] as AudioDataOrSilence.AudioFile).filePath
|
|
956
|
+
val silenceKey = SilenceCacheKey(silence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
|
|
957
|
+
|
|
958
|
+
// Check if it's the same file with silence separator
|
|
959
|
+
if (file1 == file2) {
|
|
960
|
+
var count = 1
|
|
961
|
+
var currentIndex = i
|
|
962
|
+
val indices = mutableListOf(i)
|
|
963
|
+
|
|
964
|
+
// Count how many times this pattern repeats
|
|
965
|
+
while (currentIndex + 2 < parsedData.size &&
|
|
966
|
+
parsedData[currentIndex + 2] is AudioDataOrSilence.AudioFile &&
|
|
967
|
+
(parsedData[currentIndex + 2] as AudioDataOrSilence.AudioFile).filePath == file1) {
|
|
968
|
+
|
|
969
|
+
// Check if there's a silence in between
|
|
970
|
+
if (currentIndex + 3 < parsedData.size &&
|
|
971
|
+
parsedData[currentIndex + 3] is AudioDataOrSilence.Silence) {
|
|
972
|
+
val nextSilence = parsedData[currentIndex + 3] as AudioDataOrSilence.Silence
|
|
973
|
+
val nextSilenceKey = SilenceCacheKey(nextSilence.durationMs, audioConfig.sampleRate, audioConfig.channelCount)
|
|
974
|
+
|
|
975
|
+
if (nextSilenceKey == silenceKey) {
|
|
976
|
+
count++
|
|
977
|
+
currentIndex += 2
|
|
978
|
+
indices.add(currentIndex)
|
|
979
|
+
} else {
|
|
980
|
+
break
|
|
981
|
+
}
|
|
982
|
+
} else {
|
|
983
|
+
// Last file in the pattern (no silence after)
|
|
984
|
+
count++
|
|
985
|
+
indices.add(currentIndex + 2)
|
|
986
|
+
break
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (count >= 2) {
|
|
991
|
+
interleavedPatterns.add(InterleavedPattern(file1, silenceKey, indices, count))
|
|
992
|
+
Log.d("AudioConcat", "Detected interleaved pattern: '$file1' + ${silenceKey.durationMs}ms silence, repeats $count times")
|
|
993
|
+
i = currentIndex + 2 // Skip processed items
|
|
994
|
+
continue
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
i++
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
Log.d("AudioConcat", "Duplicate analysis: ${duplicateFiles.size} files, ${duplicateSilence.size} silence patterns, ${interleavedPatterns.size} interleaved patterns")
|
|
1002
|
+
duplicateFiles.forEach { file ->
|
|
1003
|
+
Log.d("AudioConcat", " File '$file' appears ${fileCounts[file]?.size} times")
|
|
1004
|
+
}
|
|
1005
|
+
duplicateSilence.forEach { key ->
|
|
1006
|
+
Log.d("AudioConcat", " Silence ${key.durationMs}ms appears ${silenceCounts[key]?.size} times")
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return DuplicateAnalysis(duplicateFiles, duplicateSilence, fileCounts, silenceCounts, interleavedPatterns)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
283
1012
|
private fun parseAudioData(data: ReadableArray): List<AudioDataOrSilence> {
|
|
284
1013
|
val result = mutableListOf<AudioDataOrSilence>()
|
|
285
1014
|
for (i in 0 until data.size()) {
|
|
@@ -329,6 +1058,12 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
329
1058
|
|
|
330
1059
|
Log.d("AudioConcat", "Audio config: ${audioConfig.sampleRate}Hz, ${audioConfig.channelCount}ch, ${audioConfig.bitRate}bps")
|
|
331
1060
|
|
|
1061
|
+
// Analyze duplicates to determine cache strategy
|
|
1062
|
+
val duplicateAnalysis = analyzeDuplicates(parsedData, audioConfig)
|
|
1063
|
+
|
|
1064
|
+
// Create cache instance with intelligent caching strategy
|
|
1065
|
+
val cache = PCMCache(duplicateAnalysis.duplicateFiles, duplicateAnalysis.duplicateSilence)
|
|
1066
|
+
|
|
332
1067
|
// Delete existing output file
|
|
333
1068
|
val outputFile = File(outputPath)
|
|
334
1069
|
if (outputFile.exists()) {
|
|
@@ -344,33 +1079,194 @@ class AudioConcatModule(reactContext: ReactApplicationContext) :
|
|
|
344
1079
|
)
|
|
345
1080
|
|
|
346
1081
|
try {
|
|
347
|
-
//
|
|
1082
|
+
// Separate audio files and other items (silence)
|
|
1083
|
+
val audioFileItems = mutableListOf<Pair<Int, String>>()
|
|
1084
|
+
val nonAudioItems = mutableListOf<Pair<Int, AudioDataOrSilence>>()
|
|
1085
|
+
|
|
348
1086
|
for ((index, item) in parsedData.withIndex()) {
|
|
349
1087
|
when (item) {
|
|
350
1088
|
is AudioDataOrSilence.AudioFile -> {
|
|
351
|
-
|
|
352
|
-
|
|
1089
|
+
audioFileItems.add(Pair(index, item.filePath))
|
|
1090
|
+
}
|
|
1091
|
+
is AudioDataOrSilence.Silence -> {
|
|
1092
|
+
nonAudioItems.add(Pair(index, item))
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Decide whether to use parallel or sequential processing
|
|
1098
|
+
val useParallel = audioFileItems.size >= 10 // Use parallel for 10+ files
|
|
1099
|
+
|
|
1100
|
+
if (useParallel) {
|
|
1101
|
+
Log.d("AudioConcat", "Using parallel processing for ${audioFileItems.size} audio files")
|
|
353
1102
|
|
|
354
|
-
|
|
355
|
-
|
|
1103
|
+
// Process interleaved patterns optimally
|
|
1104
|
+
val processedIndices = mutableSetOf<Int>()
|
|
1105
|
+
|
|
1106
|
+
// First, handle all interleaved patterns
|
|
1107
|
+
duplicateAnalysis.interleavedPatterns.forEach { pattern ->
|
|
1108
|
+
Log.d("AudioConcat", "Processing interleaved pattern: ${pattern.filePath}, ${pattern.repeatCount} repetitions")
|
|
1109
|
+
|
|
1110
|
+
// Decode the file once
|
|
1111
|
+
val filePath = pattern.filePath
|
|
1112
|
+
val cachedData = cache.getAudioFile(filePath)
|
|
1113
|
+
|
|
1114
|
+
val pcmChunks = if (cachedData != null) {
|
|
1115
|
+
Log.d("AudioConcat", "Using cached PCM for interleaved pattern: $filePath")
|
|
1116
|
+
cachedData.chunks
|
|
1117
|
+
} else {
|
|
1118
|
+
// Decode once and store
|
|
1119
|
+
val chunks = mutableListOf<ByteArray>()
|
|
1120
|
+
val tempQueue = LinkedBlockingQueue<PCMChunk>(100)
|
|
1121
|
+
val latch = CountDownLatch(1)
|
|
1122
|
+
val seqStart = AtomicInteger(0)
|
|
1123
|
+
|
|
1124
|
+
parallelDecodeToQueue(filePath, tempQueue, seqStart, audioConfig.sampleRate, audioConfig.channelCount, latch, cache)
|
|
1125
|
+
|
|
1126
|
+
// Collect chunks
|
|
1127
|
+
var collecting = true
|
|
1128
|
+
while (collecting) {
|
|
1129
|
+
val chunk = tempQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
1130
|
+
if (chunk != null) {
|
|
1131
|
+
if (!chunk.isEndOfStream) {
|
|
1132
|
+
chunks.add(chunk.data)
|
|
1133
|
+
} else {
|
|
1134
|
+
collecting = false
|
|
1135
|
+
}
|
|
1136
|
+
} else if (latch.count == 0L) {
|
|
1137
|
+
collecting = false
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
latch.await()
|
|
1142
|
+
chunks
|
|
356
1143
|
}
|
|
357
1144
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
1145
|
+
// Get silence PCM
|
|
1146
|
+
val silencePCM = pattern.silenceKey?.let { cache.getSilence(it) }
|
|
1147
|
+
?: pattern.silenceKey?.let {
|
|
1148
|
+
val totalSamples = ((it.durationMs / 1000.0) * it.sampleRate).toInt()
|
|
1149
|
+
val bytesPerSample = it.channelCount * 2
|
|
1150
|
+
ByteArray(totalSamples * bytesPerSample)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Encode the pattern: file -> silence -> file -> silence -> ...
|
|
1154
|
+
repeat(pattern.repeatCount) { iteration ->
|
|
1155
|
+
// Encode file
|
|
1156
|
+
pcmChunks.forEach { chunk ->
|
|
1157
|
+
encoder.encodePCMChunk(chunk, false)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Encode silence (except after the last file)
|
|
1161
|
+
if (iteration < pattern.repeatCount - 1 && silencePCM != null) {
|
|
1162
|
+
encoder.encodePCMChunk(silencePCM, false)
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Mark these indices as processed
|
|
1167
|
+
pattern.indices.forEach { idx ->
|
|
1168
|
+
processedIndices.add(idx)
|
|
1169
|
+
if (idx + 1 < parsedData.size && parsedData[idx + 1] is AudioDataOrSilence.Silence) {
|
|
1170
|
+
processedIndices.add(idx + 1)
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Then process remaining items normally
|
|
1176
|
+
var audioFileIdx = 0
|
|
1177
|
+
for ((index, item) in parsedData.withIndex()) {
|
|
1178
|
+
if (processedIndices.contains(index)) {
|
|
1179
|
+
if (item is AudioDataOrSilence.AudioFile) audioFileIdx++
|
|
1180
|
+
continue
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
when (item) {
|
|
1184
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
1185
|
+
// Collect consecutive audio files for parallel processing
|
|
1186
|
+
val consecutiveFiles = mutableListOf<Pair<Int, String>>()
|
|
1187
|
+
var currentIdx = audioFileIdx
|
|
1188
|
+
|
|
1189
|
+
while (currentIdx < audioFileItems.size) {
|
|
1190
|
+
val (itemIdx, filePath) = audioFileItems[currentIdx]
|
|
1191
|
+
if (processedIndices.contains(itemIdx)) {
|
|
1192
|
+
currentIdx++
|
|
1193
|
+
continue
|
|
1194
|
+
}
|
|
1195
|
+
if (itemIdx != index + (currentIdx - audioFileIdx)) break
|
|
1196
|
+
consecutiveFiles.add(Pair(itemIdx, filePath))
|
|
1197
|
+
currentIdx++
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (consecutiveFiles.isNotEmpty()) {
|
|
1201
|
+
val optimalThreads = getOptimalThreadCount(consecutiveFiles.size)
|
|
1202
|
+
Log.d("AudioConcat", "Using $optimalThreads threads for ${consecutiveFiles.size} files (CPU cores: ${Runtime.getRuntime().availableProcessors()})")
|
|
1203
|
+
parallelProcessAudioFiles(
|
|
1204
|
+
consecutiveFiles,
|
|
1205
|
+
encoder,
|
|
1206
|
+
audioConfig.sampleRate,
|
|
1207
|
+
audioConfig.channelCount,
|
|
1208
|
+
cache,
|
|
1209
|
+
numThreads = optimalThreads
|
|
1210
|
+
)
|
|
1211
|
+
audioFileIdx = currentIdx
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
is AudioDataOrSilence.Silence -> {
|
|
1216
|
+
val durationMs = item.durationMs
|
|
1217
|
+
Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
|
|
1218
|
+
streamEncodeSilence(
|
|
1219
|
+
durationMs,
|
|
1220
|
+
encoder,
|
|
1221
|
+
audioConfig.sampleRate,
|
|
1222
|
+
audioConfig.channelCount,
|
|
1223
|
+
cache
|
|
1224
|
+
)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
} else {
|
|
1229
|
+
Log.d("AudioConcat", "Using sequential processing for ${audioFileItems.size} audio files")
|
|
1230
|
+
|
|
1231
|
+
// Process each item sequentially (original behavior)
|
|
1232
|
+
for ((index, item) in parsedData.withIndex()) {
|
|
1233
|
+
when (item) {
|
|
1234
|
+
is AudioDataOrSilence.AudioFile -> {
|
|
1235
|
+
val filePath = item.filePath
|
|
1236
|
+
Log.d("AudioConcat", "Item $index: Streaming decode $filePath")
|
|
1237
|
+
|
|
1238
|
+
val isLastFile = (index == parsedData.size - 1)
|
|
1239
|
+
streamDecodeAudioFile(
|
|
1240
|
+
filePath,
|
|
1241
|
+
encoder,
|
|
1242
|
+
isLastFile,
|
|
1243
|
+
audioConfig.sampleRate,
|
|
1244
|
+
audioConfig.channelCount
|
|
1245
|
+
)
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
is AudioDataOrSilence.Silence -> {
|
|
1249
|
+
val durationMs = item.durationMs
|
|
1250
|
+
Log.d("AudioConcat", "Item $index: Streaming silence ${durationMs}ms")
|
|
1251
|
+
|
|
1252
|
+
streamEncodeSilence(
|
|
1253
|
+
durationMs,
|
|
1254
|
+
encoder,
|
|
1255
|
+
audioConfig.sampleRate,
|
|
1256
|
+
audioConfig.channelCount,
|
|
1257
|
+
cache
|
|
1258
|
+
)
|
|
1259
|
+
}
|
|
368
1260
|
}
|
|
369
1261
|
}
|
|
370
1262
|
}
|
|
371
1263
|
|
|
372
1264
|
// Finish encoding
|
|
373
1265
|
encoder.finish()
|
|
1266
|
+
|
|
1267
|
+
// Log cache statistics
|
|
1268
|
+
Log.d("AudioConcat", "Cache statistics: ${cache.getStats()}")
|
|
1269
|
+
|
|
374
1270
|
Log.d("AudioConcat", "Successfully merged audio to $outputPath")
|
|
375
1271
|
promise.resolve(outputPath)
|
|
376
1272
|
|