🎄この記事は けものフレンズ Advent Calendar 2018 15日目の記事です🎄
前の記事: ジャパリオルゴールを作ってみた(ハードウェア編)
今回はジャパリオルゴールのソフトウェアについて紹介していきます。Arduino のスケッチ、今回定義した M25 形式の仕様などは GitHub にて詳しく公開しています。
https://github.com/nanase/YMF825-musicBOX/tree/master/sketch
Arduino スケッチ
Arduino Nano に書き込むためのスケッチを書いていきます。複数ファイルに跨る大きなスケッチになりますので、Arduino IDE は使わずに、Visual Studio Code に Arduino の拡張機能を入れてスケッチを書いていきます。Arduino IDE よりもコンパイルが遅いことを除けば、非常に使いやすいです。
初期化とポート設定
Arduino 電源投入時の処理を記述していきます。コードは player.ino と port.ino がメインです。主な初期化は以下のとおりです。
1. シリアル通信の初期化
void setup() {
PSerial.begin(9600);
PSerial.println("[DEBUG] Setup");
...
標準の Serial クラスではなく、PetitFS に付属している PSerial クラスを使います。これによりグローバル変数の使用量が僅かに減ります。機能は Serial と変わりありません。デバッグメッセージを出したいだけなので、ボーレートは 9,600 bps で十分です。
シリアル通信はデバッグ目的で使います。Arduino Nano で PC と接続したときのみ動作するため、外部電源供給時は無視されます。
2. ポートの初期化
void setupPort() {
PORT_SS_DDR_R |= PORT_SS_DDR_V;
PORT_IC_DDR_R |= PORT_IC_DDR_V;
pinMode(PIN_BT_PLAY, MODE_BT_PLAY);
pinMode(PIN_BT_NEXT, MODE_BT_NEXT);
}
#define PORT_SS_DDR_R DDRD
#define PORT_IC_DDR_R DDRB
const byte PORT_SS_DDR_V = B11000000;
const byte PORT_IC_DDR_V = B00000001;
const byte PIN_BT_PLAY = 2;
const byte PIN_BT_NEXT = 3;
const byte MODE_BT_PLAY = INPUT_PULLUP;
const byte MODE_BT_NEXT = INPUT_PULLUP;
ポートの入出力方向と、ボタンのピンモードを設定しています。
PORT_SS が YMF825 の SS (Slave-Select) ピン、PORT_IC が RST_N ピンに接続されます。今回は YMF825Board を 2 枚使うため、PORT_SS の出力は 2 本分あります。
ボタンはプルアップしておきます。つまり入力は、押す前は H レベル、押した時は H→L レベルです。後述する割り込み設定にも関連します。
3. SPIの初期化
SPI.setBitOrder(MSBFIRST);
SPI.setClockDivider(SPI_CLOCK_DIV4);
SPI.setDataMode(SPI_MODE0);
SPI.begin();
PSerial.println("[DEBUG] SPI Begin");
SPI の設定を行っています。YMF825、SDカードともに、ビットオーダは MSBFIRST
、データモードは 0
です。クロックは 4 分周 (16 MHz / 4 = 4 MHz) で指定しています。
なお、標準ライブラリの SPI クラスは Arduino Nano の場合、SS ピンが 10 番ピン固定になっていて、変更ができません。複数デバイスにも対応できないので、10 番ピンは使わずに別のピンで代用します。
PetitFS はヘッダファイルの変更で SS ピンの変更ができます。YMF825 は専用の関数を作って対応します。以下の 4 つの関数で、LchとRch、どちらの YMF825 を操作するかを決めます。
SS ピンの操作はできるだけ瞬時に行う必要があります。そのため、digitalWrite 関数は使わずにレジスタを直接操作します。スケッチのコンパイルオプションも変えておきます。詳しくは ArduinoUnoで速度に困ったら にて解説されています。
void enableLch() { PORT_SS &= ~BIT_SS_LCH; }
void enableRch() { PORT_SS &= ~BIT_SS_RCH; }
void enableLRch() { PORT_SS &= ~BIT_SS_LR; }
void disableSS() { PORT_SS |= BIT_SS_LR; }
4. YMF825のリセット
ymf825ChipUnselect();
ymf825ResetHardware();
void ymf825ChipUnselect() {
disableSS();
}
void ymf825ResetHardware() {
disableIC();
enableIC();
delayMicroseconds(100);
disableIC();
}
void enableIC() { PORT_IC &= ~BIT_IC; }
void disableIC() { PORT_IC |= BIT_IC; }
YMF825 の RST_N ピンに Lレベル を入力してリセットさせます。ここは YMF825 の仕様書通りのウェイトを入れておきます。
5. SDカード読み込みの初期化
if (!sdInitialize())
while (true)
delay(1000);
bool sdInitialize() {
PSerial.println("[DEBUG] SD initialization");
while (pf_mount(&fs) != FR_OK) {
PSerial.println("[ERROR] SD initialization failed");
delay(1000);
}
if (pf_opendir(&root, "/") != FR_OK) {
PSerial.println("[ERROR] open root dir failed");
return false;
}
return true;
}
SDカードをマウントし、ルートディレクトリを開きます。
MicroSDカードが入っていない、ファイルシステムが対応していない、もしくは壊れている場合のためにエラー処理が書かれています。大抵はで電源投入後にMicroSDカードを抜き差しされる前提でウェイトも大きくしています。
なお、Arduino の標準ライブラリである SD クラスは使用するRAMが非常に多く、しかもディレクトリの Rewind が不安定で動作が止まってしまいます。今回は PetitFS を使っています。
また、このSDカード読み込みの初期化処理は、他の初期化処理との順番が最もシビアで、YMF825 よりも後に行わなければなりません。
6. 割り込み設定
attachInterrupt(0, ymf825Pause, FALLING);
attachInterrupt(1, ymf825Next, FALLING);
ボタンが押されたときの外部割り込みの設定です。Arduino Nano では INT0, INT1 ピンが対応します。
引数で 外部割り込み番号、実行する関数、トリガモード を指定します。トリガモードは H→L の FALLING
を指定します。
SDカードの読み込み
以下の順序で、SDカードから演奏データの入ったファイルを探して読み込みます。sdio.ino の sdSeekNext
関数で読み込み処理を行っていますが、簡単にまとめると、
- ルートディレクトリの
.M25
ファイルを 1 つずつ開く - すべてのファイルについて開き終えたら、最初に戻る
という処理をやっているだけです。
ファイルからの読み取りは sdReadBuffer
関数で行っています。指定された size
分だけ、SDカードからグローバル変数のバッファにデータをコピーしていきます。
Arduino Nano は RAM が 2,048 バイト しかないため、バッファに一度にコピーできるのは数百バイトのみです。ただし、ダンプデータの制約もあり、一度に読み取らなければならないのは最低でも 468 バイトです。つまり、バッファは 468 バイト以上の領域を予め確保しなくてはなりません。
メリット | デメリット | |
---|---|---|
バッファサイズを増やす | SDカードから読み取る回数が減り、オーバヘッドが減る | RAMの使用量が増えるため標準ライブラリでは動作不安定になる恐れがある |
バッファサイズを減らす | RAMの使用量が減る | SDカードから読み取る回数が増え、オーバヘッドも増えて演奏が不安定になる |
YMF825の操作
void ymf825Write(byte address, byte data) {
ymf825ChipSelect();
SPI.transfer(address);
SPI.transfer(data);
ymf825ChipUnselect();
}
void ymf825BurstWrite(byte address, byte* data, uint16_t size) {
ymf825ChipSelect();
SPI.transfer(address);
SPI.transfer(data, size);
ymf825ChipUnselect();
}
ymf825spi.ino 内にコードがあります。
ymf825Write
、ymf825BurstWrite
関数で命令を送信しています。今回は、YMF825 に対して送信のみ行うため、受信の必要はありません。ただし、SPI.transfer
送受信両用のため、配列の内容は受信内容で上書きされることに変わりはありません。transfer 後はバッファの内容は 不定 になります。
void ymf825AllRelease() {
PSerial.print("[INFO ] All Release");
for (byte i = 0; i < 16; i++) {
enableLRch();
SPI.transfer(0x0b);
SPI.transfer(i);
disableSS();
enableLRch();
SPI.transfer(0x0f);
SPI.transfer(i);
disableSS();
}
}
ymf825AllRelease
関数は一時停止ボタンが押されたときに、YMF825 でノートオン状態の音を一括リリースさせるための処理です。
0x0b
がボイスチャンネル変更、 0x0f
でノートの状態変更です。後者はトーン番号の指定をしていますが、おそらくこれは不要です(修正予定)。
デコーダ
decoder.ino 内にコードがあります。複雑なので概要だけ紹介します。
SDカードから読み込んだ .M25
ファイルをデコードし、YMF825 の命令に変換するためのスケッチです。M25 ダンプファイルの仕様は ここ にあります。
M25ダンプファイルは 1 バイトからなるチャンクヘッダを読み取り、SELx
と WOPx
の組み合わせから、後続のデータバイトのオペレーション(扱い方)を判定します。オペレーションは以下の通り。
- ウェイト (分解能: 10ms)
- 2枚ある YMF825 のどちらに命令を送るか
- Write命令
- トーンパラメータのBurstWrite命令
- イコライザのBurstWrite命令
仕様に (Reserved)
とある項目については、次回記事の「未来編」にて紹介します。
適応的遅延
static unsigned long waitGoal = 0;
void waitBegin() {
waitGoal = micros();
}
void waitAdd(byte tick) {
waitGoal += (unsigned long)tick * WAIT_RESOLUTION * 1000;
}
void waitInvoke() {
while (waitGoal > micros()) {
delayMicroseconds(8);
}
}
waiter.ino にコードがあります。
M25 ダンプファイルの時間分解能は 10 ms です。これを delay(10)
でやろうとすると、どんどん演奏が遅れていきます。
原因は delay
関数自体よりも、SDカードの読み取りや関数呼び出しのオーバヘッドなどで予測不能な遅延が発生するためです。このため、安定したテンポで演奏させるためには delay(10)
は使えません。delay 関数の問題ではないため、delayMicroseconds 関数に変えても解決しません。
問題は「必要な待ち時間が毎回変動する」ことです。
そこで、現在の時刻を基準として、待ち状態が解除される目標時刻を決め、その目標時刻がやってくるまで細かく待つ という方法をとります。これにより、演奏テンポが遅れている場合は待ち時間を短くし、速い場合は待ち時間を長くする、というように適応的なウェイトができます。
細かく待つ は、今回は delayMicroseconds(8)
を使うことにしました。最小は 3
です。
なお、時刻の計測は micros
関数を使っており、電源投入から 約71分35秒 経過すると時刻がオーバーフローするため、一時的に演奏テンポが狂う可能性があります。
MIDIドライバ制作
ここからは演奏データの制作に移ります。
理論上は YMF825 の命令を直打ちして演奏データを作ることは可能ですが、時間も労力もかかるため MIDI から演奏データを作ることにしました。つまり、MIDI イベントから YMF825 の命令を生成するドライバを書くことになります。
MIDIドライバについては以前制作したものをそのまま使うことにしました。詳細は「YMF825を使ってFM音源でサファリメロディーを響かせてみた」を御覧ください。
要約すると、MIDIでは作れない音色のデータのみ トーンエディタ を使って制作し、ノートオン/オフ などは MIDI 編集ソフトで作っていきます。トーンエディタは C# で制作し、WPF を使っています。
演奏データ制作
演奏MIDIデータの作成
けものフレンズ楽曲の SMF (MIDIファイル) は、ようこそジャパリパークへのみ購入可能ですが、それ以外はすべて耳コピしなくてはなりません。まずは普通の MIDI として打ち込み、その後 YMF825 で演奏させるための MIDI を作っていきます。
YMF825 で演奏させるために、制作した MIDI ドライバの制約から、以下の点に注意して編曲を行っていきます。
- 同時に発音できる音は 16 個まで
- 使える MIDI メッセージは ProgramChange, PitchBend, NoteOn, NoteOff, (Velocity), Volume, Expression, Panpot
- Master Fine Tuning (音程微調整)は対応
- Reverb, Chorus, Moduration, Hold は使えない
実はこの演奏用 MIDI データの作成が、ジャパリオルゴール全工程中で最も時間がかかっていました。
ダンプデータ生成
MIDI から ジャパリオルゴール でデコードできる M25 形式のファイルを作ります。ダンプデータ と銘打ってありますが、やっていることは YMF825 の命令のダンプに近いのでこの名前で呼んでいるだけです。
M25 形式の仕様 に沿って、MIDI メッセージを変換します。変換プログラムは C# で、SMF の読み込みは自作のライブラリ MidiUtils を使いました。
この変換で 100KB の SMF が 300KB ほどの M25 になります。ファイルサイズが 3 倍も増加していることから、変換プログラムを単純に作ったため、データの効率はかなり悪いです。特にトーンパラメータを必要もないのに 16 チャネル分送ったりなど、今後の改善点はいくつもあります。
完成
ジャパリオルゴール Ver.1.1 で ようこそジャパリパークへ ~orgel ver.~ を演奏してみました。イヤホン端子からの出力を録音したものです。
— 七瀬 / リカオンのやべーやつ(EX) (@nanase_coder) 2018年9月25日
緑と黄LEDがそれぞれ音源LSI(LchとRch)との通信時、赤LEDがmicroSDカードの読み込み時に点灯します。#けものフレンズ#ジャパリオルゴール pic.twitter.com/VK46XgtnxO
Arduino スケッチを書き込み、MicroSDカードに M25 ファイルを置いて、電源を投入すれば演奏が始まります。
やや長めの演奏動画をニコニコ動画に上げておきました。
【ジャパリオルゴール】やくそくのうた |
以上がジャパリオルゴールのソフトウェア紹介になります。
これでジャパリオルゴールは完成しましたが、いくつか改良したい点や、追加したい機能が出てきました。
次の 未来編 では、ジャパリオルゴールで今後やりたいことを紹介しようと思います。
ジャパリオルゴールを作ってみた(ハードウェア編) : 前の記事
ジャパリオルゴールを作ってみた(未来編) : 次の記事