与WebRTC无关的“小”麻烦
局域网语音听起来简单:把麦克风数据打包发过去,再播放出来即可。可一旦动手,就会发现“小”麻烦层出不穷:
- NAT 虽然不存在,但端口复用、防火墙白名单依旧能把 ICE 打回原点
- 纯 P2P 在会议室拓扑里常出现“三角形”链路,需要一台可控的“协调节点”决定谁连谁
- 延迟敏感:语音>150 ms 就能被感知,缓冲区一抖就爆音
- 多线程采集、编码、网络、播放四段流水线,稍有不慎就串帧
这些痛点决定了:必须选一个“自带时间戳、拥塞控制、回声消除”的轮子,而不是裸写 RTP。
技术选型:为什么最后留下 WebRTC
| 方案 | 局域网优势 | 局域网劣势 | 结论 |
|---|---|---|---|
| 裸 RTP/UDP | 极简、无外部依赖 | 无 NAT 穿透、无 QoS、无缓冲策略 | 需自写 30% 代码 |
| SIP + RTP | 成熟、可对接 IP 话机 | 信令重、需注册服务器,ICE 要额外插件 | 太重 |
| WebRTC | 自带 ICE、SRTP、NACK、PLC、AEC,浏览器互通 | API 陡峭,需要自己写信令 | 成本最低 |
在“可控节点”+“低代码量”两项打分最高的是 WebRTC,于是拍板。
总体架构
Server (C++) ──────────────→ 仅跑信令 & 房间状态 │ WebSocket (json) ┌──┴──┐ 局域网 UDP/ICE 直连 │ A │←────────────────────→│ B │ └─────┘ └─────┘服务器不转发媒体,只负责把“SDP + ICE”准确快递到对端,A 与 B 一旦 Candidate 配对成功,后续数据与服务器零往来。
信令服务器设计(C++17)
- 单进程 epoll 模型,监听 9002(WS)与 9001(HTTP health)
- 房间用
unordered_map<string, vector<weak_ptr<Session>>>维护 - 消息格式(简化):
{"type":"offer","room":"1001","sdp":"v=0\r\no=- ..."} {"type":"ice","room":"1001","candidate":"candidate:1 1 UDP 2130706431 192.168.1.10 55348 typ host"}- 关键类(节选,Google Style)
// signaling/session.h #ifndef SIGNALING_SESSION_H_ #define SIGNALING_SESSION_H_ #include <memory> #include <string> #include "websocket/websocket.h" 和内部分享,我们采用头文件 Guards 与 std:: 前缀,保持与 Chromium 一致。 namespace prefab { class Session : public std::enable_shared_from_this<Session> { public: Session(tcp::socket sock, std::string id); void Send(const std::string& json); // 线程安全 std::string id() const { return id_; } private: void OnRead(); std::string id_; WebSocket ws_; }; } // namespace prefab #endif // SIGNALING_SESSION_H_- 消息派发(Reactor 线程摘取)
void Room::Broadcast(const std::string& sender_id, const Json::Value& msg) { std::string payload = Json::writeString(builder_, msg); for (auto& w : participants_) { if (auto s = w.lock()) { if (s->id() != sender_id) // 跳过自己 s->Send(payload); } } }服务器至此收工,不到 800 行,后续调优基本与它无关。
客户端核心流程(libwebrtc @ m114)
- PeerConnectionFactory 单例
- 创建 AudioTrack + AudioSource,本地端口走“default_adm”
- 收到远端 SDP 后 SetRemoteDescription → CreateAnswer → SetLocalDescription
- ICE 回调直接通过 WS 转发
ICE 候选交换片段
// webrtc_client.cc void OnIceCandidate(const webrtc::IceCandidateInterface* candidate)入耳 std::string sdp; candidate->ToString(&sdp); Json::Value msg; msg["type"] = "ice"; msg["candidate"] = sdp; msg["room"] = room_id_; ws_->Send(Json::writeString(builder_, msg)); }SDP 协商片段
void CreateOfferDone(webrtc::SessionDescriptionInterface* desc) { pc_->SetLocalDescription(SetLocalObserver::Create(), desc); // 触发 ICE _gathering std::string sdp; desc->ToString(&sdp); Json::Value msg; msg["type"] = "offer"; msg["sdp"] = sdp; ws_->Send(...); }注意:局域网主机候选通常只有 UDP/主机型,无需 STUN,但代码里仍保留PeerConnectionInterface::RTCConfiguration的ice_servers字段,方便以后公网扩展。
性能优化三板斧
- 带宽自适应
- 语音仅需 30-40 kbps,Opus 带内 FEC 动态开关,WebRTC 的
audio_network_adaptor默认启用,无需干预
- 语音仅需 30-40 kbps,Opus 带内 FEC 动态开关,WebRTC 的
- 抗丢包
- NACK + PLC 组合即可把 5% 随机丢包听感降到 <1%,局域网一般无丢包,可关闭 NACK 减 2 ms 延迟
- 线程模型
- 采集、编码、网络三线程足够;若嵌入式 CPU 核少,把编码线程绑在最大核,防止 Opus 编码 20 ms 片被抢占
避坑指南
- STUN/TURN 配置:局域网 demo 阶段把
ice_servers置空,可节省 200 ms 探测时间 - 缓冲区:Linux ALSA 默认 4 周期×1024 帧,延迟 85 ms;改 3×512 可压到 30 ms
- 跨平台:Windows 端用 WaveIn/Wasapi 双路径,回退策略在 CMake 里加
/DWEBRTC_WIN_USE_WASAPI - 日志:libwebrtc 默认 INFO 级,体积暴涨,发布前务必
rtc::LogMessage::GetLogToDebug(rtc::LS_WARNING)
扩展思考
- 视频只需再加 VideoTrack,同一 PeerConnection 复用信令;H264/VP8 选硬件编码,树莓派 4 可 720p@30fps
- 加密:WebRTC 默认 SRTP,局域网如想国密,可在
RtpPacket层外挂 SM4/SM3,需改SrtpCryptoSuite枚举并重新编译 - 服务器混音:如果后续做多方会议,可把服务器升级为 SFU(如 libwebrtc 的
rtc::VideoReceiveStream接口),仍保持 C++ 技术栈一致
把“能跑”变“能玩”
整套代码在 GitHub 私有模板里固化后,我们 3 人小组只花两天就让局域网对讲跑起来,延迟 60 ms、CPU 占用 7%(i5-8265U)。如果你也想亲手捏一个“会说话的程序”,又恰好对语音 AI 感兴趣,不妨顺路体验下从0打造个人豆包实时通话AI动手实验——里面把 ASR→LLM→TTS 整条链路拆成了可插拔的小任务,照着实操一遍,就能把今天这套 WebRTC 壳子直接升级成“能听懂、会回答”的语音助手。祝编码愉快,回见。