Elixir has no built-in audio/video processing — all paths go through external tools or APIs. FFmpeg is the standard choice via System.cmd/3, which is Erlang's port to the OS. The ffmpex package wraps this with a composable Elixir API. For Elixir apps running on Fly.io or in minimal Docker images where you don't want FFmpeg installed, the ChangeThisFile API is the zero-dependency path.
Method 1: System.cmd + FFmpeg (direct, no Hex deps)
The most direct approach: call FFmpeg via System.cmd/3. No Hex packages needed — just FFmpeg on the system.
# Install FFmpeg
apt install ffmpeg # Debian/Ubuntu
brew install ffmpeg # macOS
defmodule AudioConverter do
@doc """
Convert MP4 (or any video) to MP3 using FFmpeg.
Returns {:ok, output_path} or {:error, reason}.
"""
def mp4_to_mp3(input_path, output_path, bitrate \\ "192k") do
args = [
"-i", input_path,
"-vn", # drop video stream
"-acodec", "libmp3lame",
"-ab", bitrate,
"-map", "a", # select all audio tracks
"-y", # overwrite output without prompting
output_path
]
case System.cmd("ffmpeg", args, stderr_to_stdout: true) do
{_output, 0} ->
{:ok, output_path}
{output, exit_code} ->
{:error, "FFmpeg exited #{exit_code}: #{output}"}
end
end
@doc "Batch convert a directory of MP4 files to MP3."
def batch_convert(input_dir, output_dir, bitrate \\ "192k") do
File.mkdir_p!(output_dir)
input_dir
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".mp4"))
|> Task.async_stream(
fn filename ->
input = Path.join(input_dir, filename)
output = Path.join(output_dir, Path.rootname(filename) <> ".mp3")
mp4_to_mp3(input, output, bitrate)
end,
max_concurrency: 4,
timeout: :timer.minutes(10)
)
|> Enum.map(fn {:ok, result} -> result end)
end
end
# Usage
{:ok, path} = AudioConverter.mp4_to_mp3("/tmp/video.mp4", "/tmp/audio.mp3")
IO.puts("Saved: #{path}")
Bitrate guide: 128k = streaming/speech, 192k = good default for music, 256k = high-quality, 320k = maximum. stderr_to_stdout: true captures FFmpeg's verbose log output for error diagnosis.
Method 2: ffmpex (Elixir DSL wrapper for FFmpeg)
ffmpex provides a composable Elixir API for building FFmpeg commands. Useful when the command is constructed dynamically (different bitrates, filters, or codecs based on input).
# mix.exs
defp deps do
[
{:ffmpex, "~> 0.10"}
]
end
import FFmpex
use FFmpex.Options
defmodule FfmpexConverter do
@doc "Convert MP4 to MP3 using the ffmpex DSL."
def mp4_to_mp3(input_path, output_path, bitrate \\ "192k") do
command =
FFmpex.new_command()
|> add_global_option(option_y()) # overwrite output
|> add_input_file(input_path)
|> add_output_file(output_path)
|> add_file_option(option_vn()) # no video
|> add_file_option(option_acodec("libmp3lame"))
|> add_file_option(option_ab(bitrate))
|> add_file_option(option_map("a")) # all audio streams
case FFmpex.execute(command) do
{:ok, _} -> {:ok, output_path}
{:error, {output, exit_code}} ->
{:error, "FFmpex failed (exit #{exit_code}): #{output}"}
end
end
@doc "Extract a segment: seconds start_sec to end_sec."
def extract_segment(input_path, output_path, start_sec, end_sec) do
command =
FFmpex.new_command()
|> add_global_option(option_y())
|> add_input_file(input_path)
|> add_output_file(output_path)
|> add_file_option(option_vn())
|> add_file_option(option_ss(to_string(start_sec)))
|> add_file_option(option_t(to_string(end_sec - start_sec)))
|> add_file_option(option_acodec("libmp3lame"))
|> add_file_option(option_ab("192k"))
FFmpex.execute(command)
end
end
# Usage
{:ok, _} = FfmpexConverter.mp4_to_mp3("/tmp/video.mp4", "/tmp/audio.mp3")
# Extract minutes 1:00 to 3:30
{:ok, _} = FfmpexConverter.extract_segment("/tmp/video.mp4", "/tmp/clip.mp3", 60, 210)
ffmpex still requires FFmpeg installed on the system. Its advantage is conditional command construction — add options based on runtime parameters without string interpolation in arg lists.
Method 3: ChangeThisFile API via Req (no FFmpeg install)
No FFmpeg on the system required. Works in minimal Docker images, Fly.io, and AWS Lambda. 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
# mix.exs
defp deps do
[
{:req, "~> 0.5"}
]
end
defmodule CTFConverter do
@api_url "https://changethisfile.com/v1/convert"
@api_key "ctf_sk_your_key_here"
@doc """
Convert MP4 to MP3 via the ChangeThisFile API.
Returns {:ok, output_path} or {:error, reason}.
"""
def mp4_to_mp3(input_path, output_path) do
file_data = File.read!(input_path)
filename = Path.basename(input_path)
response =
Req.post!(
@api_url,
headers: [{"Authorization", "Bearer #{@api_key}"}],
form_multipart: [
{:file, file_data,
filename: filename,
content_type: "video/mp4"},
{:target, "mp3"}
],
receive_timeout: 300_000 # 5 min for large files
)
case response.status do
200 ->
File.write!(output_path, response.body)
{:ok, output_path}
status ->
{:error, "API returned #{status}: #{inspect(response.body)}"}
end
end
end
# Usage
{:ok, path} = CTFConverter.mp4_to_mp3("/tmp/video.mp4", "/tmp/audio.mp3")
IO.puts("Saved: #{path}")
Source format is auto-detected. The API handles MP4, MOV, MKV, AVI, and WebM. Extend receive_timeout for videos longer than a few minutes.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| System.cmd + FFmpeg | Self-hosted Elixir services, batch pipelines, full codec control | FFmpeg must be installed; process management complexity |
| ffmpex | Dynamic FFmpeg commands, conditional option building | Still requires FFmpeg; adds a Hex dependency |
| ChangeThisFile API (Req) | No FFmpeg install, Fly.io/Lambda, minimal Docker | Network latency, 25MB free-tier upload limit |
Production tips
- Always set a timeout on System.cmd for video processing. System.cmd/3 has no built-in timeout — a corrupt input can cause FFmpeg to hang indefinitely. Use Task.async with Task.yield(task, timeout_ms) and Task.shutdown(task, :brutal_kill) for hard timeouts.
- Use Task.async_stream for batch jobs. max_concurrency: 4 (or the number of CPU cores) prevents spawning more FFmpeg processes than the system can handle. Unbounded concurrency thrashes disk I/O.
- Check for zombie FFmpeg processes. If your Elixir process crashes mid-conversion, FFmpeg keeps running. Run :os.cmd('pkill ffmpeg') in a cleanup handler, or use Port instead of System.cmd for more control over process lifecycle.
- Don't pass user-supplied filenames directly to System.cmd. Shell injection isn't a risk with System.cmd (args are passed as a list, not a shell string) but path traversal is. Validate input paths are within your expected upload directory before processing.
- Req.post! raises on connection error. Use Req.post/2 (without the bang) and pattern-match the result for resilient API calls in supervised GenServers.
For self-hosted services with FFmpeg available, System.cmd is the lightest approach. ffmpex adds ergonomics when building commands dynamically. For Fly.io, Lambda, or minimal Docker images, the API. Free tier covers 1,000 conversions/month.