El pipeline dividido: por qué tu VAD y tu STT quieren audio distinto
La detección de turnos quiere un stream limpio. La transcripción quiere el crudo. Ahora tenemos nuestros propios números para las dos mitades de esa afirmación: la mejora nos costó cinco puntos de WER agregada y recortó las falsas alarmas del VAD en un tercio — mientras superaba de forma clara al audio sin procesar en 11 de 18 condiciones individuales.
Actualizado el 14 de agosto de 2026. Cuando este post se publicó por primera vez, la evidencia a favor de la división era de otros — un estudio estrecho y una posición declarada de un proveedor. Ahora es nuestra y está medida, y los números de abajo se han regenerado desde entonces sobre dieciocho condiciones acústicas en lugar de trece. Los valores absolutos se movieron, el orden no, y todo lo de aquí está citado del benchmark.json publicado.
Tu agente de voz tiene un stream de micrófono y dos consumidores con requisitos opuestos. El detector de turnos quiere la señal más limpia posible para no dispararse con una televisión. El reconocedor quiere el audio con el que realmente se entrenó, artefactos incluidos. Casi todos los stacks que hemos mirado les entregan a los dos el mismo buffer, y luego afinan uno a costa del otro.
Este post es el argumento de ingeniería para dividir ese stream, las mediciones que ahora lo respaldan, y las partes que son fastidiosas de hacer bien: la alineación del retardo, el pre-roll, y dónde pertenece la división dentro del pipeline de un framework de agentes.
Los dos consumidores quieren cosas distintas
La gestión de turnos (turn-taking) quiere limpio. La detección de actividad de voz (VAD) y el endpointing operan sobre energía y pistas espectrales en frames cortos. Son máquinas de umbrales. Baja el SNR y todos los umbrales que calibraste se mueven: la detección de inicio se dispara tarde porque la relación habla-ruido necesita más tiempo para cruzar, la detección de final se dispara pronto o nunca porque el suelo de ruido nunca baja del umbral de hangover. El modo de fallo de mayor valor en un agente de voz es un falso disparo de barge-in — un segundo hablante en la habitación, una TV, murmullo de cafetería con un fragmento inteligible dentro — y ese es exactamente el caso en el que un supresor que aísla al hablante principal convierte una señal inutilizable en una utilizable.
La transcripción quiere crudo. Los modelos modernos de STT se entrenan con cantidades enormes de audio real y ruidoso. El ruido está dentro de la distribución. Los artefactos de la supresión neuronal — agujeros espectrales, ruido musical, emborronado de transitorios, un suelo de ruido que se queda antinaturalmente muerto entre palabras — no lo están. Un estudio independiente (arXiv:2512.17562) encontró que el audio mejorado puntuaba peor que el crudo en las 40 configuraciones probadas, con la salvedad de que usó solo MetricGAN+, sobre habla médica, en tasa de error de palabras (WER) semántica. AssemblyAI, citándolo, informó por separado de que la cancelación de ruido de Krisp aproximadamente duplicaba la WER cuando su salida se alimentaba al STT, mientras recortaba los falsos disparos del VAD alrededor de 3.5x. Eso era evidencia pública, y escasa. Ya no es la única evidencia que tenemos.
Lo que dice nuestra propia ejecución sobre las dos mitades
Pasamos diez motores de mejora de voz (speech enhancement) por dieciocho condiciones acústicas contra un control sin procesar, puntuando WER y VAD en la misma pasada. Procedencia: un solo reconocedor, faster-whisper base.en (CTranslate2 int8 CPU, greedy), Silero VAD a umbral 0.5, 10 hablantes / 180 clips / aproximadamente 1406 segundos, unas 190 palabras de referencia por condición, Apple M1 Max, onnxruntime 1.28.0 fijado a un hilo intra-op. Método completo y salvedades en el post de lanzamiento del benchmark.
En la rama de transcripción, la mejora perdió la media agregada. La WER agregada fue 14.77% en crudo contra 15.56% del mejor motor (ai-coustics Quail L) y 20.26% de GTCRN. Las inserciones — el reconocedor inventando palabras que nadie dijo — pasaron de 101 en crudo a 210 con GTCRN y 380 con DeepFilterNet3. Ninguno de los diez le ganó a no hacer nada en esa media agregada.
Por condición la frase es otra, y el pipeline dividido depende de que ambas sean ciertas. Al menos un motor superó al crudo en 11 de las 18 condiciones, y las victorias son grandes donde ocurren: con ruido de banda ancha a 0 dB, ocho de los diez motores superaron al crudo, el mejor por 4.7 puntos. La mejora paga donde el audio es genuinamente malo y te cuesta donde no lo es. La tabla completa y sus salvedades están en ¿La supresión de ruido ayuda de verdad al STT?.
En la rama de gestión de turnos, el panorama se invierte — pero no en la métrica que la gente suele citar. La F1 agregada del VAD apenas se movió: 0.950 en crudo contra 0.947 del mejor stream mejorado. Si calificaras la mejora por la F1 del VAD, concluirías que no hace nada.
La tasa de falsas alarmas cuenta una historia completamente distinta, y las falsas alarmas son lo que un barge-in realmente es.
| Condición | Tasa de falsas alarmas del VAD, crudo | Mejorado | Motor |
|---|---|---|---|
| Murmullo a 5 dB | 97.7% | 68.2% | FastEnhancer-L |
| Hablante competidor a 5 dB | 58.1% | 37.2% | ai-coustics Quail VF |
Bajo murmullo a 5 dB, el stream crudo da falsa alarma en esencialmente cada frame sin habla que ve el detector — 97.7% es un VAD que ha dejado de funcionar como compuerta. La mejora lo lleva a 68.2%. Eso sigue siendo malo, y es una mejora de treinta puntos en el único número que se traduce directamente en que tu agente interrumpa a un llamante que no ha hablado.
Esta es la base medida de la división. La mejora se gana su lugar en la rama de gestión de turnos incondicionalmente, y se gana su lugar en la rama de transcripción solo en condiciones específicas — en la misma ejecución, sobre el mismo audio, con el mismo reconocedor. No son dos opiniones que intercambiar. Son dos ramas que quieren dos señales distintas, y una de ellas quiere una señal distinta según la habitación.
Dos límites honestos a eso. Que la F1 apenas se mueva significa que la mejora está intercambiando en alguna medida falsas alarmas por pérdidas, y 190 palabras de referencia por condición separan efectos grandes, no pequeños. Y estas son estadísticas de VAD a nivel de frame, no límites de turno etiquetados: «menos falsos barge-ins» como métrica de producto de extremo a extremo todavía necesita una definición acordada de falso barge-in, que aún no tenemos.
La arquitectura
El stream único es un compromiso que nadie eligió deliberadamente:
┌──────────────┐
mic ──▶ enhance ──▶│ same buffer │──▶ VAD / endpointing (happy)
└──────────────┘──▶ STT (five points worse, pooled)
El pipeline dividido es el mismo análisis, dos salidas:
┌─▶ enhanced ──▶ VAD / endpointing / barge-in
mic ──▶ enhance┤
└─▶ raw (delay-matched) ──▶ ring buffer ──▶ STT
▲
└── turn boundaries index into HERE
Una sola pasada de inferencia, dos tomas. El mejorador ya calcula internamente una estimación de presencia de habla; el coste extra de emitir ambos streams es una copia y una línea de retardo, no un segundo modelo.
La parte que todo el mundo hace mal: el stream crudo va adelantado
Un mejorador tiene latencia algorítmica — el frame de análisis más cualquier lookahead. En los modelos de nuestro manifiesto eso va de 12 a 30 ms para todo excepto DeepFilterNet3, que medimos en 100 ms a través de un adaptador block-online. La salida mejorada, por tanto, va retrasada respecto a la entrada cruda en esa cantidad.
Si emites ambos streams ingenuamente, el detector de turnos produce límites en el tiempo del stream mejorado y tú los aplicas a índices del stream crudo, así que cada enunciado que recortas para el STT está desplazado un frame. A 30 ms eso basta para recortar una oclusiva al inicio de palabra, y una /p/ recortada es una sustitución o una eliminación en tu transcripción. El bug es sutil porque no falla ruidosamente; simplemente te cuesta una fracción de punto de WER para siempre.
El arreglo es una línea de retardo en la ruta cruda, ajustada al retardo declarado del mejorador, de modo que ambos streams compartan un reloj de muestras. Nuestro SDK lo declara como model.audioDelay — 15 ms a 16 kHz — y hace el contrato comprobable: process() devuelve exactamente tantas muestras como recibió, y las primeras audioDelay muestras de un stream vuelven como silencio. La línea de retardo en sí es código tuyo, y es un ring buffer que escribes una vez:
import { Model, Processor, UsageReporter } from '@anecho/sdk';
const reporter = new UsageReporter(process.env.ANECHO_API_KEY);
const model = Model.fromFile('anecho_focus_16k_v2.anecho'); // from your dashboard
const proc = new Processor(model, await reporter.ensureLicense());
const rawTap = delayLine(model.audioDelay); // yours: audioDelay samples deep
// 20 ms of float32 mono at 16 kHz = 320 samples — one model hop
for await (const block of micBlocks) {
const enhanced = proc.process(block);
const raw = rawTap.push(block); // now sample-aligned with `enhanced`
turnDetector.push(enhanced); // clean stream drives boundaries
rawRing.write(raw); // raw stream is what STT will read
}
El fichero de modelo es el .anecho por cliente que descargas del dashboard — un modelo, 16 kHz, 4.0 MB. La decisión de habla que alimenta al detector de turnos puede venir del Vad del SDK, que lee el mismo fichero de modelo, así que la división no te cuesta un segundo modelo ni una segunda descarga.
El pre-roll, o por qué desaparece tu primera palabra
Un VAD declara habla después de haber visto habla. Todo detector tiene un retardo de inicio — los frames que necesitó para estar seguro. Si empiezas a llenar el buffer del STT en el momento en que el VAD se dispara, ya has tirado el ataque de la primera palabra.
Por eso el ring buffer importa más que la propia división. Quieres un ring buffer crudo escrito de forma continua con unos cuantos cientos de milisegundos de historial, y límites de turno que indexen dentro de él:
turnDetector.on('turn', async (turn) => {
// turn.startSample / turn.endSample index the shared sample clock,
// so they are valid indices into the raw ring too
const utterance = rawRing.slice(
turn.startSample - PREROLL_SAMPLES,
turn.endSample + HANGOVER_SAMPLES,
);
await stt.transcribe(utterance);
});
Dos valores por defecto que vale la pena enunciar explícitamente, porque son los primeros que la gente afina:
- Pre-roll de 300 ms. Un seguro barato. A 16 kHz mono float32 son 19.2 KB de memoria por stream.
- Hangover más allá del final. Las fricativas finales y las oclusivas finales sin explosión son de baja energía y un endpointer ansioso las corta. Si tus transcripciones pierden sistemáticamente la
/s/del plural, esta es la razón.
En Python la forma es la misma:
import os
from anecho import Model, Processor, UsageReporter
reporter = UsageReporter(os.environ["ANECHO_API_KEY"])
model = Model.from_file("anecho_focus_16k_v2.anecho")
proc = Processor(model, reporter.ensure_license())
raw_tap = DelayLine(model.audio_delay()) # yours, as above
for block in mic_blocks(320): # 20 ms at 16 kHz — one model hop
enhanced = proc.process(block)
turn_detector.push(enhanced)
raw_ring.write(raw_tap.push(block))
Dónde vive la división en un stack real
En un framework de agentes, la división tiene que ocurrir antes del enrutado propio del framework, porque el framework asume una sola pista de audio. Ese punto — el callback de audio, el procesador de frames, el hook de medios, como lo llame tu framework — es donde va el bucle de arriba. Lo entregamos como una función de bloque a bloque en lugar de como un plugin por framework precisamente para que encaje en cualquiera de esas costuras: el procesador no tiene opinión sobre lo que hay aguas abajo, y el enrutado sigue siendo código tuyo, lo que significa que sigue siendo medible.
La matriz de enrutado, dicha claramente — cada fila es un cableado de las dos tomas, y cambiar entre ellas es una línea en tu bucle:
| cableado | La gestión de turnos recibe | El STT recibe | Cuándo usarlo |
|---|---|---|---|
| vad-only | mejorado | crudo, con retardo ajustado | por defecto; entornos ruidosos, problemas de barge-in |
| both | mejorado | mejorado | tráfico con hablantes competidores, donde nuestros datos dicen que el STT también gana |
| stt-only | crudo | mejorado | raro; solo si tu VAD ya es robusto al ruido y tu STT no |
| disabled | crudo | crudo | el control, y aquello contra lo que deberías medir |
Recomendamos vad-only por defecto porque es hacia donde apuntan nuestras propias mediciones, sobre nuestro corpus, con un reconocedor — no porque lo hayamos probado para tu tráfico.
La fila both no es hipotética, y tampoco es rara. La mejora superó al crudo en transcripción en 11 de nuestras 18 condiciones — con más claridad donde una voz competidora o ruido de banda ancha a bajo SNR está destruyendo genuinamente pistas fonéticas. El mayor resultado individual es ai-coustics Quail VF sobre hablante competidor a 5 dB, recortando las inserciones de 19 a 10 y la WER de 26.3% a 20.5%. Si tus llamantes están en habitaciones con otro humano hablando, both-mejorado es defendible con evidencia. Agregado sobre las dieciocho condiciones, el mismo modelo es más de tres puntos peor que el crudo, porque también corre sobre audio limpio, reverberante y degradado por el operador, donde solo puede eliminar información — que es la razón de que no sea el valor por defecto, y de que esto sea una medición por despliegue y no un ajuste que podamos elegir por ti.
El coste, y lo que la división no cuesta
La objeción que oímos es que esto duplica el audio que mueves. No lo hace, si mantienes la ruta cruda local. La mejora corre donde el audio ya está — en el worklet del navegador, en tu servidor de medios, o en tu proceso de Node — y solo el stream que un consumidor dado necesita cruza una frontera de red. El stream mejorado alimenta un VAD que normalmente está en el mismo proceso. El stream crudo va a tu proveedor de STT, cosa que iba a hacer de todos modos.
Lo que sí cuesta:
- Memoria. Un ring buffer por sesión. Aproximadamente 19 KB por 300 ms a 16 kHz mono float32; digamos 64 KB por sesión con margen.
- Una línea de retardo. Un frame de latencia del mejorador añadido a la ruta cruda para que los relojes coincidan. Esto no añade latencia de respuesta de extremo a extremo, porque la ruta cruda iba adelantada, no atrasada — te estás alineando al stream más lento que ya tenías.
- Una cosa más sobre la que razonar. Dos streams son dos lugares donde puede esconderse un bug. La mitigación es que ambos se derivan de una sola llamada a
process()con un solo reloj de muestras, en lugar de dos pipelines independientes que pueden derivar.
Lo que sigue sin saberse
- Algunos reconocedores pueden estar entrenados con audio mejorado. Si un proveedor preprocesa en su propia ingesta, alimentarlo con crudo es correcto por una razón distinta, o incorrecto por una sutil. Ninguno de nosotros puede ver esos conjuntos de entrenamiento.
- El punto de cruce del SNR. La mejora gana claramente a bajo SNR y pierde claramente con audio limpio, pero todavía no podemos ponerle un número a dónde se da la vuelta. La densidad de ganadores alcanza su pico a 0 dB — ocho de diez motores — y se adelgaza en ambas direcciones, con un motor que supera al crudo incluso en la condición
clean. Eso es una pendiente, no un umbral, y diferirá por motor. Localizarlo como es debido es lo que haría entregable la mejora condicional. - Las ganancias en gestión de turnos son a nivel de frame, no a nivel de turno. Reportamos la F1 y la tasa de falsas alarmas del VAD. «Menos falsos barge-ins» necesita límites de turno etiquetados y una métrica acordada. Eso es lo siguiente que le debemos al benchmark.
- La telefonía es su propio caso. En una ruta de banda estrecha de 8 kHz el reconocedor está fuera de distribución antes de que toques nada, ningún mejorador de nuestra matriz produjo ahí una mejora significativa y varios lo degradaron gravemente, y el razonamiento cambia; ver 8 kHz es donde la IA de voz se rompe de verdad. Desde entonces hemos corrido un experimento aparte a 8 kHz de extremo a extremo, con tres modelos genuinamente nativos de banda estrecha dentro: el nativo le gana a remuestrear-y-cruzar-los-dedos por 0.7 puntos para el mismo proveedor, y nada en absoluto le gana al audio del llamante sin tocar.
La razón para mantener el enrutado en tu propio bucle, en lugar de enterrado en un valor por defecto del framework, es que hace la pregunta testeable por despliegue. Cambia la toma del STT entre crudo, mejorado y desactivado, pasa tu propio audio por el arnés, y lee la ΔWER contra el control passthrough. Si el mejorado gana con tu tráfico, entrega el mejorado — al SDK no le importa qué respuesta obtengas, y a nosotros tampoco, mientras la medición sea real.
El quickstart y la referencia del SDK están en /docs. El arnés que produce los números de ΔWER está en github.com/anecho, y la matriz publicada está en /benchmark.