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
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:
--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:
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:
.env.example para que las elecciones de modelo sean configuración en lugar de algo enterrado en el código:
.env y pega tu clave dentro.
Apuntar el SDK a Venice
La API de Venice es compatible con OpenAI, así que usamos el cliente oficialopenai y cambiamos la URL base. Esa es toda la integración. Crea venice.py y empieza con el cliente:
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:
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:
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.
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:
"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:
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.
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:
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:
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:
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:
_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: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.
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 unwhile True alrededor de input():
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
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:
AUDIO_SOURCE / AUDIO_SINK:
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 escribesreset.
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.