新型コロナウイルスのおかげで3月から3か月間在宅勤務になっていましたが、その間NTS-1でαJunoのエミュレーションをしようと、いろいろポチポチとプログラミングをしていました。
αJunoは1980年代のローランドのアナログ・ポリフォニック・シンセサイザーです。ビンテージ・シンセサイザーとして有名なのはαJunoの前の機種であるJUNO-106ですが、αJunoもJUNO-106とは違う魅力的な音色を持っています。
NTS-1でこの開発を始めたのは2月で、3月になったら外出禁止になってしまったので、3月の三連休も外出せずにNTS-1をいじっていました。しかしαJunoのエミュレーションは、やはりポリフォニック環境で試したい・・・という気持ちがあり、だんだん開発速度は落ちていきました。
が、先日ついに4音ポリでlogue SDKが使えるminilogue xdを入手しましたので(実はこのために買ったといっても過言ではない)、一気にモチベーションが上がってほぼ完成というところまで来ました。ちょうど5月も終わるところで、新型コロナ緊急事態の解除を記念して?記事にしておきたいと思います。ちなみに製作したオシレータの名前はAlpha Oneです。
そんなわけで以下その制作メモですが、ほぼ自分用の記録です。関連することを全部1記事に入れたので、長いです。
全体像
αJunoの内部構成は以下の図のようになっています。ただし実際の回路では、HPF(ハイパスフィルタ)はコーラスの直前にあります。
今回作成したのは、このうちDCO、HPF、コーラスの各ブロックのソフトウェアによるエミュレーションです。DCOとHPFのセットをlogue SDKのオシレータ、コーラスをモジュレーションのユーザモジュールとして開発しました。VCA/VCF/LFO/エンベロープは対象外(NTS-1やminilogue xdに搭載されているものを使う)です。
波形生成
まず作ったのは、αJunoのDCOが出力するものと同じ波形を生成するオシレータです。αJunoのDCOについては、以前に詳しく調べましたので、挙動はよくわかっています。

基本的な波形はパルス波と鋸波しかなく、それを4倍や8倍の矩形波でスイッチングすることで高域成分を付加しています。サブオシレータとして1オクターブまたは2オクターブ下の矩形波があり、これらにもスイッチングした波形があります。また、ノイズオシレータは1と0がランダムに出力されるデジタルなノイズを生成します。
実質的に作らなければならないのは、基本の波形を矩形波でスイッチングする処理だけです。矩形波だけを出力するシンプルなオシレータは以前作成しました。鋸波もSDKに専用の関数が用意されており、簡単に作成できます。ノイズ生成もSDKに含まれています。
スイッチングして作る波形を安定して生成するためには、基本波形とスイッチングに使う矩形波は位相が完全に同期している必要があります。これは基本波形の位相を2倍とか4倍にすれば、位相がそろって周波数が高い波形を作ることができます。
スイッチング用の信号を1/0だけの矩形波で(ウエーブテーブルを使わずに)作ると、ここまでは割と簡単にできました。(ただし、スイッチング信号は基本の波形よりも周波数が高いので、高音域での盛大なエイリアシングノイズは避けられません。)
その成果を使って作ったのが、以前紹介したHoover Sound専用オシレータです。

