MIDIファイルの入出力処理をC#で書こうと思ったんですが、まずMIDIのファイルフォーマット仕様が結構細かい所でややこしくてはまったので、整理しておきます。
基本的には以下の「MIDI 1.0 規格書」に書いてありますが、長すぎて読むの大変なので。
https://amei.or.jp/midistandardcommittee/MIDI1.0.pdf
MIDIファイル書き出し処理を書く上での注意点
私がMIDIファイル書き出しプログラムを書いた時にはまったことは、他の方もはまりそうな注意点となりそうなので、最初に列挙しておきます。
- MIDIファイル内はビッグエンディアンである
- デルタタイムは可変長バイトである
- トラックチャンクには一番最後にトラック終了を表すメタイベントを格納する
この3点だけです。後はファイルフォーマット仕様に従って記述していけば書き出し処理は書けると思います。
MIDIファイルフォーマット全体像
超ざっくり
ヘッダーチャンク |
トラックチャンク |
トラックチャンク |
... |
もう少し詳細
|
|||
|
|||
|
|||
... |
更に詳細
|
|||||
|
|||||
|
|||||
... |
ヘッダーチャンク
ヘッダーチャンクの構造です。バイト列は16進数表記です。
中身 | 説明 |
---|---|
4D 54 68 64 ("MThd" で固定) |
ヘッダーチャンク識別子 |
00 00 00 06 (6バイト) |
ヘッダーチャンクサイズ (6固定でOK) |
00 01 (フォーマットが1) |
MIDIフォーマット (0か1か2。とりあえず1にすればOK) |
00 11 (トラック数が 17) |
トラック数 |
01 E0 (4分音符のティック数が480) |
時間単位 (変更できるが基本 01 E0 固定でOK) |
トラックチャンク
トラックチャンクの構造です。バイト列は16進数表記です。
中身 | 説明 | |||
---|---|---|---|---|
4D 54 72 6B ("MTrk" で固定) |
トラックチャンク識別子 | |||
00 00 00 1E (1Eの場合、この後のMIDIイベントが30byteあるという意味) |
トラックデータサイズ | |||
|
MIDIイベント | |||
|
MIDIイベント | |||
... | ||||
00 FF 2F 00 | トラック終了イベント |
MIDIイベントの例
以下はMIDIイベントの例です。これ以外にもたくさんありますが、とりあえずこれだけあれば音符は書き出せます。他のイベントについては冒頭で触れたMIDIの規格書の104ページ目付近から一覧が掲載されてますのでそちらを参照してください。
イベント | バイト列の構造と意味 | ||||
---|---|---|---|---|---|
トラック名 |
|
||||
Bank MSB (音色グループ変更) |
|
||||
Bank LSB (音色グループ変更) |
|
||||
音色変更 |
|
||||
エクスプレッション変更 |
|
||||
ノートオン |
|
||||
ノートオフ |
|
デルタタイムの仕様
MIDIファイル内の各MIDIイベントのタイムスタンプは、デルタタイム方式で表されます。デルタタイムは、前のMIDIイベントからの時間差分を示す相対的な時間単位であり、次のMIDIイベントの実行までの時間を示します。デルタタイムは可変長整数(Variable Length Quantity)の形式で表され、1バイトから4バイトまでの可変長の数値で表されます。
デルタタイムの最初のバイトは、7ビットがデータ値、1ビットが継続ビット(コントロールビット)となります。継続ビットが1の場合、次のバイトがデータ値の継続部分であることを示します。継続ビットが0の場合、そのバイトがデータ値の最後のバイトであることを示します。
デルタタイムは、トラックチャンク内の全てのMIDIイベントに適用されます。
デルタタイムの計算例
16進数の 83 60 というバイト列はデルタタイム 480 を示します。これを例に説明します。
83 60 をビット表記にすると以下になります。
8 3: 1000 0011
6 0: 0110 0000
83 の先頭ビットが継続ビットです。1となっているので、2バイト目の 60 もデルタタイムとして扱うことになります。60 の先頭ビットは 0 なので3バイト目には続きません。
83 60 の値ビット列(先頭を除く7ビット)は以下になります。
8 3: 000 0011
6 0: 110 0000
これらを繋げると以下になります。
000 0011 110 0000
111100000 は10進数で 480 です。よって、バイト列 83 60 はデルタタイム 480 を示します。
MIDIファイルを表すC#コード例
以下はMIDIファイルをシリアライズするために書いたC#のMidiFileクラスです。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading.Channels;
using System.Windows.Controls.Primitives;
public class MidiFile
{
public MidiFile()
{
}
// ヘッダチャンク
public int Format { get; set; } = 1;
public int TicksPerBeat { get; set; } = 480;
// トラックチャンク
public List<MidiTrack> Tracks { get; } = new List<MidiTrack>();
// ヘッダチャンクをバイト列に変換する
public byte[] GetHeaderChunkBytes()
{
List<byte> bytes = new List<byte>();
// ヘッダーチャンク識別子
bytes.AddRange(new byte[] { 0x4D, 0x54, 0x68, 0x64 });
// データ長
bytes.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x06 });
// フォーマット
bytes.AddRange(GetBytes((short)Format, 2));
// トラック数
bytes.AddRange(GetBytes((short)Tracks.Count, 2));
// 時間分解能
bytes.AddRange(GetBytes((short)TicksPerBeat, 2));
return bytes.ToArray();
}
private byte[] GetBytes(int value, int byteCount)
{
List<byte> bytes = new List<byte>(BitConverter.GetBytes((short)value));
for (int i = bytes.Count; i < byteCount; ++i)
{
bytes.Insert(0, 0x0);
}
bytes.Reverse();
return bytes.ToArray();
}
// トラックチャンクをバイト列に変換する
private byte[] GetTrackChunkBytes()
{
List<byte> bytes = new List<byte>();
foreach (var midiTrack in Tracks)
{
bytes.AddRange(midiTrack.ToBytes());
}
return bytes.ToArray();
}
// MIDIファイル全体をバイト列に変換する
public byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.AddRange(GetHeaderChunkBytes());
bytes.AddRange(GetTrackChunkBytes());
return bytes.ToArray();
}
}
public class MidiTrack
{
protected List<MidiEvent> events = new List<MidiEvent>();
public void AddEvent(MidiEvent midiEvent)
{
events.Add(midiEvent);
}
public virtual byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
// Track Chunk Header
bytes.AddRange(Encoding.ASCII.GetBytes("MTrk"));
bytes.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // データ長(後で書き換える)
int headerSize = bytes.Count;
// Track Events
foreach (MidiEvent midiEvent in events)
{
int deltaTime = midiEvent.DeltaTime;
byte[] deltaTimeBytes = MidiUtility.EncodeVariableLengthValue(deltaTime);
bytes.AddRange(deltaTimeBytes);
bytes.AddRange(midiEvent.ToBytes());
}
// End of Track Event
byte[] endOfTrackEvent = new byte[] { 0x00, 0xFF, 0x2F, 0x00 };
bytes.AddRange(endOfTrackEvent);
int trackLength = bytes.Count - headerSize; // チャンクタイプとデータ長を除く
bytes[4] = (byte)((trackLength >> 24) & 0xFF);
bytes[5] = (byte)((trackLength >> 16) & 0xFF);
bytes[6] = (byte)((trackLength >> 8) & 0xFF);
bytes[7] = (byte)(trackLength & 0xFF);
return bytes.ToArray();
}
}
public abstract class MidiEvent
{
public int DeltaTime { get; set; }
public abstract byte[] ToBytes();
}
public class NoteOnEvent : MidiEvent
{
private int channel;
private int note;
private int velocity;
public NoteOnEvent(int channel, int note, int velocity)
{
this.channel = channel;
this.note = note;
this.velocity = velocity;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add((byte)(0x90 | (channel & 0x0F))); // ステータスバイト
bytes.Add((byte)(note & 0x7F)); // データバイト
bytes.Add((byte)(velocity & 0x7F)); // データバイト
return bytes.ToArray();
}
}
public class NoteOffEvent : MidiEvent
{
private int channel;
private int note;
public NoteOffEvent(int channel, int note, int deltaTime)
{
this.channel = channel;
this.note = note;
this.DeltaTime = deltaTime;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add((byte)(0x80 | (channel & 0x0F))); // ステータスバイト
bytes.Add((byte)(note & 0x7F)); // データバイト
bytes.Add(0);
return bytes.ToArray();
}
}
public class ProgramChangeEvent : MidiEvent
{
private int channel;
private int program;
public ProgramChangeEvent(int channel, int program)
{
this.channel = channel;
this.program = program;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add((byte)(0xC0 | (channel & 0x0F))); // ステータスバイト
bytes.Add((byte)(program & 0x7F)); // データバイト
return bytes.ToArray();
}
}
public class BankMsbEvent : MidiEvent
{
private int channel;
private int value;
public BankMsbEvent(int channel, int value)
{
this.channel = channel;
this.value = value;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add((byte)(0xB0 | (channel & 0x0F))); // ステータスバイト
bytes.Add(0x00);
bytes.Add((byte)value); // データバイト
return bytes.ToArray();
}
}
public class BankLsbEvent : MidiEvent
{
private int channel;
private int value;
public BankLsbEvent(int channel, int value)
{
this.channel = channel;
this.value = value;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add((byte)(0xB0 | (channel & 0x0F))); // ステータスバイト
bytes.Add(0x20);
bytes.Add((byte)value); // データバイト
return bytes.ToArray();
}
}
public class ExpressionEvent : MidiEvent
{
private int channel;
private int value;
public ExpressionEvent(int channel, int value)
{
this.channel = channel;
this.value = value;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add((byte)(0xB0 | (channel & 0x0F))); // ステータスバイト
bytes.Add(0x0B);
bytes.Add((byte)value); // データバイト
return bytes.ToArray();
}
}
public class TrackNameEvent : MidiEvent
{
public string TrackName { get; set; } = string.Empty;
public TrackNameEvent(string trackName)
{
TrackName = trackName;
}
public override byte[] ToBytes()
{
List<byte> bytes = new List<byte>();
bytes.Add(0xFF);
bytes.Add(0x03);
var trackNameBytes = ASCIIEncoding.ASCII.GetBytes(TrackName);
bytes.Add((byte)trackNameBytes.Length); // 長さ
bytes.AddRange(trackNameBytes);
return bytes.ToArray();
}
}
上記クラスの使用例です。
// MIDIファイルを作成
MidiFile midiFile = new MidiFile();
int trackCount = 16;
for (int trackIndex = 0; trackIndex < trackCount; trackIndex++)
{
var channel = trackIndex;
MidiTrack track = new();
track.AddEvent(new TrackNameEvent($"TRACK {trackIndex}"));
// プログラムチェンジイベントを追加
track.AddEvent(new BankMsbEvent(channel, 0));
track.AddEvent(new BankLsbEvent(channel, 0));
ProgramChangeEvent programChangeEvent = new ProgramChangeEvent(channel, 0);
track.AddEvent(programChangeEvent);
track.AddEvent(new ExpressionEvent(channel, 100));
midiFile.Tracks.Add(track);
}
{
var track = midiFile.Tracks[1];
int channel = 0;
// スケールを演奏するためのノートオン/ノートオフイベントを追加
int[] scale =
{
60, 62, 64, 65, 67, 69, 71
};
int velocity = 64;
int duration = 480; // 四分音符の長さ
foreach (int note in scale)
{
NoteOnEvent noteOnEvent = new NoteOnEvent(channel, note, velocity);
track.AddEvent(noteOnEvent);
noteOnEvent.DeltaTime = 0;
NoteOffEvent noteOffEvent = new NoteOffEvent(channel, note, duration);
track.AddEvent(noteOffEvent);
}
}
// MIDIファイルを書き出し
byte[] bytes = midiFile.ToBytes();
using (FileStream fileStream = new FileStream("output.mid", FileMode.Create))
{
fileStream.Write(bytes, 0, bytes.Length);
}