Skip to main content
Venice può ascoltarti e risponderti a voce. Non c’è un socket speech-to-speech in tempo reale a cui connettersi, il che sembra una limitazione finché non noti che un voice agent è in realtà solo tre normali chiamate HTTP in un ciclo: trascrivere ciò che l’utente ha detto, generare una risposta, pronunciare la risposta. In questa guida costruiremo quel ciclo come app da terminale in Python. Premi Invio, parla, premi di nuovo Invio, e la risposta esce dagli altoparlanti. Puoi anche digitare una riga se preferisci non usare il microfono. È la stessa forma STT → LLM → TTS della guida LiveKit Agents, meno LiveKit, le wake word e gli strumenti. Togliere il framework è proprio il punto: alla fine saprai esattamente quali tre richieste fanno il lavoro, e perché due di esse le facciamo in streaming. Prima di continuare: ti servirà una chiave API Venice. Esportala come variabile d’ambiente:
Ti interessa l’implementazione completa del codice? Dai un’occhiata al repository GitHub.

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
Registrazione e riproduzione passano per sounddevice, che incapsula PortAudio. uv sync installa il pacchetto Python, e su Windows è tutto ciò che ti serve. macOS e Linux vogliono anche la libreria PortAudio:
Niente di tutto questo riguarda Venice — è solo il modo in cui i campioni entrano ed escono dalla tua macchina. L’app accetta un flag --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:
Questa suddivisione conta più di quanto sembri. 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:
Poi crea .env.example così le scelte dei modelli sono configurazione anziché qualcosa di sepolto nel codice:
Copialo in .env e incolla la tua chiave.

Puntare l’SDK verso Venice

L’API di Venice è compatibile con OpenAI, quindi usiamo il client ufficiale openai e cambiamo la base URL. L’integrazione è tutta qui. Crea venice.py e comincia con il client:
Nota che controlliamo noi stessi la presenza della chiave invece di lasciare che 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:
Ogni chiamata qui sotto incanala i propri fallimenti attraverso questa funzione, così un ID di voce sbagliato o una chiave scaduta emergono come una riga leggibile invece di uno stack trace.

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:
La trascrizione di Venice è richiesta/risposta anziché un socket in streaming, motivo per cui la registrazione ha una fine definita — premiamo Invio invece di fare voice-activity detection. Se vuoi l’endpointing basato su VAD, è il compito che la guida LiveKit affida a Silero. Una trascrizione vuota è un esito normale, non un errore. Qualcuno premerà Invio due volte per sbaglio, e un amichevole “Non ho capito” batte un’eccezione ogni volta.

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.
Con questo in atto, la chiamata è una normale completion in streaming:
La decisione di design importante è che questa funzione restituisce frasi, non token. Il TTS ha bisogno di una proposizione completa per azzeccare la prosodia, quindi bufferizziamo i delta finché non ne abbiamo una, poi la passiamo avanti. È ciò che permette all’audio di iniziare a suonare mentre il modello sta ancora parlando. L’evento 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:
Nota che la regex richiede spazi bianchi dopo la punteggiatura. È deliberato: a metà stream, "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:
Un ID di voce sconosciuto fallisce lato API con un messaggio chiaro, il che è meglio di una allowlist locale che diventa silenziosamente obsoleta man mano che Venice aggiunge voci. Le voci sono però specifiche per modello, quindi una voce Kokoro con un diverso modello TTS non funzionerà — consulta Modelli Text-to-Speech per gli abbinamenti.

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.
Non instradare mai un corpo HTTP non verificato in un sink audio grezzo. Non c’è negoziazione di formato sul lato riproduzione a salvarti — qualunque byte arrivi viene riprodotto come campioni.

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:
Quelli sono due fallimenti genuinamente diversi con due rimedi diversi, e 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:
Il 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:
Sono quattordici righe per evitare di scrivere mai una registrazione della voce di qualcuno in una directory temporanea, il che sembra un buon compromesso. 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:
Quel buffer _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:
Mettere l’eccezione sulla coda e rilanciarla sul lato consumatore è ciò che mantiene onesta la gestione degli errori. Un thread in background che muore in silenzio ti dà un blocco invece di un messaggio, e 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.
Il player viene creato in modo lazy al primo chunk di audio anziché in anticipo, così un fallimento del TTS non lascia uno stream di output inattivo a tenere occupati gli altoparlanti. E 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 è un while True intorno a input():
Una riga vuota significa “ascolta”; qualsiasi altra cosa è trattata come input digitato. La cronologia è rifilata agli ultimi otto scambi, che è più che sufficiente per una conversazione parlata e mantiene piatto il conteggio dei token di input invece di farlo crescere finché qualcosa non protesta. La gestione degli errori a due livelli merita una menzione. I fallimenti di setup escono — non ha senso avviare una REPL che non puoi usare. I fallimenti per turno stampano e tornano al prompt, perché un rate limit o una registrazione mal riuscita non dovrebbero terminare la sessione. Anche quella chiamata a 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

Premi Invio, parla, premi di nuovo Invio. Digita una riga se preferisci non usare il microfono, 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:
Se prende il microfono o gli altoparlanti sbagliati, chiedi a PortAudio cosa vede e metti un nome o un indice in AUDIO_SOURCE / AUDIO_SINK:
E i test:

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 digiti reset. 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.
Grazie per la lettura! Speriamo che questo abbia tolto un po’ di mistero ai voice agent — sono molto meno esotici di quanto sembrino una volta viste le tre richieste sottostanti.

Risorse correlate