今年の6月から取り組んでいたGoogle Summer of Codeの期間が終わり,OSSの会議ツールであるJitsiで取り組んでいた私のプロジェクトは無事修了した.
プロジェクトの目的はJitsiのSFUサーバーであるVideobridgeにAudio Subscription機能を追加することだった.現状,サーバーでは全参加者の音声ストリームを全参加者に無条件で転送している.映像と違って音声ストリームはさほど帯域を消費しないため特に問題はないが,特定の参加者をミュートしたい,逆に特定の参加者の音声だけを聞きたいといったユースケースにエレガントに対応することはできない(受信した上でクライアントで再生しないという実装はできるが美しくないよね).というモチベーションで,参加者から受け取りたい音声ストリームのSubscriptionを受信しそれに基づいて転送を行う機能追加を3ヶ月間かけて行っていた.
具体的な使い方や実装の話はJitsiの公式ブログで執筆したのでそちらを参照いただきたい.具体的には,以下のような型を定義して参加者のSubscriptionを伝達できるようにした
type ReceiverAudioSubscription = {
mode: "All" | "None" | "Include" | "Exclude";
list?: string[];
};
実装において工夫した点としては,まずサーバーの処理速度を落とさないことである.選択的転送ということはパケット毎にどの参加者がこのパケットを欲しがっているのかを判断する必要がある.しかし参加者一人一人のSubscriptionステートの確認に時間がかかると全体のスループットが低下するため,今回の実装では,データプレーンの参照用に全参加者のSubscriptionステートをMapとして持っておくデザインにした.これにより,データ構造は若干冗長になるが,パケット毎の転送判定処理はアプリに影響を及ぼさない程度になった.
また,もともと実装されていた,最も音量の大きい三人の参加者の音声のみを転送する機能(route-loudest-only)との協調にも少し工夫が必要だった.実際の会議では三人以上が喋っていても,本当に必要な情報は声のデカいやつ三人くらいであろうという経験則に基づき,JitsiではRFC6464で定義される,RTPヘッダーに音量情報を含められる拡張フィールドを利用して,パケットを受信時にまず音量を基にした破棄判定を行なっている.これはとてもクレバーであるが,Audio Subscriptionとは相性が悪い.例えばAudio Subscriptionを利用してブレイクアウトルーム機能を作ったとしよう.各部屋の参加者はその部屋の他の参加者の音声ストリームのみSubscribeする.当然参加者としてはその参加者が全体の上位三人の音量であろうとなかろうと,無音でなければ送って欲しいわけだが,最前段でroute-loudest-onlyが適用されている限りそうはならない.
結果的には,先に述べた転送判定処理用のMapとは別に,明示的にSubscribeされているストリーム(mode: IncludeのSubscriptionのリストに含まれるストリーム)のみを持つ配列を用意し,route-loudest-onlyの判定時に,その配列にストリームが含まれれば破棄しないという条件分岐を追加した.
他にも複数サーバーの接続などのエッジケースを検討する必要があったが,なんとか修了することができてよかった.