PIC AVR 工作室->TopPage->AVRの工作2->似非PSG

似非PSG

TINY2313をPSG音源チップにしてみました。

こんな音が鳴らせます。 オリジナル同様3和音で鳴り、ノイズも出せます。 (firefox上で再生すると音が途切れるようなので、一旦mp3ファイルを保存してから再生してみてください)

似非PSGについて

PC-6001やMSXなど、80年代のパソコンに搭載されていた「PSG」や「SSG」などと呼ばれる音源機能をTINY2313ワンチップで実現。 いわゆるファミコン的なピコピコ音楽(矩形波)を鳴らす音源チップです。

PWMでアナログ音声出力。かつてのPSG同様に音程3CH+ノイズ3CH、音量/エンベロープの制御を実現。 TINY2313ワンチップに収めたので、あとは電源、20Mhzクロック、リセットのプルアップ用抵抗、USARTの入力信号だけ繋げれば、 PWMで音声が出力できます。

似非PSG(Pseud PSG)でPPSG。安直な名前です。

ただし処理速度の都合で、周波数指定値はオリジナルよりちょっと低音側になってたり、I/OがUSART経由だったりしますが、 機能的にはかつてのPSGとほぼ同等です。

色々なマイコンとUSARTで繋いで、PSG同様にレジスタに値を書くだけで自由に音が出せます。

PSGとSSGについて

PSGはProgrammable Sound Generatoの略で、GI社のAY-3-8910やその同等品を指します。 SSGはSoftware-Controlled Sound Generatorの略で、ヤマハのFM音源チップなどに搭載された同等機能です。

A,B,C,3つのチャンネル別々に音程を設定でき、またそれぞれのチャンネルにはノイズを出力することが出来ます (ただしノイズ生成ユニットは1個を3チャンネルで共用しているのですべてのチャンネルに同じ波形が適用され、 チャンネル別に設定できるのはボリウムやエンベロープだけ)。

音量は各チャンネルとも0~15の16段階から指定できますが、 エンベロープを掛けた場合はそのエンベロープ周波数および指定した波形形状(ノコギリ波や三角波)に合わせ、 0~15の間で自動的に音量が上下されます。 (エンベロープ生成ユニットも1個しか搭載されていないので、周波数や波形がすべてのチャンネルで共用される)。

なお、エンベロープの音量は、オリジナルのPSGでは0~15の16段階ですが、SSGは内部では0~31の32段階で処理され、 音量の変化が滑らかになっています。今回はSSGに倣って、滑らかな32段階としてみました。

PSGなどのチップは、CPUから見ると8ビットバスのSRAMの様なアクセスをするようなI/Fをしていて、 このSRAM的なメモリ(レジスタ)に値を書き込むことで、あとはチップ内で音程やエンベロープ、ノイズなどを自動的に生成して出力します。 (まぁ、レジスタの番号を一旦ラッチしておくなど、アクセス方法はSRAMとはちょっと異なりますが)

SRAM的にアクセスできるということは、当然そのくらいの速度でアクセスできるようになっているので、 いくらAVRが速いといっても、ピンチェンジ割込み起動などのソフトウェア処理ではオリジナル同様の速度を実現するのは無理がありました。 なので、マイコンとのI/Fはアレンジが必要になりました。

なお、PC-6001などではレジスタ14、レジスタ15を使ってATARI互換のジョイスティックI/Fが搭載されていましたが、 マイコンでは敢えてこの機能を使う意味はないと思うので、ばっさり切り捨てました。

作戦

必要となる機能はオリジナルとほぼ同じなので、オリジナルと異なる部分(制約などで実現できない部分)などについて整理。

信号入力

オリジナルは8ビットパラレル+数本の制御信号でSRAMなどの様にアクセスすることができますが、 通信速度が速すぎてAVRのピンチェンジ割込みでは追いつかないのと、使用する信号線の数が多くTINY2313ではピン配置が難しいので、 別の手段を考えます。

I2C、SPI、USARTのようなシリアルI/Fがラクチンだろうと思いますが、後述するタイマー割込み中で行う音声合成処理にかなり時間を取られるので、 I2CやSPIでは通信速度がちょっと速すぎて取り込みが追いつかない恐れがあります。

よって、通信速度をあらかじめ絞ってしまえるUSARTを使います。「レジスタ番号」「データ」の2個セットを1組として受け取ります。

アナログ出力

