2オペレータFM音源をC#で実装する|サイン波2本で音を作る最小構成
はじめに
前回はFM音源の原理を、式 out = sin(2π·fc·t + I·sin(2π·fm·t)) 1本だけで説明しました。今回はその式を、そのままC#のコードに落とします。
ゴールはひとつだけ。キャリアとモジュレータの2個のサイン波(2オペレータ)で、実際に鳴る1音のPCM波形を生成することです。アルゴリズム(接続の組み合わせ)やオペレータを増やす話はまだ出しません。それは次回以降の領分で、今回は「最小のFM音源」に集中します。
オペレータを「位相を持ち、毎サンプル位相を進めてsinを引く1ユニット」として設計します。モジュレータのsin出力をキャリアの位相に足し込めば、それが前回の式そのものです。サンプル単位のループで位相を phase += freq / sampleRate ずつ進めながら値を埋めていくと、1音分のPCMバッファが完成します。音量・エンベロープは固定にして、まずは「鳴る」ことを最優先にします。
オペレータは「位相を進めてsinを引く」だけ
FM音源の部品はオペレータと呼ばれます。難しそうな名前ですが、やることは1つです。自分の位相(phase)を毎サンプル少しずつ進めて、その位相の sin を返す。それだけです。
オペレータが持つ状態は、現在の位相と、自分の周波数です。ここで周波数は絶対値ではなく、基準となる音の高さ(ピッチ)に対する比 Ratio で持ちます。前回触れたとおり、こうしないと音の高さを変えたときに音色が崩れるからです。基準周波数 hertz に Ratio を掛けたものが、そのオペレータの実際の周波数になります。
// 1つのサイン波ユニット。状態は「位相」だけ。
public class FmOperator
{
// 基準周波数に対する周波数比。1.0 ならピッチそのまま、2.0 なら1オクターブ上。
public double Ratio { get; set; } = 1.0;
private double _phase;
// 1サンプル分、位相を進める。phaseModulation がFM変調の入力。
public double Next(double baseHertz, int sampleRate, double phaseModulation)
{
var value = Math.Sin(2 * Math.PI * _phase + phaseModulation);
_phase += baseHertz * Ratio / sampleRate;
return value;
}
}
位相を Ratio で決まる量だけ毎サンプル足していくこの操作を、位相累算(phase accumulation)と呼びます。baseHertz * Ratio / sampleRate が「1サンプルあたりに進む位相の量」です。たとえばサンプリングレートが44100Hzで、周波数440Hzのサイン波なら、1秒(44100サンプル)かけて位相がちょうど440周します。
Next の引数 phaseModulation が、FM音源の心臓です。ここに何か値を足し込むと、その瞬間だけ位相が前後にズレ、結果として周波数が揺さぶられます。何も足さなければ(0なら)、ただの素のサイン波です。
2個を直列に繋ぐ = 前回の式
オペレータを2個用意します。1個をモジュレータ、もう1個をキャリアにして、モジュレータの出力をキャリアの phaseModulation に流し込む。これだけで2オペレータFMになります。
public class TwoOperatorFm
{
private readonly FmOperator _modulator = new() { Ratio = 1.0 };
private readonly FmOperator _carrier = new() { Ratio = 1.0 };
// 変調指数 I。倍音の豊かさを決める。
public double ModulationIndex { get; set; } = 2.0;
public double Next(double baseHertz, int sampleRate)
{
// モジュレータは素のサイン波(変調入力なし)
var mod = _modulator.Next(baseHertz, sampleRate, 0) * ModulationIndex;
// キャリアの位相に mod を足し込む = 周波数変調
return _carrier.Next(baseHertz, sampleRate, mod);
}
}
この Next の中身を1行で表すと、
out = sin(2π·(fc)·t + I·sin(2π·(fm)·t))
になります。_carrier の位相が 2π·fc·t に、mod(=I·sin(2π·fm·t))がキャリアへの変調入力に対応します。前回の式が、そっくりそのままコードになっているのが見て取れるはずです。ModulationIndex が大きいほどキャリアの位相が大きく揺さぶられ、倍音が豊かになります。
サンプル単位のループで1音を埋める
あとは、欲しい長さ(サンプル数)のバッファを用意して、Next を呼び続けて埋めるだけです。出力は -1.0〜1.0 の double なので、16bit PCM(short)に変換するために short.MaxValue を掛けます。
public static short[] Generate2OpFm(double hertz, int sampleRate, int length, int volume)
{
var fm = new TwoOperatorFm { ModulationIndex = 2.0 };
var result = new short[length];
var volumeMagnification = volume / 100d;
for (var i = 0; i < length; i++)
{
var sample = fm.Next(hertz, sampleRate);
result[i] = (short)(short.MaxValue * volumeMagnification * sample);
}
return result;
}
length は「鳴らしたい秒数 × サンプリングレート」で決まります。たとえば44100Hzで0.5秒なら22050サンプルです。このループを回すと、1音ぶんのFM波形が short[] に詰まります。これをWAVのデータ部に書き出せば、ブラウザやスピーカーで再生できます。PICOMでは、こうして作ったPCMをWAVバイナリに包んでWeb Audio APIで鳴らしています。
最小実装では音量を固定にしています。実際にはこのままだと音の出だしと終わりで波形がいきなり立ち上がり・途切れるため、「プツッ」というクリックノイズが乗りがちです。音の輪郭を時間で整える仕組みがエンベロープ(ADSR)で、これを入れると音が滑らかに立ち上がって減衰します。今回は範囲を絞るため固定音量にとどめ、エンベロープは最終回の4オペレータ実装でまとめて扱います。
なぜ「直列」なのか、そして次回へ
今回繋いだのは「モジュレータ → キャリア」という1本の直列です。2オペレータでできる接続は、実質これだけです。だから今回はアルゴリズムという言葉を一度も使いませんでした。繋ぎ方が1通りしかないなら、繋ぎ方を選ぶという概念がそもそも要らないからです。
ところが、オペレータを3個4個と増やすと、「どれをどれに繋ぐか」の組み合わせが一気に増えます。直列で深く繋げば歪んだ複雑な音に、並列で足し合わせればオルガンのような音になります。この繋ぎ方のパターンこそがアルゴリズムで、FM音源の表現力の本体です。
次回は、2オペレータでは何が足りないのか、オペレータを増やすと何ができるようになるのかを、アルゴリズムという軸で見ていきます。
まとめ
- オペレータは「位相を毎サンプル進めてsinを引く」だけの1ユニットで、状態は位相のみ
- 周波数は絶対値でなく基準ピッチに対する比
Ratioで持つと、音の高さを変えても音色が保たれる - モジュレータのsin出力をキャリアの位相に足し込むのが、前回の式そのもの
- サンプル単位のループで
phase += freq / sampleRateと進めながら埋めれば、1音分のPCMが完成する - 2オペレータの接続は直列1通りしかないので、アルゴリズムという概念はまだ要らない





