てことで、まずはシンセサイザの基本、鋸波から見ていく。
これは
syOscRender.mode0
にコードがある。
.m0casetab dd syOscRender.m0c2, ; ...
dd syOscRender.m0c1, ; ..n , INVALID!
dd syOscRender.m0c212, ; .c.
dd syOscRender.m0c21, ; .cn
dd syOscRender.m0c12, ; o..
dd syOscRender.m0c1, ; o.n
dd syOscRender.m0c2, ; oc. , INVALID!
dd syOscRender.m0c121 ; ocn
まず、ジャンプテーブルの定義から。
なぜ、たかが鋸波で8種(実質6種)も分岐があるのか疑問に思うかもしれないけど、この時点ではよく分らないので次へ。
section .text
.mode0 ; tri/saw
mov eax, [ebp + syWOsc.cnt]
mov esi, [ebp + syWOsc.freq]
; calc float helper values
mov ebx, esi
shr ebx, 9
or ebx, 0x3f800000
mov [ebp + syWOsc.tmp], ebx
fld dword [ebp + syWOsc.gain] ; <g>
fld1 ; 1 <g>
fsubr dword [ebp + syWOsc.tmp] ; <f> <g>
fld1 ; <1> <f> <g>
fdiv st0, st1 ; <1/f> <f> <g>
fld st2 ; <g> <1/f> <f> <g>
ここから実際に波形をレンダリングするコードが始まる。
まずebpにsyWOsc構造体へのポインタが入っている前提。
はじめに定数値などをFPUのスタックに積んでいく。
ここで分りづらいのは、calc float helper valuesの部分だろう。
syWOsc.cntは毎ステップfreqの値が加算される小数部32bitの固定小数点だ。
この上位23ビットをfloatの仮数部に入れて、符号と指数は決めうちにして、1.0から2.0までのfloatを作っている。
その後、そこから1を引いて0.0から1.0にしている。FPUで割り算とかが発生するのを嫌ってこうしてるのかな。
あと、コメントにFPUのスタックの状況が書いてあるが、この<f>がそうやって作った値となる。
なお、スタックは左からst0 st1 st2 st3... という順で書いてある。正直がこれがないとマジでFPUのコードは解読不能。あっても辛いけど。
mov ebx, [ebp + syWOsc.brpt]
shr ebx, 9
or ebx, 0x3f800000
mov [ebp + syWOsc.tmp], ebx
fld dword [ebp + syWOsc.tmp] ; <b> <g> <1/f> <f> <g>
fld1 ; <1> <b> <g> <1/f> <f> <g>
fsubr st0, st1 ; <col> <b> <g> <1/f> <f> <g>
; m1=2/col
; m2=-2/(1-col)
; c1=gain/2*m1 = gain/col
; c2=gain/2*m2 = -gain/(1-col)
fld st0 ; <col> <col> <b> <g> <1/f> <f> <g>
fdivr st0, st3 ; <c1> <col> <b> <g> <1/f> <f> <g>
fld1 ; <1> <c1> <col> <b> <g> <1/f> <f> <g>
fsubrp st2, st0 ; <c1> <1-col> <b> <g> <1/f> <f> <g>
fxch st1 ; <1-col> <c1> <b> <g> <1/f> <f> <g>
fdivp st3, st0 ; <c1> <b> <g/(1-col)> <1/f> <f> <g>
fxch st2 ; <g/(1-col)> <b> <c1> <1/f> <f> <g>
fchs ; <c2> <b> <c1> <1/f> <f> <g>
次に、syWOsc.brptという謎のプロパティが出てくるが、これはVSTiプラグイン上ではcolorとして設定できる値だ。
おそらく、breakpointの略ではないかと思うが、これが鋸波、三角波の頂点の位置を決めている。つまり、colorの値を変えると鋸波~三角波~逆鋸波へと変化していく。mode1の矩形波ではduty比になる。
<b>が先ほどと同様の方法で1.0から2.0へfloatへcastしたもので、<col>がそれから1.0引いたものとなる。この値とgainを使って鋸波・三角波の上りと下りの係数を決める。
; calc state
mov ebx, eax
sub ebx, esi ; ................................ c
rcr edx, 1 ; c............................... .
cmp ebx, [ebp + syWOsc.brpt] ; c............................... n
rcl edx, 2 ; ..............................nc .
.m0loop
mov ebx, eax
shr ebx, 9
or ebx, 0x3f800000
mov [ebp + syWOsc.tmp], ebx
fld dword [ebp + syWOsc.tmp] ; <p+b> <c2> <b> <c1> <1/f> <f> <g>
fsub st0, st2 ; <p> <c2> <b> <c1> <1/f> <f> <g>
cmp eax, [ebp + syWOsc.brpt] ; ..............................oc n
rcl edx, 1 ; .............................ocn .
and edx, 7 ; 00000000000000000000000000000ocn
jmp dword [cs: .m0casetab + 4*edx]
次にcntとbrptから3bitの状態を作り、最初に定義したジャンプテーブルで分岐する。
3bitをocnとすると、oは1個前のループのcntがbrpt未満か、cが前回cntが1周したか(overflowしたか)、nが現在のcntがbrpt未満か、を表す。
三角波の前半の上り坂をphase1として下り坂をphase2とすると、ようやく最初のジャンプテーブルの意味が分ってくる。
syOscRender.m0c2は前回も今回もphase2、
syOscRender.m0c121は前回phase1で、cntが1周してまたphase1になったことを表す。
しかしなぜこんなに場合分けが必要なのかよく分らんな。普通だったら2パターン、多くても4パターンで実装すればいいんじゃね?と素人の僕は思うが、ここまで境界条件に拘る理由は何だろうね。
場合分け後の計算は、コメントに書いてある式の通りなので飛ばす。
.m0pl
fadd st0, st6 ; <out> <c2> <b> <c1> <1/f> <f> <g>
add eax, esi ; ...............................n c
rcl edx, 1 ; ..............................nc .
test byte [ebp + syWOsc.ring], 1
jz .m0noring
fmul dword [edi + 4*ecx] ; <out'> <c2> <b> <c1> <1/f> <f> <g>
jmp .m0store
.m0noring
fadd dword [edi + 4*ecx] ; <out'> <c2> <b> <c1> <1/f> <f> <g>
.m0store
fstp dword [edi + 4*ecx] ; <c2> <b> <c1> <1/f> <f> <g>
inc ecx
jz .m0end
jmp .m0loop
条件分岐後に.m0plに合流する。最後にgainを足して(丸めのため?)、次のジャンプテーブルのために3bitの状態を更新する。出力先はedi+4*ecxの指すアドレスとなる。ring modulationが有効なら、既に書き込まれた値と乗算、そうでなければ加算する。
鋸波がかなり難解な作りになっているので、これが分ればあとは簡単。sin波はfsinを使わず7次の多項式で近似してるのがちょっとしたポイント。
http://www.agner.org/optimize/instruction_tables.pdf
によると最近のCPUでもfsinは100clk程度は見ておく必要があるようなので、今でもこれは有効だと思う。fsinだと倍精度で計算しちゃうだろうから、オーバースペックだしね。
ちなみに、さらに高速化・高精度にしたいなら、区間分割して(cntの上位ビットで振り分ける)それごとに係数を変える、さらに係数をテイラー展開ではなくミニマックス近似を行うのが定石。
fm sin波では、前段までのOSCの出力をcntに足す、いわゆるFM変調を行う。
あとnoiseとか矩形波があるけど省略。OSCはそんな感じ。