diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0e1ebb3..94cf493 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -38,9 +38,100 @@ jobs:
"
# Les tests d'interface pilotent un vrai navigateur via CDP. Sans lui ils
- # s'annoncent ignorés au lieu d'échouer — mais on préfère les faire tourner.
- - name: Vérifier la présence d'un navigateur
- run: which google-chrome chromium chromium-browser 2>/dev/null || echo "aucun navigateur — les tests d'UI seront ignorés"
+ # s'annoncent ignorés SANS faire rougir la suite : 32 tests muets et une
+ # sortie verte. En local c'est un repli acceptable ; en CI c'est un angle
+ # mort, donc ici on échoue franchement.
+ - name: Exiger un navigateur pour les tests d'interface
+ run: |
+ node -e "
+ const { findBrowser } = require('./tests/browser.js');
+ const bin = findBrowser();
+ if (!bin) {
+ console.error('aucun navigateur trouvé : les 32 tests d\'interface seraient ignorés en silence');
+ process.exit(1);
+ }
+ console.log('navigateur : ' + bin);
+ "
+
+ # Le portillon du code d'accès ne se voit que depuis une adresse NON
+ # locale : la machine hôte est exemptée exprès. Sans interface réseau, ces
+ # tests s'annoncent ignorés sans faire rougir la suite — même angle mort
+ # que les tests d'interface sans navigateur.
+ - name: Exiger une adresse réseau pour les tests du code d'accès
+ run: |
+ node -e "
+ const os = require('os');
+ const ip = Object.values(os.networkInterfaces() || {}).flat()
+ .find(i => i && i.family === 'IPv4' && !i.internal);
+ if (!ip) {
+ console.error('aucune adresse réseau : le portillon du code d\'accès serait ignoré en silence');
+ process.exit(1);
+ }
+ console.log('adresse réseau : ' + ip.address);
+ "
- name: Suite complète
run: npm test
+
+ # L'icône de zone de notification est du PowerShell embarqué dans server.js,
+ # écrit depuis Linux et JAMAIS EXÉCUTÉ. `tests/systray.test.js` en lit le
+ # source, mais lire n'est pas analyser : une parenthèse manquante y survivrait
+ # jusqu'à la machine de régie.
+ #
+ # Ce travail-là ne demande PAS de session graphique — l'analyseur de PowerShell
+ # est une classe .NET ordinaire. On ne prétend donc pas tester l'icône : on
+ # ferme le seul angle mort qu'un runner peut fermer, et on le dit.
+ powershell:
+ runs-on: windows-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Le script de l'icône s'analyse-t-il seulement ?
+ shell: pwsh
+ run: |
+ $src = Get-Content -Raw server.js
+ if ($src -notmatch '(?s)const SYSTRAY_PS1 = `(.*?)`;') {
+ Write-Error 'SYSTRAY_PS1 introuvable dans server.js'; exit 1
+ }
+ $ps = $Matches[1]
+ $tokens = $null; $erreurs = $null
+ [System.Management.Automation.Language.Parser]::ParseInput(
+ $ps, [ref]$tokens, [ref]$erreurs) | Out-Null
+ # ⚠ TÉMOIN POSITIF D'ABORD. `$null.Count` vaut 0 en PowerShell : si
+ # l'analyseur ne rendait rien du tout, « zéro erreur » serait vrai et
+ # cette étape passerait au vert sans avoir rien analysé. On exige donc
+ # d'abord d'avoir vraiment lu un script.
+ if ($null -eq $tokens -or $tokens.Count -lt 100) {
+ Write-Host "l'analyseur n'a rendu que $($tokens.Count) jeton(s) : rien n'a été analysé"
+ exit 1
+ }
+ if ($null -ne $erreurs -and $erreurs.Count -gt 0) {
+ Write-Host "$($erreurs.Count) erreur(s) d'analyse dans SYSTRAY_PS1 :"
+ $erreurs | ForEach-Object { Write-Host (" ligne {0} : {1}" -f $_.Extent.StartLineNumber, $_.Message) }
+ exit 1
+ }
+ Write-Host "SYSTRAY_PS1 s'analyse sans erreur ($($tokens.Count) jetons)."
+
+ # Et l'analyseur doit être capable de dire NON : on lui donne le même
+ # script avec une accolade en trop. S'il ne bronche pas, c'est que
+ # cette étape ne mesure rien — le défaut qu'on vient d'éviter.
+ $t2 = $null; $e2 = $null
+ [System.Management.Automation.Language.Parser]::ParseInput(
+ $ps + "`nfunction Casse { ", [ref]$t2, [ref]$e2) | Out-Null
+ if ($null -eq $e2 -or $e2.Count -eq 0) {
+ Write-Host "l'analyseur accepte un script volontairement cassé : il ne mesure rien"
+ exit 1
+ }
+ Write-Host "témoin : un script cassé est bien refusé ($($e2.Count) erreur(s))."
+
+ # La suite ne tourne que sur Ubuntu ; ce passage-ci attrape ce qui n'existe
+ # que sous Windows — séparateurs de chemin, casse des noms de fichiers.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Suite complète, sous Windows
+ # Les tests d'interface s'annoncent ignorés sans navigateur : c'est
+ # accepté ICI, ce passage n'existe pas pour eux. Le compte reste
+ # affiché, et le passage Ubuntu, lui, les exige.
+ run: npm test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79e42ce..099eb59 100644
Binary files a/CHANGELOG.md and b/CHANGELOG.md differ
diff --git a/CLAUDE.md b/CLAUDE.md
index 5cc6c17..a7f6cd9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,7 +2,7 @@
App web (Node.js zéro dépendance + page unique) qui pilote les fixtures DMX de
MadMapper en OSC : multi-chasers, vagues, couleur, presets, MIDI/OSC, Ableton
-Link. **2.0.1** · MIT · Pierre-Yves Mansour — Collectif WSK. Travail en **français**.
+Link. **2.0.2** · MIT · Pierre-Yves Mansour — Collectif WSK. Travail en **français**.
## À lire EN PREMIER
@@ -29,6 +29,9 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa
tempo, ni la protection « on ne coupe jamais un show » de l'arrêt auto.
5. Manuel PDF : regénérer avec `python3 docs/build-manuel.py `
(reportlab + DejaVu ; pas de glyphes ⏻ ⧉ ni d'exposants Unicode → carrés vides).
+ Sur une machine nue : `pip install reportlab` + `apt install fonts-dejavu-core
+ fonts-dejavu-extra` (l'italique est dans *-extra*). Sans l'italique il génère
+ quand même, en romain, et le dit. ⚠ Le dossier de sortie doit EXISTER.
6. Édits ciblés (Edit/Grep) plutôt que réécritures ; réponses concises ;
économiser les tokens (préférence explicite de Pym).
7. Ne pas toucher : `scenoled.madproject`, `Chaser 4.2.2 Setup.exe`, `serverA.js`.
@@ -36,7 +39,7 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa
## Lancer / tester
- `node server.js` → http://localhost:3333 (config générée : `cascade-config.json`).
-- **`npm test`** (= `node --test`) : 248 tests, zéro dépendance. **À lancer avant
+- **`npm test`** (= `node --test`) : 328 tests, zéro dépendance. **À lancer avant
de conclure toute modif du serveur.** ⚠ `node --test tests/` échoue sur Node 24
(chemin pris pour un module) — utiliser `node --test` tout court.
- Instance isolée pour tester à la main : `CASCADE_PORT=3461 CASCADE_NO_BROWSER=1
@@ -44,8 +47,13 @@ qui ont déjà coûté cher. `docs/madmapper-osc-api.md` = référence OSC MadMa
`CASCADE_OSCIN`, `CASCADE_FEEDBACK`, `CASCADE_MMPORT`, `CASCADE_MMHOST`).
- Faux Carabiner = serveur TCP local port 17000 qui pousse
`status { :peers 1 :bpm 128.0 ... }\n`.
-- UI : Playwright dispo ; utiliser `waitUntil: 'domcontentloaded'`
- (`networkidle` ne vient jamais, l'UI poll toutes les 120 ms).
+- UI : les 45 tests d'interface pilotent un vrai navigateur en CDP maison
+ (`tests/browser.js`, zéro dépendance) — **pas** Playwright. Sans navigateur ils
+ s'annoncent ignorés SANS faire rougir la suite : vérifier le compte (328), pas
+ la couleur. `CASCADE_NAVIGATEUR=` impose un navigateur ;
+ `PLAYWRIGHT_BROWSERS_PATH` est balayé tout seul. Si tu passes par Playwright
+ à la main, `waitUntil: 'domcontentloaded'` (`networkidle` ne vient jamais,
+ l'UI poll toutes les 120 ms).
- Exécutables : `node build.js` → `build/` (voir `docs/ETAT-DU-PROJET.md` pour
les pièges pkg : `--no-bytecode` obligatoire, cibles `node22-*` seulement).
- Manuel : `python docs/build-manuel.py ` (reportlab ; les polices sont
@@ -60,7 +68,11 @@ raison d'être.
**Intégration continue et release, depuis la 2.0** (le dépôt n'avait rien avant) :
- `.github/workflows/tests.yml` — `npm test` sur chaque push vers `main` et sur
- chaque PR. Vérifie aussi qu'**aucune dépendance npm** n'a été introduite.
+ chaque PR, **sur Ubuntu ET sur Windows**. Vérifie aussi qu'**aucune dépendance
+ npm** n'a été introduite, qu'un navigateur et une adresse réseau sont bien là
+ (sans quoi 46 tests d'interface et le portillon s'annonceraient « ignorés »
+ sans faire rougir la suite), et que `SYSTRAY_PS1` s'analyse — avec un témoin
+ qui vérifie que l'analyseur sait dire non.
- `.github/workflows/release.yml` — pousser un tag `vX.Y.Z` construit les quatre
exécutables et publie la release avec eux en pièces jointes. La description
vient du CHANGELOG via `tools/notes-version.js`, jamais d'un texte recopié.
@@ -69,13 +81,24 @@ raison d'être.
⚠ **Attendre la CI verte avant de taguer.** Elle attrape ce qui est invisible en
local : courses de timing sur runner lent, chemins spécifiques à Windows.
+⚠ **La prochaine version est une MINEURE — 2.1.0, pas 2.0.3.** La section
+`[Non publié]` du CHANGELOG contient des **fonctions nouvelles** (suiveur audio,
+grille de presets, code d'accès, replis), pas seulement des correctifs : le
+versionnage sémantique impose donc une mineure. Penser aussi à passer
+`VERSION = "2.1"` dans `docs/build-manuel.py` (il porte majeure.mineure, comme le
+pied de page de l'interface) et à régénérer le PDF à la racine **et** dans `dist/`.
+
Livré et testé : page Scène 3D et manipulation souris · moteur Champ 3D
(5 formes, netteté, course, bruit dirigé) · vues par axe avec bascule de
dossiers et fondu · 7 modes de fusion · perspective atmosphérique · palette à
N arrêts, branchable sur la profondeur ou la hauteur · décalage réparti ·
crossfader A/B · modulateurs (LFO) par couche et global · coupure de secours · renvoi de disposition · démos · repère 3D.
-**248 tests, 21 mutations sur 21 détectées.** Manuel PDF, README, CHANGELOG et
+**328 tests, 36 mutations sur 36 détectées** — campagne complète passée d'un bloc
+le 2026-08-05 : zéro aveugle, zéro motif absent, `server.js` restauré à
+l'identique (le chiffre était additionné à la main avant).
+⚠ La lancer dans une COPIE du dépôt : elle modifie `server.js` en place pendant
+une heure, et un commit parti à ce moment-là emporterait un mutant. Manuel PDF, README, CHANGELOG et
exécutables des 4 plateformes sont à jour ; l'exécutable Windows a été lancé et
interrogé pour de vrai.
@@ -87,31 +110,85 @@ Trois garde-fous à connaître avant de toucher au code :
palette spatiale morte en silence ;
- `build-manuel.py` refuse de générer si un caractère manque à la police.
-Ce qui reste, par ordre de valeur : suiveur audio (à faire côté navigateur,
-en Web Audio, pour tenir le zéro-dépendance), grille visuelle des 16 presets, les trois mesures MadMapper non faites (empreinte d'une
-barre en pixels, pivot de `output/rot`, DMX Filtering), et « dessiner les
-fixtures » — qui attend d'abord un `Export Fixture Definitions…` depuis
-MadMapper. Détail dans `docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`.
+✅ **Suiveur audio et grille de presets sont LIVRÉS** (2026-08-04) — ne pas les
+reprendre. Le micro est une *source de modulateur* (`src: 'lfo' | 'audio'`),
+analysé en Web Audio côté page et poussé en query sur le poll. ⚠ Il lit une
+**énergie, pas un tempo**, et n'est lisible que sur la machine hôte (origine
+sûre) : les deux limites sont assumées, écrites, et **décidées avec Pym** — ne
+pas rouvrir sans lui. La détection de battement attend un essai en salle.
+
+Ce qui reste, par ordre de valeur : les **deux** mesures MadMapper non faites
+(empreinte d'une barre en pixels, DMX Filtering), et « dessiner les fixtures » —
+qui attend d'abord un `Export Fixture Definitions…` depuis MadMapper. Détail
+dans `docs/V2-INSPIRATIONS.md` et `docs/V2-AXES-PISTES.md`.
+
+✅ **Le pivot de `output/rot` est mesuré** (2026-07-29) : la rotation ne
+translate pas la fixture (écart 0,00 en x et y), donc l'ordre position/rotation
+est libre. C'était la troisième mesure ; elle est faite.
## Prochaines demandes de Pym (exprimées, PAS encore réalisées)
-1. **Icône de zone de notification (systray)** : point vert = serveur en route,
- rouge = arrêté ; clic droit → ouvrir l'interface / démarrer / arrêter le
- serveur. Idée : pouvoir fermer la fenêtre en laissant tourner le serveur
- tout en le voyant. ⚠ Contrainte zéro-dépendance : pas de systray natif en
- Node pur — pistes à discuter avec Pym (petit utilitaire par plateforme,
- PowerShell/AppleScript, ou accepter une dépendance ici).
+1. ✅ **Icône de zone de notification : FAITE** (2026-08-05), en PowerShell +
+ `NotifyIcon`, donc zéro dépendance. ⚠ **Windows uniquement — décidé avec
+ Pym**, macOS n'a pas d'équivalent scriptable : ne pas « réparer » cette
+ absence. ⚠ **Éteinte par défaut et JAMAIS EXÉCUTÉE** : écrite depuis Linux.
+ Le premier essai sur une vraie machine Windows reste à faire — c'est la
+ première chose à confirmer avec lui. Le script vit dans `SYSTRAY_PS1`
+ (embarqué, car le distribuable est un exécutable unique).
+
+ ⚠ **Avant l'essai, savoir ceci** : Windows range **toute nouvelle icône dans
+ le tiroir caché** (chevron `^`). L'issue la plus probable d'un premier essai
+ est « le script tourne, l'icône existe, Pym ne la voit pas ». La note est
+ dans les Réglages et dans le manuel — la lui montrer.
+
+ ⚠ Le script sonde **`/api/ping`, jamais `/api/state`** : `/api/state` remet
+ `lastUiPollAt` à jour et **désactivait l'arrêt automatique** (défaut réel,
+ trouvé en relecture, corrigé, mesuré, et rejoué par un mutant). Détail et
+ sept autres correctifs dans `docs/ETAT-DU-PROJET.md`.
+
+ ⚠ **Ce que la CI dit, et ce qu'elle ne dira jamais.** Depuis le 2026-08-05 il
+ y a un passage `windows-latest` : il **analyse** `SYSTRAY_PS1` avec
+ `[Parser]::ParseInput` (aucune session graphique requise) et lance la suite
+ entière — 328 tests, 0 ignoré, le premier passage est vert. Une parenthèse
+ manquante ne partira donc plus en régie. Mais **aucun runner ne posera jamais
+ l'icône** : `NotifyIcon` demande un bureau. L'essai en vrai reste à faire.
+ `tests/systray.test.js` lit le source en plus — c'est mieux que rien.
2. **Capture ou GIF dans le README** — nécessite une vraie session MadMapper.
-3. Suite de l'audit : voir la section « À faire » de `../Cascade-AUDIT.md` (hors dépôt)
- (code d'accès 4 chiffres, repli « avancé » du panneau Couches, tests d'UI).
-4. **`../Cascade-RELECTURES.md` (hors dépôt) — à lire avant de reprendre la v2.**
- État des deux campagnes de relecture par agents : ce qui a été mesuré et
- exploité, ce qui reste à mesurer sur MadMapper, et **32 trouvailles sur le
- moteur de champ 3D dont trois bloquantes** — la relecture s'est arrêtée avant
- la phase de vérification, donc rien n'y est confirmé contradictoirement.
- Deux de ces défauts sont **déjà en v1** (couche couleur qui allume les barres
- qu'elle ne pilote pas ; phase non préservée au changement de tempo pour le
- moteur vague).
+3. Suite de l'audit : voir la section « À faire » de `../Cascade-AUDIT.md` (hors dépôt).
+ ✅ Repli « avancé » du panneau Couches : FAIT (replis `advMiroirs` et
+ `advMelange`, 2026-08-04). ✅ Tests d'UI : faits depuis la 2.0 (CDP maison).
+ ✅ Code d'accès à 4 chiffres : FAIT (2026-08-04). ⚠ La machine hôte en est
+ EXEMPTÉE exprès (on ne peut pas s'enfermer dehors) et l'OSC/MIDI ne sont pas
+ couverts (autre domaine de confiance) — ne pas « corriger » ça sans Pym.
+ Reste : signature/notarisation macOS (compte Apple Developer, 99 $/an →
+ décision + dépense).
+4. **`../Cascade-RELECTURES.md` (hors dépôt)** — état des deux campagnes de
+ relecture par agents. ⚠ Ce fichier vit **hors du dépôt** : il est absent des
+ clones frais (sessions distantes, CI), donc ne jamais faire dépendre une
+ décision de son contenu sans l'avoir sous les yeux.
+
+ ⚠ **Les deux « défauts déjà en v1 » que ce fichier signalait sont CORRIGÉS**
+ — vérifié dans le code le 2026-08-04, ne pas repartir les chasser :
+ - *couche couleur qui allume les barres qu'elle ne pilote pas* → `mixLevel()`
+ décide barre par barre (`server.js`) ; la forme fautive globale
+ (`mix.anyCol`) a disparu. Corrigé par `648327c`, listé `CHANGELOG.md:213`,
+ couvert par `tests/regressions.test.js`.
+ - *phase non préservée au changement de tempo (moteur vague)* → `phaseContinue()`
+ intègre `dt / period` au lieu de `now / period` ; le chaser, lui, rebase
+ `startTime`. Corrigé par `619bee8`, listé `CHANGELOG.md:215`, couvert par
+ `tests/regressions.test.js` (`stepMs` 10000 → 9999, écart < 0,05).
+
+ La relecture s'était arrêtée avant sa phase de vérification : ses trouvailles
+ n'étaient **pas** confirmées contradictoirement, et celles-là étaient périmées.
+ Traiter le reste de la liste avec la même prudence — vérifier dans le code
+ avant d'y croire.
+
+ **Piste restée ouverte, elle, et non couverte** : `resolveBars()` replie sur
+ *toutes* les barres actives quand le groupe d'une couche est vide. Vider un
+ groupe (au lieu de le supprimer, qui remet `L.groupId = null`) fait donc
+ basculer une couche couleur sur tout le plateau. Comportement délibéré et
+ documenté, mais c'est la seule voie vivante qui reproduit le symptôme
+ d'origine, et aucun test ne la couvre.
Le chantier v1.3 « spectacle » (features de chase, UI premium, tests, audit,
exécutables, manuel, CHANGELOG) est **livré** — voir `CHANGELOG.md`.
diff --git a/Cascade - Manuel.pdf b/Cascade - Manuel.pdf
index f441ddd..5aa8852 100644
Binary files a/Cascade - Manuel.pdf and b/Cascade - Manuel.pdf differ
diff --git a/README.md b/README.md
index 6c51f63..c2680d7 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
*Pierre-Yves Mansour — Collectif WSK*
-    
+    
---
@@ -24,14 +24,15 @@ Téléchargez le fichier de votre système dans la [dernière release](../../rel
| Système | Fichier |
|---|---|
-| Windows | `Cascade-1.6.0-windows-x64.exe` |
-| macOS (Apple Silicon, M1 et suivants) | `Cascade-1.6.0-macos-apple-silicon` |
-| macOS (Intel) | `Cascade-1.6.0-macos-intel` |
-| Linux | `Cascade-1.6.0-linux-x64` |
+| Windows | `Cascade--windows-x64.exe` |
+| macOS (Apple Silicon, M1 et suivants) | `Cascade--macos-apple-silicon` |
+| macOS (Intel) | `Cascade--macos-intel` |
+| Linux | `Cascade--linux-x64` |
-> **macOS et Linux**, la première fois, dans un Terminal ouvert sur le dossier :
+> **macOS et Linux**, la première fois, dans un Terminal ouvert sur le dossier
+> (remplacez `` par celle que vous avez téléchargée) :
> ```bash
-> xattr -cr Cascade-1.6.0-macos-apple-silicon && chmod +x Cascade-1.6.0-macos-apple-silicon
+> xattr -cr Cascade--macos-apple-silicon && chmod +x Cascade--macos-apple-silicon
> ```
> (les binaires ne sont pas signés — sans cela macOS annonce un « fichier endommagé »)
@@ -73,6 +74,7 @@ Pour quitter : bouton ⏻ en haut à droite, ou fermez simplement la fenêtre
- **Décalage réparti** : la première barre à 0°, la dernière à N°, étalé sur la sélection. Une vague traverse la scéno selon un axe réel avec un seul réglage.
- **Modulateur par couche** : un LFO qui fait respirer un réglage tout seul — niveau, largeur, vitesse, netteté, profondeur… Sa période se cale au choix en secondes ou sur le tempo, donc il ne dérive pas contre la musique. Il ne modifie jamais le réglage affiché, il se superpose : le couper rend la main immédiatement.
- **Modulateur global** : le même, branché sur le crossfader, le master ou la vitesse. Sur le crossfader, la scène passe d'un jeu à l'autre toute seule.
+- **Suiveur audio** : le micro devient une *source* de modulateur, à côté de l'oscillateur — la lumière respire avec le son. Analyse en Web Audio côté navigateur (zéro dépendance), avec bande, gain, seuil, attaque et relâchement. Il lit une **énergie**, pas un tempo. Le micro n'est lisible que sur la machine où tourne Cascade (les navigateurs l'interdisent depuis une adresse réseau), et si le son s'arrête, le réglage réglé à la main **reprend** — jamais figé, jamais tombé au noir.
- **Crossfader A ↔ B** : rangez vos couches en deux jeux et passez de l'un à l'autre à la main — ou au fader d'une console, en OSC. C'est la différence entre déclencher un preset et *jouer* un passage.
- **Coupure de secours** : met la sortie DMX de MadMapper à zéro d'un message. La seule voie qui coupe vraiment quand une texture joue — volontairement séparée de BLACKOUT.
@@ -96,7 +98,7 @@ Pour quitter : bouton ⏻ en haut à droite, ou fermez simplement la fenêtre
- **Intensité ou couleur** : dégradé A→B qui se déplace sur les barres, ou une **palette à 8 arrêts** (Feu, Glace, Coucher de soleil, Forêt, Distance).
- **Vue spatiale** : barres positionnables au doigt ou importées depuis MadMapper (position, rotation, taille), allumage en temps réel.
- **Groupes de barres nommés** : « sol », « contres »… Une couche qui suit un groupe se met à jour toute seule quand le groupe change.
-- **16 presets nommables** qui mémorisent les couches *et* la disposition complète — rappel instantané en live. « Refrain » se retrouve plus vite que « P7 ».
+- **Grille de 16 presets nommables** qui mémorisent les couches *et* la disposition complète — rappel instantané en live. « Refrain » se retrouve plus vite que « P7 ». Chaque pavé montre l'empreinte des barres qu'il pilote, lequel joue en ce moment, et signale un preset qui ne pilote plus rien.
- **Fondu entre presets** (0 à 30 s, réglable) : la scène sortante continue de jouer et décroît pendant que la nouvelle monte. À 0, le rappel reste sec.
- **Raccourcis clavier** : `S` start/stop, `B` blackout, `Espace` tap, `R` resync, `G` GO, `1`–`8` couches, `?` pour l'aide.
- **Tempo** : tap tempo, ÷2 ×2, resync sur le temps fort, vitesse par couche et vitesse globale.
@@ -115,7 +117,7 @@ Le paramètre d'intensité par défaut est `luminosity` (fixtures DMX). Pour des
L'adresse réseau s'affiche en haut de l'interface, ou scannez le **QR code**. Même Wi-Fi, c'est tout.
-> ⚠ **Il n'y a pas de mot de passe.** N'importe qui sur le même réseau peut piloter le show. En festival, préférez un point d'accès dédié.
+> 🔒 **Code d'accès facultatif.** ⚙ Réglages → un code à 4 chiffres protège l'accès **depuis le réseau** (iPad, téléphone, Wi-Fi de la salle). Il n'est jamais demandé sur la machine où tourne Cascade — impossible de s'enfermer dehors. Cinq essais ratés bloquent l'appareil une minute. L'OSC et le MIDI ne sont pas concernés : ils passent par un câble ou une console, c'est un autre domaine de confiance. Sans code posé, n'importe qui sur le même réseau peut piloter le show.
## Contrôle OSC
@@ -138,7 +140,7 @@ Le préfixe historique `/chaser/…` reste accepté.
## Développement
```bash
-npm test # 111 tests, sans aucune dépendance
+npm test # 262 tests, sans aucune dépendance
node build.js # exécutables des 4 plateformes → build/
```
diff --git a/dist/Cascade - Manuel.pdf b/dist/Cascade - Manuel.pdf
index f441ddd..5aa8852 100644
Binary files a/dist/Cascade - Manuel.pdf and b/dist/Cascade - Manuel.pdf differ
diff --git a/dist/package.json b/dist/package.json
index eec2281..c8420e3 100644
--- a/dist/package.json
+++ b/dist/package.json
@@ -1,6 +1,6 @@
{
"name": "cascade-madmapper",
- "version": "2.0.1",
+ "version": "2.0.2",
"description": "Cascade — séquenceur LED multi-couches pour MadMapper (OSC) : chases, vagues, couleur, presets, MIDI/OSC, Ableton Link",
"author": "Pierre-Yves Mansour — Collectif WSK",
"license": "MIT",
diff --git a/dist/public/index.html b/dist/public/index.html
index 55390d2..97433fe 100644
--- a/dist/public/index.html
+++ b/dist/public/index.html
@@ -259,31 +259,59 @@
.chip.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.chip.off { opacity: .45; }
.chip input { accent-color: var(--on); }
- /* Presets */
- #presets { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;
- position: relative; padding-bottom: 4px; }
+ /* Presets — une GRILLE de pavés, pas une rangée de boutons. En régie, dans le
+ noir, on vise une case dont on connaît la place ; une rangée qui se replie
+ déplace les slots dès qu'on change la largeur de la fenêtre. 8 colonnes sur
+ poste (2 rangées), 4 sur tablette, pour garder une cible cliquable au doigt. */
+ #presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px;
+ margin-bottom: 12px; position: relative; padding-bottom: 6px; }
+ @media (min-width: 1181px) { #presets { grid-template-columns: repeat(8, minmax(0, 1fr)); } }
+ /* Le libellé et le bouton Sauver traversent toute la grille */
+ #presets > span, #presets > button:not(.slot) { grid-column: 1 / -1; }
/* Fondu en cours : un filet de progression sous la rangée de presets */
#presets::after { content: ''; position: absolute; left: 0; bottom: 0; height: 2px;
width: var(--fade, 0%); background: var(--accent); border-radius: 2px;
opacity: 0; transition: opacity .2s; box-shadow: 0 0 8px var(--glow); }
#presets.fading::after { opacity: 1; }
- #presets button.slot { min-width: 34px; padding: 8px 0; font-weight: 700; position: relative;
- font-variant-numeric: tabular-nums; }
+ #presets button.slot { min-width: 0; padding: 7px 7px 6px; font-weight: 700; position: relative;
+ font-variant-numeric: tabular-nums; display: flex; flex-direction: column;
+ align-items: stretch; gap: 5px; min-height: 54px; text-align: left; }
+ /* Empreinte : une case par barre pilotée par le preset, teintée comme la
+ couche qui la pilote. C'est une couverture, PAS une image du motif — un
+ chase n'allume qu'une barre à la fois, une photo serait presque vide. */
+ #presets .emp { display: flex; gap: 1px; height: 6px; margin-top: auto;
+ border-radius: 2px; overflow: hidden; }
+ #presets .emp i { flex: 1 1 0; min-width: 0; background: var(--border); }
+ /* Ce preset ne pilote plus aucune barre : à dire, surtout pas à masquer */
+ #presets button.slot.muet { border-style: dashed; }
+ /* Le pavé qui joue, et celui d'où l'on vient pendant un fondu */
+ #presets button.slot.joue { border-color: var(--accent); color: var(--accent);
+ box-shadow: 0 0 10px var(--glow); }
+ #presets button.slot.sortante { border-color: var(--accent2); opacity: .7; }
/* Un slot occupé porte une pastille orange : on lit la banque d'un coup d'œil */
#presets button.slot:not(.empty)::after { content: ''; position: absolute; top: 4px; right: 4px;
width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
/* Preset nommé : le nom prime, le numéro reste lisible pour le MIDI/OSC */
- #presets button.slot.named { min-width: 0; padding: 6px 12px 6px 9px; display: inline-flex;
- align-items: baseline; gap: 6px; }
+ /* ⚠ Le pavé nommé garde la colonne : le numéro au-dessus, le nom en dessous,
+ l'empreinte en bas. Repasser en `inline-flex` remettrait l'empreinte sur la
+ même ligne que le nom. L'ordre numéro puis nom est un contrat de test. */
+ #presets button.slot.named { padding: 7px 7px 6px; }
#presets button.slot.named::after { display: none; }
- #presets .pnum { font-size: 10px; color: var(--dim); font-weight: 700; }
+ #presets .pnum { font-size: 10px; color: var(--dim); font-weight: 700; line-height: 1; }
#presets button.slot.named:hover .pnum { color: var(--accent); }
- #presets .pname { font-size: 12px; font-weight: 600; letter-spacing: .3px;
- max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #presets button.slot.joue .pnum { color: var(--accent); }
+ #presets .pname { font-size: 12px; font-weight: 600; letter-spacing: .3px; line-height: 1.2;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#presets button.empty { opacity: .4; font-weight: 400; }
#presets button.savemode { border-color: var(--off); color: var(--off);
animation: blinkSave 1s steps(2, start) infinite; }
@keyframes blinkSave { 50% { opacity: .55; } }
+ /* Suiveur audio : un vu-mètre, seule CSS nouvelle du panneau — le reste
+ hérite du style générique des replis `details` déjà en place. */
+ #audioVu { display: block; width: 100%; height: 8px; border-radius: 4px;
+ background: var(--panel2); overflow: hidden; }
+ #audioVu i { display: block; height: 100%; width: 0; background: var(--accent);
+ transition: width .08s linear; }
/* Vue spatiale */
#stage { position: relative; height: 320px;
background: radial-gradient(ellipse at 50% 120%, #141926 0%, #0a0c10 70%);
@@ -374,6 +402,17 @@ Raccourcis & gestes
Double-clic Sur une ligne de réglage : retour à la valeur par défaut
Double-clic Sur un nom de couche : renommer · sur une barre : pivoter
Double-clic Sur un preset : renommer
+ Code d'accès
+ À quoi ça sert Un code facultatif à 4 chiffres (⚙ Réglages) protège l'accès depuis le réseau — iPad, téléphone, Wi-Fi de la salle.
+ Jamais enfermé dehors Il n'est jamais demandé sur la machine où tourne Cascade : il y a donc toujours une voie pour le retirer.
+ Ce qu'il ne couvre pas L'OSC et le MIDI : ils passent par un câble ou une console sur le réseau de production, c'est un autre domaine de confiance.
+ Essais limités Cinq essais ratés bloquent l'appareil une minute — sans ça, 10 000 combinaisons se passeraient en revue en quelques secondes. Changer ou retirer le code déconnecte les appareils déjà entrés.
+ Suiveur audio
+ Principe Le micro est une source de modulateur , comme l'oscillateur : Global → repli Suiveur audio , puis Source = Micro sur le modulateur d'une couche ou sur le modulateur global.
+ Ce qu'il lit Une énergie , pas un tempo : il fait respirer la lumière avec le son. Il ne détecte pas les battements et ne cale pas le chase sur la musique.
+ Limite Le micro n'est lisible que sur la machine où tourne Cascade — jamais depuis une adresse réseau, les navigateurs l'interdisent. Depuis un iPad, tout le reste fonctionne.
+ Sécurité Si le son s'arrête d'arriver (onglet fermé, micro débranché), le réglage revient à ce que vous aviez posé — il ne reste jamais figé sur la dernière valeur entendue.
+ Grille de presets La bande de couleur montre les barres que le preset pilote — une couverture, pas une image du motif. Bord en pointillé : ce preset ne pilote plus aucune barre. Pavé orange : le dernier rappelé.
Glisser Une barre dans la vue spatiale : la déplacer
Page Scène (vue 3D)
Glisser une barre La déplacer · Maj = en hauteur · Alt = l'orienter
@@ -474,7 +513,7 @@ Premiers pas
sur un iPad en un scan.
-
+
+
+ Mélange & espace
Fusion
HTP — le plus fort gagne
@@ -643,6 +687,7 @@ Pattern
Toujours A B
+
Modulateur
— aucun —
@@ -658,6 +703,12 @@ Pattern
+
Source
+
+ Oscillateur (boucle)
+ Micro (suiveur audio)
+
+
Forme
Sinus (respiration)
@@ -766,6 +817,12 @@ Global
+ Source
+
+ Oscillateur (boucle)
+ Micro (suiveur audio)
+
+
Forme
Sinus (respiration)
@@ -792,6 +849,38 @@ Global
+
+ Suiveur audio
+ Le micro devient une source de modulateur , à côté de l'oscillateur :
+ choisissez « Micro » comme Source sur le modulateur d'une couche, ou sur le modulateur global.
+ Il lit une énergie — il ne détecte pas les battements et ne cale pas le tempo.
+ ⚠ Le micro n'est lisible que sur la machine où tourne Cascade , jamais depuis une
+ adresse réseau : les navigateurs interdisent l'accès au micro hors origine sûre. Depuis un iPad,
+ tout le reste fonctionne.
+ Micro
+ 🎤 Écouter
+ Entrée
+ — par défaut —
+ Bande
+
+ Grave (grosse caisse, basse)
+ Médium
+ Aigu (cymbales, voix)
+ Tout le spectre
+
+
+ Gain
+
+ Seuil
+
+ Attaque
+
+ Relâchement
+
+ Niveau
+
+ Micro à l'arrêt.
+
Fondu presets
Courbe dimmer
@@ -917,6 +1006,18 @@ Barre sélectionnée
+
+ Code d'accès
+ Cette régie est protégée par un code à
+ 4 chiffres. Demande-le à la personne qui tient la lumière.
+
+
+
+ Entrer
+
+
+
Connexion OSC MadMapper
Adresse IP (127.0.0.1 si même machine)
@@ -936,6 +1037,28 @@ Connexion OSC MadMapper
Port OSC entrant (contrôleur externe → cette app)
+ Code d'accès à 4 chiffres
+
+ Icône près de l'horloge
+
+ ⚠ Windows uniquement
+ (macOS n'a pas d'équivalent pilotable sans installer autre chose), et jamais essayée
+ en vrai : elle a été écrite depuis une machine Linux. Si elle ne fait rien, elle ne
+ casse rien non plus — dites-le et on corrige.
+ 💡 Windows range
+ toute nouvelle icône dans le tiroir caché : si vous ne la voyez pas près de
+ l'heure, cliquez le chevron ^ à gauche de l'horloge et faites-la glisser
+ dehors. Ce n'est à faire qu'une fois.
+
+ 🔓 Retirer le code
+
+ Facultatif. Il protège l'accès depuis le
+ réseau — iPad, téléphone, Wi-Fi de la salle. Il n'est jamais demandé sur cette
+ machine , donc on ne peut pas s'enfermer dehors. L'OSC et le MIDI ne sont pas concernés :
+ ils passent par un câble ou une console, c'est un autre domaine de confiance.
+ Changer ou retirer le code déconnecte les appareils déjà entrés .
Annuler
Enregistrer
@@ -990,6 +1113,25 @@
Diagnostic OSC
// null = inactif · {type:'layer'} = pour la couche courante · {type:'group', id}
let assignMode = null;
let saveMode = false; // presets : prochain clic = sauvegarde
+// Empreintes des presets. Elles ne voyagent PAS dans /api/state (poll 120 ms) :
+// on les retire seulement quand le serveur signale que la banque a changé, via
+// son compteur de révision. `infoEnCours` évite d'empiler les requêtes si le
+// réseau traîne ; un échec laisse simplement les pavés sans bande, sans bruit.
+let infoRev = -1, infosPresets = null, infoEnCours = false;
+function chargerInfosPresets() {
+ if (!S || infoEnCours || S.presetsRev === infoRev) return;
+ infoEnCours = true;
+ fetch('/api/presets-info')
+ .then(r => r.json())
+ .then(r => {
+ if (!r || !r.ok) return;
+ infosPresets = r.infos; infoRev = r.rev;
+ const pr = $('#presets');
+ if (pr) pr.dataset.sig = ''; // force un re-rendu des pavés
+ })
+ .catch(() => {}) // hors ligne : les pavés restent lisibles, sans empreinte
+ .finally(() => { infoEnCours = false; });
+}
const post = (url, body = {}) => fetch(url, { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
@@ -1139,8 +1281,13 @@
Diagnostic OSC
// presets
const pr = $('#presets');
+ chargerInfosPresets();
// La signature inclut les NOMS : sinon un renommage ne se verrait jamais.
- const psig = S.presets.map(p => p || '·').join('') + (saveMode ? 'S' : '');
+ // `infoRev` en fait partie pour que des empreintes fraîchement chargées
+ // déclenchent le re-rendu. En revanche presetActif, fade et fadeDe en sont
+ // EXCLUS, à dessein : ils changent à chaque poll, et les inclure
+ // reconstruirait les 16 pavés huit fois par seconde, en plein spectacle.
+ const psig = S.presets.map(p => p || '·').join('') + '|' + infoRev + (saveMode ? 'S' : '');
if (pr.dataset.sig !== psig) {
pr.dataset.sig = psig;
pr.innerHTML = '
PRESETS ';
@@ -1159,9 +1306,39 @@
Diagnostic OSC
} else {
b.textContent = i + 1;
}
+ // Empreinte : une case par barre pilotée, teintée comme sa couche.
+ // ⚠ Aucun texte dans ces éléments — le textContent du pavé doit rester
+ // « 1Refrain », c'est un contrat vérifié par les tests d'interface.
+ const info = infosPresets && infosPresets[i];
+ if (info && info.c) {
+ const emp = document.createElement('span');
+ emp.className = 'emp';
+ let peintes = 0;
+ for (const ch of info.c) {
+ const cell = document.createElement('i');
+ if (ch !== '.') {
+ const coul = info.pal[+ch];
+ if (coul) { cell.style.background = coul; peintes++; }
+ }
+ emp.appendChild(cell);
+ }
+ b.appendChild(emp);
+ // Un preset qui ne pilote plus aucune barre : le DIRE. C'est
+ // l'information la plus utile de la grille avant de tirer un pavé —
+ // le masquer laisserait croire à une panne au moment du rappel.
+ if (!peintes) b.classList.add('muet');
+ }
+ // L'infobulle porte ce que l'empreinte ne peut pas montrer : le nombre de
+ // couches actives et leurs moteurs (P = pas-à-pas, V = vague, C = champ).
+ const detail = info
+ ? '\n' + info.a + ' couche' + (info.a > 1 ? 's' : '') + ' active'
+ + (info.a > 1 ? 's' : '') + ' sur ' + info.t
+ + (info.e ? ' — moteurs ' + info.e.split('').join(', ') : '')
+ + (info.c && !/[^.]/.test(info.c) ? '\n⚠ ne pilote aucune barre' : '')
+ : '';
b.title = saveMode ? 'Enregistrer ici' : (p
? 'Rappeler « ' + p +' » : couches + ordre des fixtures + vue spatiale'
- + '\nDouble-clic : renommer'
+ + detail + '\nDouble-clic : renommer'
: 'Slot vide');
b.onclick = () => {
if (saveMode) {
@@ -1303,12 +1480,19 @@
Diagnostic OSC
if (document.activeElement !== $('#lfoParam')) $('#lfoParam').value = m ? m.param : '';
$('#blocLfo').style.display = m ? '' : 'none';
if (m) {
+ if (document.activeElement !== $('#lfoSrc')) $('#lfoSrc').value = m.src || 'lfo';
if (document.activeElement !== $('#lfoForme')) $('#lfoForme').value = m.forme;
$('#lfoSync').checked = !!m.sync;
+ // En source micro, la forme d'onde et la période ne veulent plus rien
+ // dire : c'est le son qui donne le mouvement. Les laisser affichés ferait
+ // régler des boutons sans effet — la panne la plus agaçante qui soit.
+ const mic = m.src === 'audio';
+ $('#lfoForme').closest('label').style.display = mic ? 'none' : '';
+ $('#lfoSync').closest('label').style.display = mic ? 'none' : '';
// Un seul des deux réglages de durée est montré : afficher une période en
// secondes que le serveur ignore ferait chercher longtemps.
- $('#rowLfoPeriode').style.display = m.sync ? 'none' : '';
- $('#rowLfoCycles').style.display = m.sync ? '' : 'none';
+ $('#rowLfoPeriode').style.display = (mic || m.sync) ? 'none' : '';
+ $('#rowLfoCycles').style.display = (!mic && m.sync) ? '' : 'none';
if (document.activeElement !== $('#lfoCycles')) $('#lfoCycles').value = String(m.cycles);
setSlider('#lfoPeriode', periodeVersCurseur(m.periodeMs),
v => { const ms = curseurVersPeriode(v);
@@ -1360,6 +1544,35 @@
Diagnostic OSC
// le second miroir donnerait une case sans effet — et une case sans effet
// ressemble à une panne.
$('#rowMirrorV').style.display = champ ? 'none' : '';
+ // Pastilles des deux nouveaux replis, sur le motif exact de « Groove ». Sans
+ // elles, un miroir ou une fusion oubliés derrière un repli fermé deviennent
+ // un mystère — c'est précisément ce que la pastille de Groove évite déjà.
+ {
+ const actifsM = [
+ L.mirrorH && 'miroir ↔',
+ L.mirrorV && !champ && 'miroir ↕',
+ ].filter(Boolean);
+ const bM = $('#miroirsBadge');
+ bM.textContent = actifsM.length ? actifsM.length + (actifsM.length > 1 ? ' actifs' : ' actif') : '';
+ bM.title = actifsM.join(', ');
+ const detM = $('#advMiroirs');
+ if (detM.dataset.vu !== selId) { detM.dataset.vu = selId; if (actifsM.length) detM.open = true; }
+
+ const actifsX = [
+ (L.blend || 'htp') !== 'htp' && 'fusion ' + (L.blend || 'htp'),
+ L.prof && 'profondeur',
+ L.spread && 'décalage réparti',
+ L.deck && 'jeu ' + String(L.deck).toUpperCase(),
+ ].filter(Boolean);
+ const bX = $('#melangeBadge');
+ bX.textContent = actifsX.length ? actifsX.length + (actifsX.length > 1 ? ' actifs' : ' actif') : '';
+ bX.title = actifsX.join(', ');
+ const detX = $('#advMelange');
+ // ⚠ Ouverture automatique SEULEMENT au changement de couche : sinon
+ // l'utilisateur ne pourrait jamais refermer le repli (piège déjà payé sur
+ // « Groove »).
+ if (detX.dataset.vu !== selId) { detX.dataset.vu = selId; if (actifsX.length) detX.open = true; }
+ }
renderBarsTarget(L);
renderGroups();
@@ -1387,10 +1600,14 @@
Diagnostic OSC
if (document.activeElement !== $('#mgParam')) $('#mgParam').value = g ? g.param : '';
$('#blocMg').style.display = g ? '' : 'none';
if (g) {
+ if (document.activeElement !== $('#mgSrc')) $('#mgSrc').value = g.src || 'lfo';
if (document.activeElement !== $('#mgForme')) $('#mgForme').value = g.forme;
$('#mgSync').checked = !!g.sync;
- $('#rowMgPeriode').style.display = g.sync ? 'none' : '';
- $('#rowMgCycles').style.display = g.sync ? '' : 'none';
+ const micG = g.src === 'audio';
+ $('#mgForme').closest('label').style.display = micG ? 'none' : '';
+ $('#mgSync').closest('label').style.display = micG ? 'none' : '';
+ $('#rowMgPeriode').style.display = (micG || g.sync) ? 'none' : '';
+ $('#rowMgCycles').style.display = (!micG && g.sync) ? '' : 'none';
if (document.activeElement !== $('#mgCycles')) $('#mgCycles').value = String(g.cycles);
setSlider('#mgPeriode', periodeVersCurseur(g.periodeMs),
v => { const ms = curseurVersPeriode(v);
@@ -1399,6 +1616,23 @@
Diagnostic OSC
if (document.activeElement !== $('#mgMax')) $('#mgMax').value = g.max;
}
}
+ {
+ // Suiveur audio : les curseurs et la pastille du repli. La pastille dit s'il
+ // écoute, pour qu'un repli fermé ne cache pas un micro resté ouvert.
+ const st = S.settings || {};
+ setSlider('#audioGain', Math.round(((st.audioGain == null ? 1 : st.audioGain) - 0.1) / 4.9 * 100),
+ v => (0.1 + v / 100 * 4.9).toFixed(1) + ' ×');
+ setSlider('#audioSeuil', Math.round((st.audioSeuil == null ? 0.06 : st.audioSeuil) / 0.9 * 100),
+ v => (v / 100 * 0.9 * 100).toFixed(0) + ' %');
+ setSlider('#audioAttaque', Math.round(((st.audioAttaque || 12) - 1) / 199 * 100),
+ v => Math.round(1 + v / 100 * 199) + ' ms');
+ setSlider('#audioRelache', Math.round(((st.audioRelache || 260) - 20) / 1480 * 100),
+ v => Math.round(20 + v / 100 * 1480) + ' ms');
+ if (document.activeElement !== $('#audioBande')) $('#audioBande').value = st.audioBande || 'grave';
+ const badge = $('#audioBadge');
+ badge.textContent = suiveur.actif ? 'à l’écoute' : '';
+ badge.title = suiveur.actif ? 'Le micro est ouvert et alimente les modulateurs réglés sur « Micro ».' : '';
+ }
setSlider('#presetFade', fadeToSlider(S.global.presetFade || 0),
v => { const ms = fadeFromSlider(v); return ms ? (ms < 1000 ? ms + ' ms' : (ms / 1000).toFixed(1) + ' s') : 'sec'; });
if (document.activeElement !== $('#dimmer')) $('#dimmer').value = S.global.dimmer || 'linear';
@@ -1406,6 +1640,18 @@
Diagnostic OSC
const barrePresets = $('#presets');
barrePresets.classList.toggle('fading', S.fade != null);
barrePresets.style.setProperty('--fade', ((S.fade || 0) * 100).toFixed(1) + '%');
+ // Quel pavé joue, et d'où l'on vient pendant un fondu. Appliqué à CHAQUE poll
+ // mais hors de la signature de rendu : on repeint deux classes, on ne
+ // reconstruit pas les 16 pavés. Les accolades gardent ces locales hors de la
+ // portée de render(), où un `const` en double ferait tomber un garde-fou.
+ {
+ const pads = barrePresets.querySelectorAll('button.slot');
+ const sortant = S.fade != null ? S.fadeDe : null;
+ pads.forEach((b, i) => {
+ b.classList.toggle('joue', S.presetActif === i);
+ b.classList.toggle('sortante', sortant === i && S.presetActif !== i);
+ });
+ }
$('#bpm').innerHTML = '—
' + Math.round(60000 / L.stepMs) + ' BPM (pas)';
// Ableton Link : quand il pilote, le tempo manuel est neutralisé
@@ -2134,6 +2380,17 @@
Diagnostic OSC
$('#btnGo').onclick = () => post('/api/resync', { id: sel().id });
$('#dimmer').onchange = e => post('/api/global', { dimmer: e.target.value }).then(poll);
$('#presetFade').oninput = e => post('/api/global', { presetFade: fadeFromSlider(+e.target.value) });
+
+// Suiveur audio — la calibration vit dans la configuration du serveur, donc elle
+// voyage avec le projet sur la clé USB, au lieu de rester dans ce navigateur.
+$('#audioBande').onchange = e => post('/api/settings', { audioBande: e.target.value });
+$('#audioGain').oninput = e => post('/api/settings', { audioGain: 0.1 + (+e.target.value / 100) * 4.9 });
+$('#audioSeuil').oninput = e => post('/api/settings', { audioSeuil: (+e.target.value / 100) * 0.9 });
+$('#audioAttaque').oninput = e => post('/api/settings', { audioAttaque: 1 + (+e.target.value / 100) * 199 });
+$('#audioRelache').oninput = e => post('/api/settings', { audioRelache: 20 + (+e.target.value / 100) * 1480 });
+$('#lfoSrc').onchange = (e) =>
+ setL({ lfo: { ...lfoCourant(), src: e.target.value } }).then(poll);
+$('#mgSrc').onchange = (e) => posterMg({ src: e.target.value });
$('#mirrorH').onchange = e => setL({ mirrorH: e.target.checked }).then(poll);
$('#mirrorV').onchange = e => setL({ mirrorV: e.target.checked }).then(poll);
$('#axisX').oninput = e => setL({ axisX: +e.target.value / 100 });
@@ -2454,6 +2711,173 @@
Diagnostic OSC
const target = (S.midiMap || {})[key];
if (target) midiApply(target, v01, isTrigger);
}
+/* ─── Suiveur audio ───────────────────────────────────────────────────────────
+ * Le micro devient une SOURCE de modulateur, à côté de l'oscillateur.
+ *
+ * L'analyse vit ici, dans le navigateur : le zéro-dépendance interdit une
+ * entrée son côté serveur. Le niveau part ensuite au serveur en paramètre du
+ * poll qu'on fait déjà — aucune requête de plus.
+ *
+ * ⚠ Le micro n'est accessible qu'en ORIGINE SÛRE (https ou localhost). Depuis
+ * un iPad sur http://
:3333, `navigator.mediaDevices` n'existe pas. C'est
+ * assumé et dit à l'écran, comme pour Web MIDI qui n'existe que sur Chrome et
+ * Edge : le reste de Cascade continue de marcher depuis la tablette.
+ */
+const suiveur = { actif: false, demarrage: false, niv: 0, ctx: null, analyseur: null,
+ flux: null, bins: null, dernier: 0, etatDsp: { niveau: 0 } };
+
+/** Bornes des bandes, en fraction du spectre analysé. */
+const AUDIO_BANDES = { grave: [0, 0.08], medium: [0.08, 0.35], aigu: [0.35, 1], tout: [0, 1] };
+
+/**
+ * Enveloppe audio — fonction PURE, sans DOM ni AudioContext.
+ *
+ * Isolée exprès : c'est ce qui rend la DSP testable sans micro, avec un spectre
+ * fabriqué à la main. Rend le niveau 0..1 et le nouvel état du lissage.
+ *
+ * `reglages` : { bande, gain, seuil, attaque, relache } (attaque/relache en ms)
+ */
+function enveloppeAudio(bins, dt, reglages, etat) {
+ const [d, f] = AUDIO_BANDES[reglages.bande] || AUDIO_BANDES.tout;
+ const i0 = Math.floor(d * bins.length), i1 = Math.max(i0 + 1, Math.floor(f * bins.length));
+ let somme = 0;
+ for (let i = i0; i < i1; i++) somme += bins[i];
+ let v = somme / (i1 - i0) / 255;
+ // Porte de bruit AVEC ré-étalement : sans lui, la course utile serait amputée
+ // du seuil et le suiveur deviendrait mou dès qu'on coupe un peu de souffle.
+ const seuil = reglages.seuil;
+ v = v <= seuil ? 0 : (v - seuil) / (1 - seuil);
+ v = Math.min(1, v * reglages.gain);
+ // Lissage exponentiel ASYMÉTRIQUE : on monte vite et on redescend lentement.
+ // C'est ce qui fait une respiration plutôt qu'un clignotement.
+ const cible = v;
+ const tau = cible > etat.niveau ? reglages.attaque : reglages.relache;
+ const k = 1 - Math.exp(-dt / Math.max(1, tau));
+ const niveau = etat.niveau + (cible - etat.niveau) * k;
+ return { niveau: Math.max(0, Math.min(1, niveau)), etat: { niveau } };
+}
+
+/** Réglages courants du suiveur, lus dans la configuration du serveur. */
+function reglagesAudio() {
+ const s = (S && S.settings) || {};
+ return { bande: s.audioBande || 'grave', gain: s.audioGain == null ? 1 : s.audioGain,
+ seuil: s.audioSeuil == null ? 0.06 : s.audioSeuil,
+ attaque: s.audioAttaque || 12, relache: s.audioRelache || 260 };
+}
+
+function etatAudio(txt) { $('#audioEtat').textContent = txt; }
+
+/** Liste les entrées son. ⚠ Un nom de périphérique est une donnée EXTERNE. */
+function remplirEntrees(list) {
+ const sel = $('#audioEntree');
+ const garde = sel.value;
+ sel.textContent = '';
+ const def = document.createElement('option');
+ def.value = ''; def.textContent = '— par défaut —';
+ sel.appendChild(def);
+ list.forEach((d, i) => {
+ const o = document.createElement('option');
+ o.value = d.deviceId || '';
+ // textContent, JAMAIS innerHTML : un micro peut s'appeler ` `.
+ o.textContent = d.label || ('Entrée ' + (i + 1));
+ sel.appendChild(o);
+ });
+ sel.value = garde;
+}
+
+async function demarrerAudio() {
+ if (suiveur.actif) { arreterAudio(); return; }
+ // ⚠ GARDE SYNCHRONE, avant le moindre `await`. `suiveur.actif` n'est posé
+ // qu'après `getUserMedia` ET `ctx.resume()` — or `getUserMedia` bloque sur la
+ // demande d'autorisation du navigateur, le moment le PLUS long. Un régisseur
+ // qui reclique parce qu'il ne voit rien se retrouvait avec deux démarrages en
+ // vol : le second écrasait `flux` et `ctx`, laissant les premiers orphelins.
+ // Micro jamais relâché (voyant allumé), AudioContext fuité — et les
+ // navigateurs en plafonnent une poignée — plus deux boucles d'analyse.
+ if (suiveur.demarrage) return;
+ suiveur.demarrage = true;
+ try {
+ await ouvrirAudio();
+ } finally { suiveur.demarrage = false; }
+}
+
+async function ouvrirAudio() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ etatAudio('Le micro n’est lisible que sur la machine où tourne Cascade '
+ + '(les navigateurs l’interdisent depuis une adresse réseau). Tout le reste fonctionne.');
+ return;
+ }
+ const dev = $('#audioEntree').value;
+ try {
+ // Pas de traitement de la voix : on veut le son de la salle, pas une
+ // conversation nettoyée. L'annulation d'écho écraserait la musique.
+ suiveur.flux = await navigator.mediaDevices.getUserMedia({ audio: {
+ deviceId: dev ? { exact: dev } : undefined,
+ echoCancellation: false, noiseSuppression: false, autoGainControl: false } });
+ } catch (e) {
+ etatAudio('Micro refusé ou indisponible. Cascade continue sans lui.');
+ return;
+ }
+ try {
+ suiveur.ctx = new (window.AudioContext || window.webkitAudioContext)();
+ if (suiveur.ctx.state === 'suspended') await suiveur.ctx.resume();
+ suiveur.analyseur = suiveur.ctx.createAnalyser();
+ suiveur.analyseur.fftSize = 1024;
+ suiveur.analyseur.smoothingTimeConstant = 0.5;
+ suiveur.ctx.createMediaStreamSource(suiveur.flux).connect(suiveur.analyseur);
+ suiveur.bins = new Uint8Array(suiveur.analyseur.frequencyBinCount);
+ suiveur.etatDsp = { niveau: 0 };
+ suiveur.dernier = 0;
+ suiveur.actif = true;
+ $('#btnAudio').classList.add('active');
+ $('#btnAudio').textContent = '🎤 Écoute…';
+ etatAudio('Le micro écoute. Choisissez « Micro » comme Source sur un modulateur.');
+ boucleAudio();
+ // ⚠ AUCUN `await` après `actif = true`. Sinon `actif` et `demarrage` sont
+ // vrais en même temps le temps de cet appel, et un changement d'entrée son
+ // pile à cet instant (`arreterAudio()` puis `demarrerAudio()`) tombait sur
+ // la garde et ne redémarrait rien. La liste est un confort : elle se
+ // remplit quand elle peut.
+ navigator.mediaDevices.enumerateDevices()
+ .then(l => remplirEntrees(l.filter(d => d.kind === 'audioinput')))
+ .catch(() => {});
+ } catch (e) {
+ etatAudio('L’analyse audio n’a pas pu démarrer sur ce navigateur.');
+ arreterAudio();
+ }
+}
+
+function arreterAudio() {
+ suiveur.actif = false;
+ suiveur.niv = 0;
+ if (suiveur.flux) { try { suiveur.flux.getTracks().forEach(t => t.stop()); } catch (e) {} }
+ if (suiveur.ctx) { try { suiveur.ctx.close(); } catch (e) {} }
+ suiveur.flux = null; suiveur.ctx = null; suiveur.analyseur = null;
+ $('#btnAudio').classList.remove('active');
+ $('#btnAudio').textContent = '🎤 Écouter';
+ $('#audioVu').firstElementChild.style.width = '0%';
+ etatAudio('Micro à l’arrêt.');
+}
+
+/**
+ * Boucle d'analyse, calée sur l'affichage. Elle ne fait QUE mesurer : le niveau
+ * part au serveur avec le poll suivant, pas dans une requête à elle.
+ */
+function boucleAudio() {
+ if (!suiveur.actif) return;
+ const now = performance.now();
+ const dt = suiveur.dernier ? Math.min(200, now - suiveur.dernier) : 16;
+ suiveur.dernier = now;
+ suiveur.analyseur.getByteFrequencyData(suiveur.bins);
+ const r = enveloppeAudio(suiveur.bins, dt, reglagesAudio(), suiveur.etatDsp);
+ suiveur.niv = r.niveau; suiveur.etatDsp = r.etat;
+ $('#audioVu').firstElementChild.style.width = (r.niveau * 100).toFixed(1) + '%';
+ requestAnimationFrame(boucleAudio);
+}
+
+$('#btnAudio').onclick = demarrerAudio;
+$('#audioEntree').onchange = () => { if (suiveur.actif) { arreterAudio(); demarrerAudio(); } };
+
function initMidi() {
if (!navigator.requestMIDIAccess) return;
navigator.requestMIDIAccess({ sysex: false }).then(acc => {
@@ -2508,6 +2932,22 @@ Diagnostic OSC
$('#setFb').value = S.settings.feedbackPort;
$('#setParam').value = S.global.param;
$('#setOscIn').value = S.settings.oscInPort || 7000;
+ // Le code lui-même n'est JAMAIS renvoyé par le serveur : on ne peut afficher
+ // que son existence. Le champ part donc vide, et un champ vide ne change rien.
+ $('#setAcces').value = '';
+ $('#setSystray').checked = !!S.settings.systray;
+ // Ne pas proposer une case qui ne peut rien faire : hors Windows, on le dit.
+ // ⚠ On demande au SERVEUR, pas au navigateur : le réglage agit sur la machine
+ // qui fait tourner Cascade, et cette page s'ouvre aussi bien depuis un iPad.
+ const surWindows = !!S.win;
+ $('#rowSystray').style.opacity = surWindows ? '1' : '0.45';
+ $('#systrayNote').style.display = surWindows ? 'none' : '';
+ $('#systrayOu').style.display = surWindows ? '' : 'none';
+ const a = (S.acces || {});
+ $('#accesEtat').textContent = a.actif
+ ? '— actif. Laisser vide ne change rien ; taper 4 chiffres le remplace.'
+ : '— aucun code pour l’instant.';
+ $('#btnAccesRetirer').style.display = a.actif ? '' : 'none';
$('#dlgSettings').showModal();
};
// « Trouver le port » : le port d'entrée OSC de MadMapper est un réglage de
@@ -2537,11 +2977,84 @@ Diagnostic OSC
};
$('#setCancel').onclick = () => $('#dlgSettings').close();
$('#setSave').onclick = async () => {
- await post('/api/settings', { mmHost: $('#setHost').value.trim(), mmPort: +$('#setPort').value, feedbackPort: +$('#setFb').value, oscInPort: +$('#setOscIn').value });
+ // Un SEUL envoi de réglages. Deux, c'étaient deux sauvegardes de config et
+ // deux évaluations de l'icône par enregistrement — de quoi tuer puis relancer
+ // le script coup sur coup.
+ await post('/api/settings', {
+ mmHost: $('#setHost').value.trim(), mmPort: +$('#setPort').value,
+ feedbackPort: +$('#setFb').value, oscInPort: +$('#setOscIn').value,
+ systray: $('#setSystray').checked,
+ });
await post('/api/global', { param: $('#setParam').value.trim() || 'luminosity' });
+ // ⚠ UN CHAMP VIDE NE TOUCHE À RIEN. Une première version envoyait alors un
+ // retrait — or le champ est vidé à CHAQUE ouverture du dialogue (le serveur ne
+ // renvoie jamais le code). Ouvrir Réglages pour changer le port MadMapper et
+ // enregistrer supprimait donc le code d'accès en silence, et déconnectait
+ // toutes les tablettes. Le retrait est maintenant un geste explicite : le
+ // bouton « Retirer le code », qui demande confirmation.
+ const nouveau = $('#setAcces').value.trim();
+ if (nouveau) {
+ const r = await post('/api/acces', { nouveau });
+ if (r && r.ok === false && r.error) { toast(r.error); return; }
+ $('#setAcces').value = '';
+ toast('Code d’accès enregistré.');
+ }
$('#dlgSettings').close(); poll();
};
+/* ─── Code d'accès ────────────────────────────────────────────────────────────
+ * Quand le serveur répond 401, on demande le code. Le jeton revient dans un
+ * cookie HttpOnly : la page ne le voit jamais, donc un nom de fixture piégé ne
+ * peut pas le faire fuir. Rien à stocker ici.
+ */
+let accesDemande = false;
+function demanderAcces() {
+ if (accesDemande) return;
+ accesDemande = true;
+ $('#accesErreur').textContent = '';
+ $('#accesCode').value = '';
+ if (!$('#dlgAcces').open) $('#dlgAcces').showModal();
+ $('#accesCode').focus();
+}
+async function envoyerAcces() {
+ const code = $('#accesCode').value.trim();
+ if (!/^\d{4}$/.test(code)) { $('#accesErreur').textContent = 'Quatre chiffres.'; return; }
+ let r;
+ try {
+ r = await fetch('/api/acces', { method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code }) }).then(x => x.json());
+ } catch (e) { $('#accesErreur').textContent = 'Serveur injoignable.'; return; }
+ if (r && r.ok) {
+ accesDemande = false;
+ $('#dlgAcces').close();
+ poll();
+ return;
+ }
+ $('#accesCode').value = '';
+ $('#accesCode').focus();
+ // On dit combien d'essais restent : sans ça, le blocage ressemble à une panne.
+ $('#accesErreur').textContent = r && r.attendre
+ ? 'Trop d’essais. Réessaie dans ' + r.attendre + ' s.'
+ : 'Code incorrect.' + (r && r.restants != null ? ' Encore ' + r.restants + ' essai'
+ + (r.restants > 1 ? 's' : '') + '.' : '');
+}
+$('#btnAccesRetirer').onclick = async () => {
+ // Retirer un code déconnecte toutes les tablettes déjà entrées : ça se
+ // confirme. C'est le seul chemin qui supprime le code.
+ if (!confirm('Retirer le code d’accès ?\n\nCascade redeviendra pilotable par '
+ + 'n’importe qui sur le réseau, et les appareils déjà connectés seront déconnectés.')) return;
+ const r = await post('/api/acces', { nouveau: '' });
+ if (r && r.ok === false && r.error) { toast(r.error); return; }
+ toast('Code d’accès retiré.');
+ $('#dlgSettings').close();
+ poll();
+};
+
+$('#accesOk').onclick = envoyerAcces;
+$('#accesCode').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); envoyerAcces(); } };
+// Un dialogue modal se ferme sur Échap : ici il n'y a rien derrière, on le rouvre.
+$('#dlgAcces').addEventListener('close', () => { if (accesDemande) setTimeout(demanderAcces, 50); });
+
// ═══════════════════════════════════════════════════════════════════════
// PAGE SCÈNE 3D
//
@@ -3288,7 +3801,14 @@ Diagnostic OSC
// ── Boucle d'état ────────────────────────────────────────────────────
async function poll() {
try {
- const ns = await fetch('/api/state').then(r => r.json());
+ // Le niveau du micro voyage sur le poll qu'on fait déjà : ~8 octets sur la
+ // ligne de requête, zéro dans la réponse. Sans micro, rien n'est ajouté —
+ // un iPad ne peut donc pas écraser ce qu'entend la machine hôte.
+ const rep = await fetch('/api/state' + (suiveur.actif ? '?a=' + suiveur.niv.toFixed(3) : ''));
+ // 401 = code d'accès demandé. Sans ce test, la réponse d'erreur remplacerait
+ // l'état et render() planterait sur un objet qui n'a ni couches ni fixtures.
+ if (rep.status === 401) { demanderAcces(); return; }
+ const ns = await rep.json();
if (dragging && S) { S.levels = ns.levels; S.colors = ns.colors; S.global = ns.global; }
else S = ns;
$('#status').classList.add('ok');
diff --git a/dist/server.js b/dist/server.js
index ace9733..49c4b44 100644
--- a/dist/server.js
+++ b/dist/server.js
@@ -14,11 +14,15 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec, spawn } = require('child_process');
+const crypto = require('crypto');
const APP_NAME = 'Cascade';
-const VERSION = '2.0.1';
+const VERSION = '2.0.2';
const SIGNATURE = 'Pierre-Yves Mansour — Collectif WSK';
const PRESET_SLOTS = 16;
+// Orange signature de la charte (`--accent` dans public/index.html). Sert aux
+// empreintes de presets, où une couche d'intensité n'a pas de couleur à montrer.
+const ACCENT = '#f2900f';
const MAX_LAYERS = 8;
const MAX_FIXTURES = 128;
const MAX_MIDI_BINDINGS = 256;
@@ -36,6 +40,11 @@ const BACKUP_FILE = CONFIG_FILE + '.bak';
const LEGACY_FILE = process.env.CASCADE_CONFIG ? null : path.join(DATA_DIR, 'chaser-config.json');
const NO_BROWSER = process.env.CASCADE_NO_BROWSER === '1';
const NO_AUTOQUIT = process.env.CASCADE_NO_AUTOQUIT === '1';
+// Délai de grâce de l'arrêt automatique. Réglable pour que les tests puissent
+// le MESURER en une seconde au lieu de neuf : sans ça, la seule vérification
+// possible serait indirecte, et c'est exactement comme ça qu'on ne voit pas
+// qu'un sondage d'icône réarme le compteur.
+const UI_GONE_MS = Math.max(200, +process.env.CASCADE_UI_GONE_MS || 8000);
// Robustesse : une erreur imprévue ne doit jamais tuer le show.
process.on('uncaughtException', (e) => console.error('[cascade] erreur inattendue :', e && e.message));
@@ -121,7 +130,20 @@ const state = {
// /getControlValues). D'où ce réglage explicite : sans lui, tout
// renvoi de disposition entasserait les barres dans le coin
// supérieur gauche.
- outW: 1920, outH: 1080 },
+ outW: 1920, outH: 1080,
+ // Suiveur audio — la calibration. L'analyse elle-même vit dans le
+ // navigateur (Web Audio) : le zéro-dépendance interdit une entrée
+ // son côté serveur. Ces réglages voyagent avec la configuration.
+ audioGain: 1, audioSeuil: 0.06,
+ audioAttaque: 12, audioRelache: 260, audioBande: 'grave',
+ // Icône de zone de notification (Windows seulement). ⚠ Éteinte
+ // par défaut : écrite depuis Linux, jamais exécutée.
+ systray: false,
+ // Code d'accès facultatif à 4 chiffres. On stocke un HACHAGE salé,
+ // jamais le code : `settings` n'est pas exporté avec le projet,
+ // mais il est écrit en clair dans `cascade-config.json`, à côté de
+ // l'exécutable — donc sur la clé USB qu'on prête.
+ acces: null },
fixtures: [],
// Dimensions du plateau, en MÈTRES. Repère main droite, origine au centre du
// plateau au sol : X = jardin↔cour, Y = profondeur (vers le lointain),
@@ -222,7 +244,7 @@ function writeConfigNow() {
const data = JSON.stringify({
app: APP_NAME, version: VERSION, projectName: state.projectName,
settings: state.settings, scene: state.scene, vues: state.vues,
- scene: state.scene, fixtures: state.fixtures, groups: state.groups,
+ fixtures: state.fixtures, groups: state.groups,
layers: state.layers, global, presets: state.presets, midiMap: state.midiMap,
}, null, 2);
const tmp = CONFIG_FILE + '.tmp';
@@ -330,6 +352,13 @@ function sanitizeLayerSet(set) {
const LFO_PARAMS = ['level', 'floor', 'width', 'speed', 'duty', 'course',
'prof', 'phase', 'spread'];
const LFO_FORMES = ['sine', 'triangle', 'square', 'rampe'];
+// D'où vient le mouvement d'un modulateur. Liste FERMÉE, comme `LFO_PARAMS` :
+// une valeur inconnue retombe sur 'lfo', donc toute config, tout preset et tout
+// export existants se comportent exactement comme avant.
+const MOD_SOURCES = ['lfo', 'audio'];
+// Bandes du suiveur audio. « grave » suit la grosse caisse et la basse, « aigu »
+// les cymbales et les voix, « tout » l'énergie générale.
+const AUDIO_BANDES = ['grave', 'medium', 'aigu', 'tout'];
function sanitizeLFO(v) {
if (!v || typeof v !== 'object') return null;
@@ -337,6 +366,7 @@ function sanitizeLFO(v) {
return {
on: !!v.on,
param: v.param,
+ src: MOD_SOURCES.includes(v.src) ? v.src : 'lfo',
forme: LFO_FORMES.includes(v.forme) ? v.forme : 'sine',
// Bornes larges : de la respiration lente (2 min) au frémissement (100 ms).
periodeMs: Math.round(cnum(v.periodeMs, 100, 120000, 4000)),
@@ -378,7 +408,12 @@ function appliquerLFO(L, now, store = engines) {
e.lfoLast = now;
e.lfoU = (e.lfoU + dt / periode) % 1;
- const brut = m.min + (m.max - m.min) * ondeLFO(m.forme, e.lfoU);
+ // ⚠ L'horloge ci-dessus avance MÊME en source audio : basculer sur le micro
+ // puis revenir à l'oscillateur reprend la course là où elle en était, au lieu
+ // de téléporter le modulateur au milieu de son cycle.
+ const x = valeurMod(m, e.lfoU, now);
+ if (x == null) return L; // micro périmé : le réglage du régisseur reprend
+ const brut = doserMod(m, x, L[m.param]);
return { ...L, ...sanitizeLayerSet({ [m.param]: brut }) };
}
@@ -426,6 +461,7 @@ function sanitizeModGlobal(v) {
return {
on: !!v.on,
param: v.param,
+ src: MOD_SOURCES.includes(v.src) ? v.src : 'lfo',
forme: LFO_FORMES.includes(v.forme) ? v.forme : 'sine',
periodeMs: Math.round(cnum(v.periodeMs, 100, 120000, 8000)),
sync: !!v.sync,
@@ -448,6 +484,40 @@ function globalEff(cle) {
return (modGlobalVal && cle in modGlobalVal) ? modGlobalVal[cle] : state.global[cle];
}
+/**
+ * Niveau entendu au micro. Analysé par le NAVIGATEUR (Web Audio), poussé ici en
+ * paramètre du poll : le zéro-dépendance interdit une entrée son côté serveur.
+ *
+ * Volatil, jamais persisté, jamais exporté : c'est une mesure de l'instant, pas
+ * un réglage. Un niveau sauvegardé se retrouverait figé dans un preset.
+ *
+ * ⚠ LA PÉREMPTION REND LA MAIN, elle ne tombe pas à zéro. Sans niveau frais —
+ * onglet fermé, micro débranché, machine en veille — `niveauAudio` rend `null`
+ * et le modulateur laisse le réglage du régisseur intact. Retomber sur `min`
+ * mettrait le paramètre à sa borne basse : sur `master` avec min 0, c'est le
+ * noir en plein spectacle.
+ *
+ * ⚠⚠ ET LE FONDU DOSE LE RETOUR À LA MAIN, PAS LE NIVEAU. C'est toute la
+ * subtilité, et une première version s'y est trompée : elle faisait décroître le
+ * NIVEAU vers 0 entre la tenue et la péremption. Or `min + (max-min) × niveau`
+ * envoie un niveau nul sur `min` — donc le paramètre plongeait vers sa borne
+ * basse pendant 450 ms AVANT de rendre la main d'un coup. Exactement le noir en
+ * plein show que le paragraphe ci-dessus dit éviter, et un saut à l'arrivée.
+ * `frais` pondère donc le mélange entre la valeur modulée et le réglage réglé à
+ * la main : à 1 le micro commande, à 0 le régisseur a repris, et entre les deux
+ * ça glisse de l'un à l'autre sans jamais viser `min`.
+ */
+const audio = { v: 0, at: 0 };
+const AUDIO_TENUE_MS = 250, AUDIO_PEREMPTION_MS = 700;
+function niveauAudio(now) {
+ if (!audio.at) return null;
+ const age = now - audio.at;
+ if (age >= AUDIO_PEREMPTION_MS) return null;
+ const frais = age <= AUDIO_TENUE_MS ? 1
+ : 1 - (age - AUDIO_TENUE_MS) / (AUDIO_PEREMPTION_MS - AUDIO_TENUE_MS);
+ return { v: audio.v, frais };
+}
+
/** Forme d'onde commune aux deux modulateurs, sur une phase 0..1. */
function ondeLFO(forme, u) {
if (forme === 'square') return u < 0.5 ? 1 : 0;
@@ -456,6 +526,27 @@ function ondeLFO(forme, u) {
return 0.5 - 0.5 * Math.cos(2 * Math.PI * u);
}
+/**
+ * D'où vient le mouvement : l'oscillateur, ou le micro. Rend `{ v, frais }`, ou
+ * `null` quand la source audio n'a plus rien de frais — l'appelant laisse alors
+ * le réglage tel quel. L'oscillateur est toujours frais : il ne décroche pas.
+ */
+function valeurMod(m, u, now) {
+ return m.src === 'audio' ? niveauAudio(now) : { v: ondeLFO(m.forme, u), frais: 1 };
+}
+
+/**
+ * Mélange la valeur modulée et le réglage posé à la main, selon la fraîcheur de
+ * la source. Voir `niveauAudio` : c'est ce qui empêche un micro qui décroche
+ * d'emmener le paramètre sur sa borne basse.
+ */
+function doserMod(m, x, reglage) {
+ const brut = m.min + (m.max - m.min) * x.v;
+ if (x.frais >= 1) return brut;
+ const r = fini(reglage, brut);
+ return r + x.frais * (brut - r);
+}
+
/**
* Recalcule le modulateur global. Comme celui des couches, il n'écrit JAMAIS
* dans `state` : sa valeur vit dans `modGlobalVal`, le temps d'une image.
@@ -476,7 +567,10 @@ function calculerModGlobal(now) {
const dt = Math.min(1000, Math.max(0, now - engGlobal.last));
engGlobal.last = now;
engGlobal.u = (engGlobal.u + dt / periode) % 1;
- const brut = m.min + (m.max - m.min) * ondeLFO(m.forme, engGlobal.u);
+ // L'horloge avance même en source audio (voir `appliquerLFO`).
+ const x = valeurMod(m, engGlobal.u, now);
+ if (x == null) return; // micro périmé : `modGlobalVal` reste null, réglage intact
+ const brut = doserMod(m, x, state.global[m.param]);
// Mêmes bornes qu'une saisie à la main : le modulateur ne peut rien sortir de
// sa plage, même avec des min/max délirants.
const propre = sanitizeGlobal({ [m.param]: brut });
@@ -496,6 +590,29 @@ function sanitizeSettings(s) {
for (const k of ['outW', 'outH']) {
if (k in s) o[k] = Math.round(cnum(s[k], 16, 32768, o[k]));
}
+ // Calibration du suiveur audio. Elle vit ICI et pas dans le navigateur : la
+ // promesse du projet est que la configuration voyage avec l'exécutable, sur
+ // une clé USB. Un réglage laissé dans le `localStorage` resterait sur la
+ // machine, et le régisseur retrouverait un micro déréglé en arrivant en salle.
+ if ('audioGain' in s) o.audioGain = cnum(s.audioGain, 0.1, 10, 1);
+ if ('audioSeuil' in s) o.audioSeuil = cnum(s.audioSeuil, 0, 0.9, 0.06);
+ if ('audioAttaque' in s) o.audioAttaque = Math.round(cnum(s.audioAttaque, 1, 500, 12));
+ if ('audioRelache' in s) o.audioRelache = Math.round(cnum(s.audioRelache, 20, 3000, 260));
+ if ('audioBande' in s) o.audioBande = AUDIO_BANDES.includes(s.audioBande) ? s.audioBande : 'grave';
+ if ('systray' in s) o.systray = !!s.systray;
+ // Code d'accès : accepté seulement s'il a la FORME d'un réglage déjà haché.
+ // Il arrive par deux voies légitimes — le fichier de configuration au
+ // démarrage, et `/api/acces`, qui hache lui-même. Un client qui poserait un
+ // faux haché via `/api/settings` se contenterait de changer le code, ce qui
+ // demande déjà d'être autorisé ; mais on refuse quand même toute forme
+ // douteuse plutôt que de la recopier telle quelle.
+ if ('acces' in s) {
+ const a = s.acces;
+ o.acces = (a && typeof a === 'object'
+ && typeof a.sel === 'string' && /^[0-9a-f]{16}$/.test(a.sel)
+ && typeof a.h === 'string' && /^[0-9a-f]{64}$/.test(a.h))
+ ? { sel: a.sel, h: a.h } : null;
+ }
return o;
}
/** Les clés MIDI sont bornées en nombre ET en forme (`cc:1:7`, `note:10:36`). */
@@ -726,7 +843,11 @@ function sanitizePresets(list) {
if (!Array.isArray(list)) return Array(PRESET_SLOTS).fill(null);
return list.slice(0, PRESET_SLOTS).map(p =>
p && typeof p === 'object' && Array.isArray(p.layers) && p.layers.length
- ? { name: String(p.name || 'P').slice(0, 12),
+ // ⚠ 16, pas 12 : `savePreset` et le renommage coupent tous deux à 16.
+ // Couper plus court ICI rabotait silencieusement tout nom de 13 à 16
+ // caractères — pas à la saisie, mais au redémarrage et à l'import, quand
+ // plus personne ne regarde. « Refrain final 2 » revenait « Refrain fin ».
+ ? { name: String(p.name || 'P').slice(0, 16),
layers: p.layers.slice(0, MAX_LAYERS).map(sanitizeLayer),
fixtures: Array.isArray(p.fixtures) ? sanitizeFixtures(p.fixtures) : null }
: null
@@ -1124,9 +1245,179 @@ function setLinkActive(on, keepError) {
}
function killCarabiner() { if (linkChild) { try { linkChild.kill(); } catch (e) {} linkChild = null; } }
-process.on('exit', killCarabiner);
-process.on('SIGINT', () => { flushConfig(); killCarabiner(); process.exit(0); });
-process.on('SIGTERM', () => { flushConfig(); killCarabiner(); process.exit(0); });
+
+/* ─── Icône de zone de notification (Windows) ─────────────────────────────────
+ * Demande de Pym : voir d'un coup d'œil si ça tourne, et piloter sans rouvrir
+ * la page. Node n'a AUCUNE API de zone de notification — dessiner cette icône
+ * demande du code natif. Sous Windows, PowerShell donne accès à
+ * `System.Windows.Forms.NotifyIcon`, présent d'origine : zéro installation,
+ * zéro dépendance npm, la règle n°2 tient.
+ *
+ * ⚠ WINDOWS SEULEMENT, et c'est un choix, pas un oubli (décidé avec Pym le
+ * 2026-08-05). macOS n'a pas d'équivalent scriptable : un élément de barre de
+ * menus demande une application compilée ou un outil tiers. Linux dépend de
+ * l'environnement de bureau. Plutôt qu'une fonction bancale partout, une
+ * fonction franche là où se trouve la régie.
+ *
+ * ⚠ DÉSACTIVÉE PAR DÉFAUT, parce qu'elle n'a jamais pu être exécutée : elle a
+ * été écrite depuis Linux, où PowerShell n'existe pas. Tout est donc enveloppé :
+ * un échec est silencieux et ne touche jamais le serveur. À activer dans
+ * Réglages, et à confirmer sur une vraie machine Windows.
+ *
+ * Le script est EMBARQUÉ ici, pas dans un fichier à côté : le distribuable est
+ * un exécutable unique, et un `.ps1` externe n'existerait pas à côté de lui.
+ * Il est écrit dans le dossier temporaire au démarrage.
+ *
+ * Le dialogue passe par l'API HTTP de Cascade, pas par un tuyau maison : le
+ * script sait déjà parler à `localhost`, et ça évite tout un protocole.
+ *
+ * ⚠ EN ÉDITANT CE SCRIPT : c'est un littéral de gabarit JavaScript. Un accent
+ * grave (l'échappement de PowerShell) ou une séquence ${…} casserait
+ * `server.js` AU CHARGEMENT — Cascade ne démarrerait plus du tout, pas
+ * seulement l'icône. Les libellés sont aussi volontairement SANS ACCENTS.
+ */
+const SYSTRAY_PS1 = `param([int]$Port)
+$ErrorActionPreference = 'SilentlyContinue'
+Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
+
+function Rond([System.Drawing.Color]$c) {
+ $bmp = New-Object System.Drawing.Bitmap 16,16
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $g.SmoothingMode = 'AntiAlias'
+ $g.FillEllipse((New-Object System.Drawing.SolidBrush $c), 2, 2, 12, 12)
+ $g.Dispose()
+ return [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
+}
+$vert = Rond ([System.Drawing.Color]::FromArgb(61,220,132))
+$rouge = Rond ([System.Drawing.Color]::FromArgb(255,82,82))
+$base = "http://127.0.0.1:$Port"
+
+function Appeler($chemin) {
+ try { Invoke-RestMethod -Uri "$base$chemin" -Method Post -Body '{}' -ContentType 'application/json' -TimeoutSec 3 | Out-Null } catch {}
+}
+
+$ni = New-Object System.Windows.Forms.NotifyIcon
+$ni.Icon = $rouge
+$ni.Text = 'Cascade'
+$ni.Visible = $true
+
+# Un seul chemin de sortie, et il commence par effacer l'icone. Sans ce
+# NIM_DELETE (c'est ce que fait Visible = false), Windows garde une pastille
+# morte jusqu'a ce que la souris passe dessus.
+function Partir {
+ $ni.Visible = $false
+ [System.Windows.Forms.Application]::Exit()
+ [Environment]::Exit(0)
+}
+
+$menu = New-Object System.Windows.Forms.ContextMenuStrip
+$mOuvrir = $menu.Items.Add("Ouvrir l'interface")
+$mOuvrir.add_Click({ Start-Process $base })
+$menu.Items.Add('-') | Out-Null
+$mGo = $menu.Items.Add('Demarrer le show')
+$mGo.add_Click({ Appeler '/api/start' })
+$mStop = $menu.Items.Add('Arreter le show')
+$mStop.add_Click({ Appeler '/api/stop' })
+$menu.Items.Add('-') | Out-Null
+$mQuit = $menu.Items.Add('Quitter Cascade')
+$mQuit.add_Click({ Appeler '/api/quit'; Partir })
+$ni.ContextMenuStrip = $menu
+$ni.add_MouseDoubleClick({ Start-Process $base })
+
+# Sondage de l'etat sur /api/ping : la route la plus legere, ET la seule qui ne
+# fasse pas passer ce script pour une interface ouverte (sinon l'arret
+# automatique de Cascade ne se declencherait plus jamais).
+#
+# Trois echecs d'affilee avant de partir, pas un seul : un pic de charge en
+# plein show fait depasser le delai de 3 s, et une icone qui disparait pour ca
+# ne revient plus jamais.
+$script:rates = 0
+$timer = New-Object System.Windows.Forms.Timer
+$timer.Interval = 1500
+$timer.add_Tick({
+ try {
+ $e = Invoke-RestMethod -Uri "$base/api/ping" -TimeoutSec 3 -ErrorAction Stop
+ $script:rates = 0
+ # La case vient d'etre decochee : on part proprement, sans laisser de
+ # fantome. Le serveur tue ce processus de son cote 3 s plus tard, au cas ou.
+ if ($e.PSObject.Properties['systray'] -and -not $e.systray) { Partir; return }
+ if ($e.running) { $ni.Icon = $vert; $ni.Text = 'Cascade - le show tourne' }
+ else { $ni.Icon = $rouge; $ni.Text = 'Cascade - a l arret' }
+ } catch {
+ $script:rates = $script:rates + 1
+ if ($script:rates -ge 3) { Partir }
+ }
+})
+$timer.Start()
+[System.Windows.Forms.Application]::Run()
+`;
+
+let systrayChild = null;
+/**
+ * Éteindre l'icône.
+ *
+ * `doux` = on laisse d'abord le script partir de LUI-MÊME. Il voit
+ * `systray: false` sur son prochain sondage (1,5 s au pire) et efface l'icône
+ * en partant. Tuer le processus, à l'inverse, n'exécute aucun nettoyage :
+ * `TerminateProcess` ne laisse pas le temps d'envoyer `NIM_DELETE`, et Windows
+ * garde une pastille morte jusqu'à ce que la souris passe dessus. Le kill reste
+ * armé 3 s plus tard, au cas où le script serait coincé — et il vise CE
+ * processus-là, pas « celui qui tourne à ce moment-là ».
+ *
+ * On garde la référence pendant ces 3 s : si la case est recochée entretemps,
+ * `startSystray()` ne relance rien et le script, revoyant `systray: true`,
+ * reste en place. L'icône n'a même pas cligné.
+ */
+function killSystray(doux) {
+ const enfant = systrayChild;
+ if (!enfant) return;
+ if (doux) {
+ setTimeout(() => { try { enfant.kill(); } catch (e) {} }, 3000).unref();
+ return;
+ }
+ systrayChild = null;
+ try { enfant.kill(); } catch (e) {}
+}
+function startSystray() {
+ // Silencieux et sans conséquence si quoi que ce soit manque : cette fonction
+ // ne doit JAMAIS empêcher Cascade de démarrer.
+ try {
+ if (process.platform !== 'win32' || !state.settings.systray || systrayChild) return;
+ const fichier = path.join(os.tmpdir(), 'cascade-systray.ps1');
+ // Le BOM n'est pas décoratif : PowerShell 5.1 lit un .ps1 SANS BOM dans la
+ // page de codes ANSI de la machine, pas en UTF-8. Le script est ASCII pur
+ // aujourd'hui (libellés volontairement sans accents), donc ça marcherait —
+ // mais le premier accent ajouté casserait tout, très loin d'ici.
+ fs.writeFileSync(fichier, '' + SYSTRAY_PS1);
+ const enfant = spawn('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden',
+ '-ExecutionPolicy', 'Bypass', '-File', fichier, '-Port', String(actualPort)],
+ { stdio: 'ignore', windowsHide: true });
+ systrayChild = enfant;
+ // ⚠ Comparer avant d'effacer. Sans ça : on tue A, on lance B, l'événement
+ // `exit` de A arrive APRÈS et met la référence à null alors que B est
+ // vivant — Cascade perd sa poignée et un `startSystray()` ultérieur pose
+ // une deuxième icône.
+ const oublier = () => { if (systrayChild === enfant) systrayChild = null; };
+ enfant.on('error', (e) => {
+ // Le seul endroit qui dira pourquoi rien n'apparaît. Sans ce message,
+ // un premier essai raté sur une vraie machine Windows ne remonte rien.
+ console.warn('[cascade] icône de notification : ' + (e && e.message));
+ oublier();
+ });
+ enfant.on('exit', (code) => {
+ if (code) console.warn('[cascade] icône de notification : PowerShell a quitté (code ' + code + ').');
+ oublier();
+ });
+ } catch (e) {
+ console.warn('[cascade] icône de notification : ' + (e && e.message));
+ systrayChild = null;
+ }
+}
+
+process.on('exit', () => { killCarabiner(); killSystray(); });
+process.on('SIGINT', () => { flushConfig(); killCarabiner(); killSystray(); process.exit(0); });
+process.on('SIGTERM', () => { flushConfig(); killCarabiner(); killSystray(); process.exit(0); });
// ---------------------------------------------------------------------------
// Arrêt automatique (mode app, plus de terminal visible) : quand la dernière
@@ -1135,10 +1426,12 @@ process.on('SIGTERM', () => { flushConfig(); killCarabiner(); process.exit(0); }
// - jamais tant qu'aucune interface ne s'est encore connectée (démarrage) ;
// - JAMAIS pendant que les chasers tournent (on ne coupe pas un show) ;
// - jamais si un contrôleur OSC externe a parlé récemment.
-// 8 s de grâce : un simple rechargement de page ne déclenche rien.
+// - et l'icône de zone de notification sonde `/api/ping`, PAS `/api/state` :
+// sinon elle passerait pour une interface ouverte et l'arrêt automatique ne
+// se déclencherait plus jamais.
+// 8 s de grâce par défaut : un simple rechargement de page ne déclenche rien.
// ---------------------------------------------------------------------------
let lastUiPollAt = 0, lastOscAt = 0;
-const UI_GONE_MS = 8000;
setInterval(() => {
if (NO_AUTOQUIT || !lastUiPollAt) return;
const now = Date.now();
@@ -1148,7 +1441,7 @@ setInterval(() => {
console.log('[cascade] plus aucune interface ouverte — arrêt automatique.');
flushConfig(); killCarabiner();
process.exit(0);
-}, 2000);
+}, Math.min(2000, Math.max(100, Math.floor(UI_GONE_MS / 4))));
// Courbe vitesse : 0–1 → ×0.1 à ×4, centre 0.5 = ×1
function speedCurve(v) { return v >= 0.5 ? Math.pow(4, (v - 0.5) * 2) : Math.pow(10, (v - 0.5) * 2); }
@@ -1485,9 +1778,18 @@ function savePreset(i, name) {
return { name: n || 'P' + (i + 1), layers: deep(state.layers), fixtures: deep(state.fixtures) };
}
/** Fondu en cours entre deux presets (null = rappel sec). */
-let fade = null; // { start, dur, layers, fixtures, engines }
+let fade = null; // { start, dur, de, layers, fixtures, engines }
function cancelFade() { fade = null; }
+// ── Repères de la banque de presets, pour l'interface ─────────────────────────
+// Les deux vivent en MÉMOIRE et n'entrent PAS dans `state` : rien à sanitiser,
+// rien à exporter, rien à migrer. `presetActif` = dernier slot rappelé ou
+// enregistré, pour que le régisseur voie quel pavé joue. `presetsRev` = compteur
+// de révision de la banque : l'interface ne recharge les empreintes que
+// lorsqu'il bouge, au lieu de les faire voyager 8 fois par seconde.
+let presetActif = null;
+let presetsRev = 1;
+
/**
* Rappelle un preset. Si un temps de fondu est réglé ET qu'un show tourne,
* la scène sortante est mise de côté avec son état moteur : elle continue de
@@ -1506,7 +1808,7 @@ function recallPreset(i, fadeMs) {
}
// Un rappel pendant un fondu remplace le précédent : la scène en cours de
// sortie est abandonnée, sinon il faudrait empiler les fondus à l'infini.
- fade = { start: Date.now(), dur, layers: deep(state.layers),
+ fade = { start: Date.now(), dur, de: presetActif, layers: deep(state.layers),
fixtures: deep(state.fixtures), engines: store };
} else cancelFade();
state.layers = deep(p.layers);
@@ -1521,11 +1823,71 @@ function recallPreset(i, fadeMs) {
pruneCaches();
}
engines.clear();
+ // ⚠ Un rappel ne change pas la BANQUE, donc il n'incrémente pas `presetsRev`
+ // en général — recharger les empreintes à chaque rappel ferait une requête de
+ // plus au pire moment. MAIS s'il a remplacé le plateau, il change l'empreinte
+ // de tout preset qui n'a PAS de disposition à lui (les projets importés
+ // peuvent en avoir : `sanitizePresets` autorise `fixtures: null`), puisque
+ // ceux-là retombent sur `state.fixtures`. Mesuré : une empreinte passait de
+ // trois barres à une sans que la grille le sache.
+ if (Array.isArray(p.fixtures) && p.fixtures.length) presetsRev++;
+ presetActif = i;
return true;
}
/** Noms des presets pour l'interface : null = slot vide. */
function presetNames() { return state.presets.map((p, i) => p ? (p.name || 'P' + (i + 1)) : null); }
+/**
+ * Empreinte d'un preset, pour la vignette d'un pavé de la grille.
+ *
+ * ⚠ C'est une empreinte de COUVERTURE, pas une photo du motif : elle dit quelles
+ * barres le preset pilote et avec quelles teintes, pas ce qui est allumé à un
+ * instant donné (un chase n'allume qu'une barre à la fois — une photo serait
+ * presque vide et changerait à chaque image).
+ *
+ * Elle passe par `resolveBars`, donc elle ment EXACTEMENT comme mentirait le
+ * moteur : un groupe vidé ramène la couche sur toutes les barres, ici comme en
+ * scène. C'est voulu — une vignette qui corrige le moteur serait pire.
+ *
+ * Aucun moteur n'est instancié, aucun état runtime touché, aucun OSC émis : on
+ * peut l'appeler pendant un show sans rien déranger. Et elle n'est PAS dans
+ * `/api/state`, à dessein : cette réponse part 8 fois par seconde.
+ */
+function infoPreset(p) {
+ if (!p) return null;
+ // Un preset importé d'une vieille config peut n'avoir aucune fixture : on
+ // retombe alors sur le plateau courant, faute de mieux.
+ const fx = (Array.isArray(p.fixtures) && p.fixtures.length ? p.fixtures : state.fixtures)
+ .filter(f => f && f.enabled !== false);
+ // Plafond de 64 cases : au-delà on sous-échantillonne. Une bande de 128 cases
+ // serait illisible, et la charge utile grossirait pour rien.
+ const pas = Math.ceil(fx.length / 64) || 1;
+ const vus = fx.filter((f, i) => i % pas === 0);
+ const rang = new Map(vus.map((f, i) => [f.id, i]));
+ const cases = new Array(vus.length).fill('.');
+ const pal = [];
+ const act = p.layers.filter(L => L.enabled);
+ for (const L of act) {
+ const c = L.target === 'color' ? (L.colorA || '#ff2000') : ACCENT;
+ let k = pal.indexOf(c);
+ if (k < 0) { k = pal.length; pal.push(c); }
+ // ⚠ Résoudre sur `fx` COMPLET, pas sur l'échantillon : `resolveBars` replie
+ // sur toutes les barres quand aucune de la sélection n'est présente, et
+ // sous-échantillonner d'abord déclencherait ce repli à tort.
+ for (const f of resolveBars(L, fx)) {
+ const r = rang.get(f.id);
+ if (r != null) cases[r] = String(k);
+ }
+ }
+ return {
+ a: act.length, t: p.layers.length,
+ e: act.map(L => L.engine === 'steps' ? 'P' : L.engine === 'wave' ? 'V' : 'C').join('').slice(0, 8),
+ pal, c: cases.join(''),
+ };
+}
+/** Empreintes des 16 slots. Servie à la demande, jamais dans `/api/state`. */
+function presetsInfo() { return state.presets.map(infoPreset); }
+
/** Resync : recale la phase (départ des pas) d'une couche, ou de toutes. */
/**
* GO / RESYNC : tout repart ensemble, sur le temps fort.
@@ -2453,14 +2815,254 @@ function readBody(req) {
req.on('aborted', () => finish({}));
});
}
+/* ─── Code d'accès facultatif ─────────────────────────────────────────────────
+ * Cascade s'ouvre depuis un iPad ou un téléphone, donc depuis le Wi-Fi de la
+ * salle. Un code à 4 chiffres empêche un curieux de prendre la main sur la
+ * lumière pendant le spectacle.
+ *
+ * ⚠ QUATRE CHIFFRES, C'EST 10 000 COMBINAISONS. Sans limitation de tentatives,
+ * ça se casse en quelques secondes et le code ne protège RIEN. La limitation
+ * ci-dessous n'est donc pas un raffinement : c'est ce qui rend la fonction
+ * honnête. Elle est verrouillée par un test et par un anti-mutant.
+ *
+ * Ce que ce code NE fait pas, volontairement :
+ * - il ne ferme pas l'OSC ni le MIDI : ceux-là passent par un câble ou une
+ * console posée sur le réseau de production, c'est un autre domaine de
+ * confiance, et les fermer casserait les installations existantes ;
+ * - il n'est jamais demandé en local. La machine hôte est celle qu'on a
+ * physiquement sous la main, et cette exemption garantit qu'on ne peut PAS
+ * s'enfermer dehors : il y a toujours une voie pour retirer le code.
+ *
+ * LA LIMITATION EST À DEUX ÉTAGES, et le second compte le plus. Le blocage par
+ * adresse (5 essais / minute) arrête un curieux, mais s'effondre dès qu'un
+ * attaquant fait varier son IP source — trivial en IPv6, où il tient tout un
+ * /64. D'où un PLAFOND GLOBAL, indépendant de l'IP : au-delà de N échecs par
+ * minute tous clients confondus, toute tentative depuis le réseau est refusée
+ * un moment. La machine hôte, elle, n'est jamais limitée (elle ne passe pas par
+ * le code). Un code à 4 chiffres reste faible par nature : ces deux étages le
+ * ramènent d'« instantané » à « des heures », ce qui suffit à couvrir un show,
+ * pas à en faire un secret fort.
+ *
+ * Mesuré (fenêtre ramenée à 2 s pour l'observer) : 6 échecs sur 6 adresses
+ * distinctes bloquent une adresse JAMAIS vue ; après la fenêtre, un échec rend à
+ * nouveau 401 et le bon code passe. Le blocage se LÈVE — il ne se perpétue pas.
+ * C'était le risque de cette mécanique : la durée du blocage vaut celle de la
+ * fenêtre, donc au moment où il se lève, la fenêtre est forcément expirée et le
+ * compteur repart de zéro. Raccourcir le blocage sous la fenêtre casserait ça.
+ *
+ * ⚠ LIMITE CONNUE, NON CORRIGÉE — le « DNS rebinding ». Rien ne vérifie l'en-tête
+ * `Host` : une page hébergée sur un domaine qui résout vers 127.0.0.1 devient
+ * MÊME ORIGINE aux yeux du navigateur, et tous les gardes ci-dessus tombent avec
+ * elle. Le correctif serait de refuser un `Host` qui n'est ni localhost ni une
+ * adresse de `lanUrls()` — mais il casserait l'accès par nom d'hôte
+ * (`http://mac-de-pym.local:3333`), qui marche aujourd'hui. Le QR code, lui,
+ * donne toujours une adresse IP. ⚠ ARBITRAGE DE PYM avant de trancher : on
+ * échange une attaque sophistiquée contre une façon de se connecter qui
+ * fonctionne. Ne pas « corriger » ça tout seul.
+ *
+ * ⚠ COMPROMIS ASSUMÉ du plafond global : quelqu'un qui rate volontairement
+ * 30 fois empêche les AUTRES appareils du réseau d'entrer pendant une minute.
+ * C'est un déni de service, mais le moins cher des deux : le régisseur garde sa
+ * machine (jamais bloquée), et l'alternative — laisser passer — rendrait le code
+ * cassable. Une gêne d'une minute contre la prise de contrôle du spectacle.
+ *
+ * ⚠ LIMITE ASSUMÉE : le code haché est écrit dans `cascade-config.json`, en
+ * clair sur le disque, à côté de l'exécutable. Quatre chiffres se cassent hors
+ * ligne instantanément : le code ne protège donc PAS contre quelqu'un qui tient
+ * ce fichier (ou la clé USB). C'est le même domaine de confiance que la machine
+ * hôte. En revanche un PROJET exporté ne porte pas `settings`, donc le partager
+ * ne fuit pas le code.
+ */
+const ACCES_MAX_ESSAIS = 5; // avant blocage, PAR adresse
+const ACCES_BLOCAGE_MS = 60000; // durée du blocage, par adresse
+const ACCES_JETON_MS = 12 * 3600 * 1000;
+const ACCES_GLOBAL_MAX = 30; // échecs/minute TOUS clients confondus, avant blocage global
+const ACCES_GLOBAL_MS = 60000; // fenêtre et durée du blocage global
+const ACCES_OUBLI_MS = 5 * 60000; // au-delà, une adresse inactive est oubliée (borne la mémoire)
+const ACCES_MAX_CLES = 4096; // plafond dur du nombre d'adresses suivies
+
+/** Jetons de session valides, en mémoire : un redémarrage redemande le code. */
+const accesJetons = new Map(); // jeton -> expiration
+/** Tentatives ratées par adresse : { n, jusqua, vu }. */
+const accesEssais = new Map();
+/** Étage global : échecs récents tous clients confondus, et fin du blocage. */
+const accesGlobal = { n: 0, debut: 0, bloqueJusqua: 0 };
+
+/**
+ * Balaie les adresses oubliées et les jetons expirés. Sans ça, un attaquant qui
+ * fait varier son IP source remplirait `accesEssais` sans fin (fuite mémoire).
+ * Appelé à chaque tentative — donc jamais si personne n'attaque.
+ */
+function nettoyerAcces(now) {
+ for (const [k, v] of accesEssais) {
+ if (now - (v.vu || 0) > ACCES_OUBLI_MS) accesEssais.delete(k);
+ }
+ // Filet dur : si la Map déborde malgré tout, on repart de zéro. Le plafond
+ // global reste, lui, en place — c'est lui qui tient sous une vraie attaque.
+ if (accesEssais.size > ACCES_MAX_CLES) accesEssais.clear();
+ for (const [j, exp] of accesJetons) {
+ if (now > exp) accesJetons.delete(j);
+ }
+}
+
+function hacherCode(code, sel) {
+ return crypto.createHash('sha256').update(sel + ':' + code).digest('hex');
+}
+/** Rend l'objet à persister, ou null pour retirer le code. */
+function poserCode(code) {
+ const c = String(code == null ? '' : code).trim();
+ if (!c) return null;
+ if (!/^\d{4}$/.test(c)) return undefined; // invalide : l'appelant refuse
+ const sel = crypto.randomBytes(8).toString('hex');
+ return { sel, h: hacherCode(c, sel) };
+}
+function codeJuste(code, reglage) {
+ if (!reglage || !reglage.h || !reglage.sel) return false;
+ const a = Buffer.from(hacherCode(String(code || ''), reglage.sel), 'hex');
+ const b = Buffer.from(String(reglage.h), 'hex');
+ // Comparaison à temps constant : sinon la durée de la réponse renseigne sur
+ // le nombre de chiffres justes.
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+/**
+ * L'en-tête `Host` désigne-t-il bien CETTE machine ?
+ *
+ * ⚠ C'est la parade au « DNS rebinding », et sans elle tous les autres gardes
+ * tombent. L'attaque : une page sur `mechant.com`, avec une durée de vie DNS très
+ * courte, se ré-résout vers `127.0.0.1` après son chargement. Pour le navigateur
+ * l'origine n'a pas changé — la page est « chez elle » — donc le garde
+ * `Content-Type` ne sert plus à rien (elle pose l'en-tête qu'elle veut) et le
+ * cookie `SameSite` ne protège plus. Et Cascade, lui, voit une requête venant de
+ * 127.0.0.1, donc il l'exempte du code d'accès. Contrôle complet de la lumière,
+ * depuis n'importe quelle page web, sans être sur le réseau.
+ *
+ * Une requête rebindée porte `Host: mechant.com`. On n'accepte donc que ce sous
+ * quoi Cascade se sert légitimement :
+ * - `localhost` et les adresses IP littérales (ce que donne le QR code) ;
+ * - les noms en `.local` — réservés au mDNS (RFC 6762), donc impossibles à
+ * posséder sur Internet : le système ne les résout que sur le réseau local.
+ * C'est ce qui garde `mac-de-pym.local:3333` sans rouvrir la faille ;
+ * - une requête SANS `Host` (HTTP/1.0, `curl` nu) : un navigateur en envoie
+ * toujours un, donc son absence n'est jamais une attaque par rebinding.
+ *
+ * ⚠ Ce qui reste refusé, et c'est assumé : un nom de machine Windows sans
+ * suffixe, et les alias du fichier `hosts`. Décidé avec Pym le 2026-08-05 : il
+ * passe par le QR code ou par l'adresse IP.
+ */
+function hoteAutorise(req) {
+ const brut = req.headers && req.headers.host;
+ if (!brut) return true;
+ let h = String(brut).trim().toLowerCase();
+ if (h.startsWith('[')) { // IPv6 entre crochets : [::1]:3333
+ const f = h.indexOf(']');
+ return f > 0; // littéral IPv6 = cette machine
+ }
+ const parts = h.split(':');
+ if (parts.length > 2) return true; // IPv6 nu, sans crochets
+ h = parts[0];
+ if (h === 'localhost' || h.endsWith('.local')) return true;
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(h); // adresse IPv4 littérale
+}
+
+/**
+ * Le corps est-il annoncé en JSON ? Garde CSRF : voir le bloc POST.
+ *
+ * ⚠ ON COMPARE L'ESSENCE DU TYPE, PAS UNE SOUS-CHAÎNE. Une première version
+ * testait `.includes('application/json')` — et se contournait en une ligne :
+ * `Content-Type: multipart/form-data; boundary=application/json` contient la
+ * sous-chaîne, et la règle CORS ne regarde que l'essence (`type/sous-type`), en
+ * ignorant les paramètres. Ce type-là est donc « safelisté » : un `fetch` en
+ * `no-cors` le pose SANS pré-vol. Mesuré contre le serveur : `/api/quit` tuait
+ * le processus et `/api/acces {nouveau}` posait le code de l'installation.
+ * On coupe donc au premier `;` avant de comparer — ce qui accepte au passage
+ * `APPLICATION/JSON` (légal en HTTP) et refuse `application/jsonp`.
+ */
+function estJson(req) {
+ return String(req.headers['content-type'] || '')
+ .split(';')[0].trim().toLowerCase() === 'application/json';
+}
+
+/** Les réglages SANS le haché du code : ce qu'on a le droit d'envoyer. */
+function sansCode(reglages) {
+ const { acces, ...reste } = reglages;
+ return reste;
+}
+
+/** L'adresse est-elle la machine elle-même ? */
+function estLocal(req) {
+ const a = (req.socket && req.socket.remoteAddress) || '';
+ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
+}
+function cleEssais(req) { return (req.socket && req.socket.remoteAddress) || '?'; }
+
+function jetonDeLaRequete(req) {
+ const brut = req.headers && req.headers.cookie;
+ if (!brut) return null;
+ const m = /(?:^|;\s*)cascade_acces=([A-Za-z0-9]+)/.exec(brut);
+ return m ? m[1] : null;
+}
+function jetonValide(req) {
+ const j = jetonDeLaRequete(req);
+ if (!j) return false;
+ const exp = accesJetons.get(j);
+ if (!exp) return false;
+ if (Date.now() > exp) { accesJetons.delete(j); return false; }
+ return true;
+}
+/** Vrai si la requête a le droit de passer. */
+function accesAutorise(req) {
+ if (!state.settings.acces) return true; // aucun code posé
+ if (estLocal(req)) return true; // la machine hôte, toujours
+ return jetonValide(req);
+}
+
function json(res, obj, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
}
+/**
+ * Récupère le niveau du micro poussé par l'interface, en paramètre du poll
+ * qu'elle fait déjà : `GET /api/state?a=0.42`. Aucune requête de plus, ~8 octets
+ * sur la ligne de requête, zéro octet dans la réponse.
+ *
+ * Tolérant par construction : toute valeur illisible est ignorée en silence,
+ * plutôt que d'installer un niveau aberrant. Une entrée hostile ne doit jamais
+ * pouvoir clouer un modulateur en butée.
+ */
+function lireNiveauAudio(req) {
+ // ⚠ Refuser tout ce qui n'est pas une requête de script. Sans ce garde, une
+ // page piégée ouverte sur la machine hôte pouvait boucler sur
+ // ` ` et clouer un modulateur de
+ // source audio en butée — sur le master avec min 0, le noir en plein show.
+ // Le `fetch` de l'interface annonce `empty` ; une image annonce `image`.
+ // Un client sans cet en-tête (curl, un test) passe : il n'est pas piégeable.
+ const dest = req.headers['sec-fetch-dest'];
+ if (dest && dest !== 'empty') return;
+ const q = req.url.indexOf('?');
+ if (q < 0) return;
+ const m = /(?:^|&)a=([0-9.]{1,8})(?:&|$)/.exec(req.url.slice(q + 1));
+ if (!m) return;
+ const v = parseFloat(m[1]);
+ if (!(v >= 0)) return; // NaN inclus : la comparaison est fausse pour lui
+ audio.v = Math.min(1, v);
+ audio.at = Date.now();
+}
+
const server = http.createServer(async (req, res) => {
const url = req.url.split('?')[0];
+ // Parade au DNS rebinding — voir `hoteAutorise`. Placé tout en haut : même la
+ // PAGE est refusée, sinon elle se chargerait pour voir chaque appel d'API
+ // échouer, ce qui ressemblerait à une panne au lieu d'un refus.
+ if (!hoteAutorise(req)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
+ return res.end('Cascade ne répond que sur localhost, une adresse IP, ou un nom en .local.\n'
+ + 'Reçu : ' + String(req.headers.host || '(aucun)') + '\n'
+ + 'Utilisez le QR code de l\'interface, ou l\'adresse IP affichée en haut.');
+ }
+
if (url === '/' || url === '/index.html') {
fs.readFile(path.join(__dirname, 'public', 'index.html'), (err, buf) => {
if (err) { res.writeHead(500); return res.end('public/index.html introuvable'); }
@@ -2470,10 +3072,117 @@ const server = http.createServer(async (req, res) => {
return;
}
+ // ── Portillon du code d'accès ──────────────────────────────────────────────
+ // La PAGE est toujours servie (juste au-dessus) : sans elle, impossible
+ // d'afficher la demande de code. C'est l'API qui est fermée.
+ if (url.startsWith('/api/') && url !== '/api/ping' && url !== '/api/acces') {
+ if (!accesAutorise(req)) {
+ // ⚠ On note quand même qu'une interface est là. L'arrêt automatique
+ // regarde `lastUiPollAt` : sans cette ligne, le serveur pourrait se
+ // couper pendant que le régisseur tape son code. Le pire qu'un curieux
+ // puisse faire est donc de garder Cascade allumé — sans rien piloter.
+ if (url === '/api/state') lastUiPollAt = Date.now();
+ return json(res, { ok: false, acces: 'requis' }, 401);
+ }
+ }
+
+ // Demande du code, pose et retrait. Volontairement hors du portillon.
+ if (url === '/api/acces') {
+ if (req.method !== 'POST') return json(res, { ok: false }, 405);
+ // Même garde CSRF que les autres POST (voir le bloc POST plus bas) : un
+ // formulaire piégé ne doit pas pouvoir brûler le budget de tentatives.
+ if (!estJson(req)) {
+ return json(res, { ok: false, error: 'Content-Type application/json requis' }, 415);
+ }
+ const body = await readBody(req);
+ const cle = cleEssais(req);
+ const e = accesEssais.get(cle);
+
+ // Poser ou retirer un code : réservé à qui est DÉJÀ autorisé, sinon un
+ // inconnu pourrait simplement remplacer le code par le sien.
+ if (body && 'nouveau' in body) {
+ if (!accesAutorise(req)) return json(res, { ok: false, acces: 'requis' }, 401);
+ const pose = poserCode(body.nouveau);
+ if (pose === undefined) {
+ return json(res, { ok: false, error: 'le code doit faire exactement 4 chiffres' });
+ }
+ state.settings.acces = pose;
+ // Changer ou retirer le code invalide toutes les sessions ouvertes :
+ // sinon l'iPad d'hier continuerait d'entrer avec l'ancien.
+ accesJetons.clear();
+ saveConfig();
+ return json(res, { ok: true, actif: !!pose });
+ }
+
+ // Entrer le code.
+ if (!state.settings.acces) return json(res, { ok: true, actif: false });
+ const now = Date.now();
+ nettoyerAcces(now);
+ // Étage global : il tient même quand l'attaquant change d'IP à chaque essai.
+ if (accesGlobal.bloqueJusqua > now) {
+ return json(res, { ok: false, error: 'trop d’essais',
+ attendre: Math.ceil((accesGlobal.bloqueJusqua - now) / 1000) }, 429);
+ }
+ // Étage par adresse : arrête un curieux sans pénaliser tout le réseau.
+ if (e && e.jusqua > now) {
+ return json(res, { ok: false, error: 'trop d’essais',
+ attendre: Math.ceil((e.jusqua - now) / 1000) }, 429);
+ }
+ if (codeJuste(body && body.code, state.settings.acces)) {
+ accesEssais.delete(cle);
+ const jeton = crypto.randomBytes(24).toString('hex');
+ accesJetons.set(jeton, now + ACCES_JETON_MS);
+ res.writeHead(200, {
+ 'Content-Type': 'application/json',
+ // `HttpOnly` : le jeton n'est pas lisible en JavaScript, donc un nom de
+ // fixture piégé ne pourrait pas le faire fuir. `SameSite=Strict` : il
+ // ne part pas sur une requête déclenchée depuis un autre site.
+ 'Set-Cookie': 'cascade_acces=' + jeton + '; Path=/; HttpOnly; SameSite=Strict; Max-Age='
+ + Math.floor(ACCES_JETON_MS / 1000),
+ });
+ return res.end(JSON.stringify({ ok: true, actif: true }));
+ }
+ // Raté. On compte l'échec aux DEUX étages.
+ // Global : une fenêtre glissante d'une minute. Au-delà du plafond, tout le
+ // réseau est bloqué le temps de la fenêtre — c'est le prix pour qu'un
+ // attaquant multi-IP ne puisse pas balayer les 10 000 codes.
+ if (now - accesGlobal.debut > ACCES_GLOBAL_MS) { accesGlobal.debut = now; accesGlobal.n = 0; }
+ accesGlobal.n++;
+ if (accesGlobal.n >= ACCES_GLOBAL_MAX) accesGlobal.bloqueJusqua = now + ACCES_GLOBAL_MS;
+ // Par adresse : on ne remet le compteur à zéro que si un blocage a EXISTÉ et
+ // qu'il est fini. Tester `jusqua <= maintenant` seul est vrai aussi pour
+ // `jusqua = 0` (jamais bloqué) : le compteur repartait alors de zéro à
+ // chaque essai et la limitation ne limitait RIEN — « restants : 4 » à vie.
+ const expire = e && e.jusqua > 0 && e.jusqua <= now;
+ const n = (expire ? 0 : (e ? e.n : 0)) + 1;
+ const bloque = n >= ACCES_MAX_ESSAIS;
+ accesEssais.set(cle, { n: bloque ? 0 : n, jusqua: bloque ? now + ACCES_BLOCAGE_MS : 0, vu: now });
+ return json(res, { ok: false, error: 'code incorrect',
+ restants: bloque ? 0 : ACCES_MAX_ESSAIS - n,
+ attendre: bloque ? ACCES_BLOCAGE_MS / 1000 : 0 }, 401);
+ }
+
// Sert à repérer qu'une autre instance de Cascade tient déjà le port.
+ //
+ // ⚠ C'est AUSSI la route que sonde l'icône de zone de notification, et c'est
+ // délibéré : `/api/state` remet `lastUiPollAt` à jour, donc un sondage de
+ // l'icône y aurait fait passer Cascade pour « une interface est ouverte » et
+ // AURAIT DÉSACTIVÉ L'ARRÊT AUTOMATIQUE — case cochée, plus moyen de fermer
+ // Cascade autrement qu'au Gestionnaire des tâches. `/api/ping` n'y touche pas.
+ // Accessoirement, `/api/state` renvoie fixtures, couches, scène, vues et
+ // niveaux : une copie complète toutes les 1,5 s pour lire un booléen.
+ //
+ // Les deux clés ne sortent QUE pour la machine hôte : sur le réseau, ping
+ // reste la carte de visite minimale qu'il a toujours été (elle est hors du
+ // portillon du code d'accès, donc tout ce qu'on y met est public).
if (url === '/api/ping') {
+ const rep = { app: APP_NAME, version: VERSION };
+ if (estLocal(req)) {
+ rep.running = state.global.running;
+ rep.systray = !!state.settings.systray;
+ }
res.writeHead(200, { 'Content-Type': 'application/json' });
- return res.end(JSON.stringify({ app: APP_NAME, version: VERSION }));
+ return res.end(JSON.stringify(rep));
}
if (url === '/api/export') {
@@ -2499,14 +3208,41 @@ const server = http.createServer(async (req, res) => {
}, null, 2));
}
+ // Empreintes des presets, servies À LA DEMANDE. Volontairement hors de
+ // `/api/state` : celle-ci part 8 fois par seconde, et 16 empreintes y
+ // pèseraient une demi-kilo-octet à chaque fois pour une donnée qui ne bouge
+ // qu'à l'enregistrement. L'interface ne rappelle cette route que lorsque
+ // `presetsRev` change. Aucune écriture, aucun effet de bord — contrairement à
+ // `/api/export`, qui remet `dirtySinceExport` à false.
+ if (url === '/api/presets-info') {
+ return json(res, { ok: true, rev: presetsRev, infos: presetsInfo() });
+ }
+
if (url === '/api/state') {
lastUiPollAt = Date.now(); // une interface est ouverte (voir arrêt automatique)
+ lireNiveauAudio(req); // suiveur audio : le niveau voyage sur le poll
return json(res, {
- app: APP_NAME, version: VERSION, net: lanUrls(),
- settings: state.settings, scene: state.scene, vues: state.vues,
+ // `win` décrit la machine où tourne CASCADE, pas le navigateur : le
+ // réglage de l'icône agit côté serveur, et l'interface est faite pour être
+ // ouverte depuis un iPad. `navigator.platform` répondrait sur la mauvaise
+ // machine — case grisée à tort depuis une tablette, case active à tort
+ // devant un hôte macOS (et `systray: true` écrit dans la config, qui
+ // voyage sur la clé USB).
+ app: APP_NAME, version: VERSION, net: lanUrls(), win: process.platform === 'win32',
+ // ⚠ Le haché du code d'accès ne sort JAMAIS d'ici. Quatre chiffres, c'est
+ // 10 000 combinaisons : un haché salé se casse hors ligne instantanément,
+ // donc l'envoyer reviendrait à envoyer le code. L'interface n'a besoin
+ // que de savoir s'il y en a un — c'est la clé `acces` plus bas.
+ settings: sansCode(state.settings), scene: state.scene, vues: state.vues,
fixtures: state.fixtures, groups: state.groups,
layers: state.layers, global: state.global,
- presets: presetNames(),
+ // `presets` garde sa forme de tableau de 16 chaînes. Les deux repères qui
+ // suivent sont des entiers : ~40 octets, contre ~560 pour les empreintes,
+ // qui vivent sur `/api/presets-info`.
+ presets: presetNames(), presetsRev, presetActif,
+ // Seulement s'il y a un code, JAMAIS le code ni son haché. `local` sert à
+ // l'interface pour dire « depuis cette machine, il n'est pas demandé ».
+ acces: { actif: !!state.settings.acces, local: estLocal(req) },
midiMap: state.midiMap,
link: { active: link.active, connected: link.connected, bpm: link.bpm, peers: link.peers,
error: link.error,
@@ -2518,12 +3254,26 @@ const server = http.createServer(async (req, res) => {
host: state.settings.mmHost, port: state.settings.mmPort },
// Progression du fondu entre presets (0-1), pour l'afficher en direct
fade: fade ? Math.min(1, (Date.now() - fade.start) / fade.dur) : null,
+ // Slot d'où part le fondu (null = on ne sait pas d'où on vient), pour que
+ // la grille montre le pavé sortant en même temps que l'entrant.
+ fadeDe: fade && fade.de != null ? fade.de : null,
project: { name: state.projectName, dirty: dirtySinceExport, lastExportAt },
levels: runtime.levels, colors: runtime.colors,
});
}
if (req.method === 'POST') {
+ // ⚠ CSRF sur la machine hôte. `estLocal` laisse passer localhost SANS cookie
+ // (pour ne jamais s'enfermer dehors). Sans ce garde, une page web piégée
+ // ouverte dans le navigateur de l'hôte pourrait poster un formulaire vers
+ // `/api/new` (efface le projet), `/api/quit` (coupe le serveur) ou
+ // `/api/blackout` — un `
-
+
+
+ Mélange & espace
Fusion
HTP — le plus fort gagne
@@ -643,6 +687,7 @@ Pattern
Toujours A B
+
Modulateur
— aucun —
@@ -658,6 +703,12 @@ Pattern
+
Source
+
+ Oscillateur (boucle)
+ Micro (suiveur audio)
+
+
Forme
Sinus (respiration)
@@ -766,6 +817,12 @@ Global
+ Source
+
+ Oscillateur (boucle)
+ Micro (suiveur audio)
+
+
Forme
Sinus (respiration)
@@ -792,6 +849,38 @@ Global
+
+ Suiveur audio
+ Le micro devient une source de modulateur , à côté de l'oscillateur :
+ choisissez « Micro » comme Source sur le modulateur d'une couche, ou sur le modulateur global.
+ Il lit une énergie — il ne détecte pas les battements et ne cale pas le tempo.
+ ⚠ Le micro n'est lisible que sur la machine où tourne Cascade , jamais depuis une
+ adresse réseau : les navigateurs interdisent l'accès au micro hors origine sûre. Depuis un iPad,
+ tout le reste fonctionne.
+ Micro
+ 🎤 Écouter
+ Entrée
+ — par défaut —
+ Bande
+
+ Grave (grosse caisse, basse)
+ Médium
+ Aigu (cymbales, voix)
+ Tout le spectre
+
+
+ Gain
+
+ Seuil
+
+ Attaque
+
+ Relâchement
+
+ Niveau
+
+ Micro à l'arrêt.
+
Fondu presets
Courbe dimmer
@@ -917,6 +1006,18 @@ Barre sélectionnée
+
+ Code d'accès
+ Cette régie est protégée par un code à
+ 4 chiffres. Demande-le à la personne qui tient la lumière.
+
+
+
+ Entrer
+
+
+
Connexion OSC MadMapper
Adresse IP (127.0.0.1 si même machine)
@@ -936,6 +1037,28 @@ Connexion OSC MadMapper
Port OSC entrant (contrôleur externe → cette app)
+ Code d'accès à 4 chiffres
+
+ Icône près de l'horloge
+
+ ⚠ Windows uniquement
+ (macOS n'a pas d'équivalent pilotable sans installer autre chose), et jamais essayée
+ en vrai : elle a été écrite depuis une machine Linux. Si elle ne fait rien, elle ne
+ casse rien non plus — dites-le et on corrige.
+ 💡 Windows range
+ toute nouvelle icône dans le tiroir caché : si vous ne la voyez pas près de
+ l'heure, cliquez le chevron ^ à gauche de l'horloge et faites-la glisser
+ dehors. Ce n'est à faire qu'une fois.
+
+ 🔓 Retirer le code
+
+ Facultatif. Il protège l'accès depuis le
+ réseau — iPad, téléphone, Wi-Fi de la salle. Il n'est jamais demandé sur cette
+ machine , donc on ne peut pas s'enfermer dehors. L'OSC et le MIDI ne sont pas concernés :
+ ils passent par un câble ou une console, c'est un autre domaine de confiance.
+ Changer ou retirer le code déconnecte les appareils déjà entrés .
Annuler
Enregistrer
@@ -990,6 +1113,25 @@
Diagnostic OSC
// null = inactif · {type:'layer'} = pour la couche courante · {type:'group', id}
let assignMode = null;
let saveMode = false; // presets : prochain clic = sauvegarde
+// Empreintes des presets. Elles ne voyagent PAS dans /api/state (poll 120 ms) :
+// on les retire seulement quand le serveur signale que la banque a changé, via
+// son compteur de révision. `infoEnCours` évite d'empiler les requêtes si le
+// réseau traîne ; un échec laisse simplement les pavés sans bande, sans bruit.
+let infoRev = -1, infosPresets = null, infoEnCours = false;
+function chargerInfosPresets() {
+ if (!S || infoEnCours || S.presetsRev === infoRev) return;
+ infoEnCours = true;
+ fetch('/api/presets-info')
+ .then(r => r.json())
+ .then(r => {
+ if (!r || !r.ok) return;
+ infosPresets = r.infos; infoRev = r.rev;
+ const pr = $('#presets');
+ if (pr) pr.dataset.sig = ''; // force un re-rendu des pavés
+ })
+ .catch(() => {}) // hors ligne : les pavés restent lisibles, sans empreinte
+ .finally(() => { infoEnCours = false; });
+}
const post = (url, body = {}) => fetch(url, { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
@@ -1139,8 +1281,13 @@
Diagnostic OSC
// presets
const pr = $('#presets');
+ chargerInfosPresets();
// La signature inclut les NOMS : sinon un renommage ne se verrait jamais.
- const psig = S.presets.map(p => p || '·').join('') + (saveMode ? 'S' : '');
+ // `infoRev` en fait partie pour que des empreintes fraîchement chargées
+ // déclenchent le re-rendu. En revanche presetActif, fade et fadeDe en sont
+ // EXCLUS, à dessein : ils changent à chaque poll, et les inclure
+ // reconstruirait les 16 pavés huit fois par seconde, en plein spectacle.
+ const psig = S.presets.map(p => p || '·').join('') + '|' + infoRev + (saveMode ? 'S' : '');
if (pr.dataset.sig !== psig) {
pr.dataset.sig = psig;
pr.innerHTML = '
PRESETS ';
@@ -1159,9 +1306,39 @@
Diagnostic OSC
} else {
b.textContent = i + 1;
}
+ // Empreinte : une case par barre pilotée, teintée comme sa couche.
+ // ⚠ Aucun texte dans ces éléments — le textContent du pavé doit rester
+ // « 1Refrain », c'est un contrat vérifié par les tests d'interface.
+ const info = infosPresets && infosPresets[i];
+ if (info && info.c) {
+ const emp = document.createElement('span');
+ emp.className = 'emp';
+ let peintes = 0;
+ for (const ch of info.c) {
+ const cell = document.createElement('i');
+ if (ch !== '.') {
+ const coul = info.pal[+ch];
+ if (coul) { cell.style.background = coul; peintes++; }
+ }
+ emp.appendChild(cell);
+ }
+ b.appendChild(emp);
+ // Un preset qui ne pilote plus aucune barre : le DIRE. C'est
+ // l'information la plus utile de la grille avant de tirer un pavé —
+ // le masquer laisserait croire à une panne au moment du rappel.
+ if (!peintes) b.classList.add('muet');
+ }
+ // L'infobulle porte ce que l'empreinte ne peut pas montrer : le nombre de
+ // couches actives et leurs moteurs (P = pas-à-pas, V = vague, C = champ).
+ const detail = info
+ ? '\n' + info.a + ' couche' + (info.a > 1 ? 's' : '') + ' active'
+ + (info.a > 1 ? 's' : '') + ' sur ' + info.t
+ + (info.e ? ' — moteurs ' + info.e.split('').join(', ') : '')
+ + (info.c && !/[^.]/.test(info.c) ? '\n⚠ ne pilote aucune barre' : '')
+ : '';
b.title = saveMode ? 'Enregistrer ici' : (p
? 'Rappeler « ' + p +' » : couches + ordre des fixtures + vue spatiale'
- + '\nDouble-clic : renommer'
+ + detail + '\nDouble-clic : renommer'
: 'Slot vide');
b.onclick = () => {
if (saveMode) {
@@ -1303,12 +1480,19 @@
Diagnostic OSC
if (document.activeElement !== $('#lfoParam')) $('#lfoParam').value = m ? m.param : '';
$('#blocLfo').style.display = m ? '' : 'none';
if (m) {
+ if (document.activeElement !== $('#lfoSrc')) $('#lfoSrc').value = m.src || 'lfo';
if (document.activeElement !== $('#lfoForme')) $('#lfoForme').value = m.forme;
$('#lfoSync').checked = !!m.sync;
+ // En source micro, la forme d'onde et la période ne veulent plus rien
+ // dire : c'est le son qui donne le mouvement. Les laisser affichés ferait
+ // régler des boutons sans effet — la panne la plus agaçante qui soit.
+ const mic = m.src === 'audio';
+ $('#lfoForme').closest('label').style.display = mic ? 'none' : '';
+ $('#lfoSync').closest('label').style.display = mic ? 'none' : '';
// Un seul des deux réglages de durée est montré : afficher une période en
// secondes que le serveur ignore ferait chercher longtemps.
- $('#rowLfoPeriode').style.display = m.sync ? 'none' : '';
- $('#rowLfoCycles').style.display = m.sync ? '' : 'none';
+ $('#rowLfoPeriode').style.display = (mic || m.sync) ? 'none' : '';
+ $('#rowLfoCycles').style.display = (!mic && m.sync) ? '' : 'none';
if (document.activeElement !== $('#lfoCycles')) $('#lfoCycles').value = String(m.cycles);
setSlider('#lfoPeriode', periodeVersCurseur(m.periodeMs),
v => { const ms = curseurVersPeriode(v);
@@ -1360,6 +1544,35 @@
Diagnostic OSC
// le second miroir donnerait une case sans effet — et une case sans effet
// ressemble à une panne.
$('#rowMirrorV').style.display = champ ? 'none' : '';
+ // Pastilles des deux nouveaux replis, sur le motif exact de « Groove ». Sans
+ // elles, un miroir ou une fusion oubliés derrière un repli fermé deviennent
+ // un mystère — c'est précisément ce que la pastille de Groove évite déjà.
+ {
+ const actifsM = [
+ L.mirrorH && 'miroir ↔',
+ L.mirrorV && !champ && 'miroir ↕',
+ ].filter(Boolean);
+ const bM = $('#miroirsBadge');
+ bM.textContent = actifsM.length ? actifsM.length + (actifsM.length > 1 ? ' actifs' : ' actif') : '';
+ bM.title = actifsM.join(', ');
+ const detM = $('#advMiroirs');
+ if (detM.dataset.vu !== selId) { detM.dataset.vu = selId; if (actifsM.length) detM.open = true; }
+
+ const actifsX = [
+ (L.blend || 'htp') !== 'htp' && 'fusion ' + (L.blend || 'htp'),
+ L.prof && 'profondeur',
+ L.spread && 'décalage réparti',
+ L.deck && 'jeu ' + String(L.deck).toUpperCase(),
+ ].filter(Boolean);
+ const bX = $('#melangeBadge');
+ bX.textContent = actifsX.length ? actifsX.length + (actifsX.length > 1 ? ' actifs' : ' actif') : '';
+ bX.title = actifsX.join(', ');
+ const detX = $('#advMelange');
+ // ⚠ Ouverture automatique SEULEMENT au changement de couche : sinon
+ // l'utilisateur ne pourrait jamais refermer le repli (piège déjà payé sur
+ // « Groove »).
+ if (detX.dataset.vu !== selId) { detX.dataset.vu = selId; if (actifsX.length) detX.open = true; }
+ }
renderBarsTarget(L);
renderGroups();
@@ -1387,10 +1600,14 @@
Diagnostic OSC
if (document.activeElement !== $('#mgParam')) $('#mgParam').value = g ? g.param : '';
$('#blocMg').style.display = g ? '' : 'none';
if (g) {
+ if (document.activeElement !== $('#mgSrc')) $('#mgSrc').value = g.src || 'lfo';
if (document.activeElement !== $('#mgForme')) $('#mgForme').value = g.forme;
$('#mgSync').checked = !!g.sync;
- $('#rowMgPeriode').style.display = g.sync ? 'none' : '';
- $('#rowMgCycles').style.display = g.sync ? '' : 'none';
+ const micG = g.src === 'audio';
+ $('#mgForme').closest('label').style.display = micG ? 'none' : '';
+ $('#mgSync').closest('label').style.display = micG ? 'none' : '';
+ $('#rowMgPeriode').style.display = (micG || g.sync) ? 'none' : '';
+ $('#rowMgCycles').style.display = (!micG && g.sync) ? '' : 'none';
if (document.activeElement !== $('#mgCycles')) $('#mgCycles').value = String(g.cycles);
setSlider('#mgPeriode', periodeVersCurseur(g.periodeMs),
v => { const ms = curseurVersPeriode(v);
@@ -1399,6 +1616,23 @@
Diagnostic OSC
if (document.activeElement !== $('#mgMax')) $('#mgMax').value = g.max;
}
}
+ {
+ // Suiveur audio : les curseurs et la pastille du repli. La pastille dit s'il
+ // écoute, pour qu'un repli fermé ne cache pas un micro resté ouvert.
+ const st = S.settings || {};
+ setSlider('#audioGain', Math.round(((st.audioGain == null ? 1 : st.audioGain) - 0.1) / 4.9 * 100),
+ v => (0.1 + v / 100 * 4.9).toFixed(1) + ' ×');
+ setSlider('#audioSeuil', Math.round((st.audioSeuil == null ? 0.06 : st.audioSeuil) / 0.9 * 100),
+ v => (v / 100 * 0.9 * 100).toFixed(0) + ' %');
+ setSlider('#audioAttaque', Math.round(((st.audioAttaque || 12) - 1) / 199 * 100),
+ v => Math.round(1 + v / 100 * 199) + ' ms');
+ setSlider('#audioRelache', Math.round(((st.audioRelache || 260) - 20) / 1480 * 100),
+ v => Math.round(20 + v / 100 * 1480) + ' ms');
+ if (document.activeElement !== $('#audioBande')) $('#audioBande').value = st.audioBande || 'grave';
+ const badge = $('#audioBadge');
+ badge.textContent = suiveur.actif ? 'à l’écoute' : '';
+ badge.title = suiveur.actif ? 'Le micro est ouvert et alimente les modulateurs réglés sur « Micro ».' : '';
+ }
setSlider('#presetFade', fadeToSlider(S.global.presetFade || 0),
v => { const ms = fadeFromSlider(v); return ms ? (ms < 1000 ? ms + ' ms' : (ms / 1000).toFixed(1) + ' s') : 'sec'; });
if (document.activeElement !== $('#dimmer')) $('#dimmer').value = S.global.dimmer || 'linear';
@@ -1406,6 +1640,18 @@
Diagnostic OSC
const barrePresets = $('#presets');
barrePresets.classList.toggle('fading', S.fade != null);
barrePresets.style.setProperty('--fade', ((S.fade || 0) * 100).toFixed(1) + '%');
+ // Quel pavé joue, et d'où l'on vient pendant un fondu. Appliqué à CHAQUE poll
+ // mais hors de la signature de rendu : on repeint deux classes, on ne
+ // reconstruit pas les 16 pavés. Les accolades gardent ces locales hors de la
+ // portée de render(), où un `const` en double ferait tomber un garde-fou.
+ {
+ const pads = barrePresets.querySelectorAll('button.slot');
+ const sortant = S.fade != null ? S.fadeDe : null;
+ pads.forEach((b, i) => {
+ b.classList.toggle('joue', S.presetActif === i);
+ b.classList.toggle('sortante', sortant === i && S.presetActif !== i);
+ });
+ }
$('#bpm').innerHTML = '—
' + Math.round(60000 / L.stepMs) + ' BPM (pas)';
// Ableton Link : quand il pilote, le tempo manuel est neutralisé
@@ -2134,6 +2380,17 @@
Diagnostic OSC
$('#btnGo').onclick = () => post('/api/resync', { id: sel().id });
$('#dimmer').onchange = e => post('/api/global', { dimmer: e.target.value }).then(poll);
$('#presetFade').oninput = e => post('/api/global', { presetFade: fadeFromSlider(+e.target.value) });
+
+// Suiveur audio — la calibration vit dans la configuration du serveur, donc elle
+// voyage avec le projet sur la clé USB, au lieu de rester dans ce navigateur.
+$('#audioBande').onchange = e => post('/api/settings', { audioBande: e.target.value });
+$('#audioGain').oninput = e => post('/api/settings', { audioGain: 0.1 + (+e.target.value / 100) * 4.9 });
+$('#audioSeuil').oninput = e => post('/api/settings', { audioSeuil: (+e.target.value / 100) * 0.9 });
+$('#audioAttaque').oninput = e => post('/api/settings', { audioAttaque: 1 + (+e.target.value / 100) * 199 });
+$('#audioRelache').oninput = e => post('/api/settings', { audioRelache: 20 + (+e.target.value / 100) * 1480 });
+$('#lfoSrc').onchange = (e) =>
+ setL({ lfo: { ...lfoCourant(), src: e.target.value } }).then(poll);
+$('#mgSrc').onchange = (e) => posterMg({ src: e.target.value });
$('#mirrorH').onchange = e => setL({ mirrorH: e.target.checked }).then(poll);
$('#mirrorV').onchange = e => setL({ mirrorV: e.target.checked }).then(poll);
$('#axisX').oninput = e => setL({ axisX: +e.target.value / 100 });
@@ -2454,6 +2711,173 @@
Diagnostic OSC
const target = (S.midiMap || {})[key];
if (target) midiApply(target, v01, isTrigger);
}
+/* ─── Suiveur audio ───────────────────────────────────────────────────────────
+ * Le micro devient une SOURCE de modulateur, à côté de l'oscillateur.
+ *
+ * L'analyse vit ici, dans le navigateur : le zéro-dépendance interdit une
+ * entrée son côté serveur. Le niveau part ensuite au serveur en paramètre du
+ * poll qu'on fait déjà — aucune requête de plus.
+ *
+ * ⚠ Le micro n'est accessible qu'en ORIGINE SÛRE (https ou localhost). Depuis
+ * un iPad sur http://
:3333, `navigator.mediaDevices` n'existe pas. C'est
+ * assumé et dit à l'écran, comme pour Web MIDI qui n'existe que sur Chrome et
+ * Edge : le reste de Cascade continue de marcher depuis la tablette.
+ */
+const suiveur = { actif: false, demarrage: false, niv: 0, ctx: null, analyseur: null,
+ flux: null, bins: null, dernier: 0, etatDsp: { niveau: 0 } };
+
+/** Bornes des bandes, en fraction du spectre analysé. */
+const AUDIO_BANDES = { grave: [0, 0.08], medium: [0.08, 0.35], aigu: [0.35, 1], tout: [0, 1] };
+
+/**
+ * Enveloppe audio — fonction PURE, sans DOM ni AudioContext.
+ *
+ * Isolée exprès : c'est ce qui rend la DSP testable sans micro, avec un spectre
+ * fabriqué à la main. Rend le niveau 0..1 et le nouvel état du lissage.
+ *
+ * `reglages` : { bande, gain, seuil, attaque, relache } (attaque/relache en ms)
+ */
+function enveloppeAudio(bins, dt, reglages, etat) {
+ const [d, f] = AUDIO_BANDES[reglages.bande] || AUDIO_BANDES.tout;
+ const i0 = Math.floor(d * bins.length), i1 = Math.max(i0 + 1, Math.floor(f * bins.length));
+ let somme = 0;
+ for (let i = i0; i < i1; i++) somme += bins[i];
+ let v = somme / (i1 - i0) / 255;
+ // Porte de bruit AVEC ré-étalement : sans lui, la course utile serait amputée
+ // du seuil et le suiveur deviendrait mou dès qu'on coupe un peu de souffle.
+ const seuil = reglages.seuil;
+ v = v <= seuil ? 0 : (v - seuil) / (1 - seuil);
+ v = Math.min(1, v * reglages.gain);
+ // Lissage exponentiel ASYMÉTRIQUE : on monte vite et on redescend lentement.
+ // C'est ce qui fait une respiration plutôt qu'un clignotement.
+ const cible = v;
+ const tau = cible > etat.niveau ? reglages.attaque : reglages.relache;
+ const k = 1 - Math.exp(-dt / Math.max(1, tau));
+ const niveau = etat.niveau + (cible - etat.niveau) * k;
+ return { niveau: Math.max(0, Math.min(1, niveau)), etat: { niveau } };
+}
+
+/** Réglages courants du suiveur, lus dans la configuration du serveur. */
+function reglagesAudio() {
+ const s = (S && S.settings) || {};
+ return { bande: s.audioBande || 'grave', gain: s.audioGain == null ? 1 : s.audioGain,
+ seuil: s.audioSeuil == null ? 0.06 : s.audioSeuil,
+ attaque: s.audioAttaque || 12, relache: s.audioRelache || 260 };
+}
+
+function etatAudio(txt) { $('#audioEtat').textContent = txt; }
+
+/** Liste les entrées son. ⚠ Un nom de périphérique est une donnée EXTERNE. */
+function remplirEntrees(list) {
+ const sel = $('#audioEntree');
+ const garde = sel.value;
+ sel.textContent = '';
+ const def = document.createElement('option');
+ def.value = ''; def.textContent = '— par défaut —';
+ sel.appendChild(def);
+ list.forEach((d, i) => {
+ const o = document.createElement('option');
+ o.value = d.deviceId || '';
+ // textContent, JAMAIS innerHTML : un micro peut s'appeler ` `.
+ o.textContent = d.label || ('Entrée ' + (i + 1));
+ sel.appendChild(o);
+ });
+ sel.value = garde;
+}
+
+async function demarrerAudio() {
+ if (suiveur.actif) { arreterAudio(); return; }
+ // ⚠ GARDE SYNCHRONE, avant le moindre `await`. `suiveur.actif` n'est posé
+ // qu'après `getUserMedia` ET `ctx.resume()` — or `getUserMedia` bloque sur la
+ // demande d'autorisation du navigateur, le moment le PLUS long. Un régisseur
+ // qui reclique parce qu'il ne voit rien se retrouvait avec deux démarrages en
+ // vol : le second écrasait `flux` et `ctx`, laissant les premiers orphelins.
+ // Micro jamais relâché (voyant allumé), AudioContext fuité — et les
+ // navigateurs en plafonnent une poignée — plus deux boucles d'analyse.
+ if (suiveur.demarrage) return;
+ suiveur.demarrage = true;
+ try {
+ await ouvrirAudio();
+ } finally { suiveur.demarrage = false; }
+}
+
+async function ouvrirAudio() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ etatAudio('Le micro n’est lisible que sur la machine où tourne Cascade '
+ + '(les navigateurs l’interdisent depuis une adresse réseau). Tout le reste fonctionne.');
+ return;
+ }
+ const dev = $('#audioEntree').value;
+ try {
+ // Pas de traitement de la voix : on veut le son de la salle, pas une
+ // conversation nettoyée. L'annulation d'écho écraserait la musique.
+ suiveur.flux = await navigator.mediaDevices.getUserMedia({ audio: {
+ deviceId: dev ? { exact: dev } : undefined,
+ echoCancellation: false, noiseSuppression: false, autoGainControl: false } });
+ } catch (e) {
+ etatAudio('Micro refusé ou indisponible. Cascade continue sans lui.');
+ return;
+ }
+ try {
+ suiveur.ctx = new (window.AudioContext || window.webkitAudioContext)();
+ if (suiveur.ctx.state === 'suspended') await suiveur.ctx.resume();
+ suiveur.analyseur = suiveur.ctx.createAnalyser();
+ suiveur.analyseur.fftSize = 1024;
+ suiveur.analyseur.smoothingTimeConstant = 0.5;
+ suiveur.ctx.createMediaStreamSource(suiveur.flux).connect(suiveur.analyseur);
+ suiveur.bins = new Uint8Array(suiveur.analyseur.frequencyBinCount);
+ suiveur.etatDsp = { niveau: 0 };
+ suiveur.dernier = 0;
+ suiveur.actif = true;
+ $('#btnAudio').classList.add('active');
+ $('#btnAudio').textContent = '🎤 Écoute…';
+ etatAudio('Le micro écoute. Choisissez « Micro » comme Source sur un modulateur.');
+ boucleAudio();
+ // ⚠ AUCUN `await` après `actif = true`. Sinon `actif` et `demarrage` sont
+ // vrais en même temps le temps de cet appel, et un changement d'entrée son
+ // pile à cet instant (`arreterAudio()` puis `demarrerAudio()`) tombait sur
+ // la garde et ne redémarrait rien. La liste est un confort : elle se
+ // remplit quand elle peut.
+ navigator.mediaDevices.enumerateDevices()
+ .then(l => remplirEntrees(l.filter(d => d.kind === 'audioinput')))
+ .catch(() => {});
+ } catch (e) {
+ etatAudio('L’analyse audio n’a pas pu démarrer sur ce navigateur.');
+ arreterAudio();
+ }
+}
+
+function arreterAudio() {
+ suiveur.actif = false;
+ suiveur.niv = 0;
+ if (suiveur.flux) { try { suiveur.flux.getTracks().forEach(t => t.stop()); } catch (e) {} }
+ if (suiveur.ctx) { try { suiveur.ctx.close(); } catch (e) {} }
+ suiveur.flux = null; suiveur.ctx = null; suiveur.analyseur = null;
+ $('#btnAudio').classList.remove('active');
+ $('#btnAudio').textContent = '🎤 Écouter';
+ $('#audioVu').firstElementChild.style.width = '0%';
+ etatAudio('Micro à l’arrêt.');
+}
+
+/**
+ * Boucle d'analyse, calée sur l'affichage. Elle ne fait QUE mesurer : le niveau
+ * part au serveur avec le poll suivant, pas dans une requête à elle.
+ */
+function boucleAudio() {
+ if (!suiveur.actif) return;
+ const now = performance.now();
+ const dt = suiveur.dernier ? Math.min(200, now - suiveur.dernier) : 16;
+ suiveur.dernier = now;
+ suiveur.analyseur.getByteFrequencyData(suiveur.bins);
+ const r = enveloppeAudio(suiveur.bins, dt, reglagesAudio(), suiveur.etatDsp);
+ suiveur.niv = r.niveau; suiveur.etatDsp = r.etat;
+ $('#audioVu').firstElementChild.style.width = (r.niveau * 100).toFixed(1) + '%';
+ requestAnimationFrame(boucleAudio);
+}
+
+$('#btnAudio').onclick = demarrerAudio;
+$('#audioEntree').onchange = () => { if (suiveur.actif) { arreterAudio(); demarrerAudio(); } };
+
function initMidi() {
if (!navigator.requestMIDIAccess) return;
navigator.requestMIDIAccess({ sysex: false }).then(acc => {
@@ -2508,6 +2932,22 @@ Diagnostic OSC
$('#setFb').value = S.settings.feedbackPort;
$('#setParam').value = S.global.param;
$('#setOscIn').value = S.settings.oscInPort || 7000;
+ // Le code lui-même n'est JAMAIS renvoyé par le serveur : on ne peut afficher
+ // que son existence. Le champ part donc vide, et un champ vide ne change rien.
+ $('#setAcces').value = '';
+ $('#setSystray').checked = !!S.settings.systray;
+ // Ne pas proposer une case qui ne peut rien faire : hors Windows, on le dit.
+ // ⚠ On demande au SERVEUR, pas au navigateur : le réglage agit sur la machine
+ // qui fait tourner Cascade, et cette page s'ouvre aussi bien depuis un iPad.
+ const surWindows = !!S.win;
+ $('#rowSystray').style.opacity = surWindows ? '1' : '0.45';
+ $('#systrayNote').style.display = surWindows ? 'none' : '';
+ $('#systrayOu').style.display = surWindows ? '' : 'none';
+ const a = (S.acces || {});
+ $('#accesEtat').textContent = a.actif
+ ? '— actif. Laisser vide ne change rien ; taper 4 chiffres le remplace.'
+ : '— aucun code pour l’instant.';
+ $('#btnAccesRetirer').style.display = a.actif ? '' : 'none';
$('#dlgSettings').showModal();
};
// « Trouver le port » : le port d'entrée OSC de MadMapper est un réglage de
@@ -2537,11 +2977,84 @@ Diagnostic OSC
};
$('#setCancel').onclick = () => $('#dlgSettings').close();
$('#setSave').onclick = async () => {
- await post('/api/settings', { mmHost: $('#setHost').value.trim(), mmPort: +$('#setPort').value, feedbackPort: +$('#setFb').value, oscInPort: +$('#setOscIn').value });
+ // Un SEUL envoi de réglages. Deux, c'étaient deux sauvegardes de config et
+ // deux évaluations de l'icône par enregistrement — de quoi tuer puis relancer
+ // le script coup sur coup.
+ await post('/api/settings', {
+ mmHost: $('#setHost').value.trim(), mmPort: +$('#setPort').value,
+ feedbackPort: +$('#setFb').value, oscInPort: +$('#setOscIn').value,
+ systray: $('#setSystray').checked,
+ });
await post('/api/global', { param: $('#setParam').value.trim() || 'luminosity' });
+ // ⚠ UN CHAMP VIDE NE TOUCHE À RIEN. Une première version envoyait alors un
+ // retrait — or le champ est vidé à CHAQUE ouverture du dialogue (le serveur ne
+ // renvoie jamais le code). Ouvrir Réglages pour changer le port MadMapper et
+ // enregistrer supprimait donc le code d'accès en silence, et déconnectait
+ // toutes les tablettes. Le retrait est maintenant un geste explicite : le
+ // bouton « Retirer le code », qui demande confirmation.
+ const nouveau = $('#setAcces').value.trim();
+ if (nouveau) {
+ const r = await post('/api/acces', { nouveau });
+ if (r && r.ok === false && r.error) { toast(r.error); return; }
+ $('#setAcces').value = '';
+ toast('Code d’accès enregistré.');
+ }
$('#dlgSettings').close(); poll();
};
+/* ─── Code d'accès ────────────────────────────────────────────────────────────
+ * Quand le serveur répond 401, on demande le code. Le jeton revient dans un
+ * cookie HttpOnly : la page ne le voit jamais, donc un nom de fixture piégé ne
+ * peut pas le faire fuir. Rien à stocker ici.
+ */
+let accesDemande = false;
+function demanderAcces() {
+ if (accesDemande) return;
+ accesDemande = true;
+ $('#accesErreur').textContent = '';
+ $('#accesCode').value = '';
+ if (!$('#dlgAcces').open) $('#dlgAcces').showModal();
+ $('#accesCode').focus();
+}
+async function envoyerAcces() {
+ const code = $('#accesCode').value.trim();
+ if (!/^\d{4}$/.test(code)) { $('#accesErreur').textContent = 'Quatre chiffres.'; return; }
+ let r;
+ try {
+ r = await fetch('/api/acces', { method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code }) }).then(x => x.json());
+ } catch (e) { $('#accesErreur').textContent = 'Serveur injoignable.'; return; }
+ if (r && r.ok) {
+ accesDemande = false;
+ $('#dlgAcces').close();
+ poll();
+ return;
+ }
+ $('#accesCode').value = '';
+ $('#accesCode').focus();
+ // On dit combien d'essais restent : sans ça, le blocage ressemble à une panne.
+ $('#accesErreur').textContent = r && r.attendre
+ ? 'Trop d’essais. Réessaie dans ' + r.attendre + ' s.'
+ : 'Code incorrect.' + (r && r.restants != null ? ' Encore ' + r.restants + ' essai'
+ + (r.restants > 1 ? 's' : '') + '.' : '');
+}
+$('#btnAccesRetirer').onclick = async () => {
+ // Retirer un code déconnecte toutes les tablettes déjà entrées : ça se
+ // confirme. C'est le seul chemin qui supprime le code.
+ if (!confirm('Retirer le code d’accès ?\n\nCascade redeviendra pilotable par '
+ + 'n’importe qui sur le réseau, et les appareils déjà connectés seront déconnectés.')) return;
+ const r = await post('/api/acces', { nouveau: '' });
+ if (r && r.ok === false && r.error) { toast(r.error); return; }
+ toast('Code d’accès retiré.');
+ $('#dlgSettings').close();
+ poll();
+};
+
+$('#accesOk').onclick = envoyerAcces;
+$('#accesCode').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); envoyerAcces(); } };
+// Un dialogue modal se ferme sur Échap : ici il n'y a rien derrière, on le rouvre.
+$('#dlgAcces').addEventListener('close', () => { if (accesDemande) setTimeout(demanderAcces, 50); });
+
// ═══════════════════════════════════════════════════════════════════════
// PAGE SCÈNE 3D
//
@@ -3288,7 +3801,14 @@ Diagnostic OSC
// ── Boucle d'état ────────────────────────────────────────────────────
async function poll() {
try {
- const ns = await fetch('/api/state').then(r => r.json());
+ // Le niveau du micro voyage sur le poll qu'on fait déjà : ~8 octets sur la
+ // ligne de requête, zéro dans la réponse. Sans micro, rien n'est ajouté —
+ // un iPad ne peut donc pas écraser ce qu'entend la machine hôte.
+ const rep = await fetch('/api/state' + (suiveur.actif ? '?a=' + suiveur.niv.toFixed(3) : ''));
+ // 401 = code d'accès demandé. Sans ce test, la réponse d'erreur remplacerait
+ // l'état et render() planterait sur un objet qui n'a ni couches ni fixtures.
+ if (rep.status === 401) { demanderAcces(); return; }
+ const ns = await rep.json();
if (dragging && S) { S.levels = ns.levels; S.colors = ns.colors; S.global = ns.global; }
else S = ns;
$('#status').classList.add('ok');
diff --git a/server.js b/server.js
index ace9733..49c4b44 100644
--- a/server.js
+++ b/server.js
@@ -14,11 +14,15 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec, spawn } = require('child_process');
+const crypto = require('crypto');
const APP_NAME = 'Cascade';
-const VERSION = '2.0.1';
+const VERSION = '2.0.2';
const SIGNATURE = 'Pierre-Yves Mansour — Collectif WSK';
const PRESET_SLOTS = 16;
+// Orange signature de la charte (`--accent` dans public/index.html). Sert aux
+// empreintes de presets, où une couche d'intensité n'a pas de couleur à montrer.
+const ACCENT = '#f2900f';
const MAX_LAYERS = 8;
const MAX_FIXTURES = 128;
const MAX_MIDI_BINDINGS = 256;
@@ -36,6 +40,11 @@ const BACKUP_FILE = CONFIG_FILE + '.bak';
const LEGACY_FILE = process.env.CASCADE_CONFIG ? null : path.join(DATA_DIR, 'chaser-config.json');
const NO_BROWSER = process.env.CASCADE_NO_BROWSER === '1';
const NO_AUTOQUIT = process.env.CASCADE_NO_AUTOQUIT === '1';
+// Délai de grâce de l'arrêt automatique. Réglable pour que les tests puissent
+// le MESURER en une seconde au lieu de neuf : sans ça, la seule vérification
+// possible serait indirecte, et c'est exactement comme ça qu'on ne voit pas
+// qu'un sondage d'icône réarme le compteur.
+const UI_GONE_MS = Math.max(200, +process.env.CASCADE_UI_GONE_MS || 8000);
// Robustesse : une erreur imprévue ne doit jamais tuer le show.
process.on('uncaughtException', (e) => console.error('[cascade] erreur inattendue :', e && e.message));
@@ -121,7 +130,20 @@ const state = {
// /getControlValues). D'où ce réglage explicite : sans lui, tout
// renvoi de disposition entasserait les barres dans le coin
// supérieur gauche.
- outW: 1920, outH: 1080 },
+ outW: 1920, outH: 1080,
+ // Suiveur audio — la calibration. L'analyse elle-même vit dans le
+ // navigateur (Web Audio) : le zéro-dépendance interdit une entrée
+ // son côté serveur. Ces réglages voyagent avec la configuration.
+ audioGain: 1, audioSeuil: 0.06,
+ audioAttaque: 12, audioRelache: 260, audioBande: 'grave',
+ // Icône de zone de notification (Windows seulement). ⚠ Éteinte
+ // par défaut : écrite depuis Linux, jamais exécutée.
+ systray: false,
+ // Code d'accès facultatif à 4 chiffres. On stocke un HACHAGE salé,
+ // jamais le code : `settings` n'est pas exporté avec le projet,
+ // mais il est écrit en clair dans `cascade-config.json`, à côté de
+ // l'exécutable — donc sur la clé USB qu'on prête.
+ acces: null },
fixtures: [],
// Dimensions du plateau, en MÈTRES. Repère main droite, origine au centre du
// plateau au sol : X = jardin↔cour, Y = profondeur (vers le lointain),
@@ -222,7 +244,7 @@ function writeConfigNow() {
const data = JSON.stringify({
app: APP_NAME, version: VERSION, projectName: state.projectName,
settings: state.settings, scene: state.scene, vues: state.vues,
- scene: state.scene, fixtures: state.fixtures, groups: state.groups,
+ fixtures: state.fixtures, groups: state.groups,
layers: state.layers, global, presets: state.presets, midiMap: state.midiMap,
}, null, 2);
const tmp = CONFIG_FILE + '.tmp';
@@ -330,6 +352,13 @@ function sanitizeLayerSet(set) {
const LFO_PARAMS = ['level', 'floor', 'width', 'speed', 'duty', 'course',
'prof', 'phase', 'spread'];
const LFO_FORMES = ['sine', 'triangle', 'square', 'rampe'];
+// D'où vient le mouvement d'un modulateur. Liste FERMÉE, comme `LFO_PARAMS` :
+// une valeur inconnue retombe sur 'lfo', donc toute config, tout preset et tout
+// export existants se comportent exactement comme avant.
+const MOD_SOURCES = ['lfo', 'audio'];
+// Bandes du suiveur audio. « grave » suit la grosse caisse et la basse, « aigu »
+// les cymbales et les voix, « tout » l'énergie générale.
+const AUDIO_BANDES = ['grave', 'medium', 'aigu', 'tout'];
function sanitizeLFO(v) {
if (!v || typeof v !== 'object') return null;
@@ -337,6 +366,7 @@ function sanitizeLFO(v) {
return {
on: !!v.on,
param: v.param,
+ src: MOD_SOURCES.includes(v.src) ? v.src : 'lfo',
forme: LFO_FORMES.includes(v.forme) ? v.forme : 'sine',
// Bornes larges : de la respiration lente (2 min) au frémissement (100 ms).
periodeMs: Math.round(cnum(v.periodeMs, 100, 120000, 4000)),
@@ -378,7 +408,12 @@ function appliquerLFO(L, now, store = engines) {
e.lfoLast = now;
e.lfoU = (e.lfoU + dt / periode) % 1;
- const brut = m.min + (m.max - m.min) * ondeLFO(m.forme, e.lfoU);
+ // ⚠ L'horloge ci-dessus avance MÊME en source audio : basculer sur le micro
+ // puis revenir à l'oscillateur reprend la course là où elle en était, au lieu
+ // de téléporter le modulateur au milieu de son cycle.
+ const x = valeurMod(m, e.lfoU, now);
+ if (x == null) return L; // micro périmé : le réglage du régisseur reprend
+ const brut = doserMod(m, x, L[m.param]);
return { ...L, ...sanitizeLayerSet({ [m.param]: brut }) };
}
@@ -426,6 +461,7 @@ function sanitizeModGlobal(v) {
return {
on: !!v.on,
param: v.param,
+ src: MOD_SOURCES.includes(v.src) ? v.src : 'lfo',
forme: LFO_FORMES.includes(v.forme) ? v.forme : 'sine',
periodeMs: Math.round(cnum(v.periodeMs, 100, 120000, 8000)),
sync: !!v.sync,
@@ -448,6 +484,40 @@ function globalEff(cle) {
return (modGlobalVal && cle in modGlobalVal) ? modGlobalVal[cle] : state.global[cle];
}
+/**
+ * Niveau entendu au micro. Analysé par le NAVIGATEUR (Web Audio), poussé ici en
+ * paramètre du poll : le zéro-dépendance interdit une entrée son côté serveur.
+ *
+ * Volatil, jamais persisté, jamais exporté : c'est une mesure de l'instant, pas
+ * un réglage. Un niveau sauvegardé se retrouverait figé dans un preset.
+ *
+ * ⚠ LA PÉREMPTION REND LA MAIN, elle ne tombe pas à zéro. Sans niveau frais —
+ * onglet fermé, micro débranché, machine en veille — `niveauAudio` rend `null`
+ * et le modulateur laisse le réglage du régisseur intact. Retomber sur `min`
+ * mettrait le paramètre à sa borne basse : sur `master` avec min 0, c'est le
+ * noir en plein spectacle.
+ *
+ * ⚠⚠ ET LE FONDU DOSE LE RETOUR À LA MAIN, PAS LE NIVEAU. C'est toute la
+ * subtilité, et une première version s'y est trompée : elle faisait décroître le
+ * NIVEAU vers 0 entre la tenue et la péremption. Or `min + (max-min) × niveau`
+ * envoie un niveau nul sur `min` — donc le paramètre plongeait vers sa borne
+ * basse pendant 450 ms AVANT de rendre la main d'un coup. Exactement le noir en
+ * plein show que le paragraphe ci-dessus dit éviter, et un saut à l'arrivée.
+ * `frais` pondère donc le mélange entre la valeur modulée et le réglage réglé à
+ * la main : à 1 le micro commande, à 0 le régisseur a repris, et entre les deux
+ * ça glisse de l'un à l'autre sans jamais viser `min`.
+ */
+const audio = { v: 0, at: 0 };
+const AUDIO_TENUE_MS = 250, AUDIO_PEREMPTION_MS = 700;
+function niveauAudio(now) {
+ if (!audio.at) return null;
+ const age = now - audio.at;
+ if (age >= AUDIO_PEREMPTION_MS) return null;
+ const frais = age <= AUDIO_TENUE_MS ? 1
+ : 1 - (age - AUDIO_TENUE_MS) / (AUDIO_PEREMPTION_MS - AUDIO_TENUE_MS);
+ return { v: audio.v, frais };
+}
+
/** Forme d'onde commune aux deux modulateurs, sur une phase 0..1. */
function ondeLFO(forme, u) {
if (forme === 'square') return u < 0.5 ? 1 : 0;
@@ -456,6 +526,27 @@ function ondeLFO(forme, u) {
return 0.5 - 0.5 * Math.cos(2 * Math.PI * u);
}
+/**
+ * D'où vient le mouvement : l'oscillateur, ou le micro. Rend `{ v, frais }`, ou
+ * `null` quand la source audio n'a plus rien de frais — l'appelant laisse alors
+ * le réglage tel quel. L'oscillateur est toujours frais : il ne décroche pas.
+ */
+function valeurMod(m, u, now) {
+ return m.src === 'audio' ? niveauAudio(now) : { v: ondeLFO(m.forme, u), frais: 1 };
+}
+
+/**
+ * Mélange la valeur modulée et le réglage posé à la main, selon la fraîcheur de
+ * la source. Voir `niveauAudio` : c'est ce qui empêche un micro qui décroche
+ * d'emmener le paramètre sur sa borne basse.
+ */
+function doserMod(m, x, reglage) {
+ const brut = m.min + (m.max - m.min) * x.v;
+ if (x.frais >= 1) return brut;
+ const r = fini(reglage, brut);
+ return r + x.frais * (brut - r);
+}
+
/**
* Recalcule le modulateur global. Comme celui des couches, il n'écrit JAMAIS
* dans `state` : sa valeur vit dans `modGlobalVal`, le temps d'une image.
@@ -476,7 +567,10 @@ function calculerModGlobal(now) {
const dt = Math.min(1000, Math.max(0, now - engGlobal.last));
engGlobal.last = now;
engGlobal.u = (engGlobal.u + dt / periode) % 1;
- const brut = m.min + (m.max - m.min) * ondeLFO(m.forme, engGlobal.u);
+ // L'horloge avance même en source audio (voir `appliquerLFO`).
+ const x = valeurMod(m, engGlobal.u, now);
+ if (x == null) return; // micro périmé : `modGlobalVal` reste null, réglage intact
+ const brut = doserMod(m, x, state.global[m.param]);
// Mêmes bornes qu'une saisie à la main : le modulateur ne peut rien sortir de
// sa plage, même avec des min/max délirants.
const propre = sanitizeGlobal({ [m.param]: brut });
@@ -496,6 +590,29 @@ function sanitizeSettings(s) {
for (const k of ['outW', 'outH']) {
if (k in s) o[k] = Math.round(cnum(s[k], 16, 32768, o[k]));
}
+ // Calibration du suiveur audio. Elle vit ICI et pas dans le navigateur : la
+ // promesse du projet est que la configuration voyage avec l'exécutable, sur
+ // une clé USB. Un réglage laissé dans le `localStorage` resterait sur la
+ // machine, et le régisseur retrouverait un micro déréglé en arrivant en salle.
+ if ('audioGain' in s) o.audioGain = cnum(s.audioGain, 0.1, 10, 1);
+ if ('audioSeuil' in s) o.audioSeuil = cnum(s.audioSeuil, 0, 0.9, 0.06);
+ if ('audioAttaque' in s) o.audioAttaque = Math.round(cnum(s.audioAttaque, 1, 500, 12));
+ if ('audioRelache' in s) o.audioRelache = Math.round(cnum(s.audioRelache, 20, 3000, 260));
+ if ('audioBande' in s) o.audioBande = AUDIO_BANDES.includes(s.audioBande) ? s.audioBande : 'grave';
+ if ('systray' in s) o.systray = !!s.systray;
+ // Code d'accès : accepté seulement s'il a la FORME d'un réglage déjà haché.
+ // Il arrive par deux voies légitimes — le fichier de configuration au
+ // démarrage, et `/api/acces`, qui hache lui-même. Un client qui poserait un
+ // faux haché via `/api/settings` se contenterait de changer le code, ce qui
+ // demande déjà d'être autorisé ; mais on refuse quand même toute forme
+ // douteuse plutôt que de la recopier telle quelle.
+ if ('acces' in s) {
+ const a = s.acces;
+ o.acces = (a && typeof a === 'object'
+ && typeof a.sel === 'string' && /^[0-9a-f]{16}$/.test(a.sel)
+ && typeof a.h === 'string' && /^[0-9a-f]{64}$/.test(a.h))
+ ? { sel: a.sel, h: a.h } : null;
+ }
return o;
}
/** Les clés MIDI sont bornées en nombre ET en forme (`cc:1:7`, `note:10:36`). */
@@ -726,7 +843,11 @@ function sanitizePresets(list) {
if (!Array.isArray(list)) return Array(PRESET_SLOTS).fill(null);
return list.slice(0, PRESET_SLOTS).map(p =>
p && typeof p === 'object' && Array.isArray(p.layers) && p.layers.length
- ? { name: String(p.name || 'P').slice(0, 12),
+ // ⚠ 16, pas 12 : `savePreset` et le renommage coupent tous deux à 16.
+ // Couper plus court ICI rabotait silencieusement tout nom de 13 à 16
+ // caractères — pas à la saisie, mais au redémarrage et à l'import, quand
+ // plus personne ne regarde. « Refrain final 2 » revenait « Refrain fin ».
+ ? { name: String(p.name || 'P').slice(0, 16),
layers: p.layers.slice(0, MAX_LAYERS).map(sanitizeLayer),
fixtures: Array.isArray(p.fixtures) ? sanitizeFixtures(p.fixtures) : null }
: null
@@ -1124,9 +1245,179 @@ function setLinkActive(on, keepError) {
}
function killCarabiner() { if (linkChild) { try { linkChild.kill(); } catch (e) {} linkChild = null; } }
-process.on('exit', killCarabiner);
-process.on('SIGINT', () => { flushConfig(); killCarabiner(); process.exit(0); });
-process.on('SIGTERM', () => { flushConfig(); killCarabiner(); process.exit(0); });
+
+/* ─── Icône de zone de notification (Windows) ─────────────────────────────────
+ * Demande de Pym : voir d'un coup d'œil si ça tourne, et piloter sans rouvrir
+ * la page. Node n'a AUCUNE API de zone de notification — dessiner cette icône
+ * demande du code natif. Sous Windows, PowerShell donne accès à
+ * `System.Windows.Forms.NotifyIcon`, présent d'origine : zéro installation,
+ * zéro dépendance npm, la règle n°2 tient.
+ *
+ * ⚠ WINDOWS SEULEMENT, et c'est un choix, pas un oubli (décidé avec Pym le
+ * 2026-08-05). macOS n'a pas d'équivalent scriptable : un élément de barre de
+ * menus demande une application compilée ou un outil tiers. Linux dépend de
+ * l'environnement de bureau. Plutôt qu'une fonction bancale partout, une
+ * fonction franche là où se trouve la régie.
+ *
+ * ⚠ DÉSACTIVÉE PAR DÉFAUT, parce qu'elle n'a jamais pu être exécutée : elle a
+ * été écrite depuis Linux, où PowerShell n'existe pas. Tout est donc enveloppé :
+ * un échec est silencieux et ne touche jamais le serveur. À activer dans
+ * Réglages, et à confirmer sur une vraie machine Windows.
+ *
+ * Le script est EMBARQUÉ ici, pas dans un fichier à côté : le distribuable est
+ * un exécutable unique, et un `.ps1` externe n'existerait pas à côté de lui.
+ * Il est écrit dans le dossier temporaire au démarrage.
+ *
+ * Le dialogue passe par l'API HTTP de Cascade, pas par un tuyau maison : le
+ * script sait déjà parler à `localhost`, et ça évite tout un protocole.
+ *
+ * ⚠ EN ÉDITANT CE SCRIPT : c'est un littéral de gabarit JavaScript. Un accent
+ * grave (l'échappement de PowerShell) ou une séquence ${…} casserait
+ * `server.js` AU CHARGEMENT — Cascade ne démarrerait plus du tout, pas
+ * seulement l'icône. Les libellés sont aussi volontairement SANS ACCENTS.
+ */
+const SYSTRAY_PS1 = `param([int]$Port)
+$ErrorActionPreference = 'SilentlyContinue'
+Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
+
+function Rond([System.Drawing.Color]$c) {
+ $bmp = New-Object System.Drawing.Bitmap 16,16
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $g.SmoothingMode = 'AntiAlias'
+ $g.FillEllipse((New-Object System.Drawing.SolidBrush $c), 2, 2, 12, 12)
+ $g.Dispose()
+ return [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
+}
+$vert = Rond ([System.Drawing.Color]::FromArgb(61,220,132))
+$rouge = Rond ([System.Drawing.Color]::FromArgb(255,82,82))
+$base = "http://127.0.0.1:$Port"
+
+function Appeler($chemin) {
+ try { Invoke-RestMethod -Uri "$base$chemin" -Method Post -Body '{}' -ContentType 'application/json' -TimeoutSec 3 | Out-Null } catch {}
+}
+
+$ni = New-Object System.Windows.Forms.NotifyIcon
+$ni.Icon = $rouge
+$ni.Text = 'Cascade'
+$ni.Visible = $true
+
+# Un seul chemin de sortie, et il commence par effacer l'icone. Sans ce
+# NIM_DELETE (c'est ce que fait Visible = false), Windows garde une pastille
+# morte jusqu'a ce que la souris passe dessus.
+function Partir {
+ $ni.Visible = $false
+ [System.Windows.Forms.Application]::Exit()
+ [Environment]::Exit(0)
+}
+
+$menu = New-Object System.Windows.Forms.ContextMenuStrip
+$mOuvrir = $menu.Items.Add("Ouvrir l'interface")
+$mOuvrir.add_Click({ Start-Process $base })
+$menu.Items.Add('-') | Out-Null
+$mGo = $menu.Items.Add('Demarrer le show')
+$mGo.add_Click({ Appeler '/api/start' })
+$mStop = $menu.Items.Add('Arreter le show')
+$mStop.add_Click({ Appeler '/api/stop' })
+$menu.Items.Add('-') | Out-Null
+$mQuit = $menu.Items.Add('Quitter Cascade')
+$mQuit.add_Click({ Appeler '/api/quit'; Partir })
+$ni.ContextMenuStrip = $menu
+$ni.add_MouseDoubleClick({ Start-Process $base })
+
+# Sondage de l'etat sur /api/ping : la route la plus legere, ET la seule qui ne
+# fasse pas passer ce script pour une interface ouverte (sinon l'arret
+# automatique de Cascade ne se declencherait plus jamais).
+#
+# Trois echecs d'affilee avant de partir, pas un seul : un pic de charge en
+# plein show fait depasser le delai de 3 s, et une icone qui disparait pour ca
+# ne revient plus jamais.
+$script:rates = 0
+$timer = New-Object System.Windows.Forms.Timer
+$timer.Interval = 1500
+$timer.add_Tick({
+ try {
+ $e = Invoke-RestMethod -Uri "$base/api/ping" -TimeoutSec 3 -ErrorAction Stop
+ $script:rates = 0
+ # La case vient d'etre decochee : on part proprement, sans laisser de
+ # fantome. Le serveur tue ce processus de son cote 3 s plus tard, au cas ou.
+ if ($e.PSObject.Properties['systray'] -and -not $e.systray) { Partir; return }
+ if ($e.running) { $ni.Icon = $vert; $ni.Text = 'Cascade - le show tourne' }
+ else { $ni.Icon = $rouge; $ni.Text = 'Cascade - a l arret' }
+ } catch {
+ $script:rates = $script:rates + 1
+ if ($script:rates -ge 3) { Partir }
+ }
+})
+$timer.Start()
+[System.Windows.Forms.Application]::Run()
+`;
+
+let systrayChild = null;
+/**
+ * Éteindre l'icône.
+ *
+ * `doux` = on laisse d'abord le script partir de LUI-MÊME. Il voit
+ * `systray: false` sur son prochain sondage (1,5 s au pire) et efface l'icône
+ * en partant. Tuer le processus, à l'inverse, n'exécute aucun nettoyage :
+ * `TerminateProcess` ne laisse pas le temps d'envoyer `NIM_DELETE`, et Windows
+ * garde une pastille morte jusqu'à ce que la souris passe dessus. Le kill reste
+ * armé 3 s plus tard, au cas où le script serait coincé — et il vise CE
+ * processus-là, pas « celui qui tourne à ce moment-là ».
+ *
+ * On garde la référence pendant ces 3 s : si la case est recochée entretemps,
+ * `startSystray()` ne relance rien et le script, revoyant `systray: true`,
+ * reste en place. L'icône n'a même pas cligné.
+ */
+function killSystray(doux) {
+ const enfant = systrayChild;
+ if (!enfant) return;
+ if (doux) {
+ setTimeout(() => { try { enfant.kill(); } catch (e) {} }, 3000).unref();
+ return;
+ }
+ systrayChild = null;
+ try { enfant.kill(); } catch (e) {}
+}
+function startSystray() {
+ // Silencieux et sans conséquence si quoi que ce soit manque : cette fonction
+ // ne doit JAMAIS empêcher Cascade de démarrer.
+ try {
+ if (process.platform !== 'win32' || !state.settings.systray || systrayChild) return;
+ const fichier = path.join(os.tmpdir(), 'cascade-systray.ps1');
+ // Le BOM n'est pas décoratif : PowerShell 5.1 lit un .ps1 SANS BOM dans la
+ // page de codes ANSI de la machine, pas en UTF-8. Le script est ASCII pur
+ // aujourd'hui (libellés volontairement sans accents), donc ça marcherait —
+ // mais le premier accent ajouté casserait tout, très loin d'ici.
+ fs.writeFileSync(fichier, '' + SYSTRAY_PS1);
+ const enfant = spawn('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden',
+ '-ExecutionPolicy', 'Bypass', '-File', fichier, '-Port', String(actualPort)],
+ { stdio: 'ignore', windowsHide: true });
+ systrayChild = enfant;
+ // ⚠ Comparer avant d'effacer. Sans ça : on tue A, on lance B, l'événement
+ // `exit` de A arrive APRÈS et met la référence à null alors que B est
+ // vivant — Cascade perd sa poignée et un `startSystray()` ultérieur pose
+ // une deuxième icône.
+ const oublier = () => { if (systrayChild === enfant) systrayChild = null; };
+ enfant.on('error', (e) => {
+ // Le seul endroit qui dira pourquoi rien n'apparaît. Sans ce message,
+ // un premier essai raté sur une vraie machine Windows ne remonte rien.
+ console.warn('[cascade] icône de notification : ' + (e && e.message));
+ oublier();
+ });
+ enfant.on('exit', (code) => {
+ if (code) console.warn('[cascade] icône de notification : PowerShell a quitté (code ' + code + ').');
+ oublier();
+ });
+ } catch (e) {
+ console.warn('[cascade] icône de notification : ' + (e && e.message));
+ systrayChild = null;
+ }
+}
+
+process.on('exit', () => { killCarabiner(); killSystray(); });
+process.on('SIGINT', () => { flushConfig(); killCarabiner(); killSystray(); process.exit(0); });
+process.on('SIGTERM', () => { flushConfig(); killCarabiner(); killSystray(); process.exit(0); });
// ---------------------------------------------------------------------------
// Arrêt automatique (mode app, plus de terminal visible) : quand la dernière
@@ -1135,10 +1426,12 @@ process.on('SIGTERM', () => { flushConfig(); killCarabiner(); process.exit(0); }
// - jamais tant qu'aucune interface ne s'est encore connectée (démarrage) ;
// - JAMAIS pendant que les chasers tournent (on ne coupe pas un show) ;
// - jamais si un contrôleur OSC externe a parlé récemment.
-// 8 s de grâce : un simple rechargement de page ne déclenche rien.
+// - et l'icône de zone de notification sonde `/api/ping`, PAS `/api/state` :
+// sinon elle passerait pour une interface ouverte et l'arrêt automatique ne
+// se déclencherait plus jamais.
+// 8 s de grâce par défaut : un simple rechargement de page ne déclenche rien.
// ---------------------------------------------------------------------------
let lastUiPollAt = 0, lastOscAt = 0;
-const UI_GONE_MS = 8000;
setInterval(() => {
if (NO_AUTOQUIT || !lastUiPollAt) return;
const now = Date.now();
@@ -1148,7 +1441,7 @@ setInterval(() => {
console.log('[cascade] plus aucune interface ouverte — arrêt automatique.');
flushConfig(); killCarabiner();
process.exit(0);
-}, 2000);
+}, Math.min(2000, Math.max(100, Math.floor(UI_GONE_MS / 4))));
// Courbe vitesse : 0–1 → ×0.1 à ×4, centre 0.5 = ×1
function speedCurve(v) { return v >= 0.5 ? Math.pow(4, (v - 0.5) * 2) : Math.pow(10, (v - 0.5) * 2); }
@@ -1485,9 +1778,18 @@ function savePreset(i, name) {
return { name: n || 'P' + (i + 1), layers: deep(state.layers), fixtures: deep(state.fixtures) };
}
/** Fondu en cours entre deux presets (null = rappel sec). */
-let fade = null; // { start, dur, layers, fixtures, engines }
+let fade = null; // { start, dur, de, layers, fixtures, engines }
function cancelFade() { fade = null; }
+// ── Repères de la banque de presets, pour l'interface ─────────────────────────
+// Les deux vivent en MÉMOIRE et n'entrent PAS dans `state` : rien à sanitiser,
+// rien à exporter, rien à migrer. `presetActif` = dernier slot rappelé ou
+// enregistré, pour que le régisseur voie quel pavé joue. `presetsRev` = compteur
+// de révision de la banque : l'interface ne recharge les empreintes que
+// lorsqu'il bouge, au lieu de les faire voyager 8 fois par seconde.
+let presetActif = null;
+let presetsRev = 1;
+
/**
* Rappelle un preset. Si un temps de fondu est réglé ET qu'un show tourne,
* la scène sortante est mise de côté avec son état moteur : elle continue de
@@ -1506,7 +1808,7 @@ function recallPreset(i, fadeMs) {
}
// Un rappel pendant un fondu remplace le précédent : la scène en cours de
// sortie est abandonnée, sinon il faudrait empiler les fondus à l'infini.
- fade = { start: Date.now(), dur, layers: deep(state.layers),
+ fade = { start: Date.now(), dur, de: presetActif, layers: deep(state.layers),
fixtures: deep(state.fixtures), engines: store };
} else cancelFade();
state.layers = deep(p.layers);
@@ -1521,11 +1823,71 @@ function recallPreset(i, fadeMs) {
pruneCaches();
}
engines.clear();
+ // ⚠ Un rappel ne change pas la BANQUE, donc il n'incrémente pas `presetsRev`
+ // en général — recharger les empreintes à chaque rappel ferait une requête de
+ // plus au pire moment. MAIS s'il a remplacé le plateau, il change l'empreinte
+ // de tout preset qui n'a PAS de disposition à lui (les projets importés
+ // peuvent en avoir : `sanitizePresets` autorise `fixtures: null`), puisque
+ // ceux-là retombent sur `state.fixtures`. Mesuré : une empreinte passait de
+ // trois barres à une sans que la grille le sache.
+ if (Array.isArray(p.fixtures) && p.fixtures.length) presetsRev++;
+ presetActif = i;
return true;
}
/** Noms des presets pour l'interface : null = slot vide. */
function presetNames() { return state.presets.map((p, i) => p ? (p.name || 'P' + (i + 1)) : null); }
+/**
+ * Empreinte d'un preset, pour la vignette d'un pavé de la grille.
+ *
+ * ⚠ C'est une empreinte de COUVERTURE, pas une photo du motif : elle dit quelles
+ * barres le preset pilote et avec quelles teintes, pas ce qui est allumé à un
+ * instant donné (un chase n'allume qu'une barre à la fois — une photo serait
+ * presque vide et changerait à chaque image).
+ *
+ * Elle passe par `resolveBars`, donc elle ment EXACTEMENT comme mentirait le
+ * moteur : un groupe vidé ramène la couche sur toutes les barres, ici comme en
+ * scène. C'est voulu — une vignette qui corrige le moteur serait pire.
+ *
+ * Aucun moteur n'est instancié, aucun état runtime touché, aucun OSC émis : on
+ * peut l'appeler pendant un show sans rien déranger. Et elle n'est PAS dans
+ * `/api/state`, à dessein : cette réponse part 8 fois par seconde.
+ */
+function infoPreset(p) {
+ if (!p) return null;
+ // Un preset importé d'une vieille config peut n'avoir aucune fixture : on
+ // retombe alors sur le plateau courant, faute de mieux.
+ const fx = (Array.isArray(p.fixtures) && p.fixtures.length ? p.fixtures : state.fixtures)
+ .filter(f => f && f.enabled !== false);
+ // Plafond de 64 cases : au-delà on sous-échantillonne. Une bande de 128 cases
+ // serait illisible, et la charge utile grossirait pour rien.
+ const pas = Math.ceil(fx.length / 64) || 1;
+ const vus = fx.filter((f, i) => i % pas === 0);
+ const rang = new Map(vus.map((f, i) => [f.id, i]));
+ const cases = new Array(vus.length).fill('.');
+ const pal = [];
+ const act = p.layers.filter(L => L.enabled);
+ for (const L of act) {
+ const c = L.target === 'color' ? (L.colorA || '#ff2000') : ACCENT;
+ let k = pal.indexOf(c);
+ if (k < 0) { k = pal.length; pal.push(c); }
+ // ⚠ Résoudre sur `fx` COMPLET, pas sur l'échantillon : `resolveBars` replie
+ // sur toutes les barres quand aucune de la sélection n'est présente, et
+ // sous-échantillonner d'abord déclencherait ce repli à tort.
+ for (const f of resolveBars(L, fx)) {
+ const r = rang.get(f.id);
+ if (r != null) cases[r] = String(k);
+ }
+ }
+ return {
+ a: act.length, t: p.layers.length,
+ e: act.map(L => L.engine === 'steps' ? 'P' : L.engine === 'wave' ? 'V' : 'C').join('').slice(0, 8),
+ pal, c: cases.join(''),
+ };
+}
+/** Empreintes des 16 slots. Servie à la demande, jamais dans `/api/state`. */
+function presetsInfo() { return state.presets.map(infoPreset); }
+
/** Resync : recale la phase (départ des pas) d'une couche, ou de toutes. */
/**
* GO / RESYNC : tout repart ensemble, sur le temps fort.
@@ -2453,14 +2815,254 @@ function readBody(req) {
req.on('aborted', () => finish({}));
});
}
+/* ─── Code d'accès facultatif ─────────────────────────────────────────────────
+ * Cascade s'ouvre depuis un iPad ou un téléphone, donc depuis le Wi-Fi de la
+ * salle. Un code à 4 chiffres empêche un curieux de prendre la main sur la
+ * lumière pendant le spectacle.
+ *
+ * ⚠ QUATRE CHIFFRES, C'EST 10 000 COMBINAISONS. Sans limitation de tentatives,
+ * ça se casse en quelques secondes et le code ne protège RIEN. La limitation
+ * ci-dessous n'est donc pas un raffinement : c'est ce qui rend la fonction
+ * honnête. Elle est verrouillée par un test et par un anti-mutant.
+ *
+ * Ce que ce code NE fait pas, volontairement :
+ * - il ne ferme pas l'OSC ni le MIDI : ceux-là passent par un câble ou une
+ * console posée sur le réseau de production, c'est un autre domaine de
+ * confiance, et les fermer casserait les installations existantes ;
+ * - il n'est jamais demandé en local. La machine hôte est celle qu'on a
+ * physiquement sous la main, et cette exemption garantit qu'on ne peut PAS
+ * s'enfermer dehors : il y a toujours une voie pour retirer le code.
+ *
+ * LA LIMITATION EST À DEUX ÉTAGES, et le second compte le plus. Le blocage par
+ * adresse (5 essais / minute) arrête un curieux, mais s'effondre dès qu'un
+ * attaquant fait varier son IP source — trivial en IPv6, où il tient tout un
+ * /64. D'où un PLAFOND GLOBAL, indépendant de l'IP : au-delà de N échecs par
+ * minute tous clients confondus, toute tentative depuis le réseau est refusée
+ * un moment. La machine hôte, elle, n'est jamais limitée (elle ne passe pas par
+ * le code). Un code à 4 chiffres reste faible par nature : ces deux étages le
+ * ramènent d'« instantané » à « des heures », ce qui suffit à couvrir un show,
+ * pas à en faire un secret fort.
+ *
+ * Mesuré (fenêtre ramenée à 2 s pour l'observer) : 6 échecs sur 6 adresses
+ * distinctes bloquent une adresse JAMAIS vue ; après la fenêtre, un échec rend à
+ * nouveau 401 et le bon code passe. Le blocage se LÈVE — il ne se perpétue pas.
+ * C'était le risque de cette mécanique : la durée du blocage vaut celle de la
+ * fenêtre, donc au moment où il se lève, la fenêtre est forcément expirée et le
+ * compteur repart de zéro. Raccourcir le blocage sous la fenêtre casserait ça.
+ *
+ * ⚠ LIMITE CONNUE, NON CORRIGÉE — le « DNS rebinding ». Rien ne vérifie l'en-tête
+ * `Host` : une page hébergée sur un domaine qui résout vers 127.0.0.1 devient
+ * MÊME ORIGINE aux yeux du navigateur, et tous les gardes ci-dessus tombent avec
+ * elle. Le correctif serait de refuser un `Host` qui n'est ni localhost ni une
+ * adresse de `lanUrls()` — mais il casserait l'accès par nom d'hôte
+ * (`http://mac-de-pym.local:3333`), qui marche aujourd'hui. Le QR code, lui,
+ * donne toujours une adresse IP. ⚠ ARBITRAGE DE PYM avant de trancher : on
+ * échange une attaque sophistiquée contre une façon de se connecter qui
+ * fonctionne. Ne pas « corriger » ça tout seul.
+ *
+ * ⚠ COMPROMIS ASSUMÉ du plafond global : quelqu'un qui rate volontairement
+ * 30 fois empêche les AUTRES appareils du réseau d'entrer pendant une minute.
+ * C'est un déni de service, mais le moins cher des deux : le régisseur garde sa
+ * machine (jamais bloquée), et l'alternative — laisser passer — rendrait le code
+ * cassable. Une gêne d'une minute contre la prise de contrôle du spectacle.
+ *
+ * ⚠ LIMITE ASSUMÉE : le code haché est écrit dans `cascade-config.json`, en
+ * clair sur le disque, à côté de l'exécutable. Quatre chiffres se cassent hors
+ * ligne instantanément : le code ne protège donc PAS contre quelqu'un qui tient
+ * ce fichier (ou la clé USB). C'est le même domaine de confiance que la machine
+ * hôte. En revanche un PROJET exporté ne porte pas `settings`, donc le partager
+ * ne fuit pas le code.
+ */
+const ACCES_MAX_ESSAIS = 5; // avant blocage, PAR adresse
+const ACCES_BLOCAGE_MS = 60000; // durée du blocage, par adresse
+const ACCES_JETON_MS = 12 * 3600 * 1000;
+const ACCES_GLOBAL_MAX = 30; // échecs/minute TOUS clients confondus, avant blocage global
+const ACCES_GLOBAL_MS = 60000; // fenêtre et durée du blocage global
+const ACCES_OUBLI_MS = 5 * 60000; // au-delà, une adresse inactive est oubliée (borne la mémoire)
+const ACCES_MAX_CLES = 4096; // plafond dur du nombre d'adresses suivies
+
+/** Jetons de session valides, en mémoire : un redémarrage redemande le code. */
+const accesJetons = new Map(); // jeton -> expiration
+/** Tentatives ratées par adresse : { n, jusqua, vu }. */
+const accesEssais = new Map();
+/** Étage global : échecs récents tous clients confondus, et fin du blocage. */
+const accesGlobal = { n: 0, debut: 0, bloqueJusqua: 0 };
+
+/**
+ * Balaie les adresses oubliées et les jetons expirés. Sans ça, un attaquant qui
+ * fait varier son IP source remplirait `accesEssais` sans fin (fuite mémoire).
+ * Appelé à chaque tentative — donc jamais si personne n'attaque.
+ */
+function nettoyerAcces(now) {
+ for (const [k, v] of accesEssais) {
+ if (now - (v.vu || 0) > ACCES_OUBLI_MS) accesEssais.delete(k);
+ }
+ // Filet dur : si la Map déborde malgré tout, on repart de zéro. Le plafond
+ // global reste, lui, en place — c'est lui qui tient sous une vraie attaque.
+ if (accesEssais.size > ACCES_MAX_CLES) accesEssais.clear();
+ for (const [j, exp] of accesJetons) {
+ if (now > exp) accesJetons.delete(j);
+ }
+}
+
+function hacherCode(code, sel) {
+ return crypto.createHash('sha256').update(sel + ':' + code).digest('hex');
+}
+/** Rend l'objet à persister, ou null pour retirer le code. */
+function poserCode(code) {
+ const c = String(code == null ? '' : code).trim();
+ if (!c) return null;
+ if (!/^\d{4}$/.test(c)) return undefined; // invalide : l'appelant refuse
+ const sel = crypto.randomBytes(8).toString('hex');
+ return { sel, h: hacherCode(c, sel) };
+}
+function codeJuste(code, reglage) {
+ if (!reglage || !reglage.h || !reglage.sel) return false;
+ const a = Buffer.from(hacherCode(String(code || ''), reglage.sel), 'hex');
+ const b = Buffer.from(String(reglage.h), 'hex');
+ // Comparaison à temps constant : sinon la durée de la réponse renseigne sur
+ // le nombre de chiffres justes.
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+/**
+ * L'en-tête `Host` désigne-t-il bien CETTE machine ?
+ *
+ * ⚠ C'est la parade au « DNS rebinding », et sans elle tous les autres gardes
+ * tombent. L'attaque : une page sur `mechant.com`, avec une durée de vie DNS très
+ * courte, se ré-résout vers `127.0.0.1` après son chargement. Pour le navigateur
+ * l'origine n'a pas changé — la page est « chez elle » — donc le garde
+ * `Content-Type` ne sert plus à rien (elle pose l'en-tête qu'elle veut) et le
+ * cookie `SameSite` ne protège plus. Et Cascade, lui, voit une requête venant de
+ * 127.0.0.1, donc il l'exempte du code d'accès. Contrôle complet de la lumière,
+ * depuis n'importe quelle page web, sans être sur le réseau.
+ *
+ * Une requête rebindée porte `Host: mechant.com`. On n'accepte donc que ce sous
+ * quoi Cascade se sert légitimement :
+ * - `localhost` et les adresses IP littérales (ce que donne le QR code) ;
+ * - les noms en `.local` — réservés au mDNS (RFC 6762), donc impossibles à
+ * posséder sur Internet : le système ne les résout que sur le réseau local.
+ * C'est ce qui garde `mac-de-pym.local:3333` sans rouvrir la faille ;
+ * - une requête SANS `Host` (HTTP/1.0, `curl` nu) : un navigateur en envoie
+ * toujours un, donc son absence n'est jamais une attaque par rebinding.
+ *
+ * ⚠ Ce qui reste refusé, et c'est assumé : un nom de machine Windows sans
+ * suffixe, et les alias du fichier `hosts`. Décidé avec Pym le 2026-08-05 : il
+ * passe par le QR code ou par l'adresse IP.
+ */
+function hoteAutorise(req) {
+ const brut = req.headers && req.headers.host;
+ if (!brut) return true;
+ let h = String(brut).trim().toLowerCase();
+ if (h.startsWith('[')) { // IPv6 entre crochets : [::1]:3333
+ const f = h.indexOf(']');
+ return f > 0; // littéral IPv6 = cette machine
+ }
+ const parts = h.split(':');
+ if (parts.length > 2) return true; // IPv6 nu, sans crochets
+ h = parts[0];
+ if (h === 'localhost' || h.endsWith('.local')) return true;
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(h); // adresse IPv4 littérale
+}
+
+/**
+ * Le corps est-il annoncé en JSON ? Garde CSRF : voir le bloc POST.
+ *
+ * ⚠ ON COMPARE L'ESSENCE DU TYPE, PAS UNE SOUS-CHAÎNE. Une première version
+ * testait `.includes('application/json')` — et se contournait en une ligne :
+ * `Content-Type: multipart/form-data; boundary=application/json` contient la
+ * sous-chaîne, et la règle CORS ne regarde que l'essence (`type/sous-type`), en
+ * ignorant les paramètres. Ce type-là est donc « safelisté » : un `fetch` en
+ * `no-cors` le pose SANS pré-vol. Mesuré contre le serveur : `/api/quit` tuait
+ * le processus et `/api/acces {nouveau}` posait le code de l'installation.
+ * On coupe donc au premier `;` avant de comparer — ce qui accepte au passage
+ * `APPLICATION/JSON` (légal en HTTP) et refuse `application/jsonp`.
+ */
+function estJson(req) {
+ return String(req.headers['content-type'] || '')
+ .split(';')[0].trim().toLowerCase() === 'application/json';
+}
+
+/** Les réglages SANS le haché du code : ce qu'on a le droit d'envoyer. */
+function sansCode(reglages) {
+ const { acces, ...reste } = reglages;
+ return reste;
+}
+
+/** L'adresse est-elle la machine elle-même ? */
+function estLocal(req) {
+ const a = (req.socket && req.socket.remoteAddress) || '';
+ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
+}
+function cleEssais(req) { return (req.socket && req.socket.remoteAddress) || '?'; }
+
+function jetonDeLaRequete(req) {
+ const brut = req.headers && req.headers.cookie;
+ if (!brut) return null;
+ const m = /(?:^|;\s*)cascade_acces=([A-Za-z0-9]+)/.exec(brut);
+ return m ? m[1] : null;
+}
+function jetonValide(req) {
+ const j = jetonDeLaRequete(req);
+ if (!j) return false;
+ const exp = accesJetons.get(j);
+ if (!exp) return false;
+ if (Date.now() > exp) { accesJetons.delete(j); return false; }
+ return true;
+}
+/** Vrai si la requête a le droit de passer. */
+function accesAutorise(req) {
+ if (!state.settings.acces) return true; // aucun code posé
+ if (estLocal(req)) return true; // la machine hôte, toujours
+ return jetonValide(req);
+}
+
function json(res, obj, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
}
+/**
+ * Récupère le niveau du micro poussé par l'interface, en paramètre du poll
+ * qu'elle fait déjà : `GET /api/state?a=0.42`. Aucune requête de plus, ~8 octets
+ * sur la ligne de requête, zéro octet dans la réponse.
+ *
+ * Tolérant par construction : toute valeur illisible est ignorée en silence,
+ * plutôt que d'installer un niveau aberrant. Une entrée hostile ne doit jamais
+ * pouvoir clouer un modulateur en butée.
+ */
+function lireNiveauAudio(req) {
+ // ⚠ Refuser tout ce qui n'est pas une requête de script. Sans ce garde, une
+ // page piégée ouverte sur la machine hôte pouvait boucler sur
+ // ` ` et clouer un modulateur de
+ // source audio en butée — sur le master avec min 0, le noir en plein show.
+ // Le `fetch` de l'interface annonce `empty` ; une image annonce `image`.
+ // Un client sans cet en-tête (curl, un test) passe : il n'est pas piégeable.
+ const dest = req.headers['sec-fetch-dest'];
+ if (dest && dest !== 'empty') return;
+ const q = req.url.indexOf('?');
+ if (q < 0) return;
+ const m = /(?:^|&)a=([0-9.]{1,8})(?:&|$)/.exec(req.url.slice(q + 1));
+ if (!m) return;
+ const v = parseFloat(m[1]);
+ if (!(v >= 0)) return; // NaN inclus : la comparaison est fausse pour lui
+ audio.v = Math.min(1, v);
+ audio.at = Date.now();
+}
+
const server = http.createServer(async (req, res) => {
const url = req.url.split('?')[0];
+ // Parade au DNS rebinding — voir `hoteAutorise`. Placé tout en haut : même la
+ // PAGE est refusée, sinon elle se chargerait pour voir chaque appel d'API
+ // échouer, ce qui ressemblerait à une panne au lieu d'un refus.
+ if (!hoteAutorise(req)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
+ return res.end('Cascade ne répond que sur localhost, une adresse IP, ou un nom en .local.\n'
+ + 'Reçu : ' + String(req.headers.host || '(aucun)') + '\n'
+ + 'Utilisez le QR code de l\'interface, ou l\'adresse IP affichée en haut.');
+ }
+
if (url === '/' || url === '/index.html') {
fs.readFile(path.join(__dirname, 'public', 'index.html'), (err, buf) => {
if (err) { res.writeHead(500); return res.end('public/index.html introuvable'); }
@@ -2470,10 +3072,117 @@ const server = http.createServer(async (req, res) => {
return;
}
+ // ── Portillon du code d'accès ──────────────────────────────────────────────
+ // La PAGE est toujours servie (juste au-dessus) : sans elle, impossible
+ // d'afficher la demande de code. C'est l'API qui est fermée.
+ if (url.startsWith('/api/') && url !== '/api/ping' && url !== '/api/acces') {
+ if (!accesAutorise(req)) {
+ // ⚠ On note quand même qu'une interface est là. L'arrêt automatique
+ // regarde `lastUiPollAt` : sans cette ligne, le serveur pourrait se
+ // couper pendant que le régisseur tape son code. Le pire qu'un curieux
+ // puisse faire est donc de garder Cascade allumé — sans rien piloter.
+ if (url === '/api/state') lastUiPollAt = Date.now();
+ return json(res, { ok: false, acces: 'requis' }, 401);
+ }
+ }
+
+ // Demande du code, pose et retrait. Volontairement hors du portillon.
+ if (url === '/api/acces') {
+ if (req.method !== 'POST') return json(res, { ok: false }, 405);
+ // Même garde CSRF que les autres POST (voir le bloc POST plus bas) : un
+ // formulaire piégé ne doit pas pouvoir brûler le budget de tentatives.
+ if (!estJson(req)) {
+ return json(res, { ok: false, error: 'Content-Type application/json requis' }, 415);
+ }
+ const body = await readBody(req);
+ const cle = cleEssais(req);
+ const e = accesEssais.get(cle);
+
+ // Poser ou retirer un code : réservé à qui est DÉJÀ autorisé, sinon un
+ // inconnu pourrait simplement remplacer le code par le sien.
+ if (body && 'nouveau' in body) {
+ if (!accesAutorise(req)) return json(res, { ok: false, acces: 'requis' }, 401);
+ const pose = poserCode(body.nouveau);
+ if (pose === undefined) {
+ return json(res, { ok: false, error: 'le code doit faire exactement 4 chiffres' });
+ }
+ state.settings.acces = pose;
+ // Changer ou retirer le code invalide toutes les sessions ouvertes :
+ // sinon l'iPad d'hier continuerait d'entrer avec l'ancien.
+ accesJetons.clear();
+ saveConfig();
+ return json(res, { ok: true, actif: !!pose });
+ }
+
+ // Entrer le code.
+ if (!state.settings.acces) return json(res, { ok: true, actif: false });
+ const now = Date.now();
+ nettoyerAcces(now);
+ // Étage global : il tient même quand l'attaquant change d'IP à chaque essai.
+ if (accesGlobal.bloqueJusqua > now) {
+ return json(res, { ok: false, error: 'trop d’essais',
+ attendre: Math.ceil((accesGlobal.bloqueJusqua - now) / 1000) }, 429);
+ }
+ // Étage par adresse : arrête un curieux sans pénaliser tout le réseau.
+ if (e && e.jusqua > now) {
+ return json(res, { ok: false, error: 'trop d’essais',
+ attendre: Math.ceil((e.jusqua - now) / 1000) }, 429);
+ }
+ if (codeJuste(body && body.code, state.settings.acces)) {
+ accesEssais.delete(cle);
+ const jeton = crypto.randomBytes(24).toString('hex');
+ accesJetons.set(jeton, now + ACCES_JETON_MS);
+ res.writeHead(200, {
+ 'Content-Type': 'application/json',
+ // `HttpOnly` : le jeton n'est pas lisible en JavaScript, donc un nom de
+ // fixture piégé ne pourrait pas le faire fuir. `SameSite=Strict` : il
+ // ne part pas sur une requête déclenchée depuis un autre site.
+ 'Set-Cookie': 'cascade_acces=' + jeton + '; Path=/; HttpOnly; SameSite=Strict; Max-Age='
+ + Math.floor(ACCES_JETON_MS / 1000),
+ });
+ return res.end(JSON.stringify({ ok: true, actif: true }));
+ }
+ // Raté. On compte l'échec aux DEUX étages.
+ // Global : une fenêtre glissante d'une minute. Au-delà du plafond, tout le
+ // réseau est bloqué le temps de la fenêtre — c'est le prix pour qu'un
+ // attaquant multi-IP ne puisse pas balayer les 10 000 codes.
+ if (now - accesGlobal.debut > ACCES_GLOBAL_MS) { accesGlobal.debut = now; accesGlobal.n = 0; }
+ accesGlobal.n++;
+ if (accesGlobal.n >= ACCES_GLOBAL_MAX) accesGlobal.bloqueJusqua = now + ACCES_GLOBAL_MS;
+ // Par adresse : on ne remet le compteur à zéro que si un blocage a EXISTÉ et
+ // qu'il est fini. Tester `jusqua <= maintenant` seul est vrai aussi pour
+ // `jusqua = 0` (jamais bloqué) : le compteur repartait alors de zéro à
+ // chaque essai et la limitation ne limitait RIEN — « restants : 4 » à vie.
+ const expire = e && e.jusqua > 0 && e.jusqua <= now;
+ const n = (expire ? 0 : (e ? e.n : 0)) + 1;
+ const bloque = n >= ACCES_MAX_ESSAIS;
+ accesEssais.set(cle, { n: bloque ? 0 : n, jusqua: bloque ? now + ACCES_BLOCAGE_MS : 0, vu: now });
+ return json(res, { ok: false, error: 'code incorrect',
+ restants: bloque ? 0 : ACCES_MAX_ESSAIS - n,
+ attendre: bloque ? ACCES_BLOCAGE_MS / 1000 : 0 }, 401);
+ }
+
// Sert à repérer qu'une autre instance de Cascade tient déjà le port.
+ //
+ // ⚠ C'est AUSSI la route que sonde l'icône de zone de notification, et c'est
+ // délibéré : `/api/state` remet `lastUiPollAt` à jour, donc un sondage de
+ // l'icône y aurait fait passer Cascade pour « une interface est ouverte » et
+ // AURAIT DÉSACTIVÉ L'ARRÊT AUTOMATIQUE — case cochée, plus moyen de fermer
+ // Cascade autrement qu'au Gestionnaire des tâches. `/api/ping` n'y touche pas.
+ // Accessoirement, `/api/state` renvoie fixtures, couches, scène, vues et
+ // niveaux : une copie complète toutes les 1,5 s pour lire un booléen.
+ //
+ // Les deux clés ne sortent QUE pour la machine hôte : sur le réseau, ping
+ // reste la carte de visite minimale qu'il a toujours été (elle est hors du
+ // portillon du code d'accès, donc tout ce qu'on y met est public).
if (url === '/api/ping') {
+ const rep = { app: APP_NAME, version: VERSION };
+ if (estLocal(req)) {
+ rep.running = state.global.running;
+ rep.systray = !!state.settings.systray;
+ }
res.writeHead(200, { 'Content-Type': 'application/json' });
- return res.end(JSON.stringify({ app: APP_NAME, version: VERSION }));
+ return res.end(JSON.stringify(rep));
}
if (url === '/api/export') {
@@ -2499,14 +3208,41 @@ const server = http.createServer(async (req, res) => {
}, null, 2));
}
+ // Empreintes des presets, servies À LA DEMANDE. Volontairement hors de
+ // `/api/state` : celle-ci part 8 fois par seconde, et 16 empreintes y
+ // pèseraient une demi-kilo-octet à chaque fois pour une donnée qui ne bouge
+ // qu'à l'enregistrement. L'interface ne rappelle cette route que lorsque
+ // `presetsRev` change. Aucune écriture, aucun effet de bord — contrairement à
+ // `/api/export`, qui remet `dirtySinceExport` à false.
+ if (url === '/api/presets-info') {
+ return json(res, { ok: true, rev: presetsRev, infos: presetsInfo() });
+ }
+
if (url === '/api/state') {
lastUiPollAt = Date.now(); // une interface est ouverte (voir arrêt automatique)
+ lireNiveauAudio(req); // suiveur audio : le niveau voyage sur le poll
return json(res, {
- app: APP_NAME, version: VERSION, net: lanUrls(),
- settings: state.settings, scene: state.scene, vues: state.vues,
+ // `win` décrit la machine où tourne CASCADE, pas le navigateur : le
+ // réglage de l'icône agit côté serveur, et l'interface est faite pour être
+ // ouverte depuis un iPad. `navigator.platform` répondrait sur la mauvaise
+ // machine — case grisée à tort depuis une tablette, case active à tort
+ // devant un hôte macOS (et `systray: true` écrit dans la config, qui
+ // voyage sur la clé USB).
+ app: APP_NAME, version: VERSION, net: lanUrls(), win: process.platform === 'win32',
+ // ⚠ Le haché du code d'accès ne sort JAMAIS d'ici. Quatre chiffres, c'est
+ // 10 000 combinaisons : un haché salé se casse hors ligne instantanément,
+ // donc l'envoyer reviendrait à envoyer le code. L'interface n'a besoin
+ // que de savoir s'il y en a un — c'est la clé `acces` plus bas.
+ settings: sansCode(state.settings), scene: state.scene, vues: state.vues,
fixtures: state.fixtures, groups: state.groups,
layers: state.layers, global: state.global,
- presets: presetNames(),
+ // `presets` garde sa forme de tableau de 16 chaînes. Les deux repères qui
+ // suivent sont des entiers : ~40 octets, contre ~560 pour les empreintes,
+ // qui vivent sur `/api/presets-info`.
+ presets: presetNames(), presetsRev, presetActif,
+ // Seulement s'il y a un code, JAMAIS le code ni son haché. `local` sert à
+ // l'interface pour dire « depuis cette machine, il n'est pas demandé ».
+ acces: { actif: !!state.settings.acces, local: estLocal(req) },
midiMap: state.midiMap,
link: { active: link.active, connected: link.connected, bpm: link.bpm, peers: link.peers,
error: link.error,
@@ -2518,12 +3254,26 @@ const server = http.createServer(async (req, res) => {
host: state.settings.mmHost, port: state.settings.mmPort },
// Progression du fondu entre presets (0-1), pour l'afficher en direct
fade: fade ? Math.min(1, (Date.now() - fade.start) / fade.dur) : null,
+ // Slot d'où part le fondu (null = on ne sait pas d'où on vient), pour que
+ // la grille montre le pavé sortant en même temps que l'entrant.
+ fadeDe: fade && fade.de != null ? fade.de : null,
project: { name: state.projectName, dirty: dirtySinceExport, lastExportAt },
levels: runtime.levels, colors: runtime.colors,
});
}
if (req.method === 'POST') {
+ // ⚠ CSRF sur la machine hôte. `estLocal` laisse passer localhost SANS cookie
+ // (pour ne jamais s'enfermer dehors). Sans ce garde, une page web piégée
+ // ouverte dans le navigateur de l'hôte pourrait poster un formulaire vers
+ // `/api/new` (efface le projet), `/api/quit` (coupe le serveur) ou
+ // `/api/blackout` — un `