しかし、Hoover Soundのようなもともとノイジーなサウンドでなく、矩形波や鋸波を1/0の矩形波でスイッチングしただけのサウンドを聴くと、特に高音域でのエイリアシングが気になります。ですので、αJuno DCOエミュレータでは基本、すべての波形をウエーブテーブルで生成していきます。
αJunoの波形はすべて矩形波と鋸波の組み合わせに分解できるので、logue SDKの内蔵ウエーブテーブルを組み合わせれば理屈の上では再現可能です。
スイッチングに使う矩形波もウェーブテーブルから作成し、スイッチングされる波形に掛け算します。logue SDKのウエーブテーブルのデータは-1.0~1.0ですので、スイッチングされる側の波形は+1.0して0.0~2.0とし、スイッチングする波形は0.0~1.0の範囲に変換します。そして、掛け算した結果にから、先ほど加えた1.0を引きます。
これは1/0の信号とANDを取る代わりに掛け算で表現しているだけなのですが、実はこれだと問題が出る場合があります。エイリアシングノイズが出ないように帯域制限されたウェーブテーブルで作る矩形波は、立ち上がり・立ち下がりのエッジが鈍っており、波形で見ると垂直ではなく傾斜しています。このような矩形波同士で、元の波形とスイッチング波形がクロスオーバーしている状態で、掛け算すると下図のようにスパイクが出てしまいます。
そのため、場合によっては波形の位相を少しずらして、スパイクが起こらないようにする必要があります。
また、αJunoの音色には、PWMつき波形もスイッチングと同じくらい重要です。
矩形波のPWMは、下記に書いたように鋸波から作ります。鋸波自体はウエーブテーブルから生成します。

αJunoの特徴的な波形であるPWMつき鋸波は、鋸波の2倍の周波数のPWMつき矩形波をこのやり方で生成し、それをスイッチング信号として鋸波に掛け算すると生成できます。実際には、矩形波と鋸波でPWM部分を完全に一致させるため、スイッチング信号はPWM矩形波にその位相を半波長分だけずらしたものを加算(両方の値のMax値を取る)して生成しています。
サブオシレータについては、サブオシレータの位相と矩形波や鋸波の位相は基本周波数換算で0.5波長だけずれていますので、実装時には注意が必要です。
PWMのLFO変調
今回作ったエミュレータも実機と同様、PWMはデューティ比を固定で設定することも、LFOで変調することもできますが、その指定方法が少しだけ実機と異なります。
実機では「PW / PWM」と「PWM RATE」という二つのパラメータがあり、「PWM RATE=0」のときはデューティ比は固定値で、その値は「PW / PWM」の値になります。「PWM RATE>0」のときはデューティ比はLFOで変動し、その深さが「PW / PWM」の値になります。従って、デューティ比を静的に設定すること(PW)とLFOで変動させること(PWM)は排他になっています。デューティ比は50%から0%(波形が消失する寸前)の範囲の値を取ります。
一方、logue SDKでは、デューティ比を固定で指定するための「Shape」と、LFOで変動させるための「LFO rate」「Pitch/shape LFO depth」(NTS-1の場合。minilogue xdについては後述します)の独立したパラメータがあります。ShapeパラメータもShape LFO depthパラメータも、値は[0.0, 1.0]で変動します。
つまり、実機ではデューティ比を決めるのは常に「PW / PWM」パラメータだけなのですが、logue SDKではShapeパラメータとLFOの2つのパラメータがあり、これらは完全に独立しています。そこで、これら二つのパラメータを同時に使えるよう、SHAPEを優先してデューティ比を静的に決めた後に、SHAPEの余白(1 – SHAPE)についてLFOで動的に変動させることにしました。
図にすると下図のようになります。パラメータのうち、図の山型の部分がLFOで変動する部分で、四角い部分が静的に設定する部分です。αJunoの実機でのLFOによるPWM変調の動作は、図ではShape=0のときと同じになります。PW/PWMパラメータが山の高さを決めます。
minilogue xdでは、また別の問題があります。logue SDKでは、NTS-1とminilogue xdとでLFOの値域がなぜか異なっています。NTS-1では0.0~1.0ですが、minilogue xdでは-1.0~1.0です。これについては以前以下の記事でも触れました。

