🧩

【AVR ATtiny10】Tinyバトルサウンド (4)クロック沼編

に公開

【AVR ATtiny10】Tinyバトルサウンド

💡 この記事は 2022.05.29 に公開した記事を最新コードに合わせて書き直したものです

おさらい

ここまでで有名メロディIC GSE3568 の同等品の M09 のサウンドの構成やノイズ合成のアルゴリズムを特定し、前回おたのしみ編では AVR ATtiny10 でそれを実装したソースコードや回路図を公開しましたので、皆様におかれましても大変おたのしみいただいたことと存じます。今回はいよいよ本サウンド生成処理の核心、クロック数合わせですので、引き続き奮っておたのしみください。

改めてソースコードです。

ソースコードと HEXファイル

TinyBattleSound131.zip

  • src/main.asm … 各種定義やメインループがあります
  • src/button.asm … キーボード入力処理です
  • src/sound.asm今回のターゲット、サウンドジェネレータです
  • src/sound_list.asm … ボタンとサウンドの対応付けです
  • src/sound_1.asm ~今回のターゲット、サウンドシーケンスです
  • src/wait.asmクロック単位の時間待ちを行うマクロで、もう一つの主役です
  • TinyBattleSound.hex … アセンブル済みの HEXファイルです
  • TinyBattleSound - 20230903b.png … 回路図です

ソースコードは 4 TAB です。

解説

サウンド出力は安定したクロックでタイミングのズレ無く正確に行わないと、音の聞こえ方に悪影響があります。クロックの揺れはジッターと言いまして、ピュアオーディオ界隈などではこのジッターの抑制やらなんやらかんやらで発電所の違いにまでもこだわると聞いていますが、こちらは幸い乾電池駆動ですので、ひたすら正確なクロック管理を行うことが責務となります。
ここでは、コードのいかなる分岐を通ろうとも狂わないサウンドクロックを実現する手法と、トーン生成や LFSR によるノイズ生成処理の実例を示していきます。

クロック数管理

前置きの通り今回はソフトウェアでサウンドを生成しますので、処理クロック数はサウンドのパルス幅ぴったりである必要があります。1クロック多くても少なくてもだめです。このような場合、狙ったクロック数より早く終わるコードを書き、または遅い処理に合わせるように、余った時間をウェイトで潰すのが簡単です。なのでウェイトの方法をいくつか挙げます。

まずはウェイトの基本を。

1クロック待ち
    nop
2クロック待ち
    rjmp WAIT_NEXT
WAIT_NEXT:
3n クロック待ち
    ldi TMP, n
WAIT_LP:
    dec TMP
    brne WAIT_LP
8クロック待ち
    rcall WAIT_RET

WAIT_RET: ; どこかの ret に便乗可
    ret

単純に nop を必要クロック数分並べてもウェイトとしては用をなしますが、1KB しか無い ROM容量は大変貴重なので、1命令で 2クロックかかる rjmp や、呼んで戻ってくるのに 8クロックかかる rcallret、そしてループでの時間待ちなどを組み合わせてコードサイズが小さくなるように努めます。

wait.asm ではこれをマクロ化しています。

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 n … nクロック待つ
WAIT_R n … nクロックで ret する
n は定数、静的なウェイト処理です

wait.asm ラッピングマクロ
wait.asm
; 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 の値に応じて、noprjmp などを並べただけのコードからウェイトループを呼び出すコードまで、n クロックを待つできるだけ小さなコードに展開されます。ウェイトループは 8bit版と 16bit版とがあり、n の大きさで使い分けます。
n の値毎にウェイトのコードを並べた泥臭いマクロですが、作ってしまえばスマートに使えます。WAIT のくせになかなかの働き者です。

もう少し掘り下げます。

8bit版のウェイトループはこんな感じのサブルーチンです。

wait.asm
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 です。

とりあえず TMP0 を設定して rcall WAIT_3X_10 すると、まず rcall が完了するまでに 4クロック、TMP をデクリメントして ret のところへやってくるまでに 2クロック、そして ret が完了するまでに 4クロックの合計 10クロックを費やします。もし TMP1 を設定していたなら、ループが 1回回って 3クロックの追加です。つまり

設定値 * 3 + 10クロック

の待ち時間です。それがラベル名にある 3X_10 に込めた意味です。WAIT_3X_11nop で1クロック使ってから WAIT_3X_10 に到達するので、

設定値 * 3 + 11クロック

の待ち時間になります。だからラベル名も 3X_11 です。3X_12 も同様です。3X_R8 などの R付きのものは後で説明します。

で、これで 1クロック単位で必要時間待つことができるのですが、計算が面倒なのでマクロでラップしているのです。抜粋すると WAITマクロ内のこの部分です。

wait.asm
; 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

詳しくはソースを確認していただきたいのですが、n の値で場合分けしてぴったりそのクロック数を待つ WAIT_3X シリーズの呼び出しに展開してくれます。もちろんここで挿入する ldi TMP, 待ちループ数 の 1クロックもウェイト時間に含むよう考慮しています。16bitカウンタ版の WAIT_4X シリーズも同様です。

⚠ なおこの WAIT の引数は定数のみです。レジスタの値で動的に待つことはできません

ラッピングマクロにはもう一つ、WAIT_R を用意しています。

wait.asm
; 指定クロックで 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

これは、ウェイトしてから ret するマクロです。指定クロック数後に ret が完了するようになっています。WAIT_3X シリーズを rcall して ret する構造ではなく、WAIT_3Xrjmp してしまいます。すると WAIT_3X の中の ret によって目的地に ret できるのです。で、rjmprcall より 2クロック少ないので、WAIT_3X_10 に対して WAIT_3X_R8 のように 2クロック少ない名前を与えています。

これらマクロによってきめ細かく必要なだけ手軽に任意長のウェイトを挿入することができますので、クロック数管理に大いに役立ちます。

💡 最初の .IF @0 <= 4、比較が間違ってて、.IF @0 < 4 が正解です。あと .ELIF @0 < 8.ELIF @0 <= 8 が正解で、その上さらに最適化ができますね。ちょっと甘かったですね。

差動出力マクロおさらい

前回の記事で説明した差動出力マクロ OUTPUT_SOUND を改めて引用します。出力が完了するタイミングを知るため、ここでは out PORTB の位置などご確認ください。詳細は前回に譲ります。

