4オペレータFM音源をC#で実装する|アルゴリズム・フィードバック・ADSR
はじめに
シリーズ最終回です。第1回で原理を、第2回で2オペレータの最小実装を、第3回でアルゴリズムの概念を見てきました。今回はそれを全部コードにして、PICOMで実際に動いている4オペレータFM音源を完成させます。
この記事のコードは、PICOMのリポジトリにある実装そのものです。第2回で作った2オペレータの最小構成を土台に、「どう4オペレータへ一般化したか」を辿る形で進めます。
オペレータのパラメータ(周波数比・出力レベル・ADSR)を持つFMOperator、4つのオペレータとアルゴリズム・フィードバックをまとめるFMParameters、そして毎サンプルでアルゴリズムに応じた変調を計算するFMWaveの3つで構成します。第2回の「位相を進めてsinを引く」ループはそのまま生き、増えたのは4本ぶんの位相配列、switchによるアルゴリズム分岐、OP1のセルフフィードバック、そしてADSRエンベロープです。
オペレータのパラメータを持つFMOperator
第2回のオペレータは位相とRatioだけでした。4オペレータでは、各オペレータに出力レベルとエンベロープを持たせます。PICOMのFMOperatorは次のとおりです。
public class FMOperator : ObservableObject
{
private double _ratio = 1.0;
public double Ratio { get => _ratio; set => SetProperty(ref _ratio, value); }
private double _outputLevel = 1.0;
public double OutputLevel { get => _outputLevel; set => SetProperty(ref _outputLevel, value); }
private double _attack = 0.0;
public double Attack { get => _attack; set => SetProperty(ref _attack, value); }
private double _decay = 0.3;
public double Decay { get => _decay; set => SetProperty(ref _decay, value); }
private double _sustain = 1.0;
public double Sustain { get => _sustain; set => SetProperty(ref _sustain, value); }
private double _release = 0.1;
public double Release { get => _release; set => SetProperty(ref _release, value); }
}
Ratioは第2回と同じ周波数比です。OutputLevelは、そのオペレータの出力の大きさです。ここが重要で、OutputLevelはオペレータがキャリアかモジュレータかで意味が変わります。キャリアなら音量、モジュレータなら変調の深さ、つまり第2回でいう変調指数 I に相当します。Attack〜Releaseはエンベロープのパラメータで、これは後半で扱います。ObservableObjectを継承しているのは、UIのスライダーと双方向に同期するためです。
4つをまとめるFMParameters
4つのFMOperatorと、アルゴリズム・フィードバックをまとめるのがFMParametersです。
public class FMParameters : ObservableObject
{
public const int OperatorCount = 4;
public FMOperator[] Operators { get; }
private FMAlgorithm _algorithm = FMAlgorithm.Alg4;
public FMAlgorithm Algorithm { get => _algorithm; set => SetProperty(ref _algorithm, value); }
private double _feedback = 0.0;
public double Feedback { get => _feedback; set => SetProperty(ref _feedback, value); }
public FMParameters()
{
Operators = new FMOperator[OperatorCount];
for (var i = 0; i < OperatorCount; i++)
{
Operators[i] = new FMOperator();
Operators[i].PropertyChanged += OnOperatorChanged;
}
// 既定 Algorithm 4 = (OP1→OP2)+(OP3→OP4)
// OP1, OP3 が modulator、OP2, OP4 が carrier
Operators[0].OutputLevel = 0.5;
Operators[1].OutputLevel = 1.0;
Operators[2].OutputLevel = 0.0;
Operators[3].OutputLevel = 0.0;
}
}
Algorithmは第3回で見たAlg0〜7のenumです。FeedbackはOP1が自分自身を変調する量で、これも後で出てきます。
既定値が第3回で「バランス型」と紹介したAlg4になっているのに注目してください。コンストラクタでOP1(モジュレータ)に0.5、OP2(キャリア)に1.0の出力レベルを与え、OP3・OP4は0にしています。つまり初期状態は、第2回で作った「モジュレータ→キャリア」の2オペレータFMが1組だけ鳴る設定です。第2回の到達点が、そのまま4オペレータ実装の初期値として埋め込まれているわけです。
アルゴリズムのenumは、第3回の接続図をdocコメントとして持っています。
public enum FMAlgorithm
{
/// <summary>OP1 → OP2 → OP3 → OP4(OP4 がキャリア)</summary>
Alg0 = 0,
/// <summary>(OP1 + OP2) → OP3 → OP4</summary>
Alg1 = 1,
// ... Alg2〜Alg6 ...
/// <summary>OP1 + OP2 + OP3 + OP4(全並列)</summary>
Alg7 = 7,
}
波形生成:第2回のループを4本に広げる
中心となるのがFMWaveのGenerateWaveです。第2回のループとの違いを意識しながら見てください。位相は4本ぶんの配列になり、それぞれRatioから周波数を決めます。
public override short[] GenerateWave(SoundFormat format, int length, int volume, double hertz)
{
var result = new short[length];
var sampleRate = (int)format.SamplingFrequency;
var volumeMagnification = volume / 100d;
const double twoPi = 2 * Math.PI;
Span<double> phases = stackalloc double[4];
Span<double> freqs = stackalloc double[4];
for (var i = 0; i < 4; i++)
{
freqs[i] = hertz * _ops[i].Ratio;
}
double lastOp1 = 0;
Span<double> envs = stackalloc double[4];
var noteDuration = (double)length / sampleRate;
for (var i = 0; i < length; i++)
{
var t = (double)i / sampleRate;
for (var k = 0; k < 4; k++)
{
envs[k] = _ops[k].EnvelopeAt(t, noteDuration) * _ops[k].OutputLevel;
}
double op1, op2, op3, op4, output;
switch (_algorithm)
{
// ... アルゴリズムごとの分岐(次節)...
}
// 複数キャリアの和で振幅が溢れるのを防ぐクリップ
if (output > 1.0) output = 1.0;
else if (output < -1.0) output = -1.0;
result[i] = (short)(short.MaxValue * volumeMagnification * output);
for (var k = 0; k < 4; k++)
{
phases[k] += freqs[k] / sampleRate;
}
}
return result;
}
骨格は第2回とまったく同じです。サンプルごとに各オペレータの値を計算し、最後に位相をphases[k] += freqs[k] / sampleRateで進める。違いは、位相が4本になったこと、各サンプルでエンベロープenvs[k]を計算していること、そして変調の繋ぎ方をswitchで切り替えていることだけです。envs[k]はエンベロープにOutputLevelを掛けた値で、これが第2回の変調指数や音量の役割を一手に担います。
アルゴリズムをswitchで切り替える
switchの中身が、第3回で図にした接続をそのままコードにしたものです。両極端と既定の3つを並べます。
case FMAlgorithm.Alg0: // 1→2→3→4
op1 = Math.Sin(twoPi * phases[0] + _feedback * lastOp1) * envs[0];
op2 = Math.Sin(twoPi * phases[1] + op1) * envs[1];
op3 = Math.Sin(twoPi * phases[2] + op2) * envs[2];
op4 = Math.Sin(twoPi * phases[3] + op3) * envs[3];
output = op4;
lastOp1 = op1;
break;
case FMAlgorithm.Alg4: // (1→2)+(3→4)
op1 = Math.Sin(twoPi * phases[0] + _feedback * lastOp1) * envs[0];
op2 = Math.Sin(twoPi * phases[1] + op1) * envs[1];
op3 = Math.Sin(twoPi * phases[2]) * envs[2];
op4 = Math.Sin(twoPi * phases[3] + op3) * envs[3];
output = op2 + op4;
lastOp1 = op1;
break;
case FMAlgorithm.Alg7: // 1+2+3+4(全並列)
default:
op1 = Math.Sin(twoPi * phases[0] + _feedback * lastOp1) * envs[0];
op2 = Math.Sin(twoPi * phases[1]) * envs[1];
op3 = Math.Sin(twoPi * phases[2]) * envs[2];
op4 = Math.Sin(twoPi * phases[3]) * envs[3];
output = op1 + op2 + op3 + op4;
lastOp1 = op1;
break;
読み方は単純です。あるオペレータが別のオペレータを変調するとき、変調する側の出力を、される側のMath.Sin(twoPi * phases[...] + ここ)に足し込む。これは第2回でモジュレータの出力をキャリアの位相に足したのと、まったく同じ操作です。
Alg0ではop1がop2の位相に、op2がop3に、op3がop4に足し込まれ、変調が4段直列に連なります。出力は最後のop4だけ。Alg7では誰も誰も変調せず、4本をそのままop1 + op2 + op3 + op4と足すだけ。Alg4はop1→op2とop3→op4の2ペアを作り、2つのキャリアop2 + op4を足します。第3回の3つの図が、そのまま3つのcaseになっているのが見て取れるはずです。
並列のキャリアを足すアルゴリズムでは出力が1を超えうるので、ループ末尾で[-1, 1]にクリップしています。第3回の最後で触れた振幅管理が、この2行です。
OP1のセルフフィードバック
どのcaseにも共通して、OP1にだけ_feedback * lastOp1が足されています。
op1 = Math.Sin(twoPi * phases[0] + _feedback * lastOp1) * envs[0];
// ...
lastOp1 = op1;
lastOp1は1サンプル前のOP1の出力です。それをFeedbackの量だけ自分の位相に戻すことで、OP1が自分自身を変調します。これがフィードバックで、OPNA系FM音源の特徴的な機能です。フィードバックを上げると、単一オペレータでもノコギリ波に近い倍音リッチな波形が得られ、ベースやディストーション系の音作りで効いてきます。1サンプル前の値を次に渡すだけで実現できるのが、いかにもデジタルらしい仕組みです。
ADSRエンベロープで音の輪郭を作る
第2回では固定音量にして、出だしと終わりのクリックノイズを保留にしていました。最終回ではそれをADSRエンベロープで解決します。各サンプルのenvs[k]を計算していたEnvelopeAtの中身がこれです。
public double EnvelopeAt(double t, double noteDuration)
{
var adsLevel = AdsLevelAt(t);
if (Release > 0)
{
var releaseStart = noteDuration - Release;
if (t >= releaseStart)
{
var fadeOut = 1.0 - (t - releaseStart) / Release;
return fadeOut > 0 ? adsLevel * fadeOut : 0;
}
}
return adsLevel;
}
private double AdsLevelAt(double t)
{
if (Attack > 0 && t < Attack)
{
return t / Attack;
}
var afterAttack = t - Attack;
if (Decay > 0 && afterAttack < Decay)
{
// 1.0 から Sustain へ向かう指数減衰
var tau = Decay / 3.0; // t = Decay で約95%まで到達
return Sustain + (1.0 - Sustain) * Math.Exp(-afterAttack / tau);
}
return Sustain;
}
エンベロープは音量を時間で形づくる係数(0〜1)です。AdsLevelAtがアタック・ディケイ・サステインを担います。アタック期間はt / Attackで0から1へ線形に立ち上がり、ディケイ期間は1.0からサステイン値へ指数的に減衰し、その後はサステイン値を保ちます。EnvelopeAtは、音の末尾Release秒で線形にフェードアウトを掛け、音の終わりをちょうど0に着地させます。
これを各オペレータの出力に毎サンプル掛けることで、音が滑らかに立ち上がって減衰し、クリックノイズが消えます。モジュレータ側にエンベロープを掛ければ、時間とともに倍音が変化する(アタックは明るく、減衰すると丸くなる)といったFMらしい音作りもできます。
PICOMのこの実装は、発音時間があらかじめ決まっている(ノート長が固定の)モデルです。Releaseはノート末尾から逆算してフェードを始めます。鍵盤を押している間ずっとサステインを保ち、離した瞬間からリリースに入る、というリアルタイム楽器型のエンベロープとは設計が異なります。楽譜から各音の長さが決まっているPICOMの用途では、この方式の方が単純で扱いやすくなります。
完成、そして次の展開
これで第3回までの概念がすべてコードになりました。4つのオペレータ、アルゴリズムによる接続の切り替え、フィードバック、各オペレータのRatio・出力レベル・ADSR。第2回の2オペレータ実装が、位相配列の拡張とswitchの追加だけで、ちゃんと4オペレータへ地続きに一般化されているのが確認できたはずです。
実は、ここまで作ると次にやりたくなるのが「既存の名作音色を再現したい」という欲です。PC-98のFM音源には、PMDなどのドライバで使われた音色データ(.ff形式やJSON)という資産があります。OPNAのレジスタ値(AR/DR/SR/RR/TL/MLなど)を、この記事で作ったFMOperatorのパラメータへ変換して読み込めば、当時の音色をそのまま鳴らせます。
ただし音色データの変換ロジックや、データそのものの著作権の扱いは、それだけで1本の記事になる重さです。FM音源の仕組みを作るという本シリーズの範囲はここまでとして、音色ローダー(IFMVoiceLoader構想)とOPNA音色の再現は、別記事として改めて書く予定です。
まとめ
FMOperatorは周波数比・出力レベル・ADSRを持ち、OutputLevelはキャリアでは音量・モジュレータでは変調の深さを意味するFMParametersは4つのオペレータとアルゴリズム・フィードバックをまとめ、既定はAlg4(第2回の2オペレータFMが初期値)- 波形生成ループの骨格は第2回と同じで、位相を4本に広げ
switchでアルゴリズムを切り替えるだけ - 変調は「変調する側の出力を、される側のsinの位相に足す」操作で、第2回とまったく同じ
- OP1のセルフフィードバックと各オペレータのADSRで、倍音リッチな音やクリックのない滑らかな発音が得られる
- OPNA音色データのロードは別記事に切り出す






