Files
chordlab/files/chordlab.html
T
2026-07-16 17:17:03 +02:00

959 lines
35 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 — 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>