スティルハウスの書庫の書庫

はてなダイアリーで書いてた「スティルハウスの書庫」を移転してきました。

DE0で作った音源をMIDIキーボードで鳴らしてみた。

これはWeb Music Developers JP Advent Calendar 2012の12月19日分のエントリです。Web Musicとちょっと違う話題ですが :)

これまでのまとめ

2〜3か月前にFPGAの入門用ボードDE0を買ったのがはじまり。



これでなに作ろうか?自作CPUもいいのだけど、中学のころ連日ヤマハ仙台店に通っては(鍵盤弾けないのに)PolysixDX7やJUNOに何時間もぶらさがって店員さんに煙たがられていた程度にはアナログシンセ好きの俺としては、まずは音!FPGAを使ったデジタル回路設計の練習がてらにDE0で音を出してみたかった。デジタル回路も電子工作もデジタル信号処理も素人なんだけど。。

ここまでやってきたこと:

  • DE0で音を出してみた。 まずDE0って音だせるの?どこに何繋げばいいの?ってところから。440Hzで0と1を繰り返す信号をスピーカーにつないだらブザーみたいな音が出た。
  • DE0で正弦波を出してみた。DE0のデジタル信号をオーディオのアナログ信号に変換するためのDAコンバーターを使うのに手こずった。なんとか440Hzの正弦波を鳴らせた。
  • DE0でMIDIをデコードしてみた。 音が出たら、キーボードで弾いてみたいね。となるとMIDIでつなげるか。そもそもMIDIってどんなプロトコルなんだっけ?なんとかMIDIキーボードから出てくるノートナンバーを読み取るとこまでできた。

次の野望はMIDIキーボードで正弦波を鳴らすこと。その本題に入る前に、なぜいま俺がFPGAにハマっているのか書いておきたい。

FPGAってなに?

FPGAは、見た目はCPUみたいなチップだけど、中身はまったく違う。簡単に言うと大人の電子ブロックだ。子供の頃、ブロック10個くらい入った電子ブロックを買ってもらっていろんな回路組んで使い倒したけど、ブロック50個くらいぎっしり詰まったフルセットを持つ友人がうらやましかった。



その電子ブロックみたいな電子部品(ロジック・エレメントって言うもの)が、例えばDE0に載ってるAltera Cyclone III 3C16だと1万5000個ほど詰まってて、それをハンダゴテ使わずともPCからのプログラミングで自由自在に組み替えて回路を組めるという、子供の頃の俺からすると未来の世界すぎて鼻血が出そうなおもちゃだ。FPGAといえば昔は一般人が趣味に使えるようなものではなかったけど、それが今や1万円ちょいで買える!これは大人買いするしかない。しました。

最近の工学部の学生さんは実習でFPGAを使ってる人も多く、「FPGAでCPU自作した」とか「ファミコンエミュレーター作った」とか、さらりとおっしゃる。つまりスノボと一緒だ。ある一定の年代以上の人はまったく使ったことない。おじさんの時代にはそんなのなかったぞ!うらやましすぎる!ハンダゴテで自作CPUなんてハードル高すぎたよ。。

ファミコンやMSX等のレトロCPUをFPGAで再現してる人も多いけど、FPGAはCPU以外にもいろんなデジタル回路を組むことができる。今回みたいに「俺はシンセっぽい何か作る」でもいいし、「将棋AI用のめちゃ速い探索回路」「株取引用のスパコンっぽいの」「Lispを直接実行するプロセッサ」「デジタルFMラジオ」「脳を再現」...皆さんいろんなことに使ってる。それらのデジタル回路をPC操作だけでさくっと組めて、なにか動きがヘンだったらすぐ直せるのが電子工作的には画期的。今どきのmakerムーブメントっぽいノリなのだ。ほしいチップがなければ自分で作る!

HDLプログラミングがスゴい

