Skip to content

03 上行 PCM 发包链路:积压、回压与弱网基线

音频流链路搭建的第一个突破口,应该放在上行 PCM 发包链路。

原因很简单:SRService 会持续产出 PCM,而 WebSocket 发送速度会受到 Wi-Fi、TCP 重传、TLS 写入、云端消费速度和任务调度影响。一旦生产速度大于发送速度,设备侧就会出现 ringbuf 或 queue 积压。

这个问题不解决,后面的协议设计、ASR 效果、打断逻辑和多轮对话都会被拖累。

本章不宣称已经完成优化,只先把问题、瓶颈、指标和测试基线定义清楚。

本章聚焦这一段:

flowchart LR
    SR["SRService\nWakeNet / Voice Activity / PCM"] --> RB["PCM Ringbuf / Queue"]
    RB --> WS["WebSocketTask\nbinary frame send\nv1: 裸 PCM"]
    WS --> GW["Cloud Gateway\nreceive / ASR pipeline"]

先不处理下行 TTS 播放,也不急着引入 UDP、Opus 或 WebRTC。原因是上行链路更容易形成可控实验:

  • 输入速率固定:16kHz / 16bit / mono 大约 32KB/s
  • 发送目标明确:WebSocket binary frame。
  • 结果容易观测:产生多少、发送多少、积压多少、丢弃多少。
  • 弱网下问题最先暴露:发送变慢,队列变深,语音迟到。

WebSocket 底层有 TCP,TCP 会保证字节有序、可靠、不乱序。但音频系统关心的不只是“有没有送到”,还关心“是不是按时送到”。

TCP 能保证TCP 不能保证
字节不乱序这段音频是否还来得及用于当前 turn
丢包后重传重传造成的额外延迟是否可接受
连接内有序新音频是否会被旧音频堵住
写入最终成功或失败设备侧 queue 是否已经积压过深

所以本章要解决的问题不是“TCP 会不会丢包”,而是:

当网络或云端变慢时,上行 PCM 会不会在设备侧越积越多,最终变成迟到音频。

这条链路的瓶颈通常不在某一行代码,而在生产速度、发送速度和业务时限之间。

位置可能瓶颈典型现象
PCM 采集固定速率持续产出网络变慢后仍然不断写入 ringbuf
ringbuf / queue没有水位指标不知道积压了多少毫秒音频
分帧采集粒度和发送粒度绑死小包开销大,大包延迟高
WebSocket 发送写入阻塞或变慢send 调用耗时上升
云端 gateway读取或处理变慢设备侧发送正常,但云端消费滞后
turn 管理旧 turn 队列未清空新一轮混入上一轮尾部音频

如果没有指标,只看日志里的“send ok”,很容易误判。send ok 只能说明这次调用没有立即失败,不能说明链路实时健康。

当前上行 PCM 如果按 16kHz / 16bit / mono 计算:

16000 samples/s * 2 bytes = 32000 B/s

也就是每秒约 32KB。换算成常见帧长:

音频时长PCM 大小
20ms640B
50ms1600B
100ms3200B
128ms4096B

这张表的意义不是说必须按 4096B 发包,而是提醒一个关键取舍:

采集粒度、缓存粒度和发送粒度可以不同,不应该因为某个 buffer 大小就默认接受对应的链路延迟。

如果一次发送 4096B,单次 frame 就已经包含约 128ms 音频。再叠加网络抖动、云端处理和下行 TTS,交互延迟很容易被放大。

第一步不要急着优化,先让链路说真话。

建议每个 turn 维护一组上行统计:

指标含义用途
uplink_id设备本地生成的上行观测 ID关联本轮 audio_sr/audio_rr,不依赖云端 turn_id
produced_bytesSR 侧产出的 PCM 字节数判断生产速度
sent_bytesWebSocket 已发送字节数判断发送速度
dropped_bytes设备侧主动丢弃的 PCM 字节数判断是否触发保护
drop_count主动丢弃次数判断丢弃频率
tx_ringbuf_depth_bytes当前发送 ringbuf 积压字节数判断是否接近危险区
tx_ringbuf_depth_ms当前发送 ringbuf 积压音频时长比 byte 更直观
ws_send_call_ms单次 WebSocket 发送调用耗时判断本地写 socket 是否阻塞
audio_report_rtt_msaudio_sr 发出到对应 audio_rr 返回的应用层往返判断云端应用层是否及时看见报告
last_send_ok_ms最近一次发送成功时间判断发送是否停滞
ws_stateWebSocket 当前状态关联 close / error / reconnect

其中最重要的是:

tx_ringbuf_depth_ms = tx_ringbuf_depth_bytes / 32000 * 1000

用毫秒看积压,才符合音频系统的直觉。

这里的 ws_send_call_ms 不是 RTT。它只表示设备侧调用 WebSocket 发送接口的本地耗时;当前 v1 用 audio_sr/audio_rr 计算应用层 audio_report_rtt_ms,不追底层 TCP ACK RTT,也不做帧级 ACK。

当前更贴切的拆分不是让 Session 自己持有所有统计状态,而是单独放一个 AudioLinkObserver

SRService stats \
-> AudioLinkObserver -> audio_sr / snapshot / bottleneck
WebSocketTask stats /
audio_rr -> AudioLinkObserver -> audio_report_rtt_ms

这样拆分的原因是:

  • SRService 只说明“产出了多少、入队多少、丢弃多少”。
  • WebSocketTask 只说明“发出了多少、发送调用是否变慢、发送队列水位多少”。
  • AudioLinkObserver 负责把这些事实合并成链路视角,并维护 uplink_id / sr_id / audio_report_rtt_ms
  • Session 仍然只负责业务时机:什么时候开始观测、什么时候发 periodic/final audio_sr、什么时候把 audio_rr 交给 observer。

当前阶段它只做被动分析,不根据 health_statebottleneck 自动 drop、abort、reconnect。这样可以先积累真实基线,避免还没看清瓶颈就把控制策略写死。

当前阶段不建议直接引入 RTP/RTCP,也不应该把这套设计称为完整 RTP/RTCP 实现。

更合适的做法是:在现有 WebSocket PCM 链路上借鉴它的核心思想。

RTP/RTCP 概念当前项目中的轻量实现
RTP sequence number后续 AudioFrameHeader 版本再引入;v1 不做
RTP timestamp后续 AudioFrameHeader 版本再引入;v1 不做
RTP payload当前仍是裸 PCM binary payload
RTCP Sender Report设备周期性发送 audio_sr
RTCP Receiver Report云端周期性返回 audio_rr
packet loss没有帧序号前不做结论性统计
jitter没有帧时间戳前不做结论性统计
RTT设备通过 SR/RR 回显计算应用层 RTT

这样做的价值是:不切换协议栈,也能先把成熟实时媒体系统里的 Sender Report / Receiver Report 思路引入当前链路。seqtimestampjitterloss 留到后续帧头版本,不在 v1 里假装已经可靠统计。

v1 不改 binary PCM 格式。等 audio_sr/audio_rr 指标跑出基线后,再考虑把上行 PCM binary frame 从裸 PCM 升级为:

AudioFrameHeader
- version
- turn_id
- seq
- capture_timestamp_ms
- duration_ms
- payload_len
payload
- PCM bytes

这个版本要让云端能回答几个当前 v1 回答不了的问题:

  • 这一帧属于哪个 turn?
  • 这一帧是第几帧?
  • 这段 PCM 是什么时候采集的?
  • 这一帧代表多少毫秒音频?
  • payload 长度是否符合预期?

设备作为上行 sender,可以每 1s 或每 N 帧发送一次 audio_sr

