5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42 TokyoAdvent Calendar 2023

Day 7

コマンドラインで作曲したい!〜 MML to MIDI compiler のようなものを作ってみた〜

Last updated at Posted at 2023-12-07

クリスマスを心よりお喜び申し上げます。
エンジニア養成機関 42 Tokyo の在校生 @snara-42 と申します。
こちらは 42 Tokyo Advent Calendarの7日目の記事です。

12/7 という数字を見てまず思いつくものといえば、そう、ピアノの鍵盤🎹ですよね
| !|! | !|!|! |

ということで、なんとなく作曲したい気分の時に、コマンドラインから手軽に作曲できるように、MML文字列をMIDIファイルに変換するプログラムを作ろうとしてみました。

そこまで便利なものができたかと言うとまだそうでもないのですが、もっと便利にしてみたいと思います。

コードはこちら (そのうち予告なく変更します)

MML (Music Macro Language) って?

Music Markup Language
MusicXML
Mathematical Markup Language
とは異なります。

アルファベットと記号で音符を記述するドメイン固有言語。
agは音符、r は休符、数字は音符の長さ、^ は2倍の長さであることを表す。
例:きらきら星
c4 c g g a a g^ f f e e d d c^
image.png

MIDI (Musical Instrument Digital Interface) って?

電子楽器の演奏データを機器間で転送・共有するための規格。
バイト列で演奏イベントを表現する。
今回は Standard MIDI File 形式でファイルに演奏データを保存。
例:きらきら星

> xxd out.mid

00000000: 4d54 6864 0000 0006 0001 0001 01e0 4d54  MThd..........MT
00000010: 726b 0000 0094 00ff 5804 0402 1808 00ff  rk......X.......
00000020: 5103 07a1 2000 c000 0090 3c50 8360 803c  Q... .....<P.`.<
00000030: 0000 903c 5083 6080 3c00 0090 4350 8360  ...<P.`.<...CP.`
00000040: 8043 0000 9043 5083 6080 4300 0090 4550  .C...CP.`.C...EP
00000050: 8360 8045 0000 9045 5083 6080 4500 0090  .`.E...EP.`.E...
00000060: 4350 8740 8043 0000 9041 5083 6080 4100  CP.@.C...AP.`.A.
00000070: 0090 4150 8360 8041 0000 9040 5083 6080  ..AP.`.A...@P.`.
00000080: 4000 0090 4050 8360 8040 0000 903e 5083  @...@P.`.@...>P.
00000090: 6080 3e00 0090 3e50 8360 803e 0000 903c  `.>...>P.`.>...<
000000a0: 5087 4080 3c00 00ff 2f00                 P.@.<.../.

image.png

実装

言語

  • バイナリーデータも文字列も扱いやすい言語がいい
  • 辞書型があると嬉しい
  • 可変長引数や動的配列も欲しい
  • できればコマンドラインからフラグで色々渡したい

ということで go言語で実装することに。

中間表現

MMLの全ての記法やMIDIのあらゆる種類のイベントに対応することは面倒なため、
今回はトークンごとに区切られた文字列が入力されることを前提とし、音符以外のイベントをほぼ扱わないことにしました。

まずNote配列を MIDIイベントの byte配列に変換する機能を作り、それからMML文字列をNote配列に変換できるようにしました。

var tracks []string

type Note struct {
	num     []uint8 // 音高(和音にもできる)
	time    uint32  // 長さ
	vel     uint8   // 音量
	channel uint8   // MIDIチャンネル
}

type MidiData []byte

MIDI file の構造

AMEI MIDI1.0 規格書 より引用

MIDI ファイルは、常にヘッダー・チャンクで始まり、その後にひとつかそれ以上のトラック・チャンクが続く。

MThd <length of header data>
<header data>
MTrk <length of track data>
<track data>
MTrk <length of track data>
<track data>
...

音符 → MIDI

とりあえずヘルパー関数

文字列や、 8, 16, 24, 32bit unsigned の値を big endian で書き込む関数を用意。

type MidiData []byte

func (midi *MidiData) push(vals ...byte) {
	*midi = append(*midi, vals...)
}
func (midi *MidiData) push_str(vals string) {
	*midi = append(*midi, []byte(vals)...)
}
func (midi *MidiData) push_u16(v uint16) {
	midi.push(byte(v>>8&0xff), byte(v>>0&0xff))
}
func (midi *MidiData) push_u24(v uint32) {
	midi.push(byte(v>>16&0xff), byte(v>>8&0xff), byte(v>>0&0xff))
}
func (midi *MidiData) push_u32(v uint32) {
	midi.push(byte(v>>24&0xff), byte(v>>16&0xff), byte(v>>8&0xff), byte(v>>0&0xff))
}

MIDI の 可変長数値表現 を書き込む関数

上位byteから順に、各byteの下位7bitで数値を表し、最上位bitを「次のバイトもデータバイトが続くかどうかのフラグ」に用いる。
<delta-time> という変数が登場したらこの表現。

func (midi *MidiData) push_delta(v uint32) {
	if v > 0x0fffffff {
		panic("delta must be <= 0x0fffffff")
	}
	var buf uint32 = v & 0x7f
	for (v >> 7) > 0 {
		v >>= 7
		buf <<= 8
		buf |= 0x80
		buf += v & 0x7f
	}
	for {
		midi.push(byte(buf & 0xff))
		if buf&0x80 == 0 {
			break
		}
		buf >>= 8
	}
}

MIDI のヘッダー・チャンクを書き込む関数

意外と単純。
<length> は 32bit big endian、
<format> <ntrks> <division> は 16bit big endian。

<header chunk> = 
<chunk type> // "MThd"
<length> // byte数 big endian。0x00000006
<format> // フォーマット番号。今回は 1
<ntrks>  // len(tracks)
<division> // 4分音符の分解能。今回は 480
func main() {
 // ...
 	const Timebase = 480
	const midi_format = 1
	header := generate_header(midi_format, uint16(len(tracks)), Timebase)
 // ...
}

func generate_header(midi_format, n_tracks, timebase uint16) (res MidiData) {
	res.push_str("MThd")
	const header_len = 6
	res.push_u32(header_len)
	res.push_u16(midi_format)
	res.push_u16(n_tracks)
	res.push_u16(timebase)
	return res
}

トラック・チャンクを書き込む関数

1つの音符を表すためには
0 note-on <音高> <音量>
<delta-time> note-off <音高> <音量>
を書き込む。
(音を止める際には <delta-time> note-on <音階> 0 としてもよく、その場合は note-on を省略できる)

note-on = 0x90 + <channel>
note-off = 0x80 + <channel>

音高 = note-number
= 0 (C-1: 8.1758 Hz)
〜 60 (C4, 中央C: 261.63 Hz)
〜 69 (A4, 基準A: 440 Hz)
〜 127 (G9: 12543.9 Hz)

音量 = velocity
= 0 (off) 〜 1 (ppp) 〜 64 (mf) 〜 127 (fff)

和音の場合は、
0 on 0 on 0 on <delta-time> off 0 off 0 off
のように、全部同時にon → 待つ → 全部同時にoff という流れになる。

各トラックの最後には、トラック終端を示すイベント <delta-time> 0xff 2f 00 を書き込む。

<track chunk> = 
<chunk type>  // "MTrk"
<length>      // これ以降のbyte数 big endian。len(res)
<MTrk event>+ // MIDIイベントbyte列

<MTrk event> =
<delta-time> // 前回のイベントからの経過時間の可変長表現。直前のイベントと同時なら0
<event> 
func main() {
  // ...
	for i, t := range tracks {
		notes := parse_track(uint8(i), strings.Split(t, " "))
  
		block := generate_track(notes)
		res.push_str("MTrk")
		res.push_u32(uint32(len(block)))
		res.push(block...)
	}
  // ...
}

func generate_track(track []Note) (res MidiData) {
	const NoteOn     = 0x90
	const NoteOff    = 0x80

	for _, n := range track {
		for _, v := range n.num {
			res.push_delta(0)
			res.push(NoteOn + n.channel)
			res.push(v)
			res.push(n.vel)
		}
		for i, v := range n.num {
			if i == 0 {
				res.push_delta(n.time)
			} else {
				res.push_delta(0)
			}
			res.push(NoteOff + n.channel)
			res.push(v)
			res.push(0)
		}
	}
	end_of_track := []byte{0xff, 0x2f, 0x00}
	res.push_delta(0)
	res.push(end_of_track...)
	return res
}

楽器をデフォルトから変えるには
<delta-time> <program-change> <instrument>
を書き込む。

<program-change> = 0xc0 + <channel>
instrument = 0 (Acoustic Piano) 〜 127 (Gunshot)

 	const ProgChange = 0xc0

 	res.push_delta(0)
	res.push(ProgChange + channel)
	res.push(Instrument)

拍子をデフォルトの $ 4 / 4 $ から変えるには
<delta-time> 0xff 58 04 <分子> <log2(分母)> <メトロノーム1拍あたりのクロックの数> <24クロック=4分音符 あたりの32分音符の数>
を書き込む。

log2(分母): 4分音符なら02、8部音符なら03
メトロノーム1拍あたりのクロックの数: 4分音符 = 24 = 0x18 クロック なので 96(=全音符) / 分母
最後のパラメーターは $(1/4) \div (1/32) = $ 0x08 で固定でよさそう。

例: $6/8$ 拍子
0x00 ff 58 04 06 03 0c 08

	timefrac_set := []byte{0xff, 0x58, 0x04}
	res.push_delta(0)
	res.push(timefrac_set...)
	res.push(Numerator,
		byte(log2(Denominator)),
		96/byte(Denominator), // clock/beat
		32/4) // 4分音符 = 32分音符*8

MML → 音符

音階名からノートナンバーに変換する関数

c d e f g a b = 音符
+ , - = ♯ , ♭

var note_map = map[string]uint8{
	"c":  0,
	"c+": 1,
	"d-": 1,
	"d":  2,
	"d+": 3,
	"e-": 3,
	"e":  4,
	"f":  5,
	"f+": 6,
	"g-": 6,
	"g":  7,
	"g+": 8,
	"a-": 8,
	"a":  9,
	"a+": 10,
	"b-": 10,
	"b":  11,
}
func get_notename(tok string) (notenum uint8, rest string, ok bool) {
	for i := len(tok); i >= 1; i-- {
		if notenum, ok := note_map[tok[:i]]; ok {
			return notenum, tok[i:], true
		}
	}
	return 0, tok, false
}

文字列配列を音符配列に変換する関数

改良の余地が大きい

` ` = 和音を囲む
r = 休符
l = 長さ指定
o = オクターブ指定
> , < = 1オクターブ(=12半音) 上げる , 下げる

func parse_track(channel uint8, tokens []string) (notes []Note) {
	type t_ctx struct {
		time     uint32
		vel      uint8
		octave   uint8
		is_chord bool
	}
	const central_C = 60
	ctx := t_ctx{
		time:     Timebase,
		vel:      80,
		octave:   central_C,
		is_chord: false,
	}

	for _, tok := range tokens {
		if len(tok) < 1 {
			continue
		}
		if notenum, tok, ok := get_notename(tok); ok {
			duration := parse_length(&ctx.time, tok)
			if !ctx.is_chord {
				n := Note{num: []uint8{}, time: duration, vel: ctx.vel, channel: channel}
				notes = append(notes, n)
			}
			n := &notes[len(notes)-1]
			n.num = append(n.num, ctx.octave+notenum)
			n.time = duration
		} else if tok[:1] == "`" {
			if !ctx.is_chord {
				n := Note{num: []uint8{}, time: ctx.time, vel: ctx.vel, channel: channel}
				notes = append(notes, n)
			} else if ctx.is_chord {
				duration := parse_length(&ctx.time, tok[1:])
				n := &notes[len(notes)-1]
				n.time = duration
			}
			ctx.is_chord = !ctx.is_chord
		} else if tok[:1] == "r" {
			duration := parse_length(&ctx.time, tok[1:])
			n := Note{num: []uint8{0}, time: duration, vel: 0, channel: channel}
			notes = append(notes, n)
		} else if tok[:1] == "l" {
			ctx.time = parse_length(&ctx.time, tok[1:])
		} else if tok[:1] == "o" {
			oct, _ := strconv.ParseUint(tok[1:], 10, 32)
			ctx.octave = 12 * uint8(oct)
		} else if tok == ">" {
			ctx.octave += 12
		} else if tok == "<" {
			ctx.octave -= 12
		} else {
			fmt.Println("unsupported token: " + tok)
		}
	}

	return notes
}

