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 # macOS
require '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

ApproachBest forTradeoff
Streamio FFMPEGRails apps, progress tracking, metadata introspectionFFmpeg binary still required; gem adds overhead for simple jobs
Open3 + FFmpegScripts, batch jobs, no gem overheadFFmpeg binary required; manual error handling
ChangeThisFile APIHeroku/PaaS, no FFmpeg binary availableNetwork 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.