Compare commits

..

7 Commits

Author SHA1 Message Date
Denis Defolie d114b9818e step-16 2026-10-06 19:24:22 +02:00
Denis Defolie 79d45587ba update README AP RPI5 2026-10-06 18:47:36 +02:00
Denis Defolie ec88de1b3c correction agent 2026-10-06 18:17:48 +02:00
denis 3051d6abbe makefile-1-update 2026-10-06 15:55:05 +02:00
denis 35bb51145f readme-agent 2026-10-06 14:43:35 +02:00
denis 1d07a0c255 make-agent 2026-10-06 14:27:41 +02:00
denis 99beaff4ca firmware-readme-update 2026-10-06 14:12:13 +02:00
29 changed files with 1297 additions and 108 deletions
+105 -24
View File
@@ -35,8 +35,8 @@ Prompt de référence : `prompt-assistant-vocal-esp32.md` (rôle, style, feuille
| 13 | Wake word (openWakeWord) | **fait** (serveur + firmware) | **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)** | | 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** |
| 15 | Fiabilisation | **fait** (broker + serveur + firmware) | **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 | | 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | **oui (2026-10-06)** |
| 16 | Optimisation | à faire | — | | 16 | Optimisation | **fait** (serveur + option broker ; firmware inchangé) | en attente |
**Règle absolue** : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N. **Règle absolue** : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N.
@@ -61,6 +61,8 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
| Périphériques I2S | I2S0 = micro (RX), I2S1 = ampli (TX) | duplex séparé, pas de conflit d'horloges | | 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 | | 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 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 | | 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 | | 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 | | Partitions `huge_app.csv` (3 Mo d'application, pas d'OTA) | 2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut |
@@ -129,6 +131,11 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
| 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 | | 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 | | 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) | | 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) |
| Étape 16 : **parole < `diarization.direct_below_s` (4 s) = une seule personne** → `Diarizer.embed()` au lieu de la diarisation, Whisper sans mots horodatés | 2026-10-06 | mesuré sur 113 sessions réelles : 0,02 s contre 0,42 s, écart de score médian +0,001, 4 verdicts changés (2 en mieux, 2 en moins bien, tous au seuil) ; aucune session récente à 2 voix. Contrepartie : deux voix dans une phrase courte ne sont plus séparées |
| Étape 16 : parallélisme diarisation / Whisper **écarté** | 2026-10-06 | mesuré : séquentiel 1,43-1,59 s, parallèle 1,34-1,56 s ; chacun ralentit l'autre (Whisper 1,1 → 1,4 s, diarisation 0,4 → 0,8 s), **même dans deux processus** : saturation du processeur, pas le verrou de Python |
| Étape 16 : `beam_size` 5 gardé, `stt.cpu_threads` 0 (défaut, 4 fils) | 2026-10-06 | beam 1 : −0,05 s et 1 texte différent sur 16 ; fils : 4 → 1,08 s, 8 → 0,98 s, 16 → 1,29 s sur le PC ; l'encodeur de Whisper (fenêtre fixe de 30 s) domine |
| Étape 16 : cache de synthèse (64 phrases), fin de commande cherchée toutes les 100 ms, `TCP_NODELAY` côté serveur (`on_socket_open`) et broker (`set_tcp_nodelay true`) | 2026-10-06 | cache : 0,03-0,14 s par réponse répétée ; l'effet de `TCP_NODELAY` sur « premier son a ~240 ms » est une **hypothèse** (Nagle + accusés retardés de l'ESP32), à confirmer sur la carte |
| 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) | | 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) | | 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) | | 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) |
@@ -144,7 +151,7 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
| « 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 | | « 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) | | 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 | | 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-agent/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 | | 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 | | `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 | | **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 | | `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 |
@@ -199,6 +206,7 @@ server/voice_server/wake_word.py WakeWordDetector (openWakeWord ONNX)
server/voice_server/hands_free.py HandsFreeListener : veille / commande / réponse [é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] server/voice_server/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
tools/enroll.py make enroll / profiles / forget [étape 9] tools/enroll.py make enroll / profiles / forget [étape 9]
tools/latency_check.py make latency : avant/après sur enregistrements réels [étape 16]
tools/say.py make say : phrase Piper en WAV + MP3 (téléphone) [étape 14] 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] docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3]
firmware/src/main.cpp boucle PTT → I2S → série [étape 1] firmware/src/main.cpp boucle PTT → I2S → série [étape 1]
@@ -212,8 +220,9 @@ mosquitto/passwd comptes hachés (make mqtt-user), NON version
server/tests/test_mqtt_link.py présence, refus du broker, journal fichier [é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/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
server/voice_server/command_router.py CommandRouter : domotique / machines [é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/voice_agent.py agent Linux autonome (liste blanche, dry_run) [étape 15b]
agent/agent.yaml.example modèle de /etc/voice-agent/agent.yaml [é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] 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/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
server/ squelette Python (modules à partir de l'étape 3) server/ squelette Python (modules à partir de l'étape 3)
@@ -230,29 +239,58 @@ TLS (option de l'étape 15, non activée).
1. Lire ce fichier, puis `README.md`. 1. Lire ce fichier, puis `README.md`.
2. `make help` liste toutes les cibles. 2. `make help` liste toutes les cibles.
3. Vérifier l'étape en cours dans le tableau du §2, et **ne rien faire au-delà**. 3. Vérifier l'étape en cours dans le tableau du §2, et **ne rien faire au-delà**.
4. Contrôle rapide de l'environnement : `make test` (98 tests), `make lint`, `make fw-check`. 4. Contrôle rapide de l'environnement : `make test` (203 tests), `make lint`, `make fw-check`.
### État au 2026-09-29 au soir (fin de session) ### État au 2026-09-30 au soir (fin de session)
- (état du 2026-09-29 au soir, dépassé : l'étape 9 a été validée le 2026-09-30, voir le journal.) - **Étapes 1 à 15 validées.** Étape **15b écrite**, vérifiée hors carte (broker jetable), **pas encore
- Étape 9 alors écrite et testée hors ligne. Étapes 1 à 8 validées. essayée par l'utilisateur**. Ne pas commencer l'étape 16 avant sa validation explicite.
- Tout était vert à l'arrêt : 98 tests, pyright 0 erreur, `make fw-check` SUCCESS (RAM 36,7 %, - Vérifié à l'arrêt : 203 tests, pyright 0 erreur (tools/ et agent/ inclus), `make fw-check`
Flash 31,3 %), `pip check` propre. Aucun serveur lancé. SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). Git : `main`, 1 commit, arbre propre.
- `server/.venv` contient les extras `speech` et `diarization` (torch CPU) ; `server/models/` Aucun serveur, agent ni conteneur de test lancé (`voice-acl-test`, `voice-agent-test` supprimés).
contient whisper `small`, la voix Piper et le pipeline pyannote (accès HF réglé, `HF_TOKEN` dans - Broker : **Raspberry Pi 5, 192.168.1.200, comptes exigés** (depuis le 2026-10-06). Le serveur s'y
`server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**). connecte (`voice-server`, `server/.env`). Le conteneur local (192.168.1.104, anonyme) n'est plus utilisé.
- **Aucun profil vocal** : `server/profiles/` n'existe pas encore (mes essais étaient dans le scratchpad). - Firmware : la version **étape 13** est sur la carte (essais mains libres réussis) ; téléversement de
- Firmware inchangé depuis l'étape 5 (déjà sur la carte). Broker : conteneur Docker de l'utilisateur, 192.168.1.104. 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` : extras `speech`, `diarization` (torch CPU), `wakeword` ; `server/models/` complet ;
`HF_TOKEN` dans `server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**.
Ne jamais toucher `firmware/include/secrets.h` (utiliser `make 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 9 (actions de l'utilisateur) ### Reprise de l'étape 15b (actions de l'utilisateur)
1. `make run`, puis 5 questions variées de 1 à 4 s, seul. 1. Terminal 1 : `make agent-run` (crée `agent/agent.yaml` en **mode essai**, `dry_run: true` ; vérifier
2. Dans un autre terminal, sans arrêter le serveur : `make enroll NAME=Denis`. `id: pc-bureau`, `mqtt.host: 192.168.1.200` et le compte MQTT `pc-bureau`).
3. Nouvelles questions → le journal doit afficher `SPEAKER_00 = Denis (0.7x)`. 2. Terminal 2 : `make run` → `agent pc-bureau : en ligne, actions lock, reboot, shutdown`.
4. Une autre voix (personne ou haut-parleur) → `Inconnu (…, proche de Denis)`. 3. « Hey Mycroft, verrouille le PC du bureau » → « Je verrouille le PC du bureau. » ; agent :
5. `make diarize-demo` → votre voix reconnue, Piper « Inconnu ». `essai : loginctl lock-sessions (non execute, dry_run)`.
6. Analyser le journal collé, puis demander la validation de l'étape 9 avant l'étape 10 4. « Hey Mycroft, éteins le PC du bureau » → « Confirmez-vous… ? » → « Hey Mycroft, oui » → agent :
(association locuteur + texte : `{"speaker": "Denis", "text": "..."}`). `essai : systemctl poweroff`.
5. « Hey Mycroft, allume le PC du bureau » → « …est déjà en marche. »
6. Ctrl-C sur l'agent → serveur : `agent pc-bureau : hors ligne` ; « éteins le PC du bureau » →
« …ne répond pas ».
7. 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.
--- ---
@@ -269,7 +307,8 @@ Reste à valider **avec le matériel** : câblage, niveau du micro, intelligibil
## 7. Git ## 7. Git
Dépôt initialisé le 2026-09-30 (branche `main`, aucun dépôt distant). Identité **locale au dépôt** : 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. 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`, 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. profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements virtuels sont ignorés.
@@ -766,3 +805,45 @@ profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements
« ne répond pas ». 202 tests, pyright 0 erreur (agent/ inclus). En attente de l'essai réel. « 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 : - **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). 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 run` ne donne plus de réponse ». Cause : `mqtt.host` passé à
192.168.1.200 (Pi 5, comptes exigés), mais **la carte n'est sur aucun broker** : absente de .200
(aucun `voice/esp32-01/state`), `offline` sur .104. Serveur sain (connecté, agent `pc-bureau` vu en
ligne, `dry_run`). À 15:50, serveur éjecté toutes les 2 s : un autre client `voice-server` sur .200
(second serveur, arrêté depuis). Décision : broker sur le Pi 5. Fait : `MQTT_HOST` du Makefile relu
dans `config.yaml`, modèles (`agent.yaml.example`, `secrets.h.example`, README) en .200, restes de
`/etc/voice-agent` alignés sur `/etc/voice-assistant` (dont le défaut `--config` de l'agent).
**Reste à faire par l'utilisateur** : compte `esp32-01` sur le Pi, puis portail de la carte
(broker 192.168.1.200, utilisateur `esp32-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-AP`** du 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 : broker `10.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é en
`proto rsn`, CCMP, **canal 6** (était 13) ; (3) `Reason: 15 4WAY_HANDSHAKE_TIMEOUT` : mot de passe
Wi-Fi enregistré dans la carte faux, prouvé par `AP-STA-POSSIBLE-PSK-MISMATCH` dans le journal
`wpa_supplicant` du Pi → ressaisi dans le portail. Piège du portail `WifiConfig` : 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-bureau` en `dry_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 transmet `Identification.closest`). 214 tests, pyright 0 erreur. Serveur à relancer
(config.yaml lu au démarrage). Étape 15b : essais en cours, pas encore validée.
- **2026-10-06** — **Étape 15b validée** par l'utilisateur (« Agents Linux ok, réponse dans les logs »).
Étape 16 lancée : optimisation.
- **2026-10-06** — **Étape 16 écrite** (serveur seul, plus une option du broker ; firmware inchangé).
Point de départ mesuré sur 63 échanges réels : réponse prête en 1,54 s (Whisper 1,06 + diarisation
0,40 + synthèse 0,07). Bancs dans le scratchpad, sur les enregistrements réels en lecture seule.
Retenu : empreinte directe pour une parole courte, Whisper sans mots dans ce cas, cache de synthèse,
vérification de fin de commande à 100 ms, `TCP_NODELAY`. Écarté sur mesures : parallélisme, beam 1,
16 fils. `make latency COUNT=60` : **1,62 s → 1,09 s (−0,53 s, 33 %)**, même texte 54/54, même
identification 50/54. 220 tests, pyright 0 erreur. `voice.conf` validé sur un broker jetable
(supprimé). **À faire par l'utilisateur** : `make mosquitto-config` sur le Pi pour `set_tcp_nodelay`.
**Non fait, à décider** : numéros de séquence des chunks (§3.4 du prompt, change le protocole et le
firmware) ; `end_silence_ms` (800 ms d'attente après la phrase, le plus gros délai restant).
+10 -3
View File
@@ -16,13 +16,16 @@ OUT ?= recordings/test.wav
TONE ?= 440 TONE ?= 440
TEXT ?= Quelle heure est-il ? TEXT ?= Quelle heure est-il ?
LAST ?= 5 LAST ?= 5
COUNT ?= 30
JITTER ?= 0 JITTER ?= 0
VOICE ?= fr_FR-tom-medium VOICE ?= fr_FR-tom-medium
SPEAKER ?= SPEAKER ?=
SAY_OUT ?= recordings/voix-test/phrase.wav SAY_OUT ?= recordings/voix-test/phrase.wav
WAKE ?= WAKE ?=
WAKE_VOICE ?= en_GB-alan-medium WAKE_VOICE ?= en_GB-alan-medium
MQTT_HOST ?= 192.168.1.104 # Adresse du broker : UN seul réglage, mqtt.host de server/config.yaml, relu ici pour les
# cibles mqtt-* (surcharge ponctuelle : make mqtt-watch MQTT_HOST=...).
MQTT_HOST ?= $(shell awk '/^mqtt:/{m=1;next} m && $$1=="host:"{print $$2; exit}' server/config.yaml)
MSG ?= {"cmd":"ping"} MSG ?= {"cmd":"ping"}
MOSQ_DIR ?= $(HOME)/srv/mosquitto MOSQ_DIR ?= $(HOME)/srv/mosquitto
MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml
@@ -39,7 +42,7 @@ MQTT_AS = @echo "(compte MQTT : $(if $(MQTT_USERNAME),$(MQTT_USERNAME) de s
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help install-system mosquitto-config mosquitto-docker mqtt-user agent-run agent-install agent-log mqtt-agents mqtt-watch mqtt-transcript mqtt-home mqtt-control venv install install-dev install-speech install-diarization install-wakeword models \ .PHONY: help install-system mosquitto-config mosquitto-docker mqtt-user agent-run agent-install agent-log mqtt-agents mqtt-watch mqtt-transcript mqtt-home mqtt-control venv install install-dev install-speech install-diarization install-wakeword models \
say speech-check transcribe vad vad-all diarize diarize-demo enroll profiles forget run test lint record play inspect play-esp tone-esp play-mqtt tone-mqtt run-echo pio-venv fw-build fw-check fw-upload \ say latency speech-check transcribe vad vad-all diarize diarize-demo enroll profiles forget run test lint record play inspect play-esp tone-esp play-mqtt tone-mqtt run-echo pio-venv fw-build fw-check fw-upload \
fw-monitor clean fw-monitor clean
## ------------------------------------------------------------------ aide ## ------------------------------------------------------------------ aide
@@ -89,7 +92,7 @@ mosquitto/passwd:
mqtt-user: ## Crée ou change le compte MQTT NAME (voice-server, ou le DEVICE_ID d'une carte) dans mosquitto/passwd mqtt-user: ## Crée ou change le compte MQTT NAME (voice-server, ou le DEVICE_ID d'une carte) dans mosquitto/passwd
@test -n "$(NAME)" || { echo "usage : make mqtt-user NAME=voice-server puis make mqtt-user NAME=esp32-01"; exit 1; } @test -n "$(NAME)" || { echo "usage : make mqtt-user NAME=voice-server puis make mqtt-user NAME=esp32-01"; exit 1; }
@touch mosquitto/passwd && chmod 600 mosquitto/passwd @touch mosquitto/passwd && chmod 600 mosquitto/passwd
@echo "mot de passe de $(NAME) (idee : openssl rand -hex 16), a reporter dans $(if $(filter voice-server,$(NAME)),server/.env,firmware/include/secrets.h) :" @echo "mot de passe de $(NAME) (idee : openssl rand -hex 16), a reporter dans $(if $(filter voice-server,$(NAME)),server/.env,$(if $(findstring esp32,$(NAME)),firmware/include/secrets.h,/etc/voice-assistant/agent.yaml)) :"
@$(MOSQ_PASSWD) $(NAME) @$(MOSQ_PASSWD) $(NAME)
@echo "compte $(NAME) enregistre, hache, dans mosquitto/passwd. Installation : make mosquitto-docker (ou mosquitto-config)." @echo "compte $(NAME) enregistre, hache, dans mosquitto/passwd. Installation : make mosquitto-docker (ou mosquitto-config)."
@@ -137,6 +140,7 @@ venv: ## Crée l'environnement virtuel Python
install: venv ## Installe le serveur Python (dépendances de base) install: venv ## Installe le serveur Python (dépendances de base)
$(PIP) install -e server $(PIP) install -e server
@test -f server/.env || { cp server/.env.example server/.env; echo "server/.env créé depuis .env.example — renseigner MQTT_USERNAME/PASSWORD et HF_TOKEN si besoin."; }
install-dev: install pio-venv ## Installe en plus les outils de développement (pyright, pytest, platformio) install-dev: install pio-venv ## Installe en plus les outils de développement (pyright, pytest, platformio)
$(PIP) install -e "server[dev]" $(PIP) install -e "server[dev]"
@@ -160,6 +164,9 @@ say: ## Fait dire [WAKE (voix anglaise) puis] TEXT par VOICE (SPEAKER) : WAV +
$(PY) tools/say.py "$(TEXT)" --voice $(VOICE) $(if $(SPEAKER),--speaker $(SPEAKER)) \ $(PY) tools/say.py "$(TEXT)" --voice $(VOICE) $(if $(SPEAKER),--speaker $(SPEAKER)) \
$(if $(WAKE),--wake "$(WAKE)" --wake-voice $(WAKE_VOICE)) --out $(SAY_OUT) $(if $(WAKE),--wake "$(WAKE)" --wake-voice $(WAKE_VOICE)) --out $(SAY_OUT)
latency: ## Mesure la latence du serveur avant/après l'étape 16 sur les COUNT derniers enregistrements
$(PY) tools/latency_check.py --last $(COUNT)
speech-check: ## Synthèse, transcription et RÉPONSE de l'assistant à TEXT, sans ESP32 (étape 6) speech-check: ## Synthèse, transcription et RÉPONSE de l'assistant à TEXT, sans ESP32 (étape 6)
$(PY) tools/speech_check.py "$(TEXT)" $(PY) tools/speech_check.py "$(TEXT)"
+190 -16
View File
@@ -103,33 +103,180 @@ No Base64: MQTT payloads carry raw PCM bytes.
## 4. Installation ## 4. Installation
```bash ### 4.1 ESP32 firmware
# 1. System packages (mosquitto, ffmpeg, venv) + serial port access
make install-system
# Log out and back in to activate the "dialout" group.
# 2. Python environment and dev tooling (pyright, pytest, platformio) **Prerequisites:** a USB cable with data lines (not charge-only), port identified
(`ls /dev/ttyUSB*` or `/dev/ttyACM*` after plugging in).
```bash
make pio-venv # PlatformIO in its own venv — once only
make fw-build # compile without flashing
make fw-upload SERIAL_PORT=/dev/ttyUSB0
```
**First boot — WiFi configuration portal**
If no credentials have been configured yet, the board automatically opens an
access point:
1. The LED blinks rapidly (100 ms on / 100 ms off).
2. Connect a phone or PC to the **VoiceAssist-Config** Wi-Fi network.
3. Open `http://192.168.4.1` in a browser.
4. Enter the Wi-Fi network **(2.4 GHz required)** and the MQTT broker IP address.
5. Click **Save and restart** — credentials are saved to persistent memory (NVS)
and the board reboots.
> **Reconfiguration:** hold the PTT button for 3 s at power-on to reopen the
> portal, even if credentials are already stored.
**Alternative — `secrets.h`** (for repeated development builds)
```bash
cp firmware/include/secrets.h.example firmware/include/secrets.h
# Edit: WIFI_SSID, WIFI_PASSWORD, MQTT_HOST
make fw-upload SERIAL_PORT=/dev/ttyUSB0
```
NVS takes priority over `secrets.h`. To start from scratch, force the portal
with the PTT button (or erase the NVS with `esptool.py erase_flash`).
**Raspberry Pi 5 Wi-Fi access point: WPA2 required**
If the board connects to the Pi's access point (NetworkManager, here `RPI5-AP`),
it must use **WPA2**. By default the ESP32 refuses any network below WPA2, and
an access point created by NetworkManager may be WPA1. Recommended fix, on the Pi:
```bash
nmcli -t -f NAME,TYPE con show --active # name of the access point connection
sudo nmcli con modify "RPI5-AP" wifi-sec.key-mgmt wpa-psk wifi-sec.proto rsn \
wifi-sec.pairwise ccmp wifi-sec.group ccmp \
802-11-wireless.band bg 802-11-wireless.channel 6
sudo nmcli con down "RPI5-AP" && sudo nmcli con up "RPI5-AP"
```
`proto rsn` enforces WPA2; channel 6 avoids channels 12 and 13, which the ESP32
handles poorly. The Wi-Fi password does not change. On this network the Pi is
`10.42.0.1`: that is the broker address to enter in the portal.
Diagnosis on the serial monitor (`make fw-monitor`, or minicom at **921600 baud**):
| Board message | Cause | Fix |
|---|---|---|
| `Reason: 211` | access point uses WPA1 | switch it to WPA2 (above) |
| `Reason: 15 - 4WAY_HANDSHAKE_TIMEOUT` | wrong Wi-Fi password stored in the board | re-enter it in the portal; the Pi confirms it: `sudo journalctl -u wpa_supplicant \| grep PSK-MISMATCH` |
| `# mqtt : echec (code 5)` | MQTT account missing on the broker, or different password | `make mqtt-user NAME=esp32-01` on the Pi |
| unreadable bytes when the button is pressed | MQTT not connected: the session goes over the serial cable | check the `# wifi` and `# mqtt` lines at boot |
> In the portal, both password fields (Wi-Fi and MQTT) are always shown empty:
> retype them on every save, a field left empty erases the stored password.
---
### 4.2 Python server
**Prerequisites:** Python 3.10+, Docker (for the broker) or native Mosquitto.
```bash
# 1. System packages (mosquitto, ffmpeg, venv) + serial port access (dialout group)
make install-system
# → log out and back in to activate the "dialout" group
# 2. MQTT broker (choose one)
make mosquitto-docker # Docker container (recommended)
make mosquitto-config # native Debian Mosquitto service (installed via make install-system)
# 3. Python environment + dev tooling (pytest, pyright, PlatformIO)
make install-dev make install-dev
# PlatformIO gets its own venv (.pio-venv): its dependencies clash with the # PlatformIO gets its own venv (.pio-venv): its dependencies clash with the
# voice server's. # voice server's.
# 3. Speech recognition and synthesis (step 6): faster-whisper, Piper, ~550 MB of models # 4. Speech recognition + synthesis (~550 MB)
make install-speech make install-speech
make models make models
# 4. Diarization (step 8): pyannote + CPU torch (~1.3 GB), Hugging Face token required # 5. Diarization + speaker identification (~1.3 GB) — optional, heavy on RPi 4
# Prerequisite: Hugging Face "Read" token in server/.env (HF_TOKEN=hf_…)
# Accept the conditions at https://huggingface.co/pyannote/speaker-diarization-community-1
cp server/.env.example server/.env # then HF_TOKEN=hf_…
make install-diarization make install-diarization
cp server/.env.example server/.env # then HF_TOKEN=... (see step 8)
make models make models
# 5. Secrets (used from step 3 onwards) # 6. Wake word openWakeWord (~5 MB) — optional
cp firmware/include/secrets.h.example firmware/include/secrets.h make install-wakeword
cp server/.env.example server/.env make models
# 6. Help # 7. Run the assistant
make help make run
``` ```
**Files to configure:**
| File | Contents |
|---|---|
| `server/.env` | `MQTT_USERNAME`, `MQTT_PASSWORD`, `HF_TOKEN` |
| `server/config.yaml` | rooms, voices, access rights, machine list |
For help on all targets: `make help`.
---
### 4.3 Linux agent (machines controlled by voice)
Each Linux machine to control (desktop PC, NAS…) runs a lightweight service that
executes **only** the actions listed in its own configuration file — no shell
exposed.
**On the target machine:**
```bash
# 1. Copy the agent/ directory from the Raspberry Pi
scp -r /home/rpi5/voice-assistant/agent/ user@desktop-pc:~/voice-agent-install/
# 2. Install the service (creates /opt/voice-agent/, /etc/voice-assistant/, systemd unit)
cd ~/voice-agent-install
sudo make install
# 3. Create the MQTT account on the server (if the broker is secured, step 15)
# (run on the Raspberry Pi, not on the target machine)
make mqtt-user NAME=pc-bureau # note the password
make mosquitto-docker # or mosquitto-config — reloads the broker
# 4. Configure the agent on the target machine
sudo nano /etc/voice-assistant/agent.yaml
# id: pc-bureau ← name used in MQTT topics
# mqtt.host: 192.168.1.200 ← broker IP address
# mqtt.username: pc-bureau ← same value as id (empty if anonymous broker)
# mqtt.password: <password> ← generated in step 3 (empty if anonymous broker)
# dry_run: false ← true to test without executing anything
# 4. Enable and start
sudo systemctl enable --now voice-agent
sudo systemctl status voice-agent
```
**On the server,** add the machine to `server/config.yaml`:
```yaml
machines:
list:
- id: pc-bureau
name: "PC du bureau" # spoken as: "le PC du bureau"
mac: "AA:BB:CC:DD:EE:FF" # MAC address for Wake-on-LAN
ip: 192.168.1.50
```
**Try it without shutting anything down** (`dry_run: true` in `agent.yaml`):
```bash
make run # server (terminal 1)
make agent-run # agent in test mode on this machine (terminal 2)
# Say: "Hey Mycroft, éteins le PC du bureau"
# → the agent prints: essai : systemctl poweroff (non exécuté)
```
Default available actions: `shutdown`, `reboot`, `lock`; `wake`
(power on over the network) is handled by the server via Wake-on-LAN.
--- ---
## 5. Usage ## 5. Usage
@@ -145,6 +292,7 @@ make tone-esp TONE=440 # pure tone on the ESP32 speaker (step 2
make run # assistant: answers every question (step 6) make run # assistant: answers every question (step 6)
make speech-check TEXT="Quelle heure est-il ?" # recognition and synthesis, no ESP32 make speech-check TEXT="Quelle heure est-il ?" # recognition and synthesis, no ESP32
make say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 for a phone make say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 for a phone
make latency # server latency before/after step 16, on real recordings
make transcribe OUT=server/recordings/<file>.wav make transcribe OUT=server/recordings/<file>.wav
make vad OUT=server/recordings/<file>.wav # speech found by the VAD (step 7) make vad OUT=server/recordings/<file>.wav # speech found by the VAD (step 7)
make vad-all # VAD over every server recording make vad-all # VAD over every server recording
@@ -167,13 +315,39 @@ make lint # pyright type check
The exact state of the project, step by step, lives in **[CONTEXT.md](CONTEXT.md)**. The exact state of the project, step by step, lives in **[CONTEXT.md](CONTEXT.md)**.
Current step: **15b — Linux machines on the network**. Steps 1 to 15 are validated. Current step: **16 — optimisation**. Steps 1 to 15b are validated.
### Step 16 — optimisation
Measure first, optimise next: `make latency` replays the latest recordings through the
old chain, then the new one. On 54 real commands (PC, 20 logical cores): **1.6 s → 1.1 s**
between the end of the sentence and the recognised text, same text 54 times out of 54.
| Optimisation | Measured gain | Setting |
|---|---|---|
| Short speech: direct voice embedding, no diarization | 0.40 s (0.42 → 0.02 s) | `diarization.direct_below_s: 4.0` |
| Short speech: Whisper without word timestamps | 0.10 s | automatic |
| Answers already spoken kept in memory (synthesis cache) | 0.03 to 0.14 s | automatic, 64 sentences |
| End of command checked every 100 ms (250 before) | 0.075 s on average | — |
| Immediate network sends (`TCP_NODELAY`: server, broker) | to be measured: `premier son a … ms` | `set_tcp_nodelay true` in `voice.conf` |
Measured, then **rejected**: diarization and Whisper in parallel (0.03 to 0.10 s only: both
computations saturate the processor, even in two processes), `beam_size: 1` (0.05 s, and one
different text out of 16), 16 threads for Whisper (slower than 4).
1. On the Raspberry Pi (broker): `git pull`, then `make mosquitto-config`.
2. `make run`, a few commands: the log shows `1 locuteur (parole courte, empreinte directe
20 ms)` and `reponse prete en ~1.1 s` (1.5 s before).
3. `make latency`: before, after, gain, and the identifications that differ.
Speech shorter than 4 s is assumed to come from **a single person**. Two voices in a short
sentence are no longer separated (they were at step 10); beyond 4 s nothing changes.
### Step 15b — Linux machines on the network ### Step 15b — Linux machines on the network
"Hey Mycroft, éteins le PC du bureau": each machine runs an **agent** "Hey Mycroft, éteins le PC du bureau": each machine runs an **agent**
([agent/voice_agent.py](agent/voice_agent.py)), a small MQTT client that only runs ([agent/voice_agent.py](agent/voice_agent.py)), a small MQTT client that only runs
the actions in **its own** list (`/etc/voice-agent/agent.yaml`): fixed commands, the actions in **its own** list (`/etc/voice-assistant/agent.yaml`): fixed commands,
no shell. The server only sends an action name. "Allume" wakes a machine that is no shell. The server only sends an action name. "Allume" wakes a machine that is
off through **Wake-on-LAN** (a network packet received by the sleeping network card). off through **Wake-on-LAN** (a network packet received by the sleeping network card).
@@ -188,7 +362,7 @@ off through **Wake-on-LAN** (a network packet received by the sleeping network c
2. `make run`, then "Hey Mycroft, éteins le PC du bureau" → confirmation → "oui": 2. `make run`, then "Hey Mycroft, éteins le PC du bureau" → confirmation → "oui":
the agent prints `essai : systemctl poweroff (non execute)`. the agent prints `essai : systemctl poweroff (non execute)`.
3. Real machine: copy `agent/` to it, `make -C agent install`, edit 3. Real machine: copy `agent/` to it, `make -C agent install`, edit
`/etc/voice-agent/agent.yaml`, and name it in `server/config.yaml` (`machines.list`). `/etc/voice-assistant/agent.yaml`, and name it in `server/config.yaml` (`machines.list`).
**Anonymous broker**: any device on the network can then publish on `agent/<machine>/command`. **Anonymous broker**: any device on the network can then publish on `agent/<machine>/command`.
In production, use the accounts and rights of step 15 (`mosquitto/acl` has the agent rules). In production, use the accounts and rights of step 15 (`mosquitto/acl` has the agent rules).
+194 -16
View File
@@ -102,33 +102,184 @@ Pas de Base64 : les payloads MQTT contiennent des octets PCM bruts.
## 4. Installation ## 4. Installation
### 4.1 Firmware ESP32
**Prérequis :** câble USB avec les données (pas charge seule), port identifié
(`ls /dev/ttyUSB*` ou `/dev/ttyACM*` après branchement).
```bash ```bash
# 1. Paquets système (mosquitto, ffmpeg, venv) + accès au port série make pio-venv # PlatformIO dans son propre venv — une seule fois
make fw-build # compile sans flasher
make fw-upload SERIAL_PORT=/dev/ttyUSB0
```
**Premier démarrage — portail de configuration WiFi**
Si aucun identifiant n'a encore été configuré, la carte ouvre automatiquement
un point d'accès :
1. La LED clignote rapidement (100 ms on / 100 ms off).
2. Connectez un téléphone ou un PC au réseau **VoiceAssist-Config**.
3. Ouvrez `http://192.168.4.1` dans un navigateur.
4. Renseignez le Wi-Fi **(réseau 2,4 GHz obligatoire)** et l'adresse IP du
broker MQTT.
5. Cliquez **Enregistrer et redémarrer** — les identifiants sont sauvegardés
dans la mémoire permanente (NVS) et la carte redémarre.
> **Reconfiguration :** tenez le bouton PTT appuyé 3 s à la mise sous tension
> pour rouvrir le portail, même si des identifiants sont déjà enregistrés.
**Alternative — `secrets.h`** (pour les compilations répétées en développement)
```bash
cp firmware/include/secrets.h.example firmware/include/secrets.h
# Éditer : WIFI_SSID, WIFI_PASSWORD, MQTT_HOST
make fw-upload SERIAL_PORT=/dev/ttyUSB0
```
La NVS reste prioritaire sur `secrets.h`. Pour repartir de zéro, forcez le
portail avec le bouton PTT (ou effacez la NVS avec `esptool.py erase_flash`).
**Point d'accès Wi-Fi du Raspberry Pi 5 : WPA2 obligatoire**
Si la carte se connecte au point d'accès du Pi (NetworkManager, ici `RPI5-AP`),
celui-ci doit être en **WPA2**. L'ESP32 refuse par défaut tout réseau en dessous
de WPA2, et un point d'accès créé par NetworkManager peut être en WPA1.
Correction recommandée, sur le Pi :
```bash
nmcli -t -f NAME,TYPE con show --active # nom de la connexion du point d'accès
sudo nmcli con modify "RPI5-AP" wifi-sec.key-mgmt wpa-psk wifi-sec.proto rsn \
wifi-sec.pairwise ccmp wifi-sec.group ccmp \
802-11-wireless.band bg 802-11-wireless.channel 6
sudo nmcli con down "RPI5-AP" && sudo nmcli con up "RPI5-AP"
```
`proto rsn` impose WPA2 ; le canal 6 évite les canaux 12 et 13, mal gérés par
l'ESP32. Le mot de passe du Wi-Fi ne change pas. Sur ce réseau, le Pi a
l'adresse `10.42.0.1` : c'est l'adresse du broker à saisir dans le portail.
Diagnostic au moniteur série (`make fw-monitor`, ou minicom à **921600 bauds**) :
| Message de la carte | Cause | Correctif |
|---|---|---|
| `Reason: 211` | point d'accès en WPA1 | le passer en WPA2 (ci-dessus) |
| `Reason: 15 - 4WAY_HANDSHAKE_TIMEOUT` | mot de passe Wi-Fi enregistré dans la carte faux | le ressaisir dans le portail ; le Pi le confirme : `sudo journalctl -u wpa_supplicant \| grep PSK-MISMATCH` |
| `# mqtt : echec (code 5)` | compte MQTT absent du broker, ou mot de passe différent | `make mqtt-user NAME=esp32-01` sur le Pi |
| des octets illisibles à l'appui du bouton | MQTT non connecté : la session part par le câble série | voir les lignes `# wifi` et `# mqtt` au démarrage |
> Dans le portail, les deux champs de mot de passe (Wi-Fi et MQTT) s'affichent
> toujours vides : retapez-les à chaque enregistrement, un champ laissé vide
> efface le mot de passe enregistré.
---
### 4.2 Serveur Python
**Prérequis :** Python 3.10+, Docker (pour le broker) ou Mosquitto natif.
```bash
# 1. Paquets système (mosquitto, ffmpeg, venv) + groupe dialout (port série)
make install-system make install-system
# Se déconnecter/reconnecter pour activer le groupe « dialout ». # → se déconnecter/reconnecter pour activer le groupe « dialout »
# 2. Environnement Python et outils de développement (pyright, pytest, platformio) # 2. Broker MQTT (choisir l'une ou l'autre)
make mosquitto-docker # conteneur Docker (recommandé)
make mosquitto-config # service Mosquitto Debian natif (déjà installé via make install-system)
# 3. Environnement Python + outils de développement (pytest, pyright, PlatformIO)
make install-dev make install-dev
# PlatformIO est installé dans son propre venv (.pio-venv) : ses dépendances # PlatformIO est dans son propre venv (.pio-venv) : ses dépendances entrent
# entrent en conflit avec celles du serveur vocal. # en conflit avec celles du serveur vocal.
# 3. Transcription et synthèse vocale (étape 6) : faster-whisper, Piper, ~550 Mo de modèles # 4. Transcription + synthèse vocale (~550 Mo)
make install-speech make install-speech
make models make models
# 4. Diarisation (étape 8) : pyannote + torch CPU (~1,3 Go), jeton Hugging Face requis # 5. Diarisation + identification vocale (~1,3 Go) — optionnel, lourd sur RPi 4
# Prérequis : compte Hugging Face + accepter les conditions sur
# https://huggingface.co/pyannote/speaker-diarization-community-1
# Créer un jeton « Read » sur https://huggingface.co/settings/tokens
cp server/.env.example server/.env # puis HF_TOKEN=hf_…
make install-diarization make install-diarization
cp server/.env.example server/.env # puis HF_TOKEN=... (voir étape 8)
make models make models
# 5. Secrets (utilisés à partir de l'étape 3) # 6. Mot de réveil openWakeWord (~5 Mo) — optionnel
cp firmware/include/secrets.h.example firmware/include/secrets.h make install-wakeword
cp server/.env.example server/.env make models
# 6. Aide # 7. Lancer l'assistant
make help make run
``` ```
**Fichiers à configurer :**
| Fichier | Contenu |
|---|---|
| `server/.env` | `MQTT_USERNAME`, `MQTT_PASSWORD`, `HF_TOKEN` |
| `server/config.yaml` | pièces, voix, droits d'accès, liste des machines |
Pour l'aide sur toutes les cibles : `make help`.
---
### 4.3 Agent Linux (machines commandées par la voix)
Chaque machine Linux à commander (PC du bureau, NAS…) fait tourner un service
léger qui n'exécute **que** les actions listées dans son fichier de
configuration — aucun shell exposé.
**Sur la machine cible :**
```bash
# 1. Copier le répertoire agent/ (depuis le Raspberry Pi)
scp -r /home/rpi5/voice-assistant/agent/ user@pc-bureau:~/voice-agent/
# 2. Installer le service (crée /opt/voice-agent/, /etc/voice-assistant/, unité systemd)
cd ~/voice-agent
sudo make install
# 3. Créer le compte MQTT sur le serveur (si le broker est sécurisé, étape 15)
# (à exécuter sur le Raspberry Pi, pas sur la machine cible)
make mqtt-user NAME=pc-bureau # noter le mot de passe
make mosquitto-docker # ou mosquitto-config — recharge le broker
# 4. Configurer l'agent sur la machine cible
sudo nano /etc/voice-assistant/agent.yaml
# id: pc-bureau ← nom dans les topics MQTT
# mqtt.host: 192.168.1.200 ← adresse IP du broker
# mqtt.username: pc-bureau ← même valeur que id (vide si broker anonyme)
# mqtt.password: <mot-de-passe> ← généré à l'étape 3 (vide si broker anonyme)
# dry_run: false ← true pour tester sans rien exécuter
# 4. Activer et démarrer
sudo systemctl enable --now voice-agent
sudo systemctl status voice-agent
```
**Sur le serveur,** ajouter la machine dans `server/config.yaml` :
```yaml
machines:
list:
- id: pc-bureau
name: "PC du bureau" # dit à voix haute : « le PC du bureau »
mac: "AA:BB:CC:DD:EE:FF" # adresse MAC pour Wake-on-LAN
ip: 192.168.1.50
```
**Essai sans rien éteindre** (`dry_run: true` dans `agent.yaml`) :
```bash
make run # serveur (terminal 1)
make agent-run # agent en mode essai sur cette machine (terminal 2)
# Dire : « Hey Mycroft, éteins le PC du bureau »
# → l'agent affiche : essai : systemctl poweroff (non exécuté)
```
Actions disponibles par défaut : `shutdown`, `reboot`, `lock` ; `wake`
(allumer depuis le réseau) est géré par le serveur via Wake-on-LAN.
--- ---
## 5. Utilisation ## 5. Utilisation
@@ -144,6 +295,7 @@ make tone-esp TONE=440 # son pur sur le haut-parleur de l'ESP32
make run # assistant : répond à chaque question (étape 6) make run # assistant : répond à chaque question (étape 6)
make speech-check TEXT="Quelle heure est-il ?" # transcription et synthèse, sans ESP32 make speech-check TEXT="Quelle heure est-il ?" # transcription et synthèse, sans ESP32
make say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 pour un téléphone make say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 pour un téléphone
make latency # latence du serveur avant/après l'étape 16, sur de vrais enregistrements
make transcribe OUT=server/recordings/<fichier>.wav make transcribe OUT=server/recordings/<fichier>.wav
make vad OUT=server/recordings/<fichier>.wav # parole trouvée par la VAD (étape 7) make vad OUT=server/recordings/<fichier>.wav # parole trouvée par la VAD (étape 7)
make vad-all # VAD sur tous les enregistrements du serveur make vad-all # VAD sur tous les enregistrements du serveur
@@ -166,13 +318,39 @@ make lint # typage pyright
L'état exact du projet, étape par étape, est dans **[CONTEXT.md](CONTEXT.md)**. L'état exact du projet, étape par étape, est dans **[CONTEXT.md](CONTEXT.md)**.
Étape courante : **15b — machines Linux du réseau**. Les étapes 1 à 15 sont validées. Étape courante : **16 — optimisation**. Les étapes 1 à 15b sont validées.
### Étape 16 — optimisation
Mesurer d'abord, optimiser ensuite : `make latency` rejoue les derniers enregistrements
avec l'ancienne puis la nouvelle chaîne. Sur 54 commandes réelles (PC, 20 cœurs logiques) :
**1,6 s → 1,1 s** entre la fin de la phrase et le texte reconnu, même texte 54 fois sur 54.
| Optimisation | Gain mesuré | Réglage |
|---|---|---|
| Parole courte : empreinte vocale directe, sans diarisation | 0,40 s (0,42 → 0,02 s) | `diarization.direct_below_s: 4.0` |
| Parole courte : Whisper sans horodatage des mots | 0,10 s | automatique |
| Réponses déjà dites gardées en mémoire (cache de synthèse) | 0,03 à 0,14 s | automatique, 64 phrases |
| Fin de commande cherchée toutes les 100 ms (250 avant) | 0,075 s en moyenne | — |
| Envois réseau immédiats (`TCP_NODELAY` : serveur, broker) | à mesurer : `premier son a … ms` | `set_tcp_nodelay true` dans `voice.conf` |
Pistes mesurées puis **écartées** : diarisation et Whisper en parallèle (0,03 à 0,10 s seulement :
les deux calculs saturent le processeur, même dans deux processus), `beam_size: 1` (0,05 s, et
un texte différent sur 16), 16 fils pour Whisper (plus lent que 4).
1. Sur le Raspberry Pi (broker) : `git pull`, puis `make mosquitto-config`.
2. `make run`, quelques commandes : le journal affiche `1 locuteur (parole courte, empreinte
directe 20 ms)` et `reponse prete en ~1,1 s` (1,5 s avant).
3. `make latency` : avant, après, gain, et les identifications qui diffèrent.
Une parole de moins de 4 s est supposée venir d'**une seule personne**. Deux voix dans une
phrase courte ne sont plus séparées (elles l'étaient à l'étape 10) ; au-delà de 4 s, rien ne change.
### Étape 15b — machines Linux du réseau ### Étape 15b — machines Linux du réseau
« Hey Mycroft, éteins le PC du bureau » : chaque machine fait tourner un **agent** « Hey Mycroft, éteins le PC du bureau » : chaque machine fait tourner un **agent**
([agent/voice_agent.py](agent/voice_agent.py)), petit client MQTT qui n'exécute que ([agent/voice_agent.py](agent/voice_agent.py)), petit client MQTT qui n'exécute que
les actions de **sa** liste (`/etc/voice-agent/agent.yaml`), commandes fixes, sans les actions de **sa** liste (`/etc/voice-assistant/agent.yaml`), commandes fixes, sans
shell. Le serveur n'envoie qu'un nom d'action. « Allume » réveille une machine shell. Le serveur n'envoie qu'un nom d'action. « Allume » réveille une machine
éteinte par **Wake-on-LAN** (paquet réseau reçu par la carte réseau en veille). éteinte par **Wake-on-LAN** (paquet réseau reçu par la carte réseau en veille).
@@ -187,7 +365,7 @@ shell. Le serveur n'envoie qu'un nom d'action. « Allume » réveille une machin
2. `make run`, puis « Hey Mycroft, éteins le PC du bureau » → confirmation → « oui » : 2. `make run`, puis « Hey Mycroft, éteins le PC du bureau » → confirmation → « oui » :
l'agent affiche `essai : systemctl poweroff (non execute)`. l'agent affiche `essai : systemctl poweroff (non execute)`.
3. Machine réelle : copier `agent/` dessus, `make -C agent install`, régler 3. Machine réelle : copier `agent/` dessus, `make -C agent install`, régler
`/etc/voice-agent/agent.yaml`, et la nommer dans `server/config.yaml` (`machines.list`). `/etc/voice-assistant/agent.yaml`, et la nommer dans `server/config.yaml` (`machines.list`).
**Broker anonyme** : tout appareil du réseau peut alors publier sur `agent/<machine>/command`. **Broker anonyme** : tout appareil du réseau peut alors publier sur `agent/<machine>/command`.
En production, les comptes et droits de l'étape 15 (`mosquitto/acl` contient les règles des agents). En production, les comptes et droits de l'étape 15 (`mosquitto/acl` contient les règles des agents).
+2 -2
View File
@@ -4,7 +4,7 @@
SHELL := /bin/bash SHELL := /bin/bash
PREFIX := /opt/voice-agent PREFIX := /opt/voice-agent
CONFIG_DIR := /etc/voice-agent CONFIG_DIR := /etc/voice-assistant
# Mêmes versions que le serveur (server/pyproject.toml). # Mêmes versions que le serveur (server/pyproject.toml).
DEPS := paho-mqtt==2.1.0 PyYAML==6.0.2 DEPS := paho-mqtt==2.1.0 PyYAML==6.0.2
@@ -40,4 +40,4 @@ uninstall: ## Retire le service et le programme (garde la configuration)
sudo rm -rf $(PREFIX) sudo rm -rf $(PREFIX)
log: ## Journal de l'agent installé (Ctrl-C pour quitter) log: ## Journal de l'agent installé (Ctrl-C pour quitter)
journalctl -u voice-agent -f sudo journalctl -u voice-agent -f
+2 -2
View File
@@ -1,6 +1,6 @@
# Agent de l'assistant vocal (étape 15b) : UNE machine Linux commandée par la voix. # Agent de l'assistant vocal (étape 15b) : UNE machine Linux commandée par la voix.
# #
# Installé par « make agent-install » dans /etc/voice-agent/agent.yaml (lisible par root # Installé par « make agent-install » dans /etc/voice-assistant/agent.yaml (lisible par root
# seulement : il peut contenir un mot de passe MQTT). Après modification : # seulement : il peut contenir un mot de passe MQTT). Après modification :
# sudo systemctl restart voice-agent # sudo systemctl restart voice-agent
# #
@@ -12,7 +12,7 @@
id: pc-bureau id: pc-bureau
mqtt: mqtt:
host: 192.168.1.104 # adresse IP du broker host: 192.168.1.200 # adresse IP du broker (Raspberry Pi 5)
port: 1883 port: 1883
username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme
password: "" password: ""
+1 -1
View File
@@ -5,7 +5,7 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
ExecStart=/opt/voice-agent/.venv/bin/python /opt/voice-agent/voice_agent.py --config /etc/voice-agent/agent.yaml ExecStart=/opt/voice-agent/.venv/bin/python /opt/voice-agent/voice_agent.py --config /etc/voice-assistant/agent.yaml
# Relancé s'il s'arrête (broker absent au démarrage, plantage...) : l'agent doit toujours répondre. # Relancé s'il s'arrête (broker absent au démarrage, plantage...) : l'agent doit toujours répondre.
Restart=always Restart=always
RestartSec=5 RestartSec=5
+3 -3
View File
@@ -3,7 +3,7 @@
L'agent se connecte au broker MQTT et attend des NOMS d'action sur L'agent se connecte au broker MQTT et attend des NOMS d'action sur
agent/<id>/command. Il n'exécute que les actions de SON fichier de configuration agent/<id>/command. Il n'exécute que les actions de SON fichier de configuration
(/etc/voice-agent/agent.yaml), chacune étant une commande fixe, sans shell et (/etc/voice-assistant/agent.yaml), chacune étant une commande fixe, sans shell et
sans argument venu du réseau : un message forgé ne peut rien lancer d'autre. sans argument venu du réseau : un message forgé ne peut rien lancer d'autre.
agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline » agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline »
@@ -16,7 +16,7 @@ jamais exécutée à son retour.
Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur. Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur.
Exemples : Exemples :
python3 voice_agent.py --config /etc/voice-agent/agent.yaml python3 voice_agent.py --config /etc/voice-assistant/agent.yaml
make agent-run # sur le PC de développement, en mode essai (dry_run) make agent-run # sur le PC de développement, en mode essai (dry_run)
""" """
@@ -252,7 +252,7 @@ class VoiceAgent:
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:
"""Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service).""" """Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service)."""
parser = argparse.ArgumentParser(description="agent de l'assistant vocal pour une machine Linux") parser = argparse.ArgumentParser(description="agent de l'assistant vocal pour une machine Linux")
parser.add_argument("--config", type=Path, default=Path("/etc/voice-agent/agent.yaml")) parser.add_argument("--config", type=Path, default=Path("/etc/voice-assistant/agent.yaml"))
args = parser.parse_args(argv) args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
try: try:
+13
View File
@@ -176,6 +176,19 @@ static constexpr uint32_t CAPTURE_TASK_STACK_BYTES = 4096;
/// Débit série : 921 600 baud = ~92 kB/s, largement au-dessus des 32 kB/s d'audio. /// Débit série : 921 600 baud = ~92 kB/s, largement au-dessus des 32 kB/s d'audio.
static constexpr uint32_t SERIAL_BAUD = 921600; static constexpr uint32_t SERIAL_BAUD = 921600;
// -------------------------------------------------------- Configuration Wi-Fi (portail AP)
/**
* SSID du point d'accès ouvert en mode configuration (premier démarrage ou
* bouton PTT tenu pendant WIFI_CONFIG_HOLD_MS au démarrage). La carte sert
* un formulaire HTTP sur http://192.168.4.1 tant que l'utilisateur n'a pas
* validé les identifiants Wi-Fi + MQTT.
*/
static constexpr const char* WIFI_CONFIG_AP_SSID = "VoiceAssist-Config";
/// Durée de maintien du bouton PTT au démarrage pour forcer le portail (ms).
static constexpr uint32_t WIFI_CONFIG_HOLD_MS = 3000;
// ------------------------------------------------------------ Wi-Fi et MQTT (étape 3) // ------------------------------------------------------------ Wi-Fi et MQTT (étape 3)
/// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/... /// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/...
+1 -1
View File
@@ -22,7 +22,7 @@ static constexpr const char* WIFI_PASSWORD = "mon-mot-de-passe";
/// Adresse IP du broker Mosquitto (Raspberry Pi ou PC). Une adresse IP plutôt /// Adresse IP du broker Mosquitto (Raspberry Pi ou PC). Une adresse IP plutôt
/// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire. /// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire.
/// Sur Linux : `ip -4 addr` sur la machine du broker. /// Sur Linux : `ip -4 addr` sur la machine du broker.
static constexpr const char* MQTT_HOST = "192.168.1.104"; static constexpr const char* MQTT_HOST = "192.168.1.200";
static constexpr uint16_t MQTT_PORT = 1883; static constexpr uint16_t MQTT_PORT = 1883;
/// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ». /// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ».
+4
View File
@@ -83,6 +83,10 @@ public:
/// Nombre de connexions réussies depuis le démarrage. /// Nombre de connexions réussies depuis le démarrage.
uint32_t connections() const; uint32_t connections() const;
/// Met à jour le port avant le premier appel à begin() (utile si la
/// configuration vient de la NVS plutôt que d'une constante de secrets.h).
void setPort(uint16_t port) { _port = port; }
private: private:
PubSubClient _client; PubSubClient _client;
const char* _host; const char* _host;
+248
View File
@@ -0,0 +1,248 @@
#include "WifiConfig.h"
#include "config.h"
#include <Preferences.h>
#include <WebServer.h>
#include <WiFi.h>
// ------------------------------------------------------------------ NVS keys
namespace {
constexpr const char* NVS_NS = "voice-cfg";
constexpr const char* KEY_SSID = "ssid";
constexpr const char* KEY_PASS = "pass";
constexpr const char* KEY_HOST = "mqttHost";
constexpr const char* KEY_PORT = "mqttPort";
constexpr const char* KEY_USER = "mqttUser";
constexpr const char* KEY_MPWD = "mqttPass";
// ------------------------------------------------------------------ HTML
// Encodage UTF-8 inline ; servi avec charset=utf-8.
const char HTML_FORM[] =
"<!DOCTYPE html><html lang='fr'><head>"
"<meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Assistant vocal &#8212; configuration</title>"
"<style>"
"body{font-family:sans-serif;max-width:420px;margin:32px auto;padding:0 16px;color:#222}"
"h2{color:#0078d4;margin-bottom:4px}"
"p.s{color:#666;font-size:.9em;margin:0 0 20px}"
"fieldset{border:1px solid #ccc;border-radius:6px;padding:12px;margin-bottom:16px}"
"legend{font-weight:bold;padding:0 6px}"
"label{display:block;margin-top:10px;font-size:.9em;color:#444}"
"input{width:100%;padding:7px 8px;box-sizing:border-box;border:1px solid #bbb;"
"border-radius:4px;margin-top:3px;font-size:1em}"
"button{width:100%;padding:12px;background:#0078d4;color:#fff;border:none;"
"border-radius:6px;font-size:1em;cursor:pointer}"
"button:hover{background:#005fa3}"
"</style></head><body>"
"<h2>Assistant vocal</h2>"
"<p class='s'>Renseignez le r&eacute;seau Wi-Fi et l&rsquo;adresse du broker MQTT,"
" puis cliquez sur <em>Enregistrer</em>.</p>"
"<form method='POST' action='/save'>"
"<fieldset><legend>Wi-Fi</legend>"
"<label>R&eacute;seau (SSID)</label>"
"<input type='text' name='ssid' value='%SSID%' maxlength='63' required>"
"<label>Mot de passe</label>"
"<input type='password' name='pass' placeholder='(vide si r&eacute;seau ouvert)' maxlength='63'>"
"</fieldset>"
"<fieldset><legend>Broker MQTT</legend>"
"<label>Adresse IP</label>"
"<input type='text' name='mqttHost' value='%HOST%' placeholder='192.168.1.x' maxlength='63' required>"
"<label>Port</label>"
"<input type='number' name='mqttPort' value='%PORT%' min='1' max='65535' required>"
"<label>Utilisateur (vide = connexion anonyme)</label>"
"<input type='text' name='mqttUser' value='%USER%' maxlength='31'>"
"<label>Mot de passe MQTT</label>"
"<input type='password' name='mqttPass' placeholder='(vide si anonyme)' maxlength='63'>"
"</fieldset>"
"<button type='submit'>Enregistrer et red&eacute;marrer</button>"
"</form></body></html>";
const char HTML_OK[] =
"<!DOCTYPE html><html lang='fr'><head><meta charset='utf-8'>"
"<title>Sauvegard&eacute;</title></head>"
"<body style='font-family:sans-serif;text-align:center;padding:60px;color:#222'>"
"<p style='font-size:3em'>&#10003;</p>"
"<h2 style='color:#107c10'>Configuration sauvegard&eacute;e</h2>"
"<p>L&rsquo;assistant red&eacute;marre dans quelques secondes&hellip;</p>"
"</body></html>";
} // namespace
WifiConfig* WifiConfig::_instance = nullptr;
WifiConfig::WifiConfig() {
_instance = this;
// Par défaut, l'utilisateur MQTT est DEVICE_ID : les règles ACL du broker
// (pattern voice/%u/...) l'exigent. Ce repli garantit que MqttLink ne
// stocke pas un pointeur vide avant que begin() soit appelé.
strncpy(_mqttUser, DEVICE_ID, sizeof(_mqttUser) - 1);
}
WifiConfig::~WifiConfig() {
delete _server;
}
// ------------------------------------------------------------------ NVS I/O
bool WifiConfig::loadFromNvs() {
Preferences prefs;
if (!prefs.begin(NVS_NS, true)) {
return false;
}
if (!prefs.isKey(KEY_SSID)) {
prefs.end();
return false;
}
prefs.getString(KEY_SSID, _ssid, sizeof(_ssid));
prefs.getString(KEY_PASS, _password, sizeof(_password));
prefs.getString(KEY_HOST, _mqttHost, sizeof(_mqttHost));
_mqttPort = prefs.getUShort(KEY_PORT, 1883);
prefs.getString(KEY_USER, _mqttUser, sizeof(_mqttUser));
prefs.getString(KEY_MPWD, _mqttPassword, sizeof(_mqttPassword));
prefs.end();
// Utilisateur vide → repli sur DEVICE_ID (requis par le broker ACL).
if (_mqttUser[0] == '\0') {
strncpy(_mqttUser, DEVICE_ID, sizeof(_mqttUser) - 1);
}
// SSID et hôte non vides : configuration valide.
return _ssid[0] != '\0' && _mqttHost[0] != '\0';
}
void WifiConfig::saveToNvs(const char* ssid, const char* password,
const char* mqttHost, uint16_t mqttPort,
const char* mqttUser, const char* mqttPassword) {
Preferences prefs;
if (!prefs.begin(NVS_NS, false)) {
Serial.println("# config : erreur d'ecriture NVS");
return;
}
prefs.putString(KEY_SSID, ssid);
prefs.putString(KEY_PASS, password);
prefs.putString(KEY_HOST, mqttHost);
prefs.putUShort(KEY_PORT, mqttPort);
prefs.putString(KEY_USER, mqttUser);
prefs.putString(KEY_MPWD, mqttPassword);
prefs.end();
Serial.println("# config : identifiants sauvegardes en NVS");
}
// ------------------------------------------------------------------ begin
void WifiConfig::begin(bool forcePortal,
const char* fbSsid, const char* fbPassword,
const char* fbMqttHost, uint16_t fbMqttPort,
const char* fbMqttUser, const char* fbMqttPass) {
if (!forcePortal && loadFromNvs()) {
Serial.printf("# config : Wi-Fi \"%s\", broker %s:%u (NVS)\n",
_ssid, _mqttHost, _mqttPort);
return;
}
// Repli depuis secrets.h : utilisé si les deux champs essentiels sont renseignés,
// sans être copié en NVS (l'utilisateur reste maître de la migration).
if (!forcePortal && fbSsid[0] != '\0' && fbMqttHost[0] != '\0') {
strncpy(_ssid, fbSsid, sizeof(_ssid) - 1);
strncpy(_password, fbPassword, sizeof(_password) - 1);
strncpy(_mqttHost, fbMqttHost, sizeof(_mqttHost) - 1);
_mqttPort = fbMqttPort;
const char* user = (fbMqttUser[0] != '\0') ? fbMqttUser : DEVICE_ID;
strncpy(_mqttUser, user, sizeof(_mqttUser) - 1);
strncpy(_mqttPassword, fbMqttPass, sizeof(_mqttPassword) - 1);
Serial.printf("# config : Wi-Fi \"%s\", broker %s:%u (secrets.h)\n",
_ssid, _mqttHost, _mqttPort);
return;
}
Serial.println(forcePortal
? "# config : portail force (bouton PTT tenu au demarrage)"
: "# config : aucun identifiant disponible, lancement du portail");
runPortal(); // ne retourne pas
}
// ------------------------------------------------------------------ Portail
void WifiConfig::onRoot() {
String page = HTML_FORM;
page.replace("%SSID%", _instance->_ssid);
page.replace("%HOST%", _instance->_mqttHost);
char portStr[8];
snprintf(portStr, sizeof(portStr), "%u", _instance->_mqttPort);
page.replace("%PORT%", portStr);
page.replace("%USER%", _instance->_mqttUser);
_instance->_server->send(200, "text/html; charset=utf-8", page);
}
void WifiConfig::onSave() {
WebServer* srv = _instance->_server;
const String ssid = srv->arg("ssid");
const String mqttHost = srv->arg("mqttHost");
if (ssid.isEmpty()) {
srv->send(400, "text/plain", "SSID manquant");
return;
}
if (mqttHost.isEmpty()) {
srv->send(400, "text/plain", "Adresse broker manquante");
return;
}
const String pass = srv->arg("pass");
const uint16_t port = static_cast<uint16_t>(srv->arg("mqttPort").toInt());
const String mqttUser = srv->arg("mqttUser");
const String mqttPass = srv->arg("mqttPass");
_instance->saveToNvs(
ssid.c_str(), pass.c_str(),
mqttHost.c_str(), port ? port : uint16_t(1883),
mqttUser.c_str(), mqttPass.c_str());
srv->send(200, "text/html; charset=utf-8", HTML_OK);
delay(1500);
ESP.restart();
}
void WifiConfig::onNotFound() {
_instance->_server->sendHeader("Location", "/", true);
_instance->_server->send(302, "text/plain", "");
}
void WifiConfig::runPortal() {
WiFi.disconnect(true);
WiFi.mode(WIFI_AP);
if (!WiFi.softAP(WIFI_CONFIG_AP_SSID)) {
Serial.println("# portail : echec softAP, redemarrage dans 5 s");
delay(5000);
ESP.restart();
}
Serial.printf(
"# portail : AP \"%s\" actif "
"-- connectez-vous au Wi-Fi puis allez sur http://192.168.4.1\n",
WIFI_CONFIG_AP_SSID);
// LED : clignotement rapide (100 ms) pendant toute la durée du portail.
// La broche est déjà configurée en OUTPUT par led.begin() avant begin().
pinMode(PIN_STATUS_LED, OUTPUT);
_server = new WebServer(80);
_server->on("/", HTTP_GET, onRoot);
_server->on("/save", HTTP_POST, onSave);
_server->onNotFound(onNotFound);
_server->begin();
Serial.println("# portail : en attente de configuration...");
uint32_t lastToggle = 0;
bool ledOn = false;
for (;;) {
_server->handleClient();
if (millis() - lastToggle >= 100) {
lastToggle = millis();
ledOn = !ledOn;
digitalWrite(PIN_STATUS_LED, ledOn ? HIGH : LOW);
}
}
}
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <Arduino.h>
/**
* @brief Configuration Wi-Fi et MQTT persistée en NVS (Preferences).
*
* Au premier démarrage (NVS vide, pas de secrets.h), ou si le bouton PTT est
* tenu pendant WIFI_CONFIG_HOLD_MS au démarrage, la carte ouvre un point
* d'accès Wi-Fi (SSID défini par WIFI_CONFIG_AP_SSID dans config.h) et sert
* un formulaire de configuration sur http://192.168.4.1. Après validation,
* les identifiants sont sauvegardés en NVS et la carte redémarre.
*
* En fonctionnement normal, les identifiants viennent de la NVS. Si la NVS
* est vide et que secrets.h fournit des valeurs non vides, elles sont utilisées
* comme repli sans être copiées en NVS.
*
* Les accesseurs retournent des pointeurs vers des tampons internes valides
* pour toute la durée de vie de l'objet : ils peuvent être passés directement
* aux constructeurs de WifiLink et MqttLink (trick pointeur — les tampons sont
* remplis par begin() avant que begin() de ces classes ne soit appelé).
*/
class WifiConfig {
public:
WifiConfig();
~WifiConfig();
/**
* @brief Charge les identifiants depuis la NVS ou le repli (secrets.h).
*
* Si forcePortal vaut true, ou si aucun identifiant valide n'est disponible,
* lance le portail AP + HTTP et ne revient jamais (l'ESP32 redémarre après
* sauvegarde).
*
* @param forcePortal true pour forcer le portail (bouton tenu au démarrage).
* @param fbSsid Repli (secrets.h) : SSID — chaîne vide si absent.
* @param fbPassword Repli : mot de passe Wi-Fi.
* @param fbMqttHost Repli : adresse IP du broker.
* @param fbMqttPort Repli : port du broker.
* @param fbMqttUser Repli : utilisateur MQTT.
* @param fbMqttPass Repli : mot de passe MQTT.
*/
void begin(bool forcePortal,
const char* fbSsid, const char* fbPassword,
const char* fbMqttHost, uint16_t fbMqttPort,
const char* fbMqttUser, const char* fbMqttPass);
const char* ssid() const { return _ssid; }
const char* password() const { return _password; }
const char* mqttHost() const { return _mqttHost; }
uint16_t mqttPort() const { return _mqttPort; }
const char* mqttUser() const { return _mqttUser; }
const char* mqttPassword() const { return _mqttPassword; }
private:
char _ssid[64] = {};
char _password[64] = {};
char _mqttHost[64] = {};
uint16_t _mqttPort = 1883;
char _mqttUser[32] = {};
char _mqttPassword[64] = {};
class WebServer* _server = nullptr;
bool loadFromNvs();
void saveToNvs(const char* ssid, const char* password,
const char* mqttHost, uint16_t mqttPort,
const char* mqttUser, const char* mqttPassword);
void runPortal();
static WifiConfig* _instance;
static void onRoot();
static void onSave();
static void onNotFound();
};
+40 -7
View File
@@ -34,15 +34,21 @@
#include "SerialFramer.h" #include "SerialFramer.h"
#include "StatusLed.h" #include "StatusLed.h"
#include "VoiceSession.h" #include "VoiceSession.h"
#include "WifiConfig.h"
#include "WifiLink.h" #include "WifiLink.h"
#include "config.h" #include "config.h"
// Les secrets (Wi-Fi, broker) ne sont jamais versionnés : on les exige ici, avec // secrets.h est optionnel : s'il est présent ses valeurs servent de repli si
// un message clair, plutôt qu'une erreur de compilation obscure plus loin. // la NVS est vide. Sans lui, le portail AP/HTTP est lancé au premier démarrage.
#if __has_include("secrets.h") #if __has_include("secrets.h")
#include "secrets.h" #include "secrets.h"
#else #else
#error "firmware/include/secrets.h absent : cp firmware/include/secrets.h.example firmware/include/secrets.h, puis renseignez le Wi-Fi et l'adresse du broker" static constexpr const char* WIFI_SSID = "";
static constexpr const char* WIFI_PASSWORD = "";
static constexpr const char* MQTT_HOST = "";
static constexpr uint16_t MQTT_PORT = 1883;
static constexpr const char* MQTT_USER = "";
static constexpr const char* MQTT_PASSWORD = "";
#endif #endif
namespace { namespace {
@@ -63,8 +69,11 @@ DemoDevices devices(PIN_DEMO_LIGHT);
SerialFramer framer(Serial); SerialFramer framer(Serial);
SerialFrameReader reader(Serial); SerialFrameReader reader(Serial);
WiFiClient network; WiFiClient network;
WifiLink wifi(WIFI_SSID, WIFI_PASSWORD, DEVICE_ID); // cfg DOIT être déclaré avant wifi et mqtt : leurs constructeurs stockent des
MqttLink mqtt(network, MQTT_HOST, MQTT_PORT, DEVICE_ID, MQTT_USER, MQTT_PASSWORD); // pointeurs vers ses tampons internes, remplis plus tard par cfg.begin().
WifiConfig cfg;
WifiLink wifi(cfg.ssid(), cfg.password(), DEVICE_ID);
MqttLink mqtt(network, cfg.mqttHost(), cfg.mqttPort(), DEVICE_ID, cfg.mqttUser(), cfg.mqttPassword());
/// Chunk en cours d'envoi réseau : 1600 octets, alloué une seule fois. /// Chunk en cours d'envoi réseau : 1600 octets, alloué une seule fois.
uint8_t outgoingChunk[AUDIO_CHUNK_BYTES]; uint8_t outgoingChunk[AUDIO_CHUNK_BYTES];
@@ -462,6 +471,29 @@ void setup() {
devices.begin(); devices.begin();
button.begin(); button.begin();
// Détection du mode configuration : bouton PTT tenu dès le démarrage.
// On attend WIFI_CONFIG_HOLD_MS ms ; si relâché avant, démarrage normal.
bool forceConfig = false;
if (digitalRead(PIN_BUTTON_PTT) == LOW) {
Serial.println("# bouton PTT detecte au demarrage, attente pour confirmer...");
const uint32_t holdStart = millis();
while (digitalRead(PIN_BUTTON_PTT) == LOW) {
if (millis() - holdStart >= WIFI_CONFIG_HOLD_MS) {
forceConfig = true;
break;
}
delay(10);
}
}
// Charge les identifiants Wi-Fi + MQTT depuis la NVS ou secrets.h ;
// lance le portail AP/HTTP si aucun n'est disponible ou si forcé.
// Ne retourne pas en cas de portail (reboot après sauvegarde).
cfg.begin(forceConfig,
WIFI_SSID, WIFI_PASSWORD,
MQTT_HOST, MQTT_PORT,
MQTT_USER, MQTT_PASSWORD);
Serial.println("\n# --- assistant vocal, etapes 1 a 3 : audio + Wi-Fi + MQTT ---"); Serial.println("\n# --- assistant vocal, etapes 1 a 3 : audio + Wi-Fi + MQTT ---");
Serial.printf("# format : %lu Hz, 16 bits, mono, chunk %lu ms = %u octets\n", Serial.printf("# format : %lu Hz, 16 bits, mono, chunk %lu ms = %u octets\n",
static_cast<unsigned long>(AUDIO_SAMPLE_RATE), static_cast<unsigned long>(AUDIO_SAMPLE_RATE),
@@ -511,9 +543,10 @@ void setup() {
// --- Réseau, en dernier : l'auto-test du micro se fait sans les émissions // --- Réseau, en dernier : l'auto-test du micro se fait sans les émissions
// radio du Wi-Fi (pointes de 300 mA), qui pourraient fausser la mesure. // radio du Wi-Fi (pointes de 300 mA), qui pourraient fausser la mesure.
Serial.printf("# reseau : Wi-Fi \"%s\", broker %s:%u, topics %s/%s/..., compte MQTT %s\n", Serial.printf("# reseau : Wi-Fi \"%s\", broker %s:%u, topics %s/%s/..., compte MQTT %s\n",
WIFI_SSID, MQTT_HOST, MQTT_PORT, MQTT_TOPIC_PREFIX, DEVICE_ID, cfg.ssid(), cfg.mqttHost(), cfg.mqttPort(), MQTT_TOPIC_PREFIX, DEVICE_ID,
MQTT_PASSWORD[0] != '\0' ? "oui" : "NON (anonyme)"); cfg.mqttPassword()[0] != '\0' ? "oui" : "NON (anonyme)");
network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS); network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS);
mqtt.setPort(cfg.mqttPort()); // synchronise _port avec la valeur chargée par cfg
if (!mqtt.begin(onMqttMessage, onMqttConnect)) { if (!mqtt.begin(onMqttMessage, onMqttConnect)) {
Serial.println("# ERREUR : tampon MQTT impossible a allouer"); Serial.println("# ERREUR : tampon MQTT impossible a allouer");
} }
+5
View File
@@ -27,6 +27,11 @@ allow_anonymous false
password_file /mosquitto/config/passwd password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl acl_file /mosquitto/config/acl
# Étape 16 : envoi immédiat des petits paquets (pas d'algorithme de Nagle). Un chunk audio
# de 1600 octets tient en deux paquets TCP ; sans cette option, le second attend l'accusé de
# réception du premier, que l'ESP32 retarde : la réponse démarre ~200 ms plus tard.
set_tcp_nodelay true
# Taille maximale d'un paquet MQTT complet (en-tête et topic compris). # Taille maximale d'un paquet MQTT complet (en-tête et topic compris).
# Un chunk audio fait 1600 octets : 8192 laisse une large marge. # Un chunk audio fait 1600 octets : 8192 laisse une large marge.
# (remplace « message_size_limit », obsolète depuis Mosquitto 2.0) # (remplace « message_size_limit », obsolète depuis Mosquitto 2.0)
+20 -5
View File
@@ -11,7 +11,7 @@ audio:
# étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/... # étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/...
mqtt: mqtt:
host: localhost # le broker tourne sur la même machine que le serveur host: 10.42.0.1 # broker du Raspberry Pi 5 (comptes exigés : server/.env) ; relu par le Makefile
port: 1883 port: 1883
keepalive: 30 keepalive: 30
client_id: voice-server # unique sur le broker client_id: voice-server # unique sur le broker
@@ -42,6 +42,9 @@ stt:
compute_type: int8 compute_type: int8
beam_size: 5 # 1 = plus rapide, 5 = plus précis beam_size: 5 # 1 = plus rapide, 5 = plus précis
no_speech_threshold: 0.6 # segment écarté au-delà : Whisper invente du texte sur le silence no_speech_threshold: 0.6 # segment écarté au-delà : Whisper invente du texte sur le silence
# étape 16 : fils de calcul de Whisper ; 0 = défaut de la bibliothèque (4). Mesuré sur un PC
# à 20 cœurs logiques : 4 → 1,08 s, 8 → 0,98 s, 16 → 1,29 s (pire). Sur un Pi 5 : laisser 0.
cpu_threads: 0
# étape 6 : synthèse vocale (modèle dans models/piper/<voice>.onnx, « make models ») # étape 6 : synthèse vocale (modèle dans models/piper/<voice>.onnx, « make models »)
# Piper produit du 22 050 Hz : le serveur rééchantillonne en audio.sample_rate. # Piper produit du 22 050 Hz : le serveur rééchantillonne en audio.sample_rate.
@@ -64,6 +67,11 @@ diarization:
enabled: true # false sur Raspberry Pi 4 (trop lourd) ou sans l'extra « diarization » enabled: true # false sur Raspberry Pi 4 (trop lourd) ou sans l'extra « diarization »
model: pyannote/speaker-diarization-community-1 model: pyannote/speaker-diarization-community-1
max_speakers: 3 # borne haute : un assistant de salon, pas une réunion max_speakers: 3 # borne haute : un assistant de salon, pas une réunion
# étape 16 : en dessous de cette durée de PAROLE, on suppose une seule personne (une
# commande) : son empreinte vocale est calculée directement, sans chercher qui parle quand.
# Mesuré sur 113 sessions réelles : 0,02 s au lieu de 0,42 s, même identification
# (écart de score médian 0,001). Au-delà, diarisation complète. 0 : toujours la diarisation.
direct_below_s: 4.0
# étape 9 : identification du locuteur (probabiliste, jamais une sécurité). # étape 9 : identification du locuteur (probabiliste, jamais une sécurité).
# Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique). # Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique).
@@ -119,9 +127,14 @@ access:
machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b
confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui » confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui »
confirm_timeout_s: 15 confirm_timeout_s: 15
# Un « oui » seul dure ~0,7 s : trop court pour atteindre speaker.similarity_threshold
# (mesuré : Denis 0,29 à 0,47 ; les autres profils face à Denis : 0,12 au plus).
# La confirmation est donc acceptée si le demandeur reste le profil le PLUS PROCHE,
# avec au moins ce score. Une voix reconnue comme quelqu'un d'autre est refusée.
confirm_min_score: 0.25
# étape 15b : machines Linux du réseau. Chacune fait tourner un agent (agent/voice_agent.py) # étape 15b : machines Linux du réseau. Chacune fait tourner un agent (agent/voice_agent.py)
# qui n'exécute QUE les actions de SA liste (/etc/voice-agent/agent.yaml) ; ici, on dit # qui n'exécute QUE les actions de SA liste (/etc/voice-assistant/agent.yaml) ; ici, on dit
# seulement comment on les appelle. « allume » = Wake-on-LAN, envoyé par le serveur # seulement comment on les appelle. « allume » = Wake-on-LAN, envoyé par le serveur
# (la machine éteinte n'a pas d'agent). Nouvelle action : l'ajouter à l'agent, ici # (la machine éteinte n'a pas d'agent). Nouvelle action : l'ajouter à l'agent, ici
# (mots et phrases), puis dans access.rules.machine (sinon : niveau complet exigé). # (mots et phrases), puis dans access.rules.machine (sinon : niveau complet exigé).
@@ -129,10 +142,12 @@ machines:
topic_prefix: agent # agent/<machine>/state (retenu), command, result topic_prefix: agent # agent/<machine>/state (retenu), command, result
broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local
actions: # mots sans accents ; « does » et « verb » : phrases dites actions: # mots sans accents ; « does » et « verb » : phrases dites
# Un mot par entrée, tel que Whisper l'écrit : « lock » devient souvent « Locke »,
# « shutdown » devient « Shut down » (d'où « shut »).
wake: {words: [allume, allumer, allumez, demarre, demarrer, reveille, reveiller], does: "J'allume", verb: allumer} wake: {words: [allume, allumer, allumez, demarre, demarrer, reveille, reveiller], does: "J'allume", verb: allumer}
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez], does: "J'éteins", verb: éteindre} shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez, shutdown, shut], does: "J'éteins", verb: éteindre}
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer} reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer, reboot, reboote, rebooter], does: "Je redémarre", verb: redémarrer}
lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller} lock: {words: [verrouille, verrouiller, verrouillez, lock, locke, locker, loque], does: "Je verrouille", verb: verrouiller}
list: # identifiant de l'agent → comment on l'appelle list: # identifiant de l'agent → comment on l'appelle
pc-bureau: pc-bureau:
# Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ». # Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ».
+23
View File
@@ -105,6 +105,29 @@ def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None:
assert resolution.answer.startswith("Seul Denis") assert resolution.answer.startswith("Seul Denis")
def test_short_yes_is_accepted_when_the_requester_stays_the_closest_voice(policy: AccessPolicy) -> None:
"""Essai réel du 2026-10-06 : « OUI » de Denis à 0,35, sous le seuil d'identification."""
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "OUI", None, closest="Denis", score=0.35)
assert resolution is not None and resolution.command == DOOR_OPEN
@pytest.mark.parametrize(
("speaker", "closest", "score"),
[
(None, "Denis", 0.12), # trop loin de Denis : n'importe qui
(None, "Jessica", 0.40), # plus proche de quelqu'un d'autre
(None, None, None), # pas d'empreinte du tout
("Jessica", "Jessica", 0.8), # reconnue comme quelqu'un d'autre
],
)
def test_short_yes_from_someone_else_is_refused(policy: AccessPolicy, speaker: str | None,
closest: str | None, score: float | None) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "oui", speaker, closest=closest, score=score)
assert resolution is not None and resolution.command is None
def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None: def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis") policy.ask("esp32-01", DOOR_OPEN, "Denis")
clock.now += 16 clock.now += 16
+4
View File
@@ -57,6 +57,10 @@ def with_mac(mac: str = MAC) -> MachinesSettings:
("Éteins le P.C. du bureau.", "shutdown"), ("Éteins le P.C. du bureau.", "shutdown"),
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"), ("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
("Verrouille le PC du bureau", "lock"), ("Verrouille le PC du bureau", "lock"),
# Mots anglais, tels que Whisper les a écrits à l'essai réel du 2026-10-06.
("Locke le PC du bureau", "lock"),
("Reboot le PC du bureau", "reboot"),
("Shut down le PC du bureau", "shutdown"),
("Allume le PC du bureau", "wake"), ("Allume le PC du bureau", "wake"),
("Le PC du bureau", None), ("Le PC du bureau", None),
], ],
+27
View File
@@ -129,3 +129,30 @@ def test_per_word_threshold_and_near_miss(caplog: pytest.LogCaptureFixture, tts:
assert all(detection is None for detection in found) assert all(detection is None for detection in found)
assert "presque reconnu « alexa »" in caplog.text assert "presque reconnu « alexa »" in caplog.text
def test_repeated_answer_comes_from_the_cache(tts: TextToSpeech) -> None:
"""Étape 16 : une réponse déjà dite n'est pas resynthétisée (Piper varie d'un essai à l'autre)."""
first = tts.synthesize("J'allume la lumière du salon.")
again = tts.synthesize("J'allume la lumière du salon.")
other = tts.synthesize("J'éteins la lumière du salon.")
assert again.pcm == first.pcm and again.audio_s == first.audio_s
assert again.elapsed_s < 0.005 < first.elapsed_s
assert other.pcm != first.pcm
def test_cache_keeps_a_bounded_number_of_answers(tts: TextToSpeech, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("voice_server.text_to_speech.CACHE_PHRASES", 2)
fresh = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio), SETTINGS.audio.sample_rate)
for text in ("Un.", "Deux.", "Trois."):
fresh.synthesize(text)
assert fresh.synthesize("Trois.").elapsed_s < 0.005 # encore en mémoire
assert fresh.synthesize("Un.").elapsed_s > 0.005 # la plus ancienne est sortie
def test_transcription_without_word_times(tts: TextToSpeech, stt: SpeechToText) -> None:
"""Étape 16 : sans horodatage des mots, même texte, pas de mots."""
pcm = tts.synthesize("Fermez la porte du garage.").pcm
plain = stt.transcribe(pcm, words=False)
assert plain.words == () and "garage" in plain.text.lower()
+79 -4
View File
@@ -42,6 +42,11 @@ class FakeVad:
class FakeDiarizer: class FakeDiarizer:
def __init__(self) -> None: def __init__(self) -> None:
self.lengths: list[int] = [] self.lengths: list[int] = []
self.embedded: list[int] = []
def embed(self, pcm: bytes) -> np.ndarray:
self.embedded.append(len(pcm))
return np.array([1.0, 0.0], dtype=np.float32) # la voix que FakeIdentifier reconnaît
def diarize(self, pcm: bytes) -> Diarization: def diarize(self, pcm: bytes) -> Diarization:
self.lengths.append(len(pcm)) self.lengths.append(len(pcm))
@@ -67,10 +72,12 @@ class FakeStt:
self.words = words self.words = words
self.calls = 0 self.calls = 0
self.lengths: list[int] = [] self.lengths: list[int] = []
self.with_words: list[bool] = []
def transcribe(self, pcm: bytes) -> Transcript: def transcribe(self, pcm: bytes, words: bool = True) -> Transcript:
self.calls += 1 self.calls += 1
self.lengths.append(len(pcm)) self.lengths.append(len(pcm))
self.with_words.append(words)
return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped, return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped,
words=self.words) words=self.words)
@@ -131,13 +138,14 @@ def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult:
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None, def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | None = None, diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | ShortYesIdentifier | None = None,
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None, publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline: controller: FakeController | None = None, policy: AccessPolicy | None = None,
direct_below_s: float = 0.0) -> VoicePipeline:
ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard
return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer, return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer,
identifier=identifier, publisher=publisher, controller=controller, policy=policy, identifier=identifier, publisher=publisher, controller=controller, policy=policy,
read=lambda result: PCM, clock=lambda: next(ticks)) read=lambda result: PCM, clock=lambda: next(ticks), direct_below_s=direct_below_s)
def test_full_turn() -> None: def test_full_turn() -> None:
@@ -324,3 +332,70 @@ def test_sensitive_command_waits_for_yes() -> None:
controller=controller, policy=policy).handle(session()) controller=controller, policy=policy).handle(session())
assert second is not None and second.command == door assert second is not None and second.command == door
assert [call[1] for call in controller.calls] == [door] assert [call[1] for call in controller.calls] == [door]
class ShortYesIdentifier:
"""Un « oui » de 0,7 s : sous le seuil, mais Denis reste le profil le plus proche (ou non)."""
def __init__(self, closest: str, score: float) -> None:
self._verdict = Identification(name=None, score=score, closest=closest)
def identify(self, embedding: np.ndarray) -> Identification:
return self._verdict
@pytest.mark.parametrize(("closest", "score", "executed"), [("Denis", 0.35, True), ("Jessica", 0.35, False),
("Denis", 0.10, False)])
def test_short_yes_confirms_only_from_the_closest_voice(closest: str, score: float, executed: bool) -> None:
"""Essai réel du 2026-10-06 : « OUI » de Denis à 0,35 refusé comme « voix inconnue »."""
events: list[str] = []
controller = FakeController(events)
policy = AccessPolicy(ACCESS, HOME)
door = DeviceCommand("open", "door", "salon")
pipeline(FakeStt("ouvre la porte", words=DENIS_WORDS), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(door),
controller=controller, policy=policy).handle(session())
second = pipeline(FakeStt("oui", words=(Word(" oui", 0.0, 0.1),)), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=ShortYesIdentifier(closest, score),
assistant=EchoAssistant(), controller=controller, policy=policy).handle(session())
assert second is not None
assert [call[1] for call in controller.calls] == ([door] if executed else [])
assert second.answer == ("J'ouvre la porte du salon." if executed else "Seul Denis peut confirmer. J'annule.")
def test_short_speech_is_identified_without_diarization() -> None:
"""Étape 16 : 0,5 s de parole = une commande, une personne : empreinte directe, pas de diarisation."""
stt, diarizer = FakeStt("allume la lumière"), FakeDiarizer()
turn = pipeline(stt, FakeTts(), FakePlayer(), diarizer=diarizer, identifier=FakeIdentifier(),
direct_below_s=4.0).handle(session())
assert turn is not None
assert diarizer.lengths == [] and diarizer.embedded == [16000] # même audio que Whisper
assert stt.with_words == [False] # mots non horodatés : inutiles ici
assert (turn.speakers, turn.names) == (1, ("Denis",))
assert [(u.speaker, u.text, u.score) for u in turn.utterances] == [("Denis", "allume la lumière", 0.8)]
def test_long_speech_still_goes_through_diarization() -> None:
"""Au-delà du seuil, plusieurs personnes ont pu parler : diarisation complète, mots horodatés."""
stt, diarizer = FakeStt("bonjour", words=DENIS_WORDS), FakeDiarizer()
turn = pipeline(stt, FakeTts(), FakePlayer(), diarizer=diarizer, identifier=FakeIdentifier(),
direct_below_s=0.4).handle(session()) # la fausse VAD garde 0,5 s de parole
assert turn is not None and turn.speakers == 2
assert diarizer.embedded == [] and diarizer.lengths == [16000] and stt.with_words == [True]
def test_short_speech_applies_rights_to_the_identified_person() -> None:
"""Le raccourci ne change rien aux droits : Denis reconnu, porte → confirmation demandée."""
events: list[str] = []
turn = pipeline(FakeStt("ouvre la porte"), FakeTts(), FakePlayer(events), diarizer=FakeDiarizer(),
identifier=FakeIdentifier(), assistant=EchoAssistant(DeviceCommand("open", "door", "salon")),
controller=FakeController(events), policy=AccessPolicy(ACCESS, HOME),
direct_below_s=4.0).handle(session())
assert turn is not None and turn.answer.startswith("Confirmez-vous")
+2
View File
@@ -11,6 +11,7 @@
Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton. Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton.
Étape 14 : droits par profil (complet / standard / limite), confirmation des actions sensibles. Étape 14 : droits par profil (complet / standard / limite), confirmation des actions sensibles.
Étape 15 : présence + testament (les cartes coupent leur micro si le serveur meurt), journal fichier. Étape 15 : présence + testament (les cartes coupent leur micro si le serveur meurt), journal fichier.
Étape 16 : latence : empreinte directe pour une parole courte, cache de synthèse, envois sans attente.
Étape 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN. Étape 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN.
""" """
@@ -136,6 +137,7 @@ def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioS
publisher=link, publisher=link,
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None, controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
policy=AccessPolicy(settings.access, settings.home, settings.machines), policy=AccessPolicy(settings.access, settings.home, settings.machines),
direct_below_s=settings.diarization.direct_below_s,
) )
return pipeline.handle return pipeline.handle
+27 -4
View File
@@ -142,8 +142,15 @@ class AccessPolicy:
if speaker is not None: if speaker is not None:
self._pending[board] = _Pending(command, speaker, self._clock() + self._access.confirm_timeout_s) self._pending[board] = _Pending(command, speaker, self._clock() + self._access.confirm_timeout_s)
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None,
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire.""" score: float | None = None) -> Resolution | None:
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire.
Args:
speaker: personne reconnue, None si la voix n'atteint pas le seuil d'identification.
closest: profil le plus proche, même sous le seuil.
score: similarité avec ce profil.
"""
pending = self._pending.pop(board, None) pending = self._pending.pop(board, None)
if pending is None or self._clock() > pending.expires: if pending is None or self._clock() > pending.expires:
return None return None
@@ -152,14 +159,30 @@ class AccessPolicy:
said_no = any(word in NO for word in words) said_no = any(word in NO for word in words)
if not (said_yes or said_no): if not (said_yes or said_no):
return None # autre chose : la demande en attente est abandonnée, on traite la nouvelle return None # autre chose : la demande en attente est abandonnée, on traite la nouvelle
if speaker != pending.speaker: if speaker != pending.speaker and not self._probably(pending.speaker, speaker, closest, score):
logger.warning("%s : confirmation de %s refusee, donnee par %s", board, pending.speaker, logger.warning("%s : confirmation de %s refusee, donnee par %s", board, pending.speaker,
speaker or "une voix inconnue") speaker or f"une voix inconnue (proche de {closest}, {self._shown(score)})")
return Resolution(f"Seul {pending.speaker} peut confirmer. J'annule.", None) return Resolution(f"Seul {pending.speaker} peut confirmer. J'annule.", None)
if speaker != pending.speaker:
logger.info("%s : confirmation de %s acceptee a %s : voix trop courte pour le seuil, mais "
"profil le plus proche", board, pending.speaker, self._shown(score))
if said_no: if said_no:
return Resolution("D'accord, j'annule.", None) return Resolution("D'accord, j'annule.", None)
return Resolution(self._announce(pending.command), pending.command) return Resolution(self._announce(pending.command), pending.command)
def _probably(self, expected: str, speaker: str | None, closest: str | None, score: float | None) -> bool:
"""Voix non reconnue, mais la personne attendue reste la plus proche : assez pour un « oui ».
Un « oui » dure moins d'une seconde, trop peu pour une empreinte nette. La demande,
elle, a été reconnue au seuil normal quelques secondes plus tôt, sur la même carte.
"""
return (speaker is None and closest == expected and score is not None
and score >= self._access.confirm_min_score)
@staticmethod
def _shown(score: float | None) -> str:
return "?" if score is None else f"{score:.2f}"
def _request(self, command: Command) -> str: def _request(self, command: Command) -> str:
"""« ouvrir la porte du salon », « éteindre le PC du bureau ».""" """« ouvrir la porte du salon », « éteindre le PC du bureau »."""
return command.asked if isinstance(command, MachineCommand) else request(command, self._home) return command.asked if isinstance(command, MachineCommand) else request(command, self._home)
+3 -1
View File
@@ -42,7 +42,9 @@ from voice_server.wake_word import Detection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CHECK_EVERY_CHUNKS = 5 # recherche du silence final toutes les 250 ms d'audio # Recherche du silence final toutes les 100 ms d'audio (250 ms avant l'étape 16) : la fin de la
# commande est vue en moyenne 75 ms plus tôt, pour une VAD de quelques ms sur le tampon.
CHECK_EVERY_CHUNKS = 2
COMMAND_AFTER_WAKE_S = 0.3 # parole qui continue au moins 0,3 s après le mot : il y a une commande COMMAND_AFTER_WAKE_S = 0.3 # parole qui continue au moins 0,3 s après le mot : il y a une commande
STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché
+11
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import logging import logging
import socket
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@@ -94,6 +95,7 @@ class MqttLink:
self._client.on_connect = self._on_connect self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect self._client.on_disconnect = self._on_disconnect
self._client.on_message = self._on_message self._client.on_message = self._on_message
self._client.on_socket_open = self._on_socket_open
self._client.reconnect_delay_set(min_delay=1, max_delay=10) self._client.reconnect_delay_set(min_delay=1, max_delay=10)
if self._presence is not None: if self._presence is not None:
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir # Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
@@ -165,6 +167,15 @@ class MqttLink:
client.subscribe(pattern, qos=1) client.subscribe(pattern, qos=1)
logger.info("abonne a %s (QoS 1)", pattern) logger.info("abonne a %s (QoS 1)", pattern)
def _on_socket_open(self, client: mqtt.Client, userdata: Any, sock: Any) -> None:
# Étape 16 : sans cela, le noyau retient les petits paquets en attendant un accusé de
# réception (algorithme de Nagle) ; un chunk audio de 1600 octets tient en deux paquets,
# et le second attendrait. Un flux temps réel envoie tout de suite.
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except (OSError, AttributeError): # socket TLS ou WebSocket enveloppé : option indisponible
logger.debug("TCP_NODELAY non applique")
def _on_disconnect(self, client: mqtt.Client, userdata: Any, def _on_disconnect(self, client: mqtt.Client, userdata: Any,
flags: mqtt.DisconnectFlags, reason_code: ReasonCode, flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
properties: Properties | None) -> None: properties: Properties | None) -> None:
+6
View File
@@ -61,6 +61,7 @@ class SttSettings:
beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis
no_speech_threshold: float no_speech_threshold: float
model_dir: Path # dossier du modèle téléchargé par « make models » model_dir: Path # dossier du modèle téléchargé par « make models »
cpu_threads: int = 0 # fils de calcul de Whisper ; 0 : défaut de la bibliothèque (4)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -90,6 +91,7 @@ class DiarizationSettings:
model: str # dépôt Hugging Face du pipeline model: str # dépôt Hugging Face du pipeline
max_speakers: int max_speakers: int
model_dir: Path # copie locale téléchargée par « make models » model_dir: Path # copie locale téléchargée par « make models »
direct_below_s: float = 0.0 # parole plus courte : empreinte directe, sans diarisation (étape 16)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -136,6 +138,7 @@ class AccessSettings:
rules: dict[str, dict[str, str]] # appareil → action → niveau minimal rules: dict[str, dict[str, str]] # appareil → action → niveau minimal
confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter
confirm_timeout_s: float confirm_timeout_s: float
confirm_min_score: float = 0.25 # « oui » trop court pour le seuil : accepté si le demandeur reste le plus proche
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -284,6 +287,7 @@ def load_settings(path: Path) -> Settings:
beam_size=int(stt["beam_size"]), beam_size=int(stt["beam_size"]),
no_speech_threshold=float(stt["no_speech_threshold"]), no_speech_threshold=float(stt["no_speech_threshold"]),
model_dir=models_dir / "whisper" / str(stt["model"]), model_dir=models_dir / "whisper" / str(stt["model"]),
cpu_threads=int(stt.get("cpu_threads", 0)),
), ),
tts=TtsSettings( tts=TtsSettings(
voice=str(tts["voice"]), voice=str(tts["voice"]),
@@ -302,6 +306,7 @@ def load_settings(path: Path) -> Settings:
max_speakers=int(diarization["max_speakers"]), max_speakers=int(diarization["max_speakers"]),
# « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1 # « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1
model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1], model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1],
direct_below_s=float(diarization.get("direct_below_s", 0.0)),
), ),
speaker=SpeakerSettings( speaker=SpeakerSettings(
profiles_dir=(base / str(speaker["profiles_dir"])).resolve(), profiles_dir=(base / str(speaker["profiles_dir"])).resolve(),
@@ -333,6 +338,7 @@ def load_settings(path: Path) -> Settings:
for device, actions in dict(access["rules"]).items()}, for device, actions in dict(access["rules"]).items()},
confirm=tuple(str(item) for item in access.get("confirm") or ()), confirm=tuple(str(item) for item in access.get("confirm") or ()),
confirm_timeout_s=float(access["confirm_timeout_s"]), confirm_timeout_s=float(access["confirm_timeout_s"]),
confirm_min_score=float(access.get("confirm_min_score", 0.25)),
), ),
machines=_machines(data), machines=_machines(data),
recordings_dir=(base / str(storage["recordings_dir"])).resolve(), recordings_dir=(base / str(storage["recordings_dir"])).resolve(),
+13 -7
View File
@@ -74,13 +74,19 @@ class SpeechToText:
began = time.monotonic() began = time.monotonic()
# int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32. # int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32.
self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type) self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type,
cpu_threads=stt.cpu_threads)
# Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question. # Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question.
self.transcribe(bytes(sample_rate * 2)) self.transcribe(bytes(sample_rate * 2))
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began) logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
def transcribe(self, pcm: bytes) -> Transcript: def transcribe(self, pcm: bytes, words: bool = True) -> Transcript:
"""Transcrit le PCM et écarte les segments suspects.""" """Transcrit le PCM et écarte les segments suspects.
Args:
words: horodater chaque mot. Utile seulement pour répartir le texte entre
plusieurs locuteurs (étape 10) ; sans cela, ~0,1 s de moins (mesuré, étape 16).
"""
began = time.monotonic() began = time.monotonic()
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0 audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
segments, _ = self._model.transcribe( segments, _ = self._model.transcribe(
@@ -91,12 +97,12 @@ class SpeechToText:
condition_on_previous_text=False, condition_on_previous_text=False,
no_speech_threshold=self._stt.no_speech_threshold, no_speech_threshold=self._stt.no_speech_threshold,
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
word_timestamps=True, # instant de chaque mot : +5 à 10 % de calcul (mesuré) word_timestamps=words, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
) )
kept: list[str] = [] kept: list[str] = []
dropped: list[str] = [] dropped: list[str] = []
words: list[Word] = [] found: list[Word] = []
for segment in segments: # générateur : le calcul se fait pendant ce parcours for segment in segments: # générateur : le calcul se fait pendant ce parcours
text = segment.text.strip() text = segment.text.strip()
if not text: if not text:
@@ -105,7 +111,7 @@ class SpeechToText:
dropped.append(text) dropped.append(text)
else: else:
kept.append(text) kept.append(text)
words.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end)) found.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
for w in segment.words or ()) for w in segment.words or ())
return Transcript( return Transcript(
@@ -113,5 +119,5 @@ class SpeechToText:
audio_s=audio.size / self._sample_rate, audio_s=audio.size / self._sample_rate,
elapsed_s=time.monotonic() - began, elapsed_s=time.monotonic() - began,
dropped=tuple(dropped), dropped=tuple(dropped),
words=tuple(words), words=tuple(found),
) )
+15 -1
View File
@@ -17,6 +17,8 @@ if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'ex
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CACHE_PHRASES = 64 # réponses gardées en mémoire : ~100 Ko chacune, 6 Mo au plus
@dataclass(frozen=True) @dataclass(frozen=True)
class Speech: class Speech:
@@ -32,6 +34,10 @@ class TextToSpeech:
Piper produit du 22 050 Hz en flottants ; AudioResampler le ramène au Piper produit du 22 050 Hz en flottants ; AudioResampler le ramène au
format du système (16 kHz, s16le) et fixe sa crête à -3 dBFS. format du système (16 kHz, s16le) et fixe sa crête à -3 dBFS.
Les réponses déjà synthétisées sont gardées en mémoire (étape 16) : « J'allume
la lumière du salon. » ou « Confirmez-vous… » reviennent souvent, et leur
synthèse coûte 30 à 140 ms à chaque fois.
""" """
def __init__(self, tts: TtsSettings, resampler: AudioResampler, sample_rate: int) -> None: def __init__(self, tts: TtsSettings, resampler: AudioResampler, sample_rate: int) -> None:
@@ -53,12 +59,16 @@ class TextToSpeech:
self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale) self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale)
self._resampler = resampler self._resampler = resampler
self._sample_rate = sample_rate self._sample_rate = sample_rate
self._cache: dict[str, Speech] = {} # texte → parole ; un dict garde l'ordre d'insertion
logger.info("voix piper %s chargee en %.1f s (%d Hz)", tts.voice, logger.info("voix piper %s chargee en %.1f s (%d Hz)", tts.voice,
time.monotonic() - began, self._voice.config.sample_rate) time.monotonic() - began, self._voice.config.sample_rate)
def synthesize(self, text: str) -> Speech: def synthesize(self, text: str) -> Speech:
"""Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout.""" """Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout."""
began = time.monotonic() began = time.monotonic()
cached = self._cache.get(text)
if cached is not None:
return Speech(pcm=cached.pcm, audio_s=cached.audio_s, elapsed_s=time.monotonic() - began)
parts: list[np.ndarray] = [] parts: list[np.ndarray] = []
rate = self._voice.config.sample_rate rate = self._voice.config.sample_rate
for chunk in self._voice.synthesize(text, syn_config=self._config): for chunk in self._voice.synthesize(text, syn_config=self._config):
@@ -66,8 +76,12 @@ class TextToSpeech:
rate = chunk.sample_rate rate = chunk.sample_rate
signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32) signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32)
pcm = self._resampler.convert(signal, rate) pcm = self._resampler.convert(signal, rate)
return Speech( speech = Speech(
pcm=pcm, pcm=pcm,
audio_s=len(pcm) / 2 / self._sample_rate, audio_s=len(pcm) / 2 / self._sample_rate,
elapsed_s=time.monotonic() - began, elapsed_s=time.monotonic() - began,
) )
if len(self._cache) >= CACHE_PHRASES:
del self._cache[next(iter(self._cache))] # la plus ancienne sort
self._cache[text] = speech
return speech
+48 -11
View File
@@ -7,6 +7,7 @@ VAD → diarisation → identification → transcription → qui a dit quoi →
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import time import time
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
@@ -30,6 +31,7 @@ from voice_server.voice_activity import VadResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot
DIRECT_LABEL = "SPEAKER_00" # étiquette de l'unique locuteur d'une parole courte (étape 16)
class SpeechDetector(Protocol): class SpeechDetector(Protocol):
@@ -43,6 +45,8 @@ class SpeakerSplitter(Protocol):
def diarize(self, pcm: bytes) -> Diarization: ... def diarize(self, pcm: bytes) -> Diarization: ...
def embed(self, pcm: bytes) -> np.ndarray: ...
class Identifier(Protocol): class Identifier(Protocol):
"""Ce que la boucle attend de l'identification du locuteur.""" """Ce que la boucle attend de l'identification du locuteur."""
@@ -57,13 +61,14 @@ class Policy(Protocol):
def ask(self, board: str, command: Command, speaker: str | None) -> None: ... def ask(self, board: str, command: Command, speaker: str | None) -> None: ...
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: ... def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None,
score: float | None = None) -> Resolution | None: ...
class Transcriber(Protocol): class Transcriber(Protocol):
"""Ce que la boucle attend de la transcription.""" """Ce que la boucle attend de la transcription."""
def transcribe(self, pcm: bytes) -> Transcript: ... def transcribe(self, pcm: bytes, words: bool = True) -> Transcript: ...
class Responder(Protocol): class Responder(Protocol):
@@ -129,9 +134,12 @@ class VoicePipeline:
policy: Policy | None = None, policy: Policy | None = None,
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path), read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
clock: Callable[[], float] = time.monotonic, clock: Callable[[], float] = time.monotonic,
direct_below_s: float = 0.0,
) -> None: ) -> None:
""" """
Args: Args:
direct_below_s: parole plus courte que cela : une seule personne supposée, empreinte
directe au lieu de la diarisation (étape 16) ; 0 : toujours la diarisation.
diarizer: diarisation, None pour s'en passer (Raspberry Pi 4). diarizer: diarisation, None pour s'en passer (Raspberry Pi 4).
identifier: identification ; sans diarisation, elle n'a pas d'empreintes. identifier: identification ; sans diarisation, elle n'a pas d'empreintes.
publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul). publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul).
@@ -152,6 +160,7 @@ class VoicePipeline:
self._policy = policy self._policy = policy
self._read = read self._read = read
self._clock = clock self._clock = clock
self._direct_below_s = direct_below_s
def handle(self, result: SessionResult) -> Turn | None: def handle(self, result: SessionResult) -> Turn | None:
"""Répond à la session ; None si elle était trop courte pour être une question.""" """Répond à la session ; None si elle était trop courte pour être une question."""
@@ -169,29 +178,38 @@ class VoicePipeline:
if vad.has_speech: if vad.has_speech:
logger.info("%s : parole %.2f s sur %.2f s (segments %s s), VAD %.0f ms", result.device, logger.info("%s : parole %.2f s sur %.2f s (segments %s s), VAD %.0f ms", result.device,
vad.speech_s, vad.total_s, vad.bounds(), vad.elapsed_s * 1000) vad.speech_s, vad.total_s, vad.bounds(), vad.elapsed_s * 1000)
# Parole courte = une commande, une seule personne : son empreinte suffit (20 ms au lieu
# de 400 ms de diarisation), et Whisper n'a pas à horodater les mots (étape 16).
direct = self._diarizer is not None and vad.speech_s < self._direct_below_s
if self._diarizer is not None and direct:
speakers = 1
verdicts = self._identify_directly(result.device, vad.speech_pcm, self._diarizer)
# La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10). # La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10).
if self._diarizer is not None: elif self._diarizer is not None:
diarization = self._diarizer.diarize(vad.speech_pcm) diarization = self._diarizer.diarize(vad.speech_pcm)
speakers = len(diarization.speakers) speakers = len(diarization.speakers)
logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers, logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers,
diarization.describe() or "aucun tour", diarization.elapsed_s) diarization.describe() or "aucun tour", diarization.elapsed_s)
turns = diarization.turns turns = diarization.turns
verdicts = self._identify(result.device, diarization) verdicts = self._identify(result.device, diarization)
transcript = self._stt.transcribe(vad.speech_pcm) transcript = self._stt.transcribe(vad.speech_pcm, words=not direct)
else: else:
direct = False
logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device, logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device,
result.session_id, result.levels.peak_dbfs) result.session_id, result.levels.peak_dbfs)
transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0) transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0)
if transcript.dropped: if transcript.dropped:
logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped)) logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped))
utterances = self._who_said_what(result, transcript, turns, verdicts) utterances = self._who_said_what(result, transcript, turns, verdicts, direct)
# On répond à la dernière personne qui a parlé : c'est elle qui attend la réponse, # On répond à la dernière personne qui a parlé : c'est elle qui attend la réponse,
# et ce sont ses droits qui s'appliquent. # et ce sont ses droits qui s'appliquent.
last = utterances[-1] if utterances else None last = utterances[-1] if utterances else None
speaker = last.speaker if last is not None and last.speaker != UNKNOWN else None speaker = last.speaker if last is not None and last.speaker != UNKNOWN else None
score = last.score if last is not None else None score = last.score if last is not None else None
reply, refused = self._decide(result.device, transcript.text, speaker, score) verdict = verdicts.get(last.label) if last is not None else None
closest = verdict.closest if verdict is not None else None
reply, refused = self._decide(result.device, transcript.text, speaker, score, closest)
answer = reply.text answer = reply.text
if reply.command is not None and self._controller is not None: if reply.command is not None and self._controller is not None:
# La commande part avant la réponse parlée : la lumière s'allume pendant que la carte le dit. # La commande part avant la réponse parlée : la lumière s'allume pendant que la carte le dit.
@@ -209,13 +227,14 @@ class VoicePipeline:
stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s, stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s,
answer_s=speech.audio_s) answer_s=speech.audio_s)
def _decide(self, board: str, text: str, speaker: str | None, score: float | None) -> tuple[Reply, bool]: def _decide(self, board: str, text: str, speaker: str | None, score: float | None,
closest: str | None = None) -> tuple[Reply, bool]:
"""Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente.""" """Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente."""
if not text: if not text:
return Reply(NOTHING_HEARD), False return Reply(NOTHING_HEARD), False
if self._policy is not None: if self._policy is not None:
# Une confirmation attendue passe avant tout : « oui » n'est pas une requête. # Une confirmation attendue passe avant tout : « oui » n'est pas une requête.
resolution = self._policy.resolve(board, text, speaker) resolution = self._policy.resolve(board, text, speaker, closest, score)
if resolution is not None: if resolution is not None:
return Reply(resolution.answer, resolution.command), resolution.command is None return Reply(resolution.answer, resolution.command), resolution.command is None
reply = self._assistant.respond(text, speaker, board) reply = self._assistant.respond(text, speaker, board)
@@ -239,12 +258,30 @@ class VoicePipeline:
f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items())) f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items()))
return verdicts return verdicts
def _who_said_what(self, result: SessionResult, transcript: Transcript, def _identify_directly(self, device: str, pcm: bytes, diarizer: SpeakerSplitter) -> dict[str, Identification]:
turns: tuple[SpeakerTurn, ...], verdicts: dict[str, Identification]) -> list[Utterance]: """Parole courte : une empreinte pour tout l'extrait, un seul verdict (étape 16)."""
began = time.monotonic()
embedding = diarizer.embed(pcm)
if self._identifier is None:
return {}
verdict = self._identifier.identify(embedding)
logger.info("%s : 1 locuteur (parole courte, empreinte directe %.0f ms) : %s", device,
(time.monotonic() - began) * 1000, verdict.describe())
return {DIRECT_LABEL: verdict}
def _who_said_what(self, result: SessionResult, transcript: Transcript, turns: tuple[SpeakerTurn, ...],
verdicts: dict[str, Identification], direct: bool = False) -> list[Utterance]:
"""Interventions de la session, journalisées et publiées sur voice/<carte>/transcript.""" """Interventions de la session, journalisées et publiées sur voice/<carte>/transcript."""
if not transcript.text: if not transcript.text:
return [] return []
utterances = attribute(transcript.words, turns, verdicts) verdict = verdicts.get(DIRECT_LABEL) if direct else None
if verdict is not None:
# Une seule personne : tout le texte est à elle, sans horodatage des mots.
score = None if math.isnan(verdict.score) else verdict.score
utterances = [Utterance(speaker=verdict.label, text=transcript.text, start_s=0.0,
end_s=transcript.audio_s, score=score, label=DIRECT_LABEL)]
else:
utterances = attribute(transcript.words, turns, verdicts)
if not utterances: # texte sans horodatage des mots : une seule intervention anonyme if not utterances: # texte sans horodatage des mots : une seule intervention anonyme
utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0, utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0,
end_s=transcript.audio_s, score=None)] end_s=transcript.audio_s, score=None)]
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Mesure la latence du serveur sur des enregistrements réels, avant et après l'étape 16.
Pour chaque enregistrement de server/recordings/ (lecture seule, rien n'est écrit) :
avant VAD → diarisation → identification → Whisper avec mots horodatés
après VAD → empreinte directe si la parole est courte (diarization.direct_below_s),
diarisation sinon → identification → Whisper sans mots si parole courte
Le temps affiché est celui du calcul, de la fin de la session au texte reconnu :
il ne compte ni le réseau, ni la synthèse (quelques dizaines de ms, en cache
pour les réponses répétées). À lancer sur la machine qui fait tourner le serveur.
Exemples :
python3 tools/latency_check.py # les 30 derniers enregistrements
python3 tools/latency_check.py --last 100
"""
from __future__ import annotations
import argparse
import logging
import statistics
import sys
import time
import warnings
from collections.abc import Callable
from pathlib import Path
from typing import TypeVar
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from voice_server.audio_buffer import read_pcm # noqa: E402
from voice_server.settings import load_settings # noqa: E402
logger = logging.getLogger("latency_check")
T = TypeVar("T")
def timed(function: Callable[[], T]) -> tuple[float, T]:
"""Durée d'un appel, et son résultat."""
began = time.monotonic()
result = function()
return time.monotonic() - began, result
def main(argv: list[str]) -> int:
"""Rejoue les enregistrements et affiche les médianes."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--last", type=int, default=30, help="nombre d'enregistrements, les plus recents")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
warnings.filterwarnings("ignore") # avertissements de torch sur les extraits très courts
logging.getLogger("faster_whisper").setLevel(logging.WARNING) # une ligne par transcription sinon
settings = load_settings(args.config)
try:
from voice_server.diarization import Diarizer
from voice_server.speaker_identifier import SpeakerIdentifier
from voice_server.speech_to_text import SpeechToText
from voice_server.voice_activity import VoiceActivityDetector
vad = VoiceActivityDetector(settings.vad, settings.audio.sample_rate)
stt = SpeechToText(settings.stt, settings.audio.sample_rate)
diarizer = Diarizer(settings.diarization, settings.audio.sample_rate)
except (ImportError, FileNotFoundError) as error:
logger.error("%s : lancez 'make install-speech', 'make install-diarization' et 'make models'", error)
return 1
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
threshold = settings.diarization.direct_below_s
files = sorted(settings.recordings_dir.glob("*.wav"), key=lambda path: path.stat().st_mtime)[-args.last:]
clips = [result.speech_pcm for result in (vad.detect(read_pcm(path)) for path in files) if result.has_speech]
if not clips:
logger.error("aucun enregistrement avec de la parole dans %s", settings.recordings_dir)
return 1
diarizer.diarize(clips[0]) # premier appel plus lent : hors mesure
diarizer.embed(clips[0])
before: list[float] = []
after: list[float] = []
short = same_text = same_name = 0
changes: list[str] = []
for pcm in clips:
speech_s = len(pcm) / 2 / settings.audio.sample_rate
diar_s, diarization = timed(lambda: diarizer.diarize(pcm))
words_s, with_words = timed(lambda: stt.transcribe(pcm))
before.append(diar_s + words_s)
old_name = (identifier.identify(diarization.embeddings[0]).label
if diarization.embeddings is not None and len(diarization.speakers) == 1 else None)
if speech_s < threshold:
short += 1
embed_s, embedding = timed(lambda: diarizer.embed(pcm))
plain_s, plain = timed(lambda: stt.transcribe(pcm, words=False))
after.append(embed_s + plain_s)
same_text += plain.text == with_words.text
verdict = identifier.identify(embedding)
same_name += verdict.label == old_name
if verdict.label != old_name:
changes.append(f"{old_name} -> {verdict.describe()} ({speech_s:.1f} s de parole)")
else:
after.append(diar_s + words_s)
def median(values: list[float]) -> float:
return statistics.median(values)
logger.info("%d enregistrements avec parole (%.1f s de parole en mediane), dont %d sous %.1f s",
len(clips), median([len(pcm) / 2 / settings.audio.sample_rate for pcm in clips]), short, threshold)
logger.info("avant : %.2f s (mediane), %.2f s (pire)", median(before), max(before))
logger.info("apres : %.2f s (mediane), %.2f s (pire)", median(after), max(after))
logger.info("gain : %.2f s, soit %.0f %%", median(before) - median(after),
100 * (1 - median(after) / median(before)))
if short:
logger.info("paroles courtes : meme texte %d/%d, meme identification %d/%d", same_text, short,
same_name, short)
for change in changes: # attendu : des voix à la limite du seuil, dans les deux sens
logger.info(" identification differente : %s", change)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))