LoginSignup
1
0

More than 1 year has passed since last update.

JSでSMFを読み書きする

Last updated at Posted at 2022-05-14

動機

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

作るか!!

SMFの構造

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

ところどころ怪しいところがあったのでそのときは
日本のちゃんとした団体が翻訳した仕様書を確認

簡潔にまとめます
大まかな構造は

  • ヘッダーチャンク
    • チャンク名(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

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でメタイベントはトラック分けて書きました
分解能は極力抑えてみました

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を使って読んでいきます

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しています

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を渡すとこんな具合で返ってきます

{
	"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

1
0
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
1
0