この記事の目的
前回の続き。JavaScriptにはWebAudioAPIがあるし音楽系に強いんじゃねと思って書き始めた。意外ときちんとSoundFontの仕様を解説してるサイトがほとんどない、最もきちんとしていたのはSoundFont 2.04 仕様の概要まとめであった。概要はそこで確認してもらうとしてここでは構造体の定義など細かな部分を見ながらJavaScriptで解析していく。
Sound fontの構造
RIFF形式で見ると
- INFOチャンク
- メタデータ
- sdtaチャンク
- smplチャンク サンプリングデータ
- sm24チャンク 下位24ビット(任意なのでない場合もある、というか殆ど無い)
- pdtaチャンク
- phdrチャンク---プリセットヘッダー
- pbagチャンク---プリセットのゾーンを定義
- pmodチャンク---プリセット用の変調器(Mudulator)を定義
- pgenチャンク---プリセット用の生成器(Generator)を定義
- instチャンク---インストルメントパネルを定義
- ibagチャンク---インストのゾーンを定義
- imodチャンク---インスト用の変調器(Modulator)を定義
- igenチャンク---インスト用の生成器(Generator)を定義
- shdrチャンク---サンプルヘッダー
メタデータは任意(あってもなくてもいいもの)が多いが、それ以降ははないといけない、順番も決まっている。唯一の例外が下位ビットデータsm24
である。
function check(data){
if( data.type!=='sfbk' ) throw new Error('Sound Fontファイルではありません');
if( data.data.length!==3 ) throw new Error('Sound Fontファイル形式が不正です');
if( data.data[0].type!=='INFO' ) throw new Error('Sound Fontの1つ目のチャンクはINFOであるべきです');
if( data.data[1].type!=='sdta' ) throw new Error('Sound Fontの2つ目のチャンクはsdtaであるべきです');
if( data.data[2].type!=='pdta' ) throw new Error('Sound Fontの3つ目のチャンクはpdtaであるべきです');
if( data.data[1].data[0].type!=='smpl' ) throw new Error('sdtaチャンクの1つ目はsmplであるべきです');
if( data.data[1].data[1] && data.data[1].data[1].type!=='sm24' )
throw new Error('sdtaチャンクのふたつ目のチャンクがsm24ではありません');
if( data.data[2].data[0].type!=="phdr" ) throw new Error('pdtaのひとつ目のチャンクはphdrであるべきです');
if( data.data[2].data[1].type!=="pbag" ) throw new Error('pdtaのひとつ目のチャンクはpbagであるべきです');
if( data.data[2].data[2].type!=="pmod" ) throw new Error('pdtaのひとつ目のチャンクはpmodであるべきです');
if( data.data[2].data[3].type!=="pgen" ) throw new Error('pdtaのひとつ目のチャンクはpgenであるべきです');
if( data.data[2].data[4].type!=="inst" ) throw new Error('pdtaのひとつ目のチャンクはinstであるべきです');
if( data.data[2].data[5].type!=="ibag" ) throw new Error('pdtaのひとつ目のチャンクはibagであるべきです');
if( data.data[2].data[6].type!=="imod" ) throw new Error('pdtaのひとつ目のチャンクはimodであるべきです');
if( data.data[2].data[7].type!=="igen" ) throw new Error('pdtaのひとつ目のチャンクはigenであるべきです');
if( data.data[2].data[8].type!=="shdr" ) throw new Error('pdtaのひとつ目のチャンクはshdrであるべきです');
return true;
};
というcheck関数でチェックします受け取りは前回作ったRIFF形式をオブジェクトにしたものです。
各チャンクはそれぞれのデータを直列化(ただ並べただけで)どこにどう配置されてるかはヘッダー系から読み解きます。
ユーザー視点から(構築後は)は
- Preset1
- Global Zone
- Zone-Instrument
- Global Zone
- Zone-Sample
- Zone-Sample
- Zone-Instrument
- ...
- Preset2
- ...
の様になります。音の高さや長さによりゾーンが割り振られその中にはいくつかののGeneratorとModulatorを持ちます。Preset/Instrument全てに適応されるGlobal Zoneを除いてゾーンにはPresetはInstrumentに、InstrumentはSampleにつながっています。
このゾーンはpbag/ibag
で定義されています。Generatorはpgen/igen
、Modulatorはpmod/imod
で定義されています。こうしてみると、PresetとInstrumentの構造は似ていますね。
構造体の定義
バイナリデータは構造体になっています。元は仕様書をあたってもらうとして仕様書のDWORD,WORD,BYTE
がわかりにくかったのでc言語っぽく書きます。(文字のCHARと数字で使うCHARが混在してるのとかヤメて...)
struct sample_header{ // shdr
char name[20]; // 1byte×20
uint32 start;
uint32 end;
uint32 loop_start;
uint32 loop_end;
uint8 original_key;
int8 ch_correction;
uint16 sample_link;
uint16 sample_type; // SFSampleLink
} // 42 byte
最初にコメントアウトしているshdr
は出現チャンクを表しています。最後にコメントアウトしてるSFSampleLink
は仕様上はEnumerator
とされていますが実体は2byteの数字なのでそれを優先して書きます。(読みだすときはサイズが重要なんだから変に書かないで欲しい)。
struct instrument{ // inst
char name[20];
uint16 inst_bag_Ndx;
}; // 22byte
inst_bag_Ndx
ですが開始位置を示しています。終了位置は次のデータを読んでその開始位置から判定します、そのせいで結構ハマりました。
(最初の開始位置は0しかありえないんだから素直に終了位置書いておけばそれだけで完結したのに...。)
struct preset{ // pdhr
char name[20];
uint16 preset;
uint16 bank;
uint16 preset_bag_Ndx;
uint32 library
uint32 gen;
uint32 morphology;
} // 38 byte
library,gen,morphology
は将来のために確保しているだけらしい。実際全てに0が詰まっていた。
struct bag{ // pbag/ibag
uint16 generator_Ndx;
uint16 modulator_Ndx;
}; // 4byte
struct generator{ // pgen/igen
uint16 generator_oper; // SFGenerator
?int16 amount; // genAmountType
} // 4byte
はい、?はタイポではありません。Generatorの種類によって変わるらしいです。ある場合はlow, high
の範囲を表すものまたある時はuint16
整数値、でも大体int16
になるらしいです。
どれが何になるかはっきりした記述はなかったけれども大体実装していくうちに気づきました。それは実際に読み込む場面で解説します。
struct modulator{ // pmod/imod
uint16 modulator_src_oper; // SFModulator
uint16 generator_dest_oper; // SFGenerator
int16 amount;
uint16 modulator_amp_src_oper; // SFModulator
uint16 modulator_trans_oper; // SFTransform
}; // 10byte
とコレだけの構造体の情報があれば読んでいけるはずです。
##実際に読んでみる。
main部分
import SoundFont from "./sf2/SoundFont.js";
export function make(promiss){
return promiss.then(data=>{
const info=makeInfo(data.data[0]);
const samples=makeSamples(data.data[2].data[8], data.data[1]);
const inst_list=parseInst(data.data[2].data[4]);
const insts=makeInst(inst_list, data.data[2].data[5], data.data[2].data[6], data.data[2].data[7], samples);
const preset_list=parsePreset(data.data[2].data[0]);
const presets=makePreset(preset_list, data.data[2].data[1], data.data[2].data[2], data.data[2].data[3], insts);
return new SoundFont(info, presets);
});
SoundFont
はクラスで定義していますが今はなんのメソッドもありませんただのJavaScriptオブジェクトです。何かを実装するときはクラスのメソッドとして定義します。
PresetはInstrumentを参照してInstrumentはSampleを参照しています。参照されるものが解析されていないと場所がわからないのでSample、Instrument、Presetの順に解析します。
ちょっとdata
だらけになって何がなんだかな感じなってますが...。それは各メソッドの引数の名前で定義してます。
Sampleの解析
function makeSamples(shdr, sdta){
let samples=[];
for( let i=0; i<shdr.data.length; i+=46 ){
const start=getDword(shdr.data, i+20);
const end=getDword(shdr.data, i+24);
samples.push({
name: getString(shdr.data, i, i+20),
sample: getSampleData(sdta, start, end),
loop_start: getDword(shdr.data, i+28)-start,
loop_end: getDword(shdr.data, i+32)-start,
sampling_rate: getDword(shdr.data, i+36),
origin_pitch: shdr.data[i+40],
correction: getChar(shdr.data, i+41),
sample_link: getWord(shdr.data, i+42),
sample_type: getWord(shdr.data, i+44)
});
}
return samples;
};
function getSampleData(sdta, start, end){
const smpl=sdta.data[0];
const sm24=sdta.data[1];
let int_data=new Int16Array(new Uint8Array(smpl.data.subarray(2*start, 2*end)).buffer);
let data=new Float32Array(int_data.length);
if( sm24 ){
let int_sm24=new Int8Array(new Uint8Array(sm24.data.subarray(start, end)).buffer);
for( let i=0; i<int_data.length; i++ ) data[i]=(256*int_data[i]+int_sm24[i])/(32767.*256.); // Max of int16_t*uint8_t
}
else for( let i=0; i<int_data.length; i++ ) data[i]=int_data[i]/32767.; // Max of int16_t
return data;
};
前の記事のようにTypedArrayを使ったほうが高速らしいのでTypedArrayを使います。不動点少数なのでFloat32Array
を使いますがそのまま入れるとビットの関係上変な値なったので一度Uint16Array
を経由させています。TypedArrayのコンストラクタにbit flagがあったけど使いこなせそうにないので...。
Instrument
function parseInst(inst){
let insts=[];
for( let i=0; i<inst.data.length; i+=22 ){
insts.push({ name: getString(inst.data, i, i+20), Ndx: getWord(inst.data, i+22+20) }); // 次のinstのNdxを入れている。
}
return insts;
};
function makeInst(inst_list, ibag, imod, igen, samples){
let bIndex=0;
let gIndex=0;
let mIndex=0;
let insts=[];
for( let i=0; i<inst_list.length; i++ ){
let inst_obj={ name: inst_list[i].name, zones: [] };
for( let j=bIndex; j<inst_list[i].Ndx; j++ ){
let gNext=getWord(ibag.data, 4*(j+1)); // 次の要素のインデックスを読む。
let mNext=getWord(ibag.data, 4*(j+1)+2);
let zone={ generators: {}, modulators: [] };
for( let k=gIndex; k<gNext; k++ ){
let gen=getGenerator(igen.data.subarray(4*k, 4*(k+1)));
if( k===gNext-1 ){
if( j===bIndex && gen.name!=="sample_id" ){
if( zone.generators[gen.name] ) throw new Error("同じGenerator IDがあります");
zone.generators[gen.name]=gen.data;
}
else if( gen.name!=="sample_id" ) throw new Error("Generatorの最後がSample IDでありません");
else zone.sample=samples[gen.data];
}
else{
if( zone.generators[gen.name] ) throw new Error("同じGenerator IDがあります");
zone.generators[gen.name]=gen.data;
}
}
for( let k=mIndex; k<mNext; k++ ){
zone.modulators.push(getModulator(imod.data.subarray(10*k, 10*(k+1))));
}
if( zone.sample ) inst_obj.zones.push(zone);
else inst_obj.global_zone=zone;
gIndex=gNext;
mIndex=mNext;
}
bIndex=inst_list[i].Ndx;
insts.push(inst_obj);
}
return insts
};
function getModulator(array){
return { mod_src_oper: getWord(array, 0),
gen_name: getGeneratorName(getWord(array, 2)),
mount: getShort(array, 4),
mod_amt_src_oper: getWord(array, 6),
mod_trans_oper: getWord(array, 8)
};
};
function getGeneratorName(id){
let name_list=[
"start_addrs_offset", "end_addrs_offset", "start_loop_addrs_offset", "end_loop_addrs_offset", "start_addrs_coarse",
"mod_flo_to_pitch", "viv_flo_to_pitch", "vib_env_to_pitch", "init_filter_Fc", "init_filter_Q",
"mod_flo_to_filter_Fc", "mod_env_to_filter_Fc", "end_addrs_coarse_offset", "mod_flo_to_volume", "unused1",
"chouse_effect_send", "reverb_effect_send", "pan", "unused2", "unused3",
"unused4", "decay_mod_flo", "freq_mod_flo", "delay_vib_flo", "freq_viv_flo",
"delay_mod_env", "attack_mod_env", "hold_mod_env", "decay_mod_env", "sustain_mod_env",
"release_mod_env", "keynum_to_mod_env_hold", "keynum_to_mod_env_decay", "delay_vol_env", "attack_vol_env",
"hold_vol_env", "decay_vol_env", "sustain_vol_env", "release_vol_env", "keynum_to_vol_env_hold",
"keynum_to_vol_env_decay", "instrument", "reserved1", "key_range", "vel_range",
"start_loop_addrs_coarse_offset", "keynum", "velocity", "init_attenuation", "reserved2",
"end_loop_addrs_coarse_offset", "coarse_tune", "fine_tune", "sample_id", "sample_modes",
"reserved3", "scale_tuning", "exclusive_class", "overriding_root_key", "unused5",
"end_oper" ];
return name_list[id];
};
function getGenerator(array){
let name=getGeneratorName(getWord(array, 0));
if( !name ) throw new Error("tmp exception");
if( name==="sample_id" || name==="instrument" ) return { name: name, data: getWord(array, 2) };
if( name==="key_range" || name=="vel_range" ) return { name: name, data: [array[2], array[3]] };
return { name: name, data: getShort(array, 2) };
};
inst
だけ先に分割しています。前に言ったように次の開始要素を取ってくるためにNdx
の部分だけオフセットを載せています。sample_ID/instrument
は各ゾーン、除くグローバルゾーン、のGeneratorの最後に現れるはずなのでチェックを入れています。次の要素に気づくまでこの例外でことごとく死んでいました。
getGeneratorNameは仕様書にあるEnumratorを全部列挙しています。(もっと良い実装ありそうだがとりあえずコレで逃げておく...)
そしてGeneratorのamount
ですが多分key_range, vel_range
のみHi/Lo
、`SampleID/Instrument'のみ整数型だと思うのでそこだけ構造を変えています。
###Preset
function parsePreset(phdr){
let presets=[];
for( let i=0; i<phdr.data.length; i+=38 ){
presets.push({ name: getString(phdr.data, i, i+20),
MIDInumber: getWord(phdr.data, i+20),
bank: getWord(phdr.data, i+22),
Ndx: getWord(phdr.data, i+38+24), // 次のphdrのNdxを入れている。
library: getDword(phdr.data, i+26),
generator: getDword(phdr.data, i+30),
morphology: getDword(phdr.data, i+34)
});
}
return presets
};
function makePreset(preset_list, ibag, imod, igen, insts){
let bIndex=0;
let gIndex=0;
let mIndex=0;
let presets=[];
for( let i=0; i<preset_list.length; i++ ){
let preset_obj={ name: preset_list[i].name,
MIDInumber: preset_list[i].MIDInumber,
bank: preset_list[i].bank,
library: preset_list[i].library,
generator: preset_list[i].generator,
morphology: preset_list[i].morphology,
zones: [] };
for( let j=bIndex; j<preset_list[i].Ndx; j++ ){
//**********************************//
この部分はInstrumentと同じなので省略
//**********************************//
bIndex=preset_list[i].Ndx;
if( preset_obj.name!=="EOP" ) presets.push(preset_obj);
}
return presets;
};
Presetは一番上位にあるので終端データ(Terminator)を除外しておきます。Presetから紐付けされるInstrument以下は終端データに紐がつかないので勝手に捨てられます。
エントリーポイント
最後にエントリーポイントを前回のRIFF形式を読むためのriff.js
と今回のsf2.js
を読み込んでる。終了確認はPromiseのコールバックの中で出している。
const riff=require("./riff.js");
const sf2=require("./sf2.js");
window.addEventListener("load", ()=>{
console.log("===== Initilization START =====");
let riff_data=riff.read("sf2/hoge.sf2");
let sf_promiss=sf2.make(riff_data);
sf_promiss.then(sound_font=>{
console.log(sound_font);
console.log("===== Initilization FINISH =====");
});
});
かなり泥臭い実装になってしまった。どうしても低レベルなデータを扱うとそうなってしまう気がする。あとModulatorの意味がわからなかったのでそのままスルーに近い実装になっているので意味が理解できたら実装を変えるかもしれません。
SoundFontの実装は
import sf2Info from "./sf2Info.js";
import sf2Preset from "./sf2Preset.js";
export default class SoundFont{
constructor(info, presets){
this.info=new sf2Info(info);
this.presets=[];
for( const preset of presets ){
this.presets.push(new sf2Preset(preset));
}
}
};
このようになっており、サウンドフォント系のデータはnew
でラップしてある(他のsf2XXXX
も同様)。今はメソッドはないがここに実装を入れることにより泥臭いバイナリデータ部分と切り分けたい。
成功すれば巨大なオブジェクトがコンソールに出力される。それがSoundFontである。