diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4056f..e6bd863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Historique des versions +## 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. +- Ajoute **Récupérer tous les vrais noms** pour traiter en une fois tous les types présents dans le projet analysé et enregistrer leurs profils locaux avec sauvegarde des versions précédentes. +- Refuse automatiquement tout inventaire dont le nombre de paramètres diffère du projet LiveProfessor afin d'empêcher les décalages de labels. +- Conserve l'interception Companion/OSC comme solution de secours pour les formats anciens et les plug-ins qui n'exposent pas directement un inventaire compatible. +- Intercepte en continu les vrais noms envoyés par LiveProfessor au contrôleur actif au lieu de les effacer au début de la capture. +- Réutilise instantanément les libellés déjà visibles sur le contrôleur et conserve leur ordre exact dans la Controller Map enregistrée. +- Attend et relance la demande jusqu’à 30 secondes lorsque LiveProfessor tarde à envoyer son inventaire, sans bloquer l’interface. +- Clarifie en français et en anglais le changement temporaire de plug-in qui déclenche l’émission des noms par LiveProfessor. + ## V.2026 (2026.2) — 2026-08-10 - Réunit le logiciel et sa bibliothèque publique versionnée dans le même dépôt GitHub. diff --git a/README.md b/README.md index c341a51..914637c 100644 --- a/README.md +++ b/README.md @@ -10,25 +10,25 @@

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

## Dernière version (accès rapide) -**Version stable : [Controller Studio V.2026.2](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.2)**
+**Version stable : [Controller Studio V.2026.3](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.3)**
Téléchargement direct : -- [Installer Controller Studio pour Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/Controller-Studio-for-LiveProfessor-Setup-v2026.2.exe) -- [Version portable Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/Controller-Studio-for-LiveProfessor.exe) -- [Notice complète en français (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/Controller-Studio-for-LiveProfessor-Notice-FR.pdf) -- [Full English manual (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/Controller-Studio-for-LiveProfessor-Manual-EN.pdf) -- [Sommes de contrôle SHA-256](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/SHA256SUMS.txt) +- [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) [Lire cette présentation en anglais](README_EN.md) -> **Version publique V.2026.2 pour Windows.** Controller Studio travaille sur une copie AutoMap et conserve le projet `.rack2` source intact. +> **Version publique V.2026.3 pour Windows.** Controller Studio travaille sur une copie AutoMap et conserve le projet `.rack2` source intact. ## À quoi sert Controller Studio ? @@ -36,7 +36,7 @@ Controller Studio transforme un contrôleur MIDI en surface de contrôle organis | Contrôle Live | Banque de contrôleurs | Plugin Studio | AutoMap | |---|---|---|---| -| Pilote EC4 temps réel, banques, push, Shift, labels, valeurs et reconnexion | Profils prêts à exporter et éditeur de contrôleur | Vrais noms reçus de LiveProfessor, priorités et sélection paramètre par paramètre | Choix des plug-ins et instances, UniBank ou FullBank, copie `.rack2` validée | +| Pilote EC4 temps réel, banques, push, Shift, labels, valeurs et reconnexion | Profils prêts à exporter et éditeur de contrôleur | Vrais noms lus dans les plug-ins installés, récupération globale, priorités et cases individuelles | Choix des plug-ins et instances, UniBank ou FullBank, copie `.rack2` validée | L’interface existe en français et en anglais, se réduit dans la zone de notification et place le journal temps réel dans une fenêtre séparée. @@ -50,21 +50,25 @@ Controller Studio relit la copie générée avant de la proposer. Les affectatio ## Plugin Studio -Le fichier `.rack2` conserve l’ordre et les identifiants des paramètres, mais pas toujours leurs libellés humains. Plugin Studio complète ces informations avec les noms renvoyés par le contrôleur Companion de LiveProfessor. +Le fichier `.rack2` conserve l’ordre et les identifiants des paramètres, mais pas toujours leurs libellés humains. Plugin Studio retrouve ces informations directement dans les plug-ins VST3 installés, chacun dans un processus isolé. Le résultat n’est accepté que si son nombre de paramètres correspond exactement au projet LiveProfessor. + +Après l’analyse du projet, **Récupérer tous les vrais noms** traite tous ses types de plug-ins en une seule opération, crée ou met à jour les profils locaux et sauvegarde automatiquement les versions précédentes. Pour un plug-in donné : -1. sélectionnez son instance dans LiveProfessor ; -2. rappelez la map **SiLeMI/O AutoMap - NomDuPlugin** dans la barre supérieure — pas la map Dynamic ; -3. cliquez sur **Récupérer automatiquement les vrais noms** ; -4. utilisez **Tout cocher**, **Tout décocher** ou les cases individuelles ; -5. ajustez si nécessaire le libellé court, le type, le rôle ou la priorité, puis enregistrez le profil local. +1. ouvrez le profil du plug-in puis cliquez sur **Récupérer automatiquement les vrais noms** ; +2. utilisez **Tout cocher**, **Tout décocher** ou les cases individuelles ; +3. ajustez si nécessaire le libellé court, le type, le rôle ou la priorité, puis enregistrez le profil local. + +Si un ancien format ou un plug-in particulier ne fournit pas directement un inventaire compatible, Controller Studio propose automatiquement le retour Companion/OSC de LiveProfessor comme seconde méthode. La relation entre l’emplacement de la Controller Map et l’identifiant interne du paramètre est conservée afin d’éviter qu’un bon réglage reçoive le label d’un autre. +VST est une marque déposée de Steinberg Media Technologies GmbH. Les mentions tierces figurent dans [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + ## Banque de contrôleurs intégrée -V.2026.2 fournit dix profils déclaratifs prêts à exporter : +V.2026.3 fournit dix profils déclaratifs prêts à exporter : - Faderfox EC4, PC4, UC4 et PC12 ; - Behringer X-Touch Compact, X-Touch Mini et X-Touch One ; diff --git a/README_EN.md b/README_EN.md index 5d5fb49..dbf897d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,25 +10,25 @@

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

## Latest version (quick access) -**Stable release: [Controller Studio V.2026.2](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.2)**
+**Stable release: [Controller Studio V.2026.3](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/tag/v2026.3)**
Direct downloads: -- [Controller Studio installer for Windows x64 (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/Controller-Studio-for-LiveProfessor-Setup-v2026.2.exe) -- [Portable Windows x64 version (.exe)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/Controller-Studio-for-LiveProfessor.exe) -- [Full English manual (.pdf)](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/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.2/Controller-Studio-for-LiveProfessor-Notice-FR.pdf) -- [SHA-256 checksums](https://github.com/Mamat79/Controller-Studio-for-LiveProfessor/releases/download/v2026.2/SHA256SUMS.txt) +- [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) [Lire cette présentation en français](README.md) -> **Public V.2026.2 Windows release.** Controller Studio always works on a new AutoMap copy and keeps the source `.rack2` project untouched. +> **Public V.2026.3 Windows release.** Controller Studio always works on a new AutoMap copy and keeps the source `.rack2` project untouched. ## What does Controller Studio do? @@ -36,7 +36,7 @@ Controller Studio turns a MIDI controller into an organized control surface for | Live control | Controller bank | Plugin Studio | AutoMap | |---|---|---|---| -| Real-time EC4 driver, banks, push, Shift, labels, values and reconnection | Ready-to-export profiles and a controller editor | Names received from LiveProfessor, priorities and parameter-by-parameter selection | Plug-in and instance selection, UniBank or FullBank, validated `.rack2` copy | +| Real-time EC4 driver, banks, push, Shift, labels, values and reconnection | Ready-to-export profiles and a controller editor | Real names read from installed plug-ins, batch retrieval, priorities and individual checkboxes | Plug-in and instance selection, UniBank or FullBank, validated `.rack2` copy | The interface is available in French and English, minimizes to the notification area and keeps the real-time log in a separate window. @@ -50,21 +50,25 @@ Controller Studio reloads the generated copy before presenting it. Existing manu ## Plugin Studio -A `.rack2` file stores parameter order and identifiers, but not always their human-readable labels. Plugin Studio completes that data with names returned by the LiveProfessor Companion controller. +A `.rack2` file stores parameter order and identifiers, but not always their human-readable labels. Plugin Studio retrieves that data directly from installed VST3 plug-ins, each in an isolated process. A result is accepted only when its parameter count exactly matches the LiveProfessor project. + +After project analysis, **Retrieve all real names** processes every plug-in type in one operation, creates or updates local profiles, and automatically backs up previous versions. For one plug-in: -1. select its instance in LiveProfessor; -2. recall **SiLeMI/O AutoMap - PluginName** from the top bar — not the Dynamic map; -3. click **Automatically retrieve real names**; -4. use **Select all**, **Select none** or the individual checkboxes; -5. adjust the short label, kind, technical role or priority if needed, then save the local profile. +1. open the plug-in profile and click **Automatically retrieve real names**; +2. use **Select all**, **Select none** or the individual checkboxes; +3. adjust the short label, kind, technical role or priority if needed, then save the local profile. + +If an older format or a particular plug-in does not provide a directly compatible inventory, Controller Studio automatically offers LiveProfessor Companion/OSC feedback as a second method. The link between each Controller Map slot and the internal parameter identifier is preserved, preventing a correct control from receiving another parameter’s label. +VST is a registered trademark of Steinberg Media Technologies GmbH. Third-party notices are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + ## Built-in controller bank -V.2026.2 includes ten declarative profiles ready to export: +V.2026.3 includes ten declarative profiles ready to export: - Faderfox EC4, PC4, UC4 and PC12; - Behringer X-Touch Compact, X-Touch Mini and X-Touch One; diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..0d64c99 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,31 @@ +# Third-party notices + +Controller Studio contains a minimal interoperability implementation based on +the public VST 3 plug-in interfaces published by Steinberg Media Technologies +GmbH. It does not include or redistribute any third-party plug-in. + +VST is a registered trademark of Steinberg Media Technologies GmbH. + +## VST 3 interfaces + +MIT License + +Copyright (c) 2025, Steinberg Media Technologies GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/windows/SiLeMIO-Controller-Studio.iss b/packaging/windows/SiLeMIO-Controller-Studio.iss index ab581a6..fcac2cd 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.2" +#define MyAppVersion "2026.3" #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.2.0.0 +VersionInfoVersion=2026.3.0.0 VersionInfoCompany={#MyAppPublisher} VersionInfoDescription={#MyAppName} installer VersionInfoProductName={#MyAppName} @@ -42,6 +42,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Files] Source: "{#ProjectRoot}\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#ProjectRoot}\THIRD_PARTY_NOTICES.md"; DestDir: "{app}"; Flags: ignoreversion [InstallDelete] Type: files; Name: "{app}\Uninstall-SiLeMIO-Controller-Studio.ps1" diff --git a/packaging/windows/build_setup.ps1 b/packaging/windows/build_setup.ps1 index 12e2598..e1c807d 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.2.exe' +$setup = Join-Path $projectRoot 'dist\Controller-Studio-for-LiveProfessor-Setup-v2026.3.exe' $checksum = "$setup.sha256" if ($RebuildApplication -or -not (Test-Path -LiteralPath $application -PathType Leaf)) { diff --git a/packaging/windows/gui_entry.py b/packaging/windows/gui_entry.py index d97fd18..97e9d75 100644 --- a/packaging/windows/gui_entry.py +++ b/packaging/windows/gui_entry.py @@ -28,6 +28,10 @@ def packaged_smoke_test() -> int: if __name__ == "__main__": if "--smoke-test" in sys.argv[1:]: raise SystemExit(packaged_smoke_test()) + if "--vst3-scan-worker" in sys.argv[1:]: + from silemio_control_hub.vst3_scanner import worker_main + + raise SystemExit(worker_main(sys.argv[1:])) from silemio_control_hub.desktop import main raise SystemExit(main()) diff --git a/packaging/windows/install.ps1 b/packaging/windows/install.ps1 index 2151c77..958ca30 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.2' +Set-ItemProperty -Path $uninstallKey -Name DisplayVersion -Value '2026.3' 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 83a762c..85ef327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "silemio-control-hub" -version = "2026.2" +version = "2026.3" 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 9f15e22..5334e5c 100644 --- a/scripts/generate_manuals.py +++ b/scripts/generate_manuals.py @@ -604,13 +604,13 @@ def english_content() -> dict: [ ("1", "Ouvrez l'onglet Plug-ins et choisissez un projet .rack2."), ("2", "Analysez le projet : les instances identiques sont regroupées automatiquement."), - ("3", "Ouvrez le profil d'un type de plug-in."), - ("4", "Dans la barre supérieure de LiveProfessor, rappelez SiLeMI/O AutoMap - NomDuPlugin, et non la map Dynamic."), - ("5", "Cliquez sur Récupérer automatiquement les vrais noms."), - ("6", "Utilisez Tout cocher, Tout décocher ou les cases individuelles pour choisir les paramètres AutoMap."), - ("7", "Corrigez si nécessaire les noms et priorités, enregistrez localement, puis utilisez le profil dans AutoMap."), + ("3", "Cliquez sur Récupérer tous les vrais noms pour traiter tous les types installés dans des processus isolés."), + ("4", "Seuls les inventaires dont le nombre correspond exactement au projet sont enregistrés."), + ("5", "Ouvrez un profil pour utiliser Tout cocher, Tout décocher ou les cases individuelles."), + ("6", "Corrigez si nécessaire les noms et priorités, enregistrez localement, puis utilisez le profil dans AutoMap."), ], ), + ("callout", "Pour un format ancien ou un plug-in atypique, le bouton individuel propose automatiquement l'interception Companion/OSC comme solution de secours."), ("h2", "Profils locaux sûrs"), ( "p", @@ -745,7 +745,8 @@ def english_content() -> dict: "4. Organize parameters with Plugin Studio", [ ("h2", "Recognize a plug-in"), - ("steps", [("1", "Open the Plug-ins tab and choose a .rack2 project."), ("2", "Analyze the project; matching instances are grouped automatically."), ("3", "Open the profile for one plug-in type."), ("4", "In the LiveProfessor top bar, recall SiLeMI/O AutoMap - PluginName, not the Dynamic map."), ("5", "Click Automatically retrieve real names."), ("6", "Use Select all, Select none, or individual checkboxes to choose AutoMap parameters."), ("7", "Adjust names and priorities if needed, save locally, then use the profile in AutoMap.")]), + ("steps", [("1", "Open the Plug-ins tab and choose a .rack2 project."), ("2", "Analyze the project; matching instances are grouped automatically."), ("3", "Click Retrieve all real names to process every installed type in isolated workers."), ("4", "Only inventories whose count exactly matches the project are saved."), ("5", "Open a profile to use Select all, Select none, or individual checkboxes."), ("6", "Adjust names and priorities if needed, save locally, then use the profile in AutoMap.")]), + ("callout", "For an older format or an unusual plug-in, the individual button automatically offers Companion/OSC interception as a fallback."), ("h2", "Safe local profiles"), ("p", "Profiles are declarative JSON files. Replaced versions can be backed up, downloaded code is never executed, and the analyzed project remains unchanged."), ], diff --git a/src/silemio_control_hub/__init__.py b/src/silemio_control_hub/__init__.py index 6732517..32b299f 100644 --- a/src/silemio_control_hub/__init__.py +++ b/src/silemio_control_hub/__init__.py @@ -48,4 +48,4 @@ "prepare_liveprofessor_project", ] -__version__ = "2026.2" +__version__ = "2026.3" diff --git a/src/silemio_control_hub/desktop.py b/src/silemio_control_hub/desktop.py index 73b75ef..744107c 100644 --- a/src/silemio_control_hub/desktop.py +++ b/src/silemio_control_hub/desktop.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, replace from pathlib import Path +import hashlib import os import queue import re @@ -27,7 +28,11 @@ total_display_message, ) from .adapters.hosts import export_liveprofessor_controller -from .adapters.hosts.liveprofessor_automap import ProjectInventory, inspect_project +from .adapters.hosts.liveprofessor_automap import ( + ProjectInventory, + inspect_plugin_parameter_slots, + inspect_project, +) from .adapters.hosts.liveprofessor_controller import ( bank_rotary_count, default_companion_template, @@ -67,8 +72,10 @@ capture_liveprofessor_parameter_names, compatible_user_profile, editable_parameters, + merge_scanned_parameter_names, next_user_profile_version, request_liveprofessor_companion_names, + retrieve_installed_parameter_names, save_user_profile, ) from .registry import ControllerRegistry @@ -358,6 +365,26 @@ "plugin_status_analysis": "{types} type(s) reconnu(s), {instances} instance(s).", "plugin_analysis_ready": "Analyse terminée : {types} type(s), {instances} instance(s).", "plugin_analysis_error": "Analyse des plug-ins impossible", + "plugin_scan_all": "Récupérer tous les vrais noms", + "plugin_scan_all_title": "Récupérer tous les noms de paramètres", + "plugin_scan_all_confirm": ( + "Controller Studio va lire les paramètres exposés par les {types} types de " + "plug-ins installés, chacun dans un processus isolé. Seuls les inventaires " + "dont le nombre correspond exactement au projet seront enregistrés. Les " + "profils locaux existants seront sauvegardés avant mise à jour.\n\n" + "Le projet .rack2 ne sera jamais modifié. Continuer ?" + ), + "plugin_scan_all_running": "Lecture des plug-ins installés…", + "plugin_scan_all_progress": "Lecture {current}/{total} : {name}", + "plugin_scan_all_success": ( + "{saved} profil(s) mis à jour avec les vrais noms.\n" + "{skipped} plug-in(s) ignoré(s).{details}" + ), + "plugin_scan_all_details": ( + "\n\nDétails :\n{details}\n\nOuvrez le profil d'un plug-in ignoré pour " + "utiliser automatiquement le secours LiveProfessor." + ), + "plugin_scan_all_none": "Aucun profil n'a pu être mis à jour.{details}", "plugin_edit": "Créer / modifier le profil…", "plugin_use_automap": "Utiliser dans AutoMap", "plugin_open_folder": "Dossier des profils", @@ -373,7 +400,7 @@ "plugin_kind_meter": "Mesure", "plugin_editor_title": "Profil de plug-in — {name}", "plugin_editor_intro": ( - "Récupérez automatiquement les vrais noms depuis LiveProfessor, puis choisissez " + "Lisez automatiquement les vrais noms dans le plug-in installé, puis choisissez " "précisément les paramètres à inclure dans AutoMap." ), "plugin_editor_instances": "{instances} instance(s) • {parameters} paramètre(s)", @@ -383,19 +410,31 @@ "plugin_select_none": "Tout décocher", "plugin_enabled_count": "{enabled}/{total} paramètre(s) inclus", "plugin_capture_names": "Récupérer automatiquement les vrais noms", - "plugin_capture_names_title": "Récupérer les noms depuis LiveProfessor", + "plugin_capture_names_title": "Récupérer les vrais noms", + "plugin_scan_direct_busy": "Lecture du plug-in installé…", + "plugin_scan_direct_success": ( + "{count} vrai(s) nom(s) lu(s) directement dans le plug-in installé. " + "Vérifiez-les avant d'enregistrer." + ), + "plugin_scan_fallback": ( + "La lecture directe de « {name} » n'a pas abouti :\n{error}\n\n" + "Voulez-vous essayer l'interception du retour LiveProfessor ?" + ), "plugin_capture_names_ready": ( "Dans LiveProfessor, sélectionnez une instance de « {name} ». Dans la barre " "du haut, cliquez sur le nom de la map et choisissez exactement " - "« SiLeMI/O AutoMap - {name} » — pas la map Dynamic. Cliquez ensuite sur OK : " - "Controller Studio demandera les noms sans modifier le projet." + "« SiLeMI/O AutoMap - {name} » — pas la map Dynamic. Si les libellés sont " + "visibles sur votre contrôleur, cliquez sur OK. Sinon, changez brièvement de " + "plug-in puis revenez sur « {name} » : Controller Studio intercepte les noms " + "envoyés au contrôleur sans modifier le projet." ), - "plugin_capture_busy": "Récupération en cours…", + "plugin_capture_busy": "Interception des noms en cours…", "plugin_capture_error": "Récupération impossible : {error}", "plugin_capture_missing": ( "Aucun vrai nom n’a été reçu. Vérifiez que la bonne instance est sélectionnée " "et que « SiLeMI/O AutoMap - {name} » est la map active dans LiveProfessor " - "(la map Dynamic ne convient pas à cette capture), puis réessayez." + "(la map Dynamic ne convient pas), changez de plug-in puis revenez sur cette " + "instance avant de réessayer." ), "plugin_capture_no_map": ( "Le projet analysé ne contient pas encore de Controller Map exploitable pour " @@ -762,6 +801,26 @@ "plugin_status_analysis": "{types} type(s) recognized, {instances} instance(s).", "plugin_analysis_ready": "Analysis complete: {types} type(s), {instances} instance(s).", "plugin_analysis_error": "Could not analyze plug-ins", + "plugin_scan_all": "Retrieve all real names", + "plugin_scan_all_title": "Retrieve all parameter names", + "plugin_scan_all_confirm": ( + "Controller Studio will read the parameters exported by the {types} installed " + "plug-in types, each in an isolated process. Only inventories whose count " + "exactly matches the project will be saved. Existing local profiles will be " + "backed up before they are updated.\n\nThe .rack2 project will never be " + "modified. Continue?" + ), + "plugin_scan_all_running": "Reading installed plug-ins…", + "plugin_scan_all_progress": "Reading {current}/{total}: {name}", + "plugin_scan_all_success": ( + "{saved} profile(s) updated with real names.\n" + "{skipped} plug-in(s) skipped.{details}" + ), + "plugin_scan_all_details": ( + "\n\nDetails:\n{details}\n\nOpen a skipped plug-in profile to " + "automatically use the LiveProfessor fallback." + ), + "plugin_scan_all_none": "No profile could be updated.{details}", "plugin_edit": "Create / edit profile…", "plugin_use_automap": "Use in AutoMap", "plugin_open_folder": "Profiles folder", @@ -777,8 +836,8 @@ "plugin_kind_meter": "Meter", "plugin_editor_title": "Plug-in profile — {name}", "plugin_editor_intro": ( - "Automatically retrieve the real names from LiveProfessor, then choose exactly " - "which parameters AutoMap should include." + "Automatically read the real names from the installed plug-in, then choose " + "exactly which parameters AutoMap should include." ), "plugin_editor_instances": "{instances} instance(s) • {parameters} parameter(s)", "plugin_parameter_enabled": "AutoMap", @@ -787,19 +846,30 @@ "plugin_select_none": "Select none", "plugin_enabled_count": "{enabled}/{total} parameter(s) included", "plugin_capture_names": "Automatically retrieve real names", - "plugin_capture_names_title": "Get names from LiveProfessor", + "plugin_capture_names_title": "Retrieve real names", + "plugin_scan_direct_busy": "Reading the installed plug-in…", + "plugin_scan_direct_success": ( + "{count} real name(s) read directly from the installed plug-in. " + "Review them before saving." + ), + "plugin_scan_fallback": ( + "Direct inspection of “{name}” did not succeed:\n{error}\n\n" + "Would you like to try intercepting LiveProfessor feedback?" + ), "plugin_capture_names_ready": ( "In LiveProfessor, select an instance of “{name}”. In the top bar, click the " "map name and choose exactly “SiLeMI/O AutoMap - {name}” — not the Dynamic " - "map. Then click OK: Controller Studio will request the names without changing " - "the project." + "map. If the labels are visible on your controller, click OK. Otherwise, " + "briefly select another plug-in and return to “{name}”: Controller Studio " + "intercepts the names sent to the controller without changing the project." ), - "plugin_capture_busy": "Retrieving names…", + "plugin_capture_busy": "Intercepting names…", "plugin_capture_error": "Could not retrieve names: {error}", "plugin_capture_missing": ( "No real name was received. Check that the correct instance is selected and " "“SiLeMI/O AutoMap - {name}” is the active map in LiveProfessor (the Dynamic " - "map cannot be used for this capture), then try again." + "map cannot be used), select another plug-in and return to this instance, " + "then try again." ), "plugin_capture_no_map": ( "The analyzed project does not yet contain a usable Controller Map for this " @@ -1028,6 +1098,7 @@ def __init__(self, root: tk.Tk) -> None: self._plugin_analysis: PluginProjectAnalysis | None = None self._plugin_summary_by_iid: dict[str, PluginTypeSummary] = {} self._plugin_analysis_running = False + self._plugin_batch_running = False self._plugin_analysis_results: queue.SimpleQueue[ tuple[PluginProjectAnalysis | None, PluginProfileRegistry | None, str | None] ] = queue.SimpleQueue() @@ -1560,26 +1631,33 @@ def _build_plugins_tab(self, parent: ttk.Frame) -> None: textvariable=self.plugin_analysis_status_var, style="Subtitle.TLabel", ).grid(row=0, column=0, sticky="w") + self.plugin_scan_all_button = ttk.Button( + toolbar, + text=self._t("plugin_scan_all"), + command=self._scan_all_plugin_names, + state="disabled", + style="Accent.TButton", + ) + self.plugin_scan_all_button.grid(row=0, column=1, padx=(8, 0)) self.plugin_edit_button = ttk.Button( toolbar, text=self._t("plugin_edit"), command=self._open_plugin_profile_editor, state="disabled", - style="Accent.TButton", ) - self.plugin_edit_button.grid(row=0, column=1, padx=(8, 0)) + self.plugin_edit_button.grid(row=0, column=2, padx=(8, 0)) self.plugin_automap_button = ttk.Button( toolbar, text=self._t("plugin_use_automap"), command=self._use_plugin_project_in_automap, state="disabled", ) - self.plugin_automap_button.grid(row=0, column=2, padx=(8, 0)) + self.plugin_automap_button.grid(row=0, column=3, padx=(8, 0)) ttk.Button( toolbar, text=self._t("plugin_open_folder"), command=self._open_plugin_profile_folder, - ).grid(row=0, column=3, padx=(8, 0)) + ).grid(row=0, column=4, padx=(8, 0)) tree_frame = ttk.Frame(parent) tree_frame.grid(row=4, column=0, sticky="nsew") @@ -1681,6 +1759,7 @@ def _populate_plugin_catalog(self, profiles) -> None: self._t("plugin_catalog_status", profiles=len(profiles)) ) self.plugin_edit_button.configure(state="disabled") + self.plugin_scan_all_button.configure(state="disabled") self.plugin_automap_button.configure(state="disabled") def _populate_plugin_analysis(self, analysis: PluginProjectAnalysis) -> None: @@ -1715,6 +1794,9 @@ def _populate_plugin_analysis(self, analysis: PluginProjectAnalysis) -> None: ) ) self.plugin_edit_button.configure(state="disabled") + self.plugin_scan_all_button.configure( + state="normal" if analysis.plugin_types else "disabled" + ) self.plugin_automap_button.configure(state="normal") if analysis.plugin_types: first = "plugin-type-0" @@ -1729,7 +1811,12 @@ def _selected_plugin_summary(self) -> PluginTypeSummary | None: return self._plugin_summary_by_iid.get(selected[0]) def _select_plugin_type(self, _event=None) -> None: - state = "normal" if self._selected_plugin_summary() is not None else "disabled" + state = ( + "normal" + if self._selected_plugin_summary() is not None + and not self._plugin_batch_running + else "disabled" + ) self.plugin_edit_button.configure(state=state) def _browse_plugin_project(self) -> None: @@ -1749,6 +1836,7 @@ def _analyze_plugin_project(self) -> None: self._plugin_analysis_running = True self.plugin_analysis_status_var.set(self._t("plugin_status_running")) self.plugin_edit_button.configure(state="disabled") + self.plugin_scan_all_button.configure(state="disabled") self.plugin_automap_button.configure(state="disabled") def worker() -> None: @@ -1783,6 +1871,7 @@ def _finish_plugin_analysis( self._plugin_analysis = None self.plugin_analysis_status_var.set(error or self._t("plugin_analysis_error")) self.plugin_edit_button.configure(state="disabled") + self.plugin_scan_all_button.configure(state="disabled") self.plugin_automap_button.configure(state="disabled") messagebox.showerror( self._t("plugin_analysis_error"), @@ -1822,6 +1911,140 @@ def _use_plugin_project_in_automap(self) -> None: self.automap_project_var.set(str(analysis.path)) self._analyze_automap_project() + def _scan_all_plugin_names(self) -> None: + analysis = self._plugin_analysis + if analysis is None or self._plugin_batch_running: + return + if not messagebox.askyesno( + self._t("plugin_scan_all_title"), + self._t("plugin_scan_all_confirm", types=len(analysis.plugin_types)), + parent=self.root, + ): + return + + self._plugin_batch_running = True + self.plugin_scan_all_button.configure(state="disabled") + self.plugin_edit_button.configure(state="disabled") + self.plugin_automap_button.configure(state="disabled") + self.plugin_analysis_status_var.set(self._t("plugin_scan_all_running")) + + def show_progress(current: int, name: str) -> None: + if self._closing: + return + self.plugin_analysis_status_var.set( + self._t( + "plugin_scan_all_progress", + current=current, + total=len(analysis.plugin_types), + name=name, + ) + ) + + def worker() -> None: + prepared: list[tuple[PluginTypeSummary, object]] = [] + errors: list[str] = [] + try: + profiles = list(PluginProfileRegistry().all()) + for index, summary in enumerate(analysis.plugin_types, start=1): + self.root.after( + 0, + lambda current=index, name=summary.observation.name: show_progress( + current, name + ), + ) + try: + scan = retrieve_installed_parameter_names(summary) + parameters = merge_scanned_parameter_names( + editable_parameters(summary.resolved), + scan, + ) + current_profile = compatible_user_profile( + profiles, + summary.observation, + ) + profile = build_user_profile( + summary.observation, + parameters, + profile_id=( + current_profile.id + if current_profile is not None + else None + ), + profile_version=next_user_profile_version( + profiles, + summary.observation, + ), + ) + prepared.append((summary, profile)) + profiles.append(profile) + except Exception as exc: + errors.append(f"{summary.observation.name}: {exc}") + + current_hash = hashlib.sha256(analysis.path.read_bytes()).hexdigest().upper() + if current_hash != analysis.source_sha256: + prepared.clear() + errors.append( + "Le projet LiveProfessor a changé pendant la lecture ; " + "aucun profil n'a été enregistré." + ) + + saved = 0 + for summary, profile in prepared: + try: + destination = ( + default_user_plugin_profile_dir() / f"{profile.id}.json" + ) + save_user_profile(profile, replace=destination.exists()) + saved += 1 + except Exception as exc: + errors.append(f"{summary.observation.name}: {exc}") + except Exception as exc: + saved = 0 + errors.append(str(exc)) + + self.root.after(0, lambda: finish(saved, errors)) + + def finish(saved: int, errors: list[str]) -> None: + self._plugin_batch_running = False + if self._closing: + return + detail_lines = errors[:8] + if len(errors) > len(detail_lines): + detail_lines.append(f"… +{len(errors) - len(detail_lines)}") + details = ( + self._t("plugin_scan_all_details", details="\n".join(detail_lines)) + if detail_lines + else "" + ) + if saved: + body = self._t( + "plugin_scan_all_success", + saved=saved, + skipped=len(errors), + details=details, + ) + if errors: + messagebox.showwarning( + self._t("plugin_scan_all_title"), body, parent=self.root + ) + else: + messagebox.showinfo( + self._t("plugin_scan_all_title"), body, parent=self.root + ) + else: + messagebox.showerror( + self._t("plugin_scan_all_title"), + self._t("plugin_scan_all_none", details=details), + parent=self.root, + ) + self._analyze_plugin_project() + + threading.Thread( + target=worker, + name="installed-plugin-batch-scanner", + daemon=True, + ).start() + def _open_plugin_profile_editor(self) -> None: summary = self._selected_plugin_summary() if summary is None: @@ -1990,13 +2213,17 @@ def toggle_selected_parameter(_event=None) -> str: command=lambda: set_all_parameters(False), ).pack(side="left", padx=(8, 0)) - def capture_names_from_liveprofessor() -> None: + def begin_liveprofessor_capture() -> None: nonlocal parameters if not messagebox.askokcancel( self._t("plugin_capture_names_title"), self._t("plugin_capture_names_ready", name=summary.observation.name), parent=window, ): + capture_button.configure( + state="normal", + text=self._t("plugin_capture_names"), + ) return capture_button.configure( state="disabled", @@ -2060,9 +2287,33 @@ def fail_capture(error: str) -> None: runtime = self.runtime if runtime is not None and runtime.running: - runtime.clear_companion_names() - runtime.refresh_companion() - window.after(900, lambda: finish_capture(tuple(runtime.names))) + analysis = self._plugin_analysis + if analysis is None: + fail_capture("aucun projet LiveProfessor analysé") + return + required_slots = tuple( + inspect_plugin_parameter_slots( + analysis.path, + plugin_uid=summary.instances[0].plugin_uid, + ) + ) + + def collect_runtime_names() -> None: + try: + live_names = runtime.capture_companion_names( + required_indices=required_slots, + ) + except Exception as exc: + error = str(exc) + window.after(0, lambda: fail_capture(error)) + return + window.after(0, lambda: finish_capture(live_names)) + + threading.Thread( + target=collect_runtime_names, + name="liveprofessor-runtime-name-capture", + daemon=True, + ).start() return try: @@ -2093,10 +2344,74 @@ def collect_names() -> None: daemon=True, ).start() + def capture_names_automatically() -> None: + nonlocal parameters + capture_button.configure( + state="disabled", + text=self._t("plugin_scan_direct_busy"), + ) + + def finish_direct_scan(scan) -> None: + nonlocal parameters + try: + parameters = list( + merge_scanned_parameter_names(parameters, scan) + ) + except Exception as exc: + offer_liveprofessor_fallback(str(exc)) + return + capture_button.configure( + state="normal", + text=self._t("plugin_capture_names"), + ) + for index in range(len(parameters)): + refresh_parameter_row(index) + refresh_included_count() + load_parameter() + messagebox.showinfo( + self._t("plugin_capture_names_title"), + self._t( + "plugin_scan_direct_success", + count=len(parameters), + ), + parent=window, + ) + + def offer_liveprofessor_fallback(error: str) -> None: + capture_button.configure( + state="normal", + text=self._t("plugin_capture_names"), + ) + if messagebox.askyesno( + self._t("plugin_capture_names_title"), + self._t( + "plugin_scan_fallback", + name=summary.observation.name, + error=error, + ), + parent=window, + ): + begin_liveprofessor_capture() + + def scan_installed_plugin() -> None: + try: + scan = retrieve_installed_parameter_names(summary) + except Exception as exc: + error = str(exc) + window.after(0, lambda: offer_liveprofessor_fallback(error)) + return + window.after(0, lambda: finish_direct_scan(scan)) + + threading.Thread( + target=scan_installed_plugin, + name="installed-plugin-name-scanner", + daemon=True, + ).start() + capture_button = ttk.Button( selection_bar, text=self._t("plugin_capture_names"), - command=capture_names_from_liveprofessor, + command=capture_names_automatically, style="Accent.TButton", ) capture_button.pack(side="left", padx=(16, 0)) diff --git a/src/silemio_control_hub/library_remote.py b/src/silemio_control_hub/library_remote.py index 11e8b58..a9b7f07 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.2.dev0" + return "2026.3.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 95bc7ff..a21118a 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 748e769..8accdb1 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/plugin_studio.py b/src/silemio_control_hub/plugin_studio.py index cb65b35..da8cd7b 100644 --- a/src/silemio_control_hub/plugin_studio.py +++ b/src/silemio_control_hub/plugin_studio.py @@ -23,6 +23,7 @@ ) from .plugin_profiles import ( PluginObservation, + PluginParameterKind, PluginParameterProfile, PluginProfile, PluginProfileError, @@ -33,6 +34,7 @@ ) from .plugin_registry import default_user_plugin_profile_dir from .transports.osc import OSCClient, OSCServer +from .vst3_scanner import VST3ScanResult, scan_installed_vst3 @dataclass(frozen=True, slots=True) @@ -71,14 +73,17 @@ def request_liveprofessor_companion_names( feedback_host: str, feedback_port: int, max_controls: int = 512, - timeout: float = 1.5, - quiet_period: float = 0.15, + timeout: float = 30.0, + quiet_period: float = 0.35, + retry_interval: float = 2.0, ) -> tuple[str, ...]: """Request Companion labels without starting a hardware controller runtime. LiveProfessor sends one ``/Companion/ControllerNames`` message per mapped - rotary. Collection stops shortly after the last label so the desktop stays - responsive while still accepting larger Controller Maps. + rotary. Depending on the active Controller Map, that inventory can arrive + several seconds after a refresh request. Requests are therefore retried + until the first label arrives, then collection stops shortly after the last + label so larger Controller Maps are accepted without a fixed short delay. """ if not 1 <= int(request_port) <= 65535 or not 1 <= int(feedback_port) <= 65535: @@ -124,18 +129,26 @@ def receive(address: str, args: list[object]) -> None: raise PluginProfileError( f"le port de retour OSC {feedback_host}:{feedback_port} est indisponible: {exc}" ) from exc - client.send("/init") - client.send("/refresh") - client.send("/ViewSets/Refresh") - deadline = time.monotonic() + max(0.05, timeout) - first_name.wait(max(0.05, timeout)) - while first_name.is_set() and time.monotonic() < deadline: - with lock: - quiet_for = time.monotonic() - last_update[0] - if quiet_for >= max(0.0, quiet_period): + next_request = 0.0 + retry_after = max(0.05, retry_interval) + settle_after = max(0.0, quiet_period) + while time.monotonic() < deadline: + now = time.monotonic() + if now >= next_request: + client.send("/init") + client.send("/refresh") + client.send("/ViewSets/Refresh") + next_request = now + retry_after + if first_name.is_set(): + with lock: + quiet_for = time.monotonic() - last_update[0] + if quiet_for >= settle_after: + break + remaining = deadline - time.monotonic() + if remaining <= 0: break - time.sleep(min(0.02, max(0.001, deadline - time.monotonic()))) + time.sleep(min(0.02, max(0.001, remaining))) except OSError as exc: raise PluginProfileError(f"communication OSC impossible: {exc}") from exc finally: @@ -236,6 +249,59 @@ def editable_parameters( ) +def retrieve_installed_parameter_names( + summary: PluginTypeSummary, + *, + database: Path | None = None, + timeout: float = 20.0, +) -> VST3ScanResult: + """Read the exact parameter inventory exported by an installed plug-in. + + The scanner itself runs out of process. Its result is accepted only when + the exported parameter count exactly matches the LiveProfessor project. + """ + + return scan_installed_vst3( + summary.observation.name, + expected_parameter_count=len(summary.observation.parameters), + plugin_format=summary.observation.plugin_format, + database=database, + timeout=timeout, + ) + + +def merge_scanned_parameter_names( + parameters: Sequence[PluginParameterProfile], + scan: VST3ScanResult, +) -> tuple[PluginParameterProfile, ...]: + """Merge a verified VST3 inventory without losing the user's choices.""" + + if len(parameters) != len(scan.parameters): + raise PluginProfileError( + f"inventaire incompatible : {len(scan.parameters)} noms pour " + f"{len(parameters)} paramètres LiveProfessor" + ) + updated: list[PluginParameterProfile] = [] + for index, (current, scanned) in enumerate(zip(parameters, scan.parameters)): + if scanned.index != index: + raise PluginProfileError("l'ordre du scanner de plug-ins est invalide") + kind = current.kind + if scanned.step_count == 1: + kind = PluginParameterKind.TOGGLE + elif scanned.step_count > 1: + kind = PluginParameterKind.ENUM + updated.append( + replace( + current, + name=scanned.name, + short_label=compact_label(scanned.name, fallback_index=index), + unit=scanned.unit or current.unit, + kind=kind, + ) + ) + return tuple(updated) + + def capture_liveprofessor_parameter_names( parameters: Sequence[PluginParameterProfile], *, diff --git a/src/silemio_control_hub/runtime/ec4_liveprofessor.py b/src/silemio_control_hub/runtime/ec4_liveprofessor.py index 92a0231..f91ea53 100644 --- a/src/silemio_control_hub/runtime/ec4_liveprofessor.py +++ b/src/silemio_control_hub/runtime/ec4_liveprofessor.py @@ -8,7 +8,7 @@ import re import threading import time -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from typing import Any @@ -99,6 +99,7 @@ def __init__( self._running = False self._stop = threading.Event() self._lock = threading.RLock() + self._last_companion_name_update = 0.0 self._midi = MidiConnection(self._on_midi) self._osc_client = OSCClient(config.liveprofessor_host, config.liveprofessor_port) self._osc_server = OSCServer( @@ -302,6 +303,74 @@ def clear_companion_names(self) -> None: self._received_control_numbers.clear() self._received_companion_names = False + def companion_names_snapshot(self) -> tuple[str, ...]: + """Return the latest labels intercepted from LiveProfessor.""" + + with self._lock: + return tuple(self.names) + + def capture_companion_names( + self, + *, + required_indices: Iterable[int] = (), + timeout: float = 30.0, + quiet_period: float = 0.35, + retry_interval: float = 2.0, + ) -> tuple[str, ...]: + """Capture labels for Plugin Studio without discarding live feedback. + + Controller-name feedback is continuously received by the running + bridge. When the selected plug-in already appears on the hardware, + the cached labels are the most reliable and fastest source. If no + useful mapped label is cached, refresh requests are retried while the + existing OSC listener keeps collecting the replies. + """ + + required = tuple( + sorted( + { + int(index) + for index in required_indices + if 0 <= int(index) < len(self.names) + } + ) + ) + + def useful_count(snapshot: tuple[str, ...]) -> int: + indices = required or tuple(range(len(snapshot))) + return sum(bool(snapshot[index].strip()) for index in indices) + + retry_after = max(0.05, retry_interval) + settle_after = max(0.0, quiet_period) + snapshot = self.companion_names_snapshot() + with self._lock: + cached_quiet_for = time.monotonic() - self._last_companion_name_update + if useful_count(snapshot) and cached_quiet_for >= settle_after: + self._log("Noms Companion deja recus: capture directe du controleur actif") + return snapshot + + deadline = time.monotonic() + max(0.05, timeout) + next_request = 0.0 + while time.monotonic() < deadline: + now = time.monotonic() + if now >= next_request: + self.refresh_companion(log_request=next_request == 0.0) + next_request = now + retry_after + + snapshot = self.companion_names_snapshot() + if useful_count(snapshot): + with self._lock: + quiet_for = time.monotonic() - self._last_companion_name_update + if quiet_for >= settle_after: + return snapshot + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(0.02, max(0.001, remaining))) + + return self.companion_names_snapshot() + def _schedule_companion_refresh(self, delay: float | None = None) -> None: if self.config.mode != "companion": return @@ -1240,9 +1309,11 @@ def _update_name(self, index: int, name: str) -> None: if not 0 <= index < self.config.max_controls: return name = self._coerce_companion_name(index, name) - changed = name != self.names[index] - self.names[index] = name - self.short_names[index] = "" if not name else short_label(name, index) + with self._lock: + changed = name != self.names[index] + self.names[index] = name + self.short_names[index] = "" if not name else short_label(name, index) + self._last_companion_name_update = time.monotonic() if changed and index // self.config.bank_size == self.active_bank: self._schedule_name_refresh() diff --git a/src/silemio_control_hub/vst3_scanner.py b/src/silemio_control_hub/vst3_scanner.py new file mode 100644 index 0000000..cd367c3 --- /dev/null +++ b/src/silemio_control_hub/vst3_scanner.py @@ -0,0 +1,931 @@ +"""Isolated, read-only inspection of parameters exposed by installed VST3 plug-ins. + +The worker implements only the small, stable subset of the VST3 ABI needed to +instantiate an edit controller and read ``ParameterInfo`` records. It never +loads a plug-in in the Controller Studio GUI process: a crash or a hang remains +confined to the disposable worker process. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import ctypes +import json +import os +from pathlib import Path +import struct +import subprocess +import sys +from typing import Any +import xml.etree.ElementTree as ET + + +class VST3ScanError(RuntimeError): + """Raised when an installed plug-in cannot be inspected safely.""" + + +def _debug(message: str) -> None: + if os.environ.get("SILEMIO_VST3_DEBUG") == "1": + print(f"VST3 scanner: {message}", file=sys.stderr, flush=True) + + +@dataclass(frozen=True, slots=True) +class ScannedParameter: + index: int + parameter_id: int + name: str + short_name: str + unit: str + step_count: int + flags: int + + @classmethod + def from_dict(cls, raw: object, *, index: int) -> "ScannedParameter": + if not isinstance(raw, dict): + raise VST3ScanError("réponse de paramètre invalide") + try: + actual_index = int(raw["index"]) + parameter_id = int(raw["parameter_id"]) + name = str(raw["name"]).strip() + short_name = str(raw.get("short_name", "")).strip() + unit = str(raw.get("unit", "")).strip() + step_count = int(raw.get("step_count", 0)) + flags = int(raw.get("flags", 0)) + except (KeyError, TypeError, ValueError) as exc: + raise VST3ScanError("réponse de paramètre invalide") from exc + if actual_index != index or not name or step_count < 0: + raise VST3ScanError("ordre ou nom de paramètre invalide") + return cls( + index=actual_index, + parameter_id=parameter_id, + name=name, + short_name=short_name, + unit=unit, + step_count=step_count, + flags=flags, + ) + + def to_dict(self) -> dict[str, object]: + return { + "index": self.index, + "parameter_id": self.parameter_id, + "name": self.name, + "short_name": self.short_name, + "unit": self.unit, + "step_count": self.step_count, + "flags": self.flags, + } + + +@dataclass(frozen=True, slots=True) +class VST3ScanResult: + plugin_name: str + class_name: str + module_path: Path + parameters: tuple[ScannedParameter, ...] + + @classmethod + def from_dict(cls, raw: object) -> "VST3ScanResult": + if not isinstance(raw, dict): + raise VST3ScanError("réponse du scanner invalide") + try: + plugin_name = str(raw["plugin_name"]).strip() + class_name = str(raw["class_name"]).strip() + module_path = Path(str(raw["module_path"])).expanduser().resolve() + raw_parameters = raw["parameters"] + except (KeyError, TypeError, ValueError, OSError) as exc: + raise VST3ScanError("réponse du scanner invalide") from exc + if not plugin_name or not class_name or not isinstance(raw_parameters, list): + raise VST3ScanError("réponse du scanner incomplète") + parameters = tuple( + ScannedParameter.from_dict(parameter, index=index) + for index, parameter in enumerate(raw_parameters) + ) + return cls(plugin_name, class_name, module_path, parameters) + + def to_dict(self) -> dict[str, object]: + return { + "plugin_name": self.plugin_name, + "class_name": self.class_name, + "module_path": str(self.module_path), + "parameters": [parameter.to_dict() for parameter in self.parameters], + } + + +def default_liveprofessor_plugin_database() -> Path: + appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + return appdata / "audiostrom" / "LiveProfessor 2" / "PluginsX64.xml" + + +def installed_plugin_paths( + plugin_name: str, + *, + plugin_format: str = "VST3", + database: Path | None = None, +) -> tuple[Path, ...]: + """Resolve exact LiveProfessor database entries for one plug-in name.""" + + requested_name = str(plugin_name).strip().casefold() + requested_format = str(plugin_format).strip().casefold() + source = Path(database or default_liveprofessor_plugin_database()) + if not source.is_file(): + raise VST3ScanError(f"base des plug-ins LiveProfessor introuvable : {source}") + try: + root = ET.parse(source).getroot() + except (ET.ParseError, OSError) as exc: + raise VST3ScanError(f"base des plug-ins LiveProfessor illisible : {exc}") from exc + + matches: list[Path] = [] + seen: set[str] = set() + for element in root.iter(): + if element.tag.rsplit("}", 1)[-1].casefold() != "plugin": + continue + if str(element.get("name", "")).strip().casefold() != requested_name: + continue + entry_format = str(element.get("format", "")).strip().casefold() + if requested_format and entry_format != requested_format: + continue + raw_path = str(element.get("file", "")).strip() + if not raw_path: + continue + candidate = Path(raw_path).expanduser() + if not candidate.exists(): + continue + resolved = candidate.resolve() + key = str(resolved).casefold() + if key not in seen: + seen.add(key) + matches.append(resolved) + if not matches: + raise VST3ScanError( + f"« {plugin_name} » est absent de la base VST3 de LiveProfessor" + ) + return tuple(matches) + + +def _worker_command(module_path: Path, expected_name: str) -> list[str]: + if getattr(sys, "frozen", False): + return [ + sys.executable, + "--vst3-scan-worker", + str(module_path), + expected_name, + ] + return [ + sys.executable, + "-m", + "silemio_control_hub.vst3_scanner", + "--worker", + str(module_path), + expected_name, + ] + + +def scan_vst3_module( + module_path: Path, + *, + expected_name: str, + timeout: float = 20.0, +) -> VST3ScanResult: + """Inspect a VST3 module in a disposable subprocess.""" + + source = Path(module_path).expanduser().resolve() + if not source.exists(): + raise VST3ScanError(f"module de plug-in introuvable : {source}") + creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + try: + completed = subprocess.run( + _worker_command(source, expected_name), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=max(1.0, float(timeout)), + creationflags=creationflags, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise VST3ScanError( + f"« {expected_name} » ne répond pas après {timeout:g} secondes" + ) from exc + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip() + if len(detail) > 500: + detail = detail[-500:] + raise VST3ScanError( + f"échec du scanner pour « {expected_name} »" + + (f" : {detail}" if detail else "") + ) + lines = [line for line in completed.stdout.splitlines() if line.strip()] + if not lines: + raise VST3ScanError(f"aucune réponse du scanner pour « {expected_name} »") + try: + payload = json.loads(lines[-1]) + except json.JSONDecodeError as exc: + raise VST3ScanError(f"réponse illisible pour « {expected_name} »") from exc + result = VST3ScanResult.from_dict(payload) + if result.module_path != source: + raise VST3ScanError("le scanner a répondu pour un autre module") + return result + + +def scan_installed_vst3( + plugin_name: str, + *, + expected_parameter_count: int, + plugin_format: str = "VST3", + database: Path | None = None, + timeout: float = 20.0, +) -> VST3ScanResult: + """Resolve and scan an installed plug-in, accepting only an exact count.""" + + if str(plugin_format).strip().casefold() != "vst3": + raise VST3ScanError( + f"le format {plugin_format} ne permet pas encore la lecture directe" + ) + errors: list[str] = [] + for path in installed_plugin_paths( + plugin_name, + plugin_format=plugin_format, + database=database, + ): + try: + result = scan_vst3_module( + path, + expected_name=plugin_name, + timeout=timeout, + ) + except VST3ScanError as exc: + errors.append(str(exc)) + continue + actual_count = len(result.parameters) + if actual_count != expected_parameter_count: + errors.append( + f"« {plugin_name} » expose {actual_count} paramètres, " + f"mais le projet LiveProfessor en contient {expected_parameter_count}" + ) + continue + return result + raise VST3ScanError(" ; ".join(errors) or f"« {plugin_name} » est introuvable") + + +def _decode_c_string(value: Any) -> str: + return bytes(value).split(b"\0", 1)[0].decode("utf-8", errors="replace").strip() + + +def _decode_string128(value: Any) -> str: + raw = ctypes.string_at(ctypes.addressof(value), ctypes.sizeof(value)) + return raw.decode("utf-16-le", errors="replace").split("\0", 1)[0].strip() + + +def _iid(l1: int, l2: int, l3: int, l4: int) -> bytes: + # VST3 uses the COM-compatible byte order for TUID values on Windows. + return ( + struct.pack("> 16) & 0xFF, (l2 >> 24) & 0xFF, l2 & 0xFF, (l2 >> 8) & 0xFF)) + + struct.pack(">II", l3, l4) + ) + + +_IID_COMPONENT = _iid(0xE831FF31, 0xF2D54301, 0x928EBBEE, 0x25697802) +_IID_EDIT_CONTROLLER = _iid(0xDCD7BBE3, 0x7742448D, 0xA874AACC, 0x979C759E) +_IID_CONNECTION_POINT = _iid(0x70A4156F, 0x6E6E4026, 0x989148BF, 0xAA60D8D1) +_IID_FUNKNOWN = _iid(0x00000000, 0x00000000, 0xC0000000, 0x00000046) +_IID_HOST_APPLICATION = _iid(0x58E595CC, 0xDB2D4969, 0x8B6AAF8C, 0x36A664E5) +_IID_COMPONENT_HANDLER = _iid(0x93A0BEA3, 0x0BD045DB, 0x8E890B0C, 0xC1E46AC6) +_IID_STREAM = _iid(0xC3BF6EA2, 0x30994752, 0x9B6BF990, 0x1EE33E9B) + + +class _PClassInfo(ctypes.Structure): + _fields_ = [ + ("cid", ctypes.c_ubyte * 16), + ("cardinality", ctypes.c_int32), + ("category", ctypes.c_char * 32), + ("name", ctypes.c_char * 64), + ] + + +class _ParameterInfo(ctypes.Structure): + _fields_ = [ + ("parameter_id", ctypes.c_uint32), + ("title", ctypes.c_uint16 * 128), + ("short_title", ctypes.c_uint16 * 128), + ("units", ctypes.c_uint16 * 128), + ("step_count", ctypes.c_int32), + ("default_normalized_value", ctypes.c_double), + ("unit_id", ctypes.c_int32), + ("flags", ctypes.c_int32), + ] + + +_CALL = ctypes.WINFUNCTYPE if os.name == "nt" else ctypes.CFUNCTYPE + + +class _HostApplication(ctypes.Structure): + _fields_ = [ + ("vtable", ctypes.POINTER(ctypes.c_void_p)), + ("reference_count", ctypes.c_uint32), + ] + + +class _HostContext: + """Minimal mandatory IHostApplication passed during plug-in initialize.""" + + def __init__(self) -> None: + query_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_void_p), + ) + ref_type = _CALL(ctypes.c_uint32, ctypes.c_void_p) + name_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_uint16), + ) + create_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_void_p), + ) + + def query(this, iid_pointer, output) -> int: + requested = ctypes.string_at(iid_pointer, 16) + if requested not in {_IID_FUNKNOWN, _IID_HOST_APPLICATION}: + output[0] = None + return -1 + output[0] = this + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + instance.reference_count += 1 + return 0 + + def add_ref(this) -> int: + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + instance.reference_count += 1 + return instance.reference_count + + def release(this) -> int: + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + if instance.reference_count: + instance.reference_count -= 1 + return instance.reference_count + + def get_name(_this, destination) -> int: + encoded = "Controller Studio".encode("utf-16-le") + b"\0\0" + ctypes.memmove(destination, encoded, min(len(encoded), 128 * 2)) + return 0 + + def create_instance(_this, _class_id, _interface_id, output) -> int: + output[0] = None + return -1 + + self.callbacks = ( + query_type(query), + ref_type(add_ref), + ref_type(release), + name_type(get_name), + create_type(create_instance), + ) + self.vtable = (ctypes.c_void_p * len(self.callbacks))( + *(ctypes.cast(callback, ctypes.c_void_p).value for callback in self.callbacks) + ) + self.instance = _HostApplication( + ctypes.cast(self.vtable, ctypes.POINTER(ctypes.c_void_p)), + 1, + ) + + @property + def pointer(self) -> ctypes.c_void_p: + return ctypes.cast(ctypes.pointer(self.instance), ctypes.c_void_p) + + +class _ComponentHandlerContext: + """Minimal mandatory IComponentHandler used during controller inspection.""" + + def __init__(self) -> None: + query_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_void_p), + ) + ref_type = _CALL(ctypes.c_uint32, ctypes.c_void_p) + edit_type = _CALL(ctypes.c_int32, ctypes.c_void_p, ctypes.c_uint32) + perform_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_double, + ) + restart_type = _CALL(ctypes.c_int32, ctypes.c_void_p, ctypes.c_int32) + + def query(this, iid_pointer, output) -> int: + requested = ctypes.string_at(iid_pointer, 16) + if requested not in {_IID_FUNKNOWN, _IID_COMPONENT_HANDLER}: + output[0] = None + return -1 + output[0] = this + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + instance.reference_count += 1 + return 0 + + def add_ref(this) -> int: + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + instance.reference_count += 1 + return instance.reference_count + + def release(this) -> int: + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + if instance.reference_count: + instance.reference_count -= 1 + return instance.reference_count + + def edit(_this, _parameter_id) -> int: + return 0 + + def perform(_this, _parameter_id, _value) -> int: + return 0 + + def restart(_this, flags) -> int: + _debug(f"component requested restart flags={flags}") + return 0 + + self.callbacks = ( + query_type(query), + ref_type(add_ref), + ref_type(release), + edit_type(edit), + perform_type(perform), + edit_type(edit), + restart_type(restart), + ) + self.vtable = (ctypes.c_void_p * len(self.callbacks))( + *(ctypes.cast(callback, ctypes.c_void_p).value for callback in self.callbacks) + ) + self.instance = _HostApplication( + ctypes.cast(self.vtable, ctypes.POINTER(ctypes.c_void_p)), + 1, + ) + + @property + def pointer(self) -> ctypes.c_void_p: + return ctypes.cast(ctypes.pointer(self.instance), ctypes.c_void_p) + + +class _MemoryStreamContext: + """Small in-memory IBStream for component-to-controller state transfer.""" + + def __init__(self) -> None: + self.data = bytearray() + self.position = 0 + query_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_void_p), + ) + ref_type = _CALL(ctypes.c_uint32, ctypes.c_void_p) + io_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_int32, + ctypes.POINTER(ctypes.c_int32), + ) + seek_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.c_int64, + ctypes.c_int32, + ctypes.POINTER(ctypes.c_int64), + ) + tell_type = _CALL( + ctypes.c_int32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_int64), + ) + + def query(this, iid_pointer, output) -> int: + requested = ctypes.string_at(iid_pointer, 16) + if requested not in {_IID_FUNKNOWN, _IID_STREAM}: + output[0] = None + return -1 + output[0] = this + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + instance.reference_count += 1 + return 0 + + def add_ref(this) -> int: + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + instance.reference_count += 1 + return instance.reference_count + + def release(this) -> int: + instance = ctypes.cast(this, ctypes.POINTER(_HostApplication)).contents + if instance.reference_count: + instance.reference_count -= 1 + return instance.reference_count + + def read(_this, buffer, count, actual) -> int: + amount = min(max(0, count), max(0, len(self.data) - self.position)) + if amount: + chunk = bytes(self.data[self.position : self.position + amount]) + ctypes.memmove(buffer, chunk, amount) + self.position += amount + if actual: + actual[0] = amount + return 0 if amount == count else 1 + + def write(_this, buffer, count, actual) -> int: + amount = max(0, count) + chunk = ctypes.string_at(buffer, amount) if amount else b"" + end = self.position + amount + if end > len(self.data): + self.data.extend(b"\0" * (end - len(self.data))) + self.data[self.position : end] = chunk + self.position = end + if actual: + actual[0] = amount + return 0 + + def seek(_this, offset, mode, result) -> int: + if mode == 0: + target = offset + elif mode == 1: + target = self.position + offset + elif mode == 2: + target = len(self.data) + offset + else: + return 1 + if target < 0: + return 1 + self.position = int(target) + if result: + result[0] = self.position + return 0 + + def tell(_this, result) -> int: + if not result: + return 1 + result[0] = self.position + return 0 + + self.callbacks = ( + query_type(query), + ref_type(add_ref), + ref_type(release), + io_type(read), + io_type(write), + seek_type(seek), + tell_type(tell), + ) + self.vtable = (ctypes.c_void_p * len(self.callbacks))( + *(ctypes.cast(callback, ctypes.c_void_p).value for callback in self.callbacks) + ) + self.instance = _HostApplication( + ctypes.cast(self.vtable, ctypes.POINTER(ctypes.c_void_p)), + 1, + ) + + @property + def pointer(self) -> ctypes.c_void_p: + return ctypes.cast(ctypes.pointer(self.instance), ctypes.c_void_p) + + +def _method( + interface: ctypes.c_void_p, + index: int, + result_type: Any, + *argument_types: Any, +): + if not interface or not interface.value: + raise VST3ScanError("interface VST3 nulle") + vtable = ctypes.cast( + interface, + ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p)), + ).contents + address = vtable[index] + return _CALL(result_type, ctypes.c_void_p, *argument_types)(address) + + +def _query_interface(interface: ctypes.c_void_p, iid: bytes) -> ctypes.c_void_p | None: + output = ctypes.c_void_p() + iid_buffer = (ctypes.c_ubyte * 16).from_buffer_copy(iid) + result = _method( + interface, + 0, + ctypes.c_int32, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_void_p), + )(interface, iid_buffer, ctypes.byref(output)) + return output if result == 0 and output.value else None + + +def _release(interface: ctypes.c_void_p | None) -> None: + if interface and interface.value: + _method(interface, 2, ctypes.c_uint32)(interface) + + +def _plugin_binary(path: Path) -> Path: + if path.is_file(): + return path + binary_root = path / "Contents" / "x86_64-win" + candidates = sorted(binary_root.glob("*.vst3")) + if not candidates: + raise VST3ScanError(f"binaire VST3 x64 introuvable dans {path}") + expected = binary_root / path.name + return expected if expected in candidates else candidates[0] + + +def _select_class(factory: ctypes.c_void_p, expected_name: str) -> _PClassInfo: + count = _method(factory, 4, ctypes.c_int32)(factory) + classes: list[_PClassInfo] = [] + get_info = _method(factory, 5, ctypes.c_int32, ctypes.c_int32, ctypes.POINTER(_PClassInfo)) + for index in range(max(0, count)): + info = _PClassInfo() + if get_info(factory, index, ctypes.byref(info)) != 0: + continue + if _decode_c_string(info.category).casefold() == "audio module class": + classes.append(info) + if not classes: + raise VST3ScanError("aucune classe audio VST3 trouvée") + requested = expected_name.strip().casefold() + exact = [info for info in classes if _decode_c_string(info.name).casefold() == requested] + if len(exact) == 1: + return exact[0] + if len(classes) == 1: + return classes[0] + partial = [ + info + for info in classes + if requested in _decode_c_string(info.name).casefold() + or _decode_c_string(info.name).casefold() in requested + ] + if len(partial) == 1: + return partial[0] + available = ", ".join(_decode_c_string(info.name) for info in classes) + raise VST3ScanError( + f"classe « {expected_name} » ambiguë dans le module ({available})" + ) + + +def _create_instance( + factory: ctypes.c_void_p, + class_id: Any, + interface_id: bytes, +) -> ctypes.c_void_p: + output = ctypes.c_void_p() + iid_buffer = (ctypes.c_ubyte * 16).from_buffer_copy(interface_id) + create = _method( + factory, + 6, + ctypes.c_int32, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_ubyte), + ctypes.POINTER(ctypes.c_void_p), + ) + result = create(factory, class_id, iid_buffer, ctypes.byref(output)) + if result != 0 or not output.value: + raise VST3ScanError("impossible d'instancier le contrôleur VST3") + return output + + +def _scan_in_process(module_path: Path, expected_name: str) -> VST3ScanResult: + source = Path(module_path).expanduser().resolve() + binary = _plugin_binary(source) + if os.name != "nt": + raise VST3ScanError("la lecture directe VST3 est disponible sous Windows") + try: + library = ctypes.WinDLL(str(binary)) + except OSError as exc: + raise VST3ScanError(f"chargement impossible : {exc}") from exc + + initialized_dll = False + factory: ctypes.c_void_p | None = None + component: ctypes.c_void_p | None = None + controller: ctypes.c_void_p | None = None + component_connection: ctypes.c_void_p | None = None + controller_connection: ctypes.c_void_p | None = None + component_initialized = False + controller_initialized = False + connected = False + component_active = False + host = _HostContext() + component_handler = _ComponentHandlerContext() + try: + init_dll = getattr(library, "InitDll", None) + if init_dll is not None: + init_dll.restype = ctypes.c_bool + if not init_dll(): + raise VST3ScanError("InitDll a refusé le chargement") + initialized_dll = True + get_factory = getattr(library, "GetPluginFactory", None) + if get_factory is None: + raise VST3ScanError("GetPluginFactory est absent du module") + get_factory.restype = ctypes.c_void_p + factory = ctypes.c_void_p(get_factory()) + if not factory.value: + raise VST3ScanError("la fabrique VST3 est indisponible") + class_info = _select_class(factory, expected_name) + component = _create_instance(factory, class_info.cid, _IID_COMPONENT) + initialize_component = _method(component, 3, ctypes.c_int32, ctypes.c_void_p) + if initialize_component(component, host.pointer) != 0: + raise VST3ScanError("initialisation du composant VST3 impossible") + component_initialized = True + + controller = _query_interface(component, _IID_EDIT_CONTROLLER) + _debug(f"single component/controller={controller is not None}") + if controller is None: + controller_class_id = (ctypes.c_ubyte * 16)() + get_controller_class_id = _method( + component, + 5, + ctypes.c_int32, + ctypes.POINTER(ctypes.c_ubyte), + ) + if get_controller_class_id(component, controller_class_id) != 0: + raise VST3ScanError("le composant n'expose pas de contrôleur d'édition") + controller = _create_instance( + factory, + controller_class_id, + _IID_EDIT_CONTROLLER, + ) + initialize_controller = _method(controller, 3, ctypes.c_int32, ctypes.c_void_p) + if initialize_controller(controller, host.pointer) != 0: + raise VST3ScanError("initialisation du contrôleur VST3 impossible") + controller_initialized = True + _debug("separate controller initialized") + + component_connection = _query_interface(component, _IID_CONNECTION_POINT) + controller_connection = _query_interface(controller, _IID_CONNECTION_POINT) + if component_connection is not None and controller_connection is not None: + connect_component = _method( + component_connection, + 3, + ctypes.c_int32, + ctypes.c_void_p, + ) + connect_controller = _method( + controller_connection, + 3, + ctypes.c_int32, + ctypes.c_void_p, + ) + first = connect_component(component_connection, controller_connection) + second = connect_controller(controller_connection, component_connection) + connected = first == 0 and second == 0 + _debug(f"connection results component={first} controller={second}") + + set_component_handler = _method( + controller, + 16, + ctypes.c_int32, + ctypes.c_void_p, + ) + handler_result = set_component_handler(controller, component_handler.pointer) + _debug(f"setComponentHandler result={handler_result}") + + component_state = _MemoryStreamContext() + get_component_state = _method( + component, + 13, + ctypes.c_int32, + ctypes.c_void_p, + ) + get_state_result = get_component_state(component, component_state.pointer) + _debug( + f"getState result={get_state_result} bytes={len(component_state.data)}" + ) + if get_state_result == 0: + component_state.position = 0 + set_component_state = _method( + controller, + 5, + ctypes.c_int32, + ctypes.c_void_p, + ) + state_result = set_component_state(controller, component_state.pointer) + _debug(f"setComponentState result={state_result}") + + get_count = _method(controller, 8, ctypes.c_int32) + count = get_count(controller) + _debug(f"parameter count after initialize/connect={count}") + if count == 0: + # A small number of plug-ins populate their edit controller only + # after the component has entered its active state. No processing + # bus is created and no audio is run; this only completes the host + # side of their documented component lifecycle. + try: + set_active = _method(component, 11, ctypes.c_int32, ctypes.c_ubyte) + active_result = set_active(component, 1) + _debug(f"setActive result={active_result}") + if active_result == 0: + component_active = True + count = get_count(controller) + _debug(f"parameter count after setActive={count}") + except Exception: + pass + if count < 0 or count > 100_000: + raise VST3ScanError(f"nombre de paramètres invalide : {count}") + get_parameter_info = _method( + controller, + 9, + ctypes.c_int32, + ctypes.c_int32, + ctypes.POINTER(_ParameterInfo), + ) + parameters: list[ScannedParameter] = [] + for index in range(count): + info = _ParameterInfo() + if get_parameter_info(controller, index, ctypes.byref(info)) != 0: + raise VST3ScanError(f"lecture impossible du paramètre {index + 1}") + name = _decode_string128(info.title) + if not name: + name = _decode_string128(info.short_title) or f"Parameter {index + 1}" + parameters.append( + ScannedParameter( + index=index, + parameter_id=int(info.parameter_id), + name=name, + short_name=_decode_string128(info.short_title), + unit=_decode_string128(info.units), + step_count=int(info.step_count), + flags=int(info.flags), + ) + ) + return VST3ScanResult( + plugin_name=expected_name, + class_name=_decode_c_string(class_info.name), + module_path=source, + parameters=tuple(parameters), + ) + finally: + if connected and component_connection and controller_connection: + try: + _method(component_connection, 4, ctypes.c_int32, ctypes.c_void_p)( + component_connection, controller_connection + ) + _method(controller_connection, 4, ctypes.c_int32, ctypes.c_void_p)( + controller_connection, component_connection + ) + except Exception: + pass + _release(controller_connection) + _release(component_connection) + if controller is not None: + try: + _method(controller, 16, ctypes.c_int32, ctypes.c_void_p)( + controller, None + ) + except Exception: + pass + if controller_initialized: + try: + _method(controller, 4, ctypes.c_int32)(controller) + except Exception: + pass + _release(controller) + if component is not None: + if component_active: + try: + _method(component, 11, ctypes.c_int32, ctypes.c_ubyte)(component, 0) + except Exception: + pass + if component_initialized: + try: + _method(component, 4, ctypes.c_int32)(component) + except Exception: + pass + _release(component) + if factory is not None: + _release(factory) + if initialized_dll: + exit_dll = getattr(library, "ExitDll", None) + if exit_dll is not None: + try: + exit_dll() + except Exception: + pass + + +def worker_main(arguments: list[str] | None = None) -> int: + args = list(sys.argv[1:] if arguments is None else arguments) + if len(args) == 3 and args[0] in {"--worker", "--vst3-scan-worker"}: + args = args[1:] + if len(args) != 2: + print("usage: vst3_scanner --worker MODULE EXPECTED_NAME", file=sys.stderr) + return 2 + try: + result = _scan_in_process(Path(args[0]), args[1]) + except Exception as exc: + print(f"{type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + print(json.dumps(result.to_dict(), ensure_ascii=False, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(worker_main()) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 6298de8..dddebff 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -1,4 +1,5 @@ import unittest +import threading from types import SimpleNamespace from silemio_control_hub.runtime.config import BridgeConfig @@ -87,6 +88,64 @@ def test_companion_names_can_be_cleared_before_plugin_capture(self): self.assertEqual(bridge.short_names[0], "") self.assertFalse(bridge._received_companion_names) + def test_plugin_capture_reuses_names_already_sent_to_controller(self): + bridge = self.make_bridge() + bridge._on_osc("/Companion/ControllerNames", ["Rotary1", "Threshold"]) + bridge._osc_client.messages.clear() + + names = bridge.capture_companion_names( + required_indices=(0,), + timeout=0.1, + quiet_period=0, + ) + + self.assertEqual(names[0], "Threshold") + self.assertFalse(bridge._osc_client.messages) + + def test_plugin_capture_waits_for_delayed_controller_feedback(self): + bridge = self.make_bridge() + + def delayed_feedback(): + bridge._on_osc("/Companion/ControllerNames", ["Rotary1", "Brightness"]) + + timer = threading.Timer(0.04, delayed_feedback) + timer.start() + self.addCleanup(timer.cancel) + + names = bridge.capture_companion_names( + required_indices=(0,), + timeout=0.3, + quiet_period=0.01, + retry_interval=0.02, + ) + + self.assertEqual(names[0], "Brightness") + self.assertTrue( + any(address == "/refresh" for address, _args in bridge._osc_client.messages) + ) + + def test_plugin_capture_waits_for_a_recent_name_batch_to_settle(self): + bridge = self.make_bridge() + bridge._on_osc("/Companion/ControllerNames", ["Rotary1", "Threshold"]) + + timer = threading.Timer( + 0.04, + lambda: bridge._on_osc( + "/Companion/ControllerNames", ["Rotary2", "Ratio"] + ), + ) + timer.start() + self.addCleanup(timer.cancel) + + names = bridge.capture_companion_names( + required_indices=(0, 1), + timeout=0.3, + quiet_period=0.06, + retry_interval=0.2, + ) + + self.assertEqual(names[:2], ("Threshold", "Ratio")) + def test_push_name_is_visible_without_overriding_a_rotary_label(self): bridge = self.make_bridge() bridge._on_osc("/Companion/ControllerNames", ["Generic Button 1", "Bypass"]) diff --git a/tests/test_plugin_studio.py b/tests/test_plugin_studio.py index 6de61c0..0509106 100644 --- a/tests/test_plugin_studio.py +++ b/tests/test_plugin_studio.py @@ -1,4 +1,5 @@ import hashlib +import threading import pytest @@ -23,10 +24,13 @@ capture_liveprofessor_parameter_names, default_user_profile_id, editable_parameters, + merge_scanned_parameter_names, next_user_profile_version, request_liveprofessor_companion_names, + retrieve_installed_parameter_names, save_user_profile, ) +from silemio_control_hub.vst3_scanner import ScannedParameter, VST3ScanResult def _observation(*, stable_id="VST3-Test-1234", parameter_count=3): @@ -238,6 +242,73 @@ def test_liveprofessor_names_follow_saved_slot_mapping_not_parameter_order( assert updated[2].enabled is False +def test_scanned_names_preserve_user_choices_and_classify_discrete_parameters(tmp_path): + observation = _observation() + original = _parameters(observation) + scan = VST3ScanResult( + plugin_name=observation.name, + class_name=observation.name, + module_path=tmp_path / "test.vst3", + parameters=( + ScannedParameter(0, 100, "Input Gain", "Input", "dB", 0, 1), + ScannedParameter(1, 101, "Mode", "Mode", "", 3, 1), + ScannedParameter(2, 102, "Bypass", "Bypass", "", 1, 1), + ), + ) + + updated = merge_scanned_parameter_names(original, scan) + + assert [parameter.name for parameter in updated] == ["Input Gain", "Mode", "Bypass"] + assert updated[0].unit == "dB" + assert updated[0].importance == 90 + assert updated[0].role == "input_gain" + assert updated[1].kind == PluginParameterKind.ENUM + assert updated[2].kind == PluginParameterKind.TOGGLE + assert updated[2].enabled is False + + +def test_scanned_names_reject_a_parameter_count_mismatch(tmp_path): + observation = _observation() + scan = VST3ScanResult( + plugin_name=observation.name, + class_name=observation.name, + module_path=tmp_path / "test.vst3", + parameters=( + ScannedParameter(0, 100, "Input", "Input", "", 0, 1), + ), + ) + + with pytest.raises(PluginProfileError, match="inventaire incompatible"): + merge_scanned_parameter_names(_parameters(observation), scan) + + +def test_retrieve_installed_names_uses_exact_project_identity(monkeypatch): + observation = _observation(parameter_count=3) + summary = type( + "Summary", + (), + {"observation": observation}, + )() + received = {} + sentinel = object() + + def fake_scan(name, **kwargs): + received["name"] = name + received.update(kwargs) + return sentinel + + monkeypatch.setattr("silemio_control_hub.plugin_studio.scan_installed_vst3", fake_scan) + + assert retrieve_installed_parameter_names(summary, timeout=7) is sentinel + assert received == { + "name": observation.name, + "expected_parameter_count": 3, + "plugin_format": "VST3", + "database": None, + "timeout": 7, + } + + def test_companion_name_request_works_without_hardware_runtime(monkeypatch): state = {} @@ -285,3 +356,52 @@ def close(self): assert names == ("Input Gain", "", "Output Gain") assert state["requests"] == ["/init", "/refresh", "/ViewSets/Refresh"] assert state["started"] and state["stopped"] and state["closed"] + + +def test_companion_name_request_retries_until_delayed_inventory(monkeypatch): + state = {"refreshes": 0} + + class FakeServer: + def __init__(self, host, port, callback, error_callback): + state["callback"] = callback + + def start(self): + pass + + def stop(self): + pass + + class FakeClient: + def __init__(self, host, port): + pass + + def send(self, address, *args): + if address != "/refresh": + return + state["refreshes"] += 1 + if state["refreshes"] == 2: + threading.Timer( + 0.02, + lambda: state["callback"]( + "/Companion/ControllerNames", ["Rotary1", "Brightness"] + ), + ).start() + + def close(self): + pass + + monkeypatch.setattr("silemio_control_hub.plugin_studio.OSCServer", FakeServer) + monkeypatch.setattr("silemio_control_hub.plugin_studio.OSCClient", FakeClient) + + names = request_liveprofessor_companion_names( + host="127.0.0.1", + request_port=8010, + feedback_host="127.0.0.1", + feedback_port=8011, + timeout=0.5, + quiet_period=0.01, + retry_interval=0.05, + ) + + assert names == ("Brightness",) + assert state["refreshes"] >= 2 diff --git a/tests/test_product_identity.py b/tests/test_product_identity.py index 5872e6e..7ca1df4 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.2" + assert __version__ == "2026.3" diff --git a/tests/test_vst3_scanner.py b/tests/test_vst3_scanner.py new file mode 100644 index 0000000..794c420 --- /dev/null +++ b/tests/test_vst3_scanner.py @@ -0,0 +1,74 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from silemio_control_hub.vst3_scanner import ( + ScannedParameter, + VST3ScanError, + VST3ScanResult, + installed_plugin_paths, + scan_installed_vst3, + scan_vst3_module, +) + + +def _result(path: Path, *, count: int = 2) -> VST3ScanResult: + return VST3ScanResult( + plugin_name="Test Plug", + class_name="Test Plug", + module_path=path.resolve(), + parameters=tuple( + ScannedParameter(index, 100 + index, f"Name {index}", "", "", 0, 1) + for index in range(count) + ), + ) + + +def test_liveprofessor_database_resolves_only_exact_existing_vst3_entries(tmp_path): + plugin = tmp_path / "Test Plug.vst3" + plugin.mkdir() + other = tmp_path / "Other.vst3" + other.mkdir() + database = tmp_path / "PluginsX64.xml" + database.write_text( + "" + f'' + f'' + f'' + "", + encoding="utf-8", + ) + + assert installed_plugin_paths("test plug", database=database) == (plugin.resolve(),) + + +def test_worker_result_is_parsed_and_validated(monkeypatch, tmp_path): + plugin = tmp_path / "Test.vst3" + plugin.mkdir() + result = _result(plugin) + completed = SimpleNamespace( + returncode=0, + stdout="plug-in diagnostic line\n" + json.dumps(result.to_dict()) + "\n", + stderr="", + ) + monkeypatch.setattr("silemio_control_hub.vst3_scanner.subprocess.run", lambda *a, **k: completed) + + assert scan_vst3_module(plugin, expected_name="Test Plug") == result + + +def test_installed_scan_rejects_a_shift_risk_when_counts_differ(monkeypatch, tmp_path): + plugin = tmp_path / "Test.vst3" + plugin.mkdir() + monkeypatch.setattr( + "silemio_control_hub.vst3_scanner.installed_plugin_paths", + lambda *a, **k: (plugin.resolve(),), + ) + monkeypatch.setattr( + "silemio_control_hub.vst3_scanner.scan_vst3_module", + lambda *a, **k: _result(plugin, count=2), + ) + + with pytest.raises(VST3ScanError, match="expose 2 paramètres.*en contient 3"): + scan_installed_vst3("Test Plug", expected_parameter_count=3)