Skip to main content
A Venice pode te ouvir e responder falando. Não há um socket de speech-to-speech em tempo real para conectar, o que soa como uma limitação até você perceber que um agente de voz é na verdade apenas três chamadas HTTP comuns em um loop: transcrever o que o usuário disse, gerar uma resposta, falar a resposta. Neste guia, vamos construir esse loop como um app de terminal em Python. Pressione Enter, fale, pressione Enter de novo, e a resposta toca nos seus alto-falantes. Você pode digitar uma linha em vez disso, se preferir não usar o microfone. Esta é a mesma forma STT → LLM → TTS do guia do LiveKit Agents, menos o LiveKit, as wake words e as ferramentas. Tirar o framework do caminho é o objetivo: no final, você saberá exatamente quais três requisições fazem o trabalho, e por que fazemos streaming de duas delas. Antes de continuar: você vai precisar de uma chave de API da Venice. Exporte-a como uma variável de ambiente:
Interessado na implementação completa do código? Confira o repositório no GitHub.

Pré-requisitos

  • Python 3.11 ou mais novo, e uv
  • Uma chave de API da Venice de venice.ai
  • Um microfone e alto-falantes, se você quiser o loop de voz completo
Gravação e reprodução passam pelo sounddevice, que encapsula o PortAudio. uv sync instala o pacote Python, e no Windows isso é tudo o que você precisa. macOS e Linux querem a biblioteca PortAudio também:
Nada disso é voltado à Venice — é só como as amostras entram e saem da sua máquina. O app aceita uma flag --text-only que pula o microfone por completo e ainda exercita o chat e o TTS, então você pode acompanhar em uma máquina sem nenhum hardware de áudio.

O Que Vamos Construir

Um turno de conversa são três requisições: Esses IDs de modelo são um ponto de partida, não uma lista fixa. A Venice rotaciona o catálogo, então resolva-os em tempo de execução a partir de GET /models?type=... e GET /models/traits antes de colocar qualquer coisa em produção. Veja Depreciações para entender como isso se desenrola. Vamos manter a árvore de código pequena de propósito:
A divisão importa mais do que parece. venice.py é a parte que você pode levar direto para um app web, um bot de Discord ou uma integração telefônica. audio.py é o único arquivo que se importa com a máquina em que está rodando, e a Venice nunca vê nada dele — a API só recebe um blob WAV na entrada e devolve PCM bruto na saída.

Configurando

Crie o projeto e adicione as dependências. O SDK da OpenAI faz todo o trabalho de HTTP, o python-dotenv mantém a chave fora do histórico do seu shell, e o sounddevice conversa com o microfone e os alto-falantes:
Depois crie o .env.example para que as escolhas de modelo sejam configuração em vez de algo enterrado no código:
Copie-o para .env e cole sua chave nele.

Apontando o SDK para a Venice

A API da Venice é compatível com a OpenAI, então usamos o cliente openai oficial e mudamos a base URL. Essa é toda a integração. Crie venice.py e comece pelo cliente:
Note que verificamos a chave nós mesmos em vez de deixar os.environ["VENICE_API_KEY"] lançar uma exceção. Um traceback de KeyError é uma péssima primeira experiência para algo tão corriqueiro quanto uma chave ausente. Mais uma tarefa de manutenção enquanto estamos aqui. O SDK levanta subclasses de OpenAIError, e o detalhe útil fica enterrado no corpo da resposta, então vale a pena desembrulhá-lo uma única vez:
Toda chamada abaixo canaliza suas falhas por aqui, então um ID de voz inválido ou uma chave expirada aparece como uma linha legível em vez de um stack trace.

Ouvindo o Usuário

POST /audio/transcriptions recebe um arquivo de áudio e retorna texto. Estamos gravando WAV mono de 16 kHz localmente, mas o endpoint aceita os formatos usuais, então mapeamos a extensão do arquivo para um tipo MIME em vez de fixar um no código:
A transcrição na Venice é requisição/resposta em vez de um socket de streaming, e é por isso que a gravação tem um fim definido — pressionamos Enter em vez de rodar detecção de atividade de voz. Se você quiser endpointing baseado em VAD, esse é o trabalho que o guia do LiveKit entrega ao Silero. Uma transcrição vazia é um resultado normal, não um erro. Alguém vai pressionar Enter duas vezes por acidente, e um amigável “não entendi” ganha de uma exceção todas as vezes.