sound.asm 差動出力マクロ
.MACRO OUTPUT_SOUND
    subi TMP, -(((1 << ((PNO_SOUND2) - (PNO_SOUND))) - 1) << PNO_SOUND) ; +0
    sbrc TMP, PNO_SOUND2    ; +1
    sbi DIDR0, PNO_BTNIN    ; +2
    out PORTB, TMP          ; +3 👈出力処理
    sbrs TMP, PNO_SOUND2    ; 0(+4) 👈出力完了
    cbi DIDR0, PNO_BTNIN    ; 1
                            ; 2 👈マクロの次の命令のクロック
.ENDM

マクロ先頭を +0 として +3クロック目に出力実行、+4クロック目に出力完了、その +4クロック目を新しいパルスの起点としてクロック0 とすると、マクロの次の命令は出力完了後 2クロック目になります。

トーンジェネレータ

トーンジェネレータは、指定の周期の規則的なパルスを生成します。パルスの幅は ARG、そして発声するパルス数を PULSECNT で指定することにします。


トーンジェネレータのパラメータ

このコードには厳密なクロック数管理が必要ですが、とりあえず次のような発端となるコードを書いて、次にクロック数を合わせていきます。

トーンジェネレータ 仮組み
SUB_TONE:
TONE_LP1:
    ; ピン出力
    in TMP, PORTB
    andi TMP, 1 << PNO_SOUND ; 👈現在の出力状態を取得
    OUTPUT_SOUND ; 👈差動出力マクロ

    mov TMP, ARG
TONE_LP2:
    dec TMP
    brne TONE_LP2 ; 👈ARG の数だけループで時間待ち

    dec PULSECNT
    brne TONE_LP1 ; 1👈PULSECNT の数だけ繰り返しパルスを出力

    ret

本システムのシステムクロックはサウンドクロックの 4倍ですから、ARG の値 1 に対して 4クロック待つ必要があります。ARG による待ちは TONE_LP2 ですが、このループはこのままでは 1周 3クロックしか無いので、1クロックのウェイトを入れて 4クロック周期にします。その上で、OUTPUT_SOUND の次の命令をポートのピン変化が完了してからのクロック2 としてクロックを追ってみます。

トーンジェネレータ クロックトレース
SUB_TONE:
TONE_LP1:
    ; ピン出力
    in TMP, PORTB
    andi TMP, 1 << PNO_SOUND
    OUTPUT_SOUND

    mov TMP, ARG    ; 2 👋マクロ内で出力完了後 2クロック目
TONE_LP2:
    WAIT 1          ; 3 👈ループが 4クロック単位になるように WAIT
    dec TMP         ; 4
    brne TONE_LP2   ; 5

    dec PULSECNT    ; 6
    brne TONE_LP1   ; 7

    ret             ; 8
                    ; 12 👈呼び出し元に戻った時点のクロック

まずは最短クロック数を計るため、ARG などの値はループしない最小であると仮定します。すると、ret の完了は 12クロック目になります。これは呼び出し元でクロック管理する際に必要な値です。呼び出し元では SUB_TONE を次のように利用したりするはずです。

SUB_TONE 呼び出し元例
SOUND_LP:
   [何か処理 A]

   rcall SUB_TONE

   [何か処理 B]
   rjmp SOUND_LP ; ループ

[何か処理] は、rcall SUB_TONE するにあたって ARGPULSECNT を設定したりループ判定するなどです。そして先ほど判明したことは rcall SUB_TONE から戻った直後がクロック 12 であるということですので、そこから数えてループ後に再び rcall SUB_TONE をする時、クロックがいくつになっているかを調べます。各行のコメントにその処理の開始クロックを書いていきます。rcall の次の行を 12 として、そこからループを回り、再び rcall するまでを実行順にトレースしていきます。

SUB_TONE 呼び出し元のクロックトレース
SOUND_LP:
   [何か処理 A]     ; 12 + B + 2

   rcall SUB_TONE   ; 12 + B + 2 + A

   [何か処理 B]     ; 12 👋ここから数え始め
   rjmp SOUND_LP    ; 12 + B

その結果、rcall SUB_TONE12 + B + 2 + A クロック目で呼び出されることが分かります。ひとまずそれが 28クロック目ということにします(実際の処理からの一例ですが、とにかくそれっぽい値を決めます)。

再びトーンジェネレータのコードに戻り、SUB_TONE の入り口からクロックを数えます。呼び出し元が 28クロック目で rcall するので、SUB_TONE の入り口はその 4クロック後の 32クロック目になります。

トーンジェネレータ クロックトレース
SUB_TONE:
TONE_LP1:
    ; ピン出力
    in TMP, PORTB   ; 32 👋呼び出し元 rcall 時点で 28クロック目
    andi TMP, 1 << PNO_SOUND ; 33
    OUTPUT_SOUND    ; 34
                    ; 37 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 38クロック目

    mov TMP, ARG    ; 2 👈マクロ内で出力完了後 2クロック目
TONE_LP2:
    WAIT 1          ; 3
    dec TMP         ; 4
    brne TONE_LP2   ; 5

    dec PULSECNT    ; 6
    brne TONE_LP1   ; 7

    ret             ; 8
                    ; 12 呼び出し元に戻った時点のクロック

すると OUTPUT_SOUNDマクロ内での out PORTB の実行タイミングは 37クロック目ということが分かります。すると実際にパルスの出力が変化するのは 38クロック目ということになりますが、これは 4の倍数ではありません。システムクロックはサウンドクロックの4倍なので、パルス幅は 4の倍数クロックである必要があります。なのでウェイトを挿入して out PORTB が 39クロック目になるようにすれば、パルスの出力変化は 4の倍数である 40クロック目になります。

トーンジェネレータ 出力完了タイミング調整
SUB_TONE:
TONE_LP1:
    ; ピン出力
    WAIT 2          ; 32 👈出力完了タイミング調整
    in TMP, PORTB   ; 34
    andi TMP, 1 << PNO_SOUND ; 35
    OUTPUT_SOUND    ; 36
                    ; 39 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 40クロック目

    mov TMP, ARG    ; 2 👈マクロ内で出力完了後 2クロック目
TONE_LP2:
    WAIT 1          ; 3
    dec TMP         ; 4
    brne TONE_LP2   ; 5

    dec PULSECNT    ; 6
    brne TONE_LP1   ; 7

    ret             ; 8
                    ; 12 呼び出し元に戻った時点のクロック

