開発ブログ

BLOG

音程のずれを 1 セント未満の精度で測る、ブラウザのチューナーを作る

マイクの音から音の高さを McLeod の方法で求めるチューナーを、ブラウザで作った。4 弦ベースの低い E を検出できなかったこと、高い音ほどずれた原因と 1 セント未満まで精度を上げた方法、読み取りに合わせて針を揺らさず滑らかに動かす、ばねの動きの式を書く。

公開 2026年10月12日

メトロノームの画面から、チューナーを使えるようになりました。マイクで楽器の音を聞き、いちばん近い音名とオクターブ、ずれ(セント)、周波数を出します。セントは音程の単位で、半音の 100 分の 1 です。同じときに、選んだ音を鳴らし続ける機能(ドローン)も入りました。ドローンの話は別の記事に書きました。

チューナーの画面。番号 1 は音名 A4 と「-8 cent」「438.0 Hz」の読み取り、番号 2 は扇形のメーターと、少し左に振れた針
438 Hz の音を聞いたところ
  1. いちばん近い音名とオクターブ、ずれ(セント)、周波数
  2. 扇形のメーター。左が低め、右が高め

この記事では、チューナーの中身を書きます。マイクの音から音の高さをどう求めたか、測ったずれの誤差を 1 セント未満にするまでに直したこと、測った値に合わせて針を滑らかに動かす方法の 3 つです。

マイクの音から音の高さを求める

マイクの音は AnalyserNode で受け取ります。音は、波の高さを一定の間隔で測った値(サンプル)の並びで届きます。48 kHz なら 1 秒に 48,000 サンプルです。60 ミリ秒ごとに、直近の 4096 サンプル(48 kHz で約 85 ミリ秒)を取り出して調べます。

音の高さの求め方には、McLeod の方法を使いました。音を少しずつずらして元の音と重ね、ずらし幅ごとに、どれだけ似ているかを -1〜1 の値で出します(正規化した自己相関と呼ばれる値です)。ずらした幅が 1 周期ちょうどのところで値が高くなるので、その山の位置から周期と周波数がわかります。山の位置は、山とその両隣の 3 点を放物線で結び、1 サンプルより細かく求めています。

山は 1 周期のところだけでなく、2 周期・3 周期のところにも立ちます。倍音の多い楽器では、2 周期目の山のほうが高くなることもあります。いちばん高い山を選ぶと、1 オクターブ下に取り違えてしまいます。そこで、いちばん高い山に比べて 9 割の高さを超えた山のうち、最初の山を選びます。

const highest = Math.max(...peaks.map((tau) => nsdf[tau]));
const chosen = peaks.find((tau) => nsdf[tau] >= highest * PEAK_THRESHOLD); // 0.9

これとは別に、選んだ山の値そのものが 0.8 に届かないときは、周期のある音ではない(雑音や打楽器)とみなし、音の高さを出しません。

4 弦ベースの低い E を検出できなかった

合成した音で試すテストで、4 弦ベースの E1(約 41 Hz)だけが検出できませんでした。

山は、似ている度合いの値が 0 を上に横切ってから、下に横切るまでの区間で探していました。E1 の 1 周期は 48 kHz で約 1165 サンプルです。一方、ずらし幅を調べる範囲は、探す周波数の下限(38 Hz)に合わせて約 1265 サンプルまでです。山を過ぎて値が下がり始めても、0 を下に横切る前に範囲が終わってしまい、山として数えていませんでした。

そこで、範囲の終わりまでに 0 を下に横切っていなくても、山として数えるようにしました。ただし、いちばん高い値が範囲の端にあるときは数えません。端でなければ、値はもう下がり始めているからです。

// 低い音は、ピークを過ぎて 0 を横切る前に計算の範囲が終わる。下がり始めていれば(端でなければ)ピークとみなす
if (state.inside && state.best >= minLag && state.best < nsdf.length - 1) {
  peaks.push(state.best);
}

高い音ほどずれた

低い E を直したあと、倍音を多く含むノコギリ波(弦楽器や管楽器に近い音)を合成し、求めた音の高さのずれをセントで測りました。表の 44.1 kHz・48 kHz は、合成した音の 1 秒あたりのサンプル数です。

周波数
44.1 kHz
48 kHz
82.41 Hz
+0.15
-0.56
110 Hz
-0.50
-0.14
220 Hz
-0.39
+0.32
440 Hz
+1.07
+0.49
880 Hz
+1.54
-1.33

440 Hz・880 Hz の高い音でずれが大きく、880 Hz では 1.5 セント近くずれていました。

