絵から音へ戻る — スペクトルを読む
手元の波形から「どの周波数がどれだけ含まれているか」を取り出すのが DFT(離散フーリエ変換)。音の数式を絵にするのと逆向きで、絵にした波形を周波数の成分表に翻訳する。
入力に使うのは、ビン 4・9・18 の sin を 1.0 : 0.6 : 0.35 の振幅で足した波形。N 個のサンプルを signal 配列に詰める。
// ビン 4, 9, 18 に成分を持たせた入力
const parts = [
{ k: 4, a: 1.0 },
{ k: 9, a: 0.6 },
{ k: 18, a: 0.35 },
]
const signal = new Array(N)
for (let i = 0; i < N; i++) {
let s = 0
for (const p of parts) s += p.a * Math.sin((2 * Math.PI * p.k * i) / N)
signal[i] = s
}下はその signal を折れ線にしたもの。3つの sin が混ざって、見ただけでは何の周波数がどれだけ入っているか読み取れない。
この波形を読み解くのが内積。ビン k ごとに、波形と「その周波数の sin/cos」を掛けて足し合わせる。波形にその周波数の成分が含まれていれば和が大きくなり、含まれていなければ打ち消し合って小さくなる。re/im が cos/sin 側の成分で、hypot がその大きさ。
// ビン k の強さ = 波形と周波数 k の正弦波の内積
const mag = (k) => {
let re = 0
let im = 0
for (let i = 0; i < N; i++) {
const ph = (2 * Math.PI * k * i) / N
re += signal[i] * Math.cos(ph)
im -= signal[i] * Math.sin(ph)
}
return Math.hypot(re, im) / N
}各ビンの mag を前半(ナイキストまで)ぜんぶ並べると、スペクトルになる。下は上段に同じ入力波形、下段にその DFT を棒で描いている。
下段の棒は、ビン 4・9・18 にだけ立っていて、高さが入力の振幅(1.0 : 0.6 : 0.35)の比になっている。読み取れなかった波形が、周波数の成分表に変わる。倍音を足して作る波形と、その波形にどの倍音がいくつ含まれているかを読む DFT は、ちょうど往復になっている。parts の k や a を書き換えて Run すると、棒の位置と高さがそのまま追従する。
re/im は波形 x[i] と複素正弦波(cos が実部、sin が虚部)との内積で、hypot がその大きさ(振幅)になる。ビン k は周波数に対応し、解像度 N の半分(ナイキスト)より上は折り返すので前半だけ見る。この素朴な二重ループは O(N²)。同じ計算を分割統治で O(N log N) に畳んだのが FFT(Cooley–Tukey, 1965)で、AnalyserNode.getByteFrequencyData が裏で回しているのもこれ。アルゴリズムが速いだけで、取り出す中身は二重ループと同じ。