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