抽屉里那台 2016 年的手机,现在是我桌上的"瑶瑶"
小智那个开源语音助手,我看了一阵子了。官方玩法是买个 ESP32 开发板,配麦克风喇叭,刷固件,得到一个小盒子。
每次准备下单都会卡一下。因为抽屉里躺着一台 OPPO A37m,2016 年的,MT6750,Android 5.1,换下来之后一直没扔。
有天想明白一件事。ESP32 得外挂麦克风、喇叭、屏幕、摄像头、电池管理,才能干这一件事。这台手机上这些东西全都有。八核 A53,内存比 ESP32 多出几个数量级,还有彩色屏幕、摄像头、锂电池、完整的 WiFi 协议栈。
我为什么要买个更弱的东西,去替代我本来就有、而且强得多的东西。
于是这事就变成了:让这台手机自己变成那个小盒子。
官方客户端装不上
最省事当然是用现成的客户端。翻了几个开源实现,挑了个看起来最完整的。
装不上。minSdk 要 24,我这机器是 API 22。
差两个大版本,不是改个数字能糊过去的。新版 Flutter 的运行时本身就要求 24,底下那个录音库也是。降级、翻旧版本,都试了。
要么换手机,要么自己写。
我不想换手机,那就自己写。不用 Gradle,手写编译脚本,手动打包 APK。这台机器上跑不动现代那套工具链。
把源码当说明书
自己写,先得知道协议长什么样。
好在官方 ESP32 固件是开源的。我干脆把那堆 C++ 当说明书看:怎么发 hello、怎么协商音频参数、16kHz 的 Opus 帧怎么一帧帧推上去、服务器会怎么回。看进去之后其实不难,就是啰嗦。
中间有个没想到的问题:Opus 编解码在纯 Java 里,跑得动这颗 2016 年的 CPU 吗。跑得动,但要压。复杂度从默认 9 降到 3,关掉音调分析,一帧 60ms 的音频编码大概 18.8 毫秒,2.6 倍实时。够。
然后卡在一个更隐蔽的地方。
反复连、反复失败,服务器一直回一句很客气的话:No firmware version info was found。翻译过来就是 "我不知道你是谁"。
我一度以为是鉴权头写错了,或者是设备 ID 格式、协议版本的问题,来回折腾了很久。最后才发现这协议要求设备先报到一次:往一个 OTA 接口 POST 一份设备信息,服务器登记过你,才让你建会话。
日志里第一次不再出现那句话的时候,我知道通了。
然后喇叭里传出来一句:
你好呀。
那一下其实没什么 "好厉害" 的感觉,更像是接上了。一台躺了好几年的机器,突然开始有回应。
免提这件事,被数据否了
有了对话就开始不满足。按住说话太麻烦,我想要免提:手机放桌上,我说话它听,它说话我听,中间不碰。
技术上这是 AEC,回声消除。喇叭放出来的声音会原封不动被自己的麦克风收进去,得从采集信号里减掉。
Android 本来有现成的方案。一个个试:
| 试的办法 | 结果 |
|---|---|
| 系统自带的回声消除音效 | 一启用,麦克风读出来直接是 0 帧 |
| 切通话模式 | 麦克风被系统静音,峰值从 15726 掉到 14 |
| 换 VOICE_COMMUNICATION 音源 | 信号弱到没法用 |
| 自己写一个 | 见下 |
自己写的是个自适应滤波器,NLMS。思路很朴素:我知道自己在放什么,那就把它从麦克风里减掉。
跑起来,回声反而变大了。滤波发散了。
我加了段诊断想找延迟,打出来的东西让我看了几秒:
回声延迟: 开头段 4473 采样 (279.6 ms, 相关度 0.04)
结尾段 2536 采样 (158.5 ms, 相关度 0.05)
延迟漂移: 1937 采样 (121.1 ms)
六秒钟里,音频管线的延迟漂了 121 毫秒。
第二个数字更要命。延迟要是稳定的,这个相关度应该在 0.5 以上。0.04 意味着麦克风收到的信号,在任何单一延迟下都不是播放信号的副本 —— 延迟一直在变,把相关性糊掉了。
固定延迟的自适应滤波器,这种条件下数学上不可能收敛。
所以问题不在我代码。这条路在这台机器上就是不成立。
想要免提体验只剩一条路:插耳机,让声学回声从物理上消失。
花了挺久换来一个 "不行"。不过这次不太难受,因为这个 "不行" 是有数的,谁来看都是同一个结论,比 "好像效果不太好" 强。
服务器不肯听长句
后来又撞上一件更怪的。
我用打字提问做测试。短句一直好好的,长句完全没有反应。也不是报错,就是安安静静什么都没发生。
去翻 logcat,才发现服务器其实回了话:
{"type":"alert","status":"ERROR",
"message":"Detect is only for wake words, do not send long texts."}
原来 "文本输入" 这条通道是给唤醒词用的,就是 "你好小智" 那种两三个字的短词。我一直在蹭这条通道,蹭了挺久自己都不知道。
界限在哪,我把长度一段段试过去:
| 输入 | 结果 |
|---|---|
| 英文 44 字符 | 正常回答 |
| 英文 48 字符 | 被拒 |
| 中文 16 字 | 正常 |
| 中文 17 字 | 被拒 |
中文 16 字(48 字节)能过,英文也是 48 字节却被拒。所以它不是按字节算的。
我猜它算的是 "这句话念出来要多久"。中文语速大概每秒 4 个字,英文大概每秒 11 个字符,边界正好都落在 4 秒附近。这条通道的预算本来就是唤醒词,一两秒,多出来的自然超了。
那怎么办。我想了个绕的:它只认 "话",那就把文字变成话。
手机上有现成的 TTS(讯飞)。我把要问的问题在本地合成成语音,编码成 Opus,当成 "用户说的话" 发出去。
结果比我预期好。服务器把合成音频一字不差听下来了,还自己补了标点:
[12.2s] 识别结果: "帮我看看屏幕上写的是什么内容,然后念给我听。"
从那以后打字提问再没有长度限制。短句走快路,长句自动 "念" 给自己听。
一个意外
拍照识物这块,我本来已经做好去申请第三方视觉 API 的准备了。注册、拿 Key、填配置,这类项目最烦的一步。
结果翻协议握手消息的时候,看见服务器在 initialize 里主动塞了一段:
"capabilities": {
"vision": {
"url": ".../vision/explain",
"token": "..."
}
}
服务器自带视觉接口,还主动告诉设备怎么用。
也就是说拍照识物根本不需要任何 Key。我之前那堆准备全白做。
照它给的格式把照片 POST 上去,第一次就通了。描述得还挺细,桌上一块金属板和一张插画,连 "表面有灰尘" 都写出来了。
后来我还是补了个自定义接口的配置项,万一哪天服务器不给了还能接自己的。默认这条路零配置直接能用。
它开始动手了
到这儿它还只是能聊天。真正让我觉得不一样的是它开始对物理世界起作用。
小智协议里有个 MCP 机制:设备把 "自己能干什么" 注册给服务器,大模型就能像调工具一样调它。ESP32 固件里这机制用来控几个 GPIO。
我这是手机,能注册的东西就多了:手电筒、音量、亮度、静音、电量、位置、时间、拍照、截屏、录屏。
第一次测的时候我在屏幕上打了一句 "打开手电筒"。
日志滚过几行:
<<< 模型决定调用: self.phone.set_torch {"on":true}
[工具执行] -> flashlight on
<<< "好啦,手电筒已经打开了。"
我没只看它自己的日志。去查了系统相机服务:
Device is open. Client[0] PID: 17687
相机硬件真的被我的进程占住了。关掉再查,变回 Device is closed。
那一下的兴奋很具体。它不只是回了句话,是听懂了然后动了手,动的是真的手。
后面每个工具我都这么验了一遍:不看它报 "成功",去看系统里那个值到底变没变。说 "音量调到百分之八十",扬声器音量从 16 变成 13(80% × 16 = 12.8)。说 "亮度调到七十",亮度从 39 变成 179(70% × 255 = 178.5),模式也从自动切成手动。说 "调成静音",静音模式从 2 变成 0。十个工具,每个都这么查过一遍。
有件小事挺好笑。测麦克风那次我根本没说话,它把屋里电视的声音听了进去,识别成一串驴唇不对马嘴的话,然后一本正经地回答了一通。那会儿我就知道,采集和上传这条链路是通的。
让它当桌面
最后一步是取代系统桌面。这事我拖了很久,比技术上需要的久得多。
因为风险性质不一样。前面所有改动都只影响一个 App,这一步要是出错,我面对的是一块黑屏手机。
后来敢做,是因为先把退路铺好了。先确认这台机器上只有一个桌面应用,没有歧义;然后写了个脚本,隐藏原桌面之后立刻验证 HOME 键有没有落到我的 App 上,没落上去就自动回滚;再空跑一遍。
然后才真执行。脚本跑完最后一句是:
[ OK ] HOME lands on com.xzai.client(关键时刻)
按下 HOME 键,屏幕上出来的是它的脸。
我还是不太放心,又做了件事:把它回滚,再重新应用一遍。我想确认 "可回滚" 不是写在文档里的一句话,是真能按下去的按钮。
pm unhide 一执行,原桌面马上回来了。再隐藏一次,我的 App 又回来了。两个方向都真通了,我才算放心。
最后是重启。对一台要通电即用的设备,这才是真正的考试。重启完屏幕亮起来,出现的是它,原桌面还在隐藏状态。
一堆很难忘的坑
这项目最花时间的从来不是写功能,是各种看着正常、其实完全不对的东西。
一个空字符串把服务器搞疯了。 返回工具列表时我按惯例带了个 "nextCursor": "",本意是 "没有下一页"。服务器理解成 "还有下一页",无限重复请求,日志里的编号一会儿就从 223 涨到 248 以上。去掉这个字段就好了。
一段 "成功" 的脚本一个字都没写进去。 封堵 OTA 的脚本里我用了 heredoc。后来发现 Android 的 shell 是用临时文件实现 heredoc 的,而默认临时目录不可写。失败还是静默的:脚本继续往下跑,还打印 "完成",目标文件一个字没变。要是直接上真机,就是脚本说成功了、其实什么都没做,极难发现。
PowerShell 变量名不区分大小写。 验收脚本第一次跑出来 8 个通过、其余全红,每一项都指向一个已经单独验证过的功能。查了很久才发现,我用 $log 存日志内容,而脚本顶部用 $LOG 存日志路径,同一个变量。路径被覆盖成几百行日志文本,后面所有文件操作静默失败。测试全红,设备其实完全正常。
ColorOS 的安装器有两个确认框。 我一直只处理第二个,安装老失败。后来才知道要先点 "替换" 再点 "完成",而且在安装过程中点任何确认框都会直接取消安装。我因为手快,装过好几次 "寂寞"。
我自己脚本里有个必然失败的 bug。 封堵 OTA 的时候,脚本死活说 "需要 root",可 root 明明是好的。写了个探针打印出来才看见:
id -u = [uid=0(root) gid=0(root) context=...]
这台 Android 5.1 的 id 根本不认 -u 参数,会把完整身份串打出来。而我写的是 [ "$(id -u)" != "0" ],拿一长串东西和 "0" 比,永远不相等。
更该反省的是,这 bug 一直没被测出来,是因为我当初为了跑通测试,把那一句替换成了 if false。等于亲手把它藏起来了。
现在
开机就是它。原来的桌面被隐藏了,通电亮屏,出现的就是它的脸。
状态栏也隐藏了,整块屏都给它。锁屏关掉,唤醒不用滑。屏幕常亮,它一直看着我。系统停在我调通的这个版本,厂家的升级推不动它了。
它有 19 种情绪、178 张贴纸,说话的时候脸上跟着变。它能听懂我说话,也能读我打的字,多长都行。拍照、看屏幕、念出屏幕上的字,都行。手电筒、音量、亮度、静音、电量、位置、录屏,也都行。被系统杀掉之后会自己回来。
有点讽刺的是,它现在能干的事,比我当初打算买的那个开发板多得多。而它本来就在我抽屉里。
最后
我们淘汰一台手机的理由通常是 "跑不动新 App 了"。"跑不动新 App" 和 "没用" 是两件事。这颗芯片放十年前是服务器级别,它还有摄像头、屏幕、扬声器、一堆传感器。把它当一台设备看,而不是一台跑不动微信的手机。
免提那件事最后没成。但我拿到了那几个数字,121 毫秒、0.04、4.7dB。它们让 "不行" 变成了一个确定的结论,而不是一个模糊的遗憾。我觉得这比 "勉强能用但体验很差" 值。
还有个事我印象挺深。这项目里好几次都是功能其实是好的、问题出在我的测量方法,最离谱的一次是我判定音量调节失败,后来发现只是查错了系统设置里那个键。反过来也有:我自己脚本里藏着一个必然失败的 bug,而我当初为了偷懒把它绕过去了。测试失败的时候,第一个该怀疑的大概是测试本身。为了让测试通过去绕开某个检查,是在给自己埋雷。
这台手机现在放在我桌上,屏幕亮着,脸上是个贴纸。
我路过的时候偶尔会说一句 "你好"。它会换个表情,然后回答我。