From d20976fea26aa48ce40093812af3d3ac082d5e70 Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Wed, 30 Sep 2026 17:40:55 +0200 Subject: [PATCH] =?UTF-8?q?Assistant=20vocal=20local=20ESP32=20:=20=C3=A9t?= =?UTF-8?q?apes=201=20=C3=A0=2015,=20et=2015b=20=C3=A9crite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- .gitattributes | 30 + .gitignore | 69 ++ .vscode/extensions.json | 8 + .vscode/settings.json.example | 13 + CONTEXT.md | 768 +++++++++++++++++++++ LICENSE | 21 + Makefile | 273 ++++++++ README-EN.md | 461 +++++++++++++ README.md | 461 +++++++++++++ agent/Makefile | 43 ++ agent/agent.yaml.example | 39 ++ agent/voice-agent.service | 14 + agent/voice_agent.py | 279 ++++++++ docs/BROCHAGE.md | 385 +++++++++++ docs/MOSQUITTO.md | 158 +++++ docs/wiring.png | Bin 0 -> 338868 bytes docs/wiring.svg | 283 ++++++++ firmware/include/config.h | 243 +++++++ firmware/include/secrets.h.example | 33 + firmware/platformio.ini | 37 + firmware/src/AudioFifo.cpp | 37 + firmware/src/AudioFifo.h | 62 ++ firmware/src/AudioOutput.cpp | 123 ++++ firmware/src/AudioOutput.h | 70 ++ firmware/src/CommandHandler.cpp | 77 +++ firmware/src/CommandHandler.h | 48 ++ firmware/src/DemoDevices.cpp | 45 ++ firmware/src/DemoDevices.h | 36 + firmware/src/MicCapture.cpp | 361 ++++++++++ firmware/src/MicCapture.h | 169 +++++ firmware/src/MicSelfTest.cpp | 295 ++++++++ firmware/src/MicSelfTest.h | 78 +++ firmware/src/MqttLink.cpp | 153 ++++ firmware/src/MqttLink.h | 109 +++ firmware/src/PlaybackStream.cpp | 185 +++++ firmware/src/PlaybackStream.h | 117 ++++ firmware/src/PushButton.cpp | 54 ++ firmware/src/PushButton.h | 46 ++ firmware/src/SerialFrameReader.cpp | 85 +++ firmware/src/SerialFrameReader.h | 58 ++ firmware/src/SerialFramer.cpp | 46 ++ firmware/src/SerialFramer.h | 41 ++ firmware/src/SerialProtocol.h | 42 ++ firmware/src/StatusLed.cpp | 47 ++ firmware/src/StatusLed.h | 44 ++ firmware/src/VoiceSession.cpp | 102 +++ firmware/src/VoiceSession.h | 72 ++ firmware/src/WifiLink.cpp | 56 ++ firmware/src/WifiLink.h | 44 ++ firmware/src/main.cpp | 579 ++++++++++++++++ mosquitto/acl | 37 + mosquitto/voice.conf | 48 ++ prompt-assistant-vocal-esp32.md | 354 ++++++++++ server/.env.example | 12 + server/config.yaml | 141 ++++ server/pyproject.toml | 62 ++ server/pyrightconfig.json | 23 + server/tests/test_access_control.py | 160 +++++ server/tests/test_assistant.py | 83 +++ server/tests/test_audio_output_path.py | 157 +++++ server/tests/test_diarization.py | 75 ++ server/tests/test_hands_free.py | 203 ++++++ server/tests/test_home_control.py | 108 +++ server/tests/test_machine_control.py | 186 +++++ server/tests/test_mqtt_link.py | 69 ++ server/tests/test_serial_frames.py | 178 +++++ server/tests/test_session_manager.py | 190 +++++ server/tests/test_speaker_attribution.py | 65 ++ server/tests/test_speaker_identifier.py | 124 ++++ server/tests/test_speech_models.py | 131 ++++ server/tests/test_voice_activity.py | 111 +++ server/tests/test_voice_agent.py | 82 +++ server/tests/test_voice_pipeline.py | 326 +++++++++ server/tests/test_wav_to_serial.py | 66 ++ server/voice_server/__init__.py | 14 + server/voice_server/__main__.py | 261 +++++++ server/voice_server/access_control.py | 179 +++++ server/voice_server/assistant.py | 129 ++++ server/voice_server/audio_buffer.py | 76 ++ server/voice_server/audio_resampler.py | 91 +++ server/voice_server/audio_sender.py | 99 +++ server/voice_server/command_router.py | 33 + server/voice_server/diarization.py | 178 +++++ server/voice_server/hands_free.py | 285 ++++++++ server/voice_server/home_control.py | 161 +++++ server/voice_server/machine_control.py | 240 +++++++ server/voice_server/mqtt_link.py | 191 +++++ server/voice_server/session_manager.py | 253 +++++++ server/voice_server/settings.py | 343 +++++++++ server/voice_server/speaker_attribution.py | 100 +++ server/voice_server/speaker_identifier.py | 164 +++++ server/voice_server/speech_to_text.py | 117 ++++ server/voice_server/text_normalize.py | 13 + server/voice_server/text_to_speech.py | 73 ++ server/voice_server/voice_activity.py | 157 +++++ server/voice_server/voice_pipeline.py | 255 +++++++ server/voice_server/wake_word.py | 104 +++ tools/diarize_check.py | 134 ++++ tools/download_models.py | 139 ++++ tools/enroll.py | 168 +++++ tools/say.py | 136 ++++ tools/serial_protocol.py | 165 +++++ tools/serial_to_wav.py | 255 +++++++ tools/speech_check.py | 102 +++ tools/vad_check.py | 80 +++ tools/wav_inspect.py | 143 ++++ tools/wav_to_mqtt.py | 134 ++++ tools/wav_to_serial.py | 160 +++++ 108 files changed, 15022 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json.example create mode 100644 CONTEXT.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README-EN.md create mode 100644 README.md create mode 100644 agent/Makefile create mode 100644 agent/agent.yaml.example create mode 100644 agent/voice-agent.service create mode 100644 agent/voice_agent.py create mode 100644 docs/BROCHAGE.md create mode 100644 docs/MOSQUITTO.md create mode 100644 docs/wiring.png create mode 100644 docs/wiring.svg create mode 100644 firmware/include/config.h create mode 100644 firmware/include/secrets.h.example create mode 100644 firmware/platformio.ini create mode 100644 firmware/src/AudioFifo.cpp create mode 100644 firmware/src/AudioFifo.h create mode 100644 firmware/src/AudioOutput.cpp create mode 100644 firmware/src/AudioOutput.h create mode 100644 firmware/src/CommandHandler.cpp create mode 100644 firmware/src/CommandHandler.h create mode 100644 firmware/src/DemoDevices.cpp create mode 100644 firmware/src/DemoDevices.h create mode 100644 firmware/src/MicCapture.cpp create mode 100644 firmware/src/MicCapture.h create mode 100644 firmware/src/MicSelfTest.cpp create mode 100644 firmware/src/MicSelfTest.h create mode 100644 firmware/src/MqttLink.cpp create mode 100644 firmware/src/MqttLink.h create mode 100644 firmware/src/PlaybackStream.cpp create mode 100644 firmware/src/PlaybackStream.h create mode 100644 firmware/src/PushButton.cpp create mode 100644 firmware/src/PushButton.h create mode 100644 firmware/src/SerialFrameReader.cpp create mode 100644 firmware/src/SerialFrameReader.h create mode 100644 firmware/src/SerialFramer.cpp create mode 100644 firmware/src/SerialFramer.h create mode 100644 firmware/src/SerialProtocol.h create mode 100644 firmware/src/StatusLed.cpp create mode 100644 firmware/src/StatusLed.h create mode 100644 firmware/src/VoiceSession.cpp create mode 100644 firmware/src/VoiceSession.h create mode 100644 firmware/src/WifiLink.cpp create mode 100644 firmware/src/WifiLink.h create mode 100644 firmware/src/main.cpp create mode 100644 mosquitto/acl create mode 100644 mosquitto/voice.conf create mode 100644 prompt-assistant-vocal-esp32.md create mode 100644 server/.env.example create mode 100644 server/config.yaml create mode 100644 server/pyproject.toml create mode 100644 server/pyrightconfig.json create mode 100644 server/tests/test_access_control.py create mode 100644 server/tests/test_assistant.py create mode 100644 server/tests/test_audio_output_path.py create mode 100644 server/tests/test_diarization.py create mode 100644 server/tests/test_hands_free.py create mode 100644 server/tests/test_home_control.py create mode 100644 server/tests/test_machine_control.py create mode 100644 server/tests/test_mqtt_link.py create mode 100644 server/tests/test_serial_frames.py create mode 100644 server/tests/test_session_manager.py create mode 100644 server/tests/test_speaker_attribution.py create mode 100644 server/tests/test_speaker_identifier.py create mode 100644 server/tests/test_speech_models.py create mode 100644 server/tests/test_voice_activity.py create mode 100644 server/tests/test_voice_agent.py create mode 100644 server/tests/test_voice_pipeline.py create mode 100644 server/tests/test_wav_to_serial.py create mode 100644 server/voice_server/__init__.py create mode 100644 server/voice_server/__main__.py create mode 100644 server/voice_server/access_control.py create mode 100644 server/voice_server/assistant.py create mode 100644 server/voice_server/audio_buffer.py create mode 100644 server/voice_server/audio_resampler.py create mode 100644 server/voice_server/audio_sender.py create mode 100644 server/voice_server/command_router.py create mode 100644 server/voice_server/diarization.py create mode 100644 server/voice_server/hands_free.py create mode 100644 server/voice_server/home_control.py create mode 100644 server/voice_server/machine_control.py create mode 100644 server/voice_server/mqtt_link.py create mode 100644 server/voice_server/session_manager.py create mode 100644 server/voice_server/settings.py create mode 100644 server/voice_server/speaker_attribution.py create mode 100644 server/voice_server/speaker_identifier.py create mode 100644 server/voice_server/speech_to_text.py create mode 100644 server/voice_server/text_normalize.py create mode 100644 server/voice_server/text_to_speech.py create mode 100644 server/voice_server/voice_activity.py create mode 100644 server/voice_server/voice_pipeline.py create mode 100644 server/voice_server/wake_word.py create mode 100644 tools/diarize_check.py create mode 100644 tools/download_models.py create mode 100644 tools/enroll.py create mode 100644 tools/say.py create mode 100644 tools/serial_protocol.py create mode 100644 tools/serial_to_wav.py create mode 100644 tools/speech_check.py create mode 100644 tools/vad_check.py create mode 100644 tools/wav_inspect.py create mode 100644 tools/wav_to_mqtt.py create mode 100644 tools/wav_to_serial.py 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 0000000000000000000000000000000000000000..702f811d089ddb15a268f71a05551646e6938eba GIT binary patch literal 338868 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 !@J5~?zEMoM zyRcq=5{PQ&_j)SSKW+#}7z|o~YpWrS)^m8!cIg{Da1VL%Bmx7t`mE*48M<7DYz!`6 z_5hKD7@@iRd(qK5Pt2 z0a~hQ;L}pE!$vbG&%8*5fqYhHq@;#}AwG>RIXugWo*Dcx-K$r z)(9MJ`{>kET1UtE{tf?Q!;UjW!I?S-Y7jq~Y8pf%1_p*Rp+ajyLqkD?6{YAxw;XI3 zWnpQV|N5r*&%h-P0fek2km4uLBNw0odXHR}D9D3`?7v=iI;sgrDgX5gw3`oO< zRQMKg9`Kq+YazBNQCMWUNB!?V8#%S~qtF;qVS(wc1RM9w*TKPCaHJoikKyt5{ChU> zfNcWdyQvySs-9^LU7)&=(TiU{vxB)Y9A2tU<4P+(eJVvss{|Ko`dPTEO0xTb0hUIy zi&(XjS7dW3%F@XL)tq$oe4st1hkvZ9N)V*SZzb@YF@DaR8pk6EW2%Ra=p88(`iIf7 z`sDACS7bBPK{Y0=jC;2OR8}_-l8R1362Eu*;Ju<;>H@KaPv*Wwu*5m

    #MuP|HX_n0s;HCzxuZ}p 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%0At;ZPk_v_n57Tdf%JoDs7k9bjn#@YPw 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%0D(s_fod3=bpecvetDZ(sZ~6}P(#5&bDy zPR#YbUl*gz`Vr>lL&)S|z)D*mJf-ZnlXUNdZ>n8Zr|$*lw-^*ywgC#Jj0>yhOYO;n zt9@U%17Tcig^<#v#~^SnzLDOfnPNKxjz>w_7f|t8ud-MC<^3EgGE%7)<4O1heH1M_ zD(5o)g!b79y5o`(BkMw=HG(FOFfB1ilQ{+gD^rqd5LxwWw(+-pz%`cRgdm+)c-(y_ zZ~ut}p<=QpswzDUzH}{g1O`=_LJ252e|M6YB7p!@ksmH^{M3MR(Y8I$bY-*hImxt% zF{LquYazs1DR7b+KTY^-Dh!j0o>HNEb$bpwZ#u9-1!lyL1*9P;xjy)|?s4_WIkT{% zD4OWQ&x>1Q%hL%1R)1!fCOmaAST8_r3`g6t_Ifh}2i|MuukoXO3E>J$f?iD-=7i37ICf@ z2RE2O=*?8*M#yPs>16+x!7|tvNZiG~Pedw^9-53uK9^amoMo_1B(sL4JH&qW1gbt)S znRKO5{|!7pNDULkzerXg@<623H}R7bEZ_u37ue3GJsz7Ffh&D`Szx3|@?7X5ainny zNRKg~b_`NQRq@~BH^W$q9FmyF-h?!l($G->h_Z_ud)86IWLOz+vCGGz$5J4*5WV95S|c7cr+-F z$rj>p_>RX}dM~mgcgO)N3Z%CcxgAFyi++@DeIJ;(BQ_(FeITF^$r+{j(r-7P9DcR7 zIV_VjNcOLpydajcXpeTom5}}tkBRbm`Q%2#bkZOr7!M)Jv?nnWMIHniL@u32b*Jv` zAXPIctlT0Gb0CT*66R?zyS9T&Z&(#3)u!SJufqcRF?~8O0Hec~`j9uuBm*`_7^j`M zsxDg=a&&bIT~$NptnfC%YTRA9~SiH1SSEOwa%L}A=9eDD^PNq(~LfU<8H-6h65Idhcf-+<}P(@EvdDKXlM zNu)wJ7c62k!X4IkBoM___T#iZ(3ll-4!F$vKV&hDqUk4ei;9ZoTjW50V4(cs@#XG3 zSqtKeLSzW$Y@AHm!i}>GRjghQt^!*VYhsv6`&`|KPOID9Nre0&u8Kkf;dpa!cFhQB zw*6z(G$^KJ{%#sHxDLFF>Kk0pZdv7i-v7(eAH5wcr1TUZNb3}(& zk@oNx=`f|yqD~R!yFjqBu6I;#XF|YRAxep6Ejew?If_$v%>C7<4R9J*x-K;<=aj=s zPhVbZe=RKqEdvb|GtNX`ngdP|8Ld1y+r^Tm-GC9aCTZKrnmzkM1^u#27s74I8wupz zO5v?6O`3F?oc`RTBt763apJ@ZB&C6&LsFiT1f9+as^snOVr4@o7d2wcV@?isVV6;h z6fK{*@`j}IxCG-+6VtL^_++t{E#8RzLv2}Pu$1<+&i7VgY$?H!L)A-;LZ*N!>&l;D zr?CqI750FASR#u9Y3IQOTLVL%ZWu2%qukL7syl_+orlsz-M@c-&RaQ9g=xxW7PSiN zQVwe&&(h5cbm~d2^UruXOXou~BT65hk4$qrYkQ_!j&kXK_zL3DtXci9SBdZ6>U|^5 zmpyv+8#ofnftGc_aOjv9bS3L)F5AZ-kV2c-X> z4?ecLTtqn+iJOQdyr2&w6rEnSR8h{ zuyAQRZy_Y%62F_xxF=!(LKh+7ap+gryPei0!f}P)p%T~Z*^Q)I6dHm&jK*xXALvwX ztbEA&9}#r>zyQfOIw#Qu@#v7>yMiWyX4KwFFSSuR^by8B=u!;-To^AzzvA_^v9LHr z*djKNkO<_2OG$e8@Zp}^n&P8DK?A9?sAKOvXarjmd*9urE&ox;Y_BWR;V$H8;PuSr zYIj5(Y)N&ROy#(q$0n^*&~PB0p`$~je=FvQ@~`;zX1e3naxIbTT{Vz_%9ez^U}$Ip zOwLxGD;>Vy=6LiyK6%I0v`d7bY4CQ4Qg$y_GB`wpYi!jR&H@M`rXPa{2e4w&C(fm# z&C3ui8tv=Aw1kHoJX~MbMtfeX&;vwem5<|EM)cI9?G%bOwlk)f+HlC`ZdeL5{m+l< z?^AT=t}nbeh>kgEcWVV@&&(Bu0S;naCqxCqXC*U(jBYMs2+1c;n+Si} z)K1Xo%6}UJ{3A@pCYFBlRjZyajO&pq5;-0*?Vx5dYzjUn={KpA4~?L-3tSIMXTWFi zL`g{48dbd?DmIxIqLU)5$Sf_>bK`VZ4pwcBA&fWV8{xpX4Z90If^}?jQe?ipTm3Qq zL_MeFE9cDcB6!-kO%fRGytpbq0Fpsapx-+LT*`qf*fN7)qf zfXErRiQ)G~kB0Uj(Y0x!h2_MPve^MM*cP7^4*6Hr!0Cl@PFBF%1=?&kaA4)pJ8;Lj z3H)EUjPBchAF5Bt=UvcbZxTn8#U+0FSUpz)9>lVL0E}M=*T*|xJk@@ihkmH&m zSSMd3VteY@o-R#H0{ASUorjiPW-6nguy!_F1_gl>yB!!s)x5-y_3hipCt4W=ym8Q|k1FgzPM}w=xRZ z`Key5wUO0_*YBHu`Ma4r(knd9{`L=8^yNRNdS9p>*0pouN_mqx(LFh73N|k+?elk8 z8APG|7az9|-5_E>>c(ZOR=tnEZ00(hC{?(o%-~Cy9uT_MS6k=v2bNCWnbiHuNALc$ zc+m#0um9Kd3%%a0jJ{re)1}Sxx#8LB>xVV{;`BPXvH$$h=%AwcKR)eL@^>aGUvBW9 z|Jsg2F8ZGz7pmsR)A`RInT+Rs0r%kQ0)Ld$MB@V|<87 zc|y|yam}Xl@EY4c1@tZbaO<+gFaN#564m%V^34NT6z__4%;Lb9Ch%F)>i=|;3oZh( zmg2veLex6z*KbWv{JI!J7fv0Z>h(H)!o0pbV3~aZXrPek+XR*=WHsNRv@tioaq*G) z=YQjt&1nEd%!is+&I^d+Xz|Udd_GL@{Bkp;lb;@M@2F6ic9M}uXrF#)TK;wYa1(Dq zD-2>f4eKlaJQlf^O#QcWS{t20OrnJc5wl(3{vjrD%eVjhwV}Bw?pK&_68P z9Qft?c9)K>o!i1l{YX$y`cF-}s?ikbp~$83AAj9>p<|y+?)iW1;a6jJU3}c^>jP=} qMQG^%ZuYNFx9J!E@0~=|J9ZeEoywS!JVe3IjHz>`oHJYI^?v|Wnq>9> literal 0 HcmV?d00001 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:]))