Files
chordlab/index.html
T
2026-07-21 07:03:39 +02:00

1474 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>_chordlab v2 — générateur d'accords</title>
<style>
:root{
--panel:#E0D6C3;
--panel-edge:#C9BCA4;
--module:#26231D;
--module-2:#332F27;
--silk:#403A2F;
--silk-light:#D8CFBB;
--lcd-bg:#141A12;
--lcd-amber:#FFB454;
--lcd-dim:#7A6A3F;
--led-on:#FFAE33;
--led-off:#4A4436;
--led-red:#E5482B;
--led-green:#8BC34A;
--key-w:#EFE9DB;
--key-b:#1C1A16;
--accent:#E5482B;
--mono:ui-monospace,"SF Mono","Cascadia Mono","JetBrains Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html{font-size:15px}
body{
font-family:var(--mono);
background:#17150F;
color:var(--silk);
min-height:100vh;
display:flex;justify-content:center;
padding:1.2rem;
}
.unit{
width:100%;max-width:1120px;
background:linear-gradient(178deg,var(--panel) 0%,#D6CBB5 100%);
border-radius:14px;
border:1px solid var(--panel-edge);
box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.5);
padding:1.4rem 1.6rem 1.6rem;
}
header{
display:flex;align-items:baseline;justify-content:space-between;
border-bottom:2px solid var(--silk);
padding-bottom:.6rem;margin-bottom:1rem;
}
header h1{font-size:1.15rem;font-weight:800;letter-spacing:.06em}
header h1 span{color:var(--accent)}
header .sub{font-size:.62rem;letter-spacing:.18em;text-transform:uppercase;opacity:.7}
header .hdr-right{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;justify-content:flex-end}
header .seg .segbtn{padding:.2rem .45rem}
/* ---------- 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;letter-spacing:.08em}
.kbd-foot{display:flex;align-items:center;justify-content:space-between;gap:.6rem;margin-top:.5rem;flex-wrap:wrap}
.seg{display:inline-flex;border:1px solid #4A4436;border-radius:6px;overflow:hidden;flex:none}
.segbtn{
background:var(--module-2);border:none;
color:var(--silk-light);font-family:var(--mono);
font-size:.55rem;letter-spacing:.1em;
padding:.28rem .5rem;cursor:pointer;opacity:.5;
}
.segbtn + .segbtn{border-left:1px solid #4A4436}
.segbtn.on{background:#4A4436;color:var(--led-on);opacity:1}
.segbtn:focus-visible{outline:2px solid var(--led-on);outline-offset:-2px}
/* ---------- 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}
footer .doclink{
color:var(--silk);text-decoration:none;
border-bottom:1px solid rgba(64,58,47,.45);
padding-bottom:1px;
}
footer .doclink:hover,footer .doclink:focus-visible{
color:var(--accent);border-bottom-color:var(--accent);outline:none;
}
footer .docsep{margin:0 .35rem;opacity:.5}
@media (max-width:900px){
.deck{grid-template-columns:1fr}
.io{grid-template-columns:1fr}
.kbd{min-height:140px}
}
@media (prefers-reduced-motion:reduce){
*{transition:none !important}
}
</style>
</head>
<body>
<div class="unit">
<header>
<h1><span>_chordlab·v2</span></h1>
<div class="hdr-right">
<span class="sub" id="tagline">chord ideas machine · underscorelab / leradiologue</span>
<span class="seg" role="group" aria-label="Language">
<button class="segbtn on" id="langFr" aria-pressed="true">FR</button><button class="segbtn" id="langEn" aria-pressed="false">EN</button>
</span>
</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-foot">
<span class="kbd-hint" id="kbdHint">clavier PC : A W S E D F T G Y H U J · ␣ = ADD · ← → = voicing</span>
<span class="seg" role="group" aria-label="Disposition du clavier">
<button class="segbtn" id="layQwerty" aria-pressed="false">QWERTY</button><button class="segbtn on" id="layAzerty" aria-pressed="true">AZERTY</button>
</span>
</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>
<a href="tuto.html" class="doclink" id="doclinkFr">Manuel (FR)</a>
<span class="docsep">·</span>
<a href="tuto_en.html" class="doclink" id="doclinkEn">Manual (EN)</a>
<span class="docsep">·</span>
_chordlab v2.5 · underscorelab / leradiologue
</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,
layout:(navigator.language||"").toLowerCase().startsWith("fr") ? "azerty" : "qwerty",
lang:(navigator.language||"").toLowerCase().startsWith("fr") ? "fr" : "en",
};
/* ---------- 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 notes réelles de l'accord.
Travaille sur les notes construites (avant voicing), donc valable
aussi bien en mode libre qu'en Key Mode. */
function slashIntervalFrom(rawNotes, which){
const root = rawNotes[0];
const rel = rawNotes.map(n => (n - root) % 12).map(i => i<0 ? i+12 : i);
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 bassFromRaw(rawNotes, rootPc){
const iv = slashIntervalFrom(rawNotes, 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 = bassFromRaw(raw, rootPc);
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);
let label = d.name;
let bass = null;
if (state.bass){
const b = bassFromRaw(d.notes, pc);
bass = b.midi;
if (b.iv !== 0) label += "/" + NOTE_NAMES[(pc + b.iv) % 12];
}
state.progression.push({
label, notes, bass, 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 = t("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 = t("Web MIDI prêt.");
}).catch(()=>{ $("midiStatus").textContent = t("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 = t("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=t("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,
layout:state.layout, lang:state.lang,
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=t("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.layout=(s.layout==="qwerty")?"qwerty":(s.layout==="azerty"?"azerty":state.layout);
state.lang=(s.lang==="en")?"en":(s.lang==="fr"?"fr":state.lang);
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=t("Session chargée.");
}catch(e){ $("sessionStatus").textContent=t("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);
setLayout(state.layout);
$("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); applyLang();
}
/* =========================================================
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=t("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");
});
/* Dispositions clavier : mêmes touches physiques, lettres différentes.
Sur AZERTY, A→Q et W→Z ; le reste de la rangée est identique. */
const KEYMAPS = {
qwerty:{a:0,w:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11},
azerty:{q:0,z:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11},
};
const KEYHINT = {
qwerty:"clavier PC : A W S E D F T G Y H U J · ␣ = ADD · ← → = voicing",
azerty:"clavier PC : Q Z S E D F T G Y H U J · ␣ = ADD · ← → = voicing",
};
function pcKeys(){ return KEYMAPS[state.layout]; }
function setLayout(l, silent){
state.layout = (l==="qwerty") ? "qwerty" : "azerty";
const isQ = state.layout==="qwerty";
$("layQwerty").classList.toggle("on", isQ);
$("layAzerty").classList.toggle("on", !isQ);
$("layQwerty").setAttribute("aria-pressed", isQ);
$("layAzerty").setAttribute("aria-pressed", !isQ);
$("kbdHint").textContent = (state.lang==="en"?KEYHINT_EN:KEYHINT)[state.layout];
}
window.addEventListener("keydown",ev=>{
if (ev.repeat||ev.target.tagName==="INPUT"||ev.target.tagName==="SELECT") return;
const k=ev.key.toLowerCase();
const map=pcKeys();
if (k in map){
keyEls[map[k]].classList.add("pressed");
triggerChord(map[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();
const map=pcKeys();
if (k in map) keyEls[map[k]].classList.remove("pressed");
});
$("layQwerty").addEventListener("click",()=>setLayout("qwerty"));
$("layAzerty").addEventListener("click",()=>setLayout("azerty"));
/* 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=t("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=t("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="${t('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="${t('déplacer à gauche')}"></button>
<button class="mv bminus" title="${t('1 temps')}"></button>
<span class="badge" title="${t('durée en temps (1 à 16)')}">${c.beats}♩</span>
<button class="mv bplus" title="${t('+1 temps')}">+</button>
<button class="mv sr" title="${t('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;
let label=dc.name, bass=null;
if (state.bass){
const b=bassFromRaw(dc.notes,pc);
bass=b.midi;
if (b.iv!==0) label+="/"+NOTE_NAMES[(pc+b.iv)%12];
}
state.progression.push({label,notes,bass,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="";
});
/* =========================================================
I18N — le français est la version de référence dans le HTML.
Le dictionnaire ci-dessous ne décrit que l'anglais ; les textes
français d'origine sont capturés au chargement et restaurés
quand on repasse en FR.
========================================================= */
const EN = {
txt:{ // textContent
"#tagline":"chord ideas machine · underscorelab / leradiologue",
"#lcdNotes":"play a note",
"#midiStatus":"Web MIDI: waiting…",
"#presetLoad":"load", "#progClear":"clear",
"#suggestBtn":"✦ suggest", "#optimizeBtn":"⇄ voice-leading",
"#exportBtn":"↓ export .mid",
"#saveJson":"↓ save session (.json)", "#loadJson":"↑ load a session",
"#doclinkFr":"Manual (FR)", "#doclinkEn":"Manual (EN)",
},
h2:{ "Chord type":"Chord type", "Root":"Root", "Voicing":"Voicing",
"Progression":"Progression", "MIDI out (live)":"MIDI out (live)",
"Export MIDI":"MIDI export", "Session":"Session" },
rowlabel:{ "Tensions":"Tensions", "Altérations":"Alterations",
"combos : dim+maj=aug · min+sus=sus2":"" },
label:{ "drop":"drop", "basse /":"bass /", "gamme":"scale",
"périphérique":"device", "canal accords":"chord channel",
"canal basse":"bass channel", "pistes":"tracks", "perf rendue":"render perf" },
option:{ "fondamentale":"root", "tierce":"third", "quinte":"fifth", "septième":"seventh",
"2 oct":"2 oct", "1 oct":"1 oct",
"majeur":"major", "mineur nat.":"nat. minor", "mineur harm.":"harm. minor",
"dorien":"dorian", "phrygien":"phrygian", "lydien":"lydian",
"mixolydien":"mixolydian", "locrien":"locrian",
"— preset (tonalité KEY) —":"— preset (KEY tonality) —",
"— désactivé —":"— off —",
"accords + basse (2 pistes)":"chords + bass (2 tracks)",
"accords seuls":"chords only",
"oui (strum/arp dans le .mid)":"yes (strum/arp in the .mid)",
"non (accords plaqués)":"no (block chords)" },
title:{ "suggère l'accord suivant (harmonie fonctionnelle)":"suggest the next chord (functional harmony)",
"re-voice toute la progression pour minimiser les mouvements":"re-voice the whole progression to minimise movement",
"applique le voice-leading automatiquement à chaque ADD":"apply voice leading automatically on every ADD",
"humanise vélocité et timing":"humanise velocity and timing",
"Octave basse":"Bass octave" },
small:{ "BPM":"BPM", "perf":"perf", "rate":"rate", "durée/accord":"length/chord" },
/* chaînes générées par le code */
run:{
"play a note":"play a note",
"hors tonalité":"out of key",
"Web MIDI non supporté (Chrome/Edge/Firefox).":"Web MIDI not supported (use Chrome/Edge/Firefox).",
"Web MIDI prêt.":"Web MIDI ready.",
"Accès MIDI refusé.":"MIDI access denied.",
"Progression vide.":"Progression is empty.",
"Session sauvée.":"Session saved.",
"Session chargée.":"Session loaded.",
"Fichier invalide.":"Invalid file.",
"Voice-leading appliqué.":"Voice leading applied.",
"vide — joue un accord puis ADD, ou ✦ suggérer":"empty — play a chord then ADD, or ✦ suggest",
"supprimer":"delete",
"déplacer à gauche":"move left",
"déplacer à droite":"move right",
"1 temps":"1 beat",
"+1 temps":"+1 beat",
"durée en temps (1 à 16)":"length in beats (1 to 16)",
"Exporté : ":"Exported: ",
},
};
const KEYHINT_EN = {
qwerty:"computer keys: A W S E D F T G Y H U J · ␣ = ADD · ← → = voicing",
azerty:"computer keys: Q Z S E D F T G Y H U J · ␣ = ADD · ← → = voicing",
};
/* traduit une chaîne runtime selon la langue courante */
function t(s){ return state.lang==="en" ? (EN.run[s] ?? s) : s; }
const FR_BASE = new Map(); // élément → texte français d'origine
function rememberFr(el, kind, val){
const k = el.dataset.i18nKey || (el.dataset.i18nKey = "k"+FR_BASE.size);
if (!FR_BASE.has(k)) FR_BASE.set(k, {});
FR_BASE.get(k)[kind] = val;
}
function frOf(el, kind){ return FR_BASE.get(el.dataset.i18nKey)?.[kind]; }
function applyLang(){
const en = state.lang==="en";
document.documentElement.lang = en ? "en" : "fr";
// textes simples ciblés par sélecteur
Object.entries(EN.txt).forEach(([sel, val])=>{
if (val===null) return;
const el = document.querySelector(sel);
if (!el) return;
rememberFr(el,"txt",frOf(el,"txt") ?? el.textContent);
el.textContent = en ? val : frOf(el,"txt");
});
// titres de modules, row-labels, labels, options, titles
const swap = (nodes, dict, kind, get, set)=>{
nodes.forEach(el=>{
const cur = get(el);
const fr = frOf(el,kind) ?? cur;
rememberFr(el,kind,fr);
const v = en ? (dict[fr] !== undefined ? dict[fr] : fr) : fr;
set(el, v);
});
};
const T = el=>el.textContent, S=(el,v)=>{el.textContent=v;};
swap([...document.querySelectorAll(".mod h2")], EN.h2, "h2", T, S);
swap([...document.querySelectorAll(".row-label")], EN.rowlabel, "rl", T, S);
swap([...document.querySelectorAll(".field label, .io-line label")], EN.label, "lb", T, S);
swap([...document.querySelectorAll("option")], EN.option, "op", T, S);
swap([...document.querySelectorAll(".bpm-wrap")], EN.small, "sm",
el=>el.childNodes[0].textContent.trim(),
(el,v)=>{ el.childNodes[0].textContent = v+" "; });
swap([...document.querySelectorAll("[title]")].filter(el=>!el.closest(".slot")), EN.title, "ti",
el=>el.getAttribute("title"), (el,v)=>el.setAttribute("title",v));
// aide clavier + LCD au repos
$("kbdHint").textContent = en ? KEYHINT_EN[state.layout] : KEYHINT[state.layout];
if (state.lastRoot===null) $("lcdNotes").textContent = en ? "play a note" : "joue une note";
// boutons de langue
$("langFr").classList.toggle("on", !en);
$("langEn").classList.toggle("on", en);
$("langFr").setAttribute("aria-pressed", !en);
$("langEn").setAttribute("aria-pressed", en);
renderProgression();
}
$("langFr").addEventListener("click",()=>{ state.lang="fr"; applyLang(); });
$("langEn").addEventListener("click",()=>{ state.lang="en"; applyLang(); });
/* init */
buildKeyboard();
setLayout(state.layout);
renderProgression();
refreshKeyDimming();
setVoicing(0,true);
applyLang();
midiInit();
</script>
</body>
</html>