🛠️

Redundant Audio Data(RED)のカスタムエンコーダを作って WebRTC の音声品質の低下を防ぐ

2021/12/15に公開

Redundant Audio Data(RED)のカスタムエンコーダを作って WebRTC の音声品質の低下を防ぐ

この記事は、 NTT Communications Advent Calendar 2021 15 日目の記事です。
2020 年入社、SkyWay 開発チーム所属の@shinyoshiakiです。

本記事では、WebRTC におけるパケットロスによる音声品質の低下を防ぐための技術である RED について書いていきます。

※本記事の内容は 2021/12/14 時点では Chrome 以外のブラウザで動作しません。

はじめに

Chrome のバージョン M96 から RFC2198 - RTP Payload for Redundant Audio Data (RED) が正式に有効化されました。

RED では RTP Payload に、最新のメディアパケットだけではなく、その直前に送信した N 個のメディアパケットを詰め込むことでメディアパケットの冗長化を行います。
この変数 N のことを Distance と呼び、Distance の値が大きいほど冗長度が上がり、パケットロス発生時の音声品質が良くなります。その代わりに音声の通信量は RED を使わないときに比べて Distance + 1 倍になります。

このPublic Service Announcement (PSA)で説明されている通り、Chrome における RED の Distance は、現状 1 に固定されています。

webrtc hacksの記事中でも言及されているように、パケットロス率が高い通信環境(webrtc hacks の例だと 60%の通信環境) だと distance 値は 1 より 2 の方が遥かに音声品質が良いです。

distance 値を増やすとその分、倍々に通信量が増えていくものの、映像の通信量に比べるとそれでも量は少ないので、音声品質を重視するユースケースにおいては 1 よりも大きい distance 値を設定したい事は大いにありえると考えられます。

Chrome における RED の Distance は、現状 1 に固定されているものの、先程の PSA ではブラウザのencoded insertable streams APIを使って、任意の RED のパケットを作るカスタムエンコーダを作ることで Distance の値を任意に設定できると言っています。

It is possible to use the encoded insertable streams API to write a custom encoder that wraps opus frames in the RFC 2198 format for applications that require more flexibility with respect to the amount of redundancy.

そこで、本記事では任意の Distance 値を設定できる RED のカスタムエンコーダを作ってみます。

本記事の内容を SkyWay の js-sdk に適用する記事を SkyWay のNote サイトにて後日公開予定です。

サンプルコード

サンプルコードはブラウザで動作させるために TypeScript を使っています。(WebAssembly を使えば任意の言語で書き直すことも可能でしょう)

記事中のサンプルコードの基となったコードを github にて公開しています。

RED パケット

カスタムエンコーダを作る上で最初に必要なのは RED パケットのシリアライズとデシリアライズをすることです。

RED パケットの仕様について見ていきましょう。

下の図は RFC2198 のセクション 7 の RED パケットの例の図です。

    0                   1                    2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3  4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |V=2|P|X| CC=0  |M|      PT     |   sequence number of primary  |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |              timestamp  of primary encoding                   |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           synchronization source (SSRC) identifier            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |1| block PT=7  |  timestamp offset         |   block length    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |0| block PT=5  |                                               |
   +-+-+-+-+-+-+-+-+                                               +
   |                                                               |
   +                LPC encoded redundant data (PT=7)              +
   |                (14 bytes)                                     |
   +                                               +---------------+
   |                                               |               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                                               +
   |                DVI4 encoded primary data (PT=5)               |
   +                (84 bytes, not to scale)                       +
   /                                                               /
   +                                                               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                               +---------------+
   |                                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

この図のうち、下の図の箇所が RED パケットにあたります。
RED パケットは RTP Payload の領域に入っています。

  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |1| block PT=7  |  timestamp offset         |   block length    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |0| block PT=5  |                                               |
   +-+-+-+-+-+-+-+-+                                               +
   |                                                               |
   +                LPC encoded redundant data (PT=7)              +
   |                (14 bytes)                                     |
   +                                               +---------------+
   |                                               |               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                                               +
   |                DVI4 encoded primary data (PT=5)               |
   +                (84 bytes, not to scale)                       +
   /                                                               /
   +                                                               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                               +---------------+
   |                                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

上の図のうち、下の図の箇所が RED パケットのヘッダーにあたります。

  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |1| block PT=7  |  timestamp offset         |   block length    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |0| block PT=5  |
   +-+-+-+-+-+-+-+-+

