ギャップレス再生

このエントリは、ニューノーマル ぴょこりんクラスタ Advent Calendar 2020のために書かれたものです。

個人用のサーバーにおいてある大量の楽曲をどこでも手軽に再生する方法としては、いろいろ模索した結果、Webブラウザでこれを再生するという方法が一番良いということになるでしょう。 しかしそこで課題となるのは、ギャップレス再生(Gapless playback / seamless playback)です。これはある曲と次の曲の間に、全くギャップを置かずに(切れ目なく)再生することです。

これが必要になるのは、本来はつながっている曲なのに、何らかの理由でファイルが分かれている場合、CDでいえばトラックが分かれている場合です。有名な例でいえば、ベートーヴェンの交響曲第5番 Op.67です。いわゆる「運命」ですね。この曲の第3楽章と第4楽章は続けて演奏されますが、たいていのCDはここを2トラックを分けています。

CDを再生している限りにおいては、これは問題にならないのですが、もはや今は物理メディアを使う時代ではありません。残念ながら、Naxos Music Libraryのようなクラシック音楽に重点を置いている配信サイトであっても、ここはちゃんとしていません。なので、第3楽章と第4楽章に移る際に1秒くらいの間があります。惜しいですね。

これは、クラシック音楽に限った話ではなく、例えば、「鉄道唱歌」のCDにおいては、さすがに30分くらいを一つのトラックとするのは憚れるのか、何トラックかに分割しているCDあります。

そんなわけで、今回の目的は、ブラウザ上でギャップレス再生によって、音楽プレイヤー的なものを作るにはどうしたらいいのかを書いてみたいと思います。試行錯誤を含めて書いていますので、一番最後の結論だけが情報として有用でしょう。

ストリーミング再生をある意味自力で作り上げるようなものになっています。

ここでのサンプルプログラムは、ブラウザの標準的な機能だけ使うことにして、ほかの外部ライブラリを使わずに書いています。(document.getElementById()を直接書くなんてすごくひさしぶりな気がする・・)

題材

滝廉太郎の「花」の前奏部分を2ファイルに分けてみました。切れ目なく違和感なく、この2つのファイルを続けて再生することが目標ということになります。

環境

サンプルは下記の環境でテストしました。

  • OS: Windows 10 Pro 20H2 (19042.685)
  • ブラウザ: Firefox 83.0, Edge 87.0.664.60, Chrome 87.0.4280.88

audio tag

まずは、audioタグです。 二つ並べて、片方が終わったら、そのイベントハンドラで、もう片方の再生を開始してみましょう。

<audio id="audio1">
  <source src="Hana_00.wav" type="audio/wav">
</audio>
<audio id="audio2">
  <source src="Hana_01.wav" type="audio/wav">
</audio>
<button type="button" onclick="start()">Start</button>

で、JavaScriptで

function start() {
    const audio1 = document.getElementById("audio1");
    const audio2 = document.getElementById("audio2");

    audio1.addEventListener("ended", () => {
        audio2.play();
    });
    audio1.play();
}

とします。

実際のサンプルです。

結果

悲しいくらいダメですね。再生が終わってからコールバックを待って、さらにそこから新しいのを再生させるようでは無理がありますね。

WebAudio

では、WebAudioを使って同じようなことをしてみましょう。

async function start() {
    const ctx = new AudioContext();

    const wav1 = await fetch("./Hana_00.wav");
    const wav2 = await fetch("./Hana_01.wav");
    
    const buffer1 = await ctx.decodeAudioData(await wav1.arrayBuffer());
    const buffer2 = await ctx.decodeAudioData(await wav2.arrayBuffer());

    const src1 = ctx.createBufferSource();
    src1.buffer = buffer1;
    const src2 = ctx.createBufferSource();
    src2.buffer = buffer2;

    src1.connect(ctx.destination);
    src2.connect(ctx.destination);

    src1.onended = () => {
        src2.start();
    };

    src1.start();
}

実際のサンプルです。

結果

DOMの処理がないためか、Audioタグを使った場合よりも、どのブラウザでもギャップは少なくなりましたが、それでもちょっとギャップレスとは言いがたいものになっています。

