Un navigateur versé dans la RAM, un clavier MIDI à la barre

Un navigateur versé dans la RAM, un clavier MIDI à la barre

Dans l'article précédent, je parlais de la philosophie générale d'AMSpiriT Lite, et notamment de cette API web qui le distingue de la plupart des émulateurs CPC : une interface REST toute bête, qu'on peut interroger avec curl depuis un terminal, mais qui ouvre en réalité la porte à des usages qu'on n'imaginerait pas avec un émulateur « fermé ».

Cette série a pour but de revenir en détail sur les principaux endpoints et les fonctionnalités phares de l'émulateur. Mais plutôt que d'aligner une liste d'endpoints, ce qui serait vite rébarbatif, on va procéder à partir de cas d'usage concrets. Dans ce billet, on commence par l'un des endpoints les plus utilisés, /api/ram, en l'illustrant avec un petit outil : le Curve Injector.

Cet outil, disponible sur le dépôt GitHub amspirit-releases, est un générateur de courbes qui tourne dans un navigateur (c'est une simple page web), et qui permet d'écrire les données générées directement dans la RAM de l'émulateur. Rien de plus, rien de moins – mais ce « rien de plus » ouvre pas mal de portes. Et comme on aime expérimenter, on va piloter le tout avec un périphérique MIDI. Voici une vidéo pour illustrer comment ça se passe :

0:00
/0:27

L'outil est né d'une situation classique en cours de développement : on veut régler une table de valeurs pour caler une animation. Quand on fait cela, on tâtonne : on essaye une formule, on réassemble, on transfère, on teste, et on recommence. On passe son temps à remplacer la table sans toucher au reste du code. Comment simplifier ces allers-retours ?

Comme l'écriture en RAM peut être pilotée par une API HTTP accessible depuis n'importe quel programme, la réponse la plus rapide n'était pas « je recompile et je recharge », mais « j'écris une page web qui génère la table et la pousse en RAM en direct ». Et ça marche même sans le code source : il suffit qu'un programme tourne dans l'émulateur, et qu'on sache à quelle adresse injecter les données.

Repris et retravaillé, l'outil propose aujourd'hui :

  • un générateur paramétrique (sinus, triangle, dents de scie, carré, impulsion, bruit), dont on règle le centre, l'amplitude, la période, la phase et le rapport cyclique,
  • un mode équation (une expression JavaScript, pour les cas que le mode paramétrique ne couvre pas),
  • un mode dessin libre à la souris,
  • un panneau Output Encoding pour filtrer la sortie : bornes et masques numériques en mode Data, ou conversion directe en couleurs via le mode Gate Array,
  • et un contrôle MIDI optionnel sur chaque paramètre.

Le tout tient dans un unique fichier HTML autonome – pas de serveur, pas de build : on double-clique dessus et ça marche. Piloter un CPC émulé depuis un clavier MIDI n'est probablement qu'un exemple parmi d'autres de ce que cette API ouverte rend possible. Voyons tout cela de plus près !

Allô, AMSpiriT ?

Le point de départ de tout ça, c'est qu'AMSpiriT expose une petite API REST sur http://127.0.0.1:6128, que l'on active à la demande. La plupart des exemples de l'article précédent reposaient sur deux endpoints : /api/ram, pour lire et écrire la mémoire, et /api/screenshot, pour récupérer une image de l'écran. On commence ici par le premier des deux, puisque c'est lui qui fait tout le travail dans le Curve Injector. Mais avant toute chose, il faut s'assurer que l'on arrive bien à dialoguer avec AMSpiriT.

/api/ping : un premier coup de sonde

Avant de se lancer dans quoi que ce soit, il faut vérifier que tout ce petit monde est bien réveillé. C'est le rôle de /api/ping, qui renvoie l'état global de l'émulateur. C'est d'ailleurs le premier endpoint qu'appelle le Curve Injector au chargement de la page.

Avec AMSpiriT lancé sur la machine, il suffit d'ouvrir l'URL http://127.0.0.1:6128/api/ping dans un navigateur :

On obtient le même résultat avec la commande curl dans un terminal :

$ curl -s http://127.0.0.1:6128/api/ping

{"ok":true,"emu":{"fps":50.1,"frame_ms":11.8,"frames":32395,"paused":false,
"cpc_model":"6128","crtc_type":0,"autotyping":false,"autotype_remaining":0,
"tl_active":false,"tl_steps_back":0,"tl_steps_fwd":0,"tl_step_kind":"frame",
"ram_apply_seq":0},"frontend":"lite","version":"1.15.4","core":2491682}

Voici le détail des champs :

  • frontend, version et core permettent d'identifier la version exacte d'AMSpiriT : ici la version Lite 1.15.4, avec l'identifiant du core d'émulation.
  • emu renseigne sur la machine émulée et son état global. En particulier :
    • cpc_model et crtc_type identifient le type de machine émulée.
    • frames donne le nombre de frames émulées depuis le démarrage, et sert d'horloge de référence. Tant qu'il reste à 0, le Z80 n'a pas encore exécuté une seule instruction, même si le serveur HTTP, lui, répond déjà.
    • autotyping et autotype_remaining renseignent sur la saisie automatique de texte en cours. C'est utile pour attendre la fin d'un envoi de texte avant d'enchaîner sur autre chose, mais ça ne nous concerne pas ici.
    • tl_active, tl_steps_back, tl_steps_fwd et tl_step_kind décrivent l'état du timelapse (le retour en arrière/avant dans l'exécution). C'est là aussi hors sujet pour le Curve Injector.
    • ram_apply_seq est un compteur d'écritures en RAM effectivement appliquées. Celui-ci va nous intéresser : on en parle juste en dessous.

