前置条件
录音和播放通过 sounddevice 完成,它封装了 PortAudio。uv sync 会安装这个 Python 包,在 Windows 上这就够了。macOS 和 Linux 还需要 PortAudio 库本身:
--text-only 参数,可以完全跳过麦克风、但仍然会用到聊天和 TTS,所以即便在一台完全没有音频硬件的机器上你也能跟着做。
我们要构建什么
一轮对话就是三次请求:
这些模型 ID 是一个起点而不是固定清单。Venice 会轮换模型目录,所以在发布任何东西之前,应该在运行时通过
GET /models?type=... 和 GET /models/traits 来解析它们。具体机制见弃用说明。
我们有意把源码树保持得很小:
venice.py 是你可以直接搬进 Web 应用、Discord 机器人或电话集成的部分。audio.py 是唯一关心它运行在什么机器上的文件,而且 Venice 完全看不到它——API 收到的只是进来的一个 WAV blob,交回的只是出去的原始 PCM。
环境搭建
创建项目并添加依赖。OpenAI SDK 负责所有 HTTP 工作,python-dotenv 让 key 不进入你的 shell 历史记录,sounddevice 与麦克风和扬声器打交道:
.env.example,让模型选择成为配置项,而不是埋在代码里的东西:
.env,然后粘贴你的 key。
让 SDK 指向 Venice
Venice 的 API 与 OpenAI 兼容,所以我们直接用官方openai client,只需改一下 base URL。整个集成就是这么多。创建 venice.py,从 client 开始:
os.environ["VENICE_API_KEY"] 直接抛异常。对于”漏配了一个 key”这样平常的事情,一个 KeyError traceback 是很糟糕的第一印象。
趁现在再做一件收尾工作。SDK 抛出的是 OpenAIError 的子类,而有用的细节埋在响应 body 里,所以值得一次性把它拆开:
听见用户
POST /audio/transcriptions 接收一个音频文件并返回文字。我们在本地录制的是 16 kHz 单声道 WAV,但这个 endpoint 接受各种常见格式,所以我们根据文件扩展名映射 MIME 类型,而不是硬编码一种:
流式生成回复
接下来是聊天调用。这里有两个 Venice 特有的设置,对智能体听起来的效果影响很大:include_venice_system_prompt: False 阻止 Venice 在我们的 system prompt 前面再拼上它自己的。如果不关掉,每次调用大约多出一千七百个输入 token,并且相当于有第二个声音在告诉模型该怎么表现。disable_thinking: True(配合 reasoning.enabled: False,供读取新字段的模型使用)阻止 GLM 在开口之前把 token 预算花在一段隐藏的思维链上——当你正等着听到回复时,那段时间是你能真切感受到的。
这个 prompt 本身的长度也物有所值。要求二十个词以内让回答听起来像口语而不是书面语,而”宁可省略细节也不要在句中戛然而止”正是防止硬性 max_tokens 上限把话截断在词中间的关键。禁用 markdown 比你想象的更重要:TTS 模型会毫不客气地把星号念出来。
把用户消息当作不可信输入的那条指令在这里承担着实实在在的工作。转写出的语音和其他用户输入没有任何区别,而”忽略你之前的指令”这句话,说出口和敲出来一样容易。
cancel 事件让调用方在用户按下 Ctrl+C 时停止消费流,而在 finally 块里关闭流则会释放连接,而不是把它晾在那里直到超时。
边到达边切分句子
按.、! 和 ? 切分能解决 90% 的情况,然后在模型第一次说出”Dr. Smith”时让你出丑。所以在把句号当作边界之前,我们先检查它前面的东西是不是一个缩写:
"Hello." 可能是一个说完的句子,也可能是 "Hello.txt" 的前半截,此刻我们无法分辨。等到空格出现意味着我们永远不会提前切断一个句子,代价是最后一句要等到流结束才能放出——这就由 iter_sentences 里最后那个 leftover 冲刷来处理。
这是一个朴素的切分器,但它够用了。它也是这里唯一一段单元测试成本很低的逻辑,所以值得测一下:
说出回复
POST /audio/speech 是第三个也是最后一个调用。有两个选项让它感觉很快:
response_format="pcm" 给我们的是 24 kHz 单声道、有符号 16 位小端的原始采样,可以不经任何解码步骤直接送进扬声器。否则 tts-kokoro 默认输出 MP3,而解码 MP3 意味着要等文件到达足够多之后才能播放任何内容。streaming: True 是 Venice 的开关,让音频在合成过程中就开始发送,而不是等整段音频做完。
resolve_voice 刻意写得很无趣——它只是修剪字符串并回退到环境变量默认值,不会去校验一份列表:
播放前先检查
这里有一个会让你从椅子上跳起来的坑。原始 PCM 没有文件头也没有 magic bytes,所以一旦一个错误响应被写进音频管道,扬声器会忠实地把那段 JSON 当成噪声以最大音量播放出来。 所以在把 body 当作音频之前先检查状态码和 content type,并把第一个 chunk 嗅探一遍作为兜底:RIFF 抓住的是 WAV 响应,ID3 抓住的是 MP3,两者都说明 response_format 没有生效。JSON 检查抓住的是错误 body。这些都不算聪明,但它们全部加起来,就是”一条可读的错误”和”一个被吓到的用户”之间的区别。
录音与播放
这部分和 Venice 无关,所以我们快速带过。audio.py 在用户说话时打开一个 PortAudio 输入流,播放回复时打开一个 PortAudio 输出流,两者都通过 sounddevice。
我们采用惰性导入,让缺失的原生库变成一句话,而不是启动时的一个 OSError:
sounddevice 把第二种报告为 import 本身抛出的裸 OSError。在这里把两者都捕获,正是让 --text-only 能在一台完全加载不了 PortAudio 的机器上工作的原因。
录音是一个不断往列表里追加数据的回调,外加一个硬性上限,防止一次被遗忘的录音会话无限增长:
try/finally 是有意的。里层的把取消操作变成一个友好的 AudioError,外层的确保无论从哪条路径退出——包括取消——都会停止并关闭流,因为一个从未被关闭的 RawInputStream 会在这一轮结束后继续占用麦克风。bytes(indata) 是复制而不是引用,因为 PortAudio 会为下一次回调复用那块缓冲区。
注意这些采样从不落盘。/audio/transcriptions 需要一个”文件形态”的上传,但”文件形态”仅仅意味着它需要一个 WAV 头,而我们可以在内存里给它加上:
wave 在标准库里,这些字节直接进入我们之前设置的 file= 参数。
播放是每条回复一个流,这样连续的句子会连成连贯的语音,而不是每句都重启一次设备:
_pending 缓冲区是这里唯一一个跳过就会挨咬的细节。HTTP chunk 的边界和采样边界毫无关系,所以一次 4096 字节的读取可能给你奇数个字节,把一个 16 位采样从中间劈开。把它写进设备后,后面每个采样都会错位一个字节,听起来就像音频版的雪花噪声。所以我们只写偶数个字节,把多出来的那个字节留到下次调用。
仓库里的完整类还有一个用于 Ctrl+C 的 abort()——立即停止设备、丢弃缓冲内容——以及用于正常路径的 close(),后者会把最后那个不完整的采样冲刷出去(补一个零字节),然后等待设备把已有内容播完。把这两个搞反,意味着要么每条回复的最后一个词都被剪掉,要么无法打断一条回复。
PortAudio 是这里的可移植层,所以同一份
audio.py 可以在 macOS、Windows 和 Linux 上运行。venice.py 里没有任何东西知道或关心到底是哪个。让流和播放重叠
这就是流式真正见效的地方。如果我们在同一个线程上消费聊天流并播放音频,播放会阻塞循环,模型剩余的 token 就滞留在 socket 缓冲区里没人读。所以我们在一个旁路线程上消费流,通过一个队列把句子递过来:BaseException 而非 Exception,意味着流内部的 KeyboardInterrupt 仍然能到达调用方。
接下来是这一轮对话本身:取出句子,逐句打印,音频一到就把它的 PCM 喂给播放器。
raise_on_error=not failed 意味着当这一轮已经在失败时,我们会安静地拆掉播放,而不是在真正的错误上再叠一个错误。
打印首个音频到达时间是个小事,但在调优时确实有用。那是用户能感受到的数字。
提示循环
剩下的一切就是围着input() 的一个 while True:
warmup 调用也物有所值。它会列出模型并发送一个单词的 TTS 探针,在用户第一轮真正的对话之前——而不是期间——建立好 TLS 连接并验证 key 和语音:
运行它
reset 开始新对话,输入 q 退出。回复途中按 Ctrl+C 会停止播放并回到提示符,而不是直接退出。
几个变体:
AUDIO_SOURCE / AUDIO_SINK:
延迟方面该有什么预期
这条管线是三个顺序请求,所以数字大致这样叠加:
在良好的网络下,到首个音频大约一秒左右。有两件事主导这个数字:TTS 是从第一句就开始还是等整条回复,以及模型在开口前会不会把 token 烧在思考上。句级流式和
disable_thinking 是这里两个一旦去掉你就会察觉的改动。
想再快一些,就让回复保持简短——第一句才是感知响应速度的瓶颈——并试试 flash 级别的聊天模型。更多内容见 LiveKit 延迟笔记。
隐私说明
有必要明确说说什么东西离开了这台机器,毕竟这个应用里有一个麦克风。 音频发到 Venice 做转写,文字返回来被念出;两者都受 Venice 零数据保留政策的保护,请求结束后他们那边不会存储任何内容。在本地,任何东西都不会写入磁盘——录音在一个列表里组装、在内存中裹上 WAV 头、直接交给请求,所以不存在会泄露或需要清理的临时文件。API key 从环境变量读取,从不打印。对话历史只存在于内存中,退出或输入reset 后即消失。
如果你需要比零保留更强的保证,各模型的隐私层级见隐私。
收尾
要带走的结论是:Venice 上的语音智能体就是三个 OpenAI 兼容的 endpoint,其中两个是流式的。这个项目里的其他一切——句子切分器、音频流、队列——存在的意义都是让这三次调用感觉像一场对话。venice.py 是值得直接拿走的那部分。把 app.py 换成一个 Web handler 或电话集成,API 层不需要任何改动。
值得接着做的一些事:
给它工具
在聊天这一步加上 function calling,智能体就能在对话途中查东西。
让它搜索
在
venice_parameters 里设置 enable_web_search,回答就不再局限于训练数据。克隆一个声音
把 Kokoro 的 voice ID 换成你自己克隆的那个。
把它放进房间
把同样的三个阶段交给 LiveKit,获得 VAD、插话打断和多人通话。