この記事の内容
JavaScriptはバイナリを扱うのが苦手という流言飛語が出回っているのでJavaScriptでどれだけ簡単にバイナリを扱えるかをまとめておく。
とは言えファイル形式もわからないバイナリデータなんて扱えないのでここではRIFF形式という汎用データ形式を扱う、これはデータをchunk(チャンク)という単位で扱い各chunkは入れ子構造が可能である。ここを見てもらうのが早い。
コードはes2015に準拠します、Webpackとbabel-loaderを使ってpreset=es2015でトランスパイルしています。
読み込み
Fetchを使います。XMLHTTPRequestなんて窓から投げ捨てろ。
export function read(file_path){
// return fetch(file_path).then(function(response){
return fetch(file_path).then(response=>{
return response.arrayBuffer();
}).then(arrayBuffer=>{
const array=new Uint8Array(arrayBuffer);
if( String.fromCharCode.apply("", array.subarray(0, 4))!=="RIFF" ) throw new Error(file_path+"はRIFFファイルではありません");
if( get_size(array.subarray(4, 8))!==array.length-8 )
throw new Error(file_path+"ファイルサイズが一致しません "+size+" "+array.length);
return riff_data(array);
}
FetchはPromise形式なので読み込みが成功すればresponseがthenに渡されます。コールバックの部分はes2015からfunctionは=>
演算子で置き換え可能になったので=>
演算子で書きます。
responseは形式が決まっていないのでバイナリに使えるバッファ列にします。バッファ列はbitの集まりのようなもので人間には読みにくいのでUint8Array
にします。Uint8Array
とは8bitの符号なし整数型の列です。RIFF形式では基本データはbyteで扱うのでbyte列にしてしまいます。
Uint8Array
はsubarray
で自由に切り取れます、
if( String.fromCharCode.apply("", array.subarray(0, 4))!=="RIFF" ) throw new Error(file_path+"はRIFFファイルではありません");
の部分は最初の4byteを持ってきて文字列に変換しています。RIFF形式は文字はすべてASCIIで書けと言っているのでString.fromCharCode
で人間に読める形式にしてくれます。RIFF形式は最初の識別子は"RIFF"
であるべきなのでその判定をして違えば例外を投げています。
function get_size(array){ return array[0]+256*array[1]+256*256*array[2]+256*256*256*array[3]; };
function get_size(array){ return new Uint32Array(new Uint8Array(array).buffer)[0]; };
get_size
は上で定義しています。どちらでもほぼ同じ意味です。上はまあわかりやすいですね。符号なし8bit整数の最大値が$2^8-1=255$なのでその上のbyteは256から始まります。さらにその上は$256*256$、256進数を計算していますw。別にバイナリだからって無理やりbit演算させる必要はありません。わかりやすく書きましょうpow(pow(2, 8), n)
とかでもいいけどそこまでやると冗長する気がする...、256ぐらいプログラマーならピンとくるはずだし。
下は少し解説がいります。Uint8Array
のbuffer
を使っているんですがコレは構築時new
された時に値が確定してその後不変です。subarray
で切り出すとnew
された時、つまり元のbuffer
を返します。なので一度作りなおしてそのbuffer
を使って8byte×4=32の整数型を作っています。そのままだと列のままなので[0]
で最初の値を取ってきています。
つまり一度、構築したデータを別の形式に変換できます。その際面倒くさいbit演算などする必要がないんです。きちんと仕様書があればそれに従って型を当てはめれば後はJavaScriptがよしなにやってくれます。
if( get_size(array.subarray(4, 8))!==array.length-8 )
throw new Error(file_path+"ファイルサイズが一致しません "+this.size+" "+array.length);
の部分はファイルサイズチェックをしています。RIFF形式はファイルサイズが埋め込まれているので実際読まれたデータと書き込まれたデータサイズが一致するか確かめています。-8
は最初に読まれた識別子とファイルサイズのために書かれた8byteのデータ量を引いています。
最後のriff_data(array)
のriff_data
は
function riff_data(array){
const id=String.fromCharCode.apply("", array.subarray(0, 4));
const size=get_size(array.subarray(4, 8));
if( id==="RIFF" || id==="LIST" ){
let pos=12;
const type=String.fromCharCode.apply("", array.subarray(8, 12));
let data=[];
while( pos<size+8 ){
const child=riff_data(array.subarray(pos, array.length)); // 再帰
data.push(child);
pos+=8+child.size;
}
let sum=0;
for( let d of data ) sum+=d.size;
sum+=8*data.length+4;
if( sum!==size ) throw new Error(type+"チャンクのファイルサイズが一致しません "+size+" "+sum);
return { type: type, size: size, data: data };
}
else return { type: id, size: size, data: array.subarray(8, 8+size) };
}
です。RIFF形式は識別子が"RIFF"か"LIST"なら子供にchunkを持ちます、その際、自身の型(のようなもの)を表すためにフォームタイプを持ちます。それ以外のデータタイプは識別子がそのままtype、後はサイズとデータが並んでいます。
"RIFF"/"LIST"の場合は再帰で下部構造を解析して終端(子供を持たないデータは)にたどり着くとデータを格納して返します、単純ですね。LIST形式ならデータサイズの整合性を確認しています、フォームタイプ用の4byteと各データの識別子、サイズ用の8×(子の数)を引いています。
解析
さてコレでエラーが出てないからきちんと読めた...。では物足りないですよね。そこでSound fontのメタデータを読んでみましょう(実はsound fontデータの解析とそれをWebAudioAPIに渡すのが目的で書き始めた)。
Sound fontもRIFF形式を利用しています。
詳しい仕様はここを見てください。
RIFF形式では一番先頭にあるデータはINFO
でメタデータを格納するべきとあるので大体がそうなっている。古いものなどは一部守られていない。
function makeInfo(chunk){
let info={};
for( let a of chunk.data ){
if( a.type==='ifil' ) info['sf_version']=1000*a.data[0]+100*a.data[1]+10*a.data[2]+a.data[3];
else if( a.type==='isng' ) info['sound_engin']=getString(a.data, 0, a.size);
else if( a.type==='INAM' ) info['name']=getString(a.data, 0, a.size);
else if( a.type==='irom' ) info['ROM']=getString(a.data, 0, a.size);
else if( a.type==='iver' ) info['rom_version']=1000*a.data[0]+100*a.data[1]+10*a.data[2]+a.data[3];
else if( a.type==='ICRD' ) info['date']=getString(a.data, 0, a.size);
else if( a.type==='IENG' ) info['designer']=getString(a.data, 0, a.size);
else if( a.type==='IPRD' ) info['product']=getString(a.data, 0, a.size);
else if( a.type==='ICOP' ) info['copy_right']=getString(a.data, 0, a.size);
else if( a.type==='ICMT' ) info['comment']=getString(a.data, 0, a.size);
else if( a.type==='ISFT' ) info['tool']=getString(a.data, 0, a.size);
else throw new Error('Sound Font Infoチャンク 不明な識別子があります '+a.type);
}
return info;
};
function getString(array, first, last){
return String.fromCharCode.apply('', array.subarray(first, last).filter(a=>{ return a!==0; }));
};
あまり綺麗な実装ではありませんがchunkの中身のデータをループで回しタグを調べています。知らないタグがあれば例外を投げています、一応Sound fontの規格上、コレ以外の識別子は認めていない。今はメタデータなので殆どが文字列です。
文字列のパースにfilter(a=>{ return a!==0; }
が入りました。これは文字列にNUL=0
が出現した際に出力が化けたので(後で取り除こうとするとコード上でnull文字
をどう書いていいかわからなかった)のでフィルターで事前に取り除いた。
一応コレで、きちんとしたデータが得られたのできちんと読み出されているだろう。
ということでバイナリデータを読むという意味だけならばC言語/C++で書くよりもよっぽど書きやすい。
結局、バイナリデータの難しさはその仕様から読みとかなければならないところにあると思う。C言語あたりは完動品があったりしてそのソースを辿れるからバイナリに強いと言われているんじゃないかと。
最後に
今回は主にUint
系を使ったがもちろん実数型もある。TypedArray-JavaScript | MDN
そしてバイナリデータを読むだけでなくバイナリとしてデータを書くことによりWebAPIの速度を格段に向上させることができる。
JavaScriptはバイナリデータを扱うのが苦手ではなく最新ではむしろ得意、バリバリのバイナリでデータを書いて速度を向上させようと言うことらしい。もちろん、WebAPIに関係ないところで使ってもあんまり恩恵は少ないと思うので使う場面は考えてだろうけどね。
余力があれば次回はSound fontの構造の具体的な説明とJavaScriptによる解析を解説したいと思います。
おまけ(自分用備忘録)
webpack環境設定
プロジェクトディレクトリを作ってnpm init
、必要なもののインストール。(webpack自体はグローバルにインストール済み。)
$ mkdir hoge
$ cd hoge
$ npm init
(対話モード ほとんどyes)
$ npm --save-dev install babel babel-core babel-loader babel-preset-latest
module.exports={
entry: __dirname+"/src/entry.js",
output: {
path: __dirname+"/dist/",
filename: "bundle.js"
},
module: {
loaders: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
query: {
presets: [ 'es2015' ]
}
}
]
}
}
注意、__dirnameはカレントディレクトリ、ないと動かない。
$ webpack
でsrc/entry.jsからbundle.jsを自動生成、コマンドを打たないと当然更新しない。--watch
で自動更新してくれるがトランスパイルエラーが怖いので使っていない。トランスパイルできてもコードが正しいとは限らない。特にタイポ系はオールスルー。
コーディングスタイルなど
JavaScript初級者のためのコーディングガイドを参考にした。
関数の巻き上げはむしろmainの処理を前にかけるので積極的に使うスタイル、巻き上げられる関数はexportしない。