PSGの音声は、tone出力が3ch、noise出力が3chで合計最大6chの音声を合成して出力することになります。 たとえ矩形波といえども複数の波形にボリウムやエンベロープを加味して合成出力するので、アナログ出力が必要になります。

アナログ信号を出力する方法としては、外部にDACのICを付ける方法、AVRに搭載しているPWMを使う方法、 複数のI/Oピンとラダー抵抗で組むDACなど考えられると思いますが、外付け部品を出来るだけ使わずチープに済ませ、 かつ音声合成を行った際に充分な分解能と周波数域を実現できるモノを探ります。

後述するように、6chの音声について15段階のボリウムを付けて音声合成出力するには、10~11ビット幅のアナログ出力が要りそうです。

MCP4921/MCP4922のようなSPI接続のDACなどを使うと速度的には充分速いので出力できる音声の周波数帯についても無理はないでしょう。 分解能も12ビットとナカナカです。ただし値が張ります。

内蔵タイマーのPWMの場合、TINY22313では8ビット幅のタイマー0、16ビット幅のタイマー1がありますが、 使用するピンアサインとの兼ね合いになってきます。信号入力にシリアルI/Fを選んだので、今回はピン配置にあまり制約がありません。 タイマー1で10ビット出力とすると、16Mhzのプリスケーラ無しで15625sps(7,812.5Hz)まで、20Mhzで19531.25sps(9,765.625Hz)まで出せます。

コレでは可聴域をカバーできないんですが、所詮矩形波だし、音楽として音符を出力するならそもそも20000hzあたりまで出力する必要は無いでしょう。 一方8ビット幅とするとダイナミックレンジが狭くなり、6chアナログ音声をカバーできなくなります。

本当は、ノイズ出力の周波数域など色々考えるともう少し高い周波数まで出せるといいなと思うんですが、TINY2313では無理で、 TINY861などの内蔵8MhzのPLL×8倍=64Mhzで使えるPWMが魅力的になってきます。しかしUSARTが内蔵されておらずUSIになってしまいます。 TINY861のUSIではダブルバッファが搭載されていないので、入力処理のタイミングがシビアになってしまい、 二重割込みなど行う必要がありそう。処理的に何かと面倒です。

また、ラダー抵抗でのDACもがんばって8ビット幅がせいぜいなので、これもダイナミックレンジが足りません。

とりあえずTINY2313内蔵のタイマー1で10ビット幅出力ということにします。

音声合成のダイナミックレンジ

waveテーブルは使いませんが、waveテーブルの換わりに矩形波(やノイズ)の山と谷を計算で算出し、それをDDS同様に出力します。

矩形波が3ch、ノイズが3chの合計6ch分合成して出力しますが、それぞれのchについてボリウムが0から15までの範囲で設定されます。 ボリウム15というのはボリウム1の15倍ということではなく、ボリウム値に対して出力量は指数関数的に増加します。

YMZ294のデータシートによると、ボリウム値が1変化するごとに出力は2の平方根(1.414…)倍の割合で増減します。 2の平方根の15乗は約180となり、それが6chあるので、最大で0~1084の振幅が取れれば足ります。 コレを表現するには2進で11ビット幅となりますが、数字をちょっとごまかし1023までのレンジにはめ込めば10ビットで表現できます。

うまい具合にクリッピングすればもっと圧縮できるかもしれませんが、いい方法が思いつかなかったので、 とりあえず数字をごまかして10ビット幅で出力する方向にします。

音声合成処理

DDSと同様に一定周期でアナログ値を出力することでアナログ音声を出力させます。

一定周期を作り出すのは当然タイマー割込みを使います。PWM出力で10ビット出力ができるタイマー1を使うので、 余ったタイマー0を使います。

PWMによる音声出力の帯域が10Khz程度までなのであまりがんばっても仕方ないといえば仕方ないかもしれませんが、 色々と考えた結果、オリジナルに近いスペックを実現するため、また音程のズレを小さくするために、 毎秒4万回以上の割込みを発生させて、都度音声合成処理を行うことを目指します。 出来るだけ速い処理(短い割り込み間隔)を実現できるようにがんばります。

このような処理では高音側で特に誤差が大きくなっていきますが、割り込み間隔が短ければ短いほど(速いほど)音程が正確に表現できます。

ちなみにMSXやPC-6001などでは毎秒22万回以上(最高周波数11万Hz以上)で音声合成を行っている計算になります。 オリジナルと同じスペックに仕上げるとすると、割込み間隔を90クロック程度(@20Mhz)以下にする必要がありますが、 いざプログラムを書いていったところそこまで短くは出来ませんでした。なので、出せる音域や周波数計算式などはオリジナルと異なります。

