声音从哪来,到哪去:USB 话机、浏览器通话和一块 ESP32(四)
上一篇:从零实现 VoWiFi | 下一篇:插在 Mac 上
上一篇把 Wi-Fi 通话的信令和语音流跑通了。但”跑通”的意思只是:树莓派内存里有一串 8 kHz 的采样数据在流动。人还是听不到。
这一篇解决的全是”声音到底走哪条线”的问题:
- 模组自己打的蜂窝电话,怎么才能有声音;
- 一只真正的 USB 电话机,怎么接进来——能摘机、能按键拨号、能振铃;
- 浏览器怎么直接当电话用;
- 通话录音;
- 一块内存小得可怜的 ESP32 开发板,怎么当一部 Wi-Fi 分机。
一个音频总线,一种格式
系统里所有音频只有一种格式:8 kHz、单声道、16 位、每 20 毫秒一帧,正好 320 字节。
这不是随便挑的。它正好是模组那块 USB 声卡的原生格式,也正好是 G.711 电话编码的采样率。所有东西都统一到这个格式之后,任何端点接进来都不需要编解码器、不需要重采样、不需要协商——把字节搬过去就行。
Wi-Fi 通话的语音 ─┐ ┌─ USB 电话机
模组的蜂窝通话 ───┼──> 音频总线 <───┼─ 浏览器
│ 8k/单声道/16位 ├─ 手机 App
│ ├─ ESP32 分机
└───────────────────└─ 录音
还有一条贯穿全局的规则:AT 命令口只允许一个协程持有,所有功能排队使用。这样”两个地方同时发 AT 命令导致回复错位”这种问题,在结构上就不可能发生。第一篇讲过这类错位有多难查,干脆从设计上堵死。
让模组自己的电话有声音
第一篇说过,ATD 只管信令。要有声音,得在模组内部搭一条路由出来:
| 步骤 | 做什么 |
|---|---|
| 1 | 打开模组的 ADB 和音频功能(某些版本出厂就是开的) |
| 2 | 把语音驱动推进模组并加载。内核 3.18.44 |
| 3 | 跑一遍音频校准 |
| 4 | 启动桥接程序,把 DSP 里的音频接到 USB 声卡上 |
| 5 | 主机侧读写这块声卡 |
| 6 | 确认通话真的接通之后再开始搬音频 |
第 3 步最容易漏,而漏掉的表现就是纯粹的静音——所有东西看起来都正常,就是没声音。校准程序要以守护进程的方式跑着,发一条设置命令,然后等日志里出现校准完成的标志。
第 6 步的坑是:这个固件通常不发那条通用的”已接通”通知,得靠轮询 AT+CLCC 判断。
整个流程被写成了一个脚本,VoHive 在每一次拨出和接听之前都运行它:
sudo /opt/vohive-call/qdc507-route status
正常状态要同时看到三样东西:模组内部的声卡存在、路由会话是活的、两个数据通道都处于运行状态。
驱动本身是 GPL 衍生物,再分发有提供源码的义务,所以项目不打包它,只从指定目录加载。
第一通电话有声音,之后全是静音
数据:第一通电话 7410 帧里有 7412 帧非静音(几乎全程有声音),之后每一通只有 0 到 2 帧,大约 20 秒后被网络挂断。
原因:桥接程序搭的路由是单次通话的状态,通话一结束就失效了。
解决:每次通话前重启桥接程序。脚本做成自愈式的——模组重启过(内存盘被清空、声卡消失)就重新加载驱动,校准标记不在就重新校准,然后无论如何都重启一遍桥接程序。完整恢复大约 13 秒,正常路径 1.4 秒。模组重启后实测 1000 帧里有 997 帧非静音。
两个 adb 陷阱,各花了几个小时
adb shell 不传递远端的退出码。 adb shell test -f xxx 对一个不存在的文件也会”成功”返回。于是”检查驱动在不在,不在就推送”这个步骤,被静默跳过了。
绕过办法是在远端 echo 一个标记,然后检查输出里有没有它——不要相信退出码。
一次性 adb shell 里放到后台的进程,会在 shell 退出时被回收。 校准守护进程和桥接程序都被杀掉了,而杀掉之后,之后每次往校准管道里写数据都会永久阻塞。表现是整个流程卡死,没有任何错误。
解决办法是它们必须运行在一个从主机侧一直保持打开的 adb 连接下面。
有声卡不等于有声音
模组重启会清掉内核模块,但树莓派上可能还挂着之前枚举出来的那块声卡。设备节点在,ls 看得见,读出来全是零。
这正是每次通话前都要跑一遍状态检查、按需重建的原因。设备存在、进程在跑、状态是 true,这三件事都不能证明数据真的在流动。
桥接建好了,但没有东西驱动它
音频桥被注册成了总线的一个端点,但没有任何东西去”泵”它,于是一帧都没流动过。
解决办法是让音频桥本身成为总线的帧泵,由采集流来打拍子。同时记录帧数和非静音帧数——让”静音的路由”在日志里能被看见,而不是等人去抱怨听不见。
接一只真正的电话机
用的是一只带拨号键盘和叉簧的 USB 电话机(Koncept KU1110,也有 AICO TalkPro U-100、wizarDial WZU-02 这些牌子,同一个东西)。
它不是标准 USB 音频设备。按键、叉簧、音频全是私有协议,只能用 libusb 抓包逆向一遍。
现在两个方向都在真机上验证过了:来电时它会响,摘机就能通话;摘机能听到拨号音,按键有按键音,号码能拨出去。还支持速拨——配置一到六位的短码,话机和网页拨号盘都会先展开再拨。(# 不能用作速拨码,因为它在话机上是”拨号结束”键。)
因为要用 libusb,这部分需要在树莓派上本机编译,不能交叉编译:
sudo apt install libusb-1.0-0-dev
CGO_ENABLED=1 go build -tags handset ...
音频断断续续,还录到了全零的采样
原因:传输层用了一把普通互斥锁,而状态轮询每 200 毫秒就要抢一次。播放和采集被活活饿死。
解决:所有操作共享一把读写锁。一个改动同时修好了断续和零采样两个症状——它们本来就是一个病。
播放延迟越来越大
原因:按定时器打拍子,每次调用还预填充一点,积压越滚越多。打一通长电话,到后面对方的声音会延迟好几秒。
解决:改成按队列深度打拍子——队列里的数据少了才补,多了就等。切换声音(比如从拨号音切到通话)时清空队列。
通话中莫名其妙挂断
原因:叉簧(听筒压着的那个开关)是机械的,会抖动。一次”挂机”读数不代表真的挂机了。
解决:去抖,连续多次读到同一个状态才算数。
初始化失败时,先怀疑话机自己
有两条顺序规则很关键:某条初始化命令每个通道只能发一次,而且两次之间必须先提交一次播放;状态轮询不能和初始化重叠,否则启动命令会超时。
但更值得说的是:这只话机的固件很容易卡死,卡死之后不管用什么驱动都以完全相同的方式失败。 有好几轮排查是在完全正确的代码里找一个想象中的 bug。
现在的做法是:初始化失败时,先跑一个最小的参考程序验证硬件本身。恢复用切换 USB 授权状态的办法,不需要拔插。
不要用 usbreset,也不要给树莓派 4 的 USB 口断电——上一篇说过,它的 USB 口是联动的,断一个等于断全部,会把模组一起带走。
来电转到话机,摘机后没有声音
数据:蜂窝那一侧总线收到了 750 帧里 699 帧非静音,话机听筒里一片寂静。
原因:这是两个总线桥接的转接场景。话机被注册成了总线的一个端点,但没有任何东西泵这个总线——和前面音频桥那个坑一模一样的形状。
解决:话机这条线路用自己的 20 毫秒节拍直接驱动。另外,话机故意不作为总线成员,否则它的麦克风会混进自己的听筒里,说话时自己听到自己。
正在振铃的电话被当成”忙”挂掉了
原因:来电号码通知会重复上报振铃状态,第二次被当成了一通新来电,会话层回了个”忙”,顺手把真正在响的那一通挂了。
解决:忽略振铃状态的重复上报。
浏览器直接打电话
后台的通话页面可以直接拨号、接听,浏览器通过 WebSocket 和音频总线交换原始 PCM:
/api/devices/{id}/call/voice/{session}/ws 二进制帧,8 kHz 单声道 16 位
浏览器没声音的两个原因,都和权限有关:浏览器录音要求安全上下文,WebSocket 被证书问题拒绝就没有语音——所以必须把自签证书加进系统信任,而不是在警告页点”继续访问”(第二篇讲过)。麦克风权限被拒绝时同样是静默失败。
网页拨出的电话只有信令没有声音:拨号的处理函数用了一个空总线,只建立了信令会话。解决是拨号时把本地音频设备挂到这通电话的总线上,直到通话结束。
蜂窝来电在网页上接听后没声音:媒体在通话真正接通之前就开始搭建了。顺序改成先接听,再建立媒体。
这三个坑放在一起看很有意思:它们都是”信令成功了,所以以为整件事成功了”。信令和媒体是两条独立的路,这是第一篇就说过的事,但它会以各种新形式反复出现。
录音
录音是接在音频总线上的一个旁路,而不是从 WebSocket 里录。这个设计决定带来一个好处:用 USB 话机接的电话——此时没有任何客户端在串流——照样能录下来。
几个细节:
- 录成立体声:远端在左声道,近端在右声道。单声道混音之后事后无法分离,而录音的意义通常恰恰是分清谁说了什么。
- 对齐:两个方向由不同的调用送来,正常情况下每 20 毫秒各一帧交替。某一侧停了超过 1 秒,就用静音补齐另一侧,而不是干等着,然后爆发式地写进来一堆对不齐的帧。
- 写盘放在独立协程上,慢速存储不会拖慢音频时钟。
- 默认关闭。录音涉及通话双方,不同地区的法规不一样。保留策略按天数和总大小两个维度,都设为 0 表示不限制。
- 没有音频的通话不留记录。下载支持断点续传,可以直接拖进度条而不用下载整个文件。
验证的方式:一通 42.34 秒的电话,左声道远端音量 0.142,右声道静音——因为这一侧当时没接任何设备。数字对得上,说明声道没有搞反。
一块 ESP32 当分机
这是一块叫 FoloToy AI Passport 的小板子:ESP32-C3,8 MB 存储,没有 PSRAM,带一块 240×320 的屏和三个按键。它没有任何蜂窝功能,目标是让它借用网关的线路,当一部 Wi-Fi 话机。
TLS 放不下
握手期间 mbedTLS 峰值需要 35 到 45 KB 的内部内存,而这块板子实测最大连续空闲块只有约 13 KB。加密握手和一通进行中的电话,挤不进同一颗芯片。
试过调小 TLS 的缓冲区参数,没用——服务端是 Go,而 Go 的 TLS 服务端不协商那个能减小分片的扩展。
解决:网关开两扇”便宜的门”,默认都关着:
server:
lan_plain_http: true # 只对本机和私有网段放行明文
plain_http_cidrs: [] # 额外放行的网段,每条非私网地址启动时都会告警
voice_udp_port: 7576 # 语音 UDP 共享端口
voice_udp_public_port: 0 # 经过端口映射且对外端口不同时填
每个会话一个临时端口,没法做端口映射
设备在另一个网络里够不着网关,只能靠端口映射。但没有人能映射一个服务端在通话开始后才随机选出来的端口。
解决办法是所有会话共用一个固定端口,靠接入密钥区分:设备先把密钥发到这个端口,服务端记住它的地址并回一个确认。不在已知地址列表里的音频直接丢弃,密钥不认识就干脆不回复——对扫描器保持沉默,对真实客户端最多多一次重试。
用 UDP 而不是 TCP 是有意的:迟到的语音帧没有重传的价值,而重传一帧会拖慢它后面的所有帧。
管理员令牌不该出现在一块开发板里
走明文链路时,谁抓到一个会话令牌,就等于拿到了整个 API 三十天的权限,包括切换 eSIM 和设备管理。
解决:增加一个签发受限令牌的接口——只能通话、收发短信、订阅事件,不能再签发新令牌。
需要说清楚的是:这只是缩小权限范围,并不提供保密性。明文就是明文。真正的解决是让设备加入 VPN,比如在 ESP32 上跑 WireGuard——它用的 ChaCha20 比 TLS 握手便宜得多,内存够用。
没有回声消除
这块芯片做不了回声消除(相关的方案基本都要求更强的型号加 PSRAM),板子也没有耳机口,只能外放。
固件的做法是:远端说话时关掉麦克风(声控切换,300 毫秒保持),再加一个按住说话的按键作为确定性的后备。这能减轻回声,但消除不了。有些限制就是硬件的限制,承认它比假装解决了要好。
现在这台树莓派能干什么
| 端点 | 接入方式 | 最大的坑 |
|---|---|---|
| 模组蜂窝通话 | USB 声卡 | 校准、每通电话重建路由、adb 的两个陷阱 |
| USB 电话机 | libusb 私有协议 | 锁饥饿、队列积压、叉簧抖动、固件卡死 |
| 浏览器 | WebSocket PCM | 证书信任、空总线 |
| 录音 | 总线旁路 | 双声道对齐 |
| ESP32 | 明文 HTTP + UDP | 内存、端口映射、令牌权限 |
树莓派这一侧到此就完整了。下一篇换个平台:同一只 dongle 插在 Mac 上,没有串口、没有 ALSA,从头再来一遍。
全系列
这个系列是个人学习和技术研究记录。模组的语音驱动是 GPL 衍生物,不随项目分发,需要自行准备。录音功能请遵守所在地关于通话录音的法律规定。