⏱️

arduino-picoのI2S::availableForWriteの意味とバッファアンダーフローの防止

2022/08/03に公開

Raspberry Pi PicoとPCM5102AでI2S再生する記事の補足になります。

arduino-pico の I2S を扱って気になった I2S::availableForWrite というメソッドがあったため、その挙動を調査しました。

ドキュメントを見る

arduino-pico のドキュメントによると、以下のような説明があります。

int availableForWrite()
Returns the number of L/R samples that can be written without potentially blocking.

参考訳: ブロッキングの恐れなく書き込めるL/Rのサンプル数を返す。

実装を読む

I2S::availableForWrite メソッドは内部的には AudioRingBuffer::available メソッドを呼び出しているだけです(実装)。名称やドキュメントの説明から推測すると利用可能なリングバッファのサンプル数を表しているように見えます。しかし実際に値を取得すると予想以上に増えたり、書き込んでいるのに全く減らないという挙動に遭遇し、どのような意味を持っているのかが正確に理解できませんでした。

AudioRingBuffer::available メソッドの 実装 を下記に示します。

AudioRingBuffer.cpp#L212-L220
int AudioRingBuffer::available() {
    if (!_running) {
        return 0;
    }
    int avail;
    avail = _wordsPerBuffer - _userOff;
    avail += ((_bufferCount + _curBuffer - _userBuffer) % _bufferCount) * _wordsPerBuffer;
    return avail;
}

リングバッファの構造と変数の意味

ここでは量子化ビット数を 16bit、サンプリング周波数を 48kHz、チャネル数を 2 として考え、音声データの 1 サンプルとは LR チャネルがペアになった 32bit データであるとします。

AudioRingBuffer では、符号なし32bit整数(= 1サンプル)の長さ _wordsPerBuffer の配列を持ったバッファが _bufferCount 分だけ作られリングバッファを構成しています。初期値より _wordsPerBuffer16 で固定、_bufferCount8 で固定されます。つまり全体としては合計 128 サンプル分のリングバッファとなります。

_userOff は書き込みの度に増加(最大値は _wordsPerBuffer - 1)していきます。
そのため _wordsPerBuffer - _userOff の結果は 現在のリングバッファ先頭の配列に格納可能な残りサンプル数 を表しており、データを書き込むたびに減少し (16) → 15 → 14 → ... → 1 → 16 → 15 → ... と変化するはずです。
1 → 16 と増加するとき、配列が満杯になってリングバッファの現在位置が変わります。この現在位置が _userBuffer_curBuffer です。

_userBuffer は 16 サンプルの書き込みの度に増加、_curBuffer は DMA による転送のたびに増加していきます。どちらも最大値は _bufferCount - 1 です。
このことから、(_bufferCount + _curBuffer - _userBuffer) % _bufferCount の結果は リングバッファの入力量(書き込み)と出力量(DMAへの転送)の関係 を表しているものと考えられます。

最後にこの値に対して _wordsPerBuffer の値が乗算され、前行の値と加算されます。

再生データを書き込まないとき

上記より、データを書き込まず DMA 転送だけが開始した場合は _curBuffer のみが転送にともなってインクリメントされていくため 0 → 1 → 2 → ... → 7 → 0 → 1 → ... と変化し、その結果、I2S::availableForWrite の返却値は 16 → 32 → 48 → ... → 128 → 16 → 32 → ... となるはずです。

確認のため、再生データを 書き込まずに I2S::availableForWrite の返却値を確認します。リングバッファは 1 サンプル 2 バイトですので、_wordsPerBuffer が意味するデータ長、すなわち 8 サンプル 16 バイトの送信処理にはステレオ 2 チャネルかつサンプリング周波数が 48kHz とすると \frac{N_{\rm sample}}{f_s N_{\rm ch}} = \frac{1}{6000} \approx 167 [μs] かかります。下記のようにシリアル経由でログを出します。

  while (buffer_index < BUFFER_SIZE) {
    // i2s.write(buffer[buffer_index]);  // 送信しない
    // i2s.write(buffer[buffer_index]);  // 送信しない
    buffer_index++;
  }

  delayMicroseconds(167);
  Serial.println(i2s.availableForWrite());
出力
...
96
112
128
16
32
48
64
64
80
96
112
128
16
32
...

タイミングが正確でないため連続して重複する数値がありますが、挙動は想定したものでした。

再生データを書き込んだとき

リングバッファにデータを書き込んだときは _userOff および _userBuffer の値が増加していきます。前述の通り、このうち _userOff は 16 になると 0 に戻って _userBuffer の値が増加するはずです。16 サンプルずつ書き込むと直後にDMAへの転送が開始され、_curBuffer_userBuffer が同値に、すなわち _curBuffer - _userBuffer が 0 となって ((_bufferCount + _curBuffer - _userBuffer) % _bufferCount) * _wordsPerBuffer の値も 0 になります。

書き込み前後の値の挙動を確認します。想定では 16 のままで変わらないはずです。

  while (buffer_index < BUFFER_SIZE) {
    Serial.print(i2s.availableForWrite());
    Serial.print(" -> ");

    for (size_t i = 0; i < 16; i++) {
      i2s.write(buffer[buffer_index]);
      i2s.write(buffer[buffer_index]);
      buffer_index++;
    }

    Serial.println(i2s.availableForWrite());
  }
  
  // delay はしない
出力
...
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
...

想定した通りの出力となりました。

この値が指し示す意味

前述の通り、 (_bufferCount + _curBuffer - _userBuffer) % _bufferCount の結果は リングバッファの入力量と出力量の関係 を表すものです。以下に I2S::availableForWrite の値の変化が指し示す意味をまとめます。

結果の値が 意味
増えたとき DMA への転送がリングバッファへの書き込みより速い(入力不足)
変わらないとき DMA への転送とリングバッファへの書き込みが同じ速さ
減ったとき DMA への転送がリングバッファへの書き込みより遅い(入力充分)

実際には入力過多であっても空きバッファのチェック処理により書き込み処理がブロックまたは失敗するためバッファオーバーフローは起こりません

逆に、入力不足の場合は結果の値が増えていき、バッファアンダーフローとなって DAC への転送も滞り、音飛びが発生します。入力不足が解消されていくと次第に値は減っていき、やがて 1 から 16 の値で落ち着きます。

前後の値を比較して、16以下の値をキープしているのであれば正常な再生が期待できます。
しかし 16 より大きい値が出てきた頃には既にバッファアンダーフローが発生している可能性があり、さらに 128 で 16 に戻る仕様のため 16 をキープしているのにアンダーフローが発生している という事態が発生しえます。

よって、このメソッドだけではアンダーフローが発生しているかを判断する(= 書き込み済みで転送可能なバッファがまだ存在するかを判断する)には少々使いづらいかもしれません。

結局のところ実用的な使い所は、冒頭の説明通りにブロッキングが発生せずに書き込めるかどうかの判定に使ったり、継続的に結果を表示して値が増加していないかを見たりするしかなさそうです。

ちなみにアンダーフロー寸前の場合は下記のような値の変化となります。これは前述のプログラムに delayMicroseconds を書いて意図的にデータを滞らせたものです。

出力
...
16 -> 16
16 -> 16
112 -> 96
96 -> 80
80 -> 80
80 -> 64
64 -> 48
48 -> 32
32 -> 16
32 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
16 -> 16
112 -> 96
96 -> 80
80 -> 80
80 -> 64
64 -> 48
48 -> 48
...

バッファアンダーフローの防止

リングバッファへのデータ書き込み量よりも DMA 転送量が大きくなるとバッファアンダーフローが発生します。16 サンプルの再生時間は約 167μs、128 サンプルでは約 1.3ms となります。

