TranscribeLocal por dentro: Whisper en el navegador y cómo dejó de inventarse frases
Cómo ejecuta TranscribeLocal Whisper, la separación de hablantes y un modelo de lenguaje dentro del navegador con Transformers.js, por qué Whisper se inventaba frases en los silencios, qué hace falta para transcribir audios largos y por qué un service worker cortaba las descargas a los cinco minutos.
Quería sacar los subtítulos de un documental en francés para traducirlos, sin subir el vídeo a ningún servicio y sin límites de duración. Mi ordenador tenía potencia de sobra para hacerlo él mismo, así que construí TranscribeLocal, una app web que transcribe con Whisper dentro del navegador. Este artículo cuenta cómo está hecha por dentro. Si buscas qué hace y cómo se usa, eso está en el artículo para usuarios.
Una regla lo condiciona todo: el audio no sale del dispositivo. No hay backend. Los modelos se descargan de Hugging Face la primera vez y a partir de ahí todo se ejecuta en el navegador del usuario: la transcripción, la separación de hablantes y el resumen.
La pieza central: Transformers.js
La app usa cuatro familias de modelos, todos en formato ONNX:
- Whisper para transcribir, en cuatro tamaños: tiny (~75 MB), base (~145 MB), small (~480 MB) y large-v3-turbo (~1,5 GB).
- pyannote-segmentation-3.0 para saber cuándo habla cada voz (~6 MB).
- WeSpeaker ResNet34 para reconocer la misma voz en distintos momentos (~26 MB).
- Qwen2.5-Instruct, de 0,5B o 1,5B parámetros, para resumir.
Los ejecuta Transformers.js, que a su vez usa ONNX Runtime Web: con WebGPU si el navegador puede usar la gráfica y con WebAssembly si no.
Elegir entre los dos tuvo su historia. La primera versión preguntaba si existía navigator.gpu, intentaba cargar con WebGPU y, si fallaba, repetía la carga con WASM. En un navegador con WebGPU presente pero sin adaptador real, los dos intentos fallaban con el mismo error. Leyendo el código de onnxruntime-web vi por qué: cuando el registro del backend WebGPU falla dentro de un worker, el fallo es global a ese worker, y una sesión nueva pedida con WASM sigue encontrándose el backend roto. La solución fue pedir los dos a la vez con device: 'auto', que Transformers.js traduce a executionProviders: ['webgpu', 'wasm']. Así es onnxruntime-web quien prueba uno y, si no puede, pasa al otro dentro de la misma sesión. Desde entonces, la comprobación de navigator.gpu solo sirve para dos cosas: avisar en la interfaz de que irá más lento y elegir la precisión de los pesos.
Todo fuera del hilo principal
Cada tarea tiene su propio Web Worker: uno para Whisper, otro para la separación de hablantes y otro para el resumen. Cargar un modelo de cientos de megas o pasar un minuto de audio por una red neuronal no congela la página. Los workers mandan mensajes de progreso (descargando, transcribiendo, separando hablantes…) que la interfaz enseña, y se quedan el modelo cargado para la siguiente vez.
Del archivo al modelo
Whisper espera audio mono a 16 kHz en un Float32Array. Da igual que el usuario suelte un MP3, un vídeo MP4 o la grabación de una pestaña: el navegador ya sabe decodificarlos. decodeAudioData de la Web Audio API devuelve los canales en crudo, y a partir de ahí son dos funciones puras: mezclar los canales a mono haciendo la media y remuestrear a 16 kHz por interpolación lineal.
export function resampleTo16k(mono: Float32Array, originalSampleRate: number): Float32Array {
const targetRate = 16000
if (originalSampleRate === targetRate) return mono
const ratio = originalSampleRate / targetRate
const newLength = Math.round(mono.length / ratio)
const result = new Float32Array(newLength)
for (let i = 0; i < newLength; i++) {
const srcIndex = i * ratio
const lower = Math.floor(srcIndex)
const upper = Math.min(lower + 1, mono.length - 1)
const frac = srcIndex - lower
result[i] = mono[lower]! + (mono[upper]! - mono[lower]!) * frac
}
return result
}
Para grabar, el micrófono sale de getUserMedia y la pestaña de getDisplayMedia. En la pestaña, el navegador obliga a pedir también el vídeo, así que las pistas de vídeo se paran en cuanto llegan. Si el usuario no marca "compartir audio", no llega ninguna pista de audio, y la app lo detecta y lo dice en vez de grabar silencio. MediaRecorder guarda la grabación como WebM, que pasa por el mismo camino que un archivo.
Audios largos: los primeros 30 segundos y nada más
Lo detecté transcribiendo una canción: el resultado entero era [MUSIC]. No era un problema con la música. Whisper trabaja con ventanas de 30 segundos, y si a la pipeline de Transformers.js no se le pide que trocee, el extractor de características descarta todo lo que pasa de 30 segundos antes de que llegue al modelo. Lo único que avisa es un console.warn. La canción tenía una intro instrumental larga, y eso era todo lo que Whisper llegaba a oír.
La solución es pasar chunk_length_s y stride_length_s: trozos de 30 segundos que se solapan 5, para no cortar palabras en las fronteras. La pipeline une después los trozos usando el solape.
Pero trocear también tiene un coste. En un audio de menos de 30 segundos, la pipeline rellena el trozo con silencio hasta completarlo, y ese silencio artificial provocaba bucles: el decodificador repetía la misma frase una y otra vez. Así que el troceo solo se activa cuando hace falta:
const isLongAudio = audioDurationSeconds > CHUNK_LENGTH_S
const output = await asr(audio, {
return_timestamps: true,
...(isLongAudio ? { chunk_length_s: CHUNK_LENGTH_S, stride_length_s: STRIDE_LENGTH_S } : {}),
...(language !== 'auto' ? { language } : {}),
})
Cómo dejó de inventarse frases
Whisper está entrenado con muchísimo audio subtitulado de internet, y eso se nota: cuando no hay voz, tiende a rellenar. Hizo falta trabajar en cuatro frentes.
Silencio. Antes de llamar al modelo se calcula la energía del audio (la raíz cuadrática media de las muestras). Si es menor que 0,001, que es prácticamente silencio, o si el audio dura menos de un cuarto de segundo, se devuelve un resultado vacío sin pasar por Whisper. No hay nada que transcribir, y si se le da, lo inventa.
Anotaciones. Ante música o aplausos, Whisper escribe [MUSIC], (applause) o [BLANK_AUDIO], como en los subtítulos para personas sordas. Podría haber hecho una lista de palabras prohibidas, pero Whisper tiene muchas variantes y en varios idiomas. Preferí un criterio estructural: se descarta cualquier fragmento que sea entero un texto entre corchetes o entre paréntesis.
const BRACKET_ANNOTATION = /^\[[^[\]]*\]$/
const PAREN_ANNOTATION = /^\([^()]*\)$/
Si después de filtrar no queda nada, como con una canción sin letra, la app dice que no detectó voz en vez de enseñar un editor vacío.
Repeticiones. Cuando Whisper entra en bucle repite la misma palabra muchas veces. Una expresión regular colapsa tres o más repeticiones seguidas de una palabra en una sola.
Precisión en WebGPU. Esta fue la más rara. Con WebGPU, Whisper empezaba a meter frases en otros idiomas que nadie había dicho. El problema estaba en la precisión de los pesos. El codificador, que convierte el audio en una representación interna, es numéricamente delicado en la atención y necesita fp32. El decodificador, que genera el texto, aguanta bien q4 (pesos de 4 bits), que además ocupa mucho menos y evita los RangeError de memoria al cargar los modelos grandes. Transformers.js permite elegir la precisión por submodelo:
const dtype = device === 'webgpu'
? { encoder_model: 'fp32', decoder_model_merged: 'q4' }
: 'q8'
En WebAssembly se usa q8 para todo.
Quién habla
Separar hablantes (diarización) en el navegador es la parte menos madura del ecosistema. Antes de diseñarla hice una prueba aparte para comprobar que el modelo de voces que necesitaba funcionaba con Transformers.js, porque Hugging Face no lo marcaba como compatible. Funcionaba: la librería registra la arquitectura wespeaker-resnet. Al final son tres pasos.
1. Segmentar por ventanas. pyannote-segmentation-3.0 recorre el audio en ventanas de 10 segundos que avanzan de 5 en 5. Por cada instante dice si no habla nadie (0), si habla una de hasta tres voces (1 a 3) o si se solapan dos (4 a 6). Las marcas de cada ventana se pasan a tiempo absoluto sumando dónde empieza la ventana.
2. Una huella por intervención. El problema de las ventanas es que el "hablante 1" de la primera no tiene por qué ser el "hablante 1" de la tercera. Para unirlos, cada intervención de una sola voz (las 1 a 3; los solapes se dejan fuera porque mezclan dos voces) pasa por WeSpeaker. WeSpeaker devuelve un vector de 256 números que resume cómo suena esa voz.
3. Agrupar. Un agrupamiento propio, sin dependencias, recorre los vectores en orden. Cada uno se compara por similitud del coseno con el centro de cada grupo existente. Si el más parecido llega a 0,5, entra en ese grupo y su centro se recalcula como la media. Si no, abre un grupo nuevo, es decir, una voz nueva.
const matchedCentroid = bestIndex === -1 ? undefined : centroids[bestIndex]
if (matchedCentroid && bestSimilarity >= threshold) {
embedding.forEach((value, i) => {
matchedCentroid.sum[i] = (matchedCentroid.sum[i] ?? 0) + value
})
matchedCentroid.count += 1
assignments.push(bestIndex)
} else {
centroids.push({ sum: Float32Array.from(embedding), count: 1 })
assignments.push(centroids.length - 1)
}
Por último, cada fragmento de texto de Whisper se asigna al hablante cuyo segmento se solapa más en el tiempo con él, y los fragmentos seguidos de la misma voz se juntan en una intervención. Escribiendo este artículo encontré un detalle ahí: la limpieza recorta los espacios de cada fragmento, así que al juntarlos las frases se pegaban ("me.The"). Ahora se añade el espacio cuando ninguno de los dos lo trae.
Resumir con un modelo de lenguaje en el navegador
El resumen usa Qwen2.5-Instruct con un system prompt que pide responder en el mismo idioma del texto, con un TL;DR y unos puntos clave. Se genera de forma determinista (do_sample: false) con un máximo de 256 tokens.
La precisión volvió a ser el problema, dos veces. La primera, en un equipo con WebGPU presente pero roto, el modelo se cargaba sin cuantizar (fp32, unos 2 GB), onnxruntime caía a WASM y el runtime se quedaba sin memoria. La excepción ni siquiera era un Error, sino un número suelto. Lo arreglé forzando q8 siempre.
La segunda la descubrí preparando este artículo: con q8 en una gráfica real, el resumen era texto sin sentido, una mezcla de idiomas, ideogramas y fragmentos de código. Lo aislé cargando el mismo modelo, con la misma versión de la librería y el mismo texto, con distintas precisiones:
| Precisión | Dispositivo | Resultado |
|---|---|---|
| q8 | WebGPU | Basura: mezcla de idiomas y código |
| q4f16 | WebGPU | Coherente, pero entra en bucle |
| q4 | WebGPU | Resumen correcto |
| q8 | WASM | Coherente |
Ahora una función elige la precisión según el dispositivo, igual que en Whisper: q4 en WebGPU y q8 en WASM. Ninguna de las dos es fp32, así que el primer problema no vuelve.
Los modelos: descargar una vez
Transformers.js guarda cada archivo que descarga en la Cache API del navegador, en una caché llamada transformers-cache, y la consulta antes de ir a la red. Encima de eso hay tres piezas:
- Un gestor de modelos que recorre esa caché, reconoce qué modelos hay por su URL y enseña su estado con un botón para borrar cada uno. También muestra el espacio usado con
navigator.storage.estimate()y permite pedir almacenamiento persistente connavigator.storage.persist(), para que el navegador no lo borre si se queda sin espacio. - Carga desde disco. Si se colocan los archivos de un modelo en
public/models/<id-del-modelo>/, Transformers.js los encuentra ahí antes de ir a Hugging Face. Sirve para usarla sin internet desde el principio, en una instalación local. - PWA. Un service worker guarda la propia aplicación (HTML, JavaScript y CSS), así que con los modelos ya en caché funciona sin conexión. Lo comprobé transcribiendo con internet, cortando la red, recargando y volviendo a transcribir.
El service worker que cortaba las descargas
Preparando este artículo, el resumen falló dos veces seguidas con un escueto network error, las dos hacia los 300 segundos. Con curl, ese mismo modelo (el archivo q4 de Qwen 0.5B, de 786 MB) bajaba en 12 segundos. Esos 300 segundos eran la pista: son los 5 minutos que Chrome da a un service worker para responder a una petición.
La configuración de la PWA tenía una regla de Workbox que interceptaba todo lo que fuera a huggingface.co para guardarlo en su propia caché (CacheFirst). Esa regla hacía pasar por el service worker descargas de cientos de megas, que se volvían lentas y morían a los cinco minutos. Además, cada modelo se guardaba dos veces: una en la caché de Workbox y otra en la de Transformers.js. Lo comprobé repitiendo la prueba con los service workers bloqueados, y el resumen salió en 51 segundos.
La regla sobraba, porque Transformers.js ya guarda los modelos. La quité, y un test se asegura de que ninguna regla del service worker vuelva a capturar una URL de un modelo.
Cómo está organizado
Es una aplicación Nuxt 4 con el renderizado en servidor desactivado: no hay nada que renderizar en un servidor si todo pasa en el navegador. Se genera como sitio estático y se publica en GitHub Pages.
El código se reparte en tres capas:
- Workers que hablan con los modelos. Cada uno tiene sus tipos de mensajes en un archivo aparte, que comparten el worker y la página.
- Composables que encapsulan cada worker y su estado (cargando el modelo, transcribiendo, terminado, error) para la interfaz.
- Funciones puras con toda la lógica que se puede probar sin modelo: remuestrear, trocear en ventanas, limpiar la transcripción, agrupar voces, alinear texto y hablantes, y generar cada formato de exportación.
Hay 237 tests con Vitest sobre esas funciones y los componentes, y pruebas de extremo a extremo con Playwright para el flujo de transcripción. GitHub Actions pasa los tests unitarios y publica en cada push. Muchos cambios tienen antes su documento de diseño, y varios de ellos nacen de lo mismo: comprobar en un navegador real algo que en los tests parecía resuelto.
Qué haría distinto
El remuestreo es una interpolación lineal sin filtro previo. Para voz funciona bien, pero al bajar de 44,1 o 48 kHz a 16 kHz deja pasar algo de aliasing. Un filtro paso bajo antes de remuestrear, o dejar que lo haga un OfflineAudioContext a 16 kHz, sería más correcto.
El agrupamiento de voces decide sobre la marcha y en orden: un vector que llega pronto y mal medido puede abrir un grupo que no debería existir, y nada lo corrige después. Un agrupamiento que mire todos los vectores a la vez (aglomerativo o espectral) sería más robusto con muchos hablantes.
La interfaz tiene preparado un tiempo estimado de transcripción, pero el worker no manda el progreso de cada trozo, así que nunca llega a mostrarse. Es lo siguiente que haría.
Y el resumen con el modelo de 0,5B es un borrador: capta la idea, pero a veces añade detalles que no estaban o no sigue el formato pedido. Para eso está la opción de 1,5B, a cambio de 1,6 GB, y es donde más margen de mejora queda.
Quedan apuntadas en el README dos ideas que no están hechas: un historial opcional cifrado con AES-GCM en IndexedDB y un tratamiento específico para árabe (de derecha a izquierda) y japonés (sin espacios entre palabras). Whisper ya transcribe esos idiomas, pero la interfaz y los subtítulos todavía no están pensados para ellos.
Puedes probar TranscribeLocal en abdell1999.github.io/transcribe-local-public.