ノイズ生成

PSGのノイズは、単なるホワイトノイズではなく「平均周波数」を上下することが出来るようになっています。

その昔、初めてPSGを弄ってみた時に「平均周波数」ってなんじゃらほい?とか色々思ったんですが、 Arduinoを使ってノイズ生成の実験を通して、 どうやったらノイズの平均周波数を上げたり下げたり出来るのかが実験でなんとなく解りました。

基本ロジックはこのまま流用し、擬似乱数の周期を規定する変数の幅は16ビットとして、 取り出す2ビットの位置については消費クロック数とランダム具合を見ながら適当に調整することにします。 (ちなみに実験で8ビット幅を試してみましたが、周期が短すぎて一定の「音色」として聞こえてしまうので、8ビット幅は不可でした)

この方式では不定周期にオンとオフを切り替える(=オン/オフ)処理を行っているわけですが当然ながら、単にオンオフをするだけじゃなく、 PSGでは音量を指定したりエンベロープを掛けたりすることが出来るようになっているので、 発生した乱数値にボリウム(またはエンベロープ)を掛け算してノイズの音声信号とします。

tone出力とnoise出力を同じPWM出力端子から出力することにしましたが、10ビットPWMでは20Mhzでも出力周波数が10Khz程度になってしまうので、 toneとnoiseを別々のchから出力させてそれぞれ9ビットPWMとする手があるかと思います(出力後にオペアンプなどで加算)。 外付け部品は多くなりますが、ノイズの出方を考えるとその方がいいかもしれません。

その他

ボリウムやエンベロープは、入力値に対して指数関数的な出力値を取ります。 当然指数計算をアセンブラでロジック書くのは非現実的なので、定数テーブルを設けておいてテーブルサーチとします。

エンベロープも同様で、16個のエンベロープパターンについて予め算出した指数の値を用いてテーブルを作っておきます。

(なお、暫定版を作ってエンベロープの周期を遅く(数秒周期)して音声出力してみたところ、 16段階の音量では再生時に明確な階調が付いてしまうことが判ったので、テーブルを作り直して32段階としました)

機能ブロック

以上のことを元に、各機能の関係ををブロック図に書き出して見ます。

全体の構成

橙色が入力処理、緑が出力処理、青が音声合成計算です。水色は、マイコンなど外部から値を書き込んで制御するための「レジスタ」で、 オリジナル同様0~15の計16個あります。TINY2313のSRAM上に確保します。

音声合成計算の処理はタイマー割込み処理内で行い、USART入力は通常処理内で行います。 出力処理はハードウェアタイマーモジュールで自動制御です。

入力処理

USARTから「レジスタ番号」「データ」の2バイト1組として受信して、SRAM上の「レジスタ」に格納します。

音楽を鳴らす上で遅延が問題にならない程度の通信速度を確保し、 一方で比較的長めになるタイマー割込み処理(後述)を加味してもハードウェアUSARTのダブルバッファがオーバーランしない範囲で、 出来るだけ速めに設定します。

20Mhzのセラロックで38400bpsで動かしてみて安定して動くことを確認しましたが、115200bpsにしたらデータ化けするようなので、 とりあえず38400bpsとしました。MIDI信号より少し速い程度なので、これでも速度的な問題は生じないだろうと思います。 クリスタルを使用すれば115200bpsも可能だろうと思います。(割り込み処理を加味しても計算上は大丈夫な様に作ってあります)

各レジスタ(水色部分)は8ビット幅ですが、レジスタによっては未使用ビットがあるので、 USARTから受信したデータをレジスタ(SRAM上)に格納する前に「データクリーニング」を行います。具体的には、 各レジスタ毎に有効なビット位置をテーブルに持っておいて、データ受信時にテーブルの値と比較して無効なビット部分にマスクを掛けます。 (例えばレジスタ13番=エンベロープマスクは0~15の16通りだけ有効なので、入力値の上位4ビットに値が入っていても0に書き換えてから格納)

各チャンネルのボリウム値(もしくはエンベロープ指定)はレジスタ(SRAM上)にそのまま格納してしまうと、 割込み処理内で毎回毎回テーブル参照することになり処理時間を食うので、格納する前に入力値→出力用のボリウム値に変換しておきます。 美しくないのですが、少しでも割り込み処理内を軽くしようと画策した結果です。

