TT Lab
开始
学习 学习路径 课程

实时通信 — WebSocket、gRPC 流式调用与 WebRTC

通过 WebSocket 和 WebRTC 发送音频帧并测量延迟

在 TT Lab 中继续学习

目标

生成语音 AI 实际收发的 16kHz、20ms 帧,通过 WebSocket 和 WebRTC 发送,并亲自计算往返延迟的百分位数、抖动和播放缓冲区大小,用数字比较这两种传输通道。

为什么重要

语音对话的体感质量,不取决于平均延迟,而取决于最慢的 1% 的帧和播放缓冲区。WebSocket 在服务器端实现简单,也容易穿过防火墙,但因为是 TCP,一旦停顿,后面的帧就会全部迟到。WebRTC 可以丢弃迟到的数据,并具备 Opus 和抖动缓冲,但连接准备很重。在语音 AI 课程中把 ASR、TTS 放到这条传输通道上之前,先在这里用亲自测量的数字,确认哪一方在什么条件下获胜。

步骤

  1. 把 WAV 切成 20ms 的帧——在 /root/rt/voice/voice.py 中创建 frames(path, frame_ms=20)。用 wave 模块读取 16 位单声道 WAV,返回长度为 frame_ms 的 bytes 片段构成的 list。一个片段的字节数是 采样率 × frame_ms / 1000 × 2。如果最后一个片段不足,就用 0 补齐。如果不是单声道 16 位,则为 ValueError。素材是 /opt/fixtures/rt/voice/speech16k.wav(16kHz,3 秒)。
  2. 按真实时间发送——在 /root/rt/voice/voice.py 中添加 async send_paced(send, frames, frame_ms, prepare=None)。第 i 帧要在 起始时刻 + i × frame_ms 发送。到了那个时刻,如果有 prepare,就调用 prepare(frame),把其结果交给 await send(...),没有则把 frame 交给它。返回一个 list,其中以起始时刻为基准的秒为单位,记录了每一帧的发送时刻。评分器会给出一个每次耗时 5ms 的 prepare。
  3. 不看平均值,而看百分位数和抖动——在 /root/rt/voice/voice.py 中添加 percentiles(samples, ps=(50, 95, 99)) 和 jitter(transit_ms)。percentiles 用最近秩(nearest-rank)方法,对每个 p 选取排序后第 ceil(p/100 × n) 个值,返回 {p: 值} 的 dict,如果 samples 为空则为 ValueError。jitter 是 RFC 3550 的到达间隔抖动估计器,用连续两个数据包的传输时间差 D 的绝对值,反复套用 J = J + (|D| - J) / 16,返回最终的 J(J 从 0 开始,只有一个值时为 0)。
  4. 通过 WebSocket 发送并测量往返——在 /root/rt/voice/voice.py 中添加 async ws_measure(url, frames, frame_ms)。连接到 url,在每帧前面附上 struct.pack("!IQ", 序号, time.monotonic_ns()) 的 12 字节,通过 send_paced 发送,每收到一次回显,就用当前时刻与头部时刻的差,计算往返毫秒数。发送完毕后 1 秒内仍未到达的,就算作丢失。返回 {"sent": 发送数, "received": 接收数, "rtt_ms": 按接收顺序的往返 list, "p50": 值, "p99": 值}。然后运行 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py ws,把输出中的 ws_p50_ms、ws_p99_ms、ws_stall_p99_ms 三行写入 /root/rt/voice/report.txt。
  5. 用 WebRTC 数据通道做同样的事——在 /root/rt/voice/voice.py 中添加 async rtc_measure(frames, frame_ms)。在一个进程内创建两个清空 ICE 服务器列表的 aiortc 对等端,亲自传递提议和应答,并由提议方打开 ordered=False、maxRetransmits=0 的数据通道。应答方把收到的消息原样返回,提议方则用与 ws_measure 相同的头部、相同的方式测量,并返回形态相同的 dict。然后运行 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py rtc,把 rtc_p50_ms、rtc_p99_ms 两行添加到 report.txt 中。
  6. 用 Opus 媒体轨道发送声音——在 /root/rt/voice/voice.py 中添加 async rtc_audio(path, seconds=2.0)。让继承自 aiortc.MediaStreamTrack 的音频轨道,把 frames(path, 20) 的前 seconds 秒,每 20ms 以 av.AudioFrame(s16、单声道、16000Hz,pts 以采样点为单位)的形式给出,并通过 addTrack 在同一个进程的两个对等端之间发送。接收方对通过 track 事件收到的轨道持续调用 recv,统计帧数和采样率。返回 {"codec": 应答 SDP 中音频 rtpmap 的第一个编解码器(例如 opus/48000/2), "sent": 发送的帧数, "received": 接收的帧数, "sample_rate": 所收帧的采样率, "duration": 从收到的第一帧到最后一帧的秒数}。
  7. 计算播放缓冲区应设为多大——在 /root/rt/voice/voice.py 中添加 playout(arrivals_ms, frame_ms, buffer_ms) 和 min_buffer(arrivals_ms, frame_ms, max_late)。arrivals_ms 是序号 i 的到达时刻(毫秒,丢失则为 None)。播放基准点是最先到达的帧 k 的到达时刻减去 k × frame_ms 所得的值,第 i 帧的播放截止时刻是 基准点 + buffer_ms + i × frame_ms。playout 返回 {"late": 比截止时刻更晚到达的个数, "lost": None 的个数}。min_buffer 从 0 开始每次增加 10ms,返回使 迟到数 ÷ 到达数 不超过 max_late 的最小 buffer_ms(如果到 1000 都没有则为 None)。

