第六章:Media Source Extensions 音频应用 —— hls.js 背后的发动机

你有没有好奇过,hls.js 和 dash.js 是怎么在浏览器里播放 HLS/DASH 流的?<audio> 标签明明不支持 M3U8,它们是怎么做到的?答案就是 Media Source Extensions(MSE)。这是一套允许 JavaScript 直接向媒体元素"喂"数据的底层 API,也是 Web 流媒体技术栈中最接近"发动机"的一层。


一、MSE 是什么?解决了什么问题?

在 MSE 出现之前(2013 年以前),<audio><video> 标签只能接受一个静态的 src URL,浏览器负责全部的网络请求和缓冲管理,开发者对这个过程毫无控制权。这意味着:

  • 无法实现自适应码率(网络变差时自动切换低码率)
  • 无法播放分片流媒体(HLS/DASH 的 M3U8/MPD 格式)
  • 无法实现精确的缓冲控制(预加载策略完全由浏览器决定)
  • 无法拼接多个音频片段无缝播放

MSE 的出现彻底改变了这一局面。它在 <audio> / <video> 和网络层之间插入了一个 JavaScript 可控的缓冲层,让开发者可以通过代码决定"往媒体元素里喂什么数据、什么时候喂、喂多少"。

传统模式:
  <audio src="..."> ──→ 浏览器自动下载 ──→ 解码播放

MSE 模式:
  <audio src="blob:..."> ──→ MediaSource ──→ SourceBuffer
                                                    ↑
                                           JavaScript 控制
                                           (下载、分片、追加)

二、MSE 核心对象模型

MSE 由三个核心对象构成,理解它们的关系是掌握 MSE 的关键。

流程图画布 · 115%
Mermaid 流程图加载中...

MediaSource 是整个 MSE 的入口对象,代表一个虚拟的媒体源。它管理着整体的媒体时间线,并持有一组 SourceBuffer

SourceBuffer 代表一条媒体轨道的缓冲区(音频轨或视频轨)。你通过 appendBuffer() 向其中追加编码后的媒体数据,浏览器负责解码和渲染。

ObjectURL 是连接 MediaSource&lt;audio&gt; 标签的桥梁。通过 URL.createObjectURL(mediaSource) 生成一个 blob: 协议的 URL,赋给 audio.src,媒体元素就与 MediaSource 绑定了。


三、MSE 基础用法:从零手写一个片段播放器

我们先用最简单的例子感受 MSE 的工作方式——把一个 fMP4 文件分两次"喂"给 &lt;audio&gt; 播放。

第一步:建立 MediaSource 与 audio 的连接

const audio = document.getElementById('audio');
const mediaSource = new MediaSource();
 
// 用 Object URL 把 MediaSource 绑定到 audio 元素
audio.src = URL.createObjectURL(mediaSource);
 
// sourceopen 事件:MediaSource 已就绪,可以添加 SourceBuffer
mediaSource.addEventListener('sourceopen', onSourceOpen);
 
function onSourceOpen() {
  console.log('MediaSource 已打开,readyState:', mediaSource.readyState);
  // 'open' 状态才能操作
  URL.revokeObjectURL(audio.src); // 释放 Object URL,节省内存
  initSourceBuffer();
}

重要:必须在 sourceopen 事件触发后才能操作 MediaSource。在此之前,readyState"closed",任何操作都会抛出异常。

第二步:创建 SourceBuffer

let sourceBuffer;
 
function initSourceBuffer() {
  // MIME 类型必须精确,包含 codecs 参数
  const mimeType = 'audio/mp4; codecs="mp4a.40.2"'; // AAC in MP4
 
  if (!MediaSource.isTypeSupported(mimeType)) {
    console.error('不支持的 MIME 类型:', mimeType);
    return;
  }
 
  sourceBuffer = mediaSource.addSourceBuffer(mimeType);
 
  // updateend 事件:上一次 appendBuffer 操作完成
  sourceBuffer.addEventListener('updateend', onUpdateEnd);
  sourceBuffer.addEventListener('error', onSourceBufferError);
 
  // 开始加载数据
  fetchAndAppend();
}

第三步:获取数据并追加到 SourceBuffer

