Synthesia風「降ってくる音符」のピアノアプリをWeb Audio + Canvasで作った

「こういうピアノアプリ作れますか」と1枚のスクリーンショット(暗い夜空を背景に、青と金色の音符バーが88鍵の鍵盤へ降ってくる映像)から始まったプロジェクト。YouTubeのピアノ演奏動画でよく見る、いわゆる Synthesia風(ピアノロール型)ビジュアライザー を、外部ライブラリなし・ブラウザ標準APIだけで実装した。

  • デモページ: /piano-roll
  • 実装: apps/web/app/pages/piano-roll.vueapp/utils/pianoRoll.tsapp/data/piano-roll/songs.ts

どんなアプリか

  • 音符バーが画面上部から降ってきて、鍵盤の上端(判定ライン)に届いた瞬間に音が鳴り、鍵盤が光る
  • 青が右手、金色が左手。本家Synthesiaや演奏系YouTuberの配色に合わせた
  • バーには運指番号(1=親指〜5=小指)を表示できる(トグルでオン/オフ)
  • 内蔵曲は「エリーゼのために(主部・約1分)」「カノン」「きらきら星」の3曲
  • テンポ(50〜150%)・音量・シークバー付き
  • 音はすべてWeb Audio APIでリアルタイム合成。音源ファイルは1つも使っていない

全体構成——ロジックと副作用の分離

ファイルは3つに分けた。CLAUDE.mdの「純粋関数とI/Oを分離する」方針に沿っている。

ファイル役割副作用
app/utils/pianoRoll.ts鍵盤レイアウト計算・時間変換・再生位置の検索なし(純粋関数のみ)
app/data/piano-roll/songs.ts内蔵曲の音符データなし(定数データ)
app/pages/piano-roll.vueCanvas描画・Web Audio発音・UIイベントすべてここに集約

純粋関数に切り出したおかげでVitestのユニットテストが34本書けて、後述するとおりデータの不整合をテストが実際に1件検出した

曲データの持ち方

1音 = { midi, start, dur, hand, finger }

export interface NoteEvent {
  midi: number      // MIDIノート番号(A0=21 〜 C8=108、A4=69が440Hz)
  start: number     // 開始位置(曲ごとの時間単位)
  dur: number       // 長さ(同上)
  hand: 'L' | 'R'   // 左手 / 右手(バーの色分け)
  finger?: number   // 運指番号 1〜5(任意)
}

時間は秒ではなく「曲ごとの時間単位」で持つ。エリーゼのためになら16分音符=1単位、きらきら星なら4分音符=1単位。曲側に unitSec(1単位の秒数)を持たせて、再生時に掛け算で秒へ変換する。こうすると楽譜からの書き起こしが「16分音符が何個分か」を数えるだけになり、テンポ調整も unitSec 1個の変更で済む。

// エリーゼのために 冒頭「ミ・レ♯・ミ・レ♯・ミ・シ・レ・ド」
n(76, 0, 1, 'R', 4), n(75, 1, 1, 'R', 3), n(76, 2, 1, 'R', 4), n(75, 3, 1, 'R', 3),
n(76, 4, 1, 'R', 4), n(71, 5, 1, 'R', 2), n(74, 6, 1, 'R', 4), n(72, 7, 1, 'R', 3),

繰り返し構造は関数で組み立てる

エリーゼのためには A-A-B-A-B-A 構成(約60秒)。A部48単位・B部25単位の配列を1回ずつ定義し、shiftEvents(全ノートの開始位置をオフセットする純粋関数)で並べて組み立てた。

events: [
  ...fuerEliseA,
  ...shiftEvents(fuerEliseA, 48),
  ...shiftEvents(fuerEliseB, 95),
  ...shiftEvents(fuerEliseA, 120),
  ...shiftEvents(fuerEliseB, 167),
  ...shiftEvents(fuerEliseA, 192),
]

B部の末尾とA部のピックアップ(ミ・レ♯)が重なる箇所は、B部側を1音手前で止めてA部の先頭に受け渡している。この接続部のオフセット計算を間違えると同じ鍵盤の音が同時刻に重複するが、それは後述のテストが守ってくれる。

楽譜データはどこから来るのか(よくある質問)

今回の3曲は楽譜を見ながら手で配列に書き起こした。外部データは使っていない。ただし世の中には機械可読の楽譜データがオープンで存在する。

  • MIDIファイル(.mid): 「いつ・どの鍵盤を・どれくらいの強さで」の演奏イベント列。このアプリのデータ形式とほぼ1対1で対応する。クラシックの著作権切れ楽曲はネット上に大量にある
  • MusicXML: 五線譜の記譜情報(音符・休符・運指・強弱記号まで)を持つXML。楽譜編集ソフトの標準交換形式
  • IMSLP(国際楽譜ライブラリープロジェクト): パブリックドメイン楽譜のスキャンPDFが約70万点。「エリーゼのために」も原典版から校訂版まで揃う
  • Mutopia Project: LilyPond形式+MIDI付きのフリー楽譜集
  • MuseScore: 無料の楽譜編集ソフト。MusicXML/MIDIの読み書きができ、コミュニティ投稿の楽譜も多い(投稿物は権利状態に注意)

つまり「有名曲ならオープンソース(正確にはパブリックドメイン)の楽譜データがあるか?」への答えはイエス。MIDIファイルをパースして NoteEvent[] に変換するローダーを書けば、手書き起こしなしで曲を増やせる。

紙の楽譜のスキャンからでも読めるのか

できる。**OMR(Optical Music Recognition、楽譜の光学認識)**という分野があり、代表的なオープンソース実装が Audiveris。スキャン画像→MusicXMLに変換でき、MuseScoreのPDFインポートも内部でこれを使っている。認識精度は印刷品質に依存するので「OMRで8〜9割→目視で修正」が現実的な運用。家にある練習用の楽譜をスキャンして取り込むワークフローは、①Audiveris等でMusicXML化 → ②MusicXML→NoteEvent変換器を書く、の2段で実現できる。手書きの書き込みが多い楽譜なら、画像を直接AIに読ませて配列に書き起こさせる方が早いケースもある。

88鍵レイアウトの計算

鍵盤はA0(MIDI 21)〜C8(MIDI 108)の88鍵。白鍵52個・黒鍵36個。

  • 白鍵: 描画幅を52等分
  • 黒鍵: 直前の白鍵との境界の中央に、白鍵の62%幅で配置
export const layoutKeyboard = (width: number, low = 21, high = 108): KeyRect[] => {
  const whites = whiteKeysBetween(low, high)   // 52個
  const ww = width / whites.length
  const whiteX = new Map(whites.map((m, i) => [m, i * ww]))
  return midiRange(low, high).map((m) => {
    if (!isBlackKey(m)) return { midi: m, x: whiteX.get(m) ?? 0, w: ww, black: false }
    const bw = ww * 0.62
    return { midi: m, x: (whiteX.get(m - 1) ?? 0) + ww - bw / 2, w: bw, black: true }
  })
}

黒鍵判定はピッチクラス(MIDI番号 mod 12)が {1, 3, 6, 8, 10} = C#/D#/F#/G#/A# かどうかを見るだけ。実物のピアノはC#とD#で黒鍵の寄り方が微妙に違うが、この用途では境界中央の簡易配置で見た目に違和感はない。

落下バーの座標系

描画の核は「再生位置 pos(秒)と画面のy座標の対応付け」。

  • 鍵盤の上端 = 判定ライン hitY
  • 先読み窓 LOOKAHEAD = 3.0秒:今から3秒以内に鳴る音だけが画面に見える
  • ピクセル/秒 pps = hitY / LOOKAHEAD

各ノートの矩形は毎フレームこう決まる。