WebAudio - Concatenate the buffers

WebAudioを使えばバッファの操作ができるようになりますので、再生前にバッファをくっつけてしまうのはどうでしょうか。最終形を確認するためにも、やってみることにしましょう。

async function start() {
    const ctx = new AudioContext();

    const wav1 = await fetch("./Hana_00.wav");
    const wav2 = await fetch("./Hana_01.wav");
    
    const buffer1 = await ctx.decodeAudioData(await wav1.arrayBuffer());
    const buffer2 = await ctx.decodeAudioData(await wav2.arrayBuffer());

    const buffer = ctx.createBuffer(buffer1.numberOfChannels, buffer1.length + buffer2.length,
                                    buffer1.sampleRate);

    for (let ch = 0; ch < buffer1.numberOfChannels; ch++) {
        buffer.copyToChannel(buffer1.getChannelData(ch), ch, 0);
        buffer.copyToChannel(buffer2.getChannelData(ch), ch, buffer1.length);
    }

    const src = ctx.createBufferSource();
    src.buffer = buffer;
    src.connect(ctx.destination);
    src.start();
}

実際のサンプルです。

結果

かなりよいのですが、予想に反して自分の環境ではどのブラウザでも微小なノイズがあります。というか理論上ギャップがでることはないはずなのですが・・・

WebAudio - Tweak the source

この2ファイルをローカルのギャップレス再生が可能なプレーヤー(foobar2000とか)につっこんでも特にノイズがないことから、ブラウザの処理に何らかの問題があることがわかります。

で、いろいろ見ていくと、buffer.sampleRateが48000であることに気づきます。もとのファイルは、44100でした。なので、デコードする段階(AudioContext.decodeAudioData)で、サンプリングレート変換が発生していることがわかります。たぶんブラウザが強制的に48kHzとしているのでしょう。

そんなわけで、新たな音声ソースを用意しました(元のファイルとは微妙に長さなどが異なっています)。

これを使って、48kHz版サンプルでリトライしてみましょう。

結果

今度は問題ないようです。二つのファイルの境界部分がサンプリングレート変換の影響でうまく合わなくなっていたのでしょう。

別解

別解としては、AudioContextを作成する際にサンプリングレートを44100にしてしまうという方法が考えられます。

async function start() {
    const ctx = new AudioContext({
        sampleRate: 44100
    });
    // [snip]
}

実際のサンプルです。当たり前ですけど、これならOKです。

ただ、設定すべきサンプリングレートの値は、デコードの前に取得しておかないといけないこと(デコードしてしまうと、そのAudioContextのサンプリングレートになってしまう)から、これはこれで若干トリッキーです。

そして、ここだけに限らないのですが、サンプリングレートが違う曲が混在して再生しなければいけないときにどうするかはなかなか難しい問題でしょう。44100と48000の最小公倍数が現実的だったらよかったのかもしれませんが。

以下では、とりあえず48kHzのソースを使っていきます。

WebAudio - start() at a scheduled time

ところで、長大なトラックになると、それを全部バッファに読み込んでおくのは好ましくありません。そもそもドキュメントによると、AudioBufferは短い音を格納しておくことを目的としているようなので、何分もあるバッファを作るのは(作れてしまいますが)目的外のようです。逆に、一つの長いトラック自体も、短いAudioBufferに分割してシームレスに再生していくほうが、本来の使い方に近いのではないでしょうか。

一つ考えられるのは、ループを用いて、同じバッファを書き換えていく手法です。ただ再生中のバッファを書き換えることは、まあ、どのブラウザでも確実に動作する方法には思えません。再生中のバッファを書き換えることは推奨されないとどこかのドキュメントで読んだ気がしますが、ちょっと見つけられていません。

ということで、別の方法を使わなくてはなりません。そこで利用したくなるのが、AudioScheduledSourceNode.start()の引数です。(これは、上の例で使っていたAudioBufferSourceNodeも継承しています) これで指定した時刻にバッファを再生することができます。

すなわち、バッファ1の再生を開始した後、バッファ2をバッファ1が終わる時刻に再生開始するようにしておけば、ギャップレス再生できるはずですが、そううまくいくでしょうか。とりあえず、下記のようなコードで試してみましょう。

