Skip to content
anecho.ai

El remuestreador que se comió el 92% de un micrófono

soxr.ResampleStream es un emisor a ráfagas. Alimentado con el quantum de 128 muestras del AudioWorklet del navegador devuelve un array vacío en el 92% de las llamadas y luego entrega 95 ms de golpe. Leímos ese vacío como 'todavía cebándose' y saltamos el bloque — lo que también saltaba el buffer intacto del micrófono que estaba al lado, de modo que solo el 8% del llamante llegó jamás al modelo. Nuestra propia telemetría lo reportó como 0.08 durante días. Esto es un bug de flujo de control, no la afirmación del impuesto de remuestreo (resample tax) que retractamos, y aquí está la sonda de cuatro líneas que lo encuentra.

Anecho Engineering16 ago 202610 min de lectura
dspresamplingvoice-agentsdebuggingstreaming

Un agente que estábamos construyendo se conectaba, mostraba una sesión viva, transmitía audio durante toda la llamada, y no respondía ni una sola vez. El socket seguía abierto. El audio seguía saliendo del navegador. No volvía nada.

La causa eran cuatro tokens de Python:

x8 = down(xin)
if not x8.size:
    continue

down es un soxr.ResampleStream con estado. El guard se lee como «el filtro polifásico todavía no se ha cebado, salta este bloque y vuelve la próxima vez». Eso es algo razonable de creer sobre un remuestreador, y sobre este es falso. soxr.ResampleStream no se ceba una vez y luego emite de forma constante. Acumula internamente y emite a ráfagas, durante toda la sesión, y si lo alimentas con bloques pequeños devuelve un array vacío en la inmensa mayoría de las llamadas.

Antes del diagnóstico, una cosa que este post no es. Ya hemos retractado una afirmación según la cual el remuestreo en sí te cuesta una fracción fija del audio del llamante; construimos el control y nuestra medición no la respaldó, y no la estamos reintroduciendo aquí en voz baja. Nada de lo de abajo es una propiedad del remuestreo. Es una propiedad del calendario de emisión de una biblioteca al encontrarse con una sentencia continue, y el audio lo tiró nuestro flujo de control, no ningún filtro.

La medición

Cuatro líneas, reproducibles en cualquier máquina con soxr instalado. Alimenta un remuestreador de stream con el propio quantum de render del navegador y cuenta cuántas veces te devuelve algo.

import numpy as np, soxr

rs = soxr.ResampleStream(16000, 8000, 1, dtype="float32", quality="VHQ")
sizes = [rs.resample_chunk(np.zeros(128, np.float32)).size for _ in range(400)]
print(sum(s > 0 for s in sizes), "non-empty of", len(sizes))

En este host — soxr 1.1.0, macOS sobre Apple silicon — eso imprime 33 non-empty of 400. Así que el 91.8% de las llamadas no devuelve nada en absoluto, y las 33 que sí devuelven cada una 758 muestras fijas: 94.8 ms de audio a 8 kHz, llegando en un solo bulto, una y otra vez, mientras dure la sesión.

El tamaño del bloque es una propiedad del remuestreador, no de tu entrada. Varía el chunk con el que lo alimentas y solo cambia la frecuencia de las ráfagas:

Chunk de entradaDuración del chunk a 16 kHzLlamadas que no devuelven nadaTamaño de ráfaga de salida
128 muestras (quantum del AudioWorklet)8 ms91.8%758 muestras (94.8 ms)
256 muestras16 ms83.2%758 muestras
512 muestras32 ms66.5%758 muestras
1024 muestras64 ms32.5%758 muestras
2048 muestras128 ms0%758 o 1516 muestras

400 llamadas por fila, un ResampleStream nuevo para cada una, de 16 kHz a 8 kHz en VHQ.

De ahí se desprenden dos cosas. Primera, hay un umbral, y es exactamente la entrada necesaria para llenar una ráfaga de salida: con diezmado 2:1, 758 muestras de salida requieren 1516 muestras de entrada, y alimentar 1516 por llamada reduce los retornos vacíos a la única llamada de cebado (0.5% de 200). Con 2048 incluso esa vuelve con datos. Por eso nadie se topa con esto con frames de servidor de 20 ms desde una pata SIP y todo el mundo se topa con ello con un worklet de navegador. Segunda, el tamaño de la ráfaga sigue al filtro, no a la fontanería: en HQ son 830 muestras, en LQ 470, y de 48 kHz → 16 kHz son 1100. Esos números son esta versión en este host, y deberías medir los tuyos en lugar de fiarte de los nuestros; la forma es lo que se transfiere.

Nada de esto es un defecto de soxr. Un remuestreador polifásico con un filtro anti-alias largo tiene que acumular entrada antes de poder producir salida correctamente filtrada, y emitir en bloques internos completos es la manera eficiente de hacerlo. La biblioteca se comporta exactamente como debe comportarse un remuestreador de stream. El bug está por entero en lo que nosotros concluimos de un retorno vacío.