またこのようにレジスタ番号によって個別処理を行うケースがあるので、レジスタ番号を元に振り分け処理(C言語でいうswitch~case文に相等) を行っています。上記のボリウム値のテーブル検索や、エンベロープ形状指定を行った際のエンベロープパターン巻き戻しなどが該当します。 (本当は、カウンタ値の初期化など色々組み込んでいたんですが、色々調べたら不用だと判って後から削りました)

実行ステップ数を削減する為にこの振り分け処理ではジャンプ先アドレスをテーブル化しておいて、 そのアドレスをスタックに積んでからret命令でジャンプするということをしています。 プログラムの見通しが悪くなるので好きではないのですが、アセンブラの場合switch~case文に相当する命令はないので、 処理速度などの観点も加味してこれで良しとしました。

音声合成計算処理

SRAM上の「レジスタ」に格納された設定値を元に音程やノイズを計算し、それをボリウム値もしくはエンベロープに従った音量に変換し、 ミキサーにオンが設定されているチャンネルだけ合成してPWMのコンペアレジスタに設定します。

タイマー割込みが発生する都度、音程を制御する各chのカウンタ値を1加算し、SRAM上のレジスタに格納されている設定値と比較します。 設定値に達していたらカウンタをゼロクリアし、同時にそのchの出力レベル(オン/オフ)を切り替えます。 このオン/オフ切り替えによって各chは矩形波となり、またレジスタ設定値によって音程の高低が制御されます。

ノイズについても同様で、タイマー割込みでカウンタ値を1加算し、レジスタの設定値と比較します。 設定値に達したら乱数を生成してノイズの信号レベル(オン/オフ)に設定します。

エンベロープもタイマー割込み発生の都度カウンタを1加算しますが、設定値に達していたらエンベロープのどこを指しているかを表す「フェーズ」 を1だけ進めます。フェーズは0~63の64フェーズありますが、エンベロープ形状によっては64まで達したらアタマに戻し、 繰り返しをしないエンベロープ形状の場合は64を繰り返します。

mixerというレジスタで、各chの音程やノイズの出すか出さないかを指定します。0で出力、1で非出力。 mixerに0が指定されているch(tone、noise)は、各ch毎に設定された音量で音が出ます。音量は、ボリウム指定なら0~15(下位4ビット)で指定します。 5ビット目をオンにすると下位4ビットになにを指定していてもエンベロープが有効になります。

出力処理

上記の音声合成計算処理にてPWMのコンペアレジスタに設定された値を元に、TINY2313のタイマー1モジュールが自動で出力します。

タイマー1のPWMは10ビット幅で設定してあり、20MhzのTINY2313では最速でおよそ20000回/秒でトグルするので、 出力できる最大周波数はおよそ10000Hzまで。タイマー0のタイマー割込みは結果的に80000回/秒で発生するので、 割り込み処理内でPWMのコンペアレジスタに書き込まれる回数も80000回/秒、つまり40000Hzまで再生できるはずのモノ。

そう。PWMのコンペアレジスタに音量の設定値が書き込みされる頻度の方が、タイマー1の1波形分周期より短いわけです。 本当はどう見てもおかしいんですが、実際はそんな周波数域の音は人間の耳には聞こえないし、 矩形波ならコンペアレジスタの値が変化するのはもっと低い周波数(長い周期)となります。 だから可聴域で矩形波を演奏する範囲では基本的に問題は無し。

じゃぁ問題がホントにないかというと、ノイズの方が問題。ノイズ出力用のオン/オフ信号が10000Hzより速く切り替わらないので、 「ブラウンノイズ(っぽい音)→ピンクノイズ(っぽい音)→ホワイトノイズ」 という具合にノイズの平均周波数が想定どおりに高くなっていかない恐れがあります。

で、実際に鳴らしてみたら、なぜかそれなりにノイズの高さが変化付いたので、とりあえずよしにしました。 上記の「作戦」の「ノイズ生成」のところでも書きましたが、とりあえずそれっぽく動くことが判ったのでこのままにします。

もうちょっとナントカするのであれば、tone出力用に9ビットPWMを一つ、noise出力用に9ビットPWMを一つ、合計2つのPWM出力を行い (これら2つ併せてタイマー1だけでok)、I/Oピンから出力後にオペアンプなどで加算処理して、LPFでも掛けるとよいかと思います。 (そうすれば20000Hzまではナントカなる)

プログラムと動作回路例

プログラム

処理速度が重要なのでアセンブラで書いてあります。

