AudioWorklets: Cómo redujimos la latencia de Voice AI en un 50%
Actualización (2026-03-02): Después de publicar este post, descubrimos que AudioWorklets para playback bypasean el AEC (Acoustic Echo Cancellation) del browser, causando loops de echo en speakers abiertos. Revertimos a
createBufferSourcecon scheduling preciso en SDK v0.0.13. Lee la historia completa en Por Qué Revertimos AudioWorklets.
Cuando lanzamos Voice AI en Formmy, nuestros usuarios reportaron dos problemas: latencia alta al iniciar la respuesta del agente y un bug donde el status se quedaba en "speaking" para siempre. Ambos problemas tenían la misma raíz: estábamos usando mal la Web Audio API.
Este post documenta lo que aprendimos, los errores que cometimos, y el patrón que adoptamos inspirados en las implementaciones oficiales de AWS.
El problema: AudioBufferSourceNode por chunk
Nuestra primera implementación convertía cada chunk de audio del servidor en un AudioBufferSourceNode individual, los encadenaba con callbacks onended, y los reproducía secuencialmente:
// Lo que hacíamos antes (no hagas esto)
private enqueuePlayback(float32: Float32Array) {
const audioBuffer = ctx.createBuffer(1, float32.length, 24000);
audioBuffer.getChannelData(0).set(float32);
const src = ctx.createBufferSource();
src.buffer = audioBuffer;
src.connect(ctx.destination);
this.audioQueue.push(src);
if (!this.isPlaying) this.playNext();
}
private playNext() {
const next = this.audioQueue.shift();
if (!next) { this.isPlaying = false; return; }
next.onended = () => this.playNext();
next.start();
}Tres problemas con este approach:
- GC pressure: Crear y destruir un
AudioBufferSourceNodepor cada chunk (que llegan cada ~20ms) genera presión constante en el garbage collector. - Gaps entre chunks: El callback
onendedse ejecuta en el main thread. Si el main thread está ocupado (render, DOM updates), hay un gap audible entre chunks. - Jitter buffer manual: Para compensar los gaps, agregamos un jitter buffer de 200ms en el main thread que acumulaba chunks antes de empezar a reproducir. Más latencia.
La solución: Un AudioWorklet continuo
Investigando las implementaciones oficiales de AWS para Nova Sonic, encontramos un patrón mucho más simple en aws-samples/sample-voicebot-nova-sonic. En lugar de crear nodos por chunk, usan un solo AudioWorklet con un buffer expandible que corre en el audio thread:
class ExpandableBuffer {
constructor() {
this.buffer = new Float32Array(24000); // 1s capacity
this.readIndex = 0;
this.writeIndex = 0;
this.isInitialBuffering = true;
this.initialBufferLength = 2400; // 100ms at 24kHz
}
write(samples) {
// Expand buffer if needed, then append
this.buffer.set(samples, this.writeIndex);
this.writeIndex += samples.length;
if (this.writeIndex - this.readIndex >= this.initialBufferLength) {
this.isInitialBuffering = false; // Start playback
}
}
read(dest) {
if (this.isInitialBuffering) {
dest.fill(0); // Silence until primed
return 0;
}
const len = Math.min(dest.length, this.writeIndex - this.readIndex);
dest.set(this.buffer.subarray(this.readIndex, this.readIndex + len));
this.readIndex += len;
if (len < dest.length) dest.fill(0, len); // Pad with silence
return len;
}
}El AudioWorkletProcessor.process() simplemente lee del buffer en cada frame de audio (cada ~2.67ms a 24kHz con frames de 128 samples):
process(inputs, outputs) {
const out = outputs[0][0];
this.buf.read(out);
return true; // Keep running
}Zero gaps. El audio thread nunca para. Si no hay datos, llena con silencio. Si hay datos, los reproduce. Sin callbacks, sin queue management, sin GC pressure.
El segundo bug: status "speaking" infinito
Con el worklet implementado, el status ya no dependía de onended callbacks. Pero introdujimos un nuevo problema: tres cosas competían por la transición speaking → ready:
- El handler de
contentEnddel servidor checabaif (!this.isPlaying) - Un contador de frames silenciosos en el main thread (
silentFrames > 10) - El flag
isInitialBufferingdel worklet
El resultado: race conditions donde ninguno ganaba consistentemente, y el status se quedaba en "speaking" para siempre.
El protocolo drain
La solución fue darle un solo dueño a la transición: el worklet.
Cuando el servidor envía contentEnd (el agente terminó de hablar), el main thread no intenta transicionar directamente. En lugar de eso, envía un mensaje drain al worklet:
// Main thread: contentEnd handler
this.playbackNode.port.postMessage({ type: "drain" });
// Worklet: drain protocol
this.port.onmessage = (e) => {
if (e.data.type === "audio") this.buf.write(e.data.audioData);
else if (e.data.type === "drain") this.draining = true;
else if (e.data.type === "barge-in") this.buf.clear();
};
process(inputs, outputs) {
const out = outputs[0][0];
const played = this.buf.read(out);
if (this.draining && played === 0) {
this.draining = false;
this.port.postMessage({ type: "drained" }); // Done!
}
return true;
}El worklet sabe exactamente cuándo el buffer se vació porque lo lee sample por sample. Cuando reporta drained, el main thread transiciona a "ready" con certeza:
this.playbackNode.port.onmessage = (e) => {
if (e.data.type === "drained") {
this.isPlayingAudio = false;
if (this.status === "speaking") this.setStatus("ready");
}
};Un solo dueño, una sola transición, cero race conditions.
Race condition bonus: creación async del worklet
Un detalle más. Crear un AudioWorklet es async (audioWorklet.addModule()). Si múltiples chunks de audio llegan mientras el worklet se está creando, cada uno llamaba a ensurePlaybackWorklet() y todos intentaban crear el worklet:
// Bug: cada chunk dispara una creación nueva
private async ensurePlaybackWorklet() {
if (this.playbackNode) return this.playbackNode;
// 3 chunks llegan aquí simultáneamente...
await ctx.audioWorklet.addModule(url); // se registra 3 veces
}El fix: cachear la promise.
private ensurePlaybackWorklet(): Promise<AudioWorkletNode> {
if (this.playbackNode) return Promise.resolve(this.playbackNode);
if (this.playbackNodePromise) return this.playbackNodePromise;
this.playbackNodePromise = this.createPlaybackWorklet();
return this.playbackNodePromise;
}Todos los chunks esperan la misma promise. El worklet se crea una sola vez.
Resultados
| Métrica | Antes | Después |
|---|---|---|
| Buffer inicial | 200ms | 100ms |
| Gaps entre chunks | Frecuentes | Cero |
| Nodos de audio por turno | ~50-100 | 1 (fijo) |
| Status stuck en "speaking" | Frecuente | Nunca |
| GC pressure | Alta | Mínima |
Takeaways
-
No crees nodos de audio por chunk. Usa un AudioWorklet con buffer continuo. El audio thread no tiene GC, no compite con el main thread, y no tiene gaps.
-
Un solo dueño para transiciones de estado. Si el worklet maneja el audio, que el worklet decida cuándo terminó. No compitas desde el main thread con heurísticas.
-
Cachea promises de inicialización async. Si algo se crea una vez pero se necesita desde múltiples callers, guarda la promise, no solo el resultado.
-
Mira las implementaciones oficiales. AWS publica ejemplos completos en
aws-samples/que resuelven exactamente estos problemas. No reinventes el wheel.
Estos cambios están disponibles en @formmy.app/chat v0.0.9+. Si usas el Voice SDK, actualiza con:
npm install @formmy.app/chat@latest