Skip to content

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 Change0件
Control ChangeCC1(モジュレーション)23件のみ。値は0か127だけ
808 の音高GM のドラムマップ(36 バスドラム、40 スネア、42/46 ハイハットなど)

決定

1. ブラウザで .mid を読まず、あらかじめJSONへ変換する

@tonejs/midi をフロントエンドに載せて .mid を直接読む案は採らない。

  • 上記のとおり .mid にはそのまま鳴らすと音楽にならない部分が入りうる。実行時に毎回これを判断させることになる。
  • クリップ単位のトラックを楽器単位へまとめる処理も、実行時にやる必要がない。
  • 変換は決定的なので、生成物をコミットすれば結果をレビューでき、再生側は素直に読むだけで済む。

2. 時間は Tone.js の bars:beats:sixteenths で持ち、テンポとループ長を明示する

  • 秒で持つとテンポを変えたときに位置関係が崩れる。tick で持つと再生側が変換を書くことになる。
  • bpm timeSignature ppq lengthBars loopBars loop をルートに置き、.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 では量子化しない。余りを削ったあと ePiano bass lead は量子化なしで16分グリッドに完全に乗り、当初「人間的な揺れ」に見えていたものは、すべてクリップからはみ出した余りだった。 グリッド外に残るのは drums の35音だけで、これはキット側の意図的なずれ。量子化すると最大42ms動いてグルーヴが変わる。

7. パーカッションは音程ではなく打楽器として持つ

GMドラムマップで書かれたトラックには isPercussiondrumMap(音名 → 打楽器名)を持たせる。これがないと再生側がドラムを音程として鳴らしてしまう。

  • チャンネル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 書き出しで行う。

次の曲を追加するときに確認すること

  1. npx tsx scripts/midi/inspect-midi.ts <入力> で、トラック構成・楽器名・Program Change / Control Change を見る。
  2. クリップの長さを越えるノートがないか。あれば制作者に意図を確認してから --trim-clip-overlap を使う。
  3. グリッドから外れた音が意図的なずれかどうか。判断がつかないうちは量子化しない。
  4. ドラムトラックがあれば --percussion を指定する。
  5. --title --artist --license を必ず渡す(CC BY 4.0 の帰属表示のため)。
  6. 素材のディレクトリの 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 への記録項目が増える
  • テンポ変化・拍子変化のある曲。現在の変換は変化があると止まる。必要になった時点で対応する
  • tonedependencies に入れること。現時点の用途は変換結果の確認だけなので、再生を実装する時点まで devDependency のままにする。

秘密情報(SESSION_PASSWORD、Supabaseキー、Safeの認証情報)はドキュメント・Issue・ログへ貼らないでください。