MP4-to-MP3 in Node.js means running FFmpeg — there's no pure-JavaScript audio transcoder with equivalent codec coverage. fluent-ffmpeg is the most popular Node.js FFmpeg wrapper and provides a clean event-driven API over FFmpeg's command-line interface. For serverless or edge environments where FFmpeg can't be installed, the ChangeThisFile API handles transcoding server-side via a standard fetch() call.

TL;DR

MethodInstallBest for
fluent-ffmpegnpm install fluent-ffmpegProduction Node.js apps, progress events, stream piping
child_process + FFmpegNone (FFmpeg installed)Minimal dependency, direct FFmpeg CLI control
ChangeThisFile APINoneNo FFmpeg installed, edge/serverless, low volume

Method 1: fluent-ffmpeg (chainable API, progress events)

fluent-ffmpeg wraps FFmpeg with a promise-friendly, event-driven Node API. FFmpeg must be installed on the system or configured via ffmpeg.setFfmpegPath().

npm install fluent-ffmpeg
# FFmpeg must be installed:
# apt install ffmpeg        # Ubuntu/Debian
# brew install ffmpeg       # macOS
const ffmpeg = require('fluent-ffmpeg');
const path = require('path');

function mp4ToMp3(inputPath, outputPath, bitrate = '192k') {
  return new Promise((resolve, reject) => {
    ffmpeg(inputPath)
      .noVideo()
      .audioCodec('libmp3lame')
      .audioBitrate(bitrate)
      .output(outputPath)
      .on('end', () => resolve(outputPath))
      .on('error', (err) => reject(err))
      .run();
  });
}

// Usage
mp4ToMp3('video.mp4', 'audio.mp3')
  .then(out => console.log(`Saved: ${out}`))
  .catch(err => console.error('Conversion failed:', err.message));

// With progress tracking
function mp4ToMp3WithProgress(inputPath, outputPath, bitrate = '192k') {
  return new Promise((resolve, reject) => {
    ffmpeg(inputPath)
      .noVideo()
      .audioCodec('libmp3lame')
      .audioBitrate(bitrate)
      .output(outputPath)
      .on('progress', (p) => {
        if (p.percent) process.stdout.write(`\r${Math.round(p.percent)}%`);
      })
      .on('end', () => { process.stdout.write('\n'); resolve(outputPath); })
      .on('error', reject)
      .run();
  });
}

The noVideo() call drops the video stream — without it, FFmpeg includes both audio and video in the output even for an audio-only target format. audioBitrate() accepts strings like '128k', '192k', '320k'.

Check for audio streams first:

const { promisify } = require('util');
const ffprobeAsync = promisify(ffmpeg.ffprobe);

async function hasAudio(filePath) {
  const metadata = await ffprobeAsync(filePath);
  return metadata.streams.some(s => s.codec_type === 'audio');
}

hasAudio('video.mp4').then(ok => {
  if (ok) return mp4ToMp3('video.mp4', 'audio.mp3');
  console.log('No audio stream found');
});

Method 2: child_process + FFmpeg (zero npm deps)

If FFmpeg is already on your system and you want zero npm dependencies, spawn it directly with Node's built-in child_process. Less ergonomic but no extra package needed.

const { spawn } = require('child_process');
const path = require('path');

function mp4ToMp3(inputPath, outputPath, bitrate = '192k') {
  return new Promise((resolve, reject) => {
    const args = [
      '-i', inputPath,
      '-vn',           // drop video stream
      '-acodec', 'libmp3lame',
      '-ab', bitrate,
      '-y',            // overwrite output if exists
      outputPath,
    ];

    const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
    const stderr = [];

    proc.stderr.on('data', d => stderr.push(d.toString()));
    proc.on('close', (code) => {
      if (code === 0) {
        resolve(outputPath);
      } else {
        const errText = stderr.join('');
        // Detect common error: no audio stream
        if (errText.includes('does not contain any stream')) {
          reject(new Error('No audio stream in input file'));
        } else {
          reject(new Error(`FFmpeg exited with code ${code}: ${errText.slice(-200)}`));
        }
      }
    });
    proc.on('error', err => reject(
      new Error(`FFmpeg not found: ${err.message}. Install with: apt install ffmpeg`)
    ));
  });
}

