LoginSignup
13
5

More than 1 year has passed since last update.

[遅延たった50ms]WebTransport + Webcodecs でもビデオエコーがしたい! 〜 AWS t4g.small H264 1080p

Last updated at Posted at 2022-01-17

AWSにWebTransportサーバー立ててみました

本当はこの低遅延を皆さんに共有できるようにデモを公開しようかとも思ったんですが、Pythonサーバーがメモリリークするので残念ながらスクショだけです

2022/03/20 Go実装によるデモサーバーを作りました。

通信方式としてはdatagramとstreamの両方で、
動画のエンコードはVP8 VP9``H264でそれぞれ1080pであれば数分程度は安定して通信できるようになりました。
ほぼほぼクライアント側のエラー処理をちゃんとするかどうかの問題なので、この辺りの作り込みが今後の肝になってくると思います。

画面表示だけだとディスプレイの表示やキャプチャのタイミングもありそうなので、
エンコード済みフレームを送信してから、1フレーム全て受信し終わるまでの時間をログに出力しました。
音声についてはデータ上の遅延はほぼないのですが、マイクがそもそも数百ミリ秒遅延します。

作ったもの

ソースコードはこちらです。
適当なサーバーにLetsEncriptで証明書を作るかローカルの自己証明書でPythonでサーバを起動すれば確認できると思います。

ざっくり傾向まとめ

色々な条件でビデオエコーしてみた際の条件をまとめておきます。

_ クライアント サーバー
マシン iMac2019 Intenl i5 3.0GHz, Mem8GB
Facetime HDカメラと内蔵マイク
AWS t4g.small Mem2GB
ap-northeash-1(東京リージョン)
環境 Chrome97 Python3.8 aioquic

データグラムとストリームでの違い。
ストリームではサーバーの負荷が若干高くなるようです。

_ datagram stream
クライアントCPU負荷 9.0%程度 10%程度
サーバーCPU負荷 4 - 15%程度 15 - 25%程度
RTT 15ms程度 15ms程度
損失率 0.05% - 1%程度 なし

エンコード形式ごとの負荷(1080p)

_ CPU デコード遅延 備考
VP8 40% 数フレーム程度 概ね問題なし
VP9 65% 30フレーム以上 エンコード・デコードにかかる遅延が大きい
キーフレームのたびに引っかかる
H264 15% なし ハードウェア支援が効くので軽い
AV1 未対応 _ _

結局WebRTCと比べると? 本当に低レベル(悪い意味ではない)

WebRTCは一通り必要な機能がセットで実装されている高レベルAPIなため、ビデオチャットをするならWebRTCで十分かと思います。
WebTransportとWebcodecsは低レベルAPIのため、今までのブラウザではあって当たり前だったものや適当に書いても安全に動くと言ったことがなくなりつつあります。

機能的な違いについては前回までに色々と書いたので、今回はその辺りの低レベルならではの泥臭いところを色々書いていきます。

ないものを書いてみると

ないもの 対策しないとどうなるか 今回の対策
エコーキャンセラーがない すぐハウリングする
マイクのハウリング抑制機能は一応働く
今回は何もしない
動画のエンコード・デコードを自分でしないといけない そもそもWebTransportは送受信プロトコル Webcodecsでやる
エンコーダ・デコーダのエラー対策をしないといけない エラーコールバックを呼び出して停止する デコーダを再実行しないといけないので地味に面倒
エラーを無視する設定があるかも?
(必要なら)受信動画のバッファリングをしないといけない 低負荷だと問題ないが高負荷だと再生速度が遅くなったり速くなったりする 今回は何もしない
パケロスの修復を自分でしないといけない 緑や紫のブロックノイズが出る
数パケット撮りこぼすだけでもいかにも壊れてますという見た目になる
今回は0で埋める
キーフレームの再送処理要求などを適切にしないといけない デコードエラーの後に次のキーフレームまで固まる 今回は150フレームごとにキーフレームを生成しているので次のキーフレームまで待つ
ブラウザ組み込みの統計データが見れない そのうち実装されるはず フレーム送信時にタイムスタンプを埋め込み、受信時に現在との差分を取る
ワーカーに処理を分けないといけない メインスレッドが重くなる WebWorkerに切り出した
微妙に痒いところが出てくるものの、むしろ分けた方が処理が書きやすい
非同期処理を意識しないといけない データの送受信がぐちゃぐちゃになって想定と違ったり、無限ループに入る 適切にawait入れたり、中断処理に気を配った
(久々に無限ループでchromeをクラッシュさせた)