特に問題なのは、基準値が0なのは共通ですが、NTS-1では正の値方向にのみ振幅するのに対して、minilogue xdでは正負両方に振幅することです。
値域を合わせるだけなら、minilogue xdの値に1.0を足してから0.5を掛ければ、0.0~1.0となりNTS-1と一致します。しかし、このやり方だとLFO depthを0にしたときの結果は、NTS-1は0になるのに対してminilogue xdでは0.5になりますので、挙動が異なってしまいます。
LFO depthの設定値を知ることができれば、minilogue xdの値にdepthを足して0.5を掛けることでNTS-1と同じ値にすることができますが、残念ながらLFO depthを取得するAPIは無いようです。今回は苦肉の策として、minilogue xdではLFOの値の絶対値を使うことにしました。このやり方は
・LFOの波形が三角波のとき、周波数が2倍になる
・LFOの波形が鋸波のとき、波形が三角波になってしまう
・LFOの波形が矩形波のとき、値が常に1.0になってしまう
といった弊害があり、好ましくはないのですが、実機の挙動に合わせようとすると他にシンプルな方法が思いつきませんでした。
各波形の音量バランス
αJunoのDCOはパルス波、鋸波、サブオシレータ、ノイズの4つを足し合わせた波形です。また、サブオシレータおよびノイズはミックスレベルの設定が4段階で行えます。この音量バランスは、実機でのバランスを測定し、今回作ったものが大体同じようになるように決めています。サブオシレータおよびノイズともに、最大音量にしても矩形波や鋸波の最大音量に僅かに及ばない音量になるようデザインされたようです。
結果的には、
・パルス波、鋸波:各27%
・ノイズ:0%、7.3%、14.6%、 22%
・サブオシレータ:0%、6%、12%、24%
という比率にしています。(実際には、ここにさらにHPF=0のときのローパスフィルタの出力が加わります。)
なお、ノイズは実機に倣い、1 or 0を出力するデジタルノイズとしています。

ハイパスフィルタ
Junoシリーズには、HPFが搭載されています。VCFではなく、全チャネルに一様に適用され、周波数特性も固定値です。αJunoでは設定値が4つあり、ハイパスが2つとフィルタなしとロー・ブーストとなっています。

logue SDKにはフィルタのライブラリがありますので、それを使って作成します。
使えるのは双二次フィルタ(Bi-Quad Filter)というデジタルフィルタのライブラリです。Bi-Quadフィルタは、パラメータによってローパス、ハイパス、バンドパス、ノッチなど様々なフィルタを作ることができます。
原理的なことはよくわかりませんが、双二次フィルタはどんなふうに使えるものなのかは、下記のページが雰囲気をつかみやすいと思います。

SDKのマニュアルはあっさりしたものですが、一通りのフィルタを試せるサンプルが用意されていますので、それを見ればライブラリの使い方は分かります。
logue-sdk/biquad.cpp at master · korginc/logue-sdk
与えるパラメータはカットオフ周波数とQ値(フィルタの切れ味に関係します)で、これらは前掲のαJunoのHPFに関する調査で回路定数が分かっていますので、その定数から以下のサイトで計算しました。
ただしローブースト時のパラメータについては、計算が間違っているのかもしれませんが実機の聴感と合わなかったので、実機との出音比較で値を決めました。
フィルタを適用するには、フィルタへの入力信号のサンプルデータs
を1サンプル毎にprocess_so(s)
のように与え、戻り値をフィルタの出力信号とします。
フィルタの切り替えはShift-Shapeパラメータを使うことにしました。αJunoだとHPFパラメータはいじりにくいのですが、それ以前のJunoシリーズではVCFの隣にHPFのスライダがあって、すぐに切り替えられるようになっていました。Shift-Shapeだとminilogue xdでは片手では切り替えられませんが、比較的アクセスしやすいです。
計算量問題
αJunoのDCOは、スイッチングに使う矩形波まで考慮すると、一度に必要な信号が意外と多いです。
まず、普通の矩形波に限っても、発音したい音の周波数をfとすると8f, 4f, 2f, f, f/2, f/4という6つの周波数が必要です。
鋸波については、fの鋸波と、PWM用の波形を生成するために2fの鋸波が2つ、それにノイズも必要です。また、最初のほうで書いた掛け算の際のスパイク回避のため、波形のデューティ比をわずかに広げようとすると、これも1つの矩形波の代わりに2つの鋸波が必要になります。
ウエーブテーブルシンセシスは軽いといっても、さすがにこれだけの波形を生成して、かつ掛け算していくとなると処理速度が足りず、minilogue xdが暴走したこともありました。もちろん全て同時に必要になるわけではないのですが、パルス波が最大2波形(PWM時)、鋸波が最大3波形(SAW5)、サブオシレータが最大2波形(Sub0,Sub4以外)、およびノイズについては同時使用の可能性がありますから、合計8波形は処理することが必要です。
計算量を削減するため、まずサブオシレータの波形生成はウエーブテーブルではなく1/0で行うことにしました。サブオシレータの周波数は1~2オクターブ低いので、エイリアシングが起こりにくいと思われるためです。
サブオシレータの波形は6種類ありますが、いずれも周波数が基本の波形(パルス波や鋸波)の4倍、2倍、1倍、1/2倍、1/4倍、となる5つの矩形波のうちいくつかのもののANDとして表すことができます。

