资讯详情 用WebSocket实现Python低延迟语音传输:从PCM到Opus的工程实践
📅 2026/10/15 18:49:55
简介基于WebSocket协议实现语音收发的Python示例资源面向希望快速搭建双向实时语音通信服务端与客户端的开发者适合用于网络编程实验、语音传输原型或轻量级语音服务改造。资源为rar压缩包共3个文件包含2个Python源码文件和1个备份文件分别对应服务器端主程序、客户端连接发送逻辑以及旧版本备份整体仅9KB代码结构简洁便于按需调整。该资源已有3112人浏览学习说明其在WebSocket入门与语音通道示例方面具备一定参考价值。借助Flask-SocketIO与websocket-client等库读者可直接运行或对比主、备版本代码梳理服务器监听、客户端连接、消息收发与音频数据编码等关键环节也可进一步接入Kaldi等语音识别工具进行扩展适合作为语音交互项目的起步模板也便于课程设计或工程原型快速落地。1. 用 WebSocket 收发语音一条比 WebRTC 更可控、比 HTTP 轮询更实时的路想用 Python 做一套实时语音服务器和客户端很多人第一反应是 WebRTC结果被 STUN/TURN、SDP 协商、ICE 候选这一堆概念劝退也有人图省事用 HTTP 轮询把录音文件一帧一帧传上去延迟直接飙到好几秒双向说话根本没法用。这时候用 WebSocket 方法实现收发语音的服务器和客户端的 Python 代码反而成了最务实的中间路线WebSocket 是全双工长连接服务器可以随时把语音帧推给客户端客户端也能按 20ms 一帧往服务器灌延迟可控、代码量小、浏览器和 Python 都认这套协议。本文面向要快速落地实时对讲、AI 语音中继、远程语音采集这类场景的从业者从帧格式设计、库选型、完整可跑通的代码、高频踩坑到进阶压缩一步步说清楚。2. WebSocket 语音传输的核心设计延迟、帧协议与 Python 库选型动手写代码前先得把“语音走 WebSocket”这件事立住。有些读者会问语音实时传输不是应该走 UDP 吗WebSocket 底层是 TCP丢包重传会不会把实时性毁掉这种担心有道理但要分场景看。做视频会议级别的多人实时通话WebRTC 确实是标准答案但如果是一对一指挥调度、AI 语音中继、远程训鸟器这类中低并发场景TCP 的可靠性和 WebSocket 的天然双向能力反而省掉了一大堆网络穿透和拥塞控制的实现成本。帧丢了重传会有延迟抖动但只要帧足够小、播放端做好缓冲和丢旧帧策略实际听感完全可以接受。2.1 为什么语音走 WebSocket 而不是 HTTP 轮询延迟与双向性HTTP 轮询做语音的核心问题是“单向提问”的模型根本不适合双向实时语音。客户端要发声就得反复 POST 录音片段服务器要回放客户端还得周期性 GET 拉取。每个 HTTP 请求都要携带头部、可能重新握手20ms 一帧的话每秒 50 个请求光协议开销就把资源吃光了。更致命的是轮询周期决定了延迟下限轮询间隔 100ms实际感受到的延迟就是 100ms 到 200ms 之间跳动间隔缩短到 20ms服务器压力又成倍增长。WebSocket 把这个问题一次性解决连接建立后服务器和客户端之间的信道是全双工的任何一方都能随时推送二进制帧。我不需要再为“这次要请求什么”操心只需要定义好“这一帧音频是什么格式、给谁听”。WebRTC 当然延迟更低但它引入的信令协议栈和媒体协商机制对很多只需要“把麦克风声音送到服务器再分发给其他端”的应用来说属于大炮打蚊子。我一般用下面这个对比表评估项目该走哪条路方案双向实时性实现复杂度中低并发可用性浏览器兼容HTTP 轮询差延迟随轮询周期增大低差资源浪费严重好SSE单向只能服务器推给客户端低不支持上行语音好WebSocket PCM好延迟约 30-60ms 可调中好用代码简单好但需要 wss原生 WebRTC最好延迟 10-30ms极高需 STUN/TURN并发高但基建重最好如果产品形态是嵌入式设备到服务器的单向上传、中控系统播报、AI 语音应答回流WebSocket 方案的性价比非常突出。真正的实时电话级体验才需要 WebRTC 全家桶。这也是为什么很多开源对讲项目仍然选择 WebSocket 而不是直接上 WebRTC。2.2 音频怎么装进 WebSocket 帧采样率、位深与 20ms 帧语音进 WebSocket 之前要先想清楚音频的编码格式。做实时传输我强烈建议从原始 PCM 开始不经过 MP3、AAC 这类有损压缩。PCM 最大的好处是“零编解码延迟”麦克风采集出来是什么字节流直接塞进网络帧。压缩格式适合离线存储不适合实时交互因为每一帧都要过编码器编码器本身有算法延迟和缓冲叠加起来很容易突破人耳可感知的 50ms 门槛。具体参数选择上最稳妥的起点是 16kHz 采样率、16bit 位深、单声道。人声的主要能量集中在 300Hz 到 3400Hz16kHz 采样率足够覆盖语音识别和语音合成接口也普遍支持这个采样率。16bit 位深是标准值PCM 编码下每个采样点占 2 字节。单声道避免左右声道数据量翻倍也能省掉声道对齐的麻烦。帧长选多少很关键。我做过实验对比20ms 是我认为实时性和封装开销的最佳平衡点帧长帧内采样点数帧字节数16k/16bit/单声道单帧延迟贡献封装效率10ms160320 字节低但对抖动敏感帧头比例偏高20ms320640 字节中人耳很难察觉合理40ms6401280 字节偏高有明显颗粒感较高100ms16003200 字节高不推荐实时最高20ms 一帧意味着每秒 50 帧每帧 640 字节。这个尺寸对 TCP 和 WebSocket 的封装都很友好单帧即使加入帧头也不会超过 MTU 分片阈值。麦克风采集回调天然按帧工作播放端也按帧喂给声卡逻辑非常顺。选 40ms 虽然能让网络包数量减半但说话时感觉声音“慢半拍”两个人抢话场景就是灾难。带宽方面PCM 流码率是固定算得出来的16000 × 2 × 8 256kbps。对局域网或者手机 4G/5G 来讲完全不是问题但公网弱网环境下偏大。这个我们留到第 6 章讲用 Opus 怎么把它压到 24kbps 左右不影响前期的框架搭建。2.3 帧协议封装给语音帧做上序号和采样率标记裸 PCM 直接塞进 WebSocket 消息虽然能跑但只能应对最简单的一对一场景。一旦进入多客户端、服务器中转、网络抖动排查裸 PCM 就会让你什么都查不了。我现在的习惯是任何实时音频帧都要带一个轻量头部至少包括帧序号和采样率。帧序号用于检测丢帧、乱序也为了压延时不至于误杀新帧。采样率则是为了让接收端能自动识别发送端用的是 16k 还是 8k避免播放出尖锐噪声。头部我一般用 8 个字节占用不大但作用明显import struct # 帧头4字节大端序号 4字节大端采样率后面跟PCM载荷 def pack_audio(seq: int, rate: int, pcm: bytes) - bytes: return struct.pack(!II, seq, rate) pcm def unpack_audio(frame: bytes): seq, rate struct.unpack(!II, frame[:8]) return seq, rate, frame[8:]这段代码逻辑很简单struct.pack(!II, seq, rate)用大端序打包两个无符号整数占用 8 字节。把采样率打到帧头上之后服务器中继时完全不关心音频内容只做原样转发客户端收到帧后解出头判断采样率和本机是否一致。!表示网络字节序大端I表示 4 字节无符号整数。这个头对于每条 20ms 音频帧来说封装开销从原来的 WebSocket 头2-14 字节增加到 8 字节依然可以忽略不计。2.4 Python 库选型websockets 服务端与 pyaudio 采集播放服务器和客户端我用同一个库把协议栈统一起来就是websocketsPython 生态里最主流的 asyncio 原生 WebSocket 实现。它的 API 设计清晰服务端websockets.serve()接收连接客户端websockets.connect()发起连接两边都提供send()/recv()收发文本和二进制帧。用同一个库的好处是消息边界处理逻辑完全一致不会出现客户端和服务端对“一帧数据怎么切分”理解不一致的玄学问题。采集和播放用pyaudio。它是 PortAudio 的 Python 封装跨平台能力成熟底层会自动做设备枚举和格式协商。安装命令是pip install websockets pyaudio这里给新读者一个提醒Windows 环境下pyaudio直接pip install经常遇到需要编译的错误建议先把pyaudio‑0.x.x‑cp39‑cp39‑win_amd64.whl这类预编译包装好再装依赖。Linux 下则要先确保系统有 PortAudio 开发库否则同样可能编不过。这些都属于买前试坑在第 5 章的排查清单里还会详细说。websockets库要注意版本差异。7.0 之前和 7.0 之后的手握 API 有变化新版本推荐直接用asyncio上下文管理器。我下面的代码基于 10.0 以上的 API如果你用的是老版本遇到serve或connect的行为不一致优先查一下库版本而不是改业务代码。3. 服务器端实现连接管理、房间隔离与语音帧中转服务器端的角色想清楚之后再写代码会非常快它不负责编解码不做回声消除也不存语音内容除非你要落盘。它唯一的核心职责是——维护一批 WebSocket 连接收到谁的音频帧就转发给谁。这个设计让服务器代码保持精简后续把中转逻辑换成 Kafka、换成 Redis 消息队列或者叠加上录音归档都不会影响音频链路的稳定性。3.1 服务端职责划分为什么服务器只做中转很多人第一次写语音服务器容易把音频处理逻辑塞进服务端比如做噪声抑制、VAD 检测、混音。我吃过这个亏起初想在服务器统一做音量归一化结果每个客户端的音频格式和电平都不同调参调了一个星期最终老老实实把处理下放到各客户端。服务端做得越多并发能力越差排障路径也越长。正确做法是保持服务器的“哑中转”特性二进制帧进来解析头部拿到房间信息或直接用连接所属房间然后把帧原样转发给房间内其他连接。房间隔离是语音服务端的第一道门槛。没有房间隔离所有客户端都会互相听到对方说话一旦用户量超过 2 个就是灾难。我用一个字典结构维护所有房间key 是房间 IDvalue 是当前房间内的连接对象及其元信息。客户端加入房间时发一个 JSON 文本帧声明身份和音频参数之后所有二进制帧都按房间维度转发。3.2 服务端完整代码下面是一个可以直接跑的最小服务器支持多房间、匿名加入、二进制语音帧广播转发、连接异常自动清理# server.py import asyncio import json import websockets # 房间表room_id - { websocket: {name: str, rate: int} } ROOMS {} async def handler(ws): room None try: async for message in ws: if isinstance(message, str): # 文本帧只用来做信令不承载音频 data json.loads(message) if data.get(type) join: room_id str(data.get(room, default)).strip() if room_id not in ROOMS: ROOMS[room_id] {} ROOMS[room_id][ws] { name: str(data.get(name, guest)), rate: int(data.get(rate, 16000)), } room ROOMS[room_id] await ws.send(json.dumps({type: joined, room: room_id})) else: # 二进制帧视为语音帧转发给同房间的其他连接 if room is None: continue for target, info in list(room.items()): if target is ws: continue try: # 1秒发送超时防止慢目标把整个事件循环拖死 await asyncio.wait_for(target.send(message), timeout1.0) except Exception: await target.close() room.pop(target, None) except websockets.exceptions.ConnectionClosed: pass finally: if room is not None: room.pop(ws, None) # 房间为空时顺手清掉避免ROOMS无限膨胀 for rid, clients in list(ROOMS.items()): if clients is room: del ROOMS[rid] break async def main(): # max_sizeNone 否则超大帧或聚合帧会被库默认限制拒绝 async with websockets.serve(handler, 0.0.0.0, 8765, max_sizeNone): await asyncio.Future() # 运行到永远 if __name__ __main__: asyncio.run(main())这段代码有四个细节值得展开。第一websockets.serve(handler, 0.0.0.0, 8765, max_sizeNone)中监听地址必须是0.0.0.0否则外网客户端根本连不进来max_sizeNone关闭了单条消息大小限制虽然 640 字节的音频帧远达不到默认上限但后期如果做 Opus 聚合帧或者在帧头里附加额外元信息这个限制会变成隐形地雷。第二asyncio.wait_for(target.send(message), timeout1.0)是防止慢客户端拖垮全房间的关键某一个客户端网络差、缓冲区满时await send()会一直挂起如果不加超时房间内所有人的音频转发都会被阻塞。第三转发时只排除发送者target is ws这样当房间里有 3 个以上客户端时语音是全向广播的符合多数对讲场景。第四ROOMS[room_id][ws] {...这种方式把连接对象本身当作字典 key省去了维护自增 ID 的麻烦连接关闭后直接用room.pop(ws, None)清理。参数说明方面8765端口可以改成任何空闲端口max_size单位是字节设成None最省心但如果你做的是公网服务且担心恶意客户端用超大帧打内存可以设成1048576并配合房间人数上限一起控制。timeout1.0这个值不建议设得更小因为正常情况下 20ms 一帧的转发耗时远低于 1 秒一旦触发超时说明目标连接大概率已经不可用了直接踢掉比反复重试更干净。3.3 为什么转发也要保活连接清理和心跳策略语音连接是长连接但长连接不等于永久连接。客户端断网、手机息屏、路由 NAT 表项老化都会让服务器端看到一个“已经死掉但没通知”的连接。最直接的后果是send()发到一半抛异常或者永远等不到 PONG 响应。上面代码里已经用try/except把转发出错的连接清理掉了但这属于事后补救。更稳妥的做法是服务端定期 ping 所有连接比如每 30 秒发一次 WebSocket Ping在handler的循环外再起一个后台任务遍历房间内的连接逐个 ping超时没回的直接关闭。为什么是 30 秒因为很多云厂商的负载均衡和 NAT 网关空闲超时一般设置在 60 秒左右客户端如果完全静默超过这个时间网关会主动断开 TCP 连接。WebSocket 的 ping 帧本身不携带应用数据带宽开销几乎为零却能让链路一直保持“活跃状态”。这个细节放到第 5 章还会再讲因为公网部署时掉线问题 90% 都出在这里。4. 客户端实现pyaudio 采集、异步发送与实时播放客户端是整套代码里最容易翻车的部分原因在于它同时要管两条时间线一条是麦克风和扬声器的实时音频时钟另一条是网络传输的不稳定时钟。这两条时间线一旦没有隔离好声音就会忽快忽慢延迟越积越大。我的做法是用两个队列把采集、发送、接收、播放四个环节解耦让每个环节只依赖上一个环节的输出不直接依赖网络或声卡的实时状态。4.1 客户端的线程模型与缓冲区设计pyaudio 的read()和write()都是阻塞调用。read()会一直等到采集够指定帧数的音频数据才返回write()会一直等到扬声器消费完数据才返回。如果直接在 asyncio 事件循环里调用这两个函数整个事件循环会被卡住WebSocket 的收发全部停摆。正确做法是用loop.run_in_executor(None, stream.read, CHUNK)把阻塞调用扔到线程池里执行让事件循环保持空闲专心处理 WebSocket 帧。队列设计上我用两个asyncio.Queue。接收队列play_q是播放侧的缓冲WebSocket 收到帧后先进队列播放任务从队列取帧写到扬声器。这个队列的长度上限就是“允许的抖动缓冲深度”。我一般设置上限 30 帧对应 600ms。超过这个值说明网络抖动已经超出可接受范围与其让声音越来越延迟不如丢旧帧追赶上实时节奏。4.2 采集端代码麦克风数据进入网络# client.py 采集部分 import asyncio import json import struct import pyaudio import websockets RATE 16000 CHANNELS 1 FORMAT pyaudio.paInt16 FRAME_MS 20 CHUNK int(RATE * FRAME_MS / 1000) # 320个采样点640字节 MAX_PLAY_BUF 30 def pack_audio(seq: int, pcm: bytes) - bytes: return struct.pack(!II, seq, RATE) pcm async def capture_task(stream_in, ws): loop asyncio.get_event_loop() seq 0 while True: # 阻塞采集放到线程池避免卡住事件循环 pcm await loop.run_in_executor(None, stream_in.read, CHUNK) await ws.send(pack_audio(seq, pcm)) seq (seq 1) 0xFFFFFFFF代码逻辑不复杂stream_in.read(CHUNK)会阻塞大约 20ms然后返回 640 字节的 PCM 数据。这一帧被pack_audio加上 8 字节帧头后直接ws.send()发到服务器。注意seq到0xFFFFFFFF时归零这是为了避免序号无限增长溢出。采集任务本身没有sleep因为read()的阻塞时间就是天然的帧节奏。CHUNK的计算方式是int(RATE * FRAME_MS / 1000)这里 RATE 是 16000FRAME_MS 是 20结果正好 320。如果你改采样率比如降到 8000CHUNK 自动变成 160不需要改其他逻辑。但要特别注意stream_in.read(CHUNK)的参数是“采样点数”而不是“字节数”320 个采样点才是 640 字节你传给 pyaudio 的frames_per_buffer也要和这个值保持一致否则read()返回的帧大小会和你预期的不一致。4.3 播放端代码从网络帧到扬声器# client.py 播放部分 import queue from contextlib import suppress async def receive_task(ws, play_q): async for frame in ws: if isinstance(frame, str): continue # 队列积压过多时丢旧帧防止延迟持续增大 if play_q.qsize() MAX_PLAY_BUF: with suppress(queue.Empty): while not play_q.empty(): play_q.get_nowait() await play_q.put(frame) async def playback_task(stream_out, play_q): loop asyncio.get_event_loop() while True: frame await play_q.get() try: seq, rate, pcm struct.unpack(!II, frame[:8])[0], \ struct.unpack(!II, frame[:8])[1], frame[8:] except struct.error: continue if rate ! RATE: print(f[warn] 对端采样率 {rate} 与本机 {RATE} 不一致) await loop.run_in_executor(None, stream_out.write, pcm)播放端两个任务协同工作。receive_task只负责把网络帧放进队列不做任何声卡操作playback_task从队列取帧解析头部后写到扬声器。play_q.size() MAX_PLAY_BUF时的丢帧逻辑是整个链路抗抖动的核心如果网络正常队列深度一般在 2-3 帧以内一旦队列稳定超过 30 帧说明播放跟不上采集节奏。此时把整个队列清空让后续的帧直接进入当前播放位置就像对讲机被按下了“追音”按钮。stream_out.write(pcm)同样走线程池执行避免事件循环卡顿。rate ! RATE时我只做了告警没做自动重采样因为真实场景里同一套系统内的采样率应该在信令阶段就对齐了。如果真的出现对端 8000 自己 16000 的情况最稳妥的方式是在收到第一次帧时用p.open()重建播放流这比在应用层做采样率转换简单得多。4.4 客户端主流程启动采集、播放与网络连接async def main(urlws://localhost:8765): p pyaudio.PyAudio() # 输入输出流必须用相同的参数否则采集和播放速度不一致 stream_in p.open(formatFORMAT, channelsCHANNELS, rateRATE, inputTrue, frames_per_bufferCHUNK) stream_out p.open(formatFORMAT, channelsCHANNELS, rateRATE, outputTrue, frames_per_bufferCHUNK) play_q asyncio.Queue() async with websockets.connect(url, max_sizeNone) as ws: # 先发信令加入房间再开始音频收发 await ws.send(json.dumps({ type: join, room: default, name: py_listener, rate: RATE })) tasks [ asyncio.create_task(capture_task(stream_in, ws)), asyncio.create_task(receive_task(ws, play_q)), asyncio.create_task(playback_task(stream_out, play_q)), ] try: await asyncio.gather(*tasks) finally: for t in tasks: t.cancel() stream_in.stop_stream() stream_in.close() stream_out.stop_stream() stream_out.close() p.terminate()这里的顺序有一个容易忽略的点必须先发join信令再启动capture_task。如果反过来采集任务立刻开始采集并按 20ms 一帧往服务器发而服务器端还没把这个客户端加入房间这些帧就会被handler里的if room is None: continue直接丢弃。这属于“数据先于信令到达”的经典问题很多第一次写实时通信的人都会在这里遇到 0-2 秒的语音丢失。p.open()的参数中frames_per_bufferCHUNK告诉 PortAudio 每次回调或读写操作使用多少采样点。我把它设为和采集帧大小一致的 320这样read()每次返回一个完整帧不需要在应用层拼接。如果你的声卡驱动要求的缓冲区大小不是这个值可以适当加大到 640、1280但不要小于 320否则会有底层缓冲不足带来的毛刺声。运行客户端时如果本机同时又要跑服务器注意端口和地址。服务器监听0.0.0.0:8765客户端连ws://localhost:8765。拿到代码后先在单机上跑两个客户端实例互相说话做闭环验证再部署到两台机器上测公网延迟这是我每次搭建这套系统的固定步骤。5. 联调与排查WebSocket 语音传输的高频踩坑清单代码能跑通只是第一步真正的挑战在联调阶段。下面这些坑我基本都在实际项目里踩过一轮每个都按“现象 → 原因 → 解决”的描述按出现频率从高到低排列。你在自己的环境里遇到类似问题可以直接按这几条逐一排查能少走很多弯路。5.1 播放端全是“嗞嗞”电流声或噪声现象本地采集后用电脑扬声器直接回放是正常的但通过 WebSocket 传到另一台机器播放出来的声音像电流声混合着人声完全听不清。原因这个问题的九成原因是采样率或声道数不匹配。发送端用 16000Hz 采集接收端用 44100Hz 的播放流去 write一帧 640 字节的数据会被当作 1600 个采样点播放速度直接变了声音自然就是刺耳的噪声。另一个隐蔽原因是 pyaudio 的write()参数要求的是采样点数而不是字节数如果stream_out.write(pcm)写入了未经头部分离的完整帧含 8 字节帧头音频数据从第 9 字节开始全部错位同样是噪声。解决接收端在播放前解出头部里的rate与本地播放流参数对比不一致时打印告警或重建播放流。代码里我已经严格只把pcm传给stream_out.write可以避免第二种情况。至于采样率不一致最简单的解决方式是让所有客户端在join信令里统一声明rate16000服务器端把rate存进房间信息新客户端进来时如果发现已有房间成员直接以房间内已存在的rate为准。5.2 延迟持续增大像对讲机拖尾现象刚连上时延迟正常双方能“秒回”跑了一两分钟后延迟越来越明显最后变成一人说完话另一人隔了 2 秒才听到。原因这是播放队列积压导致的。采集端按 20ms 一帧稳定发数据网络传输和播放端稍微慢一点队列里就会多积压几帧。如果队列无限增长延迟就会线性上升。网络抖动哪怕只是偶尔一次 200ms 的波动积压的帧也会被播放任务按正常节奏慢慢消费延迟一旦上去就下不来。解决客户端代码里的MAX_PLAY_BUF 30和丢旧帧逻辑就是干这个用的。队列超过 30 帧就把整个队列清空让后续帧直接进入当前播放位置。曾经我为了求稳把这个上限设成 300结果延迟到了 6 秒才触发清理体验完全不可用。现在的经验值是对讲场景 30-50 帧、会议场景可以放到 80-100 帧但不要超过 200 帧。另外在receive_task里可以顺带统计丢帧率打日志出来方便定位是网络问题还是播放设备问题。5.3 pyaudio 打不开设备OSError: [Errno -9999]现象代码运行到p.open()时直接抛异常提示[Errno -9999] Invalid parameter或者Unanticipated host error程序直接崩溃。原因pyaudio 的底层 PortAudio 引擎不能保证你指定的参数组合一定被声音设备支持。最常见的是frames_per_buffer设置太小声卡驱动不支持这么小的缓冲区其次是输入输出流同时打开时默认设备的独占模式被其他程序比如浏览器标签页占用了。解决先把frames_per_buffer往上调比如从 320 调到 640 或 1280看是否还报错。还不行就需要显式指定设备编号先枚举设备再从中选取输入输出设备索引p pyaudio.PyAudio() for i in range(p.get_device_count()): info p.get_device_info_by_index(i) print(i, info[name], info[maxInputChannels], info[maxOutputChannels])找到麦克风对应的索引后在p.open()里加input_device_indexidx或output_device_indexidx。设备被占用的问题则只能靠关闭其他占用程序或者换一台设备验证。这里有个血泪经验Linux 下如果集成声卡和 USB 声卡同时存在默认设备往往不是你要用的那个一定要显式指定索引。5.4 一个人说话另一台机器收不到但服务器日志正常现象服务器正常打印加入房间的消息ROOMS里能看到两个客户端发送端的ws.send()没报错但接收端就是听不到声音。用串口调试工具直接连服务器发文本帧也能正常收到响应。原因大多数情况下是客户端加入了不同的房间。两个客户端如果join信令里的room字段一个是default一个是room1服务器端的ROOMS就是两个独立字典转发逻辑自然不会把语音发到对方。另一个可能是客户端没有等joined响应就开始发音频服务器端room is None把帧全部丢弃了。解决在服务器端给join信令加一段回显响应内容包含房间 ID 和当前房间人数比如{type: joined, room: default, count: 2}。客户端启动时把这个响应打出来两个以上的机器一对比就知道是否真的在同一房间。同时给capture_task加一个延迟入口确保join信令发出并得到确认后才开始采集这个 200ms 的等待能避免开局丢帧。5.5 部署到公网后几分钟就断线内网却一切正常现象本地局域网两台机器跑得稳如老狗一放到云服务器上客户端每隔几分钟就收到ConnectionClosedError日志显示被远端关闭有时是客户端主动断开有时是服务器端收不到任何数据。原因绝大多数公网断线问题不是应用代码的锅而是链路中的代理或负载均衡设备空闲超时。WebSocket 长连接如果没有数据持续流动云厂商的 LB 设备默认 60 秒左右就会判定连接空闲直接把它杀掉。电话语音对讲时双方一直在说话还好一旦有一方沉默超过 60 秒链路就被静默清除了。解决在客户端和服务器端都加上 WebSocket 层的心跳。websockets库默认在连接建立时会交换握手帧但后续不会自动 ping。客户端可以在主循环里起一个心跳任务async def heartbeat_task(ws): while True: await asyncio.sleep(25) try: # ping 是控制帧不影响业务数据 await ws.ping() except Exception: break服务器端同理可以对每个连接做 30 秒一次的 ping 检查。除了心跳服务器部署在 Nginx 后面时还要确保 Nginx 配置了正确的 WebSocket 头转发proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;同时把proxy_read_timeout调到 3600 秒否则即使客户端心跳正常Nginx 这一层也能把连接断掉。这块配置的问题在公网场景一直排首位。6. 进阶用 Opus 把码率压到 24kbps服务端落盘自动录音基础链路跑通之后你大概率会遇到两个新需求带宽不够用以及想留存对话回溯。这两个问题分别可以用 Opus 压缩和服务端落盘解决也是我在这套 WebSocket 语音方案里最常用的两个升级手段。6.1 把 PCM 换成 Opus变长帧的封装要点PCM 256kbps 的码率在局域网不是问题但走公网、走 4G用户会心疼流量。Opus 是专门为实时语音设计的编码器16kbps 到 24kbps 就能达到接近 PSTN 电话的听感。Python 端可以用opuslib这个库安装后编码逻辑很简单import opuslib encoder opuslib.Encoder(fs16000, channels1, applicationopuslib.APPLICATION_VOIP) # 每帧压缩pcm 是 640 字节压缩后可能只有 30-80 字节 opus_data encoder.encode(pcm, 320)这里encode(pcm, 320)中间的 320 表示帧内采样点数必须和采集帧长对齐。Opus 编码输出是变长的所以不能再沿用固定的 8 字节头部 载荷的结构我习惯在头部后先加 2 字节的长度前缀声明 Opus 载荷的长度否则接收端不知道一帧到哪里结束。解码则反过来读完长度后取对应字节数丢给opuslib.Decoder。改造成本很小但码率直接从 256kbps 降到 24kbps 左右公网传输的流畅度完全不一样。6.2 服务端同时落盘录音方便回放和定位问题服务器既然要中转每一帧音频顺手落盘是最划算的。在ROOMS里给每个客户端挂一个文件对象join时创建wav文件收到二进制音频帧时把 PCM 载荷写入文件连接关闭时关闭文件并写入 WAV 文件头。WAV 格式对 PCM 来说是壳子用 Pythonwave模块处理最省事。import wave # join 时创建 wav_file wave.open(frecv_{room_id}_{name}.wav, wb) wav_file.setnchannels(1) wav_file.setsampwidth(2) # 16bit 2字节 wav_file.setframerate(RATE) # 收到音频帧时写入 wav_file.writeframes(pcm)这块有一个细节值得注意PCM 帧写入 WAV 文件不需要任何时间戳对齐因为 WAV 文件头里已经记录了采样率和声道数播放器会根据这些参数自动按时间轴播放。所以落盘代码就是在转发的同时多做一次writeframes对转发性能的影响几乎可以忽略。6.3 音量归一化与静音检测的实用技巧最后说一个几乎每个语音项目都会遇到的小问题不同人说话的音量差太多了。有的客户端声音小到服务器录下来几乎看不到波形有的把扬声器震得嗡嗡响。我在客户端播放前加了一个线性归一化取一帧 PCM 的绝对值最大值按比例缩放让峰值稳定在 80% 左右。这个处理对 16bit PCM 来讲就是纯整数运算CPU 开销可忽略效果却立竿见影。静音检测我一般放到服务器端做用webrtcvad判断每帧是否是语音是语音才转发。这样既节省带宽又能避免把客户端敲键盘、呼吸声传播给所有人。VAD 边界要设得宽松一点默认 aggressiveness 设 2太激进会把一句话的开头吞掉。这套 WebSocket 方案我跑了快两年从最初的裸 PCM 对讲到后来加 Opus、加录音、加 VAD代码的骨架始终没变20ms 一帧、帧头带序号和采样率、服务器只做哑中转、客户端队列缓冲丢旧帧。每加一个新功能都是在这些基础约定上叠加没有一次需要推翻重写。现在遇到新语音项目我的第一件事永远是先把帧格式和队列深度定下来这两个参数定了后面基本就是填业务了。希望这一篇能帮你把这个方向的路走顺。本文还有配套的精品资源点击获取