逆回りの円 ― 負の周波数の話

フーリエスケッチブックのバグは「逆回りの円」を取り違えていた。負の周波数なんて不思議なものはなく、ただ時計回りに回っているだけ、という話。


フーリエスケッチブック は、マウスで描いた形を「回る円の重ね合わせ」で再現する遊びだ。大きな円の上に少し小さい円が乗り、その上にもっと小さい円が乗る。それぞれが一定の速さで回ると、いちばん先の点が元の絵をなぞる。各円が、ひとつの周波数成分だ。

最初に作ったとき、これがうまく動かなかった。形がいつまでも閉じず、ぐにゃぐにゃと暴れる。原因は、ある円たちを逆向きに回していなかったことだった。


バグの正体

DFT(離散フーリエ変換)は NN 個の点から NN 個の係数を出す。番号は k=0,1,,N1k = 0, 1, \dots, N-1 で、係数 kk は「1周のあいだに kk 回まわる円」を表す——と素朴に思っていた。私の最初のコードもそう書いていた。

でもこれは半分しか正しくない。kkN/2N/2 を超えたあたりの係数は、本当は「速く正に回る円」ではなく「ゆっくり逆に回る円」なのだ。正しくはこう直す。

const freq = k <= N/2 ? k : k - N;   // k > N/2 は負の周波数

たとえば N=256N = 256k=255k = 255 の係数は、255255 回転ではなく 255256=1255 - 256 = -1 回転。つまり「1周につき1回、逆向きに回る円」だ。この符号を落としていたせいで、後半の円たちが猛烈な速さで正回転してしまい、絵がまったく再現されなかった。


負の周波数とは「時計回り」のこと

ここで引っかかるのが「負の周波数」という言葉だと思う。周波数がマイナスって何だ、と。

複素平面で考えると、何でもない。回るフェーザ ejωte^{j\omega t} は、ω>0\omega > 0 なら反時計回りに回る。では ω<0\omega < 0 なら? ただ時計回りに回るだけだ。

ejωt=ej(ω)te^{-j\omega t} = e^{j(-\omega)t}

負の周波数は、不思議な存在ではなく「逆回りの円」につけた名前にすぎない。フーリエスケッチブックのバグは、この時計回りの円を反時計回りだと思い込んでいた、ただそれだけのことだった。


なぜ実数の信号に「逆回り」が要るのか

ひとつ疑問が残る。私たちが描く絵や、現実の音は実数だ。なぜ逆回りの円なんてものが必要になるのか。

答えはオイラーの公式の裏返しにある。

cosθ=ejθ+ejθ2\cos\theta = \frac{e^{j\theta} + e^{-j\theta}}{2}

実数の振動 cosθ\cos\theta は、反時計回りの円と時計回りの円を半分ずつ足したものなのだ。下のアニメーションを見てほしい。長さ 12\tfrac12 の2本のフェーザが逆向きに回っている。縦方向(虚部)の動きはいつも打ち消し合い、横方向(実部)だけが残る。その和が、実軸の上を行ったり来たりする cosθ\cos\theta になる。

実数とは「上下対称な複素数の組」だと言ってもいい。だからこそ実信号のスペクトルは、正と負の周波数が必ずペアで、左右対称に現れる。片方だけでは実数にならない。フーリエスケッチブックで後半の円を逆回しにしなければいけなかったのは、描いた線が実数(正確には実部と虚部の組だが、各軸が実)の世界の住人だったからだ。


これで「回転」の話がひとつながりになった。1個の回る点(複素回転)縮みながら回る点(螺旋)、そしてたくさんの円を足し合わせるフーリエ。そのフーリエの中で、半分の円は逆向きに回っている。

負の周波数は、ずっと「逆回り」という素朴な顔をしてそこにいた。バグを直すというのは、その素朴な顔にもう一度ちゃんと挨拶することだったのだと思う。


付録:直す前のコード

参考までに、実際にバグっていたときの DFT を載せておく。係数の周波数を、ただ番号 kk のまま使っていた。

function computeDFT(pts) {
  const N = pts.length;
  const result = [];
  for (let k = 0; k < N; k++) {
    let re = 0, im = 0;
    for (let n = 0; n < N; n++) {
      const a = (2 * Math.PI * k * n) / N;
      re += pts[n].x * Math.cos(a) + pts[n].y * Math.sin(a);
      im += -pts[n].x * Math.sin(a) + pts[n].y * Math.cos(a);
    }
    const freq = k;          // ← ここが間違い。k をそのまま周波数にしていた
    result.push({ freq, amp: Math.hypot(re,im)/N, phase: Math.atan2(im,re) });
  }
  return result.sort((a,b) => b.amp - a.amp);
}

このコードで動いていた当時のスケッチブックを、そのまま貼っておく。最初から矩形波が読み込まれているけれど、後半の円が逆回りにならず猛烈に回って、線がいつまでも閉じずに暴れているはずだ。下の絵をクリックして自分で形を描いても、同じように暴れる。サンプル点だけはちゃんと通るのに、点と点のあいだで外へ跳ね回るのが、このバグの手触りだ。

DFT 本体(二重ループの中身)は何も間違っていない。係数の値はちゃんと正しく出ていた。問題は、出てきた係数を「どの速さで回る円」として解釈するか、その一行だけだった。直したのはこの一行だ。

    const freq = k <= N / 2 ? k : k - N;   // k > N/2 は負の周波数(逆回り)

そして円を回すアニメーション側は、この freq の符号をそのまま角速度に使っている。

const angle = f.freq * t + f.phase;   // freq が負なら時計回りに回る

freq が負になった円は、angle が時間とともに減っていく。つまり時計回りだ。たった一箇所、符号を取り戻しただけで、後半の円たちが正しい向きに回り始め、暴れていた線がぴたりと元の絵を閉じた。バグの修正というのは、たいてい数式そのものではなく「数式の読み方」の側にあるのだと思う。

— ランキン