Episode 24 <ベアメタルシリーズ> Raspberry Pi PicoのPWMで音を出してみた

ブログ

皆さんこんにちは、グレキチです。

11月に入って、朝晩がそこそこ寒くなってきました。まだ暖房を使うまでではないですが、寝る時には、掛け布団が必須の状況になってます。本当、寒いの嫌いなんですよね、私😖

今回もベアメタルシリーズです。マイコンはラズパイPicoを使ってます。
先日、ARMベアメタル繋がりでSTM32を使ってみた記事を紹介しましたが、そもそもラズパイPicoをまだ使い倒していなかったので、今後暫くはラズパイPicoで使える機能を順番にトライしてみようと考えました。それで今回は、ラズパイPicoのPWM機能を扱ってみようと考えて、“PWMと言えば、音でしょ!”(いや、普通はモーターに行くでしょ😅)と思い立ちましたので、その内容について紹介します。

PWMについて

まず簡単に、PWMについて説明しておきます。
PWMは Pulse Width Modulation の略で、パルス幅変調のことを言います。パルスとは、短い時間に発生する信号のことで、ここでは電気信号を指します。その電気信号の幅を変調させる、つまり、電気信号を発する時間(幅)を変化させることがパルス幅変調ということになります。パルス幅変調のイメージ(矩形波パルスの場合)は下図の通りです。
(どこかで見たことある様な図でしょ?)

図の縦軸は出力、横軸は周期です。パルスの高さは振幅で、ここでは説明し易さの為に、パルス振幅は1で表現しています。
出力0の状態から出力1の状態に移行して、それがまた出力0に戻ったタイミングが1周期になります。PWMでは、この出力0もしくは1の状態の時のパルス幅を変更することで、信号を制御しますが、出力1(出力されている状態)のパルス幅の変化量の指標に、デューティ(duty)比(デューティサイクル)というものを用いています。
例えば、上図のAの矩形波の場合、パルス1周期の間に、出力0と出力1のパルス幅が同じになっていますが、この状態が duty比 50% です。一方、Bの矩形波の場合は、パルス1周期の間に、出力1のパルス幅が全体の70%となっているので、この場合は duty比 70%となります。

以上がPWMについての一般的な説明ですが、ここで、今回の実装に特化した内容を少しだけ補足説明しておきます。
PWMでは通常、出力は固定で考えますが、今回は音の周波数を主に制御するため、パルス出力(振幅)である電圧を周波数に応じて変化させることになります。その場合、duty比は何になるかというと、圧電スピーカーから発する音の大きさ(音量)を制御するものになります。ちなみに、色々試した結果、今回使用したデバイスではduty比 50%付近が一番音量が高かったので、プログラム上では duty比 50%で固定としています。これについて詳しく調べたところ、duty比50%の時が平均電圧が最も安定する(入出力のバランスが良い)ので、一般的な圧電スピーカーでは、最も振動板を効率良く振動させることができるようです。逆に、0%とか100%では音が発生しない様です。この現象は、LED光量などを制御する時とは異なる扱い(ex : LEDの場合はduty比が増加すると光量も増加する)なので、最初に試した時は混乱しました😅

プログラム内容

プログラムに関しては、今回も注目ポイントのみについて詳細を説明します。
今回のプロジェクト名は、speakerとしました。主に編集したのは、メインのCファイルです。

まずは、ヘッダーファイルとして管理しているレジスタ設定についてです。
今回のPWM制御にあたって、以下のレジスタを新たに設定しました。
(※当然の事ながら、このマクロの設定値は、ラズパイPicoのデータシートを参照して設定しています。)

#define PWM_BASE					0x40050000

#define PWM_CH1_CSR_RW				(PWM_BASE+0x14+0x0000)
#define PWM_CH1_DIV_RW				(PWM_BASE+0x18+0x0000)
#define PWM_CH1_CTR_RW				(PWM_BASE+0x1C+0x0000)
#define PWM_CH1_CC_RW				(PWM_BASE+0x20+0x0000)
#define PWM_CH1_TOP_RW				(PWM_BASE+0x24+0x0000)

ここで、“PWM_CH1_CSR_RW” の様に、 間に“CH1”とマクロ名を設定していますが、ご推察の通り、これはChannel1を表現しています。
ラズパイPicoでは、PWMの機能は30個あるGPIOポートのどれでも使うことが出来ます。しかし、PWM用のチャンネルは16個(0Aから7Bまで)しか用意されておらず、GPIO16以降はチャンネルが重複することになります。それを表にまとめたものが下図になります。