Jeter l'encre

Le Curve Injector, comme son nom l'indique, écrit des données en RAM. Pour ce faire, il suffit d'envoyer une requête POST avec un corps JSON :

curl -s -X POST http://127.0.0.1:6128/api/ram \
    -H "Content-Type: application/json" \
    -d '{"addr": 49152, "bank":0,
         "data" : "ed5fe61ff64001107fed49ed7918f1",
         "exec":true, "entry": 49152 }'

{"ok":true,"seq":1}

Ici, on écrit une séquence d'octets, passée sous forme de chaîne hexadécimale (data), à l'adresse 49152 : c'est-à-dire 0xC000, le début de la mémoire vidéo par défaut du CPC. L'adresse est donnée en décimal, car JSON n'a pas de notation hexadécimale pour les entiers, et le champ addr doit être un entier : une chaîne comme "0xC000" serait refusée.

Les paramètres optionnels exec et entry indiquent que l'on souhaite exécuter le code transféré, et à partir de quelle adresse. Ici, la séquence d'octets correspond à un petit programme qui change aléatoirement la couleur de la bordure, en boucle (un effet « raster ») :

LD A,R          ; ED 5F     : une valeur pseudo-aléatoire 
AND #1F         ; E6 1F     : on garde les 5 bits du code couleur
OR #40          ; F6 40     : on en fait une commande couleur
LD BC,#7F10     ; 01 10 7F  : port du Gate Array, stylo 16 (la bordure)
OUT (C),C       ; ED 49     : sélection du stylo 
OUT (C),A       ; ED 79     : envoi de la couleur
JR debut        ; 18 F1     : et on recommence

On a mis exec à true pour que le programme démarre dès que le transfert est terminé. Voici ce que ça donne :

Notez le petit segment de pixels colorés au-dessus du mot « Amstrad » : ce sont les octets de notre programme. Ils sont visibles, car on les a écrits au début de la mémoire vidéo.

Le paramètre bank est optionnel : il indique dans quelle banque physique de 16 Ko on souhaite écrire, de 0 à 3 pour les quatre banques qui composent les 64 Ko de base d'un CPC, 4 et au-delà pour les banques d'extension sur les machines qui en ont (128 Ko et plus). Les plus attentifs auront noté une petite subtilité : une adresse au-delà de 0x3FFF (16 Ko) déborde sur les banques suivantes. Comme 0xC000 vaut 3 × 0x4000, écrire à l'adresse 0xC000 depuis la banque 0 revient à écrire dans la banque physique 3, à l'offset 0. On aurait donc pu utiliser addr=0 et bank=3, mais partir de la banque 0 avec l'adresse « habituelle » est plus parlant.

Saine lecture

Même si le Curve Injector n'a pas besoin de lire la RAM, on va quand même détailler cet aspect de l'endpoint /api/ram. La lecture se fait avec un simple GET, cette fois sans JSON :

GET /api/ram?addr=0xC000&len=256&view=raw&bank=0

addr et len définissent sans surprise l'adresse et la longueur du bloc à lire. Cette fois, on peut donner l'adresse en hexadécimal, car il s'agit d'un paramètre de l'URL et non plus d'un champ JSON.

