pyusbでオーディオインターフェースコントローラーを作る (1)

私は趣味として音楽活動を行っているのだが[^1]、元々プライベートで弄っていた&在宅ワーカーのころに仕事で使っていたLinuxマシン上で音楽制作を始めた関係で今でもLinuxがメインの制作環境になっている。そしてLinuxで音楽制作を行う上でのハードルはいろいろあるのだが[^2]、大きな問題の一つはタイトルにもある**オーディオインターフェースの選択肢が少ない**という事。いや後述の通り最近はかなり改善しているのでそこまで問題でもなくなりつつあるのだが。

オーディオインターフェースというのは簡単に言ってしまえば様々な機材を繋いでローノイズで録音/再生したりできるもので音楽制作をする上でほぼ必須の機材なのだが、当然音楽制作におけるLinuxの市場規模はハードウェアメーカーにLinux用ドライバーを書くモチベーションを与える規模からは程遠いため、そこについては期待できない。

とはいえここ数年ではUSB Audio Class[^3]に適合した**USB Class Compliant**を謳う機材が増えており、それらは汎用ドライバーで基本的な機能が使えるので結果としてLinuxでも利用できるようになっている。[^4]一方で汎用のドライバーでは当然限界があり[^5]、それぞれの機器独自の機能は一切使えない。私はこれまでNative Instrument Komplete Audio 6を愛用しており、それは汎用ドライバーでも不自由する事はなかったのだが、最近購入したFocusrite Scarlett 18i20というオーディオインターフェースは汎用ドライバーで対応できない機能が多くあり、それらはFocusrite社の提供するソフトウェア（Focusrite Control）経由でのみコントロールできる。

「なんでそんなものを買ったのだ？」と言われれば**「最初から自分でLinux用コントローラーを作るつもりだったから」**なのだが、ちょうど技術記事のネタになりそうなので、これから何回かに分けてpyusbでのオーディオインターフェースコントローラーの作成を書いていく。

# 使用するライブラリやソフトウェア

前述の通り独自機能を使用するためのドライバーはLinux版がないので、ここではWindowsでScarlett 18i20とFocusrite Controlのやりとりをキャプチャーして解析する。

* pyusb https://github.com/pyusb/pyusb
 * PythonでUSB機器にアクセスするためのライブラリ。libusbに依存。
* libusb https://libusb.info/
 * カーネルモードドライバーなしにUSB機器にアクセスするためのライブラリ。
* usbdk https://github.com/daynix/UsbDk
 * USB Development Kitという名前の通り、USB機器と直接やり取りするためのライブラリらしい。libusbのバックエンドとして使える。
* USBPcap https://desowin.org/usbpcap/
 * USB用のパケットキャプチャソフト。
* Ratatoskr https://github.com/tokihk/Ratatoskr
 * TCP/UDP通信、シリアルポートやUSB機器のキャプチャなどに使える汎用ソフト。USBPCapでUSB通信を行っているようだ。フィルタリングや表示のカスタマイズなど便利なので、USBPCapだけよりはよほど効率的。
* Focusrite Control https://focusrite.com/focusrite-control
 * 前述の通り、Scarlettシリーズなどのコントローラー。

当初はlibusbだけでどうにかなるだろうと思っていたが、libusbのWinUSBバックエンドのソースをチラッと見たところ一部の機器に対しては対応できないようで、そしてScarlett 18i20はその対応できない機器のようだ。（後述のコードをusbdkを使わずに動かすとエラーになる。）usbdkバックエンドを使うのであればpyusbでの操作が可能になるが、どうにも**稀にOSを巻き込んで落ちる事がある**ので、記事でやっていることを真似したい人は自己責任で。

# プロトコル解析手順

* Ratatoskrを管理者権限で起動。
* Scarlett 18i20の電源をON。
* Ratatoskrのデバイスの設定を行う。
* Focusrite Controlを起動。
* Focusrite Control経由あるいは直接機器を操作し、Ratatoskrに表示される通信内容を分析する。

書いてしまえばただこれだけの事なのだが、これが結構面倒くさい。いやもちろんプロトコルの解析は多かれ少なかれ面倒だが、例えば特に何もせずとも下記画像のように大量のデータのログが吐き出されてしまう。

<img width="640" alt="scarlet1-1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531760/16f5a811-52ae-6d5e-54ff-727f233b34a4.png">

