オーディオ用 Media Source Extensions

Dale Curtis
Dale Curtis

はじめに

Media Source Extensions(MSE)は、HTML5 の <audio> 要素と <video> 要素に対して拡張バッファリングと再生制御を提供します。元々は Dynamic Adaptive Streaming over HTTP(DASH)ベースの動画プレーヤーを容易にすることを目的として開発されましたが、以下ではオーディオに使用する方法について説明します。特にギャップレス再生に適しています。

トラック間で曲が流れる音楽アルバムを聴いたことがあると思います。今お聞きになっているかもしれません。アーティストはこうしたギャップのない再生体験を、芸術的な選択肢としてだけでなく、アナログ レコードCD で音声を 1 つの連続したストリームとして書き留めたものとして生み出しています。残念ながら、MP3AAC のような最新のオーディオ コーデックの仕組みにより、このシームレスなオーディオ体験は現在ではしばしば失われています。

その理由については後述しますが、とりあえずデモから始めましょう。以下は、優れた Sintel の最初の 30 秒です。5 つの MP3 ファイルに分割され、MSE を使用して再構築されています。赤い線は、各 MP3 の作成(エンコード)時に発生したギャップを示しています。この箇所で不具合が発生します

デモ

うわ!これは好ましくありません。改善の余地があります上のデモとまったく同じ MP3 ファイルを使用し、もう少し作業を加えることで、MSE を使用してこのわずらわしいギャップを取り除くことができます。次のデモの緑色の線は、ファイルが結合され、ギャップが削除された場所を示しています。Chrome 38 以降ではシームレスに再生されます。

デモ

ギャップのないコンテンツを作成するためのさまざまな方法があります。このデモでは、通常のユーザーが横たわっている可能性のあるファイルの種類に焦点を当てます。各ファイルが、前後の音声セグメントに関係なく個別にエンコードされている場合。

基本的な設定

まず、MediaSource インスタンスの基本設定をさかのぼって説明します。Media Source Extensions は、名前が示すように、既存のメディア要素の拡張機能にすぎません。以下では、MediaSource インスタンスを表す Object URL を音声要素の source 属性に割り当てています。通常の 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 を作成できます。上記の例では、MP3 セグメントの解析とデコードが可能な audio/mpeg セグメントを作成しています。他にもいくつかのタイプを利用できます。

異常な波形

コードには後ほど戻りますが、今度は、先ほど追加したファイル、特に末尾にあるファイルを詳しく見てみましょう。以下のグラフは、sintel_0.mp3 トラックで収集された最新の 3, 000 件のサンプルを、両方のチャンネルの平均で集計したものです。赤い線上の各ピクセルは、[-1.0, 1.0] の範囲の浮動小数点サンプルです。

sintel_0.mp3 の末尾

あのゼロ(無音)サンプルはどうなってるの!?その原因は、エンコード中に圧縮アーティファクトが生じるためです。ほぼすべてのエンコーダに、なんらかのパディングがあります。この例では、LAME はファイルの末尾に正確に 576 のパディング サンプルを追加しました。

各ファイルでは、末尾のパディング以外に、ファイルの先頭へのパディングも追加されています。sintel_1.mp3 トラックを確認すると、さらに 576 個のパディング サンプルが前面に存在します。パディングの量はエンコーダやコンテンツによって異なりますが、正確な値は各ファイルに含まれる metadata に基づきます。

sintel_1.mp3 の先頭

sintel_1.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 の先頭のサイレント セクション(青色)が削除されていることがわかります。セグメント間をシームレスに移行できます

sintel_0.mp3 と sintel_1.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 の取り込みをサポートしているため、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 ファイルの作成について説明します。iTunes でマスターされたメディアの作成については、Apple の手順に沿って進めてください。以下では、手順に沿って 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 ヘッダーを理解できないデコーダは単に無音を再生します)。このタグは常に存在するわけではなく、オプション フィールドが多数あります。このデモの目的上、Google はメディアを管理しますが、実際には、ギャップレス メタデータが実際に利用可能であるかどうかを確認するために、いくつかの追加チェックが必要になります。

まず、サンプルの総数を解析します。説明をわかりやすくするために、ここでは 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 変数でバッファリングできるデータの量に制限はありません。必要に応じて、同じデータを同じ位置に再度追加することもできます。