[C#] [NAudio] [MIDI] NAudio で MIDI ファイルを読み込んで再生する

# はじめに

私はMIDIファイルを再生するための処理を C# の基本ライブラリや NAudio から見つけられませんでした。
ですので、この記事はMIDIファイルの読み込みを NAudio にやらせそこから先は自力で行う、という処理について書いたものになります。
探せば確実に何かしらのライブラリが存在するであろう類の処理ではありますが、自身の勉強も兼ねて実装したので記事にした次第です。

## 環境

- Microsoft Visual Studio Community 2019 Version 16.11.3
- 対称のフレームワーク: .NET Framework 4.7.2
- C# 7.3

## 作るもの

NAudio で読み込んだMIDIファイルを再生するためのMIDIシーケンサクラス.
ただし、ファイルの頭から尻尾までの再生のみとし、部分再生やループには対応しない.

## 事前知識

MIDI ファイルはざっくり次のような作りになっている.

- MIDI ファイル
  - フォーマットタイプ(0, 1, 2 のいずれか)
  - 音の分解能(四分音符ひとつが何 Tick であるか)[^1]
  - トラック数
  - <トラック数>個のトラック
      - 0個以上のMIDIイベント
         - 直前のイベントからの経過 Tick 数
         - 実際の MIDI メッセージ (音を鳴らす/止める、テンポを変える、楽器を変える、等々)

[^1]: SMPTEコード(1秒は何Tickであるか)による指定もあるが、ここでは考えないものとする. NAudio も DeltaTicksPerQuarterNote というプロパティ名を利用しているから、普通は分解能が設定されるものと考えていそう.

実装上、音を鳴らす/止める、楽器を変える、等々のメッセージは適当な変換を施して出力ポートに送り込むだけでよいものの、再生速度、あるいは各イベントの発火タイミングはシーケンサーが正しく制御しなければならない.
このため、この記事の中で最も重要な話題はタイミングの計り方となる.

MIDI ファイルは複数のトラック(format=0 の場合はひとつのみ)を持っているが、テンポに関するデータは先頭のトラックにのみ置く、という決まりがあるので、先頭のトラックはコンダクター(=指揮者)トラックとも呼ばれる.
シーケンサーの仕事量を減らす上で最も重要でありがたいルールだと思う.

### MIDI ファイルに含まれるテンポ/時間の情報

テンポ/時間に関わる項目は、上記の「分解能」、「直前のイベントからの経過 Tick 数」、そして実際のMIDIメッセージとして現れる「テンポを変える」命令である.
NAudio は上記の加え、MIDIイベントに曲の先頭からの経過 Tick 数をプロパティとして持っているため、今回はこれも利用できる.
結果として、「直前のイベントからの経過 Tick 数」以外の3つをどうにかして再生速度、MIDIメッセージを送るタイミングを制御することとなる.

#### 曲のテンポの単位と考え方

普通の人が曲の速さについて言及する際の単位は BPM(Beats Per Minutes, 一分間にいくつの四分音符を鳴らせるか)だが、MIDI ファイルの中でテンポの設定をする場合は MPQ(Microseconds Per Quater note, 四分音符ひとつは何マイクロ秒であるか)である.
このふたつは以下の公式で互いに変換される.

```
MPQ * BPM = 60 000 000
```

ここに出てきている6千万という数字は、1[分] = 60[秒] = 60 000 [ミリ秒] = 60 000 000 [マイクロ秒] ということで出てきたものである.

#### 経過時間の単位と考え方

普通の人が曲の経過時間について言及する際の単位は時分秒、小節数、拍子などだと思われるが、MIDI ファイルの中では Tick数が用いられる.
Tick数はMIDI MPQ によって四分音符の数と相互に変換でき、四分音符の数はファイルの先頭で定義される分解能(=四分音符ひとつは何マイクロ秒であるか)によってマイクロ秒に変換できる.
例えば、Resolution(=分解能)=480、MPQ = 500 000、Ticks=1 650 として、ここから[ミリ秒]を求める場合は

```math
\begin{eqnarray}
[Number\ of\ quater\ notes] &=& Ticks / Resolution \\
&=& 1,650 / 480 \\
&=& 3.4375 \\
\\
[microseconds] &=& [Number\ of\ quater\ notes] * MPQ \\
&=& 3.4375 * 500,000 \\
&=& 1,718,750 \\
\\
[milliseconds] &=& [microseconds] / 1,000 \\
&=& 1,718,750 / 1,000 \\
&=& 1,718.750
\end{eqnarray}
```

のようになる.

#### 実装の方針

再生処理の先頭で `System.Diagnostics.Stopwatch` を動かし、曲の再生開始からの経過時間をミリ秒で取得できるようにする.
各トラックについて、何番目のイベントまで再生したかを変数として保持する.
全てのイベントが再生されたトラックの数を変数として保持する.
全てのトラックの再生が終わるまでループを回し続ける.
ループの先頭で経過時間(ミリ秒)を経過時間(Tick数)に変換し、イベントの持つ(Tick数)が経過時間(Tick数)以下であればそのイベントを再生する.

# 作る

### プロジェクトの作成と参照の追加

プロジェクトの種類は何でもよい(ライブラリが望ましい)とは思われるが、ここでは私が作業したものを記録の意味で書き残す

- 「新しいプロジェクトを追加」から「コンソールアプリ(.NET Framework)」を選択し「次へ」
- 適当なプロジェクト名を入力し「作成」
- 作成したプロジェクトを右クリックし「NuGetパッケージの管理(N)...」
- 「参照」タブを開き、検索窓に「Microsoft.Windows.SDK.Contracts」を入力. 出てきた「Microsoft.Windows.SDK.Contracts」を選択し「インストール」(バージョンは 2021-12-2現在で最新の 10.0.22000.196)
- 同じく検索窓に「NAudio」を入力. 出てきた「NAudio」を選択し「インストール」(バージョンは 2021-12-2現在で最新の 2.0.1)

### 曲のテンポデータを扱うクラスを作る

MIDI ファイルを正しく再生するためには、コンダクタートラックからテンポを設定するメッセージを抜き出し、正しく管理しなければならない.
つまり、どのタイミングで曲の速度がどう変化するのかを把握し、経過時間(ミリ秒)を経過時間(Tick数)に正しく変換する処理が必要となる.
まずはこの操作を実現するクラス `TempoData` を作成する.

```C#:TempoData.cs
using NAudio.Midi;
using System.Collections.Generic;
using System.Linq;

<名前空間は略>

// これは曲再生の際に用いるだけのクラスであるから、可視性は internal としている
internal class TempoData {
    // ファイル内にテンポ指定がない場合は 120 bpm = 500 000 mpq とする.
    private const int DEFAULT_MPQ = 500_000;
    // mpqStack[n] = n 個目の Set Tempo イベントが持つ MPQ
    private readonly int[] mpqStack;
    // cumulativeTicks[n] = 曲の先頭から、n 個目の Set Tempo イベントが発生するまでの時間 (Ticks)
    private readonly long[] cumulativeTicks;
    // cumulativeMicroseconds[n] = 曲の先頭から、n 個目の Set Tempo イベントが発生するまでの時間 (us)
    private readonly long[] cumulativeMicroseconds;

    // 分解能(四分音符ひとつは何 Tick であるか)
    public int Resolution { get; }

    // 再生に当たって、NAudio.Midi.MidiEventCollection は実質的に Midi ファイルとして見なせる
    public TempoData(MidiEventCollection midiEvents) {
        // Pulses Per Quater note
        int resolution = midiEvents.DeltaTicksPerQuarterNote;

        // TempoEvent のみを抜き出す (イベントは AbsoluteTime の昇順で並んでいる)
        // Set Tempo イベントは 0 番トラックにのみ現れるはずなので、midiEvents[0] のみから探す
        List<(long tick, TempoEvent message)> tempoEvents = midiEvents[0].Where(evt => evt is TempoEvent)
                            .Select(evt => (tick: evt.AbsoluteTime, message: (TempoEvent) evt))
                            .ToList();

        if ((tempoEvents.Count == 0) || (tempoEvents[0].tick != 0L)) {
            // 先頭にテンポ指定がない場合はデフォルト値を入れる
            tempoEvents.Insert(0, (0L, new TempoEvent(DEFAULT_MPQ, 0)));
        }

        this.mpqStack = new int[tempoEvents.Count];
        this.cumulativeTicks = new long[tempoEvents.Count];
        this.cumulativeMicroseconds = new long[tempoEvents.Count];

        // 0 Tick 時点での値を先に入れる
        mpqStack[0] = tempoEvents[0].message.MicrosecondsPerQuarterNote;
        cumulativeTicks[0] = cumulativeMicroseconds[0] = 0L;

        int pos = 1;
        foreach ((long tick, TempoEvent message) in tempoEvents.Skip(1)) {
            cumulativeTicks[pos] = tick;
            // deltaTick = 前回の Set Tempo からの時間 (Ticks)
            long deltaTick = tick - cumulativeTicks[pos - 1];
            mpqStack[pos] = message.MicrosecondsPerQuarterNote;
            // deltaMicroseconds = 前回の Set Tempo からの時間 (us)
            // <= MPQ = mpqStack[pos - 1] で deltaTick だけ経過している
            long deltaMicroseconds = TicksToMicroseconds(deltaTick, mpqStack[pos - 1], resolution);
            cumulativeMicroseconds[pos] = cumulativeMicroseconds[pos - 1] + deltaMicroseconds;

            ++pos;
        }

        this.Resolution = resolution;
    }// Constructor

    public long MicrosecondsToTicks(long us) {
        // 曲の開始から us[マイクロ秒] 経過した時点は、
        // 曲の開始から 何Ticks 経過した時点であるかを計算する

        int index = GetIndexFromMicroseconds(us);

        // 現在の MPQ は mpq である
        int mpq = mpqStack[index];

        // 直前のテンポ変更があったのは cumUs(マイクロ秒) 経過した時点であった
        long cumUs = cumulativeMicroseconds[index];
        // 直前のテンポ変更があったのは cumTicks(Ticks) 経過した時点であった
        long cumTicks = cumulativeTicks[index];

        // 直前のテンポ変更から deltaUs(マイクロ秒)が経過している
        long deltaUs = us - cumUs;
        // 直前のテンポ変更から deltaTicks(Ticks)が経過している
        long deltaTicks = MicrosecondsToTicks(deltaUs, mpq, Resolution);

        return cumTicks + deltaTicks;
    }

    private int GetIndexFromMicroseconds(long us) {
        // 指定された時間(マイクロ秒)時点におけるインデックスを二分探索で探す
        int lo = -1;
        int hi = cumulativeMicroseconds.Length;
        while ((hi - lo) > 1) {
            int m = hi - (hi - lo) / 2;
            if (cumulativeMicroseconds[m] <= us) lo = m;
            else hi = m;
        }
        return lo;
    }

    private static long MicrosecondsToTicks(long us, long mpq, int resolution) {
        // 時間(マイクロ秒)を時間(Tick)に変換する
        return us * resolution / mpq;
    }

    private static long TicksToMicroseconds(long tick, long mqp, int resolution) {
        // 時間(Tick)を時間(マイクロ秒)に変換する
        return tick * mqp / resolution;
    }

}// class TempoData
```

### シーケンサークラスを作る

`NAudio.Midi` にあるMIDIイベントクラスと `Windows.Devices.Midi` にあるMIDIメッセージクラスとの変換処理の記述がやや長いが、メインである Play() でやっていることはさほど難しくないはず.
(なお、申し訳ありませんが入念な調査や試験をしているわけではありませんので、勘違いによる実装/不具合があるかもしれません. 見つけた方は私を含めこの記事を参照する人のため教えていただけるととてもありがたいです.)

```C#:MidiSequencer.cs
using NAudio.Midi;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Devices.Midi;

<名前空間は略>

// MIDI イベント発生時の処理
public delegate void MidiEventHandler(MidiEvent e);

public class MidiSequencer {
    // MIDI 出力ポート
    private readonly IMidiOutPort outPort;
    // MIDI データ
    private readonly MidiEventCollection midiEvents;

    // MIDI イベント発生時の処理ハンドラー
    public event MidiEventHandler OnMidiEvent;

    public MidiSequencer(IMidiOutPort outPort, MidiEventCollection midiEvents) {
        this.outPort = outPort ?? throw new ArgumentNullException(nameof(outPort));
        this.midiEvents = midiEvents ?? throw new ArgumentNullException(nameof(midiEvents));
    }

    public async Task Play() {
        TempoData tempo = new TempoData(midiEvents);

        // 完了したトラック数
        int finishedTracks = 0;
        // 各トラックの再生済みイベント数
        int[] eventIndices = new int[midiEvents.Tracks];

        // イベントがひとつもないトラックは始まる前から終わってる.
        for (int i = 0; i < midiEvents.Tracks; ++i) {
            IList<MidiEvent> currentTrack = midiEvents[i];
            if (currentTrack.Count == 0) ++finishedTracks;
        }

        // ここから曲の再生を開始する
        Stopwatch stopWatch = new Stopwatch();
        stopWatch.Start();

        while (finishedTracks != midiEvents.Tracks) {
            // 経過時間 (microseconds)
            long elapsed = stopWatch.ElapsedMilliseconds * 1000L;
            // 経過時間 (Ticks)
            long elapsedTicks = tempo.MicrosecondsToTicks(elapsed);

            //  elapsedTicks が負の場合はバグか何か
            if (elapsedTicks < 0) throw new InvalidProgramException($"elapsedTicks = {elapsedTicks} < 0 !!");

            for (int i = 0; i < midiEvents.Tracks; ++i) {
                IList<MidiEvent> currentTrack = midiEvents[i];
                // このトラックについて再生が終了していれば次のトラックへ
                if (eventIndices[i] == currentTrack.Count) continue;

                while (currentTrack[eventIndices[i]].AbsoluteTime <= elapsedTicks) {
                    // 再生されるべき時刻(AbsoluteTime) を過ぎていれば再生する
                    MidiEvent currentEvent = currentTrack[eventIndices[i]];

                    // イベントの通知
                    OnMidiEvent?.Invoke(currentEvent);

                    // 出力ポートに送信するオブジェクトに変換する(送信できないイベントの場合 null が返される)
                    IMidiMessage messageToSend = ConvertToMidiMessageOrNull(currentEvent);
                    if(messageToSend != null) outPort.SendMessage(messageToSend);

                    // 消費済みイベント数をインクリメント
                    ++eventIndices[i];
                    if (eventIndices[i] == currentTrack.Count) {
                        // トラック内の全イベントが消化されていれば完了トラック数をインクリメントし、このトラックについてのループを抜ける
                        ++finishedTracks;
                        break;
                    }
                }
            }
            // 1ms のディレイを入れる
            await Task.Delay(1);
        }
    }// #Play()

    /// <summary>
    /// NAudio が定義したMIDIイベントオブジェクトを、Microsoft が定義したものに変換する.
    /// </summary>
    /// <param name="midiEvent">NAudio が定義したMIDIイベントオブジェクト</param>
    /// <returns>対応するクラスが存在する場合 Microsoft が定義したメッセージ. さもなくば null</returns>
    private IMidiMessage ConvertToMidiMessageOrNull(MidiEvent midiEvent) {
        switch (midiEvent.CommandCode) {
        case MidiCommandCode.NoteOff: {// 8n
            NoteEvent @event = midiEvent as NoteEvent;
            return new MidiNoteOffMessage((byte) @event.Channel, (byte) @event.NoteNumber, (byte) @event.Velocity);
        }
        case MidiCommandCode.NoteOn: {// 9n
            NoteOnEvent @event = midiEvent as NoteOnEvent;
            return new MidiNoteOnMessage((byte) @event.Channel, (byte) @event.NoteNumber, (byte) @event.Velocity);
        }
        case MidiCommandCode.KeyAfterTouch: {// An
            NoteEvent @event = midiEvent as NoteEvent;
            return new MidiPolyphonicKeyPressureMessage((byte) @event.Channel, (byte) @event.NoteNumber, (byte) @event.Velocity);
        }
        case MidiCommandCode.ControlChange: {// Bn
            ControlChangeEvent @event = midiEvent as ControlChangeEvent;
            return new MidiControlChangeMessage((byte) @event.Channel, (byte) @event.Controller, (byte) @event.ControllerValue);
        }
        case MidiCommandCode.PatchChange: {// Cn
            PatchChangeEvent @event = midiEvent as PatchChangeEvent;
            return new MidiProgramChangeMessage((byte) @event.Channel, (byte) @event.Patch);
        }
        case MidiCommandCode.ChannelAfterTouch: {// Dn
            ChannelAfterTouchEvent @event = midiEvent as ChannelAfterTouchEvent;
            return new MidiChannelPressureMessage((byte) @event.Channel, (byte) @event.AfterTouchPressure);
        }
        case MidiCommandCode.PitchWheelChange: {// En
            PitchWheelChangeEvent @event = midiEvent as PitchWheelChangeEvent;
            return new MidiPitchBendChangeMessage((byte) @event.Channel, (byte) @event.Pitch);
        }
        case MidiCommandCode.Sysex: {// F0
            SysexEvent @event = midiEvent as SysexEvent;
            MemoryStream ms = new MemoryStream();
            BinaryWriter bw = new BinaryWriter(ms);
            long _ = 0L;
            @event.Export(ref _, bw);
            bw.Flush();
            return new MidiSystemExclusiveMessage(ms.ToArray().AsBuffer());
        }
        case MidiCommandCode.Eox: // F7
        case MidiCommandCode.TimingClock: // F8
        case MidiCommandCode.StartSequence: // FA
        case MidiCommandCode.ContinueSequence: // FB
        case MidiCommandCode.StopSequence: // FC
        case MidiCommandCode.AutoSensing: // FE
        case MidiCommandCode.MetaEvent: // FF
            // これらは対応する IMidiMessage が存在しない
            return null;
        default: throw new InvalidOperationException($"Unknown MidiCommandCode: {midiEvent.CommandCode}");
        }
    }// #ConvertToMidiMessageOrNull(MidiEvent)
}// class MidiSequencer
```

# 使う

### MIDI ファイルの用意

今回は動作確認のため、 MuseScore 3 を利用して以下のような楽譜を用意し MIDI 出力したものを使用した:

- 楽譜その1:

![楽譜その1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2329888/b69ccdda-b1f5-10fd-8a84-47cfe445c1b2.png)

- 楽譜その2:

![楽譜その2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2329888/ae0c3e5c-7af2-584e-07c5-51d84af89387.png)

### 利用側クラスを作成する

```C#:Program.cs
class Program {

    // 行儀は悪いが、フィールドに MIDI ファイルが持つ分解能を保持する
    static int resolution;

    static void Main(string[] args) {
        // 指定した MIDI ファイルを再生する
        string filename = @"C:\sandbox\Test_-_Chord.mid";
        Task task = PlayMidi(filename);
        task.Wait();

        // コンソール画面が閉じるのを防ぐ
        Console.ReadLine();
    }

    // MIDI ファイルを再生する
    private static async Task PlayMidi(string filename) {
        Task<IMidiOutPort> port = PrepareMidiOutPort();

        MidiFile midiFile = new MidiFile(filename);

        // 行儀は悪いが、Sequencer_OnMidiEvent から使う変数をここでフィールドに代入する
        Program.resolution = midiFile.DeltaTicksPerQuarterNote;

        // シーケンサーを作成する
        MidiSequencer sequencer = new MidiSequencer(port.Result, midiFile.Events);
        sequencer.OnMidiEvent += Sequencer_OnMidiEvent;

        // 実際に再生する
        await sequencer.Play();
    }

    // MIDI 出力ポートを取得する
    private static async Task<IMidiOutPort> PrepareMidiOutPort() {
        string selector = MidiOutPort.GetDeviceSelector();
        DeviceInformationCollection deviceInformationCollection = await DeviceInformation.FindAllAsync(selector);
        if (deviceInformationCollection?.Count > 0) {
            // collection has items
            string id = deviceInformationCollection[0].Id;
            IMidiOutPort outPort = await MidiOutPort.FromIdAsync(id);
            return outPort;
        } else {
            // collection is null or empty
            throw new InvalidOperationException($"No MIDI device for {selector}");
        }
    }

    // MIDI イベントの処理ハンドラー
    private static void Sequencer_OnMidiEvent(MidiEvent e) {
        // 今回は 4/4 拍子であることが分かっているので 4 で割っているが、
        // MIDI ファイル内には拍子情報が必須ではないため実際に任意の曲で小節数を出そうとすると難しい.
        // ちなみに、拍子情報がある場合は MetaEvent として現れる.
        long measure = (e.AbsoluteTime / Program.resolution / 4) + 1;

        // 次の内容で出力する: [小節数 : 経過時間(Ticks) : イベントの内容]
        Console.WriteLine($"{measure,3} : {e.AbsoluteTime,5} : {e}");
    }

}// class Program
```

### 動作結果
上のプログラムを動かすと、曲が再生され、標準出力は以下のようになった(楽譜その1の場合):

```
  1 :     0 : 0 TimeSignature 4/4 TicksInClick:24 32ndsInQuarterNote:8
  1 :     0 : 0 KeySignature -2 0
  1 :     0 : 0 SetTempo 127bpm (468751)
  1 :     0 : 0 ControlChange Ch: 1 Controller ResetAllControllers Value 0
  1 :     0 : 0 PatchChange Ch: 1 Acoustic Grand
  1 :     0 : 0 ControlChange Ch: 1 Controller MainVolume Value 100
  1 :     0 : 0 ControlChange Ch: 1 Controller Pan Value 64
  1 :     0 : 0 ControlChange Ch: 1 Controller 91 Value 0
  1 :     0 : 0 ControlChange Ch: 1 Controller 93 Value 0
  1 :     0 : 0 MidiPort 00
  1 :     0 : 0 NoteOn Ch: 1 A#4 Vel:80 Len: 455
  1 :     0 : 0 NoteOn Ch: 1 D5 Vel:80 Len: 455
  1 :     0 : 0 NoteOn Ch: 1 F5 Vel:80 Len: 455
  1 :   455 : 455 NoteOn Ch: 1 A#4 Vel:0 (Note Off)
  1 :   455 : 455 NoteOn Ch: 1 D5 Vel:0 (Note Off)
  1 :   455 : 455 NoteOn Ch: 1 F5 Vel:0 (Note Off)
  1 :   480 : 480 NoteOn Ch: 1 C5 Vel:80 Len: 455
  1 :   480 : 480 NoteOn Ch: 1 D#5 Vel:80 Len: 455
  1 :   480 : 480 NoteOn Ch: 1 G#5 Vel:80 Len: 455
  1 :   935 : 935 NoteOn Ch: 1 C5 Vel:0 (Note Off)
  1 :   935 : 935 NoteOn Ch: 1 D#5 Vel:0 (Note Off)
  1 :   935 : 935 NoteOn Ch: 1 G#5 Vel:0 (Note Off)
  1 :   960 : 960 NoteOn Ch: 1 C#5 Vel:80 Len: 455
  1 :   960 : 960 NoteOn Ch: 1 F5 Vel:80 Len: 455
  1 :   960 : 960 NoteOn Ch: 1 G#5 Vel:80 Len: 455
  1 :  1415 : 1415 NoteOn Ch: 1 C#5 Vel:0 (Note Off)
  1 :  1415 : 1415 NoteOn Ch: 1 F5 Vel:0 (Note Off)
  1 :  1415 : 1415 NoteOn Ch: 1 G#5 Vel:0 (Note Off)
  1 :  1440 : 1440 NoteOn Ch: 1 D#5 Vel:80 Len: 455
  1 :  1440 : 1440 NoteOn Ch: 1 G5 Vel:80 Len: 455
  1 :  1440 : 1440 NoteOn Ch: 1 A#5 Vel:80 Len: 455
  1 :  1895 : 1895 NoteOn Ch: 1 D#5 Vel:0 (Note Off)
  1 :  1895 : 1895 NoteOn Ch: 1 G5 Vel:0 (Note Off)
  1 :  1895 : 1895 NoteOn Ch: 1 A#5 Vel:0 (Note Off)
  2 :  1920 : 1920 NoteOn Ch: 1 E5 Vel:80 Len: 455
  2 :  1920 : 1920 NoteOn Ch: 1 G#5 Vel:80 Len: 455
  2 :  1920 : 1920 NoteOn Ch: 1 B5 Vel:80 Len: 455
  2 :  2375 : 2375 NoteOn Ch: 1 E5 Vel:0 (Note Off)
  2 :  2375 : 2375 NoteOn Ch: 1 G#5 Vel:0 (Note Off)
  2 :  2375 : 2375 NoteOn Ch: 1 B5 Vel:0 (Note Off)
  2 :  2400 : 2400 NoteOn Ch: 1 F#5 Vel:80 Len: 455
  2 :  2400 : 2400 NoteOn Ch: 1 A#5 Vel:80 Len: 455
  2 :  2400 : 2400 NoteOn Ch: 1 C#6 Vel:80 Len: 455
  2 :  2855 : 2855 NoteOn Ch: 1 F#5 Vel:0 (Note Off)
  2 :  2855 : 2855 NoteOn Ch: 1 A#5 Vel:0 (Note Off)
  2 :  2855 : 2855 NoteOn Ch: 1 C#6 Vel:0 (Note Off)
  2 :  2880 : 2880 NoteOn Ch: 1 G5 Vel:80 Len: 455
  2 :  2880 : 2880 NoteOn Ch: 1 B5 Vel:80 Len: 455
  2 :  2880 : 2880 NoteOn Ch: 1 D6 Vel:80 Len: 455
  2 :  3335 : 3335 NoteOn Ch: 1 G5 Vel:0 (Note Off)
  2 :  3335 : 3335 NoteOn Ch: 1 B5 Vel:0 (Note Off)
  2 :  3335 : 3335 NoteOn Ch: 1 D6 Vel:0 (Note Off)
  2 :  3360 : 3360 NoteOn Ch: 1 A5 Vel:80 Len: 455
  2 :  3360 : 3360 NoteOn Ch: 1 C#6 Vel:80 Len: 455
  2 :  3360 : 3360 NoteOn Ch: 1 E6 Vel:80 Len: 455
  2 :  3815 : 3815 NoteOn Ch: 1 A5 Vel:0 (Note Off)
  2 :  3815 : 3815 NoteOn Ch: 1 C#6 Vel:0 (Note Off)
  2 :  3815 : 3815 NoteOn Ch: 1 E6 Vel:0 (Note Off)
  3 :  3840 : 3840 NoteOn Ch: 1 B4 Vel:80 Len: 1823
  3 :  3840 : 3840 NoteOn Ch: 1 F#5 Vel:80 Len: 1823
  3 :  3840 : 3840 NoteOn Ch: 1 B5 Vel:80 Len: 1823
  3 :  3840 : 3840 NoteOn Ch: 1 F#6 Vel:80 Len: 1823
  3 :  3840 : 3840 NoteOn Ch: 1 B6 Vel:80 Len: 1823
  3 :  5663 : 5663 NoteOn Ch: 1 B4 Vel:0 (Note Off)
  3 :  5663 : 5663 NoteOn Ch: 1 F#5 Vel:0 (Note Off)
  3 :  5663 : 5663 NoteOn Ch: 1 B5 Vel:0 (Note Off)
  3 :  5663 : 5663 NoteOn Ch: 1 F#6 Vel:0 (Note Off)
  3 :  5663 : 5663 NoteOn Ch: 1 B6 Vel:0 (Note Off)
  3 :  5664 : 5664 EndTrack
```

# 最後に

ここまで読んでいただいてありがとうございます。
拙い記事ではございましたが、C# で MIDI を扱おうとする方、MIDI の勉強をなさる方のお役に立ちましたら幸いと存じます。
私の環境では使われる MIDI シンセサイザーが Windows 標準のものであるため音質などは何とか再生できるといった風情でしたが、`MidiSequencer` に渡す `IMidiOutport` がきちんとしたものであれば実用に耐える音が再生できるのではないでしょう。多分。
C# には Midi シンセサイザも見当たらなかったので、自分で使う分にはPCの方にインストールしてしまうのが簡単なのでしょうか。

以上、改めてありがとうございました。