Faustとは

Faust (Functional Audio Stream)は、サウンド合成とオーディオ処理のための関数型プログラミング言語です(サイトトップページの直訳)。ウェブ上のIDEで気楽に試せるほか、そのコードをC言語などに出力できるようです。

Qiitaのオーディオプログラミング言語Advent Calendar 2020という素晴らしい記事群を拝見し、何かいじってみたいなと思っていたのですが、ビジュアルプログラミング系のものよりはこういった言語の方が性に合ってそうなのと、環境構築せずにすぐにいじれそうなので、試してみることにしました。関数型言語はほとんど触ったことがないのですが。

この投稿でやりたいこと

サイン波を出すようなデモはよくありますが、流石にそれだけだとつまらないのでFM音源っぽいものを実装してみることにします。自分が昔書いた記事も参考にしつつ進めます。

FM音源を実装しているFaustのサンプルコードをネットで調べてみると、2-opのものはヒットします(例えば、こちら)。ただ、これは愚直に式を実装しているだけであまり面白くありません。下の図のように、オペレータをブロックとして定義し、その組み合わせで波形を調整できるようにすることが目標です。

基礎知識の習得

Faustは文法とか特殊なのでいじりながら覚えていこうと思いますが、それにしたってとっかかりは必要です。faust_jp #1というスライド松浦知也さんが日本語で公開してくれているので、分からなくなったらそちらも見つつで進めます。

実装

ウェブ上のIDEで行います。本格的な開発は手元のエディタでやることになるかもしれませんが、今ちらっと探してみたらvscodeのプラグインがあるようです(!)。

正弦波を出す

初めの一歩

何はともあれ、FM音源の要、正弦波(サイン波)を出力します。IDEを立ち上げ、シンプルにこの二行です。

import("stdfaust.lib");
process = os.osc(500);

ま、それくらいの理解で進みましょう。実際にこれで500Hzのサイン波の音が出ます。めでたい。ライブラリドキュメントを眺めてノコギリ波とか試しちゃいたい人はどうぞいってらっしゃいませ。

なお、上記スクリーンショットはChromeで動かしていますが、Safari(手元の環境はBig sur)だと左側メニューにある「Use AudioWorklet」にチェックが付いていると音が鳴りません。チェックを外すと音が鳴ります。デフォルトでチェックが入っているので気をつけてください。

周波数を可変にする

FaustにはGUIを生成する機能があるので、試してみます。

import("stdfaust.lib");

freq = hslider("frequency",500,500,2000,0.1);
process = os.osc(freq);

はい。

真ん中にある「DSP」タブをクリックすると、横向き(horizontal)のスライダーが現れます。いじると音の高さが変わります。めでたい。

ドキュメントに記載の通りhslider("label",init,min,max,step)とのことなので、ラベル名・初期値・最小値・最大値・値の間隔を入力すればOK。

初期位相をずらす

以前の記事に記載の通り、正弦波の公式は以下の通りです。

y = A sin (2 π f t + φ)

ここのfが周波数でFaustの関数でも引数として指定していますが、φはどうしましょうかね。FM音源ではここが結構キモな訳です。

ドキュメントをみると、ありますね。oscp(freq,phase)と二つの引数を与えてやれば良いみたいです。後で使いましょう。

ADSRによる波形調整

ADSRはAttack, Decay, Sustain, Releaseの頭文字で、主に波形の振幅を調整するパラメータです。音の立ち上がりや減衰、余韻などが設定できます。Faustにはこれらエンベロープに関する関数も用意されています。

import("stdfaust.lib");

gate = button("note on");
freq = hslider("frequency",500,500,2000,0.1);
adsr = en.adsr(0.2, 0.5, 0.8, 0.5, gate);

process = os.osc(freq) * adsr;

音の立ち上がりに対して波形の振幅を調整しなければいけないので、音が鳴り始めるタイミングを作ってやらなければいけません。今回はボタンを配置し、ボタンを押したタイミングで出音、離したタイミングがReleaseが始まることにします。

ボタンはドキュメントに直接の記載はないのですが、押している最中は1、離れたら0が出力されるようです。

ADSRはまさにそのままの関数があり、ドキュメントを読むと、ADSRそれぞれのパラメータと、アタック・リリースのタイミングを示すシグナルを入力してやれば良いようです。

gate: trigger signal (attack is triggered when gate>0, release is triggered when gate=0)

Faust Libraries

なので、ボタンをそのまま突っ込めばOK。

コードは、正弦波の出力後にADSRの値を掛け合わせて音を出しています。実際に動かしてやると、note onボタンを押したら音が立ち上がり始め、ボタンを離した後余韻を残しつつ、音が消えていきます。それっぽくなってきました。素晴らしい。

もっとADSRの余韻に浸りたい方は、直打ちしているそれぞれの値をスライダーなどで設定できるようにしてみてもよいですね。

2-op FM音源

さてどうでしょう、ここまでは良いですかね?

それでは次に、2-OPのFM音源を作りましょう。一番シンプルなFM音源で、2つのオシレーターを組み合わせたものです。過去記事で解説していますが、