まず RED パケットヘッダーについて見ていきましょう。

RED パケットヘッダー

RED パケットヘッダーは複数の次の図のようなヘッダーブロックから構成されています。

    0                   1                    2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3  4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |F|   block PT  |  timestamp offset         |   block length    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

ヘッダーブロックのフィールドは次のように定義されています。

  • F

    • 1 ビット
    • 別のヘッダーブロックが後ろに続くかどうかを示す。 1 の場合は、さらにヘッダーブロックが続き、0 の場合は、これが最後のヘッダーブロックとなる
  • block PT

    • 7 ビット
    • 冗長化している RTP パケットの RTP ペイロードタイプ
      • 具体的には SDP の RED のa=rtpmap行の直後にあるa=fmtp行の${payloadType}/${payloadType}の payloadType が来る
        a=rtpmap:63 red/48000/2
        a=fmtp:63 111/111
        
  • timestamp offset

    • 14 ビット
    • RTP ヘッダーのタイムスタンプに対するこのブロックのタイムスタンプの符号なしの差
      • 冗長パケットのタイムスタンプは必ず RTP ヘッダーのタイムスタンプより古いものでなければならない
    • F ビットが 0 なら省略
  • block length

    • 10 ビット
    • ヘッダーブロックに対応するデータブロックのヘッダーブロック部を除くバイト単位の長さ。
    • F ビットが 0 なら省略

もし F ビットの値が 0 なら、それは、冗長パケットではなく最後のパケットすなわち最新のパケットであるため、timestamp offset と block length が省略され、次の図のような 8bit(1byte) のヘッダーブロックになります。

                      0 1 2 3 4 5 6 7
                     +-+-+-+-+-+-+-+-+
                     |0|   Block PT  |
                     +-+-+-+-+-+-+-+-+

これらの仕様を基に、RED のヘッダーブロックをデシリアライズ/シリアライズするプログラムを書くと次のようになります。

rtp/src/rtp/red/packet.ts
interface RedHeaderField {
  fBit: number;
  blockPT: number;
  /**14bit */
  timestampOffset?: number;
  /**10bit */
  blockLength?: number;
}

export class RedHeader {
  fields: RedHeaderField[] = [];

  static deSerialize(buf: Buffer) {
    let offset = 0;
    const header = new RedHeader();

    for (;;) {
      const field: RedHeaderField = {} as any;
      header.fields.push(field);

      const bitStream = new BitStream(buf.slice(offset));
      field.fBit = bitStream.readBits(1);
      field.blockPT = bitStream.readBits(7);

      offset++;

      // 最後のヘッダーブロック(最新のパケット)のfBitは0
      if (field.fBit === 0) {
        break;
      }

      field.timestampOffset = bitStream.readBits(14);
      field.blockLength = bitStream.readBits(10);

      offset += 3;
    }

    return [header, offset] as const;
  }

  serialize() {
    let buf = Buffer.alloc(0);
    for (const field of this.fields) {
      // 冗長パケットのブロックにはtimestampOffsetとblockLengthが存在する
      if (field.timestampOffset && field.blockLength) {
        const bitStream = new BitStream(Buffer.alloc(4))
          .writeBits(1, field.fBit)
          .writeBits(7, field.blockPT)
          .writeBits(14, field.timestampOffset)
          .writeBits(10, field.blockLength);
        buf = Buffer.concat([buf, bitStream.uint8Array]);
      }
      // 最新のパケット
      else {
        // 1byteのヘッダーブロック
        const bitStream = new BitStream(Buffer.alloc(1))
          .writeBits(1, 0)
          .writeBits(7, field.blockPT);
        buf = Buffer.concat([buf, bitStream.uint8Array]);
      }
    }
    return buf;
  }
}

RED パケットデータブロック

最後のヘッダーの直後に、ヘッダーと同じ順序で格納されたデータブロックが続きます。

   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |1| block PT=7  |  timestamp offset         |   block length    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |0| block PT=5  |                                               |
   +-+-+-+-+-+-+-+-+                                               +
   |                                                               |
   +                LPC encoded redundant data (PT=7)              +
   |                (14 bytes)                                     |
   +                                               +---------------+
   |                                               |               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                                               +
   |                DVI4 encoded primary data (PT=5)               |
   +                (84 bytes, not to scale)                       +
   /                                                               /
   +                                                               +
   |                                                               |
   +                                                               +
   |                                                               |
   +                                               +---------------+
   |                                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

