第六章: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 的关键。
MediaSource 是整个 MSE 的入口对象,代表一个虚拟的媒体源。它管理着整体的媒体时间线,并持有一组 SourceBuffer。
SourceBuffer 代表一条媒体轨道的缓冲区(音频轨或视频轨)。你通过 appendBuffer() 向其中追加编码后的媒体数据,浏览器负责解码和渲染。
ObjectURL 是连接 MediaSource 和 <audio> 标签的桥梁。通过 URL.createObjectURL(mediaSource) 生成一个 blob: 协议的 URL,赋给 audio.src,媒体元素就与 MediaSource 绑定了。
三、MSE 基础用法:从零手写一个片段播放器
我们先用最简单的例子感受 MSE 的工作方式——把一个 fMP4 文件分两次"喂"给 <audio> 播放。
第一步:建立 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 有三个状态,理解状态转换是避免操作报错的基础。
| 状态 | 含义 | 可执行操作 |
|---|---|---|
closed | 未与媒体元素关联 | 无 |
open | 已关联,可接收数据 | addSourceBuffer、appendBuffer、endOfStream |
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 最核心的方法,接收 ArrayBuffer 或 ArrayBufferView:
// 不能在 updating === true 时调用,否则抛出 InvalidStateError
if (!sourceBuffer.updating) {
sourceBuffer.appendBuffer(arrayBuffer);
}最常见的错误:在 updating 为 true 时再次调用 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)和第九章(音频效果器)中会进一步深入展开。
九、本章知识图谱
小结
MSE 是 Web 流媒体技术栈中承上启下的关键一层——它向上支撑了 hls.js、dash.js 这些高层库,向下直接操控浏览器的解码缓冲管道。理解 MSE 的状态机、串行化追加模型和缓冲管理策略,不仅能让你读懂主流播放器库的源码,更能在遇到边界问题时有能力直接在底层修复。
下一章我们将进入 Web Audio API 核心——当你不只满足于"播放音频",而是想对音频信号本身进行处理、分析、合成时,Web Audio API 就是你的武器库。从音频图(Audio Graph)到各种节点,我们将完整拆解这套强大的浏览器原生音频处理框架。