跳到主要内容

基于 MediaSource(MSE)+ WebSocket 的实时音频播放(片段缓冲 + 顺序追加)

本文基于 react/react-template-demo/src/examples/realtime-mediasource.tsx 的实现,讲清楚:浏览器如何在“没有完整文件”的情况下,一边收音频字节流、一边播放,以及背后的 API 设计思路。面试重点放在“为什么这么做 / 关键约束 / 容易踩坑”。


0. 面试速答(30 秒 TL;DR)

  • MediaSource(MSE)<audio>src 变成一个“可增量写入的虚拟媒体文件”。
  • sourceopenaddSourceBuffer(mimeCodec),用 SourceBuffer.appendBuffer() 持续追加音频片段
  • WebSocket 持续下发二进制片段;用 sequenceNumber + Map抖动/重排缓冲,按序追加到 SourceBuffer。
  • SourceBuffer背压updating=true 时不能 appendBuffer;用 updateend 事件驱动“继续喂数据”。
  • 关键风险:编解码/封装必须匹配mimeCodec + 片段格式)、缓冲区会满(需要 remove())、以及 WebSocket binaryType

1. 心智模型:把 <audio> 当成“边写边读”的播放器

传统 <audio src="xxx.mp3"> 依赖一个“完整可定位的 URL”。而实时场景里,你拿到的是不断到来的分片字节,这时 MSE 的定位是:

  • MediaSource:媒体数据的“容器/入口”,浏览器会从它读数据。
  • SourceBuffer:你往里“喂”媒体片段;浏览器负责解复用/解码/播放。
  • 你要做的事本质是:网络流 →(排队/重排)→ appendBuffer → 播放

2. 代码在做什么:逐段对齐 realtime-mediasource.tsx

2.1 初始化:把 MediaSource 挂到 <audio>

核心是两步:

  1. 创建 MediaSource
  2. 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) 创建 SourceBuffer
  • sourceBuffer.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', ...):可开始添加 buffer
  • MediaSource.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:浏览器怎么做“实时音频播放”?

答题模板:

  1. <audio> 只能播放 URL 指向的资源;要“边到边播”,用 MediaSource(MSE)src 变成可追加的媒体源
  2. sourceopen 时创建 SourceBuffer(mimeCodec),网络侧持续拿到媒体片段后 appendBuffer
  3. appendBuffer 有背压,用 updating + updateend 做队列化,避免状态错误
  4. 处理乱序/抖动:做片段缓存(可用序号)
  5. 缓冲会越积越多:需要 remove() 控制内存,并把播放点维持在“直播边缘”

Q2:appendBuffer 常见失败原因有哪些?

  • updating=true 还在写,又 append:InvalidStateError
  • mimeCodec 不支持,或片段不是该格式的合法 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()