JSでSMFを読み書きする

# 動機
楽譜データの共有はやっぱりSMF
うちのウェブアプリの独自フォーマットをSMFで書き出せたらなぁ
でもSMF関連のライブラリどれも古いしでかいなぁ

作るか！！

# SMFの構造
ほとんどこちらの記事に学ばせていただきました

https://sites.google.com/site/yyagisite/material/smfspec

ところどころ怪しいところがあったのでそのときは
[日本のちゃんとした団体が翻訳した仕様書](https://amei.or.jp/midistandardcommittee/MIDI1.0.pdf)を確認

簡潔にまとめます
大まかな構造は
- ヘッダーチャンク
    - チャンク名(4byte)
    - チャンク長(4byte)
    - フォーマットバージョン(2byte)
    - トラック数(2byte)
    - 時間単位(2byte)
- トラックチャンク
    - チャンク名(4byte)
    - チャンク長(4byte)
    - MTrkイベント
    - MTrkイベント
    - MTrkイベn…
    - 
    - トラック終端
- トラックチャンク
- トr…
- 
- トラックチャンク

最初にヘッダーチャンクが来てその後にトラックチャンクが任意個並びます

チャンク名は必ず4byteで
ヘッダーチャンクならMThd(4d 54 68 64)
トラックチャンクならMTrk(4d 54 72 6b)
が入ります

チャンク長も必ず4byteで
チャンク長の次のデータからそのチャンクの最後までの長さをbyte単位で入ります
現時点ではヘッダーチャンクは6で固定です(後述)
## ヘッダーチャンク
### チャンク名 4byte
前述の通り MThd(4d 54 68 64)
### チャンク長 4byte
ヘッダーチャンクのデータ長は今のところ固定で
2(フォーマット)+2(トラック数)+2(時間単位)=6byte
つまり 6(00 00 00 06)
### フォーマット 2byte
SMFは0,1,2のバージョンがあって世の中で主に使われているものは0,1らしい

| バージョン\機能 | トラックチャンクの最大個数 | 一つのトラックチャンクで使えるチャンネル数 |
|:-:|:-:|:-:|
| 0(00 00) | 1 | 16 |
| 1(00 01) | 65535 | 1 |
| 2(00 02) | 65535 | 多分1 |

### トラック数 2byte
このあと連なるトラックチャンクの個数
前述のバージョンの制約も確認
例えばバージョン1で2個のトラックチャンクなら
2(00 02)

### 時間単位 2byte
トラックチャンクでイベントの時間を指定する際にはこれが基になる
大きい数を指定すればその分細かい(素早い)表現ができるが
小さい数を指定すればその分長い曲の際に容量を抑えることができる

2種類ある(!?)

| bit | 15 | 14~8 | 7~0 |
|:-:|:-:|:-:|:-:|
|分解能毎四分音符(15bit)| 0 | 15bit上位7bit | 15bit下位8bit|
|フレーム毎秒(後述)+分解能毎フレーム(8bit)| 1 | フレーム毎秒(負数で) | 分解能毎フレーム |

分解能毎四分音符
四分音符を分割する数
よく480(01 e0)(一九二〇分音符相当)が使われるイメージ


フレーム毎秒
SMPTEという団体のフォーマットに則してて24,25,29.97,30から選ぶらしい
その整数部分を負数(2の補数)にして格納する
私もよくわかっていないけど多分
| フレーム毎秒 | 14~8 |
|:-:|:-:|
| 24 | -24(0b1101000)(0x68) |
| 25 | -25(0b1100111)(0x67) |
| 29.97 | -29(0b1100011)(0x63) |
| 30 | -30(0b1100010)(0x62) |

分解能毎フレーム
これは分解能毎四分音符と同じ、多分、……

## トラックチャンク
### チャンク名 4byte
前述の通り MTrk(4d 54 72 6b)
### チャンク長 4byte
この後に続くデータ次第
4byteで足りるの？→十分すぎるくらいです
### MTrkイベント
MTrkイベントは
`デルタタイム(前のイベントからの経過時間)+イベント`
で表される
#### デルタタイム
可変長数値表現で表される
| 数→可変長 | 操作 | 例 | 例 |
|:-:|:-:|:-:|:-:|
| 1. | 二進数に変換する | 127=0x7f=0b1111111 | 334=0x14e=0b101001110 |
| 2. | 下から7bitごとのグループに区切る | _1111111 | _____10,_1001110 |
| 3. | 最下位以外のグループに0x80を加える | 01111111 | 1000010,01001110 |
| 4. | そのまま結合 | 0b01111111=0x7f | 0b100001001001110=0x424e |
#### イベント
デルタタイムで指定した時間に行いたい動作の命令
一番最初のbyteが命令の種類を表していてステータスバイトと呼ぶ
これは必ず0x80以上

大きく分けて3種類ある
##### MIDIイベント
どれも固定長
同じステータスバイトの命令が連続する場合は2回目以降のステータスバイトを省略できる(ランニングステータス)
xx,yyはどちらも0x80未満でなければならない(ランニングステータスの見極めはこれを使う)
```
8N xx yy : ノートOFF (チャンネルNの鍵盤番号xxを離す)
9N xx yy : ノートON (チャンネルNの鍵盤番号xxを強さyyで押す yy==0のときノートOFFと同じ)
aN xx yy : ノート音量 変更 (チャンネルNの鍵盤番号xxを強さyyに変更)
bN xx yy : コントロール 変更 (チャンネルNのコントロール(ツマミ)xxをyyに変更)
cN xx    : プログラム 変更 (チャンネルNのプログラム(楽器)をxxに変更)
dN xx    : ノート音量 一括 変更 (チャンネルNの全ての鍵盤を強さxxに変更)
eN xx yy : ピッチベンド (チャンネルNのピッチベンドを((0x yy xx)-0x2000)に設定)←リトルエンディアン
```
`bN`コントロールについて
GM1規格で要求されているのはこれ
```
xx : 機能

01 : モジュレーション
06 : DataEntry MSB *
07 : ボリューム
0a : パン
0b : エクスプレッション
26 : DataEntry LSB *
40 : サスティン
64 : RPN LSB *
65 : RPN MSB *
79 : リセット・オール・コントローラー
7b : オール・ノート・オフ

* 明記はされていないがRPNは存在するので多分必要
```

RPNはコントロールチェンジの拡張のようなもの
RPN LSB&MSBで変更したい機能を指定してDataEntry LSB&MSBで値を指定する
GM1規格で要求されているのはこれ
```
MSB LSB : 機能

 00  00 : ピッチ・ベンド・センシティビティ
 00  01 : ファイン・チューニング
 00  02 : コース・チューニング
```
詳しくは[Wiki](https://ja.wikipedia.org/wiki/General_MIDI#Controller_events)へ
##### sysExイベント
音源プログラム(楽器)にメッセージを送信
メッセージは音源プログラム毎に固有のもの
```
f0 len ...data f7 : (メッセージ[f0,...data,f7]を送信 len=data.length+1(可変長数値表現))
f7 len ...data    : (メッセージ[   ...data   ]を送信 len=data.length  (可変長数値表現))
```
##### メタイベント
上記以外の必要なデータはこれを使う
```
ff xx len ...data : ([...data]を持つメタイベントxx len=data.length(可変長数値表現))
--------------------------------
代表的な
xx : data

01 : テキスト(data=txt エンコードはまちまち)
02 : トラック名(data=txt フォーマット0、フォーマット1,2の最初のトラックチャンクで使うと曲名として認識されるらしい)
06 : マーカー(data=txt)
2f : トラック終端 必須(data=[] トラックチャンクの最後はこのイベントで終わらなければならない)
51 : テンポ 実質必須(data=[xx,yy,zz],xxyyzz=60000000/bpm 四分音符の長さをμs単位で指定)
58 : 拍子(data=[xx,yy,zz,ww],xx/(2**yy)=拍子,zz=0x18(MIDIクロック/メトロノーム一拍),ww=0x08(MIDI四分音符に含まれる32分音符の数 黒魔術に使う?)
59 : 調号(data=[xx,yy],xx=+(♯)-(♭),yy=maj?0:min?1:)
```
MIDIクロックとsmfのtickは違う概念で
24MIDIクロック==MIDI四分音符=={header.division}tick
らしい
## 最低限の構成
時報のあれです
バージョン1でメタイベントはトラック分けて書きました
分解能は極力抑えてみました
```js
4d 54 68 64 // MThd
00 00 00 06 // 6byte
00 01 // format 1
00 02 // tracks 2
00 08 // division 8

4d 54 72 6b // MTrk
00 00 00 0b // 11byte
00 ff 51 03 0f 42 40 // bpm=60
00 ff 2f 00 // end

4d 54 72 6b // MTrk
00 00 00 1d // 29 byte
00 90 45 7f // A4
01    45 00 // 1/4/8
07    45 7f // A4
01    45 00 // 1/4/8
07    45 7f // A4
01    45 00 // 1/4/8
07    51 7f // A5
08    51 00 // 1/4/1
00 ff 2f 00 // end


// これを //


Object.assign(
	document.createElement('a'),
	{
		download:'jiho-.mid',
		href:URL.createObjectURL(
			new Blob([
				new Uint8Array(`
					4d 54 68 64
					00 00 00 06
					00 01
					00 02
					00 08

					4d 54 72 6b
					00 00 00 0b
					00 ff 51 03 0f 42 40
					00 ff 2f 00

					4d 54 72 6b
					00 00 00 1d
					00 90 45 7f
					01    45 00
					07    45 7f
					01    45 00
					07    45 7f
					01    45 00
					07    51 7f
					08    51 00
					00 ff 2f 00
				`.match(/[\da-f]{2}/g).map(x=>parseInt(x,16))
				).buffer
			])
		)
	}
).click();
```
とでもすると機能するSMFがダウンロードできちゃいます

# 読み書き
さてやっと本題ですが構造が理解できてしまえばあとは書くだけです
楽勝ですね
BlobをDataViewを使って読んでいきます
```js
const smfin=async w=>{
	w=new DataView(await new Response(w).arrayBuffer());
	if(w.getUint32(0)!==0x4d546864)throw'invaild file.';
	let p=8+w.getUint32(4),tracks=[];
	while(p<w.byteLength){
		let q=p+8,s=[],r,
				vln=(x,y)=>{while(1){y=w.getUint8(q++);x=(x<<7)|(y&0x7f);if(!(y>>7))break;}return x;},
				ui7=()=>w.getUint8(q++)&0x7f,
				ui7s=(x,...y)=>Object.assign(x,Object.fromEntries(y.map(z=>[z,ui7()])));
		if(w.getUint32(p)!==0x4d54726b)throw'invailed chunk type.';
		p+=8+w.getUint32(p+4);
		if((w.getUint32(p-4)&0xffffff)!==0xff2f00)throw'invailed chunk length or end of chunk is not defined.';
		while(q<p){
			let dt=vln(),e=w.getUint8(q),ch;
			if(e>>7){r=e;q++;}else e=r;
			ch=e&0x0f;
			s.push({dt,...[//8~
				()=>ui7s({ch,name:'noteOff'},'note','vel'),
				()=>ui7s({ch,name:'noteOn'},'note','vel'),
				()=>ui7s({ch,name:'polyPress'},'note','vel'),
				()=>ui7s({ch,name:'ctrl'},'ctrl','value'),
				()=>ui7s({ch,name:'prg'},'prg'),
				()=>ui7s({ch,name:'chPress'},'vel'),
				()=>({ch,name:'bend',value:(ui7()|(ui7()<<7))-0x2000}),
				({
					0:(l=vln())=>({name:'sysEx0',data:new Uint8Array([0xf0,...new Uint8Array(w.buffer.slice(q,q+=l))])}),
					7:(l=vln())=>({name:'sysEx7',data:new Uint8Array(w.buffer.slice(q,q+=l))}),
					15:(type=ui7(),l=vln())=>({name:'meta',type,data:new Uint8Array(w.buffer.slice(q,q+=l))})
				}[ch])
			][(e>>4)&0b0111]()});
		}
		tracks.push(s);
	}
	return{header:{format:w.getUint16(8),ntrks:w.getUint16(10),division:(x=>x>>15?{smpte:0x80-((x>>8)&0x7f),tpf:x&0xff}:x)(w.getInt16(12))},tracks};
};
```
Blob.prototype.arraybuffer()はiOS14以降という新参者なので一度Responseに落としてあげます
これでiOS10.1から使えるようになります
メタイベントは面倒だったのでUint8Arrayに詰め込んでそのまま返してます
最初は`new Uint8Array(w.buffer,q,q+=l)`だったのですが
少し長めのを渡すとoffsetが遠すぎて怒られたのでsliceしています

```js
const smfout=w=>{
	let vln=x=>{let s=[];while(1){s.unshift((x&0x7f)|(s.length?0x80:0));x>>=7;if(!x)break;}return s;},
		num=(x,l)=>new Array(l--).fill().map((_,i)=>(x>>(8*(l-i)))&0xff),
		r,rs=(x,y,...z)=>((z=z.map(_=>_&0x7f))&&(r==(r=x+(y.ch&0xf))))?z:[r,...z];
	return new Blob([new Uint8Array([
		0x4d,0x54,0x68,0x64, 0,0,0,6,
		...num(w.header.format,2),
		...num(w.header.ntrks||w.tracks.length,2),
		...typeof w.header.division=='number'?num(w.header.division&0x7fff,2):[0x80|0x80-(w.header.division.smpte&0x7f),w.header.division.tpf&0xff],
		...w.tracks.flatMap(x=>{
			x=x.flatMap(y=>[
				...vln(y.dt),
				...({
					noteOff:()=>rs(0x80,y,y.note,y.vel),
					noteOn:()=>rs(0x90,y,y.note,y.vel),
					polyPress:()=>rs(0xa0,y,y.note,y.vel),
					ctrl:()=>rs(0xb0,y,y.ctrl,y.value),
					prg:()=>rs(0xc0,y,y.prg),
					chPress:()=>rs(0xd0,y,y.vel),
					bend:(z=y.value+0x2000)=>rs(0xe0,y,z,z>>7),
					sysEx0:()=>[r=0xf0,...vln(y.data.length-1),...y.data.slice(1)],
					sysEx7:()=>[r=0xf7,...vln(y.data.length),...y.data],
					meta:()=>[r=0xff,y.type&0x7f,...vln(y.data.length),...y.data]
				}[y.name]())
			]);
			return[0x4d,0x54,0x72,0x6b,...num(x.length,4),...x];
		})
	]).buffer]);
};
```
最初はUitn8Arrayを結合するコードを書いたのですが
setしようとしたら同じくoffsetが遠すぎて怒られたのでArrayに変えています
どうやらArrayの方が色々と速いようです

時報のmidiを渡すとこんな具合で返ってきます
```json
{
	"header": {
		"format": 1,
		"ntrks": 2,
		"division": 8
	},
	"tracks": [
		[
			{"dt": 0,"name": "meta","type": 81,"data": [15,66,64]},
			{"dt": 0,"name": "meta","type": 47,"data": []}
		],
		[
			{"dt": 0,"ch": 0,"name": "noteOn","note": 69,"vel": 127},
			{"dt": 1,"ch": 0,"name": "noteOn","note": 69,"vel": 0},
			{"dt": 7,"ch": 0,"name": "noteOn","note": 69,"vel": 127},
			{"dt": 1,"ch": 0,"name": "noteOn","note": 69,"vel": 0},
			{"dt": 7,"ch": 0,"name": "noteOn","note": 69,"vel": 127},
			{"dt": 1,"ch": 0,"name": "noteOn","note": 69,"vel": 0},
			{"dt": 7,"ch": 0,"name": "noteOn","note": 81,"vel": 127},
			{"dt": 8,"ch": 0,"name": "noteOn","note": 81,"vel": 0},
			{"dt": 0,"name": "meta","type": 47,"data": []}
		]
	]
}
```
これをsmfoutに渡すとBlobが帰ってきます
めでたしめでたし

# 参考文献
https://sites.google.com/site/yyagisite/material/smfspec
https://amei.or.jp/midistandardcommittee/MIDI1.0.pdf