TINY2313に20Mhzの外部発振を繋いで動かします。セラロックでも動きましたが、クリスタルの方が通信速度の点で安心でしょう。

音声合成の処理(割り込み処理)は最大で200クロック少々。平均的には170~180クロック程度みたいです。 タイマー0のCTC割込みは250クロックに設定しました。通常の処理内でUSARTの受信処理を行っていますが、38400bpsで連続データを受信すると仮定して、 ダブルバッファがあふれないためにはこの割込みから割り込みの間に数十クロックを処理できる余裕が必要となり、それを加味して250クロックとしました。

割り込み処理の先頭では、前回の割り込み処理で計算した出力値をタイマー1のアウトプットコンペアレジスタに設定します。 割り込み処理は条件によって処理時間が伸縮するため、計算した直後に設定してしまうとジッタが生じてしまいます。 なので、計算だけ行ったら一旦メインに戻っておいて、次に割込みが発生した直後に出力しています。

まぁそもそもタイマ割込みの周期とPWMの周期が一致していないので、それに起因するうねりが発生していたりするはずなので、 あまりこだわっても仕方ないといえば仕方ないですが、一応足掻いてみました。

割り込み処理内のクロック数を減らす為に色々無理をしていて、特にレジスタの使い回しがひどい状態です。 数限られたレジスタを使いまわししていて、元々局所的に使用するはずで宣言しているレジスタを、 だいぶ離れたところで参照していたりして解りにくくなっています。

ロジック自体は構造化してあるんだけど、変数が構造化されてないので、メンテナビリティーは低くなってます。 一応、各レジスタの使い方とか参照/被参照についてコメント文で入れてあるんですが、所詮解りにくいです。あしからず。

テスト回路

この似非PSGに音のデータを送信するメインマイコンにArduinoを使ってみました。(クリックすると別窓で原寸表示)

TINY2313のPB3が音声出力のピンで、この回路(の右上)ではCRによる簡単なLPFを組んであります。 カットオフ周波数は16Khzですが、1次フィルターなのでこのくらいでよいのでは?と。

ただ、PB3はリクツ上は5Vppまで出力の振幅が振れ、このままオーディオ機器に繋いでしまうと色々やばそうなので、 右下にアッテネータ(といっても単なる可変抵抗)とアクティブLPFを組んだものを作ってみました。 冒頭のmp3はこの回路を使ってPCと接続して録音しました。オペアンプにはNJM4580を5V単電源で使いましたが、基本的には何でもいいです。 NJM4558系列がヨサゲです。ただLM358のような汎用アンプやLMC662のようなR2Rアンプでも鳴るでしょうが、ノイズの点からあまりお勧めできせん。

イヤホンなどで聞くだけなら、抵抗をシリーズに繋いだだけの回路(真ん中の回路)で充分でしょう。

オペアンプのLPFを使った回路をブレッドボードに組んでみたのがコレ。

左のブレッドボードが似非PSG周り、右がオペアンプのLPF、下が秋月互換Arduinoボード。オペアンプの出力をイヤホンに繋いで聞いてみたところ。 Arduinoとは電源線、リセット用信号(D7出力)、シリアル出力だけ繋がってます。D7にlowを出力すると似非PSGがシステムリセットされます。

Arduino用のサンプルスケッチ

Arduinoと似非PSGを繋いで鳴らすサンプルスケッチです。冒頭のmp3の元ネタです。さっきの回路図で鳴らします。

const int ledPin =  13;
const int psg_reset = 7;

const int freqs[] = {262,277,294,311,330,349,370,392,415,440,466,494,
                     523,554,587,622,659,698,740,784,830,880,932,988};

const int _do1 = 0;
const int _re1 = 2;
const int _mi1 = 4;
const int _fa1 = 5;
const int _sol1 = 7;
const int _la1 = 9;
const int _si1 = 11;
const int _do2 = 12;
const int _re2 = 14;
const int _mi2 = 16;
const int _fa2 = 17;
const int _sol2 = 19;
const int _la2 = 21;
const int _si2 = 23;
const int _rest = 255;

const int _h_note = 640;
const int _q_note = 320;
const int _8th_note = 160;


const int notes[] = {_re1,_8th_note,
                    _mi1,_8th_note,
                    _sol1,_8th_note,
                    _si1,_8th_note,
                    _la1,_8th_note,
                    _sol1,_8th_note,
                    _si1,_h_note,
                    _rest,_q_note,
                    
                    _re1,_8th_note,
                    _re2,_8th_note,
                    _si1,_8th_note,
                    _si1,_8th_note,
                    _la1,_8th_note,
                    _sol1,_8th_note,
                    _si1,_h_note,
                    _rest,_q_note,};


