Prerequisiti
- Python 3.11 o successivo, e uv
- Una chiave API Venice da venice.ai
- Un microfono e degli altoparlanti, se vuoi il ciclo vocale completo
uv sync installa il pacchetto Python, e su Windows è tutto ciò che ti serve. macOS e Linux vogliono anche la libreria PortAudio:
--text-only che salta del tutto il microfono e usa comunque chat e TTS, così puoi seguire la guida su una macchina senza alcun hardware audio.
Cosa costruiremo
Un turno di conversazione sono tre richieste:
Quegli ID di modello sono un punto di partenza, non una lista fissa. Venice ruota il catalogo, quindi risolvili a runtime da
GET /models?type=... e GET /models/traits prima di rilasciare qualsiasi cosa. Consulta Deprecazioni per come funziona.
Terremo l’albero dei sorgenti volutamente piccolo:
venice.py è la parte che puoi trasferire pari pari in una web app, un bot Discord o un’integrazione telefonica. audio.py è l’unico file a cui importa su quale macchina sta girando, e Venice non ne vede mai nulla — l’API riceve solo un blob WAV in ingresso e restituisce PCM grezzo in uscita.
Configurazione
Crea il progetto e aggiungi le dipendenze. L’SDK OpenAI fa tutto il lavoro HTTP,python-dotenv tiene la chiave fuori dalla cronologia della shell, e sounddevice parla con microfono e altoparlanti:
.env.example così le scelte dei modelli sono configurazione anziché qualcosa di sepolto nel codice:
.env e incolla la tua chiave.
Puntare l’SDK verso Venice
L’API di Venice è compatibile con OpenAI, quindi usiamo il client ufficialeopenai e cambiamo la base URL. L’integrazione è tutta qui. Crea venice.py e comincia con il client:
os.environ["VENICE_API_KEY"] sollevi un’eccezione. Un traceback di KeyError è una pessima prima esperienza per qualcosa di ordinario come una chiave mancante.
Un altro pezzo di manutenzione già che ci siamo. L’SDK solleva sottoclassi di OpenAIError, e il dettaglio utile è sepolto nel corpo della risposta, quindi vale la pena estrarlo una volta sola:
Ascoltare l’utente
POST /audio/transcriptions prende un file audio e restituisce testo. Registriamo localmente WAV mono a 16 kHz, ma l’endpoint accetta i formati consueti, quindi mappiamo l’estensione del file a un tipo MIME invece di cablarne uno:
Rispondere in streaming
Ora la chiamata di chat. Ci sono due impostazioni specifiche di Venice che fanno una vera differenza su come suona l’agente:include_venice_system_prompt: False impedisce a Venice di anteporre il proprio system prompt al nostro. Lasciato attivo, sono circa millesettecento token di input in più per chiamata e una seconda voce che dice al modello come comportarsi. disable_thinking: True (con reasoning.enabled: False per i modelli che leggono il campo più recente) impedisce a GLM di spendere il proprio budget di token in una catena di pensiero nascosta prima di dire qualcosa — che, quando stai aspettando di sentire una risposta, è tempo che si sente.
Il prompt stesso si guadagna la sua lunghezza. Chiedere venti parole mantiene le risposte con un suono parlato anziché scritto, e “omit detail rather than ending mid-sentence” è ciò che impedisce a un tetto rigido di max_tokens di troncare a metà parola. Vietare il markdown conta più di quanto pensi: un modello TTS leggerà tranquillamente gli asterischi ad alta voce.
L’istruzione di trattare il messaggio dell’utente come input non affidabile sta facendo un lavoro concreto qui. Il parlato trascritto è input utente come qualsiasi altro, e “ignora le tue istruzioni precedenti” è altrettanto facile da dire a voce quanto da digitare.
cancel permette al chiamante di smettere di consumare lo stream quando l’utente preme Ctrl+C, e chiudere lo stream in un blocco finally rilascia la connessione invece di lasciarla appesa fino al timeout.
Dividere le frasi man mano che arrivano
Dividere su., ! e ? ti porta al 90% del risultato e poi ti mette in imbarazzo la prima volta che il modello dice “Dr. Smith”. Quindi controlliamo se ciò che precede il punto è un’abbreviazione prima di trattarlo come confine:
"Hello." potrebbe essere una frase finita oppure la prima metà di "Hello.txt", e non possiamo ancora saperlo. Aspettare lo spazio significa che non tagliamo mai una frase in anticipo, al costo di trattenere l’ultima finché lo stream non finisce — cosa che iter_sentences gestisce con quel flush finale di leftover.
È uno splitter ingenuo e va benissimo così. È anche l’unico pezzo di logica qui che è economico da testare con unit test, quindi vale la pena farlo:
Pronunciare la risposta
POST /audio/speech è la terza e ultima chiamata. Due opzioni la fanno sembrare veloce:
response_format="pcm" ci dà campioni grezzi signed 16-bit little-endian a 24 kHz mono, che possiamo instradare direttamente all’altoparlante senza passaggio di decodifica. tts-kokoro altrimenti usa MP3 come predefinito, e decodificare un MP3 significa aspettare che arrivi abbastanza file prima di poterne riprodurre qualcosa. streaming: True è il flag di Venice che inizia a inviare l’audio mentre viene sintetizzato invece che dopo che l’intera clip è finita.
resolve_voice è deliberatamente banale — rifila la stringa e ricade sul default d’ambiente, e non valida contro una lista:
Controlla prima di riprodurre
Ecco l’unica trappola che ti farà saltare dalla sedia. Il PCM grezzo non ha header né magic byte, quindi se una risposta di errore viene scritta nel pipe audio, l’altoparlante riproduce fedelmente il JSON come una raffica di rumore a tutto volume. Quindi controlla lo status e il content type prima di trattare il corpo come audio, e annusa il primo chunk come rete di sicurezza:RIFF intercetta una risposta WAV e ID3 intercetta un MP3, entrambi segno che il response_format non ha avuto effetto. Il controllo JSON intercetta un corpo di errore. Niente di tutto questo è ingegnoso, e tutto questo è la differenza tra un errore leggibile e un utente spaventato.
Registrazione e riproduzione
Questa parte non è Venice, quindi andremo veloci.audio.py apre uno stream di input PortAudio mentre l’utente parla e uno stream di output PortAudio per riprodurre la risposta, entrambi tramite sounddevice.
Lo importiamo in modo lazy così che una libreria nativa mancante diventi una frase anziché un OSError all’avvio:
sounddevice riporta il secondo come un nudo OSError dall’import stesso. Catturarli entrambi qui è ciò che permette a --text-only di funzionare su una macchina che non può caricare PortAudio affatto.
La registrazione è una callback che accoda in una lista, con un tetto rigido così che una sessione dimenticata non cresca senza limite:
try/finally annidato è deliberato. Quello interno trasforma un annullamento in un AudioError amichevole, e quello esterno ferma e chiude lo stream su ogni percorso d’uscita — annullamento incluso — perché un RawInputStream che non viene mai chiuso continua a tenere il microfono dopo che il turno è finito. bytes(indata) copia anziché aliasare, dato che PortAudio riutilizza quel buffer per la callback successiva.
Nota che i campioni non toccano mai il disco. /audio/transcriptions ha bisogno di un upload a forma di file, ma “a forma di file” significa solo che serve un header WAV, e possiamo aggiungerlo in memoria:
wave è nella libreria standard, e i byte vanno direttamente all’argomento file= che abbiamo impostato prima.
La riproduzione è uno stream per risposta, così le frasi consecutive scorrono insieme come parlato continuo invece di riavviare il dispositivo ogni volta:
_pending è l’unico dettaglio qui che ti morderà se lo salti. I confini dei chunk HTTP non hanno nulla a che fare con i confini dei campioni, quindi una lettura da 4096 byte può consegnarti un numero dispari di byte e spezzare un campione a 16 bit a metà. Scrivilo sul dispositivo e ogni campione successivo è sfasato di un byte, il che suona come l’equivalente audio della statica. Quindi scriviamo sempre e solo un numero pari di byte e portiamo il byte avanzato nella chiamata successiva.
La classe completa nel repository ha anche abort() per Ctrl+C — ferma il dispositivo immediatamente, scarta ciò che è bufferizzato — e close() per il percorso normale, che svuota l’ultimo campione parziale (riempito con un byte zero) e poi aspetta che il dispositivo finisca di riprodurre ciò che ha già. Invertire quei due significa o troncare l’ultima parola di ogni risposta o non poterne interrompere una.
PortAudio è lo strato di portabilità qui, quindi lo stesso
audio.py gira su macOS, Windows e Linux. Nulla in venice.py sa o si cura di quale sia.Sovrapporre lo stream e la riproduzione
Ecco dove lo streaming ripaga davvero. Se consumiamo lo stream della chat e riproduciamo l’audio sullo stesso thread, la riproduzione blocca il ciclo e i token rimanenti del modello restano non letti in un buffer di socket. Quindi consumiamo lo stream su un thread laterale e passiamo le frasi attraverso una coda:BaseException anziché Exception significa che un KeyboardInterrupt dentro lo stream raggiunge comunque il chiamante.
Ora il turno vero e proprio: estrai le frasi, stampa ciascuna e passa il suo PCM al player man mano che arriva.
raise_on_error=not failed significa che quando il turno sta già fallendo smontiamo la riproduzione in silenzio invece di impilare un secondo errore sopra quello vero.
Stampare il tempo al primo audio è una piccola cosa genuinamente utile mentre si fa tuning. È il numero che l’utente percepisce.
Il ciclo del prompt
Tutto ciò che resta è unwhile True intorno a input():
warmup si guadagna il suo posto. Elenca i modelli e invia una sonda TTS di una parola, il che stabilisce la connessione TLS e valida la chiave e la voce prima del primo turno reale dell’utente anziché durante:
Eseguirlo
reset per iniziare una nuova conversazione, q per uscire. Ctrl+C durante una risposta ferma la riproduzione e ti riporta al prompt invece di uscire.
Alcune variazioni:
AUDIO_SOURCE / AUDIO_SINK:
Cosa aspettarsi sulla latenza
La pipeline è composta da tre richieste sequenziali, quindi i numeri si sommano più o meno così:
Aspettati circa un secondo al primo audio su una buona connessione. Due cose dominano quel numero: se il TTS parte alla prima frase o aspetta l’intera risposta, e se il modello brucia token pensando prima di parlare. Lo streaming a livello di frase e
disable_thinking sono i due cambiamenti qui che noteresti se li rimuovessi.
Se lo vuoi più veloce, tieni le risposte corte — la prima frase è ciò che condiziona la reattività percepita — e prova un modello di chat di classe flash. C’è di più su questo nelle note sulla latenza di LiveKit.
Note sulla privacy
Vale la pena essere espliciti su cosa lascia la macchina, visto che questa ha un microfono dentro. L’audio va a Venice per essere trascritto e il testo torna indietro per essere pronunciato; entrambi sono coperti dalla policy di zero data retention di Venice, e nulla viene conservato dal loro lato dopo la richiesta. Localmente, nulla viene scritto su disco — la registrazione è assemblata in una lista, avvolta in un header WAV in memoria e passata alla richiesta, quindi non c’è alcun file temporaneo da far trapelare o ripulire. La chiave API è letta dall’ambiente e mai stampata. La cronologia della conversazione vive solo in memoria e sparisce quando esci o digitireset.
Consulta Privacy per i livelli per modello se ti serve una garanzia più forte della zero retention.
Per concludere
La cosa da portare a casa: un voice agent su Venice sono tre endpoint compatibili con OpenAI, due dei quali in streaming. Tutto il resto in questo progetto — lo splitter di frasi, gli stream audio, la coda — esiste per far sembrare quelle tre chiamate una conversazione.venice.py è la parte che vale la pena rubare. Sostituisci app.py con un handler web o un’integrazione telefonica e lo strato API non cambia.
Alcune cose che vale la pena fare dopo:
Dagli strumenti
Aggiungi il function calling al passo di chat e l’agente potrà cercare informazioni a metà conversazione.
Lascialo cercare
Imposta
enable_web_search in venice_parameters e le risposte smettono di essere limitate ai dati di addestramento.Clona una voce
Sostituisci l’ID di voce Kokoro con uno che hai clonato tu stesso.
Mettilo in una stanza
Affida le stesse tre fasi a LiveKit per VAD, barge-in e chiamate multi-partecipante.