Por qué un array vacío significaba algo distinto en dos lugares

Teníamos la misma forma de guard en dos servicios, y era inofensiva en uno y grave en el otro. La diferencia merece enunciarse con precisión, porque «tuvimos este bug dos veces» no es la parte interesante.

En el endpoint de mejora el código es:

x8 = down(pcm)
if not x8.size:
    continue          # harmless here
y8 = model.process(x8)
await ws.send_bytes(up(y8))

Todo lo que hay aguas abajo se deriva de x8. Un x8 vacío significa genuinamente que todavía no hay nada que procesar, que no se ha descartado nada, y que el audio está dentro del buffer del remuestreador esperando su ráfaga. El continue es correcto.

En el endpoint del agente las mismas tres líneas estaban en lo alto de una función que tenía dos trabajos paralelos: pasar el audio del llamante por el modelo a 8 kHz y, por separado, conservar una copia intacta de 16 kHz para el brazo de control de un A/B. El continue retornaba antes de que corriera el segundo trabajo. Así que con el filtro apagado — el brazo sin ningún modelo dentro, el brazo que se supone que es la línea base honesta — el buffer crudo del micrófono se descartaba junto con el x8 vacío que no tenía nada que ver con él.

El resultado, medido de extremo a extremo contra el endpoint real: 3.7 segundos de enlace ascendente en una llamada de 46 segundos. Alrededor del 8% del llamante. Cero turnos, jamás.

La regla que escribimos en el código después es más estrecha que «nunca saltes un bloque», porque a veces saltar es lo correcto:

Nada puede condicionar un brazo a que el remuestreador del otro brazo haya producido salida.

Estuvo en la telemetría todo el tiempo

Esta es la parte que debería resultar incómoda, y es la razón de que exista el post en lugar de un mensaje de commit.

El servicio ya emitía un mensaje stats aproximadamente cada dieciséis bloques, y ese mensaje ya llevaba un campo llamado uplink_realtime — segundos de enlace ascendente divididos por los segundos de reloj desde que la sesión pasó a estar viva. En una llamada sana se sitúa cerca de 1.0. En las llamadas rotas marcaba 0.08, en un blob de JSON, en una consola de navegador, durante días.

Nadie lo leyó, porque el síntoma visible para el usuario («el agente no responde») apuntaba al modelo, al prompt, a las credenciales y a la API antes que a la aritmética. Un número que dice se está enviando el 8% del audio que crees estar enviando estaba a un scroll de distancia de la persona que depuraba, y perdió cada vez frente a una hipótesis más interesante.

De eso salieron dos cambios, y son lo bastante baratos como para recomendárselos a cualquiera que opere una ruta de audio en vivo:

  • Publica una ratio, no un recuento. Los bytes enviados no son falsables — también suben en una llamada rota. Los segundos de audio entregados por segundo de reloj tienen un valor correcto conocido, así que un valor equivocado es legible sin contexto. Ahora se envía en los mensajes session y stats con un umbral declarado: por debajo de aproximadamente 0.9 significa que se está perdiendo audio o que está llegando tarde.
  • Di lo que el número implica, en el propio payload. El mensaje stats ahora lleva la frase «el VAD de Gemini no puede terminar un turno con audio que nunca recibió» junto al número, porque el número solo no conectó con el síntoma para nadie que no hubiera encontrado ya el bug.

El bug de segundo orden: los brazos no eran comparables

Arreglar el descarte expuso un problema más sutil en el mismo código, y es el que habría invalidado en silencio el experimento para el que existe el endpoint.

Ese endpoint es un único interruptor: mismo hablante, mismo ruido de fondo, filtro encendido contra filtro apagado, a mitad de conversación. Para que esa comparación signifique algo, los dos brazos deben diferir en el audio y en nada más. No era así. El brazo filtrado heredaba la cadencia de ráfagas de ~95 ms del remuestreador; el brazo sin filtrar pasaba directamente el quantum crudo de 8 ms del navegador. Medido en la misma llamada: 10.6 frames por segundo contra 125.0 — una diferencia de 11.8x en cómo se empaquetaba una misma conversación.

Cualquier diferencia de comportamiento que un oyente atribuyera al filtro estaba confundida con la cadencia de frames, y 125 frames por segundo es mucho más de lo que la API espera. Ambos brazos se fusionan ahora en un frame fijo de 100 ms antes de enviarse, de modo que la cadencia es idéntica por construcción, y la tasa por brazo se reporta en lugar de asumirse — el payload de salud lleva uplink_frame_hz para ambos brazos, y se supone que deben leerse igual.

