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

- いちばん近い音名とオクターブ、ずれ(セント)、周波数
- 扇形のメーター。左が低め、右が高め
この記事では、チューナーの中身を書きます。マイクの音から音の高さをどう求めたか、測ったずれの誤差を 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 の設定で動きを減らしているときは、ばねで動かさず、針をすぐ目標の位置に出します。
メーターの見た目は、扇形のほかに、横に伸びる目盛りも選べます。どちらも 5 セントごとに目盛りを刻み、±5 セントの範囲を中央に示します。針がこの範囲に入ると「合っています」と出ます。

- 中央の ±5 セントの範囲。針はこの中にある
- 範囲に入ると、ずれと「合っています」をアクセントカラーで出す