const bottom = hitY - Math.max(0, nt.startSec - pos) * pps  // 発音中は鍵盤上端に張り付く
const top    = hitY - (nt.startSec + nt.durSec - pos) * pps // 残り時間ぶん上に伸びる

Math.max(0, ...) が効いていて、発音が始まったバーは下端が判定ラインに固定されたまま上端だけが下りてくる。つまりバーが鍵盤に「吸い込まれていく」見え方が、座標式1行で出る。発音中のノートは shadowBlur でグロー+対応する鍵盤を同色で塗る。

背景の星空は Math.random() で位置・半径・透明度を決めた点をリサイズ時に生成して fillRect するだけ。1〜2pxの点でも数が散っていれば夜空に見える。

音の合成——ピアノ音をオシレーター3本で偽装する

サンプル音源を使わず、1音ごとにオシレーターを組み立てる減衰音シンセで「ピアノっぽさ」を出している。

osc(triangle, f)    ── gain 1.00 ─┐
osc(sine,    2f)    ── gain 0.30 ─┼─ lowpass(6f, max 8.5kHz) ─ envelope ─┬─ master
osc(sine,    3f)    ── gain 0.10 ─┘                                      └─ reverb send(0.22) ─ convolver ─ master

ポイントは4つ。

  1. 倍音構成: 基音は三角波(奇数倍音を含み、サイン波より「芯」が出る)。そこに2倍音・3倍音のサイン波を薄く重ねる
  2. ローパスフィルタ: カットオフを基音の6倍(上限8.5kHz)に置き、高域のギラつきを落とす。低い音ほどこもり、高い音ほど抜ける、という実楽器の傾向も一応出る
  3. エンベロープ: 10msの立ち上がり → 指数カーブで素早く減衰 → ノート長に合わせてリリース。ピアノらしさの大半は「打鍵直後にスッと減衰する」このカーブが担っている
  4. リバーブ: ホワイトノイズを (1 - i/len)^2.8 で減衰させた1.8秒のバッファを ConvolverNode のインパルス応答にする。お手軽だが空間感が一気に出る

周波数変換は平均律の定義そのまま。

export const midiToFreq = (midi: number): number => 440 * 2 ** ((midi - 69) / 12)

スケジューリング——setIntervalでは音はずれる

JavaScriptのタイマーは数十ms平気でずれるので、発音タイミングを setInterval で直接叩くと曲がヨレる。定石どおり2層構えにした。

  • 50ms周期の setInterval は「見張り役」。再生位置から0.25秒以内に始まるノートを見つけたら、
  • AudioContextのサンプルクロックactx.currentTime)基準の正確な時刻を計算して osc.start(when) で予約する

再生位置は「アンカー方式」で管理する。再生開始時に anchorPos(曲内位置)と anchorCtx(オーディオ時計)を記録し、現在位置は常に計算で出す。

const currentPos = () => playing ? anchorPos + (actx.currentTime - anchorCtx) * rate : pos

テンポ変更(rate)やシークのたびにアンカーを取り直すだけで、一時停止・再開・倍速が全部この1本の式に乗る。状態変数に時間を足し込んでいく方式と違って誤差が蓄積しない。

運指番号の表示

「どの指で押さえるかの定石を、降ってくるバーに書けないか」という要望で追加した機能。NoteEventfinger(1=親指〜5=小指)をそのままバーの下端付近に描く。

if (fingerChk.checked && nt.finger && h >= 18 && w >= 9) {
  g2d.fillText(String(nt.finger), x + w / 2, Math.min(bottom, hitY) - 9)
}
  • バーが小さすぎるとき(高さ18px未満・幅9px未満)は描かない。ごちゃつき防止
  • バーの下端寄りに置くのがミソ。プレイヤーの視線は判定ライン付近にあるので、鍵盤に届く直前に番号が目に入る
  • トグルでオフにできる

運指データはエリーゼのためにだけ付けた。右手の「ミ・レ♯」交互は4-3、左手のアルペジオ(ラ・ミ・ラ)は5-2-1、といった一般的な校訂版ベースの簡易版(運指は版によって違うので、あくまで一例)。

テストが実データのバグを1件捕まえた話

純粋関数化の恩恵で、Vitestのテストが34本ある。鍵盤レイアウト(88鍵・白鍵52個・全キーが幅に収まる)、時間変換、再生位置検索、formatTime のほか、曲データ自体の整合性テストを入れた。

it.each(PIANO_SONGS)('同じ鍵盤の音が同時に重ならない', (song) => { ... })

これが初回実行で実際に失敗した。きらきら星の伴奏に入れたGコード(ソ+レ)の「レ(D4)」が、メロディの「レ」と同時刻に重なっていた。同じ鍵盤を同時に2つのノートが叩くのは物理的にあり得ない(=データ起こしのミス)。伴奏のボイシングをソ+シに変えて解消した。楽譜の手起こしは絶対にどこかで間違えるので、この種の「物理的にあり得ない状態の検出」をテストにしておく価値は高い。

エリーゼのためにをA-B連結で60秒に拡張したときも、接続部のオフセット計算が正しいことをこのテストが保証してくれた。

今後の拡張メモ

本物のピアノを繋いで弾く(Web MIDI)

ChromeはWeb MIDI APIを実装しているので、USBでPCに繋いだ電子ピアノの打鍵をブラウザで受け取れるnavigator.requestMIDIAccess() で鍵盤の押し離し(Note On/Off)が取れるため、「降ってくるバーに合わせて実際の鍵盤を弾いて判定する」音ゲー化は技術的に素直にできる。対応楽器は「USB to Host端子(USB-B)を持つ電子ピアノ」で、ここ10年の機種ならエントリー帯でもほぼ全機種が該当する(Yamaha Pシリーズ、Casio Privia、Roland FPシリーズ、Korg Bシリーズなど。PC側はUSBケーブル1本、ドライバ不要のクラスコンプライアント対応が主流)。アコースティックピアノの場合は消音ユニット付きモデル(YAMAHAサイレントピアノ等)やDisklavierのようなMIDI出力を持つ機種に限られる。

MIDIファイルローダー

.mid をドラッグ&ドロップ → パース → NoteEvent[] 変換。トラックやノート番号の中央値から左手/右手を推定すれば、手持ちのMIDIが全部この画面で再生できるようになる。

楽譜スキャン取り込み

前述のOMR(Audiveris → MusicXML → 変換器)ルート。練習中の曲を取り込んで、運指は自分の先生の指示どおりに上書きする、という使い方が見える。

ハマりどころ

  • Nuxt devサーバーのSSRモジュールキャッシュ固着: ページを開いたままファイルを編集していたら、Vite側(クライアント)は新コードなのにSSR側が古いコードを返し続け、hydration mismatchで500になった。devサーバー再起動で解消。HMRを信用しすぎないこと
  • 検証中の「再生が進まない」誤検知: DevTools経由で再生位置を計測していたら半速〜停止に見えたが、正体は同じページを人間が同時に操作していた(再生/一時停止ボタンの取り合い)。アプリのバグではなかった。ブラウザ自動化で検証するときは、操作主体が他にいないか先に確認すべし

まとめ

  • Synthesia風ビジュアライザーの本体は「座標式1本(時間→y座標)と音声時計のアンカー1本」で、思ったより小さく作れる
  • 音源ファイルなしでも、倍音3本+減衰エンベロープ+コンボリューションリバーブで「それらしいピアノ」になる
  • 曲データを純粋データとして持つと、テストで楽譜起こしのミスを機械的に検出できる
  • 楽譜データの世界にはMIDI / MusicXML / IMSLP / OMRという既存資産があり、手書き起こしからの卒業ルートも整っている