次にループ時の辻褄を合わせます。PULSECNT をデクリメントして、まだ数が残っていてループするケースを見ますと、次の通りループするための brne は 7クロック目にあるので、ループして TONE_LP1 到達時点は 9クロック目になります。

トーンジェネレータ ループ時クロックトレース
SUB_TONE:
TONE_LP1:
    WAIT 2          ; 32 9 👈ループしてきた場合のクロック

     :

    dec PULSECNT    ; 6
    brne TONE_LP1   ; 7 👋数え始め

しかしこの位置は先ほど決めた通り 32クロック目である必要があります。なのでこんな感じにラベルの位置変更とウェイト挿入で辻褄を合わせます。

トーンジェネレータ ループ時クロック調整
TONE_LP1:
    WAIT 23         ; 9 👈ループしてきた 9クロック目から 23クロックウェイト
SUB_TONE:
    ; ピン出力
    WAIT 2          ; 32 👈ループしてきても 32クロック目
    in TMP, PORTB   ; 34
    andi TMP, 1 << PNO_SOUND ; 35
    OUTPUT_SOUND    ; 36
                    ; 39 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 40クロック目

    mov TMP, ARG    ; 2 👈マクロ内で出力完了後 2クロック目
TONE_LP2:
    WAIT 1          ; 3
    dec TMP         ; 4
    brne TONE_LP2   ; 5

    dec PULSECNT    ; 6
    brne TONE_LP1   ; 7 👋数え始め

    ret             ; 8
                    ; 12 呼び出し元に戻った時点のクロック

すると、rcall された時にもループした時にも出力の変化は 40クロック目に来ることになり、辻褄が合います。

💡 サブルーチンのエントリーよりも前(下位アドレス)にループ先ラベルを置くのは意外じゃないですか?アセンブラなら可能な便利なテクニックだと思います。

またここから分かるのは、出力を変化させてから次の出力を変化させるまでに最低 40クロックを要するということです。これは 10サウンドクロックに相当しますから、パルス幅の最小値は 10 となります。これより短いパルスは出せません。なのでパルス幅 10 を出力したい時には ARG に 1 を与える必要があります(パルス幅調整ループ TONE_LP2 を最短ですり抜けるのは ARG1 の時)。ようするに ARG には、欲しいパルス幅 – 9 を設定する必用があり、これを次のように定義します。TW は Tone Width のつもりです。

💡 ただしこの後のノイズジェネレータの実装と合わせてこの値はすぐ調整します

最小トーン幅定義 暫定
#define TW(w) INT((w) - 9)

といった感じで、一旦トーンジェネレータの実装と調整は終わりです。

📝 メモ

  • トーンジェネレータはクロック28rcall すること
  • トーンジェネレータから帰ってきた時点はクロック12

ノイズジェネレータ

ノイズジェネレータは、前回の記事で特定した 9bit LFSR(線形帰還シフトレジスタ)を使って作ります。


9bit LFSR TAP bit 0, 4, 反転フィードバック

TAP は bit0 と bit4、これの XOR の反転をレジスタ右シフト時に bit8(9bit目) へフィードバックするのです。コードはいくらかの最適化の末、次のようになりました。NREG がシフトレジスタの下位 8bit、NREG_H の bit0 が シフトレジスタの 9bit目です。NREG_H の残りの bit はゴミです。

LFSR 実装
    ror NREG_H
    mov NREG_H, NREG
    ror NREG
    sbrs NREG_H, 4
    com NREG_H

途中に mov が挟まってますが、冒頭の 2つの rorCフラグを経由して 9bit のレジスタの右シフトをしています。


9bitレジスタ 右シフト

途中に挟まってた mov はシフト前の下位 8bit の内容(NREG)を NREG_H にコピーしています。


LSFR のフィードバック処理

これはシフトして追い出された bit0 をひとまずそのまま bit8 へフィードバックしたことと等価です。その後 sbrscom によって bit0 と bit4 の反転 XOR をしたような結果を得て、新しい bit8 の値としています。この動作、分かりますか?もう少し補足します。

下図でタップ位置の他の値のケースをご覧ください。


タップの値毎のフィードバック結果

0 0 または 1 1 なら 1 に、0 1 または 1 0 だったら 0 になっていますね。sbrs はレジスタの特定ビットが 1 だったら次の命令をスキップする(実行しない)のです。com はレジスタの全ビットを反転します。ぱっと見よく分からないコードですが、うまく動いています。なかなかコンパクトな実装になってるでしょう?

アルゴリズムの確認をしたところで、トーンジェネレータと同じようにクロック数を合わせつつノイズジェネレータとして実装します。ARG でノイズ 1bit分のクロック数を、PULSECNT で出力bit数を指定することにします。


ノイズジェネレータのパラメータ

まずは発端となるシンプルな実装です。

ノイズジェネレータ 仮組み
SUB_NOISE:
NOISE_LP1:
    ; ピン出力
    ldi TMP, 1 << PNO_SOUND ; 👈SOUND+ のビット位置だけ立てる
    and TMP, NREG   ; 👈ノイズレジスタその bit を取り出す
    OUTPUT_SOUND

    ; ノイズ進める
    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 のレジスタから取得している点と、内部に LFSR の実装を含んでいる点です。冒頭でノイズレジスタ NREGSOUND+ のビット位置の値を取り出しています。LFSR の図では bit0 から取り出すように描いていますが、どのビットを取ってもサウンドは同じなので都合の良いビットを選びます。また OUTPUT_SOUND では、出力したい値の反転を設定する必要がありますが、ノイズが逆相でも聞こえ方は同じですから、非反転のまま設定しています。コード短縮のためです。

ノイズジェネレータもトーンジェネレータと同じ要領でクロックを数えていきます。まずはパルス幅調整ループである NOISE_LP2 を 4クロック単位に合わせ、マクロの次の命令をクロック2 として ret が完了するまでを数えていきます。

ノイズジェネレータ クロックトレース
SUB_NOISE:
NOISE_LP1:
    ; ピン出力
    ldi TMP, 1 << PNO_SOUND
    and TMP, NREG
    OUTPUT_SOUND

    ; ノイズ進める
    ror NREG_H      ; 2 👋数え始め
    mov NREG_H, NREG ; 3
    ror NREG        ; 4
    sbrs NREG_H, 4  ; 5
    com NREG_H      ; 6

    mov TMP, ARG    ; 7
