基于 MediaSource(MSE)+ WebSocket 的实时音频播放(片段缓冲 + 顺序追加)
本文基于 react/react-template-demo/src/examples/realtime-mediasource.tsx 的实现,讲清楚:浏览器如何在“没有完整文件”的情况下,一边收音频字节流、一边播放,以及背后的 API 设计思路。面试重点放在“为什么这么做 / 关键约束 / 容易踩坑”。
0. 面试速答(30 秒 TL;DR)
- 用 MediaSource(MSE) 把
<audio>的src变成一个“可增量写入的虚拟媒体文件”。 - 在
sourceopen时addSourceBuffer(mimeCodec),用SourceBuffer.appendBuffer()持续追加音频片段。 - WebSocket 持续下发二进制片段;用
sequenceNumber + Map做抖动/重排缓冲,按序追加到 SourceBuffer。 SourceBuffer有背压:updating=true时不能appendBuffer;用updateend事件驱动“继续喂数据”。- 关键风险:编解码/封装必须匹配(
mimeCodec+ 片段格式)、缓冲区会满(需要remove())、以及 WebSocketbinaryType。
1. 心智模型:把 <audio> 当成“边写边读”的播放器
传统 <audio src="xxx.mp3"> 依赖一个“完整可定位的 URL”。而实时场景里,你拿到的是不断到来的分片字节,这时 MSE 的定位是:
MediaSource:媒体数据的“容器/入口”,浏览器会从它读数据。SourceBuffer:你往里“喂”媒体片段;浏览器负责解复用/解码/播放。- 你要做的事本质是:网络流 →(排队/重排)→ appendBuffer → 播放。
2. 代码在做什么:逐段对齐 realtime-mediasource.tsx
2.1 初始化:把 MediaSource 挂到 <audio>
核心是两步:
- 创建
MediaSource URL.createObjectURL(mediaSource)生成一个临时 URL,赋给audio.src
这相当于告诉浏览器:音频数据不从网络 URL 来,而是我用 JS 往 MediaSource 里喂。
2.2 sourceopen:创建 SourceBuffer(编解码必须对上)
mediaSource.addEventListener('sourceopen', ...) 里做:
const mimeCodec = 'audio/mpeg'(示例)MediaSource.isTypeSupported(mimeCodec)判断是否支持mediaSource.addSourceBuffer(mimeCodec)创建SourceBuffersourceBuffer.mode = 'segments':按片段自带时间戳拼接时间线(如果你的片段不带可用时间戳,工程上常改用'sequence'按追加顺序连续播放)
面试常追问点:mimeCodec 不只是“mp3/mp4”,更是“容器 + 编码”组合。工程上更常见的是:
- AAC in fMP4:
audio/mp4; codecs="mp4a.40.2" - Opus in WebM:
audio/webm; codecs="opus"
很多格式要求你先追加 Initialization Segment(初始化段),再追加 Media Segment(媒体段);否则会 appendBuffer 失败或播放不了。示例代码里假设服务端发送的片段本身就符合 audio/mpeg 的分段要求。
2.3 WebSocket:把网络流变成“可追加片段”
WebSocket 的职责是低开销持续推送二进制片段。示例里约定了消息格式:
ArrayBuffer前 4 字节:sequenceNumber(小端uint32)- 剩余字节:
audioFragment
这样做的思路是:在应用层显式携带序号,便于:
- 发现丢片/重连重复(去重、补洞)
- 做“抖动缓冲”(允许未来换成多路/多连接/甚至 UDP 类传输)
2.4 fragmentBuffer + nextSequenceNumber:抖动/重排缓冲
fragmentBuffer: Map<number, ArrayBuffer>
nextSequenceNumber: number
把片段先放进 Map,再由 processBuffer() 按序取出 连续的 片段喂给 SourceBuffer:
- 如果
0,1,2都在:就一直 append - 如果缺了
2:就停住,等2来了再继续
这就是典型的“播放侧抖动缓冲区(Jitter Buffer)”思路。
2.5 processBuffer + updateend:处理 MSE 的背压
SourceBuffer.appendBuffer() 是异步的:调用后 sourceBuffer.updating=true,直到触发 updateend 才变回 false。
所以正确做法是:
- 追加前先判断
!sourceBuffer.updating - 在
updateend事件里继续processBuffer(),形成“只要可写就尽量写”的循环
这也是面试里最关键的“机制点”:背压控制(否则会 InvalidStateError)。
3. API 思路速查(面试常问)
3.1 MediaSource / SourceBuffer
new MediaSource():创建媒体数据入口mediaSource.addEventListener('sourceopen', ...):可开始添加 bufferMediaSource.isTypeSupported(mimeCodec):格式能力判断(必须做)mediaSource.addSourceBuffer(mimeCodec):创建可追加的缓冲区sourceBuffer.appendBuffer(bytes):追加媒体片段(触发更新流程)sourceBuffer.updating:背压信号(true 时禁止 append)sourceBuffer.addEventListener('updateend', ...):本次追加完成sourceBuffer.mode:'segments'依赖片段时间戳;'sequence'按追加顺序连续拼接sourceBuffer.remove(start, end):删除旧缓冲(避免内存/Quota 爆)mediaSource.endOfStream():结束流(收尾/释放)
3.2 WebSocket(实时推送)
new WebSocket(url):建立长连接ws.binaryType = 'arraybuffer':让event.data直接是ArrayBuffer(二进制场景强烈建议)ws.onmessage:收片段ws.onopen/onclose/onerror:连接状态机ws.close():页面卸载/组件卸载时释放
4. 典型面试题 & 标准答法
Q1:浏览器怎么做“实时音频播放”?
答题模板:
<audio>只能播放 URL 指向的资源;要“边到边播”,用 MediaSource(MSE) 把src变成可追加的媒体源sourceopen时创建SourceBuffer(mimeCodec),网络侧持续拿到媒体片段后appendBufferappendBuffer有背压,用updating + updateend做队列化,避免状态错误- 处理乱序/抖动:做片段缓存(可用序号)
- 缓冲会越积越多:需要
remove()控制内存,并把播放点维持在“直播边缘”
Q2:appendBuffer 常见失败原因有哪些?
updating=true还在写,又 append:InvalidStateErrormimeCodec不支持,或片段不是该格式的合法 segment:appendBuffer抛错/音频无法解码- 缓冲区达到上限:
QuotaExceededError(需要remove()或降码率/降缓存) MediaSource已 close/end:状态不对
Q3:为什么要 sequenceNumber + Map?WebSocket 不是有序的吗?
- 单连接 WebSocket 基于 TCP,消息顺序通常是有保障的;但工程上仍常携带序号,用于:
- 丢片检测(服务端跳片/重连续传/去重)
- 未来换成“多路/多连接/不同传输”仍可复用协议
- 播放侧做“等洞补齐”的策略更直观
5. 易错点 / 坑(结合示例代码)
- WebSocket 二进制类型:如果不设
ws.binaryType = 'arraybuffer',很多浏览器默认给Blob,示例里的instanceof ArrayBuffer可能一直不成立。 - 片段必须是“可被 MSE 识别的媒体段”:尤其是 fMP4/WebM,通常要先送初始化段(init segment)。
- URL 对象要回收:
URL.createObjectURL()生成的临时 URL,组件卸载时建议URL.revokeObjectURL()(避免泄漏)。 - SourceBuffer 会满:要根据
sourceBuffer.buffered定期remove()清理历史数据,否则迟早QuotaExceededError。 - fragmentBuffer 可能无限长:如果某个
sequenceNumber永远缺失,后续片段都会堆着不播,需要“丢弃策略/超时策略”。 - 自动播放策略:
audio.play()往往要求用户手势触发;实时应用要设计交互(点击开始/静音自动播等)。
6. 速记要点(背诵版)
- 实时播放 =
MediaSource把<audio>变成“可写入源” - 追加数据 =
SourceBuffer.appendBuffer();背压 =updating + updateend - 乱序/抖动 = 片段缓存(Map/队列)+ 序号
- 关键前提 =
mimeCodec支持 + 片段格式正确(必要时先 init segment) - 稳定性 = 缓冲上限(
remove())、缺片策略、清理连接与endOfStream()