Ruby has no native audio/video processing — MP4-to-MP3 conversion always means FFmpeg. Streamio FFMPEG (the streamio-ffmpeg gem) gives a cleaner Ruby API than raw system calls and adds progress callbacks. Open3 is simpler for scripts where you just need the output. For PaaS environments where installing FFmpeg is inconvenient, the API is the right call.
Method 1: Streamio FFMPEG (object API + progress callbacks)
Streamio FFMPEG wraps the FFmpeg binary in a Ruby object model with metadata introspection and progress callbacks.
gem install streamio-ffmpeg
# Or Gemfile: gem 'streamio-ffmpeg'
bundle install
# FFmpeg must be installed separately
apt install ffmpeg # Ubuntu/Debian
brew install ffmpeg # macOSrequire 'streamio-ffmpeg'
# Basic MP4 to MP3
def mp4_to_mp3(in_path, out_path, bitrate: '192k')
movie = FFMPEG::Movie.new(in_path)
# Check the file has an audio stream
raise "No audio stream in #{in_path}" unless movie.audio_stream
options = {
audio_codec: 'libmp3lame',
audio_bitrate: bitrate,
audio_channels: 2,
video_codec: 'none' # -vn: strip video
}
encoding = FFMPEG::Transcoder.new(movie, out_path, options)
encoding.run
end
mp4_to_mp3('video.mp4', 'audio.mp3')
puts 'Done'
require 'streamio-ffmpeg'
# With progress callback
def mp4_to_mp3_with_progress(in_path, out_path)
movie = FFMPEG::Movie.new(in_path)
puts "Duration: #{movie.duration.round(1)}s, Audio: #{movie.audio_codec}"
transcoder = FFMPEG::Transcoder.new(movie, out_path, {
audio_codec: 'libmp3lame',
audio_bitrate: '192k',
video_codec: 'none'
})
transcoder.run do |progress|
print "\rProgress: #{(progress * 100).round}%"
end
puts "\nOutput: #{out_path} (#{File.size(out_path) / 1024}KB)"
end
mp4_to_mp3_with_progress('lecture.mp4', 'lecture.mp3')
Method 2: Open3 + FFmpeg (direct, no gem overhead)
For scripts where you don't need metadata introspection or progress callbacks, calling FFmpeg directly with Open3 is simpler.
require 'open3'
def mp4_to_mp3(in_path, out_path, bitrate: '192k')
cmd = [
'ffmpeg',
'-y', # overwrite output without prompt
'-i', in_path,
'-vn', # no video
'-acodec', 'libmp3lame',
'-b:a', bitrate,
'-ar', '44100', # sample rate
'-ac', '2', # stereo
out_path
]
_stdout, stderr, status = Open3.capture3(*cmd)
unless status.success?
raise "FFmpeg failed (exit #{status.exitstatus}): #{stderr.lines.last(5).join}"
end
raise "Output not created" unless File.exist?(out_path) && File.size?(out_path)
out_path
end
# Batch conversion
def batch_mp4_to_mp3(src_dir, out_dir)
Dir.mkdir(out_dir) unless Dir.exist?(out_dir)
count = 0
Dir.glob("#{src_dir}/*.mp4").each do |mp4|
out = File.join(out_dir, File.basename(mp4, '.mp4') + '.mp3')
mp4_to_mp3(mp4, out)
count += 1
puts "#{count}: #{File.basename(mp4)}"
end
count
end
batch_mp4_to_mp3('./videos', './audio')
Always use Open3.capture3 instead of backticks or system(). Backticks discard stderr (where FFmpeg writes progress and errors). system() doesn't capture output at all. Open3 gives stdout, stderr, and exit status separately.
Method 3: ChangeThisFile API (Net::HTTP, no FFmpeg install)
The API runs FFmpeg server-side. Source auto-detected from filename — pass target=mp3. Free tier: 1,000 conversions/month, no card needed.
require 'net/http'
require 'uri'
require 'securerandom'
API_KEY = 'ctf_sk_your_key_here'
def mp4_to_mp3_api(in_path, out_path)
uri = URI('https://changethisfile.com/v1/convert')
boundary = "CTF#{SecureRandom.hex(8)}"
file_data = File.binread(in_path)
body = [
"--#{boundary}\r\n",
'Content-Disposition: form-data; name="file"; filename="' + File.basename(in_path) + "\"\r\n",
"Content-Type: video/mp4\r\n\r\n",
file_data, "\r\n",
"--#{boundary}\r\n",
"Content-Disposition: form-data; name=\"target\"\r\n\r\n",
"mp3\r\n",
"--#{boundary}--\r\n"
].join
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{API_KEY}"
req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
raise "API error: #{resp.code}" unless resp.code == '200'
File.binwrite(out_path, resp.body)
end
mp4_to_mp3_api('video.mp4', 'audio.mp3')
puts 'Done'
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| Streamio FFMPEG | Rails apps, progress tracking, metadata introspection | FFmpeg binary still required; gem adds overhead for simple jobs |
| Open3 + FFmpeg | Scripts, batch jobs, no gem overhead | FFmpeg binary required; manual error handling |
| ChangeThisFile API | Heroku/PaaS, no FFmpeg binary available | Network call; 25MB file limit on free tier (upgrade for larger) |
Production tips
- Always use -y flag to prevent interactive hang. Without -y, FFmpeg waits for confirmation before overwriting existing files. This hangs indefinitely in non-interactive environments.
- Validate output with File.size?. File.size? returns nil for empty files. A successful FFmpeg call can still produce an empty output if the input has no audio stream.
- Use Timeout for stuck processes. Large files or corrupt inputs can cause FFmpeg to hang. Wrap with require 'timeout'; Timeout.timeout(300) { ... }.
- Check for libmp3lame before deploying. Some minimal FFmpeg builds lack libmp3lame. Test: ffmpeg -codecs 2>/dev/null | grep mp3 should show DEA. If not, install a full FFmpeg build: apt install ffmpeg (not ffmpeg-minimal).
- For variable bitrate, use -q:a instead of -b:a. -q:a 2 gives ~190kbps VBR which is better quality/size tradeoff than fixed 192k for music.
Open3 + FFmpeg is the most direct path for Ruby scripts. Streamio FFMPEG adds value for Rails apps where progress callbacks matter. For Heroku or PaaS, the API skips the binary dependency. Free tier: 1,000 conversions/month.