🎶

超低ビットレートな音声コーデックLyraをInsertable Streamsを用いてWebRTCのP2Pで動かす

2023/06/22に公開

はじめに

Lyraという音声コーデックをJanusというSFUで使えるようにした記事[1]があります。
WebRTCのData ChannelやWebRTC以外で独自のコーデックを使う手法もありますが、今回はこの記事を参考にWebRTCのMedia Channelを用いてGoogle Chrome間のP2PでもLyraを使えるようにしてみました。

Lyraとは

LyraはGoogleの開発した機械学習を用いた低ビットレートの音声コーデックです。
オリジナルより少し低い品質をかなりの低ビットレートで実現しようとしています。
選択可能な内、最もトラフィックの小さい 3.2kbps の場合では 20ms のサンプルをたった 8Byte にしてしまいます。(3200 \,\mathrm{bit/s} \times 0.020 \,\mathrm{s} = 64 \,\mathrm{bit})
後述のトラフィックの画像のようにWebRTCで一般的に使われているOpusでは 30kbps ほどなので約\frac{1}{10}になっています。
Lyra公開時のブログ記事[2]では概要の説明やサンプルを用いた他のコーデックとの比較などが行われています。特にOpusとの比較ではLyraの 3kbps とOpusの 10kbps が同じような評価でOpusの 6kbps をはるかに凌ぐ品質であるということがグラフにまとまっています。

LyraはC++で書かれておりそのままではブラウザでは使えないですが、この記事ではLyra v2をJavaScriptから利用可能にしたライブラリであるlyra-jsを用いてブラウザからLyraを利用します。

https://github.com/google/lyra

https://github.com/neuvideo/lyra-js

既存研究(Lyra over Janus)

冒頭で紹介した記事について、もう少し詳しく紹介します。
このブログを書いているmeetechoはブログ中で利用されているSFUのJanusを開発しています。


