commit d20976fea26aa48ce40093812af3d3ac082d5e70 Author: Denis Defolie Date: Wed Sep 30 17:40:55 2026 +0200 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bc05bbd --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..671ef1b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..837da4a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "platformio.platformio-ide", + "ms-python.python", + "ms-pyright.pyright", + "redhat.vscode-yaml" + ] +} diff --git a/.vscode/settings.json.example b/.vscode/settings.json.example new file mode 100644 index 0000000..3bee115 --- /dev/null +++ b/.vscode/settings.json.example @@ -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 +} diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..862e3df --- /dev/null +++ b/CONTEXT.md @@ -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//`, `piper/.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//`** 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/.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//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///set`** (QoS 1, **non retenu**) **et**, si la pièce est celle de la carte, `voice//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//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/+/` 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//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 . 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`, `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//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). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95dbb60 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f18e51 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README-EN.md b/README-EN.md new file mode 100644 index 0000000..f478823 --- /dev/null +++ b/README-EN.md @@ -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/.wav +make vad OUT=server/recordings/.wav # speech found by the VAD (step 7) +make vad-all # VAD over every server recording +make diarize OUT=server/recordings/.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//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//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///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//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/.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 , then **accept the conditions** at + . +2. Create a "Read" token at 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/.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/.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/.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__.wav` and logs a + report: `complete`, received / announced chunks, peak and RMS. +5. `make play OUT=server/recordings/.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=` 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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b0c2e5 --- /dev/null +++ b/README.md @@ -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/.wav +make vad OUT=server/recordings/.wav # parole trouvée par la VAD (étape 7) +make vad-all # VAD sur tous les enregistrements du serveur +make diarize OUT=server/recordings/.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//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//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///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//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/.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 , puis **accepter les conditions** sur + . +2. Jeton de type « Read » sur , 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/.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/.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/.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__.wav` et affiche + son bilan : `complete`, chunks reçus / annoncés, crête et RMS. +5. `make play OUT=server/recordings/.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=` 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). diff --git a/agent/Makefile b/agent/Makefile new file mode 100644 index 0000000..1c7d829 --- /dev/null +++ b/agent/Makefile @@ -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 diff --git a/agent/agent.yaml.example b/agent/agent.yaml.example new file mode 100644 index 0000000..3a76ae5 --- /dev/null +++ b/agent/agent.yaml.example @@ -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//..., 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 diff --git a/agent/voice-agent.service b/agent/voice-agent.service new file mode 100644 index 0000000..805d667 --- /dev/null +++ b/agent/voice-agent.service @@ -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 diff --git a/agent/voice_agent.py b/agent/voice_agent.py new file mode 100644 index 0000000..c801235 --- /dev/null +++ b/agent/voice_agent.py @@ -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//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//state {"status":"online","actions":[...]} retenu ; testament « offline » + agent//command {"action":"shutdown","id":"...","by":"Denis"} (QoS 1) + agent//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//.""" + return f"{self.topic_prefix}/{self.agent_id}/{suffix}" + + +@dataclass(frozen=True) +class Result: + """Compte rendu d'une action, publié sur agent//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:])) diff --git a/docs/BROCHAGE.md b/docs/BROCHAGE.md new file mode 100644 index 0000000..872064b --- /dev/null +++ b/docs/BROCHAGE.md @@ -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
`1 × 10⁸ pF` | **470 µF**
`4,7 × 10⁸ pF` | 1 000 µF
`1 × 10⁹ pF` | ≥ 10 V, **16 V conseillé** | +| **C2** | 10 nF
`10 000 pF` (code 103) | **100 nF**
`100 000 pF` (code **104**) | 1 µF
`1 000 000 pF` (code 105) | ≥ 25 V | +| **C3** | 10 nF
`10 000 pF` (code 103) | **100 nF**
`100 000 pF` (code **104**) | 220 nF
`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. diff --git a/docs/MOSQUITTO.md b/docs/MOSQUITTO.md new file mode 100644 index 0000000..1f94c19 --- /dev/null +++ b/docs/MOSQUITTO.md @@ -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//state` et `result` ; lire **ses** `agent//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 ` | +| `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///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//state` `{"status":"online","actions":[...]}` | l'agent Linux, étape 15b ; `offline` par son testament | à sa connexion | **oui** | +| `agent//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//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. diff --git a/docs/wiring.png b/docs/wiring.png new file mode 100644 index 0000000..702f811 Binary files /dev/null and b/docs/wiring.png differ diff --git a/docs/wiring.svg b/docs/wiring.svg new file mode 100644 index 0000000..81b92b2 --- /dev/null +++ b/docs/wiring.svg @@ -0,0 +1,283 @@ + + + + + + + + Assistant vocal local — câblage complet + ESP32-WROOM-32 · INMP441 (micro I2S) · MAX98357A (ampli I2S) · haut-parleur · bouton PTT · LED d'état + + + ESP32-WROOM-32 (DevKit V1) + + vue logique — repérez les numéros sérigraphiés + + + + EN + GPIO 36 + GPIO 39 + GPIO 34 + GPIO 35 + GPIO 32 + GPIO 12 + GPIO 13 + GND + + + GPIO 33 + GPIO 25 + GPIO 26 + GPIO 27 + GPIO 14 + VIN (5 V) + + + + + GPIO 23 + TX0 + RX0 + GPIO 21 + GPIO 19 + GPIO 18 + GPIO 5 + GPIO 17 + GPIO 16 + GPIO 15 + + + GPIO 22 + GPIO 4 + GPIO 2 + GND + 3V3 + + + + + + + + + + + + + + + + + + + + + + + + + INMP441 + micro I2S — entrée audio + 3,3 V uniquement + + SD + SCK + WS + + + L/R + GND + VDD + + + + + + + + + + + + + + + + + + + L/R → GND (canal gauche) + + + + MAX98357A + ampli classe D mono — sortie audio + alimenté en 5 V + + LRC + BCLK + DIN + + + GAIN + SD + GND + Vin + + + + + − + + + + + + + + + + + + + + + + + + GPIO 14 → LRC (vert) + GPIO 27 → BCLK (bleu) + ces deux fils contournent la carte + GPIO 22 → DIN + + + GAIN : NC → 9 dB + SD : NC → ampli actif + + + + + + + + + + + + + + + + + C1 + + + + + + + C2 + + Découplage conseillé + C1 = 470 µF / 16 V électrolytique, faible ESR + polarisé : la bande imprimée est le « − » + C2 = 100 nF X7R céramique (code 104) + les deux à moins de 3 cm des broches de l'ampli + + + + + HP + 4 Ω / 3 W + (ou 8 Ω / 2 W) + + + + + « − » jamais à la masse + + + + Bouton PTT + pull-up interne + + + → bouton + + + + + + GND + + + + LED + 330 Ω + optionnelle (LED intégrée) + + + → LED + + + + + + GND + + + + + + 3,3 V + GND (masse commune) + 5 V + + + + + + + + + + + + + + + + + + Légende + + 5 V + 3,3 V + masse / logique + horloge bit (SCK / BCLK) + mot (WS / LRC) + données audio (SD / DIN) + + + connexion électrique — deux fils qui se croisent sans point ne sont pas reliés. + + + Masse commune obligatoire entre tous les modules. + Câblez hors tension. INMP441 en 3,3 V : le 5 V le détruit. + Fils I2S de 15 cm maximum, éloignés des fils du haut-parleur. + Alimentation 5 V / 2 A : l'ampli demande jusqu'à 1,2 A sur les crêtes. + Détails et vérifications : docs/BROCHAGE.md + diff --git a/firmware/include/config.h b/firmware/include/config.h new file mode 100644 index 0000000..3a7de10 --- /dev/null +++ b/firmware/include/config.h @@ -0,0 +1,243 @@ +#pragma once + +#include +#include + +/** + * @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//... +/// 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; diff --git a/firmware/include/secrets.h.example b/firmware/include/secrets.h.example new file mode 100644 index 0000000..bee8318 --- /dev/null +++ b/firmware/include/secrets.h.example @@ -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 = ""; diff --git a/firmware/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..9cea4c2 --- /dev/null +++ b/firmware/platformio.ini @@ -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 diff --git a/firmware/src/AudioFifo.cpp b/firmware/src/AudioFifo.cpp new file mode 100644 index 0000000..d47614c --- /dev/null +++ b/firmware/src/AudioFifo.cpp @@ -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(); +} diff --git a/firmware/src/AudioFifo.h b/firmware/src/AudioFifo.h new file mode 100644 index 0000000..bc951e4 --- /dev/null +++ b/firmware/src/AudioFifo.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +#include + +/** + * @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 _overruns{0}; ///< lu par loop(), incrémenté par l'écrivain +}; diff --git a/firmware/src/AudioOutput.cpp b/firmware/src/AudioOutput.cpp new file mode 100644 index 0000000..e0ec82c --- /dev/null +++ b/firmware/src/AudioOutput.cpp @@ -0,0 +1,123 @@ +#include "AudioOutput.h" + +#include + +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(_pinBclk), + .ws = static_cast(_pinLrc), + .dout = static_cast(_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(_sampleRate) * durationMs) / 1000; + const size_t fade = _sampleRate / 100; // 10 ms : un démarrage brutal claquerait + const float step = 2.0F * static_cast(M_PI) * static_cast(frequencyHz) / + static_cast(_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(n) / static_cast(fade); + } else if (total - n < fade) { + gain = static_cast(total - n) / static_cast(fade); + } + _tone[i] = static_cast(lroundf(static_cast(amplitude) * gain * + sinf(phase))); + phase += step; + if (phase > 2.0F * static_cast(M_PI)) { + phase -= 2.0F * static_cast(M_PI); // évite la perte de précision + } + } + + write(_tone, count); + done += count; + } +} diff --git a/firmware/src/AudioOutput.h b/firmware/src/AudioOutput.h new file mode 100644 index 0000000..2ac950d --- /dev/null +++ b/firmware/src/AudioOutput.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +/** + * @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 +}; diff --git a/firmware/src/CommandHandler.cpp b/firmware/src/CommandHandler.cpp new file mode 100644 index 0000000..b1f76df --- /dev/null +++ b/firmware/src/CommandHandler.cpp @@ -0,0 +1,77 @@ +#include "CommandHandler.h" + +#include + +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(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(length > 120 ? 120 : length); + Serial.printf("# control : message non reconnu : %.*s\n", shown, + reinterpret_cast(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); +} diff --git a/firmware/src/CommandHandler.h b/firmware/src/CommandHandler.h new file mode 100644 index 0000000..58a78c7 --- /dev/null +++ b/firmware/src/CommandHandler.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +#include "DemoDevices.h" +#include "MqttLink.h" +#include "PlaybackStream.h" + +/** + * @brief Interprète les messages JSON reçus sur voice//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//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//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//event. + void handleDevice(const char* device, const char* action); +}; diff --git a/firmware/src/DemoDevices.cpp b/firmware/src/DemoDevices.cpp new file mode 100644 index 0000000..7deee7f --- /dev/null +++ b/firmware/src/DemoDevices.cpp @@ -0,0 +1,45 @@ +#include "DemoDevices.h" + +#include + +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 "?"; +} diff --git a/firmware/src/DemoDevices.h b/firmware/src/DemoDevices.h new file mode 100644 index 0000000..5c9da55 --- /dev/null +++ b/firmware/src/DemoDevices.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +/** + * @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; +}; diff --git a/firmware/src/MicCapture.cpp b/firmware/src/MicCapture.cpp new file mode 100644 index 0000000..9ed5b4e --- /dev/null +++ b/firmware/src/MicCapture.cpp @@ -0,0 +1,361 @@ +#include "MicCapture.h" + +#include + +#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(_pinSck), + .ws = static_cast(_pinWs), + .dout = I2S_GPIO_UNUSED, + .din = static_cast(_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(_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(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(_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((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(_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(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(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(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(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(sum / static_cast(count)); + } +} + +void MicCapture::settle(uint32_t durationMs) { + if (_rxHandle == nullptr) { + return; + } + + const uint32_t deadline = millis() + durationMs; + while (static_cast(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((bytesRead / sizeof(int32_t)) & 1U); + + if (err != ESP_OK) { + break; + } + } +} diff --git a/firmware/src/MicCapture.h b/firmware/src/MicCapture.h new file mode 100644 index 0000000..7e0b9ac --- /dev/null +++ b/firmware/src/MicCapture.h @@ -0,0 +1,169 @@ +#pragma once + +#include +#include + +/** + * @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); +}; diff --git a/firmware/src/MicSelfTest.cpp b/firmware/src/MicSelfTest.cpp new file mode 100644 index 0000000..3f34519 --- /dev/null +++ b/firmware/src/MicSelfTest.cpp @@ -0,0 +1,295 @@ +#include "MicSelfTest.h" + +#include + +#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(_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(peak24) * MIC_GAIN) >> 8; + + Serial.printf("# parole : crete %d dBFS apres passe-haut, %d bits utiles\n", + dbfs(static_cast(peak24)), usedBits(stats)); + Serial.printf("# apres gain x%d : crete a %ld %% de la pleine echelle\n", MIC_GAIN, + static_cast((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((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(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(words[index]), + static_cast(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(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('0' + tenth); + } + pattern[32] = '\0'; + + Serial.printf("# colonne %u, taux de 1 par bit (31..0) : %s\n", + static_cast(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(up), static_cast(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(static_cast(stats.sum) / + static_cast(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(stats.wordCount); + const double mean = static_cast(stats.sum) / count; + const double variance = static_cast(stats.sumSquares) / count - mean * mean; + if (variance <= 0.0) { + return 0.0F; + } + return static_cast(sqrt(variance) * 16.0); +} + +int MicSelfTest::dbfs(float lsb24) { + if (lsb24 <= 0.0F) { + return -999; + } + return static_cast(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 "; +} diff --git a/firmware/src/MicSelfTest.h b/firmware/src/MicSelfTest.h new file mode 100644 index 0000000..660c328 --- /dev/null +++ b/firmware/src/MicSelfTest.h @@ -0,0 +1,78 @@ +#pragma once + +#include + +#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); +}; diff --git a/firmware/src/MqttLink.cpp b/firmware/src/MqttLink.cpp new file mode 100644 index 0000000..1991fa0 --- /dev/null +++ b/firmware/src/MqttLink.cpp @@ -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(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(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(_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 : //. 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; +} diff --git a/firmware/src/MqttLink.h b/firmware/src/MqttLink.h new file mode 100644 index 0000000..b8147ea --- /dev/null +++ b/firmware/src/MqttLink.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include + +#include + +/** + * @brief Connexion au broker MQTT, avec testament et reconnexion espacée. + * + * Tous les topics ont la forme //, 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; + + /// Rappel de connexion : appelé à chaque (re)connexion au broker. + using ConnectHandler = std::function; + + /** + * @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 //. + * @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 //, 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 à //, 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 // 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; +}; diff --git a/firmware/src/PlaybackStream.cpp b/firmware/src/PlaybackStream.cpp new file mode 100644 index 0000000..89e60fb --- /dev/null +++ b/firmware/src/PlaybackStream.cpp @@ -0,0 +1,185 @@ +#include "PlaybackStream.h" + +#include + +#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(rate), static_cast(bits), + static_cast(channels), + static_cast(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(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(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(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(_fifo.available()); + if (left < _minMarginBytes.load()) { + _minMarginBytes.store(left); + } + } +} diff --git a/firmware/src/PlaybackStream.h b/firmware/src/PlaybackStream.h new file mode 100644 index 0000000..3add26e --- /dev/null +++ b/firmware/src/PlaybackStream.h @@ -0,0 +1,117 @@ +#pragma once + +#include + +#include + +#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::Idle}; + std::atomic _endOfStream{false}; ///< END reçu (ou source abandonnée) : la FIFO peut se vider + std::atomic _underruns{0}; + std::atomic _firstSoundMs{0}; ///< date du premier échantillon joué, 0 : pas encore + std::atomic _minMarginBytes{UINT32_MAX}; + std::atomic _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); +}; diff --git a/firmware/src/PushButton.cpp b/firmware/src/PushButton.cpp new file mode 100644 index 0000000..1971c35 --- /dev/null +++ b/firmware/src/PushButton.cpp @@ -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; +} diff --git a/firmware/src/PushButton.h b/firmware/src/PushButton.h new file mode 100644 index 0000000..c662a52 --- /dev/null +++ b/firmware/src/PushButton.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +/** + * @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; +}; diff --git a/firmware/src/SerialFrameReader.cpp b/firmware/src/SerialFrameReader.cpp new file mode 100644 index 0000000..b382941 --- /dev/null +++ b/firmware/src/SerialFrameReader.cpp @@ -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(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(_header[1]) | (static_cast(_header[2]) << 8); + const bool knownType = type >= static_cast(FrameType::Start) && + type <= static_cast(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(_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. +} diff --git a/firmware/src/SerialFrameReader.h b/firmware/src/SerialFrameReader.h new file mode 100644 index 0000000..c4963d6 --- /dev/null +++ b/firmware/src/SerialFrameReader.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#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(); +}; diff --git a/firmware/src/SerialFramer.cpp b/firmware/src/SerialFramer.cpp new file mode 100644 index 0000000..5779b6f --- /dev/null +++ b/firmware/src/SerialFramer.cpp @@ -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(sampleRate & 0xFF); + payload[1] = static_cast((sampleRate >> 8) & 0xFF); + payload[2] = static_cast((sampleRate >> 16) & 0xFF); + payload[3] = static_cast((sampleRate >> 24) & 0xFF); + payload[4] = static_cast(bits & 0xFF); + payload[5] = static_cast((bits >> 8) & 0xFF); + payload[6] = static_cast(channels & 0xFF); + payload[7] = static_cast((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(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(type); + header[5] = static_cast(length & 0xFF); + header[6] = static_cast((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); +} diff --git a/firmware/src/SerialFramer.h b/firmware/src/SerialFramer.h new file mode 100644 index 0000000..d43ed4e --- /dev/null +++ b/firmware/src/SerialFramer.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +#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); +}; diff --git a/firmware/src/SerialProtocol.h b/firmware/src/SerialProtocol.h new file mode 100644 index 0000000..bb0b7f6 --- /dev/null +++ b/firmware/src/SerialProtocol.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +/** + * @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 diff --git a/firmware/src/StatusLed.cpp b/firmware/src/StatusLed.cpp new file mode 100644 index 0000000..a05de11 --- /dev/null +++ b/firmware/src/StatusLed.cpp @@ -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); +} diff --git a/firmware/src/StatusLed.h b/firmware/src/StatusLed.h new file mode 100644 index 0000000..451c285 --- /dev/null +++ b/firmware/src/StatusLed.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +/** + * @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); +}; diff --git a/firmware/src/VoiceSession.cpp b/firmware/src/VoiceSession.cpp new file mode 100644 index 0000000..057a271 --- /dev/null +++ b/firmware/src/VoiceSession.cpp @@ -0,0 +1,102 @@ +#include "VoiceSession.h" + +#include + +#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(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(sampleRate), + static_cast(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(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(_chunks), static_cast(_durationMs), + static_cast(_failures), static_cast(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; +} diff --git a/firmware/src/VoiceSession.h b/firmware/src/VoiceSession.h new file mode 100644 index 0000000..8aa4f49 --- /dev/null +++ b/firmware/src/VoiceSession.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +#include "MqttLink.h" +#include "SerialFramer.h" + +/** + * @brief Une session de parole : START, chunks PCM, END. + * + * Par MQTT (étape 4) : + * voice//event {"event":"start","session":"a1b2c3","rate":16000,...} + * voice//audio/in 1600 octets de PCM brut, 20 fois par seconde + * voice//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 +}; diff --git a/firmware/src/WifiLink.cpp b/firmware/src/WifiLink.cpp new file mode 100644 index 0000000..fc35f4c --- /dev/null +++ b/firmware/src/WifiLink.cpp @@ -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((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(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(); +} diff --git a/firmware/src/WifiLink.h b/firmware/src/WifiLink.h new file mode 100644 index 0000000..9c35c59 --- /dev/null +++ b/firmware/src/WifiLink.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +/** + * @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 +}; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp new file mode 100644 index 0000000..22f086b --- /dev/null +++ b/firmware/src/main.cpp @@ -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//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 +#include +#include +#include + +#include + +#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//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 captureWanted{false}; ///< loop() demande la capture +std::atomic 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(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(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(report.chunks)); + if (report.announced >= 0) { + Serial.printf(" sur %ld annonces", static_cast(report.announced)); + } + Serial.printf(", %lu refuses (FIFO pleine), %lu reamorcages, premier son a %lu ms, " + "marge mini %ld ms, %lu ms\n", + static_cast(report.overruns), + static_cast(report.underruns), + static_cast(report.startMs), + static_cast(report.minMarginMs), + static_cast(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(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(report.chunks), static_cast(report.announced), + static_cast(report.overruns), + static_cast(report.underruns), + static_cast(report.durationMs), + static_cast(report.startMs), + static_cast(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(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(session.chunksSent()), + static_cast(session.sendFailures()), + static_cast(overruns), + static_cast(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(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(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(AUDIO_SAMPLE_RATE), + static_cast(AUDIO_CHUNK_MS), + static_cast(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(BOOT_TONE_HZ), + static_cast(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(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); + } +} diff --git a/mosquitto/acl b/mosquitto/acl new file mode 100644 index 0000000..ffe583f --- /dev/null +++ b/mosquitto/acl @@ -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 diff --git a/mosquitto/voice.conf b/mosquitto/voice.conf new file mode 100644 index 0000000..b52bc84 --- /dev/null +++ b/mosquitto/voice.conf @@ -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 diff --git a/prompt-assistant-vocal-esp32.md b/prompt-assistant-vocal-esp32.md new file mode 100644 index 0000000..987d02a --- /dev/null +++ b/prompt-assistant-vocal-esp32.md @@ -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 +#include + +/** + * @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. diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..2ec8118 --- /dev/null +++ b/server/.env.example @@ -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= diff --git a/server/config.yaml b/server/config.yaml new file mode 100644 index 0000000..8a16856 --- /dev/null +++ b/server/config.yaml @@ -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/, « 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/.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 ///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//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/_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//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 » diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..f589c80 --- /dev/null +++ b/server/pyproject.toml @@ -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"] diff --git a/server/pyrightconfig.json b/server/pyrightconfig.json new file mode 100644 index 0000000..652e3f3 --- /dev/null +++ b/server/pyrightconfig.json @@ -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 +} diff --git a/server/tests/test_access_control.py b/server/tests/test_access_control.py new file mode 100644 index 0000000..2be467d --- /dev/null +++ b/server/tests/test_access_control.py @@ -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." diff --git a/server/tests/test_assistant.py b/server/tests/test_assistant.py new file mode 100644 index 0000000..18b106e --- /dev/null +++ b/server/tests/test_assistant.py @@ -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 ?") diff --git a/server/tests/test_audio_output_path.py b/server/tests/test_audio_output_path.py new file mode 100644 index 0000000..0ff83bc --- /dev/null +++ b/server/tests/test_audio_output_path.py @@ -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=" 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é diff --git a/server/tests/test_diarization.py b/server/tests/test_diarization.py new file mode 100644 index 0000000..4267958 --- /dev/null +++ b/server/tests/test_diarization.py @@ -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=" 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 diff --git a/server/tests/test_hands_free.py b/server/tests/test_hands_free.py new file mode 100644 index 0000000..f959e5d --- /dev/null +++ b/server/tests/test_hands_free.py @@ -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=" 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=" 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 diff --git a/server/tests/test_home_control.py b/server/tests/test_home_control.py new file mode 100644 index 0000000..cf73421 --- /dev/null +++ b/server/tests/test_home_control.py @@ -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 diff --git a/server/tests/test_machine_control.py b/server/tests/test_machine_control.py new file mode 100644 index 0000000..a0c9dd0 --- /dev/null +++ b/server/tests/test_machine_control.py @@ -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"] diff --git a/server/tests/test_mqtt_link.py b/server/tests/test_mqtt_link.py new file mode 100644 index 0000000..5913a3f --- /dev/null +++ b/server/tests/test_mqtt_link.py @@ -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() diff --git a/server/tests/test_serial_frames.py b/server/tests/test_serial_frames.py new file mode 100644 index 0000000..a7e4e4e --- /dev/null +++ b/server/tests/test_serial_frames.py @@ -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(" 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(" 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(" 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") diff --git a/server/tests/test_session_manager.py b/server/tests/test_session_manager.py new file mode 100644 index 0000000..8049541 --- /dev/null +++ b/server/tests/test_session_manager.py @@ -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"] diff --git a/server/tests/test_speaker_attribution.py b/server/tests/test_speaker_attribution.py new file mode 100644 index 0000000..43ec646 --- /dev/null +++ b/server/tests/test_speaker_attribution.py @@ -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 diff --git a/server/tests/test_speaker_identifier.py b/server/tests/test_speaker_identifier.py new file mode 100644 index 0000000..afd22a8 --- /dev/null +++ b/server/tests/test_speaker_identifier.py @@ -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"] diff --git a/server/tests/test_speech_models.py b/server/tests/test_speech_models.py new file mode 100644 index 0000000..1ed8161 --- /dev/null +++ b/server/tests/test_speech_models.py @@ -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(" 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 diff --git a/server/tests/test_voice_activity.py b/server/tests/test_voice_activity.py new file mode 100644 index 0000000..5b16ecc --- /dev/null +++ b/server/tests/test_voice_activity.py @@ -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=" 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(" 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": ""} diff --git a/server/tests/test_voice_pipeline.py b/server/tests/test_voice_pipeline.py new file mode 100644 index 0000000..b5de5cf --- /dev/null +++ b/server/tests/test_voice_pipeline.py @@ -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//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] diff --git a/server/tests/test_wav_to_serial.py b/server/tests/test_wav_to_serial.py new file mode 100644 index 0000000..50efd4a --- /dev/null +++ b/server/tests/test_wav_to_serial.py @@ -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 diff --git a/server/voice_server/__init__.py b/server/voice_server/__init__.py new file mode 100644 index 0000000..4237cf4 --- /dev/null +++ b/server/voice_server/__init__.py @@ -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" diff --git a/server/voice_server/__main__.py b/server/voice_server/__main__.py new file mode 100644 index 0000000..42a1954 --- /dev/null +++ b/server/voice_server/__main__.py @@ -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//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:])) diff --git a/server/voice_server/access_control.py b/server/voice_server/access_control.py new file mode 100644 index 0000000..9f4c979 --- /dev/null +++ b/server/voice_server/access_control.py @@ -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 diff --git a/server/voice_server/assistant.py b/server/voice_server/assistant.py new file mode 100644 index 0000000..177bebe --- /dev/null +++ b/server/voice_server/assistant.py @@ -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()}" diff --git a/server/voice_server/audio_buffer.py b/server/voice_server/audio_buffer.py new file mode 100644 index 0000000..022afc0 --- /dev/null +++ b/server/voice_server/audio_buffer.py @@ -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=" 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()) diff --git a/server/voice_server/audio_resampler.py b/server/voice_server/audio_resampler.py new file mode 100644 index 0000000..7135050 --- /dev/null +++ b/server/voice_server/audio_resampler.py @@ -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(" 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=" float: + """Crête d'un PCM s16le, en dBFS.""" + samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype=" 0 else -math.inf diff --git a/server/voice_server/audio_sender.py b/server/voice_server/audio_sender.py new file mode 100644 index 0000000..4f52645 --- /dev/null +++ b/server/voice_server/audio_sender.py @@ -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//control en QoS 1 (garantis) ; + les chunks sur voice//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 diff --git a/server/voice_server/command_router.py b/server/voice_server/command_router.py new file mode 100644 index 0000000..b3c2efd --- /dev/null +++ b/server/voice_server/command_router.py @@ -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 [] diff --git a/server/voice_server/diarization.py b/server/voice_server/diarization.py new file mode 100644 index 0000000..99c5ae2 --- /dev/null +++ b/server/voice_server/diarization.py @@ -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=" np.ndarray: + """Empreinte vocale d'un PCM entier (enrôlement : une seule voix attendue).""" + audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype=" 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 diff --git a/server/voice_server/hands_free.py b/server/voice_server/hands_free.py new file mode 100644 index 0000000..a21aff5 --- /dev/null +++ b/server/voice_server/hands_free.py @@ -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//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) diff --git a/server/voice_server/home_control.py b/server/voice_server/home_control.py new file mode 100644 index 0000000..cdecb48 --- /dev/null +++ b/server/voice_server/home_control.py @@ -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///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 diff --git a/server/voice_server/machine_control.py b/server/voice_server/machine_control.py new file mode 100644 index 0000000..0122d02 --- /dev/null +++ b/server/voice_server/machine_control.py @@ -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//state agent → tous {"status":"online","actions":[...]}, retenu + testament + agent//command serveur → agent {"action":"shutdown","id":...,"by":"Denis"} + agent//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. + + +@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//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//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] diff --git a/server/voice_server/mqtt_link.py b/server/voice_server/mqtt_link.py new file mode 100644 index 0000000..e8f54d7 --- /dev/null +++ b/server/voice_server/mqtt_link.py @@ -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 // 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 « // » 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 : //. 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) diff --git a/server/voice_server/session_manager.py b/server/voice_server/session_manager.py new file mode 100644 index 0000000..98c1270 --- /dev/null +++ b/server/voice_server/session_manager.py @@ -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//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//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) diff --git a/server/voice_server/settings.py b/server/voice_server/settings.py new file mode 100644 index 0000000..4758377 --- /dev/null +++ b/server/voice_server/settings.py @@ -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 ///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 # //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, + ) diff --git a/server/voice_server/speaker_attribution.py b/server/voice_server/speaker_attribution.py new file mode 100644 index 0000000..0b5bf14 --- /dev/null +++ b/server/voice_server/speaker_attribution.py @@ -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//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") diff --git a/server/voice_server/speaker_identifier.py b/server/voice_server/speaker_identifier.py new file mode 100644 index 0000000..8f14f35 --- /dev/null +++ b/server/voice_server/speaker_identifier.py @@ -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) diff --git a/server/voice_server/speech_to_text.py b/server/voice_server/speech_to_text.py new file mode 100644 index 0000000..288a8d0 --- /dev/null +++ b/server/voice_server/speech_to_text.py @@ -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=" 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()) diff --git a/server/voice_server/text_to_speech.py b/server/voice_server/text_to_speech.py new file mode 100644 index 0000000..d39cfb3 --- /dev/null +++ b/server/voice_server/text_to_speech.py @@ -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, + ) diff --git a/server/voice_server/voice_activity.py b/server/voice_server/voice_activity.py new file mode 100644 index 0000000..a3fa6ee --- /dev/null +++ b/server/voice_server/voice_activity.py @@ -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=" 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) diff --git a/server/voice_server/voice_pipeline.py b/server/voice_server/voice_pipeline.py new file mode 100644 index 0000000..b87002e --- /dev/null +++ b/server/voice_server/voice_pipeline.py @@ -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//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//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 diff --git a/server/voice_server/wake_word.py b/server/voice_server/wake_word.py new file mode 100644 index 0000000..2a5c7b2 --- /dev/null +++ b/server/voice_server/wake_word.py @@ -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="= 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() diff --git a/tools/diarize_check.py b/tools/diarize_check.py new file mode 100644 index 0000000..55a20a7 --- /dev/null +++ b/tools/diarize_check.py @@ -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//transcript :") + print(f" {transcript_json('essai', utterances).decode()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/download_models.py b/tools/download_models.py new file mode 100644 index 0000000..9a9a1bf --- /dev/null +++ b/tools/download_models.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Télécharge les modèles de config.yaml dans server/models/ (étape 6). + + models/whisper// modèle faster-whisper (CTranslate2) + models/piper/.onnx voix Piper et sa configuration .onnx.json + models/pyannote// 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:])) diff --git a/tools/enroll.py b/tools/enroll.py new file mode 100644 index 0000000..d7f2f69 --- /dev/null +++ b/tools/enroll.py @@ -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:])) diff --git a/tools/say.py b/tools/say.py new file mode 100644 index 0000000..faef3b6 --- /dev/null +++ b/tools/say.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Fait dire une phrase par une voix Piper, en WAV et MP3 : pour la jouer au micro depuis un téléphone. + +Sert à tester l'identification (étape 9) et les droits (étape 14) sans seconde +personne. Les voix sont téléchargées au premier usage dans server/models/piper/. +Voix françaises : fr_FR-siwis-medium, fr_FR-tom-medium, fr_FR-upmc-medium +(SPEAKER 0 = Jessica, 1 = Pierre), fr_FR-gilles-low. + +Mot de réveil (--wake) : une voix française le dit « à la française » et le +modèle ne le reconnaît pas (score 0,00 mesuré, même en graphie phonétique ; +les rares réussites retombent à 0 au moindre bruit). Il est donc dit par une +voix ANGLAISE (--wake-voice), puis la voix choisie dit la commande. Le serveur +identifie la personne sur la dernière intervention : c'est bien la voix de la +commande qui compte. Mais les 0,5 s d'audio gardées avant la détection +contiennent la fin du mot : une commande d'au moins 2 s garde l'identification +nette (« Ouvre la porte du salon, s'il te plaît. »). Le fichier est vérifié par +le vrai détecteur de mot de réveil. + +0,5 s de silence avant et après : le temps d'appuyer sur le bouton, ou de laisser +le mot de réveil être reconnu. Sortie en 16 kHz, comme le micro. + +Exemples : + python3 tools/say.py "Oui, je confirme." --voice fr_FR-upmc-medium --speaker 1 + python3 tools/say.py "Ouvre la porte du salon, s'il te plaît." --wake "Hey Mycroft" +""" + +from __future__ import annotations + +import argparse +import logging +import shutil +import subprocess +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 AudioResampler # noqa: E402 +from voice_server.settings import Settings, load_settings # noqa: E402 + +logger = logging.getLogger("say") + +PAUSE_AFTER_WAKE_S = 0.6 # pause naturelle entre « Hey Mycroft » et la commande +EDGE_SILENCE_S = 0.5 + + +def synthesize(voices_dir: Path, name: str, text: str, speaker: int | None, resampler: AudioResampler) -> bytes: + """Texte → PCM 16 kHz, crête -3 dBFS ; télécharge la voix si besoin.""" + from piper import PiperVoice, SynthesisConfig + from piper.download_voices import download_voice + + model = voices_dir / f"{name}.onnx" + if not model.exists(): + logger.info("telechargement de la voix %s (une fois, ~60 Mo)…", name) + voices_dir.mkdir(parents=True, exist_ok=True) + download_voice(name, voices_dir) + voice = PiperVoice.load(model) + import numpy as np + + chunks = list(voice.synthesize(text, syn_config=SynthesisConfig(speaker_id=speaker))) + audio = np.concatenate([chunk.audio_float_array for chunk in chunks]) + return resampler.convert(audio, chunks[0].sample_rate) # normalisé : les deux voix au même niveau + + +def check_wake(settings: Settings, pcm: bytes) -> bool: + """Passe le fichier dans le vrai détecteur du serveur ; False : le mot ne sera pas reconnu.""" + try: + from voice_server.wake_word import WakeWordDetector + + detector = WakeWordDetector(settings.wake) + except (ImportError, FileNotFoundError) as error: + logger.warning("verification du mot de reveil impossible : %s", error) + return True + chunk = settings.audio.bytes_per_second * settings.audio.chunk_ms // 1000 + for start in range(0, len(pcm) - chunk + 1, chunk): + detection = detector.process(pcm[start:start + chunk]) + if detection is not None: + logger.info("mot de reveil reconnu : %s (%.2f)", detection.word, detection.score) + return True + logger.error("mot de reveil NON reconnu par le detecteur : essayez une autre --wake-voice") + return False + + +def main(argv: list[str]) -> int: + """Synthétise la phrase et écrit WAV (+ MP3 si ffmpeg est présent).""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("text", help="phrase a dire") + parser.add_argument("--voice", default="fr_FR-tom-medium", help="voix Piper") + parser.add_argument("--speaker", type=int, help="locuteur, pour les voix a plusieurs locuteurs") + parser.add_argument("--wake", help="mot de reveil dit avant la phrase, par --wake-voice (ex. 'Hey Mycroft')") + parser.add_argument("--wake-voice", default="en_GB-alan-medium", help="voix anglaise du mot de reveil") + parser.add_argument("--out", type=Path, default=Path("recordings/voix-test/phrase.wav")) + 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) + voices_dir = settings.models_dir / "piper" + resampler = AudioResampler(settings.audio) + rate = settings.audio.sample_rate + try: + pieces = [] + if args.wake: + pieces.append(synthesize(voices_dir, args.wake_voice, args.wake, None, resampler)) + pieces.append(bytes(2 * int(PAUSE_AFTER_WAKE_S * rate))) + pieces.append(synthesize(voices_dir, args.voice, args.text, args.speaker, resampler)) + except ImportError as error: + logger.error("%s : lancez 'make install-speech'", error) + return 1 + edge = bytes(2 * int(EDGE_SILENCE_S * rate)) + pcm = edge + b"".join(pieces) + edge + + args.out.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(args.out), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(rate) + wav.writeframes(pcm) + said = f"{args.wake} ({args.wake_voice}), {args.text} ({args.voice})" if args.wake else f"{args.text} ({args.voice})" + logger.info("« %s », %.1f s -> %s", said, len(pcm) / 2 / rate, args.out) + + if args.wake and not check_wake(settings, pcm): + return 1 + if shutil.which("ffmpeg"): + mp3 = args.out.with_suffix(".mp3") + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(args.out), "-codec:a", "libmp3lame", + "-q:a", "2", str(mp3)], check=True) + logger.info("pour le telephone : %s", mp3) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/serial_protocol.py b/tools/serial_protocol.py new file mode 100644 index 0000000..7d85d6a --- /dev/null +++ b/tools/serial_protocol.py @@ -0,0 +1,165 @@ +"""Protocole série partagé entre le PC et l'ESP32 (étapes 1 et 2). + +Même format dans les deux sens, et identique à firmware/src/SerialProtocol.h : + + "VSA1" | type (1 octet) | length (uint16 LE) | payload | checksum (XOR, 1 octet) + +Les lignes de texte de l'ESP32 (commençant par « # ») peuvent s'intercaler entre +les trames : le décodeur se resynchronise sur le mot magique. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import IntEnum +from typing import Iterator, Protocol + +logger = logging.getLogger("serial_protocol") + +MAGIC = b"VSA1" +HEADER_SIZE = 7 # magic (4) + type (1) + length (2) +MAX_PAYLOAD = 2048 # garde-fou, identique au firmware : un chunk légitime fait 1600 octets + + +class FrameType(IntEnum): + """Types de trames.""" + + START = 1 + AUDIO = 2 + END = 3 + + +@dataclass(frozen=True) +class Frame: + """Trame décodée.""" + + type: FrameType + payload: bytes + + +@dataclass(frozen=True) +class AudioFormat: + """Format audio annoncé par la trame START.""" + + sample_rate: int + bits: int + channels: int + + @classmethod + def from_payload(cls, payload: bytes) -> "AudioFormat": + """Décode le descripteur de 8 octets de la trame START.""" + if len(payload) != 8: + raise ValueError(f"descripteur START invalide ({len(payload)} octets)") + sample_rate = int.from_bytes(payload[0:4], "little") + bits = int.from_bytes(payload[4:6], "little") + channels = int.from_bytes(payload[6:8], "little") + return cls(sample_rate=sample_rate, bits=bits, channels=channels) + + def to_payload(self) -> bytes: + """Encode le descripteur de 8 octets de la trame START.""" + return ( + self.sample_rate.to_bytes(4, "little") + + self.bits.to_bytes(2, "little") + + self.channels.to_bytes(2, "little") + ) + + +def encode_frame(frame_type: FrameType, payload: bytes = b"") -> bytes: + """Construit une trame complète, prête à écrire sur le port série.""" + if len(payload) > MAX_PAYLOAD: + raise ValueError(f"payload de {len(payload)} octets, maximum {MAX_PAYLOAD}") + checksum = 0 + for byte in payload: + checksum ^= byte + header = MAGIC + bytes([int(frame_type)]) + len(payload).to_bytes(2, "little") + return header + payload + bytes([checksum]) + + +class ByteSource(Protocol): + """Ce dont le décodeur a besoin d'un port série : lire des octets.""" + + def read(self, size: int = 1) -> bytes: ... + + +class TextEcho: + """Affiche ligne par ligne le texte émis par l'ESP32.""" + + def __init__(self) -> None: + self._pending = bytearray() + + def feed(self, data: bytes) -> None: + """Ajoute des octets de texte ; journalise chaque ligne complète.""" + self._pending.extend(data) + while True: + end = self._pending.find(b"\n") + if end < 0: + break + line = bytes(self._pending[:end]).decode("utf-8", errors="replace").strip() + del self._pending[: end + 1] + if line: + logger.info("esp32 | %s", line) + + +class FrameReader: + """Décode le flux série en trames, en tolérant du texte intercalé.""" + + def __init__(self, port: ByteSource) -> None: + self._port = port + self._buffer = bytearray() + self._echo = TextEcho() + + def frames(self) -> Iterator[Frame]: + """Produit les trames au fil de leur arrivée (générateur infini).""" + while True: + chunk = self._port.read(1024) + if chunk: + self._buffer.extend(chunk) + yield from self._drain() + + def _drain(self) -> Iterator[Frame]: + """Extrait toutes les trames complètes présentes dans le tampon.""" + while True: + index = self._buffer.find(MAGIC) + + if index < 0: + # Pas de mot magique : tout est du texte, sauf les derniers octets + # qui pourraient être un mot magique coupé en deux lectures. + keep = len(MAGIC) - 1 + if len(self._buffer) > keep: + self._echo.feed(bytes(self._buffer[:-keep])) + del self._buffer[:-keep] + return + + if index > 0: + self._echo.feed(bytes(self._buffer[:index])) + del self._buffer[:index] + + if len(self._buffer) < HEADER_SIZE: + return # en-tête incomplet : on attend la suite + + raw_type = self._buffer[4] + length = int.from_bytes(self._buffer[5:7], "little") + + if raw_type not in (int(t) for t in FrameType) or length > MAX_PAYLOAD: + # Fausse détection : on avance d'un octet et on cherche à nouveau. + del self._buffer[:1] + continue + + total = HEADER_SIZE + length + 1 # + checksum + if len(self._buffer) < total: + return # trame incomplète : on attend la suite + + payload = bytes(self._buffer[HEADER_SIZE : HEADER_SIZE + length]) + checksum = self._buffer[total - 1] + computed = 0 + for byte in payload: + computed ^= byte + + if checksum != computed: + logger.warning("checksum invalide, trame ignoree") + del self._buffer[:1] + continue + + del self._buffer[:total] + yield Frame(type=FrameType(raw_type), payload=payload) diff --git a/tools/serial_to_wav.py b/tools/serial_to_wav.py new file mode 100644 index 0000000..c65bc71 --- /dev/null +++ b/tools/serial_to_wav.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Reçoit une session audio de l'ESP32 par le port série et écrit un fichier WAV. + +Outil de validation de l'étape 1 : il ne dépend ni de MQTT ni du serveur vocal. + +Format des trames : voir tools/serial_protocol.py (identique au firmware). + +Les lignes de texte émises par l'ESP32 (messages commençant par « # ») sont +affichées telles quelles : le lecteur se resynchronise sur le mot magique. + +Exemple : + python3 tools/serial_to_wav.py --port /dev/ttyUSB0 --baud 921600 \ + --output recordings/test.wav +""" + +from __future__ import annotations + +import argparse +import logging +import math +import sys +import wave +from array import array +from dataclasses import dataclass +from pathlib import Path + +import serial + +from serial_protocol import AudioFormat, FrameReader, FrameType + +logger = logging.getLogger("serial_to_wav") + +class WavSessionRecorder: + """Assemble les chunks PCM d'une session et les écrit dans un fichier WAV.""" + + def __init__(self, output: Path) -> None: + self._output = output + self._chunks: list[bytes] = [] + self._format: AudioFormat | None = None + + def begin(self, audio_format: AudioFormat) -> None: + """Démarre une session avec le format annoncé.""" + self._format = audio_format + self._chunks = [] + logger.info( + "session ouverte : %d Hz, %d bits, %d canal/canaux", + audio_format.sample_rate, + audio_format.bits, + audio_format.channels, + ) + + def add(self, pcm: bytes) -> None: + """Ajoute un chunk PCM à la session en cours.""" + self._chunks.append(pcm) + + @property + def active(self) -> bool: + """true si une session est ouverte.""" + return self._format is not None + + @property + def byte_count(self) -> int: + """Nombre d'octets PCM accumulés.""" + return sum(len(chunk) for chunk in self._chunks) + + def finish(self) -> Path: + """Écrit le fichier WAV et retourne son chemin.""" + if self._format is None: + raise RuntimeError("aucune session ouverte") + + pcm = b"".join(self._chunks) + duration = self.byte_count / ( + self._format.sample_rate * self._format.channels * self._format.bits / 8 + ) + + self._output.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(self._output), "wb") as wav: + wav.setnchannels(self._format.channels) + wav.setsampwidth(self._format.bits // 8) + wav.setframerate(self._format.sample_rate) + wav.writeframes(pcm) + + logger.info("ecrit %s : %d octets, %.2f s", self._output, len(pcm), duration) + diagnose(measure_levels(pcm)) + + self._format = None + return self._output + + +@dataclass(frozen=True) +class LevelReport: + """Mesures de niveau d'un flux PCM, normalisées dans [0, 1].""" + + peak: float # crête absolue + rms: float # valeur efficace (perception du volume) + dc: float # composante continue résiduelle + + +def measure_levels(pcm: bytes) -> LevelReport: + """Mesure crête, valeur efficace et offset continu d'un flux PCM s16le.""" + samples = array("h") + samples.frombytes(pcm[: len(pcm) - (len(pcm) % 2)]) + if sys.byteorder == "big": + samples.byteswap() # le flux est little-endian par convention + + if len(samples) == 0: + return LevelReport(peak=0.0, rms=0.0, dc=0.0) + + peak = max(max(samples), -min(samples)) + total = 0 + squares = 0 + for sample in samples: + total += sample + squares += sample * sample + + count = len(samples) + return LevelReport( + peak=peak / 32768.0, + rms=math.sqrt(squares / count) / 32768.0, + dc=(total / count) / 32768.0, + ) + + +def _dbfs(value: float) -> str: + """Convertit un niveau normalisé en décibels pleine échelle.""" + if value <= 0.0: + return "-inf dBFS" + return f"{20.0 * math.log10(value):+.1f} dBFS" + + +def suggested_gain_factor(peak: float, target: float = 0.25) -> int: + """Facteur (puissance de 2) à appliquer a MIC_GAIN pour viser `target` de crête.""" + if peak <= 0.0: + return 0 + exponent = round(math.log2(target / peak)) + return int(min(64, max(2, 2**exponent))) + + +def diagnose(level: LevelReport) -> None: + """Journalise le niveau mesuré et le correctif à appliquer.""" + logger.info( + "niveau : crete %.2f %% (%s) | RMS %s | offset continu %+.3f %%", + level.peak * 100.0, + _dbfs(level.peak), + _dbfs(level.rms), + level.dc * 100.0, + ) + + if level.peak <= 0.0: + logger.error("AUCUN signal : tous les echantillons sont nuls.") + logger.error(" -> le micro ne transmet rien. Verifiez dans cet ordre :") + logger.error(" 1. L/R de l'INMP441 relie a GND (sinon silence total)") + logger.error(" 2. SD du micro sur GPIO 33, SCK sur 25, WS sur 26") + logger.error(" 3. VDD du micro sur 3V3 (jamais 5 V) et GND commun") + return + + if level.peak < 0.02: + factor = suggested_gain_factor(level.peak) + logger.warning( + "niveau tres faible : multipliez MIC_GAIN par %d dans firmware/include/config.h,", + factor, + ) + logger.warning("puis 'make fw-upload'. Parlez a 20-30 cm du micro.") + return + + if level.peak > 0.99: + logger.warning("SATURATION : divisez MIC_GAIN par 2 dans firmware/include/config.h.") + return + + if abs(level.dc) > 0.02: + logger.warning( + "offset continu residuel eleve (%+.2f %%) : le micro vient peut-etre de demarrer.", + level.dc * 100.0, + ) + + logger.info("niveau correct (cible : crete entre 10 % et 90 %).") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Analyse la ligne de commande.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", default="/dev/ttyUSB0", help="port serie de l'ESP32") + parser.add_argument("--baud", type=int, default=921600, help="debit serie") + parser.add_argument( + "--output", type=Path, default=Path("recordings/test.wav"), help="fichier WAV de sortie" + ) + parser.add_argument( + "--sessions", type=int, default=1, help="nombre de sessions a enregistrer avant de quitter" + ) + parser.add_argument("--verbose", action="store_true", help="journalisation detaillee") + return parser.parse_args(argv) + + +def output_path(base: Path, index: int, total: int) -> Path: + """Numérote le fichier de sortie si plusieurs sessions sont demandées.""" + if total <= 1: + return base + return base.with_name(f"{base.stem}-{index:02d}{base.suffix}") + + +def main(argv: list[str]) -> int: + """Point d'entrée : lit le port série jusqu'à la fin des sessions demandées.""" + args = parse_args(argv) + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + datefmt="%H:%M:%S", + ) + + logger.info("ouverture de %s a %d baud", args.port, args.baud) + try: + port = serial.Serial(args.port, args.baud, timeout=0.2) + except serial.SerialException as error: + logger.error("port serie inaccessible : %s", error) + return 1 + + recorder: WavSessionRecorder | None = None + completed = 0 + + logger.info("en attente d'une session : appuyez sur le bouton PTT et parlez.") + try: + with port: + for frame in FrameReader(port).frames(): + if frame.type is FrameType.START: + recorder = WavSessionRecorder( + output_path(args.output, completed + 1, args.sessions) + ) + recorder.begin(AudioFormat.from_payload(frame.payload)) + + elif frame.type is FrameType.AUDIO: + if recorder is None or not recorder.active: + logger.debug("chunk recu hors session, ignore") + continue + recorder.add(frame.payload) + + elif frame.type is FrameType.END: + if recorder is None or not recorder.active: + continue + recorder.finish() + recorder = None + completed += 1 + if completed >= args.sessions: + return 0 + logger.info("en attente de la session suivante.") + except KeyboardInterrupt: + logger.info("interruption clavier") + if recorder is not None and recorder.active and recorder.byte_count > 0: + recorder.finish() + return 0 + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/speech_check.py b/tools/speech_check.py new file mode 100644 index 0000000..c863e02 --- /dev/null +++ b/tools/speech_check.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Vérifie VAD, transcription, réponse et synthèse sans ESP32 ni broker (étapes 6-7). + +Deux usages : + texte → Piper → Whisper → Assistant → Piper (la chaîne se relit elle-même) + WAV → VAD → Whisper → Assistant → Piper (un enregistrement de l'ESP32, comme le serveur) + +La réponse synthétisée est écrite dans --out, à écouter avec « make play ». + +Exemples : + python3 tools/speech_check.py "Quelle heure est-il ?" + python3 tools/speech_check.py --wav server/recordings/esp32-01_xxx.wav +""" + +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.assistant import Assistant # noqa: E402 +from voice_server.audio_resampler import AudioFormatError, AudioResampler # noqa: E402 +from voice_server.settings import load_settings # noqa: E402 +from voice_server.speech_to_text import SpeechToText # noqa: E402 +from voice_server.text_to_speech import TextToSpeech # noqa: E402 +from voice_server.voice_activity import VoiceActivityDetector # noqa: E402 + +logger = logging.getLogger("speech_check") + + +def main(argv: list[str]) -> int: + """Déroule la chaîne et affiche le temps de chaque maillon.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("text", nargs="?", help="phrase a synthetiser puis transcrire") + parser.add_argument("--wav", type=Path, help="enregistrement a transcrire") + parser.add_argument("--out", type=Path, default=Path("recordings/reponse.wav"), + help="WAV de la reponse synthetisee") + parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml") + args = parser.parse_args(argv) + if (args.text is None) == (args.wav is None): + parser.error("donnez soit une phrase, soit --wav") + + logging.basicConfig(level=logging.INFO, format="%(message)s") + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("faster_whisper").setLevel(logging.WARNING) + settings = load_settings(args.config) + audio = settings.audio + try: + tts = TextToSpeech(settings.tts, AudioResampler(audio), audio.sample_rate) + stt = SpeechToText(settings.stt, audio.sample_rate) + except FileNotFoundError as error: + logger.error("%s", error) + return 1 + + if args.wav is not None: + try: + # Sans normalisation : Whisper doit entendre le niveau réel du micro. + pcm = AudioResampler(audio, None).from_wav(args.wav) + except (AudioFormatError, FileNotFoundError) as error: + logger.error("%s", error) + return 1 + vad = VoiceActivityDetector(settings.vad, audio.sample_rate).detect(pcm) + logger.info("VAD : parole %.2f s sur %.2f s, segments %s", vad.speech_s, vad.total_s, + f"{vad.bounds()} s" if vad.has_speech else "aucun") + pcm = vad.speech_pcm # sans parole, Whisper reçoit un audio vide et ne rend rien + else: + spoken = tts.synthesize(args.text) + logger.info("synthese de la question : %.2f s de calcul pour %.2f s de parole", + spoken.elapsed_s, spoken.audio_s) + pcm = spoken.pcm + + transcript = stt.transcribe(pcm) + logger.info("transcription : %.2f s de calcul pour %.2f s d'audio", transcript.elapsed_s, transcript.audio_s) + if transcript.dropped: + logger.warning("hallucination ecartee : %s", " | ".join(transcript.dropped)) + answer = Assistant().reply(transcript.text) + speech = tts.synthesize(answer) + logger.info("synthese de la reponse : %.2f s de calcul pour %.2f s de parole", + speech.elapsed_s, speech.audio_s) + + 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(speech.pcm) + + print(f"\n entendu : « {transcript.text} »") + print(f" reponse : « {answer} »") + print(f" delai : {transcript.elapsed_s + speech.elapsed_s:.2f} s de calcul avant le premier son") + print(f" ecoute : make play OUT={args.out}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/vad_check.py b/tools/vad_check.py new file mode 100644 index 0000000..4a6a39f --- /dev/null +++ b/tools/vad_check.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Montre ce que la VAD (Silero) trouve dans des enregistrements (étape 7). + +Pour chaque WAV : une lettre par fenêtre de 32 ms (« # » parole, « . » hésitation, +« _ » silence), les segments gardés et la part de silence retirée. +Avec un seul fichier, la parole seule est écrite dans --out, à écouter avec « make play ». + +Exemples : + python3 tools/vad_check.py server/recordings/esp32-01_xxx.wav + python3 tools/vad_check.py server/recordings/*.wav +""" + +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.settings import load_settings # noqa: E402 +from voice_server.voice_activity import VoiceActivityDetector, envelope # noqa: E402 + +logger = logging.getLogger("vad_check") + + +def main(argv: list[str]) -> int: + """Analyse chaque fichier et affiche un bilan.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("wavs", type=Path, nargs="+", help="enregistrements a analyser") + parser.add_argument("--out", type=Path, default=Path("recordings/parole.wav"), + help="parole seule (un seul fichier analyse)") + 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) + audio = settings.audio + detector = VoiceActivityDetector(settings.vad, audio.sample_rate) + reader = AudioResampler(audio, None) # niveau d'origine : la VAD voit ce que voit le serveur + + without_speech = 0 + last = None + for path in args.wavs: + try: + result = detector.detect(reader.from_wav(path)) + except (AudioFormatError, FileNotFoundError) as error: + logger.error("%s", error) + continue + last = result + print(f"\n{path.name}") + print(f" {envelope(result.probabilities, settings.vad.threshold)}") + if result.has_speech: + removed = 100.0 * (1.0 - result.speech_s / result.total_s) if result.total_s else 0.0 + print(f" parole {result.speech_s:.2f} s sur {result.total_s:.2f} s " + f"({removed:.0f} % retire), segments {result.bounds()} s, " + f"calcul {result.elapsed_s * 1000:.0f} ms") + else: + without_speech += 1 + print(f" AUCUNE PAROLE ({result.total_s:.2f} s) : la session ne sera pas transcrite") + + print(f"\n{len(args.wavs)} fichier(s), {without_speech} sans parole") + if len(args.wavs) == 1 and last is not None and last.has_speech: + 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(last.speech_pcm) + print(f"parole seule : make play OUT={args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/wav_inspect.py b/tools/wav_inspect.py new file mode 100644 index 0000000..b41ef9f --- /dev/null +++ b/tools/wav_inspect.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Analyse un fichier WAV et affiche son enveloppe en ASCII. + +Permet de valider un enregistrement **sans écouter** : indispensable sur une +machine sans sortie audio, et plus tard sur un Raspberry Pi sans écran. + +Exemple : + python3 tools/wav_inspect.py recordings/essai.wav +""" + +from __future__ import annotations + +import argparse +import logging +import math +import sys +import wave +from array import array +from pathlib import Path + +from serial_to_wav import LevelReport, measure_levels + +logger = logging.getLogger("wav_inspect") + +# Du plus faible au plus fort : huit niveaux de remplissage. +BLOCKS = " ▁▂▃▄▅▆▇█" + +# Plage affichée par l'enveloppe, en décibels pleine échelle. +FLOOR_DBFS = -60.0 + +# Un segment dépassant le plancher de bruit de cette marge est considéré parlé. +SPEECH_MARGIN_DB = 12.0 + + +def read_pcm(path: Path) -> tuple[bytes, int]: + """Lit un WAV PCM 16 bits mono et retourne (octets, fréquence).""" + with wave.open(str(path), "rb") as wav: + if wav.getsampwidth() != 2: + raise ValueError(f"{path} : {wav.getsampwidth() * 8} bits, 16 attendus") + if wav.getnchannels() != 1: + raise ValueError(f"{path} : {wav.getnchannels()} canaux, 1 attendu") + return wav.readframes(wav.getnframes()), wav.getframerate() + + +def column_peaks(pcm: bytes, columns: int) -> list[float]: + """Découpe le flux en `columns` tranches et retourne la crête de chacune.""" + samples = array("h") + samples.frombytes(pcm[: len(pcm) - (len(pcm) % 2)]) + if sys.byteorder == "big": + samples.byteswap() + + total = len(samples) + if total == 0 or columns <= 0: + return [] + + peaks: list[float] = [] + for index in range(columns): + start = (index * total) // columns + stop = max(start + 1, ((index + 1) * total) // columns) + window = samples[start:stop] + peak = max(max(window), -min(window)) + peaks.append(peak / 32768.0) + return peaks + + +def to_dbfs(value: float) -> float: + """Convertit un niveau normalisé en dBFS, borné au plancher d'affichage.""" + if value <= 0.0: + return FLOOR_DBFS + return max(FLOOR_DBFS, 20.0 * math.log10(value)) + + +def render(peaks: list[float]) -> str: + """Dessine l'enveloppe : un caractère par tranche, échelle logarithmique.""" + glyphs: list[str] = [] + for peak in peaks: + ratio = (to_dbfs(peak) - FLOOR_DBFS) / (0.0 - FLOOR_DBFS) + index = int(round(ratio * (len(BLOCKS) - 1))) + glyphs.append(BLOCKS[min(len(BLOCKS) - 1, max(0, index))]) + return "".join(glyphs) + + +def speech_ratio(peaks: list[float]) -> float: + """Proportion de tranches nettement au-dessus du plancher de bruit.""" + if not peaks: + return 0.0 + quiet = min(to_dbfs(peak) for peak in peaks) + loud = [peak for peak in peaks if to_dbfs(peak) > quiet + SPEECH_MARGIN_DB] + return len(loud) / len(peaks) + + +def describe(level: LevelReport, peaks: list[float], duration: float) -> None: + """Journalise le verdict sur l'enregistrement.""" + ratio = speech_ratio(peaks) + logger.info("duree %.2f s | crete %.1f %% | RMS %.1f %% | offset %+.3f %%", + duration, level.peak * 100.0, level.rms * 100.0, level.dc * 100.0) + logger.info("tranches actives : %.0f %% de la duree", ratio * 100.0) + + if level.peak <= 0.0: + logger.error("fichier totalement silencieux.") + elif level.peak > 0.99: + logger.warning("saturation : diminuez MIC_GAIN.") + elif level.peak < 0.02: + logger.warning("niveau tres faible : augmentez MIC_GAIN.") + elif ratio < 0.05: + logger.warning("aucune variation nette : bruit de fond plutot que parole ?") + elif ratio > 0.95: + logger.warning("signal actif partout : bruit continu plutot que parole ?") + else: + logger.info("alternance silence / parole coherente avec une phrase.") + + +def main(argv: list[str]) -> int: + """Point d'entrée : analyse le fichier passé en argument.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wav", type=Path, help="fichier WAV a analyser") + parser.add_argument("--columns", type=int, default=64, help="largeur de l'enveloppe") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s") + + try: + pcm, rate = read_pcm(args.wav) + except (OSError, wave.Error, ValueError) as error: + logger.error("lecture impossible : %s", error) + return 1 + + duration = len(pcm) / (2.0 * rate) if rate else 0.0 + peaks = column_peaks(pcm, args.columns) + + print(f"\n{args.wav} ({rate} Hz, 16 bits, mono)") + print(f" 0 dBFS |{'-' * len(peaks)}|") + print(f" |{render(peaks)}|") + print(f"{FLOOR_DBFS:.0f} dBFS |{'-' * len(peaks)}|") + print(f" 0 s{' ' * max(0, len(peaks) - 8)}{duration:.2f} s\n") + + sys.stdout.flush() # le dessin doit sortir avant les lignes de journalisation + describe(measure_levels(pcm), peaks, duration) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/wav_to_mqtt.py b/tools/wav_to_mqtt.py new file mode 100644 index 0000000..370e00a --- /dev/null +++ b/tools/wav_to_mqtt.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Envoie un WAV (ou un son pur) à un ESP32 par MQTT, et affiche le bilan de lecture. + +Outil de validation de l'étape 5. Il réutilise les classes du serveur : +AudioResampler (mise au format, crête à -3 dBFS) et AudioSender (rythme réel). +N'importe quel WAV PCM convient : il est converti en 16 kHz mono. + +Étape 12 : --jitter MS simule un réseau irrégulier. Un chunk sur dix (toutes les +500 ms) est retenu MS millisecondes, comme lors d'un blocage Wi-Fi, puis l'envoi +rattrape son retard en rafale. Le bilan de la carte donne alors la marge minimale +(≈ 300 - MS ms) et le nombre de réamorçages (0 tant que MS < 300 ms). + +Exemples : + python3 tools/wav_to_mqtt.py server/recordings/esp32-01_xxx.wav + python3 tools/wav_to_mqtt.py --tone 440 --seconds 3 + python3 tools/wav_to_mqtt.py --tone 440 --seconds 5 --jitter 200 +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import logging +import sys +import threading +import time +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, peak_dbfs # noqa: E402 +from voice_server.audio_sender import AudioSender, Publisher # noqa: E402 +from voice_server.mqtt_link import MqttLink # noqa: E402 +from voice_server.settings import load_settings # noqa: E402 + +logger = logging.getLogger("wav_to_mqtt") + +STALL_EVERY = 10 # un chunk sur dix : un blocage toutes les 500 ms + + +class JitteryLink: + """Lien MQTT qui retient certains chunks audio : banc d'essai du tampon anti-gigue.""" + + def __init__(self, link: Publisher, stall_ms: float) -> None: + self._link = link + self._stall_s = stall_ms / 1000.0 + self._chunks = 0 + self.stalls = 0 + + def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: + if suffix == "audio/out": + self._chunks += 1 + if self._stall_s > 0 and self._chunks % STALL_EVERY == 0: + time.sleep(self._stall_s) # blocage : les chunks suivants partiront en rafale + self.stalls += 1 + self._link.publish(device, suffix, payload, qos) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Analyse la ligne de commande.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wav", type=Path, nargs="?", help="fichier WAV a jouer") + parser.add_argument("--device", default="esp32-01", help="carte destinataire") + parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml") + parser.add_argument("--tone", type=float, help="son pur de cette frequence (Hz)") + parser.add_argument("--seconds", type=float, default=3.0, help="duree du son pur") + parser.add_argument("--no-normalize", action="store_true", help="garder le niveau d'origine") + parser.add_argument("--jitter", type=float, default=0.0, + help="retient un chunk sur dix pendant ce nombre de ms (etape 12)") + args = parser.parse_args(argv) + if (args.wav is None) == (args.tone is None): + parser.error("donnez soit un fichier WAV, soit --tone") + return args + + +def main(argv: list[str]) -> int: + """Envoie l'audio, puis attend le bilan publié par la carte.""" + args = parse_args(argv) + settings = load_settings(args.config) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", + datefmt="%H:%M:%S") + + resampler = AudioResampler(settings.audio, None if args.no_normalize else -3.0) + try: + if args.tone is not None: + t = np.arange(int(settings.audio.sample_rate * args.seconds)) / settings.audio.sample_rate + fade = np.minimum(1.0, np.minimum(t, t[-1] - t) / 0.01) # fondus de 10 ms + pcm = resampler.convert(np.sin(2 * np.pi * args.tone * t) * fade, settings.audio.sample_rate) + else: + pcm = resampler.from_wav(args.wav) + except (OSError, AudioFormatError) as error: + logger.error("%s", error) + return 1 + logger.info("audio pret : %.2f s, crete %.1f dBFS", len(pcm) / settings.audio.bytes_per_second, + peak_dbfs(pcm)) + + # Client distinct du serveur : deux clients ne doivent jamais partager un identifiant. + link = MqttLink(dataclasses.replace(settings.mqtt, client_id="voice-wav-to-mqtt")) + report = threading.Event() + + def on_event(device: str, payload: bytes) -> None: + if device != args.device: + return + event = json.loads(payload) + if event.get("event") in ("played", "rejected"): + logger.info("bilan de %s : %s", device, event) + report.set() + + link.on("event", on_event) + link.start() + if not link.wait_connected(5.0): + logger.error("broker %s:%d injoignable", settings.mqtt.host, settings.mqtt.port) + link.stop() + return 1 + + sender_link = JitteryLink(link, args.jitter) + AudioSender(sender_link, settings.audio).send(args.device, pcm) + if args.jitter > 0: + logger.info("gigue simulee : %d blocages de %.0f ms ; marge attendue ~%.0f ms, reamorcage si <= 0", + sender_link.stalls, args.jitter, 300 - args.jitter) + if not report.wait(5.0): + logger.warning("aucun bilan de %s : carte hors ligne, ou firmware anterieur a l'etape 5", + args.device) + link.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/wav_to_serial.py b/tools/wav_to_serial.py new file mode 100644 index 0000000..c69155b --- /dev/null +++ b/tools/wav_to_serial.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Envoie de l'audio à l'ESP32 par le port série, pour lecture sur le haut-parleur. + +Outil de validation de l'étape 2. Deux sources possibles : + + - un fichier WAV 16 kHz, 16 bits, mono (par exemple un enregistrement de l'étape 1) ; + - un son pur généré ici (--tone) : la moindre coupure s'y entend comme un clic. + +L'envoi suit le **rythme réel** : l'ESP32 ne peut pas jouer plus vite que 16 000 +échantillons par seconde, et sa FIFO déborderait si on lui envoyait tout d'un coup. +On envoie d'abord une avance de quelques chunks (le « préremplissage »), puis un +chunk toutes les 50 ms. + +Exemples : + python3 tools/wav_to_serial.py --port /dev/ttyUSB0 recordings/essai.wav + python3 tools/wav_to_serial.py --port /dev/ttyUSB0 --tone 440 --seconds 3 +""" + +from __future__ import annotations + +import argparse +import logging +import math +import sys +import time +import wave +from pathlib import Path + +import serial + +from serial_protocol import AudioFormat, FrameType, TextEcho, encode_frame + +logger = logging.getLogger("wav_to_serial") + +EXPECTED = AudioFormat(sample_rate=16000, bits=16, channels=1) +CHUNK_MS = 50 # identique au firmware : 800 échantillons = 1600 octets +PREFILL_CHUNKS = 4 # 200 ms d'avance pour absorber les irrégularités d'envoi + + +class FormatError(ValueError): + """Le fichier n'est pas dans le format unique du système.""" + + +def read_wav(path: Path) -> bytes: + """Lit un WAV et vérifie qu'il est en 16 kHz, 16 bits, mono.""" + with wave.open(str(path), "rb") as wav: + found = AudioFormat(wav.getframerate(), wav.getsampwidth() * 8, wav.getnchannels()) + if found != EXPECTED: + raise FormatError( + f"{path} : {found.sample_rate} Hz, {found.bits} bits, {found.channels} canal(aux) ; " + "attendu 16000 Hz, 16 bits, mono. Conversion : " + f"ffmpeg -i {path} -ar 16000 -ac 1 -sample_fmt s16 converti.wav" + ) + return wav.readframes(wav.getnframes()) + + +def tone_pcm(frequency: float, seconds: float, amplitude: float = 0.25, + rate: int = EXPECTED.sample_rate) -> bytes: + """Génère un son pur en PCM s16le, avec fondus de 10 ms pour éviter les clics.""" + count = int(rate * seconds) + fade = max(1, rate // 100) + out = bytearray() + for n in range(count): + gain = min(1.0, n / fade, (count - 1 - n) / fade) + value = amplitude * gain * math.sin(2.0 * math.pi * frequency * n / rate) + out += int(round(value * 32767)).to_bytes(2, "little", signed=True) + return bytes(out) + + +def split_chunks(pcm: bytes, chunk_bytes: int) -> list[bytes]: + """Découpe le PCM en chunks ; le dernier peut être plus court, jamais impair.""" + even = pcm[: len(pcm) - (len(pcm) % 2)] # un octet orphelin serait un demi-échantillon + return [even[i : i + chunk_bytes] for i in range(0, len(even), chunk_bytes)] + + +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 immédiatement ; 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)] + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Analyse la ligne de commande.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wav", type=Path, nargs="?", help="fichier WAV 16 kHz mono a jouer") + parser.add_argument("--port", default="/dev/ttyUSB0", help="port serie de l'ESP32") + parser.add_argument("--baud", type=int, default=921600, help="debit serie") + parser.add_argument("--tone", type=float, help="joue un son pur de cette frequence (Hz)") + parser.add_argument("--seconds", type=float, default=3.0, help="duree du son pur") + parser.add_argument("--amplitude", type=float, default=0.25, + help="amplitude du son pur, 0..1 (0.25 = -12 dBFS)") + args = parser.parse_args(argv) + if (args.wav is None) == (args.tone is None): + parser.error("donnez soit un fichier WAV, soit --tone") + return args + + +def main(argv: list[str]) -> int: + """Point d'entrée : envoie l'audio au rythme réel, puis affiche le bilan de l'ESP32.""" + args = parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", + datefmt="%H:%M:%S") + + try: + if args.tone is not None: + pcm = tone_pcm(args.tone, args.seconds, args.amplitude) + logger.info("son pur %.0f Hz, %.1f s, amplitude %.0f %%", args.tone, args.seconds, + args.amplitude * 100) + else: + pcm = read_wav(args.wav) + logger.info("fichier %s : %.2f s", args.wav, len(pcm) / 32000) + except (OSError, wave.Error, FormatError) as error: + logger.error("%s", error) + return 1 + + chunk_bytes = EXPECTED.sample_rate * CHUNK_MS // 1000 * 2 + chunks = split_chunks(pcm, chunk_bytes) + schedule = send_times(len(chunks), CHUNK_MS / 1000.0, PREFILL_CHUNKS) + + try: + port = serial.Serial(args.port, args.baud, timeout=0, write_timeout=1) + except serial.SerialException as error: + logger.error("port serie inaccessible : %s", error) + return 1 + + echo = TextEcho() + with port: + port.write(encode_frame(FrameType.START, EXPECTED.to_payload())) + start = time.monotonic() + late = 0.0 + + for chunk, due in zip(chunks, schedule): + wait = start + due - time.monotonic() + if wait > 0: + time.sleep(wait) + else: + late = max(late, -wait) + port.write(encode_frame(FrameType.AUDIO, chunk)) + echo.feed(port.read(port.in_waiting or 0)) + + port.write(encode_frame(FrameType.END)) + logger.info("envoi termine : %d chunks, pire retard d'envoi %.0f ms", len(chunks), late * 1000) + + # L'ESP32 joue encore son avance, puis affiche son bilan : on l'attend. + deadline = time.monotonic() + PREFILL_CHUNKS * CHUNK_MS / 1000.0 + 1.0 + while time.monotonic() < deadline: + echo.feed(port.read(port.in_waiting or 0)) + time.sleep(0.05) + echo.feed(b"\n") + + if late * 1000 > PREFILL_CHUNKS * CHUNK_MS: + logger.warning("le PC a pris plus de retard que l'avance de l'ESP32 : coupure probable") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))