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