そこで、基本の波形の周波数の4倍の速さでカウントアップする6ビットのカウンタを作り、そのカウンタが特定のビットパターンになっているかどうかで1/0を出力するようにしています。例えばSub 2ならビットパターンがX1X1XX(周期f/2と2fがいずれも1)になっているときに1、Sub 5ならビットパターンが11XXXX(周期f/4とf/2がいずれも1)になっているときに1、という具合です。
次に、if文での分岐オーバーヘッドを減らすために、以下のSynth1の実装で用いられている手法を使いました。
これは、例えば
func(int x) {
if (x == 1) {処理A} else {処理B}
}
といった形式になっている条件分岐が、ある関数func(int x)に含まれているとき、あまり手間をかけずに関数内から条件分岐を消し去る手法です。
この手法では、上記の条件分岐を
if (flag) {処理A} else {処理B}
という形に変えます。このあと、flagを定数に変化させて、条件分岐を省略するのが狙いです。
条件分岐を内包する関数のほうはfunc(const bool flag)
とし、さらに関数の宣言にインライン宣言を付加します。
__inline func(const bool flag) {
if (flag) {処理A} else {処理B}
}
そして、呼び出す側では
if(x == 1) func(true) else func(false)
というようにflagに対して定数引数を与えます。
funcはインライン関数として宣言されているので、上記の呼び出し文はインライン展開されて
if(x == 1) {if (true) {処理A} else {処理B}} else {if (false) {処理A} else {処理B}}
となります。さらにこれがコンパイラによって
if(x == 1) {処理A} else {処理B}
というように最適化されます。
ポイントは、func(int x)の中にでif-then-elseを使っていても、インライン展開と最適化を経ると、その条件分岐はfuncの呼び出し側に移って、func内部では条件分岐しなくなるという点です。最初に関数を呼び分ける処理が追加で必要になりますが、ループの中の条件分岐を解消するには効果の大きい手法です。
今回は、必要となる基本波形が大きく異なる、鋸波の波形ががPWMの場合とそれ以外の場合を分け、どちらか一方の処理をすれば済むようにしています。また、後述するようにウエーブテーブルからの波形生成において、高域カットしたテーブルを使うかカットしていないテーブルを使うかを選択できるようにしていますが、この分岐も同じ方法で解消させています。
(なので、2×2=4回、同じ関数をインライン展開しています。)
ループの中での条件分岐で残っているのはPWMつき鋸波以外の波形ごとの分岐ですが、これを展開したらパルス波4通り、鋸波6通り、サブオシレータ2通り(デューティ比50%の矩形波の場合と、それ以外の場合)、ウエーブテーブルの選択で2通り、都合96通りの組み合わせになりますので、展開するのはあまり現実的ではないかなと思います。
コーラス
Junoといえばコーラスは避けて通れません。DCOと同時進行で、αJunoのコーラスのエミュレーションもMODモジュールとして作成しました。以下の記事は、実はこのための調査の結果をまとめたものです。

