皆さんこんにちは、Jinです。いかがお過ごしでしょうか。
今回はリレーで音を奏でる、MusicRelayのソフトウェアについて、お話ししようかと思います。
MusicRelay用に作成したソフトウェアについてはこちらに公開しています。今回はForm1.csのファイルに記載されているコードの解説を行います。
動作については以前の記事でご紹介しましたので、是非ご覧ください。
こちらの企画ではJLCPCBにご協力いただいております。併せてご覧くださいませ。
ソフトウェアの概要
再生ボタンを押してからシリアル通信によって周波数データをマイコンに送信するまでの流れをフローチャートでまとめてみました。
ソフトウェアのプログラムなのでメインループのようなものは存在せず、ボタンをクリックなどのイベントを待ち受ける形のプログラムになります。
プログラムのメインの部分はMidiファイルを解析してどのように再生するか、というところですので、今回はそのMidiファイルを解析するところと、解析したデータを変換し、再生処理をどう行っているのか、についてご説明いたします。
Midi解析
Midiファイルの構成について詳しくは解説いたしませんが、Midiファイルにはヘッダーとトラックが存在します。ヘッダーはそのMidiファイルの属性など必要な情報が明記されています。トラックは各楽器ごとの譜面で、MidiのフォーマットがSMF1であればこのトラックが各楽器ごとに分かれています。
まずはヘッダー、トラックごとに解析した情報を格納する構造体を宣言します。
//ヘッダーチャンク解析用
private struct HeaderChunkData
{
public byte[] chunkID;
public int dataLength;
public short format;
public short tracks;
public short division;
};
//トラックチャンク解析用
private struct TrackChunkData
{
public byte[] chunkID;
public int dataLength;
public byte[] data;
};
ヘッダーチャンク解析
ヘッダーチャンクの解析はプログラムの660行目付近、HeaderChunkAnalysis()という関数で行っております。まずはファイルを開くプロセスで格納したパスから、ファイルをバイナリでストリーミングします。
using(FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read))
using(BinaryReader reader = new BinaryReader(stream))
ヘッダーのデータをそれぞれBitConverterオブジェクトを用いて数値に変換し、格納していきます。
エンディアンによってはMSBとLSBが反転するため、リトルエンディアンの場合はArrayオブジェクトを用いてバイナリの配列を反転させています。
headerChunk.chunkID = reader.ReadBytes(4);
//リトルエンディアンならビットを反転させる
if (BitConverter.IsLittleEndian)
{
byte[] byteArray = reader.ReadBytes(4);
Array.Reverse(byteArray);
headerChunk.dataLength = BitConverter.ToInt32(byteArray, 0);
byteArray = reader.ReadBytes(2);
Array.Reverse(byteArray);
headerChunk.format = BitConverter.ToInt16(byteArray, 0);
byteArray = reader.ReadBytes(2);
Array.Reverse(byteArray);
headerChunk.tracks = BitConverter.ToInt16(byteArray, 0);
byteArray = reader.ReadBytes(2);
Array.Reverse(byteArray);
headerChunk.division = BitConverter.ToInt16(byteArray, 0);
}
else
{
headerChunk.dataLength = BitConverter.ToInt32(reader.ReadBytes(4), 0);
headerChunk.format = BitConverter.ToInt16(reader.ReadBytes(2), 0);
headerChunk.tracks = BitConverter.ToInt16(reader.ReadBytes(2), 0);
headerChunk.division = BitConverter.ToInt16(reader.ReadBytes(2), 0);
}
今回、リレーのほかにも鳴らしたい楽器ごとに譜面を分けたいので、フォーマット1以外のものを受け付けないように設定しています。
if(headerChunk.format != 1)
{
MessageBox.Show(Path.GetFileNameWithoutExtension(path) + "は対応していないフォーマットです。");
counter++;
if(status == Status.TestMode)
{
StopButton.PerformClick();
}
return;
}
ヘッダーチャンクが解析出来たら、次はヘッダーのトラック数の情報を元にトラックデータの解析に移ります。まず、トラックチャンク列挙体をトラックの数だけ配列にして宣言します。
TrackChunkData[] trackChunks = new TrackChunkData[headerChunk.tracks];
次に、トラックの数だけfor文を回し、必要なデータを格納していきます。格納するには基本的にヘッダーチャンクと同じようにバイナリの配列をBitConverterオブジェクトを用いて数値に変換するだけです。
//トラックチャンクの解析
for(int i = 0; i < headerChunk.tracks; i++)
{
trackChunks[i].chunkID = reader.ReadBytes(4);
if (BitConverter.IsLittleEndian)
{
byte[] byteArray = reader.ReadBytes(4);
Array.Reverse(byteArray);
trackChunks[i].dataLength = BitConverter.ToInt32(byteArray, 0);
}
else
{
trackChunks[i].dataLength = BitConverter.ToInt32(reader.ReadBytes(4), 0);
}
trackChunks[i].data = reader.ReadBytes(trackChunks[i].dataLength);
//各トラックデータについてイベントとデルタタイムの抽出
TrackDataAnalaysis(trackChunks[i].data);
}
それぞれの情報を格納出来たら、最後の行にあるように、各トラックチャンクのデータを750行目付近のTrackDataAnalaysis関数を使用して解析していきます。
トラックチャンク解析
トラックチャンクは、Midiデータにおいて一番重要になる生のデータです。
基本的にはノーツ(音階情報)のOn/Offやテンポの変更などのイベントがあり、そのイベントがいつ発生するか(イベントタイム)がセットになっており、数バイトで構成されています。
イベントにも数種類あり、メインとなるMidiイベントは以下のような構成です。ノート番号は鳴らす音階、ヴェロシティは鳴らす強さで、ノートオフで鳴っている音を止め、ノートオンで音を鳴らします。
イベント名 | 可変長バイト | 1バイト | 1バイト | 1バイト |
---|---|---|---|---|
ノートオフ | イベントタイム | 0x8n (n:チャネル) | ノート番号 | ヴェロシティ |
ノートオン | イベントタイム | 0x9n (n:チャネル) | ノート番号 | ヴェロシティ |
コードを見ていきましょう。まず、ノート情報それぞれに必要な構造体や列挙体を定義します。
//ノートの種類
private enum NoteType
{
On,
Off,
}
//ノート情報を格納する構造体
private struct NoteData
{
public int eventTime;
public int laneIndex;
public NoteType type;
};
//テンポを格納する構造体
private struct TempoData
{
public int eventTime;
public float bpm;
};
for文でトラックデータの数(=イベントの数)だけループを回します。
イベントタイムはデータが1バイトよりも大きくなる可能性があるため、最上位ビットをフラグとして扱う可変長数値表現というものを用いています。そのため、0x80で論理積を取ってデータの抽出を行います。
for (int i = 0; i < data.Length;)
{
uint deltaTime = 0;
while (true)
{
//デルタタイムの抽出
byte tmp = data[i++];
deltaTime |= tmp & (uint)0x7f;
if ((tmp & 0x80) == 0) break;
deltaTime = deltaTime << 7;
}
currentTime = deltaTime;
イベントタイムを抽出できれば、次はmidiイベント抽出していきます。ランニングステータスとは同じチャネルのイベントが続くときにイベントを省略することで、そうでない場合はまずステータスバイトにイベントを格納します。
if (data[i] < 0x80)
{
//ランニングステータス
}
else
{
statusByte = data[i++];
}
ステータスバイトの値を元にswitch文で多岐分岐を行います。
byte dataByte0, dataByte1;
if (statusByte >= 0x80 && statusByte <= 0xef)
{
switch (statusByte & 0xf0)
{
//ノートオフ
case 0x80:
dataByte0 = data[i++];
dataByte1 = data[i++];
if (longFlags[dataByte0])
{
NoteData note = new NoteData();
note.eventTime = (int)currentTime;
note.laneIndex = (int)dataByte0;
note.type = NoteType.Off;
if (harmony == 0x00)
{
PianoNoteList.Add(note);
}else if(harmony == 0x1D || harmony == 0x1E){
GuitarNoteList.Add(note);
}
longFlags[note.laneIndex] = false;
}
break;
//ノートオン
case 0x90:
dataByte0 = data[i++];
dataByte1 = data[i++];
{
NoteData note = new NoteData();
note.eventTime = (int)currentTime;
note.laneIndex = (int)dataByte0;
note.type = NoteType.On;
longFlags[note.laneIndex] = true;
if (dataByte1 == 0)
{
if (longFlags[note.laneIndex])
{
note.type = NoteType.Off;
longFlags[note.laneIndex] = false;
}
}
switch (harmony)
{
case 0x00:
PianoNoteList.Add(note);
break;
case 0x1E:
isDistGuitar = true;
goto case 0x1D;
case 0x1D:
GuitarNoteList.Add(note);
break;
}
}
break;
上位4ビットを8(ノートオフ)or9(ノートオン)かどうかを判別しています。
ノートオンではロングノーツのフラグをノート番号に設定し、配列の要素に与えています。ノートオフではそのフラグを判定し、それぞれのノート番号のデータを格納しています。hermoneyで鳴らす楽器を指定しており、リレーが演奏するピアノはpianoNoteListという配列に格納しています。
次に曲名情報とテンポの格納です。これらはコントロールイベントというイベントの種類で、次のような構成をしています。
曲名情報 | イベントタイム | 0x03 | データ長 (1バイト) | テキスト (データ長バイト) |
テンポ情報 | イベントタイム | 0x51 | テンポ (3バイト) | × |
まずは曲名情報からです。sysイベントは特に何の処理もしないのでインクリメントのみ行い、メタイベントのswitch文、0x03の分岐をご覧ください。
データ長を1バイトで受け取り、その長さ分for文を回しているのが分かると思います。そこで受け取ったデータをEncodingオブジェクトでバイナリからエンコーディングし、曲名情報として格納しています。
}
}
//SysExイベント用、インクリメントオンリー
else if (statusByte == 0x70 || statusByte == 0x7f)
{
byte dataLength = data[i++];
i += dataLength;
}
//メタイベント用
else if (statusByte == 0xff)
{
byte metaEventID = data[i++];
byte dataLength = data[i++];
switch (metaEventID)
{
//曲名を格納
case 0x03:
if (title == "")
{
NowPlaying.Text = "";
byte[] bytes = new byte[100];
for (int j = 0; j < dataLength; j++)
{
byte temp = data[i++];
bytes[j] = temp;
if ((temp & 0xf0) >= 0x80)
{
bytes[++j] |= data[i++];
}
}
title += Encoding.GetEncoding("Shift_JIS").GetString(bytes);
NowPlaying.Text = title;
}
else
{
i += dataLength;
}
break;
次にテンポです。テンポは3バイトありますので、3回シフト演算とORを用いてデータを格納しております。最後にはテンポ情報をbpmに変換した後、格納しています。
//テンポ情報を格納
case 0x51:
{
TempoData tempoData = new TempoData();
tempoData.eventTime = (int)currentTime;
uint tempo = 0;
tempo |= data[i++];
tempo <<= 8;
tempo |= data[i++];
tempo <<= 8;
tempo |= data[i++];
tempoData.bpm = 60000000 / (float)tempo;
tempoData.bpm = (float)(Math.Floor(tempoData.bpm * 10) / 10);
tempoList.Add(tempoData);
}
break;
}
}
}
再生プロトコル
Midi解析は以上です。次にこれを用いて再生を行う処理についてご説明していこうかと思います。
まず再生のプロトコルはタイマーを使用して行います。処理自体は非常に簡単で、タイマーの発生感覚をイベントタイムを用いて設定しておき、指定された時間が来ると周波数情報をシリアル通信で送信するだけです。
マイコン側ではこの受信で割込みを発生させ、その割り込みでPWMのタイマー設定値をいじっているだけです。しかしテンポが変更される場合はそう簡単にはいきません。
テンポの制御は今のところは未完成ですので、ノート制御についてのみご説明させていただきます。
ノートの送信
240行目付近のSendSerial()関数内、270行目付近のif文で制御しております。まずはSystem.Timersをインスタンス化し、タイマーをセットしております。
呼び出されるごとにイベントタイム分lastTime変数を更新し、経過時間とその差分がイベントタイムを超えたときにシリアル通信によってデータを送信しています。
counterはプレイリスト等を制御するための変数です。
int i = 0;
tim2 = new System.Timers.Timer();
tim2.Interval = 1;
StoreNoteData(serialPort1, PianoNoteList, "piano");
Int64 lastTime = DateTime.Now.Ticks / 10000;
tim2.Elapsed += (s, e) =>
{
if ((DateTime.Now.Ticks / 10000 - lastTime) >= (Int64)delays[0][i])
{
tim2.Stop();
Invoke(new LogTextDelegate(WriteLogText), "piano send: " + texts[0][i]);
serialPort1.Write(bins[0][i]);
lastTime += (Int64)delays[0][i];
i++;
if (i < delays[0].Count)
{
try
{
tim2.Start();
}
catch { }
}
else
{
if (status == Status.PlaylistMode)
{
if (counter < files.Count)
{
Invoke(new StartButtonDelegate(Stt));
}
else
{
counter = 0;
Invoke(new StopButtonDelegate(Stp));
}
}
else if (status == Status.TestMode)
{
counter = 0;
Invoke(new StopButtonDelegate(Stp));
}
}
}
};
tim2.Start();
さいごに
いかがだったでしょうか。今回はまだ未完成ながら、現時点で公開できるMusicRelayを制御するソフトウェアの一部を解説させていただきました。
Midi制御は非常に難しいですが、理解するとヴェロシティなどで楽器の制御をしたりと、非常に柔軟にハードウェアを操ることが可能になってきます。
Midiを使用する以前は独自に周波数をいちいち書き出して今いたがその手間が省け、非常に快適に曲を作れるとともにハードの制御も簡単になりました。
この企画はJLCPCBにご提供いただいております。併せてご覧ください!
皆さんも是非、独自楽器を製作してこのMidi解析を使用してみてください。良い楽器が作れると思いますよ!それではまた!GoodBye!