Skip to main content
Venice puede escucharte y responderte hablando. No hay un socket de voz a voz en tiempo real al que conectarse, lo que suena a limitación hasta que te das cuenta de que un agente de voz es en realidad solo tres llamadas HTTP corrientes en un bucle: transcribir lo que dijo el usuario, generar una respuesta, pronunciar la respuesta. En esta guía construiremos ese bucle como una app de terminal en Python. Pulsa Enter, habla, pulsa Enter otra vez, y la respuesta suena por tus altavoces. Puedes escribir una línea en su lugar si prefieres no usar el micrófono. Es la misma forma STT → LLM → TTS que la guía de LiveKit Agents, menos LiveKit, las palabras de activación y las herramientas. Quitar el framework es justamente el punto: al final sabrás exactamente qué tres peticiones hacen el trabajo, y por qué transmitimos dos de ellas en streaming. Antes de continuar: necesitarás una clave de API de Venice. Expórtala como variable de entorno:
¿Te interesa la implementación completa del código? Echa un vistazo a el repo de GitHub.

Requisitos previos

  • Python 3.11 o más reciente, y uv
  • Una clave de API de Venice de venice.ai
  • Un micrófono y altavoces, si quieres el bucle de voz completo
La grabación y la reproducción pasan por sounddevice, que envuelve PortAudio. uv sync instala el paquete de Python, y en Windows eso es todo lo que necesitas. macOS y Linux quieren además la biblioteca PortAudio:
Nada de esto tiene que ver con Venice — es solo cómo las muestras entran y salen de tu máquina. La app acepta una bandera --text-only que se salta el micrófono por completo y aun así ejercita el chat y el TTS, así que puedes seguir la guía en una máquina sin hardware de audio en absoluto.

Qué vamos a construir

Un turno de conversación son tres peticiones: Esos IDs de modelo son un punto de partida y no una lista fija. Venice rota el catálogo, así que resuélvelos en tiempo de ejecución desde GET /models?type=... y GET /models/traits antes de publicar nada. Consulta Deprecaciones para ver cómo funciona eso. Mantendremos el árbol de código pequeño a propósito:
La división importa más de lo que parece. venice.py es la parte que puedes llevarte directamente a una app web, un bot de Discord o una integración telefónica. audio.py es el único archivo al que le importa en qué máquina se está ejecutando, y Venice nunca ve nada de él — la API solo recibe un blob WAV de entrada y devuelve PCM crudo de salida.

Configuración

Crea el proyecto y añade las dependencias. El SDK de OpenAI hace todo el trabajo HTTP, python-dotenv mantiene la clave fuera del historial de tu shell, y sounddevice habla con el micrófono y los altavoces:
Luego crea .env.example para que las elecciones de modelo sean configuración en lugar de algo enterrado en el código:
Cópialo a .env y pega tu clave dentro.

Apuntar el SDK a Venice

La API de Venice es compatible con OpenAI, así que usamos el cliente oficial openai y cambiamos la URL base. Esa es toda la integración. Crea venice.py y empieza con el cliente:
Fíjate en que comprobamos la clave nosotros mismos en lugar de dejar que os.environ["VENICE_API_KEY"] lance una excepción. Un traceback de KeyError es una mala primera experiencia para algo tan corriente como una clave ausente. Una pieza más de mantenimiento ya que estamos aquí. El SDK lanza subclases de OpenAIError, y el detalle útil está enterrado en el cuerpo de la respuesta, así que merece la pena desenvolverlo una sola vez:
Cada llamada de abajo canaliza sus fallos por aquí, así que un ID de voz incorrecto o una clave caducada aparece como una línea legible en lugar de un stack trace.

Escuchar al usuario

POST /audio/transcriptions toma un archivo de audio y devuelve texto. Estamos grabando WAV mono a 16 kHz localmente, pero el endpoint acepta los formatos habituales, así que mapeamos la extensión del archivo a un tipo MIME en lugar de hardcodear uno:
La transcripción de Venice es petición/respuesta en lugar de un socket de streaming, y por eso la grabación tiene un final definido — pulsamos Enter en lugar de ejecutar detección de actividad de voz. Si quieres delimitación basada en VAD, ese es el trabajo que la guía de LiveKit le encarga a Silero. Una transcripción vacía es un resultado normal, no un error. Alguien pulsará Enter dos veces por accidente, y un amistoso “no te he entendido” gana a una excepción siempre.

