こんないいものをなんで使っていないかと言うと、そもそも開発をはじめた頃には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では、この性質を利用して、数十個のテーブルで済ましている。
オシレータ部(分岐をなくす)
オシレータ部においては、高速化のために以下のような事を行っている。
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文ブロックから、直接汎用メソッドに定数を与えて呼び出せばいいじゃん」 という意見もあると思う。しかし、これをやるとインライン展開のおかげで超巨大な関数になってしまい、コンパイラによる最適化 がうまく働かず、かなり遅いコードが生成されると思われる。
という事で、コンパイラにはもっともっと賢くなってもらいたい。。。