コーラスは、基本的にはディレイラインに入力をコピーし、左右の音声チャネルで異なる長さの遅延をかけてエコーとして出力しつつ、その遅延時間をLFOで揺らすということになります。揺らす周期の変動範囲は、実機の測定データを上記の記事でも紹介しています。
入力の信号を遅らせてコピーするだけでは、αJunoのコーラスとはちょっと違う感じの音色になります。これも上記の記事で書いてありますが、αJunoのコーラスで使われているBBD素子は、サンプリングのような動作をしています。そして、サンプリングのバッファ長が一定なので、遅延時間を増やすとサンプリング周波数が低下し、最低では28KHz程度まで下がります。
エイリアシングノイズや量子化ノイズをカットするため、BBD素子の前後にはアクティブローパスフィルタが付いており、さらに入力側と出力側それぞれにRCによるバンドパスフィルタが付いています。その結果、コーラスが元信号に付加する信号は高域と低域がカットされた信号になりますが、これがαJunoのコーラスにある種の「ふくよかさ」を与えていると思われます。
実装としては、上記の構成をほぼそのままソフトウェアで書いた感じになっています。フィルタはバンドパス+LPF+LPF+バンドパスで4段になりますが、処理性能的には問題ないようです。BBD相当の部分は、サンプリング周波数の変化まで厳密に考えようとするならBBD自体のエミュレーションが必要になりますが、現状はlogue SDKに入っているDelayLineクラスを使用しています。
実機との違い
以上のようにminilogue xdでαJunoをエミュレートしようとしてみましたが、もちろん実機との差分は多々あります。
まず、フィルタの違いは大きいです。minilogue xdのフィルタは2 poleローパスフィルタで、αJunoは4 poleローパスフィルタです。減衰の仕方が異なりますし、レゾナンスを上げたときにαJunoは低域が痩せますがminilogue xdは低域を減らさない設計です。フィルタの持つキャラクタがかなり逆方向ですので、まったく同じトーンの音は作りにくいです。
エンベロープもαJunoのほうが複雑な変化を作ることができます。また、αJunoは同じエンベロープをVCAとVCFに適用できますが、minilogue xdではVCA用エンベロープは独立で、VCFは別のADタイプの簡易エンベロープで制御します。パラメータが少ないので、αJunoと全く同じ制御をすることはできません。
LFOはminilogue xdでは1つで、VCFとShapeパラメータとピッチのいずれか一つを制御することができます。PWMをLFOで揺らしつつピッチも揺らす、といったことはできません。PWMについてはソフトウェアでLFOを実装することも可能ではありますが、既存のLFOとかぶってしまうのでUI的にはよろしくない気がしました。またαJunoでは、NOTE ONからLFOが動作を始めるまでのディレイタイムを設定できますが、これもないのでビブラート的な効果はちょっと再現しにくいですね。
以上はlogue SDKから手を出せない部分の差異でしたが、当然ながらソフトウェアシンセとしての限界もあります。同じ矩形波でも、やはり実機の音とソフトシンセの音は異なります。DACのサンプリング周波数は48KHzですから、理屈上は、24KHzの音までは出ているはずですし、それより高い音は人間の耳ではほとんど聴こえないはずですが、実際に聴いてみるとやはり実機の音のほうがシャープでクリアだと感じます。
logue SDKで作る矩形波は24KHz以上の倍音成分を削除されていますが、聴感上はエイリアシングが出てしまう波形のほうが実機に近い場合もあるように感じたので、あえてパラメータ設定でエイリアスノイズを許容することもできる(band-limitedなウエーブテーブルを使用しない)ようにしてみました。もちろん、このときは高音域は使わないほうが良く、使い道は限定されます。
実装結果
私はαJunoの音色の中ではストリングス系が好きなのですが、まあそこそこ再現できているのではないかと思います。もちろん実機のほうがいいですが・・・。
Prologue/minilogue xdの本来のキャラクターはブライトで重厚な音のようですので、αJunoとは方向性が違うのですが、それをがんばって克服しているという感じです。
コメント