{
"type": "audio_sr",
"session_id": "sess_xxx",
"uplink_id": "uplink_1",
"sr_id": 3,
"report_kind": "periodic",
"device_send_ms": 12345678,
"produced_bytes": 64000,
"sent_bytes": 64000,
"dropped_bytes": 0,
"drop_count": 0,
"tx_ringbuf_depth_bytes": 1280,
"tx_ringbuf_depth_ms": 40,
"ws_send_call_ms_last": 4,
"ws_send_call_ms_max": 12,
"last_send_ok_ms": 12345670
}

它的作用不是控制业务,而是提供发送端视角:

  • 设备认为自己发到了哪里。
  • 当前是否有积压。
  • 是否已经发生 drop。
  • 发送端时间点是多少。

云端 gateway 作为 receiver,v1 收到 audio_sr 后立即返回 audio_rr

{
"type": "audio_rr",
"session_id": "sess_xxx",
"uplink_id": "uplink_1",
"last_sr_id": 3,
"server_receive_ms": 1710000000000,
"server_report_delay_ms": 3,
"binary_bytes_received": 64000,
"binary_frames_received": 16
}

它的作用是提供接收端视角:

  • 云端累计收到了多少 binary PCM 字节。
  • 云端累计收到了多少 binary PCM frame。
  • 设备侧发送进度和云端接收进度是否一致。
  • audio_sr 从设备到云端再返回设备的应用层往返是否变慢。

设备发送 audio_sr 时,本地记录 sr_id -> device_send_ms。收到 audio_rr.last_sr_id 后,设备查回对应发送时间,再扣除云端收到 SR 到发出 RR 的停留时间:

audio_report_rtt_ms = device_now_ms - device_send_ms - server_report_delay_ms

这个 RTT 是应用层报告往返,不是底层 TCP ACK RTT。它更贴近当前项目真正关心的问题:设备侧报告是否被 gateway 应用层及时看见并返回。

audio_report_rtt_ms 只说明 SR/RR 报告链路往返是否健康,不等于用户说完到听到回复的时间。

后续还应该单独记录:

指标定义作用
vad_end_to_last_audio_rr_msVAD 结束到 final audio_sr 对应 audio_rr 返回粗略判断用户说完后上行报告是否及时闭合
vad_end_to_asr_final_msVAD 结束到 ASR final判断识别链路耗时
vad_end_to_first_audio_msVAD 结束到收到或播放第一段 TTS 音频判断用户感知等待
vad_end_to_turn_done_msVAD 结束到本轮播放完成或 turn_done判断完整闭环耗时

第一阶段不需要全做,但命名必须先分清楚:链路 RTT、上行提交延迟和用户首响延迟不是同一件事。

建议先用下面这张判断表。

观测结果可能原因下一步
produced_bytes/s 约等于 32KB/ssent_bytes/s 也约等于 32KB/s正常继续看 ws_send_call_ms 和下行
produced_bytes/s 正常,sent_bytes/s 降低WebSocket 发送或网络变慢ws_send_call_ms、socket error、云端接收
tx_ringbuf_depth_ms 持续上升生产速度大于发送速度需要回压、drop 或 abort 策略
ws_send_call_ms 突然升高TCP/TLS 写入被拖慢增加发送超时和链路健康判定
drop_count 增加但 ASR 正常保护策略可能有效检查丢弃位置是否合理
drop_count 为 0,但 ASR 变慢或污染旧音频可能迟到增加 frame deadline 和 turn 清理
WebSocket 正常但云端 ASR 慢云端消费或 ASR pipeline 是瓶颈加 gateway receive timestamp 和 ASR final timestamp

真正要避免的是这种假象:

设备侧没有报错,所以链路没问题。

实时音频里,迟到本身就是一种失败。

本章建议先做 5 组基线,不求一次解决所有问题。

