本文是「数字伴侣建造记」的第四篇。她需要一个“身体”——最初是浏览器标签,后来想要无边框的独立窗口,再后来想让她成为桌面壁纸本身。三种形态若各自实现,维护成本会失控。本文记录让三者共存的架构决定,以及为此打的三场硬仗。
原则:页面是显示器,不是应用
规则只有一条:任何状态、任何决策、任何有副作用的动作都发生在本机服务端,页面通过 WebSocket 收事件、通过 HTTP 发请求,自身不保存真相。由此获得的性质:
- 三种宿主加载同一个 URL,互为备份;
- 页面全关,系统照常运转(语音照说、热键照用);
- 每个“宿主特有的缺陷”都可以在服务端绕过——这句话是下面三场战役的共同主题。
战役一:耳朵搬进服务端
语音识别最初用浏览器的 Web Speech API。壁纸宿主上它死于一个无解的交互:麦克风权限气泡是原生控件,壁纸软件只把鼠标转发给网页内容,气泡永远点不到。且 WebView2 的语音后端本身不可靠。
解法是釜底抽薪:麦克风属于机器,不属于页面。服务端直接录音,本地 whisper 模型转写(16GB 显卡上 45 秒音频约 0.7 秒转完),文字走与打字输入相同的通道。页面从此不需要任何权限,三种宿主平等获得语音输入;隐私上,私密语境的语音永不出本机。
两个配套设计值得记录:
自听抑制。开着持续识别时,她自己的声音会从音箱进入麦克风——不处理的话她会跟自己对话。方案:播放侧上报“她的播放窗口”,服务端记一个绝对截止时刻而非布尔标志——上报方中途死掉,截止时刻自然过期,标志位永远不会卡死。识别段落若在窗口内开始则丢弃;按住说话不受抑制(按着键说话就是想插话,这是意图,不是回声)。
CUDA 缺库要到推理时才暴露。模型构造成功不代表能用——缺 cuBLAS 时构造照样成功,第一次转写才崩。因此加载后必须用半秒静音做一次“热身转写”来验真,失败则降级 CPU。另一个坑:动态库搜索走的是普通 LoadLibrary,只认 PATH,Python 侧的 add_dll_directory 不够。
战役二:喉咙搬进服务端
触发事件:壁纸软件在其他窗口获得焦点时会冻结壁纸进程——她正说到一半,整个人被暂停。在宿主设置里与各种暂停策略搏斗数轮后放弃,回到原则:声音属于机器,不属于页面。
服务端直接解码音频、写系统音频流。收益清单比预期长:任何宿主冻结都不再影响说话;多宿主同开时的重复出声永久消失(单一声源);浏览器“点击解锁自动播放”的遮罩不再需要;打断精度提高到约 40 毫秒(按块写流,随时可停);自听抑制的时间窗从“页面上报”变成服务端自己掌握的精确值。
一个环境陷阱在此期间杀过整条语音链路:日文区域设置下,重定向到文件的日志流回落到 cp932 编码,打印一句简体中文直接抛异常——而那行打印恰好在语音转写完成的必经之路上。修复是强制 UTF-8 并让打印永不抛错。教训一句话:日志不该有能力弄死它观察的东西。
战役三:手
全局快捷键最初用 Win+字母,与系统快捷键全线冲突。转向一块可编程宏键盘,让实体键发送 F13–F19——这段 USB HID 规范里存在、实体键盘上不存在的键位,永不冲突。监听端用系统原生 RegisterHotKey(发现依赖的第三方热键软件其实从未安装成功之后,索性用 Python 原生重写并入启动脚本,从此常驻)。
真正的对手是按键抖动:宏键盘一次按压可能抖出多个事件。它先后击穿三处,各自需要不同的对策:
| 现场 | 症状 | 对策 |
|---|---|---|
| 按住说话键 | 按住期间“接触不良”式反复启停 | 松键去抖:抬起后 120ms 内重新按下视为抖动 |
| 模式切换键 | 密码框关了一个又弹一个 | 串行化:互斥锁保证同时只有一个对话框 + 关闭后冷却 |
| 壁纸上的网页按钮 | 开关类按钮“点了没反应”(双击自我抵消) | 捕获层去重:同目标 250ms 内的第二次点击视为回声 |
同一块硬件的同一个毛病,在三个不同的抽象层需要三种不同的修法——这大概是本篇最工程的一课。
可复用的原则
- 宿主越多,逻辑越要下沉;“页面是显示器”这条纪律的回报随宿主数量线性增长。
- 属于机器的能力(麦克风、扬声器、热键)不要借道页面实现,宿主的每个限制都会变成你的限制。
- 超时与截止时刻优于布尔标志:持有者死亡时,前者自愈,后者永久卡死。
- 对“构造成功”保持怀疑,用最小真实调用验真。