RP2040 Datasheet P522より抜粋

この表に従って、使用したいGPIOポートがどのチャンネルに相当するのかを確認して使用することになります。これをきちんと設定しないと、マイコンからPWM信号が発信されないので要注意です。
それで、今回使用したのはGPIO2ポートなので、表で確認するとChannel1(A)ということになるため、マクロ名をCH1とし、それに対応したレジスタのアドレス値を設定しています。

次に、Cファイルの先頭で、各音階の周波数を下記のようにマクロを使って定義しています。今回の実行結果では使っていない音も登録されていますが、小学校の音楽の授業などで馴染みのある音階のC4(ドの音)から、3オクターブ上くらいまでの音階(C4からC8まで)を設定しました。

#define C4_262  (45801)
#define D4_294  (40815)
#define E4_330  (36362)
#define F4_349  (34383)
#define FS4_370 (32431)
#define G4_392  (30611)
#define GS4_415 (28915)
#define A4_440  (27271)
#define B4_494  (24290)

〜 以下、省略 〜

そして、このマクロで設定している括弧内の数字ですが、ターゲットとなる周波数(例えば、C4の場合は262Hz)から計算して導き出したある変数値です。以下にその算出方法と、各数値を使ってどのように音を制御するかについて解説します。

ターゲット周波数の制御

ラズパイPicoでのPWM制御は、下記の計算式を使って各変数値を決定し、その値を本セクションの最初で説明した、該当するレジスタに設定することで行います。式の左辺は fPWM となっていますが、この変数値が実際に出力したいターゲット周波数になるので、求めたいのはこれ以外の変数値ということになります。

RP2040 Datasheet P528より抜粋

   fPWM : 出力周波数(ターゲット周波数)
   fsys : システムクロック周波数
   TOP : TOPレジスタ設定値
   CSR_PH_CORRECT : 位相補正モードの切替値(1で有効、デフォルトは0で無効)
   DIV_INT : DIVレジスタ設定値(整数部、デフォルト値は1)
   DIV_FRAC : DIVレジスタ設定値(小数部、デフォルト値は0)

まず、CSR_PH_CORRECTですが、CSR_PH_CORRECTは、位相補正モードで考えたい場合に 1 に設定する変数で、今回は通常モードのままなのでデフォルト値(0)から変更しません。位相変更モードについての説明は、ここでは割愛します。(まだよく理解出来ていない😱)
また、計算に使用する残りの5つの変数のうち、出力周波数 fPWM は設定したい周波数なので音階表などを参照して自動的に決まり、システムクロック周波数 fsys はマイコンのクロック設定で決まります。今回、水晶発振器でクロック設定を行っているので、fsys は12MHz(=12000000)になります。

さて、変数は残り3つになりました。最終的に、ターゲットとなる周波数 fPWM を使って、この3変数を逆算して求めていくことになるのですが、これには実は組み合わせが色々あって、どう設定するかは使う人の好みやセンス次第になるかと思います。(センス!? センスねぇ〜😏)
私の場合、とにかく計算を簡便にしたかったので、DIV_INTDIV_FRAC はデフォルト値で固定とし、TOP値のみを変化させて、周波数設定を変更するように考えました。(本当の使い方は逆なのかもしれませんが😅)ちなみにこのTOP値ですが、これはパルス信号の1周期の長さに相当します。

ここで、一つ計算例を示します。
求めたいのはTOP値なので、TOPを求めるように上で出てきた計算式を変形すると、下記の様になります。

そして、仮に、ターゲット周波数がA4(周波数440Hz) の場合で計算すると、

ということで、TOP値は、27272に設定すれば良いことになります。今回の計算結果を見てもらって分かったと思いますが、結局、TOP値は、システム周波数をターゲット周波数で割ったものから1を引いた値になります。
なお、TOP値は16bitsで設定できる変数なので、最大値は65535になります。よって、65535を超える数値だと設定出来ないわけですが、今回は幸いにも、TOPの最大値は45801(音階C4の場合)だったので、DIV_INT及びDIV_FRACをデフォルト値固定で考えても問題ない結果となりました😀
但し、ピアノの鍵盤で弾ける最低音階のA0(周波数28Hz)などを使いたい場合は、DIV_INT及びDIV_FRACをデフォルト値固定にしたままだと、TOP設定可能最大値の65535を軽く超えて来るので、その場合は、DIV_INTDIV_FRACの値調整も駆使して設定することになりますので、センスを磨いて下さい😆

