TT Lab
はじめる
学ぶ 学習パス コース

リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC

音声フレームを WebSocket と WebRTC で送り遅延を測る

TT Labで続きを見る

目標

音声AIが実際にやり取りする16kHz・20msのフレームを作って、WebSocketとWebRTCで送り、往復レイテンシのパーセンタイル・ジッター・再生バッファーのサイズを自分で計算して、2つの伝送路を数字で比べます。

なぜ重要なのか

音声会話の体感品質は、平均レイテンシではなく、最も遅い1%のフレームと再生バッファーが決めます。WebSocketは、サーバー側の実装が簡単でファイアウォールをよく通りますが、TCPなので、一度止まると後ろのフレームがすべて遅れます。WebRTCは、遅れたものを捨てられ、Opusとジッターバッファーを備えていますが、接続の準備が重いです。音声AIコースで、ASR・TTSをこの伝送路の上に載せる前に、どちらがどんな条件で勝つのかを、ここで自分で測った数字で確認します。

ステップ

  1. 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秒)です。
  2. 実際の時間に合わせて送る: /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を渡します。
  3. 平均ではなく、パーセンタイルとジッターで見る: /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)。
  4. 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に書いてください。
  5. 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に追加してください。
  6. 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": 受け取った最初のフレームから最後のフレームまでの秒}を返します。
  7. 再生バッファーをどれだけにするか計算する: /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)。

参考

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)。

再生バッファーは、遅れて来たフレームを待ってくれる代わりに、すべての音をその分遅らせます。小さすぎると途切れ、大きすぎると会話がもたつきます。ジッターが大きいネットワークほど、同じ途切れの割合を守るのに、より大きなバッファーが必要です。