Skip to content
anecho.ai

O reamostrador que comeu 92% de um microfone

O soxr.ResampleStream é um emissor de rajadas. Alimentado com o quantum de 128 amostras do AudioWorklet do navegador, ele retorna um array vazio em 92% das chamadas e então devolve 95 ms de uma vez. Lemos esse vazio como "ainda preparando o filtro" e pulamos o bloco — o que também pulou o buffer intocado do microfone ao lado dele, de modo que só 8% de quem ligava chegou, em algum momento, ao modelo. A nossa própria telemetria reportou isso como 0.08 por dias. Isto é um bug de fluxo de controle, não a alegação do imposto de reamostragem (resample tax) que retiramos, e aqui está a sonda de quatro linhas que o encontra.

Anecho Engineering16 de ago. de 20269 min de leitura
dspresamplingvoice-agentsdebuggingstreaming

Um agente que estávamos construindo conectava, mostrava uma sessão ao vivo, transmitia áudio pela chamada inteira e nunca respondia uma única vez. O socket permanecia aberto. O áudio continuava saindo do navegador. Nada voltava.

A causa eram quatro tokens de Python:

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

down é um soxr.ResampleStream com estado. O guard se lê como "o filtro polifásico ainda não se preparou; pule este bloco e volte na próxima". Essa é uma coisa razoável de acreditar sobre um reamostrador, e está errada sobre este. O soxr.ResampleStream não se prepara uma vez e depois emite de forma constante. Ele acumula internamente e emite em rajadas, pela sessão inteira, e, se você o alimenta com blocos pequenos, ele retorna um array vazio na esmagadora maioria das chamadas.

Antes do diagnóstico, uma coisa que este post não é. Nós já retiramos uma afirmação de que a reamostragem em si custa uma fração fixa do áudio de quem liga; construímos o controle, a nossa medição não a sustentou, e não vamos reintroduzi-la aqui em silêncio. Nada abaixo é propriedade da reamostragem. É propriedade do cronograma de emissão de uma biblioteca encontrando um comando continue, e o áudio foi jogado fora pelo nosso fluxo de controle, não por filtro algum.

A medição

Quatro linhas, reproduzíveis em qualquer máquina com o soxr instalado. Alimente um reamostrador de stream com o próprio quantum de renderização do navegador e conte com que frequência ele te devolve alguma coisa.

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

Neste host — soxr 1.1.0, macOS em Apple silicon — isso imprime 33 non-empty of 400. Então 91.8% das chamadas não retornam absolutamente nada, e as 33 que retornam devolvem, cada uma, um valor fixo de 758 amostras: 94.8 ms de áudio de 8 kHz, chegando em um bolo só, repetidamente, enquanto a sessão durar.

O tamanho do bloco é uma propriedade do reamostrador, não da sua entrada. Varie o chunk com que você o alimenta e apenas a frequência das rajadas muda:

Chunk de entradaDuração do chunk a 16 kHzChamadas que não retornam nadaTamanho da rajada na saída
128 amostras (quantum do AudioWorklet)8 ms91.8%758 amostras (94.8 ms)
256 amostras16 ms83.2%758 amostras
512 amostras32 ms66.5%758 amostras
1024 amostras64 ms32.5%758 amostras
2048 amostras128 ms0%758 ou 1516 amostras

400 chamadas por linha, um ResampleStream novo em cada uma, 16 kHz para 8 kHz em VHQ.

Duas coisas caem disso. Primeiro, há um limiar, e ele é exatamente a entrada necessária para encher uma rajada de saída: em decimação 2:1, 758 amostras de saída exigem 1516 amostras de entrada, e alimentar 1516 por chamada reduz os retornos vazios à única chamada de preparação (0.5% de 200). Em 2048, até essa volta com conteúdo. É por isso que ninguém bate nisso com frames de 20 ms do lado do servidor vindos de uma perna SIP, e todo mundo bate nisso com um worklet de navegador. Segundo, o tamanho da rajada acompanha o filtro, não o encanamento: em HQ são 830 amostras, em LQ, 470, e de 48 kHz → 16 kHz são 1100. Esses números são desta versão neste host, e você deveria medir os seus em vez de confiar nos nossos; o formato é o que se transfere.

Nada disso é um defeito no soxr. Um reamostrador polifásico com um filtro anti-aliasing longo precisa acumular entrada antes de conseguir produzir saída corretamente filtrada, e emitir em blocos internos inteiros é a maneira eficiente de fazer isso. A biblioteca está se comportando exatamente como um reamostrador de stream deve se comportar. O bug está inteiramente no que nós concluímos de um retorno vazio.

Por que um array vazio significava coisas diferentes em dois lugares