処理の流れ (https://www.meetecho.com/blog/playing-with-lyra/ より引用)

上の画像中のInsertable Streamの部分でL16とLyraの相互変換を、Networkの部分にSFUとしてJanusを導入しています。JanusではL16を扱えるように機能が追加されています。
ブラウザではAPIからL16を利用するように指定できないので、手順中でSDPを書き換えることでL16を利用可能にしています。

実装

実装概要

WebRTCでLyraを利用するため、SDP上ではL16[3]という音声フォーマットをコーデックとして利用するようにしています。
L16を利用するとエンコード後に非圧縮のデータがそのまま使えるため、そのタイミングでInsertable StreamsによってLyraのエンコーダを利用して圧縮を行い、受信側では同様にInsertable StreamsでLyraのデコーダを利用してからL16のデコーダにデータを渡しています。
これによってブラウザのWebRTCのAPIで指定できないLyraをWebRTCで利用しています。
同時にOpusの場合にも使えるように処理中に場合分けがいくつか含まれています。

メインのWebRTCなどの処理は https://github.com/y-i/insertable-stream-lyra-p2p/blob/master/load-from-file.js 、変換処理を行う関数は https://github.com/y-i/insertable-stream-lyra-p2p/blob/master/lyra-transformer.js に実装しています。
READMEのとおりにするとひとまず動かせると思います。

この実装は2023/05現在、setCodecPreferencescreateEncodedStreamsなどが存在しないためFirefox,Safariでは動きません。

Insertable Streamsの利用

Lyraの処理はL16のエンコード処理の後とデコード処理の前に挟まるため、encodedInsertableStreamsの利用が必要です。
OpusではInsertable Streamsを利用しないため、コーデックによってオプションを分けています。

load-from-file.js
  const sender = new RTCPeerConnection({ encodedInsertableStreams: audioCodec === 'L16' });

送信側では以下のようにRTCRtpSenderからストリームを取り出し変換関数を渡す必要があります。

load-from-file.js
    const senderStreams = sender.getTransceivers()[0].sender.createEncodedStreams();
    const transformStream = new TransformStream({
      transform: encodeTransform,
    });

    senderStreams.readable.pipeThrough(transformStream).pipeTo(senderStreams.writable);

受信側では逆にRTCRtpReceiverからストリームを取り出して変換関数を渡しています。

load-from-file.js
    const receiverStream = ev.receiver.createEncodedStreams();
    const transformStream = new TransformStream({
      transform: decodeTransform,
    });

    receiverStream.readable.pipeThrough(transformStream).pipeTo(receiverStream.writable);

Lyraの扱える形式への変換

L16から渡ってくるチャンクは16bitの整数です。しかし、encodeWithLyra関数は32bitの小数を扱うため、この型の間で変換を行う必要があります。
エンコードの部分では\lbrack -32768,32767 \rbrackから\lbrack-1,1 \rbrackに変換しています。

lyra-transformer.js
  const samples = new Int16Array(chunk.data);

  // [-32768,32767] -> [-1,1]に変換
  const buffer = Float32Array.from(samples).map(v => v > 0 ? v/0x7fff : v/0x8000);

デコードの部分では逆に\lbrack-1,1 \rbrackから\lbrack-32768,32767 \rbrackに変換しています。

lyra-transformer.js
  // [-1,1] -> [-32768,32767]に変換
  const samples = Int16Array.from(decodedChunk.map(v => v > 0 ? v*0x7fff : v*0x8000));

  chunk.data = samples.buffer;

エンディアンの修正

このまま処理を書いていっても、ノイズしか出力されません。これはエンディアンを考慮せずに、Int16ArrayUint8Arrayを変換したことが原因です。
なぜならInsertable Streamsのtransformに渡ってくるchunkはNetwork Orderになってしまっているからです。[4]
リトルエンディアンではInt16Arrayの各要素の上位と下位の8bitが入れ替わっている状態になってしまっているので、これを修正する必要があります。
具体的には以下のように隣接する偶数番目と奇数番目の要素の値を入れ替えています。

lyra-transformer.js
  if (isLittleEndian) {
    // convert to Int16 of Network Order
    const bytes = new Uint8Array(chunk.data);
    for (let i = 1; i < bytes.length; i+=2) {
      const tmp = bytes[i];
      bytes[i] = bytes[i-1];
      bytes[i-1] = tmp;
    }
  }

デコード側でも元に戻すために同様に要素を入れ替えています。

送信側でのSDPの書き換え

既存研究のところでも説明したとおり、L16はAPIから設定できないのでSDPを書き換える必要があります。
そのため、createOffer()実行後にoffer内のSDPの文字列を置換しています。Lyraは20ms毎のサンプルを要求するため、その変更もまとめて行なっています。

load-from-file.js
    // setCodecPreferencesではL16を指定できないのでSDPを直接書き換える
    // ついでに20ms分のサンプルが必要なのでそこも書き換える
    offer.sdp = offer.sdp.replace('telephone-event/8000', 'L16/16000').replace('a=rtpmap:126 L16/16000', 'a=rtpmap:126 L16/16000\r\na=ptime:20');

元のブログではISACというコーデックを置き換えに利用していますが、このコーデックはM110で削除されたため、今回は別のコーデックを置き換えに利用しています。

受信側でのSDPの書き換え

元記事[1:1]では対向がJanusでしたが、今回はブラウザのため、受信側についてもSDPを書き換える必要があります。

load-from-file.js
    // sender側と同様に書き換える
    answer.sdp = answer.sdp.replace('a=group:BUNDLE', 'a=group:BUNDLE 0');
    answer.sdp = answer.sdp.replace('m=audio 0 UDP/TLS/RTP/SAVPF 0', 'm=audio 9 UDP/TLS/RTP/SAVPF 126');
    answer.sdp += `a=rtpmap:126 L16/16000\r\na=ptime:20\r\n`;

品質とトラフィックの検証

Lyraが音声向けとのことなので、音声とそれ以外の例として音楽のファイルについてそれぞれ以下の設定で比較しました。

  • Opus 制限なし
  • Opus 6kbps
  • Lyra 3.2kbps

音声ファイルはこちらのパブリックドメインのものを、
音楽ファイルはこちらのCC0のものを利用しています。

品質の比較

音声

Lyra 3.2kbpsの場合とOpus 6kbpsの場合を比較すると、ほとんど遜色がないが僅かにLyraの方が音質が良いような気がします。
しかし、Lyra公開時のブログ記事[2:1]にあるサンプルを聞き比べると、Lyraの方が良いと思えるくらいの差があります。
サンプルの音声をlyra-jsで試してもブログと同様な結果だったため、言語や音声の内容によって性能に差が生まれるのかもしれません。

音楽

Lyra 3.2kbpsの場合とOpus 6kbpsの場合を比較すると、一概にどちらが良いと言えない結果になりました。
Lyraの方がノイズが少ないのですが、Opusの方が元の雰囲気を残しているように感じます。
Lyraは音声向けとのことだったのでOpusよりかなり悪い結果になるのかと思ったのですが意外でした。

トラフィック

chrome://webrtc-internalsで見ることができるグラフを用いて実際に想定したトラフィックになっているかを確認しました。
下のグラフの通り、Lyraでは3.2kbpsになり、Opusでも制限をかけた際は6kbps程度になっていることが確認できます。


音声をLyraで流した場合

音楽をLyraで流した場合

音声をOpusで6kbpsで流した場合

音楽をOpusで6kbpsで流した場合

音声をOpusで制限せず流した場合

音楽をOpusで制限せず流した場合

まとめ

このようにGoogle ChromeではWebRTCのP2Pの場合でもLyraを動かすことができると分かりました。
性能としては音声においてはOpus未満のビットレートで同等の性能を出すことができることを確認できました。
Lyraの代わりにGoogle Chromeで他のコーデックを動かしてみるのも可能だと思います。
ハック的な方法であり使える環境は限られますが、Media Channelを用いたままカスタマイズの幅が広まる面白い手法だと思います。

脚注
  1. https://www.meetecho.com/blog/playing-with-lyra/ ↩︎ ↩︎

  2. https://opensource.googleblog.com/2022/09/lyra-v2-a-better-faster-and-more-versatile-speech-codec.html ↩︎ ↩︎ ↩︎

  3. https://www.rfc-editor.org/rfc/rfc2586 ↩︎

  4. ブログ中[2:2]にこの記述があり、実際にこのように動作してましたが、この仕様についてどこで定められているのか見つけられませんでした。 ↩︎

Discussion