アキゾラソフトのロゴアキゾラソフト技術 × キャリア × WEBツール

4オペレータFM音源をC#で実装する|アルゴリズム・フィードバック・ADSR

Contents

はじめに

シリーズ最終回です。第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 に相当します。AttackReleaseはエンベロープのパラメータで、これは後半で扱います。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本に広げる

中心となるのがFMWaveGenerateWaveです。第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ではop1op2の位相に、op2op3に、op3op4に足し込まれ、変調が4段直列に連なります。出力は最後のop4だけ。Alg7では誰も誰も変調せず、4本をそのままop1 + op2 + op3 + op4と足すだけ。Alg4はop1→op2op3→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音色データのロードは別記事に切り出す

← 第3回:4オペレータFM vs 2オペレータ第1回からのシリーズ目次

プロフィール画像
WRITTEN BY
あきぞら

東京都市大学 情報工学部在籍(2027年卒業予定)。
中学時代よりC#および.NETを用いた個人開発をスタート。現在はWebアプリエンジニアとして活動し、国内大手SaaS企業より内定。 培った技術の活用法から、新卒エンジニアとしてのキャリア形成、生産性を高めるITツールの導入まで、ITに興味のある方や現役エンジニアに役立つバラエティ豊かな情報を発信しています。