コード進行アプリにボイシングのプリセットを10種類足した(Drop 2・シェル・4度堆積など。既定の音は1バイトも変えない)
- Chord Eureka
- JavaScript
- 音楽理論
- Claude Code
Chord Eureka は、AI がコード進行を作って、その場で鳴らせる Web アプリです。作った進行は MIDI で書き出して、DAW に取り込めます。
鳴らすときの ボイシング(和音の音をどの高さに、どう並べるか)は、ずっと1種類だけでした。これに、Drop 2 やシェルなどのプリセットを10種類足しました。実装は Claude Code に任せています。この記事では、その経緯と組み立て方をまとめます(リポジトリは非公開なので、コードは説明用に書き直したものです)。
環境:Node.js(Express)/ tonal(コード解析)/ Jest / Claude Code(Opus)/ 2026年9月
既定の音を変えて、戻した
もともとのボイシングは、ルートをオクターブ3に置いて、構成音を下から 5音 積むものです。三和音なら、オクターブ上のルートと3度を重ねて5音にします。
C → C3 E3 G3 C4 E4
Cmaj7 → C3 E3 G3 B3 C4
これだと、C13 のように6音以上あるコードでは、上のテンション(13th)が落ちます。レビューでこれを指摘されて、一度 構成音をすべて鳴らす 形に変えました。
ところが、翌日に元に戻しています。理由は、このアプリを使ってくれている人がいるからです。
- 聴き慣れた響きが、ある日突然変わる
- DAW に取り込んだときの音の数が変わる
- 「直した」つもりでも、利用者から見れば「音が変わった」
そこで方針を変えて、既定の音はそのままにして、ほかの鳴らし方を選べるようにする ことにしました。CLAUDE.md にも「既定のボイシングは、既存の利用者の音と DAW への取り込みを保つために固定。オーナーの了承なしに変えない」と書いています。
既定は1バイトも変えない
プリセットを足すと、ボイシングの処理を通る経路が変わります。そこで、既定(クラシック)を選んだときは 今までとまったく同じ MIDI ファイル が出ることを確かめました。
- 過去に評価用に作った 930件の進行 で、追加前と追加後の MIDI のバイト列が一致することを確認
- テストでも、「既定を指定したとき」と「何も指定しないとき」の MIDI が同じ文字列になることを確かめる
test('既定(classic・mid)は省略したときと同じ音・同じMIDI', () => {
const options = { voicing: 'classic', register: 'mid' };
expect(AnalyzeChord(PROGRESSION, options).dataUri()).toBe(AnalyzeChord(PROGRESSION).dataUri());
});
プリセットを選ばない限り、利用者の音は変わりません。
選べるようにしたもの
画面では、再生バーの横にボイシングと音域の選択を置きました。選んだものは端末に保存して、共有 URL にも &v=(ボイシング)と &r=(音域)として載せます。
| グループ | プリセット | 中身 |
|---|---|---|
| — | クラシック(既定) | オクターブ3から5音積み(今までどおり) |
| クローズ | 密集 | ルートを一番下にして、1オクターブ内に詰める |
| スムーズ | 前の和音から、動きが一番小さい転回形を選ぶ | |
| ベース+上声 | ベースはルート、上声からはルートを抜く | |
| オープン | Drop 2 / Drop 3 / Drop 2&4 | 密集配置の上から2番目(3番目、2・4番目)の音を1オクターブ下げる |
| スプレッド | 左手はルートと7度(なければ5度)、右手は残りの音 | |
| ジャズ | シェル | ルートと5度を省き、3度・7度・テンションで弾く |
| ルートレス | ルートを抜いた構成音を、前の和音から近い形で置く | |
| 4度堆積 | 音の間隔ができるだけ完全4度になる順に積む |
音域は「低め・標準・高め」の3つで、全体を1オクターブずつ上下させます。
クラシック以外のプリセットは、どれも ベースをオクターブ2のルート(スラッシュコードなら指定した音)に置き、その上に上声を積みます。
最初は「スムーズ・オープン・ベース+上声」の3つだけでした。試聴してから「もっと種類がほしい」と頼んで、10分後のコミットで今の10種類に増えています。
決まり:書かれていない音は足さない
ジャズのボイシングでは、maj7 に 9th を足すように、コード名にない音を加えることがよくあります。でも、このアプリではそれをしないことにしました。
- 足さない:コード名に書かれていない音は鳴らさない
- 削らない:テンションは落とさない。
Em7b9をEm7として鳴らすと、別のコードになってしまう - 省いてよいのは2つだけ:ルートと完全5度。どちらも響きを大きく変えずに省ける音です。6音以上のコードでは、完全5度を省きます
クラシックは5音までなので、C13 の 13th が落ちます。プリセットではこの決まりのおかげで、13th まで鳴ります。
この決まりは、テストで全プリセットに対して確かめています。
test.each(PRESETS)('%s: 音は足さず、省くのはルート・完全5度だけ', (voicing) => {
// 構成音にない音が鳴っていないか、省いてはいけない音が欠けていないかを、コードごとに確かめる
});
ほかにも、どのプリセットでも鳴る和音の数が同じか、ベースが一番低い音になっているか、画面の選択肢とサーバーのプリセット一覧が一致しているかをテストしています。
組み立て方
Drop 系
Drop 2 は、密集配置の 上から2番目の音を1オクターブ下げる ボイシングです。ギターやピアノでよく使われる、広がりのある響きになります。
// 説明用。上から数えて positions 番目(1始まり)の音を1オクターブ下げる
function drop(stacked, positions) {
return stacked
.map((n, i) => (positions.includes(stacked.length - i) ? n - 12 : n))
.sort((a, b) => a - b);
}
// Cmaj7 の密集配置 C4 E4 G4 B4 → 上から2番目の G4 を下げる → G3 C4 E4 B4
Drop 系は4声が前提なので、三和音はルートをオクターブ上に重ねて4声にしてから下げます。
スムーズ(最小移動)
和音の 転回形をすべて作り、前の和音から一番動きが小さいもの を選びます。動きの大きさは、各音から相手の和音の一番近い音までの距離を、両方向に足したものです。
// 説明用。from の各音から to の一番近い音までの距離と、その逆を足す
function movement(from, to) {
const nearest = (notes, note) => Math.min(...notes.map((n) => Math.abs(n - note)));
return to.reduce((sum, n) => sum + nearest(from, n), 0)
+ from.reduce((sum, n) => sum + nearest(to, n), 0);
}
最初の和音には前がないので、上声の平均が D4 に近い形を選びます。テストでは、Cmaj7 → Am7 で共通音が3つ以上そのまま残ることを確かめています。
シェルとルートレスも、省く音を決めたあとは同じ選び方をしています。
4度堆積
構成音(4音以上ならルートを抜く)を、隣り合う音の間隔ができるだけ完全4度(半音5つ)になる順 に並べます。どの音から始めるかを全部試して、完全4度からのずれが一番小さい並びを選びます。
// 説明用。start から始めて、次の音は「上に半音5つ」に一番近いものを選んでいく
for (const start of pitchClasses) {
const order = [start];
let cost = 0;
while (order.length < pitchClasses.length) {
const up = (pc) => (pc - order.at(-1) + 12) % 12;
const next = remaining(order).reduce((a, b) => (Math.abs(up(b) - 5) < Math.abs(up(a) - 5) ? b : a));
cost += Math.abs(up(next) - 5);
order.push(next);
}
// cost が一番小さい order を使う
}
Dm7 なら、ルートを抜いた F・A・C を C → F → A の順に積みます。C から F は完全4度、F から A は長3度で、ずれは合計1です。完全4度だけで積めないコードでも、一番近い並びになります。
スプレッド(両手)
左手はベースのルートと、7度(なければ5度)。右手は残りの音を C4 より上に詰めます。
左手の2音目は、低すぎると濁ります。C3 より下になったら1オクターブ上げるようにしました。
試聴して直したところ
実装が終わった時点で、Claude は気になりそうな点を PROGRESS.md に書き残していました。
- オープンとベース+上声は、ルートが G〜G# のときに上声が高めになる(上声の一番下を A3〜G#4 に置くため)
- スムーズは、平行移動が続くと低い音域で密集して響きが濁ることがある
音の置き場所は voicing.js の先頭の定数(上声の一番下の範囲など)で調整できるようにしてあります。試聴して、このままで良しとしました。
試聴のあと、上の「足さない」決まりを PROGRESS.md の決定事項に移しました。ジャズ流儀のテンションを足すボイシングがほしくなったら、別のプリセットとして足すことにしています。
結果
- プリセットは10種類 + 音域3段階。最初のコミットで353行、拡充のコミットで
voicing.jsは188行になりました - 既定の音は、930件の進行で MIDI が1バイトも変わらないことを確かめた
- どのプリセットでも、鳴る和音の数とベースの位置は同じ。音は足さず、省くのはルートと完全5度だけ
楽譜の表示も、この直後に自作に置き換えています。どのボイシングを選んでも、楽譜の音と鳴る音が一致するようにしました(楽譜表示を自作した話)。
まとめ
- 既定の音を変えると、利用者には「音が変わった」としか見えない。変えずに、選べるようにした
- 既定を選んだときの MIDI がバイト単位で同じことを、930件の進行とテストで確かめた
- 「書かれていない音は足さない。省くのはルートと完全5度だけ」という決まりを、全プリセットのテストで守らせた
- Drop 系は「上から n 番目を1オクターブ下げる」、スムーズは「動きの合計が最小の転回形」、4度堆積は「間隔が完全4度に近い並び」で組み立てた
参考文献
この記事をシェア