diff --git a/.github/ISSUE_TEMPLATE/controller-profile.md b/.github/ISSUE_TEMPLATE/controller-profile.md index 0a9f669..49d9601 100644 --- a/.github/ISSUE_TEMPLATE/controller-profile.md +++ b/.github/ISSUE_TEMPLATE/controller-profile.md @@ -12,7 +12,7 @@ Fabricant et modèle / Manufacturer and model: ## Profil JSON / JSON profile -Collez ici le JSON copié par Controller Studio. / Paste the JSON copied by Controller Studio here. +Controller Studio préremplit normalement cette zone. Pour un très grand profil, collez ici le JSON placé dans le presse-papiers. / Controller Studio normally pre-fills this section. For a very large profile, paste the JSON placed on the clipboard here. ```json diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bd863..15ffd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Historique des versions +## V.2026 (2026.4) — 2026-08-10 + +- Ajoute un véritable fabricant de contrôleurs dans la Banque : encodeurs absolus ou relatifs, faders, boutons, ordre physique, banques et pages. +- Ajoute l'apprentissage direct des mouvements et appuis depuis l'entrée MIDI choisie, avec prise en charge CC, Note, NRPN et Pitch Bend. +- Ajoute sur la page Live un accès universel à la configuration et à l'apprentissage MIDI du contrôleur actif ; le setup/groupe reste clairement identifié comme spécifique à l'EC4. +- Affiche le numéro complet de la release dans le bandeau, le titre de fenêtre et la fenêtre À propos, par exemple `V.2026.4`. +- Permet de créer, modifier, dupliquer ou importer un profil sans manipuler de JSON, puis de l'enregistrer localement avec sauvegarde atomique. +- Relie directement le profil créé à l'export LiveProfessor `.ctrl2` et à AutoMap. +- Préremplit la contribution GitHub avec le profil validé complet ; GitHub ne sert plus qu'à identifier l'auteur et confirmer l'envoi, sans jeton stocké dans l'application. +- Restaure dans Réglages la fenêtre de réactivité d'EC4 Bridge : cadence et durée d'Overlay, rafraîchissement Companion/labels, timeout du retour et affichage persistant, pour tous les contrôleurs compatibles. +- Étend la banque intégrée à douze profils avec le Korg nanoKONTROL2 en mode CC et l'Akai LPD8 MK2 Program 1 documenté. +- Agrandit les polices des notices française et anglaise pour une lecture plus confortable. + ## V.2026 (2026.3) — 2026-08-10 - Lit directement les vrais noms, unités et types des paramètres exposés par les plug-ins VST3 installés, dans un processus isolé par plug-in. diff --git a/README.md b/README.md index 914637c..ce0560c 100644 --- a/README.md +++ b/README.md @@ -10,25 +10,25 @@

- Version V.2026.3 + Version V.2026.4 Windows 10 et 11 x64 Français et anglais

## Dernière version (accès rapide) -**Version stable : [Controller Studio V.2026.3](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.3)**
+**Version stable : [Controller Studio V.2026.4](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.4)**
Téléchargement direct : -- [Installer Controller Studio pour Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor-Setup-v2026.3.exe) -- [Version portable Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor.exe) -- [Notice complète en français (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor-Notice-FR.pdf) -- [Full English manual (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor-Manual-EN.pdf) -- [Sommes de contrôle SHA-256](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/SHA256SUMS.txt) +- [Installer Controller Studio pour Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor-Setup-v2026.4.exe) +- [Version portable Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor.exe) +- [Notice complète en français (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor-Notice-FR.pdf) +- [Full English manual (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor-Manual-EN.pdf) +- [Sommes de contrôle SHA-256](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/SHA256SUMS.txt) [Lire cette présentation en anglais](README_EN.md) -> **Version publique V.2026.3 pour Windows.** Controller Studio travaille sur une copie AutoMap et conserve le projet `.rack2` source intact. +> **Version publique V.2026.4 pour Windows.** Controller Studio travaille sur une copie AutoMap et conserve le projet `.rack2` source intact. ## À quoi sert Controller Studio ? @@ -68,10 +68,12 @@ VST est une marque déposée de Steinberg Media Technologies GmbH. Les mentions ## Banque de contrôleurs intégrée -V.2026.3 fournit dix profils déclaratifs prêts à exporter : +V.2026.4 fournit douze profils déclaratifs prêts à exporter et permet d’en fabriquer d’autres directement dans l’application : +- Akai Professional LPD8 MK2 (Program 1 documenté) ; - Faderfox EC4, PC4, UC4 et PC12 ; - Behringer X-Touch Compact, X-Touch Mini et X-Touch One ; +- Korg nanoKONTROL2 (mode CC, réglages d’usine des potentiomètres et faders) ; - Novation Launch Control XL 3 ; - DJ TechTools MIDI Fighter Twister ; - contrôleur MIDI générique à 16 commandes. @@ -80,15 +82,21 @@ Les modes matériels et les sources constructeur utilisés sont regroupés dans ## Créer et partager un contrôleur -L’éditeur intégré permet de décrire les encodeurs, faders, boutons, canaux MIDI, messages CC/Note et retours disponibles, puis de vérifier le profil avant export. +Cliquez sur **Créer un contrôleur…** pour partir d’un modèle de huit encodeurs, puis ajoutez, supprimez ou réordonnez encodeurs absolus ou relatifs, faders et boutons. Chaque commande accepte CC, Note, NRPN ou Pitch Bend, son canal et son numéro. **Apprendre le mouvement** et **Apprendre l’appui** capturent directement les messages reçus sur l’entrée MIDI choisie. Vous pouvez aussi **Modifier / dupliquer…** un modèle existant ou **Importer un profil…**. + +**Enregistrer dans ma banque** valide le profil et le rend disponible hors ligne. **Enregistrer + créer .ctrl2** produit aussitôt le fichier LiveProfessor. Le remplacement d’un profil personnel conserve une sauvegarde de la version précédente. La page Live donne aussi accès à **Configurer / apprentissage MIDI…** pour le contrôleur actif ; le setup/groupe visible avec l’EC4 reste une fonction spécifique à ce matériel. + +La fenêtre **Réglages** de la page Live retrouve les paramètres avancés d’EC4 Bridge : cadence de l’Overlay, durée d’affichage, rafraîchissement Companion et des labels, délai de confirmation LiveProfessor et affichage persistant. Ces temporisations sont disponibles pour tous les contrôleurs compatibles ; les outils setup/groupe et SysEx restent affichés uniquement pour l’EC4. Pour proposer un contrôleur à la bibliothèque commune : 1. créez ou sélectionnez le profil dans **Banque de contrôleurs** ; 2. cliquez sur **Proposer à la bibliothèque…** ; -3. Controller Studio valide et prépare le JSON ; -4. le formulaire GitHub prérempli s’ouvre ; -5. joignez la documentation constructeur ou vos résultats d’essai puis envoyez la proposition. +3. Controller Studio valide le profil et place automatiquement tout son contenu dans le formulaire ; +4. GitHub s’ouvre pour identifier l’auteur et demander la confirmation finale ; +5. ajoutez si possible la documentation constructeur ou vos résultats d’essai puis envoyez la proposition. + +Cette dernière confirmation reste volontairement chez GitHub : aucun mot de passe ni jeton d’accès n’est demandé ou conservé par Controller Studio. La bibliothèque publique fait partie de ce dépôt dans [`library/`](library/). Elle contient uniquement des profils JSON déclaratifs, jamais de code téléchargé et exécuté. diff --git a/README_EN.md b/README_EN.md index dbf897d..60a183d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,25 +10,25 @@

- Version V.2026.3 + Version V.2026.4 Windows 10 and 11 x64 French and English

## Latest version (quick access) -**Stable release: [Controller Studio V.2026.3](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.3)**
+**Stable release: [Controller Studio V.2026.4](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.4)**
Direct downloads: -- [Controller Studio installer for Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor-Setup-v2026.3.exe) -- [Portable Windows x64 version (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor.exe) -- [Full English manual (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor-Manual-EN.pdf) -- [Notice complète en français (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/Controller-Studio-for-LiveProfessor-Notice-FR.pdf) -- [SHA-256 checksums](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.3/SHA256SUMS.txt) +- [Controller Studio installer for Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor-Setup-v2026.4.exe) +- [Portable Windows x64 version (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor.exe) +- [Full English manual (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor-Manual-EN.pdf) +- [Notice complète en français (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/Controller-Studio-for-LiveProfessor-Notice-FR.pdf) +- [SHA-256 checksums](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.4/SHA256SUMS.txt) [Lire cette présentation en français](README.md) -> **Public V.2026.3 Windows release.** Controller Studio always works on a new AutoMap copy and keeps the source `.rack2` project untouched. +> **Public V.2026.4 Windows release.** Controller Studio always works on a new AutoMap copy and keeps the source `.rack2` project untouched. ## What does Controller Studio do? @@ -68,10 +68,12 @@ VST is a registered trademark of Steinberg Media Technologies GmbH. Third-party ## Built-in controller bank -V.2026.3 includes ten declarative profiles ready to export: +V.2026.4 includes twelve declarative profiles ready to export and can build more directly in the application: +- Akai Professional LPD8 MK2 (documented Program 1); - Faderfox EC4, PC4, UC4 and PC12; - Behringer X-Touch Compact, X-Touch Mini and X-Touch One; +- Korg nanoKONTROL2 (CC mode, factory knob and fader assignments); - Novation Launch Control XL 3; - DJ TechTools MIDI Fighter Twister; - generic 16-control MIDI controller. @@ -80,15 +82,21 @@ Hardware modes and manufacturer references are collected in the [controller prof ## Build and share a controller -The built-in editor describes encoders, faders, buttons, MIDI channels, CC/Note messages and available feedback, then validates the profile before export. +Click **Create a controller…** to start with eight encoders, then add, remove, or reorder absolute or relative encoders, faders, and buttons. Every control accepts CC, Note, NRPN, or Pitch Bend, its channel, and its number. **Learn movement** and **Learn push** capture messages directly from the chosen MIDI input. You can also **Edit / duplicate…** an existing model or **Import a profile…**. + +**Save to my bank** validates the profile and makes it available offline. **Save + create .ctrl2** immediately produces the LiveProfessor file. Replacing a personal profile keeps a backup of the previous version. The Live page also provides **Configure / MIDI Learn…** for the active controller; the setup/group section shown with the EC4 remains hardware-specific. + +The Live page **Settings** window restores EC4 Bridge’s advanced controls: Overlay update rate and duration, Companion and label refresh delays, LiveProfessor feedback timeout, and persistent display. These timings are available to every compatible controller; setup/group and SysEx tools remain EC4-only. To submit a controller to the shared library: 1. create or select its profile in **Controller bank**; 2. click **Submit to the library…**; -3. Controller Studio validates and prepares the JSON file; -4. a pre-filled GitHub form opens; -5. attach manufacturer documentation or hardware test results and submit. +3. Controller Studio validates the profile and automatically inserts all its content into the form; +4. GitHub opens to identify the author and request final confirmation; +5. add manufacturer documentation or hardware test results when possible, then submit. + +That final confirmation deliberately stays with GitHub: Controller Studio never asks for or stores a password or access token. The public library lives in this repository under [`library/`](library/). It contains declarative JSON profiles only—never downloaded executable code. diff --git a/docs/CONTROLLER_PROFILE_SOURCES.md b/docs/CONTROLLER_PROFILE_SOURCES.md index 66377af..5fe64db 100644 --- a/docs/CONTROLLER_PROFILE_SOURCES.md +++ b/docs/CONTROLLER_PROFILE_SOURCES.md @@ -6,6 +6,7 @@ matérielle complète de Controller Studio. | Profil | Mode requis | Source officielle | | --- | --- | --- | +| Akai Professional LPD8 MK2 | Program 1 configuré avec pads Note 36–43 et potentiomètres CC 36–43, canal 1 | [Guide officiel inMusic / Akai pour le Program 1](https://support.inmusicstore.com/en/support/solutions/articles/69000867977-lpd8-mkii-how-to-use-16-pads-and-knobs-in-mpc-beats) | | Behringer X-Touch Compact | Standard, Layer A, canal MIDI 1 | [Quick Start Guide, MIDI Map Layer A](https://mediadl.musictribe.com/media/PLM/data/docs/P0B3L/QSG_BE_0808-AAE_X-TOUCH%20COMPACT_WW.pdf) | | Behringer X-Touch Mini | Standard, Preset A, canal MIDI 11 | [Quick Start Guide, MIDI Map Preset A](https://mediadl.musictribe.com/media/PLM/data/docs/P0B3M/QSG_BE_0808-AAF_X-TOUCH-MINI_WW.pdf) | | Behringer X-Touch One | MIDI mode, canal MIDI 1 dans ce profil | [MIDI Mode Implementation](https://mediadl.musictribe.com/download/software/behringer/X-TOUCH/Document_BE_X-TOUCH-ONE-MIDI-Mode-Implementation.pdf) | @@ -14,6 +15,7 @@ matérielle complète de Controller Studio. | Faderfox UC4 | Factory Setup 1, groupes encodeurs et faders 1 | [UC4 User Manual V02, Factory settings](https://www.faderfox.de/PDF/UC4%20Manual%20V02.pdf) | | Faderfox PC12 | Factory Setup 1, canal MIDI 1 | [PC12 User Manual V04, Factory settings](https://www.faderfox.de/PDF/PC12%20Manual%20V04.pdf) | | DJ TechTools MIDI Fighter Twister | Factory Banks 1–4, encodeurs canal 1, push/couleurs canal 2 | [MIDI Fighter Twister User Guide 2026, Appendix 1](https://drive.google.com/file/d/0B-QvIds_FsH3Z0ZLT041VnZfOTA/view?usp=sharing) | +| Korg nanoKONTROL2 | Mode CC, canal global 1, potentiomètres CC 16–23 et faders CC 0–7 | [Téléchargements officiels : Parameter Guide et MIDI Implementation](https://www.korg.com/tmp/support/download/index.php/product/0/159/?country=us&page=product&prodid=159&prodtype=0) | Le X-Touch Compact disponible physiquement est le premier candidat à passer de `community` à `verified`. Cette promotion exige la capture des messages réels, diff --git a/library/controllers/akai/lpd8-mk2-program-1-mpc/1.0.0/profile.json b/library/controllers/akai/lpd8-mk2-program-1-mpc/1.0.0/profile.json new file mode 100644 index 0000000..d1aa9e3 --- /dev/null +++ b/library/controllers/akai/lpd8-mk2-program-1-mpc/1.0.0/profile.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "profile_version": "1.0.0", + "id": "akai.lpd8-mk2.program-1-mpc", + "manufacturer": "Akai Professional", + "model": "LPD8 MK2 — Program 1 (MPC layout)", + "midi_identity": { + "input_name_patterns": ["LPD8 mk2", "LPD8 MK2"], + "output_name_patterns": ["LPD8 mk2", "LPD8 MK2"] + }, + "bank_size": 16, + "status": "community", + "capabilities": ["push"], + "controls": [ + {"id": "knob_01", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 36}}, + {"id": "knob_02", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 37}}, + {"id": "knob_03", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 38}}, + {"id": "knob_04", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 39}}, + {"id": "knob_05", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 40}}, + {"id": "knob_06", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 41}}, + {"id": "knob_07", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 42}}, + {"id": "knob_08", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 43}}, + {"id": "pad_01", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 36}}, + {"id": "pad_02", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 37}}, + {"id": "pad_03", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 38}}, + {"id": "pad_04", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 39}}, + {"id": "pad_05", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 40}}, + {"id": "pad_06", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 41}}, + {"id": "pad_07", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 42}}, + {"id": "pad_08", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 43}} + ] +} diff --git a/library/controllers/korg/nanokontrol2-cc-factory/1.0.0/profile.json b/library/controllers/korg/nanokontrol2-cc-factory/1.0.0/profile.json new file mode 100644 index 0000000..5b3b9ad --- /dev/null +++ b/library/controllers/korg/nanokontrol2-cc-factory/1.0.0/profile.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "profile_version": "1.0.0", + "id": "korg.nanokontrol2.cc-factory", + "manufacturer": "Korg", + "model": "nanoKONTROL2 — CC Mode (factory knobs + faders)", + "midi_identity": { + "input_name_patterns": ["nanoKONTROL2"], + "output_name_patterns": ["nanoKONTROL2"] + }, + "bank_size": 16, + "status": "community", + "capabilities": [], + "controls": [ + {"id": "knob_01", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 16}}, + {"id": "knob_02", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 17}}, + {"id": "knob_03", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 18}}, + {"id": "knob_04", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 19}}, + {"id": "knob_05", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 20}}, + {"id": "knob_06", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 21}}, + {"id": "knob_07", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 22}}, + {"id": "knob_08", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 23}}, + {"id": "fader_01", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 0}}, + {"id": "fader_02", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 1}}, + {"id": "fader_03", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 2}}, + {"id": "fader_04", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 3}}, + {"id": "fader_05", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 4}}, + {"id": "fader_06", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 5}}, + {"id": "fader_07", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 6}}, + {"id": "fader_08", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 7}, "roles": ["output"]} + ] +} diff --git a/library/manifest-v1.json b/library/manifest-v1.json index 92fdf61..f469322 100644 --- a/library/manifest-v1.json +++ b/library/manifest-v1.json @@ -1,7 +1,15 @@ { "manifest_version": 1, - "generated_at": "2026-08-10T01:49:57Z", + "generated_at": "2026-08-10T09:57:14Z", "profiles": [ + { + "id": "akai.lpd8-mk2.program-1-mpc", + "version": "1.0.0", + "status": "community", + "path": "controllers/akai/lpd8-mk2-program-1-mpc/1.0.0/profile.json", + "sha256": "A3231D0D69233A76F626FE4561EAE9315729779D793416ED95A7C4CB3AF2CD87", + "minimum_hub_version": "0.1.0" + }, { "id": "behringer.x-touch-compact.layer-a", "version": "1.0.0", @@ -74,6 +82,14 @@ "sha256": "7C9FD78766FE40164DA7327471DCF945E34FFE19E4DC8682E5DF4A37A9A5AF59", "minimum_hub_version": "0.1.0" }, + { + "id": "korg.nanokontrol2.cc-factory", + "version": "1.0.0", + "status": "community", + "path": "controllers/korg/nanokontrol2-cc-factory/1.0.0/profile.json", + "sha256": "809F2E406E49789623F4E80B333BB6B7D651459BB9BF023DAB5C65BD2188D37D", + "minimum_hub_version": "0.1.0" + }, { "id": "novation.launch-control-xl3.mode-16", "version": "1.0.0", diff --git a/packaging/windows/SiLeMIO-Controller-Studio.iss b/packaging/windows/SiLeMIO-Controller-Studio.iss index fcac2cd..77960fc 100644 --- a/packaging/windows/SiLeMIO-Controller-Studio.iss +++ b/packaging/windows/SiLeMIO-Controller-Studio.iss @@ -1,5 +1,5 @@ #define MyAppName "Controller Studio for LiveProfessor" -#define MyAppVersion "2026.3" +#define MyAppVersion "2026.4" #define MyAppPublisher "Mamat" #define MyAppExeName "Controller-Studio-for-LiveProfessor.exe" #define MyShortcutName "Controller Studio for LiveProfessor" @@ -23,7 +23,7 @@ ArchitecturesAllowed=x64compatible PrivilegesRequired=lowest UsedUserAreasWarning=no UninstallDisplayIcon={app}\{#MyAppExeName} -VersionInfoVersion=2026.3.0.0 +VersionInfoVersion=2026.4.0.0 VersionInfoCompany={#MyAppPublisher} VersionInfoDescription={#MyAppName} installer VersionInfoProductName={#MyAppName} diff --git a/packaging/windows/build_setup.ps1 b/packaging/windows/build_setup.ps1 index e1c807d..3b6bc38 100644 --- a/packaging/windows/build_setup.ps1 +++ b/packaging/windows/build_setup.ps1 @@ -7,7 +7,7 @@ $ErrorActionPreference = 'Stop' $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $application = Join-Path $projectRoot 'dist\Controller-Studio-for-LiveProfessor.exe' $setupScript = Join-Path $PSScriptRoot 'SiLeMIO-Controller-Studio.iss' -$setup = Join-Path $projectRoot 'dist\Controller-Studio-for-LiveProfessor-Setup-v2026.3.exe' +$setup = Join-Path $projectRoot 'dist\Controller-Studio-for-LiveProfessor-Setup-v2026.4.exe' $checksum = "$setup.sha256" if ($RebuildApplication -or -not (Test-Path -LiteralPath $application -PathType Leaf)) { diff --git a/packaging/windows/install.ps1 b/packaging/windows/install.ps1 index 958ca30..6e195a6 100644 --- a/packaging/windows/install.ps1 +++ b/packaging/windows/install.ps1 @@ -44,7 +44,7 @@ $uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\SiLeM $hostExe = (Get-Process -Id $PID).Path $null = New-Item -Path $uninstallKey -Force Set-ItemProperty -Path $uninstallKey -Name DisplayName -Value 'Controller Studio for LiveProfessor' -Set-ItemProperty -Path $uninstallKey -Name DisplayVersion -Value '2026.3' +Set-ItemProperty -Path $uninstallKey -Name DisplayVersion -Value '2026.4' Set-ItemProperty -Path $uninstallKey -Name Publisher -Value 'Mamat' Set-ItemProperty -Path $uninstallKey -Name InstallLocation -Value $InstallRoot Set-ItemProperty -Path $uninstallKey -Name DisplayIcon -Value $targetExe diff --git a/pyproject.toml b/pyproject.toml index 85ef327..c9115e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "silemio-control-hub" -version = "2026.3" +version = "2026.4" description = "Controller Studio for LiveProfessor by SiLeMI/O" requires-python = ">=3.11" dependencies = [ diff --git a/scripts/generate_manuals.py b/scripts/generate_manuals.py index 5334e5c..6612ae0 100644 --- a/scripts/generate_manuals.py +++ b/scripts/generate_manuals.py @@ -30,7 +30,7 @@ from silemio_control_hub import __version__ # noqa: E402 -DISPLAY_VERSION = "V.2026" +DISPLAY_VERSION = f"V.{__version__}" OUTPUT_DIR = ROOT / "output" / "pdf" @@ -534,7 +534,7 @@ def english_content() -> dict: ( "steps", [ - ("1", "Ouvrez la Banque de contrôleurs et choisissez le profil de votre matériel."), + ("1", "Ouvrez la Banque de contrôleurs et choisissez le profil de votre matériel, ou cliquez sur Créer un contrôleur."), ("2", "Exportez le fichier .ctrl2 si vous souhaitez ajouter ce contrôleur à LiveProfessor."), ("3", "Cliquez sur AutoMap, choisissez un projet .rack2 et lancez l'analyse."), ("4", "Sélectionnez les plug-ins voulus puis créez une nouvelle copie du projet."), @@ -555,10 +555,21 @@ def english_content() -> dict: "p", "Chaque profil décrit les commandes MIDI, les banques, les poussoirs, les retours et les capacités d'un contrôleur. Son état indique s'il est intégré, vérifié, local ou proposé par la communauté.", ), + ("h2", "Fabriquer un contrôleur absent de la banque"), + ( + "steps", + [ + ("1", "Cliquez sur Créer un contrôleur, renseignez le fabricant et le modèle, puis générez son identifiant."), + ("2", "Ajoutez, supprimez et réordonnez encodeurs absolus ou relatifs, faders et boutons."), + ("3", "Renseignez CC, Note, NRPN ou Pitch Bend, le canal et le numéro, ou utilisez Apprendre le mouvement et Apprendre l'appui sur l'entrée MIDI choisie."), + ("4", "Enregistrez le profil dans votre banque, ou choisissez Enregistrer + créer .ctrl2."), + ], + ), + ("p", "Modifier / dupliquer crée une copie personnelle d'un profil intégré sans toucher à l'original. Importer un profil ajoute un JSON validé. Chaque remplacement personnel conserve une sauvegarde."), ("h2", "Contrôleur actif"), ( "p", - "Le sélecteur de la page Live reste synchronisé avec la banque. Lorsqu'un pilote temps réel est disponible, les boutons Démarrer, Arrêter, les banques et les réglages adaptés deviennent accessibles. Sinon, le profil reste immédiatement utilisable pour l'export et AutoMap.", + "Le sélecteur de la page Live reste synchronisé avec la banque. Configurer / apprentissage MIDI ouvre l'éditeur pour le contrôleur actif. Lorsqu'un pilote temps réel est disponible, les boutons Démarrer, Arrêter, les banques et les réglages adaptés deviennent accessibles. Le setup et le groupe affichés avec l'EC4 sont propres à ce matériel. Sinon, le profil reste immédiatement utilisable pour l'export et AutoMap.", ), ("h2", "Exporter vers LiveProfessor"), ( @@ -629,7 +640,7 @@ def english_content() -> dict: ("h2", "Contribuer"), ( "p", - "Dans la Banque de contrôleurs, Proposer à la bibliothèque valide le profil sélectionné, copie son JSON et ouvre le formulaire GitHub déjà titré. Collez le JSON, ajoutez la documentation ou les essais disponibles, puis envoyez.", + "Dans la Banque de contrôleurs, Proposer à la bibliothèque valide le profil et place automatiquement son contenu complet dans le formulaire. GitHub s'ouvre uniquement pour identifier l'auteur, ajouter si possible la documentation ou les essais matériels, puis confirmer l'envoi. Controller Studio ne demande et ne conserve aucun jeton d'accès.", ), ( "bullets", @@ -650,6 +661,11 @@ def english_content() -> dict: "p", "La page Live garde uniquement le contrôleur actif, les commandes essentielles, l'état et le dernier événement. Les connexions MIDI/OSC sont dans Réglages et le journal complet s'ouvre dans une fenêtre séparée.", ), + ("h2", "Réactivité, feedback et Overlay"), + ( + "p", + "Réglages retrouve la petite fenêtre avancée d'EC4 Bridge : cadence et durée de l'Overlay, rafraîchissement Companion et des labels, délai maximal du retour LiveProfessor et affichage persistant. Ces temporisations sont communes aux contrôleurs compatibles ; les commandes setup/groupe et SysEx restent propres à l'EC4.", + ), ("h2", "Langue et zone de notification"), ( "p", @@ -716,7 +732,7 @@ def english_content() -> dict: "1. Quick start", [ ("h2", "The shortest workflow"), - ("steps", [("1", "Open the Controller bank and choose the profile for your hardware."), ("2", "Export the .ctrl2 file when you want to add that controller to LiveProfessor."), ("3", "Click AutoMap, choose a .rack2 project, and analyze it."), ("4", "Select the required plug-ins and create a new project copy."), ("5", "Load the .ctrl2 file and the AutoMap copy in LiveProfessor.")]), + ("steps", [("1", "Open the Controller bank and choose the profile for your hardware, or click Create a controller."), ("2", "Export the .ctrl2 file when you want to add that controller to LiveProfessor."), ("3", "Click AutoMap, choose a .rack2 project, and analyze it."), ("4", "Select the required plug-ins and create a new project copy."), ("5", "Load the .ctrl2 file and the AutoMap copy in LiveProfessor.")]), ("callout", "Always preserve the original LiveProfessor project. Work only with the copy created by AutoMap."), ], ), @@ -725,8 +741,11 @@ def english_content() -> dict: [ ("h2", "Controller bank"), ("p", "Each profile describes MIDI controls, banks, pushes, feedback, and capabilities. Its status identifies it as built-in, verified, local, or community supplied."), + ("h2", "Build a controller missing from the bank"), + ("steps", [("1", "Click Create a controller, enter its manufacturer and model, then generate its identifier."), ("2", "Add, remove, and reorder absolute or relative encoders, faders, and buttons."), ("3", "Enter CC, Note, NRPN, or Pitch Bend, channel, and number, or use Learn movement and Learn push on the chosen MIDI input."), ("4", "Save the profile to your bank, or choose Save + create .ctrl2.")]), + ("p", "Edit / duplicate creates a personal copy of a built-in profile without changing the original. Import a profile installs validated JSON. Every personal replacement keeps a backup."), ("h2", "Active controller"), - ("p", "The Live page selector stays synchronized with the bank. When a real-time driver is available, Start, Stop, bank, and matching settings become available. Otherwise, the profile remains ready for export and AutoMap."), + ("p", "The Live page selector stays synchronized with the bank. Configure / MIDI Learn opens the editor for the active controller. When a real-time driver is available, Start, Stop, bank, and matching settings become available. The setup and group shown with the EC4 are specific to that hardware. Otherwise, the profile remains ready for export and AutoMap."), ("h2", "Export to LiveProfessor"), ("steps", [("1", "Select the controller in the bank."), ("2", "Click Export controller .ctrl2."), ("3", "In LiveProfessor, open Hardware Controllers Setup, then Load from file.")]), ], @@ -757,7 +776,7 @@ def english_content() -> dict: ("h2", "Update"), ("p", "The public library provides controller and plug-in profiles. Preview changes before installing them, then keep working offline from the local cache."), ("h2", "Contribute"), - ("p", "In Controller bank, Submit to the library validates the selected profile, copies its JSON, and opens a pre-titled GitHub form. Paste the JSON, add available documentation or test results, then submit it."), + ("p", "In Controller bank, Submit to the library validates the profile and automatically inserts its full content into the form. GitHub opens only to identify the author, add hardware documentation or test details when possible, and confirm the submission. Controller Studio never asks for or stores an access token."), ("bullets", ["strict format validation;", "version and SHA-256 checks;", "backup before replacement;", "no executable code in profiles."]), ], ), @@ -766,6 +785,8 @@ def english_content() -> dict: [ ("h2", "A simple first page"), ("p", "The Live page keeps only the active controller, essential actions, status, and last event. MIDI/OSC connections are under Settings, and the complete log opens in a separate window."), + ("h2", "Responsiveness, feedback, and Overlay"), + ("p", "Settings restores EC4 Bridge's compact advanced window: Overlay update rate and duration, Companion and label refresh delays, LiveProfessor feedback timeout, and persistent display. These timings are shared by compatible controllers; setup/group and SysEx commands remain EC4-only."), ("h2", "Language and notification area"), ("p", "Options > Language switches immediately between French and English. Minimizing to the notification area hides the window without stopping an active engine; Quit actually stops the application."), ("h2", "Manual, updates, and support"), @@ -794,13 +815,13 @@ def styles(): "manual": ParagraphStyle("Manual", parent=base["Normal"], fontName="Helvetica-Bold", fontSize=10, leading=12, textColor=BLUE, alignment=TA_CENTER, tracking=1.2), "subtitle": ParagraphStyle("Subtitle", parent=base["Normal"], fontName="Helvetica", fontSize=12, leading=17, textColor=MID, alignment=TA_CENTER), "h1": ParagraphStyle("H1", parent=base["Heading1"], fontName="Helvetica-Bold", fontSize=18, leading=22, textColor=NAVY, spaceAfter=5 * mm), - "h2": ParagraphStyle("H2", parent=base["Heading2"], fontName="Helvetica-Bold", fontSize=11.5, leading=15, textColor=BLUE, spaceBefore=3 * mm, spaceAfter=2 * mm), - "body": ParagraphStyle("Body", parent=base["BodyText"], fontName="Helvetica", fontSize=9.3, leading=13.2, textColor=NAVY, alignment=TA_LEFT, spaceAfter=2.5 * mm), - "small": ParagraphStyle("Small", parent=base["BodyText"], fontName="Helvetica", fontSize=8, leading=11, textColor=MID, alignment=TA_CENTER), - "toc": ParagraphStyle("Toc", parent=base["BodyText"], fontName="Helvetica", fontSize=10, leading=15, leftIndent=5 * mm, textColor=NAVY), - "bullet": ParagraphStyle("Bullet", parent=base["BodyText"], fontName="Helvetica", fontSize=9.2, leading=13, leftIndent=6 * mm, firstLineIndent=-3 * mm, textColor=NAVY, spaceAfter=1.5 * mm), - "callout": ParagraphStyle("Callout", parent=base["BodyText"], fontName="Helvetica-Bold", fontSize=9.2, leading=13, textColor=NAVY, leftIndent=3 * mm, rightIndent=3 * mm), - "table_header": ParagraphStyle("TableHeader", parent=base["BodyText"], fontName="Helvetica-Bold", fontSize=9.3, leading=13.2, textColor=colors.white, alignment=TA_LEFT), + "h2": ParagraphStyle("H2", parent=base["Heading2"], fontName="Helvetica-Bold", fontSize=12.2, leading=16, textColor=BLUE, spaceBefore=3 * mm, spaceAfter=2 * mm), + "body": ParagraphStyle("Body", parent=base["BodyText"], fontName="Helvetica", fontSize=10.2, leading=14.5, textColor=NAVY, alignment=TA_LEFT, spaceAfter=2.7 * mm), + "small": ParagraphStyle("Small", parent=base["BodyText"], fontName="Helvetica", fontSize=8.8, leading=12, textColor=MID, alignment=TA_CENTER), + "toc": ParagraphStyle("Toc", parent=base["BodyText"], fontName="Helvetica", fontSize=10.7, leading=16, leftIndent=5 * mm, textColor=NAVY), + "bullet": ParagraphStyle("Bullet", parent=base["BodyText"], fontName="Helvetica", fontSize=10.1, leading=14.3, leftIndent=6 * mm, firstLineIndent=-3 * mm, textColor=NAVY, spaceAfter=1.5 * mm), + "callout": ParagraphStyle("Callout", parent=base["BodyText"], fontName="Helvetica-Bold", fontSize=10.1, leading=14.3, textColor=NAVY, leftIndent=3 * mm, rightIndent=3 * mm), + "table_header": ParagraphStyle("TableHeader", parent=base["BodyText"], fontName="Helvetica-Bold", fontSize=10.1, leading=14.3, textColor=colors.white, alignment=TA_LEFT), } diff --git a/src/silemio_control_hub/__init__.py b/src/silemio_control_hub/__init__.py index 32b299f..178fe34 100644 --- a/src/silemio_control_hub/__init__.py +++ b/src/silemio_control_hub/__init__.py @@ -48,4 +48,4 @@ "prepare_liveprofessor_project", ] -__version__ = "2026.3" +__version__ = "2026.4" diff --git a/src/silemio_control_hub/controller_contribution.py b/src/silemio_control_hub/controller_contribution.py index c6070a3..16f3500 100644 --- a/src/silemio_control_hub/controller_contribution.py +++ b/src/silemio_control_hub/controller_contribution.py @@ -41,17 +41,54 @@ def controller_submission_url( profile: ControllerProfile, *, repository: str = DEFAULT_CONTRIBUTION_REPOSITORY, + payload: str | None = None, ) -> str: - """Build the public contribution form URL without embedding profile data.""" + """Build a pre-filled public contribution form URL. + + GitHub still performs the final authenticated confirmation in the browser; + the application never embeds or collects a user access token. + """ if repository.count("/") != 1 or any(character in repository for character in "\r\n?#"): raise ValueError("repository doit respecter la forme propriétaire/dépôt") title = f"Controller profile: {profile.manufacturer} {profile.model}" - query = urlencode( - { - "template": CONTROLLER_ISSUE_TEMPLATE, - "title": title, - }, - quote_via=quote, - ) + query_fields = { + "template": CONTROLLER_ISSUE_TEMPLATE, + "title": title, + } + if payload is not None: + compact_payload = json.dumps( + json.loads(payload), ensure_ascii=False, separators=(",", ":") + ) + query_fields["body"] = controller_submission_body(profile, compact_payload) + query = urlencode(query_fields, quote_via=quote) return f"https://github.com/{repository}/issues/new?{query}" + + +def controller_submission_body(profile: ControllerProfile, payload: str) -> str: + """Return a complete bilingual issue body with no personal machine data.""" + + return f"""## Contrôleur / Controller + +{profile.manufacturer} {profile.model} + +## Profil JSON / JSON profile + +```json +{payload.strip()} +``` + +## Source MIDI et test matériel / MIDI source and hardware test + +- Documentation: +- Firmware: {profile.firmware or '—'} +- Testé sur le matériel / Tested on hardware: oui / yes — non / no +- Entrée MIDI / MIDI input: +- Retour MIDI / MIDI feedback: +- Remarques / Notes: + +## Validation + +- [ ] Le profil ne contient aucun code exécutable. / The profile contains no executable code. +- [ ] J’autorise sa publication sous la licence du dépôt. / I agree to its publication under the repository license. +""" diff --git a/src/silemio_control_hub/controller_editor.py b/src/silemio_control_hub/controller_editor.py new file mode 100644 index 0000000..07a6b5e --- /dev/null +++ b/src/silemio_control_hub/controller_editor.py @@ -0,0 +1,638 @@ +"""Tk controller creator used by Controller Studio's controller bank.""" + +from __future__ import annotations + +import json +from pathlib import Path +import queue +import tkinter as tk +from tkinter import filedialog, messagebox, ttk +from typing import Any, Callable + +from .adapters.hosts import export_liveprofessor_controller +from .controller_studio import ( + controller_profile_payload, + midi_binding_from_message, + save_user_controller_profile, + suggest_controller_profile_id, + validate_controller_draft, +) +from .models import ProfileError +from .registry import default_user_profile_dir +from .transports.midi import MidiInputListener, input_names + + +Translator = Callable[..., str] +SavedCallback = Callable[[str], None] + + +def _split_patterns(value: str) -> list[str]: + return [item.strip() for item in value.split(";") if item.strip()] + + +class ControllerEditorDialog: + """Create or edit a declarative controller without exposing JSON to users.""" + + def __init__( + self, + parent: tk.Misc, + *, + translator: Translator, + payload: dict[str, Any], + on_saved: SavedCallback, + on_contribute: SavedCallback, + icon_path: Path | None = None, + ) -> None: + self.parent = parent + self._t = translator + self.payload = json.loads(json.dumps(payload, ensure_ascii=False)) + self.controls: list[dict[str, Any]] = self.payload.setdefault("controls", []) + self.on_saved = on_saved + self.on_contribute = on_contribute + self.listener: MidiInputListener | None = None + self.learn_queue: queue.SimpleQueue[Any] = queue.SimpleQueue() + self.learn_target: str | None = None + self.learn_after_id: str | None = None + self.selected_index: int | None = None + + self.window = tk.Toplevel(parent) + self.window.title(self._t("controller_editor_title")) + self.window.geometry("1120x780") + self.window.minsize(940, 680) + self.window.transient(parent) + self.window.protocol("WM_DELETE_WINDOW", self.close) + if icon_path and icon_path.is_file(): + try: + self.window.iconbitmap(default=str(icon_path)) + except tk.TclError: + pass + + self.manufacturer_var = tk.StringVar(value=str(self.payload.get("manufacturer", ""))) + self.model_var = tk.StringVar(value=str(self.payload.get("model", ""))) + self.profile_id_var = tk.StringVar(value=str(self.payload.get("id", ""))) + self.firmware_var = tk.StringVar(value=str(self.payload.get("firmware", ""))) + identity = self.payload.get("midi_identity") or {} + self.input_pattern_var = tk.StringVar( + value="; ".join(identity.get("input_name_patterns", [])) + ) + self.output_pattern_var = tk.StringVar( + value="; ".join(identity.get("output_name_patterns", [])) + ) + self.bank_size_var = tk.StringVar(value=str(self.payload.get("bank_size", 1))) + self.bank_count_var = tk.StringVar(value=str(self.payload.get("bank_count", 1))) + self.page_count_var = tk.StringVar(value=str(self.payload.get("page_count", 1))) + self.midi_port_var = tk.StringVar() + self.learn_status_var = tk.StringVar(value=self._t("controller_learn_idle")) + + self.control_id_var = tk.StringVar() + self.kind_var = tk.StringVar() + self.message_var = tk.StringVar() + self.channel_var = tk.StringVar(value="1") + self.number_var = tk.StringVar(value="0") + self.relative_mode_var = tk.StringVar(value="twos_complement") + self.push_enabled_var = tk.BooleanVar(value=False) + self.push_message_var = tk.StringVar() + self.push_channel_var = tk.StringVar(value="1") + self.push_number_var = tk.StringVar(value="0") + + self.kind_labels = { + self._t("controller_kind_absolute"): "absolute_encoder", + self._t("controller_kind_relative"): "relative_encoder", + self._t("controller_kind_fader"): "fader", + self._t("controller_kind_button"): "button", + self._t("controller_kind_pad"): "pad", + } + self.kind_names = {value: label for label, value in self.kind_labels.items()} + self.message_labels = { + "CC": "cc", + self._t("controller_message_note"): "note", + "NRPN": "nrpn", + self._t("controller_message_pitch"): "pitch_bend", + } + self.message_names = {value: label for label, value in self.message_labels.items()} + self._build() + self._refresh_midi_ports() + self._refresh_controls(select=0 if self.controls else None) + + def _build(self) -> None: + root = ttk.Frame(self.window, padding=12) + root.pack(fill="both", expand=True) + root.columnconfigure(0, weight=1) + root.rowconfigure(2, weight=1) + + intro = ttk.Label( + root, + text=self._t("controller_editor_intro"), + justify="left", + wraplength=1040, + ) + intro.grid(row=0, column=0, sticky="ew", pady=(0, 8)) + + identity = ttk.LabelFrame(root, text=self._t("controller_identity"), padding=8) + identity.grid(row=1, column=0, sticky="ew", pady=(0, 8)) + for column in (1, 3, 5): + identity.columnconfigure(column, weight=1) + fields = ( + ("maker", self.manufacturer_var, 0, 0), + ("model", self.model_var, 0, 2), + ("controller_profile_id", self.profile_id_var, 0, 4), + ("controller_input_pattern", self.input_pattern_var, 1, 0), + ("controller_output_pattern", self.output_pattern_var, 1, 2), + ("controller_firmware", self.firmware_var, 1, 4), + ) + for key, variable, row, column in fields: + ttk.Label(identity, text=self._t(key)).grid( + row=row, column=column, sticky="w", padx=(0, 5), pady=3 + ) + ttk.Entry(identity, textvariable=variable).grid( + row=row, column=column + 1, sticky="ew", padx=(0, 12), pady=3 + ) + ttk.Label(identity, text=self._t("controller_bank_size")).grid( + row=2, column=0, sticky="w", pady=3 + ) + ttk.Spinbox(identity, from_=1, to=99, textvariable=self.bank_size_var, width=7).grid( + row=2, column=1, sticky="w", pady=3 + ) + ttk.Label(identity, text=self._t("controller_bank_count")).grid( + row=2, column=2, sticky="w", pady=3 + ) + ttk.Spinbox(identity, from_=1, to=99, textvariable=self.bank_count_var, width=7).grid( + row=2, column=3, sticky="w", pady=3 + ) + ttk.Label(identity, text=self._t("controller_page_count")).grid( + row=2, column=4, sticky="w", pady=3 + ) + ttk.Spinbox(identity, from_=1, to=99, textvariable=self.page_count_var, width=7).grid( + row=2, column=5, sticky="w", pady=3 + ) + ttk.Button( + identity, + text=self._t("controller_generate_id"), + command=self._generate_id, + ).grid(row=2, column=6, sticky="e", padx=(8, 0)) + + center = ttk.Panedwindow(root, orient="vertical") + center.grid(row=2, column=0, sticky="nsew") + table_frame = ttk.Frame(center) + editor_frame = ttk.LabelFrame(center, text=self._t("controller_selected_control"), padding=8) + center.add(table_frame, weight=3) + center.add(editor_frame, weight=2) + + columns = ("number", "id", "kind", "message", "channel", "value", "push") + self.tree = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="browse") + headings = { + "number": "N°", + "id": self._t("controller_control_id"), + "kind": self._t("controller_control_kind"), + "message": self._t("controller_message"), + "channel": self._t("controller_channel"), + "value": self._t("controller_number"), + "push": self._t("controller_push"), + } + widths = {"number": 50, "id": 180, "kind": 180, "message": 100, "channel": 80, "value": 80, "push": 180} + for column in columns: + self.tree.heading(column, text=headings[column]) + self.tree.column(column, width=widths[column], anchor="w") + scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=scrollbar.set) + self.tree.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + self.tree.bind("<>", self._load_selected) + + add = ttk.Frame(root) + add.grid(row=3, column=0, sticky="ew", pady=(7, 7)) + for key, kind in ( + ("controller_add_encoder", "absolute_encoder"), + ("controller_add_relative", "relative_encoder"), + ("controller_add_fader", "fader"), + ("controller_add_button", "button"), + ): + ttk.Button(add, text=self._t(key), command=lambda value=kind: self._add_control(value)).pack( + side="left", padx=(0, 6) + ) + ttk.Button(add, text=self._t("controller_delete_control"), command=self._delete_control).pack(side="left", padx=(8, 6)) + ttk.Button(add, text="↑", width=3, command=lambda: self._move_control(-1)).pack(side="left") + ttk.Button(add, text="↓", width=3, command=lambda: self._move_control(1)).pack(side="left", padx=(3, 0)) + + for column in (1, 3, 5): + editor_frame.columnconfigure(column, weight=1) + ttk.Label(editor_frame, text=self._t("controller_control_id")).grid(row=0, column=0, sticky="w", pady=3) + ttk.Entry(editor_frame, textvariable=self.control_id_var).grid(row=0, column=1, sticky="ew", padx=(5, 12), pady=3) + ttk.Label(editor_frame, text=self._t("controller_control_kind")).grid(row=0, column=2, sticky="w", pady=3) + ttk.Combobox(editor_frame, textvariable=self.kind_var, values=tuple(self.kind_labels), state="readonly").grid(row=0, column=3, sticky="ew", padx=(5, 12), pady=3) + ttk.Label(editor_frame, text=self._t("controller_relative_mode")).grid(row=0, column=4, sticky="w", pady=3) + ttk.Combobox( + editor_frame, + textvariable=self.relative_mode_var, + values=("twos_complement", "binary_offset", "signed_bit", "increment_decrement"), + state="readonly", + ).grid(row=0, column=5, sticky="ew", padx=(5, 0), pady=3) + + ttk.Label(editor_frame, text=self._t("controller_input_message")).grid(row=1, column=0, sticky="w", pady=3) + ttk.Combobox(editor_frame, textvariable=self.message_var, values=tuple(self.message_labels), state="readonly").grid(row=1, column=1, sticky="ew", padx=(5, 12), pady=3) + ttk.Label(editor_frame, text=self._t("controller_channel")).grid(row=1, column=2, sticky="w", pady=3) + ttk.Spinbox(editor_frame, from_=1, to=16, textvariable=self.channel_var, width=7).grid(row=1, column=3, sticky="w", padx=(5, 12), pady=3) + ttk.Label(editor_frame, text=self._t("controller_number")).grid(row=1, column=4, sticky="w", pady=3) + ttk.Spinbox(editor_frame, from_=0, to=16383, textvariable=self.number_var, width=9).grid(row=1, column=5, sticky="w", padx=(5, 0), pady=3) + + ttk.Checkbutton(editor_frame, text=self._t("controller_push_enable"), variable=self.push_enabled_var).grid(row=2, column=0, sticky="w", pady=3) + ttk.Combobox(editor_frame, textvariable=self.push_message_var, values=tuple(self.message_labels), state="readonly").grid(row=2, column=1, sticky="ew", padx=(5, 12), pady=3) + ttk.Label(editor_frame, text=self._t("controller_channel")).grid(row=2, column=2, sticky="w", pady=3) + ttk.Spinbox(editor_frame, from_=1, to=16, textvariable=self.push_channel_var, width=7).grid(row=2, column=3, sticky="w", padx=(5, 12), pady=3) + ttk.Label(editor_frame, text=self._t("controller_number")).grid(row=2, column=4, sticky="w", pady=3) + ttk.Spinbox(editor_frame, from_=0, to=16383, textvariable=self.push_number_var, width=9).grid(row=2, column=5, sticky="w", padx=(5, 0), pady=3) + + learn = ttk.Frame(editor_frame) + learn.grid(row=3, column=0, columnspan=6, sticky="ew", pady=(7, 0)) + learn.columnconfigure(1, weight=1) + ttk.Label(learn, text=self._t("controller_midi_port")).grid(row=0, column=0, sticky="w") + self.midi_combo = ttk.Combobox(learn, textvariable=self.midi_port_var, state="normal") + self.midi_combo.grid(row=0, column=1, sticky="ew", padx=6) + ttk.Button(learn, text=self._t("refresh"), command=self._refresh_midi_ports).grid(row=0, column=2) + ttk.Button(learn, text=self._t("controller_learn_input"), command=lambda: self._start_learn("input")).grid(row=0, column=3, padx=(8, 4)) + ttk.Button(learn, text=self._t("controller_learn_push"), command=lambda: self._start_learn("push")).grid(row=0, column=4) + ttk.Label(learn, textvariable=self.learn_status_var, foreground="#087d9d").grid(row=1, column=0, columnspan=5, sticky="w", pady=(5, 0)) + ttk.Button(editor_frame, text=self._t("controller_apply_control"), command=self._apply_selected).grid(row=4, column=5, sticky="e", pady=(7, 0)) + + footer = ttk.Frame(root) + footer.grid(row=4, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(footer, text=self._t("controller_close"), command=self.close).pack(side="right") + ttk.Button(footer, text=self._t("controller_submit"), command=self._submit).pack(side="right", padx=(0, 6)) + ttk.Button(footer, text=self._t("controller_save_export"), command=self._save_and_export).pack(side="right", padx=(0, 6)) + tk.Button( + footer, + text=self._t("controller_save_local"), + command=self._save, + bg="#087d9d", + fg="#ffffff", + activebackground="#0aa1c9", + activeforeground="#ffffff", + relief="flat", + borderwidth=0, + padx=14, + pady=5, + font=("Segoe UI Semibold", 10), + cursor="hand2", + ).pack(side="right", padx=(0, 6)) + + def _generate_id(self) -> None: + self.profile_id_var.set( + suggest_controller_profile_id(self.manufacturer_var.get(), self.model_var.get()) + ) + + def _refresh_midi_ports(self) -> None: + try: + names = tuple(input_names()) + except Exception: + names = () + self.midi_combo.configure(values=names) + if not self.midi_port_var.get() and names: + self.midi_port_var.set(names[0]) + + def _display_binding(self, binding: dict[str, Any] | None) -> str: + if not binding: + return "—" + message = self.message_names.get(str(binding.get("message")), str(binding.get("message", ""))) + channel = binding.get("channel") + number = binding.get("number") + parts = [message] + if channel is not None: + parts.append(f"ch {channel}") + if number is not None: + parts.append(str(number)) + return " / ".join(parts) + + def _refresh_controls(self, *, select: int | None = None) -> None: + for iid in self.tree.get_children(): + self.tree.delete(iid) + for index, item in enumerate(self.controls): + binding = item.get("input") or {} + self.tree.insert( + "", + "end", + iid=str(index), + values=( + index + 1, + item.get("id", ""), + self.kind_names.get(str(item.get("kind")), str(item.get("kind", ""))), + self.message_names.get(str(binding.get("message")), str(binding.get("message", ""))), + binding.get("channel", "—"), + binding.get("number", "—"), + self._display_binding(item.get("push")), + ), + ) + if select is not None and 0 <= select < len(self.controls): + self.tree.selection_set(str(select)) + self.tree.focus(str(select)) + self.tree.see(str(select)) + self._load_selected() + else: + self.selected_index = None + + def _load_selected(self, _event=None) -> None: + selection = self.tree.selection() + if not selection: + return + index = int(selection[0]) + self.selected_index = index + item = self.controls[index] + binding = item.get("input") or {} + self.control_id_var.set(str(item.get("id", ""))) + self.kind_var.set(self.kind_names.get(str(item.get("kind")), str(item.get("kind", "")))) + self.message_var.set(self.message_names.get(str(binding.get("message")), str(binding.get("message", "")))) + self.channel_var.set(str(binding.get("channel", 1))) + self.number_var.set(str(binding.get("number", 0))) + self.relative_mode_var.set(str(binding.get("mode", "twos_complement"))) + push = item.get("push") + self.push_enabled_var.set(push is not None) + push = push or {"message": "note", "channel": 1, "number": 0} + self.push_message_var.set(self.message_names.get(str(push.get("message")), str(push.get("message", "")))) + self.push_channel_var.set(str(push.get("channel", 1))) + self.push_number_var.set(str(push.get("number", 0))) + + def _binding_from_fields(self, message_label: str, channel: str, number: str) -> dict[str, Any]: + message = self.message_labels.get(message_label, message_label) + binding: dict[str, Any] = {"message": message, "channel": int(channel)} + if message in {"cc", "note", "nrpn"}: + binding["number"] = int(number) + return binding + + def _apply_selected(self, *, quiet: bool = False) -> bool: + if self.selected_index is None: + return True + try: + item = self.controls[self.selected_index] + item["id"] = self.control_id_var.get().strip() + kind = self.kind_labels.get(self.kind_var.get(), self.kind_var.get()) + item["kind"] = kind + binding = self._binding_from_fields( + self.message_var.get(), self.channel_var.get(), self.number_var.get() + ) + if kind == "relative_encoder": + binding["mode"] = self.relative_mode_var.get() + else: + binding.pop("mode", None) + item["input"] = binding + if self.push_enabled_var.get(): + item["push"] = self._binding_from_fields( + self.push_message_var.get(), + self.push_channel_var.get(), + self.push_number_var.get(), + ) + else: + item.pop("push", None) + self._refresh_controls(select=self.selected_index) + return True + except (TypeError, ValueError) as exc: + if not quiet: + messagebox.showerror(self._t("controller_invalid_title"), str(exc), parent=self.window) + return False + + def _next_number(self, message: str) -> int: + used = { + int(binding["number"]) + for item in self.controls + for binding in (item.get("input"), item.get("push")) + if binding and binding.get("message") == message and "number" in binding + } + return next((value for value in range(128) if value not in used), 0) + + def _add_control(self, kind: str) -> None: + self._apply_selected(quiet=True) + index = len(self.controls) + 1 + prefix = { + "absolute_encoder": "encoder", + "relative_encoder": "encoder", + "fader": "fader", + "button": "button", + "pad": "pad", + }[kind] + message = "note" if kind in {"button", "pad"} else "cc" + binding: dict[str, Any] = { + "message": message, + "channel": 1, + "number": self._next_number(message), + } + if kind == "relative_encoder": + binding["mode"] = "twos_complement" + self.controls.append({"id": f"{prefix}_{index:02d}", "kind": kind, "input": binding}) + if int(self.bank_size_var.get() or 1) == index - 1: + self.bank_size_var.set(str(index)) + self._refresh_controls(select=index - 1) + + def _delete_control(self) -> None: + if self.selected_index is None: + return + if len(self.controls) <= 1: + messagebox.showwarning( + self._t("controller_invalid_title"), + self._t("controller_need_one_control"), + parent=self.window, + ) + return + index = self.selected_index + del self.controls[index] + try: + bank_size = int(self.bank_size_var.get()) + except ValueError: + bank_size = len(self.controls) + self.bank_size_var.set(str(min(bank_size, len(self.controls)))) + self._refresh_controls(select=min(index, len(self.controls) - 1)) + + def _move_control(self, delta: int) -> None: + if self.selected_index is None: + return + destination = self.selected_index + delta + if not 0 <= destination < len(self.controls): + return + self._apply_selected(quiet=True) + self.controls[self.selected_index], self.controls[destination] = ( + self.controls[destination], + self.controls[self.selected_index], + ) + self._refresh_controls(select=destination) + + def _start_learn(self, target: str) -> None: + if self.selected_index is None: + messagebox.showwarning( + self._t("controller_invalid_title"), + self._t("controller_select_control"), + parent=self.window, + ) + return + self._apply_selected(quiet=True) + self._stop_learn() + try: + self.listener = MidiInputListener(self.learn_queue.put) + opened = self.listener.open(self.midi_port_var.get()) + except Exception as exc: + self.listener = None + messagebox.showerror(self._t("controller_midi_error"), str(exc), parent=self.window) + return + self.learn_target = target + self.midi_port_var.set(opened) + self.learn_status_var.set( + self._t("controller_learn_wait", target=self._t(f"controller_learn_target_{target}")) + ) + self.learn_after_id = self.window.after(50, self._poll_learn) + + def _poll_learn(self) -> None: + self.learn_after_id = None + while True: + try: + message = self.learn_queue.get_nowait() + except queue.Empty: + break + binding = midi_binding_from_message(message) + if binding is None: + continue + if self.selected_index is None or self.learn_target is None: + self._stop_learn() + return + item = self.controls[self.selected_index] + if self.learn_target == "input" and item.get("kind") == "relative_encoder": + binding["mode"] = self.relative_mode_var.get() + item[self.learn_target] = binding + if self.learn_target == "push": + self.push_enabled_var.set(True) + if not self.input_pattern_var.get().strip(): + self.input_pattern_var.set(self.midi_port_var.get()) + learned = self._display_binding(binding) + self._stop_learn() + self.learn_status_var.set(self._t("controller_learn_received", binding=learned)) + self._refresh_controls(select=self.selected_index) + return + self.learn_after_id = self.window.after(50, self._poll_learn) + + def _stop_learn(self) -> None: + if self.learn_after_id is not None: + try: + self.window.after_cancel(self.learn_after_id) + except tk.TclError: + pass + self.learn_after_id = None + if self.listener is not None: + self.listener.close() + self.listener = None + self.learn_target = None + + def _draft(self) -> tuple[Any, dict[str, Any]]: + if not self._apply_selected(): + raise ProfileError(self._t("controller_fix_selected")) + profile_id = self.profile_id_var.get().strip() + if not profile_id: + profile_id = suggest_controller_profile_id( + self.manufacturer_var.get(), self.model_var.get() + ) + self.profile_id_var.set(profile_id) + self.payload.update( + { + "schema_version": 1, + "profile_version": str(self.payload.get("profile_version", "1.0.0")), + "id": profile_id, + "manufacturer": self.manufacturer_var.get().strip(), + "model": self.model_var.get().strip(), + "bank_size": int(self.bank_size_var.get()), + "bank_count": int(self.bank_count_var.get()), + "page_count": int(self.page_count_var.get()), + "status": "community", + "midi_identity": { + "input_name_patterns": _split_patterns(self.input_pattern_var.get()), + "output_name_patterns": _split_patterns(self.output_pattern_var.get()), + }, + "controls": self.controls, + } + ) + firmware = self.firmware_var.get().strip() + if firmware: + self.payload["firmware"] = firmware + else: + self.payload.pop("firmware", None) + if int(self.bank_count_var.get()) == 1: + self.payload.pop("last_bank_size", None) + return validate_controller_draft(self.payload) + + def _save_profile(self): + profile, payload = self._draft() + destination = default_user_profile_dir() / f"{profile.id}.json" + replace = destination.exists() + if replace and not messagebox.askyesno( + self._t("controller_replace_title"), + self._t("controller_replace_body", name=destination.name), + parent=self.window, + ): + return None + result = save_user_controller_profile(payload, replace=replace) + self.payload = controller_profile_payload(result.profile) + self.on_saved(result.profile.id) + return result + + def _save(self) -> None: + try: + result = self._save_profile() + except Exception as exc: + messagebox.showerror(self._t("controller_invalid_title"), str(exc), parent=self.window) + return + if result is None: + return + backup = f"\n{self._t('controller_backup', path=result.backup_path)}" if result.backup_path else "" + messagebox.showinfo( + self._t("controller_saved_title"), + self._t("controller_saved_body", name=result.profile.display_name, path=result.path) + backup, + parent=self.window, + ) + + def _save_and_export(self) -> None: + try: + result = self._save_profile() + if result is None: + return + initial = f"Controller-Studio-{result.profile.manufacturer}-{result.profile.model}".replace("/", "-") + destination = filedialog.asksaveasfilename( + parent=self.window, + title=self._t("export_title"), + defaultextension=".ctrl2", + filetypes=((self._t("controller_file_type"), "*.ctrl2"),), + initialfile=f"{initial}.ctrl2", + ) + if not destination: + return + path = Path(destination) + replace = path.exists() and messagebox.askyesno( + self._t("replace_file"), + self._t("replace_file_body", name=path.name), + parent=self.window, + ) + if path.exists() and not replace: + return + exported = export_liveprofessor_controller(result.profile, path, replace=replace) + except Exception as exc: + messagebox.showerror(self._t("export_error"), str(exc), parent=self.window) + return + messagebox.showinfo( + self._t("controller_created"), + self._t( + "export_details", + path=exported.path, + rotaries=exported.rotary_count, + buttons=exported.button_count, + sha256=exported.sha256, + ), + parent=self.window, + ) + + def _submit(self) -> None: + try: + result = self._save_profile() + except Exception as exc: + messagebox.showerror(self._t("controller_invalid_title"), str(exc), parent=self.window) + return + if result is not None: + self.on_contribute(result.profile.id) + + def close(self) -> None: + self._stop_learn() + try: + self.window.destroy() + except tk.TclError: + pass diff --git a/src/silemio_control_hub/controller_profiles/akai-lpd8-mk2-program-1-mpc.json b/src/silemio_control_hub/controller_profiles/akai-lpd8-mk2-program-1-mpc.json new file mode 100644 index 0000000..d1aa9e3 --- /dev/null +++ b/src/silemio_control_hub/controller_profiles/akai-lpd8-mk2-program-1-mpc.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "profile_version": "1.0.0", + "id": "akai.lpd8-mk2.program-1-mpc", + "manufacturer": "Akai Professional", + "model": "LPD8 MK2 — Program 1 (MPC layout)", + "midi_identity": { + "input_name_patterns": ["LPD8 mk2", "LPD8 MK2"], + "output_name_patterns": ["LPD8 mk2", "LPD8 MK2"] + }, + "bank_size": 16, + "status": "community", + "capabilities": ["push"], + "controls": [ + {"id": "knob_01", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 36}}, + {"id": "knob_02", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 37}}, + {"id": "knob_03", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 38}}, + {"id": "knob_04", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 39}}, + {"id": "knob_05", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 40}}, + {"id": "knob_06", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 41}}, + {"id": "knob_07", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 42}}, + {"id": "knob_08", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 43}}, + {"id": "pad_01", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 36}}, + {"id": "pad_02", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 37}}, + {"id": "pad_03", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 38}}, + {"id": "pad_04", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 39}}, + {"id": "pad_05", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 40}}, + {"id": "pad_06", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 41}}, + {"id": "pad_07", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 42}}, + {"id": "pad_08", "kind": "pad", "input": {"message": "note", "channel": 1, "number": 43}} + ] +} diff --git a/src/silemio_control_hub/controller_profiles/korg-nanokontrol2-cc-factory.json b/src/silemio_control_hub/controller_profiles/korg-nanokontrol2-cc-factory.json new file mode 100644 index 0000000..5b3b9ad --- /dev/null +++ b/src/silemio_control_hub/controller_profiles/korg-nanokontrol2-cc-factory.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "profile_version": "1.0.0", + "id": "korg.nanokontrol2.cc-factory", + "manufacturer": "Korg", + "model": "nanoKONTROL2 — CC Mode (factory knobs + faders)", + "midi_identity": { + "input_name_patterns": ["nanoKONTROL2"], + "output_name_patterns": ["nanoKONTROL2"] + }, + "bank_size": 16, + "status": "community", + "capabilities": [], + "controls": [ + {"id": "knob_01", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 16}}, + {"id": "knob_02", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 17}}, + {"id": "knob_03", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 18}}, + {"id": "knob_04", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 19}}, + {"id": "knob_05", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 20}}, + {"id": "knob_06", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 21}}, + {"id": "knob_07", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 22}}, + {"id": "knob_08", "kind": "absolute_encoder", "input": {"message": "cc", "channel": 1, "number": 23}}, + {"id": "fader_01", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 0}}, + {"id": "fader_02", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 1}}, + {"id": "fader_03", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 2}}, + {"id": "fader_04", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 3}}, + {"id": "fader_05", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 4}}, + {"id": "fader_06", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 5}}, + {"id": "fader_07", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 6}}, + {"id": "fader_08", "kind": "fader", "input": {"message": "cc", "channel": 1, "number": 7}, "roles": ["output"]} + ] +} diff --git a/src/silemio_control_hub/controller_studio.py b/src/silemio_control_hub/controller_studio.py new file mode 100644 index 0000000..8015327 --- /dev/null +++ b/src/silemio_control_hub/controller_studio.py @@ -0,0 +1,293 @@ +"""Safe helpers used by the in-app controller profile editor.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import tempfile +import unicodedata +from typing import Any + +from .models import ControllerProfile, ProfileError +from .registry import default_user_profile_dir + + +_SAFE_ID_PART = re.compile(r"[^a-z0-9]+") + + +@dataclass(frozen=True, slots=True) +class ControllerProfileSave: + profile: ControllerProfile + path: Path + backup_path: Path | None + + +def _slug(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + ascii_value = normalized.encode("ascii", "ignore").decode("ascii").casefold() + return _SAFE_ID_PART.sub("-", ascii_value).strip("-") + + +def suggest_controller_profile_id(manufacturer: str, model: str) -> str: + """Return a stable, schema-compatible identifier suggested from visible fields.""" + + maker = _slug(manufacturer) or "custom" + product = _slug(model) or "controller" + return f"{maker}.{product}" + + +def _binding_payload(binding) -> dict[str, Any]: + payload: dict[str, Any] = {"message": binding.message.value} + if binding.channel is not None: + payload["channel"] = binding.channel + if binding.number is not None: + payload["number"] = binding.number + if binding.mode is not None: + payload["mode"] = binding.mode + return payload + + +def controller_profile_payload(profile: ControllerProfile) -> dict[str, Any]: + """Serialize a validated profile without relying on dataclass implementation details.""" + + controls: list[dict[str, Any]] = [] + for control in profile.controls: + item: dict[str, Any] = { + "id": control.id, + "kind": control.kind.value, + "input": _binding_payload(control.input), + } + if control.push is not None: + item["push"] = _binding_payload(control.push) + if control.touch is not None: + item["touch"] = _binding_payload(control.touch) + if control.feedback is not None: + feedback: dict[str, Any] = {} + for field in ("value", "led", "color"): + binding = getattr(control.feedback, field) + if binding is not None: + feedback[field] = _binding_payload(binding) + if control.feedback.supported_colors: + feedback["supported_colors"] = list(control.feedback.supported_colors) + item["feedback"] = feedback + if control.display_cell is not None: + item["display_cell"] = control.display_cell + if control.roles: + item["roles"] = list(control.roles) + controls.append(item) + + payload: dict[str, Any] = { + "schema_version": 1, + "profile_version": profile.profile_version, + "id": profile.id, + "manufacturer": profile.manufacturer, + "model": profile.model, + "midi_identity": { + "input_name_patterns": list(profile.midi_identity.input_name_patterns), + "output_name_patterns": list(profile.midi_identity.output_name_patterns), + }, + "bank_size": profile.bank_size, + "bank_count": profile.bank_count, + "page_count": profile.page_count, + "status": "community", + "capabilities": list(profile.capabilities), + "controls": controls, + } + if profile.firmware is not None: + payload["firmware"] = profile.firmware + if profile.last_bank_size is not None: + payload["last_bank_size"] = profile.last_bank_size + if profile.modifiers: + payload["modifiers"] = [ + { + "id": modifier.id, + "input": _binding_payload(modifier.input), + "behavior": modifier.behavior.value, + } + for modifier in profile.modifiers + ] + return payload + + +def editable_controller_payload(profile: ControllerProfile, *, duplicate: bool) -> dict[str, Any]: + """Create a safe editor draft, optionally as a new personal derivative.""" + + payload = controller_profile_payload(profile) + payload["status"] = "community" + if duplicate: + payload["id"] = f"{profile.id}.custom" + payload["model"] = f"{profile.model} Custom" + payload["profile_version"] = "1.0.0" + return payload + + +def default_controller_payload() -> dict[str, Any]: + """Return an immediately editable eight-encoder starter profile.""" + + controls = [ + { + "id": f"encoder_{index:02d}", + "kind": "absolute_encoder", + "input": {"message": "cc", "channel": 1, "number": index - 1}, + } + for index in range(1, 9) + ] + return { + "schema_version": 1, + "profile_version": "1.0.0", + "id": "custom.controller", + "manufacturer": "Custom", + "model": "MIDI Controller", + "midi_identity": {"input_name_patterns": [], "output_name_patterns": []}, + "bank_size": 8, + "bank_count": 1, + "page_count": 1, + "status": "community", + "capabilities": ["commands"], + "controls": controls, + } + + +def derived_capabilities(payload: dict[str, Any]) -> list[str]: + """Keep declared hardware capabilities and add every capability required by the draft.""" + + capabilities = { + item for item in payload.get("capabilities", []) if isinstance(item, str) + } + capabilities.add("commands") + controls = payload.get("controls", []) + if any(item.get("push") is not None for item in controls): + capabilities.add("push") + if any(item.get("touch") is not None for item in controls): + capabilities.add("touch") + if any(item.get("display_cell") is not None for item in controls): + capabilities.add("display") + for item in controls: + feedback = item.get("feedback") or {} + if feedback.get("value") is not None: + capabilities.add("values") + if feedback.get("led") is not None: + capabilities.add("led") + if feedback.get("color") is not None: + capabilities.add("colors") + bindings = [item.get("input"), item.get("push"), item.get("touch")] + if any(binding and binding.get("message") == "nrpn" for binding in bindings): + capabilities.add("high_resolution") + if any(binding and binding.get("message") == "sysex" for binding in bindings): + capabilities.add("sysex") + if int(payload.get("bank_count", 1)) > 1: + capabilities.add("banks") + if int(payload.get("page_count", 1)) > 1: + capabilities.add("pages") + if payload.get("modifiers"): + capabilities.add("modifiers") + return sorted(capabilities) + + +def validate_controller_draft(payload: dict[str, Any]) -> tuple[ControllerProfile, dict[str, Any]]: + """Normalize editor data and run the same strict model used by the library.""" + + normalized = json.loads(json.dumps(payload, ensure_ascii=False)) + normalized["schema_version"] = 1 + normalized["status"] = "community" + normalized["capabilities"] = derived_capabilities(normalized) + midi_identity = normalized.setdefault("midi_identity", {}) + midi_identity.setdefault("input_name_patterns", []) + midi_identity.setdefault("output_name_patterns", []) + profile = ControllerProfile.from_dict(normalized) + return profile, normalized + + +def save_user_controller_profile( + payload: dict[str, Any], + *, + directory: Path | None = None, + replace: bool = False, +) -> ControllerProfileSave: + """Validate and atomically save a declarative user profile, with rollback backup.""" + + profile, normalized = validate_controller_draft(payload) + root = Path(directory or default_user_profile_dir()).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + destination = root / f"{profile.id}.json" + if destination.exists() and not replace: + raise ProfileError(f"{destination.name} existe déjà") + + current = destination.read_text(encoding="utf-8") if destination.exists() else None + if current is not None: + try: + current_payload = json.loads(current) + current_profile = ControllerProfile.from_dict(current_payload) + except (json.JSONDecodeError, ProfileError) as exc: + raise ProfileError(f"profil personnel existant illisible {destination}: {exc}") from exc + if current_payload != normalized: + current_version = tuple( + int(part) for part in current_profile.profile_version.split("-", 1)[0].split(".") + ) + proposed_version = tuple( + int(part) for part in profile.profile_version.split("-", 1)[0].split(".") + ) + if proposed_version <= current_version: + normalized["profile_version"] = ( + f"{current_version[0]}.{current_version[1]}.{current_version[2] + 1}" + ) + profile = ControllerProfile.from_dict(normalized) + + serialized = json.dumps(normalized, ensure_ascii=False, indent=2) + "\n" + backup_path: Path | None = None + temporary_path: Path | None = None + try: + if destination.exists(): + assert current is not None + if current != serialized: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + backup_root = root / "backups" + backup_root.mkdir(parents=True, exist_ok=True) + backup_path = backup_root / f"{profile.id}-{stamp}.json" + backup_path.write_text(current, encoding="utf-8") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="\n", + dir=root, + prefix=f".{profile.id}-", + suffix=".tmp", + delete=False, + ) as temporary: + temporary.write(serialized) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + os.replace(temporary_path, destination) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() + return ControllerProfileSave(profile, destination, backup_path) + + +def midi_binding_from_message(message: Any) -> dict[str, Any] | None: + """Convert one common mido input message into a declarative profile binding.""" + + message_type = str(getattr(message, "type", "")) + channel = getattr(message, "channel", None) + if message_type == "control_change": + return { + "message": "cc", + "channel": int(channel) + 1, + "number": int(message.control), + } + if message_type in {"note_on", "note_off"}: + if message_type == "note_on" and int(getattr(message, "velocity", 0)) == 0: + return None + return { + "message": "note", + "channel": int(channel) + 1, + "number": int(message.note), + } + if message_type == "pitchwheel": + return {"message": "pitch_bend", "channel": int(channel) + 1} + return None diff --git a/src/silemio_control_hub/desktop.py b/src/silemio_control_hub/desktop.py index 744107c..8f0e433 100644 --- a/src/silemio_control_hub/desktop.py +++ b/src/silemio_control_hub/desktop.py @@ -48,6 +48,12 @@ controller_submission_url, validated_controller_payload, ) +from .controller_editor import ControllerEditorDialog +from .controller_studio import ( + default_controller_payload, + editable_controller_payload, + save_user_controller_profile, +) from .identity import BRAND_NAME, FULL_PRODUCT_NAME, PRODUCT_NAME from .help_resources import ( PAYPAL_QR_PATH, @@ -78,7 +84,7 @@ retrieve_installed_parameter_names, save_user_profile, ) -from .registry import ControllerRegistry +from .registry import ControllerRegistry, default_user_profile_dir from .runtime import ( BridgeConfig, BridgeSnapshot, @@ -96,7 +102,7 @@ PRODUCT_ICON_PATH = Path(__file__).resolve().parent / "assets" / "controller-studio.ico" -DISPLAY_VERSION = "V.2026" +DISPLAY_VERSION = f"V.{__version__}" UI_TEXT = { @@ -135,7 +141,19 @@ "Connexions du moteur temps réel. Les outils propres au pilote actif " "apparaissent uniquement lorsqu’ils sont disponibles." ), - "ec4_zone": "Zone et apprentissage EC4", + "responsiveness_settings": "Réactivité, feedback et Overlay (ms)", + "responsiveness_intro": ( + "Réglages communs aux contrôleurs compatibles. Les valeurs d’origine " + "conviennent dans la majorité des cas." + ), + "persistent_parameter_display": "Conserver les paramètres visibles après l’Overlay", + "parameter_overlay_interval": "Mise à jour de l’Overlay (1–2000)", + "companion_refresh_delay": "Rafraîchissement Companion après commande (1–2000)", + "name_refresh_delay": "Rafraîchissement des noms / labels (1–2000)", + "feedback_confirm_timeout": "Délai maximal du retour LiveProfessor (100–10000)", + "overlay_display_duration": "Durée d’affichage de l’Overlay (200–5000)", + "ec4_zone": "Fonctions spécifiques EC4 (setup / groupe)", + "configure_active_controller": "Configurer / apprentissage MIDI…", "ec4_tools": "Outils EC4", "state_frame": "État", "last_event": "Dernier événement", @@ -333,14 +351,87 @@ "controls": "Contrôles", "layout": "Banques / pages", "refresh": "Actualiser", + "controller_create": "Créer un contrôleur…", + "controller_edit": "Modifier / dupliquer…", + "controller_import": "Importer un profil…", + "controller_import_title": "Importer un profil de contrôleur JSON", + "all_files": "Tous les fichiers", + "controller_imported_title": "Contrôleur importé", + "controller_imported_body": "{controller} est maintenant disponible dans votre banque.\n\n{path}", "contribute_controller": "Proposer à la bibliothèque…", "contribute_controller_title": "Proposer ce contrôleur", "contribute_controller_ready": ( - "Le profil validé de {controller} a été copié dans le presse-papiers.\n\n" - "GitHub va ouvrir le formulaire de contribution. Collez le JSON dans la zone " - "« Profil JSON », ajoutez les références ou tests disponibles, puis envoyez." + "Le profil validé de {controller} a été placé automatiquement dans le formulaire.\n\n" + "GitHub va s’ouvrir uniquement pour identifier l’auteur, ajouter si possible la " + "documentation ou les tests matériels, puis confirmer l’envoi." + ), + "contribute_controller_ready_clipboard": ( + "Le profil de {controller} est trop grand pour être placé dans le lien. Il a été " + "copié dans le presse-papiers : collez-le dans la zone « Profil JSON » puis confirmez." ), "contribute_controller_error": "Contribution impossible", + "controller_editor_title": "Fabriquer un contrôleur", + "controller_editor_intro": ( + "Décrivez le contrôleur, ajoutez ses commandes ou apprenez-les directement en MIDI. " + "Le profil est validé avant d’entrer dans votre banque et peut créer immédiatement " + "le fichier .ctrl2 pour LiveProfessor." + ), + "controller_identity": "Identité et organisation", + "controller_profile_id": "Identifiant", + "controller_input_pattern": "Nom(s) entrée MIDI", + "controller_output_pattern": "Nom(s) sortie MIDI", + "controller_firmware": "Firmware (facultatif)", + "controller_bank_size": "Contrôles / banque", + "controller_bank_count": "Banques", + "controller_page_count": "Pages", + "controller_generate_id": "Générer l’identifiant", + "controller_selected_control": "Commande sélectionnée", + "controller_control_id": "Nom technique", + "controller_control_kind": "Type", + "controller_message": "Message", + "controller_channel": "Canal", + "controller_number": "N° MIDI", + "controller_push": "Appui", + "controller_kind_absolute": "Encodeur absolu", + "controller_kind_relative": "Encodeur relatif", + "controller_kind_fader": "Fader", + "controller_kind_button": "Bouton", + "controller_kind_pad": "Pad", + "controller_message_note": "Note", + "controller_message_pitch": "Pitch Bend", + "controller_relative_mode": "Mode relatif", + "controller_input_message": "Mouvement / entrée", + "controller_push_enable": "Appui d’encodeur", + "controller_midi_port": "Entrée MIDI à écouter", + "controller_learn_input": "Apprendre le mouvement", + "controller_learn_push": "Apprendre l’appui", + "controller_learn_idle": "Sélectionnez une ligne puis lancez l’apprentissage MIDI.", + "controller_learn_wait": "En écoute : actionnez {target} sur le contrôleur…", + "controller_learn_target_input": "la commande", + "controller_learn_target_push": "son appui", + "controller_learn_received": "Reçu : {binding}", + "controller_add_encoder": "+ Encodeur", + "controller_add_relative": "+ Encodeur relatif", + "controller_add_fader": "+ Fader", + "controller_add_button": "+ Bouton", + "controller_delete_control": "Supprimer", + "controller_apply_control": "Appliquer à la ligne", + "controller_close": "Fermer", + "controller_submit": "Proposer à la bibliothèque", + "controller_save_export": "Enregistrer + créer .ctrl2", + "controller_save_local": "Enregistrer dans ma banque", + "controller_invalid_title": "Profil de contrôleur invalide", + "controller_need_one_control": "Un profil doit conserver au moins une commande.", + "controller_select_control": "Sélectionnez d’abord une commande dans la liste.", + "controller_midi_error": "Apprentissage MIDI impossible", + "controller_fix_selected": "Corrigez la commande sélectionnée.", + "controller_replace_title": "Remplacer ce profil personnel ?", + "controller_replace_body": ( + "{name} existe déjà dans votre banque. Le remplacer ? Une sauvegarde sera conservée." + ), + "controller_backup": "Sauvegarde précédente : {path}", + "controller_saved_title": "Contrôleur enregistré", + "controller_saved_body": "{name} est disponible dans votre banque.\n\n{path}", "plugin_studio_title": "Plugin Studio", "plugin_studio_badge": "LOCAL • SÛR", "plugin_intro": ( @@ -578,7 +669,18 @@ "Real-time engine connections. Driver-specific tools only appear when " "they are available." ), - "ec4_zone": "EC4 zone and MIDI learn", + "responsiveness_settings": "Responsiveness, feedback and Overlay (ms)", + "responsiveness_intro": ( + "Shared settings for compatible controllers. The defaults suit most setups." + ), + "persistent_parameter_display": "Keep parameters visible after the Overlay", + "parameter_overlay_interval": "Overlay update interval (1–2000)", + "companion_refresh_delay": "Companion refresh after a command (1–2000)", + "name_refresh_delay": "Name / label refresh delay (1–2000)", + "feedback_confirm_timeout": "LiveProfessor feedback timeout (100–10000)", + "overlay_display_duration": "Overlay display duration (200–5000)", + "ec4_zone": "EC4-specific functions (setup / group)", + "configure_active_controller": "Configure / MIDI Learn…", "ec4_tools": "EC4 tools", "state_frame": "Status", "last_event": "Last event", @@ -770,14 +872,87 @@ "controls": "Controls", "layout": "Banks / pages", "refresh": "Refresh", + "controller_create": "Create a controller…", + "controller_edit": "Edit / duplicate…", + "controller_import": "Import a profile…", + "controller_import_title": "Import a JSON controller profile", + "all_files": "All files", + "controller_imported_title": "Controller imported", + "controller_imported_body": "{controller} is now available in your bank.\n\n{path}", "contribute_controller": "Submit to the library…", "contribute_controller_title": "Submit this controller", "contribute_controller_ready": ( - "The validated {controller} profile was copied to the clipboard.\n\n" - "GitHub will open the contribution form. Paste the JSON into “Profile JSON”, " - "add any available references or test results, then submit it." + "The validated {controller} profile was inserted into the form automatically.\n\n" + "GitHub will open only to identify the author, add hardware documentation or test " + "details when available, and confirm the submission." + ), + "contribute_controller_ready_clipboard": ( + "The {controller} profile is too large for a pre-filled link. It was copied to the " + "clipboard: paste it into “JSON profile”, then confirm the submission." ), "contribute_controller_error": "Unable to prepare contribution", + "controller_editor_title": "Build a controller", + "controller_editor_intro": ( + "Describe the controller, add its controls, or learn them directly from MIDI. " + "The profile is validated before entering your bank and can immediately create " + "the LiveProfessor .ctrl2 file." + ), + "controller_identity": "Identity and layout", + "controller_profile_id": "Identifier", + "controller_input_pattern": "MIDI input name(s)", + "controller_output_pattern": "MIDI output name(s)", + "controller_firmware": "Firmware (optional)", + "controller_bank_size": "Controls / bank", + "controller_bank_count": "Banks", + "controller_page_count": "Pages", + "controller_generate_id": "Generate identifier", + "controller_selected_control": "Selected control", + "controller_control_id": "Technical name", + "controller_control_kind": "Type", + "controller_message": "Message", + "controller_channel": "Channel", + "controller_number": "MIDI no.", + "controller_push": "Push", + "controller_kind_absolute": "Absolute encoder", + "controller_kind_relative": "Relative encoder", + "controller_kind_fader": "Fader", + "controller_kind_button": "Button", + "controller_kind_pad": "Pad", + "controller_message_note": "Note", + "controller_message_pitch": "Pitch Bend", + "controller_relative_mode": "Relative mode", + "controller_input_message": "Movement / input", + "controller_push_enable": "Encoder push", + "controller_midi_port": "MIDI input to listen to", + "controller_learn_input": "Learn movement", + "controller_learn_push": "Learn push", + "controller_learn_idle": "Select a row, then start MIDI Learn.", + "controller_learn_wait": "Listening: operate {target} on the controller…", + "controller_learn_target_input": "the control", + "controller_learn_target_push": "its push", + "controller_learn_received": "Received: {binding}", + "controller_add_encoder": "+ Encoder", + "controller_add_relative": "+ Relative encoder", + "controller_add_fader": "+ Fader", + "controller_add_button": "+ Button", + "controller_delete_control": "Delete", + "controller_apply_control": "Apply to row", + "controller_close": "Close", + "controller_submit": "Submit to the library", + "controller_save_export": "Save + create .ctrl2", + "controller_save_local": "Save to my bank", + "controller_invalid_title": "Invalid controller profile", + "controller_need_one_control": "A profile must keep at least one control.", + "controller_select_control": "Select a control in the list first.", + "controller_midi_error": "MIDI Learn failed", + "controller_fix_selected": "Fix the selected control.", + "controller_replace_title": "Replace this personal profile?", + "controller_replace_body": ( + "{name} already exists in your bank. Replace it? A backup will be kept." + ), + "controller_backup": "Previous backup: {path}", + "controller_saved_title": "Controller saved", + "controller_saved_body": "{name} is available in your bank.\n\n{path}", "plugin_studio_title": "Plugin Studio", "plugin_studio_badge": "LOCAL • SAFE", "plugin_intro": ( @@ -1078,6 +1253,24 @@ def __init__(self, root: tk.Tk) -> None: self.target_setup_var = tk.StringVar(value=str(self.runtime_config.target_setup)) self.target_group_var = tk.StringVar(value=str(self.runtime_config.target_group)) self.display_enabled_var = tk.BooleanVar(value=self.runtime_config.display_enabled) + self.persistent_display_var = tk.BooleanVar( + value=self.runtime_config.persistent_parameter_display + ) + self.parameter_overlay_interval_var = tk.StringVar( + value=str(self.runtime_config.parameter_overlay_interval_ms) + ) + self.companion_refresh_delay_var = tk.StringVar( + value=str(self.runtime_config.companion_refresh_delay_ms) + ) + self.name_refresh_delay_var = tk.StringVar( + value=str(self.runtime_config.name_refresh_delay_ms) + ) + self.feedback_timeout_var = tk.StringVar( + value=str(self.runtime_config.feedback_confirm_timeout_ms) + ) + self.overlay_display_duration_var = tk.StringVar( + value=str(self.runtime_config.overlay_display_duration_ms) + ) self.runtime_status = tk.StringVar(value=self._t("runtime_not_started")) self.runtime_last_event = tk.StringVar(value="—") self.runtime_bank = tk.StringVar(value="1 / 1") @@ -1342,6 +1535,11 @@ def _build_live_tab(self, parent: ttk.Frame) -> None: self.live_controller_combo.bind( "<>", self._select_live_controller ) + ttk.Button( + controller, + text=self._t("configure_active_controller"), + command=self.edit_controller, + ).grid(row=0, column=1, sticky="e", padx=(0, 8)) self.live_driver_badge = tk.Label( controller, bg="#dff5fb", @@ -1350,7 +1548,7 @@ def _build_live_tab(self, parent: ttk.Frame) -> None: padx=9, pady=3, ) - self.live_driver_badge.grid(row=0, column=1, sticky="e") + self.live_driver_badge.grid(row=0, column=2, sticky="e") self.live_driver_note = ttk.Label( controller, style="Subtitle.TLabel", @@ -1358,7 +1556,7 @@ def _build_live_tab(self, parent: ttk.Frame) -> None: justify="left", ) self.live_driver_note.grid( - row=1, column=0, columnspan=2, sticky="ew", pady=(7, 0) + row=1, column=0, columnspan=3, sticky="ew", pady=(7, 0) ) actions = ttk.Frame(parent) @@ -1534,29 +1732,39 @@ def _build_controllers_tab(self, parent: ttk.Frame) -> None: self.controller_tree.grid(row=0, column=0, sticky="nsew") scrollbar.grid(row=0, column=1, sticky="ns") self.controller_tree.bind("<>", self._select_controller) + self.controller_tree.bind("", lambda _event: self.edit_controller()) actions = ttk.Frame(parent, padding=(0, 12, 0, 0)) actions.grid(row=1, column=0, columnspan=2, sticky="ew") - ttk.Button(actions, text=self._t("refresh"), command=self.reload_catalog).pack( - side="left" - ) - ttk.Button( - actions, - text=self._t("export_controller"), - command=self.export_controller, - ).pack(side="left", padx=(8, 0)) + primary = ttk.Frame(actions) + primary.pack(fill="x") + tk.Button( + primary, + text=self._t("controller_create"), + command=self.create_controller, + bg="#087d9d", + fg="#ffffff", + activebackground="#0aa1c9", + activeforeground="#ffffff", + relief="flat", + borderwidth=0, + padx=14, + pady=5, + font=("Segoe UI Semibold", 10), + cursor="hand2", + ).pack(side="left") ttk.Button( - actions, - text=self._t("contribute_controller"), - command=self.contribute_controller, + primary, + text=self._t("controller_edit"), + command=self.edit_controller, ).pack(side="left", padx=(8, 0)) ttk.Button( - actions, - text=self._t("minimize"), - command=self.minimize_to_tray, + primary, + text=self._t("controller_import"), + command=self.import_controller, ).pack(side="left", padx=(8, 0)) tk.Button( - actions, + primary, text=self._t("prepare_automap"), command=self.prepare_automap, bg="#087d9d", @@ -1571,6 +1779,27 @@ def _build_controllers_tab(self, parent: ttk.Frame) -> None: cursor="hand2", ).pack(side="right") + secondary = ttk.Frame(actions) + secondary.pack(fill="x", pady=(7, 0)) + ttk.Button(secondary, text=self._t("refresh"), command=self.reload_catalog).pack( + side="left" + ) + ttk.Button( + secondary, + text=self._t("export_controller"), + command=self.export_controller, + ).pack(side="left", padx=(8, 0)) + ttk.Button( + secondary, + text=self._t("contribute_controller"), + command=self.contribute_controller, + ).pack(side="left", padx=(8, 0)) + ttk.Button( + secondary, + text=self._t("minimize"), + command=self.minimize_to_tray, + ).pack(side="left", padx=(8, 0)) + def _build_plugins_tab(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) parent.rowconfigure(4, weight=1) @@ -2792,6 +3021,12 @@ def _apply_live_profile_state(self) -> None: self.live_settings_ec4_frame.grid() else: self.live_settings_ec4_frame.grid_remove() + if self.live_settings_window is not None and _widget_exists( + self.live_settings_window + ): + self.live_settings_window.geometry( + f"920x{560 if supported else 500}" + ) if not supported and not (self.runtime and self.runtime.running): self.runtime_status.set( self._t( @@ -2814,20 +3049,105 @@ def _selected_profile(self): return None return self.registry.get(self.selected_profile_id) - def contribute_controller(self) -> None: + def create_controller(self) -> None: + self._show_controller_editor(default_controller_payload()) + + def edit_controller(self) -> None: profile = self._selected_profile() if profile is None: return + source = self.registry.source(profile.id).resolve() + user_root = default_user_profile_dir().resolve() + try: + is_personal = source.is_relative_to(user_root) + except ValueError: + is_personal = False + self._show_controller_editor( + editable_controller_payload(profile, duplicate=not is_personal) + ) + + def _show_controller_editor(self, payload: dict[str, object]) -> None: + current = getattr(self, "controller_editor", None) + try: + if current is not None and current.window.winfo_exists(): + current.window.destroy() + except tk.TclError: + pass + + def saved(profile_id: str) -> None: + self.registry = ControllerRegistry() + self.reload_catalog(preferred_profile_id=profile_id) + + def contribute(profile_id: str) -> None: + saved(profile_id) + self.root.after_idle(lambda: self.contribute_controller(profile_id)) + + self.controller_editor = ControllerEditorDialog( + self.root, + translator=self._t, + payload=payload, + on_saved=saved, + on_contribute=contribute, + icon_path=PRODUCT_ICON_PATH, + ) + + def import_controller(self) -> None: + source = filedialog.askopenfilename( + parent=self.root, + title=self._t("controller_import_title"), + filetypes=(("JSON", "*.json"), (self._t("all_files"), "*.*")), + ) + if not source: + return + try: + profile = ControllerRegistry.load_file(Path(source)) + payload = editable_controller_payload(profile, duplicate=False) + destination = default_user_profile_dir() / f"{profile.id}.json" + replace_file = destination.exists() and messagebox.askyesno( + self._t("controller_replace_title"), + self._t("controller_replace_body", name=destination.name), + parent=self.root, + ) + if destination.exists() and not replace_file: + return + result = save_user_controller_profile(payload, replace=replace_file) + self.registry = ControllerRegistry() + self.reload_catalog(preferred_profile_id=result.profile.id) + except Exception as exc: + messagebox.showerror( + self._t("controller_invalid_title"), str(exc), parent=self.root + ) + return + messagebox.showinfo( + self._t("controller_imported_title"), + self._t( + "controller_imported_body", + controller=result.profile.display_name, + path=result.path, + ), + parent=self.root, + ) + + def contribute_controller(self, profile_id: str | None = None) -> None: + if profile_id is not None: + self.selected_profile_id = profile_id + profile = self._selected_profile() + if profile is None: + return + clipboard_fallback = False try: source = self.registry.source(profile.id) contribution, payload = validated_controller_payload( source, expected_profile_id=profile.id, ) - self.root.clipboard_clear() - self.root.clipboard_append(payload) - self.root.update_idletasks() - url = controller_submission_url(contribution) + url = controller_submission_url(contribution, payload=payload) + if len(url) > 30_000: + clipboard_fallback = True + self.root.clipboard_clear() + self.root.clipboard_append(payload) + self.root.update_idletasks() + url = controller_submission_url(contribution) if not webbrowser.open(url): raise OSError("le navigateur n’a pas accepté le lien GitHub") except Exception as exc: @@ -2840,7 +3160,11 @@ def contribute_controller(self) -> None: messagebox.showinfo( self._t("contribute_controller_title"), self._t( - "contribute_controller_ready", + ( + "contribute_controller_ready_clipboard" + if clipboard_fallback + else "contribute_controller_ready" + ), controller=contribution.display_name, ), parent=self.root, @@ -3445,6 +3769,18 @@ def _runtime_config_from_form(self) -> BridgeConfig: target_setup=int(self.target_setup_var.get().strip()), target_group=int(self.target_group_var.get().strip()), display_enabled=bool(self.display_enabled_var.get()), + persistent_parameter_display=bool(self.persistent_display_var.get()), + parameter_overlay_interval_ms=int( + self.parameter_overlay_interval_var.get().strip() + ), + companion_refresh_delay_ms=int( + self.companion_refresh_delay_var.get().strip() + ), + name_refresh_delay_ms=int(self.name_refresh_delay_var.get().strip()), + feedback_confirm_timeout_ms=int(self.feedback_timeout_var.get().strip()), + overlay_display_duration_ms=int( + self.overlay_display_duration_var.get().strip() + ), ui_language=self.language_var.get(), ) config.validate() @@ -3460,6 +3796,12 @@ def _apply_runtime_config_to_form(self, config: BridgeConfig) -> None: self.target_setup_var.set(str(config.target_setup)) self.target_group_var.set(str(config.target_group)) self.display_enabled_var.set(config.display_enabled) + self.persistent_display_var.set(config.persistent_parameter_display) + self.parameter_overlay_interval_var.set(str(config.parameter_overlay_interval_ms)) + self.companion_refresh_delay_var.set(str(config.companion_refresh_delay_ms)) + self.name_refresh_delay_var.set(str(config.name_refresh_delay_ms)) + self.feedback_timeout_var.set(str(config.feedback_confirm_timeout_ms)) + self.overlay_display_duration_var.set(str(config.overlay_display_duration_ms)) def refresh_midi_ports(self) -> None: try: @@ -3965,8 +4307,9 @@ def show_live_settings(self) -> None: window = tk.Toplevel(self.root) self.live_settings_window = window window.title(self._t("live_settings_title")) - window.geometry("900x420") - window.minsize(760, 360) + supported = live_runtime_supported(self.selected_profile_id) + window.geometry(f"920x{560 if supported else 500}") + window.minsize(800, 470) window.transient(self.root) if PRODUCT_ICON_PATH.is_file(): try: @@ -4031,10 +4374,48 @@ def show_live_settings(self) -> None: command=self.refresh_midi_ports, ).grid(row=2, column=3, sticky="e", pady=(7, 0)) + responsiveness = ttk.LabelFrame( + frame, text=self._t("responsiveness_settings"), padding=10 + ) + responsiveness.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + responsiveness.columnconfigure(1, weight=1) + responsiveness.columnconfigure(3, weight=1) + ttk.Label( + responsiveness, + text=self._t("responsiveness_intro"), + wraplength=840, + justify="left", + ).grid(row=0, column=0, columnspan=4, sticky="ew", pady=(0, 6)) + timing_fields = ( + ("parameter_overlay_interval", self.parameter_overlay_interval_var, 1, 2000), + ("companion_refresh_delay", self.companion_refresh_delay_var, 1, 2000), + ("name_refresh_delay", self.name_refresh_delay_var, 1, 2000), + ("feedback_confirm_timeout", self.feedback_timeout_var, 100, 10000), + ("overlay_display_duration", self.overlay_display_duration_var, 200, 5000), + ) + for index, (label_key, variable, minimum, maximum) in enumerate(timing_fields): + row = 1 + index // 2 + column = (index % 2) * 2 + ttk.Label(responsiveness, text=self._t(label_key)).grid( + row=row, column=column, sticky="w", padx=(0, 6), pady=3 + ) + ttk.Spinbox( + responsiveness, + from_=minimum, + to=maximum, + textvariable=variable, + width=9, + ).grid(row=row, column=column + 1, sticky="w", padx=(0, 16), pady=3) + ttk.Checkbutton( + responsiveness, + text=self._t("persistent_parameter_display"), + variable=self.persistent_display_var, + ).grid(row=4, column=0, columnspan=4, sticky="w", pady=(7, 0)) + self.live_settings_ec4_frame = ttk.LabelFrame( frame, text=self._t("ec4_tools"), padding=10 ) - self.live_settings_ec4_frame.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + self.live_settings_ec4_frame.grid(row=3, column=0, sticky="ew", pady=(10, 0)) ttk.Checkbutton( self.live_settings_ec4_frame, text=self._t("display_enabled"), @@ -4058,7 +4439,7 @@ def show_live_settings(self) -> None: self.runtime_settings_action_buttons.append(button) footer = ttk.Frame(frame) - footer.grid(row=3, column=0, sticky="ew", pady=(14, 0)) + footer.grid(row=4, column=0, sticky="ew", pady=(14, 0)) ttk.Button( footer, text=self._t("save"), diff --git a/src/silemio_control_hub/library_remote.py b/src/silemio_control_hub/library_remote.py index a9b7f07..2437176 100644 --- a/src/silemio_control_hub/library_remote.py +++ b/src/silemio_control_hub/library_remote.py @@ -226,7 +226,7 @@ def _installed_hub_version() -> str: try: return package_version("silemio-control-hub") except PackageNotFoundError: - return "2026.3.dev0" + return "2026.4.dev0" def _check_hub_compatibility(manifest: LibraryManifest, hub_version: str) -> None: diff --git a/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Manual-EN.pdf b/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Manual-EN.pdf index a21118a..89ba2a9 100644 Binary files a/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Manual-EN.pdf and b/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Manual-EN.pdf differ diff --git a/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Notice-FR.pdf b/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Notice-FR.pdf index 8accdb1..3cdd279 100644 Binary files a/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Notice-FR.pdf and b/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Notice-FR.pdf differ diff --git a/src/silemio_control_hub/transports/__init__.py b/src/silemio_control_hub/transports/__init__.py index c6c3dcb..d0307e6 100644 --- a/src/silemio_control_hub/transports/__init__.py +++ b/src/silemio_control_hub/transports/__init__.py @@ -1,11 +1,12 @@ """Concrete MIDI and OSC transports used by controller and host adapters.""" -from .midi import MidiBackendError, MidiConnection, input_names, output_names +from .midi import MidiBackendError, MidiConnection, MidiInputListener, input_names, output_names from .osc import OSCClient, OSCError, OSCServer, decode_message, encode_message __all__ = [ "MidiBackendError", "MidiConnection", + "MidiInputListener", "OSCClient", "OSCError", "OSCServer", diff --git a/src/silemio_control_hub/transports/midi.py b/src/silemio_control_hub/transports/midi.py index 4c5dc68..acba7a3 100644 --- a/src/silemio_control_hub/transports/midi.py +++ b/src/silemio_control_hub/transports/midi.py @@ -122,3 +122,38 @@ def send(self, message: Any) -> None: except Exception as exc: self.close() raise MidiBackendError(f"envoi MIDI impossible: {exc}") from exc + + +class MidiInputListener: + """Small input-only connection used by Controller Studio's MIDI Learn UI.""" + + def __init__(self, callback: Callable[[Any], None]) -> None: + self.callback = callback + self.input_port: Any | None = None + self.input_name = "" + self._lock = threading.RLock() + + @property + def is_open(self) -> bool: + return bool(self.input_port and not self.input_port.closed) + + def open(self, requested_input: str) -> str: + mido = _mido() + with self._lock: + self.close() + self.input_name = resolve_port(requested_input, list(mido.get_input_names())) + try: + self.input_port = mido.open_input(self.input_name, callback=self.callback) + except Exception as exc: + self.close() + raise MidiBackendError(f"ouverture de l’entrée MIDI impossible: {exc}") from exc + return self.input_name + + def close(self) -> None: + with self._lock: + if self.input_port is not None: + try: + self.input_port.close() + except Exception: + pass + self.input_port = None diff --git a/tests/test_controller_contribution.py b/tests/test_controller_contribution.py index 75f2236..8cfec7c 100644 --- a/tests/test_controller_contribution.py +++ b/tests/test_controller_contribution.py @@ -7,6 +7,7 @@ import pytest from silemio_control_hub.controller_contribution import ( + controller_submission_body, controller_submission_url, validated_controller_payload, ) @@ -65,3 +66,14 @@ def test_submission_url_targets_the_product_repository_and_template(tmp_path: Pa assert parsed.path == "/Mamat79/Controller-Studio-for-LiveProfessor/issues/new" assert query["template"] == ["controller-profile.md"] assert query["title"] == ["Controller profile: ACME & Sons Knob / One"] + + +def test_submission_url_can_prefill_the_complete_validated_profile(tmp_path: Path): + profile, payload = validated_controller_payload(_profile(tmp_path / "profile.json")) + url = controller_submission_url(profile, payload=payload) + body = parse_qs(urlsplit(url).query)["body"][0] + + assert "ACME & Sons Knob / One" in body + assert '"id":"acme.knob"' in body + assert "```json" in body + assert controller_submission_body(profile, "{}").startswith("## Contrôleur") diff --git a/tests/test_controller_registry.py b/tests/test_controller_registry.py index c9bbc4e..8b7cf58 100644 --- a/tests/test_controller_registry.py +++ b/tests/test_controller_registry.py @@ -14,6 +14,7 @@ def test_builtin_profiles_are_valid_and_distinct(): registry = ControllerRegistry() profiles = registry.all() assert {profile.id for profile in profiles} == { + "akai.lpd8-mk2.program-1-mpc", "behringer.x-touch-compact.layer-a", "behringer.x-touch-mini.preset-a", "behringer.x-touch-one.midi", @@ -23,6 +24,7 @@ def test_builtin_profiles_are_valid_and_distinct(): "faderfox.pc4.setup-1", "faderfox.uc4.setup-1-group-1", "generic.midi.16", + "korg.nanokontrol2.cc-factory", "novation.launch-control-xl3.mode-16", } ec4 = registry.get("faderfox.ec4") @@ -64,6 +66,15 @@ def test_builtin_profiles_are_valid_and_distinct(): assert all(control.supports_rotation for control in twister.controls) assert all(control.supports_press for control in twister.controls) + nanokontrol = registry.get("korg.nanokontrol2.cc-factory") + assert nanokontrol.bank_size == 16 + assert sum(control.supports_rotation for control in nanokontrol.controls) == 16 + + lpd8 = registry.get("akai.lpd8-mk2.program-1-mpc") + assert lpd8.bank_size == 16 + assert sum(control.supports_rotation for control in lpd8.controls) == 8 + assert sum(control.supports_press for control in lpd8.controls) == 8 + def test_duplicate_control_ids_are_rejected(tmp_path): profile = { diff --git a/tests/test_controller_studio.py b/tests/test_controller_studio.py new file mode 100644 index 0000000..6c221cc --- /dev/null +++ b/tests/test_controller_studio.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from silemio_control_hub.controller_studio import ( + controller_profile_payload, + default_controller_payload, + editable_controller_payload, + midi_binding_from_message, + save_user_controller_profile, + suggest_controller_profile_id, + validate_controller_draft, +) +from silemio_control_hub.models import ControllerProfile, ProfileError + + +def test_profile_id_suggestion_is_schema_safe_and_accent_neutral(): + assert suggest_controller_profile_id("Béh/Ringer", "X Touch + Perso") == ( + "beh-ringer.x-touch-perso" + ) + + +def test_starter_profile_is_valid_and_round_trips(): + profile, payload = validate_controller_draft(default_controller_payload()) + assert profile.display_name == "Custom MIDI Controller" + assert profile.bank_size == 8 + assert len(profile.controls) == 8 + assert ControllerProfile.from_dict(controller_profile_payload(profile)) == profile + assert payload["status"] == "community" + + +def test_duplicate_is_personal_and_keeps_control_definitions(): + profile, _ = validate_controller_draft(default_controller_payload()) + duplicate = editable_controller_payload(profile, duplicate=True) + cloned, _ = validate_controller_draft(duplicate) + assert cloned.id == "custom.controller.custom" + assert cloned.status == "community" + assert cloned.controls == profile.controls + + +def test_user_profile_save_is_atomic_and_backs_up_replacement(tmp_path: Path): + payload = default_controller_payload() + first = save_user_controller_profile(payload, directory=tmp_path) + assert first.path.is_file() + assert first.backup_path is None + + payload["model"] = "MIDI Controller Mk II" + with pytest.raises(ProfileError, match="existe déjà"): + save_user_controller_profile(payload, directory=tmp_path) + second = save_user_controller_profile(payload, directory=tmp_path, replace=True) + assert second.backup_path is not None + assert second.profile.profile_version == "1.0.1" + assert json.loads(second.backup_path.read_text(encoding="utf-8"))["model"] == ( + "MIDI Controller" + ) + assert json.loads(second.path.read_text(encoding="utf-8"))["model"] == ( + "MIDI Controller Mk II" + ) + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ( + SimpleNamespace(type="control_change", channel=0, control=74, value=63), + {"message": "cc", "channel": 1, "number": 74}, + ), + ( + SimpleNamespace(type="note_on", channel=9, note=40, velocity=127), + {"message": "note", "channel": 10, "number": 40}, + ), + ( + SimpleNamespace(type="pitchwheel", channel=2, pitch=42), + {"message": "pitch_bend", "channel": 3}, + ), + ], +) +def test_midi_learn_converts_common_messages(message, expected): + assert midi_binding_from_message(message) == expected + + +def test_midi_learn_ignores_note_on_zero_and_unknown_messages(): + assert ( + midi_binding_from_message( + SimpleNamespace(type="note_on", channel=0, note=40, velocity=0) + ) + is None + ) + assert midi_binding_from_message(SimpleNamespace(type="clock")) is None diff --git a/tests/test_desktop.py b/tests/test_desktop.py index 06b0179..d63ce62 100644 --- a/tests/test_desktop.py +++ b/tests/test_desktop.py @@ -18,6 +18,7 @@ def test_desktop_controller_table_uses_the_registry_without_legacy_dependencies( ) assert {row.profile_id for row in rows} == { + "akai.lpd8-mk2.program-1-mpc", "behringer.x-touch-compact.layer-a", "behringer.x-touch-mini.preset-a", "behringer.x-touch-one.midi", @@ -27,6 +28,7 @@ def test_desktop_controller_table_uses_the_registry_without_legacy_dependencies( "faderfox.pc4.setup-1", "faderfox.uc4.setup-1-group-1", "generic.midi.16", + "korg.nanokontrol2.cc-factory", "novation.launch-control-xl3.mode-16", } assert all(row.controls > 0 for row in rows) diff --git a/tests/test_liveprofessor_controller_export.py b/tests/test_liveprofessor_controller_export.py index 5936a9b..71c2431 100644 --- a/tests/test_liveprofessor_controller_export.py +++ b/tests/test_liveprofessor_controller_export.py @@ -69,6 +69,28 @@ def test_generic_profile_export_contains_only_its_sixteen_rotaries(tmp_path): assert tree.get("OSCOutPort") == 9011 +@pytest.mark.parametrize( + ("profile_id", "expected_counts"), + [ + ("korg.nanokontrol2.cc-factory", (16, 0)), + ("akai.lpd8-mk2.program-1-mpc", (8, 8)), + ], +) +def test_common_controller_profiles_export_expected_liveprofessor_controls( + tmp_path, + profile_id, + expected_counts, +): + destination = tmp_path / f"{profile_id}.ctrl2" + + export_liveprofessor_controller( + ControllerRegistry().get(profile_id), + destination, + ) + + assert _counts(parse_tree(destination.read_bytes())) == expected_counts + + def test_ec4_profile_can_export_the_recommended_sixteen_rotary_unibank(tmp_path): destination = tmp_path / "Faderfox-EC4-UniBank.ctrl2" diff --git a/tests/test_product_identity.py b/tests/test_product_identity.py index 7ca1df4..e48422b 100644 --- a/tests/test_product_identity.py +++ b/tests/test_product_identity.py @@ -15,4 +15,4 @@ def test_current_product_identity_is_liveprofessor_specific(): assert FULL_PRODUCT_NAME == "Controller Studio for LiveProfessor" assert WINDOWS_PRODUCT_FOLDER == "Controller Studio for LiveProfessor" assert WINDOWS_PRODUCT_SLUG == "Controller-Studio-for-LiveProfessor" - assert __version__ == "2026.3" + assert __version__ == "2026.4"