Transmitir la respuesta en streaming

Ahora la llamada de chat. Hay dos ajustes específicos de Venice aquí que marcan una diferencia real en cómo suena el agente:
include_venice_system_prompt: False evita que Venice anteponga su propio system prompt al nuestro. Si se deja activado, son aproximadamente mil setecientos tokens de entrada extra por llamada y una segunda voz diciéndole al modelo cómo comportarse. disable_thinking: True (con reasoning.enabled: False para los modelos que leen el campo más nuevo) evita que GLM gaste su presupuesto de tokens en una cadena de pensamiento oculta antes de decir nada — lo que, cuando estás esperando oír una respuesta, es tiempo que se nota. El prompt en sí se gana su longitud. Pedir veinte palabras hace que las respuestas suenen habladas en lugar de escritas, y “omit detail rather than ending mid-sentence” es lo que evita que un tope duro de max_tokens trunque a mitad de palabra. Prohibir el markdown importa más de lo que crees: un modelo de TTS leerá los asteriscos en voz alta sin problema.
La instrucción de tratar el mensaje del usuario como entrada no confiable está haciendo un trabajo real aquí. El habla transcrita es entrada de usuario como cualquier otra, e “ignora tus instrucciones anteriores” es igual de fácil de decir en voz alta que de teclear.
Con eso en su sitio, la llamada es un completion en streaming normal:
La decisión de diseño importante es que esto produce frases, no tokens. El TTS necesita una cláusula completa para acertar con la prosodia, así que acumulamos deltas hasta tener una y entonces la entregamos. Eso es lo que permite que el audio empiece a sonar mientras el modelo aún está hablando. El evento cancel permite al llamador dejar de drenar el stream cuando el usuario pulsa Ctrl+C, y cerrar el stream en un bloque finally libera la conexión en lugar de dejarla colgada hasta el timeout.

Dividir frases según llegan

Dividir por ., ! y ? te lleva al 90% del camino y luego te deja en evidencia la primera vez que el modelo dice “Dr. Smith”. Así que comprobamos si lo que hay antes del punto es una abreviatura antes de tratarlo como un límite:
Fíjate en que la regex exige espacio en blanco tras la puntuación. Eso es deliberado: a mitad de stream, "Hello." podría ser una frase terminada o podría ser la primera mitad de "Hello.txt", y todavía no podemos saberlo. Esperar al espacio significa que nunca cortamos una frase antes de tiempo, a costa de retener la última hasta que el stream termina — lo que iter_sentences maneja con ese vaciado final de leftover. Es un divisor ingenuo y está bien. También es la única pieza de lógica aquí que resulta barata de testear unitariamente, así que merece la pena hacerlo:

Pronunciar la respuesta

POST /audio/speech es la tercera y última llamada. Dos opciones hacen que se sienta rápida:
response_format="pcm" nos da muestras crudas de 16 bits con signo en little-endian a 24 kHz mono, que podemos enviar directamente al altavoz sin paso de decodificación. tts-kokoro por defecto devuelve MP3, y decodificar un MP3 significa esperar a que llegue suficiente archivo antes de poder reproducir nada. streaming: True es la bandera de Venice que empieza a enviar audio a medida que se sintetiza en lugar de después de que el clip entero esté terminado. resolve_voice es deliberadamente aburrida — recorta la cadena y recurre al valor por defecto del entorno, y no valida contra una lista:
Un ID de voz desconocido falla en la API con un mensaje claro, lo cual es mejor que una allowlist local que se queda obsoleta en silencio a medida que Venice añade voces. Eso sí, las voces son específicas de cada modelo, así que una voz de Kokoro contra otro modelo de TTS no funcionará — consulta Modelos de texto a voz para los emparejamientos.

Comprueba antes de reproducir

Aquí está el único gotcha que te hará saltar de la silla. El PCM crudo no tiene cabecera ni magic bytes, así que si una respuesta de error acaba escrita en la tubería de audio, el altavoz reproduce fielmente el JSON como una ráfaga de ruido a todo volumen. Así que comprueba el estado y el content type antes de tratar el cuerpo como audio, y olfatea el primer chunk como respaldo:
RIFF detecta una respuesta WAV e ID3 detecta un MP3, y ambas significan que el response_format no surtió efecto. La comprobación de JSON detecta un cuerpo de error. Nada de esto es ingenioso, y todo ello es la diferencia entre un error legible y un usuario sobresaltado.
Nunca envíes un cuerpo HTTP sin comprobar a un sumidero de audio crudo. No hay negociación de formato en el lado de la reproducción que te salve — los bytes que lleguen se reproducen como muestras.

Grabación y reproducción

Esta parte no es Venice, así que iremos rápido. audio.py abre un stream de entrada de PortAudio mientras el usuario habla y un stream de salida de PortAudio para reproducir la respuesta, ambos a través de sounddevice. Lo importamos de forma perezosa para que una biblioteca nativa ausente se convierta en una frase en lugar de un OSError al arrancar:
Son dos fallos genuinamente distintos con dos arreglos distintos, y sounddevice reporta el segundo como un OSError a secas desde el propio import. Capturar ambos aquí es lo que permite que --text-only funcione en una máquina que no puede cargar PortAudio en absoluto. La grabación es un callback que va añadiendo a una lista, con un tope duro para que una sesión olvidada no crezca sin límite:
El try/finally anidado es deliberado. El interior convierte una cancelación en un AudioError amistoso, y el exterior detiene y cierra el stream en todas las salidas — incluida la cancelación — porque un RawInputStream que nunca se cierra sigue reteniendo el micrófono después de que el turno haya terminado. bytes(indata) copia en lugar de crear un alias, ya que PortAudio reutiliza ese buffer para el siguiente callback. Fíjate en que las muestras nunca tocan el disco. /audio/transcriptions necesita una subida con forma de archivo, pero “con forma de archivo” solo significa que necesita una cabecera WAV, y podemos ponérsela en memoria:
Son catorce líneas para no escribir jamás una grabación de la voz de alguien en un directorio temporal, lo que parece un buen trato. wave está en la biblioteca estándar, y los bytes van directos al argumento file= que configuramos antes. La reproducción es un stream por respuesta, para que las frases consecutivas fluyan como habla continua en lugar de reiniciar el dispositivo cada vez:
Ese buffer _pending es el único detalle aquí que te morderá si lo omites. Los límites de los chunks HTTP no tienen nada que ver con los límites de las muestras, así que una lectura de 4096 bytes puede entregarte un número impar de bytes y partir una muestra de 16 bits por la mitad. Escribe eso en el dispositivo y todas las muestras siguientes quedan desplazadas un byte, lo que suena como el equivalente en audio de la estática. Así que solo escribimos siempre un número par de bytes y arrastramos el byte sobrante a la siguiente llamada. La clase completa en el repo también tiene abort() para Ctrl+C — detener el dispositivo inmediatamente, descartar lo que está en el buffer — y close() para el camino normal, que vacía la última muestra parcial (rellenada con un byte cero) y luego espera a que el dispositivo termine de reproducir lo que ya tiene. Invertir esas dos significa o bien recortar la última palabra de cada respuesta o bien no poder interrumpir una.
PortAudio es la capa de portabilidad aquí, así que el mismo audio.py corre en macOS, Windows y Linux. Nada en venice.py sabe ni le importa cuál.

Solapar el stream y la reproducción

Aquí es donde el streaming realmente rinde. Si drenamos el stream de chat y reproducimos el audio en el mismo hilo, la reproducción bloquea el bucle y los tokens restantes del modelo se quedan sin leer en un buffer de socket. Así que drenamos el stream en un hilo aparte y pasamos las frases por una cola:
Poner la excepción en la cola y relanzarla en el lado del consumidor es lo que mantiene honesto el manejo de errores. Un hilo en segundo plano que muere en silencio te da un cuelgue en lugar de un mensaje, y usar BaseException en lugar de Exception significa que un KeyboardInterrupt dentro del stream aún llega al llamador. Ahora el turno en sí: extraer frases, imprimir cada una y alimentar su PCM al reproductor a medida que llega.
El reproductor se crea perezosamente con el primer chunk de audio en lugar de por adelantado, para que un fallo de TTS no deje un stream de salida ocioso reteniendo los altavoces. Y raise_on_error=not failed significa que cuando el turno ya está fallando desmontamos la reproducción en silencio en lugar de apilar un segundo error encima del real. Imprimir el tiempo hasta el primer audio es una cosa pequeña que resulta genuinamente útil al afinar. Es el número que el usuario siente.