y = A sin (2 π f t + φ)

ここのφを定数ではなく、正弦波でいじってやることになります。また、「モジュレータの周波数がキャリアの周波数の整数倍」ってのも重要でした。

import("stdfaust.lib");

gate = button("note on");
freq_carrier = hslider("carrier frequency",500,500,2000,0.1);
freq_modulator = hslider("modulator freq index",1,1,10,1);
amp_modulator = hslider("modulator amp", 1,0,10,0.1);
adsr = en.adsr(0.2, 0.5, 0.8, 0.5, gate);
out_modulator = os.osc(freq_modulator);
process = os.oscp(freq_carrier, amp_modulator*out_modulator) * adsr;

はい。ちょっといきなり行が増えましたが。

amp_modulator * out_modulatorがφに対応します。

モジュレータの周波数とamp(振幅)をいじってやると、かなりそれっぽい音が鳴ります。良き哉。

せっかくなので、モジュレータの出力にもADSRかけてやりましょう。

import("stdfaust.lib");

gate = button("note on");
freq_carrier = hslider("carrier frequency",500,500,2000,0.1);
freq_modulator = hslider("modulator freq index",1,1,10,1);
amp_modulator = hslider("modulator amp", 1,0,10,0.1);
adsr_carrier = en.adsr(0.2, 0.5, 0.8, 0.5, gate);
adsr_modulator = en.adsr(0.8, 0.8, 0.7, 1.0, gate);
out_modulator = os.osc(freq_modulator) * adsr_modulator;
process = os.oscp(freq_carrier, amp_modulator*out_modulator) * adsr_carrier;

まぁ、こんな感じです。音はそれっぽくなってとても嬉しいのですが、冒頭に書いたように、コードが、どうも面白くありません。オペレータが2つあるにも関わらず、コードとしては全てが入れ子になっているためです。何とかしましょう。

オペレータのブロック化

ここで、おもむろにYM2608 OPNAアプリケーションマニュアルから図を引用します。

ここのOP1とOP2と書かれているのがそれぞれオペレータです。どちらも

  • 初期位相を入力としてもらう(βF1(t)とF1(t))
  • 周波数をもらう(ωmとωc:mはモジュレータ、cはキャリアですね)
  • エンベロープ情報をもらう
  • 波を出力する(F1(t)とF2(t))

ところが一緒です。そして、OP1の出力 F1(t)をOP2の入力に入れている(上のコードではout_modulatorの部分)という図です。オペレータを一つの関数にして、それを直列に繋いで音を鳴らすというのを実現してみたいと思います。

オペレータを関数にする

まずオペレータを関数化してみます。

import("stdfaust.lib");

gate = button("note on");
freq_carrier = hslider("carrier frequency",500,500,2000,0.1);
freq_modulator = hslider("modulator freq index",1,1,10,1);
amp_modulator = hslider("modulator amp", 1,0,10,0.1);
adsr_carrier = en.adsr(0.2, 0.5, 0.8, 0.5, gate);
adsr_modulator = en.adsr(0.8, 0.8, 0.7, 1.0, gate);

operator(freq, index, amp, adsr, phase) = os.oscp(freq*index, phase) * amp * adsr;

process = operator(freq_carrier, 
                    1, 
                    1, 
                    adsr_carrier, 
                    operator(freq_carrier, freq_modulator, amp_modulator, adsr_modulator, 0));

とりあえず、operator(freq, index, amp, adsr, phase)という関数を定義してひとしきり引数をまとめてしました。

同じように音は鳴ります。ただ、くどいですが最後の行がいけてない。入れ子になっちゃってるってのもそうですが、手前のoperator関数がOP2で、後ろのoperator関数がOP1なんですよね。ここも信号の処理と同じようにOP1→OP2と並べたいところです。

Faustの入力・出力の概念を(少しだけ)理解する

今更ですが、Faustの入力・出力の考え方を調べてみます。

もともと音声処理用の言語なので、音声処理のブロックを接続して音を加工するといったことを簡単に記述できるようになっています。それが接続用の特殊な文法で、こいつがFaustをややこしくしている原因でもありますが、慣れてくると面白いです。

原文のドキュメントの記載はこちらです。あ、ハイ…といった感じで読む気が失せると思うので、上記のオペレータの例だけ考えます。

2つのオペレータは、OP1の出力がOP2の入力に繋がる、ある意味直列の関係になっています。Faustで直列接続を表す記号は「:」です。Aブロックの出力をBブロックの入力に繋ぐ、は「A:B」で表すことができます。

関数の足らない引数を手前のブロックからの入力に割り当てられる、というルール

これです。今回このルールを使います。

operator(freq, index, amp, adsr, phase)

この関数の5個ある引数のうち、前から引っ張ってきたいのは最後のphaseです。これを手前のオペレータの出力からもらってきたい。その場合、

operator(freq_carrier, 1, 1, adsr_carrier) 

と引数を4つだけ入れておくと、その引数が前から順番に充当され、残った最後のphaseは前のブロックからもらってこれる状態になります。

