# 声音从哪来，到哪去：USB 话机、浏览器通话和一块 ESP32（四）

- URL: https://wuming.si/blog/4005/
- Author: Aaron
- Published: 2026-09-02
- Language: zh-Hans
- Categories: Technology
- Originally published at: https://blog.wo.ai/archives/4005

> 上一篇：[从零实现 VoWiFi](https://wuming.si/blog/4004) ｜ 下一篇：[插在 Mac 上](https://wuming.si/blog/4006)

上一篇把 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 在**每一次**拨出和接听之前都运行它：

```sh
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，这部分需要在树莓派上本机编译，不能交叉编译：

```sh
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：

```text
/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 服务端不协商那个能减小分片的扩展。

**解决**：网关开两扇"便宜的门"，默认都关着：

```yaml
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. [它到底是什么](https://wuming.si/blog/4002)
2. [树莓派上的短信与 eSIM 网关](https://wuming.si/blog/4003)
3. [从零实现 VoWiFi](https://wuming.si/blog/4004)
4. [USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005)
5. [插在 Mac 上](https://wuming.si/blog/4006)
6. [iPhone 客户端](https://wuming.si/blog/4007)
7. [安卓平板直接驱动](https://wuming.si/blog/4008)

---

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