达是酒中趣,琴上偶然音

声音从哪来,到哪去:USB 话机、浏览器通话和一块 ESP32(四)

· Technology

上一篇:从零实现 VoWiFi | 下一篇:插在 Mac 上

上一篇把 Wi-Fi 通话的信令和语音流跑通了。但”跑通”的意思只是:树莓派内存里有一串 8 kHz 的采样数据在流动。人还是听不到。

这一篇解决的全是”声音到底走哪条线”的问题:

  1. 模组自己打的蜂窝电话,怎么才能有声音;
  2. 一只真正的 USB 电话机,怎么接进来——能摘机、能按键拨号、能振铃;
  3. 浏览器怎么直接当电话用;
  4. 通话录音;
  5. 一块内存小得可怜的 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 话机接的电话——此时没有任何客户端在串流——照样能录下来。

几个细节:

验证的方式:一通 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,从头再来一遍。

全系列

  1. 它到底是什么
  2. 树莓派上的短信与 eSIM 网关
  3. 从零实现 VoWiFi
  4. USB 话机、浏览器通话与 ESP32
  5. 插在 Mac 上
  6. iPhone 客户端
  7. 安卓平板直接驱动

这个系列是个人学习和技术研究记录。模组的语音驱动是 GPL 衍生物,不随项目分发,需要自行准备。录音功能请遵守所在地关于通话录音的法律规定。