おさらい
前回までにバトルサウンドで有名なメロディIC GSE3568 の同等品の M09 のサウンド構成の分析を終えました。クロック単位のサウンドの構成やノイズ合成のアルゴリズムも特定することができたので、今回はいよいよ ATtiny10 で実装します。
おたのしみ
まずは実際に作っておたのしみいただけるよう、回路図とソースコードと HEXファイルを公開します。
スピーカー駆動のトランジスタ周辺に抵抗が無いのが気になりますか?ここはあえてエミッタフォロワにしてるので、マイコンから見たインピーダンスはスピーカーのそれの hFE倍になっていますから抵抗が無くても問題無いはずです。
水色のラインはプログラマ接続用です。JP1(および JP2)は通常はショートですが、プログラミング時は開放してください。ボタンやスピーカーなどをプログラミングインタフェースから(実用上問題無い程度に)切り離します。実は当初は JP1 だけだったのですが、圧電サウンダは結構プログラミングインタフェースの邪魔をするらしく、JP2 を追加する必要がありました。でもハンドメイドで省略できるような位置に入れています。こんな感じで、JP1 のジャンパーピンに圧電サウンダの GND側リードを繋いでしまえば JP2 がいらなくなります。
え?サウンドは 8種類だったはずなのになぜボタンが 10個あるのかって?
サービスです。
そしてソースコードと HEXファイルはこちらです。
- src/main.asm … 各種定義やメインループがあります
- src/button.asm … キーボード入力処理です
- src/sound.asm … 今回のターゲットであるサウンドジェネレータです
- src/wait.asm … クロック単位の時間待ちを行うマクロで、もう一つのターゲットです
- TinyBattleSound.hex … アセンブル済みの HEXファイルです
ソースコードは 4 TAB です。
アセンブル済みの HEXファイルが同梱されているので、ブレッドボードででもなんでも回路を組んで書き込めば試せます。こんな感じになります。
※音量注意!
ギガやら無料ブログスペース温存のため動画のビットレートはだいぶエコで貧相ですが、動作の様子は充分確認できると思います。ボタンは省略して抵抗の足をテスター棒で触るスタイルだったり回路図に無いものも付いてたりしますが気にしないでください。ちなみに黄色い付箋紙はスピーカーの音を大きくする拡声板です。小型スピーカーを使う場合はこんなちょっとした何かで音量など雲泥の差が出ます。あと最後から 2番目のサウンドは最新版では 1オクターブ下げてあり、よりそれっぽくなりました。
解説
前置き
おたのしみのイージーモードはここで終了です。ここから先プログラムの仕組みなどを長々と解説していきます。アセンブラでゴリゴリ書いていくので、ここから先を楽しめる人はそんなにいないかもしれません。
概要
以下 TinyBattleSound の主なスペックです。
- 使用マイコン … AVR ATtiny10
- 電源 … 1.8V~5V (乾電池 2本やリチウムコイン電池などの 3V 駆動を想定)
- システムクロック … 最大500kHz
- サウンドジェネレータ … ソフトウェア式 1bit 出力(タイマ/カウンタ不使用)
- サウンドクロック … 125kHz
- 実装サウンド … メロディIC GSE3568 や何かを彷彿とさせるサウンド 10種
当初トーンの発声にはタイマ/カウンタを使おうかと思っていましたが、何かと面倒だったのでソフトウェア式にしました。ソフトウェア式ではプログラムでタイミングを取って出力ピンを ON/OFF して音声を出力します。なので今回のコードの肝はクロック数管理です。
元のメロディIC のクロックは 128kHz でしたが、TinyBattleSound では 500kHz を 4分周した 125kHz とします。分析によって得たパルス幅などをそのまま使うと、この周波数の違いによって音程にして 0.4半音(12 * log2(125 / 128) = -0.41)ほど低い音になってしまいますが、それでいいことにします。
クロック数管理
今回はソフトウェアでサウンドを生成しますので、処理クロック数はサウンドのパルス幅ぴったりである必要があります。1クロック多くても少なくてもだめです。このような場合、狙ったクロック数より早く終わるコードを書き、余った時間をウェイトで潰すように作るのが簡単です。なのでウェイトの方法をいくつか挙げます。
; 1クロック待ち
nop
; 2クロック待ち
rjmp WAIT_NEXT
WAIT_NEXT:
; 3n クロック待ち
ldi TMP, n
WAIT_LP:
dec TMP
brne WAIT_LP
; 8クロック待ち
rcall WAIT_RET
; 以下どこかの ret に便乗可
WAIT_RET:
ret
単純に nop を必要クロック数分並べてもウェイトとしては用をなしますが、1KB しか無い ROM容量は大変貴重なので、1命令で 2クロックかかる rjmp や、呼んで戻ってくるのに 8クロックかかる rcall と ret、そしてループでの時間待ちなどを組み合わせてコードサイズが小さくなるように努めます。
これをマクロ化したのが wait.asm です。
; 時間待ちマクロ
; WAIT 1 CLOCK
.MACRO WAIT1
nop
.ENDM
; WAIT 2 CLOCK
.MACRO WAIT2
rjmp LOC1
LOC1:
.ENDM
; WAIT 8 CLOCK
.MACRO WAIT8
rcall WAIT_RET
.ENDM
前述した方法のいくつかをそのままマクロ化したものです。そしてさらにこのマクロを使いやすくするために次のようにラップしています。コードは示しますが結論だけ分かればよく、使い方も先に書いておきますのでしっかり読んでいただく必要はありません。
; WAIT <CLOCK>
.MACRO WAIT
.IF @0 <= 0
; 0 CLOCK
.ELIF @0 == 1
; 1 CLOCK
WAIT1
.ELIF @0 == 2
; 2 CLOCK
WAIT2
LOC2_1:
.ELIF @0 == 3
; 3 CLOCK
WAIT2
WAIT1
.ELIF @0 == 4
; 4 CLOCK
WAIT2
WAIT2
.ELIF @0 == 5
; 5 CLOCK
WAIT2
WAIT2
WAIT1
.ELIF @0 == 6
; 6 CLOCK
WAIT2
WAIT2
WAIT2
.ELIF @0 == 7
; 7 CLOCK
WAIT2
WAIT2
WAIT2
WAIT1
.ELIF @0 == 8
; 8 CLOCK
WAIT8
.ELIF @0 == 9
; 9 CLOCK
WAIT8
WAIT1
.ELIF @0 == 10
; 10 CLOCK
WAIT8
WAIT2
.ELIF @0 == 16
; 16 CLOCK
WAIT8
WAIT8
.ELIF @0 <= 778
; 11~ 778
; 8bit
.IF (@0 - 11) % 3 == 0
ldi TMP, (@0 - 11) / 3
rcall WAIT_3X_10
.ELIF (@0 - 12) % 3 == 0
ldi TMP, (@0 - 12) / 3
rcall WAIT_3X_11
.ELIF (@0 - 13) % 3 == 0
ldi TMP, (@0 - 13) / 3
rcall WAIT_3X_12
.ENDIF
.ELSE
; 16bit
.IF (@0 - 13) % 4 == 0
ldi TMP, LOW((@0 - 13) / 4)
ldi TMP2, HIGH((@0 - 13) / 4)
rcall WAIT_4X_11
.ELIF (@0 - 14) % 4 == 0
ldi TMP, LOW((@0 - 14) / 4)
ldi TMP2, HIGH((@0 - 14) / 4)
rcall WAIT_4X_12
.ELIF (@0 - 15) % 4 == 0
ldi TMP, LOW((@0 - 15) / 4)
ldi TMP2, HIGH((@0 - 15) / 4)
rcall WAIT_4X_13
.ELIF (@0 - 16) % 4 == 0
ldi TMP, LOW((@0 - 16) / 4)
ldi TMP2, HIGH((@0 - 16) / 4)
rcall WAIT_4X_14
.ENDIF
.ENDIF
.ENDM
WAIT n のように書けば、n の値に応じてコードが小さくなるようなウェイトに展開されます。n の値毎にウェイトのコードを並べた泥臭いマクロですが、作ってしまえばスマートに使えます。
小さな待ち時間の際には数命令を並べただけのコードになり、ある程度大きくなるとループ処理と組み合わせたコードになります。ループ処理では 8bit版と 16bit版を用意して、それをマクロ内で使い分けています。
8bit版のループ処理はこんな感じのサブルーチンです。
WAIT_3X_12:
WAIT_3X_R10:
nop
WAIT_3X_11:
WAIT_3X_R9:
nop
WAIT_3X_10:
WAIT_3X_R8:
subi TMP, 1 ; 4
brcc WAIT_3X_10 ; 5
ret ; 6
; 10
ラベルがたくさん付いていてややこしいですが、WAIT_3X_10 から説明します。使用するレジスタは 待ち時間 TMP です。とりあえず TMP に 0 を設定して rcall WAIT_3X_10 すると、まず rcall が完了するまでに 4クロック、TMP をデクリメントして ret のところへやってくるまでに 2クロック、そして ret が完了するまでに 4クロックの合計 10クロックを費やします。もし TMP に 1 を設定していたなら、ループが 1回回って 3クロックの追加です。つまり 設定値 * 3 + 10クロックの待ち時間です。それがラベル名にある 3X_10 の意味です。WAIT_3X_11 は nop で1クロック使ってから WAIT_3X_10 に到達するので、設定値 * 3 + 11クロックの待ち時間になります。だからラベル名も 3X_11 です。3X_12 も同様です。3X_R8 などの R付のものは後で説明です。
で、これで 1クロック単位で必要時間待つことができるのですが、計算が面倒なのでマクロでラップしてあるのです。抜粋すると WAITマクロ内のこの部分です。
; 11~ 778
; 8bit
.IF (@0 - 11) % 3 == 0
ldi TMP, (@0 - 11) / 3
rcall WAIT_3X_10
.ELIF (@0 - 12) % 3 == 0
ldi TMP, (@0 - 12) / 3
rcall WAIT_3X_11
.ELIF (@0 - 13) % 3 == 0
ldi TMP, (@0 - 13) / 3
rcall WAIT_3X_12
.ENDIF
詳しくはコードを確認していただきたいのですが、WAIT n の形で待ちたいクロック数を与えれば、中でうまいこと計算してぴったりそのクロック数を待つ WAIT_3X シリーズの呼び出しをしてくれます。ただしこのウェイト処理のクロック数は定数です。レジスタの値で動的に待つなどはできません。
また 16bitカウンタ版の WAIT_4X シリーズもありますが、これも同様の仕組みです。
ラッピングマクロにはもう一つ WAIT_R を用意しています。
; 指定クロックで ret を完了させる(9~)
.MACRO WAIT_R
.IF @0 <= 4
.ERROR "out of range"
.ELIF @0 == 4
ret
.ELIF @0 < 8
WAIT @0 - 4
ret
.ELIF @0 <= 776
; 8bit
.IF (@0 - 9) % 3 == 0
ldi TMP, (@0 - 9) / 3
rjmp WAIT_3X_R8
.ELIF (@0 - 10) % 3 == 0
ldi TMP, (@0 - 10) / 3
rjmp WAIT_3X_R9
.ELIF (@0 - 11) % 3 == 0
ldi TMP, (@0 - 11) / 3
rjmp WAIT_3X_R10
.ENDIF
.ELSE
; 16bit
.IF (@0 - 11) % 4 == 0
ldi TMP, LOW((@0 - 11) / 4)
ldi TMP2, HIGH((@0 - 11) / 4)
rjmp WAIT_4X_R9
.ELIF (@0 - 12) % 4 == 0
ldi TMP, LOW((@0 - 12) / 4)
ldi TMP2, HIGH((@0 - 12) / 4)
rjmp WAIT_4X_R10
.ELIF (@0 - 13) % 4 == 0
ldi TMP, LOW((@0 - 13) / 4)
ldi TMP2, HIGH((@0 - 13) / 4)
rjmp WAIT_4X_R11
.ELIF (@0 - 14) % 4 == 0
ldi TMP, LOW((@0 - 14) / 4)
ldi TMP2, HIGH((@0 - 14) / 4)
rjmp WAIT_4X_R12
.ENDIF
.ENDIF
.ENDM
これは、ウェイトしてから ret するマクロです。指定クロック数後に ret が完了するようになっています。WAIT_3X シリーズを rcall して ret する構造ではなく、WAIT_3X に rjump してしまいます。すると WAIT_3X の中の ret によって目的地に ret できるのです。で、rjmp は rcall より 2クロック少ないので、WAIT_3X_10 に対して WAIT_3X_R8 のように 2クロック少ない名前を与えています。WAIT_4Xシリーズに対しても同様です。
これらマクロによってきめ細かく必要なだけ手軽に任意長のウェイトを挿入することができますので、クロック数管理に大いに役立ちます。
トーンジェネレータ
トーンジェネレータは、指定の周期の規則的なパルスを生成します。パルスの幅は ARG、そして発声するパルス数を PULSECNT で指定することにします。
このコードには厳密なクロック数管理が必要ですが、とりあえず次のような発端となるコードを書いて、次にクロック数を合わせていきます。
SUB_TONE:
TONE_LP1:
; ピン出力
in TMP, PORTB
eor TMP, CONST_SOUND
out PORTB, TMP
mov TMP, ARG
TONE_LP2:
dec TMP
brne TONE_LP2
dec PULSECNT
brne TONE_LP1
ret
簡単に説明しますと、冒頭ピン出力の部分ではサウンド出力ピンを反転しています(ON→OFF、OFF→ON)※。CONST_SOUND はサウンドピンのビット位置のみ 1 になった固定値のレジスタで、ポートの現在値と XOR(eor) すると反転します。そして ARG の数だけループして待ちます。これでARG が大きいほどパルスの幅は伸びます。そして PULSECNT をデクリメントしながら 0 になるまで同じ処理を繰り返し、指定個数のパルスを出力します。
※出力の反転なら PINB の当該ピンビットに 1 を書くだけで行えますが、これを書いた当時はその手法を失念していました。sbi PINB, PNO_SOUND の1行で出力ピン反転が完了します。
本システムのシステムクロックはサウンドクロックの 4倍ですから、ARG の値 1 に対して 4クロック待つ必要があります。ARG による待ちは TONE_LP2 ですが、このループはこのままでは 1周 3クロックしか無いので 1クロックのウェイトを入れて 4クロック周期にしなければなりません。その上で、ポートのピン変化が完了した位置(out PORTB の次)をクロック0 として時間を計ってみます。
SUB_TONE:
TONE_LP1:
; ピン出力
in TMP, PORTB
eor TMP, CONST_SOUND
out PORTB, TMP
mov TMP, ARG ; 0 ←数え始め
TONE_LP2:
WAIT 1 ; 1
dec TMP ; 2
brne TONE_LP2 ; 3
dec PULSECNT ; 4
brne TONE_LP1 ; 5
ret ; 6
; 10 呼び出し元に戻った時点のクロック
まずは最短クロック数を計るため、ARG などの値はループしない最小であると仮定します。すると、ret の完了は 10クロック目になります。これは呼び出し元でクロック管理する際に必要な値です。呼び出し元では SUB_TONE を次のように利用したりするはずです。
SOUND_LP:
[何か処理 A]
rcall SUB_TONE
[何か処理 B]
rjmp SOUND_LP ; ループ
[何か処理] は、rcall SUB_TONE するにあたって ARG や PULSECNT を設定したりループ判定するなどです。そしてここで既知となったクロックは rcall SUB_TONE から戻った直後が 10 であるということですので、そこから数えてループ後に再び rcall SUB_TONE をする時、クロックがいくつになっているかを調べます。各行のコメントにその処理の開始クロックを書いていきます。rcall の次の行を 10 として、そこからループを回り、再び rcall するまでを実行順にトレースしていきます。
SOUND_LP:
[何か処理 A] ; 10 + B + 2 (3)
rcall SUB_TONE ; 10 + B + 2 + A (4)
[何か処理 B] ; 10 ← ここから数え始め (1)
rjmp SOUND_LP ; 10 + B (2)
その結果、rcall SUB_TONE は 10 + B + 2 + A クロック目で呼び出されることが分かります。ひとまずそれが 26クロック目ということにします(実際の処理からの一例です)。
再びトーンジェネレータのコードに戻り、SUB_TONE の入り口からクロックを数えます。呼び出し元が 26クロック目で rcall するので、SUB_TONE の入り口はその 4クロック後の 30クロック目になります。
SUB_TONE:
TONE_LP1:
; ピン出力
in TMP, PORTB ; 30 呼び出し元 rcall 時点で 26クロック目
eor TMP, CONST_SOUND ; 31
out PORTB, TMP ; 32
mov TMP, ARG ; 0
TONE_LP2:
WAIT 1 ; 1
dec TMP ; 2
brne TONE_LP2 ; 3
dec PULSECNT ; 4
brne TONE_LP1 ; 5
ret ; 6
; 10 呼び出し元に戻った時点のクロック
すると out PORTB の実行タイミングは 32クロック目ということが分かります。すると実際にパルスの出力が変化するのは 33クロック目ということになりますが、これは 4の倍数ではありません。システムクロックはサウンドクロックの4倍なので、パルス幅は 4の倍数クロックである必要があります。なのでウェイトを挿入して out PORTB が 35クロック目になるようにすれば、パルスの出力変化は 4の倍数である 36クロック目になります。
SUB_TONE:
TONE_LP1:
WAIT 3 ; 30
; ピン出力
in TMP, PORTB ; 33
eor TMP, CONST_SOUND ; 34
out PORTB, TMP ; 35
mov TMP, ARG ; 0 (36クロック目)
TONE_LP2:
WAIT 1 ; 1
dec TMP ; 2
brne TONE_LP2 ; 3
dec PULSECNT ; 4
brne TONE_LP1 ; 5
ret ; 6
; 10 呼び出し元に戻った時点のクロック
次にループ時の辻褄を合わせます。PULSECNT をデクリメントして、まだ数が残っていてループするケースを見ますと、ループするための brne は 5クロック目にあるので、ループして TONE_LP1 到達時点は以下の通り 7クロック目になります。
SUB_TONE:
TONE_LP1:
WAIT 3 ; 30 7 ←ループ時のクロック(2)
:
dec PULSECNT ; 4
brne TONE_LP1 ; 5 ←数え始め (1)
しかしこの位置は先ほど数えた通り 30クロック目である必要があります。なのでこんな感じにラベルの位置変更とウェイト挿入で辻褄を合わせます。
TONE_LP1:
WAIT 23 ; 7 (2)
SUB_TONE:
WAIT 3 ; 30 (3)
; ピン出力
in TMP, PORTB ; 33
eor TMP, CONST_SOUND ; 34
out PORTB, TMP ; 35
mov TMP, ARG ; 0 (36クロック目)
TONE_LP2:
WAIT 1 ; 1
dec TMP ; 2
brne TONE_LP2 ; 3
dec PULSECNT ; 4
brne TONE_LP1 ; 5 ←数え始め (1)
ret ; 6
; 10 呼び出し元に戻った時点のクロック
すると、rcall された時にもループした時にも出力の変化は 36クロック目に来ることになり、辻褄が合います。
またここから分かるのは、出力を変化させてから次の出力を変化させるまでに最低36クロックを要するということです。これは 9サウンドクロックに相当しますから、パルス幅の最小値は 9 となります。これより短いパルスは出せません。なのでパルス幅 9 を出力したい時には ARG に 1 を与える必要があります(パルス幅調整ループ TONE_LP2 を最短ですり抜けるのは ARG が 1 の時)。ようするに ARG には、欲しいパルス幅 – 8 を設定する必用があり、これは次のように定義します。TW は Tone Width のつもりです。
※ただしノイズジェネレータの実装と合わせてこの値はすぐ調整します
#define TW(w) INT((w) - 8)
といった感じで、一旦トーンジェネレータの実装と調整は終わりです。
ノイズジェネレータ
ノイズジェネレータは、前回の記事で特定した 9bit LFSR(線形帰還シフトレジスタ)を使って作ります。
TAP は bit0 と bit4、これの XOR の反転をレジスタ右シフト時に bit9 へフィードバックするのです。コードはいくらかの最適化の末、次のようになりました。NREG がシフトレジスタの下位 8bit、NREG_H の bit0 が シフトレジスタの 9bit目です。NREG_H の残りの bit は何が入っていてもよいです。
ror NREG_H
mov NREG_H, NREG
ror NREG
sbrs NREG_H, 4
com NREG_H
途中に mov が挟まってますが、冒頭の 2つの ror は Cフラグを経由して 9bit のレジスタの右シフトをしています。
途中に挟まってた mov はシフト前の下位 8bit の内容(NREG)を NREG_H にコピーします。その後 sbrs と com によって bit0 と bit4 の反転 XOR をしたような結果を得て、新しい bit9 の値としています。この動作、分かりますか?もう少し補足します。
下図でタップ位置の他の値のケースをご覧ください。00 または 11 なら 1 に、01 または 10 だったら 0 になっていますね。sbrs はレジスタの特定ビットが 1 だったら次の命令をスキップする(実行しない)のです。com はレジスタの全ビットを反転するのです。うまく動いていますね。ぱっと見よく分からないコードですが、なかなかコンパクトな実装になってるでしょう?
アルゴリズムの確認をしたところで、トーンジェネレータと同じようにクロック数を合わせつつノイズジェネレータとして実装します。ARG でノイズ 1bit分のクロック数を、 PULSECNT で出力bit数を指定することにします。
まずは発端となるシンプルな実装です。
SUB_NOISE30:
; ピン出力
mov TMP, NREG
and TMP, CONST_SOUND
out PORTB, TMP
; ノイズ進める
ror NREG_H
mov NREG_H, NREG
ror NREG
sbrs NREG_H, 4
com NREG_H
mov TMP, ARG
NOISE_LP2:
dec TMP
brne NOISE_LP2
dec PULSECNT
brne NOISE_LP1
ret
トーンジェネレータと違うのは、冒頭のサウンド出力ピンの値を LFSR から取得している点と、FLSR の実装を含んでいる点です。COUNST_SOUND にはサウンド出力ピンに相当するビット位置が立った値が入っていて、これをそのまま out PORTB すればサウンド出力ピンが ON になります。ここではこの値と LFSR の乱数が入ったレジスタ NREG と AND を取ることで、乱数に従って ON になったり OFF になったりします。NREG からノイズとして取り出すビット位置は bit0 である必要はなく、いつも同じ位置であればどのビットから取り出しても同じノイズが得られます。
ノイズジェネレータもトーンジェネレータと同じ要領でクロックを数えていきます。まずはパルス幅調整ループである NOISE_LP2 を 4クロック周期に合わせ、出力ピンの変化完了時点をクロック0 として ret が完了するまでを数えていきます。
SUB_NOISE:
NOISE_LP1:
; ピン出力
mov TMP, NREG
and TMP, CONST_SOUND
out PORTB, TMP
; ノイズ進める
ror NREG_H ; 0 ←数え始め
mov NREG_H, NREG ; 1
ror NREG ; 2
sbrs NREG_H, 4 ; 3
com NREG_H ; 4
mov TMP, ARG ; 5
NOISE_LP2:
WAIT 1 ; 6
dec TMP ; 7
brne NOISE_LP2 ; 8
dec PULSECNT ; 9
brne NOISE_LP1 ; 10
ret ; 11
; 15 呼び出し元に戻った時点のクロック
呼び出し元への ret は 15クロック目に完了することが分かりました。そしてトーンジェネレータの場合と同じように、呼び出し元が再び rcall SUB_NOISE するのが何クロック目になるのかを数えます。ひとまずそれが 30クロック目ということにします(実際の処理の一例です)。そして再び SUB_NOISE の入り口からクロック数を数えます。
SUB_NOISE:
NOISE_LP1:
; ピン出力
mov TMP, NREG ; 34 呼び出し元 rcall 時点で 30クロック目
and TMP, CONST_SOUND ; 35
out PORTB, TMP ; 36
; ノイズ進める
ror NREG_H ; 0 (37クロック目)
mov NREG_H, NREG ; 1
ror NREG ; 2
sbrs NREG_H, 4 ; 3
com NREG_H ; 4
mov TMP, ARG ; 5
NOISE_LP2:
WAIT 1 ; 6
dec TMP ; 7
brne NOISE_LP2 ; 8
dec PULSECNT ; 9
brne NOISE_LP1 ; 10
ret ; 11
; 15 呼び出し元に戻った時点のクロック
するとここでも out PORTB の次のクロックが 37 となり 4の倍数になっていないので、トーンジェネレータと同じ要領で WAIT を入れて調整し、さらに NOISE_LP1 の辻褄合わせもします。
NOISE_LP1:
WAIT 22 ; 12
SUB_NOISE:
WAIT 3 ; 34 呼び出し元 rcall 時点で 30クロック目
; ピン出力
mov TMP, NREG ; 37
and TMP, CONST_SOUND ; 38
out PORTB, TMP ; 39
; ノイズ進める
ror NREG_H ; 0 (40クロック目)
mov NREG_H, NREG ; 1
ror NREG ; 2
sbrs NREG_H, 4 ; 3
com NREG_H ; 4
mov TMP, ARG ; 5
NOISE_LP2:
WAIT 1 ; 6
dec TMP ; 7
brne NOISE_LP2 ; 8
dec PULSECNT ; 9
brne NOISE_LP1 ; 10
ret ; 11
; 15 呼び出し元に戻った時点のクロック
これで rcall された時にもループした時にも出力の変化は 40クロック目に来ることになり、辻褄が合いました。
そして必要な最低クロック数が 40 = 10サウンドクロックであることがわかりましたので、パルス幅 10 が欲しいときに ARG に 1 を与えるようにします。その定義はこちらです。Noise Width のつもりです。
#define NW(w) INT((w) - 9)
これでノイズジェネレータの実装と調整もひとまず終わりです。
トーンジェネレータ微調整
トーンジェネレータは、前回のサウンドピン出力変化から 36クロック目に次の変化タイミングが来るのでした。一方ノイズジェネレータではこれが 40クロック目になっています。この差があると何かと面倒ですし、クロック差も小さく調整も容易ですから、トーンジェネレータの出力タイミングを調整します。
トーンジェネレータの冒頭部分は次のようになっていました。
SUB_TONE:
WAIT 3 ; 30 呼び出し元 rcall 時点で 26クロック目
; ピン出力
in TMP, PORTB ; 33
eor TMP, CONST_SOUND ; 34
out PORTB, TMP ; 35
mov TMP, ARG ; 0 (36クロック目)
out PORTB の次が 36クロック目になるように調整されていますが、これをノイズジェネレータと合わせて 40クロック目に来るようにします。
SUB_TONE:
WAIT 7 ; 30 呼び出し元 rcall 時点で 26クロック目
; ピン出力
in TMP, PORTB ; 37
eor TMP, CONST_SOUND ; 38
out PORTB, TMP ; 39
mov TMP, ARG ; 0 (40クロック目)
冒頭のウェイトを 4クロック増やしただけです。ただしこれでトーンジェネレータの最低クロック数も 9サウンドクロックから 10サウンドクロックに増えてしまったので、TW の定義も変更します。トーンジェネレータとノイズジェネレータの最低クロック数が揃って扱いやすくなりました。
#define TW(w) INT((w) - 9)
サウンドシーケンス処理基本
トーンジェネレータやノイズジェネレータにパラメータを与えながら順に呼び出し、特定のサウンドを構成することをサウンドシーケンス処理と呼ぶことにします。簡単なものだと、レジスタ設定と rcall を羅列するだけです。
MYSOUND:
ldi ARG, TW(50)
ldi PULSECNT, PCNT(50, 1000)
rcall SUB_TONE
ldi ARG, TW(60)
ldi PULSECNT, PCNT(60, 1000)
rcall SUB_TONE
ldi ARG, TW(70)
ldi PULSECNT, PCNT(70, 1000)
rcall SUB_TONE
ret
パルス幅 50, 60, 70 で長さが 1000サウンドクロックの音を順に出すコードっぽいものです。そうです、まだ「っぽいもの」でしかありません。クロック合わせをしていないからです。というわけで、とりあえずクロックを数えます。
※PCNT はパルス幅と長さを与えるとパルスカウントを計算するマクロです
まず rcall SUB_TONE は、戻ってきた時のクロックは 10 で数えるのでした。
MYSOUND:
ldi ARG, TW(50)
ldi PULSECNT, PCNT(50, 1000)
rcall SUB_TONE
ldi ARG, TW(60) ; 10 ←
ldi PULSECNT, PCNT(60, 1000) ; 11
rcall SUB_TONE ; 12
ldi ARG, TW(70) ; 10 ←
ldi PULSECNT, PCNT(70, 1000) ; 11
rcall SUB_TONE ; 12
ret ; 10 ←
; 14
いいですね。しかし SUB_TONE は 30クロック目で rcall するとちょうどいいようにできているのでした。なのでこうしてクロックを合わせます。
MYSOUND:
ldi ARG, TW(50)
ldi PULSECNT, PCNT(50, 1000)
rcall SUB_TONE
ldi ARG, TW(60) ; 10
ldi PULSECNT, PCNT(60, 1000) ; 11
WAIT 18 ; 12
rcall SUB_TONE ; 30
ldi ARG, TW(70) ; 10
ldi PULSECNT, PCNT(70, 1000) ; 11
WAIT 18 ; 12
rcall SUB_TONE ; 30
ret ; 10
; 14
一番最初の rcall についてはほっといていいです。この rcall より前にはパルスは出ていないので、タイミングを合わせる必要が無いからです。クロック数を合わせてタイミングを取っているのは、前のパルスからの時間を正確にするためです。逆に、一番最後の rcall はこのままではいけません。以下のように、この後の ret の向こうにサウンド出力ピンを OFF にする処理があるからです。
; サウンドシーケンス呼び出し側等価処理
rcall MYSOUND
cbi PORTB, PNO_SOUND ; 音止める
なので最後のパルスからしかるべき時間が経った後にピンが OFF されるようにしなければなりません。
ここでトーンジェネレータのタイミングをおさらいすると、こんなタイミングで動作しているのでした。
内部というのはトーンジェネレータ内部、ユーザーというのはトーンジェネレータを呼び出す側の処理です。トーンジェネレータはサウンド出力を変化させた位置をクロック0 とした時、クロック10 でユーザー側に戻り、次のサウンドジェネレータ呼び出し時のクロック40 で次の変化を完了させるのでした。実際には ARG によるパルス幅指定によってパルス幅の引き延ばしが入って以下のようになります。
内部で Wクロックの追加が入りますので、その後のタイミングが W だけ後ろにずれます。しかし、処理がユーザーサイドへ戻ってから次のサウンド出力変化の予定タイミングまでは 30クロックのままです。なので便宜上 W を省略して、ユーザーサイドへ戻った時点はクロック10、次のサウンド出力変化はクロック40 であるとします。
このようなタイミングに則り、ユーザーサイドでも最後のトーンジェネレータの呼び出しの後はクロック40 でサウンド出力を OFF にするように制御する必用があります。先ほどのコードではすぐに ret してしまいましたが、これではクロック40 よりもだいぶ早くサウンド出力ピンが OFF になってしまいます。なのでこうします。
MYSOUND:
ldi ARG, TW(50)
ldi PULSECNT, PCNT(50, 1000)
rcall SUB_TONE
ldi ARG, TW(60) ; 10
ldi PULSECNT, PCNT(60, 1000) ; 11
WAIT 18 ; 12
rcall SUB_TONE ; 30
ldi ARG, TW(70) ; 10
ldi PULSECNT, PCNT(70, 1000) ; 11
WAIT 18 ; 12
rcall SUB_TONE ; 30
WAIT_R 39 - 10 ; 10
; 39
クロック40 でサウンド出力ピンを OFF にしたいので、ret はクロック39 で完了させたいです。WAIT_R は指定クロック後に ret を完了させますので、目標クロック 39 からWAIT_R 実行時点のクロック 10 を引いた値を指定します。これで最後のパルス出力が正しい長さで終了します。
なおこの目標クロック 39 は次のように定義していますので、実際のコードでは SGOFF – 10 のように書きます。
#define SGOFF 39
#6 サウンドシーケンス処理実例
クロックの合わせ方について必要なことは全部書いたので、最後に実際のサウンドをひとつ例に取って実践します。トーンもノイズも使った #6 の音で行きます。
※音量注意!
最初はトーンでピュ~、次にノイズでボーンです。トーンの部分の周波数変化はテーブルで音を並べることによって行います。まずはクロック合わせは置いておいて処理を書きます。
SOUND6:
; ピュ~
LDTBL SOUND6_TBL
SOUND6_LP1:
ld ARG, X+
ld PULSECNT, X+
rcall SUB_TONE
cpi XL, LOW(ADDR(SOUND6_TBL_END))
brne SOUND6_LP1
; ボーン
ldi LP1, 32
SOUND6_LP2:
ldi ARG, NW(254)
ldi PULSECNT, 32
rcall SUB_NOISE
dec LP1
brne SOUND6_LP2
; ボタンが離されてなかったら繰り返し
sbis PINB, PNO_BTNIN
rjmp SOUND6_RET
ret
SOUND6_TBL:
.db TD(32, 8125), TD(33, 8125), TD(34, 8125), TD(35, 8125), TD(36, 8125), TD(37, 8125), TD(38, 8125), TD(39, 8125)
.db TD(40, 8125), TD(41, 8125), TD(42, 8125), TD(43, 8125), TD(44, 8125), TD(45, 8125), TD(46, 8125), TD(47, 8125)
.db TD(48, 8125), TD(49, 8125), TD(50, 8125), TD(51, 8125), TD(52, 8125), TD(53, 8125), TD(54, 8125), TD(55, 8125)
.db TD(56, 8125), TD(57, 8125), TD(59, 8125), TD(61, 8125), TD(63, 8125), TD(65, 8125), TD(67, 8125), TD(69, 8125)
SOUND6_TBL_END:
SOUND6_TBL がピュ~のサウンドデータですが、TD というマクロを使用しています。
#define PCNT(w, l) INT((l) / (w) + .5)
#define TW(w) INT((w) - 9)
#define TD(w, l) TW(w), PCNT(w, l)
これは必要なパルス幅と発生時間(サウンドクロック数単位)を与えると、実際のパルス幅データとパルス数をデータ化してくれるものです。
また LDTBL SOUND6_TBL は、16bit レジスタ X に SOUND6_TBL のアドレスを設定するマクロです。
ボーンの部分ではなぜか 32回のループを構成していますが、これはボーンの長さとして 1024パルス欲しいもののこの値は 8bit レジスタに設定できないので、32 x 32 に分けているのです。
まずは最初にあるトーンジェネレータの呼び出し後からクロック数を数えます。青マーカー部分は数えた結果として得られるのなので、数え始めは赤マーカーの部分しか数字は判明していません。
SOUND6:
; ピュ~
LDTBL SOUND6_TBL
SOUND6_LP1:
ld ARG, X+ ; 13
ld PULSECNT, X+ ; 15
rcall SUB_TONE ; 17
cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 10 ←数え始め
brne SOUND6_LP1 ; 11
; ボーン
ldi LP1, 32 ; 12
SOUND6_LP2:
ldi ARG, NW(254) ; 13
ldi PULSECNT, 32 ; 14
rcall SUB_NOISE ; 15
dec LP1
brne SOUND6_LP2
; ボタンが離されてなかったら繰り返し
sbis PINB, PNO_BTNIN
rjmp SOUND6
ret
続いてノイズジェネレータの呼び出し後からも数えてみます。
; ボーン
ldi LP1, 32 ; 12
SOUND6_LP2:
ldi ARG, NW(254) ; 13 18
ldi PULSECNT, 32 ; 14 19
rcall SUB_NOISE ; 15 20
dec LP1 ; 15 ←数え始め
brne SOUND6_LP2 ; 16
; ボタンが離されてなかったら繰り返し
sbis PINB, PNO_BTNIN ; 17
rjmp SOUND6 ; 18
ret ; 19
; 23
すると SOUND6_LP2 の内部でクロック数に矛盾が出る部分が出ることが分かるので、大きい方のクロックに合わせて WAIT を入れて調整します。
; ボーン
ldi LP1, 32 ; 12
WAIT 5 ; 13
SOUND6_LP2:
ldi ARG, NW(254) ; 18
ldi PULSECNT, 32 ; 19
rcall SUB_NOISE ; 20
dec LP1 ; 15 ←数え始め
brne SOUND6_LP2 ; 16
さらにボタンが押されたままの場合の繰り返し処理からクロック数を数えると、
SOUND6:
; ピュ~
LDTBL SOUND6_TBL ; 20
SOUND6_LP1:
ld ARG, X+ ; 13 22
ld PULSECNT, X+ ; 15 24
rcall SUB_TONE ; 17 26
cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 10
brne SOUND6_LP1 ; 11
; ボーン
ldi LP1, 32 ; 12
WAIT 5 ; 13
SOUND6_LP2:
ldi ARG, NW(254) ; 18
ldi PULSECNT, 32 ; 19
rcall SUB_NOISE ; 20
dec LP1 ; 15
brne SOUND6_LP2 ; 16
; ボタンが離されてなかったら繰り返し
sbis PINB, PNO_BTNIN ; 17
rjmp SOUND6 ; 18 ←数え始め
ret ; 19
; 23
ここでも SOUND6_LP1 の内部で矛盾が出ることが分かるので、ウェイトを入れて調整します。ですがウェイトを入れたいのは SOUND6_LP1 へループする brne SOUND6_LP1 に対してなので、このままではウェイトを入れる余地がありません。なので分岐を変更してこうします(LOC は Location のことで、適当なラベル名が欲しい時に利用しています)。
SOUND6:
; ピュ~
LDTBL SOUND6_TBL ; 20
SOUND6_LP1:
ld ARG, X+ ; 22
ld PULSECNT, X+ ; 24
rcall SUB_TONE ; 26
cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 10
breq SOUND6_LOC1 ; 11
WAIT 8 ; 12
rjmp SOUND6_LP1 ; 20
SOUND6_LOC1:
分岐を変更してしまったので、ボーンのクロック数も再確認します。
SOUND6:
; ピュ~
LDTBL SOUND6_TBL ; 20
SOUND6_LP1:
ld ARG, X+ ; 22
ld PULSECNT, X+ ; 24
rcall SUB_TONE ; 26
cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 10
breq SOUND6_LOC1 ; 11 ←数え始め
WAIT 8 ; 12
rjmp SOUND6_LP1 ; 20
SOUND6_LOC1:
; ボーン
ldi LP1, 32 ; 12 13
WAIT 5 ; 13 14
SOUND6_LP2:
ldi ARG, NW(254) ; 18 19
ldi PULSECNT, 32 ; 19 20
rcall SUB_NOISE ; 20 21
dec LP1 ; 15
brne SOUND6_LP2 ; 16
矛盾のある部分がありますので、基本的には大きい方のクロックを取ります。ただここはウェイトが挿入されていますので、ラッキーなことにウェイトを減らして吸収できる部分もあります。
; ボーン
ldi LP1, 32 ; 13
WAIT 4 ; 14
SOUND6_LP2:
ldi ARG, NW(254) ; 18
ldi PULSECNT, 32 ; 19
rcall SUB_NOISE ; 20
dec LP1 ; 15
brne SOUND6_LP2 ; 16
うまく収まりましたね。次はトーンジェネレータとノイズジェネレータの呼び出しタイミングを合わせる必要があります。トーンジェネレータはクロック26 で、ノイズジェネレータはクロック30 で rcall しないといけないのでした。トーンジェネレータの方はよいですがノイズジェネレータの方は合ってないので、こんな風に合わせてみます。
; ボーン
ldi LP1, 32 ; 13
WAIT 4 ; 14
SOUND6_LP2:
ldi ARG, NW(254) ; 18
ldi PULSECNT, 32 ; 19
WAIT 10 ; 20
rcall SUB_NOISE ; 30
dec LP1 ; 15
brne SOUND6_LP2 ; 16
合いましたね。問題ありません。ただ、そもそもノイズジェネレータは本当にクロック30 でしか呼び出せないのでしょうか?ノイズジェネレータの冒頭を確認してみます。
NOISE_LP1:
WAIT 22 ; 12
SUB_NOISE:
WAIT 3 ; 34 呼び出し元 rcall 時点で 30クロック目
; ピン出力
mov TMP, NREG ; 37
and TMP, CONST_SOUND ; 38
out PORTB, TMP ; 39
ノイズジェネレータの入り口である SUB_NOISE はクロック34 に合わせてあるので、その呼び出しはクロック30 で行わなければならないのでした。でもよく見るとその上にクロック12 から流れてくる NOISE_LP1 からの経路があります。なのでこんな感じにしてみます。
NOISE_LP1:
WAIT 12 ; 12
SUB_NOISE20:
WAIT 10 ; 24
SUB_NOISE:
WAIT 3 ; 34 呼び出し元 rcall 時点で 30クロック目
; ピン出力
mov TMP, NREG ; 37
and TMP, CONST_SOUND ; 38
out PORTB, TMP ; 39
すると NOISE_LP1 から流れてくる場合のクロック数に影響を与えず、rcall時点でクロック20 で呼び出せる入り口が作れました。またせっかくなので、ラベル名も呼び出し時クロック数を付けるルールにしてしまいます。
NOISE_LP1:
WAIT 12 ; 12
SUB_NOISE20:
WAIT 10 ; 24
SUB_NOISE30:
いくらか分かりやすくなりました。
再びサウンドシーケンス処理に戻り、こんな感じにします。呼び出し元にウェイトを入れるのではなく、呼び出し元クロックに合わせたサウンドジェネレータ・ノイズジェネレータのエントリーを利用しました。
SOUND6:
; ピュ~
LDTBL SOUND6_TBL ; 20
SOUND6_LP1:
ld ARG, X+ ; 22
ld PULSECNT, X+ ; 24
rcall SUB_TONE26 ; 26
cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 10
breq SOUND6_LOC1 ; 11
WAIT 8 ; 12
rjmp SOUND6_LP1 ; 20
SOUND6_LOC1:
; ボーン
ldi LP1, 32 ; 13
WAIT 4 ; 14
SOUND6_LP2:
ldi ARG, NW(254) ; 18
ldi PULSECNT, 32 ; 19
rcall SUB_NOISE20 ; 20
dec LP1 ; 15
brne SOUND6_LP2 ; 16
あとは最後のパルスの終了タイミングの調整です。ボタンが離されていた場合の処理をこんな感じに修整します。
; ボタンが離されてなかったら繰り返し
sbis PINB, PNO_BTNIN ; 17
rjmp SOUND6 ; 18
WAIT_R SGOFF - 19 ; 19
; SGOFF(39)
クロック合わせ済みのコード全体はこうなりました(テーブル除く)。
SOUND6:
; ピュ~
LDTBL SOUND6_TBL ; 20
SOUND6_LP1:
ld ARG, X+ ; 22
ld PULSECNT, X+ ; 24
rcall SUB_TONE26 ; 26
cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 10
breq SOUND6_LOC1 ; 11
WAIT 8 ; 12
rjmp SOUND6_LP1 ; 20
SOUND6_LOC1:
; ボーン
ldi LP1, 32 ; 13
WAIT 4 ; 14
SOUND6_LP2:
ldi ARG, NW(254) ; 18
ldi PULSECNT, 32 ; 19
rcall SUB_NOISE20 ; 20
dec LP1 ; 15
brne SOUND6_LP2 ; 16
; ボタンが離されてなかったら繰り返し
sbis PINB, PNO_BTNIN ; 17
rjmp SOUND6 ; 18
WAIT_R SGOFF - 19 ; 19
; SGOFF(39)
謎クロック
例えば rcall は大抵 4クロックなのですが、なぜか 5クロックかかることがあります。他にも、所によって 1クロック多くかかってしまう命令があります。実際のコードを引用して見てみます。
SOUNDX1_LP2:
ld ARG, X+ ; 13
ld PULSECNT, X+ ; 15
rcall SUB_TONE17 ; 17 *
cpi XL, LOW(ADDR(SOUNDX1_TBL_END)) ; 10
brne SOUNDX1_LP2 ; 11
WAIT SGOFF - 12 ; 12
cbi PORTB, PNO_SOUND
WAIT 8245 * 4 + 13 ; 0
SOUNDX1_LP3:
ld PULSECNT, -X ; 13
ld ARG, -X ; +1 clock ; 15
rcall SUB_TONE19 ; +1 clock ; 18 *
cpi XL, LOW(ADDR(SOUNDX1_TBL)) ; 10
brne SOUNDX1_LP3 ; 11
同じような構成のループが 2つ並んでいます。SOUNDX1_LP2 と SOUNDX1_LP3 です。Xレジスタの増加方向が逆順という違いがありますが、やってることは同じです。でも SOUNDX1_LP3 の方では 1クロック余計にかかっている箇所があります。赤マーカーを引いた場所ですが、対する緑マーカーを引いた場所ではそのようなクロックの挿入はありません。単純に X+ と -X という違いではない何かの条件で追加クロックが発生し、またこの場所では必ず追加されるので、たまたまでもない何かがあるのです。
なぜここに追加クロックが発生するのか、この謎はまだ解けていないので、シミュレータで動作確認するしかない状況です。常に1クロック増えるので、音が揺れたりはせず極わずかに低くなるだけなので実際気付かないと思いますが、クロックをきっちり合わせたい時には注意しなければならない現象です。ここでは追加クロックも数えて丁度合うようにしています。
ボタン入力
今回は10個のボタンを識別していますが、ATtiny10 でたくさんのボタンを扱う方法についてはこちらの記事で扱っています。GPIO 2ピンを使って最大20+1ボタン扱えます。
ボタン数は button.asm の冒頭に定義していますので、ボタン数を変更したくなった場合にはここを修正します。
.EQU BTN_NUM = 10
.EQU BTN_EXTRA = 0
バグ修正
初回公開ソースにバグがあったので、2022/05/31 に修正しました。
なんと 8bit値の設定箇所にはみ出す数字を設定していました。
SOUNDX1_TBL:
.db TD(184, 2054), TD(266, 2054), TD(103, 2054), TD(83, 2054), TD(71, 2054)
SOUNDX1_TBL_END:
数字が大きいほど音の周波数が低くなりますので、これは限界を超えた低い音です。少しだけ音を高くずらすという修整方法もありますが、ここはシステムクロックを落として音程を下げることにしました。結局こんな修正をしています。
- このサウンド出力時にはシステムクロックを 1/4 に落とす(低音出力モード)
- システムクロックを落とした分、パルス幅データと発声長データの値は 1/4 にする
- このサウンドは音程を 1オクターブ下げる方が雰囲気がよかったので、パルス幅データを 2倍にする
Sounds good!
できましたね!
いやもちろんできないはずはなかったのです。元のメロディIC のクロックが 128kHz なのに対して ATtiny10 は 3V でも 4MHz 出せる高性能プロセッサなので、サウンドに特化したハードウェアでなくても力技でどうにでもなるはずだったのです。ただ、いかに低クロックで実現するかも秘めたるテーマのひとつでした。サウンドクロックとしては元の 128kHz に近い 125kHz を選び、システムクロックはその 4倍の 500kHz としましたが、このくらいのクロックだと出来るかどうかはやってみないと分かりませんでした(その前に 2倍の 250kHz でやろうとして挫折したのは内緒です)。
ところで今回はノイズジェネレータだけでなくトーンジェネレータもソフトウェア実装にしました。当初の目論見はそうではなかったのですが、トーンをタイマ/カウンタで実装しようとすると割り込み処理の都合、システムクロックを引き上げないと無理そうだったのです。ハードウェアを使うためにシステムクロックを引き上げるというのもなんとも本末転倒ですよね。いやもちろん上げちゃいけないこともないのです。低クロックで動かせば ATtiny10 が安くなるというわけでもありませんし、外から見ても分かりませんし。でも莫大なCPUパワーと広大なメモリにまかせたゴリ押し処理が蔓延するこんな世の中ですから、ATtiny10 ではミニマムな世界を楽しみたいところです。
何はともあれ無事やり遂げました。有名メロディIC不足の不安におびえることなく毎日笑顔で過ごせるのです。素晴らしいことです。コードを右往左往しながらクロックを数えた気分はいかがでしたか?まあ正直、無駄に長い記事になってしまいましたので読んでくれた人は少ないと思いますが、いつかご自身でオリジナルの効果音を実装してみてください。ROMはまだ 88バイト残ってますし、要らないサウンドと差し替えてもいいですし、ボタンだってまだ増やせます。その時この記事が、クロックのやりくりをパズルのように楽しむヒントになれば幸いです。
といったところでまたそのうち。