const SEGMENTS = [
  '/audio/init.mp4',   // 初始化片段(包含 codec 信息,必须最先加载)
  '/audio/seg001.m4s', // 第 1 个媒体片段
  '/audio/seg002.m4s', // 第 2 个媒体片段
  '/audio/seg003.m4s', // 第 3 个媒体片段
];
let segmentIndex = 0;
 
async function fetchAndAppend() {
  if (segmentIndex >= SEGMENTS.length) {
    // 所有片段加载完毕,通知 MediaSource 结束
    if (!sourceBuffer.updating) {
      mediaSource.endOfStream();
    }
    return;
  }
 
  const url = SEGMENTS[segmentIndex++];
 
  try {
    const response = await fetch(url);
    const arrayBuffer = await response.arrayBuffer();
 
    // 等待 SourceBuffer 不在更新状态时再追加
    if (sourceBuffer.updating) {
      // 如果正在更新,等待 updateend 再追加
      sourceBuffer.addEventListener('updateend', () => {
        sourceBuffer.appendBuffer(arrayBuffer);
      }, { once: true });
    } else {
      sourceBuffer.appendBuffer(arrayBuffer);
    }
  } catch (err) {
    console.error('片段加载失败:', err);
  }
}
 
function onUpdateEnd() {
  console.log(
    'SourceBuffer 更新完成,已缓冲:',
    getBufferedRanges(sourceBuffer)
  );
  // 继续加载下一个片段
  fetchAndAppend();
}
 
function getBufferedRanges(sb) {
  const ranges = [];
  for (let i = 0; i < sb.buffered.length; i++) {
    ranges.push(`s ~ s`);
  }
  return ranges.join(', ');
}

四、MSE 状态机:readyState 与关键事件

MediaSource 有三个状态,理解状态转换是避免操作报错的基础。

流程图画布 · 115%
Mermaid 流程图加载中...
状态含义可执行操作
closed未与媒体元素关联
open已关联,可接收数据addSourceBufferappendBufferendOfStream
ended数据流已结束可再次 appendBuffer 恢复到 open

MediaSource 关键事件

mediaSource.addEventListener('sourceopen',  () => { /* 可以开始操作 */ });
mediaSource.addEventListener('sourceended', () => { /* endOfStream() 后触发 */ });
mediaSource.addEventListener('sourceclose', () => { /* 与媒体元素断开后触发 */ });

SourceBuffer 关键事件

sourceBuffer.addEventListener('updatestart', () => {
  // appendBuffer / remove 操作开始
});
 
sourceBuffer.addEventListener('update', () => {
  // 操作进行中
});
 
sourceBuffer.addEventListener('updateend', () => {
  // 操作完成,可以进行下一次 appendBuffer
  // 这是最重要的事件——必须等它触发后才能再次 appendBuffer
});
 
sourceBuffer.addEventListener('error', () => {
  console.error('SourceBuffer 错误');
});
 
sourceBuffer.addEventListener('abort', () => {
  console.log('操作被中止');
});

五、SourceBuffer 深度操作

5.1 追加数据:appendBuffer()

appendBuffer() 是 MSE 最核心的方法,接收 ArrayBufferArrayBufferView

// 不能在 updating === true 时调用,否则抛出 InvalidStateError
if (!sourceBuffer.updating) {
  sourceBuffer.appendBuffer(arrayBuffer);
}

最常见的错误:在 updatingtrue 时再次调用 appendBuffer(),会抛出 InvalidStateError。必须等 updateend 事件触发后才能继续追加。

5.2 删除缓冲数据:remove()

在长时间播放场景(如直播),缓冲区会不断增长,最终触发 QuotaExceededError。需要定期清理已播放的缓冲数据:

function evictOldBuffer(sourceBuffer, currentTime) {
  // 删除当前播放位置之前 30 秒的缓冲数据
  const evictBefore = Math.max(0, currentTime - 30);
 
  if (sourceBuffer.buffered.length > 0) {
    const bufferedStart = sourceBuffer.buffered.start(0);
    if (evictBefore > bufferedStart) {
      sourceBuffer.remove(bufferedStart, evictBefore);
      // remove() 也是异步操作,同样会触发 updateend
    }
  }
}
 
