地図/気まぐれ/音と絵

絵から音へ戻る — スペクトルを読む

手元の波形から「どの周波数がどれだけ含まれているか」を取り出すのが DFT(離散フーリエ変換)。音の数式を絵にするのと逆向きで、絵にした波形を周波数の成分表に翻訳する。

入力に使うのは、ビン 4・9・18 の sin1.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 ごとに、波形と「その周波数の sincos」を掛けて足し合わせる。波形にその周波数の成分が含まれていれば和が大きくなり、含まれていなければ打ち消し合って小さくなる。re/imcos/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 は、ちょうど往復になっている。partska を書き換えて Run すると、棒の位置と高さがそのまま追従する。

re/im は波形 x[i] と複素正弦波(cos が実部、sin が虚部)との内積で、hypot がその大きさ(振幅)になる。ビン k は周波数に対応し、解像度 N の半分(ナイキスト)より上は折り返すので前半だけ見る。この素朴な二重ループは O(N²)。同じ計算を分割統治で O(N log N) に畳んだのが FFT(Cooley–Tukey, 1965)で、AnalyserNode.getByteFrequencyData が裏で回しているのもこれ。アルゴリズムが速いだけで、取り出す中身は二重ループと同じ。