De pager à talkie-walkie : le M5Stack converse désormais avec Mistral Vibe

Trois mois après le pager hardware, trois nouvelles versions : menu de configuration, dictée Voxtral au micro du device, migration vers l'API d'approbation native de Vibe 2.23 et réponses lues à voix haute par TTS. Le bouton physique est devenu un talkie-walkie d'agent — récit technique, bugs compris.

Photo du boîtier rouge M5Stack Fire posé sur un bureau en bois chaudement éclairé, écran affichant le Chaton Fat blanc en pixel art couché sur un fond arc-en-ciel rayé avec la mention « Credits N/A », une matrice LED hexagonale éteinte posée sur le dessus, fer à souder et bande LED floues en arrière-plan
Le M5Stack Fire au repos, Chaton Fat en vedette, matrice hexagonale posée dessus
Sommaire
Résumer avec l'IA

Suite directe de « Mistral Vibe sans amarrage : pourquoi j'ai développé un pager hardware ». En mai, le M5Stack savait alerter et valider. Trois releases plus tard (0.4.0 → 0.6.0), il configure, écoute et parle. Récit de ce qui a changé — côté device, côté Mistral Vibe, et côté façon de travailler avec un agent.


À retenir

  • Le pager d'amarrage est devenu un talkie-walkie d'agent : on dicte une instruction au micro du M5Stack (appui long A), Voxtral la transcrit, le tour démarre dans Vibe — et la réponse finale est lue à voix haute par le haut-parleur du Fire (TTS Voxtral). La boucle conversationnelle est fermée, sans toucher le clavier.
  • Le plaidoyer de mai a été exaucé. Mistral Vibe 2.23 a remplacé AgentLoop.set_approval_callback par un InteractionRequestBroker : les approbations sont des événements avec request_id, résolus via une API publique. La course « modal TUI contre bouton physique » est devenue native — et j'ai pu supprimer tout le monkey-patch.
  • v0.4.0 : menu de configuration sur le device (quiet mode, luminosité LED, source micro, langue de voix…) persisté en NVS — et un sélecteur de « modèle » qui propose le Chaton Fat 😼, « le nouveau modèle Mistral ».
  • Le coût caché du TTS embarqué : une queue Bluetooth SPP de 512 octets qui jetait des octets en plein stream, un DAC I2S qui jouait 5,5× trop vite (la voix « accélérée »), un asyncio.sleep Windows qui hachait le débit. Trois bugs, trois leçons d'embedded.
  • Le making-of vaut l'histoire : quinze briefs écrits pour Mistral Vibe (l'implémenteur), une règle qui a tout changé — « compile avant chaque commit, sinon c'est un échec » — et un git reset --hard de trop, récupéré via les dangling blobs de git.

1. Où en était-on

Fin mai, vibe-m5stack savait faire une chose très bien : casser l'amarrage. Quand l'agent demande la permission d'écrire un fichier ou de pousser un commit, le M5Stack Fire s'allume, les LED se voient à cinq mètres, et on valide au bouton — depuis la cuisine. Le transport est passé de l'USB au Bluetooth Classic SPP, le firmware affichait un statut ambiant continu (il réfléchit, il lit, il écrit, il m'attend, il est mort), et un watchdog alertait quand l'agent plantait sans un mot.

Mais le device restait un récepteur. Il m'affichait ce que l'agent faisait, il me sonnait quand il avait besoin de moi — et il n'y avait aucun moyen de lui répondre sans revenir au clavier. La roadmap de mai listait quatre suites possibles ; la quatrième — « bouton vocal pour donner des instructions complémentaires sans revenir au PC » — était la plus ambitieuse.

Un été et quatre releases plus tard (0.4.0, 0.5.0, 0.5.1, 0.6.0), elle est livrée — et accompagnée de choses que je n'avais pas osé mettre sur la liste. Voici le détail, version par version, avec les vraies histoires.


2. v0.4.0 — Le device prend ses réglages

