🎧

Raspberry Pi PicoとPCM5102AでI2S再生

2022/08/03に公開

RP2040 で I2S を使った音声信号の出力および再生を行います。

ここでは Raspberry Pi Pico と DAC に PCM5102A と対象に回路図などを記述しますが、RP2040 搭載ボードであれば Pico 以外でも動作可能です。

前提: MCLK不要のDAC

I2S での再生には最低限 LRCLK, BCLK, SDATA の3つの信号が必要ですが、DAC によってはこれ以外に MCLK が必要になるものがあります。

この記事で示す RP2040 のコードでは MCLK を出力しません。そのため LRCLK, BCLK, SDATA と同期した MCLK が必要な DAC は使用できません。使用できるのは BCLK を PLL で逓倍して内部で MCLK を自動生成できるような DAC のみです。以下に使用できる DAC の例を示します。

IC名 サンプリング周波数 最大解像度 入手先 備考
PCM5102A 384kHz 32bit 秋月 千石 DigiKey 動作確認済み
UDA1334A 100kHz 24bit 千石 動作確認済み、ディスコン(製造中止)品
MAX98357A 96kHz 32bit DigiKey スイッチサイエンス 千石 動作確認済み、1chのみ、スピーカー用
CS4350 192kHz 24bit DigiKey

PCM5102A については供給元の Texas Instruments 社の販売方法変更の方針により順次、IC 単体での入手ができなくなる見込みです。

PCM510x シリーズについて

PCM510x シリーズは MCLK 不要な I2S 対応ステレオ DAC IC です。データシートによると以下の特徴があります。

  • 2ch, 384kHz, 32bit まで対応
  • アナログ部 3.3V、デジタル部 3.3V または 1.8V で駆動
  • 対応フォーマットは I2S または 左詰め
  • PLL 搭載で BCLK を元に MCLK 信号を内部生成
  • 出力は最大 2.1Vrms
  • ハードウェア(ピン入力)によるフォーマット設定
  • DC除去フィルタ不要

型番の x 部分はダイナミックレンジおよびSN比の違いによるもので、PCM5102A はそれぞれ 112dB の値を持ちます。

音声信号出力は 0V センター、つまりDC成分がない状態で出力されています。これは PCM510x が IC 内部で負電圧を生成しているためで、出力回路部はローパスフィルタのシンプルな構成にできます。その代わりとして負電圧を生成するために外部にフライングコンデンサが必要となっています。

回路


Raspberry Pi Pico と PCM5102A の接続

接続は下記の 4 線です。XSMT(ソフトウェアミュート)はポップノイズ防止のためのもので、不要であれば HIGH (+3V3) に接続してください。SCK は PLL で内部生成させるため LOW (GND) に接続します。

Raspberry Pi Pico PCM5102A 備考
GP22 DIN SDATA
GP21 LRCK LRCLK
GP20 BCK BCLK
GP19 XSMT ミュート制御、10kΩでプルダウン

FLT は通常のレイテンシで再生させるため LOW に接続します。HIGH に接続するとローレイテンシでの再生になりますが出力特性が変わります。

FMT は LOW に接続して I2S でのデコードとします。デエンファシス不要ですので DEMP も LOW に接続します。

負電圧の生成のためのフライングコンデンサは CAPP/CAPM および CPGND/VNEG 間の 2.2μF です。LDOOには内部生成される1.8V用のバイパスコンデンサを接続しておきます。10μFの極性に注意してください。

OUTL/OUTR にはRCローパスフィルタ用の抵抗とコンデンサを接続します。カットオフ周波数 f_c [Hz] は

f_c = \frac{1}{2\pi RC} \simeq 15915.49

となります。

コード

arduino-pico(いわゆる earlephilhower 版)を使います。PlatformIOへの導入は こちら を参考にしてください。導入方法は将来変更される恐れがあり、都度確認してください。

基本的には I2S のサンプルプログラム のままでよいのですが、出力周波数が粗いこととミュート制御に対応していないため、下記のようなプログラムとなりました。

#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.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;
  }

  while (buffer_index < BUFFER_SIZE) {
    i2s.write(buffer[buffer_index]);  // L
    i2s.write(buffer[buffer_index]);  // R
    buffer_index++;
  }
}

