diff --git a/src/main/kotlin/com/sangdol/text_to_speech/FfmpegUtils.kt b/src/main/kotlin/com/sangdol/text_to_speech/FfmpegUtils.kt new file mode 100644 index 0000000..5fed926 --- /dev/null +++ b/src/main/kotlin/com/sangdol/text_to_speech/FfmpegUtils.kt @@ -0,0 +1,94 @@ +package com.sangdol.text_to_speech + +import net.bramp.ffmpeg.FFmpeg +import net.bramp.ffmpeg.FFmpegExecutor +import net.bramp.ffmpeg.builder.FFmpegBuilder +import org.springframework.stereotype.Component +import java.io.File + +private const val FFMPEG_PATH: String = "/opt/homebrew/bin/ffmpeg" +private const val SILENCE_AUDIO_PATH: String = "library/silences" + +private fun convertSilenceAudioFilePath(durationMs: Long) = "$SILENCE_AUDIO_PATH/silence-${durationMs}ms.mp3" + +@Component +class FfmpegUtils( + private val ffmpegExecutor: FFmpegExecutor = FFmpegExecutor(FFmpeg(FFMPEG_PATH)) +) { + + fun createSilenceAudio(durationMs: Long) { + val durationSeconds = durationMs / 1000.0 + val outputFile = convertSilenceAudioFilePath(durationMs) + + val command = listOf( + FFMPEG_PATH, + "-y", + "-f", "lavfi", + "-i", "anullsrc=r=44100:cl=stereo", + "-t", durationSeconds.toString(), + "-acodec", "mp3", + outputFile + ) + + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + + process.inputStream.bufferedReader().forEachLine { println(it) } + process.waitFor() + .takeUnless { exitCode -> exitCode == 0 } + ?: throw RuntimeException("FFmpeg failed with exit code ${process.exitValue()}") + } + + fun mergeAudioWithInterval(intervalMs: Long, targetDirectory: String, saveDirectory: String, fileName: String) { + val targetDir = File(targetDirectory).also { validateIsValidFileDirectory(it) } + val fileNameRegex = """(\d+)(.*)""".toRegex() + + if (intervalMs > 0 && !File(convertSilenceAudioFilePath(intervalMs)).exists()) { + createSilenceAudio(intervalMs) + } + + val files = targetDir.listFiles().sortedBy { it -> + val matchResult: MatchResult = fileNameRegex.find(it.name) + ?: throw IllegalStateException("Invalid filename-convention") + matchResult.groups[1]!!.value.toInt() + }.map { "$it" } + + val inputs = mutableListOf() + files.forEachIndexed { idx, file -> + inputs.add(file) + if (intervalMs > 0L && (idx != files.lastIndex)) { + inputs.add("library/silences/silence-${intervalMs}ms.mp3") + } + } + + val builder = FFmpegBuilder() + + inputs.forEach { filePath -> builder.addInput(filePath) } + + val filterInputs = inputs.indices.joinToString("") { idx -> "[$idx:a]" } + val filterComplex = "$filterInputs concat=n=${inputs.size}:v=0:a=1[out]" + + builder.setComplexFilter(filterComplex) + .addOutput("$saveDirectory/$fileName") + .setAudioCodec("mp3") + .addExtraArgs("-map", "[out]") + .done() + + ffmpegExecutor.createJob(builder).run() + } + + private fun validateIsValidFileDirectory(dir: File) { + if (!dir.exists()) { + throw IllegalArgumentException("Directory not found: $dir") + } + if (!dir.isDirectory) { + throw IllegalArgumentException("Path is not a directory: $dir") + } + val fileList = dir.listFiles() + + if (fileList == null || fileList.isEmpty()) { + throw IllegalArgumentException("Directory is null or empty: $dir") + } + } +}