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 Sep 27, 2015

WebRTCでもORTCでも、ネイティブ側から音声ストリームを流せればこうした手作りの機構よりも遥かに性能・品質の良い配信が出来るため、こうした手法はロストテクノロジーとなるだろう。

@GoodLuckJimmy
Copy link

Hi ykst. I have a quistion.
it doesn't work with my audio file. my test audio file is like below
Sample rate: 8000
Channels: mono
Resolution: 8-bit

I think it wouldn't work for Float32Array. It worked with Int8Array like "playAudioStream(new Int8Array(data)". I can listen the music file. but too much noise too.

do you know what I should do?

@sassyn
Copy link

sassyn commented Jun 2, 2016

Hi,

Did u manged to make it work?
All my test didn't mange to stream the file.

I tried any Mp3 format, as well PCM, even AAC
I convert them with FFMPEG

Things like:
-acodec libmp3lame -ar 44100 -ac 1 -b:a 1k 1.mp3
-acodec pcm_s16le -ar 44100 -ac 1 1.wav

I can only hear sound for a sec when I open the page, then it is getting in a loop.

Any idea?
Thank You

@ykst
Copy link
Author

ykst commented Jun 2, 2016

Hi, @UsernameChris @sassyn (thanks for the mail)

I just updated server-side source (see diff: https://gist.github.com/ykst/6e80e3566bd6b9d63d19/revisions ).
The gist was originally written 3 years ago, and there seems to be a change in ws on sending buffer so the old server-side was happened to disturb samples by rewriting working buffer. Sorry for inconvenience you. 😅
Now both stereo/mono mp3 should work well with the new version.

@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