これが分かれば直列で繋ぐというのも実装できます。やってみましょう。

import("stdfaust.lib");

gate = button("note on");
freq_carrier = hslider("carrier frequency",500,500,2000,0.1);
freq_modulator = hslider("modulator freq index",1,1,10,1);
amp_modulator = hslider("modulator amp", 1,0,10,0.1);
adsr_carrier = en.adsr(0.2, 0.5, 0.8, 0.5, gate);
adsr_modulator = en.adsr(0.8, 0.8, 0.7, 1.0, gate);

operator(freq, index, amp, adsr, phase) = os.oscp(freq*index, phase) *amp * adsr;

op1 = operator(freq_carrier, freq_modulator, amp_modulator, adsr_modulator, 0);
op2 = operator(freq_carrier, 1, 1, adsr_carrier);

process = op1:op2;

はい。だいぶ見通しが良くなりました。op2の引数が一つ足りない状態で記述されているのが分かります。

ここで、「Diagram」タブにご注目。このタブではブロックの接続をビジュアライズしてくれるのですが、まさに意図した結果になりました。満足。

フィードバック回路も作れちゃう?

調子に乗って、もう少し難しいのに挑戦してみましょう。YM2608の一つのオペレータには、フィードバック回路が付いてます。出力したものの一部を自分に戻すというやつです。先の資料を再掲すると、

この部分です。

結論から言ってしまうと、再帰の記法「~」を使うとできるんだそうです。

一つ上のコードでは、OP1は初期位相を入れる必要がなかったので0を代入しました。

op1 = operator(freq_carrier, freq_modulator, amp_modulator, adsr_modulator, 0);

これを、外から入力するようにしたいので、最後の0は省きます。そして、OP1の出力を、OP2と自分の両方に入れる、という記述をします。

import("stdfaust.lib");

gate = button("note on");
freq_carrier = hslider("carrier frequency",500,500,2000,0.1);
freq_modulator = hslider("modulator freq index",1,1,10,1);
amp_modulator = hslider("modulator amp", 1,0,10,0.1);
adsr_carrier = en.adsr(0.2, 0.5, 0.8, 0.5, gate);
adsr_modulator = en.adsr(0.8, 0.8, 0.7, 1.0, gate);

operator(freq, index, amp, adsr, phase) = os.oscp(freq*index, phase) * amp * adsr;

op1_ = operator(freq_carrier, freq_modulator, amp_modulator, adsr_modulator);
feedback = hslider("feedback", 0, 0, 1, 0.01);
op1 = (op1_) ~ *(feedback);
op2 = operator(freq_carrier, 1, 1, adsr_carrier);
process = op1:op2;

こうです。…毎回迷いなく書ける自信がないですが。自信がなくなったらDiagramを見てみます。

変わってない!と早合点せずに、op1をクリックしてみましょう。そうすると、

はい。正直なところ、リファレンスのrecursive expressionの項に記載のある例(One pole filter)を見てこれをそのまま使えばいいじゃないかと思ったくらいなので、もう少し修行が必要です…。

ただまぁ、実際に動かしてみると硬めの音になってそれっぽい感じです。たぶん。

リファクタリングというか

よくよく考えてみると(よくよく考えなくても)、feedbackは入力された初期位相を調整するので、operator関数の中で使っているampと一緒にできそうです。

  • これまでは後ろのブロックに出力する波形の振幅を調整するのにampを使っていたものを
  • 前のブロックから受け取った初期位相phaseを調整するのに使う

ようにします。

import("stdfaust.lib");

gate = button("note on");
freq_carrier = hslider("carrier frequency",500,500,2000,0.1);
freq_modulator = hslider("modulator freq index",1,1,10,1);
amp_modulator = hslider("modulator amp", 1,0,10,0.1);
adsr_carrier = en.adsr(0.2, 0.5, 0.8, 0.5, gate);
adsr_modulator = en.adsr(0.8, 0.8, 0.7, 1.0, gate);

operator(freq, index, adsr, amp, phase) = os.oscp(freq*index, phase * amp) * adsr;

feedback = hslider("feedback", 0, 0, 10, 0.01);

op1 = operator(freq_carrier, freq_modulator, adsr_modulator, feedback) ~ _;
op2 = operator(freq_carrier, 1, adsr_carrier, amp_modulator);
process = op1:op2;

これまた「~」の使い方が特殊ですが、念の為Diagramで確認しておきます。

うまくいっているようです。

右側の図をみると分かりますが、引数に指定していなかったphaseが手前のブロックからもらってくるということも分かります。

Web IDEへのリンク

今気づいたのですが、FaustのウェブIDEで作成したコードをURLに埋め込んでシェアできるようですので、載せておきます。こちらです

まとめ

FM音源の基礎部分を、ブロックを繋ぐという記法を用いて実装できました。それなりな感じになってちょっと満足。

今後の展望

過去記事では4オペレータだったり並列接続だったりというYM2608のアルゴリズムっぽいものを作ったりしたので、時間ができたらそれをやってみたいなと思います。あとはネイティブコードに変換してAudioUnit化してみるとか、色々楽しめそうです。