リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
音声フレームを WebSocket と WebRTC で送り遅延を測る
目標
音声AIが実際にやり取りする16kHz・20msのフレームを作って、WebSocketとWebRTCで送り、往復レイテンシのパーセンタイル・ジッター・再生バッファーのサイズを自分で計算して、2つの伝送路を数字で比べます。
なぜ重要なのか
音声会話の体感品質は、平均レイテンシではなく、最も遅い1%のフレームと再生バッファーが決めます。WebSocketは、サーバー側の実装が簡単でファイアウォールをよく通りますが、TCPなので、一度止まると後ろのフレームがすべて遅れます。WebRTCは、遅れたものを捨てられ、Opusとジッターバッファーを備えていますが、接続の準備が重いです。音声AIコースで、ASR・TTSをこの伝送路の上に載せる前に、どちらがどんな条件で勝つのかを、ここで自分で測った数字で確認します。
ステップ
- WAVを20msのフレームに切る: /root/rt/voice/voice.pyにframes(path, frame_ms=20)を作ってください。waveモジュールで16ビットモノラルのWAVを読み、frame_msの長さのbytesの断片のlistを返します。断片1つのバイト数は、サンプリングレート × frame_ms / 1000 × 2です。最後の断片が足りなければ、0で埋めます。モノラルの16ビットでなければValueErrorです。素材は/opt/fixtures/rt/voice/speech16k.wav(16kHz、3秒)です。
- 実際の時間に合わせて送る: /root/rt/voice/voice.pyにasync send_paced(send, frames, frame_ms, prepare=None)を追加してください。i番目のフレームを、開始時刻 + i × frame_msに合わせて送ります。その時刻になったら、prepareがあればprepare(frame)を呼び出してその結果を、なければframeを、await send(...)に渡します。各フレームを送った時刻を、開始時刻を基準にした秒で入れたlistを返します。採点ツールは、1回に5msかかるprepareを渡します。
- 平均ではなく、パーセンタイルとジッターで見る: /root/rt/voice/voice.pyにpercentiles(samples, ps=(50, 95, 99))とjitter(transit_ms)を追加してください。percentilesは、最近傍順位(nearest-rank)方式で、pごとに、並べ替えた値のceil(p/100 × n)番目を選んで{p: 値}のdictを返し、samplesが空ならValueErrorです。jitterは、RFC 3550の到着間隔ジッターの推定器で、連続した2つのパケットの転送時間の差Dの絶対値で、J = J + (|D| - J) / 16を繰り返した最終的なJを返します(Jは0から始まり、値が1つだけなら0)。
- WebSocketで送って往復を測る: /root/rt/voice/voice.pyにasync ws_measure(url, frames, frame_ms)を追加してください。urlにつながり、フレームごとに、前にstruct.pack("!IQ", 連番, time.monotonic_ns())の12バイトを付けて、send_pacedで送り、エコーを受け取るたびに、現在の時刻とヘッダーの時刻との差で、往復のミリ秒を数えます。全部送ったあと、1秒以内に来なかったものは、失ったものとみなします。{"sent": 送った数, "received": 受け取った数, "rtt_ms": 受け取った順の往復のlist, "p50": 値, "p99": 値}を返します。そのあと、/opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py wsを実行して、出力のws_p50_ms・ws_p99_ms・ws_stall_p99_msの3行を、/root/rt/voice/report.txtに書いてください。
- WebRTCデータチャネルで同じことをする: /root/rt/voice/voice.pyにasync rtc_measure(frames, frame_ms)を追加してください。1つのプロセスの中に、ICEサーバーのリストを空にしたaiortcのピアを2つ作ってオファー・アンサーを直接渡し、オファー側がordered=False、maxRetransmits=0のデータチャネルを開きます。アンサー側は受け取ったメッセージをそのまま返し、オファー側は、ws_measureと同じヘッダー・同じ方式で測って、同じ形のdictを返します。そのあと、/opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py rtcを実行して、rtc_p50_ms・rtc_p99_msの2行を、report.txtに追加してください。
- Opusメディアトラックで音を送る: /root/rt/voice/voice.pyにasync rtc_audio(path, seconds=2.0)を追加してください。aiortc.MediaStreamTrackを継承したオーディオトラックが、frames(path, 20)の先頭seconds秒を、20msごとにav.AudioFrame(s16、mono、16000Hz、ptsはサンプル単位)として出すようにし、同じプロセスの2つのピアの間で、addTrackで送ります。受け取る側は、trackイベントで受け取ったトラックで、recvを呼び続けて、フレーム数とサンプリングレートを数えます。{"codec": アンサーSDPのオーディオrtpmapの最初のコーデック(例: opus/48000/2), "sent": 送ったフレーム数, "received": 受け取ったフレーム数, "sample_rate": 受け取ったフレームのサンプリングレート, "duration": 受け取った最初のフレームから最後のフレームまでの秒}を返します。
- 再生バッファーをどれだけにするか計算する: /root/rt/voice/voice.pyにplayout(arrivals_ms, frame_ms, buffer_ms)とmin_buffer(arrivals_ms, frame_ms, max_late)を追加してください。arrivals_msは、連番iの到着時刻(ミリ秒、失っていればNone)です。再生の基準点は、最初に到着したフレームkの到着時刻から、k × frame_msを引いた値で、i番目のフレームの再生の締め切りは、基準点 + buffer_ms + i × frame_msです。playoutは、{"late": 締め切りより遅く到着した数, "lost": Noneの数}を返します。min_bufferは、0から10msずつ増やしていき、遅い数 ÷ 到着した数がmax_late以下になる、最も小さいbuffer_msを返します(1000までなければNone)。
参考
- 作業フォルダは/root/rt/voiceです。mkdir -p /root/rt/voiceで、先に作ってください。
- 測定ツールは、/opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.pyのwsとrtcで、自分の関数を呼び出して使います。エコーサーバーと止まる中継器は、測定ツールが自分で起動します。
- 素材のWAVは、/opt/fixtures/rt/voice/make_wav.pyが決定的に作った、16kHzモノラル3秒のファイルです。人の声ではなく、倍音と音節のエンベロープで真似た音です。
- よくあるミスが2つあります。送るたびにframe_msだけ眠って、準備時間が累積することと、オーディオトラックのrecvが眠らず、数秒分を一瞬で出してしまうことです。
- Pythonは、必ず/opt/rt-lab/bin/pythonで実行します。このラボのライブラリは、その仮想環境にだけ入っていて、普通のpython3で実行すると、ModuleNotFoundErrorが出ます。alias rpy=/opt/rt-lab/bin/pythonのように短くしておくと便利です。
- ラボのPodは、外へ出る接続が塞がれています。すべての通信は、同じPodの中の127.0.0.1で行われ、インストールやダウンロードは必要ありません。
- 採点ツールは、コードを別プロセスで読み込んで、実際に接続を張ってみます。例のファイルは関数の枠にすぎないので、そのままでは合格しません。前のステップで完成させた関数は、消さないでください。
- ラボのセッションが終わると、/rootのファイルは残りません。必要なコードは、終える前に別に保管してください。
WAVを20msのフレームに切る
/root/rt/voice/voice.pyにframes(path, frame_ms=20)を作ってください。waveモジュールで16ビットモノラルのWAVを読み、frame_msの長さのbytesの断片のlistを返します。断片1つのバイト数は、サンプリングレート × frame_ms / 1000 × 2です。最後の断片が足りなければ、0で埋めます。モノラルの16ビットでなければValueErrorです。素材は/opt/fixtures/rt/voice/speech16k.wav(16kHz、3秒)です。
音声コーデックとASRは、ほとんどが10・20・30ms単位で音を受け取ります。16kHzで20msは、320サンプル、640バイトです。最後の断片を捨てると、語尾が切れます。
実際の時間に合わせて送る
/root/rt/voice/voice.pyにasync send_paced(send, frames, frame_ms, prepare=None)を追加してください。i番目のフレームを、開始時刻 + i × frame_msに合わせて送ります。その時刻になったら、prepareがあればprepare(frame)を呼び出してその結果を、なければframeを、await send(...)に渡します。各フレームを送った時刻を、開始時刻を基準にした秒で入れたlistを返します。採点ツールは、1回に5msかかるprepareを渡します。
送るたびにframe_msだけ眠ると、準備に使った時間が毎回足されて、50フレームで数百msずれます。締め切りの時刻を絶対時刻で計算して、残った分だけ眠ってください。一度にまとめて送ってもいけません。受け取る側のバッファーがあふれます。
平均ではなく、パーセンタイルとジッターで見る
/root/rt/voice/voice.pyにpercentiles(samples, ps=(50, 95, 99))とjitter(transit_ms)を追加してください。percentilesは、最近傍順位(nearest-rank)方式で、pごとに、並べ替えた値のceil(p/100 × n)番目を選んで{p: 値}のdictを返し、samplesが空ならValueErrorです。jitterは、RFC 3550の到着間隔ジッターの推定器で、連続した2つのパケットの転送時間の差Dの絶対値で、J = J + (|D| - J) / 16を繰り返した最終的なJを返します(Jは0から始まり、値が1つだけなら0)。
リアルタイムチャネルでユーザーが感じるのは、平均ではなく、たまに来る長いレイテンシです。1%のフレームが300ms遅れると、平均はほとんど変わりませんが、会話は途切れます。ジッターは、到着がどれだけ不揃いかを表し、再生バッファーのサイズを決める根拠になります。
WebSocketで送って往復を測る
/root/rt/voice/voice.pyにasync ws_measure(url, frames, frame_ms)を追加してください。urlにつながり、フレームごとに、前にstruct.pack("!IQ", 連番, time.monotonic_ns())の12バイトを付けて、send_pacedで送り、エコーを受け取るたびに、現在の時刻とヘッダーの時刻との差で、往復のミリ秒を数えます。全部送ったあと、1秒以内に来なかったものは、失ったものとみなします。{"sent": 送った数, "received": 受け取った数, "rtt_ms": 受け取った順の往復のlist, "p50": 値, "p99": 値}を返します。そのあと、/opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py wsを実行して、出力のws_p50_ms・ws_p99_ms・ws_stall_p99_msの3行を、/root/rt/voice/report.txtに書いてください。
送る仕事と受け取る仕事を1つのループで交互に行うと、受け取りを待つあいだ、次のフレームが遅れます。受け取る側を、asyncioのタスクとして別に置いてください。benchの2番目の測定は、TCPが一度300ms止まる場合です。失ったものがないのにp99が跳ねる理由を、説明できなければなりません。
WebRTCデータチャネルで同じことをする
/root/rt/voice/voice.pyにasync rtc_measure(frames, frame_ms)を追加してください。1つのプロセスの中に、ICEサーバーのリストを空にしたaiortcのピアを2つ作ってオファー・アンサーを直接渡し、オファー側がordered=False、maxRetransmits=0のデータチャネルを開きます。アンサー側は受け取ったメッセージをそのまま返し、オファー側は、ws_measureと同じヘッダー・同じ方式で測って、同じ形のdictを返します。そのあと、/opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py rtcを実行して、rtc_p50_ms・rtc_p99_msの2行を、report.txtに追加してください。
同じプロセスなので、シグナリングサーバーは必要ありません。一方のピアのlocalDescriptionを、もう一方のピアのsetRemoteDescriptionに、そのまま渡せばよいのです。チャネルが開く前に送ると消えます。
Opusメディアトラックで音を送る
/root/rt/voice/voice.pyにasync rtc_audio(path, seconds=2.0)を追加してください。aiortc.MediaStreamTrackを継承したオーディオトラックが、frames(path, 20)の先頭seconds秒を、20msごとにav.AudioFrame(s16、mono、16000Hz、ptsはサンプル単位)として出すようにし、同じプロセスの2つのピアの間で、addTrackで送ります。受け取る側は、trackイベントで受け取ったトラックで、recvを呼び続けて、フレーム数とサンプリングレートを数えます。{"codec": アンサーSDPのオーディオrtpmapの最初のコーデック(例: opus/48000/2), "sent": 送ったフレーム数, "received": 受け取ったフレーム数, "sample_rate": 受け取ったフレームのサンプリングレート, "duration": 受け取った最初のフレームから最後のフレームまでの秒}を返します。
データチャネルはバイトを運び、メディアトラックは、コーデックとRTPタイムスタンプとSRTPを経て、音を運びます。WebRTCのOpusは、入力が16kHzでも、SDPには常にopus/48000/2と書かれ、受け取る側は48kHzでデコードして出します。ASRの前で、また16kHzに下げる必要があるという意味です。recvが自分で眠らないと、3秒分が一瞬で出ていきます。
再生バッファーをどれだけにするか計算する
/root/rt/voice/voice.pyにplayout(arrivals_ms, frame_ms, buffer_ms)とmin_buffer(arrivals_ms, frame_ms, max_late)を追加してください。arrivals_msは、連番iの到着時刻(ミリ秒、失っていればNone)です。再生の基準点は、最初に到着したフレームkの到着時刻から、k × frame_msを引いた値で、i番目のフレームの再生の締め切りは、基準点 + buffer_ms + i × frame_msです。playoutは、{"late": 締め切りより遅く到着した数, "lost": Noneの数}を返します。min_bufferは、0から10msずつ増やしていき、遅い数 ÷ 到着した数がmax_late以下になる、最も小さいbuffer_msを返します(1000までなければNone)。
再生バッファーは、遅れて来たフレームを待ってくれる代わりに、すべての音をその分遅らせます。小さすぎると途切れ、大きすぎると会話がもたつきます。ジッターが大きいネットワークほど、同じ途切れの割合を守るのに、より大きなバッファーが必要です。