シンセプログラミング

Synth1の内部構造や、処理のノウハウ等を少しずつ説明していきます。書いていく内容の順番は、気の向くままに。 ただし、私の知識や実力も十分ではないので、内容の保証はありません。間違い等があればやさしく指摘してください(笑)。

目次

  • 開発環境について '06.5.5
  • オシレーター部(WAVEテーブルとBLIT) '06.5.5
  • オシレーター部(パルス波とノイズ) '06.5.14
  • オシレーター部(分岐をなくす) '06.5.14
  • オシレーター部(位相変数) '06.5.14
  • オシレーター部(float->intキャスト) '06.5.14

  • 開発環境について

    基本的なサイクルは、以下のとおり。
    1.ソースコード書く
    2.コンパイル・リンク
    3.Synth1の起動
    4.実際にMIDIキーボードから音を鳴らしてテスト
    で、これを延々と繰り返す。 ある程度動くソースが書けたら、即座にコンパイルして音を鳴らしてテストみる、というのが自分のスタイルだ。 ちょっとした調整的な修正なら、数十秒置きにこのサイクルを繰り返す。 だから、コンパイル時間や、鳴らすためにSynth1の立ち上げの時間を短縮することが開発効率に大きくかかわってくる。

    コンパイル時間に関しては、昔に比べるとハードウェアが進化したおかげで、あまり気にならなくなった。 開発環境には、Visual Sudio6.0 を使ってはいるが、makeファイル生成ツールと化している。 コンパイルは、常にnmakeを使って、コマンドプロンプトで行っている。 テキストエディタは秀丸だ。

    ちなみに、丁度今回のV1.07からは、コンパイラに「Microsoft Visual C++ Toolkit 2003」を使っている。 これは、MSからフリーで提供されているのだけど、なかなかできがよいと思う。 自動的にSSE/SSE2コードへ展開する最適化オプションも備えている。ただし、Synth1では、このオプションは使っていない。 なぜかというと、わりと適当に書いた個所は効果があったけども、手動でSSEを使ったり手間をかけた個所ではむしろ遅くなった。 全体としては、やや遅くなるようだった為。ただし、現在の自分のマシンのCPUは、PentiumMなので、別のCPUでは また違うかもしれない。

    Synth1の立ち上げには、VSTホスト等は使わない。 実は開発時には、スタンドアロンで動作するようなモジュールとして開発している。 これは、sonar等のホストアプリの起動の手間の省略と、独自のデバッグ用ウィンドウの表示を 実現するため。 ただし、MIDIイベントの受信と、サウンドを鳴らす処理を自前で行う必要がある。 これにはWindowsマルチメディアAPI (mmioOpen()などなど) を使っている。。。いや、使っていた。 つい最近、サウンド処理の部分はASIOに書き換えた。これでレイテンシが小さくなってとても弾きやすくなった。


    オシレータ部(WAVEテーブルとBLIT)

    最初に白状すると、波形の生成には、WAVEテーブル参照方式+一次補間を使っている。ただしノイズ波形は除く。 「なあんだ」と思う人もいるかもしれない。そう、Synth1はVirtualアナログとか言っておきながら、 今はやり(?)の、「BLIT」を用いたものではない。 「BLIT」とは何かというと、原理的にエイリアスノイズが発生しない波形生成方式のひとつ。 エイリアスノイズの説明は他によい説明がたくさんあると思うで、割愛する。 BLITは「Band Limited Impulse Train」の略で「帯域制限されたインパルス列」?。 10年くらい前に Stilson & Smithが発表した技術で、これ Alias-Free Digital Synthesis of Classic Analog Waveformsがその論文。 Music-DSPのBandlimited waveforms synopsis.(のwaveforms.txt)にも もうちょっと素人にわかりやすく説明されている。ここにはBLIT以外にもエイリアスを発生しない方式が紹介されている。 しかし、全てを理解するのはハッキリ言って難しいです、これ。私も完全には理解できていません。(数学も英語もだめな私。とほほ) また、BLITを使ったSAW/SQUAREオシレータをCで実装したコードは、Synthesis ToolKitにある。 今度KORGからでるシンセRadiasにもエイリアスのないオシレータが装備されるようだが、これもBLITの応用だろうか?。 とにかくBLITは、基本的なアナログシンセの波形に関してはエイリアスノイズがでないという優れもの。

    こんないいものをなんで使っていないかと言うと、そもそも開発をはじめた頃にはBLITなんか聞いたこともなかった、 というのがある。しかし知ってからも、この処理は重たそうだと思って敬遠してた。 WAVEテーブル方式のいいところは軽いこと。そして、少しだけ手間をかければ、エイリアスも気にならない程度に使いものになるのだ。 実際ハード/ソフトを問わず、アナログエミュレーションのオシレータのほとんどは、 WAVEテーブルを基本としているのではないだろうか?と勝手に思っている。

    Synth1では、SAW/TRIANGLE波形は、周波数に応じた複数のWAVEテーブルをもっている。 理想的なノコギリ波や、三角波は、無限個の倍音を含んでいる。 しかし、この無限個の倍音は、デジタルでは表現できない。 例えばサンプリング周波数(fs)が44100Hzの場合に表現できる最高の周波数はその半分の22100Hzなので、 22100Hz以上の倍音は表現できずに、エイリアスノイズになる。 たとえば、発生すべき音が100Hzの場合には、その倍音の周波数は、200,300,400,...,22100となり、220番目までの 倍音は表現できるが、それ以降の倍音は鳴らしてはいけない。あるいは、発生すべき音が5KHzだったならば、 10K,15K,20Kと、3つの倍音までしかその波形には、含んではいけない。 これを実現するために、鳴らすべき周波数に応じて倍音の個数を調整したテーブルを複数用意している。 なお、律儀に全周波数帯域にわたってこれをやると、先の220個の例からもわかるように 莫大な数のテーブルが必要になる(もし50Hzから用意すると440個)が、1テーブルのサイズにもよるが、これは現実的ではない。 実は、エイリアスノイズがより目立つのは、高い音を鳴らした時だ。Synth1では、この性質を利用して、数十個のテーブルで済ましている。


    オシレータ部(パルス波と、ノイズ)

    サイン波そのものには倍音はない。ゆえに、ただ一種類のテーブルのみを用意すればよい。 パルス波(矩形波)は、2つのノコギリ波を横に(=時間軸方向に)ずらして差をとる事で生成できる。 そして、ずらす量によってパルスの幅が変化する。ゆえに、専用のWAVEテーブルはもたない。 Synth1では、ひとつのテーブルサイズは、2048サンプルだ(float型を使っているので、メモリサイズは、8KB)。 これを、信号生成ループ内で、1次補間しながら読み出している。1次補間といっても、結構強力で 「コンピュータ音楽」によると、1024サンプルのテーブルの場合だと、補間なしの場合のS/N比は48db、 補間ありで109dbに改善するようだ。

    ノイズの生成には、周期が、(2^24)-1のM系列を使っている。 これは、 「ディジタル信号処理の基礎」に具体的な生成方法が説明されていて、それをほぼそのまま使っている。 おそらく、webにも同様の情報はあると思う。


    オシレータ部(分岐をなくす)

    オシレータ部においては、高速化のために以下のような事を行っている。
    1.信号生成ループ内の分岐(=if文)を徹底的になくす。
    2.位相の変数には、整数型を使い固定小数点として計算する。
    3.小数型(float)から整数型へのキャストは、自前でキャストルーチンを書く(=VisualC++標準のキャストを使わない)。
    以上、インパクトの大きいもの順だ。

    1.信号生成ループ内の分岐(=if文)を徹底的になくす
    とにかく、分岐をなくす事を考える。今の時代でも(だからこそ?)、この効果は非常に大きいのだ。これについて考えてみよう。 信号生成のアルゴリズムは、RingスイッチやSyncスイッチのon/off状態、FMツマミが0以上かどうか等によって変わる。 これを「素直に」実装すると、こんな感じになると思う(これ以外にも分岐条件として波形の種類や、oscのデチューン有無などあるが省略)。

    // 信号生成関数
    void generateSignal(
        float *out,  // 信号出力バッファ
        int   size,  // 出力サンプル数
        bool  ring,  // Ringモジュレーションスイッチ
        bool  sync,  // Syncスイッチ
        bool  fm,     // FM処理が必要かどうか
        ...          // 他いろいろ
    ){
        for(i=0;i<size;i++){
            // osc1生成
            float osc1_out = ....;
    
            // osc2生成
            float osc2_out = ....;
            if(ring){        // リングモジュレーション時は、OSC2の出力はOSC1と掛け算。
                osc2_out *= osc1_out;
            }
    
            // osc1とosc2をミックスして、出力バッファに格納
            out[i] = ....;
    
            // osc1の位相を進める
            if(fm){          // FM有りの時の位相処理
                osc1_phase = ....;
            }else{           // FMなしの時の位相処理
                osc1_phase = ....;
            }
    
            // osc2の位相を進める
            osc2_phase = ....;
            if(sync){        // シンク有りの時の位相処理
                osc2_phase = ....;
            }else{           // シンク無しの時の位相処理
                osc2_phase = ....;
            }
    
        }
    
    	return;
    }
    
    このコードは、Ring/Sync/FMの状態に応じて動作する「汎用の」ルーチンと言える。しかし、分岐命令を多く使っているので 遅い。高速化のためには、状態に応じて分岐をなくした専用ルーチンを用意して、それを適切に呼び出すという方法がよいと思う。 しかし、言うのは簡単だが、とっても面倒くさい。 この例でも、on/offをとるパラメータが3つ(ring/sync/fm)あるので、2^3=8種類の専用ルーチンが必要になる。 実際「手作業でif文を削除して。。。」なんて事はメンテナンス性を考えてもやってられない。 という事で、富豪的なアプローチだが、「少し手作業」+「インライン化」+「コンパイラ最適化」のあわせ技で対処を行っている。 こんな具合だ。
    // 信号生成関数
    void generateSignal(
        float *out,  // 信号出力バッファ
        int   size,  // 出力サンプル数
        bool  ring,  // Ringモジュレーションスイッチ
        bool  sync,  // Syncスイッチ
        bool  fm,     // FM処理が必要かどうか
        ...          // 他いろいろ
    ){
    	// 状態に応じて適切な信号生成ルーチンを呼び分ける。
    	if(ring){
    		if(sync){
    			if(fm) generateSignal_RSF(out,size,...); // ring sync fm
    			else   generateSignal_RS_(out,size,...); // ring sync
    		}else{
    			if(fm) generateSignal_R_F(out,size,...); // ring fm
    			else   generateSignal_R__(out,size,...); // ring
    		}
    	}else{
    		if(sync){
    			if(fm) generateSignal__SF(out,size,...); // sync fm
    			else   generateSignal__S_(out,size,...); // sync
    		}else{
    			if(fm) generateSignal___F(out,size,...); // fm
    			else   generateSignal____(out,size,...); // 
    		}
    	}
    }
    
    // 信号生成(専用ルーチン)
    void generateSignal_RSF(float *out,int size,...){ generateSignal_generic(out,size,true,true,true,...); }
    void generateSignal_RS_(float *out,int size,...){ generateSignal_generic(out,size,true,true, false,...); }
    void generateSignal_R_F(float *out,int size,...){ generateSignal_generic(out,size,true,false,true,...); }
    void generateSignal_R__(float *out,int size,...){ generateSignal_generic(out,size,true,false,false,...); }
    void generateSignal__SF(float *out,int size,...){ generateSignal_generic(out,size,false,true,true,...); }
    void generateSignal__S_(float *out,int size,...){ generateSignal_generic(out,size,false,true,false,...); }
    void generateSignal___F(float *out,int size,...){ generateSignal_generic(out,size,false,false,true,...); }
    void generateSignal____(float *out,int size,...){ generateSignal_generic(out,size,false,false,false,...); }
    
    // 信号生成(汎用)
    // 必ずインライン展開すること。
    __forceinline void generateSignal_generic(
        float *out,  // 信号出力バッファ
        int   size,  // 出力サンプル数
        bool  ring,  // Ringモジュレーションスイッチ
        bool  sync,  // Syncスイッチ
        bool  fm,     // FM処理が必要かどうか
        ...          // 他いろいろ
    ){
    	中身は、先にあげたgenerateSignal()とまったく同じ
    }
    
    ※Synth1ではringとFMは同時使用できないのでこの通りではない。
     また先にも書いたが波形の種類なども分岐条件となるので、実際は72種類の専用関数がある。
    
    という事で、これでもかなり面倒だがこのようにしておくと、コンパイラによって各専用関数内には汎用関数がinline展開される。 さらに、if文の分岐条件が定数になる為、最適化によって、ブランチレスな(分岐の無い)マシンコードが生成される。 「専用関数なんか用意せずに、generateSignal()のif文ブロックから、直接汎用メソッドに定数を与えて呼び出せばいいじゃん」 という意見もあると思う。しかし、これをやるとインライン展開のおかげで超巨大な関数になってしまい、コンパイラによる最適化 がうまく働かず、かなり遅いコードが生成されると思われる。
    という事で、コンパイラにはもっともっと賢くなってもらいたい。。。
    オシレータ部(位相変数)

    上の続き。

    2.位相の変数には、整数型を使い、固定小数点として計算する。
    位相変数とはWAVEテーブルの読み出し位置を保持している変数のこと。 この読み出し位置を一定の値で増加させていくことで、一定の周波数の音が生成される。 たとえば、サンプリング周波数Fs=44.1K、テーブルサイズ=2048サンプルの場合、位相変数を1サンプルあたり1づつ増加させながら 読み出すと、約22Hz(44.1K/2048=21.53)の音が生成される。 ただし、いろんな周波数の音を生成しなくてはいけないので、位相変数と増分値は小数で表す必要がある。 また、位相変数がテーブルサイズを超えた場合には、サイズに収まるように位相変数を調整しなくてはいけない。
    440Hzの音を再生する場合はこんな感じになる。

    float phase=0;                     // 位相変数
    float delta=440.f / 44100 * 2048;  // 位相増分(440Hzの場合)
    
    for(int i=0;i<size;i++){
        out[i] = pWaveTable[(int)phase];      // テーブルから読み出し
        phase = phase + delta;                // 位相を進める。
        if(phase >= 2048) phase=phase-2048.f; // テーブルサイズに収まるように調整
    }
    
    ※1次補間処理は省略
    
    これといって悪くないコードだ。 しかしなんと、このコードで一番時間がかかる処理は「テーブルからの読みだし」の行のintへのキャストだったりする! ここで、位相変数に固定小数点を使うと、テーブル読み出しでintへのキャストを使わなくてもよくなり、さらに 最後のサイズ調整のif文が不要になるというおまけまでついてきて、高速化が期待できる。 たとえば整数部16bit、小数部16ビットの固定少数点を使うとこんな感じになる。
    unsigned int phase=0;                                     // 位相変数
    unsigned int delta=(int)(440.0 / 44100 * 2048 * (1<<16)); // 位相増分(440Hzの場合)
    for(int i=0;i<size;i++){
        out[i] = pWaveTable[ HIWORD(phase) ];      // テーブルから読み出し。
                                                   //   HIWORDは上位16ビット(この場合整数部)を取得するWindowsマクロ。
        phase = phase + delta;                     // 位相を進める。
        phase = phase & (2048 * (1<<16) - 1);      // テーブルサイズに収まるように調整。(2048*...)は、
                                                   //   コンパイラにより計算済みの値となる事を期待している。
    }
    
    ※1次補間処理は省略
    
    なお整数部11bit、小数部21bitとすると、整数部の最大値は2047となるため最後の調整の行は完全に不要になる。 が、あってもなくても、性能へのインパクトは小さいと思われるので、今のところSynth1では、16bit:16bitを採用している。
    オシレータ部(float->intキャスト)

    上の続き。

    3.小数型(float)から整数型へのキャストは、自前でキャストルーチンを書く(=VisualC++標準のキャストを使わない)。
    先の位相変数のところでは、float->intキャストを回避できたが、それでもどうしても整数変換を行わないと いけない個所は出てくる。信号処理ループの外ならたいしたコストではないのでキャストを使えばいいと思う。 しかし、ループ内では自前の変換処理を使う方が断然速い。
    上でも書いたように、VCコンパイラが吐き出すfloat->intへのキャストルーチンは遅いのだ。 この当たりの詳しい事情は、午後のこ〜だの作者さんの
    ページのNo.009 あたりに書かれている(私もこのページにはお世話になりました)。 FPUには、浮動小数→整数変換時の丸めモードがいくつかあるが、デフォルトでは四捨五入モードとなっている。 Synth1では、切り捨てでなく四捨五入でも要が足りる場面しかないので、こんなインライン関数を定義して、キャストの代わりに使っている。

    inline int _FLOAT2INT(float f)
    {
        int i;
        _asm {
            fld     dword ptr [f]
            fistp   dword ptr [i]
        }
        return i;
    }
    
    オシレータ部では、この整数変換はFM変調の時に使われている。 Synth1の場合FM(周波数変調)は、OSC2の出力(osc2_out)でもってosc1の周波数を揺らすという仕様なので、 osc1の位相変数に対して操作を行う事で実現できる。FM時の位相処理(osc2の出力によって、マイナス方向にも動く)を簡単に書くとこんな感じ。
    osc1_phase = osc1_phase + osc1_delta + _FLOAT2INT(osc2_out * fmAmount * 2048/2 * (1<<16));	// 位相処理
    osc1_phase = osc1_phase & (2048 * (1<<16) - 1);      // テーブルサイズに収まるように調整。
    
    ただし、
    unsigned int osc1_phase; // osc1位相(16:16固定小数)
    unsigned int osc1_delta; // osc1位相増分
    float        osc2_out;   // osc2の出力(-1〜+1)
    float        fmAmount;   // FM変調量(0〜1)
    テーブルサイズ=2048
    
    ちなみに、2行目の位相変数をテーブルサイズにおさめる処理は、固定小数だから単純なビット演算で済んでいる。 もし位相変数が浮動小数だとマイナスの事やらの考慮が必要なので、とっても面倒なコードを書かないといけない。
    Copyright © 2006 by Daichi. All rights reserved.
    return home