NOISE_LP2:
    WAIT 1          ; 8 👈ループが 4クロック単位になるように WAIT
    dec TMP         ; 9
    brne NOISE_LP2  ; 10

    dec PULSECNT    ; 11
    brne NOISE_LP1  ; 12

    ret             ; 13
                    ; 17 👈呼び出し元に戻った時点のクロック

呼び出し元への ret は 17クロック目に完了することが分かりました。そしてトーンジェネレータの場合と同じように、呼び出し元が再び rcall SUB_NOISE するのが何クロック目になるのかを数えます。過程は省略しますがひとまずそれが 32クロック目ということにします(実際の処理の一例です)。そして再び SUB_NOISE の入り口からクロック数を数えます。

ノイズジェネレータ クロックトレース
SUB_NOISE:
NOISE_LP1:
    ; ピン出力
    ldi TMP, 1 << PNO_SOUND ; 36 👋呼び出し元 rcall 時点で 32クロック目
    and TMP, NREG   ; 37
    OUTPUT_SOUND    ; 38
                    ; 42 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 43クロック目

    ; ノイズ進める
    ror NREG_H      ; 2 👈マクロ内で出力完了後 2クロック目
    mov NREG_H, NREG ; 3
    ror NREG        ; 4
    sbrs NREG_H, 4  ; 5
    com NREG_H      ; 6

    mov TMP, ARG    ; 7
NOISE_LP2:
    WAIT 1          ; 8
    dec TMP         ; 9
    brne NOISE_LP2  ; 10

    dec PULSECNT    ; 11
    brne NOISE_LP1  ; 12

    ret             ; 13
                    ; 17 👈呼び出し元に戻った時点のクロック

するとここでも out PORTB の次のクロックが 43 となり 4の倍数になっていないので、トーンジェネレータと同じ要領で WAIT を入れて調整し、さらに NOISE_LP1 の辻褄合わせまでしちゃいます。

ノイズジェネレータ 出力完了タイミング、ループ時クロック調整
NOISE_LP1:
    WAIT 22         ; 14 👈ループしてきた 14クロック目から 22クロックウェイト

SUB_NOISE:
    WAIT 1          ; 36 👈出力完了タイミングを 4の倍数(44)に
    ; ピン出力
    ldi TMP, 1 << PNO_SOUND ; 37
    and TMP, NREG   ; 38
    OUTPUT_SOUND    ; 39
                    ; 43 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 44クロック目

    ; ノイズ進める
    ror NREG_H      ; 2 👈マクロ内で出力完了後 2クロック目
    mov NREG_H, NREG ; 3
    ror NREG        ; 4
    sbrs NREG_H, 4  ; 5
    com NREG_H      ; 6

    mov TMP, ARG    ; 7
NOISE_LP2:
    WAIT 1          ; 8
    dec TMP         ; 9
    brne NOISE_LP2  ; 10

    dec PULSECNT    ; 11
    brne NOISE_LP1  ; 12 👋 数え始め、2クロック後に NOISE_LP1 へ到達

    ret             ; 13
                    ; 17 👈呼び出し元に戻った時点のクロック

これで rcall された時にもループした時にも出力の変化は 44クロック目に来ることになり、辻褄が合いました。
そして必要な最低クロック数が 44 = 11サウンドクロックであることがわかりましたので、パルス幅 11 が欲しいときに ARG に 1 を与えるようにします。その定義はこちらです。Noise Width のつもりです。

最小ノイズ幅定義
#define NW(w) INT((w) - 10)

これでノイズジェネレータの実装と調整もひとまず終わりです。

📝 メモ

  • ノイズジェネレータはクロック32rcall すること
  • ノイズジェネレータから帰ってきた時点はクロック17

トーンジェネレータ微調整

トーンジェネレータは、前回のサウンドピン出力変化から 40クロック目に次の変化タイミングが来るのでした。一方ノイズジェネレータではこれが 44クロック目になっています。この差があると何かと面倒ですし、クロック差も小さく調整も容易ですから、トーンジェネレータの出力タイミングを調整します。

トーンジェネレータの冒頭部分は次のようになっていました。

トーンジェネレータ 入口
SUB_TONE:
    ; ピン出力
    WAIT 2          ; 32
    in TMP, PORTB   ; 34
    andi TMP, 1 << PNO_SOUND ; 35
    OUTPUT_SOUND    ; 36
                    ; 39 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 40クロック目

    mov TMP, ARG    ; 2 👈マクロ内で出力完了後 2クロック目

out PORTB の次が 40クロック目になるように調整されていますが、これをノイズジェネレータと合わせて 44クロック目に来るようにします。

トーンジェネレータ 出力完了タイミング再調整
SUB_TONE:
    ; ピン出力
    WAIT 6          ; 32 👋ウェイト調整
    in TMP, PORTB   ; 38
    andi TMP, 1 << PNO_SOUND ; 39
    OUTPUT_SOUND    ; 40
                    ; 43 👈マクロ内の out PORTB 位置
                    ; 0  👈マクロ内で出力完了 44クロック目

    mov TMP, ARG    ; 2 👈マクロ内で出力完了後 2クロック目

冒頭のウェイトを 4クロック増やしただけです。ただしこれでトーンジェネレータの最低クロック数も 10サウンドクロックから 11サウンドクロックに増えてしまったので、TW も再定義します。トーンジェネレータとノイズジェネレータの最低クロック数が揃って扱いやすくなりました。

最小トーン幅 再定義
#define TW(w) INT((w) - 10)

📝 メモ

  • トーンジェネレータおよびノイズジェネレータの最小パルス幅は 11

サウンドシーケンス処理基本

トーンジェネレータやノイズジェネレータにパラメータを与えながら順に呼び出し、目的のサウンドを構成することをサウンドシーケンス処理と呼ぶことにします。簡単なものだと、レジスタ設定と 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 は、戻ってきた時のクロックは 12 で数えるのでした。

サウンドシーケンス クロックトレース
MYSOUND:
    ldi ARG, TW(50)
    ldi PULSECNT, PCNT(50, 1000)
    rcall SUB_TONE

    ldi ARG, TW(60) ; 12 👈前 rcall から戻ってきた直後
    ldi PULSECNT, PCNT(60, 1000) ; 13
    rcall SUB_TONE  ; 14

    ldi ARG, TW(70) ; 12 👈前 rcall から戻ってきた直後
    ldi PULSECNT, PCNT(70, 1000) ; 13
    rcall SUB_TONE  ; 14

    ret             ; 12 👈前 rcall から戻ってきた直後
                    ; 16 👈呼び出し元に戻った時点のクロック