Tínhamos o mesmo formato de guard em dois serviços, e ele era inofensivo em um e severo no outro. A diferença merece ser declarada com precisão, porque "tivemos este bug duas vezes" não é a parte interessante.

No endpoint de aprimoramento, o código é:

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

Tudo rio abaixo é derivado de x8. Um x8 vazio genuinamente significa que ainda não há nada a processar, nada foi descartado, e o áudio está dentro do buffer do reamostrador esperando a sua rajada. O continue está correto.

No endpoint do agente, as mesmas três linhas ficavam no topo de uma função que tinha dois trabalhos paralelos: rodar o áudio de quem liga através do modelo a 8 kHz e, separadamente, manter uma cópia intocada de 16 kHz para o braço de controle de um A/B. O continue retornava antes de o segundo trabalho rodar. Então, com o filtro desligado — o braço sem modelo nenhum, o braço que deveria ser a baseline honesta —, o buffer bruto do microfone era descartado junto com o x8 vazio que não tinha nada a ver com ele.

O resultado, medido de ponta a ponta contra o endpoint real: 3.7 segundos de uplink em uma chamada de 46 segundos. Cerca de 8% de quem ligava. Zero turnos, sempre.

A regra que escrevemos no código depois é mais estreita do que "nunca pule um bloco", porque às vezes pular é o certo:

Nada pode condicionar um braço ao fato de o reamostrador do outro braço ter produzido saída.

Estava na telemetria o tempo todo

Esta é a parte que deve ser desconfortável, e é a razão de este post existir em vez de uma mensagem de commit.

O serviço já emitia uma mensagem stats aproximadamente a cada dezesseis blocos, e essa mensagem já carregava um campo chamado uplink_realtime — segundos de uplink divididos pelos segundos de relógio de parede desde que a sessão ficou ao vivo. Em uma chamada saudável, ele fica perto de 1.0. Nas chamadas quebradas, ele ficou em 0.08, em um blob de JSON, em um console de navegador, por dias.

Ninguém o leu, porque o sintoma visível ao usuário ("o agente não responde") apontava para o modelo, o prompt, as credenciais e a API antes de apontar para aritmética. Um número que diz 8% do áudio que você acha que está enviando está sendo enviado estava a um scroll de distância da pessoa depurando, e perdeu para uma hipótese mais interessante todas as vezes.

Duas mudanças saíram disso, e elas são baratas o suficiente para que recomendemos as duas a qualquer um que opere um caminho de áudio ao vivo:

  • Publique uma razão, não uma contagem. Bytes enviados é infalsificável — sobe também em uma chamada quebrada. Segundos de áudio entregues por segundo de relógio de parede tem um valor correto conhecido, então um valor errado é legível sem contexto. Isso agora vai nas mensagens session e stats, com um limiar declarado: abaixo de aproximadamente 0.9 significa que áudio está sendo perdido ou chegando atrasado.
  • Diga o que o número implica, no próprio payload. A mensagem stats agora carrega a frase "a detecção de atividade de voz (VAD) do Gemini não pode encerrar um turno com áudio que nunca recebeu" ao lado do número, porque o número sozinho não se conectava ao sintoma para ninguém que ainda não tivesse encontrado o bug.

O bug de segunda ordem: os braços não eram comparáveis

Consertar o descarte expôs um problema mais sutil no mesmo código, e é o que teria invalidado em silêncio o experimento para o qual o endpoint existe.

Esse endpoint é um único interruptor: mesmo locutor, mesmo ruído de fundo, filtro ligado contra filtro desligado, no meio da conversa. Para essa comparação significar alguma coisa, os dois braços precisam diferir no áudio e em nada mais. Não era o caso. O braço filtrado herdou a cadência de rajadas de ~95 ms do reamostrador; o braço não filtrado passava direto o quantum bruto de 8 ms do navegador. Medido na mesma chamada: 10.6 frames por segundo contra 125.0 — uma diferença de 11.8x em como uma mesma conversa era empacotada.

Qualquer diferença de comportamento que um ouvinte atribuísse ao filtro estava confundida com a cadência de frames, e 125 frames por segundo é muito mais do que a API espera. Os dois braços agora são coalescidos em um frame fixo de 100 ms antes de serem enviados, então a cadência é idêntica por construção, e a taxa por braço é reportada em vez de presumida — o payload de saúde carrega uplink_frame_hz para os dois braços, e eles devem marcar o mesmo valor.

Há um interlock que vale nomear, porque é o tipo de coisa que transforma uma correção em um bug diferente. Um frame parcialmente montado é áudio que não foi enviado. A coalescência só é segura porque outra coisa garante que o buffer sempre tem um produtor — caso contrário, o final da última frase de quem liga fica parado em um frame pela metade exatamente no momento em que o servidor precisa dele para detectar que quem liga parou. Essa garantia é o keep-alive descrito no post sobre o Gemini Live, e as duas mudanças não são separáveis.