bank joue le même rôle qu'en écriture. view, en revanche, mérite un mot d'explication : le CPC sait basculer ses banques mémoire et superposer ses ROMs à la RAM, donc lire « l'adresse 0xC000 » peut avoir plusieurs significations tant qu'on n'a pas précisé à travers quel mapping on regarde. Trois vues sont disponibles :

  • raw (par défaut) : le contenu brut de la banque demandée, sans tenir compte de la configuration mémoire du moment.
  • cpu : la mémoire telle que le Z80 la voit à cet instant, avec les ROMs et les banques actuellement connectées. C'est la vue qu'on veut dès qu'on cherche à observer ce qu'exécute réellement un programme en cours, typiquement pour le déboguer.
  • fw : la vue firmware, qui donne accès aux ROMs (la ROM basse et la ROM haute active).

Dans notre cas, adresser directement les banques avec raw est le plus adapté.

La réponse revient sous la forme d'une simple chaîne hexadécimale ({"hex": "004080c0..."}), deux caractères par octet, sans séparateur. Là encore, on peut vérifier rapidement dans le navigateur que ça fonctionne :

Si tu ne m'acquittes pas... tout peut s'oublier

On a vu qu'après une commande d'écriture, AMSpiriT répond immédiatement par {"ok":true,"seq":N}. Cette réponse est un simple accusé de réception : elle indique que l'écriture a été reçue et mise en file d'attente, pas qu'elle a déjà été appliquée. L'émulateur ne l'effectuera qu'au prochain tour de sa boucle principale.

C'est là que ram_apply_seq, croisé plus haut dans /api/ping, entre en jeu. Il suffit de le comparer au numéro N renvoyé dans le champ seq : dès que ram_apply_seq est supérieur ou égal à N, l'écriture a effectivement eu lieu.

Autrement dit : relire la RAM juste après avoir reçu {"ok":true,"seq":N} peut renvoyer l'ancien contenu, si on est plus rapide que la boucle principale. La bonne pratique est donc d'interroger /api/ping jusqu'à ce que ram_apply_seq atteigne N avant de faire confiance à une relecture :

# Écriture de 4 octets en 0xC000
$ curl -s -X POST http://127.0.0.1:6128/api/ram \
    -H "Content-Type: application/json" \
    -d '{"addr": 49152, "data": "ff00ff00"}'
=> {"ok":true,"seq":5}

# Vérification que l'écriture a eu lieu
$ curl -s http://127.0.0.1:6128/api/ping | grep -o '"ram_apply_seq":[0-9]*'
=> "ram_apply_seq":5

# C'est bon, on peut relire les données
$ curl -s "http://127.0.0.1:6128/api/ram?addr=0xC000&len=4&bank=0"
=> {"addr":49152,"len":4,"hex":"ff00ff00"}

Ici, ram_apply_seq vaut déjà 5 au moment du ping – la boucle a eu le temps de tourner entre les deux appels curl – et la relecture confirme bien les octets envoyés. Comme le compteur est global, il permet aussi de repérer si d'autres écritures ont été appliquées entre-temps : ram_apply_seq dépasse alors N.

La RAM vidéo fait des vagues

Bien ! Assez de théorie ! Il est temps d'utiliser notre outil, avec un premier exemple bête et méchant : injecter directement la courbe générée dans les 16 Ko de mémoire vidéo à partir de 0xC000. La longueur de l'injection monte ici à 16 384 octets : de quoi remplir l'écran entier avec le motif choisi, en une seule requête POST /api/ram. Une vidéo pour illustrer tout ça :

0:00
/0:22

C'est un bon test de stress pour l'outil (16 Ko encodés en hexadécimal, ça fait 32 768 caractères dans le corps JSON), mais c'est surtout un exemple pédagogique : la relation entre la forme de la courbe éditée dans le navigateur et les octets qui atterrissent en mémoire vidéo est immédiatement visible à l'écran. Ce qui s'affiche, ce sont les octets bruts, interprétés à travers le codage des pixels propre au CPC.

Garder le cap

La vidéo en tête de l'article montrait une utilisation plus classique de l'outil : on injecte une table de valeurs à l'adresse exacte utilisée par le programme qui tourne, et on regarde l'effet réagir en temps réel pendant qu'on manipule l'amplitude, la période ou la phase depuis la page.

Dans ce cas, une courbe « brute » est rarement exploitable telle quelle : le programme qui lit la table attend des valeurs dans une plage précise, parfois avec certains bits imposés, et il ne s'agit pas de laisser une amplitude un peu trop généreuse écrire n'importe quoi en mémoire.

C'est pourquoi, une fois la courbe calculée, chaque échantillon passe par un encodeur avant d'être envoyé à /api/ram. C'est le rôle du panneau Output Encoding, dans son mode par défaut : le mode Data. Le panneau Output Preview, situé sous l'aperçu de la courbe, montre le résultat de ce filtrage : la courbe telle qu'elle sera réellement écrite en RAM,

Le pipeline est volontairement simple, et s'applique dans cet ordre :

  1. Bornes : on fixe une valeur minimale et une valeur maximale. Ce qui dépasse est ramené dans la plage, selon l'un des deux modes proposés :
    • Saturate : la valeur est écrêtée sur la borne la plus proche. Une sinusoïde trop ample se retrouve « aplatie » en haut et en bas.
    • Modulo : la valeur « reboucle » à l'intérieur de la plage. Ce qui sort par le haut rentre par le bas, pratique pour un défilement ou un index qui doit tourner en boucle.
  2. AND (optionnel) : un masque hexadécimal appliqué après les bornes, pour ne conserver que certains bits.
  3. OR (optionnel) : un second masque, appliqué ensuite, pour forcer certains bits à 1.
  4. Taille : chaque échantillon est enfin écrit sur 1 ou 2 octets. En 2 octets, les valeurs sont écrites en little-endian (octet de poids faible en premier), c'est-à-dire l'ordre natif du Z80 : un LD HL,(table) récupère directement la bonne valeur. C'est utile par exemple pour une table d'adresses écran ou de déplacements sur 16 bits.

La combinaison AND/OR est particulièrement pratique sur CPC, où l'on manipule souvent des octets dont une partie des bits a une signification fixe. Un exemple immédiat : on l'a vu avec notre petit programme raster, pour changer une couleur, on envoie au Gate Array un octet de la forme 010xxxxx, dont les 5 bits de poids faible sont le code couleur. Le programme faisait AND #1F puis OR #40 sur une valeur aléatoire. En appliquant les mêmes masques dans l'encodeur, n'importe quelle courbe produit à coup sûr une table de commandes couleur valides, directement utilisables par un OUT (C),A.

Cet exemple AND/OR produit des codes Gate Array valides, mais dans l'ordre des codes matériels, qui n'a aucun sens visuel : deux codes voisins peuvent donner des couleurs sans rapport. Pour obtenir de vrais dégradés, il faut aller un cran plus loin.

La courbe prend des couleurs

C'est ce qui a donné naissance au second mode d'encodage : faire en sorte que la courbe produise directement des couleurs, plutôt que des octets numériques « quelconques ».

Un petit rappel s'impose sur la façon dont le CPC gère ses couleurs, car elle diffère de la plupart des machines 8 bits de l'époque : pas de palette de teintes choisies une à une et figées dans le circuit vidéo, mais une vraie synthèse additive RVB, avec trois niveaux (0 %, 50 %, 100 %) sur chacune des composantes rouge, verte et bleue, soit 3³ = 27 couleurs possibles.

