Comment interfacer Gambas avec des bibliothèques externes
Introduction
Il y a un grand nombre de bibliothèques disponibles dans un système Linux, susceptibles de réaliser des tas de choses très utiles, et beaucoup de ces bibliothèques peuvent être utilisées à partir de Gambas en utilisant certaines de ses fonctionnalités.
La première étape consiste à trouver une bibliothèque appropriée à utiliser pour un objectif donné. Toutes les bibliothèques ne sont pas utilisables, mais la grande majorité l'est. La condition préalable est que la bibliothèque soit écrite en C. La plupart des bibliothèques sont écrites en C, tandis que d'autres sont écrites en C++ et, peut-être, dans d'autres langages encore. Ce document se concentrera uniquement sur les bibliothèques en C.
Une fois trouvée la bibliothèque désirée, sa logique générale doit être comprise pour déterminer ce qui sera nécessaire, et comment la nouvelle fonctionnalité sera utilisée par le programme final. La documentation complète de la bibliothèque, et quelques exemples éventuels, doivent être lus soigneusement. Les bibliothèques ne sont pas des programmes, et de ce fait, leur philosophie est assez différente. Un programme s’efforce de ne contenir que les routines nécessaires pour faire son travail, alors qu’une bibliothèque est ce que son nom exprime : une collection de routines, utilisables un grand nombre de fois, par de nombreux programmes différents. Il n’est pas rare de trouver, dans une bibliothèque, deux routines, ou plus, qui font la même chose, mais d’une manière légèrement différente. Les bibliothèques sont parfois écrites en gardant à l'esprit qu'elles seront utilisées par différents langages, et pas seulement le C : python, ruby, ocaml, beaucoup d’autres, Gambas inclus. Les bibliothèques essaient d’encapsuler les détails d’une tâche derrière un point d’accès, d’une manière semblable à un objet Gambas ; mais elles n’ont ni propriétés ni de méthodes - tout est mis en œuvre par appel à des fonctions auxquelles on passe ces points d’accès. Si vous pensez à une classe Gambas avec trois propriétés et quatre méthodes, une bibliothèque externe qui implémenterait la même chose aurait au moins sept (trois+quatre) sous-routines, et probablement deux de plus pour créer et détruire l’objet. En dehors de ça, il n’y a pas une grande différence entre définir une propriété et appeler une fonction, cette dernière étant juste un peu plus longue à écrire. Pour rechercher ce dont nous avons besoin, nous devons nous attendre à devoir lire beaucoup de documentations, trop souvent mal écrites (hep ! à propos, comment documentons-nous notre logiciel ?).
La déclaration externe
La déclaration externe est simple. C’est comme la déclaration d’une sous-routine Gambas normale, mais précédée de "
EXTERN". Le mot-clé EXTERN signale à Gambas que le corps de la procédure n’est pas défini par le programme Gambas que nous sommes en train d’écrire, mais quelque part ailleurs (une bibliothèque externe). Nous devons aussi spécifier quelle bibliothèque employer : ceci est réalisé par la clause "
IN libraryXXX". Comme alternative, vous pouvez employer une instruction
LIBRARY séparée : toutes les déclarations externes subséquentes feront référence à cette instruction. "IN library" et "LIBRARY xxx" peuvent tous deux spécifier un numéro de version après une virgule, et ceci est recommandé.
Voyons un exemple, choisi pour sa simplicité :
LIBRARY "libc:6"
EXTERN getgid() AS Integer
Ces deux lignes disent qu’une fonction nommée "getgid" existe dans la bibliothèque "libc" version 6. Cette fonction ne prend pas de paramètres, et retourne un integer (l’ID de groupe).
On peut écrire la même chose ainsi :
EXTERN getgid() AS Integer IN "libc:6"
Un autre exemple, légèrement plus complexe. Cette fois, nous avons des paramètres, et une dernière chose à dire à propos de la déclaration formelle :
' int kill(pid_t pid, int sig);
EXTERN killme(pid AS Integer, sig AS Integer) AS Integer IN "libc:6" EXEC "kill"
La première ligne (le commentaire) montre la déclaration d'origine, et la deuxième ligne celle de Gambas. Nous pouvons observer un certain nombre de choses.
Premièrement, que signifie la déclaration d'origine ? Elle signifie qu'"une fonction nommée kill retourne un int, et accepte deux paramètres. Le premier est nommé pid et son type est pid_t; le second est nommé sig et son type est int". Contrairement à Gambas, le langage C met le type d’une variable avant celle-ci et non après.
Deuxièmement, ce qui en Gambas est appelé "
Integer", est appelé "int" en C.
Troisièmement, qu’est-ce que "pid_t"? C’est un type ; nous pouvons comprendre ça car c’est écrit à un endroit où un spécificateur de type est attendu, et parce qu’il se termine par "_t" (tiret bas t).
Quatrièmement, une nouvelle clause
EXEC "kill" est utilisée dans la déclaration Gambas. Elle est nécessaire parce que nous voulons utiliser une fonction nommée kill, mais
KILL est un nom déjà utilisé par Gambas. Aussi en Gambas nous devons nommer différemment la fonction, mais quoiqu'il en soit, il nous faut indiquer le vrai nom dans la bibliothèque. La déclaration dit "je déclare une fonction externe nommée killme, mais son vrai nom est kill". J’ai choisi le nom killme car dans l’exemple Gambas attaché, cette fonction est employée pour tuer le programme en cours.
Pour être sincère, j’ai remarqué que, même sans renommer la fonction kill en killme, le programme fonctionnait également. Peut-être est-ce dû à la sensibilité à la casse - C est sensible, mais pas Gambas, aussi y-a-t-il toujours une différence entre kill (bas de casse) et KILL (haut de casse). Quoi qu’il en soit, lorsqu’il y a un risque de conflit de nom, mieux vaut employer la technique de renommage.
Attention --- Fin de la partie facile
(je plaisante)
À ce stade, plusieurs choses méritent d’être notées. La majorité des bibliothèques appropriées sont écrites en C, qui est un langage différent de Gambas. Nous devons connaître au moins un peu les déclarations en C, pour les traduire en Gambas. En se référant au dernier exemple, on peut se demander pourquoi j’ai traduit le type pid_t en integer. La réponse simple et correcte est "parce que, sur mon système, le type pid_t est en réalité un integer". Cette réponse est vraiment correcte, mais se doit d’être mieux expliquée, en parlant d'agrumes (?). Si l’on pense aux citrons et aux oranges, qui sont tous deux des agrumes, et sont très semblables : ils pèsent à peu près le même poids, et peuvent parfois être interchangés ; vous pouvez les manger directement, ou les presser pour en boire le jus, mais il y a peu de chance pour que vous mettiez du jus d’orange sur votre poisson grillé. En C, ceci s’exprime par le fait qu’il est peu probable que vous vouliez utiliser la fonction kill() en lui passant un integer arbitraire. Plus vraisemblablement, vous lui passeriez un identifiant de processus (PID), qui en réalité est un integer, mais désigné plus précisément comme pid_t. Dans mon explication, j’ai également dit que "sur mon système le type pid_t...". Oui, sur mon système - sur la plupart des systèmes, le type pid_t est un integer, mais ça pourrait être différent.
La réponse finale se trouve en entrant ces commandes dans un terminal :
grep -r pid_t /usr/include/* |grep "#define"
grep -r pid_t /usr/include/* |grep typedef
Ce qui affichera la manière complexe dont les types sont gérés en C. Ce débat est trop compliqué pour aller plus loin ; étant donné que Gambas tourne sous Linux, et probablement sur des systèmes de bureau, on peut considérer que tous les paramètres passés à une fonction, et retournés par elle, sont soit des integer, soit des pointeurs – qui sont aussi des integers, soit des chaînes – qui sont des pointeurs qui sont des integers. Cela peut aussi être des nombres flottants - float et double. La table suivante liste quelques-uns des types que vous pouvez rencontrer dans une déclaration en C, et le type adéquate qui peut être utilisé en Gambas :
type C type Gambas
int -> integer
long -> long
float -> single
double -> float
xxxx* -> pointeur (l’astérisque signifie exactement "pointeur")
char* -> pointeur – mais voir plus loin
autres types -> integer ou pointeur (selon la déclaration); voir plus loin
Nous commencerons par introduire brièvement les pointeurs, qui sont peu utilisés en Gambas. Un pointeur est un integer, mais utilisé d’une façon très différente. La chose qui ressemble le plus à un pointeur en Gambas est une instance de classe. Quand vous créez, disons, une
Form en Gambas, beaucoup de données sont enregistrées quelque part en mémoire. Cette mémoire conservera tous les réglages spécifiques de la feuille : son titre, ses couleurs, la liste de tous ses enfants, etc. L’adresse de ce bloc mémoire est retournée au programme, et stockée dans la variable qui fait référence à la feuille nouvellement créée :
Cette variable MainForm est en réalité un pointeur : en seulement 4 (ou 8) octets, elle suit beaucoup de données, stockées quelque part en mémoire à un emplacement (adresse) spécifique. La mémoire est une longue séquence de cellules (octets), chacune identifiée par un numéro progressif. Un pointeur contient le numéro d'identification d'une cellule de la mémoire (son adresse). En C, les pointeurs sont utilisés pour deux raisons : la première est que passer seulement une adresse (un pointeur contient une adresse), est beaucoup plus rapide que passer beaucoup de données ; c'est la même raison pour laquelle les variables d'instance Gambas comme MainForm sont similaires à des pointeurs.
La deuxième justification est lorsque la fonction appelée doit pouvoir modifier la variable que nous passons. Par exemple, si en Gambas on écrit :
INPUT a ' où a est un integer
en C on écrirait :
void input(int *a);
...
input(&a);
La raison est que nous voulons que notre commande
INPUT remplisse notre variable "a". En C, nous devons appeler input() et lui dire où se trouve notre variable, pour qu’il puisse remplir la variable. L’esperluette "&" prend l’adresse de la variable, et la passe à la fonction. La déclaration de input() dit "int *a", ce qui atteste que "a" n’est pas un integer, mais un pointeur sur un integer, c.a.d. que le paramètre exprime où trouver la valeur, pas la valeur elle-même.
Implémentation des pointeurs en Gambas
Gambas possède le type de donnée
Pointer, et un jeu d’opérations adéquat. Pour employer un pointeur, une déclaration normale est nécessaire comme toute autre variable. Puis, une valeur doit lui être assignée. Quand on utilise une variable normale, on peut souvent lui assigner un littéral ; on peut, par exemple, écrire "a=3". Avec les pointeurs, ce n’est pas judicieux. Un pointeur donne accès à n’importe quel emplacement mémoire, mais vous devez savoir par avance quel emplacement vous intéresse, et cet endroit doit être le bon pour l’utilisation voulue, sinon Gambas ou le système d’exploitation seront mécontents. C’est pratiquement la même chose que de dire que vous ne pouvez pas écrire "MainForm = 3". Vous pouvez écrire "MainForm =
NEW()", ou "MainForm = AutreFormExistante", ou "MainForm =
NULL". Donc une assignation directe doit toujours être à NULL, à un autre pointeur, ou un appel à une fonction retournant un pointeur. Exactement comme une variable d’instance de classe telle que MainForm.
Parfois, une fonction externe retourne un pointeur, et ce pointeur sera requis pour invoquer des appels subséquents à la bibliothèque externe. Ce cas ressemble beaucoup à la création d’une feuille (Form), et à l’utilisation de cette référence pour agir sur la feuille elle-même. Dans ce cas, la donnée derrière (pointée par) le pointeur est dite "opaque": On ne peut pas voir à travers quelque chose d’opaque, donc nous ne savons pas et ne voulons pas savoir. C’est le cas le plus simple ; un exemple à ce propos est la bibliothèque LDAP. La première chose à faire pour interagir avec LDAP est d’ouvrir une connexion à un serveur. Toutes les opérations subséquentes seront faites sur la connexion créée par des appels spécifiques. Les choses se passent ainsi :
LIBRARY "libldap:2"
PRIVATE EXTERN ldap_init(host AS String, port AS Integer) AS Pointer
PRIVATE ldapconn as Pointer
...
ldapconn = ldap_init(host, 389)
IF ldapconn = NULL THEN error.Raise("Impossible de se connecter au server ldap ")
Comme nous l’avons déjà vu, une
LIBRARY est spécifiée. Puis, une fonction
EXTERN est déclarée; cette fonction est celle là même qui sera appelée pour faire quelque chose avec ldap. Les deux dernières lignes sont celles qui, à l’exécution, ouvriront la connexion et stockeront son point d’accès, ou son instance. Dans ce cas spécifique, ldap_init() retourne NULL si quelque chose s’est mal passé, aussi pouvons nous tester NULL pour générer une erreur. Une fois obtenu un point d’accès à la connexion, ce dernier doit être spécifié à chaque appel suivant de la bibliothèque ldap. Par exemple, pour supprimer une entrée de la base de donnée, ce qui suit doit être utilisé :
PRIVATE EXTERN ldap_delete_s(ldconn AS Pointeur, dn AS String) AS Integer
...
PUBLIC SUB remove(dn AS String) AS Integer
DIM res AS Integer
res = ldap_delete_s(ldapconn, dn)
Malheureusement, les choses ne sont pas toujours simples. L’une des raisons pour lesquelles le C utilise un pointeur, est de permettre à une sous-routine d’écrire les données à l’emplacement indiqué par les paramètres d’appel. Pour rester dans l’initialisation de bibliothèque, ALSA par exemple est différent. Pour initialiser un
dialog avec le séquenceur alsa, un point d’accès vers le séquenceur est nécessaire. La déclaration C de cette fonction est :
int snd_seq_open(snd_seq_t **seqp, const char * name, Int streams, Int mode);
Hé ! Qu’est-ce que c’est que ce "snd_seq_t **seqp"? Nous savons que l’astérisque est employée pour indiquer un pointeur – alors, que signifie un double astérisque ? C’est facile : un pointeur vers un pointeur. Cette fonction snd_seq_open() emploie la notation pointeur pour remplir une valeur; cette valeur est elle-même un pointeur. À la différence du cas LDAP, où la fonction ldap_init() retourne seulement une valeur, ici cette fonction retourne deux valeurs. Le résultat retourné par la fonction est un code d’erreur – toutes les fonctions ALSA utilisent ce schéma. Un résultat retourné valant zéro signifie le succès. Aussi, pour retourner plus d’une valeur, la fonction ne peut qu’écrire des données à un emplacement que nous lui spécifions, en faisant appel à un pointeur. La valeur qu’elle écrit a le type pointeur, aussi la notation "double pointeur" est-elle utilisée. Jusque là, ça va. Mais peut-on traduire cela en Gambas ? Oui et non. Nous avons besoin d’un pointeur, et là n’est pas le problème. Puis nous devons prendre l’adresse de ce pointeur, pour obtenir "un pointeur vers un pointeur". Gambas3 peut le faire, Gambas2 ne le peut pas.
Voyons la manière facile, valable uniquement en Gambas3. La fonction
VarPtr() retourne l’adresse d’une variable ou, en d’autres termes, un pointeur vers cette variable – et son nom le dit lui-même : VAR-PTR, "variable pointeur". En Gambas3 nous écririons :
PRIVATE EXTERN snd_seq_open(Pseq AS Pointeur, name AS String, streams AS Integer, mode AS Integer) AS Integer
...
PRIVATE AlsaHandler as Pointeur
...
err = snd_seq_open(VarPtr(AlsaHandler), "default", 0, 0)
La déclaration EXTERN dit que snd_seq_open() attend un pointeur, ce qui est vrai : snd_seq_open() attend un pointeur vers un pointeur, ce qui de toute façon est un pointeur. Aussi déclarons-nous une variable Alsahandler comme pointeur, et nous lui passons son adresse en utilisant VarPtr() qui retourne un pointeur sur la variable.
En Gambas 2 cela n’est pas possible – nous n’avons pas VarPtr(). Nous devons tout de même déclarer une variable pour contenir le point d’accès, comme avant, mais ensuite nous ne pouvons pas obtenir son adresse, ou un pointeur sur elle. Nous allons attaquer le problème par un autre côté. Nous devons trouver un emplacement en mémoire a passer à alsa et, après ça, aller chercher dans cet emplacement. En Gambas 2 la seule façon est la fonction
Alloc(). En utilisant Alloc(), nous réservons un morceau de mémoire quelque part, et nous obtenons son adresse. Cette adresse est ce dont nous avons besoin de transmettre à snd_seq_open() : un pointeur contient une adresse. Bien, peut-on commencer à écrire quelque chose? Oui :
' int snd_seq_open(snd_seq_t **seqp, const char * name, Int streams, Int mode);
PRIVATE EXTERN snd_seq_open(Pseq AS Pointer, name AS String, streams AS Integer, mode AS Integer) AS Integer
PRIVATE AlsaHandler as Pointer
...
DIM err AS Integer
DIM ret AS Pointer
ret = Alloc(4) ' 4 est la taille d’un pointeur pour les systèmes 32-bit; 8 pour les systèmes 64-bit.
err = snd_seq_open(ret, "default", 0, 0)
Quand nous voulons ouvrir la connexion et obtenir un point d’accès, nous réservons un peu de mémoire et lui passons son adresse, de manière à ce que snd_seq_open() y écrive des données utiles. Mais alors, comment pouvons-nous lire cet emplacement pour récupérer le point d’accès ? Ici arrive la fonctionnalité des pointeurs en Gambas. Les pointeurs peuvent fonctionner comme des flux – vous pouvez lire et écrire dedans. En réalité, la mémoire de l’ordinateur est une suite de cellules mémoire, d’accord ?
Nous pouvons lire une valeur à partir d’un pointeur avec :
À ce stade, nous avons réussi. C’est un peu comme une Odyssée, mais c’est valable ! Nous devons juste libérer la mémoire que nous avons réservée avec Alloc(), aussi l’Odyssée n’est-elle pas encore terminée. Cette mémoire a été utilisée de manière temporaire, et nous pouvons le négliger, mais si cette opération était faite de nombreuses fois dans un programme, le programme continuerait a consommer de la mémoire. Normalement Gambas a une gestion automatique de la mémoire, mais dans ce cas-là, cela ne nous est pas utile parce qu’il ne sait pas ce que nous faisons de cette mémoire. Nous sommes donc responsables de la libération de la mémoire quand nous en avons terminé avec elle :
Il y a d’autres raisons d’utiliser un pointeur. Prenez la déclaration de getloadavg(), une jolie fonction qui nous dit combien notre CPU a été occupé cette dernière minute :
int getloadavg(double loadavg[], int nelem);
Ce merveilleux langage C peut-il même passer des tableaux aux fonctions ? Oui. Et essayez de deviner comment il le fait ? Encore les pointeurs...
Dans ce cas, le tableau passé à la fonction sera rempli par une ou plusieurs valeurs, chacune signifiant une sorte de charge moyenne différente ; chaque valeur sera placée à des emplacements consécutifs dans le tableau. Mais C n’est pas assez futé pour savoir de quelle taille est un tableau, alors la fonction ne peut savoir combien de valeurs écrire. Nous devons le dire à la fonction, via le paramètre "nelem". Pour faire court, la déclaration correcte dans cette situation est :
EXTERN getloadavg(ploadavg AS Pointeur, nelem AS Integer) AS Integer
Il nous faut passer un pointeur, parce que la fonction getloadavg attend un pointeur, même si ça n’apparaît pas évident en regardant sa déclaration. Le pointeur doit pointer vers de la ram libre, parce que la fonction remplira la mémoire désignée par ce pointeur. Ensuite, nous lirons les valeurs et, finalement, nous libérerons la mémoire. Voici un exemple d’usage :
PUBLIC SUB get_load() AS Float
DIM p AS Pointer
DIM r AS Float
p = Alloc(8)
IF getloadavg(p, 1) <> 1 THEN
Free(p)
RETURN -1 ' error
ENDIF
READ #p, r
Free(p)
RETURN r
END
La sous-routine est limpide : nous allouons 8 octets parce qu’un float Gambas est long de 8 octets. Puis nous appelons getloadavg(), qui remplira ces 8 octets. Que l’opération réussisse ou non, nous devons libérer la mémoire allouée. Mais, si l’opération réussit, nous devons d’abord lire la mémoire. C’est pourquoi nous avons deux "free(p)" dans la sous-routine. Une façon plus élégante serait d’utiliser la clause
FINALLY, mais la première manière est plus proche de l’esprit du C...
getloadavg() retourne le nombre de valeurs lues. En ne demandant qu’une seule valeur, il est légitime d’interpréter un résultat de retour différent de un comme une erreur. Si nous en avions demandé trois, et obtenu seulement deux, nous aurions eu une drôle de situation - quelque chose entre un résultat correct et un échec. Ceci et d’autres drôles de choses peuvent se voir quand on essaye d’utiliser des anciennes interfaces. Par exemple, dans certaines versions d’Unix il n’y a pas de méthode claire pour lire un nom de fichier. La fonction retourne le nombre de caractères écrits, mais aucune indication que le nom est plus court que cela. Aussi, vous n’êtes sûr d’avoir lu le nom complet que si vous passez un tampon plus grand que le résultat de la fonction. Mais vous avez le résultat de la fonction
après l’appel, pas avant ! L’utilisation typique consiste à prendre une valeur arbitraire, disons 256, et de faire un premier essai. S’il échoue, vous ajoutez encore 256 octets, et réessayez. Et ainsi de suite...
Revenons à notre getloadavg(). Nous avons utilisé Alloc(8) parce qu’un float Gambas est long de 8 octets. Et nous avons utilisé un float pour nous interfacer avec un double C. Mais où est-il spécifié qu’un double C est long de 8 octets ? En fait, il existe des machines où un double comporte 10 octets. C’est un sérieux problème, parce que la sous-routine ci-dessus ne fonctionnera pas. Nous pourrions allouer plus de mémoire, peut-être 64 octets au lieu de 8 : je suis pratiquement certain qu’aucun calculateur existant utilise plus de 64 octets pour un nombre flottant. Mais de toute façon, essayer de lire une valeur sur 8 octets dans une valeur de 64-octets conduirait à un non-sens. Peut-être est-il préférable de laisser un programme se planter, plutôt que de donner l’impression qu’il fonctionne. Une question peut surgir... Comment un programme en C peut-il fonctionner sur autant d’architectures différentes ? La réponse est la suivante : parce qu'une nouvelle architecture, peut-être différente, doit avoir un jeu homogène de noyau, fichiers include et compilateur. Dans un programme C réel, vous ne verrez jamais une instruction comme "alloc(8)", mais à la place quelque chose comme "alloc(sizeof(double))". Le compilateur connaît la taille d’un double, et le mot-clé "sizeof" injecte le savoir du compilateur dans le programme source.
Complément à propos des pointeurs
À ce stade, une meilleure explication devient nécessaire. L’instruction
READ #ret,... lit quelque chose dans l’emplacement pointé par le pointeur "ret". Il est important d’insister encore pour dire que cette sorte de choses doit être conçue avec soin. Mal travailler avec les pointeurs est une des causes d’échec les plus courantes dans les programmes en C et, quand on utilise les pointeurs, Gambas ne se comportera pas autrement. Dans notre cas c’est facile, parce que nous avons fait notre travail en quelques lignes d’un seul jet.
La sémantique de l’instruction READ des pointeurs ressemble à celle de flux, mais avec une différence importante : alors que le flux est automatiquement avancé après une lecture ou une écriture, l’opération sur pointeur ne le fait pas. Si notre mémoire contient deux variables à lire, l’une après l’autre, nous devons décaler le pointeur par nous-mêmes :
READ #mypointeur, var1_4byte
mypointeur += 4
READ #mypointeur, var2_4byte
Comme vous pouvez le voir, il est possible de traiter un pointeur comme un integer. En utilisant ce mécanisme, on peut se déplacer en avant et en arrière dans la mémoire pour émuler ce qu’en C on appelle "struct". Une "C struct" est un groupe de variables hétérogènes mises les unes à côté des autres, qui peuvent ensuite être traitées comme une variable simple. Son homologue le plus proche dans Gambas est, à nouveau, une classe. Les structures sont souvent référencées par un pointeur, spécialement quand elles doivent être passées à une fonction. Nous verrons plus tard une méthode alternative pour implémenter ceci en Gambas, mais pour l’instant nous parlons de pointeurs, aussi nous allons donc terminer ce sujet. Le langage C a également les "unions", qui sont des choses inconnues dans Gambas, et qui doivent donc être émulées en utilisant les pointeurs (ce n’est pas complètement vrai). Les unions sont composées de deux variables, ou plus, qui partagent la même mémoire : écrire dans une variable modifie implicitement les autres : elles se recoupent. La finalité de ceci est de décrire en un unique type différentes dispositions. En combinant struct et union, des configurations complexes peuvent être engendrées, et ces dispositions sont difficiles non seulement à gérer, mais même à comprendre. Pour donner un exemple, nous allons encore parler du séquenceur ALSA. Le séquenceur fonctionne avec des évènements (essentiellement les notes à jouer) qui ont des marqueurs temporels indiquant "quand" ces évènements doivent être joués ou abandonnés. Ces marqueurs temporels peuvent être exprimés en ticks, ce qui est la manière traditionnelle en relation avec le métronome. Les ticks sont des integers normaux. Mais ALSA va plus loin, et permet d’utiliser des marqueurs temporels temps réel, une indication bien plus précise, utile pour synchroniser la musique avec d’autres éléments (vidéo, par exemple). Cette mesure est plus précise, et par conséquent elle nécessite plus de mémoire pour contenir la précision plus grande (deux integers). Il y a donc des évènements ayant un temps spécifié par 4 octets (un integer), et des évènements ayant un temps spécifié sur 8 octets. Ils auraient pu utiliser simplement deux champs, respectivement de 4 et 8 octets, l’un après l’autre. Mais en utilisant une union, on économise 4 octets. La mémoire réelle réservée pour les marqueurs temporels est de 8 octets, suffisante pour contenir l’une ou l’autre des valeurs, mais à un niveau logique, ces deux valeurs sont mutuellement exclusives. Tout ceci est géré automatiquement par le compilateur C. Quand on joue avec les unions en Gambas, on doit faire tout cela par soi-même.
Une Drum Machine en Gambas (software de percussion)
Un exemple concret concernant tout ce que nous avons vu jusque là est la bibliothèque ALSA : une drum machine simple, très basique, peut être implémentée en Gambas. D’abord, une drum machine, qu’est-ce que c’est ? C’est une machine qui émule la combinaison d’un joueur de batterie et d’un jeu de caisses. Beaucoup de musiciens l’emploient, en particulier ceux qui produisent de la musique par eux-mêmes. Dans le monde Gambas, on réalise cela en utilisant ALSA. ALSA est l’Architecture Linux
Sound Avancée, et son but est de fournir un jeu complet de fonctions pour produire des sons et, ainsi, de la musique. Du point de vue du calculateur, les sons génériques et la musique sont deux choses différentes. Si vous exécutez un fichier MP3, ALSA actionnera les hauts parleurs comme préconisé par les données MP3, sans connaître ni analyser quoi que ce soit. Nous nous intéressons à une autre sorte d’interface – L’interface séquenceur. Un séquenceur se débrouille avec les "événements", qui sont "joués" aux bons moments, en utilisant les paramètres appropriés (ou propriétés) de l’évènement. Si nous pensons à un joueur de piano, nous pouvons voir qu’il presse ses touches, une ou plusieurs ensemble, à différents moments. Simplement, chaque pression de touche est un évènement. Les trois choses les plus importantes quand on tape sur une touche de piano sont : 1) quand ; 2) quelle touche ; 3) avec quelle force. Si vous voulez jouer deux notes en même temps, vous créez deux évènements ayant le même "marqueur temporel". Si vous voulez faire un accord, vous créez trois évènements ayant le même temps, trois notes différentes, et (probablement) la même force. Ensuite, vous fournissez ces évènements au séquenceur, et il les enverra à quelque chose d’autre qui produira des sons. Le séquenceur ne se soucie pas de produire des sons : ceux-là peuvent l’être par un software, ou envoyés par une interface MIDI à un instrument de musique externe. Si, au lieu d’un piano, vous dites "je veux des trompettes", les trois notes seront jouées par trois trompettes. Cette interface ne spécifie pas "combien de temps dure la note": Elles sonneront jusqu'à ce qu’un autre évènement dise d’arrêter. Donc une simple note est en fait réalisée par deux évènements : un NOTE-MARCHE et un NOTE-ARRET. Dans le cas des caisses, une note identifie différents instruments de percussion : grosse caisse, caisse claire, cymbales, maracas, cloches, ou même sifflets et tant d’autres.
Music et calculateurs ont beaucoup en commun. Par exemple, une mesure musicale typique est divisée en quatre temps. Le nombre quatre est-il inhabituel pour les calculateurs ? Les touches du piano sont numérotées, et la force ("vélocité") du toucher peut être exprimée par un nombre, également, comme la durée de la note. Il y a d’autres valeurs mises en jeu, par exemple la puissance avec laquelle un joueur de flûte souffle dans son instrument (après que la note a débuté), mais nous n’irons pas si loin. Seulement permettez-moi de dire qu’un bon séquenceur, combiné à un bon matériel, peut étonnamment bien simuler un orchestre entier.
La drum machine simple présente une grille à l’écran, et chaque cellule de la grille représente une note : son numéro de ligne spécifie la note a jouer (sur une drum machine, différentes notes correspondent à différents accessoires ou instruments). La colonne de la cellule représente le temps auquel la note sera jouée. La grille comporte deux mesures qui sont jouées encore et encore – c’est suffisant pour construire un rythme normal. Chaque mesure est divisée en quatre temps, et chaque temps est divisé plus avant en quatre16èmes. La ligne du haut est une règle de visualisation, et la colonne la plus à gauche est utilisée pour écouter un instrument. Cliquer dans une cellule bascule un marqueur "o"; pour jouer le motif, cliquer le bouton "Play grid". Les autres boutons produisent d’autres sons, juste pour montrer les choses les plus simples comme les accords, les legati, les arpèges.
Pour que le programme produise des sons, le dispositif client et le port corrects (terminologie alsa) doivent être écrits dans les deux premières lignes de FMain.class, et dépendent du hardware installé. Un "aconnect -ol" envoyé depuis un terminal affiche les dispositifs appropriés. Si un synthétiseur software est présent, comme Timidity, il sera probablement vu comme "client 128". Le dispositif de sortie MIDI peut être le numéro 16. Le numéro de port peut être probablement toujours le numéro 0. Une autre manière de trouver la numérotation correcte est d’utiliser un séquenceur qui marche déjà ou un player MIDI, comme Kmidi, et de prendre sa configuration MIDI.
La raison principale d’analyser ce programme est d’examiner une interface complète avec une bibliothèque externe. La plupart des aspects ont été déjà présentés, mais une partie importante qui n’a pas encore été couverte est la manière de se débrouiller avec les structures C en utilisant les pointeurs.
Au lieu d’utiliser les pointeurs, une alternative est d’employer des variables déclarées dans une classe, puis de passer une instance de cette classe à une fonction externe ; ce n’est pas abordé ici : la méthode serait meilleure et plus claire, mais les pointeurs sont plus souples. Une approche encore meilleure est possible en Gambas3, qui possède des structures natives.
Parce que le programme est très spécifique à alsa, nous sauterons tout sauf la "event structure". Une fois effectuées toutes les choses requises par alsa (ouverture d’alsa, création de file d’attente, de ports, leur démarrage etc.), il reste juste à construire les évènements et à les envoyer à alsa, qui les jouera à l’instant correct. Un évènement est défini par alsa comme ceci :
snd_seq_event_type_t type
unsigned char flags
unsigned char tag
unsigned char queue
snd_seq_timestamp_t time
snd_seq_addr_t source
snd_seq_addr_t dest
union {
snd_seq_ev_note_t note
snd_seq_ev_ctrl_t control
snd_seq_ev_raw8_t raw8
snd_seq_ev_raw32_t raw32
snd_seq_ev_ext_t ext
snd_seq_ev_queue_control_t queue
snd_seq_timestamp_t time
snd_seq_addr_t addr
snd_seq_connect_t connect
snd_seq_result_t result
}
Les premières lignes, avant "union", sont communes à chaque type d’évènement ; en fait, elles contiennent le"type" de l’évènement , quelques "drapeaux", une "étiquette", la "file d’attente" où mettre en file d'attente l’évènement, la "source" (qui a créé l’évènement ?) et le "dest" (à qui envoyer cet évènement ?). Jetons un œil à la première ligne : "snd_seq_event_type_t type". Le champ est nommé "type", et ce type est "snd_seq_event_type_t type". Nous devons donc examiner la documentation pour trouver comment ce type est constitué. Nous trouvons :
typedef unsigned char snd_seq_event_type_t
La ligne ci-dessus exprime que "snd_seq_event_type_t" est un alias de "unsigned char". Un unsigned char est un octet.
Les trois champs suivants de la struct sont des drapeaux étiquette et file d’attente, tous de type unsigned char, donc des octets.
Ensuite, le marqueur temporel "time" est declaré comme un "snd_seq_timestamp_t" ; en cherchant encore la déclaration, nous trouvons que c’est une union contenant soit un tick midi (un unsigned int _ entier non signé) soit une struct qui est composée de deux unsigned int. Le résultat est que la longueur de ce champ est de 2 unsigned ints, soit 8 octets sur les systèmes 32-bit. La première partie d’un évènement est composée, à notre point de vue, des champs suivants :
type_d_évènement un seul octet
drapeaux un seul octet
étiquette un seul octet
file d’attente un seul octet
marqueur_temps, composé de :
tick (int) ou tv_sec un entier
tv_nsec un entier
Si nous voulons remplir le champ "tick", nous devons diriger un pointeur vers le début de la mémoire de l'évènement , puis avancer le pointeur de 4 octets, puis écrire dans le pointeur la valeur désirée (un entier).
La classe CAlsa alloue la mémoire pour un seul évènement :
PUBLIC SUB alsa_open(myname AS String)
...
...
' alloue un évènement pour travailler avec. Il est global pour éviter le fardeau d’allocation/désallocation
ev = Alloc(SIZE_DE_SEQEV)
et manipule ensuite cette mémoire encore et encore avant de passer l’évènement à alsa. La sous-routine prepareev() efface l’évènement et remplit la partie commune. Voici sa déclaration :
PRIVATE SUB prepareev(type AS Octet, flags AS Octet, ts AS Integer) AS Pointeur
DIM p AS Pointeur
DIM i AS Integer
Les paramètres de la fonction reflètent ce qui nous intéresse - par exemple, nous ne sommes pas intéressés par le champ "tag", aussi ne passons-nous pas de valeur pour lui. La première étape est d’effacer l’évènement, pour être sûr qu’aucune donnée non voulue soit restée là :
' Efface l’evènement
p = ev
b = 0
FOR i = 1 TO SIZE_OF_SEQEV
WRITE #p, b
INC p
NEXT
Le pointeur "p" est dirigé vers le début de l’évènement avec "p = ev". Pour être sûr d'écrire uniquement un seul octet au lieu de deux ou quatre à chaque itération, nous employons une variable temporaire déclarée comme octet : b=0. En utilisant une boucle for-next, un flux de zéros est écrit en sortie. Au lieu d’employer une variable temporaire, nous aurions aussi pu employer l’instruction "
WRITE #p, chr(0), 1". Cette dernière instruction est une forme spéciale utilisable pour les chaînes, qui spécifie combien d’octets de la chaîne doivent êtres écrits. Si on ne spécifie pas le nombre d’octets, cad. si on utilise "WRITE #p, chaîne", Gambas écrit toute la chaîne, mais précédée d’une représentation binaire de la longueur de la chaîne ; ce n’est probablement pas ce que nous voulons dans cette situation.
Un meilleur algorithme pour effacer l’évènement serait d’écrire 4 octets à la fois, et réduit la boucle d’1/4. Une façon encore meilleure serait de simplement réécrire les champs qui nous intéressent, et d’effacer seulement les champs que nous savons être utilisés.
Après effacement de l’évènement, nous commençons à remplir les champs pertinents. Nous dirigeons à nouveau notre pointeur "p" à l’endroit correct (nous le déplaçons, vous vous souvenez ?), nous écrivons la valeur, et déplaçons le pointeur plus avant :
p = ev
WRITE #p, type
p += 1 ' maintenant p pointe vers le champ flag
Le reste de la routine est une répétition de ce que nous venons de voir. Au moment d’écrire le marqueur temporel, qui est une union C, on a le code suivant :
WRITE #p, ts ' marqueur temporel
p += 4
Cette simple instruction écrit le premier des deux entiers de "snd_seq_timestamp_t time".
Puis :
ts = 0
WRITE #p, ts ' 2ème partie (évènement temps réel)
p += 4
Bien, ces trois lignes ne sont pas nécessaires. Nous effaçons toute la mémoire avant, il n’y a donc pas besoin de mettre un quelconque champ à zéro. Mais nous devons quand même déplacer le pointeur. Les deux blocks de code précédents pourraient être ainsi :
WRITE #p, ts ' marqueur temporel
p += 8
La sous-routine prepareev() est appelée par noteon() et noteoff(), qui continuent à remplir l’évènement avec leurs propres données. La sous-routine noteon() est ceci :
PUBLIC SUB noteon(ts AS Integer, channel AS Byte, note AS Byte, velocity AS Byte)
DIM p AS Pointer
DIM err AS Integer
p = prepareev(SND_SEQ_EVENT_NOTEON, 0, ts)
WRITE #p, channel
INC p
WRITE #p, note
INC p
WRITE #p, velocity
err = snd_seq_event_output_buffer(handle, ev)
La sous-routine accepte un marqueur temporel "ts", qui explicite -quand- jouer la note, et se réfère au moment où la file d’attente a été démarrée. Un marqueur temporel de zéro, ou bien une valeur inférieure à l’heure actuelle de la file d’attente, est joué immédiatement. Si le marqueur temporel est plus grand que l’heure de la file d’attente, L’évènement sera alors joué dans le futur, quand la file d’attente atteindra l’heure correcte. Mais alsa peut également utiliser des marqueur temporel relatifs, en positionnant un drapeau dans l’évènement. La classe Gambas CAlsa utilise cette possibilité en l’encapsulant directement dans le marqueur temporel. Si le paramètre ts pour prepareev() est mis à une valeur négative, la routine inverse son signe et positionne le drapeau relatif. Dans le programme principal, la routine btArpeggio_Click() produit trois notes à la suite grâce à cette possibilité :
PUBLIC SUB btArpeggio_Click()
alsa.noteon(0, 0, 60, 100)
alsa.noteoff(-100, 0, 60, 100)
alsa.noteon(-100, 0, 64, 100)
alsa.noteoff(-200, 0, 64, 100)
alsa.noteon(-200, 0, 67, 100)
alsa.noteoff(-300, 0, 67, 100)
alsa.flush
END
La sous-routine démarre la première note au temps 0, ce qui signifie "maintenant ". Après 100 ticks, la note est arrêtée, et une nouvelle note est démarrée (timestamp = -100 signifie "100 ticks après 0 -- 100 ticks après maintenant "). Et ainsi de suite pour les autres notes.
Etant donné que jouer les notes est la tâche la plus courante, et que pour jouer une note on doit créer un note-on et un note-off, la classe CAlsa implémente une routine pour faire ça dans un même appel. La routine suivante utilise un simple appel à playnote(), qui à son tour génère en interne deux évènements :
' 32 notes ayant une durée inférieur à leur espacement
' "staccato": chaque note se termine bien avant le début de la suivante
PUBLIC SUB btStaccato_Click()
DIM i AS Integer
FOR i = 1 TO 32
' marqueur temporaire relatif =10, 20, 30... pas successifs par 10
' mais les notes ont une durée =5, et non 10
alsa.playnote(-10 * i, 0, 60 + i, 100, 5)
NEXT
alsa.flush
END
utilise un appel simple à playnote(), qui à son tour produit de façon interne deux évènements.
Enfin, et pour être précis, voici une explication de l’algorithme de la drum machine.
L’essentiel est de produire un flux d’évènements, qui sera joué plus tard. Nous devons préparer d’avance un paquet d’évènements, ainsi le hardware a des données pour travailler. Mais une drum machine peut être conservée pendant longtemps, et nous ne pouvons pas mettre en tampon tous les évènements nécessaires - nous devons fournir un certain nombre d’évènements en avance sur le timing, pas trop mais pas trop peu. Le "pointeur" interne de la drum machine place toujours une mesure musicale d’avance sur le son que nous entendons. On ne peut pas prédire de manière fiable "quand" de nouvelles données seront nécessaires, parce que le séquenceur consomme les évènements en se basant sur un chronomètre qui peut être différent du nôtre. Faute d’information en retour du séquenceur, il est pratiquement impossible de conserver le synchronisme. Le problème est même plus difficile si nous voulons avoir un suivi visuel de ce que le séquenceur joue à un moment donné. Ceci est résolu en ajoutant, au flux d’évènement, un évènement dont le but n’est pas de produire du son, mais de nous le renvoyer : un écho. Le séquenceur reçoit ces évènements additionnels, et nous les renvoie au moment adéquat. Quand nous voyons ces échos, nous savons où en est le séquenceur. La drum machine Gambas envoie un écho pour chaque quart temps, et utilise cette information, quand elle revient, pour fournir un retour visuel. Ces évènements échos peuvent contenir des données utilisateur – ces données sont utilisées pour les distinguer ; dans le programme, cela peut marquer un début de mesure, et à cette condition une nouvelle mesure est chargée (mise en tampon par avance) vers le séquenceur.
Le problème, dans ce programme, est que l’interface alsa normale ne fournit pas de retour pour signaler quand un évènement est prêt à être lu (même s’il le faisait, Gambas2 ne pourrait pas l’utiliser). C’est simulé par la classe CAlsa, qui lève un évènement Gambas quand un évènement alsa de type "écho" a été lu, mais la classe CAlsa elle-même utilise le polling pour interroger alsa. La fréquence de ce polling peut être modifiée via le curseur "poll freq" du programme principal. Le positionnement de cette fréquence à des valeurs faibles peut produire un retour visuel imprécis de la drum machine, mais la précision de la musique n’en sera pas affecté. En réalité, sur au moins une de mes machines les plus lentes, il apparaît que les taux de polling lents fonctionnent mieux que les plus rapides. Cela peut s’expliquer, sans doute, en considérant que faire du polling a un coût CPU : avec un polling trop fréquent, ma machine sollicite trop la CPU et lui fait perdre de sa disponibilité.