Pre-requisites
- Python 3.11 or newer, and uv
- A Venice API key from venice.ai
- A microphone and speakers, if you want the full voice loop
uv sync installs the Python package, and on Windows that’s all you need. macOS and Linux want the PortAudio library too:
--text-only flag that skips the mic entirely and still exercises chat and TTS, so you can follow along on a box with no audio hardware at all.
What We’re Building
One turn of conversation is three requests:
Those model IDs are a starting point rather than a fixed list. Venice rotates the catalog, so resolve them at runtime from
GET /models?type=... and GET /models/traits before you ship anything. See Deprecations for how that plays out.
We’ll keep the source tree small on purpose:
venice.py is the part you can lift straight into a web app, a Discord bot, or a phone integration. audio.py is the only file that cares what machine it’s running on, and Venice never sees any of it — the API only ever receives a WAV blob on the way in and hands back raw PCM on the way out.
Setting Up
Create the project and add the dependencies. The OpenAI SDK does all the HTTP work,python-dotenv keeps the key out of your shell history, and sounddevice talks to the mic and speakers:
.env.example so the model choices are configuration rather than something buried in the code:
.env and paste your key in.
Pointing the SDK at Venice
Venice’s API is OpenAI-compatible, so we use the officialopenai client and change the base URL. That’s the whole integration. Create venice.py and start with the client:
os.environ["VENICE_API_KEY"] throw. A KeyError traceback is a bad first experience for something as ordinary as a missing key.
One more piece of housekeeping while we’re here. The SDK raises OpenAIError subclasses, and the useful detail is buried in the response body, so it’s worth unwrapping once:
Hearing the User
POST /audio/transcriptions takes an audio file and returns text. We’re recording 16 kHz mono WAV locally, but the endpoint accepts the usual formats, so we map the file extension to a MIME type rather than hardcoding one:
Streaming the Reply
Now the chat call. There are two Venice-specific settings here that make a real difference to how the agent sounds:include_venice_system_prompt: False stops Venice prepending its own system prompt to ours. Left on, it’s roughly seventeen hundred extra input tokens per call and a second voice telling the model how to behave. disable_thinking: True (with reasoning.enabled: False for models that read the newer field) stops GLM spending its token budget on a hidden chain of thought before it says anything — which, when you’re waiting to hear a reply, is time you can hear.
The prompt itself earns its length. Asking for twenty words keeps answers sounding spoken rather than written, and “omit detail rather than ending mid-sentence” is what stops a hard max_tokens cap from truncating mid-word. Banning markdown matters more than you’d think: a TTS model will happily read asterisks aloud.
The instruction to treat the user’s message as untrusted is doing real work here. Transcribed speech is user input like any other, and “ignore your previous instructions” is just as easy to say out loud as it is to type.
cancel event lets the caller stop draining the stream when the user hits Ctrl+C, and closing the stream in a finally block releases the connection instead of leaving it hanging until the timeout.
Splitting Sentences As They Arrive
Splitting on., !, and ? gets you 90% of the way there and then embarrasses you the first time the model says “Dr. Smith”. So we check whether the thing before the full stop is an abbreviation before treating it as a boundary:
"Hello." might be a finished sentence or it might be the first half of "Hello.txt", and we can’t tell yet. Waiting for the space means we never cut a sentence early, at the cost of holding the last one until the stream ends — which iter_sentences handles with that final leftover flush.
This is a naive splitter and it’s fine. It’s also the one piece of logic here that’s cheap to unit test, so it’s worth doing:
Speaking the Reply
POST /audio/speech is the third and last call. Two options make it feel fast:
response_format="pcm" gives us raw signed 16-bit little-endian samples at 24 kHz mono, which we can pipe straight to the speaker with no decode step. tts-kokoro otherwise defaults to MP3, and decoding an MP3 means waiting for enough of the file to arrive before you can play any of it. streaming: True is the Venice flag that starts sending audio as it’s synthesized instead of after the whole clip is done.
resolve_voice is deliberately dull — it trims the string and falls back to the environment default, and does not validate against a list:
Check Before You Play
Here’s the one gotcha that will make you jump out of your chair. Raw PCM has no header and no magic bytes, so if an error response gets written into the audio pipe, the speaker faithfully plays the JSON as a burst of noise at full volume. So check the status and content type before you treat the body as audio, and sniff the first chunk as a backstop:RIFF catches a WAV response and ID3 catches an MP3, both of which mean the response_format didn’t take effect. The JSON check catches an error body. None of this is clever, and all of it is the difference between a readable error and a startled user.
Recording and Playback
This part is not Venice, so we’ll move quickly.audio.py opens a PortAudio input stream while the user talks and a PortAudio output stream to play the reply, both through sounddevice.
We import it lazily so that a missing native library becomes a sentence rather than an OSError at startup:
sounddevice reports the second one as a bare OSError from the import itself. Catching both here is what lets --text-only work on a machine that can’t load PortAudio at all.
Recording is a callback that appends into a list, with a hard cap so a forgotten session doesn’t grow without limit:
try/finally is deliberate. The inner one turns a cancel into a friendly AudioError, and the outer one stops and closes the stream on every path out — including cancellation — because a RawInputStream that never gets closed keeps holding the microphone after the turn is over. bytes(indata) copies rather than aliases, since PortAudio reuses that buffer for the next callback.
Note that the samples never touch the disk. /audio/transcriptions needs a file-shaped upload, but “file-shaped” only means it needs a WAV header, and we can put one on in memory:
wave is in the standard library, and the bytes go straight to the file= argument we set up earlier.
Playback is one stream per reply, so consecutive sentences run together as continuous speech instead of restarting the device each time:
_pending buffer is the one detail here that will bite you if you skip it. HTTP chunk boundaries have nothing to do with sample boundaries, so a 4096-byte read can hand you an odd number of bytes and split a 16-bit sample down the middle. Write that to the device and every subsequent sample is byte-shifted, which sounds like the audio equivalent of static. So we only ever write an even number of bytes and carry the spare byte into the next call.
The full class in the repo also has abort() for Ctrl+C — stop the device immediately, discard what’s buffered — and close() for the normal path, which flushes the last partial sample (padded with a zero byte) and then waits for the device to finish playing what it already has. Getting those two backwards means either clipping the last word off every reply or being unable to interrupt one.
PortAudio is the portability layer here, so the same
audio.py runs on macOS, Windows, and Linux. Nothing in venice.py knows or cares which.Overlapping the Stream and the Playback
Here’s where the streaming actually pays off. If we drain the chat stream and play audio on the same thread, playback blocks the loop and the model’s remaining tokens sit unread in a socket buffer. So we drain the stream on a side thread and hand sentences over a queue:BaseException rather than Exception means a KeyboardInterrupt inside the stream still reaches the caller.
Now the turn itself: pull sentences, print each one, and feed its PCM to the player as it arrives.
raise_on_error=not failed means that when the turn is already failing we tear playback down quietly instead of stacking a second error on top of the real one.
Printing time-to-first-audio is a small thing that’s genuinely useful while tuning. It’s the number the user feels.
The Prompt Loop
Everything left is awhile True around input():
warmup call earns its keep too. It lists models and sends a one-word TTS probe, which establishes the TLS connection and validates the key and the voice before the user’s first real turn rather than during it:
Running It
reset to start a new conversation, q to quit. Ctrl+C during a reply stops playback and drops you back at the prompt rather than exiting.
A few variations:
AUDIO_SOURCE / AUDIO_SINK:
What to Expect on Latency
The pipeline is three sequential requests, so the numbers stack roughly like this:
Expect somewhere around a second to first audio on a good connection. Two things dominate that number: whether TTS starts on the first sentence or waits for the whole reply, and whether the model burns tokens thinking before it talks. Sentence-level streaming and
disable_thinking are the two changes here that you’d notice if you removed them.
If you want it faster, keep replies short — the first sentence is what gates perceived responsiveness — and try a flash-class chat model. There’s more on this in the LiveKit latency notes.
Privacy Notes
Worth being explicit about what leaves the machine, since this one has a microphone in it. Audio goes to Venice to be transcribed and text comes back to be spoken; both are covered by Venice’s zero data retention policy, and nothing is stored on their side after the request. Locally, nothing is written to disk at all — the recording is assembled in a list, wrapped in a WAV header in memory, and handed to the request, so there’s no temp file to leak or clean up. The API key is read from the environment and never printed. Conversation history lives in memory only and disappears when you quit or typereset.
See Privacy for the per-model tiers if you need a stronger guarantee than zero retention.
Finishing Up
The thing to take away: a voice agent on Venice is three OpenAI-compatible endpoints, two of them streamed. Everything else in this project — the sentence splitter, the audio streams, the queue — exists to make those three calls feel like a conversation.venice.py is the part worth stealing. Swap app.py for a web handler or a phone integration and the API layer doesn’t change.
Some things worth doing next:
Give it tools
Add function calling to the chat step and the agent can look things up mid-conversation.
Let it search
Set
enable_web_search in venice_parameters and answers stop being limited to training data.Clone a voice
Swap the Kokoro voice ID for one you cloned yourself.
Put it in a room
Hand the same three stages to LiveKit for VAD, barge-in, and multi-participant calls.