Playing audio files in a Pi Pico without a DAC

Salvatore Sanfilippo

DACなしでPi Picoでオーディオファイルを再生する

原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する

Raspberry Pi Picoは、いつの間にか組み込み開発で一番のお気に入りのチップになりつつある。よくできた頑丈なハードウェアで、賢さと情熱をもって設計されたと感じさせる機能が盛りだくさんだ(GPIOを駆動するステートマシンは最高の機能だ!)。主な弱点だった接続性の欠如も、Wバリアントで解消された。データシートは素晴らしく、チップのあらゆる側面が文書化されている。おまけに、MicroPython(私がよく使っている)でもしっかりサポートされているし、C SDK環境もまずまずだ。もっとも、今どきの流儀が求める無駄な複雑さに満ちてはいるが。Makefileを生成するためのcmakeビルドシステム、あれやこれやを定義するためのファイル(使うライブラリ、デバッグ出力など)、総じて小さなデバイス用の小さなプログラムをコンパイルするという目的に対しては大げさすぎる。いや、それどころではない。この複雑さは、機能が固定されたFIXEDなハードウェア(Wあり/なしの違いを除けば)のためのプログラムを生成するためだけのものなのだ。今日のソフトウェアがいかにひどいかという愚痴はこのへんにしておくが、覚えておく必要はある。

そんなMCUでやってみたいクールなことの一つが、音を鳴らすことだ。最も手っ取り早い方法は、チップに内蔵されたPWM機能を使うことだ。GPIOは、望みの周波数で単に0と1を交互に切り替えるように設定できる。こんな感じだ。

from machine import Pin, PWM
pwm = PWM(Pin(1))
pwm.freq(400)
pwm.duty_u16(1000)

PicoのGNDとピン1にピエゾを繋いだとすれば、400Hzの矩形波の音が聞こえるはずだ。ただ、矩形波ほど聴き心地の悪い音もそうはない。もう少しマシにできないだろうか。ここではサイン波を作るといった中間ステップはすべて飛ばして、直接wavファイルの再生に進もう。その方法がわかれば、他の波形(サイン波やノイズ、そういった波形のエンベロープなど)も簡単に自分で生成できるようになる。

ここでこう思うだろう。PicoはピンをHighかLowに切り替えることしかできないのに、どうやってwavファイルを再生するための複雑な波形を生成できるのか?まともな非矩形波は異なるレベルで構成されているのだから、DACが必要になるはずだ!幸いなことに、DACなしでも、Picoのたった1つのピンだけでこれをすべて実現できる。

複雑な音の生成の仕組み

ここであまり背景を深く掘り下げるつもりはない。ただ知っておいてほしいのは、自明な矩形波、つまり出力の最小レベルと最大レベルを単に行き来するだけの波形ではないものを作りたいなら、中間の段階が必要になるということだ。例えばこんなイメージだ。

S0: #
S1: ####
S2: ######
S3: #######
S4: ########

以下同様で、S0が最初のサンプル、S1が2番目のサンプル、……という具合だ。

各サンプルの長さはサンプリング周波数に依存する。つまり、1秒間に何回オーディオ波形を変化させるか(再生時)、あるいはサンプリングするか(録音時)ということだ。つまり、複雑な音を再生するには、Picoのピンが異なる電圧を出力できる必要があるということだ。

PicoでこれをPWMだけで実現するトリックがある。非常に高い周波数の矩形波を使い、生成したい電圧ごとに異なるデューティ比を使うという方法だ。そこで、非常に高い周波数で出力するように設定する。

pwm.freq(100000)

そして、S0のサンプルを生成したいときは、デューティ比(値は0から65535の範囲)を小さな値に設定する。S1を生成したいときはより大きな値を使う、といった具合だ。順番にやると、こんな感じになる。

pwm.duty_u16(3000)   # S0
pwm.duty_u16(12000) # S1
pwm.duty_u16(18000) # S2
pwm.duty_u16(21000) # S3
pwm.duty_u16(24000) # S4

デューティ比とは、ピンが1になっている時間と0になっている時間の割合のことだ。デューティ比が65535なら100%の時間ピンがHigh、0なら常にLowということになる。これらはすべて、設定した交互の周波数を維持したまま行われる。オシロスコープで拡大して見たかのようにズームすると、S2とS3のサンプル生成中に何が起きているかが見えてくる。

S2:

######################
#
#
#
#
######################
#
#
#
#

一方、S3はこんな感じになる。

######################
######################
#
#
#
######################
######################
#
#
#

ピンは同じ周波数でHighとLowを繰り返すが、S3の場合はHighの時間がより長い。これにより平均電圧が高くなる。こうして波形を近似できるわけだ。

WAVファイルを変換して再生する

wavファイルを再生するには、MicroPythonで読み込みやすいraw形式に変換する必要がある。私はSoundCloudから「Oh no!」というwavファイルをダウンロードした。そこで変換はこんな感じになる。

ffmpeg -i ohno.wav -ar 24000 -acodec pcm_u8 -f u8 output.raw

ここではファイルを8ビットオーディオ(1サンプルあたり256段階の出力レベル)に変換している。いずれにせよ、PWMのトリックでは異なるレベルをそこまで正確には近似できないし、リソースも限られている。16ビットでも試せるが、この方法でも十分な結果が得られた。

次に、mpremote経由でoutput.rawファイルをデバイスにアップロードする。

mpremote cp output.raw :

次に、「play.py」という名前でも、好きな名前でもいいので、以下の内容でファイルを作成する。

from machine import Pin, PWM

pwm = PWM(Pin(1))
pwm.freq(100000)

f = open("output.raw","rb")
buf = bytearray(4096)
while f.readinto(buf) > 0:
    for sample in buf:
        pwm.duty_u16(sample<<8)
        x=1
        x=1
        x=1
        x=1
        x=1
f.close()

ここでやっているのは、ファイルを取得し、1回のループで4096サンプルずつ読み込み、サンプル値に応じて異なるPWMデューティ比を次々と設定することで「再生」しているだけだ。問題は、PCMファイルでは1秒あたり24000サンプルあることだ(ffmpegのコマンドライン参照)。これがMicroPythonの速度と合っていることをどう保証できるだろうか?実際、完全には一致しないので、ピッチが正しく聞こえるように少し遅延させるために「x=1」という文を追加した。

ちなみに、sample<<8というのが何なのか気になったなら、これは8ビットのサンプルをPWMのデューティ比の設定に必要な16ビット精度にスケーリングし直しているだけだ。

この方法の欠点は、再生中はプログラムが占有されてしまうことだ。まだ試してはいないが、MicroPythonはスレッドをサポートしているので、オーディオ再生を別スレッドで行うのが良い方法かもしれない。

おまけ:サイン波生成

# Sin wave
wave=[]
wave_samples = 40
pwm.freq(100000)
for i in range(wave_samples):
    x = i/wave_samples*3.14*2
    dc = int((1+math.sin(x))*65000)
    wave.append(dc)
print(wave)

for i in range(1000):
    for dc in wave: pwm.duty_u16(dc)

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント