2026-08-19 曲データ(.mid)をTone.js用のJSONへ変換する(Issue #80)
- 課題: Webで曲を鳴らすために、DAWで書き出した
.midをフロントエンドが読める形にする必要があった。書き出された.midはそのまま鳴らせる状態ではなく、演奏に関係しないイベントや、DAW上では鳴っていなかったノートを含んでいた。どれを残すかを決めないと、実装ごとに解釈が変わってしまう。
前提: DAWの書き出しに何が入るか(曲によらない)
- 1クリップが1トラックとして出てくる。 楽器ごとに1本ではないため、そのまま読むと同じ楽器がトラックとして分かれたままになる。
- 楽器名は
instrumentNameメタに入る。@tonejs/midiはこれを読まず、トラック名(多くはUntitled)しか見えない。 - Program Change と Control Change が混ざる。 音源固有の設定で、Web側で音色を作る以上は意味を持たない。
- リージョンで切られて鳴っていなかった部分も、クリップの中身として出ることがある。 DAWでは聞こえていた音と一致しない。
以下の内容は最初に収録した曲のもので、曲ごとに変わる。 判断の根拠として残す。
最初の曲「Breeze Zero」(assets/music/karawapo-breeze-zero.mid)を調べた結果
| 項目 | 内容 |
|---|---|
| テンポ・拍子・分解能 | 134 BPM / 4/4 / 480 ppq、変化なし |
| トラック数 | 31。うち5つはメタ情報のみでノートを持たない |
| 実体 | 残り26トラックはクリップ。4小節のクリップを7回並べた28小節 |
| 楽器 | e piano / lead / bass / 808 の4つ |
| Program Change | 0件 |
| Control Change | CC1(モジュレーション)23件のみ。値は0か127だけ |
808 の音高 | GM のドラムマップ(36 バスドラム、40 スネア、42/46 ハイハットなど) |
決定
1. ブラウザで .mid を読まず、あらかじめJSONへ変換する
@tonejs/midi をフロントエンドに載せて .mid を直接読む案は採らない。
- 上記のとおり
.midにはそのまま鳴らすと音楽にならない部分が入りうる。実行時に毎回これを判断させることになる。 - クリップ単位のトラックを楽器単位へまとめる処理も、実行時にやる必要がない。
- 変換は決定的なので、生成物をコミットすれば結果をレビューでき、再生側は素直に読むだけで済む。
2. 時間は Tone.js の bars:beats:sixteenths で持ち、テンポとループ長を明示する
- 秒で持つとテンポを変えたときに位置関係が崩れる。tick で持つと再生側が変換を書くことになる。
bpmtimeSignatureppqlengthBarsloopBarsloopをルートに置き、.midに暗黙で入っていた情報をJSONの側で読み取れるようにする。
3. 元データは assets/music/、生成物は public/music/ に置く
public/はNext.jsが配信する場所で、手で編集しない生成物の置き場所として扱う。.midは再生時に不要な編集用の元データなので、配信対象と混ぜない。tests/の下にも置かない(テストの付属物ではなく、素材そのものであるため)。
4. ノート以外のイベントは取り込まない
Program Change と Control Change は取り込まず、何をどれだけ捨てたかを変換時にコンソールへ出す。 黙って落とすと、次の曲で意味のあるCCが入っていたときに気づけない。
Breeze Zero では Program Change が0件、CC はモジュレーションが23件(値は0/127)だけだった。
5. クリップからはみ出したノートを削れるようにする(--trim-clip-overlap)
クリップの長さを越えて鳴り出すノートを削る。最後のクリップにもはみ出した分が入りうるため、そこもクリップ長から算出して削る。
- 既定では削らない。 ノートを消す処理なので、書き出しを確認したうえで明示的に指定する。
- 意図的に次のクリップへ音を伸ばしている曲もありうる。削る前に、その重なりが音楽的に必要かを制作者に確認する。
- Breeze Zero では、はみ出した音が書き出しの副産物なのか意図した響きなのかを、データだけからは判断できなかった。制作者に聞いて初めて「削ってよい」と確定した。この確認は手順の一部であり、省略できない。
Breeze Zero では63音を削った。削らないとクリップの境目6か所で前後の和音が重なって濁る。削った結果、素材はちょうど28小節になりループが自然に閉じた。
6. タイミングは既定で量子化しない
元の演奏を変えないことを既定とする。 --quantize は用意するが、グリッドから外れている音が「制作者の意図したずれ」なのか「書き出しの副産物」なのかを見分けてから使う。
Breeze Zero では量子化しない。余りを削ったあと
ePianobassleadは量子化なしで16分グリッドに完全に乗り、当初「人間的な揺れ」に見えていたものは、すべてクリップからはみ出した余りだった。 グリッド外に残るのはdrumsの35音だけで、これはキット側の意図的なずれ。量子化すると最大42ms動いてグルーヴが変わる。
7. パーカッションは音程ではなく打楽器として持つ
GMドラムマップで書かれたトラックには isPercussion と drumMap(音名 → 打楽器名)を持たせる。これがないと再生側がドラムを音程として鳴らしてしまう。
- チャンネル10ではなくチャンネル1で書かれていると自動判別できないため、変換時に
--percussionで指定する。 - 指定漏れに気づけるよう、全音高がコアキット(35-51)に収まるトラックがあれば警告を出す。GM全域(35-81)はメロディの音域と重なるため、判定には使わない。
Breeze Zero では
808が該当する(論理名drums、8音色)。
8. 作品名・制作者・ライセンスをJSONに持たせる
CC BY 4.0 は帰属表示が条件で、再生する画面に出す必要がある。 これを再生側のコードへ直接書くと素材と離れて食い違うため、title artist license を生成物のJSONに入れ、画面はそこから読む。指定がないときに既定値を入れることはしない(誤ったライセンス表記を作らないため)。
9. 変換のたびにMIDIと突き合わせて検証する
JSONの時間表記をtickへ戻し、元のノート集合と1対1で一致することを毎回確認する。 削る指定をしたときは、削ったあとの集合と一致することを確認する。耳での確認は scripts/midi/preview.html(Tone.js)と WAV 書き出しで行う。
次の曲を追加するときに確認すること
npx tsx scripts/midi/inspect-midi.ts <入力>で、トラック構成・楽器名・Program Change / Control Change を見る。- クリップの長さを越えるノートがないか。あれば制作者に意図を確認してから
--trim-clip-overlapを使う。 - グリッドから外れた音が意図的なずれかどうか。判断がつかないうちは量子化しない。
- ドラムトラックがあれば
--percussionを指定する。 --title--artist--licenseを必ず渡す(CC BY 4.0 の帰属表示のため)。- 素材のディレクトリの
CREDITS.mdに、収録前の確認項目を含めて記録する。
文書化した場所
scripts/midi/README.md: 使い方、出力するJSONの形、Breeze Zero 固有の事情assets/music/CREDITS.md: 作品のクレジットと、音源を収録する前の確認項目public/music/CREDITS.md: 生成物が元データと同じクレジット・ライセンスであること
含めなかったもの
- 再生UIそのもの(どの画面でいつ鳴らすか、音量や停止の扱い)
- Web側の音色の作り込み。
preview.htmlと WAV 書き出しの音は確認用で、本番の音源ではない - 音声ファイル(WAV/MP3)の収録。録音を収録する場合は原盤権が発生するため、CREDITS への記録項目が増える
- テンポ変化・拍子変化のある曲。現在の変換は変化があると止まる。必要になった時点で対応する
toneをdependenciesに入れること。現時点の用途は変換結果の確認だけなので、再生を実装する時点まで devDependency のままにする。