Skip to main content

Pérégrinations avec CDK (Curses Development Kit) et C++

Mise à jour

Ce projet est actuellement en réingénérie; l'idée est d'isoler l'interface graphique pour en faire une librairie. Des mises à jour seront donc à venir.

Partie I - Le contexte

Voici maintenant un an que je travaille sur un projet C++ utilisant la librairie CDK. Le but de ce projet est de créer un gestionnaire/éditeur universel fonctionnant avec l'interface "texte" sous Linux.

Initialement, le programme a été conçu pour gérer les programmes et les performances de deux Yamaha TX816, chacun possédant 8 modules indépendants (les “TF1”). Pour rappel, chaque module est un synthétiseur à part entière, équivalent au célèbre Yamaha DX7, pouvant stocker 32 programmes (des voices dans le jargon Yamaha) et 32 performances. C'est ce qui distingue un module TF1 d'un DX7, ce dernier n'ayant qu'une seule performance.

Avec deux TX816, l'ensemble équivaut à gérer 16 synthétiseurs similaires, 512 programmes et 512 performances.

Le choix d'une interface TUI (Text-based User Interface) s'est fait pour plusieurs raisons:

  • L'expérience: j'ai déjà réalisé des interfaces avec curses, bien que celà remonte à plus de 30 ans !
  • La rapidité: historiquement, les interfaces textuelles étaient beaucoup plus rapides (responsive) que les interfaces graphiques.
  • L'ergonomie: j'ai toujours trouvé la navigation sans souris beaucoup plus facile et naturelle.
  • Le look vintage: le projet est parti initialement du besoin de gérer les programmes de 2 Yamaha TX816, des appareils datant de 1984. À cette époque, les interfaces graphiques étaient balbutiantes (car les cartes graphiques étaient très chers), alors que les interfaces textuelles étaient déjà très développées.

En outre, ce projet constitue également un canevas pour deux autres projets que j'ai en tête:

  • l'interface graphique pour la restauration d'un Yamaha QX1, le séquenceur "officiel" du TX816.
  • la création de fichiers AKP et AKM pour Akai S5000/S6000.
  • Évolution de l'architecture

    Interface

    Un programme (voice) de FM 6-op (TF1, DX7, TX7) est constitué de 155 paramètres, auxquels s'ajoutent 17 paramètres de performance; l’interface étant initialement codée "en dur" (hard-coded), chaque ajout ou modification demandait beaucoup de modifications. C'est devenu rapidement ingérable!

    J'ai donc rapidement choisi de transférer la description de l'interface dans un fichier externe; le XML était un choix évident (quoique j'hésitais avec JSON), que je gère grâce à la bibliothèque PugiXML.

    Note technique : PugiXML est un parseur XML de type DOM (Document Object Model), qui charge entièrement le document en mémoire. Si la mémoire est une contrainte, une alternative pourrait être d'utiliser un parseur SAX (Simple API for XML) tel qu'Expat.

    En outre, confier l'interface à un document XML permet l'ouverture vers d'autres synthétiseurs.

    Cependant, l'interface était initialement codée directement avec ncurses; c'était très lourd, et la gestion du refresh était assez pénible.

    Je me suis tourné vers la librairie panel, mais elle ajoutait encore une couche de complexité.

    J'ai donc cherché une librairie qui simplifierait la gestion de ncurses. Cette librairie existe bien, c'est CDK (Curses Development Kit), toujours activement maintenue par Thomas E. Dickey, qui maintient aussi ... ncurses!

    Malheureusement, CDK étant développé en C, l'intégration avec C++ ne s'est pas fait de façon aussi harmonieuse qu'anticipée ...

    La raison principale est liée aux classes et à une gestion beaucoup plus efficace de la mémoire en C++, mais que C ne prend pas en compte. J'ai dû développer un wrapper pour en tirer parti.

    Connectivité

    Évidemment la communication avec les instruments se fait par MIDI. Elle est assurée par la bibliothèque RtMIDI. Lors de mes tests, j'ai utilisé deux interfaces USB : une E-MU 1X1 et une Midiman Midisport 4.

    La logique métier et le bus d'événements

    J'avais un prototype fonctionnant à peine, mais j'étais toujours insatisfait car la logique métier et les protocoles de communication avec le synthé (via SYSEX) étaient encore codés en dur.

    L'étape suivante consistait à trouver un moyen de décrire aussi la logique métier en XML; cela a été réalisé en ajoutant des paramètres d'action aux objets (onSelect, onLoad, onEscape, etc.) et en transformant le programme principal en une boucle s'appuyant sur un bus de messages. Chaque action pousse un message dans le bus, qui à son tour déclenche une autre action.

    À l’usage, la boucle de messages s’avérait très gourmande en ressources (12% du CPU). Il fallait trouver autre chose; c’est ainsi que sont arrivés l’utilisation de threads et les interruptions. Un thread gère l'interface avec l'utilisateur (gestion des objets CDK), un second thread "dort" et est réveillé par l'arrivé de nouveaux messages sur le bus.

    Cette architecture a fait baisser l'utilisation du CPU à ... quasiment rien.

    Partie II - Architecture

    L’interface utilisateur

    Écrans et widgets

    L’interface utilisateur est composée d’objets graphiques: des écrans (screen) qui contiennent des éléments graphiques (widgets). La description de tous les objets graphiques est contenue dans un document XML.

    Voici un exemple de description:

    <screens>
    	<screen name="Main screen" … >
    		<vMENU name="Menu" … >
    			<menu text="File"> 
    				<entry … >Performance </entry>
    				<entry … >Load a performance </entry>
    				<entry … >Save a performance </entry>
    				<entry … >Quit </entry>
    			</menu>
    			…
    	</vMENU>
    	</screen>
    	<screen name="MIDI devices" … >
    		<vSCROLL name="MIDI inputs" … />
    		<vSCROLL name="MIDI outputs" … />
    		<vLABEL … > MIDI Interfaces </vLABEL>
    	</screen>
    	<screen name="Configuration" … >
    			… 
    	</screen>
    </screens>
    

    Un écran est simplement un conteneur de widgets, nommé pour en faciliter la gestion. Ouvrir (SCREEN_OPEN) ou détruire (SCREEN_CLOSE) un écran va créer ou détruire tous les widgets appartenant à cet écran.

    Les écrans sont numérotés par ordre d’ouverture dans le document XML; l’écran 0, donc le premier qui apparaît dans le document, est l’écran principal (MAINSCREEN). Il est toujours visible.

    Les différents widgets disponibles proviennent directement de CDK: vMENU, vSCROLL, vLABEL etc. Il y a 28 types de widgets () mais tous ne sont pas gérés dans mon application (pour le moment).

    Chaque widget a sa propre classe. Cependant, comme certains paramètres sont communs à tous les types de widget, j'ai créé une classe de base (purement abstraite) dont les widgets héritent.

    Navigation

    Dans CDK, l'activation d'un widget (interaction avec l'utilisateur) se termine par un statut: vNORMAL (l'utilisateur à validé avec la touche ENTER) ou vESCAPE_HIT (l'utilisateur à appuyé sur ESCAPE).

    Il manque la possibilité de "survoler" un widget, c'est-à-dire d'y atterir et d'en repartir sans action, ce que fait la touche TAB dans les interface graphiques.

    Malheureusement pour certains widget, TAB est considéré comme équivalent à ENTER; c'est limitant pour naviguer entre les widgets sans valider.

    Les widgets possèdent plusieurs fonctions pour traiter la saisie:

    • injectCDKwidget "envoie" un caractère au widget (comme s'il avait été tapé par l'utilisateur). Le widget réagit selon le caractères reçu:
      • ENTER ou TAB: valide et sort avec le statut vNORMAL.
      • ESCAPE: sort avec le statut vESCAPE_HIT.
      • autre: sort avec le statut vEARLY_EXIT.
    • setCDKObjectPreProcess appelle une fonction avant l'activation du widget.
    • setCDKObjectPostProcess appelle une fonction après l'activation du widget.
    • bindCDKObject appelle une fonction quand un caractère donné est saisi.
    class Navigation : public Events::ActionMap
    {
    protected:      
    	inline static int       navLastkey;
     public: 
     	inline static auto      navCallback = [](EObjectType, void* object, void* clientData, chtype key) {
    		navLastkey = key;
    		CDKOBJS* widget = (CDKOBJS*)object;
    		widget->exitType = vEARLY_EXIT; // On force la sortie sans valider
    		return (chtype)key;
    	};
    };
    

    La classe Screen

    class Screen : public Navigation
    {
    private:
    	CDKSCREEN                       *screen;                // Struct CDKSCREEN
    	WINDOW                          *window;                // Struct WINDOW required for box
    	std::string                     name;
    	std::vector<Widget::WdgPtr>     Widgets;                // List of pointers to widgets
    	Widget::Base                    *focus = nullptr;       // Widget having the focus
    	int                             width, height, y, x;
    	bool                            visible, box;
    public: 
    	Screen (CDKSCREEN*,std::string,int,int,int,int,bool,bool,const Events::ActionMap&); // The base constructor
    	Screen(const Screen&);                // The copy constructor
    	Screen(Screen&&) noexcept;            // The moving constructor
    	Screen& operator=(Screen&&) noexcept; // The moving assignement operator
    	~Screen ();                           // The destructor
    
    	// Getters
    	CDKSCREEN*      getScreen() const { return screen; }
    	std::string     getName() const { return name; }
    	bool            IsVisible() const { return visible; }
    	bool            IsBoxed() const { return box; }
    	void            dump(const std::string& n);
    
    	// Find a widget by its name
    	Widget::Base *findWidget(const string&);
    	// Find a widget by its name and return the type of the widget
    	template <typename T>
    	T* getWidgetAs(const std::string& n) {
    		Widget::Base* base = this->findWidget(n);
    		if (base == nullptr) return nullptr;
    		return static_cast<T*>(base);
    	}
    
    	// Calling widget constructors
    	/* SCALE */     bool AddScale( const std::string&,int&,int&,const string&,const string&,chtype,int,int,int,int,int,int,bool,bool,const Events::ActionMap&);
    	/* USCALE */    bool AddUScale( const std::string&,int&,int&,const string&,const string&,chtype,int,uint,uint,uint,uint,uint,bool,bool,const Events::ActionMap&);
     	/* DSCALE */    bool AddWidget( const std::string&,int&,int&,const string&,const string&,chtype,int,double,double,double,double,double,int,bool,bool,const Events::ActionM    ap&);
     	...
    };
    
    🎹 Fiche Projet : QX1 Cyber-Restoration (Proto-Cubase) 1. Architecture Matérielle (Hardware) Châssis : Yamaha QX1 (Original). Cerveau : Raspberry Pi (Master) remplaçant le Hitachi 63B03 (1 MHz). Gestion E/S (Coprocesseur) : Raspberry Pi Pico (RP2040/RP2350). MIDI : 8 entrées / 8 sorties via les machines à états PIO (UART 31.25k bauds). Interface : Scan de la matrice du clavier original + Pilotage du LCD 2x40. Lien Pi/Pico : USB Composite (MIDI Class Compliant + HID Keyboard). Affichage : 1. LCD natif (Infos temps réel). 2. Sortie HDMI (Interface graphique étendue). 2. Architecture Logicielle (Software) Langage : C++20 (Standard moderne). Moteur de Séquenceur : TSE3 (Gestion des morceaux, tracks et MIDI realtime). Interface Graphique (HDMI) : * Bibliothèque CDK (Curses Development Kit) sur ncurses. Look & Feel inspiré de Cubase 3 (Atari ST). Hiérarchie des Classes : ActionMap : Dictionnaire de commandes (le "Quoi"). Navigation (Hérite de ActionMap) : Gestion du focus, des groupes de widgets et des touches TAB / Shift+TAB (via vEARLY_EXIT). Screen (Hérite de Navigation) : Rendu concret des fenêtres et widgets. Données : Classe Corpus pour centraliser les variables partagées (MidiPorts, Instruments, etc.). 3. Points Techniques Clés à retenir Navigation : Interception des touches de navigation dans les widgets via bindCDKObject pour éviter la validation automatique (ENTER). Synchronisation : Utilisation de l'exécution synchrone (executeAction) lors du ON_LOAD des écrans pour garantir que les données sont prêtes avant l'affichage des widgets.

    Les classes

    La librairie CDK propose des facilités n'est Mon projet est de développer une interface semi-graphique en C++ basée sur ncurses. Pour celà, j'utilise la librairie CDK (Curses Development Kit) qui propose une librarie de 28 objets graphiques (widget) pré-définis, simplifiant la gestion de ncurses. Mon interface est définie dans un fichier de ressource en XML qui contient les caractéristiques des widgets (types, coordonnés, tailles, etc.), le fichier étant lu grâce à la librairie pugixml.

    Ma structure est la suivante: une fenêtre (Window) contient un ou plusieurs écrans (Screen), chaque écran contient un ou plusieurs objets (Widget). Pour simplifier la gestion, je veux que tous les widjets utilisent les mêmes fonctions: constructeur et destructeur, getValue, etc. Pour celà, j'ai défini une classe abstraite de base (BaseWidget) et des classes dérivées basées sur un template:

    	class BaseWidget {
    	  protected:
    		string          name;
    		Coord           x, y;
    		bool            visible;
    	  public:
    	  	...
    	}
    
    	template <typename T>
    	class Widget : public BaseWidget {
    		T       *ptr;
    	  public:
    		...
    	}
    

    Pour l'écran, j'ai défini une classe Screen contenant un vecteur de widgets:

    	typedef std::unique_ptr<BaseWidget>             BASE_PTR;
    	
    	class Screen {
    	  private:
    		CDKSCREEN               *screen;        // Struct CDKSCREEN
    		vector<BASE_PTR>        Widgets;        // List of smart pointers to widgets
    		BASE_PTR                focus;          // Widget having the focus
    		bool                    visible;        // Visible
    	  public:
    	  	...
    	}
    

    Enfin, la classe Window contient un vecteur de Screen:

    	typedef std::unique_ptr<Screen>         SCREEN_PTR;
    	
    	class Window {
    	  private:
    		WINDOW             *win;           // Struct WINDOW
    		string             name;           // Window's name
    		vector<SCREEN_PTR> screens;        // Vector of screens
    		Size               h, w;           // Initial size
    		Coord              y, x;           // Initial coordinates
    		int                color_pair;     // Color pair
    		bool               visible;        // window's visibility
    	  public:
    		...
    	}
    

    Le problème auquel je me heurte est que CDK n'est pas prévu pour une gestion d'objet; en particulier, il n'est pas directement possible de copier un widget ce qui est un requis pour le constructeur de copie des classes sous-jacentes (Screen et Window). La solution est de recréer un widget identique, mais pour celà, il faut connaître tous les paramètres qui ont servis à créer le widget d'origine; certains sont disponibles par des fonctions get(), mais pas tous. Il faut donc mémoriser les paramètres manquants, et celà dépend du type de widget. De plus, pour certains widgets par exemple la liste déroulante CDKScroll, il est possible d'ajouter ou de modifier les valeurs après la création, ce qui est justement un des intérêts de ce widget.

Comments