以上のようにして計算したTOP値を、対応するターゲット周波数のマクロ値として今回設定しています。

デューティ比の制御

ラズパイPicoの場合、PWMのduty比は、CCと定義されたカウンター比較値と呼ばれるものを使って、この値とTOP値とを比較することで決定する仕組みになっています。それで、duty比 100% の際の計算式は、以下の様に定義されています。

  CCTOP + 1

至ってシンプルですね。なので単純に考えると、TOP値のおよそ半分でCC値を設定すれば、duty比を約50%に設定出来る訳です。
ならば😏ということで、今回は、TOP値を2で割った値をプログラム上で使うこととしました。なお、実際のプログラムでは、下記の通り “2で割る” を、値の右シフト1で表現してます。

PUT32(PWM_CH1_CC_RW, (melody[i] >> 1)); // duty比 50%

なぜこんな方法をとったかというと、コード記述が簡単だったということもありますが、一番の理由は、ベアメタルだからということになろうかと思います。

for文などのループ内で配列を使う場合、配列から取り出した値をさらに乗除演算しようとすると、C言語の標準ライブラリの呼び出し(たぶんmalloc?)が掛かって、ビルドエラーになります。
ベアメタルでC言語の標準ライブラリを使いたい場合は、標準ライブラリを模した関数を特別に自身で作成するか、ビルド時に標準ライブラリを使用する旨を伝える設定をしないといけなくなります。そもそもベアメタルでは、標準ライブラリなどを極力使わないことで、データ容量を小さく留める仕組みだと思うので、使ってしまったら本末転倒になってしまいますね。
なので、シフト演算を使って、穏便に事を収めた次第です。
(いやーそれにしても、duty比の設定値が40%とか60%で無くて助かった🤣)

メロディ演奏の仕組み

あと説明が必要なこととしては、実際にメロディを奏でる場合は、連続して音を発生させる必要があったので、発生させる音と音の長さについては各々配列を設けて、それをforループで回す様にコードを作成しました。実際のコードは下記の様に、発生させる音の配列名をmelody、音の長さの方はmelody_durationsと名付けました。

static const unsigned int melody[25] = {
        E5_659, D5_587, C5_523, D5_587,
        E5_659, E5_659, E5_659,
        D5_587, D5_587, D5_587,
        E5_659, G5_784, G5_784,

        E5_659, D5_587, C5_523, D5_587,
        E5_659, E5_659, E5_659,
        D5_587, D5_587, E5_659, D5_587,
        C5_523
    };

    static const unsigned int melody_durations[25] = {
        TIME_100MSEC*3, TIME_100MSEC, TIME_100MSEC*2, TIME_100MSEC*2,
        TIME_100MSEC*2, TIME_100MSEC*2, TIME_100MSEC*2,
        TIME_100MSEC*2, TIME_100MSEC*2, TIME_100MSEC*2, 
        TIME_100MSEC*2, TIME_100MSEC*2, TIME_100MSEC*2, 

        TIME_100MSEC*3, TIME_100MSEC, TIME_100MSEC*2, TIME_100MSEC*2,
        TIME_100MSEC*2, TIME_100MSEC*2, TIME_100MSEC*2,
        TIME_100MSEC*2, TIME_100MSEC*2, TIME_100MSEC*3, TIME_100MSEC,
        TIME_100MSEC*4
    };

あっそうそう。音の長さのデフォルト値もマクロで定義してました。この方がちょっとだけ使い勝手が良かったので。
それと、メロディをうまく奏でるには、各音の途中でちょこちょこと休符が必要になってきます。休符の表現方法としては、当初、周波数を人の耳の可聴域外に持っていく方法を考えましたが、これはうまくいきませんでした。なので、CC値を0に設定してパルス波を発生させない方法を今回は取りました。詳しくはコードを確認してみて下さい。

以上、プログラムの説明でした。
いつものように、プログラム全体はこちらにアップロードしています。

回路設計

