97 KiB
CONTEXT.md — état d'avancement du projet
Fichier de reprise. À relire en premier en cas d'interruption, et à mettre à jour à la fin de chaque étape. Il décrit ce qui est fait, ce qui est en cours, et les décisions techniques déjà prises (pour ne pas les rediscuter).
1. Projet
Assistant vocal 100 % local : ESP32 (micro INMP441) → MQTT → serveur Python (VAD, faster-whisper, diarisation, Piper) → MQTT → ESP32 (ampli MAX98357A).
Prompt de référence : prompt-assistant-vocal-esp32.md (rôle, style, feuille de route, conventions).
2. Feuille de route et avancement
| # | Étape | État | Validé par l'utilisateur |
|---|---|---|---|
| 0 | Ossature du dépôt (arborescence, Makefile, git, README) | fait | en attente |
| 1 | Entrée audio ESP32 (I2S RX) + tools/serial_to_wav.py |
fait | oui (2026-09-29) |
| 2 | Sortie audio ESP32 (I2S TX) + tools/wav_to_serial.py |
fait | oui (2026-09-29) |
| 3 | Mosquitto + topics (Wi-Fi, MQTT, état retenu, testament) | fait | oui (2026-09-29) |
| 4 | ESP32 → MQTT → serveur (sessions reconstruites en WAV) | fait | oui (2026-09-29) |
| 5 | Serveur → MQTT → ESP32 (WAV serveur joué par l'ESP32, mode écho) | fait | oui (2026-09-29) |
| 6 | MVP : PTT → faster-whisper → réponse → Piper → ESP32 | fait (serveur seul, firmware inchangé) | oui (2026-09-29) |
| 7 | VAD (Silero) | fait (serveur seul) | oui (2026-09-29) |
| 8 | Diarisation (pyannote) | fait, test réel à deux voix réussi | oui (2026-09-29) |
| 9 | Identification (embeddings + cosinus + seuil) | fait (serveur seul) | oui (2026-09-30) |
| 10 | Association locuteur + texte | fait (serveur seul) | oui (2026-09-30) |
| 11 | Assistant / domotique | fait (serveur + firmware) | oui (2026-09-30) |
| 12 | Buffer de sortie anti-jitter | fait (firmware + banc de gigue) | oui (2026-09-30) |
| 13 | Wake word (openWakeWord) | fait (serveur + firmware) | oui (2026-09-30) |
| 14 | Multi-utilisateurs | fait (serveur seul) | oui (2026-09-30) |
| 15 | Fiabilisation | fait (broker + serveur + firmware) | oui (2026-09-30) |
| 15b | Agents Linux (ajout utilisateur) : commander des machines du réseau | fait (serveur + agent) | en attente |
| 16 | Optimisation | à faire | — |
Règle absolue : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N.
Exigence ajoutée par l'utilisateur (2026-09-30) : l'assistant doit aussi pouvoir commander des machines Linux du réseau. Absente de la feuille de route du prompt. Proposition en discussion : un agent MQTT par machine (liste blanche d'actions définie sur la machine), Wake-on-LAN par le serveur, état retenu + testament comme l'ESP32 ; actions sensibles seulement après les droits par profil (étape 14) et l'authentification MQTT (étape 15). Placement décidé : étape 15b (après droits par profil et authentification MQTT). Liste d'actions et machines à préciser.
3. Décisions techniques figées
| Sujet | Décision | Raison |
|---|---|---|
| Format audio unique | PCM s16le, 16 kHz, mono |
compatible Whisper/Silero, simple pour l'ESP32 |
| Taille de chunk | 50 ms = 800 échantillons = 1600 octets | compromis latence / surcoût protocole |
| Encodage MQTT | bytes bruts, jamais de Base64 | −33 % de débit, pas de CPU perdu |
| Core Arduino-ESP32 | 3.x (ESP-IDF 5.x), driver driver/i2s_std.h |
ancien driver/i2s.h déprécié ; plateforme pioarduino |
| Plateforme PlatformIO | pioarduino 54.03.21 (fork maintenu pour core 3.x) |
le paquet officiel espressif32 est resté en core 2.0.x |
| PlatformIO isolé | venv dédié .pio-venv, avec click==8.1.8 |
click ≥ 8.3 casse l'esptool 5.0 du core 3.x (erreur get_metavar() missing ctx) ; évite aussi tout conflit avec les dépendances du serveur |
| Périphériques I2S | I2S0 = micro (RX), I2S1 = ampli (TX) | duplex séparé, pas de conflit d'horloges |
| Broches | voir firmware/include/config.h |
aucune broche de strapping (0, 2, 5, 12, 15) sur l'audio |
Broker de dev : conteneur mosquitto_services-mosquitto-1, compose ~/srv/docker-compose.mosquitto.yml, config montée ~/srv/mosquitto/config/mosquitto.conf ; IP du PC = 192.168.1.104 (et non .0.104) |
2026-09-29 | constaté sur la machine |
Broker = Raspberry Pi 5, 192.168.1.200, comptes exigés (anonyme refusé) ; un seul réglage : mqtt.host de server/config.yaml, relu par le Makefile (MQTT_HOST) |
2026-10-06 | décision utilisateur (« broker sur pi5 ») ; le conteneur local du PC de dev (192.168.1.104, anonyme) tourne encore mais n'est plus utilisé |
Carte : Wi-Fi et broker en NVS, réglés par le portail VoiceAssist-Config (http://192.168.4.1, bouton PTT tenu 3 s au démarrage) ; secrets.h n'est plus qu'un repli si la NVS est vide (WifiConfig, ajouté par l'utilisateur) |
2026-10-06 | changer de broker = portail, pas recompilation |
MQTT : état JSON retenu + testament {"status":"offline"} retenu QoS 1, keepalive 10 s |
2026-09-29 | offline en 15 s max après coupure ; serveur informé même s'il démarre après l'ESP32 |
Wi-Fi : setSleep(false) |
2026-09-29 | le modem endormi ajoute 100 ms+ de latence : incompatible avec le flux audio |
Partitions huge_app.csv (3 Mo d'application, pas d'OTA) |
2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut |
Sortie par MQTT : START/END sur control (QoS 1), PCM sur audio/out (QoS 0), 300 ms d'avance puis 1 chunk / 50 ms |
2026-09-29 | FIFO ESP32 de 512 ms ; la carte publie played (reçus/annoncés, sous-alimentations) |
Tout audio envoyé à l'ESP32 : 16 kHz mono, crête à −3 dBFS (AudioResampler, soxr) |
2026-09-29 | ESP32 simple ; règle le volume faible noté à l'étape 2 ; Piper (22,05 kHz) passera par là |
Firmware : lecture regroupée dans PlaybackStream (série ou MQTT), commandes dans CommandHandler (ArduinoJson 7.2.1) |
2026-09-29 | un seul chemin de lecture ; les commandes s'enrichiront à l'étape 11 |
Capture : micro → tâche de capture → FIFO 1 s → loop() → MQTT |
2026-09-29 | publish() peut bloquer sur un Wi-Fi faible ; le DMA du micro n'a que 75 ms de réserve |
| Perte détectée par le nombre de chunks du END (pas de numéro de séquence avant l'étape 16) | 2026-09-29 | audio en QoS 0 ; le serveur compare reçus / annoncés après 0,5 s de grâce |
Limite connue : PubSubClient ne publie qu'en QoS 0, event compris (le prompt demande QoS 1) |
2026-09-29 | serveur tolérant (session sans END close après 5 s d'inactivité). Revu à l'étape 15 : conservé (aucune perte mesurée sur le réseau local ; esp-mqtt = réécriture de MqttLink et de ses rappels, sans gain observable) |
| Session sans broker → liaison série, format de l'étape 1 | 2026-09-29 | make record reste un outil de dépannage |
| Sortie : stéréo, même échantillon sur les deux voies | 2026-09-29 | le MAX98357A joue (G+D)/2 : niveau exact, inversion G/D sans effet ; on ne mise plus sur un mode mono implicite |
Sortie : canal I2S1 toujours actif, auto_clear → silence quand rien à jouer |
2026-09-29 | broches de l'ampli jamais flottantes (cause du souffle initial) |
| Lecture : réception → FIFO (StreamBuffer statique 512 ms) → tâche audio → I2S | 2026-09-29 | architecture exigée pour MQTT (callback court) ; la tâche est rythmée par le DMA |
| FIFO : écriture tout ou rien par chunk | 2026-09-29 | un chunk coupé = octet impair = tout le reste désaligné |
Protocole série partagé : SerialProtocol.h ↔ tools/serial_protocol.py |
2026-09-29 | une seule définition par langage ; test croisé encodeur Python → décodeur C++ |
Passe-haut micro 80 Hz, 1er ordre, calculé en 24 bits (MIC_HPF_SHIFT = 5) |
2026-09-29 | 87 % de l'énergie d'un enregistrement était infrasonore (plaque qui fléchit sous le doigt, souffle) ; 0,6 % après filtre, voix 99 % |
| Cadrage micro : MSB (24 bits utiles), mesuré, pas supposé | 2026-09-29 | en Philips, le bit de signe déborde dans le mot précédent (23 bits + 00000001) ; l'auto-test bascule seul si un autre module est Philips |
| Les horloges du micro ne s'arrêtent jamais après le démarrage | 2026-09-29 | l'INMP441 s'endort sans horloge ; chaque réveil = dérive lente de la composante continue (+23 % FS encore à 600 ms) |
| Lecture I2S stéréo : la phase de trame est suivie entre les appels | corrigé le 2026-09-29 | i2s_channel_read() peut rendre un nombre impair de mots ; sans suivi, les deux moitiés s'échangent en cours de route et on enregistre la moitié muette |
| Micro : SCK → GPIO 25, WS → GPIO 26 | corrigé le 2026-09-29 | le schéma initial les avait inversés ; aucune raison technique ne départage 25 et 26, la doc a été alignée sur le banc réel |
| Liaison série étape 1 | 921 600 baud, trames VSA1 + longueur |
32 kB/s d'audio impossible à 115 200 baud |
| Conversion micro | mot I2S 32 bits → >> 16, gain ×4, offset DC glissant |
l'INMP441 place 24 bits utiles en tête de mot |
| Licence | MIT | choix par défaut du prompt |
Extra speech : faster-whisper 1.2.1, piper-tts 1.8.0 (au lieu de 1.0.3 / 1.2.0) |
2026-09-29 | Python 3.13 : faster-whisper 1.0 exige av<13 (compilation depuis les sources, échoue sans les en-têtes ffmpeg) ; piper-tts 1.2 dépend de piper-phonemize sans roue 3.13. piper-tts ≥ 1.3 embarque espeak-ng. API Piper : PiperVoice.load(), synthesize() → AudioChunk.audio_float_array |
Modèles locaux dans server/models/ : whisper/<model>/, piper/<voice>.onnx(.json), via make models (tools/download_models.py) |
2026-09-29 | aucun accès réseau au démarrage du serveur ; ~550 Mo ; ignorés par git |
STT : small, int8, beam_size 5, condition_on_previous_text=False, vad_filter=False (VAD = étape 7) |
2026-09-29 | mesuré sur ce PC (20 cœurs) : ≈ 1,0 s quelle que soit la phrase (1 à 3 s), le nombre de fils (0/8/16) ou le beam (1/5) : Whisper encode toujours une fenêtre de 30 s |
Session de crête < −45 dBFS (stt.min_peak_dbfs) : Whisper n'est pas appelé, la carte dit « Je n'ai rien entendu. » |
2026-09-29 | mesuré : 2 s de silence numérique = 4,8 s de calcul (repli sur les températures) |
Hallucinations connues écartées (KNOWN_HALLUCINATIONS : Amara.org, sous-titres…) |
2026-09-29 | mesuré : 1 s de bruit gaussien → « Sous-titres réalisés par la communauté d'Amara.org » |
Imports faster-whisper / Piper différés (dans __init__) |
2026-09-29 | make run-echo et les tests fonctionnent sans l'extra speech |
Assistant à règles (assistant.py) : heure, date, nom, bonjour, merci ; sinon « Vous avez dit : … » ; heure écrite « une heure », « midi », « 19 heures 5 » |
2026-09-29 | Piper lit « 1 heure » = « un heure » ; Whisper écrit parfois « Quel heure » (mots-clés tolérants) |
VAD : Silero ONNX livré par faster-whisper (faster_whisper.vad.get_vad_model), segmentation écrite dans voice_activity.py (hystérésis 0,5 / 0,35, pause 500 ms, parole ≥ 250 ms, marge 150 ms) |
2026-09-29 | pas de torch ; segmentation testable sans modèle ; 2 à 4 ms par session |
| VAD sur la session entière (après le END), pas en flux | 2026-09-29 | suffisant en PTT ; la détection de fin de parole en flux servira au mode mains libres (étape 13) |
La VAD remplace le seuil de crête stt.min_peak_dbfs (supprimé) |
2026-09-29 | l'appui muet de l'étape 6 (crête −30,7 dBFS) passait le seuil ; la VAD le rejette |
Diarisation : pyannote.audio 4.0.7, torch 2.14.0+cpu / torchaudio 2.11.0+cpu (index pytorch.org), modèle pyannote/speaker-diarization-community-1 (33 Mo) |
2026-09-29 | Python 3.13 ; sans l'index CPU, pip tire torch CUDA (plusieurs Go de nvidia-*) ; pyannote 3.3.2 antérieur à ce torch |
| numpy 2.1.3 → 2.5.3 (dépendance de base) | 2026-09-29 | pyannote-metrics 4 exige numpy ≥ 2.2.2 ; 78 tests repassés avec 2.5.3 (soxr, onnxruntime, ctranslate2 OK) |
Télémétrie pyannote coupée (PYANNOTE_METRICS_ENABLED=false, forcé par disable_telemetry() avant l'import) |
2026-09-29 | activée par défaut en 4.x : envoie durée et nombre de locuteurs à otel.pyannote.ai ; variable relue à chaque appel |
Modèle pyannote copié dans models/pyannote/<nom>/ par make models (snapshot_download, HF_TOKEN de server/.env), chargé depuis ce dossier |
2026-09-29 | les sous-modèles sont des $model/… relatifs : aucun appel au hub à l'exécution. HF_HUB_OFFLINE non utilisé : lu à l'import de huggingface_hub, que faster-whisper a déjà fait |
Diarisation sur vad.speech_pcm, le même audio que Whisper, en séquence avant Whisper |
2026-09-29 | instants communs pour l'association locuteur/texte (étape 10) ; parallélisation → étape 16 |
diarization.enabled dans config.yaml ; activée mais absente → le serveur refuse de démarrer avec la marche à suivre |
2026-09-29 | Raspberry Pi 4 : false ; pas de repli silencieux |
pyannote 4 rend une empreinte vocale par locuteur (speaker_embeddings, WeSpeaker ResNet34), conservée dans Diarization.embeddings |
2026-09-29 | candidate pour l'identification (étape 9), à comparer à SpeechBrain ECAPA prévu par le prompt ; speechbrain retiré de l'extra diarization |
Empreintes : modèle embedding/ du pipeline pyannote (WeSpeaker ResNet34, 256 dim.) via Inference(window="whole") sur toute la parole de chaque locuteur (tours de exclusive_speaker_diarization mis bout à bout), 66 ms |
2026-09-29 | les speaker_embeddings de pyannote sont NaN sous ~2 s de parole : il ne garde que les fenêtres de 10 s où le locuteur parle seul ≥ 20 % (filter_embeddings, min_active_ratio=0.2) ; toutes les questions PTT étaient NaN |
| Tours de parole : version exclusive (sans chevauchement) | 2026-09-29 | une voix par empreinte ; prête pour le découpage du texte (étape 10) |
| Seuil cosinus 0,45 (remplace 0,75) | 2026-09-29 | mesuré : profil de 5 sessions contre 23 autres sessions de l'utilisateur 0,52–0,83 (0,52 = 0,9 s de parole) ; Piper direct ou par haut-parleur −0,08 à 0,11. À recalibrer avec de vraies autres personnes (étape 14) |
Profils : server/profiles/<nom>.json (nom, modèle, nb de sessions, date, empreinte normalisée), écriture atomique, relus quand le dossier change |
2026-09-29 | JSON lisible (on sait ce qu'on stocke) ; un profil d'un autre modèle est ignoré ; enrôlement pris en compte sans redémarrer |
Enrôlement (make enroll NAME=, tools/enroll.py) : ≥ 3 sessions de ≥ 1 s, une seule voix, écart des sessions qui ne ressemblent pas aux autres (cosinus avec la moyenne des autres < seuil) |
2026-09-29 | la session mélangée 093463 (voix + haut-parleur, vue comme 1 locuteur par pyannote) est écartée à 0,27–0,32 |
| Identification seulement avec la diarisation (pas d'empreinte sinon) | 2026-09-29 | choix de l'utilisateur (empreintes pyannote plutôt que SpeechBrain) |
Qui a dit quoi : word_timestamps=True dans Whisper, chaque mot au tour de diarisation qui le recouvre le plus (sinon le plus proche), mots consécutifs d'un même locuteur regroupés |
2026-09-30 | mesuré : +0,07 à 0,14 s (5 à 10 %) ; les bornes des mots tombent sur celles des tours (même audio pour les deux) |
Topic voice/<carte>/transcript (serveur → abonnés, QoS 1, non retenu) : {"session", "utterances": [{"speaker", "text", "start", "end", "score"}]} |
2026-09-30 | format de la feuille de route + instants et score pour la domotique (étape 11) et les droits (étape 14) ; le serveur ne s'y abonne pas |
L'assistant reçoit le nom de la dernière personne qui a parlé (reply(text, speaker)) : « Bonjour Denis ! », « Avec plaisir, Denis. » |
2026-09-30 | c'est elle qui attend la réponse ; None si inconnue |
Domotique : commandes sur home/<pièce>/<appareil>/set (QoS 1, non retenu) et, si la pièce est celle de la carte, voice/<carte>/control {"cmd":"device",...} exécuté par l'ESP32 (choix de l'utilisateur : « les deux », en simulation) |
2026-09-30 | toute domotique peut s'abonner ; l'ESP32 « exécute la commande éventuelle » (prompt) ; une commande retenue serait rejouée à un abonné tardif |
Analyse par mots-clés (home_control.py) : action × appareil × pièce ; « allume/éteins » seuls = lumière ; sans pièce = pièce de la carte (home.boards) ; appareil/action incompatibles ou incomplets → question |
2026-09-30 | suffisant pour 3 appareils ; tolère « fermé » (Whisper) ; limites : 1 appareil et 1 pièce par phrase, faux positifs possibles (« je porte… ») |
Identifiants en anglais dans les topics et le JSON (light, shutter, door, on/off/open/close) ; pièces = noms de config.yaml, en slug (salle-de-bain) |
2026-09-30 | mêmes chaînes côté Python et firmware |
Firmware : DemoDevices, LED sur GPIO 21 (330 Ω), volets et porte simulés ; confirmation {"event":"device","ok":...,"state":...} journalisée par le serveur |
2026-09-30 | GPIO 21 libre, hors strapping ; la boucle commande → exécution → confirmation est visible |
| La commande part avant la réponse parlée | 2026-09-30 | la lumière s'allume pendant que la carte le dit |
normalize déplacé dans text_normalize.py |
2026-09-30 | commun à l'assistant et à la domotique, sans import circulaire |
| Météo : réponse honnête « je fonctionne sans Internet » | 2026-09-30 | plutôt que répéter la question |
Anti-gigue : amorçage à 300 ms (PLAYBACK_START_MS) avant le premier échantillon, réamorçage après une famine (pause propre plutôt que bribes), son plus court que la réserve joué dès le END |
2026-09-30 | 300 ms = 6 chunks = avance envoyée d'emblée par le serveur : la lecture démarre dès qu'elle est arrivée ; valeur de l'exemple du prompt |
underruns du bilan = réamorçages (famines en plein flux) ; nouveaux champs start_ms (délai du premier son) et min_margin_ms (plus petite réserve mesurée) |
2026-09-30 | l'ancien compteur comptait chaque bloc incomplet, y compris au démarrage : 6 à 10 par lecture sans aucune perte |
Banc de gigue : --jitter MS / make tone-mqtt JITTER=MS retient 1 chunk sur 10 (toutes les 500 ms) pendant MS ms, puis rattrapage en rafale |
2026-09-30 | scénario déterministe : marge attendue ≈ 300 − MS ms, réamorçages seulement au-delà de 300 ms ; prouver plutôt qu'attendre un mauvais Wi-Fi |
Mot de réveil : openWakeWord 0.6.0 en ONNX, installé --no-deps (exige tflite-runtime, absent pour Python 3.13) ; modèles alexa, hey_jarvis, hey_mycroft v0.1 + melspectrogram/embedding, depuis les releases GitHub v0.5.1 |
2026-09-30 | mesuré : « Alexa » (Piper FR) 0,92–1,0 ; « Hé Maïcrofte » 1,0 ; « Hey Djâr-viss » 0,74 ; lus à la française Jarvis/Mycroft ≈ 0 ; 0 fausse alerte sur 90 enregistrements réels (max 0,07) ; ~1 ms par chunk |
Flux continu voice/<carte>/audio/stream (QoS 0), activé par le serveur (cmd listen) à la connexion/redémarrage de la carte, coupé à l'arrêt du serveur ; jamais pendant une réponse (écho, Wi-Fi) ni pendant un appui (le bouton reprend la main) |
2026-09-30 | le serveur décide ; la carte reste simple |
HandsFreeListener : VEILLE (préroll 500 ms en mémoire seulement) → COMMANDE (fin = 800 ms de silence après de la parole qui dépasse le mot de 0,3 s ; abandon après 4 s sans commande ; max 10 s) → RÉPONSE (jusqu'au bilan played, garde 20 s) ; la commande devient une SessionResult ordinaire |
2026-09-30 | aucune autre partie de la chaîne ne change |
| Délais d'une commande mesurés en audio reçu, pas à l'horloge | 2026-09-30 | trouvé en simulation : en rafale Wi-Fi, 2 s de son peuvent arriver en 10 ms ; l'horloge ne sert qu'aux gardes (réponse sans bilan, flux perdu 2 s) |
LED : éclair 100 ms / 2 s = écoute continue ; allumée = commande en cours (cmd capture) ; StatusLed::blink(period, onMs) |
2026-09-30 | l'utilisateur voit quand le micro part sur le réseau |
Droits : complet > standard > limite (access_control.py) ; inconnu = limite, enrôlé sans niveau = standard, users: {Denis: complet} |
2026-09-30 | niveaux du prompt ; conversation toujours permise |
Niveau complet seulement si score ≥ 0,6 (full_min_score), sinon rétrogradé en standard |
2026-09-30 | la voix n'est pas une preuve : une identification de justesse ne doit pas tout ouvrir |
| Règles par appareil/action ; action absente = niveau complet | 2026-09-30 | on ne permet jamais par oubli |
Actions sensibles (confirm: [door.open]) : question, puis « oui »/« non » de la même personne, sur la même carte, en 15 s ; toute autre phrase abandonne la demande |
2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) |
Confirmation : acceptée si la voix est reconnue comme le demandeur, ou si elle est sous le seuil mais que le demandeur reste le profil le plus proche avec access.confirm_min_score (0,25) ; voix reconnue comme quelqu'un d'autre : refusée |
2026-10-06 | mesuré : 6 « oui » seuls de Denis (~0,7 s de parole) à 0,29-0,47, 5 sous le seuil 0,45 ; autres profils face à Denis ≤ 0,12 (profils entiers, surtout voix de synthèse : à revoir avec de vraies personnes) |
| Droits appliqués au dernier locuteur de la session | 2026-09-30 | c'est lui qui attend la réponse (cohérent avec l'étape 10) |
Broker : allow_anonymous false + password_file + acl_file ; comptes voice-server (voice/# et home/# en lecture-écriture) et un par carte, nom = DEVICE_ID (pattern ... voice/%u/...) |
2026-09-30 | micro inaudible et commandes impossibles pour le reste du réseau ; une carte ne peut ni écouter ni usurper une autre (vérifié sur broker jetable) |
Outils make mqtt-* : identifiants du serveur relus dans server/.env (-include), recettes en @ |
2026-09-30 | pas de compte supplémentaire ; mot de passe jamais affiché par make ; mot de passe hexadécimal (#, $ cassent make) |
Présence serveur : voice/server/status retenu, online à chaque connexion, testament offline, offline publié à l'arrêt propre ; seulement MqttLink(presence=True) du vrai serveur |
2026-09-30 | ESP32 : serveur absent → listenWanted=false, plus de flux micro ; les outils (wav_to_mqtt) ne doivent pas faire croire au départ du serveur |
Topic de présence hors motif voice/+/<suffixe> des abonnements serveur |
2026-09-30 | sinon le serveur se prendrait pour une carte (écoute activée sur voice/server/control) |
Chien de garde ESP32 : TWDT reconfiguré 15 s, panique ; loop (enableLoopWDT), tâches capture et audio inscrites ; réarmé entre deux envois réseau |
2026-09-30 | NetworkClient::write peut attendre 10 × 1 s (lu dans le core 3.x) : 5 s par défaut redémarrerait sur un Wi-Fi faible |
MQTT reste dans loop() (pas de tâche dédiée) ; connexion TCP 1 s (setConnectionTimeout) + CONNACK 2 s = 3 s de blocage au pire, broker absent seulement |
2026-09-30 | PubSubClient pas prévu pour plusieurs tâches ; audio dans ses propres tâches |
Wi-Fi relancé (disconnect + begin) après 30 s d'absence |
2026-09-30 | filet si la reconnexion automatique n'aboutit pas |
Journal serveur copié dans logging.file (RotatingFileHandler 1 Mo × 4), logs/ ignoré par git |
2026-09-30 | Raspberry Pi sans écran ; les transcriptions y figurent = données personnelles |
Machines : agent MQTT par machine (agent/voice_agent.py, autonome : paho-mqtt + PyYAML), config YAML sur la machine |
2026-09-30 | demande utilisateur (config YAML) ; MQTT = transport unique (§3.7) ; SSH écarté : clés sur le serveur, liste blanche côté serveur seulement |
| Liste blanche sur la machine : action = commande fixe en liste (pas de shell), le serveur n'envoie qu'un nom ; nom inconnu refusé | 2026-09-30 | un message forgé ne peut rien lancer d'autre ; config refusée si command est une chaîne |
Topics agent/<id>/state (retenu, actions annoncées, testament), command (QoS 1, non retenu, session propre), result |
2026-09-30 | le serveur sait si la machine répond et ce qu'elle accepte ; aucune commande rejouée au redémarrage |
« Allume » = Wake-on-LAN envoyé par le serveur (paquet magique UDP port 9, broadcast configurable), si agent hors ligne et MAC connue |
2026-09-30 | machine éteinte = pas d'agent |
| Phrase qui nomme une machine traitée avant la domotique ; noms de plusieurs mots (« pc du bureau ») | 2026-09-30 | « éteins le PC du bureau » ≠ « éteins la lumière du bureau » (bureau est une pièce) |
Droits : access.rules.machine (wake/lock standard, shutdown/reboot complet), confirm: [machine.shutdown, machine.reboot] ; MachineCommand porte ses phrases (said, asked) |
2026-09-30 | réutilise étape 14 sans changer DeviceCommand ; CommandRouter aiguille vers Home/MachineController |
Service systemd root (/opt/voice-agent, /etc/voice-assistant/agent.yaml 0600), Restart=always ; agent/ autonome (make -C agent install) |
2026-09-30 | poweroff/reboot exigent root ; il suffit de copier le dossier sur la machine |
dry_run dans agent.yaml ; make agent-run crée agent/agent.yaml en mode essai |
2026-09-30 | tester « éteins le PC » sur le PC de développement sans l'éteindre |
| TLS non activé (option de l'étape 15) ; numéros de séquence reportés à l'étape 16 (§3.4 du prompt : « phase 16 ») | 2026-09-30 | comptes + ACL ferment l'accès au micro ; TLS = certificats + ~40 Ko de RAM ESP32, à décider par l'utilisateur |
make run = mode assistant ; make run-echo = étape 5 |
2026-09-29 | une file + un fil de travail : les sessions sont traitées une à une, hors du fil réseau |
4. Fichiers existants (et qui les utilise)
docs/BROCHAGE.md brochage, consommation, vérifications multimètre
docs/wiring.svg schéma de câblage complet (source versionnable)
firmware/include/config.h constantes matérielles + audio (étapes 1→16)
firmware/include/secrets.h.example modèle Wi-Fi / MQTT (utilisé à partir de l'étape 3)
firmware/src/MicCapture.* I2S RX stéréo + conversion 32→16 bits [étape 1]
firmware/src/MicSelfTest.* auto-test du micro au démarrage [étape 1]
firmware/src/PushButton.* bouton PTT anti-rebond [étape 1]
firmware/src/StatusLed.* LED d'état [étape 1]
firmware/src/SerialProtocol.h format des trames, partagé [étapes 1-2]
firmware/src/SerialFramer.* émission des trames vers le PC [étape 1]
firmware/src/SerialFrameReader.* réception des trames du PC [étape 2]
firmware/src/AudioFifo.* FIFO réception → tâche audio (StreamBuffer) [étape 2]
firmware/src/AudioOutput.* I2S1 TX vers MAX98357A, bip de test [étape 2]
firmware/src/WifiLink.* Wi-Fi station, non bloquant [étape 3]
firmware/src/MqttLink.* PubSubClient, testament, reconnexion [étape 3]
firmware/src/VoiceSession.* START / chunks / END, MQTT ou série [étape 4]
firmware/src/PlaybackStream.* flux de lecture + tâche audio + bilan [étape 5]
firmware/src/CommandHandler.* JSON du topic control (start/end/ping) [étape 5]
server/voice_server/audio_resampler.py WAV quelconque -> 16 kHz mono -3 dBFS [étape 5]
server/voice_server/audio_sender.py START / chunks rythmés / END vers une carte [étape 5]
tools/wav_to_mqtt.py WAV ou son pur -> carte, avec bilan [étape 5]
server/voice_server/settings.py config.yaml + .env [étape 4]
server/voice_server/mqtt_link.py paho-mqtt 2.x, abonnement voice/+/... [étape 4]
server/voice_server/audio_buffer.py PCM borné, niveaux, écriture WAV [étape 4]
server/voice_server/session_manager.py sessions, pertes, délais, WAV [étape 4]
server/voice_server/__main__.py point d'entrée : make run [étape 4]
server/voice_server/speech_to_text.py SpeechToText (faster-whisper) + Transcript [étape 6]
server/voice_server/text_to_speech.py TextToSpeech (Piper → 16 kHz) + Speech [étape 6]
server/voice_server/assistant.py Assistant à règles (heure, date…) [étape 6]
server/voice_server/voice_pipeline.py VoicePipeline : STT → réponse → TTS → envoi [étape 6]
tools/download_models.py make models [étape 6]
tools/speech_check.py make speech-check / make transcribe [étapes 6-7]
server/voice_server/voice_activity.py VoiceActivityDetector (Silero), find_segments [étape 7]
tools/vad_check.py make vad / make vad-all [étape 7]
server/voice_server/diarization.py Diarizer (pyannote), SpeakerTurn, timeline [étape 8]
tools/diarize_check.py make diarize / make diarize-demo [étapes 8-10]
server/voice_server/speaker_identifier.py SpeakerIdentifier, profils, cosinus [étape 9]
server/voice_server/speaker_attribution.py attribute, Utterance, transcript_json [étape 10]
server/voice_server/home_control.py parse_intent, decide, HomeController [étape 11]
server/voice_server/text_normalize.py normalize (commun) [étape 11]
firmware/src/DemoDevices.* LED GPIO 21 + volets/porte simulés [étape 11]
server/voice_server/wake_word.py WakeWordDetector (openWakeWord ONNX) [étape 13]
server/voice_server/hands_free.py HandsFreeListener : veille / commande / réponse [étape 13]
server/voice_server/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
tools/enroll.py make enroll / profiles / forget [étape 9]
tools/say.py make say : phrase Piper en WAV + MP3 (téléphone) [étape 14]
docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3]
firmware/src/main.cpp boucle PTT → I2S → série [étape 1]
tools/serial_protocol.py format des trames, partagé [étapes 1-2]
tools/serial_to_wav.py réception des trames série → WAV [étape 1]
tools/wav_inspect.py enveloppe ASCII et niveaux d'un WAV [étape 1]
tools/wav_to_serial.py WAV ou son pur → ESP32, au rythme réel [étape 2]
mosquitto/voice.conf configuration du broker [étape 3]
mosquitto/acl droits par topic : serveur, une carte = ses topics [étape 15]
mosquitto/passwd comptes hachés (make mqtt-user), NON versionné [étape 15]
server/tests/test_mqtt_link.py présence, refus du broker, journal fichier [étape 15]
server/voice_server/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
server/voice_server/command_router.py CommandRouter : domotique / machines [étape 15b]
firmware/src/WifiConfig.* Wi-Fi + broker en NVS, portail AP/HTTP (utilisateur) [2026-10-06]
agent/voice_agent.py agent Linux autonome (liste blanche, dry_run) [étape 15b]
agent/agent.yaml.example modèle de /etc/voice-assistant/agent.yaml [étape 15b]
agent/voice-agent.service, agent/Makefile service systemd, make -C agent install [étape 15b]
server/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
server/ squelette Python (modules à partir de l'étape 3)
Makefile toutes les opérations système
Non encore écrits (volontairement, par étape) : optimisation et numéros de séquence (16), TLS (option de l'étape 15, non activée).
5. Reprise après incident
- Lire ce fichier, puis
README.md. make helpliste toutes les cibles.- Vérifier l'étape en cours dans le tableau du §2, et ne rien faire au-delà.
- Contrôle rapide de l'environnement :
make test(203 tests),make lint,make fw-check.
État au 2026-09-30 au soir (fin de session)
- Étapes 1 à 15 validées. Étape 15b écrite, vérifiée hors carte (broker jetable), pas encore essayée par l'utilisateur. Ne pas commencer l'étape 16 avant sa validation explicite.
- Vérifié à l'arrêt : 203 tests, pyright 0 erreur (tools/ et agent/ inclus),
make fw-checkSUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). Git :main, 1 commit, arbre propre. Aucun serveur, agent ni conteneur de test lancé (voice-acl-test,voice-agent-testsupprimés). - Broker : Raspberry Pi 5, 192.168.1.200, comptes exigés (depuis le 2026-10-06). Le serveur s'y
connecte (
voice-server,server/.env). Le conteneur local (192.168.1.104, anonyme) n'est plus utilisé. - Firmware : la version étape 13 est sur la carte (essais mains libres réussis) ; téléversement de la version étape 15 (chien de garde, présence serveur) non confirmé. Les deux fonctionnent avec le serveur actuel, qui publie sa présence.
server/.venv: extrasspeech,diarization(torch CPU),wakeword;server/models/complet ;HF_TOKENdansserver/.env— secret de l'utilisateur, ne jamais le modifier ni l'afficher. Ne jamais toucherfirmware/include/secrets.h(utilisermake fw-check).- 6 profils vocaux : Denis (complet), Gilles, Jessica, Pierre, Siwis, Tom (standard). Phrases de
test au téléphone :
recordings/voix-test/(make say,WAKE="Hey Mycroft"= voix anglaise). - Mot de réveil retenu par l'utilisateur : « Hey Mycroft » (prononcé à l'anglaise).
Reprise de l'étape 15b (actions de l'utilisateur)
- Terminal 1 :
make agent-run(créeagent/agent.yamlen mode essai,dry_run: true; vérifierid: pc-bureau,mqtt.host: 192.168.1.200et le compte MQTTpc-bureau). - Terminal 2 :
make run→agent pc-bureau : en ligne, actions lock, reboot, shutdown. - « Hey Mycroft, verrouille le PC du bureau » → « Je verrouille le PC du bureau. » ; agent :
essai : loginctl lock-sessions (non execute, dry_run). - « Hey Mycroft, éteins le PC du bureau » → « Confirmez-vous… ? » → « Hey Mycroft, oui » → agent :
essai : systemctl poweroff. - « Hey Mycroft, allume le PC du bureau » → « …est déjà en marche. »
- Ctrl-C sur l'agent → serveur :
agent pc-bureau : hors ligne; « éteins le PC du bureau » → « …ne répond pas ». - Analyser les journaux collés, puis demander la validation de l'étape 15b avant l'étape 16.
Points ouverts (ni oubliés, ni décidés)
machines.actions.*.words: un mot par entrée (comparaison mot à mot) ; « lance la sauvegarde » ne peut pas être reconnu tel quel. Proposé à l'utilisateur (2026-09-30) : accepter des expressions de plusieurs mots. Pas de réponse encore. La négation n'est pas gérée (« n'éteins pas » = éteindre ; la confirmation limite le risque).- Test instable (rare, ~2 échecs sur 15 lancements de la suite, jamais seul) :
test_diarization.py::test_embeddings_exist_for_a_short_question(synthèse Piper aléatoire, existait avant le 2026-10-06). Cause exacte non recherchée. - « le PC » seul n'est pas un nom de machine (
names: « pc du bureau »…) : à ajouter si l'utilisateur le veut, ambigu avec plusieurs machines. - Seuils du mot de réveil : réglage reporté (outil prêt : « mot presque reconnu »).
- Seuil d'identification 0,45 à recalibrer avec de vraies personnes.
- Whisper : hallucination sur 0,8 s de parole (« Je vous remercie de votre soutien… ») ; un « Ouvre » perdu une fois en tête de commande.
- Étape 16 : diarisation en parallèle de Whisper,
start_ms~214 ms (fenêtre TCP lwIP ?), numéros de séquence (§3.4 du prompt) ; TLS (option de l'étape 15) si l'utilisateur le demande.
6. Vérifications déjà passées (sans matériel)
| Contrôle | Commande | Résultat |
|---|---|---|
| Compilation du firmware | make fw-build |
SUCCESS, 0 warning avec -Wall ; RAM 7,2 % (23 536 o), Flash 24,7 % (324 182 o) |
| Tests Python | make test |
162 tests passés (dont le vrai pipeline pyannote et openWakeWord) (trames, sessions, sortie, assistant, boucle, VAD, diarisation, vrais modèles) |
| Typage Python | make lint |
0 erreur, 0 warning (pyright, mode standard) |
| Chaîne série → WAV | port série simulé, sinus 440 Hz 1 s | WAV 16 kHz / 16 bits / mono, 16 000 trames, crête 48,8 % |
Reste à valider avec le matériel : câblage, niveau du micro, intelligibilité.
7. Git
Dépôt initialisé le 2026-09-30 (branche main). Dépôt distant ajouté par l'utilisateur :
origin = https://github.com/deunix-educ/voice-assistant.git (commits et push : par l'utilisateur, ou à sa demande). Identité locale au dépôt :
Denis Defolie denis.defolie@gmail.com. Commits et push seulement à la demande de l'utilisateur.
Vérifié avant le premier commit : secrets.h, .env, mosquitto/passwd, agent/agent.yaml,
profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements virtuels sont ignorés.
docs/test-suivi-perso.txt (notes personnelles) ignoré à la demande de l'utilisateur ; ancien doublon
firmware/secrets.h.example (modèle périmé, sans vrai secret) supprimé à sa demande.
8. Journal
- 2026-09-27 — Création du dépôt, ossature complète, étape 1 écrite (I2S RX +
serial_to_wav.py). Compilation, tests et typage vérifiés. En attente de validation matérielle par l'utilisateur. - 2026-09-27 — Ajout de
docs/BROCHAGE.mdetdocs/wiring.svg(schéma de câblage complet : micro, ampli, HP, bouton, LED, rails d'alimentation). Point ouvert reporté à l'étape 2 : si le niveau sonore est deux fois trop faible, forcer le mode « canal gauche » du MAX98357A par un 100 kΩ deSDvers GND. - 2026-09-28 —
docs/BROCHAGE.md§6 : types et valeurs des condensateurs (C1 = 470 µF 16 V électrolytique faible ESR, C2 = 100 nF X7R, C3 = 100 nF anti-rebond facultatif), bornes mini/maxi justifiées par le calcul et lecture du marquage à 3 chiffres. Schéma mis à jour : C1 et C2 dessinés côte à côte sur leVinde l'ampli. - 2026-09-29 — Matériel branché par l'utilisateur : le HP soufflait en permanence. Cause : à l'étape 1 les broches BCLK/LRC/DIN sont flottantes et le MAX98357A (SD tiré au + par 100 kΩ) amplifie ce bruit. Correctif :
silenceAmplifier()dansmain.cpp, appelé en tout premier danssetup(), force les trois broches à 0 → veille automatique de l'ampli. Firmware recompilé (SUCCESS). Reste le bruit pendant les ~300 ms de boot : option matérielleSD→ GND, ou piloterSDpar un GPIO à l'étape 2 (résout aussi le « pop » d'activation). - 2026-09-29 — Premier enregistrement réel : crête sous 2 %.
MIC_GAINpassé de 4 à 16 (dimensionnement : INMP441 à -26 dBFS/94 dB SPL, voix à 30 cm ≈ 80 dB SPL crête → -40 dBFS ≈ 1 % ; ×16 → ~16 %, soit 16 dB de marge).tools/serial_to_wav.pyaffiche désormais crête + RMS + offset continu en dBFS, distingue « aucun signal » (câblage) de « niveau faible » (gain) et propose le facteur correctif. 6 tests, pyright propre, firmware recompilé. - 2026-09-29 — L'utilisateur confirme le câblage. Ajout d'un auto-test matériel dans le firmware :
MicCapture::probe()/stats()relèvent min/max/zéros des mots I2S bruts,reportMicHealth()(main.cpp) imprime le verdict sur la série au démarrage (bruit ambiant) et à la fin de chaque session (parole, avec la valeur deMIC_GAINconseillée). Distingue : aucun mot lu (I2S muet) / mots figés (L/R ou SD) / amplitude mesurable (question de gain).make fw-upload && make fw-monitorsuffit désormais à diagnostiquer sans le PC côté WAV. - 2026-09-29 — Cause trouvée : l'auto-test renvoyait 39 200 mots I2S tous exactement nuls, avec deux micros différents et un câblage vérifié. Signature de la lecture de la mauvaise moitié de trame : l'INMP441 sort des zéros pendant la moitié qu'il n'utilise pas, et la correspondance
L/R↔I2S_STD_SLOT_LEFT/RIGHTn'est pas celle attendue sur l'ESP32 d'origine. Correctif :MicCapture::useSlot()(reconfiguration à chaud viai2s_channel_reconfig_std_slot) etselectMicSlot()dansmain.cpp, qui écoute les deux moitiés au démarrage et garde celle qui porte du signal. Décision figée : ne jamais supposer le mapping des slots I2S sur ESP32, le mesurer. - 2026-09-29 — Les deux moitiés de trame sont nulles → la piste « slot » ne suffit pas. Vérification faite dans les en-têtes ESP-IDF installés (
framework-arduinoespressif32-libs/esp32/include/esp_driver_i2s/.../i2s_std.h) : la configuration Philips 32 bits mono est correcte pour l'ESP32, le bug n'est pas là. Deux corrections : (1) l'auto-test affichaitamplitude 0sans distinguer « mots nuls » de « aucun mot lu » → il imprime désormais le nombre de mots ; (2)selectMicConfiguration()explore les 4 combinaisons {SCK/WS normaux, permutés} × {slot gauche, droite}, car une permutation SCK/WS donne exactement le même symptôme (le micro reçoit 16 kHz au lieu de 1 MHz et n'émet rien). En cas d'échec des 4, le firmware imprime un protocole de mesure au voltmètre et laisse les horloges tourner sur les broches du schéma. - 2026-09-29 — Le micro capte. Session réelle : 24 000 mots, amplitude 1 547 300 LSB24 (-14 dBFS brut), soit 6 044 LSB sur 16 bits avant gain.
MIC_GAINpassé de 16 à 1 (crête ≈ 18 % de pleine échelle). Le calcul théorique datasheet (-26 dBFS @ 94 dB SPL → -40 dBFS attendus) sous-estimait le niveau réel de 26 dB. Décision figée : pour les niveaux audio, mesurer sur le banc, ne pas extrapoler du datasheet. Point à confirmer : quelle combinaison a été retenue parselectMicConfiguration()(SCK/WS normaux ou permutés) — si permutés, corriger le câblage ou le schéma. - 2026-09-29 — Fausse piste corrigée. L'auto-test avait retenu « SCK/WS permutés » avec une amplitude de 16,7 M LSB24, soit ~100 % de la pleine échelle 24 bits (8 388 608) au repos : ce n'était pas du signal mais du bruit pleine échelle d'une ligne de données flottante. Mon critère « garder la plus grande amplitude » était faux. Trois corrections : (1)
MicCapturelit désormais en stéréo (I2S_SLOT_MODE_STEREO) et choisit la moitié de trame en logiciel — la sélection de slot par le périphérique est ambiguë sur l'ESP32 et testait probablement deux fois la même moitié ; (2) convention d'amplitude unifiée (crête = demi-écart crête à crête, en LSB24) ; (3) garde-fouNOISE_FLOOR_LIMIT= 50 % de pleine échelle : au repos, au-delà c'est une ligne flottante, la candidate est rejetée et le message désigneSD/alimentation. Décision figée : un niveau proche de la pleine échelle au repos est un défaut, jamais un succès. - 2026-09-29 — Mesures utilisateur : VDD = 3,29 V, GPIO 25 et 26 = 1,64 V (≈ VDD/2, signature d'un carré 50 % → les deux horloges tournent et arrivent). Alimentation et horloges donc écartées ; seule reste la ligne
SD→ GPIO 33. Ajout deMicCapture::probeDataLine(): applique le pull-up puis le pull-down interne (~45 kΩ) et échantillonne la broche 4000 fois, horloges actives. Une sortie logique (quelques ohms) écrase le pull : si les lectures suivent le pull (100 % puis 0 %), la ligne flotte ; sinon elle est pilotée.gpio_get_level()fonctionne même quand la broche est routée vers l'I2S, d'où un test sans démonter la configuration. - 2026-09-29 — Test électrique : « ligne PILOTEE ». Mais ce verdict confondait deux cas très différents : ligne active et ligne figée à un niveau fixe (un fil posé sur GND ou L/R donne aussi « pilotée »). Verdicts désormais séparés en quatre : flottante / tenue à 0 / tenue à 1 / porte des transitions. Ajout de
MicCapture::readRaw()+dumpRawFrames(): vidage hexadécimal de 12 trames (les deux moitiés côte à côte). C'est la seule observation qui distingue un décalage de bits d'une ligne morte. Les pourcentages pull-up/pull-down sont l'information clé et doivent être relevés. - 2026-09-29 — Lecture de la structure des relevés : en mode « permuté », une moitié de trame porte des données et l'autre est exactement nulle — signature d'un INMP441 correctement cadencé (
L/Rfixe la moitié d'émission). Du bruit de ligne flottante serait présent dans les deux moitiés. Donc le micro fonctionne, et les filsSCK/WSne sont pas sur les broches du schéma. Deux corrections : (1)MIC_SETTLE_MS = 250— le transitoire de mise en route de l'INMP441 faussait toutes les mesures d'ambiance (0, puis -8, puis -15 dBFS d'un essai à l'autre) ;begin()jette désormais ce début ; (2) le firmware annonce les GPIO réels retenus pour SCK et WS au lieu du mot « permuté », et affiche les mots bruts si le niveau au repos dépasse 10 % de la pleine échelle. Point ouvert : déterminer si c'est le câblage ou le schéma du dépôt qui doit changer. - 2026-09-29 — Brochage corrigé. Le banc est câblé
SCK→GPIO 25 etWS→GPIO 26, soit l'inverse du schéma initial. GPIO 25 et 26 étant équivalents (aucune fonction de strapping, tous deux en sortie), la documentation a été alignée sur le matériel plutôt que l'inverse. Modifiés :config.h,docs/wiring.svg,docs/wiring.png,docs/BROCHAGE.md(§1.1, ASCII, §9, §10),README.md,README-EN.md,tools/serial_to_wav.py. Vérifs : firmware SUCCESS, 6 tests, pyright 0 erreur, SVG re-rendu et relu. - 2026-09-29 —
aplayéchouait avec « unable to open slave » : rien à voir avec le WAV. Le PCM ALSAdefaultpasse pardmix, dont l'esclave est la carte 0 =C110, un périphérique USB de capture seule (pcm0c, aucunpcm*p). D'où l'ENOENT.paplay(PulseAudio) fonctionne ;aplay -D plughw:1,0est refusé car PulseAudio tient la carte, etaplay -D pulseéchoue (pluginlibasound2-pluginsabsent). Ajout demake play(essaie paplay, pw-play, ffplay, aplay) et surtout detools/wav_inspect.py+make inspect: enveloppe ASCII, crête/RMS/offset et détection d'alternance silence/parole — valider un enregistrement sans écouter, ce qui servira aussi sur le Raspberry Pi sans écran. 8 tests, pyright 0 erreur. - 2026-09-29 — Bug trouvé : perte d'alignement de trame. L'auto-test voyait du signal mais le WAV enregistré était fait de zéros exacts. Cause : en lecture stéréo, je déduisais la moitié de trame de la parité de l'indice dans le tampon (
_scratch[i*2]/[i*2+1]). Ori2s_channel_read()rend ce qui est disponible, pas forcément un nombre pair de mots — et l'ancienflush()(lectures à vide) pouvait s'arrêter au milieu d'une trame. Les deux moitiés s'échangeaient alors, etread()prenait la moitié muette. Corrections : (1)_phasemémorisée entre les appels et avancée mot par mot dansread(),probe()etreadRaw(); (2)flush()fait désormaisi2s_channel_disable()+enable(), ce qui vide le DMA et garantit que le prochain mot est un début de trame ; (3)begin()termine parflush(). Décision figée : ne jamais déduire un canal d'une parité d'indice sur une API en flux d'octets. - 2026-09-29 — Diagnostic enfin net et cohérent : test électrique 100 % / 0 % → la ligne suit exactement le pull interne, donc GPIO 33 flotte, rien ne le pilote. Le « bruit pleine échelle » vu sur une moitié de trame est du couplage capacitif depuis les pistes d'horloge voisines, capté par une entrée non connectée — d'où son rejet par
NOISE_FLOOR_LIMIT. Alimentation (3,29 V) et horloges (1,64 V) déjà validées au voltmètre : le seul maillon restant est le filSDmicro → GPIO 33. Correction d'un défaut de mon diagnostic : le vidage hexa s'exécutait sur la configuration permutée (dernière de la boucle) et montrait donc des zéros non représentatifs ; on revient maintenant au câblage documenté avant de diagnostiquer. Ajout d'un protocole de continuité (§8 de BROCHAGE.md), à mesurer sur la pastille du module et non au bout du fil. - 2026-09-29 — Les 3 continuités sont bonnes (
SD↔ GPIO 33,L/R↔ GND, pas de court-circuit). Toutes les hypothèses de câblage côté connexions sont donc épuisées. Deux pistes restantes : (a) les signaux atteignent l'embase ESP32 mais pas les pastilles du module (soudure sèche) — à vérifier en mesurant SCK/WS/VDD sur le module ; (b) l'horloge bit de 1,024 MHz (16 kHz × 64) est trop lente pour ce module : certains INMP441 de contrefaçon ne démarrent qu'au-dessus de ~2 MHz. L'auto-test balaie désormais 2 fréquences × 2 câblages × 2 moitiés, etstartSession()annonce la fréquence réellement retenue dans l'en-tête de session (le WAV reste donc correct si 48 kHz est nécessaire). - 2026-09-29 — Relecture complète de l'historique : j'avais tiré une conclusion fausse.
Le verdict « FLOTTE » (ligne SD) avait été mesuré alors que la configuration active était la
dernière de la boucle d'essais, c'est-à-dire horloges inversées pour le câblage réel : micro
non cadencé, donc muet. Le verdict « PILOTÉE » de l'essai précédent avait, lui, été mesuré
horloges correctes. Conclusion juste : le micro pilote bien sa ligne SD. Les tests de
continuité demandés ensuite étaient superflus (tous bons). J'avais repéré ce défaut pour le
vidage hexa, pas pour le test électrique qui avait le même.
Ce que tous les essais montrent de façon constante : horloges correctes → une moitié de trame
porte des valeurs énormes et erratiques au repos (0, -8, -14, -15 dBFS), l'autre est exactement
nulle ; horloges inversées → tout est nul. Hypothèses restantes, par ordre :
(H1) mot décalé d'un bit (cadrage Philips vs MSB) : le bit 31 devient aléatoire et un signal
faible ressemble à un bruit pleine échelle ;
(H2) quelques mots parasites qui font exploser la mesure crête (max − min), métrique fragile ;
(H3) pull-down de 100 kΩ absent sur SD, pourtant exigé par le datasheet (sortie haute impédance
hors moitié de trame).
Réponse firmware : nouvelle classe
MicSelfTest(main.cpp passe de 437 à ~150 lignes) ; balayage {16 kHz, 48 kHz} × {Philips, MSB} × {2 moitiés} ; critère = valeur efficace au repos (< -20 dBFS) + cohérence du bit de signe (bit31 = bit30 dans > 99 % des mots), plus jamais l'amplitude crête ; pull-down interne activé sur SD (MIC_SD_PULLDOWN) ; analyse bit à bit (taux de 1 par position) + vidage hexa en configuration nominale ; test électrique refait horloges correctes. Le balayage SCK/WS permutés est supprimé : câblage vérifié. - 2026-09-29 — H1 observée directement : mot décalé d'un bit en cadrage Philips.
Analyse bit à bit (16 kHz Philips) : 1re moitié =
F806B200… avec 9 zéros en bas au lieu de 8 (bit 8 à 0 sur 8 mots sur 8), 2e moitié =00000001en permanence. Lecture : le premier bit émis par le micro (signe) est échantillonné un coup d'horloge trop tard et déborde dans le dernier bit du mot précédent ; les 23 bits restants sont décalés d'un rang. Confirmation croisée : à 48 kHz en cadrage MSB, signe incohérent seulement 0,5 % et 2e moitié exactement nulle. Bug de mon critère : la 2e moitié constante00000001avait été retenue comme « plausible » (non nulle, RMS nulle après>> 12, signe cohérent) → un enregistrement aurait été silencieux. Corrections : refus des constantes (minWord == maxWord) ; statistiques séparant composante continue (sum) et variations (écart type) — seul l'écart type juge le bruit de repos ; plancher à -110 dBFS ;MIC_SETTLE_MS250 → 600 ms (l'INMP441 s'endort à chaque arrêt des horloges et chaque réveil laisse une composante continue lente, -30 dBFS encore visible à 250 ms) ; analyse bit à bit faite sur la configuration retenue, en régime établi (trames du milieu). À confirmer par le prochain essai : cadrage MSB attendu comme configuration retenue. - 2026-09-29 — Essai suivant : 16 kHz Philips encore retenu à tort, toujours décalé (9 zéros,
00000001dans l'autre moitié). Mon test de cohérence du signe (bit31 = bit30) est aveugle sur un signal faible : toute la partie haute du mot vaut1, en perdre un ne change rien. Il ne détecte le décalage que sur un signal fort. Nouveau critère, indépendant de l'amplitude : nombre de bits utiles = 32 − ctz(OU de tous les mots) ; avec le pull-down, un INMP441 bien cadré donne exactement 24. Validé hors carte sur les mots réels du banc (g++) : Philips → 23 (décalé), mêmes échantillons recadrés MSB → 24. Valeurs recadrées ≈ −227 000 LSB24 (composante continue de réveil, ≈ −31 dBFS) avec des écarts de quelques milliers : audio sain. Mesures au repos en MSB : continu −43 dBFS, variations −44 dBFS (16 kHz). Décision attendue : cadrage MSB retenu automatiquement. - 2026-09-29 — Cadrage MSB confirmé sur le banc : 24 bits utiles (8 zéros exacts), 2e moitié
exactement nulle. Mais l'auto-test échouait : les mots au repos valaient +1 950 000 (23 % de pleine
échelle) en montant d'échantillon en échantillon. Cause : le micro se réveillait à chaque essai.
L'INMP441 s'endort dès que ses horloges s'arrêtent, et mon auto-test les arrêtait entre chaque
configuration ; chaque mesure « au repos » mesurait donc un réveil, pas la pièce (d'où −43 dBFS un jour,
−13 dBFS le suivant). Pire :
flush()faisaitdisable()/enable(), donc chaque session d'enregistrement aurait démarré par ce transitoire (« boum » grave). Refonte : (1) un seul réveil au démarrage, lu en MSB,MIC_SETTLE_MS= 1500 ; (2) décision purement structurelle : moitié ni nulle ni constante, 24 bits utiles → MSB ; 25 → micro Philips, bascule et revérification ; le niveau au repos n'est plus qu'une information ; (3)flush()vide le DMA par lecture, sans arrêter les horloges, en suivant la phase, et amorce le filtre de continu avec la moyenne mesurée ; (4) toutes les lectures traitent les mots reçus avant de tester l'erreur (une lecture interrompue ne décale plus la phase) ; (5)readRaw()rend toujours des trames entières ; (6) suppression du balayage 48 kHz et de l'incohérence de signe (détecteur aveugle au repos) ; (7)delay(1500)au démarrage pour que le moniteur série ne perde plus le début de l'auto-test. Hypothèse de robustesse notée : quand personne ne lit entre deux sessions, le DMA déborde ; les descripteurs contiennent des trames entières (200 × 2 mots) et les lectures sont de taille paire, la parité — donc l'alignement — est conservée. - 2026-09-29 — Auto-test réussi sur le banc : 16 kHz, cadrage MSB, 1re moitié, 24 bits utiles,
2e moitié strictement nulle, ligne SD pilotée. Mots bruts
FE466E00… → échantillons ≈ −111 000 ± 2 000 LSB24 (continu ≈ −37 dBFS). Niveau au repos mesuré −37 dBFS de variations pendant l'auto-test, mais l'analyse bit à bit faite juste après montre des échantillons confinés dans [−131 000 ; −98 000], soit ≈ −54 dBFS : le micro finissait de se stabiliser, 1,5 s après son réveil. Sans conséquence pour les sessions (horloges jamais arrêtées ensuite). Reste à valider : enregistrement réel +make inspect. - 2026-09-29 — Premier enregistrement audible (entendu au casque ;
paplayfonctionne, sortie analogique par défaut). Analyse spectrale du WAV (numpy) : 87 % de l'énergie sous 20 Hz, voix réelle mais à −42 dBFS dans la bande 300–3 400 Hz. Chaîne de capture saine : aucun saut > 382 LSB, aucune longue suite de valeurs identiques, donc aucun glissement de phase. Cause la plus probable : mécanique (plaque d'essai qui fléchit sous le doigt tenant le bouton) + souffle. L'ancien filtre (MIC_DC_SHIFT = 12, 0,6 Hz) ne retirait que le continu. Simulation de plusieurs réglages sur le WAV réel : 1 étage à 80 Hz suffit (infrasons 0,6 %, voix 99,1 %) ; 2 étages n'apportent presque rien. Implémenté :MIC_HPF_SHIFT = 5(80 Hz), filtre calculé en 24 bits (en 16 bits, zone morte de 31 LSB), gain appliqué avant la réduction 24 → 16 bits avec arrondi,MIC_GAIN = 4(mesuré après filtre). La recommandation de gain en fin de session porte désormais sur la crête après passe-haut (RawStats::filteredPeak), plus sur les mots bruts. Vérifié hors carte (g++, arithmétique identique au firmware) : écart à la théorie du 1er ordre < 0,3 dB, −29 dB à 3 Hz, < 0,4 dB au-dessus de 300 Hz. Étape 1 : critère « audible, sans saturation » atteint ; confirmation attendue sur le firmware final. - 2026-09-29 — Étape 1 validée par l'utilisateur (« le son est vraiment bien »). Le WAV validé provient encore de l'ancien firmware (dérive continue +0,3 % → +4,6 %, impossible avec le passe-haut à 80 Hz) : le filtre 80 Hz sera vérifié sur le matériel avec le firmware de l'étape 2.
- 2026-09-29 — Étape 2 écrite. Firmware :
AudioOutput(I2S1 TX, 16 bits stéréo dupliqué, Philips par défaut viaAMP_FORMAT_PHILIPS,auto_clear, bip 440 Hz −12 dBFS au démarrage = test de la sortie seule),AudioFifo(StreamBuffer statique 16 Ko, tout ou rien),SerialFrameReader(machine à états), tâche audio statique cœur 1 priorité 5,SerialProtocol.hpartagé ;silenceAmplifier()supprimé (l'I2S tient les broches dès la 1re ligne desetup()) ;loop()sert la lecture même si le micro est en défaut. PC :serial_protocol.pyextrait,wav_to_serial.py(WAV ou--tone, avance 200 ms puis 1 chunk / 50 ms), ciblesmake play-espetmake tone-esp. Vérifs : firmware SUCCESS 0 warning (RAM 17,4 %) ; test croisé encodeur Python → décodeur C++ compilé sur PC (texte parasite, faux mot magique, somme fausse, longueur excessive : 4 trames valides, 2 rejetées) ; outil PC sur pseudo-terminal : 4 chunks d'avance puis 50 ± 1 ms ; 15 tests pytest (dont un qui a trouvé un vrai bug : dernier chunk de longueur impaire), pyright 0 erreur. Non vérifiable sans matériel : cadrage Philips réel du MAX98357A côté ESP32, bruit de l'ampli cadencé en permanence sur le micro, absence de coupure sur la carte. - 2026-09-29 — Étape 2 validée par l'utilisateur : bip, son pur sans clic, lecture de la voix,
contrôle du micro — tout passe. Seul point : niveau du HP faible mais audible. Explication :
bip à −12 dBFS par choix, et le WAV rejoué (étape 1, ancien firmware, gain 1) avait sa voix vers
−30 dBFS crête. Pistes, sans code : mesurer
Vinsur la pastille de l'ampli (≥ 4,5 V), rejouer un enregistrement fait avec le firmware actuel (gain 4 → +12 dB),GAINà GND (+6 dB, 15 dB), enceinte pour le HP. Correctif de fond prévu : normaliser côté serveur l'audio envoyé (étape 5, puis la voix Piper à l'étape 6), l'ESP32 restant simple. Prochaine étape : 3 (Mosquitto + topics). - 2026-09-29 — Étape 3 écrite. Constat en arrivant : le broker du conteneur
mosquitto_services-mosquitto-1redémarrait en boucle (378 redémarrages). Sa configuration, écrite à la main hors du dépôt, déclarait deuxlistener 1883:Error: Address in use, reproduit dans un conteneur jetable. Le journal restait muet carlog_dest syslog(dans MAvoice.conf) n'écrit nulle part dans un conteneur ;message_size_limitétait aussi obsolète. L'IP donnée par l'utilisateur (192.168.0.104) ne correspond pas à la machine : 192.168.1.104.voice.confcorrigé (log_dest stdout,max_packet_size, pièges documentés) et testé dans un broker jetable : étatonlineretenu, testamentofflinepublié à la coupure brutale du client, et reçu par un abonné arrivé après coup. Le conteneur de l'utilisateur n'a pas été modifié (hors dépôt, fichiers de l'uid 1883) : ciblemake mosquitto-dockerfournie. Firmware :WifiLink,MqttLink(PubSubClient 2.8 épinglé, tampon 2048, keepalive 10 s, socket 2 s, essais toutes les 5 s), état JSON retenu toutes les 30 s (IP, RSSI, uptime, micro, ampli), événementbootavec la cause du redémarrage (dontbrownout), abonnementcontroljournalisé ; secrets transmis par constructeur,#errorexplicite sisecrets.hmanque ; Wi-Fi démarré après l'auto-test du micro. Partitionshuge_app(Flash 74,5 % → 31 %). Build SUCCESS 0 warning (avec unsecrets.htemporaire, supprimé). Ciblesmqtt-watch,mqtt-control(clients de l'image Docker simosquitto-clientsabsent).docs/MOSQUITTO.mdcréé. Correction faite dans la doc : un reset d'ESP32 ne ferme PAS la connexion TCP (testament en ≤ 15 s). - 2026-09-29 — Étape 3, premiers résultats : broker réparé par l'utilisateur avec sa propre
config (pas
voice.conf:log_dest file,log_type all,message_size_limit) — fonctionne, maislog_type alljournalisera chaque chunk audio à l'étape 4 → passer àvoice.confavant. ESP32 : Wi-Fi « BPI_R3-2G » en 4,8 s, IP 192.168.1.23, RSSI −76 à −78 dBm (faible), MQTT connecté. Vérifié directement sur le broker : un abonné tardif reçoit aussitôt l'état retenu (uptime 158 s), republié 30 s plus tard (188 s). Auto-test micro : variations au repos −46 dBFS (Wi-Fi démarré après). Restent à confirmer : messagecontrolreçu,offlineaprès coupure d'alimentation. - 2026-09-29 — Étape 3 validée par l'utilisateur.
mqtt-watch: état périodique,controlrelayé par le broker,offlinepublié par le testament à la coupure d'alimentation, puisonline(uptime 12 s) etevent boot reason=poweronau retour. Avant l'étape 4 : passer le broker survoice.conf(make mosquitto-docker) et améliorer le RSSI (−75 à −79 dBm, viser > −70). - 2026-09-29 — Étape 3 validée ; étape 4 écrite. Firmware :
VoiceSession(START / chunks / END en JSON surevent, PCM brut suraudio/in, id de session 6 hex), tâche de capture dédiée- FIFO de capture 32 Ko (1 s) :
loop()ne lit plus le micro, il vide la FIFO vers MQTT ;AudioFifoà stockage fourni ;MqttLink::publishBinary. RAM 36,7 %, Flash 31,1 %, 0 warning. Serveur (premier code) :settings,mqtt_link,audio_buffer,session_manager,__main__;make run. Verdicts : complete, lost_chunks, timeout, interrupted. 30 tests pytest, pyright 0. Essai de bout en bout sur le vrai broker avec une carte simulée (paho, rythme réel) : complete 40/40 ✓, timeout 20/? ✓, mais la session à 3 chunks perdus était classéeinterrupted(nouveau START arrivé pendant le délai de grâce) → bug corrigé, test de régression ajouté, scénario rejoué :lost_chunks 37/40+ alerte, puis session suivantecomplete. Pas detools/mqtt_to_wav.py: le serveur remplit ce rôle ; à réévaluer avecwav_to_mqtt.py(étape 5).
- FIFO de capture 32 Ko (1 s) :
- 2026-09-29 — Incident causé par moi :
firmware/include/secrets.hde l'utilisateur détruit. Mes compilations de contrôle faisaientcp secrets.h.example secrets.h ; make fw-build ; rm -f secrets.h. Sans conséquence à l'étape 3 (fichier pas encore créé) ; à l'étape 4, la copie a écrasé le vrai fichier puisrml'a supprimé (dossier modifié à 17:02). Symptôme chez l'utilisateur :'WIFI_SSID' was not declared(le#errors'affiche bien, mais GCC continue et ajoute cette erreur). Correctif de méthode : ciblemake fw-check, secrets factices dans unmktemp -dviaPLATFORMIO_BUILD_FLAGS=-I<tmp>,firmware/include/jamais touché. Règle aussi notée en mémoire. L'utilisateur doit recréer sonsecrets.h(SSID relevé dans ses journaux : « BPI_R3-2G », broker 192.168.1.104 déjà dans le modèle). - 2026-09-29 — Étape 4 validée : 4 phrases de 4 à 6 mots, intelligibles au casque. Analyse des 4 WAV serveur : 1,8 à 4,35 s, nombres entiers de chunks (36, 50, 87, 56), crête −10,4 à −15,3 dBFS (gain ×4 dans la cible), offset ≈ 0 % et infrasons 0 à 0,3 % (contre +3 % et 87 % avant) : passe-haut 80 Hz validé sur le matériel. Voix 99,4 à 99,9 % de l'énergie. Prochaine étape : 5 (serveur → MQTT → ESP32).
- 2026-09-29 — Étape 4 validée ; étape 5 écrite. Firmware :
PlaybackStream(lecture série ou MQTT, tâche audio, bilanplayedpublié surevent),CommandHandler(start/end/ping, ArduinoJson 7.2.1), abonnementaudio/out. Build 0 warning, RAM 36,7 %. Serveur :AudioResampler(8/16/24/32 bits, toute fréquence, mono, soxr HQ, crête −3 dBFS),AudioSender(300 ms d'avance, rythme réel),MqttLink.publish/wait_connected,SessionManager(on_finished=...),--echo(fil de lecture dédié, hors fil paho). Outiltools/wav_to_mqtt.py, ciblestone-mqtt,play-mqtt,run-echo. 41 tests (dont un qui a trouvé qu'un WAV tronqué levaitEOFErrorau lieu du message ffmpeg), pyright 0. Essais de bout en bout sur le broker réel (carte simulée « esp32-test ») : son pur 40/40, écart max 51 ms, crête −3,0 dBFS, bilan reçu ; écho : session 30/30 à −24,3 dBFS renvoyée à −3 dBFS, 30/30. Incident causé par moi : mon serveur de test a repris l'identifiantvoice-serverdumake runde l'utilisateur (lancé à 17:16) → ~16 s d'éjections mutuelles ; son serveur a enregistréserver/recordings/esp32-test_20260929-173525_voix01.wav(artefact de test, 0,5 s, supprimable). Correctifs : identifiant de test distinct, indice « identifiant en double ? » dans le journal de déconnexion, règle mémorisée. Lemake runde l'utilisateur tourne encore l'ancien code (étape 4). - 2026-09-29 — Étape 5, premier essai utilisateur : écho une fois (phrase coupée), puis rien. Journal
serveur : session
39ecbaouverte et close en 72 ms, 0 chunk → le bouton est lu relâché pendant l'appui (contact intermittent sur la plaque ? carte déplacée pour le Wi-Fi ?). Le serveur ignorait en silence les sessions < 0,2 s pour l'écho (défaut de ma part). Ligneen ligne12 s après la session : republication périodique ou redémarrage — indécidable, le journal n'affichait pas l'uptime. Instrumentation ajoutée : raison de fin de session (« bouton relache » / « duree maximale »), alerte si session < 300 ms, purge des événements de bouton périmés (un appui mémorisé pendant une session relançait une session fantôme), uptime dans le journal d'état serveur, événementbootjournalisé avec sa cause (WARNING si brownout/panic/watchdog), écho ignoré annoncé. - 2026-09-29 — Étape 5, second essai (instrumenté) : diagnostic Wi-Fi confirmé par les chiffres.
RSSI −81 à −75 dBm. Session 833429 : ~108 chunks captés, 42 envoyés, 66 perdus faute de place
(FIFO de capture 1 s débordée : envoi Wi-Fi bloqué plusieurs secondes) ; écho 11/42 chunks, END
perdu (reconnexion MQTT pendant l'écho :
offlinepuisonline130 ms après, uptime continu 102→107 s, donc pas de redémarrage). Sessions suivantes 55/55 et 76/76 (4 perdus), échos complets mais 35 et 107 sous-alimentations (≈ 2 s de trous sur 3,8 s). Serveur toujours à l'heure (retard d'envoi 0 ms). Bouton : plus aucun problème (appuis de 2 à 5 s). Décision : ne pas masquer côté logiciel (le tampon anti-gigue est l'étape 12) ; expérience demandée : ESP32 à 1–2 m du point d'accès. Cibles :−65 dBm fiable, −65 à −72 sous-alimentations ponctuelles, < −72 pertes. Piste matérielle si l'emplacement ne peut pas changer : ESP32-WROOM-32U + antenne externe.
- 2026-09-29 — Étape 5 validée (« Echo est bon maintenant ») après changement de SSID par
l'utilisateur. RSSI −69 à −72 dBm (à peine mieux que −75/−81), même IP 192.168.1.23 (même réseau :
autre point d'accès ou autre canal). État stable 7 min, aucune reconnexion. Leçon : le canal / la
congestion 2,4 GHz compte autant que le RSSI. Chiffres de lecture (
played) non fournis. Prochaine étape : 6 (MVP : faster-whisper → réponse → Piper → ESP32). - 2026-09-29 — Étape 6 écrite (serveur seul, firmware inchangé).
speech_to_text.py,text_to_speech.py,assistant.py,voice_pipeline.py;__main__.py: mode assistant par défaut, écho avec--echo.config.yaml:stt.model small,beam_size,no_speech_threshold,min_peak_dbfs,tts.length_scale(cléoutput_sample_rateretirée : c'estaudio.sample_rate). Extraspeechinstallé dansserver/.venvet modèles téléchargés (549 Mo) par moi. Mesures : Piper 0,04 à 0,10 s pour 1 à 3 s de parole ; Whisper ≈ 1,0 s ;make speech-check→ « Quelle heure est-il ? » relu exactement, réponse prête en 1,04 s de calcul. Les 3 derniers enregistrements réels de l'ESP32 (server/recordings/) transcrits sans faute (« Fermez le volet de la salle de bain. »). 65 tests (dont 3 avec les vrais modèles, ignorés sansmake models), pyright 0 erreur. Délai perçu attendu ≈ 1,1 s de calcul + 0,3 s d'avance d'envoi. Pas d'essai bout en bout sur le broker réel (éviterait deux serveurs qui répondent à la même carte). En attente de validation matérielle par l'utilisateur (make run). - 2026-09-29 — Étape 6 validée par l'utilisateur (« Dialogue très correct »). 9 questions
d'affilée, toutes les sessions
complete, toutes les lectureschunks N/N: heure, date, bonjour, nom → bonnes réponses ; phrases libres répétées. Réponse prête 0,95 à 1,11 s après la fin de session (Whisper 0,89–1,06 s, Piper 0,05–0,11 s), pire retard d'envoi 0 ms. Appui sans parler : crête −30,7 dBFS (bruit du bouton, au-dessus du seuil de −45) → Whisper appelé, hallucination « Amara.org » écartée par le filtre, réponse « Je n'ai rien entendu. ». Points notés pour plus tard : (a) sous-alimentations 6 à 9 par lecture même sans perte, 25 et 35 quand le RSSI tombe à −83 dBm. La base 6–9 vient probablement du démarrage : la tâche audio vide chaque chunk dès son arrivée (DMA encore vide) et compte chaque bloc incomplet ; il n'y a pas de seuil de démarrage → étape 12 (lecture lancée seulement après l'avance de 300 ms, sous-alimentations comptées après) ; (b) session muette : RMS −46,8 dBFS contre −22 à −28 pour la parole → la VAD de l'étape 7 remplacera le seuil de crête ; (c) « fermé la porte » pour « fermez » : homophone, l'étape 11 devra tolérer ce genre d'écart ; (d) 7 chunks perdus faute de place à la capture (Wi-Fi). Prochaine étape : 7 (VAD Silero). Silero est déjà livré par faster-whisper (faster_whisper/assets/silero_vad_v6.onnx, exécuté par onnxruntime) : pas besoin de torch. - 2026-09-29 — Étape 7 écrite (serveur seul, firmware inchangé).
voice_activity.py(find_segments,pad_segments,VoiceActivityDetector,envelope), branché dansVoicePipelineavant Whisper ;tools/vad_check.py(make vad,make vad-all) ;speech_check --wavpasse par la VAD. Config : sectionvad(cléwindow_samplesretirée : 512 est imposé, constanteWINDOW_SAMPLES),stt.min_peak_dbfssupprimé. Mesures sur les 27 enregistrements du serveur : 6 sans parole, tous à juste titre (3 sessions vides de l'étape 5, l'appui muet0137fe, mes 2 sons pursesp32-test) ; pour les 9 questions de l'étape 6, 8 à 37 % de silence retiré, l'utilisateur parle ~0,5 s après l'appui. Gain inattendu :c5a22c, transcrit « fermé la porte » à l'étape 6, devient « fermez la porte » une fois le silence retiré. Appui muet : réponse prête en 0,03 s au lieu de 1,11 s.c5a22ccommence à 0,00 s : parole dès l'appui, début de mot possiblement coupé (piège à signaler). 78 tests, pyright 0 erreur. Pas d'essai sur le broker : lemake runde l'utilisateur (étape 6, lancé à 19:26) tournait encore ; vérification hors ligne demake_assistantsur 2 vraies sessions. En attente de validation par l'utilisateur. - 2026-09-29 — Étape 7 validée par l'utilisateur (« Semble fonctionner »). 6 sessions :
segments de parole trouvés partout (jusqu'à 3 segments, pauses de 0,6 à 0,95 s conservées en une
seule transcription), appui muet (crête −32,4 dBFS) rejeté par la VAD, réponse prête en 0,07 s.
VAD 7 à 11 ms (25 ms au premier appel). Erreur « 4 jours, sommes-nous ? » pour « Quel jour
sommes-nous ? » (
ce69b6, pause de 0,6 s après « jour ») : pas due à la VAD — sur l'audio brut, Whisper écrit « qu'à jour, sommes-nous ». Essaiinitial_prompt(phrases types de l'assistant) : corrigece69b6mais dégradea2428a(« quelles jours ») → non retenu ; à reconsidérer à l'étape 11 (vocabulaire des intentions, règles tolérantes : « jour » + « sommes ») ou modèlemediumà l'étape 16. Prochaine étape : 8 (diarisation pyannote). - 2026-09-29 — Étape 8 écrite (serveur seul). Installé par moi dans
server/.venv: pyannote.audio 4.0.7 + torch CPU (.venv= 1,9 Go), numpy monté à 2.5.3 ;pip checkpropre.diarization.py(Diarizer,Diarization,SpeakerTurn,timeline,disable_telemetry), branché dansVoicePipelineaprès la VAD ;make install-diarization,make diarize,make diarize-demo(voix de l'utilisateur + Piper + voix de l'utilisateur) ;make modelstélécharge le pipeline siHF_TOKENest dansserver/.env, sinon explique la démarche et rend 1. Découverte : pyannote 4 a une télémétrie active par défaut vers otel.pyannote.ai → coupée. Non vérifié : le vrai pipeline n'a pas tourné,server/.envn'existe pas (pas de jeton). J'ai relu le code de pyannote là où le mien s'y appuie : entrée{"waveform": (1, n), "sample_rate"}acceptée, silence →DiarizeOutputvide sans erreur,speaker_embeddings(n, dim). 84 tests + 1 ignoré (modèle réel), pyright 0 erreur. En attente : jeton de l'utilisateur, puis validation. - 2026-09-29 — Modèle pyannote obtenu : premier refus
GatedRepoErrorcar le compte HF du jeton (type « read », valide) n'avait pas accepté les conditions ; réglé par l'utilisateur.make models,make test(85, vrai pipeline compris) etmake diarize-demoOK : 2 locuteurs trouvés dans l'ordre vous / Piper / vous (0,08-1,55 · 1,75-5,58 · 5,82-7,25 s), 0,47 s pour 7,35 s de parole. Avertissement torchstd(): degrees of freedom(morceau trop court) masqué dansDiarizer.diarize. Question seule enmake run: OK selon l'utilisateur. Reste : une session réelle à deux voix. - 2026-09-29 — Test réel à deux voix réussi (session
453cce, 11,85 s, au micro de l'ESP32) : l'utilisateur, une voix Piper jouée par un haut-parleur, l'utilisateur. VAD : 3 segments, 6,34 s de parole. Diarisation : SPEAKER_00 0,08-1,50 · SPEAKER_01 1,57-4,69 · SPEAKER_00 4,92-6,17 — les deux phrases de l'utilisateur portent la même étiquette, et les bornes coïncident avec celles de la VAD (1,61 et 4,89 s dans l'audio sans silences). Diarisation 0,46 s. Délai : réponse prête en 2,04 s (VAD 0,02 + diarisation 0,46 + Whisper 1,34 pour 6,3 s + Piper 0,21) : la diarisation s'ajoute à Whisper → candidate à la parallélisation (étape 16). « la roi de synthèse » : voix passée par un haut-parleur puis le micro. Validation explicite de l'étape 8 non encore donnée. - 2026-09-29 — Étape 8 validée par l'utilisateur. Étape 9 lancée avec les empreintes de pyannote (choix de l'utilisateur, après comparaison avec SpeechBrain ECAPA : précision publiée du même ordre ; SpeechBrain n'apporterait que l'indépendance vis-à-vis de la diarisation, au prix d'un modèle, d'une dépendance et d'un risque de compatibilité avec torchaudio 2.11).
- 2026-09-29 — Étape 9 écrite (serveur seul). Mesure préalable : les empreintes rendues par
pyannote sont NaN pour toutes les questions courtes (cf. décisions) → empreinte recalculée par
locuteur avec le sous-modèle WeSpeaker.
speaker_identifier.py(SpeakerIdentifier,VoiceProfile,Identification,save_profile,load_profiles),tools/enroll.py,make enroll/profiles/forget, identification affichée parmake diarize;VoicePipelinejournaliseSPEAKER_00 = Nom (score).diarize-demochoisissait453cce(deux voix) puis093463(mélange) : il prend maintenant la question la plus récente de 1 à 4 s à une seule voix (--voicepour imposer). Avertissements numpy « Mean of empty slice » (empreintes NaN internes de pyannote) masqués. Essai complet dans le scratchpad (aucun profil écrit dansserver/profiles/) : profil « Essai » sur 10 sessions → 6 retenues (2 à deux voix, 1 muette, 1 mélangée écartées), cohérence 0,79–0,86 ;c5a22c→ Essai 0,74 ;453cce→ Essai 0,72 / haut-parleur Inconnu −0,05 ; démo → Essai 0,84 / Piper Inconnu −0,02. Délai : + ~0,5 s de diarisation et d'empreinte (réponse prête 1,47 s pour une question de 1,3 s). 98 tests, pyright 0 erreur. En attente : enrôlement réel et validation. - 2026-09-30 — Fichiers de test pour l'étape 9 (demande de l'utilisateur, à jouer depuis son
téléphone) :
recordings/voix-test/{1-tom,2-pierre,3-jessica,4-gilles,5-siwis}.{wav,mp3}, 5 voix Piper différentes (tom-medium, upmc-medium pierre et jessica, gilles-low, siwis-medium), 0,5 s de silence avant et après, crête −3 dBFS, toutes relues correctement par Whisper. Voix téléchargées dans le scratchpad puis supprimées (rien ajouté àserver/models/).fr_FR-mls-mediumécartée : aucun de ses 125 locuteurs n'est transcrit correctement. Mesure à retenir : Jessica et Siwis (deux voix féminines de synthèse) se ressemblent à 0,60 (fichier contre fichier), au-dessus du seuil de 0,45 ; les autres paires 0,01–0,37. Le seuil devra peut-être monter si une voix étrangère est reconnue comme l'utilisateur (ses propres sessions : 0,52–0,83, marge faible). - 2026-09-30 — Étape 9 validée par l'utilisateur (« Tout semble marcher super bien »). 6 profils enrôlés par l'utilisateur : Denis (5 sessions) et les 5 voix de synthèse jouées au téléphone (Gilles 3, Jessica 3, Pierre 4, Siwis 3, Tom 5). Essai réel : Denis 0,74 et 0,78 ; Tom 0,95, Gilles 0,96, Siwis 0,96, Jessica 0,93, Pierre 0,92 — chaque voix trouvée, Jessica et Siwis (0,60 entre elles) bien départagées par le meilleur score. Réponse prête en 1,5 à 1,8 s (diarisation + empreinte ≈ 0,45 s). Limites à garder en tête : (1) les scores de 0,92–0,96 des voix de synthèse sont gonflés — même fichier rejoué à l'enrôlement et au test, audio quasi identique ; le vrai test de généralisation est Denis (phrases nouvelles, 0,74–0,78) ; (2) le cas « Inconnu » n'a pas été rejoué en réel ce matin (toutes les voix étaient enrôlées) ; il a été vérifié hier (haut-parleur −0,05, Piper −0,02) ; (3) seuil 0,45 à recalibrer avec de vraies personnes (étape 14). « Quel ton fera-t-il » pour « temps » : Whisper sur une voix de téléphone. Prochaine étape : 10.
- 2026-09-30 — Étape 10 écrite (serveur seul).
WordetTranscript.words(Whisper,word_timestamps),speaker_attribution.py(turn_of,attribute,Utterance,transcript_json),VoicePipelinepublie survoice/<carte>/transcriptet passe le nom de la dernière personne à l'assistant ;make diarizeaffiche qui a dit quoi et le message MQTT. Essai hors ligne sur453cceavec les profils réels de l'utilisateur : Denis « Ouvrir la porte. », Siwis (juste : c'était sa voix au haut-parleur) « Je suis la roi de synthèse… », Denis « Fermez la porte. », bornes 0,00-1,26 / 1,50-4,46 / 4,92-5,86 s. 110 tests, pyright 0 erreur. En attente : essai sur la carte et validation. - 2026-09-30 — Étape 10 validée par l'utilisateur (« continuer maintenant »). Essai carte :
Denis 0,68 / 0,79, Jessica 0,92, interventions et textes justes ; « Bonjour » seul (1,06 s de parole)
→ Inconnu 0,32 → salutation générique (piège annoncé : phrase trop courte pour une empreinte).
Session à plusieurs voix non refaite sur la carte (vérifiée hors ligne sur
453cce).make mqtt-watchaffichait l'audio binaire (voice/#) : limité aux topics texte (state, event, control, transcript) ; ajout demake mqtt-transcript. Prochaine étape : 11 (intentions + domotique). - 2026-09-30 — Étape 11 écrite (serveur + firmware). Choix utilisateur : commandes vers
home/…et exécution par la carte pour sa pièce ; pas de domotique réelle (simulation). Serveur :home_control.py,Assistant.respond()→Reply(text, command),HomeController,MqttLink.publish_topic, sectionhomede config.yaml (pièces +boards: esp32-01: salon), journal des confirmationsdevice. Firmware :DemoDevices,CommandHandlergèrecmd: device,PIN_DEMO_LIGHT = 21;make fw-checkSUCCESS, 0 warning (RAM 36,7 %, Flash 31,4 %).make mqtt-watchincluthome/#;make mqtt-home.docs/BROCHAGE.md: LED GPIO 21 + 330 Ω (non dessinée surwiring.svg). Essai hors ligne sur 3 enregistrements réels : volets du salon fermés/ouverts →home/salon/shutter/set+esp32-01/control; salle de bain →home/…seul. 130 tests, pyright 0 erreur. Firmware non téléversé (à faire par l'utilisateur). En attente de validation. - 2026-09-30 — Étape 11 validée par l'utilisateur (« Semble fonctionner correctement »),
firmware téléversé. « Éteint la lumière » (graphie Whisper) et « Allume la lumière » →
home/salon/light/set+esp32-01/control, confirmation de la carte 40 ms après la commande ({"event":"device","ok":true,"state":"off"}), puis la réponse parlée ; « Quel temps fait-il ? » → réponse « sans Internet ». Réponse prête en 1,41 à 1,55 s.mqtt-watchlisible : transcript, commande, confirmation, START/END de lecture,played. Sous-alimentations toujours 6 à 10 par lecture, sans aucune perte (chunks 34/34) : c'est l'objet de l'étape 12. Prochaine étape : 12. - 2026-09-30 — Exigence utilisateur : commander des machines Linux → étape 15b (décidé).
- 2026-09-30 — Étape 12 écrite. Firmware :
PlaybackStreamdevient une machine à états (Idle / Priming / Playing) dans la tâche audio ;PLAYBACK_START_MS = 300; bilanplayedenrichi (start_ms,min_margin_ms,underruns= réamorçages) ;make fw-checkSUCCESS, 0 warning (RAM 36,7 %, Flash 31,4 %). Cause probable des 6 à 10 « sous-alimentations » : la tâche jouait dès le premier chunk, sans attendre l'avance envoyée par le serveur, et comptait chaque bloc incomplet. Serveur : journalplayedenrichi (WARNING s'il y a des réamorçages) ; bancJitteryLinkdanstools/wav_to_mqtt.py,JITTERdans le Makefile,tone-mqttpasse à 5 s. 131 tests, pyright 0 erreur. Non vérifié sur la carte (je n'envoie pas de son sur le matériel de l'utilisateur) : firmware à téléverser, puis essaisJITTER=0/200/400. En attente de validation. - 2026-09-30 — Premiers essais de l'étape 12 sur la carte (
tone-mqtt5 s, JITTER 0 / 200 / 400) : marge mini 204 / 142 / 2 ms (suit bien la gigue), réamorçages 1 / 1 / 2, premier son à 214–218 ms. Deux écarts avec la prévision : (1) un réamorçage systématique, même sans gigue, hors de la marge mesurée ; (2) l'avance de 300 ms n'arrive pas d'un bloc mais en ~200 ms (hypothèse : fenêtre TCP de lwIP ~5,7 Ko < 6 chunks de 1,6 Ko, la carte lit au rythme de PubSubClient). Instrumentation ajoutée plutôt que supposition :gaps_ms(instant de chaque réamorçage depuis le premier son) dans le bilanplayed, la série et le journal serveur. En attente d'un nouvel essai. - 2026-09-30 — Cause du réamorçage systématique trouvée grâce à
gaps_ms: 1er essai après démarrage 0 réamorçage ; 2e essaigaps_ms:[28043]= uptime de la carte, donc compté avant le premier son du flux (_firstSoundMs= 0). Faux compte, inaudible : après un flux, la tâche restait en Playing, bloquée ≤ 20 ms dansread(); le START suivant arrivait pendant ce temps, leread()rendait 0 octet,_endOfStreamvenait d'être remis à faux → famine comptée pour un flux pas encore commencé. Correctif : passage à Idle quand la FIFO est vide après le END, famine comptée seulement par compare-and-swap Playing → Priming (échoue siopen()a déjà changé l'état), etopen()met l'état à Priming avant de remettre les compteurs à zéro.make fw-checkSUCCESS. Premier son à 214 ms : l'avance arrive étalée (fenêtre TCP de lwIP probable) ; à revoir à l'étape 16 (latence), sans effet sur la fluidité. Marge mini 188–204 ms sans gigue. - 2026-09-30 — Essai final de l'étape 12 (firmware corrigé) :
tone-mqtt×3 → 0 réamorçage,gaps_msvide à chaque fois (le cas qui échouait) ; JITTER=200 → 0, marge mini 162 ms ; JITTER=400 → 1 réamorçage réel à 364 ms du premier son (1er blocage), marge 2 ms. Premier son 212–221 ms. Critères de l'étape 12 atteints ; validation explicite de l'utilisateur en attente. - 2026-09-30 — Étape 12 validée par l'utilisateur. Étape 13 lancée : mots de réveil anglais prêts à l'emploi alexa, hey_jarvis, hey_mycroft (les trois ; alexa si un seul).
- 2026-09-30 — Étape 13 écrite (serveur + firmware). openWakeWord installé
--no-depsdansserver/.venv, modèles dansserver/models/openwakeword/(make models). Serveur :wake_word.py,hands_free.py,make_listenerdans__main__.py(état/événements composés, balayage, arrêt qui coupe l'écoute), sectionwakede config.yaml, extrawakeword+make install-wakeword. Firmware :cmd listen/cmd capture, flux continu dansloop()(updateStreaming,stopStreaming), LEDblink(2000, 100);make fw-checkSUCCESS, 0 warning. Simulation complète hors ligne (flux : phrase sans mot puis « Alexa, allume la lumière », Piper) : 1re phrase ignorée, aucun fichier ; mot 0,99 ; commande close 2,75 s après ;home/salon/light/set+ carte ; réponse. 142 tests (dont vie privée : rien écrit en veille ; rafale Wi-Fi), pyright 0 erreur. Non vérifié sur la carte : firmware à téléverser. Limite connue : broker anonyme → flux audio lisible par tout le réseau local (étape 15) ; si le serveur meurt sanslisten false, la carte continue d'émettre (testament serveur à prévoir à l'étape 15). En attente de validation. - 2026-09-30 — Retour utilisateur (essai réel de l'étape 13) : « Hey Jarvis » et « Alexa » pas
toujours reconnus (prononciation anglaise nécessaire), « Hey Mycroft » fiable en français.
Ajouts : seuil propre à chaque mot (
wake.thresholds, vide par défaut) et journal des mots presque reconnus (pic de score ≥ 0,15 retombé sans atteindre le seuil) pour régler sur mesures. 143 tests, pyright 0 erreur. Aucun seuil changé tant qu'on n'a pas les pics réels. - 2026-09-30 — Étape 13 validée par l'utilisateur, qui retient « Hey Mycroft » comme mot principal (fiable prononcé en français) ; alexa et hey_jarvis restent actifs. Réglage des seuils par mot reporté (outil prêt : « presque reconnu »). Étape 14 lancée : droits par profil.
- 2026-09-30 — Étape 14 écrite (serveur seul, firmware inchangé).
access_control.py(AccessPolicy,Level,Verdict,Resolution), sectionaccessde config.yaml,VoicePipeline._decide(confirmation en attente d'abord, puis droits, puis exécution),home_control.announce/request, élision « d'ouvrir ». Essai hors ligne avec les vrais profils : Siwis (standard, 0,59) « ouvre la porte » → refusé ; Denis (complet, 0,69) → confirmation demandée ; phrase suivante sans oui/non → demande abandonnée ; Denis ferme les volets → exécuté. 162 tests, pyright 0 erreur. En attente de l'essai réel et de la validation. - 2026-09-30 — Ajout de
make say(tools/say.py) : fait direTEXTpar une voix Piper (VOICE,SPEAKERpour upmc : 0 = Jessica, 1 = Pierre), 0,5 s de silence avant/après, crête −3 dBFS, WAV + MP3 (ffmpeg) dansSAY_OUT(défautrecordings/voix-test/phrase.wav) ; voix téléchargée au premier usage. Pour tester droits et confirmation sans seconde personne (≠speech-check, qui fait entendre la RÉPONSE). Générés :recordings/voix-test/tom-porte(« Hey Mycroft, ouvre la porte. ») etpierre-lumiere(« Hey Mycroft, allume la lumière. »). - 2026-09-30 — Retour utilisateur : « Hey Mycroft » doit être prononcé à l'anglaise, sinon rien.
Mesures (détecteur hey_mycroft réel, scratchpad) : voix FR (tom, upmc, siwis) en texte ou en graphies
francisées → 0,00 ; en phonèmes bruts Piper
[[ˈɛj mˈajkʁɔft]]→ ~1 synthèse sur 2 atteint 0,5 (Piper est aléatoire), mais retombe à 0 au moindre bruit/filtrage → inutilisable au téléphone. Voix anglaise en_GB-alan-medium → 1,00 partout, même bruit 15 dB + bande 300-4000 Hz + −12 dB (ryan et lessac moins stables). D'oùmake say WAKE="Hey Mycroft"(--wake,--wake-voice) : Alan dit le mot, 0,6 s de pause, puis VOICE dit la commande ; chaque morceau normalisé à −3 dBFS, sortie 16 kHz ; fichier vérifié parWakeWordDetector(échec → code 1). Identification : le serveur prend la dernière intervention, mais la pré-écoute (0,5 s avant la détection, qui tombe ~0,15 s avant la fin du mot) mêle la voix d'Alan : commande courte → Tom 0,42-0,46 (sous le seuil) ; commande ≥ 2 s (« …du salon, s'il te plaît. ») → Tom 0,57, Pierre 0,77 sur la découpe exacte du serveur. Refaits :tom-porte(« Ouvre la porte du salon, s'il te plaît. », détecteur 1,00) etpierre-lumiere(« Allume la lumière du salon, s'il te plaît. », 0,95). 162 tests, pyright 0 erreur. - 2026-09-30 — Essai réel de l'étape 14 (« satisfaisant, ma voix Denis OK »). Denis : lumière
on/off exécutées (0,71-0,84), porte → confirmation → « oui » (bouton, ou « croft ? Oui. » en mains
libres) → exécutée ; Tom (fichier téléphone, 0,68) porte → refusé ; Pierre (0,83) identifié.
Bug trouvé : Pierre (standard) REFUSÉ pour allumer la lumière, « requis complet ». Cause : YAML 1.1
lit
on/offnus comme des booléens → règleslight.True/False, actiononabsente → défaut complet. Corrigé : guillemets dans config.yaml,settings._key()rend True/False → on/off, etAccessPolicyrefuse au démarrage une action inconnue (plus d'erreur silencieuse). Aussi : Denis à 0,59 (< full_min_score 0,6) recevait « votre profil ne permet pas » — faux ; message désormais « je ne reconnais pas assez nettement votre voix… Répétez, plus près du micro. ». Autres constats, non corrigés : 1re session hallucinée par Whisper (0,82 s de parole → « Je vous remercie de votre soutien… », 5,5 s de calcul) ; un « Ouvre » perdu en tête de commande (« la porte du salon… » → « Que dois-je faire avec la porte ? »), réussi au 2e essai. 165 tests, pyright 0 erreur. - 2026-09-30 — Étape 14 validée par l'utilisateur. Étape 15 lancée.
- 2026-09-30 — Étape 15 écrite (broker + serveur + firmware). Broker :
voice.conf(allow_anonymous false,password_file,acl_file),mosquitto/acl,make mqtt-user,mosquitto-docker/mosquitto-configinstallent acl + passwd (0600). Vérifié sur un broker jetable (conteneurvoice-acl-test, port 18830, supprimé ensuite ; le broker de l'utilisateur n'a pas été touché) : anonyme et mauvais mot de passe refusés ; carte qui publie sous le nom d'une autre, lit le micro ou les commandes d'une autre, publie sur home/ → jeté ; carte lit ses commandes et la présence. Un broker ouvert accepte un client qui envoie des identifiants (vérifié) → migration : comptes, .env + secrets.h, firmware + serveur, broker en dernier. Serveur :MqttLink(presence=True),refusal_hint,setup_logging; vérifié : arrêt propre →offline,kill -9→ testament immédiat, mauvais mot de passe → message clair,make run-echoavec config de test → journal fichier. Firmware : TWDT 15 s,setConnectionTimeout(1000), Wi-Fi relancé à 30 s, présence serveur (micro coupé si absent), utilisateur MQTT = DEVICE_ID par défaut, diagnostic codes 4/5.make fw-checkSUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). 170 tests, pyright 0 erreur. Non vérifié sur la carte : déclenchement réel du chien de garde (pas de commande de test). En attente de l'essai réel et de la validation. - 2026-09-30 — Étape 15 validée par l'utilisateur, qui garde le broker de développement
anonyme (
make mosquitto-dockernon lancé ; comptes et ACL à activer en production). Conséquence à rappeler : tant que le broker est anonyme, tout appareil du réseau peut publier sur les topics de commande (cartes, et agents Linux de l'étape 15b). Étape 15b lancée : agent MQTT sur chaque machine, configuration YAML. - 2026-09-30 — Étape 15b écrite (serveur + agent ; firmware inchangé). Serveur :
machine_control.py(parse/decide,MachineController: états des agents, interprétation, commande ou Wake-on-LAN),command_router.py,MqttLink.on_topic(filtres hors voice/), sectionmachinesde config.yaml (actions : mots + phrases ;pc-bureau),AccessPolicyetAssistantgénéralisés (Command = DeviceCommand | MachineCommand). Agent :agent/voice_agent.py,agent.yaml.example, service systemd,agent/Makefile; ciblesagent-run,agent-install,agent-log,mqtt-agents; règles agents dansmosquitto/acl(pour la production). Essai de bout en bout sur broker jetable anonyme (port 18832, supprimé) : agent endry_runannoncé avec 3 actions ; « éteins » et « verrouille » → agent → compte rendu ; « allume » → « déjà en marche » ; agent arrêté → hors ligne → « ne répond pas ». 202 tests, pyright 0 erreur (agent/ inclus). En attente de l'essai réel. - 2026-09-30 — Git initialisé à la demande de l'utilisateur, avant l'essai de l'étape 15b :
premier commit (108 fichiers) sur
main; contrôle des fichiers sensibles fait (§7). - 2026-10-06 — Reprise sur le PC de dev. Entre-temps, 3 commits de l'utilisateur (
WifiConfig: portail de configuration et NVS ; README agent ; agent installé sous/etc/voice-assistant) et un dépôt distant GitHub. Symptôme : «make runne donne plus de réponse ». Cause :mqtt.hostpassé à 192.168.1.200 (Pi 5, comptes exigés), mais la carte n'est sur aucun broker : absente de .200 (aucunvoice/esp32-01/state),offlinesur .104. Serveur sain (connecté, agentpc-bureauvu en ligne,dry_run). À 15:50, serveur éjecté toutes les 2 s : un autre clientvoice-serversur .200 (second serveur, arrêté depuis). Décision : broker sur le Pi 5. Fait :MQTT_HOSTdu Makefile relu dansconfig.yaml, modèles (agent.yaml.example,secrets.h.example, README) en .200, restes de/etc/voice-agentalignés sur/etc/voice-assistant(dont le défaut--configde l'agent). Reste à faire par l'utilisateur : compteesp32-01sur le Pi, puis portail de la carte (broker 192.168.1.200, utilisateuresp32-01, mot de passe). Étape 15b toujours en attente d'essai. - 2026-10-06 — Carte reconnectée, chaîne complète rétablie sur le Pi 5. Réseau réel : point d'accès
RPI5-APdu Pi (NetworkManager, réseau 10.42.0.0/24, Pi = 10.42.0.1 ; le Pi est aussi 192.168.1.200 en Ethernet, même broker). Carte : broker10.42.0.1, IP 10.42.0.73, signal −53 dBm. Trois causes successives, lues dans le moniteur série (921600 bauds) : (1) session envoyée en binaire sur le port série = MQTT non connecté (repli de l'étape 4) ; (2)Reason: 211: l'AP était en WPA1, le core ESP32 3.x exige WPA2 par défaut (_minSecurity) → AP passé enproto rsn, CCMP, canal 6 (était 13) ; (3)Reason: 15 4WAY_HANDSHAKE_TIMEOUT: mot de passe Wi-Fi enregistré dans la carte faux, prouvé parAP-STA-POSSIBLE-PSK-MISMATCHdans le journalwpa_supplicantdu Pi → ressaisi dans le portail. Piège du portailWifiConfig: champs de mot de passe toujours vides, un champ laissé vide efface la valeur enregistrée (correction proposée à l'utilisateur, pas de réponse). Étape 15b toujours en attente d'essai et de validation. - 2026-10-06 — Premiers essais réels de l'étape 15b (agent
pc-bureauendry_run, broker du Pi) : « verrouille le PC du bureau » exécuté deux fois (agent :essai : loginctl lock-sessions). (1) Mots anglais non reconnus (« Locke », « Reboot », « Shut down » → « Que dois-je faire… ») : ajoutés àmachines.actions.*.words(lock/locke/locker/loque, reboot/reboote/rebooter, shutdown/shut). (2) Confirmation refusée : « OUI » de Denis à 0,35 = « voix inconnue » → règle du profil le plus proche (confirm_min_score: 0.25,AccessPolicy.resolve(..., closest, score), le pipeline transmetIdentification.closest). 214 tests, pyright 0 erreur. Serveur à relancer (config.yaml lu au démarrage). Étape 15b : essais en cours, pas encore validée.