Go + GStreamer でお手軽 WebRTC 体験

f:id:castaneai:20200316212941p:plain

P2Pベースで低遅延で映像や音声を送れるWebRTCという技術がある。 映像というのは目に見えるものなので、コードを書いて映像が動くとテンション上がる。

しかし、その分映像や音声データの取り扱いはむずかしい。 WebRTCもそうで、「へぇ〜こんなことができるのか!」って概要はわかっても、「じゃあ実際に手元で動かしてみよう!」ってなると急に(…どうすれば?)ってなる。

GStreamer でテスト用の動画を作り出す

WebRTCの実例を探すと、ビデオ通話がとても多い。 たしかに低遅延で映像や音声をやりとりする技術なので向いている。

しかし、Webカメラを持ってないかもしれないし、自分の顔が映るのはなんか嫌というのもあって、他の映像を流したくなった。

ということで、WebRTCに流すための映像データをすぐ用意できるのだろうか? WebRTCに映像を流すときは映像の形式(コーデックなど)が決まったものでないといけないので、動画なら何でもいいというわけにはいかない。

そこで便利なのがGStreamer

GStreamerは動画や音声を作り出したり変換したり再生したりと様々なことができる便利ツール。 しかも、Windows/Mac/Linuxの3つのOSどれでも動くのでありがたい。

たとえばMacだとHomebrewですぐに導入できる。

$ brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly

(※badだとかuglyだとか危なそうな名前のプラグインも入れてるが、これはライセンスの問題や実装の不安定さから来てるものらしい。とりあえず個人で試すだけなら気にしなくていいと思う)

インストールしたらとりあえず次のコマンドを打ってみる。カラフルなバーが見えたら成功!

$ gst-launch-1.0 -v videotestsrc ! autovideosink

f:id:castaneai:20200315211213p:plain

このGStreamerのすごいところは、プラグインを入れることで様々な動画形式に対応できるところ。 WebRTCに流すための動画形式ももちろん対応している。

pion/webrtc

テスト用の動画を作る環境が用意できたら、次はいよいよWebRTCの実装の方に目を向ける。

pion/webrtcというWebRTCをGoで実装したライブラリがある。

Goで実装されているのが個人的にすごいうれしい点で、次のようなメリットがある。

WebRTCでH.264のテスト動画を流す

では何より実際に動くものがないと、テンションも上がらないのでまずはざざっと動作するコード例を紹介する。 結構長く見えるかもだけど、Go特有のエラーチェックがほとんどなので処理はそこまで多くない。

$ go mod init ....
$ go get github.com/notedit/gstreamer-go github.com/pion/webrtc/v2
package main

import (
    "encoding/base64"
    "encoding/json"
    "fmt"
    "log"
    "math/rand"
    "os"

    "github.com/pion/webrtc/v2/pkg/media"

    "github.com/notedit/gstreamer-go"

    "github.com/pion/webrtc/v2"
)

func main() {
    offerb, err := base64.StdEncoding.DecodeString(os.Args[1])
    if err != nil {
        log.Fatalf("failed to read offer: %+v", err)
    }
    var offer webrtc.SessionDescription
    if err := json.Unmarshal(offerb, &offer); err != nil {
        log.Fatalf("failed to unmarshal offer: %+v", err)
    }

    mediaEngine := webrtc.MediaEngine{}
    if err := mediaEngine.PopulateFromSDP(offer); err != nil {
        log.Fatalf("failed to populate from SDP: %+v", err)
    }

    var payloadType uint8
    for _, videoCodec := range mediaEngine.GetCodecsByKind(webrtc.RTPCodecTypeVideo) {
        if videoCodec.Name == webrtc.H264 {
            payloadType = videoCodec.PayloadType
            break
        }
    }
    if payloadType == 0 {
        log.Fatal("Remote peer does not support H264")
    }

    api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
    pc, err := api.NewPeerConnection(webrtc.Configuration{
        ICEServers: []webrtc.ICEServer{
            {
                URLs: []string{"stun:stun.l.google.com:19302"},
            },
        },
    })
    if err != nil {
        log.Fatalf("failed to new peeer connection: %+v", err)
    }
    pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
        log.Printf("peer state change: %v", state)
    })

    videoTrack, err := pc.NewTrack(payloadType, rand.Uint32(), "video", "pion")
    if err != nil {
        log.Fatalf("failed to new track: %+v", err)
    }
    if _, err = pc.AddTrack(videoTrack); err != nil {
        log.Fatalf("failed to add track: %+v", err)
    }

    // video streaming via GStreamer
    go func() {
        pipeline, err := gstreamer.New("videotestsrc is-live=true ! video/x-raw,format=I420 ! x264enc ! appsink name=app")
        if err != nil {
            log.Printf("failed to new GStreamer pipeline: %+v", err)
            return
        }
        pipeline.Start()
        app := pipeline.FindElement("app")
        for data := range app.Poll() {
            if err := videoTrack.WriteSample(media.Sample{Data: data, Samples: 90000}); err != nil {
                log.Printf("failed to write video data: %+v", err)
            }
        }
    }()

    if err := pc.SetRemoteDescription(offer); err != nil {
        log.Fatalf("failed to set remote desc: %+v", err)
    }
    answer, err := pc.CreateAnswer(nil)
    if err != nil {
        log.Fatalf("failed to set create answer: %+v", err)
    }
    log.Printf("create answer")
    if err = pc.SetLocalDescription(answer); err != nil {
        log.Fatalf("failed to set local description: %+v", err)
    }

    answerb, err := json.Marshal(answer)
    if err != nil {
        log.Fatalf("failed to marshal answer: %+v", err)
    }
    answerb64 := base64.StdEncoding.EncodeToString(answerb)
    fmt.Println(answerb64)

    select {}
}