Première brique, moins spectaculaire mais structurante : un menu de configuration directement sur le device. Appui long C (~1 s) depuis l'écran d'accueil, navigation avec C (suivant) et B (précédent), sélection avec A. Tous les réglages sont persistés en NVS (la mémoire flash de l'ESP32) avec un magic byte de validation et des défauts sûrs sur un device neuf :

  • Quiet Mode — coupe vibrations et bips d'approbation (pour les réunions) ;
  • LED Brightness — 16 / 32 / 64 / 128 / 255, appliqué en direct ;
  • Model — le chat Mistral animé… ou le Chaton Fat 😼 ;
  • Mic — source du push-to-talk : device ou PC ;
  • Voice Out et Voice Lang — arrivés plus tard, on y revient ;
  • Debug et Demo Mode — audit des flux et vitrine autonome.
Photo du boîtier rouge M5Stack Fire posé sur une table en verre en extérieur, écran affichant le menu « CONFIG MENU » avec les items Quiet Mode OFF, LED Brightness 16, Model Mistral, Debug ON, Mic Device, Demo Mode ON et Exit, trois boutons physiques gris en dessous, jardin et piscine flous en arrière-plan
Le menu de configuration embarqué : appui long C depuis l'écran Ready, navigation aux trois boutons, réglages persistés en NVS

Le Chaton Fat mérite une explication. C'est un easter egg : un sprite pixel-art fixe, corps blanc contour noir, trop gros pour danser — c'est précisément le gag. L'écran affiche un faux bandeau « le nouveau modèle Mistral ». On choisit son modèle comme on choisit un fond d'écran, et le chat obèse reste impassible pendant que le vrai agent travaille.

Photo du boîtier rouge M5Stack Fire posé sur une table en bois clair, écran affichant le Chaton Fat blanc en pixel art couché sur un fond arc-en-ciel rayé orange et jaune, une matrice LED hexagonale éteinte posée sur le dessus du boîtier avec son câble, vase tressé blanc en arrière-plan
Le Chaton Fat, « le nouveau modèle Mistral » : sprite pixel-art fixe, trop gros pour danser — c'est le gag

L'anecdote embedded : fronts contre niveaux

La première version du menu avait un défaut savoureux : elle se refermait toute seule en 30 ms. La cause : le front du bouton (l'instant exact où l'on appuie) ouvrait le menu et naviguait, mais le niveau « bouton encore enfoncé » — lu quelques itérations de boucle plus tard — déclenchait la sortie. En électronique embarquée, la distinction front/niveau est la première chose qu'on apprend, et la première qu'on oubliera à chaque nouveau contexte. La correction : purger les flags au démarrage du menu, attendre le relâchement (wait-release) avant d'entrer dans la boucle de navigation, et baser ButtonManager::isHeld() sur un vrai niveau plutôt que sur un front résiduel.

C'est le genre de bug qui ne pardonne pas et qui n'apparaît que sur du vrai matériel. Deux lignes de correctif, une heure de débogage.


3. v0.5.0 — Parler à son agent

C'est la release qui change la nature de l'objet. Le principe : appui long A (~0,5 s), on parle, on relâche. Le texte transcrit apparaît dans Vibe comme un vrai message et démarre un tour. Trois gestes, selon le contexte :

Geste Contexte Effet
A long + parler Ready Nouvelle instruction — démarre un tour dans Vibe
A long + parler Approbation Approuve immédiatement + commentaire dicté injecté dans le tour en cours
B long + parler Approbation Rejette avec la consigne dictée comme raison du refus

Le deuxième geste est celui qui change le quotidien : l'agent propose un commit, je dis « A long… approuve, mais ajoute les tests avant de pousser » — l'approbation part au relâchement du bouton et le commentaire pilote la todo en direct, sans bloquer le tour. L'asymétrie du troisième geste est voulue : refuser prend plus de temps qu'approuver, parce qu'un refus sans raison oblige l'agent à deviner.

Push-to-talk : appui long A, on parle au device, Voxtral transcrit, le tour démarre dans la TUI — ici une recherche météo dictée depuis le canapé.

Comment ça marche (et ce que ça a coûté)

Le Fire n'a pas de micro « officiel » dans sa fiche produit, mais son socle M5GO embarque un MEMS parfaitement utilisable. Le pipeline voix montante tient en quatre étapes :

M5Stack Fire (micro MEMS du socle M5GO)
   │  capture ADC → G.711 µ-law 16 kHz (décimation anti-repliement)
   │  streamé EN DIRECT pendant la dictée (base64 par chunks, Bluetooth SPP)
   ▼
PC (plugin vibe-m5stack)
   │  réception + rééchantillonnage (auto-calibration du débit ADC :
   │  le device mesure sa propre durée d'enregistrement)
   ▼
Mistral Voxtral (transcription)
   │  clé API résolue exactement comme Vibe la résout
   ▼
Vibe (injection du texte → tour agent)

Deux choix d'architecture méritent d'être expliqués.

L'audio est streamé pendant qu'on parle, pas après. Au relâchement du bouton, la transcription part immédiatement — la latence perçue est celle de Voxtral, pas celle du transfert. Le prix : le lien Bluetooth SPP transporte désormais un flux temps réel en plus des messages JSON de statut, ce qui deviendra le cœur du problème de la v0.6.0.

La clé API n'est pas reconfigurée. Le plugin résout la clé Mistral exactement comme Vibe la résout (variable d'environnement, ou keyring du login navigateur). Zéro configuration supplémentaire : si Vibe marche, la voix marche. C'est le genre de détail qui fait la différence entre un projet de week-end et un outil qu'on garde.

Jusqu'à 60 s de dictée, états LISTENING / TRANSCRIBING affichés à l'écran avec leur animation LED dédiée, et fallback micro PC si le device n'a pas de socle M5GO.

Mode démo : l'objet sans le PC

Bonus apparu en dictant à la voix — depuis le canapé — la spécification de la feature elle-même : Demo Mode. Sans session PC, le device enchaîne dix secondes après le boot les sept animations LED (welcome, les quatre activités de thinking, waiting, done), chaque état légendé à l'écran dans sa couleur, chat animé ou Chaton Fat en vedette. N'importe quel bouton pour sortir. C'est la vitrine pour un salon ou un stand — et la preuve que le device est autonome.

Mode démo : sans PC, le device enchaîne ses 7 animations LED légendées — ici la matrice hexagonale en motif bleu pendant que le bandeau annonce « DEMO Running... ».

Et une dernière pièce discrète : la reconnexion à chaud. Un reboot du device (flash, coupure Bluetooth) ne tue plus la session — reconnexion automatique et resynchronisation. Avant, chaque reflashage était une session perdue ; maintenant, c'est un non-événement.


4. v0.5.1 — Le jour où l'upgrade a tué le device (et pourquoi c'est une bonne nouvelle)

