电话接入后,很多 Voice Agent 的日志会出现一个很整齐的时间:RTP 首包到了,几十毫秒后 ASR 才收到首帧。于是团队开始争论,问题是不是 G.711 太旧、Opus 更快,还是把 8 kHz 升到 16 kHz 本身拖慢了识别。
这几个词容易被混在一起。G.711 PCMU/PCMA 是电话侧常见的窄带编码;Opus 是为交互式语音和音频设计的 codec;重采样只是把 PCM 流交给下游模型时改变采样率。它们分别影响协商、音频信息量、CPU 开销、缓存和测量位置。把它们统称为"音频格式问题",最后只能得到一个没法行动的总延迟。
我的判断是:**不要为了追求"更高采样率"而在入口堆转换。电话来的 8 kHz G.711,先稳定解码、只做一次显式转换并把耗时记出来;若链路原生协商到 Opus,也先确认 packet、jitter buffer 与 runtime 的输入契约。**升采样能让接口对齐,不会凭空补回窄带中已经缺失的语音细节。
本文讨论的是 AI 客服从电话 RTP 进入 ASR 前的入口设计,不比较厂商模型能力,也不声称某种编码必然带来更高识别率。文末附了一个只用 Python 标准库的策略夹具,用于验证 codec/rate 选择与时间口径。
目录
- 先把"编码、采样率、重采样"拆开
- 电话入口不是音质排行榜
- 一个更稳的入口契约
- 为什么我不建议反复转码
- 用实验记录,而不是平均延迟,判断入口代价
- 把选择逻辑写成可测策略
- 运行示例与八项测试
- 中文客服样本怎样验收
- 上线后应该追哪些日志
- 结论与边界
- 参考资料
先把"编码、采样率、重采样"拆开
它们不是同一个参数。
| 概念 | 在入口里解决什么 | 常见误解 |
|---|---|---|
| codec | RTP 包里的音频如何表示和解码 | "codec 名字更先进,识别一定更准" |
| sample rate | 每秒采多少个样本,决定信号带宽上限 | "升到 16 kHz 就等于拿到了 16 kHz 录音" |
| packet duration | 每个 RTP 包装多少毫秒音频 | "包越小一定越快" |
| resampling | 在已解码 PCM 与下游输入契约之间转换采样率 | "重采样是免费的格式转换" |
RFC 3551 给出了 RTP profile 中 PCMU 和 PCMA 的静态 payload type:PCMU 是 PT 0、PCMA 是 PT 8,时钟率均为 8,000 Hz。它描述的是 RTP 传输和编码约定,不保证后面的 ASR 一定适合 8 kHz 输入。RFC 3551 是先核对这个事实的地方。
Opus 的定位不同。RFC 6716 把它定义为交互式语音和音频 codec,支持从窄带语音到高质量立体声音频的范围,并且算法延迟受 frame、模式和实现影响。它并不是一个"换上就自动降低端到端延迟"的开关。RFC 6716 对其延迟范围和工作模式有更准确的边界。
电话入口不是音质排行榜
如果用户从 PSTN 或传统 SIP trunk 呼入,入口经常就是 8 kHz 的 PCMU/PCMA。此时最重要的问题不是"能不能把它伪装成宽带音频",而是:SDP 是否协商成功、RTP 是否连续到达、每个 20 ms 包是否按预期解码,以及唯一一次转到 ASR 输入采样率的边界在哪里。
这条链路可以很朴素:
#mermaid-svg-DCjIDtQQDg1Supow{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DCjIDtQQDg1Supow .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DCjIDtQQDg1Supow .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DCjIDtQQDg1Supow .error-icon{fill:#552222;}#mermaid-svg-DCjIDtQQDg1Supow .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DCjIDtQQDg1Supow .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DCjIDtQQDg1Supow .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DCjIDtQQDg1Supow .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DCjIDtQQDg1Supow .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DCjIDtQQDg1Supow .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DCjIDtQQDg1Supow .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DCjIDtQQDg1Supow .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DCjIDtQQDg1Supow .marker.cross{stroke:#333333;}#mermaid-svg-DCjIDtQQDg1Supow svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DCjIDtQQDg1Supow p{margin:0;}#mermaid-svg-DCjIDtQQDg1Supow .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DCjIDtQQDg1Supow .cluster-label text{fill:#333;}#mermaid-svg-DCjIDtQQDg1Supow .cluster-label span{color:#333;}#mermaid-svg-DCjIDtQQDg1Supow .cluster-label span p{background-color:transparent;}#mermaid-svg-DCjIDtQQDg1Supow .label text,#mermaid-svg-DCjIDtQQDg1Supow span{fill:#333;color:#333;}#mermaid-svg-DCjIDtQQDg1Supow .node rect,#mermaid-svg-DCjIDtQQDg1Supow .node circle,#mermaid-svg-DCjIDtQQDg1Supow .node ellipse,#mermaid-svg-DCjIDtQQDg1Supow .node polygon,#mermaid-svg-DCjIDtQQDg1Supow .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DCjIDtQQDg1Supow .rough-node .label text,#mermaid-svg-DCjIDtQQDg1Supow .node .label text,#mermaid-svg-DCjIDtQQDg1Supow .image-shape .label,#mermaid-svg-DCjIDtQQDg1Supow .icon-shape .label{text-anchor:middle;}#mermaid-svg-DCjIDtQQDg1Supow .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DCjIDtQQDg1Supow .rough-node .label,#mermaid-svg-DCjIDtQQDg1Supow .node .label,#mermaid-svg-DCjIDtQQDg1Supow .image-shape .label,#mermaid-svg-DCjIDtQQDg1Supow .icon-shape .label{text-align:center;}#mermaid-svg-DCjIDtQQDg1Supow .node.clickable{cursor:pointer;}#mermaid-svg-DCjIDtQQDg1Supow .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DCjIDtQQDg1Supow .arrowheadPath{fill:#333333;}#mermaid-svg-DCjIDtQQDg1Supow .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DCjIDtQQDg1Supow .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DCjIDtQQDg1Supow .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DCjIDtQQDg1Supow .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DCjIDtQQDg1Supow .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DCjIDtQQDg1Supow .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DCjIDtQQDg1Supow .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DCjIDtQQDg1Supow .cluster text{fill:#333;}#mermaid-svg-DCjIDtQQDg1Supow .cluster span{color:#333;}#mermaid-svg-DCjIDtQQDg1Supow div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DCjIDtQQDg1Supow .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DCjIDtQQDg1Supow rect.text{fill:none;stroke-width:0;}#mermaid-svg-DCjIDtQQDg1Supow .icon-shape,#mermaid-svg-DCjIDtQQDg1Supow .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DCjIDtQQDg1Supow .icon-shape p,#mermaid-svg-DCjIDtQQDg1Supow .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DCjIDtQQDg1Supow .icon-shape .label rect,#mermaid-svg-DCjIDtQQDg1Supow .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DCjIDtQQDg1Supow .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DCjIDtQQDg1Supow .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DCjIDtQQDg1Supow :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} RTP: PCMU/8000
G.711 解码
PCM 8 kHz
一次重采样 8k → 16k
ASR 输入帧
关键字段确认
这里的"8k → 16k"不是音质修复。它通常只是为了让 ASR runtime 接受统一 PCM 输入。把低采样率信号插值成更密的样本,不会恢复原始电话带宽之外的频率;它能做的是让后续模块按统一帧长、统一接口工作。
LiveKit 的 SIP codec 协商文档也把 codec 和 rate 作为明确字段处理,并举例说明 caller 可以提供 PCMU、PCMA、Opus 等多种 offer。需要从协商结果选一条可用路径,而不是在 Agent 内部假设所有电话都是 16 kHz PCM。LiveKit codec negotiation 是一个可参考的实现边界。
一个更稳的入口契约
闪电智能VoiceAgent 的音频入口应对下游暴露稳定的契约,而不是把 SIP codec 判断散落在 ASR、VAD 和业务代码里。最小契约可以包含:
json
{
"call_id": "opaque-id",
"source_codec": "PCMU",
"source_rate_hz": 8000,
"packet_ms": 20,
"target_pcm_rate_hz": 16000,
"conversion": "decode_then_resample_8000_to_16000",
"rtp_first_packet_at": "monotonic-time",
"asr_first_frame_at": "monotonic-time"
}
这样做有两个直接好处。第一,ASR 只接收已声明的 PCM 格式,避免某个 provider 把 PCMA/8000 送进一个只按 PCMU/8000 解码的分支。第二,性能问题能分开看:rtp_first_packet_at → asr_first_frame_at 是入口处理时间,不能混进 LLM 首 token 或 TTS 首包。
我不建议把"codec 名称"直接写成用户画像或业务路由条件。它是连接质量和信令协商的一部分,不是客户偏好。对中文客服真正重要的是后续数字、地址、订单号能否确认,而不是输入被包装成了哪个看起来更潮的编码。
为什么我不建议反复转码
一个常见的入口是:G.711 解码成 PCM 8 kHz,升到 16 kHz 给 VAD,再转成另一个格式给 ASR,ASR 返回时再为分析模块复制一份。每一步都可能合理,但如果没有明确所有权和缓冲策略,问题会慢慢出现:
- 每次转换都可能创建新的 buffer,抖动高峰时增加 GC 或内存压力;
- 固定大帧会让 endpoint 等待更久,固定小帧又会放大调度与包处理开销;
- 把解码、重采样和降噪混成一个总耗时后,无法判断慢在 codec、队列还是 VAD;
- 同一音频被不同模块以不同 rate 解释,会让时间戳和字段确认难以对齐。
更可控的方式是让媒体网关完成 RTP 解包与解码,再由一个入口适配器负责一次 PCM 转换。需要多消费者时,共享带有 rate/format 元数据的不可变帧引用,或在明确的 fan-out 边界复制。不要在每个消费者里"顺手转一次"。
Opus 也不等于可以忽略这些问题。它可以适配交互式音频场景,但实际的首帧等待仍受 packetization、jitter buffer、网络抖动、解码实现和下游 chunk 组织影响。改 codec 前,先证明当前瓶颈就在 codec/重采样段,而不是把网络抖动错记成模型慢。
用实验记录,而不是平均延迟,判断入口代价
如果要比较两条入口路径,先固定以下条件:同一段音频、同一 VAD、同一 ASR 模型、同一部署位置、同一 chunk 大小。一次只改一个变量,例如把 PCMU/8000 → PCM/16000 的重采样实现换掉,或把 Opus 的 packet duration 从 20 ms 改成另一档。不要同时换 codec、VAD、模型和网络线路。
建议至少记录这几个时间点:
| 时间点 | 定义 | 用途 |
|---|---|---|
t_rtp_first |
收到第一个合法 RTP 包 | 分开电话媒体到达与模型等待 |
t_pcm_ready |
解码后的第一段 PCM 可用 | 观察 decoder/queue 边界 |
t_asr_frame |
第一帧已交给 ASR | 衡量重采样与帧组装 |
t_asr_partial |
首个 partial 到达 | 观察 ASR 自身和网络 |
t_field_confirmed |
关键字段被确认 | 看真实客服任务,而不是只看转写 |
t_asr_frame - t_rtp_first 是这篇最该看的入口指标。把它按 codec、rate、packet_ms、线路和版本分组,再看 P50/P95。本文没有真实电话样本,因此不填任何数值;下面的演示时间只验证字段相减的口径。
把选择逻辑写成可测策略
示例的重点不在"写一个音频库",而是让选择和转换显式出现:
python
def choose_ingress(offers, asr_rate_hz=16000):
usable = [o for o in offers if (o.codec, o.sample_rate_hz) in SUPPORTED]
if not usable:
return IngressPlan(False, "", 0, None, None, "no_supported_codec_rate_pair")
selected = next((o for o in usable if o.codec in {"PCMU", "PCMA"}), usable[0])
if selected.sample_rate_hz == asr_rate_hz:
return IngressPlan(True, selected.codec, selected.sample_rate_hz, asr_rate_hz, "passthrough", "rate_matches_asr")
return IngressPlan(True, selected.codec, selected.sample_rate_hz, asr_rate_hz,
f"decode_then_resample_{selected.sample_rate_hz}_to_{asr_rate_hz}",
"one_explicit_conversion_boundary")
完整代码在 examples/audio_ingress.py。这里特意没有给 G.711 与 Opus 做"好/坏"评分:同一条业务线能不能优先用某个 offer,取决于 trunk 支持、端侧条件、ASR 输入、成本和真实样本。示例只保证不支持的 codec/rate 在进入 ASR 前被拒绝,且转换不会悄悄发生。
运行示例与八项测试
text
CSDN-音频入口编解码/
├── article.md
├── README.md
├── examples/
│ ├── audio_ingress.py
│ └── run_demo.py
└── tests/
└── test_audio_ingress.py
在项目根目录运行:
bash
python3 -m unittest discover -v
python3 -m examples.run_demo
8 项测试覆盖 PCMU、PCMA、Opus-only offer、电话 codec 优先、未知 codec、异常 packet duration、入口耗时分离和不可能的时间顺序。它们防的是接口和观测回归,不是验证"某种编码识别更准"。
中文客服样本怎样验收
音频入口稳定后,验收不应只播放一句普通话问候。至少放进这些样本:
| 样本 | 检查点 |
|---|---|
| 手机号与验证码读法 | 8 kHz 输入后数字是否需要二次确认 |
| 地址与楼栋号 | "十二号楼/二十号楼"等易混字段是否在业务层确认 |
| 订单号混有字母 | ASR、后处理和确认话术是否保留原始证据 |
| 方言或口音 | 不把识别失败硬归因于 codec,保留可回听或人工复核路径 |
| 用户打断 | 解码/重采样队列能否停止旧音频,不给下一轮混入残帧 |
| 单向音频与丢包 | RTP 监控能否先发现媒体问题,而不是让 ASR 静默等待 |
金额、身份、赔付、账户信息不能因"音频入口已通过"而跳过确认。入口只保证声音以可解释方式到达后续链路,不是业务事实的证明。
上线后应该追哪些日志
至少把 call_id、SIP Call-ID、source codec/rate、packet_ms、RTP 首包、PCM ready、ASR 首帧、丢包/抖动、VAD endpoint 和最终字段确认串进同一条 trace。日志中不应保存完整电话号码、Authorization 或原始音频;需要回溯时使用脱敏 ID、受控录音引用和访问审计。
当 P95 变慢时,先看是哪一段变慢:
text
RTP 首包慢 → 电话媒体/网络问题
RTP 到 ASR 首帧慢 → 解码、重采样、buffer 或调度问题
ASR 首帧到 partial 慢 → ASR 服务或网络问题
partial 到字段确认慢 → 业务规则、对话确认或人工边界问题
这比"换一个更快的 codec"更接近可操作的排障。
结论与边界
G.711、Opus 和重采样都会影响 Voice Agent 的入口,但它们不是同一个优化旋钮。电话侧的 G.711 8 kHz 常常是输入事实;升到 16 kHz 是接口适配,不是音质恢复;Opus 的价值也要结合协商、packet、网络与实现来判断。
闪电智能VoiceAgent 更可靠的做法是把 codec/rate/转换记录成入口契约,只有一次明确转换,并把 RTP → ASR 的时间单独记账。真实中文电话样本、SRTP/NAT、jitter buffer 和具体 ASR 模型仍需在线路环境中验证,本文代码不替代这些集成测试。