目录
- 引子
- 声音在计算机里长什么样?
- [sounddevice:更现代的 "Pythonic" 选择](#sounddevice:更现代的 “Pythonic” 选择)
-
- 声音数据到底长什么样?
- 最简单的录音:固定时长模式
-
- 补充:播放函数:`sd.play()`
- [补充:`scipy.io.wavfile` vs `soundfile` ------ 音频文件读写库怎么选?](#补充:
scipy.io.wavfilevssoundfile—— 音频文件读写库怎么选?)
- 实时流模式录音
- 语音活动检测(VAD)------让程序"听"懂静音与说话
-
- PyTorch从零搭建环境
- [Silero VAD:一个开箱即用的专业方案](#Silero VAD:一个开箱即用的专业方案)
- [Demo:使用 Silero VAD 实时录音](#Demo:使用 Silero VAD 实时录音)
- [语音转文字 ------ Whisper 与 faster-whisper](#语音转文字 —— Whisper 与 faster-whisper)
-
- [faster-whisper:Whisper 的"加速版"](#faster-whisper:Whisper 的“加速版”)
- [把 VAD + faster-whisper 串起来](#把 VAD + faster-whisper 串起来)
- 让程序"开口说话"------接入大模型与语音合成
-
- 大模型选择:用什么?
- [从 `pyttsx3` 到 `RealtimeTTS`:一次 TTS 方案的升级](#从
pyttsx3到RealtimeTTS:一次 TTS 方案的升级) - [完整闭环:VAD → Whisper → LLM → TTS](#完整闭环:VAD → Whisper → LLM → TTS)
- "回声"问题
- 写在最后
引子
在 AI 应用里,语音交互正在从"锦上添花"变成"标配功能"。但很多教程一上来就推付费 API 或者重量级框架,让想自己动手的人望而却步。
这次我们换个玩法:全部用开源或免费方案。
- 用
sounddevice接管麦克风,实现实时录音。 - 用
silero-vad判断你什么时候开始说话、什么时候说完。 - 用
faster-whisper本地把语音转成文字(不需要联网,不需要 API Key)。 - 用
pyttsx3合成自然语音(本地,免费)。
一套组合拳打下来,你也能在自己的电脑上养出一个"小爱同学"的雏形。全程代码不超过 200 行,读完就能跑。
声音在计算机里长什么样?
在开始写代码之前,我们先花几分钟搞清楚一个核心问题:声音在计算机里到底是怎么被表示和存储的?
如果你之前没接触过音频处理,可能会觉得"不就是个 .wav 文件吗?"。但当我们开始用代码操作麦克风、调整采样率、理解 VAD 为什么对 16kHz 有要求时,这些基础概念就会变得至关重要。
现实世界的声音是连续的模拟信号 ------空气振动传播到你的耳朵。但计算机只能处理离散的数字信号 ,所以我们需要一个"翻译"过程:模数转换(ADC)。
这个过程有两个关键参数:
采样率(Sample Rate)

赫兹,是国际单位制中频率的单位,它是每秒钟的周期性变动重复次数的计量。
赫兹简称赫。每秒钟振动(或振荡、波动)一次为1赫兹,或可写成次/秒,周/秒。因德国科学家赫兹而命名。
采样率决定了每秒钟从模拟信号中采集多少个"快照"。单位是 Hz(赫兹)。
- 16,000 Hz(16kHz):每秒钟采集 16000 个样本点。这是语音处理中最常用的采样率(Whisper、VAD 都默认支持)。
- 44,100 Hz(44.1kHz):CD 音质标准,适合音乐。
- 48,000 Hz(48kHz):专业音频/视频制作标准。
❓ 一个常见的困惑:为什么采样率的单位也是赫兹?
敏锐的你可能会发现一个问题:声音本身的频率单位是赫兹(Hz),采样率的单位也是赫兹(Hz)。那这两者到底有什么区别?
它们虽然用了同一个单位,但描述的对象完全不同:
- 声音的频率(声波的频率) :描述的是声波本身振动的快慢 。比如,一个 440 Hz 的声音,意味着声源每秒钟振动 440 次,你的耳朵听到的就是"中央C"这个音高。这是模拟世界的属性。
- 采样率(采样的频率) :描述的是计算机"偷看"这个声音的快慢 。比如,16kHz 的采样率,意味着计算机每秒钟对连续的声波"拍 16000 张快照"。这是数字世界的操作。
用一个类比来理解:
想象一辆赛车正在赛道上飞速行驶。赛车本身的速度(比如 300 km/h)对应的是声音的频率 ------这是赛车(声波)自身的属性。而跑道边的高速摄像机每秒能拍多少张照片(比如每秒 1000 帧)对应的是采样率------这是记录设备(计算机)的行为。
赛车快不快,和相机拍得快不快,是两码事。它们虽然都会用到"每秒多少次"这个描述方式(因为本质上都是在描述"频率"这个概念),但对象完全不同。
再看一个更技术化的例子:
假设有一个 1000 Hz 的正弦波(声音频率),意味着它每秒完成 1000 次振动。如果我们用 8000 Hz 的采样率去录制它,意味着计算机每秒采集 8000 个样本点。1000 Hz 描述的是波本身的周期 ,8000 Hz 描述的是采样动作的周期。
而奈奎斯特定理(下面会提到)恰好把这两者联系了起来:要录制一个 1000 Hz 的声音,采样率至少需要 2000 Hz。也就是说,只有采样频率 ≥ 2 × 声音频率,你才能"抓住"这个声音的完整形态。低于 2 倍,声音就会被"拍糊"(混叠失真)。
所以,这两个"赫兹"不是同一个东西,但通过奈奎斯特定理,它们有了明确的数学关系。
根据奈奎斯特采样定理(Nyquist Theorem),要无损还原一个频率为 f 的信号,采样率至少需要是 2f。人类语音的主要频率范围在 300 Hz ~ 3400 Hz 之间(电话质量),所以 8kHz 理论上就够。16kHz 则提供了足够的余量,同时文件大小和计算开销都比 44.1kHz 小得多。
你可以这样理解:
采样率就像你用多快的快门拍连续的动作。拍日常对话,16 帧/秒就够看清了;拍高速赛车,得用 120 帧/秒。但代价是------帧率越高,文件越大,处理越慢。
位深度(Bit Depth)
位深度决定了每个样本点的精度------即声音的"细腻程度"。
- 16-bit:每个样本用 16 个二进制位表示,取值范围是 -32768 ~ 32767。这是最常见的音频格式(CD 标准)。
- 8-bit:取值范围是 -128 ~ 127,音质差,有可闻的量化噪声。
- 32-bit float :用浮点数表示音频,取值范围通常是 -1.0 ~ 1.0。Python 的
sounddevice录音默认就是float32格式,方便后续计算。
声音的本质是空气压强的波动 ------声源振动导致空气分子被周期性地压缩和稀疏,形成疏密相间的波。麦克风里有一层振膜,声波的压力变化让振膜随之振动,振膜的位置变化被转换成电压的变化 。所以,麦克风输出的是一段随时间变化的电压信号。
采样取的就是:在某个极短的时间点上,这个电压的瞬时值是多少。
如果把声音看成一个波,那么声音的波形图里,横轴是时间,纵轴是振幅(也就是电压值)。
采样,就是在横轴上每隔固定的时间间隔"戳"一个点,记录这个点对应的纵坐标数值。
这就是采样率的意义:横轴上点与点的距离有多远。
16kHz 采样,意味着横轴上每 1/16000 秒就戳一个点。
位深度决定了记录这个纵坐标数值时,精度有多高。
继续用波形的横纵坐标来理解:
- 横轴(时间)的精度由采样率决定:点越密,时间越精确。
- 纵轴(振幅)的精度由位深度决定:数值分得越细,振幅越精确。
位深度决定了纵轴有多少个"刻度"。麦克风振膜振动的范围是有限的(比如 -1V ~ +1V),位深度就是把这段电压范围切成多少个等份。
具体来说:
- 8-bit:用 8 个二进制位表示一个数值,共有 2⁸ = 256 个刻度。把 -1V ~ +1V 切成 256 份,每一份大约是 0.0078V。这意味着,你的电压值只能落在这 256 个整数刻度上,实际振膜位置和记录位置之间可能有最多 0.0039V 的误差(四舍五入的舍入误差)。
- 16-bit:共有 2¹⁶ = 65536 个刻度,每一份是 0.00003V。误差比 8-bit 小了 256 倍。
- 32-bit float :本质上还是 32 个二进制位,但它用浮点数格式 存储,动态范围更大,且能表示小数。Python 的
sounddevice用float32,意味着它把振幅归一化到 -1.0 ~ 1.0 之间,也就是把整个电压范围映射到这个区间,然后用浮点数精确表示。虽然是 32 位,但因为是浮点数,相比 32-bit 整数,它牺牲了一点点精度,换来了极大的动态范围和灵活性(比如后续的音频处理算法不需要担心溢出)。
想象一个正弦波:
振幅
↑
1| /\ /\
| / \ / \
0| / \ / \
|/ \/ \
-1|________________→ 时间
- 采样率:决定了横轴上你"戳"了多少个点(比如 16 个点/秒)。
- 位深度:决定了纵轴上你"测量"这个点的高度时,能用多精确的尺子。16-bit 的尺子有 65536 个刻度,8-bit 的尺子只有 256 个刻度。
为什么说 8-bit 有"可闻的量化噪声"?
因为实际振膜的位置是连续的(可以是任何值),但 8-bit 记录时只能选择最近的整数刻度。这个"四舍五入"的过程会引入误差,这个误差本身就是噪声。
如果把 8-bit 的音频放大听,你会听到类似"嘶嘶"的底噪。那不是环境噪音,而是量化过程中人为引入的误差。
而 16-bit 的刻度足够细,这个误差小到人耳几乎听不到,所以听起来就干净了。
直观类比:
位深度像是用多少种颜色来画一幅画。16-bit 有 65536 个"音量刻度",8-bit 只有 256 个。刻度越细,声音的细节保留越多。
📌 最后,附加一张速查表,等后面的章节我们开始写代码时,你会反复用到这张表:
| 概念 | 单位 | 本项目的推荐值 | 为什么 |
|---|---|---|---|
| 声音频率 | Hz | 300~3400 Hz(人声范围) | 人类语音的主要频段 |
| 采样率 | Hz | 16000 Hz | 够覆盖人声 + 余量,VAD/Whisper 都支持 |
| 位深度 | bit | 16-bit 或 float32 | 音质够用,标准格式支持好 |
| 声道 | - | 1(单声道) | Whisper 要求单声道输入 |
声道
声道(Channel) 指的是同时录制的音频信号的数量。
当我们在麦克风阵列里放一个麦克风,录到的信号是单声道(1 channel)。放两个麦克风,录到的信号是立体声(2 channels)。每个麦克风各自产生一路独立的音频流,每一路的采样率和位深度都是一样的,但振幅值各自独立------因为它们摆放在不同的物理位置,捡到的声音细节不同。
回到"波形图"模型:
- 横轴:时间(由采样率决定采样点的密度)。
- 纵轴:振幅(由位深度决定精度)。
- 深度 / 层数 :声道数。单声道就是一层波形;立体声就是两层波形叠在一起,左声道和右声道可以完全独立变化。
为什么立体声能"听出方位"?
人类的两个耳朵长在左右两侧,声源到左耳和右耳的距离不同,导致声音到达两耳的时间有细微的差异(双耳时差 ),同时头部的遮挡也会让声音在两耳的强度不同(双耳强度差)。大脑通过这两个差来计算声源的方向。
立体声录音就是模仿这个原理:用两只麦克风摆在不同位置,分别录制左、右两个声道的声音。播放时,左声道的声音送到左耳机,右声道的声音送到右耳机,你的耳朵就会产生"声音从那边传来的"空间感。
单声道录音只用一只麦克风,你听到的是声音的"总和",无法分辨声源方向。
音频文件里,样本并不是按"左、右、左、右..."顺序排列的,而是每个采样时刻会保存所有声道的数据。
比如 16kHz、立体声的 WAV 文件:
时刻 1: 左声道样本 1, 右声道样本 1
时刻 2: 左声道样本 2, 右声道样本 2
时刻 3: 左声道样本 3, 右声道样本 3
...
每个样本都是按位深度存储的独立数值。所以:
- 单声道 5 秒 = 16000 × 5 = 80000 个样本
- 立体声 5 秒 = 16000 × 5 × 2 = 160000 个样本(翻倍)
如果你家里有那种"降噪耳机"或"环绕音箱",它们往往会有多个物理扬声器(5.1、7.1 声道),每个声道对应一个独立的音频流,播放时分配到对应的扬声器位置,形成全方位的空间音频效果。这也是一样的道理------声道数 = 同时录制的独立音频流数量。
所以,
声音可以是单声道(Mono)或立体声(Stereo)。
- 单声道(1 channel):只有一个音频轨道,适合语音识别(Whisper 默认要求单声道输入)。
- 立体声(2 channels):左右两个轨道,用于音乐和沉浸式音频。
对于语音助手,始终用单声道。这不仅是 Whisper 的要求,也能让你的录音文件体积减半。
常见音频格式
采样率和位深度决定了原始音频数据的大小。比如一段 5 秒的 16kHz、16-bit 单声道音频:
数据量 = 采样率 × 位深度 × 声道数 × 时长
= 16000 × 2 字节 × 1 × 5 = 160,000 字节 ≈ 156 KB
这只是原始 PCM 数据,实际存储时还需要封装成文件格式:
| 格式 | 特点 | 适用场景 |
|---|---|---|
| WAV | 无损,直接存储原始 PCM 数据(或压缩的 LPCM),文件较大。 | 录音、音频编辑、Whisper 输入。 |
| MP3 | 有损压缩,文件小,音质有损失。 | 音乐存储、网络传输。 |
| FLAC | 无损压缩,文件比 WAV 小,但保持完整音质。 | 高保真音频存档。 |
| OGG / Opus | 开源有损压缩,流媒体优化。 | 实时通信(如 Discord、WebRTC)。 |
我们之后用 sounddevice + scipy.io.wavfile.write 保存的就是 WAV 文件,这是最直接、最兼容的格式。
在接下来的章节里,你会发现:
- 配置
sounddevice录音时,你必须指定 采样率 和 声道数。 - 安装
webrtcvad或silero-vad时,它们要求输入必须是 16kHz 单声道 16-bit PCM。 - 使用
whisper或faster-whisper时,如果你的音频采样率不是 16kHz,它会自动重采样,但这也意味着额外的计算开销。
提前理解这些参数,你就能在后续踩坑时迅速定位问题,而不是盲目试错。
你提到的"py那个啥库",应该就是 PyAudio。这两个库是目前 Python 生态里最主流的音频 I/O 方案,它们底层都依赖 PortAudio,但设计思路和用户体验完全不同。
sounddevice:更现代的 "Pythonic" 选择
sounddevice 是一个较新的库,设计上更贴合 Python 开发者的习惯。
- 更简洁、更 Pythonic 的 API :它的接口设计非常直观,比如直接用
sd.play()播放、sd.rec()录音,代码写起来很顺畅。 - 与 NumPy 无缝集成:音频数据直接以 NumPy 数组形式处理,这正好和我们上一章聊到的音频在计算机里就是"采样点数值数组"的概念对应上了。
- 安装更省心 :在 Windows 和 macOS 上,
sounddevice的安装包(wheel)已经自带了 PortAudio 的二进制文件,装完就能用,省去了单独编译的麻烦。 - 性能优秀 :在需要低于 200 毫秒延迟的低延迟场景下表现出色。
- 更新活跃:项目维护更新比较频繁,能较好地适配新的 Python 版本和系统架构。
架构i上,sounddevice 是 Python 对 PortAudio 的封装。
bash
Python
|
|
sounddevice
|
|
PortAudio(C库)
|
|
Windows WASAPI
Linux ALSA
macOS CoreAudio
|
|
麦克风 / 扬声器
也就是说import sounddevice最后调用的是 C 层,所以它性能很好。
而,PyAudio 是更早、更经典的库,它提供的 API 更接近 PortAudio 本身的 C 语言接口。
- 更贴近底层的控制:它提供了更细粒度的配置选项,适合需要进行复杂音频流控制或对底层有特殊要求的场景。
- 生态成熟,文档丰富:作为"老大哥",网上有大量关于 PyAudio 的资料和示例代码。
- 安装稍显复杂 :它没有捆绑 PortAudio,因此通常需要你自行在系统上安装 PortAudio 才能正常安装和使用。
- 更新较慢 :项目维护不如
sounddevice活跃,最后一次大版本更新在 2017 年,在某些新系统(如 ARM64 架构)上可能存在兼容性问题。
两者的核心差异可以总结如下:
| 特性 | sounddevice |
PyAudio |
|---|---|---|
| API 设计 | 高级、Pythonic、简洁 | 低级、贴近 C 语言、更复杂 |
| 安装难度 | 简单(Windows/Mac 自带 PortAudio) | 较复杂(需自行安装 PortAudio) |
| 数据处理 | 原生支持 NumPy 数组 | 主要处理字节流(bytes) |
| 延迟性能 | 优秀(适合 <200ms 场景) | 一般(适合 >500ms 场景) |
| 维护状态 | 活跃,持续更新 | 较缓慢,上次大版本更新在 2017 年 |
我建议选择 sounddevice。因为它更 Pythonic、安装方便,并且和 NumPy 的紧密结合,能让代码更简洁,也更符合我们后续处理音频数据的思路。
安装:
bash
pip install sounddevice
测试:
安装完成后,先用这个命令确认你的电脑能找到麦克风设备:
python
import sounddevice as sd
print(sd.query_devices())
输出示例:
0 Microphone Array (Realtek), MME
1 Speaker (Realtek), MME
2 NVIDIA Broadcast, Windows DirectSound
...
如果能看到至少一个输入设备(带 Microphone 字样的),说明安装成功了。

不过,如果你在自己的电脑上运行这段代码,可能会看到类似这样的输出:
0 Microsoft 声音映射器 - Input, MME (2 in, 0 out)
> 1 麦克风阵列 (Realtek(R) Audio), MME (2 in, 0 out)
2 Microsoft 声音映射器 - Output, MME (0 in, 2 out)
< 3 扬声器 (Realtek(R) Audio), MME (0 in, 8 out)
4 主声音捕获驱动程序, Windows DirectSound (2 in, 0 out)
...
注意第 1 行前面有一个 >,第 3 行前面有一个 <。这两个符号是什么意思?
| 符号 | 含义 | 解释 |
|---|---|---|
> |
默认输入设备 | sounddevice 录音时默认使用的麦克风 |
< |
默认输出设备 | sounddevice 播放声音时默认使用的扬声器 |
也就是说,当你调用 sd.rec() 录音时,实际上是用 > 标记的那个设备。
如果某个设备同时是默认输入和输出(比如 USB 耳麦),它会显示为 > < 两个箭头。
那么问题来了:我的电脑上设备列表这么长,为什么偏偏是这一行被标记为默认?
因为 sounddevice 会直接读取操作系统的系统默认音频设备设置:
- Windows:来自"声音控制面板"里设置的"默认设备"。
- macOS:来自"系统偏好设置 → 声音 → 输入/输出"。
- Linux:来自 PulseAudio 或 ALSA 的默认配置。
所以,如果你在 Windows 的"声音设置"里把"耳机"设为默认麦克风,> 就会出现在耳机对应的那一行前面。
如果我的电脑没有 > 怎么办?
有时候,某些配置下 query_devices() 可能不显示箭头。这通常意味着系统没有明确设置默认设备,或者 sounddevice 无法正确识别。
这时你有两个选择:
-
手动指定设备:通过设备索引号(中括号里的数字)来指定。
python# 用索引号指定输入设备,比如 1 号是"麦克风阵列" sd.default.device = 1 # 或者分别指定输入和输出 sd.default.device = (1, 3) # (输入, 输出) -
用名字匹配:如果你知道设备名称的一部分,可以用循环查找。
pythonimport sounddevice as sd devices = sd.query_devices() for i, dev in enumerate(devices): if "麦克风" in dev['name']: sd.default.device = i print(f"已切换到设备 {i}: {dev['name']}") break
一个常见的翻车场景:选错设备
很多人在第一步测试 sd.rec() 时,录出来的音频全是静音或白噪音,原因往往不是代码写错了,而是 sounddevice 默认用了错误的设备。
比如,有些 Windows 电脑会把"立体声混音"(Stereo Mix)设为默认输入设备。这个东西录的是电脑内部正在播放的声音,而不是你对着麦克风说的话。所以你对着麦克风喊半天,录到的却是空白的系统音频流。
解决方法很简单:用 sd.query_devices() 找到带"Microphone"字样的设备索引,然后手动指定。
python
import sounddevice as sd
# 手动指定麦克风设备(假设索引是 1)
sd.default.device = 1
# 然后正常录音即可
audio = sd.rec(...)
这个坑我在第一次接触 sounddevice 时踩过,希望你读到这篇时能顺利跳过。😊
声音数据到底长什么样?
这是一个非常关键的概念,很多初学者会在这一步产生困惑。
麦克风返回的不是文本,不是语音内容,而是一连串的电压采样值。
比如,麦克风不会直接返回:
python
"你好"
它返回的是这样的数据:
python
# 某个时刻的一段音频数据
array([
0.001,
0.002,
-0.003,
0.005,
-0.001,
0.008,
...
], dtype=float32)
每一个数字代表在某一瞬间,麦克风振膜所在的位置(电压值)。
把这些数字按照时间顺序连起来,就形成了声波的波形。
在 sounddevice 中,音频数据的默认格式是:
| 属性 | 值 |
|---|---|
| 数据类型 | float32 |
| 取值范围 | -1.0 ~ 1.0 |
这个取值范围对应的是电压的归一化值,后面我们处理音频时经常会用到。
在写代码之前,我们先回顾一下上一章讲过的那几个参数,它们会在 sounddevice 的 API 里直接出现:
采样率(Sample Rate)
python
samplerate = 16000 # 16kHz
这表示每秒钟采集 16000 个样本点。语音识别用 16kHz 就足够了。
声道数(Channels)
python
channels = 1 # 单声道
语音识别用单声道即可,立体声只会让数据量翻倍,对识别内容没有帮助。
最简单的录音:固定时长模式
我们先从最简单的开始:录一段固定时长的音频(比如 5 秒),保存成 WAV 文件。
python
import sounddevice as sd
from scipy.io.wavfile import write
samplerate = 16000
duration = 5 # 秒
# 开始录音
audio = sd.rec(
int(duration * samplerate), # 总共采集多少个样本点
samplerate=samplerate,
channels=1,
dtype="float32"
)
# 等待录音完成(阻塞)
sd.wait()
# 保存为 WAV 文件
write("test.wav", samplerate, audio)
执行流程:
- 调用
sd.rec(),后台开始录音 sd.wait()会阻塞,直到录制完成为止- 返回一个 NumPy 数组,形状为
(80000, 1)------80000 个样本点(5 秒 × 16000 Hz),1 个声道 scipy.io.wavfile.write()把这个数组保存成.wav文件
这个模式简单直接,但它有一个致命问题:你必须提前知道要录几秒。
如果用户说话超过 5 秒,音频会被截断;如果用户 2 秒就说完了,剩下 3 秒全是静音,浪费了计算资源。
是的,sounddevice 不仅可以录音,也完全可以用来播放音频。
它播放音频的核心,就是 sd.play() 这个函数。
补充:播放函数:sd.play()
这个函数的功能非常直接:把一个 NumPy 数组作为音频数据播放出来。
python
import sounddevice as sd
import soundfile as sf
# 读取音频文件,得到音频数据 data 和采样率 fs
data, fs = sf.read('my_audio.wav', dtype='float32')
# 播放音频
sd.play(data, fs)
sd.wait() # 阻塞主线程,直到音频播放完毕
print("播放完成!")
# 或者使用sd.play(data, fs, blocking=True) # 直接阻塞
data: 一个 NumPy 数组,包含了要播放的音频数据。fs: 音频数据的采样率(比如 16000 或 44100)。
sd.play() 默认是非阻塞 的,它会立刻返回,让音频在后台播放。如果你希望程序等音频播放完再继续执行,可以加上 blocking=True 参数。
对于需要更精细控制的场景(比如流式播放、低延迟应用),sounddevice 也提供了 OutputStream 类。你可以把它想象成一个通往声卡的"管道",通过回调函数 callback 持续地将音频数据"喂"给声卡。
⚙️ 补充说明
- 支持的数据类型 :
sd.play()支持多种 NumPy 数据类型,如float32,int16等。 - 指定播放设备 : 和录音一样,你也可以通过
sd.default.device或device参数来指定使用哪个扬声器或耳机进行播放。 - 常用搭配 : 播放音频时,常与
soundfile库搭配,用于读取各种格式的音频文件。
sounddevice 的播放功能与录音功能一样强大且易用,非常适合在语音助手中实现"开口说话"的环节。
可能有读者观察到了,在播放示例里用了 soundfile而非scipy!
两者的核心差异:
scipy.io.wavfile.read:
- 只能读写 WAV 格式(严格来说是 PCM WAV)。
- 读取后返回的
data默认是int16类型(如果是 16-bit WAV)。 - 而
sd.play()最推荐的数据格式是float32(范围 -1.0 ~ 1.0)。
所以,如果用 scipy 读取后直接播放,可能会因为数据类型不对导致音量大爆炸或者被截断。你需要手动转成 float32:
python
from scipy.io.wavfile import read
import sounddevice as sd
fs, data = read("test.wav") # data 是 int16
data = data / 32768.0 # 手动归一化到 -1.0 ~ 1.0
sd.play(data.astype('float32'), fs) # 转成 float32 再播放
soundfile.read:
- 支持更多格式(WAV、FLAC、OGG、AIFF 等)。
- 可以直接指定
dtype='float32',读取后数据已经在-1.0 ~ 1.0范围内,直接就能喂给sd.play()。
python
import soundfile as sf
data, fs = sf.read("test.wav", dtype='float32') # 一步到位
sd.play(data, fs) # 直接播放
演示播放时,soundfile 更简洁、更安全:
- 少写一行转换代码。
- 避免类型不匹配带来的坑。
- 展示更专业的库组合 :
sounddevice+soundfile是音频 I/O 的黄金搭档。
补充:scipy.io.wavfile vs soundfile ------ 音频文件读写库怎么选?
在 Python 中,读写 WAV 文件最常见的有两个库:scipy.io.wavfile 和 soundfile。它们都提供了简单的 read() / write() 接口,但在细节上有一些值得关注的差异。
| 对比项 | scipy.io.wavfile |
soundfile |
|---|---|---|
| 支持格式 | 仅 WAV(PCM) | WAV、FLAC、OGG、AIFF 等 |
| 读取数据类型 | 默认保持文件原始类型(int16、int24 等) | 可通过 dtype 参数指定(如 float32) |
| 写入数据类型 | 自动根据传入数组的 dtype 决定 | 可通过 subtype 参数指定 |
| 依赖 | 无额外依赖(scipy 自带) | 需要 libsndfile(C 库) |
| 安装难度 | 简单(scipy 是科学计算标配) | Windows 偶有 C 库依赖问题 |
| 典型用法 | fs, data = read("file.wav") |
data, fs = read("file.wav", dtype='float32') |
| 返回顺序 | (采样率, 数据) |
(数据, 采样率) |
最容易被忽略的坑:返回值的顺序
这两个库的 read() 函数返回值的顺序是相反的,初学者很容易搞混:
python
from scipy.io.wavfile import read as scipy_read
import soundfile as sf
# scipy: (采样率, 数据)
fs, data_scipy = scipy_read("test.wav")
# soundfile: (数据, 采样率)
data_sf, fs = sf.read("test.wav")
建议: 无论用哪个库,拿到数据后先 print(type(data), data.shape, fs) 确认一下,避免把采样率和数据搞反。
最关键的区别:数据类型的处理
这是两个库最核心的差异,直接决定了你用哪个更顺手。
scipy.io.wavfile.read 读取 16-bit WAV 返回的是 int16 数组,取值范围 -32768 ~ 32767:
python
from scipy.io.wavfile import read
fs, data = read("test.wav") # data 是 int16
print(data.dtype) # int16
print(data[0]) # 比如 12345
而 soundfile.read 可以通过 dtype 参数指定返回类型,最常见的是直接指定 float32:
python
import soundfile as sf
data, fs = sf.read("test.wav", dtype='float32') # data 是 float32
print(data.dtype) # float32
print(data[0]) # 比如 0.376 (范围 -1.0 ~ 1.0)
为什么 dtype 这么重要?
- 很多音频处理算法(如 VAD、Whisper 预处理)期望输入是 -1.0 ~ 1.0 范围的 float32。
soundfile可以直接返回这个格式,省去手动转换。scipy需要你手动归一化:
python
import numpy as np
from scipy.io.wavfile import read
fs, data_int16 = read("test.wav")
# 方法一:保持 int16 范围转 float32(无精度损失,适合播放)
data_float32 = data_int16.astype(np.float32)
# 方法二:归一化到 -1.0 ~ 1.0(有微小精度损失,适合算法处理)
data_float32_norm = data_int16.astype(np.float32) / 32768.0
精度损失真的重要吗?
从 int16 到 float32 的归一化(除以 32768.0)理论上会有精度损失,因为 int16 只有 65536 个离散值。但对于语音识别(Whisper)和 VAD 来说,这个损失完全可以忽略------后续的特征提取(傅里叶变换、梅尔滤波)和神经网络计算,引入的误差比这个大得多。
选型建议:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 只想读写 WAV,不想加依赖 | scipy.io.wavfile |
已经装了 scipy 就不用再装别的 |
| 需要处理 FLAC/OGG 格式 | soundfile |
scipy 不支持 |
| 想直接拿到 float32 数据 | soundfile |
省去手动归一化 |
| 隔离环境、Windows 用户 | scipy.io.wavfile |
避免 libsndfile 安装问题 |
| 追求代码统一和简洁 | soundfile |
读写 API 一致,不绕弯子 |
实时流模式录音
如果我们要做一个语音 Agent ,那么就不能"等用户说完再开始录",而是一直在监听,但只有在有人说话时才存储音频。
这就需要用到 sounddevice 的 InputStream 和 回调函数(callback)。
核心思想:
- 以极小的音频块(比如 30ms)持续从麦克风读取数据。
- 每读到一块,就交给回调函数处理。
- 回调函数里,用 VAD(语音活动检测)判断这一块有没有人声,决定是否开始/停止录音。
python
import sounddevice as sd
def callback(indata, frames, time, status):
# indata 的形状是 (frames, channels)
# 比如 (512, 1) 表示 512 个样本点,单声道
print(indata.shape)
with sd.InputStream(
samplerate=16000,
channels=1,
callback=callback
):
# 保持程序运行,持续监听
while True:
pass
运行后,只要你对着麦克风说话,控制台会不断输出:
(512, 1)
(512, 1)
(512, 1)
...
关键参数解释:
| 参数 | 值 | 含义 |
|---|---|---|
samplerate=16000 |
16kHz | 采样率 |
channels=1 |
单声道 | 录音通道数 |
callback |
函数 | 每收到一块数据就调用一次 |
frames=512 |
默认值 | 每块音频包含 512 个样本点,对应 512/16000 = 32ms |
这就是 Agent 式录音的核心:
麦克风持续收音
|
v
每 32ms 切一块
|
v
丢给 callback
|
v
VAD 判断有没有人声
|
+-- 是 → 开始录音
|
+-- 否 → 继续监听
如果在代码执行的时候遇到**Invalid number of channels**------ 声道数无效。
简单来说:你当前的设备 不支持 channels=1(单声道),或者设备本身不是输入设备。
sounddevice 在打开流时,会向系统声卡驱动请求特定参数(采样率、声道数等)。如果驱动不支持,就会返回这个错误。
常见的两种场景:
- 你选择的默认输入设备是"立体声混音"或"虚拟音频线",这些设备可能只支持 2 声道(立体声),不支持 1 声道。
- 你没有指定输入设备,而系统默认的输入设备本身就不支持单声道(极少见,但存在)。
第一步:查看你的设备列表
python
import sounddevice as sd
print(sd.query_devices())
你会看到类似这样的输出:
0 Microsoft 声音映射器 - Input, MME (2 in, 0 out)
1 麦克风阵列 (Realtek(R) Audio), MME (2 in, 0 out)
2 扬声器 (Realtek(R) Audio), MME (0 in, 2 out)
...
注意 (2 in, 0 out) 表示该设备支持 2 个输入声道,(0 in, 2 out) 表示输出设备。我们要找的是 in 不为 0 的设备。
第二步:手动指定一个输入设备
找到你的物理麦克风(通常是带有 Microphone 字样的那一行),记下前面的索引号(比如 1)。然后在代码里显式指定:
python
import sounddevice as sd
# 强制使用设备 1(麦克风阵列)
sd.default.device = 1 # 或者直接放在 InputStream 参数里
with sd.InputStream(
samplerate=16000,
channels=1, # 依然用单声道
callback=callback,
device=1 # 显式指定设备索引
):
...
第三步:如果仍然报错,尝试调整声道数
如果设备只支持 2 声道,而你必须用单声道,可以尝试让 sounddevice 自己处理混音:将 channels 设为 2,然后在回调函数里取第一个通道(或者平均左右声道)作为单声道数据。
然后你的 callback 里拿到的 indata 会是一个 (frames, 2) 的形状,也就是双声道数据。对于语音识别,你只取左声道(或右声道)就行了:
python
import sounddevice as sd
import numpy as np
def callback(indata, frames, time, status):
# indata.shape = (frames, 2)
# 取左声道(第 0 列)作为单声道
mono = indata[:, 0] # shape: (frames,)
# 现在 mono 就是单声道数据,可以喂给 VAD 了
# 你的 VAD 处理逻辑...
with sd.InputStream(
samplerate=16000,
channels=2, # 改成 2,匹配你的设备
callback=callback,
):
while True:
sd.sleep(100)
⚠️ 但这样有个问题
你的设备显示 2 in, 0 out,但实际笔记本内置麦克风阵列虽然显示"2 声道",通常只是左右两个麦克风而已(或者一个麦克风+一个环境降噪)。两个声道的内容几乎一样(都录的是你的声音),所以取左声道或取平均,对语音识别几乎没有影响。
但是,如果你用的是"立体声混音"(Stereo Mix),它的左右声道内容就不同了(左声道是系统音频输出左声道,右声道是系统音频输出右声道),取单声道可能会导致内容丢失一部分。不过你用的是麦克风阵列,不是立体声混音,所以不用担心。
跑这段代码看看录到的数据长什么样:
python
import sounddevice as sd
import numpy as np
def callback(indata, frames, time, status):
print(f"shape: {indata.shape}") # 应该输出 (512, 2)
print(f"左声道均值: {indata[:, 0].mean():.6f}")
print(f"右声道均值: {indata[:, 1].mean():.6f}")
with sd.InputStream(samplerate=16000, channels=2, callback=callback):
while True:
sd.sleep(100)
对着麦克风说句话,看两个声道的均值是不是差不多。如果差不多,就说明两个声道内容一样,取哪个都行。如果差别很大,说明设备是真的"立体声"采集,那你就需要确认你的麦克风物理位置是否影响语音内容------对于大多数内置麦克风阵列,两个声道通常录的是同一个声音,只是可能有点相位差,但取左声道作为单声道足够------两个麦克风位置很近,录的都是你的声音(只是可能有极小的相位差)。
我们来写一个边录制边播放的demo,它的工作流程是这样的:
- 启动后立即开始录音(后台持续录制)。
- 用户按回车键 → 停止录音,然后立即播放刚才录下的内容。
- 播放完成后,自动回到录音状态,进入下一轮循环。
这样就形成了一个"录制 → 回放 → 录制 → 回放"的无限循环,很适合用来测试麦克风和扬声器是否正常工作,也可以作为构建更复杂语音应用的基础。
python
import sounddevice as sd
import numpy as np
# 音频参数
SAMPLE_RATE = 16000
CHANNELS = 2 # 如果你的设备只支持双声道,这里写 2;支持单声道则写 1
BLOCKSIZE = 1024 # 每次回调的帧数,约 64ms
def record_until_enter():
"""
录音直到用户按回车键,返回录音数据(float32,形状 [N, CHANNELS])
"""
print("🎙️ 录音中... (按回车停止)")
audio_frames = []
def callback(indata, frames, time, status):
audio_frames.append(indata.copy())
# 打开音频输入流,后台持续录音
with sd.InputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
callback=callback,
blocksize=BLOCKSIZE
):
# 主线程阻塞,等待用户按回车
input()
print("⏹️ 录音结束")
if not audio_frames:
return np.array([], dtype=np.float32)
# 合并所有数据块
audio_data = np.concatenate(audio_frames, axis=0)
return audio_data
def play_audio(audio_data):
"""
播放音频数据
"""
if audio_data.size == 0:
print("⚠️ 没有音频数据可播放")
return
# 如果是双声道,取左声道作为单声道播放(你也可以取平均或保留立体声)
if CHANNELS == 2:
# 取左声道
mono = audio_data[:, 0]
else:
mono = audio_data
print("🔊 播放中...")
sd.play(mono, samplerate=SAMPLE_RATE)
sd.wait() # 阻塞直到播放完成
print("✅ 播放结束")
def main():
print("=== 录音回放循环 Demo ===")
print("按回车停止录音并回放,播放完成后自动进入下一轮录音")
print("按 Ctrl+C 退出程序\n")
try:
while True:
# 1. 录音直到用户按回车
audio_data = record_until_enter()
# 2. 播放录到的内容
play_audio(audio_data)
print("\n" + "-" * 40)
print("准备下一轮录音...\n")
except KeyboardInterrupt:
print("\n👋 退出程序")
if __name__ == "__main__":
main()
🧪 运行说明
- 启动程序 :运行后,终端会显示
🎙️ 录音中... (按回车停止),此时程序已经在后台开始录音。 - 说话:对着麦克风说几句话(比如"你好,测试测试")。
- 按回车:录音停止,程序会自动播放刚才录下的声音。
- 播放完成:程序自动进入下一轮录音,等待你再次按回车。
如此循环,直到你按 Ctrl+C 退出。
⚙️ 参数调整建议
| 参数 | 说明 | 推荐值 |
|---|---|---|
SAMPLE_RATE |
采样率,语音用 16000 Hz | 16000 |
CHANNELS |
你的设备支持的声道数,一般为 1 或 2 | 根据你的设备设置,如果不知道可以用 2 |
BLOCKSIZE |
每次回调的数据块大小(帧数),影响延迟和性能 | 1024(约 64ms)或 512(约 32ms) |
如果你的设备支持单声道,可以改成 CHANNELS=1,然后播放时直接播放 audio_data(不需要取左声道)。
🐛 常见问题
- 听不到声音 :检查系统音量,确认扬声器/耳机正常,并且
sd.query_devices()确认默认输出设备正确。 - 录音只有噪声 :检查
sd.default.device是否指向了正确的输入设备(不是"立体声混音")。 - 按回车后卡住不播放 :检查
sd.wait()是否被阻塞,可能是音频设备被占用或输出设备不支持当前采样率。

语音活动检测(VAD)------让程序"听"懂静音与说话
上一章,我们已经学会了用 sounddevice 从麦克风采集音频,也实现了"录音 → 回放"的闭环。
但我们始终绕开了一个核心问题:
怎么知道用户开始说话了?
如果只是傻傻地录 5 秒,用户说快了会截断,说慢了会录进大段静音,体验极其糟糕。
我们需要一种机制,让程序实时感知到:
"有人开始说话了 → 开始录音"
"人说完了 → 停止录音,送去识别"
这个机制,就是 VAD------Voice Activity Detection,语音活动检测。
我们先不用任何高级库,从最直观的思路开始。
原理非常简单:
声音的本质是波形,每个采样点是一个数值(比如 float32,范围 -1.0 ~ 1.0)。当人说话时,这些数值的幅度会明显变大;当没人说话时,幅度接近 0。
我们可以用 RMS(均方根) 来衡量一段音频的"能量":
R M S = x 1 2 + x 2 2 + ⋯ + x n 2 n RMS = \sqrt{\frac{x_1^2 + x_2^2 + \dots + x_n^2}{n}} RMS=nx12+x22+⋯+xn2
RMS 越大,声音越大。于是我们可以设定一个音量阈值:
如果 RMS > 阈值 → 认为有人在说话
如果 RMS < 阈值 → 认为是静音
我们来写一个完整的 Demo:它持续监听麦克风,当音量超过阈值时开始缓存音频,当连续静音超过 1 秒时认为话已说完,然后输出录音时长。
python
import sounddevice as sd
import numpy as np
import time
SAMPLE_RATE = 16000
CHANNELS = 2
THRESHOLD = 0.02 # 音量阈值,需要根据环境调整
SILENCE_SECONDS = 1.0 # 连续静音多久认为说话结束
frames = [] # 缓存当前说话的音频块
speaking = False # 是否正在说话
silence_start = None # 静音开始的时间
def rms(audio):
"""计算 RMS(均方根),衡量音量大小"""
return np.sqrt(np.mean(audio ** 2))
def callback(indata, frames_count, time_info, status):
global speaking, silence_start, frames
audio = indata[:, 0] # 取左声道
volume = rms(audio)
# 实时打印音量(调试用)
print(f"音量: {volume:.4f}")
if volume > THRESHOLD:
# 检测到声音
if not speaking:
print("🟢 开始说话")
speaking = True
frames = [] # 清空缓存
frames.append(audio.copy())
silence_start = None # 重置静音计时器
else:
# 静音
if speaking:
frames.append(audio.copy())
if silence_start is None:
silence_start = time.time()
elif time.time() - silence_start > SILENCE_SECONDS:
print("🔴 结束说话")
process_audio(frames)
frames = []
speaking = False
silence_start = None
def process_audio(frames):
"""处理录到的完整音频段"""
audio = np.concatenate(frames)
duration = len(audio) / SAMPLE_RATE
print(f"📁 收到音频片段: {duration:.2f} 秒")
# 下一章我们会在这里调用 Whisper
with sd.InputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
blocksize=320, # 20ms 一帧 (320/16000 = 0.02s)
callback=callback
):
print("🎙️ 监听中...(按 Ctrl+C 退出)")
while True:
time.sleep(1)
运行后,你对麦克风说话,会看到类似这样的输出:
🎙️ 监听中...(按 Ctrl+C 退出)
音量: 0.0012
音量: 0.0015
音量: 0.0009
音量: 0.0821 ← 突然变大
🟢 开始说话
音量: 0.1245
音量: 0.1567
音量: 0.0031 ← 声音消失
🔴 结束说话
📁 收到音频片段: 3.52 秒
上面的代码确实能工作,但用在实际产品里会遇到几个明显的痛点:
| 问题 | 说明 |
|---|---|
| 环境噪音 | 风扇声、键盘声、空调声会让音量阈值失效------要么阈值太高录不到人声,要么阈值太低把噪音当人声。 |
| 距离敏感 | 人靠近麦克风音量 0.3,远了只有 0.01,同一个阈值无法适应。 |
| 无法区分语音和音乐 | 音箱里放的音乐也会触发录音。 |
| 阈值需要人工调优 | 每个人的麦克风、环境都不同,写死在代码里不够通用。 |
所以,在真实场景中,我们会使用基于深度学习的 VAD 模型,它能分析音频的频谱特征,而不是简单的音量大小,从而更准确地判断一段声音是不是"人说话"。
其中最常用、最成熟的方案之一,就是 Silero VAD。
PyTorch从零搭建环境
Silero VAD 是一个基于 PyTorch 的深度学习模型,它的依赖如下:
| 依赖包 | 最低版本 | 说明 |
|---|---|---|
torch |
>= 1.12.0 | PyTorch 核心库 |
torchaudio |
>= 0.12.0 | 音频 I/O 工具(非必须,但推荐) |
onnxruntime |
>= 1.16.1 | ONNX 模型推理(可选,用于加速) |
核心原则:先装 PyTorch,再装 silero-vad。
第一步:检查你的显卡和 CUDA 环境
如果你没有 NVIDIA 显卡,或者不需要 GPU 加速,可以直接跳到第二步,安装 CPU 版本的 PyTorch。
如果你有 NVIDIA 显卡并希望用 GPU 加速,需要先确认两件事:
- 显卡驱动是否正常 → 用
nvidia-smi查看 - 当前 CUDA 版本是多少 → 也是用
nvidia-smi查看
查看显卡驱动和 CUDA 版本
打开终端(Windows 用 CMD 或 PowerShell),运行:
bash
nvidia-smi
输出示例(关注右上角):
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 546.17 Driver Version: 546.17 CUDA Version: 12.3 |
|-------------------------------+----------------------+----------------------+
| GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
|===============================+======================+======================|
| 0 NVIDIA GeForce RTX 4060 WDDM | 00000000:01:00.0 On | N/A |
| ... |
+-----------------------------------------------------------------------------+
重点关注 右上角的 CUDA Version: 12.3 。
⚠️ 注意:
nvidia-smi显示的是"驱动支持的最高 CUDA 版本",不代表你已经安装了完整版 CUDA Toolkit。但这对安装 PyTorch 已经足够了------因为 PyTorch 会自带所需的 CUDA 运行时,不需要单独安装 CUDA Toolkit。
两种常见的 CUDA 版本号含义
| 命令 | 显示内容 | 说明 |
|---|---|---|
nvidia-smi |
驱动最高支持的 CUDA 版本 | 决定你能装多高版本的 PyTorch |
nvcc --version |
实际安装的 CUDA Toolkit 版本 | 如果没装过 CUDA Toolkit,这个命令会报错 |
对于安装 PyTorch 来说,只需要看 nvidia-smi 的 CUDA 版本就够了。PyTorch 的预编译包会自带 CUDA 运行时,不需要你单独安装 CUDA Toolkit。
第二步:安装 PyTorch
根据你的 CUDA 版本选择对应的 PyTorch 安装命令。
场景一:没有 NVIDIA 显卡 → 安装 CPU 版本
bash
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
场景二:有 NVIDIA 显卡 → 安装 GPU 版本
根据 nvidia-smi 显示的 CUDA 版本,选择对应的安装命令:
| nvidia-smi 显示的 CUDA 版本 | 安装命令中的标签 | 完整命令 |
|---|---|---|
| CUDA 11.8 | cu118 |
pip install torch torchaudio --extra-index-url https://download.pytorch.org/whl/cu118 |
| CUDA 12.1 | cu121 |
pip install torch torchaudio --extra-index-url https://download.pytorch.org/whl/cu121 |
| CUDA 12.4 | cu124 |
pip install torch torchaudio --extra-index-url https://download.pytorch.org/whl/cu124 |
举例 :如果你的 nvidia-smi 显示 CUDA Version: 12.3,安装命令应该选 cu121 ------版本号可以向下兼容,PyTorch 的 CUDA 运行时版本不一定要和驱动版本完全一致。
💡 关于 PyTorch 的 CUDA 版本标签:
cu118表示该 PyTorch 包基于 CUDA 11.8 编译,cu121基于 CUDA 12.1,以此类推。在 PyTorch 官网(pytorch.org)可以查到所有历史版本和对应的 CUDA 标签。
第三步:验证 PyTorch 安装是否正确
安装完成后,运行以下 Python 脚本验证环境:
python
import torch
print(f"PyTorch 版本: {torch.__version__}")
print(f"CUDA 版本 (PyTorch 自带的): {torch.version.cuda}")
print(f"CUDA 是否可用: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU 名称: {torch.cuda.get_device_name(0)}")
print(f"GPU 数量: {torch.cuda.device_count()}")
期望输出(GPU 版本):
PyTorch 版本: 2.4.0
CUDA 版本 (PyTorch 自带的): 12.1
CUDA 是否可用: True
GPU 名称: NVIDIA GeForce RTX 4060
GPU 数量: 1
如果 torch.cuda.is_available() 返回 False,可能的原因:
| 问题 | 解决方案 |
|---|---|
| 装了 CPU 版本的 PyTorch | 卸载后重新安装 GPU 版本 |
| NVIDIA 驱动版本过低 | 更新显卡驱动(去 NVIDIA 官网下载) |
| 显卡太老,不支持 CUDA | 改用 CPU 版本(性能稍慢,但对 VAD 完全够用) |
第四步:安装 Silero VAD
PyTorch 装好并验证通过后,安装 Silero VAD 就非常简单了:
bash
pip install silero-vad
💡 关于
silero-vad-lite:还有一个轻量级版本silero-vad-lite,它不需要 PyTorch,内嵌了 ONNX Runtime,适合对依赖大小敏感的场景。不过它的 API 有所不同,且不是官方发布。
第五步:验证 Silero VAD 是否安装成功
python
from silero_vad import load_silero_vad, get_speech_timestamps
model = load_silero_vad() # 会自动下载模型文件(约 2MB)
print("Silero VAD 加载成功!")
如果看到 Silero VAD 加载成功! 且没有报错,说明环境已经全部就绪。
Silero VAD:一个开箱即用的专业方案
Silero VAD 是一个预训练的深度神经网络模型,专门用于语音活动检测。
输入: 16kHz 单声道 PCM 音频(就是 sounddevice 录到的 float32 数组)。
输出: 一个 0~1 之间的概率值,越接近 1 表示越像是人声。
python
import torch
from silero_vad import load_silero_vad, get_speech_timestamps
model = load_silero_vad() # 加载预训练模型
audio = ... # 你的音频数据
speech_timestamps = get_speech_timestamps(audio, model)
Silero VAD 的优势:
它使用深度学习训练而成,输入 16kHz 单声道 PCM 音频,输出一个 0~1 之间的概率值------越接近 1,表示该音频块越可能是人声。
| 特性 | 说明 |
|---|---|
| 准确率 | 显著优于音量阈值和传统 GMM 方法,能有效区分语音和噪声。 |
| 抗噪能力 | 在风扇、键盘、空调等常见背景噪声下仍能保持准确。 |
| 体积小 | 模型文件约 2MB,适合嵌入式和移动端。 |
| 速度快 | 处理一个 30ms 音频块耗时 < 1ms(单线程 CPU)。 |
| 易用 | 提供 Python 封装,几行代码即可调用。 |
python
import torch
from silero_vad import load_silero_vad, get_speech_timestamps
model = load_silero_vad() # 加载预训练模型
wav = ... # 16kHz 单声道 float32 数组
speech_timestamps = get_speech_timestamps(wav, model)
get_speech_timestamps 会返回一个列表,标记出所有"人声片段"的起止时间:
python
[
{'start': 1.2, 'end': 3.8}, # 第一段人声:1.2~3.8 秒
{'start': 5.0, 'end': 6.5}, # 第二段人声:5.0~6.5 秒
]
silero-vad 也支持流式处理,可以每 20ms~30ms 喂入一个音频块,实时获取语音概率:
python
from silero_vad_lite import SileroVAD
vad = SileroVAD(16000) # 初始化,采样率必须为 16000
# 每次从麦克风取到 512 个样本(约 32ms)时:
speech_prob = vad.process(chunk) # 返回 0~1 之间的概率值
if speech_prob > 0.5:
print("检测到人声")
else:
print("静音")
这样我们就可以替代之前的"音量阈值"逻辑,让 VAD 判断更准确。
如果读者有兴趣进一步探索,可以了解:
- VAD 模型的核心原理 :Silero VAD 基于 LSTM 或 CRNN 结构,对音频的频谱特征(而非时域波形)进行分类。
- 与其他 VAD 方案的对比:WebRTC VAD(基于高斯混合模型,速度快但准确率稍低)和 Silero VAD(基于深度学习,准确率高但需要 PyTorch 环境)。
- Silero VAD 还支持:返回语音片段的精确时间戳,支持多语言(中英文均适用)。
Demo:使用 Silero VAD 实时录音
现在我们来写一个使用官方 silero-vad 进行实时语音活动检测并自动录制的完整 Demo。
python
import queue
import time
import numpy as np
import sounddevice as sd
import torch
from scipy.io.wavfile import write
from silero_vad import load_silero_vad, VADIterator
# ====================== 参数配置 ======================
SAMPLE_RATE = 16000
CHANNELS = 2
CHUNK_SIZE = 512 # 512 / 16000 = 32ms
# VAD 参数
VAD_THRESHOLD = 0.5 # 语音概率阈值
MIN_SILENCE_MS = 800 # 静音多久认为说话结束(ms)
SPEECH_PAD_MS = 30 # 语音段前后填充(ms)
# ====================== 加载 Silero VAD ======================
vad_model = load_silero_vad()
vad_iterator = VADIterator(
model=vad_model,
threshold=VAD_THRESHOLD,
sampling_rate=SAMPLE_RATE,
min_silence_duration_ms=MIN_SILENCE_MS,
speech_pad_ms=SPEECH_PAD_MS,
)
# ====================== 音频队列 ======================
audio_queue = queue.Queue()
def audio_callback(indata, frames, time_info, status):
if status:
print(status)
audio = indata[:, 0].copy() if indata.shape[1] > 1 else indata.flatten().copy()
audio_queue.put(audio)
# ====================== 保存音频 ======================
def save_audio(audio_data):
timestamp = int(time.time() * 1000)
filename = f"recording_{timestamp}.wav"
write(filename, SAMPLE_RATE, audio_data)
duration = len(audio_data) / SAMPLE_RATE
print(f"💾 已保存: {filename} (时长: {duration:.2f} 秒)")
# ====================== 主循环 ======================
def main():
print("🎙️ 开始监听... (按 Ctrl+C 退出)")
print(f" 阈值: {VAD_THRESHOLD}, 静音超时: {MIN_SILENCE_MS}ms, 填充: {SPEECH_PAD_MS}ms")
recording = []
speaking = False
with sd.InputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
blocksize=CHUNK_SIZE,
callback=audio_callback
):
while True:
chunk = audio_queue.get()
tensor = torch.from_numpy(chunk)
# VAD 判断
vad_result = vad_iterator(tensor, return_seconds=False)
if vad_result:
if "start" in vad_result:
print(">>> 检测到人声,开始录音")
speaking = True
recording = []
elif "end" in vad_result:
print("<<< 静音超时,录音结束")
speaking = False
if recording:
audio = np.concatenate(recording)
save_audio(audio)
else:
print("⚠️ 录音为空,跳过保存")
if speaking:
recording.append(chunk)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n👋 退出")
| 参数 | 默认值 | 含义 |
|---|---|---|
threshold |
0.5 | 语音概率阈值,高于此值判定为语音。调低 (如 0.3)更灵敏(轻声也能触发),调高(如 0.7)更保守(减少误触发)。 |
sampling_rate |
16000 | 采样率,必须与音频一致。 |
min_silence_duration_ms |
100 | 静音持续多少毫秒后认为说话结束。注意:这里默认只有 100ms,非常短,实际使用中建议调大到 500~1000ms,否则说话稍作停顿就会被截断。 |
speech_pad_ms |
30 | 在语音段的前后各填充多少毫秒的音频。这能避免语音被"切掉"开头或结尾,保留完整的发音。 |
运行效果
🎙️ 开始监听... (按 Ctrl+C 退出)
阈值: 0.5, 静音超时: 800ms, 填充: 30ms
>>> 检测到人声,开始录音
<<< 静音超时,录音结束
💾 已保存: recording_1734567890123.wav (时长: 3.52 秒)
>>> 检测到人声,开始录音
<<< 静音超时,录音结束
💾 已保存: recording_1734567895678.wav (时长: 2.18 秒)
speech_pad_ms 的作用(重点解释)
这个参数很容易被忽视,但其实很关键。
假设你说了"你好"两个字,"你"的起音和"好"的尾音可能音量很小,VAD 可能会把开头和结尾的微弱语音当成"非语音"而截掉。speech_pad_ms=30 会在检测到的语音段前后各补 30ms 的音频,相当于给语音段加了"安全余量",确保完整的发音被保留。
原始 VAD 检测到的语音段: [ | 语音 | ]
加上 30ms 前后填充后: [ 填充 | 语音 | 填充 ]
这样保存下来的音频就不会出现"首尾被切掉"的问题。
参数调优指南
| 现象 | 调整方案 |
|---|---|
| 说话没反应(检测不到) | 降低 threshold 到 0.3~0.4 |
| 背景噪音被误判为人声 | 提高 threshold 到 0.6~0.7 |
| 说话中间稍作停顿就被截断 | 增大 min_silence_duration_ms 到 1000~1500 |
| 说的话断了,不完整 | 检查 speech_pad_ms 是否太小(建议 30~60) |
| 频繁误触发 | 提高 threshold,同时确认音频设备没有录到立体声混音 |
如果你想立刻听到录音内容,把 save_audio(audio) 替换为:
python
import sounddevice as sd
def play_audio(audio_data):
sd.play(audio_data, SAMPLE_RATE)
sd.wait()
print("🔊 播放完成")
这样每次说话结束,电脑就会自动播放你刚才说的话。

我们也可以使用windows自带的播放器播放音频。

语音转文字 ------ Whisper 与 faster-whisper
上一章,我们已经实现了"自动检测说话 → 录制音频 → 保存文件"的闭环。但录音本身不是目的------我们的最终目标是让计算机理解我们说了什么。
这一章,我们给程序装上"听觉皮层":语音转文字(ASR,Automatic Speech Recognition)。
Whisper 是 OpenAI 于 2022 年开源的通用语音识别模型。它采用 Transformer 编码器-解码器架构,在 68 万小时的多语言音频数据上训练而成。
Whisper 的几个核心特点:
| 特点 | 说明 |
|---|---|
| 多语言支持 | 支持 99 种语言的识别与翻译 |
| 鲁棒性强 | 对背景噪音、口音差异、语速变化都有较好的适应能力 |
| 端到端设计 | 不需要传统的语音预处理(如 VAD、降噪),直接输入音频即可 |
| 开源 | 提供完整的预训练模型权重,可本地部署 |
最重要的一点:Whisper 是完全离线的。 不需要联网,不需要 API Key,所有推理都在本地完成。

Whisper 提供了 6 种规模的模型,从轻量到高精度各有侧重:
| 模型 | 参数量 | 显存需求 | 相对速度 | 适用场景 |
|---|---|---|---|---|
tiny |
39 M | ~1 GB | 10x | 移动端、低延迟场景 |
base |
74 M | ~1 GB | 7x | 通用场景,CPU 可跑 |
small |
244 M | ~2 GB | 4x | 平衡型,适合大多数桌面应用 |
medium |
769 M | ~5 GB | 2x | 高精度需求 |
large |
1550 M | ~10 GB | 1x | 最高精度,需要高性能 GPU |
turbo |
809 M | ~6 GB | 8x | large-v3 的优化版,速度更快 |
选择建议:
- 如果你在 CPU 上运行 ,选
tiny或base,可以做到近实时转录。 - 如果你有 普通 GPU(如 RTX 3060,8GB 显存) ,选
small或medium,在速度和准确率之间取得平衡。 - 如果追求 最高准确率 且有高性能 GPU(如 RTX 4090,24GB 显存),选
large。
对于中文识别:
tiny/base的中文准确率约 85%-88%small/medium可达 92%-94%large可达 95% 以上
faster-whisper:Whisper 的"加速版"
Whisper 的精度很高,但代价是速度。
原版 Whisper 的 large 模型有 15.5 亿参数 ,在 GPU 上处理一段 13 分钟的音频需要 4 分 30 秒。对于实时对话场景,这个速度显然不够。
于是,社区给出了一个优化方案:faster-whisper。
faster-whisper 是 OpenAI Whisper 的一个重新实现 ,由 SYSTRAN 团队开发。它使用 CTranslate2(Transformer 模型的高性能推理引擎)替代了原版的 PyTorch 实现。

核心优势:
| 指标 | 原版 Whisper | faster-whisper | 提升 |
|---|---|---|---|
| 转录速度 | 1x(基准) | 2-4x | 200-400% |
| 内存使用 | 基准 | -67% | 节省 2/3 内存 |
以 large-v2 模型处理 13 分钟音频为例:
| 实现 | 精度 | 时间 | GPU 显存 |
|---|---|---|---|
| 原版 Whisper | fp16 | 4分30秒 | 11325 MB |
| faster-whisper | fp16 | 54秒 | 4755 MB |
| faster-whisper | int8 | 59秒 | 3091 MB |
速度提升约 5 倍,显存占用减少一半以上。
在 CPU 上同样优势明显:
| 实现 | 精度 | 时间 | 内存 |
|---|---|---|---|
| 原版 Whisper | fp32 | 10分31秒 | 3101 MB |
| faster-whisper | fp32 | 2分44秒 | 1675 MB |
| faster-whisper | int8 | 2分04秒 | 995 MB |
结论:用 faster-whisper,同样精度,速度更快,内存更少。
安装
bash
pip install faster-whisper
如果有 NVIDIA GPU,可以安装 CUDA 支持版以获得最佳性能:
bash
pip install faster-whisper[cuda]
基础用法
python
from faster_whisper import WhisperModel
# 加载模型(首次运行自动下载)
model = WhisperModel("base", device="cpu", compute_type="int8")
# 转录音频
segments, info = model.transcribe("recording.wav", language="zh")
# 输出识别结果
for segment in segments:
print("[%.2fs -> %.2fs] %s" % (segment.start, segment.end, segment.text))
或者也可以提前下载模型,然后指定第一个参数为模型路径:

python
def audio_to_text():
"""
使用 whisper 将音频文件识别为文字。
"""
model = WhisperModel("D:/LLM/faster-whisper-medium")
segments, info = model.transcribe("my_speech.wav")
for segment in segments:
print("[%.2fs -> %.2fs] %s" % (segment.start, segment.end, segment.text))
几个关键参数:
| 参数 | 可选值 | 说明 |
|---|---|---|
device |
"cpu" / "cuda" |
使用 CPU 还是 GPU |
compute_type |
"int8" / "float16" / "float32" |
精度类型。int8 最快最省内存,精度损失极小 |
和原版 Whisper 的主要区别:
| 对比项 | 原版 Whisper | faster-whisper |
|---|---|---|
| 返回方式 | 一次性返回完整结果 | 生成器 (segments),边解码边返回 |
| 模型格式 | PyTorch .pt |
CTranslate2 转换格式 |
| 设备选择 | 自动检测 | 手动指定 device |
把 VAD + faster-whisper 串起来
现在,我们把第二章的 VAD 录音和 faster-whisper 识别结合起来,实现一个完整的 "张嘴说话 → 自动录音 → 自动转文字" 的 Demo。
python
import queue
import time
import numpy as np
import sounddevice as sd
import torch
from scipy.io.wavfile import write
from silero_vad import load_silero_vad, VADIterator
from faster_whisper import WhisperModel
# ========== 参数 ==========
SAMPLE_RATE = 16000
CHANNELS = 2
CHUNK_SIZE = 512 # 32ms
# VAD 参数
VAD_THRESHOLD = 0.5
MIN_SILENCE_MS = 800
SPEECH_PAD_MS = 30
# Whisper 参数[可选]
WHISPER_MODEL = "base" # tiny / base / small / medium / large
WHISPER_DEVICE = "cpu" # cpu / cuda
WHISPER_COMPUTE_TYPE = "int8" # int8 / float16 / float32
# ========== 加载 VAD ==========
vad_model = load_silero_vad()
vad_iterator = VADIterator(
model=vad_model,
threshold=VAD_THRESHOLD,
sampling_rate=SAMPLE_RATE,
min_silence_duration_ms=MIN_SILENCE_MS,
speech_pad_ms=SPEECH_PAD_MS,
)
# ========== 加载 Whisper ==========
whisper = WhisperModel("D:/LLM/faster-whisper-medium")
# ========== 音频队列 ==========
audio_queue = queue.Queue()
def audio_callback(indata, frames, time_info, status):
if status:
print(status)
audio = indata[:, 0].copy() if indata.shape[1] > 1 else indata.flatten().copy()
audio_queue.put(audio)
# ========== 主循环 ==========
def main():
print("🎙️ 开始监听... (按 Ctrl+C 退出)")
print(f" VAD: 阈值={VAD_THRESHOLD}, 静音超时={MIN_SILENCE_MS}ms")
print(f" Whisper: 模型={WHISPER_MODEL}, 设备={WHISPER_DEVICE}")
recording = []
speaking = False
with sd.InputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
blocksize=CHUNK_SIZE,
callback=audio_callback
):
while True:
chunk = audio_queue.get()
tensor = torch.from_numpy(chunk)
vad_result = vad_iterator(tensor, return_seconds=False)
if vad_result:
if "start" in vad_result:
print(">>> 检测到人声,开始录音")
speaking = True
recording = []
elif "end" in vad_result:
print("<<< 静音超时,录音结束")
speaking = False
if recording:
audio = np.concatenate(recording)
duration = len(audio) / SAMPLE_RATE
print(f"📁 音频片段: {duration:.2f} 秒")
# Whisper 转写
segments, info = whisper.transcribe(audio, language="zh")
text = "".join(seg.text for seg in segments)
print(f"📝 识别结果: {text}")
print("---")
else:
print("⚠️ 录音为空,跳过")
if speaking:
recording.append(chunk)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n👋 退出")
运行效果:
🎙️ 开始监听... (按 Ctrl+C 退出)
VAD: 阈值=0.5, 静音超时=800ms
Whisper: 模型=base, 设备=cpu
>>> 检测到人声,开始录音
<<< 静音超时,录音结束
📁 音频片段: 2.70 秒
📝 识别结果: 帮我查询一下今天北京天气
---
>>> 检测到人声,开始录音
<<< 静音超时,录音结束
📁 音频片段: 1.85 秒
📝 识别结果: 好的我现在来查
---
让程序"开口说话"------接入大模型与语音合成
到目前为止,我们完成了:
- 第一章:声音的数字化(采样率、位深度、声道)
- 第二章:VAD 语音活动检测(自动检测说话起止)
- 第三章:Whisper 语音转文字(把声音变成文字)
现在,我们只差最后两步:
- 把文字交给大模型,让它理解并生成回答。
- 把回答念出来,用 TTS(Text-to-Speech)语音合成。
大模型选择:用什么?
可以选择本地模型 或云端 API,各有优劣:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Ollama(本地) | 完全离线、免费、隐私安全 | 需要一定显存,响应稍慢 |
| DeepSeek API | 价格极低、速度快、中文好 | 需要联网,有 API Key |
| OpenAI API | 质量最高、生态最完善 | 需要联网,有 API Key |
bash
# 大模型相关
pip install langchain_openai
# TTS 相关
pip install pyttsx3 # 本地语音合成
TTS 基础:用 pyttsx3 把文字念出来
python
import pyttsx3
def speak(text, rate=150, volume=1.0):
"""将文字合成为语音并播放"""
engine = pyttsx3.init()
engine.setProperty('rate', rate) # 语速,默认 200,150 更自然
engine.setProperty('volume', volume) # 音量 0-1
engine.say(text)
engine.runAndWait()
# 测试
speak("你好,欢迎使用语音助手!")
⚠️ 注意 :
pyttsx3依赖系统的 TTS 引擎:
- Windows:使用内置的 SAPI5,自带中文语音。
- macOS:使用 NSSpeechSynthesizer,支持中文。
- Linux :需要安装
espeak和espeak-data,中文支持可能较差。如果你想要更自然的声音,可以换成
edge-tts(免费,联网)------我们会在本章末尾提到。
接入大模型
这里我们使用glm的免费模型,搭配langchain-openai快速搭建一个demo:
python
import pyttsx3
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from langchain.messages import AIMessage
model = ChatOpenAI(
model="glm-4.7",
base_url="https://open.bigmodel.cn/api/paas/v4",
api_key=SecretStr("x"),
temperature=0.7,
max_retries=3
)
def call_llm(query:str)->str:
try:
message:AIMessage = model.invoke(query)
content:str = str(message.content)
print(f"llm:{content}")
return content
except Exception:
return "出了一点儿小问题,请重试"
def speak(text, rate=150, volume=1.0):
"""将文字合成为语音并播放"""
engine = pyttsx3.init()
engine.setProperty('rate', rate) # 语速,默认 200,150 更自然
engine.setProperty('volume', volume) # 音量 0-1
engine.say(text)
engine.runAndWait()
# 测试
if __name__ == '__main__':
speak(call_llm(input(">>>")))

从 pyttsx3 到 RealtimeTTS:一次 TTS 方案的升级
在最初的设计中,我选择了 pyttsx3 作为语音合成引擎。理由很简单:离线、免费、开箱即用,不需要任何 API Key,也不需要联网。
但在实现"流式播放"时,我遇到了一个令人头疼的问题。
我需要的是:LLM 每生成一个文本块,TTS 就立刻开始播放,而不是等所有内容生成完再一次性朗读。
理论上,pyttsx3 提供了 say() 和 runAndWait() 两个方法,看起来可以这样实现:
python
for chunk in llm_stream():
engine.say(chunk)
engine.runAndWait() # 逐个播放
但实际运行时,问题层出不穷:
- 播放一两句话后,
runAndWait()不再阻塞,直接返回,后续文本无法播出。 - 即使每次重新初始化
pyttsx3引擎,仍然会出现随机卡死或静音。 - 不同操作系统(Windows / macOS / Linux)表现不一致,Windows 上尤其不稳定。
我花了不少时间尝试各种方案------重新初始化引擎、用线程管理播放队列、每次播放前重置引擎状态------但始终无法得到一个稳定且流畅 的流式播放方案。最终的结论是:pyttsx3 的设计目标是一次性播放完整文本 ,它的底层引擎(Windows SAPI5 / NSSpeechSynthesizer / espeak)并不适合高频的、流式的 say() + runAndWait() 调用。
翻阅源码后发现,这是 pyttsx3 SAPI5 驱动的一个 bug。在sapi5.py:209:
python
def _ISpeechVoiceEvents_EndStream(self, stream_number, stream_position):
d = self._driver
if d._speaking:
d._proxy.notify("finished-utterance", completed=not d._stopping)
d._speaking = False
d._stopping = False
d._proxy.setBusy(False)
d.endLoop() # ← 问题在这里
完整的执行过程是这样的:
-
runAndWait() 被调用时,队列状态
-
for 循环先把 6 个 say 命令全部入队(因为 _busy=True,_pump()
不执行),然后 runAndWait() 在末尾再追加一个 endLoop 命令:
队列: [say("你好,"), say("欢迎来到"), say("语音合成教程"), say("今天"),
say("我们聊聊"), say("流式 TTS"), endLoop]
实际执行过程
- startLoop() 进入 while 循环,setBusy(False) → 触发 _pump() → 取出
say("你好,") 执行 → setBusy(True) → pump 停止 - SAPI5 异步合成播放 "你好,"
- 播放完毕 → COM 触发 EndStream 事件
- EndStream 中 setBusy(False) → _pump() → 取出 say("欢迎来到") 异步开始
- 紧接着 d.endLoop() 被调用 → self._looping = False
- startLoop() 的 while 循环检测到 _looping == False,直接退出
- runAndWait() 返回 → 程序结束 → COM 对象销毁,所有未完成的语音被终止
所以 "欢迎来到" 虽然被异步派发了,但程序几乎立刻退出,来不及播放。后面 4 个
chunk 根本没被从队列取出。
所以,根本原因就是EndStream 事件在每个语音片段说完后都会触发,但 d.endLoop()无条件终止了整个消息泵循环。注释写着 # hangs if you dont havethis------说明作者知道有问题,但为了防止单句场景下挂死,硬加了这行。
正确的做法是:不需要这行。runAndWait() 已经在队列末尾放了 endLoop 命令,当所有say 都执行完后,_pump() 自然会执行到那个 endLoop 命令,优雅退出循环。
这就是 pyttsx3 不适合多段连续播放的原因------它的 SAPI5 驱动从设计上只支持单次say。 这也印证了我之前的建议:要做流式 TTS,得绕过 pyttsx3 直接操作 SAPI5 COM接口。
我需要一个真正支持流式合成的 TTS 库,同时满足:
- 免费或低成本
- 音质自然
- Python 生态,易于集成
- 能稳定处理 LLM 的流式输出
在调研了多个方案后,我找到了 RealtimeTTS。
RealtimeTTS 是一个专门为"实时流式文本转语音"设计的 Python 库。它的核心设计目标就是:边接收文本边合成语音,延迟极低,适合对话式 AI 场景。
它本身不直接提供 TTS 能力 ,而是作为一个"调度层",将流式文本分发给后端的 TTS 引擎,由后端引擎完成实际的语音合成。
你可以在 pip install 时指定后端:
bash
# 使用系统 TTS(pyttsx3)
pip install "realtimetts[system]"
# 使用 Google TTS(免费,需联网)
pip install "realtimetts[gtts]"
# 使用 Azure TTS(需 API Key,音质最好)
pip install "realtimetts[azure]"
在安装 RealtimeTTS 的过程中,我遇到了一个典型的版本兼容问题,值得单独拿出来说一说。
执行安装命令后,pyaudio 编译失败,报错信息指向:
fatal error C1083: 无法打开包括文件: "portaudio.h": No such file or directory
即使安装了 Microsoft C++ Build Tools,问题依然存在------编译器能跑了,但 pyaudio 还依赖 PortAudio 这个 C 库的头文件,Windows 上默认没有。
我查了一下 PyPI 上 pyaudio 的发布记录,发现:
- Python 3.12:有官方预编译的 wheel: pyaudio-0.2.14-cp312-cp312-win_amd64.whl
- Python 3.13:有官方预编译的 wheel: pyaudio-0.2.14-cp313-cp313-win_amd64.whl
- Python 3.14 :没有官方 wheel
而我当时用的正好是 Python 3.14 。pip 找不到预编译的 wheel,就会尝试从源码编译。在 Windows 上编译 pyaudio 需要额外安装 PortAudio 开发库,这显然不是我们想要的体验。
有两种方法可以解决这个问题:
方案一:降级到 Python 3.12 或 3.13
这是最省心的方式:
bash
# 用 uv 创建一个 Python 3.12 的虚拟环境
uv python install 3.12
uv venv --python 3.12
.venv\Scripts\activate
# 然后正常安装
pip install "realtimetts[system]"
方案二:下载第三方预编译的 wheel
如果你坚持使用 Python 3.14,可以下载第三方预编译的 pyaudio wheel:
- 项目地址:https://sourceforge.net/projects/pcpu/
- 文件地址:https://sourceforge.net/projects/pcpu/files/Python3 windows amd64/pyaudio-0.2.14-cp314-cp314-win_amd64.whl/download
下载后直接安装:
bash
pip install pyaudio-0.2.14-cp314-cp314-win_amd64.whl
安装完 pyaudio 后,再执行 pip install "realtimetts[system]" 就不会报错了。
⚠️ 安全提醒 :这是第三方个人维护的预编译包,虽然 SourceForge 会对所有下载进行恶意软件扫描,但在使用前请确保你从可靠的来源获取文件,并自行判断其安全性。
📝 关于 SourceForge 平台:
SourceForge 是一个历史悠久、规模很大的软件托管平台。但正因为它影响力大,也吸引了一些攻击者。
- 风险确实存在:近年来,有攻击者利用 SourceForge 分发过伪装成合法软件的恶意程序。这些攻击主要针对搜索"破解版"软件的用户。
- 风险不代表全盘否定 :这并不意味着 SourceForge 上所有项目都是危险的。其风险更接近于一个大型公共广场,既有正规店铺,也可能混入不法分子。关键在于你下载的是哪个具体项目。
对于下载的这个文件,可以从两方面来看:
1. 可疑之处
- 非官方来源 :PyAudio 的官方发布渠道是 PyPI。这个文件来自一个个人项目页面 (
pcpu),而非官方渠道。 - 项目信息不透明 :关于
pcpu这个项目及其维护者的公开信息非常有限,我们很难判断其可信度。
2. 相对积极的因素
- 文件性质 :你下载的是一个
.whl(wheel)文件,本质上是一个预编译的 Python 包,不是直接的.exe可执行文件,风险相对较低。 - 文件用途 :
pyaudio是知名开源库,其源代码公开。恶意行为者更倾向于伪装成游戏、办公软件等受众更广的程序,而不是一个相对小众的开发者工具。 - 平台安全措施:SourceForge 会对所有上传文件进行病毒扫描。
综合来看,这个文件大概率是安全的,但确实无法100%保证。
如果你决定使用,建议先进行安全扫描:
- 上传到 VirusTotal :这是最直接有效的方法。访问 virustotal.com,上传你下载的
.whl文件,它会用几十种杀毒引擎进行扫描。如果结果中有多个引擎报毒,就绝对不要使用。 - 检查文件哈希:在 VirusTotal 上可以查看文件的 SHA-256 等哈希值,你可以将这些值与网上其他人分享的哈希值进行比对,看是否一致。
如果你仍觉得不放心,也完全可以选择其他方案:
- 方案一:降级 Python 版本 :这是最稳妥、最推荐的方法。使用 Python 3.12 或 3.13,然后直接通过
pip install pyaudio安装官方版本,完全绕过这个第三方文件。 - 方案二:使用 Conda :Conda 也为 Python 3.14 提供了 PyAudio 的预编译包,可以通过
conda install -c conda-forge pyaudio尝试安装。

为了不降级 Python,虽然对第三方来源有些顾虑,但还是下载并安装了:
bash
pip install pyaudio-0.2.14-cp314-cp314-win_amd64.whl
这次安装成功了,没有再报编译错误。
满心欢喜地运行我的测试代码:
python
from RealtimeTTS import TextToAudioStream, SystemEngine
结果弹出了新的错误:
ModuleNotFoundError: No module named 'pyaudioop'
查看调用栈,发现是 RealtimeTTS 依赖的 pydub 库在导入时尝试加载 pyaudioop 模块,而 pyaudioop 是 Python 标准库的一部分,在 Python 3.13 中已经被移除。
也就是说,即使我通过第三方 wheel 绕过了编译阶段,运行时依然因为 Python 3.14 与旧版依赖库不兼容而失败。pydub 尚未适配 Python 3.13+,所以这条路也走不通。
折腾了一圈,我最终还是选择了最稳妥的方案------降级 Python 版本到 3.12。
使用 uv 创建 Python 3.12 虚拟环境:
bash
uv python install 3.12
uv venv --python 3.12
.venv\Scripts\activate
uv pip install "realtimetts[system]"
这一次,所有安装和运行都顺利通过。
RealtimeTTS 负责管理文本流的分段、缓冲和时序,后端引擎负责合成音频。这种解耦设计让它既灵活又稳定。
下面是一个完整的流式 TTS 演示,配合一个模拟的 LLM 流式生成器:
python
from RealtimeTTS import TextToAudioStream, SystemEngine
import time
# 初始化引擎(这里用的是系统 TTS,你也可以换成 gTTS 或 Azure)
engine = SystemEngine()
stream = TextToAudioStream(engine)
def text_generator():
"""模拟 LLM 流式输出"""
chunks = ["你好,", "欢迎来到", "语音合成教程", "今天", "我们聊聊", "流式 TTS"]
for chunk in chunks:
yield chunk
time.sleep(0.5) # 模拟生成延迟
# 喂入生成器并播放
stream.feed(text_generator())
stream.play()
运行后,你会听到:每个文本块生成后,几乎同时就开始朗读,不需要等待所有内容生成完毕。
| 对比项 | pyttsx3(分段播放) |
RealtimeTTS |
|---|---|---|
| 流式支持 | ❌ 不稳定,易卡死 | ✅ 原生支持,稳定可靠 |
| 音质 | 机械音 | 取决于后端(可换) |
| 安装 | 简单 | 稍复杂(需指定后端) |
| 适用场景 | 一次性播放完整文本 | 实时对话、流式交互 |
然后我们试试模型对话,这里为了声音好听,我们可以接入免费的edge_tts:
python
pip install "realtimetts[edge]"
然后可以提前下载好两个依赖:
-
mpv播放器:https://mpv.io/installation/ 点击进去 https://github.com/shinchiro/mpv-winbuild-cmake/releases

-
ffmpeg:https://www.gyan.dev/ffmpeg/builds/,下载稳定版

python
from RealtimeTTS import TextToAudioStream, EdgeEngine
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
# ==================== 配置 LLM ====================
# 替换成你自己的 API 信息(支持 OpenAI、智谱、DeepSeek 等兼容接口)
API_KEY = "x"
BASE_URL = "https://open.bigmodel.cn/api/paas/v4" # 智谱
MODEL_NAME = "glm-4.7"
llm = ChatOpenAI(
model=MODEL_NAME,
base_url=BASE_URL,
api_key=SecretStr(API_KEY),
temperature=0.7,
max_retries=3,
streaming=True # 启用流式输出
)
# ==================== 配置 TTS ====================
# 使用系统 TTS,手动指定中文语音(你的注册表路径)
engine = EdgeEngine()
engine.set_voice("zh-CN-XiaoxiaoNeural")
stream = TextToAudioStream(engine)
# ==================== 核心函数 ====================
def llm_stream_generator(query: str):
"""
生成器:逐块产出 LLM 的回答
"""
print("🤖 助手: ", end="", flush=True)
for chunk in llm.stream(query):
content = str(chunk.content)
if content:
print(content, end="", flush=True)
yield content
print() # 换行
# ==================== 主程序 ====================
def main():
user_input = input(">>> ")
print("正在生成回答...")
# 创建生成器
generator = llm_stream_generator(user_input)
# 喂入 TTS 并播放(这会阻塞直到播放完成)
stream.feed(generator)
stream.play()
print("✅ 播放完成")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n👋 退出")
输出结果(是一个女孩儿的声音):
python
>>> 你是谁?
正在生成回答...
🤖 助手: 我是一个大型语言模型,由Z.ai训练。
你可以把我理解为一个非常博学且富有创造力的AI大脑。我通过阅读和分析海量的文本数据(比如书籍、网页、代码等)来学习语言、知识和逻辑推理。
我没有个人身份、情感或意识,也不会像人类那样拥有主观体验。我的目标是根据你的指令,提供有用的信息、完成指定的任务(比如写文章、翻译、总结文本),或者只是和你进行一场有趣的对话。
总而言之,你可以把我当作一个知识渊博、功能多样的数字助手。有什么想聊的,或者需要我帮忙的吗?
✅ 播放完成
进程已结束,退出代码为 0
完整闭环:VAD → Whisper → LLM → TTS
现在,我们把所有模块串起来:
你说话 → VAD 检测 → 录制音频 → Whisper 转文字 → LLM 生成回答 → TTS 念出来
python
import queue
import numpy as np
import sounddevice as sd
import torch
from silero_vad import load_silero_vad, VADIterator
from faster_whisper import WhisperModel
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from RealtimeTTS import TextToAudioStream, EdgeEngine
# ==================== 配置 ====================
SAMPLE_RATE = 16000
CHANNELS = 2
CHUNK_SIZE = 512 # 32ms
# VAD 参数
VAD_THRESHOLD = 0.5
MIN_SILENCE_MS = 800
SPEECH_PAD_MS = 30
# LLM 配置(智谱为例)
API_KEY = "x"
BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
MODEL_NAME = "glm-4.7"
# ==================== 初始化各模块 ====================
# 1. VAD
vad_model = load_silero_vad()
vad_iterator = VADIterator(
model=vad_model,
threshold=VAD_THRESHOLD,
sampling_rate=SAMPLE_RATE,
min_silence_duration_ms=MIN_SILENCE_MS,
speech_pad_ms=SPEECH_PAD_MS,
)
# 2. Whisper
whisper = WhisperModel("D:/LLM/faster-whisper-medium")
# 3. LLM
llm = ChatOpenAI(
model=MODEL_NAME,
base_url=BASE_URL,
api_key=SecretStr(API_KEY),
temperature=0.7,
max_retries=3,
streaming=True,
)
# 4. TTS
tts_engine = EdgeEngine()
tts_engine.set_voice("zh-CN-XiaoxiaoNeural")
tts_stream = TextToAudioStream(tts_engine)
# ==================== 工具函数 ====================
def llm_stream_generator(query: str):
"""流式调用 LLM,逐块产出回答"""
print("🤖 助手: ", end="", flush=True)
full_text = ""
for chunk in llm.stream(query):
content = str(chunk.content)
if content:
print(content, end="", flush=True)
full_text += content
yield content
print()
return full_text
# ==================== 音频采集 ====================
audio_queue = queue.Queue()
def audio_callback(indata, frames, time_info, status):
if status:
print(status)
audio = indata[:, 0].copy() if indata.shape[1] > 1 else indata.flatten().copy()
audio_queue.put(audio)
# ==================== 主循环 ====================
def main():
print("🎙️ 语音助手已启动... (按 Ctrl+C 退出)")
print(" 对着麦克风说话,程序会自动识别并回答\n")
recording = []
speaking = False
with sd.InputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
blocksize=CHUNK_SIZE,
callback=audio_callback
):
while True:
chunk = audio_queue.get()
tensor = torch.from_numpy(chunk)
vad_result = vad_iterator(tensor, return_seconds=False)
if vad_result:
if "start" in vad_result:
print("\n>>> 检测到语音,开始录音...")
speaking = True
recording = []
elif "end" in vad_result:
print("<<< 录音结束")
speaking = False
if recording:
audio = np.concatenate(recording)
duration = len(audio) / SAMPLE_RATE
print(f"📁 音频片段: {duration:.2f} 秒")
# 1. Whisper 转文字
segments, info = whisper.transcribe(audio, language="zh")
text = "".join(seg.text for seg in segments)
print(f"📝 用户: {text}")
# 2. LLM 生成回答(流式打印 + TTS 流式播放)
generator = llm_stream_generator(text)
tts_stream.feed(generator)
tts_stream.play() # 阻塞直到播放完成
print("---")
else:
print("⚠️ 录音为空,跳过")
if speaking:
recording.append(chunk)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n👋 再见!")
运行效果:
🎙️ 语音助手已启动... (按 Ctrl+C 退出)
说话即可,程序会自动识别并回答
>>> 检测到语音,开始录音...
<<< 录音结束
📁 音频片段: 2.70 秒
📝 用户: 今天北京天气怎么样?
🤖 助手: 今天北京晴天,气温18到28度,适宜外出活动。
🔊 播放: 今天北京晴天,气温18到28度,适宜外出活动。
---
>>> 检测到语音,开始录音...
<<< 录音结束
📁 音频片段: 1.85 秒
📝 用户: 那明天呢?
🤖 助手: 明天北京多云,气温16到26度,可能会有小雨。
🔊 播放: 明天北京多云,气温16到26度,可能会有小雨。
---
"回声"问题
在上述代码的运行中,你会发现一个非常经典的问题!语音交互系统中的**"回声"问题**------TTS 播放的声音被麦克风重新捕捉,导致系统把自己的语音输出当成了新的用户输入,形成了一个"自己和自己说话"的无限循环。
在当前的代码中,音频采集(InputStream)是持续运行 的,而 TTS 播放是阻塞式 的(tts_stream.play() 会阻塞直到播放完成)。但问题在于:
- TTS 播放时,
InputStream仍然在后台录音。 - TTS 的声音从扬声器出来,被麦克风重新捕捉。
- 这些音频被 VAD 检测到,当成新的"用户说话"触发新一轮录音。
- 新一轮录音的内容是 TTS 的回声,Whisper 识别后发给 LLM,LLM 又生成回答,TTS 又播放......形成无限循环。
即使你用的是耳机(没有外放),如果麦克风敏感度较高,仍然可能捕捉到微弱的声音。
核心思路:TTS 播放期间暂停录音。
具体做法:在 TTS 播放前设置一个标志,让音频回调函数丢弃收到的音频数据;播放完成后恢复录音。
python
import queue
import numpy as np
import sounddevice as sd
import torch
from silero_vad import load_silero_vad, VADIterator
from faster_whisper import WhisperModel
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from RealtimeTTS import TextToAudioStream, EdgeEngine
# ==================== 配置 ====================
SAMPLE_RATE = 16000
CHANNELS = 2
CHUNK_SIZE = 512 # 32ms
# VAD 参数
VAD_THRESHOLD = 0.5
MIN_SILENCE_MS = 800
SPEECH_PAD_MS = 30
# LLM 配置
API_KEY = "x"
BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
MODEL_NAME = "glm-4.7"
# ==================== 初始化各模块 ====================
# 1. VAD
vad_model = load_silero_vad()
vad_iterator = VADIterator(
model=vad_model,
threshold=VAD_THRESHOLD,
sampling_rate=SAMPLE_RATE,
min_silence_duration_ms=MIN_SILENCE_MS,
speech_pad_ms=SPEECH_PAD_MS,
)
# 2. Whisper
whisper = WhisperModel("D:/LLM/faster-whisper-medium")
# 3. LLM
llm = ChatOpenAI(
model=MODEL_NAME,
base_url=BASE_URL,
api_key=SecretStr(API_KEY),
temperature=0.7,
max_retries=3,
streaming=True,
)
# 4. TTS
tts_engine = EdgeEngine()
tts_engine.set_voice("zh-CN-XiaoxiaoNeural")
tts_stream = TextToAudioStream(tts_engine)
# ==================== 状态 ====================
# AI 是否正在说话
#
# 非常重要:
# audio_callback() 会持续运行,
# 所以这里用这个变量告诉 callback:
#
# "现在是 AI 在说话,麦克风数据不要了。"
assistant_speaking = False
# ==================== LLM ====================
def llm_stream_generator(query: str):
"""流式调用 LLM,逐块产出回答"""
print("🤖 助手: ", end="", flush=True)
for chunk in llm.stream(query):
content = str(chunk.content)
if content:
print(content, end="", flush=True)
yield content
print()
# ==================== 音频采集 ====================
audio_queue = queue.Queue()
def audio_callback(indata, frames, time_info, status):
global assistant_speaking
if status:
print(status)
# ==================================================
# AI 正在播放声音
#
# 直接丢弃麦克风数据!
#
# 这样 AI 的声音就不会进入 VAD。
# ==================================================
if assistant_speaking:
return
if indata.shape[1] > 1:
audio = indata[:, 0].copy()
else:
audio = indata.flatten().copy()
audio_queue.put(audio)
# ==================== 清空音频队列 ====================
def clear_audio_queue():
"""清空 AI 播放期间可能残留的麦克风数据"""
while True:
try:
audio_queue.get_nowait()
except queue.Empty:
break
# ==================== 主循环 ====================
def main():
global assistant_speaking
print("🎙️ 语音助手已启动... (按 Ctrl+C 退出)")
print(" 对着麦克风说话,程序会自动识别并回答\n")
recording = []
speaking = False
with sd.InputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
blocksize=CHUNK_SIZE,
callback=audio_callback
):
while True:
# ==========================================
# 获取麦克风数据
# ==========================================
chunk = audio_queue.get()
tensor = torch.from_numpy(chunk)
# ==========================================
# VAD
# ==========================================
vad_result = vad_iterator(
tensor,
return_seconds=False
)
if vad_result:
# ======================================
# 开始说话
# ======================================
if "start" in vad_result:
print("\n>>> 检测到语音,开始录音...")
speaking = True
recording = []
# ======================================
# 结束说话
# ======================================
elif "end" in vad_result:
print("<<< 录音结束")
speaking = False
if recording:
# ==================================
# 合并音频
# ==================================
audio = np.concatenate(recording)
duration = len(audio) / SAMPLE_RATE
print(
f"📁 音频片段: {duration:.2f} 秒"
)
# ==================================
# 1. Whisper
# ==================================
segments, info = whisper.transcribe(
audio,
language="zh"
)
text = "".join(
seg.text for seg in segments
).strip()
print(f"📝 用户: {text}")
# ==================================
# 防止空文本
# ==================================
if not text:
print("⚠️ 没有识别到有效文本")
continue
# ==================================
# 2. LLM + TTS
# ==================================
print("🤖 AI 正在思考...")
# ----------------------------------
# 非常重要:
#
# 从这里开始,AI 会产生声音。
#
# audio_callback() 会开始丢弃
# 麦克风数据。
# ----------------------------------
assistant_speaking = True
try:
generator = llm_stream_generator(text)
tts_stream.feed(generator)
# 阻塞直到 TTS 播放完成
tts_stream.play()
finally:
# ----------------------------------
# 仍然保持 True
#
# 先清理播放期间产生的残留数据。
# ----------------------------------
clear_audio_queue()
# ----------------------------------
# 重置 VAD 状态
#
# 因为 AI 播放期间 VAD 没有收到
# 音频,所以它可能还停留在
# "speech started" 状态。
# ----------------------------------
vad_iterator.reset_states()
speaking = False
recording = []
# ----------------------------------
# 最后才恢复麦克风监听
# ----------------------------------
assistant_speaking = False
print("---")
else:
print("⚠️ 录音为空,跳过")
# ==========================================
# 正在说话 -> 保存音频
# ==========================================
if speaking:
recording.append(chunk)
# ==================== 程序入口 ====================
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n👋 再见!")
这里主要改了 4 个地方。
① 增加 assistant_speaking
python
assistant_speaking = False
它表示:
False= 用户阶段,正常监听麦克风True= AI 阶段,忽略麦克风
② 在 audio_callback() 里面直接丢弃
这是最关键的:
python
if assistant_speaking:
return
所以当 assistant_speaking = True 之后:
AI 🔊 → 麦克风 🎤 → audio_callback() → assistant_speaking == True → return
根本不会进入 audio_queue。 这比在主循环里 if assistant_speaking: continue 更好,因为后者会导致 audio_queue 越积越多。
③ TTS 播放结束后清空队列
python
clear_audio_queue()
因为 sounddevice 的 callback 是独立持续运行的。假设 AI 播放过程中已经有一些音频进入队列,恢复监听之前必须把它们扔掉。
④ 重置 VAD
python
vad_iterator.reset_states()
因为我们在 AI 播放期间故意不向 VAD 喂音频 ,VAD 内部状态可能还停留在 SPEECH START。恢复麦克风之后,如果不 reset,可能会出现一些奇怪的状态。
这样就能解决现在的 "AI 和自己说话"无限循环 。不过这只是一个阶段性的 半双工方案 ,它有一个明显限制:AI 说话的时候,你不能打断它。
下一阶段如果你想把它做成真正的语音 Agent,就应该把:
用户 → VAD → Whisper → LLM → TTS → 用户
改造成 并发 Pipeline + Barge-in(用户打断):
┌── VAD/ASR ──→ LLM ──→ TTS
│
🎤 麦克风 ──────┤
│
└────── 检测用户打断
│
▼
停止 TTS 🔇
写在最后
到这里,一个最基础的语音 Agent 就算是跑起来了。
从最开始的 sounddevice 采集麦克风音频,到 Silero VAD 判断用户什么时候开始说话、什么时候停止说话,再交给 faster-whisper 把声音转换成文字,经过大模型生成回答,最后通过 EdgeTTS 将文字重新变成声音播放出来。看起来只是"说话 → AI 回答"这么简单的一件事情,真正自己动手实现一遍之后才会发现,中间其实藏着这么多细节。
尤其是实时语音和普通的文本 Agent 完全不是一回事。文本 Agent 可以等用户把一句话完整输入之后再处理,而语音 Agent 从麦克风接收到的永远是一段一段连续到来的音频。什么时候算"开始说话"?什么时候算"说完了"?如何避免环境噪声触发 VAD?如何让 LLM 的输出一边生成一边进入 TTS?这些问题都需要我们真正把数据流跑起来之后才能体会。
而当这一套流程真正跑通之后,你会发现所谓的"语音 Agent"其实也没有想象中那么神秘:
text
麦克风
↓
VAD
↓
Speech-to-Text
↓
LLM
↓
Text-to-Speech
↓
扬声器
本质上就是把几个独立的模型和音频组件串成了一条实时的数据流水线。
当然,现在这个版本距离真正意义上的"智能语音助手"还有一些距离。
比如还有一个非常有意思的问题:
当 AI 正在通过扬声器说话的时候,如果用户突然开口,它会发生什么?
如果麦克风和扬声器距离比较近,你很快就会发现一个非常经典的问题:
text
AI:"你好,我是你的语音助手......"
↓
扬声器
↓
麦克风
↓
"检测到了人声"
↓
"用户说话了!"
于是 AI 开始和自己聊天。
这并不是 VAD 出了问题。恰恰相反,VAD 非常正确地判断出了"这里存在人类语音"。问题在于,它并不知道这个声音究竟来自用户 ,还是来自扬声器中的 AI。
这就引出了语音交互领域一个非常经典的话题:
AEC(Acoustic Echo Cancellation,声学回声消除)。
真正成熟的语音助手,需要能够区分"我正在播放的声音"和"用户正在说的声音",甚至还要支持用户在 AI 说话的过程中随时打断它,也就是所谓的 Barge-in。
到这里,这篇文章就先不继续往下挖了。
毕竟当你开始研究 AEC 的时候,就已经不再只是"调用几个模型"这么简单了,而是开始真正进入实时音频处理的世界。
下一篇,再继续折腾这个坑。
项目资源和代码已归档于:https://github.com/QwQzy/pyvoiceagent