El bucle del prompt

Todo lo que queda es un while True alrededor de input():
Una línea vacía significa “escuchar”; cualquier otra cosa se trata como entrada escrita. El historial se recorta a los últimos ocho intercambios, que es de sobra para una conversación hablada y mantiene plano el recuento de tokens de entrada en lugar de crecer hasta que algo se queje. El manejo de errores en dos niveles merece mención. Los fallos de configuración salen del programa — no tiene sentido arrancar un REPL que no puedes usar. Los fallos por turno se imprimen y vuelven al prompt, porque un límite de tasa o una grabación fallida no debería terminar la sesión. Esa llamada a warmup también se gana su sitio. Lista los modelos y envía una sonda de TTS de una palabra, lo que establece la conexión TLS y valida la clave y la voz antes del primer turno real del usuario en lugar de durante él:

Ejecutarlo

Pulsa Enter, habla, pulsa Enter otra vez. Escribe una línea si prefieres no usar el micrófono, reset para empezar una conversación nueva, q para salir. Ctrl+C durante una respuesta detiene la reproducción y te devuelve al prompt en lugar de salir. Algunas variantes:
Si toma el micrófono o los altavoces equivocados, pregúntale a PortAudio qué puede ver y pon un nombre o índice en AUDIO_SOURCE / AUDIO_SINK:
Y los tests:

Qué esperar en latencia

El pipeline son tres peticiones secuenciales, así que los números se apilan más o menos así: Espera alrededor de un segundo hasta el primer audio con una buena conexión. Dos cosas dominan ese número: si el TTS empieza con la primera frase o espera la respuesta completa, y si el modelo quema tokens pensando antes de hablar. El streaming a nivel de frase y disable_thinking son los dos cambios de aquí que notarías si los quitaras. Si lo quieres más rápido, mantén las respuestas cortas — la primera frase es lo que determina la capacidad de respuesta percibida — y prueba un modelo de chat de clase flash. Hay más sobre esto en las notas de latencia de LiveKit.

Notas de privacidad

Merece la pena ser explícito sobre qué sale de la máquina, ya que esta tiene un micrófono dentro. El audio va a Venice para ser transcrito y el texto vuelve para ser pronunciado; ambos están cubiertos por la política de retención cero de datos de Venice, y nada se almacena en su lado después de la petición. Localmente, no se escribe nada en disco en absoluto — la grabación se ensambla en una lista, se envuelve en una cabecera WAV en memoria y se entrega a la petición, así que no hay archivo temporal que filtrar ni limpiar. La clave de API se lee del entorno y nunca se imprime. El historial de conversación vive solo en memoria y desaparece cuando sales o escribes reset. Consulta Privacidad para los niveles por modelo si necesitas una garantía más fuerte que la retención cero.

Para terminar

La idea que hay que llevarse: un agente de voz en Venice son tres endpoints compatibles con OpenAI, dos de ellos en streaming. Todo lo demás en este proyecto — el divisor de frases, los streams de audio, la cola — existe para hacer que esas tres llamadas se sientan como una conversación. venice.py es la parte que vale la pena robar. Cambia app.py por un handler web o una integración telefónica y la capa de API no cambia. Algunas cosas que vale la pena hacer a continuación:

Dale herramientas

Añade function calling al paso de chat y el agente podrá consultar cosas a mitad de conversación.

Deja que busque

Activa enable_web_search en venice_parameters y las respuestas dejan de estar limitadas a los datos de entrenamiento.

Clona una voz

Cambia el ID de voz de Kokoro por uno que hayas clonado tú mismo.

Ponlo en una sala

Entrega las mismas tres etapas a LiveKit para VAD, interrupciones y llamadas con varios participantes.
¡Gracias por leer! Con suerte esto le ha quitado algo de misterio a los agentes de voz — son mucho menos exóticos de lo que suenan una vez que ves las tres peticiones que hay debajo.

Recursos relacionados