最近流行りのArduinoやRaspberry Piも見た目はDE0に似てるけど、それらはどれもCPUが載っているボード。CPUで動かすプログラムコードを書いて使うので、パソコンやサーバーのプログラミングと基本はあまり変わらない。一方で、FPGAはCPUではないので、いわゆる普通の「プログラム」はそのままでは動かない。FPGAのプログラミングとはつまりデジタル回路の設計のこと。Verilog HDLやVHDLといった専用の言語でデジタル回路を設計して、FPGAボードに付属するツールで実際の回路にコンパイルする。例えば reg [7:0] foo; って書くと、8ビット幅のデータを記憶してくれるレジスタ(変数みたいなもの)の回路が生成される。




大人の電子ブロックなのだ


このHDLのコーディングがとってもおもしろい。普通のプログラミング言語みたいに頭から順番に実行されるわけじゃなくて、回路なので、コードの一行一行がすべて同時並列に動く(はぁ?)。例えばa = b + c;って書くと、bとcを足し算し続ける回路ができる。CPUみたいに気が向いたときに1回だけ足し算してくれるんじゃなくて、昼も夜もいつでも常時足し算中。なのでbやcに流れるデータが変われば、aの結果もデジタル回路的に可及的速やかに変化する。この足し算回路を1000個並べることも簡単で、すると1000回分の足し算が1クロックで完了する。ループなんていうセコいものは書かなくてよい。

いわばこれは光の速さで動くExcelだ。やりたいことをExcelの式の数珠つなぎで書いてそこに大量のデータを流してやると、数万個の演算器がすべて同時に、かつ常時フルスピードで動作して答えを出してくれる。ある意味スパコン。だから画像処理や暗号化など大規模な並列計算が必要な用途によく使われる。やりたいことをどこまで並列化できるかはプログラマのアイディア次第。CPUで動く言語ではなかなか味わえない、この脳みその裏側の筋肉が鍛えられるようなパラダイムシフト感が新鮮だ(でもやっぱり初学者の俺には扱いが難しくてCPUの方が全然ラクだ〜って思うことも多いのだけど。詳しくは後述)。

MIDIキーボードで正弦波を鳴らす

すっごく前置きが長くなった。話を本題に戻して、前回やっと拾えたMIDIキーボードからのノートナンバー、例えば「30h」(鍵盤の真ん中のC)のような数字を周波数に変換して、これまで440Hz固定で鳴らしていた正弦波の音程を変える仕組みをつくる。

前々回に正弦波を鳴らした仕組みは、サインテーブルを使うもの。つまり正弦波の波ひとつ分の時間(440Hzなら2.3ms)を128分割(17μs)して、17μsごとにどんな電圧の信号を出力すればいいかを表にして回路に組み込んでしまう(sin関数を演算で求める回路を組むのはめんどい)。イメージ的にはこんな感じ:



17μsごとに順番に表から電圧の値を読み取ってDAコンバーターに送ると、アナログ信号に変換されて440Hzの正弦波として聴こえる。

この正弦波の周波数を変えるには、同じ表を使いつつ、読み取るスピードだけ変えればよい(今どきのシンセはこんな単純な仕組みじゃないと思うけど)。では、この読み取りの時間間隔をノートナンバーから求めるにはどうすればいいか。「MIDIノートナンバー 周波数」でググったら、いい感じの変換式が出てきた(g200kgさんのサイト!)。



この式を使うのだけど、これも割り算やらべき乗やらをわざわざ演算回路で組むのは大げさなのでテーブルで済ませてしまおう。今度は「ノートナンバーとサインテーブル読み取り間隔の対応表」をこんな感じで作る。



例えばノートナンバー0hならば、DE0の50MHzのクロックを47778回数えるごとに1回サインテーブルから値を読み取る、、って具合だ。この対応表をコピーして、HDLの関数stepsをこんなふうに書いた(コード全体はこちら)。

function [15:0] steps;
	input [7:0] note;
	case (note)
		0: steps=47778;
		1: steps=45096;
		2: steps=42565;
		...

さらに、MIDIステータス90hのメッセージが飛んできたらそのノートナンバーをstepsに渡してサインテーブル読み取り間隔を得るコードを書く。

if (midi_status == 8'h90) begin
	note_on = 1; 
	current_steps = steps(midi_data1);
end

新しい間隔はcurrent_stepsっていうレジスタに入るので、こんなふうな50MHzクロックCLKを数えるカウンタcntを書いて、サインテーブル読み取り用クロックstepclkを作る。このstepclkのタイミングでサインテーブルを読みだしていく。

// clock for each step
reg [15:0] cnt;
wire stepclk;
always @(posedge CLK) begin
	if (cnt == 0)
		cnt = current_steps;
	else 
		cnt = cnt - 1;
end
assign stepclk = cnt == 0;

ここで、DE0内蔵のロジック・アナライザを使ってcurrent_stepsやcnt、出力データにどんな値が出ているか確かめてみる。



いい感じ。MIDIノートナンバー3Chに対応したcurrent_stepsとして05D5hがセットされて、その間隔でstepcntの値が0〜127まで増え、それを使ってサインテーブルから読み取った値DATがきちんと正弦波を描いている。

こんな仕組みで、意外とあっさりMIDIキーボードで叩いた音程で正弦波を鳴らすことができた。いえい!



...和音を出したいね?

しかし現状では単音しか出ない。ここまで来ると欲が出て、和音を出したくなる。横文字で言い表せばポリフォニック・シンセサイザーだ!(正弦波のみ・フィルター・エンベロープ他なんにもなし)

しかし実はこのポリ化でハマって半日くらい悩んでた。何が難しいか。正弦波を出す回路(WaveGenって名前のモジュールにした)をそのまま10や20ほどコピーするのは、あっという間にできる。なにせ1万5000個も部品あるし。問題は、それらに個々の音を割り振る仕組みだ。MIDIキーボードでいくつかの鍵盤を同時に叩くと、それぞれに対応するMIDIメッセージが飛んでくる。その瞬間に、いま空いているWaveGenを順番に探していって、空いているものが見つかったらそれにメッセージを渡す。

こういう順序だった処理って普通のプログラミング言語で書けばあっという間なのだけど、デジタル回路で実装するには慣れが必要。すべてが並列に動いており「順番にやる」という概念のない世界なので、レジスタやカウンタで状態を保持する仕組みを作ってあげて、それらの状態遷移図を頭のなかに描けなければならない。Node.jsやSAXみたいな非同期プログラミングしかない世界を思い浮かべてほしい。ややこしい。デバッグもやっかい。人類にはまだデジタル回路は早すぎたんや。。フォン・ノイマンさんやっぱ天才。もう二度とCPU様のことは裏切りませんすみません申し訳ありませんともがき苦しむことしばし(俺が単に初学者なだけなんだけど)。

結局はこんなふうにした。8個のWaveGenを作って、最初のひとつにMIDIメッセージを渡す。もしそれが発音中で忙しければ次のWaveGenにMIDIメッセージをバケツリレーするという、ソフトウェアにおけるChain of Responsibilityパターンっぽい動きだ。

// generates 8 polyphonic waves
WaveGen wg0(CLK, MIDI_MSG, MIDI_MSG_RDY, midi_msg_thru[0], wg_dat0);
WaveGen wg1(CLK, MIDI_MSG, midi_msg_thru[0], midi_msg_thru[1], wg_dat1);
WaveGen wg2(CLK, MIDI_MSG, midi_msg_thru[1], midi_msg_thru[2], wg_dat2);
WaveGen wg3(CLK, MIDI_MSG, midi_msg_thru[2], midi_msg_thru[3], wg_dat3);
WaveGen wg4(CLK, MIDI_MSG, midi_msg_thru[3], midi_msg_thru[4], wg_dat4);
WaveGen wg5(CLK, MIDI_MSG, midi_msg_thru[4], midi_msg_thru[5], wg_dat5);
WaveGen wg6(CLK, MIDI_MSG, midi_msg_thru[5], midi_msg_thru[6], wg_dat6);
WaveGen wg7(CLK, MIDI_MSG, midi_msg_thru[6], midi_msg_thru[7], wg_dat7);

ここで、wg0が忙しかったらmidi_msg_thru[0]フラグが立つので、wg1がMIDI_MSGを読んで発音する。もしwg1が忙しかったらmidi_msg_thru[1]が立って...という具合。大まかにはこんな構成だけど、各WaveGenの中での状態遷移とロジックの組み立てとデバッグがむずかった。。(実際のコードはここ

こんな仕組みでいいのかな?なにせ、このあたりの組み方を詳細に説明してるようなページが見つからなかったので、思いつくまま実装してみた。もっと勉強すればよりよい実装方法があるかもしれない。「FPGAでデジタルシンセを作る本」ってあったらほしい!

音のミックスってどう書くの?

もうひとつちょっと悩んだのが、音のミックスのしかた。デジタル音声のミックスって単純な足し算でいいんだよね?WaveGenから出力された波形はwg_datという8ビットのデータとして出てくるので、それらを足し算した値をDAコンバーターに送る。

assign OUTDAT = wg_dat0 + wg_dat1 + wg_dat2 + wg_dat3 + wg_dat4 + wg_dat5 + wg_dat6 + wg_dat7;

これでとりあえず試しに和音を出してみたら、最初ものすごっく歪んだ音がでてきた。足し算で出てきた答えが8ビットをオーバーフローして信号がぐちゃぐちゃになったからだ...しかしこれはこれでアリかな?ウィンターライブの教授のソロみたいにぎゅうううんとディストーションが効いてるすてきな音!と一瞬思ったものの、やはり透き通ったきれいな正弦波の和音をまずは出さねば。

本当は足し算する前にwg_datに定数で乗算してボリュームを落とし、合計して8ビット超えないようにする必要があるはず。あとは8ビット超えてもオーバーフローせずにFFhで頭打ちになるような足し算(飽和演算ってやつ?)とか。しかしラッキーなことに、今回使ってるDACは12ビット入るので、単純に足し算結果を入れるOUTDATを12ビットにしてそのままDACに入れたら歪まなくなった。

8音ポリ正弦波シンセができた〜

そんなこんなでポリフォニック化がいちばんの障壁であった。しかしなんとか和音が出せたよ!



見た目のギーク感を高めるために各WaveGenの発音状態をLED表示する回路も入れといた。さらに、キーボードが弾けない俺ではかっこよくデモできないので、バッハ様にデモしてもらった様子も入れておいた(MIDIファイルはこちらのサイトからお借りした)。

和音が出ているところをUSBオシロスコープで波形を観測してみると、なんだかうねうねっとしている。



ところで、手弾きでは問題なく動いてたのに、パソコンからMIDIファイルを流してみると最初は全然ダメダメだった。発音はするけど、うまくノートオフを拾えてないようす。調べてると、ノートオフの代わりに「ベロシティ(鍵盤を叩く強弱)が0のノートオン」を流すというMIDIの使い方もあることを知った。さらにはRyoya KAWAIさんがRunningStatusについて教えてくれて、なんだMIDIメッセージって3バイト固定じゃなくて可変長なのかぁ〜こりゃHDLじゃめんどいな!とCPUのありがたみを再度思い知った次第。

こんな構成になった

ここまで作ってきた回路のまとめ。DE0の開発ツール使うとこんなふうなオシャレな図を作ってくれる。



MIDI入力の非同期シリアル信号はMIDIモジュールによって3バイトのMIDIメッセージに変換される。それを受けてPolyWaveGenが上述の数珠つなぎによってMIDIメッセージを各WaveGen(サインテーブルによる正弦波生成モジュール)に割り振る。WaveGenから出てきた波形信号を、DAコンバーターへのインタフェースとなるSPIに渡す。一番下のNoteDisplayはMIDIノートナンバーをDE0の7セグLEDで表示するモジュール。

これまで書いたコード全体はここに置いておいた。

次の野望

シンセっぽくするなら、あとはやっぱりフィルタとエンベロープ、さらにはサンプラーかな。サンプラーはわりと簡単にできそう。A/DコンバーターのICが手元にあるので、そのデータをDE0のSDRAMに読み込んで、サインテーブル同様に読み出せばいいかな。あとFM音源はもしかして割と簡単にできるかな?サインテーブルを読み出すスピードを、別のサインテーブルでモジュレーションする。一方で、フィルタは難しそう。加算や乗算を組み合わせたFIRだのIIRだの謎回路を組まないとデジタルフィルタにならない。その辺りでググると数式がいっぱい出てくるので萎える。。こっち方面のいわゆるデジタル信号処理の方向には数学苦手な俺は向いてる気がしないなぁ。。