▍AI Dev Lab
MetroTune

MetroTune ができるまで — メトロノームとチューナーを作る

メトロノーム+チューナーの開発ログ。Web Audioの先読みスケジューラでテンポをぶれさせない方法と、マイク波形を自己相関で解析して音名とセントを出す実装を記録します。

楽器を練習するとき、最低限ほしい道具が 2 つあります。メトロノーム(一定のテンポを刻む)とチューナー(音の高さを合わせる)。どちらもアプリはありますが、広告だらけだったり、片方ずつ別アプリだったり。

MetroTune は、この 2 つを 1 ページにまとめたツールです。しかも面白いことに、両方とも「音の時間を正確に扱う」という点で技術的に地続きでした。メトロノームは正確な間隔で音を鳴らし、チューナーは音の周期を正確に測る。裏表の関係です。


#🥁 メトロノーム:JS タイマーでは刻めない

まず知っておくべき事実:setInterval でメトロノームを作ると、必ずガタつきます。

JavaScript のタイマーは、他の処理に割り込まれて数十ミリ秒ずれます。人間の耳はテンポのゆらぎに敏感なので、このズレは「もたつき」としてはっきり聞こえてしまう。練習の役に立ちません。

解決策は look-ahead スケジューリングです。JS で「いつ鳴らすか」を予約だけして、実際の発音は Web Audio の正確なオーディオクロックに任せます。

ts
export function playClick(
 ctx: AudioContext, dest: AudioNode, when: number,
 kind: "accent" | "beat" | "sub",
): void {
 const osc = ctx.createOscillator
 const gain = ctx.createGain
 const freq = kind === "accent" ? 1760 : kind === "beat" ? 1320 : 880
 const amp = kind === "accent" ? 0.6 : kind === "beat" ? 0.42 : 0.2
 osc.type = "square"
 osc.frequency.value = freq
 gain.gain.setValueAtTime(0.0001, when)
 gain.gain.exponentialRampToValueAtTime(amp, when + 0.001)
 gain.gain.exponentialRampToValueAtTime(0.0001, when + 0.05)
 osc.connect(gain); gain.connect(dest)
 osc.start(when); osc.stop(when + 0.06)
}

引数の when が肝です。これは「オーディオクロック上の、鳴らすべき正確な時刻」。JS 側は少し先の拍までをまとめて when 付きで予約しておき、発音のタイミングはオーディオエンジンがサンプル精度で守る。これで、何分回してもずれないクリックになります。

クリック音は録音ではなくその場で合成しています。正方波を短く鳴らし、exponentialRampToValueAtTime で一瞬で立ち上げてすぐ減衰させる——これが「カチッ」という硬い音になります。強拍・弱拍・裏拍で周波数と音量を変えて(1760 / 1320 / 880 Hz)、拍の重みを聴き分けられるようにしました。


#🎸 チューナー:音の「周期」を測る

チューナーはメトロノームの逆です。鳴っている音を録って、その基本周波数(音の高さ)を測る。これには**自己相関(autocorrelation)**という手法を使いました。

音は波です。同じ高さの音は、同じ波形が一定周期で繰り返されている。自己相関は「波形を少しずつずらして自分自身と重ね、一番よく一致するズレ幅」を探すことで、その繰り返しの周期を見つけます。

ts
export function autoCorrelate(buf: Float32Array, sampleRate: number): number {
 const SIZE = buf.length
 // 無音なら棄却

 let rms = 0
 for (let i = 0; i < SIZE; i++) rms += buf[i] * buf[i]
 rms = Math.sqrt(rms / SIZE)
 if (rms < 0.01) return -1

 // ... 端の無音をトリム ...


 const c = new Array(n).fill(0)
 for (let lag = 0; lag < n; lag++) {
 for (let i = 0; i < n - lag; i++) c[lag] += b[i] * b[i + lag] // ずらして重ねる

 }
 // 最初の谷を越えたあとの、一致が最大になるズレ = 1 周期

 // ...

 return sampleRate / T0 // 周期 → 周波数

}

c[lag] が「lag だけずらして重ねたときの一致度」。これが最大になる lag(= T0)が 1 周期のサンプル数で、サンプルレート ÷ T0 が周波数になります。

工夫が 2 つあります。ひとつは無音の棄却。RMS(音の大きさ)が小さすぎるときは -1 を返して、無音の中からノイズを拾わないようにする。もうひとつは放物線補間です。

ts
const a = (x1 + x3 - 2 * x2) / 2
const bb = (x3 - x1) / 2
if (a) T0 = T0 - bb / (2 * a)

自己相関のピークはサンプル単位(飛び飛び)でしか出ませんが、ピーク周辺の 3 点に放物線を当てて頂点を求めると、サンプルの間の周期まで推定できます。これがないと、特に高い音でチューニングがガタつきます。


#🎵 周波数を音名とセントに変換する

周波数(例: 442 Hz)が出たら、それを人間に分かる「音名(A)と、どれだけずれているか(セント)」に翻訳します。

ts
export function freqToNote(freq: number, a4 = 440): PitchReading | null {
 if (freq <= 0 || !Number.isFinite(freq)) return null
 const midi = 69 + 12 * Math.log2(freq / a4)
 const nearest = Math.round(midi)
 const cents = Math.round((midi - nearest) * 100)
 const note = NOTE_NAMES[((nearest % 12) + 12) % 12]
 const octave = Math.floor(nearest / 12) - 1
 return { freq, note, octave, cents }
}

