audio_stream_end=True não encerra um turno na Gemini Live API
Medido no Vertex contra o gemini-live-2.5-flash-native-audio com um único clipe de 5 segundos: áudio sozinho retorna 0 bytes, áudio mais audio_stream_end=True retorna 0 bytes, e áudio mais 1.5 segundos de silêncio ao final retorna 229,994 bytes. O servidor encerra um turno quando a própria detecção de atividade de voz (VAD) dele ouve silêncio, então o seu uplink precisa carregar uma pausa como amostras reais, e nunca como chunks ausentes. A própria documentação do Google diz duas coisas diferentes sobre esse campo, e os docs do Vertex nem o mencionam.
Se você está construindo um agente de voz sobre a Gemini Live API e ele conecta, transmite áudio, mostra uma sessão saudável e nunca responde, este post é as duas horas que você está prestes a gastar.
Primeiro a medição. Um clipe de 5 segundos do LibriSpeech, enviado de três maneiras para o mesmo endpoint do Vertex, mesmo modelo, mesma configuração de sessão. A única coisa que varia é como o cliente sinaliza que quem liga parou de falar:
| O que o cliente enviou | Bytes retornados |
|---|---|
| Apenas áudio, nenhum sinal de fim | 0 |
Áudio, depois send_realtime_input(audio_stream_end=True) | 0 |
| Áudio, depois 1.5 s de silêncio ao final como amostras reais | 229,994 |
229,994 bytes de mono de 16 bits a 24 kHz são cerca de 4.8 segundos de resposta. A linha do meio é a que merece ser encarada. O campo se chama audio_stream_end, você o define como True, o socket permanece aberto, nenhum erro volta — e o modelo não responde. Nunca. Nós esperamos.
O servidor encerra um turno quando o próprio detector de atividade de voz dele ouve silêncio. Esse é o único mecanismo. Então o silêncio precisa chegar até ele, como amostras, em taxa de tempo real. Um cliente que simplesmente para de enviar não é um cliente que terminou de falar; é um cliente indistinguível de um cliente morto.
O que a documentação diz, que são duas coisas diferentes
Fomos procurar isso nos docs depois de medir, e os docs estão genuinamente divididos contra si mesmos. As três citações abaixo vêm da própria referência e do próprio guia publicados pelo Google.
A referência normativa do campo, em BidiGenerateContentRealtimeInput, descreve um estado do stream, não um turno:
"Indica que o stream de áudio terminou, por exemplo porque o microfone foi desligado. Isso só deve ser enviado quando a detecção automática de atividade está habilitada (o que é o default). O cliente pode reabrir o stream enviando uma mensagem de áudio."
O guia da Live API, na sua seção de VAD automático, diz a mesma coisa em termos operacionais — flush, não fim:
"Quando o stream de áudio fica pausado por mais de um segundo (por exemplo, porque o usuário desligou o microfone), um evento
audioStreamEnddeve ser enviado para descarregar qualquer áudio em cache. O cliente pode retomar o envio de dados de áudio a qualquer momento."
E o mesmo guia, na sua seção de VAD híbrido, diz algo bem diferente:
"O servidor trata o sinal
audio_stream_endcomo um comando de finalização imediata, contornando o atraso padrão de detecção de silêncio do lado do servidor e retornando a transcrição e a resposta do modelo com latência mínima."
As duas primeiras não podem ser, junto com a terceira, o comportamento geral de um único campo, e a terceira é a única frase em qualquer lugar que diz que ele produz uma resposta. Enquanto isso, o próprio tipo do SDK para a mensagem que o contém declara o contrato real do modelo:
"O fim do turno não é especificado explicitamente; em vez disso, é derivado da atividade do usuário (por exemplo, fim de fala)."
Há mais uma coisa que achamos ser a explicação real de por que isso é tão fácil de errar. A documentação do Vertex não menciona audioStreamEnd em lugar nenhum. Não conseguimos encontrar o campo em uma única página da Live API do Vertex — e o Vertex é onde vive o gemini-live-2.5-flash-native-audio, em disponibilidade geral, como o modelo recomendado para agentes de voz de baixa latência. Então, na plataforma em que medimos, o campo não está documentado como fazendo coisa alguma, e o parágrafo que diz que ele finaliza um turno está no guia do outro produto.
Não estamos afirmando que os docs do Google estão errados. Estamos afirmando que no Vertex, com o VAD automático nos seus defaults, neste modelo, nesta data, o sinal não produziu nada e 1.5 segundos de silêncio produziram uma resposta completa — e que, se você ler apenas a frase sobre finalização imediata, vai construir algo que trava.
Não somos os primeiros a bater nisso. A issue #1328 do python-genai reporta exatamente isso, passo a passo: envie audioStreamEnd quando o microfone desliga e então "observe que o modelo não encerra o turno do usuário nem reage, mesmo depois de esperar mais de um minuto." Essa issue foi fechada por um bot de inatividade em janeiro, sem correção e sem confirmação, então trate-a como corroboração de outro desenvolvedor, não como uma declaração oficial — e é precisamente por isso que estamos publicando uma medição em vez de um link.
O invariante que conserta isso
Escrevemos isto no serviço como um contrato de uma frase, porque todo o resto do arquivo está rio abaixo dele:
Uma vez que a chamada está ao vivo, um stream contínuo de PCM16 a 16 kHz chega à API em taxa de tempo real, e uma pausa é carregada como amostras de silêncio reais — nunca como chunks ausentes.
Isso não é uma preferência de estilo. É o protocolo, reescrito como algo que você pode testar. Cada lugar do seu código que pode se recusar a encaminhar um chunk é um lugar que pode travar a chamada para sempre com o socket ainda aberto, e os dois lugares que nós tínhamos eram bugs.
A segunda consequência é a que pega clientes de navegador, e ela não é óbvia: o seu cliente vai parar de enviar áudio durante uma pausa, queira você ou não.
- O Chrome entrega a um
AudioWorkletProcessorum array de entrada vazio sempre que o barramento rio acima é marcado como silencioso. Não um buffer de zeros — um array vazio. Código ingênuo encaminha nada. - Uma track mutada, ou uma troca de dispositivo, interrompe os callbacks por completo.
Então "o usuário ficou quieto" e "o microfone parou de produzir callbacks" chegam ao seu servidor como o mesmo evento, e a única coisa de que o VAD do lado do servidor precisa para responder é exatamente a coisa que o navegador decidiu não te dar.
A nossa correção é um keep-alive que sintetiza o silêncio que o cliente deveria ter enviado e o empurra pelo caminho idêntico — os mesmos reamostradores, o mesmo modelo — para que o pipeline permaneça coerente e o stream carregue amostras de silêncio verdadeiras, em vez de um buraco. Dois detalhes importam mais do que a ideia:
- Acione-o pela chegada de dados do cliente, nunca pelo seu próprio uplink. O nosso uplink é legitimamente bursty, porque o reamostrador de stream emite em levas de aproximadamente 95 ms. Um keep-alive que observasse a própria saída leria cada intervalo entre rajadas como um cliente travado e injetaria silêncio no meio de fala ao vivo. O nosso dispara depois de 150 ms sem nada vindo do navegador, o que dá cerca de dezenove quanta de 128 amostras perdidos: um travamento, não jitter.
- Limite o preenchimento por tick. Um travamento longo é reparado de forma constante, em vez de como um único blob enorme e atrasado que chega fora de proporção com a conversa.
E reporte isso. A quantidade de silêncio sintetizado por chamada é um número no nosso payload de saúde, não um reparo escondido, porque uma sessão que é 40% silêncio sintético está te dizendo algo sobre o cliente que você quer saber.
O interlock que ninguém espera
A coalescência veio em seguida, por uma razão diferente — dois braços de um A/B estavam sendo empacotados de formas diferentes, 10.6 contra 125.0 frames por segundo — e os dois braços agora se montam em frames fixos de 100 ms antes de serem enviados. A orientação do próprio Google é "envie chunks pequenos (entre 20 ms e 40 ms) para minimizar a latência", e 125 frames por segundo está muito fora disso na outra direção.
Mas um frame parcialmente montado é áudio que não foi enviado. Então a coalescência só é segura por causa do keep-alive: sem um produtor atrás do buffer, o fim 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. O keep-alive garante que o buffer sempre tem um produtor, então um frame parcial é sempre descarregado pelo silêncio que vem depois dele.
Duas mudanças de aparência independente que não são separáveis. Se você adotar a coalescência de frames sem o keep-alive, construiu uma versão mais sutil do mesmo travamento.
Torne as configurações de detecção de turnos explícitas
A última coisa que mudamos não foi tanto código quanto visibilidade. Este serviço deixava cada parâmetro de VAD implícito, e foi assim que "o agente nunca responde" conseguiu parecer um mistério em vez de uma configuração.
A superfície documentada, para referência:
| Campo | O que os docs dizem |
|---|---|
automaticActivityDetection.disabled | O default é false — o VAD do lado do servidor está ligado |
silenceDurationMs | "O default interno do servidor é de aproximadamente 800ms"; o guia recomenda 500–800 ms |
prefixPaddingMs | Nenhum default documentado que tenhamos conseguido encontrar |
startOfSpeechSensitivity / endOfSpeechSensitivity | O SDK declara defaults diferentes por plataforma — LOW no branch enterprise, HIGH no branch da Gemini API |
activityStart / activityEnd | "só podem ser enviados se a detecção automática de atividade (isto é, do lado do servidor) estiver desabilitada" |
Duas armadilhas nessa tabela. Primeiro, o exemplo de configuração do próprio guia usa prefix_padding_ms: 20 e silence_duration_ms: 100 — valores ilustrativos, e 100 ms fica dentro da faixa que a mesma página chama de "baixa demais", em que "o sistema encerra turnos de fala durante pausas naturais, dividindo um único enunciado em vários fragmentos pequenos de áudio." Não copie o exemplo para produção.
Segundo, detecção manual de atividade é um mundo diferente, não um complemento. Com disabled: true, você mesmo envia activityStart e activityEnd, e — segundo o guia — "um audioStreamEnd não é enviado nessa configuração." Se o VAD é seu, o limite do turno é seu, e o problema do silêncio desaparece; você simplesmente comprou a metade mais difícil do problema, que é detectar turnos em uma linha ruidosa.
A nossa própria escolha é expor os cinco como variáveis de ambiente e parâmetros de query por chamada, e deixá-los não definidos por padrão, para que o endpoint de status reporte "vertex default" em vez de um número que o serviço inventou. As duas configurações que medimos funcionando foram medidas nos defaults do Vertex, e movê-las em silêncio mudaria um resultado que agora está provado.
O resto do contrato de áudio, já que você vai precisar dele de qualquer jeito
Da documentação, e vale a pena acertar tudo de uma vez:
- Entrada: PCM bruto de 16 bits, little-endian. "O áudio de entrada é nativamente 16kHz, mas a Live API reamostra se necessário, então qualquer taxa de amostragem pode ser enviada." Declare a taxa no tipo MIME de cada blob, por exemplo
audio/pcm;rate=16000. A página de troubleshooting do Vertex acrescenta a contagem de canais: um único canal mono. - Saída: PCM bruto de 16 bits, little-endian, sempre 24 kHz. Note a assimetria — você envia 16 kHz, você recebe 24 kHz. Qualquer mixagem ou gravação rio abaixo tem que reamostrar um dos dois.
- Chunking: 20 a 40 ms; não acumule em buffer por volta de um segundo antes de enviar.
- Duração da sessão: sem compressão de janela de contexto, sessões só de áudio estão documentadas em 15 minutos e as de áudio e vídeo em 2. Separadamente, "o tempo de vida de uma conexão é limitado a cerca de 10 minutos devido a restrições de conexão WebSocket", com um aviso
goAwayenviado 60 segundos antes do fim. São dois limites diferentes, e o mais curto é o da conexão, então reconectar é parte normal de uma chamada longa, não um caminho de erro. Nós o tratamos assim: fila de microfone limitada e com descarte do mais antigo através da lacuna, para que uma reconexão não "se recupere" reproduzindo um backlog crescente e respondendo a perguntas de dez segundos atrás.
Uma ressalva sobre onde esses docs vivem: as páginas da Live API do Vertex foram rebatizadas e movidas, e o caminho antigo cloud.google.com/vertex-ai/generative-ai/docs/live-api agora redireciona. Se um link nas suas anotações está morto, é por isso.
Ressalvas
- Isto é n = 1 no estímulo. Um clipe de 5 segundos, três condições, um modelo (
gemini-live-2.5-flash-native-audio), uma região, uma data. É uma demonstração limpa e reproduzível de um comportamento, não um levantamento. Não reportaríamos a contagem de bytes como se fosse um benchmark. - O resultado 0 / 0 / 229,994 é uma demonstração de que o silêncio funciona e a flag não funcionou, naquela configuração. Ele não estabelece que o
audio_stream_endnunca faz nada, em nenhum modelo, em nenhuma configuração. Se você conseguir mostrá-lo finalizando um turno no Vertex, queremos ver a configuração. - O comportamento foi medido no Vertex. O branch da Gemini API documenta defaults de sensibilidade diferentes, e o parágrafo de VAD híbrido que descreve a finalização está no guia desse branch.
- A issue da comunidade é corroboração, não confirmação. Ela foi fechada por automação, sem resolução.
- Não estamos medindo qualidade aqui. Nada neste post diz coisa alguma sobre quão bem o modelo ouviu quem ligou. Esse é um experimento separado, e o caminho de áudio na frente dele é aquilo em que realmente trabalhamos.
A versão curta
- O próprio VAD do servidor é a única coisa que encerra um turno. Projete para isso e para nada mais.
- Carregue pausas como amostras. Se o seu cliente fica quieto, sintetize o silêncio e envie-o pelo mesmo caminho do áudio real.
- Nunca acione um keep-alive pelo seu próprio uplink — um caminho de transmissão bursty vai parecer um cliente travado e retalhar fala ao vivo.
- Coalesça para um frame fixo, mas só quando algo garantir que o buffer tem um produtor. As duas mudanças são uma mudança só.
- Publique uma razão de áudio entregue por sessão. Segundos de uplink sobre segundos de relógio de parede tem um valor correto conhecido perto de 1.0, o que torna um valor errado legível. A nossa marcou 0.08 por dias enquanto a UI alegremente reportava uma sessão ao vivo, e isso é um post próprio.
- Não copie os valores de exemplo do VAD. Deixe-os não definidos, reporte o que está em vigor e mude-os deliberadamente.