WebTransportでもWarp(超低遅延配信)がしたい!

# 1秒未満の超低遅延配信が見れます
[デモサイト](https://demo-webtrans.tokishirazu.llc/warp-demo/index.html)、最新のChromeでのみ動きます。[Big Buck Burmy](https://peach.blender.org/)をストリームで繰り返し配信しています。
[ソースコード](https://github.com/alivelime/sample_webtransport/tree/master/warp-demo) : サーバー側のソースはGoのみです。(aioquicがやっぱり不安定?) 環境構築周りのファイルはないです

既知の問題
- ビットレートは500kです (GPU付きインスタンスは高いから仕方ないよね)
- 同一のブラウザで複数開くと挙動がおかしくなるかもしれません 

<img width=720 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91710/8ab261f7-41e7-737d-2555-bc10292f1ae4.png" />

# Warp is なんぞや?
Warpとはtwichが提唱するメディア配信プロトコルです。
現在はIETFの[ドラフト](https://www.ietf.org/id/draft-lcurley-warp-00.html)が提出されています。
[こちらの方のブログ](https://asnokaze.hatenablog.com/entry/2022/02/12/005150)が大変参考になります。

ざっくりと説明すると
- 細切れ(今回は500ms)動画や音声を1ストリーム1セグメントで配信する
- ストリームは並行して送受信てギルので、あるストリームが詰まってしまっても次のストリームを受信したらそこまでスキップされば良い。
- 音声を優先して受信する
というところかと思います。

```plantuml
left to right direction

actor user
actor server

[音声セグメント1_500ms] as a_seg_1
[動画セグメント1_500ms] as v_seg_1
[音声セグメント2_500ms] as a_seg_2
[動画セグメント2_500ms] as v_seg_2

user <-- v_seg_1
user <-- a_seg_1
user <--- a_seg_2
user <--- v_seg_2

a_seg_1 ---- server: ストリーム1
v_seg_1 ---- server: ストリーム2
a_seg_2 --- server: ストリーム3
v_seg_2 --- server: ストリーム4


```

なぜQUICなのかというと
- UDPではパケロスしたり破損したりするのでエラー処理が大変
- TCPだと上記の問題はないものの、正しい順番でパケットを受け取るまで後続のデータを受信しない
- かと言って複数のTCPコネクションを貼るにはハンドシェイクなどの負荷が高い

というところで、そこをうまくやってくれるのがQUICのストリームということです。

# 今までの低遅延配信との違い

現在のライブ配信では数秒程度のセグメントファイルをHTTP/1.1 または HTTP/2で受信していますが、それをHTTP/3にして、さらにWebTransportにします。

```plantuml
left to right direction

package "今回のやつ(HTTP/3)\nコネクションは一つで済むし細かいデータを細かく送れる\nあとポーリングが不要" as http_3 {
  actor user3
  [CDN] as server3
  node "一つのQUICコネクション(UDP)" as quic {
    [segment1.mp4\n0.5秒] as segment5
    [segment2.mp4\n0.5秒] as segment6
    [segmentN.mp4\n0.5秒] as segmentN3

    user3 <-- segment5: ストリームで送信
    user3 <-- segment6: ストリームで送信
    user3 <-- segmentN3: ストリームで送信
    segment5 -[hidden]le- segment5
    segment6 -[hidden]le- segmentN3
  }
  segment5 <-- server3
  segment6 <-- server3
  segmentN3 <-- server3
}

package "今まで(HTTP/2)\nストリームで多重化できるがTCPで詰まる" as http_2 {
  actor user2
  [CDN] as server2
  node "一つのTCPコネクション" as tcp {
    [パケロスによるブロック] as packet_lost

    [segment1.mp4\n数秒の長さ] as segment3
    [segment2.mp4\n数秒の長さ] as segment4
    [segmentN.mp4\n数秒の長さ] as segmentN2

    packet_lost <-- segment3
    packet_lost <-- segment4
    packet_lost <-- segmentN2

    segment3 <-- server2
    segment4 <-- server2
    segmentN2 <-- server2

    segment3 -[hidden]le- segment4
    segment4 -[hidden]le- segmentN2
  }
  user2 <-- packet_lost: 結局TCPで詰まる!

}

package "今まで(HTTP/1.1)\n低遅延にすると細かい通信が必要" as http_1_1 {
  actor user1
  [CDN] as server1
  [segment1.mp4\n数秒の長さ] as segment1
  [segment2.mp4\n数秒の長さ] as segment2
  [segmentN.mp4\n数秒の長さ] as segmentN

  user1 <-- segment1
  user1 <-- segment2
  user1 <-- segmentN
  segment1 <-- server1: HTTP/1.1コネクション
  segment2 <-- server1: HTTP/1.1コネクション
  segmentN <-- server1: 都度コネクションを貼る

  user1 ---> server1: プレイリストのポーリングとリクエスト
  segment1 -[hidden]le- segment2
  segment2 -[hidden]le- segmentN
}
```

# つくり
今回はEC2とGPU付きGKEに環境を構築し、それぞれMP4を読み込んで延々と配信するパターンとSRTで受信したものを配信するパターンを作りました。
GKEやGPUを使ったハードウェアエンコードについては本題とずれるので割愛します。
(まあ、環境構築はなかなか骨が折れますが、一度作って仕舞えば楽。あとはSSL証明書の管理だけなんとかならないものか。。)


## パターン1 : ファイルからストリーミング
動画ファイルをffmpegで読み込み、それをHLSとして500msのセグメントファイルに書き出します。(ついでに現在時刻を動画に描画しておきます)
そしてファイルの書き出しを検知してサーバープログラムで読み取り、1セグメント1ストリームで配信していきます。
今回は0.5秒単位のセグメントファイルを書き出しているため、遅延は600ms - 1000ms程度になります。

```plantuml
left to right direction

actor user

node brwoser {
  [<video>] as video
  [VideoDecoder] as v_dec
  [AudioDecoder] as a_dec
  [WebTransport] as wt_connection
  v_dec <-- wt_connection
  a_dec <-- wt_connection
  video <-- v_dec: MediaStreamTrack
  video <-- a_dec: MediaStreamTrack
}
user <-- video
node server {
  node wt_server {
    [watch dog] as watcher
    [sender]
    [QUIC Server] as q_server
    q_server <- sender
  }
 [ffmpeg]
  folder movie {
    [bbb.mp4] as bbb
  }
  folder media {
    [動画セグメント1] as v_seg
    [音声セグメント1] as a_seg
  }
  v_seg <-- ffmpeg: 書き込み
  a_seg <-- ffmpeg: 0.5秒ごと
  ffmpeg --> bbb: 読み込む

  sender --> v_seg: 読み込む
  sender --> a_seg:  読み込む
  watcher -[hidden]- v_seg
  watcher --> media: 更新チェック
}

wt_connection --> q_server: 接続する
wt_connection <-- q_server: メディアデータ
```

### 動画的なこと
ffmpegのソフトウェアエンコードでのパラメータは以下のようになっています。
ハードウェアエンコードするとより負荷が少なくなりますし、多少遅延も少なくなります。


```shell
ffmpeg -re -stream_loop -1 -i ../movie/bbb_sunflower_1080p_30fps_normal.mp4 \
    -vf "drawtext=fontfile=../movie/arial.ttf: text='%{localtime\:%X}_%{frame_num}': r=24: fontsize=60: fontcolor=white: x=(w-tw)/2: y=h-(2*lh): box=1: boxcolor=0x00000000@1" \
    -ac 2 -acodec aac -vcodec libx264 -threads 2 -b:v 500k \
    -r 30 -seg_duration 0.5 -g 15 -x264-params open-gop=0 -refs 0 -bf 0 \
    -sc_threshold 0 -b_strategy 0 -strftime 1 -use_template 1 \
    -window_size 5 -hls_playlist 1 -streaming 1 -remove_at_exit 1 -f dash manifest.mpd
```

## パターン2 : SRTにて配信したものを中継する
パターン1では動画ファイルのストリーミング配信でしたが、こちらでは配信されたものをブロードキャストする方法を検討していきます。
配信者からSRTなどでサーバーに配信し、それをトランスコードせずにflagmented MP4に変換するだけにします。

~~遅延については5秒程度と予想より遅くなりました。~~
そろそろ1秒程度になりそうです!
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">1920 x 1080 h264 30fps 5Mbps + AAC<br>OBSからSRTで打ち上げてgstreamerで再エンコしてWar(WebTransport)でChrome複数タブで視聴するとこまで遅延は1秒ちょい<br><br>映像が詰まった時はスキップ、音声のみモードもつけてみた<br>2時間流してても音飛びやズレは少ないけど回線と時計の影響が大きい <a href="https://t.co/kkVwFUop6B">pic.twitter.com/kkVwFUop6B</a></p>&mdash; alivelime (@purejapaneseonl) <a href="https://twitter.com/purejapaneseonl/status/1568946172831866881?ref_src=twsrc%5Etfw">September 11, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 


```plantuml
left to right direction

actor user
actor publisher

node brwoser {
  [<video>] as video
  [VideoDecoder] as v_dec
  [AudioDecoder] as a_dec
  [WebTransport] as wt_connection
  v_dec <-- wt_connection
  a_dec <-- wt_connection
  video <-- v_dec: MediaStreamTrack
  video <-- a_dec: MediaStreamTrack
}
user <-- video
node server {
  node wt_server {
    [watch dog] as watcher
    [sender]
    [QUIC Server] as q_server
    q_server <- sender
  }
 [ffmpeg]
  folder media {
    [動画セグメント1] as v_seg
    [音声セグメント1] as a_seg
  }
  ffmpeg --> v_seg: 書き込み
  ffmpeg --> a_seg: 0.5秒ごと

  sender --> v_seg: 読み込む
  sender --> a_seg:  読み込む
  watcher -[hidden]- v_seg
  watcher --> media: 更新チェック
}

publisher -----> ffmpeg: 配信
wt_connection --> q_server: 接続する
wt_connection <-- q_server: メディアデータ
```

### 動画的なこと
今回は手元のiMacからffmpegもしくはOBSにてSRTやRTMPにて配信してみました。
SRTのインストールについてはmacはbrewで、ubuntuなどはソースビルドする必要があります。
ffmpegは新しいバージョンであれば対応しているはずなので特にソースビルドは不要でした。

RTMPだとnginxでサーバーを立てないといけないですが、SRTだと `mode=listener` で待ち受けモードにできるのでとても簡単です。
```shell
# サーバーにてSRTを待ち受ける
$ cd ../media && ffmpeg -re -i "srt://:4201?mode=listener&latency=120" \
    -acodec copy -vcodec copy -threads 2 \
    -strftime 1 -use_template 1 -seg_duration 0.5 \
    -window_size 5 -hls_playlist 1 -streaming 1 -remove_at_exit 1 -f dash manifest.mpd

# Macにて配信する
$ brew install srt
# mp4を用意する
$ wget https://mirror.clarkson.edu/blender/demo/movies/BBB/bbb_sunflower_1080p_30fps_normal.mp4

# (arial.ttfをシステムディレクトリからコピーしてきてください。)

$ cd movie/ && ffmpeg -re -stream_loop -1 -i bbb_sunflower_1080p_30fps_normal.mp4 \
    -vf "drawtext=fontfile=arial.ttf: \
        text='%{localtime\:%X}_%{frame_num}': r=24: fontsize=60: fontcolor=white: \
        x=(w-tw)/2: y=h-(2*lh): box=1: boxcolor=0x00000000@1" \
    -ac 2 -acodec aac -vcodec h264_videotoolbox -threads 6 -b:v 500k \
     -r 30 -seg_duration 0.5 -g 15 -x264-params open-gop=0 -refs 0 -bf 0 \
    -sc_threshold 0 -b_strategy 0 \
    -tune zerolatency \
    -f flv  \
    "srt://{your host ip address}:4201?pkt_size=1316"
```

# プログラム的なお話

WebTransportやWebCodecsそのものについては前回の記事などで解説していますので、今回はWarpの特徴的なところのみ記載してみます。
とは言っても、1セグメントを1ストリームで受信するというだけなので概念としてはわかりやすいかと思います。
ただし実装してみると動画と音声のタイミングを合わせるところやバッファリングをするのかしないのか、もしくは遅延に対してどう処理するか、と言ったことがたくさん出てきます。

次のコードはストリーム受信の処理です。init もしくは segment を示すJSONデータを受け取ったのちにメディアデータを受信し、MP4box.jsにてパースしています。

まずは新しいストリームを受信する処理です。
データは最初にそのストリームが何を示すのかJSONに入っていますのでまずはそれをパースします。

```javascript
  async accept() {
    let reader = this.wt.incomingUnidirectionalStreams.getReader()
    let sid = 0
    this.timeVideo = performance.now()
    this.timeAudio = performance.now()
    try {
      while (true) {
        //  console.log("wait stream")
        const {value, done} = await reader.read()
        if (done) {
          console.log("Done accept unidirectional streams.")
          return
        }
        const stream = value;
        // ストリームから読み取り、ここは新しいストリームを優先させるため await しない
        (async () => {
          // console.log("accept stream")
          let segmentType = ""
          const reader = stream.getReader()

          // read message
          const { value, done } = await reader.read()
          if (done) {
            console.log("error: empty stream.")
            return
          }
          ... 受信したデータの処理、下記に続く
        })()
      }
    } catch (err) {
      console.log("accept incoming unidirectional stream. " + err)
    }
  }
```

ちなみにWebSocketでは送信したデータごとに相手の方も一つのデータとして処理できますが、WebTransportではQUIC層にてバッファリングされるため、どこからどこまでがJSONデータなのか長さを入れておく必要があります。

```javascript
          const msg_len = new DataView(value.buffer, 0).getInt8(0)
          // console.log(msg_len)
          console.log(new TextDecoder().decode(value.slice(1, 1 + msg_len)))
          const message = JSON.parse(new TextDecoder().decode(value.slice(1, 1 + msg_len)))

         ... // JSONの内容で処理を分ける。長いので下記に抜粋する

```

さて、続いてメディアデータの処理です。
ポイントとしてはinit.mp4がないと処理ができないため、セグメントデータを受信しつつもinit.mp4を待つ必要があるところくらいでしょうか。
MP4Box.jsに放り込むとフレームごとにデータを取り出してくれます。

```javascript
          if (message.init !== undefined) {
            segmentType = 'init'
            this.id = message.init.id
            // 最初のinitの読み込み完了を待つ
            this.initMP4 = new Promise(async (resolve, reject) => {
              ... // init.mp4読み込むプロミス
            });
            console.log("received init.mp4")
            return
          } else if (message.segment !== undefined) {
            if (this.kind === "video") {
              // console.log("segment " + this.kind + " : " + (timeStart - this.timeVideo))
              this.timeVideo = timeStart
            } else {
              // console.log("segment " + this.kind + " : " + (timeStart - this.timeAudio))
              this.timeAudio = timeStart
            }
            let demuxer = new MP4Demuxer(sid++, await this.initMP4, this.kind, this.onframe, nbSamples);

            demuxer.source.onframe = this.onframe.bind(this);
            segmentType = "segment"

            await demuxer.getConfig().then(async (config) => {
              if (this.kind === 'video') {
                self.postMessage({
                  type: "play",
                  width: config.codedWidth,
                  height: config.codedHeight,
                })
              }

              this.decoder.configure(config);
            });
            demuxer.ondata(value.slice(1 + msg_len, value.byteLength), false)
            demuxer.start()
            while (true) {
              const { value, done } = await reader.read()
              if (done) {
                console.log("stream done " + (sid-1) + " : " + (performance.now() - timeStart))
                break
              }
              demuxer.ondata(value, true)
            }
          } else {
            console.log("unknown type message. ")
            console.log(message)
            return
          }

```

### 描画するところ
動画を再生する際にはフレームレートに合わせてタイマーにてフレームを処理する方法がありますが、今回はライブ配信用途なので遅延したフレームは捨てることにします。
遅延していないフレームは setTimeout()で再生すべき時間になるまで待機させています。
精度はミリ秒単位なので問題ないでしょうし、setTimeout()につまれるフレームは最大15なので多分問題ないとは思います。
追っかけ再生機能とか作るならちゃんとキューイングする必要はありそうです。

```javascript
  onframe(frame) {
    if (!this.firstTimestamp) {
      this.firstTimestamp = frame.timestamp - 1; // 0除算を避けるため1ずらしておく
    }

    const diff = Number(this.timeStart + ((frame.timestamp - this.firstTimestamp) / 1000) - performance.now())

    // フレームが早ければ待つ。フレームレート内のタイミングであれば描画する、遅ければ読み飛ばす
    if (diff > 0) {
      if (this.kind === "video") {
        // console.log(diff)
      }
      setTimeout(
        () => {
          this.frameWriter.write(frame);
          frame.close();
        },
        diff
      )
    } else {
      console.log("frame skipped. " + diff)
      // 描画すべき時刻をすぎている場合は読み飛ばす
    }
  }
```

# 今後の課題とまとめ

超低遅延の配信といえばWebRTCでクラスタを組んだものなどありますが、なかなかスケールしづらいというのが課題でした。
WarpであればCFAMを使うため、DASHやHLSなどの既存のCDNと組み合わせる可能性もあり、QUICのロードバランスやマルチキャスト周りの仕様策定が待たれるところです。