简介
Media Source Extensions (MSE) 为 HTML5 <audio>
和 <video>
元素提供了扩展的缓冲和播放控制功能。虽然这些 API 最初是为方便使用基于 Dynamic Adaptive Streaming over HTTP (DASH) 的视频播放器而开发的,但我们将在下文中介绍如何将它们用于音频,尤其是用于无缝播放。
您可能听过歌曲在专辑中流畅衔接的音乐专辑;您现在可能正在听这样的专辑。音乐人创作这些无缝播放体验,既是一种艺术选择,也是黑胶唱片和 CD 的产物,因为音频会被写入为一个连续的串流。遗憾的是,由于 MP3 和 AAC 等现代音频编解码器的工作方式,这种流畅的听觉体验如今往往会被忽略。
我们将在下文中详细说明原因,但现在先来看一个演示。以下是优秀的 Sintel 的前 30 秒,已被分割为 5 个单独的 MP3 文件,并使用 MSE 重新组合。红线表示在创建(编码)每个 MP3 文件期间出现的空白;您会在这些位置听到声音中断。
呸!这确实不太好,我们可以做得更好。只需稍微多做一些工作,使用上面演示中完全相同的 MP3 文件,我们就可以使用 MSE 来移除这些令人讨厌的空白。下一个演示中的绿色线条表示文件的连接位置和删除的空白。在 Chrome 38 及更高版本中,这将顺畅播放!
您可以通过多种方式制作无缝内容。在本演示中,我们将重点介绍普通用户可能会存放的文件类型。其中每个文件都是单独编码的,不考虑其前后音频片段。
基本设置
首先,我们来回顾一下 MediaSource
实例的基本设置。顾名思义,媒体源扩展只是现有媒体元素的扩展。在下面,我们将表示 MediaSource
实例的 Object URL
分配给音频元素的 source 属性,就像设置标准网址一样。
var audio = document.createElement('audio');
var mediaSource = new MediaSource();
var SEGMENTS = 5;
mediaSource.addEventListener('sourceopen', function() {
var sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
function onAudioLoaded(data, index) {
// Append the ArrayBuffer data into our new SourceBuffer.
sourceBuffer.appendBuffer(data);
}
// Retrieve an audio segment via XHR. For simplicity, we're retrieving the
// entire segment at once, but we could also retrieve it in chunks and append
// each chunk separately. MSE will take care of assembling the pieces.
GET('sintel/sintel_0.mp3', function(data) { onAudioLoaded(data, 0); } );
});
audio.src = URL.createObjectURL(mediaSource);
MediaSource
对象连接后,它会执行一些初始化操作,并最终触发 sourceopen
事件;此时,我们可以创建 SourceBuffer
。在上面的示例中,我们将创建一个 audio/mpeg
解析器,它能够解析和解码 MP3 片段;还有多种其他类型可供选择。
异常波形
我们稍后会再回过头来看看代码,但现在我们先仔细看看刚刚附加的文件,尤其是文件的末尾。下图显示了 sintel_0.mp3
轨道中两个声道最后 3, 000 个样本的平均值。红线上的每个像素都是 [-1.0, 1.0]
范围内的浮点样本。
为什么有那么多零(静音)样本?实际上,这些问题是编码期间引入的压缩工件造成的。几乎每个编码器都会引入某种类型的填充。在本例中,LAME 向文件末尾添加了 576 个内边距采样点。
除了在文件末尾添加内边距外,我们还在文件开头添加了内边距。如果我们预览一下 sintel_1.mp3
轨道,就会发现前面还有 576 个填充样本。填充量因编码器和内容而异,但我们可以根据每个文件中包含的 metadata
了解确切值。
上一个演示中片段之间出现的故障,就是由每个文件开头和末尾的静音部分造成的。为了实现无缝播放,我们需要移除这些静音部分。幸运的是,您可以使用 MediaSource
轻松实现这一点。在下文中,我们将修改 onAudioLoaded()
方法,以使用附加窗口和时间戳偏移来消除此静默期。
示例代码
function onAudioLoaded(data, index) {
// Parsing gapless metadata is unfortunately non trivial and a bit messy, so
// we'll glaze over it here; see the appendix for details.
// ParseGaplessData() will return a dictionary with two elements:
//
// audioDuration: Duration in seconds of all non-padding audio.
// frontPaddingDuration: Duration in seconds of the front padding.
//
var gaplessMetadata = ParseGaplessData(data);
// Each appended segment must be appended relative to the next. To avoid any
// overlaps, we'll use the end timestamp of the last append as the starting
// point for our next append or zero if we haven't appended anything yet.
var appendTime = index > 0 ? sourceBuffer.buffered.end(0) : 0;
// Simply put, an append window allows you to trim off audio (or video) frames
// which fall outside of a specified time range. Here, we'll use the end of
// our last append as the start of our append window and the end of the real
// audio data for this segment as the end of our append window.
sourceBuffer.appendWindowStart = appendTime;
sourceBuffer.appendWindowEnd = appendTime + gaplessMetadata.audioDuration;
// The timestampOffset field essentially tells MediaSource where in the media
// timeline the data given to appendBuffer() should be placed. I.e., if the
// timestampOffset is 1 second, the appended data will start 1 second into
// playback.
//
// MediaSource requires that the media timeline starts from time zero, so we
// need to ensure that the data left after filtering by the append window
// starts at time zero. We'll do this by shifting all of the padding we want
// to discard before our append time (and thus, before our append window).
sourceBuffer.timestampOffset =
appendTime - gaplessMetadata.frontPaddingDuration;
// When appendBuffer() completes, it will fire an updateend event signaling
// that it's okay to append another segment of media. Here, we'll chain the
// append for the next segment to the completion of our current append.
if (index == 0) {
sourceBuffer.addEventListener('updateend', function() {
if (++index < SEGMENTS) {
GET('sintel/sintel_' + index + '.mp3',
function(data) { onAudioLoaded(data, index); });
} else {
// We've loaded all available segments, so tell MediaSource there are no
// more buffers which will be appended.
mediaSource.endOfStream();
URL.revokeObjectURL(audio.src);
}
});
}
// appendBuffer() will now use the timestamp offset and append window settings
// to filter and timestamp the data we're appending.
//
// Note: While this demo uses very little memory, more complex use cases need
// to be careful about memory usage or garbage collection may remove ranges of
// media in unexpected places.
sourceBuffer.appendBuffer(data);
}
无缝波形
我们再来看看应用附加窗口后波形的变化,看看我们全新的代码取得了什么成效。下图显示,我们移除了 sintel_0.mp3
末尾的静音部分(红色)和 sintel_1.mp3
开头的静音部分(蓝色),使片段之间实现了流畅的过渡。
总结
至此,我们已将所有五个片段无缝拼接到一起,演示到此结束。在结束之前,您可能已经注意到,我们的 onAudioLoaded()
方法没有考虑容器或编解码器。也就是说,无论容器或编解码器类型如何,所有这些技术都适用。您可以在下方重玩原始演示版,播放的是支持 DASH 的分片 MP4 文件,而不是 MP3 文件。
如需了解详情,请参阅下文的附录,详细了解无缝内容制作和元数据解析。您还可以探索 gapless.js
,详细了解为此演示版提供支持的代码。
感谢您阅读本邮件!
附录 A:制作无缝衔接的内容
要想制作出无缝衔接的内容,并非易事。下面,我们将详细介绍如何制作此演示中使用的 Sintel 媒体。首先,您需要一份 Sintel 的无损 FLAC 曲目副本;为方便日后参考,下方列出了 SHA1。您需要准备 FFmpeg、MP4Box、LAME 和安装了 afconvert 的 OSX。
unzip Jan_Morgenstern-Sintel-FLAC.zip
sha1sum 1-Snow_Fight.flac
# 0535ca207ccba70d538f7324916a3f1a3d550194 1-Snow_Fight.flac
首先,我们将拆分 1-Snow_Fight.flac
轨道的前 31.5 秒。我们还希望在 28 秒后添加 2.5 秒的淡出效果,以避免在播放结束后点击任何内容。使用以下 FFmpeg 命令行,我们可以完成所有这些操作,并将结果放入 sintel.flac
。
ffmpeg -i 1-Snow_Fight.flac -t 31.5 -af "afade=t=out:st=28:d=2.5" sintel.flac
接下来,我们将该文件拆分为 5 个时长均为 6.5 秒的 wave 文件;由于几乎所有编码器都支持提取 wave 文件,因此使用 wave 文件最为简单。同样,我们可以使用 FFmpeg 精确地执行此操作,然后我们将获得:sintel_0.wav
、sintel_1.wav
、sintel_2.wav
、sintel_3.wav
和 sintel_4.wav
。
ffmpeg -i sintel.flac -acodec pcm_f32le -map 0 -f segment \
-segment_list out.list -segment_time 6.5 sintel_%d.wav
接下来,我们来创建 MP3 文件。LAME 提供了多种用于创建无缝内容的选项。如果您可以控制内容,不妨考虑将 --nogap
与对所有文件进行批量编码结合使用,以完全避免在片段之间添加填充内容。不过,在此演示中,我们需要填充,因此将使用标准的高品质 VBR 编码来编码波形文件。
lame -V=2 sintel_0.wav sintel_0.mp3
lame -V=2 sintel_1.wav sintel_1.mp3
lame -V=2 sintel_2.wav sintel_2.mp3
lame -V=2 sintel_3.wav sintel_3.mp3
lame -V=2 sintel_4.wav sintel_4.mp3
至此,您已完成创建 MP3 文件所需的全部操作。现在,我们来介绍如何创建分片 MP4 文件。我们将按照 Apple 的说明创建适合 iTunes 的母版媒体。在下文中,我们将按照说明将 Wave 文件转换为中间 CAF 文件,然后使用推荐的参数将其在 MP4 容器中编码为 AAC。
afconvert sintel_0.wav sintel_0_intermediate.caf -d 0 -f caff \
--soundcheck-generate
afconvert sintel_1.wav sintel_1_intermediate.caf -d 0 -f caff \
--soundcheck-generate
afconvert sintel_2.wav sintel_2_intermediate.caf -d 0 -f caff \
--soundcheck-generate
afconvert sintel_3.wav sintel_3_intermediate.caf -d 0 -f caff \
--soundcheck-generate
afconvert sintel_4.wav sintel_4_intermediate.caf -d 0 -f caff \
--soundcheck-generate
afconvert sintel_0_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
-b 256000 -q 127 -s 2 sintel_0.m4a
afconvert sintel_1_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
-b 256000 -q 127 -s 2 sintel_1.m4a
afconvert sintel_2_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
-b 256000 -q 127 -s 2 sintel_2.m4a
afconvert sintel_3_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
-b 256000 -q 127 -s 2 sintel_3.m4a
afconvert sintel_4_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
-b 256000 -q 127 -s 2 sintel_4.m4a
现在,我们有几个 M4A 文件,需要先进行适当的分片,然后才能与 MediaSource
搭配使用。为此,我们将使用一秒的 fragment 大小。MP4Box 会将每个分片 MP4 写出为 sintel_#_dashinit.mp4
,并附带一个可丢弃的 MPEG-DASH 清单 (sintel_#_dash.mpd
)。
MP4Box -dash 1000 sintel_0.m4a && mv sintel_0_dashinit.mp4 sintel_0.mp4
MP4Box -dash 1000 sintel_1.m4a && mv sintel_1_dashinit.mp4 sintel_1.mp4
MP4Box -dash 1000 sintel_2.m4a && mv sintel_2_dashinit.mp4 sintel_2.mp4
MP4Box -dash 1000 sintel_3.m4a && mv sintel_3_dashinit.mp4 sintel_3.mp4
MP4Box -dash 1000 sintel_4.m4a && mv sintel_4_dashinit.mp4 sintel_4.mp4
rm sintel_{0,1,2,3,4}_dash.mpd
大功告成!现在,我们有了包含正确元数据的碎片化 MP4 和 MP3 文件,这些元数据对于实现无缝播放至关重要。如需详细了解该元数据的具体内容,请参阅附录 B。
附录 B:解析无缝元数据
与创建无缝内容一样,解析无缝元数据可能很棘手,因为没有标准的存储方法。下文将介绍两个最常见的编码器 LAME 和 iTunes 如何存储其无缝元数据。首先,为上面使用的 ParseGaplessData()
设置一些辅助方法和大纲。
// Since most MP3 encoders store the gapless metadata in binary, we'll need a
// method for turning bytes into integers. Note: This doesn't work for values
// larger than 2^30 since we'll overflow the signed integer type when shifting.
function ReadInt(buffer) {
var result = buffer.charCodeAt(0);
for (var i = 1; i < buffer.length; ++i) {
result <<../= 8;
result += buffer.charCodeAt(i);
}
return result;
}
function ParseGaplessData(arrayBuffer) {
// Gapless data is generally within the first 512 bytes, so limit parsing.
var byteStr = new TextDecoder().decode(arrayBuffer.slice(0, 512));
var frontPadding = 0, endPadding = 0, realSamples = 0;
// ... we'll fill this in as we go below.
我们将先介绍 Apple 的 iTunes 元数据格式,因为它最容易解析和说明。在 MP3 和 M4A 文件中,iTunes(和 afconvert)会以 ASCII 格式写入一个简短的部分,如下所示:
iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00
此信息会写入 MP3 容器内的 ID3 标记中,以及 MP4 容器内的元数据 Atom 中。出于我们的目的,我们可以忽略第一个 0000000
令牌。接下来的三个令牌分别是前端填充、后端填充和非填充样本总数。将每个时间点除以音频的采样率,即可得出每个时间点的时长。
// iTunes encodes the gapless data as hex strings like so:
//
// 'iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00'
// 'iTunSMPB[ 26 bytes ]####### frontpad endpad real samples'
//
// The approach here elides the complexity of actually parsing MP4 atoms. It
// may not work for all files without some tweaks.
var iTunesDataIndex = byteStr.indexOf('iTunSMPB');
if (iTunesDataIndex != -1) {
var frontPaddingIndex = iTunesDataIndex + 34;
frontPadding = parseInt(byteStr.substr(frontPaddingIndex, 8), 16);
var endPaddingIndex = frontPaddingIndex + 9;
endPadding = parseInt(byteStr.substr(endPaddingIndex, 8), 16);
var sampleCountIndex = endPaddingIndex + 9;
realSamples = parseInt(byteStr.substr(sampleCountIndex, 16), 16);
}
另一方面,大多数开源 MP3 编码器会将无缝元数据存储在静音 MPEG 帧内的特殊 Xing 标头中(由于是静音帧,因此不理解 Xing 标头的解码器只会播放静音)。遗憾的是,此标记并不总是存在,并且包含多个可选字段。在本演示中,我们可以控制媒体,但在实际操作中,您需要进行一些额外的检查,才能知道无缝元数据何时实际可用。
首先,我们将解析总样本数。为简单起见,我们将从 Xing 标头中读取此信息,但也可以从常规的 MPEG 音频标头中构建此信息。Xing 标头可以使用 Xing
或 Info
标记。在该标记后面正好 4 个字节处,有 32 位表示文件中的帧总数;将此值乘以每帧的样本数,即可得出文件中的总样本数。
// Xing padding is encoded as 24bits within the header. Note: This code will
// only work for Layer3 Version 1 and Layer2 MP3 files with XING frame counts
// and gapless information. See the following document for more details:
// http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header
var xingDataIndex = byteStr.indexOf('Xing');
if (xingDataIndex == -1) xingDataIndex = byteStr.indexOf('Info');
if (xingDataIndex != -1) {
// See section 2.3.1 in the link above for the specifics on parsing the Xing
// frame count.
var frameCountIndex = xingDataIndex + 8;
var frameCount = ReadInt(byteStr.substr(frameCountIndex, 4));
// For Layer3 Version 1 and Layer2 there are 1152 samples per frame. See
// section 2.1.5 in the link above for more details.
var paddedSamples = frameCount * 1152;
// ... we'll cover this below.
现在,我们已经有了样本总数,接下来可以继续读取填充样本的数量。根据编码器,这可能会写入嵌套在 Xing 标头中的 LAME 或 Lavf 标记下。在该标头正好 17 个字节后面,有 3 个字节,分别表示前端和后端填充,每个 12 位。
xingDataIndex = byteStr.indexOf('LAME');
if (xingDataIndex == -1) xingDataIndex = byteStr.indexOf('Lavf');
if (xingDataIndex != -1) {
// See http://gabriel.mp3-tech.org/mp3infotag.html#delays for details of
// how this information is encoded and parsed.
var gaplessDataIndex = xingDataIndex + 21;
var gaplessBits = ReadInt(byteStr.substr(gaplessDataIndex, 3));
// Upper 12 bits are the front padding, lower are the end padding.
frontPadding = gaplessBits >> 12;
endPadding = gaplessBits & 0xFFF;
}
realSamples = paddedSamples - (frontPadding + endPadding);
}
return {
audioDuration: realSamples * SECONDS_PER_SAMPLE,
frontPaddingDuration: frontPadding * SECONDS_PER_SAMPLE
};
}
这样,我们就拥有了用于解析绝大多数无缝内容的完整函数。不过,边缘情况肯定很多,因此建议您在生产环境中使用类似代码之前谨慎行事。
附录 C:垃圾回收
系统会根据内容类型、平台专用限制和当前播放位置,主动对 SourceBuffer
实例的内存进行垃圾回收。在 Chrome 中,系统会先从已播放的缓冲区回收内存。但是,如果内存用量超出平台特定限制,则会从未播放的缓冲区中移除内存。
当播放到时间轴中因回收内存而出现的空白时,如果空白足够小,播放可能会出现故障;如果空白过大,播放可能会完全停止。这两种情况都无法提供良好的用户体验,因此请务必避免一次附加太多数据,并从媒体时间轴中手动移除不再需要的范围。
您可以通过对每个 SourceBuffer
使用 remove()
方法来移除范围;该方法接受以秒为单位的 [start, end]
范围。与 appendBuffer()
类似,每个 remove()
在完成后都会触发 updateend
事件。在事件触发之前,不应发出其他移除或附加操作。
在桌面版 Chrome 中,您一次最多可以在内存中保留大约 12 兆字节的音频内容和 150 兆字节的视频内容。您不应依赖浏览器或平台中的这些值;例如,这些值绝对不能代表移动设备。
垃圾回收仅会影响添加到 SourceBuffers
中的数据;您可以在 JavaScript 变量中缓冲的数据量没有限制。您还可以根据需要将相同的数据重新附加到同一位置。