Chordlab V2.0 - initial

This commit is contained in:
lrdlg
2026-07-16 14:37:03 +02:00
commit 5d227c8ac9
7 changed files with 2728 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
# _chordlab ORC·v2
Générateur d'accords et de progressions harmoniques dans le navigateur, inspiré de la logique du **Telepathic Instruments Orchid ORC-1** (implémentation indépendante, étendue). Un seul fichier HTML, zéro dépendance, zéro backend.
> Construis un accord avec un doigt, façonne son voicing, chaîne une progression, envoie-la en MIDI live vers ton studio dawless ou exporte-la en `.mid` pour ton DAW.
---
## Fonctionnalités
**Construction d'accords**
- Clavier une octave pour la fondamentale (souris, tactile ou clavier PC)
- 8 types d'accords : Dim · Min · Maj · Aug · Sus2 · Sus4 · 5 (power chord) · m♭6
- 12 modificateurs empilables : 6 · m7 · M7 · 9 · ♭9 · ♯9 · 11 · ♯11 · 13 · ♭5 · ♯5 · no3
- Nommage automatique (Cm7, Cdim7, Cø7, C7(♭9)…) affiché sur le LCD
**Voicing**
- Knob de voicing rotatif ±8 crans (inversions en cascade, une note à la fois)
- Drop voicings : close · drop 2 · drop 3 · drop 2+4 · spread
- Accords slash : basse sur la fondamentale, la tierce, la quinte ou la septième
- Moteur basse séparé (1 ou 2 octaves)
**Tonalité et intelligence harmonique**
- Key Mode : accords diatoniques à un doigt dans 8 gammes (majeur, mineur naturel/harmonique, dorien, phrygien, lydien, mixolydien, locrien)
- Voice-leading automatique (VL AUTO) et optimisation globale de la progression
- Suggestion d'accord suivant par table d'harmonie fonctionnelle
- 8 presets de cadences générés dans la tonalité choisie
**Progression et lecture**
- Rack de slots : durée individuelle (1/2/4/8 temps), réordonnancement, audition au clic
- Modes de performance : block · strum ↑↓ · harp · arp ↑/↓/↕/random (1/8 à 1/32)
- Humanisation vélocité/timing
**Entrées / sorties**
- **Web MIDI out live** : accords et basse sur deux canaux séparés, vers n'importe quelle interface USB-MIDI
- **Export `.mid`** : format 1, PPQ 480, tempo inclus, performance rendue en option
- **Session `.json`** : sauvegarde/chargement complet (réglages + progression)
---
## Démarrage rapide
1. Ouvrir `index.html` dans **Chrome, Edge ou Firefox** (Safari ne supporte pas Web MIDI).
2. Cliquer une note du clavier → l'accord sonne et s'affiche sur le LCD.
3. `ADD` (ou barre espace) pour l'empiler dans la progression, `PLAY` pour l'écouter.
4. Pour le MIDI live : brancher une interface USB-MIDI, autoriser l'accès MIDI, sélectionner le périphérique dans le panneau **MIDI out**.
> ⚠️ Web MIDI exige un **contexte sécurisé** : `https://` ou `http://localhost`. Un fichier ouvert en `file://` fonctionne pour l'audio et l'export, mais pas pour la sortie MIDI live selon les navigateurs. D'où l'intérêt de l'héberger (voir tutoriel de déploiement).
---
## Canaux MIDI par défaut
| Rôle | Canal | Machine cible (setup de référence) |
|---|---|---|
| Accords | 3 | Behringer Pro-800 (nappes) |
| Basse | 5 | TD-3 + K-2 en unisson |
Modifiables dans le panneau **MIDI out (live)**. L'export `.mid` reprend les mêmes canaux.
---
## Structure du dépôt
```
chordlab/
├── index.html # l'application complète
├── README.md
└── docs/
├── TUTORIEL_UTILISATION.md # guide utilisateur complet
└── TUTORIEL_DEPLOIEMENT_COOLIFY.md # mise en ligne + mises à jour
```
---
## Compatibilité
| Navigateur | Audio | MIDI live | Export .mid |
|---|---|---|---|
| Chrome / Edge | ✅ | ✅ | ✅ |
| Firefox | ✅ | ✅ | ✅ |
| Safari | ✅ | ❌ (pas de Web MIDI) | ✅ |
---
## Notes de conception
- Le moteur de voicing reproduit le principe du dial de l'Orchid : chaque cran fait une **rotation** de l'accord (la note la plus grave remonte d'une octave, ou l'inverse), sur ±8 crans.
- Les combinaisons classiques tombent naturellement : `DIM + 6 = dim7`, `DIM + m7 = ø7` (demi-diminué), `MAJ + m7 + ♯9 = accord Hendrix`.
- Empiler `m7 + M7` affiche `WTF?` sur le LCD — clin d'œil assumé à l'Orchid.
- Le voice-leading minimise la somme des déplacements de voix entre deux accords en testant les rotations de voicing candidates.
- Aucune donnée n'est stockée dans le navigateur : la persistance passe exclusivement par les fichiers `.json` de session.
## Licence & crédit
Projet personnel underscorelab / leradiologue. La logique d'accords s'inspire du concept de l'Orchid ORC-1 de Telepathic Instruments, sans réutiliser ni code, ni sons, ni assets de l'instrument original.
+170
View File
@@ -0,0 +1,170 @@
# _chordlab — Mise en ligne avec Coolify
Déploiement sur l'infrastructure ghostinthemachine.fr, **sur le même modèle que le site des vacances** (`sudouest.ghostinthemachine.fr`) : dépôt Gitea → ressource Coolify en build pack **Static** → domaine servi par Traefik avec le certificat wildcard existant.
Cible retenue dans ce tutoriel : `https://chordlab.ghostinthemachine.fr`
> **Pourquoi héberger une app qui tient dans un fichier ?** Parce que **Web MIDI exige un contexte sécurisé** (`https://` ou `localhost`). En `file://`, la sortie MIDI live vers le hardware est bloquée par le navigateur. Une fois sur ton domaine en HTTPS, tout fonctionne — y compris depuis le portable posé à côté du studio.
---
## 0. Prérequis (déjà en place sur ton infra)
- Serveur Scaleway avec **Coolify 4.x** et **Traefik** opérationnels
- **Gitea** self-hosted accessible
- Wildcard DNS `*.ghostinthemachine.fr` pointant vers le serveur (Cloudflare)
- Certificat wildcard géré par acme.sh/Cloudflare — rien à faire côté SSL
[PROBABLE] Les libellés exacts de l'interface Coolify peuvent varier légèrement selon la version ; le déroulé ci-dessous correspond au flux validé lors du déploiement du site des vacances.
---
## 1. Préparer le dépôt Gitea
### 1.1 Créer le dépôt
Dans Gitea : **+ → Nouveau dépôt** → nom `chordlab`. Comme pour le site des vacances, le contenu n'a rien de sensible : **le plus simple est de le créer public** (voir §2.2 pour la raison précise).
### 1.2 Structurer et pousser
```bash
mkdir chordlab && cd chordlab
git init
# Le fichier de l'app DOIT s'appeler index.html
# (le serveur statique sert index.html à la racine du domaine)
cp /chemin/vers/chordlab_v2.html index.html
# Documentation
cp /chemin/vers/README.md .
mkdir docs
cp /chemin/vers/TUTORIEL_UTILISATION.md docs/
cp /chemin/vers/TUTORIEL_DEPLOIEMENT_COOLIFY.md docs/
git add .
git commit -m "chordlab v2 — initial"
git remote add origin https://git.ghostinthemachine.fr/matthieu/chordlab.git
git push -u origin main
```
> Adapte l'URL du remote à ton instance Gitea réelle et à ton utilisateur.
---
## 2. Créer la ressource dans Coolify
### 2.1 Nouvelle ressource
1. Coolify → ton projet → **+ New → Resource**.
2. Choisir **Public Repository** (dépôt public) et coller l'URL HTTPS du dépôt Gitea :
`https://git.ghostinthemachine.fr/matthieu/chordlab.git`
3. Branche : `main`.
4. **Build Pack : Static** — c'est le point clé. Pas de Dockerfile, pas de Nixpacks : Coolify sert simplement les fichiers via un conteneur nginx.
5. **Publish Directory : `/`** (l'`index.html` est à la racine du dépôt).
### 2.2 ⚠️ Le piège du dépôt privé (leçon du site des vacances)
Si le dépôt Gitea est **privé** et que la ressource est de type « Public Repository », le déploiement échoue avec :
```
exit code: 128
fatal: could not read Username for 'https://git.ghostinthemachine.fr'
```
Coolify clone sans credentials, Gitea refuse. Deux solutions, exactement comme la dernière fois :
| Option | Quand | Comment |
|---|---|---|
| **A. Rendre le dépôt public** | contenu non sensible (notre cas) | Gitea → dépôt → Paramètres → cocher « Visibilité : public ». Redéployer. C'est la solution retenue pour le site des vacances. |
| **B. Deploy Key SSH** | si tu tiens au dépôt privé | Coolify → ressource → Source → générer une clé de déploiement → l'ajouter dans Gitea (dépôt → Paramètres → Clés de déploiement) → utiliser l'URL **SSH** du dépôt (`git@git.ghostinthemachine.fr:matthieu/chordlab.git`) |
### 2.3 Domaine
Dans la ressource → **Domains** :
```
https://chordlab.ghostinthemachine.fr
```
- Le wildcard DNS Cloudflare couvre déjà `chordlab.ghostinthemachine.fr` → aucune entrée DNS à créer.
- Bien saisir le domaine **avec `https://`** : c'est ce qui pilote la génération des labels Traefik pour la redirection et le TLS.
### 2.4 Déployer
Bouton **Deploy**. Le log doit montrer : clone du dépôt → build statique → conteneur démarré. Première mise en ligne : généralement moins d'une minute.
---
## 3. Vérifications post-déploiement
1. **`https://chordlab.ghostinthemachine.fr`** répond avec l'interface (face avant beige, LCD).
2. **Cadenas SSL** valide (certificat wildcard).
3. **Test Web MIDI** : interface USB-MIDI branchée → recharger la page → le navigateur demande l'autorisation MIDI → le périphérique apparaît dans le panneau *MIDI out (live)* et le LCD affiche `MIDI ON`. Si l'autorisation n'est jamais demandée, vérifier que l'URL est bien en `https://` (c'était le même principe que le micro pour LibreChat : pas de périphérique sans contexte sécurisé).
4. **Test export** : deux accords → ADD → `↓ exporter .mid` → le fichier se télécharge.
---
## 4. Mise à jour de l'application
Même mécanique que pour le site des vacances : **le dépôt Git est la source de vérité**, Coolify redéploie depuis le dernier commit.
### 4.1 Mise à jour manuelle (workflow de base)
```bash
cd chordlab
# remplacer index.html par la nouvelle version de l'app
cp /chemin/vers/chordlab_v2.html index.html
git add index.html
git commit -m "v2.1 — <description du changement>"
git push
```
Puis dans Coolify → ressource `chordlab`**Redeploy**. Le conteneur est reconstruit avec les nouveaux fichiers ; interruption de service quasi nulle.
### 4.2 Déploiement automatique au push (optionnel)
Pour supprimer l'étape manuelle :
1. Coolify → ressource → **Webhooks** → copier l'URL du webhook de déploiement.
2. Gitea → dépôt `chordlab`**Paramètres → Webhooks → Ajouter un webhook → Gitea**.
3. URL cible : l'URL Coolify copiée · Événement : **Push** · branche `main`.
4. Tester avec « Test Delivery », puis faire un commit de contrôle : chaque `git push` déclenche désormais le redéploiement.
### 4.3 Revenir en arrière
Deux options :
- **Git** : `git revert <commit>` puis push (propre, historisé — à préférer) ;
- **Coolify** : la liste des déploiements permet de relancer une image précédente. [INCERTAIN] La disponibilité du rollback en un clic dépend de la version de Coolify ; le revert Git fonctionne dans tous les cas.
---
## 5. Dépannage
| Symptôme | Cause probable | Correction |
|---|---|---|
| `exit code: 128 — could not read Username` | dépôt privé cloné sans credentials | §2.2 : dépôt public ou Deploy Key SSH |
| 404 Traefik sur le domaine | domaine mal saisi dans la ressource, ou routeur pas régénéré | vérifier Domains (avec `https://`), redéployer ; en dernier recours contrôler les labels du conteneur (`docker inspect`) |
| La page charge mais pas de demande d'autorisation MIDI | contexte non sécurisé ou navigateur Safari | vérifier `https://`, utiliser Chrome/Edge/Firefox |
| Ancienne version affichée après redéploiement | cache navigateur (fichier HTML unique) | rechargement forcé (Ctrl+Shift+R) ; si récurrent, ajouter un query string versionné dans les liens partagés (`?v=2.1`) |
| Le conteneur tourne mais rien ne répond | l'app n'est pas nommée `index.html` à la racine, ou Publish Directory incorrect | renommer le fichier / corriger le Publish Directory, redéployer |
| Périphérique MIDI absent de la liste | interface branchée après le chargement | recharger la page (l'app écoute les changements, mais certains OS n'exposent le port qu'à la connexion) |
---
## 6. Récapitulatif du modèle
```
[poste local] [serveur ghostinthemachine.fr]
index.html ──git push──▶ Gitea ──clone──▶ Coolify (Static build pack)
conteneur nginx statique
Traefik + wildcard SSL
https://chordlab.ghostinthemachine.fr
```
Un fichier, un push, un redeploy. Le même modèle resservira pour toute page ou micro-app statique à venir.
+236
View File
@@ -0,0 +1,236 @@
# _chordlab v2 — Tutoriel complet d'utilisation
Ce guide couvre l'intégralité de l'application, du premier accord jusqu'aux workflows dawless et DAW. Comptez 10 minutes pour tout lire, 2 minutes pour être opérationnel.
---
## 1. Vue d'ensemble de l'interface
L'écran est organisé comme une face avant de machine hardware :
```
┌─────────────────────────────────────────────────────┐
│ LCD — nom de l'accord, notes, état des modes │
├──────────────┬──────────────────────┬───────────────┤
│ CHORD TYPE │ ROOT │ VOICING │
│ TENSIONS │ (clavier 1 oct.) │ drop/slash │
│ ALTÉRATIONS │ │ BASS / KEY │
├──────────────┴──────────────────────┴───────────────┤
│ PROGRESSION — slots, transport, perf, BPM │
├──────────────────┬──────────────────┬───────────────┤
│ MIDI OUT LIVE │ EXPORT MIDI │ SESSION │
└──────────────────┴──────────────────┴───────────────┘
```
Le **LCD** est votre référence permanente : il affiche le nom de l'accord courant, ses notes réelles (avec octaves), et l'état des modes (voicing, drop, key, perf, MIDI, voice-leading).
---
## 2. Jouer un accord
Le principe fondamental, hérité de l'Orchid : **vous ne jouez jamais les notes de l'accord, vous le décrivez**.
1. **Type** — un seul actif à la fois (LED allumée). MAJ est sélectionné au démarrage.
2. **Fondamentale** — cliquez une touche du clavier, ou utilisez le clavier PC : `A W S E D F T G Y H U J` = C C# D D# E F F# G G# A A# B.
3. L'accord sonne immédiatement et s'affiche sur le LCD.
Les huit types :
| Bouton | Accord | Intervalles |
|---|---|---|
| DIM | diminué | 1 · ♭3 · ♭5 |
| MIN | mineur | 1 · ♭3 · 5 |
| MAJ | majeur | 1 · 3 · 5 |
| AUG | augmenté | 1 · 3 · ♯5 |
| SUS2 | suspendu 2 | 1 · 2 · 5 |
| SUS4 | suspendu 4 | 1 · 4 · 5 |
| 5 | power chord | 1 · 5 |
| m♭6 | mineur sixte bémol | 1 · ♭3 · ♭6 |
---
## 3. Tensions et altérations
Les deux rangées inférieures s'**empilent** librement sur le type (plusieurs LEDs actives simultanément).
**Tensions** — elles *ajoutent* des notes :
| Bouton | Ajoute | Exemple avec MAJ + C |
|---|---|---|
| 6 | sixte majeure | C6 |
| m7 | septième mineure | C7 |
| M7 | septième majeure | Cmaj7 |
| 9 | neuvième | Cadd9 (ou C7(9) si une 7e est là) |
| ♭9 / ♯9 | neuvièmes altérées | C7(♭9), C7(♯9) « Hendrix » |
| 11 / ♯11 | onzièmes | C7(11), Cmaj7(♯11) |
| 13 | treizième | C7(13) |
**Altérations** — elles *modifient ou retirent* des notes :
| Bouton | Effet |
|---|---|
| ♭5 | remplace la quinte juste par une quinte diminuée |
| ♯5 | remplace la quinte juste par une quinte augmentée |
| no3 | retire la tierce (accords ouverts, parfaits pour l'ambient) |
**Combinaisons remarquables** :
- `DIM + 6` → accord de **septième diminuée** (Cdim7)
- `DIM + m7`**demi-diminué** (Cø7)
- `MIN + m7 + 9` → le m9 des nappes deep
- `m7 + M7` ensemble → le LCD affiche `WTF?`. Vous êtes prévenu.
---
## 4. Le voicing
C'est le cœur expressif de la machine. Trois outils indépendants qui se cumulent :
### 4.1 Le knob rotatif (±8)
Chaque cran fait une **rotation** de l'accord : à +1, la note la plus grave remonte d'une octave ; à 1, la plus aiguë descend. Exemple sur Cmaj7 :
```
0 : C4 E4 G4 B4
+1 : E4 G4 B4 C5
+2 : G4 B4 C5 E5
1 : B3 C4 E4 G4
```
Manipulation : glisser verticalement sur le knob, boutons /0/+, flèches ←/→ du clavier, ou double-clic pour revenir à 0. Le même accord à +3 et à 2 raconte deux histoires complètement différentes — c'est l'outil n°1 pour trouver *la* couleur.
### 4.2 Drop voicings
Appliqués après la rotation, ils écartent les voix comme le ferait un pianiste ou un arrangeur :
| Mode | Effet | Usage typique |
|---|---|---|
| close | rien (position serrée) | défaut |
| drop 2 | 2e voix depuis le haut → 1 octave | le voicing jazz standard |
| drop 3 | 3e voix depuis le haut → 1 octave | grands écarts, guitare |
| drop 2+4 | deux voix descendues | très ouvert, cinématique |
| spread | une voix sur deux → +1 octave | nappes larges, ambient |
### 4.3 Accords slash
Le sélecteur **basse /** choisit quelle note de l'accord la basse joue : fondamentale, tierce, quinte ou septième. Le LCD affiche alors `C/E`, `C/G`… La détection est intelligente : sur un accord mineur elle prend la tierce mineure, sur un ♭5 la quinte diminuée.
### 4.4 BASS
Le bouton **BASS** active le moteur basse (1 ou 2 octaves). En MIDI live et à l'export, la basse sort sur son propre canal — c'est elle qui pilote ta TD-3 pendant que les accords vont au Pro-800.
---
## 5. Key Mode — accords diatoniques à un doigt
Activez **KEY**, choisissez une tonique et une gamme parmi les huit disponibles. À partir de là :
- Chaque touche du clavier génère automatiquement **l'accord diatonique du degré correspondant** (en Do majeur : C→C, D→Dm, E→Em, F→F, G→G, A→Am, B→Bdim).
- Les touches hors tonalité sont **grisées** et n'émettent rien (le LCD affiche `hors tonalité`).
- Les tensions 6/7/9/11/13 restent actives et sont harmonisées **dans la gamme** (la 7e de G en Do majeur sera F naturel → G7, pas Gmaj7). Un astérisque dans le nom (`G7*`) signale un accord harmonisé diatoniquement.
Le mineur harmonique vaut le détour : le degré V y devient un vrai accord de dominante avec sensible — la cadence mineure classique à un doigt.
---
## 6. Construire une progression
### 6.1 Manuellement
Jouez un accord, puis **ADD** (ou barre espace). Il apparaît dans un slot avec son nom, ses notes et sa durée.
Sur chaque slot :
- **badge durée** (`4♩`) — cliquer fait tourner 4 → 8 → 2 → 1 temps
- ** ** — déplacer le slot dans la séquence
- **×** — supprimer
- **clic sur le slot** — l'auditionner seul
### 6.2 Avec les presets
Le menu déroulant propose huit cadences classiques (IVviIV, iiVI, cycle des quintes…). **Charger** les génère dans la tonalité et la gamme sélectionnées, avec vos tensions et votre voicing courants. C'est le moyen le plus rapide d'avoir une base à triturer.
### 6.3 Avec la suggestion harmonique
**✦ suggérer** ajoute un accord choisi par une table d'harmonie fonctionnelle (après un I : IV, V, vi… ; après un ii : V ; etc.), dans la tonalité courante. Enchaînez les clics pour laisser la machine dériver, reprenez la main quand ça devient intéressant.
### 6.4 Voice-leading
Deux outils pour des enchaînements fluides :
- **VL AUTO** (transport) — chaque accord ajouté est automatiquement re-voicé pour minimiser le mouvement des voix par rapport au précédent.
- **⇄ voice-leading** — optimise d'un coup toute la progression existante. À utiliser après un chargement de preset : la différence s'entend immédiatement.
---
## 7. Lecture et modes de performance
**PLAY** lance la progression au BPM affiché ; **LOOP** la fait boucler ; le slot en cours s'illumine.
Le sélecteur **perf** transforme la restitution de chaque accord :
| Mode | Rendu |
|---|---|
| block | accord plaqué |
| strum ↑ / ↓ | notes égrenées vers le haut / le bas (façon guitare) |
| harp | balayage rapide (glissando) |
| arp ↑ / ↓ / ↕ / rnd | arpège à la vitesse du sélecteur **rate** (1/8, 1/16, 1/32) |
**HUM** humanise vélocités (±10) et timing (±15 ms) — utile pour que les strums et arps respirent.
> Ces modes s'appliquent partout : à l'audition, en MIDI live, et **dans l'export .mid** (désactivable, voir §9).
---
## 8. MIDI live vers le hardware
1. Branchez votre interface USB-MIDI **avant** d'ouvrir la page (ou rechargez-la après).
2. Le navigateur demande l'autorisation d'accès MIDI — acceptez.
3. Panneau **MIDI out (live)** : sélectionnez le périphérique.
4. Vérifiez les canaux : **accords ch3** (Pro-800), **basse ch5** (TD-3/K-2) par défaut — adaptez à votre routage.
5. Jouez : chaque accord cliqué, chaque slot auditionné et la lecture complète partent en MIDI, avec les modes de performance rendus.
Le LCD affiche `MIDI ON` quand un périphérique est actif. En cas de note bloquée sur une machine, **STOP** envoie un All Notes Off sur les 16 canaux.
> Contexte sécurisé requis : Web MIDI ne fonctionne qu'en `https://` ou sur `localhost`. C'est la raison principale d'héberger l'app (voir le tutoriel Coolify).
---
## 9. Export .mid
Panneau **Export MIDI** :
- **pistes** : `accords + basse` (2 pistes, chacune sur son canal) ou `accords seuls`
- **perf rendue** : `oui` écrit les strums/arps note par note dans le fichier ; `non` exporte des accords plaqués (préférable si vous voulez arpéger dans le DAW ou le Tracker)
Le fichier généré (`chordlab_Cm7-Fm7-Ab-Bb_112bpm.mid`, format 1, PPQ 480, tempo inclus) respecte la durée individuelle de chaque slot.
**Destinations typiques** :
- **DAW** — glisser-déposer sur une piste instrument, c'est tout.
- **Polyend Tracker Mini** — copier le `.mid` sur la carte SD et l'importer ; chaque piste MIDI devient une piste du pattern.
---
## 10. Sessions
**↓ sauver la session** génère un `.json` contenant tous les réglages (type, tensions, voicing, drop, slash, tonalité, perf, BPM…) et la progression complète. **↑ charger une session** restaure tout.
L'application ne stocke rien dans le navigateur : le fichier `.json` est l'unique persistance. Versionnez vos sessions dans Gitea comme n'importe quel fichier de projet.
---
## 11. Raccourcis clavier
| Touche | Action |
|---|---|
| A W S E D F T G Y H U J | fondamentales C → B |
| Espace | ADD (ajouter l'accord courant à la progression) |
| ← / → | voicing 1 / +1 |
| ↑ ↓ ← → (knob focalisé) | voicing fin |
| double-clic sur le knob | voicing = 0 |
---
## 12. Deux workflows de référence
### Workflow dawless (session hardware)
1. KEY ON, tonique et gamme du morceau.
2. Preset ou ✦ suggérer pour poser 48 accords ; ⇄ voice-leading.
3. MIDI live branché : accords ch3 → Pro-800 (nappe lente), basse ch5 → TD-3.
4. LOOP + perf `block`, BPM du morceau : la progression tourne pendant que vous sculptez les sons sur les machines et enregistrez sur le L-12.
5. Session sauvée en `.json` pour reprendre la structure plus tard.
### Workflow DAW / Tracker (composition)
1. Construction libre (KEY OFF) : types + tensions + drop voicings pour chercher des couleurs.
2. VL AUTO activé, on empile au fil de l'inspiration.
3. Export `.mid` avec `perf rendue : non` (accords propres) → import dans le DAW ou le Tracker Mini.
4. L'arpégiation et le sound design se font dans la machine de destination.
+958
View File
@@ -0,0 +1,958 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>_chordlab — générateur d'accords</title>
<style>
:root{
--panel:#E0D6C3; /* face avant beige */
--panel-edge:#C9BCA4;
--module:#26231D; /* modules charbon */
--module-2:#332F27;
--silk:#403A2F; /* sérigraphie sur beige */
--silk-light:#D8CFBB; /* sérigraphie sur charbon */
--lcd-bg:#141A12;
--lcd-amber:#FFB454;
--lcd-dim:#7A6A3F;
--led-on:#FFAE33;
--led-off:#4A4436;
--led-red:#E5482B;
--key-w:#EFE9DB;
--key-b:#1C1A16;
--accent:#E5482B;
--mono:ui-monospace,"SF Mono","Cascadia Mono","JetBrains Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html{font-size:15px}
body{
font-family:var(--mono);
background:#17150F;
color:var(--silk);
min-height:100vh;
display:flex;justify-content:center;
padding:1.2rem;
}
.unit{
width:100%;max-width:1060px;
background:linear-gradient(178deg,var(--panel) 0%,#D6CBB5 100%);
border-radius:14px;
border:1px solid var(--panel-edge);
box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.5);
padding:1.4rem 1.6rem 1.6rem;
}
header{
display:flex;align-items:baseline;justify-content:space-between;
border-bottom:2px solid var(--silk);
padding-bottom:.6rem;margin-bottom:1rem;
}
header h1{font-size:1.15rem;font-weight:800;letter-spacing:.06em}
header h1 span{color:var(--accent)}
header .sub{font-size:.62rem;letter-spacing:.18em;text-transform:uppercase;opacity:.7}
/* ---------- LCD ---------- */
.lcd{
background:var(--lcd-bg);
border-radius:8px;
border:3px solid #0B0E0A;
box-shadow:inset 0 0 24px rgba(0,0,0,.8), 0 1px 0 rgba(255,255,255,.35);
padding:.8rem 1.1rem;
display:grid;grid-template-columns:1fr auto;gap:.4rem 1rem;align-items:center;
margin-bottom:1.1rem;
min-height:86px;
}
.lcd .chord-name{
font-size:2.1rem;font-weight:700;color:var(--lcd-amber);
text-shadow:0 0 12px rgba(255,180,84,.45);
line-height:1.1;
min-height:2.4rem;
}
.lcd .chord-notes{font-size:.8rem;color:var(--lcd-dim);letter-spacing:.12em}
.lcd .lcd-right{text-align:right;font-size:.68rem;color:var(--lcd-dim);line-height:1.7}
.lcd .lcd-right b{color:var(--lcd-amber);font-weight:600}
/* ---------- Rangée principale ---------- */
.deck{
display:grid;
grid-template-columns:250px 1fr 190px;
gap:1rem;
margin-bottom:1.1rem;
}
.mod{
background:var(--module);
border-radius:10px;
padding:.8rem;
box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 2px 6px rgba(0,0,0,.25);
}
.mod h2{
font-size:.58rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--silk-light);opacity:.65;margin-bottom:.6rem;
}
.btn-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem}
.hwbtn{
background:var(--module-2);
border:1px solid #4A4436;
border-radius:7px;
color:var(--silk-light);
font-family:var(--mono);
font-size:.72rem;font-weight:600;
padding:.75rem 0 .55rem;
cursor:pointer;
display:flex;flex-direction:column;align-items:center;gap:.4rem;
transition:transform .05s;
}
.hwbtn:active{transform:translateY(1px)}
.hwbtn .led{
width:8px;height:8px;border-radius:50%;
background:var(--led-off);
box-shadow:inset 0 1px 2px rgba(0,0,0,.6);
}
.hwbtn.on .led{
background:var(--led-on);
box-shadow:0 0 8px var(--led-on), 0 0 2px var(--led-on);
}
.hwbtn:focus-visible{outline:2px solid var(--led-on);outline-offset:2px}
.row-label{font-size:.55rem;letter-spacing:.16em;color:var(--silk-light);opacity:.45;margin:.6rem 0 .35rem;text-transform:uppercase}
/* ---------- Clavier ---------- */
.kbd-mod{display:flex;flex-direction:column}
.kbd{position:relative;flex:1;min-height:170px;display:flex;border-radius:6px;overflow:hidden}
.wkey{
flex:1;background:var(--key-w);
border:1px solid #B9AE97;border-top:none;
cursor:pointer;position:relative;
display:flex;align-items:flex-end;justify-content:center;
padding-bottom:.4rem;font-size:.6rem;color:#8A8070;
}
.wkey:active,.wkey.pressed{background:#D9CFBA}
.wkey.outkey{background:#DDD6C8;color:#B5AC98}
.bkey{
position:absolute;top:0;width:9%;height:58%;
background:var(--key-b);
border-radius:0 0 4px 4px;
cursor:pointer;z-index:2;
box-shadow:0 3px 5px rgba(0,0,0,.4);
}
.bkey:active,.bkey.pressed{background:#3A362C}
.bkey.outkey{background:#4B4739}
.kbd-hint{font-size:.55rem;color:var(--silk-light);opacity:.4;margin-top:.5rem;letter-spacing:.08em}
/* ---------- Voicing / bass / key ---------- */
.side{display:flex;flex-direction:column;gap:.8rem}
.knob-wrap{display:flex;flex-direction:column;align-items:center;gap:.3rem}
.knob{width:74px;height:74px;cursor:ns-resize;touch-action:none}
.knob circle.body{fill:#3A362C;stroke:#4A4436;stroke-width:2}
.knob line.ind{stroke:var(--led-on);stroke-width:3;stroke-linecap:round}
.knob-val{font-size:.75rem;color:var(--silk-light)}
.mini-row{display:flex;gap:.4rem;align-items:center;justify-content:center}
.mini-btn{
background:var(--module-2);border:1px solid #4A4436;border-radius:5px;
color:var(--silk-light);font-family:var(--mono);font-size:.7rem;
padding:.25rem .55rem;cursor:pointer;
}
select,input[type=number]{
background:var(--module-2);border:1px solid #4A4436;border-radius:5px;
color:var(--silk-light);font-family:var(--mono);font-size:.7rem;
padding:.3rem .35rem;width:100%;
}
.toggle-line{display:flex;align-items:center;gap:.5rem;font-size:.65rem;color:var(--silk-light)}
.toggle-line .hwbtn{flex-direction:row;padding:.4rem .6rem;gap:.4rem}
/* ---------- Progression ---------- */
.prog-mod{margin-bottom:1.1rem}
.prog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem}
.prog-slots{display:flex;gap:.5rem;flex-wrap:wrap;min-height:64px}
.slot{
background:var(--module-2);
border:1px solid #4A4436;border-radius:7px;
padding:.45rem .6rem;min-width:96px;
color:var(--silk-light);cursor:pointer;position:relative;
font-size:.72rem;
}
.slot.playing{border-color:var(--led-on);box-shadow:0 0 10px rgba(255,174,51,.35)}
.slot .sname{font-weight:700;color:var(--lcd-amber);font-size:.85rem}
.slot .snotes{opacity:.55;font-size:.58rem;margin-top:.15rem}
.slot .del{
position:absolute;top:2px;right:5px;color:var(--silk-light);
opacity:.4;font-size:.7rem;cursor:pointer;background:none;border:none;font-family:var(--mono);
}
.slot .del:hover{opacity:1;color:var(--led-red)}
.slot-empty{
border:1px dashed #4A4436;border-radius:7px;min-width:96px;
display:flex;align-items:center;justify-content:center;
color:var(--silk-light);opacity:.35;font-size:.62rem;padding:.45rem .6rem;
}
.transport{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.7rem}
.transport .hwbtn{flex-direction:row;padding:.5rem .8rem;gap:.45rem}
.transport .hwbtn.rec .led{background:var(--led-off)}
.transport .hwbtn.playing .led{background:var(--led-red);box-shadow:0 0 8px var(--led-red)}
.bpm-wrap{display:flex;align-items:center;gap:.35rem;color:var(--silk-light);font-size:.68rem}
.bpm-wrap input{width:64px}
.preset-wrap{margin-left:auto;display:flex;gap:.4rem;align-items:center}
.preset-wrap select{width:auto}
/* ---------- IO ---------- */
.io{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.io-line{display:grid;grid-template-columns:auto 1fr;gap:.5rem;align-items:center;font-size:.65rem;color:var(--silk-light);margin-bottom:.45rem}
.io-line label{opacity:.6;white-space:nowrap}
.status{font-size:.6rem;color:var(--silk-light);opacity:.55;margin-top:.4rem;min-height:1em}
.export-btn{
width:100%;background:var(--accent);border:none;border-radius:7px;
color:#FFF3E4;font-family:var(--mono);font-weight:700;font-size:.8rem;
padding:.7rem;cursor:pointer;letter-spacing:.05em;margin-top:.3rem;
}
.export-btn:active{transform:translateY(1px)}
footer{margin-top:1rem;font-size:.55rem;opacity:.5;letter-spacing:.1em;text-align:right}
@media (max-width:860px){
.deck{grid-template-columns:1fr}
.io{grid-template-columns:1fr}
.kbd{min-height:140px}
}
@media (prefers-reduced-motion:reduce){
*{transition:none !important}
}
</style>
</head>
<body>
<div class="unit">
<header>
<h1>_chordlab <span>ORC</span></h1>
<div class="sub">chord ideas machine · underscorelab / leradiologue</div>
</header>
<!-- LCD -->
<div class="lcd" role="status" aria-live="polite">
<div>
<div class="chord-name" id="lcdName"></div>
<div class="chord-notes" id="lcdNotes">joue une note</div>
</div>
<div class="lcd-right">
<div>VOICING <b id="lcdVoicing">0</b></div>
<div>KEY <b id="lcdKey">OFF</b></div>
<div>MIDI <b id="lcdMidi"></b></div>
</div>
</div>
<!-- Rangée principale -->
<div class="deck">
<!-- Types + extensions -->
<div class="mod">
<h2>Chord type</h2>
<div class="btn-grid" id="typeBtns">
<button class="hwbtn" data-type="dim"><span class="led"></span>DIM</button>
<button class="hwbtn" data-type="min"><span class="led"></span>MIN</button>
<button class="hwbtn on" data-type="maj"><span class="led"></span>MAJ</button>
<button class="hwbtn" data-type="sus"><span class="led"></span>SUS</button>
</div>
<div class="row-label">Modifiers</div>
<div class="btn-grid" id="extBtns">
<button class="hwbtn" data-ext="6"><span class="led"></span>6</button>
<button class="hwbtn" data-ext="m7"><span class="led"></span>m7</button>
<button class="hwbtn" data-ext="M7"><span class="led"></span>M7</button>
<button class="hwbtn" data-ext="9"><span class="led"></span>9</button>
</div>
<div class="row-label" style="opacity:.3">combos : dim+maj=aug · min+sus=sus2</div>
</div>
<!-- Clavier -->
<div class="mod kbd-mod">
<h2>Root</h2>
<div class="kbd" id="kbd"></div>
<div class="kbd-hint">clavier PC : A W S E D F T G Y H U J &nbsp;·&nbsp; ␣ = ajouter à la progression</div>
</div>
<!-- Voicing / bass / key -->
<div class="mod side">
<h2>Voicing</h2>
<div class="knob-wrap">
<svg class="knob" id="voicingKnob" viewBox="0 0 74 74" role="slider" aria-label="Voicing" aria-valuemin="-8" aria-valuemax="8" aria-valuenow="0" tabindex="0">
<circle class="body" cx="37" cy="37" r="33"/>
<line class="ind" id="knobInd" x1="37" y1="37" x2="37" y2="10"/>
</svg>
<div class="knob-val" id="knobVal">0</div>
<div class="mini-row">
<button class="mini-btn" id="voicDown"></button>
<button class="mini-btn" id="voicReset">0</button>
<button class="mini-btn" id="voicUp">+</button>
</div>
</div>
<div class="toggle-line">
<button class="hwbtn" id="bassBtn"><span class="led"></span>BASS</button>
<select id="bassOct" title="Octave basse">
<option value="-24">2 oct</option>
<option value="-12" selected>1 oct</option>
</select>
</div>
<div class="toggle-line">
<button class="hwbtn" id="keyBtn"><span class="led"></span>KEY</button>
<select id="keyRoot"></select>
<select id="keyMode">
<option value="major">maj</option>
<option value="minor">min</option>
</select>
</div>
</div>
</div>
<!-- Progression -->
<div class="mod prog-mod">
<div class="prog-head">
<h2 style="margin:0">Progression</h2>
<div class="preset-wrap">
<select id="presetSel">
<option value="">— preset (dans la tonalité KEY) —</option>
<option value="1,5,6,4">I V vi IV</option>
<option value="2,5,1">ii V I</option>
<option value="1,6,4,5">I vi IV V</option>
<option value="1,4,6,5">I IV vi V</option>
<option value="6,4,1,5">vi IV I V</option>
<option value="1,7,6,7">i VII VI VII (min)</option>
<option value="1,3,7,6">i III VII VI (min)</option>
</select>
<button class="mini-btn" id="presetLoad">charger</button>
<button class="mini-btn" id="progClear">vider</button>
</div>
</div>
<div class="prog-slots" id="progSlots"></div>
<div class="transport">
<button class="hwbtn" id="addBtn"><span class="led"></span>ADD (␣)</button>
<button class="hwbtn" id="playBtn"><span class="led"></span>PLAY</button>
<button class="hwbtn" id="loopBtn"><span class="led"></span>LOOP</button>
<div class="bpm-wrap">BPM <input type="number" id="bpm" value="112" min="30" max="300"></div>
<div class="bpm-wrap">durée/accord
<select id="chordLen" style="width:auto">
<option value="4">1 mesure</option>
<option value="2">1/2 mesure</option>
<option value="8">2 mesures</option>
</select>
</div>
</div>
</div>
<!-- IO -->
<div class="io">
<div class="mod">
<h2>MIDI out (live)</h2>
<div class="io-line"><label>périphérique</label><select id="midiDev"><option value="">— désactivé —</option></select></div>
<div class="io-line"><label>canal accords</label><select id="chChord"></select></div>
<div class="io-line"><label>canal basse</label><select id="chBass"></select></div>
<div class="status" id="midiStatus">Web MIDI : en attente…</div>
</div>
<div class="mod">
<h2>Export</h2>
<div class="io-line"><label>nom du fichier</label><input type="number" style="display:none"><select id="exportSplit">
<option value="split">accords + basse (2 pistes)</option>
<option value="chords">accords seuls</option>
</select></div>
<button class="export-btn" id="exportBtn">↓ exporter .mid</button>
<div class="status" id="exportStatus"></div>
</div>
</div>
<footer>_chordlab v1.0 · logique d'accords inspirée de l'Orchid ORC-1 (implémentation indépendante)</footer>
</div>
<script>
"use strict";
/* =========================================================
_chordlab — générateur d'accords façon Orchid
Moteur : root + type + extensions → set d'intervalles
Voicing : rotation des notes (inversions en cascade)
========================================================= */
const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const BASE_OCTAVE_MIDI = 60; // C4
/* ---------- État ---------- */
const state = {
types: ["maj"], // ordre de sélection
exts: new Set(),
voicing: 0, // -8..+8 rotations
bass: false,
bassOct: -12,
keyMode: false,
keyRoot: 0, // 0 = C
keyScale: "major",
lastRoot: null, // dernière note jouée (0..11)
progression: [], // [{label, notes[], bass, rootPc}]
playing: false,
loop: false,
playIdx: -1,
playTimer: null,
liveNotes: [], // notes MIDI actuellement tenues (live audition)
};
/* ---------- Moteur d'accords ---------- */
const TYPE_INTERVALS = {
dim:[0,3,6], min:[0,3,7], maj:[0,4,7], sus:[0,5,7],
aug:[0,4,8], sus2:[0,2,7],
};
const EXT_INTERVALS = { "6":9, "m7":10, "M7":11, "9":14 };
function resolveType(types){
const s = new Set(types);
if (s.has("dim") && s.has("maj")) return "aug";
if (s.has("min") && s.has("sus")) return "sus2";
return types[types.length-1] || "maj";
}
function chordName(rootPc, type, exts){
const base = {maj:"", min:"m", dim:"dim", sus:"sus4", aug:"aug", sus2:"sus2"}[type];
let name = NOTE_NAMES[rootPc] + base;
const e = new Set(exts);
// cas nommés proprement
if (type==="dim" && e.has("6") && !e.has("m7") && !e.has("M7")) { name = NOTE_NAMES[rootPc]+"dim7"; e.delete("6"); }
else if (type==="dim" && e.has("m7")) { name = NOTE_NAMES[rootPc]+"ø7"; e.delete("m7"); }
if (e.has("m7") && e.has("M7")) return name + " WTF?"; // hommage
if (e.has("m7")) { name += "7"; e.delete("m7"); }
if (e.has("M7")) { name += "maj7"; e.delete("M7"); }
if (e.has("6")) { name += "6"; e.delete("6"); }
if (e.has("9")) { name += (name===NOTE_NAMES[rootPc]+ "" || /7|6/.test(name)) ? "9".replace("9", name.match(/7|6/)?"(9)":"add9") : "add9"; }
return name;
}
/* Construit les notes MIDI (avant voicing) */
function buildChord(rootPc, type, exts){
const intervals = new Set(TYPE_INTERVALS[type]);
exts.forEach(e => intervals.add(EXT_INTERVALS[e]));
const root = BASE_OCTAVE_MIDI + rootPc;
return [...intervals].sort((a,b)=>a-b).map(i => root + i);
}
/* Voicing = rotation : +1 → la note la plus basse monte d'une octave */
function applyVoicing(notes, steps){
let n = [...notes].sort((a,b)=>a-b);
const k = Math.abs(steps);
for (let i=0;i<k;i++){
if (steps>0){ n[0]+=12; } else { n[n.length-1]-=12; }
n.sort((a,b)=>a-b);
}
return n;
}
/* ---------- Key Mode : accords diatoniques ---------- */
const SCALES = { major:[0,2,4,5,7,9,11], minor:[0,2,3,5,7,8,10] };
function diatonicChord(notePc, keyRoot, scaleName, exts){
const scale = SCALES[scaleName];
const rel = (notePc - keyRoot + 12) % 12;
const deg = scale.indexOf(rel);
if (deg === -1) return null; // hors tonalité
const pick = j => keyRoot + scale[(deg+j)%7] + 12*Math.floor((deg+j)/7);
const semis = [pick(0), pick(2), pick(4)]; // triade en tierces
if (exts.has("6")) semis.push(pick(5));
if (exts.has("m7") || exts.has("M7")) semis.push(pick(6)); // 7e diatonique
if (exts.has("9")) semis.push(pick(8));
const root = BASE_OCTAVE_MIDI + notePc;
const notes = semis.map(s => BASE_OCTAVE_MIDI + s);
// normalisation : ancrer la fondamentale sur la note jouée
const shift = root - notes[0];
const final = notes.map(n => n + shift).sort((a,b)=>a-b);
// nom : détecter la qualité de la triade
const i1 = final[1]-final[0], i2 = final[2]-final[0];
let q = "";
if (i1===4 && i2===7) q=""; else if (i1===3 && i2===7) q="m";
else if (i1===3 && i2===6) q="dim"; else if (i1===4 && i2===8) q="aug";
let name = NOTE_NAMES[notePc] + q;
if (exts.has("m7")||exts.has("M7")) name += "7*";
if (exts.has("6")) name += "6*";
if (exts.has("9")) name += "(9)*";
return {notes: final, name};
}
/* ---------- Accord courant ---------- */
function currentChord(rootPc){
if (state.keyMode){
const d = diatonicChord(rootPc, state.keyRoot, state.keyScale, state.exts);
if (!d) return null;
return {name:d.name, notes:applyVoicing(d.notes, state.voicing), rootPc};
}
const type = resolveType(state.types);
const notes = applyVoicing(buildChord(rootPc, type, [...state.exts]), state.voicing);
return {name:chordName(rootPc, type, [...state.exts]), notes, rootPc};
}
function bassNote(rootPc){ return BASE_OCTAVE_MIDI + rootPc + state.bassOct; }
/* =========================================================
AUDIO — petit poly Web Audio (2 saws désaccordées + LP)
========================================================= */
let AC = null, master = null;
function ensureAudio(){
if (AC) return;
AC = new (window.AudioContext||window.webkitAudioContext)();
master = AC.createGain(); master.gain.value = 0.5;
const comp = AC.createDynamicsCompressor();
master.connect(comp); comp.connect(AC.destination);
}
function playNote(midi, dur, vel=0.8, isBass=false){
ensureAudio();
const t = AC.currentTime;
const f = 440 * Math.pow(2,(midi-69)/12);
const g = AC.createGain();
const lp = AC.createBiquadFilter();
lp.type="lowpass";
lp.frequency.value = isBass ? 700 : 2200;
lp.Q.value = 0.7;
const oscs = [];
const detunes = isBass ? [0] : [-6, 6];
detunes.forEach(d=>{
const o = AC.createOscillator();
o.type = isBass ? "triangle" : "sawtooth";
o.frequency.value = f;
o.detune.value = d;
o.connect(lp); oscs.push(o);
});
lp.connect(g); g.connect(master);
const peak = (isBass?0.28:0.14) * vel;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(peak, t+0.015);
g.gain.setTargetAtTime(peak*0.7, t+0.05, 0.25);
g.gain.setTargetAtTime(0, t+dur, 0.12);
oscs.forEach(o=>{ o.start(t); o.stop(t+dur+0.8); });
}
function playChordAudio(chord, dur){
chord.notes.forEach(n => playNote(n, dur, 0.8, false));
if (state.bass) playNote(bassNote(chord.rootPc), dur, 0.9, true);
}
/* =========================================================
WEB MIDI — sortie live
========================================================= */
let midiAccess = null, midiOut = null;
const $ = id => document.getElementById(id);
function midiInit(){
if (!navigator.requestMIDIAccess){
$("midiStatus").textContent = "Web MIDI non supporté (utilise Chrome/Edge/Firefox).";
$("lcdMidi").textContent = "N/A";
return;
}
navigator.requestMIDIAccess({sysex:false}).then(acc=>{
midiAccess = acc;
refreshMidiDevices();
acc.onstatechange = refreshMidiDevices;
$("midiStatus").textContent = "Web MIDI prêt.";
}).catch(()=>{ $("midiStatus").textContent = "Accès MIDI refusé."; });
}
function refreshMidiDevices(){
const sel = $("midiDev");
const cur = sel.value;
sel.innerHTML = '<option value="">— désactivé —</option>';
midiAccess.outputs.forEach(out=>{
const o = document.createElement("option");
o.value = out.id; o.textContent = out.name;
sel.appendChild(o);
});
sel.value = cur;
selectMidiDevice();
}
function selectMidiDevice(){
const id = $("midiDev").value;
midiOut = id ? midiAccess.outputs.get(id) : null;
$("lcdMidi").textContent = midiOut ? "ON" : "—";
}
function midiSend(chord, durMs){
if (!midiOut) return;
const chC = parseInt($("chChord").value)-1;
const chB = parseInt($("chBass").value)-1;
const now = performance.now();
chord.notes.forEach(n=>{
midiOut.send([0x90|chC, n, 100]);
midiOut.send([0x80|chC, n, 0], now+durMs);
});
if (state.bass){
const b = bassNote(chord.rootPc);
midiOut.send([0x90|chB, b, 110]);
midiOut.send([0x80|chB, b, 0], now+durMs);
}
}
function midiPanic(){
if (!midiOut) return;
for (let ch=0; ch<16; ch++) midiOut.send([0xB0|ch, 123, 0]);
}
/* =========================================================
EXPORT .mid — writer minimal (format 1, PPQ 480)
========================================================= */
function vlq(n){
const bytes=[n&0x7F];
while(n>>=7) bytes.unshift((n&0x7F)|0x80);
return bytes;
}
function trackChunk(events){
// events: [{dt, data:[...]}]
const body=[];
events.forEach(e=>{ body.push(...vlq(e.dt), ...e.data); });
body.push(0x00,0xFF,0x2F,0x00); // end of track
const len = body.length;
return [0x4D,0x54,0x72,0x6B,(len>>>24)&255,(len>>>16)&255,(len>>>8)&255,len&255,...body];
}
function exportMidi(){
if (!state.progression.length){
$("exportStatus").textContent = "Progression vide — ajoute des accords d'abord.";
return;
}
const PPQ = 480;
const bpm = Math.max(30, Math.min(300, parseInt($("bpm").value)||112));
const beats = parseInt($("chordLen").value);
const ticksPerChord = beats*PPQ;
const gate = Math.round(ticksPerChord*0.97);
const tempo = Math.round(60000000/bpm);
const split = $("exportSplit").value;
const chC = parseInt($("chChord").value)-1;
const chB = parseInt($("chBass").value)-1;
const meta = [
{dt:0, data:[0xFF,0x51,0x03,(tempo>>16)&255,(tempo>>8)&255,tempo&255]},
{dt:0, data:[0xFF,0x58,0x04,4,2,24,8]},
];
const chordEv = [];
const bassEv = [];
state.progression.forEach((c,i)=>{
const at = i*ticksPerChord;
// note-ons groupés à dt calculé depuis la fin du précédent gate
c.notes.forEach((n,j)=>{
chordEv.push({t:at, on:true, n, ch:chC});
chordEv.push({t:at+gate, on:false, n, ch:chC});
});
if (c.bass !== null && c.bass !== undefined){
bassEv.push({t:at, on:true, n:c.bass, ch:chB});
bassEv.push({t:at+gate, on:false, n:c.bass, ch:chB});
}
});
const toTrack = evs=>{
evs.sort((a,b)=> a.t-b.t || (a.on===b.on?0:(a.on?1:-1)));
let last=0; const out=[];
evs.forEach(e=>{
out.push({dt:e.t-last, data:[(e.on?0x90:0x80)|e.ch, e.n, e.on?100:0]});
last=e.t;
});
return out;
};
const tracks = [trackChunk([...meta, ...toTrack(chordEv)])];
if (split==="split" && bassEv.length) tracks.push(trackChunk(toTrack(bassEv)));
const nTracks = tracks.length;
const header = [0x4D,0x54,0x68,0x64,0,0,0,6, 0,1, 0,nTracks, (PPQ>>8)&255, PPQ&255];
const bytes = new Uint8Array([...header, ...tracks.flat()]);
const blob = new Blob([bytes], {type:"audio/midi"});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
const labels = state.progression.map(c=>c.label.replace(/[^\w#]/g,"")).slice(0,4).join("-");
a.download = `chordlab_${labels}_${bpm}bpm.mid`;
a.click();
URL.revokeObjectURL(a.href);
$("exportStatus").textContent = `Exporté : ${a.download}`;
}
/* =========================================================
UI
========================================================= */
/* ----- clavier ----- */
const WHITE = [0,2,4,5,7,9,11];
const BLACK = [{pc:1,pos:0},{pc:3,pos:1},{pc:6,pos:3},{pc:8,pos:4},{pc:10,pos:5}];
const kbdEl = $("kbd");
const keyEls = {};
function buildKeyboard(){
WHITE.forEach(pc=>{
const k = document.createElement("div");
k.className="wkey"; k.dataset.pc=pc;
k.textContent = NOTE_NAMES[pc];
k.setAttribute("role","button");
kbdEl.appendChild(k); keyEls[pc]=k;
});
BLACK.forEach(({pc,pos})=>{
const k = document.createElement("div");
k.className="bkey"; k.dataset.pc=pc;
k.style.left = `calc(${(pos+1)*(100/7)}% - 4.5%)`;
kbdEl.appendChild(k); keyEls[pc]=k;
});
}
function refreshKeyDimming(){
const scale = SCALES[state.keyScale];
Object.entries(keyEls).forEach(([pc,el])=>{
const inKey = !state.keyMode || scale.includes((pc - state.keyRoot + 12)%12);
el.classList.toggle("outkey", !inKey);
});
}
/* ----- déclenchement d'accord ----- */
function triggerChord(rootPc, fromPlayback=false){
const chord = currentChord(rootPc);
if (!chord){
$("lcdName").textContent = "×";
$("lcdNotes").textContent = "hors tonalité";
return null;
}
state.lastRoot = rootPc;
$("lcdName").textContent = chord.name;
$("lcdNotes").textContent = chord.notes.map(n=>NOTE_NAMES[n%12]+(Math.floor(n/12)-1)).join(" · ")
+ (state.bass ? " | bass "+NOTE_NAMES[bassNote(rootPc)%12] : "");
if (!fromPlayback){
const durS = 1.4;
playChordAudio(chord, durS);
midiSend(chord, durS*1000);
}
return chord;
}
/* ----- boutons type/ext ----- */
document.querySelectorAll("#typeBtns .hwbtn").forEach(btn=>{
btn.addEventListener("click", ()=>{
const t = btn.dataset.type;
const i = state.types.indexOf(t);
if (i>=0){
if (state.types.length>1) state.types.splice(i,1);
} else {
const test = [...state.types, t];
const s = new Set(test);
const combo = (s.has("dim")&&s.has("maj")) || (s.has("min")&&s.has("sus"));
state.types = (test.length<=1 || (test.length===2 && combo)) ? test : [t];
}
document.querySelectorAll("#typeBtns .hwbtn").forEach(b=>
b.classList.toggle("on", state.types.includes(b.dataset.type)));
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
});
document.querySelectorAll("#extBtns .hwbtn").forEach(btn=>{
btn.addEventListener("click", ()=>{
const e = btn.dataset.ext;
state.exts.has(e) ? state.exts.delete(e) : state.exts.add(e);
btn.classList.toggle("on", state.exts.has(e));
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
});
/* ----- clavier souris + PC ----- */
kbdEl.addEventListener("pointerdown", ev=>{
const pc = ev.target.dataset.pc;
if (pc===undefined) return;
ev.target.classList.add("pressed");
triggerChord(parseInt(pc));
});
kbdEl.addEventListener("pointerup", ev=>{
ev.target.classList && ev.target.classList.remove("pressed");
});
const PCKEYS = {a:0,w:1,s:2,e:3,d:4,f:5,t:6,g:7,y:8,h:9,u:10,j:11};
window.addEventListener("keydown", ev=>{
if (ev.repeat || ev.target.tagName==="INPUT" || ev.target.tagName==="SELECT") return;
const k = ev.key.toLowerCase();
if (k in PCKEYS){
const pc = PCKEYS[k];
keyEls[pc].classList.add("pressed");
triggerChord(pc);
} else if (ev.code==="Space"){
ev.preventDefault();
addToProgression();
}
});
window.addEventListener("keyup", ev=>{
const k = ev.key.toLowerCase();
if (k in PCKEYS) keyEls[PCKEYS[k]].classList.remove("pressed");
});
/* ----- knob voicing ----- */
const knob = $("voicingKnob");
function setVoicing(v){
state.voicing = Math.max(-8, Math.min(8, v));
const angle = state.voicing * 16; // ±128°
const rad = (angle-90)*Math.PI/180;
$("knobInd").setAttribute("x2", 37 + 26*Math.cos(rad));
$("knobInd").setAttribute("y2", 37 + 26*Math.sin(rad));
$("knobVal").textContent = state.voicing>0 ? "+"+state.voicing : state.voicing;
$("lcdVoicing").textContent = $("knobVal").textContent;
knob.setAttribute("aria-valuenow", state.voicing);
if (state.lastRoot!==null) triggerChord(state.lastRoot);
}
let dragY=null, dragStart=0;
knob.addEventListener("pointerdown", ev=>{ dragY=ev.clientY; dragStart=state.voicing; knob.setPointerCapture(ev.pointerId); });
knob.addEventListener("pointermove", ev=>{
if (dragY===null) return;
const dv = Math.round((dragY-ev.clientY)/14);
if (dragStart+dv !== state.voicing) setVoicing(dragStart+dv);
});
knob.addEventListener("pointerup", ()=>{ dragY=null; });
knob.addEventListener("dblclick", ()=>setVoicing(0));
knob.addEventListener("keydown", ev=>{
if (ev.key==="ArrowUp"||ev.key==="ArrowRight"){ setVoicing(state.voicing+1); ev.preventDefault(); }
if (ev.key==="ArrowDown"||ev.key==="ArrowLeft"){ setVoicing(state.voicing-1); ev.preventDefault(); }
});
$("voicUp").addEventListener("click", ()=>setVoicing(state.voicing+1));
$("voicDown").addEventListener("click", ()=>setVoicing(state.voicing-1));
$("voicReset").addEventListener("click", ()=>setVoicing(0));
/* ----- bass & key ----- */
$("bassBtn").addEventListener("click", function(){
state.bass = !state.bass;
this.classList.toggle("on", state.bass);
if (state.lastRoot!==null) triggerChord(state.lastRoot);
});
$("bassOct").addEventListener("change", function(){ state.bassOct = parseInt(this.value); });
NOTE_NAMES.forEach((n,i)=>{
const o = document.createElement("option");
o.value=i; o.textContent=n;
$("keyRoot").appendChild(o);
});
$("keyBtn").addEventListener("click", function(){
state.keyMode = !state.keyMode;
this.classList.toggle("on", state.keyMode);
updateKeyLcd(); refreshKeyDimming();
});
$("keyRoot").addEventListener("change", function(){ state.keyRoot=parseInt(this.value); updateKeyLcd(); refreshKeyDimming(); });
$("keyMode").addEventListener("change", function(){ state.keyScale=this.value; updateKeyLcd(); refreshKeyDimming(); });
function updateKeyLcd(){
$("lcdKey").textContent = state.keyMode
? NOTE_NAMES[state.keyRoot] + (state.keyScale==="major"?" maj":" min")
: "OFF";
}
/* ----- progression ----- */
function addToProgression(){
if (state.lastRoot===null) return;
const chord = currentChord(state.lastRoot);
if (!chord) return;
state.progression.push({
label: chord.name,
notes: [...chord.notes],
bass: state.bass ? bassNote(chord.rootPc) : null,
rootPc: chord.rootPc,
});
renderProgression();
}
$("addBtn").addEventListener("click", addToProgression);
$("progClear").addEventListener("click", ()=>{ stopPlayback(); state.progression=[]; renderProgression(); });
function renderProgression(){
const el = $("progSlots");
el.innerHTML="";
if (!state.progression.length){
const e = document.createElement("div");
e.className="slot-empty";
e.textContent="vide — joue un accord puis ADD";
el.appendChild(e);
return;
}
state.progression.forEach((c,i)=>{
const s = document.createElement("div");
s.className="slot"+(i===state.playIdx?" playing":"");
s.innerHTML = `<button class="del" title="supprimer">×</button>
<div class="sname">${c.label}</div>
<div class="snotes">${c.notes.map(n=>NOTE_NAMES[n%12]).join(" ")}${c.bass!==null?" +B":""}</div>`;
s.querySelector(".del").addEventListener("click", ev=>{
ev.stopPropagation();
state.progression.splice(i,1);
renderProgression();
});
s.addEventListener("click", ()=>{
const durS = 1.2;
c.notes.forEach(n=>playNote(n,durS));
if (c.bass!==null) playNote(c.bass,durS,0.9,true);
midiSendRaw(c, durS*1000);
});
el.appendChild(s);
});
}
function midiSendRaw(c, durMs){
if (!midiOut) return;
const chC = parseInt($("chChord").value)-1;
const chB = parseInt($("chBass").value)-1;
const now = performance.now();
c.notes.forEach(n=>{ midiOut.send([0x90|chC,n,100]); midiOut.send([0x80|chC,n,0],now+durMs); });
if (c.bass!==null){ midiOut.send([0x90|chB,c.bass,110]); midiOut.send([0x80|chB,c.bass,0],now+durMs); }
}
/* ----- presets diatoniques ----- */
$("presetLoad").addEventListener("click", ()=>{
const val = $("presetSel").value;
if (!val) return;
const degrees = val.split(",").map(Number);
const scale = SCALES[state.keyScale];
stopPlayback();
state.progression = [];
degrees.forEach(d=>{
const pc = (state.keyRoot + scale[(d-1)%7]) % 12;
const chord = (()=>{
const dChord = diatonicChord(pc, state.keyRoot, state.keyScale, state.exts);
if (!dChord) return null;
return {name:dChord.name, notes:applyVoicing(dChord.notes, state.voicing), rootPc:pc};
})();
if (chord) state.progression.push({
label: chord.name, notes:[...chord.notes],
bass: state.bass ? bassNote(pc) : null, rootPc: pc,
});
});
renderProgression();
});
/* ----- lecture ----- */
function stopPlayback(){
state.playing=false; state.playIdx=-1;
clearTimeout(state.playTimer);
$("playBtn").classList.remove("playing","on");
$("playBtn").innerHTML = '<span class="led"></span>PLAY';
midiPanic();
renderProgression();
}
function startPlayback(){
if (!state.progression.length) return;
state.playing=true;
$("playBtn").classList.add("playing","on");
$("playBtn").innerHTML = '<span class="led"></span>STOP';
let i=0;
const step = ()=>{
if (!state.playing) return;
if (i>=state.progression.length){
if (state.loop){ i=0; } else { stopPlayback(); return; }
}
const bpm = Math.max(30, Math.min(300, parseInt($("bpm").value)||112));
const beats = parseInt($("chordLen").value);
const durMs = beats*60000/bpm;
const c = state.progression[i];
state.playIdx=i;
renderProgression();
$("lcdName").textContent = c.label;
$("lcdNotes").textContent = c.notes.map(n=>NOTE_NAMES[n%12]+(Math.floor(n/12)-1)).join(" · ");
const gateS = durMs/1000*0.95;
c.notes.forEach(n=>playNote(n,gateS));
if (c.bass!==null) playNote(c.bass,gateS,0.9,true);
midiSendRaw(c, durMs*0.95);
i++;
state.playTimer = setTimeout(step, durMs);
};
step();
}
$("playBtn").addEventListener("click", ()=> state.playing ? stopPlayback() : startPlayback());
$("loopBtn").addEventListener("click", function(){
state.loop=!state.loop;
this.classList.toggle("on", state.loop);
});
/* ----- MIDI UI ----- */
for (let i=1;i<=16;i++){
const o1=document.createElement("option"); o1.value=i; o1.textContent="ch "+i;
const o2=o1.cloneNode(true);
$("chChord").appendChild(o1); $("chBass").appendChild(o2);
}
$("chChord").value=3; // Pro-800 — nappes
$("chBass").value=5; // TD-3 + K-2 unison
$("midiDev").addEventListener("change", selectMidiDevice);
/* ----- export ----- */
$("exportBtn").addEventListener("click", exportMidi);
/* ----- init ----- */
buildKeyboard();
renderProgression();
refreshKeyDimming();
setVoicing(0);
midiInit();
</script>
</body>
</html>
BIN
View File
Binary file not shown.
+1268
View File
File diff suppressed because it is too large Load Diff