WebRTCでやれよ!と言われそうなところですが、 WebSocket+WebAudioの組み合わせで音声ストリーミングをシンプルに構成する方法を紹介してみます。
サーバーサイドは何でも良いのですが、
とりあえず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
は次に説明します。
とりあえずクライアントは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>
いくつかポイントを抑えてみます。
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_time
がscheduled_time
を越えてしまった)場合は貯金が無い状態なので、気を取り直して現在時刻からの再生を行います。
簡単ですね。
ちなみにこのようにduration
を使えば、
サーバー側がチャンクのサイズを途中で変更してきてもクライアントは意識する事無く処理出来る、というメリットもあります。
このサンプルではサーバー側が一度に全てのデータを送信するのでこれでも問題ありませんが、
マイク入力等のリアルタイムストリームでは逐次的にデータが到達することになるので、
即座に再生してしまうと少しでもネットワークが遅延すると音声が途切れてしまいます。
このような場合は、initial_delay_sec
の値を増やして再生時間をずらす事で、
遅延を吸収するバッファリングを実現することが出来ます。
エラー処理や厳密な遅延の対処はざっくり省きましたが、基本的な構成としては以上のようなものです。 プロトコルというより生のPCMをそのまま突っ込んでるだけなので、非常に単純ですね。 後は途中でエンコーダ/デコーダをかますなり、タイムスタンプを付加してイベントや映像と同期するなりで、適宜応用していくことができます。
ちなみにこの技術を使って、映像と音声のhtml5ストリーミングを実装したiOSアプリもあるようです(ステマ)。
アプリのソースはこちら→https://github.com/ykst/HomeStreamer
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