1273 lines
49 KiB
HTML
1273 lines
49 KiB
HTML
<!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>
|
||
<script defer src="https://umami.ghostinthemachine.fr/script.js" data-website-id="0e5510f0-0521-4e70-b43e-6cc7d97df7a3"></script>
|
||
</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></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="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.2 · 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,
|
||
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 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({n:chord.bass, at:0, dur:beats*0.97, vel:110+hV(), isBass:true});
|
||
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,
|
||
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.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;
|
||
$("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);});
|
||
$("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>
|