Codifica audio a MP3 en el navegador con WebAssembly
Convierte un archivo de audio corto en un MP3 sin subirlo. En este tutorial crearás una pequeña aplicación de navegador con FFmpeg.wasm, un servidor estático local, un botón para cancelar y un enlace de descarga.
El encoding en el navegador mantiene el audio seleccionado en el dispositivo del usuario. El navegador descarga los recursos del codificador, lee el archivo seleccionado en la memoria y ejecuta FFmpeg en un Web Worker. El encoding sigue consumiendo CPU y memoria, por lo que este ejemplo limita las entradas a 25 MiB y procesa un archivo a la vez.
Elige el conjunto de paquetes de FFmpeg
FFmpeg.wasm empaqueta FFmpeg como WebAssembly. Usamos
@ffmpeg/core@0.12.10, de un solo hilo, con @ffmpeg/ffmpeg@0.12.15.
El wrapper y el núcleo tienen números de versión independientes; instalar el mismo número para
ambos no identifica un conjunto de paquetes válido.
- Procesamiento local: la aplicación no tiene ningún endpoint para subir audio.
- Capacidad de respuesta: un worker ejecuta el codificador fuera del hilo principal de la interfaz.
- Compatibilidad de formatos: FFmpeg proporciona los decodificadores y el codificador MP3 usados aquí.
WebAssembly no garantiza una velocidad de encoding nativa. Las preguntas frecuentes de FFmpeg.wasm explican sus limitaciones de rendimiento y memoria; prueba archivos representativos en los dispositivos que admitas.
Configura el proyecto local
Requisitos previos
Usa Node.js 24.15 o posterior dentro de la rama 24.x, o 26.5 o posterior dentro de la rama 26.x,
Bash y un navegador con WebAssembly y workers de tipo módulo. El tutorial se probó en Linux con
Node.js 24.15.0, 26.5.0 y 26.8.1, Yarn 4.12.0 y Chromium 145. Estos son los entornos de ejecución del
servidor que se probaron, no requisitos del codificador del navegador. Node ejecuta
server.ts directamente mediante el
soporte nativo de TypeScript;
no se necesita compilador ni empaquetador.
Comprueba que node y corepack estén en tu PATH antes de
crear archivos. Instala Corepack por separado si tu distribución de
Node no lo incluye. La configuración siguiente solicita explícitamente Yarn 4.12.0 a Corepack.
Empieza con un WAV corto e intacto, mono o estéreo, de 44,1 kHz o 48 kHz. Otros contenedores de audio
solo funcionan cuando su decodificador está incluido en el núcleo de FFmpeg de versión fija; las
frecuencias de muestreo o distribuciones de canales no compatibles pueden remuestrearse o mezclarse
con menos canales para MP3. El atributo accept del selector de archivos es
una ayuda, no una validación. Otros navegadores y dispositivos móviles requieren sus propias pruebas.
Configuración del proyecto
Pega este bloque en Bash desde el directorio donde quieras crear el proyecto. No permite usar un
directorio webassembly-audio-encoder existente y mantiene tu shell en su directorio original,
incluso si la instalación falla. El archivo de bloqueo del directorio secundario crea un proyecto
Yarn independiente, y el nombre de archivo de configuración personalizado evita heredar los ajustes
habituales de .yarnrc.yml de un proyecto superior.
(
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/
)
Conserva todo el directorio ffmpeg: sus módulos JavaScript incluyen el worker
del wrapper y sus importaciones relativas. Ambos directorios de dependencias de terceros deben
proceder de este conjunto de paquetes instalado. No hay dependencia de una CDN en tiempo de
ejecución ni se necesita un empaquetador para estas importaciones relativas del navegador.
Si la instalación o la copia fallan después de escribir los archivos de configuración, conserva el directorio, resuelve el error indicado y vuelve a intentar los pasos restantes desde el mismo directorio superior:
(
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/
)
El reintento reemplaza los archivos de los paquetes de terceros y conserva los archivos de tu
aplicación. Usa el mismo selector YARN_RC_FILENAME para cualquier comando de Yarn
posterior en este proyecto.
Configuración del servidor de desarrollo
Guarda esto como webassembly-audio-encoder/server.ts. Solo sirve public/, por lo que
los archivos del proyecto no quedan expuestos. El puerto 3000 debe estar libre. Express 5
pasa los fallos de enlace al callback de listen;
el código muestra un error y finaliza con un estado distinto de cero:
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')
})
Después de crear los archivos siguientes, inicia el servidor desde el directorio superior:
(cd webassembly-audio-encoder && node server.ts)
Abre http://127.0.0.1:3000. Detén el servidor con Ctrl+C cuando termines; tu shell permanece
en el directorio superior. Este servidor de bucle local es para realizar el tutorial localmente.
Añade los controles del codificador
Guarda esto como 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>
Guarda esto como webassembly-audio-encoder/public/index.js. Cada intento tiene su propio worker y sistema de
archivos virtual. Al terminar ese worker se descartan sus archivos virtuales y el estado del
codificador, incluso después de una conversión fallida. El Blob de la descarga completada permanece
disponible hasta que se revoca su URL.
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.'
}
})
Los nombres fijos de los archivos virtuales evitan tratar el nombre de un archivo seleccionado como
una opción o ruta de FFmpeg. Una asignación explícita de audio selecciona el primer flujo de audio,
y un código de salida distinto de cero impide ofrecer un resultado parcial como una descarga
correcta. El enlace al MP3 sigue siendo válido hasta el siguiente intento de encoding o hasta que
se oculte la página. En este comando, -b:a 192k selecciona una tasa de bits
constante para MP3. Las opciones de libmp3lame
distinguen la tasa de bits de los ajustes de calidad con tasa de bits variable.
Carga los recursos locales correspondientes
El wrapper de FFmpeg inicia un worker de tipo módulo desde la copia
de vendor/ffmpeg/worker.js. Ese worker importa el núcleo ESM desde el mismo origen y carga su
archivo Wasm. Este núcleo de un solo hilo no necesita un ffmpeg-core.worker.js independiente
ni SharedArrayBuffer. No lo sustituyas por @ffmpeg/core-mt sin implementar también sus
requisitos adicionales de workers y aislamiento entre orígenes.
Limpia los recursos de cada conversión
La capa de JavaScript gestiona la selección de archivos y las URL de descarga; FFmpeg se encarga de la decodificación y el encoding. No se necesita un AudioContext ni un AudioWorklet para esta conversión de archivos.
La referencia de la API de FFmpeg documenta las operaciones de
archivos basadas en promesas, el tiempo de espera máximo de ejecución y
terminate(). Crear un worker nuevo por tarea requiere tiempo de inicialización,
pero simplifica la cancelación y la limpieza de archivos virtuales.
Convierte y comprueba una grabación
Selecciona un WAV corto con sonido reconocible tanto al principio como al final y luego pulsa
Encode audio. Cuando termine el encoding, el estado mostrará
Done. Your MP3 is ready to download.
Sigue el enlace Download MP3.
Abre el archivo output.mp3 guardado en un reproductor de audio y escúchalo hasta
el final. Su duración, canales y contenido deberían coincidir con la grabación seleccionada.
Prueba otra grabación y luego un archivo vacío o no compatible después de una conversión correcta. El enlace de descarga anterior debería desaparecer cuando pulses Encode audio. Pulsa Cancel durante la carga y durante el encoding; después, Encode audio debería volver a estar disponible. Sal de la página y regresa: la página debería permitir otra conversión con el enlace de descarga anterior oculto.
Una salida correcta del codificador no demuestra que una entrada dañada estuviera intacta. FFmpeg puede recuperar audio de algunos archivos truncados y aun así devolver cero. Usa entradas intactas y comprueba toda la grabación descargada antes de confiar en ella.
El límite de entrada de 25 MiB es una política de este ejemplo, no una garantía de consumo máximo de memoria. El audio decodificado y el codificador pueden ocupar mucha más memoria que la entrada comprimida. El comando tiene un tiempo de espera máximo de encoding de 60 segundos, y el plazo límite externo de 120 segundos termina una carga o llamada al worker que se haya bloqueado. Para archivos más grandes, considera el procesamiento del lado del servidor en lugar de aumentar los límites sin medir el consumo.
Mantén juntos los archivos de dependencias de terceros y el código de la aplicación, y repite las pruebas de conversión cuando actualices los paquetes de versión fija. Este ejemplo no proporciona ningún endpoint de subida ni de almacenamiento; tu navegador guarda las descargas.
Para flujos de trabajo que necesitan subidas y procesamiento del lado del servidor, explora el servicio de encoding de audio de Transloadit.