场景操作重点看
正常网络普通语音输入,完整一轮 ASRsent_bytes/s 是否稳定在 32KB/s 左右
慢云端人为让 gateway 或 ASR 前处理变慢tx_ringbuf_depth_ms 是否持续上涨
网络抖动增加延迟和 jitterws_send_call_ms 是否尖峰明显
连续多轮连续触发 10-20 轮对话turn 切换后队列是否清空
打断场景speaking/listening 中途打断旧 turn 上行音频是否继续发送

如果环境暂时不方便做真实弱网,也可以先做“慢消费者”测试:在云端 gateway 里人为延迟读取或处理,观察设备侧队列是否积压。这不等价于真实网络弱网,但足够暴露生产和消费速度不匹配的问题。

这些阈值不是最终标准,只是方便第一轮基线判断。

指标建议阈值处理建议
tx_ringbuf_depth_ms< 300ms健康
tx_ringbuf_depth_ms300-800mswarning,记录链路不健康
tx_ringbuf_depth_ms> 800ms触发 drop、abort 或重建链路
ws_send_call_msP95 < 50ms可接受
ws_send_call_msP95 > 150ms发送链路可能阻塞
last_send_ok_ms> 2000ms 无更新认为发送停滞
turn_done 后残留队列0必须清空或丢弃

这里最关键的是 tx_ringbuf_depth_ms。只看 bytes 不够直观,看毫秒才能知道这段音频是否已经过期。

等基线数据出来后,再决定具体优化动作。当前可以先规划几个方向。

采集可以保持小粒度,例如 20ms 或算法输出的固定块;发送可以按更合适的 frame 组包,例如 40-80ms

不要让 4096B 这类 buffer 大小直接决定业务时延。

建议上行 binary frame 至少能关联:

turn_id
seq
timestamp_ms
duration_ms
payload_len

这样云端和日志才能判断:

  • 这一帧属于哪一轮。
  • 是否有跳帧。
  • 是否迟到。
  • 是否跨 turn 污染。

队列不能无限增长。超过阈值后必须有策略:

  • 丢弃最旧帧。
  • 丢弃静音段。
  • 终止当前 turn。
  • close WebSocket 后重新进入 listening。

具体选择要看 ASR 模式。如果 ASR 更依赖完整语音,直接丢中间语音可能影响识别;但无限积压会让整轮对话变得更差。

如果长时间没有发送进展,不应该让状态机继续假装 listening 正常。

可以先定义:

last_send_ok_ms 超过 2s 未更新
或 tx_ringbuf_depth_ms 超过 800ms
或连续 N 次 send 失败

触发链路异常收口:停止上行授权、清空当前 turn 队列、关闭 WebSocket、通知 Session 进入可恢复状态。

任何 stop listeningturn_doneabortsession reset 都应该明确处理上行队列。

否则旧音频可能在网络恢复后继续发出,造成最难排查的污染问题。

第 3 章真正完成时,至少应该能回答这些问题:

  • 正常网络下,上行是否稳定达到 32KB/s
  • 弱网或慢云端时,tx_ringbuf_depth_ms 如何变化?
  • WebSocket 发送变慢时,ws_send_call_ms 是否能观测到?
  • audio_sraudio_rr 能否复盘设备发送进度和云端接收进度?
  • audio_report_rtt_ms 是否能和 tx_ringbuf_depth_ms、云端接收字节数关联起来?
  • 后续如果引入帧头,是否需要补充帧级 loss / jitter 指标?
  • backlog 超过阈值后,系统是 warning、drop、abort 还是重连?
  • turn 结束后,上行队列是否清零?
  • 自动化日志能否复盘一轮上行链路?

如果这些问题回答不了,就还不能说链路可靠。

本章只是确定第一个深入方向:上行 PCM 发包链路的积压与回压。

它暂时不解决所有音频可靠性问题,也不急着替换 WebSocket。当前更重要的是先把链路量出来,找到瓶颈,再用数据决定优化方向。

后续真正有价值的工作,是把这些指标接入设备侧和云端日志,用正常网络、慢云端、弱网和连续多轮场景跑出基线数据。