// ...
notes := parse_track(uint8(channel), strings.Split(track, " "))

さらにコマンドラインのフラグを受け取って色々する関数を追加して、

とりあえずできた!

go run midi.go -n 3 -d 4  -t 100 \
'r2 e8 d c^^ d e f g2  > c8 < b a4^ a g^^  > c4 < b a g a b > c < g f e^' \
&& timidity -Od out.mid  
const (
	silent_night_chorus = "` < < b- > b- > d f `8. ` < < b- > b- > e- g `16 ` < < b- > b- > d f `8 ` < < b- > f b- > d `4. " +
		"` < < b- > b- > d f `8. ` < < b- > b- > e- g `16 ` < < b- > b- > d f `8 ` < < b- > f b- > d `4. " +
		"` < f a > e- > c < `4 ` < f a > e- > c < `8 ` < f > c e- a `4. " +
		"` < < b- > b- > d b- `4 ` < < b- > f > d b- `8 ` < < b- > b- > d f `4. " +
		"` < e- b- > e- g `4 ` < e- b- > e- g `8 ` < e- g > g b- `8. ` < e- a > f a `16 ` < e- b- > e- g `8 " +
		"` < < b- > b- > d f `8. ` < < b- > b- > e- g `16 ` < < b- > b- > d f `8 ` < < b- > f b- > d `4. " +
		"` < e- e- b- > g `4 ` < e- b- > e- g `8 ` < e- g > g b- `8. ` < e- a > f a `16 ` < e- b- > e- g `8 " +
		"` < < b- > b- > d f `8. ` < < b- > b- > e- g `16 ` < < b- > b- > d f `8 ` < < b- > f b- > d `4. " +
		"` < f a > e- > c < `4 ` < f a > e- > c < `8 ` < f > c e- > e- < `8. ` < f a > e- > c < `16 ` < f > c e- a `8 " +
		"` < < b- > b- > d b- `4. ` < < b- > b- > f > d < ` " +
		"` < < b- > f > d b- `8 ` < < b- > f > d f ` ` < < b- > f b- > d ` ` < < f > f > d f `8. ` < < f > f > c e- `16 ` < < f > e- a > c `8 " +
		"` < < b- > d b- b- `2"

	silent_night_guitar = "l8 < < b- > f f l16 < b- > f b- > d < b- f " +
		"l8 < b- > f f l16 < b- > f b- > d < b- f " +
		"l8 < f > f f l16 < f > f a > c f < f " +
		"l8 < b- > f f l16 < b- > f b- > d < b- f " +
		"l8 < b- > e- e- l8 < b- > e- e- " +
		"l8 < b- > f f l16 < b- > f b- > d < b- f " +
		"l8 < b- > e- e- l8 < b- > e- e- " +
		"l8 < b- > f f l16 < b- > f b- > d < b- f " +
		"l8 < f > f f l8 < f > f f " +
		"l16 < b- > f b- > d < b- f l16 < b- > f b- > d < b- f " +
		"l8 < b- > f f l8 < f > f f " +
		"l8 < b- f d b- "
)

var tracks = []string{silent_night_chorus, silent_night_guitar}

https://github.com/snara-42/musica/blob/main/silent_night.mid
https://cdn.discordapp.com/attachments/773008425411149874/1182366209967128656/silent_night.wav

結論

MIDI の仕組みを理解できた気がします。
(でもやっぱり MuseScore や サクラmml なんかの方が便利かも)

参考

終わりに

お読みいただきありがとうございました。
採譜をしてたら財布をすられた、
なんてことがないようにお気をつけください。

明日は @snara-42 が、WAV file を扱って色んな音律で音を生成する記事を書くつもりらしいです。
明日中に書き終わったらいいですけどね。

代わって @now4est さんが記事を書いてくださいました。感謝感激

00 ff 2f 00
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?