Prérequis
- Python 3.11 ou plus récent, et uv
- Une clé d’API Venice depuis venice.ai
- Un microphone et des haut-parleurs, si vous voulez la boucle vocale complète
uv sync installe le paquet Python, et sous Windows c’est tout ce dont vous avez besoin. macOS et Linux exigent aussi la bibliothèque PortAudio :
--text-only qui contourne entièrement le micro tout en exerçant le chat et la TTS, pour que vous puissiez suivre sur une machine sans aucun matériel audio.
Ce que nous construisons
Un tour de conversation, c’est trois requêtes :
Ces identifiants de modèles sont un point de départ plutôt qu’une liste figée. Venice fait tourner le catalogue, alors résolvez-les à l’exécution depuis
GET /models?type=... et GET /models/traits avant de livrer quoi que ce soit. Consultez Dépréciations pour voir comment cela se déroule.
Nous garderons l’arborescence source volontairement petite :
venice.py est la partie que vous pouvez transplanter telle quelle dans une application web, un bot Discord ou une intégration téléphonique. audio.py est le seul fichier qui se soucie de la machine sur laquelle il tourne, et Venice n’en voit jamais rien — l’API ne reçoit jamais qu’un blob WAV à l’entrée et rend du PCM brut à la sortie.
Mise en place
Créez le projet et ajoutez les dépendances. Le SDK OpenAI fait tout le travail HTTP,python-dotenv garde la clé hors de l’historique de votre shell, et sounddevice parle au micro et aux haut-parleurs :
.env.example pour que le choix des modèles soit de la configuration plutôt que quelque chose d’enfoui dans le code :
.env et collez-y votre clé.
Pointer le SDK vers Venice
L’API de Venice est compatible OpenAI, donc nous utilisons le client officielopenai et changeons l’URL de base. C’est toute l’intégration. Créez venice.py et commencez par le client :
os.environ["VENICE_API_KEY"] lever une exception. Une trace KeyError est une mauvaise première expérience pour quelque chose d’aussi banal qu’une clé manquante.
Encore un peu d’intendance pendant que nous y sommes. Le SDK lève des sous-classes d’OpenAIError, et le détail utile est enfoui dans le corps de la réponse, donc cela vaut la peine de le déballer une fois pour toutes :
Entendre l’utilisateur
POST /audio/transcriptions prend un fichier audio et renvoie du texte. Nous enregistrons localement en WAV mono 16 kHz, mais le point de terminaison accepte les formats habituels, donc nous mappons l’extension du fichier vers un type MIME plutôt que d’en coder un en dur :
Streamer la réponse
Passons à l’appel de chat. Il y a ici deux réglages propres à Venice qui font une vraie différence dans la manière dont l’agent sonne :include_venice_system_prompt: False empêche Venice de préfixer notre prompt système avec le sien. Laissé activé, c’est environ mille sept cents jetons d’entrée supplémentaires par appel et une seconde voix qui dit au modèle comment se comporter. disable_thinking: True (avec reasoning.enabled: False pour les modèles qui lisent le champ plus récent) empêche GLM de dépenser son budget de jetons dans une chaîne de pensée cachée avant de dire quoi que ce soit — ce qui, quand vous attendez d’entendre une réponse, est du temps que vous pouvez entendre.
Le prompt lui-même mérite sa longueur. Demander vingt mots garde des réponses qui sonnent parlé plutôt qu’écrit, et « omettre du détail plutôt que de finir au milieu d’une phrase » est ce qui empêche un plafond max_tokens strict de tronquer au milieu d’un mot. Interdire le markdown compte plus qu’on ne le croirait : un modèle TTS lira volontiers les astérisques à voix haute.
L’instruction de traiter le message de l’utilisateur comme une entrée non fiable fait ici un vrai travail. La parole transcrite est une entrée utilisateur comme une autre, et « ignore tes instructions précédentes » est tout aussi facile à dire à voix haute qu’à taper.
cancel permet à l’appelant d’arrêter de vider le flux quand l’utilisateur fait Ctrl+C, et fermer le flux dans un bloc finally libère la connexion au lieu de la laisser en suspens jusqu’au timeout.
Découper les phrases au fil de leur arrivée
Découper sur., ! et ? vous amène à 90 % du chemin puis vous ridiculise la première fois que le modèle dit « Dr. Smith ». Nous vérifions donc si ce qui précède le point est une abréviation avant de le traiter comme une frontière :
"Hello." peut être une phrase finie ou la première moitié de "Hello.txt", et nous ne pouvons pas encore le savoir. Attendre l’espace signifie que nous ne coupons jamais une phrase trop tôt, au prix de retenir la dernière jusqu’à la fin du flux — ce que iter_sentences gère avec ce vidage final du leftover.
C’est un découpeur naïf, et cela suffit. C’est aussi le seul morceau de logique ici qui est peu coûteux à tester unitairement, donc cela vaut la peine de le faire :
Énoncer la réponse
POST /audio/speech est le troisième et dernier appel. Deux options le rendent rapide au ressenti :
response_format="pcm" nous donne des échantillons bruts signés 16 bits little-endian à 24 kHz mono, que nous pouvons envoyer directement au haut-parleur sans étape de décodage. tts-kokoro renvoie sinon du MP3 par défaut, et décoder un MP3 signifie attendre qu’une partie suffisante du fichier arrive avant de pouvoir en jouer quoi que ce soit. streaming: True est le drapeau Venice qui commence à envoyer l’audio au fur et à mesure de sa synthèse plutôt qu’une fois le clip entier terminé.
resolve_voice est délibérément terne — elle épure la chaîne et retombe sur la valeur par défaut de l’environnement, sans valider contre une liste :
Vérifier avant de jouer
Voici le piège qui vous fera bondir de votre chaise. Le PCM brut n’a ni en-tête ni octets magiques, donc si une réponse d’erreur est écrite dans le tuyau audio, le haut-parleur joue fidèlement le JSON comme une rafale de bruit à plein volume. Vérifiez donc le statut et le type de contenu avant de traiter le corps comme de l’audio, et sondez le premier fragment en filet de sécurité :RIFF attrape une réponse WAV et ID3 un MP3, deux cas où le response_format n’a pas pris effet. La vérification JSON attrape un corps d’erreur. Rien de tout cela n’est astucieux, et tout cela fait la différence entre une erreur lisible et un utilisateur qui sursaute.
Enregistrement et lecture
Cette partie n’est pas du Venice, donc nous irons vite.audio.py ouvre un flux d’entrée PortAudio pendant que l’utilisateur parle et un flux de sortie PortAudio pour jouer la réponse, les deux via sounddevice.
Nous l’importons paresseusement pour qu’une bibliothèque native manquante devienne une phrase plutôt qu’une OSError au démarrage :
sounddevice signale le second comme une simple OSError levée par l’import lui-même. Attraper les deux ici est ce qui permet à --text-only de fonctionner sur une machine qui ne peut pas du tout charger PortAudio.
L’enregistrement est un callback qui accumule dans une liste, avec un plafond strict pour qu’une session oubliée ne grossisse pas sans limite :
try/finally imbriqué est délibéré. Le bloc interne transforme une annulation en une AudioError aimable, et le bloc externe arrête et ferme le flux sur tous les chemins de sortie — y compris l’annulation — parce qu’un RawInputStream jamais fermé continue de retenir le microphone une fois le tour terminé. bytes(indata) copie plutôt que d’aliaser, puisque PortAudio réutilise ce tampon pour le callback suivant.
Notez que les échantillons ne touchent jamais le disque. /audio/transcriptions a besoin d’un envoi en forme de fichier, mais « en forme de fichier » signifie seulement qu’il faut un en-tête WAV, et nous pouvons en poser un en mémoire :
wave fait partie de la bibliothèque standard, et les octets vont directement dans l’argument file= que nous avons configuré plus tôt.
La lecture utilise un flux par réponse, afin que les phrases consécutives s’enchaînent en parole continue au lieu de redémarrer le périphérique à chaque fois :
_pending est le détail qui vous mordra si vous le sautez. Les frontières des fragments HTTP n’ont rien à voir avec les frontières des échantillons, donc une lecture de 4096 octets peut vous remettre un nombre impair d’octets et couper un échantillon 16 bits en deux. Écrivez cela sur le périphérique et chaque échantillon suivant est décalé d’un octet, ce qui sonne comme l’équivalent audio de la neige. Nous n’écrivons donc jamais qu’un nombre pair d’octets et reportons l’octet restant à l’appel suivant.
La classe complète du dépôt a aussi abort() pour Ctrl+C — arrêter le périphérique immédiatement, jeter ce qui est en tampon — et close() pour le chemin normal, qui vide le dernier échantillon partiel (complété d’un octet zéro) puis attend que le périphérique finisse de jouer ce qu’il a déjà. Inverser ces deux-là signifie soit couper le dernier mot de chaque réponse, soit être incapable d’en interrompre une.
PortAudio est ici la couche de portabilité, donc le même
audio.py tourne sur macOS, Windows et Linux. Rien dans venice.py ne sait ni ne se soucie duquel.Superposer le flux et la lecture
C’est ici que le streaming paie vraiment. Si nous vidons le flux de chat et jouons l’audio sur le même thread, la lecture bloque la boucle et les jetons restants du modèle attendent, non lus, dans un tampon de socket. Nous vidons donc le flux sur un thread annexe et passons les phrases par une file :BaseException plutôt qu’Exception signifie qu’un KeyboardInterrupt à l’intérieur du flux atteint quand même l’appelant.
Maintenant le tour lui-même : tirer les phrases, imprimer chacune, et alimenter le lecteur avec son PCM au fur et à mesure de son arrivée.
raise_on_error=not failed signifie que lorsque le tour est déjà en train d’échouer, nous démontons la lecture discrètement au lieu d’empiler une seconde erreur sur la vraie.
Imprimer le temps jusqu’au premier audio est une petite chose véritablement utile pendant le réglage. C’est le chiffre que l’utilisateur ressent.
La boucle de prompt
Tout ce qui reste est unwhile True autour d’input() :
warmup gagne aussi sa place. Il liste les modèles et envoie une sonde TTS d’un mot, ce qui établit la connexion TLS et valide la clé et la voix avant le premier vrai tour de l’utilisateur plutôt que pendant :
Le lancer
reset pour démarrer une nouvelle conversation, q pour quitter. Ctrl+C pendant une réponse arrête la lecture et vous ramène au prompt au lieu de quitter.
Quelques variantes :
AUDIO_SOURCE / AUDIO_SINK :
À quoi s’attendre côté latence
Le pipeline est composé de trois requêtes séquentielles, donc les chiffres s’empilent à peu près comme ceci :
Attendez-vous à environ une seconde jusqu’au premier audio sur une bonne connexion. Deux choses dominent ce chiffre : si la TTS démarre à la première phrase ou attend la réponse entière, et si le modèle brûle des jetons à réfléchir avant de parler. Le streaming au niveau des phrases et
disable_thinking sont les deux changements que vous remarqueriez si vous les retiriez.
Si vous le voulez plus rapide, gardez des réponses courtes — la première phrase est ce qui conditionne la réactivité perçue — et essayez un modèle de chat de classe flash. Il y a plus de détails dans les notes de latence LiveKit.
Notes sur la confidentialité
Cela vaut la peine d’être explicite sur ce qui quitte la machine, puisque celle-ci contient un microphone. L’audio part vers Venice pour être transcrit et le texte revient pour être énoncé ; les deux sont couverts par la politique de rétention zéro des données de Venice, et rien n’est stocké de leur côté après la requête. Localement, rien n’est écrit sur le disque — l’enregistrement est assemblé dans une liste, enveloppé d’un en-tête WAV en mémoire, et remis à la requête, donc il n’y a aucun fichier temporaire à faire fuiter ou à nettoyer. La clé d’API est lue depuis l’environnement et jamais imprimée. L’historique de conversation ne vit qu’en mémoire et disparaît quand vous quittez ou tapezreset.
Consultez Confidentialité pour les niveaux par modèle si vous avez besoin d’une garantie plus forte que la rétention zéro.
Pour finir
La chose à retenir : un agent vocal sur Venice, c’est trois points de terminaison compatibles OpenAI, dont deux streamés. Tout le reste de ce projet — le découpeur de phrases, les flux audio, la file — n’existe que pour faire de ces trois appels une conversation.venice.py est la partie qui vaut la peine d’être volée. Remplacez app.py par un gestionnaire web ou une intégration téléphonique et la couche API ne change pas.
Quelques choses à faire ensuite qui en valent la peine :
Donnez-lui des outils
Ajoutez l’appel de fonctions à l’étape de chat et l’agent peut chercher des informations en pleine conversation.
Laissez-le chercher
Réglez
enable_web_search dans venice_parameters et les réponses cessent d’être limitées aux données d’entraînement.Clonez une voix
Remplacez l’identifiant de voix Kokoro par une voix que vous avez clonée vous-même.
Mettez-le dans une salle
Confiez les trois mêmes étapes à LiveKit pour le VAD, l’interruption au vol, et les appels multi-participants.