いいですね。しかし SUB_TONE は 28クロック目で rcall するとちょうどいいようにできているのでした。なのでこうしてクロック数を合わせます。

サウンドシーケンス タイミング合わせ
MYSOUND:
    ldi ARG, TW(50)
    ldi PULSECNT, PCNT(50, 1000)
    rcall SUB_TONE

    ldi ARG, TW(60) ; 12 👈前 rcall から戻ってきた直後
    ldi PULSECNT, PCNT(60, 1000) ; 13
    WAIT 14         ; 14 👈次が 28クロック目になるように
    rcall SUB_TONE  ; 28

    ldi ARG, TW(70) ; 12 👈前 rcall から戻ってきた直後
    ldi PULSECNT, PCNT(70, 1000) ; 13
    WAIT 14         ; 14 👈次が 28クロック目になるように
    rcall SUB_TONE  ; 28

    ret             ; 12 👈前 rcall から戻ってきた直後
                    ; 16 👈呼び出し元に戻った時点のクロック

一番最初の rcall についてはほっといていいです。クロック数を合わせてタイミングを取るのは、前のパルスからの時間を正確にするためですが、この rcall より前にはパルスは出ていないので、タイミングを合わせる必要が無いからです。一方で、一番最後の rcall 以降はこのままではいけません。以下のように、この後の ret の向こうにサウンド出力ピンを OFF にする処理があるからです。

サウンドシーケンス呼び出し側等価処理
    ; サウンドシーケンス呼び出し側等価処理
    rcall MYSOUND
    out PORTB, ZERO     ; 音止める 👈 ret の直後はここに来る
    cbi DDRB, PNO_SOUND ; サウンドピン OPEN

なので最後のパルス出力からしかるべき時間が経った後にピンが OFF されるようにしなければいけません。

ここでトーンジェネレータのタイミングをおさらいすると、こんなタイミングで動作しているのでした。


トーンジェネレータ動作タイミング

内部というのはトーンジェネレータ内部、ユーザーというのはトーンジェネレータを呼び出す側の処理です。トーンジェネレータはサウンド出力を変化させた位置をクロック0 とした時、クロック12 でユーザー側に戻りますが、この時にはまだパルスを全部出し終えていない、仕掛中の段階です。このパルスの終わりは図の通りクロック44 であって、これは通常、クロック28 でトーンジェネレータをもう一度呼び出した時に訪れるタイミングです。

ARG によるパルス幅指定をした場合は、以下のようになります。


パルス引き延ばし時の動作タイミング

内部で Wクロックの追加が入りますので、その後のタイミングが W だけ後ろにずれます。しかし、処理がユーザーサイドへ戻った時点でまだ出し終えていないパルス幅は W の値に依らず 32クロックのまま変わりませんから、ユーザー側の処理に戻った時点をクロック12 として先程と同様にクロック28 でトーンジェネレータをもう一度呼び出した時に丁度よくパルスの周期が終了します。

もしトーンジェネレータを呼び出さずユーザーがパルスを OFF する場合、トーンジェネレータに合わせてクロック44 のタイミングで OFF させなければ、パルス幅がトーンジェネレータでの出力と合わなくなってしまいます。先ほどのコードではすぐに ret してしまいましたので、クロック44 よりもだいぶ早くパルスが OFF になってしまうというわけです。

なのでこうします。

サウンドシーケンス OFF タイミング合わせ
    rcall SUB_TONE  ; 28 👈最後の rcall

    WAIT_R 43-12    ; 12 👈ret で戻った時に 43クロック目になるように
                    ; 43

クロック44 でパルスを OFF にしたいので、ret はクロック43 で完了させたいです。WAIT_R は指定クロック後に ret を完了させますので、目標クロック 43 から WAIT_R 実行時点のクロック 12 を引いた値を指定します。これで最後のパルス出力が正しい長さで終了します。

そしてこの目標クロック 43 は次のように定義していますので、実際のコードでは SGOUT – 12 のように書きます。

sound.asm
#define SGOUT 43    ; サウンドピン出力タイミング(out PORTB のクロック、4n - 1 になるように)

サウンド#6 シーケンス処理実例

クロックの合わせ方について必要なことは一通り書いたので、最後に実際のサウンドをひとつ例に取って実践します。トーンもノイズも使った #6 のサウンドで行きます。

実際のサウンド

音量注意!

https://youtu.be/UzGaBBbkZTQ

サウンド分析データTSV
m09-tone.zip

最初はトーンでピュ~、次にノイズでボーンです。トーンの部分の周波数変化はテーブルで音を並べることによって行います。まずはクロック数合わせは置いておいて処理を書きます。

サウンド#6 仮組み
SOUND6:
SOUND6_LP1:
    ; ピュ~
    ldi XL, LOW(ADDR(SOUND6_TBL))   ; 👈テーブルアドレスを X に
    ldi XH, HIGH(ADDR(SOUND6_TBL))  ; 👈 〃

SOUND6_LP2:
    ld ARG, X+      ; 👈テーブルからパルス幅取得
    ld PULSECNT, X+ ; 👈テーブルからパルス数取得
    rcall SUB_TONE
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 👈テーブル終端チェック
                                      ; 💡テーブルサイズが 256Bytes 以下なら Low Byte のみ比較で OK
    brne SOUND6_LP2

    ; ボーン
    ldi LP1, 32 ; 👈32回ループ
SOUND6_LP3:
    ldi ARG, NW(254)
    ldi PULSECNT, 32 ; 👈パルス数 32、ループによって総計 1024
    rcall SUB_NOISE
    dec LP1
    brne SOUND6_LP3

    ; ボタンが離されてなかったら繰り返し
    sbis EIFR, INTF0
    rjmp SOUND6_LP1
    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:

まずは最初にあるトーンジェネレータの呼び出し後からクロック数を数えます。rcall から戻ったところがクロック12 ですから、ここを数え始めとして、SOUND6_LP2 にループする場合と「ボーン」のrcall SUB_NOISE までのクロック数を求めていきます。

サウンド#6 クロックトレース
SOUND6:
SOUND6_LP1:
    ; ピュ~
    ldi XL, LOW(ADDR(SOUND6_TBL))
    ldi XH, HIGH(ADDR(SOUND6_TBL))

SOUND6_LP2:
    ld ARG, X+          ; 15 👈ループしてきた場合
    ld PULSECNT, X+     ; 17
    rcall SUB_TONE      ; 19
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 12 👋数え始め、rcall後のクロック12
    brne SOUND6_LP2     ; 13

    ; ボーン
    ldi LP1, 32         ; 14 👈ループせず素通りした場合
SOUND6_LP3:
    ldi ARG, NW(254)    ; 15
    ldi PULSECNT, 32    ; 16
    rcall SUB_NOISE     ; 17
    dec LP1
    brne SOUND6_LP3

続いてノイズジェネレータの呼び出し後からも数えてみます。

サウンド#6 クロックトレース
    ; ボーン
    ldi LP1, 32         ; 14
SOUND6_LP3:
    ldi ARG, NW(254)    ; 15 20 👈初回突入とループでの再突入とで差異がある
    ldi PULSECNT, 32    ; 16 21 👈 〃
    rcall SUB_NOISE     ; 17 22 👈 〃
    dec LP1             ; 17 👋数え始め、rcall後のクロック17
    brne SOUND6_LP3     ; 18 👈ループする場合を考える

    ; ボタンが離されてなかったら繰り返し
    sbis EIFR, INTF0    ; 19
    rjmp SOUND6_LP1     ; 20
    ret                 ; 21
                        ; 25

すると SOUND6_LP3 の内部でクロック数に矛盾が出る部分が出ることが分かるので、大きい方のクロックに合わせて WAIT を入れて調整します。

サウンド#6 クロック調整
    ; ボーン
    ldi LP1, 32         ; 14
    WAIT 5              ; 15 👈クロック調整
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20 👈矛盾解消
    ldi PULSECNT, 32    ; 21 👈 〃
    rcall SUB_NOISE     ; 22 👈 〃
    dec LP1             ; 17 👋数え始め、rcall後のクロック17
    brne SOUND6_LP3     ; 18

さらにボタンが押されたままの場合の SOUND6_LP1 からのクロック数を数えると、

サウンド#6 クロックトレース
SOUND6:
SOUND6_LP1:
    ; ピュ~
    ldi XL, LOW(ADDR(SOUND6_TBL))  ; 22 👈ループしてきた時
    ldi XH, HIGH(ADDR(SOUND6_TBL)) ; 23

SOUND6_LP2:
    ld ARG, X+          ; 15 24 👈初回突入とループでの再突入とで差異がある
    ld PULSECNT, X+     ; 17 26 👈 〃
    rcall SUB_TONE      ; 19 28 👈 〃
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 12
    brne SOUND6_LP2     ; 13

    ; ボーン
    ldi LP1, 32         ; 14
    WAIT 5              ; 15
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20
    ldi PULSECNT, 32    ; 21
    rcall SUB_NOISE     ; 22
    dec LP1             ; 17
    brne SOUND6_LP3     ; 18

    ; ボタンが離されてなかったら繰り返し
    sbis EIFR, INTF0    ; 19
    rjmp SOUND6_LP1     ; 20 👋数え始め
    ret                 ; 21
                        ; 25

ここでも SOUND6_LP2 の内部で矛盾が出ることが分かるので、ウェイトを入れて調整します。ですがクロックの大きい方に合わせるためにウェイトを入れたいのは SOUND6_LP2 へループする brne SOUND6_LP2 に対してなので、このままではウェイトを入れる良い場所がありません

なので分岐を変更してこうします(LOC は Location のことで、適当なラベル名が欲しい時に利用しています)。

サウンド#6 分岐変更、タイミング調整
SOUND6:
SOUND6_LP1:
    ; ピュ~
    ldi XL, LOW(ADDR(SOUND6_TBL))  ; 22
    ldi XH, HIGH(ADDR(SOUND6_TBL)) ; 23

SOUND6_LP2:
    ld ARG, X+          ; 24 👈矛盾解消
    ld PULSECNT, X+     ; 26 👈 〃
    rcall SUB_TONE      ; 28 👈 〃
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 12
    breq SOUND6_LOC1    ; 13 👋分岐変更
    WAIT 8              ; 14 👈クロック調整
    rjmp SOUND6_LP2     ; 22
SOUND6_LOC1:            ; 👈分岐先追加

分岐を変更してしまったので、ボーンのクロック数も再確認します。

サウンド#6 クロック再確認
SOUND6:
SOUND6_LP1:
    ; ピュ~
    ldi XL, LOW(ADDR(SOUND6_TBL))  ; 22
    ldi XH, HIGH(ADDR(SOUND6_TBL)) ; 23

SOUND6_LP2:
    ld ARG, X+          ; 24
    ld PULSECNT, X+     ; 26
    rcall SUB_TONE      ; 28
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 12
    breq SOUND6_LOC1    ; 13 👋数え始め
    WAIT 8              ; 14
    rjmp SOUND6_LP2     ; 22
SOUND6_LOC1:

    ; ボーン
    ldi LP1, 32         ; 14 15 👈変化
    WAIT 5              ; 15 16 👈変化
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20 21 👈変化
    ldi PULSECNT, 32    ; 21 22 👈変化
    rcall SUB_NOISE     ; 22 23 👈変化
    dec LP1             ; 17
    brne SOUND6_LP3     ; 18

クロック数が変化した部分がありますが、タイミングを調整していた SOUND6_LP3 の内側は元に戻しておきたいです。幸いループ直前にウェイトを置いていたので、これを減らしてタイミングを元に戻します。

サウンド#6 タイミング調整
    ; ボーン
    ldi LP1, 32         ; 15
    WAIT 4              ; 16 👈WAIT 減らす
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20 👈解消
    ldi PULSECNT, 32    ; 21 👈 〃
    rcall SUB_NOISE     ; 22 👈 〃
    dec LP1             ; 17
    brne SOUND6_LP3     ; 18

うまく収まりましたね。

次はトーンジェネレータとノイズジェネレータの呼び出しタイミングを合わせる必要があります。トーンジェネレータはクロック28 で、ノイズジェネレータはクロック32 で rcall しないといけないのでした。トーンジェネレータはちょうど合っているのですがノイズジェネレータは合っていないので、こんな風に合わせてみます。

サウンド#6 呼び出しタイミング調整
    ; ボーン
    ldi LP1, 32         ; 15
    WAIT 4              ; 16
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20
    ldi PULSECNT, 32    ; 21
    WAIT 10             ; 22 👈rcall のタイミング調整
    rcall SUB_NOISE     ; 32 👈クロック32 で呼び出し
    dec LP1             ; 17
    brne SOUND6_LP3     ; 18

合いましたね。問題ありません。ただ、そもそもノイズジェネレータは、本当にクロック32 でしか呼び出せないのでしょうか?

とりあえずノイズジェネレータの冒頭を確認してみます。

ノイズジェネレータ 冒頭
NOISE_LP1:
    WAIT 22         ; 14

SUB_NOISE:          ; 👋ノイズジェネレータ入り口
    WAIT 1          ; 36 呼び出し元 rcall 時点で 32クロック目
    ; ピン出力

ノイズジェネレータの入り口である SUB_NOISE はクロック36 に合わせてあるので、その呼び出しはクロック32 で行わなければならないのでした。でもよく見るとその上にクロック14 から流れてくる NOISE_LP1 からの経路があります。なのでこんな感じにしてみます。

ノイズジェネレータ 入り口追加
NOISE_LP1:
    WAIT 12         ; 14 👈WAIT を分割

SUB_NOISE22:        ; 👋入り口追加
    WAIT 10         ; 26 呼び出し元 rcall 時点で 22クロック目

SUB_NOISE32:        ; 👈名称変更、呼び出し元クロック数を名前に含める
    WAIT 1          ; 36 呼び出し元 rcall 時点で 32クロック目
    ; ピン出力

こうすると NOISE_LP1 から流れてくる場合のクロック数に影響を与えず、rcall時点でクロック22 で呼び出せる入り口が作れました。またせっかくなので、ラベル名も呼び出し時クロック数を付けるルールにしてしまいます。分かりやすくなりましたね。

💡 このルールによって、シーケンス処理で rcall する部分ではとにかくそこのクロック数での SUB_NOISExx を書いてしまえばよくなります。アセンブル時にそのエントリーが未定義ならエラーになりますから、それを受けて SUB_NOISE の冒頭の WAIT を分割してそのエントリーを作って再アセンブルすれば良いのです。もちろん SUB_TONE についても同様です。

再びサウンドシーケンス処理に戻り、こんな感じにします。呼び出し元にウェイトを入れるのではなく、呼び出し元クロックに合わせたサウンドジェネレータ・ノイズジェネレータのエントリーを利用しました。

サウンド#6 呼び出しリファイン
SOUND6_LP2:
    ld ARG, X+          ; 24
    ld PULSECNT, X+     ; 26
    rcall SUB_TONE28    ; 28 👈クロック28 に対応する呼び出し
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 12
    breq SOUND6_LOC1    ; 13
    WAIT 8              ; 14
    rjmp SOUND6_LP2     ; 22
SOUND6_LOC1:

    ; ボーン
    ldi LP1, 32         ; 15
    WAIT 4              ; 16
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20
    ldi PULSECNT, 32    ; 21
    rcall SUB_NOISE22   ; 22 👈クロック22 に対応する呼び出し
    dec LP1             ; 17
    brne SOUND6_LP3     ; 18

あとは最後のパルスの終了タイミングの調整です。ボタンが離されていた場合の処理をこんな感じに修整します。

サウンド#6 終了タイミング調整
    ; ボタンが離されてなかったら繰り返し
    sbis EIFR, INTF0    ; 19
    rjmp SOUND6_LP1     ; 20

    WAIT_R SGOUT - 21   ; 21 👈タイミングを合わせて ret

クロック数合わせ済みのコード全体はこうなりました(テーブル除く)。

サウンド#6 完成
SOUND6:
SOUND6_LP1:
    ; ピュ~
    ldi XL, LOW(ADDR(SOUND6_TBL))  ; 22
    ldi XH, HIGH(ADDR(SOUND6_TBL)) ; 23

SOUND6_LP2:
    ld ARG, X+          ; 24
    ld PULSECNT, X+     ; 26
    rcall SUB_TONE28    ; 28
    cpi XL, LOW(ADDR(SOUND6_TBL_END)) ; 12
    breq SOUND6_LOC1    ; 13
    WAIT 8              ; 14
    rjmp SOUND6_LP2     ; 22
SOUND6_LOC1:

    ; ボーン
    ldi LP1, 32         ; 15
    WAIT 4              ; 16
SOUND6_LP3:
    ldi ARG, NW(254)    ; 20
    ldi PULSECNT, 32    ; 21
    rcall SUB_NOISE22   ; 22
    dec LP1             ; 17
    brne SOUND6_LP3     ; 18

    ; ボタンが離されてなかったら繰り返し
    sbis EIFR, INTF0    ; 19
    rjmp SOUND6_LP1     ; 20

    WAIT_R SGOUT - 21   ; 21

共通処理

各サウンドはテーブルが違うだけで処理は一緒のことは多く、そういったものは sound.asmTBLSOUND で処理できるようにしています。こんな感じで使います。

sound_3.asm
SOUND3:
    ldi XL, LOW(ADDR(SOUND3_TBL))
    ldi XH, HIGH(ADDR(SOUND3_TBL))
    rjmp TBLSOUND   ; 👈 X にテーブルアドレスを設定して呼び出しておしまい

SOUND3_TBL:
    .db 2, LOW(ADDR(SOUND3_TBL_END)) ; 👈繰り返し回数 2 と、テーブルの終了アドレス(下位バイトのみ)
    .db TD(71, 1277), TD(77, 1277), TD(83, 1277), TD(89, 1277), TD(95, 1277), TD(101, 1277), TD(107, 1277), TD(113, 1277)
    .db TD(113, 1277), TD(107, 1277), TD(101, 1277), TD(95, 1277), TD(89, 1277), TD(83, 1277), TD(77, 1277), TD(71, 1277)
SOUND3_TBL_END:

ノイズと組み合わせるなどでシーケンスの一部だけテーブル処理したい場合は、TBLSOUND_ONESHOT_MUTETBLSOUND_ONESHOT_NOMUTE と言ったエントリーもあります。TBLSOUND_ONESHOT_xxx は繰り返し数などを持たないテーブルアドレスを X に、テーブルの終了アドレスの下位バイトを TBLEND に設定して rcall すればテーブルを 1度だけ鳴らして戻ってくるので、自分のシーケンスを続けることができます。MUTE であれば出力ピンを OFFに、NOMUTE なら出力ピンはそのままにして戻ります。音の間の無音の時間を長く取る場合、出力が動かない直流になってスピーカーにたくさん電流が流れちゃうので MUTE します。

💡 本記事で解説した SOUND #6 の実装も、ソースコード上ではテーブル処理を利用しています。やっぱり小さな ROM ですから、使いまわせるところは使いまわさないとあっという間に ROM 不足になります。

驚きの重低音サウンド!

音程を表すパルス幅データは値が大きいほど 低い音になりますが、8bit なので限界があります。全体的に音程の低いサウンドはパルス幅が大きくなりますので定義できなくなりますが、マイコンの動作クロックを落とす秘策があります。main.asmSUB_LOWCLOCK を呼び出すだけでシステムクロックが 1/4 になり、音程も 2オクターブ下がりますので、思う存分重低音を鳴らしてお楽しみください。sound_X1.asm などでやっています。

💡 シーケンスが終わるとシステムクロックは元に戻るので、処理内で戻す必要はありません。

謎の追加クロック

ちょっと不可解な現象がありまして。例えば rcall は大抵 4クロックなのですが、なぜか 5クロックかかることがあります。他にも、所によって 1クロック多くかかってしまう命令があります。過去のコードではありますが、実際のコードを引用して見てみます。

SOUNDX1_LP2:
    ld ARG, X+          ; 13 👈通常 2クロック
    ld PULSECNT, X+     ; 15 👈通常 2クロック
    rcall SUB_TONE17    ; 17 👈通常 4クロック
    cpi XL, LOW(ADDR(SOUNDX1_TBL_END))
    brne SOUNDX1_LP2

    WAIT SGOUT - 12
    cbi PORTB, PNO_SOUND
    WAIT 8245 * 4 + 13

SOUNDX1_LP3:
    ld PULSECNT, -X     ; 13 👈これは 2クロック
    ld ARG, -X          ; 15 ❓何故か 3クロック
    rcall SUB_TONE19    ; 18 ❓何故か 5クロック
    cpi XL, LOW(ADDR(SOUNDX1_TBL))
    brne SOUNDX1_LP3

同じような構成のループが 2つ並んでいます。SOUNDX1_LP2SOUNDX1_LP3 です。Xレジスタの増加方向が逆順という違いがありますが、やってることは同じです。でも SOUNDX1_LP3 の方では 1クロック余計にかかっている箇所があります。単純に X+-X という違いではない何かの条件で追加クロックが発生し、またこの場所では必ず追加されるので、たまたまでもない何かがあるのです。

なぜここに追加クロックが発生するのか、この謎はまだ解けていないので、シミュレータで動作確認するしかない状況です。常に 1クロック増える状態なので音が揺れたりはせず実際気付かないと思いますが、クロック数をきっちり合わせたい時には注意しなければならない現象です。ここでは追加クロックも数えて丁度合うようにしています。

大きな声じゃ言えませんが

この記事を書いていて見つけたのですが、クロックを数え間違っているところを見つけました。

見るな
sound.asm
TBLSOUND:
    ld TBLLP, X+        ; 21
    ld TBLEND, X+       ; 23
    sts SOUNDTBL_L, XL  ; 25

TBLSOUND_LP0:
    mov LP1, TBLLP      ; 26 👈ループする時にクロックが合わない!
TBLSOUND_LP1:
    lds XL, SOUNDTBL_L  ; 27
    rjmp TBLSOUND_LP2   ; 28

    :

    ; ボタンが離されてなかったら繰り返し
TBLSOUND_NEXT0:
    sbis EIFR, INTF0    ; 18
    rjmp TBLSOUND_LP0   ; 19 👋ここからループするのに!

    WAIT_R SGOUT - 20   ; 20

TBLSOUND でサウンドを1周鳴らした後の繰り返し処理で 5クロック少なくなってます。何かの機会に直します。

まとめ

以上をまとめると、クロック数を合わせる手順はこんな感じになります。

STEP1 とりあえず組む
まずはクロックは気にせず仮組みをします。

STEP2 クロックを追う
クロック数が明確になっているところを起点に、命令を辿りながら各命令のクロックを求めます。

STEP3 タイミングを合わせる
ある地点で必要なクロック数になっていなかったら、WAIT を入れて調整します。

STEP4 矛盾の解決
ループに最初に入った場合とループしてきた場合とでクロック数の違いがあれば、クロック数の多い方に合わせるようにクロック数の少ない経路に WAIT を入れて調整します。必要に応じて分岐条件を変え、WAIT を挿入する位置を作り出します。

Sounds good!

といった感じで、できましたよ!
オリジナルとそん色の無いサウンドの再現によって、かの有名おもしろメロディIC喪失の不安と悲しみは去りました。おめでとう。ありがとう。

もちろんできないはずはなかったのです。オリジナルIC のクロックは 128kHz なのに ATtiny10 は 1.8V でも 4MHz を叩き出す高性能マイコンですから。サウンドに特化したハードウェアでなくとも、力技でどうにでもなったでしょう。しかし ATtiny10 にまで、巷に蔓延した莫大なCPUパワーにまかせたゴリ押し処理をさせたいでしょうか。いやそんなことは誰もさせたくないはず。つまり先行きの見えない低クロックの迷路を進むのは必然だったのです。

サウンドクロックとしては元の 128kHz に近い 125kHz を選び、システムクロックはその 4倍の 500kHz としました。なぜ 4MHz でもなく 1MHz でもなく 500kHz か。それは 250kHz でやろうとして挫折したからです。言わせんな恥ずかしい。

さて、一緒にクロックを数えた気分はいかがでしたか?正直、無駄に長い記事になってしまったと思っています。読んでくれた人も少ないでしょう。でも、いつか是非ご自身でオリジナルの効果音の実装に挑戦してみてください。公開ソースでは ROM は 1024バイトいっぱい、ただの 1バイトも残っていないのですが、要らないサウンドと差し替えればよいですし、ボタンだってまだ増やせます。その時この記事が、沼の中での道しるべになれば幸いです。

といったところで、またそのうち。


≪ 前の記事: (3)おたのしみ編

このシリーズ

🧩 【AVR ATtiny10】Tinyバトルサウンド / 全4回

関連記事

🧩 【AVR ATtiny10】ピンが無ければ ADC を使えばいいじゃない / 全3回

🧩 Hello ATtiny10!

🧭 コンテンツガイド

Discussion