Chordlab V2.0 - initial

This commit is contained in:
lrdlg
2026-07-16 14:37:03 +02:00
commit 5d227c8ac9
7 changed files with 2728 additions and 0 deletions
+958
View File
@@ -0,0 +1,958 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>_chordlab — générateur d'accords</title>
<style>
:root{
--panel:#E0D6C3; /* face avant beige */
--panel-edge:#C9BCA4;
--module:#26231D; /* modules charbon */
--module-2:#332F27;
--silk:#403A2F; /* sérigraphie sur beige */
--silk-light:#D8CFBB; /* sérigraphie sur charbon */
--lcd-bg:#141A12;
--lcd-amber:#FFB454;
--lcd-dim:#7A6A3F;
--led-on:#FFAE33;
--led-off:#4A4436;
--led-red:#E5482B;
--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:1060px;
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:86px;
}
.lcd .chord-name{
font-size:2.1rem;font-weight:700;color:var(--lcd-amber);
text-shadow:0 0 12px rgba(255,180,84,.45);
line-height:1.1;
min-height:2.4rem;
}
.lcd .chord-notes{font-size:.8rem;color:var(--lcd-dim);letter-spacing:.12em}
.lcd .lcd-right{text-align:right;font-size:.68rem;color:var(--lcd-dim);line-height:1.7}
.lcd .lcd-right b{color:var(--lcd-amber);font-weight:600}
/* ---------- Rangée principale ---------- */
.deck{
display:grid;
grid-template-columns:250px 1fr 190px;
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:.5rem}
.hwbtn{
background:var(--module-2);
border:1px solid #4A4436;
border-radius:7px;
color:var(--silk-light);
font-family:var(--mono);
font-size:.72rem;font-weight:600;
padding:.75rem 0 .55rem;
cursor:pointer;
display:flex;flex-direction:column;align-items:center;gap:.4rem;
transition:transform .05s;
}
.hwbtn:active{transform:translateY(1px)}
.hwbtn .led{
width:8px;height:8px;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: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:.6rem 0 .35rem;text-transform:uppercase}
/* ---------- Clavier ---------- */
.kbd-mod{display:flex;flex-direction:column}
.kbd{position:relative;flex:1;min-height:170px;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}
/* ---------- Voicing / bass / key ---------- */
.side{display:flex;flex-direction:column;gap:.8rem}
.knob-wrap{display:flex;flex-direction:column;align-items:center;gap:.3rem}
.knob{width:74px;height:74px;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:.75rem;color:var(--silk-light)}
.mini-row{display:flex;gap:.4rem;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:.7rem;
padding:.25rem .55rem;cursor:pointer;
}
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:.7rem;
padding:.3rem .35rem;width:100%;
}
.toggle-line{display:flex;align-items:center;gap:.5rem;font-size:.65rem;color:var(--silk-light)}
.toggle-line .hwbtn{flex-direction:row;padding:.4rem .6rem;gap:.4rem}
/* ---------- Progression ---------- */
.prog-mod{margin-bottom:1.1rem}
.prog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem}
.prog-slots{display:flex;gap:.5rem;flex-wrap:wrap;min-height:64px}
.slot{
background:var(--module-2);
border:1px solid #4A4436;border-radius:7px;
padding:.45rem .6rem;min-width:96px;
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}
.slot .snotes{opacity:.55;font-size:.58rem;margin-top:.15rem}
.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:96px;
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:.5rem .8rem;gap:.45rem}
.transport .hwbtn.rec .led{background:var(--led-off)}
.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:.68rem}
.bpm-wrap input{width:64px}
.preset-wrap{margin-left:auto;display:flex;gap:.4rem;align-items:center}
.preset-wrap select{width:auto}
/* ---------- IO ---------- */
.io{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.io-line{display:grid;grid-template-columns:auto 1fr;gap:.5rem;align-items:center;font-size:.65rem;color:var(--silk-light);margin-bottom:.45rem}
.io-line label{opacity:.6;white-space:nowrap}
.status{font-size:.6rem;color:var(--silk-light);opacity:.55;margin-top:.4rem;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:.8rem;
padding:.7rem;cursor:pointer;letter-spacing:.05em;margin-top:.3rem;
}
.export-btn:active{transform:translateY(1px)}
footer{margin-top:1rem;font-size:.55rem;opacity:.5;letter-spacing:.1em;text-align:right}
@media (max-width:860px){
.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</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></div>
<div>KEY <b id="lcdKey">OFF</b></div>
<div>MIDI <b id="lcdMidi"></b></div>
</div>
</div>
<!-- Rangée principale -->
<div class="deck">
<!-- Types + extensions -->
<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="sus"><span class="led"></span>SUS</button>
</div>
<div class="row-label">Modifiers</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>
</div>
<div class="row-label" style="opacity:.3">combos : dim+maj=aug · min+sus=sus2</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 &nbsp;·&nbsp; ␣ = ajouter à la progression</div>
</div>
<!-- Voicing / bass / key -->
<div class="mod side">
<h2>Voicing</h2>
<div class="knob-wrap">
<svg class="knob" id="voicingKnob" viewBox="0 0 74 74" role="slider" aria-label="Voicing" aria-valuemin="-8" aria-valuemax="8" aria-valuenow="0" tabindex="0">
<circle class="body" cx="37" cy="37" r="33"/>
<line class="ind" id="knobInd" x1="37" y1="37" x2="37" y2="10"/>
</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="toggle-line">
<button class="hwbtn" id="bassBtn"><span class="led"></span>BASS</button>
<select id="bassOct" title="Octave basse">
<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>
<select id="keyMode">
<option value="major">maj</option>
<option value="minor">min</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">
<select id="presetSel">
<option value="">— preset (dans la 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 (min)</option>
<option value="1,3,7,6">i III VII VI (min)</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>
<div class="bpm-wrap">BPM <input type="number" id="bpm" value="112" min="30" max="300"></div>
<div class="bpm-wrap">durée/accord
<select id="chordLen" style="width:auto">
<option value="4">1 mesure</option>
<option value="2">1/2 mesure</option>
<option value="8">2 mesures</option>
</select>
</div>
</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</h2>
<div class="io-line"><label>nom du fichier</label><input type="number" style="display:none"><select id="exportSplit">
<option value="split">accords + basse (2 pistes)</option>
<option value="chords">accords seuls</option>
</select></div>
<button class="export-btn" id="exportBtn">↓ exporter .mid</button>
<div class="status" id="exportStatus"></div>
</div>
</div>
<footer>_chordlab v1.0 · logique d'accords inspirée de l'Orchid ORC-1 (implémentation indépendante)</footer>
</div>
<script>
"use strict";
/* =========================================================
_chordlab — générateur d'accords façon Orchid
Moteur : root + type + extensions → set d'intervalles
Voicing : rotation des notes (inversions en cascade)
========================================================= */
const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const BASE_OCTAVE_MIDI = 60; // C4
/* ---------- État ---------- */
const state = {
types: ["maj"], // ordre de sélection
exts: new Set(),
voicing: 0, // -8..+8 rotations
bass: false,
bassOct: -12,
keyMode: false,
keyRoot: 0, // 0 = C
keyScale: "major",
lastRoot: null, // dernière note jouée (0..11)
progression: [], // [{label, notes[], bass, rootPc}]
playing: false,
loop: false,
playIdx: -1,
playTimer: null,
liveNotes: [], // notes MIDI actuellement tenues (live audition)
};
/* ---------- Moteur d'accords ---------- */
const TYPE_INTERVALS = {
dim:[0,3,6], min:[0,3,7], maj:[0,4,7], sus:[0,5,7],
aug:[0,4,8], sus2:[0,2,7],
};
const EXT_INTERVALS = { "6":9, "m7":10, "M7":11, "9":14 };
function resolveType(types){
const s = new Set(types);
if (s.has("dim") && s.has("maj")) return "aug";
if (s.has("min") && s.has("sus")) return "sus2";
return types[types.length-1] || "maj";
}
function chordName(rootPc, type, exts){
const base = {maj:"", min:"m", dim:"dim", sus:"sus4", aug:"aug", sus2:"sus2"}[type];
let name = NOTE_NAMES[rootPc] + base;
const e = new Set(exts);
// cas nommés proprement
if (type==="dim" && e.has("6") && !e.has("m7") && !e.has("M7")) { name = NOTE_NAMES[rootPc]+"dim7"; e.delete("6"); }
else if (type==="dim" && e.has("m7")) { name = NOTE_NAMES[rootPc]+"ø7"; e.delete("m7"); }
if (e.has("m7") && e.has("M7")) return name + " WTF?"; // hommage
if (e.has("m7")) { name += "7"; e.delete("m7"); }
if (e.has("M7")) { name += "maj7"; e.delete("M7"); }
if (e.has("6")) { name += "6"; e.delete("6"); }
if (e.has("9")) { name += (name===NOTE_NAMES[rootPc]+ "" || /7|6/.test(name)) ? "9".replace("9", name.match(/7|6/)?"(9)":"add9") : "add9"; }
return name;
}
/* Construit les notes MIDI (avant voicing) */
function buildChord(rootPc, type, exts){
const intervals = new Set(TYPE_INTERVALS[type]);
exts.forEach(e => intervals.add(EXT_INTERVALS[e]));
const root = BASE_OCTAVE_MIDI + rootPc;
return [...intervals].sort((a,b)=>a-b).map(i => root + i);
}
/* Voicing = rotation : +1 → la note la plus basse monte d'une octave */
function applyVoicing(notes, steps){
let n = [...notes].sort((a,b)=>a-b);
const k = Math.abs(steps);
for (let i=0;i<k;i++){
if (steps>0){ n[0]+=12; } else { n[n.length-1]-=12; }
n.sort((a,b)=>a-b);
}
return n;
}
/* ---------- Key Mode : accords diatoniques ---------- */
const SCALES = { major:[0,2,4,5,7,9,11], minor:[0,2,3,5,7,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; // hors tonalité
const pick = j => keyRoot + scale[(deg+j)%7] + 12*Math.floor((deg+j)/7);
const semis = [pick(0), pick(2), pick(4)]; // triade en tierces
if (exts.has("6")) semis.push(pick(5));
if (exts.has("m7") || exts.has("M7")) semis.push(pick(6)); // 7e diatonique
if (exts.has("9")) semis.push(pick(8));
const root = BASE_OCTAVE_MIDI + notePc;
const notes = semis.map(s => BASE_OCTAVE_MIDI + s);
// normalisation : ancrer la fondamentale sur la note jouée
const shift = root - notes[0];
const final = notes.map(n => n + shift).sort((a,b)=>a-b);
// nom : détecter la qualité de la triade
const i1 = final[1]-final[0], i2 = final[2]-final[0];
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)*";
return {notes: final, name};
}
/* ---------- Accord courant ---------- */
function currentChord(rootPc){
if (state.keyMode){
const d = diatonicChord(rootPc, state.keyRoot, state.keyScale, state.exts);
if (!d) return null;
return {name:d.name, notes:applyVoicing(d.notes, state.voicing), rootPc};
}
const type = resolveType(state.types);
const notes = applyVoicing(buildChord(rootPc, type, [...state.exts]), state.voicing);
return {name:chordName(rootPc, type, [...state.exts]), notes, rootPc};
}
function bassNote(rootPc){ return BASE_OCTAVE_MIDI + rootPc + state.bassOct; }
/* =========================================================
AUDIO — petit poly Web Audio (2 saws désaccordées + LP)
========================================================= */
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){
ensureAudio();
const t = AC.currentTime;
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 oscs = [];
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); oscs.push(o);
});
lp.connect(g); g.connect(master);
const peak = (isBass?0.28:0.14) * 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);
oscs.forEach(o=>{ o.start(t); o.stop(t+dur+0.8); });
}
function playChordAudio(chord, dur){
chord.notes.forEach(n => playNote(n, dur, 0.8, false));
if (state.bass) playNote(bassNote(chord.rootPc), dur, 0.9, true);
}
/* =========================================================
WEB MIDI — sortie live
========================================================= */
let midiAccess = null, midiOut = null;
const $ = id => document.getElementById(id);
function midiInit(){
if (!navigator.requestMIDIAccess){
$("midiStatus").textContent = "Web MIDI non supporté (utilise 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(){
const id = $("midiDev").value;
midiOut = id ? midiAccess.outputs.get(id) : null;
$("lcdMidi").textContent = midiOut ? "ON" : "—";
}
function midiSend(chord, durMs){
if (!midiOut) return;
const chC = parseInt($("chChord").value)-1;
const chB = parseInt($("chBass").value)-1;
const now = performance.now();
chord.notes.forEach(n=>{
midiOut.send([0x90|chC, n, 100]);
midiOut.send([0x80|chC, n, 0], now+durMs);
});
if (state.bass){
const b = bassNote(chord.rootPc);
midiOut.send([0x90|chB, b, 110]);
midiOut.send([0x80|chB, b, 0], now+durMs);
}
}
function midiPanic(){
if (!midiOut) return;
for (let ch=0; ch<16; ch++) midiOut.send([0xB0|ch, 123, 0]);
}
/* =========================================================
EXPORT .mid — writer minimal (format 1, PPQ 480)
========================================================= */
function vlq(n){
const bytes=[n&0x7F];
while(n>>=7) bytes.unshift((n&0x7F)|0x80);
return bytes;
}
function trackChunk(events){
// events: [{dt, data:[...]}]
const body=[];
events.forEach(e=>{ body.push(...vlq(e.dt), ...e.data); });
body.push(0x00,0xFF,0x2F,0x00); // end of track
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 — ajoute des accords d'abord.";
return;
}
const PPQ = 480;
const bpm = Math.max(30, Math.min(300, parseInt($("bpm").value)||112));
const beats = parseInt($("chordLen").value);
const ticksPerChord = beats*PPQ;
const gate = Math.round(ticksPerChord*0.97);
const tempo = Math.round(60000000/bpm);
const split = $("exportSplit").value;
const chC = parseInt($("chChord").value)-1;
const 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 chordEv = [];
const bassEv = [];
state.progression.forEach((c,i)=>{
const at = i*ticksPerChord;
// note-ons groupés à dt calculé depuis la fin du précédent gate
c.notes.forEach((n,j)=>{
chordEv.push({t:at, on:true, n, ch:chC});
chordEv.push({t:at+gate, on:false, n, ch:chC});
});
if (c.bass !== null && c.bass !== undefined){
bassEv.push({t:at, on:true, n:c.bass, ch:chB});
bassEv.push({t:at+gate, on:false, n:c.bass, ch:chB});
}
});
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?100:0]});
last=e.t;
});
return out;
};
const tracks = [trackChunk([...meta, ...toTrack(chordEv)])];
if (split==="split" && bassEv.length) tracks.push(trackChunk(toTrack(bassEv)));
const nTracks = tracks.length;
const header = [0x4D,0x54,0x68,0x64,0,0,0,6, 0,1, 0,nTracks, (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}`;
}
/* =========================================================
UI
========================================================= */
/* ----- clavier ----- */
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);
});
}
/* ----- déclenchement d'accord ----- */
function triggerChord(rootPc, fromPlayback=false){
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(" · ")
+ (state.bass ? " | bass "+NOTE_NAMES[bassNote(rootPc)%12] : "");
if (!fromPlayback){
const durS = 1.4;
playChordAudio(chord, durS);
midiSend(chord, durS*1000);
}
return chord;
}
/* ----- boutons type/ext ----- */
document.querySelectorAll("#typeBtns .hwbtn").forEach(btn=>{
btn.addEventListener("click", ()=>{
const t = btn.dataset.type;
const i = state.types.indexOf(t);
if (i>=0){
if (state.types.length>1) state.types.splice(i,1);
} else {
const test = [...state.types, t];
const s = new Set(test);
const combo = (s.has("dim")&&s.has("maj")) || (s.has("min")&&s.has("sus"));
state.types = (test.length<=1 || (test.length===2 && combo)) ? test : [t];
}
document.querySelectorAll("#typeBtns .hwbtn").forEach(b=>
b.classList.toggle("on", state.types.includes(b.dataset.type)));
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
});
document.querySelectorAll("#extBtns .hwbtn").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){
const pc = PCKEYS[k];
keyEls[pc].classList.add("pressed");
triggerChord(pc);
} else if (ev.code==="Space"){
ev.preventDefault();
addToProgression();
}
});
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){
state.voicing = Math.max(-8, Math.min(8, v));
const angle = state.voicing * 16; // ±128°
const rad = (angle-90)*Math.PI/180;
$("knobInd").setAttribute("x2", 37 + 26*Math.cos(rad));
$("knobInd").setAttribute("y2", 37 + 26*Math.sin(rad));
$("knobVal").textContent = state.voicing>0 ? "+"+state.voicing : state.voicing;
$("lcdVoicing").textContent = $("knobVal").textContent;
knob.setAttribute("aria-valuenow", state.voicing);
if (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));
/* ----- bass & key ----- */
$("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(); });
$("keyMode").addEventListener("change", function(){ state.keyScale=this.value; updateKeyLcd(); refreshKeyDimming(); });
function updateKeyLcd(){
$("lcdKey").textContent = state.keyMode
? NOTE_NAMES[state.keyRoot] + (state.keyScale==="major"?" maj":" min")
: "OFF";
}
/* ----- progression ----- */
function addToProgression(){
if (state.lastRoot===null) return;
const chord = currentChord(state.lastRoot);
if (!chord) return;
state.progression.push({
label: chord.name,
notes: [...chord.notes],
bass: state.bass ? bassNote(chord.rootPc) : null,
rootPc: chord.rootPc,
});
renderProgression();
}
$("addBtn").addEventListener("click", addToProgression);
$("progClear").addEventListener("click", ()=>{ stopPlayback(); state.progression=[]; renderProgression(); });
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";
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>`;
s.querySelector(".del").addEventListener("click", ev=>{
ev.stopPropagation();
state.progression.splice(i,1);
renderProgression();
});
s.addEventListener("click", ()=>{
const durS = 1.2;
c.notes.forEach(n=>playNote(n,durS));
if (c.bass!==null) playNote(c.bass,durS,0.9,true);
midiSendRaw(c, durS*1000);
});
el.appendChild(s);
});
}
function midiSendRaw(c, durMs){
if (!midiOut) return;
const chC = parseInt($("chChord").value)-1;
const chB = parseInt($("chBass").value)-1;
const now = performance.now();
c.notes.forEach(n=>{ midiOut.send([0x90|chC,n,100]); midiOut.send([0x80|chC,n,0],now+durMs); });
if (c.bass!==null){ midiOut.send([0x90|chB,c.bass,110]); midiOut.send([0x80|chB,c.bass,0],now+durMs); }
}
/* ----- presets diatoniques ----- */
$("presetLoad").addEventListener("click", ()=>{
const val = $("presetSel").value;
if (!val) return;
const degrees = val.split(",").map(Number);
const scale = SCALES[state.keyScale];
stopPlayback();
state.progression = [];
degrees.forEach(d=>{
const pc = (state.keyRoot + scale[(d-1)%7]) % 12;
const chord = (()=>{
const dChord = diatonicChord(pc, state.keyRoot, state.keyScale, state.exts);
if (!dChord) return null;
return {name:dChord.name, notes:applyVoicing(dChord.notes, state.voicing), rootPc:pc};
})();
if (chord) state.progression.push({
label: chord.name, notes:[...chord.notes],
bass: state.bass ? bassNote(pc) : null, rootPc: pc,
});
});
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;
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 = Math.max(30, Math.min(300, parseInt($("bpm").value)||112));
const beats = parseInt($("chordLen").value);
const durMs = beats*60000/bpm;
const c = state.progression[i];
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 gateS = durMs/1000*0.95;
c.notes.forEach(n=>playNote(n,gateS));
if (c.bass!==null) playNote(c.bass,gateS,0.9,true);
midiSendRaw(c, durMs*0.95);
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 — nappes
$("chBass").value=5; // TD-3 + K-2 unison
$("midiDev").addEventListener("change", selectMidiDevice);
/* ----- export ----- */
$("exportBtn").addEventListener("click", exportMidi);
/* ----- init ----- */
buildKeyboard();
renderProgression();
refreshKeyDimming();
setVoicing(0);
midiInit();
</script>
</body>
</html>