Skip to content

Instantly share code, notes, and snippets.

@ykst
Last active August 2, 2024 08:44
Show Gist options
  • Save ykst/6e80e3566bd6b9d63d19 to your computer and use it in GitHub Desktop.
Save ykst/6e80e3566bd6b9d63d19 to your computer and use it in GitHub Desktop.
WebAudio+WebSocketでブラウザへの音声リアルタイムストリーミングを実装する

WebAudio+WebSocketでブラウザへの音声リアルタイムストリーミングを実装する

WebRTCでやれよ!と言われそうなところですが、 WebSocket+WebAudioの組み合わせで音声ストリーミングをシンプルに構成する方法を紹介してみます。

サーバーサイド(Node.js + ws + pcm)

サーバーサイドは何でも良いのですが、 とりあえずNode.jsでtest.mp3というサンプルファイルをpcmモジュールでデコードし、 wsでクライアントに垂れ流す作りにしておきます。

この例ではPCMサンプルが[-1, 1]の範囲で入ってくるので、 これをそのままFloat32ArrayのArrayBufferにして突っ込めばそのままWebAudioで再生可能な形式になります。

var pcm = require('pcm');

var wss = new (require('ws').Server)({
    server: require('http').createServer(function(req, res) {
        res.writeHead(200, { 'Content-Type': 'text/html'});
        res.end(require('fs').readFileSync('index.html'));
    }).listen(8888)
});

var buf = new Float32Array(8192);
var idx = 0;

wss.on('connection', function (ws) {
    console.log('connected');
    // モノラル、44.1kHz
    pcm.getPcmData('test.mp3', { stereo: false, sampleRate: 44100 },
        function(sample, channel) {
            buf[idx++] = sample;
            // 適当に8192サンプルずつで区切って送信する
            if (idx == buf.length) {
                ws.send(buf);
                buf = new Float32Array(8192);
                idx = 0;
            }
        },
        function() {
            /* dummy end callback */
        }
    );

    ws.on('close', function () {
        console.log('close');
    });
});

要は、連続的にPCMをクライアントに流せれば何でも良いです。 index.htmlは次に説明します。

クライアントHTML

とりあえずクライアントはjavascriptだけの簡易ページにします。

<!DOCTYPE html>
<html> 
<head> <meta content="text/html" charset="UTF-8"> </head>
<body> 
<script type="text/javascript">

var ws = new WebSocket('ws://localhost:8888'),
    ctx = new (window.AudioContext||window.webkitAudioContext),
    initial_delay_sec = 0,
    scheduled_time = 0;

function playChunk(audio_src, scheduled_time) {
    if (audio_src.start) {
        audio_src.start(scheduled_time);
    } else {
        audio_src.noteOn(scheduled_time);
    }
}

function playAudioStream(audio_f32) {
    var audio_buf = ctx.createBuffer(1, audio_f32.length, 44100),
        audio_src = ctx.createBufferSource(),
        current_time = ctx.currentTime;

    audio_buf.getChannelData(0).set(audio_f32);

    audio_src.buffer = audio_buf;
    audio_src.connect(ctx.destination);

    if (current_time < scheduled_time) {
        playChunk(audio_src, scheduled_time);
        scheduled_time += audio_buf.duration;
    } else {
        playChunk(audio_src, current_time);
        scheduled_time = current_time + audio_buf.duration + initial_delay_sec;
    }
}

ws.binaryType = 'arraybuffer';

ws.onopen = function() {
    console.log('open');
};

ws.onerror = function(e) {
    console.log(String(e));
};

ws.onmessage = function(evt) {
    if (evt.data.constructor !== ArrayBuffer) throw 'expecting ArrayBuffer';

    playAudioStream(new Float32Array(evt.data));
};

</script>
</body>
</html>

いくつかポイントを抑えてみます。

Web Audio APIの互換性問題

webkit系のブラウザではベンダプリフィックスや一部API名が一貫していないため、このサンプルでは、

    ctx = new (window.AudioContext||window.webkitAudioContext),

ここと、

    if (audio_src.start) {
        audio_src.start(scheduled_time);
    } else {
        audio_src.noteOn(scheduled_time);
    }

ここの、2箇所でカバーしています。

タイミングの制御

再生の仕組み自体はWebSocketから届いたメッセージをcreateBufferSourceで変換してstart/noteOnしていくだけなのですが、 リアルタイムストリーミングでは細切れバッファ(=チャンク)送信の遅延による揺らぎが発生するため、 そのまま何も考えず再生すると音がぶちぶち途切れてしまいます。

ここでのポイントは、AudioBufferの再生時間を示すdurationを使って、再生予定時刻を積み上げるという方法です。

    if (current_time < scheduled_time) {
        playChunk(audio_src, scheduled_time);
        scheduled_time += audio_buf.duration;
    } else {
        playChunk(audio_src, current_time);
        scheduled_time = current_time + audio_buf.duration + initial_delay_sec;
    }

start/noteOnではAudioContextのcurrentTimeと同じ時間軸で再生時刻を指定することができます。 なので、durationでチャンクの再生時間をscheduled_timeに加算して次のチャンクの再生時刻を決めれば、隙間無く再生を行う事が出来ます。 現在再生中でない(current_timescheduled_timeを越えてしまった)場合は貯金が無い状態なので、気を取り直して現在時刻からの再生を行います。 簡単ですね。

ちなみにこのようにdurationを使えば、 サーバー側がチャンクのサイズを途中で変更してきてもクライアントは意識する事無く処理出来る、というメリットもあります。

バッファリングの設定

このサンプルではサーバー側が一度に全てのデータを送信するのでこれでも問題ありませんが、 マイク入力等のリアルタイムストリームでは逐次的にデータが到達することになるので、 即座に再生してしまうと少しでもネットワークが遅延すると音声が途切れてしまいます。 このような場合は、initial_delay_secの値を増やして再生時間をずらす事で、 遅延を吸収するバッファリングを実現することが出来ます。

まとめ

エラー処理や厳密な遅延の対処はざっくり省きましたが、基本的な構成としては以上のようなものです。 プロトコルというより生のPCMをそのまま突っ込んでるだけなので、非常に単純ですね。 後は途中でエンコーダ/デコーダをかますなり、タイムスタンプを付加してイベントや映像と同期するなりで、適宜応用していくことができます。

ちなみにこの技術を使って、映像と音声のhtml5ストリーミングを実装したiOSアプリもあるようです(ステマ)。

アプリのソースはこちら→https://github.com/ykst/HomeStreamer

@ykst
Copy link
Author

ykst commented Jun 2, 2016

And @UsernameChris, the format of your file looks a little extraordinary so there might be other problems.
Please try any mp3 at http://download.wavetlan.com/SVV/Media/HTTP/http-mp3.htm those should work fine.
MP3 format is not really concerned in this gist, since it is just about sending single channel Float32Array (capped [-1.0, 1.0]) of PCM. Hope it will help you.

@GoodLuckJimmy
Copy link

Thanks for your reply. my websocket server is made in C++
I think there are some problems between javascript and C++

@cloverstudio
Copy link

こういうローレベルでの実装のニーズはあるので凄く参考になりました。
ありがとうございます。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment