音频媒体来源扩展

戴尔·柯蒂斯
Dale Curtis

简介

媒体来源扩展 (MSE) 为 HTML5 <audio><video> 元素提供扩展的缓冲和播放控件。虽然最初开发这些播放器是为了支持基于 HTTP 动态自适应流式传输 (DASH) 的视频播放器,但我们在下文中介绍了如何将这些播放器用于音频,尤其是无间断播放

您可能听过一张音乐专辑,其中歌曲在曲目间无缝切换;现在,甚至可能都在听一张专辑。音乐人打造了这些无间断播放体验,既可作为艺术的选择,也可作为黑胶唱片CD 的工件,其中音频以连续流的形式进行写入。遗憾的是,由于 MP3AAC 等现代音频编解码器的工作方式,如今人们常常无法获得这种无缝的听觉体验。

我们将在下文详细说明原因,但现在我们先来演示一下。以下是精彩的 Sintel 的前三十秒内容,它被分成五个单独的 MP3 文件,然后使用 MSE 重新组合。红线表示在制作(编码)每个 MP3 的过程中引入的缺口;您会在这些时间点听到故障。

演示

太糟糕了!这样的体验不好,但是我们还可以做得更好。再多做一点工作,就可以使用上面演示中完全相同的 MP3 文件,我们可以使用 MSE 来消除这些烦人的间隙。下一个演示中的绿线表示文件已联接的位置和移除了间隔的位置。在 Chrome 38 及以上版本中,可以无缝播放!

演示

您可以通过多种方式创建无间断内容。在本演示中,我们将着重介绍普通用户可能存在的文件类型。其中每个文件都经过单独编码,而不考虑其前后的音频片段。

基本设置

首先,让我们回溯并介绍 MediaSource 实例的基本设置。顾名思义,媒体来源扩展只是现有媒体元素的扩展。下面,我们将为音频元素的来源属性分配一个 Object URL(表示 MediaSource 实例);就像设置标准网址一样。

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 轨道中两个渠道的最后 3000 个样本的平均值。红线上的每个像素都是 [-1.0, 1.0] 范围内的一个浮点样本

mp3 间隙

那些零(静默)样本是什么意思?它们实际上是由编码过程中引入的压缩伪影引起的。几乎每个编码器都会引入某种类型的填充。在本例中,LAME 在文件末尾正好添加了 576 个填充样本。

除了末尾的内边距之外,每个文件的开头也添加了内边距。如果向前看一下 sintel_1.mp3 轨道,会看到前面存在 576 个内边距样本。内边距大小因编码器和内容而异,但我们知道每个文件中包含的 metadata 的确切值。

mp3 间断结束

每个文件开头和末尾的无声部分是导致上一个演示中各个片段之间出现干扰的原因。为了实现无间断播放 我们需要消除这些无声部分幸运的是,使用 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 开头的静默部分(蓝色)均已移除;这使我们可以在各片段之间无缝过渡。

mp3 中音

总结

至此,我们将全部五个片段无缝拼接成了一个片段,演示到此结束。在开始之前,您可能已经注意到,我们的 onAudioLoaded() 方法没有考虑容器或编解码器。这意味着,无论容器或编解码器类型如何,所有这些技术都适用。下面,您可以重放支持 DASH 的原始演示版碎片化 MP4,而不是 MP3。

演示

如需了解详情,请参阅以下附录,深入了解无间断内容创建和元数据解析。您还可以探索 gapless.js,更详细地了解为本演示提供支持的代码。

感谢您阅读本邮件!

附录 A:创建无间断内容

创作无间断的内容并非易事。下面,我们将介绍如何创建本演示中使用的 Sintel 媒体。首先,您需要一份适用于 Sintel 的无损 FLAC 音轨的副本;为便于后人使用,下面提供了 SHA1。如需使用工具,您需要安装 FFmpegMP4BoxLAME,以及通过 afconvert 安装 OSX。

    unzip Jan_Morgenstern-Sintel-FLAC.zip
    sha1sum 1-Snow_Fight.flac
    # 0535ca207ccba70d538f7324916a3f1a3d550194  1-Snow_Fight.flac

首先,我们将 1-Snow_Fight.flac 轨道的前 31.5 秒拆分。我们还想添加 2.5 秒的淡出时间(从 28 秒开始算起),以避免播放结束后产生任何点击。使用下面的 FFmpeg 命令行就可以完成上述所有操作,并将结果放入 sintel.flac 中。

    ffmpeg -i 1-Snow_Fight.flac -t 31.5 -af "afade=t=out:st=28:d=2.5" sintel.flac

接下来,我们会将该文件拆分为 5 个 wave 文件,每个文件的大小为 6.5 秒;使用 wave 是最简单的,因为几乎每个编码器都支持对 wave 进行提取。同样,我们可以使用 FFmpeg 精确地执行此操作,之后我们将有以下元素:sintel_0.wavsintel_1.wavsintel_2.wavsintel_3.wavsintel_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 对所有文件进行批量编码,以避免在各片段之间完全填充填充。不过,出于此演示目的,我们希望进行这种填充,因此我们将对 wave 文件使用标准的高品质 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 一起使用。出于我们的目的,我们将使用 1 秒的 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 标头可以使用 XingInfo 标记进行标记。这个标记后面正好是 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 中,将首先从已播放的缓冲区中回收内存。但是,如果内存用量超过平台特定限制,则会从未播放的缓冲区中移除内存。

当播放因回收内存而导致在时间轴上出现间隔时,如果间隔足够小,则可能会发生故障;如果间隔太大,则可能会完全停止播放。这两种操作都无法带来良好的用户体验,因此请务必避免一次性附加过多数据,并从媒体时间轴中手动移除不再需要的范围。

您可以通过每个 SourceBufferremove() 方法移除范围;该方法采用 [start, end] 范围(以秒为单位)。与 appendBuffer() 类似,每个 remove() 会在完成后触发一个 updateend 事件。在事件触发之前,不应发出其他移除或附加操作。

在桌面版 Chrome 中,您可以同时在内存中保存大约 12 MB 的音频内容和 150 MB 的视频内容。您不应在不同浏览器或平台上依赖这些值;例如,它们绝不能代表移动设备。

垃圾回收只会影响添加到 SourceBuffers 的数据;对于 JavaScript 变量中可以缓冲的数据量,其没有任何限制。如有必要,您还可以在同一位置重新附加相同的数据。

反馈