coding, photo, plant and demo

*farbrausch V2 synthesizerをちょっと読んでみた

歯ブラシ解読会があるということで、僕も(発表には間に合わないけど)何か読んでみるかと思った。

farbrauschと言えば、そのintroは圧倒的な物量で見るものを唖然とさせてくれるのだけど、中でも64kbとは思えないリッチな音楽も当時は驚愕ものだった。特にfarbrausch衝撃の出世作であった2000年のfr-08は、64kbでありながら、本編前のローディング中にも専用の曲が流れるという意味不明な豪華さ。正直、当時はどうなってんじゃこりゃ?と笑うしかなかったわけだけど、あれからもう早12年近くが経つのだ。当時岐阜の高専生だった僕も、今や上京し社畜8年目。時の流れは無情である。

そんなfarbrauschが彼らのintro/demoのコアのコードを先日公開した。当時僕らを熱狂させてくれた魔術的コードが白日の下に晒されたのだから、これは何か読むしかないってわけで、とりあえずサウンド部分を見ることにした。最近全然デモシーンにもグラフィック周りにもご無沙汰だったので、たぶん他は読んでも解らんからね(汗)

V2 synthesizerとは何か

その摩訶不思議なfarbrauschのintroに使われているシンセサイザがV2 synthesizerというシステム。farbrauschのkb(Tammo Hinrichs)作。
3オペ16チャンネル 64音同時発声のシンセサイザ。内、スピーチ合成用に1ch。
楽曲作成用にVSTiプラグインやBuzzプラグインが提供されている。

バイナリは1.0が2004年に公開されて、1.5が2008年に公開されている。
http://1337haxorz.de/products.html

ソースは先日から
https://github.com/farbrausch/fr_public/tree/master/v2
で手に入るようになった。

基本スペック

基本スペック自体は普通のシンセかな、という気がする。ただ、この普通といえるレベルのシンセを当時の環境でリアルタイムにデモの裏で動かすという速度面の最適化をすると同時に、64kbの片隅に押し込めるサイズ面での最適化を行っていたというのが凄いのだ。

  • チャンネル
    • 3オシレータ
      • 正弦波、鋸波、三角波、正弦波の(前段オシレータによる)周波数変調、ノイズ、等
    • LFO*2
    • EG(Envelope Generator)*2
    • VCF*2
      • LPF,BPF,HPF,Notch,MoogLPF,MoogHPF
    • VelocityやNote、LFOやEGの出力は殆どあらゆるパラメータに結線可能。
    • 16ch(最終ch)はスピーチシンセサイザに割り振られている。
  • グローバル
    • リバーブ
    • ディレイ
    • コンプレッサ
    • バスブースト

YM2151 好きとしては4オペ(OSC)で複雑なFM変調がかけられるアルゴリズムを持っていると二重丸なんだけどね。kbさんはどうやらFM変調はあまり使わず、VCFやLFO等を駆使したテクノっぽい音作りがメインのようなので、3オペで割り切った構成なんだと思われる。

V2を使ったintro/demo

farbrauschのwerkkzeug3を使ったintroは全てと思われる。てことで、幾つか紹介。個人的にはfr08は当然として、MS2001 inv.の曲が何故か印象に残ってたり。

kbが作曲したintro

kbは作曲していないがv2を使った有名なintro

あと、まだV2はない時代だけどkbが作曲した有名なdemoだと
elitegroup - Kasparov
がある(当時はこれ、賛否両論あったよなあ)。V2のソースコードを見るとelitegroupの文字が見受けられるので、当時からのコードを使いまわしているようだ。

曲の作り方から再生まで

  • VSTiプラグインとして起動
 VSTiプラグインとして提供されているので、適当なVSTホストに読み込ませる。
 例えば、OpenMPTを使う。
 InstrumentにVSTiプラグインを読み込ませると、下記のような画面が出てくる。ソフトシンセに詳しければ、これで大体何ができるかは分る…のかな?DTMやらない僕は最初はよく分りませんでした。大まかには左側が各種モジュールで、右側が各種入力やそれらのモジュール、あるいはモジュール間の結線となります。EGやLFOはここで結線しないと意味ないです。
 ということで、これを弄り回して音色を作っていきます。



  • 音ができたらVSTホストで適当に曲を作る
  • 曲が完成したら、recordボタンを押してから曲を再生すると、V2 synthesizerに投げ込まれたコマンドがダンプされてv2mファイルが出来上がり
  • v2mファイルをlibv2で読み込ませて再生

