JavaScript
バイナリ
es2015
SoundFont

JavaScriptでSoundFont2を解析

More than 1 year has passed since last update.

この記事の目的

前回の続き。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である。

sf2.js
    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部分

sf2.js
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の解析

sf2.js
    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があったけど使いこなせそうにないので...:disappointed_relieved:

Instrument

sf2.js
    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

sf2.js
    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のコールバックの中で出している。

entry.js
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の実装は

SoundFont.js
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である。