Cette section est celle que je voulais écrire depuis mai.

Dans l'article d'origine, le monkey-patch de AgentLoop.set_approval_callback était présenté comme un compromis assumé : « la robustesse du wrapper dépend de l'API interne Vibe. Si Mistral refactor AgentLoop, le wrapper casse. » Et je formulais un vœu : « un point d'extension officiel Mistral pour la couche permission ».

Mistral l'a livré. Et comme toute migration d'API interne, ça a commencé par un crash.

Le crash

uv tool upgrade un matin d'août → Vibe 2.23 → AttributeError: set_approval_callback au lancement. La 2.23 a supprimé la méthode que je patchais, remplacée par un InteractionRequestBroker : les demandes d'approbation deviennent des ApprovalRequestEvent émis dans le flux d'événements de l'agent loop, chacun portant un request_id, et se résolvent via une API publiqueresolve_approval_request(request_id, response, feedback).

Résultat immédiat : plus personne ne pouvait installer le plugin (le resolver tirait Vibe vers une version incompatible). J'ai posé un pin d'urgence (mistral-vibe<2.23, la v0.3.1 avait déjà servi de mode dégradé), puis porté le hook.

La migration : moins de code, plus de garanties

La nouvelle mécanique est objectivement meilleure, et pas seulement parce qu'elle est officielle :

  • Le hook observe le flux d'événements qu'il itérait déjà, repère les ApprovalRequestEvent, et lance la course M5Stack en tâche de fond — sans consommer l'événement, que la TUI reçoit toujours.
  • Device et TUI résolvent le même request_id ; le broker ignore les résolutions tardives (future.done()). La course « premier qui répond gagne » — que je simulais à la main avec un Future partagé — est devenue native.
  • Fini le fouillage des internals de la TUI Textual pour fermer la modal quand le bouton gagnait : ce hack-là aussi est mort.
  • Détail de sémantique important : sur l'ancien chemin, un timeout du device renvoyait « NON » — acceptable quand on courait contre notre propre modal, dangereux sur le nouveau (un refus fantôme volerait la décision à la TUI). Désormais, timeout = on ne résout pas, la décision reste à la TUI.
