自分らしいmidiプレイヤーを作りたい!midiを使用して自作楽器の制御がしたい!
以前に引き続き、今回はmidiプレイヤーの解説、特に以前格納したノートデータを使用した再生処理とUIイベントの話について詳述していきたいと思います。
皆さんこんにちは、いかがお過ごしでしょうか。
私はもうすぐMaker Faire Kyoto 2023が開催され、準備に急いでおります。非常に慌ただしくお問い合わせの対応にも時間を要すると思いますが、何卒応援していただけると幸いに御座います。
Ele Mag Playerにて基板提供をJLCPCB様に頂いてこの企画は実現しています。あわせてご覧くださいませ。
シリアル送信/再生部分
早速ですが、再生処理について記述していきます。
まず、再生に用いたタイマーは制度が良いクラスを作成されていた、以下の記事に書かれていたものをお借りしました。ありがとうございます。
[C#] ミリ秒単位のタイマーを作成する
上記タイマーを使用し、1msごとに処理を起こします。そのタイマーのコールバック関数内で以下の再生処理を行うコードを記述しています。
string[] serialData = new string[2] { "", "" };
List<uint> msgs = new List<uint>();
double currentTime = Math.Round(DateTime.Now.Ticks / 10000.0);
if (playData == null || playData.Count < 1) return;
if (timIdx < playData.Count)
{
timePos = currentTime - startTime + timeOffset;
if (timePos - lastTime >= Math.Floor(playData[timIdx].delay))
{
for(int i = 0; i < playData[timIdx].midiMsg.Count; i++)
{
int idx = (int)playData[timIdx].playPart[i];
if (filterCheckBox.GetItemChecked(idx))
{
if (partList[idx].port.IsOpen)
partList[idx].port.Write(playData[timIdx].serialData[(idx != 1) ? 0 : 1].ToArray(),
0, playData[timIdx].serialData[(idx != 1) ? 0 : 1].Count);
midi.Send(playData[timIdx].midiMsg[i].RawData);
}
else if (!filterCheckBox.GetItemChecked(idx))
{
if (partList[idx].port.IsOpen)
partList[idx].port.Write(playData[timIdx].serialRstData[(idx != 1) ? 0 : 1].ToArray(),
0, playData[timIdx].serialRstData[(idx != 1) ? 0 : 1].Count);
midi.Send(playData[timIdx].midiRstMsg[i].RawData);
}
}
timIdx++;
lastTime = timePos;
}
Invoke(new DataClass.DelegateData.TrackBarDelegate(UpDateTrackBar));
}
else
{
timer.Stop();
Invoke(new DataClass.DelegateData.PlayerButtonDelegate(PlayCtl), NextButton);
}
現在時刻をcurrentTime変数に格納し、開始時間や時間のオフセット(一時停止や時間調整のトラックバーで制御)との差分を取ったtimePos変数と、前回の更新時間のlastTime変数との差分がMidiData型リストの現在位置のdelayを上回っているか判定します。
上回っていた場合、再生する4パートでfor文をループします。UIのフィルターチェック状況を判定し、チェックオンのパートに関しては再生処理を行います。
再生処理単体は非常にシンプル。まず、NAudio.MidiクラスのsendメソッドにmidiMessageを渡してあげるだけ。これでノートをオン/オフしてくれます。
またシリアル通信の処理はきちんとポートが解放されているかどうかを判定してから行わないと例外発生してしまうので、しています。
再生したらlastTimeの更新処理を行い、別スレッドのコントロールにアクセスするためデリゲートを使ってUIの更新処理も行っています。
ファイル処理
以下、ファイルメニューのファイル処理です。
//ファイルメニューのクリックイベント
private void OpenFileMenuItem_Click(object sender, EventArgs e)
{
fileList.Clear();
ToolStripMenuItem[] menuItem = new ToolStripMenuItem[] {OpenMidiFileMenuItem, OpenMidiFileMenuItem};
string ctlName = ((ToolStripMenuItem)sender).Name;
openFileDialog.Filter = (ctlName == menuItem[0].Name) ? "midiファイル(*.mid)|*.mid" : "プレイリストファイル(*.m3u) | *.m3u";
if (openFileDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
if (ctlName == menuItem[0].Name)
{
DataClass.FileData data = new DataClass.FileData();
data.filePath = openFileDialog.FileName;
data.title = "";
fileList.Add(data);
FileLabel.Text = "MidiFile : " + Path.GetFileNameWithoutExtension(data.filePath);
}
else
{
string playlistPath = openFileDialog.FileName;
PlaylistLabel.Text = "MidiPlaylist : " + Path.GetFileNameWithoutExtension(playlistPath);
FileEncoder(playlistPath);
if (isShuffle)
{
IndexRandom();
}
}
playData.Clear();
tempoList.Clear();
HeaderChunkAnalysis();
allTime = playData.Sum(a => a.delay);
trackBar.Maximum = (int)Math.Round(allTime);
maxTime = TimeSpan.FromMilliseconds(allTime);
LabelTime.Text = changeTime.ToString(@"mm\:ss") + "/" + maxTime.ToString(@"mm\:ss");
}
}
Formアプリでめちゃくちゃ秀逸なOpenFileDialogを用いてファイルを開いています。フィルターきちんとかけてます。
ファイルのデータは専用の構造体のリスト変数に格納しています。
各プレイヤーコントロールのイベント
再生や停止のプレイヤーボタンをクリックしたときに発生するコントロールイベントです。
//以下各種音楽再生用ボタンクリックイベント
private void PlayerButton_Click(object sender, EventArgs e)
{
sender = (PictureBox)sender;
if(sender != PlayButton)
{
PlayButton.Image = System.Drawing.Image.FromFile(@".\IMG\play_icon.png");
menuStrip1.Enabled = true;
changeTime = new TimeSpan(0, 0, 0);
StopTimer();
//データをリセット
timePos = 0;
timIdx = 0;
trackBar.Value = 0;
startTime = 0;
timeOffset = 0;
lastTime = 0;
for (int i = 0; i < fileList.Count; i++)
{
DataClass.FileData data = new DataClass.FileData();
data.playlistPath = fileList[i].playlistPath;
data.filePath = fileList[i].filePath;
data.title = "";
fileList[i] = data;
}
changeTime = new TimeSpan(0, 0, 0);
status = DataClass.PlayStatus.Stopping;
}
if(sender != StopButton)
{
if (sender != PlayButton)
{
fileCounter = (sender == NextButton) ? (fileCounter + 1 >= fileList.Count) ? 0 : fileCounter + 1
: (fileCounter <= 1) ? 0 : fileCounter - 1;
ResetData();
if (fileCounter == 0 && sender == NextButton && !isRepeat)
return;
}
if (status != DataClass.PlayStatus.Playing)
{
PlayButton.Image = System.Drawing.Image.FromFile(@".\IMG\pause_icon.png");
menuStrip1.Enabled = false;
StartTimer();
status = DataClass.PlayStatus.Playing;
}
else if(status == DataClass.PlayStatus.Playing)
{
PlayButton.Image = System.Drawing.Image.FromFile(@".\IMG\play_icon.png");
menuStrip1.Enabled = true;
StopTimer();
status = DataClass.PlayStatus.Pausing;
}
}
}
最初にsenderでボタンの名前を判別します。
スキップはリターンは、ファイルのリストの現在位置を表すfileCounter変数の制御を行います。リピートモードの判定を行い、リピートならそのまま下におりて自動で再生処理をします。
補足ですが、画像をフリー素材のアイコンからとってきて、pictureboxをボタン代わりにするとUIが凝れて非常に便利です。参考までに
ランダム/リピート
プレイヤーボタンのコントロールイベントと同様、こちらもsenderで呼び出し元を取得します。
private void PlayControlButtonClick(object sender, EventArgs e)
{
if((PictureBox)sender == RepeatButton)
{
isRepeat = !isRepeat;
if (isRepeat)
RepeatButton.Image = System.Drawing.Image.FromFile(@".\IMG\repeat_icon_enable.png");
else
RepeatButton.Image = System.Drawing.Image.FromFile(@".\IMG\repeat_icon_disable.png");
}
if((PictureBox)sender == ShuffleButton)
{
isShuffle = !isShuffle;
if (isShuffle)
{
ShuffleButton.Image = System.Drawing.Image.FromFile(@".\IMG\random_icon_enable.png");
IndexRandom();
}
else
{
if (fileList.Count > 0 && fileList[0].playlistPath != null)
{
string playlist = fileList[0].playlistPath;
fileList.Clear();
FileEncoder(playlist);
}
ShuffleButton.Image = System.Drawing.Image.FromFile(@".\IMG\random_icon_disable.png");
}
}
}
まずはUIの画像を更新します。
その後、リピートはリピート状態を記憶する変数の状態を更新するだけですが、ランダムの場合はファイルのリストをシャッフルしてくれる関数を呼び出しています。
//ランダムなインデックス作成(シャッフル時)
private void IndexRandom()
{
Random rnd = new Random();
for(int i = 0; i < fileList.Count; i++)
{
int random = rnd.Next(i, fileList.Count);
DataClass.FileData tmp = fileList[i];
fileList[i] = fileList[random];
fileList[random] = tmp;
}
}
RandomクラスのNextメソッドを使用したシンプルなシャッフルです。
時間制御
最後に、トラックバーや一時停止による時間制御の仕組みを解説していきます。
private void trackBar_MouseDown(object sender, MouseEventArgs e)
{
StopTimer();
}
private void trackBar_MouseUp(object sender, MouseEventArgs e)
{
FindTimeIdx();
if (status == DataClass.PlayStatus.Playing)
{
StartTimer();
}
}
private void trackBar_Scroll(object sender, EventArgs e)
{
timePos -= timeOffset;
timeOffset = trackBar.Value - timePos;
timePos += timeOffset;
changeTime = new TimeSpan(0, 0, 0, 0, (int)timePos);
if (changeTime < maxTime)
{
LabelTime.Text = changeTime.ToString(@"mm\:ss") + "/" + maxTime.ToString(@"mm\:ss");
}
else
{
LabelTime.Text = maxTime.ToString(@"mm\:ss") + "/" + maxTime.ToString(@"mm\:ss");
}
}
TrackBarの仕様として、スクロールしている間ずっとScrollイベントが呼ばれてしまうといった厄介な問題があります。
そこで、上記コードのようにMouseUPとMouseDownによってトラックバー調整の前と後の処理を分けています。これで動作は軽くなり、コードもシンプルに見えます。
スクロールイベントでは主に、時間ラベルの更新処理と、先ほど再生処理の再生位置を示す変数に加算するtimeOffsetの値を調整しています。
マウスアップすると、その時点のdelay情報から全ノートデータの格納されているリストの現在位置を示すインデックスの変数を更新する関数を呼び出しています。
これによって、トラックバーによる時間の更新処理を実現しています。
まとめ
いかがでしたか?
今回は前回に引き続き、プレイヤーのソースコードを解説していきました。実際アプリを作成するときは、midiファイルの解析だけでなく、イベント処理や時間の制御、シリアル通信の設計など考えないといけないことが膨大です。
そのため、このようなアプリの作成は大変ですが非常に良い勉強になります。
次回はいよいよ、ハードウェアの解説をしていこうかと思います!実際にJLCPCBに発注した基板を徹底的に解説していきますので、随時チェックしてみてください。
最後までご覧いただきありがとうございました。次の記事で会いましょう。それではまた!GoodBye!!