音の高さは対数で並んでいます(1 オクターブ上がると周波数は 2 倍)。だから log2(freq / a4) で「A4 から何オクターブ分か」を測り、12 を掛けて半音単位の番号(MIDI ノート番号)にします。一番近い整数が音名、そこからのズレがセント(半音の 1/100)。0 セントならジャスト、±なら少し高い/低い。

a4 = 440 を引数にしているのは、基準ピッチを 442 Hz などに変えられるようにするため。オーケストラや古楽では基準が違うことがあるので、ここは可変にしました。


#😓 苦労したところ

#マイク許可と無音

チューナーはマイク入力(getUserMedia)が必要で、ユーザーの許可が要ります。許可されるまでは動かせないし、許可後も「音が鳴っていない間」は周波数が定まらない。無音時に無理やり数値を出すと、画面がチラチラして落ち着きません。RMS で無音を弾き、有効な音が入っているときだけ表示を更新するようにしました。

#倍音に釣られる

弦楽器やリード楽器は、基本周波数の上に倍音(2 倍・3 倍…の周波数)が乗ります。素朴なピーク検出だと、この倍音を基本周波数と誤認して「1 オクターブ上」と表示してしまう。自己相関で「最初の谷を越えたあとの最大ピーク」を取るようにしているのは、この倍音の誤検出を避けるための定石です。

#メトロノームとチューナーの同居

両方が同じ AudioContext を使うので、メトロノームのクリックがチューナーのマイクに回り込むと、自分のクリック音をチューニングし始めてしまう。使う場面が違う(テンポ練習と音合わせ)ので、基本は片方ずつ使う想定にしていますが、こうした「音の道具を 1 ページに同居させる」ときの相互干渉は、作って初めて気づく類の問題でした。


#🔭 今後の拡張

  • チューナーの楽器プリセット — ギター/ベース/ウクレレの各弦を目標音として表示
  • メトロノームのアクセントパターン編集 — 拍ごとの強弱を自由に組む
  • テンポの段階増加 — 練習で少しずつ BPM を上げる自動モード
  • 視覚メトロノーム — 音を出せない環境向けの点滅・振り子表示

#💡 このサービスから言えること

「時間を正確に扱う」という一点で、鳴らす側と測る側はつながっている。 メトロノームは正確な時刻に音を出し、チューナーは音の周期を正確に測る。方向は逆でも、必要な精度の源は同じ Web Audio のクロックでした。一見別々の機能が、根っこで同じ技術を使っている——だから 1 つのツールにまとめる意味がありました。

技術的な核心は、やはり look-ahead スケジューリングと自己相関+放物線補間という、音の分野の定石です。どちらも「JS の粒度では足りない精度を、別の仕組みで補う」という発想でできています。ブラウザで音を扱うなら避けて通れない考え方で、環境音の合成でも同じ手を使っています。目に見えないぶん、音の道具は「正確さ」がそのまま使い物になるかを決める、という実感が残りました。

[ ./next_action ]

読んだら、 MetroTune を実際に動かす。

この開発ログは MetroTune をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ShadowTwin

立体パズルの別解を作る:同じ影と最少の積み木

ShadowTwinの開発記録。3方向の図から別々の最少配置を2つ探す仕組み、5問の独立全探索、スマホの横並び比較、保存・再編集と入力検査を解説します。実照明の計算や能力診断ではありません。

read log →
SlitCard

スリットアニメーションを作る:4コマの合成と透明原稿の設計

SlitCardの開発記録。4コマの帯を交互に合成し、すき間と黒帯で別のコマを見せる仕組みを解説。同縮尺の台紙・透明シートSVG、50mm確認線、手動プレビュー・初期非表示、再編集と実出力検査を整理します。

read log →
WindowLight

日当たりシミュレーションを作る:窓の光を床へ投影する幾何モデル

WindowLightの開発記録。太陽位置近似から窓の4隅を床へ投影し、凸多角形を部屋で切り取る設計を解説。国立天文台の16時刻との限定照合、大気差・低高度・実際の採光との違い、SVGと設定の再編集を整理します。

read log →
GridLights

電源構成の比較を作る:割合と実量を100個の電球で読み解く

電源構成比較GridLightsの開発記録。12か国の年次発電データを最大剰余法で100個の電球に変換し、割合とTWhを比較する設計を解説。欠損を0補完しない7分類、予想と実値、出典付きSVGと実データ検証を整理します。

read log →
SoundLoom

音のお絵かきを作る:実WAVから絵を測り直す設計

音のお絵かきの開発記録。描画を正弦波のPCMとWAVへ変換し、保存音声の周波数をGoertzel法で測って比較する仕組みを解説。音量・測定窓・量子化の限界と、編集・離脱時の古い結果を抑止する検証を整理します。

read log →
MazeLoom

宝探し迷路を作る:寄り道まで含めた最短ルートの探索

宝探し迷路の開発記録。通路を木として生成し、現在地と3つの宝の取得状態を組み合わせて最短ルートを探す仕組みを解説。問題コードの版固定、画面と解答付きPDFの一致、手数と印刷の検証を整理します。

read log →