void psg_sound(int registor,int out_data){
  
    Serial.write((uint8_t)registor);
    Serial.write((uint8_t)out_data);
}


void note(int note1, int length){

  if (note1 != _rest){
    psg_sound(7,0b00111110);          //note on
    psg_sound(0,(40000 / freqs[note1]) & 0xff);    //lower 8bit of freq-data-A
    psg_sound(1,(40000 / freqs[note1]) >> 8);    //higher 8bit of freq-data-A
    delay(length / 2);
  } else {
    psg_sound(7,0b00111111);          //note off
    delay(length / 2);
  }

  psg_sound(7,0b00111111);          //note off
  delay(length - length / 2);
}


void no_note(){
  psg_sound(7,0b00111111);          //note off
}


void noise(unsigned int freq,int length){

  psg_sound(7,0b00110111);          //noise on
  psg_sound(6,(40000 / freq) & 0x1f);  //noise freq
  delay(length / 2);

  psg_sound(7,0b00111111);          //noise off
}


void setup()   {                

  pinMode(ledPin, OUTPUT);   
  pinMode(psg_reset, OUTPUT);
  digitalWrite(psg_reset,LOW);
  delay(100);
  pinMode(psg_reset, INPUT);
  delay(1000);

  Serial.begin(38400);
  
  psg_sound(8,10);  // set ch-A-volume as 10 //
  
  psg_sound(7,0b00111110);  // set ch-A tone on //
}



void loop()                     
{
  int i;
  
  for (i = 0; i < 32; i=i+2) {
    note(notes[i],notes[i+1]);
  }
  no_note();
  
  noise(1290,2000);      //output noise (lowest freq)
  
  delay(3000);
}

サンプルスケッチの簡単な解説とレジスタ設定

各レジスタへの書き込み方法

psg_sound関数で各レジスタへの書き込みを行っています。MSXやPC-6001などのsound文と同じものと思ってください。 関数の中身は、単に「レジスタ番号」と「データ」の2つのデータを連続してシリアル出力しているだけです。

レジスタ番号は0~15を指定。データは各レジスタに格納するためのデータです。 レジスタ14と15は書き込むことはできますが、ジョイスティック周りの処理は省いているのでまったく参照されません。(書いても無駄)

psg_sound関数はこのようにレジスタにデータを書き込むだけの処理しかしないので、toneやnoise、envelopeの各周波数は、 別途計算を行ったうえでレジスタへの書き込み内容を決定する必要があります。

なお、この関数の出力先だけをパラレルI/Oに変更すれば、旧来からのPSG/SSG音源チップ(YMZ294など)も鳴らせるスケッチになっています。 (リセット信号は当然音源チップのリセット端子に繋いでおいて、Vccに抵抗でプルアップする必要があるのは同様です)

全レジスタの一覧を挙げます。(オリジナルと同じ構成です)

toneの周波数設定

このサンプルスケッチでは、音程はnote関数で計算しています。周波数値を元にレジスタへ書き込む値に換算しています。

MSXでは1.7897725MHzを16分周した111,860.78125Hzがベースクロックになりますが、 今回作った似非PSGでは80000Hzを2分周した40000Hzがベースクロックになります。 MSXなどと比べて、2~3倍周期が長くなります。つまり同じ数値をレジスタに入れるとおよそ1オクターブ半くらい音程が下がる計算になります。

ベースクロック(=40000Hz)を周波数で割った数値をレジスタに書き込むと、その周波数の音が出力されます。 なのでこのスケッチ中でも40000を希望周波数で割った値を出力するという処理になっています。 ただし、上位4ビットと下位8ビットを分割して、それぞれを該当チャンネルのレジスタに設定する必要があります。 (このスケッチではチャンネルAの周波数について上位4ビットがレジスタ1、下位8ビットがレジスタ0)

計算しなくても、以下の表の数値を(上位4ビットと下位8ビットに分けて)レジスタに書き込めばもっと簡単に各音程が出力できます。 (オクターブ4が中央)

1 2 3 4 5 6 7 8
C 1223 612 306 153 76 38 19 10
C# 1154 577 289 144 72 36 18 9
D 1090 545 272 136 68 34 17 9
D# 1029 514 257 129 64 32 16 8
E 971 485 243 121 61 30 15 8
F 916 458 229 115 57 29 14 7
F# 865 432 216 108 54 27 14 7
G 816 408 204 102 51 26 13 6
G# 771 385 193 96 48 24 12 6
A 727 364 182 91 45 23 11 6
A# 686 343 172 86 43 21 11 5
B 648 324 162 81 40 20 10 5

例えば、中央のオクターブ4の「ラ」の音は数値「91」をレジスタに書き込めば鳴らすことが出来ます。 (レジスタ0に91を、レジスタ1に0を入れる)

まぁ、ごらんの様に高い周波数域になると周波数分解能が低く、3オクターブ上(オクターブ7)まで行くと半音が分解できないところが出てきます。 オリジナルに比べると、高音側が弱いということになります。まぁ、マイコン1個でまねするわけだから処理能力的に仕方ないところ。

せいぜい2オクターブ上までが音楽として使える限界でしょうか。

noiseの周波数設定

PSGで出力するノイズはホワイトノイズやピンクノイズのように周波数に幅がある音で特定の周波数を持たないのですが、 平均周波数という呼び方でズウォーという「野太いノイズ」なのか、シャーーーという「甲高いノイズ」なのか調整出来るようになっています。 (詳しくはArduinoで行った実験のページをご参照)

今回作った似非PSGも仕組みは同様ですが、基準となる周波数が低くなっているので、このノイズ出力周波数も低めになっています。

具体的には1290Hz~40000Hzの間で指定することが出来ます。どんな音が出るのかは、鳴らしながら調整するのが現実的でしょう。

(なお周波数とは言っても、実際はランダムでオンとオフを切り替えてノイズ的な音を出しており、 その最小切り替え時間を設定するという仕組みになっているので、いわゆる「周波数」とは異なります)

また、比較的広い周波数が設定できるように見えますが、この周波数範囲を5ビットでカバーしているので、 1Hzや2Hz程度変化させても音は変化しません(丸め誤差に入ってしまう)。指数関数的に32段階でこれらの周波数をカバーしているので、 鳴らしながら設定値を弄っていくのがやはり現実的でしょう。

平均周波数からレジスタ設定値への変換は、toneと同様に基準周波数40000Hzを平均周波数で割った値が設定値になります。 (後ほどノイズ出力のサンプルスケッチも挙げます)

ミキサーとボリウムの設定

ミキサーは、0,1,2の3つのビットがそれぞれtoneのA,B,Cチャンネルの出力有無を、 3,4,5の3つのビットがnoiseのA,B,Cチャンネルの出力有無を表しています。各ビットを1にすると無音に、0にすると音が出ます。

例えば、Aチャンネルのtoneとnoise(それぞれビット0とビット3)の両ビットを0にすると、音程とノイズの両方を同時に出力することもできます。

ボリウムは0~15の16段階で指定できます。0は無音です。オリジナルと同様に数値の大きさに対して指数関数的に出力電圧の振幅は大きくなります。

ボリウム指定はこのように4ビット値で指定することになっていますが、5ビット目をオン(=1)にするとボリウム値は無視されて、 エンベロープが有効になります。

エンベロープの設定

エンベロープをオンにすると、指定したエンベロープ形状とエンベロープ周波数で自動的に音量を上げ下げします。

指定できるエンベロープ形状は以下の16通りですが、一部は重複した形状になっています(オリジナルと同じ)。

ベースとなる周波数は80000Hzを32分周した2500Hzを基準として、2500Hzを周波数指定レジスタの値で割った周波数で上下します。 周波数というのはこの表の一番下にある横矢印(「env freq」と書かれている矢印の長さ)の1個分が1秒間に何回かを表します。

例えば、1秒間で1回だとすればレジスタには2500を、1秒間で2回だとすれば1250を指定すれば実現できます。

エンベロープ周波数は、2500Hz~0.03815Hzで指定できます(それぞれレジスタに1~65535を指定した場合)。 といっても、2500Hzでエンベロープというのは現実的ではなくて、実際は10Hz以下くらいで使用するのが一般的でしょう。 0.03815Hzはおよそ26秒/1波という周期です。

以下のサンプルスケッチでその計算式を眺めると解り易いでしょう。

ノイズとエンベロープのサンプルスケッチ

ノイズやエンベロープを使って出力するサンプルスケッチを挙げます。

エンベロープ形状=8(ノコギリ波)を使って毎秒1回の周期(1Hz)のエンベロープを掛けて、 もっとも低い平均周波数(1290Hz)のノイズを出力するスケッチです。

const int ledPin =  13;
const int psg_reset = 7;

const int base_freq = 80000 / 2;
const float base_noise = 80000.0 / 32.0;

void psg_sound(int registor,int out_data){
  
    Serial.write((uint8_t)registor);
    Serial.write((uint8_t)out_data);
}


void set_envelope(float env_freq,int env_shape){

  int reg_value;
  
  reg_value = (int)(base_noise / env_freq);
  
  psg_sound(11,reg_value & 0xff);  //noise freq low
  psg_sound(12,reg_value >> 8);  //noise freq high
  
  psg_sound(13,env_shape);  //noise freq high
}


void noise(unsigned int freq){

  psg_sound(7,0b00110111);          //noise on
  psg_sound(6,(base_freq / freq) & 0x1f);  //noise freq
}


void setup()   {                

  pinMode(psg_reset, OUTPUT);
  digitalWrite(psg_reset,LOW);
  delay(100);
  pinMode(psg_reset, INPUT);
  delay(1000);

  Serial.begin(38400);
  
  psg_sound(8,16);  // set ch-A as envelope

  set_envelope(1.0 ,8);  //set envelope freq=1hz and shape=8

  noise(1290);  // start output noise with average freq. (1290-40000)
}


void loop()                     
{
}

コレを使って鳴らしてみたのがこのmp3ファイルです。 ノイズを1秒毎にノコギリ波のエンベロープで鳴らしたものです。メトロノームの様に毎分60回のテンポで鳴ります。 (ちょっとボリウム大き目かもしれません…ご注意)

録音の都合で金属音っぽい感じになってますが、実際は「ザッ、ザッ、ザッ、ザッ…」っというノイズっぽい音が出ています。

シリアルI/Oを他のI/Fと共用する方法について

マイコンによってはシリアルI/Oは1個しかなかったりして貴重です。それをこのPSG出力だけで占有してしまうとモッタイナイです。

この似非PSGの場合はマイコンからシリアル出力する信号を使用するわけですが、PCとの接続用にも並行して使用したいという場合もあるでしょう。

そういうときに、一時は似非PSGに、一時はPCに、と切り替えて出力できればありがたいわけです。 で、そのように切り替えて出力するための仕組みを考えてみました。

端的に言えば、ロジックIC(74HCxxシリーズなど)からor回路(74HC32)かNAND回路(74HC00)を使えば切り替えが出来ます。

or回路の場合は2つのor回路を使います。シリアル出力信号を2つのor回路の片方の入力に、残った2つの信号をそれぞれ制御信号に繋ぎます。 制御信号をlowにするとアクティブ、highにすると無信号状態にできます。3回路、4回路使えば3つ、4つと接続できます。

NANDを使う場合は、2回路をNANDそのままに、2回路は入力をそれぞれショートさせて「NOT」回路として使います。 シリアル出力信号をこのnotで一旦反転させておいて、その反転信号と制御信号をNANDに入れてやれば、1でアクティブ、0で無信号になります。 not回路を組む為に2回路使ってしまうので、IC1個で2回路までしか接続できませんが、NANDの買い置きが常備されている人は多いでしょう。

買い置きのある方どちらでもイイと思いますが、汎用のロジックIC1個で切り替えて出力することができます。

ただし1点注意が必要です。

シリアル出力信号は、TINY2313ではハードウェア処理(シフトレジスタ)にて自動的に出力されますが、 このシフトレジスタ(一種の出力バッファ)が掃けるまで制御信号を変化させては駄目ということです。 ビット列が出力している最中に制御信号を変化させるとデータが化けて届くことになります。 出力したら、制御信号を変化させるまでの短い間待っている必要があります。忘れずに。

まとめ

今回作った似非PSGを使えば、マイコンでPSGを鳴らすのにパラレルI/Oで接続する必要も無く、 1個100円程度のTINY2313と20Mhzクロックだけで動くので、まぁ簡易的な用途には使えるんじゃないかなぁと思います。

がんばってはみたものの、オリジナルと比較して出力できる周波数帯が低めになってしまい、高音側周波数の正確性は確保できませんでしたが、 まぁ用途によってはこれでも音楽出力用の音源としてそこそこ遊べるんではないかと思います。

なお、当プログラムは商用/非商用に限らずお好きなように使っていただいてかまいませんが、 何があっても責任は負いかねますので、at your own riskでご使用ください。