// 每 10 秒清理一次
setInterval(() => {
  if (!sourceBuffer.updating) {
    evictOldBuffer(sourceBuffer, audio.currentTime);
  }
}, 10000);

5.3 时间戳偏移:timestampOffset

当你拼接多个音频片段时,每个片段内部的时间戳可能都从 0 开始。timestampOffset 用于将片段的时间戳偏移到正确的位置:

// 第一个片段:时长 10 秒,时间戳 0~10s
sourceBuffer.timestampOffset = 0;
sourceBuffer.appendBuffer(segment1);
 
// 第二个片段:内部时间戳也从 0 开始,但需要接在 10s 之后
sourceBuffer.timestampOffset = 10;
sourceBuffer.appendBuffer(segment2);
// 浏览器会把 segment2 的时间戳偏移 10s,放在 10~20s 的位置

5.4 追加模式:appendWindowStart / appendWindowEnd

设置一个时间窗口,只有在这个窗口内的数据才会被追加,窗口外的数据会被丢弃:

// 只接受 5s ~ 15s 之间的数据
sourceBuffer.appendWindowStart = 5;
sourceBuffer.appendWindowEnd = 15;
sourceBuffer.appendBuffer(data); // 5s 之前和 15s 之后的帧会被丢弃

这在实现精确剪辑、广告插入等场景时非常有用。


六、实战:用 MSE 实现自适应码率音频播放器

这是本章的核心实战——从零实现一个简化版的 ABR(自适应码率)音频播放器,模拟 hls.js 的核心逻辑。

整体架构设计

┌─────────────────────────────────────────────────────┐
│                   MSE ABR Player                    │
│                                                     │
│  ┌──────────┐   ┌──────────┐   ┌─────────────────┐ │
│  │ Manifest │   │ Segment  │   │   ABR Controller │ │
│  │  Parser  │──→│ Fetcher  │──→│  (码率决策引擎)  │ │
│  └──────────┘   └──────────┘   └────────┬────────┘ │
│                                          │          │
│  ┌──────────────────────────────────────▼────────┐ │
│  │              Buffer Manager                   │ │
│  │  (MediaSource + SourceBuffer 管理)             │ │
│  └──────────────────────────────────────────────┘ │
│                        │                           │
│              ┌─────────▼──────────┐                │
│              │   HTMLAudioElement │                │
│              └────────────────────┘                │
└─────────────────────────────────────────────────────┘

完整实现代码

class MSEAudioPlayer {
  constructor(audioEl) {
    this.audio = audioEl;
    this.mediaSource = null;
    this.sourceBuffer = null;
    this.segments = [];         // 待加载的片段队列
    this.isAppending = false;   // 是否正在追加数据
    this.levels = [];           // 可用码率列表
    this.currentLevel = 0;      // 当前码率级别
    this.bandwidthEstimate = 1e6; // 带宽估算(bps),初始 1Mbps
  }
 
  // ── 初始化 ──────────────────────────────────────────
  async load(manifestUrl) {
    // 1. 解析 manifest(这里用简化的 JSON 格式模拟 M3U8)
    const manifest = await fetch(manifestUrl).then(r => r.json());
    this.levels = manifest.levels; // [{ bitrate, segments: [...urls] }]
    this.currentLevel = this._selectInitialLevel();
 
    // 2. 建立 MSE 管道
    this._setupMediaSource();
  }
 
  _setupMediaSource() {
    this.mediaSource = new MediaSource();
    this.audio.src = URL.createObjectURL(this.mediaSource);
 
    this.mediaSource.addEventListener('sourceopen', () => {
      URL.revokeObjectURL(this.audio.src);
      this._createSourceBuffer();
    });
  }
 
  _createSourceBuffer() {
    const mimeType = 'audio/mp4; codecs="mp4a.40.2"';
 
    if (!MediaSource.isTypeSupported(mimeType)) {
      throw new Error(`不支持的 MIME 类型: `);
    }
 
    this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
    this.sourceBuffer.mode = 'segments'; // 片段模式
 
    this.sourceBuffer.addEventListener('updateend', () => {
      this.isAppending = false;
      this._processQueue(); // 追加完成后继续处理队列
    });
 
    // 开始加载初始化片段
    this._loadInitSegment();
  }
 