参考

把 WAV 切成 20ms 的帧

在 /root/rt/voice/voice.py 中创建 frames(path, frame_ms=20)。用 wave 模块读取 16 位单声道 WAV,返回长度为 frame_ms 的 bytes 片段构成的 list。一个片段的字节数是 采样率 × frame_ms / 1000 × 2。如果最后一个片段不足,就用 0 补齐。如果不是单声道 16 位,则为 ValueError。素材是 /opt/fixtures/rt/voice/speech16k.wav(16kHz,3 秒)。

语音编解码器和 ASR 大多以 10、20、30ms 为单位接收声音。在 16kHz 下,20ms 是 320 个采样点、640 字节。如果丢掉最后一个片段,话尾就会被截掉。

按真实时间发送

在 /root/rt/voice/voice.py 中添加 async send_paced(send, frames, frame_ms, prepare=None)。第 i 帧要在 起始时刻 + i × frame_ms 发送。到了那个时刻,如果有 prepare,就调用 prepare(frame),把其结果交给 await send(...),没有则把 frame 交给它。返回一个 list,其中以起始时刻为基准的秒为单位,记录了每一帧的发送时刻。评分器会给出一个每次耗时 5ms 的 prepare。

如果每次发送都睡 frame_ms,准备所用的时间就会每次累加,50 帧就会推迟几百 ms。要把截止时刻按绝对时刻来计算,只睡剩余的时间。也不能一股脑集中发送——接收方的缓冲区会溢出。

不看平均值,而看百分位数和抖动

在 /root/rt/voice/voice.py 中添加 percentiles(samples, ps=(50, 95, 99)) 和 jitter(transit_ms)。percentiles 用最近秩(nearest-rank)方法,对每个 p 选取排序后第 ceil(p/100 × n) 个值,返回 {p: 值} 的 dict,如果 samples 为空则为 ValueError。jitter 是 RFC 3550 的到达间隔抖动估计器,用连续两个数据包的传输时间差 D 的绝对值,反复套用 J = J + (|D| - J) / 16,返回最终的 J(J 从 0 开始,只有一个值时为 0)。

在实时通道中,用户感受到的不是平均值,而是偶尔出现的长延迟。如果有 1% 的帧晚了 300ms,平均值几乎不变,对话却会中断。抖动表示到达有多么参差不齐,是确定播放缓冲区大小的依据。

通过 WebSocket 发送并测量往返