それぞれの冗長パケットのデータブロックの長さはヘッダーブロックの blockLength フィールドと一致します。

最新のパケットのデータブロックの長さは RED パケットの長さからヘッダーブロックと冗長パケットのデータブロックの長さを引いた残りになります。

これで RED パケットをデシリアライズ/シリアライズするルールが揃ったので、RED パケットをデシリアライズ/シリアライズするプログラムが完成させられます。

rtp/src/rtp/red/packet.ts
export class Red {
  header: RedHeader;
  blocks: {
    block: Buffer;
    blockPT: number;
    /**14bit */
    timestampOffset?: number;
  }[] = [];

  static deSerialize(buf: Buffer) {
    const red = new Red();
    let offset = 0;
    [red.header, offset] = RedHeader.deSerialize(buf);

    red.header.fields.forEach(({ blockLength, timestampOffset, blockPT }) => {
      if (blockLength && timestampOffset) {
        // 冗長パケットの長さはblockLength
        const block = buf.slice(offset, offset + blockLength);
        red.blocks.push({ block, blockPT, timestampOffset });
        offset += blockLength;
      } else {
        // 最新のパケットの長さは残りの全領域
        const block = buf.slice(offset);
        red.blocks.push({ block, blockPT });
      }
    });

    return red;
  }

  serialize() {
    this.header = new RedHeader();

    for (const { timestampOffset, blockPT, block } of this.blocks) {
      // 冗長パケット
      if (timestampOffset) {
        this.header.fields.push({
          fBit: 1,
          blockPT,
          blockLength: block.length,
          timestampOffset,
        });
      }
      // 最新のパケット
      else {
        this.header.fields.push({ fBit: 0, blockPT });
      }
    }

    let buf = this.header.serialize();

    // データブロックを詰める
    for (const { block } of this.blocks) {
      buf = Buffer.concat([buf, block]);
    }

    return buf;
  }
}

RED カスタムエンコーダ

RED パケットの読み書きが出来るようになったので、次は冗長化したい Distance 個の過去のパケットを RED パケットに詰め込む RED エンコーダを作ります。

rtp/src/rtp/red/encoder.ts
export class RedEncoder {
  cache: { block: Buffer; timestamp: number; blockPT: number }[] = [];
  // 保持するパケットの最大数。この大きさが最大のdistanceとなる
  cacheSize = 10;

  // デフォルトのdistanceを1とする
  constructor(public distance = 1) {}

  // 最新のパケットをcacheで保管する
  push(payload: { block: Buffer; timestamp: number; blockPT: number }) {
    this.cache.push(payload);
    // 古いパケットを捨てる
    if (this.cache.length > this.cacheSize) {
      this.cache.shift();
    }
  }

  // REDパケットを作る
  build() {
    const red = new Red();

    const redundantPayloads = this.cache.slice(-(this.distance + 1));
    const presentPayload = redundantPayloads.pop();

    // 冗長パケットを詰める
    redundantPayloads.forEach((redundant) => {
      // RTP Headerのタイムスタンプは32bitなのでそれを考慮した計算をする
      const timestampOffset = uint32Add(
        presentPayload.timestamp,
        -redundant.timestamp
      );
      // 14bit以上の時にオーバーフローする
      // https://bugs.chromium.org/p/webrtc/issues/detail?id=13182
      if (timestampOffset >= (0x01 << 14) ) {
        return;
      }
      red.blocks.push({
        block: redundant.block,
        blockPT: redundant.blockPT,
        timestampOffset,
      });
    });
    // 最新のパケットを詰める
    red.blocks.push({
      block: presentPayload.block,
      blockPT: presentPayload.blockPT,
    });
    return red;
  }
}

受信した RTP Payload とその RTP Header timestamp をpushメソッドでエンコーダのキャッシュに貯めて、buildメソッドで任意の distance の RED パケットを生成する構造になっています。

encoded insertable streams でカスタムエンコーダを使う

いよいよ本題に入ります。先程作ったカスタムエンコーダと insertable を組み合わせて、ブラウザ上でカスタムエンコーダを動かします。

送信 Peer が受信 Peer に向かって音声を片側送信するシンプルなユースケースにカスタムエンコーダを組み込んだサンプルコードを用意しました。

rtp/examples/browser/customEncoder/main.ts
import { buffer2ArrayBuffer, Red, RedEncoder } from "werift-rtp";