mp4ToMp3('video.mp4', 'audio.mp3')
  .then(out => console.log(`Saved: ${out}`))
  .catch(console.error);

Capture proc.stderr even when you don't need progress — FFmpeg writes all diagnostic output (including errors) to stderr. Without capturing it, spawn errors are silent.

Method 3: ChangeThisFile API (built-in fetch, no FFmpeg)

POST the MP4 with Node's built-in fetch (Node 18+). Pass target=mp3 — source is auto-detected from the filename. Free tier: 1,000 conversions/month, no card needed.

# Test with curl first
curl -X POST https://changethisfile.com/v1/convert \
  -H "Authorization: Bearer ctf_sk_your_key" \
  -F "file=@video.mp4" \
  -F "target=mp3" \
  --output audio.mp3
// Node 18+ (built-in fetch)
const fs = require('fs');
const path = require('path');

const API_KEY = 'ctf_sk_your_key_here';

async function mp4ToMp3Api(inputPath, outputPath) {
  const fileBuffer = fs.readFileSync(inputPath);
  const blob = new Blob([fileBuffer], { type: 'video/mp4' });

  const form = new FormData();
  form.append('file', blob, path.basename(inputPath));
  form.append('target', 'mp3');

  const res = await fetch('https://changethisfile.com/v1/convert', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${API_KEY}` },
    body: form,
    signal: AbortSignal.timeout(300_000), // 5 min timeout
  });

  if (!res.ok) {
    const err = await res.text();
    throw new Error(`API error ${res.status}: ${err}`);
  }

  const arrayBuffer = await res.arrayBuffer();
  fs.writeFileSync(outputPath, Buffer.from(arrayBuffer));
}

mp4ToMp3Api('video.mp4', 'audio.mp3')
  .then(() => console.log('Done'))
  .catch(console.error);

AbortSignal.timeout() cancels the request if the server doesn't respond within 5 minutes — important for large uploads. For very large files, consider streaming the write using fs.createWriteStream and res.body.pipeTo() instead of buffering with arrayBuffer().

When to use each

ApproachBest forTradeoff
fluent-ffmpegProduction apps, progress tracking, stream pipingExtra npm package; FFmpeg must be installed
child_processMinimal dependencies, scripting, containers with FFmpegVerbose error handling; no built-in progress events
ChangeThisFile APIServerless, edge runtimes, no FFmpeg availableNetwork upload time; 25MB file limit on free tier

Production tips

  • Always check for an audio stream before converting. Some MP4 files (screen recordings, silent animations) have no audio. ffprobe detects this in under a second. Failing early gives a clear error instead of a cryptic FFmpeg exit code.
  • Use 192k as the default bitrate. Most listeners cannot distinguish 192k from 320k. Podcasts and voice content sound fine at 128k. 320k is for archival quality only.
  • Limit concurrency for batch jobs. FFmpeg is CPU-intensive. Use a concurrency limiter (p-limit, p-queue) capped at CPU count when converting many files in parallel. Unlimited concurrency saturates the CPU and slows all conversions.
  • Handle ENOENT errors from fluent-ffmpeg. If FFmpeg is not found, fluent-ffmpeg throws ENOENT. Catch and re-throw with a clear install instruction — the raw error is not user-friendly.
  • For Cloudflare Workers, use the API. fluent-ffmpeg and child_process both require Node.js APIs unavailable in the Workers runtime. The fetch-based API approach works in any edge environment.

For Node.js services with FFmpeg installed, fluent-ffmpeg is the standard choice — clean API, progress events, and a large community. For edge runtimes or containers without FFmpeg, the ChangeThisFile API handles transcoding server-side. Free tier: 1,000 conversions/month.