一个本地 AI 中控要做到「随叫随到」,得先解决两件事:听清(ASR)和听见(唤醒)。 这两件事在国内网络环境下各有一串坑,我把踩过的都记在这了。
做语音时最常见的第一个错误,是把「语音识别」和「语音唤醒」当成一件事。
语音识别(ASR) | 语音唤醒(Wake Word) | |
|---|---|---|
干什么 | 把波形转成文字 | 判断「刚才那声是不是在叫我」 |
输出 | 一整句文本 |
|
谁来打分 | 语言模型(解码整段声学特征) | 小分类器(跑在冻结的特征提取器上) |
延迟要求 | 秒级可接受 | 毫秒级,必须常驻 |
能不能云端 | 可以(但违数据主权) | 绝对不行,要 7×24 在线 |
本项目对应 | Vosk / FunASR / faster-whisper | openWakeWord / Porcupine / Vosk 关键词 |
这个区分为什么重要?因为它决定了两条完全不同的工程路径。
ASR 是「按需调用」的——用户按住说话才启动,装 1.3GB 还是 42MB 只影响加载时间。
唤醒是「常驻」的——它 7×24 开着,每 80 毫秒算一次分,CPU 占用和内存都是硬约束。
我在这一篇里要讲的三个大坑,恰好一头踩在 ASR 上,一头踩在唤醒上,还有一个踩在两边交汇的地方。
用户反馈:「语音识别老是识别错,换个大模型是不是就好了?」
我当时的第一反应是——那肯定是他还没下大模型。vosk 官方提供了中文大模型 vosk-model-cn-0.22(1.3GB),WER(字错率)官方数据是:
模型 | 体积 | WER · SpeechIO-02 | WER · THCHS |
|---|---|---|---|
| 42 MB | 23.54 | 17.15 |
| 1.3 GB | 13.98 | 7.43 |
错误率近乎腰斩,值得换。
于是我写了个下载脚本,做了断点续传、大小校验、ZIP 完整性校验、磁盘预检、退避重试——脚本本身没问题。
然后我去看了一眼加载路径,发现问题根本不在模型上。
lingjing_voice.py 原本的模型候选列表是按体积从小到大排的,而查找函数 find_model_path()(L99)的逻辑是:
# 遍历候选列表,返回【第一个】命中的目录两件事凑在一起,就是一个必然结果:
只要 42MB 小模型还躺在
models/目录里,那 1.3GB 大模型永远不会被加载。 哪怕你亲手把它下载好、放在同一个目录下。
这不是「模型不好」,是「模型永远轮不到上班」。用户以为换了模型,其实在换寂寞。
改完的候选列表(lingjing_voice.py L61-63):
_FORCE_MODEL = os.environ.get("LINGJING_VOSK_MODEL", "").strip()
# 【关键】顺序即优先级:大模型优先
_MODEL_CANDIDATES = (
os.path.join(MODEL_DIR, "vosk-model-cn-0.22"), # 约 1.3GB,准确率最高
os.path.join(MODEL_DIR, "vosk-model-small-cn-0.22"), # 约 42MB,体积小、速度快
os.path.join(MODEL_DIR, "vosk-model-cn"), # 其它版本
)加上 LINGJING_VOSK_MODEL 环境变量作为逃生舱——低配机吃不住 1.3GB(官方提示最高可能需要约 16GB 内存),可以一键指回小模型:
set LINGJING_VOSK_MODEL=D:\lingjing_test\models\vosk-model-small-cn-0.22改完立刻跑全量回归:556 项断言全通过 / 0 失败。
任何「遍历候选 + 取第一个命中」的加载逻辑,都是隐形的优先级陷阱。 你以为你装了什么不重要——顺序才决定谁生效。
我后来把这条写进了项目的工程记忆。它在模型加载、插件加载、配置文件读取这几个场景里反复出现。
我做唤醒的时候选的是 openWakeWord(Apache-2.0),理由很实在:
安检时 check_safety.py 报了 BLOCK=2 / WARN=7 → REJECT。逐条人工复核后,两条 BLOCK 都是条件不可达:
位置 | 命中 | 复核结论 | |
|---|---|---|---|
B1 |
|
| 仅当调用方显式传入非空 |
B2 |
|
| 运行时不可达—— |
WARN 7 条性质正常(1 处模型下载是显式调用才触发,无静默联网)。
但真正的问题在功能层面:openWakeWord 官方预训练唤醒词只有
alexa / hey_jarvis / hey_mycroft / hey_rhasspy / timer / weather全是英文。「灵境」没有现成模型,必须自训练。而官方指定的训练环境是 Google Colab。
colab.research.google.com 连不上。原训练方案直接作废。
这不是「再等等」的问题——是路线要重选。我不可能让辉哥为了训一个唤醒词去搞科学上网。
我重写了一整套《国内平替平台训练指南》,核心是三个替换:
替换一:训练平台 → 国内免费 GPU
原方案 | 平替方案 | 说明 |
|---|---|---|
Google Colab | 百度 AI Studio(V100/T4,免费 8h/日) | 账号直接登录,界面与 Colab 几乎一致 |
— | 阿里魔搭 ModelScope(A10 免费额度) | 备选 |
替换二:HuggingFace → 国内镜像(这条是卡死重灾区)
训练要拉特征文件和噪声数据集,直连 HF 会卡到怀疑人生。解法是改环境变量:
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"替换三:GitHub 拉仓库 → REST API 通道
这一条我要单独强调,因为它是当晚最阴的一个坑。
我 git clone openWakeWord 时成功了——但那纯属侥幸。08:00 之后 github.com 彻底 502 / CONNECT 000,而 api.github.com 和 raw.githubusercontent.com 依然可达。
在代理环境下,直连
github.com是不稳定的。正确姿势是走 REST API:GET /repos/{repo}/releases/tags/v0.5.1 → 取资源清单 GET /releases/assets/{id} → 带 Accept: application/octet-stream全程只走 GitHub 官方域名,不引入任何第三方镜像。
下载脚本还得做:60 秒超时、重试 3 次带指数退避 + 抖动、按官方 size 逐字节校验、打印 SHA256。
给「灵境」写死的配置(已按国内网络与中文适配):
target_phrase: ["灵境"]
custom_negative_phrases: ["你好", "小艺小艺", "小度小度", "天猫精灵",
"Hey jarvis", "灵镜", "零境"]
n_samples: 1000
n_samples_val: 500
steps: 10000 # 免费 T4/A10 约 10~30 分钟;想更准加到 30000~50000
model_type: fc
layer_size: 32
target_false_positives_per_hour: 0.2负样本里特意塞了「灵镜」「零境」——近似音必须进负样本,否则模型分不清。
正样本则是 300 条中文 TTS 合成音频(一键生成唤醒音频.bat 一键生成)。
这是 openWakeWord 最漂亮的地方——模型和代码解耦:
# 把灵境.onnx 丢进模型目录
"$PY" voice_wakeup.py --model 灵境.onnx --threshold 0.4 --verbose模块会自动用文件名(去 .onnx)作为唤醒词键名,命中时回调 (key="灵境", score=...)。
换唤醒词这件事,从「改代码」降级成了「换文件」。 这才是好的架构。
辉哥实测中文发音说「Hey Jarvis」,唤醒分峰值只有 0.0776——远低于官方推荐的 0.5 阈值。
降阈值能触发。但降阈值带来一个更糟的问题:
在非安静环境下,阈值 0.05 下 5 秒误触发 6 次,峰值冲到 0.1901——一声空调 / 电流瞬态就把我叫醒了。
0.05 是「能响」但不是「能用」的阈值。纯降阈值是权宜之计,不是解法。
我写了 _record_and_analyze.py,做本地 DSP 增强(带通滤波 / 预加重 / AGC / 组合),然后在仓库自带的真人语音上做自检:
变体 | hey_jarvis 分 | alexa 分 |
|---|---|---|
原始 | 0.0000 | 1.0000 |
带通 / 预加重 / AGC / 组合 | 0.0000 | 0.99+ |
这张表是整篇文章里最重要的一张表。
它的意思是:无论怎么增强,"alexa" 的音频都不可能变成 "hey_jarvis" 的分数。
同时另一组数据:组合增强把我环境里的噪声尖峰从 0.2342 压到 0.0008。
两件事合起来,结论非常干净:
DSP 增强能压误报,但抬不动唤醒分。 因为问题根本不是「音质差 / 音量小 / 有噪声」, 而是音素 mismatch——中文口音的 "Hey Jarvis" 和英文训练集里的声学特征根本不对齐。
方案 | 做法 | 判定 |
|---|---|---|
A-纯降阈值 |
| 能触发,但误报高,仅权宜 |
A-增强+低阈值 | 组合增强 + 阈值 0.1~0.15 | 推荐过渡:增强压误报,分略升 |
B-中文自训练 | 训「灵境 / 贾维斯」专模 | 根因解 |
D-换引擎 | Porcupine 免训练中文 | 当天就能用(见下节) |
历史录音 RMS 只有约 523/32767,偏低。音量不足本身也会显著拖累识别率——所以「模型换大之后还是不准」的时候,第一件事应该是检查输入电平,而不是继续折腾模型。
既然 B 方案(自训练)需要平台和半小时,我顺手把「今天就能用」的路也铺好了。最终形成三套接口完全同构的唤醒引擎:
引擎 | 需训练 | 联网需求 | 中文「灵境」 | 推理位置 |
|---|---|---|---|---|
openWakeWord | ✅ 需训 | 训练需 GPU 平台 | ✅ 自训专模 | 本地 ONNX |
Porcupine | ❌ 免训练 | 仅一次性下 | ✅ 官方中文模型 | 本地 100% |
Vosk 关键词 | ❌ 免训练 | ❌ 断网也行 | ✅ 本机已有模型 | 本地 100% |
Porcupine 是 Picovoice 的商业级唤醒引擎,自带中文模型 porcupine_params_zh.pv。
流程:
灵境.ppn;porcupine_params_zh.pv;handle = pvporcupine.create(
access_key,
keyword_paths=["灵境.ppn"],
model_path="porcupine_params_zh.pv",
)
# 每帧 int16 音频喂进去
if handle.process(int16_frame) >= 0:
print("唤醒!")推理 100% 在本机跑,不联网、不上传。唯一需要网络的就是「去控制台生成 .ppn」这一步,一次性。
既然 ASR 已经用 Vosk 了,那唤醒为什么不能也用?答案是完全可以:
麦克风实时采音 → Vosk 识别中文 → 文本里出现"灵境"即触发不训练、不联网、不注册、不下载任何东西——本机已有中文模型。
代价是:Vosk 是通用识别,可能把「灵镜 / 零境」等近似音也触发,CPU 占用比专用引擎高。作保底 OK,日常主力仍建议 Porcupine 或自训模型。
三个脚本对外接口完全同构:
on_detect(key, score) 回调
start() / stop() / run() 方法
后台守护线程采音 · 冷却去抖 · PCM 不落盘不上传未来可以互替换,或者加一个 --engine 统一开关,互不影响主代码。这就是好设计的味道——加一条新路线不用改旧路线。
做语音时我顺手把输出端(播报)也修了,因为它的两个坑和识别端遥相呼应。
现网 lingjing_tts.py 的播放链路:
位置 | 函数 | 起子进程? | 说明 |
|---|---|---|---|
L93–123 |
| ❌ 无 | ctypes 直调 |
L126–150 |
| ✅ 有 |
|
L153–161 |
| 条件 | 先试 MCI,失败则兜底到 PowerShell |
所以黑框的真正成因是:_mci_play() 在某些环境返回 False → 自动兜底到 PowerShell → 闪黑框。
而断网发不出声是另一个独立问题:_speak_blocking() 用 edge_tts.Communicate 合成,必须联网。
一句话:黑框来自播放兜底的子进程,断网哑来自云端合成。这是两个独立问题,必须分别治。
import win32com.client
speaker = win32com.client.Dispatch("SAPI.SpVoice")
# 中文 LCID: 804 简体 / 404 繁体 / c04 香港 / 1004 新加坡 / 1404 澳门
for v in speaker.GetVoices():
if (v.GetAttribute("Language") or "").lower() in ("804", "404", "c04", "1004", "1404"):
speaker.Voice = v
break
speaker.Speak(text, 1) # 1 = SVSFlagsAsync,调用立即返回,不阻塞 UI实测数据:
文本长度 | 合成耗时 | 音频时长 | 实时率 RTF | WAV 体积 | RMS |
|---|---|---|---|---|---|
6 字 | 246 ms | 2.32 s | 0.106 | 99.9 KB | 1000.4 |
22 字 | 265 ms | 5.91 s | 0.045 | 254.5 KB | 1723.9 |
66 字 | 252 ms | 17.28 s | 0.015 | 744.1 KB | 2095.7 |
几个关键点:
Microsoft Huihui Desktop - Chinese (Simplified)(LCID 804,Female);方案 | 劝退理由 |
|---|---|
ChatTTS | 依赖 |
Piper | 原版 |
「能跑」和「能打包分发」是两件事。AGPL/GPL 在分发场景是硬伤,不能因为「跑起来了」就放行。
用例 | 实测 | 判定 |
|---|---|---|
环境自检(openwakeword 0.6.0 / onnxruntime 1.31.1 / 模型齐备) | — | PASS |
正样本 | 峰值 1.0000 | PASS |
正样本 | 峰值 1.0000 | PASS |
负样本 · 3 秒静音 | 峰值 0.0000 | PASS(零误报) |
负样本 · 3 秒白噪声 | 峰值 0.0314 | PASS |
麦克风实时 5 秒(Cirrus Logic 麦克风) | 62 帧、均值 RMS 1512、峰值 0.0038、触发 0 次 | 采音通路正常、底噪零误报 |
推理性能 | 均值 2.06 ms/帧、P95 2.02 ms → CPU 占用约 2.6% | 可 7×24 常驻 |
辉哥中文口音峰值 | 0.0776(阈值 0.05 下 5 秒误触发 6 次,峰值 0.1901) | 音素 mismatch,坐实 |
DSP 增强自检 | hey_jarvis 分恒为 0.0000;噪声尖峰 0.2342 → 0.0008 | 增强压噪不抬分 |
ASR 模型优先级修复后回归 |
| 未跌破基线 |
SAPI5 合成(6/22/66 字) | 246 / 265 / 252 ms,RMS 1000~2095 | 真实有声内容 |
--mic 15,喊 5~10 遍回报次数。pip install imageio-ffmpeg(自带静态 ffmpeg)。.ppn 需要你亲自去 console.picovoice.ai 生成——这是唯一涉及外部账号的步骤,我不能替你注册。_sandbox/tts_local_test.wav(2.32s,22050Hz)。给 AI 装耳朵,难点从来不是「能不能识别」,而是「能不能常驻、不误报、不联网」。 三个坑分别对应三层:加载顺序坑 → 让你辛苦下的模型永远不上场(解:优先级显式化 + 逃生舱环境变量) 网络生态坑 → 让你依赖的官方训练环境在国内用不了(解:国内平台平替 + API 通道 + 镜像) 音素 mismatch 坑 → 让你用降阈值这种会自欺欺人的手段硬凑(解:认清根因,换引擎或自训专模) 最后那条最重要:当一个方案需要你不断调低阈值来换取"能触发",它多半已经错了。 阈值是最后一根稻草,不是解决方案。
文件 | 作用 |
|---|---|
| openWakeWord 唤醒模块(独立,不 import 任何 |
| Porcupine 中文唤醒(免训练首选) |
| Vosk 离线关键词触发(断网兜底) |
| ASR 采集 + 识别(已修为大模型优先) |
| 三引擎统一入口(vosk / funasr / whisper) |
| 播报链路(SAPI5 本地优先,零子进程) |
| 双击即测 |
| 造 300 条中文正样本 |
| 下载 1.3GB 大模型(断点续传 + 校验) |
本文所有性能数字均为本机真机实测,WER 数据引自 Vosk 官方模型页原文,非估算。
首发于腾讯云开发者社区。WER 与耗时数据均来自本机真机评测。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。