Raspberry Pi PicoとRustでUSBオーディオインターフェイス内蔵シンセを作ってみたいっ!
はい、前回はPicoとRustの入門でした。実際にシンセを作っていくにあたっってなんとか音声出力をつけてみたいです。Picoにスピーカーをつければ良いのでしょうけれど、PicoってDACがついていなくてスピーカーに音声信号を出力しようとすると、まずDACをつなげてデジタル信号をSPIなりI2Cなりで送ってアナログに変換して、それをいい具合の電圧に調整してスピーカーに出力することが必要になります。PWMを使って擬似的に出力することも可能らしいのですが一気に手間が増えちゃうんですよ。おまけに自分はアナログ回路が全然得意じゃないです(というかそちらも勉強しなければ)
なのでアナログを介さずデジタルのまま信号を取り出すことに挑戦してみました。
デジタル信号のまま取り出すにはやはりSPIやI2Cなどの通信プロトコルを使って・・・となるのが通常だとは思うのですが今回はUSBで取り出すことにします。つまり。
USBオーディオインターフェイスを作る!
ってことですね。このnoteでもたびたび取り上げてきたオーディオインターフェイス。家の中にもオーディオインターフェイス機能の付いた機材がもう10個くらいは転がってそうですがついに自作に挑戦するところまでやってきました。700円くらいでオーディオインターフェイスが作れちゃうなんていい時代になりましたね(まだできてません)
前回のおさらい
前回はIDEをセットアップしてRustプログラムの開発環境を作成し、ADCで読み取った値によってLEDアレイの光り方を操作するプログラムを書いてみました。
その後もデバッグ環境の整備で苦しんでいて、その後調査し直してopenocd + picoprobeではなくrust-dapを使う方法にしました。openocd + picoprobeだとprobe-runが動かなくてcargo runにrunner書くのが大変そうっていう理由からです。前回は変数が見えなかったのに今回は見えるようになりました。たぶんopenocdの設定ファイルが変になってたせい。
source [find interface/cmsis-dap.cfg]
transport select swd
adapter speed 10000
source [find target/rp2040.cfg]
現状これできちんと見えています。cmsis-dap.cfgを使っているのがミソ(たぶん)。adapter speedはもっと早くしても良さそうでした。で、嬉しいのがcargo embedコマンドも使えるようになったことです。さくっと動作してくれてとても良いですね。
USBオーディオデバイスの作成
さて、実際に始めていきましょう。まずは先人の足跡を辿るところから。
USBデバイスに関してはCやC++ならtinyusbというライブラリがあって、Picoも既に対応してるというか思いっきりpicoのSDKに入ってます。
同じようなものがRustにもあれば、と探してみるとクレートを発見できました。
rp2040のhalクレートに確かusbがあったような・・・
さらにrp-picoのexamplesにもUSB使うものを発見しました
で、usbオーディオはどれ使えばいいかなーと検索していたらまたもそのままズバリのサンプルコードを見つけました。
もうPico冥利に尽きますね・・・。
んで、ちょいちょいいじって無事オーディオインターフェイスとして認識することを確認しました。
ターミナルからsystem_profilerで確認してもきちんと見えますね。
Pico Audio Interface:
Product ID: 0x27dd
Vendor ID: 0x16c0
Version: 0.10
Serial Number: 100
Speed: Up to 12 Mb/s
Manufacturer: Euskace Sound Labs
Location ID: 0x00134000 / 13
Current Available (mA): 500
Current Required (mA): 100
Extra Operating Current (mA): 0
ポテンショメーターを使って制御可能にする
さて、1kHzのサイン波しか出ないのをどうにかするためにポテンショメータの入力を使ってみましょう。前回出てきたGroveシールドをここでも使います。ゲーミングポテンショメータではなくって普通のボリューム型のポテンショメータです。
アングルユニットって書いてますけど平たく言えばボリュームのことですね。
抵抗値を読み取るので前回同様ADCを使います。
A2スロットを使うとすると電圧出力のピンはA2、つまりPico側のGPIO28ピンをADCとして利用することになります。もしA1スロットを利用する場合はPico側のGPIO27を設定するということですね。
ではこちらのADCの部分を作ってしまいます。ソースはこちら。
これでADCのAnalog値の範囲を取ってみます。二つ使うとやはり差があるのがわかります。
とりあえず時計回りに回しきって24とか29、反時計回りに回しきって2690〜2830くらい。片方のポテンショメータが2690くらいでMax、片方は2830くらいです。歯痒いですね。
で、これらのアナログの値を使うのに安全なのは35から2680としてロジックを組んでいきます。反時計回りに絞りきった状態が0、時計回りに振り切った状態が1となるのが自然な感覚なので2680以上の値を0、35以下を1になるようにマッピングしておきます。
波形出力ロジックの実装
では実際に波形を出力してPicoから音声出力するロジックに取り掛かります。
一応、サンプルのソースを絞りまくって必要最小限にしたものがこちら
#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _;
use panic_probe as _;
use bsp::entry;
use bsp::hal::{clocks::init_clocks_and_plls, pac, usb::UsbBus, watchdog::Watchdog};
use rp_pico as bsp;
use usb_device::{class_prelude::UsbBusAllocator, prelude::*};
use usbd_audio::{AudioClassBuilder, Format, StreamConfig, TerminalType};
#[entry]
fn main() -> ! {
info!("program start");
let mut pac = pac::Peripherals::take().unwrap();
let mut watchdog = Watchdog::new(pac.WATCHDOG);
// External high-speed crystal on the pico board is 12Mhz
let external_xtal_freq_hz = 12_000_000u32;
let clocks = init_clocks_and_plls(
external_xtal_freq_hz,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let usb_bus = UsbBusAllocator::new(UsbBus::new(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut usb_audio = AudioClassBuilder::new()
.input(
StreamConfig::new_discrete(Format::S16le, 1, &[48000], TerminalType::InMicrophone)
.unwrap(),
)
.build(&usb_bus)
.unwrap();
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.max_packet_size_0(64)
.manufacturer("Euskace Labs")
.product("Pico Interface")
.serial_number("101")
.build();
// Sine Waves only for 1ch
let sinetab = [
0i16, 4276, 8480, 12539, 16383, 19947, 23169, 25995, 28377, 30272, 31650, 32486, 32767,
32486, 31650, 30272, 28377, 25995, 23169, 19947, 16383, 12539, 8480, 4276, 0, -4276, -8480,
-12539, -16383, -19947, -23169, -25995, -28377, -30272, -31650, -32486, -32767, -32486,
-31650, -30272, -28377, -25995, -23169, -19947, -16383, -12539, -8480, -4276,
];
let sinetab_le = unsafe { &*(&sinetab as *const _ as *const [u8; 96]) };
loop {
if usb_dev.poll(&mut [&mut usb_audio]) {
usb_audio.write(sinetab_le).ok();
}
}
}
main関数の中でやっていることはいつものようにペリフェラルを引っ張り出したりクロックの設定をしたりということのあとに
USBバスアロケータを作る
USBオーディオクラスをビルドする
USBデバイスをビルドする
loopの中でpollする。
pollの後にusbオーディオクラスに対して書き込みを行う
といった感じです。順序だいじ。
下から10行くらい(?)がめちゃ重要でsinetabとsinetab_leっていうのがサイン波の実態です。sinetabに並んでる0, 4276, 8480…っていう数列はよく1kHzのsine波のバッファに使われるやつみたいです。
この数列がよくわからなかったので手元でプロットしてみました。画像に表示するにはdebug-plotterっていうクレートが便利です。
use debug_plotter;
fn main() {
let sinebuf = [
0i16, 4276, 8480, 12539, 16383, 19947, 23169, 25995, 28377, 30272, 31650, 32486, 32767,
32486, 31650, 30272, 28377, 25995, 23169, 19947, 16383, 12539, 8480, 4276, 0, -4276, -8480,
-12539, -16383, -19947, -23169, -25995, -28377, -30272, -31650, -32486, -32767, -32486,
-31650, -30272, -28377, -25995, -23169, -19947, -16383, -12539, -8480, -4276,
];
for a in 0usize..48usize {
let sinebuf = sinebuf[a] as f32 / 50000.0;
debug_plotter::plot!(sinebuf where caption = "Sinebuf array");
}
}
これだけでplotできちゃいます。ちなみにprotプログラム用のCargo.tomlには
[features]
default = ["debug", "plot-release"]
debug = []
plot-release = []
こういうおまじないを書かなければ動きませんでした。
ということでsinetabをプロットしてみるとサイン波になっているのがわかると思います。Y軸の値については16bitなので65536、これをプラスとマイナスで使っているので値は-32768から32768の間を取ります。
一方sintab_leの中はこんな感じ
[0, 0, 180, 16, 32, 33, 251, 48, 255, 63, 235, 77, 129, 90, 139, 101, 217, 110, 64, 118, 162, 123, 230, 126, 255, 127, 230, 126, 162, 123, 64, 118, 217, 110, 139, 101, 129, 90, 235….]
ただなんでこうなるの?????
let sinetab_le = unsafe { &*(&sinetab as *const _ as *const [u8; 96]) };
と、ちょっと悩みましたが、これi16のバッファ配列をu8のバッファ配列に読み替えてるんですね。例えばsintabの数値の2つめの4276i16は2進数だと0001000010110100なんですよね。これをu8が2つ連なったものと考えると00010000と10110100、つまり16と180になります。で、リトルエンディアンなので順序が逆になってるから180, 16という並びになるってことですね。
48個のi16の配列というのはサンプリング周波数を48kHzにしていて、それをさらに荒くしてカウントしているのかなーと思いました。1kHzのサイン波は1秒間に1000周期なので1秒間にこのi16の配列を1000回送ってるのでしょうか・・・この辺がよくわかりませんでした。出力をMacで録音してイコライザーで見てみると
確かにほぼ1kHzです。試しにボリューム調整してみようと、ルックアップテーブルの要素の値を半分にしたものを出力してみたらちょっぴりボリュームが低くなりました。
音量メーターが50パーセントにならないのはおそらくデシベル換算で表示しているからでしょうか。
この後書き込みに対していろいろ実験してみました。
match usb_audio.write(sinetab_le) {
Ok(_r) => info!("wrote"),
Err(msg) => match msg {
usbd_audio::Error::InvalidValue => info!("invalid vlaue"),
usbd_audio::Error::BandwidthExceeded => info!("bandwidth"),
usbd_audio::Error::StreamNotInitialized => info!("not initialized"),
usbd_audio::Error::UsbError(usb_device::UsbError::EndpointMemoryOverflow) => {
info!("memory overflow")
}
usbd_audio::Error::UsbError(usb_device::UsbError::EndpointOverflow) => {
info!("ep overflow")
}
usbd_audio::Error::UsbError(usb_device::UsbError::BufferOverflow) => {
info!("buff overflow")
}
usbd_audio::Error::UsbError(usb_device::UsbError::InvalidEndpoint) => {
info!("invalid endpoint")
}
usbd_audio::Error::UsbError(usb_device::UsbError::InvalidState) => {
info!("invalid state")
}
usbd_audio::Error::UsbError(usb_device::UsbError::ParseError) => {
info!("parse error")
}
usbd_audio::Error::UsbError(usb_device::UsbError::Unsupported) => {
info!("unsupported error")
}
usbd_audio::Error::UsbError(usb_device::UsbError::WouldBlock) => {
info!("would block error")
}
},
}
当初のバッファで試してみるとMac側でインターフェイスからの読み込みを始めると"wrote"が延々と表示され、読み込みを止めると"would block error"が2度発生して、その後usb_dev.pollできなくなるのかwriteメソッドが走らなくなります。
ちなみにバッファをそのまま2倍にしたら"buff overflow"が発生して書き込みできず。要するにこの48個のi16配列をどうにかしなきゃならないみたいですね。
さて、秒間に1000回呼ばれてるのはどこが制御しているのというところですが、これはUSBのフルスピードの仕様でbIntervalというpollingの間隔が定義されていて、それが1msでハードコードされているためです。
writeできるデータが96バイトである理由については
fn ep_size(format: Format, channels: u8, max_rate: u32) -> Result<u16> {
let octets_per_frame = channels as u32
* match format {
Format::S16le => 2,
Format::S24le => 3,
};
let ep_size = octets_per_frame * max_rate / 1000;
if ep_size > MAX_ISO_EP_SIZE {
return Err(Error::BandwidthExceeded);
}
Ok(ep_size as u16)
}
このendpointのサイズを取る関数内でチャンネルが1ch、S16le、max_rateが48000なので 2 * 48000 / 1000 で96バイトということになります。この2というのは16bitは8bit(1byte) * 2の2ということです。で、秒間に96バイト * 1000書き込み可能ということはデータ通信部分だけで96kbpsなんですね。USBフルスピードだと10Mbpsくらい出るはずなのに切ないですが16bit48kHzの仕様をきちんと満たしています。へー。
リアルタイムオーディオの難しさ
16bit 48kHzでいうと16bitの要素で要素数48個の配列(96byte)の計算を1ms以内に収めるといったところが難しそうです。1ms以内なら楽勝かもとは思うものの、ちょっと重い計算をするとすぐに1msでは済まなくなりそう。
マルチチャンネルの扱い
今まではモノラルでの入力でした。そこでステレオも試してみようとオーディオクラスのビルダーにチャンネル数を2として設定し、バッファを2つ繋げて送信してみると2kHzになるんですよ。なぜだろうかとしばらく思案してひょっとしてLchとRch、バッファが互い違いで入ってきてるのかもと気づいて実際に左右左右という詰めかたをしてみると見事に1kHzで再生できました。なるほど。
合わせ
ということで準備が整ったのでADCからの入力でピッチとボリュームを変更できるようにしたものをレコーディング(?)してみました。ソースは後日アップロードしておきます。音量低いと思いっきりノイズが入るのでおそらく単純に掛け算しちゃいけないぽいですね。
終わりに
今回はADCからの入力を使ってピッチや音量を変更してみました。でもこれだとADCにつなげるPotというか抵抗が必要なんですよね。この抵抗を無くしてPico1枚でシンセ作れたら素敵だろうなーって思いました。
ということでUSB MIDIとUSB Audio Classの複合デバイスを作ろうと格闘中で、コンピュータ側からMIDIを入力するとPicoがDSPしてUSB経由で出力するみたいなのを作ろうとしています。
ホントは正月っぽい曲か第九でもPicoで鳴らせるようになってたらよかったんですけどねー。来年に持ち越しです。
ではみなさま良いお年をお送りください!