Photo du boîtier rouge M5Stack Fire posé sur une table en bois clair, écran affichant une demande d'approbation « [abe1d547] search_replace » sur le fichier readme.txt avec les libellés A APPROVE en vert, B REJECT en orange et C CANCEL en vert, une matrice LED hexagonale brillant en blanc au-dessus du boîtier, vase tressé blanc en arrière-plan
Demande d'approbation native (API InteractionRequestBroker de Vibe 2.23) : la course TUI contre bouton physique est désormais officiellement supportée

Tout le code legacy est parti : patch_agent_loop(), le wrapper modal, le hack _pending_approval. Il ne reste qu'un chemin, plus un fail-fast lisible à l'import — si une vieille API est détectée, le message dit exactement quoi faire (uv tool upgrade mistral-vibe) au lieu d'une AttributeError cryptique. Pin levé, la 0.5.1 exige mistral-vibe>=2.23.

L'anecdote git : les objets stagés ne meurent jamais

Pendant cette migration, l'IA qui implémentait a lancé un git reset --hard non demandé — effaçant apparemment une partie du travail de la session. Sauf que git ne jette presque rien : les blobs stagés restent dans l'object store tant que le garbage collector n'est pas passé. Un git fsck --lost-found, un repérage des blobs orphelins par date et taille, un git cat-file -p pour vérifier le contenu — et tout est revenu.

Leçon double : pour moi, les dangling blobs sont un filet de sécurité réel, pas une légende de blog ; pour le workflow agent, c'est l'argument définitif pour la règle qui gouverne désormais tout le projet — « aucun git reset --hard » est écrit en tête de chaque brief.


5. v0.6.0 — L'agent répond à voix haute

Avec la voix montante, l'objet était à moitié talkie-walkie : je parlais, l'agent exécutait — mais sa réponse restait prisonnière de la TUI, à dix mètres. La v0.6.0 ferme la boucle : à la fin de chaque tour, la réponse finale de l'agent est lue à voix haute par le haut-parleur du Fire.

Le réglage tient en deux items de menu :

  • Voice Out : Off (défaut — opt-in strict) / Device (haut-parleur du Fire) / PC (enceintes du poste) ;
  • Voice Lang : FR (voix Marie) / EN (voix Jane) — appliqué à la lecture suivante, sans redémarrage.

La synthèse réutilise le client TTS embarqué dans Vibe lui-même (modèle Voxtral mini-TTS) — même clé, même résolution, même philosophie que la dictée. Côté plugin, le texte du tour est nettoyé avant lecture : les blocs de code et le markdown sautent (personne ne veut entendre « triple backslash »), garde-fou technique à 4 000 caractères, et la lecture est intégrale — n'importe quel bouton l'interrompt, une nouvelle dictée la coupe aussi.

La boucle fermée, avec le son : question dictée au device, « Generating... » dans la TUI, puis la réponse lue à voix haute par le haut-parleur du Fire pendant que la matrice s'anime. (Activez le son.)

Le trajet audio descendant : synthèse côté PC → ré-encodage G.711 µ-law 16 kHz (le format du lien, symétrique de la voix montante) → normalisation crête ~90 % → passe-bas anti-repliement → décimation 24→16 kHz → chunks base64 streamés sur le SPP → pré-buffer 1,5 s côté device → lecture. Une télémétrie tts_diag remonte au PC le décompte chunks/octets reçus à chaque fin de stream.

Ça a l'air simple. Ça ne l'était pas.

Bug n°1 — La queue Bluetooth de 512 octets

Premier test réel : la voix jouait quelques secondes, puis se taillait des morceaux. Le log du firmware crachait RX Full! Discarding en boucle. Diagnostic : le callback de réception du SPP avait une queue de 512 octets — largement assez pour les messages JSON de statut, dérisoire face à un flux audio continu. Chaque octet arrivé alors que la queue était pleine était jeté silencieusement.