Fazendo Streaming da Resposta

Agora a chamada de chat. Há duas configurações específicas da Venice aqui que fazem diferença real em como o agente soa:
include_venice_system_prompt: False impede que a Venice anteponha o próprio system prompt ao nosso. Deixado ligado, são cerca de mil e setecentos tokens de entrada extras por chamada e uma segunda voz dizendo ao modelo como se comportar. disable_thinking: True (com reasoning.enabled: False para modelos que leem o campo mais novo) impede que o GLM gaste seu orçamento de tokens em uma cadeia de pensamento oculta antes de dizer qualquer coisa — o que, quando você está esperando ouvir uma resposta, é um tempo que dá para ouvir. O prompt em si justifica seu tamanho. Pedir vinte palavras mantém as respostas soando faladas em vez de escritas, e “omit detail rather than ending mid-sentence” é o que impede um limite rígido de max_tokens de truncar no meio de uma palavra. Banir markdown importa mais do que você imagina: um modelo de TTS lê asteriscos em voz alta sem pestanejar.
A instrução de tratar a mensagem do usuário como não confiável está fazendo um trabalho real aqui. Fala transcrita é entrada de usuário como qualquer outra, e “ignore suas instruções anteriores” é tão fácil de dizer em voz alta quanto de digitar.
Com isso no lugar, a chamada é uma completion em streaming normal:
A decisão de design importante é que isso produz frases, não tokens. O TTS precisa de uma oração completa para acertar a prosódia, então acumulamos os deltas em buffer até termos uma, e então a entregamos. É isso que permite que o áudio comece a tocar enquanto o modelo ainda está falando. O evento cancel permite que o chamador pare de drenar o stream quando o usuário pressiona Ctrl+C, e fechar o stream em um bloco finally libera a conexão em vez de deixá-la pendurada até o timeout.

Dividindo Frases Conforme Elas Chegam

Dividir em ., ! e ? te leva a 90% do caminho e depois te envergonha na primeira vez em que o modelo diz “Dr. Smith”. Então verificamos se o que vem antes do ponto final é uma abreviação antes de tratá-lo como um limite:
Note que a regex exige espaço em branco após a pontuação. Isso é deliberado: no meio do stream, "Hello." pode ser uma frase concluída ou pode ser a primeira metade de "Hello.txt", e ainda não dá para saber. Esperar pelo espaço significa que nunca cortamos uma frase cedo demais, ao custo de segurar a última até o stream terminar — o que iter_sentences resolve com aquele flush final do leftover. Este é um divisor ingênuo e está tudo bem. Ele também é a única peça de lógica aqui que é barata de testar unitariamente, então vale a pena fazer:

Falando a Resposta

POST /audio/speech é a terceira e última chamada. Duas opções fazem com que ela pareça rápida:
response_format="pcm" nos dá amostras brutas de 16 bits com sinal em little-endian a 24 kHz mono, que podemos canalizar direto para o alto-falante sem etapa de decodificação. Caso contrário, o tts-kokoro usa MP3 por padrão, e decodificar um MP3 significa esperar que uma parte suficiente do arquivo chegue antes de poder tocar qualquer coisa. streaming: True é a flag da Venice que começa a enviar áudio conforme ele é sintetizado, em vez de depois que o clipe inteiro está pronto. resolve_voice é deliberadamente sem graça — ela apara a string e recorre ao padrão do ambiente, e não valida contra uma lista:
Um ID de voz desconhecido falha na API com uma mensagem clara, o que é melhor do que uma allowlist local que silenciosamente fica desatualizada conforme a Venice adiciona vozes. As vozes são específicas de cada modelo, porém, então uma voz do Kokoro contra um modelo de TTS diferente não vai funcionar — veja Modelos de Text-to-Speech para os pareamentos.

Verifique Antes de Tocar

