959 lines
35 KiB
HTML
959 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>_chordlab — 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 · ␣ = 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>
|