これで、君のintroにも立派な曲が付けれるぞ、っと。

ファイル構成

v2/以下のファイル構成を洗い出してみた。
これ以外にもファイルは多数あるけど、読まなくても本質的には問題ない。たぶん。

synth.h
synth.asm
 実際のレンダリングを行う中核。
 フルアセンブラ。

v2mplayer.h
v2mplayer.cpp
 v2mのロード、レンダリング。
 werkkzeug3にはこれのコピーが_viruz2.hpp,.cppとして入っている。

dsio.h
dsio.asm
 DirectSound制御モジュール
 何故かフルアセンブラ。

ronan.h
ronan.cpp
 スピーチシンセサイザ。

v2mから再生までの流れ

実際のコードを深堀りする前に、全体の流れをざっくり抑えておこう。

v2m形式

まずはv2mがどんな形式なのかを説明する。
ざっくり言えば、MIDIメッセージのProgram Change,Control Change,Pitch Bend,Note On/Offを記録したもの。
ただし、圧縮率を高めるためにSMF(Standard Midi File)とは格納方式がかなり異なる。
SMFの場合、(Format0,1,2で異なる面もあるが)基本的にはデルタタイム(可変バイト長)+MIDIメッセージを順番に並べるだけである。

v2mでもデルタタイム(ただし固定長)+MIDIメッセージを並べるという点では同じ。ただ、並べ方に工夫がある。
v2mの場合はチャンネル毎、メッセージ種類毎、パラメータのバイト単位で並べなおす。例えば、最初はch.1のProgram Changeのパラメータだけが並び、次にそのデルタタイムの先頭バイトだけが並び、次にそのデルタタイムの2バイト目が並ぶといった具合。SMFがAoSで、v2mはSoAと言えば近いのかな。
これによる利点は2つあって、ひとつは、チャンネルやメッセージの種類を表すMIDIメッセージの最初の1バイトがメッセージ毎に省略できること。次に似た値が並びやすいので圧縮が期待できる点。

このあたりの概略はsoundsys.cppの頭のコメントや、vsti/v2mrecoder.cppのExport関数をみると分りやすい。

v2mのデコード

v2m形式の概要は分ったので、デコードからシンセサイザ本体までの流れを見てみる。このあたりは、v2mplayer.cppを見れば分る。
v2mを読み込むと最初に従いパッチのセットアップやグローバルな設定を行う。
そしてtick毎に、V2MPlayer::Tick()によってv2mに従い、MIDIメッセージを生成へ渡す。具体的には、メッセージを1Tick分書き溜めてから、synth.asmのsynthProcessMIDIを呼ぶ。

生成するMIDIメッセージは以下のみ。
Cn pp Program Change
Bn cc vv Control Change
En ll mm Pitch Wheel Change
9n kk vv Note On
synth.asmはNote Off(1000nnnn)は受け付けない。代わりにNote Onのvelocityを0としてNote Off扱いとする。

MIDIメッセージの詳細は
http://www.midi.org/techspecs/midimessages.php
を参照。

synth.asmのインタフェイスがMIDIメッセージなのは、VSTi対応のためと思われ。

synthでレンダリング

では、このMIDIメッセージからどう波形をレンダリングしているのか?

その答えがsyhth.asmにある。ここが肝心要なのであるが、FPU命令だらけのフルアセンブラであり、高校生のころx86逆アセンブラを自作していたような自分でもなかなか読むのが辛い。はっきりいってCで書いてあれば、なんてことはないコードのはずなんだが…

データ構造を大まかに捉える

まずはどんなデータ構造があるのかを大雑把に掴んでおく。
SYNが基点となるsynthesizer本体の構造体となる。ポイントとなるプロパティだけを見てみる。

struc SYN
.patchmap resd 1
 .chanmap    resd POLY
 .chans    resb 16*CHANINFO.size
 .voicesv  resb POLY*syVV2.size
 .voicesw  resb POLY*syWV2.size
 .chansv       resb 16*syVChan.size     
 .chansw       resb 16*syWChan.size

patchmapはpatch(v2soundで定義される構造体。いわゆるinstrument)への配列ポインタ。

voicesとchans(チャンネル)は見れば分るが、末尾のvとwはなんだろう?
コードを読むと分るが、vがv2sound(値をBYTEで持っている)と互換を持っていて最初にv2soundから展開される構造体で、値をfloatで持つ。wは実際に発声するときに使うworkspaceで、Vが持っているプロパティに加え、各種テンポラリ変数を持っている。
v2soundとVとWの間でどのようにコピーされるかはstoreChanvaluesやsyV2Set、syChanSet等を読むと分る。