  // ── 加载逻辑 ─────────────────────────────────────────
  async _loadInitSegment() {
    const level = this.levels[this.currentLevel];
    const initUrl = level.initUrl;
 
    const { data, duration } = await this._fetchWithTiming(initUrl);
    this._enqueue(data);
    this._processQueue();
 
    // 初始化片段加载完成后,开始加载媒体片段
    this._startSegmentLoading();
  }
 
  async _startSegmentLoading() {
    const level = this.levels[this.currentLevel];
 
    for (let i = 0; i < level.segments.length; i++) {
      // ABR 决策:每加载一个片段前重新评估码率
      const newLevel = this._selectLevel();
      if (newLevel !== this.currentLevel) {
        console.log(`码率切换: L → L`);
        this.currentLevel = newLevel;
        // 注意:切换码率需要重新加载 init segment(此处简化)
      }
 
      const segUrl = this.levels[this.currentLevel].segments[i];
      const { data, duration, bytes } = await this._fetchWithTiming(segUrl);
 
      // 更新带宽估算(指数移动平均)
      const bps = (bytes * 8) / duration;
      this.bandwidthEstimate = 0.7 * this.bandwidthEstimate + 0.3 * bps;
 
      this._enqueue(data);
      this._processQueue();
 
      // 控制缓冲量:缓冲超过 30s 则等待
      await this._waitForBufferDrain(30);
    }
 
    // 所有片段加载完毕
    this._endStream();
  }
 
  // ── 带宽测量的 fetch ──────────────────────────────────
  async _fetchWithTiming(url) {
    const startTime = performance.now();
    const response = await fetch(url);
    const data = await response.arrayBuffer();
    const duration = (performance.now() - startTime) / 1000; // 秒
    return { data, duration, bytes: data.byteLength };
  }
 
  // ── 缓冲队列管理 ──────────────────────────────────────
  _enqueue(arrayBuffer) {
    this.segments.push(arrayBuffer);
  }
 
  _processQueue() {
    if (
      this.isAppending ||
      this.segments.length === 0 ||
      this.sourceBuffer.updating
    ) return;
 
    this.isAppending = true;
    const segment = this.segments.shift();
    this.sourceBuffer.appendBuffer(segment);
  }
 
  // ── ABR 码率决策 ──────────────────────────────────────
  _selectInitialLevel() {
    // 初始选最低码率,快速启动
    return 0;
  }
 
  _selectLevel() {
    // 简单 ABR 算法:选择码率不超过可用带宽 80% 的最高质量
    const availableBandwidth = this.bandwidthEstimate * 0.8;
 
    let best = 0;
    for (let i = 0; i < this.levels.length; i++) {
      if (this.levels[i].bitrate <= availableBandwidth) {
        best = i;
      }
    }
    return best;
  }
 
  // ── 缓冲控制 ──────────────────────────────────────────
  _getBufferedAhead() {
    const buf = this.sourceBuffer.buffered;
    if (buf.length === 0) return 0;
 
    const currentTime = this.audio.currentTime;
    for (let i = 0; i < buf.length; i++) {
      if (buf.start(i) <= currentTime && currentTime <= buf.end(i)) {
        return buf.end(i) - currentTime;
      }
    }
    return 0;
  }
 
  _waitForBufferDrain(maxBufferAhead) {
    return new Promise(resolve => {
      const check = () => {
        if (this._getBufferedAhead() < maxBufferAhead) {
          resolve();
        } else {
          setTimeout(check, 500);
        }
      };
      check();
    });
  }
 
  // ── 清理缓冲 ──────────────────────────────────────────
  evictBuffer() {
    if (this.sourceBuffer.updating) return;
    const evictBefore = Math.max(0, this.audio.currentTime - 30);
    if (
      this.sourceBuffer.buffered.length > 0 &&
      evictBefore > this.sourceBuffer.buffered.start(0)
    ) {
      this.sourceBuffer.remove(this.sourceBuffer.buffered.start(0), evictBefore);
    }
  }
 
  // ── 结束流 ────────────────────────────────────────────
  _endStream() {
    if (this.mediaSource.readyState === 'open' && !this.sourceBuffer.updating) {
      this.mediaSource.endOfStream();
      console.log('流播放完毕');
    }
  }
 
