Files
chordlab/files (1)/index.html
T
2026-07-16 17:36:44 +02:00

1324 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>_chordlab v2 — générateur d'accords</title>
<style>
:root{
--panel:#E0D6C3;
--panel-edge:#C9BCA4;
--module:#26231D;
--module-2:#332F27;
--silk:#403A2F;
--silk-light:#D8CFBB;
--lcd-bg:#141A12;
--lcd-amber:#FFB454;
--lcd-dim:#7A6A3F;
--led-on:#FFAE33;
--led-off:#4A4436;
--led-red:#E5482B;
--led-green:#8BC34A;
--key-w:#EFE9DB;
--key-b:#1C1A16;
--accent:#E5482B;
--mono:ui-monospace,"SF Mono","Cascadia Mono","JetBrains Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html{font-size:15px}
body{
font-family:var(--mono);
background:#17150F;
color:var(--silk);
min-height:100vh;
display:flex;justify-content:center;
padding:1.2rem;
}
.unit{
width:100%;max-width:1120px;
background:linear-gradient(178deg,var(--panel) 0%,#D6CBB5 100%);
border-radius:14px;
border:1px solid var(--panel-edge);
box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.5);
padding:1.4rem 1.6rem 1.6rem;
}
header{
display:flex;align-items:baseline;justify-content:space-between;
border-bottom:2px solid var(--silk);
padding-bottom:.6rem;margin-bottom:1rem;
}
header h1{font-size:1.15rem;font-weight:800;letter-spacing:.06em}
header h1 span{color:var(--accent)}
header .sub{font-size:.62rem;letter-spacing:.18em;text-transform:uppercase;opacity:.7}
/* ---------- LCD ---------- */
.lcd{
background:var(--lcd-bg);
border-radius:8px;
border:3px solid #0B0E0A;
box-shadow:inset 0 0 24px rgba(0,0,0,.8), 0 1px 0 rgba(255,255,255,.35);
padding:.8rem 1.1rem;
display:grid;grid-template-columns:1fr auto;gap:.4rem 1rem;align-items:center;
margin-bottom:1.1rem;
min-height:92px;
}
.lcd .chord-name{
font-size:2rem;font-weight:700;color:var(--lcd-amber);
text-shadow:0 0 12px rgba(255,180,84,.45);
line-height:1.1;min-height:2.3rem;
}
.lcd .chord-notes{font-size:.78rem;color:var(--lcd-dim);letter-spacing:.1em}
.lcd .lcd-right{text-align:right;font-size:.64rem;color:var(--lcd-dim);line-height:1.65}
.lcd .lcd-right b{color:var(--lcd-amber);font-weight:600}
/* ---------- Rangée principale ---------- */
.deck{
display:grid;
grid-template-columns:290px 1fr 210px;
gap:1rem;
margin-bottom:1.1rem;
}
.mod{
background:var(--module);
border-radius:10px;
padding:.8rem;
box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 2px 6px rgba(0,0,0,.25);
}
.mod h2{
font-size:.58rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--silk-light);opacity:.65;margin-bottom:.6rem;
}
.btn-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.45rem}
.hwbtn{
background:var(--module-2);
border:1px solid #4A4436;
border-radius:7px;
color:var(--silk-light);
font-family:var(--mono);
font-size:.68rem;font-weight:600;
padding:.6rem 0 .45rem;
cursor:pointer;
display:flex;flex-direction:column;align-items:center;gap:.32rem;
transition:transform .05s;
}
.hwbtn:active{transform:translateY(1px)}
.hwbtn .led{
width:7px;height:7px;border-radius:50%;
background:var(--led-off);
box-shadow:inset 0 1px 2px rgba(0,0,0,.6);
}
.hwbtn.on .led{
background:var(--led-on);
box-shadow:0 0 8px var(--led-on), 0 0 2px var(--led-on);
}
.hwbtn.on.green .led{background:var(--led-green);box-shadow:0 0 8px var(--led-green)}
.hwbtn:focus-visible{outline:2px solid var(--led-on);outline-offset:2px}
.row-label{font-size:.55rem;letter-spacing:.16em;color:var(--silk-light);opacity:.45;margin:.55rem 0 .3rem;text-transform:uppercase}
/* ---------- Clavier ---------- */
.kbd-mod{display:flex;flex-direction:column}
.kbd{position:relative;flex:1;min-height:180px;display:flex;border-radius:6px;overflow:hidden}
.wkey{
flex:1;background:var(--key-w);
border:1px solid #B9AE97;border-top:none;
cursor:pointer;position:relative;
display:flex;align-items:flex-end;justify-content:center;
padding-bottom:.4rem;font-size:.6rem;color:#8A8070;
}
.wkey:active,.wkey.pressed{background:#D9CFBA}
.wkey.outkey{background:#DDD6C8;color:#B5AC98}
.bkey{
position:absolute;top:0;width:9%;height:58%;
background:var(--key-b);
border-radius:0 0 4px 4px;
cursor:pointer;z-index:2;
box-shadow:0 3px 5px rgba(0,0,0,.4);
}
.bkey:active,.bkey.pressed{background:#3A362C}
.bkey.outkey{background:#4B4739}
.kbd-hint{font-size:.55rem;color:var(--silk-light);opacity:.4;margin-top:.5rem;letter-spacing:.08em}
/* ---------- Colonne voicing ---------- */
.side{display:flex;flex-direction:column;gap:.65rem}
.knob-wrap{display:flex;flex-direction:column;align-items:center;gap:.25rem}
.knob{width:68px;height:68px;cursor:ns-resize;touch-action:none}
.knob circle.body{fill:#3A362C;stroke:#4A4436;stroke-width:2}
.knob line.ind{stroke:var(--led-on);stroke-width:3;stroke-linecap:round}
.knob-val{font-size:.72rem;color:var(--silk-light)}
.mini-row{display:flex;gap:.35rem;align-items:center;justify-content:center}
.mini-btn{
background:var(--module-2);border:1px solid #4A4436;border-radius:5px;
color:var(--silk-light);font-family:var(--mono);font-size:.68rem;
padding:.25rem .5rem;cursor:pointer;
}
.mini-btn:active{transform:translateY(1px)}
select,input[type=number]{
background:var(--module-2);border:1px solid #4A4436;border-radius:5px;
color:var(--silk-light);font-family:var(--mono);font-size:.68rem;
padding:.28rem .3rem;width:100%;
}
.toggle-line{display:flex;align-items:center;gap:.4rem;font-size:.62rem;color:var(--silk-light)}
.toggle-line .hwbtn{flex-direction:row;padding:.38rem .55rem;gap:.35rem}
.field{display:grid;grid-template-columns:auto 1fr;gap:.4rem;align-items:center;font-size:.6rem;color:var(--silk-light)}
.field label{opacity:.55;white-space:nowrap}
/* ---------- Progression ---------- */
.prog-mod{margin-bottom:1.1rem}
.prog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem;flex-wrap:wrap;gap:.4rem}
.prog-slots{display:flex;gap:.5rem;flex-wrap:wrap;min-height:70px}
.slot{
background:var(--module-2);
border:1px solid #4A4436;border-radius:7px;
padding:.45rem .6rem .35rem;min-width:104px;
color:var(--silk-light);cursor:pointer;position:relative;
font-size:.72rem;
}
.slot.playing{border-color:var(--led-on);box-shadow:0 0 10px rgba(255,174,51,.35)}
.slot .sname{font-weight:700;color:var(--lcd-amber);font-size:.85rem;padding-right:.9rem}
.slot .snotes{opacity:.55;font-size:.56rem;margin-top:.15rem}
.slot .srow{display:flex;gap:.3rem;margin-top:.3rem;align-items:center}
.slot .badge{
background:#1E1B15;border:1px solid #4A4436;border-radius:4px;
font-size:.55rem;padding:.1rem .3rem;cursor:pointer;color:var(--silk-light);
}
.slot .mv{background:none;border:none;color:var(--silk-light);opacity:.4;font-family:var(--mono);font-size:.7rem;cursor:pointer;padding:0 .15rem}
.slot .mv:hover{opacity:1}
.slot .del{
position:absolute;top:2px;right:5px;color:var(--silk-light);
opacity:.4;font-size:.7rem;cursor:pointer;background:none;border:none;font-family:var(--mono);
}
.slot .del:hover{opacity:1;color:var(--led-red)}
.slot-empty{
border:1px dashed #4A4436;border-radius:7px;min-width:104px;
display:flex;align-items:center;justify-content:center;
color:var(--silk-light);opacity:.35;font-size:.62rem;padding:.45rem .6rem;
}
.transport{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.7rem}
.transport .hwbtn{flex-direction:row;padding:.45rem .7rem;gap:.4rem}
.transport .hwbtn.playing .led{background:var(--led-red);box-shadow:0 0 8px var(--led-red)}
.bpm-wrap{display:flex;align-items:center;gap:.35rem;color:var(--silk-light);font-size:.65rem}
.bpm-wrap input{width:60px}
.bpm-wrap select{width:auto}
.preset-wrap{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap}
.preset-wrap select{width:auto}
/* ---------- IO ---------- */
.io{display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem}
.io-line{display:grid;grid-template-columns:auto 1fr;gap:.5rem;align-items:center;font-size:.62rem;color:var(--silk-light);margin-bottom:.4rem}
.io-line label{opacity:.6;white-space:nowrap}
.status{font-size:.58rem;color:var(--silk-light);opacity:.55;margin-top:.35rem;min-height:1em}
.export-btn{
width:100%;background:var(--accent);border:none;border-radius:7px;
color:#FFF3E4;font-family:var(--mono);font-weight:700;font-size:.76rem;
padding:.6rem;cursor:pointer;letter-spacing:.05em;margin-top:.25rem;
}
.export-btn:active{transform:translateY(1px)}
.ghost-btn{
width:100%;background:var(--module-2);border:1px solid #4A4436;border-radius:7px;
color:var(--silk-light);font-family:var(--mono);font-size:.68rem;
padding:.5rem;cursor:pointer;margin-top:.25rem;
}
footer{margin-top:1rem;font-size:.55rem;opacity:.5;letter-spacing:.1em;text-align:right}
@media (max-width:900px){
.deck{grid-template-columns:1fr}
.io{grid-template-columns:1fr}
.kbd{min-height:140px}
}
@media (prefers-reduced-motion:reduce){
*{transition:none !important}
}
</style>
</head>
<body>
<div class="unit">
<header>
<h1>_chordlab <span>ORC·v2</span></h1>
<div class="sub">chord ideas machine · underscorelab / leradiologue</div>
</header>
<!-- LCD -->
<div class="lcd" role="status" aria-live="polite">
<div>
<div class="chord-name" id="lcdName"></div>
<div class="chord-notes" id="lcdNotes">joue une note</div>
</div>
<div class="lcd-right">
<div>VOICING <b id="lcdVoicing">0</b> · <b id="lcdDrop">CLOSE</b></div>
<div>KEY <b id="lcdKey">OFF</b> · PERF <b id="lcdPerf">BLOCK</b> · B <b id="lcdBassPerf">TENUE</b></div>
<div>MIDI <b id="lcdMidi"></b> · VL <b id="lcdVl">OFF</b></div>
</div>
</div>
<!-- Rangée principale -->
<div class="deck">
<!-- Types + tensions + altérations -->
<div class="mod">
<h2>Chord type</h2>
<div class="btn-grid" id="typeBtns">
<button class="hwbtn" data-type="dim"><span class="led"></span>DIM</button>
<button class="hwbtn" data-type="min"><span class="led"></span>MIN</button>
<button class="hwbtn on" data-type="maj"><span class="led"></span>MAJ</button>
<button class="hwbtn" data-type="aug"><span class="led"></span>AUG</button>
<button class="hwbtn" data-type="sus2"><span class="led"></span>SUS2</button>
<button class="hwbtn" data-type="sus4"><span class="led"></span>SUS4</button>
<button class="hwbtn" data-type="five"><span class="led"></span>5</button>
<button class="hwbtn" data-type="m6th"><span class="led"></span>m♭6</button>
</div>
<div class="row-label">Tensions</div>
<div class="btn-grid" id="extBtns">
<button class="hwbtn" data-ext="6"><span class="led"></span>6</button>
<button class="hwbtn" data-ext="m7"><span class="led"></span>m7</button>
<button class="hwbtn" data-ext="M7"><span class="led"></span>M7</button>
<button class="hwbtn" data-ext="9"><span class="led"></span>9</button>
<button class="hwbtn" data-ext="b9"><span class="led"></span>♭9</button>
<button class="hwbtn" data-ext="s9"><span class="led"></span>♯9</button>
<button class="hwbtn" data-ext="11"><span class="led"></span>11</button>
<button class="hwbtn" data-ext="s11"><span class="led"></span>♯11</button>
</div>
<div class="row-label">Altérations</div>
<div class="btn-grid" id="altBtns">
<button class="hwbtn" data-ext="13"><span class="led"></span>13</button>
<button class="hwbtn" data-ext="b5"><span class="led"></span>♭5</button>
<button class="hwbtn" data-ext="s5"><span class="led"></span>♯5</button>
<button class="hwbtn" data-ext="no3"><span class="led"></span>no3</button>
</div>
</div>
<!-- Clavier -->
<div class="mod kbd-mod">
<h2>Root</h2>
<div class="kbd" id="kbd"></div>
<div class="kbd-hint">clavier PC : A W S E D F T G Y H U J · ␣ = ADD · ← → = voicing</div>
</div>
<!-- Voicing / drop / slash / bass / key -->
<div class="mod side">
<h2>Voicing</h2>
<div class="knob-wrap">
<svg class="knob" id="voicingKnob" viewBox="0 0 68 68" role="slider" aria-label="Voicing" aria-valuemin="-8" aria-valuemax="8" aria-valuenow="0" tabindex="0">
<circle class="body" cx="34" cy="34" r="30"/>
<line class="ind" id="knobInd" x1="34" y1="34" x2="34" y2="9"/>
</svg>
<div class="knob-val" id="knobVal">0</div>
<div class="mini-row">
<button class="mini-btn" id="voicDown"></button>
<button class="mini-btn" id="voicReset">0</button>
<button class="mini-btn" id="voicUp">+</button>
</div>
</div>
<div class="field"><label>drop</label>
<select id="dropSel">
<option value="close">close</option>
<option value="drop2">drop 2</option>
<option value="drop3">drop 3</option>
<option value="drop24">drop 2+4</option>
<option value="spread">spread</option>
</select>
</div>
<div class="field"><label>basse /</label>
<select id="slashSel">
<option value="root">fondamentale</option>
<option value="third">tierce</option>
<option value="fifth">quinte</option>
<option value="seventh">septième</option>
</select>
</div>
<div class="toggle-line">
<button class="hwbtn" id="bassBtn"><span class="led"></span>BASS</button>
<select id="bassOct">
<option value="-24">2 oct</option>
<option value="-12" selected>1 oct</option>
</select>
</div>
<div class="field"><label>rythme B</label>
<select id="bassPerfSel">
<option value="held">tenue</option>
<option value="quarter">noires</option>
<option value="eighth">croches</option>
<option value="offbeat">contretemps</option>
<option value="octave">octaves</option>
<option value="root5">fond.quinte</option>
<option value="walk">boogie</option>
<option value="tresillo">332</option>
<option value="pump">pompe</option>
<option value="rnd">aléatoire</option>
</select>
</div>
<div class="toggle-line">
<button class="hwbtn" id="keyBtn"><span class="led"></span>KEY</button>
<select id="keyRoot"></select>
</div>
<div class="field"><label>gamme</label>
<select id="keyScaleSel">
<option value="major">majeur</option>
<option value="minor">mineur nat.</option>
<option value="harmMinor">mineur harm.</option>
<option value="dorian">dorien</option>
<option value="phrygian">phrygien</option>
<option value="lydian">lydien</option>
<option value="mixolydian">mixolydien</option>
<option value="locrian">locrien</option>
</select>
</div>
</div>
</div>
<!-- Progression -->
<div class="mod prog-mod">
<div class="prog-head">
<h2 style="margin:0">Progression</h2>
<div class="preset-wrap">
<button class="mini-btn" id="suggestBtn" title="suggère l'accord suivant (harmonie fonctionnelle)">✦ suggérer</button>
<button class="mini-btn" id="optimizeBtn" title="re-voice toute la progression pour minimiser les mouvements">⇄ voice-leading</button>
<select id="presetSel">
<option value="">— preset (tonalité KEY) —</option>
<option value="1,5,6,4">I V vi IV</option>
<option value="2,5,1">ii V I</option>
<option value="1,6,4,5">I vi IV V</option>
<option value="1,4,6,5">I IV vi V</option>
<option value="6,4,1,5">vi IV I V</option>
<option value="1,7,6,7">i VII VI VII</option>
<option value="1,3,7,6">i III VII VI</option>
<option value="1,4,7,3,6,2,5,1">circle of fifths</option>
</select>
<button class="mini-btn" id="presetLoad">charger</button>
<button class="mini-btn" id="progClear">vider</button>
</div>
</div>
<div class="prog-slots" id="progSlots"></div>
<div class="transport">
<button class="hwbtn" id="addBtn"><span class="led"></span>ADD (␣)</button>
<button class="hwbtn" id="playBtn"><span class="led"></span>PLAY</button>
<button class="hwbtn" id="loopBtn"><span class="led"></span>LOOP</button>
<button class="hwbtn" id="vlBtn" title="applique le voice-leading automatiquement à chaque ADD"><span class="led"></span>VL AUTO</button>
<div class="bpm-wrap">BPM <input type="number" id="bpm" value="112" min="30" max="300"></div>
<div class="bpm-wrap">perf
<select id="perfSel">
<option value="block">block</option>
<option value="strumUp">strum ↑</option>
<option value="strumDown">strum ↓</option>
<option value="harp">harp</option>
<option value="arpUp">arp ↑</option>
<option value="arpDown">arp ↓</option>
<option value="arpUpDown">arp ↕</option>
<option value="arpRnd">arp rnd</option>
</select>
</div>
<div class="bpm-wrap">rate
<select id="rateSel">
<option value="0.5">1/8</option>
<option value="0.25" selected>1/16</option>
<option value="0.125">1/32</option>
</select>
</div>
<button class="hwbtn" id="humanBtn" title="humanise vélocité et timing"><span class="led"></span>HUM</button>
</div>
</div>
<!-- IO -->
<div class="io">
<div class="mod">
<h2>MIDI out (live)</h2>
<div class="io-line"><label>périphérique</label><select id="midiDev"><option value="">— désactivé —</option></select></div>
<div class="io-line"><label>canal accords</label><select id="chChord"></select></div>
<div class="io-line"><label>canal basse</label><select id="chBass"></select></div>
<div class="status" id="midiStatus">Web MIDI : en attente…</div>
</div>
<div class="mod">
<h2>Export MIDI</h2>
<div class="io-line"><label>pistes</label><select id="exportSplit">
<option value="split">accords + basse (2 pistes)</option>
<option value="chords">accords seuls</option>
</select></div>
<div class="io-line"><label>perf rendue</label><select id="exportPerf">
<option value="yes">oui (strum/arp dans le .mid)</option>
<option value="block">non (accords plaqués)</option>
</select></div>
<button class="export-btn" id="exportBtn">↓ exporter .mid</button>
<div class="status" id="exportStatus"></div>
</div>
<div class="mod">
<h2>Session</h2>
<button class="ghost-btn" id="saveJson">↓ sauver la session (.json)</button>
<button class="ghost-btn" id="loadJson">↑ charger une session</button>
<input type="file" id="fileInput" accept=".json" style="display:none">
<div class="status" id="sessionStatus"></div>
</div>
</div>
<footer>_chordlab v2.1 · logique d'accords inspirée de l'Orchid ORC-1 (implémentation indépendante étendue)</footer>
</div>
<script>
"use strict";
/* =========================================================
_chordlab v2 — moteur étendu
root + type + tensions + altérations → intervalles
voicing rotatif → drop voicing → slash bass
========================================================= */
const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const BASE = 60; // C4
const state = {
type:"maj",
exts:new Set(),
voicing:0,
drop:"close",
slash:"root",
bass:false,
bassOct:-12,
keyMode:false,
keyRoot:0,
keyScale:"major",
lastRoot:null,
progression:[], // {label, notes[], bass, rootPc, beats}
playing:false, loop:false, playIdx:-1, playTimer:null,
vlAuto:false,
perf:"block", rate:0.25,
bassPerf:"held",
humanize:false,
};
/* ---------- Moteur d'accords ---------- */
const TYPE_INTERVALS = {
dim:[0,3,6], min:[0,3,7], maj:[0,4,7], aug:[0,4,8],
sus2:[0,2,7], sus4:[0,5,7], five:[0,7], m6th:[0,3,8],
};
const TENSIONS = { "6":9, "m7":10, "M7":11, "9":14, "b9":13, "s9":15, "11":17, "s11":18, "13":21 };
function buildChord(rootPc, type, exts){
const set = new Set(TYPE_INTERVALS[type]);
exts.forEach(e=>{ if (e in TENSIONS) set.add(TENSIONS[e]); });
if (exts.has("b5") && set.has(7)){ set.delete(7); set.add(6); }
if (exts.has("s5") && set.has(7)){ set.delete(7); set.add(8); }
if (exts.has("no3")){ set.delete(3); set.delete(4); }
const root = BASE + rootPc;
return [...set].sort((a,b)=>a-b).map(i=>root+i);
}
/* nom pragmatique */
function chordName(rootPc, type, exts){
const e = new Set(exts);
const R = NOTE_NAMES[rootPc];
let base = {maj:"", min:"m", dim:"dim", aug:"aug", sus2:"sus2", sus4:"sus4", five:"5", m6th:"m♭6"}[type];
let name = R + base;
if (e.has("m7") && e.has("M7")) return name + " WTF?";
// cas nommés
if (type==="dim" && e.has("6") && !e.has("m7")){ name = R+"dim7"; e.delete("6"); }
else if (type==="dim" && e.has("m7")){ name = R+"ø7"; e.delete("m7"); }
else if (e.has("m7")){ name += "7"; e.delete("m7"); }
else if (e.has("M7")){ name += "maj7"; e.delete("M7"); }
if (e.has("6")){ name += "6"; e.delete("6"); }
const has7 = /7/.test(name);
const tens = [], direct = [];
if (e.has("9")) has7 ? tens.push("9") : direct.push("add9");
if (e.has("b9")) tens.push("♭9");
if (e.has("s9")) tens.push("♯9");
if (e.has("11")) has7 ? tens.push("11") : direct.push("add11");
if (e.has("s11"))tens.push("♯11");
if (e.has("13")) has7 ? tens.push("13") : direct.push("add13");
if (e.has("b5")) tens.push("♭5");
if (e.has("s5")) tens.push("♯5");
if (e.has("no3"))tens.push("no3");
name += direct.join("");
if (tens.length) name += "(" + tens.join(",") + ")";
return name;
}
/* Voicing = rotation */
function applyVoicing(notes, steps){
let n = [...notes].sort((a,b)=>a-b);
for (let i=0;i<Math.abs(steps);i++){
if (steps>0){ n[0]+=12; } else { n[n.length-1]-=12; }
n.sort((a,b)=>a-b);
}
return n;
}
/* Drop voicings, appliqués après rotation */
function applyDrop(notes, mode){
const n = [...notes].sort((a,b)=>a-b);
const L = n.length;
if (mode==="drop2" && L>=3) n[L-2]-=12;
else if (mode==="drop3" && L>=4) n[L-3]-=12;
else if (mode==="drop24" && L>=4){ n[L-2]-=12; n[L-4]-=12; }
else if (mode==="spread"){ for(let i=1;i<L;i+=2) n[i]+=12; }
return n.sort((a,b)=>a-b);
}
/* Slash bass : choisit la note de basse parmi les tons de l'accord */
function slashInterval(rootPc, type, exts, which){
const rel = buildChord(rootPc, type, exts).map(n=>n-(BASE+rootPc));
const pickFrom = arr => arr.find(i=>rel.includes(i));
if (which==="third") return pickFrom([4,3,5,2]) ?? 0;
if (which==="fifth") return pickFrom([7,6,8]) ?? 0;
if (which==="seventh") return pickFrom([10,11,9]) ?? 0;
return 0;
}
function bassNoteFor(rootPc, type, exts){
const iv = slashInterval(rootPc, type, exts, state.slash);
return {midi: BASE + rootPc + iv + state.bassOct, iv};
}
/* ---------- Key Mode diatonique (8 gammes) ---------- */
const SCALES = {
major:[0,2,4,5,7,9,11], minor:[0,2,3,5,7,8,10],
harmMinor:[0,2,3,5,7,8,11],
dorian:[0,2,3,5,7,9,10], phrygian:[0,1,3,5,7,8,10],
lydian:[0,2,4,6,7,9,11], mixolydian:[0,2,4,5,7,9,10],
locrian:[0,1,3,5,6,8,10],
};
function diatonicChord(notePc, keyRoot, scaleName, exts){
const scale = SCALES[scaleName];
const rel = (notePc - keyRoot + 12) % 12;
const deg = scale.indexOf(rel);
if (deg === -1) return null;
const pick = j => scale[(deg+j)%7] + 12*Math.floor((deg+j)/7);
const semis = [pick(0), pick(2), pick(4)];
if (exts.has("6")) semis.push(pick(5));
if (exts.has("m7") || exts.has("M7")) semis.push(pick(6));
if (exts.has("9")) semis.push(pick(8));
if (exts.has("11")) semis.push(pick(10));
if (exts.has("13")) semis.push(pick(12));
const root = BASE + notePc;
const notes = semis.map(s => BASE + keyRoot + s);
const shift = root - notes[0];
let final = notes.map(n => n + shift);
if (exts.has("no3")) final = final.filter((n,i)=>i!==1);
final.sort((a,b)=>a-b);
const i1 = final[1]-final[0], i2 = final.length>2 ? final[2]-final[0] : 7;
let q = "";
if (i1===4 && i2===7) q=""; else if (i1===3 && i2===7) q="m";
else if (i1===3 && i2===6) q="dim"; else if (i1===4 && i2===8) q="aug";
let name = NOTE_NAMES[notePc] + q;
if (exts.has("m7")||exts.has("M7")) name += "7*";
if (exts.has("6")) name += "6*";
if (exts.has("9")) name += "(9)*";
if (exts.has("11")) name += "(11)*";
if (exts.has("13")) name += "(13)*";
return {notes: final, name, deg};
}
/* ---------- Accord courant ---------- */
function currentChord(rootPc){
let raw, name;
if (state.keyMode){
const d = diatonicChord(rootPc, state.keyRoot, state.keyScale, state.exts);
if (!d) return null;
raw = d.notes; name = d.name;
} else {
raw = buildChord(rootPc, state.type, state.exts);
name = chordName(rootPc, state.type, state.exts);
}
let notes = applyDrop(applyVoicing(raw, state.voicing), state.drop);
// slash dans le nom
let bass = null;
if (state.bass){
const b = state.keyMode
? {midi: BASE + rootPc + state.bassOct, iv:0}
: bassNoteFor(rootPc, state.type, state.exts);
bass = b.midi;
if (b.iv !== 0) name += "/" + NOTE_NAMES[(rootPc + b.iv) % 12];
}
return {name, notes, bass, rootPc};
}
/* ---------- Voice-leading ---------- */
function vlDistance(a, b){
const A=[...a].sort((x,y)=>x-y), B=[...b].sort((x,y)=>x-y);
const L=Math.min(A.length,B.length);
let d=0;
for(let i=0;i<L;i++) d += Math.abs(A[i]-B[i]);
const mA=A.reduce((s,x)=>s+x,0)/A.length, mB=B.reduce((s,x)=>s+x,0)/B.length;
return d + Math.abs(mA-mB)*0.5;
}
function bestVoicingAgainst(notes, prevNotes){
let best = notes, bestD = Infinity;
for (let r=-4; r<=4; r++){
const cand = applyVoicing(notes, r);
const d = vlDistance(cand, prevNotes);
if (d < bestD){ bestD = d; best = cand; }
}
return best;
}
/* ---------- Suggestion harmonique (table fonctionnelle) ---------- */
const NEXT_DEG = {0:[3,4,5,1,2],1:[4,6,3],2:[5,3,1],3:[4,0,1,6],4:[0,5,3,2],5:[1,3,4,2],6:[0,2,5]};
function suggestNext(){
const scale = SCALES[state.keyScale];
let deg;
if (state.progression.length){
const last = state.progression[state.progression.length-1];
const rel = (last.rootPc - state.keyRoot + 12) % 12;
deg = scale.indexOf(rel);
}
if (deg === undefined || deg === -1) deg = 0;
const cands = NEXT_DEG[deg] || [0];
const nd = cands[Math.floor(Math.random()*cands.length)];
const pc = (state.keyRoot + scale[nd]) % 12;
const d = diatonicChord(pc, state.keyRoot, state.keyScale, state.exts);
if (!d) return;
let notes = applyDrop(applyVoicing(d.notes, state.voicing), state.drop);
if (state.vlAuto && state.progression.length)
notes = bestVoicingAgainst(notes, state.progression[state.progression.length-1].notes);
state.progression.push({
label: d.name, notes,
bass: state.bass ? BASE + pc + state.bassOct : null,
rootPc: pc, beats: 4,
});
renderProgression();
}
/* =========================================================
RENDU D'ÉVÉNEMENTS — partagé audio / MIDI live / export
events: [{n, at (beats), dur (beats), vel, isBass}]
========================================================= */
function renderBassEvents(bassMidi, beats, hV, hT){
const p = state.bassPerf, ev = [];
const push = (n, at, dur, vel=110)=>{
at = Math.max(0, at + hT());
dur = Math.min(dur, Math.max(0.1, beats - at - 0.02));
ev.push({n, at, dur, vel:vel+hV(), isBass:true});
};
if (p==="held"){ push(bassMidi, 0, beats*0.97); }
else if (p==="quarter"){ for(let t=0;t<beats;t+=1) push(bassMidi,t,0.9); }
else if (p==="eighth"){ for(let t=0;t<beats;t+=0.5) push(bassMidi,t,0.42); }
else if (p==="offbeat"){ for(let t=0.5;t<beats;t+=1) push(bassMidi,t,0.42); }
else if (p==="octave"){ let i=0; for(let t=0;t<beats;t+=0.5) push(i++%2?bassMidi+12:bassMidi,t,0.42); }
else if (p==="root5"){ let i=0; for(let t=0;t<beats;t+=1) push(i++%2?bassMidi+7:bassMidi,t,0.9); }
else if (p==="walk"){ const seq=[0,7,12,7]; let i=0; for(let t=0;t<beats;t+=1) push(bassMidi+seq[i++%4],t,0.9); }
else if (p==="tresillo"){
for(let c=0;c<beats;c+=4)
[0,1.5,3].forEach(o=>{ if(c+o<beats) push(bassMidi,c+o,1.2,114); });
}
else if (p==="pump"){ for(let t=0;t<beats;t+=1) push(bassMidi,t,0.38,114); }
else if (p==="rnd"){
const opts=[0,0,7,12];
for(let t=0;t<beats;t+=0.5)
if (Math.random()<0.65) push(bassMidi+opts[Math.floor(Math.random()*opts.length)],t,0.42);
if (!ev.length) push(bassMidi,0,0.9); // jamais de silence total
}
else { push(bassMidi, 0, beats*0.97); }
return ev;
}
function renderEvents(chord, beats, perfOverride){
const perf = perfOverride || state.perf;
const rate = state.rate;
const ev = [];
const hum = state.humanize;
const hV = ()=> hum ? Math.round((Math.random()-0.5)*20) : 0;
const hT = ()=> hum ? (Math.random()-0.5)*0.03 : 0;
if (chord.bass !== null && chord.bass !== undefined)
ev.push(...renderBassEvents(chord.bass, beats, hV, hT));
const notes = chord.notes;
if (perf==="block"){
notes.forEach(n=>ev.push({n, at:Math.max(0,hT()), dur:beats*0.95, vel:100+hV(), isBass:false}));
} else if (perf==="strumUp" || perf==="strumDown" || perf==="harp"){
const gap = perf==="harp" ? 0.035 : 0.08;
const seq = perf==="strumDown" ? [...notes].reverse() : notes;
seq.forEach((n,i)=>{
const at = Math.max(0, i*gap + hT());
ev.push({n, at, dur:Math.max(0.1,beats*0.95-at), vel:100+hV(), isBass:false});
});
} else { // arp
const steps = Math.max(1, Math.floor(beats/rate));
let seq;
if (perf==="arpUp") seq = notes;
else if (perf==="arpDown") seq = [...notes].reverse();
else if (perf==="arpUpDown") seq = [...notes, ...[...notes].reverse().slice(1,-1)];
else seq = null; // rnd
for (let k=0;k<steps;k++){
const n = seq ? seq[k%seq.length] : notes[Math.floor(Math.random()*notes.length)];
ev.push({n, at:Math.max(0,k*rate+hT()), dur:rate*0.8, vel:96+hV(), isBass:false});
}
}
ev.forEach(e=>{ e.vel = Math.max(20, Math.min(127, e.vel)); });
return ev;
}
/* =========================================================
AUDIO — poly Web Audio
========================================================= */
let AC=null, master=null;
function ensureAudio(){
if (AC) return;
AC = new (window.AudioContext||window.webkitAudioContext)();
master = AC.createGain(); master.gain.value = 0.5;
const comp = AC.createDynamicsCompressor();
master.connect(comp); comp.connect(AC.destination);
}
function playNote(midi, dur, vel=0.8, isBass=false, delayS=0){
ensureAudio();
const t = AC.currentTime + delayS;
const f = 440 * Math.pow(2,(midi-69)/12);
const g = AC.createGain();
const lp = AC.createBiquadFilter();
lp.type="lowpass"; lp.frequency.value = isBass?700:2200; lp.Q.value=0.7;
const detunes = isBass ? [0] : [-6, 6];
detunes.forEach(d=>{
const o = AC.createOscillator();
o.type = isBass ? "triangle" : "sawtooth";
o.frequency.value = f; o.detune.value = d;
o.connect(lp);
o.start(t); o.stop(t+dur+0.8);
});
lp.connect(g); g.connect(master);
const peak = (isBass?0.28:0.13)*vel;
g.gain.setValueAtTime(0,t);
g.gain.linearRampToValueAtTime(peak, t+0.015);
g.gain.setTargetAtTime(peak*0.7, t+0.05, 0.25);
g.gain.setTargetAtTime(0, t+dur, 0.12);
}
function playEventsAudio(events, bpm){
const spb = 60/bpm;
events.forEach(e=>playNote(e.n, e.dur*spb, e.vel/127, e.isBass, e.at*spb));
}
/* =========================================================
WEB MIDI live
========================================================= */
let midiAccess=null, midiOut=null;
const $ = id => document.getElementById(id);
function midiInit(){
if (!navigator.requestMIDIAccess){
$("midiStatus").textContent = "Web MIDI non supporté (Chrome/Edge/Firefox).";
$("lcdMidi").textContent = "N/A";
return;
}
navigator.requestMIDIAccess({sysex:false}).then(acc=>{
midiAccess = acc; refreshMidiDevices();
acc.onstatechange = refreshMidiDevices;
$("midiStatus").textContent = "Web MIDI prêt.";
}).catch(()=>{ $("midiStatus").textContent = "Accès MIDI refusé."; });
}
function refreshMidiDevices(){
const sel = $("midiDev"); const cur = sel.value;
sel.innerHTML = '<option value="">— désactivé —</option>';
midiAccess.outputs.forEach(out=>{
const o = document.createElement("option");
o.value=out.id; o.textContent=out.name; sel.appendChild(o);
});
sel.value = cur; selectMidiDevice();
}
function selectMidiDevice(){
midiOut = $("midiDev").value ? midiAccess.outputs.get($("midiDev").value) : null;
$("lcdMidi").textContent = midiOut ? "ON" : "—";
}
function playEventsMidi(events, bpm){
if (!midiOut) return;
const mspb = 60000/bpm;
const chC = parseInt($("chChord").value)-1;
const chB = parseInt($("chBass").value)-1;
const now = performance.now();
events.forEach(e=>{
const ch = e.isBass ? chB : chC;
midiOut.send([0x90|ch, e.n, e.vel], now + e.at*mspb);
midiOut.send([0x80|ch, e.n, 0], now + (e.at+e.dur)*mspb);
});
}
function midiPanic(){
if (!midiOut) return;
for (let ch=0; ch<16; ch++) midiOut.send([0xB0|ch,123,0]);
}
/* déclenche un accord (audition + live) */
function auditChord(chord, beats=2){
const bpm = getBpm();
const ev = renderEvents(chord, beats);
playEventsAudio(ev, bpm);
playEventsMidi(ev, bpm);
}
function getBpm(){ return Math.max(30, Math.min(300, parseInt($("bpm").value)||112)); }
/* =========================================================
EXPORT .mid (format 1, PPQ 480, perf rendue en option)
========================================================= */
function vlq(n){
const bytes=[n&0x7F];
while(n>>=7) bytes.unshift((n&0x7F)|0x80);
return bytes;
}
function trackChunk(events){
const body=[];
events.forEach(e=>{ body.push(...vlq(e.dt), ...e.data); });
body.push(0x00,0xFF,0x2F,0x00);
const len=body.length;
return [0x4D,0x54,0x72,0x6B,(len>>>24)&255,(len>>>16)&255,(len>>>8)&255,len&255,...body];
}
function exportMidi(){
if (!state.progression.length){
$("exportStatus").textContent = "Progression vide.";
return;
}
const PPQ=480, bpm=getBpm();
const tempo=Math.round(60000000/bpm);
const renderPerf = $("exportPerf").value==="yes" ? null : "block";
const split=$("exportSplit").value;
const chC=parseInt($("chChord").value)-1, chB=parseInt($("chBass").value)-1;
const meta=[
{dt:0, data:[0xFF,0x51,0x03,(tempo>>16)&255,(tempo>>8)&255,tempo&255]},
{dt:0, data:[0xFF,0x58,0x04,4,2,24,8]},
];
const chordRaw=[], bassRaw=[];
let cursor=0;
state.progression.forEach(c=>{
const ev = renderEvents(c, c.beats, renderPerf==="block"?"block":undefined);
ev.forEach(e=>{
const on = Math.round(cursor + e.at*PPQ);
const off = Math.round(cursor + (e.at+e.dur)*PPQ);
const tgt = e.isBass ? bassRaw : chordRaw;
const ch = e.isBass ? chB : chC;
tgt.push({t:on, on:true, n:e.n, ch, vel:e.vel});
tgt.push({t:off, on:false, n:e.n, ch, vel:0});
});
cursor += c.beats*PPQ;
});
const toTrack = evs=>{
evs.sort((a,b)=> a.t-b.t || (a.on===b.on?0:(a.on?1:-1)));
let last=0; const out=[];
evs.forEach(e=>{
out.push({dt:e.t-last, data:[(e.on?0x90:0x80)|e.ch, e.n, e.on?e.vel:0]});
last=e.t;
});
return out;
};
const tracks=[trackChunk([...meta,...toTrack(chordRaw)])];
if (split==="split" && bassRaw.length) tracks.push(trackChunk(toTrack(bassRaw)));
const nT=tracks.length;
const header=[0x4D,0x54,0x68,0x64,0,0,0,6, 0,1, 0,nT, (PPQ>>8)&255, PPQ&255];
const bytes=new Uint8Array([...header,...tracks.flat()]);
const blob=new Blob([bytes],{type:"audio/midi"});
const a=document.createElement("a");
a.href=URL.createObjectURL(blob);
const labels=state.progression.map(c=>c.label.replace(/[^\w#]/g,"")).slice(0,4).join("-");
a.download=`chordlab_${labels}_${bpm}bpm.mid`;
a.click(); URL.revokeObjectURL(a.href);
$("exportStatus").textContent=`Exporté : ${a.download}`;
}
/* =========================================================
SESSION save/load (fichier JSON — pas de localStorage)
========================================================= */
function saveSession(){
const data = {
v:2,
settings:{
type:state.type, exts:[...state.exts], voicing:state.voicing,
drop:state.drop, slash:state.slash, bass:state.bass, bassOct:state.bassOct,
keyMode:state.keyMode, keyRoot:state.keyRoot, keyScale:state.keyScale,
perf:state.perf, rate:state.rate, humanize:state.humanize,
bassPerf:state.bassPerf,
bpm:getBpm(), vlAuto:state.vlAuto,
},
progression:state.progression,
};
const blob=new Blob([JSON.stringify(data,null,1)],{type:"application/json"});
const a=document.createElement("a");
a.href=URL.createObjectURL(blob);
a.download="chordlab_session.json";
a.click(); URL.revokeObjectURL(a.href);
$("sessionStatus").textContent="Session sauvée.";
}
function loadSession(file){
const r=new FileReader();
r.onload=()=>{
try{
const d=JSON.parse(r.result);
const s=d.settings||{};
state.type=s.type||"maj";
state.exts=new Set(s.exts||[]);
state.voicing=s.voicing||0;
state.drop=s.drop||"close";
state.slash=s.slash||"root";
state.bass=!!s.bass;
state.bassOct=s.bassOct||-12;
state.keyMode=!!s.keyMode;
state.keyRoot=s.keyRoot||0;
state.keyScale=s.keyScale||"major";
state.perf=s.perf||"block";
state.rate=s.rate||0.25;
state.bassPerf=s.bassPerf||"held";
state.humanize=!!s.humanize;
state.vlAuto=!!s.vlAuto;
state.progression=Array.isArray(d.progression)?d.progression:[];
state.progression.forEach(c=>{ c.beats=Math.max(1,Math.min(16,c.beats||4)); });
if (s.bpm) $("bpm").value=s.bpm;
syncUiFromState();
renderProgression();
$("sessionStatus").textContent="Session chargée.";
}catch(e){ $("sessionStatus").textContent="Fichier invalide."; }
};
r.readAsText(file);
}
function syncUiFromState(){
document.querySelectorAll("#typeBtns .hwbtn").forEach(b=>b.classList.toggle("on", b.dataset.type===state.type));
document.querySelectorAll("[data-ext]").forEach(b=>b.classList.toggle("on", state.exts.has(b.dataset.ext)));
$("dropSel").value=state.drop; $("slashSel").value=state.slash;
$("bassBtn").classList.toggle("on", state.bass);
$("bassOct").value=state.bassOct;
$("keyBtn").classList.toggle("on", state.keyMode);
$("keyRoot").value=state.keyRoot;
$("keyScaleSel").value=state.keyScale;
$("perfSel").value=state.perf; $("rateSel").value=state.rate;
$("bassPerfSel").value=state.bassPerf;
$("lcdBassPerf").textContent=$("bassPerfSel").selectedOptions[0].textContent.toUpperCase();
$("humanBtn").classList.toggle("on", state.humanize);
$("vlBtn").classList.toggle("on", state.vlAuto);
$("lcdDrop").textContent=state.drop.toUpperCase();
$("lcdPerf").textContent=$("perfSel").selectedOptions[0].textContent.toUpperCase();
$("lcdVl").textContent=state.vlAuto?"ON":"OFF";
updateKeyLcd(); refreshKeyDimming(); setVoicing(state.voicing, true);
}
/* =========================================================
UI
========================================================= */
const WHITE=[0,2,4,5,7,9,11];
const BLACK=[{pc:1,pos:0},{pc:3,pos:1},{pc:6,pos:3},{pc:8,pos:4},{pc:10,pos:5}];
const kbdEl=$("kbd"); const keyEls={};
function buildKeyboard(){
WHITE.forEach(pc=>{
const k=document.createElement("div");
k.className="wkey"; k.dataset.pc=pc; k.textContent=NOTE_NAMES[pc];
k.setAttribute("role","button");
kbdEl.appendChild(k); keyEls[pc]=k;
});
BLACK.forEach(({pc,pos})=>{
const k=document.createElement("div");
k.className="bkey"; k.dataset.pc=pc;
k.style.left=`calc(${(pos+1)*(100/7)}% - 4.5%)`;
kbdEl.appendChild(k); keyEls[pc]=k;
});
}
function refreshKeyDimming(){
const scale=SCALES[state.keyScale];
Object.entries(keyEls).forEach(([pc,el])=>{
const inKey=!state.keyMode || scale.includes((pc-state.keyRoot+12)%12);
el.classList.toggle("outkey",!inKey);
});
}
function triggerChord(rootPc){
const chord=currentChord(rootPc);
if (!chord){
$("lcdName").textContent="×";
$("lcdNotes").textContent="hors tonalité";
return null;
}
state.lastRoot=rootPc;
$("lcdName").textContent=chord.name;
$("lcdNotes").textContent=chord.notes.map(n=>NOTE_NAMES[n%12]+(Math.floor(n/12)-1)).join(" · ")
+(chord.bass!==null?" | B:"+NOTE_NAMES[chord.bass%12]:"");
auditChord(chord);
return chord;
}
/* type radio */
document.querySelectorAll("#typeBtns .hwbtn").forEach(btn=>{
btn.addEventListener("click",()=>{
state.type=btn.dataset.type;
document.querySelectorAll("#typeBtns .hwbtn").forEach(b=>
b.classList.toggle("on", b.dataset.type===state.type));
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
});
/* tensions + altérations toggles */
document.querySelectorAll("[data-ext]").forEach(btn=>{
btn.addEventListener("click",()=>{
const e=btn.dataset.ext;
state.exts.has(e)?state.exts.delete(e):state.exts.add(e);
btn.classList.toggle("on", state.exts.has(e));
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
});
/* clavier souris + PC */
kbdEl.addEventListener("pointerdown",ev=>{
const pc=ev.target.dataset.pc;
if (pc===undefined) return;
ev.target.classList.add("pressed");
triggerChord(parseInt(pc));
});
kbdEl.addEventListener("pointerup",ev=>{
ev.target.classList&&ev.target.classList.remove("pressed");
});
const PCKEYS={a:0,w:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11};
window.addEventListener("keydown",ev=>{
if (ev.repeat||ev.target.tagName==="INPUT"||ev.target.tagName==="SELECT") return;
const k=ev.key.toLowerCase();
if (k in PCKEYS){
keyEls[PCKEYS[k]].classList.add("pressed");
triggerChord(PCKEYS[k]);
} else if (ev.code==="Space"){ ev.preventDefault(); addToProgression(); }
else if (ev.key==="ArrowRight"){ setVoicing(state.voicing+1); }
else if (ev.key==="ArrowLeft"){ setVoicing(state.voicing-1); }
});
window.addEventListener("keyup",ev=>{
const k=ev.key.toLowerCase();
if (k in PCKEYS) keyEls[PCKEYS[k]].classList.remove("pressed");
});
/* knob voicing */
const knob=$("voicingKnob");
function setVoicing(v, silent=false){
state.voicing=Math.max(-8,Math.min(8,v));
const angle=state.voicing*16;
const rad=(angle-90)*Math.PI/180;
$("knobInd").setAttribute("x2",34+24*Math.cos(rad));
$("knobInd").setAttribute("y2",34+24*Math.sin(rad));
$("knobVal").textContent=state.voicing>0?"+"+state.voicing:state.voicing;
$("lcdVoicing").textContent=$("knobVal").textContent;
knob.setAttribute("aria-valuenow",state.voicing);
if (!silent && state.lastRoot!==null) triggerChord(state.lastRoot);
}
let dragY=null,dragStart=0;
knob.addEventListener("pointerdown",ev=>{dragY=ev.clientY;dragStart=state.voicing;knob.setPointerCapture(ev.pointerId);});
knob.addEventListener("pointermove",ev=>{
if (dragY===null) return;
const dv=Math.round((dragY-ev.clientY)/14);
if (dragStart+dv!==state.voicing) setVoicing(dragStart+dv);
});
knob.addEventListener("pointerup",()=>{dragY=null;});
knob.addEventListener("dblclick",()=>setVoicing(0));
knob.addEventListener("keydown",ev=>{
if (ev.key==="ArrowUp"||ev.key==="ArrowRight"){setVoicing(state.voicing+1);ev.preventDefault();}
if (ev.key==="ArrowDown"||ev.key==="ArrowLeft"){setVoicing(state.voicing-1);ev.preventDefault();}
});
$("voicUp").addEventListener("click",()=>setVoicing(state.voicing+1));
$("voicDown").addEventListener("click",()=>setVoicing(state.voicing-1));
$("voicReset").addEventListener("click",()=>setVoicing(0));
/* drop / slash / bass / key */
$("dropSel").addEventListener("change",function(){
state.drop=this.value;
$("lcdDrop").textContent=this.value.toUpperCase();
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
$("slashSel").addEventListener("change",function(){
state.slash=this.value;
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
$("bassBtn").addEventListener("click",function(){
state.bass=!state.bass;
this.classList.toggle("on",state.bass);
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
$("bassOct").addEventListener("change",function(){state.bassOct=parseInt(this.value);});
NOTE_NAMES.forEach((n,i)=>{
const o=document.createElement("option");
o.value=i;o.textContent=n;
$("keyRoot").appendChild(o);
});
$("keyBtn").addEventListener("click",function(){
state.keyMode=!state.keyMode;
this.classList.toggle("on",state.keyMode);
updateKeyLcd();refreshKeyDimming();
});
$("keyRoot").addEventListener("change",function(){state.keyRoot=parseInt(this.value);updateKeyLcd();refreshKeyDimming();});
$("keyScaleSel").addEventListener("change",function(){state.keyScale=this.value;updateKeyLcd();refreshKeyDimming();});
function updateKeyLcd(){
$("lcdKey").textContent=state.keyMode
?NOTE_NAMES[state.keyRoot]+" "+$("keyScaleSel").selectedOptions[0].textContent.slice(0,4)
:"OFF";
}
/* perf / rate / humanize / VL auto */
$("perfSel").addEventListener("change",function(){
state.perf=this.value;
$("lcdPerf").textContent=this.selectedOptions[0].textContent.toUpperCase();
});
$("rateSel").addEventListener("change",function(){state.rate=parseFloat(this.value);});
$("bassPerfSel").addEventListener("change",function(){
state.bassPerf=this.value;
$("lcdBassPerf").textContent=this.selectedOptions[0].textContent.toUpperCase();
});
$("humanBtn").addEventListener("click",function(){
state.humanize=!state.humanize;
this.classList.toggle("on",state.humanize);
});
$("vlBtn").addEventListener("click",function(){
state.vlAuto=!state.vlAuto;
this.classList.toggle("on",state.vlAuto);
$("lcdVl").textContent=state.vlAuto?"ON":"OFF";
});
/* progression */
function addToProgression(){
if (state.lastRoot===null) return;
const chord=currentChord(state.lastRoot);
if (!chord) return;
let notes=[...chord.notes];
if (state.vlAuto && state.progression.length)
notes=bestVoicingAgainst(notes, state.progression[state.progression.length-1].notes);
state.progression.push({
label:chord.name, notes,
bass:chord.bass, rootPc:chord.rootPc, beats:4,
});
renderProgression();
}
$("addBtn").addEventListener("click",addToProgression);
$("progClear").addEventListener("click",()=>{stopPlayback();state.progression=[];renderProgression();});
$("suggestBtn").addEventListener("click",suggestNext);
$("optimizeBtn").addEventListener("click",()=>{
for (let i=1;i<state.progression.length;i++){
state.progression[i].notes=bestVoicingAgainst(
state.progression[i].notes, state.progression[i-1].notes);
}
renderProgression();
$("sessionStatus").textContent="Voice-leading appliqué.";
});
function renderProgression(){
const el=$("progSlots");
el.innerHTML="";
if (!state.progression.length){
const e=document.createElement("div");
e.className="slot-empty";
e.textContent="vide — joue un accord puis ADD, ou ✦ suggérer";
el.appendChild(e);
return;
}
state.progression.forEach((c,i)=>{
const s=document.createElement("div");
s.className="slot"+(i===state.playIdx?" playing":"");
s.innerHTML=`<button class="del" title="supprimer">×</button>
<div class="sname">${c.label}</div>
<div class="snotes">${c.notes.map(n=>NOTE_NAMES[n%12]).join(" ")}${c.bass!==null?" +B":""}</div>
<div class="srow">
<button class="mv sl" title="déplacer à gauche"></button>
<button class="mv bminus" title="1 temps"></button>
<span class="badge" title="durée en temps (1 à 16)">${c.beats}♩</span>
<button class="mv bplus" title="+1 temps">+</button>
<button class="mv sr" title="déplacer à droite"></button>
</div>`;
s.querySelector(".del").addEventListener("click",ev=>{
ev.stopPropagation();
state.progression.splice(i,1);renderProgression();
});
s.querySelector(".sl").addEventListener("click",ev=>{
ev.stopPropagation();
if (i>0){[state.progression[i-1],state.progression[i]]=[state.progression[i],state.progression[i-1]];renderProgression();}
});
s.querySelector(".sr").addEventListener("click",ev=>{
ev.stopPropagation();
if (i<state.progression.length-1){[state.progression[i+1],state.progression[i]]=[state.progression[i],state.progression[i+1]];renderProgression();}
});
s.querySelector(".bminus").addEventListener("click",ev=>{
ev.stopPropagation();
c.beats=Math.max(1,c.beats-1);renderProgression();
});
s.querySelector(".bplus").addEventListener("click",ev=>{
ev.stopPropagation();
c.beats=Math.min(16,c.beats+1);renderProgression();
});
s.addEventListener("click",()=>{
const ev=renderEvents(c, Math.min(c.beats,2));
playEventsAudio(ev,getBpm());
playEventsMidi(ev,getBpm());
});
el.appendChild(s);
});
}
/* presets */
$("presetLoad").addEventListener("click",()=>{
const val=$("presetSel").value;
if (!val) return;
const degrees=val.split(",").map(Number);
const scale=SCALES[state.keyScale];
stopPlayback();
state.progression=[];
let prev=null;
degrees.forEach(d=>{
const pc=(state.keyRoot+scale[(d-1)%7])%12;
const dc=diatonicChord(pc,state.keyRoot,state.keyScale,state.exts);
if (!dc) return;
let notes=applyDrop(applyVoicing(dc.notes,state.voicing),state.drop);
if (state.vlAuto && prev) notes=bestVoicingAgainst(notes,prev);
prev=notes;
state.progression.push({
label:dc.name,notes,
bass:state.bass?BASE+pc+state.bassOct:null,
rootPc:pc,beats:4,
});
});
renderProgression();
});
/* lecture */
function stopPlayback(){
state.playing=false;state.playIdx=-1;
clearTimeout(state.playTimer);
$("playBtn").classList.remove("playing","on");
$("playBtn").innerHTML='<span class="led"></span>PLAY';
midiPanic();
renderProgression();
}
function startPlayback(){
if (!state.progression.length) return;
ensureAudio();
state.playing=true;
$("playBtn").classList.add("playing","on");
$("playBtn").innerHTML='<span class="led"></span>STOP';
let i=0;
const step=()=>{
if (!state.playing) return;
if (i>=state.progression.length){
if (state.loop){i=0;} else {stopPlayback();return;}
}
const bpm=getBpm();
const c=state.progression[i];
const durMs=c.beats*60000/bpm;
state.playIdx=i;
renderProgression();
$("lcdName").textContent=c.label;
$("lcdNotes").textContent=c.notes.map(n=>NOTE_NAMES[n%12]+(Math.floor(n/12)-1)).join(" · ");
const ev=renderEvents(c,c.beats);
playEventsAudio(ev,bpm);
playEventsMidi(ev,bpm);
i++;
state.playTimer=setTimeout(step,durMs);
};
step();
}
$("playBtn").addEventListener("click",()=>state.playing?stopPlayback():startPlayback());
$("loopBtn").addEventListener("click",function(){
state.loop=!state.loop;
this.classList.toggle("on",state.loop);
});
/* MIDI UI */
for (let i=1;i<=16;i++){
const o1=document.createElement("option");o1.value=i;o1.textContent="ch "+i;
const o2=o1.cloneNode(true);
$("chChord").appendChild(o1);$("chBass").appendChild(o2);
}
$("chChord").value=3; // Pro-800
$("chBass").value=5; // TD-3 + K-2
$("midiDev").addEventListener("change",selectMidiDevice);
/* export & session */
$("exportBtn").addEventListener("click",exportMidi);
$("saveJson").addEventListener("click",saveSession);
$("loadJson").addEventListener("click",()=>$("fileInput").click());
$("fileInput").addEventListener("change",function(){
if (this.files[0]) loadSession(this.files[0]);
this.value="";
});
/* init */
buildKeyboard();
renderProgression();
refreshKeyDimming();
setVoicing(0,true);
midiInit();
</script>
</body>
</html>