要するにRTPやRTCPでやってくれている処理がまるっとない感じです。
この辺りはマスタリングTCP/IP RTP編に色々と記載されているようです。

じゃあ逆に何のメリットがあるのかというと

  • 低遅延!
  • サーバー設置が楽!
  • 柔軟性

この辺りではないかと思います。
特にサーバー側はPythonスクリプト起動して443(もしくは4433)を開けるだけなので圧倒的に楽です。

実装で工夫した点について

ブラウザでのプログラミングってこんなに難しかったっけ?
と思うくらい低レベルで楽しいのでその辺りについて書いておきます。

VP9 H264でエンコード・デコードしよう

Webcodecsの概要については前回書いたので、適宜そちらもご覧ください。

マニュアルによるとcodecの指定はconfigure()メソッドで行うようです。
なのでVP8でエンコードする場合はこのように指定するだけです。

  encoder.configure({
    codec: "vp8",
    width: video.width,
    height: video.height,
    latencyMode: "realtime",
  });

ではVP9やH264も同じように codec: "vp9"とか codec: "avc1"とか書くだけなように思うじゃないですか。
でも残念ながら違うようなのです。
マニュアルにはdescriptionを指定しろとあり、その形式はどうやらこういう感じのようです。
なので、とりあえずcodec: "avc1.64001E" と指定してみたのですが、なぜかデコーダーにキーフレームがないと怒られ、先のサイト様をよくみてみるとAnnex Bという形式にしないとメタデータがないそうで、そのオプションをつけることでうまく動作しました。

また、VP9についてはchromiumのテストコードにパラメータ令を見つけることができました。
この辺りはまだほとんど情報がないので苦労するところです (動画の勉強すれば良いだけな気もしますが)

なので実際はこうなります。

client_worker.js
  encoder.configure({
    codec: (video.codec === 'vp8' ? 'vp8'
      : video.codec === 'vp9' ? 'vp09.00.10.08'
      : video.codec ===  'h264' ? 'avc1.64001E'
      : 'vp8'
    ),
    avc: (video.codec === 'h264'
      ? { format: "annexb" }
      : undefined
    ),
    width: video.width,
    height: video.height,
    latencyMode: "realtime",
  });

// デコーダの設定も全く同じ

パラメータが何を示しているのかはまだみてません。
なお、音声はOpusで特に良いような気もするので他のフォーマットはあまり試していません。

デコードエラーから復旧しよう

さて、エンコーダは基本的にカメラからの入力なので問題ないですが、
デコーダはデータの受信タイミングやデータの欠損などがあり、ストリームを使っていてもタイミングの問題などでエラーになることがあります。
デコーダがエラーを出力するとコールバックが呼ばれ、デコーダはその後処理を行なってくれなくなります。

client_worker.js
// 普通に設定すると、、、
  const decoder = new VideoDecoder({
    output: (frame) => {
     rameWriter.write(frame);
     decodedFrameCount++
    },
    error: () => {console.log("デコードエラー")},
  });
  decoder.configure(...);

  // 受信ループ
  (async () => {
  while(true) {
    // ... 1フレーム受信してデータを結合する
    const payload = ...;

   // 受信データからchunkを作成し
      const chunk = new EncodedVideoChunk({
        type: (type === 1 ? 'key' : 'delta'),
        timestamp: Number(view.getBigInt64(9)),
        duration: Number(view.getBigInt64(17)),
        data: new DataView(payload, 25),
      });

    // デコードするも、ここでずっとエラーが出続ける
    decoder.decode(chunk);

    // 次のフレームへ
  }
  })()

エラーを無視したり、リセットする方法があれば良いのですが、見当たらなかったのでエラーのコールバックに復旧用の処理を入れます。