回路図は下記の通りです。
(※シリーズを進める毎に回路が少々複雑になってきたので、今回から回路図も載せるようにしました。その為に、Fritzingというソフトを導入しました。このソフト、配線図を作成すると、回路図のベースを自動で書いてくれるので大変便利です。電子工作を続ける限りは、長いこと重宝しそうです☺️)

トランジスタはNPN型を使っています。
トランジスタのベースとラズパイPicoのGPIO2ポートとの間に1kΩの抵抗をかませています。これは、マイコンからトランジスタへ流れ込む過電流防止(トランジスタ保護)のためと、ベース電流の変動を安定させることでトランジスタの動作も安定させられるための、二つの理由からです。

また、圧電スピーカーにも並列に1kΩの抵抗を配置していますが、この理由は音量UPのためです。私はもともと機械系エンジニアなので、電気電子についてまだあまり詳しくはありませんが、今回色々と調べた情報から言えることは、圧電スピーカーはインピーダンスが非常に高く、必要電流値も非常に小さくて済み、且つ、電圧で駆動する構造になっているため、並列に抵抗を繋ぐことで圧電スピーカーへの余分な電流を抵抗側に流してあげる方が良いとのことでした。この抵抗を繋がないと、本当に微かな音しか聞こえませんでした😩
もしくは、手元にインダクタがあったら、インダクタを並列に接続して使うのも手のようです。なお、インダクタの適正値は10mH〜50mH位が良いと、こちらのサイトで紹介されてました。私もインダクタが入手できたら、別途試してみようと思ってます。

音量UPに関して、さらにもう一つ付け加えておくと、今回NPN型トランジスタを使った回路のため、圧電スピーカーへの入力電圧は3.3Vより上げても問題無いです。そういう訳で、ラズパイPicoがPCからUSB給電されている状態であれば、Ch39のVSYS端子から5V電圧を取り出すことができるので、そちらを圧電スピーカーに供給すれば、3.3V入力の場合に比べてさらにちょっとだけ音量UPが出来ることに気づきました✌️

(回路図補足:万が一、トランジスタのベース側からノイズが入ると微細電流が流れ、コレクタに電流が流れてスイッチが入ってしまうため、誤作動の原因になるとのこと。そのため、ベースとエミッタを繋げるように抵抗をかませるか、もしくはベースとGNDを抵抗で繋げるかで対応したらなお良いみたいでした。しかし、今回は特にノイズを気にすることはなかったので、対応しませんでした。単に、写真と動画を撮り直すのが面倒だった説も否めない😆)

次に配線ですが、下図のように計画しました。(配線図作成ソフトの仕様上で、圧電スピーカーの画像が実際のものとは異なります)

実装結果は下記の通りです。(前回同様、便宜上で、上の回路図には無いBOOTSELのリセットスイッチも実装していますが、そこは気にしないで下さい)
注意点としては、トランジスタの結線端子を間違えないことと、抵抗を追加する位置に気をつけることくらいですね。

実行結果

実行動画は下の通りです。
プログラムした曲は、多分皆さんどこかで聞いたことがあると思ったので、これにしました。プログラム内容を先に見られた方は、既にご承知かと思いますが。

本当は流行りの曲とかを実演で見せたかったんですが、著作権関係で面倒だなと思ったので使いませんでした😭

ちょっとほのぼのして貰えましたかね?😄

まとめ

今回はPWMを使ったメロディ演奏の実装でした。ここまで読んで頂いてありがとうございます。

実は圧電スピーカーを扱う前に、LED光量のPWM制御も既に試してました。LED光量制御の場合は、プログラムが単純だったので面白みにかけるなーと思ったので、敢えて記事にはしませんでした。(またLEDか!! と、いうこともありますしね🤣)
私も含め、皆さんには音の制御の方が、興味深かったんじゃないでしょうか?🤔

記事の冒頭でも述べたように、duty比の扱い方がLED制御などとは異なっていたのが、難しかったポイントの一つでした。プログラム作成では、配列の扱いにも戸惑いましたね〜😅
それと、電子回路に関しては、最初音が全然聞こえなかったので、不良品のスピーカーを掴まさせたのか💢と一瞬憤慨したこともありましたが、単なる自身の勉強不足だったので良かったです。お陰で、トランジスタの扱い方に関する知識なども増えて、また一つ成長出来たと思います。

ということで、今回は以上です。また次の記事を楽しみにして頂けると嬉しいです。

ではまた👋