Aqui está a pegadinha que vai te fazer pular da cadeira. PCM bruto não tem cabeçalho nem magic bytes, então se uma resposta de erro for escrita no pipe de áudio, o alto-falante toca fielmente o JSON como uma rajada de ruído em volume máximo. Então verifique o status e o content type antes de tratar o corpo como áudio, e inspecione o primeiro chunk como salvaguarda:
RIFF captura uma resposta WAV e ID3 captura um MP3, e ambos significam que o response_format não teve efeito. A verificação de JSON captura um corpo de erro. Nada disso é engenhoso, e tudo isso é a diferença entre um erro legível e um usuário assustado.
Nunca canalize um corpo HTTP não verificado para um destino de áudio bruto. Não há negociação de formato no lado da reprodução para te salvar — quaisquer bytes que cheguem são tocados como amostras.

Gravação e Reprodução

Esta parte não é Venice, então vamos passar rápido. audio.py abre um stream de entrada do PortAudio enquanto o usuário fala e um stream de saída do PortAudio para tocar a resposta, ambos através do sounddevice. Nós o importamos de forma preguiçosa para que uma biblioteca nativa ausente vire uma frase em vez de um OSError na inicialização:
Essas são duas falhas genuinamente diferentes com duas correções diferentes, e o sounddevice reporta a segunda como um OSError puro vindo do próprio import. Capturar as duas aqui é o que permite que --text-only funcione em uma máquina que não consegue carregar o PortAudio de jeito nenhum. A gravação é um callback que anexa a uma lista, com um teto rígido para que uma sessão esquecida não cresça sem limite:
O try/finally aninhado é deliberado. O interno transforma um cancelamento em um AudioError amigável, e o externo para e fecha o stream em todo caminho de saída — incluindo o cancelamento — porque um RawInputStream que nunca é fechado continua segurando o microfone depois que o turno terminou. bytes(indata) copia em vez de referenciar, já que o PortAudio reutiliza aquele buffer para o próximo callback. Note que as amostras nunca tocam o disco. /audio/transcriptions precisa de um upload em formato de arquivo, mas “formato de arquivo” só significa que precisa de um cabeçalho WAV, e podemos colocar um em memória:
São catorze linhas para evitar jamais escrever uma gravação da voz de alguém em um diretório temporário, o que parece uma boa troca. wave está na biblioteca padrão, e os bytes vão direto para o argumento file= que configuramos antes. A reprodução é um stream por resposta, então frases consecutivas emendam como fala contínua em vez de reiniciar o dispositivo a cada vez:
Aquele buffer _pending é o único detalhe aqui que vai te morder se você pulá-lo. Limites de chunk HTTP não têm nada a ver com limites de amostra, então uma leitura de 4096 bytes pode te entregar um número ímpar de bytes e dividir uma amostra de 16 bits ao meio. Escreva isso no dispositivo e toda amostra subsequente fica deslocada por um byte, o que soa como o equivalente em áudio de estática. Então só escrevemos um número par de bytes e carregamos o byte sobressalente para a próxima chamada. A classe completa no repositório também tem abort() para Ctrl+C — parar o dispositivo imediatamente, descartar o que está em buffer — e close() para o caminho normal, que faz o flush da última amostra parcial (preenchida com um byte zero) e então espera o dispositivo terminar de tocar o que já tem. Inverter os dois significa ou cortar a última palavra de toda resposta ou ser incapaz de interromper uma.
O PortAudio é a camada de portabilidade aqui, então o mesmo audio.py roda no macOS, no Windows e no Linux. Nada em venice.py sabe ou se importa com qual.

Sobrepondo o Stream e a Reprodução

É aqui que o streaming realmente compensa. Se drenarmos o stream de chat e tocarmos o áudio na mesma thread, a reprodução bloqueia o loop e os tokens restantes do modelo ficam parados sem leitura em um buffer de socket. Então drenamos o stream em uma thread lateral e entregamos as frases por uma fila:
Colocar a exceção na fila e relançá-la no lado do consumidor é o que mantém o tratamento de erros honesto. Uma thread em segundo plano que morre silenciosamente te dá um travamento em vez de uma mensagem, e BaseException em vez de Exception significa que um KeyboardInterrupt dentro do stream ainda alcança o chamador. Agora o turno em si: puxar frases, imprimir cada uma, e alimentar o player com seu PCM conforme chega.
O player é criado de forma preguiçosa no primeiro chunk de áudio em vez de antecipadamente, então uma falha de TTS não deixa um stream de saída ocioso segurando os alto-falantes. E raise_on_error=not failed significa que quando o turno já está falhando, desmontamos a reprodução silenciosamente em vez de empilhar um segundo erro em cima do erro real. Imprimir o tempo até o primeiro áudio é uma coisa pequena que é genuinamente útil durante o ajuste. É o número que o usuário sente.