// VideoEncoderのコンストラクタに指定するerrorハンドラから new VideoEncoder() したいので無理やりやる
// ちゃんとやるならメッセージパッシング方式にしてどこかでちゃんとステータス管理するのが良いかも

    let decoder = null;
    const newVideoDecoder = (frameWriter, onerror) =>
       new VideoDecoder({
        output: (frame) => {
          frameWriter.write(frame);
          decodedFrameCount++
        },
        error: onerror,
      });
    const onerror = (e) => {
      wait_keyframe = true;
      console.log(e);

      // ここの処理で受信ループのデコーダをリセットする
      decoder = newVideoDecoder(frameWriter, onerror);
      decoder.configure(...);
    };
    decoder = newVideoDecoder(frameWriter, onerror);
    decoder.configure(...)

  // 受信ループ
  async () => {
    while(true) {
      ...
      decoder.decode(chunk); // onerrorの中でリセットされる
      ...
    }
  }()

これでデコードエラーでもデコーダのリセットができます。

キーフレームを待ち受けよう

デコードを開始したり、リセットした後にはまずキーフレームが必要になります。
今回は150フレームに1フレームを送信するので、それまでデコードせずにスキップします。
本来はこのような場合、キーフレームを送信するように要求したほうが良いです。
また、キーフレームの間隔については短すぎるとデータ量が増えてしまい、VP9では負荷が高くなりますし、逆に長いと欠損時のズレがだんだん大きくなってしまいます。

キーフレームはエンコード時に設定します

client_worker.js

let wait_keyframe = true;

// カメラのストリームからフレームを受け取る処理
async function sendVideo(video, sendtype) {
  ...
  whiel(true) {
      const {value, done} = await frameReader.read();
      if (done) {
        self.postMessage("camera stream ended.");
        break;
      }
      var frame = value;
      // ここで150フレームに1フレーム、キーフレームを作成する
      encoder.encode(frame, {keyFrame: (frameCount % 150 == 0 ? true : false)});
      frame.close();
  }
}

// 受信処理
async function recvVideo(video, sendtype) {

    // 1フレーム分のデータを受信した時のコールバック
    const onframe =  (payload) => {
      // 受信データからchunkを作成
      let chunk = ...;

      // キーフレームが来たらフラグを消す
      if (wait_keyframe && type === 1) {
        self.postMessage(`Video: Received key frames. latency: ${latency}, last frame = ${frameCount - decodedFrameCount}, size: ${chunk.byteLength}, time: ${chunk.timestamp}, duration: ${chunk.duration}`);
        wait_keyframe = false;
      }

      // キーフレームが来るか、30フレーム以上たまったら読み飛ばす
      if (!wait_keyframe && (frameCount - decodedFrameCount < 30)) {
        try {
          decoder.decode(chunk);
        } catch(e) {
          onerror(e);
        }
      } else {
        // skip frame and wait next key frame.
        console.log(`skip frame ${wait_keyframe} ${frameCount} ${decodedFrameCount}`);
        decodedFrameCount++;
        wait_keyframe = true;
      }
    }
}

この辺りのエラー時のキーフレームの再送要求を素早く実現したり、
たまったフレームをクリアしたり低フレームレートになるように適度にスキップしたり、
この辺りで品質に差が出そうです。

パケロスに対応しよう

ストリームでは特に気にしなくて良いですが、データグラムでは到着や順序が保証されません。
なので、1000バイトごとにデータを分割して送信しますが、その際にフレーム番号とパケット番号をつけて管理します。
既存のライブラリでシリアライズしたほうが良いですが、今回は調査のために自前でデータをやりくりします。

async function sendDatagram(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);

  // そして1000バイトずつデータを送信します
  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;
  }
}

そしてデータ受信時にデータが揃っていなければとりあえず0データで埋めます

client_worker/js
// バッファにあるデータから1フレーム分のデータを結合する処理
function concatFrame(buffer, size, count,  frame_number, onframe) {
  const buf = buffer[frame_number];
  let length = size[frame_number];
  if (length === undefined || !buf) {
      self.postMessage(`skipped frame ${frame_number}`);
      return;
  }
  // フレームの全体の長さがわからない場合は、欠損率は5%くらいと考える ←おそらく25%くらいでみた方が良いかも?
  if (length === 0) {
    length = parseInt(buf.length * 1.05 * 1000);
  }

    // データを結合する
    let payload = new Uint8Array(length);
    let pos = 0;
    for (let i = 0; pos < length; i++) {
      try {
        // データがあればそれを使う。なければ0で埋める
        if (i in buf) {
          payload.set(new Uint8Array(buf[i]), pos);
          pos += buf[i].byteLength;
        } else {
          // データがない、パケロスした
          packet_lost++;
          self.postMessage(`packet lost frame ${frame_number}, packet ${i}. ${packet_lost} ${(packet_lost)/(packet_lost + packet_recv)}`);
          let dummy = new ArrayBuffer(pos + 1000 < length ? 1000 : length - pos);
          pos += dummy.byteLength; // これを一行したに書くと無限ループすることがある (次の行でエラーがキャッチされるのにposが進まないため)
          payload.set(new Uint8Array(dummy), pos);
        }
      } catch(e) {
        console.log(e); // 最初のパケットで全体の大きさが分かってないとout of rangeにu.
      }
    }

    // データを処理する
    onframe(payload.buffer);
}

