Encode audio to MP3 in the browser with WebAssembly
Turn a short audio file into an MP3 without uploading it. This tutorial builds a small browser application with FFmpeg.wasm, a local static server, a cancel button, and a download link.
Browser encoding keeps the selected audio on the user’s device. The browser downloads the encoder assets, reads the selected file into memory, and runs FFmpeg in a Web Worker. Encoding still uses CPU and memory, so this example limits inputs to 25 MiB and processes one file at a time.
Choose the FFmpeg package set
FFmpeg.wasm packages FFmpeg as WebAssembly. We use the single-threaded
@ffmpeg/core@0.12.10 with @ffmpeg/ffmpeg@0.12.15. The wrapper and core have separate version
numbers; installing the same number for both does not identify a valid package set.
- Local processing: the application has no audio-upload endpoint.
- Responsiveness: a worker runs the encoder outside the main UI thread.
- Format support: FFmpeg supplies decoders and the MP3 encoder used here.
WebAssembly does not guarantee native encoding speed. The FFmpeg.wasm FAQ explains its performance and memory limitations; test representative files on the devices you support.
Set up the local project
Prerequisites
Use Node.js 24.15 or later within 24.x, or 26.5 or later within 26.x, Bash, and a browser with
WebAssembly and module workers. The walkthrough was tested on Linux with Node.js 24.15.0, 26.5.0,
and 26.8.1, Yarn 4.12.0, and Chromium 145. These are the tested server runtimes, not requirements
of the browser encoder. Node runs server.ts directly using
native TypeScript support;
no compiler or bundler is needed.
Check that node and corepack are on your PATH before creating files. Install
Corepack separately if your Node distribution
does not include it. The setup below asks Corepack for Yarn 4.12.0 explicitly.
Start with a short, intact mono or stereo WAV at 44.1 kHz or 48 kHz. Other audio containers work only
when their decoder is included in the pinned FFmpeg core; unsupported sample rates or channel layouts
may be resampled or downmixed for MP3. The file picker’s accept attribute is a convenience, not
validation. Other browsers and mobile devices need their own testing.
Project setup
Paste this block into Bash from the directory where you want to create the project. It refuses an
existing webassembly-audio-encoder directory and keeps your shell in its original directory,
including if installation fails. The child lockfile creates a separate Yarn project, and the custom
configuration filename avoids inheriting a parent project’s usual .yarnrc.yml settings.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir webassembly-audio-encoder &&
cd webassembly-audio-encoder &&
printf '%s\n' \
'{"private":true,"type":"module","packageManager":"yarn@4.12.0",' \
'"dependencies":{"@ffmpeg/ffmpeg":"0.12.15","@ffmpeg/core":"0.12.10","express":"5.1.0"}}' \
> package.json &&
printf '\n' > yarn.lock &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' \
> .audio-yarnrc.yml &&
YARN_RC_FILENAME=.audio-yarnrc.yml corepack yarn@4.12.0 install &&
mkdir -p public/vendor/ffmpeg public/vendor/core &&
cp -R node_modules/@ffmpeg/ffmpeg/dist/esm/. public/vendor/ffmpeg/ &&
cp -R node_modules/@ffmpeg/core/dist/esm/. public/vendor/core/
)
Keep the whole ffmpeg directory: its JavaScript modules include the wrapper’s worker and its
relative imports. Both vendor directories must come from this installed package set. There is no
CDN dependency at runtime and no bundler needed for these relative browser imports.
If installation or copying fails after the configuration files were written, keep the directory, resolve the reported error, and retry the remaining steps from the same parent directory:
(
cd webassembly-audio-encoder &&
YARN_RC_FILENAME=.audio-yarnrc.yml corepack yarn@4.12.0 install &&
mkdir -p public/vendor/ffmpeg public/vendor/core &&
cp -R node_modules/@ffmpeg/ffmpeg/dist/esm/. public/vendor/ffmpeg/ &&
cp -R node_modules/@ffmpeg/core/dist/esm/. public/vendor/core/
)
The retry replaces vendored package files and preserves your application files. Use the same
YARN_RC_FILENAME selector for any later Yarn commands in this project.
Development server setup
Save this as webassembly-audio-encoder/server.ts. It serves only public/, so the project files
are not exposed. Port 3000 must be free. Express 5
passes bind failures to the listen callback;
the code prints an error and exits with a nonzero status:
import { fileURLToPath } from 'node:url'
import express from 'express'
const app = express()
app.use(express.static(fileURLToPath(new URL('./public/', import.meta.url))))
app.listen(3000, '127.0.0.1', (error) => {
if (error) {
console.error(`Cannot start the audio server: ${error.message}`)
process.exitCode = 1
return
}
console.log('Open http://127.0.0.1:3000')
})
After creating the files below, start the server from the parent directory:
(cd webassembly-audio-encoder && node server.ts)
Open http://127.0.0.1:3000. Stop the server with Ctrl+C when finished; your shell stays in the
parent directory. This loopback server is for the local walkthrough.
Add the encoder controls
Save this as webassembly-audio-encoder/public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Browser audio encoder</title>
</head>
<body>
<h1>Encode audio to MP3</h1>
<label for="uploader">Audio file, up to 25 MiB</label>
<input type="file" id="uploader" accept="audio/*" />
<button id="encodeButton" type="button">Encode audio</button>
<button id="cancelButton" type="button" disabled>Cancel</button>
<p id="status" role="status">Choose an audio file.</p>
<a id="download" download="output.mp3" hidden>Download MP3</a>
<script type="module" src="./index.js"></script>
</body>
</html>
Save this as webassembly-audio-encoder/public/index.js. Each attempt gets its own worker and virtual
filesystem. Terminating that worker discards its virtual files and encoder state, including after
a failed conversion. The completed download’s Blob remains available until its URL is revoked.
import { FFmpeg } from './vendor/ffmpeg/index.js'
const uploader = document.getElementById('uploader')
const encodeButton = document.getElementById('encodeButton')
const cancelButton = document.getElementById('cancelButton')
const status = document.getElementById('status')
const download = document.getElementById('download')
let active = null
let downloadURL = null
function clearDownload() {
download.hidden = true
download.removeAttribute('href')
if (downloadURL !== null) URL.revokeObjectURL(downloadURL)
downloadURL = null
}
function cancelEncoding() {
if (active === null) return
active.canceled = true
active.ffmpeg.terminate()
}
async function encodeFile() {
if (active !== null) return
clearDownload()
const file = uploader.files?.[0]
if (!file || file.size === 0 || file.size > 25 * 1024 * 1024) {
status.textContent = 'Choose a nonempty audio file of at most 25 MiB.'
return
}
const job = { ffmpeg: new FFmpeg(), canceled: false }
active = job
uploader.disabled = true
encodeButton.disabled = true
cancelButton.disabled = false
status.textContent = 'Loading the encoder…'
// Also bound loading and worker failures that may never reply to the wrapper.
const deadline = setTimeout(() => {
job.ffmpeg.terminate()
}, 120_000)
try {
await job.ffmpeg.load({
coreURL: new URL('./vendor/core/ffmpeg-core.js', location.href).href,
wasmURL: new URL('./vendor/core/ffmpeg-core.wasm', location.href).href,
})
const input = new Uint8Array(await file.arrayBuffer())
if (job.canceled) return
await job.ffmpeg.writeFile('input.audio', input)
status.textContent = 'Encoding…'
const exitCode = await job.ffmpeg.exec(
['-i', 'input.audio', '-map', '0:a:0', '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', 'output.mp3'],
60_000,
)
if (exitCode !== 0) throw new Error('Encoder failed or timed out')
const output = await job.ffmpeg.readFile('output.mp3')
if (!(output instanceof Uint8Array) || output.byteLength === 0) {
throw new Error('Encoder returned no audio')
}
downloadURL = URL.createObjectURL(new Blob([output], { type: 'audio/mpeg' }))
download.href = downloadURL
download.hidden = false
status.textContent = 'Done. Your MP3 is ready to download.'
} catch {
status.textContent = job.canceled
? 'Encoding canceled.'
: 'Encoding failed. Try a shorter supported audio file and check the encoder assets.'
} finally {
clearTimeout(deadline)
job.ffmpeg.terminate()
active = null
uploader.disabled = false
encodeButton.disabled = false
cancelButton.disabled = true
if (job.canceled) status.textContent = 'Encoding canceled.'
}
}
encodeButton.addEventListener('click', encodeFile)
cancelButton.addEventListener('click', cancelEncoding)
window.addEventListener('pagehide', () => {
cancelEncoding()
clearDownload()
})
window.addEventListener('pageshow', (event) => {
if (event.persisted && active === null) {
status.textContent = 'Choose an audio file to encode again.'
}
})
The fixed virtual filenames avoid treating a selected filename as an FFmpeg option or path. An
explicit audio mapping selects the first audio stream, and a nonzero exit code prevents a partial
output from being offered as a successful download. The MP3 link stays valid until the next encoding
attempt or until the page is hidden. For this command, -b:a 192k selects a constant MP3 bitrate.
The libmp3lame options distinguish bitrate from
variable-bitrate quality settings.
Load matching local assets
The FFmpeg wrapper launches a
module worker from the copied vendor/ffmpeg/worker.js. That worker imports the ESM core from the
same origin and loads its Wasm file. This single-threaded core does not need a separate
ffmpeg-core.worker.js or SharedArrayBuffer. Do not substitute @ffmpeg/core-mt without also
implementing its additional worker and cross-origin isolation requirements.
Clean up each conversion
The JavaScript layer manages file selection and download URLs; FFmpeg handles decoding and encoding. An AudioContext or AudioWorklet is unnecessary for this file conversion.
The FFmpeg API reference documents
the promise-based file operations, execution timeout, and terminate(). A fresh worker per job
costs initialization time but makes cancellation and virtual-file cleanup straightforward.
Convert and check a recording
Select a short WAV with recognizable sound at both the beginning and end, then press
Encode audio. When encoding finishes, the status reads
Done. Your MP3 is ready to download.
Follow Download MP3. Open the saved output.mp3 in an audio player and
listen through the end. Its duration, channels, and content should match your selected recording.
Try a different recording next, then try an empty or unsupported file after a successful conversion. The previous download link should disappear when you press Encode audio. Press Cancel during loading and during encoding; afterward, Encode audio should become available again. Navigate away and back: the page should allow another conversion with the old download link hidden.
A successful encoder exit does not prove that a damaged input was intact. FFmpeg can recover audio from some truncated files and still return zero. Use intact inputs and check the whole downloaded recording before relying on it.
The 25 MiB input limit is a policy for this demo, not a peak-memory guarantee. Decoded audio and the encoder can occupy much more memory than the compressed input. The command has a 60-second encoding timeout, and the outer 120-second deadline terminates a stalled load or worker call. For larger files, consider server-side processing rather than raising the limits without measurement.
Keep the vendor files and application code together, and repeat the conversion checks when updating the pinned packages. This example provides no upload or storage endpoint; downloads are saved by your browser.
For workflows that need uploads and server-side processing, explore Transloadit’s audio encoding service.
