Skip to main content
Venice peut vous entendre et vous répondre. Il n’y a pas de socket speech-to-speech en temps réel auquel se connecter, ce qui ressemble à une limitation jusqu’à ce que vous remarquiez qu’un agent vocal n’est en réalité que trois appels HTTP ordinaires dans une boucle : transcrire ce que l’utilisateur a dit, générer une réponse, énoncer la réponse. Dans ce guide, nous allons construire cette boucle sous forme d’application de terminal en Python. Appuyez sur Entrée, parlez, appuyez à nouveau sur Entrée, et la réponse sort de vos haut-parleurs. Vous pouvez taper une ligne à la place si vous préférez ne pas utiliser le micro. C’est la même architecture STT → LLM → TTS que le guide LiveKit Agents, moins LiveKit, les mots d’activation et les outils. Retirer le framework est justement le but : à la fin, vous saurez exactement quelles trois requêtes font le travail, et pourquoi nous en streamons deux. Avant de continuer : vous aurez besoin d’une clé d’API Venice. Exportez-la comme variable d’environnement :
Intéressé par l’implémentation complète du code ? Consultez le dépôt GitHub.

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
L’enregistrement et la lecture passent par sounddevice, qui enveloppe PortAudio. 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 :
Rien de tout cela ne concerne Venice — c’est simplement la manière dont les échantillons entrent et sortent de votre machine. L’application accepte un drapeau --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 :
Cette séparation compte plus qu’il n’y paraît. 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 :
Créez ensuite .env.example pour que le choix des modèles soit de la configuration plutôt que quelque chose d’enfoui dans le code :
Copiez-le vers .env et collez-y votre clé.

Pointer le SDK vers Venice

L’API de Venice est compatible OpenAI, donc nous utilisons le client officiel openai et changeons l’URL de base. C’est toute l’intégration. Créez venice.py et commencez par le client :
Notez que nous vérifions la clé nous-mêmes plutôt que de laisser 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 :
Chaque appel ci-dessous fait transiter ses échecs par cette fonction, si bien qu’un identifiant de voix invalide ou une clé expirée fait surface comme une ligne lisible plutôt qu’une pile d’appels.

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 :
La transcription Venice fonctionne en requête/réponse plutôt qu’en socket de streaming, ce qui explique pourquoi l’enregistrement a une fin bien définie — nous appuyons sur Entrée au lieu de faire de la détection d’activité vocale. Si vous voulez une délimitation basée sur le VAD, c’est le travail que le guide LiveKit confie à Silero. Une transcription vide est un résultat normal, pas une erreur. Quelqu’un finira par appuyer deux fois sur Entrée par accident, et un aimable « Je n’ai pas compris » vaut toujours mieux qu’une exception.

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.
Cela en place, l’appel est une complétion streamée normale :
La décision de conception importante est que cette fonction produit des phrases, pas des jetons. La TTS a besoin d’une proposition complète pour réussir la prosodie, donc nous mettons les deltas en tampon jusqu’à en avoir une, puis nous la transmettons. C’est ce qui permet à l’audio de commencer à jouer pendant que le modèle parle encore. L’événement 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 :
Notez que l’expression régulière exige un espace après la ponctuation. C’est délibéré : en cours de flux, "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 :
Un identifiant de voix inconnu échoue au niveau de l’API avec un message clair, ce qui vaut mieux qu’une liste blanche locale qui se périme silencieusement à mesure que Venice ajoute des voix. Les voix sont cependant propres à chaque modèle, donc une voix Kokoro avec un autre modèle TTS ne fonctionnera pas — consultez Modèles de synthèse vocale pour les associations.

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.
N’envoyez jamais un corps HTTP non vérifié dans une sortie audio brute. Il n’y a aucune négociation de format côté lecture pour vous sauver — les octets qui arrivent, quels qu’ils soient, sont joués comme des échantillons.

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 :
Ce sont deux échecs véritablement différents avec deux correctifs différents, et 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 :
Le 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 :
Voilà quatorze lignes pour éviter de jamais écrire un enregistrement de la voix de quelqu’un dans un répertoire temporaire, ce qui semble un bon échange. 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 :
Ce tampon _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 :
Mettre l’exception dans la file et la relever côté consommateur est ce qui garde la gestion d’erreurs honnête. Un thread d’arrière-plan qui meurt en silence vous donne un blocage au lieu d’un message, et 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.
Le lecteur est créé paresseusement au premier fragment d’audio plutôt qu’en amont, afin qu’un échec de TTS ne laisse pas un flux de sortie inactif retenir les haut-parleurs. Et 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 un while True autour d’input() :
Une ligne vide signifie « écoute » ; tout le reste est traité comme une entrée tapée. L’historique est taillé aux huit derniers échanges, ce qui suffit largement pour une conversation parlée et garde le nombre de jetons d’entrée stable au lieu de croître jusqu’à ce que quelque chose se plaigne. La gestion d’erreurs à deux niveaux mérite d’être soulignée. Les échecs de configuration font quitter — il ne sert à rien de démarrer un REPL inutilisable. Les échecs par tour s’impriment et rendent la main au prompt, parce qu’une limite de débit ou un enregistrement raté ne devrait pas terminer la session. Cet appel 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

Appuyez sur Entrée, parlez, appuyez à nouveau sur Entrée. Tapez une ligne si vous préférez ne pas utiliser le micro, 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 :
S’il attrape le mauvais microphone ou les mauvais haut-parleurs, demandez à PortAudio ce qu’il voit et mettez un nom ou un index dans AUDIO_SOURCE / AUDIO_SINK :
Et les tests :

À 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 tapez reset. 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.
Merci de votre lecture ! En espérant que cela ait dissipé un peu du mystère des agents vocaux — ils sont bien moins exotiques qu’ils n’en ont l’air une fois qu’on voit les trois requêtes en dessous.

Ressources associées