syVV2,syWV2の中にあるsyVOsc,syWOscや、syVLFO,syWLFOなども、同様の関係。

chanmapはどのボイスをどのチャンネルが使っているかを示してる。

大まかに各構造体の関係をまとめると、
  • SYN has Patches(v2sound), Voices(syVV2), Channels(syVChan), channel map(voiceとchannelの関係)
  • Voice has 3 OSCs(syVOsc), 2 VCFs(syVFlt), 2 LFOs(syVLFO), 2 Envelope Generators(syVEnv), 1 Distorter(syVDist)
  • Channel has 1 BassBoost(syVBoost), 1 Distorter(syVDist), 1 Chorus(syVModDel)
  • syVxxxが最初にv2soundを元にパラメータ設定される構造体で、syWxxxが実際に発生する際に使われる構造体(workspace)

ノートONを追う

ではそろそろv2mplayer.cppとの繋ぎから見ていこう。
v2mplayerで作られたMIDIメッセージは
_synthProcessMIDI
を経由してシンセサイザ本体に取り込まれる。ここでは代表的なProcessNoteOnの挙動を見てみる。
ざっくり見ていくと、空いてるvoiceを探したら(.foundfv)、storeV2Valuesでpatchをvoiceへ設定して、syV2NoteOnでOSCやEGの初期化を行う。先で述べたVとWがややこしいけど、意味が分っていれば読めるはず。
他のMIDIメッセージの処理も同じ感じで追える。と思う。

レンダリングを追う

チャンネルのレンダリング自体は大まかに2つのパートに分かれる。
EGやLFOの更新を行うsyV2Tick(EGやLFOからOSC等へのパラメータ反映はstoreChanValuesのmodmatrixで行う)と実際にレンダリングを行うsyV2Renderだ。

前者はEGとLFOの更新だから、特殊なことはしていないので解説は省く。
後者もそういう意味ではそこまで特殊なことはしていないと思うが、OSCとVCF(Filter)の代表的な実装だけを見ていこう。

OSC(Oscillator)

まず、音の基本となるOSCから。これはsyOscRenderでレンダリングしている。
modeによってtri/saw(三角波、鋸波),pulse(矩形波),sin,noise,FM変調sin,auxa,auxbが切り替わる。
auxa/auxbは外部入力を想定しているのだけど、v2mでは関係ないっぽい。

鋸波/三角波

てことで、まずはシンセサイザの基本、鋸波から見ていく。
これは
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波/fm sin波

鋸波がかなり難解な作りになっているので、これが分ればあとは簡単。sin波はfsinを使わず7次の多項式で近似してるのがちょっとしたポイント。

http://www.agner.org/optimize/instruction_tables.pdf
によると最近のCPUでもfsinは100clk程度は見ておく必要があるようなので、今でもこれは有効だと思う。fsinだと倍精度で計算しちゃうだろうから、オーバースペックだしね。
ちなみに、さらに高速化・高精度にしたいなら、区間分割して(cntの上位ビットで振り分ける)それごとに係数を変える、さらに係数をテイラー展開ではなくミニマックス近似を行うのが定石。

fm sin波では、前段までのOSCの出力をcntに足す、いわゆるFM変調を行う。

あとnoiseとか矩形波があるけど省略。OSCはそんな感じ。

VCF(Filter)

次にフィルタを見ていく。
チャンネル単位で掛けられるLPFなどのフィルタはsyFltRenderに書かれている。
ここには普通のLPF/HPF/BPF/NotchとMoog LPF/HPFがある。
LPF/HPF/BPF/Notchは基本の構成っぽいので説明を割愛。アナログ回路でもこれらのフィル対は単純化して言えば、RLC回路のどこの電圧を測るかだけの問題なので、プログラム上も同じ処理をしてどの数値を拾うだけかの問題。

フィルタ自体の原理は僕が大昔書いた
http://kmkz.jp/mtm/mag/lab/digitalfilter.htm
と似たようなもんです。今見ると数式が多いな。難しくないはずだけど、数式があると難しく感じる不思議。

moog filterが気になるけど、moogは全然分らないので説明できないな。資料をまず探さなきゃ。スピーチシンセのronanも見てないし(これはCだし読むのは容易いけど)。ということで、時間がなくなってきたのでまた続く!かも。