  // ── 销毁 ──────────────────────────────────────────────
  destroy() {
    this.audio.src = '';
    this.segments = [];
    this.mediaSource = null;
    this.sourceBuffer = null;
  }
}
 
// 使用
const audio = document.getElementById('audio');
const player = new MSEAudioPlayer(audio);
player.load('/manifest.json');

七、常见错误与排查指南

QuotaExceededError

缓冲区超出浏览器限制时抛出。不同浏览器的限制不同,Chrome 通常允许约 150MB。解决方案是定期调用 sourceBuffer.remove() 清理已播放的数据:

sourceBuffer.addEventListener('error', (e) => {
  if (e.type === 'error') {
    // 检查是否是 QuotaExceededError
    evictBuffer(); // 清理旧数据后重试
  }
});

InvalidStateError

SourceBuffer.updating === true 时调用 appendBuffer()remove() 会触发此错误。始终用队列 + updateend 事件来串行化操作:

// 永远不要这样做:
sourceBuffer.appendBuffer(data1);
sourceBuffer.appendBuffer(data2); // ❌ 抛出 InvalidStateError
 
// 正确做法:用队列
appendQueue.push(data1, data2);
// 在 updateend 回调中逐个出队追加

NotSupportedError(addSourceBuffer 时)

MIME 类型或 codecs 字符串不被支持。必须先用 MediaSource.isTypeSupported() 检测:

const types = [
  'audio/mp4; codecs="mp4a.40.2"',   // AAC in MP4
  'audio/mp4; codecs="opus"',          // Opus in MP4
  'audio/webm; codecs="opus"',         // Opus in WebM
];
 
const supportedType = types.find(t => MediaSource.isTypeSupported(t));
if (!supportedType) {
  throw new Error('当前浏览器不支持任何可用的音频格式');
}

解码错误导致 mediaSource.endOfStream('decode')

追加了损坏或不兼容的数据时,浏览器会触发解码错误。常见原因是:

  • 追加了非 fMP4 格式的数据(如普通 MP4 而非 Fragmented MP4)
  • init segment 和 media segment 的编码参数不匹配
  • 片段之间存在时间戳断层
# 检查文件是否是 fMP4(Fragmented MP4)
mp4info your_file.mp4 | grep "Fragment"
# 或用 ffprobe
ffprobe -v quiet -print_format json -show_format your_file.mp4

八、MSE 与 Web Audio API 的结合

MSE 不仅能控制播放,还能与 Web Audio API 结合,对流媒体音频进行实时处理(如均衡器、音量归一化):

const audioCtx = new AudioContext();
const audio = document.getElementById('audio');
 
// 将 audio 元素作为 Web Audio 的输入源
const source = audioCtx.createMediaElementSource(audio);
 
// 插入增益节点(音量控制)
const gainNode = audioCtx.createGain();
gainNode.gain.value = 1.2; // 提升 20% 音量
 
// 插入均衡器(BiquadFilter)
const eqNode = audioCtx.createBiquadFilter();
eqNode.type = 'highshelf';
eqNode.frequency.value = 8000;
eqNode.gain.value = 3; // 高频提升 3dB
 
// 连接音频处理链
source.connect(eqNode);
eqNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
 
// MSE 正常工作,音频数据经过 Web Audio 处理链后输出

这个组合在第七章(Web Audio API)和第九章(音频效果器)中会进一步深入展开。


九、本章知识图谱

流程图画布 · 115%
Mermaid 流程图加载中...

小结

MSE 是 Web 流媒体技术栈中承上启下的关键一层——它向上支撑了 hls.js、dash.js 这些高层库,向下直接操控浏览器的解码缓冲管道。理解 MSE 的状态机、串行化追加模型和缓冲管理策略,不仅能让你读懂主流播放器库的源码,更能在遇到边界问题时有能力直接在底层修复。

下一章我们将进入 Web Audio API 核心——当你不只满足于"播放音频",而是想对音频信号本身进行处理、分析、合成时,Web Audio API 就是你的武器库。从音频图(Audio Graph)到各种节点,我们将完整拆解这套强大的浏览器原生音频处理框架。