とにかくすさまじい頻度で通信が行われており、Focusrite Controlで何か操作してもすぐにログが流れてしまう。しかしこの大量の通信を見ていくと、これを手掛かりに結構なことが分かる。

まずどうも連番のようになっている部分があるので、恐らくScarlettは連番でコントローラーのソフトウェアが整合性の取れた状態かを判断していると考えられる。そして272バイトのScarlettからのコントロール転送は殆ど0埋めされている事が気にかかる。というわけで以下のフィルターをかけてからいろいろと操作をしてみる。

```
DataSize == 272 && HexString != /^011000000001[0-9A-Z]{4}0{264}/
```

試行錯誤の結果、どうやらこの大量のログはオーディオデータ、恐らくはFocusrite Controlのモニター表示用の物だろうとあたりが付いた。これは後々同様のモニター機能を作ろうと思った場合には使うことになるが、直近特に必要にはならないので、以下のフィルターでサプレスしてしまう。

```
DataSize != 272 && HexString != /^2102000003001800011000000/ && DataSize != 0 && HexString != "A103000003001001" && HexString != "0100000000000000"
```

このフィルターをかけると本来必要なインタラプト転送までサプレスされてしまうのが、そこは適宜フィルターを一部外して確認する必要がある。

なおScarlettの電源を入れた直後や後述のスクリプト実行時にも上記とは別の大量の通信が確認できる。通信内容のダンプを見る限り、恐らく状態の問い合わせを行うセットアップシーケンスである。またusbdkを使ったプログラムを走らせると同じ通信が確認できるので、

* usbdkが電源ON/OFFに近いリセット処理のような事をやっている？
* 何らかのトリガーでFocusrite Controlと一緒にインストールされるFocusrite Notifierがセットアップシーケンスを起動している？

といったところが推測できる。

# 簡単な状態の問い合わせ

大体解析の流れを把握したところで、まずはScarlettの状態の問い合わせを解析し、Pythonで状態の問い合わせ処理を書いてみる。本来の目的はLinuxでの動作だが、Focusrite Controlによる正規の通信内容を解析しつつ試行錯誤でコードを書いていくので、一旦はWindows環境でコードを書くことにする。

## 問い合わせ処理の解析

まずはFocusrite ControlがScarlettの状態をどうやって受信しているのかを探り当てる必要がある。単純に考えれば、本体の状態切り替えのボタンを何かしら押したときの状態変更をインタラプト転送で検知しているはずだ。

実際にScarlett本体のINSTやPADを適当に押していくと、以下のようなデータが送受信できる。RECVはScarlettからの受信データ、SENDはScarlettへの送信データである

```
RECV Interrupt 00 00 80 00 00 00 00 00
SEND Control   21 02 00 00 03 00 18 00 00 00 80 00 08 00 EE 99
               00 00 00 00 00 00 00 00 7C 00 00 00 18 00 00 00
RECV Control
SEND Interrupt 
RECV Interrupt 01 00 00 00 00 00 00 00
SEND Control   A1 03 00 00 03 00 28 00
RECV Control   00 00 80 00 18 00 EE 99 00 00 00 00 00 00 00 00 
               01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
               00 00 00 00 00 00 00 00
```

これはScarlett 18i20のアナログ入力1のマイクモードをINSTに変えた時の結果だが、実は他のボタンを押した場合でも最後のデータ以外のやり取りは通番以外変わっていない。つまり

* インタラプト転送でデバイス状態の変更通知を受信する。
* ホストがコントロール転送でデバイス状態の問い合わせを行う。
* ホストがインタラプト転送の読み込みを行う。
* ホストがデータ要求のコントロール転送を送信する。送信データの先頭ビットが立っている=デバイスからのデータ送信要求、末尾2バイト=データ長で、この場合40バイト
* Scarlettから各種アナログ入力の状態が返ってくる。

という流れのようだ。また最終的な受信データのフォーマットは、

* 1..8バイト: コントロール転送のセットアップパケット。
* 9..16バイト: 後述エラーケースも含めて考えれば応答ステータスか？
* 17..24バイト: アナログ入力のLINE/INST
* 25..32バイト: アナログ入力のPAD
* 33..40バイト: アナログ入力のAIR

となっている。

## 問い合わせコード

大体わかったところで、実際にアナログ入力の状態を問い合わせるコードを書いてみる。

