Pico2でビットパーフェクトな光デジタル出力USB AUDIO deviceを作りたい
はじめに
Raspberry Pi Pico2でtiny-USBを使わずに単純なUSBデバイスを作ってみるという記事を以前書きました。
これを大幅に改造して、USB Audio Class(UAC) Deviceを作ろうという試みです。
UAC Deviceとは何かというと、いわゆるUSBオーディオインターフェースとか言われるやつです。
出力は光デジタル(S/PDIF)で行います。
ぶっちゃけるとわざわざ作らなくても、アリエクとかでCM108とかで検索して出てくる数百円のUSBサウンドモジュールとかに光デジタルコネクタを付けるとそれで終わります。
さらにいうと、Raspberry Pi PicoであればFoxDACという前例が既にあります。
そもそも、UACはTinyUSBで使えますし、S/PDIFはpico-SDKのサンプルにあります。
が、それはそれ。あくまで自分で作ってみたいということで。
※ちなみに実質やってるのはソフト開発が99%ですので、これをハードウェア開発というのかはかなり微妙なところかと思いますが、S/PDIFの動作原理についてはバリバリにハードウェア領域かと思いますので(UACは微妙)、elchikaへの投稿とさせていただきました。
目標
ビットパーフェクトな光デジタル(S/PDIF)出力のUSB AUDIO Deviceを作る。
TinyUSBは使わず、USBプロトコルスタックから自前で作る。
仕様
- 48KHz/24bit/2ch専用
- 基準クロックはPico2のデフォルトのシステムクロックの150MHz。
- Asynchronous
必要なもの
- RP2350開発ボード
WeACT Studio RP2350A_V20を使用しました。
こちら→https://ja.aliexpress.com/item/1005008117237405.html - 光デジタルコネクタ
秋月のこちら→https://akizukidenshi.com/catalog/g/g109598/ - UART→USB変換ブリッジ
ログ表示のために必要です。3.3Vでそのまま接続できるものが便利です。 - その他配線資材、ブレッドボード等
UACの概要
UACはUSB接続で音声を扱うための規格で、1.0と2.0があります。
今回やりたいことは1.0で良いので1.0しかサポートしません。
よって、本記事の記載内容はすべて1.0をベースに記載しています。2.0との細かい差分は調べていないのでわかりません。
UAC1.0はマイクやUSB-DAC、オーディオインターフェースなどで利用され、多くのOSで標準ドライバによる認識ができます。音量調整などの制御にも対応します(今回作成するものは非対応)。UAC2.0と比べると対応できるbit数・サンプルレートなどに制限があります。
Asynchronousについては聞きなれないかもしれません。USBオーディオにおけるAsynchronous方式は、音声データを送る側(HOST)ではなく、受け取る機器側のクロックを基準にオーディオデータのサンプルレートを決める方式です。これにより、HOST(PC等)側のクロック基準ではなく、USB Device側のクロック基準でサンプルレートが決定します。
Asynchronousとクロックの関係
Asynchronous方式では、USB Device側に水晶発振器などの高精度なクロック源(例:6.144MHz)を用意し、それを基準にDACやS/PDIFの送出クロックを生成できます。HOSTからのUSB転送は、このDevice側クロックに合わせて制御されます。一方、Synchronous方式ではHOST側のUSB転送タイミングを基準とするため、受信側でPLLなどを使ってDACやS/PDIF送信に必要なクロックを生成します。そのため、クロックジッターなどの影響を受けやすくなります。
・・・というのは建前で、
Pico2で実装する上でのAsynchronous
Synchronous方式で作ろうとすると、USB DeviceがHOST側から得られるクロックは1KHzです。S/PDIFの送出は6.144MHzですので、なんとかして1KHzから6.144MHzを作らなくてはいけません。しかもなるべく低ジッターで抑える必要があります。Pico2でこれをやるには結構厳しいです。
であればPico2側に6.144MHzクロック源を持たせて、HOST側がそれに同期してくれるほうが簡単です。
とはいえ今回は6.144MHzはPico2のシステムクロック(150MHz)からfractional dividerで作るので、そもそも150MHz1クロック分のジッターが乗りますし、そのほかにPLLジッターも乗ります。音質的観点でいえばまったくもってメリットはありません。
ですが、外部に6.144MHz(もしくはそれの整数倍。よく使われるのは12.288や24.576MHz)のクロック源を用意すれば、S/PDIFの送出クロックをPLLに頼らず生成でき、高品質な信号を送信できるので、部品を一つ用意するだけでこの問題は解決します。
が、めんどくさいので今回はPLLとfractional dividerを使います。
USB概要
USB自体の詳細については、「Raspberry Pi Pico2でtiny-USBを使わずに単純なUSBデバイスを作ってみる」の「USB通信の概要」を参照してください。
リンク先記事で触れていない内容で、今回使用するIsochronous転送について触れます。
Isochronousとは
一定周期でデータを転送する方式です。音声や映像など、リアルタイム性が重要なデータに適しており、一定の帯域が確保される一方、再送制御は行われないため、エラーが発生したデータは再送されません。
つまりBulk転送ではエラー発生時に再送されますが、Raspberry Pi Pico/Pico2ではUSBハードウェアが自動的に処理するため、通常はその点を意識する必要はありません。Isochronousでも同様です。
転送周期はConfiguration Descriptor内のEndpoint DescriptorにあるbIntervalで定義されます。なお、bRefreshは主にfeedback Endpointなどで使用される値で、通常のIsochronousデータ転送の周期を直接指定するものではありません。詳しくは後述します。
UACの詳細
endpoint構成
- EP0 control IN/OUT
初期ネゴやInterface選択(後述)に使います。 - EP1 OUT(Isochronous)
実際のオーディオデータの転送に使います。 - EP1 IN(Isochronous)
feedbackクロック制御(後述)に使います。
Configuration
今回のConfiguration Descriptorの概要は以下の構成になります。さらに下位の階層があったり、細かいパラメータはソースを参照してください。
Configuration Descriptor
├Interface Descriptor 0(Audio Control用)
│└terminal情報等
├Interface Descriptor 1/Alternate0(再生停止時)
└Interface Descriptor 1/Alternate1(48k/24bit/2ch再生時)
├オーディオフォーマット情報
│└オーディオデータ用Endpoint情報
└feedback用Endpoint情報
Interface Descriptor
Interface Descriptorは、それぞれの機能の塊(Interface)と思ってください。今回の場合、
- Interface 0
主に管理用です。terminal情報(後述)が必須です(たぶん)。その他、今回は実装していませんが、ボリュームコントロールボタン等がある場合はそれらの管理もこのInterfaceが行います。そのため、そのような機能があるUACは、ここにもEndpoint情報が存在する場合があります。 - Interface 1
再生用。Alternate(以降Altと記載)はHOST側からの要求により機能が切り替わります。
今回は、Alt0は再生停止時の状態です。停止していますので、Endpoint情報は存在しません。通常、停止時は必ずAlt0とするようです。
Alt1は再生時に使用します。そのため、実際にデータを受信するためのEndpoint情報と、feedback(後述)を行うためのEndpoint情報が存在します。
複数のサンプルレートなどに対応しているものは、その設定ごとにAltが存在します。 - Interface 2
今回は存在しません。録再両用Deviceの場合は、Interface 2が録音用として存在します。
terminal情報
今回は3つのterminalが存在します(ソース参照)。terminalとはなんらかの「出入口」であると考えてください。
-
Audio Control Input Terminal
wTerminalTypeが0x0101(USB Streaming)となっています。これはつまりUSB側の入口です。HOSTとデータをやり取りするターミナルです。
2chオーディオに対応しています。bTerminalIDは1です(ほかのterminalと被らなければ何でもよい)。 -
Audio Control Output Terminal(1)
wTerminalTypeが0x0301(Speaker)となっています。実際のところHW的に実装はしていませんが、HOST見えにはスピーカーDeviceです。いわゆるアナログの出口です。
ちなみに、今回は使用していませんが、wTerminalType:0x0603(Line connector)という定義もあります。
bSourceIDを0x01としていますので、上記Audio Control Input Terminalをソースとして入力し、ここから出力するという接続になります。 -
Audio Control Output Terminal(2)
wTerminalTypeが0x0602(Digital audio interface)となっています。ここが今回作りたかったメインです。Audio Control Output Terminal(1)と同様、bSourceIDを0x01としていますので、上記Audio Control Input Terminalをソースとして入力し、ここから出力するという接続になります。
つまり、音としてはOutput Terminal(1)も(2)も同じ音がなります。ここをHOST側から区別して別のデータを流すことはできません。
であれば、terminal情報としてはAudio Control Output Terminalを1つだけにして、HOST見えにはアナログ口だけにしてHW的にはS/PDIF出力だけにすればいいような気もしますし実際可能です。が、一部ソフトでデジタルしか出力させないとかアナログしか出力させないといった制約があるため(実際のところ別ストリームを流せないので両方に出力される)、このような構成にするのが良いようです。
feedback
Asynchronous方式のキモです。
前述したとおり、S/PDIF信号の送出レート(=実質的にサンプルレート)はUSB Device側が管理します。
HOST側の基準クロックと、Device側の基準クロックには必ず誤差(製造誤差等)があるため、お互いが自分の基準でデータを送信し続けると、いつか不整合が発生します。
そのため、Deviceは、HOSTからデータが送られてくる間隔(1ms)を基準に、その基準時間で自身が何サンプル再生したかを求め、HOSTから見たDeviceのサンプルレートをHOSTに通知し、HOSTはそれに合わせてDeviceに送るデータ量を調整する、という動作をします。
今回の作例では、feedback endpointはbInterval(基準時間)=1ms、bRefresh(更新間隔)=32回ごと、となっているため、32msごとにHOSTからFeedbackのIN要求が来て、Deviceはその時点でFeedback値(実質的にはHOSTから見たDeviceの実サンプルレート)を返します。
ちなみに実際にfeedbackとして送る実データは、理論値は786432(0xC0000)となります。これは48(1msあたりのサンプル数)×16384(UACの仕様(10.14形式)としての固定値)です。
今回の実装としては、16384ms=更新512回の間に実際にS/PDIFに送信したサンプル数をfeedbackの実データとして使用します。
なので、再生を開始してから16384ms間は過去の送信済みサンプル数の情報がないため正しいfeedbackを行えず、データ欠落などが発生します。
では、なぜ16384msという長い期間でサンプル数を測定するようにしたかというと、feedback値は10.14形式で表すことに関連します。
10.14形式とは、10bit整数部+14bit小数部という形式ですが、イマイチわかりにくいため、もう少し簡単に考えてみます。
2^14=16384ですので、例えば小数値48.000を10.14形式に変換するなら、48.000×16384=786432(0xc0000)で、48.000を10.14形式で表すと0xc0000という実値になります。
ここで、48kHzの場合、1msあたりのサンプル数は48なので、単純に 48 × 2^14 = 786432 がfeedbackの理論値になります。
今回はこれをもっと長い時間で測定します。feedback値の更新間隔は32msなので、512回分をまとめると、
32ms × 512 = 16384ms
となります。
この16384msの間に、実際にS/PDIFへ出力したサンプル数を数えます。48.000kHzぴったりなら、
48000 × 16384 / 1000 = 786432サンプル
となります。
なので、16384msの間に実際にS/PDIFへ出力したサンプル数を数えることで、それをそのまま10.14形式のfeedback値として使えます。
実際のサンプル数がこれより多ければ実際のサンプルレートは48kHzより高く、少なければ低いことになります。
つまり、Pico2のクロックがHOSTのクロックとどの程度ずれているかを、実際にS/PDIFへ出力したサンプル数から測定してHOSTへ伝えているわけです。
※これはUACの規格上512回分の測定値を使えという仕様があるわけではなく、512回分を使用することで計算が簡単になり実装しやすいという理由です。
本来は過去の送信済みサンプル数情報が少ない場合の処理を入れるべきですが、妥協して割愛しています。無音でもいいのでまずは16秒程度再生してから使いましょう。
Windows11の場合、デフォルトサウンドデバイスとして選択した時点で無音のストリームが流れる(らしい、たまに止まる)ので、実仕様ではそんなに問題ないでしょう。
というかfeedbackしない場合でも、今回試した限りではせいぜい数秒ごとに1サンプル欠落するとかなので普通に使っている限りはそうそう気づきません。
※今後AC-3とかの圧縮データを流したいとか考えているので、それを考えると欠落は許容されないのでfeedbackの実装は今回の実験での必須条件としました。
S/PDIFの詳細
S/PDIFについて、基本となる構造は、「TangNanoでS/PDIFミキサー(簡易版)を作る」の「チャンネルステータス」の章に記載しています。
また、チャンネルステータスについては、「Tang Nano 9kでS/PDIFミキサーを作る(ver2)」の「補足:SPDIFのチャンネルステータス」の章に記載しています。
ここでは上記で記載していない箇所について説明します。
BMC(Biphase Mark Coding)
サブフレームは32ビット長ですが、その送信したい32個の「1」や「0」が実際にデジタル信号としてどのように送信されるかという内容です。
ここでは区別するために、実際に送信したい値を「0」「1」と表記し、実際に電気信号として送信する値を「H」「L」と記載します。
光デジタルで送信する場合、H/Lのどちらが光っている状態か、という規定はありません。どちらの極性でも動作します。
BMC符号化方式はビット列に対し、1/0をHL/LH/HH/LLのどれかに割り当てて送信します。つまり、送信データレートは2倍となります。
なので、サンプルレート48KHzの場合、1フレーム64bitを2倍レートで送信するため、48KHz×64bit×2倍=6.144MHzの送信レートになります。
ここで、符号化は単純に下記となります。
「1」→HL or LH
「0」→HH or LL
つまり、「1」の場合は信号が変化する、「0」の場合は信号は変化しない、となります。
ただ、これだけだと、例えばずっと0が続いた場合は信号がずっと変化しないので、ビット境界では必ず出力を反転するという取り決めがあります。
つまり、
「0000」→「HHLLHHLL」or「LLHHLLHH」 (どちらを使うかは一つ前の状態による)
「1010」→「HLHHLHLL」or「LHLLHLHH」 (どちらを使うかは一つ前の状態による)
となります。そのため、HまたはLが3つ続くということは起こり得ません。HまたはLを3つ続けてはいけない、という規定だけだと、
「1111」→「HLLHHLLH」
などでも満たせますが、仕様としてNGです(が、実際このような出力をする機器を見たことがあります)。
そのため、BMCで符号化されたものはH/Lといった値自体に意味はなく、あくまで「変化したかしないか」だけが重要になります。
ただ、これだけだとどこがフレームの始まりか、どこがチャンネルステータスの始まりか、などがわからないため、それを区別するためにPREAMBLE部に特殊なパターンを埋め込みます。PREAMBLE部は3種類あり、それぞれ「Bマーク」「Mマーク」「Wマーク」と名前が付いています。
| 名称 | BMCパターン(左から順に送信される) | 備考 |
|---|---|---|
| Bマーク | HHHLHLLL | ブロック先頭 |
| Wマーク | HHHLLHLL | フレーム後半のサブフレーム先頭 |
| Mマーク | HHHLLLHL | ブロック先頭以外のフレーム前半のサブフレーム先頭 |
※便宜上BMCパターンは1パターンのみ記載しました、極性反転パターンもあり得ます。
このように、PREAMBLE部のみHまたはLが3つ続くパターンが存在します。
レシーバでは、この3つ続くパターンを検出し、そこをフレーム先頭として認識します。また、Bマークが来るのを待ち、そこからチャンネルステータスビットを拾い、192フレーム受信したところでチャンネルステータスがすべて受信完了します。
ソースについて
USBプロトコルスタック部については、Raspberry Pi Pico2でtiny-USBを使わずに単純なUSBデバイスを作ってみるで作成したものをかなり流用しています。そのため、そちらの記事も参考にしてください。
今回は、Configuration Descriptorが64byte(EP0のMAX packet size)を超えるため、パケット分割を行う必要があり、その対応が入っています。
主にUACに関連する実装はusbdevice.cpp、UACのコンフィグ関連はUAC.c、S/PDIFに関係するところはspdiftx.cに記載しています。
spdiftx.cはbmcエンコード処理とフレームバッファにあるものをGPIO(PIO)に出力する処理で、USBから受信したPCMデータをフレームバッファに詰める処理はusbdevice.cppにあります(詰める際にspdiftx.cのbmcエンコード処理を使う)。
S/PDIF関連処理とusbdevice.cppの切り分けがイマイチな気はしますが、とりあえずお試しということで・・・。
S/PDIF関連
spdiftx.cで、初期データの作成と、PIOを使用してBMCフレームバッファからGPIOへの出力をしています。
フレームバッファは、uint64_t txbuf[192][2]を用意し、BMC符号化済みのビット列を格納します。
1サブフレームはBMC符号化前は32bitですが、BMC符号化でデータ量が2倍に増えるため、64bit長となります。
末尾の配列で2サブフレーム分を確保しているため、txbuf[n]が1フレーム分のデータとなります。
これをDMAをつかってPIOに流し、PIOは受信したデータをシリアル化して出力します。
PIOのクロックはfractional dividerを使って、システムクロック(150MHz)から6.144MHzを作り、それで動かしています。
DMAは192フレーム分PIOに送信すると終了しますが、DMA chainを設定しており、DMA0が終わった直後にDMA1が自動で動き始めます。
また、DMA1が終わるとまたすぐにDMA0が動き始めます。
ただし、DMAの転送元アドレスは毎回設定しないといけないため、DMA終了後に割り込みを発生し、転送元アドレスの再設定をしています。
初期データの作成は、フレームバッファにPREAMBLE、無音PCMデータ、チャンネルステータス(all 0)を設定します。
また、本来設定すべきチャンネルステータスのデータもchstatusに設定し、PCMデータを実際に設定するときにまとめて設定します(本当は初期化の時に正しいチャンネルステータスを設定すべき。とはいえ実際に音を出すまでは設定しなくても問題ない)。
実際のbmc符号化部(bmcenc)は、PCMデータ、現在のフレーム番号(=チャンネルステータスbit番号)、サブフレーム番号を与えることで、サブフレームのbit12~27(PCMデータ)、bit28~31(VUCP)をBMC符号化したデータを返します。パリティbitがあるため、サブフレームのbit数は必ず偶数になります。
そのため、正しいデータを流し続けると、BMC符号化したデータはフレームの先頭はかならずHで始まり変化しません。ですので、PREAMBLE部とそれに続くAUX部、PCMデータのLSB部は更新する必要はありません。
BMC符号化されたデータをフレームバッファに書き込むのはbmcenc()の呼び元です。
UAC関連
基本的にセットアップシーケンスは前回作ったそのままです。
SET_INTERFACEを受信した場合、Interfaceの切り替えとなります。実際にはPCMデータの送信開始か、送信停止指示となります。
この際、BMCフレームバッファの初期化と、バッファ書き込み位置の初期化をします。
特に送信停止時、BMCフレームバッファのクリアをしないと、バッファに残ってる音声データがずっと送信され続けます。
EP1 in要求受信した場合、feedback要求ですので、前述したとおり過去512回分の実際の送信サンプル数を計算し、feedback値として応答します。詳細はソースのコメントを参照してください。再生中(SET_INTERFACEでAlt1選択された後)であれば32msごとに要求が発生します。
EP1 outを受信した場合(ep_out_handler)、それはPCMデータなので、BMCフレームバッファへ格納します。Isochronous転送なので、再生中であれば1msごとにデータを受信します。
この際、SET_INTERFACE直後はDMAがどこまでデータを送信しているのかが不明なため、まずDMAの送信中の位置をしらべ、その少し先(具体的には10サンプル)からPCMデータを格納していきます。
2回目以降は、前回格納した位置の続きから格納していきます。
feedbackが正しく行われていれば、DMAを使用したS/PDIFの送信データ量とUSBからの受信データ量は等しくなるので、DMA送信中の位置とUSBから受信したデータを格納していく位置関係はずれません。
ただし、USB接続後初回の再生直後のfeedbackが正しく行われていない場合では、DMA送信中の位置とデータ格納位置がずれてきます。そのため、同期ずれが起きた場合は強制的に位置関係を再同期します。その際、データ欠落や重複が発生する可能性があります(logに"force syncと表示")。
ちなみにUSBからは24bitのPCMデータを受信しますが、実際に使うのは16bitだけです。単なる手抜きです。
ソース一式
直接ここに貼れる量ではないため、google driveに格納してあります。
※ソースファイルのライセンスについては同梱のlicence.txtを参照してください。本記事のライセンスとは異なります。
https://drive.google.com/drive/folders/1zATaHLxcjW_jsZ7C7SXF04CW4pmrSvQk?usp=sharing
build方法
rp2350用にmake環境を作る場合は、buildディレクトリを作成し、buildディレクトリ配下で以下のコマンドを実行します。
$ cmake -DPICO_PLATFORM=rp2350 -DPICO_BOARD=pico2 ..
その後、コンパイルは
$ make
で、uf2ファイルが作成されます。
※pico-SDKのインストールが完了していて、パス設定までは完了している前提です。
動作確認
ここまですべてソフト的な解説で電子工作とは程遠いですが
、やっとここで実際に配線してみます。
とはいっても光デジタルコネクタとUARTをPico2ボードに接続するだけです。
3.3Vを受けれるUART-USB変換があればそのままつなげられるので便利です。このあたりは各自適当に工夫してください。
UARTは460800bpsです。
実際にlogを表示しつつWindows11に接続すると、SETUPが終わった後、(デフォルトデバイスに選択されていれば)SET_INTERFACEでAlt1が選択され、"force sync"が何度か表示されると思います。
SET_INTERFACE(Alt1)から16.384秒経過するとfeedbackは落ち着くはずです。このfeedback値はPico2側クロックとPC側クロックのずれで決まるため、一度feedbackが安定すると、再生停止しAlt0に切り替わった後、また再生開始してAlt1に遷移しても"force sync"は起きないはずです。
feedbackが安定した状態であれば、少なくとも3分くらい試した感じではビットパーフェクトで再生できているようでした。
Windows11でどうやってビットパーフェクトの検証をするかは、「Tang Nano 9kでS/PDIFミキサーを作る(ver2)」の「動かす」を参考にしてください。
Androidからでも再生できましたので、Configuration Descriptorの作り方についても特にWindows依存とかしていないようで大丈夫みたいです。
おわりに
全力で車輪の再発明をしました。他人が作ったものを使用するほうが簡単ではありますが、動作理解や実験という意味では自分で作ることに価値があると思います。
改善案としては、前述したとおり、外部水晶を使って音質向上(するのか?既にビットパーフェクトなのだが・・・)を目指すとか、feedbackの実装をきちんとするとかがまずあると思います。
また、AC-3やDTSのストリームを流せるようになれば、AVアンプに5.1chオーディオを流せるようになります。圧縮ストリームを光デジタルで流せるUACデバイスはそこそこ高いので、もし実現できれば1000円かからずに作れそうですね。
参考文献
USB Device Class Definition for Audio Devices 1.0 (おもに概要とconfig descriptorの作り方)
USB Device Class Definition for Audio Data Formats 1.0 (おもにcontrol(音量ボタンなど)やオーディオフォーマット関連)
USB Device Class Definition for Audio Terminal Types 1.0 (おもにterminal関連)
IEC-60958-1 Digital audio interface –Part 1: General (おもにフレーム構造やBMC)
IEC 60958-3 PART 3 CONSUMER APPLICATIONS (おもにチャンネルステータス)
投稿者の人気記事


-
lyricalmagical
さんが
前の日曜日の2:06
に
編集
をしました。
(メッセージ: 初版)
ログインしてコメントを投稿する