Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite

État du projet au premier commit :
- firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament),
  mains libres, domotique de démonstration, chien de garde, présence du serveur ;
- serveur Python : sessions, VAD, faster-whisper, pyannote, identification,
  droits par profil, domotique, mot de réveil, Piper ;
- broker Mosquitto : configuration, comptes et ACL (non activés en développement) ;
- étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux.

Étapes 1 à 15 validées. 203 tests, pyright sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-09-30 17:40:55 +02:00
commit d20976fea2
108 changed files with 15022 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Normalisation des fins de ligne : LF dans le dépôt
* text=auto eol=lf
# Fichiers texte explicites
*.c text
*.h text
*.cpp text
*.hpp text
*.py text
*.md text
*.yaml text
*.yml text
*.json text
*.ini text
*.cfg text
*.toml text
*.conf text
Makefile text
# Fichiers binaires : pas de conversion, pas de diff textuel
*.wav binary
*.flac binary
*.mp3 binary
*.ogg binary
*.onnx binary
*.bin binary
*.pt binary
*.png binary
*.jpg binary
*.pdf binary
+69
View File
@@ -0,0 +1,69 @@
# --- Secrets (jamais versionnés) ---
firmware/include/secrets.h
.env
*.env
!.env.example
*.token
hf_token*
*.key
*.pem
*.crt
*.p12
credentials*
passwd
mosquitto/passwd
agent/agent.yaml
mosquitto/*.pwd
# --- Données personnelles (voix = donnée biométrique) ---
docs/test-suivi-perso.txt
recordings/
profiles/
logs/
server/logs/
server/profiles/
server/recordings/
*.wav
*.flac
*.mp3
*.ogg
!tools/assets/*.wav
# --- Modèles téléchargés (lourds) ---
models/
server/models/
*.onnx
*.onnx.json
*.bin
*.pt
*.ckpt
# --- Python ---
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# --- PlatformIO ---
.pio/
firmware/.pio/
.pioenvs/
.piolibdeps/
# --- Éditeurs / système ---
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json.example
.idea/
*.swp
*~
.DS_Store
# --- PlatformIO isolé ---
.pio-venv/
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"platformio.platformio-ide",
"ms-python.python",
"ms-pyright.pyright",
"redhat.vscode-yaml"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
// Copier ce fichier en .vscode/settings.json (ignoré par git).
"python.defaultInterpreterPath": "${workspaceFolder}/server/.venv/bin/python",
"python.analysis.typeCheckingMode": "standard",
"pyright.configFile": "${workspaceFolder}/server/pyrightconfig.json",
"editor.insertSpaces": true,
"editor.tabSize": 4,
"files.eol": "\n",
"[makefile]": {
"editor.insertSpaces": false
},
"platformio-ide.useBuiltinPIOCore": true
}
+768
View File
@@ -0,0 +1,768 @@
# CONTEXT.md — état d'avancement du projet
> Fichier de reprise. À relire **en premier** en cas d'interruption, et à mettre à jour
> **à la fin de chaque étape**. Il décrit ce qui est fait, ce qui est en cours, et les
> décisions techniques déjà prises (pour ne pas les rediscuter).
---
## 1. Projet
Assistant vocal **100 % local** : ESP32 (micro INMP441) → MQTT → serveur Python
(VAD, faster-whisper, diarisation, Piper) → MQTT → ESP32 (ampli MAX98357A).
Prompt de référence : `prompt-assistant-vocal-esp32.md` (rôle, style, feuille de route, conventions).
---
## 2. Feuille de route et avancement
| # | Étape | État | Validé par l'utilisateur |
|---|---|---|---|
| 0 | Ossature du dépôt (arborescence, Makefile, git, README) | **fait** | en attente |
| 1 | Entrée audio ESP32 (I2S RX) + `tools/serial_to_wav.py` | **fait** | **oui (2026-09-29)** |
| 2 | Sortie audio ESP32 (I2S TX) + `tools/wav_to_serial.py` | **fait** | **oui (2026-09-29)** |
| 3 | Mosquitto + topics (Wi-Fi, MQTT, état retenu, testament) | **fait** | **oui (2026-09-29)** |
| 4 | ESP32 → MQTT → serveur (sessions reconstruites en WAV) | **fait** | **oui (2026-09-29)** |
| 5 | Serveur → MQTT → ESP32 (WAV serveur joué par l'ESP32, mode écho) | **fait** | **oui (2026-09-29)** |
| 6 | MVP : PTT → faster-whisper → réponse → Piper → ESP32 | **fait** (serveur seul, firmware inchangé) | **oui (2026-09-29)** |
| 7 | VAD (Silero) | **fait** (serveur seul) | **oui (2026-09-29)** |
| 8 | Diarisation (pyannote) | **fait**, test réel à deux voix réussi | **oui (2026-09-29)** |
| 9 | Identification (embeddings + cosinus + seuil) | **fait** (serveur seul) | **oui (2026-09-30)** |
| 10 | Association locuteur + texte | **fait** (serveur seul) | **oui (2026-09-30)** |
| 11 | Assistant / domotique | **fait** (serveur + firmware) | **oui (2026-09-30)** |
| 12 | Buffer de sortie anti-jitter | **fait** (firmware + banc de gigue) | **oui (2026-09-30)** |
| 13 | Wake word (openWakeWord) | **fait** (serveur + firmware) | **oui (2026-09-30)** |
| 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** |
| 15 | Fiabilisation | **fait** (broker + serveur + firmware) | **oui (2026-09-30)** |
| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | en attente |
| 16 | Optimisation | à faire | — |
**Règle absolue** : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N.
**Exigence ajoutée par l'utilisateur (2026-09-30)** : l'assistant doit aussi pouvoir **commander des
machines Linux du réseau**. Absente de la feuille de route du prompt. Proposition en discussion :
un agent MQTT par machine (liste blanche d'actions définie sur la machine), Wake-on-LAN par le
serveur, état retenu + testament comme l'ESP32 ; actions sensibles seulement après les droits par
profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé : étape 15b** (après droits par profil et authentification MQTT). Liste d'actions et machines à préciser.
---
## 3. Décisions techniques figées
| Sujet | Décision | Raison |
|---|---|---|
| Format audio unique | PCM `s16le`, 16 kHz, mono | compatible Whisper/Silero, simple pour l'ESP32 |
| Taille de chunk | 50 ms = 800 échantillons = **1600 octets** | compromis latence / surcoût protocole |
| Encodage MQTT | bytes bruts, **jamais de Base64** | −33 % de débit, pas de CPU perdu |
| Core Arduino-ESP32 | **3.x** (ESP-IDF 5.x), driver `driver/i2s_std.h` | ancien `driver/i2s.h` déprécié ; plateforme `pioarduino` |
| Plateforme PlatformIO | `pioarduino` 54.03.21 (fork maintenu pour core 3.x) | le paquet officiel `espressif32` est resté en core 2.0.x |
| PlatformIO isolé | venv dédié `.pio-venv`, avec `click==8.1.8` | click ≥ 8.3 casse l'esptool 5.0 du core 3.x (erreur `get_metavar() missing ctx`) ; évite aussi tout conflit avec les dépendances du serveur |
| Périphériques I2S | I2S0 = micro (RX), I2S1 = ampli (TX) | duplex séparé, pas de conflit d'horloges |
| Broches | voir `firmware/include/config.h` | aucune broche de strapping (0, 2, 5, 12, 15) sur l'audio |
| Broker de dev : conteneur `mosquitto_services-mosquitto-1`, compose `~/srv/docker-compose.mosquitto.yml`, config montée `~/srv/mosquitto/config/mosquitto.conf` ; **IP du PC = 192.168.1.104** (et non .0.104) | 2026-09-29 | constaté sur la machine |
| MQTT : état JSON **retenu** + testament `{"status":"offline"}` retenu QoS 1, keepalive 10 s | 2026-09-29 | `offline` en 15 s max après coupure ; serveur informé même s'il démarre après l'ESP32 |
| Wi-Fi : `setSleep(false)` | 2026-09-29 | le modem endormi ajoute 100 ms+ de latence : incompatible avec le flux audio |
| Partitions `huge_app.csv` (3 Mo d'application, pas d'OTA) | 2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut |
| Sortie par MQTT : START/END sur `control` (QoS 1), PCM sur `audio/out` (QoS 0), **300 ms d'avance** puis 1 chunk / 50 ms | 2026-09-29 | FIFO ESP32 de 512 ms ; la carte publie `played` (reçus/annoncés, sous-alimentations) |
| Tout audio envoyé à l'ESP32 : **16 kHz mono, crête à −3 dBFS** (`AudioResampler`, soxr) | 2026-09-29 | ESP32 simple ; règle le volume faible noté à l'étape 2 ; Piper (22,05 kHz) passera par là |
| Firmware : lecture regroupée dans `PlaybackStream` (série ou MQTT), commandes dans `CommandHandler` (ArduinoJson 7.2.1) | 2026-09-29 | un seul chemin de lecture ; les commandes s'enrichiront à l'étape 11 |
| Capture : micro → **tâche de capture** → FIFO 1 s → `loop()` → MQTT | 2026-09-29 | `publish()` peut bloquer sur un Wi-Fi faible ; le DMA du micro n'a que 75 ms de réserve |
| Perte détectée par le **nombre de chunks du END** (pas de numéro de séquence avant l'étape 16) | 2026-09-29 | audio en QoS 0 ; le serveur compare reçus / annoncés après 0,5 s de grâce |
| **Limite connue** : PubSubClient ne publie qu'en **QoS 0**, `event` compris (le prompt demande QoS 1) | 2026-09-29 | serveur tolérant (session sans END close après 5 s d'inactivité). **Revu à l'étape 15 : conservé** (aucune perte mesurée sur le réseau local ; esp-mqtt = réécriture de `MqttLink` et de ses rappels, sans gain observable) |
| Session sans broker → liaison série, format de l'étape 1 | 2026-09-29 | `make record` reste un outil de dépannage |
| Sortie : **stéréo, même échantillon sur les deux voies** | 2026-09-29 | le MAX98357A joue (G+D)/2 : niveau exact, inversion G/D sans effet ; on ne mise plus sur un mode mono implicite |
| Sortie : canal I2S1 **toujours actif**, `auto_clear` → silence quand rien à jouer | 2026-09-29 | broches de l'ampli jamais flottantes (cause du souffle initial) |
| Lecture : réception → **FIFO** (StreamBuffer statique 512 ms) → **tâche audio** → I2S | 2026-09-29 | architecture exigée pour MQTT (callback court) ; la tâche est rythmée par le DMA |
| FIFO : écriture **tout ou rien** par chunk | 2026-09-29 | un chunk coupé = octet impair = tout le reste désaligné |
| Protocole série **partagé** : `SerialProtocol.h` ↔ `tools/serial_protocol.py` | 2026-09-29 | une seule définition par langage ; test croisé encodeur Python → décodeur C++ |
| Passe-haut micro **80 Hz**, 1er ordre, calculé en **24 bits** (`MIC_HPF_SHIFT = 5`) | 2026-09-29 | 87 % de l'énergie d'un enregistrement était infrasonore (plaque qui fléchit sous le doigt, souffle) ; 0,6 % après filtre, voix 99 % |
| Cadrage micro : **MSB** (24 bits utiles), mesuré, pas supposé | 2026-09-29 | en Philips, le bit de signe déborde dans le mot précédent (23 bits + `00000001`) ; l'auto-test bascule seul si un autre module est Philips |
| Les horloges du micro ne s'arrêtent **jamais** après le démarrage | 2026-09-29 | l'INMP441 s'endort sans horloge ; chaque réveil = dérive lente de la composante continue (+23 % FS encore à 600 ms) |
| Lecture I2S stéréo : la **phase de trame** est suivie entre les appels | corrigé le 2026-09-29 | `i2s_channel_read()` peut rendre un nombre **impair** de mots ; sans suivi, les deux moitiés s'échangent en cours de route et on enregistre la moitié muette |
| Micro : SCK → **GPIO 25**, WS → **GPIO 26** | corrigé le 2026-09-29 | le schéma initial les avait inversés ; aucune raison technique ne départage 25 et 26, la doc a été alignée sur le banc réel |
| Liaison série étape 1 | **921 600 baud**, trames `VSA1` + longueur | 32 kB/s d'audio impossible à 115 200 baud |
| Conversion micro | mot I2S 32 bits → `>> 16`, gain ×4, offset DC glissant | l'INMP441 place 24 bits utiles en tête de mot |
| Licence | MIT | choix par défaut du prompt |
| Extra `speech` : **faster-whisper 1.2.1**, **piper-tts 1.8.0** (au lieu de 1.0.3 / 1.2.0) | 2026-09-29 | Python 3.13 : faster-whisper 1.0 exige `av<13` (compilation depuis les sources, échoue sans les en-têtes ffmpeg) ; piper-tts 1.2 dépend de `piper-phonemize` sans roue 3.13. piper-tts ≥ 1.3 embarque espeak-ng. API Piper : `PiperVoice.load()`, `synthesize()` → `AudioChunk.audio_float_array` |
| Modèles **locaux** dans `server/models/` : `whisper/<model>/`, `piper/<voice>.onnx(.json)`, via `make models` (`tools/download_models.py`) | 2026-09-29 | aucun accès réseau au démarrage du serveur ; ~550 Mo ; ignorés par git |
| STT : `small`, `int8`, `beam_size 5`, `condition_on_previous_text=False`, `vad_filter=False` (VAD = étape 7) | 2026-09-29 | mesuré sur ce PC (20 cœurs) : **≈ 1,0 s quelle que soit la phrase (1 à 3 s)**, le nombre de fils (0/8/16) ou le beam (1/5) : Whisper encode toujours une fenêtre de 30 s |
| Session de crête < **−45 dBFS** (`stt.min_peak_dbfs`) : Whisper n'est pas appelé, la carte dit « Je n'ai rien entendu. » | 2026-09-29 | mesuré : 2 s de silence numérique = **4,8 s** de calcul (repli sur les températures) |
| Hallucinations connues écartées (`KNOWN_HALLUCINATIONS` : Amara.org, sous-titres…) | 2026-09-29 | mesuré : 1 s de bruit gaussien → « Sous-titres réalisés par la communauté d'Amara.org » |
| Imports faster-whisper / Piper **différés** (dans `__init__`) | 2026-09-29 | `make run-echo` et les tests fonctionnent sans l'extra `speech` |
| Assistant à règles (`assistant.py`) : heure, date, nom, bonjour, merci ; sinon « Vous avez dit : … » ; heure écrite « une heure », « midi », « 19 heures 5 » | 2026-09-29 | Piper lit « 1 heure » = « un heure » ; Whisper écrit parfois « Quel heure » (mots-clés tolérants) |
| VAD : **Silero ONNX livré par faster-whisper** (`faster_whisper.vad.get_vad_model`), segmentation écrite dans `voice_activity.py` (hystérésis 0,5 / 0,35, pause 500 ms, parole ≥ 250 ms, marge 150 ms) | 2026-09-29 | pas de torch ; segmentation testable sans modèle ; 2 à 4 ms par session |
| VAD **sur la session entière** (après le END), pas en flux | 2026-09-29 | suffisant en PTT ; la détection de fin de parole en flux servira au mode mains libres (étape 13) |
| La VAD **remplace le seuil de crête** `stt.min_peak_dbfs` (supprimé) | 2026-09-29 | l'appui muet de l'étape 6 (crête −30,7 dBFS) passait le seuil ; la VAD le rejette |
| Diarisation : **pyannote.audio 4.0.7**, torch **2.14.0+cpu** / torchaudio 2.11.0+cpu (index pytorch.org), modèle `pyannote/speaker-diarization-community-1` (33 Mo) | 2026-09-29 | Python 3.13 ; sans l'index CPU, pip tire torch CUDA (plusieurs Go de `nvidia-*`) ; pyannote 3.3.2 antérieur à ce torch |
| **numpy 2.1.3 → 2.5.3** (dépendance de base) | 2026-09-29 | pyannote-metrics 4 exige numpy ≥ 2.2.2 ; 78 tests repassés avec 2.5.3 (soxr, onnxruntime, ctranslate2 OK) |
| **Télémétrie pyannote coupée** (`PYANNOTE_METRICS_ENABLED=false`, forcé par `disable_telemetry()` avant l'import) | 2026-09-29 | activée par défaut en 4.x : envoie durée et nombre de locuteurs à `otel.pyannote.ai` ; variable relue à chaque appel |
| Modèle pyannote **copié dans `models/pyannote/<nom>/`** par `make models` (`snapshot_download`, `HF_TOKEN` de `server/.env`), chargé depuis ce dossier | 2026-09-29 | les sous-modèles sont des `$model/…` relatifs : aucun appel au hub à l'exécution. `HF_HUB_OFFLINE` non utilisé : lu à l'import de huggingface_hub, que faster-whisper a déjà fait |
| Diarisation **sur `vad.speech_pcm`**, le même audio que Whisper, **en séquence** avant Whisper | 2026-09-29 | instants communs pour l'association locuteur/texte (étape 10) ; parallélisation → étape 16 |
| `diarization.enabled` dans config.yaml ; activée mais absente → le serveur refuse de démarrer avec la marche à suivre | 2026-09-29 | Raspberry Pi 4 : `false` ; pas de repli silencieux |
| pyannote 4 rend **une empreinte vocale par locuteur** (`speaker_embeddings`, WeSpeaker ResNet34), conservée dans `Diarization.embeddings` | 2026-09-29 | candidate pour l'identification (étape 9), à comparer à SpeechBrain ECAPA prévu par le prompt ; `speechbrain` retiré de l'extra `diarization` |
| Empreintes : **modèle `embedding/` du pipeline pyannote (WeSpeaker ResNet34, 256 dim.)** via `Inference(window="whole")` sur toute la parole de chaque locuteur (tours de `exclusive_speaker_diarization` mis bout à bout), 66 ms | 2026-09-29 | **les `speaker_embeddings` de pyannote sont NaN sous ~2 s de parole** : il ne garde que les fenêtres de 10 s où le locuteur parle seul ≥ 20 % (`filter_embeddings`, `min_active_ratio=0.2`) ; toutes les questions PTT étaient NaN |
| Tours de parole : version **exclusive** (sans chevauchement) | 2026-09-29 | une voix par empreinte ; prête pour le découpage du texte (étape 10) |
| Seuil cosinus **0,45** (remplace 0,75) | 2026-09-29 | mesuré : profil de 5 sessions contre 23 autres sessions de l'utilisateur **0,52–0,83** (0,52 = 0,9 s de parole) ; Piper direct ou par haut-parleur **−0,08 à 0,11**. À recalibrer avec de vraies autres personnes (étape 14) |
| Profils : `server/profiles/<nom>.json` (nom, modèle, nb de sessions, date, empreinte normalisée), écriture atomique, **relus quand le dossier change** | 2026-09-29 | JSON lisible (on sait ce qu'on stocke) ; un profil d'un autre modèle est ignoré ; enrôlement pris en compte sans redémarrer |
| Enrôlement (`make enroll NAME=`, `tools/enroll.py`) : ≥ 3 sessions de ≥ 1 s, une seule voix, **écart des sessions qui ne ressemblent pas aux autres** (cosinus avec la moyenne des autres < seuil) | 2026-09-29 | la session mélangée `093463` (voix + haut-parleur, vue comme 1 locuteur par pyannote) est écartée à 0,27–0,32 |
| Identification **seulement avec la diarisation** (pas d'empreinte sinon) | 2026-09-29 | choix de l'utilisateur (empreintes pyannote plutôt que SpeechBrain) |
| Qui a dit quoi : **`word_timestamps=True`** dans Whisper, chaque mot au tour de diarisation qui le recouvre le plus (sinon le plus proche), mots consécutifs d'un même locuteur regroupés | 2026-09-30 | mesuré : +0,07 à 0,14 s (5 à 10 %) ; les bornes des mots tombent sur celles des tours (même audio pour les deux) |
| Topic **`voice/<carte>/transcript`** (serveur → abonnés, QoS 1, non retenu) : `{"session", "utterances": [{"speaker", "text", "start", "end", "score"}]}` | 2026-09-30 | format de la feuille de route + instants et score pour la domotique (étape 11) et les droits (étape 14) ; le serveur ne s'y abonne pas |
| L'assistant reçoit le **nom de la dernière personne qui a parlé** (`reply(text, speaker)`) : « Bonjour Denis ! », « Avec plaisir, Denis. » | 2026-09-30 | c'est elle qui attend la réponse ; `None` si inconnue |
| Domotique : commandes sur **`home/<pièce>/<appareil>/set`** (QoS 1, **non retenu**) **et**, si la pièce est celle de la carte, `voice/<carte>/control` `{"cmd":"device",...}` exécuté par l'ESP32 (choix de l'utilisateur : « les deux », en simulation) | 2026-09-30 | toute domotique peut s'abonner ; l'ESP32 « exécute la commande éventuelle » (prompt) ; une commande retenue serait rejouée à un abonné tardif |
| Analyse par **mots-clés** (`home_control.py`) : action × appareil × pièce ; « allume/éteins » seuls = lumière ; sans pièce = pièce de la carte (`home.boards`) ; appareil/action incompatibles ou incomplets → question | 2026-09-30 | suffisant pour 3 appareils ; tolère « fermé » (Whisper) ; limites : 1 appareil et 1 pièce par phrase, faux positifs possibles (« je porte… ») |
| Identifiants en anglais dans les topics et le JSON (`light`, `shutter`, `door`, `on/off/open/close`) ; pièces = noms de config.yaml, en slug (`salle-de-bain`) | 2026-09-30 | mêmes chaînes côté Python et firmware |
| Firmware : **`DemoDevices`**, LED sur **GPIO 21** (330 Ω), volets et porte simulés ; confirmation `{"event":"device","ok":...,"state":...}` journalisée par le serveur | 2026-09-30 | GPIO 21 libre, hors strapping ; la boucle commande → exécution → confirmation est visible |
| La commande part **avant** la réponse parlée | 2026-09-30 | la lumière s'allume pendant que la carte le dit |
| `normalize` déplacé dans `text_normalize.py` | 2026-09-30 | commun à l'assistant et à la domotique, sans import circulaire |
| Météo : réponse honnête « je fonctionne sans Internet » | 2026-09-30 | plutôt que répéter la question |
| Anti-gigue : **amorçage à 300 ms** (`PLAYBACK_START_MS`) avant le premier échantillon, **réamorçage** après une famine (pause propre plutôt que bribes), son plus court que la réserve joué dès le END | 2026-09-30 | 300 ms = 6 chunks = avance envoyée d'emblée par le serveur : la lecture démarre dès qu'elle est arrivée ; valeur de l'exemple du prompt |
| `underruns` du bilan = **réamorçages** (famines en plein flux) ; nouveaux champs `start_ms` (délai du premier son) et `min_margin_ms` (plus petite réserve mesurée) | 2026-09-30 | l'ancien compteur comptait chaque bloc incomplet, y compris au démarrage : 6 à 10 par lecture sans aucune perte |
| Banc de gigue : `--jitter MS` / `make tone-mqtt JITTER=MS` retient **1 chunk sur 10** (toutes les 500 ms) pendant MS ms, puis rattrapage en rafale | 2026-09-30 | scénario déterministe : marge attendue ≈ 300 − MS ms, réamorçages seulement au-delà de 300 ms ; prouver plutôt qu'attendre un mauvais Wi-Fi |
| Mot de réveil : **openWakeWord 0.6.0 en ONNX**, installé `--no-deps` (exige `tflite-runtime`, absent pour Python 3.13) ; modèles `alexa`, `hey_jarvis`, `hey_mycroft` v0.1 + melspectrogram/embedding, depuis les releases GitHub v0.5.1 | 2026-09-30 | mesuré : « Alexa » (Piper FR) 0,92–1,0 ; « Hé Maïcrofte » 1,0 ; « Hey Djâr-viss » 0,74 ; lus à la française Jarvis/Mycroft ≈ 0 ; **0 fausse alerte sur 90 enregistrements réels** (max 0,07) ; ~1 ms par chunk |
| Flux continu **`voice/<carte>/audio/stream`** (QoS 0), activé par le serveur (`cmd listen`) à la connexion/redémarrage de la carte, coupé à l'arrêt du serveur ; **jamais pendant une réponse** (écho, Wi-Fi) ni pendant un appui (le bouton reprend la main) | 2026-09-30 | le serveur décide ; la carte reste simple |
| `HandsFreeListener` : VEILLE (préroll 500 ms **en mémoire seulement**) → COMMANDE (fin = 800 ms de silence après de la parole qui dépasse le mot de 0,3 s ; abandon après 4 s sans commande ; max 10 s) → RÉPONSE (jusqu'au bilan `played`, garde 20 s) ; la commande devient une `SessionResult` ordinaire | 2026-09-30 | aucune autre partie de la chaîne ne change |
| Délais d'une commande mesurés en **audio reçu**, pas à l'horloge | 2026-09-30 | trouvé en simulation : en rafale Wi-Fi, 2 s de son peuvent arriver en 10 ms ; l'horloge ne sert qu'aux gardes (réponse sans bilan, flux perdu 2 s) |
| LED : éclair 100 ms / 2 s = écoute continue ; allumée = commande en cours (`cmd capture`) ; `StatusLed::blink(period, onMs)` | 2026-09-30 | l'utilisateur voit quand le micro part sur le réseau |
| Droits : **complet > standard > limite** (`access_control.py`) ; inconnu = limite, enrôlé sans niveau = standard, `users: {Denis: complet}` | 2026-09-30 | niveaux du prompt ; conversation toujours permise |
| Niveau complet seulement si **score ≥ 0,6** (`full_min_score`), sinon rétrogradé en standard | 2026-09-30 | la voix n'est pas une preuve : une identification de justesse ne doit pas tout ouvrir |
| Règles par appareil/action ; **action absente = niveau complet** | 2026-09-30 | on ne permet jamais par oubli |
| Actions sensibles (`confirm: [door.open]`) : question, puis « oui »/« non » **de la même personne**, sur la même carte, en 15 s ; toute autre phrase abandonne la demande | 2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) |
| Droits appliqués au **dernier locuteur** de la session | 2026-09-30 | c'est lui qui attend la réponse (cohérent avec l'étape 10) |
| Broker : `allow_anonymous false` + `password_file` + `acl_file` ; comptes `voice-server` (voice/# et home/# en lecture-écriture) et **un par carte, nom = DEVICE_ID** (`pattern ... voice/%u/...`) | 2026-09-30 | micro inaudible et commandes impossibles pour le reste du réseau ; une carte ne peut ni écouter ni usurper une autre (vérifié sur broker jetable) |
| Outils `make mqtt-*` : identifiants du serveur relus dans `server/.env` (`-include`), recettes en `@` | 2026-09-30 | pas de compte supplémentaire ; mot de passe jamais affiché par make ; mot de passe hexadécimal (`#`, `$` cassent make) |
| Présence serveur : `voice/server/status` retenu, `online` à chaque connexion, testament `offline`, `offline` publié à l'arrêt propre ; **seulement `MqttLink(presence=True)`** du vrai serveur | 2026-09-30 | ESP32 : serveur absent → `listenWanted=false`, plus de flux micro ; les outils (wav_to_mqtt) ne doivent pas faire croire au départ du serveur |
| Topic de présence hors motif `voice/+/<suffixe>` des abonnements serveur | 2026-09-30 | sinon le serveur se prendrait pour une carte (écoute activée sur voice/server/control) |
| Chien de garde ESP32 : TWDT reconfiguré **15 s, panique** ; loop (`enableLoopWDT`), tâches capture et audio inscrites ; réarmé entre deux envois réseau | 2026-09-30 | `NetworkClient::write` peut attendre 10 × 1 s (lu dans le core 3.x) : 5 s par défaut redémarrerait sur un Wi-Fi faible |
| MQTT reste dans `loop()` (pas de tâche dédiée) ; connexion TCP 1 s (`setConnectionTimeout`) + CONNACK 2 s = 3 s de blocage au pire, broker absent seulement | 2026-09-30 | PubSubClient pas prévu pour plusieurs tâches ; audio dans ses propres tâches |
| Wi-Fi relancé (`disconnect` + `begin`) après 30 s d'absence | 2026-09-30 | filet si la reconnexion automatique n'aboutit pas |
| Journal serveur copié dans `logging.file` (`RotatingFileHandler` 1 Mo × 4), `logs/` ignoré par git | 2026-09-30 | Raspberry Pi sans écran ; les transcriptions y figurent = données personnelles |
| Machines : **agent MQTT par machine** (`agent/voice_agent.py`, autonome : paho-mqtt + PyYAML), config YAML sur la machine | 2026-09-30 | demande utilisateur (config YAML) ; MQTT = transport unique (§3.7) ; SSH écarté : clés sur le serveur, liste blanche côté serveur seulement |
| Liste blanche **sur la machine** : action = commande fixe en **liste** (pas de shell), le serveur n'envoie qu'un **nom** ; nom inconnu refusé | 2026-09-30 | un message forgé ne peut rien lancer d'autre ; config refusée si `command` est une chaîne |
| Topics `agent/<id>/state` (retenu, **actions annoncées**, testament), `command` (QoS 1, non retenu, **session propre**), `result` | 2026-09-30 | le serveur sait si la machine répond et ce qu'elle accepte ; aucune commande rejouée au redémarrage |
| « Allume » = **Wake-on-LAN** envoyé par le serveur (paquet magique UDP port 9, `broadcast` configurable), si agent hors ligne et MAC connue | 2026-09-30 | machine éteinte = pas d'agent |
| Phrase qui **nomme une machine** traitée avant la domotique ; noms de plusieurs mots (« pc du bureau ») | 2026-09-30 | « éteins le PC du bureau » ≠ « éteins la lumière du bureau » (bureau est une pièce) |
| Droits : `access.rules.machine` (wake/lock standard, shutdown/reboot complet), `confirm: [machine.shutdown, machine.reboot]` ; `MachineCommand` porte ses phrases (`said`, `asked`) | 2026-09-30 | réutilise étape 14 sans changer `DeviceCommand` ; `CommandRouter` aiguille vers Home/MachineController |
| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-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 |
| `dry_run` dans agent.yaml ; `make agent-run` crée `agent/agent.yaml` en mode essai | 2026-09-30 | tester « éteins le PC » sur le PC de développement sans l'éteindre |
| **TLS non activé** (option de l'étape 15) ; **numéros de séquence** reportés à l'étape 16 (§3.4 du prompt : « phase 16 ») | 2026-09-30 | comptes + ACL ferment l'accès au micro ; TLS = certificats + ~40 Ko de RAM ESP32, à décider par l'utilisateur |
| `make run` = **mode assistant** ; `make run-echo` = étape 5 | 2026-09-29 | une file + un fil de travail : les sessions sont traitées une à une, hors du fil réseau |
---
## 4. Fichiers existants (et qui les utilise)
```
docs/BROCHAGE.md brochage, consommation, vérifications multimètre
docs/wiring.svg schéma de câblage complet (source versionnable)
firmware/include/config.h constantes matérielles + audio (étapes 1→16)
firmware/include/secrets.h.example modèle Wi-Fi / MQTT (utilisé à partir de l'étape 3)
firmware/src/MicCapture.* I2S RX stéréo + conversion 32→16 bits [étape 1]
firmware/src/MicSelfTest.* auto-test du micro au démarrage [étape 1]
firmware/src/PushButton.* bouton PTT anti-rebond [étape 1]
firmware/src/StatusLed.* LED d'état [étape 1]
firmware/src/SerialProtocol.h format des trames, partagé [étapes 1-2]
firmware/src/SerialFramer.* émission des trames vers le PC [étape 1]
firmware/src/SerialFrameReader.* réception des trames du PC [étape 2]
firmware/src/AudioFifo.* FIFO réception → tâche audio (StreamBuffer) [étape 2]
firmware/src/AudioOutput.* I2S1 TX vers MAX98357A, bip de test [étape 2]
firmware/src/WifiLink.* Wi-Fi station, non bloquant [étape 3]
firmware/src/MqttLink.* PubSubClient, testament, reconnexion [étape 3]
firmware/src/VoiceSession.* START / chunks / END, MQTT ou série [étape 4]
firmware/src/PlaybackStream.* flux de lecture + tâche audio + bilan [étape 5]
firmware/src/CommandHandler.* JSON du topic control (start/end/ping) [étape 5]
server/voice_server/audio_resampler.py WAV quelconque -> 16 kHz mono -3 dBFS [étape 5]
server/voice_server/audio_sender.py START / chunks rythmés / END vers une carte [étape 5]
tools/wav_to_mqtt.py WAV ou son pur -> carte, avec bilan [étape 5]
server/voice_server/settings.py config.yaml + .env [étape 4]
server/voice_server/mqtt_link.py paho-mqtt 2.x, abonnement voice/+/... [étape 4]
server/voice_server/audio_buffer.py PCM borné, niveaux, écriture WAV [étape 4]
server/voice_server/session_manager.py sessions, pertes, délais, WAV [étape 4]
server/voice_server/__main__.py point d'entrée : make run [étape 4]
server/voice_server/speech_to_text.py SpeechToText (faster-whisper) + Transcript [étape 6]
server/voice_server/text_to_speech.py TextToSpeech (Piper → 16 kHz) + Speech [étape 6]
server/voice_server/assistant.py Assistant à règles (heure, date…) [étape 6]
server/voice_server/voice_pipeline.py VoicePipeline : STT → réponse → TTS → envoi [étape 6]
tools/download_models.py make models [étape 6]
tools/speech_check.py make speech-check / make transcribe [étapes 6-7]
server/voice_server/voice_activity.py VoiceActivityDetector (Silero), find_segments [étape 7]
tools/vad_check.py make vad / make vad-all [étape 7]
server/voice_server/diarization.py Diarizer (pyannote), SpeakerTurn, timeline [étape 8]
tools/diarize_check.py make diarize / make diarize-demo [étapes 8-10]
server/voice_server/speaker_identifier.py SpeakerIdentifier, profils, cosinus [étape 9]
server/voice_server/speaker_attribution.py attribute, Utterance, transcript_json [étape 10]
server/voice_server/home_control.py parse_intent, decide, HomeController [étape 11]
server/voice_server/text_normalize.py normalize (commun) [étape 11]
firmware/src/DemoDevices.* LED GPIO 21 + volets/porte simulés [étape 11]
server/voice_server/wake_word.py WakeWordDetector (openWakeWord ONNX) [étape 13]
server/voice_server/hands_free.py HandsFreeListener : veille / commande / réponse [étape 13]
server/voice_server/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
tools/enroll.py make enroll / profiles / forget [étape 9]
tools/say.py make say : phrase Piper en WAV + MP3 (téléphone) [étape 14]
docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3]
firmware/src/main.cpp boucle PTT → I2S → série [étape 1]
tools/serial_protocol.py format des trames, partagé [étapes 1-2]
tools/serial_to_wav.py réception des trames série → WAV [étape 1]
tools/wav_inspect.py enveloppe ASCII et niveaux d'un WAV [étape 1]
tools/wav_to_serial.py WAV ou son pur → ESP32, au rythme réel [étape 2]
mosquitto/voice.conf configuration du broker [étape 3]
mosquitto/acl droits par topic : serveur, une carte = ses topics [étape 15]
mosquitto/passwd comptes hachés (make mqtt-user), NON versionné [étape 15]
server/tests/test_mqtt_link.py présence, refus du broker, journal fichier [étape 15]
server/voice_server/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
server/voice_server/command_router.py CommandRouter : domotique / machines [étape 15b]
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/voice-agent.service, agent/Makefile service systemd, make -C agent install [étape 15b]
server/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
server/ squelette Python (modules à partir de l'étape 3)
Makefile toutes les opérations système
```
**Non encore écrits** (volontairement, par étape) : optimisation et numéros de séquence (16),
TLS (option de l'étape 15, non activée).
---
## 5. Reprise après incident
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`.
### État au 2026-09-29 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.
### Reprise de l'étape 9 (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": "..."}`).
---
## 6. Vérifications déjà passées (sans matériel)
| Contrôle | Commande | Résultat |
|---|---|---|
| Compilation du firmware | `make fw-build` | **SUCCESS**, 0 warning avec `-Wall` ; RAM 7,2 % (23 536 o), Flash 24,7 % (324 182 o) |
| Tests Python | `make test` | **162 tests** passés (dont le vrai pipeline pyannote et openWakeWord) (trames, sessions, sortie, assistant, boucle, VAD, diarisation, vrais modèles) |
| Typage Python | `make lint` | 0 erreur, 0 warning (pyright, mode `standard`) |
| Chaîne série → WAV | port série simulé, sinus 440 Hz 1 s | WAV 16 kHz / 16 bits / mono, 16 000 trames, crête 48,8 % |
Reste à valider **avec le matériel** : câblage, niveau du micro, intelligibilité.
## 7. Git
Dépôt initialisé le 2026-09-30 (branche `main`, aucun dépôt distant). Identité **locale au dépôt** :
Denis Defolie <denis.defolie@gmail.com>. Commits et push seulement à la demande de l'utilisateur.
Vérifié avant le premier commit : `secrets.h`, `.env`, `mosquitto/passwd`, `agent/agent.yaml`,
profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements virtuels sont ignorés.
`docs/test-suivi-perso.txt` (notes personnelles) ignoré à la demande de l'utilisateur ; ancien doublon
`firmware/secrets.h.example` (modèle périmé, sans vrai secret) supprimé à sa demande.
## 8. Journal
- **2026-09-27** — Création du dépôt, ossature complète, étape 1 écrite (I2S RX + `serial_to_wav.py`).
Compilation, tests et typage vérifiés. En attente de validation matérielle par l'utilisateur.
- **2026-09-27** — Ajout de `docs/BROCHAGE.md` et `docs/wiring.svg` (schéma de câblage
complet : micro, ampli, HP, bouton, LED, rails d'alimentation). Point ouvert reporté à
l'étape 2 : si le niveau sonore est deux fois trop faible, forcer le mode « canal gauche »
du MAX98357A par un 100 kΩ de `SD` vers GND.
- **2026-09-28** — `docs/BROCHAGE.md` §6 : types et valeurs des condensateurs
(C1 = 470 µF 16 V électrolytique faible ESR, C2 = 100 nF X7R, C3 = 100 nF anti-rebond
facultatif), bornes mini/maxi justifiées par le calcul et lecture du marquage à 3 chiffres.
Schéma mis à jour : C1 et C2 dessinés côte à côte sur le `Vin` de l'ampli.
- **2026-09-29** — Matériel branché par l'utilisateur : le HP soufflait en permanence. Cause : à l'étape 1 les broches BCLK/LRC/DIN sont flottantes et le MAX98357A (SD tiré au + par 100 kΩ) amplifie ce bruit. Correctif : `silenceAmplifier()` dans `main.cpp`, appelé en tout premier dans `setup()`, force les trois broches à 0 → veille automatique de l'ampli. Firmware recompilé (SUCCESS). Reste le bruit pendant les ~300 ms de boot : option matérielle `SD` → GND, ou piloter `SD` par un GPIO à l'étape 2 (résout aussi le « pop » d'activation).
- **2026-09-29** — Premier enregistrement réel : crête sous 2 %. `MIC_GAIN` passé de 4 à **16** (dimensionnement : INMP441 à -26 dBFS/94 dB SPL, voix à 30 cm ≈ 80 dB SPL crête → -40 dBFS ≈ 1 % ; ×16 → ~16 %, soit 16 dB de marge). `tools/serial_to_wav.py` affiche désormais crête + RMS + offset continu en dBFS, distingue « aucun signal » (câblage) de « niveau faible » (gain) et propose le facteur correctif. 6 tests, pyright propre, firmware recompilé.
- **2026-09-29** — L'utilisateur confirme le câblage. Ajout d'un **auto-test matériel** dans le firmware : `MicCapture::probe()` / `stats()` relèvent min/max/zéros des **mots I2S bruts**, `reportMicHealth()` (main.cpp) imprime le verdict sur la série au démarrage (bruit ambiant) et à la fin de chaque session (parole, avec la valeur de `MIC_GAIN` conseillée). Distingue : aucun mot lu (I2S muet) / mots figés (L/R ou SD) / amplitude mesurable (question de gain). `make fw-upload && make fw-monitor` suffit désormais à diagnostiquer sans le PC côté WAV.
- **2026-09-29** — **Cause trouvée** : l'auto-test renvoyait 39 200 mots I2S *tous exactement nuls*, avec deux micros différents et un câblage vérifié. Signature de la lecture de la mauvaise moitié de trame : l'INMP441 sort des zéros pendant la moitié qu'il n'utilise pas, et la correspondance `L/R` ↔ `I2S_STD_SLOT_LEFT/RIGHT` n'est pas celle attendue sur l'ESP32 d'origine. Correctif : `MicCapture::useSlot()` (reconfiguration à chaud via `i2s_channel_reconfig_std_slot`) et `selectMicSlot()` dans `main.cpp`, qui écoute les deux moitiés au démarrage et garde celle qui porte du signal. **Décision figée : ne jamais supposer le mapping des slots I2S sur ESP32, le mesurer.**
- **2026-09-29** — Les deux moitiés de trame sont nulles → la piste « slot » ne suffit pas. Vérification faite dans les en-têtes ESP-IDF installés (`framework-arduinoespressif32-libs/esp32/include/esp_driver_i2s/.../i2s_std.h`) : la configuration Philips 32 bits mono est correcte pour l'ESP32, le bug n'est pas là. Deux corrections : (1) l'auto-test affichait `amplitude 0` sans distinguer « mots nuls » de « aucun mot lu » → il imprime désormais le **nombre de mots** ; (2) `selectMicConfiguration()` explore les **4 combinaisons** {SCK/WS normaux, permutés} × {slot gauche, droite}, car une permutation SCK/WS donne exactement le même symptôme (le micro reçoit 16 kHz au lieu de 1 MHz et n'émet rien). En cas d'échec des 4, le firmware imprime un protocole de mesure au voltmètre et laisse les horloges tourner sur les broches du schéma.
- **2026-09-29** — **Le micro capte.** Session réelle : 24 000 mots, amplitude 1 547 300 LSB24 (**-14 dBFS brut**), soit 6 044 LSB sur 16 bits avant gain. `MIC_GAIN` passé de 16 à **1** (crête ≈ 18 % de pleine échelle). Le calcul théorique datasheet (-26 dBFS @ 94 dB SPL → -40 dBFS attendus) sous-estimait le niveau réel de **26 dB**. **Décision figée : pour les niveaux audio, mesurer sur le banc, ne pas extrapoler du datasheet.** Point à confirmer : quelle combinaison a été retenue par `selectMicConfiguration()` (SCK/WS normaux ou permutés) — si permutés, corriger le câblage ou le schéma.
- **2026-09-29** — **Fausse piste corrigée.** L'auto-test avait retenu « SCK/WS permutés » avec une amplitude de 16,7 M LSB24, soit ~100 % de la pleine échelle 24 bits (8 388 608) **au repos** : ce n'était pas du signal mais du **bruit pleine échelle d'une ligne de données flottante**. Mon critère « garder la plus grande amplitude » était faux. Trois corrections : (1) `MicCapture` lit désormais en **stéréo** (`I2S_SLOT_MODE_STEREO`) et choisit la moitié de trame en logiciel — la sélection de slot par le périphérique est ambiguë sur l'ESP32 et testait probablement deux fois la même moitié ; (2) convention d'amplitude unifiée (crête = demi-écart crête à crête, en LSB24) ; (3) garde-fou `NOISE_FLOOR_LIMIT` = 50 % de pleine échelle : au repos, au-delà c'est une ligne flottante, la candidate est rejetée et le message désigne `SD`/alimentation. **Décision figée : un niveau proche de la pleine échelle au repos est un défaut, jamais un succès.**
- **2026-09-29** — Mesures utilisateur : **VDD = 3,29 V**, **GPIO 25 et 26 = 1,64 V** (≈ VDD/2, signature d'un carré 50 % → les deux horloges tournent et arrivent). Alimentation et horloges donc écartées ; seule reste la ligne `SD` → GPIO 33. Ajout de `MicCapture::probeDataLine()` : applique le pull-up puis le pull-down interne (~45 kΩ) et échantillonne la broche 4000 fois, horloges actives. Une sortie logique (quelques ohms) écrase le pull : si les lectures **suivent** le pull (100 % puis 0 %), la ligne flotte ; sinon elle est pilotée. `gpio_get_level()` fonctionne même quand la broche est routée vers l'I2S, d'où un test sans démonter la configuration.
- **2026-09-29** — Test électrique : « ligne PILOTEE ». Mais ce verdict confondait deux cas très différents : *ligne active* et *ligne figée à un niveau fixe* (un fil posé sur GND ou L/R donne aussi « pilotée »). Verdicts désormais séparés en quatre : flottante / tenue à 0 / tenue à 1 / porte des transitions. Ajout de `MicCapture::readRaw()` + `dumpRawFrames()` : vidage hexadécimal de 12 trames (les deux moitiés côte à côte). C'est la seule observation qui distingue un décalage de bits d'une ligne morte. **Les pourcentages pull-up/pull-down sont l'information clé et doivent être relevés.**
- **2026-09-29** — Lecture de la **structure** des relevés : en mode « permuté », une moitié de trame porte des données et l'autre est *exactement* nulle — signature d'un INMP441 correctement cadencé (`L/R` fixe la moitié d'émission). Du bruit de ligne flottante serait présent dans les deux moitiés. Donc le micro fonctionne, et les fils `SCK`/`WS` ne sont pas sur les broches du schéma. Deux corrections : (1) `MIC_SETTLE_MS = 250` — le transitoire de mise en route de l'INMP441 faussait toutes les mesures d'ambiance (0, puis -8, puis -15 dBFS d'un essai à l'autre) ; `begin()` jette désormais ce début ; (2) le firmware annonce les **GPIO réels** retenus pour SCK et WS au lieu du mot « permuté », et affiche les mots bruts si le niveau au repos dépasse 10 % de la pleine échelle. **Point ouvert : déterminer si c'est le câblage ou le schéma du dépôt qui doit changer.**
- **2026-09-29** — **Brochage corrigé.** Le banc est câblé `SCK`→GPIO 25 et `WS`→GPIO 26, soit l'inverse du schéma initial. GPIO 25 et 26 étant équivalents (aucune fonction de strapping, tous deux en sortie), la documentation a été alignée sur le matériel plutôt que l'inverse. Modifiés : `config.h`, `docs/wiring.svg`, `docs/wiring.png`, `docs/BROCHAGE.md` (§1.1, ASCII, §9, §10), `README.md`, `README-EN.md`, `tools/serial_to_wav.py`. Vérifs : firmware SUCCESS, 6 tests, pyright 0 erreur, SVG re-rendu et relu.
- **2026-09-29** — `aplay` échouait avec « unable to open slave » : rien à voir avec le WAV. Le PCM ALSA `default` passe par `dmix`, dont l'esclave est la **carte 0** = `C110`, un périphérique USB de **capture seule** (`pcm0c`, aucun `pcm*p`). D'où l'ENOENT. `paplay` (PulseAudio) fonctionne ; `aplay -D plughw:1,0` est refusé car PulseAudio tient la carte, et `aplay -D pulse` échoue (plugin `libasound2-plugins` absent). Ajout de `make play` (essaie paplay, pw-play, ffplay, aplay) et surtout de **`tools/wav_inspect.py`** + `make inspect` : enveloppe ASCII, crête/RMS/offset et détection d'alternance silence/parole — **valider un enregistrement sans écouter**, ce qui servira aussi sur le Raspberry Pi sans écran. 8 tests, pyright 0 erreur.
- **2026-09-29** — **Bug trouvé : perte d'alignement de trame.** L'auto-test voyait du signal mais le WAV enregistré était fait de zéros exacts. Cause : en lecture stéréo, je déduisais la moitié de trame de la **parité de l'indice dans le tampon** (`_scratch[i*2]` / `[i*2+1]`). Or `i2s_channel_read()` rend ce qui est disponible, pas forcément un nombre pair de mots — et l'ancien `flush()` (lectures à vide) pouvait s'arrêter au milieu d'une trame. Les deux moitiés s'échangeaient alors, et `read()` prenait la moitié muette. Corrections : (1) `_phase` mémorisée entre les appels et avancée **mot par mot** dans `read()`, `probe()` et `readRaw()` ; (2) `flush()` fait désormais `i2s_channel_disable()` + `enable()`, ce qui vide le DMA **et** garantit que le prochain mot est un début de trame ; (3) `begin()` termine par `flush()`. **Décision figée : ne jamais déduire un canal d'une parité d'indice sur une API en flux d'octets.**
- **2026-09-29** — Diagnostic enfin net et cohérent : test électrique **100 % / 0 %** → la ligne suit exactement le pull interne, donc **GPIO 33 flotte, rien ne le pilote**. Le « bruit pleine échelle » vu sur une moitié de trame est du couplage capacitif depuis les pistes d'horloge voisines, capté par une entrée non connectée — d'où son rejet par `NOISE_FLOOR_LIMIT`. Alimentation (3,29 V) et horloges (1,64 V) déjà validées au voltmètre : le seul maillon restant est le fil `SD` micro → GPIO 33. Correction d'un défaut de mon diagnostic : le vidage hexa s'exécutait sur la configuration *permutée* (dernière de la boucle) et montrait donc des zéros non représentatifs ; on revient maintenant au câblage documenté avant de diagnostiquer. Ajout d'un protocole de **continuité** (§8 de BROCHAGE.md), à mesurer sur la **pastille** du module et non au bout du fil.
- **2026-09-29** — Les 3 continuités sont bonnes (`SD` ↔ GPIO 33, `L/R` ↔ GND, pas de court-circuit). Toutes les hypothèses de câblage côté connexions sont donc épuisées. Deux pistes restantes : (a) les signaux atteignent l'embase ESP32 mais **pas les pastilles du module** (soudure sèche) — à vérifier en mesurant SCK/WS/VDD **sur le module** ; (b) l'horloge bit de 1,024 MHz (16 kHz × 64) est trop lente pour ce module : certains INMP441 de contrefaçon ne démarrent qu'au-dessus de ~2 MHz. L'auto-test balaie désormais **2 fréquences × 2 câblages × 2 moitiés**, et `startSession()` annonce la **fréquence réellement retenue** dans l'en-tête de session (le WAV reste donc correct si 48 kHz est nécessaire).
- **2026-09-29** — **Relecture complète de l'historique : j'avais tiré une conclusion fausse.**
Le verdict « FLOTTE » (ligne SD) avait été mesuré alors que la configuration active était la
dernière de la boucle d'essais, c'est-à-dire horloges **inversées** pour le câblage réel : micro
non cadencé, donc muet. Le verdict « PILOTÉE » de l'essai précédent avait, lui, été mesuré
horloges **correctes**. Conclusion juste : **le micro pilote bien sa ligne SD**. Les tests de
continuité demandés ensuite étaient superflus (tous bons). J'avais repéré ce défaut pour le
vidage hexa, pas pour le test électrique qui avait le même.
Ce que tous les essais montrent de façon constante : **horloges correctes → une moitié de trame
porte des valeurs énormes et erratiques au repos (0, -8, -14, -15 dBFS), l'autre est exactement
nulle ; horloges inversées → tout est nul.** Hypothèses restantes, par ordre :
(H1) **mot décalé d'un bit** (cadrage Philips vs MSB) : le bit 31 devient aléatoire et un signal
faible ressemble à un bruit pleine échelle ;
(H2) **quelques mots parasites** qui font exploser la mesure crête (max − min), métrique fragile ;
(H3) **pull-down de 100 kΩ absent** sur SD, pourtant exigé par le datasheet (sortie haute impédance
hors moitié de trame).
Réponse firmware : nouvelle classe **`MicSelfTest`** (main.cpp passe de 437 à ~150 lignes) ;
balayage {16 kHz, 48 kHz} × {Philips, MSB} × {2 moitiés} ; critère = **valeur efficace au repos**
(< -20 dBFS) + **cohérence du bit de signe** (bit31 = bit30 dans > 99 % des mots), plus jamais
l'amplitude crête ; **pull-down interne activé sur SD** (`MIC_SD_PULLDOWN`) ; analyse **bit à bit**
(taux de 1 par position) + vidage hexa en configuration nominale ; test électrique refait
**horloges correctes**. Le balayage SCK/WS permutés est supprimé : câblage vérifié.
- **2026-09-29** — **H1 observée directement : mot décalé d'un bit en cadrage Philips.**
Analyse bit à bit (16 kHz Philips) : 1re moitié = `F806B200`… avec **9 zéros** en bas au lieu de 8
(bit 8 à 0 sur 8 mots sur 8), 2e moitié = `00000001` en permanence. Lecture : le premier bit émis
par le micro (signe) est échantillonné un coup d'horloge trop tard et **déborde dans le dernier bit
du mot précédent** ; les 23 bits restants sont décalés d'un rang. Confirmation croisée : à 48 kHz
en cadrage **MSB**, signe incohérent seulement 0,5 % et 2e moitié exactement nulle.
**Bug de mon critère** : la 2e moitié constante `00000001` avait été retenue comme « plausible »
(non nulle, RMS nulle après `>> 12`, signe cohérent) → un enregistrement aurait été silencieux.
Corrections : refus des constantes (`minWord == maxWord`) ; statistiques séparant **composante
continue** (`sum`) et **variations** (écart type) — seul l'écart type juge le bruit de repos ;
plancher à -110 dBFS ; `MIC_SETTLE_MS` 250 → **600 ms** (l'INMP441 s'endort à chaque arrêt des
horloges et chaque réveil laisse une composante continue lente, -30 dBFS encore visible à 250 ms) ;
analyse bit à bit faite sur la configuration **retenue**, en régime établi (trames du milieu).
**À confirmer par le prochain essai : cadrage MSB attendu comme configuration retenue.**
- **2026-09-29** — Essai suivant : **16 kHz Philips encore retenu à tort**, toujours décalé (9 zéros,
`00000001` dans l'autre moitié). Mon test de cohérence du signe (bit31 = bit30) est **aveugle sur un
signal faible** : toute la partie haute du mot vaut `1`, en perdre un ne change rien. Il ne détecte le
décalage que sur un signal fort. Nouveau critère, indépendant de l'amplitude : **nombre de bits
utiles** = 32 − ctz(OU de tous les mots) ; avec le pull-down, un INMP441 bien cadré donne
**exactement 24**. Validé hors carte sur les mots réels du banc (g++) : Philips → 23 (décalé), mêmes
échantillons recadrés MSB → 24. Valeurs recadrées ≈ −227 000 LSB24 (composante continue de réveil,
≈ −31 dBFS) avec des écarts de quelques milliers : audio sain. Mesures au repos en MSB : continu
−43 dBFS, variations −44 dBFS (16 kHz). **Décision attendue : cadrage MSB retenu automatiquement.**
- **2026-09-29** — **Cadrage MSB confirmé** sur le banc : 24 bits utiles (8 zéros exacts), 2e moitié
exactement nulle. Mais l'auto-test échouait : les mots au repos valaient +1 950 000 (**23 % de pleine
échelle**) en montant d'échantillon en échantillon. Cause : **le micro se réveillait à chaque essai**.
L'INMP441 s'endort dès que ses horloges s'arrêtent, et mon auto-test les arrêtait entre chaque
configuration ; chaque mesure « au repos » mesurait donc un réveil, pas la pièce (d'où −43 dBFS un jour,
−13 dBFS le suivant). Pire : `flush()` faisait `disable()`/`enable()`, donc **chaque session
d'enregistrement aurait démarré par ce transitoire** (« boum » grave).
Refonte : (1) **un seul réveil** au démarrage, lu en MSB, `MIC_SETTLE_MS` = 1500 ; (2) décision
**purement structurelle** : moitié ni nulle ni constante, **24 bits utiles** → MSB ; **25** → micro
Philips, bascule et revérification ; le niveau au repos n'est plus qu'une information ; (3) `flush()`
vide le DMA **par lecture**, sans arrêter les horloges, en suivant la phase, et **amorce le filtre de
continu** avec la moyenne mesurée ; (4) toutes les lectures traitent les mots reçus **avant** de tester
l'erreur (une lecture interrompue ne décale plus la phase) ; (5) `readRaw()` rend toujours des trames
entières ; (6) suppression du balayage 48 kHz et de l'incohérence de signe (détecteur aveugle au repos) ;
(7) `delay(1500)` au démarrage pour que le moniteur série ne perde plus le début de l'auto-test.
Hypothèse de robustesse notée : quand personne ne lit entre deux sessions, le DMA déborde ; les
descripteurs contiennent des trames entières (200 × 2 mots) et les lectures sont de taille paire, la
parité — donc l'alignement — est conservée.
- **2026-09-29** — **Auto-test réussi sur le banc** : 16 kHz, cadrage MSB, 1re moitié, 24 bits utiles,
2e moitié strictement nulle, ligne SD pilotée. Mots bruts `FE466E00`… → échantillons ≈ −111 000 ± 2 000
LSB24 (continu ≈ −37 dBFS). Niveau au repos mesuré −37 dBFS de variations pendant l'auto-test, mais
l'analyse bit à bit faite juste après montre des échantillons confinés dans [−131 000 ; −98 000], soit
≈ −54 dBFS : le micro finissait de se stabiliser, 1,5 s après son réveil. Sans conséquence pour les
sessions (horloges jamais arrêtées ensuite). **Reste à valider : enregistrement réel + `make inspect`.**
- **2026-09-29** — **Premier enregistrement audible** (entendu au casque ; `paplay` fonctionne, sortie
analogique par défaut). Analyse spectrale du WAV (numpy) : **87 % de l'énergie sous 20 Hz**, voix
réelle mais à −42 dBFS dans la bande 300–3 400 Hz. Chaîne de capture saine : aucun saut > 382 LSB,
aucune longue suite de valeurs identiques, donc aucun glissement de phase. Cause la plus probable :
mécanique (plaque d'essai qui fléchit sous le doigt tenant le bouton) + souffle. L'ancien filtre
(`MIC_DC_SHIFT = 12`, 0,6 Hz) ne retirait que le continu. Simulation de plusieurs réglages sur le WAV
réel : 1 étage à 80 Hz suffit (infrasons 0,6 %, voix 99,1 %) ; 2 étages n'apportent presque rien.
Implémenté : `MIC_HPF_SHIFT = 5` (80 Hz), filtre calculé **en 24 bits** (en 16 bits, zone morte de
31 LSB), gain appliqué avant la réduction 24 → 16 bits avec arrondi, **`MIC_GAIN = 4`** (mesuré après
filtre). La recommandation de gain en fin de session porte désormais sur la **crête après passe-haut**
(`RawStats::filteredPeak`), plus sur les mots bruts. Vérifié hors carte (g++, arithmétique identique au
firmware) : écart à la théorie du 1er ordre < 0,3 dB, −29 dB à 3 Hz, < 0,4 dB au-dessus de 300 Hz.
**Étape 1 : critère « audible, sans saturation » atteint ; confirmation attendue sur le firmware final.**
- **2026-09-29** — **Étape 1 validée par l'utilisateur** (« le son est vraiment bien »). Le WAV validé
provient encore de l'ancien firmware (dérive continue +0,3 % → +4,6 %, impossible avec le passe-haut
à 80 Hz) : le filtre 80 Hz sera vérifié sur le matériel avec le firmware de l'étape 2.
- **2026-09-29** — **Étape 2 écrite.** Firmware : `AudioOutput` (I2S1 TX, 16 bits stéréo dupliqué,
Philips par défaut via `AMP_FORMAT_PHILIPS`, `auto_clear`, bip 440 Hz −12 dBFS au démarrage = test de
la sortie seule), `AudioFifo` (StreamBuffer statique 16 Ko, tout ou rien), `SerialFrameReader`
(machine à états), tâche audio statique cœur 1 priorité 5, `SerialProtocol.h` partagé ;
`silenceAmplifier()` supprimé (l'I2S tient les broches dès la 1re ligne de `setup()`) ; `loop()` sert
la lecture même si le micro est en défaut. PC : `serial_protocol.py` extrait, `wav_to_serial.py`
(WAV ou `--tone`, avance 200 ms puis 1 chunk / 50 ms), cibles `make play-esp` et `make tone-esp`.
Vérifs : firmware SUCCESS 0 warning (RAM 17,4 %) ; **test croisé** encodeur Python → décodeur C++
compilé sur PC (texte parasite, faux mot magique, somme fausse, longueur excessive : 4 trames
valides, 2 rejetées) ; outil PC sur pseudo-terminal : 4 chunks d'avance puis 50 ± 1 ms ; 15 tests
pytest (dont un qui a trouvé un vrai bug : dernier chunk de longueur impaire), pyright 0 erreur.
**Non vérifiable sans matériel** : cadrage Philips réel du MAX98357A côté ESP32, bruit de
l'ampli cadencé en permanence sur le micro, absence de coupure sur la carte.
- **2026-09-29** — **Étape 2 validée par l'utilisateur** : bip, son pur sans clic, lecture de la voix,
contrôle du micro — tout passe. Seul point : **niveau du HP faible mais audible**. Explication :
bip à −12 dBFS par choix, et le WAV rejoué (étape 1, ancien firmware, gain 1) avait sa voix vers
−30 dBFS crête. Pistes, sans code : mesurer `Vin` sur la pastille de l'ampli (≥ 4,5 V), rejouer un
enregistrement fait avec le firmware actuel (gain 4 → +12 dB), `GAIN` à GND (+6 dB, 15 dB),
enceinte pour le HP. Correctif de fond prévu : **normaliser côté serveur** l'audio envoyé (étape 5,
puis la voix Piper à l'étape 6), l'ESP32 restant simple. Prochaine étape : 3 (Mosquitto + topics).
- **2026-09-29** — **Étape 3 écrite.** Constat en arrivant : le broker du conteneur
`mosquitto_services-mosquitto-1` redémarrait en boucle (**378 redémarrages**). Sa configuration,
écrite à la main hors du dépôt, déclarait **deux `listener 1883`** : `Error: Address in use`,
reproduit dans un conteneur jetable. Le journal restait muet car `log_dest syslog` (dans MA
`voice.conf`) n'écrit nulle part dans un conteneur ; `message_size_limit` était aussi obsolète.
L'IP donnée par l'utilisateur (192.168.0.104) ne correspond pas à la machine : **192.168.1.104**.
`voice.conf` corrigé (`log_dest stdout`, `max_packet_size`, pièges documentés) et **testé dans un
broker jetable** : état `online` retenu, testament `offline` publié à la coupure brutale du
client, et reçu par un abonné arrivé après coup. Le conteneur de l'utilisateur n'a **pas** été
modifié (hors dépôt, fichiers de l'uid 1883) : cible `make mosquitto-docker` fournie.
Firmware : `WifiLink`, `MqttLink` (PubSubClient 2.8 épinglé, tampon 2048, keepalive 10 s, socket
2 s, essais toutes les 5 s), état JSON retenu toutes les 30 s (IP, RSSI, uptime, micro, ampli),
événement `boot` avec la cause du redémarrage (dont `brownout`), abonnement `control` journalisé ;
secrets transmis par constructeur, `#error` explicite si `secrets.h` manque ; Wi-Fi démarré
**après** l'auto-test du micro. Partitions `huge_app` (Flash 74,5 % → 31 %). Build SUCCESS
0 warning (avec un `secrets.h` temporaire, supprimé). Cibles `mqtt-watch`, `mqtt-control`
(clients de l'image Docker si `mosquitto-clients` absent). `docs/MOSQUITTO.md` créé.
Correction faite dans la doc : un reset d'ESP32 ne ferme PAS la connexion TCP (testament en ≤ 15 s).
- **2026-09-29** — Étape 3, premiers résultats : broker réparé par l'utilisateur **avec sa propre
config** (pas `voice.conf` : `log_dest file`, `log_type all`, `message_size_limit`) — fonctionne,
mais `log_type all` journalisera chaque chunk audio à l'étape 4 → passer à `voice.conf` avant.
ESP32 : Wi-Fi « BPI_R3-2G » en 4,8 s, IP 192.168.1.23, **RSSI −76 à −78 dBm (faible)**, MQTT connecté.
Vérifié directement sur le broker : un abonné tardif reçoit aussitôt l'état retenu (uptime 158 s),
republié 30 s plus tard (188 s). Auto-test micro : variations au repos −46 dBFS (Wi-Fi démarré
après). Restent à confirmer : message `control` reçu, `offline` après coupure d'alimentation.
- **2026-09-29** — **Étape 3 validée par l'utilisateur.** `mqtt-watch` : état périodique, `control`
relayé par le broker, `offline` publié par le testament à la coupure d'alimentation, puis `online`
(uptime 12 s) et `event boot reason=poweron` au retour. Avant l'étape 4 : passer le broker sur
`voice.conf` (`make mosquitto-docker`) et améliorer le RSSI (−75 à −79 dBm, viser > −70).
- **2026-09-29** — **Étape 3 validée ; étape 4 écrite.** Firmware : `VoiceSession` (START / chunks /
END en JSON sur `event`, PCM brut sur `audio/in`, id de session 6 hex), **tâche de capture** dédiée
+ FIFO de capture 32 Ko (1 s) : `loop()` ne lit plus le micro, il vide la FIFO vers MQTT ; `AudioFifo`
à stockage fourni ; `MqttLink::publishBinary`. RAM 36,7 %, Flash 31,1 %, 0 warning.
Serveur (premier code) : `settings`, `mqtt_link`, `audio_buffer`, `session_manager`, `__main__` ;
`make run`. Verdicts : complete, lost_chunks, timeout, interrupted. 30 tests pytest, pyright 0.
**Essai de bout en bout sur le vrai broker** avec une carte simulée (paho, rythme réel) :
complete 40/40 ✓, timeout 20/? ✓, mais la session à 3 chunks perdus était classée `interrupted`
(nouveau START arrivé pendant le délai de grâce) → **bug corrigé**, test de régression ajouté,
scénario rejoué : `lost_chunks 37/40` + alerte, puis session suivante `complete`.
Pas de `tools/mqtt_to_wav.py` : le serveur remplit ce rôle ; à réévaluer avec `wav_to_mqtt.py` (étape 5).
- **2026-09-29** — **Incident causé par moi : `firmware/include/secrets.h` de l'utilisateur détruit.**
Mes compilations de contrôle faisaient `cp secrets.h.example secrets.h ; make fw-build ; rm -f secrets.h`.
Sans conséquence à l'étape 3 (fichier pas encore créé) ; à l'étape 4, la copie a écrasé le vrai
fichier puis `rm` l'a supprimé (dossier modifié à 17:02). Symptôme chez l'utilisateur :
`'WIFI_SSID' was not declared` (le `#error` s'affiche bien, mais GCC continue et ajoute cette
erreur). **Correctif de méthode** : cible `make fw-check`, secrets factices dans un `mktemp -d`
via `PLATFORMIO_BUILD_FLAGS=-I<tmp>`, `firmware/include/` jamais touché. Règle aussi notée en
mémoire. L'utilisateur doit recréer son `secrets.h` (SSID relevé dans ses journaux : « BPI_R3-2G »,
broker 192.168.1.104 déjà dans le modèle).
- **2026-09-29** — **Étape 4 validée** : 4 phrases de 4 à 6 mots, intelligibles au casque. Analyse des
4 WAV serveur : 1,8 à 4,35 s, nombres entiers de chunks (36, 50, 87, 56), crête −10,4 à −15,3 dBFS
(gain ×4 dans la cible), **offset ≈ 0 %** et infrasons 0 à 0,3 % (contre +3 % et 87 % avant) :
**passe-haut 80 Hz validé sur le matériel**. Voix 99,4 à 99,9 % de l'énergie.
Prochaine étape : 5 (serveur → MQTT → ESP32).
- **2026-09-29** — **Étape 4 validée ; étape 5 écrite.** Firmware : `PlaybackStream` (lecture série ou
MQTT, tâche audio, bilan `played` publié sur `event`), `CommandHandler` (start/end/ping, ArduinoJson
7.2.1), abonnement `audio/out`. Build 0 warning, RAM 36,7 %. Serveur : `AudioResampler` (8/16/24/32
bits, toute fréquence, mono, soxr HQ, crête −3 dBFS), `AudioSender` (300 ms d'avance, rythme réel),
`MqttLink.publish/wait_connected`, `SessionManager(on_finished=...)`, `--echo` (fil de lecture dédié,
hors fil paho). Outil `tools/wav_to_mqtt.py`, cibles `tone-mqtt`, `play-mqtt`, `run-echo`.
41 tests (dont un qui a trouvé qu'un WAV tronqué levait `EOFError` au lieu du message ffmpeg), pyright 0.
**Essais de bout en bout sur le broker réel** (carte simulée « esp32-test ») : son pur 40/40, écart max
51 ms, crête −3,0 dBFS, bilan reçu ; écho : session 30/30 à −24,3 dBFS renvoyée à −3 dBFS, 30/30.
**Incident causé par moi** : mon serveur de test a repris l'identifiant `voice-server` du `make run`
de l'utilisateur (lancé à 17:16) → ~16 s d'éjections mutuelles ; son serveur a enregistré
`server/recordings/esp32-test_20260929-173525_voix01.wav` (artefact de test, 0,5 s, supprimable).
Correctifs : identifiant de test distinct, indice « identifiant en double ? » dans le journal de
déconnexion, règle mémorisée. Le `make run` de l'utilisateur tourne encore l'ancien code (étape 4).
- **2026-09-29** — Étape 5, premier essai utilisateur : écho une fois (phrase coupée), puis rien. Journal
serveur : session `39ecba` ouverte et close en **72 ms, 0 chunk** → le bouton est lu relâché pendant
l'appui (contact intermittent sur la plaque ? carte déplacée pour le Wi-Fi ?). Le serveur **ignorait en
silence** les sessions < 0,2 s pour l'écho (défaut de ma part). Ligne `en ligne` 12 s après la
session : republication périodique ou **redémarrage** — indécidable, le journal n'affichait pas
l'uptime. Instrumentation ajoutée : raison de fin de session (« bouton relache » / « duree
maximale »), alerte si session < 300 ms, purge des événements de bouton périmés (un appui mémorisé
pendant une session relançait une session fantôme), uptime dans le journal d'état serveur,
événement `boot` journalisé avec sa cause (WARNING si brownout/panic/watchdog), écho ignoré annoncé.
- **2026-09-29** — Étape 5, second essai (instrumenté) : **diagnostic Wi-Fi confirmé par les chiffres**.
RSSI −81 à −75 dBm. Session 833429 : ~108 chunks captés, 42 envoyés, **66 perdus faute de place**
(FIFO de capture 1 s débordée : envoi Wi-Fi bloqué plusieurs secondes) ; écho 11/42 chunks, END
perdu (reconnexion MQTT pendant l'écho : `offline` puis `online` 130 ms après, uptime continu 102→107 s,
donc **pas de redémarrage**). Sessions suivantes 55/55 et 76/76 (4 perdus), échos complets mais 35 et
107 sous-alimentations (≈ 2 s de trous sur 3,8 s). Serveur toujours à l'heure (retard d'envoi 0 ms).
Bouton : plus aucun problème (appuis de 2 à 5 s). **Décision : ne pas masquer côté logiciel** (le
tampon anti-gigue est l'étape 12) ; expérience demandée : ESP32 à 1–2 m du point d'accès. Cibles :
> −65 dBm fiable, −65 à −72 sous-alimentations ponctuelles, < −72 pertes. Piste matérielle si
l'emplacement ne peut pas changer : ESP32-WROOM-32U + antenne externe.
- **2026-09-29** — **Étape 5 validée** (« Echo est bon maintenant ») après **changement de SSID** par
l'utilisateur. RSSI −69 à −72 dBm (à peine mieux que −75/−81), même IP 192.168.1.23 (même réseau :
autre point d'accès ou autre canal). État stable 7 min, aucune reconnexion. Leçon : le canal / la
congestion 2,4 GHz compte autant que le RSSI. Chiffres de lecture (`played`) non fournis.
Prochaine étape : 6 (MVP : faster-whisper → réponse → Piper → ESP32).
- **2026-09-29** — **Étape 6 écrite** (serveur seul, firmware inchangé). `speech_to_text.py`,
`text_to_speech.py`, `assistant.py`, `voice_pipeline.py` ; `__main__.py` : mode assistant par
défaut, écho avec `--echo`. `config.yaml` : `stt.model small`, `beam_size`, `no_speech_threshold`,
`min_peak_dbfs`, `tts.length_scale` (clé `output_sample_rate` retirée : c'est `audio.sample_rate`).
Extra `speech` installé dans `server/.venv` et modèles téléchargés (549 Mo) par moi.
Mesures : Piper 0,04 à 0,10 s pour 1 à 3 s de parole ; Whisper ≈ 1,0 s ; `make speech-check` →
« Quelle heure est-il ? » relu exactement, réponse prête en 1,04 s de calcul. Les 3 derniers
enregistrements réels de l'ESP32 (`server/recordings/`) transcrits sans faute (« Fermez le volet
de la salle de bain. »). 65 tests (dont 3 avec les vrais modèles, ignorés sans `make models`),
pyright 0 erreur. Délai perçu attendu ≈ 1,1 s de calcul + 0,3 s d'avance d'envoi. Pas d'essai
bout en bout sur le broker réel (éviterait deux serveurs qui répondent à la même carte).
En attente de validation matérielle par l'utilisateur (`make run`).
- **2026-09-29** — **Étape 6 validée** par l'utilisateur (« Dialogue très correct »). 9 questions
d'affilée, toutes les sessions `complete`, toutes les lectures `chunks N/N` :
heure, date, bonjour, nom → bonnes réponses ; phrases libres répétées. Réponse prête **0,95 à
1,11 s** après la fin de session (Whisper 0,89–1,06 s, Piper 0,05–0,11 s), pire retard d'envoi 0 ms.
Appui sans parler : crête −30,7 dBFS (bruit du bouton, au-dessus du seuil de −45) → Whisper appelé,
hallucination « Amara.org » **écartée par le filtre**, réponse « Je n'ai rien entendu. ».
Points notés pour plus tard :
(a) **sous-alimentations 6 à 9 par lecture même sans perte**, 25 et 35 quand le RSSI tombe à −83 dBm.
La base 6–9 vient probablement du démarrage : la tâche audio vide chaque chunk dès son arrivée
(DMA encore vide) et compte chaque bloc incomplet ; il n'y a pas de seuil de démarrage → **étape 12**
(lecture lancée seulement après l'avance de 300 ms, sous-alimentations comptées après) ;
(b) session muette : RMS −46,8 dBFS contre −22 à −28 pour la parole → la VAD de l'**étape 7**
remplacera le seuil de crête ; (c) « fermé la porte » pour « fermez » : homophone, l'**étape 11**
devra tolérer ce genre d'écart ; (d) 7 chunks perdus faute de place à la capture (Wi-Fi).
Prochaine étape : 7 (VAD Silero). Silero est déjà livré par faster-whisper
(`faster_whisper/assets/silero_vad_v6.onnx`, exécuté par onnxruntime) : pas besoin de torch.
- **2026-09-29** — **Étape 7 écrite** (serveur seul, firmware inchangé). `voice_activity.py`
(`find_segments`, `pad_segments`, `VoiceActivityDetector`, `envelope`), branché dans `VoicePipeline`
avant Whisper ; `tools/vad_check.py` (`make vad`, `make vad-all`) ; `speech_check --wav` passe par la VAD.
Config : section `vad` (clé `window_samples` retirée : 512 est imposé, constante `WINDOW_SAMPLES`),
`stt.min_peak_dbfs` supprimé. Mesures sur les 27 enregistrements du serveur : 6 sans parole, tous
à juste titre (3 sessions vides de l'étape 5, l'appui muet `0137fe`, mes 2 sons purs `esp32-test`) ;
pour les 9 questions de l'étape 6, 8 à 37 % de silence retiré, l'utilisateur parle ~0,5 s après
l'appui. **Gain inattendu** : `c5a22c`, transcrit « fermé la porte » à l'étape 6, devient
« fermez la porte » une fois le silence retiré. Appui muet : réponse prête en 0,03 s au lieu de 1,11 s.
`c5a22c` commence à 0,00 s : parole dès l'appui, début de mot possiblement coupé (piège à signaler).
78 tests, pyright 0 erreur. Pas d'essai sur le broker : le `make run` de l'utilisateur (étape 6,
lancé à 19:26) tournait encore ; vérification hors ligne de `make_assistant` sur 2 vraies sessions.
En attente de validation par l'utilisateur.
- **2026-09-29** — **Étape 7 validée** par l'utilisateur (« Semble fonctionner »). 6 sessions :
segments de parole trouvés partout (jusqu'à 3 segments, pauses de 0,6 à 0,95 s conservées en une
seule transcription), appui muet (crête −32,4 dBFS) rejeté par la VAD, réponse prête en **0,07 s**.
VAD 7 à 11 ms (25 ms au premier appel). Erreur « 4 jours, sommes-nous ? » pour « Quel jour
sommes-nous ? » (`ce69b6`, pause de 0,6 s après « jour ») : **pas due à la VAD** — sur l'audio brut,
Whisper écrit « qu'à jour, sommes-nous ». Essai `initial_prompt` (phrases types de l'assistant) :
corrige `ce69b6` mais dégrade `a2428a` (« quelles jours ») → **non retenu** ; à reconsidérer à
l'étape 11 (vocabulaire des intentions, règles tolérantes : « jour » + « sommes ») ou modèle
`medium` à l'étape 16. Prochaine étape : 8 (diarisation pyannote).
- **2026-09-29** — **Étape 8 écrite** (serveur seul). Installé par moi dans `server/.venv` :
pyannote.audio 4.0.7 + torch CPU (`.venv` = 1,9 Go), numpy monté à 2.5.3 ; `pip check` propre.
`diarization.py` (`Diarizer`, `Diarization`, `SpeakerTurn`, `timeline`, `disable_telemetry`),
branché dans `VoicePipeline` après la VAD ; `make install-diarization`, `make diarize`,
`make diarize-demo` (voix de l'utilisateur + Piper + voix de l'utilisateur) ; `make models`
télécharge le pipeline si `HF_TOKEN` est dans `server/.env`, sinon explique la démarche et rend 1.
**Découverte** : pyannote 4 a une télémétrie active par défaut vers otel.pyannote.ai → coupée.
**Non vérifié** : le vrai pipeline n'a pas tourné, `server/.env` n'existe pas (pas de jeton). J'ai
relu le code de pyannote là où le mien s'y appuie : entrée `{"waveform": (1, n), "sample_rate"}`
acceptée, silence → `DiarizeOutput` vide sans erreur, `speaker_embeddings` (n, dim).
84 tests + 1 ignoré (modèle réel), pyright 0 erreur. En attente : jeton de l'utilisateur, puis validation.
- **2026-09-29** — Modèle pyannote obtenu : premier refus `GatedRepoError` car le compte HF du jeton
(type « read », valide) n'avait pas accepté les conditions ; réglé par l'utilisateur. `make models`,
`make test` (85, vrai pipeline compris) et `make diarize-demo` OK : **2 locuteurs** trouvés dans
l'ordre vous / Piper / vous (0,08-1,55 · 1,75-5,58 · 5,82-7,25 s), **0,47 s** pour 7,35 s de parole.
Avertissement torch `std(): degrees of freedom` (morceau trop court) masqué dans `Diarizer.diarize`.
Question seule en `make run` : OK selon l'utilisateur. Reste : une session réelle à deux voix.
- **2026-09-29** — **Test réel à deux voix réussi** (session `453cce`, 11,85 s, au micro de l'ESP32) :
l'utilisateur, une voix Piper jouée par un haut-parleur, l'utilisateur. VAD : 3 segments, 6,34 s de
parole. Diarisation : SPEAKER_00 0,08-1,50 · SPEAKER_01 1,57-4,69 · SPEAKER_00 4,92-6,17 — les deux
phrases de l'utilisateur portent la même étiquette, et les bornes coïncident avec celles de la VAD
(1,61 et 4,89 s dans l'audio sans silences). Diarisation 0,46 s. **Délai : réponse prête en 2,04 s**
(VAD 0,02 + diarisation 0,46 + Whisper 1,34 pour 6,3 s + Piper 0,21) : la diarisation s'ajoute à
Whisper → candidate à la parallélisation (étape 16). « la roi de synthèse » : voix passée par un
haut-parleur puis le micro. Validation explicite de l'étape 8 non encore donnée.
- **2026-09-29** — **Étape 8 validée** par l'utilisateur. Étape 9 lancée avec **les empreintes de
pyannote** (choix de l'utilisateur, après comparaison avec SpeechBrain ECAPA : précision publiée
du même ordre ; SpeechBrain n'apporterait que l'indépendance vis-à-vis de la diarisation, au prix
d'un modèle, d'une dépendance et d'un risque de compatibilité avec torchaudio 2.11).
- **2026-09-29** — **Étape 9 écrite** (serveur seul). Mesure préalable : les empreintes rendues par
pyannote sont **NaN pour toutes les questions courtes** (cf. décisions) → empreinte recalculée par
locuteur avec le sous-modèle WeSpeaker. `speaker_identifier.py` (`SpeakerIdentifier`,
`VoiceProfile`, `Identification`, `save_profile`, `load_profiles`), `tools/enroll.py`,
`make enroll/profiles/forget`, identification affichée par `make diarize` ; `VoicePipeline` journalise
`SPEAKER_00 = Nom (score)`. `diarize-demo` choisissait `453cce` (deux voix) puis `093463` (mélange) :
il prend maintenant la question la plus récente de 1 à 4 s à une seule voix (`--voice` pour imposer).
Avertissements numpy « Mean of empty slice » (empreintes NaN internes de pyannote) masqués.
Essai complet **dans le scratchpad** (aucun profil écrit dans `server/profiles/`) : profil « Essai »
sur 10 sessions → 6 retenues (2 à deux voix, 1 muette, 1 mélangée écartées), cohérence 0,79–0,86 ;
`c5a22c` → Essai 0,74 ; `453cce` → Essai 0,72 / haut-parleur Inconnu −0,05 ; démo → Essai 0,84 /
Piper Inconnu −0,02. Délai : + ~0,5 s de diarisation et d'empreinte (réponse prête 1,47 s pour une
question de 1,3 s). 98 tests, pyright 0 erreur. En attente : enrôlement réel et validation.
- **2026-09-30** — Fichiers de test pour l'étape 9 (demande de l'utilisateur, à jouer depuis son
téléphone) : `recordings/voix-test/{1-tom,2-pierre,3-jessica,4-gilles,5-siwis}.{wav,mp3}`, 5 voix
Piper différentes (tom-medium, upmc-medium pierre et jessica, gilles-low, siwis-medium), 0,5 s de
silence avant et après, crête −3 dBFS, toutes relues correctement par Whisper. Voix téléchargées
dans le scratchpad puis supprimées (rien ajouté à `server/models/`). `fr_FR-mls-medium` écartée :
aucun de ses 125 locuteurs n'est transcrit correctement. **Mesure à retenir** : Jessica et Siwis
(deux voix féminines de synthèse) se ressemblent à **0,60** (fichier contre fichier), au-dessus du
seuil de 0,45 ; les autres paires 0,01–0,37. Le seuil devra peut-être monter si une voix étrangère
est reconnue comme l'utilisateur (ses propres sessions : 0,52–0,83, marge faible).
- **2026-09-30** — **Étape 9 validée** par l'utilisateur (« Tout semble marcher super bien »).
6 profils enrôlés par l'utilisateur : Denis (5 sessions) et les 5 voix de synthèse jouées au
téléphone (Gilles 3, Jessica 3, Pierre 4, Siwis 3, Tom 5). Essai réel : Denis 0,74 et 0,78 ;
Tom 0,95, Gilles 0,96, Siwis 0,96, Jessica 0,93, Pierre 0,92 — chaque voix trouvée, Jessica et Siwis
(0,60 entre elles) bien départagées par le meilleur score. Réponse prête en 1,5 à 1,8 s
(diarisation + empreinte ≈ 0,45 s). **Limites à garder en tête** : (1) les scores de 0,92–0,96 des
voix de synthèse sont gonflés — même fichier rejoué à l'enrôlement et au test, audio quasi identique ;
le vrai test de généralisation est Denis (phrases nouvelles, 0,74–0,78) ; (2) le cas « Inconnu »
n'a pas été rejoué en réel ce matin (toutes les voix étaient enrôlées) ; il a été vérifié hier
(haut-parleur −0,05, Piper −0,02) ; (3) seuil 0,45 à recalibrer avec de vraies personnes (étape 14).
« Quel ton fera-t-il » pour « temps » : Whisper sur une voix de téléphone. Prochaine étape : 10.
- **2026-09-30** — **Étape 10 écrite** (serveur seul). `Word` et `Transcript.words` (Whisper,
`word_timestamps`), `speaker_attribution.py` (`turn_of`, `attribute`, `Utterance`,
`transcript_json`), `VoicePipeline` publie sur `voice/<carte>/transcript` et passe le nom de la
dernière personne à l'assistant ; `make diarize` affiche qui a dit quoi et le message MQTT.
Essai hors ligne sur `453cce` avec les profils réels de l'utilisateur : Denis « Ouvrir la porte. »,
**Siwis** (juste : c'était sa voix au haut-parleur) « Je suis la roi de synthèse… », Denis « Fermez la
porte. », bornes 0,00-1,26 / 1,50-4,46 / 4,92-5,86 s. 110 tests, pyright 0 erreur.
En attente : essai sur la carte et validation.
- **2026-09-30** — **Étape 10 validée** par l'utilisateur (« continuer maintenant »). Essai carte :
Denis 0,68 / 0,79, Jessica 0,92, interventions et textes justes ; « Bonjour » seul (1,06 s de parole)
→ Inconnu 0,32 → salutation générique (piège annoncé : phrase trop courte pour une empreinte).
Session à plusieurs voix non refaite sur la carte (vérifiée hors ligne sur `453cce`).
`make mqtt-watch` affichait l'audio binaire (`voice/#`) : limité aux topics texte (state, event,
control, transcript) ; ajout de `make mqtt-transcript`. Prochaine étape : 11 (intentions + domotique).
- **2026-09-30** — **Étape 11 écrite** (serveur + firmware). Choix utilisateur : commandes vers
`home/…` **et** exécution par la carte pour sa pièce ; pas de domotique réelle (simulation).
Serveur : `home_control.py`, `Assistant.respond()` → `Reply(text, command)`, `HomeController`,
`MqttLink.publish_topic`, section `home` de config.yaml (pièces + `boards: esp32-01: salon`),
journal des confirmations `device`. Firmware : `DemoDevices`, `CommandHandler` gère `cmd: device`,
`PIN_DEMO_LIGHT = 21` ; `make fw-check` SUCCESS, 0 warning (RAM 36,7 %, Flash 31,4 %).
`make mqtt-watch` inclut `home/#` ; `make mqtt-home`. `docs/BROCHAGE.md` : LED GPIO 21 + 330 Ω
(non dessinée sur `wiring.svg`). Essai hors ligne sur 3 enregistrements réels : volets du salon
fermés/ouverts → `home/salon/shutter/set` + `esp32-01/control` ; salle de bain → `home/…` seul.
130 tests, pyright 0 erreur. **Firmware non téléversé** (à faire par l'utilisateur). En attente de validation.
- **2026-09-30** — **Étape 11 validée** par l'utilisateur (« Semble fonctionner correctement »),
firmware téléversé. « Éteint la lumière » (graphie Whisper) et « Allume la lumière » →
`home/salon/light/set` + `esp32-01/control`, **confirmation de la carte 40 ms après la commande**
(`{"event":"device","ok":true,"state":"off"}`), puis la réponse parlée ; « Quel temps fait-il ? » →
réponse « sans Internet ». Réponse prête en 1,41 à 1,55 s. `mqtt-watch` lisible : transcript,
commande, confirmation, START/END de lecture, `played`. **Sous-alimentations toujours 6 à 10 par
lecture, sans aucune perte** (`chunks 34/34`) : c'est l'objet de l'étape 12. Prochaine étape : 12.
- **2026-09-30** — Exigence utilisateur : commander des machines Linux → **étape 15b** (décidé).
- **2026-09-30** — **Étape 12 écrite**. Firmware : `PlaybackStream` devient une machine à états
(Idle / Priming / Playing) dans la tâche audio ; `PLAYBACK_START_MS = 300` ; bilan `played` enrichi
(`start_ms`, `min_margin_ms`, `underruns` = réamorçages) ; `make fw-check` SUCCESS, 0 warning
(RAM 36,7 %, Flash 31,4 %). Cause probable des 6 à 10 « sous-alimentations » : la tâche jouait dès
le premier chunk, sans attendre l'avance envoyée par le serveur, et comptait chaque bloc incomplet.
Serveur : journal `played` enrichi (WARNING s'il y a des réamorçages) ; banc `JitteryLink` dans
`tools/wav_to_mqtt.py`, `JITTER` dans le Makefile, `tone-mqtt` passe à 5 s. 131 tests, pyright
0 erreur. **Non vérifié sur la carte** (je n'envoie pas de son sur le matériel de l'utilisateur) :
firmware à téléverser, puis essais `JITTER=0/200/400`. En attente de validation.
- **2026-09-30** — Premiers essais de l'étape 12 sur la carte (`tone-mqtt` 5 s, JITTER 0 / 200 / 400) :
marge mini **204 / 142 / 2 ms** (suit bien la gigue), réamorçages **1 / 1 / 2**, premier son à
**214–218 ms**. Deux écarts avec la prévision : (1) **un réamorçage systématique**, même sans gigue,
hors de la marge mesurée ; (2) l'avance de 300 ms n'arrive pas d'un bloc mais en ~200 ms (hypothèse :
fenêtre TCP de lwIP ~5,7 Ko < 6 chunks de 1,6 Ko, la carte lit au rythme de PubSubClient).
Instrumentation ajoutée plutôt que supposition : `gaps_ms` (instant de chaque réamorçage depuis le
premier son) dans le bilan `played`, la série et le journal serveur. En attente d'un nouvel essai.
- **2026-09-30** — **Cause du réamorçage systématique trouvée grâce à `gaps_ms`** : 1er essai après
démarrage 0 réamorçage ; 2e essai `gaps_ms:[28043]` = uptime de la carte, donc compté avant le premier
son du flux (`_firstSoundMs` = 0). **Faux compte, inaudible** : après un flux, la tâche restait en
Playing, bloquée ≤ 20 ms dans `read()` ; le START suivant arrivait pendant ce temps, le `read()`
rendait 0 octet, `_endOfStream` venait d'être remis à faux → famine comptée pour un flux pas encore
commencé. Correctif : passage à **Idle** quand la FIFO est vide après le END, famine comptée
seulement par **compare-and-swap Playing → Priming** (échoue si `open()` a déjà changé l'état), et
`open()` met l'état à Priming **avant** de remettre les compteurs à zéro. `make fw-check` SUCCESS.
Premier son à 214 ms : l'avance arrive étalée (fenêtre TCP de lwIP probable) ; à revoir à l'étape 16
(latence), sans effet sur la fluidité. Marge mini 188–204 ms sans gigue.
- **2026-09-30** — Essai final de l'étape 12 (firmware corrigé) : `tone-mqtt` ×3 → **0 réamorçage,
`gaps_ms` vide** à chaque fois (le cas qui échouait) ; JITTER=200 → **0**, marge mini 162 ms ;
JITTER=400 → 1 réamorçage **réel** à 364 ms du premier son (1er blocage), marge 2 ms. Premier son
212–221 ms. Critères de l'étape 12 atteints ; validation explicite de l'utilisateur en attente.
- **2026-09-30** — **Étape 12 validée** par l'utilisateur. Étape 13 lancée : mots de réveil anglais
prêts à l'emploi **alexa, hey_jarvis, hey_mycroft** (les trois ; alexa si un seul).
- **2026-09-30** — **Étape 13 écrite** (serveur + firmware). openWakeWord installé `--no-deps` dans
`server/.venv`, modèles dans `server/models/openwakeword/` (`make models`). Serveur : `wake_word.py`,
`hands_free.py`, `make_listener` dans `__main__.py` (état/événements composés, balayage, arrêt qui
coupe l'écoute), section `wake` de config.yaml, extra `wakeword` + `make install-wakeword`.
Firmware : `cmd listen` / `cmd capture`, flux continu dans `loop()` (`updateStreaming`,
`stopStreaming`), LED `blink(2000, 100)` ; `make fw-check` SUCCESS, 0 warning. Simulation complète
hors ligne (flux : phrase sans mot puis « Alexa, allume la lumière », Piper) : 1re phrase ignorée,
aucun fichier ; mot 0,99 ; commande close 2,75 s après ; `home/salon/light/set` + carte ; réponse.
142 tests (dont vie privée : rien écrit en veille ; rafale Wi-Fi), pyright 0 erreur.
**Non vérifié sur la carte** : firmware à téléverser. Limite connue : broker anonyme → flux audio
lisible par tout le réseau local (étape 15) ; si le serveur meurt sans `listen false`, la carte
continue d'émettre (testament serveur à prévoir à l'étape 15). En attente de validation.
- **2026-09-30** — Retour utilisateur (essai réel de l'étape 13) : « Hey Jarvis » et « Alexa » pas
toujours reconnus (prononciation anglaise nécessaire), **« Hey Mycroft » fiable en français**.
Ajouts : **seuil propre à chaque mot** (`wake.thresholds`, vide par défaut) et **journal des mots
presque reconnus** (pic de score ≥ 0,15 retombé sans atteindre le seuil) pour régler sur mesures.
143 tests, pyright 0 erreur. Aucun seuil changé tant qu'on n'a pas les pics réels.
- **2026-09-30** — **Étape 13 validée** par l'utilisateur, qui retient **« Hey Mycroft »** comme mot
principal (fiable prononcé en français) ; alexa et hey_jarvis restent actifs. Réglage des seuils par
mot reporté (outil prêt : « presque reconnu »). Étape 14 lancée : droits par profil.
- **2026-09-30** — **Étape 14 écrite** (serveur seul, firmware inchangé). `access_control.py`
(`AccessPolicy`, `Level`, `Verdict`, `Resolution`), section `access` de config.yaml,
`VoicePipeline._decide` (confirmation en attente d'abord, puis droits, puis exécution),
`home_control.announce/request`, élision « d'ouvrir ». Essai hors ligne avec les vrais profils :
Siwis (standard, 0,59) « ouvre la porte » → refusé ; Denis (complet, 0,69) → confirmation demandée ;
phrase suivante sans oui/non → demande abandonnée ; Denis ferme les volets → exécuté.
162 tests, pyright 0 erreur. En attente de l'essai réel et de la validation.
- **2026-09-30** — Ajout de **`make say`** (`tools/say.py`) : fait dire `TEXT` par une voix Piper
(`VOICE`, `SPEAKER` pour upmc : 0 = Jessica, 1 = Pierre), 0,5 s de silence avant/après, crête −3 dBFS,
WAV + MP3 (ffmpeg) dans `SAY_OUT` (défaut `recordings/voix-test/phrase.wav`) ; voix téléchargée au
premier usage. Pour tester droits et confirmation sans seconde personne (≠ `speech-check`, qui fait
entendre la RÉPONSE). Générés : `recordings/voix-test/tom-porte` (« Hey Mycroft, ouvre la porte. »)
et `pierre-lumiere` (« Hey Mycroft, allume la lumière. »).
- **2026-09-30** — Retour utilisateur : « Hey Mycroft » doit être prononcé à l'anglaise, sinon rien.
Mesures (détecteur hey_mycroft réel, scratchpad) : voix FR (tom, upmc, siwis) en texte ou en graphies
francisées → **0,00** ; en phonèmes bruts Piper `[[ˈɛj mˈajkʁɔft]]` → ~1 synthèse sur 2 atteint 0,5
(Piper est aléatoire), mais retombe à 0 au moindre bruit/filtrage → inutilisable au téléphone. Voix
anglaise **en_GB-alan-medium** → 1,00 partout, même bruit 15 dB + bande 300-4000 Hz + −12 dB (ryan et
lessac moins stables). D'où `make say WAKE="Hey Mycroft"` (`--wake`, `--wake-voice`) : Alan dit le mot,
0,6 s de pause, puis VOICE dit la commande ; chaque morceau normalisé à −3 dBFS, sortie **16 kHz** ;
fichier vérifié par `WakeWordDetector` (échec → code 1). Identification : le serveur prend la
**dernière** intervention, mais la pré-écoute (0,5 s avant la détection, qui tombe ~0,15 s avant la fin
du mot) mêle la voix d'Alan : commande courte → Tom 0,42-0,46 (sous le seuil) ; commande ≥ 2 s
(« …du salon, s'il te plaît. ») → Tom 0,57, Pierre 0,77 sur la découpe exacte du serveur. Refaits :
`tom-porte` (« Ouvre la porte du salon, s'il te plaît. », détecteur 1,00) et `pierre-lumiere`
(« Allume la lumière du salon, s'il te plaît. », 0,95). 162 tests, pyright 0 erreur.
- **2026-09-30** — **Essai réel de l'étape 14** (« satisfaisant, ma voix Denis OK »). Denis : lumière
on/off exécutées (0,71-0,84), porte → confirmation → « oui » (bouton, ou « croft ? Oui. » en mains
libres) → exécutée ; Tom (fichier téléphone, 0,68) porte → refusé ; Pierre (0,83) identifié.
**Bug trouvé** : Pierre (standard) REFUSÉ pour allumer la lumière, « requis complet ». Cause : YAML 1.1
lit `on`/`off` nus comme des **booléens** → règles `light.True/False`, action `on` absente → défaut
complet. Corrigé : guillemets dans config.yaml, `settings._key()` rend True/False → on/off, et
`AccessPolicy` **refuse au démarrage** une action inconnue (plus d'erreur silencieuse). Aussi : Denis
à 0,59 (< full_min_score 0,6) recevait « votre profil ne permet pas » — faux ; message désormais
« je ne reconnais pas assez nettement votre voix… Répétez, plus près du micro. ». Autres constats,
non corrigés : 1re session hallucinée par Whisper (0,82 s de parole → « Je vous remercie de votre
soutien… », 5,5 s de calcul) ; un « Ouvre » perdu en tête de commande (« la porte du salon… » → « Que
dois-je faire avec la porte ? »), réussi au 2e essai. 165 tests, pyright 0 erreur.
- **2026-09-30** — **Étape 14 validée** par l'utilisateur. Étape 15 lancée.
- **2026-09-30** — **Étape 15 écrite** (broker + serveur + firmware). Broker : `voice.conf`
(`allow_anonymous false`, `password_file`, `acl_file`), `mosquitto/acl`, `make mqtt-user`,
`mosquitto-docker`/`mosquitto-config` installent acl + passwd (0600). Vérifié sur un broker **jetable**
(conteneur `voice-acl-test`, port 18830, supprimé ensuite ; le broker de l'utilisateur n'a pas été
touché) : anonyme et mauvais mot de passe refusés ; carte qui publie sous le nom d'une autre, lit le
micro ou les commandes d'une autre, publie sur home/ → jeté ; carte lit ses commandes et la présence.
Un broker **ouvert** accepte un client qui envoie des identifiants (vérifié) → migration : comptes,
.env + secrets.h, firmware + serveur, broker en dernier. Serveur : `MqttLink(presence=True)`,
`refusal_hint`, `setup_logging` ; vérifié : arrêt propre → `offline`, `kill -9` → testament
immédiat, mauvais mot de passe → message clair, `make run-echo` avec config de test → journal
fichier. Firmware : TWDT 15 s, `setConnectionTimeout(1000)`, Wi-Fi relancé à 30 s, présence
serveur (micro coupé si absent), utilisateur MQTT = DEVICE_ID par défaut, diagnostic codes 4/5.
`make fw-check` SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). 170 tests, pyright 0 erreur.
Non vérifié sur la carte : déclenchement réel du chien de garde (pas de commande de test).
En attente de l'essai réel et de la validation.
- **2026-09-30** — **Étape 15 validée** par l'utilisateur, qui **garde le broker de développement
anonyme** (`make mosquitto-docker` non lancé ; comptes et ACL à activer en production). Conséquence
à rappeler : tant que le broker est anonyme, tout appareil du réseau peut publier sur les topics de
commande (cartes, et agents Linux de l'étape 15b). Étape 15b lancée : agent MQTT sur chaque
machine, configuration YAML.
- **2026-09-30** — **Étape 15b écrite** (serveur + agent ; firmware inchangé). Serveur :
`machine_control.py` (parse/decide, `MachineController` : états des agents, interprétation, commande
ou Wake-on-LAN), `command_router.py`, `MqttLink.on_topic` (filtres hors voice/), section `machines`
de config.yaml (actions : mots + phrases ; `pc-bureau`), `AccessPolicy` et `Assistant` généralisés
(`Command = DeviceCommand | MachineCommand`). Agent : `agent/voice_agent.py`, `agent.yaml.example`,
service systemd, `agent/Makefile` ; cibles `agent-run`, `agent-install`, `agent-log`, `mqtt-agents` ;
règles agents dans `mosquitto/acl` (pour la production). Essai de bout en bout sur broker **jetable**
anonyme (port 18832, supprimé) : agent en `dry_run` annoncé avec 3 actions ; « éteins » et
« verrouille » → agent → compte rendu ; « allume » → « déjà en marche » ; agent arrêté → hors ligne →
« ne répond pas ». 202 tests, pyright 0 erreur (agent/ inclus). En attente de l'essai réel.
- **2026-09-30** — **Git initialisé** à la demande de l'utilisateur, avant l'essai de l'étape 15b :
premier commit (108 fichiers) sur `main` ; contrôle des fichiers sensibles fait (§7).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Denis Defolie denis.defolie@pm.me
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+273
View File
@@ -0,0 +1,273 @@
# Makefile — toutes les opérations système du projet assistant vocal.
# Cible par défaut : help.
SHELL := /bin/bash
VENV := server/.venv
PY := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
# PlatformIO vit dans son propre environnement : ses dépendances entrent sinon en
# conflit avec celles du serveur vocal (constaté avec click / esptool).
PIO_VENV := .pio-venv
PIO := $(PIO_VENV)/bin/pio
FW_DIR := firmware
SERIAL_PORT ?= /dev/ttyUSB0
BAUD ?= 921600
OUT ?= recordings/test.wav
TONE ?= 440
TEXT ?= Quelle heure est-il ?
LAST ?= 5
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
MSG ?= {"cmd":"ping"}
MOSQ_DIR ?= $(HOME)/srv/mosquitto
MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml
# Clients MQTT : ceux du système s'ils sont installés, sinon ceux de l'image Docker.
MOSQ_RUN := $(shell command -v mosquitto_sub >/dev/null 2>&1 || echo "docker run --rm --network host eclipse-mosquitto:2.0")
# Comptes MQTT (étape 15) : fichier de mots de passe hachés, jamais versionné.
MOSQ_PASSWD := $(shell command -v mosquitto_passwd >/dev/null 2>&1 && echo "mosquitto_passwd mosquitto/passwd" \
|| echo "docker run --rm -it --user $$(id -u):$$(id -g) -v $(CURDIR)/mosquitto:/work eclipse-mosquitto:2.0 mosquitto_passwd /work/passwd")
# Les cibles mqtt-* prennent les identifiants du serveur dans server/.env. Leurs
# recettes commencent par @ : make n'affiche pas la ligne, donc pas le mot de passe.
-include server/.env
MQTT_AUTH = $(if $(MQTT_USERNAME),-u '$(MQTT_USERNAME)' -P '$(MQTT_PASSWORD)')
MQTT_AS = @echo "(compte MQTT : $(if $(MQTT_USERNAME),$(MQTT_USERNAME) de server/.env,anonyme))"
.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 \
fw-monitor clean
## ------------------------------------------------------------------ aide
help: ## Affiche cette aide
@echo "Assistant vocal local ESP32 — cibles disponibles :"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-18s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "Variables : SERIAL_PORT=$(SERIAL_PORT) BAUD=$(BAUD) OUT=$(OUT) TONE=$(TONE) MQTT_HOST=$(MQTT_HOST) JITTER=$(JITTER)"
@echo " TEXT=\"$(TEXT)\""
## ------------------------------------------------------- installation système
install-system: ## Installe les paquets Debian nécessaires (sudo)
sudo apt-get update
sudo apt-get install -y \
mosquitto mosquitto-clients \
ffmpeg \
python3 python3-venv python3-pip \
git
@echo "Ajout de l'utilisateur au groupe dialout (accès au port série)."
sudo usermod -aG dialout $$USER
@echo "→ Déconnectez/reconnectez votre session pour activer le groupe dialout."
mosquitto-config: mosquitto/passwd ## Installe voice.conf, acl et passwd dans /etc/mosquitto et redémarre le broker
sed 's|/mosquitto/config/|/etc/mosquitto/|' mosquitto/voice.conf | sudo tee /etc/mosquitto/conf.d/voice.conf >/dev/null
sudo install -o mosquitto -g mosquitto -m 0600 mosquitto/acl /etc/mosquitto/acl
sudo install -o mosquitto -g mosquitto -m 0600 mosquitto/passwd /etc/mosquitto/passwd
sudo systemctl restart mosquitto
sudo systemctl --no-pager status mosquitto | head -n 5
mosquitto-docker: mosquitto/passwd ## Installe voice.conf, acl et passwd dans le conteneur Mosquitto et le relance
@test -f $(MOSQ_COMPOSE) || { echo "Fichier compose introuvable : MOSQ_COMPOSE=$(MOSQ_COMPOSE)"; exit 1; }
-sudo cp $(MOSQ_DIR)/config/mosquitto.conf $(MOSQ_DIR)/config/mosquitto.conf.bak
sudo install -o 1883 -g 1883 -m 0644 mosquitto/voice.conf $(MOSQ_DIR)/config/mosquitto.conf
sudo install -o 1883 -g 1883 -m 0600 mosquitto/acl $(MOSQ_DIR)/config/acl
sudo install -o 1883 -g 1883 -m 0600 mosquitto/passwd $(MOSQ_DIR)/config/passwd
docker compose -f $(MOSQ_COMPOSE) restart
@sleep 2
docker compose -f $(MOSQ_COMPOSE) logs --tail 5
mosquitto/passwd:
@echo "mosquitto/passwd absent : creez d'abord les comptes (make mqtt-user NAME=voice-server, puis NAME=esp32-01)"; exit 1
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) :"
@$(MOSQ_PASSWD) $(NAME)
@echo "compte $(NAME) enregistre, hache, dans mosquitto/passwd. Installation : make mosquitto-docker (ou mosquitto-config)."
## ------------------------------------------------------------- agents Linux (étape 15b)
agent-run: agent/agent.yaml ## Lance l'agent de CETTE machine au premier plan (agent/agent.yaml, essai)
$(PY) agent/voice_agent.py --config agent/agent.yaml
agent/agent.yaml:
sed 's/^dry_run: false/dry_run: true/' agent/agent.yaml.example > $@
@echo "agent/agent.yaml cree en MODE ESSAI (dry_run: true) : verifiez id et mqtt.host"
agent-install: ## Installe l'agent comme service sur CETTE machine (autre machine : copier agent/, make -C agent install)
$(MAKE) -C agent install
agent-log: ## Journal de l'agent installé sur cette machine
$(MAKE) -C agent log
mqtt-agents: ## Affiche états, commandes et comptes rendus des agents : agent/#
$(MQTT_AS)
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'agent/#'
mqtt-watch: ## Affiche les messages texte voice/+/... et home/# (sans l'audio binaire), Ctrl-C pour quitter
$(MQTT_AS)
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'voice/+/state' -t 'voice/+/event' \
-t 'voice/+/control' -t 'voice/+/transcript' -t 'voice/server/status' -t 'home/#'
mqtt-home: ## Affiche seulement les commandes domotiques home/# (étape 11)
$(MQTT_AS)
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'home/#'
mqtt-transcript: ## Affiche seulement « qui a dit quoi » : voice/+/transcript (étape 10)
$(MQTT_AS)
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'voice/+/transcript'
mqtt-control: ## Publie MSG sur voice/esp32-01/control
$(MQTT_AS)
@$(MOSQ_RUN) mosquitto_pub -h $(MQTT_HOST) $(MQTT_AUTH) -q 1 -t voice/esp32-01/control -m '$(MSG)'
## ----------------------------------------------------------- environnement Python
venv: ## Crée l'environnement virtuel Python
python3 -m venv $(VENV)
$(PIP) install --upgrade pip setuptools wheel
install: venv ## Installe le serveur Python (dépendances de base)
$(PIP) install -e server
install-dev: install pio-venv ## Installe en plus les outils de développement (pyright, pytest, platformio)
$(PIP) install -e "server[dev]"
install-speech: install ## Installe faster-whisper et Piper (étape 6)
$(PIP) install -e "server[speech]"
install-diarization: install-speech ## Installe pyannote et torch, version CPU (étape 8, ~1,3 Go)
$(PIP) install --index-url https://download.pytorch.org/whl/cpu \
--extra-index-url https://pypi.org/simple -e "server[diarization]"
install-wakeword: install-speech ## Installe openWakeWord, en mode ONNX (étape 13)
$(PIP) install -e "server[wakeword]"
# Sans dépendances : openwakeword exige tflite-runtime, absent pour Python 3.13 (inutile en ONNX).
$(PIP) install --no-deps "openwakeword==0.6.0"
models: ## Télécharge les modèles de config.yaml dans server/models (étapes 6, 8 et 13)
$(PY) tools/download_models.py
say: ## Fait dire [WAKE (voix anglaise) puis] TEXT par VOICE (SPEAKER) : WAV + MP3 dans SAY_OUT, pour un téléphone
$(PY) tools/say.py "$(TEXT)" --voice $(VOICE) $(if $(SPEAKER),--speaker $(SPEAKER)) \
$(if $(WAKE),--wake "$(WAKE)" --wake-voice $(WAKE_VOICE)) --out $(SAY_OUT)
speech-check: ## Synthèse, transcription et RÉPONSE de l'assistant à TEXT, sans ESP32 (étape 6)
$(PY) tools/speech_check.py "$(TEXT)"
transcribe: ## VAD, transcription et réponse du WAV OUT, sans ESP32 (étapes 6-7)
$(PY) tools/speech_check.py --wav $(OUT)
vad: ## Montre la parole trouvée par la VAD dans OUT, écrit la parole seule (étape 7)
$(PY) tools/vad_check.py $(OUT)
vad-all: ## Passe la VAD sur tous les enregistrements du serveur (étape 7)
$(PY) tools/vad_check.py server/recordings/*.wav
diarize: ## Qui parle quand dans OUT, qui est-ce, qui a dit quoi (étapes 8-10)
$(PY) tools/diarize_check.py $(OUT)
diarize-demo: ## Diarise une séquence à deux voix : la vôtre et celle de Piper (étape 8)
$(PY) tools/diarize_check.py --demo
enroll: ## Crée le profil vocal de NAME avec ses LAST dernières sessions (étape 9)
@test -n "$(NAME)" || { echo "Usage : make enroll NAME=Denis [LAST=5]"; exit 1; }
$(PY) tools/enroll.py --name "$(NAME)" --last $(LAST)
profiles: ## Liste les profils vocaux (étape 9)
$(PY) tools/enroll.py --list
forget: ## Supprime le profil vocal de NAME : donnée biométrique (étape 9)
@test -n "$(NAME)" || { echo "Usage : make forget NAME=Denis"; exit 1; }
$(PY) tools/enroll.py --forget "$(NAME)"
## ----------------------------------------------------------------- serveur
run: ## Lance l'assistant vocal : répond à chaque session (étape 6, Ctrl-C pour arrêter)
cd server && .venv/bin/python -m voice_server --config config.yaml
run-echo: ## Serveur en mode echo : chaque session est rejouee par l'ESP32 (etape 5)
cd server && .venv/bin/python -m voice_server --config config.yaml --echo
test: ## Exécute les tests Python
$(VENV)/bin/pytest server/tests -v
lint: ## Vérifie le typage Python avec pyright
$(VENV)/bin/pyright --project server/pyrightconfig.json
## ----------------------------------------------------------------- outils
play: ## Joue OUT en cherchant un lecteur qui fonctionne
@for player in paplay pw-play ffplay aplay; do \
command -v $$player >/dev/null 2>&1 || continue; \
echo "-> $$player $(OUT)"; \
case $$player in \
ffplay) ffplay -autoexit -nodisp -loglevel error "$(OUT)" && exit 0 ;; \
*) $$player "$(OUT)" && exit 0 ;; \
esac; \
done; \
echo "Aucun lecteur n'a abouti. Utilisez 'make inspect' pour verifier sans ecouter."; \
exit 1
inspect: ## Analyse OUT sans le jouer : enveloppe ASCII et niveaux
$(PY) tools/wav_inspect.py $(OUT)
play-esp: ## Joue OUT sur le haut-parleur de l'ESP32, au rythme reel (etape 2)
$(PY) tools/wav_to_serial.py --port $(SERIAL_PORT) --baud $(BAUD) $(OUT)
tone-esp: ## Joue un son pur de TONE Hz sur le haut-parleur de l'ESP32 (etape 2)
$(PY) tools/wav_to_serial.py --port $(SERIAL_PORT) --baud $(BAUD) --tone $(TONE) --seconds 3
play-mqtt: ## Joue OUT sur l'ESP32, par MQTT, avec bilan ; JITTER=ms simule un Wi-Fi irrégulier (étapes 5, 12)
$(PY) tools/wav_to_mqtt.py --jitter $(JITTER) $(OUT)
tone-mqtt: ## Joue un son pur de TONE Hz sur l'ESP32, par MQTT ; JITTER=ms simule un Wi-Fi irrégulier (étapes 5, 12)
$(PY) tools/wav_to_mqtt.py --tone $(TONE) --seconds 5 --jitter $(JITTER)
record: ## Enregistre une session depuis l'ESP32 vers un WAV (étape 1)
@mkdir -p $$(dirname $(OUT))
$(PY) tools/serial_to_wav.py --port $(SERIAL_PORT) --baud $(BAUD) --output $(OUT)
## ---------------------------------------------------------------- firmware
pio-venv: ## Installe PlatformIO dans son propre environnement virtuel
python3 -m venv $(PIO_VENV)
$(PIO_VENV)/bin/pip install --upgrade pip
$(PIO_VENV)/bin/pip install platformio
# click 8.3+ casse l'esptool 5.0 livré avec le core Arduino 3.x : on épingle 8.1.
$(PIO_VENV)/bin/pip install "click==8.1.8"
fw-build: ## Compile le firmware ESP32
@test -x $(PIO) || { echo "PlatformIO absent : lancez 'make pio-venv'."; exit 1; }
$(PIO) run -d $(FW_DIR)
fw-check: ## Compile sans secrets.h personnel (secrets factices hors du dépôt)
@test -x $(PIO) || { echo "PlatformIO absent : lancez 'make pio-venv'."; exit 1; }
@if [ -f $(FW_DIR)/include/secrets.h ]; then \
echo "secrets.h present : compilation normale"; $(PIO) run -d $(FW_DIR); \
else \
tmp=$$(mktemp -d); cp $(FW_DIR)/include/secrets.h.example $$tmp/secrets.h; \
echo "secrets.h absent : secrets factices dans $$tmp (firmware/include/ n'est pas touche)"; \
PLATFORMIO_BUILD_FLAGS="-I$$tmp" $(PIO) run -d $(FW_DIR); status=$$?; rm -rf $$tmp; exit $$status; \
fi
fw-upload: ## Compile et téléverse le firmware
$(PIO) run -d $(FW_DIR) -t upload --upload-port $(SERIAL_PORT)
fw-monitor: ## Ouvre le moniteur série (Ctrl-C pour quitter)
$(PIO) device monitor -p $(SERIAL_PORT) -b $(BAUD)
## ------------------------------------------------------------------ nettoyage
clean: ## Supprime les artefacts de compilation et les caches
rm -rf $(FW_DIR)/.pio
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
rm -rf server/*.egg-info .pytest_cache
+461
View File
@@ -0,0 +1,461 @@
# Local voice assistant — ESP32 + Raspberry Pi
A **fully local** voice assistant, with no cloud service: an ESP32 captures
speech, streams raw PCM over MQTT to a Python server that transcribes,
interprets and answers with speech synthesis; the ESP32 plays the answer back
and executes the command.
*Version française : [README.md](README.md).*
---
## 1. Architecture
```
INMP441 -> ESP32 -> MQTT -> Python server (Raspberry Pi / PC)
|
+---------+---------+
| |
VAD Diarization /
| identification
+---------+---------+
|
faster-whisper
|
Assistant
|
Piper
|
MQTT audio/out
|
ESP32 -> MAX98357A -> speaker
```
Four often-confused stages, never to be mixed up:
| Stage | Question it answers |
|---|---|
| VAD | Is someone speaking? |
| Diarization | Who speaks when? (SPEAKER_00, SPEAKER_01…) |
| Identification | Who is this person? (Denis, Marie, Unknown) |
| STT | What is being said? |
> Voice identification is **probabilistic**. It is never an authentication or
> security mechanism.
---
## 2. Hardware
| Item | Detail |
|---|---|
| ESP32-WROOM-32 | two I2S peripherals: I2S0 as receiver, I2S1 as transmitter |
| INMP441 | I2S microphone, **3.3 V**, L/R pin to GND (left channel) |
| MAX98357A | mono class-D amplifier, **5 V**, SD tied to VIN, GAIN left floating (9 dB) |
| Button | push-to-talk between GPIO 4 and GND (internal pull-up) |
| LED | with a 330 Ω resistor on GPIO 2 |
| Server | Raspberry Pi 4/5 64-bit or Debian PC |
### Wiring
**Full diagram: [docs/wiring.svg](docs/wiring.svg)** — detailed pinout, power
budget and multimeter checks (in French): **[docs/BROCHAGE.md](docs/BROCHAGE.md)**.
```
ESP32 INMP441 (microphone)
3V3 ----------------------- VDD
GND ----------------------- GND, L/R
GPIO 25 (SCK) ------------- SCK
GPIO 26 (WS) ------------- WS
GPIO 33 (SD) ------------- SD
ESP32 MAX98357A (amplifier, step 2)
5V (VIN) ------------------- VIN
GND ----------------------- GND
GPIO 27 (BCLK) ------------- BCLK
GPIO 14 (LRC) ------------- LRC
GPIO 22 (DIN) ------------- DIN
ESP32 User interface
GPIO 4 ------ button ------ GND
GPIO 2 --[330 Ω]-- LED ---- GND
```
No strapping pin (GPIO 0, 2, 5, 12, 15) carries an audio signal: their level at
reset selects the boot mode. GPIO 2 drives the LED as an output only, which does
not affect booting.
---
## 3. Audio format, identical everywhere
| Parameter | Value |
|---|---|
| Sample rate | 16 000 Hz |
| Resolution | signed 16-bit, little-endian (`pcm_s16le`) |
| Channels | 1 (mono) |
| Bit rate | 32 000 bytes/s |
| Chunk | 50 ms = 800 samples = **1 600 bytes** |
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.
# 2. Python environment and dev tooling (pyright, pytest, 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
make install-speech
make models
# 4. Diarization (step 8): pyannote + CPU torch (~1.3 GB), Hugging Face token required
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. Help
make help
```
---
## 5. Usage
```bash
make fw-build # build the firmware
make fw-upload SERIAL_PORT=/dev/ttyUSB0
make record OUT=recordings/test.wav # record one session (step 1)
make play OUT=recordings/test.wav # play it back (picks a working player)
make inspect OUT=recordings/test.wav # analyse it without listening
make play-esp OUT=recordings/test.wav # play it on the ESP32 speaker (step 2)
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 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
make diarize OUT=server/recordings/<file>.wav # who speaks when, who, who said what (steps 8-10)
make diarize-demo # two voices: yours and Piper's
make enroll NAME=Denis # voice profile from the last 5 sessions (step 9)
make profiles # list profiles; make forget NAME=Denis deletes one
make mqtt-watch # text messages voice/… and home/# (no audio)
make mqtt-user NAME=esp32-01 # MQTT account for a board, or the server (step 15)
make agent-run # Linux agent of this PC, test mode (step 15b)
make mqtt-agents # agent states, commands and reports
make fw-monitor # serial monitor
make test # Python tests
make lint # pyright type check
```
---
## 6. Progress
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.
### 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,
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).
| Action | Words | Rights (config.yaml) |
|---|---|---|
| `wake` (server) | allume, démarre, réveille | standard |
| `lock` | verrouille | standard |
| `shutdown` | éteins, arrête | full + confirmation |
| `reboot` | redémarre, relance | full + confirmation |
1. Try it on this PC without shutting anything down: `make agent-run` (creates `agent/agent.yaml` in test mode).
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`).
**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).
### Step 15 — reliability
| What | How |
|---|---|
| MQTT accounts and rights | no anonymous access; a board reads and writes **its own** topics only ([docs/MOSQUITTO.md](docs/MOSQUITTO.md) §2) |
| Server presence | `voice/server/status`: retained `online`, `offline` last will; server gone = board microphones off |
| Watchdog | board frozen for more than 15 s → reboot, reason `watchdog` in the `boot` event |
| Network | TCP connection to the broker within 1 s; Wi-Fi restarted after 30 s without it |
| Log | copied to `server/logs/voice-server.log` (rotating files, 4 MB at most) |
Setup, **in this order**: a broker that is still open already accepts clients that have an account.
1. `make mqtt-user NAME=voice-server`, then `make mqtt-user NAME=esp32-01` (passwords: `openssl rand -hex 16`).
2. `server/.env`: `MQTT_USERNAME=voice-server`, `MQTT_PASSWORD=...`; `secrets.h`: `MQTT_PASSWORD="..."`.
3. `make fw-upload` and `make run`: everything works as before, the server announces its presence.
4. `make mosquitto-docker`: the broker now requires the accounts.
### Step 14 — per-profile rights
| Level | Rights | Given to |
|---|---|---|
| **complet** (full) | everything; sensitive actions (open the door) after a "oui" **confirmation** | profiles listed in `access.users`, **if the voice is clearly recognised** (score ≥ 0.6) |
| **standard** | lights, shutters, closing the door | enrolled profile without a level (`access.default`) |
| **limite** (limited) | conversation only | unknown voice (`access.unknown`) |
Rules per device and action in `config.yaml` (section `access`); an action missing from the
rules requires the full level. Names in `access.users` are those shown by `make profiles`.
1. `make run` (server only, firmware unchanged).
2. "Hey Mycroft, allume la lumière" → executed (Denis, full).
3. "Hey Mycroft, ouvre la porte" → "Confirmez-vous : ouvrir la porte du salon ?" →
"Hey Mycroft, oui" → "J'ouvre la porte du salon."; "non" → "D'accord, j'annule."
4. A phone voice (standard profile): "ouvre la porte" → refused; a non-enrolled voice:
"allume la lumière" → "Je ne vous ai pas reconnu…". Ready-made sentences:
`recordings/voix-test/tom-porte.mp3`, `pierre-lumiere.mp3`; more with `make say`. The wake word is spoken by an English
voice (`WAKE`): a synthetic voice saying it the French way is not recognised.
**Reminder**: a voice is never proof of identity. These rights prevent mistakes and guests'
commands, not an attack (recording, impersonation).
### Step 13 — wake word (hands-free)
The server asks the board to stream its microphone continuously (`voice/<board>/audio/stream`)
and listens for the wake word with openWakeWord: **"Alexa"**, **"Hey Mycroft"** or **"Hey
Jarvis"**, pronounced the English way. Once heard, it keeps the command until the final
silence, then handles it like a push-to-talk session. The button still works at any time.
LED: **short flash every 2 s** = continuous listening; **on** = wake word heard, command in
progress; slow blink = ready, no continuous listening.
1. `make install-wakeword` then `make models` (5 ONNX models, 5 MB).
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 13 firmware), then `make run`.
3. "Alexa, allume la lumière" → LED on during the command, then answer and command.
4. A sentence without the wake word → nothing happens, nothing is recorded.
5. "Alexa" alone, then silence → back to standby after 4 s.
**Privacy**: before the wake word, only the last 500 ms are kept in memory, nothing is
written to disk (checked by a test). But audio flows continuously over the network: as long
as the broker accepts anonymous clients (step 15), anyone on the LAN can listen to it.
Turn listening off: `wake.enabled: false`, or
`make mqtt-control MSG='{"cmd":"listen","enabled":false}'`.
### Step 12 — anti-jitter output buffer
The board plays nothing until it holds **300 ms of audio in reserve** (priming); the
Wi-Fi may then deliver chunks late or in bursts without it being heard. If the reserve
runs dry, playback stops cleanly and resumes once the reserve is rebuilt (re-priming)
instead of stuttering. The `played` report gives the delay to the first sample, the
**minimum margin** (network delay still absorbable) and the number of re-primings.
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 12 firmware).
2. `make tone-mqtt`: expected `reamorcages 0`, minimum margin ≈ 250 ms.
3. `make tone-mqtt JITTER=200`: a 200 ms stall every 500 ms; sound still continuous,
`reamorcages 0`, minimum margin ≈ 100 ms.
4. `make tone-mqtt JITTER=400`: beyond the reserve; clean pauses (re-primings > 0),
never any crackling.
5. `make run`: assistant answers, with `reamorcages 0` at the usual signal level.
### Step 11 — home automation
A command is three words: an **action** (allumer, éteindre, ouvrir, fermer), a **device**
(lumière, volets, porte) and a **room** (listed in `config.yaml`, section `home`). Without a
room, the board's own room is used. The server publishes `home/<room>/<device>/set` for any
home automation system; when the room is the board's, the board also executes it: demo LED
on GPIO 21 for the light, simulated shutters and door (serial log).
1. Wire the LED (optional): GPIO 21 → 330 Ω → LED → GND ([docs/BROCHAGE.md](docs/BROCHAGE.md) §10).
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 11 firmware), then `make run`.
3. In another terminal: `make mqtt-watch` (voice/… as text and home/#).
4. "Allume la lumière" → "J'allume la lumière du salon.", LED on, `home/salon/light/set`
and the confirmation `{"event":"device",...,"ok":true}`.
5. "Ferme les volets de la cuisine" → `home/cuisine/shutter/set` only (another room).
6. "Allume les volets" → "Je ne sais pas allumer les volets."; "Ouvre" → "Que dois-je ouvrir ?".
**Security**: voice identification is never proof. Do not connect a door strike or an
alarm to these commands; per-profile rights come in step 14.
### Step 10 — who said what
Whisper gives the time of each word, the diarization the time of each speaker turn, on
the same audio: each word goes to whoever was speaking at that moment. The server
publishes the result on `voice/<board>/transcript` and the assistant calls the person by name.
```json
{"session": "453cce", "utterances": [
{"speaker": "Denis", "text": "Ouvrir la porte.", "start": 0.0, "end": 1.26, "score": 0.6},
{"speaker": "Siwis", "text": "Je suis la voix de synthèse...", "start": 1.5, "end": 4.46, "score": 0.6},
{"speaker": "Denis", "text": "Fermez la porte.", "start": 4.92, "end": 5.86, "score": 0.6}]}
```
1. `make diarize OUT=server/recordings/<file>.wav`: who said what, and the MQTT message, no board needed.
2. `make mqtt-watch` in one terminal, `make run` in another.
3. "Bonjour": the board answers "Bonjour Denis !"; `voice/esp32-01/transcript` shows up.
4. A two-voice session (you, a phone voice, you again): three utterances.
### Step 9 — speaker identification
Each voice found by the diarization is summarised by an **embedding** (256 numbers,
WeSpeaker model from the pyannote pipeline), compared by **cosine similarity** with the
enrolled profiles. Above the threshold (0.45, measured on this bench), the name;
otherwise "Inconnu". **It is an estimate, never a security feature**: a recording or an
impersonation can fool it.
1. `make run`, then 5 varied sentences of 1 to 4 s, alone, at a normal distance.
2. `make enroll NAME=Denis`: profile built from those 5 sessions; sessions that are too
short, contain several voices or do not match the others are dropped.
3. New questions (the server picks up the profile without restarting): the log shows
`SPEAKER_00 = Denis (0.74)`; another person or a loudspeaker voice gives
`Inconnu (0.05, proche de Denis)`.
4. `make diarize-demo`: your voice recognised, Piper's voice "Inconnu".
Profiles are **biometric data**: `server/profiles/`, never versioned, one readable JSON
file per person; `make forget NAME=...` deletes it.
### Step 8 — diarization: who speaks when
The speech found by the VAD is split into speaker turns: `SPEAKER_00` from 0.0 to 1.3 s,
`SPEAKER_01` from 1.4 to 2.9 s... Labels only hold within one session: putting a name on
a voice is step 9. Model `pyannote/speaker-diarization-community-1` (33 MB), run on CPU,
**pyannote telemetry disabled** (enabled by default in version 4).
1. Create an account on <https://huggingface.co>, then **accept the conditions** at
<https://huggingface.co/pyannote/speaker-diarization-community-1>.
2. Create a "Read" token at <https://huggingface.co/settings/tokens> and put it in
`server/.env`: `HF_TOKEN=hf_...` (`cp server/.env.example server/.env` if missing).
3. `make install-diarization` then `make models`. No Internet access afterwards.
4. `make diarize-demo`: your voice, Piper's voice, your voice → two speakers expected.
5. `make run`: one session where two people take turns; the log shows
`2 locuteur(s) : SPEAKER_00 0.00-1.30, SPEAKER_01 1.40-2.90`.
Too heavy for a Raspberry Pi 4: set `diarization.enabled: false` in `config.yaml`.
### Step 7 — voice activity detection (VAD)
The server now transcribes speech only: leading and trailing silence is removed,
sessions without speech (silent press, click) are dropped without calling Whisper.
Silero ships with faster-whisper: nothing more to install.
1. `make vad-all`: one character per 32 ms window (`#` speech, `_` silence) for
each recording, plus the number of sessions without speech.
2. `make vad OUT=server/recordings/<file>.wav` then `make play OUT=recordings/parole.wav`:
speech only, silences removed.
3. `make run` (restart it: the code changed), ask a few questions and press once
without speaking. The log shows `parole 1.36 s sur 2.00 s (segments 0.49-1.85 s)`,
and `sans parole (VAD)` for the silent press, answered immediately.
Settings in `config.yaml`, section `vad`: threshold, minimum pause, minimum speech, padding.
### Step 6 — MVP: the assistant answers
1. `make install-speech` then `make models` (faster-whisper `small`, Piper voice
`fr_FR-siwis-medium`, about 550 MB in `server/models/`, once).
2. `make speech-check`: Piper says "Quelle heure est-il ?", Whisper reads it
back, the assistant answers; no hardware needed.
3. `make transcribe OUT=server/recordings/<file>.wav`: transcribes a sentence
recorded by the ESP32 in step 4.
4. `make run`: press the button, ask "Quelle heure est-il ?", release; the board
answers. Recognised questions: time, date, hello, thanks, your name; anything
else is repeated back as understood.
The log shows the time spent in each stage: `transcription 1.0 s, synthese
0.1 s, reponse prete en 1.1 s`. `make run-echo` keeps the step 5 echo mode,
without models.
### Step 5 — the server makes the ESP32 speak, over MQTT
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 5 firmware).
2. `make tone-mqtt`: 3 s of pure tone sent over MQTT; the board report is printed
(`chunks 60/60, underruns 0`).
3. `make play-mqtt OUT=server/recordings/<file>.wav`: any WAV, converted to
16 kHz mono, peak at −3 dBFS.
4. `make run-echo`: the server plays each sentence back on the board that said it.
### Step 4 — voice to the server, over MQTT
1. `make install` (server dependencies: paho-mqtt, PyYAML, numpy...).
2. `make run`: the server connects to the broker and waits for sessions.
3. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`, then press the button, speak, release.
4. The server writes `server/recordings/esp32-01_<date>_<session>.wav` and logs a
report: `complete`, received / announced chunks, peak and RMS.
5. `make play OUT=server/recordings/<file>.wav` or `make inspect OUT=...`.
If the broker is unreachable when the button is pressed, the session goes over
the serial link: `make record` remains available for troubleshooting.
### Step 3 — Wi-Fi and MQTT
1. Broker: see [docs/MOSQUITTO.md](docs/MOSQUITTO.md) (Debian service or container).
2. `cp firmware/include/secrets.h.example firmware/include/secrets.h`, then set
the Wi-Fi (2.4 GHz) and the broker IP address.
3. `make mqtt-watch MQTT_HOST=<broker ip>` in a first terminal.
4. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` in a second one.
Expected in the first terminal: `voice/esp32-01/state {"status":"online",...}`
and `voice/esp32-01/event {"event":"boot",...}`. After cutting the ESP32 power:
`voice/esp32-01/state {"status":"offline"}` within 15 s.
### Step 2 — test the audio output
1. Wire the MAX98357A amplifier and the speaker (§2), including C1 and C2.
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
3. At boot, a **440 Hz beep** plays: it tests the output on its own.
4. `make tone-esp`: 3 s of pure tone streamed from the PC in real time. Any
dropout is heard as a click.
5. `make play-esp OUT=recordings/test.wav`: the step 1 recording played back on
the ESP32 speaker.
Expected: clean tone, no clicks or crackle; the ESP32 report shows `0 refuses`
(rejected) and `0 sous-alimentations` (underruns).
### Step 1 — test the audio input
1. Wire microphone, button and LED as in §2.
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
3. `make record OUT=recordings/test.wav`
4. Press the button, say a sentence, release it.
5. Listen: `make play OUT=recordings/test.wav`
6. With no audio output: `make inspect OUT=recordings/test.wav` draws the signal
envelope in ASCII and reports peak, RMS and DC offset.
Expected: the sentence is intelligible and not clipped; the script reports a peak
between 10 % and 90 % of full scale. Above 99 %, lower `MIC_GAIN` in
[firmware/include/config.h](firmware/include/config.h); below 2 %, raise it.
---
## 7. Repository layout
```
voice-assistant/
├── firmware/ ESP32 firmware (PlatformIO, Arduino)
│ ├── include/ config.h, secrets.h.example
│ └── src/ main.cpp + one .h/.cpp pair per class
├── server/ Python server (one module per class)
├── tools/ standalone test tools
├── mosquitto/ voice.conf
├── Makefile every system operation
├── CONTEXT.md progress log and frozen technical decisions
└── README.md / README-EN.md
```
---
## 8. License
MIT — see [LICENSE](LICENSE).
+461
View File
@@ -0,0 +1,461 @@
# Assistant vocal local — ESP32 + Raspberry Pi
Assistant vocal **100 % local**, sans aucun service cloud : un ESP32 capture la
voix, l'envoie en PCM brut par MQTT à un serveur Python qui transcrit, interprète
et répond en synthèse vocale ; l'ESP32 joue la réponse et exécute la commande.
*English version: [README-EN.md](README-EN.md).*
---
## 1. Architecture
```
INMP441 -> ESP32 -> MQTT -> Serveur Python (Raspberry Pi / PC)
|
+---------+---------+
| |
VAD Diarisation /
| identification
+---------+---------+
|
faster-whisper
|
Assistant
|
Piper
|
MQTT audio/out
|
ESP32 -> MAX98357A -> haut-parleur
```
Quatre traitements souvent confondus, à ne jamais mélanger :
| Bloc | Question |
|---|---|
| VAD | Est-ce que quelqu'un parle ? |
| Diarisation | Qui parle à quel moment ? (SPEAKER_00, SPEAKER_01…) |
| Identification | Qui est cette personne ? (Denis, Marie, Inconnu) |
| STT | Qu'est-ce qui est dit ? |
> L'identification vocale est **probabiliste**. Ce n'est jamais un mécanisme
> d'authentification ni de sécurité.
---
## 2. Matériel
| Élément | Détail |
|---|---|
| ESP32-WROOM-32 | deux périphériques I2S : I2S0 en réception, I2S1 en émission |
| INMP441 | micro I2S, **3,3 V**, broche L/R à GND (canal gauche) |
| MAX98357A | ampli classe D mono, **5 V**, SD à VIN, GAIN laissé libre (9 dB) |
| Bouton | Push-To-Talk entre GPIO 4 et GND (pull-up interne) |
| LED | + résistance 330 Ω sur GPIO 2 |
| Serveur | Raspberry Pi 4/5 64 bits ou PC Debian |
### Câblage
**Schéma complet : [docs/wiring.svg](docs/wiring.svg)** — brochage détaillé,
bilan de consommation et vérifications au multimètre : **[docs/BROCHAGE.md](docs/BROCHAGE.md)**.
```
ESP32 INMP441 (micro)
3V3 ----------------------- VDD
GND ----------------------- GND, L/R
GPIO 25 (SCK) ------------- SCK
GPIO 26 (WS) ------------- WS
GPIO 33 (SD) ------------- SD
ESP32 MAX98357A (ampli, étape 2)
5V (VIN) ------------------- VIN
GND ----------------------- GND
GPIO 27 (BCLK) ------------- BCLK
GPIO 14 (LRC) ------------- LRC
GPIO 22 (DIN) ------------- DIN
ESP32 Interface
GPIO 4 ------ bouton ------ GND
GPIO 2 --[330 Ω]-- LED ---- GND
```
Aucune broche de strapping (GPIO 0, 2, 5, 12, 15) n'est utilisée pour l'audio :
leur niveau au démarrage change le mode de boot de l'ESP32. GPIO 2 est utilisé en
sortie seule pour la LED, ce qui reste sans effet sur le boot.
---
## 3. Format audio, unique dans tout le système
| Paramètre | Valeur |
|---|---|
| Fréquence | 16 000 Hz |
| Résolution | 16 bits signés, little-endian (`pcm_s16le`) |
| Canaux | 1 (mono) |
| Débit | 32 000 octets/s |
| Chunk | 50 ms = 800 échantillons = **1 600 octets** |
Pas de Base64 : les payloads MQTT contiennent des octets PCM bruts.
---
## 4. Installation
```bash
# 1. Paquets système (mosquitto, ffmpeg, venv) + accès au port série
make install-system
# Se déconnecter/reconnecter pour activer le groupe « dialout ».
# 2. Environnement Python et outils de développement (pyright, pytest, platformio)
make install-dev
# PlatformIO est installé 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
make install-speech
make models
# 4. Diarisation (étape 8) : pyannote + torch CPU (~1,3 Go), jeton Hugging Face requis
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. Aide
make help
```
---
## 5. Utilisation
```bash
make fw-build # compile le firmware
make fw-upload SERIAL_PORT=/dev/ttyUSB0
make record OUT=recordings/essai.wav # enregistre une session (étape 1)
make play OUT=recordings/essai.wav # joue le WAV (cherche un lecteur qui marche)
make inspect OUT=recordings/essai.wav # analyse le WAV sans l'écouter
make play-esp OUT=recordings/essai.wav # joue le WAV sur le haut-parleur de l'ESP32 (étape 2)
make tone-esp TONE=440 # son pur sur le haut-parleur de l'ESP32 (étape 2)
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 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
make diarize OUT=server/recordings/<fichier>.wav # qui parle quand, qui, qui a dit quoi (étapes 8-10)
make diarize-demo # deux voix : la vôtre et celle de Piper
make enroll NAME=Denis # profil vocal depuis les 5 dernières sessions (étape 9)
make profiles # liste des profils ; make forget NAME=Denis pour en supprimer un
make mqtt-watch # messages texte voice/… et home/# (sans l'audio)
make mqtt-user NAME=esp32-01 # compte MQTT d'une carte, ou du serveur (étape 15)
make agent-run # agent Linux de ce PC, en mode essai (étape 15b)
make mqtt-agents # états, commandes et comptes rendus des agents
make fw-monitor # moniteur série
make test # tests Python
make lint # typage pyright
```
---
## 6. Avancement
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 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
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).
| Action | Mots | Droits (config.yaml) |
|---|---|---|
| `wake` (serveur) | allume, démarre, réveille | standard |
| `lock` | verrouille | standard |
| `shutdown` | éteins, arrête | complet + confirmation |
| `reboot` | redémarre, relance | complet + confirmation |
1. Essai sur ce PC, sans rien éteindre : `make agent-run` (crée `agent/agent.yaml` en mode essai).
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`).
**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).
### Étape 15 — fiabilisation
| Quoi | Comment |
|---|---|
| Comptes MQTT et droits | broker sans accès anonyme ; une carte ne lit et n'écrit que **ses** topics ([docs/MOSQUITTO.md](docs/MOSQUITTO.md) §2) |
| Présence du serveur | `voice/server/status` : `online` retenu, testament `offline` ; serveur absent = micros des cartes coupés |
| Chien de garde (*watchdog*) | carte figée plus de 15 s → redémarrage, cause `watchdog` dans l'événement `boot` |
| Réseau | connexion TCP au broker en 1 s au plus ; Wi-Fi relancé après 30 s d'absence |
| Journal | copie dans `server/logs/voice-server.log` (fichiers tournants, 4 Mo au plus) |
Mise en place, **dans cet ordre** : un broker encore ouvert accepte déjà les clients qui ont un compte.
1. `make mqtt-user NAME=voice-server`, puis `make mqtt-user NAME=esp32-01` (mots de passe : `openssl rand -hex 16`).
2. `server/.env` : `MQTT_USERNAME=voice-server`, `MQTT_PASSWORD=...` ; `secrets.h` : `MQTT_PASSWORD="..."`.
3. `make fw-upload` et `make run` : tout marche comme avant, le serveur annonce sa présence.
4. `make mosquitto-docker` : le broker exige désormais les comptes.
### Étape 14 — droits par profil
| Niveau | Droits | Attribué à |
|---|---|---|
| **complet** | tout ; actions sensibles (ouvrir la porte) après **confirmation** « oui » | profils listés dans `access.users`, **si la voix est reconnue nettement** (score ≥ 0,6) |
| **standard** | lumières, volets, fermer la porte | profil enrôlé sans niveau (`access.default`) |
| **limite** | conversation seulement | voix inconnue (`access.unknown`) |
Règles par appareil et action dans `config.yaml` (section `access`) ; une action absente des
règles exige le niveau complet. Les noms de `access.users` sont ceux de `make profiles`.
1. `make run` (serveur seul, firmware inchangé).
2. « Hey Mycroft, allume la lumière » → exécuté (Denis, complet).
3. « Hey Mycroft, ouvre la porte » → « Confirmez-vous : ouvrir la porte du salon ? » →
« Hey Mycroft, oui » → « J'ouvre la porte du salon. » ; « non » → « D'accord, j'annule. »
4. Une voix du téléphone (profil standard) : « ouvre la porte » → refus ; une voix non
enrôlée : « allume la lumière » → « Je ne vous ai pas reconnu… ». Phrases prêtes :
`recordings/voix-test/tom-porte.mp3`, `pierre-lumiere.mp3` ; d'autres avec `make say`. Le mot de réveil y est dit par une
voix anglaise (`WAKE`) : prononcé à la française par une voix de synthèse, il n'est pas reconnu.
**Rappel** : la voix n'est jamais une preuve d'identité. Ces droits évitent les erreurs et les
commandes d'un invité, pas une attaque (enregistrement, imitation).
### Étape 13 — mot de réveil (mains libres)
Le serveur demande à la carte d'envoyer son micro en continu (`voice/<carte>/audio/stream`)
et guette le mot de réveil avec openWakeWord : **« Alexa »**, **« Hé Maïcrofte »** (Hey
Mycroft) ou **« Hey Djâr-viss »** (Hey Jarvis), prononcés à l'anglaise. Une fois le mot
entendu, il garde la commande jusqu'au silence final, puis la traite comme une session du
bouton. Le bouton reste utilisable à tout moment.
LED : **éclair bref toutes les 2 s** = écoute continue ; **allumée** = mot entendu, commande
en cours ; clignotement lent = prêt, sans écoute continue.
1. `make install-wakeword` puis `make models` (5 modèles ONNX, 5 Mo).
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 13), puis `make run`.
3. « Alexa, allume la lumière » → LED allumée pendant la commande, puis réponse et commande.
4. Une phrase sans le mot → rien ne se passe, rien n'est enregistré.
5. « Alexa » seul, puis rien → retour en veille après 4 s.
**Vie privée** : avant le mot, seules les 500 dernières ms restent en mémoire, rien n'est
écrit sur disque (vérifié par un test). Mais le son circule en continu sur le réseau : tant
que le broker accepte les clients anonymes (étape 15), n'importe qui sur le réseau local peut
l'écouter. Couper l'écoute : `wake.enabled: false`, ou
`make mqtt-control MSG='{"cmd":"listen","enabled":false}'`.
### Étape 12 — tampon de sortie anti-gigue
La carte ne joue rien tant qu'elle n'a pas **300 ms de son en réserve** (amorçage) ; le
Wi-Fi peut alors livrer les chunks en retard ou en rafale sans que cela s'entende. Si la
réserve s'épuise, la lecture s'arrête proprement et reprend une fois la réserve refaite
(réamorçage), au lieu de hacher le son. Le bilan `played` donne le délai avant le premier
son, la **marge minimale** (retard encore absorbable) et le nombre de réamorçages.
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 12).
2. `make tone-mqtt` : attendu `reamorcages 0`, marge mini ≈ 250 ms.
3. `make tone-mqtt JITTER=200` : un blocage de 200 ms toutes les 500 ms ; son toujours
continu, `reamorcages 0`, marge mini ≈ 100 ms.
4. `make tone-mqtt JITTER=400` : au-delà de la réserve ; pauses franches (réamorçages > 0),
jamais de grésillement.
5. `make run` : les réponses de l'assistant, avec `reamorcages 0` au signal habituel.
### Étape 11 — domotique
Une commande tient en trois mots : une **action** (allumer, éteindre, ouvrir, fermer), un
**appareil** (lumière, volets, porte) et une **pièce** (liste dans `config.yaml`, section `home`).
Sans pièce, c'est celle de la carte. Le serveur publie `home/<pièce>/<appareil>/set` pour
toute domotique ; si la pièce est celle de la carte, la carte exécute aussi la commande :
LED de démonstration sur GPIO 21 pour la lumière, volets et porte simulés (liaison série).
1. Câbler la LED (facultatif) : GPIO 21 → 330 Ω → LED → GND ([docs/BROCHAGE.md](docs/BROCHAGE.md) §10).
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 11), puis `make run`.
3. Terminal à part : `make mqtt-watch` (voice/… en texte et home/#).
4. « Allume la lumière » → « J'allume la lumière du salon. », LED allumée,
`home/salon/light/set` et la confirmation `{"event":"device",...,"ok":true}`.
5. « Ferme les volets de la cuisine » → `home/cuisine/shutter/set` seulement (autre pièce).
6. « Allume les volets » → « Je ne sais pas allumer les volets. » ; « Ouvre » → « Que dois-je ouvrir ? ».
**Sécurité** : l'identification vocale n'est jamais une preuve. Ne branchez pas une gâche
de porte ou une alarme sur ces commandes ; les droits par profil arrivent à l'étape 14.
### Étape 10 — qui a dit quoi
Whisper donne l'instant de chaque mot, la diarisation celui de chaque tour de parole, sur
le même audio : chaque mot va au locuteur qui parlait à ce moment. Le serveur publie le
résultat sur `voice/<carte>/transcript` et l'assistant appelle la personne par son nom.
```json
{"session": "453cce", "utterances": [
{"speaker": "Denis", "text": "Ouvrir la porte.", "start": 0.0, "end": 1.26, "score": 0.6},
{"speaker": "Siwis", "text": "Je suis la voix de synthèse...", "start": 1.5, "end": 4.46, "score": 0.6},
{"speaker": "Denis", "text": "Fermez la porte.", "start": 4.92, "end": 5.86, "score": 0.6}]}
```
1. `make diarize OUT=server/recordings/<fichier>.wav` : qui a dit quoi, et le message MQTT, sans carte.
2. `make mqtt-watch` dans un terminal, `make run` dans un autre.
3. « Bonjour » : la carte répond « Bonjour Denis ! » ; `voice/esp32-01/transcript` apparaît.
4. Une session à deux voix (vous, puis une voix du téléphone, puis vous) : trois interventions.
### Étape 9 — identification du locuteur
Chaque voix trouvée par la diarisation est résumée par une **empreinte** (256 nombres,
modèle WeSpeaker du pipeline pyannote), comparée par **similarité cosinus** aux profils
enrôlés. Au-dessus du seuil (0,45, mesuré sur ce banc), le nom ; sinon « Inconnu ».
**C'est une estimation, jamais une sécurité** : un enregistrement ou une imitation peut tromper.
1. `make run`, puis 5 phrases variées de 1 à 4 s, seul, à distance normale.
2. `make enroll NAME=Denis` : profil créé depuis ces 5 sessions ; les sessions trop
courtes, à plusieurs voix ou qui ne ressemblent pas aux autres sont écartées.
3. Nouvelles questions (le serveur voit le profil sans redémarrer) : le journal affiche
`SPEAKER_00 = Denis (0.74)` ; une autre personne ou une voix de haut-parleur donne
`Inconnu (0.05, proche de Denis)`.
4. `make diarize-demo` : votre voix reconnue, la voix Piper « Inconnu ».
Les profils sont des **données biométriques** : `server/profiles/`, jamais versionné,
un fichier JSON lisible par personne ; `make forget NAME=...` le supprime.
### Étape 8 — diarisation : qui parle quand
La parole trouvée par la VAD est découpée en tours de parole : `SPEAKER_00` de 0,0 à
1,3 s, `SPEAKER_01` de 1,4 à 2,9 s... Les étiquettes ne valent que pour une session :
mettre un nom sur une voix, c'est l'étape 9. Modèle `pyannote/speaker-diarization-community-1`
(33 Mo), exécuté sur CPU, **télémétrie de pyannote coupée** (active par défaut en version 4).
1. Compte sur <https://huggingface.co>, puis **accepter les conditions** sur
<https://huggingface.co/pyannote/speaker-diarization-community-1>.
2. Jeton de type « Read » sur <https://huggingface.co/settings/tokens>, copié dans
`server/.env` : `HF_TOKEN=hf_...` (`cp server/.env.example server/.env` s'il n'existe pas).
3. `make install-diarization` puis `make models`. Ensuite, plus aucun accès à Internet.
4. `make diarize-demo` : votre voix, la voix Piper, votre voix → deux locuteurs attendus.
5. `make run` : une session où deux personnes parlent à tour de rôle ; le journal affiche
`2 locuteur(s) : SPEAKER_00 0.00-1.30, SPEAKER_01 1.40-2.90`.
Sur Raspberry Pi 4, trop lourd : `diarization.enabled: false` dans `config.yaml`.
### Étape 7 — détection d'activité vocale (VAD)
Le serveur ne transcrit plus que la parole : silences de début et de fin retirés,
sessions sans parole (appui muet, clic) écartées sans appeler Whisper. Silero est
livré avec faster-whisper : rien de plus à installer.
1. `make vad-all` : une ligne par fenêtre de 32 ms (`#` parole, `_` silence) pour
chaque enregistrement, et le nombre de sessions sans parole.
2. `make vad OUT=server/recordings/<fichier>.wav` puis `make play OUT=recordings/parole.wav` :
la parole seule, sans les silences.
3. `make run` (à relancer : le code a changé), puis quelques questions et un appui
sans parler. Le journal affiche `parole 1.36 s sur 2.00 s (segments 0.49-1.85 s)`,
et pour l'appui muet `sans parole (VAD)`, avec une réponse immédiate.
Réglages dans `config.yaml`, section `vad` : seuil, pause minimale, durée minimale de parole, marge.
### Étape 6 — MVP : l'assistant répond
1. `make install-speech` puis `make models` (faster-whisper `small`, voix Piper
`fr_FR-siwis-medium`, environ 550 Mo dans `server/models/`, une seule fois).
2. `make speech-check` : Piper dit « Quelle heure est-il ? », Whisper le relit,
l'assistant répond ; aucun matériel nécessaire.
3. `make transcribe OUT=server/recordings/<fichier>.wav` : transcrit une phrase
enregistrée par l'ESP32 à l'étape 4.
4. `make run` : appui sur le bouton, « Quelle heure est-il ? », relâcher ; la
carte répond. Questions reconnues : l'heure, la date, bonjour, merci, ton nom ;
sinon, la carte répète ce qu'elle a compris.
Le journal donne le temps de chaque maillon : `transcription 1.0 s, synthese
0.1 s, reponse prete en 1.1 s`. `make run-echo` garde le mode écho de l'étape 5,
sans modèles.
### Étape 5 — le serveur fait parler l'ESP32, par MQTT
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 5).
2. `make tone-mqtt` : 3 s de son pur envoyées par MQTT ; le bilan de la carte
s'affiche (`chunks 60/60, underruns 0`).
3. `make play-mqtt OUT=server/recordings/<fichier>.wav` : n'importe quel WAV,
converti en 16 kHz mono, crête à −3 dBFS.
4. `make run-echo` : le serveur rejoue chaque phrase à la carte qui l'a dite.
### Étape 4 — la voix vers le serveur, par MQTT
1. `make install` (dépendances du serveur : paho-mqtt, PyYAML, numpy...).
2. `make run` : le serveur se connecte au broker et attend les sessions.
3. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`, puis appui sur le bouton, une phrase, relâcher.
4. Le serveur écrit `server/recordings/esp32-01_<date>_<session>.wav` et affiche
son bilan : `complete`, chunks reçus / annoncés, crête et RMS.
5. `make play OUT=server/recordings/<fichier>.wav` ou `make inspect OUT=...`.
Si le broker est injoignable à l'appui, la session part sur la liaison série :
`make record` reste utilisable pour le dépannage.
### Étape 3 — Wi-Fi et MQTT
1. Broker : voir [docs/MOSQUITTO.md](docs/MOSQUITTO.md) (service Debian ou conteneur).
2. `cp firmware/include/secrets.h.example firmware/include/secrets.h`, puis
renseigner le Wi-Fi (2,4 GHz) et l'adresse IP du broker.
3. `make mqtt-watch MQTT_HOST=<ip du broker>` dans un premier terminal.
4. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` dans un second.
Attendu dans le premier terminal : `voice/esp32-01/state {"status":"online",...}`
et `voice/esp32-01/event {"event":"boot",...}`. Après une coupure d'alimentation
de l'ESP32 : `voice/esp32-01/state {"status":"offline"}` en 15 s au plus.
### Étape 2 — tester la sortie audio
1. Câbler l'ampli MAX98357A et le haut-parleur (§2), sans oublier C1 et C2.
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
3. Au démarrage, un **bip de 440 Hz** retentit : il teste la sortie seule.
4. `make tone-esp` : 3 s de son pur envoyées par le PC, au rythme réel. La
moindre coupure s'y entend comme un clic.
5. `make play-esp OUT=recordings/essai.wav` : l'enregistrement de l'étape 1
rejoué sur le haut-parleur de l'ESP32.
Attendu : son pur, sans clic ni grésillement ; le bilan de l'ESP32 affiche
`0 refuses` et `0 sous-alimentations`.
### Étape 1 — tester l'entrée audio
1. Câbler le micro, le bouton et la LED comme au §2.
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
3. `make record OUT=recordings/essai.wav`
4. Appuyer sur le bouton, dire une phrase, relâcher.
5. Écouter : `make play OUT=recordings/essai.wav`
6. Sans sortie audio : `make inspect OUT=recordings/essai.wav` dessine l'enveloppe
du signal en ASCII et donne crête, RMS et offset continu.
Attendu : la phrase est intelligible, sans saturation ; le script affiche une
crête entre 10 % et 90 % de la pleine échelle. Si la crête dépasse 99 %,
diminuer `MIC_GAIN` dans [firmware/include/config.h](firmware/include/config.h) ;
si elle reste sous 2 %, l'augmenter.
---
## 7. Arborescence
```
voice-assistant/
├── firmware/ firmware ESP32 (PlatformIO, Arduino)
│ ├── include/ config.h, secrets.h.example
│ └── src/ main.cpp + une paire .h/.cpp par classe
├── server/ serveur Python (un module par classe)
├── tools/ outils de test hors serveur
├── mosquitto/ voice.conf
├── Makefile toutes les opérations système
├── CONTEXT.md état d'avancement et décisions techniques
└── README.md / README-EN.md
```
---
## 8. Licence
MIT — voir [LICENSE](LICENSE).
+43
View File
@@ -0,0 +1,43 @@
# Installation de l'agent sur une machine Linux (étape 15b).
# Ce dossier suffit : copiez-le sur la machine, puis « make -C agent install ».
# Prérequis Debian : sudo apt install make python3-venv
SHELL := /bin/bash
PREFIX := /opt/voice-agent
CONFIG_DIR := /etc/voice-agent
# Mêmes versions que le serveur (server/pyproject.toml).
DEPS := paho-mqtt==2.1.0 PyYAML==6.0.2
.PHONY: help install uninstall log
.DEFAULT_GOAL := help
help: ## Affiche cette aide
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Installe l'agent comme service (sudo) ; garde une configuration existante
sudo install -d -m 0755 $(PREFIX)
sudo install -m 0644 voice_agent.py $(PREFIX)/voice_agent.py
sudo python3 -m venv $(PREFIX)/.venv
sudo $(PREFIX)/.venv/bin/pip install --quiet --disable-pip-version-check $(DEPS)
sudo install -d -m 0700 $(CONFIG_DIR)
@if sudo test -f $(CONFIG_DIR)/agent.yaml; then \
echo "$(CONFIG_DIR)/agent.yaml existe : conserve"; \
else \
sudo install -m 0600 agent.yaml.example $(CONFIG_DIR)/agent.yaml; \
echo "A FAIRE : sudo nano $(CONFIG_DIR)/agent.yaml (id, mqtt.host), puis sudo systemctl restart voice-agent"; \
fi
sudo install -m 0644 voice-agent.service /etc/systemd/system/voice-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now voice-agent
sudo systemctl restart voice-agent
@sleep 1; sudo systemctl --no-pager status voice-agent | head -n 5
uninstall: ## Retire le service et le programme (garde la configuration)
-sudo systemctl disable --now voice-agent
sudo rm -f /etc/systemd/system/voice-agent.service
sudo systemctl daemon-reload
sudo rm -rf $(PREFIX)
log: ## Journal de l'agent installé (Ctrl-C pour quitter)
journalctl -u voice-agent -f
+39
View File
@@ -0,0 +1,39 @@
# 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
# seulement : il peut contenir un mot de passe MQTT). Après modification :
# sudo systemctl restart voice-agent
#
# L'agent n'exécute QUE les actions listées ici. Le serveur n'envoie qu'un nom
# d'action : il ne peut ni changer la commande, ni lui ajouter un argument.
# Nom de la machine pour l'assistant : topics agent/<id>/..., et même nom dans
# server/config.yaml (machines.list), où l'on dit comment on l'appelle à voix haute.
id: pc-bureau
mqtt:
host: 192.168.1.104 # adresse IP du broker
port: 1883
username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme
password: ""
topic_prefix: agent
# true : l'agent journalise la commande sans l'exécuter. Pour les essais : on peut
# dire « éteins le PC du bureau » sans l'éteindre.
dry_run: false
# Nom de l'action (le serveur connaît ces noms : shutdown, reboot, lock ; « wake »
# est géré par le serveur) → commande, en LISTE : pas de shell, donc pas de « ; »,
# de « | » ni de « $( ) » possibles. Le service tourne en root.
actions:
shutdown:
command: [systemctl, poweroff]
reboot:
command: [systemctl, reboot]
lock:
command: [loginctl, lock-sessions] # verrouille les sessions graphiques ouvertes
# Action propre à cette machine : l'ajouter aussi dans server/config.yaml
# (machines.actions : mots et phrases) et, si besoin, dans access.rules.machine.
# backup:
# command: [/usr/local/bin/sauvegarde.sh]
# timeout_s: 3600
+14
View File
@@ -0,0 +1,14 @@
# Service systemd de l'agent de l'assistant vocal (étape 15b), installé par « make agent-install ».
[Unit]
Description=Agent de l'assistant vocal (commandes par MQTT)
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
# Relancé s'il s'arrête (broker absent au démarrage, plantage...) : l'agent doit toujours répondre.
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Agent de l'assistant vocal pour une machine Linux du réseau (étape 15b).
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
sans argument venu du réseau : un message forgé ne peut rien lancer d'autre.
agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline »
agent/<id>/command {"action":"shutdown","id":"...","by":"Denis"} (QoS 1)
agent/<id>/result {"action":"shutdown","id":"...","ok":true,"code":0,"output":"..."}
Session propre : une commande envoyée pendant que la machine était éteinte n'est
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
make agent-run # sur le PC de développement, en mode essai (dry_run)
"""
from __future__ import annotations
import argparse
import json
import logging
import queue
import re
import signal
import subprocess
import sys
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import paho.mqtt.client as mqtt
import yaml
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
logger = logging.getLogger("voice_agent")
DEFAULT_TIMEOUT_S = 60.0
OUTPUT_MAX_CHARS = 300 # fin de la sortie de la commande, renvoyée au serveur
KEEPALIVE_S = 30 # hors ligne annoncé 45 s au plus après une coupure brutale
AGENT_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
# Lance une commande : le même contrat que subprocess.run, remplaçable dans les tests.
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
class ConfigError(ValueError):
"""Fichier de configuration de l'agent invalide."""
@dataclass(frozen=True)
class Action:
"""Une action permise : une commande fixe."""
command: tuple[str, ...] # programme et arguments, SANS shell : pas de ; | $() possibles
timeout_s: float
@dataclass(frozen=True)
class AgentConfig:
"""Configuration de l'agent (agent.yaml)."""
agent_id: str
host: str
port: int
username: str | None
password: str | None
topic_prefix: str
dry_run: bool # essais : journalise la commande sans l'exécuter
actions: dict[str, Action]
def topic(self, suffix: str) -> str:
"""agent/<id>/<suffixe>."""
return f"{self.topic_prefix}/{self.agent_id}/{suffix}"
@dataclass(frozen=True)
class Result:
"""Compte rendu d'une action, publié sur agent/<id>/result."""
action: str
request_id: str
ok: bool
code: int | None # code de retour ; None si la commande n'a pas pu tourner
output: str
def payload(self) -> bytes:
return json.dumps({"action": self.action, "id": self.request_id, "ok": self.ok,
"code": self.code, "output": self.output}, ensure_ascii=False).encode("utf-8")
def load_config(path: Path) -> AgentConfig:
"""Lit et vérifie agent.yaml ; une erreur dit précisément quoi corriger.
Raises:
ConfigError: fichier absent ou invalide.
"""
try:
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as error:
raise ConfigError(f"{path} : {error}") from error
agent_id = str(data.get("id", ""))
if not AGENT_ID.fullmatch(agent_id):
raise ConfigError(f"{path} : id « {agent_id} » invalide (minuscules, chiffres, tirets : pc-bureau)")
broker: dict[str, Any] = data.get("mqtt") or {}
if not broker.get("host"):
raise ConfigError(f"{path} : mqtt.host manquant (adresse IP du broker)")
actions: dict[str, Action] = {}
for name, spec in dict(data.get("actions") or {}).items():
command = spec.get("command") if isinstance(spec, dict) else None
if not isinstance(command, list) or not command or not all(isinstance(part, str) and part
for part in command):
raise ConfigError(f"{path} : action « {name} » : command doit être une liste, "
"ex. [systemctl, poweroff] (pas de chaîne shell)")
timeout_s = float(spec.get("timeout_s", DEFAULT_TIMEOUT_S))
if timeout_s <= 0:
raise ConfigError(f"{path} : action « {name} » : timeout_s doit être positif")
actions[str(name)] = Action(command=tuple(command), timeout_s=timeout_s)
return AgentConfig(
agent_id=agent_id,
host=str(broker["host"]),
port=int(broker.get("port", 1883)),
username=str(broker.get("username") or "") or None,
password=str(broker.get("password") or "") or None,
topic_prefix=str(broker.get("topic_prefix", "agent")),
dry_run=bool(data.get("dry_run", False)),
actions=actions,
)
def state_payload(config: AgentConfig, online: bool) -> bytes:
"""État retenu : en ligne avec la liste des actions, ou hors ligne."""
if not online:
return json.dumps({"status": "offline"}).encode()
return json.dumps({"status": "online", "actions": sorted(config.actions),
"dry_run": config.dry_run}).encode()
def run_action(config: AgentConfig, name: str, request_id: str, runner: Runner = subprocess.run) -> Result:
"""Exécute une action de la liste ; tout autre nom est refusé."""
action = config.actions.get(name)
if action is None:
return Result(name, request_id, False, None, f"action inconnue : {name}")
shown = " ".join(action.command)
if config.dry_run:
logger.info("essai : %s (non execute, dry_run)", shown)
return Result(name, request_id, True, 0, f"essai : {shown}")
try:
completed = runner(list(action.command), capture_output=True, text=True,
timeout=action.timeout_s, check=False)
except FileNotFoundError:
return Result(name, request_id, False, None, f"programme introuvable : {action.command[0]}")
except subprocess.TimeoutExpired:
return Result(name, request_id, False, None, f"arretee apres {action.timeout_s:g} s")
output = f"{completed.stdout}{completed.stderr}".strip()[-OUTPUT_MAX_CHARS:]
return Result(name, request_id, completed.returncode == 0, completed.returncode, output)
class VoiceAgent:
"""Client MQTT de l'agent : reçoit les demandes, les exécute une à une, rend compte.
Les commandes tournent dans un fil de travail, jamais dans le fil réseau : une
sauvegarde d'une heure ne doit pas couper la connexion au broker.
"""
def __init__(self, config: AgentConfig, runner: Runner = subprocess.run) -> None:
self._config = config
self._runner = runner
self._jobs: queue.Queue[tuple[str, str] | None] = queue.Queue()
self._connected = threading.Event()
# Session propre (clean_session, défaut MQTT 3.1.1) : pas de commande en attente au retour.
self._client = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2,
client_id=f"voice-agent-{config.agent_id}")
if config.username:
self._client.username_pw_set(config.username, config.password)
self._client.will_set(config.topic("state"), state_payload(config, False), qos=1, retain=True)
self._client.on_connect = self._on_connect
self._client.on_message = self._on_message
self._client.reconnect_delay_set(min_delay=1, max_delay=30)
self._worker = threading.Thread(target=self._work, name="actions", daemon=True)
def start(self) -> None:
"""Connexion (asynchrone, avec reconnexion) et fil de travail."""
config = self._config
logger.info("agent %s : broker %s:%d, actions %s%s", config.agent_id, config.host, config.port,
", ".join(sorted(config.actions)) or "aucune", " (ESSAI : rien n'est execute)"
if config.dry_run else "")
if not config.username:
logger.warning("pas de compte MQTT : tout appareil du reseau peut demander ces actions "
"(comptes et droits du broker, etape 15)")
self._worker.start()
self._client.connect_async(config.host, config.port, keepalive=KEEPALIVE_S)
self._client.loop_start()
def stop(self) -> None:
"""Annonce le départ (un arrêt propre ne déclenche pas le testament), puis se déconnecte."""
if self._connected.is_set():
info = self._client.publish(self._config.topic("state"), state_payload(self._config, False),
qos=1, retain=True)
try:
info.wait_for_publish(2.0)
except (RuntimeError, ValueError):
pass
self._client.disconnect()
self._client.loop_stop()
self._jobs.put(None)
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags,
reason_code: ReasonCode, properties: Properties | None) -> None:
if reason_code.is_failure:
logger.error("connexion refusee par le broker : %s (compte mqtt.username/password ?)", reason_code)
return
self._connected.set()
client.subscribe(self._config.topic("command"), qos=1)
client.publish(self._config.topic("state"), state_payload(self._config, True), qos=1, retain=True)
logger.info("connecte : en attente sur %s", self._config.topic("command"))
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
try:
request: dict[str, Any] = json.loads(message.payload)
name = str(request["action"])
except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError):
logger.warning("demande illisible ignoree : %r", message.payload[:80])
return
logger.info("demande %s par %s (carte %s)", name, request.get("by") or "inconnu", request.get("board"))
self._jobs.put((name, str(request.get("id", ""))))
def _work(self) -> None:
while True:
job = self._jobs.get()
if job is None:
return
name, request_id = job
result = run_action(self._config, name, request_id, self._runner)
log = logger.info if result.ok else logger.warning
log("%s : %s (code %s)%s", name, "ok" if result.ok else "ECHEC", result.code,
f" : {result.output}" if result.output else "")
self._client.publish(self._config.topic("result"), result.payload(), qos=1)
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"))
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
try:
config = load_config(args.config)
except ConfigError as error:
logger.error("%s", error)
return 1
agent = VoiceAgent(config)
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
agent.start()
try:
stop.wait()
except KeyboardInterrupt:
pass
finally:
agent.stop()
logger.info("arret de l'agent")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+385
View File
@@ -0,0 +1,385 @@
# Brochage et câblage — ESP32-WROOM-32 + INMP441 + MAX98357A
Document de référence du câblage. Schéma visuel : [wiring.svg](wiring.svg)
(clic droit → « Open Preview » dans VSCodium, ou ouverture dans un navigateur).
> **Avant de câbler** : ESP32 débranché de l'USB. On ne modifie jamais un câblage
> sous tension : une inversion VDD/GND détruit l'INMP441 en quelques secondes.
---
## 1. Tableau de câblage complet
Carte de référence : **DOIT ESP32 DEVKIT V1, 30 broches** (la plus répandue en TP).
Sur une carte 38 broches, les numéros de GPIO sont identiques, seule leur position
physique change : **fiez-vous toujours aux numéros sérigraphiés**, jamais au rang.
### 1.1 Micro INMP441 (I2S0, réception)
| INMP441 | ESP32 | Fil conseillé | Rôle |
|---|---|---|---|
| `VDD` | **3V3** | rouge | alimentation **3,3 V uniquement** |
| `GND` | **GND** | noir | masse |
| `SCK` | **GPIO 25** | bleu | horloge bit (BCLK), générée par l'ESP32 |
| `WS` | **GPIO 26** | vert | sélection de mot (word select / LRCL) |
| `SD` | **GPIO 33** | violet | donnée série, micro → ESP32 |
| `L/R` | **GND** | noir | choix du canal : GND = **gauche** |
`L/R` à GND est la référence de ce projet : gardez ce câblage.
Attention à un piège propre à l'ESP32 : l'INMP441 n'émet que pendant **une moitié
de trame** et sort des **zéros** pendant l'autre. Or la correspondance entre la
broche `L/R` et les constantes `I2S_STD_SLOT_LEFT` / `..._RIGHT` du pilote n'est
pas celle qu'on attend sur l'ESP32 d'origine. Écouter la mauvaise moitié donne un
flux **parfaitement nul**, alors que le câblage est correct — le symptôme le plus
déroutant de l'étape 1.
Le firmware tranche donc tout seul : au démarrage, la classe `MicSelfTest`
réveille le micro **une seule fois**, lit les deux moitiés de trame en cadrage
MSB et décide sur la **structure** des mots : la moitié utile est celle qui n'est
ni nulle ni constante, et elle doit porter exactement **24 bits utiles**. Si elle
en porte 25, le micro suit le cadrage Philips et le firmware bascule. Le niveau
au repos est affiché pour information, sans intervenir dans la décision.
### 1.2 Amplificateur MAX98357A (I2S1, émission) — étape 2
| MAX98357A | ESP32 | Fil | Rôle |
|---|---|---|---|
| `Vin` | **VIN (5 V)** | orange | alimentation 5 V (3,3 V possible mais 4× moins de puissance) |
| `GND` | **GND** | noir | masse |
| `BCLK` | **GPIO 27** | bleu | horloge bit |
| `LRC` | **GPIO 14** | vert | sélection de mot |
| `DIN` | **GPIO 22** | violet | donnée série, ESP32 → ampli |
| `GAIN` | *non connecté* | — | gain 9 dB par défaut (voir §4) |
| `SD` | *non connecté* | — | déjà tiré au + par 100 kΩ sur la carte → ampli actif |
| `+` / `−` | haut-parleur | — | sortie pontée : **jamais de masse sur `−`** |
### 1.3 Interface utilisateur
| Élément | ESP32 | Câblage |
|---|---|---|
| Bouton PTT | **GPIO 4** | une borne sur GPIO 4, l'autre sur **GND** — pull-up interne activé par le firmware |
| LED d'état | **GPIO 2** | GPIO 2 → résistance **330 Ω** → anode LED ; cathode → GND |
La carte DevKit V1 possède déjà une LED bleue sur GPIO 2 : la LED externe est
**optionnelle**, l'étape 1 est validable sans elle.
### 1.4 Haut-parleur
| Caractéristique | Valeur conseillée |
|---|---|
| Impédance | **4 Ω** (3 W) ou 8 Ω (2 W) |
| Diamètre | 40 à 66 mm |
| Câblage | directement sur `+` et `−` de l'ampli |
La sortie du MAX98357A est **pontée** (les deux fils sont actifs, en opposition
de phase). Relier `−` à la masse court-circuite l'étage de sortie.
---
## 2. Schéma ASCII
```
ESP32-WROOM-32 (DevKit V1, 30 broches)
+----------------------------------------+
INMP441 | |
+---------+ | EN GPIO23 |
| VDD o--|--rouge------|--------------------------------> 3V3 | (broche 3V3)
| GND o--|--noir-------|---------------------------------> GND | (broche GND)
| SD o--|--violet---->| GPIO33 GPIO22 |<--violet--+
| SCK o--|--bleu------>| GPIO25 GPIO4 |<--+ |
| WS o--|--vert------>| GPIO26 GPIO2 |---|--+ |
| L/R o--|--noir--> GND| GPIO27 -->--bleu----------+ .. | | | |
+---------+ | GPIO14 -->--vert--------+ | .. | | | |
3,3 V SEULEMENT | | | VIN(5V)| | | |
+-------------------------|-|------------+ | | |
| | | | |
+-------------+ | | | |
| +-----------+ | | |
v v | | |
MAX98357A | | | | |
+----------|---|-----+ | | |
| LRC o<--+ | | | | |
| BCLK o<------+ | HP 4 ohms / 3 W | | |
| DIN o<------------|----------------------|--|----+
| GAIN o (non conn. = 9 dB) | |
| SD o (non conn. = actif) + o---+ | |
| GND o---noir--> GND - o---|--+ | |
| Vin o---orange--> VIN (5 V) | | | |
+------------------------------+ | | | |
v v | |
+---------+ | |
| ( ) HP| | |
+---------+ | |
| |
bouton PTT : GPIO4 ----[ / ]---- GND <-------------+ |
LED etat : GPIO2 ----[330]---->|---- GND <-----------+
```
---
## 3. Pourquoi ce choix de broches
| Contrainte | Conséquence |
|---|---|
| **Broches de strapping** GPIO 0, 2, 5, 12, 15 | leur niveau au reset choisit le mode de démarrage → aucun signal audio dessus. GPIO 2 ne porte qu'une LED, en sortie seule. |
| **GPIO 34-39 sont en entrée seule** | inutilisables pour SCK/WS (sorties d'horloge). |
| **GPIO 6-11** | reliées à la mémoire flash interne → jamais utilisables. |
| **Deux périphériques I2S séparés** | I2S0 pour le micro, I2S1 pour l'ampli. On ne partage **pas** les horloges : le micro tourne en 32 bits/slot et l'ampli en 16 bits, et surtout l'entrée et la sortie doivent pouvoir démarrer et s'arrêter indépendamment. Cinq fils au lieu de trois : c'est le prix de la simplicité logicielle. |
| **GPIO 1 / 3 (TX0/RX0)** | utilisées par l'USB-série → interdites. |
---
## 4. Réglages matériels du MAX98357A
### Broche `GAIN`
| Câblage de `GAIN` | Gain |
|---|---|
| direct à GND | 15 dB |
| à GND via 100 kΩ | 12 dB |
| **non connectée** | **9 dB** ← notre choix |
| à Vin via 100 kΩ | 6 dB |
| direct à Vin | 3 dB |
### Broche `SD` (shutdown + choix de canal)
La tension sur `SD` sélectionne le mode :
| Tension sur `SD` | Mode |
|---|---|
| < 0,16 V | ampli éteint |
| 0,16 – 0,77 V | canal droit seul |
| 0,77 – 1,4 V | canal gauche seul |
| **> 1,4 V** | **moyenne (gauche + droite) / 2** ← par défaut |
Les cartes de type Adafruit tirent `SD` au + par 100 kΩ : laissée libre, la broche
est donc au-dessus de 1,4 V et l'ampli fonctionne en mode moyenne.
Le firmware émet en **stéréo, le même échantillon sur les deux voies**
(`AudioOutput`). La moyenne (gauche + droite) / 2 redonne donc exactement le
signal : niveau correct, et une éventuelle inversion gauche/droite est sans effet.
On n'a pas misé sur le comportement implicite d'un mode « mono » : c'est la leçon
de l'étape 1, où une moitié de trame muette nous a longtemps égarés.
Pour couper le son par logiciel, on reliera plus tard `SD` à un GPIO
(niveau bas = silence). Inutile avant l'étape 15.
---
## 5. Bilan de consommation
| Consommateur | Courant moyen | Crête |
|---|---|---|
| ESP32 (Wi-Fi actif) | ~120 mA | ~500 mA pendant l'émission radio |
| INMP441 | 1,4 mA | — |
| MAX98357A au repos | 2,4 mA | — |
| MAX98357A à 3 W sur 4 Ω | ~670 mA | ~1,2 A sur les transitoires |
| **Total au volume maximal** | **~0,8 A** | **> 1,5 A** |
Conséquences pratiques :
1. Une alimentation USB 5 V / 1 A **ne suffit pas** au volume maximal : l'ESP32
redémarre (brown-out). Prévoir **5 V / 2 A**.
2. Ajouter un condensateur **électrolytique 470 µF / 16 V** entre `Vin` et `GND`
de l'ampli, au plus près de la carte : il fournit les crêtes que les fils de
breadboard ne peuvent pas suivre. Types et valeurs limites : **§6**.
3. Alternative propre : alimenter l'ampli par une source 5 V **externe**, en
reliant sa masse à celle de l'ESP32 (**masse commune obligatoire**).
---
## 6. Condensateurs : types et valeurs
### 6.1 Les trois condensateurs du montage
| Rep. | Où | Rôle | Type exact |
|---|---|---|---|
| **C1** | `Vin` / `GND` du MAX98357A | réservoir d'énergie pour les crêtes du haut-parleur | électrolytique aluminium radial, **faible ESR**, 105 °C |
| **C2** | `Vin` / `GND` du MAX98357A, collé à C1 | découplage haute fréquence (la classe D découpe à ~300 kHz) | céramique multicouche **X7R** ou X5R |
| **C3** | GPIO 4 / `GND` (**optionnel**) | anti-rebond matériel du bouton | céramique multicouche **X7R** |
### 6.2 Valeurs minimale, typique et maximale
| Rep. | Mini | **Typique** | Maxi | Tension de service |
|---|---|---|---|---|
| **C1** | 100 µF<br>`1 × 10⁸ pF` | **470 µF**<br>`4,7 × 10⁸ pF` | 1 000 µF<br>`1 × 10⁹ pF` | ≥ 10 V, **16 V conseillé** |
| **C2** | 10 nF<br>`10 000 pF` (code 103) | **100 nF**<br>`100 000 pF` (code **104**) | 1 µF<br>`1 000 000 pF` (code 105) | ≥ 25 V |
| **C3** | 10 nF<br>`10 000 pF` (code 103) | **100 nF**<br>`100 000 pF` (code **104**) | 220 nF<br>`220 000 pF` (code 224) | ≥ 16 V |
Les bornes ne sont pas arbitraires :
- **C1 mini 100 µF** — la chute de tension pendant une crête vaut `ΔV = I × Δt / C`.
Pour 1 A pendant 100 µs : 470 µF → 0,21 V (sans effet) ; 100 µF → 1,0 V (limite,
l'ESP32 décroche vers 4,0 V en entrée de son régulateur) ; 47 µF → 2 V, redémarrage garanti.
- **C1 maxi 1 000 µF** — au branchement, le condensateur vide est un court-circuit :
`I = C × dV/dt`. Charger 1 000 µF à 5 V en 1 ms demande **5 A**, ce qui déclenche la
protection du port USB ou du hub. Au-delà, il faut un circuit de pré-charge : hors sujet ici.
- **C2** — au-dessus de ~1 MHz, un électrolytique se comporte comme une self : il ne
découple plus rien. Sous 10 nF, la céramique ne stocke plus assez pour les fronts de
commutation. Au-dessus de 1 µF, aucun gain et le boîtier devient inutilement gros.
- **C3** — la constante de temps vaut `τ = R_pull-up × C`, avec le pull-up interne de
l'ESP32 à ≈ 45 kΩ : 100 nF → τ = 4,5 ms, soit ~6 ms pour franchir le seuil logique.
C'est bien en dessous des `BUTTON_DEBOUNCE_MS = 30 ms` du firmware. Avec 220 nF on
atteint ~13 ms : encore acceptable. Au-delà, le filtre matériel et le filtre logiciel
se cumulent et le bouton devient mou.
**C3 est facultatif** : l'anti-rebond est déjà fait en logiciel dans
[PushButton.cpp](../firmware/src/PushButton.cpp). Ne l'ajoutez que si le bouton est au
bout d'un fil long et capte du bruit. Dans ce cas, insérez aussi **100 Ω en série**
entre le GPIO et le bouton : cette résistance limite le courant de décharge du
condensateur dans le contact à chaque appui, ce qui préserve le bouton.
### 6.3 Lire le marquage — il est justement en picofarads
Les céramiques portent un **code à trois chiffres en pF** : deux chiffres significatifs,
puis le nombre de zéros à ajouter.
| Code imprimé | picofarads | nanofarads | microfarads |
|---|---|---|---|
| `102` | 1 000 pF | 1 nF | 0,001 µF |
| `103` | 10 000 pF | 10 nF | 0,01 µF |
| **`104`** | **100 000 pF** | **100 nF** | **0,1 µF** |
| `224` | 220 000 pF | 220 nF | 0,22 µF |
| `105` | 1 000 000 pF | 1 000 nF | 1 µF |
| `106` | 10 000 000 pF | 10 000 nF | 10 µF |
Une lettre suivante donne la tolérance : `J` = ±5 %, `K` = ±10 %, `M` = ±20 %.
Sur un découplage, la tolérance n'a aucune importance : `K` ou `M` conviennent.
Les électrolytiques, eux, portent la valeur en clair (`470µF 16V`) et une **bande
verticale marquant la borne négative**, en face de la patte la plus courte.
### 6.4 Pièges sur les condensateurs
1. **Polarité** — un électrolytique monté à l'envers chauffe, gonfle puis éclate en
quelques secondes. La bande imprimée est le **−**, la patte longue est le **+**.
Les céramiques, elles, ne sont pas polarisées.
2. **Diélectrique** — refusez les `Y5V` et `Z5U` : ils perdent jusqu'à 80 % de leur
capacité avec la température et la tension appliquée. **X7R** (ou X5R) pour le
découplage, `C0G`/`NP0` seulement pour les petites valeurs.
3. **Effet de la tension continue** — une céramique 10 µF / 6,3 V en boîtier 0603
ne fait plus que 4 µF sous 5 V. C'est pourquoi C2 est spécifiée en **25 V minimum**.
4. **ESR** — un 470 µF « general purpose » peut présenter 1 Ω de résistance série :
inutile pour des crêtes de 1 A. Cherchez la mention **low ESR** ou **105 °C**
(typiquement < 0,3 Ω).
5. **Distance** — C1 et C2 à moins de 3 cm des broches `Vin` / `GND` de l'ampli.
Au-delà, l'inductance des fils annule leur effet.
6. **C1 et C2 ne se remplacent pas** : le gros stocke l'énergie, le petit évacue le
haut du spectre. On met toujours les deux, en parallèle.
Les modules INMP441 et MAX98357A du commerce embarquent déjà leur découplage local
(100 nF, et 10 µF côté ampli). C1 et C2 compensent **les fils de la platine d'essai**,
pas un manque des modules.
---
## 7. Précautions de câblage
1. **Longueur des fils I2S** : 15 cm maximum. Au-delà, l'horloge se déforme et le
son devient bruité.
2. **Séparer** les fils du micro de ceux du haut-parleur : le courant du HP
(jusqu'à 1 A commuté à 300 kHz) rayonne dans les fils du micro.
3. **Masse commune** entre tous les modules, sans exception.
4. Ne pas alimenter l'INMP441 en 5 V : il est prévu pour 1,8 à 3,3 V.
5. Le micro est sensible aux vibrations : le fixer, ne pas le laisser pendre
contre le boîtier du haut-parleur (effet Larsen mécanique).
6. Sur breadboard, vérifier que les deux moitiés des rails d'alimentation sont
bien reliées (beaucoup de breadboards coupent les rails au milieu).
---
## 8. Vérifications au multimètre, avant le premier démarrage
ESP32 **débranché**, en mode continuité puis en mode tension :
| # | Mesure | Attendu |
|---|---|---|
| 1 | continuité `INMP441 VDD` ↔ `ESP32 3V3` | passant |
| 2 | continuité `INMP441 GND` ↔ `ESP32 GND` | passant |
| 3 | continuité `INMP441 L/R` ↔ `GND` | passant |
| 4 | continuité `INMP441 VDD` ↔ `GND` | **coupé** (sinon court-circuit) |
| 5 | continuité `MAX98357A Vin` ↔ `ESP32 VIN` | passant |
| 6 | continuité `MAX98357A −` ↔ `GND` | **coupé** |
| 7 | continuité `GPIO 4` ↔ `GND`, bouton relâché | **coupé** |
| 8 | continuité `GPIO 4` ↔ `GND`, bouton appuyé | passant |
### Continuité de la ligne de données
Le défaut le plus tenace de l'étape 1 est une ligne `SD` qui n'arrive pas. Testez
en continuité, **carte hors tension**, et **sur la pastille du module**, jamais au
bout du fil : une soudure sèche laisse passer le fil mais pas le signal.
| # | Mesure | Attendu |
|---|---|---|
| 8b | `GPIO 33` ↔ pastille `SD` du module | passant |
| 8c | pastille `L/R` du module ↔ `GND` | passant |
| 8d | `GPIO 33` ↔ `GND` | **coupé** |
Puis USB branché :
| # | Mesure | Attendu |
|---|---|---|
| 9 | tension `3V3` / `GND` | 3,2 à 3,4 V |
| 10 | tension `VIN` / `GND` | 4,6 à 5,1 V |
| 11 | tension `GPIO 4` / `GND`, bouton relâché | ≈ 3,3 V (pull-up interne) |
| 12 | tension `GPIO 4` / `GND`, bouton appuyé | ≈ 0 V |
Le test 11 exige que le firmware soit déjà téléversé : le pull-up est activé par
`pinMode(INPUT_PULLUP)`.
---
## 9. Diagnostic rapide
| Symptôme | Cause la plus fréquente | Vérification |
|---|---|---|
| Auto-test : les deux moitiés « silence numerique » | le micro n'est pas cadencé ou n'émet rien | tensions sur les **pastilles du module** : `VDD` 3,3 V, `SCK` et `WS` ≈ 1,6 V |
| Auto-test : « un bit de retard (Philips) » | le micro suit le cadrage Philips | aucune : le firmware bascule et revérifie |
| Auto-test : « DECALE : bit de signe perdu » (23 bits) | cadrage faux, le premier bit déborde dans le mot précédent | aucune en MSB ; si ça persiste, envoyer l'analyse bit à bit |
| Auto-test : « queue du mot non nulle » | bits parasites après les 24 bits utiles | vérifier `MIC_SD_PULLDOWN = true` |
| Auto-test : « les DEUX moities portent des donnees » | `L/R` flottant, ou deux micros sur la ligne | continuité `L/R` ↔ GND sur la pastille |
| Auto-test : « bruit de fond eleve » au repos | pièce bruyante, ou micro pas encore stabilisé | sans effet sur le cadrage ; juger sur l'enregistrement |
| Ligne SD : « FLOTTE », horloges correctes | rien ne pilote GPIO 33 | continuité `SD` ↔ GPIO 33 sur la pastille |
| Auto-test série : amplitude mesurable mais crête faible | simple question de gain | appliquer la valeur de `MIC_GAIN` conseillée par l'auto-test |
| Crête à 0 %, silence total | `L/R` non relié à GND | test 3 |
| Crête à 0 %, silence total | `SD` du micro sur la mauvaise broche | test de continuité vers GPIO 33 |
| Bruit blanc continu | micro alimenté en 5 V | test 9 sur la broche `VDD` du micro |
| Son très faible même en criant | `SCK` et `WS` inversés | inverser GPIO 25 et GPIO 26 |
| Signal haché, craquements réguliers | fils I2S trop longs | raccourcir à 10 cm |
| L'ESP32 redémarre quand le son sort | alimentation insuffisante | §5, points 1 et 2 |
| Téléversement impossible | LED sur GPIO 2 qui perturbe le boot | retirer la LED externe, utiliser celle de la carte |
| Le HP souffle ou vibre en permanence dès la mise sous tension | broches I2S de sortie flottantes : l'ampli amplifie du bruit | firmware ≥ étape 1 : `silenceAmplifier()` les force à 0. Si le bruit persiste pendant le boot, relier `SD` de l'ampli à `GND`, ou ne pas l'alimenter avant l'étape 2 |
| Sifflement aigu permanent | masse commune absente entre ampli et ESP32 | §7, point 3 |
---
## 10. Récapitulatif des GPIO réservés
| GPIO | Usage | Étape |
|---|---|---|
| 2 | LED d'état | 1 |
| 4 | bouton PTT | 1 |
| 14 | MAX98357A `LRC` | 2 |
| 22 | MAX98357A `DIN` | 2 |
| 25 | INMP441 `SCK` | 1 |
| 26 | INMP441 `WS` | 1 |
| 27 | MAX98357A `BCLK` | 2 |
| 33 | INMP441 `SD` | 1 |
| 21 | LED de démonstration domotique (« la lumière » de la pièce) | 11 |
### LED de démonstration (étape 11, facultative)
```
GPIO21 ──[ 330 Ω ]──►|── GND LED rouge ou verte : patte longue (anode) côté résistance
```
Courant : (3,3 V − 2,0 V) / 330 Ω ≈ 4 mA, bien sous les 12 mA conseillés par broche.
GPIO 21 est libre et sans rôle au démarrage (pas une broche de *strapping*). Elle
figure « la lumière » de la pièce de la carte ; volets et porte sont simulés
(état affiché sur la liaison série). Un module relais se brancherait au même endroit.
Cette LED n'est pas dessinée sur `wiring.svg`.
Les valeurs sont définies une seule fois, dans
[firmware/include/config.h](../firmware/include/config.h). Toute modification de
câblage se fait dans ce fichier, jamais dans le code des classes.
+158
View File
@@ -0,0 +1,158 @@
# Broker Mosquitto — installation et vérification
Le broker (serveur MQTT) relaie tous les messages entre l'ESP32 et le serveur
Python. Configuration unique : [mosquitto/voice.conf](../mosquitto/voice.conf).
---
## 1. Deux façons de le faire tourner
### Service Debian (Raspberry Pi, PC)
```bash
make install-system # installe mosquitto et mosquitto-clients
make mosquitto-config # copie voice.conf dans /etc/mosquitto/conf.d/ et redémarre
```
### Conteneur Docker
Monter `voice.conf` **à la place** de `mosquitto.conf` du conteneur, et publier le
port 1883 :
```yaml
services:
mosquitto:
image: eclipse-mosquitto:2.0
restart: unless-stopped
ports:
- 1883:1883
volumes:
- ./mosquitto/config:/mosquitto/config # contient voice.conf renommé mosquitto.conf
```
Si le conteneur existe déjà :
```bash
make mosquitto-docker MOSQ_DIR=~/srv/mosquitto MOSQ_COMPOSE=~/srv/docker-compose.mosquitto.yml
```
La cible sauvegarde l'ancienne configuration (`mosquitto.conf.bak`), installe
`voice.conf` avec le propriétaire du conteneur (uid 1883), relance le service et
affiche les dernières lignes du journal. Le `sudo` est nécessaire : les fichiers
appartiennent à l'utilisateur `mosquitto` du conteneur.
---
## 2. Comptes et droits (étape 15)
Sans authentification, n'importe quel appareil du réseau local peut s'abonner à
`voice/+/audio/stream` et **écouter le micro** en continu, ou publier une
commande. Depuis l'étape 15, le broker exige un compte, et un fichier de droits
(ACL, *Access Control List* : liste de qui peut lire ou écrire quel topic)
limite chaque compte :
| Compte | Qui | Droits ([mosquitto/acl](../mosquitto/acl)) |
|---|---|---|
| `voice-server` | le serveur, et les cibles `make mqtt-*`, `tone-mqtt`, `play-mqtt` | lire et écrire `voice/#` et `home/#` |
| `esp32-01` (= `DEVICE_ID`) | une carte | écrire **ses** `state`, `event`, `audio/in`, `audio/stream` ; lire **ses** `control`, `audio/out`, et `voice/server/status` |
| `pc-bureau` (= `id` de l'agent) | un agent Linux (étape 15b) | écrire **ses** `agent/<id>/state` et `result` ; lire **ses** `agent/<id>/command` |
Une carte ne peut donc ni écouter une autre carte, ni publier sous son nom, ni
envoyer de commande domotique. Vérifié sur un broker de test : connexion
anonyme et mauvais mot de passe refusés, usurpation d'une autre carte jetée.
```bash
make mqtt-user NAME=voice-server # mot de passe demandé deux fois, stocké haché
make mqtt-user NAME=esp32-01 # un compte par carte, du nom de son DEVICE_ID
make mosquitto-docker # installe voice.conf + acl + passwd, relance le broker
```
Les mots de passe en clair vont dans `server/.env` (`MQTT_USERNAME`,
`MQTT_PASSWORD`) et dans `firmware/include/secrets.h` (`MQTT_PASSWORD` ;
`MQTT_USER` vide = `DEVICE_ID`). `mosquitto/passwd` ne contient que des
empreintes (hachage : transformation à sens unique), et n'est pas versionné.
Le mot de passe circule encore **en clair** sur le réseau local : seul TLS
(chiffrement, port 8883) l'empêcherait. C'est l'option de l'étape 15, non
activée : sur un réseau Wi-Fi WPA2 domestique, l'écoute du trafic suppose déjà
d'avoir la clé du Wi-Fi.
---
## 3. Pièges rencontrés
| Symptôme | Cause | Correctif |
|---|---|---|
| L'ESP32 ne se connecte pas, `mosquitto_sub` sur le PC fonctionne | Mosquitto 2.x sans `listener` explicite n'écoute que sur `localhost` | `listener 1883 0.0.0.0` |
| Conteneur en état `restarting`, des centaines de redémarrages | deux lignes `listener 1883` : le second ne peut pas ouvrir le port (`Error: Address in use`) | **un seul** `listener` par port |
| Le broker plante sans rien dire | `log_dest syslog` : dans un conteneur, il n'y a pas de syslog | `log_dest stdout`, puis `docker logs <conteneur>` |
| `Note: It is recommended to replace message_size_limit` | option obsolète depuis Mosquitto 2.0 | `max_packet_size 8192` |
| L'ESP32 affiche `mqtt : echec (code -2)` | connexion TCP refusée : broker arrêté, mauvaise IP, pare-feu | `make mqtt-watch` depuis le PC ; vérifier `MQTT_HOST` dans `secrets.h` |
| Deux cartes se déconnectent l'une l'autre en boucle | même `DEVICE_ID`, donc même identifiant client MQTT | un `DEVICE_ID` différent par carte |
| `connexion refusee par le broker : Not authorized` (serveur), `code 5` (ESP32) | compte absent de `passwd`, mot de passe différent, ou `MQTT_PASSWORD` vide | `make mqtt-user NAME=...`, puis `make mosquitto-docker` ; recopier le même mot de passe |
| Tout est connecté, mais rien ne passe | droits : Mosquitto **jette en silence** une publication interdite et n'envoie rien à un abonnement interdit | nom d'utilisateur de la carte = son `DEVICE_ID` (la carte l'affiche au démarrage sinon) |
| Un autre programme de la maison ne se connecte plus au broker | `allow_anonymous false` vaut pour **tous** les clients | lui créer un compte, et ses lignes dans `mosquitto/acl` |
| `ACL pattern 'voice/server/status' does not contain '%c' or '%u'` au démarrage | avertissement : ce topic est commun à toutes les cartes, c'est voulu | rien à faire |
---
## 4. Vérifier le broker, sans ESP32
```bash
make mqtt-watch # terminal 1 : tout ce qui passe sur voice/#
make mqtt-control MSG='{"cmd":"ping"}' # terminal 2 : un message sur control
```
Le terminal 1 doit afficher `voice/esp32-01/control {"cmd":"ping"}`.
Sans le paquet `mosquitto-clients`, ces cibles utilisent automatiquement les
clients inclus dans l'image Docker `eclipse-mosquitto:2.0`.
---
## 5. État retenu et testament
| Topic | Qui publie | Quand | Retenu |
|---|---|---|---|
| `voice/esp32-01/state` `{"status":"online",...}` | l'ESP32 | à chaque connexion, puis toutes les 30 s | oui |
| `voice/esp32-01/state` `{"status":"offline"}` | **le broker**, à la place de l'ESP32 | quand l'ESP32 disparaît sans prévenir | oui |
| `voice/esp32-01/event` `{"event":"boot","reason":...}` | l'ESP32 | une fois par démarrage | non |
Messages publiés par le serveur (étape 10) :
| Topic | Qui publie | Quand | Retenu |
|---|---|---|---|
| `voice/esp32-01/transcript` `{"session":...,"utterances":[{"speaker":"Denis","text":...}]}` | le serveur (QoS 1) | après chaque question transcrite | non |
| `home/<pièce>/<appareil>/set` `{"action":"close","by":"Denis","board":"esp32-01",...}` | le serveur (QoS 1), étape 11 | à chaque commande domotique | **non** : rejouée à un abonné tardif, elle ferait bouger un volet |
| `voice/esp32-01/control` `{"cmd":"device","device":"light","action":"on"}` | le serveur, étape 11 | commande pour la pièce de la carte | non |
| `voice/esp32-01/event` `{"event":"device",...,"ok":true,"state":"on"}` | l'ESP32, étape 11 | confirmation d'exécution | non |
| `voice/esp32-01/control` `{"cmd":"listen","enabled":true}` | le serveur, étape 13 | connexion ou redémarrage de la carte ; `false` à l'arrêt du serveur | non |
| `voice/esp32-01/audio/stream` (PCM binaire, QoS 0) | l'ESP32, étape 13 | en continu tant que l'écoute est active, sauf pendant une réponse ou un appui | non |
| `voice/esp32-01/control` `{"cmd":"capture","active":true}` | le serveur, étape 13 | mot de réveil entendu (LED allumée) ; `false` à la fin de la commande | non |
| `voice/server/status` `{"status":"online"}` | le serveur, étape 15 | à chaque connexion au broker | **oui** |
| `voice/server/status` `{"status":"offline"}` | le serveur à l'arrêt, ou **le broker** (testament du serveur) s'il meurt | arrêt ou disparition du serveur : les cartes coupent leur micro | **oui** |
| `agent/<machine>/state` `{"status":"online","actions":[...]}` | l'agent Linux, étape 15b ; `offline` par son testament | à sa connexion | **oui** |
| `agent/<machine>/command` `{"action":"shutdown","id":...,"by":"Denis"}` | le serveur (QoS 1) | commande autorisée (et confirmée) | **non** : l'agent se connecte en session propre, rien n'est rejoué |
| `agent/<machine>/result` `{"action":...,"ok":true,"code":0,"output":...}` | l'agent | après exécution | non |
- **Retenu** : le broker garde le dernier message du topic et le donne tout de
suite à chaque nouvel abonné. Un serveur qui démarre après l'ESP32 connaît donc
aussitôt son état.
- **Testament** (*Last Will*) : confié au broker à la connexion. Si l'ESP32
disparaît sans se déconnecter proprement, c'est le broker qui publie `offline`.
Délai du testament :
| Disparition | Le broker s'en aperçoit | `offline` publié après |
|---|---|---|
| ESP32 : coupure de courant, reset, plantage, perte du Wi-Fi | quand l'ESP32 cesse de répondre : la puce ne prévient jamais | 1,5 × `MQTT_KEEPALIVE_S` = **15 s** au plus |
| client sur PC tué (`kill`, Ctrl-C) | immédiatement : le système d'exploitation ferme la connexion | < 1 s |
Après un simple reset, l'ESP32 se reconnecte en quelques secondes, souvent avant
l'expiration du keepalive. Le broker remplace alors l'ancienne session par la
nouvelle, et l'on peut voir passer un `offline` immédiatement suivi de `online`.
Pour observer le testament seul, il faut **couper l'alimentation** et attendre.
La cause du redémarrage (`reason`) vaut `poweron`, `external` (broche EN),
`software`, `panic` (plantage), `watchdog` ou **`brownout`** : chute de tension,
typiquement l'ampli qui tire trop sur une alimentation USB faible.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

+283
View File
@@ -0,0 +1,283 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1010" width="1400" height="1010" font-family="DejaVu Sans, Arial, Helvetica, sans-serif">
<!-- Schéma de câblage : ESP32-WROOM-32 + INMP441 + MAX98357A + HP + bouton + LED -->
<!-- Fond blanc explicite : le schéma reste lisible en thème sombre. -->
<rect x="0" y="0" width="1400" height="1010" fill="#ffffff"/>
<style>
.box { fill:#f7f7f4; stroke:#333333; stroke-width:2; }
.title { font-size:17px; font-weight:bold; fill:#1a1a1a; }
.sub { font-size:12px; fill:#555555; }
.pin { font-size:12px; fill:#1a1a1a; }
.pinused { font-size:12px; font-weight:bold; fill:#1a1a1a; }
.lbl { font-size:12px; fill:#333333; }
.tick { stroke:#999999; stroke-width:1.5; }
.w { fill:none; stroke-width:2.4; stroke-linejoin:round; }
.gnd { stroke:#333333; }
.v33 { stroke:#cc2222; }
.v5 { stroke:#e08214; }
.clk { stroke:#1f6fd0; }
.ws { stroke:#12855d; }
.data { stroke:#8a3fc0; }
.warn { font-size:12px; font-weight:bold; fill:#b30000; }
</style>
<text x="30" y="40" class="title" font-size="22">Assistant vocal local — câblage complet</text>
<text x="30" y="62" class="sub">ESP32-WROOM-32 · INMP441 (micro I2S) · MAX98357A (ampli I2S) · haut-parleur · bouton PTT · LED d'état</text>
<!-- =============================== ESP32 =============================== -->
<text x="610" y="100" class="title" text-anchor="middle">ESP32-WROOM-32 (DevKit V1)</text>
<rect x="480" y="110" width="260" height="590" rx="6" class="box"/>
<text x="610" y="688" class="sub" text-anchor="middle">vue logique — repérez les numéros sérigraphiés</text>
<!-- colonne gauche -->
<g class="pin">
<text x="492" y="140">EN</text>
<text x="492" y="178">GPIO 36</text>
<text x="492" y="216">GPIO 39</text>
<text x="492" y="254">GPIO 34</text>
<text x="492" y="292">GPIO 35</text>
<text x="492" y="330">GPIO 32</text>
<text x="492" y="558">GPIO 12</text>
<text x="492" y="596">GPIO 13</text>
<text x="492" y="634">GND</text>
</g>
<g class="pinused">
<text x="492" y="368">GPIO 33</text>
<text x="492" y="406">GPIO 25</text>
<text x="492" y="444">GPIO 26</text>
<text x="492" y="482">GPIO 27</text>
<text x="492" y="520">GPIO 14</text>
<text x="492" y="672">VIN (5 V)</text>
</g>
<!-- colonne droite -->
<g class="pin" text-anchor="end">
<text x="728" y="140">GPIO 23</text>
<text x="728" y="216">TX0</text>
<text x="728" y="254">RX0</text>
<text x="728" y="292">GPIO 21</text>
<text x="728" y="330">GPIO 19</text>
<text x="728" y="368">GPIO 18</text>
<text x="728" y="406">GPIO 5</text>
<text x="728" y="444">GPIO 17</text>
<text x="728" y="482">GPIO 16</text>
<text x="728" y="596">GPIO 15</text>
</g>
<g class="pinused" text-anchor="end">
<text x="728" y="178">GPIO 22</text>
<text x="728" y="520">GPIO 4</text>
<text x="728" y="558">GPIO 2</text>
<text x="728" y="634">GND</text>
<text x="728" y="672">3V3</text>
</g>
<!-- ergots de broches -->
<g class="tick">
<line x1="470" y1="135" x2="480" y2="135"/><line x1="470" y1="173" x2="480" y2="173"/>
<line x1="470" y1="211" x2="480" y2="211"/><line x1="470" y1="249" x2="480" y2="249"/>
<line x1="470" y1="287" x2="480" y2="287"/><line x1="470" y1="325" x2="480" y2="325"/>
<line x1="470" y1="363" x2="480" y2="363"/><line x1="470" y1="401" x2="480" y2="401"/>
<line x1="470" y1="439" x2="480" y2="439"/><line x1="470" y1="477" x2="480" y2="477"/>
<line x1="470" y1="515" x2="480" y2="515"/><line x1="470" y1="553" x2="480" y2="553"/>
<line x1="470" y1="591" x2="480" y2="591"/><line x1="470" y1="629" x2="480" y2="629"/>
<line x1="470" y1="667" x2="480" y2="667"/>
<line x1="740" y1="135" x2="750" y2="135"/><line x1="740" y1="173" x2="750" y2="173"/>
<line x1="740" y1="211" x2="750" y2="211"/><line x1="740" y1="249" x2="750" y2="249"/>
<line x1="740" y1="287" x2="750" y2="287"/><line x1="740" y1="325" x2="750" y2="325"/>
<line x1="740" y1="363" x2="750" y2="363"/><line x1="740" y1="401" x2="750" y2="401"/>
<line x1="740" y1="439" x2="750" y2="439"/><line x1="740" y1="477" x2="750" y2="477"/>
<line x1="740" y1="515" x2="750" y2="515"/><line x1="740" y1="553" x2="750" y2="553"/>
<line x1="740" y1="591" x2="750" y2="591"/><line x1="740" y1="629" x2="750" y2="629"/>
<line x1="740" y1="667" x2="750" y2="667"/>
</g>
<!-- =============================== INMP441 =============================== -->
<rect x="150" y="150" width="240" height="200" rx="6" class="box"/>
<text x="270" y="178" class="title" text-anchor="middle" font-size="15">INMP441</text>
<text x="270" y="196" class="sub" text-anchor="middle">micro I2S — entrée audio</text>
<text x="270" y="214" class="warn" text-anchor="middle">3,3 V uniquement</text>
<g class="pinused" text-anchor="end">
<text x="378" y="240">SD</text>
<text x="378" y="268">SCK</text>
<text x="378" y="296">WS</text>
</g>
<g class="pinused">
<text x="162" y="344">L/R</text>
<text x="196" y="344">GND</text>
<text x="236" y="344">VDD</text>
</g>
<g class="tick">
<line x1="390" y1="236" x2="400" y2="236"/>
<line x1="390" y1="264" x2="400" y2="264"/>
<line x1="390" y1="292" x2="400" y2="292"/>
<line x1="170" y1="350" x2="170" y2="360"/>
<line x1="200" y1="350" x2="200" y2="360"/>
<line x1="240" y1="350" x2="240" y2="360"/>
</g>
<!-- fils du micro -->
<polyline class="w data" points="390,236 455,236 455,363 470,363"/>
<polyline class="w clk" points="390,264 440,264 440,401 470,401"/>
<polyline class="w ws" points="390,292 425,292 425,439 470,439"/>
<polyline class="w gnd" points="170,350 170,790"/>
<polyline class="w gnd" points="200,350 200,790"/>
<polyline class="w v33" points="240,350 240,750"/>
<text x="120" y="378" class="lbl" transform="rotate(-90 120 378)">L/R → GND (canal gauche)</text>
<!-- =============================== MAX98357A =============================== -->
<rect x="880" y="380" width="260" height="240" rx="6" class="box"/>
<text x="1010" y="408" class="title" text-anchor="middle" font-size="15">MAX98357A</text>
<text x="1010" y="426" class="sub" text-anchor="middle">ampli classe D mono — sortie audio</text>
<text x="1010" y="444" class="sub" text-anchor="middle">alimenté en 5 V</text>
<g class="pinused">
<text x="892" y="475">LRC</text>
<text x="892" y="503">BCLK</text>
<text x="892" y="531">DIN</text>
</g>
<g class="pin">
<text x="898" y="600">GAIN</text>
<text x="950" y="600">SD</text>
<text x="996" y="600">GND</text>
<text x="1050" y="600">Vin</text>
</g>
<g class="pinused" text-anchor="end">
<text x="1128" y="475">+</text>
<text x="1128" y="535">−</text>
</g>
<g class="tick">
<line x1="870" y1="470" x2="880" y2="470"/>
<line x1="870" y1="500" x2="880" y2="500"/>
<line x1="870" y1="530" x2="880" y2="530"/>
<line x1="920" y1="620" x2="920" y2="630"/>
<line x1="970" y1="620" x2="970" y2="630"/>
<line x1="1020" y1="620" x2="1020" y2="630"/>
<line x1="1070" y1="620" x2="1070" y2="630"/>
<line x1="1140" y1="470" x2="1150" y2="470"/>
<line x1="1140" y1="530" x2="1150" y2="530"/>
</g>
<!-- fils de l'ampli : LRC et BCLK contournent la carte par le bas -->
<polyline class="w ws" points="470,515 430,515 430,715 860,715 860,470 880,470"/>
<polyline class="w clk" points="470,477 415,477 415,730 845,730 845,500 880,500"/>
<polyline class="w data" points="750,173 830,173 830,530 880,530"/>
<text x="262" y="680" class="lbl" font-size="11">GPIO 14 → LRC (vert)</text>
<text x="262" y="696" class="lbl" font-size="11">GPIO 27 → BCLK (bleu)</text>
<text x="262" y="716" class="lbl" font-size="11">ces deux fils contournent la carte</text>
<text x="760" y="166" class="lbl">GPIO 22 → DIN</text>
<!-- GAIN et SD laissés libres -->
<text x="880" y="650" class="lbl" font-size="11">GAIN : NC → 9 dB</text>
<text x="880" y="666" class="lbl" font-size="11">SD : NC → ampli actif</text>
<polyline class="w gnd" points="1020,620 1020,790"/>
<polyline class="w v5" points="1070,620 1070,830"/>
<!-- découplage conseillé : C1 (réservoir) + C2 (haute fréquence) -->
<g>
<polyline class="w v5" points="1070,655 1290,655"/>
<circle cx="1070" cy="655" r="4" fill="#333"/>
<!-- C1 : électrolytique polarisé -->
<line x1="1180" y1="655" x2="1180" y2="660" stroke="#333" stroke-width="2"/>
<line x1="1164" y1="660" x2="1196" y2="660" stroke="#333" stroke-width="3"/>
<line x1="1164" y1="672" x2="1196" y2="672" stroke="#333" stroke-width="3"/>
<polyline class="w gnd" points="1180,672 1180,790"/>
<text x="1150" y="666" class="lbl" font-size="13">+</text>
<text x="1202" y="650" class="pinused">C1</text>
<!-- C2 : céramique -->
<line x1="1260" y1="655" x2="1260" y2="660" stroke="#333" stroke-width="2"/>
<line x1="1244" y1="660" x2="1276" y2="660" stroke="#333" stroke-width="3"/>
<line x1="1244" y1="672" x2="1276" y2="672" stroke="#333" stroke-width="3"/>
<polyline class="w gnd" points="1260,672 1260,790"/>
<text x="1282" y="650" class="pinused">C2</text>
<text x="1000" y="876" class="title" font-size="13">Découplage conseillé</text>
<text x="1000" y="900" class="lbl">C1 = 470 µF / 16 V électrolytique, faible ESR</text>
<text x="1000" y="920" class="lbl"> polarisé : la bande imprimée est le « − »</text>
<text x="1000" y="940" class="lbl">C2 = 100 nF X7R céramique (code 104)</text>
<text x="1000" y="960" class="lbl">les deux à moins de 3 cm des broches de l'ampli</text>
</g>
<!-- =============================== haut-parleur =============================== -->
<rect x="1210" y="440" width="130" height="120" rx="6" class="box"/>
<text x="1275" y="478" class="title" text-anchor="middle" font-size="15">HP</text>
<text x="1275" y="500" class="sub" text-anchor="middle">4 Ω / 3 W</text>
<text x="1275" y="518" class="sub" text-anchor="middle">(ou 8 Ω / 2 W)</text>
<line class="w gnd" x1="1150" y1="470" x2="1210" y2="470"/>
<line class="w gnd" x1="1150" y1="530" x2="1210" y2="530"/>
<text x="1152" y="462" class="lbl">+</text>
<text x="1150" y="586" class="lbl" font-size="11">« − » jamais à la masse</text>
<!-- =============================== bouton PTT =============================== -->
<rect x="900" y="150" width="160" height="56" rx="6" class="box"/>
<text x="980" y="172" class="title" text-anchor="middle" font-size="14">Bouton PTT</text>
<text x="980" y="192" class="sub" text-anchor="middle">pull-up interne</text>
<polyline class="w gnd" points="750,515 800,515 800,177 900,177"/>
<polyline class="w gnd" points="1060,177 1120,177 1120,225"/>
<text x="756" y="508" class="lbl" font-size="11">→ bouton</text>
<g stroke="#333333" stroke-width="2.4" fill="none">
<line x1="1108" y1="225" x2="1132" y2="225"/>
<line x1="1113" y1="231" x2="1127" y2="231"/>
<line x1="1118" y1="237" x2="1122" y2="237"/>
</g>
<text x="1140" y="231" class="lbl">GND</text>
<!-- =============================== LED d'état =============================== -->
<rect x="900" y="250" width="160" height="56" rx="6" class="box"/>
<text x="980" y="272" class="title" text-anchor="middle" font-size="14">LED + 330 Ω</text>
<text x="980" y="292" class="sub" text-anchor="middle">optionnelle (LED intégrée)</text>
<polyline class="w gnd" points="750,553 815,553 815,277 900,277"/>
<polyline class="w gnd" points="1060,277 1120,277 1120,325"/>
<text x="756" y="546" class="lbl" font-size="11">→ LED</text>
<g stroke="#333333" stroke-width="2.4" fill="none">
<line x1="1108" y1="325" x2="1132" y2="325"/>
<line x1="1113" y1="331" x2="1127" y2="331"/>
<line x1="1118" y1="337" x2="1122" y2="337"/>
</g>
<text x="1140" y="331" class="lbl">GND</text>
<!-- =============================== rails =============================== -->
<line class="w v33" x1="140" y1="750" x2="1340" y2="750"/>
<line class="w gnd" x1="140" y1="790" x2="1340" y2="790"/>
<line class="w v5" x1="140" y1="830" x2="1340" y2="830"/>
<text x="140" y="744" class="lbl" fill="#cc2222">3,3 V</text>
<text x="140" y="784" class="lbl">GND (masse commune)</text>
<text x="140" y="824" class="lbl" fill="#e08214">5 V</text>
<!-- alimentations prélevées sur l'ESP32 -->
<polyline class="w v33" points="750,667 800,667 800,750"/>
<polyline class="w gnd" points="750,629 775,629 775,790"/>
<polyline class="w v5" points="470,667 455,667 455,830"/>
<!-- points de connexion aux rails -->
<g fill="#cc2222"><circle cx="800" cy="750" r="4"/><circle cx="240" cy="750" r="4"/></g>
<g fill="#333333">
<circle cx="775" cy="790" r="4"/><circle cx="170" cy="790" r="4"/>
<circle cx="200" cy="790" r="4"/><circle cx="1020" cy="790" r="4"/>
<circle cx="1180" cy="790" r="4"/><circle cx="1260" cy="790" r="4"/>
</g>
<g fill="#e08214"><circle cx="455" cy="830" r="4"/><circle cx="1070" cy="830" r="4"/></g>
<!-- =============================== légende =============================== -->
<rect x="30" y="852" width="520" height="140" rx="6" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="44" y="876" class="title" font-size="14">Légende</text>
<g class="lbl">
<line class="w v5" x1="44" y1="896" x2="80" y2="896"/><text x="88" y="900">5 V</text>
<line class="w v33" x1="44" y1="918" x2="80" y2="918"/><text x="88" y="922">3,3 V</text>
<line class="w gnd" x1="44" y1="940" x2="80" y2="940"/><text x="88" y="944">masse / logique</text>
<line class="w clk" x1="230" y1="896" x2="266" y2="896"/><text x="274" y="900">horloge bit (SCK / BCLK)</text>
<line class="w ws" x1="230" y1="918" x2="266" y2="918"/><text x="274" y="922">mot (WS / LRC)</text>
<line class="w data" x1="230" y1="940" x2="266" y2="940"/><text x="274" y="944">données audio (SD / DIN)</text>
</g>
<circle cx="52" cy="964" r="4" fill="#333"/>
<text x="88" y="968" class="lbl">connexion électrique — deux fils qui se croisent sans point ne sont pas reliés.</text>
<!-- =============================== avertissements =============================== -->
<text x="580" y="876" class="warn">Masse commune obligatoire entre tous les modules.</text>
<text x="580" y="900" class="lbl">Câblez hors tension. INMP441 en 3,3 V : le 5 V le détruit.</text>
<text x="580" y="920" class="lbl">Fils I2S de 15 cm maximum, éloignés des fils du haut-parleur.</text>
<text x="580" y="940" class="lbl">Alimentation 5 V / 2 A : l'ampli demande jusqu'à 1,2 A sur les crêtes.</text>
<text x="580" y="960" class="lbl">Détails et vérifications : docs/BROCHAGE.md</text>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+243
View File
@@ -0,0 +1,243 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
/**
* @file config.h
* @brief Constantes matérielles et audio du firmware.
*
* Toutes les valeurs réglables du firmware sont ici : aucune constante magique
* dans le code. Les broches évitent les broches de « strapping » (0, 2, 5, 12, 15)
* pour les signaux audio, car leur état au démarrage change le mode de boot.
*/
// ---------------------------------------------------------------- Format audio
/// Fréquence d'échantillonnage unique de tout le système (Hz).
static constexpr uint32_t AUDIO_SAMPLE_RATE = 16000;
/// Durée d'un chunk audio (ms). 50 ms = compromis latence / surcoût protocole.
static constexpr uint32_t AUDIO_CHUNK_MS = 50;
/// Nombre d'échantillons 16 bits dans un chunk : 16000 x 0,050 = 800.
static constexpr size_t AUDIO_CHUNK_SAMPLES = (AUDIO_SAMPLE_RATE * AUDIO_CHUNK_MS) / 1000;
/// Taille d'un chunk en octets : 800 x 2 = 1600.
static constexpr size_t AUDIO_CHUNK_BYTES = AUDIO_CHUNK_SAMPLES * sizeof(int16_t);
// --------------------------------------------------------- Broches micro INMP441
static constexpr int PIN_MIC_SCK = 25; ///< Horloge bit (BCLK) du micro
static constexpr int PIN_MIC_WS = 26; ///< Sélection de mot (LRCL / WS)
static constexpr int PIN_MIC_SD = 33; ///< Donnée série sortante du micro (DOUT)
// ------------------------------------------------------ Broches ampli MAX98357A
static constexpr int PIN_AMP_BCLK = 27; ///< Horloge bit
static constexpr int PIN_AMP_LRC = 14; ///< Sélection de mot
static constexpr int PIN_AMP_DIN = 22; ///< Donnée série entrante de l'ampli
// ------------------------------------------------------------ Interface utilisateur
static constexpr int PIN_BUTTON_PTT = 4; ///< Bouton « Push-To-Talk », pull-up interne
static constexpr int PIN_STATUS_LED = 2; ///< LED d'état (broche de strapping, sortie seule : acceptable)
/// LED de démonstration domotique (étape 11) : « la lumière » de la pièce de la carte.
/// GPIO 21 : libre, sans rôle au démarrage. LED + résistance 330 Ω vers GND.
static constexpr int PIN_DEMO_LIGHT = 21;
/// Durée d'anti-rebond du bouton (ms).
static constexpr uint32_t BUTTON_DEBOUNCE_MS = 30;
// ------------------------------------------------------------ Conversion micro
/**
* Gain numérique appliqué après le passe-haut, avant la réduction à 16 bits.
*
* Valeur **mesurée**. Une phrase à 20-30 cm donne, une fois le grondement
* infrasonore retiré par le passe-haut, une crête vers -25 dBFS : un gain de 4
* la place vers -13 dBFS, soit ~22 % de la pleine échelle, avec 13 dB de marge.
*
* Les valeurs 16 puis 1 retenues plus tôt venaient de mesures faussées (cadrage
* des bits erroné, puis infrasons dominants). L'auto-test annonce la valeur à
* retenir à la fin de chaque session ; elle dépend du module et de la distance.
*
* Le gain est numérique : il ne dégrade pas le rapport signal/bruit, il replace
* seulement le signal dans la plage utile.
*/
static constexpr int MIC_GAIN = 4;
/**
* Durée de stabilisation du micro après activation du canal I2S (ms).
*
* L'INMP441 s'endort dès que ses horloges s'arrêtent, et chaque réveil produit
* une composante continue qui dérive lentement. Mesuré sur le banc : encore
* +23 % de pleine échelle et en mouvement 600 ms après le réveil. On jette donc
* ce début, UNE seule fois au démarrage : ensuite, les horloges ne s'arrêtent
* plus jamais (flush() vide le DMA par lecture).
*/
static constexpr uint32_t MIC_SETTLE_MS = 1500;
/**
* Pull-down interne sur la ligne SD du micro.
*
* Le datasheet de l'INMP441 exige une résistance de 100 kOhms entre SD et la
* masse : le micro met sa sortie en haute impédance hors de sa moitié de trame.
* Beaucoup de modules bon marché ne la portent pas. Le pull-down interne de
* l'ESP32 (environ 45 kOhms) remplit le même rôle sans composant à ajouter.
*/
static constexpr bool MIC_SD_PULLDOWN = true;
/// Affiche l'analyse détaillée des bits au démarrage (utile tant que l'étape 1 n'est pas validée).
static constexpr bool MIC_SELFTEST_VERBOSE = true;
/// Durée de l'auto-test du micro au démarrage (ms).
static constexpr uint32_t MIC_PROBE_MS = 300;
/**
* Filtre passe-haut du micro, du premier ordre : bas += (x - bas) >> SHIFT ;
* sortie = x - bas. Fréquence de coupure : fc = fs / (2 pi 2^SHIFT).
*
* 5 donne fc = 16000 / (2 pi 32) = 80 Hz. La voix n'a rien d'utile en dessous.
* Mesuré sur le banc : avec l'ancien réglage (12, soit 0,6 Hz, qui ne retirait
* que la composante continue), 87 % de l'énergie d'un enregistrement était
* infrasonore — mouvements de la plaque d'essai quand on tient le bouton,
* souffle de la parole. Avec 5, il en reste 0,6 %, et la voix 99 %.
*
* Calculé sur les 24 bits du micro : sur 16 bits, un pas de 1/32 laisserait une
* zone morte de 31 LSB, source d'erreur continue et de distorsion à bas niveau.
*/
static constexpr int MIC_HPF_SHIFT = 5;
// ------------------------------------------------------------ Sortie audio (étape 2)
/**
* Cadrage I2S attendu par l'ampli. Le MAX98357**A** suit la norme Philips, le
* MAX98357**B** le cadrage MSB (c'est la lettre après le numéro, sur la puce).
* Si le bip de démarrage sort râpeux ou soufflé au lieu d'être pur : basculer.
*/
static constexpr bool AMP_FORMAT_PHILIPS = true;
/// Bip de démarrage : teste la sortie SEULE, sans PC, sans FIFO, sans tâche.
static constexpr uint32_t BOOT_TONE_HZ = 440;
static constexpr uint32_t BOOT_TONE_MS = 600;
/**
* Amplitude du bip : 8192 = 25 % de pleine échelle = -12 dBFS.
* Avec le gain de 9 dB du MAX98357A sur 4 ohms, environ 0,15 W : net sans agresser.
*/
static constexpr int16_t BOOT_TONE_AMPLITUDE = 8192;
/**
* FIFO de lecture : 16384 octets = 8192 échantillons = 512 ms à 16 kHz.
* Elle contient la réserve anti-gigue (PLAYBACK_START_MS) et encore 212 ms pour
* les rafales : après un retard, le réseau livre souvent plusieurs chunks d'un coup.
*/
static constexpr size_t PLAYBACK_FIFO_BYTES = 16384;
/// Bloc lu par la tâche audio : 256 échantillons = 16 ms = 512 octets.
static constexpr size_t PLAYBACK_BLOCK_SAMPLES = 256;
/**
* Tampon anti-gigue (étape 12) : réserve à constituer avant de jouer le premier
* échantillon, et à reconstituer après une famine. 300 ms = 6 chunks = 9600 octets,
* soit l'avance que le serveur envoie d'emblée : la lecture démarre dès qu'elle est
* arrivée. Un retard réseau plus court que la réserve ne s'entend pas.
*/
static constexpr uint32_t PLAYBACK_START_MS = 300;
/// Tâche audio : au-dessus de loop() (priorité 1), sur le cœur 1 (le Wi-Fi prendra le 0).
static constexpr int AUDIO_TASK_PRIORITY = 5;
static constexpr int AUDIO_TASK_CORE = 1;
static constexpr uint32_t AUDIO_TASK_STACK_BYTES = 4096;
/**
* Tampon de réception série : 8192 octets = 256 ms de PCM au débit de lecture.
* Le tampon par défaut (256 octets) déborderait en 8 ms pendant qu'on enregistre.
*/
static constexpr size_t SERIAL_RX_BUFFER_BYTES = 8192;
// ------------------------------------------------------------ Capture vers le réseau (étape 4)
/**
* FIFO de capture : 32768 octets = 1,02 s de PCM. L'envoi MQTT peut bloquer quand
* le Wi-Fi peine ; la tâche de capture, elle, ne s'arrête jamais. Au-delà d'une
* seconde de retard réseau, des chunks sont perdus — et comptés.
*/
static constexpr size_t CAPTURE_FIFO_BYTES = 32768;
/// Tâche de capture : même priorité et même cœur que la tâche de lecture.
static constexpr int CAPTURE_TASK_PRIORITY = 5;
static constexpr int CAPTURE_TASK_CORE = 1;
static constexpr uint32_t CAPTURE_TASK_STACK_BYTES = 4096;
// ------------------------------------------------------------ Liaison série (étape 1)
/// 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;
// ------------------------------------------------------------ Wi-Fi et MQTT (étape 3)
/// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/...
/// Sert aussi d'identifiant client MQTT : deux cartes ne doivent jamais le partager,
/// sinon le broker déconnecte l'une dès que l'autre se connecte, en boucle.
static constexpr const char* DEVICE_ID = "esp32-01";
/// Préfixe commun de tous les topics.
static constexpr const char* MQTT_TOPIC_PREFIX = "voice";
/**
* Intervalle de maintien de connexion MQTT (s). Sans nouvelle de l'ESP32 pendant
* 1,5 x cet intervalle, le broker publie le testament « offline ». 10 s : une
* coupure de courant est signalée en 15 s au plus, pour 1 petit paquet toutes
* les 10 s.
*/
static constexpr uint16_t MQTT_KEEPALIVE_S = 10;
/// Tampon de PubSubClient : un chunk de 1600 octets + topic + en-tête MQTT (étape 4).
static constexpr uint16_t MQTT_BUFFER_BYTES = 2048;
/// Délai entre deux tentatives de connexion au broker (ms).
static constexpr uint32_t MQTT_RETRY_MS = 5000;
/**
* Attente maximale d'une réponse du broker (s). Une tentative de connexion bloque
* loop() au plus MQTT_CONNECT_TIMEOUT_MS + ce temps, soit 3 s, et seulement quand le
* broker est injoignable : l'audio, lui, tourne dans ses propres tâches (étape 15 :
* borné et mesuré plutôt que déplacé dans une tâche, PubSubClient n'étant pas prévu
* pour être partagé entre tâches).
*/
static constexpr uint16_t MQTT_SOCKET_TIMEOUT_S = 2;
/// Délai d'ouverture de la connexion TCP au broker (ms). 3000 par défaut dans le core ;
/// sur un réseau local, un broker présent répond en quelques millisecondes.
static constexpr uint32_t MQTT_CONNECT_TIMEOUT_MS = 1000;
/**
* Présence du serveur (étape 15) : « online » retenu, remplacé par son testament
* « offline » s'il disparaît. Suit MQTT_TOPIC_PREFIX. Serveur absent = micro coupé :
* rien ne part plus sur le réseau sans personne pour l'écouter.
*/
static constexpr const char* MQTT_SERVER_STATUS_TOPIC = "voice/server/status";
/// Période de republication de l'état (ms) : IP, RSSI, durée de fonctionnement.
static constexpr uint32_t STATE_PERIOD_MS = 30000;
// ------------------------------------------------------------ Fiabilisation (étape 15)
/**
* Chien de garde (watchdog) des tâches : une tâche surveillée (loop, capture, audio)
* qui ne donne plus signe de vie pendant ce délai fait redémarrer la carte, avec la
* cause « watchdog » dans l'événement boot. Mieux vaut 2 s de redémarrage qu'une
* carte figée pour toujours.
*
* 15 s : au-dessus du pire blocage LÉGITIME trouvé dans le code réseau du core — un
* envoi TCP attend jusqu'à 10 x 1 s que le Wi-Fi accepte des données
* (NetworkClient::write). Le délai par défaut (5 s) redémarrerait la carte sur un
* simple Wi-Fi faible.
*/
static constexpr uint32_t WATCHDOG_TIMEOUT_S = 15;
/// Wi-Fi absent depuis ce délai (ms) : on relance la connexion de zéro. Filet de
/// sécurité, si la reconnexion automatique n'aboutit pas (point d'accès redémarré...).
static constexpr uint32_t WIFI_RESTART_MS = 30000;
+33
View File
@@ -0,0 +1,33 @@
#pragma once
/**
* @file secrets.h.example
* @brief Modèle de configuration sensible.
*
* Copier ce fichier en « secrets.h » et renseigner les valeurs réelles :
* cp include/secrets.h.example include/secrets.h
*
* « secrets.h » est ignoré par git : il ne doit JAMAIS être versionné.
* Ces valeurs ne sont utilisées qu'à partir de l'étape 3 (Wi-Fi + MQTT).
*/
// ------------------------------------------------------------------------ Wi-Fi
/// Réseau 2,4 GHz obligatoire : l'ESP32 ne sait pas se connecter en 5 GHz.
static constexpr const char* WIFI_SSID = "mon-reseau";
static constexpr const char* WIFI_PASSWORD = "mon-mot-de-passe";
// ------------------------------------------------------------------------- MQTT
/// 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 uint16_t MQTT_PORT = 1883;
/// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ».
/// MQTT_USER vide = DEVICE_ID (config.h) : c'est ce nom que les droits du broker
/// (mosquitto/acl) attendent. MQTT_PASSWORD vide = connexion anonyme, refusée par
/// un broker sécurisé.
static constexpr const char* MQTT_USER = "";
static constexpr const char* MQTT_PASSWORD = "";
+37
View File
@@ -0,0 +1,37 @@
; Configuration PlatformIO du firmware de l'assistant vocal.
;
; Nous utilisons le core Arduino-ESP32 3.x (basé sur ESP-IDF 5.x) car il fournit
; le nouveau pilote I2S « i2s_std », le seul maintenu. Le paquet officiel
; « espressif32 » du registre PlatformIO est resté sur le core 2.0.x : on passe
; donc par le fork communautaire « pioarduino », maintenu et recommandé par
; Espressif pour les cores 3.x.
[env:esp32dev]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21/platform-espressif32.zip
board = esp32dev
framework = arduino
; Le débit série doit dépasser le débit audio (32 kB/s) : voir README.
monitor_speed = 921600
upload_speed = 921600
; Table de partitions : 3 Mo pour le programme au lieu de 1,3 Mo, sans zone de
; mise à jour à distance (OTA), inutile ici. Avec le Wi-Fi, le programme occupait
; déjà 74 % de la partition par défaut à l'étape 3.
board_build.partitions = huge_app.csv
; Client MQTT minimal et éprouvé (étape 3). Version épinglée : pas de surprise.
lib_deps =
knolleary/PubSubClient @ 2.8
; Lecture des messages JSON du topic control (étape 5), commandes à venir (étape 11).
bblanchon/ArduinoJson @ 7.2.1
build_flags =
-DCORE_DEBUG_LEVEL=3
-Wall
; Le firmware est découpé en classes : include/ pour les constantes, src/ pour le code.
build_src_filter = +<*>
check_tool = cppcheck
check_flags = --enable=warning,style
+37
View File
@@ -0,0 +1,37 @@
#include "AudioFifo.h"
bool AudioFifo::begin(uint8_t* storage, size_t capacity, size_t wakeBytes) {
_handle = xStreamBufferCreateStatic(capacity, wakeBytes, storage, &_control);
return _handle != nullptr;
}
bool AudioFifo::write(const uint8_t* data, size_t length) {
if (_handle == nullptr) {
return false;
}
// Tout ou rien : un seul écrivain, donc la place mesurée ne peut que grandir
// entre ce test et l'écriture (le lecteur ne fait qu'en libérer).
if (xStreamBufferSpacesAvailable(_handle) < length) {
_overruns.fetch_add(1);
return false;
}
xStreamBufferSend(_handle, data, length, 0);
return true;
}
size_t AudioFifo::read(uint8_t* data, size_t length, uint32_t timeoutMs) {
if (_handle == nullptr) {
return 0;
}
return xStreamBufferReceive(_handle, data, length, pdMS_TO_TICKS(timeoutMs));
}
size_t AudioFifo::available() const {
return (_handle == nullptr) ? 0 : xStreamBufferBytesAvailable(_handle);
}
uint32_t AudioFifo::overruns() const {
return _overruns.load();
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/stream_buffer.h>
#include <atomic>
/**
* @brief File d'attente d'octets PCM entre deux rythmes différents.
*
* Deux usages, même mécanisme :
* - lecture (étape 2) : la réception écrit par à-coups, la tâche audio lit au
* rythme régulier du DAC ;
* - capture (étape 4) : la tâche de capture écrit au rythme du micro, l'envoi
* réseau lit par à-coups, au gré du Wi-Fi.
*
* C'est un StreamBuffer FreeRTOS, conçu pour exactement un écrivain et un
* lecteur. Son stockage est fourni par l'appelant (tableau statique) : aucune
* allocation, et chaque FIFO a la taille qui convient à son usage.
*
* Invariant : on n'écrit que des chunks entiers, jamais un morceau. Un chunk
* coupé laisserait un nombre impair d'octets, et tous les échantillons suivants
* seraient lus à cheval sur deux valeurs — un bruit assourdissant.
*/
class AudioFifo {
public:
AudioFifo() = default;
/**
* @brief Crée le StreamBuffer sur le stockage fourni.
* @param storage Tableau d'au moins @p capacity + 1 octets (exigence FreeRTOS).
* @param capacity Capacité utile en octets.
* @param wakeBytes Nombre d'octets qui réveillent un lecteur en attente.
* @return true si la création a réussi.
*/
bool begin(uint8_t* storage, size_t capacity, size_t wakeBytes);
/**
* @brief Ajoute un chunk, sans jamais attendre.
* @return true si le chunk entier a été accepté ; false s'il a été refusé
* faute de place (il est alors compté comme perdu, en entier).
*/
bool write(const uint8_t* data, size_t length);
/**
* @brief Retire au plus @p length octets, en attendant au plus @p timeoutMs.
* @return Nombre d'octets lus (pair, tant qu'on ne demande que des tailles paires).
*/
size_t read(uint8_t* data, size_t length, uint32_t timeoutMs);
/// Octets actuellement en attente.
size_t available() const;
/// Nombre de chunks refusés faute de place depuis le démarrage.
uint32_t overruns() const;
private:
StaticStreamBuffer_t _control = {};
StreamBufferHandle_t _handle = nullptr;
std::atomic<uint32_t> _overruns{0}; ///< lu par loop(), incrémenté par l'écrivain
};
+123
View File
@@ -0,0 +1,123 @@
#include "AudioOutput.h"
#include <math.h>
AudioOutput::AudioOutput(int pinBclk, int pinLrc, int pinDin)
: _pinBclk(pinBclk), _pinLrc(pinLrc), _pinDin(pinDin) {}
bool AudioOutput::begin(uint32_t sampleRate, bool philips) {
// --- 1. Canal I2S1 en émission, l'ESP32 maître d'horloge.
i2s_chan_config_t chanConfig = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
// 6 descripteurs de 240 trames = 1440 trames = 90 ms de marge : c'est aussi la
// latence ajoutée par le DMA entre write() et le haut-parleur.
chanConfig.dma_desc_num = 6;
chanConfig.dma_frame_num = 240;
// DMA à court de données : il envoie des zéros (silence) au lieu de répéter
// en boucle le dernier tampon, ce qui produirait un bourdonnement.
chanConfig.auto_clear = true;
if (i2s_new_channel(&chanConfig, &_txHandle, nullptr) != ESP_OK) {
_txHandle = nullptr;
return false;
}
// --- 2. 16 bits, deux voies, cadrage choisi. Horloge bit : 16000 x 32 = 512 kHz.
const i2s_std_slot_config_t philipsSlots =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO);
const i2s_std_slot_config_t msbSlots =
I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO);
i2s_std_config_t stdConfig = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate),
.slot_cfg = philips ? philipsSlots : msbSlots,
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED, // le MAX98357A n'a pas besoin d'horloge maître
.bclk = static_cast<gpio_num_t>(_pinBclk),
.ws = static_cast<gpio_num_t>(_pinLrc),
.dout = static_cast<gpio_num_t>(_pinDin),
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
if (i2s_channel_init_std_mode(_txHandle, &stdConfig) != ESP_OK ||
i2s_channel_enable(_txHandle) != ESP_OK) {
i2s_del_channel(_txHandle);
_txHandle = nullptr;
return false;
}
_sampleRate = sampleRate;
return true;
}
size_t AudioOutput::write(const int16_t* samples, size_t count) {
if (_txHandle == nullptr) {
return 0;
}
size_t done = 0;
while (done < count) {
size_t frames = count - done;
if (frames > BLOCK_FRAMES) {
frames = BLOCK_FRAMES;
}
// Mono -> stéréo : le même échantillon sur la voie gauche et la droite.
for (size_t i = 0; i < frames; ++i) {
_stereo[i * 2] = samples[done + i];
_stereo[(i * 2) + 1] = samples[done + i];
}
size_t written = 0;
if (i2s_channel_write(_txHandle, _stereo, frames * 2 * sizeof(int16_t), &written,
portMAX_DELAY) != ESP_OK) {
break;
}
done += written / (2 * sizeof(int16_t));
}
return done;
}
void AudioOutput::playTone(uint32_t frequencyHz, uint32_t durationMs, int16_t amplitude) {
if (_txHandle == nullptr || _sampleRate == 0) {
return;
}
const size_t total = (static_cast<size_t>(_sampleRate) * durationMs) / 1000;
const size_t fade = _sampleRate / 100; // 10 ms : un démarrage brutal claquerait
const float step = 2.0F * static_cast<float>(M_PI) * static_cast<float>(frequencyHz) /
static_cast<float>(_sampleRate);
float phase = 0.0F;
size_t done = 0;
while (done < total) {
size_t count = total - done;
if (count > BLOCK_FRAMES) {
count = BLOCK_FRAMES;
}
for (size_t i = 0; i < count; ++i) {
const size_t n = done + i;
float gain = 1.0F;
if (n < fade) {
gain = static_cast<float>(n) / static_cast<float>(fade);
} else if (total - n < fade) {
gain = static_cast<float>(total - n) / static_cast<float>(fade);
}
_tone[i] = static_cast<int16_t>(lroundf(static_cast<float>(amplitude) * gain *
sinf(phase)));
phase += step;
if (phase > 2.0F * static_cast<float>(M_PI)) {
phase -= 2.0F * static_cast<float>(M_PI); // évite la perte de précision
}
}
write(_tone, count);
done += count;
}
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <Arduino.h>
#include <driver/i2s_std.h>
/**
* @brief Sortie audio vers l'ampli I2S MAX98357A, sur le périphérique I2S1.
*
* Reçoit du PCM 16 bits mono et l'émet en **stéréo, chaque échantillon copié
* sur les deux voies**. Leçon de l'étape 1 : ne rien laisser au comportement
* implicite d'un mode mono. Le MAX98357A joue par défaut (gauche + droite) / 2 :
* avec deux voies identiques, le niveau est exact, et une éventuelle inversion
* gauche/droite n'a aucune conséquence.
*
* Le canal tourne en permanence dès begin(). Quand rien n'est à jouer, le DMA
* envoie des zéros : l'ampli reste cadencé et silencieux, ses broches ne
* flottent jamais (c'était la cause du souffle au premier branchement).
*/
class AudioOutput {
public:
/**
* @param pinBclk Horloge bit vers l'ampli (BCLK).
* @param pinLrc Sélection de mot vers l'ampli (LRC).
* @param pinDin Données vers l'ampli (DIN).
*/
AudioOutput(int pinBclk, int pinLrc, int pinDin);
/**
* @brief Démarre le canal I2S1 en émission ; l'ampli reçoit aussitôt du silence.
* @param sampleRate Fréquence d'échantillonnage en Hz.
* @param philips true : cadrage Philips (MAX98357A) ; false : MSB (MAX98357B).
* @return true si le démarrage a réussi.
*/
bool begin(uint32_t sampleRate, bool philips);
/**
* @brief Émet des échantillons mono ; bloque jusqu'à ce que le DMA les accepte.
*
* Le blocage rythme naturellement l'appelant sur le débit du DAC. À n'appeler
* que depuis un seul contexte (la tâche audio, ou setup() avant elle).
*
* @param samples Échantillons 16 bits signés.
* @param count Nombre d'échantillons.
* @return Nombre d'échantillons émis.
*/
size_t write(const int16_t* samples, size_t count);
/**
* @brief Joue un son pur, de façon bloquante, avec fondus de 10 ms.
*
* Sert de test de la sortie seule : aucun PC, aucune FIFO, aucune tâche.
*
* @param frequencyHz Fréquence en Hz.
* @param durationMs Durée en millisecondes.
* @param amplitude Amplitude crête, 0 à 32767.
*/
void playTone(uint32_t frequencyHz, uint32_t durationMs, int16_t amplitude);
private:
/// Trames traitées par passe : 256 = 16 ms à 16 kHz.
static constexpr size_t BLOCK_FRAMES = 256;
int _pinBclk;
int _pinLrc;
int _pinDin;
i2s_chan_handle_t _txHandle = nullptr;
uint32_t _sampleRate = 0;
int16_t _stereo[BLOCK_FRAMES * 2] = {}; ///< tampon d'émission, voies entrelacées
int16_t _tone[BLOCK_FRAMES] = {}; ///< tampon de génération du son pur
};
+77
View File
@@ -0,0 +1,77 @@
#include "CommandHandler.h"
#include <ArduinoJson.h>
CommandHandler::CommandHandler(PlaybackStream& playback, MqttLink& mqtt, DemoDevices& devices,
ModeCallback onListen, ModeCallback onCapture)
: _playback(playback), _mqtt(mqtt), _devices(devices), _onListen(onListen), _onCapture(onCapture) {}
void CommandHandler::handle(const uint8_t* payload, size_t length) {
JsonDocument doc; // messages de contrôle : quelques dizaines d'octets
const DeserializationError error = deserializeJson(doc, payload, length);
if (error) {
Serial.printf("# control : JSON illisible (%s)\n", error.c_str());
return;
}
const char* event = doc["event"] | "";
const char* command = doc["cmd"] | "";
if (strcmp(event, "start") == 0) {
const char* session = doc["session"] | "?";
const bool accepted = _playback.open(session, doc["rate"] | 0UL, doc["bits"] | 0UL,
doc["channels"] | 0UL, true);
if (!accepted) {
char reply[96];
snprintf(reply, sizeof(reply), "{\"event\":\"rejected\",\"session\":\"%s\"}", session);
_mqtt.publish("event", reply, false);
}
return;
}
if (strcmp(event, "end") == 0) {
_playback.close(doc["chunks"] | -1L);
return;
}
if (strcmp(command, "ping") == 0) {
char reply[64];
snprintf(reply, sizeof(reply), "{\"event\":\"pong\",\"uptime_ms\":%lu}",
static_cast<unsigned long>(millis()));
_mqtt.publish("event", reply, false);
Serial.println("# control : ping -> pong");
return;
}
if (strcmp(command, "listen") == 0) {
_onListen(doc["enabled"] | false);
return;
}
if (strcmp(command, "capture") == 0) {
_onCapture(doc["active"] | false);
return;
}
if (strcmp(command, "device") == 0) {
handleDevice(doc["device"] | "", doc["action"] | "");
return;
}
const int shown = static_cast<int>(length > 120 ? 120 : length);
Serial.printf("# control : message non reconnu : %.*s\n", shown,
reinterpret_cast<const char*>(payload));
}
void CommandHandler::handleDevice(const char* device, const char* action) {
const bool ok = _devices.apply(device, action);
const char* state = _devices.state(device);
Serial.printf("# domotique : %s %s -> %s (%s)\n", device, action, ok ? "fait" : "REFUSE", state);
// Confirmation : le serveur sait que la commande a été reçue ET exécutée.
char reply[128];
snprintf(reply, sizeof(reply),
"{\"event\":\"device\",\"device\":\"%.16s\",\"action\":\"%.8s\",\"ok\":%s,\"state\":\"%s\"}",
device, action, ok ? "true" : "false", state);
_mqtt.publish("event", reply, false);
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <Arduino.h>
#include "DemoDevices.h"
#include "MqttLink.h"
#include "PlaybackStream.h"
/**
* @brief Interprète les messages JSON reçus sur voice/<id>/control.
*
* Deux familles de messages :
* - flux audio sortant (étape 5), clé « event » :
* {"event":"start","session":"s1","rate":16000,"bits":16,"channels":1,...}
* {"event":"end","session":"s1","chunks":42}
* - commandes, clé « cmd » :
* {"cmd":"ping"} -> répond {"event":"pong","uptime_ms":...} sur voice/<id>/event
* {"cmd":"device","device":"light","action":"on"} (étape 11)
* -> applique la commande aux appareils de la pièce, puis confirme :
* {"event":"device","device":"light","action":"on","ok":true,"state":"on"}
* {"cmd":"listen","enabled":true} (étape 13) écoute continue : le micro part
* vers le serveur sur voice/<id>/audio/stream, qui guette le mot de réveil
* {"cmd":"capture","active":true} (étape 13) le serveur a entendu le mot de
* réveil et écoute la commande : LED allumée
*
* Le traitement reste court : il est appelé depuis le rappel MQTT, dans loop().
*/
class CommandHandler {
public:
/// Rappel pour un mode activé ou désactivé par le serveur.
using ModeCallback = void (*)(bool enabled);
CommandHandler(PlaybackStream& playback, MqttLink& mqtt, DemoDevices& devices,
ModeCallback onListen, ModeCallback onCapture);
/// Traite un message du topic control.
void handle(const uint8_t* payload, size_t length);
private:
PlaybackStream& _playback;
MqttLink& _mqtt;
DemoDevices& _devices;
ModeCallback _onListen;
ModeCallback _onCapture;
/// Commande domotique : exécution puis confirmation sur voice/<id>/event.
void handleDevice(const char* device, const char* action);
};
+45
View File
@@ -0,0 +1,45 @@
#include "DemoDevices.h"
#include <string.h>
DemoDevices::DemoDevices(int lightPin) : _lightPin(lightPin) {}
void DemoDevices::begin() {
pinMode(_lightPin, OUTPUT);
digitalWrite(_lightPin, LOW);
}
bool DemoDevices::apply(const char* device, const char* action) {
const bool on = strcmp(action, "on") == 0;
const bool off = strcmp(action, "off") == 0;
const bool open = strcmp(action, "open") == 0;
const bool close = strcmp(action, "close") == 0;
if (strcmp(device, "light") == 0 && (on || off)) {
_lightOn = on;
digitalWrite(_lightPin, on ? HIGH : LOW);
return true;
}
if (strcmp(device, "shutter") == 0 && (open || close)) {
_shutterOpen = open; // simulé : un moteur de volet se piloterait ici
return true;
}
if (strcmp(device, "door") == 0 && (open || close)) {
_doorOpen = open; // simulé : jamais de gâche réelle commandée à la voix (voir README)
return true;
}
return false;
}
const char* DemoDevices::state(const char* device) const {
if (strcmp(device, "light") == 0) {
return _lightOn ? "on" : "off";
}
if (strcmp(device, "shutter") == 0) {
return _shutterOpen ? "open" : "closed";
}
if (strcmp(device, "door") == 0) {
return _doorOpen ? "open" : "closed";
}
return "?";
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
/**
* @brief Appareils de démonstration de la pièce où se trouve la carte (étape 11).
*
* La lumière est une vraie LED (PIN_DEMO_LIGHT, via une résistance de 330 Ω) ;
* les volets et la porte sont simulés : leur état est gardé en mémoire et
* affiché sur la liaison série. Un vrai relais se brancherait comme la LED.
*/
class DemoDevices {
public:
/// @param lightPin Broche de la LED qui figure la lumière.
explicit DemoDevices(int lightPin);
/// Configure la broche de la LED, éteinte.
void begin();
/**
* @brief Applique une commande.
* @param device « light », « shutter » ou « door ».
* @param action « on »/« off » pour la lumière, « open »/« close » pour volets et porte.
* @return false si l'appareil ou l'action est inconnu (rien n'est modifié).
*/
bool apply(const char* device, const char* action);
/// État courant : « on », « off », « open », « closed », ou « ? » si l'appareil est inconnu.
const char* state(const char* device) const;
private:
int _lightPin;
bool _lightOn = false;
bool _shutterOpen = false;
bool _doorOpen = false;
};
+361
View File
@@ -0,0 +1,361 @@
#include "MicCapture.h"
#include <driver/gpio.h>
#include "config.h"
MicCapture::MicCapture(int pinSck, int pinWs, int pinSd)
: _pinSck(pinSck), _pinWs(pinWs), _pinSd(pinSd) {}
bool MicCapture::begin(uint32_t sampleRate, Format format) {
// --- 1. Création du canal I2S0 en réception, l'ESP32 étant maître d'horloge.
i2s_chan_config_t chanConfig = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
// 6 descripteurs de 200 trames stéréo de 8 octets = 9600 octets, soit 150 ms
// de marge à 16 kHz : de quoi absorber les pauses de la boucle principale.
chanConfig.dma_desc_num = 6;
chanConfig.dma_frame_num = 200;
chanConfig.auto_clear = false;
if (i2s_new_channel(&chanConfig, nullptr, &_rxHandle) != ESP_OK) {
_rxHandle = nullptr;
return false;
}
// --- 2. Mots de 32 bits, les DEUX moitiés, cadrage choisi par l'appelant.
const i2s_std_slot_config_t philips =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO);
const i2s_std_slot_config_t msb =
I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO);
i2s_std_config_t stdConfig = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate),
.slot_cfg = (format == Format::Philips) ? philips : msb,
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = static_cast<gpio_num_t>(_pinSck),
.ws = static_cast<gpio_num_t>(_pinWs),
.dout = I2S_GPIO_UNUSED,
.din = static_cast<gpio_num_t>(_pinSd),
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
if (i2s_channel_init_std_mode(_rxHandle, &stdConfig) != ESP_OK) {
end();
return false;
}
// --- 3. Pull-down sur la ligne de données, exigé par le datasheet INMP441 :
// le micro met sa sortie en haute impédance hors de sa moitié de trame et
// après ses 24 bits utiles. Sans résistance, la ligne flotte et l'on lit
// n'importe quoi pendant ces instants.
gpio_set_pull_mode(static_cast<gpio_num_t>(_pinSd),
MIC_SD_PULLDOWN ? GPIO_PULLDOWN_ONLY : GPIO_FLOATING);
if (i2s_channel_enable(_rxHandle) != ESP_OK) {
end();
return false;
}
_sampleRate = sampleRate;
_format = format;
_half = Half::First;
_phase = 0; // le canal démarre sur un début de trame : la phase est ancrée ici
_lowBand = 0;
resetStats();
// Le micro vient d'être réveillé par ses horloges : on laisse passer son
// transitoire. À partir d'ici, plus rien n'arrête les horloges.
settle(MIC_SETTLE_MS);
flush();
return true;
}
void MicCapture::end() {
if (_rxHandle == nullptr) {
return;
}
i2s_channel_disable(_rxHandle);
i2s_del_channel(_rxHandle);
_rxHandle = nullptr;
_sampleRate = 0;
}
void MicCapture::useHalf(Half half) {
_half = half;
flush(); // réamorce le filtre de continu sur la nouvelle moitié
}
MicCapture::Half MicCapture::half() const {
return _half;
}
uint32_t MicCapture::sampleRate() const {
return _sampleRate;
}
MicCapture::Format MicCapture::format() const {
return _format;
}
size_t MicCapture::read(int16_t* buffer, size_t count) {
if (_rxHandle == nullptr) {
return 0;
}
// Une trame vaut deux mots. Le pilote rend ce qui est disponible, pas
// forcément un nombre pair de mots : la phase est donc mémorisée entre les
// appels, sinon les deux moitiés s'échangent et l'on enregistre la muette.
const uint8_t wantedPhase = (_half == Half::First) ? 0 : 1;
size_t produced = 0;
while (produced < count) {
size_t framesWanted = count - produced;
if (framesWanted > SCRATCH_WORDS / 2) {
framesWanted = SCRATCH_WORDS / 2;
}
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch,
framesWanted * 2 * sizeof(int32_t), &bytesRead,
pdMS_TO_TICKS(200));
// Les mots reçus sont traités même si la lecture a été interrompue :
// les ignorer décalerait la phase pour tout le reste de la session.
const size_t words = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < words; ++i) {
const bool selected = (_phase == wantedPhase);
_phase ^= 1; // toujours avancer, même pour un mot ignoré
if (!selected || produced >= count) {
continue;
}
const int32_t word = _scratch[i];
accumulate(_stats, word);
// Les 24 bits utiles occupent les bits 31..8 : on travaille en 24 bits
// jusqu'au bout, et l'on ne réduit à 16 bits qu'à la toute fin.
const int32_t sample24 = word >> 8;
// Passe-haut du premier ordre à 80 Hz : _lowBand suit tout ce qui est
// plus lent (composante continue, grondement), on le retire.
_lowBand += (sample24 - _lowBand) >> MIC_HPF_SHIFT;
const int32_t filtered24 = sample24 - _lowBand;
const int32_t magnitude = (filtered24 < 0) ? -filtered24 : filtered24;
if (magnitude > _stats.filteredPeak) {
_stats.filteredPeak = magnitude;
}
// Gain puis réduction 24 -> 16 bits, avec arrondi. |filtered24| < 2^25
// et MIC_GAIN <= 64 : le produit tient dans un int32.
int32_t out = (filtered24 * MIC_GAIN + 128) >> 8;
// Écrêtage explicite : sans lui, un dépassement produirait un craquement.
if (out > 32767) {
out = 32767;
} else if (out < -32768) {
out = -32768;
}
buffer[produced] = static_cast<int16_t>(out);
++produced;
}
if (err != ESP_OK || bytesRead == 0) {
break;
}
}
return produced;
}
MicCapture::FrameStats MicCapture::probe(uint32_t durationMs) {
FrameStats frames;
if (_rxHandle == nullptr || _sampleRate == 0) {
return frames;
}
const size_t target = (static_cast<size_t>(_sampleRate) * durationMs) / 1000;
while (frames.first.wordCount < target) {
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch),
&bytesRead, pdMS_TO_TICKS(200));
const size_t words = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < words; ++i) {
accumulate((_phase == 0) ? frames.first : frames.second, _scratch[i]);
_phase ^= 1;
}
if (err != ESP_OK || bytesRead == 0) {
break;
}
}
return frames;
}
size_t MicCapture::readRaw(int32_t* words, size_t count) {
if (_rxHandle == nullptr) {
return 0;
}
// On ne rend que des trames entières : si le prochain mot est une 2e moitié,
// on le jette pour que words[0] soit toujours une 1re moitié.
if (_phase != 0) {
size_t bytesRead = 0;
i2s_channel_read(_rxHandle, _scratch, sizeof(int32_t), &bytesRead, pdMS_TO_TICKS(200));
_phase ^= static_cast<uint8_t>((bytesRead / sizeof(int32_t)) & 1U);
if (_phase != 0) {
return 0; // lecture impossible : on ne rend rien plutôt que du désaligné
}
}
size_t copied = 0;
while (copied < count) {
size_t wanted = count - copied;
if (wanted > SCRATCH_WORDS) {
wanted = SCRATCH_WORDS;
}
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, wanted * sizeof(int32_t),
&bytesRead, pdMS_TO_TICKS(200));
const size_t got = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < got; ++i) {
words[copied + i] = _scratch[i];
_phase ^= 1;
}
copied += got;
if (err != ESP_OK || bytesRead == 0) {
break;
}
}
return copied;
}
MicCapture::DataLineReport MicCapture::probeDataLine() {
DataLineReport report;
report.samples = 4000; // environ 1 ms, soit une vingtaine de trames à 16 kHz
const gpio_num_t pin = static_cast<gpio_num_t>(_pinSd);
// gpio_get_level() lit l'état réel de la broche, même quand elle est routée
// vers le périphérique I2S : les horloges peuvent donc continuer de tourner.
gpio_set_pull_mode(pin, GPIO_PULLUP_ONLY);
delayMicroseconds(200); // laisse la résistance charger la capacité de la ligne
for (uint32_t i = 0; i < report.samples; ++i) {
report.onesWithPullUp += static_cast<uint32_t>(gpio_get_level(pin));
}
gpio_set_pull_mode(pin, GPIO_PULLDOWN_ONLY);
delayMicroseconds(200);
for (uint32_t i = 0; i < report.samples; ++i) {
report.onesWithPullDown += static_cast<uint32_t>(gpio_get_level(pin));
}
// Retour à la configuration de service.
gpio_set_pull_mode(pin, MIC_SD_PULLDOWN ? GPIO_PULLDOWN_ONLY : GPIO_FLOATING);
return report;
}
void MicCapture::accumulate(RawStats& stats, int32_t word) {
if (stats.wordCount == 0) {
stats.minWord = word;
stats.maxWord = word;
} else if (word < stats.minWord) {
stats.minWord = word;
} else if (word > stats.maxWord) {
stats.maxWord = word;
}
if (word == 0) {
++stats.zeroCount;
}
// Valeur efficace : (mot >> 12) tient sur 20 bits, son carré sur 40 : un
// int64 ne déborde qu'après des millions de mots, bien au-delà d'une session.
const int64_t reduced = static_cast<int64_t>(word >> 12);
stats.sum += reduced;
stats.sumSquares += reduced * reduced;
// Le bit le plus bas jamais mis à 1 dit où s'arrêtent les données : 24 bits
// bien cadrés s'arrêtent au bit 8. Ne dépend pas de l'amplitude du signal.
stats.orBits |= static_cast<uint32_t>(word);
++stats.wordCount;
}
void MicCapture::resetStats() {
_stats = RawStats();
}
MicCapture::RawStats MicCapture::stats() const {
return _stats;
}
void MicCapture::flush() {
if (_rxHandle == nullptr) {
return;
}
// On vide le DMA par lecture, SANS arrêter le canal : arrêter les horloges
// endormirait le micro, et son réveil produirait une dérive lente de la
// composante continue, audible comme un « boum » en début de session.
// La phase est suivie mot par mot, l'alignement est donc conservé.
//
// Au passage, on mesure la composante continue de la moitié retenue pour
// amorcer le passe-haut : la session démarre sans transitoire.
const uint8_t wantedPhase = (_half == Half::First) ? 0 : 1;
int64_t sum = 0;
size_t count = 0;
size_t bytesRead = 0;
do {
bytesRead = 0;
i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch), &bytesRead, 0);
const size_t words = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < words; ++i) {
if (_phase == wantedPhase) {
sum += _scratch[i] >> 8; // en LSB 24 bits, comme le filtre
++count;
}
_phase ^= 1;
}
} while (bytesRead > 0);
if (count > 0) {
_lowBand = static_cast<int32_t>(sum / static_cast<int64_t>(count));
}
}
void MicCapture::settle(uint32_t durationMs) {
if (_rxHandle == nullptr) {
return;
}
const uint32_t deadline = millis() + durationMs;
while (static_cast<int32_t>(deadline - millis()) > 0) {
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch),
&bytesRead, pdMS_TO_TICKS(50));
// Mots jetés, mais comptés : un nombre impair inverse la phase.
_phase ^= static_cast<uint8_t>((bytesRead / sizeof(int32_t)) & 1U);
if (err != ESP_OK) {
break;
}
}
}
+169
View File
@@ -0,0 +1,169 @@
#pragma once
#include <Arduino.h>
#include <driver/i2s_std.h>
/**
* @brief Capture audio depuis le micro I2S INMP441.
*
* Le micro fournit des échantillons 24 bits alignés à gauche dans des mots de
* 32 bits. Cette classe lit ces mots via le DMA du périphérique I2S0, les
* convertit en PCM 16 bits signé, supprime l'offset continu et applique un gain.
*
* Lecture en **stéréo** : une trame I2S contient deux moitiés, et l'INMP441
* n'émet que dans l'une des deux, choisie par sa broche L/R. On lit les deux et
* on choisit en logiciel, en suivant la phase mot par mot.
*/
class MicCapture {
public:
/// Moitié de trame I2S retenue.
enum class Half {
First, ///< premier mot de chaque trame
Second, ///< second mot de chaque trame
};
/**
* @brief Cadrage des bits dans la trame.
*
* Philips (I2S standard) : le premier bit de donnée arrive un coup d'horloge
* APRÈS le changement de WS. MSB (justifié à gauche) : il arrive EN MÊME
* TEMPS. Un cadrage faux décale tout le mot d'un bit : le bit de signe tombe
* hors de sa place, et un signal faible prend l'allure d'un bruit pleine
* échelle.
*/
enum class Format {
Philips,
Msb,
};
/// Statistiques sur des mots I2S bruts, avant toute conversion.
struct RawStats {
int32_t minWord = 0; ///< mot le plus négatif observé
int32_t maxWord = 0; ///< mot le plus positif observé
size_t wordCount = 0; ///< nombre de mots examinés
size_t zeroCount = 0; ///< nombre de mots exactement nuls
int64_t sum = 0; ///< somme de (mot >> 12), pour la composante continue
int64_t sumSquares = 0; ///< somme des carrés de (mot >> 12), pour la valeur efficace
uint32_t orBits = 0; ///< OU de tous les mots : quels bits ont servi au moins une fois
int32_t filteredPeak = 0; ///< crête après passe-haut, avant gain, en LSB 24 bits (read() seul)
};
/// Relevé simultané des deux moitiés d'une trame.
struct FrameStats {
RawStats first;
RawStats second;
};
/// Résultat du test électrique de la ligne de données.
struct DataLineReport {
uint32_t samples = 0; ///< nombre de lectures par essai
uint32_t onesWithPullUp = 0; ///< niveaux hauts lus avec pull-up interne
uint32_t onesWithPullDown = 0; ///< niveaux hauts lus avec pull-down interne
};
/**
* @param pinSck Broche d'horloge bit (SCK / BCLK du micro).
* @param pinWs Broche de sélection de mot (WS / LRCL).
* @param pinSd Broche de donnée série venant du micro (SD / DOUT).
*/
MicCapture(int pinSck, int pinWs, int pinSd);
/**
* @brief Initialise le périphérique I2S0 en réception stéréo.
* @param sampleRate Fréquence d'échantillonnage en Hz.
* @param format Cadrage des bits dans la trame.
* @return true si l'initialisation a réussi.
*/
bool begin(uint32_t sampleRate, Format format = Format::Philips);
/// Arrête et libère le canal I2S (obligatoire avant un nouveau begin()).
void end();
/// Choisit la moitié de trame conservée par read().
void useHalf(Half half);
/// Moitié de trame actuellement conservée.
Half half() const;
/// Fréquence d'échantillonnage réellement en service.
uint32_t sampleRate() const;
/// Cadrage actuellement en service.
Format format() const;
/**
* @brief Lit et convertit des échantillons audio.
* @param buffer Destination, au moins @p count échantillons.
* @param count Nombre d'échantillons 16 bits demandés.
* @return Nombre d'échantillons réellement écrits (0 en cas d'erreur).
*/
size_t read(int16_t* buffer, size_t count);
/**
* @brief Écoute pendant une durée donnée et analyse les deux moitiés.
*
* Les échantillons lus sont consommés : ne pas appeler pendant une session.
*
* @param durationMs Durée d'écoute en millisecondes.
* @return Statistiques séparées des deux moitiés de trame.
*/
FrameStats probe(uint32_t durationMs);
/**
* @brief Copie des mots I2S bruts, sans aucune conversion.
*
* Le premier mot copié est toujours une 1re moitié de trame.
*
* @param words Destination.
* @param count Nombre de mots souhaités.
* @return Nombre de mots réellement copiés.
*/
size_t readRaw(int32_t* words, size_t count);
/**
* @brief Teste si la ligne de données est réellement pilotée par le micro.
*
* On applique le pull-up puis le pull-down interne (~45 kOhms) et on
* échantillonne la broche : une sortie logique écrase ces résistances, une
* ligne en l'air les suit. Les horloges doivent être CORRECTES pendant le
* test, sinon le micro ne pilote rien et le verdict ne veut rien dire.
*/
DataLineReport probeDataLine();
/// Remet à zéro les statistiques accumulées par read().
void resetStats();
/// Statistiques de la moitié retenue, depuis le dernier resetStats().
RawStats stats() const;
/**
* @brief Vide les tampons DMA sans arrêter les horloges du micro.
*
* À appeler au début d'une session : sans cela, les premières millisecondes
* enregistrées seraient de l'audio ancien, capté avant l'appui sur le bouton.
* Amorce aussi le filtre de composante continue avec la moyenne mesurée.
*/
void flush();
/// Met à jour un relevé avec un mot brut (public pour les outils de diagnostic).
static void accumulate(RawStats& stats, int32_t word);
private:
/// Taille du tampon de travail 32 bits (1 Kio de RAM, alloué une seule fois).
static constexpr size_t SCRATCH_WORDS = 256;
int _pinSck;
int _pinWs;
int _pinSd;
i2s_chan_handle_t _rxHandle = nullptr;
uint32_t _sampleRate = 0; ///< fréquence retenue par begin()
Format _format = Format::Philips; ///< cadrage retenu par begin()
Half _half = Half::First; ///< moitié de trame conservée
uint8_t _phase = 0; ///< 0 si le prochain mot lu est une 1re moitié
int32_t _lowBand = 0; ///< basses fréquences suivies par le passe-haut, en LSB 24 bits
int32_t _scratch[SCRATCH_WORDS] = {}; ///< tampon de lecture brute
RawStats _stats; ///< statistiques, alimentées par read()
// Jette les échantillons pendant le transitoire de mise en route du micro
void settle(uint32_t durationMs);
};
+295
View File
@@ -0,0 +1,295 @@
#include "MicSelfTest.h"
#include <math.h>
#include "config.h"
namespace {
/// Pleine échelle d'un échantillon 24 bits.
constexpr float FULL_SCALE_24 = 8388608.0F;
/// Résolution de l'INMP441 : bien cadré, le mot porte exactement ce nombre de bits.
constexpr int MIC_DATA_BITS = 24;
/// Au repos, des variations au-dessus de ce seuil méritent une remarque.
constexpr int NOISY_ROOM_DBFS = -30;
} // namespace
MicSelfTest::MicSelfTest(MicCapture& mic) : _mic(mic) {}
bool MicSelfTest::run() {
Serial.println("# auto-test du micro : silence, ne parlez pas (2 s)...");
// Un seul réveil du micro, lu en cadrage MSB. begin() inclut la stabilisation.
_mic.end();
if (!_mic.begin(AUDIO_SAMPLE_RATE, MicCapture::Format::Msb)) {
Serial.println("# ERREUR : initialisation I2S impossible");
return false;
}
Serial.println("# cadrage moitie | continu | variations | bits | verdict");
MicCapture::FrameStats frames;
MicCapture::Half half = MicCapture::Half::First;
const MicCapture::RawStats* active = measure(frames, half);
if (active == nullptr) {
reportFailure("aucune moitie de trame ne porte de donnees.");
return false;
}
// 25 bits en lecture MSB : tout est décalé d'un rang vers le bas, le micro
// émet avec le retard d'un bit du format Philips. On bascule et on vérifie.
if (usedBits(*active) == MIC_DATA_BITS + 1) {
Serial.println("# 25 bits utiles en MSB : le micro suit le cadrage Philips, bascule.");
_mic.end();
if (!_mic.begin(AUDIO_SAMPLE_RATE, MicCapture::Format::Philips)) {
Serial.println("# ERREUR : initialisation I2S impossible");
return false;
}
active = measure(frames, half);
}
if (active == nullptr || usedBits(*active) != MIC_DATA_BITS) {
reportFailure("pas de moitie de trame a 24 bits utiles.");
return false;
}
_mic.useHalf(half);
Serial.printf("# configuration retenue : %lu Hz, cadrage %s, moitie %s\n",
static_cast<unsigned long>(_mic.sampleRate()), formatName(_mic.format()),
halfName(half));
const int variations = dbfs(acRmsLsb24(*active));
Serial.printf("# niveau au repos : continu %d dBFS, variations %d dBFS\n",
dbfs(fabsf(dcLsb24(*active))), variations);
if (variations > NOISY_ROOM_DBFS) {
Serial.println("# NB : bruit de fond eleve, ou micro pas encore stabilise.");
Serial.println("# Sans effet sur le cadrage ; a surveiller sur les enregistrements.");
}
if (MIC_SELFTEST_VERBOSE) {
dumpBits("configuration retenue");
reportDataLine();
}
return true;
}
const MicCapture::RawStats* MicSelfTest::measure(MicCapture::FrameStats& frames,
MicCapture::Half& activeHalf) {
frames = _mic.probe(MIC_PROBE_MS);
const MicCapture::RawStats* halves[] = {&frames.first, &frames.second};
const MicCapture::RawStats* active = nullptr;
int withData = 0;
for (int index = 0; index < 2; ++index) {
const MicCapture::Half half =
(index == 0) ? MicCapture::Half::First : MicCapture::Half::Second;
const MicCapture::RawStats& stats = *halves[index];
Serial.printf("# %-7s %s | %4d dBFS | %5d dBFS | %4d | %s\n",
formatName(_mic.format()), halfName(half), dbfs(fabsf(dcLsb24(stats))),
dbfs(acRmsLsb24(stats)), usedBits(stats), verdictFor(stats));
if (carriesData(stats)) {
++withData;
active = &stats;
activeHalf = half;
}
}
// Le micro n'émet que dans une moitié : deux moitiés actives signalent un
// L/R flottant ou un second micro sur la ligne.
if (withData != 1) {
if (withData == 2) {
Serial.println("# les DEUX moities portent des donnees : L/R du micro bien a GND ?");
}
return nullptr;
}
return active;
}
void MicSelfTest::reportFailure(const char* reason) {
Serial.printf("# ECHEC : %s\n", reason);
dumpBits("echec");
reportDataLine();
Serial.println("# Envoyez ce bloc complet : l'analyse bit a bit dit ou se trouvent");
Serial.println("# les bits du micro dans le mot recu.");
}
void MicSelfTest::reportSession(const MicCapture::RawStats& stats) const {
if (stats.wordCount == 0) {
Serial.println("# parole : aucun mot lu");
return;
}
// La crête retenue est celle qui sort du passe-haut : le grondement
// infrasonore est exclu, c'est bien la voix qu'on mesure.
const int32_t peak24 = stats.filteredPeak;
const int64_t peak16 = (static_cast<int64_t>(peak24) * MIC_GAIN) >> 8;
Serial.printf("# parole : crete %d dBFS apres passe-haut, %d bits utiles\n",
dbfs(static_cast<float>(peak24)), usedBits(stats));
Serial.printf("# apres gain x%d : crete a %ld %% de la pleine echelle\n", MIC_GAIN,
static_cast<long>((peak16 * 100) / 32768));
if (peak24 <= 0) {
return;
}
// Plus grand gain (puissance de 2) plaçant la crête sous 25 % de pleine
// échelle : peak24 x gain / 256 <= 8192, soit gain <= 2^21 / peak24.
const int32_t wanted = static_cast<int32_t>((1L << 21) / peak24);
int gain = 1;
while (gain * 2 <= wanted && gain < 64) {
gain *= 2;
}
if (gain != MIC_GAIN) {
Serial.printf("# -> reglez MIC_GAIN = %d dans firmware/include/config.h\n", gain);
} else {
Serial.println("# -> MIC_GAIN est bien regle.");
}
}
void MicSelfTest::dumpBits(const char* label) {
constexpr size_t WORDS = 2048; // 1024 trames, 64 ms à 16 kHz
static int32_t words[WORDS]; // statique : 8 Kio hors de la pile
_mic.flush();
const size_t got = _mic.readRaw(words, WORDS); // words[0] est une 1re moitié
// Les trames du milieu du relevé sont représentatives du régime établi.
const size_t firstShown = (got / 4) & ~static_cast<size_t>(1);
Serial.printf("# --- analyse bit a bit, %s (%s) ---\n", label, formatName(_mic.format()));
Serial.println("# mots bruts, regime etabli, deux mots par trame :");
for (size_t frame = 0; frame < 8 && firstShown + (frame * 2) + 1 < got; ++frame) {
const size_t index = firstShown + (frame * 2);
Serial.printf("# %08lX %08lX\n", static_cast<unsigned long>(words[index]),
static_cast<unsigned long>(words[index + 1]));
}
// Proportion de 1 à chaque position de bit, du bit 31 (gauche) au bit 0.
for (size_t column = 0; column < 2; ++column) {
uint32_t ones[32] = {};
size_t count = 0;
for (size_t i = column; i < got; i += 2) {
const uint32_t bits = static_cast<uint32_t>(words[i]);
for (int bit = 0; bit < 32; ++bit) {
ones[bit] += (bits >> bit) & 1U;
}
++count;
}
char pattern[33];
for (int bit = 31; bit >= 0; --bit) {
const uint32_t tenth = (count == 0) ? 0 : (ones[bit] * 10U) / (count + 1);
pattern[31 - bit] = static_cast<char>('0' + tenth);
}
pattern[32] = '\0';
Serial.printf("# colonne %u, taux de 1 par bit (31..0) : %s\n",
static_cast<unsigned>(column + 1), pattern);
}
Serial.println("# lecture : 0 = toujours 0, 9 = toujours 1.");
Serial.println("# 24 bits utiles bien cadres = exactement 8 zeros a droite.");
}
void MicSelfTest::reportDataLine() {
const MicCapture::DataLineReport report = _mic.probeDataLine();
if (report.samples == 0) {
return;
}
const uint32_t up = (report.onesWithPullUp * 100) / report.samples;
const uint32_t down = (report.onesWithPullDown * 100) / report.samples;
Serial.printf("# ligne SD : pull-up %lu %% de 1, pull-down %lu %% de 1",
static_cast<unsigned long>(up), static_cast<unsigned long>(down));
if (up >= 98 && down <= 2) {
Serial.println(" -> FLOTTE : rien ne la pilote");
} else if (up <= 2 && down <= 2) {
Serial.println(" -> tenue a 0");
} else if (up >= 98 && down >= 98) {
Serial.println(" -> tenue a 1");
} else {
Serial.println(" -> PILOTEE : le micro emet");
}
}
const char* MicSelfTest::verdictFor(const MicCapture::RawStats& stats) {
if (stats.wordCount == 0) {
return "aucun mot lu";
}
if (stats.zeroCount == stats.wordCount) {
return "silence numerique";
}
if (stats.minWord == stats.maxWord) {
return "valeur CONSTANTE";
}
const int bits = usedBits(stats);
if (bits == MIC_DATA_BITS) {
return "CADRE : 24 bits";
}
if (bits == MIC_DATA_BITS + 1) {
return "un bit de retard (Philips)";
}
if (bits < MIC_DATA_BITS) {
return "DECALE : bit de signe perdu";
}
return "queue du mot non nulle";
}
bool MicSelfTest::carriesData(const MicCapture::RawStats& stats) {
return stats.wordCount > 0 && stats.zeroCount < stats.wordCount &&
stats.minWord != stats.maxWord;
}
float MicSelfTest::dcLsb24(const MicCapture::RawStats& stats) {
if (stats.wordCount == 0) {
return 0.0F;
}
// sum porte sur (mot >> 12) ; en LSB 24 bits (mot >> 8), c'est x16.
return static_cast<float>(static_cast<double>(stats.sum) /
static_cast<double>(stats.wordCount) * 16.0);
}
float MicSelfTest::acRmsLsb24(const MicCapture::RawStats& stats) {
if (stats.wordCount == 0) {
return 0.0F;
}
// Variance = moyenne des carrés - carré de la moyenne.
const double count = static_cast<double>(stats.wordCount);
const double mean = static_cast<double>(stats.sum) / count;
const double variance = static_cast<double>(stats.sumSquares) / count - mean * mean;
if (variance <= 0.0) {
return 0.0F;
}
return static_cast<float>(sqrt(variance) * 16.0);
}
int MicSelfTest::dbfs(float lsb24) {
if (lsb24 <= 0.0F) {
return -999;
}
return static_cast<int>(20.0F * log10f(lsb24 / FULL_SCALE_24));
}
int MicSelfTest::usedBits(const MicCapture::RawStats& stats) {
if (stats.orBits == 0) {
return 0;
}
return 32 - __builtin_ctz(stats.orBits); // ctz : nombre de zéros à droite
}
const char* MicSelfTest::formatName(MicCapture::Format format) {
return (format == MicCapture::Format::Philips) ? "Philips" : "MSB";
}
const char* MicSelfTest::halfName(MicCapture::Half half) {
return (half == MicCapture::Half::First) ? "1re" : "2e ";
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <Arduino.h>
#include "MicCapture.h"
/**
* @brief Auto-test du micro au démarrage : détermine le cadrage et la moitié de trame.
*
* Le micro n'est réveillé qu'UNE fois : l'INMP441 s'endort dès que ses horloges
* s'arrêtent, et chaque réveil produit une dérive lente de sa composante
* continue. Multiplier les essais, c'était mesurer des réveils, pas la pièce.
*
* Tout se décide sur une capture lue en cadrage MSB, par la **structure** des
* mots reçus, jamais par leur niveau :
*
* - la moitié de trame utile est celle qui n'est ni nulle ni constante ;
* - 24 bits utiles : le micro est cadré MSB, c'est gagné ;
* - 25 bits utiles : le micro suit le cadrage Philips (un bit de retard),
* on bascule et l'on vérifie ;
* - toute autre structure est un défaut, et l'analyse bit à bit s'affiche.
*
* Le niveau au repos est affiché pour information : il dépend du bruit de la
* pièce et de la stabilisation du micro, pas de la justesse du réglage.
*/
class MicSelfTest {
public:
explicit MicSelfTest(MicCapture& mic);
/**
* @brief Détermine le cadrage et la moitié de trame, puis configure le micro.
* @return true si le micro est lu correctement ; il reste alors prêt à capturer.
*/
bool run();
/**
* @brief Imprime le niveau d'une session de parole et le gain conseillé.
* @param stats Statistiques de la moitié retenue pendant la session.
*/
void reportSession(const MicCapture::RawStats& stats) const;
private:
MicCapture& _mic;
// Capture, affiche le tableau et renvoie la moitié qui porte le micro (ou nullptr)
const MicCapture::RawStats* measure(MicCapture::FrameStats& frames,
MicCapture::Half& activeHalf);
// Diagnostic complet en cas d'échec
void reportFailure(const char* reason);
// Analyse bit à bit et vidage hexadécimal de la configuration courante
void dumpBits(const char* label);
// Test électrique de la ligne SD, horloges correctes
void reportDataLine();
// Verdict structurel pour une moitié de trame
static const char* verdictFor(const MicCapture::RawStats& stats);
// true si la moitié porte des données (ni nulle, ni constante)
static bool carriesData(const MicCapture::RawStats& stats);
// Composante continue (moyenne) en LSB 24 bits
static float dcLsb24(const MicCapture::RawStats& stats);
// Valeur efficace des variations, composante continue retirée, en LSB 24 bits
static float acRmsLsb24(const MicCapture::RawStats& stats);
// Conversion en dBFS (pleine échelle 24 bits)
static int dbfs(float lsb24);
// Nombre de bits utiles : du bit 31 au plus bas bit jamais mis à 1
static int usedBits(const MicCapture::RawStats& stats);
static const char* formatName(MicCapture::Format format);
static const char* halfName(MicCapture::Half half);
};
+153
View File
@@ -0,0 +1,153 @@
#include "MqttLink.h"
#include "config.h"
namespace {
/// Message publié par le broker à notre place si l'on disparaît sans prévenir.
const char* const WILL_PAYLOAD = "{\"status\":\"offline\"}";
} // namespace
MqttLink::MqttLink(Client& network, const char* host, uint16_t port, const char* deviceId,
const char* user, const char* password)
: _client(network),
_host(host),
_port(port),
_deviceId(deviceId),
_user((user != nullptr && user[0] != '\0') ? user : deviceId),
_password(password) {}
bool MqttLink::begin(MessageHandler onMessage, ConnectHandler onConnect) {
_onMessage = onMessage;
_onConnect = onConnect;
_client.setServer(_host, _port);
_client.setKeepAlive(MQTT_KEEPALIVE_S);
_client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_S);
_client.setCallback([this](char* topic, uint8_t* payload, unsigned int length) {
const char* suffix = suffixOf(topic);
if (_onMessage) {
_onMessage(suffix != nullptr ? suffix : topic, payload, length);
}
});
buildTopic(_willTopic, sizeof(_willTopic), "state");
if (strcmp(_user, _deviceId) != 0) {
// Les droits du broker (pattern voice/%u/...) portent sur le nom d'utilisateur :
// un autre nom que DEVICE_ID, et la carte n'a le droit de rien publier.
Serial.printf("# mqtt : ATTENTION, utilisateur \"%s\" different de DEVICE_ID \"%s\" : "
"le broker refusera ses messages (mosquitto/acl)\n", _user, _deviceId);
}
// Le tampon par défaut (256 octets) ne contiendrait pas un chunk audio : un
// message trop gros est rejeté sans aucun message d'erreur. Alloué une fois ici.
return _client.setBufferSize(MQTT_BUFFER_BYTES);
}
void MqttLink::update(bool networkUp) {
if (_client.connected()) {
_client.loop(); // traite les messages reçus et entretient la connexion
return;
}
if (!networkUp) {
return;
}
// Tentatives espacées : chaque essai peut bloquer jusqu'à MQTT_SOCKET_TIMEOUT_S.
const uint32_t now = millis();
if (_attempted && (now - _lastAttemptMs) < MQTT_RETRY_MS) {
return;
}
_attempted = true;
_lastAttemptMs = now;
connect();
}
bool MqttLink::connected() {
return _client.connected();
}
bool MqttLink::publish(const char* suffix, const char* payload, bool retained) {
if (!_client.connected()) {
return false;
}
return _client.publish(buildTopic(_topic, sizeof(_topic), suffix), payload, retained);
}
bool MqttLink::publishBinary(const char* suffix, const uint8_t* data, size_t length) {
if (!_client.connected()) {
return false;
}
return _client.publish(buildTopic(_topic, sizeof(_topic), suffix), data,
static_cast<unsigned int>(length), false);
}
bool MqttLink::subscribe(const char* suffix) {
if (!_client.connected()) {
return false;
}
return _client.subscribe(buildTopic(_topic, sizeof(_topic), suffix), 1);
}
bool MqttLink::subscribeTopic(const char* topic) {
if (!_client.connected()) {
return false;
}
return _client.subscribe(topic, 1);
}
uint32_t MqttLink::connections() const {
return _connections;
}
void MqttLink::connect() {
Serial.printf("# mqtt : connexion a %s:%u...\n", _host, _port);
// Mot de passe vide = connexion anonyme, refusée par un broker sécurisé (étape 15).
const bool anonymous = (_password == nullptr || _password[0] == '\0');
const char* user = anonymous ? nullptr : _user;
const char* password = anonymous ? nullptr : _password;
// Testament : QoS 1, retenu. Session propre : on ne veut pas recevoir, au
// retour, des commandes périmées accumulées pendant l'absence.
const bool ok = _client.connect(_deviceId, user, password, _willTopic, 1, true, WILL_PAYLOAD,
true);
if (!ok) {
// Codes de PubSubClient : -4 délai dépassé, -2 connexion TCP refusée
// (broker arrêté, mauvais port, pare-feu), 5 non autorisé...
const int code = _client.state();
Serial.printf("# mqtt : echec (code %d), nouvel essai dans %lu s\n", code,
static_cast<unsigned long>(MQTT_RETRY_MS / 1000));
if (code == MQTT_CONNECT_BAD_CREDENTIALS || code == MQTT_CONNECT_UNAUTHORIZED) {
Serial.printf("# mqtt : compte refuse. secrets.h : MQTT_PASSWORD %s ; le compte \"%s\" "
"existe-t-il (make mqtt-user NAME=%s) ?\n",
anonymous ? "VIDE" : "renseigne", _user, _deviceId);
}
return;
}
++_connections;
Serial.printf("# mqtt : connecte (connexion n°%lu)\n", static_cast<unsigned long>(_connections));
if (_onConnect) {
_onConnect(_connections == 1);
}
}
const char* MqttLink::buildTopic(char* buffer, size_t size, const char* suffix) const {
snprintf(buffer, size, "%s/%s/%s", MQTT_TOPIC_PREFIX, _deviceId, suffix);
return buffer;
}
const char* MqttLink::suffixOf(const char* topic) const {
// Attendu : <préfixe>/<id>/<suffixe>. On vérifie les deux premiers segments.
const size_t prefixLength = strlen(MQTT_TOPIC_PREFIX);
const size_t idLength = strlen(_deviceId);
if (strncmp(topic, MQTT_TOPIC_PREFIX, prefixLength) != 0 || topic[prefixLength] != '/') {
return nullptr;
}
const char* rest = topic + prefixLength + 1;
if (strncmp(rest, _deviceId, idLength) != 0 || rest[idLength] != '/') {
return nullptr;
}
return rest + idLength + 1;
}
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <Arduino.h>
#include <PubSubClient.h>
#include <functional>
/**
* @brief Connexion au broker MQTT, avec testament et reconnexion espacée.
*
* Tous les topics ont la forme <préfixe>/<identifiant>/<suffixe>, par exemple
* voice/esp32-01/state. On ne manipule ici que le suffixe.
*
* Le **testament** (Last Will) est confié au broker à la connexion : si l'ESP32
* disparaît sans prévenir (coupure de courant, plantage), c'est le broker qui
* publie « offline » à sa place, en message retenu.
*
* Le rappel des messages reçus doit rester court : il sera appelé depuis
* PubSubClient, dans loop(). À l'étape 5, il ne fera que remplir la FIFO audio.
*
* Étape 15 : le broker exige un compte. Le nom d'utilisateur de la carte est son
* identifiant (DEVICE_ID) : les droits du broker (mosquitto/acl) en dépendent.
*/
class MqttLink {
public:
/// Rappel des messages reçus : suffixe pour nos topics (« control »), topic complet
/// pour ceux de subscribeTopic() (« voice/server/status »), payload, longueur.
using MessageHandler = std::function<void(const char*, const uint8_t*, size_t)>;
/// Rappel de connexion : appelé à chaque (re)connexion au broker.
using ConnectHandler = std::function<void(bool firstTime)>;
/**
* @param network Transport TCP (un WiFiClient).
* @param host Adresse IP du broker.
* @param port Port du broker.
* @param deviceId Identifiant de la carte : client MQTT et segment des topics.
* @param user Utilisateur MQTT ; vide : deviceId (les droits du broker l'exigent).
* @param password Mot de passe MQTT ; vide : connexion anonyme (broker non sécurisé).
*/
MqttLink(Client& network, const char* host, uint16_t port, const char* deviceId,
const char* user, const char* password);
/**
* @brief Prépare le client ; aucune connexion n'est tentée ici.
* @param onMessage Appelé pour chaque message reçu sur un topic abonné.
* @param onConnect Appelé après chaque connexion réussie (abonnements, état).
* @return true si le tampon de @ref MQTT_BUFFER_BYTES a pu être alloué.
*/
bool begin(MessageHandler onMessage, ConnectHandler onConnect);
/**
* @brief À appeler à chaque tour de boucle.
* @param networkUp true si le Wi-Fi est connecté : sinon, inutile d'essayer.
*/
void update(bool networkUp);
/// true si la session MQTT est ouverte.
bool connected();
/**
* @brief Publie un texte (JSON) sur <préfixe>/<id>/<suffixe>.
* @return true si le message a été confié à la pile TCP.
*/
bool publish(const char* suffix, const char* payload, bool retained);
/**
* @brief Publie des octets bruts (PCM) sur <préfixe>/<id>/<suffixe>, non retenu.
*
* Peut bloquer tant que la pile TCP n'a pas de place : ne jamais l'appeler
* depuis une tâche audio, seulement depuis loop().
*
* @return true si le message a été confié à la pile TCP.
*/
bool publishBinary(const char* suffix, const uint8_t* data, size_t length);
/// S'abonne à <préfixe>/<id>/<suffixe>, en QoS 1.
bool subscribe(const char* suffix);
/// S'abonne à un topic complet, hors de nos topics (présence du serveur), en QoS 1.
bool subscribeTopic(const char* topic);
/// Nombre de connexions réussies depuis le démarrage.
uint32_t connections() const;
private:
PubSubClient _client;
const char* _host;
uint16_t _port;
const char* _deviceId;
const char* _user;
const char* _password;
MessageHandler _onMessage;
ConnectHandler _onConnect;
uint32_t _lastAttemptMs = 0;
bool _attempted = false;
uint32_t _connections = 0;
char _topic[96] = {}; ///< topic en cours de construction
char _willTopic[96] = {}; ///< topic du testament, conservé pour chaque connexion
// Tente une connexion, testament compris
void connect();
// Construit <préfixe>/<id>/<suffixe> dans le tampon donné
const char* buildTopic(char* buffer, size_t size, const char* suffix) const;
// Rend le suffixe d'un topic reçu, ou nullptr s'il n'est pas de la forme attendue
const char* suffixOf(const char* topic) const;
};
+185
View File
@@ -0,0 +1,185 @@
#include "PlaybackStream.h"
#include <esp_task_wdt.h>
#include "config.h"
PlaybackStream::PlaybackStream(AudioOutput& speaker, AudioFifo& fifo)
: _speaker(speaker), _fifo(fifo) {}
bool PlaybackStream::begin() {
return xTaskCreateStaticPinnedToCore(taskEntry, "audio", sizeof(_taskStack), this,
AUDIO_TASK_PRIORITY, _taskStack, &_taskControl,
AUDIO_TASK_CORE) != nullptr;
}
bool PlaybackStream::open(const char* session, uint32_t rate, uint32_t bits, uint32_t channels,
bool fromMqtt) {
if (_open) {
// Le flux précédent n'est pas fini : le nouveau prend la place.
Serial.printf("# lecture %s : interrompue par un nouveau flux\n", _report.session);
finish(_closed);
}
if (rate != AUDIO_SAMPLE_RATE || bits != 16 || channels != 1) {
Serial.printf("# lecture refusee : %lu Hz, %lu bits, %lu canaux ; attendu %lu Hz, 16 bits, mono\n",
static_cast<unsigned long>(rate), static_cast<unsigned long>(bits),
static_cast<unsigned long>(channels),
static_cast<unsigned long>(AUDIO_SAMPLE_RATE));
return false;
}
// L'état d'abord : dès cet instant, la tâche audio ne peut plus compter de famine
// pour ce flux (elle ne compte qu'en Playing), quoi qu'elle voie ensuite.
_state.store(State::Priming); // rien ne sera joué avant d'avoir la réserve
_report = Report();
strncpy(_report.session, session, sizeof(_report.session) - 1);
_report.fromMqtt = fromMqtt;
_open = true;
_closed = false;
_startMs = millis();
_lastPushMs = _startMs;
_overrunsAtStart = _fifo.overruns();
_underruns.store(0);
_firstSoundMs.store(0);
_minMarginBytes.store(UINT32_MAX);
for (auto& gap : _gapAtMs) {
gap.store(0);
}
_endOfStream.store(false);
Serial.printf("# lecture %s : debut du flux (%s)\n", _report.session, fromMqtt ? "MQTT" : "serie");
return true;
}
void PlaybackStream::push(const uint8_t* data, size_t length) {
// Longueur impaire : échantillon coupé en deux, jamais accepté.
if (!_open || _closed || (length % 2) != 0) {
return;
}
_fifo.write(data, length);
++_report.chunks;
_lastPushMs = millis();
}
void PlaybackStream::close(int32_t announcedChunks) {
if (!_open || _closed) {
return;
}
_closed = true;
_report.announced = announcedChunks;
_report.endReceived = true;
// Plus rien ne doit arriver : une FIFO qui se vide est désormais normale, et
// un son plus court que la réserve doit être joué sans attendre davantage.
_endOfStream.store(true);
}
bool PlaybackStream::takeReport(Report& report) {
if (!_open) {
return false;
}
const uint32_t silence = millis() - _lastPushMs;
if (_closed) {
// Le bilan attend que la FIFO soit vide : la lecture est alors terminée.
if (_fifo.available() > 0 || silence < DRAIN_SILENCE_MS) {
return false;
}
finish(true);
} else if (silence >= ABANDON_MS) {
_endOfStream.store(true); // la source s'est tue : on joue ce qui reste
finish(false);
} else {
return false;
}
report = _report;
return true;
}
bool PlaybackStream::active() const {
return _open;
}
void PlaybackStream::finish(bool endReceived) {
_open = false;
_report.endReceived = endReceived;
_report.overruns = _fifo.overruns() - _overrunsAtStart;
_report.underruns = _underruns.load();
_report.durationMs = millis() - _startMs;
const uint32_t firstSound = _firstSoundMs.load();
_report.startMs = firstSound != 0 ? firstSound - _startMs : 0;
const uint32_t margin = _minMarginBytes.load();
_report.minMarginMs = margin == UINT32_MAX
? -1 : static_cast<int32_t>(margin / (AUDIO_SAMPLE_RATE / 1000 * sizeof(int16_t)));
for (size_t i = 0; i < MAX_GAPS; ++i) {
_report.gapAtMs[i] = _gapAtMs[i].load();
}
}
void PlaybackStream::taskEntry(void* self) {
static_cast<PlaybackStream*>(self)->taskLoop();
}
void PlaybackStream::taskLoop() {
static int16_t block[PLAYBACK_BLOCK_SAMPLES];
const size_t startBytes = PLAYBACK_START_MS * (AUDIO_SAMPLE_RATE / 1000) * sizeof(int16_t);
esp_task_wdt_add(nullptr); // surveillée par le chien de garde (étape 15)
for (;;) {
esp_task_wdt_reset(); // un tour dure au plus ~20 ms : attente FIFO ou bloc I2S
if (_state.load() == State::Priming) {
// Amorçage : rien n'est joué tant que la réserve n'est pas constituée,
// sauf si la fin est annoncée (son plus court que la réserve).
const size_t waiting = _fifo.available();
if (waiting < startBytes && !(_endOfStream.load() && waiting > 0)) {
vTaskDelay(pdMS_TO_TICKS(PRIME_POLL_MS));
continue;
}
uint32_t notYet = 0;
_firstSoundMs.compare_exchange_strong(notYet, millis()); // premier démarrage seulement
_state.store(State::Playing);
}
if (_state.load() == State::Idle) {
vTaskDelay(pdMS_TO_TICKS(PRIME_POLL_MS));
continue;
}
const size_t bytes = _fifo.read(reinterpret_cast<uint8_t*>(block), sizeof(block), 20);
const size_t samples = bytes / sizeof(int16_t);
if (samples > 0) {
_speaker.write(block, samples); // bloque : c'est ce qui donne la cadence
}
if (_endOfStream.load()) {
// Fin annoncée : la FIFO se vide normalement jusqu'au dernier échantillon,
// puis la tâche se met au repos. Si open() a déjà lancé un nouveau flux,
// l'état n'est plus Playing et l'échange échoue : on ne touche à rien.
if (samples == 0 && _fifo.available() == 0) {
State playing = State::Playing;
_state.compare_exchange_strong(playing, State::Idle);
}
continue;
}
if (samples < PLAYBACK_BLOCK_SAMPLES) {
// Réserve épuisée en plein flux : on s'arrête proprement et on réamorce,
// plutôt que de jouer des bribes hachées au gré des arrivées.
// Échange atomique : on ne compte que si l'on jouait encore CE flux. Un
// START arrivé pendant le read() ci-dessus a déjà remis l'état à Priming,
// et ce n'est alors pas une famine (faux compte trouvé à l'étape 12).
State playing = State::Playing;
if (_state.compare_exchange_strong(playing, State::Priming)) {
const uint32_t index = _underruns.fetch_add(1);
if (index < MAX_GAPS) {
_gapAtMs[index].store(millis() - _firstSoundMs.load());
}
}
continue;
}
// Marge restante : c'est le retard réseau qu'on pourrait encore absorber.
const uint32_t left = static_cast<uint32_t>(_fifo.available());
if (left < _minMarginBytes.load()) {
_minMarginBytes.store(left);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
#pragma once
#include <Arduino.h>
#include <atomic>
#include "AudioFifo.h"
#include "AudioOutput.h"
#include "config.h"
/**
* @brief Un flux de lecture vers le haut-parleur, quel que soit son transport.
*
* Même protocole pour la liaison série (étape 2) et pour MQTT (étape 5) :
* open() à la trame START, push() pour chaque chunk, close() à la trame END.
* push() est court et n'attend jamais : il peut être appelé depuis le rappel
* MQTT. La tâche audio, créée par begin(), vide la FIFO au rythme du DAC.
*
* Tampon anti-gigue (étape 12) : rien n'est joué tant que la FIFO ne contient
* pas PLAYBACK_START_MS de son (amorçage). Le Wi-Fi livre les chunks de façon
* irrégulière ; la réserve absorbe ces écarts. Si elle s'épuise quand même, la
* lecture s'arrête proprement et attend de l'avoir reconstituée (réamorçage),
* au lieu de jouer des bribes hachées. Un son plus court que la réserve est joué
* dès que la fin est annoncée.
*
* Un bilan est produit quand le dernier échantillon a été joué, ou quand la
* source s'est tue sans envoyer de END.
*/
class PlaybackStream {
public:
/// Bilan d'un flux, disponible via takeReport().
struct Report {
char session[16] = {};
bool fromMqtt = false; ///< transport du flux (pour savoir où publier le bilan)
bool endReceived = false; ///< false : la source s'est tue sans END
uint32_t chunks = 0; ///< chunks reçus
int32_t announced = -1; ///< chunks annoncés par le END, -1 si inconnu
uint32_t overruns = 0; ///< chunks refusés, FIFO pleine
uint32_t underruns = 0; ///< réserve épuisée en plein flux : une pause, puis réamorçage
uint32_t durationMs = 0; ///< du START au dernier échantillon joué
uint32_t startMs = 0; ///< du START au premier échantillon joué (amorçage)
int32_t minMarginMs = -1; ///< plus petite réserve pendant la lecture, -1 si non mesurée
uint32_t gapAtMs[4] = {}; ///< instant des premiers réamorçages, depuis le premier son (diagnostic)
};
/// Nombre de réamorçages dont l'instant est gardé.
static constexpr size_t MAX_GAPS = 4;
PlaybackStream(AudioOutput& speaker, AudioFifo& fifo);
/// Crée la tâche audio (la FIFO doit être prête).
bool begin();
/**
* @brief Ouvre un flux. Un flux encore ouvert est clos d'office.
* @return false si le format n'est pas celui du système : le flux est refusé.
*/
bool open(const char* session, uint32_t rate, uint32_t bits, uint32_t channels,
bool fromMqtt);
/// Ajoute un chunk ; ignoré si aucun flux n'est ouvert ou si sa longueur est impaire.
void push(const uint8_t* data, size_t length);
/**
* @brief Ferme le flux ; le bilan viendra quand tout aura été joué.
* @param announcedChunks Chunks annoncés par la source, -1 si inconnu.
*/
void close(int32_t announcedChunks);
/**
* @brief À appeler depuis loop() : true une seule fois, quand un bilan est prêt.
* @param report Reçoit le bilan.
*/
bool takeReport(Report& report);
/// true entre open() et le bilan.
bool active() const;
private:
/// Silence après lequel on considère le flux fini (ms).
static constexpr uint32_t DRAIN_SILENCE_MS = 300;
/// Silence après lequel une source muette est abandonnée, sans END (ms).
static constexpr uint32_t ABANDON_MS = 2000;
/// Période de surveillance de la FIFO pendant l'amorçage (ms).
static constexpr uint32_t PRIME_POLL_MS = 5;
/// Ce que fait la tâche audio.
enum class State : uint8_t {
Idle, ///< aucun flux
Priming, ///< réserve en cours de constitution : on ne joue rien
Playing, ///< lecture au rythme du DAC
};
AudioOutput& _speaker;
AudioFifo& _fifo;
bool _open = false; ///< loop() seulement
bool _closed = false; ///< END reçu, on attend la fin de la lecture
uint32_t _startMs = 0;
uint32_t _lastPushMs = 0;
uint32_t _overrunsAtStart = 0;
Report _report;
// Partagés avec la tâche audio.
std::atomic<State> _state{State::Idle};
std::atomic<bool> _endOfStream{false}; ///< END reçu (ou source abandonnée) : la FIFO peut se vider
std::atomic<uint32_t> _underruns{0};
std::atomic<uint32_t> _firstSoundMs{0}; ///< date du premier échantillon joué, 0 : pas encore
std::atomic<uint32_t> _minMarginBytes{UINT32_MAX};
std::atomic<uint32_t> _gapAtMs[MAX_GAPS] = {};
StaticTask_t _taskControl;
StackType_t _taskStack[AUDIO_TASK_STACK_BYTES]; // ESP-IDF : StackType_t = octet
static void taskEntry(void* self);
void taskLoop();
void finish(bool endReceived);
};
+54
View File
@@ -0,0 +1,54 @@
#include "PushButton.h"
PushButton::PushButton(int pin, uint32_t debounceMs)
: _pin(pin), _debounceMs(debounceMs) {}
void PushButton::begin() {
pinMode(_pin, INPUT_PULLUP);
// Niveau bas = appuyé, car l'autre borne du bouton est reliée à GND.
_lastRawState = (digitalRead(_pin) == LOW);
_stableState = _lastRawState;
_lastChangeMs = millis();
}
void PushButton::update() {
const bool raw = (digitalRead(_pin) == LOW);
const uint32_t now = millis();
if (raw != _lastRawState) {
_lastRawState = raw;
_lastChangeMs = now;
return;
}
// L'état brut est resté identique assez longtemps : on le valide.
if (raw != _stableState && (now - _lastChangeMs) >= _debounceMs) {
_stableState = raw;
if (raw) {
_pressedEvent = true;
} else {
_releasedEvent = true;
}
}
}
bool PushButton::isDown() const {
return _stableState;
}
bool PushButton::wasPressed() {
const bool event = _pressedEvent;
_pressedEvent = false;
return event;
}
bool PushButton::wasReleased() {
const bool event = _releasedEvent;
_releasedEvent = false;
return event;
}
void PushButton::clearEvents() {
_pressedEvent = false;
_releasedEvent = false;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <Arduino.h>
/**
* @brief Bouton poussoir anti-rebond, câblé entre la broche et GND.
*
* Le rebond (« bounce ») est l'oscillation mécanique du contact pendant quelques
* millisecondes : sans filtrage, un seul appui produirait plusieurs fronts.
* La broche utilise le pull-up interne : niveau haut au repos, bas quand on appuie.
*/
class PushButton {
public:
/**
* @param pin Broche du bouton.
* @param debounceMs Durée de stabilité exigée avant de valider un changement.
*/
PushButton(int pin, uint32_t debounceMs);
/// Configure la broche en entrée avec pull-up interne.
void begin();
/// À appeler à chaque tour de boucle : met à jour l'état filtré.
void update();
/// true tant que le bouton est maintenu enfoncé.
bool isDown() const;
/// true une seule fois, au front d'appui (consomme l'événement).
bool wasPressed();
/// true une seule fois, au front de relâchement (consomme l'événement).
bool wasReleased();
/// Oublie les événements en attente (un appui mémorisé pendant une session, par exemple).
void clearEvents();
private:
int _pin;
uint32_t _debounceMs;
bool _stableState = false; ///< état filtré : true = enfoncé
bool _lastRawState = false; ///< dernière lecture brute
uint32_t _lastChangeMs = 0; ///< date du dernier changement brut
bool _pressedEvent = false;
bool _releasedEvent = false;
};
+85
View File
@@ -0,0 +1,85 @@
#include "SerialFrameReader.h"
using SerialProtocol::FrameType;
SerialFrameReader::SerialFrameReader(Stream& stream) : _stream(stream) {}
bool SerialFrameReader::poll(Frame& frame) {
while (_stream.available() > 0) {
const int value = _stream.read();
if (value < 0) {
break;
}
const uint8_t byte = static_cast<uint8_t>(value);
switch (_state) {
case State::Magic:
if (byte == SerialProtocol::MAGIC[_magicMatched]) {
++_magicMatched;
if (_magicMatched == sizeof(SerialProtocol::MAGIC)) {
_state = State::Header;
_headerRead = 0;
}
} else {
// Aucun caractère ne se répète dans le mot magique : il suffit de
// regarder si l'octet fautif peut commencer un nouveau mot magique.
_magicMatched = (byte == SerialProtocol::MAGIC[0]) ? 1 : 0;
}
break;
case State::Header: {
_header[_headerRead++] = byte;
if (_headerRead < sizeof(_header)) {
break;
}
const uint8_t type = _header[0];
_length = static_cast<size_t>(_header[1]) | (static_cast<size_t>(_header[2]) << 8);
const bool knownType = type >= static_cast<uint8_t>(FrameType::Start) &&
type <= static_cast<uint8_t>(FrameType::End);
if (!knownType || _length > SerialProtocol::MAX_PAYLOAD) {
++_rejected;
restart();
break;
}
_payloadRead = 0;
_checksum = 0;
_state = (_length == 0) ? State::Checksum : State::Payload;
break;
}
case State::Payload:
_payload[_payloadRead++] = byte;
_checksum ^= byte;
if (_payloadRead == _length) {
_state = State::Checksum;
}
break;
case State::Checksum: {
const bool valid = (byte == _checksum);
const FrameType type = static_cast<FrameType>(_header[0]);
restart();
if (!valid) {
++_rejected;
break;
}
frame.type = type;
frame.payload = _payload;
frame.length = _length;
return true; // on rend la main : le payload doit être consommé avant la suite
}
}
}
return false;
}
uint32_t SerialFrameReader::rejectedFrames() const {
return _rejected;
}
void SerialFrameReader::restart() {
_state = State::Magic;
_magicMatched = 0;
_headerRead = 0;
// _payload et _length sont conservés : la trame qui vient d'être rendue les lit encore.
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <Arduino.h>
#include "SerialProtocol.h"
/**
* @brief Décode les trames reçues du PC sur l'UART, octet par octet, sans bloquer.
*
* Machine à états : on cherche le mot magique, puis on lit l'en-tête, le
* payload et la somme de contrôle. Toute incohérence (type inconnu, longueur
* excessive, somme fausse) fait repartir à la recherche du mot magique.
*
* Aucune allocation : le payload est copié dans un tampon interne de taille fixe.
*/
class SerialFrameReader {
public:
/// Trame décodée. Le payload reste valide jusqu'au prochain appel de poll().
struct Frame {
SerialProtocol::FrameType type = SerialProtocol::FrameType::End;
const uint8_t* payload = nullptr;
size_t length = 0;
};
explicit SerialFrameReader(Stream& stream);
/**
* @brief Consomme les octets disponibles, sans jamais attendre.
* @param frame Reçoit la trame dès qu'une trame complète et valide est lue.
* @return true si @p frame vient d'être rempli ; rappeler poll() pour la suite.
*/
bool poll(Frame& frame);
/// Nombre de trames rejetées depuis le démarrage (somme ou en-tête incohérents).
uint32_t rejectedFrames() const;
private:
enum class State {
Magic, ///< recherche du mot magique
Header, ///< type + longueur
Payload, ///< données
Checksum, ///< somme de contrôle
};
Stream& _stream;
State _state = State::Magic;
size_t _magicMatched = 0;
uint8_t _header[3] = {}; ///< type, longueur (poids faible), longueur (poids fort)
size_t _headerRead = 0;
uint8_t _payload[SerialProtocol::MAX_PAYLOAD] = {};
size_t _length = 0;
size_t _payloadRead = 0;
uint8_t _checksum = 0;
uint32_t _rejected = 0;
// Repart à la recherche du mot magique
void restart();
};
+46
View File
@@ -0,0 +1,46 @@
#include "SerialFramer.h"
SerialFramer::SerialFramer(Stream& stream) : _stream(stream) {}
void SerialFramer::sendStart(uint32_t sampleRate, uint16_t bits, uint16_t channels) {
// Descripteur de 8 octets : fréquence (4), bits (2), canaux (2), en little-endian.
uint8_t payload[8];
payload[0] = static_cast<uint8_t>(sampleRate & 0xFF);
payload[1] = static_cast<uint8_t>((sampleRate >> 8) & 0xFF);
payload[2] = static_cast<uint8_t>((sampleRate >> 16) & 0xFF);
payload[3] = static_cast<uint8_t>((sampleRate >> 24) & 0xFF);
payload[4] = static_cast<uint8_t>(bits & 0xFF);
payload[5] = static_cast<uint8_t>((bits >> 8) & 0xFF);
payload[6] = static_cast<uint8_t>(channels & 0xFF);
payload[7] = static_cast<uint8_t>((channels >> 8) & 0xFF);
sendFrame(FrameType::Start, payload, sizeof(payload));
}
void SerialFramer::sendAudio(const int16_t* pcm, size_t count) {
// L'ESP32 est little-endian : les int16 sont déjà au bon format en mémoire.
sendFrame(FrameType::Audio, reinterpret_cast<const uint8_t*>(pcm), count * sizeof(int16_t));
}
void SerialFramer::sendEnd() {
sendFrame(FrameType::End, nullptr, 0);
}
void SerialFramer::sendFrame(FrameType type, const uint8_t* payload, size_t length) {
uint8_t header[SerialProtocol::HEADER_SIZE];
memcpy(header, SerialProtocol::MAGIC, sizeof(SerialProtocol::MAGIC));
header[4] = static_cast<uint8_t>(type);
header[5] = static_cast<uint8_t>(length & 0xFF);
header[6] = static_cast<uint8_t>((length >> 8) & 0xFF);
uint8_t checksum = 0;
for (size_t i = 0; i < length; ++i) {
checksum ^= payload[i];
}
_stream.write(header, sizeof(header));
if (length > 0) {
_stream.write(payload, length);
}
_stream.write(&checksum, 1);
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include "SerialProtocol.h"
/**
* @brief Encapsule des données binaires dans des trames envoyées sur l'UART.
*
* Le port série sert aussi aux messages de journalisation : il faut donc pouvoir
* distinguer l'audio du texte. Format des trames : voir SerialProtocol.h.
*/
class SerialFramer {
public:
using FrameType = SerialProtocol::FrameType;
explicit SerialFramer(Stream& stream);
/**
* @brief Envoie une trame START décrivant le format audio.
* @param sampleRate Fréquence d'échantillonnage en Hz.
* @param bits Nombre de bits par échantillon.
* @param channels Nombre de canaux.
*/
void sendStart(uint32_t sampleRate, uint16_t bits, uint16_t channels);
/**
* @brief Envoie un chunk audio.
* @param pcm Échantillons 16 bits signés.
* @param count Nombre d'échantillons.
*/
void sendAudio(const int16_t* pcm, size_t count);
/// Envoie la trame de fin de session.
void sendEnd();
private:
Stream& _stream;
void sendFrame(FrameType type, const uint8_t* payload, size_t length);
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
/**
* @file SerialProtocol.h
* @brief Format des trames binaires échangées avec le PC sur l'UART (étapes 1 et 2).
*
* Même format dans les deux sens, et identique à tools/serial_protocol.py :
*
* octets 0..3 : "VSA1" mot magique
* octet 4 : type 1 = START, 2 = AUDIO, 3 = END
* octets 5..6 : length taille utile en octets (uint16 little-endian)
* octets 7.. : payload length octets
* dernier : checksum OU exclusif de tous les octets du payload
*
* Le mot magique permet de se resynchroniser : du texte peut s'intercaler entre
* les trames. À l'étape 4, MQTT remplace ce transport.
*/
namespace SerialProtocol {
/// Mot magique de début de trame (aucun caractère répété : resynchronisation simple).
constexpr uint8_t MAGIC[4] = {'V', 'S', 'A', '1'};
/// Mot magique + type + longueur.
constexpr size_t HEADER_SIZE = 7;
/// Taille maximale d'un payload ; un chunk légitime fait 1600 octets.
constexpr size_t MAX_PAYLOAD = 2048;
/// Taille du descripteur de format porté par une trame START.
constexpr size_t START_PAYLOAD = 8;
/// Types de trames.
enum class FrameType : uint8_t {
Start = 1, ///< début de flux, payload = fréquence (u32), bits (u16), canaux (u16)
Audio = 2, ///< chunk de PCM 16 bits signé little-endian
End = 3, ///< fin de flux, payload vide
};
} // namespace SerialProtocol
+47
View File
@@ -0,0 +1,47 @@
#include "StatusLed.h"
StatusLed::StatusLed(int pin) : _pin(pin) {}
void StatusLed::begin() {
pinMode(_pin, OUTPUT);
write(false);
}
void StatusLed::on() {
_blinking = false;
write(true);
}
void StatusLed::off() {
_blinking = false;
write(false);
}
void StatusLed::blink(uint32_t periodMs, uint32_t onMs) {
const uint32_t lit = onMs == 0 ? periodMs / 2 : onMs;
if (_blinking && _periodMs == periodMs && _onMs == lit) {
return; // déjà dans ce mode : ne pas réinitialiser la phase
}
_blinking = true;
_periodMs = periodMs;
_onMs = lit;
_lastToggleMs = millis();
}
void StatusLed::update() {
if (!_blinking) {
return;
}
const uint32_t now = millis();
const uint32_t phase = _state ? _onMs : (_periodMs - _onMs); // durée de l'état courant
if ((now - _lastToggleMs) >= phase) {
_lastToggleMs = now;
write(!_state);
}
}
void StatusLed::write(bool state) {
_state = state;
digitalWrite(_pin, state ? HIGH : LOW);
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <Arduino.h>
/**
* @brief LED d'état, non bloquante.
*
* Aucun delay() : la LED clignote grâce à une comparaison de dates dans update(),
* ce qui laisse le chemin audio libre de toute attente.
*/
class StatusLed {
public:
explicit StatusLed(int pin);
/// Configure la broche en sortie, LED éteinte.
void begin();
/// Allume la LED en continu.
void on();
/// Éteint la LED.
void off();
/**
* @brief Fait clignoter la LED.
* @param periodMs Période complète du clignotement en millisecondes.
* @param onMs Durée allumée dans chaque période ; 0 = la moitié (clignotement régulier).
* Un éclair bref (100 ms toutes les 2 s) signale l'écoute continue (étape 13).
*/
void blink(uint32_t periodMs, uint32_t onMs = 0);
/// À appeler à chaque tour de boucle : applique le clignotement.
void update();
private:
int _pin;
bool _blinking = false;
uint32_t _periodMs = 500;
uint32_t _onMs = 250;
uint32_t _lastToggleMs = 0;
bool _state = false;
void write(bool state);
};
+102
View File
@@ -0,0 +1,102 @@
#include "VoiceSession.h"
#include <esp_random.h>
#include "config.h"
VoiceSession::VoiceSession(MqttLink& mqtt, SerialFramer& framer) : _mqtt(mqtt), _framer(framer) {}
void VoiceSession::start(Transport transport, uint32_t sampleRate) {
_transport = transport;
_active = true;
_chunks = 0;
_failures = 0;
_startMs = millis();
_durationMs = 0;
// Identifiant court, tiré du générateur matériel : distingue deux sessions
// successives dans les journaux et les noms de fichiers du serveur.
snprintf(_id, sizeof(_id), "%06lx", static_cast<unsigned long>(esp_random() & 0xFFFFFF));
if (_transport == Transport::Serial) {
_framer.sendStart(sampleRate, 16, 1);
return;
}
char payload[192];
snprintf(payload, sizeof(payload),
"{\"event\":\"start\",\"session\":\"%s\",\"rate\":%lu,\"bits\":16,\"channels\":1,"
"\"codec\":\"pcm_s16le\",\"chunk_ms\":%lu}",
_id, static_cast<unsigned long>(sampleRate),
static_cast<unsigned long>(AUDIO_CHUNK_MS));
_mqtt.publish("event", payload, false);
}
bool VoiceSession::sendChunk(const uint8_t* pcm, size_t bytes) {
if (!_active) {
return false;
}
bool ok = true;
if (_transport == Transport::Serial) {
_framer.sendAudio(reinterpret_cast<const int16_t*>(pcm), bytes / sizeof(int16_t));
} else {
ok = _mqtt.publishBinary("audio/in", pcm, bytes);
}
if (ok) {
++_chunks;
} else {
++_failures;
}
return ok;
}
void VoiceSession::end(uint32_t captureOverruns) {
if (!_active) {
return;
}
_active = false;
_durationMs = millis() - _startMs;
if (_transport == Transport::Serial) {
_framer.sendEnd();
return;
}
char payload[192];
snprintf(payload, sizeof(payload),
"{\"event\":\"end\",\"session\":\"%s\",\"chunks\":%lu,\"duration_ms\":%lu,"
"\"send_failures\":%lu,\"capture_overruns\":%lu}",
_id, static_cast<unsigned long>(_chunks), static_cast<unsigned long>(_durationMs),
static_cast<unsigned long>(_failures), static_cast<unsigned long>(captureOverruns));
_mqtt.publish("event", payload, false);
}
bool VoiceSession::active() const {
return _active;
}
VoiceSession::Transport VoiceSession::transport() const {
return _transport;
}
const char* VoiceSession::id() const {
return _id;
}
uint32_t VoiceSession::chunksSent() const {
return _chunks;
}
uint32_t VoiceSession::sendFailures() const {
return _failures;
}
uint32_t VoiceSession::durationMs() const {
return _durationMs;
}
uint32_t VoiceSession::elapsedMs() const {
return millis() - _startMs;
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <Arduino.h>
#include "MqttLink.h"
#include "SerialFramer.h"
/**
* @brief Une session de parole : START, chunks PCM, END.
*
* Par MQTT (étape 4) :
* voice/<id>/event {"event":"start","session":"a1b2c3","rate":16000,...}
* voice/<id>/audio/in 1600 octets de PCM brut, 20 fois par seconde
* voice/<id>/event {"event":"end","session":"a1b2c3","chunks":42,...}
*
* Le END porte le nombre de chunks envoyés : le serveur le compare au nombre
* reçu et détecte ainsi les pertes, sans numéro de séquence (celui-ci viendra
* à l'étape 16).
*
* Si MQTT n'est pas connecté à l'appui, la session part sur la liaison série,
* au format de l'étape 1 : `make record` reste un outil de dépannage.
*/
class VoiceSession {
public:
/// Transport de la session en cours.
enum class Transport {
Mqtt,
Serial,
};
VoiceSession(MqttLink& mqtt, SerialFramer& framer);
/**
* @brief Ouvre une session et annonce son format.
* @param transport MQTT ou liaison série.
* @param sampleRate Fréquence réellement en service.
*/
void start(Transport transport, uint32_t sampleRate);
/**
* @brief Envoie un chunk de PCM. Peut bloquer (Wi-Fi) : appeler depuis loop().
* @return true si le chunk a été confié au transport.
*/
bool sendChunk(const uint8_t* pcm, size_t bytes);
/**
* @brief Ferme la session et publie son bilan.
* @param captureOverruns Chunks perdus avant l'envoi, faute de place en FIFO.
*/
void end(uint32_t captureOverruns);
bool active() const;
Transport transport() const;
const char* id() const;
uint32_t chunksSent() const;
uint32_t sendFailures() const;
uint32_t durationMs() const;
/// Temps écoulé depuis start(), session en cours ou non.
uint32_t elapsedMs() const;
private:
MqttLink& _mqtt;
SerialFramer& _framer;
Transport _transport = Transport::Mqtt;
bool _active = false;
char _id[8] = {}; ///< 6 chiffres hexadécimaux + zéro final
uint32_t _startMs = 0;
uint32_t _durationMs = 0;
uint32_t _chunks = 0; ///< chunks confiés au transport
uint32_t _failures = 0; ///< chunks refusés par le transport
};
+56
View File
@@ -0,0 +1,56 @@
#include "WifiLink.h"
#include "config.h"
WifiLink::WifiLink(const char* ssid, const char* password, const char* hostname)
: _ssid(ssid), _password(password), _hostname(hostname) {}
void WifiLink::begin() {
WiFi.mode(WIFI_STA);
WiFi.setHostname(_hostname);
// Économie d'énergie coupée : le modem endormi ajoute des retards de 100 ms
// et plus sur la réception. Inacceptable pour un flux audio (étapes 4 et 5).
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.begin(_ssid, _password);
_lostSinceMs = millis();
_retrySinceMs = _lostSinceMs;
}
void WifiLink::update() {
const bool now = connected();
if (!now && (millis() - _retrySinceMs) >= WIFI_RESTART_MS) {
// Filet de sécurité : la reconnexion automatique n'a rien donné, on repart de zéro.
Serial.printf("# wifi : toujours absent apres %lu s, nouvelle connexion\n",
static_cast<unsigned long>((millis() - _lostSinceMs) / 1000));
WiFi.disconnect();
WiFi.begin(_ssid, _password);
_retrySinceMs = millis();
}
if (now == _wasConnected) {
return;
}
_wasConnected = now;
if (now) {
Serial.printf("# wifi : connecte en %lu ms, IP %s, signal %d dBm\n",
static_cast<unsigned long>(millis() - _lostSinceMs),
WiFi.localIP().toString().c_str(), WiFi.RSSI());
} else {
_lostSinceMs = millis();
_retrySinceMs = _lostSinceMs;
Serial.println("# wifi : connexion perdue, reconnexion automatique...");
}
}
bool WifiLink::connected() const {
return WiFi.status() == WL_CONNECTED;
}
int WifiLink::rssi() const {
return connected() ? WiFi.RSSI() : 0;
}
IPAddress WifiLink::ip() const {
return connected() ? WiFi.localIP() : IPAddress();
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
/**
* @brief Connexion Wi-Fi en mode station, non bloquante, reconnexion automatique.
*
* begin() lance la connexion et rend la main aussitôt : l'audio ne doit jamais
* attendre le réseau. update() signale les changements d'état et, si le réseau
* reste absent plus de WIFI_RESTART_MS, relance la connexion de zéro (étape 15).
*/
class WifiLink {
public:
/**
* @param ssid Nom du réseau (2,4 GHz).
* @param password Mot de passe.
* @param hostname Nom de la carte sur le réseau local.
*/
WifiLink(const char* ssid, const char* password, const char* hostname);
/// Lance la connexion, sans attendre.
void begin();
/// À appeler à chaque tour de boucle : journalise connexion et perte, relance si besoin.
void update();
/// true si l'ESP32 a une adresse IP.
bool connected() const;
/// Puissance du signal reçu, en dBm (-50 excellent, -80 limite).
int rssi() const;
/// Adresse IP courante, ou 0.0.0.0.
IPAddress ip() const;
private:
const char* _ssid;
const char* _password;
const char* _hostname;
bool _wasConnected = false;
uint32_t _lostSinceMs = 0; ///< début de la coupure en cours, pour la journaliser
uint32_t _retrySinceMs = 0; ///< dernière relance de la connexion
};
+579
View File
@@ -0,0 +1,579 @@
/**
* @file main.cpp
* @brief Étapes 1 à 5 — voix vers le serveur et réponses du serveur, par MQTT.
*
* Entrée (étape 4) : PTT -> micro I2S0 -> tâche de capture -> FIFO -> loop() -> MQTT audio/in
* (ou liaison série si le broker est absent, format de l'étape 1).
* Sortie (étapes 2 et 5) : MQTT audio/out ou trames série -> PlaybackStream -> FIFO
* -> tâche audio -> I2S1 -> ampli.
* Réseau (étape 3) : Wi-Fi + MQTT ; état retenu sur voice/<id>/state, testament « offline ».
* Fiabilisation (étape 15) : compte MQTT, chien de garde des tâches, micro coupé quand
* le serveur annonce son absence (voice/server/status).
*
* Les deux chemins sont indépendants : un micro en panne n'empêche pas la
* lecture, et inversement. Aucune allocation dynamique dans les chemins audio.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <esp_system.h>
#include <esp_task_wdt.h>
#include <atomic>
#include "AudioFifo.h"
#include "AudioOutput.h"
#include "CommandHandler.h"
#include "DemoDevices.h"
#include "MicCapture.h"
#include "MicSelfTest.h"
#include "MqttLink.h"
#include "PlaybackStream.h"
#include "PushButton.h"
#include "SerialFrameReader.h"
#include "SerialFramer.h"
#include "StatusLed.h"
#include "VoiceSession.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.
#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"
#endif
namespace {
using SerialProtocol::FrameType;
MicCapture mic(PIN_MIC_SCK, PIN_MIC_WS, PIN_MIC_SD);
MicSelfTest selfTest(mic);
AudioOutput speaker(PIN_AMP_BCLK, PIN_AMP_LRC, PIN_AMP_DIN);
// Deux FIFO, stockage statique fourni par nous (+1 octet exigé par FreeRTOS).
uint8_t playbackStorage[PLAYBACK_FIFO_BYTES + 1];
uint8_t captureStorage[CAPTURE_FIFO_BYTES + 1];
AudioFifo playbackFifo; ///< PC -> tâche audio -> ampli
AudioFifo captureFifo; ///< micro -> tâche de capture -> loop() -> réseau
PushButton button(PIN_BUTTON_PTT, BUTTON_DEBOUNCE_MS);
StatusLed led(PIN_STATUS_LED);
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);
/// Chunk en cours d'envoi réseau : 1600 octets, alloué une seule fois.
uint8_t outgoingChunk[AUDIO_CHUNK_BYTES];
/// Durée maximale d'une session d'enregistrement, garde-fou si le bouton reste bloqué (ms).
constexpr uint32_t SESSION_MAX_MS = 15000;
// --- Session de parole (étape 4), manipulée par loop() seulement.
VoiceSession session(mqtt, framer);
uint32_t captureOverrunsAtStart = 0;
bool micReady = false;
// Écoute continue (étape 13), pilotée par le serveur. loop() seulement.
bool listenWanted = false; ///< le serveur demande le flux continu
bool streaming = false; ///< le micro part en continu sur voice/<id>/audio/stream
bool wakeCapturing = false; ///< mot de réveil entendu : le serveur écoute la commande
uint32_t wakeCaptureSinceMs = 0;
bool serverOnline = false; ///< présence annoncée par le serveur (étape 15)
// Partagés entre loop() et la tâche de capture.
std::atomic<bool> captureWanted{false}; ///< loop() demande la capture
std::atomic<bool> captureRunning{false}; ///< la tâche capture effectivement
bool speakerReady = false;
uint32_t lastStateMs = 0;
// --- Lecture (étapes 2 et 5) et commandes (étape 5).
PlaybackStream playback(speaker, playbackFifo);
void onListen(bool enabled);
void onCapture(bool active);
CommandHandler commands(playback, mqtt, devices, onListen, onCapture);
// Pile et contrôle de la tâche de capture, alloués statiquement.
StaticTask_t captureTaskControl;
StackType_t captureTaskStack[CAPTURE_TASK_STACK_BYTES];
/**
* @brief Tâche de capture : micro -> FIFO de capture, au rythme du micro.
*
* Elle ne touche jamais au réseau : un envoi Wi-Fi peut bloquer, et pendant ce
* temps le DMA du micro (75 ms de réserve) déborderait. Ici, rien ne bloque
* sauf la lecture du micro elle-même, qui rend la main toutes les 50 ms.
*/
void captureTask(void* /*unused*/) {
static int16_t chunk[AUDIO_CHUNK_SAMPLES];
esp_task_wdt_add(nullptr); // surveillée par le chien de garde (étape 15)
for (;;) {
esp_task_wdt_reset(); // « je suis vivante » : au moins toutes les 50 ms
if (!captureWanted.load()) {
captureRunning.store(false);
vTaskDelay(pdMS_TO_TICKS(5));
continue;
}
if (!captureRunning.load()) {
mic.flush(); // on jette l'audio capté avant l'appui
mic.resetStats(); // les statistiques ne portent que sur cette session
captureRunning.store(true);
}
const size_t samples = mic.read(chunk, AUDIO_CHUNK_SAMPLES); // ~50 ms
if (samples > 0) {
// Tout ou rien : si la FIFO est pleine, le chunk est compté perdu.
captureFifo.write(reinterpret_cast<const uint8_t*>(chunk), samples * sizeof(int16_t));
}
}
}
/// Lit un entier little-endian dans un payload.
uint32_t readLe(const uint8_t* data, size_t bytes) {
uint32_t value = 0;
for (size_t i = 0; i < bytes; ++i) {
value |= static_cast<uint32_t>(data[i]) << (8 * i);
}
return value;
}
/// Traite une trame reçue du PC par la liaison série (étape 2).
void handleFrame(const SerialFrameReader::Frame& frame) {
switch (frame.type) {
case FrameType::Start:
if (frame.length != SerialProtocol::START_PAYLOAD) {
Serial.println("# lecture refusee : descripteur START invalide");
return;
}
playback.open("serie", readLe(frame.payload, 4), readLe(frame.payload + 4, 2),
readLe(frame.payload + 6, 2), false);
break;
case FrameType::Audio:
playback.push(frame.payload, frame.length);
break;
case FrameType::End:
playback.close(-1); // la liaison série n'annonce pas le nombre de chunks
break;
}
}
/// Affiche le bilan d'un flux joué, et le publie s'il venait du serveur.
void reportPlayback(const PlaybackStream::Report& report) {
Serial.printf("# lecture %s : %s, %lu chunks recus", report.session,
report.endReceived ? "terminee" : "abandonnee (pas de END)",
static_cast<unsigned long>(report.chunks));
if (report.announced >= 0) {
Serial.printf(" sur %ld annonces", static_cast<long>(report.announced));
}
Serial.printf(", %lu refuses (FIFO pleine), %lu reamorcages, premier son a %lu ms, "
"marge mini %ld ms, %lu ms\n",
static_cast<unsigned long>(report.overruns),
static_cast<unsigned long>(report.underruns),
static_cast<unsigned long>(report.startMs),
static_cast<long>(report.minMarginMs),
static_cast<unsigned long>(report.durationMs));
// Instants des réamorçages (depuis le premier son) : au début, au milieu, ou à la fin ?
char gaps[48] = "";
size_t used = 0;
for (size_t i = 0; i < PlaybackStream::MAX_GAPS && i < report.underruns; ++i) {
used += snprintf(gaps + used, sizeof(gaps) - used, "%s%lu", i == 0 ? "" : ",",
static_cast<unsigned long>(report.gapAtMs[i]));
}
if (report.underruns > 0) {
Serial.printf("# lecture %s : reamorcages a [%s] ms du premier son\n", report.session, gaps);
}
if (!report.fromMqtt) {
return;
}
char payload[304];
snprintf(payload, sizeof(payload),
"{\"event\":\"played\",\"session\":\"%s\",\"end\":%s,\"chunks\":%lu,"
"\"announced\":%ld,\"overruns\":%lu,\"underruns\":%lu,\"duration_ms\":%lu,"
"\"start_ms\":%lu,\"min_margin_ms\":%ld,\"gaps_ms\":[%s]}",
report.session, report.endReceived ? "true" : "false",
static_cast<unsigned long>(report.chunks), static_cast<long>(report.announced),
static_cast<unsigned long>(report.overruns),
static_cast<unsigned long>(report.underruns),
static_cast<unsigned long>(report.durationMs),
static_cast<unsigned long>(report.startMs),
static_cast<long>(report.minMarginMs), gaps);
mqtt.publish("event", payload, false);
}
/// Cause du dernier redémarrage, en mot court pour le JSON.
const char* resetReason() {
switch (esp_reset_reason()) {
case ESP_RST_POWERON:
return "poweron";
case ESP_RST_EXT:
return "external"; // broche EN, ou ouverture du port série sur certaines cartes
case ESP_RST_SW:
return "software";
case ESP_RST_PANIC:
return "panic"; // plantage du firmware
case ESP_RST_INT_WDT:
case ESP_RST_TASK_WDT:
case ESP_RST_WDT:
return "watchdog"; // une tâche a monopolisé le processeur
case ESP_RST_BROWNOUT:
return "brownout"; // chute de tension : alimentation trop faible (ampli !)
default:
return "other";
}
}
/**
* @brief Publie l'état de la carte, en message retenu.
*
* Retenu : le broker garde le dernier état et le donne aussitôt à tout nouvel
* abonné. Un serveur qui démarre après l'ESP32 sait donc immédiatement s'il est
* en ligne, sans attendre la prochaine publication.
*/
void publishState() {
char payload[192];
snprintf(payload, sizeof(payload),
"{\"status\":\"online\",\"ip\":\"%s\",\"rssi\":%d,\"uptime_s\":%lu,"
"\"mic\":%s,\"speaker\":%s}",
wifi.ip().toString().c_str(), wifi.rssi(),
static_cast<unsigned long>(millis() / 1000), micReady ? "true" : "false",
speakerReady ? "true" : "false");
mqtt.publish("state", payload, true);
lastStateMs = millis();
}
/// Présence du serveur : absent, plus rien ne sort du micro (étape 15).
void onServerStatus(const uint8_t* payload, size_t length) {
char text[64];
const size_t n = length < sizeof(text) - 1 ? length : sizeof(text) - 1;
memcpy(text, payload, n);
text[n] = '\0';
const bool online = strstr(text, "\"online\"") != nullptr;
if (online == serverOnline) {
return;
}
serverOnline = online;
if (online) {
Serial.println("# serveur : en ligne");
return;
}
// Testament du serveur (plantage) ou arrêt : il ne pourra plus dire « listen false ».
// Il redemandera l'écoute à son retour, en voyant l'état de la carte.
listenWanted = false;
wakeCapturing = false;
Serial.println("# serveur : ABSENT, ecoute continue coupee");
}
/// Appelé à chaque connexion au broker.
void onMqttConnect(bool firstTime) {
serverOnline = false; // l'état retenu du serveur arrive avec l'abonnement
mqtt.subscribe("control");
mqtt.subscribe("audio/out");
mqtt.subscribeTopic(MQTT_SERVER_STATUS_TOPIC);
publishState(); // remplace aussitôt le « offline » retenu d'une absence précédente
if (firstTime) {
char payload[96];
snprintf(payload, sizeof(payload), "{\"event\":\"boot\",\"reason\":\"%s\"}",
resetReason());
mqtt.publish("event", payload, false);
}
}
/// Appelé pour chaque message reçu. Court : pas d'attente, pas de traitement lourd.
void onMqttMessage(const char* suffix, const uint8_t* payload, size_t length) {
if (strcmp(suffix, "audio/out") == 0) {
playback.push(payload, length); // ne fait que remplir la FIFO
} else if (strcmp(suffix, "control") == 0) {
commands.handle(payload, length);
} else if (strcmp(suffix, MQTT_SERVER_STATUS_TOPIC) == 0) {
onServerStatus(payload, length);
}
}
/// Commande « listen » du serveur : active ou coupe l'écoute continue.
void onListen(bool enabled) {
listenWanted = enabled;
Serial.printf("# ecoute continue %s par le serveur\n", enabled ? "activee" : "desactivee");
}
/// Commande « capture » du serveur : mot de réveil entendu (LED allumée) ou commande finie.
void onCapture(bool active) {
wakeCapturing = active;
wakeCaptureSinceMs = millis();
}
/// Arrête le flux continu et jette ce qui restait : rien ne sort du micro sans raison.
void stopStreaming() {
captureWanted.store(false);
const uint32_t waitStart = millis();
while (captureRunning.load() && (millis() - waitStart) < 200) {
delay(1);
}
while (captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0) > 0) {
}
streaming = false;
}
/**
* Écoute continue (étape 13) : le micro part vers le serveur, qui guette le mot de réveil.
* Jamais pendant une réponse parlée : le micro entendrait le haut-parleur, et le Wi-Fi
* doit alors servir la lecture.
*/
void updateStreaming() {
const bool wanted = listenWanted && serverOnline && mqtt.connected() && !playback.active();
if (wanted && !streaming) {
captureWanted.store(true);
streaming = true;
} else if (!wanted && streaming) {
stopStreaming();
}
if (!streaming) {
return;
}
while (captureFifo.available() >= AUDIO_CHUNK_BYTES) {
const size_t bytes = captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0);
if (bytes == 0) {
break;
}
mqtt.publishBinary("audio/stream", outgoingChunk, bytes);
esp_task_wdt_reset(); // chaque envoi peut attendre le Wi-Fi jusqu'à 10 s : on avance
}
}
/// LED au repos : allumée = commande écoutée, éclair bref = écoute continue, lent = prêt.
void updateIdleLed() {
if (wakeCapturing && (millis() - wakeCaptureSinceMs) > SESSION_MAX_MS) {
wakeCapturing = false; // le serveur n'a jamais dit « fini » : on ne reste pas allumé
}
if (wakeCapturing) {
led.on();
} else if (streaming) {
led.blink(2000, 100);
} else {
led.blink(1000);
}
}
/// Ouvre une session de parole : MQTT si possible, liaison série sinon.
void startSession() {
const bool viaMqtt = mqtt.connected();
if (!viaMqtt) {
Serial.println("# session : MQTT non connecte, envoi par la liaison serie (make record)");
}
captureOverrunsAtStart = captureFifo.overruns();
session.start(viaMqtt ? VoiceSession::Transport::Mqtt : VoiceSession::Transport::Serial,
mic.sampleRate());
captureWanted.store(true);
led.on();
}
/// Envoie au réseau les chunks complets en attente. Peut bloquer sur le Wi-Fi.
void drainCapture(bool includePartial) {
for (;;) {
const size_t available = captureFifo.available();
if (available == 0 || (!includePartial && available < AUDIO_CHUNK_BYTES)) {
return;
}
const size_t bytes = captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0);
if (bytes == 0) {
return;
}
session.sendChunk(outgoingChunk, bytes);
esp_task_wdt_reset(); // chaque envoi peut attendre le Wi-Fi jusqu'à 10 s : on avance
}
}
/// Ferme la session : arrête la capture, envoie le reste, publie le bilan.
void stopSession(const char* reason) {
captureWanted.store(false);
// La tâche finit le chunk en cours (50 ms au plus) avant de s'arrêter.
const uint32_t waitStart = millis();
while (captureRunning.load() && (millis() - waitStart) < 200) {
delay(1);
}
drainCapture(true);
const uint32_t overruns = captureFifo.overruns() - captureOverrunsAtStart;
session.end(overruns);
led.off();
Serial.printf("\n# session %s terminee (%s, %s) : %lu chunks envoyes, %lu echecs d'envoi, "
"%lu perdus faute de place, %lu ms\n",
session.id(), reason,
session.transport() == VoiceSession::Transport::Mqtt ? "MQTT" : "serie",
static_cast<unsigned long>(session.chunksSent()),
static_cast<unsigned long>(session.sendFailures()),
static_cast<unsigned long>(overruns),
static_cast<unsigned long>(session.durationMs()));
if (session.durationMs() < 300) {
// Trop court pour contenir une parole : appui trop bref, ou bouton qui
// « lâche » tout seul (contact intermittent, rebond). Le serveur l'ignore.
Serial.printf("# ATTENTION : session de %lu ms seulement. Si vous teniez le bouton,\n"
"# son contact est intermittent : verifiez ses fils sur la plaque.\n",
static_cast<unsigned long>(session.durationMs()));
}
selfTest.reportSession(mic.stats());
}
/**
* @brief Chien de garde (étape 15) : délai porté à WATCHDOG_TIMEOUT_S, redémarrage si dépassé.
*
* loop() est surveillée à chaque tour ; les tâches capture et audio s'inscrivent elles-mêmes.
* La tâche « idle » du cœur 0 reste surveillée : si le Wi-Fi (cœur 0) monopolise le processeur,
* le chien de garde le voit aussi.
*/
void setupWatchdog() {
const esp_task_wdt_config_t config = {
.timeout_ms = WATCHDOG_TIMEOUT_S * 1000,
.idle_core_mask = 1 << 0,
.trigger_panic = true, // redémarrage, et non simple message
};
if (esp_task_wdt_reconfigure(&config) != ESP_OK) {
Serial.println("# ERREUR : chien de garde non configure");
return;
}
enableLoopWDT();
Serial.printf("# chien de garde : %lu s (loop, capture, audio)\n",
static_cast<unsigned long>(WATCHDOG_TIMEOUT_S));
}
} // namespace
void setup() {
// En tout premier : la sortie audio prend les broches de l'ampli et lui envoie
// du silence. Laissées flottantes, elles le faisaient souffler à plein volume.
speakerReady = speaker.begin(AUDIO_SAMPLE_RATE, AMP_FORMAT_PHILIPS);
Serial.setRxBufferSize(SERIAL_RX_BUFFER_BYTES); // avant begin(), sinon ignoré
Serial.begin(SERIAL_BAUD);
// Hors chemin audio : le moniteur série se reconnecte après le reset de la
// carte. Sans cette attente, les premières lignes de l'auto-test sont perdues.
delay(1500);
led.begin();
devices.begin();
button.begin();
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),
static_cast<unsigned long>(AUDIO_CHUNK_MS),
static_cast<unsigned>(AUDIO_CHUNK_BYTES));
Serial.printf("# micro : SCK=GPIO %d, WS=GPIO %d, SD=GPIO %d\n", PIN_MIC_SCK, PIN_MIC_WS,
PIN_MIC_SD);
Serial.printf("# ampli : BCLK=GPIO %d, LRC=GPIO %d, DIN=GPIO %d, cadrage %s\n", PIN_AMP_BCLK,
PIN_AMP_LRC, PIN_AMP_DIN, AMP_FORMAT_PHILIPS ? "Philips" : "MSB");
// --- Sortie : test isolé par un bip, puis mise en place de la chaîne de lecture.
if (!speakerReady) {
Serial.println("# ERREUR : sortie audio I2S1 impossible a demarrer");
} else {
Serial.printf("# bip de demarrage : %lu Hz, %lu ms, -12 dBFS\n",
static_cast<unsigned long>(BOOT_TONE_HZ),
static_cast<unsigned long>(BOOT_TONE_MS));
speaker.playTone(BOOT_TONE_HZ, BOOT_TONE_MS, BOOT_TONE_AMPLITUDE);
if (!playbackFifo.begin(playbackStorage, PLAYBACK_FIFO_BYTES,
PLAYBACK_BLOCK_SAMPLES * sizeof(int16_t))) {
Serial.println("# ERREUR : FIFO de lecture impossible a creer");
} else {
playback.begin();
Serial.printf("# lecture prete : FIFO %u ms, tache audio sur le coeur %d\n",
static_cast<unsigned>(PLAYBACK_FIFO_BYTES / (AUDIO_SAMPLE_RATE / 1000 * 2)),
AUDIO_TASK_CORE);
}
}
// --- Entrée : auto-test du micro.
micReady = selfTest.run();
if (micReady) {
// La tâche de capture ne démarre qu'après l'auto-test : ils partagent le micro.
if (!captureFifo.begin(captureStorage, CAPTURE_FIFO_BYTES, AUDIO_CHUNK_BYTES)) {
Serial.println("# ERREUR : FIFO de capture impossible a creer");
micReady = false;
} else {
xTaskCreateStaticPinnedToCore(captureTask, "capture", CAPTURE_TASK_STACK_BYTES,
nullptr, CAPTURE_TASK_PRIORITY, captureTaskStack,
&captureTaskControl, CAPTURE_TASK_CORE);
Serial.println("# micro pret. Appuyez sur le bouton PTT et parlez.");
led.blink(1000); // clignotement lent = prêt, au repos
}
}
// --- 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)");
network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS);
if (!mqtt.begin(onMqttMessage, onMqttConnect)) {
Serial.println("# ERREUR : tampon MQTT impossible a allouer");
}
wifi.begin();
// En dernier : l'auto-test du micro et le bip ont leurs propres durées, hors surveillance.
setupWatchdog();
}
void loop() {
// La réception série passe toujours en premier : elle ne doit jamais attendre.
SerialFrameReader::Frame frame;
while (reader.poll(frame)) {
handleFrame(frame);
}
// Réseau : non bloquant, sauf pendant une tentative de connexion au broker.
wifi.update();
mqtt.update(wifi.connected());
PlaybackStream::Report played;
if (playback.takeReport(played)) {
reportPlayback(played);
}
if (mqtt.connected() && (millis() - lastStateMs) >= STATE_PERIOD_MS) {
publishState();
}
if (!micReady) {
led.blink(150); // clignotement rapide : micro en défaut, lecture toujours possible
led.update();
return;
}
button.update();
if (!session.active()) {
if (button.wasPressed()) {
button.clearEvents(); // un relâchement périmé ne doit pas clore la session
if (streaming) {
stopStreaming(); // le bouton reprend la main sur le micro
}
startSession();
} else {
updateStreaming();
updateIdleLed();
}
led.update();
return;
}
// --- Session en cours : la capture tourne dans sa tâche ; ici on envoie.
drainCapture(false);
if (button.wasReleased() || !button.isDown()) {
stopSession("bouton relache");
button.clearEvents(); // un appui mémorisé pendant la session ne doit pas en relancer une
led.blink(1000);
} else if (session.elapsedMs() > SESSION_MAX_MS) {
stopSession("duree maximale");
led.blink(1000);
}
}
+37
View File
@@ -0,0 +1,37 @@
# Droits par topic de l'assistant vocal (étape 15), pour Mosquitto 2.x.
#
# Tout ce qui n'est pas permis ici est INTERDIT, et Mosquitto refuse en silence :
# une publication interdite est jetée sans erreur côté client, un abonnement
# interdit ne reçoit simplement rien. Symptôme typique : « tout est connecté,
# mais rien ne se passe ».
# Le serveur vocal, et les outils du Makefile qui prennent ses identifiants
# (mqtt-watch, tone-mqtt, play-mqtt...) : tout voice/, la domotique home/ et les
# agents Linux agent/ (étape 15b).
user voice-server
topic readwrite voice/#
topic readwrite home/#
topic readwrite agent/#
# Chaque carte : SES topics seulement. %u est remplacé par le nom d'utilisateur
# MQTT, qui est donc le DEVICE_ID de la carte (esp32-01). Une carte ne peut ni
# écouter le micro d'une autre, ni publier sous son nom.
pattern write voice/%u/state
pattern write voice/%u/event
pattern write voice/%u/audio/in
pattern write voice/%u/audio/stream
pattern read voice/%u/control
pattern read voice/%u/audio/out
# Chaque agent Linux (étape 15b) : compte = son id (pc-bureau). Il ne reçoit que SES
# commandes : sans ces droits, n'importe qui pourrait éteindre toutes les machines.
pattern write agent/%u/state
pattern write agent/%u/result
pattern read agent/%u/command
# Présence du serveur (« online », ou « offline » publié par son testament) :
# toutes les cartes la lisent, pour couper leur micro quand il disparaît.
# Au démarrage, Mosquitto prévient « ACL pattern 'voice/server/status' does not
# contain '%c' or '%u' » : c'est voulu (le même topic pour tous), et la règle
# s'applique bien (vérifié).
pattern read voice/server/status
+48
View File
@@ -0,0 +1,48 @@
# Configuration Mosquitto 2.x de l'assistant vocal.
#
# Deux façons de l'utiliser :
# - service Debian : `make mosquitto-config` (copie vers /etc/mosquitto/conf.d/) ;
# - conteneur Docker : monter ce fichier comme /mosquitto/config/mosquitto.conf
# (voir docs/MOSQUITTO.md).
#
# Piège n°1 : sans « listener » explicite, Mosquitto 2.x n'écoute QUE sur
# localhost. L'ESP32 ne peut alors pas se connecter, alors que mosquitto_sub
# fonctionne très bien sur la machine elle-même.
#
# Piège n°2 : UN SEUL « listener » par port. Deux lignes « listener 1883 »
# font planter le broker au démarrage (« Error: Address in use ») ; dans un
# conteneur en « restart: unless-stopped », il redémarre alors en boucle.
listener 1883 0.0.0.0
# Étape 15 : plus d'accès anonyme. Chaque client a un compte (make mqtt-user),
# et le fichier acl dit qui peut lire ou écrire quel topic : une carte ne lit
# que ses commandes, et personne d'autre que le serveur n'entend les micros.
# Chemins du conteneur Docker ; « make mosquitto-config » les adapte au service
# Debian (/etc/mosquitto/).
#
# Piège n°4 : TOUS les clients de ce broker ont désormais besoin d'un compte,
# y compris ceux d'autres projets qui l'utilisaient sans mot de passe.
allow_anonymous false
password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl
# 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)
max_packet_size 8192
# Journal sur la sortie standard : visible avec `docker logs` dans un conteneur,
# et dans `journalctl -u mosquitto` avec le service Debian.
# Piège n°3 : « log_dest syslog » dans un conteneur n'écrit nulle part, et un
# plantage au démarrage devient invisible.
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
connection_messages true
# Pas de persistance : les messages audio n'ont aucun intérêt après coup, et
# l'état « retenu » de l'ESP32 est republié à chaque connexion.
persistence false
+354
View File
@@ -0,0 +1,354 @@
# PROMPT — Assistant vocal local ESP32 / Raspberry Pi (MQTT, faster-whisper, Piper)
---
## 1. RÔLE
Tu es **ingénieur senior en systèmes embarqués et traitement de la parole**, doublé d'un **enseignant expérimenté** (IUT / école d'ingénieurs). Tu maîtrises :
- **ESP32** : Arduino-ESP32 (C++), I2S (RX et TX), FreeRTOS (tâches, queues, StreamBuffer), Wi-Fi, watchdog.
- **Audio numérique** : PCM s16le, échantillonnage, rééchantillonnage, buffers, anti-jitter, gain, offset DC.
- **MQTT** : Mosquitto 2.x, QoS, Last Will, payload binaire, PubSubClient (ESP32), paho-mqtt 2.x (Python).
- **Python 3.11+** : architecture modulaire orientée classes, typage strict (pyright), logging, asyncio/threads.
- **Traitement vocal local** : Silero VAD, faster-whisper, pyannote.audio, SpeechBrain (ECAPA), openWakeWord, Piper TTS.
Ta personnalité : **pédagogue, rigoureux, pragmatique**. Tu privilégies toujours la solution la plus simple qui fonctionne, tu expliques le *pourquoi* avant le *comment*, et tu refuses la complexité inutile.
---
## 2. OBJECTIF
Guider pas à pas des étudiants dans la réalisation d'un **assistant vocal 100 % local** : un ESP32 capture la voix (INMP441), l'envoie en PCM binaire via MQTT à un serveur Python (Raspberry Pi ou PC) qui transcrit (faster-whisper), interprète, répond en synthèse vocale (Piper) et renvoie l'audio PCM via MQTT à l'ESP32 qui le joue (MAX98357A) et exécute la commande éventuelle.
---
## 3. CONTEXTE TECHNIQUE
### 3.1 Architecture
```
INMP441 -> ESP32 -> MQTT -> Serveur Python (Raspberry Pi / PC)
|
+---------+---------+
| |
VAD Diarisation /
| identification
+---------+---------+
|
faster-whisper
|
Assistant
|
Piper
|
MQTT audio/out
|
ESP32 -> MAX98357A -> haut-parleur
```
Rôle de chaque bloc (à ne jamais confondre) :
| Bloc | Question à laquelle il répond |
|---|---|
| VAD | Est-ce que quelqu'un parle ? |
| Diarisation | Qui parle à quel moment ? (SPEAKER_00, SPEAKER_01…) |
| Identification | Qui est cette personne ? (Denis, Marie, Inconnu) |
| STT | Qu'est-ce qui est dit ? |
### 3.2 Matériel
- **ESP32-WROOM-32** (2 périphériques I2S : I2S0 en RX micro, I2S1 en TX ampli).
- **INMP441** : micro I2S, broche L/R à GND (canal gauche), alimentation 3,3 V.
- **MAX98357A** : ampli I2S classe D mono, alimentation 5 V, broche SD (arrêt) et GAIN documentées.
- **Bouton Push-To-Talk** + **LED d'état**.
- **Raspberry Pi 4/5 (64 bits, Debian/Raspberry Pi OS)** ou PC Debian.
Brochage proposé (à justifier et adapter si besoin, **jamais de broches de strapping** GPIO 0, 2, 5, 12, 15 pour l'audio) :
| Signal | GPIO |
|---|---|
| INMP441 SCK / WS / SD | 26 / 25 / 33 |
| MAX98357A BCLK / LRC / DIN | 27 / 14 / 22 |
| Bouton PTT (pull-up interne) | 4 |
| LED état | 2 (sortie uniquement, acceptable) |
### 3.3 Format audio (unique dans tout le système)
- 16 kHz, 16 bits, mono, **PCM signé little-endian (`pcm_s16le`)**.
- Débit ≈ 32 kB/s ; chunks de **40 à 50 ms = 1280 à 1600 octets**.
- **Pas de Base64** : le payload MQTT audio contient des bytes PCM bruts.
- L'INMP441 fournit des échantillons 24 bits dans des mots de 32 bits : conversion en 16 bits obligatoire (décalage + gain), suppression de l'offset DC recommandée.
- Piper produit souvent du 22 050 Hz : **rééchantillonner côté serveur en 16 kHz** avant envoi, pour garder l'ESP32 simple.
### 3.4 Topics MQTT
| Topic | Sens | Payload | QoS |
|---|---|---|---|
| `voice/esp32-01/control` | serveur → ESP32 | JSON (commandes) | 1 |
| `voice/esp32-01/audio/in` | ESP32 → serveur | bytes PCM | 0 |
| `voice/esp32-01/audio/out` | serveur → ESP32 | bytes PCM | 0 |
| `voice/esp32-01/state` | ESP32 → serveur | JSON, retained + Last Will `offline` | 1 |
| `voice/esp32-01/event` | ESP32 → serveur | JSON (start / end / erreurs) | 1 |
Cycle de session : `START` (JSON sur `event`) → chunks PCM binaires (`audio/in`) → `END` (JSON sur `event`).
```json
{
"event": "start",
"session": "abc123",
"rate": 16000,
"bits": 16,
"channels": 1,
"codec": "pcm_s16le",
"chunk_ms": 50
}
```
La sortie suit le même principe : `START` sur `control` → chunks sur `audio/out` → `END`. Le numéro de séquence (phase 16) sera un en-tête binaire fixe et documenté (ex. `uint32` little-endian) préfixé au PCM.
### 3.5 Pièges connus à signaler au bon moment
- Mosquitto 2.x n'écoute que sur `localhost` sans `listener 1883` explicite dans la configuration.
- PubSubClient : `setBufferSize()` doit dépasser la taille d'un chunk + en-tête MQTT ; `loop()` doit être appelé très souvent.
- Le callback MQTT de l'ESP32 doit être court : il pousse dans une FIFO, une tâche FreeRTOS dédiée écrit en I2S.
- Silero VAD attend des fenêtres de 512 échantillons à 16 kHz.
- faster-whisper : `language="fr"`, `compute_type="int8"` ; modèle `base` sur Pi 4, `small` sur Pi 5/PC.
- pyannote.audio nécessite un token Hugging Face (secret, jamais versionné) et reste lourd sur Raspberry Pi : prévoir l'option PC.
- si la latence de pyannote sur le Raspberry Pi est trop importante, on déplacera uniquement la diarisation/identification vers une machine plus puissante.
- La diarisation n'a probablement pas besoin d'être permanente: envoyer à pyannote uniquement la séquence parlée, plutôt que de lui faire analyser en permanence le flux microphone.
- L'identification vocale est **probabiliste** : ce n'est **jamais** une authentification de sécurité.
### 3.6 Feuille de route (ordre imposé)
| # | Étape | Critère de validation |
|---|---|---|
| 1 | Entrée audio ESP32 (I2S RX) | Phrase enregistrée via série → WAV sur PC, audible et sans saturation |
| 2 | Sortie audio ESP32 (I2S TX) | Sinus 440 Hz puis PCM joués sans coupure ni parasite |
| 3 | Mosquitto + topics | `mosquitto_sub` reçoit états et Last Will |
| 4 | ESP32 → MQTT → serveur | Session START/chunks/END reconstruite en WAV côté serveur |
| 5 | Serveur → MQTT → ESP32 | Un WAV serveur est joué par l'ESP32 |
| 6 | **MVP** : PTT → faster-whisper → réponse → Piper → ESP32 | Boucle complète stable |
| 7 | VAD (Silero) | Silences supprimés, début/fin de parole détectés |
| 8 | Diarisation (pyannote) | Segments horodatés par locuteur |
| 9 | Identification (embeddings + cosinus + seuil) | Nom ou « Inconnu » |
| 10 | Association locuteur + texte | `{"speaker": "Denis", "text": "..."}` |
| 11 | Assistant / domotique | Intention + entités → commande MQTT |
| 12 | Buffer de sortie anti-jitter | Lecture fluide malgré les variations réseau |
| 13 | Wake word (openWakeWord côté serveur) | Activation mains libres |
| 14 | Multi-utilisateurs | Droits par profil (complet / standard / limité) |
| 15 | Fiabilisation | Reconnexions, séquences, timeouts, watchdog, logs, auth MQTT, TLS optionnel |
| 16 | Optimisation | Streaming, parallélisme, latence, cache |
**Règle absolue** : ne jamais passer à l'étape suivante tant que l'étape courante n'est pas validée par l'utilisateur.
### 3.7 Principes techniques
1. ESP32 simple : acquisition, restitution, transport. Tout traitement lourd sur le serveur.
2. MQTT comme transport unique ; audio en PCM binaire.
3. Entrée et sortie audio développées et testées séparément.
4. Chaque module testable isolément (outil ou script de test fourni).
5. Architecture locale et autonome : aucun service cloud.
---
## 4. PUBLIC CIBLE
Étudiants (BUT / licence / première année d'école d'ingénieurs) :
- bases en C/C++ et Python, notions d'électronique ;
- découvrent I2S, MQTT et le traitement de la parole ;
- travaillent sous Linux Debian avec VSCodium + PlatformIO.
Adapte le vocabulaire : définis chaque terme technique à sa première apparition (une phrase suffit).
---
## 5. STYLE DE COMMUNICATION
- Ton **mentor** : clair, bienveillant, exigeant sur la rigueur.
- Phrases courtes. Pas de jargon non expliqué. Pas de remplissage.
- Français pour les explications et les commentaires de code ; anglais pour tout identifiant de code.
---
## 6. FORMAT ATTENDU
Une réponse = **une étape** de la feuille de route, structurée ainsi :
1. **Objectif de l'étape** (2–3 lignes).
2. **Notions clés** : explication courte des concepts nouveaux.
3. **Schéma** (ASCII) du flux de données ou du câblage concerné.
4. **Fichiers créés / modifiés** : arborescence partielle.
5. **Code complet** de chaque fichier concerné (aucun `...`, aucun extrait tronqué).
6. **Procédure de test** : commandes exactes (`make`, `pio`, `mosquitto_sub`, scripts).
7. **Critères de validation** : ce que l'étudiant doit observer.
8. **Pièges courants** et diagnostic associé.
9. **Question de validation** : demander à l'utilisateur de confirmer le résultat avant de continuer.
---
## 7. RAISONNEMENT
Avant d'écrire le code de chaque étape, raisonne étape par étape (sans l'exposer longuement) :
1. contraintes matérielles et temps réel (RAM ESP32, taille des buffers, cadence I2S) ;
2. calculs de dimensionnement (octets par chunk, durée de buffer, débit) — **les afficher** dans la réponse ;
3. choix de la solution la plus simple satisfaisant le critère de validation ;
4. risques et points de défaillance.
Présente ensuite uniquement la synthèse utile : calculs, choix retenus et leur justification en une phrase.
---
## 8. INSTRUCTIONS PERSONNALISÉES
### 8.1 Conventions de code
- **Code en anglais, commentaires en français**, indentation **4 espaces** partout (C++, Python, Makefile excepté pour les tabulations imposées par `make`, YAML, JSON).
- **Organisation en modules sous forme de classes**, une responsabilité par classe.
- Chaque classe et méthode publique documentée (Doxygen `/** */` en C++, docstrings en Python).
- Python : typage complet, compatible **pyright** en mode `standard`, `logging` (jamais `print`), configuration via `config.yaml` + `.env`.
- C++ : pas d'allocation dynamique dans les boucles audio, pas de `delay()` bloquant dans le chemin audio, constantes dans `config.h`.
- Dépendances Python épinglées dans `pyproject.toml`.
### 8.2 Modules attendus
** Ide vscodium evec les extensions Platformio, Python, Pyright. Configurer pyright
**Firmware ESP32 (PlatformIO, framework Arduino)** :
`WifiLink`, `MqttLink`, `MicCapture` (I2S RX + conversion 32→16 bits), `AudioOutput` (I2S TX), `AudioFifo` (ring buffer / StreamBuffer), `PushButton`, `StatusLed`, `VoiceSession` (START/chunks/END), `CommandHandler`.
**Serveur Python** :
`MqttLink`, `SessionManager`, `AudioBuffer`, `VoiceActivityDetector`, `SpeechToText`, `Diarizer`, `SpeakerIdentifier`, `Assistant`, `TextToSpeech`, `AudioResampler`, `VoiceServer` (orchestration).
### 8.3 Arborescence du dépôt
```
voice-assistant/
├── firmware/
│ ├── platformio.ini
│ ├── include/ # config.h, secrets.h.example
│ └── src/ # main.cpp + une paire .h/.cpp par classe
├── server/
│ ├── voice_server/ # un module .py par classe
│ ├── tests/
│ ├── config.yaml
│ ├── .env.example
│ ├── pyproject.toml
│ └── pyrightconfig.json
├── tools/ # serial_to_wav.py, wav_to_mqtt.py, mqtt_to_wav.py
├── mosquitto/ # voice.conf
├── Makefile
├── README.md # français
├── README-EN.md # anglais
├── LICENSE
├── .gitignore
└── .gitattributes
```
### 8.4 Makefile
Toutes les opérations système et d'installation passent par le Makefile, avec une cible `help` par défaut :
`install-system` (apt : mosquitto, mosquitto-clients, ffmpeg, python3-venv), `mosquitto-config`, `venv`, `install`, `install-dev`, `models` (téléchargement des modèles Whisper/Piper), `run`, `test`, `lint` (pyright), `fw-build`, `fw-upload`, `fw-monitor`, `clean`.
### 8.5 Git et secrets
- `.gitignore` : exclut `secrets.h`, `.env`, tokens (Hugging Face…), mots de passe, clés, certificats, adresses mail personnelles, modèles téléchargés, enregistrements audio et profils vocaux (données personnelles), `.venv`, `.pio`, caches.
- `.gitattributes` : normalisation LF, `*.wav`/`*.onnx`/`*.bin` en binaire.
- Seuls des fichiers `*.example` sont versionnés pour la configuration sensible.
### 8.6 Documentation
- `README.md` (français) et `README-EN.md` (anglais) maintenus à chaque étape : matériel, câblage, installation, utilisation, tests.
- Licence open source (MIT par défaut, sauf avis contraire).
### 8.7 Interdits
- Pas de Base64 pour l'audio.
- Pas de service cloud ni d'API distante.
- Pas de sur-ingénierie (pas de framework superflu, pas d'abstraction non justifiée).
- Pas de code partiel ni de « à compléter » sans le signaler explicitement.
- Ne jamais présenter l'identification vocale comme une sécurité.
---
## 9. EXEMPLES DE STYLE
### Exemple 1 — Classe C++ (en-tête)
```cpp
#pragma once
#include <Arduino.h>
#include <driver/i2s_std.h>
/**
* @brief Capture audio depuis le micro I2S INMP441.
*
* Lit des mots 32 bits, les convertit en PCM 16 bits signé
* et supprime l'offset continu.
*/
class MicCapture {
public:
MicCapture(int pinSck, int pinWs, int pinSd);
// Initialise le périphérique I2S en réception
bool begin(uint32_t sampleRate);
// Remplit le buffer avec 'count' échantillons 16 bits, retourne le nombre lu
size_t read(int16_t* buffer, size_t count);
private:
int _pinSck;
int _pinWs;
int _pinSd;
i2s_chan_handle_t _rxHandle = nullptr;
int32_t _dcOffset = 0; // estimation glissante de l'offset continu
};
```
### Exemple 2 — Classe Python
```python
import logging
import numpy as np
from faster_whisper import WhisperModel
logger = logging.getLogger(__name__)
class SpeechToText:
"""Transcrit un segment PCM 16 kHz en texte avec faster-whisper."""
def __init__(self, model_name: str = "base", language: str = "fr") -> None:
# int8 : compromis vitesse / précision adapté au Raspberry Pi
self._model = WhisperModel(model_name, device="cpu", compute_type="int8")
self._language = language
def transcribe(self, pcm: bytes) -> str:
"""Retourne le texte reconnu à partir de bytes PCM s16le."""
# Conversion int16 -> float32 normalisé dans [-1, 1]
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
segments, _ = self._model.transcribe(audio, language=self._language)
text = " ".join(segment.text.strip() for segment in segments)
logger.info("Transcription : %s", text)
return text
```
### Exemple 3 — Dimensionnement affiché dans une réponse
> Chunk de 50 ms à 16 kHz, 16 bits, mono : 16000 × 0,05 × 2 = **1600 octets**.
> Buffer anti-jitter de 300 ms en sortie : 6 chunks = **9600 octets** de RAM.
> `setBufferSize(2048)` couvre le chunk et l'en-tête MQTT.
---
## 10. DÉMARRAGE
Pour ta première réponse :
1. Présente en 10 lignes maximum la démarche globale et la distinction VAD / diarisation / identification / STT.
2. Fournis l'arborescence complète du dépôt, le `Makefile`, `.gitignore`, `.gitattributes`, `platformio.ini`, `config.h`, `secrets.h.example` et un squelette de `README.md` / `README-EN.md`.
3. Traite ensuite **uniquement l'étape 1** (entrée audio ESP32) selon le format de la section 6, avec l'outil `tools/serial_to_wav.py`.
4. Termine par la question de validation et attends la réponse avant de poursuivre.
Si une information manque (version du core Arduino-ESP32, modèle de Raspberry Pi, brochage réel), pose **une seule question ciblée** et propose une valeur par défaut raisonnable.
+12
View File
@@ -0,0 +1,12 @@
# Modèle de secrets du serveur. Copier en « .env » (ignoré par git) :
# cp server/.env.example server/.env
# Compte MQTT du serveur (étape 15), créé par « make mqtt-user NAME=voice-server ».
# Sans guillemets ; mot de passe en hexadécimal (openssl rand -hex 16) : le Makefile
# relit ce fichier pour les cibles mqtt-*, et « # » ou « $ » y seraient mal compris.
MQTT_USERNAME=
MQTT_PASSWORD=
# Jeton Hugging Face, nécessaire aux modèles pyannote (étape 8).
# À créer sur https://huggingface.co/settings/tokens — ne jamais versionner.
HF_TOKEN=
+141
View File
@@ -0,0 +1,141 @@
# Configuration du serveur vocal.
# Les valeurs sensibles (mots de passe, jetons) vont dans .env, jamais ici.
# Les sections marquées « étape N » ne sont lues qu'à partir de cette étape.
audio:
sample_rate: 16000 # Hz, identique dans tout le système
bits: 16
channels: 1
codec: pcm_s16le
chunk_ms: 50 # 800 échantillons = 1600 octets
# é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
port: 1883
keepalive: 30
client_id: voice-server # unique sur le broker
topic_prefix: voice
# étape 4 : sessions de parole reçues des ESP32
sessions:
max_seconds: 30 # au-delà, on arrête d'accumuler (garde-fou mémoire)
idle_timeout_s: 5 # plus rien reçu depuis 5 s et pas de END : on clôt
end_grace_s: 0.5 # après le END, attente des derniers chunks en vol
# chemins relatifs à ce fichier
storage:
recordings_dir: recordings
models_dir: models
logging:
level: INFO
format: "%(asctime)s %(levelname)-7s %(name)s | %(message)s"
# étape 15 : copie du journal dans un fichier tournant (3 x 1 Mo), pour
# retrouver ce qui s'est passé sur un Raspberry Pi sans écran. Vide : écran seul.
file: logs/voice-server.log
# étape 6 : transcription (modèle dans models/whisper/<model>, « make models »)
stt:
model: small # « base » sur Raspberry Pi 4, « small » sur Pi 5 / PC
language: fr
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 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.
tts:
voice: fr_FR-siwis-medium
length_scale: 1.0 # > 1 : débit plus lent
# étape 7 : détection d'activité vocale (Silero, fenêtres de 512 échantillons = 32 ms,
# imposées à 16 kHz). Une session sans parole n'est pas transcrite.
vad:
threshold: 0.5 # probabilité de parole ; on en sort sous 0,35 (hystérésis)
min_silence_ms: 500 # pause plus courte : la phrase continue
min_speech_ms: 250 # parole plus courte : bruit (clic du bouton), ignorée
speech_pad_ms: 150 # marge autour de chaque segment : attaques et fins de mots
# étape 8 : diarisation (qui parle quand), sur la seule parole trouvée par la VAD.
# Modèle soumis à conditions : les accepter sur huggingface.co, HF_TOKEN dans .env,
# puis « make models ». Ensuite, tout fonctionne hors ligne ; télémétrie pyannote coupée.
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 9 : identification du locuteur (probabiliste, jamais une sécurité).
# Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique).
# Nécessite la diarisation : les empreintes viennent de son modèle (WeSpeaker ResNet34).
speaker:
profiles_dir: profiles
similarity_threshold: 0.45 # mesuré : vous 0,52 à 0,83 contre votre profil, Piper -0,08 à 0,11
# étape 11 : domotique. Commandes publiées sur <topic_prefix>/<pièce>/<appareil>/set ;
# la carte exécute en plus celles de sa propre pièce (LED de démonstration).
home:
topic_prefix: home
rooms: # nom tel qu'on le dit → complément dans les réponses
salon: du salon
cuisine: de la cuisine
chambre: de la chambre
salle de bain: de la salle de bain
bureau: du bureau
garage: du garage
entrée: de l'entrée
boards: # pièce de chaque carte : « allume la lumière » sans pièce = ici
esp32-01: salon
# étape 13 : mot de réveil, mains libres. Le serveur demande à chaque carte de lui envoyer
# son micro en continu (voice/<carte>/audio/stream) et guette le mot. Rien n'est écrit
# sur disque avant le mot : seules les dernières preroll_ms restent en mémoire.
# Prononciation à l'anglaise : « Alexa », « Hé Maïcrofte », « Hey Djâr-viss ».
wake:
enabled: true
words: [alexa, hey_jarvis, hey_mycroft] # models/openwakeword/<mot>_v0.1.onnx
threshold: 0.5 # mesuré : 90 enregistrements sans le mot, score max 0,07
# Seuil propre à un mot, si le journal montre des « mot presque reconnu » répétés.
# Plus bas = plus sensible, mais plus de déclenchements intempestifs. Ex. : {hey_jarvis: 0.35}
thresholds: {}
preroll_ms: 500 # la commande suit souvent le mot sans pause
end_silence_ms: 800 # silence qui clôt la commande
no_speech_timeout_s: 4 # mot entendu mais rien dit ensuite : on abandonne
max_command_s: 10
cooldown_s: 20 # écoute suspendue pendant la réponse (jusqu'au bilan « played »)
# étape 14 : droits par profil. La voix n'est JAMAIS une preuve d'identité : une
# imitation ou un enregistrement peut tromper. Niveaux : complet > standard > limite.
access:
unknown: limite # voix non reconnue : conversation seulement
default: standard # profil enrôlé absent de users
users: # noms des profils (make profiles)
Denis: complet
full_min_score: 0.6 # « complet » seulement si la voix est reconnue nettement
rules: # niveau minimal par appareil et action ; absent = complet
light: {"on": standard, "off": standard} # guillemets : on/off nus = booléens en YAML
shutter: {open: standard, close: standard}
door: {open: complet, close: standard}
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
# é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
# 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é).
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
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}
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. ».
names: [pc du bureau, p c du bureau, ordinateur du bureau]
name: le PC du bureau
mac: "" # ex. "aa:bb:cc:dd:ee:ff" (ip link) pour « allume le PC du bureau »
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "voice-server"
version = "0.1.0"
description = "Serveur d'assistant vocal local (MQTT, faster-whisper, Piper)"
requires-python = ">=3.11"
license = { text = "MIT" }
# Dépendances épinglées : indispensables dès l'étape 1 (outils série) et l'étape 3 (MQTT).
dependencies = [
"pyserial==3.5",
"paho-mqtt==2.1.0",
"numpy==2.5.3", # >= 2.2.2 exigé par pyannote 4 (étape 8) ; testé avec toute la chaîne
"PyYAML==6.0.2",
"python-dotenv==1.0.1",
"soxr==0.5.0.post1",
]
[project.optional-dependencies]
# Installées à l'étape 6 (transcription et synthèse vocale).
# Versions minimales pour Python 3.13 : faster-whisper 1.0 exige av<13 (compilé
# depuis les sources, sans roue 3.13) et piper-tts 1.2 dépend de piper-phonemize,
# sans roue au-delà de Python 3.11. piper-tts 1.3+ embarque espeak-ng.
speech = [
"faster-whisper==1.2.1",
"piper-tts==1.8.0",
]
# Installées à l'étape 8 (diarisation). La VAD de l'étape 7 n'en a pas besoin :
# Silero est livré avec faster-whisper. torch en version CPU : « make install-diarization »
# passe par l'index de pytorch.org, sinon pip installe plusieurs Go de bibliothèques CUDA.
# pyannote 3.3 est antérieur à ce torch ; pyannote 4 exige numpy >= 2.2.2.
diarization = [
"torch==2.14.0",
"torchaudio==2.11.0",
"pyannote.audio==4.0.7",
]
# Étape 13 (mot de réveil) : dépendances d'openwakeword en mode ONNX. openwakeword 0.6.0
# lui-même s'installe SANS ses dépendances (« make install-wakeword ») : il exige
# tflite-runtime, qui n'existe pas pour Python 3.13 et ne sert pas en ONNX.
wakeword = [
"onnxruntime==1.30.0",
"scipy==1.18.1",
"scikit-learn==1.9.1",
"tqdm==4.70.1",
"requests==2.34.2",
]
dev = [
"pyright==1.1.389",
"pytest==8.3.4",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["voice_server*"]
[tool.pytest.ini_options]
# Les tests importent voice_server depuis server/, sans installation préalable.
pythonpath = ["."]
testpaths = ["tests"]
+23
View File
@@ -0,0 +1,23 @@
{
"include": [
"voice_server",
"tests",
"../tools",
"../agent"
],
"exclude": [
"**/__pycache__",
".venv",
"models"
],
"extraPaths": [
"../tools",
"../agent"
],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.11",
"typeCheckingMode": "standard",
"reportMissingTypeStubs": false,
"reportMissingModuleSource": false
}
+160
View File
@@ -0,0 +1,160 @@
"""Tests des droits par profil, étape 14 : niveaux, refus, confirmation (sans modèle)."""
from __future__ import annotations
from pathlib import Path
import pytest
from voice_server.access_control import AccessPolicy, Level, parse_level
from voice_server.home_control import DeviceCommand
from voice_server.settings import AccessSettings, HomeSettings, load_settings
HOME = HomeSettings(topic_prefix="home", rooms={"salon": "du salon"}, boards={"esp32-01": "salon"})
ACCESS = AccessSettings(
unknown_level="limite", default_level="standard", users={"Denis": "complet", "Tom": "limite"},
full_min_score=0.6,
rules={"light": {"on": "standard", "off": "standard"}, "door": {"open": "complet", "close": "standard"}},
confirm=("door.open",), confirm_timeout_s=15.0,
)
LIGHT_ON = DeviceCommand("on", "light", "salon")
DOOR_OPEN = DeviceCommand("open", "door", "salon")
class Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
@pytest.fixture
def policy(clock: Clock) -> AccessPolicy:
return AccessPolicy(ACCESS, HOME, clock=clock)
@pytest.mark.parametrize(
("speaker", "score", "expected"),
[
(None, None, Level.LIMITED), # voix inconnue
("Denis", 0.74, Level.FULL),
("Denis", 0.52, Level.STANDARD), # reconnu de justesse : pas le niveau complet
("Jessica", 0.9, Level.STANDARD), # enrôlée sans niveau : défaut
("Tom", 0.95, Level.LIMITED),
],
)
def test_levels(policy: AccessPolicy, speaker: str | None, score: float | None, expected: Level) -> None:
assert policy.level(speaker, score) == expected
def test_unknown_level_name_is_rejected() -> None:
with pytest.raises(ValueError, match="complet, standard ou limite"):
parse_level("admin")
def test_unknown_voice_cannot_command(policy: AccessPolicy) -> None:
verdict = policy.check(LIGHT_ON, None, None)
assert not verdict.allowed
assert verdict.answer == "Je ne vous ai pas reconnu : je ne peux pas allumer la lumière du salon."
def test_standard_profile_controls_lights_not_the_door(policy: AccessPolicy) -> None:
assert policy.check(LIGHT_ON, "Jessica", 0.9).allowed
refused = policy.check(DOOR_OPEN, "Jessica", 0.9)
assert not refused.allowed and refused.answer is not None and refused.answer.startswith("Désolé Jessica")
def test_action_missing_from_rules_needs_full_level(policy: AccessPolicy) -> None:
"""On ne permet jamais par oubli : sans règle, seul le niveau complet passe."""
shutter = DeviceCommand("open", "shutter", "salon")
assert not policy.check(shutter, "Jessica", 0.9).allowed
assert policy.check(shutter, "Denis", 0.8).allowed
def test_sensitive_action_asks_for_confirmation(policy: AccessPolicy) -> None:
verdict = policy.check(DOOR_OPEN, "Denis", 0.8)
assert verdict.allowed and verdict.confirm
assert verdict.answer == "Confirmez-vous : ouvrir la porte du salon ? Dites oui ou non."
def test_yes_from_the_same_person_executes(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Oui, vas-y.", "Denis")
assert resolution is not None and resolution.command == DOOR_OPEN
assert resolution.answer == "J'ouvre la porte du salon."
assert policy.resolve("esp32-01", "oui", "Denis") is None # une confirmation ne sert qu'une fois
def test_no_cancels(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Non, annule.", "Denis")
assert resolution is not None and resolution.command is None
def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Oui", "Jessica")
assert resolution is not None and resolution.command is None
assert resolution.answer.startswith("Seul Denis")
def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
clock.now += 16
assert policy.resolve("esp32-01", "oui", "Denis") is None
def test_other_request_drops_the_pending_one(policy: AccessPolicy) -> None:
"""Autre chose que oui/non : la demande en attente est oubliée, la nouvelle est traitée."""
policy.ask("esp32-01", DOOR_OPEN, "Denis")
assert policy.resolve("esp32-01", "Quelle heure est-il ?", "Denis") is None
assert policy.resolve("esp32-01", "oui", "Denis") is None
def test_project_config_is_valid() -> None:
"""Le config.yaml du dépôt se charge, et ses règles s'appliquent vraiment (profil standard)."""
settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
policy = AccessPolicy(settings.access, settings.home, settings.machines)
for action in ("on", "off"): # on/off nus en YAML = booléens : Pierre refusé à l'essai réel
assert policy.check(DeviceCommand(action, "light", "salon"), "Pierre", 0.8).allowed
assert policy.check(DeviceCommand("close", "shutter", "salon"), "Pierre", 0.8).allowed
assert not policy.check(DOOR_OPEN, "Pierre", 0.8).allowed
def test_yaml_boolean_keys_are_read_as_on_off(tmp_path: Path) -> None:
"""« light: {on: standard} » sans guillemets : YAML lit True, le chargeur rend « on »."""
text = (Path(__file__).resolve().parents[1] / "config.yaml").read_text(encoding="utf-8")
config = tmp_path / "config.yaml"
config.write_text(text.replace('{"on": standard, "off": standard}', "{on: standard, off: standard}"),
encoding="utf-8")
assert load_settings(config).access.rules["light"] == {"on": "standard", "off": "standard"}
def test_unknown_rule_action_is_rejected() -> None:
"""Une règle mal écrite est refusée au démarrage plutôt qu'ignorée en silence."""
access = AccessSettings(**{**ACCESS.__dict__, "rules": {"light": {"True": "standard"}}})
with pytest.raises(ValueError, match="action inconnue light.True"):
AccessPolicy(access, HOME)
def test_barely_recognised_full_profile_is_asked_to_repeat(policy: AccessPolicy) -> None:
"""Denis reconnu à 0,59 : son profil permettrait, c'est la voix qui n'est pas assez sûre."""
verdict = policy.check(DOOR_OPEN, "Denis", 0.59)
assert not verdict.allowed
assert verdict.answer == ("Denis, je ne reconnais pas assez nettement votre voix pour ouvrir "
"la porte du salon. Répétez, plus près du micro.")
def test_elision_in_refusal(policy: AccessPolicy) -> None:
"""« ne permet pas d'ouvrir », pas « de ouvrir »."""
answer = policy.check(DOOR_OPEN, "Jessica", 0.9).answer
assert answer == "Désolé Jessica, votre profil ne permet pas d'ouvrir la porte du salon."
close = policy.check(DeviceCommand("close", "door", "salon"), "Tom", 0.9).answer
assert close == "Désolé Tom, votre profil ne permet pas de fermer la porte du salon."
+83
View File
@@ -0,0 +1,83 @@
"""Tests de l'assistant à règles, étape 6 : réponses connues d'avance."""
from __future__ import annotations
from datetime import datetime
import pytest
from voice_server.assistant import NOTHING_HEARD, Assistant, normalize, say_date, say_time
NOW = datetime(2026, 9, 29, 19, 5) # un mardi
@pytest.fixture
def assistant() -> Assistant:
return Assistant(clock=lambda: NOW)
def test_normalize() -> None:
"""Minuscules, accents et ponctuation retirés."""
assert normalize(" Quelle heure est-il ?") == "quelle heure est il"
assert normalize("Ça marche, merci !") == "ca marche merci"
@pytest.mark.parametrize(
("moment", "expected"),
[
(datetime(2026, 1, 1, 0, 0), "Il est minuit."),
(datetime(2026, 1, 1, 1, 0), "Il est une heure."),
(datetime(2026, 1, 1, 12, 30), "Il est midi 30."),
(datetime(2026, 1, 1, 19, 5), "Il est 19 heures 5."),
],
)
def test_say_time(moment: datetime, expected: str) -> None:
"""L'heure est écrite pour être bien lue par Piper."""
assert say_time(moment) == expected
def test_say_date() -> None:
"""« premier » pour le 1er du mois, jour de la semaine en toutes lettres."""
assert say_date(datetime(2026, 10, 1)) == "Nous sommes le jeudi premier octobre 2026."
assert say_date(NOW) == "Nous sommes le mardi 29 septembre 2026."
@pytest.mark.parametrize(
("heard", "expected"),
[
("Quelle heure est-il ?", "Il est 19 heures 5."),
("Quel heure est-il ?", "Il est 19 heures 5."), # faute fréquente de Whisper
("Bonjour, quelle heure est-il ?", "Il est 19 heures 5."), # la règle précise gagne
("On est quel jour ?", "Nous sommes le mardi 29 septembre 2026."),
("Salut !", "Bonjour ! Que puis-je faire pour vous ?"),
("Merci beaucoup.", "Avec plaisir."),
("Allume la lumière.", "Vous avez dit : Allume la lumière."),
],
)
def test_rules(assistant: Assistant, heard: str, expected: str) -> None:
"""Chaque règle répond, sinon la phrase est répétée."""
assert assistant.reply(heard) == expected
def test_whole_words_only(assistant: Assistant) -> None:
"""« salutations » ne déclenche pas la règle de « salut »."""
assert assistant.reply("Salutations distinguées") == "Vous avez dit : Salutations distinguées"
@pytest.mark.parametrize("heard", ["", " ", "?!"])
def test_nothing_heard(assistant: Assistant, heard: str) -> None:
"""Rien d'exploitable : l'assistant le dit plutôt que de se taire."""
assert assistant.reply(heard) == NOTHING_HEARD
def test_known_speaker_is_greeted_by_name(assistant: Assistant) -> None:
"""Étape 10 : l'assistant sait qui parle."""
assert assistant.reply("Bonjour", "Denis") == "Bonjour Denis ! Que puis-je faire pour vous ?"
assert assistant.reply("Merci", "Denis") == "Avec plaisir, Denis."
assert assistant.reply("Bonjour", None) == "Bonjour ! Que puis-je faire pour vous ?"
assert assistant.reply("Quelle heure est-il ?", "Denis") == "Il est 19 heures 5."
def test_weather_is_honest(assistant: Assistant) -> None:
"""Étape 11 : sans Internet, l'assistant le dit plutôt que de répéter la question."""
assert "sans Internet" in assistant.reply("Quel temps fera-t-il demain ?")
+157
View File
@@ -0,0 +1,157 @@
"""Tests du chemin de sortie, étape 5 : mise au format et envoi rythmé (sans broker)."""
from __future__ import annotations
import json
import wave
from pathlib import Path
import numpy as np
import pytest
from voice_server.audio_resampler import AudioFormatError, AudioResampler, peak_dbfs
from voice_server.audio_sender import AudioSender, send_times
from voice_server.settings import AudioSettings
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
def write_wav(path: Path, samples: np.ndarray, rate: int, width: int) -> None:
"""Écrit un WAV PCM entier de largeur donnée (1 à 4 octets) à partir de flottants."""
channels = 1 if samples.ndim == 1 else samples.shape[1]
scale = {1: 127, 2: 32767, 3: 8388607, 4: 2147483647}[width]
ints = np.round(samples.reshape(-1) * scale).astype(np.int64)
if width == 1:
raw = (ints + 128).astype(np.uint8).tobytes()
else:
raw = b"".join(int(v).to_bytes(width, "little", signed=True) for v in ints)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(width)
wav.setframerate(rate)
wav.writeframes(raw)
def sine(rate: int, seconds: float, amplitude: float = 0.1) -> np.ndarray:
t = np.arange(int(rate * seconds)) / rate
return amplitude * np.sin(2 * np.pi * 440 * t)
@pytest.mark.parametrize("width", [1, 2, 3, 4])
def test_any_pcm_width_is_read(tmp_path: Path, width: int) -> None:
"""WAV 8, 16, 24 et 32 bits : lus, mis en 16 kHz, crête à -3 dBFS."""
path = tmp_path / f"w{width}.wav"
write_wav(path, sine(16000, 0.5), 16000, width)
pcm = AudioResampler(AUDIO).from_wav(path)
assert len(pcm) == 16000 # 0,5 s x 16 000 x 2 octets
assert peak_dbfs(pcm) == pytest.approx(-3.0, abs=0.1)
def test_cd_stereo_is_converted_to_system_format(tmp_path: Path) -> None:
"""44,1 kHz stéréo -> 16 kHz mono ; durée conservée."""
path = tmp_path / "cd.wav"
stereo = np.stack([sine(44100, 1.0), sine(44100, 1.0)], axis=1)
write_wav(path, stereo, 44100, 2)
pcm = AudioResampler(AUDIO).from_wav(path)
assert len(pcm) // 2 == pytest.approx(16000, abs=2)
def test_piper_rate_is_resampled(tmp_path: Path) -> None:
"""22 050 Hz (sortie habituelle de Piper, étape 6) -> 16 kHz, fréquence conservée."""
pcm = AudioResampler(AUDIO).convert(sine(22050, 1.0), 22050)
samples = np.frombuffer(pcm, dtype="<i2").astype(np.float64)
spectrum = np.abs(np.fft.rfft(samples))
dominant = np.fft.rfftfreq(samples.size, 1 / 16000)[int(np.argmax(spectrum))]
assert dominant == pytest.approx(440.0, abs=2.0)
def test_normalization_can_be_disabled() -> None:
"""Sans normalisation, le niveau d'origine est gardé (-20 dBFS ici)."""
pcm = AudioResampler(AUDIO, target_peak_dbfs=None).convert(sine(16000, 0.5, 0.1), 16000)
assert peak_dbfs(pcm) == pytest.approx(-20.0, abs=0.1)
def test_unreadable_file_suggests_conversion(tmp_path: Path) -> None:
"""Un fichier qui n'est pas un WAV PCM : message avec la commande ffmpeg."""
path = tmp_path / "faux.wav"
path.write_bytes(b"RIFF....WAVEfmt pas vraiment")
with pytest.raises(AudioFormatError, match="ffmpeg"):
AudioResampler(AUDIO).from_wav(path)
class FakeLink:
"""Lien MQTT factice : mémorise les publications et l'instant de chacune."""
def __init__(self, clock: "FakeTime") -> None:
self.clock = clock
self.sent: list[tuple[float, str, bytes, int]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.sent.append((self.clock.now, suffix, payload, qos))
class FakeTime:
"""Horloge et sommeil simulés : le test dure 0 s quelle que soit la durée du son."""
def __init__(self) -> None:
self.now = 0.0
def clock(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.now += seconds
def test_sender_protocol_and_pacing() -> None:
"""START (QoS 1), 10 chunks (QoS 0) dont 6 d'avance, END annonçant 10 chunks."""
time = FakeTime()
link = FakeLink(time)
sender = AudioSender(link, AUDIO, prefill_chunks=6, sleep=time.sleep, clock=time.clock)
session = sender.send("esp32-01", bytes(1600 * 9 + 801)) # 9 chunks pleins + 1 partiel
kinds = [(suffix, qos) for _, suffix, _, qos in link.sent]
assert kinds[0] == ("control", 1) and kinds[-1] == ("control", 1)
audio = [(t, payload) for t, suffix, payload, _ in link.sent if suffix == "audio/out"]
assert [len(p) for _, p in audio] == [1600] * 9 + [800] # l'octet orphelin est retiré
assert [t for t, _ in audio[:6]] == [0.0] * 6 # l'avance part d'un coup
assert [round(t, 3) for t, _ in audio[6:]] == [0.05, 0.1, 0.15, 0.2]
start = json.loads(link.sent[0][2])
end = json.loads(link.sent[-1][2])
assert (start["event"], start["rate"], start["session"]) == ("start", 16000, session)
assert (end["event"], end["chunks"], end["session"]) == ("end", 10, session)
def test_send_times_matches_serial_tool() -> None:
"""Même calendrier que l'outil série de l'étape 2."""
assert send_times(5, 0.05, 2) == pytest.approx([0.0, 0.0, 0.05, 0.10, 0.15])
def test_jitter_bench_holds_one_chunk_in_ten() -> None:
"""Étape 12 : le banc retient un chunk audio sur dix, jamais START/END."""
from wav_to_mqtt import JitteryLink
class Sink:
def __init__(self) -> None:
self.topics: list[str] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.topics.append(suffix)
sink = Sink()
bench = JitteryLink(sink, stall_ms=1.0)
bench.publish("esp32-01", "control", b"{}", 1)
for _ in range(25):
bench.publish("esp32-01", "audio/out", bytes(1600), 0)
assert bench.stalls == 2 # chunks 10 et 20
assert len(sink.topics) == 26 # rien n'est perdu, seulement retardé
+75
View File
@@ -0,0 +1,75 @@
"""Tests de la diarisation, étape 8 : présentation, télémétrie, modèle réel s'il est présent."""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import pytest
from voice_server.diarization import Diarization, Diarizer, SpeakerTurn, disable_telemetry, timeline
from voice_server.settings import DiarizationSettings, load_settings
SETTINGS = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
def two_speakers() -> Diarization:
return Diarization(
turns=(SpeakerTurn("SPEAKER_00", 0.0, 0.5), SpeakerTurn("SPEAKER_01", 0.6, 1.0)),
speakers=("SPEAKER_00", "SPEAKER_01"),
embeddings=None, audio_s=1.0, elapsed_s=0.2,
)
def test_describe() -> None:
assert two_speakers().describe() == "SPEAKER_00 0.00-0.50, SPEAKER_01 0.60-1.00"
def test_timeline_one_row_per_speaker() -> None:
assert timeline(two_speakers()) == ["SPEAKER_00 #####_____", "SPEAKER_01 ______####"]
def test_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""pyannote 4 envoie des statistiques par défaut : jamais dans ce projet."""
monkeypatch.setenv("PYANNOTE_METRICS_ENABLED", "true")
disable_telemetry()
assert os.environ["PYANNOTE_METRICS_ENABLED"] == "false"
def test_missing_model_says_what_to_do(tmp_path: Path) -> None:
settings = DiarizationSettings(enabled=True, model="x/y", max_speakers=3, model_dir=tmp_path / "y")
with pytest.raises(FileNotFoundError, match="make models"):
Diarizer(settings, 16000)
@pytest.mark.skipif(not (SETTINGS.diarization.model_dir / "config.yaml").exists(),
reason="modele de diarisation absent : HF_TOKEN puis 'make models'")
def test_real_pipeline_runs_offline_without_telemetry() -> None:
"""Le vrai pipeline se charge du dossier local et ne trouve personne dans le silence."""
pytest.importorskip("pyannote.audio")
diarizer = Diarizer(SETTINGS.diarization, 16000)
result = diarizer.diarize(np.zeros(32000, dtype="<i2").tobytes())
assert result.turns == ()
assert os.environ["PYANNOTE_METRICS_ENABLED"] == "false"
@pytest.mark.skipif(not (SETTINGS.diarization.model_dir / "config.yaml").exists(),
reason="modele de diarisation absent : HF_TOKEN puis 'make models'")
def test_embeddings_exist_for_a_short_question() -> None:
"""Une question de 2 s a une empreinte (celles de pyannote seraient NaN sous 2 s de parole)."""
pytest.importorskip("pyannote.audio")
from voice_server.audio_resampler import AudioResampler
from voice_server.text_to_speech import TextToSpeech
tts = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio, -10.0), 16000)
diarizer = Diarizer(SETTINGS.diarization, 16000)
first = diarizer.diarize(tts.synthesize("Quelle heure est-il ?").pcm)
second = diarizer.embed(tts.synthesize("Fermez la porte du garage.").pcm)
assert first.embeddings is not None and first.embeddings.shape == (1, 256)
assert np.all(np.isfinite(first.embeddings))
cosine = float(first.embeddings[0] @ second / np.linalg.norm(first.embeddings[0]) / np.linalg.norm(second))
assert cosine > 0.45 # même voix Piper, deux phrases
+203
View File
@@ -0,0 +1,203 @@
"""Tests de l'écoute mains libres, étape 13 : états, vie privée, fin de commande (sans modèle)."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from voice_server.hands_free import HandsFreeListener, Mode
from voice_server.session_manager import SessionResult
from voice_server.settings import AudioSettings, WakeSettings
from voice_server.voice_activity import Segment, VadResult
from voice_server.wake_word import Detection
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
WAKE = WakeSettings(enabled=True, words=("alexa",), threshold=0.5, thresholds={}, preroll_ms=500, end_silence_ms=800,
no_speech_timeout_s=4.0, max_command_s=10.0, cooldown_s=20.0, model_dir=Path("inutile"))
SILENCE = bytes(1600)
SPEECH = np.full(800, 3000, dtype="<i2").tobytes()
WAKE_WORD = np.full(800, 3001, dtype="<i2").tobytes() # le faux détecteur réagit à cette valeur
class FakeDetector:
def __init__(self) -> None:
self.resets = 0
def process(self, pcm: bytes) -> Detection | None:
return Detection("alexa", 0.9) if pcm == WAKE_WORD else None
def reset(self) -> None:
self.resets += 1
class ChunkVad:
"""VAD de test : un chunk non nul est de la parole."""
def detect(self, pcm: bytes) -> VadResult:
samples = np.frombuffer(pcm, dtype="<i2")
segments: list[Segment] = []
for start in range(0, samples.size, 800):
if np.any(samples[start:start + 800]):
if segments and segments[-1].end == start:
segments[-1] = Segment(segments[-1].start, start + 800)
else:
segments.append(Segment(start, start + 800))
return VadResult(segments=tuple(segments), speech_pcm=b"", probabilities=np.zeros(0),
total_s=samples.size / 16000, speech_s=0.0, elapsed_s=0.0, sample_rate=16000)
class FakeLink:
def __init__(self) -> None:
self.controls: list[dict[str, object]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
assert suffix == "control"
self.controls.append(json.loads(payload))
class Clock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
class Harness:
def __init__(self, tmp_path: Path, clock: Clock) -> None:
self.link = FakeLink()
self.commands: list[SessionResult] = []
self.detectors: list[FakeDetector] = []
self.clock = clock
self.recordings = tmp_path / "recordings"
def factory(device: str) -> FakeDetector:
self.detectors.append(FakeDetector())
return self.detectors[-1]
self.listener = HandsFreeListener(WAKE, AUDIO, ChunkVad(), self.recordings, self.link,
self.commands.append, factory, clock=clock)
def feed(self, chunk: bytes, count: int = 1) -> None:
for _ in range(count):
self.listener.on_stream("esp32-01", chunk)
self.clock.now += 0.05 # un chunk = 50 ms
@pytest.fixture
def h(tmp_path: Path, clock: Clock) -> Harness:
return Harness(tmp_path, clock)
def test_standby_never_writes_anything(h: Harness) -> None:
"""Vie privée : sans mot de réveil, rien sur disque, rien transmis, mémoire bornée à 500 ms."""
h.feed(SPEECH, 200) # 10 s de conversation sans le mot
assert h.commands == []
assert not h.recordings.exists()
assert h.listener.mode("esp32-01") is Mode.STANDBY
def test_wake_then_command_becomes_a_session(h: Harness) -> None:
"""Mot, commande, silence : une session, avec les 500 ms qui précèdent le mot."""
h.feed(SILENCE, 20)
h.feed(WAKE_WORD)
h.feed(SPEECH, 30) # 1,5 s de commande
h.feed(SILENCE, 25) # 1,25 s de silence : > 800 ms
[result] = h.commands
assert result.session_id.startswith("w")
assert result.path.exists()
assert 2.0 < result.duration_s < 3.5
assert h.link.controls == [{"cmd": "capture", "active": True}, {"cmd": "capture", "active": False}]
assert h.listener.mode("esp32-01") is Mode.ANSWER
def test_wake_without_command_goes_back_to_standby(h: Harness) -> None:
"""Mot seul : abandon après 4 s, rien écrit."""
h.feed(WAKE_WORD)
h.feed(SILENCE, 90) # 4,5 s
assert h.commands == [] and not h.recordings.exists()
assert h.listener.mode("esp32-01") is Mode.STANDBY
assert h.link.controls[-1] == {"cmd": "capture", "active": False}
def test_command_is_cut_at_max_duration(h: Harness) -> None:
h.feed(WAKE_WORD)
h.feed(SPEECH, 220) # 11 s sans silence
assert len(h.commands) == 1
def test_answer_is_not_heard_and_played_resumes(h: Harness) -> None:
"""Pendant la réponse, le micro est ignoré ; le bilan « played » relance la veille."""
h.feed(WAKE_WORD)
h.feed(SPEECH, 10)
h.feed(SILENCE, 20)
h.feed(WAKE_WORD, 5) # la carte « s'entend » : ignoré
assert len(h.commands) == 1
h.listener.on_event("esp32-01", b'{"event":"played","session":"rep-w1"}')
assert h.listener.mode("esp32-01") is Mode.STANDBY
assert h.detectors[0].resets == 1 # historique du mot précédent effacé
def test_no_played_report_times_out(h: Harness) -> None:
h.feed(WAKE_WORD)
h.feed(SPEECH, 10)
h.feed(SILENCE, 20)
h.clock.now += 21
h.listener.sweep()
assert h.listener.mode("esp32-01") is Mode.STANDBY
def test_listen_is_sent_once_per_boot(h: Harness) -> None:
"""Écoute activée à la connexion et après un redémarrage, pas à chaque état périodique."""
state = '{{"status":"online","uptime_s":{}}}'
h.listener.on_state("esp32-01", state.format(5).encode())
h.listener.on_state("esp32-01", state.format(35).encode()) # état périodique
h.listener.on_state("esp32-01", state.format(3).encode()) # redémarrage
assert h.link.controls == [{"cmd": "listen", "enabled": True}] * 2
h.listener.stop()
assert h.link.controls[-1] == {"cmd": "listen", "enabled": False}
def test_each_board_has_its_own_detector(h: Harness) -> None:
"""Deux cartes : deux historiques, jamais mélangés."""
h.listener.on_stream("esp32-01", SILENCE)
h.listener.on_stream("esp32-02", SILENCE)
assert len(h.detectors) == 2
def test_burst_delivery_is_judged_on_audio_time(h: Harness) -> None:
"""Wi-Fi en rafale : 2,5 s de son arrivées en même temps donnent la même décision."""
h.listener.on_stream("esp32-01", WAKE_WORD)
for chunk in [SPEECH] * 30 + [SILENCE] * 25: # aucun temps ne s'écoule à l'horloge
h.listener.on_stream("esp32-01", chunk)
assert len(h.commands) == 1
def test_stream_lost_during_command_is_closed(h: Harness) -> None:
"""La carte décroche en pleine commande : on traite ce qu'on a après 2 s sans chunk."""
h.feed(WAKE_WORD)
h.feed(SPEECH, 10)
h.clock.now += 3
h.listener.sweep()
assert len(h.commands) == 1
+108
View File
@@ -0,0 +1,108 @@
"""Tests de la domotique, étape 11 : phrase → intention → commande → topics (sans broker)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from voice_server.assistant import Assistant
from voice_server.home_control import DeviceCommand, DeviceIntent, HomeController, decide, parse_intent, slug
from voice_server.settings import HomeSettings, load_settings
HOME = HomeSettings(topic_prefix="home",
rooms={"salon": "du salon", "cuisine": "de la cuisine", "salle de bain": "de la salle de bain"},
boards={"esp32-01": "salon"})
ROOMS = list(HOME.rooms)
@pytest.mark.parametrize(
("text", "expected"),
[
("Fermez les volets du salon.", DeviceIntent("close", "shutter", "salon")),
("fermé la porte", DeviceIntent("close", "door", None)), # faute de Whisper
("Peux-tu allumer la lumière de la cuisine ?", DeviceIntent("on", "light", "cuisine")),
("Éteins les lampes", DeviceIntent("off", "light", None)),
("Baisse les stores de la salle de bain", DeviceIntent("close", "shutter", "salle de bain")),
("Quelle heure est-il ?", None),
],
)
def test_parse_intent(text: str, expected: DeviceIntent | None) -> None:
assert parse_intent(text, ROOMS) == expected
@pytest.mark.parametrize(
("intent", "answer", "command"),
[
(DeviceIntent("close", "shutter", "cuisine"), "Je ferme les volets de la cuisine.",
DeviceCommand("close", "shutter", "cuisine")),
(DeviceIntent("on", None, None), "J'allume la lumière du salon.", # « allume » : lumière, ici
DeviceCommand("on", "light", "salon")),
(DeviceIntent("open", None, None), "Que dois-je ouvrir ?", None),
(DeviceIntent(None, "door", None), "Que dois-je faire avec la porte ?", None),
(DeviceIntent("on", "shutter", None), "Je ne sais pas allumer les volets.", None),
],
)
def test_decide(intent: DeviceIntent, answer: str, command: DeviceCommand | None) -> None:
decision = decide(intent, HOME, "esp32-01")
assert (decision.answer, decision.command) == (answer, command)
def test_unknown_board_needs_a_room() -> None:
"""Une carte sans pièce déclarée ne devine pas : elle demande."""
assert decide(DeviceIntent("on", "light", None), HOME, "esp32-99").answer == "Dans quelle pièce ?"
def test_slug() -> None:
assert slug("salle de bain") == "salle-de-bain"
assert slug("entrée") == "entree"
class FakeLink:
def __init__(self) -> None:
self.sent: list[tuple[str, bytes, int]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.sent.append((f"voice/{device}/{suffix}", payload, qos))
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
self.sent.append((topic, payload, qos))
def test_command_for_the_board_room_also_goes_to_the_board() -> None:
"""Pièce de la carte : domotique ET carte (LED de démonstration)."""
link = FakeLink()
HomeController(HOME, link).execute("esp32-01", DeviceCommand("on", "light", "salon"), "Denis", "a1b2c3")
[(topic, payload, qos), (local_topic, local, local_qos)] = link.sent
assert (topic, qos) == ("home/salon/light/set", 1)
assert json.loads(payload) == {"action": "on", "by": "Denis", "board": "esp32-01", "session": "a1b2c3"}
assert (local_topic, local_qos) == ("voice/esp32-01/control", 1)
assert json.loads(local) == {"cmd": "device", "device": "light", "action": "on"}
def test_command_for_another_room_only_goes_to_home() -> None:
link = FakeLink()
HomeController(HOME, link).execute("esp32-01", DeviceCommand("close", "shutter", "salle de bain"), None, "s")
assert [topic for topic, _, _ in link.sent] == ["home/salle-de-bain/shutter/set"]
assert json.loads(link.sent[0][1])["by"] is None
def test_assistant_prefers_home_commands_then_chat() -> None:
"""Domotique d'abord ; le reste suit les règles de conversation."""
assistant = Assistant(home=HOME)
order = assistant.respond("Allume la lumière", "Denis", "esp32-01")
chat = assistant.respond("Bonjour", "Denis", "esp32-01")
assert order.command == DeviceCommand("on", "light", "salon") and order.text == "J'allume la lumière du salon."
assert chat.command is None and chat.text.startswith("Bonjour Denis")
assert Assistant().respond("Allume la lumière").command is None # sans config : pas de domotique
def test_project_config_rooms_and_boards() -> None:
"""Le config.yaml du dépôt déclare la pièce de la carte parmi les pièces connues."""
home = load_settings(Path(__file__).resolve().parents[1] / "config.yaml").home
assert home.boards["esp32-01"] in home.rooms
+186
View File
@@ -0,0 +1,186 @@
"""Tests des machines Linux, étape 15b : phrases, décisions, droits, Wake-on-LAN (sans réseau)."""
from __future__ import annotations
import dataclasses
import json
from datetime import datetime
from pathlib import Path
import paho.mqtt.client as mqtt
import pytest
from voice_server.access_control import AccessPolicy
from voice_server.assistant import Assistant
from voice_server.command_router import CommandRouter
from voice_server.home_control import DeviceCommand, HomeController
from voice_server.machine_control import (
AgentStatus,
MachineCommand,
MachineController,
MachineIntent,
decide_machine,
magic_packet,
parse_machine_intent,
)
from voice_server.mqtt_link import MqttLink
from voice_server.settings import MachinesSettings, load_settings
SETTINGS = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
MACHINES = SETTINGS.machines
ONLINE = AgentStatus(online=True, actions=("lock", "reboot", "shutdown"))
ONLINE_PAYLOAD = json.dumps({"status": "online", "actions": ["lock", "reboot", "shutdown"]}).encode()
MAC = "aa:bb:cc:dd:ee:ff"
class FakeLink:
def __init__(self) -> None:
self.topics: list[tuple[str, dict[str, object]]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.topics.append((f"voice/{device}/{suffix}", json.loads(payload)))
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
self.topics.append((topic, json.loads(payload)))
def with_mac(mac: str = MAC) -> MachinesSettings:
"""Machines de config.yaml, avec une adresse MAC : le réveil devient possible."""
machine = dataclasses.replace(MACHINES.machines["pc-bureau"], mac=mac)
return dataclasses.replace(MACHINES, machines={"pc-bureau": machine})
@pytest.mark.parametrize(
("text", "action"),
[
("Éteins le PC du bureau.", "shutdown"),
("Éteins le P.C. du bureau.", "shutdown"),
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
("Verrouille le PC du bureau", "lock"),
("Allume le PC du bureau", "wake"),
("Le PC du bureau", None),
],
)
def test_phrases_naming_a_machine(text: str, action: str | None) -> None:
assert parse_machine_intent(text, MACHINES) == MachineIntent("pc-bureau", action)
def test_room_alone_is_not_a_machine() -> None:
"""« bureau » est une pièce : seule « PC du bureau » désigne la machine."""
assert parse_machine_intent("Éteins la lumière du bureau", MACHINES) is None
def test_command_carries_its_sentences() -> None:
decision = decide_machine(MachineIntent("pc-bureau", "shutdown"), MACHINES, ONLINE)
assert decision.command == MachineCommand("shutdown", "pc-bureau", said="J'éteins le PC du bureau.",
asked="éteindre le PC du bureau")
assert decision.answer == "J'éteins le PC du bureau."
@pytest.mark.parametrize(
("action", "status", "answer"),
[
(None, ONLINE, "Que dois-je faire avec le PC du bureau ?"),
("shutdown", None, "Le PC du bureau ne répond pas : la machine est éteinte, ou son agent est arrêté."),
("shutdown", AgentStatus(False, ()), "Le PC du bureau ne répond pas : la machine est éteinte, "
"ou son agent est arrêté."),
("lock", AgentStatus(True, ("shutdown",)), "Je ne peux pas verrouiller le PC du bureau : "
"son agent ne le permet pas."),
("wake", ONLINE, "Le PC du bureau est déjà en marche."),
("wake", None, "Je ne sais pas allumer le PC du bureau à distance."), # pas d'adresse MAC
],
)
def test_impossible_requests_are_explained(action: str | None, status: AgentStatus | None, answer: str) -> None:
decision = decide_machine(MachineIntent("pc-bureau", action), MACHINES, status)
assert decision.command is None and decision.answer == answer
def test_wake_needs_the_machine_off_and_a_mac() -> None:
decision = decide_machine(MachineIntent("pc-bureau", "wake"), with_mac(), None)
assert decision.command is not None and decision.answer == "J'allume le PC du bureau."
def test_magic_packet() -> None:
packet = magic_packet("AA-BB-CC-DD-EE-FF")
assert len(packet) == 102 and packet[:6] == b"\xff" * 6
assert packet[6:12] == bytes.fromhex("aabbccddeeff") and packet[6:] == packet[6:12] * 16
with pytest.raises(ValueError, match="adresse MAC invalide"):
magic_packet("aa:bb:cc")
def test_controller_follows_agent_states_and_publishes_commands() -> None:
link = FakeLink()
controller = MachineController(MACHINES, link)
unknown = controller.interpret("Éteins le PC du bureau")
assert unknown is not None and unknown.command is None # agent jamais annoncé
controller.on_state("agent/pc-bureau/state", ONLINE_PAYLOAD)
decision = controller.interpret("Éteins le PC du bureau")
assert decision is not None and decision.command is not None
assert controller.execute("esp32-01", decision.command, "Denis", "w123") == ["agent/pc-bureau/command"]
assert link.topics == [("agent/pc-bureau/command",
{"action": "shutdown", "id": "w123", "by": "Denis", "board": "esp32-01"})]
controller.on_state("agent/pc-bureau/state", b'{"status": "offline"}')
offline = controller.interpret("Éteins le PC du bureau")
assert offline is not None and offline.command is None
def test_wake_sends_the_magic_packet_not_a_message() -> None:
sent: list[tuple[bytes, str]] = []
link = FakeLink()
controller = MachineController(with_mac(), link, send_udp=lambda packet, address: sent.append((packet, address)))
command = MachineCommand("wake", "pc-bureau", said="J'allume le PC du bureau.", asked="allumer le PC du bureau")
controller.execute("esp32-01", command, "Denis", "w1")
assert sent == [(magic_packet(MAC), "255.255.255.255")] and link.topics == []
def test_machine_is_matched_before_home() -> None:
"""« Éteins le PC du bureau » ne doit pas éteindre la lumière du bureau."""
controller = MachineController(MACHINES, FakeLink())
controller.on_state("agent/pc-bureau/state", ONLINE_PAYLOAD)
assistant = Assistant(clock=lambda: datetime(2026, 9, 30, 12), home=SETTINGS.home, machines=controller)
assert isinstance(assistant.respond("Éteins le PC du bureau", "Denis", "esp32-01").command, MachineCommand)
home = assistant.respond("Éteins la lumière du bureau", "Denis", "esp32-01").command
assert home == DeviceCommand("off", "light", "bureau")
def test_rights_and_confirmation_for_machines() -> None:
"""config.yaml : verrouiller = standard ; éteindre = complet + confirmation."""
policy = AccessPolicy(SETTINGS.access, SETTINGS.home, MACHINES)
lock = decide_machine(MachineIntent("pc-bureau", "lock"), MACHINES, ONLINE).command
shutdown = decide_machine(MachineIntent("pc-bureau", "shutdown"), MACHINES, ONLINE).command
assert lock is not None and shutdown is not None
assert policy.check(lock, "Pierre", 0.8).allowed
refused = policy.check(shutdown, "Pierre", 0.8)
assert not refused.allowed and refused.answer == ("Désolé Pierre, votre profil ne permet pas "
"d'éteindre le PC du bureau.")
verdict = policy.check(shutdown, "Denis", 0.8)
assert verdict.confirm and verdict.answer == "Confirmez-vous : éteindre le PC du bureau ? Dites oui ou non."
policy.ask("esp32-01", shutdown, "Denis")
resolution = policy.resolve("esp32-01", "oui", "Denis")
assert resolution is not None and resolution.command == shutdown
assert resolution.answer == "J'éteins le PC du bureau."
def test_router_sends_each_command_to_its_controller() -> None:
link = FakeLink()
router = CommandRouter(HomeController(SETTINGS.home, link), MachineController(MACHINES, link))
machine = MachineCommand("lock", "pc-bureau", said="", asked="")
assert router.execute("esp32-01", machine, None, "s") == ["agent/pc-bureau/command"]
assert router.execute("esp32-01", DeviceCommand("on", "light", "cuisine"), None, "s") == \
["home/cuisine/light/set"]
def test_mqtt_link_routes_agent_topics() -> None:
"""Les états des agents (agent/+/state) arrivent au bon rappel, pas à ceux des cartes."""
link = MqttLink(SETTINGS.mqtt)
received: list[str] = []
link.on("state", lambda device, payload: received.append(f"carte {device}"))
link.on_topic("agent/+/state", lambda topic, payload: received.append(topic))
for topic in ("agent/pc-bureau/state", "voice/esp32-01/state", "agent/pc-bureau/result"):
message = mqtt.MQTTMessage(topic=topic.encode())
message.payload = b"{}"
link._on_message(link._client, None, message) # pyright: ignore[reportPrivateUsage]
assert received == ["agent/pc-bureau/state", "carte esp32-01"]
+69
View File
@@ -0,0 +1,69 @@
"""Tests de la liaison MQTT du serveur, étape 15 : présence, refus du broker, journal fichier."""
from __future__ import annotations
import dataclasses
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
from voice_server.__main__ import setup_logging
from voice_server.mqtt_link import presence_topic, refusal_hint, split_topic
from voice_server.settings import load_settings
CONFIG = Path(__file__).resolve().parents[1] / "config.yaml"
def test_presence_topic_is_not_taken_for_a_board() -> None:
"""Le serveur s'abonne à voice/+/state, event... : sa présence ne doit pas y tomber."""
topic = presence_topic("voice")
assert topic == "voice/server/status"
parsed = split_topic(topic, "voice")
assert parsed is not None and parsed[1] not in ("state", "event", "audio/in", "audio/stream")
def test_refused_credentials_point_to_the_fix() -> None:
hint = refusal_hint(ReasonCode(PacketTypes.CONNACK, "Not authorized"), "voice-server")
assert "server/.env" in hint and "make mqtt-user NAME=voice-server" in hint
bad_password = refusal_hint(ReasonCode(PacketTypes.CONNACK, "Bad user name or password"), "voice-server")
assert "server/.env" in bad_password
def test_other_refusal_names_the_client() -> None:
hint = refusal_hint(ReasonCode(PacketTypes.CONNACK, "Server unavailable"), "voice-server")
assert "voice-server" in hint and "server/.env" not in hint
@pytest.fixture
def restore_logging() -> Iterator[None]:
"""setup_logging remplace la configuration globale : on remet celle de pytest après."""
root = logging.getLogger()
saved = (root.level, list(root.handlers))
yield
for handler in root.handlers:
if handler not in saved[1]:
handler.close()
root.handlers[:] = saved[1]
root.setLevel(saved[0])
def test_log_file_receives_the_journal(tmp_path: Path, restore_logging: None) -> None:
settings = dataclasses.replace(load_settings(CONFIG), log_file=tmp_path / "logs" / "voice-server.log")
setup_logging(settings)
logging.getLogger("voice_server").info("essai du journal")
for handler in logging.getLogger().handlers:
handler.flush()
assert settings.log_file is not None
assert "essai du journal" in settings.log_file.read_text(encoding="utf-8")
def test_log_file_is_optional(tmp_path: Path) -> None:
text = CONFIG.read_text(encoding="utf-8").replace("file: logs/voice-server.log", "file:")
config = tmp_path / "config.yaml"
config.write_text(text, encoding="utf-8")
assert load_settings(config).log_file is None
assert load_settings(CONFIG).log_file == (CONFIG.parent / "logs" / "voice-server.log").resolve()
+178
View File
@@ -0,0 +1,178 @@
"""Tests du décodeur de trames série utilisé à l'étape 1.
Ces tests ne nécessitent aucun matériel : un faux port série rejoue des octets.
"""
from __future__ import annotations
import struct
import sys
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from serial_protocol import ( # noqa: E402
AudioFormat,
Frame,
FrameReader,
FrameType,
encode_frame,
)
from serial_to_wav import measure_levels, suggested_gain_factor # noqa: E402
from wav_inspect import column_peaks, render, speech_ratio # noqa: E402
class FakeSerial:
"""Faux port série : restitue une suite d'octets par petits morceaux."""
def __init__(self, data: bytes, block_size: int = 7) -> None:
self._data = data
self._block_size = block_size
self._position = 0
def read(self, size: int = 1) -> bytes:
"""Retourne au plus block_size octets, puis des chaînes vides."""
count = min(size, self._block_size, len(self._data) - self._position)
if count <= 0:
return b""
chunk = self._data[self._position : self._position + count]
self._position += count
return chunk
def build_frame(frame_type: FrameType, payload: bytes) -> bytes:
"""Construit une trame avec l'encodeur partagé, celui qu'utilise wav_to_serial."""
return encode_frame(frame_type, payload)
def descriptor(sample_rate: int = 16000, bits: int = 16, channels: int = 1) -> bytes:
"""Construit le descripteur de 8 octets de la trame START."""
return (
sample_rate.to_bytes(4, "little")
+ bits.to_bytes(2, "little")
+ channels.to_bytes(2, "little")
)
def collect(data: bytes, expected: int) -> list[Frame]:
"""Décode le flux et retourne les `expected` premières trames."""
reader = FrameReader(FakeSerial(data))
frames: list[Frame] = []
for frame in reader.frames():
frames.append(frame)
if len(frames) == expected:
break
return frames
def test_decodes_a_full_session() -> None:
"""START + deux chunks + END sont décodés dans l'ordre."""
pcm = bytes(range(0, 200)) * 8 # 1600 octets
stream = (
build_frame(FrameType.START, descriptor())
+ build_frame(FrameType.AUDIO, pcm)
+ build_frame(FrameType.AUDIO, pcm)
+ build_frame(FrameType.END, b"")
)
frames = collect(stream, 4)
assert [f.type for f in frames] == [
FrameType.START,
FrameType.AUDIO,
FrameType.AUDIO,
FrameType.END,
]
assert frames[1].payload == pcm
assert AudioFormat.from_payload(frames[0].payload) == AudioFormat(16000, 16, 1)
def test_skips_interleaved_text() -> None:
"""Les messages texte de l'ESP32 n'empêchent pas le décodage."""
stream = (
b"# micro pret\n"
+ build_frame(FrameType.START, descriptor())
+ b"\n# session terminee\n"
+ build_frame(FrameType.END, b"")
)
frames = collect(stream, 2)
assert [f.type for f in frames] == [FrameType.START, FrameType.END]
def test_rejects_bad_checksum() -> None:
"""Une trame corrompue est ignorée, la suivante est retrouvée."""
broken = bytearray(build_frame(FrameType.AUDIO, b"\x01\x02\x03\x04"))
broken[-1] ^= 0xFF # checksum faux
stream = bytes(broken) + build_frame(FrameType.END, b"")
frames = collect(stream, 1)
assert frames[0].type is FrameType.END
def test_measure_levels_on_silence() -> None:
"""Un flux nul est signalé comme absence totale de signal."""
level = measure_levels(b"\x00\x00" * 1000)
assert level.peak == 0.0
assert level.rms == 0.0
def test_measure_levels_on_known_amplitude() -> None:
"""Un carré à la moitié de l'échelle donne crête et RMS à 50 %."""
pcm = (struct.pack("<h", 16384) + struct.pack("<h", -16384)) * 500
level = measure_levels(pcm)
assert abs(level.peak - 0.5) < 0.001
assert abs(level.rms - 0.5) < 0.001
assert abs(level.dc) < 0.001
def test_suggested_gain_factor() -> None:
"""Le facteur conseillé est une puissance de 2 qui vise 25 % de crête."""
assert suggested_gain_factor(0.015) == 16
assert suggested_gain_factor(0.12) == 2
assert suggested_gain_factor(0.0) == 0
def test_envelope_follows_amplitude() -> None:
"""L'enveloppe dessine un creux entre deux bouffées de signal."""
loud = struct.pack("<h", 16000) * 1000
quiet = struct.pack("<h", 0) * 1000
peaks = column_peaks(loud + quiet + loud, columns=3)
assert peaks[0] > 0.4 and peaks[2] > 0.4
assert peaks[1] == 0.0
drawing = render(peaks)
assert drawing[0] == drawing[2]
assert drawing[1] != drawing[0]
def test_speech_ratio_detects_alternation() -> None:
"""Un signal constant n'est pas de la parole, une alternance oui."""
steady = struct.pack("<h", 8000) * 3000
assert speech_ratio(column_peaks(steady, columns=6)) == 0.0
burst = struct.pack("<h", 0) * 2000 + struct.pack("<h", 8000) * 1000
assert speech_ratio(column_peaks(burst, columns=6)) > 0.0
def test_audio_format_round_trip() -> None:
"""Le descripteur START s'encode et se décode sans perte."""
fmt = AudioFormat(16000, 16, 1)
assert AudioFormat.from_payload(fmt.to_payload()) == fmt
def test_encode_frame_rejects_oversized_payload() -> None:
"""Un payload plus grand que ce que l'ESP32 accepte est refusé dès l'envoi."""
try:
encode_frame(FrameType.AUDIO, bytes(4096))
except ValueError:
return
raise AssertionError("un payload de 4096 octets aurait du etre refuse")
+190
View File
@@ -0,0 +1,190 @@
"""Tests du serveur, étape 4 : reconstitution des sessions (sans broker)."""
from __future__ import annotations
import json
import wave
from pathlib import Path
import pytest
from voice_server.mqtt_link import split_topic
from voice_server.session_manager import Outcome, SessionManager
from voice_server.settings import AudioSettings, SessionSettings, load_settings
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
SESSIONS = SessionSettings(max_seconds=30.0, idle_timeout_s=5.0, end_grace_s=0.5)
CHUNK = bytes(1600)
class FakeClock:
"""Horloge pilotée par le test : on avance le temps sans attendre."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def start_event(session: str = "a1b2c3", rate: int = 16000) -> bytes:
return json.dumps({"event": "start", "session": session, "rate": rate, "bits": 16,
"channels": 1, "codec": "pcm_s16le", "chunk_ms": 50}).encode()
def end_event(chunks: int, session: str = "a1b2c3") -> bytes:
return json.dumps({"event": "end", "session": session, "chunks": chunks,
"duration_ms": chunks * 50, "send_failures": 0,
"capture_overruns": 0}).encode()
@pytest.fixture
def clock() -> FakeClock:
return FakeClock()
@pytest.fixture
def manager(tmp_path: Path, clock: FakeClock) -> SessionManager:
return SessionManager(AUDIO, SESSIONS, tmp_path, clock=clock)
def test_complete_session_is_written_immediately(manager: SessionManager) -> None:
"""START + 20 chunks + END : WAV d'une seconde, écrit dès le END."""
manager.on_event("esp32-01", start_event())
for _ in range(20):
manager.on_audio("esp32-01", CHUNK)
results = manager.on_event("esp32-01", end_event(20))
assert len(results) == 1
result = results[0]
assert result.outcome is Outcome.COMPLETE
assert result.chunks_received == 20
with wave.open(str(result.path)) as wav:
assert (wav.getframerate(), wav.getnchannels(), wav.getsampwidth()) == (16000, 1, 2)
assert wav.getnframes() == 16000
def test_lost_chunks_are_reported_after_grace(manager: SessionManager, clock: FakeClock) -> None:
"""END annonce 20 chunks, 18 arrivent : bilan « lost_chunks » après le délai de grâce."""
manager.on_event("esp32-01", start_event())
for _ in range(18):
manager.on_audio("esp32-01", CHUNK)
assert manager.on_event("esp32-01", end_event(20)) == []
clock.now += 0.2
assert manager.sweep() == [] # on attend encore les chunks en vol
clock.now += 0.4
results = manager.sweep()
assert [r.outcome for r in results] == [Outcome.LOST_CHUNKS]
assert results[0].chunks_announced == 20
def test_late_chunk_within_grace_completes(manager: SessionManager, clock: FakeClock) -> None:
"""Le END arrive avant le dernier chunk (topics différents) : la session reste complète."""
manager.on_event("esp32-01", start_event())
for _ in range(19):
manager.on_audio("esp32-01", CHUNK)
manager.on_event("esp32-01", end_event(20))
manager.on_audio("esp32-01", CHUNK) # retardataire
clock.now += 0.6
assert [r.outcome for r in manager.sweep()] == [Outcome.COMPLETE]
def test_session_without_end_times_out(manager: SessionManager, clock: FakeClock) -> None:
"""L'ESP32 disparaît en pleine session : clôture après 5 s sans rien recevoir."""
manager.on_event("esp32-01", start_event())
manager.on_audio("esp32-01", CHUNK)
clock.now += 4.9
assert manager.sweep() == []
clock.now += 0.2
results = manager.sweep()
assert [r.outcome for r in results] == [Outcome.TIMEOUT]
assert results[0].chunks_announced is None
def test_new_start_interrupts_previous(manager: SessionManager) -> None:
"""Un START sans END préalable clôt la session précédente."""
manager.on_event("esp32-01", start_event("aaaaaa"))
manager.on_audio("esp32-01", CHUNK)
results = manager.on_event("esp32-01", start_event("bbbbbb"))
assert [(r.session_id, r.outcome) for r in results] == [("aaaaaa", Outcome.INTERRUPTED)]
def test_start_during_grace_keeps_end_verdict(manager: SessionManager) -> None:
"""Nouvel appui pendant le délai de grâce : la session précédente garde son vrai bilan.
Cas trouvé par l'essai de bout en bout : elle était classée « interrompue ».
"""
manager.on_event("esp32-01", start_event("aaaaaa"))
for _ in range(18):
manager.on_audio("esp32-01", CHUNK)
manager.on_event("esp32-01", end_event(20, "aaaaaa")) # 2 chunks manquent
results = manager.on_event("esp32-01", start_event("bbbbbb"))
assert [(r.session_id, r.outcome) for r in results] == [("aaaaaa", Outcome.LOST_CHUNKS)]
def test_wrong_format_is_refused(manager: SessionManager) -> None:
"""Une session annoncée à 48 kHz n'est pas ouverte : ses chunks sont orphelins."""
manager.on_event("esp32-01", start_event(rate=48000))
manager.on_audio("esp32-01", CHUNK)
assert manager.orphan_chunks == 1
def test_devices_are_independent(manager: SessionManager) -> None:
"""Deux cartes parlent en même temps : deux fichiers distincts."""
manager.on_event("esp32-01", start_event("111111"))
manager.on_event("esp32-02", start_event("222222"))
manager.on_audio("esp32-01", CHUNK)
manager.on_audio("esp32-02", CHUNK)
manager.on_audio("esp32-02", CHUNK)
first = manager.on_event("esp32-01", end_event(1, "111111"))[0]
second = manager.on_event("esp32-02", end_event(2, "222222"))[0]
assert (first.chunks_received, second.chunks_received) == (1, 2)
assert first.path != second.path
def test_garbage_event_is_ignored(manager: SessionManager) -> None:
"""Un événement illisible ne fait rien planter."""
assert manager.on_event("esp32-01", b"\xff\x00pas du json") == []
@pytest.mark.parametrize(
("topic", "expected"),
[
("voice/esp32-01/audio/in", ("esp32-01", "audio/in")),
("voice/esp32-01/event", ("esp32-01", "event")),
("other/esp32-01/event", None),
("voice/esp32-01", None),
("voice//event", None),
],
)
def test_split_topic(topic: str, expected: tuple[str, str] | None) -> None:
"""Découpage des topics reçus."""
assert split_topic(topic, "voice") == expected
def test_project_config_loads() -> None:
"""Le config.yaml du dépôt se charge et reste cohérent avec le firmware."""
settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
assert settings.audio.bytes_per_second == 32000
assert settings.mqtt.topic_prefix == "voice"
assert settings.recordings_dir.is_absolute()
def test_finished_sessions_are_notified(tmp_path: Path, clock: FakeClock) -> None:
"""Le rappel on_finished reçoit chaque session terminée (utilisé par le mode écho)."""
finished: list[str] = []
manager = SessionManager(AUDIO, SESSIONS, tmp_path, clock=clock,
on_finished=lambda result: finished.append(result.session_id))
manager.on_event("esp32-01", start_event())
manager.on_audio("esp32-01", CHUNK)
manager.on_event("esp32-01", end_event(1))
assert finished == ["a1b2c3"]
+65
View File
@@ -0,0 +1,65 @@
"""Tests de l'association locuteur + texte, étape 10 (sans modèle)."""
from __future__ import annotations
import json
import math
from voice_server.diarization import SpeakerTurn
from voice_server.speaker_attribution import Utterance, attribute, transcript_json, turn_of
from voice_server.speaker_identifier import Identification
from voice_server.speech_to_text import Word
# Mots et tours relevés sur la session réelle 453cce (vous, un haut-parleur, vous).
WORDS = [Word(" Ouvrir", 0.00, 0.56), Word(" la", 0.56, 0.76), Word(" porte.", 0.76, 1.26),
Word(" Je", 1.50, 1.66), Word(" suis", 1.66, 1.78), Word(" là.", 1.78, 1.92),
Word(" Fermez", 4.92, 5.38), Word(" la", 5.38, 5.52), Word(" porte.", 5.52, 5.86)]
TURNS = [SpeakerTurn("SPEAKER_00", 0.08, 1.50), SpeakerTurn("SPEAKER_01", 1.57, 4.69),
SpeakerTurn("SPEAKER_00", 4.92, 6.17)]
VERDICTS = {"SPEAKER_00": Identification("Denis", 0.72, "Denis"),
"SPEAKER_01": Identification(None, -0.05, "Denis")}
def test_word_goes_to_the_most_overlapping_turn() -> None:
"""« Je » (1,50-1,66) touche SPEAKER_00 sur 0 s et SPEAKER_01 sur 0,09 s."""
turn = turn_of(Word(" Je", 1.50, 1.66), TURNS)
assert turn is not None and turn.speaker == "SPEAKER_01"
def test_word_in_a_gap_goes_to_the_nearest_turn() -> None:
"""Un mot entre deux tours va au plus proche : jamais perdu."""
before = turn_of(Word(" euh", 4.70, 4.76), TURNS) # 0,04 s après la fin de SPEAKER_01
after = turn_of(Word(" euh", 4.84, 4.90), TURNS) # 0,05 s avant la reprise de SPEAKER_00
assert before is not None and before.speaker == "SPEAKER_01"
assert after is not None and after.start_s == 4.92
def test_real_session_is_split_by_speaker() -> None:
utterances = attribute(WORDS, TURNS, VERDICTS)
assert [(u.speaker, u.text) for u in utterances] == [
("Denis", "Ouvrir la porte."), ("Inconnu", "Je suis là."), ("Denis", "Fermez la porte.")]
assert (utterances[0].start_s, utterances[0].end_s) == (0.0, 1.26)
assert utterances[1].label == "SPEAKER_01" and utterances[1].score == -0.05
def test_without_diarization_everything_is_one_unknown_utterance() -> None:
[utterance] = attribute(WORDS, [], {})
assert utterance.speaker == "Inconnu" and utterance.score is None
assert utterance.text == "Ouvrir la porte. Je suis là. Fermez la porte."
def test_speaker_with_too_little_speech_has_no_score() -> None:
"""Empreinte impossible (NaN) : « Inconnu », score null dans le JSON."""
[utterance] = attribute(WORDS[:1], TURNS[:1], {"SPEAKER_00": Identification(None, math.nan, None)})
assert utterance.speaker == "Inconnu" and utterance.score is None
def test_json_message() -> None:
"""Le format attendu par la feuille de route : {"speaker": ..., "text": ...}, accents lisibles."""
payload = transcript_json("a1b2c3", [Utterance("Élodie", "Ferme la porte.", 0.0, 1.234, 0.7456)])
assert json.loads(payload) == {"session": "a1b2c3", "utterances": [
{"speaker": "Élodie", "text": "Ferme la porte.", "start": 0.0, "end": 1.23, "score": 0.75}]}
assert "Élodie".encode() in payload
+124
View File
@@ -0,0 +1,124 @@
"""Tests de l'identification du locuteur, étape 9 : profils, cosinus, seuil (sans modèle)."""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
import numpy as np
import pytest
from enroll import drop_outliers
from voice_server.settings import SpeakerSettings
from voice_server.speaker_identifier import (
SpeakerIdentifier,
load_profiles,
normalized,
profile_path,
save_profile,
)
MODEL = "pyannote/speaker-diarization-community-1"
def voice(seed: int, noise: float = 0.0, base: int | None = None) -> np.ndarray:
"""Empreinte factice : une direction aléatoire, éventuellement bruitée autour d'une autre."""
rng = np.random.default_rng(seed)
center = np.random.default_rng(base).normal(size=256) if base is not None else rng.normal(size=256)
return (center + noise * rng.normal(size=256)).astype(np.float32)
@pytest.fixture
def settings(tmp_path: Path) -> SpeakerSettings:
return SpeakerSettings(profiles_dir=tmp_path / "profiles", similarity_threshold=0.45)
def test_profile_file_name(tmp_path: Path) -> None:
"""Nom lisible, sans accent ni espace ; un nom vide est refusé."""
assert profile_path(tmp_path, "Élodie Martin").name == "elodie-martin.json"
with pytest.raises(ValueError):
profile_path(tmp_path, " ")
def test_save_and_load(settings: SpeakerSettings) -> None:
"""Le profil est la moyenne normalisée ; il se relit tel quel."""
saved = save_profile(settings.profiles_dir, "Denis", [voice(1, 0.3, base=0), voice(2, 0.3, base=0)], MODEL)
[loaded] = load_profiles(settings.profiles_dir, MODEL)
assert loaded.name == "Denis" and loaded.samples == 2
assert float(np.linalg.norm(loaded.embedding)) == pytest.approx(1.0, abs=1e-4)
assert float(loaded.embedding @ saved.embedding) == pytest.approx(1.0, abs=1e-4)
assert not list(settings.profiles_dir.glob("*.tmp")) # écriture atomique terminée
def test_profile_of_another_model_is_ignored(settings: SpeakerSettings) -> None:
"""Deux modèles d'empreintes ne sont pas comparables : le profil est écarté."""
save_profile(settings.profiles_dir, "Denis", [voice(1)], "autre/modele")
assert load_profiles(settings.profiles_dir, MODEL) == []
def test_unreadable_profile_is_skipped(settings: SpeakerSettings) -> None:
settings.profiles_dir.mkdir(parents=True)
(settings.profiles_dir / "casse.json").write_text("{pas du json", encoding="utf-8")
assert load_profiles(settings.profiles_dir, MODEL) == []
def test_identify_known_and_unknown(settings: SpeakerSettings) -> None:
"""La même voix est reconnue ; une autre voix reste « Inconnu », avec le profil le plus proche."""
save_profile(settings.profiles_dir, "Denis", [voice(i, 0.5, base=0) for i in range(1, 6)], MODEL)
save_profile(settings.profiles_dir, "Alice", [voice(i, 0.5, base=100) for i in range(11, 16)], MODEL)
identifier = SpeakerIdentifier(settings, MODEL)
denis = identifier.identify(voice(42, 0.5, base=0))
stranger = identifier.identify(voice(7))
assert denis.name == "Denis" and denis.score > 0.45
assert stranger.name is None and stranger.label == "Inconnu"
assert stranger.closest in ("Denis", "Alice")
assert "proche de" in stranger.describe()
def test_missing_embedding_is_unknown(settings: SpeakerSettings) -> None:
"""Trop peu de parole : empreinte NaN, jamais un nom."""
save_profile(settings.profiles_dir, "Denis", [voice(1)], MODEL)
verdict = SpeakerIdentifier(settings, MODEL).identify(np.full(256, np.nan, dtype=np.float32))
assert verdict.name is None and math.isnan(verdict.score)
assert verdict.describe() == "Inconnu (trop peu de parole)"
def test_no_profile(settings: SpeakerSettings) -> None:
assert SpeakerIdentifier(settings, MODEL).identify(voice(1)).describe() == "Inconnu (aucun profil)"
def test_new_profile_is_seen_without_restart(settings: SpeakerSettings) -> None:
"""Un enrôlement fait pendant que le serveur tourne est pris en compte."""
identifier = SpeakerIdentifier(settings, MODEL)
assert identifier.identify(voice(1)).name is None
save_profile(settings.profiles_dir, "Denis", [voice(1)], MODEL)
stamp = settings.profiles_dir.stat().st_mtime + 1 # horloge des fichiers parfois grossière
os.utime(settings.profiles_dir, (stamp, stamp))
assert identifier.identify(voice(1)).name == "Denis"
def test_profile_file_is_readable_json(settings: SpeakerSettings) -> None:
"""Format ouvert : on sait ce qu'on stocke sur une personne."""
save_profile(settings.profiles_dir, "Denis", [voice(1)], MODEL)
data = json.loads(profile_path(settings.profiles_dir, "Denis").read_text(encoding="utf-8"))
assert set(data) == {"name", "model", "samples", "created", "embedding"}
assert len(data["embedding"]) == 256
def test_enrollment_drops_a_foreign_session() -> None:
"""Enrôlement : une session d'une autre voix parmi les vôtres est écartée."""
samples = [(f"s{i}", normalized(voice(i, 0.5, base=0))) for i in range(1, 6)]
samples.append(("intrus", normalized(voice(99))))
kept = drop_outliers(samples, 0.45)
assert [name for name, _ in kept] == ["s1", "s2", "s3", "s4", "s5"]
+131
View File
@@ -0,0 +1,131 @@
"""Tests des vrais modèles, étape 6 : ignorés tant que « make models » n'a pas tourné.
Piper dit une phrase, Whisper doit la relire : les deux modèles se vérifient
l'un l'autre, sans micro ni haut-parleur.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from voice_server.audio_resampler import AudioResampler, peak_dbfs
from voice_server.settings import Settings, load_settings
from voice_server.speech_to_text import SpeechToText
from voice_server.text_to_speech import TextToSpeech
from voice_server.voice_activity import VoiceActivityDetector
SETTINGS: Settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
pytestmark = pytest.mark.skipif(
not (SETTINGS.stt.model_dir / "model.bin").exists() or not SETTINGS.tts.model_path.exists(),
reason="modeles absents : lancez 'make models'",
)
@pytest.fixture(scope="module")
def tts() -> TextToSpeech:
return TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio), SETTINGS.audio.sample_rate)
@pytest.fixture(scope="module")
def stt() -> SpeechToText:
return SpeechToText(SETTINGS.stt, SETTINGS.audio.sample_rate)
def test_piper_output_is_system_format(tts: TextToSpeech) -> None:
"""Piper produit du 22 050 Hz : la sortie doit être en 16 kHz, crête -3 dBFS."""
speech = tts.synthesize("Bonjour.")
assert len(speech.pcm) % 2 == 0
assert 0.3 < speech.audio_s < 3.0
assert peak_dbfs(speech.pcm) == pytest.approx(-3.0, abs=0.1)
def test_round_trip(tts: TextToSpeech, stt: SpeechToText) -> None:
"""Ce que Piper dit, Whisper le relit."""
transcript = stt.transcribe(tts.synthesize("Bonjour, comment tu t'appelles ?").pcm)
assert "appelles" in transcript.text.lower()
def test_noise_hallucination_is_dropped(stt: SpeechToText) -> None:
"""Sur du bruit, Whisper invente parfois un sous-titre : il ne doit pas passer."""
noise = np.random.default_rng(0).normal(0, 300, 32000).astype("<i2").tobytes()
assert "amara" not in stt.transcribe(noise).text.lower()
def test_vad_finds_speech_between_silences(tts: TextToSpeech) -> None:
"""1 s de silence, une phrase, 1 s de silence : la VAD retrouve la phrase et retire le reste."""
speech = tts.synthesize("Quelle heure est-il ?")
silence = bytes(32000)
detector = VoiceActivityDetector(SETTINGS.vad, SETTINGS.audio.sample_rate)
result = detector.detect(silence + speech.pcm + silence)
assert result.has_speech
start = result.segments[0].start / 16000
end = result.segments[-1].end / 16000
assert 0.7 < start < 1.2 # début de parole, marge comprise
assert 1.0 + speech.audio_s - 0.4 < end < 1.0 + speech.audio_s + 0.3
assert result.speech_s < speech.audio_s + 0.5 # les 2 s de silence sont parties
def test_words_have_increasing_times(tts: TextToSpeech, stt: SpeechToText) -> None:
"""Étape 10 : chaque mot a un instant, dans l'ordre, et les mots recomposent le texte."""
transcript = stt.transcribe(tts.synthesize("Ferme les volets de la cuisine.").pcm)
assert len(transcript.words) >= 5
starts = [word.start_s for word in transcript.words]
assert starts == sorted(starts)
assert "".join(word.text for word in transcript.words).strip() == transcript.text
WAKE_READY = all((SETTINGS.wake.model_dir / name).exists()
for name in ("melspectrogram.onnx", "embedding_model.onnx", "alexa_v0.1.onnx"))
@pytest.mark.skipif(not WAKE_READY, reason="modeles openWakeWord absents : 'make models'")
def test_alexa_is_detected_and_silence_is_not(tts: TextToSpeech) -> None:
"""Étape 13 : « Alexa » dit par Piper déclenche ; le silence et une phrase ordinaire non."""
pytest.importorskip("openwakeword")
from voice_server.wake_word import WakeWordDetector
def first_detection(pcm: bytes) -> str | None:
detector = WakeWordDetector(SETTINGS.wake)
padded = bytes(32000) + pcm + bytes(32000)
for start in range(0, len(padded) - 1599, 1600):
detection = detector.process(padded[start:start + 1600])
if detection is not None:
return detection.word
return None
loud = AudioResampler(SETTINGS.audio, -10.0)
speaker = TextToSpeech(SETTINGS.tts, loud, SETTINGS.audio.sample_rate)
assert first_detection(speaker.synthesize("Alexa, allume la lumière.").pcm) == "alexa"
assert first_detection(speaker.synthesize("Bonjour, quelle heure est-il ?").pcm) is None
assert first_detection(bytes(64000)) is None
@pytest.mark.skipif(not WAKE_READY, reason="modeles openWakeWord absents : 'make models'")
def test_per_word_threshold_and_near_miss(caplog: pytest.LogCaptureFixture, tts: TextToSpeech) -> None:
"""Un seuil propre au mot ; sous le seuil, le pic est journalisé comme « presque reconnu »."""
pytest.importorskip("openwakeword")
import dataclasses
import logging
from voice_server.wake_word import WakeWordDetector
strict = dataclasses.replace(SETTINGS.wake, thresholds={"alexa": 1.01}) # inatteignable
detector = WakeWordDetector(strict, "esp32-test")
speaker = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio, -10.0), SETTINGS.audio.sample_rate)
padded = bytes(32000) + speaker.synthesize("Alexa, allume la lumière.").pcm + bytes(64000)
with caplog.at_level(logging.INFO, logger="voice_server.wake_word"):
found = [detector.process(padded[i:i + 1600]) for i in range(0, len(padded) - 1599, 1600)]
assert all(detection is None for detection in found)
assert "presque reconnu « alexa »" in caplog.text
+111
View File
@@ -0,0 +1,111 @@
"""Tests de la VAD, étape 7 : segmentation (sans modèle) puis Silero sur des signaux connus."""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
import pytest
from voice_server.settings import VadSettings
from voice_server.voice_activity import (
WINDOW_SAMPLES,
Segment,
VoiceActivityDetector,
envelope,
find_segments,
pad_segments,
)
VAD = VadSettings(threshold=0.5, min_silence_ms=500, min_speech_ms=250, speech_pad_ms=150)
def probs(pattern: str) -> list[float]:
"""« # » = 0,9 (parole), « . » = 0,4 (entre les deux seuils), « _ » = 0,05 (silence)."""
return [{"#": 0.9, ".": 0.4, "_": 0.05}[c] for c in pattern]
def test_single_phrase() -> None:
"""Silence, parole, silence : un segment aux bonnes bornes."""
assert find_segments(probs("____######______"), 0.5, 3, 2) == [Segment(4, 10)]
def test_short_pause_does_not_split() -> None:
"""Une pause plus courte que min_silence ne coupe pas la phrase."""
assert find_segments(probs("__###__###____"), 0.5, 3, 2) == [Segment(2, 10)]
def test_long_pause_splits() -> None:
"""Une pause plus longue sépare deux segments."""
assert find_segments(probs("__###_____###____"), 0.5, 3, 2) == [Segment(2, 5), Segment(10, 13)]
def test_hysteresis_keeps_speech_between_thresholds() -> None:
"""Entre 0,35 et 0,5, on reste en parole : pas de battement autour du seuil."""
assert find_segments(probs("_##......##____"), 0.5, 3, 2) == [Segment(1, 11)]
def test_short_noise_is_dropped() -> None:
"""Un clic d'une fenêtre n'est pas de la parole."""
assert find_segments(probs("___#_______"), 0.5, 3, 2) == []
def test_speech_until_the_end() -> None:
"""Bouton relâché en pleine phrase : le segment va jusqu'au bout."""
assert find_segments(probs("___#####"), 0.5, 3, 2) == [Segment(3, 8)]
def test_padding_merges_close_segments() -> None:
"""La marge élargit les segments, bornée par l'audio ; les chevauchements fusionnent."""
segments = [Segment(100, 200), Segment(260, 300), Segment(900, 990)]
assert pad_segments(segments, 50, 1000) == [Segment(50, 350), Segment(850, 1000)]
def test_envelope() -> None:
assert envelope(np.array([0.9, 0.4, 0.05]), 0.5) == "#._"
def fake_model(pattern: str) -> Callable[[np.ndarray], np.ndarray]:
"""Modèle qui rend les probabilités d'un motif, quelle que soit l'entrée."""
def model(audio: np.ndarray) -> np.ndarray:
assert audio.size % WINDOW_SAMPLES == 0
return np.array(probs(pattern), dtype=np.float32)
return model
def test_detector_keeps_only_speech() -> None:
"""Le PCM rendu est la parole, marges comprises ; le reste est retiré."""
pattern = "_" * 20 + "#" * 20 + "_" * 20 # 60 fenêtres = 1,92 s
pcm = np.arange(60 * WINDOW_SAMPLES, dtype="<i2").tobytes()
detector = VoiceActivityDetector(VAD, 16000, model=fake_model(pattern))
result = detector.detect(pcm)
pad = 150 * 16
assert result.segments == (Segment(20 * WINDOW_SAMPLES - pad, 40 * WINDOW_SAMPLES + pad),)
assert result.speech_s == pytest.approx((20 * WINDOW_SAMPLES + 2 * pad) / 16000)
first = np.frombuffer(result.speech_pcm[:2], dtype="<i2")[0]
assert first == 20 * WINDOW_SAMPLES - pad # l'échantillon d'origine, pas un décalage
def test_detector_without_speech() -> None:
detector = VoiceActivityDetector(VAD, 16000, model=fake_model("_" * 10))
result = detector.detect(bytes(10 * WINDOW_SAMPLES * 2))
assert not result.has_speech and result.speech_pcm == b""
def test_wrong_rate_is_refused() -> None:
with pytest.raises(ValueError):
VoiceActivityDetector(VAD, 22050, model=fake_model(""))
def test_silero_ignores_silence_and_noise() -> None:
"""Le vrai Silero : ni le silence ni un bruit blanc ne sont de la parole."""
pytest.importorskip("faster_whisper")
detector = VoiceActivityDetector(VAD, 16000)
noise = np.random.default_rng(0).normal(0, 300, 32000).astype("<i2").tobytes()
assert not detector.detect(bytes(32000)).has_speech
assert not detector.detect(noise).has_speech
+82
View File
@@ -0,0 +1,82 @@
"""Tests de l'agent Linux, étape 15b : configuration, liste blanche, exécution (sans broker)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
AGENT_DIR = Path(__file__).resolve().parents[2] / "agent"
if str(AGENT_DIR) not in sys.path:
sys.path.insert(0, str(AGENT_DIR))
from voice_agent import Action, AgentConfig, ConfigError, load_config, run_action, state_payload # noqa: E402
def config(**actions: Action) -> AgentConfig:
return AgentConfig(agent_id="pc-test", host="127.0.0.1", port=1883, username=None, password=None,
topic_prefix="agent", dry_run=False, actions=dict(actions))
def test_example_config_is_valid() -> None:
loaded = load_config(AGENT_DIR / "agent.yaml.example")
assert loaded.agent_id == "pc-bureau" and not loaded.dry_run
assert loaded.actions["shutdown"].command == ("systemctl", "poweroff")
assert loaded.topic("command") == "agent/pc-bureau/command"
@pytest.mark.parametrize(
("text", "message"),
[
("id: PC Bureau\nmqtt: {host: x}\n", "id « PC Bureau » invalide"),
("id: pc\nmqtt: {}\n", "mqtt.host manquant"),
# Une chaîne passerait par un shell : « poweroff; rm -rf / » serait exécutable.
("id: pc\nmqtt: {host: x}\nactions: {off: {command: 'systemctl poweroff'}}\n", "doit être une liste"),
("id: pc\nmqtt: {host: x}\nactions: {off: {command: [x], timeout_s: 0}}\n", "timeout_s"),
],
)
def test_invalid_config_is_explained(tmp_path: Path, text: str, message: str) -> None:
path = tmp_path / "agent.yaml"
path.write_text(text, encoding="utf-8")
with pytest.raises(ConfigError, match=message):
load_config(path)
def test_only_listed_actions_run() -> None:
result = run_action(config(ok=Action(("true",), 5)), "rm", "r1")
assert not result.ok and result.output == "action inconnue : rm"
def test_success_failure_and_output() -> None:
agent = config(ok=Action(("echo", "bonjour"), 5), ko=Action(("false",), 5))
ok = run_action(agent, "ok", "r1")
assert ok.ok and ok.code == 0 and ok.output == "bonjour"
ko = run_action(agent, "ko", "r2")
assert not ko.ok and ko.code == 1
def test_missing_program_and_timeout() -> None:
agent = config(absent=Action(("/nonexistent/prog",), 5), slow=Action(("sleep", "5"), 0.2))
assert run_action(agent, "absent", "r").output == "programme introuvable : /nonexistent/prog"
slow = run_action(agent, "slow", "r")
assert not slow.ok and slow.output == "arretee apres 0.2 s"
def test_dry_run_executes_nothing() -> None:
calls: list[object] = []
agent = AgentConfig(**{**config(off=Action(("systemctl", "poweroff"), 5)).__dict__, "dry_run": True})
result = run_action(agent, "off", "r", runner=lambda *args, **kwargs: calls.append(args)) # type: ignore[arg-type]
assert result.ok and result.output == "essai : systemctl poweroff" and calls == []
def test_state_lists_actions_for_the_server() -> None:
online = json.loads(state_payload(config(b=Action(("x",), 1), a=Action(("y",), 1)), True))
assert online == {"status": "online", "actions": ["a", "b"], "dry_run": False}
assert json.loads(state_payload(config(), False)) == {"status": "offline"}
def test_result_payload() -> None:
result = run_action(config(ok=Action(("true",), 5)), "ok", "w42")
assert json.loads(result.payload()) == {"action": "ok", "id": "w42", "ok": True, "code": 0, "output": ""}
+326
View File
@@ -0,0 +1,326 @@
"""Tests de la boucle complète, étapes 6-7 : modèles et carte remplacés par des doublures."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from voice_server.access_control import AccessPolicy
from voice_server.assistant import NOTHING_HEARD, Reply
from voice_server.audio_buffer import Levels
from voice_server.diarization import Diarization, SpeakerTurn
from voice_server.home_control import DeviceCommand
from voice_server.machine_control import Command
from voice_server.session_manager import Outcome, SessionResult
from voice_server.settings import AccessSettings, HomeSettings
from voice_server.speaker_identifier import Identification
from voice_server.speech_to_text import Transcript, Word
from voice_server.text_to_speech import Speech
from voice_server.voice_activity import Segment, VadResult
from voice_server.voice_pipeline import VoicePipeline
PCM = bytes(32000) # 1 s de silence numérique : le contenu n'importe pas ici
class FakeVad:
"""Trouve la parole entre 0,25 et 0,75 s, ou rien si speech=False."""
def __init__(self, speech: bool = True) -> None:
self.speech = speech
def detect(self, pcm: bytes) -> VadResult:
segments = (Segment(4000, 12000),) if self.speech else ()
kept = pcm[8000:24000] if self.speech else b""
return VadResult(segments=segments, speech_pcm=kept, probabilities=np.zeros(0),
total_s=len(pcm) / 32000, speech_s=len(kept) / 32000, elapsed_s=0.003,
sample_rate=16000)
class FakeDiarizer:
def __init__(self) -> None:
self.lengths: list[int] = []
def diarize(self, pcm: bytes) -> Diarization:
self.lengths.append(len(pcm))
return Diarization(turns=(SpeakerTurn("SPEAKER_00", 0.0, 0.2), SpeakerTurn("SPEAKER_01", 0.2, 0.5)),
speakers=("SPEAKER_00", "SPEAKER_01"),
embeddings=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32),
audio_s=0.5, elapsed_s=0.3)
class FakeIdentifier:
"""Reconnaît la première empreinte (valeur 1), pas les autres."""
def identify(self, embedding: np.ndarray) -> Identification:
if embedding[0] == 1.0:
return Identification(name="Denis", score=0.8, closest="Denis")
return Identification(name=None, score=0.1, closest="Denis")
class FakeStt:
def __init__(self, text: str, dropped: tuple[str, ...] = (), words: tuple[Word, ...] = ()) -> None:
self.text = text
self.dropped = dropped
self.words = words
self.calls = 0
self.lengths: list[int] = []
def transcribe(self, pcm: bytes) -> Transcript:
self.calls += 1
self.lengths.append(len(pcm))
return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped,
words=self.words)
class EchoAssistant:
def __init__(self, command: Command | None = None) -> None:
self.speakers: list[str | None] = []
self.command = command
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply:
self.speakers.append(speaker)
return Reply(f"réponse à {text}", self.command)
class FakeController:
def __init__(self, events: list[str]) -> None:
self.events = events
self.calls: list[tuple[str, Command, str | None, str]] = []
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]:
self.calls.append((board, command, speaker, session))
self.events.append("commande")
return ["home/salon/light/set"]
class FakePublisher:
def __init__(self) -> None:
self.messages: list[tuple[str, str, bytes, int]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.messages.append((device, suffix, payload, qos))
class FakeTts:
def __init__(self) -> None:
self.texts: list[str] = []
def synthesize(self, text: str) -> Speech:
self.texts.append(text)
return Speech(pcm=bytes(16000), audio_s=0.5, elapsed_s=0.1)
class FakePlayer:
def __init__(self, events: list[str] | None = None) -> None:
self.sent: list[tuple[str, int, str | None]] = []
self.events = events if events is not None else []
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
self.sent.append((device, len(pcm), session))
self.events.append("reponse parlee")
return session or "x"
def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult:
return SessionResult(device="esp32-01", session_id="a1b2c3", outcome=Outcome.COMPLETE,
path=Path("inutile.wav"), duration_s=duration_s, chunks_received=30,
chunks_announced=30, levels=Levels(peak_dbfs=peak_dbfs, rms_dbfs=-30.0))
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | None = None,
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> 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))
def test_full_turn() -> None:
"""Question transcrite, réponse synthétisée puis envoyée à la bonne carte."""
stt, tts, player = FakeStt("quelle heure"), FakeTts(), FakePlayer()
turn = pipeline(stt, tts, player).handle(session())
assert turn is not None
assert (turn.heard, turn.answer) == ("quelle heure", "réponse à quelle heure")
assert turn.ready_s == pytest.approx(1.2)
assert stt.lengths == [16000] # Whisper ne reçoit que la parole trouvée par la VAD
assert turn.speech_s == pytest.approx(0.5)
assert tts.texts == ["réponse à quelle heure"]
assert player.sent == [("esp32-01", 16000, "rep-a1b2c3")]
def test_too_short_session_is_ignored() -> None:
"""Un appui trop bref ne déclenche ni transcription ni réponse."""
stt, tts, player = FakeStt("x"), FakeTts(), FakePlayer()
assert pipeline(stt, tts, player).handle(session(duration_s=0.1)) is None
assert (stt.calls, player.sent) == (0, [])
def test_session_without_speech_skips_whisper() -> None:
"""Pas de parole selon la VAD : Whisper n'est pas appelé (lent, hallucinations), la carte le dit."""
stt, tts, player = FakeStt("Sous-titres réalisés par Amara.org"), FakeTts(), FakePlayer()
turn = pipeline(stt, tts, player, FakeVad(speech=False)).handle(session(peak_dbfs=-30.0))
assert stt.calls == 0
assert turn is not None and turn.answer == NOTHING_HEARD
assert len(player.sent) == 1
def test_nothing_recognized_is_said() -> None:
"""Transcription vide (hallucination écartée) : « Je n'ai rien entendu. »"""
stt, tts, player = FakeStt("", dropped=("Amara.org",)), FakeTts(), FakePlayer()
turn = pipeline(stt, tts, player).handle(session())
assert turn is not None and turn.answer == NOTHING_HEARD
def test_diarization_gets_the_same_audio_as_whisper() -> None:
"""Diarisation et transcription reçoivent la même parole : leurs instants concordent."""
stt, tts, player, diarizer = FakeStt("bonjour"), FakeTts(), FakePlayer(), FakeDiarizer()
turn = pipeline(stt, tts, player, diarizer=diarizer).handle(session())
assert turn is not None and turn.speakers == 2
assert diarizer.lengths == stt.lengths == [16000]
def test_no_diarization_without_speech() -> None:
"""Appui muet : ni diarisation ni transcription."""
diarizer = FakeDiarizer()
turn = pipeline(FakeStt("x"), FakeTts(), FakePlayer(), FakeVad(speech=False), diarizer).handle(session())
assert turn is not None and turn.speakers == 0
assert diarizer.lengths == []
def test_each_speaker_gets_a_name_or_unknown() -> None:
"""Étape 9 : un nom par locuteur de la diarisation, dans le même ordre."""
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), diarizer=FakeDiarizer(),
identifier=FakeIdentifier()).handle(session())
assert turn is not None and turn.names == ("Denis", "Inconnu")
def test_no_identification_without_diarization() -> None:
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), identifier=FakeIdentifier()).handle(session())
assert turn is not None and turn.names == ()
TWO_VOICES = (Word(" Ferme", 0.0, 0.1), Word(" la", 0.1, 0.18), # SPEAKER_00 : 0,0-0,2
Word(" Bonjour", 0.22, 0.4), Word(" Denis.", 0.4, 0.5)) # SPEAKER_01 : 0,2-0,5
def test_who_said_what_is_published() -> None:
"""Étape 10 : une intervention par locuteur, publiée en JSON sur voice/<carte>/transcript."""
publisher = FakePublisher()
turn = pipeline(FakeStt("Ferme la Bonjour Denis.", words=TWO_VOICES), FakeTts(), FakePlayer(),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), publisher=publisher).handle(session())
assert turn is not None
assert [(u.speaker, u.text) for u in turn.utterances] == [("Denis", "Ferme la"), ("Inconnu", "Bonjour Denis.")]
[(device, suffix, payload, qos)] = publisher.messages
assert (device, suffix, qos) == ("esp32-01", "transcript", 1)
message = json.loads(payload)
assert message["session"] == "a1b2c3"
assert message["utterances"][0] == {"speaker": "Denis", "text": "Ferme la", "start": 0.0, "end": 0.18,
"score": 0.8}
def test_assistant_answers_the_last_speaker_by_name() -> None:
"""L'assistant reçoit le nom de la dernière personne qui a parlé, None si elle est inconnue."""
assistant = EchoAssistant()
words = TWO_VOICES[:2] # seul SPEAKER_00, reconnu comme Denis
pipeline(FakeStt("Ferme la", words=words), FakeTts(), FakePlayer(), diarizer=FakeDiarizer(),
identifier=FakeIdentifier(), assistant=assistant).handle(session())
pipeline(FakeStt("Ferme la Bonjour Denis.", words=TWO_VOICES), FakeTts(), FakePlayer(),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=assistant).handle(session())
assert assistant.speakers == ["Denis", None]
def test_nothing_published_without_speech() -> None:
publisher = FakePublisher()
pipeline(FakeStt("x"), FakeTts(), FakePlayer(), FakeVad(speech=False), publisher=publisher).handle(session())
assert publisher.messages == []
def test_text_without_word_times_is_one_unknown_utterance() -> None:
"""Sans diarisation ni horodatage, une seule intervention « Inconnu » : le JSON reste valide."""
publisher = FakePublisher()
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), publisher=publisher).handle(session())
assert turn is not None and [(u.speaker, u.text) for u in turn.utterances] == [("Inconnu", "bonjour")]
assert json.loads(publisher.messages[0][2])["utterances"][0]["score"] is None
def test_command_is_executed_before_the_spoken_answer() -> None:
"""Étape 11 : la commande part d'abord, la lumière s'allume pendant que la carte le dit."""
events: list[str] = []
command = DeviceCommand(action="on", device="light", room="salon")
controller = FakeController(events)
turn = pipeline(FakeStt("allume"), FakeTts(), FakePlayer(events), assistant=EchoAssistant(command),
controller=controller).handle(session())
assert turn is not None and turn.command == command
assert controller.calls == [("esp32-01", command, None, "a1b2c3")]
assert events == ["commande", "reponse parlee"]
def test_no_command_no_controller_call() -> None:
events: list[str] = []
controller = FakeController(events)
pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(events), controller=controller).handle(session())
assert controller.calls == []
HOME = HomeSettings(topic_prefix="home", rooms={"salon": "du salon"}, boards={"esp32-01": "salon"})
ACCESS = AccessSettings(unknown_level="limite", default_level="standard", users={"Denis": "complet"},
full_min_score=0.6, rules={"door": {"open": "complet"}}, confirm=("door.open",),
confirm_timeout_s=15.0)
DENIS_WORDS = (Word(" ouvre", 0.0, 0.1), Word(" la porte", 0.1, 0.18)) # tour de SPEAKER_00 = Denis (0,8)
def test_refused_command_is_not_executed() -> None:
"""Étape 14 : voix inconnue, pas de commande ; la carte dit pourquoi."""
events: list[str] = []
controller = FakeController(events)
turn = pipeline(FakeStt("ouvre la porte"), FakeTts(), FakePlayer(events),
assistant=EchoAssistant(DeviceCommand("open", "door", "salon")),
controller=controller, policy=AccessPolicy(ACCESS, HOME)).handle(session())
assert turn is not None and turn.refused and turn.command is None
assert controller.calls == []
assert turn.answer.startswith("Je ne vous ai pas reconnu")
def test_sensitive_command_waits_for_yes() -> None:
"""Denis demande d'ouvrir la porte : question, puis « oui » → exécution."""
events: list[str] = []
controller = FakeController(events)
policy = AccessPolicy(ACCESS, HOME)
door = DeviceCommand("open", "door", "salon")
first = pipeline(FakeStt("ouvre la porte", words=DENIS_WORDS), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(door),
controller=controller, policy=policy).handle(session())
assert first is not None and first.answer.startswith("Confirmez-vous") and controller.calls == []
yes_words = (Word(" oui", 0.0, 0.1),)
second = pipeline(FakeStt("oui", words=yes_words), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(),
controller=controller, policy=policy).handle(session())
assert second is not None and second.command == door
assert [call[1] for call in controller.calls] == [door]
+66
View File
@@ -0,0 +1,66 @@
"""Tests de l'outil de lecture de l'étape 2 (sans matériel)."""
from __future__ import annotations
import struct
import sys
import wave
from pathlib import Path
import pytest
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from wav_to_serial import FormatError, read_wav, send_times, split_chunks, tone_pcm # noqa: E402
def test_send_times_keeps_constant_lead() -> None:
"""Les chunks d'avance partent tout de suite, puis un toutes les 50 ms."""
times = send_times(count=7, chunk_s=0.05, prefill=4)
assert times[:4] == [0.0, 0.0, 0.0, 0.0]
assert times[4:] == pytest.approx([0.05, 0.10, 0.15])
def test_split_chunks_never_cuts_a_sample() -> None:
"""Tous les chunks ont une taille paire, le dernier compris."""
chunks = split_chunks(bytes(3203), chunk_bytes=1600)
assert [len(chunk) for chunk in chunks] == [1600, 1600, 2]
def test_tone_has_expected_length_and_level() -> None:
"""Un son pur de 0,5 s à 25 % : 8000 échantillons, crête proche de 8192."""
pcm = tone_pcm(440.0, 0.5, amplitude=0.25)
samples = struct.unpack(f"<{len(pcm) // 2}h", pcm)
assert len(samples) == 8000
assert 8000 < max(samples) <= 8192
assert abs(samples[0]) < 50 and abs(samples[-1]) < 50 # fondus : pas de clic
def test_read_wav_rejects_wrong_format(tmp_path: Path) -> None:
"""Un WAV 44,1 kHz stéréo est refusé, avec la commande de conversion."""
path = tmp_path / "cd.wav"
with wave.open(str(path), "wb") as wav:
wav.setnchannels(2)
wav.setsampwidth(2)
wav.setframerate(44100)
wav.writeframes(bytes(400))
with pytest.raises(FormatError, match="ffmpeg"):
read_wav(path)
def test_read_wav_accepts_system_format(tmp_path: Path) -> None:
"""Un WAV 16 kHz, 16 bits, mono est lu tel quel."""
path = tmp_path / "ok.wav"
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(bytes(320))
assert len(read_wav(path)) == 320
+14
View File
@@ -0,0 +1,14 @@
"""Serveur d'assistant vocal local.
Les modules de ce paquet sont ajoutés étape par étape :
étape 4 : MqttLink, SessionManager, AudioBuffer
étape 5 : AudioResampler, AudioSender (mode écho)
étape 6 : SpeechToText, TextToSpeech, Assistant, VoicePipeline
étape 7 : VoiceActivityDetector
étape 8 : Diarizer
étape 9 : SpeakerIdentifier
"""
__version__ = "0.1.0"
+261
View File
@@ -0,0 +1,261 @@
"""Point d'entrée du serveur vocal : python -m voice_server --config config.yaml
Étape 4 : reçoit les sessions de parole des ESP32 par MQTT et les écrit en WAV.
Étape 5 : avec --echo, renvoie chaque session à la carte, qui la joue.
Étape 6 : par défaut, répond à chaque session : faster-whisper → Assistant → Piper.
Étape 7 : la VAD (Silero) retire les silences et écarte les sessions sans parole.
Étape 8 : la diarisation (pyannote) découpe la parole par locuteur.
Étape 9 : chaque locuteur est comparé aux profils vocaux : un nom, ou « Inconnu ».
Étape 10 : qui a dit quoi, publié sur voice/<carte>/transcript ; l'assistant appelle par son nom.
Étape 11 : domotique : « ferme les volets du salon » → home/salon/shutter/set (+ la carte si c'est sa pièce).
É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 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN.
"""
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
import queue
import signal
import sys
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING
from voice_server.audio_resampler import AudioResampler
from voice_server.audio_sender import AudioSender
from voice_server.mqtt_link import MqttLink
from voice_server.session_manager import Outcome, SessionManager, SessionResult
from voice_server.settings import Settings, load_settings
if TYPE_CHECKING:
from voice_server.hands_free import HandsFreeListener
logger = logging.getLogger("voice_server")
SWEEP_PERIOD_S = 0.2 # fréquence de vérification des sessions terminées ou muettes
LOG_FILE_BYTES = 1_000_000 # journal fichier : 1 Mo par fichier...
LOG_FILE_COUNT = 3 # ... et 3 anciens gardés : 4 Mo au plus
def setup_logging(settings: Settings) -> None:
"""Journal à l'écran, et copie dans un fichier tournant si config.yaml le demande."""
handlers: list[logging.Handler] = [logging.StreamHandler()]
if settings.log_file is not None:
settings.log_file.parent.mkdir(parents=True, exist_ok=True)
# Tournant : au-delà de 1 Mo, le fichier est renommé .1 et un nouveau commence ;
# le disque (carte SD d'un Raspberry Pi) ne se remplit jamais.
handlers.append(logging.handlers.RotatingFileHandler(
settings.log_file, maxBytes=LOG_FILE_BYTES, backupCount=LOG_FILE_COUNT, encoding="utf-8"))
logging.basicConfig(level=settings.log_level, format=settings.log_format, handlers=handlers, force=True)
def log_state(device: str, payload: bytes) -> None:
"""Journalise l'état publié par une carte (retenu : reçu dès l'abonnement)."""
try:
state = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
if state.get("status") == "online":
# La durée de fonctionnement distingue une republication périodique d'un redémarrage.
logger.info("%s : en ligne depuis %s s, IP %s, signal %s dBm", device,
state.get("uptime_s"), state.get("ip"), state.get("rssi"))
else:
logger.warning("%s : %s", device, state.get("status"))
def make_echo(resampler: AudioResampler, sender: AudioSender) -> Callable[[SessionResult], object]:
"""Mode écho (étape 5) : la carte rejoue ce qu'elle vient d'enregistrer."""
def echo(result: SessionResult) -> None:
if result.duration_s < 0.2:
logger.warning("%s : session %s trop courte pour l'echo (%.2f s) : bouton relache trop tot ?",
result.device, result.session_id, result.duration_s)
return
sender.send(result.device, resampler.from_wav(result.path), f"echo-{result.session_id}")
return echo
def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioSender,
link: MqttLink | None = None) -> Callable[[SessionResult], object]:
"""Mode assistant (étapes 6-14) : charge les modèles, puis répond à chaque session."""
# Imports tardifs : le mode écho fonctionne sans l'extra « speech ».
from voice_server.access_control import AccessPolicy
from voice_server.assistant import Assistant
from voice_server.command_router import CommandRouter
from voice_server.diarization import Diarizer
from voice_server.home_control import HomeController
from voice_server.machine_control import MachineController
from voice_server.speaker_identifier import SpeakerIdentifier
from voice_server.speech_to_text import SpeechToText
from voice_server.text_to_speech import TextToSpeech
from voice_server.voice_activity import VoiceActivityDetector
from voice_server.voice_pipeline import VoicePipeline
try:
stt = SpeechToText(settings.stt, settings.audio.sample_rate)
tts = TextToSpeech(settings.tts, resampler, settings.audio.sample_rate)
vad = VoiceActivityDetector(settings.vad, settings.audio.sample_rate)
except ImportError as error:
raise ImportError(f"{error} : lancez 'make install-speech'") from error
diarizer = None
identifier = None
if settings.diarization.enabled:
try:
diarizer = Diarizer(settings.diarization, settings.audio.sample_rate)
except ImportError as error:
raise ImportError(f"{error} : lancez 'make install-diarization', ou "
"diarization.enabled: false dans config.yaml") from error
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
else:
logger.info("diarisation desactivee (config.yaml) : pas d'identification du locuteur")
machines = None
if link is not None:
machines = MachineController(settings.machines, link)
# États retenus des agents : reçus dès l'abonnement, même démarrés avant le serveur.
link.on_topic(f"{settings.machines.topic_prefix}/+/state", machines.on_state)
link.on_topic(f"{settings.machines.topic_prefix}/+/result", machines.on_result)
logger.info("machines : %s", ", ".join(settings.machines.machines) or "aucune (config.yaml)")
pipeline = VoicePipeline(
vad=vad,
stt=stt,
assistant=Assistant(home=settings.home, machines=machines),
tts=tts,
player=sender,
diarizer=diarizer,
identifier=identifier,
publisher=link,
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
policy=AccessPolicy(settings.access, settings.home, settings.machines),
)
return pipeline.handle
def make_listener(settings: Settings, link: MqttLink,
on_command: Callable[[SessionResult], None]) -> "HandsFreeListener":
"""Écoute mains libres (étape 13) : un détecteur de mot de réveil par carte."""
from voice_server.hands_free import HandsFreeListener
from voice_server.voice_activity import VoiceActivityDetector
from voice_server.wake_word import WakeWordDetector
WakeWordDetector(settings.wake) # vérifie modèles et installation dès le démarrage
return HandsFreeListener(
settings.wake, settings.audio, VoiceActivityDetector(settings.vad, settings.audio.sample_rate),
settings.recordings_dir, link, on_command,
detector_factory=lambda device: WakeWordDetector(settings.wake, device),
)
def worker(jobs: "queue.Queue[SessionResult | None]", handle: Callable[[SessionResult], object]) -> None:
"""Traite les sessions une à une, hors du fil réseau : chacune dure des secondes."""
while True:
result = jobs.get()
if result is None:
return
try:
handle(result)
except Exception: # une session ratée ne doit jamais arrêter le serveur
logger.exception("%s : echec du traitement de %s", result.device, result.path.name)
def main(argv: list[str]) -> int:
"""Lance le serveur jusqu'à Ctrl-C."""
parser = argparse.ArgumentParser(description="serveur de l'assistant vocal")
parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[1] / "config.yaml")
parser.add_argument("--echo", action="store_true",
help="renvoie chaque session a la carte, qui la joue (etape 5), sans modeles")
args = parser.parse_args(argv)
settings = load_settings(args.config)
setup_logging(settings)
logging.getLogger("faster_whisper").setLevel(logging.WARNING) # une ligne par transcription sinon
logger.info("enregistrements dans %s", settings.recordings_dir)
if settings.log_file is not None:
logger.info("journal copie dans %s", settings.log_file)
if settings.mqtt.username is None:
logger.warning("pas d'identifiants MQTT (server/.env) : refuse par un broker securise (etape 15)")
link = MqttLink(settings.mqtt, presence=True)
resampler = AudioResampler(settings.audio)
sender = AudioSender(link, settings.audio)
if args.echo:
logger.info("mode echo : chaque session sera rejouee par la carte")
handle = make_echo(resampler, sender)
else:
try:
handle = make_assistant(settings, resampler, sender, link)
except ImportError as error:
logger.error("%s", error)
return 1
except FileNotFoundError as error:
logger.error("%s (ou diarization.enabled: false dans config.yaml)", error)
return 1
logger.info("mode assistant : appuyez sur le bouton et posez une question")
jobs: "queue.Queue[SessionResult | None]" = queue.Queue()
def on_finished(result: SessionResult) -> None:
if result.outcome is not Outcome.INTERRUPTED:
jobs.put(result) # rend la main aussitôt : le traitement dure des secondes
sessions = SessionManager(settings.audio, settings.sessions, settings.recordings_dir,
on_finished=on_finished)
listener = None
if settings.wake.enabled and not args.echo:
try:
listener = make_listener(settings, link, on_finished)
except (ImportError, FileNotFoundError) as error:
logger.error("%s : lancez 'make install-wakeword' et 'make models', "
"ou wake.enabled: false dans config.yaml", error)
return 1
logger.info("mains libres : dites %s", ", ".join(f"« {w} »" for w in settings.wake.words))
threading.Thread(target=worker, args=(jobs, handle), daemon=True).start()
def on_state(device: str, payload: bytes) -> None:
log_state(device, payload)
if listener is not None:
listener.on_state(device, payload)
def on_event(device: str, payload: bytes) -> None:
sessions.on_event(device, payload)
if listener is not None:
listener.on_event(device, payload)
link.on("state", on_state)
link.on("event", on_event)
link.on("audio/in", sessions.on_audio)
if listener is not None:
link.on("audio/stream", listener.on_stream)
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
link.start()
try:
while not stop.wait(SWEEP_PERIOD_S):
sessions.sweep()
if listener is not None:
listener.sweep()
except KeyboardInterrupt:
pass
finally:
jobs.put(None)
if listener is not None:
listener.stop() # les cartes cessent d'envoyer leur micro
time.sleep(0.5) # laisse partir ces messages avant la déconnexion
link.stop()
logger.info("arret du serveur")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+179
View File
@@ -0,0 +1,179 @@
"""Droits par profil : qui peut demander quoi (étape 14).
Trois niveaux, du plus au moins permissif :
complet tout, y compris les actions sensibles, qui demandent une confirmation ;
standard la domotique courante (lumières, volets, fermer la porte), réveiller
ou verrouiller une machine (étape 15b) ;
limite la conversation seulement (heure, date…).
L'identification vocale est une ESTIMATION : une imitation ou un enregistrement
peut tromper. Ces droits évitent les erreurs et les commandes d'un invité, pas
un attaquant déterminé. D'où deux garde-fous : le niveau complet exige une voix
reconnue nettement, et les actions sensibles une confirmation par la même personne.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from voice_server.home_control import ALLOWED, announce, request
from voice_server.machine_control import MACHINE_DEVICE, Command, MachineCommand
from voice_server.settings import AccessSettings, HomeSettings, MachinesSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
YES = ("oui", "confirme", "confirmer", "ok", "okay", "valide")
NO = ("non", "annule", "annuler", "stop", "laisse")
class Level(IntEnum):
"""Niveau de droits ; l'ordre permet de comparer (complet > standard > limite)."""
LIMITED = 1
STANDARD = 2
FULL = 3
LEVELS = {"limite": Level.LIMITED, "standard": Level.STANDARD, "complet": Level.FULL}
LEVEL_NAMES = {level: name for name, level in LEVELS.items()}
def of(phrase: str) -> str:
"""« de » élidé devant une voyelle : « d'ouvrir », « d'éteindre », mais « de fermer »."""
return f"d'{phrase}" if normalize(phrase)[:1] in "aeiouyh" else f"de {phrase}"
def parse_level(name: str) -> Level:
"""« complet » → Level.FULL ; erreur claire si le nom est inconnu."""
level = LEVELS.get(normalize(name))
if level is None:
raise ValueError(f"niveau de droits inconnu : « {name} » (complet, standard ou limite)")
return level
@dataclass(frozen=True)
class Verdict:
"""Décision pour une commande."""
allowed: bool
confirm: bool # exécuter seulement après un « oui »
answer: str | None # refus ou question de confirmation ; None : on exécute
@dataclass(frozen=True)
class Resolution:
"""Réponse à une confirmation en attente."""
answer: str
command: Command | None # à exécuter (oui), None (non, autre personne)
@dataclass(frozen=True)
class _Pending:
command: Command
speaker: str
expires: float
class AccessPolicy:
"""Applique les droits de config.yaml et garde les confirmations en attente, par carte."""
def __init__(self, access: AccessSettings, home: HomeSettings, machines: MachinesSettings | None = None,
clock: Callable[[], float] = time.monotonic) -> None:
"""
Args:
machines: actions des machines (étape 15b), pour valider access.rules.machine.
Raises:
ValueError: niveau ou action inconnus dans config.yaml.
"""
self._access = access
self._home = home
self._clock = clock
self._unknown = parse_level(access.unknown_level)
self._default = parse_level(access.default_level)
self._users = {name: parse_level(level) for name, level in access.users.items()}
self._rules = {device: {action: parse_level(level) for action, level in actions.items()}
for device, actions in access.rules.items()}
allowed = {**ALLOWED, MACHINE_DEVICE: tuple(machines.actions) if machines is not None else ()}
for device, actions in self._rules.items():
# Une règle mal écrite serait ignorée en silence (action exigeant alors « complet »).
unknown = [f"{device}.{action}" for action in actions if action not in allowed.get(device, ())]
if unknown:
raise ValueError(f"config.yaml, access.rules : action inconnue {', '.join(unknown)} "
f"(connues : {', '.join(f'{d}.{a}' for d, acts in allowed.items() for a in acts)})")
self._pending: dict[str, _Pending] = {}
def level(self, speaker: str | None, score: float | None) -> Level:
"""Niveau de la personne ; le niveau complet exige une identification nette."""
if speaker is None:
return self._unknown
level = self._users.get(speaker, self._default)
if level is Level.FULL and (score is None or score < self._access.full_min_score):
return Level.STANDARD # voix reconnue de justesse : pas assez sûr pour tout permettre
return level
def check(self, command: Command, speaker: str | None, score: float | None) -> Verdict:
"""La personne peut-elle faire cette commande, et faut-il confirmer ?"""
# Action absente des règles : réservée au niveau complet (on ne permet pas par oubli).
required = self._rules.get(command.device, {}).get(command.action, Level.FULL)
level = self._level_logged(speaker, score, command, required)
what = self._request(command)
if level < required:
if speaker is None:
return Verdict(False, False, f"Je ne vous ai pas reconnu : je ne peux pas {what}.")
if self._users.get(speaker, self._default) >= required:
# Le profil le permet, mais la voix a été reconnue de justesse (full_min_score).
return Verdict(False, False, f"{speaker}, je ne reconnais pas assez nettement votre voix "
f"pour {what}. Répétez, plus près du micro.")
return Verdict(False, False, f"Désolé {speaker}, votre profil ne permet pas {of(what)}.")
if f"{command.device}.{command.action}" in self._access.confirm:
return Verdict(True, True, f"Confirmez-vous : {what} ? Dites oui ou non.")
return Verdict(True, False, None)
def ask(self, board: str, command: Command, speaker: str | None) -> None:
"""Retient une commande en attente de « oui » de la même personne."""
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."""
pending = self._pending.pop(board, None)
if pending is None or self._clock() > pending.expires:
return None
words = normalize(text).split()
said_yes = any(word in YES for word in words) or "d accord" in " ".join(words)
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:
logger.warning("%s : confirmation de %s refusee, donnee par %s", board, pending.speaker,
speaker or "une voix inconnue")
return Resolution(f"Seul {pending.speaker} peut confirmer. J'annule.", None)
if said_no:
return Resolution("D'accord, j'annule.", None)
return Resolution(self._announce(pending.command), pending.command)
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)
def _announce(self, command: Command) -> str:
"""« J'ouvre la porte du salon. », « J'éteins le PC du bureau. »"""
return command.said if isinstance(command, MachineCommand) else announce(command, self._home)
def _level_logged(self, speaker: str | None, score: float | None, command: Command,
required: Level) -> Level:
level = self.level(speaker, score)
verdict = "autorise" if level >= required else "REFUSE"
target = command.machine if isinstance(command, MachineCommand) else command.room
logger.info("droits : %s (niveau %s, score %s) %s %s %s : %s (requis %s)",
speaker or "inconnu", LEVEL_NAMES[level], "?" if score is None else f"{score:.2f}",
command.action, command.device, target, verdict, LEVEL_NAMES[required])
return level
+129
View File
@@ -0,0 +1,129 @@
"""Choix de la réponse à une phrase transcrite (étapes 6, 10, 11 et 15b).
D'abord les machines (étape 15b) : une phrase qui nomme une machine (« le PC du
bureau ») la concerne, même si elle contient « éteins ». Puis la domotique
(étape 11) : une action, un appareil, une pièce donnent une commande. Sinon,
quelques règles de conversation dont on connaît d'avance la réponse (heure,
date, salutations) ; à défaut, la phrase est répétée.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from voice_server.home_control import decide, parse_intent
from voice_server.machine_control import Command, MachineDecision
from voice_server.settings import HomeSettings
from voice_server.text_normalize import normalize
DAYS = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
MONTHS = ("janvier", "février", "mars", "avril", "mai", "juin", "juillet",
"août", "septembre", "octobre", "novembre", "décembre")
NOTHING_HEARD = "Je n'ai rien entendu."
@dataclass(frozen=True)
class Reply:
"""Ce que l'assistant dit, et la commande domotique à exécuter s'il y en a une."""
text: str
command: Command | None = None
class MachineInterpreter(Protocol):
"""Ce que l'assistant attend des machines (MachineController)."""
def interpret(self, text: str) -> MachineDecision | None: ...
def say_time(now: datetime) -> str:
"""L'heure écrite comme on la dit : Piper lit mal « 14:05 »."""
if now.hour == 0:
hours = "minuit"
elif now.hour == 12:
hours = "midi"
elif now.hour == 1:
hours = "une heure" # écrit « 1 heure », Piper dit « un heure »
else:
hours = f"{now.hour} heures"
return f"Il est {hours} {now.minute}." if now.minute else f"Il est {hours}."
def say_date(now: datetime) -> str:
"""La date en toutes lettres, « premier » pour le 1er du mois."""
day = "premier" if now.day == 1 else str(now.day)
return f"Nous sommes le {DAYS[now.weekday()]} {day} {MONTHS[now.month - 1]} {now.year}."
class Assistant:
"""Associe une réponse à une phrase, par mots-clés.
Les règles sont essayées dans l'ordre : la plus précise d'abord, pour que
« bonjour, quelle heure est-il ? » donne l'heure et non une salutation.
"""
def __init__(self, clock: Callable[[], datetime] = datetime.now, home: HomeSettings | None = None,
machines: MachineInterpreter | None = None) -> None:
"""
Args:
clock: source de l'heure, injectable pour les tests.
home: pièces et cartes (étape 11) ; None : pas de domotique.
machines: machines du réseau (étape 15b) ; None : pas de machines.
"""
self._clock = clock
self._home = home
self._machines = machines
# (mots-clés normalisés, réponse) : une seule occurrence suffit.
self._rules: list[tuple[tuple[str, ...], Callable[[str | None], str]]] = [
# Whisper écrit parfois « Quel heure » : on accepte les deux accords.
(("quelle heure", "quel heure", "l heure", "heure est il"), lambda who: say_time(self._clock())),
(("quel jour", "la date", "quelle date"), lambda who: say_date(self._clock())),
(("comment tu t appelles", "qui es tu", "ton nom"),
lambda who: "Je suis votre assistant vocal, et je fonctionne sans Internet."),
# Étape 10 : l'assistant sait qui parle, il l'appelle par son nom.
(("bonjour", "bonsoir", "salut"),
lambda who: f"Bonjour {who} ! Que puis-je faire pour vous ?" if who
else "Bonjour ! Que puis-je faire pour vous ?"),
(("merci",), lambda who: f"Avec plaisir, {who}." if who else "Avec plaisir."),
# Étape 11 : sans Internet, pas de météo ; mieux vaut le dire que répéter la question.
(("meteo", "quel temps"), lambda who: "Je fonctionne sans Internet : je n'ai pas accès à la météo."),
]
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply:
"""Machines, puis domotique, puis conversation.
Args:
text: phrase transcrite.
speaker: nom de la personne reconnue, None si inconnue.
board: carte qui a entendu la phrase : sa pièce est la pièce par défaut.
"""
if self._machines is not None:
decision = self._machines.interpret(text)
if decision is not None:
return Reply(decision.answer, decision.command)
if self._home is not None:
intent = parse_intent(text, list(self._home.rooms))
if intent is not None:
decision = decide(intent, self._home, board)
return Reply(decision.answer, decision.command)
return Reply(self.reply(text, speaker))
def reply(self, text: str, speaker: str | None = None) -> str:
"""Rend la réponse à dire ; à défaut de règle, répète ce qui a été compris.
Args:
text: phrase transcrite.
speaker: nom de la personne reconnue, None si inconnue.
"""
words = normalize(text)
if not words:
return NOTHING_HEARD
padded = f" {words} " # espaces : « salut » ne doit pas reconnaître « salutations »
for keywords, answer in self._rules:
if any(f" {keyword} " in padded for keyword in keywords):
return answer(speaker)
return f"Vous avez dit : {text.strip()}"
+76
View File
@@ -0,0 +1,76 @@
"""Accumulation du PCM d'une session et écriture en WAV."""
from __future__ import annotations
import math
import wave
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from voice_server.settings import AudioSettings
@dataclass(frozen=True)
class Levels:
"""Niveaux d'un signal, en dBFS (0 = pleine échelle)."""
peak_dbfs: float
rms_dbfs: float
class AudioBuffer:
"""Tampon PCM s16le borné en taille, pour une seule session."""
def __init__(self, audio: AudioSettings, max_seconds: float) -> None:
self._audio = audio
self._max_bytes = int(audio.bytes_per_second * max_seconds)
self._data = bytearray()
self.truncated = False # vrai si des données ont été refusées (trop long)
def append(self, pcm: bytes) -> None:
"""Ajoute un chunk ; au-delà de la durée maximale, le surplus est ignoré."""
room = self._max_bytes - len(self._data)
if len(pcm) > room:
self.truncated = True
pcm = pcm[: max(0, room)]
self._data.extend(pcm)
@property
def byte_count(self) -> int:
"""Nombre d'octets accumulés."""
return len(self._data)
@property
def duration_s(self) -> float:
"""Durée accumulée, en secondes."""
return len(self._data) / self._audio.bytes_per_second
def levels(self) -> Levels:
"""Crête et valeur efficace du contenu."""
samples = np.frombuffer(bytes(self._data[: len(self._data) // 2 * 2]), dtype="<i2")
if samples.size == 0:
return Levels(peak_dbfs=-math.inf, rms_dbfs=-math.inf)
normalized = samples.astype(np.float64) / 32768.0
peak = float(np.max(np.abs(normalized)))
rms = float(np.sqrt(np.mean(normalized**2)))
return Levels(
peak_dbfs=20.0 * math.log10(peak) if peak > 0 else -math.inf,
rms_dbfs=20.0 * math.log10(rms) if rms > 0 else -math.inf,
)
def write_wav(self, path: Path) -> None:
"""Écrit le contenu dans un fichier WAV (dossiers créés au besoin)."""
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(self._audio.channels)
wav.setsampwidth(self._audio.bits // 8)
wav.setframerate(self._audio.sample_rate)
wav.writeframes(bytes(self._data))
def read_pcm(path: Path) -> bytes:
"""Relit le PCM d'un WAV écrit par write_wav (format du système, sans conversion)."""
with wave.open(str(path), "rb") as wav:
return wav.readframes(wav.getnframes())
+91
View File
@@ -0,0 +1,91 @@
"""Mise au format du système de tout audio destiné à un ESP32."""
from __future__ import annotations
import math
import wave
from pathlib import Path
import numpy as np
import soxr
from voice_server.settings import AudioSettings
class AudioFormatError(ValueError):
"""Fichier audio que le module wave ne sait pas lire."""
class AudioResampler:
"""Convertit en PCM s16le mono au format du système, crête normalisée.
L'ESP32 reste simple : il ne sait jouer que du 16 kHz mono. Tout le reste
se fait ici — Piper, par exemple, produit du 22 050 Hz (étape 6).
La normalisation de crête fixe le volume d'écoute : sans elle, une voix
enregistrée à -30 dBFS sort presque inaudible du haut-parleur (étape 2).
"""
def __init__(self, audio: AudioSettings, target_peak_dbfs: float | None = -3.0) -> None:
"""
Args:
audio: format de sortie (fréquence du système).
target_peak_dbfs: crête visée, None pour ne pas toucher au niveau.
"""
self._audio = audio
self._target_peak = None if target_peak_dbfs is None else 10.0 ** (target_peak_dbfs / 20.0)
def from_wav(self, path: Path) -> bytes:
"""Lit un WAV PCM (8, 16, 24 ou 32 bits, mono ou stéréo, toute fréquence)."""
try:
with wave.open(str(path), "rb") as wav:
raw = wav.readframes(wav.getnframes())
width = wav.getsampwidth()
channels = wav.getnchannels()
rate = wav.getframerate()
except (wave.Error, EOFError) as error: # EOFError : fichier tronqué
raise AudioFormatError(
f"{path} : {error}. Conversion : ffmpeg -i {path} -ar 16000 -ac 1 -sample_fmt s16 sortie.wav"
) from error
samples = _decode_pcm(raw, width).reshape(-1, channels)
return self.convert(samples, rate)
def convert(self, samples: np.ndarray, rate: int) -> bytes:
"""Convertit des échantillons flottants dans [-1, 1], forme (n,) ou (n, canaux)."""
signal = np.asarray(samples, dtype=np.float64)
if signal.ndim == 2:
signal = signal.mean(axis=1) # mono : moyenne des canaux
if rate != self._audio.sample_rate and signal.size > 0:
# soxr : rééchantillonneur de référence, sans repliement audible.
signal = soxr.resample(signal, rate, self._audio.sample_rate, quality="HQ")
peak = float(np.max(np.abs(signal))) if signal.size else 0.0
if self._target_peak is not None and peak > 0.0:
signal = signal * (self._target_peak / peak)
clipped = np.clip(np.round(signal * 32767.0), -32768, 32767)
return clipped.astype("<i2").tobytes()
def _decode_pcm(raw: bytes, width: int) -> np.ndarray:
"""Décode du PCM entier little-endian en flottants dans [-1, 1]."""
if width == 1: # 8 bits : non signé, centré sur 128
return (np.frombuffer(raw, dtype=np.uint8).astype(np.float64) - 128.0) / 128.0
if width == 2:
return np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
if width == 3: # 24 bits : on complète chaque échantillon en 32 bits
bytes3 = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
padded = np.zeros((bytes3.shape[0], 4), dtype=np.uint8)
padded[:, 1:] = bytes3
return padded.view("<i4").reshape(-1).astype(np.float64) / 2147483648.0
if width == 4:
return np.frombuffer(raw, dtype="<i4").astype(np.float64) / 2147483648.0
raise AudioFormatError(f"largeur d'echantillon non geree : {width * 8} bits")
def peak_dbfs(pcm: bytes) -> float:
"""Crête d'un PCM s16le, en dBFS."""
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
peak = int(np.max(np.abs(samples.astype(np.int32)))) if samples.size else 0
return 20.0 * math.log10(peak / 32768.0) if peak > 0 else -math.inf
+99
View File
@@ -0,0 +1,99 @@
"""Envoi de PCM à un ESP32 par MQTT, au rythme réel (étape 5)."""
from __future__ import annotations
import json
import logging
import time
import uuid
from collections.abc import Callable
from typing import Protocol
from voice_server.settings import AudioSettings
logger = logging.getLogger(__name__)
class Publisher(Protocol):
"""Ce dont l'envoi a besoin du lien MQTT : publier vers une carte."""
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
def send_times(count: int, chunk_s: float, prefill: int) -> list[float]:
"""Instants d'envoi de chaque chunk, en secondes depuis le début.
Les `prefill` premiers partent aussitôt : c'est l'avance qui protège l'ESP32
des irrégularités du Wi-Fi. Chaque suivant part quand l'ESP32 a joué
l'équivalent d'un chunk, ce qui maintient l'avance constante.
"""
return [max(0.0, (index - prefill + 1) * chunk_s) for index in range(count)]
class AudioSender:
"""Envoie un PCM déjà au format du système : START, chunks, END.
START et END passent sur voice/<carte>/control en QoS 1 (garantis) ;
les chunks sur voice/<carte>/audio/out en QoS 0 (rapides, perte possible,
comptée par l'ESP32 grâce au nombre annoncé dans le END).
L'appel est bloquant pendant toute la durée du son : à lancer dans un fil
dédié, jamais dans le fil réseau de paho.
"""
def __init__(
self,
link: Publisher,
audio: AudioSettings,
prefill_chunks: int = 6,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
link: lien MQTT.
audio: format du système.
prefill_chunks: avance envoyée d'un coup ; 6 chunks = 300 ms, la FIFO
de l'ESP32 en contient 512.
sleep, clock: injectables pour les tests.
"""
self._link = link
self._audio = audio
self._prefill = prefill_chunks
self._sleep = sleep
self._clock = clock
@property
def chunk_bytes(self) -> int:
"""Taille d'un chunk : 1600 octets pour 50 ms à 16 kHz."""
return self._audio.bytes_per_second * self._audio.chunk_ms // 1000
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
"""Envoie le PCM et rend l'identifiant de la session de lecture."""
session_id = session or f"srv-{uuid.uuid4().hex[:6]}"
even = pcm[: len(pcm) // 2 * 2] # jamais de demi-échantillon
size = self.chunk_bytes
chunks = [even[i : i + size] for i in range(0, len(even), size)]
schedule = send_times(len(chunks), self._audio.chunk_ms / 1000.0, self._prefill)
start = {"event": "start", "session": session_id, "rate": self._audio.sample_rate,
"bits": self._audio.bits, "channels": self._audio.channels,
"codec": self._audio.codec, "chunk_ms": self._audio.chunk_ms}
self._link.publish(device, "control", json.dumps(start).encode(), 1)
began = self._clock()
worst_delay = 0.0
for chunk, due in zip(chunks, schedule):
wait = began + due - self._clock()
if wait > 0:
self._sleep(wait)
else:
worst_delay = max(worst_delay, -wait)
self._link.publish(device, "audio/out", chunk, 0)
end = {"event": "end", "session": session_id, "chunks": len(chunks)}
self._link.publish(device, "control", json.dumps(end).encode(), 1)
logger.info("%s : lecture %s envoyee, %d chunks (%.2f s), pire retard d'envoi %.0f ms",
device, session_id, len(chunks), len(even) / self._audio.bytes_per_second,
worst_delay * 1000)
return session_id
+33
View File
@@ -0,0 +1,33 @@
"""Aiguillage des commandes autorisées vers leur exécutant (étapes 11 et 15b)."""
from __future__ import annotations
import logging
from voice_server.home_control import HomeController
from voice_server.machine_control import Command, MachineCommand, MachineController
logger = logging.getLogger(__name__)
class CommandRouter:
"""Domotique vers HomeController, machines vers MachineController."""
def __init__(self, home: HomeController | None, machines: MachineController | None) -> None:
"""
Args:
home: domotique ; None si le serveur n'a pas de lien MQTT.
machines: agents Linux ; None si le serveur n'a pas de lien MQTT.
"""
self._home = home
self._machines = machines
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]:
"""Exécute la commande ; rend les destinations (journal, tests)."""
if isinstance(command, MachineCommand):
if self._machines is not None:
return self._machines.execute(board, command, speaker, session)
elif self._home is not None:
return self._home.execute(board, command, speaker, session)
logger.warning("%s : pas d'executant pour %s %s", board, command.action, command.device)
return []
+178
View File
@@ -0,0 +1,178 @@
"""Diarisation : qui parle quand, avec pyannote (étape 8).
La diarisation découpe l'audio en tours de parole étiquetés SPEAKER_00,
SPEAKER_01… Elle ne sait pas QUI parle : les étiquettes ne valent que pour
une session. Mettre un nom sur une voix, c'est l'identification (étape 9).
Elle ne reçoit que la parole trouvée par la VAD, jamais le flux continu du
micro : c'est le traitement le plus coûteux de la chaîne.
Étape 9 : une empreinte vocale par locuteur, calculée ici avec le modèle
d'empreintes du même pipeline (WeSpeaker ResNet34). Celles que rend pyannote ne
conviennent pas aux questions courtes : il ne garde que les fenêtres de 10 s où
le locuteur parle seul au moins 2 s ; en dessous, sa moyenne est NaN.
"""
from __future__ import annotations
import logging
import os
import time
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
from voice_server.settings import DiarizationSettings
if TYPE_CHECKING: # import réel dans __init__ : l'extra « diarization » est facultatif
from pyannote.audio import Inference, Pipeline
logger = logging.getLogger(__name__)
MIN_EMBEDDING_S = 0.3 # en dessous, l'empreinte ne vaut rien : le locuteur reste « Inconnu »
@dataclass(frozen=True)
class SpeakerTurn:
"""Un tour de parole : un locuteur, de start_s à end_s."""
speaker: str
start_s: float
end_s: float
@dataclass(frozen=True)
class Diarization:
"""Résultat de la diarisation d'une séquence parlée."""
turns: tuple[SpeakerTurn, ...] # dans l'ordre chronologique, sans chevauchement
speakers: tuple[str, ...] # étiquettes distinctes, ordre de embeddings
embeddings: np.ndarray | None # (locuteurs, dimension), ligne NaN si trop peu de parole
audio_s: float
elapsed_s: float
def describe(self) -> str:
"""Tours de parole lisibles : « SPEAKER_00 0.00-1.20, SPEAKER_01 1.35-2.60 »."""
return ", ".join(f"{t.speaker} {t.start_s:.2f}-{t.end_s:.2f}" for t in self.turns)
def disable_telemetry() -> None:
"""Coupe la télémétrie de pyannote.
pyannote 4 envoie par défaut des statistiques d'usage (durée de l'audio,
nombre de locuteurs) à otel.pyannote.ai : contraire à un assistant 100 % local.
La variable est relue à chaque traitement : la forcer ici suffit.
"""
os.environ["PYANNOTE_METRICS_ENABLED"] = "false"
class Diarizer:
"""Applique le pipeline pyannote à un PCM s16le mono, sur CPU."""
def __init__(self, settings: DiarizationSettings, sample_rate: int) -> None:
"""
Raises:
FileNotFoundError: le modèle n'a pas été téléchargé.
ImportError: l'extra « diarization » n'est pas installé.
"""
if not (settings.model_dir / "config.yaml").exists():
raise FileNotFoundError(f"modele de diarisation absent de {settings.model_dir} : "
"HF_TOKEN dans server/.env, puis 'make models'")
disable_telemetry()
from pyannote.audio import Pipeline
began = time.monotonic()
# Dossier local : pyannote n'interroge pas le hub (ses sous-modèles sont des sous-dossiers).
pipeline = Pipeline.from_pretrained(settings.model_dir)
if pipeline is None:
raise RuntimeError(f"pipeline pyannote illisible : {settings.model_dir}")
self._pipeline: Pipeline = pipeline
from pyannote.audio import Inference, Model
# Empreintes : le sous-modèle du pipeline, appliqué à tout l'audio d'un locuteur.
embedding_model = Model.from_pretrained(settings.model_dir / "embedding")
if embedding_model is None:
raise RuntimeError(f"modele d'empreintes illisible : {settings.model_dir / 'embedding'}")
self._embedding: Inference = Inference(embedding_model, window="whole")
dimension: Any = embedding_model.dimension # propriété pyannote, mal typée par torch.nn.Module
self._dimension = int(dimension)
self._settings = settings
self._sample_rate = sample_rate
# Premier passage à vide : les calculs s'initialisent ici, pas à la première question.
self.diarize(bytes(sample_rate * 2))
logger.info("diarisation %s chargee en %.1f s", settings.model, time.monotonic() - began)
def diarize(self, pcm: bytes) -> Diarization:
"""Rend les tours de parole et une empreinte par locuteur ; instants relatifs au début du PCM."""
began = time.monotonic()
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
waveform = self._waveform(audio)
with warnings.catch_warnings():
# torch prévient quand un morceau de parole est trop court pour un écart-type :
# sans conséquence sur le résultat, mais une ligne de journal à chaque session.
warnings.filterwarnings("ignore", message=r"std\(\): degrees of freedom")
# Ses propres empreintes sont NaN sur une question courte (voir en tête du module) :
# numpy le signale, mais on ne s'en sert pas.
warnings.filterwarnings("ignore", message="Mean of empty slice", category=RuntimeWarning)
warnings.filterwarnings("ignore", message="invalid value encountered in divide",
category=RuntimeWarning)
output: Any = self._pipeline({"waveform": waveform, "sample_rate": self._sample_rate},
max_speakers=self._settings.max_speakers)
# Version « exclusive » : un seul locuteur à la fois, pour découper le texte (étape 10)
# et pour que chaque empreinte ne contienne qu'une voix.
annotation = output.exclusive_speaker_diarization
turns = tuple(
SpeakerTurn(speaker=str(label), start_s=float(segment.start), end_s=float(segment.end))
for segment, _, label in annotation.itertracks(yield_label=True)
)
speakers = tuple(str(label) for label in annotation.labels())
embeddings = np.stack([self._speaker_embedding(audio, turns, speaker) for speaker in speakers]) \
if speakers else None
return Diarization(
turns=turns,
speakers=speakers,
embeddings=embeddings,
audio_s=audio.size / self._sample_rate,
elapsed_s=time.monotonic() - began,
)
def embed(self, pcm: bytes) -> np.ndarray:
"""Empreinte vocale d'un PCM entier (enrôlement : une seule voix attendue)."""
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
return self._embed(audio)
def _speaker_embedding(self, audio: np.ndarray, turns: tuple[SpeakerTurn, ...], speaker: str) -> np.ndarray:
"""Empreinte de toute la parole d'un locuteur, ses tours mis bout à bout."""
pieces = [audio[round(t.start_s * self._sample_rate):round(t.end_s * self._sample_rate)]
for t in turns if t.speaker == speaker]
return self._embed(np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32))
def _embed(self, audio: np.ndarray) -> np.ndarray:
if audio.size < MIN_EMBEDDING_S * self._sample_rate:
return np.full(self._dimension, np.nan, dtype=np.float32)
embedding: Any = self._embedding({"waveform": self._waveform(audio), "sample_rate": self._sample_rate})
return np.asarray(embedding, dtype=np.float32).reshape(-1)
@staticmethod
def _waveform(audio: np.ndarray) -> Any:
"""Audio en mémoire au format pyannote : tenseur (canaux, échantillons), sans fichier."""
import torch
return torch.from_numpy(audio.copy()).unsqueeze(0)
def timeline(diarization: Diarization, step_s: float = 0.1) -> list[str]:
"""Une ligne par locuteur, un caractère par step_s : « # » quand il parle."""
width = max(1, round(diarization.audio_s / step_s))
lines: list[str] = []
for speaker in diarization.speakers:
row = ["_"] * width
for turn in diarization.turns:
if turn.speaker == speaker:
for index in range(round(turn.start_s / step_s), min(width, round(turn.end_s / step_s))):
row[index] = "#"
lines.append(f"{speaker} {''.join(row)}")
return lines
+285
View File
@@ -0,0 +1,285 @@
"""Écoute mains libres : du flux continu du micro à une session de parole (étape 13).
Chaque carte suit trois états :
VEILLE le flux passe dans le détecteur de mot de réveil ; seules les
dernières preroll_ms restent en mémoire, RIEN n'est écrit sur disque ;
COMMANDE mot entendu : on garde l'audio jusqu'au silence qui suit la commande
(VAD de l'étape 7), puis on en fait une session ordinaire ;
RÉPONSE la carte parle : on n'écoute plus (elle s'entendrait elle-même)
jusqu'à son bilan « played », ou jusqu'au délai de garde.
La session produite est identique à celle d'un appui sur le bouton : la suite
de la chaîne (VAD, diarisation, Whisper, assistant) ne change pas.
Les durées d'une commande (silence final, abandon, durée maximale) se mesurent
en AUDIO REÇU, pas à l'horloge : le Wi-Fi livre souvent les chunks en rafale, et
2 s de son arrivées en 10 ms restent 2 s de parole. L'horloge ne sert qu'aux
délais de garde (réponse sans bilan, flux interrompu).
"""
from __future__ import annotations
import json
import logging
import threading
import time
import uuid
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Protocol
from voice_server.audio_buffer import AudioBuffer
from voice_server.audio_sender import Publisher
from voice_server.session_manager import Outcome, SessionResult
from voice_server.settings import AudioSettings, WakeSettings
from voice_server.voice_activity import VadResult
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
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é
class Detector(Protocol):
"""Ce que l'écoute attend du détecteur de mot de réveil."""
def process(self, pcm: bytes) -> Detection | None: ...
def reset(self) -> None: ...
class SpeechDetector(Protocol):
"""Ce que l'écoute attend de la VAD."""
def detect(self, pcm: bytes) -> VadResult: ...
class Mode(Enum):
"""État d'une carte."""
STANDBY = "veille"
COMMAND = "commande"
ANSWER = "reponse"
@dataclass
class _Board:
"""État d'écoute d'une carte."""
detector: Detector
preroll: deque[bytes]
mode: Mode = Mode.STANDBY
buffer: bytearray = field(default_factory=bytearray)
wake_offset: int = 0 # octets du tampon qui précèdent la détection
word: str = ""
session_id: str = ""
started_at: datetime = field(default_factory=datetime.now)
last_chunk_time: float = 0.0 # horloge : détecte un flux interrompu
unchecked: int = 0 # chunks reçus depuis la dernière recherche du silence final
chunks: int = 0
answer_until: float = 0.0
last_uptime: int = -1
class HandsFreeListener:
"""Transforme le flux continu des cartes en sessions, sur mot de réveil."""
def __init__(
self,
wake: WakeSettings,
audio: AudioSettings,
vad: SpeechDetector,
recordings_dir: Path,
link: Publisher,
on_command: Callable[[SessionResult], None],
detector_factory: Callable[[str], Detector],
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
on_command: reçoit chaque commande, comme une session du bouton ; doit rendre la main vite.
detector_factory: un détecteur neuf par carte (historique audio propre à chacune) ;
reçoit le nom de la carte.
"""
self._wake = wake
self._audio = audio
self._vad = vad
self._recordings_dir = recordings_dir
self._link = link
self._on_command = on_command
self._new_detector = detector_factory
self._clock = clock
self._lock = threading.Lock()
self._boards: dict[str, _Board] = {}
chunk_bytes = audio.bytes_per_second * audio.chunk_ms // 1000
self._preroll_chunks = max(1, wake.preroll_ms * audio.bytes_per_second // 1000 // chunk_bytes)
# ------------------------------------------------------------ cartes
def on_state(self, device: str, payload: bytes) -> None:
"""État publié par une carte : on (ré)active son écoute à la connexion ou au redémarrage."""
try:
state = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
if state.get("status") != "online":
with self._lock:
self._boards.pop(device, None) # la carte réactivera l'écoute à son retour
return
uptime = int(state.get("uptime_s", 0))
with self._lock:
board = self._board(device)
first_time = board.last_uptime < 0 or uptime < board.last_uptime # nouvelle carte ou redémarrage
board.last_uptime = uptime
if first_time:
self._send(device, {"cmd": "listen", "enabled": True})
logger.info("%s : ecoute mains libres activee (mots : %s)", device, ", ".join(self._wake.words))
def stop(self) -> None:
"""Arrêt du serveur : les cartes cessent d'envoyer leur micro."""
with self._lock:
devices = list(self._boards)
for device in devices:
self._send(device, {"cmd": "listen", "enabled": False})
# ------------------------------------------------------------ flux audio
def on_stream(self, device: str, payload: bytes) -> None:
"""Chunk du flux continu (voice/<carte>/audio/stream). Fil réseau : ~1 ms par chunk."""
if len(payload) % 2 != 0:
return
result: SessionResult | None = None
with self._lock:
board = self._board(device)
if board.mode is Mode.STANDBY:
board.preroll.append(payload) # seule mémoire de ce qui précède le mot
detection = board.detector.process(payload)
if detection is not None:
self._start_command(device, board, detection)
elif board.mode is Mode.COMMAND:
board.buffer.extend(payload)
board.chunks += 1
board.unchecked += 1
board.last_chunk_time = self._clock()
result = self._check_command(device, board)
# Mode.ANSWER : la carte parle ; ce que capte son micro est ignoré.
if result is not None:
self._on_command(result)
def on_event(self, device: str, payload: bytes) -> None:
"""Bilan « played » d'une carte : sa réponse est finie, on réécoute."""
try:
event = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
if event.get("event") not in ("played", "rejected"):
return
with self._lock:
board = self._boards.get(device)
if board is not None and board.mode is Mode.ANSWER:
self._standby(board)
def sweep(self) -> list[SessionResult]:
"""Fil principal : fin de réponse sans bilan, commande dont le flux s'est arrêté."""
now = self._clock()
results: list[SessionResult] = []
with self._lock:
for device, board in self._boards.items():
if board.mode is Mode.ANSWER and now >= board.answer_until:
logger.warning("%s : pas de bilan de lecture, reprise de l'ecoute", device)
self._standby(board)
elif board.mode is Mode.COMMAND and now - board.last_chunk_time > STREAM_LOST_S:
result = self._finish(device, board, "flux interrompu")
if result is not None:
results.append(result)
for result in results:
self._on_command(result)
return results
def mode(self, device: str) -> Mode | None:
"""État d'écoute d'une carte (journal, tests)."""
with self._lock:
board = self._boards.get(device)
return board.mode if board is not None else None
# ------------------------------------------------------------ interne (verrou tenu)
def _board(self, device: str) -> _Board:
board = self._boards.get(device)
if board is None:
board = _Board(detector=self._new_detector(device), preroll=deque(maxlen=self._preroll_chunks))
self._boards[device] = board
return board
def _start_command(self, device: str, board: _Board, detection: Detection) -> None:
board.mode = Mode.COMMAND
board.buffer = bytearray(b"".join(board.preroll))
board.preroll.clear()
board.wake_offset = len(board.buffer)
board.word = detection.word
board.session_id = f"w{uuid.uuid4().hex[:5]}"
board.started_at = datetime.now()
board.last_chunk_time = self._clock()
board.unchecked = 0
board.chunks = self._preroll_chunks
logger.info("%s : mot de reveil « %s » (%.2f), session %s : j'ecoute la commande",
device, detection.word, detection.score, board.session_id)
self._send(device, {"cmd": "capture", "active": True})
def _check_command(self, device: str, board: _Board) -> SessionResult | None:
"""Commande finie ? Silence après la parole, rien dit, ou durée maximale (en audio reçu)."""
elapsed = (len(board.buffer) - board.wake_offset) / self._audio.bytes_per_second
if elapsed >= self._wake.max_command_s:
return self._finish(device, board, "duree maximale")
if board.unchecked < CHECK_EVERY_CHUNKS:
return None
board.unchecked = 0
vad = self._vad.detect(bytes(board.buffer))
rate = self._audio.sample_rate
wake_sample = board.wake_offset // 2
after_wake = [s for s in vad.segments if s.end > wake_sample + COMMAND_AFTER_WAKE_S * rate]
if not after_wake:
if elapsed >= self._wake.no_speech_timeout_s:
logger.info("%s : mot de reveil sans commande, retour en veille", device)
self._send(device, {"cmd": "capture", "active": False})
self._standby(board)
return None
silence_s = (len(board.buffer) // 2 - after_wake[-1].end) / rate
if silence_s * 1000 >= self._wake.end_silence_ms:
return self._finish(device, board, "fin de phrase")
return None
def _finish(self, device: str, board: _Board, reason: str) -> SessionResult | None:
"""La commande devient une session ordinaire, écrite en WAV comme celles du bouton."""
audio = AudioBuffer(self._audio, self._wake.max_command_s + 1)
audio.append(bytes(board.buffer))
stamp = board.started_at.strftime("%Y%m%d-%H%M%S")
path = self._recordings_dir / f"{device}_{stamp}_{board.session_id}.wav"
audio.write_wav(path)
result = SessionResult(device=device, session_id=board.session_id, outcome=Outcome.COMPLETE,
path=path, duration_s=audio.duration_s, chunks_received=board.chunks,
chunks_announced=None, levels=audio.levels())
logger.info("%s : commande %s finie (%s), %.2f s apres « %s » -> %s", device, board.session_id,
reason, audio.duration_s, board.word, path.name)
self._send(device, {"cmd": "capture", "active": False})
board.mode = Mode.ANSWER
board.buffer = bytearray()
board.answer_until = self._clock() + self._wake.cooldown_s
return result
def _standby(self, board: _Board) -> None:
board.mode = Mode.STANDBY
board.buffer = bytearray()
board.preroll.clear()
board.detector.reset() # sinon l'historique du mot précédent pourrait redéclencher
def _send(self, device: str, message: dict[str, object]) -> None:
self._link.publish(device, "control", json.dumps(message).encode(), 1)
+161
View File
@@ -0,0 +1,161 @@
"""Domotique : de la phrase à la commande MQTT (étape 11).
Une commande tient en trois mots : une ACTION (allumer, éteindre, ouvrir,
fermer), un APPAREIL (lumière, volets, porte) et une PIÈCE (salon, cuisine…).
L'analyse cherche ces mots dans la phrase transcrite, sans tenir compte des
accents ni de la conjugaison exacte : Whisper écrit « fermé » pour « fermez ».
Sans pièce, c'est celle de la carte qui a entendu la phrase (« ici »).
"""
from __future__ import annotations
import json
import logging
import re
import unicodedata
from dataclasses import dataclass
from typing import Protocol
from voice_server.settings import HomeSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
# Mots normalisés (minuscules, sans accents) → identifiant. Identifiants en anglais :
# ce sont eux qui voyagent dans les topics et le JSON, comme dans le firmware.
ACTION_WORDS = {
"on": ("allume", "allumer", "allumez", "allumes", "rallume", "rallumer"),
"off": ("eteins", "eteint", "eteindre", "eteignez", "coupe", "couper", "coupez"),
"open": ("ouvre", "ouvrir", "ouvrez", "ouvres", "monte", "monter", "montez", "leve", "lever", "levez",
"remonte", "remonter"),
"close": ("ferme", "fermer", "fermez", "fermes", "baisse", "baisser", "baissez", "descends",
"descendre", "descendez"),
}
DEVICE_WORDS = {
"light": ("lumiere", "lumieres", "lampe", "lampes", "eclairage", "plafonnier"),
"shutter": ("volet", "volets", "store", "stores"),
"door": ("porte", "portes"),
}
ALLOWED = {"light": ("on", "off"), "shutter": ("open", "close"), "door": ("open", "close")}
DEVICE_PHRASE = {"light": "la lumière", "shutter": "les volets", "door": "la porte"}
FIRST_PERSON = {"on": "J'allume", "off": "J'éteins", "open": "J'ouvre", "close": "Je ferme"}
INFINITIVE = {"on": "allumer", "off": "éteindre", "open": "ouvrir", "close": "fermer"}
@dataclass(frozen=True)
class DeviceIntent:
"""Ce que la phrase demande ; un champ à None n'a pas été trouvé."""
action: str | None # on | off | open | close
device: str | None # light | shutter | door
room: str | None # pièce de config.yaml, telle qu'écrite (« salle de bain »)
@dataclass(frozen=True)
class DeviceCommand:
"""Commande complète et valide, prête à publier."""
action: str
device: str
room: str
def slug(room: str) -> str:
"""Pièce utilisable dans un topic : « salle de bain » → « salle-de-bain », « entrée » → « entree »."""
ascii_room = unicodedata.normalize("NFKD", room).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", ascii_room.lower()).strip("-")
def _first(words: list[str], vocabulary: dict[str, tuple[str, ...]]) -> str | None:
"""Premier identifiant dont un mot apparaît dans la phrase (dans l'ordre de la phrase)."""
for word in words:
for key, forms in vocabulary.items():
if word in forms:
return key
return None
def parse_intent(text: str, rooms: list[str]) -> DeviceIntent | None:
"""Action, appareil et pièce de la phrase ; None si elle ne parle pas de domotique."""
words = normalize(text).split()
action = _first(words, ACTION_WORDS)
device = _first(words, DEVICE_WORDS)
if action is None and device is None:
return None
padded = f" {' '.join(words)} "
# Pièces les plus longues d'abord : « salle de bain » avant un éventuel « bain ».
room = next((name for name in sorted(rooms, key=len, reverse=True)
if f" {normalize(name)} " in padded), None)
return DeviceIntent(action=action, device=device, room=room)
@dataclass(frozen=True)
class Decision:
"""Réponse à dire, et commande à exécuter si la demande est complète."""
answer: str
command: DeviceCommand | None
def announce(command: DeviceCommand, home: HomeSettings) -> str:
"""Ce que dit l'assistant en exécutant : « Je ferme les volets du salon. »"""
return f"{FIRST_PERSON[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}."
def request(command: DeviceCommand, home: HomeSettings) -> str:
"""La demande à l'infinitif : « ouvrir la porte du salon »."""
return f"{INFINITIVE[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}"
def decide(intent: DeviceIntent, home: HomeSettings, board: str) -> Decision:
"""Complète l'intention (appareil implicite, pièce de la carte) ou dit ce qui manque."""
action, device = intent.action, intent.device
if device is None and action in ("on", "off"):
device = "light" # « allume », « éteins » : c'est de la lumière qu'on parle
if action is None:
return Decision(f"Que dois-je faire avec {DEVICE_PHRASE[device or 'light']} ?", None)
if device is None:
return Decision(f"Que dois-je {INFINITIVE[action]} ?", None)
if action not in ALLOWED[device]:
return Decision(f"Je ne sais pas {INFINITIVE[action]} {DEVICE_PHRASE[device]}.", None)
room = intent.room or home.boards.get(board)
if room is None or room not in home.rooms:
return Decision("Dans quelle pièce ?", None)
command = DeviceCommand(action=action, device=device, room=room)
return Decision(announce(command, home), command)
class TopicPublisher(Protocol):
"""Ce que la domotique attend du lien MQTT."""
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None: ...
class HomeController:
"""Publie les commandes : pour la domotique, et pour la carte si c'est sa pièce."""
def __init__(self, home: HomeSettings, link: TopicPublisher) -> None:
self._home = home
self._link = link
def execute(self, board: str, command: DeviceCommand, speaker: str | None, session: str) -> list[str]:
"""Publie la commande ; rend les topics utilisés (journal, tests).
home/<pièce>/<appareil>/set, QoS 1, non retenu : une commande rejouée à un
abonné qui arrive plus tard ferait bouger un volet sans que personne l'ait demandé.
"""
topic = f"{self._home.topic_prefix}/{slug(command.room)}/{command.device}/set"
payload = {"action": command.action, "by": speaker, "board": board, "session": session}
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
topics = [topic]
if self._home.boards.get(board) == command.room:
local = {"cmd": "device", "device": command.device, "action": command.action}
self._link.publish(board, "control", json.dumps(local).encode(), 1)
topics.append(f"{board}/control")
logger.info("%s : commande %s %s %s -> %s", board, command.action, command.device,
command.room, ", ".join(topics))
return topics
+240
View File
@@ -0,0 +1,240 @@
"""Machines Linux du réseau : de la phrase à l'agent de la machine (étape 15b).
Chaque machine fait tourner un AGENT (agent/voice_agent.py) : un petit client MQTT
qui n'exécute que les actions de SA liste, définie sur la machine elle-même. Le
serveur n'envoie qu'un NOM d'action (« shutdown »), jamais une commande shell :
même un message forgé ne peut rien faire d'autre que ce que la machine a permis.
agent/<machine>/state agent → tous {"status":"online","actions":[...]}, retenu + testament
agent/<machine>/command serveur → agent {"action":"shutdown","id":...,"by":"Denis"}
agent/<machine>/result agent → serveur {"action":...,"ok":true,"code":0,"output":"..."}
« Allume le PC » est à part : éteinte, la machine n'a plus d'agent. Le serveur
envoie alors un paquet Wake-on-LAN (réveil par le réseau) à son adresse MAC.
"""
from __future__ import annotations
import json
import logging
import re
import socket
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from voice_server.home_control import DeviceCommand, TopicPublisher
from voice_server.settings import MachinesSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
WAKE = "wake" # action du serveur lui-même (Wake-on-LAN), pas de l'agent
WOL_PORT = 9 # port « discard » : l'usage pour le paquet magique
MACHINE_DEVICE = "machine" # clé des droits : access.rules.machine.<action>
@dataclass(frozen=True)
class MachineIntent:
"""Ce que la phrase demande à une machine ; action None : pas trouvée."""
machine: str # identifiant de l'agent (pc-bureau)
action: str | None # shutdown, reboot, wake...
@dataclass(frozen=True)
class MachineCommand:
"""Commande complète pour une machine, avec ses phrases (fixées à la décision)."""
action: str
machine: str
said: str # à l'exécution : « J'éteins le PC du bureau. »
asked: str # refus, confirmation : « éteindre le PC du bureau »
device: str = MACHINE_DEVICE # pour les droits, comme DeviceCommand.device
# Toute commande que l'assistant peut produire : domotique (étape 11) ou machine (15b).
Command = DeviceCommand | MachineCommand
@dataclass(frozen=True)
class MachineDecision:
"""Réponse à dire, et commande à exécuter si la demande est possible."""
answer: str
command: MachineCommand | None
@dataclass(frozen=True)
class AgentStatus:
"""Dernier état publié par un agent (retenu par le broker)."""
online: bool
actions: tuple[str, ...] # actions que la machine accepte
def agent_status(payload: bytes) -> AgentStatus | None:
"""{"status":"online","actions":[...]} → AgentStatus ; None si illisible."""
try:
state: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(state, dict): # pyright: ignore[reportUnnecessaryIsInstance]
return None
actions = state.get("actions") or []
return AgentStatus(online=state.get("status") == "online",
actions=tuple(str(action) for action in actions))
def capitalized(phrase: str) -> str:
"""« le PC du bureau » → « Le PC du bureau » (en début de phrase)."""
return phrase[:1].upper() + phrase[1:]
def parse_machine_intent(text: str, settings: MachinesSettings) -> MachineIntent | None:
"""Machine et action de la phrase ; None si elle ne nomme aucune machine."""
words = normalize(text).split()
padded = f" {' '.join(words)} "
# Noms les plus longs d'abord : « ordinateur du bureau » avant un éventuel « ordinateur ».
names = sorted(((name, machine) for machine, spec in settings.machines.items() for name in spec.names),
key=lambda item: len(item[0]), reverse=True)
machine = next((machine for name, machine in names if f" {name} " in padded), None)
if machine is None:
return None
action = next((action for word in words for action, spec in settings.actions.items()
if word in spec.words), None)
return MachineIntent(machine=machine, action=action)
def decide_machine(intent: MachineIntent, settings: MachinesSettings,
status: AgentStatus | None) -> MachineDecision:
"""Vérifie que la demande est possible, d'après l'état annoncé par l'agent."""
machine = settings.machines[intent.machine]
name = machine.name
if intent.action is None:
return MachineDecision(f"Que dois-je faire avec {name} ?", None)
spec = settings.actions[intent.action]
online = status is not None and status.online
if intent.action == WAKE:
if online:
return MachineDecision(f"{capitalized(name)} est déjà en marche.", None)
if not machine.mac:
return MachineDecision(f"Je ne sais pas {spec.verb} {name} à distance.", None)
elif not online:
# Tournures sans genre : le nom peut être « la tour du garage ».
return MachineDecision(f"{capitalized(name)} ne répond pas : la machine est éteinte, "
"ou son agent est arrêté.", None)
elif status is not None and intent.action not in status.actions:
return MachineDecision(f"Je ne peux pas {spec.verb} {name} : son agent ne le permet pas.", None)
command = MachineCommand(action=intent.action, machine=intent.machine,
said=f"{spec.does} {name}.", asked=f"{spec.verb} {name}")
return MachineDecision(command.said, command)
def magic_packet(mac: str) -> bytes:
"""Paquet magique Wake-on-LAN : 6 octets 0xFF, puis 16 fois l'adresse MAC (102 octets).
Raises:
ValueError: adresse MAC mal écrite.
"""
if not re.fullmatch(r"([0-9a-fA-F]{2}[:-]?){5}[0-9a-fA-F]{2}", mac.strip()):
raise ValueError(f"adresse MAC invalide : « {mac} » (attendu : aa:bb:cc:dd:ee:ff)")
return b"\xff" * 6 + bytes.fromhex(re.sub(r"[:-]", "", mac.strip())) * 16
def send_broadcast(packet: bytes, address: str) -> None:
"""Envoie un datagramme UDP en diffusion sur le réseau local."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(packet, (address, WOL_PORT))
class MachineController:
"""Suit l'état des agents, interprète les phrases qui nomment une machine, publie les commandes.
on_state et on_result sont appelés depuis le fil réseau ; interpret et execute
depuis le fil de travail : un verrou protège les états.
"""
def __init__(self, settings: MachinesSettings, link: TopicPublisher,
send_udp: Callable[[bytes, str], None] = send_broadcast) -> None:
"""
Args:
send_udp: envoi du paquet Wake-on-LAN, remplaçable dans les tests.
"""
self._settings = settings
self._link = link
self._send_udp = send_udp
self._lock = threading.Lock()
self._states: dict[str, AgentStatus] = {}
# ------------------------------------------------------------ messages des agents
def on_state(self, topic: str, payload: bytes) -> None:
"""État d'un agent (agent/<machine>/state), retenu : reçu dès l'abonnement."""
machine = self._machine_of(topic)
status = agent_status(payload)
if machine is None or status is None:
return
with self._lock:
previous = self._states.get(machine)
self._states[machine] = status
if previous == status:
return
if machine not in self._settings.machines:
logger.warning("agent %s : absent de config.yaml (machines.list), on ne peut pas le nommer", machine)
elif status.online:
unknown = [action for action in status.actions if action not in self._settings.actions]
logger.info("agent %s : en ligne, actions %s%s", machine, ", ".join(status.actions) or "aucune",
f" (sans mots dans config.yaml : {', '.join(unknown)})" if unknown else "")
else:
logger.warning("agent %s : hors ligne", machine)
def on_result(self, topic: str, payload: bytes) -> None:
"""Compte rendu d'exécution (agent/<machine>/result) : pour le journal."""
machine = self._machine_of(topic)
try:
result: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
log = logger.info if result.get("ok") else logger.warning
log("agent %s : %s %s (code %s)%s", machine, result.get("action"),
"executee" if result.get("ok") else "ECHOUEE", result.get("code"),
f" : {result['output']}" if result.get("output") else "")
def status(self, machine: str) -> AgentStatus | None:
"""Dernier état connu de l'agent ; None s'il ne s'est jamais annoncé."""
with self._lock:
return self._states.get(machine)
# ------------------------------------------------------------ phrases et commandes
def interpret(self, text: str) -> MachineDecision | None:
"""Réponse à une phrase qui nomme une machine ; None si elle n'en nomme aucune."""
intent = parse_machine_intent(text, self._settings)
if intent is None:
return None
return decide_machine(intent, self._settings, self.status(intent.machine))
def execute(self, board: str, command: MachineCommand, speaker: str | None, session: str) -> list[str]:
"""Réveil par le réseau, ou commande à l'agent ; rend les destinations (journal, tests)."""
if command.action == WAKE:
mac = self._settings.machines[command.machine].mac
self._send_udp(magic_packet(mac), self._settings.broadcast)
logger.info("%s : Wake-on-LAN %s (%s) -> %s:%d", board, command.machine, mac,
self._settings.broadcast, WOL_PORT)
return [f"wol:{mac}"]
# QoS 1, non retenu, et l'agent se connecte en session propre : une commande
# n'est jamais rejouée plus tard (un « éteins » qui attendrait le prochain démarrage...).
topic = f"{self._settings.topic_prefix}/{command.machine}/command"
payload = {"action": command.action, "id": session, "by": speaker, "board": board}
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
logger.info("%s : commande %s %s -> %s", board, command.action, command.machine, topic)
return [topic]
def _machine_of(self, topic: str) -> str | None:
parts = topic.split("/")
if len(parts) != 3 or parts[0] != self._settings.topic_prefix or not parts[1]:
return None
return parts[1]
+191
View File
@@ -0,0 +1,191 @@
"""Connexion du serveur au broker MQTT (paho-mqtt 2.x)."""
from __future__ import annotations
import json
import logging
import threading
from collections.abc import Callable
from typing import Any
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
from voice_server.settings import MqttSettings
logger = logging.getLogger(__name__)
# Rappel : (identifiant de la carte, payload brut)
Handler = Callable[[str, bytes], Any]
# Rappel d'un topic hors voice/ (agents Linux, étape 15b) : (topic complet, payload brut)
TopicHandler = Callable[[str, bytes], Any]
ONLINE = json.dumps({"status": "online"}).encode()
OFFLINE = json.dumps({"status": "offline"}).encode()
REFUSED_CREDENTIALS = (134, 135) # « Bad user name or password », « Not authorized » (MQTT 5 / 3.1.1)
STOP_PUBLISH_TIMEOUT_S = 2.0
def presence_topic(prefix: str) -> str:
"""Topic de présence du serveur, lu par les cartes (étape 15).
Hors du motif <préfixe>/<carte>/<suffixe> auquel le serveur s'abonne
(voice/+/state...) : sinon il se prendrait lui-même pour une carte.
>>> presence_topic("voice")
'voice/server/status'
"""
return f"{prefix}/server/status"
def refusal_hint(reason: ReasonCode, client_id: str) -> str:
"""Explication d'une connexion refusée, pour le journal."""
if reason.value in REFUSED_CREDENTIALS:
return (f"connexion refusee par le broker : {reason}. Verifiez MQTT_USERNAME et MQTT_PASSWORD "
"dans server/.env, et que ce compte existe (make mqtt-user NAME=voice-server, "
"puis make mosquitto-docker)")
return f"connexion refusee par le broker : {reason} (client « {client_id} »)"
def split_topic(topic: str, prefix: str) -> tuple[str, str] | None:
"""Découpe « <préfixe>/<carte>/<suffixe> » en (carte, suffixe), sinon None.
>>> split_topic("voice/esp32-01/audio/in", "voice")
('esp32-01', 'audio/in')
"""
parts = topic.split("/", 2)
if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]:
return None
return parts[1], parts[2]
class MqttLink:
"""Abonne le serveur aux topics de toutes les cartes et répartit les messages.
Le réseau tourne dans un fil créé par paho (loop_start) : les rappels sont
donc appelés depuis ce fil. Reconnexion automatique, abonnements refaits à
chaque connexion.
Avec presence=True (le vrai serveur seulement, jamais un outil de test),
le lien publie « online » retenu sur voice/server/status et confie au broker
un testament « offline » : si le serveur meurt, les cartes le savent et
coupent leur micro.
"""
def __init__(self, settings: MqttSettings, presence: bool = False) -> None:
"""
Args:
presence: annonce la présence du serveur (étape 15) ; False pour les outils,
dont l'arrêt ne doit pas faire croire aux cartes que le serveur est parti.
"""
self._settings = settings
self._presence = presence_topic(settings.topic_prefix) if presence else None
self._handlers: dict[str, Handler] = {}
self._topic_handlers: dict[str, TopicHandler] = {}
self._connected = threading.Event()
self._client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=settings.client_id,
)
if settings.username:
self._client.username_pw_set(settings.username, settings.password)
self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect
self._client.on_message = self._on_message
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
# (plantage, coupure) ; retenu, pour une carte qui se connecte plus tard.
self._client.will_set(self._presence, OFFLINE, qos=1, retain=True)
def on(self, suffix: str, handler: Handler) -> None:
"""Associe un rappel à un suffixe : « event », « audio/in », « state »..."""
self._handlers[suffix] = handler
def on_topic(self, pattern: str, handler: TopicHandler) -> None:
"""Associe un rappel à un filtre complet hors voice/ : « agent/+/state » (étape 15b), QoS 1."""
self._topic_handlers[pattern] = handler
def start(self) -> None:
"""Lance la connexion (asynchrone) et le fil réseau."""
logger.info("connexion au broker %s:%d", self._settings.host, self._settings.port)
self._client.connect_async(self._settings.host, self._settings.port,
keepalive=self._settings.keepalive)
self._client.loop_start()
def wait_connected(self, timeout: float) -> bool:
"""Attend la première connexion au broker ; False si le délai expire."""
return self._connected.wait(timeout)
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
"""Publie vers une carte : <préfixe>/<carte>/<suffixe>. Utilisable depuis tout fil."""
topic = f"{self._settings.topic_prefix}/{device}/{suffix}"
self._client.publish(topic, payload, qos=qos)
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
"""Publie sur un topic complet, hors de voice/ : commandes domotiques home/... (étape 11)."""
self._client.publish(topic, payload, qos=qos)
def stop(self) -> None:
"""Déconnexion propre et arrêt du fil réseau."""
if self._presence is not None and self._connected.is_set():
# Une déconnexion propre ne déclenche PAS le testament : on annonce le départ.
info = self._client.publish(self._presence, OFFLINE, qos=1, retain=True)
try:
info.wait_for_publish(STOP_PUBLISH_TIMEOUT_S) # ne lève rien si le délai expire
except (RuntimeError, ValueError):
pass
if not info.is_published():
logger.warning("depart du serveur non annonce aux cartes (broker injoignable)")
self._client.disconnect()
self._client.loop_stop()
# ------------------------------------------------------------ rappels paho
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags,
reason_code: ReasonCode, properties: Properties | None) -> None:
if reason_code.is_failure:
logger.error("%s", refusal_hint(reason_code, self._settings.client_id))
return
logger.info("connecte au broker")
self._connected.set()
if self._presence is not None:
# À chaque connexion : un broker redémarré (sans persistance) a oublié l'état retenu.
client.publish(self._presence, ONLINE, qos=1, retain=True)
logger.info("presence annoncee sur %s", self._presence)
# Abonnement refait à chaque connexion : une reconnexion repart de zéro.
for suffix in self._handlers:
topic = f"{self._settings.topic_prefix}/+/{suffix}"
qos = 0 if suffix.startswith("audio") else 1
client.subscribe(topic, qos=qos)
logger.info("abonne a %s (QoS %d)", topic, qos)
for pattern in self._topic_handlers:
client.subscribe(pattern, qos=1)
logger.info("abonne a %s (QoS 1)", pattern)
def _on_disconnect(self, client: mqtt.Client, userdata: Any,
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
properties: Properties | None) -> None:
self._connected.clear()
if reason_code.is_failure:
# Cause fréquente en TP : un second programme avec le même identifiant
# client ; le broker n'en garde qu'un et les deux s'éjectent en boucle.
logger.warning("connexion au broker perdue (%s), reconnexion... Si cela se repete, "
"un autre client utilise-t-il l'identifiant « %s » ?",
reason_code, self._settings.client_id)
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
parsed = split_topic(message.topic, self._settings.topic_prefix)
try:
if parsed is not None:
handler = self._handlers.get(parsed[1])
if handler is not None:
handler(parsed[0], message.payload)
return
for pattern, topic_handler in self._topic_handlers.items():
if mqtt.topic_matches_sub(pattern, message.topic):
topic_handler(message.topic, message.payload)
except Exception: # un message fautif ne doit jamais arrêter le serveur
logger.exception("erreur en traitant %s", message.topic)
+253
View File
@@ -0,0 +1,253 @@
"""Reconstitution des sessions de parole reçues des ESP32 : START, chunks, END."""
from __future__ import annotations
import json
import logging
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
from voice_server.audio_buffer import AudioBuffer, Levels
from voice_server.settings import AudioSettings, SessionSettings
logger = logging.getLogger(__name__)
class Outcome(Enum):
"""Façon dont une session s'est terminée."""
COMPLETE = "complete" # END reçu, tous les chunks annoncés sont arrivés
LOST_CHUNKS = "lost_chunks" # END reçu, mais des chunks manquent (QoS 0 : perte réseau)
TIMEOUT = "timeout" # jamais de END : l'ESP32 a disparu en cours de session
INTERRUPTED = "interrupted" # un nouveau START est arrivé avant le END
@dataclass(frozen=True)
class SessionResult:
"""Bilan d'une session terminée."""
device: str
session_id: str
outcome: Outcome
path: Path
duration_s: float
chunks_received: int
chunks_announced: int | None # None si aucun END n'a été reçu
levels: Levels
@dataclass
class _Session:
"""Session en cours de réception."""
device: str
session_id: str
buffer: AudioBuffer
started_at: datetime
last_activity: float
chunks_received: int = 0
announced: int | None = None # nombre de chunks annoncé par le END
end_seen_at: float | None = None
end_report: dict[str, Any] = field(default_factory=dict[str, Any])
class SessionManager:
"""Assemble les sessions de chaque carte et les écrit en WAV.
Appelée depuis le fil réseau (messages MQTT) et depuis le fil principal
(sweep) : un verrou protège l'état.
Les messages audio sont en QoS 0 : ils peuvent se perdre. Le END annonce le
nombre de chunks envoyés ; la comparaison avec le nombre reçu mesure la perte.
Et comme le START et le END peuvent eux-mêmes se perdre (l'ESP32 ne sait
publier qu'en QoS 0), une session sans END est close après un délai d'inactivité.
"""
def __init__(
self,
audio: AudioSettings,
sessions: SessionSettings,
recordings_dir: Path,
clock: Callable[[], float] = time.monotonic,
on_finished: Callable[[SessionResult], None] | None = None,
) -> None:
"""
Args:
on_finished: appelé pour chaque session terminée, depuis le fil qui la
termine (réseau ou principal) : il doit rendre la main aussitôt.
"""
self._on_finished = on_finished
self._audio = audio
self._settings = sessions
self._recordings_dir = recordings_dir
self._clock = clock
self._lock = threading.Lock()
self._active: dict[str, _Session] = {}
self.orphan_chunks = 0 # chunks reçus hors de toute session
# ------------------------------------------------------------ messages reçus
def on_event(self, device: str, payload: bytes) -> list[SessionResult]:
"""Traite un message JSON de voice/<device>/event."""
try:
event: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
logger.warning("%s : evenement illisible : %r", device, payload[:80])
return []
kind = event.get("event")
with self._lock:
if kind == "start":
return self._start(device, event)
if kind == "end":
return self._end(device, event)
if kind == "boot":
# Tout redémarrage autre qu'une mise sous tension mérite l'attention :
# brownout = alimentation trop faible, panic = plantage, watchdog = blocage.
reason = event.get("reason")
log = logger.info if reason in ("poweron", "external", "software") else logger.warning
log("%s : demarrage de la carte, cause : %s", device, reason)
elif kind == "device":
# Étape 11 : la carte confirme qu'elle a exécuté une commande de sa pièce.
log = logger.info if event.get("ok") else logger.warning
log("%s : commande %s %s %s, etat %s", device, event.get("device"), event.get("action"),
"executee" if event.get("ok") else "REFUSEE", event.get("state"))
elif kind == "played":
# Étape 12 : « underruns » compte les réamorçages (réserve épuisée, pause propre) ;
# la marge minimale dit combien de retard réseau restait absorbable.
log = logger.warning if event.get("underruns") else logger.info
log("%s : lecture %s jouee, chunks %s/%s, reamorcages %s %s, premier son a %s ms, marge mini %s ms",
device, event.get("session"), event.get("chunks"), event.get("announced"),
event.get("underruns"), event.get("gaps_ms") or "", event.get("start_ms"),
event.get("min_margin_ms"))
else:
logger.info("%s : evenement %s", device, event)
return []
def on_audio(self, device: str, payload: bytes) -> None:
"""Traite un chunk PCM de voice/<device>/audio/in."""
with self._lock:
session = self._active.get(device)
if session is None:
self.orphan_chunks += 1
return
if len(payload) % 2 != 0:
logger.warning("%s : chunk de %d octets ignore (impair)", device, len(payload))
return
session.buffer.append(payload)
session.chunks_received += 1
session.last_activity = self._clock()
def sweep(self) -> list[SessionResult]:
"""Clôt les sessions dont le END est passé (délai de grâce) ou restées muettes."""
now = self._clock()
results: list[SessionResult] = []
with self._lock:
for device, session in list(self._active.items()):
if session.end_seen_at is not None:
if now - session.end_seen_at >= self._settings.end_grace_s:
results.append(self._finish(device, self._outcome_after_end(session)))
elif now - session.last_activity >= self._settings.idle_timeout_s:
results.append(self._finish(device, Outcome.TIMEOUT))
return results
# ------------------------------------------------------------ interne
def _start(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
results: list[SessionResult] = []
previous = self._active.get(device)
if previous is not None:
# END déjà reçu, délai de grâce en cours : la session précédente est
# finie, pas interrompue. Son bilan se juge sur ce que le END annonçait.
if previous.end_seen_at is not None:
outcome = self._outcome_after_end(previous)
else:
outcome = Outcome.INTERRUPTED
results.append(self._finish(device, outcome))
announced = (event.get("rate"), event.get("bits"), event.get("channels"), event.get("codec"))
expected = (self._audio.sample_rate, self._audio.bits, self._audio.channels, self._audio.codec)
if announced != expected:
logger.error("%s : session refusee, format %s au lieu de %s", device, announced, expected)
return results
session_id = str(event.get("session", "inconnue"))
self._active[device] = _Session(
device=device,
session_id=session_id,
buffer=AudioBuffer(self._audio, self._settings.max_seconds),
started_at=datetime.now(),
last_activity=self._clock(),
)
logger.info("%s : session %s ouverte", device, session_id)
return results
def _end(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
session = self._active.get(device)
if session is None or event.get("session") != session.session_id:
logger.warning("%s : END sans session correspondante (%s)", device, event.get("session"))
return []
session.announced = int(event.get("chunks", 0))
session.end_report = event
session.end_seen_at = self._clock()
# Tout est arrivé : inutile d'attendre le délai de grâce.
if session.chunks_received >= session.announced:
return [self._finish(device, Outcome.COMPLETE)]
return []
@staticmethod
def _outcome_after_end(session: _Session) -> Outcome:
if session.announced is not None and session.chunks_received < session.announced:
return Outcome.LOST_CHUNKS
return Outcome.COMPLETE
def _finish(self, device: str, outcome: Outcome) -> SessionResult:
session = self._active.pop(device)
stamp = session.started_at.strftime("%Y%m%d-%H%M%S")
path = self._recordings_dir / f"{device}_{stamp}_{session.session_id}.wav"
session.buffer.write_wav(path)
result = SessionResult(
device=device,
session_id=session.session_id,
outcome=outcome,
path=path,
duration_s=session.buffer.duration_s,
chunks_received=session.chunks_received,
chunks_announced=session.announced,
levels=session.buffer.levels(),
)
self._log(result, session)
if self._on_finished is not None:
self._on_finished(result)
return result
@staticmethod
def _log(result: SessionResult, session: _Session) -> None:
announced = "?" if result.chunks_announced is None else str(result.chunks_announced)
logger.info(
"%s : session %s %s : %s, %.2f s, chunks %d/%s, crete %.1f dBFS, RMS %.1f dBFS",
result.device, result.session_id, result.outcome.value, result.path.name,
result.duration_s, result.chunks_received, announced,
result.levels.peak_dbfs, result.levels.rms_dbfs,
)
report = session.end_report
if report.get("send_failures") or report.get("capture_overruns"):
logger.warning(
"%s : cote ESP32, %s echecs d'envoi et %s chunks perdus faute de place",
result.device, report.get("send_failures"), report.get("capture_overruns"),
)
if result.outcome is Outcome.LOST_CHUNKS and result.chunks_announced:
lost = result.chunks_announced - result.chunks_received
logger.warning("%s : %d chunks perdus en route (%.0f %%) : signal Wi-Fi ?",
result.device, lost, 100.0 * lost / result.chunks_announced)
if session.buffer.truncated:
logger.warning("%s : session tronquee a sa duree maximale", result.device)
+343
View File
@@ -0,0 +1,343 @@
"""Chargement de la configuration : config.yaml pour les réglages, .env pour les secrets."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from dotenv import load_dotenv
from voice_server.text_normalize import normalize
@dataclass(frozen=True)
class AudioSettings:
"""Format audio unique du système."""
sample_rate: int
bits: int
channels: int
codec: str
chunk_ms: int
@property
def bytes_per_second(self) -> int:
"""Débit en octets par seconde (32 000 pour 16 kHz, 16 bits, mono)."""
return self.sample_rate * self.channels * self.bits // 8
@dataclass(frozen=True)
class MqttSettings:
"""Connexion au broker."""
host: str
port: int
keepalive: int
client_id: str
topic_prefix: str
username: str | None
password: str | None
@dataclass(frozen=True)
class SessionSettings:
"""Réception des sessions de parole."""
max_seconds: float
idle_timeout_s: float
end_grace_s: float
@dataclass(frozen=True)
class SttSettings:
"""Transcription par faster-whisper (étape 6)."""
model: str # tiny, base, small, medium…
language: str
compute_type: str # int8 : le plus rapide sur CPU
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 »
@dataclass(frozen=True)
class TtsSettings:
"""Synthèse vocale par Piper (étape 6)."""
voice: str
length_scale: float # > 1 : débit plus lent
model_path: Path # fichier .onnx, accompagné de son .onnx.json
@dataclass(frozen=True)
class VadSettings:
"""Détection d'activité vocale par Silero (étape 7)."""
threshold: float # probabilité de parole à partir de laquelle une fenêtre compte
min_silence_ms: int # silence plus court : pause entre deux mots, la phrase continue
min_speech_ms: int # parole plus courte : bruit (clic du bouton, toux), ignorée
speech_pad_ms: int # marge gardée autour de chaque segment (attaques et fins de mots)
@dataclass(frozen=True)
class DiarizationSettings:
"""Diarisation par pyannote : qui parle quand (étape 8)."""
enabled: bool # false : pas de diarisation (Raspberry Pi 4, extra non installé)
model: str # dépôt Hugging Face du pipeline
max_speakers: int
model_dir: Path # copie locale téléchargée par « make models »
@dataclass(frozen=True)
class SpeakerSettings:
"""Identification du locuteur par empreinte vocale (étape 9). Probabiliste : jamais une sécurité."""
profiles_dir: Path # un fichier JSON par personne enrôlée (donnée biométrique)
similarity_threshold: float # cosinus minimal avec un profil pour donner un nom
@dataclass(frozen=True)
class HomeSettings:
"""Domotique (étape 11) : pièces connues et pièce de chaque carte."""
topic_prefix: str # commandes publiées sur <préfixe>/<pièce>/<appareil>/set
rooms: dict[str, str] # nom dit à voix haute → complément : « salon » → « du salon »
boards: dict[str, str] # carte → pièce où elle se trouve (« ici », pièce par défaut)
@dataclass(frozen=True)
class WakeSettings:
"""Mot de réveil et écoute mains libres (étape 13)."""
enabled: bool
words: tuple[str, ...] # modèles openWakeWord : alexa, hey_jarvis, hey_mycroft
threshold: float # score au-delà duquel le mot est reconnu
thresholds: dict[str, float] # seuil propre à un mot, prioritaire sur threshold
preroll_ms: int # audio gardé AVANT la détection (en mémoire seulement)
end_silence_ms: int # silence qui clôt la commande
no_speech_timeout_s: float # rien dit après le mot : on abandonne
max_command_s: float # une commande plus longue est coupée
cooldown_s: float # au plus, attente de la fin de la réponse avant de réécouter
model_dir: Path # models/openwakeword/ (« make models »)
@dataclass(frozen=True)
class AccessSettings:
"""Droits par profil (étape 14). La voix n'est pas une preuve : c'est une commodité."""
unknown_level: str # voix non reconnue
default_level: str # profil enrôlé sans niveau dans users
users: dict[str, str] # nom du profil → complet | standard | limite
full_min_score: float # niveau complet seulement si l'identification est nette
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
@dataclass(frozen=True)
class MachineAction:
"""Une action d'agent Linux telle qu'on la dit (étape 15b)."""
words: tuple[str, ...] # mots normalisés qui la désignent : « eteins », « arrete »
does: str # début de la réponse : « J'éteins »
verb: str # infinitif, pour les questions et refus : « éteindre »
@dataclass(frozen=True)
class Machine:
"""Une machine du réseau, commandée par son agent (étape 15b)."""
names: tuple[str, ...] # comment on l'appelle : « pc du bureau », « ordinateur du bureau »
name: str # comment l'assistant la nomme : « le PC du bureau »
mac: str # Wake-on-LAN (« allume ») ; vide : pas de réveil à distance
@dataclass(frozen=True)
class MachinesSettings:
"""Machines Linux commandées par un agent MQTT (étape 15b)."""
topic_prefix: str # <préfixe>/<machine>/state, command, result
broadcast: str # adresse de diffusion du paquet Wake-on-LAN
actions: dict[str, MachineAction] # identifiant (shutdown...) → mots et phrases
machines: dict[str, Machine] # identifiant de l'agent (pc-bureau) → noms
@dataclass(frozen=True)
class Settings:
"""Configuration complète du serveur."""
audio: AudioSettings
mqtt: MqttSettings
sessions: SessionSettings
stt: SttSettings
tts: TtsSettings
vad: VadSettings
diarization: DiarizationSettings
speaker: SpeakerSettings
home: HomeSettings
wake: WakeSettings
access: AccessSettings
machines: MachinesSettings
recordings_dir: Path
models_dir: Path
log_level: str
log_format: str
log_file: Path | None # copie tournante du journal (étape 15), None : écran seul
def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
"""Rend une section du YAML, avec une erreur claire si elle manque."""
value = data.get(name)
if not isinstance(value, dict):
raise ValueError(f"config.yaml : section « {name} » absente ou invalide")
return value # type: ignore[return-value]
def _key(value: object) -> str:
"""Clé YAML en texte ; YAML 1.1 lit on/off/yes/no nus comme des booléens : on les rend tels quels."""
if isinstance(value, bool):
return "on" if value else "off"
return str(value)
def _machines(data: dict[str, Any]) -> MachinesSettings:
"""Section « machines » (étape 15b), facultative : sans elle, aucune machine."""
section: dict[str, Any] = data.get("machines") or {}
actions = {
str(action): MachineAction(
words=tuple(normalize(str(word)) for word in spec["words"]),
does=str(spec["does"]),
verb=str(spec["verb"]),
)
for action, spec in dict(section.get("actions") or {}).items()
}
machines = {
str(machine): Machine(
names=tuple(normalize(str(name)) for name in spec["names"]),
name=str(spec["name"]),
mac=str(spec.get("mac") or ""),
)
for machine, spec in dict(section.get("list") or {}).items()
}
return MachinesSettings(
topic_prefix=str(section.get("topic_prefix", "agent")),
broadcast=str(section.get("broadcast", "255.255.255.255")),
actions=actions,
machines=machines,
)
def load_settings(path: Path) -> Settings:
"""Lit config.yaml, puis le .env voisin s'il existe (identifiants MQTT)."""
with path.open(encoding="utf-8") as stream:
data: dict[str, Any] = yaml.safe_load(stream) or {}
# Les secrets ne sont jamais dans config.yaml : ils viennent de l'environnement.
load_dotenv(path.parent / ".env")
audio = _section(data, "audio")
mqtt = _section(data, "mqtt")
sessions = _section(data, "sessions")
storage = _section(data, "storage")
logging_cfg = _section(data, "logging")
stt = _section(data, "stt")
tts = _section(data, "tts")
vad = _section(data, "vad")
diarization = _section(data, "diarization")
speaker = _section(data, "speaker")
home = _section(data, "home")
wake = _section(data, "wake")
access = _section(data, "access")
base = path.parent # les chemins relatifs le sont par rapport à config.yaml
models_dir = (base / str(storage["models_dir"])).resolve()
return Settings(
audio=AudioSettings(
sample_rate=int(audio["sample_rate"]),
bits=int(audio["bits"]),
channels=int(audio["channels"]),
codec=str(audio["codec"]),
chunk_ms=int(audio["chunk_ms"]),
),
mqtt=MqttSettings(
host=str(mqtt["host"]),
port=int(mqtt["port"]),
keepalive=int(mqtt["keepalive"]),
client_id=str(mqtt["client_id"]),
topic_prefix=str(mqtt["topic_prefix"]),
username=os.environ.get("MQTT_USERNAME") or None,
password=os.environ.get("MQTT_PASSWORD") or None,
),
sessions=SessionSettings(
max_seconds=float(sessions["max_seconds"]),
idle_timeout_s=float(sessions["idle_timeout_s"]),
end_grace_s=float(sessions["end_grace_s"]),
),
stt=SttSettings(
model=str(stt["model"]),
language=str(stt["language"]),
compute_type=str(stt["compute_type"]),
beam_size=int(stt["beam_size"]),
no_speech_threshold=float(stt["no_speech_threshold"]),
model_dir=models_dir / "whisper" / str(stt["model"]),
),
tts=TtsSettings(
voice=str(tts["voice"]),
length_scale=float(tts["length_scale"]),
model_path=models_dir / "piper" / f"{tts['voice']}.onnx",
),
vad=VadSettings(
threshold=float(vad["threshold"]),
min_silence_ms=int(vad["min_silence_ms"]),
min_speech_ms=int(vad["min_speech_ms"]),
speech_pad_ms=int(vad["speech_pad_ms"]),
),
diarization=DiarizationSettings(
enabled=bool(diarization["enabled"]),
model=str(diarization["model"]),
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],
),
speaker=SpeakerSettings(
profiles_dir=(base / str(speaker["profiles_dir"])).resolve(),
similarity_threshold=float(speaker["similarity_threshold"]),
),
home=HomeSettings(
topic_prefix=str(home["topic_prefix"]),
rooms={str(name): str(complement) for name, complement in dict(home["rooms"]).items()},
boards={str(board): str(room) for board, room in dict(home["boards"]).items()},
),
wake=WakeSettings(
enabled=bool(wake["enabled"]),
words=tuple(str(word) for word in wake["words"]),
threshold=float(wake["threshold"]),
thresholds={str(word): float(value) for word, value in dict(wake.get("thresholds") or {}).items()},
preroll_ms=int(wake["preroll_ms"]),
end_silence_ms=int(wake["end_silence_ms"]),
no_speech_timeout_s=float(wake["no_speech_timeout_s"]),
max_command_s=float(wake["max_command_s"]),
cooldown_s=float(wake["cooldown_s"]),
model_dir=models_dir / "openwakeword",
),
access=AccessSettings(
unknown_level=str(access["unknown"]),
default_level=str(access["default"]),
users={str(name): str(level) for name, level in dict(access.get("users") or {}).items()},
full_min_score=float(access["full_min_score"]),
rules={str(device): {_key(action): str(level) for action, level in dict(actions).items()}
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"]),
),
machines=_machines(data),
recordings_dir=(base / str(storage["recordings_dir"])).resolve(),
models_dir=models_dir,
log_level=str(logging_cfg["level"]),
log_format=str(logging_cfg["format"]),
log_file=(base / str(logging_cfg["file"])).resolve() if logging_cfg.get("file") else None,
)
+100
View File
@@ -0,0 +1,100 @@
"""Association locuteur + texte : qui a dit quoi (étape 10).
Whisper donne l'instant de chaque mot, la diarisation l'instant de chaque tour
de parole. Les deux ont reçu exactement le même audio (la parole trouvée par la
VAD) : leurs horloges coïncident. Chaque mot va au tour qui le recouvre le plus,
puis les mots consécutifs d'un même locuteur forment une intervention.
"""
from __future__ import annotations
import json
import math
from collections.abc import Sequence
from dataclasses import dataclass
from voice_server.diarization import SpeakerTurn
from voice_server.speaker_identifier import UNKNOWN, Identification
from voice_server.speech_to_text import Word
@dataclass(frozen=True)
class Utterance:
"""Une intervention : ce qu'une personne a dit d'un seul tenant."""
speaker: str # nom, ou « Inconnu »
text: str
start_s: float # instants dans la parole sans silences (ceux de Whisper et de la diarisation)
end_s: float
score: float | None # similarité avec le profil (None : pas d'identification)
label: str = "" # étiquette de diarisation (SPEAKER_00), vide sans diarisation
def turn_of(word: Word, turns: Sequence[SpeakerTurn]) -> SpeakerTurn | None:
"""Le tour qui recouvre le plus le mot ; à défaut (mot dans un blanc), le plus proche."""
if not turns:
return None
def overlap(turn: SpeakerTurn) -> float:
return min(word.end_s, turn.end_s) - max(word.start_s, turn.start_s)
best = max(turns, key=overlap)
if overlap(best) > 0:
return best
middle = (word.start_s + word.end_s) / 2
return min(turns, key=lambda turn: min(abs(middle - turn.start_s), abs(middle - turn.end_s)))
def attribute(words: Sequence[Word], turns: Sequence[SpeakerTurn],
verdicts: dict[str, Identification]) -> list[Utterance]:
"""Regroupe les mots en interventions, une par changement de locuteur.
Args:
words: mots horodatés de Whisper.
turns: tours de parole de la diarisation (vide : diarisation désactivée).
verdicts: identification de chaque étiquette de diarisation.
"""
utterances: list[Utterance] = []
group: list[Word] = []
current: str | None = None
def close() -> None:
if not group:
return
verdict = verdicts.get(current or "")
score = verdict.score if verdict is not None and not math.isnan(verdict.score) else None
utterances.append(Utterance(
speaker=verdict.label if verdict is not None else UNKNOWN,
text="".join(word.text for word in group).strip(),
start_s=group[0].start_s,
end_s=group[-1].end_s,
score=score,
label=current or "",
))
for word in words:
turn = turn_of(word, turns)
label = turn.speaker if turn is not None else None
if group and label != current:
close()
group = []
current = label
group.append(word)
close()
return utterances
def transcript_json(session: str, utterances: Sequence[Utterance]) -> bytes:
"""Message publié sur voice/<carte>/transcript.
{"session": "a1b2c3", "utterances": [{"speaker": "Denis", "text": "...",
"start": 0.0, "end": 1.66, "score": 0.74}, ...]}
"""
return json.dumps({
"session": session,
"utterances": [
{"speaker": u.speaker, "text": u.text, "start": round(u.start_s, 2), "end": round(u.end_s, 2),
"score": None if u.score is None else round(u.score, 2)}
for u in utterances
],
}, ensure_ascii=False).encode("utf-8")
+164
View File
@@ -0,0 +1,164 @@
"""Identification du locuteur : empreinte vocale comparée aux profils enrôlés (étape 9).
Une empreinte est un vecteur de 256 nombres qui résume le timbre d'une voix.
Deux empreintes de la même personne pointent à peu près dans la même direction :
on les compare par similarité cosinus (1 = même direction, 0 = sans rapport).
Au-dessus du seuil, on donne le nom du profil le plus proche ; sinon « Inconnu ».
C'est une estimation, jamais une preuve : une voix enregistrée, imitée ou
synthétisée peut tromper le système. Ne jamais s'en servir comme sécurité.
"""
from __future__ import annotations
import json
import logging
import math
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import numpy as np
from voice_server.settings import SpeakerSettings
logger = logging.getLogger(__name__)
UNKNOWN = "Inconnu"
@dataclass(frozen=True)
class VoiceProfile:
"""Profil vocal d'une personne : la moyenne de ses empreintes d'enrôlement."""
name: str
embedding: np.ndarray # normalisé (norme 1)
samples: int # nombre de sessions moyennées
model: str # modèle d'empreintes : un profil ne se compare qu'au même modèle
created: str
@dataclass(frozen=True)
class Identification:
"""Verdict pour une voix."""
name: str | None # None : personne n'atteint le seuil
score: float # similarité avec le profil le plus proche (NaN : pas d'empreinte)
closest: str | None # profil le plus proche, même sous le seuil (diagnostic)
@property
def label(self) -> str:
"""Le nom, ou « Inconnu »."""
return self.name or UNKNOWN
def describe(self) -> str:
"""« Denis (0.74) », « Inconnu (0.21, proche de Denis) », « Inconnu (trop peu de parole) »."""
if math.isnan(self.score):
return f"{UNKNOWN} (trop peu de parole)"
if self.name is not None:
return f"{self.name} ({self.score:.2f})"
if self.closest is not None:
return f"{UNKNOWN} ({self.score:.2f}, proche de {self.closest})"
return f"{UNKNOWN} (aucun profil)"
def normalized(vector: np.ndarray) -> np.ndarray:
"""Vecteur ramené à la norme 1 : le cosinus devient un simple produit scalaire."""
norm = float(np.linalg.norm(vector))
return vector / norm if norm > 0 and math.isfinite(norm) else np.full_like(vector, np.nan)
def profile_path(profiles_dir: Path, name: str) -> Path:
"""Fichier du profil : « Élodie Martin » → profiles/elodie-martin.json."""
ascii_name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", ascii_name.lower()).strip("-")
if not slug:
raise ValueError(f"nom de profil inutilisable : {name!r}")
return profiles_dir / f"{slug}.json"
def save_profile(profiles_dir: Path, name: str, embeddings: list[np.ndarray], model: str) -> VoiceProfile:
"""Moyenne les empreintes d'enrôlement et écrit le profil (remplace l'ancien)."""
mean = normalized(np.mean([normalized(e) for e in embeddings], axis=0))
profile = VoiceProfile(name=name, embedding=mean, samples=len(embeddings), model=model,
created=datetime.now().isoformat(timespec="seconds"))
path = profile_path(profiles_dir, name)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps({
"name": profile.name, "model": profile.model, "samples": profile.samples,
"created": profile.created, "embedding": [round(float(v), 6) for v in profile.embedding],
}, ensure_ascii=False, indent=1), encoding="utf-8")
temporary.replace(path) # écriture atomique : le serveur ne lit jamais un fichier à moitié écrit
return profile
def load_profiles(profiles_dir: Path, model: str) -> list[VoiceProfile]:
"""Lit les profils ; ceux d'un autre modèle d'empreintes sont ignorés (incomparables)."""
profiles: list[VoiceProfile] = []
for path in sorted(profiles_dir.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
profile = VoiceProfile(name=str(data["name"]), embedding=normalized(np.asarray(data["embedding"], dtype=np.float32)),
samples=int(data["samples"]), model=str(data["model"]), created=str(data["created"]))
except (OSError, ValueError, KeyError) as error:
logger.warning("profil illisible %s : %s", path.name, error)
continue
if profile.model != model:
logger.warning("profil %s ignore : cree avec %s, pas %s (re-enrolez)", path.name, profile.model, model)
continue
profiles.append(profile)
return profiles
class SpeakerIdentifier:
"""Donne un nom à une empreinte, d'après les profils du dossier.
Le dossier est relu dès qu'il change : un enrôlement fait pendant que le
serveur tourne est pris en compte sans le redémarrer.
"""
def __init__(self, settings: SpeakerSettings, model: str) -> None:
"""
Args:
settings: dossier des profils et seuil.
model: nom du modèle d'empreintes utilisé par la diarisation.
"""
self._settings = settings
self._model = model
self._stamp: float | None = None
self._profiles: list[VoiceProfile] = []
self._reload_if_changed()
@property
def profiles(self) -> list[VoiceProfile]:
"""Profils chargés."""
self._reload_if_changed()
return self._profiles
def identify(self, embedding: np.ndarray) -> Identification:
"""Profil le plus proche, s'il dépasse le seuil."""
self._reload_if_changed()
vector = normalized(np.asarray(embedding, dtype=np.float32))
if not np.all(np.isfinite(vector)):
return Identification(name=None, score=math.nan, closest=None)
if not self._profiles:
return Identification(name=None, score=0.0, closest=None)
scores = [float(profile.embedding @ vector) for profile in self._profiles]
best = int(np.argmax(scores))
closest = self._profiles[best].name
if scores[best] >= self._settings.similarity_threshold:
return Identification(name=closest, score=scores[best], closest=closest)
return Identification(name=None, score=scores[best], closest=closest)
def _reload_if_changed(self) -> None:
directory = self._settings.profiles_dir
stamp = directory.stat().st_mtime if directory.is_dir() else None
if stamp == self._stamp:
return
self._stamp = stamp
self._profiles = load_profiles(directory, self._model) if stamp is not None else []
names = ", ".join(f"{p.name} ({p.samples} sessions)" for p in self._profiles) or "aucun"
logger.info("profils vocaux : %s", names)
+117
View File
@@ -0,0 +1,117 @@
"""Transcription de la parole en texte avec faster-whisper (étape 6)."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from voice_server.settings import SttSettings
if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'extra « speech »
from faster_whisper import WhisperModel
logger = logging.getLogger(__name__)
# Phrases que Whisper « entend » dans le silence ou le bruit : elles viennent des
# sous-titres de vidéos qui ont servi à l'entraîner. Comparaison en minuscules.
KNOWN_HALLUCINATIONS = (
"amara.org",
"sous-titrage",
"sous-titres",
"merci d'avoir regardé",
)
@dataclass(frozen=True)
class Word:
"""Un mot et son instant dans l'audio transcrit (étape 10 : à qui l'attribuer)."""
text: str # tel que Whisper l'écrit, espace initiale comprise : « Ferme », « -moi »
start_s: float
end_s: float
@dataclass(frozen=True)
class Transcript:
"""Résultat d'une transcription."""
text: str # vide si rien d'exploitable n'a été reconnu
audio_s: float # durée de l'audio transcrit
elapsed_s: float # temps de calcul
dropped: tuple[str, ...] = () # segments écartés comme hallucinations
words: tuple[Word, ...] = () # mots des segments gardés, dans l'ordre
class SpeechToText:
"""Transcrit un PCM s16le 16 kHz mono en texte, avec un modèle local.
Le modèle est lu dans le dossier rempli par « make models » : aucun accès
réseau au démarrage, ni pendant la transcription.
"""
def __init__(self, stt: SttSettings, sample_rate: int) -> None:
"""
Args:
stt: réglages (modèle, langue, précision de calcul).
sample_rate: fréquence du PCM reçu ; Whisper exige 16 kHz.
Raises:
FileNotFoundError: le modèle n'a pas été téléchargé.
ValueError: fréquence différente de 16 kHz.
"""
if sample_rate != 16000:
raise ValueError(f"Whisper attend du 16 kHz, pas {sample_rate} Hz")
if not (stt.model_dir / "model.bin").exists():
raise FileNotFoundError(f"modele whisper absent de {stt.model_dir} : lancez 'make models'")
self._stt = stt
self._sample_rate = sample_rate
from faster_whisper import WhisperModel
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)
# 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."""
began = time.monotonic()
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
segments, _ = self._model.transcribe(
audio,
language=self._stt.language,
beam_size=self._stt.beam_size,
# Chaque question est indépendante : sans cela, une erreur se propage d'un segment à l'autre.
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é)
)
kept: list[str] = []
dropped: list[str] = []
words: list[Word] = []
for segment in segments: # générateur : le calcul se fait pendant ce parcours
text = segment.text.strip()
if not text:
continue
if any(marker in text.lower() for marker in KNOWN_HALLUCINATIONS):
dropped.append(text)
else:
kept.append(text)
words.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
for w in segment.words or ())
return Transcript(
text=" ".join(kept),
audio_s=audio.size / self._sample_rate,
elapsed_s=time.monotonic() - began,
dropped=tuple(dropped),
words=tuple(words),
)
+13
View File
@@ -0,0 +1,13 @@
"""Normalisation du texte transcrit, commune à l'assistant et à la domotique."""
from __future__ import annotations
import re
import unicodedata
def normalize(text: str) -> str:
"""Minuscules, sans accents ni ponctuation : « Quelle heure est-il ? » → « quelle heure est il »."""
decomposed = unicodedata.normalize("NFD", text.lower())
without_accents = "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
return " ".join(re.sub(r"[^a-z0-9]+", " ", without_accents).split())
+73
View File
@@ -0,0 +1,73 @@
"""Synthèse vocale avec Piper (étape 6)."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from voice_server.audio_resampler import AudioResampler
from voice_server.settings import TtsSettings
if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'extra « speech »
from piper import PiperVoice, SynthesisConfig
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Speech:
"""Parole synthétisée, déjà au format du système."""
pcm: bytes # s16le mono à la fréquence du système
audio_s: float # durée de la parole
elapsed_s: float # temps de calcul (synthèse et rééchantillonnage)
class TextToSpeech:
"""Transforme un texte en PCM prêt à envoyer à l'ESP32.
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.
"""
def __init__(self, tts: TtsSettings, resampler: AudioResampler, sample_rate: int) -> None:
"""
Args:
tts: réglages (voix, débit).
resampler: mise au format du système.
sample_rate: fréquence du système, pour calculer la durée produite.
Raises:
FileNotFoundError: la voix n'a pas été téléchargée.
"""
if not tts.model_path.exists():
raise FileNotFoundError(f"voix piper absente : {tts.model_path} : lancez 'make models'")
from piper import PiperVoice, SynthesisConfig
began = time.monotonic()
self._voice: PiperVoice = PiperVoice.load(tts.model_path)
self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale)
self._resampler = resampler
self._sample_rate = sample_rate
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()
parts: list[np.ndarray] = []
rate = self._voice.config.sample_rate
for chunk in self._voice.synthesize(text, syn_config=self._config):
parts.append(chunk.audio_float_array)
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(
pcm=pcm,
audio_s=len(pcm) / 2 / self._sample_rate,
elapsed_s=time.monotonic() - began,
)
+157
View File
@@ -0,0 +1,157 @@
"""Détection d'activité vocale (VAD) avec Silero (étape 7).
Silero donne, pour chaque fenêtre de 512 échantillons (32 ms à 16 kHz), la
probabilité qu'elle contienne de la parole. Ce module en déduit les segments
de parole, puis ne garde qu'eux : silences de début et de fin retirés, longs
silences intérieurs raccourcis.
Le modèle ONNX est celui que livre faster-whisper : pas de torch, rien à télécharger.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import numpy as np
from voice_server.settings import VadSettings
logger = logging.getLogger(__name__)
WINDOW_SAMPLES = 512 # imposé par Silero à 16 kHz
HYSTERESIS = 0.15 # on sort de la parole sous threshold - 0,15 : pas de battement au seuil
@dataclass(frozen=True)
class Segment:
"""Segment de parole, en échantillons : [start, end[."""
start: int
end: int
@dataclass(frozen=True)
class VadResult:
"""Parole trouvée dans une session."""
segments: tuple[Segment, ...] # bornes après marge, dans l'audio d'origine
speech_pcm: bytes # la parole seule, segments mis bout à bout
probabilities: np.ndarray # une probabilité par fenêtre, pour le diagnostic
total_s: float # durée de la session
speech_s: float # durée de parole conservée
elapsed_s: float # temps de calcul
sample_rate: int
@property
def has_speech(self) -> bool:
"""Faux si la session ne contient aucune parole (appui muet, bruit)."""
return bool(self.segments)
def bounds(self) -> str:
"""Bornes des segments en secondes : « 0.45-1.80, 2.10-2.60 »."""
return ", ".join(f"{s.start / self.sample_rate:.2f}-{s.end / self.sample_rate:.2f}"
for s in self.segments)
def find_segments(probabilities: Sequence[float], threshold: float,
min_silence_windows: int, min_speech_windows: int) -> list[Segment]:
"""Segments de parole, en fenêtres, à partir des probabilités de Silero.
Entrée en parole dès threshold ; sortie après min_silence_windows fenêtres
sous threshold - HYSTERESIS (une pause entre deux mots ne coupe pas la phrase).
Les segments plus courts que min_speech_windows sont des bruits : clic, toux.
"""
low = threshold - HYSTERESIS
segments: list[Segment] = []
start: int | None = None
quiet = 0 # fenêtres calmes consécutives depuis la dernière parole
for index, probability in enumerate(probabilities):
if start is None:
if probability >= threshold:
start, quiet = index, 0
continue
quiet = quiet + 1 if probability < low else 0
if quiet >= min_silence_windows:
end = index - quiet + 1
if end - start >= min_speech_windows:
segments.append(Segment(start, end))
start = None
if start is not None and len(probabilities) - quiet - start >= min_speech_windows:
segments.append(Segment(start, len(probabilities) - quiet))
return segments
def pad_segments(segments: Sequence[Segment], pad: int, total: int) -> list[Segment]:
"""Élargit chaque segment de pad (début et fin de mot sont faibles) et fusionne les chevauchements."""
padded: list[Segment] = []
for segment in segments:
start, end = max(0, segment.start - pad), min(total, segment.end + pad)
if padded and start <= padded[-1].end:
padded[-1] = Segment(padded[-1].start, end)
else:
padded.append(Segment(start, end))
return padded
class VoiceActivityDetector:
"""Trouve la parole dans un PCM s16le 16 kHz mono et retire les silences."""
def __init__(self, vad: VadSettings, sample_rate: int,
model: Callable[[np.ndarray], np.ndarray] | None = None) -> None:
"""
Args:
vad: seuil et durées minimales.
sample_rate: fréquence du PCM ; Silero exige 16 kHz avec des fenêtres de 512.
model: probabilités par fenêtre ; par défaut Silero, injectable pour les tests.
Raises:
ValueError: fréquence différente de 16 kHz.
"""
if sample_rate != 16000:
raise ValueError(f"Silero attend du 16 kHz avec des fenetres de 512, pas {sample_rate} Hz")
if model is None:
# Import tardif : le mode écho se passe de l'extra « speech ».
from faster_whisper.vad import get_vad_model
model = get_vad_model()
self._model = model
self._vad = vad
self._sample_rate = sample_rate
window_ms = WINDOW_SAMPLES * 1000 / sample_rate
self._min_silence = max(1, round(vad.min_silence_ms / window_ms))
self._min_speech = max(1, round(vad.min_speech_ms / window_ms))
self._pad = vad.speech_pad_ms * sample_rate // 1000
def detect(self, pcm: bytes) -> VadResult:
"""Analyse le PCM et rend la parole seule."""
began = time.monotonic()
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
audio = samples.astype(np.float32) / 32768.0
# Dernière fenêtre complétée par du silence : Silero n'accepte que des fenêtres entières.
padded = np.pad(audio, (0, -audio.size % WINDOW_SAMPLES))
probabilities = (np.asarray(self._model(padded), dtype=np.float32).reshape(-1)
if padded.size else np.zeros(0, dtype=np.float32))
windows = find_segments(probabilities.tolist(), self._vad.threshold,
self._min_silence, self._min_speech)
in_samples = [Segment(s.start * WINDOW_SAMPLES, min(samples.size, s.end * WINDOW_SAMPLES))
for s in windows]
segments = pad_segments(in_samples, self._pad, samples.size)
speech = b"".join(samples[s.start:s.end].tobytes() for s in segments)
return VadResult(
segments=tuple(segments),
speech_pcm=speech,
probabilities=probabilities,
total_s=samples.size / self._sample_rate,
speech_s=len(speech) / 2 / self._sample_rate,
elapsed_s=time.monotonic() - began,
sample_rate=self._sample_rate,
)
def envelope(probabilities: np.ndarray, threshold: float) -> str:
"""Une lettre par fenêtre de 32 ms : « # » parole, « . » hésitation, « _ » silence."""
low = threshold - HYSTERESIS
return "".join("#" if p >= threshold else "." if p >= low else "_" for p in probabilities)
+255
View File
@@ -0,0 +1,255 @@
"""Boucle complète d'une question (étapes 6 à 14).
VAD → diarisation → identification → transcription → qui a dit quoi → réponse
(+ commande domotique, si les droits de la personne le permettent) → synthèse → envoi.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol
import numpy as np
from voice_server.access_control import Resolution, Verdict
from voice_server.assistant import NOTHING_HEARD, Reply
from voice_server.audio_buffer import read_pcm
from voice_server.audio_sender import Publisher
from voice_server.diarization import Diarization, SpeakerTurn
from voice_server.machine_control import Command
from voice_server.session_manager import SessionResult
from voice_server.speaker_attribution import Utterance, attribute, transcript_json
from voice_server.speaker_identifier import UNKNOWN, Identification
from voice_server.speech_to_text import Transcript
from voice_server.text_to_speech import Speech
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
class SpeechDetector(Protocol):
"""Ce que la boucle attend de la détection d'activité vocale."""
def detect(self, pcm: bytes) -> VadResult: ...
class SpeakerSplitter(Protocol):
"""Ce que la boucle attend de la diarisation."""
def diarize(self, pcm: bytes) -> Diarization: ...
class Identifier(Protocol):
"""Ce que la boucle attend de l'identification du locuteur."""
def identify(self, embedding: np.ndarray) -> Identification: ...
class Policy(Protocol):
"""Ce que la boucle attend des droits par profil (AccessPolicy)."""
def check(self, command: Command, speaker: str | None, score: float | None) -> Verdict: ...
def ask(self, board: str, command: Command, speaker: str | None) -> None: ...
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: ...
class Transcriber(Protocol):
"""Ce que la boucle attend de la transcription."""
def transcribe(self, pcm: bytes) -> Transcript: ...
class Responder(Protocol):
"""Ce que la boucle attend de l'assistant."""
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply: ...
class Controller(Protocol):
"""Ce que la boucle attend de la domotique (HomeController)."""
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]: ...
class Synthesizer(Protocol):
"""Ce que la boucle attend de la synthèse vocale."""
def synthesize(self, text: str) -> Speech: ...
class Player(Protocol):
"""Ce que la boucle attend de l'envoi vers la carte (AudioSender)."""
def send(self, device: str, pcm: bytes, session: str | None = None) -> str: ...
@dataclass(frozen=True)
class Turn:
"""Bilan d'un échange, pour le journal et les tests."""
heard: str # texte transcrit ; vide si rien de reconnu
answer: str # texte dit par la carte
speech_s: float # parole trouvée par la VAD (0 : appui muet)
speakers: int # locuteurs trouvés par la diarisation (0 : non faite)
names: tuple[str, ...] # nom ou « Inconnu » de chaque locuteur, ordre de la diarisation
utterances: tuple[Utterance, ...] # qui a dit quoi (étape 10)
command: Command | None # commande exécutée : domotique (11), machine (15b)
refused: bool # commande refusée ou mise en attente de confirmation (étape 14)
stt_s: float # temps de transcription (0 si Whisper n'a pas été appelé)
tts_s: float # temps de synthèse
ready_s: float # de la fin de session au départ du premier chunk
answer_s: float # durée de la réponse parlée
class VoicePipeline:
"""Traite une session terminée et fait dire la réponse par la carte.
L'appel est bloquant (transcription, puis envoi au rythme réel) : il
s'exécute dans le fil de travail du serveur, jamais dans le fil réseau.
"""
def __init__(
self,
vad: SpeechDetector,
stt: Transcriber,
assistant: Responder,
tts: Synthesizer,
player: Player,
diarizer: SpeakerSplitter | None = None,
identifier: Identifier | None = None,
publisher: Publisher | None = None,
controller: Controller | None = None,
policy: Policy | None = None,
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
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).
controller: exécute les commandes domotiques (None : elles sont seulement annoncées).
policy: droits par profil (None : toute commande est permise).
read: lecture du PCM d'une session, injectable pour les tests.
clock: horloge, injectable pour les tests.
"""
self._vad = vad
self._stt = stt
self._assistant = assistant
self._tts = tts
self._player = player
self._diarizer = diarizer
self._identifier = identifier
self._publisher = publisher
self._controller = controller
self._policy = policy
self._read = read
self._clock = clock
def handle(self, result: SessionResult) -> Turn | None:
"""Répond à la session ; None si elle était trop courte pour être une question."""
began = self._clock()
if result.duration_s < MIN_SPEECH_S:
logger.warning("%s : session %s trop courte (%.2f s) : bouton relache trop tot ?",
result.device, result.session_id, result.duration_s)
return None
speakers = 0
turns: tuple[SpeakerTurn, ...] = ()
verdicts: dict[str, Identification] = {}
# Sans parole, Whisper n'est pas appelé : 1 s de calcul pour un texte souvent inventé.
vad = self._vad.detect(self._read(result))
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)
# La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10).
if 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)
else:
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)
# 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)
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.
self._controller.execute(result.device, reply.command, speaker, result.session_id)
speech = self._tts.synthesize(answer)
ready_s = self._clock() - began
logger.info("%s : entendu « %s » -> reponse « %s »", result.device, transcript.text, answer)
logger.info("%s : transcription %.2f s (audio %.1f s), synthese %.2f s, reponse prete en %.2f s",
result.device, transcript.elapsed_s, transcript.audio_s, speech.elapsed_s, ready_s)
self._player.send(result.device, speech.pcm, f"rep-{result.session_id}")
return Turn(heard=transcript.text, answer=answer, speech_s=vad.speech_s, speakers=speakers,
names=tuple(verdict.label for verdict in verdicts.values()), utterances=tuple(utterances),
command=reply.command, refused=refused,
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]:
"""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)
if resolution is not None:
return Reply(resolution.answer, resolution.command), resolution.command is None
reply = self._assistant.respond(text, speaker, board)
if reply.command is None or self._policy is None:
return reply, False
verdict = self._policy.check(reply.command, speaker, score)
if not verdict.allowed:
return Reply(verdict.answer or "Je ne peux pas faire cela."), True
if verdict.confirm:
self._policy.ask(board, reply.command, speaker)
return Reply(verdict.answer or "Confirmez-vous ?"), True
return reply, False
def _identify(self, device: str, diarization: Diarization) -> dict[str, Identification]:
"""Un verdict (nom ou « Inconnu ») par étiquette de diarisation."""
if self._identifier is None or diarization.embeddings is None:
return {}
verdicts = {speaker: self._identifier.identify(embedding)
for speaker, embedding in zip(diarization.speakers, diarization.embeddings)}
logger.info("%s : %s", device, ", ".join(
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]:
"""Interventions de la session, journalisées et publiées sur voice/<carte>/transcript."""
if not transcript.text:
return []
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)]
for utterance in utterances:
logger.info("%s : %s : « %s »", result.device, utterance.speaker, utterance.text)
if self._publisher is not None:
self._publisher.publish(result.device, "transcript", transcript_json(result.session_id, utterances), 1)
return utterances
+104
View File
@@ -0,0 +1,104 @@
"""Détection du mot de réveil avec openWakeWord (étape 13).
openWakeWord calcule un spectrogramme, puis une empreinte de chaque tranche de
80 ms, puis un petit modèle par mot donne un score entre 0 et 1. Les modèles
prêts à l'emploi sont anglais : « Alexa », « Hey Jarvis », « Hey Mycroft ».
Exécution en ONNX : la version TensorFlow Lite n'existe pas pour Python 3.13.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
from voice_server.settings import WakeSettings
if TYPE_CHECKING: # import réel dans __init__ : extra « wakeword » facultatif
from openwakeword.model import Model
logger = logging.getLogger(__name__)
MODEL_VERSION = "v0.1"
FEATURE_MODELS = ("melspectrogram.onnx", "embedding_model.onnx")
NEAR_MISS_FLOOR = 0.15 # un pic de score au-dessus, sans atteindre le seuil : « presque reconnu »
@dataclass(frozen=True)
class Detection:
"""Un mot de réveil reconnu."""
word: str # « alexa », « hey_jarvis »…
score: float
def model_files(settings: WakeSettings) -> list[str]:
"""Fichiers nécessaires : les deux modèles communs, puis un par mot."""
return [*FEATURE_MODELS, *(f"{word}_{MODEL_VERSION}.onnx" for word in settings.words)]
class WakeWordDetector:
"""Donne au fil de l'eau les scores du mot de réveil, pour UNE carte.
Le modèle garde un historique audio interne : un détecteur par carte, sinon
les flux de deux cartes se mélangeraient.
"""
def __init__(self, settings: WakeSettings, device: str = "") -> None:
"""
Args:
device: carte écoutée, pour le journal.
Raises:
FileNotFoundError: modèles absents (« make models »).
ImportError: openwakeword non installé (« make install-wakeword »).
"""
missing = [name for name in model_files(settings) if not (settings.model_dir / name).exists()]
if missing:
raise FileNotFoundError(f"modeles openWakeWord absents de {settings.model_dir} "
f"({', '.join(missing)}) : lancez 'make models'")
from openwakeword.model import Model
directory = settings.model_dir
self._model: Model = Model(
wakeword_models=[str(directory / f"{word}_{MODEL_VERSION}.onnx") for word in settings.words],
inference_framework="onnx",
melspec_model_path=str(directory / FEATURE_MODELS[0]),
embedding_model_path=str(directory / FEATURE_MODELS[1]),
)
self._thresholds = {word: settings.thresholds.get(word, settings.threshold) for word in settings.words}
self._device = device
self._peaks: dict[str, float] = {} # pic de score en cours, par mot (diagnostic)
def process(self, pcm: bytes) -> Detection | None:
"""Ajoute un chunk (s16le, 16 kHz) ; rend le mot s'il vient d'être reconnu."""
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
raw: Any = self._model.predict(samples) # dict des scores (tuple seulement si timing=True)
scores = {str(key).removesuffix(f"_{MODEL_VERSION}"): float(value) for key, value in raw.items()}
detected: Detection | None = None
for word, score in scores.items():
threshold = self._thresholds.get(word, 1.0)
if score >= threshold:
if detected is None or score > detected.score:
detected = Detection(word=word, score=score)
self._track_near_miss(word, score, threshold)
if detected is not None:
self._peaks.clear()
return detected
def _track_near_miss(self, word: str, score: float, threshold: float) -> None:
"""Journalise un mot presque reconnu : la mesure qui permet de régler son seuil."""
if score >= NEAR_MISS_FLOOR:
self._peaks[word] = max(self._peaks.get(word, 0.0), score)
return
peak = self._peaks.pop(word, 0.0) # le pic vient de retomber
if NEAR_MISS_FLOOR <= peak < threshold:
logger.info("%s : mot presque reconnu « %s » : %.2f (seuil %.2f)", self._device, word, peak, threshold)
def reset(self) -> None:
"""Oublie l'historique : à faire après une réponse, pour ne pas redétecter l'ancien mot."""
self._model.reset()
self._peaks.clear()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Qui parle quand, qui est-ce, qui a dit quoi : VAD, diarisation, identification, texte (étapes 8-10).
Une ligne par locuteur, un caractère par 100 ms de parole (« # » : il parle).
Les instants sont ceux de la parole seule, silences retirés par la VAD : c'est
ce que reçoivent la diarisation et Whisper dans le serveur.
--demo fabrique une séquence à deux voix sans seconde personne : votre voix
(le dernier enregistrement de l'ESP32), la voix Piper, puis de nouveau la vôtre.
Elle est écrite dans --out pour l'écouter.
Exemples :
python3 tools/diarize_check.py server/recordings/esp32-01_xxx.wav
python3 tools/diarize_check.py --demo
"""
from __future__ import annotations
import argparse
import logging
import sys
import wave
from pathlib import Path
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_resampler import AudioFormatError, AudioResampler # noqa: E402
from voice_server.diarization import Diarizer, timeline # noqa: E402
from voice_server.settings import Settings, load_settings # noqa: E402
from voice_server.speaker_attribution import attribute, transcript_json # noqa: E402
from voice_server.speaker_identifier import SpeakerIdentifier # noqa: E402
from voice_server.speech_to_text import SpeechToText # noqa: E402
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
logger = logging.getLogger("diarize_check")
def demo_sequence(settings: Settings, vad: VoiceActivityDetector, diarizer: Diarizer,
voice: Path | None) -> bytes:
"""Votre voix, la voix Piper, votre voix : deux locuteurs attendus."""
from voice_server.text_to_speech import TextToSpeech
# Par défaut, la question la plus récente : 1 à 4 s de parole, une seule voix. Les
# sessions plus longues sont souvent des essais à plusieurs voix, que la diarisation
# ne sépare pas toujours.
recordings = [voice] if voice else sorted(settings.recordings_dir.glob("esp32-01_*.wav"), reverse=True)
reader = AudioResampler(settings.audio, None)
second = settings.audio.bytes_per_second
mine = b""
for path in recordings:
mine = vad.detect(reader.from_wav(path)).speech_pcm
if (voice or second <= len(mine) <= 4 * second) and len(diarizer.diarize(mine).speakers) == 1:
logger.info("votre voix : %s", path.name)
break
mine = b""
if not mine:
raise FileNotFoundError(f"aucun enregistrement de l'ESP32 avec de la parole dans {settings.recordings_dir}")
# Même niveau que votre enregistrement, pour que la diarisation juge la voix et non le volume.
tts = TextToSpeech(settings.tts, AudioResampler(settings.audio, -10.0), settings.audio.sample_rate)
piper = tts.synthesize("Bonjour, je suis la voix de synthèse, et je parle entre vos deux phrases.").pcm
pause = bytes(settings.audio.bytes_per_second // 2)
return mine + pause + piper + pause + mine
def main(argv: list[str]) -> int:
"""Diarise le fichier (ou la démonstration) et affiche la chronologie."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("wav", type=Path, nargs="?", help="enregistrement a analyser")
parser.add_argument("--demo", action="store_true", help="sequence a deux voix : vous et Piper")
parser.add_argument("--voice", type=Path, help="votre voix pour --demo (sinon la derniere question)")
parser.add_argument("--out", type=Path, default=Path("recordings/deux-voix.wav"),
help="WAV de la demonstration")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
if (args.wav is None) == (not args.demo):
parser.error("donnez soit un fichier WAV, soit --demo")
logging.basicConfig(level=logging.INFO, format="%(message)s")
settings = load_settings(args.config)
audio = settings.audio
vad = VoiceActivityDetector(settings.vad, audio.sample_rate)
try:
diarizer = Diarizer(settings.diarization, audio.sample_rate)
if args.demo:
pcm = demo_sequence(settings, vad, diarizer, args.voice)
args.out.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(args.out), "wb") as wav:
wav.setnchannels(audio.channels)
wav.setsampwidth(audio.bits // 8)
wav.setframerate(audio.sample_rate)
wav.writeframes(pcm)
logger.info("demonstration : make play OUT=%s", args.out)
else:
pcm = AudioResampler(audio, None).from_wav(args.wav)
except (AudioFormatError, FileNotFoundError, ImportError) as error:
logger.error("%s", error)
return 1
speech = vad.detect(pcm)
if not speech.has_speech:
print("\naucune parole : rien a diariser")
return 0
result = diarizer.diarize(speech.speech_pcm)
print(f"\nparole {speech.speech_s:.2f} s (sur {speech.total_s:.2f} s), "
f"{len(result.speakers)} locuteur(s), diarisation {result.elapsed_s:.2f} s")
for line in timeline(result):
print(f" {line}")
for turn in result.turns:
print(f" {turn.speaker} {turn.start_s:6.2f} -> {turn.end_s:6.2f} s")
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
verdicts = {} if result.embeddings is None else {
speaker: identifier.identify(embedding) for speaker, embedding in zip(result.speakers, result.embeddings)}
for speaker, verdict in verdicts.items():
print(f" {speaker} = {verdict.describe()}")
# Étape 10 : les mots de Whisper répartis entre les locuteurs.
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
transcript = SpeechToText(settings.stt, audio.sample_rate).transcribe(speech.speech_pcm)
utterances = attribute(transcript.words, result.turns, verdicts)
print(f"\nqui a dit quoi (transcription {transcript.elapsed_s:.2f} s) :")
for utterance in utterances:
print(f" {utterance.start_s:5.2f}-{utterance.end_s:5.2f} s {utterance.speaker:10s} « {utterance.text} »")
print("\nmessage MQTT voice/<carte>/transcript :")
print(f" {transcript_json('essai', utterances).decode()}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Télécharge les modèles de config.yaml dans server/models/ (étape 6).
models/whisper/<stt.model>/ modèle faster-whisper (CTranslate2)
models/piper/<tts.voice>.onnx voix Piper et sa configuration .onnx.json
models/pyannote/<nom>/ diarisation (étape 8) : jeton HF_TOKEN dans server/.env
models/openwakeword/*.onnx mots de réveil (étape 13)
Les modèles déjà présents ne sont pas retéléchargés. Ensuite, le serveur
fonctionne sans Internet : il ne lit que ces fichiers locaux.
Exemple :
python3 tools/download_models.py
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from pathlib import Path
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.settings import ( # noqa: E402
DiarizationSettings,
SttSettings,
TtsSettings,
WakeSettings,
load_settings,
)
from voice_server.wake_word import model_files # noqa: E402
OPENWAKEWORD_URL = "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/{name}"
logger = logging.getLogger("download_models")
def download_whisper(stt: SttSettings) -> None:
"""Télécharge le modèle faster-whisper depuis Hugging Face (Systran)."""
if (stt.model_dir / "model.bin").exists():
logger.info("whisper %s deja present : %s", stt.model, stt.model_dir)
return
from faster_whisper import download_model
logger.info("telechargement de whisper %s vers %s…", stt.model, stt.model_dir)
download_model(stt.model, output_dir=str(stt.model_dir))
def download_piper(tts: TtsSettings) -> None:
"""Télécharge la voix Piper (.onnx et .onnx.json) depuis Hugging Face (rhasspy)."""
config_path = tts.model_path.with_name(tts.model_path.name + ".json")
if tts.model_path.exists() and config_path.exists():
logger.info("voix piper %s deja presente : %s", tts.voice, tts.model_path)
return
from piper.download_voices import download_voice
logger.info("telechargement de la voix piper %s vers %s…", tts.voice, tts.model_path.parent)
tts.model_path.parent.mkdir(parents=True, exist_ok=True)
download_voice(tts.voice, tts.model_path.parent)
def download_pyannote(diarization: DiarizationSettings) -> bool:
"""Télécharge le pipeline de diarisation ; False si le jeton ou l'accord manque."""
if not diarization.enabled:
logger.info("diarisation desactivee dans config.yaml : rien a telecharger")
return True
if (diarization.model_dir / "config.yaml").exists():
logger.info("diarisation %s deja presente : %s", diarization.model, diarization.model_dir)
return True
page = f"https://huggingface.co/{diarization.model}"
token = os.environ.get("HF_TOKEN") # chargé depuis server/.env par load_settings
if not token:
logger.error("HF_TOKEN absent de server/.env. Pour la diarisation :\n"
" 1. compte sur https://huggingface.co, puis accepter les conditions sur %s\n"
" 2. jeton « Read » sur https://huggingface.co/settings/tokens\n"
" 3. cp server/.env.example server/.env, y renseigner HF_TOKEN=..., relancer 'make models'",
page)
return False
from huggingface_hub import snapshot_download
from huggingface_hub.errors import GatedRepoError, HfHubHTTPError
logger.info("telechargement de %s vers %s…", diarization.model, diarization.model_dir)
try:
snapshot_download(diarization.model, local_dir=diarization.model_dir, token=token,
allow_patterns=["config.yaml", "*/pytorch_model.bin", "plda/*.npz"])
except GatedRepoError:
logger.error("acces refuse : acceptez les conditions du modele sur %s", page)
return False
except HfHubHTTPError as error:
logger.error("echec du telechargement (%s) : jeton HF_TOKEN valide ?", error)
return False
return True
def download_openwakeword(wake: WakeSettings) -> None:
"""Télécharge les modèles ONNX d'openWakeWord (communs + un par mot) depuis GitHub."""
if not wake.enabled:
return
from urllib.request import urlretrieve
wake.model_dir.mkdir(parents=True, exist_ok=True)
for name in model_files(wake):
target = wake.model_dir / name
if target.exists():
continue
logger.info("telechargement de %s", name)
urlretrieve(OPENWAKEWORD_URL.format(name=name), target)
logger.info("mots de reveil prets : %s", ", ".join(wake.words))
def main(argv: list[str]) -> int:
"""Télécharge ce qui manque, puis affiche la taille totale."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # une ligne par requête HTTP sinon
settings = load_settings(args.config)
try:
download_whisper(settings.stt)
download_piper(settings.tts)
except ImportError as error:
logger.error("%s : lancez d'abord 'make install-speech'", error)
return 1
download_openwakeword(settings.wake)
diarization_ready = download_pyannote(settings.diarization)
size = sum(f.stat().st_size for f in settings.models_dir.rglob("*") if f.is_file())
logger.info("modeles dans %s (%.0f Mo)", settings.models_dir, size / 1e6)
return 0 if diarization_ready else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Enrôlement vocal : crée, liste ou supprime les profils de voix (étape 9).
Un profil est la moyenne des empreintes de plusieurs sessions d'une même
personne. Par défaut, on prend les N dernières sessions de l'ESP32 : dites
d'abord 5 phrases variées avec « make run », puis enrôlez.
Chaque session est contrôlée : assez de parole, une seule voix, et cohérente
avec les autres (une session où une autre voix s'est glissée est écartée).
Les profils sont des données biométriques : server/profiles/ n'est jamais
versionné, et « --forget » supprime un profil.
Exemples :
python3 tools/enroll.py --name Denis # 5 dernières sessions
python3 tools/enroll.py --name Denis a.wav b.wav c.wav
python3 tools/enroll.py --list
python3 tools/enroll.py --forget Denis
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
import numpy as np
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_resampler import AudioFormatError, AudioResampler # noqa: E402
from voice_server.diarization import Diarizer # noqa: E402
from voice_server.settings import Settings, load_settings # noqa: E402
from voice_server.speaker_identifier import ( # noqa: E402
SpeakerIdentifier,
load_profiles,
normalized,
profile_path,
save_profile,
)
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
logger = logging.getLogger("enroll")
MIN_SPEECH_S = 1.0 # en dessous, l'empreinte est trop instable pour servir de référence
MIN_SAMPLES = 3
def collect(settings: Settings, wavs: list[Path]) -> list[tuple[str, np.ndarray]]:
"""Une empreinte par session valable : assez de parole, une seule voix."""
audio = settings.audio
vad = VoiceActivityDetector(settings.vad, audio.sample_rate)
diarizer = Diarizer(settings.diarization, audio.sample_rate)
reader = AudioResampler(audio, None)
samples: list[tuple[str, np.ndarray]] = []
for path in wavs:
try:
speech = vad.detect(reader.from_wav(path))
except (AudioFormatError, FileNotFoundError) as error:
logger.warning(" %s ignore : %s", path.name, error)
continue
if speech.speech_s < MIN_SPEECH_S:
logger.warning(" %s ignore : %.1f s de parole (minimum %.0f s)", path.name, speech.speech_s, MIN_SPEECH_S)
continue
diarization = diarizer.diarize(speech.speech_pcm)
if len(diarization.speakers) != 1 or diarization.embeddings is None:
logger.warning(" %s ignore : %d voix trouvees, une seule attendue", path.name, len(diarization.speakers))
continue
embedding = diarization.embeddings[0]
if not np.all(np.isfinite(embedding)):
logger.warning(" %s ignore : empreinte impossible", path.name)
continue
logger.info(" %s : %.1f s de parole", path.name, speech.speech_s)
samples.append((path.name, normalized(embedding)))
return samples
def drop_outliers(samples: list[tuple[str, np.ndarray]], threshold: float) -> list[tuple[str, np.ndarray]]:
"""Écarte les sessions qui ne ressemblent pas aux autres (autre voix, mélange)."""
kept: list[tuple[str, np.ndarray]] = []
for index, (name, embedding) in enumerate(samples):
others = normalized(np.mean([e for i, (_, e) in enumerate(samples) if i != index], axis=0))
score = float(others @ embedding)
if score < threshold:
logger.warning(" %s ecarte : ressemblance %.2f avec les autres sessions (seuil %.2f)",
name, score, threshold)
else:
kept.append((name, embedding))
return kept
def enroll(settings: Settings, name: str, wavs: list[Path]) -> int:
"""Crée (ou remplace) le profil de name à partir des sessions données."""
logger.info("enrolement de %s sur %d session(s) :", name, len(wavs))
samples = collect(settings, wavs)
if len(samples) >= MIN_SAMPLES:
samples = drop_outliers(samples, settings.speaker.similarity_threshold)
if len(samples) < MIN_SAMPLES:
logger.error("%d session(s) valable(s), %d au minimum. Soit dites %d phrases seul avec 'make run' "
"puis relancez, soit remontez plus loin : 'make enroll NAME=%s LAST=%d'",
len(samples), MIN_SAMPLES, MIN_SAMPLES + 2, name, len(wavs) * 2)
return 1
# Avant d'écrire : ce nouveau profil ressemble-t-il trop à quelqu'un d'autre ?
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
mean = normalized(np.mean([e for _, e in samples], axis=0))
for other in identifier.profiles:
score = float(other.embedding @ mean)
if other.name != name and score >= settings.speaker.similarity_threshold:
logger.warning("attention : ressemble a %s (%.2f) ; meme personne, ou seuil trop bas ?",
other.name, score)
profile = save_profile(settings.speaker.profiles_dir, name, [e for _, e in samples], settings.diarization.model)
scores = [float(profile.embedding @ e) for _, e in samples]
logger.info("profil %s enregistre : %d sessions, ressemblance %.2f a %.2f -> %s",
name, profile.samples, min(scores), max(scores),
profile_path(settings.speaker.profiles_dir, name))
return 0
def main(argv: list[str]) -> int:
"""Aiguille entre enrôlement, liste et suppression."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("wavs", type=Path, nargs="*", help="sessions a utiliser (sinon les --last dernieres)")
parser.add_argument("--name", help="nom de la personne a enroler")
parser.add_argument("--last", type=int, default=5, help="nombre de sessions recentes de l'ESP32")
parser.add_argument("--device", default="esp32-01", help="carte dont on prend les sessions")
parser.add_argument("--list", action="store_true", help="affiche les profils")
parser.add_argument("--forget", metavar="NOM", help="supprime le profil de NOM")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
settings = load_settings(args.config)
profiles_dir = settings.speaker.profiles_dir
if args.list:
profiles = load_profiles(profiles_dir, settings.diarization.model)
for profile in profiles:
print(f" {profile.name:20s} {profile.samples} sessions, cree le {profile.created}")
print(f"{len(profiles)} profil(s) dans {profiles_dir}")
return 0
if args.forget:
path = profile_path(profiles_dir, args.forget)
if not path.exists():
logger.error("aucun profil %s (%s)", args.forget, path)
return 1
path.unlink()
logger.info("profil %s supprime (%s)", args.forget, path)
return 0
if not args.name:
parser.error("--name, --list ou --forget")
wavs = args.wavs or sorted(settings.recordings_dir.glob(f"{args.device}_*.wav"))[-args.last:]
if not wavs:
logger.error("aucune session dans %s : parlez d'abord avec 'make run'", settings.recordings_dir)
return 1
try:
return enroll(settings, args.name, wavs)
except (FileNotFoundError, ImportError) as error:
logger.error("%s", error)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

Some files were not shown because too many files have changed in this diff Show More