async function start() {
    const ctx = new AudioContext();

    const wav1 = await fetch("./Hana48k_00.wav");
    const wav2 = await fetch("./Hana48k_01.wav");
    
    const buffer1 = await ctx.decodeAudioData(await wav1.arrayBuffer());
    const buffer2 = await ctx.decodeAudioData(await wav2.arrayBuffer());

    const src1 = ctx.createBufferSource();
    src1.buffer = buffer1;
    const src2 = ctx.createBufferSource();
    src2.buffer = buffer2;

    src1.connect(ctx.destination);
    src2.connect(ctx.destination);

    src1.start();
    const t = ctx.currentTime;
    src2.start(t + buffer1.duration);
}

サンプルです。

結果

Edge, ChromeのChrome系ブラウザは、ノイズは聞こえないようです。ですが、Firefoxでは合間にノイズが聞こえてしまいます。

まあコードから明らかですが、src1.start()ctx.currentTimeを読み取るタイミングが、(ノイズが聞こえないくらい)十分近いとは誰も保障してくれるわけではありません。Chromeはたまたまうまくいっていると見るべきでしょう。

WebAudio - start() at a scheduled time (2)

ではどうすればいいかというと、そもそもバッファ1の再生時刻も予測可能にしてしまえばよいわけです。

ということで、下記のようなコードにしてみます。

async function start() {
// [snip]
    const DELTA = 0.2; // 200 ms
    const t = ctx.currentTime + DELTA;
    src1.start(t);
    src2.start(t + buffer1.duration);
}

サンプルです。

結果

これでどのブラウザでもノイズなくギャップレス再生ができるようになります。意外と精度よく時刻指定できるものですね。

もちろん、200msのウェイトを加えているので、ボタンを押してから微妙に時間差を感じることになりますが、実際も音声データを取ってくる処理とかがはいるので、まあこれは許容できるでしょう。

まとめ

以上をまとめて、複数の大きなAudioBufferを細かいバッファでダブルバッファ方式で再生していくクラスを書いてみました。が、いろいろと不備があるので、このままで使えるものではないと思いますので、使い方など細かいことの説明は省略します。

まあ、そもそも、デコードしている段階で、AudioBufferは作られてしまっているのでそれをさらに分割する意味は全くない気がしますね。あるとすれば、AudioBufferSourceNodeからは途中ではイベントが発生しないので(再生終了時のみ)、分割することで細かくイベントを発生させられるというくらいでしょうか。

おまけ

今回は、WAVファイルを使いましたが、実際にはなんらかのフォーマットでエンコードした形式を使うことになるでしょう。サンプリングレートのような問題がでるかどうかを確認してみたいと思います。

このサンプルを使用して、対応しているいろいろなフォーマットでエンコードしたファイルから、デコードした結果(長さ)を示します。

48kHzのソース

Audio Format 00 (Firefox) 01 (Firefox) 00 (Chrome) 01 (Chrome)
WAVE 192512 189760 192512 189760
MP3 192512 189760 192512 189760
Ogg 192512 189760 192704 189888
Opus 192512 189760 192512 189760
FLAC 192512 189760 192512 189760

あまりブレがなさそうな48kHzのソースですが、Chromeだとoggの場合のみ、ほかのものと長さが変わってきています。

44.1kHzのソース

次にデフォルトのAudioContextで、48kHzへの変換が起きた場合にどうなるかです。

Audio Format 00 (Firefox) 01 (Firefox) 00 (Chrome) 01 (Chrome)
WAVE 192819 191704 192818 191703
MP3 192819 191704 192818 191703
Ogg 192819 191704 193236 191773
Opus 192819 191688 192819 191688
FLAC 192819 191704 192818 191703

どちらもブレるケースが多くなってきます。OggやOpusだとずれが生じているようです。

ということで、可逆圧縮であればFLAC、非可逆圧縮であればMP3ということになるようです。・・・でも以前調査したとき、mp3はぶれたことがあったような気がするんですよねえ。直ったのかもしれませんし、今回のデータではたまたま起きなかったのかもしれません。なので、やはり可逆圧縮が正義なのかもしれません。