Correctif : une tâche dédiée (core 1, cycle de 1 ms) draine la queue en continu vers un ring buffer de 16 Ko, et le PC découpe ses chunks à ≤ 390 octets pour ne jamais la saturer. La leçon générale : une API de réception série est toujours conçue pour des messages, jamais pour un flux — dès qu'on transporte de l'audio, la réception devient un problème d'architecture, pas de callback.

Bug n°2 — Le DAC qui jouait 5,5× trop vite

Ensuite, la voix jouait — mais accélérée, façon cassette en mode avant rapide. Cause : l'horloge du DAC interne de l'ESP32 (GPIO 25, le haut-parleur 1 W du Fire). Configurée pour l'objectif visé, elle se comportait de façon chaotique sous 4 kHz demandés — son minimum réel étant ~22 kHz. Résultat : le flux était lu 5,5× trop vite.

Le DAC ne descendra pas plus bas, c'est une contrainte matérielle. La parade : compenser par duplication d'échantillons (zero-order hold) — mesurer le ratio réel à chaque lecture et sur-échantillonner le flux pour que la durée finale soit juste. C'est moineau-level hack, c'est mesuré, et ça sonne juste.

Bug n°3 — Le asyncio.sleep qui ment

Dernier bug, le plus sournois, parce qu'il ne se voyait nulle part dans le code. Le plugin pace l'envoi des chunks pour caler le débit ; la première version dormait 15 ms entre chaque envoi. Sous Windows, asyncio.sleep(15 ms) dort en réalité ~21 ms — et ce dérive suffit à vider le buffer côté device entre deux chunks. Voix hachée, sans explication côté firmware, parce que le firmware était innocent.

Correctif : passer à des échéances absolues (prochain envoi à t0 + n × intervalle, peu importe le temps réel dormi) et caler le débit à 16 Ko/s exactement. Leçon : sur du temps réel logiciel, ne jamais raisonner en durées de sommeil — toujours en échéances.

Et les petits

