mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
Compare commits
7 Commits
d20976fea2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d114b9818e | |||
| 79d45587ba | |||
| ec88de1b3c | |||
| 3051d6abbe | |||
| 35bb51145f | |||
| 1d07a0c255 | |||
| 99beaff4ca |
+105
-24
@@ -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)** |
|
||||
| 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** |
|
||||
| 15 | Fiabilisation | **fait** (broker + serveur + firmware) | **oui (2026-09-30)** |
|
||||
| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | en attente |
|
||||
| 16 | Optimisation | à faire | — |
|
||||
| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | **oui (2026-10-06)** |
|
||||
| 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.
|
||||
|
||||
@@ -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 |
|
||||
| Broches | voir `firmware/include/config.h` | aucune broche de strapping (0, 2, 5, 12, 15) sur l'audio |
|
||||
| Broker de dev : conteneur `mosquitto_services-mosquitto-1`, compose `~/srv/docker-compose.mosquitto.yml`, config montée `~/srv/mosquitto/config/mosquitto.conf` ; **IP du PC = 192.168.1.104** (et non .0.104) | 2026-09-29 | constaté sur la machine |
|
||||
| **Broker = Raspberry Pi 5, 192.168.1.200**, comptes exigés (anonyme refusé) ; un seul réglage : `mqtt.host` de `server/config.yaml`, relu par le Makefile (`MQTT_HOST`) | 2026-10-06 | décision utilisateur (« broker sur pi5 ») ; le conteneur local du PC de dev (192.168.1.104, anonyme) tourne encore mais n'est plus utilisé |
|
||||
| Carte : Wi-Fi et broker en **NVS**, réglés par le portail `VoiceAssist-Config` (http://192.168.4.1, bouton PTT tenu 3 s au démarrage) ; `secrets.h` n'est plus qu'un repli si la NVS est vide (`WifiConfig`, ajouté par l'utilisateur) | 2026-10-06 | changer de broker = portail, pas recompilation |
|
||||
| MQTT : état JSON **retenu** + testament `{"status":"offline"}` retenu QoS 1, keepalive 10 s | 2026-09-29 | `offline` en 15 s max après coupure ; serveur informé même s'il démarre après l'ESP32 |
|
||||
| Wi-Fi : `setSleep(false)` | 2026-09-29 | le modem endormi ajoute 100 ms+ de latence : incompatible avec le flux audio |
|
||||
| Partitions `huge_app.csv` (3 Mo d'application, pas d'OTA) | 2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut |
|
||||
@@ -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 |
|
||||
| Règles par appareil/action ; **action absente = niveau complet** | 2026-09-30 | on ne permet jamais par oubli |
|
||||
| Actions sensibles (`confirm: [door.open]`) : question, puis « oui »/« non » **de la même personne**, sur la même carte, en 15 s ; toute autre phrase abandonne la demande | 2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) |
|
||||
| Confirmation : acceptée si la voix est reconnue comme le demandeur, **ou** si elle est sous le seuil mais que le demandeur reste le **profil le plus proche** avec `access.confirm_min_score` (**0,25**) ; voix reconnue comme quelqu'un d'autre : refusée | 2026-10-06 | mesuré : 6 « oui » seuls de Denis (~0,7 s de parole) à 0,29-0,47, 5 sous le seuil 0,45 ; autres profils face à Denis ≤ 0,12 (profils entiers, surtout voix de synthèse : à revoir avec de vraies personnes) |
|
||||
| É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) |
|
||||
| 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) |
|
||||
@@ -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 |
|
||||
| Phrase qui **nomme une machine** traitée avant la domotique ; noms de plusieurs mots (« pc du bureau ») | 2026-09-30 | « éteins le PC du bureau » ≠ « éteins la lumière du bureau » (bureau est une pièce) |
|
||||
| Droits : `access.rules.machine` (wake/lock standard, shutdown/reboot complet), `confirm: [machine.shutdown, machine.reboot]` ; `MachineCommand` porte ses phrases (`said`, `asked`) | 2026-09-30 | réutilise étape 14 sans changer `DeviceCommand` ; `CommandRouter` aiguille vers Home/MachineController |
|
||||
| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-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 |
|
||||
| **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 |
|
||||
@@ -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/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
|
||||
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]
|
||||
docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3]
|
||||
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/voice_server/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
|
||||
server/voice_server/command_router.py CommandRouter : domotique / machines [étape 15b]
|
||||
firmware/src/WifiConfig.* Wi-Fi + broker en NVS, portail AP/HTTP (utilisateur) [2026-10-06]
|
||||
agent/voice_agent.py agent Linux autonome (liste blanche, dry_run) [étape 15b]
|
||||
agent/agent.yaml.example modèle de /etc/voice-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]
|
||||
server/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
|
||||
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`.
|
||||
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à**.
|
||||
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.)
|
||||
- Étape 9 alors écrite et testée hors ligne. Étapes 1 à 8 validées.
|
||||
- Tout était vert à l'arrêt : 98 tests, pyright 0 erreur, `make fw-check` SUCCESS (RAM 36,7 %,
|
||||
Flash 31,3 %), `pip check` propre. Aucun serveur lancé.
|
||||
- `server/.venv` contient les extras `speech` et `diarization` (torch CPU) ; `server/models/`
|
||||
contient whisper `small`, la voix Piper et le pipeline pyannote (accès HF réglé, `HF_TOKEN` dans
|
||||
`server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**).
|
||||
- **Aucun profil vocal** : `server/profiles/` n'existe pas encore (mes essais étaient dans le scratchpad).
|
||||
- Firmware inchangé depuis l'étape 5 (déjà sur la carte). Broker : conteneur Docker de l'utilisateur, 192.168.1.104.
|
||||
- **Étapes 1 à 15 validées.** Étape **15b écrite**, vérifiée hors carte (broker jetable), **pas encore
|
||||
essayée par l'utilisateur**. Ne pas commencer l'étape 16 avant sa validation explicite.
|
||||
- Vérifié à l'arrêt : 203 tests, pyright 0 erreur (tools/ et agent/ inclus), `make fw-check`
|
||||
SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). Git : `main`, 1 commit, arbre propre.
|
||||
Aucun serveur, agent ni conteneur de test lancé (`voice-acl-test`, `voice-agent-test` supprimés).
|
||||
- Broker : **Raspberry Pi 5, 192.168.1.200, comptes exigés** (depuis le 2026-10-06). Le serveur s'y
|
||||
connecte (`voice-server`, `server/.env`). Le conteneur local (192.168.1.104, anonyme) n'est plus utilisé.
|
||||
- Firmware : la version **étape 13** est sur la carte (essais mains libres réussis) ; téléversement de
|
||||
la version **étape 15** (chien de garde, présence serveur) **non confirmé**. Les deux fonctionnent
|
||||
avec le serveur actuel, qui publie sa présence.
|
||||
- `server/.venv` : 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.
|
||||
2. Dans un autre terminal, sans arrêter le serveur : `make enroll NAME=Denis`.
|
||||
3. Nouvelles questions → le journal doit afficher `SPEAKER_00 = Denis (0.7x)`.
|
||||
4. Une autre voix (personne ou haut-parleur) → `Inconnu (…, proche de Denis)`.
|
||||
5. `make diarize-demo` → votre voix reconnue, Piper « Inconnu ».
|
||||
6. Analyser le journal collé, puis demander la validation de l'étape 9 avant l'étape 10
|
||||
(association locuteur + texte : `{"speaker": "Denis", "text": "..."}`).
|
||||
1. Terminal 1 : `make agent-run` (crée `agent/agent.yaml` en **mode essai**, `dry_run: true` ; vérifier
|
||||
`id: pc-bureau`, `mqtt.host: 192.168.1.200` et le compte MQTT `pc-bureau`).
|
||||
2. Terminal 2 : `make run` → `agent pc-bureau : en ligne, actions lock, reboot, shutdown`.
|
||||
3. « Hey Mycroft, verrouille le PC du bureau » → « Je verrouille le PC du bureau. » ; agent :
|
||||
`essai : loginctl lock-sessions (non execute, dry_run)`.
|
||||
4. « Hey Mycroft, éteins le PC du bureau » → « Confirmez-vous… ? » → « Hey Mycroft, oui » → agent :
|
||||
`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
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -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.
|
||||
- **2026-09-30** — **Git initialisé** à la demande de l'utilisateur, avant l'essai de l'étape 15b :
|
||||
premier commit (108 fichiers) sur `main` ; contrôle des fichiers sensibles fait (§7).
|
||||
- **2026-10-06** — Reprise sur le PC de dev. Entre-temps, 3 commits de l'utilisateur (`WifiConfig` :
|
||||
portail de configuration et NVS ; README agent ; agent installé sous `/etc/voice-assistant`) et un
|
||||
dépôt distant GitHub. Symptôme : « `make 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).
|
||||
|
||||
@@ -16,13 +16,16 @@ OUT ?= recordings/test.wav
|
||||
TONE ?= 440
|
||||
TEXT ?= Quelle heure est-il ?
|
||||
LAST ?= 5
|
||||
COUNT ?= 30
|
||||
JITTER ?= 0
|
||||
VOICE ?= fr_FR-tom-medium
|
||||
SPEAKER ?=
|
||||
SAY_OUT ?= recordings/voix-test/phrase.wav
|
||||
WAKE ?=
|
||||
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"}
|
||||
MOSQ_DIR ?= $(HOME)/srv/mosquitto
|
||||
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
|
||||
.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
|
||||
|
||||
## ------------------------------------------------------------------ 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
|
||||
@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
|
||||
@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)
|
||||
@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)
|
||||
$(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)
|
||||
$(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)) \
|
||||
$(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)
|
||||
$(PY) tools/speech_check.py "$(TEXT)"
|
||||
|
||||
|
||||
+190
-16
@@ -103,33 +103,180 @@ No Base64: MQTT payloads carry raw PCM bytes.
|
||||
|
||||
## 4. Installation
|
||||
|
||||
```bash
|
||||
# 1. System packages (mosquitto, ffmpeg, venv) + serial port access
|
||||
make install-system
|
||||
# Log out and back in to activate the "dialout" group.
|
||||
### 4.1 ESP32 firmware
|
||||
|
||||
# 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
|
||||
# PlatformIO gets its own venv (.pio-venv): its dependencies clash with the
|
||||
# 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 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
|
||||
cp server/.env.example server/.env # then HF_TOKEN=... (see step 8)
|
||||
make models
|
||||
|
||||
# 5. Secrets (used from step 3 onwards)
|
||||
cp firmware/include/secrets.h.example firmware/include/secrets.h
|
||||
cp server/.env.example server/.env
|
||||
# 6. Wake word openWakeWord (~5 MB) — optional
|
||||
make install-wakeword
|
||||
make models
|
||||
|
||||
# 6. Help
|
||||
make help
|
||||
# 7. Run the assistant
|
||||
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
|
||||
@@ -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 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 latency # server latency before/after step 16, on real recordings
|
||||
make transcribe OUT=server/recordings/<file>.wav
|
||||
make vad OUT=server/recordings/<file>.wav # speech found by the VAD (step 7)
|
||||
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)**.
|
||||
|
||||
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
|
||||
|
||||
"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
|
||||
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
|
||||
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":
|
||||
the agent prints `essai : systemctl poweroff (non execute)`.
|
||||
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`.
|
||||
In production, use the accounts and rights of step 15 (`mosquitto/acl` has the agent rules).
|
||||
|
||||
@@ -102,33 +102,184 @@ Pas de Base64 : les payloads MQTT contiennent des octets PCM bruts.
|
||||
|
||||
## 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
|
||||
# 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
|
||||
# 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
|
||||
# PlatformIO est installé dans son propre venv (.pio-venv) : ses dépendances
|
||||
# entrent en conflit avec celles du serveur vocal.
|
||||
# PlatformIO est dans son propre venv (.pio-venv) : ses dépendances entrent
|
||||
# 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 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
|
||||
cp server/.env.example server/.env # puis HF_TOKEN=... (voir étape 8)
|
||||
make models
|
||||
|
||||
# 5. Secrets (utilisés à partir de l'étape 3)
|
||||
cp firmware/include/secrets.h.example firmware/include/secrets.h
|
||||
cp server/.env.example server/.env
|
||||
# 6. Mot de réveil openWakeWord (~5 Mo) — optionnel
|
||||
make install-wakeword
|
||||
make models
|
||||
|
||||
# 6. Aide
|
||||
make help
|
||||
# 7. Lancer l'assistant
|
||||
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
|
||||
@@ -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 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 latency # latence du serveur avant/après l'étape 16, sur de vrais enregistrements
|
||||
make transcribe OUT=server/recordings/<fichier>.wav
|
||||
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
|
||||
@@ -166,13 +318,39 @@ make lint # typage pyright
|
||||
|
||||
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
|
||||
|
||||
« 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
|
||||
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
|
||||
é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 » :
|
||||
l'agent affiche `essai : systemctl poweroff (non execute)`.
|
||||
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`.
|
||||
En production, les comptes et droits de l'étape 15 (`mosquitto/acl` contient les règles des agents).
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
SHELL := /bin/bash
|
||||
PREFIX := /opt/voice-agent
|
||||
CONFIG_DIR := /etc/voice-agent
|
||||
CONFIG_DIR := /etc/voice-assistant
|
||||
# Mêmes versions que le serveur (server/pyproject.toml).
|
||||
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)
|
||||
|
||||
log: ## Journal de l'agent installé (Ctrl-C pour quitter)
|
||||
journalctl -u voice-agent -f
|
||||
sudo journalctl -u voice-agent -f
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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 :
|
||||
# sudo systemctl restart voice-agent
|
||||
#
|
||||
@@ -12,7 +12,7 @@
|
||||
id: pc-bureau
|
||||
|
||||
mqtt:
|
||||
host: 192.168.1.104 # adresse IP du broker
|
||||
host: 192.168.1.200 # adresse IP du broker (Raspberry Pi 5)
|
||||
port: 1883
|
||||
username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme
|
||||
password: ""
|
||||
|
||||
@@ -5,7 +5,7 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[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.
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
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
|
||||
(/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.
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
"""
|
||||
|
||||
@@ -252,7 +252,7 @@ class VoiceAgent:
|
||||
def main(argv: list[str]) -> int:
|
||||
"""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.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)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
|
||||
try:
|
||||
|
||||
@@ -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.
|
||||
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)
|
||||
|
||||
/// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/...
|
||||
|
||||
@@ -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
|
||||
/// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire.
|
||||
/// 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;
|
||||
|
||||
/// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ».
|
||||
|
||||
@@ -83,6 +83,10 @@ public:
|
||||
/// Nombre de connexions réussies depuis le démarrage.
|
||||
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:
|
||||
PubSubClient _client;
|
||||
const char* _host;
|
||||
|
||||
@@ -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 — 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éseau Wi-Fi et l’adresse du broker MQTT,"
|
||||
" puis cliquez sur <em>Enregistrer</em>.</p>"
|
||||
"<form method='POST' action='/save'>"
|
||||
"<fieldset><legend>Wi-Fi</legend>"
|
||||
"<label>Ré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é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émarrer</button>"
|
||||
"</form></body></html>";
|
||||
|
||||
const char HTML_OK[] =
|
||||
"<!DOCTYPE html><html lang='fr'><head><meta charset='utf-8'>"
|
||||
"<title>Sauvegardé</title></head>"
|
||||
"<body style='font-family:sans-serif;text-align:center;padding:60px;color:#222'>"
|
||||
"<p style='font-size:3em'>✓</p>"
|
||||
"<h2 style='color:#107c10'>Configuration sauvegardée</h2>"
|
||||
"<p>L’assistant redémarre dans quelques secondes…</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -34,15 +34,21 @@
|
||||
#include "SerialFramer.h"
|
||||
#include "StatusLed.h"
|
||||
#include "VoiceSession.h"
|
||||
#include "WifiConfig.h"
|
||||
#include "WifiLink.h"
|
||||
#include "config.h"
|
||||
|
||||
// Les secrets (Wi-Fi, broker) ne sont jamais versionnés : on les exige ici, avec
|
||||
// un message clair, plutôt qu'une erreur de compilation obscure plus loin.
|
||||
// secrets.h est optionnel : s'il est présent ses valeurs servent de repli si
|
||||
// la NVS est vide. Sans lui, le portail AP/HTTP est lancé au premier démarrage.
|
||||
#if __has_include("secrets.h")
|
||||
#include "secrets.h"
|
||||
#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
|
||||
|
||||
namespace {
|
||||
@@ -63,8 +69,11 @@ DemoDevices devices(PIN_DEMO_LIGHT);
|
||||
SerialFramer framer(Serial);
|
||||
SerialFrameReader reader(Serial);
|
||||
WiFiClient network;
|
||||
WifiLink wifi(WIFI_SSID, WIFI_PASSWORD, DEVICE_ID);
|
||||
MqttLink mqtt(network, MQTT_HOST, MQTT_PORT, DEVICE_ID, MQTT_USER, MQTT_PASSWORD);
|
||||
// cfg DOIT être déclaré avant wifi et mqtt : leurs constructeurs stockent des
|
||||
// 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.
|
||||
uint8_t outgoingChunk[AUDIO_CHUNK_BYTES];
|
||||
@@ -462,6 +471,29 @@ void setup() {
|
||||
devices.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.printf("# format : %lu Hz, 16 bits, mono, chunk %lu ms = %u octets\n",
|
||||
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
|
||||
// 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",
|
||||
WIFI_SSID, MQTT_HOST, MQTT_PORT, MQTT_TOPIC_PREFIX, DEVICE_ID,
|
||||
MQTT_PASSWORD[0] != '\0' ? "oui" : "NON (anonyme)");
|
||||
cfg.ssid(), cfg.mqttHost(), cfg.mqttPort(), MQTT_TOPIC_PREFIX, DEVICE_ID,
|
||||
cfg.mqttPassword()[0] != '\0' ? "oui" : "NON (anonyme)");
|
||||
network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS);
|
||||
mqtt.setPort(cfg.mqttPort()); // synchronise _port avec la valeur chargée par cfg
|
||||
if (!mqtt.begin(onMqttMessage, onMqttConnect)) {
|
||||
Serial.println("# ERREUR : tampon MQTT impossible a allouer");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ allow_anonymous false
|
||||
password_file /mosquitto/config/passwd
|
||||
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).
|
||||
# Un chunk audio fait 1600 octets : 8192 laisse une large marge.
|
||||
# (remplace « message_size_limit », obsolète depuis Mosquitto 2.0)
|
||||
|
||||
+20
-5
@@ -11,7 +11,7 @@ audio:
|
||||
|
||||
# étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/...
|
||||
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
|
||||
keepalive: 30
|
||||
client_id: voice-server # unique sur le broker
|
||||
@@ -42,6 +42,9 @@ stt:
|
||||
compute_type: int8
|
||||
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
|
||||
# é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 »)
|
||||
# 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 »
|
||||
model: pyannote/speaker-diarization-community-1
|
||||
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é).
|
||||
# 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
|
||||
confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui »
|
||||
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)
|
||||
# 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
|
||||
# (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é).
|
||||
@@ -129,10 +142,12 @@ machines:
|
||||
topic_prefix: agent # agent/<machine>/state (retenu), command, result
|
||||
broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local
|
||||
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}
|
||||
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez], does: "J'éteins", verb: éteindre}
|
||||
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer}
|
||||
lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller}
|
||||
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez, shutdown, shut], does: "J'éteins", verb: éteindre}
|
||||
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer, reboot, reboote, rebooter], does: "Je redémarre", verb: redémarrer}
|
||||
lock: {words: [verrouille, verrouiller, verrouillez, lock, locke, locker, loque], does: "Je verrouille", verb: verrouiller}
|
||||
list: # identifiant de l'agent → comment on l'appelle
|
||||
pc-bureau:
|
||||
# Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ».
|
||||
|
||||
@@ -105,6 +105,29 @@ def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None:
|
||||
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:
|
||||
policy.ask("esp32-01", DOOR_OPEN, "Denis")
|
||||
clock.now += 16
|
||||
|
||||
@@ -57,6 +57,10 @@ def with_mac(mac: str = MAC) -> MachinesSettings:
|
||||
("Éteins le P.C. du bureau.", "shutdown"),
|
||||
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
|
||||
("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"),
|
||||
("Le PC du bureau", None),
|
||||
],
|
||||
|
||||
@@ -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 "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()
|
||||
|
||||
@@ -42,6 +42,11 @@ class FakeVad:
|
||||
class FakeDiarizer:
|
||||
def __init__(self) -> None:
|
||||
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:
|
||||
self.lengths.append(len(pcm))
|
||||
@@ -67,10 +72,12 @@ class FakeStt:
|
||||
self.words = words
|
||||
self.calls = 0
|
||||
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.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,
|
||||
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,
|
||||
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,
|
||||
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
|
||||
return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer,
|
||||
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:
|
||||
@@ -324,3 +332,70 @@ def test_sensitive_command_waits_for_yes() -> None:
|
||||
controller=controller, policy=policy).handle(session())
|
||||
assert second is not None and second.command == 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")
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
É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 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.
|
||||
"""
|
||||
|
||||
@@ -136,6 +137,7 @@ def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioS
|
||||
publisher=link,
|
||||
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
|
||||
policy=AccessPolicy(settings.access, settings.home, settings.machines),
|
||||
direct_below_s=settings.diarization.direct_below_s,
|
||||
)
|
||||
return pipeline.handle
|
||||
|
||||
|
||||
@@ -142,8 +142,15 @@ class AccessPolicy:
|
||||
if speaker is not None:
|
||||
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:
|
||||
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire."""
|
||||
def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None,
|
||||
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)
|
||||
if pending is None or self._clock() > pending.expires:
|
||||
return None
|
||||
@@ -152,14 +159,30 @@ class AccessPolicy:
|
||||
said_no = any(word in NO for word in words)
|
||||
if not (said_yes or said_no):
|
||||
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,
|
||||
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)
|
||||
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:
|
||||
return Resolution("D'accord, j'annule.", None)
|
||||
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:
|
||||
"""« ouvrir la porte du salon », « éteindre le PC du bureau »."""
|
||||
return command.asked if isinstance(command, MachineCommand) else request(command, self._home)
|
||||
|
||||
@@ -42,7 +42,9 @@ from voice_server.wake_word import Detection
|
||||
|
||||
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
|
||||
STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -94,6 +95,7 @@ class MqttLink:
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
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)
|
||||
if self._presence is not None:
|
||||
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
|
||||
@@ -165,6 +167,15 @@ class MqttLink:
|
||||
client.subscribe(pattern, qos=1)
|
||||
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,
|
||||
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
|
||||
properties: Properties | None) -> None:
|
||||
|
||||
@@ -61,6 +61,7 @@ class SttSettings:
|
||||
beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis
|
||||
no_speech_threshold: float
|
||||
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)
|
||||
@@ -90,6 +91,7 @@ class DiarizationSettings:
|
||||
model: str # dépôt Hugging Face du pipeline
|
||||
max_speakers: int
|
||||
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)
|
||||
@@ -136,6 +138,7 @@ class AccessSettings:
|
||||
rules: dict[str, dict[str, str]] # appareil → action → niveau minimal
|
||||
confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter
|
||||
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)
|
||||
@@ -284,6 +287,7 @@ def load_settings(path: Path) -> Settings:
|
||||
beam_size=int(stt["beam_size"]),
|
||||
no_speech_threshold=float(stt["no_speech_threshold"]),
|
||||
model_dir=models_dir / "whisper" / str(stt["model"]),
|
||||
cpu_threads=int(stt.get("cpu_threads", 0)),
|
||||
),
|
||||
tts=TtsSettings(
|
||||
voice=str(tts["voice"]),
|
||||
@@ -302,6 +306,7 @@ def load_settings(path: Path) -> Settings:
|
||||
max_speakers=int(diarization["max_speakers"]),
|
||||
# « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1
|
||||
model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1],
|
||||
direct_below_s=float(diarization.get("direct_below_s", 0.0)),
|
||||
),
|
||||
speaker=SpeakerSettings(
|
||||
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()},
|
||||
confirm=tuple(str(item) for item in access.get("confirm") or ()),
|
||||
confirm_timeout_s=float(access["confirm_timeout_s"]),
|
||||
confirm_min_score=float(access.get("confirm_min_score", 0.25)),
|
||||
),
|
||||
machines=_machines(data),
|
||||
recordings_dir=(base / str(storage["recordings_dir"])).resolve(),
|
||||
|
||||
@@ -74,13 +74,19 @@ class SpeechToText:
|
||||
|
||||
began = time.monotonic()
|
||||
# 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.
|
||||
self.transcribe(bytes(sample_rate * 2))
|
||||
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
|
||||
|
||||
def transcribe(self, pcm: bytes) -> Transcript:
|
||||
"""Transcrit le PCM et écarte les segments suspects."""
|
||||
def transcribe(self, pcm: bytes, words: bool = True) -> Transcript:
|
||||
"""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()
|
||||
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||
segments, _ = self._model.transcribe(
|
||||
@@ -91,12 +97,12 @@ class SpeechToText:
|
||||
condition_on_previous_text=False,
|
||||
no_speech_threshold=self._stt.no_speech_threshold,
|
||||
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] = []
|
||||
dropped: list[str] = []
|
||||
words: list[Word] = []
|
||||
found: list[Word] = []
|
||||
for segment in segments: # générateur : le calcul se fait pendant ce parcours
|
||||
text = segment.text.strip()
|
||||
if not text:
|
||||
@@ -105,7 +111,7 @@ class SpeechToText:
|
||||
dropped.append(text)
|
||||
else:
|
||||
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 ())
|
||||
|
||||
return Transcript(
|
||||
@@ -113,5 +119,5 @@ class SpeechToText:
|
||||
audio_s=audio.size / self._sample_rate,
|
||||
elapsed_s=time.monotonic() - began,
|
||||
dropped=tuple(dropped),
|
||||
words=tuple(words),
|
||||
words=tuple(found),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,8 @@ if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'ex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_PHRASES = 64 # réponses gardées en mémoire : ~100 Ko chacune, 6 Mo au plus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Speech:
|
||||
@@ -32,6 +34,10 @@ class TextToSpeech:
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
@@ -53,12 +59,16 @@ class TextToSpeech:
|
||||
self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale)
|
||||
self._resampler = resampler
|
||||
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,
|
||||
time.monotonic() - began, self._voice.config.sample_rate)
|
||||
|
||||
def synthesize(self, text: str) -> Speech:
|
||||
"""Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout."""
|
||||
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] = []
|
||||
rate = self._voice.config.sample_rate
|
||||
for chunk in self._voice.synthesize(text, syn_config=self._config):
|
||||
@@ -66,8 +76,12 @@ class TextToSpeech:
|
||||
rate = chunk.sample_rate
|
||||
signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32)
|
||||
pcm = self._resampler.convert(signal, rate)
|
||||
return Speech(
|
||||
speech = Speech(
|
||||
pcm=pcm,
|
||||
audio_s=len(pcm) / 2 / self._sample_rate,
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ VAD → diarisation → identification → transcription → qui a dit quoi →
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
@@ -30,6 +31,7 @@ from voice_server.voice_activity import VadResult
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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):
|
||||
@@ -43,6 +45,8 @@ class SpeakerSplitter(Protocol):
|
||||
|
||||
def diarize(self, pcm: bytes) -> Diarization: ...
|
||||
|
||||
def embed(self, pcm: bytes) -> np.ndarray: ...
|
||||
|
||||
|
||||
class Identifier(Protocol):
|
||||
"""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 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):
|
||||
"""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):
|
||||
@@ -129,9 +134,12 @@ class VoicePipeline:
|
||||
policy: Policy | None = None,
|
||||
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
direct_below_s: float = 0.0,
|
||||
) -> None:
|
||||
"""
|
||||
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).
|
||||
identifier: identification ; sans diarisation, elle n'a pas d'empreintes.
|
||||
publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul).
|
||||
@@ -152,6 +160,7 @@ class VoicePipeline:
|
||||
self._policy = policy
|
||||
self._read = read
|
||||
self._clock = clock
|
||||
self._direct_below_s = direct_below_s
|
||||
|
||||
def handle(self, result: SessionResult) -> Turn | None:
|
||||
"""Répond à la session ; None si elle était trop courte pour être une question."""
|
||||
@@ -169,29 +178,38 @@ class VoicePipeline:
|
||||
if vad.has_speech:
|
||||
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)
|
||||
# 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).
|
||||
if self._diarizer is not None:
|
||||
elif self._diarizer is not None:
|
||||
diarization = self._diarizer.diarize(vad.speech_pcm)
|
||||
speakers = len(diarization.speakers)
|
||||
logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers,
|
||||
diarization.describe() or "aucun tour", diarization.elapsed_s)
|
||||
turns = diarization.turns
|
||||
verdicts = self._identify(result.device, diarization)
|
||||
transcript = self._stt.transcribe(vad.speech_pcm)
|
||||
transcript = self._stt.transcribe(vad.speech_pcm, words=not direct)
|
||||
else:
|
||||
direct = False
|
||||
logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device,
|
||||
result.session_id, result.levels.peak_dbfs)
|
||||
transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0)
|
||||
if 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,
|
||||
# et ce sont ses droits qui s'appliquent.
|
||||
last = utterances[-1] if utterances 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
|
||||
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
|
||||
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.
|
||||
@@ -209,13 +227,14 @@ class VoicePipeline:
|
||||
stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_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."""
|
||||
if not text:
|
||||
return Reply(NOTHING_HEARD), False
|
||||
if self._policy is not None:
|
||||
# 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:
|
||||
return Reply(resolution.answer, resolution.command), resolution.command is None
|
||||
reply = self._assistant.respond(text, speaker, board)
|
||||
@@ -239,12 +258,30 @@ class VoicePipeline:
|
||||
f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items()))
|
||||
return verdicts
|
||||
|
||||
def _who_said_what(self, result: SessionResult, transcript: Transcript,
|
||||
turns: tuple[SpeakerTurn, ...], verdicts: dict[str, Identification]) -> list[Utterance]:
|
||||
def _identify_directly(self, device: str, pcm: bytes, diarizer: SpeakerSplitter) -> dict[str, Identification]:
|
||||
"""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."""
|
||||
if not transcript.text:
|
||||
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
|
||||
utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0,
|
||||
end_s=transcript.audio_s, score=None)]
|
||||
|
||||
@@ -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:]))
|
||||
Reference in New Issue
Block a user