音视频项目:实时AI语音助手
内容来自程序员老廖:
https://space.bilibili.com/3494351095204205
1. 实时 AI 语音助手整体架构与 Opus 裸流需求
核心结论:在整个实时语音流链路中,Opus 应以「裸流」(raw Opus packets)形式存在,不使用 Ogg 等文 件容器封装。
典型的实时 AI 语音助手数据流如下:

围绕 Opus 的关键约束可以总结为:
Opus 裸流,不要 Ogg 封装
-
编码端(用户设备或 TTS 后端)直接拿编码器输出的 Opus packets,打包进 RTP 或自定义二进制帧。
-
不加 Ogg page header,否则 RTC/自定义协议无法按实时帧语义解析。
ASR 侧只接受 PCM
-
以阿里云实时 ASR 为例:要求输入 PCM(LINEAR16)、16kHz、mono。
-
因此后端必须: Opus 裸流 → 解码 → 需要时重采样到 16kHz → PCM 片段 → WebSocket 发送给 ASR 。
TTS 侧输出 PCM,需要实时编码为 Opus
-
流式 TTS 返回同样是 PCM 片段(通常 16kHz mono)。
-
后端: PCM → Opus 编码(裸包)→ 注入 RTC / WebRTC 音频通道 ,再由 RTC 下发到用户端。
帧长与延迟控制
-
语音助手场景推荐 20ms 帧长:48kHz:每帧 960 samples;16kHz:每帧 320 samples。
-
20ms 帧通常在 延迟 / 压缩效率 / 鲁棒性 之间取得较好平衡,是 ASR/TTS/RTC 的常用配置。
本仓库的 Opus 使用策略(包括 alsa_opus_ws_stub.cpp )正是围绕上述约束来设计的:内部统一采用 48kHz 的 20ms 帧,必要时再通过重采样适配到 ASR/TTS 的 16kHz 要求。
项目讲解与源码领取:
https://www.bilibili.com/video/BV1X3i1BuEPC/
2. “实时 AI 语音助手”的开发框架与分层建议
为了让 Opus 裸流链路在实际项目中易于维护与扩展,推荐按如下分层来设计整体系统:
2.1 端侧 / 客户端层
典型职责:
-
采集麦克风音频(浏览器 / App / SDK)。
-
使用 WebRTC/RTC SDK 编码为 Opus 裸流,通过 RTP 或专有协议推送到云端。
-
播放来自云端的 Opus 流(由 SDK 内部解码)。
与本仓库的关系:
-
PC/Linux native 端可以参考 alsa_capture.cpp + Opus 编码逻辑,在推流前完成本地前处理(降噪/AGC/VAD 等)。
2.2 后端接入与音频网关层
典型职责:
-
与阿里云 RTC / WebRTC 网关对接,拉取或接收 Opus 裸流。
-
对接业务侧 WebSocket/gRPC 接口(例如 /asr 、 /assistant )。
-
做基础的鉴权、路由、会话管理。
与本仓库的关系:
-
可在这一层加载“音频处理 SDK”(例如由本仓库编译出的静态库/动态库),将拉取到的 Opus 裸流交给SDK 解码成 PCM,再送入下游 ASR。
2.3 音频处理与 AI 编排服务层
典型职责:
-
Opus 解码 / 编码。
-
重采样、格式转换,统一为 ASR 需要的 16kHz / mono / S16_LE 。
-
VAD / AGC / 去回声等音频前处理(可选)。
-
与 ASR / LLM / TTS 做流式交互与编排。
与本仓库的关系:
alsa_opus_ws_stub.cpp 及相关代码可以直接演化为这一层的 “Opus 裸流 → PCM → Opus 裸流” 中间件:
-
上游:来自 RTC/网关的 Opus 包。
-
中游:PCM 形式对接 ASR 和 TTS。
-
下游:再次编码为 Opus,推送回 RTC。
2.4 观测与调试层
建议从一开始就纳入
-
对每一路音频链路记录关键指标:帧 seq、pts、包大小、解码失败率、ASR/LLM/TTS 各段延迟。
-
提供本地 PCM dump(如本示例的 ws_loopback.pcm )便于离线复盘与耳听。
与本仓库的关系:
-
WebSocketSenderStub::SendAudioFrame() 中打印的 seq/pts/bytes 以及环回写 PCM 的逻辑,就是最小可用的“观测与调试”能力。
在上述整体框架下, alsa_opus_ws_stub.cpp 更像是“音频处理与编解码 SDK 的本地验证样例”:它不关心 RTC/ASR/LLM/TTS 的业务细节,只专注于 ALSA ↔ Opus 裸流 ↔ PCM 这一核心能力链路。
3. alsa_opus_ws_stub.cpp 示例:这个示例解决什么问题?
-
输入:ALSA 采集的 PCM(默认 48kHz / 单声道 / S16_LE、交错 packed)
-
处理:必要时重采样/格式转换 → 以固定帧长喂给 Opus 编码器
-
输出 A(模拟网络发送):每个 AVPacket 的 data/size 作为 Opus payload,在 SendAudioFrame() 打印发送信息
-
输出 B(环回验证): SendAudioFrame() 内部把 Opus payload 立即解码回音频帧,转为 S16_LE PCM,写 入 ws_loopback.pcm
关键特征:
-
不使用 AVFormatContext :不做 Ogg/Matroska 等容器封装,避免把“文件/容器语义”混入“实时传输”示例
-
使用 FFmpeg avcodec 进行 Opus 编码/解码,便于与项目其它 FFmpeg 代码保持一致
4. 图:整体模块与数据流
4.1 数据流图(Pipeline)

4.2 时序图(谁先做什么)

5. 核心参数与“20ms 帧”策略
5.1 采集侧固定参数(示例里写死)
-
cap_rate = 48000
-
cap_channels = 1
-
cap_format = SND_PCM_FORMAT_S16_LE
选择 48k 的原因:
-
Opus 的内部处理以 48k 为基准(工程上统一到 48k 通常最省事)
5.2 编码帧大小(frame_samples)
程序采用:
-
frame_samples = enc_ctx->frame_size (若可得)
-
否则 fallback: 960
解释:
-
48kHz 下,20ms 对应的采样点数为:$48000 \times 0.02 = 960$
-
20ms 是语音场景最常用的帧长:延迟、压缩效率、鲁棒性折中最好
6. 为什么需要 FIFO(AVAudioFifo)?
ALSA ReadFrame() 返回的帧数( frames_read )通常与 Opus 每帧固定的 frame_samples=960 不一定对齐:
-
ALSA 的 period size 可能是 1200 frames(示例日志常见)
-
编码需要 960 frames 才能凑成一帧
所以需要 FIFO 来完成:
-
积累:把 ALSA 读到的不规则大小样本缓存起来
-
切帧:每次从 FIFO 恰好取出 960 samples 组一个编码帧
7. 是否需要重采样/格式转换(SwrContext)?
7.1 编码侧(采集 → 编码器输入)
程序会判断:
-
采集格式是否已满足 encoder 的 sample_fmt / sample_rate / channels
如果不满足,就用 SwrContext 做转换,把采集到的 PCM 变成 encoder 需要的格式后写入 FIFO。
虽然示例优先选择 AV_SAMPLE_FMT_S16 ,但不同 FFmpeg/Opus 编码器配置下仍可能出现格式不一致,因此保留转换逻辑更稳。
7.2 解码侧(解码帧 → 写入 PCM 文件)
解码器输出的 AVFrame 可能是:
-
不同采样率(尽管通常是 48k)
-
不同采样格式(float/planar 等)
为了让落盘 PCM 稳定可播放,示例强制输出:
-
S16_LE / 48k / mono
因此 SendAudioFrame() 内部:
-
1.用 Opus 解码器得到 decoded_frame
-
2. 初始化/复用 createSwrFromFrameToS16()
-
3. swr_convert() 转换后 fwrite() 写入 .pcm
8. 时间戳(PTS)与序号(SEQ)
8.1 PTS 的单位
示例里 pts 采用“采样点(samples)”作为单位:
-
frame->pts = pts; pts += frame->nb_samples;
打印时会换算为毫秒:
-
pts_ms = pts_samples * 1000 / sample_rate
这符合很多实时语音协议的思路:时间戳以采样点计数最精确,换算方便。
8.2 SEQ 的意义
seq 在每个 Opus payload 发送时递增(每个 packet 一个序号)
真正上 WebSocket 时,通常会把 seq/pts/payload 打包成自定义二进制帧:
-
seq:用于乱序检测/丢包统计
-
pts:用于播放侧时间线/抖动缓冲对齐
9. “WebSocketSenderStub” 为什么要在 SendAudioFrame() 解码?
这是为了验证链路,而不是最终产品架构:
-
真实场景: SendAudioFrame() 应该把 payload 发送到网络;解码发生在对端(云端或客户端)
-
示例场景:为了确认“编码出的 Opus payload 是可解码的”,我们把解码放在发送接口内部做“环回”
优点:
-
不依赖网络/云端
-
出问题时定位更快(编码器参数、帧边界、pts、数据拷贝等)
10. 如何验证结果(推荐步骤)
1. 运行示例生成 PCM:
./build/alsa_opus_ws_stub hw:0 ws://127.0.0.1:9000/asr 5 ws_loopback.pcm 2. 播放 PCM:
aplay -f S16_LE -r 48000 -c 1 ws_loopback.pcm 如果听到的声音与麦克风输入一致(允许有编码损失),说明:
-
ALSA 采集正常
-
编码器输出 payload 正常
-
payload 帧边界正确(每包可独立解码或可顺序解码)
-
解码器参数/重采样/写文件链路正确
11. 扩展:把 Stub 替换为真实 WebSocket(建议的接口形态)
当前 SendAudioFrame() 参数已经接近真实协议需求:
-
seq :包序号
-
pts_samples :采样点时间戳
-
sample_rate :采样率(用于换算/调试)
-
payload/payload_size :Opus payload
落地真实 WebSocket 时,建议把发送帧结构定义成:
Header(固定长度):
-
magic/version
-
seq
-
pts_samples
-
payload_size
-
flags(可选:DTX、end-of-stream 等)
Payload:Opus bytes
这样可以非常自然地把本示例替换成真实网络发送端。
12. opus编解码异常分析参考
对 Opus 来说,最“不能错”的不是那些花里胡哨的调优参数,而是:时钟和格式要严丝合缝。一旦这些错了,轻则听起来怪,重则完全解不出来 / 跟下游对不上时间。
下面按优先级说,前 4 点是绝对不能错的:
1. 采样率 + time_base + pts 一致
编码器 sample_rate
-
Opus 内部“自然采样率”是 48kHz,几乎所有实时语音场景都用 48k。
-
你采集是 48k,就一定要把 enc_ctx->sample_rate 也设成 48k。
time_base 设置 典型写法(你代码里就是):
-
enc_ctx->time_base = {1, sample_rate};
-
这样 pts 的单位就是“采样点数”。
pts 的计算
-
每编码一帧就加 nb_samples : 第一帧 pts=0 ,第二帧 pts=960 ,第三帧 pts=1920 ……(20ms@48k)。
-
如果你采样率写错、time_base 写错或 pts 递增错了,下游按你给的 sample_rate 去算时间,就会: 音画不同步 / 播放速度不对 / ASR 时间戳对不上。
总结: sample_rate 、 time_base 、 pts 三个必须是同一套逻辑,错一个就全错。
2. 声道数 / 声道布局(mono/stereo)匹配实际数据
编码器侧:
-
你采集的是单声道,就要: ff_compat_set_ctx_default_ch_layout(enc_ctx, 1);
-
不能把双声道当单声道 / 单声道当双声道去喂: 会导致左右声道互相串音、音量骤降、甚至解码器认为包有问题。
解码 + 播放 / 写 PCM:
-
下游(比如 ALSA)也要按照你真正输出的声道数来配置: 解码得到 1ch,却用 2ch 设备格式去写,会导致数据量不符或声道内容错位。
总结: channels 写几,就实际喂几;ALSA 播放 / 写 PCM 也要严格对齐。
3. sample_fmt(采样格式)要和你喂进去的 buffer 真正一致
在你的项目里:
-
采集输出是 S16 : in_fmt = AV_SAMPLE_FMT_S16;
-
编码器能接受的格式可能是:S16 (整型)或 FLTP (浮点 planar)。
-
关键是:enc_ctx->sample_fmt 要么直接是 AV_SAMPLE_FMT_S16 , 要么先用 swr_convert 把 S16 转成编码器要求的格式再喂。
典型致命错误:
-
enc_ctx->sample_fmt 写成 FLTP ,但你直接把 S16 buffer 塞给编码器;
-
或者 channels > 1 却当成单通道连续 buffer 来填 planar 格式。
-
结果就是:声音完全失真 / 解码失败 / 崩溃。
总结: enc_ctx->sample_fmt + 你填入 AVFrame->data 的实际布局,必须一一对应;不一致就会“花音”甚至崩。
4. 每帧 nb_samples(帧长)要合理,和采样率对应
典型 Opus 帧长(以 48k 为例):
-
2.5ms / 5ms / 10ms / 20ms / 40ms / 60ms
-
你示例里用的是: frame_size = 960 (20ms@48k)。
必须保证:
-
nb_samples 和 sample_rate 配套(例如 20ms → 960@48k / 320@16k);
-
pts 递增是“每帧加 nb_samples”。
设错会怎样?
-
设得太奇怪(比如非常小 / 非标准),部分实现会拒绝;
-
pts 按错误的 nb_samples 递增,会导致时间线不准。
实战里:固定用 20ms 一帧(960@48k)最安全,低延迟时可以改 10ms,其他尽量别乱搞。
5. 比特率 / application / DTX 这些是“可调优”的,不是绝对不能错
bit_rate
-
典型语音场景:16k~32k mono,你现在用的 32k 很常见。
-
写错只会影响音质/带宽,一般不会“解不出来”。
application(voip / audio / lowdelay)
-
用 voip 做通话/ASR 是合理默认;
-
写成 audio 也不会崩,只是算法调优方向不太一样。
DTX / FEC / VBR/CBR
-
静音不发包(DTX)、前向纠错(FEC)、是否变码率,这些更多影响: 带宽 / 抗丢包 / 编码效率。
-
配错通常不会导致完全失败,只是达不到预期效果。
这些参数是“好坏之分”,前面 1–4 点则是“对错之分”。
6. 解码器侧:保持“诚实”和“一致”
-
多数时候,解码器会从 Opus 流里自己知道声道数 / 内部采样率;
-
你给 dec_ctx->sample_rate / ch_layout 的作用,相当于是“期望输出”, 然后再用 swr 统一成下游(ALSA / PCM 文件)需要的格式。
-
关键不要做的事是: 明明解码输出是 48k,你却告诉下游(播放 / 上层协议)是 16k; 或者把多声道误当单声道来写入/播放。
实际项目里可以怎么自查?
你可以用下面这几个“检查点”扫一遍当前代码和后续改动:
1. 采集参数 vs 编码器参数:
-
cap_rate == enc_ctx->sample_rate ?
-
cap_channels == 编码器声道数?
-
如不等,中间是否有 swr 做合法转换?
2. time_base / pts :
-
time_base={1, sample_rate} ?
-
pts 是否按 nb_samples 递增?
3. sample_fmt :
-
enc_ctx->sample_fmt 和实际喂进去的 AVFrame->format 、 frame->data 布局一致?
-
若 ALSA 是 S16、编码器要 FLTP,是否有 swr_convert ?
4. 帧长:
-
nb_samples 是否对应一个合理的帧长(如 960@48k=20ms)?
夜雨聆风