(async () => {
  // カスタムエンコーダのdistanceを3とする
  const redEncoder = new RedEncoder(3);

  // encodedInsertableStreamsを有効化しておく
  const sender = new RTCPeerConnection({
    encodedInsertableStreams: true,
  } as any);
  const receiver = new RTCPeerConnection({
    encodedInsertableStreams: true,
  } as any);

  const [track] = (
    await navigator.mediaDevices.getUserMedia({ audio: true })
  ).getTracks();

  const rtpSender = sender.addTrack(track);

  // insertableStreamsの送信側の設定をする
  const senderTransform = (sender: RTCRtpSender) => {
    //@ts-ignore
    const senderStreams = sender.createEncodedStreams();
    const readableStream = senderStreams.readable;
    const writableStream = senderStreams.writable;
    const transformStream = new TransformStream({
      transform: (encodedFrame, controller) => {
        if (encodedFrame.data.byteLength > 0) {
          // RTP Payload(REDパケット)をデシリアライズ
          const packet = Red.deSerialize(encodedFrame.data);
          // 最新のパケット(非冗長パケット)を取り出してカスタムエンコーダに渡す
          const latest = packet.blocks.at(-1);
          redEncoder.push({
            block: latest.block,
            blockPT: latest.blockPT,
            timestamp: encodedFrame.timestamp,
          });
          // カスタムエンコーダにredパケットを作らせる
          const red = redEncoder.build();
          // RTP Payloadをカスタムエンコーダで作ったREDパケットですり替える
          encodedFrame.data = buffer2ArrayBuffer(red.serialize());
        }
        controller.enqueue(encodedFrame);
      },
    });
    readableStream.pipeThrough(transformStream).pipeTo(writableStream);
  };
  senderTransform(rtpSender);

  const [transceiver] = sender.getTransceivers() as any;
  const { codecs } = RTCRtpSender.getCapabilities("audio");
  // REDの利用を宣言する
  transceiver.setCodecPreferences([
    codecs.find((c) => c.mimeType.includes("red")),
    ...codecs,
  ]);

  await sender.setLocalDescription(await sender.createOffer());
  await new Promise<void>((r) => {
    sender.onicecandidate = ({ candidate }) => {
      if (!candidate) r();
    };
  });

  // insertableStreamsの受信側の設定をする
  const receiverTransform = (receiver: RTCRtpReceiver) => {
    //@ts-ignore
    const receiverStreams = receiver.createEncodedStreams();
    const readableStream = receiverStreams.readable;
    const writableStream = receiverStreams.writable;
    const transformStream = new TransformStream({
      transform: (encodedFrame, controller) => {
        if (encodedFrame.data.byteLength > 0) {
          // RTP Payload(REDパケット)をデシリアライズ
          const red = Red.deSerialize(encodedFrame.data);
          // distance値を表示
          console.log("distance", red.blocks.length - 1);
        }
        controller.enqueue(encodedFrame);
      },
    });
    readableStream.pipeThrough(transformStream).pipeTo(writableStream);
  };
  receiver.ontrack = (e) => {
    receiverTransform(e.receiver);
  };

  await receiver.setRemoteDescription(sender.localDescription);
  await receiver.setLocalDescription(await receiver.createAnswer());
  await sender.setRemoteDescription(receiver.localDescription);
})();

ブラウザのコンソールに受信側の受信した RED パケットの distance が表示されます。
これにより、ブラウザ上で任意の RED の distance を設定することに成功したことを確認できました。

最後に

encoded insertable streams APIの登場以前は、本記事で扱った内容のような事は libwebrtc 側のコードをいじらなければ実現できませんでした。encoded insertable streams APIの登場によって、こういった比較的低レイヤな処理がブラウザ側で柔軟に出来るようになったことを実体験できて良かったです。

RED 自体も音声品質を改善する上で強力な選択肢であり、これまでパケットロスが原因で音声品質が低くなっているような環境のユーザの通話品質が良くなればいいなと思いました。

ちなみに今回はブラウザ側で RED のカスタムエンコーダを作って、RED の Distance 値を変更しましたが、SFU を利用している場合、SFU 側で RED パケットをほどいて本記事のカスタムエンコーダがやっているようなことをすれば SFU 側でも実現できたりします。
SFU を使わない P2P なユースケースだと現状、本記事のようにするしか無いでしょう。

参考文献

Discussion