Trois corrections de moindre visibilité complètent le lot : les appuis résiduels (un front de bouton antérieur à la lecture tuait celle-ci à la première frame — purge au démarrage, notification d'annulation PC→device, watchdog de 12 s sans données) ; le RX multi-messages (une ligne JSON par tour de boucle ne suivait pas les ~60 lignes/s d'un stream TTS — jusqu'à 12 messages traités par itération) ; et le partage d'I2S entre micro et haut-parleur (exclusion mutuelle stricte : pas de lecture pendant une capture, une dictée coupe d'abord la lecture puis libère le driver).


6. Le making-of : quinze briefs et une règle

Un détail mérite d'être raconté, parce qu'il dit quelque chose de la façon de construire avec les agents en 2026 : ce projet n'a pas été écrit à la main.

Le workflow tient en deux rôles. Mistral Vibe est l'implémenteur : chaque feature part d'un brief écrit (quinze fichiers BRIEF_*.md à la racine du repo) qui fixe les décisions prises à l'avance — architecture, protocole, critères d'acceptation, et la règle 0 : « pio run + pytest avant chaque commit. Aucun git reset --hard ». Et je suis l'autre rôle : décideur et relecteur. Les arbitrages (opt-in par défaut, lecture intégrale, asymétrie approve/reject) sont humains, écrits dans le brief avant qu'une ligne existe — et le brief sert aussi de grille de review : le code produit se relit contre lui, critère par critère, et ce qui s'écarte du contrat se voit immédiatement.

Ce qui a remarquablement marché : tout ce qui est structuré et isolé — le ConfigManager avec son magic byte NVS, les modules audio séparés, les tests pytest sans matériel. Ce qui a cassé deux fois : les machines à états et les intégrations traversantes — la première version du menu (inutilisable, cf. l'anecdote front/niveau), et deux passes ratées sur la voix, reprises en recadrant le brief plutôt qu'en patchant le code produit.

La règle qui a le plus changé le rendement n'est pas technique : « compile avant chaque commit, sinon c'est un échec ». Sans elle, l'agent empilait des commits cassés et le debug se noyait dans le diff. Avec elle, chaque point de la branche est un état exécutable — et le rollback reste une décision, pas une fouille.


7. Le plaidoyer de mai, exaucé — et ce qui reste

Reprenons la liste de mai, trois mois plus tard :

  1. « Un point d'extension officiel Mistral pour la couche permission »livré. L'InteractionRequestBroker de la 2.23 est exactement ça : un contrat public, des événements typés, une résolution idempotente. Le monkey-patch est mort de sa belle mort, remplacé par moins de code et plus de garanties. Moralité pour tout le monde qui bricole autour d'un runtime agent : quand l'upstream expose la vraie API, courir la migrer — même si ça commence par un crash.
  2. « Un endpoint public d'usage Vibe »toujours pas. La jauge de contexte de session fonctionne (context_tokens), la jauge de crédit mensuel reste impossible sans scraping. La demande publique tient toujours.
  3. « Un kit officiel ou communautaire packagé » → toujours rien côté Mistral. L'écosystème Claude, lui, continue de s'étoffer (macropads, Stream Decks, prototypes officiels). Le gap périphérique côté stack française reste entier — c'est précisément pour ça que le repo est en Apache 2.0.

Et sur ma propre roadmap de mai : le bouton vocal est livré (c'est cet article), le Chaton Fat a pris la place de l'easter egg « shake », et l'encodeur rotatif attend toujours. C'est le rythme honnête d'un projet de soirées : une idée sur quatre par release.


8. Et après

Ce que la 2.23+ ouvre, et qui est déjà dans les cartons :

  1. Répondre aux questions de l'agent avec les boutons. Vibe pose parfois de vraies questions à choix ; l'événement WaitingForInputEvent porte désormais les réponses prédéfinies. L'écran affiche jusqu'à trois options mappées sur A/B/C — le device passe de « approuver/rejeter » à « répondre à l'agent ». C'est, de loin, la plus grosse marche vers la télécommande complète.
  2. m5stack-gate : la confirmation physique déterministe. Le système de hooks TOML de Vibe (pre_tool/post_tool) permet un gate officiel : tant que le bouton n'est pas pressé, l'outil est nié par le runtime — plus de « ABSOLUTE RULE » en langage naturel dans les instructions, qui dépendait de l'obéissance du modèle. La politique de sécurité devient du code, plus de la rhétorique.
  3. STT temps réel. Vibe embarque un client Voxtral streaming (websocket) : la transcription en direct pendant qu'on parle, texte live à l'écran du device. Fini l'aveugle de la dictée.
  4. Le client ACP/app_server, la voie royale. Vibe converge vers un modèle client/serveur (la TUI 2.24 elle-même est devenue un client d'app_server, binaire vibe-acp disponible). Refaire le bridge comme client ACP de plein droit signifierait zéro monkey-patch résiduel, robustesse aux montées de version — et compatibilité avec tout agent parlant ACP. C'est le chantier structurant de la fin d'année.

D'ici là, l'objet vit sa vie sur la table de la cuisine : je dicte, il exécute, il répond — et le Chaton Fat veille, impassible, sur son fond arc-en-ciel.


Repo

Tout est là : github.com/rdelfosse/vibe-m5stack — Apache 2.0. Le firmware PlatformIO, le plugin Python, les quinze briefs (qui valent un atelier sur le dev agent-assisté), le web flasher, et le CHANGELOG complet des versions 0.4.0 à 0.6.0. Web flasher pour le firmware, install.ps1 / install.sh pour le plugin, vibe-m5stack doctor pour diagnostiquer. Fork, adapte, casse, raconte.


Toutes les sources citées sont vérifiées au 23 août 2026. Les versions, dates et détails techniques proviennent du CHANGELOG et du code du repo vibe-m5stack. Si tu repères une erreur factuelle, écris-moi via bonjour@romaindelfosse.fr — correction sous 72h, tracée en commit Git.

Sources

  1. vibe-m5stack — github.com
Romain Delfosse
Romain Delfosse Digital Governance & Platform Strategy

Travaillons ensemble

Un projet de transformation digitale, une marketplace à lancer ou une stratégie IA à cadrer ? Découvrez mon parcours ou écrivez-moi directement.