AudioWorklets: Cómo redujimos la latencia de Voice AI en un 50%

Voice AIAudioWorkletsWebAudioReal-time6 min de lectura
AudioWorklets: Cómo redujimos la latencia de Voice AI en un 50%

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 createBufferSource con 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:

typescript
// 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:

  1. GC pressure: Crear y destruir un AudioBufferSourceNode por cada chunk (que llegan cada ~20ms) genera presión constante en el garbage collector.
  2. Gaps entre chunks: El callback onended se ejecuta en el main thread. Si el main thread está ocupado (render, DOM updates), hay un gap audible entre chunks.
  3. 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:

javascript
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):

javascript
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:

  1. El handler de contentEnd del servidor checaba if (!this.isPlaying)
  2. Un contador de frames silenciosos en el main thread (silentFrames > 10)
  3. El flag isInitialBuffering del 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:

javascript
// 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:

typescript
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:

typescript
// 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.

typescript
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étricaAntesDespués
Buffer inicial200ms100ms
Gaps entre chunksFrecuentesCero
Nodos de audio por turno~50-1001 (fijo)
Status stuck en "speaking"FrecuenteNunca
GC pressureAltaMínima

Takeaways

  1. 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.

  2. 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.

  3. 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.

  4. 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:

bash
npm install @formmy.app/chat@latest