ちなみに、マスタリングTCP/IP RTP編にも記載されていたのですが、
パケットロスは局所的に連続して起こることが多く、
今回試していても、パケットロス自体は全体の0.05%程度でしたが、1フレーム10パケットのうち4パケット連続でロストすることもありました。
送信するデータの順番をランダムにすることで影響を抑える方法もあるようです。

タイムスタンプでレイテンシーを測ろう

画面上でのレイテンシーはスクショの通り53msでしたが
本当にそんなに低遅延なのでしょうか?

実際の遅延は色々な要素が絡みます。
デバイスの遅延は多少意識することがあるかもしれませんが、やはりエンコード・デコードと通信の遅延が大きいところです。
エンコード・デコードについてはデータがそのうち出てくるでしょうから、今回は通信部分を計測します。

ここでは1フレームの往復にかかる時間を計測してみます。
単純なRTTを計測するだけなら ping で良いですが、
動画であれば1フレームのデータが揃わないとどうしようもないのでその時間を計測します。

まずは送信時にタイムスタンプを埋め込みます。
chunkのタイムスタンプと名前が被りますが、フレームタイプの次の8バイトにミリ秒のタイムスタンプを入れます。
Date.now()でミリ秒のタイムスタンプが取得できるのでそれを利用します。
(Firefox60から精度が2ミリ秒程度になったとMDNに記載がありますがchromeはどうなんでしょう。)

client_worker.js
async function sendVideo(video, sendtype) {
  let encoder = new VideoEncoder({
      output: (chunk) => {
        // エンコード処理のコールバック

        if (stopped) {
          return;
        }

        // フレームを組み立てる
        // header(25) = type(1byte) + timestamp(8) + timestamp(8) + duration(8)
        let payload = new ArrayBuffer(25 + chunk.byteLength);
        const view = new DataView(payload);
        view.setUint8(0, (chunk.type === "key" ? 1 : 2));
        view.setBigInt64(1, BigInt(Date.now())) // タイムスタンプをここに入れる
        ...
        chunk.copyTo(new DataView(payload, 25)); // chunkデータ
        
        // フレームを送信する
        (sendtype === 'stream'
          ? sendStream(wt_video, payload)
          : sendDatagram(datagramWriter, encodedFrameCount, payload)
        );
    },
    error: (e) => {...},
  })

  // カメラからフレームを取得してエンコードする
  ...
}

受信側では1フレーム分のデータを組み立て、デコードする際に現在時刻と比較してレイテンシーを表示します。

client_worker.js
async function recvVideo(video, sendtype) {
   ... // デコーダの設定など

    // フレームデータを受信した時のコールバック
    const onframe =  (payload) => {
      // payloadからデータを復元する
      // header(25) = type(1byte) + timestamp(8) + duration(8)
      let view = new DataView(payload, 0);
      const type = view.getUint8(0);
      const latency = Date.now() - Number(view.getBigInt64(1)); // 現在時刻との差を計算する

     // レイテンシーを表示する
     if (frameCount++ % 30 == 0) {
        self.postMessage(`Video: Received 30 frames. latency: ${latency}, last frame = ${frameCount - decodedFrameCount}, size: ${chunk.byteLength}, time: ${chunk.timestamp}, duration: ${chunk.duration},`);
     }

      // デコードする
      ...
    };
}

これで冒頭のスクショのように、動画・音声それぞれのフレームごとのレイテンシーを計算することができました。

まとめ

ここまで色々調べてきましたが、これで一通りデータグラム・ストリーム・エコー・1対多モデル・エラー処理・エンコード・デコードなど検証できたかなと思います。
さて、いい加減そろそろneqoのデータグラム対応か、quic-goのプルリクがマージされるので
サーバーサイドで何かできないか調べて行こうかと思います。

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5