山の位置は、放物線で 1 サンプルより細かく求めています。ただ、880 Hz の 1 周期は 48 kHz で約 55 サンプルしかないため、サンプルの間を放物線で推し量るときの誤差が、周期に比べて大きな割合になります。

そこで、何周期か先にも同じくらいの高さの山があれば、そのずらし幅を周期の数で割るようにしました。たとえば 10 周期先の山を使えば、推し量るときの誤差も 10 分の 1 になります。周期を決めるのは、前の節で選んだ最初の山です。先の山は、その整数倍の近くにあるものだけを、細かく求めるために使います。

const multiple = peaks
  .map((tau) => ({ tau, count: Math.round(tau / first.lag) }))
  .filter(
    ({ tau, count }) =>
      count >= 2 &&
      Math.abs(tau - count * first.lag) <= 2 &&
      nsdf[tau] >= first.value * PEAK_THRESHOLD,
  )
  .at(-1);
const lag = multiple
  ? refineLag(nsdf, multiple.tau).lag / multiple.count
  : first.lag;

精度とは別に、計算の速さも直しました(直す前は 1 回の計算に 4.2〜4.8 ミリ秒)。44.1 kHz・48 kHz の音は、隣り合う 2 サンプルを平均して半分に間引いてから調べるようにしました。調べる周波数の上限は 2000 Hz で、半分にしても 1 秒あたりのサンプル数(22.05 kHz・24 kHz)はその 10 倍以上あります。ずらし幅もサンプル数も半分になるので、計算の量はおよそ 4 分の 1 になります。

2 つの変更のあとで、同じように測った結果です。

周波数
44.1 kHz
48 kHz
82.41 Hz
+0.19
-0.28
110 Hz
-0.56
+0.03
220 Hz
-0.07
+0.02
440 Hz
+0.12
-0.03
880 Hz
+0.16
+0.16

いちばん大きなずれは 0.56 セントになり、1 回の計算は 1.1〜1.4 ミリ秒になりました。いまは、ノコギリ波のずれが 1 セント未満であることを、自動のテストで確かめています。

針を滑らかに動かす

測った値は 60 ミリ秒ごとにしか変わらず、1 回ごとに少しずつ揺れます。最初は揺れを抑えるため、直近 5 回の測定値の中央値を出していました。

ブラウザに偽のマイクの音を流し、音程を変えながら、画面を描き直すたび(フレームごと)に針の角度を記録しました。すると、音が変わってから針が動き始めるまでに約 0.23 秒かかっていました。中央値は、5 回のうち 3 回が新しい音の値に入れ替わるまで変わらないためです。動き始めると、新しい値が届くたびに針が跳びます。

そのため、中央値は直近 3 回にして、ときどき外れる値を捨てるだけにしました。細かな揺れは、針の動かし方で抑えます。針は、臨界減衰のばね(行き過ぎずに、最も早く止まるばね)の動きで目標の値に近づけます。目標の値が変わってから約 0.3 秒で、針はほぼ追いつきます。前の段落の約 0.23 秒は動き始めるまでの時間で、追いつくまでの時間とは測っているものが違います。

export const stepNeedle = (state, target, elapsed, stiffness = 14) => {
  const offset = state.value - target;
  const carry = (state.velocity + stiffness * offset) * elapsed;
  const decay = Math.exp(-stiffness * elapsed);
  return {
    value: target + (offset + carry) * decay,
    velocity: (state.velocity - stiffness * carry) * decay,
  };
};

ばねの式は、少しずつ足していく近似ではなく、厳密に解いた形にしてあります。そのため、フレームの間隔が揺れても、同じ時間で同じ位置に来ます。

フレームごとの位置は React の再描画を通さず、requestAnimationFrame で要素の位置を直接書き換えます。OS の設定で動きを減らしているときは、ばねで動かさず、針をすぐ目標の位置に出します。

60 ミリ秒ごとに揺れる測定値を、針が滑らかに追う

メーターの見た目は、扇形のほかに、横に伸びる目盛りも選べます。どちらも 5 セントごとに目盛りを刻み、±5 セントの範囲を中央に示します。針がこの範囲に入ると「合っています」と出ます。

横の目盛りのチューナー。番号 1 は中央の ±5 セントの範囲と、その中の針、番号 2 はアクセントカラーで出た「+4 cent」
441 Hz の音を聞いたところ
  1. 中央の ±5 セントの範囲。針はこの中にある
  2. 範囲に入ると、ずれと「合っています」をアクセントカラーで出す

この記事を共有

X でポストはてなブックマーク