Por que usar um reamostrador com estado, afinal

A saída óbvia é chamar o soxr.resample() sem estado uma vez por chunk e nunca ver um retorno vazio. Não faça isso.

Uma chamada sem estado reinicia o filtro polifásico em cada fronteira de chunk, o que carimba uma descontinuidade no sinal cerca de trinta vezes por segundo em um chunk de 32 ms. É audível como um zumbido na taxa de chunks, e é o tipo de artefato que um ouvinte vai, com toda a razão, atribuir ao seu modelo. O reamostrador com estado carrega o estado do filtro entre chamadas precisamente para que isso não aconteça, e a emissão em rajadas é o custo visível dessa corretude.

Esse custo é honesto e limitado: é um atraso fixo de início de sessão, não jitter. Nós o medimos em vez de presumir — roundtrip_delay_ms() roda uma correlação entre entrada e saída através de um par de reamostradores de descida/subida e reporta o atraso, e o resultado é publicado em /health como atraso de transporte, mantido separado da latência algorítmica de 16 ms do próprio modelo. Dois atrasos diferentes com dois donos diferentes, reportados como dois números.

O checklist

Se você opera um caminho de áudio de navegador para servidor com uma conversão de taxa de amostragem nele:

  1. Conte o que você realmente envia. Segundos de áudio entregues sobre segundos de relógio de parede, por sessão, expostos a quem estiver depurando. Não bytes.
  2. Nunca deixe um continue cobrir mais do que a coisa que ele está guardando. Se um bloco faz dois trabalhos independentes, um retorno antecipado de um é uma falha silenciosa do outro. Separe-os.
  3. Sonde o cronograma de emissão do seu reamostrador no seu tamanho de bloco real, com as quatro linhas acima. Se você o alimenta com quanta de 128 amostras, espere não receber nada na maior parte do tempo; se você o alimenta com frames de servidor de 20 ms, nunca vai ver isso.
  4. Alimentá-lo, por chamada, com o equivalente em entrada a um bloco inteiro de saída faz o retorno vazio quase desaparecer — mas não construa sobre esse número, porque ele depende da versão e da razão de conversão, e a chamada de preparação continua vazia. Em vez disso, trate o caso vazio corretamente.
  5. Não compare dois braços com empacotamentos diferentes. Coalesça para um único tamanho de frame antes do transporte e reporte a taxa de frames por braço, para que uma divergência seja visível em vez de inferida.

E a regra geral, que é a única parte disto que não é sobre áudio: a instrumentação que teria pegado isso já existia e já tinha o valor certo dentro dela. Construir a métrica é a metade fácil. Fazer a métrica dizer o que ela implica, ao lado do sintoma que a pessoa está de fato olhando, é a metade que nós erramos.

A seguir
  • 24 de out. de 2026

    Alternativas à ai-coustics para agentes de voz e call centers (2026)

    Por que equipes olham além do ai-coustics Quail Voice Focus, e uma lista curta com fontes conforme listado em outubro de 2026: Krisp VIVA, AssemblyAI Voice Focus, isolamento de voz do LiveKit Cloud, NVIDIA Speaker Focus, Anecho e referências de código aberto. Comparamos a ai-coustics com o nosso próprio modelo em conversas ao vivo: empate com o faster-whisper, vantagem do v8_2 com o Gemini Live, menos barge-ins falsos para a ai-coustics, e a condição decide quem lidera.

  • 22 de out. de 2026

    Alternativas à Krisp para agentes de voz e call centers (2026)

    Por que equipes olham além do Krisp VIVA, e uma lista curta com fontes conforme listado em outubro de 2026: ai-coustics Quail Voice Focus, AssemblyAI Voice Focus, isolamento de voz do LiveKit Cloud, NVIDIA Speaker Focus, Anecho e referências de código aberto, com onde cada um roda, seu preço público e se o medimos. Não medimos a Krisp.

  • 20 de out. de 2026

    SDKs de isolamento de locutor comparados (2026): Krisp, ai-coustics, AssemblyAI, LiveKit e Anecho

    Uma comparação com fontes do isolamento de locutor para agentes de voz e call centers conforme listado em outubro de 2026: onde cada produto roda, latência declarada, taxas de amostragem, integração, preço público e licenciamento. Krisp VIVA e Anecho declaram 15 ms em CPU; a ai-coustics lista menos de 30 ms; AssemblyAI e LiveKit vendem isolamento dentro de um serviço em nuvem. Números de qualidade apenas do nosso próprio benchmark.