WebRTCに変わる新しい技術 WebTransportとは?
WebTransportとは、WebRTCがP2Pで使いにくく柔軟性が低いことからそれを補うため、
サーバー・クライアントモデルでリアルタイムに通信できることを目指した技術です。いよいよ年明けのChrome M97でリリースされます。
クラウドゲーミング界からの熱い要求がありますが、低レベルゆえ難しく、クラウドゲーミングへの道のりは遠いのです。(別に目指してないけど)
2021/12/4 修正 Yutaka Hiranoさん(Chromeの中の人)からご指摘頂き問題箇所を修正しました!
詳細は少し下にあります。
(フルHDなら!)リアルタイムに配信できました!
前回ストリームで配信を行い、
残念ながらChromeがエラーを吐いたりリアルタイムで処理をしてくれなかったりと言う結果になりました。
今回はよりリアルタイムに通信できるデータグラムを使い、さらにロスしたパケットはゼロ埋めすることでエラーが少なくなりました。
4Kは結局、CPU処理能力が足りずカックカクです。。
ただしエンコーダとデコーダは4K対応していて(ダミーデータなら)30Mbpsくらい転送できるので、
H264のハードウェア支援がサポートされれば理屈の上で十分実現可能と思われます。
作ったもの
ソースコードはgithubにアップしています。
(CPUが足りていない4Kの図。5K iMac 2020モデル / Intel i5 3GHz / メモリ8GB)
こちらはフルHD動画を配信したところです。WebRTCくらいには違和感がありませんでした。(意外にも音声の方が遅延が500msくらいあり、映像は体感250ms程度遅延でした)
~~~Chromeさん起きて! 仕事して!~~~
2021/12/4修正
datagramがリアルタイムに受信できかなったので、Chromeのバックグラウンドの処理がまだこなれていないのかなとか思っていのですが、
単純にserver側の実装が漏れていただけでした。
ご指摘の通り、aioquicのsend_datagram()を辿ってみるとpendingにデータを貯めているだけでした。
def send_datagram_frame(self, data: bytes) -> None:
self._datagrams_pending.append(data)
QuicConnectionProtocol
のtransmit()
を呼び出す必要があるとのこと。
参考 : 頂いたChromeのSample
修正箇所
datagramを送信したらすぐにtransmit()
することで問題なく受信できるようになりました!
def broadcast_video(payload):
# print("send video " + str(len(payload)))
for viewer in viewers.values():
viewer['connection'].send_datagram(viewer['session_id'], payload)
# send_datagram()はキューに入れるだけなのでtransmit()で転送する
viewer["protocol"].transmit();
ちなみに、aioquic自体はイベントループで処理しているため、内部で3種類タイマーを使っているようです。
WebCodecsやエンコード・デコード処理について
WebCodecsはChrome M94でリリース済みであり、エンコーダー・デコーダーと圧縮データ・生データのインターフェース定義を含みます。
メディアからの入出力はまた別のAPIセットになっているようです。
前回の記事をご覧ください。
ストリームとデータグラムの違い
詳しい話は別記事で書きましたが、ざっくりいうとUDPのようにデータの信頼性や到達が保証されないけどリアルタイムで処理できるのがdatagram
です。
TCPほど厳格にやらなくても良いけどデータ保証と順番はいい感じにやって欲しい、というのがstream
です。
_ | ストリーム | データグラム |
---|---|---|
方向性 | TCPとUDPの良いとこどり | UDP |
再送 | QUICがやってくれる | 必要なら自分でやる |
データ分割 | QUICがやってくれる | 自分で1パケットに治める |
データ結合 | 自分でやる | 自分でやる |
データの長さと終端処理 | UICがやってくれる | 自分で長さを送る |
用途 | ストリーミング配信など | リアルタイム通信など |
プログラム解説
それではストリームからデータグラムになって変わったところを見ていきましょう!
データ構造にヘッダーをつける
ストリームでは1フレーム1ストリームとして総重心しましたが、データグラムはWebTransport一コネクションにつき一つのオブジェクトでやりとりします。
動画か音声かはWebTransportのコネクションが話かけているので混ざることはないですが、1フレーム目と2フレーム目のデータが混ざってしまったら大変です。
データグラムは順番保証もしていないので、いつ何のデータが来るかわかりません。
そこで、フレームごとにストリーム番号をふりそれぞれパケット番号をふって並べ替えるようにしましょう。
パケット番号0にはこれから送信するデータ全体の長さを入れます。
パケット番号1にはヘッダとデータを、以降のパケットも1000バイトずつデータを送るようにします。
パケット番号0 | パケット番号1 | それ以降のはパケット |
---|---|---|
ストリーム番号(4) | ストリーム番号(4) | ストリーム番号(4) |
パケット番号(4) | パケット番号(4) | 984 - 1984byte |
データの長さ(4) | frame type(1) | (終わるまで1000byteずつ) |
[End Of Packet] | timestamp(8) | [End Of Packet] |
_ | duration(8) | _ |
_ | データ0 - 983byte | _ |
_ | [End Of Packet] | _ |
(必ず12byte) | (1008byteより少ない) | (1008byteより少ない) |
送信するときは自分で1000byteくらいに分割する
ストリームの時はQUIC側でいい感じにやってくれますが、データグラムだと自分でサイズを決めないといけないようです。
その代わりRDC9000によると、データグラムのQUICパケットはできるだけ分割されないように優先して扱われるようです。
async function sendBinaryData(datagramWriter, stream_number, data) {
// データフォーマット
// stream_number(4)
// packet_number(4)
// data(n)
const size = data.byteLength;
// 最初にパケット番号0としてデータの長さを送る
let header = new ArrayBuffer(4 + 4 + 4);
const view = new DataView(header);
view.setUint32(0, stream_number);
view.setUint32(4, 0); // パケット番号0はデータ全体の長さとする
view.setUint32(8, size);
datagramWriter.write(header);
// データを1000byteずつ送る
let count = 0;
for (let i = 0; i < size; ) {
const len = (size > i + 1000) ? 1000 : size - i;
let payload = new Uint8Array(8 + len);
const view = new DataView(payload.buffer);
view.setUint32(0, stream_number);
view.setUint32(4, ++count);
payload.set(new Uint8Array(data, i, len), 8);
datagramWriter.write(payload.buffer);
i += len;
}
}
受け取る時はストリーム番号とパケット番号で整理する
データグラムではデータの順番がバラバラになります。
受け取ったデータの最初の4byteにはストリーム番号があり、次の4byteにはパケット番号を入れています。
パケット番号0には全体の長さを入れてあるのでそれをもとにデータが揃ったか知ることができます。
(今回はパケット番号0がロストすると致命的ですが、再送要求は実装していません)
配列にデータを貯めておく形にします。
let size = new Array();
let count = new Array();
let buffer = new Array(); // ストリームとパケット番号ごとにデータをいれる
// こんな感じでストリームごとに貯めておきます。
size[2] = 2000; // 全部で2000バイトある
count[2] - 1000; // 1000バイト分読み込んだ
buffer[2][0] = new Array(1000); // パケット番号1のデータ
buffer[2][1] = undefined; // パケット番号2のデータが来るのを待っている状態
データグラム受信
まずはデータグラムを受け取る処理です。
初めてのストリーム番号のデータならバッファを初期化し、
パケット番号0なら全体の長さを格納します。
それ以外のパケットであればデータをパケット番号とともに配列に入れておきます。
async function readDatagram(transport, onstream) {
let size = new Array(), count = new Array();
let buffer = new Array(); // ストリームとパケット番号ごとにデータをいれる
let reader = transport.datagrams.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
self.postMessage('Done read datagram.');
return;
}
const data = value;
// 最初の8バイトを取得して、ストリーム番号とパケット番号を取得する
let view = new DataView(data.buffer);
const stream_number = view.getUint32(0);
const packet_number = view.getUint32(4);
// 初めての来たストリームデータなら、バッファを初期化する
if (!(stream_number in buffer)) {
buffer[stream_number] = new Array();
size[stream_number] = Number.MAX_SAFE_INTEGER; // 最初はストリームの全体の長さがわからない
count[stream_number] = 0
}
// 最初のパケットにはデータの長さを入れてある
if (packet_number === 0) {
size[stream_number] = view.getUint32(8);
// console.log(`${stream_number} ${packet_number} ${size[stream_number]}`);
continue;
}
buffer[stream_number][packet_number-1] = data.slice(8); // ストリーム番号とパケット番号を除いた残りのデータ全てコピーする
count[stream_number] += data.byteLength - 8
// ストリームのデータが全部揃ったら処理をする
...
let payload = new Uint8Array();
}
データ結合とパケロス対策
次にあるストリームのデータが揃った時の処理を書きます。
つまり動画なら1フレーム分のデータを受信したことになります。
データを結合してデコーダーに渡します。
ただし一つの前のフレームがまだデータとして残ってる場合、結合時に足りないパケットは0埋めして先にそちらを処理しておきます。
フレームをスキップしたりデータの長さが足りないフレームをデコーダーに食わせるとエラーを吐き出して再生が止まってしまいます。
// console.log(`${stream_number} ${packet_number} ${count[stream_number]}/${size[stream_number]}`);
if (size[stream_number] === count[stream_number]) {
// console.log(new Date(Date.now()).toISOString() + "stream readed! " + stream_number);
// 前のフレームがまだ残っている場合は先に処理してしまう。
if (stream_number-1 in buffer) {
self.postMessage(`stream ${stream_number - 1} skipped!`);
concatFrame(buffer, size, count, stream_number - 1, onstream);
}
concatFrame(buffer, size, count, stream_number, onstream);
}
}
結合とパケロスの時に0でデータを埋める処理です。
フレームをスキップしてしまうとデコーダーがエラーを吐く確率が高いです。
0で埋めることでだいぷエラー率を下げることができますが、連続したパケロスにはやはり弱いようです。
(というかローカル環境なのでChromeがポロポロこぼしているだけっぽいです)
function concatFrame(buffer, size, count, stream_number, onstream) {
const buf = buffer[stream_number];
const length = size[stream_number];
// データを結合する
let payload = new Uint8Array(length);
let pos = 0;
for (let i = 0; pos < length; i++) {
// データがあればそれを使う。なければ0で埋める
if (i in buf) {
payload.set(new Uint8Array(buf[i]), pos);
pos += buf[i].byteLength;
} else {
console.log(i); // このパケット番号が来てない!
let dummy = new ArrayBuffer(pos + 1000 < length ? 1000 : length - 1000);
payload.set(new Uint8Array(dummy), pos); // とりあえずダミーデータで長さを合わせる
pos += dummy.byteLength;
}
}
// データを処理する
onstream(payload.buffer);
// 使い終わったバッファは削除する
delete buffer[stream_number];
delete size[stream_number];
delete count[stream_number];
}
まとめ
ひとまず、リアルタイム通信に必要な基本的なところは大方検証できたように思います。
**2021/12/25訂正 WebRTCでもSDPレイヤーで制限解除できるとの情報をいただきました!**
それに、4KではないにしろフルHDで違和感のないレベルの画質とフレームレートとレイテンシーを実現できました。
今後はWasmをフル活用してエラー訂正や様々な改良が加えられて色々なサービスが出てくるのではないでしょうか?
クラウドゲーミングも良いですが、リモートデスクトップで違和感なく高画質動画を見たりできるのではないでしょうか。
高画質な画面共有も気になるところです。
次はいよいよRustでWebTransportをやるかもしれません。
[他のWebTransport関連記事はこちら](https://qiita.com/alivelime/items/70d06a2bb8da697066ff#%E4%BB%8A%E3%81%BE%E3%81%A7%E6%9B%B8%E3%81%84%E3%81%9F%E8%A8%98%E4%BA%8B)