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.
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 entrada | Duración del chunk a 16 kHz | Llamadas que no devuelven nada | Tamaño de ráfaga de salida |
|---|---|---|---|
| 128 muestras (quantum del AudioWorklet) | 8 ms | 91.8% | 758 muestras (94.8 ms) |
| 256 muestras | 16 ms | 83.2% | 758 muestras |
| 512 muestras | 32 ms | 66.5% | 758 muestras |
| 1024 muestras | 64 ms | 32.5% | 758 muestras |
| 2048 muestras | 128 ms | 0% | 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
sessionystatscon 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
statsahora 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:
- 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.
- Nunca dejes que un
continuecubra 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. - 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.
- 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.
- 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.