MP4-to-MP3 is a pure audio-extraction job — FFmpeg handles it in a single pass with no re-encoding of the video stream. Kotlin can call FFmpeg directly via ProcessBuilder, or use the ffmpeg-kt wrapper for a more idiomatic API. If you're building a JVM service and don't want to manage an FFmpeg install, the ChangeThisFile API offloads it entirely.
Method 1: ProcessBuilder + FFmpeg (direct, no extra deps)
FFmpeg is the industry standard for audio/video processing. If FFmpeg is installed on the system, ProcessBuilder gives you full control with zero extra JVM dependencies.
# Install FFmpeg
apt install ffmpeg # Debian/Ubuntu
brew install ffmpeg # macOS
choco install ffmpeg # Windows
import java.io.File
fun mp4ToMp3(inputPath: String, outputPath: String, bitrate: String = "192k"): Int {
val process = ProcessBuilder(
"ffmpeg",
"-i", inputPath,
"-vn", // drop video stream
"-acodec", "libmp3lame",
"-ab", bitrate,
"-map", "a",
"-y", // overwrite output
outputPath
)
.redirectErrorStream(true)
.start()
// Capture output for logging / error diagnosis
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode != 0) {
System.err.println("FFmpeg failed (exit $exitCode):\n$output")
}
return exitCode
}
fun main() {
val result = mp4ToMp3("video.mp4", "audio.mp3", bitrate = "192k")
if (result == 0) println("Done: audio.mp3")
}
Bitrate guide:
- 128k — Streaming quality. Acceptable for speech, podcasts.
- 192k — Good default. Transparent for most music.
- 256k / 320k — Near-lossless. Larger files; diminishing returns.
-vn drops the video stream entirely. -map a selects all audio tracks if the MP4 has multiple. -y prevents FFmpeg from hanging on an interactive prompt when the output file exists.
Method 2: ffmpeg-kt wrapper (Kotlin-idiomatic DSL)
ffmpeg-kt wraps FFmpeg's ProcessBuilder invocation in a Kotlin coroutine-friendly DSL, with structured progress callbacks and suspend-friendly execution.
// build.gradle.kts
dependencies {
implementation("io.github.oleksandrbalan:ffmpegkt:1.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
import io.github.oleksandrbalan.ffmpegkt.FFmpegKit
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val result = FFmpegKit.executeAsync(
"-i video.mp4 -vn -acodec libmp3lame -ab 192k -map a -y audio.mp3"
) { statistics ->
// Optional: log progress
print("\rTime: ${statistics.time}ms")
}
if (result.returnCode == 0) {
println("\nDone: audio.mp3")
} else {
System.err.println("Failed: ${result.logsAsString}")
}
}
ffmpeg-kt still requires FFmpeg installed on the system — it's a Kotlin API wrapper, not a bundled binary. The advantage is the progress callback and coroutine-native execution. For Android specifically, use ffmpeg-kit-android which bundles the FFmpeg binary in the AAR.
Method 3: ChangeThisFile API via Ktor HttpClient (no FFmpeg install)
No FFmpeg on the host required. POST the MP4 to /v1/convert and receive MP3. Free tier: 1,000 conversions/month.
# curl reference
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key_here" \
-F "file=@video.mp4" \
-F "target=mp3" \
--output audio.mp3
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.io.File
const val API_KEY = "ctf_sk_your_key_here"
suspend fun mp4ToMp3Api(inputPath: String, outputPath: String) {
val client = HttpClient(CIO) {
engine {
requestTimeout = 300_000 // 5 min for large video files
}
}
val response: HttpResponse = client.submitFormWithBinaryData(
url = "https://changethisfile.com/v1/convert",
formData = formData {
append("file", File(inputPath).readBytes(), Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"${File(inputPath).name}\"")
append(HttpHeaders.ContentType, ContentType.Video.MP4)
})
append("target", "mp3")
}
) {
header("Authorization", "Bearer $API_KEY")
}
File(outputPath).writeBytes(response.readBytes())
client.close()
println("Saved: $outputPath")
}
fun main() = kotlinx.coroutines.runBlocking {
mp4ToMp3Api("video.mp4", "audio.mp3")
}
The API uses FFmpeg server-side at 192k MP3 by default. Source format is auto-detected — no need to pass source=mp4. Extend requestTimeout for videos longer than a few minutes.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| ProcessBuilder + FFmpeg | Self-hosted pipelines, full codec control | FFmpeg must be installed; process management complexity |
| ffmpeg-kt | Kotlin services needing progress callbacks | Still requires FFmpeg installed; adds a wrapper dep |
| ChangeThisFile API (Ktor) | No FFmpeg install, serverless, simple pipelines | Network latency, 25MB free-tier upload limit |
Production tips
- Always set a process timeout. Use
process.waitFor(2, TimeUnit.HOURS)instead of barewaitFor(). A corrupt input file can cause FFmpeg to hang indefinitely. - Drain stdout and stderr concurrently. If you don't consume FFmpeg's output stream while it's running, the output buffer fills and the process hangs. Run the stream reader in a separate thread or use
.redirectErrorStream(true). - Use -q:a 2 for VBR encoding. Variable bitrate (-q:a 0–9, lower is better) produces smaller files at equivalent quality compared to CBR.
-q:a 2is roughly equivalent to 190kbps average. - Check for multiple audio tracks. Some MP4s (e.g., dubbed films) have multiple audio streams.
-map 0:a:0extracts only the first.-map aextracts all streams into the MP3 (not always what you want). - Temp file cleanup. If your pipeline creates temp files for upload, use Kotlin's
File.createTempFile()with a try/finally block to guarantee deletion even on exceptions.
For self-hosted JVM services with FFmpeg installed, ProcessBuilder is the lightest approach. ffmpeg-kt adds ergonomics for progress-tracking use cases. For zero-dependency pipelines or quick scripts, the API. Free tier covers 1,000 conversions/month.