自分らしいmidiプレイヤーを作りたい!midiを使用して自作楽器の制御がしたい!
そう思ったことはありませんか?今回は、Ele Mag Harmonyという企画で製作した、自作midiプレイヤーについて、ご説明していこうかと思います。
皆さんこんにちは、いかがお過ごしでしょうか。春は過ごしやすい良い季節ですね。
今回は前回から引き続き、Ele Mag Harmony企画のmidiプレイヤーの説明をしていきたいと思います。
尚、以前midi解析の話をしていますので、今回の記事ではmidi解析の話は割愛します。前回の記事は以下のものをご参照ください。
この企画はJLCPCBの協賛によって実現しています。ぜひご確認ください。
目次
環境と仕様
最初に、Midiファイルを解読して再生する、EleMagPlayerの環境と仕様について、説明していきます。
環境
まずは環境です。言語はC#、.NETを使用しています。
パッケージやバージョンの情報は以下の通りです。
パッケージ
MetroModernUI : 1.4.0
NAudio : 2.1.0
バージョン情報
C# : 7.3
Microsoft .NET Framework : 4.8
MetroModernUIはモダンなUIを構築するために使用しています。使い勝手はあまり良いとは思えませんが、使いこなせれば綺麗なUIが作成できます。
NAudioというパッケージはMidi単音を鳴らすのに使用しています。WidowsデフォルトのAPIを使用して最初の方は鳴らしていましたが、動作遅延が大きいためこちらに移行いたしました。
以下、ディレクトリ構成です。PlayerForm.csでプレイヤーのフォームを制御、DataClass.csで構造体の宣言を行っております。SettingFormsのフォルダには設定メニューに手表示される、各種設定フォームのソースコードが格納されています。
仕様
続いて仕様です。UIとそれぞれの機能について図にまとめてみました。
プレイヤーコントロール
まずは画像赤枠で記されている、再生のコントロールボタンについて解説していきます。
左から、曲戻り、停止、再生/一時停止、曲送りで、上に行ってリピートボタンとシャッフルボタンがあります。
リピートは開いているファイル形式がmidiファイルの時は一曲のリピート、m3uのプレイリストファイルの時は全曲リピートをしてくれます。
シャッフルはプレイリストの曲順をシャッフルさせるためのものなので、midiファイルのモードでは機能しません。
曲戻り/曲送りも基本的にはプレイリストファイルの曲順を制御するためのものですので、一曲のみのファイルモードでは選択してもその曲の最初に戻るくらいの機能です。
停止ボタンはその曲全体を停止し、最初の位置に戻ります。一時停止は一時停止した時間を記録し、その時点から再び再生をしてくれます。
ステータスバーとメニューバー
メニューバーにはファイルを開くためのファイルメニュー、プレイヤーの設定をするためのプレイヤーメニュー、ヘルプのダイアログを表示してくれるヘルプメニューがあります。
ステータスバーではデバイスがどのパートに割り当てられているか、どのポートが使用されているか、どのファイルが開かれているか、といった基本情報を取得することが可能になっています。
フィルター
フィルターのメニューでは、どのパートの再生をするかを選択できます。
例えばメロディのみの確認をしたい場合、ギターやベース、ドラムのチェックボックスをオフにすればその音が消えます。
曲名ラベル
現在再生中の曲の曲名が表示されます。
曲名を設定するには、midi編集ファイル等でメタ情報の曲名を設定する必要があります。
時間コントロールバー
バーをスライドすると、再生する位置を変更することができます。また右下のラベルでは、現時点の時間位置を確認することが可能です。
構造体
ここからは上記仕様のプレイヤーを実現するにあたって、どういうデータの構造を定義しているか、ご説明していきます。
以下、構造体で使用している様々な列挙体の定義です。
public enum PlayStatus
{
Playing,
Pausing,
Stopping
}
public enum Part
{
Melody,
Guitar,
Base,
Drum
};
public enum Device
{
MotorSolenoid,
Guitar,
FloppyDiscDrive,
Relay
}
//ノートの種類
public enum NoteType
{
Off,
On,
}
プレイヤーの再生状況を表すPlayStatus列挙体、パートとデバイスの情報を制御する構造体にて使用するPart列挙体、同じくデバイスの情報を選択するDevice列挙体、ノートの種類を定義しているNotoType列挙体があります。
//テンポを格納する構造体
public struct TempoData
{
public uint eventTime;
public float bpm;
};
public struct FileData
{
public string filePath, playlistPath, title;
}
public struct PartData
{
public Part playPart;
public Device playDevice;
public SerialPort port;
public int channel;
public int harmony;
public int[] timerIndex;
}
//ヘッダーチャンク解析用
public struct HeaderData
{
public byte[] chunkID;
public int dataLength;
public short format, tracks, division;
//トラックチャンク解析用
public struct TrackData
{
public byte[] chunkID, data;
public int dataLength;
};
public TrackData[] trackData;
};
//データ
public struct MidiData
{
public double delay;
public List<Part> playPart;
public string logTxt;
public List<byte>[] serialData, serialRstData;
public List<MidiMessage> midiMsg, midiRstMsg;
}
//デリゲートの構造体
public struct DelegateData
{
public delegate void PlayerButtonDelegate(object sender);
public delegate void TrackBarDelegate();
}
ヘッダーチャンクなど、midiファイルの情報を格納する構造体は前回のmidi解析について説明した記事にて詳細説明していますので、是非合わせてご覧ください。
ここで重要となるのは、解析したmidiデータを再生用に一つに統合するMidiDataという構造体です。この構造体をリスト化して、一曲でも膨大な量のノートの情報を格納しています。
イベントタイム
イベントタイムについては、TrackChunkAnalysisの関数に、デルタタイムからbpmを抽出する複雑なコードを記述する必要がありますが、それが割と難しいです。
今のところ動いているコードは以下の通りになりますが、一部調整が必要ですので、バグの含むコードとなってしまっていますのでご了承ください。
//bpmの抽出
for (int j = 0; j < tempoList.Count; j++)
{
int cnt = 0;
if (currentTime >= currentTempoDelay
&& currentTime + deltaTime <= tempoList.Sum(a => {
if (cnt++ <= j) return a.eventTime;
return 0;}))
{
for (int k = currentTempoIndex; ++k <= j;)
{
float bpm = tempoList[currentTempoIndex].bpm;
if (currentTime + deltaTime >= currentTempoDelay + tempoList[k].eventTime)
{
tmp += 60000.0 *
((currentTime > currentTempoDelay) ? currentTempoDelay + tempoList[k].eventTime - currentTime
: tempoList[k].eventTime) / bpm / headerChunk.division;
currentTempoDelay += (uint)tempoList[k].eventTime;
currentTempoIndex = k;
}
else{
tmp += 60000.0 *
((currentTime < currentTempoDelay) ? currentTime + deltaTime - currentTempoDelay : deltaTime)
/ bpm / headerChunk.division;
j = tempoList.Count;
break;
}
}
}
else if (currentTime + deltaTime > tempoList.Sum(a => a.eventTime))
{
tmp += 60000.0 * deltaTime / tempoList[tempoList.Count - 1].bpm / headerChunk.division;
break;
}
}
TrackDataAnalysisの関数に追加したbpmの抽出コードです。
currentTimeでイベントタイムを合算していき、現在のノートがミリ秒換算で何ミリ秒のデルタタイムになるのか、という情報が必要になります。
その実現のため、最初のfor文で現在位置のイベントタイムから、テンポの情報を取得し、前回の位置からのミリ秒を計算しています。
イベント情報はMidiData構造体のdelay変数に格納され、ノートを再生するときにこのdelayに基づいて時間制御を行っています。ノートの再生については次回の記事にてご説明させていただきます。
ノート情報
TrackDataAnalysis関数に、ノート情報をひとつのファイルに統合するstoreNoteDataという関数を呼び出すようにしています。
storeNoteDataはまず、受け取ったデータをリスト化していない単体のMidiData構造体変数にセットします。
int idx = 0, listIdx = Array.FindIndex(partList, a => a.channel == channel), partMax = 0;
if (listIdx < 0) return;
DataClass.MidiData data = new DataClass.MidiData();
data.playPart = new List<DataClass.Part>();
data.serialData = new List<byte>[2];
data.serialRstData = new List<byte>[2];
data.playPart.Add(partList[listIdx].playPart);
int dataIdx = 0;
if (partList[listIdx].playPart == DataClass.Part.Guitar) dataIdx = 1;
partMax = partList[listIdx].timerIndex.Length;
if (type == DataClass.NoteType.On)
{
for (int i = 0; i < partMax; i++)
{
if (partIdx[i] == 0)
{
partIdx[i] = laneIndex;
idx = i + 1;
break;
}
}
if (idx == 0) return;
int timerOffset = partList[listIdx].timerIndex[idx - 1] - 1;
timerOffset = (timerOffset < 5) ? timerOffset : timerOffset - 3;
data.logTxt = (timerOffset + 1).ToString() + ",ON," + laneIndex.ToString() + ",";
data.serialData[dataIdx] = new List<byte>() { (byte)(0x10 + timerOffset), laneIndex };
data.serialRstData[dataIdx] = new List<byte>() { (byte)(0x00 + timerOffset) };
data.midiMsg = new List<MidiMessage>() { MidiMessage.StartNote(laneIndex, velocity, channel+1) };
data.midiRstMsg = new List<MidiMessage>() { MidiMessage.StopNote(laneIndex, velocity, channel+1) };
}
else if (type == DataClass.NoteType.Off)
{
for (int i = 0; i < partMax; i++)
{
if (partIdx[i] == laneIndex)
{
partIdx[i] = 0;
idx = i + 1;
break;
}
}
if (idx == 0) return;
//ドラムのノーツオフはスルーする
if (partList[listIdx].playPart == DataClass.Part.Drum) return;
int timerOffset = partList[listIdx].timerIndex[idx - 1] - 1;
timerOffset = (timerOffset < 5) ? timerOffset : timerOffset - 3;
data.logTxt += (timerOffset + 1).ToString() + ",OFF,";
data.serialData[dataIdx] = new List<byte>() { (byte)(0x00 + timerOffset) };
data.serialRstData[dataIdx] = new List<byte>() { (byte)(0x00 + timerOffset) };
data.midiMsg = new List<MidiMessage>() { MidiMessage.StopNote(laneIndex, velocity, channel + 1) };
data.midiRstMsg = new List<MidiMessage>() { MidiMessage.StopNote(laneIndex, velocity, channel + 1) };
}
ノートオンの時にMidiMessageクラスを使用し、NAudio用にメッセージを格納しています。シリアル送信コマンドは非常に簡易的で、ノートオンだと最初の1バイトが1、あとに続く1バイトがハードで使用するタイマーのインデックス、そのあとに続く1バイトがノート番号になります。
シリアル、midiメッセージの各データにはRstというデータが設定されていますが、これは空のデータで、フィルターチェックボックスがオフの時にスイッチされ、音を消すための意味のないデータとなります。
while (true)
{
if (playData.Count < 1)
{
data.delay = delay;
currentDelay = delay;
pos = currentDelay;
playData.Add(data);
break;
}
else if (currentDelay + delay > pos)
{
if (index + 1 >= playData.Count || currentDelay + delay < pos + playData[index + 1].delay)
{
currentDelay += delay;
data.delay = currentDelay - pos;
pos = currentDelay;
playData.Insert(++index, data);
if (index < playData.Count - 1)
{
data = playData[index + 1];
data.delay -= playData[index].delay;
playData[index + 1] = data;
}
break;
}
else
{
pos += playData[++index].delay;
}
}
else if (currentDelay + delay < pos)
{
pos = delay;
currentDelay = delay;
//最初のデータを格納
data.delay = delay;
playData.Insert(index, data);
data = playData[index + 1];
data.delay -= playData[index].delay;
playData[index + 1] = data;
break;
}
else if (currentDelay + delay == pos)
{
currentDelay = pos;
//delayが0の時はリストの前要素を編集
data.delay = playData[index].delay;
data.logTxt = playData[index].logTxt + data.logTxt;
data.playPart.InsertRange(0, playData[index].playPart);
for(int i = 0; i < dataIdx; i++)
{
if (playData[index].serialData[i] != null)
{
if (data.serialData[i] != null)
{
data.serialData[i].InsertRange(0, playData[index].serialData[i]);
data.serialRstData[i].InsertRange(0, playData[index].serialRstData[i]);
}
else
{
data.serialData[i] = playData[index].serialData[i];
data.serialRstData[i] = playData[index].serialRstData[i];
}
}
}
data.midiMsg.InsertRange(0, playData[index].midiMsg);
data.midiRstMsg.InsertRange(0, playData[index].midiRstMsg);
playData[index] = data;
break;
}
次に格納した単体のMidiData構造体変数を、リスト化している生のデータに格納していきます。
イベントタイムの計算時に合算している前のノートまでのイベントタイム情報currentDelay変数と、現在のノートとの差分を表すdelay変数の和と、現在リストがどの位置にあるかを示すpos変数の差分を見ます。
コードから見て分かる通り、posより小さい場合は最初のデータとして格納、posと同じときは以前のデータを取り出し、そこに配列として追加する形を取っています。
こうすることで、同じ時間に再生する処理を複数回行うことが無く、一括でまとめて処理することが可能になります。
それ以外の時は基本的には同じ時間ではないので、新しくリスト要素を作成して格納しています。
まとめ
いかがだったでしょうか?
今回はEleMagPlayerの多機能が故に膨大なコードの一部で、データ処理までの解説と、ソフトウェア本体の仕様についてご説明させていただきました。
4年ほどかけて改良を重ねていたため、非常に膨大で複雑なコードとなっており、ご覧の通り一ページじゃ説明しきれません。
大変申し訳ありませんが、midiプレイヤー再生処理やUIのイベントについての話は次回させて頂きますので、是非チェックしてみてください!
EleMagPlayerのハードウェアでは、JLCPCBの基板をご提供いただき実現しています。ハードウェアの詳細については次々回の記事となってしまいますが、併せてご覧くださいませ。
それではまた!GooeBye!!