Hay un enclavamiento que merece nombrarse, porque es el tipo de cosa que convierte un arreglo en un bug distinto. Un frame construido a medias es audio que no se ha enviado. La fusión solo es segura porque otra cosa garantiza que el buffer siempre tiene un productor — de lo contrario, la cola de la última frase del llamante se queda en un frame medio lleno exactamente en el momento en que el servidor la necesita para detectar que el llamante ha parado. Esa garantía es el keep-alive descrito en el post de Gemini Live, y los dos cambios no son separables.

Por qué usar siquiera un remuestreador con estado

La escapatoria obvia es llamar al soxr.resample() sin estado una vez por chunk y no ver nunca un retorno vacío. No lo hagas.

Una llamada sin estado reinicia el filtro polifásico en cada frontera de chunk, lo que estampa una discontinuidad en la señal unas treinta veces por segundo con un chunk de 32 ms. Es audible como un zumbido a la frecuencia de los chunks, y es el tipo de artefacto que un oyente atribuirá, con toda la razón, a tu modelo. El remuestreador con estado arrastra el estado de su filtro entre llamadas precisamente para que eso no ocurra, y la emisión a ráfagas es el coste visible de esa corrección.

Ese coste es honesto y acotado: es un retardo fijo de inicio de sesión, no jitter. Lo medimos en lugar de asumirlo — roundtrip_delay_ms() corre una correlación entre entrada y salida a través de un par de remuestreadores de bajada/subida y reporta el desfase, y el resultado se publica en /health como retardo de transporte, separado de la latencia algorítmica propia del modelo, de 16 ms. Dos retardos distintos con dos dueños distintos, reportados como dos números.

La lista de comprobación

Si operas una ruta de audio de navegador a servidor con una conversión de frecuencia de muestreo dentro:

  1. Cuenta lo que envías de verdad. Segundos de audio entregados sobre segundos de reloj, por sesión, expuestos a quien esté depurando. No bytes.
  2. Nunca dejes que un continue cubra más que aquello que está guardando. Si un bloque hace dos trabajos independientes, un retorno temprano de uno es un fallo silencioso del otro. Sepáralos.
  3. Sondea el calendario de emisión de tu remuestreador con tu tamaño de bloque real, con las cuatro líneas de arriba. Si lo alimentas con quanta de 128 muestras, espera no recibir nada la mayor parte del tiempo; si lo alimentas con frames de servidor de 20 ms, no verás esto jamás.
  4. Alimentarlo en cada llamada con la entrada equivalente a un bloque completo de salida hace que el retorno vacío casi desaparezca — pero no construyas sobre ese número, porque depende de la versión y de la relación, y la llamada de cebado sigue volviendo vacía. En su lugar, maneja correctamente el caso vacío.
  5. No compares dos brazos con empaquetado distinto. Fusiona a un único tamaño de frame antes del transporte, y reporta la tasa de frames por brazo para que una divergencia sea visible en lugar de inferida.

Y la general, que es la única parte de esto que no va de audio: la instrumentación que habría atrapado esto ya existía y ya tenía el valor correcto dentro. Construir la métrica es la mitad fácil. Hacer que la métrica diga lo que implica, junto al síntoma que la persona está mirando realmente, es la mitad que hicimos mal.

Siguiente
  • 24 oct 2026

    Alternativas a ai-coustics para agentes de voz y centros de llamadas (2026)

    Por qué hay equipos que buscan más allá de ai-coustics Quail Voice Focus, y una lista corta con fuentes según figuraba en octubre de 2026: Krisp VIVA, AssemblyAI Voice Focus, el aislamiento de voz de LiveKit Cloud, NVIDIA Speaker Focus, Anecho y referencias de código abierto. Hemos comparado ai-coustics con nuestro propio modelo en conversaciones en vivo: empate con faster-whisper, ventaja de v8_2 con Gemini Live, menos interrupciones falsas para ai-coustics, y la condición decide quién va delante.

  • 22 oct 2026

    Alternativas a Krisp para agentes de voz y centros de llamadas (2026)

    Por qué hay equipos que buscan más allá de Krisp VIVA, y una lista corta con fuentes según figuraba en octubre de 2026: ai-coustics Quail Voice Focus, AssemblyAI Voice Focus, el aislamiento de voz de LiveKit Cloud, NVIDIA Speaker Focus, Anecho y referencias de código abierto, con dónde se ejecuta cada uno, su precio público y si lo hemos medido. No hemos medido Krisp.

  • 20 oct 2026

    Comparativa de SDK de aislamiento de hablante (2026): Krisp, ai-coustics, AssemblyAI, LiveKit y Anecho

    Una comparación con fuentes del aislamiento de hablante para agentes de voz y centros de llamadas, tal como figuraba en octubre de 2026: dónde se ejecuta cada producto, latencia declarada, frecuencias de muestreo, integración, precio público y licencias. Krisp VIVA y Anecho declaran 15 ms en CPU; ai-coustics indica menos de 30 ms; AssemblyAI y LiveKit venden el aislamiento dentro de un servicio en la nube. Los números de calidad vienen solo de nuestro propio benchmark.