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
| Method | Install | Best for |
|---|---|---|
| fluent-ffmpeg | npm install fluent-ffmpeg | Production Node.js apps, progress events, stream piping |
| child_process + FFmpeg | None (FFmpeg installed) | Minimal dependency, direct FFmpeg CLI control |
| ChangeThisFile API | None | No 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
| Approach | Best for | Tradeoff |
|---|---|---|
| fluent-ffmpeg | Production apps, progress tracking, stream piping | Extra npm package; FFmpeg must be installed |
| child_process | Minimal dependencies, scripting, containers with FFmpeg | Verbose error handling; no built-in progress events |
| ChangeThisFile API | Serverless, edge runtimes, no FFmpeg available | Network 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.