今回は、Arduinoと8×8[ドット]マトリクスLED表示器(以下単に、8×8 LED)を使って、サイコロを振ってみようと思います。ただ、サイコロを振るだけだと味気ないので、効果音(SE;Sound Effect)も鳴らしてみましょう。
※タイトルは、「神はサイコロを振らない(byアインシュタイン)」のオマージュです。
「#2-6 ドットマトリクスLEDを使う」で試してみた、8×8 LEDを使います。
この子は、MAX7219という、7SEG LED表示コントローラを搭載していて、SPIで通信します。Arduinoを使った通信方法については、「#2-6 ドットマトリクスLEDを使う」をご覧ください。
サイコロの目の数は、最大で6なので、LEDを7つ接続したら作れそうなものです。単にデジタル表示するなら、1桁の7SEG LEDがあれば事足ります。8×8 LEDを使うのだから、サイコロの目をグラフィカルに表示したいと思います。サイコロの目を構成する点を2×2[ドット]で表現すると、以下図の様なデザインが完成しました。
実物のサイコロは、立方体の6つの面に1つづつ目が表示されるのですが、今回のサイコロには「立方体」と言う制約は無いので、8×8[ドット]で表現可能な最大出目、9まで対応しておきます。
上図の様に、Excelなどを使って、目のデザインをし、数式を駆使して符号無8ビット整数のピクセル値を計算しました。出目グラフィックの右横に表示された16進数が、計算したピクセル値です。プログラムの配列変数に保持しておいて、8×8 LEDに送ると所望のグラフィックが表示できます。
const uint8 dice_pat[max_face][max_dot]=
{
{ 0x00, 0x00, 0x18, 0x3C, 0x3C, 0x18, 0x00, 0x00 }, // 1
{ 0xC0, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03 }, // 2
{ 0x03, 0x03, 0x00, 0x18, 0x18, 0x00, 0xC0, 0xC0 }, // 3
{ 0xC3, 0xC3, 0x00, 0x00, 0x00, 0x00, 0xC3, 0xC3 }, // 4
{ 0xC3, 0xC3, 0x00, 0x18, 0x18, 0x00, 0xC3, 0xC3 }, // 5
{ 0xC3, 0xC3, 0x00, 0xC3, 0xC3, 0x00, 0xC3, 0xC3 }, // 6
{ 0xC3, 0xC3, 0x00, 0xDB, 0xDB, 0x00, 0xC3, 0xC3 }, // 7
{ 0xDB, 0xDB, 0x00, 0xC3, 0xC3, 0x00, 0xDB, 0xDB }, // 8
{ 0xDB, 0xDB, 0x00, 0xDB, 0xDB, 0x00, 0xDB, 0xDB } // 9
};
グラフィカルに、点の数で出目を表示するようにしましたが、8×8[ドット]で数字(とか漢数字とか)をデザインしても良いですし、必要に応じてパタンを変更して使ってください。
8×8 LEDでサイコロのグラフィックを表示するだけでは味気ないので、動作に合わせて色々な効果音を鳴らしてみたいと思います。Arduinoで音楽を奏でる方法は、「#5-8 MMLで音楽を奏でる」で確立してあります。この時作成したplay_mml()関数とイヤホンへの出力回路が使えます。
ただ、効果音として音を鳴らすにあたって、play_mml()関数では、MMLの再生開始から終了まで処理にかかりっきり(ブロッキング処理)となってしまうので、少々使い難いと思いました。例えば、サイコロの目を回転させるアニメーション処理をしている時に、効果音を鳴らしてしまうと、効果音を演奏し終わるまで、一瞬、アニメーションが停止してしまう、といった具合です。
それほど難しい処理でもなかったので、play_mml()関数をノンブロッキングで動作可能な様に改造してみました。
元々、play_mml()関数では、タイマ1(TC1)を使ってPWMを出力、その周波数を所望の周波数に設定することで、音階を奏でていました。周波数の設定は、アウトプットコンペアマッチAレジスタ(OCR1A)で設定するモードを使っています。
そこで、アウトプットコンペアマッチA割り込み(OCI1A)を有効にすることで、PWM 1周期毎に割り込みがかかるようにしました。OCI1A割り込みの割り込みサービスルーチン(ISR)内で、音階を鳴らす時間(長さ)を判定しながら、次の音階の演奏に移るタイミングを検知した時に、MMLから次の音階データを取り出して処理する様にします。すると、MMLを演奏するための処理は、全て割り込み処理で完結するので、その間、loop()関数内では、別な処理を進めることができます。
以下、ISR内の処理を順に説明します。コードの後に、その解説が続きます。
ISR (TIMER1_COMPA_vect)
{
// OCI1A interrupt service routine
uint32 msec;
ISR関数の定義。
if (ply_msec_len==0)
{
// end of play
ply_ptr=NULL;
TCCR1A=0x01; // OC1A=normal port
TIMSK1=0x00; // OCIE1A disable
// call-back
if (ply_callbk!=NULL)
(*ply_callbk) ();
return;
}
すべての演奏の終了を検知(ply_msec_len==0)した。再生中のMMLポインタ(ply_ptr)をクリア、PWM出力と割り込みを禁止して、終了する。コールバック関数(ply_callbk)が登録されていたら、コールバックする。
msec=millis ();
if (msec - ply_msec_start>ply_msec_len)
{
// next note
ply_msec_start += ply_msec_len;
ply_msec_len=0;
play_mml (NULL);
}
現在の音階を再生開始した時間(ply_msec_start)と、現時刻(msec)を比べて、音階の再生時間(ply_msec_len)を越えたら、MMLから次の音階を拾って再生するよう、play_mml()関数を呼び出して終了。
else if (msec - ply_msec_start>ply_msec_len*7/8)
{
// stop tone
TCCR1A=0x01; // OC1A=normal port
}
}
音階の演奏時間(ply_msec_len)の7/8を経過していたら、一旦PWM出力を停止して、音を消す。「C44,C44」などの様に、同じ音が連続した時に、2つの音を分離した方が聞こえが良いため。
ISR内の処理は以上で終了です。
PWMは、可聴域20〜20k[Hz]のPWMを出力する必要があるため、割り込み周期は、最短で1/20kHz=50μsとなります。逆に、一番低い音を演奏する場合は1/20Hz=50msと、音階の周波数によって割り込み周期が変わってしまいます。しかも、最短だと50μsと言う、かなり短い周期で割り込みがかかる可能性があります。
ISR内の処理は、なるべく時間のかからないように作っておく必要がありました。上のコードは一応、合格範囲内かと。
以前のバージョンでは、例えば以下の様な処理をした場合、MMLの演奏がすべて終了するまで、UNOのLED_BUILTINが光りっぱなしでした。
digitalWrite (LED_BUILTIN,HIGH);
delay (50);
play_mml ("C44,D44,E44,F44,G44,A44,B44,C54");
digitalWrite (LED_BUILTIN,LOW);
今回改造後の新バージョンでは、MMLの演奏を開始した時に一瞬LED_BUILTINが光った後、すぐに消灯しますが、バックグラウンドでMMLの演奏が続きます。
折角、ノンブロッキングで演奏できるようにしたので、BGMみたいな繰り返し演奏もしたくなりました。そこで、ply_callbkというコールバック関数を用意しておきました。
void (*ply_callbk) (void);
この関数を設定しておくと、現在演奏中のMML文字列の演奏が終了したタイミングで、コールバックされます。コールバック関数内で、次に演奏するMMLをplay_mml()関数に渡すと、途切れることなく、MMLを演奏し続けることができます。
void bgm_callbk (void)
{
play_mml ("C44,D44,E44,F44,G44,A44,B44,C54,R02");
}
void setup (void)
{
ply_callbk=bgm_callbk;
play_mml ("R01");
}
上コードの様に使用します。このサンプルでは、「ドレミファソラシド」の音階が、バックグラウンドでずっとループして演奏し続けられます。bgm_callbk()関数内でplay_mml()関数に渡すMMLを変更すると、所望のBGMを演奏させ続けることができます。
なお、ply_callbkに設定されたコールバック関数は、ISR内から呼び出されます。コールバック関数は割り込み処理の一部であることに注意してください。
以下、改めて、play_mml()関数の仕様を説明しておきます。
スイッチとかでも良かったのですが、CdSセルを使って明るさの変化で操作するU/Iにしてみました。CdSセルに手をかざして暗くするとサイコロが回転を始め、手をどけて明るくするとサイコロが停止するようになっています。
ただし、回路を組むのが面倒だったので、U/Iは、サイコロの回転/停止を決定するCdSセルだけとしました。その他の操作はいつもの様に、シリアルモニタからコマンドを打ち込んで実施します……(1^^;)。
以下、シリアルモニタで受け付けるコマンドの仕様です。
CdSセルを使ったU/Iについて補足しておくと、上の【cds】コマンド仕様中に示した通りのキャリブレーションを実行しておくことで、CdSセルの相対照度を測定します。その相対照度が、一定(ui_thsh_cds)以上変化した時、U/Iが反応します。
一定以上暗くなった時にサイコロが回転を始め、
次に一定以上明るくなった時にサイコロの回転が停止します。
判定値(ui_thsh_cds)が大きすぎると、手で隠したくらいでは反応しなくなってしまいます。逆に小さすぎると、ちょっとした周囲光の変化に反応して勝手に動作してしまうことになります。もし、CdSセルのU/I動作が期待と異なるようであれば、キャリブレーションを実行してください。
また、一定時間で繰り返し測定した、相対照度の変化量で判定していますので、ゆっくり、じわぁ〜っと、明るさを変化させた場合には、U/Iが反応しません。← これは仕様です(1^^)。
CdSセルの照度と抵抗値の関係や、処理方法などについては、「#4-9 フォトインタラプタを作る」で実験済みですので、そちらの記事を参照してください。
最初に少し触れた通り、7SEG LEDにデジタル値でサイコロの目を表示するだけだったら、乱数で決定した出目を表示して終了なのですが、8×8 LEDでグラフィカルに表示可能なので、ちゃんと転がしてみようと思います。
例えば以下図の様に、"1"から"3"へ転がす場合、青枠の部分を"1"のドットパタンから"3"のドットパタンへ1ドットづつずらしながら表示して行きます。
プログラム的には、簡単なビット演算(左右方向スクロール)と、参照する配列の添え字操作(上下方向スクロール)に帰着します。以下コードが該当部分です。
void join_pat (
int dice_cur,
int dice_nxt,
int sft_dir,
int sft_siz)
{
// join 2 patterns dice_cur & dice_nxt to fbuf
// dice_cur: current face=0~8
// dice_nxt: next face=0~8
// sft_dir: 0=up / 1=down / 2=left / 3=right
// sft_siz: 0~(max_dot + dice_spc)
int idx;
uint8 dat1;
uint32 dat2;
// parameter
dice_cur=(dice_cur<0)? 0: (dice_cur>=max_face)? max_face - 1: dice_cur;
dice_nxt=(dice_nxt<0)? 0: (dice_nxt>=max_face)? max_face - 1: dice_nxt;
if (sft_siz<0)
{
sft_siz=-sft_siz;
sft_dir ^= 0x01;
}
sft_siz=(sft_siz>max_dot + dice_spc)? max_dot + dice_spc: sft_siz;
if (sft_dir==0)
{
// up
for (idx=0; idx<max_dot; idx++)
{
if (idx - sft_siz>=0)
dat1=dice_pat[dice_cur][idx - sft_siz];
else if (idx - sft_siz>=-dice_spc)
dat1=0x00;
else
dat1=dice_pat[dice_nxt][idx - sft_siz + max_dot + dice_spc];
fbuf[idx]=dat1;
}
}
else if (sft_dir==1)
{
// down
for (idx=0; idx<max_dot; idx++)
{
if (idx + sft_siz<max_dot)
dat1=dice_pat[dice_cur][idx + sft_siz];
else if (idx + sft_siz<max_dot + dice_spc)
dat1=0x00;
else
dat1=dice_pat[dice_nxt][idx + sft_siz - max_dot - dice_spc];
fbuf[idx]=dat1;
}
}
else if (sft_dir==2)
{
// left
for (idx=0; idx<max_dot; idx++)
{
dat2=(uint32 (dice_pat[dice_nxt][idx])<<(max_dot + dice_spc)) |
dice_pat[dice_cur][idx];
fbuf[idx]=(dat2>>sft_siz)&0xff;
}
}
else
{
// right
for (idx=0; idx<max_dot; idx++)
{
dat2=(uint32 (dice_pat[dice_cur][idx])<<(max_dot + dice_spc)) |
dice_pat[dice_nxt][idx];
fbuf[idx]=(dat2>>(max_dot + dice_spc - sft_siz))&0xff;
}
}
}
各方向のスクロールで、処理内容が微妙に異なるため、4方向場合分けしてしまいました。これが一番シンプルでコードも見やすいはず。
本来、サイコロの転がり方を厳密に再現すると、下図の様に、"1"から右に転がったら"3"が出るし、"1"から下に転がったら"2"が出ると言うように、現在の出目から次の出目に変化する変わり方には一定の法則が生じます。
サイコロが立方体(正六面体)であり、各面に表示された出目の値が、その物理的位置関係に依存しているのです。
しかし、今回のサイコロプログラムでは、最大出目を〜"9"まで設定可能としました。残念ながら、正九面体は存在しないので、"9"までの出目を物理的に配置する手段がありません。よって、転がった次の出目は、乱数で決めます。
つまり、"1"から転がって、次にいきなり"6"が現れることがあり得る、と言うことです。ただし、"1"の次の出目が再び"1"になることの無い様に、乱数を発生させることにします。さすがに、同じ出目が2回連続で現れるのは不自然なので、同じ目の連続出現だけはアルゴリズムで抑止しておきます。
dice_nxt=(dice_cur + random (dice_max - 1) + 1)%dice_max;
上コードは、現在の出目(dice_cur)から、次の出目(dice_nxt)をランダムに決める計算箇所です。この数式によって、dice_nxtは、必ず、dice_curとは異なる乱数になります。
※ついつい、Windowsでプログラムする時(いつも)の癖で、剰余(%)演算を使ってしまうのですが、上の様なケースでは、if文(やwhile文)を使った方が速いコードになります。……if/while (dice_nxt>=dice_max) dice_nxt -= dice_max;
コンピュータで普通に使用する乱数は、以下の様な式で計算した「擬似乱数列」を使用しています。
rnd[n]=(rnd[n−1]×a+b)%m
…… 線形合同法。演算子%は剰余(余り)、a,b,mは整数
コンピュータで使う場合、普通mには2のべき乗を使います。2冪数の剰余はビットAND演算に帰着するので、計算が簡単かつ高速になるためです。
mは、疑似乱数列の繰り返し長さを決める重要なファクタなので、m=232=0x1_0000_0000などの様に、整数の最大値+1を割り当てます。
aとbは、適当に決めてもうまく行きません。選び方によっては、マイナループができてしまって、乱数の繰り返し周期が短くなってしまう場合があるためです。他にも、選んだ値によっては、全く乱数に見えず、規則的に変化する、ただの数列になってしまう場合もあります。a,bの具体的な数値とか選び方は検索するとすぐに見つかります。
この節で議論したいのは、擬似乱数列の作り方、では無くて、最初の乱数rnd[0]をどうやって求めるか?です。
上で示した式だと、rnd[0]=(rnd[−1]×a+b)%mとなって、結局、rnd[−1]をどうやって決めるか、と言うことが最重要課題だとわかります。この先出現する擬似乱数列の値や順番を決めるのが、rnd[−1]だからです。rnd[−1]を乱数の種(seed)、と呼びます。
Cの標準関数では、乱数を求めるのがrand()関数、種を設定するのがsrand()関数です。srand()関数で乱数の種を設定してから、rand()関数で乱数を求めます。Arduinoの標準ライブラリでは、random()とrandomSeed()が同様な関数に相当します。
rand()関数は、0〜1の実数値(float)なので、Arduinoで使うには、ちょっとオーバヘッドが大きいかもしれませんね。Arduinoでは、実数値の計算は結構重たい処理ですから。
その点、random()では、整数で計算してくれて、最大値と最小値を指定して乱数の範囲も指定可能です。結局、符号無32ビット整数演算をしているので、Arduinoにとってはこれも重たい処理ですが、floatよりはマシでしょう。
今回のプログラムではrandom()関数を使用しました。
乱数の種に話を戻しましょう。
Windowsとかのアプリケーションで作る時は、アプリケーション実行時点の時間(時計)などから種を決定して使います。アプリケーションを実行した時点の時間は起動毎に異なるので、起動する度に異なる乱数の種を設定することが可能という訳です。
Arduinoだと、millis()とかmicros()を取っても、起動してからrandomSeed()関数に値を設定するまでの経過時間なんて、ある程度決まっているので、いつも同じ様な時間しか乱数の種に設定できません。いつも似たような時間を乱数の種に設定してしまうと、何回かやっているうちに、「あれ?このパタン、前にもあった」みたいなことになってきます。乱数の種をどうやって決めるか?って、とても重要、かつ、とても難しい問題なのです。
時々紹介されているのが、フローティングになっている適切なアナログ入力端子をanalogRead()して、読み出した値を乱数の種にするという方法です。ただ、これも、周辺の状態が安定してくると、フローティング電位も大体同じ様な電位で観測されるようになってくるし、所詮は10ビットのAD変換器出力なので、高々1,024種類の乱数の種しか作り出すことができません。
つまり確率的に、1,024回繰り返すと1回くらいは、過去に出現したのと全く同じ擬似乱数列が生成されてしまうということになります。……まして、フローティング電位が安定してしまうと、出現する乱数の種の種類は、もっと少なくなるでしょう。
これだけで十分なテーマになりそうですね。
ゴメンナサイ、現時点では明確な解決策までは示すことができません。今回は、ユーザがサイコロを振るタイミングなど、Arduinoのプログラムとは非同期に決定されるユーザインターフェイス(U/I)のタイミング(micros()を使用した)を乱数の種に使えるため、その方法でお茶を濁しておきます。
※「#6-10 真の乱数を生成する」にて、このテーマを掘り下げてみましたので、併せてご覧ください。
以下に回路図を示し、ポイントを解説します。
プログラムをリンクしておきます。
完成版プログラム:dice_8x8_led.ino
プログラムをコンパイルしてArduino UNOに書き込んでください。
イヤホンではなく、外付けのアンプ内蔵スピーカに接続して、効果音を演奏しています。
※実は、上写真の向きでは、8×8 LEDの上下左右が90°回転してしまっています。文字(数字とか漢数字とか)で表現する場合には、8×8 LEDの向きにもご注意ください。
映っているスピーカは、Bluetoothペアリングするとヘッドセットになるタイプです。スマホなどとペアリングして使用するためのスピーカだと思われます。今回使ったのは、BTではなく、背面についていたLine入力端子(ミニジャック)です。
バッテリ内蔵なので、Line入力に繋いだミニプラグケーブルしかつながっていないのに、ちゃんと音が鳴っています。イヤホンくらいしか鳴らせなかったArduinoからの出力を増幅して鳴らしてくれています。
ナレーションの背後に聞こえている効果音は、実際にArduino+スピーカで鳴らした効果音です。後から合成した物ではありません。
そういえば、play_mml()関数で演奏した効果音を実際にお聞かせしたのは初めてでしたね?どうです?ArduinoのPWM出力+LPF+カップリングだけの回路にしては、結構良い音が鳴っているでしょう?