AI Dev Lab
HumScore

HumScore ができるまで — 鼻歌を楽譜と MIDI にする

鼻歌を楽譜にするツールの開発ログ。 YIN によるピッチ検出、 Krumhansl-Schmuckler の調推定、 24 トライアドからのコード推定、 MIDI ファイルの書き出しと五線譜の SVG 描画までを記録しています。

HumScore は、鼻歌や音声ファイルをブラウザの中だけで解析して、五線譜・コード進行・MIDI ファイルを出す採譜ツールです。ピッチ検出から調の推定まで、外部 API も機械学習モデルも使わず素の JavaScript で書いています。

#🎯 「音を文字にする」の次に来るもの

音を扱うサービスはこれで 3 本目でした。音声を文字にする(voice-scribe)、音を別の音に変える(pitch-flip)ときて、残っていたのが 音を構造にする ── つまり採譜です。

やることを分解すると 4 段になります。

  1. 波形からその瞬間の周波数を取る
  2. 周波数の並びを音符に切り分ける
  3. 音符の並びから調とコードを推定する
  4. 五線譜と MIDI として出す

どれもライブラリを入れれば済みますが、1 と 3 は考え方が面白いので自前で書きました。

#🎤 YIN でピッチを取る

音の高さを取る方法として最初に思いつくのは FFT ですが、人の声には向きません。倍音が強く出るため、周波数のピークが基音ではなく倍音側に立ってしまい、1 オクターブ上に誤判定します。

そこで YIN という自己相関ベースの手法を使いました。「波形を τ だけずらして自分自身と重ねたとき、いちばん一致する τ が周期」という発想です。

ts
// 1. 差分関数 — τ ずらしたときの二乗誤差
for (let tau = 1; tau < half; tau++) {
  let sum = 0
  for (let i = 0; i < half; i++) {
    const d = frame[i] - frame[i + tau]
    sum += d * d
  }
  diff[tau] = sum
}

これだけだと τ = 0 付近が常に最小になってしまうので、YIN は 累積平均正規化を挟みます。

ts
// 2. 累積平均で割る — τ が小さいほど不利になるよう補正
runningSum += diff[tau]
cmnd[tau] = diff[tau] * tau / (runningSum || 1)

この一行が YIN の肝で、これがないとオクターブ違いを拾い続けます。あとは閾値(0.15)を最初に下回った谷を探し、放物線補間でサンプル間の精度まで詰めます。

ts
const denom = x0 + x2 - 2 * x1
const betterTau = denom !== 0 ? tau + 0.5 * (x0 - x2) / denom : tau
return sampleRate / betterTau

3 点から谷の頂点を推定するだけですが、これがないとサンプリング周波数の刻みでしか音程が出ず、半音の判定がぶれます。44.1kHz でも高い音ほど刻みが粗くなるので、補間は必須でした。

#🎼 調の推定 ── Krumhansl-Schmuckler

音符が並んだあと、「この曲は何調か」を出します。使ったのは音楽認知の研究から来た Krumhansl-Schmuckler のプロファイル法です。

考え方はこうです。

  1. 出てきた音を 12 音(クロマ)ごとに集計して、どの音がどれだけ使われたかのヒストグラムを作る
  2. 長調・短調それぞれの「典型的な使用頻度」のプロファイルと相関を取る
  3. 12 通り全部ずらして試し、いちばん相関が高い組み合わせを採用する
ts
for (let k = 0; k < 12; k++) {
  const shifted = chroma.slice(k).concat(chroma.slice(0, k))
  const cMaj = correlate(shifted, MAJOR_PROFILE)
  const cMin = correlate(shifted, MINOR_PROFILE)
  ...
}

slice(k).concat(slice(0,k)) で回転させて 24 通り(12 音 × 長短)を総当たりするだけです。「調」という感覚的なものが、ピアソン相関の最大値として出てくるのが気持ちよかった部分でした。

コード推定も同じ発想で、4 拍ごとのウィンドウに出てくる音を、24 個のトライアド(長三和音 12 + 短三和音 12)と突き合わせて最も近いものを選びます。

ts
for (let r = 0; r < 12; r++) {
  out.push({ name: `${PITCH_NAMES[r]}`,  pcs: [r, (r+4)%12, (r+7)%12], quality: "M" })
  out.push({ name: `${PITCH_NAMES[r]}m`, pcs: [r, (r+3)%12, (r+7)%12], quality: "m" })
}

長三和音は root から +4, +7 半音、短三和音は +3, +7。音楽理論がそのまま配列の添字になるのが実装していて楽しいところです。

#💾 MIDI をバイト列で書き出す

MIDI ファイルはライブラリを使わず、Standard MIDI File format 0 を直接組み立てました。ヘッダチャンク、トラックチャンク、デルタタイム(可変長数値)、Note On / Note Off を並べるだけです。

分解能は PPQN 480 にしています。四分音符を 480 分割する設定で、三連符(480 ÷ 3 = 160)も付点(480 × 1.5 = 720)も整数で表せます。割り切れる数字を選んでおくと、後で丸め誤差に悩まされません。

#😓 苦労したところ

サンプル音源をどう用意するか。 「鼻歌を録音してください」と言われても、最初の一回は試しにくい。かといって音声ファイルを同梱すると重くなります。そこで OscillatorNode でその場で音を合成して、ちょうちょ・きらきら星・カエルの歌・カノン進行の 4 つを内蔵しました。数十行のデータで、ファイルサイズはゼロです。

無音とノイズの切り分け。 ピッチ検出は無音区間でもそれらしい値を返してきます。RMS(音量の実効値)で閾値を切り、下回るフレームは「音なし」として捨てています。ここを入れないと、息継ぎの雑音が音符になります。

五線譜を SVG で描く。 ト音記号、加線、符頭、符幹、旗、シャープ、音名ラベル。楽譜は「音の高さ = y 座標」という単純な対応に見えて、加線(五線からはみ出た音の補助線)と臨時記号の位置決めが細かい規則の塊でした。ここが一番行数を食っています。

#🔭 今後の拡張

  • 和音(同時に鳴る複数の音)の採譜 — 現在は単旋律のみ
  • 拍子の自動推定(現在は 4/4 固定で、テンポだけ自己相関で推定)
  • MusicXML 書き出し(楽譜ソフトへ渡せる形)
  • 休符の明示的な表現

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

「機械学習でやること」だと思っていた処理が、古典的な信号処理と統計で十分届くことがあります。 ピッチ検出は 1993 年の YIN、調推定は 1990 年の音楽認知プロファイル。どちらも数十行で、モデルのダウンロードも待ち時間もありません。

新しい手法から入る前に、その分野で長く使われてきた古典的な方法を調べると、軽くて説明できる実装になることが多いと感じました。

[ ./next_action ]

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

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

[ ./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 →