プログラムをアップロードすると 440Hz の三角波が出力されます。下記の画像はローパスフィルタを通した後の波形です。DC除去は行っていないため波形に歪みはありません。

プログラムの要所を解説していきます。

I2Sのピンアサイン

基本的にはどのGPIOピンをアサインできますが BCLK と LRCLK は隣りあうピンでなくてはなりません。コード上では BCLK を GPIO20、LRCLK を GPIO21 に割り当てています。

#define PIN_I2S_BCLK 20
#define PIN_I2S_LRCLK (PIN_I2S_BCLK + 1)

I2Sの初期化

今回は 48kHz, 16bit で再生させるため下記のように量子化ビット数とサンプリング周波数を指定します。ピンのアサインは i2s.begin よりも前に完了させてください。

  i2s.setBCLK(PIN_I2S_BCLK);
  i2s.setDATA(PIN_I2S_DOUT);
  i2s.setBitsPerSample(16);
  i2s.begin(sampleRate);

ミュート制御は再生の準備ができたら HIGH にしてミュートを解除します。電源投入直後はプルダウンされていますのでミュート状態です。

  pinMode(PIN_I2S_MUTE, OUTPUT);
  ...
  digitalWrite(PIN_I2S_MUTE, HIGH);

再生データの書き込み

DAC に送信するデータは i2s.write を使って書き込みます。I2S クラスの内部で リングバッファ → DMA → PIO → 出力 の順に処理され送信されていきます。 ここで2回書き込んでいるのは左右の2チャネル分のデータが必要なためです。

  while (buffer_index < BUFFER_SIZE) {
    i2s.write(buffer[buffer_index]);  // L
    i2s.write(buffer[buffer_index]);  // R
    buffer_index++;
  }

ノンブロッキング書き込みの場合

上記の i2s.write はリングバッファに空きがない場合は DMA による再生データの転送完了後にリングバッファが空くのをビジーウェイトで待ってから書き込みを行います(ブロッキング)。

ブロッキングモードでは再生データの書き込みタイミングを自前で調整する必要がありませんが、リングバッファの空きを待たずに書き込みを行う(ノンブロッキング)には以下のようにします。

  bool available;

  do {
    int32_t o = (buffer[buffer_index] << 16) | (buffer[buffer_index] & 0xffff);
    available = i2s.write(o, false);
  } while (available && buffer_index++ < BUFFER_SIZE);

i2s.write メソッドの第2引数がブロッキングモードの切り替えとなっており、false でノンブロッキングモードとなります。

ノンブロッキングモードのとき、リングバッファの空きがある場合はブロッキングモードと挙動は同じです。一方、空きがない場合はバッファの書き込みを行わずに false が返されます。

プログラムでは i2s.write メソッドが true を返している場合は可能な限り書き込み、false となった場合は処理を抜けて次回の書き込みまで他の処理をするようにしています。

補足: 音が低く再生される場合

arduino-pico の 2.3.3 までは I2S のサンプリング周波数の計算に 不具合 があり、再生速度が 1/2 になってしまう問題がありました。すなわち、上記のプログラムでは 220Hz の高さで再生されてしまいます。この問題は 2022/07/30 以降および 2.3.3 よりも最新の版で 修正され 正しいサンプリング周波数で再生されるようになりました。

もしも意図した再生速度ではない場合、読み込まれた arduino-pico のバージョンが古い可能性があります。最新のブランチを指定する場合は platformio.ini にて

platformio.ini
platform_packages =
-    maxgerhardt/framework-arduinopico@https://github.com/earlephilhower/arduino-pico.git
+    maxgerhardt/framework-arduinopico@https://github.com/earlephilhower/arduino-pico.git#master

のように #master とブランチまたはタグを指定します。

I2S::availableForWrite とバッファアンダーフローの防止

上記に示した コード ではリングバッファの空きを待ってからバッファへの書き込みが行われます。待ち処理はビジーウェイトのため、その間に別処理を行うことが難しくなっています。

i2s.write を呼び出す前にビジーウェイトが発生するかを判定し、待ちが発生するかは以下のように i2s.availableForWrite を使って判定できます。なぜこのような判定になるかは別記事にて詳しく解説しています。

https://zenn.dev/nanase_t/articles/d2475c61f1c87f

  // ブロック条件をチェック
  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