Chaque couleur possède un numéro BASIC (celui qu'on passe à INK, de 0 à 26, qui n'est qu'une décomposition en base 3 des trois composantes) et un code Gate Array correspondant : la valeur matérielle qu'un programme envoie réellement via OUT pour l'appliquer. Les deux ne se confondent pas : la correspondance entre eux n'est pas arithmétique, et sur les 32 valeurs que permettent les 5 bits du code Gate Array, 5 sont redondantes (la combinatoire RVB ne monte qu'à 27).

0:00
/0:38

C'est tout l'objet du second mode du panneau Output Encoding : le mode Gate Array. Les masques disparaissent : la valeur de la courbe, toujours ramenée dans ses bornes par saturation ou modulo, désigne désormais une position dans un dégradé de couleurs. La couleur trouvée à cette position est ensuite convertie en son code Gate Array.

Plusieurs familles de dégradés sont proposées :

  • les cercles chromatiques : teintes vives, pastel, sombres, ou l'ensemble du spectre,
  • une rampe de luminosité traversant les 27 couleurs, du noir au blanc,
  • une rampe par teinte (rouge, vert, bleu, cyan, magenta, jaune), du noir au blanc,
  • pour les trois teintes primaires, une variante qui dérive vers une teinte voisine en montant en intensité : le rouge qui glisse vers l'orange puis le jaune est, sans surprise, la base d'un bon dégradé de flamme.

Les palettes sont définies dans de simples tableaux au début du script : il est facile de les modifier ou d'en ajouter à votre goût.

Comme en mode Data, le panneau Output Preview montre exactement ce qui sera écrit en RAM – mais cette fois sous la forme d'une frise des couleurs réelles, plutôt que d'une courbe numérique.

MIDI pile !

Dans certaines des vidéos, vous aurez remarqué que les paramètres de la courbe sont pilotés depuis un contrôleur MIDI physique. Rien d'exotique côté navigateur : le Curve Injector s'appuie simplement sur l'API Web MIDI, disponible nativement dans les navigateurs basés sur Chromium (Chrome, Edge...). Tout commence par une demande d'accès, qui déclenche une fenêtre de permission dans le navigateur :

navigator.requestMIDIAccess({sysex: false}).then(function(access) {
  access.inputs.forEach(function(input) {
    input.onmidimessage = onMidiMessage;
  });
});

Une fois l'accès accordé, chaque entrée MIDI détectée (access.inputs) se voit attribuer un gestionnaire onmidimessage. Chaque message arrive sous la forme d'un petit tableau d'octets (evt.data) qu'il faut décoder à la main – le standard MIDI n'a rien d'un JSON. Le Curve Injector ne s'intéresse qu'aux messages Control Change (CC), reconnaissables à leur premier octet :

function onMidiMessage(evt) {
  var data = evt.data;
  if (!data || data.length < 3) return;
  if ((data[0] & 0xF0) !== 0xB0) return;   // on ignore tout ce qui n'est pas un CC
  var channel = data[0] & 0x0F;             // canal MIDI, 0-15
  var cc      = data[1];                    // numéro de contrôleur, 0-127
  var value   = data[2];                    // valeur reçue, 0-127
  // ...
}

Vient ensuite le mécanisme d'apprentissage (« Learn ») : cliquer sur le bouton associé à un paramètre met l'outil en écoute, et le premier CC reçu sur n'importe quelle entrée est associé à ce paramètre. Plus besoin d'aller chercher dans la doc du contrôleur quel numéro de CC correspond à quel potentiomètre :

function captureLearn(deviceId, deviceName, channel, cc, value) {
  var m = midiMapping[learningParam];
  m.device  = deviceId;
  m.channel = channel;
  m.cc      = cc;
  // une valeur de 63 ou 65 trahit un encodeur relatif 
  // plutôt qu'un potard absolu
  m.type = (value === 63 || value === 65) ? 'relative' : 'absolute';
}

Cette dernière ligne mérite une explication : selon le contrôleur, un CC peut porter deux logiques différentes. Un potentiomètre ou un fader envoie une valeur absolue (0-127), qu'on met à l'échelle linéairement sur la plage du paramètre visé – y compris pour la forme d'onde, la plage 0-127 étant alors découpée en tranches égales, une par forme disponible. Un encodeur rotatif sans butée, lui, n'a pas de position absolue à transmettre : il envoie généralement 65 pour « +1 cran » et 63 pour « -1 cran », une valeur relative qu'il faut accumuler pas à pas plutôt que projeter directement :

if (m.type === 'relative') {
  var delta = (value === 65) ? 1 : (value === 63) ? -1 : 0;
  newVal = cur + delta * step;
} else {
  newVal = min + (value / 127) * (max - min);
}

Une fois la nouvelle valeur calculée, elle est appliquée au paramètre exactement comme si l'utilisateur avait déplacé le curseur à la souris : le CC MIDI n'est qu'une source d'événements de plus, au même titre qu'un input ou un change du DOM. C'est cette indifférence à la source qui fait que le reste de la chaîne (régénération de la courbe, encodage, envoi vers /api/ram) n'a rien à savoir du MIDI.

La chaîne complète, c'est donc : contrôleur MIDI → événement Web MIDI dans le navigateur → mise à jour du paramètre → régénération de la courbe → encodage → envoi via /api/ram → RAM émulée → rendu à l'écran.

C'est exactement le genre d'assemblage improbable-mais-évident que permet une API HTTP simple sur un émulateur : le contrôleur MIDI, le navigateur et l'émulateur ont été conçus indépendamment, mais ils s'interfacent simplement, et peuvent travailler ensemble.

Voila! J'espere que ce petit article vous aura donné des idées. Le Curve Injector est disponible sur le dépôt amspirit-releases. Comme toujours, prenez-le en main, bidouillez-le, faites-en quelque chose d'utile pour vous, et n’hésitez pas a le partager!

Siko / Logon System
Septembre 2026

🇬🇧 English version