コードが書けたら、次の手順でWebRTCを試す!

  • https://jsfiddle.net/z7ms3u5r/ にアクセスする
  • しばらく待ったら上の欄に長い文字列が出るので、それを引数にして go run main.go
  • しばらく待ったらGo側がまた長い文字列を出すので、それをWebブラウザ側の下の欄にコピペ
  • "Start Session" ボタンを押す
  • 映像が流れる!!

f:id:castaneai:20200315221410g:plain

GStreamerからどうやってWebRTCに流しているか

上のコードで重要なのは、GStreamerの映像をWebRTCに流している部分。 つまりこのあたり。

    // video streaming via GStreamer
    go func() {
        pipeline, err := gstreamer.New("videotestsrc is-live=true ! video/x-raw,format=I420 ! x264enc ! appsink name=app")
        if err != nil {
            log.Printf("failed to new GStreamer pipeline: %+v", err)
            return
        }
        pipeline.Start()
        app := pipeline.FindElement("app")
        for data := range app.Poll() {
            if err := videoTrack.WriteSample(media.Sample{Data: data, Samples: 90000}); err != nil {
                log.Printf("failed to write video data: %+v", err)
            }
        }
    }()

GStreamerからは導入直後に試した videotestsrc を使っている。 が、追加でいろいろな要素が増えている。

わかりやすく一要素を一行にすると、次のように4つの要素をつないでWebRTCに動画を流し込んでいる。

videotestsrc is-live=true !
video/x-raw,format=I420 !
x264enc !
appsink name=app

is-live=true

これは videotestsrc のパラメータで、ライブ配信として即座に映像を流すようにする(?)ものらしい。 内部的にどうなってるのかはちょっとわからないけど、これを入れないと映像がかなり遅れてしまうので入れた。

https://gstreamer.freedesktop.org/documentation/videotestsrc/index.html?gi-language=c#videotestsrc:is-live

video/x-raw,format=I420

これが結構厄介なところ。GStreamerでは各要素の入り口と出口に「どんな形式で入出力できるか」というのが決まっている。 パッドとかケイパビリティって呼ばれたりするみたい。

この指定は videotestsrc から映像を出すときは video/x-raw,format=I420 の形式にしてねっていう指定。x-raw というのは、RAW Videoのことで、詳しくは前の記事を参照するとよい。

何も指定しない場合、videotestsrc は次につながる要素である x264enc の入力に合うものを自動的に探して映像を出してくれるはずなのだが、なぜかWebRTC側で映像が出なかった。。

原因不明だけどとりあえず format=I420 にしたら映像が映るようになったので指定した。(なぜなのか・・・・・)

x264enc

RAW Videoを H.264 にエンコードする要素。

appsink name=app

appsinkとは任意のアプリケーションでこの要素に入ってきたデータを取得できるもの。gstreamer-goを使うと次のようにチャネルで映像のデータを直接取ることができる。

app := pipeline.FindElement("app")
<-app.Poll()

x264encのあとにこのappsinkが来てるので、H.264 でエンコードされた映像データがこのappsinkで取り出せる。 これを使ってWebRTCのVideoTrackに H.264 の映像をどんどん流し込んでいる。

トラブルシューティング

MacのChromeで映像が流れない

x264enc を使っていると、MacのChromeで映像が流れない不具合があるみたい。

qiita.com

確かに自分もMac + Chromeでやると映像が流れなかった。 そして記事の通りに key-int-max=1 にしたら直ったので、まだこの不具合はあるっぽい。。

とりあえずMacの場合Safariを使えば解決する。

ブラウザ側で base64 Session Description が出るのが遅すぎる

https://jsfiddle.net/z7ms3u5r/のページで自分が試してみると、base64 Session Description が表示されるまで1分ぐらい待たされた。 さすがに接続初期化のために1分はかかりすぎでは・・?って思って調べたら、WebRTCのICEという初期化段階でいくつかの候補サーバーがタイムアウト?してるみたい。

Getting Timeout and Error with code 701 on clicking Gather Candidates for ICE Server · Issue #1251 · webrtc/samples · GitHub

ただ、その候補を全部待たなくても接続はできるらしいので、応急処置として1秒待ったらその時点ですぐ base64 Session Description を表示するようにしたら正常に動作した。

window.setTimeout(() => {
    document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription));
}, 1000);

このあたりのWebRTCの仕組みがそもそもわかってないので、あとからちゃんと調べたいところ。

(おまけ)Wiresharkで見てみると…

ところで、WebRTCで映像を配信しながらWiresharkでパケットを見てるとRTPパケットがたくさん流れていた。

RTPというと、ちょうど前の記事で話題に上げたプロトコルで、「おお…RTP、こんなところでも活躍していたのか…!!」って感動があった。

f:id:castaneai:20200316211824p:plain