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
uv sync instala o pacote Python, e no Windows isso é tudo o que você precisa. macOS e Linux querem a biblioteca PortAudio também:
--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:
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, opython-dotenv mantém a chave fora do histórico do seu shell, e o sounddevice conversa com o microfone e os alto-falantes:
.env.example para que as escolhas de modelo sejam configuração em vez de algo enterrado no código:
.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 clienteopenai oficial e mudamos a base URL. Essa é toda a integração. Crie venice.py e comece pelo cliente:
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:
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:
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.
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:
"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:
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.
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:
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:
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:
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:
_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: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.
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 é umwhile True em volta de input():
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
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:
AUDIO_SOURCE / AUDIO_SINK:
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 digitareset.
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.