O Loop de Prompt

Tudo o que resta é um while True em volta de input():
Uma linha vazia significa “escutar”; qualquer outra coisa é tratada como entrada digitada. O histórico é aparado para as últimas oito trocas, o que é mais do que suficiente para uma conversa falada e mantém a contagem de tokens de entrada estável em vez de crescer até algo reclamar. O tratamento de erros em dois níveis merece destaque. Falhas de configuração encerram — não há por que iniciar um REPL que você não pode usar. Falhas por turno imprimem e voltam ao prompt, porque um rate limit ou uma gravação atrapalhada não deveriam encerrar a sessão. Aquela chamada de warmup também se paga. Ela lista os modelos e envia uma sonda de TTS de uma palavra, o que estabelece a conexão TLS e valida a chave e a voz antes do primeiro turno real do usuário, em vez de durante ele:

Executando

Pressione Enter, fale, pressione Enter de novo. Digite uma linha se preferir não usar o microfone, reset para começar uma nova conversa, q para sair. Ctrl+C durante uma resposta para a reprodução e te devolve ao prompt em vez de encerrar. Algumas variações:
Se ele pegar o microfone ou os alto-falantes errados, pergunte ao PortAudio o que ele consegue ver e coloque um nome ou índice em AUDIO_SOURCE / AUDIO_SINK:
E os testes:

O Que Esperar de Latência

O pipeline são três requisições sequenciais, então os números se empilham mais ou menos assim: Espere algo em torno de um segundo até o primeiro áudio em uma boa conexão. Duas coisas dominam esse número: se o TTS começa na primeira frase ou espera a resposta inteira, e se o modelo queima tokens pensando antes de falar. O streaming por frase e o disable_thinking são as duas mudanças aqui que você notaria se as removesse. Se quiser mais rápido, mantenha as respostas curtas — a primeira frase é o que determina a responsividade percebida — e experimente um modelo de chat da classe flash. Há mais sobre isso nas notas de latência do LiveKit.

Notas de Privacidade

Vale ser explícito sobre o que sai da máquina, já que esta tem um microfone dentro. O áudio vai para a Venice para ser transcrito e o texto volta para ser falado; ambos são cobertos pela política de retenção zero de dados da Venice, e nada é armazenado do lado deles depois da requisição. Localmente, nada é escrito em disco — a gravação é montada em uma lista, embrulhada em um cabeçalho WAV em memória e entregue à requisição, então não há arquivo temporário para vazar ou limpar. A chave de API é lida do ambiente e nunca impressa. O histórico da conversa vive apenas em memória e desaparece quando você sai ou digita reset. Veja Privacidade para os níveis por modelo, se você precisar de uma garantia mais forte do que retenção zero.

Concluindo

O que levar disto: um agente de voz na Venice são três endpoints compatíveis com a OpenAI, dois deles em streaming. Todo o resto neste projeto — o divisor de frases, os streams de áudio, a fila — existe para fazer essas três chamadas parecerem uma conversa. venice.py é a parte que vale a pena roubar. Troque app.py por um handler web ou uma integração telefônica e a camada de API não muda. Algumas coisas que valem a pena fazer em seguida:

Dê a ele ferramentas

Adicione function calling à etapa de chat e o agente pode consultar coisas no meio da conversa.

Deixe-o buscar

Defina enable_web_search em venice_parameters e as respostas deixam de ser limitadas aos dados de treinamento.

Clone uma voz

Troque o ID de voz do Kokoro por uma que você mesmo clonou.

Coloque-o em uma sala

Entregue as mesmas três etapas ao LiveKit para VAD, barge-in e chamadas com múltiplos participantes.
Obrigado pela leitura! Espero que isto tenha tirado um pouco do mistério dos agentes de voz — eles são bem menos exóticos do que parecem quando você enxerga as três requisições por baixo.

Recursos Relacionados