RTMP サーバを実装して遊んでみた話

# RTMP サーバを実装して遊んでみた話

[mikan](https://github.com/monyone/mikan) という RTMP 1.0 サーバを自作してみました!
TypeScript 製で コアは Node.js 非依存ですので、TCP 以外のスタックでも対応できるはず?

その上で、色々遊んでみたので、その記録になります。

https://github.com/monyone/mikan

## なにをして遊んだの?

具体的には、以下の4つをやって遊んでみました!

1. RTMP 1.0 サーバを作って RTMP クライアントと疎通する
2. RTMP で受信した 映像/音声 データを FLV に変換する
3. FLV から MPEG-TS にコンテナを変換する
4. 変換した MPEG-TS の動画データを Apple LL-HLS としてパッケージングして配信する

ffmpeg で RTMP 受けて HLS 出すのを、自分でスクラッチで書いて、LL-HLS 対応もやってみた! っていうものです

ここから、それぞれ 4 つの内容を解説します

## RTMP サーバの作成

### ハンドシェイク

RTMP では接続時にハンドシェイクを TCP とは別途行います。

クライアント側は以下を送ります。

* C0: 1 バイトのバージョン番号が書かれたデータ (RTMP 1.0 は 3)
* C1: timestamp(4byte), zero(4byte), random(1528byte)
* C2: S1のtimestamp(4byte), 自分のtimestamp, S1 の random(1528byte)

サーバ側は以下を送ります。

* S0: 1 バイトのバージョン番号が書かれたデータ (RTMP 1.0 は 3)
* S1: timestamp(4byte), zero(4byte), random(1528byte)
* S2: C1のtimestamp(4byte), 自分のtimestamp, C1 の random(1528byte)

ハンドシェイクの成立条件としては以下になります。
* サーバ側:  S1 の random (自分が送ったデータ) と C2 の random (相手の Echo) が合致する
* クライアント側: C1 の random  (自分が送ったデータ) と S2 の random (相手の Echo) が合致する

また、C0, C1 と S0, S1 はまとめて送ってもよいので、大体は以下のような手順になります

```plantuml
Client -> Server: C0 (1byte)
Client -> Server: C1 (1536byte)
Server -> Client: S0 (1byte)
Server -> Client: S1 (1536byte)
Client -> Server: C2 (1536byte)
Server -> Client: S2 (1536byte)
```

### チャンクのパース

RTMP は チャンク 単位でメッセージを伝達します。
チャンクのペイロードの大きさはデフォルト 128 byte で、コマンドによって後から相手が変えてきます。
(例えば、4500byte に後から大きくなったりとか、そういう事が起こります)

チャンクはヘッダとペイロードにわかれており、ヘッダの長さは fmt によって可変長です。
要するに、チャンク長 128byte の場合には、メッセージが 128 バイトづつ区切られます。
この 128byte で分割されたメッセージがペイロードとなり、それぞれヘッダが付くことになります。
(なので、チャンク長 128 byte といって 128 byte 固定長で処理できるかと言ったら No になります!)

UDP ではなく TCP なので、ストリームデータであり、パケットの結合、分割がありますので、
受信時には、ペイロードが メッセージ長 or チャンク長 まで溜まるまでは、処理できる状態ではない状態です。
誤ってこの時にデータの切れ目を見てチャンク終わりだと思って処理しないようにしましょう。
(ここで誤ると、映像のビットレートが高い時におかしな結果になったりして頭を抱えるので注意です!)

あと、チャンク毎に chunk_stream_id がインターリーブする可能性があるため、
chunk_stream_id 毎にどういう状態だったかを覚えておく必要があります。
そういう実装をしたのが、以下のコード例になりますので、参考にしていただければ。

https://github.com/monyone/mikan/blob/main/core/src/chunk/reciever.ts

### AMF のパース/ビルド

RTMP のメッセージングは、複雑なものだと AMF (Action Message Format) を送ります。
AMF 自体は JavaScript のオブジェクトのバイト列へのシリアライズ方式だと思えばいいです。
(JSON.stringify が無い時代からあるんだっけか...ってなりました)

AMF0 と AMF3 がありますが、大体 AMF0 を使うので、AMF0 を覚えておけばいいでしょう。

細かい説明は長くなってしまうので、シリアライズなんだなと思って、仕様とにらめっこする方が早いです。
自作の AMF0 -> JavaScript オブジェクト, JavaScript オブジェクト -> AMF0 のコードのリンクを貼っておきます。

https://github.com/monyone/mikan/tree/main/core/src/amf0

### ストリームの確立

RTMP はメッセージングプロトコルなので、そのうえで何を伝送するのか、ネゴシエーションする必要があります。
まず NetConnection を Connect で開いて、その上で NetStream Command で映像/音声を送る事をネゴります。

だいたい、こんなメッセージを AMF をチャンクに包んで送ります。

```plantuml
Client -> Server: Connect (1)
Server -> Client: _result (1)
Client -> Server: FCPublish (2)
Server -> Client: _result (2)
Client -> Server: CreateStream (3)
Server -> Client: _result (3)
Client -> Server: Publish (4)
Server -> Client: _result (4)
```

これは、そういう仕様なので、その通りに送りましょう。
ここら辺の話は、以下の Go 実装の説明が非常に参考になるかと思います。

https://developers.cyberagent.co.jp/blog/archives/13739/#flow-after-receiving-command-message

## RTMP から FLV への変換

RTMP の message_type_id が 8, 9 の場合が映像/音声ですが、これは FLV の tag と同じです。
RTMP の映像、音声の中身には FLV の中身が載っているので、
RTMP のメタ情報などを FLV のメタ情報として整形すれば、内容自体はそのままペーストしてしまって OK です。

一応、FLVヘッダを入れるのと、タイムスタンプが 3byte + ext(1byte) である点だけ注意が必要です。
変換はコードを見た方が早いので、ファイルを貼っておきます。

https://github.com/monyone/mikan/blob/main/core/src/chunk/flv.ts

## FLV から MPEG-TS への変換

FLV を MPEG-TS に変換するには以下の手順が必要です。

* AAC を Raw から ADTS に変換する
* H264 を File Format から AnnexB Format に変換する
* 映像/音声のデータを PES に入れて TS パケットに分割して送出する
* PAT, PMT セクション作って TS パケットに分割して送出する
* PCR を構成して TS パケットとして送出する

変換のコードはコア部分ではないため Node.js の Transform で作っていて、こんな感じになっています。
https://github.com/monyone/mikan/blob/main/example/rtmp-to-llhls/src/transform.ts

後半 3 つは特筆すべき事がないので、映像と音声の変換だけ以下で述べます。

### AAC の ADTS への変換

FLV の aacPacketType == 0 の時に　audioSpecificConfig が送られてくるため
ここから ADTS ヘッダを作るために必要な情報を取り出します。

これを用いて、raw AAC データに ADTS ヘッダを付け加えて、PES として送出すれば OK です。

### H264 の AnnexB への変換

FLV の avcPacketType == 0 の時に AVCDecoderConfigurationRecord が送られてくるため、
AVCDecoderConfigurationRecord の naluLengthSize, SPS, PPS を抜き出しておきます。

これを用いて、naluLengthSize の情報から NALu を取り出して、PES として送出すれば OK です。
だいたい I フレームの NALu の前に SPS, PPS があるので、無かったら入れとけばいいです。

また、AUD (Access Unit Delimiter) が PES の最初に無いと読み込まないプレイヤーがあるので
NALu の最初に無かった場合には入れときましょう。

## MPEG-TS を LL-HLS にパッケージングする

これは以下の自作した LL-HLS オリジンサーバに MPEG-TS を入れます。(手前味噌)
これも TypeScript (Node.js 向け) で作ったので、役立つときが来るんだなって感じです。

https://qiita.com/monyo-tech/items/072b6dbcaa58b499eb09

## ツールでの遊び方

1. `git clone https://github.com/monyone/mikan/`
2. rtmp-to-llhls へ移動
3. `yarn` して依存を入れる
4. node ./dist/index.js で RTMP サーバと HTTP サーバを起動
5. RTMP クライアントで `localhost:1935` に RTMP を突っ込む
6. LL-HLS を `http://localhost:8080/manifest.m3u8` で見る

## 終わりに

というわけで、RTMP を受けて実際に LL-HLS で配信するという所まで、スクラッチでやってみました!
RTMP がどういう事やってるのか、ブラックボックスだったのですが、感覚がつかめて、やってよかったです。
RTMPサーバ -> HLS までの経路が、全部、自分の実装で動いているのは、ちょっと感慨深くもあります。

少ない実装量で、動画として見れるものが出来るので、満足感を感じやすいという事もあり
配信プロトコルをスクラッチするのは QoL が上がる! と実感する所です。

## 今後の展望

まだやってないですが、RTMP クライアントも作ろうかなーと思っています。
ブラウザの WebCodecs でエンコードして、その映像の打ち上げを
RTMP on WebTransport (BidirectionalStream) で送ったりする未来もあるかも..?