在 /root/rt/voice/voice.py 中添加 async ws_measure(url, frames, frame_ms)。连接到 url,在每帧前面附上 struct.pack("!IQ", 序号, time.monotonic_ns()) 的 12 字节,通过 send_paced 发送,每收到一次回显,就用当前时刻与头部时刻的差,计算往返毫秒数。发送完毕后 1 秒内仍未到达的,就算作丢失。返回 {"sent": 发送数, "received": 接收数, "rtt_ms": 按接收顺序的往返 list, "p50": 值, "p99": 值}。然后运行 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py ws,把输出中的 ws_p50_ms、ws_p99_ms、ws_stall_p99_ms 三行写入 /root/rt/voice/report.txt。

如果在一个循环里交替进行发送和接收,那么在等待接收期间,下一帧就会迟到。请把接收一侧单独放进 asyncio 任务。bench 的第二次测量是 TCP 停顿一次 300ms 的情形。你必须能说明,为什么明明没有丢失任何数据,p99 还是会飙升。

用 WebRTC 数据通道做同样的事

在 /root/rt/voice/voice.py 中添加 async rtc_measure(frames, frame_ms)。在一个进程内创建两个清空 ICE 服务器列表的 aiortc 对等端,亲自传递提议和应答,并由提议方打开 ordered=False、maxRetransmits=0 的数据通道。应答方把收到的消息原样返回,提议方则用与 ws_measure 相同的头部、相同的方式测量,并返回形态相同的 dict。然后运行 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py rtc,把 rtc_p50_ms、rtc_p99_ms 两行添加到 report.txt 中。

因为在同一个进程里,所以不需要信令服务器。把一个对等端的 localDescription 直接交给另一个对等端的 setRemoteDescription 就行。如果在通道打开之前发送,数据就会消失。

用 Opus 媒体轨道发送声音

在 /root/rt/voice/voice.py 中添加 async rtc_audio(path, seconds=2.0)。让继承自 aiortc.MediaStreamTrack 的音频轨道,把 frames(path, 20) 的前 seconds 秒,每 20ms 以 av.AudioFrame(s16、单声道、16000Hz,pts 以采样点为单位)的形式给出,并通过 addTrack 在同一个进程的两个对等端之间发送。接收方对通过 track 事件收到的轨道持续调用 recv,统计帧数和采样率。返回 {"codec": 应答 SDP 中音频 rtpmap 的第一个编解码器(例如 opus/48000/2), "sent": 发送的帧数, "received": 接收的帧数, "sample_rate": 所收帧的采样率, "duration": 从收到的第一帧到最后一帧的秒数}。

数据通道传输字节,而媒体轨道经过编解码器、RTP 时间戳和 SRTP 来传输声音。WebRTC 的 Opus 即使输入是 16kHz,在 SDP 中也始终写成 opus/48000/2,接收方以 48kHz 解出。这意味着在送入 ASR 之前,需要重新降到 16kHz。如果 recv 自己不睡眠,3 秒的内容就会在一瞬间全部发出。

计算播放缓冲区应设为多大

在 /root/rt/voice/voice.py 中添加 playout(arrivals_ms, frame_ms, buffer_ms) 和 min_buffer(arrivals_ms, frame_ms, max_late)。arrivals_ms 是序号 i 的到达时刻(毫秒,丢失则为 None)。播放基准点是最先到达的帧 k 的到达时刻减去 k × frame_ms 所得的值,第 i 帧的播放截止时刻是 基准点 + buffer_ms + i × frame_ms。playout 返回 {"late": 比截止时刻更晚到达的个数, "lost": None 的个数}。min_buffer 从 0 开始每次增加 10ms,返回使 迟到数 ÷ 到达数 不超过 max_late 的最小 buffer_ms(如果到 1000 都没有则为 None)。

播放缓冲区是以等待迟到的帧为代价,把所有声音相应地推迟。太小会卡顿,太大则对话变得迟钝。抖动越大的网络,要保持同样的卡顿比例就需要越大的缓冲区。