From 67c724ddbd397137bd96f508bf851d6b967058ac Mon Sep 17 00:00:00 2001 From: pricelees Date: Wed, 20 Aug 2025 21:15:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EB=B0=8F=20=EB=B3=91=ED=95=A9=20=EC=8B=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EA=B0=84=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=EC=97=90=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EB=8A=94=20Ffmpeg=20=EC=82=AC=EC=9A=A9=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sangdol/text_to_speech/FfmpegUtils.kt | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/main/kotlin/com/sangdol/text_to_speech/FfmpegUtils.kt 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") + } + } +}