この時間内にデータを用意してリングバッファに書き込めればバッファアンダーフローは発生しません。しかし音声データの生成処理やデコード処理を考えるとやや少ないかもしれません。

そこで I2S にはバッファの数や内部配列の長さを変えられる I2S::setBuffers メソッドが用意されています。このメソッドでリングバッファのバッファ数、バッファ内の配列長、空状態の配列要素の初期値を指定します。

バッファ数を 16、配列長(サンプル数)を 128、全体で 2048 サンプル(約 42.7ms)のリングバッファを設定するプログラムを示します。I2S::setBuffersI2S::begin に呼び出してください。

void setup() {
  i2s.setBCLK(PIN_I2S_BCLK);
  i2s.setDATA(PIN_I2S_DOUT);
  i2s.setBitsPerSample(16);
  i2s.setBuffers(16, 128, 0);  // 追加
  i2s.begin(sampleRate);
  
  // 以下略
}

なお I2S::write メソッドは _curBuffer == _userBuffer であるときもビジーウェイトしてしまいます。ビジーウェイトを避けてデータをリングバッファに書き込むには _curBuffer != _userBuffer である条件下で I2S::write を呼び出します。このブロック条件は具体的には I2S::availableForWrite の返り値 > _wordsPerBuffer となります。

最終的なコード

以下に示すのは こちら で紹介したコードを修正し、バッファアンダーフローを防止しつつブロックを回避しながら三角波を再生するコードです。I2S::availableForWrite でブロック条件をチェックしているため、明示的にノンブロッキングモードを指定する必要がありません(ブロッキングモードでも同じ動作になります)。

#include <I2S.h>

#define PIN_I2S_BCLK 20
#define PIN_I2S_LRCLK (PIN_I2S_BCLK + 1)
#define PIN_I2S_DOUT 22
#define PIN_I2S_MUTE 19

#define BUFFER_SIZE 256

I2S i2s(OUTPUT);
const int32_t sampleRate = 48000;
int16_t buffer[BUFFER_SIZE];
uint32_t phase       = 0;
uint32_t phase_delta = 0;

int16_t triangle(uint32_t phase) {
  phase += (1 << 30);
  return ((phase < (uint32_t)(1 << 31)) ? (phase >> 16) : ((1 << 16) - (phase >> 16) - 1)) - 16383;
}

void generate_triangle(int16_t *buffer, size_t size, uint32_t *phase, uint32_t phase_delta) {
  for (size_t i = 0; i < size; i++) {
    buffer[i] = triangle(*phase);
    *phase += phase_delta;
  }
}

void setup() {
  pinMode(PIN_I2S_MUTE, OUTPUT);

  i2s.setBCLK(PIN_I2S_BCLK);
  i2s.setDATA(PIN_I2S_DOUT);
  i2s.setBitsPerSample(16);
  i2s.setBuffers(16, BUFFER_SIZE / 2, 0);  // 追加
  i2s.begin(sampleRate);

  phase_delta = 440.0 * (float)(1ULL << 32) / sampleRate;

  digitalWrite(PIN_I2S_MUTE, HIGH);
}

void loop() {
  static size_t buffer_index = 0;

  // 波形生成は必要になったときのみ実行
  if (buffer_index == BUFFER_SIZE) {
    generate_triangle(buffer, BUFFER_SIZE, &phase, phase_delta);

    buffer_index = 0;
  }

  // ブロック条件 _curBuffer != _userBuffer をチェック
  if (i2s.availableForWrite() > BUFFER_SIZE) {
    while (buffer_index < BUFFER_SIZE) {
      int32_t o = (buffer[buffer_index] << 16) | (buffer[buffer_index] & 0xffff);
      
      // ブロッキングモードを指定しているが
      // 事前にブロック条件を判定したのでブロックは発生しない
      i2s.write(o, true);
      buffer_index++;
    }
  }
}

Discussion