```analogstat.py
import usb.core
import usb.backend.libusb1
from ctypes import c_void_p, c_int
backend = usb.backend.libusb1.get_backend(find_library=lambda x: "libusb-1.0.dll")
backend.lib.libusb_set_option.argtypes = [c_void_p, c_int]
backend.lib.libusb_set_debug(backend.ctx, 5)
# 以下はusbdkを使うためのオプション
backend.lib.libusb_set_option(backend.ctx, 1)
# Scarlett 18i20のベンダーIDとプロダクトID
VENDOR_ID = 0x1235
PRODUCT_ID = 0x8215

device = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID, backend=backend)
device.set_configuration()
# usbdkのせいか勝手にセットアップシーケンスが走って、大抵は次の通番が0x77になる。
device.ctrl_transfer(0x21, 0x02, 0x00, 0x03,
    [0x00, 0x00, 0x80, 0x00, 0x08, 0x00, 0x77, 0x00,
     0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
     0x7C, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00])
# インタラプト転送読み込み。
# デバイスのエンドポイント3から読み込み=83、8バイト、タイムアウト100ms
ret = device.read(0x83, 8, 100)
print(' '.join(map(lambda x: '{0:0{1}x}'.format(x, 2), ret)))
ret = bytearray(device.ctrl_transfer(0xA1, 0x03, 0x00, 0x03, 0x0028))
print(' '.join(map(lambda x: '{0:0{1}x}'.format(x, 2), ret)))

```

※前述のとおり勝手にドライバーが通信を行ってログが著しく見にくくなるため、適当に通信の前後にsleepを入れた方が見やすい。

入力1のINSTとPADとAIR、入力4のAIR、入力5のPAD、入力8のAIRをONにし、上記のコードを実行した結果の最終的な出力は

```
RECV Control 00 00 80 00 18 00 77 00 00 00 00 00 00 00 00 00 
             01 00 00 00 00 00 00 00 01 00 00 00 01 00 00 00
             01 00 00 01 00 00 00 01
```

ビンゴ。正常な動作が確認できたところであえて通番をずらすなどおかしな通信を行ってみよう。

```
device.ctrl_transfer(0x21, 0x02, 0x00, 0x03,
    [0x00, 0x00, 0x80, 0x00, 0x08, 0x00, 0xFF, 0x00,
     0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
     0x7C, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00])
```

通番を先に進めた状態でアナログ入力の状態問い合わせのコントロール転送を送信。

```
RECV Control 00 00 80 00 00 00 77 00 03 00 00 00 00 00 00 00
```

どうやら後半8バイトにエラーステータスが入るようだ。なお40バイトのデータを送るよう要請したのに16バイトしか送信されてきていないが、これは確かUSBの規格で認められた挙動。データ長が要求されたサイズを超える場合だけがNG。

----

長くなったので今回はここまで。次回は

* 実際に機器の状態をpyusbを用いて操作してみる。
* Linuxからもコードを動かしてみる。

この2つをやってみる。

[^1]: https://return0.info/ 趣味というにはオーバーキルとは言われるが……。
[^2]: 対応機材だけでなくソフトウェア音源の少なさの問題などもあるので、推奨はしない。とはいえLinux用のバイナリしかないプラグインを使っているし、そもそもソフトウェア音源を多用せず演奏の録音がメインだし、それで海外アングラレーベルからオファーが来る程度のクオリティの作品は作れてはいるのだが。
[^3]: [規格のドキュメント](https://www.usb.org/documents?search=Audio&type%5B%5D=55&items_per_page=50)はかなり膨大で、今回やっているような適当なリバースエンジニアリングにおいてはリファレンスとして使うのも面倒というのが正直なところ。
[^4]: この背景にはiOSやAndroidを搭載したモバイル/タブレット機器での音楽制作の需要が増えており、専用ドライバーをインストールする事が困難な機器への対応という側面が強いようだ。
[^5]: 録音、再生、デジタル機器のクロックソースの切り替え、サンプリング周波数の切り替え程度が該当する。また汎用ドライバーの場合、機能面以外にレイテンシーでも不利になる事がある。とはいえ入力～出力までの全処理を含めたラウンドトリップレイテンシーで20ms程度ならそんなに問題にはならないと思われる。なおプロのスタジオで使われるPro Tools HDXでは0.7msとかの世界らしい。