Como criar um assistente de IA de áudio e vídeo com a Gemini Live API

A Gemini Live API aceita entrada e saída de áudio nativamente. Sua arquitetura de ponta a ponta reduz a latência para menos de 500 ms, sem precisar usar ASR/TTS como intermediários. A seguir, mostro como usar essa API para criar um assistente de IA capaz de manter uma conversa realmente em tempo real.
O que é a Gemini Multimodal Live API?
Primeiro, vale esclarecer a diferença. A API tradicional do Gemini funciona de forma simples: você envia um texto e recebe outro texto. Para adicionar interação por voz, porém, é necessário integrar ASR (reconhecimento de fala) e TTS (síntese de voz). Cada etapa intermediária aumenta a latência.
A Gemini Multimodal Live API segue outra abordagem: ela oferece entrada e saída de áudio nativas. O áudio capturado pelo microfone pode ser enviado diretamente, e a resposta também chega como um fluxo de áudio. Não é necessário converter formatos no meio do caminho. Essa arquitetura de ponta a ponta reduz a latência para menos de 500 ms.
Testei esse recurso em um projeto de casa inteligente. Quando o usuário dizia “diminua um pouco a luz da sala”, a IA respondia praticamente assim que ele terminava de falar. A conversa ficava tão fluida que era fácil esquecer que havia um programa do outro lado.
O modelo compatível no momento é o gemini-2.0-flash-native-audio-preview. Preste atenção nessa versão: o Google ainda está fazendo atualizações rápidas, então convém acompanhar as mudanças periodicamente.
Arquitetura e escolha das tecnologias
Agora podemos montar o sistema. Recomendo uma arquitetura separada entre frontend e backend por um motivo simples: a chave da API não pode ficar exposta no frontend.
O fluxo de dados fica assim:
[Navegador] --WebSocket--> [Proxy backend em Python] --WebSocket--> [Gemini Live API]
| | |
Captura do microfone Encaminhamento + lógica Processamento de IA
Reprodução no alto-falante VAD/controle de interrupção Geração de áudio
Talvez você se pergunte por que o navegador não pode se conectar diretamente ao Gemini. Tecnicamente, pode. O problema é que a chave da API teria de ficar no JavaScript, e qualquer pessoa poderia abri-la nas ferramentas de desenvolvedor. Já cometi esse erro uma vez; no dia seguinte, a conta disparou. Foi uma lição cara.
Por isso, escolhi a seguinte stack:
| Camada | Tecnologia | Finalidade |
|---|---|---|
| Frontend | JavaScript nativo + Web Audio API | Captura e reprodução de áudio, além de processamento em tempo real com AudioWorklet |
| Backend | Python 3.9+ + biblioteca websockets | Proxy WebSocket, detecção VAD e gerenciamento de sessão |
| Protocolo | WebSocket + JSON | Comunicação bidirecional com o Gemini |
O AudioWorklet da Web Audio API é especialmente útil porque processa o áudio em uma thread separada, sem bloquear a thread principal. Mais adiante, veremos a implementação.
Conexão WebSocket e gerenciamento de sessão
Vamos ao código. O primeiro problema a resolver é a conexão com o serviço do Gemini.
O endpoint WebSocket da Live API tem este formato:
wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=YOUR_API_KEY
Observe o v1alpha: ele indica que a interface ainda está em versão preview e pode mudar. Considere esse risco antes de usá-la em produção.
Depois de estabelecer a conexão, a primeira ação é enviar uma mensagem Setup para informar ao Gemini como a conversa deve funcionar:
import asyncio
import json
import websockets
GEMINI_API_KEY = "your-api-key-here"
GEMINI_WS_URL = (
f"wss://generativelanguage.googleapis.com/ws/"
f"google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent"
f"?key={GEMINI_API_KEY}"
)
CONFIG = {
"setup": {
"model": "models/gemini-2.0-flash-native-audio-preview",
"generation_config": {
"response_modalities": ["AUDIO"],
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Charon" # Opções: Charon, Aoede etc.
}
}
}
},
"system_instruction": {
"parts": [{"text": "Você é um assistente de IA útil. Responda de forma breve e natural."}]
}
}
}
async def connect():
async with websockets.connect(GEMINI_WS_URL) as ws:
# Envia a configuração de setup
await ws.send(json.dumps(CONFIG))
# Aguarda a resposta setup complete
response = await ws.recv()
data = json.loads(response)
if "setupComplete" in data:
print("✅ Conexão estabelecida. A conversa pode começar")
return ws
else:
raise Exception(f"Falha no setup: {data}")
Alguns parâmetros merecem atenção:
response_modalities: use["AUDIO"]quando quiser receber apenas respostas por voz. Para receber texto também, altere para["AUDIO", "TEXT"].voice_name: o Gemini oferece algumas vozes predefinidas. Eu prefiroCharon, que tem um tom mais sóbrio.
Para reconectar depois de uma queda, recomendo o backoff exponencial. Não faça várias tentativas em sequência logo de início, pois isso pode sobrecarregar o serviço:
async def connect_with_retry(max_retries=5):
for attempt in range(max_retries):
try:
return await connect()
except Exception as e:
wait_time = min(2 ** attempt, 30) # Aguarda no máximo 30 segundos
print(f"Falha na conexão ({e}). Nova tentativa em {wait_time} segundos...")
await asyncio.sleep(wait_time)
raise Exception("Não foi possível conectar após várias tentativas")
Captura e transmissão de áudio PCM de 16 kHz
Com a conexão pronta, precisamos capturar o áudio e enviá-lo.
Primeiro, por que escolher 16 kHz? A faixa de frequência da voz humana costuma ficar entre 85 Hz e 255 Hz, com frequências mais baixas para vozes masculinas e mais altas para vozes femininas. Pelo teorema de amostragem de Nyquist, 8 kHz seriam suficientes em teoria. Na prática, 16 kHz preservam mais detalhes e oferecem um bom equilíbrio entre qualidade e volume de dados. Essa também é a taxa recomendada pelo Gemini.
O código de captura no frontend fica assim:
class AudioRecorder {
constructor() {
this.sampleRate = 16000;
this.bufferSize = 1024;
this.audioContext = null;
this.workletNode = null;
this.stream = null;
this.onAudioData = null; // Função de callback
}
async start() {
// Solicita permissão para usar o microfone
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
// Cria o AudioContext com uma taxa de amostragem definida
this.audioContext = new AudioContext({
sampleRate: 16000
});
// Carrega o processador AudioWorklet
await this.audioContext.audioWorklet.addModule('pcm-processor.js');
const source = this.audioContext.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor');
// Processa os dados de áudio
this.workletNode.port.onmessage = (event) => {
const float32Data = event.data;
// Converte para PCM Int16
const int16Data = this.float32ToInt16(float32Data);
// Codifica em Base64 antes de enviar
const base64Data = btoa(String.fromCharCode(...new Uint8Array(int16Data.buffer)));
if (this.onAudioData) {
this.onAudioData(base64Data);
}
};
source.connect(this.workletNode);
console.log('🎤 Captura de áudio iniciada');
}
float32ToInt16(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
// Float32 (-1.0 ~ 1.0) -> Int16 (-32768 ~ 32767)
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Array;
}
stop() {
if (this.workletNode) {
this.workletNode.disconnect();
}
if (this.audioContext) {
this.audioContext.close();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
console.log('🛑 Captura de áudio encerrada');
}
}
O AudioWorklet precisa de um arquivo separado chamado pcm-processor.js:
// pcm-processor.js
class PCMProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input && input[0]) {
// Envia para a thread principal
this.port.postMessage(input[0].slice());
}
return true; // Mantém o processador ativo
}
}
registerProcessor('pcm-processor', PCMProcessor);
Depois de receber os dados, o backend precisa encaminhá-los ao Gemini:
async def send_audio(ws, base64_pcm_data):
"""Envia os dados de áudio para o Gemini"""
message = {
"realtime_input": {
"media_chunks": [{
"mime_type": "audio/pcm;rate=16000",
"data": base64_pcm_data
}]
}
}
await ws.send(json.dumps(message))
Há uma armadilha aqui: alguns navegadores ignoram o sampleRate especificado em getUserMedia e podem retornar áudio em 44,1 kHz ou 48 kHz. Por segurança, faça uma nova amostragem no AudioContext ou use uma biblioteca como audiobuffer-to-wav.
Implementação da detecção de atividade de voz com VAD
Agora surge outro problema. Se todo o áudio for enviado ao Gemini sem nenhum filtro, até os períodos de silêncio consumirão dados, largura de banda e dinheiro. É aí que entra o VAD (Voice Activity Detection, ou detecção de atividade de voz).
Sua função é simples: determinar se há alguém falando em um trecho de áudio. Quando há voz, o trecho é enviado; quando não há, o sistema espera.
Recomendo o WebRTC VAD, projeto de código aberto do Google. Ele é leve, rápido e produz bons resultados. O Python oferece o pacote webrtcvad:
import webrtcvad
import collections
import numpy as np
class VADProcessor:
def __init__(self, aggressiveness=2, frame_duration_ms=20):
"""
aggressiveness: 0-3; quanto maior, mais rigorosa é a detecção
(maior a chance de classificar voz como silêncio)
frame_duration_ms: 10, 20 ou 30
"""
self.vad = webrtcvad.Vad(aggressiveness)
self.frame_duration_ms = frame_duration_ms
self.sample_rate = 16000
# Buffer circular usado para suavização
self.ring_buffer = collections.deque(maxlen=30) # 600 ms
self.triggered = False
def process_frame(self, pcm_bytes):
"""
Processa um quadro de áudio e informa se ele deve ser enviado
"""
is_speech = self.vad.is_speech(pcm_bytes, self.sample_rate)
if not self.triggered:
# Estado não acionado: acumula quadros com voz
self.ring_buffer.append((pcm_bytes, is_speech))
num_voiced = sum(1 for _, speech in self.ring_buffer if speech)
# Aciona se 90% dos quadros contiverem voz
if num_voiced > 0.9 * self.ring_buffer.maxlen:
self.triggered = True
# Envia também os dados acumulados no buffer
return b''.join([f for f, _ in self.ring_buffer])
return None
else:
# Estado acionado
if is_speech:
self.ring_buffer.append((pcm_bytes, True))
return pcm_bytes
else:
self.ring_buffer.append((pcm_bytes, False))
num_unvoiced = sum(1 for _, speech in self.ring_buffer if not speech)
# Encerra o acionamento se 90% dos quadros forem silêncio
if num_unvoiced > 0.9 * self.ring_buffer.maxlen:
self.triggered = False
self.ring_buffer.clear()
return pcm_bytes
O uso fica aproximadamente assim:
vad = VADProcessor(aggressiveness=2)
async def handle_client_audio(websocket, gemini_ws):
async for message in websocket:
data = json.loads(message)
if 'audio' in data:
pcm_bytes = base64.b64decode(data['audio'])
# Detecção VAD
result = vad.process_frame(pcm_bytes)
if result:
# Há alguém falando; encaminha para o Gemini
await send_audio(gemini_ws, base64.b64encode(result).decode())
O parâmetro aggressiveness exige algum ajuste. Se o valor for muito baixo, qualquer ruído de fundo poderá ser tratado como voz. Se for muito alto, falas em volume baixo poderão passar despercebidas. Minha prática é começar com 2 e ajustar de acordo com o ambiente real.
Caso o ambiente de implantação não permita instalar o webrtcvad, uma detecção simples por limiar de energia pode servir como alternativa:
// Alternativa no frontend: detecção simples com energia RMS
function detectVoiceActivity(audioData, threshold = 0.015) {
const sum = audioData.reduce((acc, val) => acc + val * val, 0);
const rms = Math.sqrt(sum / audioData.length);
return rms > threshold;
}
Como implementar interrupção natural por fala (barge-in)
Alguns assistentes de voz não permitem interrupções: depois que começam uma resposta longa, só resta esperar. O recurso barge-in resolve esse problema. Enquanto a IA fala, o usuário pode começar a falar e interromper a saída imediatamente, fazendo o sistema voltar a escutá-lo.
A boa notícia é que a Gemini Live API oferece suporte nativo ao recurso. Basta ativar a detecção automática de atividade na configuração:
CONFIG = {
"setup": {
"model": "models/gemini-2.0-flash-native-audio-preview",
"generation_config": {
"response_modalities": ["AUDIO"],
},
"realtime_input_config": {
"automatic_activity_detection": {
"disabled": False,
"start_of_speech_sensitivity": "START_SENSITIVITY_HIGH",
"end_of_speech_sensitivity": "END_SENSITIVITY_LOW"
}
}
}
}
A configuração de sensitivity merece atenção:
- Definir
start_of_speech_sensitivitycomoHIGHtorna a IA mais sensível ao início da fala do usuário, facilitando a interrupção. - Definir
end_of_speech_sensitivitycomoLOWfaz a IA esperar um pouco mais para confirmar que o usuário realmente terminou, evitando falsos positivos.
No cliente, basta escutar o evento interrupted e parar a reprodução imediatamente:
class GeminiClient {
constructor() {
this.audioQueue = [];
this.isPlaying = false;
this.currentSource = null;
}
async handleMessage(event) {
const message = JSON.parse(event.data);
// Trata o sinal de interrupção
if (message.server_content?.interrupted) {
console.log('⚡ Interrupção do usuário: reprodução encerrada');
this.stopPlayback();
return;
}
// Trata o áudio retornado pela IA
if (message.server_content?.model_turn) {
const parts = message.server_content.model_turn.parts;
for (const part of parts) {
if (part.inline_data?.mime_type.startsWith('audio/')) {
const audioData = base64ToArrayBuffer(part.inline_data.data);
this.queueAudio(audioData);
}
}
}
}
stopPlayback() {
// Esvazia a fila de reprodução
this.audioQueue = [];
this.isPlaying = false;
// Interrompe o áudio em reprodução
if (this.currentSource) {
try {
this.currentSource.stop();
} catch (e) {
// O áudio pode já ter terminado
}
this.currentSource = null;
}
}
async queueAudio(audioData) {
this.audioQueue.push(audioData);
if (!this.isPlaying) {
this.playNext();
}
}
async playNext() {
if (this.audioQueue.length === 0) {
this.isPlaying = false;
return;
}
this.isPlaying = true;
const audioData = this.audioQueue.shift();
// Decodifica e reproduz
const audioBuffer = await this.audioContext.decodeAudioData(audioData.slice());
this.currentSource = this.audioContext.createBufferSource();
this.currentSource.buffer = audioBuffer;
this.currentSource.connect(this.audioContext.destination);
this.currentSource.onended = () => {
this.playNext();
};
this.currentSource.start();
}
}
Há um detalhe importante: o método stop() pode lançar uma exceção se o áudio já tiver terminado naturalmente. Por isso, adicionei um bloco try-catch para evitar erros no console.
Otimização de desempenho e controle de latência
Por fim, vamos reduzir ao máximo a latência do sistema.
Primeiro, precisamos entender de onde ela vem:
- Transmissão pela rede: o tempo de ida e volta dos pacotes entre o navegador, o servidor e o Gemini.
- Codificação e decodificação de áudio: o tempo gasto para comprimir e descomprimir PCM. Como o próprio PCM não tem perdas, esse custo é pequeno.
- Acúmulo no buffer: a profundidade do buffer usado para manter a reprodução contínua.
Minha experiência com esses pontos levou às seguintes otimizações.
Reduza a profundidade do buffer
Não use um buffer de reprodução maior do que o necessário. Em geral, trabalho com 100 a 200 ms:
// Configura um buffer menor
const audioContext = new AudioContext({
sampleRate: 16000,
latencyHint: 'interactive' // Modo de baixa latência
});
Taxa de bits adaptativa — na prática, buffer adaptativo
Se houver muita oscilação na rede, aumente um pouco o buffer. Quando a conexão estiver estável, reduza-o.
Cancelamento de eco local
Se o usuário estiver usando alto-falantes em vez de fones de ouvido, o microfone poderá captar a voz da IA e criar um ciclo de áudio. O getUserMedia já oferece cancelamento de eco:
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
Monitore as métricas
Para verificar se as otimizações funcionaram, registre os tempos com a Performance API:
// Registra métricas de latência
class LatencyMonitor {
constructor() {
this.metrics = [];
}
recordSendTime() {
this.lastSendTime = performance.now();
}
recordReceiveTime() {
const latency = performance.now() - this.lastSendTime;
this.metrics.push(latency);
// Mantém os 100 registros mais recentes
if (this.metrics.length > 100) {
this.metrics.shift();
}
// Calcula a latência média
const avg = this.metrics.reduce((a, b) => a + b, 0) / this.metrics.length;
console.log(`📊 Latência média: ${avg.toFixed(2)} ms`);
}
}
Em meu ambiente de teste, obtive aproximadamente estes resultados:
- Latência de ponta a ponta: 300 a 500 ms, dependendo das condições da rede.
- Tempo de resposta do primeiro pacote: 200 a 400 ms.
- Latência em conversa contínua: 150 a 300 ms.
Se a sua latência for muito maior, confira esta lista:
- A conexão WebSocket está usando HTTPS/WSS? HTTP gera custo adicional.
- Onde o servidor está implantado? Quanto mais perto dos data centers do Google, melhor.
- A detecção VAD está acrescentando latência demais? Tente reduzir a duração dos quadros.
- O buffer de reprodução do frontend está grande demais?
Outra armadilha envolve o contexto de áudio: o Chrome só permite reproduzir som depois de uma interação do usuário. Portanto, adicione à página um botão “Iniciar conversa” em vez de tentar reproduzir áudio automaticamente.
Conclusão
Com isso, concluímos um fluxo completo de desenvolvimento com a Gemini Live API: conceito inicial, arquitetura, conexão WebSocket, captura de áudio, detecção VAD, interrupção por fala e otimização de desempenho. Em cada etapa, procurei destacar também as dificuldades que encontrei na prática.
A interação por voz em tempo real ainda evolui rapidamente, e a própria Gemini Live API continua recebendo atualizações. Mesmo assim, essa arquitetura básica tem se mostrado confiável: meu projeto já a utiliza há vários meses com boa estabilidade.
Se você encontrar algum problema durante a implementação, compartilhe a experiência. Discutir soluções costuma ser mais rápido do que investigar tudo sozinho.
FAQ
Por que é necessário separar frontend e backend?
Por que usar uma taxa de amostragem de 16 kHz?
Como ajustar o parâmetro aggressiveness do VAD?
A interrupção por fala exige desenvolvimento adicional?
12 min de leitura · Publicado em: 27 fev 2026 · Atualizado em: 4 set 2026
Domínio do Google AI
Você está lendo o primeiro post desta série. Continue para o próximo ou abra o hub da série para ver toda a trilha.
Anterior
Você está no início desta série.
Próximo
Adeus ao banco de dados vetorial? Teste completo do contexto de 2 milhões de tokens e do Context Caching do Gemini
Uma avaliação aprofundada do contexto longo e do Context Caching do Gemini em comparação com o RAG tradicional para ajudar você a escolher a arquitetura certa
Parte 2 de 7



Comentários
Entre com GitHub para comentar