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 →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →