Effortless audio encoding 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.
Introduction to browser-based audio encoding
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.
The role of WebAssembly in audio processing
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.
Benefits of using WebAssembly for audio encoding
- 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.
Setting up a simple web application for audio encoding
Prerequisites
Use Node.js 24, Yarn 4, a POSIX shell, and a current browser with WebAssembly and module workers.
Start with a short WAV file. Other audio containers work only when their decoder is included in
the pinned FFmpeg core. The file picker’s accept attribute is a convenience, not validation.
Project setup
Create a fresh directory and install exact versions. The node-modules linker makes the package paths used by the asset-copy commands available:
mkdir webassembly-audio-encoder
cd webassembly-audio-encoder
corepack yarn init
corepack yarn config set nodeLinker node-modules
corepack yarn add --exact @ffmpeg/ffmpeg@0.12.15 @ffmpeg/core@0.12.10 express@5.1.0
mkdir -p public/vendor
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.
Development server setup
Save this as server.ts. It serves only public/, so the project files are not exposed:
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', () => {
console.log('Open http://127.0.0.1:3000')
})
After creating the files below, start the server from the project directory:
node server.ts
Open http://127.0.0.1:3000. Stop the server with Ctrl+C when finished.
Ffmpeg.wasm setup and usage
Save this as 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 public/index.js. Each attempt gets its own worker and virtual filesystem. Terminating
that worker releases the input, output, and encoder memory, including after a failed conversion.
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
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
}
clearDownload()
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.
Loading the WebAssembly core
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.
Integrating WebAssembly with JavaScript for enhanced audio features
The JavaScript layer manages file selection and download URLs; FFmpeg handles decoding and encoding. An AudioContext or AudioWorklet is unnecessary for file conversion. If you add real-time effects or recording later, keep that playback pipeline separate from this asynchronous file-encoding workflow.
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.
Testing and optimizing audio encoding performance
Encode a short WAV, follow the download link, and play the result. Then try a second file, cancel during loading and during encoding, and select an empty or unsupported file. Each attempt should return the buttons to their ready state. Navigate away and back repeatedly: a restored page should allow another conversion, and an old download link should remain hidden.
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.
Deploying and future-proofing your web audio application
Deploy the contents of public/ together at the site root over HTTPS. The local Express server is
only for development. Ensure your host serves JavaScript modules and .wasm files with their
correct MIME types and permits same-origin module workers. Keep the vendor files and application
code versioned together, and rerun the conversion checks when updating a dependency.
Conclusion
You now have a complete local audio-to-MP3 flow: a matching FFmpeg package set, worker loading, bounded conversion, cancellation, and a persistent download link. For workflows that need uploads and server-side processing, explore Transloadit’s audio encoding service.
