USB audio のフィードバックとは
USB-DACなどUSBから音声を再生する機器では、各サンプルを再生するタイミングはデバイス側(USB-DAC側)が決定する。それに対しPCやスマホなどデータの送り出し側が一方的に音声データを送りつけると、PCとUSB-DACの間のわずかなクロック周波数のずれにより、バッファがだんだん埋まってきたり空になったりして、データの過不足が生じ、プチっというクリックノイズが発生してしまう。これを避けるのがフィードバック(フロー制御)だが、Raspberry Pi Picoのpico-SDKに付属するサンプル usb_sound_card にはそれが実装されていない。そこで、できるだけ小さな改良でちゃんとフィードバックがかかるようにしてみた。
このサンプルにはミュート機能も実装されていないので、PC側で(音量を絞るのでなく)ミュートにしても音が出続けてしまう(Macで確認)。そこで、ついでにこれも実装した。
現状
世間ではI2S (Inter-IC Sound)で音声を再生できるDACチップを搭載したボードが多数売られている。これを使ってPCから音声を再生するには、USB経由でデータを受け取ってI2Sへと流し込むものが必要になる。そこに何らかの機能(私の場合は、デジタルイコライザ。末尾参照)を追加しようとするとマイコンを使うことになるが、それに最も適したマイコンは現状のところRaspberry Pi Pico(以下、Picoと呼ぶ)になるようだ(Arduinoでは遅すぎるし、ESP32にはUSBデバイスとして使えるものもあるが、情報が少ない)。またPicoには前述のように、usb_sound_cardというサンプルがあり、これを使うと(DACをつないだGPIOポートを設定するだけで)USB-DACが作れてしまう。上の写真はPicoと同寸で、積み重ねるだけで使用できるDACボードWaveshare Pico-Audioの例だ。
しかし、このusb_sound_cardは前述のように不完全で、しばらく音楽を聞いていると、ときどきプチ、というノイズが乗る。純正のPicoに比べて互換ボードは水晶の精度が低いのか、その頻度が少し高かったりもする。しかしなかなかこれと言った解決策は提示されていないので、自分でなんとかしてみた。
もっともきちんと開発されているのは、CQ出版社の雑誌Interfaceで連載されてきた、ラズパイPico DACだと思われる。これは外付けのDACチップを使わず本体だけで音楽再生できるもので、本誌記事(Interface 2021年8月号 p.133)でも usb_sound_card にフィードバックが実装されていないことが指摘されており、自前で実装している。しかしこの連載ではI2S DACチップを用いていないためバッファ部分から丸ごと作り直されており、I2S DACチップを使う実装にここから戻すのは無理な状態になっていた。
他には、PicoからI2Sへデータを流し込むためのDMAのハードウェアを直接覗いて制御する方法が見つかったが、いろいろ調べてみると、これでは不完全なことがわかった。DMAにセットされる前にソフトウェア的なバッファが何段かあるため、DMAだけを見てみても、この前段階のバッファ数がわからないとちゃんとした制御ができないためである。具体的に言うと、DMAバッファ残量が減少していき0になっても音は途切れるとは限らず、それが何度か続くと音が途切れたりする。つまり、ソフトウェア側のバッファが残弾0だと音が途切れるが、そうでない場合は問題ない、ということが起こっていた。
実装
オリジナルのコードに対しプルリクエストをかけたので、そちらをオリジナルの代わりに使っていただければ良い。usb_sound_card.c のみを修正しており、他は修正の必要がないようになっている。ミュート機能も実装されており、それぞれ USB_FEEDBACK, MUTE_CMD のコンパイルスイッチで分かるようにしてある。
また、修正済みのファイルだけが欲しい場合はこちらのリポジトリから取得できる。上記の、Waveshare Pico-Audioですぐ使えるコンパイル済みバイナリも掲載した。
バッファ残量のチェック
USB 経由で受け取ったデータは一旦、バッファへ格納される。そのバッファからDACへはDMA転送が行われており、自動的に次々と読み出されて空になっていくので、オリジナルのコードではバッファ残量がわかるようには作られていない。バッファ自身は pico-extras/src/common/pico_audio/audio.cpp で実装されており、リングバッファでなく連結リストで使用中と未使用(空き)がブロック単位で管理されている。この実装中で空き個数を管理していたり、それを読み出すための関数が用意されていればよかったが、それがないので自前でリストを辿り、残数を数えるシンプルなコードを書いた。
static int countFreeBuffers(void) {
int i = 0;
audio_buffer_t *audio_buffer = producer_pool->free_list;
while(audio_buffer != NULL) {
audio_buffer = audio_buffer->next;
i++;
}
return i;
}
フィードバック量の計算
上の方法で調べた空き数が、バッファ総量の半分に近づくようにフィードバック値を計算する部分は以下の通りである。
int feedbackvalue = (countFreeBuffers() - BUFFER_NUM / 2) * (2 * 40 / BUFFER_NUM);
uint feedback = ((audio_state.freq + feedbackvalue) << 14u) / 1000u;
feedbackbalue
が大きいと再生側が対応できず止まってしまうことがある。具体的には48(0.1%)だとだめなことがあったので、プラス・マイナス40の範囲で制御されるようにした。実際に、バッファ残数が1/2に近づくような動きしていることを確認した。
また、試しに以下のようにすると(作為的にPC側とDAC側の再生速度を 0.05%ずらす)、しばらく(30秒程度)聞いているうちにバッファがいっぱいになり、音がプチプチと途切れだす。この + 24
を残したまま、コメントを入れ替えてfeedbackbalue
をちゃんと計算するようにすると、その誤差を吸収するように制御がかかり、音が途切れなくなるのを確認できる。+ 24
のかわりに - 24
でチェックすると、バッファが空になるほうの動作を確認できる。
//int feedbackvalue = (countFreeBuffers() - BUFFER_NUM / 2) * (2 * 40 / BUFFER_NUM);
int feedbackvalue = 0;
uint feedback = ((audio_state.freq + 24 + feedbackvalue) << 14u) / 1000u;
バッファのサイズ修正
以上、元のソースコードを読む手間がかかったが、実装はとても簡単なものになった。ただしこれだけではうまく動かない。フィードバックをPCに返信すると、PCはデータ送信のペース(周期)を修正するのではなく、1パケットのデータ量を変えて調整する。典型的な 16bit/48kHz のデジタルオーディオの場合、1ミリ秒に1回データを送信するので、1パケットには通常48サンプルが格納される。さらにフィードバックによる調整が必要な場合、1パケットのサンプル数が47から49の範囲で制御される。しかし usb_sound_card.c はそれに対応しておらず、修正の必要があった。
まず、受け取ったデータをDACへI2Sバスで送出するためのバッファは以下のように修正した。
#if USB_FEEDBACK
// when USB-audio feedback applies, it receives upto 49 samples in each packet
producer_pool = audio_new_producer_pool(&producer_format, BUFFER_NUM, 48 + 1);
#else
producer_pool = audio_new_producer_pool(&producer_format, 8, 48); // todo correct size
#endif
上のように、なんとバッファの大きさ48
がハードコーディングされていたので、これに1を足した。また、バッファ数が8では少し心もとないので(バッファオーバーフローやアンダーフローが生じたとき、4個分ほど飛ぶ現象が確認された)、BUFFER_NUM
は16と倍増させた(あまり大きいと音声が遅延する)。
次に、USBからデータを受け取る部分については、
#if USB_FEEDBACK
// when USB-audio feedback applies, it receives upto 49 samples in each packet
#define AUDIO_MAX_PACKET_SIZE(freq) (uint8_t)(((freq + 1999) / 1000) * 4)
#else
#define AUDIO_MAX_PACKET_SIZE(freq) (uint8_t)(((freq + 999) / 1000) * 4)
#endif
のように修正した。やはり、サンプル周波数freq
が48,000のとき、パケットサイズがきっちり48 * 4
(左右それぞれ16ビットで、1サンプル4バイト)になっていたので、1個分増量した。
これにしばらく気づかず、遅い方の制御はうまくいくのに、速い方にすると動作がおかしくなるのにしばらく悩んだ。
ミュート機能
音量の設定値を変えずに瞬時にミュートする機能で、これが実装されていないためにMacでミュートにしても音が出続けてしまっていた。といっても、USBでミュートのフラグを受け取る部分は実装済みだったので、単に以下のようにボリュームを最小限に絞るコードを入れただけである。
#if MUTE_CMD
if(audio_state.mute) {
vol_mul = 1;
}
#endif
その他
もとはといえば、DAC でアナログ信号にするまえにイコライジングをかけられるDACが欲しく、しかし世の中にはこれといった製品がなかったため、自作しようとしたのがきっかけだった。1週間ほど前から部品を集め、年末年始の空き時間に制作したが、その過程で usb_sound_card.c の不完全さにいくつか気づき、修正することになったという経緯だ。
その結果出来上がったのが上のイコライザ付きのUSB-DACである。詳細は自身のHPで開発経緯を書いているので、ぜひ読んでみて欲しい。オーディオマニアは歴史的に?イコライジングを嫌い、アンプの出力までフラットな特性を保ち、スピーカーになんとか頑張ってもらって低音を出すべし・・という妙なルールがまかり通っている。そんな無理をしなくても、特に大音量での再生を求めなければ、響かせない「デッドな」スピーカーに、イコライジングで補正したほうがキレのある音が楽しめる。
今回このことで、人生始めてのプルリクエスト・・しかも、こともあろうに pico-SDK の総本山・・をやってしまった。それがどう出るか、流儀や方法が間違ってないか・・不安であり、楽しみでもある。