オーディオ用 Media Source Extensions

Dale Curtis 氏
Dale Curtis

はじめに

Media Source Extensions(MSE)は、HTML5 の <audio> 要素と <video> 要素向けの拡張バッファリングと再生コントロールを提供します。もともと Dynamic Adaptive Streaming over HTTP(DASH)ベースの動画プレーヤーをサポートするために開発されましたが、以下では、これらをオーディオ、特にギャップレス再生に使用する方法について説明します。

曲が複数のトラックにシームレスに流れる音楽アルバムを聴いたことがあるかもしれません。今、聴いているアルバムもあるかもしれません。アーティストは、このようなギャップレス再生体験を芸術的な選択として、また音声が 1 つの連続ストリームとして書き込まれた ビニール レコードCD のアーティファクトとして生み出します。残念ながら、MP3AAC などの最新のオーディオ コーデックの仕組みにより、このシームレスな聴覚エクスペリエンスはしばしば失われています。

その理由については後で詳しく説明しますが、ここではまずはデモから始めましょう。以下に、優れた Sintel を 5 つの MP3 ファイルに分割し、MSE を使用して再構成した最初の 30 秒を示します。赤い線は、各 MP3 の作成(エンコード)中に生じるギャップを示しています。この部分でグリッチが聞こえます。

デモ

うわっ!これは良いエクスペリエンスとは言えません。改善できるはずです。上のデモとまったく同じ MP3 ファイルを使用して少し手間をかければ、MSE を使用して、この煩わしいギャップをなくすことができます。次のデモの緑色の線は、ファイルが結合された場所とギャップが解消された場所を示しています。Chrome 38 以降では、シームレスに再生されます。

デモ

ギャップレス コンテンツを作成する方法はいろいろあります。このデモでは、通常のユーザーが使用するファイルのタイプに焦点を当てます。各ファイルが、前後の音声セグメントに関係なく個別にエンコードされている。

基本的な設定

まず、MediaSource インスタンスの基本設定について遡って説明しましょう。Media Source Extensions は、その名のとおり、既存のメディア要素の拡張にすぎません。ここでは、音声要素のソース属性に、MediaSource インスタンスを表す Object URL を割り当てています。標準の URL を設定する場合と同様です。

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] の範囲内の浮動小数点サンプルです。

MP3 のギャップ

あのゼロ(無音)サンプルは?これは実際には、エンコード中に発生する圧縮アーティファクトによるものです。ほぼすべてのエンコーダで、なんらかのパディングが導入されています。この場合、LAME によってファイルの最後に 576 のパディング サンプルが追加されます。

各ファイルの先頭部分にもパディングが追加されています。sintel_1.mp3 トラックの前を見ると、前面に別の 576 のパディング サンプルがあることがわかります。パディングの量はエンコーダやコンテンツによって異なりますが、Google は各ファイルに含まれる 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(中音)

まとめ

以上で、5 つすべてのセグメントが 1 つにシームレスに統合され、デモはこれで終わりです。前に説明したとおり、onAudioLoaded() メソッドではコンテナやコーデックが考慮されていません。つまり、これらの手法はすべて、コンテナやコーデックの種類に関係なく機能します。以下では、MP3 の代わりに DASH 対応の断片化された MP4 のデモを再現できます。

デモ

ギャップレス コンテンツの作成とメタデータ解析について詳しくは、以下の付録をご覧ください。また、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 秒を分割します。また、再生終了後にクリックが発生しないように、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

次に、ファイルをそれぞれ 6.5 秒の 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 秒のフラグメント サイズを使用します。MP4Box は、断片化された各 MP4 を、破棄可能な MPEG-DASH マニフェスト(sintel_#_dash.mpd)とともに sintel_#_dashinit.mp4 として書き出します。

    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 の 2 つのエンコーダが、ギャップレス メタデータを保存する方法について説明します。まず、いくつかのヘルパー メソッドと、上記で使用した 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 トークンを無視します。次の 3 つのトークンは、前面パディング、終了パディング、パディングなしの合計サンプル数です。これらをそれぞれ音声のサンプルレートで割ると、それぞれの時間が得られます。

// 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 バイト後に、フロントエンドと終了のパディングをそれぞれ 12 ビットで表す 3 バイトがあります。

        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 変数でバッファに格納できるデータの量に制限はありません。必要であれば、同じデータを同じ位置に再度追加することもできます。

フィードバック