mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite
État du projet au premier commit : - firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament), mains libres, domotique de démonstration, chien de garde, présence du serveur ; - serveur Python : sessions, VAD, faster-whisper, pyannote, identification, droits par profil, domotique, mot de réveil, Piper ; - broker Mosquitto : configuration, comptes et ACL (non activés en développement) ; - étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux. Étapes 1 à 15 validées. 203 tests, pyright sans erreur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Normalisation des fins de ligne : LF dans le dépôt
|
||||
* text=auto eol=lf
|
||||
|
||||
# Fichiers texte explicites
|
||||
*.c text
|
||||
*.h text
|
||||
*.cpp text
|
||||
*.hpp text
|
||||
*.py text
|
||||
*.md text
|
||||
*.yaml text
|
||||
*.yml text
|
||||
*.json text
|
||||
*.ini text
|
||||
*.cfg text
|
||||
*.toml text
|
||||
*.conf text
|
||||
Makefile text
|
||||
|
||||
# Fichiers binaires : pas de conversion, pas de diff textuel
|
||||
*.wav binary
|
||||
*.flac binary
|
||||
*.mp3 binary
|
||||
*.ogg binary
|
||||
*.onnx binary
|
||||
*.bin binary
|
||||
*.pt binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.pdf binary
|
||||
+69
@@ -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/
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide",
|
||||
"ms-python.python",
|
||||
"ms-pyright.pyright",
|
||||
"redhat.vscode-yaml"
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// Copier ce fichier en .vscode/settings.json (ignoré par git).
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/server/.venv/bin/python",
|
||||
"python.analysis.typeCheckingMode": "standard",
|
||||
"pyright.configFile": "${workspaceFolder}/server/pyrightconfig.json",
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 4,
|
||||
"files.eol": "\n",
|
||||
"[makefile]": {
|
||||
"editor.insertSpaces": false
|
||||
},
|
||||
"platformio-ide.useBuiltinPIOCore": true
|
||||
}
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
# CONTEXT.md — état d'avancement du projet
|
||||
|
||||
> Fichier de reprise. À relire **en premier** en cas d'interruption, et à mettre à jour
|
||||
> **à la fin de chaque étape**. Il décrit ce qui est fait, ce qui est en cours, et les
|
||||
> décisions techniques déjà prises (pour ne pas les rediscuter).
|
||||
|
||||
---
|
||||
|
||||
## 1. Projet
|
||||
|
||||
Assistant vocal **100 % local** : ESP32 (micro INMP441) → MQTT → serveur Python
|
||||
(VAD, faster-whisper, diarisation, Piper) → MQTT → ESP32 (ampli MAX98357A).
|
||||
|
||||
Prompt de référence : `prompt-assistant-vocal-esp32.md` (rôle, style, feuille de route, conventions).
|
||||
|
||||
---
|
||||
|
||||
## 2. Feuille de route et avancement
|
||||
|
||||
| # | Étape | État | Validé par l'utilisateur |
|
||||
|---|---|---|---|
|
||||
| 0 | Ossature du dépôt (arborescence, Makefile, git, README) | **fait** | en attente |
|
||||
| 1 | Entrée audio ESP32 (I2S RX) + `tools/serial_to_wav.py` | **fait** | **oui (2026-09-29)** |
|
||||
| 2 | Sortie audio ESP32 (I2S TX) + `tools/wav_to_serial.py` | **fait** | **oui (2026-09-29)** |
|
||||
| 3 | Mosquitto + topics (Wi-Fi, MQTT, état retenu, testament) | **fait** | **oui (2026-09-29)** |
|
||||
| 4 | ESP32 → MQTT → serveur (sessions reconstruites en WAV) | **fait** | **oui (2026-09-29)** |
|
||||
| 5 | Serveur → MQTT → ESP32 (WAV serveur joué par l'ESP32, mode écho) | **fait** | **oui (2026-09-29)** |
|
||||
| 6 | MVP : PTT → faster-whisper → réponse → Piper → ESP32 | **fait** (serveur seul, firmware inchangé) | **oui (2026-09-29)** |
|
||||
| 7 | VAD (Silero) | **fait** (serveur seul) | **oui (2026-09-29)** |
|
||||
| 8 | Diarisation (pyannote) | **fait**, test réel à deux voix réussi | **oui (2026-09-29)** |
|
||||
| 9 | Identification (embeddings + cosinus + seuil) | **fait** (serveur seul) | **oui (2026-09-30)** |
|
||||
| 10 | Association locuteur + texte | **fait** (serveur seul) | **oui (2026-09-30)** |
|
||||
| 11 | Assistant / domotique | **fait** (serveur + firmware) | **oui (2026-09-30)** |
|
||||
| 12 | Buffer de sortie anti-jitter | **fait** (firmware + banc de gigue) | **oui (2026-09-30)** |
|
||||
| 13 | Wake word (openWakeWord) | **fait** (serveur + firmware) | **oui (2026-09-30)** |
|
||||
| 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** |
|
||||
| 15 | Fiabilisation | **fait** (broker + serveur + firmware) | **oui (2026-09-30)** |
|
||||
| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | en attente |
|
||||
| 16 | Optimisation | à faire | — |
|
||||
|
||||
**Règle absolue** : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N.
|
||||
|
||||
**Exigence ajoutée par l'utilisateur (2026-09-30)** : l'assistant doit aussi pouvoir **commander des
|
||||
machines Linux du réseau**. Absente de la feuille de route du prompt. Proposition en discussion :
|
||||
un agent MQTT par machine (liste blanche d'actions définie sur la machine), Wake-on-LAN par le
|
||||
serveur, état retenu + testament comme l'ESP32 ; actions sensibles seulement après les droits par
|
||||
profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé : étape 15b** (après droits par profil et authentification MQTT). Liste d'actions et machines à préciser.
|
||||
|
||||
---
|
||||
|
||||
## 3. Décisions techniques figées
|
||||
|
||||
| Sujet | Décision | Raison |
|
||||
|---|---|---|
|
||||
| Format audio unique | PCM `s16le`, 16 kHz, mono | compatible Whisper/Silero, simple pour l'ESP32 |
|
||||
| Taille de chunk | 50 ms = 800 échantillons = **1600 octets** | compromis latence / surcoût protocole |
|
||||
| Encodage MQTT | bytes bruts, **jamais de Base64** | −33 % de débit, pas de CPU perdu |
|
||||
| Core Arduino-ESP32 | **3.x** (ESP-IDF 5.x), driver `driver/i2s_std.h` | ancien `driver/i2s.h` déprécié ; plateforme `pioarduino` |
|
||||
| Plateforme PlatformIO | `pioarduino` 54.03.21 (fork maintenu pour core 3.x) | le paquet officiel `espressif32` est resté en core 2.0.x |
|
||||
| PlatformIO isolé | venv dédié `.pio-venv`, avec `click==8.1.8` | click ≥ 8.3 casse l'esptool 5.0 du core 3.x (erreur `get_metavar() missing ctx`) ; évite aussi tout conflit avec les dépendances du serveur |
|
||||
| Périphériques I2S | I2S0 = micro (RX), I2S1 = ampli (TX) | duplex séparé, pas de conflit d'horloges |
|
||||
| Broches | voir `firmware/include/config.h` | aucune broche de strapping (0, 2, 5, 12, 15) sur l'audio |
|
||||
| Broker de dev : conteneur `mosquitto_services-mosquitto-1`, compose `~/srv/docker-compose.mosquitto.yml`, config montée `~/srv/mosquitto/config/mosquitto.conf` ; **IP du PC = 192.168.1.104** (et non .0.104) | 2026-09-29 | constaté sur la machine |
|
||||
| MQTT : état JSON **retenu** + testament `{"status":"offline"}` retenu QoS 1, keepalive 10 s | 2026-09-29 | `offline` en 15 s max après coupure ; serveur informé même s'il démarre après l'ESP32 |
|
||||
| Wi-Fi : `setSleep(false)` | 2026-09-29 | le modem endormi ajoute 100 ms+ de latence : incompatible avec le flux audio |
|
||||
| Partitions `huge_app.csv` (3 Mo d'application, pas d'OTA) | 2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut |
|
||||
| Sortie par MQTT : START/END sur `control` (QoS 1), PCM sur `audio/out` (QoS 0), **300 ms d'avance** puis 1 chunk / 50 ms | 2026-09-29 | FIFO ESP32 de 512 ms ; la carte publie `played` (reçus/annoncés, sous-alimentations) |
|
||||
| Tout audio envoyé à l'ESP32 : **16 kHz mono, crête à −3 dBFS** (`AudioResampler`, soxr) | 2026-09-29 | ESP32 simple ; règle le volume faible noté à l'étape 2 ; Piper (22,05 kHz) passera par là |
|
||||
| Firmware : lecture regroupée dans `PlaybackStream` (série ou MQTT), commandes dans `CommandHandler` (ArduinoJson 7.2.1) | 2026-09-29 | un seul chemin de lecture ; les commandes s'enrichiront à l'étape 11 |
|
||||
| Capture : micro → **tâche de capture** → FIFO 1 s → `loop()` → MQTT | 2026-09-29 | `publish()` peut bloquer sur un Wi-Fi faible ; le DMA du micro n'a que 75 ms de réserve |
|
||||
| Perte détectée par le **nombre de chunks du END** (pas de numéro de séquence avant l'étape 16) | 2026-09-29 | audio en QoS 0 ; le serveur compare reçus / annoncés après 0,5 s de grâce |
|
||||
| **Limite connue** : PubSubClient ne publie qu'en **QoS 0**, `event` compris (le prompt demande QoS 1) | 2026-09-29 | serveur tolérant (session sans END close après 5 s d'inactivité). **Revu à l'étape 15 : conservé** (aucune perte mesurée sur le réseau local ; esp-mqtt = réécriture de `MqttLink` et de ses rappels, sans gain observable) |
|
||||
| Session sans broker → liaison série, format de l'étape 1 | 2026-09-29 | `make record` reste un outil de dépannage |
|
||||
| Sortie : **stéréo, même échantillon sur les deux voies** | 2026-09-29 | le MAX98357A joue (G+D)/2 : niveau exact, inversion G/D sans effet ; on ne mise plus sur un mode mono implicite |
|
||||
| Sortie : canal I2S1 **toujours actif**, `auto_clear` → silence quand rien à jouer | 2026-09-29 | broches de l'ampli jamais flottantes (cause du souffle initial) |
|
||||
| Lecture : réception → **FIFO** (StreamBuffer statique 512 ms) → **tâche audio** → I2S | 2026-09-29 | architecture exigée pour MQTT (callback court) ; la tâche est rythmée par le DMA |
|
||||
| FIFO : écriture **tout ou rien** par chunk | 2026-09-29 | un chunk coupé = octet impair = tout le reste désaligné |
|
||||
| Protocole série **partagé** : `SerialProtocol.h` ↔ `tools/serial_protocol.py` | 2026-09-29 | une seule définition par langage ; test croisé encodeur Python → décodeur C++ |
|
||||
| Passe-haut micro **80 Hz**, 1er ordre, calculé en **24 bits** (`MIC_HPF_SHIFT = 5`) | 2026-09-29 | 87 % de l'énergie d'un enregistrement était infrasonore (plaque qui fléchit sous le doigt, souffle) ; 0,6 % après filtre, voix 99 % |
|
||||
| Cadrage micro : **MSB** (24 bits utiles), mesuré, pas supposé | 2026-09-29 | en Philips, le bit de signe déborde dans le mot précédent (23 bits + `00000001`) ; l'auto-test bascule seul si un autre module est Philips |
|
||||
| Les horloges du micro ne s'arrêtent **jamais** après le démarrage | 2026-09-29 | l'INMP441 s'endort sans horloge ; chaque réveil = dérive lente de la composante continue (+23 % FS encore à 600 ms) |
|
||||
| Lecture I2S stéréo : la **phase de trame** est suivie entre les appels | corrigé le 2026-09-29 | `i2s_channel_read()` peut rendre un nombre **impair** de mots ; sans suivi, les deux moitiés s'échangent en cours de route et on enregistre la moitié muette |
|
||||
| Micro : SCK → **GPIO 25**, WS → **GPIO 26** | corrigé le 2026-09-29 | le schéma initial les avait inversés ; aucune raison technique ne départage 25 et 26, la doc a été alignée sur le banc réel |
|
||||
| Liaison série étape 1 | **921 600 baud**, trames `VSA1` + longueur | 32 kB/s d'audio impossible à 115 200 baud |
|
||||
| Conversion micro | mot I2S 32 bits → `>> 16`, gain ×4, offset DC glissant | l'INMP441 place 24 bits utiles en tête de mot |
|
||||
| Licence | MIT | choix par défaut du prompt |
|
||||
| Extra `speech` : **faster-whisper 1.2.1**, **piper-tts 1.8.0** (au lieu de 1.0.3 / 1.2.0) | 2026-09-29 | Python 3.13 : faster-whisper 1.0 exige `av<13` (compilation depuis les sources, échoue sans les en-têtes ffmpeg) ; piper-tts 1.2 dépend de `piper-phonemize` sans roue 3.13. piper-tts ≥ 1.3 embarque espeak-ng. API Piper : `PiperVoice.load()`, `synthesize()` → `AudioChunk.audio_float_array` |
|
||||
| Modèles **locaux** dans `server/models/` : `whisper/<model>/`, `piper/<voice>.onnx(.json)`, via `make models` (`tools/download_models.py`) | 2026-09-29 | aucun accès réseau au démarrage du serveur ; ~550 Mo ; ignorés par git |
|
||||
| STT : `small`, `int8`, `beam_size 5`, `condition_on_previous_text=False`, `vad_filter=False` (VAD = étape 7) | 2026-09-29 | mesuré sur ce PC (20 cœurs) : **≈ 1,0 s quelle que soit la phrase (1 à 3 s)**, le nombre de fils (0/8/16) ou le beam (1/5) : Whisper encode toujours une fenêtre de 30 s |
|
||||
| Session de crête < **−45 dBFS** (`stt.min_peak_dbfs`) : Whisper n'est pas appelé, la carte dit « Je n'ai rien entendu. » | 2026-09-29 | mesuré : 2 s de silence numérique = **4,8 s** de calcul (repli sur les températures) |
|
||||
| Hallucinations connues écartées (`KNOWN_HALLUCINATIONS` : Amara.org, sous-titres…) | 2026-09-29 | mesuré : 1 s de bruit gaussien → « Sous-titres réalisés par la communauté d'Amara.org » |
|
||||
| Imports faster-whisper / Piper **différés** (dans `__init__`) | 2026-09-29 | `make run-echo` et les tests fonctionnent sans l'extra `speech` |
|
||||
| Assistant à règles (`assistant.py`) : heure, date, nom, bonjour, merci ; sinon « Vous avez dit : … » ; heure écrite « une heure », « midi », « 19 heures 5 » | 2026-09-29 | Piper lit « 1 heure » = « un heure » ; Whisper écrit parfois « Quel heure » (mots-clés tolérants) |
|
||||
| VAD : **Silero ONNX livré par faster-whisper** (`faster_whisper.vad.get_vad_model`), segmentation écrite dans `voice_activity.py` (hystérésis 0,5 / 0,35, pause 500 ms, parole ≥ 250 ms, marge 150 ms) | 2026-09-29 | pas de torch ; segmentation testable sans modèle ; 2 à 4 ms par session |
|
||||
| VAD **sur la session entière** (après le END), pas en flux | 2026-09-29 | suffisant en PTT ; la détection de fin de parole en flux servira au mode mains libres (étape 13) |
|
||||
| La VAD **remplace le seuil de crête** `stt.min_peak_dbfs` (supprimé) | 2026-09-29 | l'appui muet de l'étape 6 (crête −30,7 dBFS) passait le seuil ; la VAD le rejette |
|
||||
| Diarisation : **pyannote.audio 4.0.7**, torch **2.14.0+cpu** / torchaudio 2.11.0+cpu (index pytorch.org), modèle `pyannote/speaker-diarization-community-1` (33 Mo) | 2026-09-29 | Python 3.13 ; sans l'index CPU, pip tire torch CUDA (plusieurs Go de `nvidia-*`) ; pyannote 3.3.2 antérieur à ce torch |
|
||||
| **numpy 2.1.3 → 2.5.3** (dépendance de base) | 2026-09-29 | pyannote-metrics 4 exige numpy ≥ 2.2.2 ; 78 tests repassés avec 2.5.3 (soxr, onnxruntime, ctranslate2 OK) |
|
||||
| **Télémétrie pyannote coupée** (`PYANNOTE_METRICS_ENABLED=false`, forcé par `disable_telemetry()` avant l'import) | 2026-09-29 | activée par défaut en 4.x : envoie durée et nombre de locuteurs à `otel.pyannote.ai` ; variable relue à chaque appel |
|
||||
| Modèle pyannote **copié dans `models/pyannote/<nom>/`** par `make models` (`snapshot_download`, `HF_TOKEN` de `server/.env`), chargé depuis ce dossier | 2026-09-29 | les sous-modèles sont des `$model/…` relatifs : aucun appel au hub à l'exécution. `HF_HUB_OFFLINE` non utilisé : lu à l'import de huggingface_hub, que faster-whisper a déjà fait |
|
||||
| Diarisation **sur `vad.speech_pcm`**, le même audio que Whisper, **en séquence** avant Whisper | 2026-09-29 | instants communs pour l'association locuteur/texte (étape 10) ; parallélisation → étape 16 |
|
||||
| `diarization.enabled` dans config.yaml ; activée mais absente → le serveur refuse de démarrer avec la marche à suivre | 2026-09-29 | Raspberry Pi 4 : `false` ; pas de repli silencieux |
|
||||
| pyannote 4 rend **une empreinte vocale par locuteur** (`speaker_embeddings`, WeSpeaker ResNet34), conservée dans `Diarization.embeddings` | 2026-09-29 | candidate pour l'identification (étape 9), à comparer à SpeechBrain ECAPA prévu par le prompt ; `speechbrain` retiré de l'extra `diarization` |
|
||||
| Empreintes : **modèle `embedding/` du pipeline pyannote (WeSpeaker ResNet34, 256 dim.)** via `Inference(window="whole")` sur toute la parole de chaque locuteur (tours de `exclusive_speaker_diarization` mis bout à bout), 66 ms | 2026-09-29 | **les `speaker_embeddings` de pyannote sont NaN sous ~2 s de parole** : il ne garde que les fenêtres de 10 s où le locuteur parle seul ≥ 20 % (`filter_embeddings`, `min_active_ratio=0.2`) ; toutes les questions PTT étaient NaN |
|
||||
| Tours de parole : version **exclusive** (sans chevauchement) | 2026-09-29 | une voix par empreinte ; prête pour le découpage du texte (étape 10) |
|
||||
| Seuil cosinus **0,45** (remplace 0,75) | 2026-09-29 | mesuré : profil de 5 sessions contre 23 autres sessions de l'utilisateur **0,52–0,83** (0,52 = 0,9 s de parole) ; Piper direct ou par haut-parleur **−0,08 à 0,11**. À recalibrer avec de vraies autres personnes (étape 14) |
|
||||
| Profils : `server/profiles/<nom>.json` (nom, modèle, nb de sessions, date, empreinte normalisée), écriture atomique, **relus quand le dossier change** | 2026-09-29 | JSON lisible (on sait ce qu'on stocke) ; un profil d'un autre modèle est ignoré ; enrôlement pris en compte sans redémarrer |
|
||||
| Enrôlement (`make enroll NAME=`, `tools/enroll.py`) : ≥ 3 sessions de ≥ 1 s, une seule voix, **écart des sessions qui ne ressemblent pas aux autres** (cosinus avec la moyenne des autres < seuil) | 2026-09-29 | la session mélangée `093463` (voix + haut-parleur, vue comme 1 locuteur par pyannote) est écartée à 0,27–0,32 |
|
||||
| Identification **seulement avec la diarisation** (pas d'empreinte sinon) | 2026-09-29 | choix de l'utilisateur (empreintes pyannote plutôt que SpeechBrain) |
|
||||
| Qui a dit quoi : **`word_timestamps=True`** dans Whisper, chaque mot au tour de diarisation qui le recouvre le plus (sinon le plus proche), mots consécutifs d'un même locuteur regroupés | 2026-09-30 | mesuré : +0,07 à 0,14 s (5 à 10 %) ; les bornes des mots tombent sur celles des tours (même audio pour les deux) |
|
||||
| Topic **`voice/<carte>/transcript`** (serveur → abonnés, QoS 1, non retenu) : `{"session", "utterances": [{"speaker", "text", "start", "end", "score"}]}` | 2026-09-30 | format de la feuille de route + instants et score pour la domotique (étape 11) et les droits (étape 14) ; le serveur ne s'y abonne pas |
|
||||
| L'assistant reçoit le **nom de la dernière personne qui a parlé** (`reply(text, speaker)`) : « Bonjour Denis ! », « Avec plaisir, Denis. » | 2026-09-30 | c'est elle qui attend la réponse ; `None` si inconnue |
|
||||
| Domotique : commandes sur **`home/<pièce>/<appareil>/set`** (QoS 1, **non retenu**) **et**, si la pièce est celle de la carte, `voice/<carte>/control` `{"cmd":"device",...}` exécuté par l'ESP32 (choix de l'utilisateur : « les deux », en simulation) | 2026-09-30 | toute domotique peut s'abonner ; l'ESP32 « exécute la commande éventuelle » (prompt) ; une commande retenue serait rejouée à un abonné tardif |
|
||||
| Analyse par **mots-clés** (`home_control.py`) : action × appareil × pièce ; « allume/éteins » seuls = lumière ; sans pièce = pièce de la carte (`home.boards`) ; appareil/action incompatibles ou incomplets → question | 2026-09-30 | suffisant pour 3 appareils ; tolère « fermé » (Whisper) ; limites : 1 appareil et 1 pièce par phrase, faux positifs possibles (« je porte… ») |
|
||||
| Identifiants en anglais dans les topics et le JSON (`light`, `shutter`, `door`, `on/off/open/close`) ; pièces = noms de config.yaml, en slug (`salle-de-bain`) | 2026-09-30 | mêmes chaînes côté Python et firmware |
|
||||
| Firmware : **`DemoDevices`**, LED sur **GPIO 21** (330 Ω), volets et porte simulés ; confirmation `{"event":"device","ok":...,"state":...}` journalisée par le serveur | 2026-09-30 | GPIO 21 libre, hors strapping ; la boucle commande → exécution → confirmation est visible |
|
||||
| La commande part **avant** la réponse parlée | 2026-09-30 | la lumière s'allume pendant que la carte le dit |
|
||||
| `normalize` déplacé dans `text_normalize.py` | 2026-09-30 | commun à l'assistant et à la domotique, sans import circulaire |
|
||||
| Météo : réponse honnête « je fonctionne sans Internet » | 2026-09-30 | plutôt que répéter la question |
|
||||
| Anti-gigue : **amorçage à 300 ms** (`PLAYBACK_START_MS`) avant le premier échantillon, **réamorçage** après une famine (pause propre plutôt que bribes), son plus court que la réserve joué dès le END | 2026-09-30 | 300 ms = 6 chunks = avance envoyée d'emblée par le serveur : la lecture démarre dès qu'elle est arrivée ; valeur de l'exemple du prompt |
|
||||
| `underruns` du bilan = **réamorçages** (famines en plein flux) ; nouveaux champs `start_ms` (délai du premier son) et `min_margin_ms` (plus petite réserve mesurée) | 2026-09-30 | l'ancien compteur comptait chaque bloc incomplet, y compris au démarrage : 6 à 10 par lecture sans aucune perte |
|
||||
| Banc de gigue : `--jitter MS` / `make tone-mqtt JITTER=MS` retient **1 chunk sur 10** (toutes les 500 ms) pendant MS ms, puis rattrapage en rafale | 2026-09-30 | scénario déterministe : marge attendue ≈ 300 − MS ms, réamorçages seulement au-delà de 300 ms ; prouver plutôt qu'attendre un mauvais Wi-Fi |
|
||||
| Mot de réveil : **openWakeWord 0.6.0 en ONNX**, installé `--no-deps` (exige `tflite-runtime`, absent pour Python 3.13) ; modèles `alexa`, `hey_jarvis`, `hey_mycroft` v0.1 + melspectrogram/embedding, depuis les releases GitHub v0.5.1 | 2026-09-30 | mesuré : « Alexa » (Piper FR) 0,92–1,0 ; « Hé Maïcrofte » 1,0 ; « Hey Djâr-viss » 0,74 ; lus à la française Jarvis/Mycroft ≈ 0 ; **0 fausse alerte sur 90 enregistrements réels** (max 0,07) ; ~1 ms par chunk |
|
||||
| Flux continu **`voice/<carte>/audio/stream`** (QoS 0), activé par le serveur (`cmd listen`) à la connexion/redémarrage de la carte, coupé à l'arrêt du serveur ; **jamais pendant une réponse** (écho, Wi-Fi) ni pendant un appui (le bouton reprend la main) | 2026-09-30 | le serveur décide ; la carte reste simple |
|
||||
| `HandsFreeListener` : VEILLE (préroll 500 ms **en mémoire seulement**) → COMMANDE (fin = 800 ms de silence après de la parole qui dépasse le mot de 0,3 s ; abandon après 4 s sans commande ; max 10 s) → RÉPONSE (jusqu'au bilan `played`, garde 20 s) ; la commande devient une `SessionResult` ordinaire | 2026-09-30 | aucune autre partie de la chaîne ne change |
|
||||
| Délais d'une commande mesurés en **audio reçu**, pas à l'horloge | 2026-09-30 | trouvé en simulation : en rafale Wi-Fi, 2 s de son peuvent arriver en 10 ms ; l'horloge ne sert qu'aux gardes (réponse sans bilan, flux perdu 2 s) |
|
||||
| LED : éclair 100 ms / 2 s = écoute continue ; allumée = commande en cours (`cmd capture`) ; `StatusLed::blink(period, onMs)` | 2026-09-30 | l'utilisateur voit quand le micro part sur le réseau |
|
||||
| Droits : **complet > standard > limite** (`access_control.py`) ; inconnu = limite, enrôlé sans niveau = standard, `users: {Denis: complet}` | 2026-09-30 | niveaux du prompt ; conversation toujours permise |
|
||||
| Niveau complet seulement si **score ≥ 0,6** (`full_min_score`), sinon rétrogradé en standard | 2026-09-30 | la voix n'est pas une preuve : une identification de justesse ne doit pas tout ouvrir |
|
||||
| Règles par appareil/action ; **action absente = niveau complet** | 2026-09-30 | on ne permet jamais par oubli |
|
||||
| Actions sensibles (`confirm: [door.open]`) : question, puis « oui »/« non » **de la même personne**, sur la même carte, en 15 s ; toute autre phrase abandonne la demande | 2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) |
|
||||
| Droits appliqués au **dernier locuteur** de la session | 2026-09-30 | c'est lui qui attend la réponse (cohérent avec l'étape 10) |
|
||||
| Broker : `allow_anonymous false` + `password_file` + `acl_file` ; comptes `voice-server` (voice/# et home/# en lecture-écriture) et **un par carte, nom = DEVICE_ID** (`pattern ... voice/%u/...`) | 2026-09-30 | micro inaudible et commandes impossibles pour le reste du réseau ; une carte ne peut ni écouter ni usurper une autre (vérifié sur broker jetable) |
|
||||
| Outils `make mqtt-*` : identifiants du serveur relus dans `server/.env` (`-include`), recettes en `@` | 2026-09-30 | pas de compte supplémentaire ; mot de passe jamais affiché par make ; mot de passe hexadécimal (`#`, `$` cassent make) |
|
||||
| Présence serveur : `voice/server/status` retenu, `online` à chaque connexion, testament `offline`, `offline` publié à l'arrêt propre ; **seulement `MqttLink(presence=True)`** du vrai serveur | 2026-09-30 | ESP32 : serveur absent → `listenWanted=false`, plus de flux micro ; les outils (wav_to_mqtt) ne doivent pas faire croire au départ du serveur |
|
||||
| Topic de présence hors motif `voice/+/<suffixe>` des abonnements serveur | 2026-09-30 | sinon le serveur se prendrait pour une carte (écoute activée sur voice/server/control) |
|
||||
| Chien de garde ESP32 : TWDT reconfiguré **15 s, panique** ; loop (`enableLoopWDT`), tâches capture et audio inscrites ; réarmé entre deux envois réseau | 2026-09-30 | `NetworkClient::write` peut attendre 10 × 1 s (lu dans le core 3.x) : 5 s par défaut redémarrerait sur un Wi-Fi faible |
|
||||
| MQTT reste dans `loop()` (pas de tâche dédiée) ; connexion TCP 1 s (`setConnectionTimeout`) + CONNACK 2 s = 3 s de blocage au pire, broker absent seulement | 2026-09-30 | PubSubClient pas prévu pour plusieurs tâches ; audio dans ses propres tâches |
|
||||
| Wi-Fi relancé (`disconnect` + `begin`) après 30 s d'absence | 2026-09-30 | filet si la reconnexion automatique n'aboutit pas |
|
||||
| Journal serveur copié dans `logging.file` (`RotatingFileHandler` 1 Mo × 4), `logs/` ignoré par git | 2026-09-30 | Raspberry Pi sans écran ; les transcriptions y figurent = données personnelles |
|
||||
| Machines : **agent MQTT par machine** (`agent/voice_agent.py`, autonome : paho-mqtt + PyYAML), config YAML sur la machine | 2026-09-30 | demande utilisateur (config YAML) ; MQTT = transport unique (§3.7) ; SSH écarté : clés sur le serveur, liste blanche côté serveur seulement |
|
||||
| Liste blanche **sur la machine** : action = commande fixe en **liste** (pas de shell), le serveur n'envoie qu'un **nom** ; nom inconnu refusé | 2026-09-30 | un message forgé ne peut rien lancer d'autre ; config refusée si `command` est une chaîne |
|
||||
| Topics `agent/<id>/state` (retenu, **actions annoncées**, testament), `command` (QoS 1, non retenu, **session propre**), `result` | 2026-09-30 | le serveur sait si la machine répond et ce qu'elle accepte ; aucune commande rejouée au redémarrage |
|
||||
| « Allume » = **Wake-on-LAN** envoyé par le serveur (paquet magique UDP port 9, `broadcast` configurable), si agent hors ligne et MAC connue | 2026-09-30 | machine éteinte = pas d'agent |
|
||||
| Phrase qui **nomme une machine** traitée avant la domotique ; noms de plusieurs mots (« pc du bureau ») | 2026-09-30 | « éteins le PC du bureau » ≠ « éteins la lumière du bureau » (bureau est une pièce) |
|
||||
| Droits : `access.rules.machine` (wake/lock standard, shutdown/reboot complet), `confirm: [machine.shutdown, machine.reboot]` ; `MachineCommand` porte ses phrases (`said`, `asked`) | 2026-09-30 | réutilise étape 14 sans changer `DeviceCommand` ; `CommandRouter` aiguille vers Home/MachineController |
|
||||
| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-agent/agent.yaml` 0600), `Restart=always` ; `agent/` autonome (`make -C agent install`) | 2026-09-30 | poweroff/reboot exigent root ; il suffit de copier le dossier sur la machine |
|
||||
| `dry_run` dans agent.yaml ; `make agent-run` crée `agent/agent.yaml` en mode essai | 2026-09-30 | tester « éteins le PC » sur le PC de développement sans l'éteindre |
|
||||
| **TLS non activé** (option de l'étape 15) ; **numéros de séquence** reportés à l'étape 16 (§3.4 du prompt : « phase 16 ») | 2026-09-30 | comptes + ACL ferment l'accès au micro ; TLS = certificats + ~40 Ko de RAM ESP32, à décider par l'utilisateur |
|
||||
| `make run` = **mode assistant** ; `make run-echo` = étape 5 | 2026-09-29 | une file + un fil de travail : les sessions sont traitées une à une, hors du fil réseau |
|
||||
|
||||
---
|
||||
|
||||
## 4. Fichiers existants (et qui les utilise)
|
||||
|
||||
```
|
||||
docs/BROCHAGE.md brochage, consommation, vérifications multimètre
|
||||
docs/wiring.svg schéma de câblage complet (source versionnable)
|
||||
firmware/include/config.h constantes matérielles + audio (étapes 1→16)
|
||||
firmware/include/secrets.h.example modèle Wi-Fi / MQTT (utilisé à partir de l'étape 3)
|
||||
firmware/src/MicCapture.* I2S RX stéréo + conversion 32→16 bits [étape 1]
|
||||
firmware/src/MicSelfTest.* auto-test du micro au démarrage [étape 1]
|
||||
firmware/src/PushButton.* bouton PTT anti-rebond [étape 1]
|
||||
firmware/src/StatusLed.* LED d'état [étape 1]
|
||||
firmware/src/SerialProtocol.h format des trames, partagé [étapes 1-2]
|
||||
firmware/src/SerialFramer.* émission des trames vers le PC [étape 1]
|
||||
firmware/src/SerialFrameReader.* réception des trames du PC [étape 2]
|
||||
firmware/src/AudioFifo.* FIFO réception → tâche audio (StreamBuffer) [étape 2]
|
||||
firmware/src/AudioOutput.* I2S1 TX vers MAX98357A, bip de test [étape 2]
|
||||
firmware/src/WifiLink.* Wi-Fi station, non bloquant [étape 3]
|
||||
firmware/src/MqttLink.* PubSubClient, testament, reconnexion [étape 3]
|
||||
firmware/src/VoiceSession.* START / chunks / END, MQTT ou série [étape 4]
|
||||
firmware/src/PlaybackStream.* flux de lecture + tâche audio + bilan [étape 5]
|
||||
firmware/src/CommandHandler.* JSON du topic control (start/end/ping) [étape 5]
|
||||
server/voice_server/audio_resampler.py WAV quelconque -> 16 kHz mono -3 dBFS [étape 5]
|
||||
server/voice_server/audio_sender.py START / chunks rythmés / END vers une carte [étape 5]
|
||||
tools/wav_to_mqtt.py WAV ou son pur -> carte, avec bilan [étape 5]
|
||||
server/voice_server/settings.py config.yaml + .env [étape 4]
|
||||
server/voice_server/mqtt_link.py paho-mqtt 2.x, abonnement voice/+/... [étape 4]
|
||||
server/voice_server/audio_buffer.py PCM borné, niveaux, écriture WAV [étape 4]
|
||||
server/voice_server/session_manager.py sessions, pertes, délais, WAV [étape 4]
|
||||
server/voice_server/__main__.py point d'entrée : make run [étape 4]
|
||||
server/voice_server/speech_to_text.py SpeechToText (faster-whisper) + Transcript [étape 6]
|
||||
server/voice_server/text_to_speech.py TextToSpeech (Piper → 16 kHz) + Speech [étape 6]
|
||||
server/voice_server/assistant.py Assistant à règles (heure, date…) [étape 6]
|
||||
server/voice_server/voice_pipeline.py VoicePipeline : STT → réponse → TTS → envoi [étape 6]
|
||||
tools/download_models.py make models [étape 6]
|
||||
tools/speech_check.py make speech-check / make transcribe [étapes 6-7]
|
||||
server/voice_server/voice_activity.py VoiceActivityDetector (Silero), find_segments [étape 7]
|
||||
tools/vad_check.py make vad / make vad-all [étape 7]
|
||||
server/voice_server/diarization.py Diarizer (pyannote), SpeakerTurn, timeline [étape 8]
|
||||
tools/diarize_check.py make diarize / make diarize-demo [étapes 8-10]
|
||||
server/voice_server/speaker_identifier.py SpeakerIdentifier, profils, cosinus [étape 9]
|
||||
server/voice_server/speaker_attribution.py attribute, Utterance, transcript_json [étape 10]
|
||||
server/voice_server/home_control.py parse_intent, decide, HomeController [étape 11]
|
||||
server/voice_server/text_normalize.py normalize (commun) [étape 11]
|
||||
firmware/src/DemoDevices.* LED GPIO 21 + volets/porte simulés [étape 11]
|
||||
server/voice_server/wake_word.py WakeWordDetector (openWakeWord ONNX) [étape 13]
|
||||
server/voice_server/hands_free.py HandsFreeListener : veille / commande / réponse [étape 13]
|
||||
server/voice_server/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
|
||||
tools/enroll.py make enroll / profiles / forget [étape 9]
|
||||
tools/say.py make say : phrase Piper en WAV + MP3 (téléphone) [étape 14]
|
||||
docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3]
|
||||
firmware/src/main.cpp boucle PTT → I2S → série [étape 1]
|
||||
tools/serial_protocol.py format des trames, partagé [étapes 1-2]
|
||||
tools/serial_to_wav.py réception des trames série → WAV [étape 1]
|
||||
tools/wav_inspect.py enveloppe ASCII et niveaux d'un WAV [étape 1]
|
||||
tools/wav_to_serial.py WAV ou son pur → ESP32, au rythme réel [étape 2]
|
||||
mosquitto/voice.conf configuration du broker [étape 3]
|
||||
mosquitto/acl droits par topic : serveur, une carte = ses topics [étape 15]
|
||||
mosquitto/passwd comptes hachés (make mqtt-user), NON versionné [étape 15]
|
||||
server/tests/test_mqtt_link.py présence, refus du broker, journal fichier [étape 15]
|
||||
server/voice_server/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b]
|
||||
server/voice_server/command_router.py CommandRouter : domotique / machines [étape 15b]
|
||||
agent/voice_agent.py agent Linux autonome (liste blanche, dry_run) [étape 15b]
|
||||
agent/agent.yaml.example modèle de /etc/voice-agent/agent.yaml [étape 15b]
|
||||
agent/voice-agent.service, agent/Makefile service systemd, make -C agent install [étape 15b]
|
||||
server/tests/test_machine_control.py, test_voice_agent.py [étape 15b]
|
||||
server/ squelette Python (modules à partir de l'étape 3)
|
||||
Makefile toutes les opérations système
|
||||
```
|
||||
|
||||
**Non encore écrits** (volontairement, par étape) : optimisation et numéros de séquence (16),
|
||||
TLS (option de l'étape 15, non activée).
|
||||
|
||||
---
|
||||
|
||||
## 5. Reprise après incident
|
||||
|
||||
1. Lire ce fichier, puis `README.md`.
|
||||
2. `make help` liste toutes les cibles.
|
||||
3. Vérifier l'étape en cours dans le tableau du §2, et **ne rien faire au-delà**.
|
||||
4. Contrôle rapide de l'environnement : `make test` (98 tests), `make lint`, `make fw-check`.
|
||||
|
||||
### État au 2026-09-29 au soir (fin de session)
|
||||
|
||||
- (état du 2026-09-29 au soir, dépassé : l'étape 9 a été validée le 2026-09-30, voir le journal.)
|
||||
- Étape 9 alors écrite et testée hors ligne. Étapes 1 à 8 validées.
|
||||
- Tout était vert à l'arrêt : 98 tests, pyright 0 erreur, `make fw-check` SUCCESS (RAM 36,7 %,
|
||||
Flash 31,3 %), `pip check` propre. Aucun serveur lancé.
|
||||
- `server/.venv` contient les extras `speech` et `diarization` (torch CPU) ; `server/models/`
|
||||
contient whisper `small`, la voix Piper et le pipeline pyannote (accès HF réglé, `HF_TOKEN` dans
|
||||
`server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**).
|
||||
- **Aucun profil vocal** : `server/profiles/` n'existe pas encore (mes essais étaient dans le scratchpad).
|
||||
- Firmware inchangé depuis l'étape 5 (déjà sur la carte). Broker : conteneur Docker de l'utilisateur, 192.168.1.104.
|
||||
|
||||
### Reprise de l'étape 9 (actions de l'utilisateur)
|
||||
|
||||
1. `make run`, puis 5 questions variées de 1 à 4 s, seul.
|
||||
2. Dans un autre terminal, sans arrêter le serveur : `make enroll NAME=Denis`.
|
||||
3. Nouvelles questions → le journal doit afficher `SPEAKER_00 = Denis (0.7x)`.
|
||||
4. Une autre voix (personne ou haut-parleur) → `Inconnu (…, proche de Denis)`.
|
||||
5. `make diarize-demo` → votre voix reconnue, Piper « Inconnu ».
|
||||
6. Analyser le journal collé, puis demander la validation de l'étape 9 avant l'étape 10
|
||||
(association locuteur + texte : `{"speaker": "Denis", "text": "..."}`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Vérifications déjà passées (sans matériel)
|
||||
|
||||
| Contrôle | Commande | Résultat |
|
||||
|---|---|---|
|
||||
| Compilation du firmware | `make fw-build` | **SUCCESS**, 0 warning avec `-Wall` ; RAM 7,2 % (23 536 o), Flash 24,7 % (324 182 o) |
|
||||
| Tests Python | `make test` | **162 tests** passés (dont le vrai pipeline pyannote et openWakeWord) (trames, sessions, sortie, assistant, boucle, VAD, diarisation, vrais modèles) |
|
||||
| Typage Python | `make lint` | 0 erreur, 0 warning (pyright, mode `standard`) |
|
||||
| Chaîne série → WAV | port série simulé, sinus 440 Hz 1 s | WAV 16 kHz / 16 bits / mono, 16 000 trames, crête 48,8 % |
|
||||
|
||||
Reste à valider **avec le matériel** : câblage, niveau du micro, intelligibilité.
|
||||
|
||||
## 7. Git
|
||||
|
||||
Dépôt initialisé le 2026-09-30 (branche `main`, aucun dépôt distant). Identité **locale au dépôt** :
|
||||
Denis Defolie <denis.defolie@gmail.com>. Commits et push seulement à la demande de l'utilisateur.
|
||||
Vérifié avant le premier commit : `secrets.h`, `.env`, `mosquitto/passwd`, `agent/agent.yaml`,
|
||||
profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements virtuels sont ignorés.
|
||||
`docs/test-suivi-perso.txt` (notes personnelles) ignoré à la demande de l'utilisateur ; ancien doublon
|
||||
`firmware/secrets.h.example` (modèle périmé, sans vrai secret) supprimé à sa demande.
|
||||
|
||||
## 8. Journal
|
||||
|
||||
- **2026-09-27** — Création du dépôt, ossature complète, étape 1 écrite (I2S RX + `serial_to_wav.py`).
|
||||
Compilation, tests et typage vérifiés. En attente de validation matérielle par l'utilisateur.
|
||||
- **2026-09-27** — Ajout de `docs/BROCHAGE.md` et `docs/wiring.svg` (schéma de câblage
|
||||
complet : micro, ampli, HP, bouton, LED, rails d'alimentation). Point ouvert reporté à
|
||||
l'étape 2 : si le niveau sonore est deux fois trop faible, forcer le mode « canal gauche »
|
||||
du MAX98357A par un 100 kΩ de `SD` vers GND.
|
||||
- **2026-09-28** — `docs/BROCHAGE.md` §6 : types et valeurs des condensateurs
|
||||
(C1 = 470 µF 16 V électrolytique faible ESR, C2 = 100 nF X7R, C3 = 100 nF anti-rebond
|
||||
facultatif), bornes mini/maxi justifiées par le calcul et lecture du marquage à 3 chiffres.
|
||||
Schéma mis à jour : C1 et C2 dessinés côte à côte sur le `Vin` de l'ampli.
|
||||
- **2026-09-29** — Matériel branché par l'utilisateur : le HP soufflait en permanence. Cause : à l'étape 1 les broches BCLK/LRC/DIN sont flottantes et le MAX98357A (SD tiré au + par 100 kΩ) amplifie ce bruit. Correctif : `silenceAmplifier()` dans `main.cpp`, appelé en tout premier dans `setup()`, force les trois broches à 0 → veille automatique de l'ampli. Firmware recompilé (SUCCESS). Reste le bruit pendant les ~300 ms de boot : option matérielle `SD` → GND, ou piloter `SD` par un GPIO à l'étape 2 (résout aussi le « pop » d'activation).
|
||||
- **2026-09-29** — Premier enregistrement réel : crête sous 2 %. `MIC_GAIN` passé de 4 à **16** (dimensionnement : INMP441 à -26 dBFS/94 dB SPL, voix à 30 cm ≈ 80 dB SPL crête → -40 dBFS ≈ 1 % ; ×16 → ~16 %, soit 16 dB de marge). `tools/serial_to_wav.py` affiche désormais crête + RMS + offset continu en dBFS, distingue « aucun signal » (câblage) de « niveau faible » (gain) et propose le facteur correctif. 6 tests, pyright propre, firmware recompilé.
|
||||
- **2026-09-29** — L'utilisateur confirme le câblage. Ajout d'un **auto-test matériel** dans le firmware : `MicCapture::probe()` / `stats()` relèvent min/max/zéros des **mots I2S bruts**, `reportMicHealth()` (main.cpp) imprime le verdict sur la série au démarrage (bruit ambiant) et à la fin de chaque session (parole, avec la valeur de `MIC_GAIN` conseillée). Distingue : aucun mot lu (I2S muet) / mots figés (L/R ou SD) / amplitude mesurable (question de gain). `make fw-upload && make fw-monitor` suffit désormais à diagnostiquer sans le PC côté WAV.
|
||||
- **2026-09-29** — **Cause trouvée** : l'auto-test renvoyait 39 200 mots I2S *tous exactement nuls*, avec deux micros différents et un câblage vérifié. Signature de la lecture de la mauvaise moitié de trame : l'INMP441 sort des zéros pendant la moitié qu'il n'utilise pas, et la correspondance `L/R` ↔ `I2S_STD_SLOT_LEFT/RIGHT` n'est pas celle attendue sur l'ESP32 d'origine. Correctif : `MicCapture::useSlot()` (reconfiguration à chaud via `i2s_channel_reconfig_std_slot`) et `selectMicSlot()` dans `main.cpp`, qui écoute les deux moitiés au démarrage et garde celle qui porte du signal. **Décision figée : ne jamais supposer le mapping des slots I2S sur ESP32, le mesurer.**
|
||||
- **2026-09-29** — Les deux moitiés de trame sont nulles → la piste « slot » ne suffit pas. Vérification faite dans les en-têtes ESP-IDF installés (`framework-arduinoespressif32-libs/esp32/include/esp_driver_i2s/.../i2s_std.h`) : la configuration Philips 32 bits mono est correcte pour l'ESP32, le bug n'est pas là. Deux corrections : (1) l'auto-test affichait `amplitude 0` sans distinguer « mots nuls » de « aucun mot lu » → il imprime désormais le **nombre de mots** ; (2) `selectMicConfiguration()` explore les **4 combinaisons** {SCK/WS normaux, permutés} × {slot gauche, droite}, car une permutation SCK/WS donne exactement le même symptôme (le micro reçoit 16 kHz au lieu de 1 MHz et n'émet rien). En cas d'échec des 4, le firmware imprime un protocole de mesure au voltmètre et laisse les horloges tourner sur les broches du schéma.
|
||||
- **2026-09-29** — **Le micro capte.** Session réelle : 24 000 mots, amplitude 1 547 300 LSB24 (**-14 dBFS brut**), soit 6 044 LSB sur 16 bits avant gain. `MIC_GAIN` passé de 16 à **1** (crête ≈ 18 % de pleine échelle). Le calcul théorique datasheet (-26 dBFS @ 94 dB SPL → -40 dBFS attendus) sous-estimait le niveau réel de **26 dB**. **Décision figée : pour les niveaux audio, mesurer sur le banc, ne pas extrapoler du datasheet.** Point à confirmer : quelle combinaison a été retenue par `selectMicConfiguration()` (SCK/WS normaux ou permutés) — si permutés, corriger le câblage ou le schéma.
|
||||
- **2026-09-29** — **Fausse piste corrigée.** L'auto-test avait retenu « SCK/WS permutés » avec une amplitude de 16,7 M LSB24, soit ~100 % de la pleine échelle 24 bits (8 388 608) **au repos** : ce n'était pas du signal mais du **bruit pleine échelle d'une ligne de données flottante**. Mon critère « garder la plus grande amplitude » était faux. Trois corrections : (1) `MicCapture` lit désormais en **stéréo** (`I2S_SLOT_MODE_STEREO`) et choisit la moitié de trame en logiciel — la sélection de slot par le périphérique est ambiguë sur l'ESP32 et testait probablement deux fois la même moitié ; (2) convention d'amplitude unifiée (crête = demi-écart crête à crête, en LSB24) ; (3) garde-fou `NOISE_FLOOR_LIMIT` = 50 % de pleine échelle : au repos, au-delà c'est une ligne flottante, la candidate est rejetée et le message désigne `SD`/alimentation. **Décision figée : un niveau proche de la pleine échelle au repos est un défaut, jamais un succès.**
|
||||
- **2026-09-29** — Mesures utilisateur : **VDD = 3,29 V**, **GPIO 25 et 26 = 1,64 V** (≈ VDD/2, signature d'un carré 50 % → les deux horloges tournent et arrivent). Alimentation et horloges donc écartées ; seule reste la ligne `SD` → GPIO 33. Ajout de `MicCapture::probeDataLine()` : applique le pull-up puis le pull-down interne (~45 kΩ) et échantillonne la broche 4000 fois, horloges actives. Une sortie logique (quelques ohms) écrase le pull : si les lectures **suivent** le pull (100 % puis 0 %), la ligne flotte ; sinon elle est pilotée. `gpio_get_level()` fonctionne même quand la broche est routée vers l'I2S, d'où un test sans démonter la configuration.
|
||||
- **2026-09-29** — Test électrique : « ligne PILOTEE ». Mais ce verdict confondait deux cas très différents : *ligne active* et *ligne figée à un niveau fixe* (un fil posé sur GND ou L/R donne aussi « pilotée »). Verdicts désormais séparés en quatre : flottante / tenue à 0 / tenue à 1 / porte des transitions. Ajout de `MicCapture::readRaw()` + `dumpRawFrames()` : vidage hexadécimal de 12 trames (les deux moitiés côte à côte). C'est la seule observation qui distingue un décalage de bits d'une ligne morte. **Les pourcentages pull-up/pull-down sont l'information clé et doivent être relevés.**
|
||||
- **2026-09-29** — Lecture de la **structure** des relevés : en mode « permuté », une moitié de trame porte des données et l'autre est *exactement* nulle — signature d'un INMP441 correctement cadencé (`L/R` fixe la moitié d'émission). Du bruit de ligne flottante serait présent dans les deux moitiés. Donc le micro fonctionne, et les fils `SCK`/`WS` ne sont pas sur les broches du schéma. Deux corrections : (1) `MIC_SETTLE_MS = 250` — le transitoire de mise en route de l'INMP441 faussait toutes les mesures d'ambiance (0, puis -8, puis -15 dBFS d'un essai à l'autre) ; `begin()` jette désormais ce début ; (2) le firmware annonce les **GPIO réels** retenus pour SCK et WS au lieu du mot « permuté », et affiche les mots bruts si le niveau au repos dépasse 10 % de la pleine échelle. **Point ouvert : déterminer si c'est le câblage ou le schéma du dépôt qui doit changer.**
|
||||
- **2026-09-29** — **Brochage corrigé.** Le banc est câblé `SCK`→GPIO 25 et `WS`→GPIO 26, soit l'inverse du schéma initial. GPIO 25 et 26 étant équivalents (aucune fonction de strapping, tous deux en sortie), la documentation a été alignée sur le matériel plutôt que l'inverse. Modifiés : `config.h`, `docs/wiring.svg`, `docs/wiring.png`, `docs/BROCHAGE.md` (§1.1, ASCII, §9, §10), `README.md`, `README-EN.md`, `tools/serial_to_wav.py`. Vérifs : firmware SUCCESS, 6 tests, pyright 0 erreur, SVG re-rendu et relu.
|
||||
- **2026-09-29** — `aplay` échouait avec « unable to open slave » : rien à voir avec le WAV. Le PCM ALSA `default` passe par `dmix`, dont l'esclave est la **carte 0** = `C110`, un périphérique USB de **capture seule** (`pcm0c`, aucun `pcm*p`). D'où l'ENOENT. `paplay` (PulseAudio) fonctionne ; `aplay -D plughw:1,0` est refusé car PulseAudio tient la carte, et `aplay -D pulse` échoue (plugin `libasound2-plugins` absent). Ajout de `make play` (essaie paplay, pw-play, ffplay, aplay) et surtout de **`tools/wav_inspect.py`** + `make inspect` : enveloppe ASCII, crête/RMS/offset et détection d'alternance silence/parole — **valider un enregistrement sans écouter**, ce qui servira aussi sur le Raspberry Pi sans écran. 8 tests, pyright 0 erreur.
|
||||
- **2026-09-29** — **Bug trouvé : perte d'alignement de trame.** L'auto-test voyait du signal mais le WAV enregistré était fait de zéros exacts. Cause : en lecture stéréo, je déduisais la moitié de trame de la **parité de l'indice dans le tampon** (`_scratch[i*2]` / `[i*2+1]`). Or `i2s_channel_read()` rend ce qui est disponible, pas forcément un nombre pair de mots — et l'ancien `flush()` (lectures à vide) pouvait s'arrêter au milieu d'une trame. Les deux moitiés s'échangeaient alors, et `read()` prenait la moitié muette. Corrections : (1) `_phase` mémorisée entre les appels et avancée **mot par mot** dans `read()`, `probe()` et `readRaw()` ; (2) `flush()` fait désormais `i2s_channel_disable()` + `enable()`, ce qui vide le DMA **et** garantit que le prochain mot est un début de trame ; (3) `begin()` termine par `flush()`. **Décision figée : ne jamais déduire un canal d'une parité d'indice sur une API en flux d'octets.**
|
||||
- **2026-09-29** — Diagnostic enfin net et cohérent : test électrique **100 % / 0 %** → la ligne suit exactement le pull interne, donc **GPIO 33 flotte, rien ne le pilote**. Le « bruit pleine échelle » vu sur une moitié de trame est du couplage capacitif depuis les pistes d'horloge voisines, capté par une entrée non connectée — d'où son rejet par `NOISE_FLOOR_LIMIT`. Alimentation (3,29 V) et horloges (1,64 V) déjà validées au voltmètre : le seul maillon restant est le fil `SD` micro → GPIO 33. Correction d'un défaut de mon diagnostic : le vidage hexa s'exécutait sur la configuration *permutée* (dernière de la boucle) et montrait donc des zéros non représentatifs ; on revient maintenant au câblage documenté avant de diagnostiquer. Ajout d'un protocole de **continuité** (§8 de BROCHAGE.md), à mesurer sur la **pastille** du module et non au bout du fil.
|
||||
- **2026-09-29** — Les 3 continuités sont bonnes (`SD` ↔ GPIO 33, `L/R` ↔ GND, pas de court-circuit). Toutes les hypothèses de câblage côté connexions sont donc épuisées. Deux pistes restantes : (a) les signaux atteignent l'embase ESP32 mais **pas les pastilles du module** (soudure sèche) — à vérifier en mesurant SCK/WS/VDD **sur le module** ; (b) l'horloge bit de 1,024 MHz (16 kHz × 64) est trop lente pour ce module : certains INMP441 de contrefaçon ne démarrent qu'au-dessus de ~2 MHz. L'auto-test balaie désormais **2 fréquences × 2 câblages × 2 moitiés**, et `startSession()` annonce la **fréquence réellement retenue** dans l'en-tête de session (le WAV reste donc correct si 48 kHz est nécessaire).
|
||||
- **2026-09-29** — **Relecture complète de l'historique : j'avais tiré une conclusion fausse.**
|
||||
Le verdict « FLOTTE » (ligne SD) avait été mesuré alors que la configuration active était la
|
||||
dernière de la boucle d'essais, c'est-à-dire horloges **inversées** pour le câblage réel : micro
|
||||
non cadencé, donc muet. Le verdict « PILOTÉE » de l'essai précédent avait, lui, été mesuré
|
||||
horloges **correctes**. Conclusion juste : **le micro pilote bien sa ligne SD**. Les tests de
|
||||
continuité demandés ensuite étaient superflus (tous bons). J'avais repéré ce défaut pour le
|
||||
vidage hexa, pas pour le test électrique qui avait le même.
|
||||
Ce que tous les essais montrent de façon constante : **horloges correctes → une moitié de trame
|
||||
porte des valeurs énormes et erratiques au repos (0, -8, -14, -15 dBFS), l'autre est exactement
|
||||
nulle ; horloges inversées → tout est nul.** Hypothèses restantes, par ordre :
|
||||
(H1) **mot décalé d'un bit** (cadrage Philips vs MSB) : le bit 31 devient aléatoire et un signal
|
||||
faible ressemble à un bruit pleine échelle ;
|
||||
(H2) **quelques mots parasites** qui font exploser la mesure crête (max − min), métrique fragile ;
|
||||
(H3) **pull-down de 100 kΩ absent** sur SD, pourtant exigé par le datasheet (sortie haute impédance
|
||||
hors moitié de trame).
|
||||
Réponse firmware : nouvelle classe **`MicSelfTest`** (main.cpp passe de 437 à ~150 lignes) ;
|
||||
balayage {16 kHz, 48 kHz} × {Philips, MSB} × {2 moitiés} ; critère = **valeur efficace au repos**
|
||||
(< -20 dBFS) + **cohérence du bit de signe** (bit31 = bit30 dans > 99 % des mots), plus jamais
|
||||
l'amplitude crête ; **pull-down interne activé sur SD** (`MIC_SD_PULLDOWN`) ; analyse **bit à bit**
|
||||
(taux de 1 par position) + vidage hexa en configuration nominale ; test électrique refait
|
||||
**horloges correctes**. Le balayage SCK/WS permutés est supprimé : câblage vérifié.
|
||||
- **2026-09-29** — **H1 observée directement : mot décalé d'un bit en cadrage Philips.**
|
||||
Analyse bit à bit (16 kHz Philips) : 1re moitié = `F806B200`… avec **9 zéros** en bas au lieu de 8
|
||||
(bit 8 à 0 sur 8 mots sur 8), 2e moitié = `00000001` en permanence. Lecture : le premier bit émis
|
||||
par le micro (signe) est échantillonné un coup d'horloge trop tard et **déborde dans le dernier bit
|
||||
du mot précédent** ; les 23 bits restants sont décalés d'un rang. Confirmation croisée : à 48 kHz
|
||||
en cadrage **MSB**, signe incohérent seulement 0,5 % et 2e moitié exactement nulle.
|
||||
**Bug de mon critère** : la 2e moitié constante `00000001` avait été retenue comme « plausible »
|
||||
(non nulle, RMS nulle après `>> 12`, signe cohérent) → un enregistrement aurait été silencieux.
|
||||
Corrections : refus des constantes (`minWord == maxWord`) ; statistiques séparant **composante
|
||||
continue** (`sum`) et **variations** (écart type) — seul l'écart type juge le bruit de repos ;
|
||||
plancher à -110 dBFS ; `MIC_SETTLE_MS` 250 → **600 ms** (l'INMP441 s'endort à chaque arrêt des
|
||||
horloges et chaque réveil laisse une composante continue lente, -30 dBFS encore visible à 250 ms) ;
|
||||
analyse bit à bit faite sur la configuration **retenue**, en régime établi (trames du milieu).
|
||||
**À confirmer par le prochain essai : cadrage MSB attendu comme configuration retenue.**
|
||||
- **2026-09-29** — Essai suivant : **16 kHz Philips encore retenu à tort**, toujours décalé (9 zéros,
|
||||
`00000001` dans l'autre moitié). Mon test de cohérence du signe (bit31 = bit30) est **aveugle sur un
|
||||
signal faible** : toute la partie haute du mot vaut `1`, en perdre un ne change rien. Il ne détecte le
|
||||
décalage que sur un signal fort. Nouveau critère, indépendant de l'amplitude : **nombre de bits
|
||||
utiles** = 32 − ctz(OU de tous les mots) ; avec le pull-down, un INMP441 bien cadré donne
|
||||
**exactement 24**. Validé hors carte sur les mots réels du banc (g++) : Philips → 23 (décalé), mêmes
|
||||
échantillons recadrés MSB → 24. Valeurs recadrées ≈ −227 000 LSB24 (composante continue de réveil,
|
||||
≈ −31 dBFS) avec des écarts de quelques milliers : audio sain. Mesures au repos en MSB : continu
|
||||
−43 dBFS, variations −44 dBFS (16 kHz). **Décision attendue : cadrage MSB retenu automatiquement.**
|
||||
- **2026-09-29** — **Cadrage MSB confirmé** sur le banc : 24 bits utiles (8 zéros exacts), 2e moitié
|
||||
exactement nulle. Mais l'auto-test échouait : les mots au repos valaient +1 950 000 (**23 % de pleine
|
||||
échelle**) en montant d'échantillon en échantillon. Cause : **le micro se réveillait à chaque essai**.
|
||||
L'INMP441 s'endort dès que ses horloges s'arrêtent, et mon auto-test les arrêtait entre chaque
|
||||
configuration ; chaque mesure « au repos » mesurait donc un réveil, pas la pièce (d'où −43 dBFS un jour,
|
||||
−13 dBFS le suivant). Pire : `flush()` faisait `disable()`/`enable()`, donc **chaque session
|
||||
d'enregistrement aurait démarré par ce transitoire** (« boum » grave).
|
||||
Refonte : (1) **un seul réveil** au démarrage, lu en MSB, `MIC_SETTLE_MS` = 1500 ; (2) décision
|
||||
**purement structurelle** : moitié ni nulle ni constante, **24 bits utiles** → MSB ; **25** → micro
|
||||
Philips, bascule et revérification ; le niveau au repos n'est plus qu'une information ; (3) `flush()`
|
||||
vide le DMA **par lecture**, sans arrêter les horloges, en suivant la phase, et **amorce le filtre de
|
||||
continu** avec la moyenne mesurée ; (4) toutes les lectures traitent les mots reçus **avant** de tester
|
||||
l'erreur (une lecture interrompue ne décale plus la phase) ; (5) `readRaw()` rend toujours des trames
|
||||
entières ; (6) suppression du balayage 48 kHz et de l'incohérence de signe (détecteur aveugle au repos) ;
|
||||
(7) `delay(1500)` au démarrage pour que le moniteur série ne perde plus le début de l'auto-test.
|
||||
Hypothèse de robustesse notée : quand personne ne lit entre deux sessions, le DMA déborde ; les
|
||||
descripteurs contiennent des trames entières (200 × 2 mots) et les lectures sont de taille paire, la
|
||||
parité — donc l'alignement — est conservée.
|
||||
- **2026-09-29** — **Auto-test réussi sur le banc** : 16 kHz, cadrage MSB, 1re moitié, 24 bits utiles,
|
||||
2e moitié strictement nulle, ligne SD pilotée. Mots bruts `FE466E00`… → échantillons ≈ −111 000 ± 2 000
|
||||
LSB24 (continu ≈ −37 dBFS). Niveau au repos mesuré −37 dBFS de variations pendant l'auto-test, mais
|
||||
l'analyse bit à bit faite juste après montre des échantillons confinés dans [−131 000 ; −98 000], soit
|
||||
≈ −54 dBFS : le micro finissait de se stabiliser, 1,5 s après son réveil. Sans conséquence pour les
|
||||
sessions (horloges jamais arrêtées ensuite). **Reste à valider : enregistrement réel + `make inspect`.**
|
||||
- **2026-09-29** — **Premier enregistrement audible** (entendu au casque ; `paplay` fonctionne, sortie
|
||||
analogique par défaut). Analyse spectrale du WAV (numpy) : **87 % de l'énergie sous 20 Hz**, voix
|
||||
réelle mais à −42 dBFS dans la bande 300–3 400 Hz. Chaîne de capture saine : aucun saut > 382 LSB,
|
||||
aucune longue suite de valeurs identiques, donc aucun glissement de phase. Cause la plus probable :
|
||||
mécanique (plaque d'essai qui fléchit sous le doigt tenant le bouton) + souffle. L'ancien filtre
|
||||
(`MIC_DC_SHIFT = 12`, 0,6 Hz) ne retirait que le continu. Simulation de plusieurs réglages sur le WAV
|
||||
réel : 1 étage à 80 Hz suffit (infrasons 0,6 %, voix 99,1 %) ; 2 étages n'apportent presque rien.
|
||||
Implémenté : `MIC_HPF_SHIFT = 5` (80 Hz), filtre calculé **en 24 bits** (en 16 bits, zone morte de
|
||||
31 LSB), gain appliqué avant la réduction 24 → 16 bits avec arrondi, **`MIC_GAIN = 4`** (mesuré après
|
||||
filtre). La recommandation de gain en fin de session porte désormais sur la **crête après passe-haut**
|
||||
(`RawStats::filteredPeak`), plus sur les mots bruts. Vérifié hors carte (g++, arithmétique identique au
|
||||
firmware) : écart à la théorie du 1er ordre < 0,3 dB, −29 dB à 3 Hz, < 0,4 dB au-dessus de 300 Hz.
|
||||
**Étape 1 : critère « audible, sans saturation » atteint ; confirmation attendue sur le firmware final.**
|
||||
- **2026-09-29** — **Étape 1 validée par l'utilisateur** (« le son est vraiment bien »). Le WAV validé
|
||||
provient encore de l'ancien firmware (dérive continue +0,3 % → +4,6 %, impossible avec le passe-haut
|
||||
à 80 Hz) : le filtre 80 Hz sera vérifié sur le matériel avec le firmware de l'étape 2.
|
||||
- **2026-09-29** — **Étape 2 écrite.** Firmware : `AudioOutput` (I2S1 TX, 16 bits stéréo dupliqué,
|
||||
Philips par défaut via `AMP_FORMAT_PHILIPS`, `auto_clear`, bip 440 Hz −12 dBFS au démarrage = test de
|
||||
la sortie seule), `AudioFifo` (StreamBuffer statique 16 Ko, tout ou rien), `SerialFrameReader`
|
||||
(machine à états), tâche audio statique cœur 1 priorité 5, `SerialProtocol.h` partagé ;
|
||||
`silenceAmplifier()` supprimé (l'I2S tient les broches dès la 1re ligne de `setup()`) ; `loop()` sert
|
||||
la lecture même si le micro est en défaut. PC : `serial_protocol.py` extrait, `wav_to_serial.py`
|
||||
(WAV ou `--tone`, avance 200 ms puis 1 chunk / 50 ms), cibles `make play-esp` et `make tone-esp`.
|
||||
Vérifs : firmware SUCCESS 0 warning (RAM 17,4 %) ; **test croisé** encodeur Python → décodeur C++
|
||||
compilé sur PC (texte parasite, faux mot magique, somme fausse, longueur excessive : 4 trames
|
||||
valides, 2 rejetées) ; outil PC sur pseudo-terminal : 4 chunks d'avance puis 50 ± 1 ms ; 15 tests
|
||||
pytest (dont un qui a trouvé un vrai bug : dernier chunk de longueur impaire), pyright 0 erreur.
|
||||
**Non vérifiable sans matériel** : cadrage Philips réel du MAX98357A côté ESP32, bruit de
|
||||
l'ampli cadencé en permanence sur le micro, absence de coupure sur la carte.
|
||||
- **2026-09-29** — **Étape 2 validée par l'utilisateur** : bip, son pur sans clic, lecture de la voix,
|
||||
contrôle du micro — tout passe. Seul point : **niveau du HP faible mais audible**. Explication :
|
||||
bip à −12 dBFS par choix, et le WAV rejoué (étape 1, ancien firmware, gain 1) avait sa voix vers
|
||||
−30 dBFS crête. Pistes, sans code : mesurer `Vin` sur la pastille de l'ampli (≥ 4,5 V), rejouer un
|
||||
enregistrement fait avec le firmware actuel (gain 4 → +12 dB), `GAIN` à GND (+6 dB, 15 dB),
|
||||
enceinte pour le HP. Correctif de fond prévu : **normaliser côté serveur** l'audio envoyé (étape 5,
|
||||
puis la voix Piper à l'étape 6), l'ESP32 restant simple. Prochaine étape : 3 (Mosquitto + topics).
|
||||
- **2026-09-29** — **Étape 3 écrite.** Constat en arrivant : le broker du conteneur
|
||||
`mosquitto_services-mosquitto-1` redémarrait en boucle (**378 redémarrages**). Sa configuration,
|
||||
écrite à la main hors du dépôt, déclarait **deux `listener 1883`** : `Error: Address in use`,
|
||||
reproduit dans un conteneur jetable. Le journal restait muet car `log_dest syslog` (dans MA
|
||||
`voice.conf`) n'écrit nulle part dans un conteneur ; `message_size_limit` était aussi obsolète.
|
||||
L'IP donnée par l'utilisateur (192.168.0.104) ne correspond pas à la machine : **192.168.1.104**.
|
||||
`voice.conf` corrigé (`log_dest stdout`, `max_packet_size`, pièges documentés) et **testé dans un
|
||||
broker jetable** : état `online` retenu, testament `offline` publié à la coupure brutale du
|
||||
client, et reçu par un abonné arrivé après coup. Le conteneur de l'utilisateur n'a **pas** été
|
||||
modifié (hors dépôt, fichiers de l'uid 1883) : cible `make mosquitto-docker` fournie.
|
||||
Firmware : `WifiLink`, `MqttLink` (PubSubClient 2.8 épinglé, tampon 2048, keepalive 10 s, socket
|
||||
2 s, essais toutes les 5 s), état JSON retenu toutes les 30 s (IP, RSSI, uptime, micro, ampli),
|
||||
événement `boot` avec la cause du redémarrage (dont `brownout`), abonnement `control` journalisé ;
|
||||
secrets transmis par constructeur, `#error` explicite si `secrets.h` manque ; Wi-Fi démarré
|
||||
**après** l'auto-test du micro. Partitions `huge_app` (Flash 74,5 % → 31 %). Build SUCCESS
|
||||
0 warning (avec un `secrets.h` temporaire, supprimé). Cibles `mqtt-watch`, `mqtt-control`
|
||||
(clients de l'image Docker si `mosquitto-clients` absent). `docs/MOSQUITTO.md` créé.
|
||||
Correction faite dans la doc : un reset d'ESP32 ne ferme PAS la connexion TCP (testament en ≤ 15 s).
|
||||
- **2026-09-29** — Étape 3, premiers résultats : broker réparé par l'utilisateur **avec sa propre
|
||||
config** (pas `voice.conf` : `log_dest file`, `log_type all`, `message_size_limit`) — fonctionne,
|
||||
mais `log_type all` journalisera chaque chunk audio à l'étape 4 → passer à `voice.conf` avant.
|
||||
ESP32 : Wi-Fi « BPI_R3-2G » en 4,8 s, IP 192.168.1.23, **RSSI −76 à −78 dBm (faible)**, MQTT connecté.
|
||||
Vérifié directement sur le broker : un abonné tardif reçoit aussitôt l'état retenu (uptime 158 s),
|
||||
republié 30 s plus tard (188 s). Auto-test micro : variations au repos −46 dBFS (Wi-Fi démarré
|
||||
après). Restent à confirmer : message `control` reçu, `offline` après coupure d'alimentation.
|
||||
- **2026-09-29** — **Étape 3 validée par l'utilisateur.** `mqtt-watch` : état périodique, `control`
|
||||
relayé par le broker, `offline` publié par le testament à la coupure d'alimentation, puis `online`
|
||||
(uptime 12 s) et `event boot reason=poweron` au retour. Avant l'étape 4 : passer le broker sur
|
||||
`voice.conf` (`make mosquitto-docker`) et améliorer le RSSI (−75 à −79 dBm, viser > −70).
|
||||
- **2026-09-29** — **Étape 3 validée ; étape 4 écrite.** Firmware : `VoiceSession` (START / chunks /
|
||||
END en JSON sur `event`, PCM brut sur `audio/in`, id de session 6 hex), **tâche de capture** dédiée
|
||||
+ FIFO de capture 32 Ko (1 s) : `loop()` ne lit plus le micro, il vide la FIFO vers MQTT ; `AudioFifo`
|
||||
à stockage fourni ; `MqttLink::publishBinary`. RAM 36,7 %, Flash 31,1 %, 0 warning.
|
||||
Serveur (premier code) : `settings`, `mqtt_link`, `audio_buffer`, `session_manager`, `__main__` ;
|
||||
`make run`. Verdicts : complete, lost_chunks, timeout, interrupted. 30 tests pytest, pyright 0.
|
||||
**Essai de bout en bout sur le vrai broker** avec une carte simulée (paho, rythme réel) :
|
||||
complete 40/40 ✓, timeout 20/? ✓, mais la session à 3 chunks perdus était classée `interrupted`
|
||||
(nouveau START arrivé pendant le délai de grâce) → **bug corrigé**, test de régression ajouté,
|
||||
scénario rejoué : `lost_chunks 37/40` + alerte, puis session suivante `complete`.
|
||||
Pas de `tools/mqtt_to_wav.py` : le serveur remplit ce rôle ; à réévaluer avec `wav_to_mqtt.py` (étape 5).
|
||||
- **2026-09-29** — **Incident causé par moi : `firmware/include/secrets.h` de l'utilisateur détruit.**
|
||||
Mes compilations de contrôle faisaient `cp secrets.h.example secrets.h ; make fw-build ; rm -f secrets.h`.
|
||||
Sans conséquence à l'étape 3 (fichier pas encore créé) ; à l'étape 4, la copie a écrasé le vrai
|
||||
fichier puis `rm` l'a supprimé (dossier modifié à 17:02). Symptôme chez l'utilisateur :
|
||||
`'WIFI_SSID' was not declared` (le `#error` s'affiche bien, mais GCC continue et ajoute cette
|
||||
erreur). **Correctif de méthode** : cible `make fw-check`, secrets factices dans un `mktemp -d`
|
||||
via `PLATFORMIO_BUILD_FLAGS=-I<tmp>`, `firmware/include/` jamais touché. Règle aussi notée en
|
||||
mémoire. L'utilisateur doit recréer son `secrets.h` (SSID relevé dans ses journaux : « BPI_R3-2G »,
|
||||
broker 192.168.1.104 déjà dans le modèle).
|
||||
- **2026-09-29** — **Étape 4 validée** : 4 phrases de 4 à 6 mots, intelligibles au casque. Analyse des
|
||||
4 WAV serveur : 1,8 à 4,35 s, nombres entiers de chunks (36, 50, 87, 56), crête −10,4 à −15,3 dBFS
|
||||
(gain ×4 dans la cible), **offset ≈ 0 %** et infrasons 0 à 0,3 % (contre +3 % et 87 % avant) :
|
||||
**passe-haut 80 Hz validé sur le matériel**. Voix 99,4 à 99,9 % de l'énergie.
|
||||
Prochaine étape : 5 (serveur → MQTT → ESP32).
|
||||
- **2026-09-29** — **Étape 4 validée ; étape 5 écrite.** Firmware : `PlaybackStream` (lecture série ou
|
||||
MQTT, tâche audio, bilan `played` publié sur `event`), `CommandHandler` (start/end/ping, ArduinoJson
|
||||
7.2.1), abonnement `audio/out`. Build 0 warning, RAM 36,7 %. Serveur : `AudioResampler` (8/16/24/32
|
||||
bits, toute fréquence, mono, soxr HQ, crête −3 dBFS), `AudioSender` (300 ms d'avance, rythme réel),
|
||||
`MqttLink.publish/wait_connected`, `SessionManager(on_finished=...)`, `--echo` (fil de lecture dédié,
|
||||
hors fil paho). Outil `tools/wav_to_mqtt.py`, cibles `tone-mqtt`, `play-mqtt`, `run-echo`.
|
||||
41 tests (dont un qui a trouvé qu'un WAV tronqué levait `EOFError` au lieu du message ffmpeg), pyright 0.
|
||||
**Essais de bout en bout sur le broker réel** (carte simulée « esp32-test ») : son pur 40/40, écart max
|
||||
51 ms, crête −3,0 dBFS, bilan reçu ; écho : session 30/30 à −24,3 dBFS renvoyée à −3 dBFS, 30/30.
|
||||
**Incident causé par moi** : mon serveur de test a repris l'identifiant `voice-server` du `make run`
|
||||
de l'utilisateur (lancé à 17:16) → ~16 s d'éjections mutuelles ; son serveur a enregistré
|
||||
`server/recordings/esp32-test_20260929-173525_voix01.wav` (artefact de test, 0,5 s, supprimable).
|
||||
Correctifs : identifiant de test distinct, indice « identifiant en double ? » dans le journal de
|
||||
déconnexion, règle mémorisée. Le `make run` de l'utilisateur tourne encore l'ancien code (étape 4).
|
||||
- **2026-09-29** — Étape 5, premier essai utilisateur : écho une fois (phrase coupée), puis rien. Journal
|
||||
serveur : session `39ecba` ouverte et close en **72 ms, 0 chunk** → le bouton est lu relâché pendant
|
||||
l'appui (contact intermittent sur la plaque ? carte déplacée pour le Wi-Fi ?). Le serveur **ignorait en
|
||||
silence** les sessions < 0,2 s pour l'écho (défaut de ma part). Ligne `en ligne` 12 s après la
|
||||
session : republication périodique ou **redémarrage** — indécidable, le journal n'affichait pas
|
||||
l'uptime. Instrumentation ajoutée : raison de fin de session (« bouton relache » / « duree
|
||||
maximale »), alerte si session < 300 ms, purge des événements de bouton périmés (un appui mémorisé
|
||||
pendant une session relançait une session fantôme), uptime dans le journal d'état serveur,
|
||||
événement `boot` journalisé avec sa cause (WARNING si brownout/panic/watchdog), écho ignoré annoncé.
|
||||
- **2026-09-29** — Étape 5, second essai (instrumenté) : **diagnostic Wi-Fi confirmé par les chiffres**.
|
||||
RSSI −81 à −75 dBm. Session 833429 : ~108 chunks captés, 42 envoyés, **66 perdus faute de place**
|
||||
(FIFO de capture 1 s débordée : envoi Wi-Fi bloqué plusieurs secondes) ; écho 11/42 chunks, END
|
||||
perdu (reconnexion MQTT pendant l'écho : `offline` puis `online` 130 ms après, uptime continu 102→107 s,
|
||||
donc **pas de redémarrage**). Sessions suivantes 55/55 et 76/76 (4 perdus), échos complets mais 35 et
|
||||
107 sous-alimentations (≈ 2 s de trous sur 3,8 s). Serveur toujours à l'heure (retard d'envoi 0 ms).
|
||||
Bouton : plus aucun problème (appuis de 2 à 5 s). **Décision : ne pas masquer côté logiciel** (le
|
||||
tampon anti-gigue est l'étape 12) ; expérience demandée : ESP32 à 1–2 m du point d'accès. Cibles :
|
||||
> −65 dBm fiable, −65 à −72 sous-alimentations ponctuelles, < −72 pertes. Piste matérielle si
|
||||
l'emplacement ne peut pas changer : ESP32-WROOM-32U + antenne externe.
|
||||
- **2026-09-29** — **Étape 5 validée** (« Echo est bon maintenant ») après **changement de SSID** par
|
||||
l'utilisateur. RSSI −69 à −72 dBm (à peine mieux que −75/−81), même IP 192.168.1.23 (même réseau :
|
||||
autre point d'accès ou autre canal). État stable 7 min, aucune reconnexion. Leçon : le canal / la
|
||||
congestion 2,4 GHz compte autant que le RSSI. Chiffres de lecture (`played`) non fournis.
|
||||
Prochaine étape : 6 (MVP : faster-whisper → réponse → Piper → ESP32).
|
||||
- **2026-09-29** — **Étape 6 écrite** (serveur seul, firmware inchangé). `speech_to_text.py`,
|
||||
`text_to_speech.py`, `assistant.py`, `voice_pipeline.py` ; `__main__.py` : mode assistant par
|
||||
défaut, écho avec `--echo`. `config.yaml` : `stt.model small`, `beam_size`, `no_speech_threshold`,
|
||||
`min_peak_dbfs`, `tts.length_scale` (clé `output_sample_rate` retirée : c'est `audio.sample_rate`).
|
||||
Extra `speech` installé dans `server/.venv` et modèles téléchargés (549 Mo) par moi.
|
||||
Mesures : Piper 0,04 à 0,10 s pour 1 à 3 s de parole ; Whisper ≈ 1,0 s ; `make speech-check` →
|
||||
« Quelle heure est-il ? » relu exactement, réponse prête en 1,04 s de calcul. Les 3 derniers
|
||||
enregistrements réels de l'ESP32 (`server/recordings/`) transcrits sans faute (« Fermez le volet
|
||||
de la salle de bain. »). 65 tests (dont 3 avec les vrais modèles, ignorés sans `make models`),
|
||||
pyright 0 erreur. Délai perçu attendu ≈ 1,1 s de calcul + 0,3 s d'avance d'envoi. Pas d'essai
|
||||
bout en bout sur le broker réel (éviterait deux serveurs qui répondent à la même carte).
|
||||
En attente de validation matérielle par l'utilisateur (`make run`).
|
||||
- **2026-09-29** — **Étape 6 validée** par l'utilisateur (« Dialogue très correct »). 9 questions
|
||||
d'affilée, toutes les sessions `complete`, toutes les lectures `chunks N/N` :
|
||||
heure, date, bonjour, nom → bonnes réponses ; phrases libres répétées. Réponse prête **0,95 à
|
||||
1,11 s** après la fin de session (Whisper 0,89–1,06 s, Piper 0,05–0,11 s), pire retard d'envoi 0 ms.
|
||||
Appui sans parler : crête −30,7 dBFS (bruit du bouton, au-dessus du seuil de −45) → Whisper appelé,
|
||||
hallucination « Amara.org » **écartée par le filtre**, réponse « Je n'ai rien entendu. ».
|
||||
Points notés pour plus tard :
|
||||
(a) **sous-alimentations 6 à 9 par lecture même sans perte**, 25 et 35 quand le RSSI tombe à −83 dBm.
|
||||
La base 6–9 vient probablement du démarrage : la tâche audio vide chaque chunk dès son arrivée
|
||||
(DMA encore vide) et compte chaque bloc incomplet ; il n'y a pas de seuil de démarrage → **étape 12**
|
||||
(lecture lancée seulement après l'avance de 300 ms, sous-alimentations comptées après) ;
|
||||
(b) session muette : RMS −46,8 dBFS contre −22 à −28 pour la parole → la VAD de l'**étape 7**
|
||||
remplacera le seuil de crête ; (c) « fermé la porte » pour « fermez » : homophone, l'**étape 11**
|
||||
devra tolérer ce genre d'écart ; (d) 7 chunks perdus faute de place à la capture (Wi-Fi).
|
||||
Prochaine étape : 7 (VAD Silero). Silero est déjà livré par faster-whisper
|
||||
(`faster_whisper/assets/silero_vad_v6.onnx`, exécuté par onnxruntime) : pas besoin de torch.
|
||||
- **2026-09-29** — **Étape 7 écrite** (serveur seul, firmware inchangé). `voice_activity.py`
|
||||
(`find_segments`, `pad_segments`, `VoiceActivityDetector`, `envelope`), branché dans `VoicePipeline`
|
||||
avant Whisper ; `tools/vad_check.py` (`make vad`, `make vad-all`) ; `speech_check --wav` passe par la VAD.
|
||||
Config : section `vad` (clé `window_samples` retirée : 512 est imposé, constante `WINDOW_SAMPLES`),
|
||||
`stt.min_peak_dbfs` supprimé. Mesures sur les 27 enregistrements du serveur : 6 sans parole, tous
|
||||
à juste titre (3 sessions vides de l'étape 5, l'appui muet `0137fe`, mes 2 sons purs `esp32-test`) ;
|
||||
pour les 9 questions de l'étape 6, 8 à 37 % de silence retiré, l'utilisateur parle ~0,5 s après
|
||||
l'appui. **Gain inattendu** : `c5a22c`, transcrit « fermé la porte » à l'étape 6, devient
|
||||
« fermez la porte » une fois le silence retiré. Appui muet : réponse prête en 0,03 s au lieu de 1,11 s.
|
||||
`c5a22c` commence à 0,00 s : parole dès l'appui, début de mot possiblement coupé (piège à signaler).
|
||||
78 tests, pyright 0 erreur. Pas d'essai sur le broker : le `make run` de l'utilisateur (étape 6,
|
||||
lancé à 19:26) tournait encore ; vérification hors ligne de `make_assistant` sur 2 vraies sessions.
|
||||
En attente de validation par l'utilisateur.
|
||||
- **2026-09-29** — **Étape 7 validée** par l'utilisateur (« Semble fonctionner »). 6 sessions :
|
||||
segments de parole trouvés partout (jusqu'à 3 segments, pauses de 0,6 à 0,95 s conservées en une
|
||||
seule transcription), appui muet (crête −32,4 dBFS) rejeté par la VAD, réponse prête en **0,07 s**.
|
||||
VAD 7 à 11 ms (25 ms au premier appel). Erreur « 4 jours, sommes-nous ? » pour « Quel jour
|
||||
sommes-nous ? » (`ce69b6`, pause de 0,6 s après « jour ») : **pas due à la VAD** — sur l'audio brut,
|
||||
Whisper écrit « qu'à jour, sommes-nous ». Essai `initial_prompt` (phrases types de l'assistant) :
|
||||
corrige `ce69b6` mais dégrade `a2428a` (« quelles jours ») → **non retenu** ; à reconsidérer à
|
||||
l'étape 11 (vocabulaire des intentions, règles tolérantes : « jour » + « sommes ») ou modèle
|
||||
`medium` à l'étape 16. Prochaine étape : 8 (diarisation pyannote).
|
||||
- **2026-09-29** — **Étape 8 écrite** (serveur seul). Installé par moi dans `server/.venv` :
|
||||
pyannote.audio 4.0.7 + torch CPU (`.venv` = 1,9 Go), numpy monté à 2.5.3 ; `pip check` propre.
|
||||
`diarization.py` (`Diarizer`, `Diarization`, `SpeakerTurn`, `timeline`, `disable_telemetry`),
|
||||
branché dans `VoicePipeline` après la VAD ; `make install-diarization`, `make diarize`,
|
||||
`make diarize-demo` (voix de l'utilisateur + Piper + voix de l'utilisateur) ; `make models`
|
||||
télécharge le pipeline si `HF_TOKEN` est dans `server/.env`, sinon explique la démarche et rend 1.
|
||||
**Découverte** : pyannote 4 a une télémétrie active par défaut vers otel.pyannote.ai → coupée.
|
||||
**Non vérifié** : le vrai pipeline n'a pas tourné, `server/.env` n'existe pas (pas de jeton). J'ai
|
||||
relu le code de pyannote là où le mien s'y appuie : entrée `{"waveform": (1, n), "sample_rate"}`
|
||||
acceptée, silence → `DiarizeOutput` vide sans erreur, `speaker_embeddings` (n, dim).
|
||||
84 tests + 1 ignoré (modèle réel), pyright 0 erreur. En attente : jeton de l'utilisateur, puis validation.
|
||||
- **2026-09-29** — Modèle pyannote obtenu : premier refus `GatedRepoError` car le compte HF du jeton
|
||||
(type « read », valide) n'avait pas accepté les conditions ; réglé par l'utilisateur. `make models`,
|
||||
`make test` (85, vrai pipeline compris) et `make diarize-demo` OK : **2 locuteurs** trouvés dans
|
||||
l'ordre vous / Piper / vous (0,08-1,55 · 1,75-5,58 · 5,82-7,25 s), **0,47 s** pour 7,35 s de parole.
|
||||
Avertissement torch `std(): degrees of freedom` (morceau trop court) masqué dans `Diarizer.diarize`.
|
||||
Question seule en `make run` : OK selon l'utilisateur. Reste : une session réelle à deux voix.
|
||||
- **2026-09-29** — **Test réel à deux voix réussi** (session `453cce`, 11,85 s, au micro de l'ESP32) :
|
||||
l'utilisateur, une voix Piper jouée par un haut-parleur, l'utilisateur. VAD : 3 segments, 6,34 s de
|
||||
parole. Diarisation : SPEAKER_00 0,08-1,50 · SPEAKER_01 1,57-4,69 · SPEAKER_00 4,92-6,17 — les deux
|
||||
phrases de l'utilisateur portent la même étiquette, et les bornes coïncident avec celles de la VAD
|
||||
(1,61 et 4,89 s dans l'audio sans silences). Diarisation 0,46 s. **Délai : réponse prête en 2,04 s**
|
||||
(VAD 0,02 + diarisation 0,46 + Whisper 1,34 pour 6,3 s + Piper 0,21) : la diarisation s'ajoute à
|
||||
Whisper → candidate à la parallélisation (étape 16). « la roi de synthèse » : voix passée par un
|
||||
haut-parleur puis le micro. Validation explicite de l'étape 8 non encore donnée.
|
||||
- **2026-09-29** — **Étape 8 validée** par l'utilisateur. Étape 9 lancée avec **les empreintes de
|
||||
pyannote** (choix de l'utilisateur, après comparaison avec SpeechBrain ECAPA : précision publiée
|
||||
du même ordre ; SpeechBrain n'apporterait que l'indépendance vis-à-vis de la diarisation, au prix
|
||||
d'un modèle, d'une dépendance et d'un risque de compatibilité avec torchaudio 2.11).
|
||||
- **2026-09-29** — **Étape 9 écrite** (serveur seul). Mesure préalable : les empreintes rendues par
|
||||
pyannote sont **NaN pour toutes les questions courtes** (cf. décisions) → empreinte recalculée par
|
||||
locuteur avec le sous-modèle WeSpeaker. `speaker_identifier.py` (`SpeakerIdentifier`,
|
||||
`VoiceProfile`, `Identification`, `save_profile`, `load_profiles`), `tools/enroll.py`,
|
||||
`make enroll/profiles/forget`, identification affichée par `make diarize` ; `VoicePipeline` journalise
|
||||
`SPEAKER_00 = Nom (score)`. `diarize-demo` choisissait `453cce` (deux voix) puis `093463` (mélange) :
|
||||
il prend maintenant la question la plus récente de 1 à 4 s à une seule voix (`--voice` pour imposer).
|
||||
Avertissements numpy « Mean of empty slice » (empreintes NaN internes de pyannote) masqués.
|
||||
Essai complet **dans le scratchpad** (aucun profil écrit dans `server/profiles/`) : profil « Essai »
|
||||
sur 10 sessions → 6 retenues (2 à deux voix, 1 muette, 1 mélangée écartées), cohérence 0,79–0,86 ;
|
||||
`c5a22c` → Essai 0,74 ; `453cce` → Essai 0,72 / haut-parleur Inconnu −0,05 ; démo → Essai 0,84 /
|
||||
Piper Inconnu −0,02. Délai : + ~0,5 s de diarisation et d'empreinte (réponse prête 1,47 s pour une
|
||||
question de 1,3 s). 98 tests, pyright 0 erreur. En attente : enrôlement réel et validation.
|
||||
- **2026-09-30** — Fichiers de test pour l'étape 9 (demande de l'utilisateur, à jouer depuis son
|
||||
téléphone) : `recordings/voix-test/{1-tom,2-pierre,3-jessica,4-gilles,5-siwis}.{wav,mp3}`, 5 voix
|
||||
Piper différentes (tom-medium, upmc-medium pierre et jessica, gilles-low, siwis-medium), 0,5 s de
|
||||
silence avant et après, crête −3 dBFS, toutes relues correctement par Whisper. Voix téléchargées
|
||||
dans le scratchpad puis supprimées (rien ajouté à `server/models/`). `fr_FR-mls-medium` écartée :
|
||||
aucun de ses 125 locuteurs n'est transcrit correctement. **Mesure à retenir** : Jessica et Siwis
|
||||
(deux voix féminines de synthèse) se ressemblent à **0,60** (fichier contre fichier), au-dessus du
|
||||
seuil de 0,45 ; les autres paires 0,01–0,37. Le seuil devra peut-être monter si une voix étrangère
|
||||
est reconnue comme l'utilisateur (ses propres sessions : 0,52–0,83, marge faible).
|
||||
- **2026-09-30** — **Étape 9 validée** par l'utilisateur (« Tout semble marcher super bien »).
|
||||
6 profils enrôlés par l'utilisateur : Denis (5 sessions) et les 5 voix de synthèse jouées au
|
||||
téléphone (Gilles 3, Jessica 3, Pierre 4, Siwis 3, Tom 5). Essai réel : Denis 0,74 et 0,78 ;
|
||||
Tom 0,95, Gilles 0,96, Siwis 0,96, Jessica 0,93, Pierre 0,92 — chaque voix trouvée, Jessica et Siwis
|
||||
(0,60 entre elles) bien départagées par le meilleur score. Réponse prête en 1,5 à 1,8 s
|
||||
(diarisation + empreinte ≈ 0,45 s). **Limites à garder en tête** : (1) les scores de 0,92–0,96 des
|
||||
voix de synthèse sont gonflés — même fichier rejoué à l'enrôlement et au test, audio quasi identique ;
|
||||
le vrai test de généralisation est Denis (phrases nouvelles, 0,74–0,78) ; (2) le cas « Inconnu »
|
||||
n'a pas été rejoué en réel ce matin (toutes les voix étaient enrôlées) ; il a été vérifié hier
|
||||
(haut-parleur −0,05, Piper −0,02) ; (3) seuil 0,45 à recalibrer avec de vraies personnes (étape 14).
|
||||
« Quel ton fera-t-il » pour « temps » : Whisper sur une voix de téléphone. Prochaine étape : 10.
|
||||
- **2026-09-30** — **Étape 10 écrite** (serveur seul). `Word` et `Transcript.words` (Whisper,
|
||||
`word_timestamps`), `speaker_attribution.py` (`turn_of`, `attribute`, `Utterance`,
|
||||
`transcript_json`), `VoicePipeline` publie sur `voice/<carte>/transcript` et passe le nom de la
|
||||
dernière personne à l'assistant ; `make diarize` affiche qui a dit quoi et le message MQTT.
|
||||
Essai hors ligne sur `453cce` avec les profils réels de l'utilisateur : Denis « Ouvrir la porte. »,
|
||||
**Siwis** (juste : c'était sa voix au haut-parleur) « Je suis la roi de synthèse… », Denis « Fermez la
|
||||
porte. », bornes 0,00-1,26 / 1,50-4,46 / 4,92-5,86 s. 110 tests, pyright 0 erreur.
|
||||
En attente : essai sur la carte et validation.
|
||||
- **2026-09-30** — **Étape 10 validée** par l'utilisateur (« continuer maintenant »). Essai carte :
|
||||
Denis 0,68 / 0,79, Jessica 0,92, interventions et textes justes ; « Bonjour » seul (1,06 s de parole)
|
||||
→ Inconnu 0,32 → salutation générique (piège annoncé : phrase trop courte pour une empreinte).
|
||||
Session à plusieurs voix non refaite sur la carte (vérifiée hors ligne sur `453cce`).
|
||||
`make mqtt-watch` affichait l'audio binaire (`voice/#`) : limité aux topics texte (state, event,
|
||||
control, transcript) ; ajout de `make mqtt-transcript`. Prochaine étape : 11 (intentions + domotique).
|
||||
- **2026-09-30** — **Étape 11 écrite** (serveur + firmware). Choix utilisateur : commandes vers
|
||||
`home/…` **et** exécution par la carte pour sa pièce ; pas de domotique réelle (simulation).
|
||||
Serveur : `home_control.py`, `Assistant.respond()` → `Reply(text, command)`, `HomeController`,
|
||||
`MqttLink.publish_topic`, section `home` de config.yaml (pièces + `boards: esp32-01: salon`),
|
||||
journal des confirmations `device`. Firmware : `DemoDevices`, `CommandHandler` gère `cmd: device`,
|
||||
`PIN_DEMO_LIGHT = 21` ; `make fw-check` SUCCESS, 0 warning (RAM 36,7 %, Flash 31,4 %).
|
||||
`make mqtt-watch` inclut `home/#` ; `make mqtt-home`. `docs/BROCHAGE.md` : LED GPIO 21 + 330 Ω
|
||||
(non dessinée sur `wiring.svg`). Essai hors ligne sur 3 enregistrements réels : volets du salon
|
||||
fermés/ouverts → `home/salon/shutter/set` + `esp32-01/control` ; salle de bain → `home/…` seul.
|
||||
130 tests, pyright 0 erreur. **Firmware non téléversé** (à faire par l'utilisateur). En attente de validation.
|
||||
- **2026-09-30** — **Étape 11 validée** par l'utilisateur (« Semble fonctionner correctement »),
|
||||
firmware téléversé. « Éteint la lumière » (graphie Whisper) et « Allume la lumière » →
|
||||
`home/salon/light/set` + `esp32-01/control`, **confirmation de la carte 40 ms après la commande**
|
||||
(`{"event":"device","ok":true,"state":"off"}`), puis la réponse parlée ; « Quel temps fait-il ? » →
|
||||
réponse « sans Internet ». Réponse prête en 1,41 à 1,55 s. `mqtt-watch` lisible : transcript,
|
||||
commande, confirmation, START/END de lecture, `played`. **Sous-alimentations toujours 6 à 10 par
|
||||
lecture, sans aucune perte** (`chunks 34/34`) : c'est l'objet de l'étape 12. Prochaine étape : 12.
|
||||
- **2026-09-30** — Exigence utilisateur : commander des machines Linux → **étape 15b** (décidé).
|
||||
- **2026-09-30** — **Étape 12 écrite**. Firmware : `PlaybackStream` devient une machine à états
|
||||
(Idle / Priming / Playing) dans la tâche audio ; `PLAYBACK_START_MS = 300` ; bilan `played` enrichi
|
||||
(`start_ms`, `min_margin_ms`, `underruns` = réamorçages) ; `make fw-check` SUCCESS, 0 warning
|
||||
(RAM 36,7 %, Flash 31,4 %). Cause probable des 6 à 10 « sous-alimentations » : la tâche jouait dès
|
||||
le premier chunk, sans attendre l'avance envoyée par le serveur, et comptait chaque bloc incomplet.
|
||||
Serveur : journal `played` enrichi (WARNING s'il y a des réamorçages) ; banc `JitteryLink` dans
|
||||
`tools/wav_to_mqtt.py`, `JITTER` dans le Makefile, `tone-mqtt` passe à 5 s. 131 tests, pyright
|
||||
0 erreur. **Non vérifié sur la carte** (je n'envoie pas de son sur le matériel de l'utilisateur) :
|
||||
firmware à téléverser, puis essais `JITTER=0/200/400`. En attente de validation.
|
||||
- **2026-09-30** — Premiers essais de l'étape 12 sur la carte (`tone-mqtt` 5 s, JITTER 0 / 200 / 400) :
|
||||
marge mini **204 / 142 / 2 ms** (suit bien la gigue), réamorçages **1 / 1 / 2**, premier son à
|
||||
**214–218 ms**. Deux écarts avec la prévision : (1) **un réamorçage systématique**, même sans gigue,
|
||||
hors de la marge mesurée ; (2) l'avance de 300 ms n'arrive pas d'un bloc mais en ~200 ms (hypothèse :
|
||||
fenêtre TCP de lwIP ~5,7 Ko < 6 chunks de 1,6 Ko, la carte lit au rythme de PubSubClient).
|
||||
Instrumentation ajoutée plutôt que supposition : `gaps_ms` (instant de chaque réamorçage depuis le
|
||||
premier son) dans le bilan `played`, la série et le journal serveur. En attente d'un nouvel essai.
|
||||
- **2026-09-30** — **Cause du réamorçage systématique trouvée grâce à `gaps_ms`** : 1er essai après
|
||||
démarrage 0 réamorçage ; 2e essai `gaps_ms:[28043]` = uptime de la carte, donc compté avant le premier
|
||||
son du flux (`_firstSoundMs` = 0). **Faux compte, inaudible** : après un flux, la tâche restait en
|
||||
Playing, bloquée ≤ 20 ms dans `read()` ; le START suivant arrivait pendant ce temps, le `read()`
|
||||
rendait 0 octet, `_endOfStream` venait d'être remis à faux → famine comptée pour un flux pas encore
|
||||
commencé. Correctif : passage à **Idle** quand la FIFO est vide après le END, famine comptée
|
||||
seulement par **compare-and-swap Playing → Priming** (échoue si `open()` a déjà changé l'état), et
|
||||
`open()` met l'état à Priming **avant** de remettre les compteurs à zéro. `make fw-check` SUCCESS.
|
||||
Premier son à 214 ms : l'avance arrive étalée (fenêtre TCP de lwIP probable) ; à revoir à l'étape 16
|
||||
(latence), sans effet sur la fluidité. Marge mini 188–204 ms sans gigue.
|
||||
- **2026-09-30** — Essai final de l'étape 12 (firmware corrigé) : `tone-mqtt` ×3 → **0 réamorçage,
|
||||
`gaps_ms` vide** à chaque fois (le cas qui échouait) ; JITTER=200 → **0**, marge mini 162 ms ;
|
||||
JITTER=400 → 1 réamorçage **réel** à 364 ms du premier son (1er blocage), marge 2 ms. Premier son
|
||||
212–221 ms. Critères de l'étape 12 atteints ; validation explicite de l'utilisateur en attente.
|
||||
- **2026-09-30** — **Étape 12 validée** par l'utilisateur. Étape 13 lancée : mots de réveil anglais
|
||||
prêts à l'emploi **alexa, hey_jarvis, hey_mycroft** (les trois ; alexa si un seul).
|
||||
- **2026-09-30** — **Étape 13 écrite** (serveur + firmware). openWakeWord installé `--no-deps` dans
|
||||
`server/.venv`, modèles dans `server/models/openwakeword/` (`make models`). Serveur : `wake_word.py`,
|
||||
`hands_free.py`, `make_listener` dans `__main__.py` (état/événements composés, balayage, arrêt qui
|
||||
coupe l'écoute), section `wake` de config.yaml, extra `wakeword` + `make install-wakeword`.
|
||||
Firmware : `cmd listen` / `cmd capture`, flux continu dans `loop()` (`updateStreaming`,
|
||||
`stopStreaming`), LED `blink(2000, 100)` ; `make fw-check` SUCCESS, 0 warning. Simulation complète
|
||||
hors ligne (flux : phrase sans mot puis « Alexa, allume la lumière », Piper) : 1re phrase ignorée,
|
||||
aucun fichier ; mot 0,99 ; commande close 2,75 s après ; `home/salon/light/set` + carte ; réponse.
|
||||
142 tests (dont vie privée : rien écrit en veille ; rafale Wi-Fi), pyright 0 erreur.
|
||||
**Non vérifié sur la carte** : firmware à téléverser. Limite connue : broker anonyme → flux audio
|
||||
lisible par tout le réseau local (étape 15) ; si le serveur meurt sans `listen false`, la carte
|
||||
continue d'émettre (testament serveur à prévoir à l'étape 15). En attente de validation.
|
||||
- **2026-09-30** — Retour utilisateur (essai réel de l'étape 13) : « Hey Jarvis » et « Alexa » pas
|
||||
toujours reconnus (prononciation anglaise nécessaire), **« Hey Mycroft » fiable en français**.
|
||||
Ajouts : **seuil propre à chaque mot** (`wake.thresholds`, vide par défaut) et **journal des mots
|
||||
presque reconnus** (pic de score ≥ 0,15 retombé sans atteindre le seuil) pour régler sur mesures.
|
||||
143 tests, pyright 0 erreur. Aucun seuil changé tant qu'on n'a pas les pics réels.
|
||||
- **2026-09-30** — **Étape 13 validée** par l'utilisateur, qui retient **« Hey Mycroft »** comme mot
|
||||
principal (fiable prononcé en français) ; alexa et hey_jarvis restent actifs. Réglage des seuils par
|
||||
mot reporté (outil prêt : « presque reconnu »). Étape 14 lancée : droits par profil.
|
||||
- **2026-09-30** — **Étape 14 écrite** (serveur seul, firmware inchangé). `access_control.py`
|
||||
(`AccessPolicy`, `Level`, `Verdict`, `Resolution`), section `access` de config.yaml,
|
||||
`VoicePipeline._decide` (confirmation en attente d'abord, puis droits, puis exécution),
|
||||
`home_control.announce/request`, élision « d'ouvrir ». Essai hors ligne avec les vrais profils :
|
||||
Siwis (standard, 0,59) « ouvre la porte » → refusé ; Denis (complet, 0,69) → confirmation demandée ;
|
||||
phrase suivante sans oui/non → demande abandonnée ; Denis ferme les volets → exécuté.
|
||||
162 tests, pyright 0 erreur. En attente de l'essai réel et de la validation.
|
||||
- **2026-09-30** — Ajout de **`make say`** (`tools/say.py`) : fait dire `TEXT` par une voix Piper
|
||||
(`VOICE`, `SPEAKER` pour upmc : 0 = Jessica, 1 = Pierre), 0,5 s de silence avant/après, crête −3 dBFS,
|
||||
WAV + MP3 (ffmpeg) dans `SAY_OUT` (défaut `recordings/voix-test/phrase.wav`) ; voix téléchargée au
|
||||
premier usage. Pour tester droits et confirmation sans seconde personne (≠ `speech-check`, qui fait
|
||||
entendre la RÉPONSE). Générés : `recordings/voix-test/tom-porte` (« Hey Mycroft, ouvre la porte. »)
|
||||
et `pierre-lumiere` (« Hey Mycroft, allume la lumière. »).
|
||||
- **2026-09-30** — Retour utilisateur : « Hey Mycroft » doit être prononcé à l'anglaise, sinon rien.
|
||||
Mesures (détecteur hey_mycroft réel, scratchpad) : voix FR (tom, upmc, siwis) en texte ou en graphies
|
||||
francisées → **0,00** ; en phonèmes bruts Piper `[[ˈɛj mˈajkʁɔft]]` → ~1 synthèse sur 2 atteint 0,5
|
||||
(Piper est aléatoire), mais retombe à 0 au moindre bruit/filtrage → inutilisable au téléphone. Voix
|
||||
anglaise **en_GB-alan-medium** → 1,00 partout, même bruit 15 dB + bande 300-4000 Hz + −12 dB (ryan et
|
||||
lessac moins stables). D'où `make say WAKE="Hey Mycroft"` (`--wake`, `--wake-voice`) : Alan dit le mot,
|
||||
0,6 s de pause, puis VOICE dit la commande ; chaque morceau normalisé à −3 dBFS, sortie **16 kHz** ;
|
||||
fichier vérifié par `WakeWordDetector` (échec → code 1). Identification : le serveur prend la
|
||||
**dernière** intervention, mais la pré-écoute (0,5 s avant la détection, qui tombe ~0,15 s avant la fin
|
||||
du mot) mêle la voix d'Alan : commande courte → Tom 0,42-0,46 (sous le seuil) ; commande ≥ 2 s
|
||||
(« …du salon, s'il te plaît. ») → Tom 0,57, Pierre 0,77 sur la découpe exacte du serveur. Refaits :
|
||||
`tom-porte` (« Ouvre la porte du salon, s'il te plaît. », détecteur 1,00) et `pierre-lumiere`
|
||||
(« Allume la lumière du salon, s'il te plaît. », 0,95). 162 tests, pyright 0 erreur.
|
||||
- **2026-09-30** — **Essai réel de l'étape 14** (« satisfaisant, ma voix Denis OK »). Denis : lumière
|
||||
on/off exécutées (0,71-0,84), porte → confirmation → « oui » (bouton, ou « croft ? Oui. » en mains
|
||||
libres) → exécutée ; Tom (fichier téléphone, 0,68) porte → refusé ; Pierre (0,83) identifié.
|
||||
**Bug trouvé** : Pierre (standard) REFUSÉ pour allumer la lumière, « requis complet ». Cause : YAML 1.1
|
||||
lit `on`/`off` nus comme des **booléens** → règles `light.True/False`, action `on` absente → défaut
|
||||
complet. Corrigé : guillemets dans config.yaml, `settings._key()` rend True/False → on/off, et
|
||||
`AccessPolicy` **refuse au démarrage** une action inconnue (plus d'erreur silencieuse). Aussi : Denis
|
||||
à 0,59 (< full_min_score 0,6) recevait « votre profil ne permet pas » — faux ; message désormais
|
||||
« je ne reconnais pas assez nettement votre voix… Répétez, plus près du micro. ». Autres constats,
|
||||
non corrigés : 1re session hallucinée par Whisper (0,82 s de parole → « Je vous remercie de votre
|
||||
soutien… », 5,5 s de calcul) ; un « Ouvre » perdu en tête de commande (« la porte du salon… » → « Que
|
||||
dois-je faire avec la porte ? »), réussi au 2e essai. 165 tests, pyright 0 erreur.
|
||||
- **2026-09-30** — **Étape 14 validée** par l'utilisateur. Étape 15 lancée.
|
||||
- **2026-09-30** — **Étape 15 écrite** (broker + serveur + firmware). Broker : `voice.conf`
|
||||
(`allow_anonymous false`, `password_file`, `acl_file`), `mosquitto/acl`, `make mqtt-user`,
|
||||
`mosquitto-docker`/`mosquitto-config` installent acl + passwd (0600). Vérifié sur un broker **jetable**
|
||||
(conteneur `voice-acl-test`, port 18830, supprimé ensuite ; le broker de l'utilisateur n'a pas été
|
||||
touché) : anonyme et mauvais mot de passe refusés ; carte qui publie sous le nom d'une autre, lit le
|
||||
micro ou les commandes d'une autre, publie sur home/ → jeté ; carte lit ses commandes et la présence.
|
||||
Un broker **ouvert** accepte un client qui envoie des identifiants (vérifié) → migration : comptes,
|
||||
.env + secrets.h, firmware + serveur, broker en dernier. Serveur : `MqttLink(presence=True)`,
|
||||
`refusal_hint`, `setup_logging` ; vérifié : arrêt propre → `offline`, `kill -9` → testament
|
||||
immédiat, mauvais mot de passe → message clair, `make run-echo` avec config de test → journal
|
||||
fichier. Firmware : TWDT 15 s, `setConnectionTimeout(1000)`, Wi-Fi relancé à 30 s, présence
|
||||
serveur (micro coupé si absent), utilisateur MQTT = DEVICE_ID par défaut, diagnostic codes 4/5.
|
||||
`make fw-check` SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). 170 tests, pyright 0 erreur.
|
||||
Non vérifié sur la carte : déclenchement réel du chien de garde (pas de commande de test).
|
||||
En attente de l'essai réel et de la validation.
|
||||
- **2026-09-30** — **Étape 15 validée** par l'utilisateur, qui **garde le broker de développement
|
||||
anonyme** (`make mosquitto-docker` non lancé ; comptes et ACL à activer en production). Conséquence
|
||||
à rappeler : tant que le broker est anonyme, tout appareil du réseau peut publier sur les topics de
|
||||
commande (cartes, et agents Linux de l'étape 15b). Étape 15b lancée : agent MQTT sur chaque
|
||||
machine, configuration YAML.
|
||||
- **2026-09-30** — **Étape 15b écrite** (serveur + agent ; firmware inchangé). Serveur :
|
||||
`machine_control.py` (parse/decide, `MachineController` : états des agents, interprétation, commande
|
||||
ou Wake-on-LAN), `command_router.py`, `MqttLink.on_topic` (filtres hors voice/), section `machines`
|
||||
de config.yaml (actions : mots + phrases ; `pc-bureau`), `AccessPolicy` et `Assistant` généralisés
|
||||
(`Command = DeviceCommand | MachineCommand`). Agent : `agent/voice_agent.py`, `agent.yaml.example`,
|
||||
service systemd, `agent/Makefile` ; cibles `agent-run`, `agent-install`, `agent-log`, `mqtt-agents` ;
|
||||
règles agents dans `mosquitto/acl` (pour la production). Essai de bout en bout sur broker **jetable**
|
||||
anonyme (port 18832, supprimé) : agent en `dry_run` annoncé avec 3 actions ; « éteins » et
|
||||
« verrouille » → agent → compte rendu ; « allume » → « déjà en marche » ; agent arrêté → hors ligne →
|
||||
« ne répond pas ». 202 tests, pyright 0 erreur (agent/ inclus). En attente de l'essai réel.
|
||||
- **2026-09-30** — **Git initialisé** à la demande de l'utilisateur, avant l'essai de l'étape 15b :
|
||||
premier commit (108 fichiers) sur `main` ; contrôle des fichiers sensibles fait (§7).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Makefile — toutes les opérations système du projet assistant vocal.
|
||||
# Cible par défaut : help.
|
||||
|
||||
SHELL := /bin/bash
|
||||
VENV := server/.venv
|
||||
PY := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
# PlatformIO vit dans son propre environnement : ses dépendances entrent sinon en
|
||||
# conflit avec celles du serveur vocal (constaté avec click / esptool).
|
||||
PIO_VENV := .pio-venv
|
||||
PIO := $(PIO_VENV)/bin/pio
|
||||
FW_DIR := firmware
|
||||
SERIAL_PORT ?= /dev/ttyUSB0
|
||||
BAUD ?= 921600
|
||||
OUT ?= recordings/test.wav
|
||||
TONE ?= 440
|
||||
TEXT ?= Quelle heure est-il ?
|
||||
LAST ?= 5
|
||||
JITTER ?= 0
|
||||
VOICE ?= fr_FR-tom-medium
|
||||
SPEAKER ?=
|
||||
SAY_OUT ?= recordings/voix-test/phrase.wav
|
||||
WAKE ?=
|
||||
WAKE_VOICE ?= en_GB-alan-medium
|
||||
MQTT_HOST ?= 192.168.1.104
|
||||
MSG ?= {"cmd":"ping"}
|
||||
MOSQ_DIR ?= $(HOME)/srv/mosquitto
|
||||
MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml
|
||||
# Clients MQTT : ceux du système s'ils sont installés, sinon ceux de l'image Docker.
|
||||
MOSQ_RUN := $(shell command -v mosquitto_sub >/dev/null 2>&1 || echo "docker run --rm --network host eclipse-mosquitto:2.0")
|
||||
# Comptes MQTT (étape 15) : fichier de mots de passe hachés, jamais versionné.
|
||||
MOSQ_PASSWD := $(shell command -v mosquitto_passwd >/dev/null 2>&1 && echo "mosquitto_passwd mosquitto/passwd" \
|
||||
|| echo "docker run --rm -it --user $$(id -u):$$(id -g) -v $(CURDIR)/mosquitto:/work eclipse-mosquitto:2.0 mosquitto_passwd /work/passwd")
|
||||
# Les cibles mqtt-* prennent les identifiants du serveur dans server/.env. Leurs
|
||||
# recettes commencent par @ : make n'affiche pas la ligne, donc pas le mot de passe.
|
||||
-include server/.env
|
||||
MQTT_AUTH = $(if $(MQTT_USERNAME),-u '$(MQTT_USERNAME)' -P '$(MQTT_PASSWORD)')
|
||||
MQTT_AS = @echo "(compte MQTT : $(if $(MQTT_USERNAME),$(MQTT_USERNAME) de server/.env,anonyme))"
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help install-system mosquitto-config mosquitto-docker mqtt-user agent-run agent-install agent-log mqtt-agents mqtt-watch mqtt-transcript mqtt-home mqtt-control venv install install-dev install-speech install-diarization install-wakeword models \
|
||||
say speech-check transcribe vad vad-all diarize diarize-demo enroll profiles forget run test lint record play inspect play-esp tone-esp play-mqtt tone-mqtt run-echo pio-venv fw-build fw-check fw-upload \
|
||||
fw-monitor clean
|
||||
|
||||
## ------------------------------------------------------------------ aide
|
||||
|
||||
help: ## Affiche cette aide
|
||||
@echo "Assistant vocal local ESP32 — cibles disponibles :"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
@echo "Variables : SERIAL_PORT=$(SERIAL_PORT) BAUD=$(BAUD) OUT=$(OUT) TONE=$(TONE) MQTT_HOST=$(MQTT_HOST) JITTER=$(JITTER)"
|
||||
@echo " TEXT=\"$(TEXT)\""
|
||||
|
||||
## ------------------------------------------------------- installation système
|
||||
|
||||
install-system: ## Installe les paquets Debian nécessaires (sudo)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
mosquitto mosquitto-clients \
|
||||
ffmpeg \
|
||||
python3 python3-venv python3-pip \
|
||||
git
|
||||
@echo "Ajout de l'utilisateur au groupe dialout (accès au port série)."
|
||||
sudo usermod -aG dialout $$USER
|
||||
@echo "→ Déconnectez/reconnectez votre session pour activer le groupe dialout."
|
||||
|
||||
mosquitto-config: mosquitto/passwd ## Installe voice.conf, acl et passwd dans /etc/mosquitto et redémarre le broker
|
||||
sed 's|/mosquitto/config/|/etc/mosquitto/|' mosquitto/voice.conf | sudo tee /etc/mosquitto/conf.d/voice.conf >/dev/null
|
||||
sudo install -o mosquitto -g mosquitto -m 0600 mosquitto/acl /etc/mosquitto/acl
|
||||
sudo install -o mosquitto -g mosquitto -m 0600 mosquitto/passwd /etc/mosquitto/passwd
|
||||
sudo systemctl restart mosquitto
|
||||
sudo systemctl --no-pager status mosquitto | head -n 5
|
||||
|
||||
mosquitto-docker: mosquitto/passwd ## Installe voice.conf, acl et passwd dans le conteneur Mosquitto et le relance
|
||||
@test -f $(MOSQ_COMPOSE) || { echo "Fichier compose introuvable : MOSQ_COMPOSE=$(MOSQ_COMPOSE)"; exit 1; }
|
||||
-sudo cp $(MOSQ_DIR)/config/mosquitto.conf $(MOSQ_DIR)/config/mosquitto.conf.bak
|
||||
sudo install -o 1883 -g 1883 -m 0644 mosquitto/voice.conf $(MOSQ_DIR)/config/mosquitto.conf
|
||||
sudo install -o 1883 -g 1883 -m 0600 mosquitto/acl $(MOSQ_DIR)/config/acl
|
||||
sudo install -o 1883 -g 1883 -m 0600 mosquitto/passwd $(MOSQ_DIR)/config/passwd
|
||||
docker compose -f $(MOSQ_COMPOSE) restart
|
||||
@sleep 2
|
||||
docker compose -f $(MOSQ_COMPOSE) logs --tail 5
|
||||
|
||||
mosquitto/passwd:
|
||||
@echo "mosquitto/passwd absent : creez d'abord les comptes (make mqtt-user NAME=voice-server, puis NAME=esp32-01)"; exit 1
|
||||
|
||||
mqtt-user: ## Crée ou change le compte MQTT NAME (voice-server, ou le DEVICE_ID d'une carte) dans mosquitto/passwd
|
||||
@test -n "$(NAME)" || { echo "usage : make mqtt-user NAME=voice-server puis make mqtt-user NAME=esp32-01"; exit 1; }
|
||||
@touch mosquitto/passwd && chmod 600 mosquitto/passwd
|
||||
@echo "mot de passe de $(NAME) (idee : openssl rand -hex 16), a reporter dans $(if $(filter voice-server,$(NAME)),server/.env,firmware/include/secrets.h) :"
|
||||
@$(MOSQ_PASSWD) $(NAME)
|
||||
@echo "compte $(NAME) enregistre, hache, dans mosquitto/passwd. Installation : make mosquitto-docker (ou mosquitto-config)."
|
||||
|
||||
## ------------------------------------------------------------- agents Linux (étape 15b)
|
||||
|
||||
agent-run: agent/agent.yaml ## Lance l'agent de CETTE machine au premier plan (agent/agent.yaml, essai)
|
||||
$(PY) agent/voice_agent.py --config agent/agent.yaml
|
||||
|
||||
agent/agent.yaml:
|
||||
sed 's/^dry_run: false/dry_run: true/' agent/agent.yaml.example > $@
|
||||
@echo "agent/agent.yaml cree en MODE ESSAI (dry_run: true) : verifiez id et mqtt.host"
|
||||
|
||||
agent-install: ## Installe l'agent comme service sur CETTE machine (autre machine : copier agent/, make -C agent install)
|
||||
$(MAKE) -C agent install
|
||||
|
||||
agent-log: ## Journal de l'agent installé sur cette machine
|
||||
$(MAKE) -C agent log
|
||||
|
||||
mqtt-agents: ## Affiche états, commandes et comptes rendus des agents : agent/#
|
||||
$(MQTT_AS)
|
||||
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'agent/#'
|
||||
|
||||
mqtt-watch: ## Affiche les messages texte voice/+/... et home/# (sans l'audio binaire), Ctrl-C pour quitter
|
||||
$(MQTT_AS)
|
||||
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'voice/+/state' -t 'voice/+/event' \
|
||||
-t 'voice/+/control' -t 'voice/+/transcript' -t 'voice/server/status' -t 'home/#'
|
||||
|
||||
mqtt-home: ## Affiche seulement les commandes domotiques home/# (étape 11)
|
||||
$(MQTT_AS)
|
||||
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'home/#'
|
||||
|
||||
mqtt-transcript: ## Affiche seulement « qui a dit quoi » : voice/+/transcript (étape 10)
|
||||
$(MQTT_AS)
|
||||
@$(MOSQ_RUN) mosquitto_sub -h $(MQTT_HOST) $(MQTT_AUTH) -v -t 'voice/+/transcript'
|
||||
|
||||
mqtt-control: ## Publie MSG sur voice/esp32-01/control
|
||||
$(MQTT_AS)
|
||||
@$(MOSQ_RUN) mosquitto_pub -h $(MQTT_HOST) $(MQTT_AUTH) -q 1 -t voice/esp32-01/control -m '$(MSG)'
|
||||
|
||||
## ----------------------------------------------------------- environnement Python
|
||||
|
||||
venv: ## Crée l'environnement virtuel Python
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip setuptools wheel
|
||||
|
||||
install: venv ## Installe le serveur Python (dépendances de base)
|
||||
$(PIP) install -e server
|
||||
|
||||
install-dev: install pio-venv ## Installe en plus les outils de développement (pyright, pytest, platformio)
|
||||
$(PIP) install -e "server[dev]"
|
||||
|
||||
install-speech: install ## Installe faster-whisper et Piper (étape 6)
|
||||
$(PIP) install -e "server[speech]"
|
||||
|
||||
install-diarization: install-speech ## Installe pyannote et torch, version CPU (étape 8, ~1,3 Go)
|
||||
$(PIP) install --index-url https://download.pytorch.org/whl/cpu \
|
||||
--extra-index-url https://pypi.org/simple -e "server[diarization]"
|
||||
|
||||
install-wakeword: install-speech ## Installe openWakeWord, en mode ONNX (étape 13)
|
||||
$(PIP) install -e "server[wakeword]"
|
||||
# Sans dépendances : openwakeword exige tflite-runtime, absent pour Python 3.13 (inutile en ONNX).
|
||||
$(PIP) install --no-deps "openwakeword==0.6.0"
|
||||
|
||||
models: ## Télécharge les modèles de config.yaml dans server/models (étapes 6, 8 et 13)
|
||||
$(PY) tools/download_models.py
|
||||
|
||||
say: ## Fait dire [WAKE (voix anglaise) puis] TEXT par VOICE (SPEAKER) : WAV + MP3 dans SAY_OUT, pour un téléphone
|
||||
$(PY) tools/say.py "$(TEXT)" --voice $(VOICE) $(if $(SPEAKER),--speaker $(SPEAKER)) \
|
||||
$(if $(WAKE),--wake "$(WAKE)" --wake-voice $(WAKE_VOICE)) --out $(SAY_OUT)
|
||||
|
||||
speech-check: ## Synthèse, transcription et RÉPONSE de l'assistant à TEXT, sans ESP32 (étape 6)
|
||||
$(PY) tools/speech_check.py "$(TEXT)"
|
||||
|
||||
transcribe: ## VAD, transcription et réponse du WAV OUT, sans ESP32 (étapes 6-7)
|
||||
$(PY) tools/speech_check.py --wav $(OUT)
|
||||
|
||||
vad: ## Montre la parole trouvée par la VAD dans OUT, écrit la parole seule (étape 7)
|
||||
$(PY) tools/vad_check.py $(OUT)
|
||||
|
||||
vad-all: ## Passe la VAD sur tous les enregistrements du serveur (étape 7)
|
||||
$(PY) tools/vad_check.py server/recordings/*.wav
|
||||
|
||||
diarize: ## Qui parle quand dans OUT, qui est-ce, qui a dit quoi (étapes 8-10)
|
||||
$(PY) tools/diarize_check.py $(OUT)
|
||||
|
||||
diarize-demo: ## Diarise une séquence à deux voix : la vôtre et celle de Piper (étape 8)
|
||||
$(PY) tools/diarize_check.py --demo
|
||||
|
||||
enroll: ## Crée le profil vocal de NAME avec ses LAST dernières sessions (étape 9)
|
||||
@test -n "$(NAME)" || { echo "Usage : make enroll NAME=Denis [LAST=5]"; exit 1; }
|
||||
$(PY) tools/enroll.py --name "$(NAME)" --last $(LAST)
|
||||
|
||||
profiles: ## Liste les profils vocaux (étape 9)
|
||||
$(PY) tools/enroll.py --list
|
||||
|
||||
forget: ## Supprime le profil vocal de NAME : donnée biométrique (étape 9)
|
||||
@test -n "$(NAME)" || { echo "Usage : make forget NAME=Denis"; exit 1; }
|
||||
$(PY) tools/enroll.py --forget "$(NAME)"
|
||||
|
||||
## ----------------------------------------------------------------- serveur
|
||||
|
||||
run: ## Lance l'assistant vocal : répond à chaque session (étape 6, Ctrl-C pour arrêter)
|
||||
cd server && .venv/bin/python -m voice_server --config config.yaml
|
||||
|
||||
run-echo: ## Serveur en mode echo : chaque session est rejouee par l'ESP32 (etape 5)
|
||||
cd server && .venv/bin/python -m voice_server --config config.yaml --echo
|
||||
|
||||
test: ## Exécute les tests Python
|
||||
$(VENV)/bin/pytest server/tests -v
|
||||
|
||||
lint: ## Vérifie le typage Python avec pyright
|
||||
$(VENV)/bin/pyright --project server/pyrightconfig.json
|
||||
|
||||
## ----------------------------------------------------------------- outils
|
||||
|
||||
play: ## Joue OUT en cherchant un lecteur qui fonctionne
|
||||
@for player in paplay pw-play ffplay aplay; do \
|
||||
command -v $$player >/dev/null 2>&1 || continue; \
|
||||
echo "-> $$player $(OUT)"; \
|
||||
case $$player in \
|
||||
ffplay) ffplay -autoexit -nodisp -loglevel error "$(OUT)" && exit 0 ;; \
|
||||
*) $$player "$(OUT)" && exit 0 ;; \
|
||||
esac; \
|
||||
done; \
|
||||
echo "Aucun lecteur n'a abouti. Utilisez 'make inspect' pour verifier sans ecouter."; \
|
||||
exit 1
|
||||
|
||||
inspect: ## Analyse OUT sans le jouer : enveloppe ASCII et niveaux
|
||||
$(PY) tools/wav_inspect.py $(OUT)
|
||||
|
||||
play-esp: ## Joue OUT sur le haut-parleur de l'ESP32, au rythme reel (etape 2)
|
||||
$(PY) tools/wav_to_serial.py --port $(SERIAL_PORT) --baud $(BAUD) $(OUT)
|
||||
|
||||
tone-esp: ## Joue un son pur de TONE Hz sur le haut-parleur de l'ESP32 (etape 2)
|
||||
$(PY) tools/wav_to_serial.py --port $(SERIAL_PORT) --baud $(BAUD) --tone $(TONE) --seconds 3
|
||||
|
||||
play-mqtt: ## Joue OUT sur l'ESP32, par MQTT, avec bilan ; JITTER=ms simule un Wi-Fi irrégulier (étapes 5, 12)
|
||||
$(PY) tools/wav_to_mqtt.py --jitter $(JITTER) $(OUT)
|
||||
|
||||
tone-mqtt: ## Joue un son pur de TONE Hz sur l'ESP32, par MQTT ; JITTER=ms simule un Wi-Fi irrégulier (étapes 5, 12)
|
||||
$(PY) tools/wav_to_mqtt.py --tone $(TONE) --seconds 5 --jitter $(JITTER)
|
||||
|
||||
record: ## Enregistre une session depuis l'ESP32 vers un WAV (étape 1)
|
||||
@mkdir -p $$(dirname $(OUT))
|
||||
$(PY) tools/serial_to_wav.py --port $(SERIAL_PORT) --baud $(BAUD) --output $(OUT)
|
||||
|
||||
## ---------------------------------------------------------------- firmware
|
||||
|
||||
pio-venv: ## Installe PlatformIO dans son propre environnement virtuel
|
||||
python3 -m venv $(PIO_VENV)
|
||||
$(PIO_VENV)/bin/pip install --upgrade pip
|
||||
$(PIO_VENV)/bin/pip install platformio
|
||||
# click 8.3+ casse l'esptool 5.0 livré avec le core Arduino 3.x : on épingle 8.1.
|
||||
$(PIO_VENV)/bin/pip install "click==8.1.8"
|
||||
|
||||
fw-build: ## Compile le firmware ESP32
|
||||
@test -x $(PIO) || { echo "PlatformIO absent : lancez 'make pio-venv'."; exit 1; }
|
||||
$(PIO) run -d $(FW_DIR)
|
||||
|
||||
fw-check: ## Compile sans secrets.h personnel (secrets factices hors du dépôt)
|
||||
@test -x $(PIO) || { echo "PlatformIO absent : lancez 'make pio-venv'."; exit 1; }
|
||||
@if [ -f $(FW_DIR)/include/secrets.h ]; then \
|
||||
echo "secrets.h present : compilation normale"; $(PIO) run -d $(FW_DIR); \
|
||||
else \
|
||||
tmp=$$(mktemp -d); cp $(FW_DIR)/include/secrets.h.example $$tmp/secrets.h; \
|
||||
echo "secrets.h absent : secrets factices dans $$tmp (firmware/include/ n'est pas touche)"; \
|
||||
PLATFORMIO_BUILD_FLAGS="-I$$tmp" $(PIO) run -d $(FW_DIR); status=$$?; rm -rf $$tmp; exit $$status; \
|
||||
fi
|
||||
|
||||
fw-upload: ## Compile et téléverse le firmware
|
||||
$(PIO) run -d $(FW_DIR) -t upload --upload-port $(SERIAL_PORT)
|
||||
|
||||
fw-monitor: ## Ouvre le moniteur série (Ctrl-C pour quitter)
|
||||
$(PIO) device monitor -p $(SERIAL_PORT) -b $(BAUD)
|
||||
|
||||
## ------------------------------------------------------------------ nettoyage
|
||||
|
||||
clean: ## Supprime les artefacts de compilation et les caches
|
||||
rm -rf $(FW_DIR)/.pio
|
||||
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||
rm -rf server/*.egg-info .pytest_cache
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# Local voice assistant — ESP32 + Raspberry Pi
|
||||
|
||||
A **fully local** voice assistant, with no cloud service: an ESP32 captures
|
||||
speech, streams raw PCM over MQTT to a Python server that transcribes,
|
||||
interprets and answers with speech synthesis; the ESP32 plays the answer back
|
||||
and executes the command.
|
||||
|
||||
*Version française : [README.md](README.md).*
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
INMP441 -> ESP32 -> MQTT -> Python server (Raspberry Pi / PC)
|
||||
|
|
||||
+---------+---------+
|
||||
| |
|
||||
VAD Diarization /
|
||||
| identification
|
||||
+---------+---------+
|
||||
|
|
||||
faster-whisper
|
||||
|
|
||||
Assistant
|
||||
|
|
||||
Piper
|
||||
|
|
||||
MQTT audio/out
|
||||
|
|
||||
ESP32 -> MAX98357A -> speaker
|
||||
```
|
||||
|
||||
Four often-confused stages, never to be mixed up:
|
||||
|
||||
| Stage | Question it answers |
|
||||
|---|---|
|
||||
| VAD | Is someone speaking? |
|
||||
| Diarization | Who speaks when? (SPEAKER_00, SPEAKER_01…) |
|
||||
| Identification | Who is this person? (Denis, Marie, Unknown) |
|
||||
| STT | What is being said? |
|
||||
|
||||
> Voice identification is **probabilistic**. It is never an authentication or
|
||||
> security mechanism.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hardware
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| ESP32-WROOM-32 | two I2S peripherals: I2S0 as receiver, I2S1 as transmitter |
|
||||
| INMP441 | I2S microphone, **3.3 V**, L/R pin to GND (left channel) |
|
||||
| MAX98357A | mono class-D amplifier, **5 V**, SD tied to VIN, GAIN left floating (9 dB) |
|
||||
| Button | push-to-talk between GPIO 4 and GND (internal pull-up) |
|
||||
| LED | with a 330 Ω resistor on GPIO 2 |
|
||||
| Server | Raspberry Pi 4/5 64-bit or Debian PC |
|
||||
|
||||
### Wiring
|
||||
|
||||
**Full diagram: [docs/wiring.svg](docs/wiring.svg)** — detailed pinout, power
|
||||
budget and multimeter checks (in French): **[docs/BROCHAGE.md](docs/BROCHAGE.md)**.
|
||||
|
||||
```
|
||||
ESP32 INMP441 (microphone)
|
||||
3V3 ----------------------- VDD
|
||||
GND ----------------------- GND, L/R
|
||||
GPIO 25 (SCK) ------------- SCK
|
||||
GPIO 26 (WS) ------------- WS
|
||||
GPIO 33 (SD) ------------- SD
|
||||
|
||||
ESP32 MAX98357A (amplifier, step 2)
|
||||
5V (VIN) ------------------- VIN
|
||||
GND ----------------------- GND
|
||||
GPIO 27 (BCLK) ------------- BCLK
|
||||
GPIO 14 (LRC) ------------- LRC
|
||||
GPIO 22 (DIN) ------------- DIN
|
||||
|
||||
ESP32 User interface
|
||||
GPIO 4 ------ button ------ GND
|
||||
GPIO 2 --[330 Ω]-- LED ---- GND
|
||||
```
|
||||
|
||||
No strapping pin (GPIO 0, 2, 5, 12, 15) carries an audio signal: their level at
|
||||
reset selects the boot mode. GPIO 2 drives the LED as an output only, which does
|
||||
not affect booting.
|
||||
|
||||
---
|
||||
|
||||
## 3. Audio format, identical everywhere
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| Sample rate | 16 000 Hz |
|
||||
| Resolution | signed 16-bit, little-endian (`pcm_s16le`) |
|
||||
| Channels | 1 (mono) |
|
||||
| Bit rate | 32 000 bytes/s |
|
||||
| Chunk | 50 ms = 800 samples = **1 600 bytes** |
|
||||
|
||||
No Base64: MQTT payloads carry raw PCM bytes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation
|
||||
|
||||
```bash
|
||||
# 1. System packages (mosquitto, ffmpeg, venv) + serial port access
|
||||
make install-system
|
||||
# Log out and back in to activate the "dialout" group.
|
||||
|
||||
# 2. Python environment and dev tooling (pyright, pytest, platformio)
|
||||
make install-dev
|
||||
# PlatformIO gets its own venv (.pio-venv): its dependencies clash with the
|
||||
# voice server's.
|
||||
|
||||
# 3. Speech recognition and synthesis (step 6): faster-whisper, Piper, ~550 MB of models
|
||||
make install-speech
|
||||
make models
|
||||
|
||||
# 4. Diarization (step 8): pyannote + CPU torch (~1.3 GB), Hugging Face token required
|
||||
make install-diarization
|
||||
cp server/.env.example server/.env # then HF_TOKEN=... (see step 8)
|
||||
make models
|
||||
|
||||
# 5. Secrets (used from step 3 onwards)
|
||||
cp firmware/include/secrets.h.example firmware/include/secrets.h
|
||||
cp server/.env.example server/.env
|
||||
|
||||
# 6. Help
|
||||
make help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Usage
|
||||
|
||||
```bash
|
||||
make fw-build # build the firmware
|
||||
make fw-upload SERIAL_PORT=/dev/ttyUSB0
|
||||
make record OUT=recordings/test.wav # record one session (step 1)
|
||||
make play OUT=recordings/test.wav # play it back (picks a working player)
|
||||
make inspect OUT=recordings/test.wav # analyse it without listening
|
||||
make play-esp OUT=recordings/test.wav # play it on the ESP32 speaker (step 2)
|
||||
make tone-esp TONE=440 # pure tone on the ESP32 speaker (step 2)
|
||||
make run # assistant: answers every question (step 6)
|
||||
make speech-check TEXT="Quelle heure est-il ?" # recognition and synthesis, no ESP32
|
||||
make say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 for a phone
|
||||
make transcribe OUT=server/recordings/<file>.wav
|
||||
make vad OUT=server/recordings/<file>.wav # speech found by the VAD (step 7)
|
||||
make vad-all # VAD over every server recording
|
||||
make diarize OUT=server/recordings/<file>.wav # who speaks when, who, who said what (steps 8-10)
|
||||
make diarize-demo # two voices: yours and Piper's
|
||||
make enroll NAME=Denis # voice profile from the last 5 sessions (step 9)
|
||||
make profiles # list profiles; make forget NAME=Denis deletes one
|
||||
make mqtt-watch # text messages voice/… and home/# (no audio)
|
||||
make mqtt-user NAME=esp32-01 # MQTT account for a board, or the server (step 15)
|
||||
make agent-run # Linux agent of this PC, test mode (step 15b)
|
||||
make mqtt-agents # agent states, commands and reports
|
||||
make fw-monitor # serial monitor
|
||||
make test # Python tests
|
||||
make lint # pyright type check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Progress
|
||||
|
||||
The exact state of the project, step by step, lives in **[CONTEXT.md](CONTEXT.md)**.
|
||||
|
||||
Current step: **15b — Linux machines on the network**. Steps 1 to 15 are validated.
|
||||
|
||||
### Step 15b — Linux machines on the network
|
||||
|
||||
"Hey Mycroft, éteins le PC du bureau": each machine runs an **agent**
|
||||
([agent/voice_agent.py](agent/voice_agent.py)), a small MQTT client that only runs
|
||||
the actions in **its own** list (`/etc/voice-agent/agent.yaml`): fixed commands,
|
||||
no shell. The server only sends an action name. "Allume" wakes a machine that is
|
||||
off through **Wake-on-LAN** (a network packet received by the sleeping network card).
|
||||
|
||||
| Action | Words | Rights (config.yaml) |
|
||||
|---|---|---|
|
||||
| `wake` (server) | allume, démarre, réveille | standard |
|
||||
| `lock` | verrouille | standard |
|
||||
| `shutdown` | éteins, arrête | full + confirmation |
|
||||
| `reboot` | redémarre, relance | full + confirmation |
|
||||
|
||||
1. Try it on this PC without shutting anything down: `make agent-run` (creates `agent/agent.yaml` in test mode).
|
||||
2. `make run`, then "Hey Mycroft, éteins le PC du bureau" → confirmation → "oui":
|
||||
the agent prints `essai : systemctl poweroff (non execute)`.
|
||||
3. Real machine: copy `agent/` to it, `make -C agent install`, edit
|
||||
`/etc/voice-agent/agent.yaml`, and name it in `server/config.yaml` (`machines.list`).
|
||||
|
||||
**Anonymous broker**: any device on the network can then publish on `agent/<machine>/command`.
|
||||
In production, use the accounts and rights of step 15 (`mosquitto/acl` has the agent rules).
|
||||
|
||||
### Step 15 — reliability
|
||||
|
||||
| What | How |
|
||||
|---|---|
|
||||
| MQTT accounts and rights | no anonymous access; a board reads and writes **its own** topics only ([docs/MOSQUITTO.md](docs/MOSQUITTO.md) §2) |
|
||||
| Server presence | `voice/server/status`: retained `online`, `offline` last will; server gone = board microphones off |
|
||||
| Watchdog | board frozen for more than 15 s → reboot, reason `watchdog` in the `boot` event |
|
||||
| Network | TCP connection to the broker within 1 s; Wi-Fi restarted after 30 s without it |
|
||||
| Log | copied to `server/logs/voice-server.log` (rotating files, 4 MB at most) |
|
||||
|
||||
Setup, **in this order**: a broker that is still open already accepts clients that have an account.
|
||||
|
||||
1. `make mqtt-user NAME=voice-server`, then `make mqtt-user NAME=esp32-01` (passwords: `openssl rand -hex 16`).
|
||||
2. `server/.env`: `MQTT_USERNAME=voice-server`, `MQTT_PASSWORD=...`; `secrets.h`: `MQTT_PASSWORD="..."`.
|
||||
3. `make fw-upload` and `make run`: everything works as before, the server announces its presence.
|
||||
4. `make mosquitto-docker`: the broker now requires the accounts.
|
||||
|
||||
### Step 14 — per-profile rights
|
||||
|
||||
| Level | Rights | Given to |
|
||||
|---|---|---|
|
||||
| **complet** (full) | everything; sensitive actions (open the door) after a "oui" **confirmation** | profiles listed in `access.users`, **if the voice is clearly recognised** (score ≥ 0.6) |
|
||||
| **standard** | lights, shutters, closing the door | enrolled profile without a level (`access.default`) |
|
||||
| **limite** (limited) | conversation only | unknown voice (`access.unknown`) |
|
||||
|
||||
Rules per device and action in `config.yaml` (section `access`); an action missing from the
|
||||
rules requires the full level. Names in `access.users` are those shown by `make profiles`.
|
||||
|
||||
1. `make run` (server only, firmware unchanged).
|
||||
2. "Hey Mycroft, allume la lumière" → executed (Denis, full).
|
||||
3. "Hey Mycroft, ouvre la porte" → "Confirmez-vous : ouvrir la porte du salon ?" →
|
||||
"Hey Mycroft, oui" → "J'ouvre la porte du salon."; "non" → "D'accord, j'annule."
|
||||
4. A phone voice (standard profile): "ouvre la porte" → refused; a non-enrolled voice:
|
||||
"allume la lumière" → "Je ne vous ai pas reconnu…". Ready-made sentences:
|
||||
`recordings/voix-test/tom-porte.mp3`, `pierre-lumiere.mp3`; more with `make say`. The wake word is spoken by an English
|
||||
voice (`WAKE`): a synthetic voice saying it the French way is not recognised.
|
||||
|
||||
**Reminder**: a voice is never proof of identity. These rights prevent mistakes and guests'
|
||||
commands, not an attack (recording, impersonation).
|
||||
|
||||
### Step 13 — wake word (hands-free)
|
||||
|
||||
The server asks the board to stream its microphone continuously (`voice/<board>/audio/stream`)
|
||||
and listens for the wake word with openWakeWord: **"Alexa"**, **"Hey Mycroft"** or **"Hey
|
||||
Jarvis"**, pronounced the English way. Once heard, it keeps the command until the final
|
||||
silence, then handles it like a push-to-talk session. The button still works at any time.
|
||||
|
||||
LED: **short flash every 2 s** = continuous listening; **on** = wake word heard, command in
|
||||
progress; slow blink = ready, no continuous listening.
|
||||
|
||||
1. `make install-wakeword` then `make models` (5 ONNX models, 5 MB).
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 13 firmware), then `make run`.
|
||||
3. "Alexa, allume la lumière" → LED on during the command, then answer and command.
|
||||
4. A sentence without the wake word → nothing happens, nothing is recorded.
|
||||
5. "Alexa" alone, then silence → back to standby after 4 s.
|
||||
|
||||
**Privacy**: before the wake word, only the last 500 ms are kept in memory, nothing is
|
||||
written to disk (checked by a test). But audio flows continuously over the network: as long
|
||||
as the broker accepts anonymous clients (step 15), anyone on the LAN can listen to it.
|
||||
Turn listening off: `wake.enabled: false`, or
|
||||
`make mqtt-control MSG='{"cmd":"listen","enabled":false}'`.
|
||||
|
||||
### Step 12 — anti-jitter output buffer
|
||||
|
||||
The board plays nothing until it holds **300 ms of audio in reserve** (priming); the
|
||||
Wi-Fi may then deliver chunks late or in bursts without it being heard. If the reserve
|
||||
runs dry, playback stops cleanly and resumes once the reserve is rebuilt (re-priming)
|
||||
instead of stuttering. The `played` report gives the delay to the first sample, the
|
||||
**minimum margin** (network delay still absorbable) and the number of re-primings.
|
||||
|
||||
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 12 firmware).
|
||||
2. `make tone-mqtt`: expected `reamorcages 0`, minimum margin ≈ 250 ms.
|
||||
3. `make tone-mqtt JITTER=200`: a 200 ms stall every 500 ms; sound still continuous,
|
||||
`reamorcages 0`, minimum margin ≈ 100 ms.
|
||||
4. `make tone-mqtt JITTER=400`: beyond the reserve; clean pauses (re-primings > 0),
|
||||
never any crackling.
|
||||
5. `make run`: assistant answers, with `reamorcages 0` at the usual signal level.
|
||||
|
||||
### Step 11 — home automation
|
||||
|
||||
A command is three words: an **action** (allumer, éteindre, ouvrir, fermer), a **device**
|
||||
(lumière, volets, porte) and a **room** (listed in `config.yaml`, section `home`). Without a
|
||||
room, the board's own room is used. The server publishes `home/<room>/<device>/set` for any
|
||||
home automation system; when the room is the board's, the board also executes it: demo LED
|
||||
on GPIO 21 for the light, simulated shutters and door (serial log).
|
||||
|
||||
1. Wire the LED (optional): GPIO 21 → 330 Ω → LED → GND ([docs/BROCHAGE.md](docs/BROCHAGE.md) §10).
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 11 firmware), then `make run`.
|
||||
3. In another terminal: `make mqtt-watch` (voice/… as text and home/#).
|
||||
4. "Allume la lumière" → "J'allume la lumière du salon.", LED on, `home/salon/light/set`
|
||||
and the confirmation `{"event":"device",...,"ok":true}`.
|
||||
5. "Ferme les volets de la cuisine" → `home/cuisine/shutter/set` only (another room).
|
||||
6. "Allume les volets" → "Je ne sais pas allumer les volets."; "Ouvre" → "Que dois-je ouvrir ?".
|
||||
|
||||
**Security**: voice identification is never proof. Do not connect a door strike or an
|
||||
alarm to these commands; per-profile rights come in step 14.
|
||||
|
||||
### Step 10 — who said what
|
||||
|
||||
Whisper gives the time of each word, the diarization the time of each speaker turn, on
|
||||
the same audio: each word goes to whoever was speaking at that moment. The server
|
||||
publishes the result on `voice/<board>/transcript` and the assistant calls the person by name.
|
||||
|
||||
```json
|
||||
{"session": "453cce", "utterances": [
|
||||
{"speaker": "Denis", "text": "Ouvrir la porte.", "start": 0.0, "end": 1.26, "score": 0.6},
|
||||
{"speaker": "Siwis", "text": "Je suis la voix de synthèse...", "start": 1.5, "end": 4.46, "score": 0.6},
|
||||
{"speaker": "Denis", "text": "Fermez la porte.", "start": 4.92, "end": 5.86, "score": 0.6}]}
|
||||
```
|
||||
|
||||
1. `make diarize OUT=server/recordings/<file>.wav`: who said what, and the MQTT message, no board needed.
|
||||
2. `make mqtt-watch` in one terminal, `make run` in another.
|
||||
3. "Bonjour": the board answers "Bonjour Denis !"; `voice/esp32-01/transcript` shows up.
|
||||
4. A two-voice session (you, a phone voice, you again): three utterances.
|
||||
|
||||
### Step 9 — speaker identification
|
||||
|
||||
Each voice found by the diarization is summarised by an **embedding** (256 numbers,
|
||||
WeSpeaker model from the pyannote pipeline), compared by **cosine similarity** with the
|
||||
enrolled profiles. Above the threshold (0.45, measured on this bench), the name;
|
||||
otherwise "Inconnu". **It is an estimate, never a security feature**: a recording or an
|
||||
impersonation can fool it.
|
||||
|
||||
1. `make run`, then 5 varied sentences of 1 to 4 s, alone, at a normal distance.
|
||||
2. `make enroll NAME=Denis`: profile built from those 5 sessions; sessions that are too
|
||||
short, contain several voices or do not match the others are dropped.
|
||||
3. New questions (the server picks up the profile without restarting): the log shows
|
||||
`SPEAKER_00 = Denis (0.74)`; another person or a loudspeaker voice gives
|
||||
`Inconnu (0.05, proche de Denis)`.
|
||||
4. `make diarize-demo`: your voice recognised, Piper's voice "Inconnu".
|
||||
|
||||
Profiles are **biometric data**: `server/profiles/`, never versioned, one readable JSON
|
||||
file per person; `make forget NAME=...` deletes it.
|
||||
|
||||
### Step 8 — diarization: who speaks when
|
||||
|
||||
The speech found by the VAD is split into speaker turns: `SPEAKER_00` from 0.0 to 1.3 s,
|
||||
`SPEAKER_01` from 1.4 to 2.9 s... Labels only hold within one session: putting a name on
|
||||
a voice is step 9. Model `pyannote/speaker-diarization-community-1` (33 MB), run on CPU,
|
||||
**pyannote telemetry disabled** (enabled by default in version 4).
|
||||
|
||||
1. Create an account on <https://huggingface.co>, then **accept the conditions** at
|
||||
<https://huggingface.co/pyannote/speaker-diarization-community-1>.
|
||||
2. Create a "Read" token at <https://huggingface.co/settings/tokens> and put it in
|
||||
`server/.env`: `HF_TOKEN=hf_...` (`cp server/.env.example server/.env` if missing).
|
||||
3. `make install-diarization` then `make models`. No Internet access afterwards.
|
||||
4. `make diarize-demo`: your voice, Piper's voice, your voice → two speakers expected.
|
||||
5. `make run`: one session where two people take turns; the log shows
|
||||
`2 locuteur(s) : SPEAKER_00 0.00-1.30, SPEAKER_01 1.40-2.90`.
|
||||
|
||||
Too heavy for a Raspberry Pi 4: set `diarization.enabled: false` in `config.yaml`.
|
||||
|
||||
### Step 7 — voice activity detection (VAD)
|
||||
|
||||
The server now transcribes speech only: leading and trailing silence is removed,
|
||||
sessions without speech (silent press, click) are dropped without calling Whisper.
|
||||
Silero ships with faster-whisper: nothing more to install.
|
||||
|
||||
1. `make vad-all`: one character per 32 ms window (`#` speech, `_` silence) for
|
||||
each recording, plus the number of sessions without speech.
|
||||
2. `make vad OUT=server/recordings/<file>.wav` then `make play OUT=recordings/parole.wav`:
|
||||
speech only, silences removed.
|
||||
3. `make run` (restart it: the code changed), ask a few questions and press once
|
||||
without speaking. The log shows `parole 1.36 s sur 2.00 s (segments 0.49-1.85 s)`,
|
||||
and `sans parole (VAD)` for the silent press, answered immediately.
|
||||
|
||||
Settings in `config.yaml`, section `vad`: threshold, minimum pause, minimum speech, padding.
|
||||
|
||||
### Step 6 — MVP: the assistant answers
|
||||
|
||||
1. `make install-speech` then `make models` (faster-whisper `small`, Piper voice
|
||||
`fr_FR-siwis-medium`, about 550 MB in `server/models/`, once).
|
||||
2. `make speech-check`: Piper says "Quelle heure est-il ?", Whisper reads it
|
||||
back, the assistant answers; no hardware needed.
|
||||
3. `make transcribe OUT=server/recordings/<file>.wav`: transcribes a sentence
|
||||
recorded by the ESP32 in step 4.
|
||||
4. `make run`: press the button, ask "Quelle heure est-il ?", release; the board
|
||||
answers. Recognised questions: time, date, hello, thanks, your name; anything
|
||||
else is repeated back as understood.
|
||||
|
||||
The log shows the time spent in each stage: `transcription 1.0 s, synthese
|
||||
0.1 s, reponse prete en 1.1 s`. `make run-echo` keeps the step 5 echo mode,
|
||||
without models.
|
||||
|
||||
### Step 5 — the server makes the ESP32 speak, over MQTT
|
||||
|
||||
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (step 5 firmware).
|
||||
2. `make tone-mqtt`: 3 s of pure tone sent over MQTT; the board report is printed
|
||||
(`chunks 60/60, underruns 0`).
|
||||
3. `make play-mqtt OUT=server/recordings/<file>.wav`: any WAV, converted to
|
||||
16 kHz mono, peak at −3 dBFS.
|
||||
4. `make run-echo`: the server plays each sentence back on the board that said it.
|
||||
|
||||
### Step 4 — voice to the server, over MQTT
|
||||
|
||||
1. `make install` (server dependencies: paho-mqtt, PyYAML, numpy...).
|
||||
2. `make run`: the server connects to the broker and waits for sessions.
|
||||
3. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`, then press the button, speak, release.
|
||||
4. The server writes `server/recordings/esp32-01_<date>_<session>.wav` and logs a
|
||||
report: `complete`, received / announced chunks, peak and RMS.
|
||||
5. `make play OUT=server/recordings/<file>.wav` or `make inspect OUT=...`.
|
||||
|
||||
If the broker is unreachable when the button is pressed, the session goes over
|
||||
the serial link: `make record` remains available for troubleshooting.
|
||||
|
||||
### Step 3 — Wi-Fi and MQTT
|
||||
|
||||
1. Broker: see [docs/MOSQUITTO.md](docs/MOSQUITTO.md) (Debian service or container).
|
||||
2. `cp firmware/include/secrets.h.example firmware/include/secrets.h`, then set
|
||||
the Wi-Fi (2.4 GHz) and the broker IP address.
|
||||
3. `make mqtt-watch MQTT_HOST=<broker ip>` in a first terminal.
|
||||
4. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` in a second one.
|
||||
|
||||
Expected in the first terminal: `voice/esp32-01/state {"status":"online",...}`
|
||||
and `voice/esp32-01/event {"event":"boot",...}`. After cutting the ESP32 power:
|
||||
`voice/esp32-01/state {"status":"offline"}` within 15 s.
|
||||
|
||||
### Step 2 — test the audio output
|
||||
|
||||
1. Wire the MAX98357A amplifier and the speaker (§2), including C1 and C2.
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
|
||||
3. At boot, a **440 Hz beep** plays: it tests the output on its own.
|
||||
4. `make tone-esp`: 3 s of pure tone streamed from the PC in real time. Any
|
||||
dropout is heard as a click.
|
||||
5. `make play-esp OUT=recordings/test.wav`: the step 1 recording played back on
|
||||
the ESP32 speaker.
|
||||
|
||||
Expected: clean tone, no clicks or crackle; the ESP32 report shows `0 refuses`
|
||||
(rejected) and `0 sous-alimentations` (underruns).
|
||||
|
||||
### Step 1 — test the audio input
|
||||
|
||||
1. Wire microphone, button and LED as in §2.
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
|
||||
3. `make record OUT=recordings/test.wav`
|
||||
4. Press the button, say a sentence, release it.
|
||||
5. Listen: `make play OUT=recordings/test.wav`
|
||||
6. With no audio output: `make inspect OUT=recordings/test.wav` draws the signal
|
||||
envelope in ASCII and reports peak, RMS and DC offset.
|
||||
|
||||
Expected: the sentence is intelligible and not clipped; the script reports a peak
|
||||
between 10 % and 90 % of full scale. Above 99 %, lower `MIC_GAIN` in
|
||||
[firmware/include/config.h](firmware/include/config.h); below 2 %, raise it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Repository layout
|
||||
|
||||
```
|
||||
voice-assistant/
|
||||
├── firmware/ ESP32 firmware (PlatformIO, Arduino)
|
||||
│ ├── include/ config.h, secrets.h.example
|
||||
│ └── src/ main.cpp + one .h/.cpp pair per class
|
||||
├── server/ Python server (one module per class)
|
||||
├── tools/ standalone test tools
|
||||
├── mosquitto/ voice.conf
|
||||
├── Makefile every system operation
|
||||
├── CONTEXT.md progress log and frozen technical decisions
|
||||
└── README.md / README-EN.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,461 @@
|
||||
# Assistant vocal local — ESP32 + Raspberry Pi
|
||||
|
||||
Assistant vocal **100 % local**, sans aucun service cloud : un ESP32 capture la
|
||||
voix, l'envoie en PCM brut par MQTT à un serveur Python qui transcrit, interprète
|
||||
et répond en synthèse vocale ; l'ESP32 joue la réponse et exécute la commande.
|
||||
|
||||
*English version: [README-EN.md](README-EN.md).*
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
INMP441 -> ESP32 -> MQTT -> Serveur Python (Raspberry Pi / PC)
|
||||
|
|
||||
+---------+---------+
|
||||
| |
|
||||
VAD Diarisation /
|
||||
| identification
|
||||
+---------+---------+
|
||||
|
|
||||
faster-whisper
|
||||
|
|
||||
Assistant
|
||||
|
|
||||
Piper
|
||||
|
|
||||
MQTT audio/out
|
||||
|
|
||||
ESP32 -> MAX98357A -> haut-parleur
|
||||
```
|
||||
|
||||
Quatre traitements souvent confondus, à ne jamais mélanger :
|
||||
|
||||
| Bloc | Question |
|
||||
|---|---|
|
||||
| VAD | Est-ce que quelqu'un parle ? |
|
||||
| Diarisation | Qui parle à quel moment ? (SPEAKER_00, SPEAKER_01…) |
|
||||
| Identification | Qui est cette personne ? (Denis, Marie, Inconnu) |
|
||||
| STT | Qu'est-ce qui est dit ? |
|
||||
|
||||
> L'identification vocale est **probabiliste**. Ce n'est jamais un mécanisme
|
||||
> d'authentification ni de sécurité.
|
||||
|
||||
---
|
||||
|
||||
## 2. Matériel
|
||||
|
||||
| Élément | Détail |
|
||||
|---|---|
|
||||
| ESP32-WROOM-32 | deux périphériques I2S : I2S0 en réception, I2S1 en émission |
|
||||
| INMP441 | micro I2S, **3,3 V**, broche L/R à GND (canal gauche) |
|
||||
| MAX98357A | ampli classe D mono, **5 V**, SD à VIN, GAIN laissé libre (9 dB) |
|
||||
| Bouton | Push-To-Talk entre GPIO 4 et GND (pull-up interne) |
|
||||
| LED | + résistance 330 Ω sur GPIO 2 |
|
||||
| Serveur | Raspberry Pi 4/5 64 bits ou PC Debian |
|
||||
|
||||
### Câblage
|
||||
|
||||
**Schéma complet : [docs/wiring.svg](docs/wiring.svg)** — brochage détaillé,
|
||||
bilan de consommation et vérifications au multimètre : **[docs/BROCHAGE.md](docs/BROCHAGE.md)**.
|
||||
|
||||
```
|
||||
ESP32 INMP441 (micro)
|
||||
3V3 ----------------------- VDD
|
||||
GND ----------------------- GND, L/R
|
||||
GPIO 25 (SCK) ------------- SCK
|
||||
GPIO 26 (WS) ------------- WS
|
||||
GPIO 33 (SD) ------------- SD
|
||||
|
||||
ESP32 MAX98357A (ampli, étape 2)
|
||||
5V (VIN) ------------------- VIN
|
||||
GND ----------------------- GND
|
||||
GPIO 27 (BCLK) ------------- BCLK
|
||||
GPIO 14 (LRC) ------------- LRC
|
||||
GPIO 22 (DIN) ------------- DIN
|
||||
|
||||
ESP32 Interface
|
||||
GPIO 4 ------ bouton ------ GND
|
||||
GPIO 2 --[330 Ω]-- LED ---- GND
|
||||
```
|
||||
|
||||
Aucune broche de strapping (GPIO 0, 2, 5, 12, 15) n'est utilisée pour l'audio :
|
||||
leur niveau au démarrage change le mode de boot de l'ESP32. GPIO 2 est utilisé en
|
||||
sortie seule pour la LED, ce qui reste sans effet sur le boot.
|
||||
|
||||
---
|
||||
|
||||
## 3. Format audio, unique dans tout le système
|
||||
|
||||
| Paramètre | Valeur |
|
||||
|---|---|
|
||||
| Fréquence | 16 000 Hz |
|
||||
| Résolution | 16 bits signés, little-endian (`pcm_s16le`) |
|
||||
| Canaux | 1 (mono) |
|
||||
| Débit | 32 000 octets/s |
|
||||
| Chunk | 50 ms = 800 échantillons = **1 600 octets** |
|
||||
|
||||
Pas de Base64 : les payloads MQTT contiennent des octets PCM bruts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation
|
||||
|
||||
```bash
|
||||
# 1. Paquets système (mosquitto, ffmpeg, venv) + accès au port série
|
||||
make install-system
|
||||
# Se déconnecter/reconnecter pour activer le groupe « dialout ».
|
||||
|
||||
# 2. Environnement Python et outils de développement (pyright, pytest, platformio)
|
||||
make install-dev
|
||||
# PlatformIO est installé dans son propre venv (.pio-venv) : ses dépendances
|
||||
# entrent en conflit avec celles du serveur vocal.
|
||||
|
||||
# 3. Transcription et synthèse vocale (étape 6) : faster-whisper, Piper, ~550 Mo de modèles
|
||||
make install-speech
|
||||
make models
|
||||
|
||||
# 4. Diarisation (étape 8) : pyannote + torch CPU (~1,3 Go), jeton Hugging Face requis
|
||||
make install-diarization
|
||||
cp server/.env.example server/.env # puis HF_TOKEN=... (voir étape 8)
|
||||
make models
|
||||
|
||||
# 5. Secrets (utilisés à partir de l'étape 3)
|
||||
cp firmware/include/secrets.h.example firmware/include/secrets.h
|
||||
cp server/.env.example server/.env
|
||||
|
||||
# 6. Aide
|
||||
make help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Utilisation
|
||||
|
||||
```bash
|
||||
make fw-build # compile le firmware
|
||||
make fw-upload SERIAL_PORT=/dev/ttyUSB0
|
||||
make record OUT=recordings/essai.wav # enregistre une session (étape 1)
|
||||
make play OUT=recordings/essai.wav # joue le WAV (cherche un lecteur qui marche)
|
||||
make inspect OUT=recordings/essai.wav # analyse le WAV sans l'écouter
|
||||
make play-esp OUT=recordings/essai.wav # joue le WAV sur le haut-parleur de l'ESP32 (étape 2)
|
||||
make tone-esp TONE=440 # son pur sur le haut-parleur de l'ESP32 (étape 2)
|
||||
make run # assistant : répond à chaque question (étape 6)
|
||||
make speech-check TEXT="Quelle heure est-il ?" # transcription et synthèse, sans ESP32
|
||||
make say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 pour un téléphone
|
||||
make transcribe OUT=server/recordings/<fichier>.wav
|
||||
make vad OUT=server/recordings/<fichier>.wav # parole trouvée par la VAD (étape 7)
|
||||
make vad-all # VAD sur tous les enregistrements du serveur
|
||||
make diarize OUT=server/recordings/<fichier>.wav # qui parle quand, qui, qui a dit quoi (étapes 8-10)
|
||||
make diarize-demo # deux voix : la vôtre et celle de Piper
|
||||
make enroll NAME=Denis # profil vocal depuis les 5 dernières sessions (étape 9)
|
||||
make profiles # liste des profils ; make forget NAME=Denis pour en supprimer un
|
||||
make mqtt-watch # messages texte voice/… et home/# (sans l'audio)
|
||||
make mqtt-user NAME=esp32-01 # compte MQTT d'une carte, ou du serveur (étape 15)
|
||||
make agent-run # agent Linux de ce PC, en mode essai (étape 15b)
|
||||
make mqtt-agents # états, commandes et comptes rendus des agents
|
||||
make fw-monitor # moniteur série
|
||||
make test # tests Python
|
||||
make lint # typage pyright
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Avancement
|
||||
|
||||
L'état exact du projet, étape par étape, est dans **[CONTEXT.md](CONTEXT.md)**.
|
||||
|
||||
Étape courante : **15b — machines Linux du réseau**. Les étapes 1 à 15 sont validées.
|
||||
|
||||
### Étape 15b — machines Linux du réseau
|
||||
|
||||
« Hey Mycroft, éteins le PC du bureau » : chaque machine fait tourner un **agent**
|
||||
([agent/voice_agent.py](agent/voice_agent.py)), petit client MQTT qui n'exécute que
|
||||
les actions de **sa** liste (`/etc/voice-agent/agent.yaml`), commandes fixes, sans
|
||||
shell. Le serveur n'envoie qu'un nom d'action. « Allume » réveille une machine
|
||||
éteinte par **Wake-on-LAN** (paquet réseau reçu par la carte réseau en veille).
|
||||
|
||||
| Action | Mots | Droits (config.yaml) |
|
||||
|---|---|---|
|
||||
| `wake` (serveur) | allume, démarre, réveille | standard |
|
||||
| `lock` | verrouille | standard |
|
||||
| `shutdown` | éteins, arrête | complet + confirmation |
|
||||
| `reboot` | redémarre, relance | complet + confirmation |
|
||||
|
||||
1. Essai sur ce PC, sans rien éteindre : `make agent-run` (crée `agent/agent.yaml` en mode essai).
|
||||
2. `make run`, puis « Hey Mycroft, éteins le PC du bureau » → confirmation → « oui » :
|
||||
l'agent affiche `essai : systemctl poweroff (non execute)`.
|
||||
3. Machine réelle : copier `agent/` dessus, `make -C agent install`, régler
|
||||
`/etc/voice-agent/agent.yaml`, et la nommer dans `server/config.yaml` (`machines.list`).
|
||||
|
||||
**Broker anonyme** : tout appareil du réseau peut alors publier sur `agent/<machine>/command`.
|
||||
En production, les comptes et droits de l'étape 15 (`mosquitto/acl` contient les règles des agents).
|
||||
|
||||
### Étape 15 — fiabilisation
|
||||
|
||||
| Quoi | Comment |
|
||||
|---|---|
|
||||
| Comptes MQTT et droits | broker sans accès anonyme ; une carte ne lit et n'écrit que **ses** topics ([docs/MOSQUITTO.md](docs/MOSQUITTO.md) §2) |
|
||||
| Présence du serveur | `voice/server/status` : `online` retenu, testament `offline` ; serveur absent = micros des cartes coupés |
|
||||
| Chien de garde (*watchdog*) | carte figée plus de 15 s → redémarrage, cause `watchdog` dans l'événement `boot` |
|
||||
| Réseau | connexion TCP au broker en 1 s au plus ; Wi-Fi relancé après 30 s d'absence |
|
||||
| Journal | copie dans `server/logs/voice-server.log` (fichiers tournants, 4 Mo au plus) |
|
||||
|
||||
Mise en place, **dans cet ordre** : un broker encore ouvert accepte déjà les clients qui ont un compte.
|
||||
|
||||
1. `make mqtt-user NAME=voice-server`, puis `make mqtt-user NAME=esp32-01` (mots de passe : `openssl rand -hex 16`).
|
||||
2. `server/.env` : `MQTT_USERNAME=voice-server`, `MQTT_PASSWORD=...` ; `secrets.h` : `MQTT_PASSWORD="..."`.
|
||||
3. `make fw-upload` et `make run` : tout marche comme avant, le serveur annonce sa présence.
|
||||
4. `make mosquitto-docker` : le broker exige désormais les comptes.
|
||||
|
||||
### Étape 14 — droits par profil
|
||||
|
||||
| Niveau | Droits | Attribué à |
|
||||
|---|---|---|
|
||||
| **complet** | tout ; actions sensibles (ouvrir la porte) après **confirmation** « oui » | profils listés dans `access.users`, **si la voix est reconnue nettement** (score ≥ 0,6) |
|
||||
| **standard** | lumières, volets, fermer la porte | profil enrôlé sans niveau (`access.default`) |
|
||||
| **limite** | conversation seulement | voix inconnue (`access.unknown`) |
|
||||
|
||||
Règles par appareil et action dans `config.yaml` (section `access`) ; une action absente des
|
||||
règles exige le niveau complet. Les noms de `access.users` sont ceux de `make profiles`.
|
||||
|
||||
1. `make run` (serveur seul, firmware inchangé).
|
||||
2. « Hey Mycroft, allume la lumière » → exécuté (Denis, complet).
|
||||
3. « Hey Mycroft, ouvre la porte » → « Confirmez-vous : ouvrir la porte du salon ? » →
|
||||
« Hey Mycroft, oui » → « J'ouvre la porte du salon. » ; « non » → « D'accord, j'annule. »
|
||||
4. Une voix du téléphone (profil standard) : « ouvre la porte » → refus ; une voix non
|
||||
enrôlée : « allume la lumière » → « Je ne vous ai pas reconnu… ». Phrases prêtes :
|
||||
`recordings/voix-test/tom-porte.mp3`, `pierre-lumiere.mp3` ; d'autres avec `make say`. Le mot de réveil y est dit par une
|
||||
voix anglaise (`WAKE`) : prononcé à la française par une voix de synthèse, il n'est pas reconnu.
|
||||
|
||||
**Rappel** : la voix n'est jamais une preuve d'identité. Ces droits évitent les erreurs et les
|
||||
commandes d'un invité, pas une attaque (enregistrement, imitation).
|
||||
|
||||
### Étape 13 — mot de réveil (mains libres)
|
||||
|
||||
Le serveur demande à la carte d'envoyer son micro en continu (`voice/<carte>/audio/stream`)
|
||||
et guette le mot de réveil avec openWakeWord : **« Alexa »**, **« Hé Maïcrofte »** (Hey
|
||||
Mycroft) ou **« Hey Djâr-viss »** (Hey Jarvis), prononcés à l'anglaise. Une fois le mot
|
||||
entendu, il garde la commande jusqu'au silence final, puis la traite comme une session du
|
||||
bouton. Le bouton reste utilisable à tout moment.
|
||||
|
||||
LED : **éclair bref toutes les 2 s** = écoute continue ; **allumée** = mot entendu, commande
|
||||
en cours ; clignotement lent = prêt, sans écoute continue.
|
||||
|
||||
1. `make install-wakeword` puis `make models` (5 modèles ONNX, 5 Mo).
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 13), puis `make run`.
|
||||
3. « Alexa, allume la lumière » → LED allumée pendant la commande, puis réponse et commande.
|
||||
4. Une phrase sans le mot → rien ne se passe, rien n'est enregistré.
|
||||
5. « Alexa » seul, puis rien → retour en veille après 4 s.
|
||||
|
||||
**Vie privée** : avant le mot, seules les 500 dernières ms restent en mémoire, rien n'est
|
||||
écrit sur disque (vérifié par un test). Mais le son circule en continu sur le réseau : tant
|
||||
que le broker accepte les clients anonymes (étape 15), n'importe qui sur le réseau local peut
|
||||
l'écouter. Couper l'écoute : `wake.enabled: false`, ou
|
||||
`make mqtt-control MSG='{"cmd":"listen","enabled":false}'`.
|
||||
|
||||
### Étape 12 — tampon de sortie anti-gigue
|
||||
|
||||
La carte ne joue rien tant qu'elle n'a pas **300 ms de son en réserve** (amorçage) ; le
|
||||
Wi-Fi peut alors livrer les chunks en retard ou en rafale sans que cela s'entende. Si la
|
||||
réserve s'épuise, la lecture s'arrête proprement et reprend une fois la réserve refaite
|
||||
(réamorçage), au lieu de hacher le son. Le bilan `played` donne le délai avant le premier
|
||||
son, la **marge minimale** (retard encore absorbable) et le nombre de réamorçages.
|
||||
|
||||
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 12).
|
||||
2. `make tone-mqtt` : attendu `reamorcages 0`, marge mini ≈ 250 ms.
|
||||
3. `make tone-mqtt JITTER=200` : un blocage de 200 ms toutes les 500 ms ; son toujours
|
||||
continu, `reamorcages 0`, marge mini ≈ 100 ms.
|
||||
4. `make tone-mqtt JITTER=400` : au-delà de la réserve ; pauses franches (réamorçages > 0),
|
||||
jamais de grésillement.
|
||||
5. `make run` : les réponses de l'assistant, avec `reamorcages 0` au signal habituel.
|
||||
|
||||
### Étape 11 — domotique
|
||||
|
||||
Une commande tient en trois mots : une **action** (allumer, éteindre, ouvrir, fermer), un
|
||||
**appareil** (lumière, volets, porte) et une **pièce** (liste dans `config.yaml`, section `home`).
|
||||
Sans pièce, c'est celle de la carte. Le serveur publie `home/<pièce>/<appareil>/set` pour
|
||||
toute domotique ; si la pièce est celle de la carte, la carte exécute aussi la commande :
|
||||
LED de démonstration sur GPIO 21 pour la lumière, volets et porte simulés (liaison série).
|
||||
|
||||
1. Câbler la LED (facultatif) : GPIO 21 → 330 Ω → LED → GND ([docs/BROCHAGE.md](docs/BROCHAGE.md) §10).
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 11), puis `make run`.
|
||||
3. Terminal à part : `make mqtt-watch` (voice/… en texte et home/#).
|
||||
4. « Allume la lumière » → « J'allume la lumière du salon. », LED allumée,
|
||||
`home/salon/light/set` et la confirmation `{"event":"device",...,"ok":true}`.
|
||||
5. « Ferme les volets de la cuisine » → `home/cuisine/shutter/set` seulement (autre pièce).
|
||||
6. « Allume les volets » → « Je ne sais pas allumer les volets. » ; « Ouvre » → « Que dois-je ouvrir ? ».
|
||||
|
||||
**Sécurité** : l'identification vocale n'est jamais une preuve. Ne branchez pas une gâche
|
||||
de porte ou une alarme sur ces commandes ; les droits par profil arrivent à l'étape 14.
|
||||
|
||||
### Étape 10 — qui a dit quoi
|
||||
|
||||
Whisper donne l'instant de chaque mot, la diarisation celui de chaque tour de parole, sur
|
||||
le même audio : chaque mot va au locuteur qui parlait à ce moment. Le serveur publie le
|
||||
résultat sur `voice/<carte>/transcript` et l'assistant appelle la personne par son nom.
|
||||
|
||||
```json
|
||||
{"session": "453cce", "utterances": [
|
||||
{"speaker": "Denis", "text": "Ouvrir la porte.", "start": 0.0, "end": 1.26, "score": 0.6},
|
||||
{"speaker": "Siwis", "text": "Je suis la voix de synthèse...", "start": 1.5, "end": 4.46, "score": 0.6},
|
||||
{"speaker": "Denis", "text": "Fermez la porte.", "start": 4.92, "end": 5.86, "score": 0.6}]}
|
||||
```
|
||||
|
||||
1. `make diarize OUT=server/recordings/<fichier>.wav` : qui a dit quoi, et le message MQTT, sans carte.
|
||||
2. `make mqtt-watch` dans un terminal, `make run` dans un autre.
|
||||
3. « Bonjour » : la carte répond « Bonjour Denis ! » ; `voice/esp32-01/transcript` apparaît.
|
||||
4. Une session à deux voix (vous, puis une voix du téléphone, puis vous) : trois interventions.
|
||||
|
||||
### Étape 9 — identification du locuteur
|
||||
|
||||
Chaque voix trouvée par la diarisation est résumée par une **empreinte** (256 nombres,
|
||||
modèle WeSpeaker du pipeline pyannote), comparée par **similarité cosinus** aux profils
|
||||
enrôlés. Au-dessus du seuil (0,45, mesuré sur ce banc), le nom ; sinon « Inconnu ».
|
||||
**C'est une estimation, jamais une sécurité** : un enregistrement ou une imitation peut tromper.
|
||||
|
||||
1. `make run`, puis 5 phrases variées de 1 à 4 s, seul, à distance normale.
|
||||
2. `make enroll NAME=Denis` : profil créé depuis ces 5 sessions ; les sessions trop
|
||||
courtes, à plusieurs voix ou qui ne ressemblent pas aux autres sont écartées.
|
||||
3. Nouvelles questions (le serveur voit le profil sans redémarrer) : le journal affiche
|
||||
`SPEAKER_00 = Denis (0.74)` ; une autre personne ou une voix de haut-parleur donne
|
||||
`Inconnu (0.05, proche de Denis)`.
|
||||
4. `make diarize-demo` : votre voix reconnue, la voix Piper « Inconnu ».
|
||||
|
||||
Les profils sont des **données biométriques** : `server/profiles/`, jamais versionné,
|
||||
un fichier JSON lisible par personne ; `make forget NAME=...` le supprime.
|
||||
|
||||
### Étape 8 — diarisation : qui parle quand
|
||||
|
||||
La parole trouvée par la VAD est découpée en tours de parole : `SPEAKER_00` de 0,0 à
|
||||
1,3 s, `SPEAKER_01` de 1,4 à 2,9 s... Les étiquettes ne valent que pour une session :
|
||||
mettre un nom sur une voix, c'est l'étape 9. Modèle `pyannote/speaker-diarization-community-1`
|
||||
(33 Mo), exécuté sur CPU, **télémétrie de pyannote coupée** (active par défaut en version 4).
|
||||
|
||||
1. Compte sur <https://huggingface.co>, puis **accepter les conditions** sur
|
||||
<https://huggingface.co/pyannote/speaker-diarization-community-1>.
|
||||
2. Jeton de type « Read » sur <https://huggingface.co/settings/tokens>, copié dans
|
||||
`server/.env` : `HF_TOKEN=hf_...` (`cp server/.env.example server/.env` s'il n'existe pas).
|
||||
3. `make install-diarization` puis `make models`. Ensuite, plus aucun accès à Internet.
|
||||
4. `make diarize-demo` : votre voix, la voix Piper, votre voix → deux locuteurs attendus.
|
||||
5. `make run` : une session où deux personnes parlent à tour de rôle ; le journal affiche
|
||||
`2 locuteur(s) : SPEAKER_00 0.00-1.30, SPEAKER_01 1.40-2.90`.
|
||||
|
||||
Sur Raspberry Pi 4, trop lourd : `diarization.enabled: false` dans `config.yaml`.
|
||||
|
||||
### Étape 7 — détection d'activité vocale (VAD)
|
||||
|
||||
Le serveur ne transcrit plus que la parole : silences de début et de fin retirés,
|
||||
sessions sans parole (appui muet, clic) écartées sans appeler Whisper. Silero est
|
||||
livré avec faster-whisper : rien de plus à installer.
|
||||
|
||||
1. `make vad-all` : une ligne par fenêtre de 32 ms (`#` parole, `_` silence) pour
|
||||
chaque enregistrement, et le nombre de sessions sans parole.
|
||||
2. `make vad OUT=server/recordings/<fichier>.wav` puis `make play OUT=recordings/parole.wav` :
|
||||
la parole seule, sans les silences.
|
||||
3. `make run` (à relancer : le code a changé), puis quelques questions et un appui
|
||||
sans parler. Le journal affiche `parole 1.36 s sur 2.00 s (segments 0.49-1.85 s)`,
|
||||
et pour l'appui muet `sans parole (VAD)`, avec une réponse immédiate.
|
||||
|
||||
Réglages dans `config.yaml`, section `vad` : seuil, pause minimale, durée minimale de parole, marge.
|
||||
|
||||
### Étape 6 — MVP : l'assistant répond
|
||||
|
||||
1. `make install-speech` puis `make models` (faster-whisper `small`, voix Piper
|
||||
`fr_FR-siwis-medium`, environ 550 Mo dans `server/models/`, une seule fois).
|
||||
2. `make speech-check` : Piper dit « Quelle heure est-il ? », Whisper le relit,
|
||||
l'assistant répond ; aucun matériel nécessaire.
|
||||
3. `make transcribe OUT=server/recordings/<fichier>.wav` : transcrit une phrase
|
||||
enregistrée par l'ESP32 à l'étape 4.
|
||||
4. `make run` : appui sur le bouton, « Quelle heure est-il ? », relâcher ; la
|
||||
carte répond. Questions reconnues : l'heure, la date, bonjour, merci, ton nom ;
|
||||
sinon, la carte répète ce qu'elle a compris.
|
||||
|
||||
Le journal donne le temps de chaque maillon : `transcription 1.0 s, synthese
|
||||
0.1 s, reponse prete en 1.1 s`. `make run-echo` garde le mode écho de l'étape 5,
|
||||
sans modèles.
|
||||
|
||||
### Étape 5 — le serveur fait parler l'ESP32, par MQTT
|
||||
|
||||
1. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` (firmware de l'étape 5).
|
||||
2. `make tone-mqtt` : 3 s de son pur envoyées par MQTT ; le bilan de la carte
|
||||
s'affiche (`chunks 60/60, underruns 0`).
|
||||
3. `make play-mqtt OUT=server/recordings/<fichier>.wav` : n'importe quel WAV,
|
||||
converti en 16 kHz mono, crête à −3 dBFS.
|
||||
4. `make run-echo` : le serveur rejoue chaque phrase à la carte qui l'a dite.
|
||||
|
||||
### Étape 4 — la voix vers le serveur, par MQTT
|
||||
|
||||
1. `make install` (dépendances du serveur : paho-mqtt, PyYAML, numpy...).
|
||||
2. `make run` : le serveur se connecte au broker et attend les sessions.
|
||||
3. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`, puis appui sur le bouton, une phrase, relâcher.
|
||||
4. Le serveur écrit `server/recordings/esp32-01_<date>_<session>.wav` et affiche
|
||||
son bilan : `complete`, chunks reçus / annoncés, crête et RMS.
|
||||
5. `make play OUT=server/recordings/<fichier>.wav` ou `make inspect OUT=...`.
|
||||
|
||||
Si le broker est injoignable à l'appui, la session part sur la liaison série :
|
||||
`make record` reste utilisable pour le dépannage.
|
||||
|
||||
### Étape 3 — Wi-Fi et MQTT
|
||||
|
||||
1. Broker : voir [docs/MOSQUITTO.md](docs/MOSQUITTO.md) (service Debian ou conteneur).
|
||||
2. `cp firmware/include/secrets.h.example firmware/include/secrets.h`, puis
|
||||
renseigner le Wi-Fi (2,4 GHz) et l'adresse IP du broker.
|
||||
3. `make mqtt-watch MQTT_HOST=<ip du broker>` dans un premier terminal.
|
||||
4. `make fw-upload SERIAL_PORT=/dev/ttyUSB0` dans un second.
|
||||
|
||||
Attendu dans le premier terminal : `voice/esp32-01/state {"status":"online",...}`
|
||||
et `voice/esp32-01/event {"event":"boot",...}`. Après une coupure d'alimentation
|
||||
de l'ESP32 : `voice/esp32-01/state {"status":"offline"}` en 15 s au plus.
|
||||
|
||||
### Étape 2 — tester la sortie audio
|
||||
|
||||
1. Câbler l'ampli MAX98357A et le haut-parleur (§2), sans oublier C1 et C2.
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
|
||||
3. Au démarrage, un **bip de 440 Hz** retentit : il teste la sortie seule.
|
||||
4. `make tone-esp` : 3 s de son pur envoyées par le PC, au rythme réel. La
|
||||
moindre coupure s'y entend comme un clic.
|
||||
5. `make play-esp OUT=recordings/essai.wav` : l'enregistrement de l'étape 1
|
||||
rejoué sur le haut-parleur de l'ESP32.
|
||||
|
||||
Attendu : son pur, sans clic ni grésillement ; le bilan de l'ESP32 affiche
|
||||
`0 refuses` et `0 sous-alimentations`.
|
||||
|
||||
### Étape 1 — tester l'entrée audio
|
||||
|
||||
1. Câbler le micro, le bouton et la LED comme au §2.
|
||||
2. `make fw-upload SERIAL_PORT=/dev/ttyUSB0`
|
||||
3. `make record OUT=recordings/essai.wav`
|
||||
4. Appuyer sur le bouton, dire une phrase, relâcher.
|
||||
5. Écouter : `make play OUT=recordings/essai.wav`
|
||||
6. Sans sortie audio : `make inspect OUT=recordings/essai.wav` dessine l'enveloppe
|
||||
du signal en ASCII et donne crête, RMS et offset continu.
|
||||
|
||||
Attendu : la phrase est intelligible, sans saturation ; le script affiche une
|
||||
crête entre 10 % et 90 % de la pleine échelle. Si la crête dépasse 99 %,
|
||||
diminuer `MIC_GAIN` dans [firmware/include/config.h](firmware/include/config.h) ;
|
||||
si elle reste sous 2 %, l'augmenter.
|
||||
|
||||
---
|
||||
|
||||
## 7. Arborescence
|
||||
|
||||
```
|
||||
voice-assistant/
|
||||
├── firmware/ firmware ESP32 (PlatformIO, Arduino)
|
||||
│ ├── include/ config.h, secrets.h.example
|
||||
│ └── src/ main.cpp + une paire .h/.cpp par classe
|
||||
├── server/ serveur Python (un module par classe)
|
||||
├── tools/ outils de test hors serveur
|
||||
├── mosquitto/ voice.conf
|
||||
├── Makefile toutes les opérations système
|
||||
├── CONTEXT.md état d'avancement et décisions techniques
|
||||
└── README.md / README-EN.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Licence
|
||||
|
||||
MIT — voir [LICENSE](LICENSE).
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent de l'assistant vocal (étape 15b) : UNE machine Linux commandée par la voix.
|
||||
#
|
||||
# Installé par « make agent-install » dans /etc/voice-agent/agent.yaml (lisible par root
|
||||
# seulement : il peut contenir un mot de passe MQTT). Après modification :
|
||||
# sudo systemctl restart voice-agent
|
||||
#
|
||||
# L'agent n'exécute QUE les actions listées ici. Le serveur n'envoie qu'un nom
|
||||
# d'action : il ne peut ni changer la commande, ni lui ajouter un argument.
|
||||
|
||||
# Nom de la machine pour l'assistant : topics agent/<id>/..., et même nom dans
|
||||
# server/config.yaml (machines.list), où l'on dit comment on l'appelle à voix haute.
|
||||
id: pc-bureau
|
||||
|
||||
mqtt:
|
||||
host: 192.168.1.104 # adresse IP du broker
|
||||
port: 1883
|
||||
username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme
|
||||
password: ""
|
||||
topic_prefix: agent
|
||||
|
||||
# true : l'agent journalise la commande sans l'exécuter. Pour les essais : on peut
|
||||
# dire « éteins le PC du bureau » sans l'éteindre.
|
||||
dry_run: false
|
||||
|
||||
# Nom de l'action (le serveur connaît ces noms : shutdown, reboot, lock ; « wake »
|
||||
# est géré par le serveur) → commande, en LISTE : pas de shell, donc pas de « ; »,
|
||||
# de « | » ni de « $( ) » possibles. Le service tourne en root.
|
||||
actions:
|
||||
shutdown:
|
||||
command: [systemctl, poweroff]
|
||||
reboot:
|
||||
command: [systemctl, reboot]
|
||||
lock:
|
||||
command: [loginctl, lock-sessions] # verrouille les sessions graphiques ouvertes
|
||||
# Action propre à cette machine : l'ajouter aussi dans server/config.yaml
|
||||
# (machines.actions : mots et phrases) et, si besoin, dans access.rules.machine.
|
||||
# backup:
|
||||
# command: [/usr/local/bin/sauvegarde.sh]
|
||||
# timeout_s: 3600
|
||||
@@ -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
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Agent de l'assistant vocal pour une machine Linux du réseau (étape 15b).
|
||||
|
||||
L'agent se connecte au broker MQTT et attend des NOMS d'action sur
|
||||
agent/<id>/command. Il n'exécute que les actions de SON fichier de configuration
|
||||
(/etc/voice-agent/agent.yaml), chacune étant une commande fixe, sans shell et
|
||||
sans argument venu du réseau : un message forgé ne peut rien lancer d'autre.
|
||||
|
||||
agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline »
|
||||
agent/<id>/command {"action":"shutdown","id":"...","by":"Denis"} (QoS 1)
|
||||
agent/<id>/result {"action":"shutdown","id":"...","ok":true,"code":0,"output":"..."}
|
||||
|
||||
Session propre : une commande envoyée pendant que la machine était éteinte n'est
|
||||
jamais exécutée à son retour.
|
||||
|
||||
Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur.
|
||||
|
||||
Exemples :
|
||||
python3 voice_agent.py --config /etc/voice-agent/agent.yaml
|
||||
make agent-run # sur le PC de développement, en mode essai (dry_run)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import yaml
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
logger = logging.getLogger("voice_agent")
|
||||
|
||||
DEFAULT_TIMEOUT_S = 60.0
|
||||
OUTPUT_MAX_CHARS = 300 # fin de la sortie de la commande, renvoyée au serveur
|
||||
KEEPALIVE_S = 30 # hors ligne annoncé 45 s au plus après une coupure brutale
|
||||
AGENT_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||
|
||||
# Lance une commande : le même contrat que subprocess.run, remplaçable dans les tests.
|
||||
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Fichier de configuration de l'agent invalide."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""Une action permise : une commande fixe."""
|
||||
|
||||
command: tuple[str, ...] # programme et arguments, SANS shell : pas de ; | $() possibles
|
||||
timeout_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
"""Configuration de l'agent (agent.yaml)."""
|
||||
|
||||
agent_id: str
|
||||
host: str
|
||||
port: int
|
||||
username: str | None
|
||||
password: str | None
|
||||
topic_prefix: str
|
||||
dry_run: bool # essais : journalise la commande sans l'exécuter
|
||||
actions: dict[str, Action]
|
||||
|
||||
def topic(self, suffix: str) -> str:
|
||||
"""agent/<id>/<suffixe>."""
|
||||
return f"{self.topic_prefix}/{self.agent_id}/{suffix}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
"""Compte rendu d'une action, publié sur agent/<id>/result."""
|
||||
|
||||
action: str
|
||||
request_id: str
|
||||
ok: bool
|
||||
code: int | None # code de retour ; None si la commande n'a pas pu tourner
|
||||
output: str
|
||||
|
||||
def payload(self) -> bytes:
|
||||
return json.dumps({"action": self.action, "id": self.request_id, "ok": self.ok,
|
||||
"code": self.code, "output": self.output}, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def load_config(path: Path) -> AgentConfig:
|
||||
"""Lit et vérifie agent.yaml ; une erreur dit précisément quoi corriger.
|
||||
|
||||
Raises:
|
||||
ConfigError: fichier absent ou invalide.
|
||||
"""
|
||||
try:
|
||||
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError) as error:
|
||||
raise ConfigError(f"{path} : {error}") from error
|
||||
agent_id = str(data.get("id", ""))
|
||||
if not AGENT_ID.fullmatch(agent_id):
|
||||
raise ConfigError(f"{path} : id « {agent_id} » invalide (minuscules, chiffres, tirets : pc-bureau)")
|
||||
broker: dict[str, Any] = data.get("mqtt") or {}
|
||||
if not broker.get("host"):
|
||||
raise ConfigError(f"{path} : mqtt.host manquant (adresse IP du broker)")
|
||||
|
||||
actions: dict[str, Action] = {}
|
||||
for name, spec in dict(data.get("actions") or {}).items():
|
||||
command = spec.get("command") if isinstance(spec, dict) else None
|
||||
if not isinstance(command, list) or not command or not all(isinstance(part, str) and part
|
||||
for part in command):
|
||||
raise ConfigError(f"{path} : action « {name} » : command doit être une liste, "
|
||||
"ex. [systemctl, poweroff] (pas de chaîne shell)")
|
||||
timeout_s = float(spec.get("timeout_s", DEFAULT_TIMEOUT_S))
|
||||
if timeout_s <= 0:
|
||||
raise ConfigError(f"{path} : action « {name} » : timeout_s doit être positif")
|
||||
actions[str(name)] = Action(command=tuple(command), timeout_s=timeout_s)
|
||||
|
||||
return AgentConfig(
|
||||
agent_id=agent_id,
|
||||
host=str(broker["host"]),
|
||||
port=int(broker.get("port", 1883)),
|
||||
username=str(broker.get("username") or "") or None,
|
||||
password=str(broker.get("password") or "") or None,
|
||||
topic_prefix=str(broker.get("topic_prefix", "agent")),
|
||||
dry_run=bool(data.get("dry_run", False)),
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
|
||||
def state_payload(config: AgentConfig, online: bool) -> bytes:
|
||||
"""État retenu : en ligne avec la liste des actions, ou hors ligne."""
|
||||
if not online:
|
||||
return json.dumps({"status": "offline"}).encode()
|
||||
return json.dumps({"status": "online", "actions": sorted(config.actions),
|
||||
"dry_run": config.dry_run}).encode()
|
||||
|
||||
|
||||
def run_action(config: AgentConfig, name: str, request_id: str, runner: Runner = subprocess.run) -> Result:
|
||||
"""Exécute une action de la liste ; tout autre nom est refusé."""
|
||||
action = config.actions.get(name)
|
||||
if action is None:
|
||||
return Result(name, request_id, False, None, f"action inconnue : {name}")
|
||||
shown = " ".join(action.command)
|
||||
if config.dry_run:
|
||||
logger.info("essai : %s (non execute, dry_run)", shown)
|
||||
return Result(name, request_id, True, 0, f"essai : {shown}")
|
||||
try:
|
||||
completed = runner(list(action.command), capture_output=True, text=True,
|
||||
timeout=action.timeout_s, check=False)
|
||||
except FileNotFoundError:
|
||||
return Result(name, request_id, False, None, f"programme introuvable : {action.command[0]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
return Result(name, request_id, False, None, f"arretee apres {action.timeout_s:g} s")
|
||||
output = f"{completed.stdout}{completed.stderr}".strip()[-OUTPUT_MAX_CHARS:]
|
||||
return Result(name, request_id, completed.returncode == 0, completed.returncode, output)
|
||||
|
||||
|
||||
class VoiceAgent:
|
||||
"""Client MQTT de l'agent : reçoit les demandes, les exécute une à une, rend compte.
|
||||
|
||||
Les commandes tournent dans un fil de travail, jamais dans le fil réseau : une
|
||||
sauvegarde d'une heure ne doit pas couper la connexion au broker.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AgentConfig, runner: Runner = subprocess.run) -> None:
|
||||
self._config = config
|
||||
self._runner = runner
|
||||
self._jobs: queue.Queue[tuple[str, str] | None] = queue.Queue()
|
||||
self._connected = threading.Event()
|
||||
# Session propre (clean_session, défaut MQTT 3.1.1) : pas de commande en attente au retour.
|
||||
self._client = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"voice-agent-{config.agent_id}")
|
||||
if config.username:
|
||||
self._client.username_pw_set(config.username, config.password)
|
||||
self._client.will_set(config.topic("state"), state_payload(config, False), qos=1, retain=True)
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_message = self._on_message
|
||||
self._client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
self._worker = threading.Thread(target=self._work, name="actions", daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Connexion (asynchrone, avec reconnexion) et fil de travail."""
|
||||
config = self._config
|
||||
logger.info("agent %s : broker %s:%d, actions %s%s", config.agent_id, config.host, config.port,
|
||||
", ".join(sorted(config.actions)) or "aucune", " (ESSAI : rien n'est execute)"
|
||||
if config.dry_run else "")
|
||||
if not config.username:
|
||||
logger.warning("pas de compte MQTT : tout appareil du reseau peut demander ces actions "
|
||||
"(comptes et droits du broker, etape 15)")
|
||||
self._worker.start()
|
||||
self._client.connect_async(config.host, config.port, keepalive=KEEPALIVE_S)
|
||||
self._client.loop_start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Annonce le départ (un arrêt propre ne déclenche pas le testament), puis se déconnecte."""
|
||||
if self._connected.is_set():
|
||||
info = self._client.publish(self._config.topic("state"), state_payload(self._config, False),
|
||||
qos=1, retain=True)
|
||||
try:
|
||||
info.wait_for_publish(2.0)
|
||||
except (RuntimeError, ValueError):
|
||||
pass
|
||||
self._client.disconnect()
|
||||
self._client.loop_stop()
|
||||
self._jobs.put(None)
|
||||
|
||||
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags,
|
||||
reason_code: ReasonCode, properties: Properties | None) -> None:
|
||||
if reason_code.is_failure:
|
||||
logger.error("connexion refusee par le broker : %s (compte mqtt.username/password ?)", reason_code)
|
||||
return
|
||||
self._connected.set()
|
||||
client.subscribe(self._config.topic("command"), qos=1)
|
||||
client.publish(self._config.topic("state"), state_payload(self._config, True), qos=1, retain=True)
|
||||
logger.info("connecte : en attente sur %s", self._config.topic("command"))
|
||||
|
||||
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
|
||||
try:
|
||||
request: dict[str, Any] = json.loads(message.payload)
|
||||
name = str(request["action"])
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError):
|
||||
logger.warning("demande illisible ignoree : %r", message.payload[:80])
|
||||
return
|
||||
logger.info("demande %s par %s (carte %s)", name, request.get("by") or "inconnu", request.get("board"))
|
||||
self._jobs.put((name, str(request.get("id", ""))))
|
||||
|
||||
def _work(self) -> None:
|
||||
while True:
|
||||
job = self._jobs.get()
|
||||
if job is None:
|
||||
return
|
||||
name, request_id = job
|
||||
result = run_action(self._config, name, request_id, self._runner)
|
||||
log = logger.info if result.ok else logger.warning
|
||||
log("%s : %s (code %s)%s", name, "ok" if result.ok else "ECHEC", result.code,
|
||||
f" : {result.output}" if result.output else "")
|
||||
self._client.publish(self._config.topic("result"), result.payload(), qos=1)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service)."""
|
||||
parser = argparse.ArgumentParser(description="agent de l'assistant vocal pour une machine Linux")
|
||||
parser.add_argument("--config", type=Path, default=Path("/etc/voice-agent/agent.yaml"))
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as error:
|
||||
logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
agent = VoiceAgent(config)
|
||||
stop = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
agent.start()
|
||||
try:
|
||||
stop.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
agent.stop()
|
||||
logger.info("arret de l'agent")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,385 @@
|
||||
# Brochage et câblage — ESP32-WROOM-32 + INMP441 + MAX98357A
|
||||
|
||||
Document de référence du câblage. Schéma visuel : [wiring.svg](wiring.svg)
|
||||
(clic droit → « Open Preview » dans VSCodium, ou ouverture dans un navigateur).
|
||||
|
||||
> **Avant de câbler** : ESP32 débranché de l'USB. On ne modifie jamais un câblage
|
||||
> sous tension : une inversion VDD/GND détruit l'INMP441 en quelques secondes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tableau de câblage complet
|
||||
|
||||
Carte de référence : **DOIT ESP32 DEVKIT V1, 30 broches** (la plus répandue en TP).
|
||||
Sur une carte 38 broches, les numéros de GPIO sont identiques, seule leur position
|
||||
physique change : **fiez-vous toujours aux numéros sérigraphiés**, jamais au rang.
|
||||
|
||||
### 1.1 Micro INMP441 (I2S0, réception)
|
||||
|
||||
| INMP441 | ESP32 | Fil conseillé | Rôle |
|
||||
|---|---|---|---|
|
||||
| `VDD` | **3V3** | rouge | alimentation **3,3 V uniquement** |
|
||||
| `GND` | **GND** | noir | masse |
|
||||
| `SCK` | **GPIO 25** | bleu | horloge bit (BCLK), générée par l'ESP32 |
|
||||
| `WS` | **GPIO 26** | vert | sélection de mot (word select / LRCL) |
|
||||
| `SD` | **GPIO 33** | violet | donnée série, micro → ESP32 |
|
||||
| `L/R` | **GND** | noir | choix du canal : GND = **gauche** |
|
||||
|
||||
`L/R` à GND est la référence de ce projet : gardez ce câblage.
|
||||
|
||||
Attention à un piège propre à l'ESP32 : l'INMP441 n'émet que pendant **une moitié
|
||||
de trame** et sort des **zéros** pendant l'autre. Or la correspondance entre la
|
||||
broche `L/R` et les constantes `I2S_STD_SLOT_LEFT` / `..._RIGHT` du pilote n'est
|
||||
pas celle qu'on attend sur l'ESP32 d'origine. Écouter la mauvaise moitié donne un
|
||||
flux **parfaitement nul**, alors que le câblage est correct — le symptôme le plus
|
||||
déroutant de l'étape 1.
|
||||
|
||||
Le firmware tranche donc tout seul : au démarrage, la classe `MicSelfTest`
|
||||
réveille le micro **une seule fois**, lit les deux moitiés de trame en cadrage
|
||||
MSB et décide sur la **structure** des mots : la moitié utile est celle qui n'est
|
||||
ni nulle ni constante, et elle doit porter exactement **24 bits utiles**. Si elle
|
||||
en porte 25, le micro suit le cadrage Philips et le firmware bascule. Le niveau
|
||||
au repos est affiché pour information, sans intervenir dans la décision.
|
||||
|
||||
### 1.2 Amplificateur MAX98357A (I2S1, émission) — étape 2
|
||||
|
||||
| MAX98357A | ESP32 | Fil | Rôle |
|
||||
|---|---|---|---|
|
||||
| `Vin` | **VIN (5 V)** | orange | alimentation 5 V (3,3 V possible mais 4× moins de puissance) |
|
||||
| `GND` | **GND** | noir | masse |
|
||||
| `BCLK` | **GPIO 27** | bleu | horloge bit |
|
||||
| `LRC` | **GPIO 14** | vert | sélection de mot |
|
||||
| `DIN` | **GPIO 22** | violet | donnée série, ESP32 → ampli |
|
||||
| `GAIN` | *non connecté* | — | gain 9 dB par défaut (voir §4) |
|
||||
| `SD` | *non connecté* | — | déjà tiré au + par 100 kΩ sur la carte → ampli actif |
|
||||
| `+` / `−` | haut-parleur | — | sortie pontée : **jamais de masse sur `−`** |
|
||||
|
||||
### 1.3 Interface utilisateur
|
||||
|
||||
| Élément | ESP32 | Câblage |
|
||||
|---|---|---|
|
||||
| Bouton PTT | **GPIO 4** | une borne sur GPIO 4, l'autre sur **GND** — pull-up interne activé par le firmware |
|
||||
| LED d'état | **GPIO 2** | GPIO 2 → résistance **330 Ω** → anode LED ; cathode → GND |
|
||||
|
||||
La carte DevKit V1 possède déjà une LED bleue sur GPIO 2 : la LED externe est
|
||||
**optionnelle**, l'étape 1 est validable sans elle.
|
||||
|
||||
### 1.4 Haut-parleur
|
||||
|
||||
| Caractéristique | Valeur conseillée |
|
||||
|---|---|
|
||||
| Impédance | **4 Ω** (3 W) ou 8 Ω (2 W) |
|
||||
| Diamètre | 40 à 66 mm |
|
||||
| Câblage | directement sur `+` et `−` de l'ampli |
|
||||
|
||||
La sortie du MAX98357A est **pontée** (les deux fils sont actifs, en opposition
|
||||
de phase). Relier `−` à la masse court-circuite l'étage de sortie.
|
||||
|
||||
---
|
||||
|
||||
## 2. Schéma ASCII
|
||||
|
||||
```
|
||||
ESP32-WROOM-32 (DevKit V1, 30 broches)
|
||||
+----------------------------------------+
|
||||
INMP441 | |
|
||||
+---------+ | EN GPIO23 |
|
||||
| VDD o--|--rouge------|--------------------------------> 3V3 | (broche 3V3)
|
||||
| GND o--|--noir-------|---------------------------------> GND | (broche GND)
|
||||
| SD o--|--violet---->| GPIO33 GPIO22 |<--violet--+
|
||||
| SCK o--|--bleu------>| GPIO25 GPIO4 |<--+ |
|
||||
| WS o--|--vert------>| GPIO26 GPIO2 |---|--+ |
|
||||
| L/R o--|--noir--> GND| GPIO27 -->--bleu----------+ .. | | | |
|
||||
+---------+ | GPIO14 -->--vert--------+ | .. | | | |
|
||||
3,3 V SEULEMENT | | | VIN(5V)| | | |
|
||||
+-------------------------|-|------------+ | | |
|
||||
| | | | |
|
||||
+-------------+ | | | |
|
||||
| +-----------+ | | |
|
||||
v v | | |
|
||||
MAX98357A | | | | |
|
||||
+----------|---|-----+ | | |
|
||||
| LRC o<--+ | | | | |
|
||||
| BCLK o<------+ | HP 4 ohms / 3 W | | |
|
||||
| DIN o<------------|----------------------|--|----+
|
||||
| GAIN o (non conn. = 9 dB) | |
|
||||
| SD o (non conn. = actif) + o---+ | |
|
||||
| GND o---noir--> GND - o---|--+ | |
|
||||
| Vin o---orange--> VIN (5 V) | | | |
|
||||
+------------------------------+ | | | |
|
||||
v v | |
|
||||
+---------+ | |
|
||||
| ( ) HP| | |
|
||||
+---------+ | |
|
||||
| |
|
||||
bouton PTT : GPIO4 ----[ / ]---- GND <-------------+ |
|
||||
LED etat : GPIO2 ----[330]---->|---- GND <-----------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Pourquoi ce choix de broches
|
||||
|
||||
| Contrainte | Conséquence |
|
||||
|---|---|
|
||||
| **Broches de strapping** GPIO 0, 2, 5, 12, 15 | leur niveau au reset choisit le mode de démarrage → aucun signal audio dessus. GPIO 2 ne porte qu'une LED, en sortie seule. |
|
||||
| **GPIO 34-39 sont en entrée seule** | inutilisables pour SCK/WS (sorties d'horloge). |
|
||||
| **GPIO 6-11** | reliées à la mémoire flash interne → jamais utilisables. |
|
||||
| **Deux périphériques I2S séparés** | I2S0 pour le micro, I2S1 pour l'ampli. On ne partage **pas** les horloges : le micro tourne en 32 bits/slot et l'ampli en 16 bits, et surtout l'entrée et la sortie doivent pouvoir démarrer et s'arrêter indépendamment. Cinq fils au lieu de trois : c'est le prix de la simplicité logicielle. |
|
||||
| **GPIO 1 / 3 (TX0/RX0)** | utilisées par l'USB-série → interdites. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Réglages matériels du MAX98357A
|
||||
|
||||
### Broche `GAIN`
|
||||
|
||||
| Câblage de `GAIN` | Gain |
|
||||
|---|---|
|
||||
| direct à GND | 15 dB |
|
||||
| à GND via 100 kΩ | 12 dB |
|
||||
| **non connectée** | **9 dB** ← notre choix |
|
||||
| à Vin via 100 kΩ | 6 dB |
|
||||
| direct à Vin | 3 dB |
|
||||
|
||||
### Broche `SD` (shutdown + choix de canal)
|
||||
|
||||
La tension sur `SD` sélectionne le mode :
|
||||
|
||||
| Tension sur `SD` | Mode |
|
||||
|---|---|
|
||||
| < 0,16 V | ampli éteint |
|
||||
| 0,16 – 0,77 V | canal droit seul |
|
||||
| 0,77 – 1,4 V | canal gauche seul |
|
||||
| **> 1,4 V** | **moyenne (gauche + droite) / 2** ← par défaut |
|
||||
|
||||
Les cartes de type Adafruit tirent `SD` au + par 100 kΩ : laissée libre, la broche
|
||||
est donc au-dessus de 1,4 V et l'ampli fonctionne en mode moyenne.
|
||||
|
||||
Le firmware émet en **stéréo, le même échantillon sur les deux voies**
|
||||
(`AudioOutput`). La moyenne (gauche + droite) / 2 redonne donc exactement le
|
||||
signal : niveau correct, et une éventuelle inversion gauche/droite est sans effet.
|
||||
On n'a pas misé sur le comportement implicite d'un mode « mono » : c'est la leçon
|
||||
de l'étape 1, où une moitié de trame muette nous a longtemps égarés.
|
||||
|
||||
Pour couper le son par logiciel, on reliera plus tard `SD` à un GPIO
|
||||
(niveau bas = silence). Inutile avant l'étape 15.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bilan de consommation
|
||||
|
||||
| Consommateur | Courant moyen | Crête |
|
||||
|---|---|---|
|
||||
| ESP32 (Wi-Fi actif) | ~120 mA | ~500 mA pendant l'émission radio |
|
||||
| INMP441 | 1,4 mA | — |
|
||||
| MAX98357A au repos | 2,4 mA | — |
|
||||
| MAX98357A à 3 W sur 4 Ω | ~670 mA | ~1,2 A sur les transitoires |
|
||||
| **Total au volume maximal** | **~0,8 A** | **> 1,5 A** |
|
||||
|
||||
Conséquences pratiques :
|
||||
|
||||
1. Une alimentation USB 5 V / 1 A **ne suffit pas** au volume maximal : l'ESP32
|
||||
redémarre (brown-out). Prévoir **5 V / 2 A**.
|
||||
2. Ajouter un condensateur **électrolytique 470 µF / 16 V** entre `Vin` et `GND`
|
||||
de l'ampli, au plus près de la carte : il fournit les crêtes que les fils de
|
||||
breadboard ne peuvent pas suivre. Types et valeurs limites : **§6**.
|
||||
3. Alternative propre : alimenter l'ampli par une source 5 V **externe**, en
|
||||
reliant sa masse à celle de l'ESP32 (**masse commune obligatoire**).
|
||||
|
||||
---
|
||||
|
||||
## 6. Condensateurs : types et valeurs
|
||||
|
||||
### 6.1 Les trois condensateurs du montage
|
||||
|
||||
| Rep. | Où | Rôle | Type exact |
|
||||
|---|---|---|---|
|
||||
| **C1** | `Vin` / `GND` du MAX98357A | réservoir d'énergie pour les crêtes du haut-parleur | électrolytique aluminium radial, **faible ESR**, 105 °C |
|
||||
| **C2** | `Vin` / `GND` du MAX98357A, collé à C1 | découplage haute fréquence (la classe D découpe à ~300 kHz) | céramique multicouche **X7R** ou X5R |
|
||||
| **C3** | GPIO 4 / `GND` (**optionnel**) | anti-rebond matériel du bouton | céramique multicouche **X7R** |
|
||||
|
||||
### 6.2 Valeurs minimale, typique et maximale
|
||||
|
||||
| Rep. | Mini | **Typique** | Maxi | Tension de service |
|
||||
|---|---|---|---|---|
|
||||
| **C1** | 100 µF<br>`1 × 10⁸ pF` | **470 µF**<br>`4,7 × 10⁸ pF` | 1 000 µF<br>`1 × 10⁹ pF` | ≥ 10 V, **16 V conseillé** |
|
||||
| **C2** | 10 nF<br>`10 000 pF` (code 103) | **100 nF**<br>`100 000 pF` (code **104**) | 1 µF<br>`1 000 000 pF` (code 105) | ≥ 25 V |
|
||||
| **C3** | 10 nF<br>`10 000 pF` (code 103) | **100 nF**<br>`100 000 pF` (code **104**) | 220 nF<br>`220 000 pF` (code 224) | ≥ 16 V |
|
||||
|
||||
Les bornes ne sont pas arbitraires :
|
||||
|
||||
- **C1 mini 100 µF** — la chute de tension pendant une crête vaut `ΔV = I × Δt / C`.
|
||||
Pour 1 A pendant 100 µs : 470 µF → 0,21 V (sans effet) ; 100 µF → 1,0 V (limite,
|
||||
l'ESP32 décroche vers 4,0 V en entrée de son régulateur) ; 47 µF → 2 V, redémarrage garanti.
|
||||
- **C1 maxi 1 000 µF** — au branchement, le condensateur vide est un court-circuit :
|
||||
`I = C × dV/dt`. Charger 1 000 µF à 5 V en 1 ms demande **5 A**, ce qui déclenche la
|
||||
protection du port USB ou du hub. Au-delà, il faut un circuit de pré-charge : hors sujet ici.
|
||||
- **C2** — au-dessus de ~1 MHz, un électrolytique se comporte comme une self : il ne
|
||||
découple plus rien. Sous 10 nF, la céramique ne stocke plus assez pour les fronts de
|
||||
commutation. Au-dessus de 1 µF, aucun gain et le boîtier devient inutilement gros.
|
||||
- **C3** — la constante de temps vaut `τ = R_pull-up × C`, avec le pull-up interne de
|
||||
l'ESP32 à ≈ 45 kΩ : 100 nF → τ = 4,5 ms, soit ~6 ms pour franchir le seuil logique.
|
||||
C'est bien en dessous des `BUTTON_DEBOUNCE_MS = 30 ms` du firmware. Avec 220 nF on
|
||||
atteint ~13 ms : encore acceptable. Au-delà, le filtre matériel et le filtre logiciel
|
||||
se cumulent et le bouton devient mou.
|
||||
|
||||
**C3 est facultatif** : l'anti-rebond est déjà fait en logiciel dans
|
||||
[PushButton.cpp](../firmware/src/PushButton.cpp). Ne l'ajoutez que si le bouton est au
|
||||
bout d'un fil long et capte du bruit. Dans ce cas, insérez aussi **100 Ω en série**
|
||||
entre le GPIO et le bouton : cette résistance limite le courant de décharge du
|
||||
condensateur dans le contact à chaque appui, ce qui préserve le bouton.
|
||||
|
||||
### 6.3 Lire le marquage — il est justement en picofarads
|
||||
|
||||
Les céramiques portent un **code à trois chiffres en pF** : deux chiffres significatifs,
|
||||
puis le nombre de zéros à ajouter.
|
||||
|
||||
| Code imprimé | picofarads | nanofarads | microfarads |
|
||||
|---|---|---|---|
|
||||
| `102` | 1 000 pF | 1 nF | 0,001 µF |
|
||||
| `103` | 10 000 pF | 10 nF | 0,01 µF |
|
||||
| **`104`** | **100 000 pF** | **100 nF** | **0,1 µF** |
|
||||
| `224` | 220 000 pF | 220 nF | 0,22 µF |
|
||||
| `105` | 1 000 000 pF | 1 000 nF | 1 µF |
|
||||
| `106` | 10 000 000 pF | 10 000 nF | 10 µF |
|
||||
|
||||
Une lettre suivante donne la tolérance : `J` = ±5 %, `K` = ±10 %, `M` = ±20 %.
|
||||
Sur un découplage, la tolérance n'a aucune importance : `K` ou `M` conviennent.
|
||||
|
||||
Les électrolytiques, eux, portent la valeur en clair (`470µF 16V`) et une **bande
|
||||
verticale marquant la borne négative**, en face de la patte la plus courte.
|
||||
|
||||
### 6.4 Pièges sur les condensateurs
|
||||
|
||||
1. **Polarité** — un électrolytique monté à l'envers chauffe, gonfle puis éclate en
|
||||
quelques secondes. La bande imprimée est le **−**, la patte longue est le **+**.
|
||||
Les céramiques, elles, ne sont pas polarisées.
|
||||
2. **Diélectrique** — refusez les `Y5V` et `Z5U` : ils perdent jusqu'à 80 % de leur
|
||||
capacité avec la température et la tension appliquée. **X7R** (ou X5R) pour le
|
||||
découplage, `C0G`/`NP0` seulement pour les petites valeurs.
|
||||
3. **Effet de la tension continue** — une céramique 10 µF / 6,3 V en boîtier 0603
|
||||
ne fait plus que 4 µF sous 5 V. C'est pourquoi C2 est spécifiée en **25 V minimum**.
|
||||
4. **ESR** — un 470 µF « general purpose » peut présenter 1 Ω de résistance série :
|
||||
inutile pour des crêtes de 1 A. Cherchez la mention **low ESR** ou **105 °C**
|
||||
(typiquement < 0,3 Ω).
|
||||
5. **Distance** — C1 et C2 à moins de 3 cm des broches `Vin` / `GND` de l'ampli.
|
||||
Au-delà, l'inductance des fils annule leur effet.
|
||||
6. **C1 et C2 ne se remplacent pas** : le gros stocke l'énergie, le petit évacue le
|
||||
haut du spectre. On met toujours les deux, en parallèle.
|
||||
|
||||
Les modules INMP441 et MAX98357A du commerce embarquent déjà leur découplage local
|
||||
(100 nF, et 10 µF côté ampli). C1 et C2 compensent **les fils de la platine d'essai**,
|
||||
pas un manque des modules.
|
||||
|
||||
---
|
||||
|
||||
## 7. Précautions de câblage
|
||||
|
||||
1. **Longueur des fils I2S** : 15 cm maximum. Au-delà, l'horloge se déforme et le
|
||||
son devient bruité.
|
||||
2. **Séparer** les fils du micro de ceux du haut-parleur : le courant du HP
|
||||
(jusqu'à 1 A commuté à 300 kHz) rayonne dans les fils du micro.
|
||||
3. **Masse commune** entre tous les modules, sans exception.
|
||||
4. Ne pas alimenter l'INMP441 en 5 V : il est prévu pour 1,8 à 3,3 V.
|
||||
5. Le micro est sensible aux vibrations : le fixer, ne pas le laisser pendre
|
||||
contre le boîtier du haut-parleur (effet Larsen mécanique).
|
||||
6. Sur breadboard, vérifier que les deux moitiés des rails d'alimentation sont
|
||||
bien reliées (beaucoup de breadboards coupent les rails au milieu).
|
||||
|
||||
---
|
||||
|
||||
## 8. Vérifications au multimètre, avant le premier démarrage
|
||||
|
||||
ESP32 **débranché**, en mode continuité puis en mode tension :
|
||||
|
||||
| # | Mesure | Attendu |
|
||||
|---|---|---|
|
||||
| 1 | continuité `INMP441 VDD` ↔ `ESP32 3V3` | passant |
|
||||
| 2 | continuité `INMP441 GND` ↔ `ESP32 GND` | passant |
|
||||
| 3 | continuité `INMP441 L/R` ↔ `GND` | passant |
|
||||
| 4 | continuité `INMP441 VDD` ↔ `GND` | **coupé** (sinon court-circuit) |
|
||||
| 5 | continuité `MAX98357A Vin` ↔ `ESP32 VIN` | passant |
|
||||
| 6 | continuité `MAX98357A −` ↔ `GND` | **coupé** |
|
||||
| 7 | continuité `GPIO 4` ↔ `GND`, bouton relâché | **coupé** |
|
||||
| 8 | continuité `GPIO 4` ↔ `GND`, bouton appuyé | passant |
|
||||
|
||||
### Continuité de la ligne de données
|
||||
|
||||
Le défaut le plus tenace de l'étape 1 est une ligne `SD` qui n'arrive pas. Testez
|
||||
en continuité, **carte hors tension**, et **sur la pastille du module**, jamais au
|
||||
bout du fil : une soudure sèche laisse passer le fil mais pas le signal.
|
||||
|
||||
| # | Mesure | Attendu |
|
||||
|---|---|---|
|
||||
| 8b | `GPIO 33` ↔ pastille `SD` du module | passant |
|
||||
| 8c | pastille `L/R` du module ↔ `GND` | passant |
|
||||
| 8d | `GPIO 33` ↔ `GND` | **coupé** |
|
||||
|
||||
Puis USB branché :
|
||||
|
||||
| # | Mesure | Attendu |
|
||||
|---|---|---|
|
||||
| 9 | tension `3V3` / `GND` | 3,2 à 3,4 V |
|
||||
| 10 | tension `VIN` / `GND` | 4,6 à 5,1 V |
|
||||
| 11 | tension `GPIO 4` / `GND`, bouton relâché | ≈ 3,3 V (pull-up interne) |
|
||||
| 12 | tension `GPIO 4` / `GND`, bouton appuyé | ≈ 0 V |
|
||||
|
||||
Le test 11 exige que le firmware soit déjà téléversé : le pull-up est activé par
|
||||
`pinMode(INPUT_PULLUP)`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Diagnostic rapide
|
||||
|
||||
| Symptôme | Cause la plus fréquente | Vérification |
|
||||
|---|---|---|
|
||||
| Auto-test : les deux moitiés « silence numerique » | le micro n'est pas cadencé ou n'émet rien | tensions sur les **pastilles du module** : `VDD` 3,3 V, `SCK` et `WS` ≈ 1,6 V |
|
||||
| Auto-test : « un bit de retard (Philips) » | le micro suit le cadrage Philips | aucune : le firmware bascule et revérifie |
|
||||
| Auto-test : « DECALE : bit de signe perdu » (23 bits) | cadrage faux, le premier bit déborde dans le mot précédent | aucune en MSB ; si ça persiste, envoyer l'analyse bit à bit |
|
||||
| Auto-test : « queue du mot non nulle » | bits parasites après les 24 bits utiles | vérifier `MIC_SD_PULLDOWN = true` |
|
||||
| Auto-test : « les DEUX moities portent des donnees » | `L/R` flottant, ou deux micros sur la ligne | continuité `L/R` ↔ GND sur la pastille |
|
||||
| Auto-test : « bruit de fond eleve » au repos | pièce bruyante, ou micro pas encore stabilisé | sans effet sur le cadrage ; juger sur l'enregistrement |
|
||||
| Ligne SD : « FLOTTE », horloges correctes | rien ne pilote GPIO 33 | continuité `SD` ↔ GPIO 33 sur la pastille |
|
||||
| Auto-test série : amplitude mesurable mais crête faible | simple question de gain | appliquer la valeur de `MIC_GAIN` conseillée par l'auto-test |
|
||||
| Crête à 0 %, silence total | `L/R` non relié à GND | test 3 |
|
||||
| Crête à 0 %, silence total | `SD` du micro sur la mauvaise broche | test de continuité vers GPIO 33 |
|
||||
| Bruit blanc continu | micro alimenté en 5 V | test 9 sur la broche `VDD` du micro |
|
||||
| Son très faible même en criant | `SCK` et `WS` inversés | inverser GPIO 25 et GPIO 26 |
|
||||
| Signal haché, craquements réguliers | fils I2S trop longs | raccourcir à 10 cm |
|
||||
| L'ESP32 redémarre quand le son sort | alimentation insuffisante | §5, points 1 et 2 |
|
||||
| Téléversement impossible | LED sur GPIO 2 qui perturbe le boot | retirer la LED externe, utiliser celle de la carte |
|
||||
| Le HP souffle ou vibre en permanence dès la mise sous tension | broches I2S de sortie flottantes : l'ampli amplifie du bruit | firmware ≥ étape 1 : `silenceAmplifier()` les force à 0. Si le bruit persiste pendant le boot, relier `SD` de l'ampli à `GND`, ou ne pas l'alimenter avant l'étape 2 |
|
||||
| Sifflement aigu permanent | masse commune absente entre ampli et ESP32 | §7, point 3 |
|
||||
|
||||
---
|
||||
|
||||
## 10. Récapitulatif des GPIO réservés
|
||||
|
||||
| GPIO | Usage | Étape |
|
||||
|---|---|---|
|
||||
| 2 | LED d'état | 1 |
|
||||
| 4 | bouton PTT | 1 |
|
||||
| 14 | MAX98357A `LRC` | 2 |
|
||||
| 22 | MAX98357A `DIN` | 2 |
|
||||
| 25 | INMP441 `SCK` | 1 |
|
||||
| 26 | INMP441 `WS` | 1 |
|
||||
| 27 | MAX98357A `BCLK` | 2 |
|
||||
| 33 | INMP441 `SD` | 1 |
|
||||
| 21 | LED de démonstration domotique (« la lumière » de la pièce) | 11 |
|
||||
|
||||
### LED de démonstration (étape 11, facultative)
|
||||
|
||||
```
|
||||
GPIO21 ──[ 330 Ω ]──►|── GND LED rouge ou verte : patte longue (anode) côté résistance
|
||||
```
|
||||
|
||||
Courant : (3,3 V − 2,0 V) / 330 Ω ≈ 4 mA, bien sous les 12 mA conseillés par broche.
|
||||
GPIO 21 est libre et sans rôle au démarrage (pas une broche de *strapping*). Elle
|
||||
figure « la lumière » de la pièce de la carte ; volets et porte sont simulés
|
||||
(état affiché sur la liaison série). Un module relais se brancherait au même endroit.
|
||||
Cette LED n'est pas dessinée sur `wiring.svg`.
|
||||
|
||||
Les valeurs sont définies une seule fois, dans
|
||||
[firmware/include/config.h](../firmware/include/config.h). Toute modification de
|
||||
câblage se fait dans ce fichier, jamais dans le code des classes.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Broker Mosquitto — installation et vérification
|
||||
|
||||
Le broker (serveur MQTT) relaie tous les messages entre l'ESP32 et le serveur
|
||||
Python. Configuration unique : [mosquitto/voice.conf](../mosquitto/voice.conf).
|
||||
|
||||
---
|
||||
|
||||
## 1. Deux façons de le faire tourner
|
||||
|
||||
### Service Debian (Raspberry Pi, PC)
|
||||
|
||||
```bash
|
||||
make install-system # installe mosquitto et mosquitto-clients
|
||||
make mosquitto-config # copie voice.conf dans /etc/mosquitto/conf.d/ et redémarre
|
||||
```
|
||||
|
||||
### Conteneur Docker
|
||||
|
||||
Monter `voice.conf` **à la place** de `mosquitto.conf` du conteneur, et publier le
|
||||
port 1883 :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 1883:1883
|
||||
volumes:
|
||||
- ./mosquitto/config:/mosquitto/config # contient voice.conf renommé mosquitto.conf
|
||||
```
|
||||
|
||||
Si le conteneur existe déjà :
|
||||
|
||||
```bash
|
||||
make mosquitto-docker MOSQ_DIR=~/srv/mosquitto MOSQ_COMPOSE=~/srv/docker-compose.mosquitto.yml
|
||||
```
|
||||
|
||||
La cible sauvegarde l'ancienne configuration (`mosquitto.conf.bak`), installe
|
||||
`voice.conf` avec le propriétaire du conteneur (uid 1883), relance le service et
|
||||
affiche les dernières lignes du journal. Le `sudo` est nécessaire : les fichiers
|
||||
appartiennent à l'utilisateur `mosquitto` du conteneur.
|
||||
|
||||
---
|
||||
|
||||
## 2. Comptes et droits (étape 15)
|
||||
|
||||
Sans authentification, n'importe quel appareil du réseau local peut s'abonner à
|
||||
`voice/+/audio/stream` et **écouter le micro** en continu, ou publier une
|
||||
commande. Depuis l'étape 15, le broker exige un compte, et un fichier de droits
|
||||
(ACL, *Access Control List* : liste de qui peut lire ou écrire quel topic)
|
||||
limite chaque compte :
|
||||
|
||||
| Compte | Qui | Droits ([mosquitto/acl](../mosquitto/acl)) |
|
||||
|---|---|---|
|
||||
| `voice-server` | le serveur, et les cibles `make mqtt-*`, `tone-mqtt`, `play-mqtt` | lire et écrire `voice/#` et `home/#` |
|
||||
| `esp32-01` (= `DEVICE_ID`) | une carte | écrire **ses** `state`, `event`, `audio/in`, `audio/stream` ; lire **ses** `control`, `audio/out`, et `voice/server/status` |
|
||||
| `pc-bureau` (= `id` de l'agent) | un agent Linux (étape 15b) | écrire **ses** `agent/<id>/state` et `result` ; lire **ses** `agent/<id>/command` |
|
||||
|
||||
Une carte ne peut donc ni écouter une autre carte, ni publier sous son nom, ni
|
||||
envoyer de commande domotique. Vérifié sur un broker de test : connexion
|
||||
anonyme et mauvais mot de passe refusés, usurpation d'une autre carte jetée.
|
||||
|
||||
```bash
|
||||
make mqtt-user NAME=voice-server # mot de passe demandé deux fois, stocké haché
|
||||
make mqtt-user NAME=esp32-01 # un compte par carte, du nom de son DEVICE_ID
|
||||
make mosquitto-docker # installe voice.conf + acl + passwd, relance le broker
|
||||
```
|
||||
|
||||
Les mots de passe en clair vont dans `server/.env` (`MQTT_USERNAME`,
|
||||
`MQTT_PASSWORD`) et dans `firmware/include/secrets.h` (`MQTT_PASSWORD` ;
|
||||
`MQTT_USER` vide = `DEVICE_ID`). `mosquitto/passwd` ne contient que des
|
||||
empreintes (hachage : transformation à sens unique), et n'est pas versionné.
|
||||
|
||||
Le mot de passe circule encore **en clair** sur le réseau local : seul TLS
|
||||
(chiffrement, port 8883) l'empêcherait. C'est l'option de l'étape 15, non
|
||||
activée : sur un réseau Wi-Fi WPA2 domestique, l'écoute du trafic suppose déjà
|
||||
d'avoir la clé du Wi-Fi.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pièges rencontrés
|
||||
|
||||
| Symptôme | Cause | Correctif |
|
||||
|---|---|---|
|
||||
| L'ESP32 ne se connecte pas, `mosquitto_sub` sur le PC fonctionne | Mosquitto 2.x sans `listener` explicite n'écoute que sur `localhost` | `listener 1883 0.0.0.0` |
|
||||
| Conteneur en état `restarting`, des centaines de redémarrages | deux lignes `listener 1883` : le second ne peut pas ouvrir le port (`Error: Address in use`) | **un seul** `listener` par port |
|
||||
| Le broker plante sans rien dire | `log_dest syslog` : dans un conteneur, il n'y a pas de syslog | `log_dest stdout`, puis `docker logs <conteneur>` |
|
||||
| `Note: It is recommended to replace message_size_limit` | option obsolète depuis Mosquitto 2.0 | `max_packet_size 8192` |
|
||||
| L'ESP32 affiche `mqtt : echec (code -2)` | connexion TCP refusée : broker arrêté, mauvaise IP, pare-feu | `make mqtt-watch` depuis le PC ; vérifier `MQTT_HOST` dans `secrets.h` |
|
||||
| Deux cartes se déconnectent l'une l'autre en boucle | même `DEVICE_ID`, donc même identifiant client MQTT | un `DEVICE_ID` différent par carte |
|
||||
| `connexion refusee par le broker : Not authorized` (serveur), `code 5` (ESP32) | compte absent de `passwd`, mot de passe différent, ou `MQTT_PASSWORD` vide | `make mqtt-user NAME=...`, puis `make mosquitto-docker` ; recopier le même mot de passe |
|
||||
| Tout est connecté, mais rien ne passe | droits : Mosquitto **jette en silence** une publication interdite et n'envoie rien à un abonnement interdit | nom d'utilisateur de la carte = son `DEVICE_ID` (la carte l'affiche au démarrage sinon) |
|
||||
| Un autre programme de la maison ne se connecte plus au broker | `allow_anonymous false` vaut pour **tous** les clients | lui créer un compte, et ses lignes dans `mosquitto/acl` |
|
||||
| `ACL pattern 'voice/server/status' does not contain '%c' or '%u'` au démarrage | avertissement : ce topic est commun à toutes les cartes, c'est voulu | rien à faire |
|
||||
|
||||
---
|
||||
|
||||
## 4. Vérifier le broker, sans ESP32
|
||||
|
||||
```bash
|
||||
make mqtt-watch # terminal 1 : tout ce qui passe sur voice/#
|
||||
make mqtt-control MSG='{"cmd":"ping"}' # terminal 2 : un message sur control
|
||||
```
|
||||
|
||||
Le terminal 1 doit afficher `voice/esp32-01/control {"cmd":"ping"}`.
|
||||
|
||||
Sans le paquet `mosquitto-clients`, ces cibles utilisent automatiquement les
|
||||
clients inclus dans l'image Docker `eclipse-mosquitto:2.0`.
|
||||
|
||||
---
|
||||
|
||||
## 5. État retenu et testament
|
||||
|
||||
| Topic | Qui publie | Quand | Retenu |
|
||||
|---|---|---|---|
|
||||
| `voice/esp32-01/state` `{"status":"online",...}` | l'ESP32 | à chaque connexion, puis toutes les 30 s | oui |
|
||||
| `voice/esp32-01/state` `{"status":"offline"}` | **le broker**, à la place de l'ESP32 | quand l'ESP32 disparaît sans prévenir | oui |
|
||||
| `voice/esp32-01/event` `{"event":"boot","reason":...}` | l'ESP32 | une fois par démarrage | non |
|
||||
|
||||
Messages publiés par le serveur (étape 10) :
|
||||
|
||||
| Topic | Qui publie | Quand | Retenu |
|
||||
|---|---|---|---|
|
||||
| `voice/esp32-01/transcript` `{"session":...,"utterances":[{"speaker":"Denis","text":...}]}` | le serveur (QoS 1) | après chaque question transcrite | non |
|
||||
| `home/<pièce>/<appareil>/set` `{"action":"close","by":"Denis","board":"esp32-01",...}` | le serveur (QoS 1), étape 11 | à chaque commande domotique | **non** : rejouée à un abonné tardif, elle ferait bouger un volet |
|
||||
| `voice/esp32-01/control` `{"cmd":"device","device":"light","action":"on"}` | le serveur, étape 11 | commande pour la pièce de la carte | non |
|
||||
| `voice/esp32-01/event` `{"event":"device",...,"ok":true,"state":"on"}` | l'ESP32, étape 11 | confirmation d'exécution | non |
|
||||
| `voice/esp32-01/control` `{"cmd":"listen","enabled":true}` | le serveur, étape 13 | connexion ou redémarrage de la carte ; `false` à l'arrêt du serveur | non |
|
||||
| `voice/esp32-01/audio/stream` (PCM binaire, QoS 0) | l'ESP32, étape 13 | en continu tant que l'écoute est active, sauf pendant une réponse ou un appui | non |
|
||||
| `voice/esp32-01/control` `{"cmd":"capture","active":true}` | le serveur, étape 13 | mot de réveil entendu (LED allumée) ; `false` à la fin de la commande | non |
|
||||
| `voice/server/status` `{"status":"online"}` | le serveur, étape 15 | à chaque connexion au broker | **oui** |
|
||||
| `voice/server/status` `{"status":"offline"}` | le serveur à l'arrêt, ou **le broker** (testament du serveur) s'il meurt | arrêt ou disparition du serveur : les cartes coupent leur micro | **oui** |
|
||||
| `agent/<machine>/state` `{"status":"online","actions":[...]}` | l'agent Linux, étape 15b ; `offline` par son testament | à sa connexion | **oui** |
|
||||
| `agent/<machine>/command` `{"action":"shutdown","id":...,"by":"Denis"}` | le serveur (QoS 1) | commande autorisée (et confirmée) | **non** : l'agent se connecte en session propre, rien n'est rejoué |
|
||||
| `agent/<machine>/result` `{"action":...,"ok":true,"code":0,"output":...}` | l'agent | après exécution | non |
|
||||
|
||||
- **Retenu** : le broker garde le dernier message du topic et le donne tout de
|
||||
suite à chaque nouvel abonné. Un serveur qui démarre après l'ESP32 connaît donc
|
||||
aussitôt son état.
|
||||
- **Testament** (*Last Will*) : confié au broker à la connexion. Si l'ESP32
|
||||
disparaît sans se déconnecter proprement, c'est le broker qui publie `offline`.
|
||||
|
||||
Délai du testament :
|
||||
|
||||
| Disparition | Le broker s'en aperçoit | `offline` publié après |
|
||||
|---|---|---|
|
||||
| ESP32 : coupure de courant, reset, plantage, perte du Wi-Fi | quand l'ESP32 cesse de répondre : la puce ne prévient jamais | 1,5 × `MQTT_KEEPALIVE_S` = **15 s** au plus |
|
||||
| client sur PC tué (`kill`, Ctrl-C) | immédiatement : le système d'exploitation ferme la connexion | < 1 s |
|
||||
|
||||
Après un simple reset, l'ESP32 se reconnecte en quelques secondes, souvent avant
|
||||
l'expiration du keepalive. Le broker remplace alors l'ancienne session par la
|
||||
nouvelle, et l'on peut voir passer un `offline` immédiatement suivi de `online`.
|
||||
Pour observer le testament seul, il faut **couper l'alimentation** et attendre.
|
||||
|
||||
La cause du redémarrage (`reason`) vaut `poweron`, `external` (broche EN),
|
||||
`software`, `panic` (plantage), `watchdog` ou **`brownout`** : chute de tension,
|
||||
typiquement l'ampli qui tire trop sur une alimentation USB faible.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
+283
@@ -0,0 +1,283 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1010" width="1400" height="1010" font-family="DejaVu Sans, Arial, Helvetica, sans-serif">
|
||||
<!-- Schéma de câblage : ESP32-WROOM-32 + INMP441 + MAX98357A + HP + bouton + LED -->
|
||||
<!-- Fond blanc explicite : le schéma reste lisible en thème sombre. -->
|
||||
<rect x="0" y="0" width="1400" height="1010" fill="#ffffff"/>
|
||||
|
||||
<style>
|
||||
.box { fill:#f7f7f4; stroke:#333333; stroke-width:2; }
|
||||
.title { font-size:17px; font-weight:bold; fill:#1a1a1a; }
|
||||
.sub { font-size:12px; fill:#555555; }
|
||||
.pin { font-size:12px; fill:#1a1a1a; }
|
||||
.pinused { font-size:12px; font-weight:bold; fill:#1a1a1a; }
|
||||
.lbl { font-size:12px; fill:#333333; }
|
||||
.tick { stroke:#999999; stroke-width:1.5; }
|
||||
.w { fill:none; stroke-width:2.4; stroke-linejoin:round; }
|
||||
.gnd { stroke:#333333; }
|
||||
.v33 { stroke:#cc2222; }
|
||||
.v5 { stroke:#e08214; }
|
||||
.clk { stroke:#1f6fd0; }
|
||||
.ws { stroke:#12855d; }
|
||||
.data { stroke:#8a3fc0; }
|
||||
.warn { font-size:12px; font-weight:bold; fill:#b30000; }
|
||||
</style>
|
||||
|
||||
<text x="30" y="40" class="title" font-size="22">Assistant vocal local — câblage complet</text>
|
||||
<text x="30" y="62" class="sub">ESP32-WROOM-32 · INMP441 (micro I2S) · MAX98357A (ampli I2S) · haut-parleur · bouton PTT · LED d'état</text>
|
||||
|
||||
<!-- =============================== ESP32 =============================== -->
|
||||
<text x="610" y="100" class="title" text-anchor="middle">ESP32-WROOM-32 (DevKit V1)</text>
|
||||
<rect x="480" y="110" width="260" height="590" rx="6" class="box"/>
|
||||
<text x="610" y="688" class="sub" text-anchor="middle">vue logique — repérez les numéros sérigraphiés</text>
|
||||
|
||||
<!-- colonne gauche -->
|
||||
<g class="pin">
|
||||
<text x="492" y="140">EN</text>
|
||||
<text x="492" y="178">GPIO 36</text>
|
||||
<text x="492" y="216">GPIO 39</text>
|
||||
<text x="492" y="254">GPIO 34</text>
|
||||
<text x="492" y="292">GPIO 35</text>
|
||||
<text x="492" y="330">GPIO 32</text>
|
||||
<text x="492" y="558">GPIO 12</text>
|
||||
<text x="492" y="596">GPIO 13</text>
|
||||
<text x="492" y="634">GND</text>
|
||||
</g>
|
||||
<g class="pinused">
|
||||
<text x="492" y="368">GPIO 33</text>
|
||||
<text x="492" y="406">GPIO 25</text>
|
||||
<text x="492" y="444">GPIO 26</text>
|
||||
<text x="492" y="482">GPIO 27</text>
|
||||
<text x="492" y="520">GPIO 14</text>
|
||||
<text x="492" y="672">VIN (5 V)</text>
|
||||
</g>
|
||||
|
||||
<!-- colonne droite -->
|
||||
<g class="pin" text-anchor="end">
|
||||
<text x="728" y="140">GPIO 23</text>
|
||||
<text x="728" y="216">TX0</text>
|
||||
<text x="728" y="254">RX0</text>
|
||||
<text x="728" y="292">GPIO 21</text>
|
||||
<text x="728" y="330">GPIO 19</text>
|
||||
<text x="728" y="368">GPIO 18</text>
|
||||
<text x="728" y="406">GPIO 5</text>
|
||||
<text x="728" y="444">GPIO 17</text>
|
||||
<text x="728" y="482">GPIO 16</text>
|
||||
<text x="728" y="596">GPIO 15</text>
|
||||
</g>
|
||||
<g class="pinused" text-anchor="end">
|
||||
<text x="728" y="178">GPIO 22</text>
|
||||
<text x="728" y="520">GPIO 4</text>
|
||||
<text x="728" y="558">GPIO 2</text>
|
||||
<text x="728" y="634">GND</text>
|
||||
<text x="728" y="672">3V3</text>
|
||||
</g>
|
||||
|
||||
<!-- ergots de broches -->
|
||||
<g class="tick">
|
||||
<line x1="470" y1="135" x2="480" y2="135"/><line x1="470" y1="173" x2="480" y2="173"/>
|
||||
<line x1="470" y1="211" x2="480" y2="211"/><line x1="470" y1="249" x2="480" y2="249"/>
|
||||
<line x1="470" y1="287" x2="480" y2="287"/><line x1="470" y1="325" x2="480" y2="325"/>
|
||||
<line x1="470" y1="363" x2="480" y2="363"/><line x1="470" y1="401" x2="480" y2="401"/>
|
||||
<line x1="470" y1="439" x2="480" y2="439"/><line x1="470" y1="477" x2="480" y2="477"/>
|
||||
<line x1="470" y1="515" x2="480" y2="515"/><line x1="470" y1="553" x2="480" y2="553"/>
|
||||
<line x1="470" y1="591" x2="480" y2="591"/><line x1="470" y1="629" x2="480" y2="629"/>
|
||||
<line x1="470" y1="667" x2="480" y2="667"/>
|
||||
<line x1="740" y1="135" x2="750" y2="135"/><line x1="740" y1="173" x2="750" y2="173"/>
|
||||
<line x1="740" y1="211" x2="750" y2="211"/><line x1="740" y1="249" x2="750" y2="249"/>
|
||||
<line x1="740" y1="287" x2="750" y2="287"/><line x1="740" y1="325" x2="750" y2="325"/>
|
||||
<line x1="740" y1="363" x2="750" y2="363"/><line x1="740" y1="401" x2="750" y2="401"/>
|
||||
<line x1="740" y1="439" x2="750" y2="439"/><line x1="740" y1="477" x2="750" y2="477"/>
|
||||
<line x1="740" y1="515" x2="750" y2="515"/><line x1="740" y1="553" x2="750" y2="553"/>
|
||||
<line x1="740" y1="591" x2="750" y2="591"/><line x1="740" y1="629" x2="750" y2="629"/>
|
||||
<line x1="740" y1="667" x2="750" y2="667"/>
|
||||
</g>
|
||||
|
||||
<!-- =============================== INMP441 =============================== -->
|
||||
<rect x="150" y="150" width="240" height="200" rx="6" class="box"/>
|
||||
<text x="270" y="178" class="title" text-anchor="middle" font-size="15">INMP441</text>
|
||||
<text x="270" y="196" class="sub" text-anchor="middle">micro I2S — entrée audio</text>
|
||||
<text x="270" y="214" class="warn" text-anchor="middle">3,3 V uniquement</text>
|
||||
<g class="pinused" text-anchor="end">
|
||||
<text x="378" y="240">SD</text>
|
||||
<text x="378" y="268">SCK</text>
|
||||
<text x="378" y="296">WS</text>
|
||||
</g>
|
||||
<g class="pinused">
|
||||
<text x="162" y="344">L/R</text>
|
||||
<text x="196" y="344">GND</text>
|
||||
<text x="236" y="344">VDD</text>
|
||||
</g>
|
||||
<g class="tick">
|
||||
<line x1="390" y1="236" x2="400" y2="236"/>
|
||||
<line x1="390" y1="264" x2="400" y2="264"/>
|
||||
<line x1="390" y1="292" x2="400" y2="292"/>
|
||||
<line x1="170" y1="350" x2="170" y2="360"/>
|
||||
<line x1="200" y1="350" x2="200" y2="360"/>
|
||||
<line x1="240" y1="350" x2="240" y2="360"/>
|
||||
</g>
|
||||
|
||||
<!-- fils du micro -->
|
||||
<polyline class="w data" points="390,236 455,236 455,363 470,363"/>
|
||||
<polyline class="w clk" points="390,264 440,264 440,401 470,401"/>
|
||||
<polyline class="w ws" points="390,292 425,292 425,439 470,439"/>
|
||||
|
||||
<polyline class="w gnd" points="170,350 170,790"/>
|
||||
<polyline class="w gnd" points="200,350 200,790"/>
|
||||
<polyline class="w v33" points="240,350 240,750"/>
|
||||
<text x="120" y="378" class="lbl" transform="rotate(-90 120 378)">L/R → GND (canal gauche)</text>
|
||||
|
||||
<!-- =============================== MAX98357A =============================== -->
|
||||
<rect x="880" y="380" width="260" height="240" rx="6" class="box"/>
|
||||
<text x="1010" y="408" class="title" text-anchor="middle" font-size="15">MAX98357A</text>
|
||||
<text x="1010" y="426" class="sub" text-anchor="middle">ampli classe D mono — sortie audio</text>
|
||||
<text x="1010" y="444" class="sub" text-anchor="middle">alimenté en 5 V</text>
|
||||
<g class="pinused">
|
||||
<text x="892" y="475">LRC</text>
|
||||
<text x="892" y="503">BCLK</text>
|
||||
<text x="892" y="531">DIN</text>
|
||||
</g>
|
||||
<g class="pin">
|
||||
<text x="898" y="600">GAIN</text>
|
||||
<text x="950" y="600">SD</text>
|
||||
<text x="996" y="600">GND</text>
|
||||
<text x="1050" y="600">Vin</text>
|
||||
</g>
|
||||
<g class="pinused" text-anchor="end">
|
||||
<text x="1128" y="475">+</text>
|
||||
<text x="1128" y="535">−</text>
|
||||
</g>
|
||||
<g class="tick">
|
||||
<line x1="870" y1="470" x2="880" y2="470"/>
|
||||
<line x1="870" y1="500" x2="880" y2="500"/>
|
||||
<line x1="870" y1="530" x2="880" y2="530"/>
|
||||
<line x1="920" y1="620" x2="920" y2="630"/>
|
||||
<line x1="970" y1="620" x2="970" y2="630"/>
|
||||
<line x1="1020" y1="620" x2="1020" y2="630"/>
|
||||
<line x1="1070" y1="620" x2="1070" y2="630"/>
|
||||
<line x1="1140" y1="470" x2="1150" y2="470"/>
|
||||
<line x1="1140" y1="530" x2="1150" y2="530"/>
|
||||
</g>
|
||||
|
||||
<!-- fils de l'ampli : LRC et BCLK contournent la carte par le bas -->
|
||||
<polyline class="w ws" points="470,515 430,515 430,715 860,715 860,470 880,470"/>
|
||||
<polyline class="w clk" points="470,477 415,477 415,730 845,730 845,500 880,500"/>
|
||||
<polyline class="w data" points="750,173 830,173 830,530 880,530"/>
|
||||
<text x="262" y="680" class="lbl" font-size="11">GPIO 14 → LRC (vert)</text>
|
||||
<text x="262" y="696" class="lbl" font-size="11">GPIO 27 → BCLK (bleu)</text>
|
||||
<text x="262" y="716" class="lbl" font-size="11">ces deux fils contournent la carte</text>
|
||||
<text x="760" y="166" class="lbl">GPIO 22 → DIN</text>
|
||||
|
||||
<!-- GAIN et SD laissés libres -->
|
||||
<text x="880" y="650" class="lbl" font-size="11">GAIN : NC → 9 dB</text>
|
||||
<text x="880" y="666" class="lbl" font-size="11">SD : NC → ampli actif</text>
|
||||
|
||||
<polyline class="w gnd" points="1020,620 1020,790"/>
|
||||
<polyline class="w v5" points="1070,620 1070,830"/>
|
||||
|
||||
<!-- découplage conseillé : C1 (réservoir) + C2 (haute fréquence) -->
|
||||
<g>
|
||||
<polyline class="w v5" points="1070,655 1290,655"/>
|
||||
<circle cx="1070" cy="655" r="4" fill="#333"/>
|
||||
|
||||
<!-- C1 : électrolytique polarisé -->
|
||||
<line x1="1180" y1="655" x2="1180" y2="660" stroke="#333" stroke-width="2"/>
|
||||
<line x1="1164" y1="660" x2="1196" y2="660" stroke="#333" stroke-width="3"/>
|
||||
<line x1="1164" y1="672" x2="1196" y2="672" stroke="#333" stroke-width="3"/>
|
||||
<polyline class="w gnd" points="1180,672 1180,790"/>
|
||||
<text x="1150" y="666" class="lbl" font-size="13">+</text>
|
||||
<text x="1202" y="650" class="pinused">C1</text>
|
||||
|
||||
<!-- C2 : céramique -->
|
||||
<line x1="1260" y1="655" x2="1260" y2="660" stroke="#333" stroke-width="2"/>
|
||||
<line x1="1244" y1="660" x2="1276" y2="660" stroke="#333" stroke-width="3"/>
|
||||
<line x1="1244" y1="672" x2="1276" y2="672" stroke="#333" stroke-width="3"/>
|
||||
<polyline class="w gnd" points="1260,672 1260,790"/>
|
||||
<text x="1282" y="650" class="pinused">C2</text>
|
||||
|
||||
<text x="1000" y="876" class="title" font-size="13">Découplage conseillé</text>
|
||||
<text x="1000" y="900" class="lbl">C1 = 470 µF / 16 V électrolytique, faible ESR</text>
|
||||
<text x="1000" y="920" class="lbl"> polarisé : la bande imprimée est le « − »</text>
|
||||
<text x="1000" y="940" class="lbl">C2 = 100 nF X7R céramique (code 104)</text>
|
||||
<text x="1000" y="960" class="lbl">les deux à moins de 3 cm des broches de l'ampli</text>
|
||||
</g>
|
||||
|
||||
<!-- =============================== haut-parleur =============================== -->
|
||||
<rect x="1210" y="440" width="130" height="120" rx="6" class="box"/>
|
||||
<text x="1275" y="478" class="title" text-anchor="middle" font-size="15">HP</text>
|
||||
<text x="1275" y="500" class="sub" text-anchor="middle">4 Ω / 3 W</text>
|
||||
<text x="1275" y="518" class="sub" text-anchor="middle">(ou 8 Ω / 2 W)</text>
|
||||
<line class="w gnd" x1="1150" y1="470" x2="1210" y2="470"/>
|
||||
<line class="w gnd" x1="1150" y1="530" x2="1210" y2="530"/>
|
||||
<text x="1152" y="462" class="lbl">+</text>
|
||||
<text x="1150" y="586" class="lbl" font-size="11">« − » jamais à la masse</text>
|
||||
|
||||
<!-- =============================== bouton PTT =============================== -->
|
||||
<rect x="900" y="150" width="160" height="56" rx="6" class="box"/>
|
||||
<text x="980" y="172" class="title" text-anchor="middle" font-size="14">Bouton PTT</text>
|
||||
<text x="980" y="192" class="sub" text-anchor="middle">pull-up interne</text>
|
||||
<polyline class="w gnd" points="750,515 800,515 800,177 900,177"/>
|
||||
<polyline class="w gnd" points="1060,177 1120,177 1120,225"/>
|
||||
<text x="756" y="508" class="lbl" font-size="11">→ bouton</text>
|
||||
<g stroke="#333333" stroke-width="2.4" fill="none">
|
||||
<line x1="1108" y1="225" x2="1132" y2="225"/>
|
||||
<line x1="1113" y1="231" x2="1127" y2="231"/>
|
||||
<line x1="1118" y1="237" x2="1122" y2="237"/>
|
||||
</g>
|
||||
<text x="1140" y="231" class="lbl">GND</text>
|
||||
|
||||
<!-- =============================== LED d'état =============================== -->
|
||||
<rect x="900" y="250" width="160" height="56" rx="6" class="box"/>
|
||||
<text x="980" y="272" class="title" text-anchor="middle" font-size="14">LED + 330 Ω</text>
|
||||
<text x="980" y="292" class="sub" text-anchor="middle">optionnelle (LED intégrée)</text>
|
||||
<polyline class="w gnd" points="750,553 815,553 815,277 900,277"/>
|
||||
<polyline class="w gnd" points="1060,277 1120,277 1120,325"/>
|
||||
<text x="756" y="546" class="lbl" font-size="11">→ LED</text>
|
||||
<g stroke="#333333" stroke-width="2.4" fill="none">
|
||||
<line x1="1108" y1="325" x2="1132" y2="325"/>
|
||||
<line x1="1113" y1="331" x2="1127" y2="331"/>
|
||||
<line x1="1118" y1="337" x2="1122" y2="337"/>
|
||||
</g>
|
||||
<text x="1140" y="331" class="lbl">GND</text>
|
||||
|
||||
<!-- =============================== rails =============================== -->
|
||||
<line class="w v33" x1="140" y1="750" x2="1340" y2="750"/>
|
||||
<line class="w gnd" x1="140" y1="790" x2="1340" y2="790"/>
|
||||
<line class="w v5" x1="140" y1="830" x2="1340" y2="830"/>
|
||||
<text x="140" y="744" class="lbl" fill="#cc2222">3,3 V</text>
|
||||
<text x="140" y="784" class="lbl">GND (masse commune)</text>
|
||||
<text x="140" y="824" class="lbl" fill="#e08214">5 V</text>
|
||||
|
||||
<!-- alimentations prélevées sur l'ESP32 -->
|
||||
<polyline class="w v33" points="750,667 800,667 800,750"/>
|
||||
<polyline class="w gnd" points="750,629 775,629 775,790"/>
|
||||
<polyline class="w v5" points="470,667 455,667 455,830"/>
|
||||
|
||||
<!-- points de connexion aux rails -->
|
||||
<g fill="#cc2222"><circle cx="800" cy="750" r="4"/><circle cx="240" cy="750" r="4"/></g>
|
||||
<g fill="#333333">
|
||||
<circle cx="775" cy="790" r="4"/><circle cx="170" cy="790" r="4"/>
|
||||
<circle cx="200" cy="790" r="4"/><circle cx="1020" cy="790" r="4"/>
|
||||
<circle cx="1180" cy="790" r="4"/><circle cx="1260" cy="790" r="4"/>
|
||||
</g>
|
||||
<g fill="#e08214"><circle cx="455" cy="830" r="4"/><circle cx="1070" cy="830" r="4"/></g>
|
||||
|
||||
<!-- =============================== légende =============================== -->
|
||||
<rect x="30" y="852" width="520" height="140" rx="6" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
|
||||
<text x="44" y="876" class="title" font-size="14">Légende</text>
|
||||
<g class="lbl">
|
||||
<line class="w v5" x1="44" y1="896" x2="80" y2="896"/><text x="88" y="900">5 V</text>
|
||||
<line class="w v33" x1="44" y1="918" x2="80" y2="918"/><text x="88" y="922">3,3 V</text>
|
||||
<line class="w gnd" x1="44" y1="940" x2="80" y2="940"/><text x="88" y="944">masse / logique</text>
|
||||
<line class="w clk" x1="230" y1="896" x2="266" y2="896"/><text x="274" y="900">horloge bit (SCK / BCLK)</text>
|
||||
<line class="w ws" x1="230" y1="918" x2="266" y2="918"/><text x="274" y="922">mot (WS / LRC)</text>
|
||||
<line class="w data" x1="230" y1="940" x2="266" y2="940"/><text x="274" y="944">données audio (SD / DIN)</text>
|
||||
</g>
|
||||
<circle cx="52" cy="964" r="4" fill="#333"/>
|
||||
<text x="88" y="968" class="lbl">connexion électrique — deux fils qui se croisent sans point ne sont pas reliés.</text>
|
||||
|
||||
<!-- =============================== avertissements =============================== -->
|
||||
<text x="580" y="876" class="warn">Masse commune obligatoire entre tous les modules.</text>
|
||||
<text x="580" y="900" class="lbl">Câblez hors tension. INMP441 en 3,3 V : le 5 V le détruit.</text>
|
||||
<text x="580" y="920" class="lbl">Fils I2S de 15 cm maximum, éloignés des fils du haut-parleur.</text>
|
||||
<text x="580" y="940" class="lbl">Alimentation 5 V / 2 A : l'ampli demande jusqu'à 1,2 A sur les crêtes.</text>
|
||||
<text x="580" y="960" class="lbl">Détails et vérifications : docs/BROCHAGE.md</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,243 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/**
|
||||
* @file config.h
|
||||
* @brief Constantes matérielles et audio du firmware.
|
||||
*
|
||||
* Toutes les valeurs réglables du firmware sont ici : aucune constante magique
|
||||
* dans le code. Les broches évitent les broches de « strapping » (0, 2, 5, 12, 15)
|
||||
* pour les signaux audio, car leur état au démarrage change le mode de boot.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------- Format audio
|
||||
|
||||
/// Fréquence d'échantillonnage unique de tout le système (Hz).
|
||||
static constexpr uint32_t AUDIO_SAMPLE_RATE = 16000;
|
||||
|
||||
/// Durée d'un chunk audio (ms). 50 ms = compromis latence / surcoût protocole.
|
||||
static constexpr uint32_t AUDIO_CHUNK_MS = 50;
|
||||
|
||||
/// Nombre d'échantillons 16 bits dans un chunk : 16000 x 0,050 = 800.
|
||||
static constexpr size_t AUDIO_CHUNK_SAMPLES = (AUDIO_SAMPLE_RATE * AUDIO_CHUNK_MS) / 1000;
|
||||
|
||||
/// Taille d'un chunk en octets : 800 x 2 = 1600.
|
||||
static constexpr size_t AUDIO_CHUNK_BYTES = AUDIO_CHUNK_SAMPLES * sizeof(int16_t);
|
||||
|
||||
// --------------------------------------------------------- Broches micro INMP441
|
||||
|
||||
static constexpr int PIN_MIC_SCK = 25; ///< Horloge bit (BCLK) du micro
|
||||
static constexpr int PIN_MIC_WS = 26; ///< Sélection de mot (LRCL / WS)
|
||||
static constexpr int PIN_MIC_SD = 33; ///< Donnée série sortante du micro (DOUT)
|
||||
|
||||
// ------------------------------------------------------ Broches ampli MAX98357A
|
||||
|
||||
static constexpr int PIN_AMP_BCLK = 27; ///< Horloge bit
|
||||
static constexpr int PIN_AMP_LRC = 14; ///< Sélection de mot
|
||||
static constexpr int PIN_AMP_DIN = 22; ///< Donnée série entrante de l'ampli
|
||||
|
||||
// ------------------------------------------------------------ Interface utilisateur
|
||||
|
||||
static constexpr int PIN_BUTTON_PTT = 4; ///< Bouton « Push-To-Talk », pull-up interne
|
||||
static constexpr int PIN_STATUS_LED = 2; ///< LED d'état (broche de strapping, sortie seule : acceptable)
|
||||
/// LED de démonstration domotique (étape 11) : « la lumière » de la pièce de la carte.
|
||||
/// GPIO 21 : libre, sans rôle au démarrage. LED + résistance 330 Ω vers GND.
|
||||
static constexpr int PIN_DEMO_LIGHT = 21;
|
||||
|
||||
/// Durée d'anti-rebond du bouton (ms).
|
||||
static constexpr uint32_t BUTTON_DEBOUNCE_MS = 30;
|
||||
|
||||
// ------------------------------------------------------------ Conversion micro
|
||||
|
||||
/**
|
||||
* Gain numérique appliqué après le passe-haut, avant la réduction à 16 bits.
|
||||
*
|
||||
* Valeur **mesurée**. Une phrase à 20-30 cm donne, une fois le grondement
|
||||
* infrasonore retiré par le passe-haut, une crête vers -25 dBFS : un gain de 4
|
||||
* la place vers -13 dBFS, soit ~22 % de la pleine échelle, avec 13 dB de marge.
|
||||
*
|
||||
* Les valeurs 16 puis 1 retenues plus tôt venaient de mesures faussées (cadrage
|
||||
* des bits erroné, puis infrasons dominants). L'auto-test annonce la valeur à
|
||||
* retenir à la fin de chaque session ; elle dépend du module et de la distance.
|
||||
*
|
||||
* Le gain est numérique : il ne dégrade pas le rapport signal/bruit, il replace
|
||||
* seulement le signal dans la plage utile.
|
||||
*/
|
||||
static constexpr int MIC_GAIN = 4;
|
||||
|
||||
/**
|
||||
* Durée de stabilisation du micro après activation du canal I2S (ms).
|
||||
*
|
||||
* L'INMP441 s'endort dès que ses horloges s'arrêtent, et chaque réveil produit
|
||||
* une composante continue qui dérive lentement. Mesuré sur le banc : encore
|
||||
* +23 % de pleine échelle et en mouvement 600 ms après le réveil. On jette donc
|
||||
* ce début, UNE seule fois au démarrage : ensuite, les horloges ne s'arrêtent
|
||||
* plus jamais (flush() vide le DMA par lecture).
|
||||
*/
|
||||
static constexpr uint32_t MIC_SETTLE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Pull-down interne sur la ligne SD du micro.
|
||||
*
|
||||
* Le datasheet de l'INMP441 exige une résistance de 100 kOhms entre SD et la
|
||||
* masse : le micro met sa sortie en haute impédance hors de sa moitié de trame.
|
||||
* Beaucoup de modules bon marché ne la portent pas. Le pull-down interne de
|
||||
* l'ESP32 (environ 45 kOhms) remplit le même rôle sans composant à ajouter.
|
||||
*/
|
||||
static constexpr bool MIC_SD_PULLDOWN = true;
|
||||
|
||||
/// Affiche l'analyse détaillée des bits au démarrage (utile tant que l'étape 1 n'est pas validée).
|
||||
static constexpr bool MIC_SELFTEST_VERBOSE = true;
|
||||
|
||||
/// Durée de l'auto-test du micro au démarrage (ms).
|
||||
static constexpr uint32_t MIC_PROBE_MS = 300;
|
||||
|
||||
/**
|
||||
* Filtre passe-haut du micro, du premier ordre : bas += (x - bas) >> SHIFT ;
|
||||
* sortie = x - bas. Fréquence de coupure : fc = fs / (2 pi 2^SHIFT).
|
||||
*
|
||||
* 5 donne fc = 16000 / (2 pi 32) = 80 Hz. La voix n'a rien d'utile en dessous.
|
||||
* Mesuré sur le banc : avec l'ancien réglage (12, soit 0,6 Hz, qui ne retirait
|
||||
* que la composante continue), 87 % de l'énergie d'un enregistrement était
|
||||
* infrasonore — mouvements de la plaque d'essai quand on tient le bouton,
|
||||
* souffle de la parole. Avec 5, il en reste 0,6 %, et la voix 99 %.
|
||||
*
|
||||
* Calculé sur les 24 bits du micro : sur 16 bits, un pas de 1/32 laisserait une
|
||||
* zone morte de 31 LSB, source d'erreur continue et de distorsion à bas niveau.
|
||||
*/
|
||||
static constexpr int MIC_HPF_SHIFT = 5;
|
||||
|
||||
// ------------------------------------------------------------ Sortie audio (étape 2)
|
||||
|
||||
/**
|
||||
* Cadrage I2S attendu par l'ampli. Le MAX98357**A** suit la norme Philips, le
|
||||
* MAX98357**B** le cadrage MSB (c'est la lettre après le numéro, sur la puce).
|
||||
* Si le bip de démarrage sort râpeux ou soufflé au lieu d'être pur : basculer.
|
||||
*/
|
||||
static constexpr bool AMP_FORMAT_PHILIPS = true;
|
||||
|
||||
/// Bip de démarrage : teste la sortie SEULE, sans PC, sans FIFO, sans tâche.
|
||||
static constexpr uint32_t BOOT_TONE_HZ = 440;
|
||||
static constexpr uint32_t BOOT_TONE_MS = 600;
|
||||
|
||||
/**
|
||||
* Amplitude du bip : 8192 = 25 % de pleine échelle = -12 dBFS.
|
||||
* Avec le gain de 9 dB du MAX98357A sur 4 ohms, environ 0,15 W : net sans agresser.
|
||||
*/
|
||||
static constexpr int16_t BOOT_TONE_AMPLITUDE = 8192;
|
||||
|
||||
/**
|
||||
* FIFO de lecture : 16384 octets = 8192 échantillons = 512 ms à 16 kHz.
|
||||
* Elle contient la réserve anti-gigue (PLAYBACK_START_MS) et encore 212 ms pour
|
||||
* les rafales : après un retard, le réseau livre souvent plusieurs chunks d'un coup.
|
||||
*/
|
||||
static constexpr size_t PLAYBACK_FIFO_BYTES = 16384;
|
||||
|
||||
/// Bloc lu par la tâche audio : 256 échantillons = 16 ms = 512 octets.
|
||||
static constexpr size_t PLAYBACK_BLOCK_SAMPLES = 256;
|
||||
|
||||
/**
|
||||
* Tampon anti-gigue (étape 12) : réserve à constituer avant de jouer le premier
|
||||
* échantillon, et à reconstituer après une famine. 300 ms = 6 chunks = 9600 octets,
|
||||
* soit l'avance que le serveur envoie d'emblée : la lecture démarre dès qu'elle est
|
||||
* arrivée. Un retard réseau plus court que la réserve ne s'entend pas.
|
||||
*/
|
||||
static constexpr uint32_t PLAYBACK_START_MS = 300;
|
||||
|
||||
/// Tâche audio : au-dessus de loop() (priorité 1), sur le cœur 1 (le Wi-Fi prendra le 0).
|
||||
static constexpr int AUDIO_TASK_PRIORITY = 5;
|
||||
static constexpr int AUDIO_TASK_CORE = 1;
|
||||
static constexpr uint32_t AUDIO_TASK_STACK_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Tampon de réception série : 8192 octets = 256 ms de PCM au débit de lecture.
|
||||
* Le tampon par défaut (256 octets) déborderait en 8 ms pendant qu'on enregistre.
|
||||
*/
|
||||
static constexpr size_t SERIAL_RX_BUFFER_BYTES = 8192;
|
||||
|
||||
// ------------------------------------------------------------ Capture vers le réseau (étape 4)
|
||||
|
||||
/**
|
||||
* FIFO de capture : 32768 octets = 1,02 s de PCM. L'envoi MQTT peut bloquer quand
|
||||
* le Wi-Fi peine ; la tâche de capture, elle, ne s'arrête jamais. Au-delà d'une
|
||||
* seconde de retard réseau, des chunks sont perdus — et comptés.
|
||||
*/
|
||||
static constexpr size_t CAPTURE_FIFO_BYTES = 32768;
|
||||
|
||||
/// Tâche de capture : même priorité et même cœur que la tâche de lecture.
|
||||
static constexpr int CAPTURE_TASK_PRIORITY = 5;
|
||||
static constexpr int CAPTURE_TASK_CORE = 1;
|
||||
static constexpr uint32_t CAPTURE_TASK_STACK_BYTES = 4096;
|
||||
|
||||
// ------------------------------------------------------------ Liaison série (étape 1)
|
||||
|
||||
/// Débit série : 921 600 baud = ~92 kB/s, largement au-dessus des 32 kB/s d'audio.
|
||||
static constexpr uint32_t SERIAL_BAUD = 921600;
|
||||
|
||||
// ------------------------------------------------------------ Wi-Fi et MQTT (étape 3)
|
||||
|
||||
/// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/...
|
||||
/// Sert aussi d'identifiant client MQTT : deux cartes ne doivent jamais le partager,
|
||||
/// sinon le broker déconnecte l'une dès que l'autre se connecte, en boucle.
|
||||
static constexpr const char* DEVICE_ID = "esp32-01";
|
||||
|
||||
/// Préfixe commun de tous les topics.
|
||||
static constexpr const char* MQTT_TOPIC_PREFIX = "voice";
|
||||
|
||||
/**
|
||||
* Intervalle de maintien de connexion MQTT (s). Sans nouvelle de l'ESP32 pendant
|
||||
* 1,5 x cet intervalle, le broker publie le testament « offline ». 10 s : une
|
||||
* coupure de courant est signalée en 15 s au plus, pour 1 petit paquet toutes
|
||||
* les 10 s.
|
||||
*/
|
||||
static constexpr uint16_t MQTT_KEEPALIVE_S = 10;
|
||||
|
||||
/// Tampon de PubSubClient : un chunk de 1600 octets + topic + en-tête MQTT (étape 4).
|
||||
static constexpr uint16_t MQTT_BUFFER_BYTES = 2048;
|
||||
|
||||
/// Délai entre deux tentatives de connexion au broker (ms).
|
||||
static constexpr uint32_t MQTT_RETRY_MS = 5000;
|
||||
|
||||
/**
|
||||
* Attente maximale d'une réponse du broker (s). Une tentative de connexion bloque
|
||||
* loop() au plus MQTT_CONNECT_TIMEOUT_MS + ce temps, soit 3 s, et seulement quand le
|
||||
* broker est injoignable : l'audio, lui, tourne dans ses propres tâches (étape 15 :
|
||||
* borné et mesuré plutôt que déplacé dans une tâche, PubSubClient n'étant pas prévu
|
||||
* pour être partagé entre tâches).
|
||||
*/
|
||||
static constexpr uint16_t MQTT_SOCKET_TIMEOUT_S = 2;
|
||||
|
||||
/// Délai d'ouverture de la connexion TCP au broker (ms). 3000 par défaut dans le core ;
|
||||
/// sur un réseau local, un broker présent répond en quelques millisecondes.
|
||||
static constexpr uint32_t MQTT_CONNECT_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Présence du serveur (étape 15) : « online » retenu, remplacé par son testament
|
||||
* « offline » s'il disparaît. Suit MQTT_TOPIC_PREFIX. Serveur absent = micro coupé :
|
||||
* rien ne part plus sur le réseau sans personne pour l'écouter.
|
||||
*/
|
||||
static constexpr const char* MQTT_SERVER_STATUS_TOPIC = "voice/server/status";
|
||||
|
||||
/// Période de republication de l'état (ms) : IP, RSSI, durée de fonctionnement.
|
||||
static constexpr uint32_t STATE_PERIOD_MS = 30000;
|
||||
|
||||
// ------------------------------------------------------------ Fiabilisation (étape 15)
|
||||
|
||||
/**
|
||||
* Chien de garde (watchdog) des tâches : une tâche surveillée (loop, capture, audio)
|
||||
* qui ne donne plus signe de vie pendant ce délai fait redémarrer la carte, avec la
|
||||
* cause « watchdog » dans l'événement boot. Mieux vaut 2 s de redémarrage qu'une
|
||||
* carte figée pour toujours.
|
||||
*
|
||||
* 15 s : au-dessus du pire blocage LÉGITIME trouvé dans le code réseau du core — un
|
||||
* envoi TCP attend jusqu'à 10 x 1 s que le Wi-Fi accepte des données
|
||||
* (NetworkClient::write). Le délai par défaut (5 s) redémarrerait la carte sur un
|
||||
* simple Wi-Fi faible.
|
||||
*/
|
||||
static constexpr uint32_t WATCHDOG_TIMEOUT_S = 15;
|
||||
|
||||
/// Wi-Fi absent depuis ce délai (ms) : on relance la connexion de zéro. Filet de
|
||||
/// sécurité, si la reconnexion automatique n'aboutit pas (point d'accès redémarré...).
|
||||
static constexpr uint32_t WIFI_RESTART_MS = 30000;
|
||||
@@ -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 = "";
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/stream_buffer.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
/**
|
||||
* @brief File d'attente d'octets PCM entre deux rythmes différents.
|
||||
*
|
||||
* Deux usages, même mécanisme :
|
||||
* - lecture (étape 2) : la réception écrit par à-coups, la tâche audio lit au
|
||||
* rythme régulier du DAC ;
|
||||
* - capture (étape 4) : la tâche de capture écrit au rythme du micro, l'envoi
|
||||
* réseau lit par à-coups, au gré du Wi-Fi.
|
||||
*
|
||||
* C'est un StreamBuffer FreeRTOS, conçu pour exactement un écrivain et un
|
||||
* lecteur. Son stockage est fourni par l'appelant (tableau statique) : aucune
|
||||
* allocation, et chaque FIFO a la taille qui convient à son usage.
|
||||
*
|
||||
* Invariant : on n'écrit que des chunks entiers, jamais un morceau. Un chunk
|
||||
* coupé laisserait un nombre impair d'octets, et tous les échantillons suivants
|
||||
* seraient lus à cheval sur deux valeurs — un bruit assourdissant.
|
||||
*/
|
||||
class AudioFifo {
|
||||
public:
|
||||
AudioFifo() = default;
|
||||
|
||||
/**
|
||||
* @brief Crée le StreamBuffer sur le stockage fourni.
|
||||
* @param storage Tableau d'au moins @p capacity + 1 octets (exigence FreeRTOS).
|
||||
* @param capacity Capacité utile en octets.
|
||||
* @param wakeBytes Nombre d'octets qui réveillent un lecteur en attente.
|
||||
* @return true si la création a réussi.
|
||||
*/
|
||||
bool begin(uint8_t* storage, size_t capacity, size_t wakeBytes);
|
||||
|
||||
/**
|
||||
* @brief Ajoute un chunk, sans jamais attendre.
|
||||
* @return true si le chunk entier a été accepté ; false s'il a été refusé
|
||||
* faute de place (il est alors compté comme perdu, en entier).
|
||||
*/
|
||||
bool write(const uint8_t* data, size_t length);
|
||||
|
||||
/**
|
||||
* @brief Retire au plus @p length octets, en attendant au plus @p timeoutMs.
|
||||
* @return Nombre d'octets lus (pair, tant qu'on ne demande que des tailles paires).
|
||||
*/
|
||||
size_t read(uint8_t* data, size_t length, uint32_t timeoutMs);
|
||||
|
||||
/// Octets actuellement en attente.
|
||||
size_t available() const;
|
||||
|
||||
/// Nombre de chunks refusés faute de place depuis le démarrage.
|
||||
uint32_t overruns() const;
|
||||
|
||||
private:
|
||||
StaticStreamBuffer_t _control = {};
|
||||
StreamBufferHandle_t _handle = nullptr;
|
||||
std::atomic<uint32_t> _overruns{0}; ///< lu par loop(), incrémenté par l'écrivain
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "AudioOutput.h"
|
||||
|
||||
#include <math.h>
|
||||
|
||||
AudioOutput::AudioOutput(int pinBclk, int pinLrc, int pinDin)
|
||||
: _pinBclk(pinBclk), _pinLrc(pinLrc), _pinDin(pinDin) {}
|
||||
|
||||
bool AudioOutput::begin(uint32_t sampleRate, bool philips) {
|
||||
// --- 1. Canal I2S1 en émission, l'ESP32 maître d'horloge.
|
||||
i2s_chan_config_t chanConfig = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
|
||||
// 6 descripteurs de 240 trames = 1440 trames = 90 ms de marge : c'est aussi la
|
||||
// latence ajoutée par le DMA entre write() et le haut-parleur.
|
||||
chanConfig.dma_desc_num = 6;
|
||||
chanConfig.dma_frame_num = 240;
|
||||
// DMA à court de données : il envoie des zéros (silence) au lieu de répéter
|
||||
// en boucle le dernier tampon, ce qui produirait un bourdonnement.
|
||||
chanConfig.auto_clear = true;
|
||||
|
||||
if (i2s_new_channel(&chanConfig, &_txHandle, nullptr) != ESP_OK) {
|
||||
_txHandle = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- 2. 16 bits, deux voies, cadrage choisi. Horloge bit : 16000 x 32 = 512 kHz.
|
||||
const i2s_std_slot_config_t philipsSlots =
|
||||
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO);
|
||||
const i2s_std_slot_config_t msbSlots =
|
||||
I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO);
|
||||
|
||||
i2s_std_config_t stdConfig = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate),
|
||||
.slot_cfg = philips ? philipsSlots : msbSlots,
|
||||
.gpio_cfg = {
|
||||
.mclk = I2S_GPIO_UNUSED, // le MAX98357A n'a pas besoin d'horloge maître
|
||||
.bclk = static_cast<gpio_num_t>(_pinBclk),
|
||||
.ws = static_cast<gpio_num_t>(_pinLrc),
|
||||
.dout = static_cast<gpio_num_t>(_pinDin),
|
||||
.din = I2S_GPIO_UNUSED,
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (i2s_channel_init_std_mode(_txHandle, &stdConfig) != ESP_OK ||
|
||||
i2s_channel_enable(_txHandle) != ESP_OK) {
|
||||
i2s_del_channel(_txHandle);
|
||||
_txHandle = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
_sampleRate = sampleRate;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t AudioOutput::write(const int16_t* samples, size_t count) {
|
||||
if (_txHandle == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t done = 0;
|
||||
while (done < count) {
|
||||
size_t frames = count - done;
|
||||
if (frames > BLOCK_FRAMES) {
|
||||
frames = BLOCK_FRAMES;
|
||||
}
|
||||
|
||||
// Mono -> stéréo : le même échantillon sur la voie gauche et la droite.
|
||||
for (size_t i = 0; i < frames; ++i) {
|
||||
_stereo[i * 2] = samples[done + i];
|
||||
_stereo[(i * 2) + 1] = samples[done + i];
|
||||
}
|
||||
|
||||
size_t written = 0;
|
||||
if (i2s_channel_write(_txHandle, _stereo, frames * 2 * sizeof(int16_t), &written,
|
||||
portMAX_DELAY) != ESP_OK) {
|
||||
break;
|
||||
}
|
||||
done += written / (2 * sizeof(int16_t));
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
void AudioOutput::playTone(uint32_t frequencyHz, uint32_t durationMs, int16_t amplitude) {
|
||||
if (_txHandle == nullptr || _sampleRate == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t total = (static_cast<size_t>(_sampleRate) * durationMs) / 1000;
|
||||
const size_t fade = _sampleRate / 100; // 10 ms : un démarrage brutal claquerait
|
||||
const float step = 2.0F * static_cast<float>(M_PI) * static_cast<float>(frequencyHz) /
|
||||
static_cast<float>(_sampleRate);
|
||||
float phase = 0.0F;
|
||||
|
||||
size_t done = 0;
|
||||
while (done < total) {
|
||||
size_t count = total - done;
|
||||
if (count > BLOCK_FRAMES) {
|
||||
count = BLOCK_FRAMES;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const size_t n = done + i;
|
||||
float gain = 1.0F;
|
||||
if (n < fade) {
|
||||
gain = static_cast<float>(n) / static_cast<float>(fade);
|
||||
} else if (total - n < fade) {
|
||||
gain = static_cast<float>(total - n) / static_cast<float>(fade);
|
||||
}
|
||||
_tone[i] = static_cast<int16_t>(lroundf(static_cast<float>(amplitude) * gain *
|
||||
sinf(phase)));
|
||||
phase += step;
|
||||
if (phase > 2.0F * static_cast<float>(M_PI)) {
|
||||
phase -= 2.0F * static_cast<float>(M_PI); // évite la perte de précision
|
||||
}
|
||||
}
|
||||
|
||||
write(_tone, count);
|
||||
done += count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
/**
|
||||
* @brief Sortie audio vers l'ampli I2S MAX98357A, sur le périphérique I2S1.
|
||||
*
|
||||
* Reçoit du PCM 16 bits mono et l'émet en **stéréo, chaque échantillon copié
|
||||
* sur les deux voies**. Leçon de l'étape 1 : ne rien laisser au comportement
|
||||
* implicite d'un mode mono. Le MAX98357A joue par défaut (gauche + droite) / 2 :
|
||||
* avec deux voies identiques, le niveau est exact, et une éventuelle inversion
|
||||
* gauche/droite n'a aucune conséquence.
|
||||
*
|
||||
* Le canal tourne en permanence dès begin(). Quand rien n'est à jouer, le DMA
|
||||
* envoie des zéros : l'ampli reste cadencé et silencieux, ses broches ne
|
||||
* flottent jamais (c'était la cause du souffle au premier branchement).
|
||||
*/
|
||||
class AudioOutput {
|
||||
public:
|
||||
/**
|
||||
* @param pinBclk Horloge bit vers l'ampli (BCLK).
|
||||
* @param pinLrc Sélection de mot vers l'ampli (LRC).
|
||||
* @param pinDin Données vers l'ampli (DIN).
|
||||
*/
|
||||
AudioOutput(int pinBclk, int pinLrc, int pinDin);
|
||||
|
||||
/**
|
||||
* @brief Démarre le canal I2S1 en émission ; l'ampli reçoit aussitôt du silence.
|
||||
* @param sampleRate Fréquence d'échantillonnage en Hz.
|
||||
* @param philips true : cadrage Philips (MAX98357A) ; false : MSB (MAX98357B).
|
||||
* @return true si le démarrage a réussi.
|
||||
*/
|
||||
bool begin(uint32_t sampleRate, bool philips);
|
||||
|
||||
/**
|
||||
* @brief Émet des échantillons mono ; bloque jusqu'à ce que le DMA les accepte.
|
||||
*
|
||||
* Le blocage rythme naturellement l'appelant sur le débit du DAC. À n'appeler
|
||||
* que depuis un seul contexte (la tâche audio, ou setup() avant elle).
|
||||
*
|
||||
* @param samples Échantillons 16 bits signés.
|
||||
* @param count Nombre d'échantillons.
|
||||
* @return Nombre d'échantillons émis.
|
||||
*/
|
||||
size_t write(const int16_t* samples, size_t count);
|
||||
|
||||
/**
|
||||
* @brief Joue un son pur, de façon bloquante, avec fondus de 10 ms.
|
||||
*
|
||||
* Sert de test de la sortie seule : aucun PC, aucune FIFO, aucune tâche.
|
||||
*
|
||||
* @param frequencyHz Fréquence en Hz.
|
||||
* @param durationMs Durée en millisecondes.
|
||||
* @param amplitude Amplitude crête, 0 à 32767.
|
||||
*/
|
||||
void playTone(uint32_t frequencyHz, uint32_t durationMs, int16_t amplitude);
|
||||
|
||||
private:
|
||||
/// Trames traitées par passe : 256 = 16 ms à 16 kHz.
|
||||
static constexpr size_t BLOCK_FRAMES = 256;
|
||||
|
||||
int _pinBclk;
|
||||
int _pinLrc;
|
||||
int _pinDin;
|
||||
i2s_chan_handle_t _txHandle = nullptr;
|
||||
uint32_t _sampleRate = 0;
|
||||
int16_t _stereo[BLOCK_FRAMES * 2] = {}; ///< tampon d'émission, voies entrelacées
|
||||
int16_t _tone[BLOCK_FRAMES] = {}; ///< tampon de génération du son pur
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "CommandHandler.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
CommandHandler::CommandHandler(PlaybackStream& playback, MqttLink& mqtt, DemoDevices& devices,
|
||||
ModeCallback onListen, ModeCallback onCapture)
|
||||
: _playback(playback), _mqtt(mqtt), _devices(devices), _onListen(onListen), _onCapture(onCapture) {}
|
||||
|
||||
void CommandHandler::handle(const uint8_t* payload, size_t length) {
|
||||
JsonDocument doc; // messages de contrôle : quelques dizaines d'octets
|
||||
const DeserializationError error = deserializeJson(doc, payload, length);
|
||||
if (error) {
|
||||
Serial.printf("# control : JSON illisible (%s)\n", error.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const char* event = doc["event"] | "";
|
||||
const char* command = doc["cmd"] | "";
|
||||
|
||||
if (strcmp(event, "start") == 0) {
|
||||
const char* session = doc["session"] | "?";
|
||||
const bool accepted = _playback.open(session, doc["rate"] | 0UL, doc["bits"] | 0UL,
|
||||
doc["channels"] | 0UL, true);
|
||||
if (!accepted) {
|
||||
char reply[96];
|
||||
snprintf(reply, sizeof(reply), "{\"event\":\"rejected\",\"session\":\"%s\"}", session);
|
||||
_mqtt.publish("event", reply, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(event, "end") == 0) {
|
||||
_playback.close(doc["chunks"] | -1L);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(command, "ping") == 0) {
|
||||
char reply[64];
|
||||
snprintf(reply, sizeof(reply), "{\"event\":\"pong\",\"uptime_ms\":%lu}",
|
||||
static_cast<unsigned long>(millis()));
|
||||
_mqtt.publish("event", reply, false);
|
||||
Serial.println("# control : ping -> pong");
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(command, "listen") == 0) {
|
||||
_onListen(doc["enabled"] | false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(command, "capture") == 0) {
|
||||
_onCapture(doc["active"] | false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(command, "device") == 0) {
|
||||
handleDevice(doc["device"] | "", doc["action"] | "");
|
||||
return;
|
||||
}
|
||||
|
||||
const int shown = static_cast<int>(length > 120 ? 120 : length);
|
||||
Serial.printf("# control : message non reconnu : %.*s\n", shown,
|
||||
reinterpret_cast<const char*>(payload));
|
||||
}
|
||||
|
||||
void CommandHandler::handleDevice(const char* device, const char* action) {
|
||||
const bool ok = _devices.apply(device, action);
|
||||
const char* state = _devices.state(device);
|
||||
Serial.printf("# domotique : %s %s -> %s (%s)\n", device, action, ok ? "fait" : "REFUSE", state);
|
||||
|
||||
// Confirmation : le serveur sait que la commande a été reçue ET exécutée.
|
||||
char reply[128];
|
||||
snprintf(reply, sizeof(reply),
|
||||
"{\"event\":\"device\",\"device\":\"%.16s\",\"action\":\"%.8s\",\"ok\":%s,\"state\":\"%s\"}",
|
||||
device, action, ok ? "true" : "false", state);
|
||||
_mqtt.publish("event", reply, false);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "DemoDevices.h"
|
||||
#include "MqttLink.h"
|
||||
#include "PlaybackStream.h"
|
||||
|
||||
/**
|
||||
* @brief Interprète les messages JSON reçus sur voice/<id>/control.
|
||||
*
|
||||
* Deux familles de messages :
|
||||
* - flux audio sortant (étape 5), clé « event » :
|
||||
* {"event":"start","session":"s1","rate":16000,"bits":16,"channels":1,...}
|
||||
* {"event":"end","session":"s1","chunks":42}
|
||||
* - commandes, clé « cmd » :
|
||||
* {"cmd":"ping"} -> répond {"event":"pong","uptime_ms":...} sur voice/<id>/event
|
||||
* {"cmd":"device","device":"light","action":"on"} (étape 11)
|
||||
* -> applique la commande aux appareils de la pièce, puis confirme :
|
||||
* {"event":"device","device":"light","action":"on","ok":true,"state":"on"}
|
||||
* {"cmd":"listen","enabled":true} (étape 13) écoute continue : le micro part
|
||||
* vers le serveur sur voice/<id>/audio/stream, qui guette le mot de réveil
|
||||
* {"cmd":"capture","active":true} (étape 13) le serveur a entendu le mot de
|
||||
* réveil et écoute la commande : LED allumée
|
||||
*
|
||||
* Le traitement reste court : il est appelé depuis le rappel MQTT, dans loop().
|
||||
*/
|
||||
class CommandHandler {
|
||||
public:
|
||||
/// Rappel pour un mode activé ou désactivé par le serveur.
|
||||
using ModeCallback = void (*)(bool enabled);
|
||||
|
||||
CommandHandler(PlaybackStream& playback, MqttLink& mqtt, DemoDevices& devices,
|
||||
ModeCallback onListen, ModeCallback onCapture);
|
||||
|
||||
/// Traite un message du topic control.
|
||||
void handle(const uint8_t* payload, size_t length);
|
||||
|
||||
private:
|
||||
PlaybackStream& _playback;
|
||||
MqttLink& _mqtt;
|
||||
DemoDevices& _devices;
|
||||
ModeCallback _onListen;
|
||||
ModeCallback _onCapture;
|
||||
|
||||
/// Commande domotique : exécution puis confirmation sur voice/<id>/event.
|
||||
void handleDevice(const char* device, const char* action);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "DemoDevices.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
DemoDevices::DemoDevices(int lightPin) : _lightPin(lightPin) {}
|
||||
|
||||
void DemoDevices::begin() {
|
||||
pinMode(_lightPin, OUTPUT);
|
||||
digitalWrite(_lightPin, LOW);
|
||||
}
|
||||
|
||||
bool DemoDevices::apply(const char* device, const char* action) {
|
||||
const bool on = strcmp(action, "on") == 0;
|
||||
const bool off = strcmp(action, "off") == 0;
|
||||
const bool open = strcmp(action, "open") == 0;
|
||||
const bool close = strcmp(action, "close") == 0;
|
||||
|
||||
if (strcmp(device, "light") == 0 && (on || off)) {
|
||||
_lightOn = on;
|
||||
digitalWrite(_lightPin, on ? HIGH : LOW);
|
||||
return true;
|
||||
}
|
||||
if (strcmp(device, "shutter") == 0 && (open || close)) {
|
||||
_shutterOpen = open; // simulé : un moteur de volet se piloterait ici
|
||||
return true;
|
||||
}
|
||||
if (strcmp(device, "door") == 0 && (open || close)) {
|
||||
_doorOpen = open; // simulé : jamais de gâche réelle commandée à la voix (voir README)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* DemoDevices::state(const char* device) const {
|
||||
if (strcmp(device, "light") == 0) {
|
||||
return _lightOn ? "on" : "off";
|
||||
}
|
||||
if (strcmp(device, "shutter") == 0) {
|
||||
return _shutterOpen ? "open" : "closed";
|
||||
}
|
||||
if (strcmp(device, "door") == 0) {
|
||||
return _doorOpen ? "open" : "closed";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
/**
|
||||
* @brief Appareils de démonstration de la pièce où se trouve la carte (étape 11).
|
||||
*
|
||||
* La lumière est une vraie LED (PIN_DEMO_LIGHT, via une résistance de 330 Ω) ;
|
||||
* les volets et la porte sont simulés : leur état est gardé en mémoire et
|
||||
* affiché sur la liaison série. Un vrai relais se brancherait comme la LED.
|
||||
*/
|
||||
class DemoDevices {
|
||||
public:
|
||||
/// @param lightPin Broche de la LED qui figure la lumière.
|
||||
explicit DemoDevices(int lightPin);
|
||||
|
||||
/// Configure la broche de la LED, éteinte.
|
||||
void begin();
|
||||
|
||||
/**
|
||||
* @brief Applique une commande.
|
||||
* @param device « light », « shutter » ou « door ».
|
||||
* @param action « on »/« off » pour la lumière, « open »/« close » pour volets et porte.
|
||||
* @return false si l'appareil ou l'action est inconnu (rien n'est modifié).
|
||||
*/
|
||||
bool apply(const char* device, const char* action);
|
||||
|
||||
/// État courant : « on », « off », « open », « closed », ou « ? » si l'appareil est inconnu.
|
||||
const char* state(const char* device) const;
|
||||
|
||||
private:
|
||||
int _lightPin;
|
||||
bool _lightOn = false;
|
||||
bool _shutterOpen = false;
|
||||
bool _doorOpen = false;
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
#include "MicCapture.h"
|
||||
|
||||
#include <driver/gpio.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
MicCapture::MicCapture(int pinSck, int pinWs, int pinSd)
|
||||
: _pinSck(pinSck), _pinWs(pinWs), _pinSd(pinSd) {}
|
||||
|
||||
bool MicCapture::begin(uint32_t sampleRate, Format format) {
|
||||
// --- 1. Création du canal I2S0 en réception, l'ESP32 étant maître d'horloge.
|
||||
i2s_chan_config_t chanConfig = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
|
||||
// 6 descripteurs de 200 trames stéréo de 8 octets = 9600 octets, soit 150 ms
|
||||
// de marge à 16 kHz : de quoi absorber les pauses de la boucle principale.
|
||||
chanConfig.dma_desc_num = 6;
|
||||
chanConfig.dma_frame_num = 200;
|
||||
chanConfig.auto_clear = false;
|
||||
|
||||
if (i2s_new_channel(&chanConfig, nullptr, &_rxHandle) != ESP_OK) {
|
||||
_rxHandle = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- 2. Mots de 32 bits, les DEUX moitiés, cadrage choisi par l'appelant.
|
||||
const i2s_std_slot_config_t philips =
|
||||
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO);
|
||||
const i2s_std_slot_config_t msb =
|
||||
I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO);
|
||||
|
||||
i2s_std_config_t stdConfig = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate),
|
||||
.slot_cfg = (format == Format::Philips) ? philips : msb,
|
||||
.gpio_cfg = {
|
||||
.mclk = I2S_GPIO_UNUSED,
|
||||
.bclk = static_cast<gpio_num_t>(_pinSck),
|
||||
.ws = static_cast<gpio_num_t>(_pinWs),
|
||||
.dout = I2S_GPIO_UNUSED,
|
||||
.din = static_cast<gpio_num_t>(_pinSd),
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (i2s_channel_init_std_mode(_rxHandle, &stdConfig) != ESP_OK) {
|
||||
end();
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- 3. Pull-down sur la ligne de données, exigé par le datasheet INMP441 :
|
||||
// le micro met sa sortie en haute impédance hors de sa moitié de trame et
|
||||
// après ses 24 bits utiles. Sans résistance, la ligne flotte et l'on lit
|
||||
// n'importe quoi pendant ces instants.
|
||||
gpio_set_pull_mode(static_cast<gpio_num_t>(_pinSd),
|
||||
MIC_SD_PULLDOWN ? GPIO_PULLDOWN_ONLY : GPIO_FLOATING);
|
||||
|
||||
if (i2s_channel_enable(_rxHandle) != ESP_OK) {
|
||||
end();
|
||||
return false;
|
||||
}
|
||||
|
||||
_sampleRate = sampleRate;
|
||||
_format = format;
|
||||
_half = Half::First;
|
||||
_phase = 0; // le canal démarre sur un début de trame : la phase est ancrée ici
|
||||
_lowBand = 0;
|
||||
resetStats();
|
||||
|
||||
// Le micro vient d'être réveillé par ses horloges : on laisse passer son
|
||||
// transitoire. À partir d'ici, plus rien n'arrête les horloges.
|
||||
settle(MIC_SETTLE_MS);
|
||||
flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
void MicCapture::end() {
|
||||
if (_rxHandle == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
i2s_channel_disable(_rxHandle);
|
||||
i2s_del_channel(_rxHandle);
|
||||
_rxHandle = nullptr;
|
||||
_sampleRate = 0;
|
||||
}
|
||||
|
||||
void MicCapture::useHalf(Half half) {
|
||||
_half = half;
|
||||
flush(); // réamorce le filtre de continu sur la nouvelle moitié
|
||||
}
|
||||
|
||||
MicCapture::Half MicCapture::half() const {
|
||||
return _half;
|
||||
}
|
||||
|
||||
uint32_t MicCapture::sampleRate() const {
|
||||
return _sampleRate;
|
||||
}
|
||||
|
||||
MicCapture::Format MicCapture::format() const {
|
||||
return _format;
|
||||
}
|
||||
|
||||
size_t MicCapture::read(int16_t* buffer, size_t count) {
|
||||
if (_rxHandle == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Une trame vaut deux mots. Le pilote rend ce qui est disponible, pas
|
||||
// forcément un nombre pair de mots : la phase est donc mémorisée entre les
|
||||
// appels, sinon les deux moitiés s'échangent et l'on enregistre la muette.
|
||||
const uint8_t wantedPhase = (_half == Half::First) ? 0 : 1;
|
||||
size_t produced = 0;
|
||||
|
||||
while (produced < count) {
|
||||
size_t framesWanted = count - produced;
|
||||
if (framesWanted > SCRATCH_WORDS / 2) {
|
||||
framesWanted = SCRATCH_WORDS / 2;
|
||||
}
|
||||
|
||||
size_t bytesRead = 0;
|
||||
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch,
|
||||
framesWanted * 2 * sizeof(int32_t), &bytesRead,
|
||||
pdMS_TO_TICKS(200));
|
||||
|
||||
// Les mots reçus sont traités même si la lecture a été interrompue :
|
||||
// les ignorer décalerait la phase pour tout le reste de la session.
|
||||
const size_t words = bytesRead / sizeof(int32_t);
|
||||
for (size_t i = 0; i < words; ++i) {
|
||||
const bool selected = (_phase == wantedPhase);
|
||||
_phase ^= 1; // toujours avancer, même pour un mot ignoré
|
||||
|
||||
if (!selected || produced >= count) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int32_t word = _scratch[i];
|
||||
accumulate(_stats, word);
|
||||
|
||||
// Les 24 bits utiles occupent les bits 31..8 : on travaille en 24 bits
|
||||
// jusqu'au bout, et l'on ne réduit à 16 bits qu'à la toute fin.
|
||||
const int32_t sample24 = word >> 8;
|
||||
|
||||
// Passe-haut du premier ordre à 80 Hz : _lowBand suit tout ce qui est
|
||||
// plus lent (composante continue, grondement), on le retire.
|
||||
_lowBand += (sample24 - _lowBand) >> MIC_HPF_SHIFT;
|
||||
const int32_t filtered24 = sample24 - _lowBand;
|
||||
|
||||
const int32_t magnitude = (filtered24 < 0) ? -filtered24 : filtered24;
|
||||
if (magnitude > _stats.filteredPeak) {
|
||||
_stats.filteredPeak = magnitude;
|
||||
}
|
||||
|
||||
// Gain puis réduction 24 -> 16 bits, avec arrondi. |filtered24| < 2^25
|
||||
// et MIC_GAIN <= 64 : le produit tient dans un int32.
|
||||
int32_t out = (filtered24 * MIC_GAIN + 128) >> 8;
|
||||
|
||||
// Écrêtage explicite : sans lui, un dépassement produirait un craquement.
|
||||
if (out > 32767) {
|
||||
out = 32767;
|
||||
} else if (out < -32768) {
|
||||
out = -32768;
|
||||
}
|
||||
|
||||
buffer[produced] = static_cast<int16_t>(out);
|
||||
++produced;
|
||||
}
|
||||
|
||||
if (err != ESP_OK || bytesRead == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return produced;
|
||||
}
|
||||
|
||||
MicCapture::FrameStats MicCapture::probe(uint32_t durationMs) {
|
||||
FrameStats frames;
|
||||
if (_rxHandle == nullptr || _sampleRate == 0) {
|
||||
return frames;
|
||||
}
|
||||
|
||||
const size_t target = (static_cast<size_t>(_sampleRate) * durationMs) / 1000;
|
||||
while (frames.first.wordCount < target) {
|
||||
size_t bytesRead = 0;
|
||||
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch),
|
||||
&bytesRead, pdMS_TO_TICKS(200));
|
||||
|
||||
const size_t words = bytesRead / sizeof(int32_t);
|
||||
for (size_t i = 0; i < words; ++i) {
|
||||
accumulate((_phase == 0) ? frames.first : frames.second, _scratch[i]);
|
||||
_phase ^= 1;
|
||||
}
|
||||
|
||||
if (err != ESP_OK || bytesRead == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
size_t MicCapture::readRaw(int32_t* words, size_t count) {
|
||||
if (_rxHandle == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// On ne rend que des trames entières : si le prochain mot est une 2e moitié,
|
||||
// on le jette pour que words[0] soit toujours une 1re moitié.
|
||||
if (_phase != 0) {
|
||||
size_t bytesRead = 0;
|
||||
i2s_channel_read(_rxHandle, _scratch, sizeof(int32_t), &bytesRead, pdMS_TO_TICKS(200));
|
||||
_phase ^= static_cast<uint8_t>((bytesRead / sizeof(int32_t)) & 1U);
|
||||
if (_phase != 0) {
|
||||
return 0; // lecture impossible : on ne rend rien plutôt que du désaligné
|
||||
}
|
||||
}
|
||||
|
||||
size_t copied = 0;
|
||||
while (copied < count) {
|
||||
size_t wanted = count - copied;
|
||||
if (wanted > SCRATCH_WORDS) {
|
||||
wanted = SCRATCH_WORDS;
|
||||
}
|
||||
|
||||
size_t bytesRead = 0;
|
||||
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, wanted * sizeof(int32_t),
|
||||
&bytesRead, pdMS_TO_TICKS(200));
|
||||
|
||||
const size_t got = bytesRead / sizeof(int32_t);
|
||||
for (size_t i = 0; i < got; ++i) {
|
||||
words[copied + i] = _scratch[i];
|
||||
_phase ^= 1;
|
||||
}
|
||||
copied += got;
|
||||
|
||||
if (err != ESP_OK || bytesRead == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
MicCapture::DataLineReport MicCapture::probeDataLine() {
|
||||
DataLineReport report;
|
||||
report.samples = 4000; // environ 1 ms, soit une vingtaine de trames à 16 kHz
|
||||
|
||||
const gpio_num_t pin = static_cast<gpio_num_t>(_pinSd);
|
||||
|
||||
// gpio_get_level() lit l'état réel de la broche, même quand elle est routée
|
||||
// vers le périphérique I2S : les horloges peuvent donc continuer de tourner.
|
||||
gpio_set_pull_mode(pin, GPIO_PULLUP_ONLY);
|
||||
delayMicroseconds(200); // laisse la résistance charger la capacité de la ligne
|
||||
for (uint32_t i = 0; i < report.samples; ++i) {
|
||||
report.onesWithPullUp += static_cast<uint32_t>(gpio_get_level(pin));
|
||||
}
|
||||
|
||||
gpio_set_pull_mode(pin, GPIO_PULLDOWN_ONLY);
|
||||
delayMicroseconds(200);
|
||||
for (uint32_t i = 0; i < report.samples; ++i) {
|
||||
report.onesWithPullDown += static_cast<uint32_t>(gpio_get_level(pin));
|
||||
}
|
||||
|
||||
// Retour à la configuration de service.
|
||||
gpio_set_pull_mode(pin, MIC_SD_PULLDOWN ? GPIO_PULLDOWN_ONLY : GPIO_FLOATING);
|
||||
return report;
|
||||
}
|
||||
|
||||
void MicCapture::accumulate(RawStats& stats, int32_t word) {
|
||||
if (stats.wordCount == 0) {
|
||||
stats.minWord = word;
|
||||
stats.maxWord = word;
|
||||
} else if (word < stats.minWord) {
|
||||
stats.minWord = word;
|
||||
} else if (word > stats.maxWord) {
|
||||
stats.maxWord = word;
|
||||
}
|
||||
|
||||
if (word == 0) {
|
||||
++stats.zeroCount;
|
||||
}
|
||||
|
||||
// Valeur efficace : (mot >> 12) tient sur 20 bits, son carré sur 40 : un
|
||||
// int64 ne déborde qu'après des millions de mots, bien au-delà d'une session.
|
||||
const int64_t reduced = static_cast<int64_t>(word >> 12);
|
||||
stats.sum += reduced;
|
||||
stats.sumSquares += reduced * reduced;
|
||||
|
||||
// Le bit le plus bas jamais mis à 1 dit où s'arrêtent les données : 24 bits
|
||||
// bien cadrés s'arrêtent au bit 8. Ne dépend pas de l'amplitude du signal.
|
||||
stats.orBits |= static_cast<uint32_t>(word);
|
||||
|
||||
++stats.wordCount;
|
||||
}
|
||||
|
||||
void MicCapture::resetStats() {
|
||||
_stats = RawStats();
|
||||
}
|
||||
|
||||
MicCapture::RawStats MicCapture::stats() const {
|
||||
return _stats;
|
||||
}
|
||||
|
||||
void MicCapture::flush() {
|
||||
if (_rxHandle == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On vide le DMA par lecture, SANS arrêter le canal : arrêter les horloges
|
||||
// endormirait le micro, et son réveil produirait une dérive lente de la
|
||||
// composante continue, audible comme un « boum » en début de session.
|
||||
// La phase est suivie mot par mot, l'alignement est donc conservé.
|
||||
//
|
||||
// Au passage, on mesure la composante continue de la moitié retenue pour
|
||||
// amorcer le passe-haut : la session démarre sans transitoire.
|
||||
const uint8_t wantedPhase = (_half == Half::First) ? 0 : 1;
|
||||
int64_t sum = 0;
|
||||
size_t count = 0;
|
||||
size_t bytesRead = 0;
|
||||
|
||||
do {
|
||||
bytesRead = 0;
|
||||
i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch), &bytesRead, 0);
|
||||
|
||||
const size_t words = bytesRead / sizeof(int32_t);
|
||||
for (size_t i = 0; i < words; ++i) {
|
||||
if (_phase == wantedPhase) {
|
||||
sum += _scratch[i] >> 8; // en LSB 24 bits, comme le filtre
|
||||
++count;
|
||||
}
|
||||
_phase ^= 1;
|
||||
}
|
||||
} while (bytesRead > 0);
|
||||
|
||||
if (count > 0) {
|
||||
_lowBand = static_cast<int32_t>(sum / static_cast<int64_t>(count));
|
||||
}
|
||||
}
|
||||
|
||||
void MicCapture::settle(uint32_t durationMs) {
|
||||
if (_rxHandle == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t deadline = millis() + durationMs;
|
||||
while (static_cast<int32_t>(deadline - millis()) > 0) {
|
||||
size_t bytesRead = 0;
|
||||
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch),
|
||||
&bytesRead, pdMS_TO_TICKS(50));
|
||||
|
||||
// Mots jetés, mais comptés : un nombre impair inverse la phase.
|
||||
_phase ^= static_cast<uint8_t>((bytesRead / sizeof(int32_t)) & 1U);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
/**
|
||||
* @brief Capture audio depuis le micro I2S INMP441.
|
||||
*
|
||||
* Le micro fournit des échantillons 24 bits alignés à gauche dans des mots de
|
||||
* 32 bits. Cette classe lit ces mots via le DMA du périphérique I2S0, les
|
||||
* convertit en PCM 16 bits signé, supprime l'offset continu et applique un gain.
|
||||
*
|
||||
* Lecture en **stéréo** : une trame I2S contient deux moitiés, et l'INMP441
|
||||
* n'émet que dans l'une des deux, choisie par sa broche L/R. On lit les deux et
|
||||
* on choisit en logiciel, en suivant la phase mot par mot.
|
||||
*/
|
||||
class MicCapture {
|
||||
public:
|
||||
/// Moitié de trame I2S retenue.
|
||||
enum class Half {
|
||||
First, ///< premier mot de chaque trame
|
||||
Second, ///< second mot de chaque trame
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Cadrage des bits dans la trame.
|
||||
*
|
||||
* Philips (I2S standard) : le premier bit de donnée arrive un coup d'horloge
|
||||
* APRÈS le changement de WS. MSB (justifié à gauche) : il arrive EN MÊME
|
||||
* TEMPS. Un cadrage faux décale tout le mot d'un bit : le bit de signe tombe
|
||||
* hors de sa place, et un signal faible prend l'allure d'un bruit pleine
|
||||
* échelle.
|
||||
*/
|
||||
enum class Format {
|
||||
Philips,
|
||||
Msb,
|
||||
};
|
||||
|
||||
/// Statistiques sur des mots I2S bruts, avant toute conversion.
|
||||
struct RawStats {
|
||||
int32_t minWord = 0; ///< mot le plus négatif observé
|
||||
int32_t maxWord = 0; ///< mot le plus positif observé
|
||||
size_t wordCount = 0; ///< nombre de mots examinés
|
||||
size_t zeroCount = 0; ///< nombre de mots exactement nuls
|
||||
int64_t sum = 0; ///< somme de (mot >> 12), pour la composante continue
|
||||
int64_t sumSquares = 0; ///< somme des carrés de (mot >> 12), pour la valeur efficace
|
||||
uint32_t orBits = 0; ///< OU de tous les mots : quels bits ont servi au moins une fois
|
||||
int32_t filteredPeak = 0; ///< crête après passe-haut, avant gain, en LSB 24 bits (read() seul)
|
||||
};
|
||||
|
||||
/// Relevé simultané des deux moitiés d'une trame.
|
||||
struct FrameStats {
|
||||
RawStats first;
|
||||
RawStats second;
|
||||
};
|
||||
|
||||
/// Résultat du test électrique de la ligne de données.
|
||||
struct DataLineReport {
|
||||
uint32_t samples = 0; ///< nombre de lectures par essai
|
||||
uint32_t onesWithPullUp = 0; ///< niveaux hauts lus avec pull-up interne
|
||||
uint32_t onesWithPullDown = 0; ///< niveaux hauts lus avec pull-down interne
|
||||
};
|
||||
|
||||
/**
|
||||
* @param pinSck Broche d'horloge bit (SCK / BCLK du micro).
|
||||
* @param pinWs Broche de sélection de mot (WS / LRCL).
|
||||
* @param pinSd Broche de donnée série venant du micro (SD / DOUT).
|
||||
*/
|
||||
MicCapture(int pinSck, int pinWs, int pinSd);
|
||||
|
||||
/**
|
||||
* @brief Initialise le périphérique I2S0 en réception stéréo.
|
||||
* @param sampleRate Fréquence d'échantillonnage en Hz.
|
||||
* @param format Cadrage des bits dans la trame.
|
||||
* @return true si l'initialisation a réussi.
|
||||
*/
|
||||
bool begin(uint32_t sampleRate, Format format = Format::Philips);
|
||||
|
||||
/// Arrête et libère le canal I2S (obligatoire avant un nouveau begin()).
|
||||
void end();
|
||||
|
||||
/// Choisit la moitié de trame conservée par read().
|
||||
void useHalf(Half half);
|
||||
|
||||
/// Moitié de trame actuellement conservée.
|
||||
Half half() const;
|
||||
|
||||
/// Fréquence d'échantillonnage réellement en service.
|
||||
uint32_t sampleRate() const;
|
||||
|
||||
/// Cadrage actuellement en service.
|
||||
Format format() const;
|
||||
|
||||
/**
|
||||
* @brief Lit et convertit des échantillons audio.
|
||||
* @param buffer Destination, au moins @p count échantillons.
|
||||
* @param count Nombre d'échantillons 16 bits demandés.
|
||||
* @return Nombre d'échantillons réellement écrits (0 en cas d'erreur).
|
||||
*/
|
||||
size_t read(int16_t* buffer, size_t count);
|
||||
|
||||
/**
|
||||
* @brief Écoute pendant une durée donnée et analyse les deux moitiés.
|
||||
*
|
||||
* Les échantillons lus sont consommés : ne pas appeler pendant une session.
|
||||
*
|
||||
* @param durationMs Durée d'écoute en millisecondes.
|
||||
* @return Statistiques séparées des deux moitiés de trame.
|
||||
*/
|
||||
FrameStats probe(uint32_t durationMs);
|
||||
|
||||
/**
|
||||
* @brief Copie des mots I2S bruts, sans aucune conversion.
|
||||
*
|
||||
* Le premier mot copié est toujours une 1re moitié de trame.
|
||||
*
|
||||
* @param words Destination.
|
||||
* @param count Nombre de mots souhaités.
|
||||
* @return Nombre de mots réellement copiés.
|
||||
*/
|
||||
size_t readRaw(int32_t* words, size_t count);
|
||||
|
||||
/**
|
||||
* @brief Teste si la ligne de données est réellement pilotée par le micro.
|
||||
*
|
||||
* On applique le pull-up puis le pull-down interne (~45 kOhms) et on
|
||||
* échantillonne la broche : une sortie logique écrase ces résistances, une
|
||||
* ligne en l'air les suit. Les horloges doivent être CORRECTES pendant le
|
||||
* test, sinon le micro ne pilote rien et le verdict ne veut rien dire.
|
||||
*/
|
||||
DataLineReport probeDataLine();
|
||||
|
||||
/// Remet à zéro les statistiques accumulées par read().
|
||||
void resetStats();
|
||||
|
||||
/// Statistiques de la moitié retenue, depuis le dernier resetStats().
|
||||
RawStats stats() const;
|
||||
|
||||
/**
|
||||
* @brief Vide les tampons DMA sans arrêter les horloges du micro.
|
||||
*
|
||||
* À appeler au début d'une session : sans cela, les premières millisecondes
|
||||
* enregistrées seraient de l'audio ancien, capté avant l'appui sur le bouton.
|
||||
* Amorce aussi le filtre de composante continue avec la moyenne mesurée.
|
||||
*/
|
||||
void flush();
|
||||
|
||||
/// Met à jour un relevé avec un mot brut (public pour les outils de diagnostic).
|
||||
static void accumulate(RawStats& stats, int32_t word);
|
||||
|
||||
private:
|
||||
/// Taille du tampon de travail 32 bits (1 Kio de RAM, alloué une seule fois).
|
||||
static constexpr size_t SCRATCH_WORDS = 256;
|
||||
|
||||
int _pinSck;
|
||||
int _pinWs;
|
||||
int _pinSd;
|
||||
i2s_chan_handle_t _rxHandle = nullptr;
|
||||
uint32_t _sampleRate = 0; ///< fréquence retenue par begin()
|
||||
Format _format = Format::Philips; ///< cadrage retenu par begin()
|
||||
Half _half = Half::First; ///< moitié de trame conservée
|
||||
uint8_t _phase = 0; ///< 0 si le prochain mot lu est une 1re moitié
|
||||
int32_t _lowBand = 0; ///< basses fréquences suivies par le passe-haut, en LSB 24 bits
|
||||
int32_t _scratch[SCRATCH_WORDS] = {}; ///< tampon de lecture brute
|
||||
RawStats _stats; ///< statistiques, alimentées par read()
|
||||
|
||||
// Jette les échantillons pendant le transitoire de mise en route du micro
|
||||
void settle(uint32_t durationMs);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
#include "MicSelfTest.h"
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
namespace {
|
||||
|
||||
/// Pleine échelle d'un échantillon 24 bits.
|
||||
constexpr float FULL_SCALE_24 = 8388608.0F;
|
||||
|
||||
/// Résolution de l'INMP441 : bien cadré, le mot porte exactement ce nombre de bits.
|
||||
constexpr int MIC_DATA_BITS = 24;
|
||||
|
||||
/// Au repos, des variations au-dessus de ce seuil méritent une remarque.
|
||||
constexpr int NOISY_ROOM_DBFS = -30;
|
||||
|
||||
} // namespace
|
||||
|
||||
MicSelfTest::MicSelfTest(MicCapture& mic) : _mic(mic) {}
|
||||
|
||||
bool MicSelfTest::run() {
|
||||
Serial.println("# auto-test du micro : silence, ne parlez pas (2 s)...");
|
||||
|
||||
// Un seul réveil du micro, lu en cadrage MSB. begin() inclut la stabilisation.
|
||||
_mic.end();
|
||||
if (!_mic.begin(AUDIO_SAMPLE_RATE, MicCapture::Format::Msb)) {
|
||||
Serial.println("# ERREUR : initialisation I2S impossible");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.println("# cadrage moitie | continu | variations | bits | verdict");
|
||||
MicCapture::FrameStats frames;
|
||||
MicCapture::Half half = MicCapture::Half::First;
|
||||
const MicCapture::RawStats* active = measure(frames, half);
|
||||
|
||||
if (active == nullptr) {
|
||||
reportFailure("aucune moitie de trame ne porte de donnees.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 25 bits en lecture MSB : tout est décalé d'un rang vers le bas, le micro
|
||||
// émet avec le retard d'un bit du format Philips. On bascule et on vérifie.
|
||||
if (usedBits(*active) == MIC_DATA_BITS + 1) {
|
||||
Serial.println("# 25 bits utiles en MSB : le micro suit le cadrage Philips, bascule.");
|
||||
_mic.end();
|
||||
if (!_mic.begin(AUDIO_SAMPLE_RATE, MicCapture::Format::Philips)) {
|
||||
Serial.println("# ERREUR : initialisation I2S impossible");
|
||||
return false;
|
||||
}
|
||||
active = measure(frames, half);
|
||||
}
|
||||
|
||||
if (active == nullptr || usedBits(*active) != MIC_DATA_BITS) {
|
||||
reportFailure("pas de moitie de trame a 24 bits utiles.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_mic.useHalf(half);
|
||||
|
||||
Serial.printf("# configuration retenue : %lu Hz, cadrage %s, moitie %s\n",
|
||||
static_cast<unsigned long>(_mic.sampleRate()), formatName(_mic.format()),
|
||||
halfName(half));
|
||||
|
||||
const int variations = dbfs(acRmsLsb24(*active));
|
||||
Serial.printf("# niveau au repos : continu %d dBFS, variations %d dBFS\n",
|
||||
dbfs(fabsf(dcLsb24(*active))), variations);
|
||||
if (variations > NOISY_ROOM_DBFS) {
|
||||
Serial.println("# NB : bruit de fond eleve, ou micro pas encore stabilise.");
|
||||
Serial.println("# Sans effet sur le cadrage ; a surveiller sur les enregistrements.");
|
||||
}
|
||||
|
||||
if (MIC_SELFTEST_VERBOSE) {
|
||||
dumpBits("configuration retenue");
|
||||
reportDataLine();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const MicCapture::RawStats* MicSelfTest::measure(MicCapture::FrameStats& frames,
|
||||
MicCapture::Half& activeHalf) {
|
||||
frames = _mic.probe(MIC_PROBE_MS);
|
||||
|
||||
const MicCapture::RawStats* halves[] = {&frames.first, &frames.second};
|
||||
const MicCapture::RawStats* active = nullptr;
|
||||
int withData = 0;
|
||||
|
||||
for (int index = 0; index < 2; ++index) {
|
||||
const MicCapture::Half half =
|
||||
(index == 0) ? MicCapture::Half::First : MicCapture::Half::Second;
|
||||
const MicCapture::RawStats& stats = *halves[index];
|
||||
|
||||
Serial.printf("# %-7s %s | %4d dBFS | %5d dBFS | %4d | %s\n",
|
||||
formatName(_mic.format()), halfName(half), dbfs(fabsf(dcLsb24(stats))),
|
||||
dbfs(acRmsLsb24(stats)), usedBits(stats), verdictFor(stats));
|
||||
|
||||
if (carriesData(stats)) {
|
||||
++withData;
|
||||
active = &stats;
|
||||
activeHalf = half;
|
||||
}
|
||||
}
|
||||
|
||||
// Le micro n'émet que dans une moitié : deux moitiés actives signalent un
|
||||
// L/R flottant ou un second micro sur la ligne.
|
||||
if (withData != 1) {
|
||||
if (withData == 2) {
|
||||
Serial.println("# les DEUX moities portent des donnees : L/R du micro bien a GND ?");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
void MicSelfTest::reportFailure(const char* reason) {
|
||||
Serial.printf("# ECHEC : %s\n", reason);
|
||||
dumpBits("echec");
|
||||
reportDataLine();
|
||||
Serial.println("# Envoyez ce bloc complet : l'analyse bit a bit dit ou se trouvent");
|
||||
Serial.println("# les bits du micro dans le mot recu.");
|
||||
}
|
||||
|
||||
void MicSelfTest::reportSession(const MicCapture::RawStats& stats) const {
|
||||
if (stats.wordCount == 0) {
|
||||
Serial.println("# parole : aucun mot lu");
|
||||
return;
|
||||
}
|
||||
|
||||
// La crête retenue est celle qui sort du passe-haut : le grondement
|
||||
// infrasonore est exclu, c'est bien la voix qu'on mesure.
|
||||
const int32_t peak24 = stats.filteredPeak;
|
||||
const int64_t peak16 = (static_cast<int64_t>(peak24) * MIC_GAIN) >> 8;
|
||||
|
||||
Serial.printf("# parole : crete %d dBFS apres passe-haut, %d bits utiles\n",
|
||||
dbfs(static_cast<float>(peak24)), usedBits(stats));
|
||||
Serial.printf("# apres gain x%d : crete a %ld %% de la pleine echelle\n", MIC_GAIN,
|
||||
static_cast<long>((peak16 * 100) / 32768));
|
||||
|
||||
if (peak24 <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Plus grand gain (puissance de 2) plaçant la crête sous 25 % de pleine
|
||||
// échelle : peak24 x gain / 256 <= 8192, soit gain <= 2^21 / peak24.
|
||||
const int32_t wanted = static_cast<int32_t>((1L << 21) / peak24);
|
||||
int gain = 1;
|
||||
while (gain * 2 <= wanted && gain < 64) {
|
||||
gain *= 2;
|
||||
}
|
||||
if (gain != MIC_GAIN) {
|
||||
Serial.printf("# -> reglez MIC_GAIN = %d dans firmware/include/config.h\n", gain);
|
||||
} else {
|
||||
Serial.println("# -> MIC_GAIN est bien regle.");
|
||||
}
|
||||
}
|
||||
|
||||
void MicSelfTest::dumpBits(const char* label) {
|
||||
constexpr size_t WORDS = 2048; // 1024 trames, 64 ms à 16 kHz
|
||||
static int32_t words[WORDS]; // statique : 8 Kio hors de la pile
|
||||
|
||||
_mic.flush();
|
||||
const size_t got = _mic.readRaw(words, WORDS); // words[0] est une 1re moitié
|
||||
|
||||
// Les trames du milieu du relevé sont représentatives du régime établi.
|
||||
const size_t firstShown = (got / 4) & ~static_cast<size_t>(1);
|
||||
|
||||
Serial.printf("# --- analyse bit a bit, %s (%s) ---\n", label, formatName(_mic.format()));
|
||||
Serial.println("# mots bruts, regime etabli, deux mots par trame :");
|
||||
for (size_t frame = 0; frame < 8 && firstShown + (frame * 2) + 1 < got; ++frame) {
|
||||
const size_t index = firstShown + (frame * 2);
|
||||
Serial.printf("# %08lX %08lX\n", static_cast<unsigned long>(words[index]),
|
||||
static_cast<unsigned long>(words[index + 1]));
|
||||
}
|
||||
|
||||
// Proportion de 1 à chaque position de bit, du bit 31 (gauche) au bit 0.
|
||||
for (size_t column = 0; column < 2; ++column) {
|
||||
uint32_t ones[32] = {};
|
||||
size_t count = 0;
|
||||
for (size_t i = column; i < got; i += 2) {
|
||||
const uint32_t bits = static_cast<uint32_t>(words[i]);
|
||||
for (int bit = 0; bit < 32; ++bit) {
|
||||
ones[bit] += (bits >> bit) & 1U;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
|
||||
char pattern[33];
|
||||
for (int bit = 31; bit >= 0; --bit) {
|
||||
const uint32_t tenth = (count == 0) ? 0 : (ones[bit] * 10U) / (count + 1);
|
||||
pattern[31 - bit] = static_cast<char>('0' + tenth);
|
||||
}
|
||||
pattern[32] = '\0';
|
||||
|
||||
Serial.printf("# colonne %u, taux de 1 par bit (31..0) : %s\n",
|
||||
static_cast<unsigned>(column + 1), pattern);
|
||||
}
|
||||
Serial.println("# lecture : 0 = toujours 0, 9 = toujours 1.");
|
||||
Serial.println("# 24 bits utiles bien cadres = exactement 8 zeros a droite.");
|
||||
}
|
||||
|
||||
void MicSelfTest::reportDataLine() {
|
||||
const MicCapture::DataLineReport report = _mic.probeDataLine();
|
||||
if (report.samples == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t up = (report.onesWithPullUp * 100) / report.samples;
|
||||
const uint32_t down = (report.onesWithPullDown * 100) / report.samples;
|
||||
|
||||
Serial.printf("# ligne SD : pull-up %lu %% de 1, pull-down %lu %% de 1",
|
||||
static_cast<unsigned long>(up), static_cast<unsigned long>(down));
|
||||
if (up >= 98 && down <= 2) {
|
||||
Serial.println(" -> FLOTTE : rien ne la pilote");
|
||||
} else if (up <= 2 && down <= 2) {
|
||||
Serial.println(" -> tenue a 0");
|
||||
} else if (up >= 98 && down >= 98) {
|
||||
Serial.println(" -> tenue a 1");
|
||||
} else {
|
||||
Serial.println(" -> PILOTEE : le micro emet");
|
||||
}
|
||||
}
|
||||
|
||||
const char* MicSelfTest::verdictFor(const MicCapture::RawStats& stats) {
|
||||
if (stats.wordCount == 0) {
|
||||
return "aucun mot lu";
|
||||
}
|
||||
if (stats.zeroCount == stats.wordCount) {
|
||||
return "silence numerique";
|
||||
}
|
||||
if (stats.minWord == stats.maxWord) {
|
||||
return "valeur CONSTANTE";
|
||||
}
|
||||
|
||||
const int bits = usedBits(stats);
|
||||
if (bits == MIC_DATA_BITS) {
|
||||
return "CADRE : 24 bits";
|
||||
}
|
||||
if (bits == MIC_DATA_BITS + 1) {
|
||||
return "un bit de retard (Philips)";
|
||||
}
|
||||
if (bits < MIC_DATA_BITS) {
|
||||
return "DECALE : bit de signe perdu";
|
||||
}
|
||||
return "queue du mot non nulle";
|
||||
}
|
||||
|
||||
bool MicSelfTest::carriesData(const MicCapture::RawStats& stats) {
|
||||
return stats.wordCount > 0 && stats.zeroCount < stats.wordCount &&
|
||||
stats.minWord != stats.maxWord;
|
||||
}
|
||||
|
||||
float MicSelfTest::dcLsb24(const MicCapture::RawStats& stats) {
|
||||
if (stats.wordCount == 0) {
|
||||
return 0.0F;
|
||||
}
|
||||
// sum porte sur (mot >> 12) ; en LSB 24 bits (mot >> 8), c'est x16.
|
||||
return static_cast<float>(static_cast<double>(stats.sum) /
|
||||
static_cast<double>(stats.wordCount) * 16.0);
|
||||
}
|
||||
|
||||
float MicSelfTest::acRmsLsb24(const MicCapture::RawStats& stats) {
|
||||
if (stats.wordCount == 0) {
|
||||
return 0.0F;
|
||||
}
|
||||
// Variance = moyenne des carrés - carré de la moyenne.
|
||||
const double count = static_cast<double>(stats.wordCount);
|
||||
const double mean = static_cast<double>(stats.sum) / count;
|
||||
const double variance = static_cast<double>(stats.sumSquares) / count - mean * mean;
|
||||
if (variance <= 0.0) {
|
||||
return 0.0F;
|
||||
}
|
||||
return static_cast<float>(sqrt(variance) * 16.0);
|
||||
}
|
||||
|
||||
int MicSelfTest::dbfs(float lsb24) {
|
||||
if (lsb24 <= 0.0F) {
|
||||
return -999;
|
||||
}
|
||||
return static_cast<int>(20.0F * log10f(lsb24 / FULL_SCALE_24));
|
||||
}
|
||||
|
||||
int MicSelfTest::usedBits(const MicCapture::RawStats& stats) {
|
||||
if (stats.orBits == 0) {
|
||||
return 0;
|
||||
}
|
||||
return 32 - __builtin_ctz(stats.orBits); // ctz : nombre de zéros à droite
|
||||
}
|
||||
|
||||
const char* MicSelfTest::formatName(MicCapture::Format format) {
|
||||
return (format == MicCapture::Format::Philips) ? "Philips" : "MSB";
|
||||
}
|
||||
|
||||
const char* MicSelfTest::halfName(MicCapture::Half half) {
|
||||
return (half == MicCapture::Half::First) ? "1re" : "2e ";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "MicCapture.h"
|
||||
|
||||
/**
|
||||
* @brief Auto-test du micro au démarrage : détermine le cadrage et la moitié de trame.
|
||||
*
|
||||
* Le micro n'est réveillé qu'UNE fois : l'INMP441 s'endort dès que ses horloges
|
||||
* s'arrêtent, et chaque réveil produit une dérive lente de sa composante
|
||||
* continue. Multiplier les essais, c'était mesurer des réveils, pas la pièce.
|
||||
*
|
||||
* Tout se décide sur une capture lue en cadrage MSB, par la **structure** des
|
||||
* mots reçus, jamais par leur niveau :
|
||||
*
|
||||
* - la moitié de trame utile est celle qui n'est ni nulle ni constante ;
|
||||
* - 24 bits utiles : le micro est cadré MSB, c'est gagné ;
|
||||
* - 25 bits utiles : le micro suit le cadrage Philips (un bit de retard),
|
||||
* on bascule et l'on vérifie ;
|
||||
* - toute autre structure est un défaut, et l'analyse bit à bit s'affiche.
|
||||
*
|
||||
* Le niveau au repos est affiché pour information : il dépend du bruit de la
|
||||
* pièce et de la stabilisation du micro, pas de la justesse du réglage.
|
||||
*/
|
||||
class MicSelfTest {
|
||||
public:
|
||||
explicit MicSelfTest(MicCapture& mic);
|
||||
|
||||
/**
|
||||
* @brief Détermine le cadrage et la moitié de trame, puis configure le micro.
|
||||
* @return true si le micro est lu correctement ; il reste alors prêt à capturer.
|
||||
*/
|
||||
bool run();
|
||||
|
||||
/**
|
||||
* @brief Imprime le niveau d'une session de parole et le gain conseillé.
|
||||
* @param stats Statistiques de la moitié retenue pendant la session.
|
||||
*/
|
||||
void reportSession(const MicCapture::RawStats& stats) const;
|
||||
|
||||
private:
|
||||
MicCapture& _mic;
|
||||
|
||||
// Capture, affiche le tableau et renvoie la moitié qui porte le micro (ou nullptr)
|
||||
const MicCapture::RawStats* measure(MicCapture::FrameStats& frames,
|
||||
MicCapture::Half& activeHalf);
|
||||
|
||||
// Diagnostic complet en cas d'échec
|
||||
void reportFailure(const char* reason);
|
||||
|
||||
// Analyse bit à bit et vidage hexadécimal de la configuration courante
|
||||
void dumpBits(const char* label);
|
||||
|
||||
// Test électrique de la ligne SD, horloges correctes
|
||||
void reportDataLine();
|
||||
|
||||
// Verdict structurel pour une moitié de trame
|
||||
static const char* verdictFor(const MicCapture::RawStats& stats);
|
||||
|
||||
// true si la moitié porte des données (ni nulle, ni constante)
|
||||
static bool carriesData(const MicCapture::RawStats& stats);
|
||||
|
||||
// Composante continue (moyenne) en LSB 24 bits
|
||||
static float dcLsb24(const MicCapture::RawStats& stats);
|
||||
|
||||
// Valeur efficace des variations, composante continue retirée, en LSB 24 bits
|
||||
static float acRmsLsb24(const MicCapture::RawStats& stats);
|
||||
|
||||
// Conversion en dBFS (pleine échelle 24 bits)
|
||||
static int dbfs(float lsb24);
|
||||
|
||||
// Nombre de bits utiles : du bit 31 au plus bas bit jamais mis à 1
|
||||
static int usedBits(const MicCapture::RawStats& stats);
|
||||
|
||||
static const char* formatName(MicCapture::Format format);
|
||||
static const char* halfName(MicCapture::Half half);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "MqttLink.h"
|
||||
|
||||
#include "config.h"
|
||||
|
||||
namespace {
|
||||
/// Message publié par le broker à notre place si l'on disparaît sans prévenir.
|
||||
const char* const WILL_PAYLOAD = "{\"status\":\"offline\"}";
|
||||
} // namespace
|
||||
|
||||
MqttLink::MqttLink(Client& network, const char* host, uint16_t port, const char* deviceId,
|
||||
const char* user, const char* password)
|
||||
: _client(network),
|
||||
_host(host),
|
||||
_port(port),
|
||||
_deviceId(deviceId),
|
||||
_user((user != nullptr && user[0] != '\0') ? user : deviceId),
|
||||
_password(password) {}
|
||||
|
||||
bool MqttLink::begin(MessageHandler onMessage, ConnectHandler onConnect) {
|
||||
_onMessage = onMessage;
|
||||
_onConnect = onConnect;
|
||||
|
||||
_client.setServer(_host, _port);
|
||||
_client.setKeepAlive(MQTT_KEEPALIVE_S);
|
||||
_client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_S);
|
||||
_client.setCallback([this](char* topic, uint8_t* payload, unsigned int length) {
|
||||
const char* suffix = suffixOf(topic);
|
||||
if (_onMessage) {
|
||||
_onMessage(suffix != nullptr ? suffix : topic, payload, length);
|
||||
}
|
||||
});
|
||||
|
||||
buildTopic(_willTopic, sizeof(_willTopic), "state");
|
||||
|
||||
if (strcmp(_user, _deviceId) != 0) {
|
||||
// Les droits du broker (pattern voice/%u/...) portent sur le nom d'utilisateur :
|
||||
// un autre nom que DEVICE_ID, et la carte n'a le droit de rien publier.
|
||||
Serial.printf("# mqtt : ATTENTION, utilisateur \"%s\" different de DEVICE_ID \"%s\" : "
|
||||
"le broker refusera ses messages (mosquitto/acl)\n", _user, _deviceId);
|
||||
}
|
||||
|
||||
// Le tampon par défaut (256 octets) ne contiendrait pas un chunk audio : un
|
||||
// message trop gros est rejeté sans aucun message d'erreur. Alloué une fois ici.
|
||||
return _client.setBufferSize(MQTT_BUFFER_BYTES);
|
||||
}
|
||||
|
||||
void MqttLink::update(bool networkUp) {
|
||||
if (_client.connected()) {
|
||||
_client.loop(); // traite les messages reçus et entretient la connexion
|
||||
return;
|
||||
}
|
||||
if (!networkUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tentatives espacées : chaque essai peut bloquer jusqu'à MQTT_SOCKET_TIMEOUT_S.
|
||||
const uint32_t now = millis();
|
||||
if (_attempted && (now - _lastAttemptMs) < MQTT_RETRY_MS) {
|
||||
return;
|
||||
}
|
||||
_attempted = true;
|
||||
_lastAttemptMs = now;
|
||||
connect();
|
||||
}
|
||||
|
||||
bool MqttLink::connected() {
|
||||
return _client.connected();
|
||||
}
|
||||
|
||||
bool MqttLink::publish(const char* suffix, const char* payload, bool retained) {
|
||||
if (!_client.connected()) {
|
||||
return false;
|
||||
}
|
||||
return _client.publish(buildTopic(_topic, sizeof(_topic), suffix), payload, retained);
|
||||
}
|
||||
|
||||
bool MqttLink::publishBinary(const char* suffix, const uint8_t* data, size_t length) {
|
||||
if (!_client.connected()) {
|
||||
return false;
|
||||
}
|
||||
return _client.publish(buildTopic(_topic, sizeof(_topic), suffix), data,
|
||||
static_cast<unsigned int>(length), false);
|
||||
}
|
||||
|
||||
bool MqttLink::subscribe(const char* suffix) {
|
||||
if (!_client.connected()) {
|
||||
return false;
|
||||
}
|
||||
return _client.subscribe(buildTopic(_topic, sizeof(_topic), suffix), 1);
|
||||
}
|
||||
|
||||
bool MqttLink::subscribeTopic(const char* topic) {
|
||||
if (!_client.connected()) {
|
||||
return false;
|
||||
}
|
||||
return _client.subscribe(topic, 1);
|
||||
}
|
||||
|
||||
uint32_t MqttLink::connections() const {
|
||||
return _connections;
|
||||
}
|
||||
|
||||
void MqttLink::connect() {
|
||||
Serial.printf("# mqtt : connexion a %s:%u...\n", _host, _port);
|
||||
|
||||
// Mot de passe vide = connexion anonyme, refusée par un broker sécurisé (étape 15).
|
||||
const bool anonymous = (_password == nullptr || _password[0] == '\0');
|
||||
const char* user = anonymous ? nullptr : _user;
|
||||
const char* password = anonymous ? nullptr : _password;
|
||||
|
||||
// Testament : QoS 1, retenu. Session propre : on ne veut pas recevoir, au
|
||||
// retour, des commandes périmées accumulées pendant l'absence.
|
||||
const bool ok = _client.connect(_deviceId, user, password, _willTopic, 1, true, WILL_PAYLOAD,
|
||||
true);
|
||||
if (!ok) {
|
||||
// Codes de PubSubClient : -4 délai dépassé, -2 connexion TCP refusée
|
||||
// (broker arrêté, mauvais port, pare-feu), 5 non autorisé...
|
||||
const int code = _client.state();
|
||||
Serial.printf("# mqtt : echec (code %d), nouvel essai dans %lu s\n", code,
|
||||
static_cast<unsigned long>(MQTT_RETRY_MS / 1000));
|
||||
if (code == MQTT_CONNECT_BAD_CREDENTIALS || code == MQTT_CONNECT_UNAUTHORIZED) {
|
||||
Serial.printf("# mqtt : compte refuse. secrets.h : MQTT_PASSWORD %s ; le compte \"%s\" "
|
||||
"existe-t-il (make mqtt-user NAME=%s) ?\n",
|
||||
anonymous ? "VIDE" : "renseigne", _user, _deviceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
++_connections;
|
||||
Serial.printf("# mqtt : connecte (connexion n°%lu)\n", static_cast<unsigned long>(_connections));
|
||||
if (_onConnect) {
|
||||
_onConnect(_connections == 1);
|
||||
}
|
||||
}
|
||||
|
||||
const char* MqttLink::buildTopic(char* buffer, size_t size, const char* suffix) const {
|
||||
snprintf(buffer, size, "%s/%s/%s", MQTT_TOPIC_PREFIX, _deviceId, suffix);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const char* MqttLink::suffixOf(const char* topic) const {
|
||||
// Attendu : <préfixe>/<id>/<suffixe>. On vérifie les deux premiers segments.
|
||||
const size_t prefixLength = strlen(MQTT_TOPIC_PREFIX);
|
||||
const size_t idLength = strlen(_deviceId);
|
||||
if (strncmp(topic, MQTT_TOPIC_PREFIX, prefixLength) != 0 || topic[prefixLength] != '/') {
|
||||
return nullptr;
|
||||
}
|
||||
const char* rest = topic + prefixLength + 1;
|
||||
if (strncmp(rest, _deviceId, idLength) != 0 || rest[idLength] != '/') {
|
||||
return nullptr;
|
||||
}
|
||||
return rest + idLength + 1;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <PubSubClient.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
/**
|
||||
* @brief Connexion au broker MQTT, avec testament et reconnexion espacée.
|
||||
*
|
||||
* Tous les topics ont la forme <préfixe>/<identifiant>/<suffixe>, par exemple
|
||||
* voice/esp32-01/state. On ne manipule ici que le suffixe.
|
||||
*
|
||||
* Le **testament** (Last Will) est confié au broker à la connexion : si l'ESP32
|
||||
* disparaît sans prévenir (coupure de courant, plantage), c'est le broker qui
|
||||
* publie « offline » à sa place, en message retenu.
|
||||
*
|
||||
* Le rappel des messages reçus doit rester court : il sera appelé depuis
|
||||
* PubSubClient, dans loop(). À l'étape 5, il ne fera que remplir la FIFO audio.
|
||||
*
|
||||
* Étape 15 : le broker exige un compte. Le nom d'utilisateur de la carte est son
|
||||
* identifiant (DEVICE_ID) : les droits du broker (mosquitto/acl) en dépendent.
|
||||
*/
|
||||
class MqttLink {
|
||||
public:
|
||||
/// Rappel des messages reçus : suffixe pour nos topics (« control »), topic complet
|
||||
/// pour ceux de subscribeTopic() (« voice/server/status »), payload, longueur.
|
||||
using MessageHandler = std::function<void(const char*, const uint8_t*, size_t)>;
|
||||
|
||||
/// Rappel de connexion : appelé à chaque (re)connexion au broker.
|
||||
using ConnectHandler = std::function<void(bool firstTime)>;
|
||||
|
||||
/**
|
||||
* @param network Transport TCP (un WiFiClient).
|
||||
* @param host Adresse IP du broker.
|
||||
* @param port Port du broker.
|
||||
* @param deviceId Identifiant de la carte : client MQTT et segment des topics.
|
||||
* @param user Utilisateur MQTT ; vide : deviceId (les droits du broker l'exigent).
|
||||
* @param password Mot de passe MQTT ; vide : connexion anonyme (broker non sécurisé).
|
||||
*/
|
||||
MqttLink(Client& network, const char* host, uint16_t port, const char* deviceId,
|
||||
const char* user, const char* password);
|
||||
|
||||
/**
|
||||
* @brief Prépare le client ; aucune connexion n'est tentée ici.
|
||||
* @param onMessage Appelé pour chaque message reçu sur un topic abonné.
|
||||
* @param onConnect Appelé après chaque connexion réussie (abonnements, état).
|
||||
* @return true si le tampon de @ref MQTT_BUFFER_BYTES a pu être alloué.
|
||||
*/
|
||||
bool begin(MessageHandler onMessage, ConnectHandler onConnect);
|
||||
|
||||
/**
|
||||
* @brief À appeler à chaque tour de boucle.
|
||||
* @param networkUp true si le Wi-Fi est connecté : sinon, inutile d'essayer.
|
||||
*/
|
||||
void update(bool networkUp);
|
||||
|
||||
/// true si la session MQTT est ouverte.
|
||||
bool connected();
|
||||
|
||||
/**
|
||||
* @brief Publie un texte (JSON) sur <préfixe>/<id>/<suffixe>.
|
||||
* @return true si le message a été confié à la pile TCP.
|
||||
*/
|
||||
bool publish(const char* suffix, const char* payload, bool retained);
|
||||
|
||||
/**
|
||||
* @brief Publie des octets bruts (PCM) sur <préfixe>/<id>/<suffixe>, non retenu.
|
||||
*
|
||||
* Peut bloquer tant que la pile TCP n'a pas de place : ne jamais l'appeler
|
||||
* depuis une tâche audio, seulement depuis loop().
|
||||
*
|
||||
* @return true si le message a été confié à la pile TCP.
|
||||
*/
|
||||
bool publishBinary(const char* suffix, const uint8_t* data, size_t length);
|
||||
|
||||
/// S'abonne à <préfixe>/<id>/<suffixe>, en QoS 1.
|
||||
bool subscribe(const char* suffix);
|
||||
|
||||
/// S'abonne à un topic complet, hors de nos topics (présence du serveur), en QoS 1.
|
||||
bool subscribeTopic(const char* topic);
|
||||
|
||||
/// Nombre de connexions réussies depuis le démarrage.
|
||||
uint32_t connections() const;
|
||||
|
||||
private:
|
||||
PubSubClient _client;
|
||||
const char* _host;
|
||||
uint16_t _port;
|
||||
const char* _deviceId;
|
||||
const char* _user;
|
||||
const char* _password;
|
||||
MessageHandler _onMessage;
|
||||
ConnectHandler _onConnect;
|
||||
uint32_t _lastAttemptMs = 0;
|
||||
bool _attempted = false;
|
||||
uint32_t _connections = 0;
|
||||
char _topic[96] = {}; ///< topic en cours de construction
|
||||
char _willTopic[96] = {}; ///< topic du testament, conservé pour chaque connexion
|
||||
|
||||
// Tente une connexion, testament compris
|
||||
void connect();
|
||||
|
||||
// Construit <préfixe>/<id>/<suffixe> dans le tampon donné
|
||||
const char* buildTopic(char* buffer, size_t size, const char* suffix) const;
|
||||
|
||||
// Rend le suffixe d'un topic reçu, ou nullptr s'il n'est pas de la forme attendue
|
||||
const char* suffixOf(const char* topic) const;
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "PlaybackStream.h"
|
||||
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
PlaybackStream::PlaybackStream(AudioOutput& speaker, AudioFifo& fifo)
|
||||
: _speaker(speaker), _fifo(fifo) {}
|
||||
|
||||
bool PlaybackStream::begin() {
|
||||
return xTaskCreateStaticPinnedToCore(taskEntry, "audio", sizeof(_taskStack), this,
|
||||
AUDIO_TASK_PRIORITY, _taskStack, &_taskControl,
|
||||
AUDIO_TASK_CORE) != nullptr;
|
||||
}
|
||||
|
||||
bool PlaybackStream::open(const char* session, uint32_t rate, uint32_t bits, uint32_t channels,
|
||||
bool fromMqtt) {
|
||||
if (_open) {
|
||||
// Le flux précédent n'est pas fini : le nouveau prend la place.
|
||||
Serial.printf("# lecture %s : interrompue par un nouveau flux\n", _report.session);
|
||||
finish(_closed);
|
||||
}
|
||||
|
||||
if (rate != AUDIO_SAMPLE_RATE || bits != 16 || channels != 1) {
|
||||
Serial.printf("# lecture refusee : %lu Hz, %lu bits, %lu canaux ; attendu %lu Hz, 16 bits, mono\n",
|
||||
static_cast<unsigned long>(rate), static_cast<unsigned long>(bits),
|
||||
static_cast<unsigned long>(channels),
|
||||
static_cast<unsigned long>(AUDIO_SAMPLE_RATE));
|
||||
return false;
|
||||
}
|
||||
|
||||
// L'état d'abord : dès cet instant, la tâche audio ne peut plus compter de famine
|
||||
// pour ce flux (elle ne compte qu'en Playing), quoi qu'elle voie ensuite.
|
||||
_state.store(State::Priming); // rien ne sera joué avant d'avoir la réserve
|
||||
|
||||
_report = Report();
|
||||
strncpy(_report.session, session, sizeof(_report.session) - 1);
|
||||
_report.fromMqtt = fromMqtt;
|
||||
_open = true;
|
||||
_closed = false;
|
||||
_startMs = millis();
|
||||
_lastPushMs = _startMs;
|
||||
_overrunsAtStart = _fifo.overruns();
|
||||
_underruns.store(0);
|
||||
_firstSoundMs.store(0);
|
||||
_minMarginBytes.store(UINT32_MAX);
|
||||
for (auto& gap : _gapAtMs) {
|
||||
gap.store(0);
|
||||
}
|
||||
_endOfStream.store(false);
|
||||
Serial.printf("# lecture %s : debut du flux (%s)\n", _report.session, fromMqtt ? "MQTT" : "serie");
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlaybackStream::push(const uint8_t* data, size_t length) {
|
||||
// Longueur impaire : échantillon coupé en deux, jamais accepté.
|
||||
if (!_open || _closed || (length % 2) != 0) {
|
||||
return;
|
||||
}
|
||||
_fifo.write(data, length);
|
||||
++_report.chunks;
|
||||
_lastPushMs = millis();
|
||||
}
|
||||
|
||||
void PlaybackStream::close(int32_t announcedChunks) {
|
||||
if (!_open || _closed) {
|
||||
return;
|
||||
}
|
||||
_closed = true;
|
||||
_report.announced = announcedChunks;
|
||||
_report.endReceived = true;
|
||||
// Plus rien ne doit arriver : une FIFO qui se vide est désormais normale, et
|
||||
// un son plus court que la réserve doit être joué sans attendre davantage.
|
||||
_endOfStream.store(true);
|
||||
}
|
||||
|
||||
bool PlaybackStream::takeReport(Report& report) {
|
||||
if (!_open) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t silence = millis() - _lastPushMs;
|
||||
if (_closed) {
|
||||
// Le bilan attend que la FIFO soit vide : la lecture est alors terminée.
|
||||
if (_fifo.available() > 0 || silence < DRAIN_SILENCE_MS) {
|
||||
return false;
|
||||
}
|
||||
finish(true);
|
||||
} else if (silence >= ABANDON_MS) {
|
||||
_endOfStream.store(true); // la source s'est tue : on joue ce qui reste
|
||||
finish(false);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
report = _report;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlaybackStream::active() const {
|
||||
return _open;
|
||||
}
|
||||
|
||||
void PlaybackStream::finish(bool endReceived) {
|
||||
_open = false;
|
||||
_report.endReceived = endReceived;
|
||||
_report.overruns = _fifo.overruns() - _overrunsAtStart;
|
||||
_report.underruns = _underruns.load();
|
||||
_report.durationMs = millis() - _startMs;
|
||||
const uint32_t firstSound = _firstSoundMs.load();
|
||||
_report.startMs = firstSound != 0 ? firstSound - _startMs : 0;
|
||||
const uint32_t margin = _minMarginBytes.load();
|
||||
_report.minMarginMs = margin == UINT32_MAX
|
||||
? -1 : static_cast<int32_t>(margin / (AUDIO_SAMPLE_RATE / 1000 * sizeof(int16_t)));
|
||||
for (size_t i = 0; i < MAX_GAPS; ++i) {
|
||||
_report.gapAtMs[i] = _gapAtMs[i].load();
|
||||
}
|
||||
}
|
||||
|
||||
void PlaybackStream::taskEntry(void* self) {
|
||||
static_cast<PlaybackStream*>(self)->taskLoop();
|
||||
}
|
||||
|
||||
void PlaybackStream::taskLoop() {
|
||||
static int16_t block[PLAYBACK_BLOCK_SAMPLES];
|
||||
const size_t startBytes = PLAYBACK_START_MS * (AUDIO_SAMPLE_RATE / 1000) * sizeof(int16_t);
|
||||
esp_task_wdt_add(nullptr); // surveillée par le chien de garde (étape 15)
|
||||
|
||||
for (;;) {
|
||||
esp_task_wdt_reset(); // un tour dure au plus ~20 ms : attente FIFO ou bloc I2S
|
||||
if (_state.load() == State::Priming) {
|
||||
// Amorçage : rien n'est joué tant que la réserve n'est pas constituée,
|
||||
// sauf si la fin est annoncée (son plus court que la réserve).
|
||||
const size_t waiting = _fifo.available();
|
||||
if (waiting < startBytes && !(_endOfStream.load() && waiting > 0)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(PRIME_POLL_MS));
|
||||
continue;
|
||||
}
|
||||
uint32_t notYet = 0;
|
||||
_firstSoundMs.compare_exchange_strong(notYet, millis()); // premier démarrage seulement
|
||||
_state.store(State::Playing);
|
||||
}
|
||||
if (_state.load() == State::Idle) {
|
||||
vTaskDelay(pdMS_TO_TICKS(PRIME_POLL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t bytes = _fifo.read(reinterpret_cast<uint8_t*>(block), sizeof(block), 20);
|
||||
const size_t samples = bytes / sizeof(int16_t);
|
||||
if (samples > 0) {
|
||||
_speaker.write(block, samples); // bloque : c'est ce qui donne la cadence
|
||||
}
|
||||
if (_endOfStream.load()) {
|
||||
// Fin annoncée : la FIFO se vide normalement jusqu'au dernier échantillon,
|
||||
// puis la tâche se met au repos. Si open() a déjà lancé un nouveau flux,
|
||||
// l'état n'est plus Playing et l'échange échoue : on ne touche à rien.
|
||||
if (samples == 0 && _fifo.available() == 0) {
|
||||
State playing = State::Playing;
|
||||
_state.compare_exchange_strong(playing, State::Idle);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (samples < PLAYBACK_BLOCK_SAMPLES) {
|
||||
// Réserve épuisée en plein flux : on s'arrête proprement et on réamorce,
|
||||
// plutôt que de jouer des bribes hachées au gré des arrivées.
|
||||
// Échange atomique : on ne compte que si l'on jouait encore CE flux. Un
|
||||
// START arrivé pendant le read() ci-dessus a déjà remis l'état à Priming,
|
||||
// et ce n'est alors pas une famine (faux compte trouvé à l'étape 12).
|
||||
State playing = State::Playing;
|
||||
if (_state.compare_exchange_strong(playing, State::Priming)) {
|
||||
const uint32_t index = _underruns.fetch_add(1);
|
||||
if (index < MAX_GAPS) {
|
||||
_gapAtMs[index].store(millis() - _firstSoundMs.load());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Marge restante : c'est le retard réseau qu'on pourrait encore absorber.
|
||||
const uint32_t left = static_cast<uint32_t>(_fifo.available());
|
||||
if (left < _minMarginBytes.load()) {
|
||||
_minMarginBytes.store(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include "AudioFifo.h"
|
||||
#include "AudioOutput.h"
|
||||
#include "config.h"
|
||||
|
||||
/**
|
||||
* @brief Un flux de lecture vers le haut-parleur, quel que soit son transport.
|
||||
*
|
||||
* Même protocole pour la liaison série (étape 2) et pour MQTT (étape 5) :
|
||||
* open() à la trame START, push() pour chaque chunk, close() à la trame END.
|
||||
* push() est court et n'attend jamais : il peut être appelé depuis le rappel
|
||||
* MQTT. La tâche audio, créée par begin(), vide la FIFO au rythme du DAC.
|
||||
*
|
||||
* Tampon anti-gigue (étape 12) : rien n'est joué tant que la FIFO ne contient
|
||||
* pas PLAYBACK_START_MS de son (amorçage). Le Wi-Fi livre les chunks de façon
|
||||
* irrégulière ; la réserve absorbe ces écarts. Si elle s'épuise quand même, la
|
||||
* lecture s'arrête proprement et attend de l'avoir reconstituée (réamorçage),
|
||||
* au lieu de jouer des bribes hachées. Un son plus court que la réserve est joué
|
||||
* dès que la fin est annoncée.
|
||||
*
|
||||
* Un bilan est produit quand le dernier échantillon a été joué, ou quand la
|
||||
* source s'est tue sans envoyer de END.
|
||||
*/
|
||||
class PlaybackStream {
|
||||
public:
|
||||
/// Bilan d'un flux, disponible via takeReport().
|
||||
struct Report {
|
||||
char session[16] = {};
|
||||
bool fromMqtt = false; ///< transport du flux (pour savoir où publier le bilan)
|
||||
bool endReceived = false; ///< false : la source s'est tue sans END
|
||||
uint32_t chunks = 0; ///< chunks reçus
|
||||
int32_t announced = -1; ///< chunks annoncés par le END, -1 si inconnu
|
||||
uint32_t overruns = 0; ///< chunks refusés, FIFO pleine
|
||||
uint32_t underruns = 0; ///< réserve épuisée en plein flux : une pause, puis réamorçage
|
||||
uint32_t durationMs = 0; ///< du START au dernier échantillon joué
|
||||
uint32_t startMs = 0; ///< du START au premier échantillon joué (amorçage)
|
||||
int32_t minMarginMs = -1; ///< plus petite réserve pendant la lecture, -1 si non mesurée
|
||||
uint32_t gapAtMs[4] = {}; ///< instant des premiers réamorçages, depuis le premier son (diagnostic)
|
||||
};
|
||||
|
||||
/// Nombre de réamorçages dont l'instant est gardé.
|
||||
static constexpr size_t MAX_GAPS = 4;
|
||||
|
||||
PlaybackStream(AudioOutput& speaker, AudioFifo& fifo);
|
||||
|
||||
/// Crée la tâche audio (la FIFO doit être prête).
|
||||
bool begin();
|
||||
|
||||
/**
|
||||
* @brief Ouvre un flux. Un flux encore ouvert est clos d'office.
|
||||
* @return false si le format n'est pas celui du système : le flux est refusé.
|
||||
*/
|
||||
bool open(const char* session, uint32_t rate, uint32_t bits, uint32_t channels,
|
||||
bool fromMqtt);
|
||||
|
||||
/// Ajoute un chunk ; ignoré si aucun flux n'est ouvert ou si sa longueur est impaire.
|
||||
void push(const uint8_t* data, size_t length);
|
||||
|
||||
/**
|
||||
* @brief Ferme le flux ; le bilan viendra quand tout aura été joué.
|
||||
* @param announcedChunks Chunks annoncés par la source, -1 si inconnu.
|
||||
*/
|
||||
void close(int32_t announcedChunks);
|
||||
|
||||
/**
|
||||
* @brief À appeler depuis loop() : true une seule fois, quand un bilan est prêt.
|
||||
* @param report Reçoit le bilan.
|
||||
*/
|
||||
bool takeReport(Report& report);
|
||||
|
||||
/// true entre open() et le bilan.
|
||||
bool active() const;
|
||||
|
||||
private:
|
||||
/// Silence après lequel on considère le flux fini (ms).
|
||||
static constexpr uint32_t DRAIN_SILENCE_MS = 300;
|
||||
/// Silence après lequel une source muette est abandonnée, sans END (ms).
|
||||
static constexpr uint32_t ABANDON_MS = 2000;
|
||||
/// Période de surveillance de la FIFO pendant l'amorçage (ms).
|
||||
static constexpr uint32_t PRIME_POLL_MS = 5;
|
||||
|
||||
/// Ce que fait la tâche audio.
|
||||
enum class State : uint8_t {
|
||||
Idle, ///< aucun flux
|
||||
Priming, ///< réserve en cours de constitution : on ne joue rien
|
||||
Playing, ///< lecture au rythme du DAC
|
||||
};
|
||||
|
||||
AudioOutput& _speaker;
|
||||
AudioFifo& _fifo;
|
||||
bool _open = false; ///< loop() seulement
|
||||
bool _closed = false; ///< END reçu, on attend la fin de la lecture
|
||||
uint32_t _startMs = 0;
|
||||
uint32_t _lastPushMs = 0;
|
||||
uint32_t _overrunsAtStart = 0;
|
||||
Report _report;
|
||||
|
||||
// Partagés avec la tâche audio.
|
||||
std::atomic<State> _state{State::Idle};
|
||||
std::atomic<bool> _endOfStream{false}; ///< END reçu (ou source abandonnée) : la FIFO peut se vider
|
||||
std::atomic<uint32_t> _underruns{0};
|
||||
std::atomic<uint32_t> _firstSoundMs{0}; ///< date du premier échantillon joué, 0 : pas encore
|
||||
std::atomic<uint32_t> _minMarginBytes{UINT32_MAX};
|
||||
std::atomic<uint32_t> _gapAtMs[MAX_GAPS] = {};
|
||||
|
||||
StaticTask_t _taskControl;
|
||||
StackType_t _taskStack[AUDIO_TASK_STACK_BYTES]; // ESP-IDF : StackType_t = octet
|
||||
|
||||
static void taskEntry(void* self);
|
||||
void taskLoop();
|
||||
void finish(bool endReceived);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
/**
|
||||
* @brief Bouton poussoir anti-rebond, câblé entre la broche et GND.
|
||||
*
|
||||
* Le rebond (« bounce ») est l'oscillation mécanique du contact pendant quelques
|
||||
* millisecondes : sans filtrage, un seul appui produirait plusieurs fronts.
|
||||
* La broche utilise le pull-up interne : niveau haut au repos, bas quand on appuie.
|
||||
*/
|
||||
class PushButton {
|
||||
public:
|
||||
/**
|
||||
* @param pin Broche du bouton.
|
||||
* @param debounceMs Durée de stabilité exigée avant de valider un changement.
|
||||
*/
|
||||
PushButton(int pin, uint32_t debounceMs);
|
||||
|
||||
/// Configure la broche en entrée avec pull-up interne.
|
||||
void begin();
|
||||
|
||||
/// À appeler à chaque tour de boucle : met à jour l'état filtré.
|
||||
void update();
|
||||
|
||||
/// true tant que le bouton est maintenu enfoncé.
|
||||
bool isDown() const;
|
||||
|
||||
/// true une seule fois, au front d'appui (consomme l'événement).
|
||||
bool wasPressed();
|
||||
|
||||
/// true une seule fois, au front de relâchement (consomme l'événement).
|
||||
bool wasReleased();
|
||||
|
||||
/// Oublie les événements en attente (un appui mémorisé pendant une session, par exemple).
|
||||
void clearEvents();
|
||||
|
||||
private:
|
||||
int _pin;
|
||||
uint32_t _debounceMs;
|
||||
bool _stableState = false; ///< état filtré : true = enfoncé
|
||||
bool _lastRawState = false; ///< dernière lecture brute
|
||||
uint32_t _lastChangeMs = 0; ///< date du dernier changement brut
|
||||
bool _pressedEvent = false;
|
||||
bool _releasedEvent = false;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "SerialFrameReader.h"
|
||||
|
||||
using SerialProtocol::FrameType;
|
||||
|
||||
SerialFrameReader::SerialFrameReader(Stream& stream) : _stream(stream) {}
|
||||
|
||||
bool SerialFrameReader::poll(Frame& frame) {
|
||||
while (_stream.available() > 0) {
|
||||
const int value = _stream.read();
|
||||
if (value < 0) {
|
||||
break;
|
||||
}
|
||||
const uint8_t byte = static_cast<uint8_t>(value);
|
||||
|
||||
switch (_state) {
|
||||
case State::Magic:
|
||||
if (byte == SerialProtocol::MAGIC[_magicMatched]) {
|
||||
++_magicMatched;
|
||||
if (_magicMatched == sizeof(SerialProtocol::MAGIC)) {
|
||||
_state = State::Header;
|
||||
_headerRead = 0;
|
||||
}
|
||||
} else {
|
||||
// Aucun caractère ne se répète dans le mot magique : il suffit de
|
||||
// regarder si l'octet fautif peut commencer un nouveau mot magique.
|
||||
_magicMatched = (byte == SerialProtocol::MAGIC[0]) ? 1 : 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::Header: {
|
||||
_header[_headerRead++] = byte;
|
||||
if (_headerRead < sizeof(_header)) {
|
||||
break;
|
||||
}
|
||||
const uint8_t type = _header[0];
|
||||
_length = static_cast<size_t>(_header[1]) | (static_cast<size_t>(_header[2]) << 8);
|
||||
const bool knownType = type >= static_cast<uint8_t>(FrameType::Start) &&
|
||||
type <= static_cast<uint8_t>(FrameType::End);
|
||||
if (!knownType || _length > SerialProtocol::MAX_PAYLOAD) {
|
||||
++_rejected;
|
||||
restart();
|
||||
break;
|
||||
}
|
||||
_payloadRead = 0;
|
||||
_checksum = 0;
|
||||
_state = (_length == 0) ? State::Checksum : State::Payload;
|
||||
break;
|
||||
}
|
||||
|
||||
case State::Payload:
|
||||
_payload[_payloadRead++] = byte;
|
||||
_checksum ^= byte;
|
||||
if (_payloadRead == _length) {
|
||||
_state = State::Checksum;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::Checksum: {
|
||||
const bool valid = (byte == _checksum);
|
||||
const FrameType type = static_cast<FrameType>(_header[0]);
|
||||
restart();
|
||||
if (!valid) {
|
||||
++_rejected;
|
||||
break;
|
||||
}
|
||||
frame.type = type;
|
||||
frame.payload = _payload;
|
||||
frame.length = _length;
|
||||
return true; // on rend la main : le payload doit être consommé avant la suite
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t SerialFrameReader::rejectedFrames() const {
|
||||
return _rejected;
|
||||
}
|
||||
|
||||
void SerialFrameReader::restart() {
|
||||
_state = State::Magic;
|
||||
_magicMatched = 0;
|
||||
_headerRead = 0;
|
||||
// _payload et _length sont conservés : la trame qui vient d'être rendue les lit encore.
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "SerialProtocol.h"
|
||||
|
||||
/**
|
||||
* @brief Décode les trames reçues du PC sur l'UART, octet par octet, sans bloquer.
|
||||
*
|
||||
* Machine à états : on cherche le mot magique, puis on lit l'en-tête, le
|
||||
* payload et la somme de contrôle. Toute incohérence (type inconnu, longueur
|
||||
* excessive, somme fausse) fait repartir à la recherche du mot magique.
|
||||
*
|
||||
* Aucune allocation : le payload est copié dans un tampon interne de taille fixe.
|
||||
*/
|
||||
class SerialFrameReader {
|
||||
public:
|
||||
/// Trame décodée. Le payload reste valide jusqu'au prochain appel de poll().
|
||||
struct Frame {
|
||||
SerialProtocol::FrameType type = SerialProtocol::FrameType::End;
|
||||
const uint8_t* payload = nullptr;
|
||||
size_t length = 0;
|
||||
};
|
||||
|
||||
explicit SerialFrameReader(Stream& stream);
|
||||
|
||||
/**
|
||||
* @brief Consomme les octets disponibles, sans jamais attendre.
|
||||
* @param frame Reçoit la trame dès qu'une trame complète et valide est lue.
|
||||
* @return true si @p frame vient d'être rempli ; rappeler poll() pour la suite.
|
||||
*/
|
||||
bool poll(Frame& frame);
|
||||
|
||||
/// Nombre de trames rejetées depuis le démarrage (somme ou en-tête incohérents).
|
||||
uint32_t rejectedFrames() const;
|
||||
|
||||
private:
|
||||
enum class State {
|
||||
Magic, ///< recherche du mot magique
|
||||
Header, ///< type + longueur
|
||||
Payload, ///< données
|
||||
Checksum, ///< somme de contrôle
|
||||
};
|
||||
|
||||
Stream& _stream;
|
||||
State _state = State::Magic;
|
||||
size_t _magicMatched = 0;
|
||||
uint8_t _header[3] = {}; ///< type, longueur (poids faible), longueur (poids fort)
|
||||
size_t _headerRead = 0;
|
||||
uint8_t _payload[SerialProtocol::MAX_PAYLOAD] = {};
|
||||
size_t _length = 0;
|
||||
size_t _payloadRead = 0;
|
||||
uint8_t _checksum = 0;
|
||||
uint32_t _rejected = 0;
|
||||
|
||||
// Repart à la recherche du mot magique
|
||||
void restart();
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
#include "SerialFramer.h"
|
||||
|
||||
SerialFramer::SerialFramer(Stream& stream) : _stream(stream) {}
|
||||
|
||||
void SerialFramer::sendStart(uint32_t sampleRate, uint16_t bits, uint16_t channels) {
|
||||
// Descripteur de 8 octets : fréquence (4), bits (2), canaux (2), en little-endian.
|
||||
uint8_t payload[8];
|
||||
payload[0] = static_cast<uint8_t>(sampleRate & 0xFF);
|
||||
payload[1] = static_cast<uint8_t>((sampleRate >> 8) & 0xFF);
|
||||
payload[2] = static_cast<uint8_t>((sampleRate >> 16) & 0xFF);
|
||||
payload[3] = static_cast<uint8_t>((sampleRate >> 24) & 0xFF);
|
||||
payload[4] = static_cast<uint8_t>(bits & 0xFF);
|
||||
payload[5] = static_cast<uint8_t>((bits >> 8) & 0xFF);
|
||||
payload[6] = static_cast<uint8_t>(channels & 0xFF);
|
||||
payload[7] = static_cast<uint8_t>((channels >> 8) & 0xFF);
|
||||
|
||||
sendFrame(FrameType::Start, payload, sizeof(payload));
|
||||
}
|
||||
|
||||
void SerialFramer::sendAudio(const int16_t* pcm, size_t count) {
|
||||
// L'ESP32 est little-endian : les int16 sont déjà au bon format en mémoire.
|
||||
sendFrame(FrameType::Audio, reinterpret_cast<const uint8_t*>(pcm), count * sizeof(int16_t));
|
||||
}
|
||||
|
||||
void SerialFramer::sendEnd() {
|
||||
sendFrame(FrameType::End, nullptr, 0);
|
||||
}
|
||||
|
||||
void SerialFramer::sendFrame(FrameType type, const uint8_t* payload, size_t length) {
|
||||
uint8_t header[SerialProtocol::HEADER_SIZE];
|
||||
memcpy(header, SerialProtocol::MAGIC, sizeof(SerialProtocol::MAGIC));
|
||||
header[4] = static_cast<uint8_t>(type);
|
||||
header[5] = static_cast<uint8_t>(length & 0xFF);
|
||||
header[6] = static_cast<uint8_t>((length >> 8) & 0xFF);
|
||||
|
||||
uint8_t checksum = 0;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
checksum ^= payload[i];
|
||||
}
|
||||
|
||||
_stream.write(header, sizeof(header));
|
||||
if (length > 0) {
|
||||
_stream.write(payload, length);
|
||||
}
|
||||
_stream.write(&checksum, 1);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "SerialProtocol.h"
|
||||
|
||||
/**
|
||||
* @brief Encapsule des données binaires dans des trames envoyées sur l'UART.
|
||||
*
|
||||
* Le port série sert aussi aux messages de journalisation : il faut donc pouvoir
|
||||
* distinguer l'audio du texte. Format des trames : voir SerialProtocol.h.
|
||||
*/
|
||||
class SerialFramer {
|
||||
public:
|
||||
using FrameType = SerialProtocol::FrameType;
|
||||
|
||||
explicit SerialFramer(Stream& stream);
|
||||
|
||||
/**
|
||||
* @brief Envoie une trame START décrivant le format audio.
|
||||
* @param sampleRate Fréquence d'échantillonnage en Hz.
|
||||
* @param bits Nombre de bits par échantillon.
|
||||
* @param channels Nombre de canaux.
|
||||
*/
|
||||
void sendStart(uint32_t sampleRate, uint16_t bits, uint16_t channels);
|
||||
|
||||
/**
|
||||
* @brief Envoie un chunk audio.
|
||||
* @param pcm Échantillons 16 bits signés.
|
||||
* @param count Nombre d'échantillons.
|
||||
*/
|
||||
void sendAudio(const int16_t* pcm, size_t count);
|
||||
|
||||
/// Envoie la trame de fin de session.
|
||||
void sendEnd();
|
||||
|
||||
private:
|
||||
Stream& _stream;
|
||||
|
||||
void sendFrame(FrameType type, const uint8_t* payload, size_t length);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/**
|
||||
* @file SerialProtocol.h
|
||||
* @brief Format des trames binaires échangées avec le PC sur l'UART (étapes 1 et 2).
|
||||
*
|
||||
* Même format dans les deux sens, et identique à tools/serial_protocol.py :
|
||||
*
|
||||
* octets 0..3 : "VSA1" mot magique
|
||||
* octet 4 : type 1 = START, 2 = AUDIO, 3 = END
|
||||
* octets 5..6 : length taille utile en octets (uint16 little-endian)
|
||||
* octets 7.. : payload length octets
|
||||
* dernier : checksum OU exclusif de tous les octets du payload
|
||||
*
|
||||
* Le mot magique permet de se resynchroniser : du texte peut s'intercaler entre
|
||||
* les trames. À l'étape 4, MQTT remplace ce transport.
|
||||
*/
|
||||
namespace SerialProtocol {
|
||||
|
||||
/// Mot magique de début de trame (aucun caractère répété : resynchronisation simple).
|
||||
constexpr uint8_t MAGIC[4] = {'V', 'S', 'A', '1'};
|
||||
|
||||
/// Mot magique + type + longueur.
|
||||
constexpr size_t HEADER_SIZE = 7;
|
||||
|
||||
/// Taille maximale d'un payload ; un chunk légitime fait 1600 octets.
|
||||
constexpr size_t MAX_PAYLOAD = 2048;
|
||||
|
||||
/// Taille du descripteur de format porté par une trame START.
|
||||
constexpr size_t START_PAYLOAD = 8;
|
||||
|
||||
/// Types de trames.
|
||||
enum class FrameType : uint8_t {
|
||||
Start = 1, ///< début de flux, payload = fréquence (u32), bits (u16), canaux (u16)
|
||||
Audio = 2, ///< chunk de PCM 16 bits signé little-endian
|
||||
End = 3, ///< fin de flux, payload vide
|
||||
};
|
||||
|
||||
} // namespace SerialProtocol
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
/**
|
||||
* @brief LED d'état, non bloquante.
|
||||
*
|
||||
* Aucun delay() : la LED clignote grâce à une comparaison de dates dans update(),
|
||||
* ce qui laisse le chemin audio libre de toute attente.
|
||||
*/
|
||||
class StatusLed {
|
||||
public:
|
||||
explicit StatusLed(int pin);
|
||||
|
||||
/// Configure la broche en sortie, LED éteinte.
|
||||
void begin();
|
||||
|
||||
/// Allume la LED en continu.
|
||||
void on();
|
||||
|
||||
/// Éteint la LED.
|
||||
void off();
|
||||
|
||||
/**
|
||||
* @brief Fait clignoter la LED.
|
||||
* @param periodMs Période complète du clignotement en millisecondes.
|
||||
* @param onMs Durée allumée dans chaque période ; 0 = la moitié (clignotement régulier).
|
||||
* Un éclair bref (100 ms toutes les 2 s) signale l'écoute continue (étape 13).
|
||||
*/
|
||||
void blink(uint32_t periodMs, uint32_t onMs = 0);
|
||||
|
||||
/// À appeler à chaque tour de boucle : applique le clignotement.
|
||||
void update();
|
||||
|
||||
private:
|
||||
int _pin;
|
||||
bool _blinking = false;
|
||||
uint32_t _periodMs = 500;
|
||||
uint32_t _onMs = 250;
|
||||
uint32_t _lastToggleMs = 0;
|
||||
bool _state = false;
|
||||
|
||||
void write(bool state);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
#include "VoiceSession.h"
|
||||
|
||||
#include <esp_random.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
VoiceSession::VoiceSession(MqttLink& mqtt, SerialFramer& framer) : _mqtt(mqtt), _framer(framer) {}
|
||||
|
||||
void VoiceSession::start(Transport transport, uint32_t sampleRate) {
|
||||
_transport = transport;
|
||||
_active = true;
|
||||
_chunks = 0;
|
||||
_failures = 0;
|
||||
_startMs = millis();
|
||||
_durationMs = 0;
|
||||
|
||||
// Identifiant court, tiré du générateur matériel : distingue deux sessions
|
||||
// successives dans les journaux et les noms de fichiers du serveur.
|
||||
snprintf(_id, sizeof(_id), "%06lx", static_cast<unsigned long>(esp_random() & 0xFFFFFF));
|
||||
|
||||
if (_transport == Transport::Serial) {
|
||||
_framer.sendStart(sampleRate, 16, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
char payload[192];
|
||||
snprintf(payload, sizeof(payload),
|
||||
"{\"event\":\"start\",\"session\":\"%s\",\"rate\":%lu,\"bits\":16,\"channels\":1,"
|
||||
"\"codec\":\"pcm_s16le\",\"chunk_ms\":%lu}",
|
||||
_id, static_cast<unsigned long>(sampleRate),
|
||||
static_cast<unsigned long>(AUDIO_CHUNK_MS));
|
||||
_mqtt.publish("event", payload, false);
|
||||
}
|
||||
|
||||
bool VoiceSession::sendChunk(const uint8_t* pcm, size_t bytes) {
|
||||
if (!_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
if (_transport == Transport::Serial) {
|
||||
_framer.sendAudio(reinterpret_cast<const int16_t*>(pcm), bytes / sizeof(int16_t));
|
||||
} else {
|
||||
ok = _mqtt.publishBinary("audio/in", pcm, bytes);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
++_chunks;
|
||||
} else {
|
||||
++_failures;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void VoiceSession::end(uint32_t captureOverruns) {
|
||||
if (!_active) {
|
||||
return;
|
||||
}
|
||||
_active = false;
|
||||
_durationMs = millis() - _startMs;
|
||||
|
||||
if (_transport == Transport::Serial) {
|
||||
_framer.sendEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
char payload[192];
|
||||
snprintf(payload, sizeof(payload),
|
||||
"{\"event\":\"end\",\"session\":\"%s\",\"chunks\":%lu,\"duration_ms\":%lu,"
|
||||
"\"send_failures\":%lu,\"capture_overruns\":%lu}",
|
||||
_id, static_cast<unsigned long>(_chunks), static_cast<unsigned long>(_durationMs),
|
||||
static_cast<unsigned long>(_failures), static_cast<unsigned long>(captureOverruns));
|
||||
_mqtt.publish("event", payload, false);
|
||||
}
|
||||
|
||||
bool VoiceSession::active() const {
|
||||
return _active;
|
||||
}
|
||||
|
||||
VoiceSession::Transport VoiceSession::transport() const {
|
||||
return _transport;
|
||||
}
|
||||
|
||||
const char* VoiceSession::id() const {
|
||||
return _id;
|
||||
}
|
||||
|
||||
uint32_t VoiceSession::chunksSent() const {
|
||||
return _chunks;
|
||||
}
|
||||
|
||||
uint32_t VoiceSession::sendFailures() const {
|
||||
return _failures;
|
||||
}
|
||||
|
||||
uint32_t VoiceSession::durationMs() const {
|
||||
return _durationMs;
|
||||
}
|
||||
|
||||
uint32_t VoiceSession::elapsedMs() const {
|
||||
return millis() - _startMs;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "MqttLink.h"
|
||||
#include "SerialFramer.h"
|
||||
|
||||
/**
|
||||
* @brief Une session de parole : START, chunks PCM, END.
|
||||
*
|
||||
* Par MQTT (étape 4) :
|
||||
* voice/<id>/event {"event":"start","session":"a1b2c3","rate":16000,...}
|
||||
* voice/<id>/audio/in 1600 octets de PCM brut, 20 fois par seconde
|
||||
* voice/<id>/event {"event":"end","session":"a1b2c3","chunks":42,...}
|
||||
*
|
||||
* Le END porte le nombre de chunks envoyés : le serveur le compare au nombre
|
||||
* reçu et détecte ainsi les pertes, sans numéro de séquence (celui-ci viendra
|
||||
* à l'étape 16).
|
||||
*
|
||||
* Si MQTT n'est pas connecté à l'appui, la session part sur la liaison série,
|
||||
* au format de l'étape 1 : `make record` reste un outil de dépannage.
|
||||
*/
|
||||
class VoiceSession {
|
||||
public:
|
||||
/// Transport de la session en cours.
|
||||
enum class Transport {
|
||||
Mqtt,
|
||||
Serial,
|
||||
};
|
||||
|
||||
VoiceSession(MqttLink& mqtt, SerialFramer& framer);
|
||||
|
||||
/**
|
||||
* @brief Ouvre une session et annonce son format.
|
||||
* @param transport MQTT ou liaison série.
|
||||
* @param sampleRate Fréquence réellement en service.
|
||||
*/
|
||||
void start(Transport transport, uint32_t sampleRate);
|
||||
|
||||
/**
|
||||
* @brief Envoie un chunk de PCM. Peut bloquer (Wi-Fi) : appeler depuis loop().
|
||||
* @return true si le chunk a été confié au transport.
|
||||
*/
|
||||
bool sendChunk(const uint8_t* pcm, size_t bytes);
|
||||
|
||||
/**
|
||||
* @brief Ferme la session et publie son bilan.
|
||||
* @param captureOverruns Chunks perdus avant l'envoi, faute de place en FIFO.
|
||||
*/
|
||||
void end(uint32_t captureOverruns);
|
||||
|
||||
bool active() const;
|
||||
Transport transport() const;
|
||||
const char* id() const;
|
||||
uint32_t chunksSent() const;
|
||||
uint32_t sendFailures() const;
|
||||
uint32_t durationMs() const;
|
||||
|
||||
/// Temps écoulé depuis start(), session en cours ou non.
|
||||
uint32_t elapsedMs() const;
|
||||
|
||||
private:
|
||||
MqttLink& _mqtt;
|
||||
SerialFramer& _framer;
|
||||
Transport _transport = Transport::Mqtt;
|
||||
bool _active = false;
|
||||
char _id[8] = {}; ///< 6 chiffres hexadécimaux + zéro final
|
||||
uint32_t _startMs = 0;
|
||||
uint32_t _durationMs = 0;
|
||||
uint32_t _chunks = 0; ///< chunks confiés au transport
|
||||
uint32_t _failures = 0; ///< chunks refusés par le transport
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "WifiLink.h"
|
||||
|
||||
#include "config.h"
|
||||
|
||||
WifiLink::WifiLink(const char* ssid, const char* password, const char* hostname)
|
||||
: _ssid(ssid), _password(password), _hostname(hostname) {}
|
||||
|
||||
void WifiLink::begin() {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setHostname(_hostname);
|
||||
// Économie d'énergie coupée : le modem endormi ajoute des retards de 100 ms
|
||||
// et plus sur la réception. Inacceptable pour un flux audio (étapes 4 et 5).
|
||||
WiFi.setSleep(false);
|
||||
WiFi.setAutoReconnect(true);
|
||||
WiFi.begin(_ssid, _password);
|
||||
_lostSinceMs = millis();
|
||||
_retrySinceMs = _lostSinceMs;
|
||||
}
|
||||
|
||||
void WifiLink::update() {
|
||||
const bool now = connected();
|
||||
if (!now && (millis() - _retrySinceMs) >= WIFI_RESTART_MS) {
|
||||
// Filet de sécurité : la reconnexion automatique n'a rien donné, on repart de zéro.
|
||||
Serial.printf("# wifi : toujours absent apres %lu s, nouvelle connexion\n",
|
||||
static_cast<unsigned long>((millis() - _lostSinceMs) / 1000));
|
||||
WiFi.disconnect();
|
||||
WiFi.begin(_ssid, _password);
|
||||
_retrySinceMs = millis();
|
||||
}
|
||||
if (now == _wasConnected) {
|
||||
return;
|
||||
}
|
||||
_wasConnected = now;
|
||||
|
||||
if (now) {
|
||||
Serial.printf("# wifi : connecte en %lu ms, IP %s, signal %d dBm\n",
|
||||
static_cast<unsigned long>(millis() - _lostSinceMs),
|
||||
WiFi.localIP().toString().c_str(), WiFi.RSSI());
|
||||
} else {
|
||||
_lostSinceMs = millis();
|
||||
_retrySinceMs = _lostSinceMs;
|
||||
Serial.println("# wifi : connexion perdue, reconnexion automatique...");
|
||||
}
|
||||
}
|
||||
|
||||
bool WifiLink::connected() const {
|
||||
return WiFi.status() == WL_CONNECTED;
|
||||
}
|
||||
|
||||
int WifiLink::rssi() const {
|
||||
return connected() ? WiFi.RSSI() : 0;
|
||||
}
|
||||
|
||||
IPAddress WifiLink::ip() const {
|
||||
return connected() ? WiFi.localIP() : IPAddress();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
/**
|
||||
* @brief Connexion Wi-Fi en mode station, non bloquante, reconnexion automatique.
|
||||
*
|
||||
* begin() lance la connexion et rend la main aussitôt : l'audio ne doit jamais
|
||||
* attendre le réseau. update() signale les changements d'état et, si le réseau
|
||||
* reste absent plus de WIFI_RESTART_MS, relance la connexion de zéro (étape 15).
|
||||
*/
|
||||
class WifiLink {
|
||||
public:
|
||||
/**
|
||||
* @param ssid Nom du réseau (2,4 GHz).
|
||||
* @param password Mot de passe.
|
||||
* @param hostname Nom de la carte sur le réseau local.
|
||||
*/
|
||||
WifiLink(const char* ssid, const char* password, const char* hostname);
|
||||
|
||||
/// Lance la connexion, sans attendre.
|
||||
void begin();
|
||||
|
||||
/// À appeler à chaque tour de boucle : journalise connexion et perte, relance si besoin.
|
||||
void update();
|
||||
|
||||
/// true si l'ESP32 a une adresse IP.
|
||||
bool connected() const;
|
||||
|
||||
/// Puissance du signal reçu, en dBm (-50 excellent, -80 limite).
|
||||
int rssi() const;
|
||||
|
||||
/// Adresse IP courante, ou 0.0.0.0.
|
||||
IPAddress ip() const;
|
||||
|
||||
private:
|
||||
const char* _ssid;
|
||||
const char* _password;
|
||||
const char* _hostname;
|
||||
bool _wasConnected = false;
|
||||
uint32_t _lostSinceMs = 0; ///< début de la coupure en cours, pour la journaliser
|
||||
uint32_t _retrySinceMs = 0; ///< dernière relance de la connexion
|
||||
};
|
||||
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* @file main.cpp
|
||||
* @brief Étapes 1 à 5 — voix vers le serveur et réponses du serveur, par MQTT.
|
||||
*
|
||||
* Entrée (étape 4) : PTT -> micro I2S0 -> tâche de capture -> FIFO -> loop() -> MQTT audio/in
|
||||
* (ou liaison série si le broker est absent, format de l'étape 1).
|
||||
* Sortie (étapes 2 et 5) : MQTT audio/out ou trames série -> PlaybackStream -> FIFO
|
||||
* -> tâche audio -> I2S1 -> ampli.
|
||||
* Réseau (étape 3) : Wi-Fi + MQTT ; état retenu sur voice/<id>/state, testament « offline ».
|
||||
* Fiabilisation (étape 15) : compte MQTT, chien de garde des tâches, micro coupé quand
|
||||
* le serveur annonce son absence (voice/server/status).
|
||||
*
|
||||
* Les deux chemins sont indépendants : un micro en panne n'empêche pas la
|
||||
* lecture, et inversement. Aucune allocation dynamique dans les chemins audio.
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_system.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include "AudioFifo.h"
|
||||
#include "AudioOutput.h"
|
||||
#include "CommandHandler.h"
|
||||
#include "DemoDevices.h"
|
||||
#include "MicCapture.h"
|
||||
#include "MicSelfTest.h"
|
||||
#include "MqttLink.h"
|
||||
#include "PlaybackStream.h"
|
||||
#include "PushButton.h"
|
||||
#include "SerialFrameReader.h"
|
||||
#include "SerialFramer.h"
|
||||
#include "StatusLed.h"
|
||||
#include "VoiceSession.h"
|
||||
#include "WifiLink.h"
|
||||
#include "config.h"
|
||||
|
||||
// Les secrets (Wi-Fi, broker) ne sont jamais versionnés : on les exige ici, avec
|
||||
// un message clair, plutôt qu'une erreur de compilation obscure plus loin.
|
||||
#if __has_include("secrets.h")
|
||||
#include "secrets.h"
|
||||
#else
|
||||
#error "firmware/include/secrets.h absent : cp firmware/include/secrets.h.example firmware/include/secrets.h, puis renseignez le Wi-Fi et l'adresse du broker"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
using SerialProtocol::FrameType;
|
||||
|
||||
MicCapture mic(PIN_MIC_SCK, PIN_MIC_WS, PIN_MIC_SD);
|
||||
MicSelfTest selfTest(mic);
|
||||
AudioOutput speaker(PIN_AMP_BCLK, PIN_AMP_LRC, PIN_AMP_DIN);
|
||||
// Deux FIFO, stockage statique fourni par nous (+1 octet exigé par FreeRTOS).
|
||||
uint8_t playbackStorage[PLAYBACK_FIFO_BYTES + 1];
|
||||
uint8_t captureStorage[CAPTURE_FIFO_BYTES + 1];
|
||||
AudioFifo playbackFifo; ///< PC -> tâche audio -> ampli
|
||||
AudioFifo captureFifo; ///< micro -> tâche de capture -> loop() -> réseau
|
||||
PushButton button(PIN_BUTTON_PTT, BUTTON_DEBOUNCE_MS);
|
||||
StatusLed led(PIN_STATUS_LED);
|
||||
DemoDevices devices(PIN_DEMO_LIGHT);
|
||||
SerialFramer framer(Serial);
|
||||
SerialFrameReader reader(Serial);
|
||||
WiFiClient network;
|
||||
WifiLink wifi(WIFI_SSID, WIFI_PASSWORD, DEVICE_ID);
|
||||
MqttLink mqtt(network, MQTT_HOST, MQTT_PORT, DEVICE_ID, MQTT_USER, MQTT_PASSWORD);
|
||||
|
||||
/// Chunk en cours d'envoi réseau : 1600 octets, alloué une seule fois.
|
||||
uint8_t outgoingChunk[AUDIO_CHUNK_BYTES];
|
||||
|
||||
/// Durée maximale d'une session d'enregistrement, garde-fou si le bouton reste bloqué (ms).
|
||||
constexpr uint32_t SESSION_MAX_MS = 15000;
|
||||
|
||||
// --- Session de parole (étape 4), manipulée par loop() seulement.
|
||||
VoiceSession session(mqtt, framer);
|
||||
uint32_t captureOverrunsAtStart = 0;
|
||||
bool micReady = false;
|
||||
|
||||
// Écoute continue (étape 13), pilotée par le serveur. loop() seulement.
|
||||
bool listenWanted = false; ///< le serveur demande le flux continu
|
||||
bool streaming = false; ///< le micro part en continu sur voice/<id>/audio/stream
|
||||
bool wakeCapturing = false; ///< mot de réveil entendu : le serveur écoute la commande
|
||||
uint32_t wakeCaptureSinceMs = 0;
|
||||
bool serverOnline = false; ///< présence annoncée par le serveur (étape 15)
|
||||
|
||||
// Partagés entre loop() et la tâche de capture.
|
||||
std::atomic<bool> captureWanted{false}; ///< loop() demande la capture
|
||||
std::atomic<bool> captureRunning{false}; ///< la tâche capture effectivement
|
||||
bool speakerReady = false;
|
||||
uint32_t lastStateMs = 0;
|
||||
|
||||
// --- Lecture (étapes 2 et 5) et commandes (étape 5).
|
||||
PlaybackStream playback(speaker, playbackFifo);
|
||||
void onListen(bool enabled);
|
||||
void onCapture(bool active);
|
||||
CommandHandler commands(playback, mqtt, devices, onListen, onCapture);
|
||||
|
||||
// Pile et contrôle de la tâche de capture, alloués statiquement.
|
||||
StaticTask_t captureTaskControl;
|
||||
StackType_t captureTaskStack[CAPTURE_TASK_STACK_BYTES];
|
||||
|
||||
/**
|
||||
* @brief Tâche de capture : micro -> FIFO de capture, au rythme du micro.
|
||||
*
|
||||
* Elle ne touche jamais au réseau : un envoi Wi-Fi peut bloquer, et pendant ce
|
||||
* temps le DMA du micro (75 ms de réserve) déborderait. Ici, rien ne bloque
|
||||
* sauf la lecture du micro elle-même, qui rend la main toutes les 50 ms.
|
||||
*/
|
||||
void captureTask(void* /*unused*/) {
|
||||
static int16_t chunk[AUDIO_CHUNK_SAMPLES];
|
||||
esp_task_wdt_add(nullptr); // surveillée par le chien de garde (étape 15)
|
||||
|
||||
for (;;) {
|
||||
esp_task_wdt_reset(); // « je suis vivante » : au moins toutes les 50 ms
|
||||
if (!captureWanted.load()) {
|
||||
captureRunning.store(false);
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!captureRunning.load()) {
|
||||
mic.flush(); // on jette l'audio capté avant l'appui
|
||||
mic.resetStats(); // les statistiques ne portent que sur cette session
|
||||
captureRunning.store(true);
|
||||
}
|
||||
|
||||
const size_t samples = mic.read(chunk, AUDIO_CHUNK_SAMPLES); // ~50 ms
|
||||
if (samples > 0) {
|
||||
// Tout ou rien : si la FIFO est pleine, le chunk est compté perdu.
|
||||
captureFifo.write(reinterpret_cast<const uint8_t*>(chunk), samples * sizeof(int16_t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lit un entier little-endian dans un payload.
|
||||
uint32_t readLe(const uint8_t* data, size_t bytes) {
|
||||
uint32_t value = 0;
|
||||
for (size_t i = 0; i < bytes; ++i) {
|
||||
value |= static_cast<uint32_t>(data[i]) << (8 * i);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Traite une trame reçue du PC par la liaison série (étape 2).
|
||||
void handleFrame(const SerialFrameReader::Frame& frame) {
|
||||
switch (frame.type) {
|
||||
case FrameType::Start:
|
||||
if (frame.length != SerialProtocol::START_PAYLOAD) {
|
||||
Serial.println("# lecture refusee : descripteur START invalide");
|
||||
return;
|
||||
}
|
||||
playback.open("serie", readLe(frame.payload, 4), readLe(frame.payload + 4, 2),
|
||||
readLe(frame.payload + 6, 2), false);
|
||||
break;
|
||||
case FrameType::Audio:
|
||||
playback.push(frame.payload, frame.length);
|
||||
break;
|
||||
case FrameType::End:
|
||||
playback.close(-1); // la liaison série n'annonce pas le nombre de chunks
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche le bilan d'un flux joué, et le publie s'il venait du serveur.
|
||||
void reportPlayback(const PlaybackStream::Report& report) {
|
||||
Serial.printf("# lecture %s : %s, %lu chunks recus", report.session,
|
||||
report.endReceived ? "terminee" : "abandonnee (pas de END)",
|
||||
static_cast<unsigned long>(report.chunks));
|
||||
if (report.announced >= 0) {
|
||||
Serial.printf(" sur %ld annonces", static_cast<long>(report.announced));
|
||||
}
|
||||
Serial.printf(", %lu refuses (FIFO pleine), %lu reamorcages, premier son a %lu ms, "
|
||||
"marge mini %ld ms, %lu ms\n",
|
||||
static_cast<unsigned long>(report.overruns),
|
||||
static_cast<unsigned long>(report.underruns),
|
||||
static_cast<unsigned long>(report.startMs),
|
||||
static_cast<long>(report.minMarginMs),
|
||||
static_cast<unsigned long>(report.durationMs));
|
||||
|
||||
// Instants des réamorçages (depuis le premier son) : au début, au milieu, ou à la fin ?
|
||||
char gaps[48] = "";
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < PlaybackStream::MAX_GAPS && i < report.underruns; ++i) {
|
||||
used += snprintf(gaps + used, sizeof(gaps) - used, "%s%lu", i == 0 ? "" : ",",
|
||||
static_cast<unsigned long>(report.gapAtMs[i]));
|
||||
}
|
||||
if (report.underruns > 0) {
|
||||
Serial.printf("# lecture %s : reamorcages a [%s] ms du premier son\n", report.session, gaps);
|
||||
}
|
||||
|
||||
if (!report.fromMqtt) {
|
||||
return;
|
||||
}
|
||||
char payload[304];
|
||||
snprintf(payload, sizeof(payload),
|
||||
"{\"event\":\"played\",\"session\":\"%s\",\"end\":%s,\"chunks\":%lu,"
|
||||
"\"announced\":%ld,\"overruns\":%lu,\"underruns\":%lu,\"duration_ms\":%lu,"
|
||||
"\"start_ms\":%lu,\"min_margin_ms\":%ld,\"gaps_ms\":[%s]}",
|
||||
report.session, report.endReceived ? "true" : "false",
|
||||
static_cast<unsigned long>(report.chunks), static_cast<long>(report.announced),
|
||||
static_cast<unsigned long>(report.overruns),
|
||||
static_cast<unsigned long>(report.underruns),
|
||||
static_cast<unsigned long>(report.durationMs),
|
||||
static_cast<unsigned long>(report.startMs),
|
||||
static_cast<long>(report.minMarginMs), gaps);
|
||||
mqtt.publish("event", payload, false);
|
||||
}
|
||||
|
||||
/// Cause du dernier redémarrage, en mot court pour le JSON.
|
||||
const char* resetReason() {
|
||||
switch (esp_reset_reason()) {
|
||||
case ESP_RST_POWERON:
|
||||
return "poweron";
|
||||
case ESP_RST_EXT:
|
||||
return "external"; // broche EN, ou ouverture du port série sur certaines cartes
|
||||
case ESP_RST_SW:
|
||||
return "software";
|
||||
case ESP_RST_PANIC:
|
||||
return "panic"; // plantage du firmware
|
||||
case ESP_RST_INT_WDT:
|
||||
case ESP_RST_TASK_WDT:
|
||||
case ESP_RST_WDT:
|
||||
return "watchdog"; // une tâche a monopolisé le processeur
|
||||
case ESP_RST_BROWNOUT:
|
||||
return "brownout"; // chute de tension : alimentation trop faible (ampli !)
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Publie l'état de la carte, en message retenu.
|
||||
*
|
||||
* Retenu : le broker garde le dernier état et le donne aussitôt à tout nouvel
|
||||
* abonné. Un serveur qui démarre après l'ESP32 sait donc immédiatement s'il est
|
||||
* en ligne, sans attendre la prochaine publication.
|
||||
*/
|
||||
void publishState() {
|
||||
char payload[192];
|
||||
snprintf(payload, sizeof(payload),
|
||||
"{\"status\":\"online\",\"ip\":\"%s\",\"rssi\":%d,\"uptime_s\":%lu,"
|
||||
"\"mic\":%s,\"speaker\":%s}",
|
||||
wifi.ip().toString().c_str(), wifi.rssi(),
|
||||
static_cast<unsigned long>(millis() / 1000), micReady ? "true" : "false",
|
||||
speakerReady ? "true" : "false");
|
||||
mqtt.publish("state", payload, true);
|
||||
lastStateMs = millis();
|
||||
}
|
||||
|
||||
/// Présence du serveur : absent, plus rien ne sort du micro (étape 15).
|
||||
void onServerStatus(const uint8_t* payload, size_t length) {
|
||||
char text[64];
|
||||
const size_t n = length < sizeof(text) - 1 ? length : sizeof(text) - 1;
|
||||
memcpy(text, payload, n);
|
||||
text[n] = '\0';
|
||||
const bool online = strstr(text, "\"online\"") != nullptr;
|
||||
if (online == serverOnline) {
|
||||
return;
|
||||
}
|
||||
serverOnline = online;
|
||||
if (online) {
|
||||
Serial.println("# serveur : en ligne");
|
||||
return;
|
||||
}
|
||||
// Testament du serveur (plantage) ou arrêt : il ne pourra plus dire « listen false ».
|
||||
// Il redemandera l'écoute à son retour, en voyant l'état de la carte.
|
||||
listenWanted = false;
|
||||
wakeCapturing = false;
|
||||
Serial.println("# serveur : ABSENT, ecoute continue coupee");
|
||||
}
|
||||
|
||||
/// Appelé à chaque connexion au broker.
|
||||
void onMqttConnect(bool firstTime) {
|
||||
serverOnline = false; // l'état retenu du serveur arrive avec l'abonnement
|
||||
mqtt.subscribe("control");
|
||||
mqtt.subscribe("audio/out");
|
||||
mqtt.subscribeTopic(MQTT_SERVER_STATUS_TOPIC);
|
||||
publishState(); // remplace aussitôt le « offline » retenu d'une absence précédente
|
||||
|
||||
if (firstTime) {
|
||||
char payload[96];
|
||||
snprintf(payload, sizeof(payload), "{\"event\":\"boot\",\"reason\":\"%s\"}",
|
||||
resetReason());
|
||||
mqtt.publish("event", payload, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appelé pour chaque message reçu. Court : pas d'attente, pas de traitement lourd.
|
||||
void onMqttMessage(const char* suffix, const uint8_t* payload, size_t length) {
|
||||
if (strcmp(suffix, "audio/out") == 0) {
|
||||
playback.push(payload, length); // ne fait que remplir la FIFO
|
||||
} else if (strcmp(suffix, "control") == 0) {
|
||||
commands.handle(payload, length);
|
||||
} else if (strcmp(suffix, MQTT_SERVER_STATUS_TOPIC) == 0) {
|
||||
onServerStatus(payload, length);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commande « listen » du serveur : active ou coupe l'écoute continue.
|
||||
void onListen(bool enabled) {
|
||||
listenWanted = enabled;
|
||||
Serial.printf("# ecoute continue %s par le serveur\n", enabled ? "activee" : "desactivee");
|
||||
}
|
||||
|
||||
/// Commande « capture » du serveur : mot de réveil entendu (LED allumée) ou commande finie.
|
||||
void onCapture(bool active) {
|
||||
wakeCapturing = active;
|
||||
wakeCaptureSinceMs = millis();
|
||||
}
|
||||
|
||||
/// Arrête le flux continu et jette ce qui restait : rien ne sort du micro sans raison.
|
||||
void stopStreaming() {
|
||||
captureWanted.store(false);
|
||||
const uint32_t waitStart = millis();
|
||||
while (captureRunning.load() && (millis() - waitStart) < 200) {
|
||||
delay(1);
|
||||
}
|
||||
while (captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0) > 0) {
|
||||
}
|
||||
streaming = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écoute continue (étape 13) : le micro part vers le serveur, qui guette le mot de réveil.
|
||||
* Jamais pendant une réponse parlée : le micro entendrait le haut-parleur, et le Wi-Fi
|
||||
* doit alors servir la lecture.
|
||||
*/
|
||||
void updateStreaming() {
|
||||
const bool wanted = listenWanted && serverOnline && mqtt.connected() && !playback.active();
|
||||
if (wanted && !streaming) {
|
||||
captureWanted.store(true);
|
||||
streaming = true;
|
||||
} else if (!wanted && streaming) {
|
||||
stopStreaming();
|
||||
}
|
||||
if (!streaming) {
|
||||
return;
|
||||
}
|
||||
while (captureFifo.available() >= AUDIO_CHUNK_BYTES) {
|
||||
const size_t bytes = captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0);
|
||||
if (bytes == 0) {
|
||||
break;
|
||||
}
|
||||
mqtt.publishBinary("audio/stream", outgoingChunk, bytes);
|
||||
esp_task_wdt_reset(); // chaque envoi peut attendre le Wi-Fi jusqu'à 10 s : on avance
|
||||
}
|
||||
}
|
||||
|
||||
/// LED au repos : allumée = commande écoutée, éclair bref = écoute continue, lent = prêt.
|
||||
void updateIdleLed() {
|
||||
if (wakeCapturing && (millis() - wakeCaptureSinceMs) > SESSION_MAX_MS) {
|
||||
wakeCapturing = false; // le serveur n'a jamais dit « fini » : on ne reste pas allumé
|
||||
}
|
||||
if (wakeCapturing) {
|
||||
led.on();
|
||||
} else if (streaming) {
|
||||
led.blink(2000, 100);
|
||||
} else {
|
||||
led.blink(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre une session de parole : MQTT si possible, liaison série sinon.
|
||||
void startSession() {
|
||||
const bool viaMqtt = mqtt.connected();
|
||||
if (!viaMqtt) {
|
||||
Serial.println("# session : MQTT non connecte, envoi par la liaison serie (make record)");
|
||||
}
|
||||
|
||||
captureOverrunsAtStart = captureFifo.overruns();
|
||||
session.start(viaMqtt ? VoiceSession::Transport::Mqtt : VoiceSession::Transport::Serial,
|
||||
mic.sampleRate());
|
||||
captureWanted.store(true);
|
||||
led.on();
|
||||
}
|
||||
|
||||
/// Envoie au réseau les chunks complets en attente. Peut bloquer sur le Wi-Fi.
|
||||
void drainCapture(bool includePartial) {
|
||||
for (;;) {
|
||||
const size_t available = captureFifo.available();
|
||||
if (available == 0 || (!includePartial && available < AUDIO_CHUNK_BYTES)) {
|
||||
return;
|
||||
}
|
||||
const size_t bytes = captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0);
|
||||
if (bytes == 0) {
|
||||
return;
|
||||
}
|
||||
session.sendChunk(outgoingChunk, bytes);
|
||||
esp_task_wdt_reset(); // chaque envoi peut attendre le Wi-Fi jusqu'à 10 s : on avance
|
||||
}
|
||||
}
|
||||
|
||||
/// Ferme la session : arrête la capture, envoie le reste, publie le bilan.
|
||||
void stopSession(const char* reason) {
|
||||
captureWanted.store(false);
|
||||
// La tâche finit le chunk en cours (50 ms au plus) avant de s'arrêter.
|
||||
const uint32_t waitStart = millis();
|
||||
while (captureRunning.load() && (millis() - waitStart) < 200) {
|
||||
delay(1);
|
||||
}
|
||||
|
||||
drainCapture(true);
|
||||
const uint32_t overruns = captureFifo.overruns() - captureOverrunsAtStart;
|
||||
session.end(overruns);
|
||||
led.off();
|
||||
|
||||
Serial.printf("\n# session %s terminee (%s, %s) : %lu chunks envoyes, %lu echecs d'envoi, "
|
||||
"%lu perdus faute de place, %lu ms\n",
|
||||
session.id(), reason,
|
||||
session.transport() == VoiceSession::Transport::Mqtt ? "MQTT" : "serie",
|
||||
static_cast<unsigned long>(session.chunksSent()),
|
||||
static_cast<unsigned long>(session.sendFailures()),
|
||||
static_cast<unsigned long>(overruns),
|
||||
static_cast<unsigned long>(session.durationMs()));
|
||||
if (session.durationMs() < 300) {
|
||||
// Trop court pour contenir une parole : appui trop bref, ou bouton qui
|
||||
// « lâche » tout seul (contact intermittent, rebond). Le serveur l'ignore.
|
||||
Serial.printf("# ATTENTION : session de %lu ms seulement. Si vous teniez le bouton,\n"
|
||||
"# son contact est intermittent : verifiez ses fils sur la plaque.\n",
|
||||
static_cast<unsigned long>(session.durationMs()));
|
||||
}
|
||||
selfTest.reportSession(mic.stats());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Chien de garde (étape 15) : délai porté à WATCHDOG_TIMEOUT_S, redémarrage si dépassé.
|
||||
*
|
||||
* loop() est surveillée à chaque tour ; les tâches capture et audio s'inscrivent elles-mêmes.
|
||||
* La tâche « idle » du cœur 0 reste surveillée : si le Wi-Fi (cœur 0) monopolise le processeur,
|
||||
* le chien de garde le voit aussi.
|
||||
*/
|
||||
void setupWatchdog() {
|
||||
const esp_task_wdt_config_t config = {
|
||||
.timeout_ms = WATCHDOG_TIMEOUT_S * 1000,
|
||||
.idle_core_mask = 1 << 0,
|
||||
.trigger_panic = true, // redémarrage, et non simple message
|
||||
};
|
||||
if (esp_task_wdt_reconfigure(&config) != ESP_OK) {
|
||||
Serial.println("# ERREUR : chien de garde non configure");
|
||||
return;
|
||||
}
|
||||
enableLoopWDT();
|
||||
Serial.printf("# chien de garde : %lu s (loop, capture, audio)\n",
|
||||
static_cast<unsigned long>(WATCHDOG_TIMEOUT_S));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setup() {
|
||||
// En tout premier : la sortie audio prend les broches de l'ampli et lui envoie
|
||||
// du silence. Laissées flottantes, elles le faisaient souffler à plein volume.
|
||||
speakerReady = speaker.begin(AUDIO_SAMPLE_RATE, AMP_FORMAT_PHILIPS);
|
||||
|
||||
Serial.setRxBufferSize(SERIAL_RX_BUFFER_BYTES); // avant begin(), sinon ignoré
|
||||
Serial.begin(SERIAL_BAUD);
|
||||
// Hors chemin audio : le moniteur série se reconnecte après le reset de la
|
||||
// carte. Sans cette attente, les premières lignes de l'auto-test sont perdues.
|
||||
delay(1500);
|
||||
|
||||
led.begin();
|
||||
devices.begin();
|
||||
button.begin();
|
||||
|
||||
Serial.println("\n# --- assistant vocal, etapes 1 a 3 : audio + Wi-Fi + MQTT ---");
|
||||
Serial.printf("# format : %lu Hz, 16 bits, mono, chunk %lu ms = %u octets\n",
|
||||
static_cast<unsigned long>(AUDIO_SAMPLE_RATE),
|
||||
static_cast<unsigned long>(AUDIO_CHUNK_MS),
|
||||
static_cast<unsigned>(AUDIO_CHUNK_BYTES));
|
||||
Serial.printf("# micro : SCK=GPIO %d, WS=GPIO %d, SD=GPIO %d\n", PIN_MIC_SCK, PIN_MIC_WS,
|
||||
PIN_MIC_SD);
|
||||
Serial.printf("# ampli : BCLK=GPIO %d, LRC=GPIO %d, DIN=GPIO %d, cadrage %s\n", PIN_AMP_BCLK,
|
||||
PIN_AMP_LRC, PIN_AMP_DIN, AMP_FORMAT_PHILIPS ? "Philips" : "MSB");
|
||||
|
||||
// --- Sortie : test isolé par un bip, puis mise en place de la chaîne de lecture.
|
||||
if (!speakerReady) {
|
||||
Serial.println("# ERREUR : sortie audio I2S1 impossible a demarrer");
|
||||
} else {
|
||||
Serial.printf("# bip de demarrage : %lu Hz, %lu ms, -12 dBFS\n",
|
||||
static_cast<unsigned long>(BOOT_TONE_HZ),
|
||||
static_cast<unsigned long>(BOOT_TONE_MS));
|
||||
speaker.playTone(BOOT_TONE_HZ, BOOT_TONE_MS, BOOT_TONE_AMPLITUDE);
|
||||
|
||||
if (!playbackFifo.begin(playbackStorage, PLAYBACK_FIFO_BYTES,
|
||||
PLAYBACK_BLOCK_SAMPLES * sizeof(int16_t))) {
|
||||
Serial.println("# ERREUR : FIFO de lecture impossible a creer");
|
||||
} else {
|
||||
playback.begin();
|
||||
Serial.printf("# lecture prete : FIFO %u ms, tache audio sur le coeur %d\n",
|
||||
static_cast<unsigned>(PLAYBACK_FIFO_BYTES / (AUDIO_SAMPLE_RATE / 1000 * 2)),
|
||||
AUDIO_TASK_CORE);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entrée : auto-test du micro.
|
||||
micReady = selfTest.run();
|
||||
if (micReady) {
|
||||
// La tâche de capture ne démarre qu'après l'auto-test : ils partagent le micro.
|
||||
if (!captureFifo.begin(captureStorage, CAPTURE_FIFO_BYTES, AUDIO_CHUNK_BYTES)) {
|
||||
Serial.println("# ERREUR : FIFO de capture impossible a creer");
|
||||
micReady = false;
|
||||
} else {
|
||||
xTaskCreateStaticPinnedToCore(captureTask, "capture", CAPTURE_TASK_STACK_BYTES,
|
||||
nullptr, CAPTURE_TASK_PRIORITY, captureTaskStack,
|
||||
&captureTaskControl, CAPTURE_TASK_CORE);
|
||||
Serial.println("# micro pret. Appuyez sur le bouton PTT et parlez.");
|
||||
led.blink(1000); // clignotement lent = prêt, au repos
|
||||
}
|
||||
}
|
||||
|
||||
// --- Réseau, en dernier : l'auto-test du micro se fait sans les émissions
|
||||
// radio du Wi-Fi (pointes de 300 mA), qui pourraient fausser la mesure.
|
||||
Serial.printf("# reseau : Wi-Fi \"%s\", broker %s:%u, topics %s/%s/..., compte MQTT %s\n",
|
||||
WIFI_SSID, MQTT_HOST, MQTT_PORT, MQTT_TOPIC_PREFIX, DEVICE_ID,
|
||||
MQTT_PASSWORD[0] != '\0' ? "oui" : "NON (anonyme)");
|
||||
network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS);
|
||||
if (!mqtt.begin(onMqttMessage, onMqttConnect)) {
|
||||
Serial.println("# ERREUR : tampon MQTT impossible a allouer");
|
||||
}
|
||||
wifi.begin();
|
||||
|
||||
// En dernier : l'auto-test du micro et le bip ont leurs propres durées, hors surveillance.
|
||||
setupWatchdog();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// La réception série passe toujours en premier : elle ne doit jamais attendre.
|
||||
SerialFrameReader::Frame frame;
|
||||
while (reader.poll(frame)) {
|
||||
handleFrame(frame);
|
||||
}
|
||||
|
||||
// Réseau : non bloquant, sauf pendant une tentative de connexion au broker.
|
||||
wifi.update();
|
||||
mqtt.update(wifi.connected());
|
||||
|
||||
PlaybackStream::Report played;
|
||||
if (playback.takeReport(played)) {
|
||||
reportPlayback(played);
|
||||
}
|
||||
if (mqtt.connected() && (millis() - lastStateMs) >= STATE_PERIOD_MS) {
|
||||
publishState();
|
||||
}
|
||||
|
||||
if (!micReady) {
|
||||
led.blink(150); // clignotement rapide : micro en défaut, lecture toujours possible
|
||||
led.update();
|
||||
return;
|
||||
}
|
||||
|
||||
button.update();
|
||||
|
||||
if (!session.active()) {
|
||||
if (button.wasPressed()) {
|
||||
button.clearEvents(); // un relâchement périmé ne doit pas clore la session
|
||||
if (streaming) {
|
||||
stopStreaming(); // le bouton reprend la main sur le micro
|
||||
}
|
||||
startSession();
|
||||
} else {
|
||||
updateStreaming();
|
||||
updateIdleLed();
|
||||
}
|
||||
led.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Session en cours : la capture tourne dans sa tâche ; ici on envoie.
|
||||
drainCapture(false);
|
||||
|
||||
if (button.wasReleased() || !button.isDown()) {
|
||||
stopSession("bouton relache");
|
||||
button.clearEvents(); // un appui mémorisé pendant la session ne doit pas en relancer une
|
||||
led.blink(1000);
|
||||
} else if (session.elapsedMs() > SESSION_MAX_MS) {
|
||||
stopSession("duree maximale");
|
||||
led.blink(1000);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,354 @@
|
||||
# PROMPT — Assistant vocal local ESP32 / Raspberry Pi (MQTT, faster-whisper, Piper)
|
||||
|
||||
---
|
||||
|
||||
## 1. RÔLE
|
||||
|
||||
Tu es **ingénieur senior en systèmes embarqués et traitement de la parole**, doublé d'un **enseignant expérimenté** (IUT / école d'ingénieurs). Tu maîtrises :
|
||||
|
||||
- **ESP32** : Arduino-ESP32 (C++), I2S (RX et TX), FreeRTOS (tâches, queues, StreamBuffer), Wi-Fi, watchdog.
|
||||
- **Audio numérique** : PCM s16le, échantillonnage, rééchantillonnage, buffers, anti-jitter, gain, offset DC.
|
||||
- **MQTT** : Mosquitto 2.x, QoS, Last Will, payload binaire, PubSubClient (ESP32), paho-mqtt 2.x (Python).
|
||||
- **Python 3.11+** : architecture modulaire orientée classes, typage strict (pyright), logging, asyncio/threads.
|
||||
- **Traitement vocal local** : Silero VAD, faster-whisper, pyannote.audio, SpeechBrain (ECAPA), openWakeWord, Piper TTS.
|
||||
|
||||
Ta personnalité : **pédagogue, rigoureux, pragmatique**. Tu privilégies toujours la solution la plus simple qui fonctionne, tu expliques le *pourquoi* avant le *comment*, et tu refuses la complexité inutile.
|
||||
|
||||
---
|
||||
|
||||
## 2. OBJECTIF
|
||||
|
||||
Guider pas à pas des étudiants dans la réalisation d'un **assistant vocal 100 % local** : un ESP32 capture la voix (INMP441), l'envoie en PCM binaire via MQTT à un serveur Python (Raspberry Pi ou PC) qui transcrit (faster-whisper), interprète, répond en synthèse vocale (Piper) et renvoie l'audio PCM via MQTT à l'ESP32 qui le joue (MAX98357A) et exécute la commande éventuelle.
|
||||
|
||||
---
|
||||
|
||||
## 3. CONTEXTE TECHNIQUE
|
||||
|
||||
### 3.1 Architecture
|
||||
|
||||
```
|
||||
INMP441 -> ESP32 -> MQTT -> Serveur Python (Raspberry Pi / PC)
|
||||
|
|
||||
+---------+---------+
|
||||
| |
|
||||
VAD Diarisation /
|
||||
| identification
|
||||
+---------+---------+
|
||||
|
|
||||
faster-whisper
|
||||
|
|
||||
Assistant
|
||||
|
|
||||
Piper
|
||||
|
|
||||
MQTT audio/out
|
||||
|
|
||||
ESP32 -> MAX98357A -> haut-parleur
|
||||
```
|
||||
|
||||
Rôle de chaque bloc (à ne jamais confondre) :
|
||||
|
||||
| Bloc | Question à laquelle il répond |
|
||||
|---|---|
|
||||
| VAD | Est-ce que quelqu'un parle ? |
|
||||
| Diarisation | Qui parle à quel moment ? (SPEAKER_00, SPEAKER_01…) |
|
||||
| Identification | Qui est cette personne ? (Denis, Marie, Inconnu) |
|
||||
| STT | Qu'est-ce qui est dit ? |
|
||||
|
||||
### 3.2 Matériel
|
||||
|
||||
- **ESP32-WROOM-32** (2 périphériques I2S : I2S0 en RX micro, I2S1 en TX ampli).
|
||||
- **INMP441** : micro I2S, broche L/R à GND (canal gauche), alimentation 3,3 V.
|
||||
- **MAX98357A** : ampli I2S classe D mono, alimentation 5 V, broche SD (arrêt) et GAIN documentées.
|
||||
- **Bouton Push-To-Talk** + **LED d'état**.
|
||||
- **Raspberry Pi 4/5 (64 bits, Debian/Raspberry Pi OS)** ou PC Debian.
|
||||
|
||||
Brochage proposé (à justifier et adapter si besoin, **jamais de broches de strapping** GPIO 0, 2, 5, 12, 15 pour l'audio) :
|
||||
|
||||
| Signal | GPIO |
|
||||
|---|---|
|
||||
| INMP441 SCK / WS / SD | 26 / 25 / 33 |
|
||||
| MAX98357A BCLK / LRC / DIN | 27 / 14 / 22 |
|
||||
| Bouton PTT (pull-up interne) | 4 |
|
||||
| LED état | 2 (sortie uniquement, acceptable) |
|
||||
|
||||
### 3.3 Format audio (unique dans tout le système)
|
||||
|
||||
- 16 kHz, 16 bits, mono, **PCM signé little-endian (`pcm_s16le`)**.
|
||||
- Débit ≈ 32 kB/s ; chunks de **40 à 50 ms = 1280 à 1600 octets**.
|
||||
- **Pas de Base64** : le payload MQTT audio contient des bytes PCM bruts.
|
||||
- L'INMP441 fournit des échantillons 24 bits dans des mots de 32 bits : conversion en 16 bits obligatoire (décalage + gain), suppression de l'offset DC recommandée.
|
||||
- Piper produit souvent du 22 050 Hz : **rééchantillonner côté serveur en 16 kHz** avant envoi, pour garder l'ESP32 simple.
|
||||
|
||||
### 3.4 Topics MQTT
|
||||
|
||||
| Topic | Sens | Payload | QoS |
|
||||
|---|---|---|---|
|
||||
| `voice/esp32-01/control` | serveur → ESP32 | JSON (commandes) | 1 |
|
||||
| `voice/esp32-01/audio/in` | ESP32 → serveur | bytes PCM | 0 |
|
||||
| `voice/esp32-01/audio/out` | serveur → ESP32 | bytes PCM | 0 |
|
||||
| `voice/esp32-01/state` | ESP32 → serveur | JSON, retained + Last Will `offline` | 1 |
|
||||
| `voice/esp32-01/event` | ESP32 → serveur | JSON (start / end / erreurs) | 1 |
|
||||
|
||||
Cycle de session : `START` (JSON sur `event`) → chunks PCM binaires (`audio/in`) → `END` (JSON sur `event`).
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "start",
|
||||
"session": "abc123",
|
||||
"rate": 16000,
|
||||
"bits": 16,
|
||||
"channels": 1,
|
||||
"codec": "pcm_s16le",
|
||||
"chunk_ms": 50
|
||||
}
|
||||
```
|
||||
|
||||
La sortie suit le même principe : `START` sur `control` → chunks sur `audio/out` → `END`. Le numéro de séquence (phase 16) sera un en-tête binaire fixe et documenté (ex. `uint32` little-endian) préfixé au PCM.
|
||||
|
||||
### 3.5 Pièges connus à signaler au bon moment
|
||||
|
||||
- Mosquitto 2.x n'écoute que sur `localhost` sans `listener 1883` explicite dans la configuration.
|
||||
- PubSubClient : `setBufferSize()` doit dépasser la taille d'un chunk + en-tête MQTT ; `loop()` doit être appelé très souvent.
|
||||
- Le callback MQTT de l'ESP32 doit être court : il pousse dans une FIFO, une tâche FreeRTOS dédiée écrit en I2S.
|
||||
- Silero VAD attend des fenêtres de 512 échantillons à 16 kHz.
|
||||
- faster-whisper : `language="fr"`, `compute_type="int8"` ; modèle `base` sur Pi 4, `small` sur Pi 5/PC.
|
||||
- pyannote.audio nécessite un token Hugging Face (secret, jamais versionné) et reste lourd sur Raspberry Pi : prévoir l'option PC.
|
||||
- si la latence de pyannote sur le Raspberry Pi est trop importante, on déplacera uniquement la diarisation/identification vers une machine plus puissante.
|
||||
- La diarisation n'a probablement pas besoin d'être permanente: envoyer à pyannote uniquement la séquence parlée, plutôt que de lui faire analyser en permanence le flux microphone.
|
||||
- L'identification vocale est **probabiliste** : ce n'est **jamais** une authentification de sécurité.
|
||||
|
||||
### 3.6 Feuille de route (ordre imposé)
|
||||
|
||||
| # | Étape | Critère de validation |
|
||||
|---|---|---|
|
||||
| 1 | Entrée audio ESP32 (I2S RX) | Phrase enregistrée via série → WAV sur PC, audible et sans saturation |
|
||||
| 2 | Sortie audio ESP32 (I2S TX) | Sinus 440 Hz puis PCM joués sans coupure ni parasite |
|
||||
| 3 | Mosquitto + topics | `mosquitto_sub` reçoit états et Last Will |
|
||||
| 4 | ESP32 → MQTT → serveur | Session START/chunks/END reconstruite en WAV côté serveur |
|
||||
| 5 | Serveur → MQTT → ESP32 | Un WAV serveur est joué par l'ESP32 |
|
||||
| 6 | **MVP** : PTT → faster-whisper → réponse → Piper → ESP32 | Boucle complète stable |
|
||||
| 7 | VAD (Silero) | Silences supprimés, début/fin de parole détectés |
|
||||
| 8 | Diarisation (pyannote) | Segments horodatés par locuteur |
|
||||
| 9 | Identification (embeddings + cosinus + seuil) | Nom ou « Inconnu » |
|
||||
| 10 | Association locuteur + texte | `{"speaker": "Denis", "text": "..."}` |
|
||||
| 11 | Assistant / domotique | Intention + entités → commande MQTT |
|
||||
| 12 | Buffer de sortie anti-jitter | Lecture fluide malgré les variations réseau |
|
||||
| 13 | Wake word (openWakeWord côté serveur) | Activation mains libres |
|
||||
| 14 | Multi-utilisateurs | Droits par profil (complet / standard / limité) |
|
||||
| 15 | Fiabilisation | Reconnexions, séquences, timeouts, watchdog, logs, auth MQTT, TLS optionnel |
|
||||
| 16 | Optimisation | Streaming, parallélisme, latence, cache |
|
||||
|
||||
**Règle absolue** : ne jamais passer à l'étape suivante tant que l'étape courante n'est pas validée par l'utilisateur.
|
||||
|
||||
### 3.7 Principes techniques
|
||||
|
||||
1. ESP32 simple : acquisition, restitution, transport. Tout traitement lourd sur le serveur.
|
||||
2. MQTT comme transport unique ; audio en PCM binaire.
|
||||
3. Entrée et sortie audio développées et testées séparément.
|
||||
4. Chaque module testable isolément (outil ou script de test fourni).
|
||||
5. Architecture locale et autonome : aucun service cloud.
|
||||
|
||||
---
|
||||
|
||||
## 4. PUBLIC CIBLE
|
||||
|
||||
Étudiants (BUT / licence / première année d'école d'ingénieurs) :
|
||||
|
||||
- bases en C/C++ et Python, notions d'électronique ;
|
||||
- découvrent I2S, MQTT et le traitement de la parole ;
|
||||
- travaillent sous Linux Debian avec VSCodium + PlatformIO.
|
||||
|
||||
Adapte le vocabulaire : définis chaque terme technique à sa première apparition (une phrase suffit).
|
||||
|
||||
---
|
||||
|
||||
## 5. STYLE DE COMMUNICATION
|
||||
|
||||
- Ton **mentor** : clair, bienveillant, exigeant sur la rigueur.
|
||||
- Phrases courtes. Pas de jargon non expliqué. Pas de remplissage.
|
||||
- Français pour les explications et les commentaires de code ; anglais pour tout identifiant de code.
|
||||
|
||||
---
|
||||
|
||||
## 6. FORMAT ATTENDU
|
||||
|
||||
Une réponse = **une étape** de la feuille de route, structurée ainsi :
|
||||
|
||||
1. **Objectif de l'étape** (2–3 lignes).
|
||||
2. **Notions clés** : explication courte des concepts nouveaux.
|
||||
3. **Schéma** (ASCII) du flux de données ou du câblage concerné.
|
||||
4. **Fichiers créés / modifiés** : arborescence partielle.
|
||||
5. **Code complet** de chaque fichier concerné (aucun `...`, aucun extrait tronqué).
|
||||
6. **Procédure de test** : commandes exactes (`make`, `pio`, `mosquitto_sub`, scripts).
|
||||
7. **Critères de validation** : ce que l'étudiant doit observer.
|
||||
8. **Pièges courants** et diagnostic associé.
|
||||
9. **Question de validation** : demander à l'utilisateur de confirmer le résultat avant de continuer.
|
||||
|
||||
---
|
||||
|
||||
## 7. RAISONNEMENT
|
||||
|
||||
Avant d'écrire le code de chaque étape, raisonne étape par étape (sans l'exposer longuement) :
|
||||
|
||||
1. contraintes matérielles et temps réel (RAM ESP32, taille des buffers, cadence I2S) ;
|
||||
2. calculs de dimensionnement (octets par chunk, durée de buffer, débit) — **les afficher** dans la réponse ;
|
||||
3. choix de la solution la plus simple satisfaisant le critère de validation ;
|
||||
4. risques et points de défaillance.
|
||||
|
||||
Présente ensuite uniquement la synthèse utile : calculs, choix retenus et leur justification en une phrase.
|
||||
|
||||
---
|
||||
|
||||
## 8. INSTRUCTIONS PERSONNALISÉES
|
||||
|
||||
### 8.1 Conventions de code
|
||||
|
||||
- **Code en anglais, commentaires en français**, indentation **4 espaces** partout (C++, Python, Makefile excepté pour les tabulations imposées par `make`, YAML, JSON).
|
||||
- **Organisation en modules sous forme de classes**, une responsabilité par classe.
|
||||
- Chaque classe et méthode publique documentée (Doxygen `/** */` en C++, docstrings en Python).
|
||||
- Python : typage complet, compatible **pyright** en mode `standard`, `logging` (jamais `print`), configuration via `config.yaml` + `.env`.
|
||||
- C++ : pas d'allocation dynamique dans les boucles audio, pas de `delay()` bloquant dans le chemin audio, constantes dans `config.h`.
|
||||
- Dépendances Python épinglées dans `pyproject.toml`.
|
||||
|
||||
### 8.2 Modules attendus
|
||||
** Ide vscodium evec les extensions Platformio, Python, Pyright. Configurer pyright
|
||||
|
||||
**Firmware ESP32 (PlatformIO, framework Arduino)** :
|
||||
`WifiLink`, `MqttLink`, `MicCapture` (I2S RX + conversion 32→16 bits), `AudioOutput` (I2S TX), `AudioFifo` (ring buffer / StreamBuffer), `PushButton`, `StatusLed`, `VoiceSession` (START/chunks/END), `CommandHandler`.
|
||||
|
||||
**Serveur Python** :
|
||||
`MqttLink`, `SessionManager`, `AudioBuffer`, `VoiceActivityDetector`, `SpeechToText`, `Diarizer`, `SpeakerIdentifier`, `Assistant`, `TextToSpeech`, `AudioResampler`, `VoiceServer` (orchestration).
|
||||
|
||||
### 8.3 Arborescence du dépôt
|
||||
|
||||
```
|
||||
voice-assistant/
|
||||
├── firmware/
|
||||
│ ├── platformio.ini
|
||||
│ ├── include/ # config.h, secrets.h.example
|
||||
│ └── src/ # main.cpp + une paire .h/.cpp par classe
|
||||
├── server/
|
||||
│ ├── voice_server/ # un module .py par classe
|
||||
│ ├── tests/
|
||||
│ ├── config.yaml
|
||||
│ ├── .env.example
|
||||
│ ├── pyproject.toml
|
||||
│ └── pyrightconfig.json
|
||||
├── tools/ # serial_to_wav.py, wav_to_mqtt.py, mqtt_to_wav.py
|
||||
├── mosquitto/ # voice.conf
|
||||
├── Makefile
|
||||
├── README.md # français
|
||||
├── README-EN.md # anglais
|
||||
├── LICENSE
|
||||
├── .gitignore
|
||||
└── .gitattributes
|
||||
```
|
||||
|
||||
### 8.4 Makefile
|
||||
|
||||
Toutes les opérations système et d'installation passent par le Makefile, avec une cible `help` par défaut :
|
||||
`install-system` (apt : mosquitto, mosquitto-clients, ffmpeg, python3-venv), `mosquitto-config`, `venv`, `install`, `install-dev`, `models` (téléchargement des modèles Whisper/Piper), `run`, `test`, `lint` (pyright), `fw-build`, `fw-upload`, `fw-monitor`, `clean`.
|
||||
|
||||
### 8.5 Git et secrets
|
||||
|
||||
- `.gitignore` : exclut `secrets.h`, `.env`, tokens (Hugging Face…), mots de passe, clés, certificats, adresses mail personnelles, modèles téléchargés, enregistrements audio et profils vocaux (données personnelles), `.venv`, `.pio`, caches.
|
||||
- `.gitattributes` : normalisation LF, `*.wav`/`*.onnx`/`*.bin` en binaire.
|
||||
- Seuls des fichiers `*.example` sont versionnés pour la configuration sensible.
|
||||
|
||||
### 8.6 Documentation
|
||||
|
||||
- `README.md` (français) et `README-EN.md` (anglais) maintenus à chaque étape : matériel, câblage, installation, utilisation, tests.
|
||||
- Licence open source (MIT par défaut, sauf avis contraire).
|
||||
|
||||
### 8.7 Interdits
|
||||
|
||||
- Pas de Base64 pour l'audio.
|
||||
- Pas de service cloud ni d'API distante.
|
||||
- Pas de sur-ingénierie (pas de framework superflu, pas d'abstraction non justifiée).
|
||||
- Pas de code partiel ni de « à compléter » sans le signaler explicitement.
|
||||
- Ne jamais présenter l'identification vocale comme une sécurité.
|
||||
|
||||
---
|
||||
|
||||
## 9. EXEMPLES DE STYLE
|
||||
|
||||
### Exemple 1 — Classe C++ (en-tête)
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
/**
|
||||
* @brief Capture audio depuis le micro I2S INMP441.
|
||||
*
|
||||
* Lit des mots 32 bits, les convertit en PCM 16 bits signé
|
||||
* et supprime l'offset continu.
|
||||
*/
|
||||
class MicCapture {
|
||||
public:
|
||||
MicCapture(int pinSck, int pinWs, int pinSd);
|
||||
|
||||
// Initialise le périphérique I2S en réception
|
||||
bool begin(uint32_t sampleRate);
|
||||
|
||||
// Remplit le buffer avec 'count' échantillons 16 bits, retourne le nombre lu
|
||||
size_t read(int16_t* buffer, size_t count);
|
||||
|
||||
private:
|
||||
int _pinSck;
|
||||
int _pinWs;
|
||||
int _pinSd;
|
||||
i2s_chan_handle_t _rxHandle = nullptr;
|
||||
int32_t _dcOffset = 0; // estimation glissante de l'offset continu
|
||||
};
|
||||
```
|
||||
|
||||
### Exemple 2 — Classe Python
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpeechToText:
|
||||
"""Transcrit un segment PCM 16 kHz en texte avec faster-whisper."""
|
||||
|
||||
def __init__(self, model_name: str = "base", language: str = "fr") -> None:
|
||||
# int8 : compromis vitesse / précision adapté au Raspberry Pi
|
||||
self._model = WhisperModel(model_name, device="cpu", compute_type="int8")
|
||||
self._language = language
|
||||
|
||||
def transcribe(self, pcm: bytes) -> str:
|
||||
"""Retourne le texte reconnu à partir de bytes PCM s16le."""
|
||||
# Conversion int16 -> float32 normalisé dans [-1, 1]
|
||||
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
segments, _ = self._model.transcribe(audio, language=self._language)
|
||||
text = " ".join(segment.text.strip() for segment in segments)
|
||||
logger.info("Transcription : %s", text)
|
||||
return text
|
||||
```
|
||||
|
||||
### Exemple 3 — Dimensionnement affiché dans une réponse
|
||||
|
||||
> Chunk de 50 ms à 16 kHz, 16 bits, mono : 16000 × 0,05 × 2 = **1600 octets**.
|
||||
> Buffer anti-jitter de 300 ms en sortie : 6 chunks = **9600 octets** de RAM.
|
||||
> `setBufferSize(2048)` couvre le chunk et l'en-tête MQTT.
|
||||
|
||||
---
|
||||
|
||||
## 10. DÉMARRAGE
|
||||
|
||||
Pour ta première réponse :
|
||||
|
||||
1. Présente en 10 lignes maximum la démarche globale et la distinction VAD / diarisation / identification / STT.
|
||||
2. Fournis l'arborescence complète du dépôt, le `Makefile`, `.gitignore`, `.gitattributes`, `platformio.ini`, `config.h`, `secrets.h.example` et un squelette de `README.md` / `README-EN.md`.
|
||||
3. Traite ensuite **uniquement l'étape 1** (entrée audio ESP32) selon le format de la section 6, avec l'outil `tools/serial_to_wav.py`.
|
||||
4. Termine par la question de validation et attends la réponse avant de poursuivre.
|
||||
|
||||
Si une information manque (version du core Arduino-ESP32, modèle de Raspberry Pi, brochage réel), pose **une seule question ciblée** et propose une valeur par défaut raisonnable.
|
||||
@@ -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=
|
||||
@@ -0,0 +1,141 @@
|
||||
# Configuration du serveur vocal.
|
||||
# Les valeurs sensibles (mots de passe, jetons) vont dans .env, jamais ici.
|
||||
# Les sections marquées « étape N » ne sont lues qu'à partir de cette étape.
|
||||
|
||||
audio:
|
||||
sample_rate: 16000 # Hz, identique dans tout le système
|
||||
bits: 16
|
||||
channels: 1
|
||||
codec: pcm_s16le
|
||||
chunk_ms: 50 # 800 échantillons = 1600 octets
|
||||
|
||||
# étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/...
|
||||
mqtt:
|
||||
host: localhost # le broker tourne sur la même machine que le serveur
|
||||
port: 1883
|
||||
keepalive: 30
|
||||
client_id: voice-server # unique sur le broker
|
||||
topic_prefix: voice
|
||||
|
||||
# étape 4 : sessions de parole reçues des ESP32
|
||||
sessions:
|
||||
max_seconds: 30 # au-delà, on arrête d'accumuler (garde-fou mémoire)
|
||||
idle_timeout_s: 5 # plus rien reçu depuis 5 s et pas de END : on clôt
|
||||
end_grace_s: 0.5 # après le END, attente des derniers chunks en vol
|
||||
|
||||
# chemins relatifs à ce fichier
|
||||
storage:
|
||||
recordings_dir: recordings
|
||||
models_dir: models
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
format: "%(asctime)s %(levelname)-7s %(name)s | %(message)s"
|
||||
# étape 15 : copie du journal dans un fichier tournant (3 x 1 Mo), pour
|
||||
# retrouver ce qui s'est passé sur un Raspberry Pi sans écran. Vide : écran seul.
|
||||
file: logs/voice-server.log
|
||||
|
||||
# étape 6 : transcription (modèle dans models/whisper/<model>, « make models »)
|
||||
stt:
|
||||
model: small # « base » sur Raspberry Pi 4, « small » sur Pi 5 / PC
|
||||
language: fr
|
||||
compute_type: int8
|
||||
beam_size: 5 # 1 = plus rapide, 5 = plus précis
|
||||
no_speech_threshold: 0.6 # segment écarté au-delà : Whisper invente du texte sur le silence
|
||||
|
||||
# étape 6 : synthèse vocale (modèle dans models/piper/<voice>.onnx, « make models »)
|
||||
# Piper produit du 22 050 Hz : le serveur rééchantillonne en audio.sample_rate.
|
||||
tts:
|
||||
voice: fr_FR-siwis-medium
|
||||
length_scale: 1.0 # > 1 : débit plus lent
|
||||
|
||||
# étape 7 : détection d'activité vocale (Silero, fenêtres de 512 échantillons = 32 ms,
|
||||
# imposées à 16 kHz). Une session sans parole n'est pas transcrite.
|
||||
vad:
|
||||
threshold: 0.5 # probabilité de parole ; on en sort sous 0,35 (hystérésis)
|
||||
min_silence_ms: 500 # pause plus courte : la phrase continue
|
||||
min_speech_ms: 250 # parole plus courte : bruit (clic du bouton), ignorée
|
||||
speech_pad_ms: 150 # marge autour de chaque segment : attaques et fins de mots
|
||||
|
||||
# étape 8 : diarisation (qui parle quand), sur la seule parole trouvée par la VAD.
|
||||
# Modèle soumis à conditions : les accepter sur huggingface.co, HF_TOKEN dans .env,
|
||||
# puis « make models ». Ensuite, tout fonctionne hors ligne ; télémétrie pyannote coupée.
|
||||
diarization:
|
||||
enabled: true # false sur Raspberry Pi 4 (trop lourd) ou sans l'extra « diarization »
|
||||
model: pyannote/speaker-diarization-community-1
|
||||
max_speakers: 3 # borne haute : un assistant de salon, pas une réunion
|
||||
|
||||
# étape 9 : identification du locuteur (probabiliste, jamais une sécurité).
|
||||
# Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique).
|
||||
# Nécessite la diarisation : les empreintes viennent de son modèle (WeSpeaker ResNet34).
|
||||
speaker:
|
||||
profiles_dir: profiles
|
||||
similarity_threshold: 0.45 # mesuré : vous 0,52 à 0,83 contre votre profil, Piper -0,08 à 0,11
|
||||
|
||||
# étape 11 : domotique. Commandes publiées sur <topic_prefix>/<pièce>/<appareil>/set ;
|
||||
# la carte exécute en plus celles de sa propre pièce (LED de démonstration).
|
||||
home:
|
||||
topic_prefix: home
|
||||
rooms: # nom tel qu'on le dit → complément dans les réponses
|
||||
salon: du salon
|
||||
cuisine: de la cuisine
|
||||
chambre: de la chambre
|
||||
salle de bain: de la salle de bain
|
||||
bureau: du bureau
|
||||
garage: du garage
|
||||
entrée: de l'entrée
|
||||
boards: # pièce de chaque carte : « allume la lumière » sans pièce = ici
|
||||
esp32-01: salon
|
||||
|
||||
# étape 13 : mot de réveil, mains libres. Le serveur demande à chaque carte de lui envoyer
|
||||
# son micro en continu (voice/<carte>/audio/stream) et guette le mot. Rien n'est écrit
|
||||
# sur disque avant le mot : seules les dernières preroll_ms restent en mémoire.
|
||||
# Prononciation à l'anglaise : « Alexa », « Hé Maïcrofte », « Hey Djâr-viss ».
|
||||
wake:
|
||||
enabled: true
|
||||
words: [alexa, hey_jarvis, hey_mycroft] # models/openwakeword/<mot>_v0.1.onnx
|
||||
threshold: 0.5 # mesuré : 90 enregistrements sans le mot, score max 0,07
|
||||
# Seuil propre à un mot, si le journal montre des « mot presque reconnu » répétés.
|
||||
# Plus bas = plus sensible, mais plus de déclenchements intempestifs. Ex. : {hey_jarvis: 0.35}
|
||||
thresholds: {}
|
||||
preroll_ms: 500 # la commande suit souvent le mot sans pause
|
||||
end_silence_ms: 800 # silence qui clôt la commande
|
||||
no_speech_timeout_s: 4 # mot entendu mais rien dit ensuite : on abandonne
|
||||
max_command_s: 10
|
||||
cooldown_s: 20 # écoute suspendue pendant la réponse (jusqu'au bilan « played »)
|
||||
|
||||
# étape 14 : droits par profil. La voix n'est JAMAIS une preuve d'identité : une
|
||||
# imitation ou un enregistrement peut tromper. Niveaux : complet > standard > limite.
|
||||
access:
|
||||
unknown: limite # voix non reconnue : conversation seulement
|
||||
default: standard # profil enrôlé absent de users
|
||||
users: # noms des profils (make profiles)
|
||||
Denis: complet
|
||||
full_min_score: 0.6 # « complet » seulement si la voix est reconnue nettement
|
||||
rules: # niveau minimal par appareil et action ; absent = complet
|
||||
light: {"on": standard, "off": standard} # guillemets : on/off nus = booléens en YAML
|
||||
shutter: {open: standard, close: standard}
|
||||
door: {open: complet, close: standard}
|
||||
machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b
|
||||
confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui »
|
||||
confirm_timeout_s: 15
|
||||
|
||||
# étape 15b : machines Linux du réseau. Chacune fait tourner un agent (agent/voice_agent.py)
|
||||
# qui n'exécute QUE les actions de SA liste (/etc/voice-agent/agent.yaml) ; ici, on dit
|
||||
# seulement comment on les appelle. « allume » = Wake-on-LAN, envoyé par le serveur
|
||||
# (la machine éteinte n'a pas d'agent). Nouvelle action : l'ajouter à l'agent, ici
|
||||
# (mots et phrases), puis dans access.rules.machine (sinon : niveau complet exigé).
|
||||
machines:
|
||||
topic_prefix: agent # agent/<machine>/state (retenu), command, result
|
||||
broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local
|
||||
actions: # mots sans accents ; « does » et « verb » : phrases dites
|
||||
wake: {words: [allume, allumer, allumez, demarre, demarrer, reveille, reveiller], does: "J'allume", verb: allumer}
|
||||
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez], does: "J'éteins", verb: éteindre}
|
||||
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer}
|
||||
lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller}
|
||||
list: # identifiant de l'agent → comment on l'appelle
|
||||
pc-bureau:
|
||||
# Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ».
|
||||
names: [pc du bureau, p c du bureau, ordinateur du bureau]
|
||||
name: le PC du bureau
|
||||
mac: "" # ex. "aa:bb:cc:dd:ee:ff" (ip link) pour « allume le PC du bureau »
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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 ?")
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests du chemin de sortie, étape 5 : mise au format et envoi rythmé (sans broker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from voice_server.audio_resampler import AudioFormatError, AudioResampler, peak_dbfs
|
||||
from voice_server.audio_sender import AudioSender, send_times
|
||||
from voice_server.settings import AudioSettings
|
||||
|
||||
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
|
||||
|
||||
|
||||
def write_wav(path: Path, samples: np.ndarray, rate: int, width: int) -> None:
|
||||
"""Écrit un WAV PCM entier de largeur donnée (1 à 4 octets) à partir de flottants."""
|
||||
channels = 1 if samples.ndim == 1 else samples.shape[1]
|
||||
scale = {1: 127, 2: 32767, 3: 8388607, 4: 2147483647}[width]
|
||||
ints = np.round(samples.reshape(-1) * scale).astype(np.int64)
|
||||
if width == 1:
|
||||
raw = (ints + 128).astype(np.uint8).tobytes()
|
||||
else:
|
||||
raw = b"".join(int(v).to_bytes(width, "little", signed=True) for v in ints)
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(channels)
|
||||
wav.setsampwidth(width)
|
||||
wav.setframerate(rate)
|
||||
wav.writeframes(raw)
|
||||
|
||||
|
||||
def sine(rate: int, seconds: float, amplitude: float = 0.1) -> np.ndarray:
|
||||
t = np.arange(int(rate * seconds)) / rate
|
||||
return amplitude * np.sin(2 * np.pi * 440 * t)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1, 2, 3, 4])
|
||||
def test_any_pcm_width_is_read(tmp_path: Path, width: int) -> None:
|
||||
"""WAV 8, 16, 24 et 32 bits : lus, mis en 16 kHz, crête à -3 dBFS."""
|
||||
path = tmp_path / f"w{width}.wav"
|
||||
write_wav(path, sine(16000, 0.5), 16000, width)
|
||||
|
||||
pcm = AudioResampler(AUDIO).from_wav(path)
|
||||
|
||||
assert len(pcm) == 16000 # 0,5 s x 16 000 x 2 octets
|
||||
assert peak_dbfs(pcm) == pytest.approx(-3.0, abs=0.1)
|
||||
|
||||
|
||||
def test_cd_stereo_is_converted_to_system_format(tmp_path: Path) -> None:
|
||||
"""44,1 kHz stéréo -> 16 kHz mono ; durée conservée."""
|
||||
path = tmp_path / "cd.wav"
|
||||
stereo = np.stack([sine(44100, 1.0), sine(44100, 1.0)], axis=1)
|
||||
write_wav(path, stereo, 44100, 2)
|
||||
|
||||
pcm = AudioResampler(AUDIO).from_wav(path)
|
||||
|
||||
assert len(pcm) // 2 == pytest.approx(16000, abs=2)
|
||||
|
||||
|
||||
def test_piper_rate_is_resampled(tmp_path: Path) -> None:
|
||||
"""22 050 Hz (sortie habituelle de Piper, étape 6) -> 16 kHz, fréquence conservée."""
|
||||
pcm = AudioResampler(AUDIO).convert(sine(22050, 1.0), 22050)
|
||||
samples = np.frombuffer(pcm, dtype="<i2").astype(np.float64)
|
||||
|
||||
spectrum = np.abs(np.fft.rfft(samples))
|
||||
dominant = np.fft.rfftfreq(samples.size, 1 / 16000)[int(np.argmax(spectrum))]
|
||||
assert dominant == pytest.approx(440.0, abs=2.0)
|
||||
|
||||
|
||||
def test_normalization_can_be_disabled() -> None:
|
||||
"""Sans normalisation, le niveau d'origine est gardé (-20 dBFS ici)."""
|
||||
pcm = AudioResampler(AUDIO, target_peak_dbfs=None).convert(sine(16000, 0.5, 0.1), 16000)
|
||||
|
||||
assert peak_dbfs(pcm) == pytest.approx(-20.0, abs=0.1)
|
||||
|
||||
|
||||
def test_unreadable_file_suggests_conversion(tmp_path: Path) -> None:
|
||||
"""Un fichier qui n'est pas un WAV PCM : message avec la commande ffmpeg."""
|
||||
path = tmp_path / "faux.wav"
|
||||
path.write_bytes(b"RIFF....WAVEfmt pas vraiment")
|
||||
|
||||
with pytest.raises(AudioFormatError, match="ffmpeg"):
|
||||
AudioResampler(AUDIO).from_wav(path)
|
||||
|
||||
|
||||
class FakeLink:
|
||||
"""Lien MQTT factice : mémorise les publications et l'instant de chacune."""
|
||||
|
||||
def __init__(self, clock: "FakeTime") -> None:
|
||||
self.clock = clock
|
||||
self.sent: list[tuple[float, str, bytes, int]] = []
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
self.sent.append((self.clock.now, suffix, payload, qos))
|
||||
|
||||
|
||||
class FakeTime:
|
||||
"""Horloge et sommeil simulés : le test dure 0 s quelle que soit la durée du son."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def clock(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def test_sender_protocol_and_pacing() -> None:
|
||||
"""START (QoS 1), 10 chunks (QoS 0) dont 6 d'avance, END annonçant 10 chunks."""
|
||||
time = FakeTime()
|
||||
link = FakeLink(time)
|
||||
sender = AudioSender(link, AUDIO, prefill_chunks=6, sleep=time.sleep, clock=time.clock)
|
||||
|
||||
session = sender.send("esp32-01", bytes(1600 * 9 + 801)) # 9 chunks pleins + 1 partiel
|
||||
|
||||
kinds = [(suffix, qos) for _, suffix, _, qos in link.sent]
|
||||
assert kinds[0] == ("control", 1) and kinds[-1] == ("control", 1)
|
||||
audio = [(t, payload) for t, suffix, payload, _ in link.sent if suffix == "audio/out"]
|
||||
assert [len(p) for _, p in audio] == [1600] * 9 + [800] # l'octet orphelin est retiré
|
||||
assert [t for t, _ in audio[:6]] == [0.0] * 6 # l'avance part d'un coup
|
||||
assert [round(t, 3) for t, _ in audio[6:]] == [0.05, 0.1, 0.15, 0.2]
|
||||
|
||||
start = json.loads(link.sent[0][2])
|
||||
end = json.loads(link.sent[-1][2])
|
||||
assert (start["event"], start["rate"], start["session"]) == ("start", 16000, session)
|
||||
assert (end["event"], end["chunks"], end["session"]) == ("end", 10, session)
|
||||
|
||||
|
||||
def test_send_times_matches_serial_tool() -> None:
|
||||
"""Même calendrier que l'outil série de l'étape 2."""
|
||||
assert send_times(5, 0.05, 2) == pytest.approx([0.0, 0.0, 0.05, 0.10, 0.15])
|
||||
|
||||
|
||||
def test_jitter_bench_holds_one_chunk_in_ten() -> None:
|
||||
"""Étape 12 : le banc retient un chunk audio sur dix, jamais START/END."""
|
||||
from wav_to_mqtt import JitteryLink
|
||||
|
||||
class Sink:
|
||||
def __init__(self) -> None:
|
||||
self.topics: list[str] = []
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
self.topics.append(suffix)
|
||||
|
||||
sink = Sink()
|
||||
bench = JitteryLink(sink, stall_ms=1.0)
|
||||
bench.publish("esp32-01", "control", b"{}", 1)
|
||||
for _ in range(25):
|
||||
bench.publish("esp32-01", "audio/out", bytes(1600), 0)
|
||||
|
||||
assert bench.stalls == 2 # chunks 10 et 20
|
||||
assert len(sink.topics) == 26 # rien n'est perdu, seulement retardé
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests de la diarisation, étape 8 : présentation, télémétrie, modèle réel s'il est présent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from voice_server.diarization import Diarization, Diarizer, SpeakerTurn, disable_telemetry, timeline
|
||||
from voice_server.settings import DiarizationSettings, load_settings
|
||||
|
||||
SETTINGS = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
|
||||
|
||||
|
||||
def two_speakers() -> Diarization:
|
||||
return Diarization(
|
||||
turns=(SpeakerTurn("SPEAKER_00", 0.0, 0.5), SpeakerTurn("SPEAKER_01", 0.6, 1.0)),
|
||||
speakers=("SPEAKER_00", "SPEAKER_01"),
|
||||
embeddings=None, audio_s=1.0, elapsed_s=0.2,
|
||||
)
|
||||
|
||||
|
||||
def test_describe() -> None:
|
||||
assert two_speakers().describe() == "SPEAKER_00 0.00-0.50, SPEAKER_01 0.60-1.00"
|
||||
|
||||
|
||||
def test_timeline_one_row_per_speaker() -> None:
|
||||
assert timeline(two_speakers()) == ["SPEAKER_00 #####_____", "SPEAKER_01 ______####"]
|
||||
|
||||
|
||||
def test_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""pyannote 4 envoie des statistiques par défaut : jamais dans ce projet."""
|
||||
monkeypatch.setenv("PYANNOTE_METRICS_ENABLED", "true")
|
||||
disable_telemetry()
|
||||
assert os.environ["PYANNOTE_METRICS_ENABLED"] == "false"
|
||||
|
||||
|
||||
def test_missing_model_says_what_to_do(tmp_path: Path) -> None:
|
||||
settings = DiarizationSettings(enabled=True, model="x/y", max_speakers=3, model_dir=tmp_path / "y")
|
||||
with pytest.raises(FileNotFoundError, match="make models"):
|
||||
Diarizer(settings, 16000)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (SETTINGS.diarization.model_dir / "config.yaml").exists(),
|
||||
reason="modele de diarisation absent : HF_TOKEN puis 'make models'")
|
||||
def test_real_pipeline_runs_offline_without_telemetry() -> None:
|
||||
"""Le vrai pipeline se charge du dossier local et ne trouve personne dans le silence."""
|
||||
pytest.importorskip("pyannote.audio")
|
||||
diarizer = Diarizer(SETTINGS.diarization, 16000)
|
||||
|
||||
result = diarizer.diarize(np.zeros(32000, dtype="<i2").tobytes())
|
||||
|
||||
assert result.turns == ()
|
||||
assert os.environ["PYANNOTE_METRICS_ENABLED"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (SETTINGS.diarization.model_dir / "config.yaml").exists(),
|
||||
reason="modele de diarisation absent : HF_TOKEN puis 'make models'")
|
||||
def test_embeddings_exist_for_a_short_question() -> None:
|
||||
"""Une question de 2 s a une empreinte (celles de pyannote seraient NaN sous 2 s de parole)."""
|
||||
pytest.importorskip("pyannote.audio")
|
||||
from voice_server.audio_resampler import AudioResampler
|
||||
from voice_server.text_to_speech import TextToSpeech
|
||||
|
||||
tts = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio, -10.0), 16000)
|
||||
diarizer = Diarizer(SETTINGS.diarization, 16000)
|
||||
first = diarizer.diarize(tts.synthesize("Quelle heure est-il ?").pcm)
|
||||
second = diarizer.embed(tts.synthesize("Fermez la porte du garage.").pcm)
|
||||
|
||||
assert first.embeddings is not None and first.embeddings.shape == (1, 256)
|
||||
assert np.all(np.isfinite(first.embeddings))
|
||||
cosine = float(first.embeddings[0] @ second / np.linalg.norm(first.embeddings[0]) / np.linalg.norm(second))
|
||||
assert cosine > 0.45 # même voix Piper, deux phrases
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Tests de l'écoute mains libres, étape 13 : états, vie privée, fin de commande (sans modèle)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from voice_server.hands_free import HandsFreeListener, Mode
|
||||
from voice_server.session_manager import SessionResult
|
||||
from voice_server.settings import AudioSettings, WakeSettings
|
||||
from voice_server.voice_activity import Segment, VadResult
|
||||
from voice_server.wake_word import Detection
|
||||
|
||||
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
|
||||
WAKE = WakeSettings(enabled=True, words=("alexa",), threshold=0.5, thresholds={}, preroll_ms=500, end_silence_ms=800,
|
||||
no_speech_timeout_s=4.0, max_command_s=10.0, cooldown_s=20.0, model_dir=Path("inutile"))
|
||||
SILENCE = bytes(1600)
|
||||
SPEECH = np.full(800, 3000, dtype="<i2").tobytes()
|
||||
WAKE_WORD = np.full(800, 3001, dtype="<i2").tobytes() # le faux détecteur réagit à cette valeur
|
||||
|
||||
|
||||
class FakeDetector:
|
||||
def __init__(self) -> None:
|
||||
self.resets = 0
|
||||
|
||||
def process(self, pcm: bytes) -> Detection | None:
|
||||
return Detection("alexa", 0.9) if pcm == WAKE_WORD else None
|
||||
|
||||
def reset(self) -> None:
|
||||
self.resets += 1
|
||||
|
||||
|
||||
class ChunkVad:
|
||||
"""VAD de test : un chunk non nul est de la parole."""
|
||||
|
||||
def detect(self, pcm: bytes) -> VadResult:
|
||||
samples = np.frombuffer(pcm, dtype="<i2")
|
||||
segments: list[Segment] = []
|
||||
for start in range(0, samples.size, 800):
|
||||
if np.any(samples[start:start + 800]):
|
||||
if segments and segments[-1].end == start:
|
||||
segments[-1] = Segment(segments[-1].start, start + 800)
|
||||
else:
|
||||
segments.append(Segment(start, start + 800))
|
||||
return VadResult(segments=tuple(segments), speech_pcm=b"", probabilities=np.zeros(0),
|
||||
total_s=samples.size / 16000, speech_s=0.0, elapsed_s=0.0, sample_rate=16000)
|
||||
|
||||
|
||||
class FakeLink:
|
||||
def __init__(self) -> None:
|
||||
self.controls: list[dict[str, object]] = []
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
assert suffix == "control"
|
||||
self.controls.append(json.loads(payload))
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 100.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> Clock:
|
||||
return Clock()
|
||||
|
||||
|
||||
class Harness:
|
||||
def __init__(self, tmp_path: Path, clock: Clock) -> None:
|
||||
self.link = FakeLink()
|
||||
self.commands: list[SessionResult] = []
|
||||
self.detectors: list[FakeDetector] = []
|
||||
self.clock = clock
|
||||
self.recordings = tmp_path / "recordings"
|
||||
|
||||
def factory(device: str) -> FakeDetector:
|
||||
self.detectors.append(FakeDetector())
|
||||
return self.detectors[-1]
|
||||
|
||||
self.listener = HandsFreeListener(WAKE, AUDIO, ChunkVad(), self.recordings, self.link,
|
||||
self.commands.append, factory, clock=clock)
|
||||
|
||||
def feed(self, chunk: bytes, count: int = 1) -> None:
|
||||
for _ in range(count):
|
||||
self.listener.on_stream("esp32-01", chunk)
|
||||
self.clock.now += 0.05 # un chunk = 50 ms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def h(tmp_path: Path, clock: Clock) -> Harness:
|
||||
return Harness(tmp_path, clock)
|
||||
|
||||
|
||||
def test_standby_never_writes_anything(h: Harness) -> None:
|
||||
"""Vie privée : sans mot de réveil, rien sur disque, rien transmis, mémoire bornée à 500 ms."""
|
||||
h.feed(SPEECH, 200) # 10 s de conversation sans le mot
|
||||
|
||||
assert h.commands == []
|
||||
assert not h.recordings.exists()
|
||||
assert h.listener.mode("esp32-01") is Mode.STANDBY
|
||||
|
||||
|
||||
def test_wake_then_command_becomes_a_session(h: Harness) -> None:
|
||||
"""Mot, commande, silence : une session, avec les 500 ms qui précèdent le mot."""
|
||||
h.feed(SILENCE, 20)
|
||||
h.feed(WAKE_WORD)
|
||||
h.feed(SPEECH, 30) # 1,5 s de commande
|
||||
h.feed(SILENCE, 25) # 1,25 s de silence : > 800 ms
|
||||
|
||||
[result] = h.commands
|
||||
assert result.session_id.startswith("w")
|
||||
assert result.path.exists()
|
||||
assert 2.0 < result.duration_s < 3.5
|
||||
assert h.link.controls == [{"cmd": "capture", "active": True}, {"cmd": "capture", "active": False}]
|
||||
assert h.listener.mode("esp32-01") is Mode.ANSWER
|
||||
|
||||
|
||||
def test_wake_without_command_goes_back_to_standby(h: Harness) -> None:
|
||||
"""Mot seul : abandon après 4 s, rien écrit."""
|
||||
h.feed(WAKE_WORD)
|
||||
h.feed(SILENCE, 90) # 4,5 s
|
||||
|
||||
assert h.commands == [] and not h.recordings.exists()
|
||||
assert h.listener.mode("esp32-01") is Mode.STANDBY
|
||||
assert h.link.controls[-1] == {"cmd": "capture", "active": False}
|
||||
|
||||
|
||||
def test_command_is_cut_at_max_duration(h: Harness) -> None:
|
||||
h.feed(WAKE_WORD)
|
||||
h.feed(SPEECH, 220) # 11 s sans silence
|
||||
|
||||
assert len(h.commands) == 1
|
||||
|
||||
|
||||
def test_answer_is_not_heard_and_played_resumes(h: Harness) -> None:
|
||||
"""Pendant la réponse, le micro est ignoré ; le bilan « played » relance la veille."""
|
||||
h.feed(WAKE_WORD)
|
||||
h.feed(SPEECH, 10)
|
||||
h.feed(SILENCE, 20)
|
||||
h.feed(WAKE_WORD, 5) # la carte « s'entend » : ignoré
|
||||
assert len(h.commands) == 1
|
||||
|
||||
h.listener.on_event("esp32-01", b'{"event":"played","session":"rep-w1"}')
|
||||
|
||||
assert h.listener.mode("esp32-01") is Mode.STANDBY
|
||||
assert h.detectors[0].resets == 1 # historique du mot précédent effacé
|
||||
|
||||
|
||||
def test_no_played_report_times_out(h: Harness) -> None:
|
||||
h.feed(WAKE_WORD)
|
||||
h.feed(SPEECH, 10)
|
||||
h.feed(SILENCE, 20)
|
||||
h.clock.now += 21
|
||||
|
||||
h.listener.sweep()
|
||||
|
||||
assert h.listener.mode("esp32-01") is Mode.STANDBY
|
||||
|
||||
|
||||
def test_listen_is_sent_once_per_boot(h: Harness) -> None:
|
||||
"""Écoute activée à la connexion et après un redémarrage, pas à chaque état périodique."""
|
||||
state = '{{"status":"online","uptime_s":{}}}'
|
||||
h.listener.on_state("esp32-01", state.format(5).encode())
|
||||
h.listener.on_state("esp32-01", state.format(35).encode()) # état périodique
|
||||
h.listener.on_state("esp32-01", state.format(3).encode()) # redémarrage
|
||||
|
||||
assert h.link.controls == [{"cmd": "listen", "enabled": True}] * 2
|
||||
|
||||
h.listener.stop()
|
||||
assert h.link.controls[-1] == {"cmd": "listen", "enabled": False}
|
||||
|
||||
|
||||
def test_each_board_has_its_own_detector(h: Harness) -> None:
|
||||
"""Deux cartes : deux historiques, jamais mélangés."""
|
||||
h.listener.on_stream("esp32-01", SILENCE)
|
||||
h.listener.on_stream("esp32-02", SILENCE)
|
||||
assert len(h.detectors) == 2
|
||||
|
||||
|
||||
def test_burst_delivery_is_judged_on_audio_time(h: Harness) -> None:
|
||||
"""Wi-Fi en rafale : 2,5 s de son arrivées en même temps donnent la même décision."""
|
||||
h.listener.on_stream("esp32-01", WAKE_WORD)
|
||||
for chunk in [SPEECH] * 30 + [SILENCE] * 25: # aucun temps ne s'écoule à l'horloge
|
||||
h.listener.on_stream("esp32-01", chunk)
|
||||
|
||||
assert len(h.commands) == 1
|
||||
|
||||
|
||||
def test_stream_lost_during_command_is_closed(h: Harness) -> None:
|
||||
"""La carte décroche en pleine commande : on traite ce qu'on a après 2 s sans chunk."""
|
||||
h.feed(WAKE_WORD)
|
||||
h.feed(SPEECH, 10)
|
||||
h.clock.now += 3
|
||||
|
||||
h.listener.sweep()
|
||||
|
||||
assert len(h.commands) == 1
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests du décodeur de trames série utilisé à l'étape 1.
|
||||
|
||||
Ces tests ne nécessitent aucun matériel : un faux port série rejoue des octets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
from serial_protocol import ( # noqa: E402
|
||||
AudioFormat,
|
||||
Frame,
|
||||
FrameReader,
|
||||
FrameType,
|
||||
encode_frame,
|
||||
)
|
||||
from serial_to_wav import measure_levels, suggested_gain_factor # noqa: E402
|
||||
from wav_inspect import column_peaks, render, speech_ratio # noqa: E402
|
||||
|
||||
|
||||
class FakeSerial:
|
||||
"""Faux port série : restitue une suite d'octets par petits morceaux."""
|
||||
|
||||
def __init__(self, data: bytes, block_size: int = 7) -> None:
|
||||
self._data = data
|
||||
self._block_size = block_size
|
||||
self._position = 0
|
||||
|
||||
def read(self, size: int = 1) -> bytes:
|
||||
"""Retourne au plus block_size octets, puis des chaînes vides."""
|
||||
count = min(size, self._block_size, len(self._data) - self._position)
|
||||
if count <= 0:
|
||||
return b""
|
||||
chunk = self._data[self._position : self._position + count]
|
||||
self._position += count
|
||||
return chunk
|
||||
|
||||
|
||||
def build_frame(frame_type: FrameType, payload: bytes) -> bytes:
|
||||
"""Construit une trame avec l'encodeur partagé, celui qu'utilise wav_to_serial."""
|
||||
return encode_frame(frame_type, payload)
|
||||
|
||||
|
||||
def descriptor(sample_rate: int = 16000, bits: int = 16, channels: int = 1) -> bytes:
|
||||
"""Construit le descripteur de 8 octets de la trame START."""
|
||||
return (
|
||||
sample_rate.to_bytes(4, "little")
|
||||
+ bits.to_bytes(2, "little")
|
||||
+ channels.to_bytes(2, "little")
|
||||
)
|
||||
|
||||
|
||||
def collect(data: bytes, expected: int) -> list[Frame]:
|
||||
"""Décode le flux et retourne les `expected` premières trames."""
|
||||
reader = FrameReader(FakeSerial(data))
|
||||
frames: list[Frame] = []
|
||||
for frame in reader.frames():
|
||||
frames.append(frame)
|
||||
if len(frames) == expected:
|
||||
break
|
||||
return frames
|
||||
|
||||
|
||||
def test_decodes_a_full_session() -> None:
|
||||
"""START + deux chunks + END sont décodés dans l'ordre."""
|
||||
pcm = bytes(range(0, 200)) * 8 # 1600 octets
|
||||
stream = (
|
||||
build_frame(FrameType.START, descriptor())
|
||||
+ build_frame(FrameType.AUDIO, pcm)
|
||||
+ build_frame(FrameType.AUDIO, pcm)
|
||||
+ build_frame(FrameType.END, b"")
|
||||
)
|
||||
|
||||
frames = collect(stream, 4)
|
||||
|
||||
assert [f.type for f in frames] == [
|
||||
FrameType.START,
|
||||
FrameType.AUDIO,
|
||||
FrameType.AUDIO,
|
||||
FrameType.END,
|
||||
]
|
||||
assert frames[1].payload == pcm
|
||||
assert AudioFormat.from_payload(frames[0].payload) == AudioFormat(16000, 16, 1)
|
||||
|
||||
|
||||
def test_skips_interleaved_text() -> None:
|
||||
"""Les messages texte de l'ESP32 n'empêchent pas le décodage."""
|
||||
stream = (
|
||||
b"# micro pret\n"
|
||||
+ build_frame(FrameType.START, descriptor())
|
||||
+ b"\n# session terminee\n"
|
||||
+ build_frame(FrameType.END, b"")
|
||||
)
|
||||
|
||||
frames = collect(stream, 2)
|
||||
|
||||
assert [f.type for f in frames] == [FrameType.START, FrameType.END]
|
||||
|
||||
|
||||
def test_rejects_bad_checksum() -> None:
|
||||
"""Une trame corrompue est ignorée, la suivante est retrouvée."""
|
||||
broken = bytearray(build_frame(FrameType.AUDIO, b"\x01\x02\x03\x04"))
|
||||
broken[-1] ^= 0xFF # checksum faux
|
||||
stream = bytes(broken) + build_frame(FrameType.END, b"")
|
||||
|
||||
frames = collect(stream, 1)
|
||||
|
||||
assert frames[0].type is FrameType.END
|
||||
|
||||
|
||||
def test_measure_levels_on_silence() -> None:
|
||||
"""Un flux nul est signalé comme absence totale de signal."""
|
||||
level = measure_levels(b"\x00\x00" * 1000)
|
||||
|
||||
assert level.peak == 0.0
|
||||
assert level.rms == 0.0
|
||||
|
||||
|
||||
def test_measure_levels_on_known_amplitude() -> None:
|
||||
"""Un carré à la moitié de l'échelle donne crête et RMS à 50 %."""
|
||||
pcm = (struct.pack("<h", 16384) + struct.pack("<h", -16384)) * 500
|
||||
|
||||
level = measure_levels(pcm)
|
||||
|
||||
assert abs(level.peak - 0.5) < 0.001
|
||||
assert abs(level.rms - 0.5) < 0.001
|
||||
assert abs(level.dc) < 0.001
|
||||
|
||||
|
||||
def test_suggested_gain_factor() -> None:
|
||||
"""Le facteur conseillé est une puissance de 2 qui vise 25 % de crête."""
|
||||
assert suggested_gain_factor(0.015) == 16
|
||||
assert suggested_gain_factor(0.12) == 2
|
||||
assert suggested_gain_factor(0.0) == 0
|
||||
|
||||
|
||||
def test_envelope_follows_amplitude() -> None:
|
||||
"""L'enveloppe dessine un creux entre deux bouffées de signal."""
|
||||
loud = struct.pack("<h", 16000) * 1000
|
||||
quiet = struct.pack("<h", 0) * 1000
|
||||
peaks = column_peaks(loud + quiet + loud, columns=3)
|
||||
|
||||
assert peaks[0] > 0.4 and peaks[2] > 0.4
|
||||
assert peaks[1] == 0.0
|
||||
|
||||
drawing = render(peaks)
|
||||
assert drawing[0] == drawing[2]
|
||||
assert drawing[1] != drawing[0]
|
||||
|
||||
|
||||
def test_speech_ratio_detects_alternation() -> None:
|
||||
"""Un signal constant n'est pas de la parole, une alternance oui."""
|
||||
steady = struct.pack("<h", 8000) * 3000
|
||||
assert speech_ratio(column_peaks(steady, columns=6)) == 0.0
|
||||
|
||||
burst = struct.pack("<h", 0) * 2000 + struct.pack("<h", 8000) * 1000
|
||||
assert speech_ratio(column_peaks(burst, columns=6)) > 0.0
|
||||
|
||||
|
||||
def test_audio_format_round_trip() -> None:
|
||||
"""Le descripteur START s'encode et se décode sans perte."""
|
||||
fmt = AudioFormat(16000, 16, 1)
|
||||
assert AudioFormat.from_payload(fmt.to_payload()) == fmt
|
||||
|
||||
|
||||
def test_encode_frame_rejects_oversized_payload() -> None:
|
||||
"""Un payload plus grand que ce que l'ESP32 accepte est refusé dès l'envoi."""
|
||||
try:
|
||||
encode_frame(FrameType.AUDIO, bytes(4096))
|
||||
except ValueError:
|
||||
return
|
||||
raise AssertionError("un payload de 4096 octets aurait du etre refuse")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests des vrais modèles, étape 6 : ignorés tant que « make models » n'a pas tourné.
|
||||
|
||||
Piper dit une phrase, Whisper doit la relire : les deux modèles se vérifient
|
||||
l'un l'autre, sans micro ni haut-parleur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from voice_server.audio_resampler import AudioResampler, peak_dbfs
|
||||
from voice_server.settings import Settings, load_settings
|
||||
from voice_server.speech_to_text import SpeechToText
|
||||
from voice_server.text_to_speech import TextToSpeech
|
||||
from voice_server.voice_activity import VoiceActivityDetector
|
||||
|
||||
SETTINGS: Settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (SETTINGS.stt.model_dir / "model.bin").exists() or not SETTINGS.tts.model_path.exists(),
|
||||
reason="modeles absents : lancez 'make models'",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tts() -> TextToSpeech:
|
||||
return TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio), SETTINGS.audio.sample_rate)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stt() -> SpeechToText:
|
||||
return SpeechToText(SETTINGS.stt, SETTINGS.audio.sample_rate)
|
||||
|
||||
|
||||
def test_piper_output_is_system_format(tts: TextToSpeech) -> None:
|
||||
"""Piper produit du 22 050 Hz : la sortie doit être en 16 kHz, crête -3 dBFS."""
|
||||
speech = tts.synthesize("Bonjour.")
|
||||
|
||||
assert len(speech.pcm) % 2 == 0
|
||||
assert 0.3 < speech.audio_s < 3.0
|
||||
assert peak_dbfs(speech.pcm) == pytest.approx(-3.0, abs=0.1)
|
||||
|
||||
|
||||
def test_round_trip(tts: TextToSpeech, stt: SpeechToText) -> None:
|
||||
"""Ce que Piper dit, Whisper le relit."""
|
||||
transcript = stt.transcribe(tts.synthesize("Bonjour, comment tu t'appelles ?").pcm)
|
||||
|
||||
assert "appelles" in transcript.text.lower()
|
||||
|
||||
|
||||
def test_noise_hallucination_is_dropped(stt: SpeechToText) -> None:
|
||||
"""Sur du bruit, Whisper invente parfois un sous-titre : il ne doit pas passer."""
|
||||
noise = np.random.default_rng(0).normal(0, 300, 32000).astype("<i2").tobytes()
|
||||
|
||||
assert "amara" not in stt.transcribe(noise).text.lower()
|
||||
|
||||
|
||||
def test_vad_finds_speech_between_silences(tts: TextToSpeech) -> None:
|
||||
"""1 s de silence, une phrase, 1 s de silence : la VAD retrouve la phrase et retire le reste."""
|
||||
speech = tts.synthesize("Quelle heure est-il ?")
|
||||
silence = bytes(32000)
|
||||
detector = VoiceActivityDetector(SETTINGS.vad, SETTINGS.audio.sample_rate)
|
||||
|
||||
result = detector.detect(silence + speech.pcm + silence)
|
||||
|
||||
assert result.has_speech
|
||||
start = result.segments[0].start / 16000
|
||||
end = result.segments[-1].end / 16000
|
||||
assert 0.7 < start < 1.2 # début de parole, marge comprise
|
||||
assert 1.0 + speech.audio_s - 0.4 < end < 1.0 + speech.audio_s + 0.3
|
||||
assert result.speech_s < speech.audio_s + 0.5 # les 2 s de silence sont parties
|
||||
|
||||
|
||||
def test_words_have_increasing_times(tts: TextToSpeech, stt: SpeechToText) -> None:
|
||||
"""Étape 10 : chaque mot a un instant, dans l'ordre, et les mots recomposent le texte."""
|
||||
transcript = stt.transcribe(tts.synthesize("Ferme les volets de la cuisine.").pcm)
|
||||
|
||||
assert len(transcript.words) >= 5
|
||||
starts = [word.start_s for word in transcript.words]
|
||||
assert starts == sorted(starts)
|
||||
assert "".join(word.text for word in transcript.words).strip() == transcript.text
|
||||
|
||||
|
||||
WAKE_READY = all((SETTINGS.wake.model_dir / name).exists()
|
||||
for name in ("melspectrogram.onnx", "embedding_model.onnx", "alexa_v0.1.onnx"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not WAKE_READY, reason="modeles openWakeWord absents : 'make models'")
|
||||
def test_alexa_is_detected_and_silence_is_not(tts: TextToSpeech) -> None:
|
||||
"""Étape 13 : « Alexa » dit par Piper déclenche ; le silence et une phrase ordinaire non."""
|
||||
pytest.importorskip("openwakeword")
|
||||
from voice_server.wake_word import WakeWordDetector
|
||||
|
||||
def first_detection(pcm: bytes) -> str | None:
|
||||
detector = WakeWordDetector(SETTINGS.wake)
|
||||
padded = bytes(32000) + pcm + bytes(32000)
|
||||
for start in range(0, len(padded) - 1599, 1600):
|
||||
detection = detector.process(padded[start:start + 1600])
|
||||
if detection is not None:
|
||||
return detection.word
|
||||
return None
|
||||
|
||||
loud = AudioResampler(SETTINGS.audio, -10.0)
|
||||
speaker = TextToSpeech(SETTINGS.tts, loud, SETTINGS.audio.sample_rate)
|
||||
assert first_detection(speaker.synthesize("Alexa, allume la lumière.").pcm) == "alexa"
|
||||
assert first_detection(speaker.synthesize("Bonjour, quelle heure est-il ?").pcm) is None
|
||||
assert first_detection(bytes(64000)) is None
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skipif(not WAKE_READY, reason="modeles openWakeWord absents : 'make models'")
|
||||
def test_per_word_threshold_and_near_miss(caplog: pytest.LogCaptureFixture, tts: TextToSpeech) -> None:
|
||||
"""Un seuil propre au mot ; sous le seuil, le pic est journalisé comme « presque reconnu »."""
|
||||
pytest.importorskip("openwakeword")
|
||||
import dataclasses
|
||||
import logging
|
||||
|
||||
from voice_server.wake_word import WakeWordDetector
|
||||
|
||||
strict = dataclasses.replace(SETTINGS.wake, thresholds={"alexa": 1.01}) # inatteignable
|
||||
detector = WakeWordDetector(strict, "esp32-test")
|
||||
speaker = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio, -10.0), SETTINGS.audio.sample_rate)
|
||||
padded = bytes(32000) + speaker.synthesize("Alexa, allume la lumière.").pcm + bytes(64000)
|
||||
with caplog.at_level(logging.INFO, logger="voice_server.wake_word"):
|
||||
found = [detector.process(padded[i:i + 1600]) for i in range(0, len(padded) - 1599, 1600)]
|
||||
|
||||
assert all(detection is None for detection in found)
|
||||
assert "presque reconnu « alexa »" in caplog.text
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests de la VAD, étape 7 : segmentation (sans modèle) puis Silero sur des signaux connus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from voice_server.settings import VadSettings
|
||||
from voice_server.voice_activity import (
|
||||
WINDOW_SAMPLES,
|
||||
Segment,
|
||||
VoiceActivityDetector,
|
||||
envelope,
|
||||
find_segments,
|
||||
pad_segments,
|
||||
)
|
||||
|
||||
VAD = VadSettings(threshold=0.5, min_silence_ms=500, min_speech_ms=250, speech_pad_ms=150)
|
||||
|
||||
|
||||
def probs(pattern: str) -> list[float]:
|
||||
"""« # » = 0,9 (parole), « . » = 0,4 (entre les deux seuils), « _ » = 0,05 (silence)."""
|
||||
return [{"#": 0.9, ".": 0.4, "_": 0.05}[c] for c in pattern]
|
||||
|
||||
|
||||
def test_single_phrase() -> None:
|
||||
"""Silence, parole, silence : un segment aux bonnes bornes."""
|
||||
assert find_segments(probs("____######______"), 0.5, 3, 2) == [Segment(4, 10)]
|
||||
|
||||
|
||||
def test_short_pause_does_not_split() -> None:
|
||||
"""Une pause plus courte que min_silence ne coupe pas la phrase."""
|
||||
assert find_segments(probs("__###__###____"), 0.5, 3, 2) == [Segment(2, 10)]
|
||||
|
||||
|
||||
def test_long_pause_splits() -> None:
|
||||
"""Une pause plus longue sépare deux segments."""
|
||||
assert find_segments(probs("__###_____###____"), 0.5, 3, 2) == [Segment(2, 5), Segment(10, 13)]
|
||||
|
||||
|
||||
def test_hysteresis_keeps_speech_between_thresholds() -> None:
|
||||
"""Entre 0,35 et 0,5, on reste en parole : pas de battement autour du seuil."""
|
||||
assert find_segments(probs("_##......##____"), 0.5, 3, 2) == [Segment(1, 11)]
|
||||
|
||||
|
||||
def test_short_noise_is_dropped() -> None:
|
||||
"""Un clic d'une fenêtre n'est pas de la parole."""
|
||||
assert find_segments(probs("___#_______"), 0.5, 3, 2) == []
|
||||
|
||||
|
||||
def test_speech_until_the_end() -> None:
|
||||
"""Bouton relâché en pleine phrase : le segment va jusqu'au bout."""
|
||||
assert find_segments(probs("___#####"), 0.5, 3, 2) == [Segment(3, 8)]
|
||||
|
||||
|
||||
def test_padding_merges_close_segments() -> None:
|
||||
"""La marge élargit les segments, bornée par l'audio ; les chevauchements fusionnent."""
|
||||
segments = [Segment(100, 200), Segment(260, 300), Segment(900, 990)]
|
||||
assert pad_segments(segments, 50, 1000) == [Segment(50, 350), Segment(850, 1000)]
|
||||
|
||||
|
||||
def test_envelope() -> None:
|
||||
assert envelope(np.array([0.9, 0.4, 0.05]), 0.5) == "#._"
|
||||
|
||||
|
||||
def fake_model(pattern: str) -> Callable[[np.ndarray], np.ndarray]:
|
||||
"""Modèle qui rend les probabilités d'un motif, quelle que soit l'entrée."""
|
||||
def model(audio: np.ndarray) -> np.ndarray:
|
||||
assert audio.size % WINDOW_SAMPLES == 0
|
||||
return np.array(probs(pattern), dtype=np.float32)
|
||||
return model
|
||||
|
||||
|
||||
def test_detector_keeps_only_speech() -> None:
|
||||
"""Le PCM rendu est la parole, marges comprises ; le reste est retiré."""
|
||||
pattern = "_" * 20 + "#" * 20 + "_" * 20 # 60 fenêtres = 1,92 s
|
||||
pcm = np.arange(60 * WINDOW_SAMPLES, dtype="<i2").tobytes()
|
||||
detector = VoiceActivityDetector(VAD, 16000, model=fake_model(pattern))
|
||||
|
||||
result = detector.detect(pcm)
|
||||
|
||||
pad = 150 * 16
|
||||
assert result.segments == (Segment(20 * WINDOW_SAMPLES - pad, 40 * WINDOW_SAMPLES + pad),)
|
||||
assert result.speech_s == pytest.approx((20 * WINDOW_SAMPLES + 2 * pad) / 16000)
|
||||
first = np.frombuffer(result.speech_pcm[:2], dtype="<i2")[0]
|
||||
assert first == 20 * WINDOW_SAMPLES - pad # l'échantillon d'origine, pas un décalage
|
||||
|
||||
|
||||
def test_detector_without_speech() -> None:
|
||||
detector = VoiceActivityDetector(VAD, 16000, model=fake_model("_" * 10))
|
||||
|
||||
result = detector.detect(bytes(10 * WINDOW_SAMPLES * 2))
|
||||
|
||||
assert not result.has_speech and result.speech_pcm == b""
|
||||
|
||||
|
||||
def test_wrong_rate_is_refused() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
VoiceActivityDetector(VAD, 22050, model=fake_model(""))
|
||||
|
||||
|
||||
def test_silero_ignores_silence_and_noise() -> None:
|
||||
"""Le vrai Silero : ni le silence ni un bruit blanc ne sont de la parole."""
|
||||
pytest.importorskip("faster_whisper")
|
||||
detector = VoiceActivityDetector(VAD, 16000)
|
||||
noise = np.random.default_rng(0).normal(0, 300, 32000).astype("<i2").tobytes()
|
||||
|
||||
assert not detector.detect(bytes(32000)).has_speech
|
||||
assert not detector.detect(noise).has_speech
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests de l'agent Linux, étape 15b : configuration, liste blanche, exécution (sans broker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
AGENT_DIR = Path(__file__).resolve().parents[2] / "agent"
|
||||
if str(AGENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(AGENT_DIR))
|
||||
|
||||
from voice_agent import Action, AgentConfig, ConfigError, load_config, run_action, state_payload # noqa: E402
|
||||
|
||||
|
||||
def config(**actions: Action) -> AgentConfig:
|
||||
return AgentConfig(agent_id="pc-test", host="127.0.0.1", port=1883, username=None, password=None,
|
||||
topic_prefix="agent", dry_run=False, actions=dict(actions))
|
||||
|
||||
|
||||
def test_example_config_is_valid() -> None:
|
||||
loaded = load_config(AGENT_DIR / "agent.yaml.example")
|
||||
assert loaded.agent_id == "pc-bureau" and not loaded.dry_run
|
||||
assert loaded.actions["shutdown"].command == ("systemctl", "poweroff")
|
||||
assert loaded.topic("command") == "agent/pc-bureau/command"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "message"),
|
||||
[
|
||||
("id: PC Bureau\nmqtt: {host: x}\n", "id « PC Bureau » invalide"),
|
||||
("id: pc\nmqtt: {}\n", "mqtt.host manquant"),
|
||||
# Une chaîne passerait par un shell : « poweroff; rm -rf / » serait exécutable.
|
||||
("id: pc\nmqtt: {host: x}\nactions: {off: {command: 'systemctl poweroff'}}\n", "doit être une liste"),
|
||||
("id: pc\nmqtt: {host: x}\nactions: {off: {command: [x], timeout_s: 0}}\n", "timeout_s"),
|
||||
],
|
||||
)
|
||||
def test_invalid_config_is_explained(tmp_path: Path, text: str, message: str) -> None:
|
||||
path = tmp_path / "agent.yaml"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match=message):
|
||||
load_config(path)
|
||||
|
||||
|
||||
def test_only_listed_actions_run() -> None:
|
||||
result = run_action(config(ok=Action(("true",), 5)), "rm", "r1")
|
||||
assert not result.ok and result.output == "action inconnue : rm"
|
||||
|
||||
|
||||
def test_success_failure_and_output() -> None:
|
||||
agent = config(ok=Action(("echo", "bonjour"), 5), ko=Action(("false",), 5))
|
||||
ok = run_action(agent, "ok", "r1")
|
||||
assert ok.ok and ok.code == 0 and ok.output == "bonjour"
|
||||
ko = run_action(agent, "ko", "r2")
|
||||
assert not ko.ok and ko.code == 1
|
||||
|
||||
|
||||
def test_missing_program_and_timeout() -> None:
|
||||
agent = config(absent=Action(("/nonexistent/prog",), 5), slow=Action(("sleep", "5"), 0.2))
|
||||
assert run_action(agent, "absent", "r").output == "programme introuvable : /nonexistent/prog"
|
||||
slow = run_action(agent, "slow", "r")
|
||||
assert not slow.ok and slow.output == "arretee apres 0.2 s"
|
||||
|
||||
|
||||
def test_dry_run_executes_nothing() -> None:
|
||||
calls: list[object] = []
|
||||
agent = AgentConfig(**{**config(off=Action(("systemctl", "poweroff"), 5)).__dict__, "dry_run": True})
|
||||
result = run_action(agent, "off", "r", runner=lambda *args, **kwargs: calls.append(args)) # type: ignore[arg-type]
|
||||
assert result.ok and result.output == "essai : systemctl poweroff" and calls == []
|
||||
|
||||
|
||||
def test_state_lists_actions_for_the_server() -> None:
|
||||
online = json.loads(state_payload(config(b=Action(("x",), 1), a=Action(("y",), 1)), True))
|
||||
assert online == {"status": "online", "actions": ["a", "b"], "dry_run": False}
|
||||
assert json.loads(state_payload(config(), False)) == {"status": "offline"}
|
||||
|
||||
|
||||
def test_result_payload() -> None:
|
||||
result = run_action(config(ok=Action(("true",), 5)), "ok", "w42")
|
||||
assert json.loads(result.payload()) == {"action": "ok", "id": "w42", "ok": True, "code": 0, "output": ""}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Tests de la boucle complète, étapes 6-7 : modèles et carte remplacés par des doublures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from voice_server.access_control import AccessPolicy
|
||||
from voice_server.assistant import NOTHING_HEARD, Reply
|
||||
from voice_server.audio_buffer import Levels
|
||||
from voice_server.diarization import Diarization, SpeakerTurn
|
||||
from voice_server.home_control import DeviceCommand
|
||||
from voice_server.machine_control import Command
|
||||
from voice_server.session_manager import Outcome, SessionResult
|
||||
from voice_server.settings import AccessSettings, HomeSettings
|
||||
from voice_server.speaker_identifier import Identification
|
||||
from voice_server.speech_to_text import Transcript, Word
|
||||
from voice_server.text_to_speech import Speech
|
||||
from voice_server.voice_activity import Segment, VadResult
|
||||
from voice_server.voice_pipeline import VoicePipeline
|
||||
|
||||
PCM = bytes(32000) # 1 s de silence numérique : le contenu n'importe pas ici
|
||||
|
||||
|
||||
class FakeVad:
|
||||
"""Trouve la parole entre 0,25 et 0,75 s, ou rien si speech=False."""
|
||||
|
||||
def __init__(self, speech: bool = True) -> None:
|
||||
self.speech = speech
|
||||
|
||||
def detect(self, pcm: bytes) -> VadResult:
|
||||
segments = (Segment(4000, 12000),) if self.speech else ()
|
||||
kept = pcm[8000:24000] if self.speech else b""
|
||||
return VadResult(segments=segments, speech_pcm=kept, probabilities=np.zeros(0),
|
||||
total_s=len(pcm) / 32000, speech_s=len(kept) / 32000, elapsed_s=0.003,
|
||||
sample_rate=16000)
|
||||
|
||||
|
||||
class FakeDiarizer:
|
||||
def __init__(self) -> None:
|
||||
self.lengths: list[int] = []
|
||||
|
||||
def diarize(self, pcm: bytes) -> Diarization:
|
||||
self.lengths.append(len(pcm))
|
||||
return Diarization(turns=(SpeakerTurn("SPEAKER_00", 0.0, 0.2), SpeakerTurn("SPEAKER_01", 0.2, 0.5)),
|
||||
speakers=("SPEAKER_00", "SPEAKER_01"),
|
||||
embeddings=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32),
|
||||
audio_s=0.5, elapsed_s=0.3)
|
||||
|
||||
|
||||
class FakeIdentifier:
|
||||
"""Reconnaît la première empreinte (valeur 1), pas les autres."""
|
||||
|
||||
def identify(self, embedding: np.ndarray) -> Identification:
|
||||
if embedding[0] == 1.0:
|
||||
return Identification(name="Denis", score=0.8, closest="Denis")
|
||||
return Identification(name=None, score=0.1, closest="Denis")
|
||||
|
||||
|
||||
class FakeStt:
|
||||
def __init__(self, text: str, dropped: tuple[str, ...] = (), words: tuple[Word, ...] = ()) -> None:
|
||||
self.text = text
|
||||
self.dropped = dropped
|
||||
self.words = words
|
||||
self.calls = 0
|
||||
self.lengths: list[int] = []
|
||||
|
||||
def transcribe(self, pcm: bytes) -> Transcript:
|
||||
self.calls += 1
|
||||
self.lengths.append(len(pcm))
|
||||
return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped,
|
||||
words=self.words)
|
||||
|
||||
|
||||
class EchoAssistant:
|
||||
def __init__(self, command: Command | None = None) -> None:
|
||||
self.speakers: list[str | None] = []
|
||||
self.command = command
|
||||
|
||||
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply:
|
||||
self.speakers.append(speaker)
|
||||
return Reply(f"réponse à {text}", self.command)
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
self.calls: list[tuple[str, Command, str | None, str]] = []
|
||||
|
||||
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]:
|
||||
self.calls.append((board, command, speaker, session))
|
||||
self.events.append("commande")
|
||||
return ["home/salon/light/set"]
|
||||
|
||||
|
||||
class FakePublisher:
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[tuple[str, str, bytes, int]] = []
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
self.messages.append((device, suffix, payload, qos))
|
||||
|
||||
|
||||
class FakeTts:
|
||||
def __init__(self) -> None:
|
||||
self.texts: list[str] = []
|
||||
|
||||
def synthesize(self, text: str) -> Speech:
|
||||
self.texts.append(text)
|
||||
return Speech(pcm=bytes(16000), audio_s=0.5, elapsed_s=0.1)
|
||||
|
||||
|
||||
class FakePlayer:
|
||||
def __init__(self, events: list[str] | None = None) -> None:
|
||||
self.sent: list[tuple[str, int, str | None]] = []
|
||||
self.events = events if events is not None else []
|
||||
|
||||
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
|
||||
self.sent.append((device, len(pcm), session))
|
||||
self.events.append("reponse parlee")
|
||||
return session or "x"
|
||||
|
||||
|
||||
def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult:
|
||||
return SessionResult(device="esp32-01", session_id="a1b2c3", outcome=Outcome.COMPLETE,
|
||||
path=Path("inutile.wav"), duration_s=duration_s, chunks_received=30,
|
||||
chunks_announced=30, levels=Levels(peak_dbfs=peak_dbfs, rms_dbfs=-30.0))
|
||||
|
||||
|
||||
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
|
||||
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | None = None,
|
||||
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
|
||||
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline:
|
||||
ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard
|
||||
return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer,
|
||||
identifier=identifier, publisher=publisher, controller=controller, policy=policy,
|
||||
read=lambda result: PCM, clock=lambda: next(ticks))
|
||||
|
||||
|
||||
def test_full_turn() -> None:
|
||||
"""Question transcrite, réponse synthétisée puis envoyée à la bonne carte."""
|
||||
stt, tts, player = FakeStt("quelle heure"), FakeTts(), FakePlayer()
|
||||
|
||||
turn = pipeline(stt, tts, player).handle(session())
|
||||
|
||||
assert turn is not None
|
||||
assert (turn.heard, turn.answer) == ("quelle heure", "réponse à quelle heure")
|
||||
assert turn.ready_s == pytest.approx(1.2)
|
||||
assert stt.lengths == [16000] # Whisper ne reçoit que la parole trouvée par la VAD
|
||||
assert turn.speech_s == pytest.approx(0.5)
|
||||
assert tts.texts == ["réponse à quelle heure"]
|
||||
assert player.sent == [("esp32-01", 16000, "rep-a1b2c3")]
|
||||
|
||||
|
||||
def test_too_short_session_is_ignored() -> None:
|
||||
"""Un appui trop bref ne déclenche ni transcription ni réponse."""
|
||||
stt, tts, player = FakeStt("x"), FakeTts(), FakePlayer()
|
||||
|
||||
assert pipeline(stt, tts, player).handle(session(duration_s=0.1)) is None
|
||||
assert (stt.calls, player.sent) == (0, [])
|
||||
|
||||
|
||||
def test_session_without_speech_skips_whisper() -> None:
|
||||
"""Pas de parole selon la VAD : Whisper n'est pas appelé (lent, hallucinations), la carte le dit."""
|
||||
stt, tts, player = FakeStt("Sous-titres réalisés par Amara.org"), FakeTts(), FakePlayer()
|
||||
|
||||
turn = pipeline(stt, tts, player, FakeVad(speech=False)).handle(session(peak_dbfs=-30.0))
|
||||
|
||||
assert stt.calls == 0
|
||||
assert turn is not None and turn.answer == NOTHING_HEARD
|
||||
assert len(player.sent) == 1
|
||||
|
||||
|
||||
def test_nothing_recognized_is_said() -> None:
|
||||
"""Transcription vide (hallucination écartée) : « Je n'ai rien entendu. »"""
|
||||
stt, tts, player = FakeStt("", dropped=("Amara.org",)), FakeTts(), FakePlayer()
|
||||
|
||||
turn = pipeline(stt, tts, player).handle(session())
|
||||
|
||||
assert turn is not None and turn.answer == NOTHING_HEARD
|
||||
|
||||
|
||||
def test_diarization_gets_the_same_audio_as_whisper() -> None:
|
||||
"""Diarisation et transcription reçoivent la même parole : leurs instants concordent."""
|
||||
stt, tts, player, diarizer = FakeStt("bonjour"), FakeTts(), FakePlayer(), FakeDiarizer()
|
||||
|
||||
turn = pipeline(stt, tts, player, diarizer=diarizer).handle(session())
|
||||
|
||||
assert turn is not None and turn.speakers == 2
|
||||
assert diarizer.lengths == stt.lengths == [16000]
|
||||
|
||||
|
||||
def test_no_diarization_without_speech() -> None:
|
||||
"""Appui muet : ni diarisation ni transcription."""
|
||||
diarizer = FakeDiarizer()
|
||||
|
||||
turn = pipeline(FakeStt("x"), FakeTts(), FakePlayer(), FakeVad(speech=False), diarizer).handle(session())
|
||||
|
||||
assert turn is not None and turn.speakers == 0
|
||||
assert diarizer.lengths == []
|
||||
|
||||
|
||||
def test_each_speaker_gets_a_name_or_unknown() -> None:
|
||||
"""Étape 9 : un nom par locuteur de la diarisation, dans le même ordre."""
|
||||
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), diarizer=FakeDiarizer(),
|
||||
identifier=FakeIdentifier()).handle(session())
|
||||
|
||||
assert turn is not None and turn.names == ("Denis", "Inconnu")
|
||||
|
||||
|
||||
def test_no_identification_without_diarization() -> None:
|
||||
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), identifier=FakeIdentifier()).handle(session())
|
||||
|
||||
assert turn is not None and turn.names == ()
|
||||
|
||||
|
||||
TWO_VOICES = (Word(" Ferme", 0.0, 0.1), Word(" la", 0.1, 0.18), # SPEAKER_00 : 0,0-0,2
|
||||
Word(" Bonjour", 0.22, 0.4), Word(" Denis.", 0.4, 0.5)) # SPEAKER_01 : 0,2-0,5
|
||||
|
||||
|
||||
def test_who_said_what_is_published() -> None:
|
||||
"""Étape 10 : une intervention par locuteur, publiée en JSON sur voice/<carte>/transcript."""
|
||||
publisher = FakePublisher()
|
||||
turn = pipeline(FakeStt("Ferme la Bonjour Denis.", words=TWO_VOICES), FakeTts(), FakePlayer(),
|
||||
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), publisher=publisher).handle(session())
|
||||
|
||||
assert turn is not None
|
||||
assert [(u.speaker, u.text) for u in turn.utterances] == [("Denis", "Ferme la"), ("Inconnu", "Bonjour Denis.")]
|
||||
[(device, suffix, payload, qos)] = publisher.messages
|
||||
assert (device, suffix, qos) == ("esp32-01", "transcript", 1)
|
||||
message = json.loads(payload)
|
||||
assert message["session"] == "a1b2c3"
|
||||
assert message["utterances"][0] == {"speaker": "Denis", "text": "Ferme la", "start": 0.0, "end": 0.18,
|
||||
"score": 0.8}
|
||||
|
||||
|
||||
def test_assistant_answers_the_last_speaker_by_name() -> None:
|
||||
"""L'assistant reçoit le nom de la dernière personne qui a parlé, None si elle est inconnue."""
|
||||
assistant = EchoAssistant()
|
||||
words = TWO_VOICES[:2] # seul SPEAKER_00, reconnu comme Denis
|
||||
pipeline(FakeStt("Ferme la", words=words), FakeTts(), FakePlayer(), diarizer=FakeDiarizer(),
|
||||
identifier=FakeIdentifier(), assistant=assistant).handle(session())
|
||||
pipeline(FakeStt("Ferme la Bonjour Denis.", words=TWO_VOICES), FakeTts(), FakePlayer(),
|
||||
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=assistant).handle(session())
|
||||
|
||||
assert assistant.speakers == ["Denis", None]
|
||||
|
||||
|
||||
def test_nothing_published_without_speech() -> None:
|
||||
publisher = FakePublisher()
|
||||
pipeline(FakeStt("x"), FakeTts(), FakePlayer(), FakeVad(speech=False), publisher=publisher).handle(session())
|
||||
|
||||
assert publisher.messages == []
|
||||
|
||||
|
||||
def test_text_without_word_times_is_one_unknown_utterance() -> None:
|
||||
"""Sans diarisation ni horodatage, une seule intervention « Inconnu » : le JSON reste valide."""
|
||||
publisher = FakePublisher()
|
||||
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), publisher=publisher).handle(session())
|
||||
|
||||
assert turn is not None and [(u.speaker, u.text) for u in turn.utterances] == [("Inconnu", "bonjour")]
|
||||
assert json.loads(publisher.messages[0][2])["utterances"][0]["score"] is None
|
||||
|
||||
|
||||
def test_command_is_executed_before_the_spoken_answer() -> None:
|
||||
"""Étape 11 : la commande part d'abord, la lumière s'allume pendant que la carte le dit."""
|
||||
events: list[str] = []
|
||||
command = DeviceCommand(action="on", device="light", room="salon")
|
||||
controller = FakeController(events)
|
||||
turn = pipeline(FakeStt("allume"), FakeTts(), FakePlayer(events), assistant=EchoAssistant(command),
|
||||
controller=controller).handle(session())
|
||||
|
||||
assert turn is not None and turn.command == command
|
||||
assert controller.calls == [("esp32-01", command, None, "a1b2c3")]
|
||||
assert events == ["commande", "reponse parlee"]
|
||||
|
||||
|
||||
def test_no_command_no_controller_call() -> None:
|
||||
events: list[str] = []
|
||||
controller = FakeController(events)
|
||||
pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(events), controller=controller).handle(session())
|
||||
|
||||
assert controller.calls == []
|
||||
|
||||
|
||||
HOME = HomeSettings(topic_prefix="home", rooms={"salon": "du salon"}, boards={"esp32-01": "salon"})
|
||||
ACCESS = AccessSettings(unknown_level="limite", default_level="standard", users={"Denis": "complet"},
|
||||
full_min_score=0.6, rules={"door": {"open": "complet"}}, confirm=("door.open",),
|
||||
confirm_timeout_s=15.0)
|
||||
DENIS_WORDS = (Word(" ouvre", 0.0, 0.1), Word(" la porte", 0.1, 0.18)) # tour de SPEAKER_00 = Denis (0,8)
|
||||
|
||||
|
||||
def test_refused_command_is_not_executed() -> None:
|
||||
"""Étape 14 : voix inconnue, pas de commande ; la carte dit pourquoi."""
|
||||
events: list[str] = []
|
||||
controller = FakeController(events)
|
||||
turn = pipeline(FakeStt("ouvre la porte"), FakeTts(), FakePlayer(events),
|
||||
assistant=EchoAssistant(DeviceCommand("open", "door", "salon")),
|
||||
controller=controller, policy=AccessPolicy(ACCESS, HOME)).handle(session())
|
||||
|
||||
assert turn is not None and turn.refused and turn.command is None
|
||||
assert controller.calls == []
|
||||
assert turn.answer.startswith("Je ne vous ai pas reconnu")
|
||||
|
||||
|
||||
def test_sensitive_command_waits_for_yes() -> None:
|
||||
"""Denis demande d'ouvrir la porte : question, puis « oui » → exécution."""
|
||||
events: list[str] = []
|
||||
controller = FakeController(events)
|
||||
policy = AccessPolicy(ACCESS, HOME)
|
||||
door = DeviceCommand("open", "door", "salon")
|
||||
|
||||
first = pipeline(FakeStt("ouvre la porte", words=DENIS_WORDS), FakeTts(), FakePlayer(events),
|
||||
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(door),
|
||||
controller=controller, policy=policy).handle(session())
|
||||
assert first is not None and first.answer.startswith("Confirmez-vous") and controller.calls == []
|
||||
|
||||
yes_words = (Word(" oui", 0.0, 0.1),)
|
||||
second = pipeline(FakeStt("oui", words=yes_words), FakeTts(), FakePlayer(events),
|
||||
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(),
|
||||
controller=controller, policy=policy).handle(session())
|
||||
assert second is not None and second.command == door
|
||||
assert [call[1] for call in controller.calls] == [door]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Point d'entrée du serveur vocal : python -m voice_server --config config.yaml
|
||||
|
||||
Étape 4 : reçoit les sessions de parole des ESP32 par MQTT et les écrit en WAV.
|
||||
Étape 5 : avec --echo, renvoie chaque session à la carte, qui la joue.
|
||||
Étape 6 : par défaut, répond à chaque session : faster-whisper → Assistant → Piper.
|
||||
Étape 7 : la VAD (Silero) retire les silences et écarte les sessions sans parole.
|
||||
Étape 8 : la diarisation (pyannote) découpe la parole par locuteur.
|
||||
Étape 9 : chaque locuteur est comparé aux profils vocaux : un nom, ou « Inconnu ».
|
||||
Étape 10 : qui a dit quoi, publié sur voice/<carte>/transcript ; l'assistant appelle par son nom.
|
||||
Étape 11 : domotique : « ferme les volets du salon » → home/salon/shutter/set (+ la carte si c'est sa pièce).
|
||||
Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton.
|
||||
Étape 14 : droits par profil (complet / standard / limite), confirmation des actions sensibles.
|
||||
Étape 15 : présence + testament (les cartes coupent leur micro si le serveur meurt), journal fichier.
|
||||
Étape 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import queue
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from voice_server.audio_resampler import AudioResampler
|
||||
from voice_server.audio_sender import AudioSender
|
||||
from voice_server.mqtt_link import MqttLink
|
||||
from voice_server.session_manager import Outcome, SessionManager, SessionResult
|
||||
from voice_server.settings import Settings, load_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from voice_server.hands_free import HandsFreeListener
|
||||
|
||||
logger = logging.getLogger("voice_server")
|
||||
|
||||
SWEEP_PERIOD_S = 0.2 # fréquence de vérification des sessions terminées ou muettes
|
||||
LOG_FILE_BYTES = 1_000_000 # journal fichier : 1 Mo par fichier...
|
||||
LOG_FILE_COUNT = 3 # ... et 3 anciens gardés : 4 Mo au plus
|
||||
|
||||
|
||||
def setup_logging(settings: Settings) -> None:
|
||||
"""Journal à l'écran, et copie dans un fichier tournant si config.yaml le demande."""
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
||||
if settings.log_file is not None:
|
||||
settings.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Tournant : au-delà de 1 Mo, le fichier est renommé .1 et un nouveau commence ;
|
||||
# le disque (carte SD d'un Raspberry Pi) ne se remplit jamais.
|
||||
handlers.append(logging.handlers.RotatingFileHandler(
|
||||
settings.log_file, maxBytes=LOG_FILE_BYTES, backupCount=LOG_FILE_COUNT, encoding="utf-8"))
|
||||
logging.basicConfig(level=settings.log_level, format=settings.log_format, handlers=handlers, force=True)
|
||||
|
||||
|
||||
def log_state(device: str, payload: bytes) -> None:
|
||||
"""Journalise l'état publié par une carte (retenu : reçu dès l'abonnement)."""
|
||||
try:
|
||||
state = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
if state.get("status") == "online":
|
||||
# La durée de fonctionnement distingue une republication périodique d'un redémarrage.
|
||||
logger.info("%s : en ligne depuis %s s, IP %s, signal %s dBm", device,
|
||||
state.get("uptime_s"), state.get("ip"), state.get("rssi"))
|
||||
else:
|
||||
logger.warning("%s : %s", device, state.get("status"))
|
||||
|
||||
|
||||
def make_echo(resampler: AudioResampler, sender: AudioSender) -> Callable[[SessionResult], object]:
|
||||
"""Mode écho (étape 5) : la carte rejoue ce qu'elle vient d'enregistrer."""
|
||||
def echo(result: SessionResult) -> None:
|
||||
if result.duration_s < 0.2:
|
||||
logger.warning("%s : session %s trop courte pour l'echo (%.2f s) : bouton relache trop tot ?",
|
||||
result.device, result.session_id, result.duration_s)
|
||||
return
|
||||
sender.send(result.device, resampler.from_wav(result.path), f"echo-{result.session_id}")
|
||||
return echo
|
||||
|
||||
|
||||
def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioSender,
|
||||
link: MqttLink | None = None) -> Callable[[SessionResult], object]:
|
||||
"""Mode assistant (étapes 6-14) : charge les modèles, puis répond à chaque session."""
|
||||
# Imports tardifs : le mode écho fonctionne sans l'extra « speech ».
|
||||
from voice_server.access_control import AccessPolicy
|
||||
from voice_server.assistant import Assistant
|
||||
from voice_server.command_router import CommandRouter
|
||||
from voice_server.diarization import Diarizer
|
||||
from voice_server.home_control import HomeController
|
||||
from voice_server.machine_control import MachineController
|
||||
from voice_server.speaker_identifier import SpeakerIdentifier
|
||||
from voice_server.speech_to_text import SpeechToText
|
||||
from voice_server.text_to_speech import TextToSpeech
|
||||
from voice_server.voice_activity import VoiceActivityDetector
|
||||
from voice_server.voice_pipeline import VoicePipeline
|
||||
|
||||
try:
|
||||
stt = SpeechToText(settings.stt, settings.audio.sample_rate)
|
||||
tts = TextToSpeech(settings.tts, resampler, settings.audio.sample_rate)
|
||||
vad = VoiceActivityDetector(settings.vad, settings.audio.sample_rate)
|
||||
except ImportError as error:
|
||||
raise ImportError(f"{error} : lancez 'make install-speech'") from error
|
||||
|
||||
diarizer = None
|
||||
identifier = None
|
||||
if settings.diarization.enabled:
|
||||
try:
|
||||
diarizer = Diarizer(settings.diarization, settings.audio.sample_rate)
|
||||
except ImportError as error:
|
||||
raise ImportError(f"{error} : lancez 'make install-diarization', ou "
|
||||
"diarization.enabled: false dans config.yaml") from error
|
||||
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
|
||||
else:
|
||||
logger.info("diarisation desactivee (config.yaml) : pas d'identification du locuteur")
|
||||
|
||||
machines = None
|
||||
if link is not None:
|
||||
machines = MachineController(settings.machines, link)
|
||||
# États retenus des agents : reçus dès l'abonnement, même démarrés avant le serveur.
|
||||
link.on_topic(f"{settings.machines.topic_prefix}/+/state", machines.on_state)
|
||||
link.on_topic(f"{settings.machines.topic_prefix}/+/result", machines.on_result)
|
||||
logger.info("machines : %s", ", ".join(settings.machines.machines) or "aucune (config.yaml)")
|
||||
|
||||
pipeline = VoicePipeline(
|
||||
vad=vad,
|
||||
stt=stt,
|
||||
assistant=Assistant(home=settings.home, machines=machines),
|
||||
tts=tts,
|
||||
player=sender,
|
||||
diarizer=diarizer,
|
||||
identifier=identifier,
|
||||
publisher=link,
|
||||
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
|
||||
policy=AccessPolicy(settings.access, settings.home, settings.machines),
|
||||
)
|
||||
return pipeline.handle
|
||||
|
||||
|
||||
def make_listener(settings: Settings, link: MqttLink,
|
||||
on_command: Callable[[SessionResult], None]) -> "HandsFreeListener":
|
||||
"""Écoute mains libres (étape 13) : un détecteur de mot de réveil par carte."""
|
||||
from voice_server.hands_free import HandsFreeListener
|
||||
from voice_server.voice_activity import VoiceActivityDetector
|
||||
from voice_server.wake_word import WakeWordDetector
|
||||
|
||||
WakeWordDetector(settings.wake) # vérifie modèles et installation dès le démarrage
|
||||
return HandsFreeListener(
|
||||
settings.wake, settings.audio, VoiceActivityDetector(settings.vad, settings.audio.sample_rate),
|
||||
settings.recordings_dir, link, on_command,
|
||||
detector_factory=lambda device: WakeWordDetector(settings.wake, device),
|
||||
)
|
||||
|
||||
|
||||
def worker(jobs: "queue.Queue[SessionResult | None]", handle: Callable[[SessionResult], object]) -> None:
|
||||
"""Traite les sessions une à une, hors du fil réseau : chacune dure des secondes."""
|
||||
while True:
|
||||
result = jobs.get()
|
||||
if result is None:
|
||||
return
|
||||
try:
|
||||
handle(result)
|
||||
except Exception: # une session ratée ne doit jamais arrêter le serveur
|
||||
logger.exception("%s : echec du traitement de %s", result.device, result.path.name)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Lance le serveur jusqu'à Ctrl-C."""
|
||||
parser = argparse.ArgumentParser(description="serveur de l'assistant vocal")
|
||||
parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[1] / "config.yaml")
|
||||
parser.add_argument("--echo", action="store_true",
|
||||
help="renvoie chaque session a la carte, qui la joue (etape 5), sans modeles")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
settings = load_settings(args.config)
|
||||
setup_logging(settings)
|
||||
logging.getLogger("faster_whisper").setLevel(logging.WARNING) # une ligne par transcription sinon
|
||||
logger.info("enregistrements dans %s", settings.recordings_dir)
|
||||
if settings.log_file is not None:
|
||||
logger.info("journal copie dans %s", settings.log_file)
|
||||
if settings.mqtt.username is None:
|
||||
logger.warning("pas d'identifiants MQTT (server/.env) : refuse par un broker securise (etape 15)")
|
||||
|
||||
link = MqttLink(settings.mqtt, presence=True)
|
||||
resampler = AudioResampler(settings.audio)
|
||||
sender = AudioSender(link, settings.audio)
|
||||
if args.echo:
|
||||
logger.info("mode echo : chaque session sera rejouee par la carte")
|
||||
handle = make_echo(resampler, sender)
|
||||
else:
|
||||
try:
|
||||
handle = make_assistant(settings, resampler, sender, link)
|
||||
except ImportError as error:
|
||||
logger.error("%s", error)
|
||||
return 1
|
||||
except FileNotFoundError as error:
|
||||
logger.error("%s (ou diarization.enabled: false dans config.yaml)", error)
|
||||
return 1
|
||||
logger.info("mode assistant : appuyez sur le bouton et posez une question")
|
||||
|
||||
jobs: "queue.Queue[SessionResult | None]" = queue.Queue()
|
||||
|
||||
def on_finished(result: SessionResult) -> None:
|
||||
if result.outcome is not Outcome.INTERRUPTED:
|
||||
jobs.put(result) # rend la main aussitôt : le traitement dure des secondes
|
||||
|
||||
sessions = SessionManager(settings.audio, settings.sessions, settings.recordings_dir,
|
||||
on_finished=on_finished)
|
||||
listener = None
|
||||
if settings.wake.enabled and not args.echo:
|
||||
try:
|
||||
listener = make_listener(settings, link, on_finished)
|
||||
except (ImportError, FileNotFoundError) as error:
|
||||
logger.error("%s : lancez 'make install-wakeword' et 'make models', "
|
||||
"ou wake.enabled: false dans config.yaml", error)
|
||||
return 1
|
||||
logger.info("mains libres : dites %s", ", ".join(f"« {w} »" for w in settings.wake.words))
|
||||
threading.Thread(target=worker, args=(jobs, handle), daemon=True).start()
|
||||
|
||||
def on_state(device: str, payload: bytes) -> None:
|
||||
log_state(device, payload)
|
||||
if listener is not None:
|
||||
listener.on_state(device, payload)
|
||||
|
||||
def on_event(device: str, payload: bytes) -> None:
|
||||
sessions.on_event(device, payload)
|
||||
if listener is not None:
|
||||
listener.on_event(device, payload)
|
||||
|
||||
link.on("state", on_state)
|
||||
link.on("event", on_event)
|
||||
link.on("audio/in", sessions.on_audio)
|
||||
if listener is not None:
|
||||
link.on("audio/stream", listener.on_stream)
|
||||
|
||||
stop = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
|
||||
link.start()
|
||||
try:
|
||||
while not stop.wait(SWEEP_PERIOD_S):
|
||||
sessions.sweep()
|
||||
if listener is not None:
|
||||
listener.sweep()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
jobs.put(None)
|
||||
if listener is not None:
|
||||
listener.stop() # les cartes cessent d'envoyer leur micro
|
||||
time.sleep(0.5) # laisse partir ces messages avant la déconnexion
|
||||
link.stop()
|
||||
logger.info("arret du serveur")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -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
|
||||
@@ -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()}"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Accumulation du PCM d'une session et écriture en WAV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.settings import AudioSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Levels:
|
||||
"""Niveaux d'un signal, en dBFS (0 = pleine échelle)."""
|
||||
|
||||
peak_dbfs: float
|
||||
rms_dbfs: float
|
||||
|
||||
|
||||
class AudioBuffer:
|
||||
"""Tampon PCM s16le borné en taille, pour une seule session."""
|
||||
|
||||
def __init__(self, audio: AudioSettings, max_seconds: float) -> None:
|
||||
self._audio = audio
|
||||
self._max_bytes = int(audio.bytes_per_second * max_seconds)
|
||||
self._data = bytearray()
|
||||
self.truncated = False # vrai si des données ont été refusées (trop long)
|
||||
|
||||
def append(self, pcm: bytes) -> None:
|
||||
"""Ajoute un chunk ; au-delà de la durée maximale, le surplus est ignoré."""
|
||||
room = self._max_bytes - len(self._data)
|
||||
if len(pcm) > room:
|
||||
self.truncated = True
|
||||
pcm = pcm[: max(0, room)]
|
||||
self._data.extend(pcm)
|
||||
|
||||
@property
|
||||
def byte_count(self) -> int:
|
||||
"""Nombre d'octets accumulés."""
|
||||
return len(self._data)
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
"""Durée accumulée, en secondes."""
|
||||
return len(self._data) / self._audio.bytes_per_second
|
||||
|
||||
def levels(self) -> Levels:
|
||||
"""Crête et valeur efficace du contenu."""
|
||||
samples = np.frombuffer(bytes(self._data[: len(self._data) // 2 * 2]), dtype="<i2")
|
||||
if samples.size == 0:
|
||||
return Levels(peak_dbfs=-math.inf, rms_dbfs=-math.inf)
|
||||
normalized = samples.astype(np.float64) / 32768.0
|
||||
peak = float(np.max(np.abs(normalized)))
|
||||
rms = float(np.sqrt(np.mean(normalized**2)))
|
||||
return Levels(
|
||||
peak_dbfs=20.0 * math.log10(peak) if peak > 0 else -math.inf,
|
||||
rms_dbfs=20.0 * math.log10(rms) if rms > 0 else -math.inf,
|
||||
)
|
||||
|
||||
def write_wav(self, path: Path) -> None:
|
||||
"""Écrit le contenu dans un fichier WAV (dossiers créés au besoin)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(self._audio.channels)
|
||||
wav.setsampwidth(self._audio.bits // 8)
|
||||
wav.setframerate(self._audio.sample_rate)
|
||||
wav.writeframes(bytes(self._data))
|
||||
|
||||
|
||||
def read_pcm(path: Path) -> bytes:
|
||||
"""Relit le PCM d'un WAV écrit par write_wav (format du système, sans conversion)."""
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
return wav.readframes(wav.getnframes())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Mise au format du système de tout audio destiné à un ESP32."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import soxr
|
||||
|
||||
from voice_server.settings import AudioSettings
|
||||
|
||||
|
||||
class AudioFormatError(ValueError):
|
||||
"""Fichier audio que le module wave ne sait pas lire."""
|
||||
|
||||
|
||||
class AudioResampler:
|
||||
"""Convertit en PCM s16le mono au format du système, crête normalisée.
|
||||
|
||||
L'ESP32 reste simple : il ne sait jouer que du 16 kHz mono. Tout le reste
|
||||
se fait ici — Piper, par exemple, produit du 22 050 Hz (étape 6).
|
||||
|
||||
La normalisation de crête fixe le volume d'écoute : sans elle, une voix
|
||||
enregistrée à -30 dBFS sort presque inaudible du haut-parleur (étape 2).
|
||||
"""
|
||||
|
||||
def __init__(self, audio: AudioSettings, target_peak_dbfs: float | None = -3.0) -> None:
|
||||
"""
|
||||
Args:
|
||||
audio: format de sortie (fréquence du système).
|
||||
target_peak_dbfs: crête visée, None pour ne pas toucher au niveau.
|
||||
"""
|
||||
self._audio = audio
|
||||
self._target_peak = None if target_peak_dbfs is None else 10.0 ** (target_peak_dbfs / 20.0)
|
||||
|
||||
def from_wav(self, path: Path) -> bytes:
|
||||
"""Lit un WAV PCM (8, 16, 24 ou 32 bits, mono ou stéréo, toute fréquence)."""
|
||||
try:
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
raw = wav.readframes(wav.getnframes())
|
||||
width = wav.getsampwidth()
|
||||
channels = wav.getnchannels()
|
||||
rate = wav.getframerate()
|
||||
except (wave.Error, EOFError) as error: # EOFError : fichier tronqué
|
||||
raise AudioFormatError(
|
||||
f"{path} : {error}. Conversion : ffmpeg -i {path} -ar 16000 -ac 1 -sample_fmt s16 sortie.wav"
|
||||
) from error
|
||||
samples = _decode_pcm(raw, width).reshape(-1, channels)
|
||||
return self.convert(samples, rate)
|
||||
|
||||
def convert(self, samples: np.ndarray, rate: int) -> bytes:
|
||||
"""Convertit des échantillons flottants dans [-1, 1], forme (n,) ou (n, canaux)."""
|
||||
signal = np.asarray(samples, dtype=np.float64)
|
||||
if signal.ndim == 2:
|
||||
signal = signal.mean(axis=1) # mono : moyenne des canaux
|
||||
|
||||
if rate != self._audio.sample_rate and signal.size > 0:
|
||||
# soxr : rééchantillonneur de référence, sans repliement audible.
|
||||
signal = soxr.resample(signal, rate, self._audio.sample_rate, quality="HQ")
|
||||
|
||||
peak = float(np.max(np.abs(signal))) if signal.size else 0.0
|
||||
if self._target_peak is not None and peak > 0.0:
|
||||
signal = signal * (self._target_peak / peak)
|
||||
|
||||
clipped = np.clip(np.round(signal * 32767.0), -32768, 32767)
|
||||
return clipped.astype("<i2").tobytes()
|
||||
|
||||
|
||||
def _decode_pcm(raw: bytes, width: int) -> np.ndarray:
|
||||
"""Décode du PCM entier little-endian en flottants dans [-1, 1]."""
|
||||
if width == 1: # 8 bits : non signé, centré sur 128
|
||||
return (np.frombuffer(raw, dtype=np.uint8).astype(np.float64) - 128.0) / 128.0
|
||||
if width == 2:
|
||||
return np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
|
||||
if width == 3: # 24 bits : on complète chaque échantillon en 32 bits
|
||||
bytes3 = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
|
||||
padded = np.zeros((bytes3.shape[0], 4), dtype=np.uint8)
|
||||
padded[:, 1:] = bytes3
|
||||
return padded.view("<i4").reshape(-1).astype(np.float64) / 2147483648.0
|
||||
if width == 4:
|
||||
return np.frombuffer(raw, dtype="<i4").astype(np.float64) / 2147483648.0
|
||||
raise AudioFormatError(f"largeur d'echantillon non geree : {width * 8} bits")
|
||||
|
||||
|
||||
def peak_dbfs(pcm: bytes) -> float:
|
||||
"""Crête d'un PCM s16le, en dBFS."""
|
||||
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
|
||||
peak = int(np.max(np.abs(samples.astype(np.int32)))) if samples.size else 0
|
||||
return 20.0 * math.log10(peak / 32768.0) if peak > 0 else -math.inf
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Envoi de PCM à un ESP32 par MQTT, au rythme réel (étape 5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from voice_server.settings import AudioSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Publisher(Protocol):
|
||||
"""Ce dont l'envoi a besoin du lien MQTT : publier vers une carte."""
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
|
||||
|
||||
|
||||
def send_times(count: int, chunk_s: float, prefill: int) -> list[float]:
|
||||
"""Instants d'envoi de chaque chunk, en secondes depuis le début.
|
||||
|
||||
Les `prefill` premiers partent aussitôt : c'est l'avance qui protège l'ESP32
|
||||
des irrégularités du Wi-Fi. Chaque suivant part quand l'ESP32 a joué
|
||||
l'équivalent d'un chunk, ce qui maintient l'avance constante.
|
||||
"""
|
||||
return [max(0.0, (index - prefill + 1) * chunk_s) for index in range(count)]
|
||||
|
||||
|
||||
class AudioSender:
|
||||
"""Envoie un PCM déjà au format du système : START, chunks, END.
|
||||
|
||||
START et END passent sur voice/<carte>/control en QoS 1 (garantis) ;
|
||||
les chunks sur voice/<carte>/audio/out en QoS 0 (rapides, perte possible,
|
||||
comptée par l'ESP32 grâce au nombre annoncé dans le END).
|
||||
|
||||
L'appel est bloquant pendant toute la durée du son : à lancer dans un fil
|
||||
dédié, jamais dans le fil réseau de paho.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
link: Publisher,
|
||||
audio: AudioSettings,
|
||||
prefill_chunks: int = 6,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
link: lien MQTT.
|
||||
audio: format du système.
|
||||
prefill_chunks: avance envoyée d'un coup ; 6 chunks = 300 ms, la FIFO
|
||||
de l'ESP32 en contient 512.
|
||||
sleep, clock: injectables pour les tests.
|
||||
"""
|
||||
self._link = link
|
||||
self._audio = audio
|
||||
self._prefill = prefill_chunks
|
||||
self._sleep = sleep
|
||||
self._clock = clock
|
||||
|
||||
@property
|
||||
def chunk_bytes(self) -> int:
|
||||
"""Taille d'un chunk : 1600 octets pour 50 ms à 16 kHz."""
|
||||
return self._audio.bytes_per_second * self._audio.chunk_ms // 1000
|
||||
|
||||
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
|
||||
"""Envoie le PCM et rend l'identifiant de la session de lecture."""
|
||||
session_id = session or f"srv-{uuid.uuid4().hex[:6]}"
|
||||
even = pcm[: len(pcm) // 2 * 2] # jamais de demi-échantillon
|
||||
size = self.chunk_bytes
|
||||
chunks = [even[i : i + size] for i in range(0, len(even), size)]
|
||||
schedule = send_times(len(chunks), self._audio.chunk_ms / 1000.0, self._prefill)
|
||||
|
||||
start = {"event": "start", "session": session_id, "rate": self._audio.sample_rate,
|
||||
"bits": self._audio.bits, "channels": self._audio.channels,
|
||||
"codec": self._audio.codec, "chunk_ms": self._audio.chunk_ms}
|
||||
self._link.publish(device, "control", json.dumps(start).encode(), 1)
|
||||
|
||||
began = self._clock()
|
||||
worst_delay = 0.0
|
||||
for chunk, due in zip(chunks, schedule):
|
||||
wait = began + due - self._clock()
|
||||
if wait > 0:
|
||||
self._sleep(wait)
|
||||
else:
|
||||
worst_delay = max(worst_delay, -wait)
|
||||
self._link.publish(device, "audio/out", chunk, 0)
|
||||
|
||||
end = {"event": "end", "session": session_id, "chunks": len(chunks)}
|
||||
self._link.publish(device, "control", json.dumps(end).encode(), 1)
|
||||
logger.info("%s : lecture %s envoyee, %d chunks (%.2f s), pire retard d'envoi %.0f ms",
|
||||
device, session_id, len(chunks), len(even) / self._audio.bytes_per_second,
|
||||
worst_delay * 1000)
|
||||
return session_id
|
||||
@@ -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 []
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Diarisation : qui parle quand, avec pyannote (étape 8).
|
||||
|
||||
La diarisation découpe l'audio en tours de parole étiquetés SPEAKER_00,
|
||||
SPEAKER_01… Elle ne sait pas QUI parle : les étiquettes ne valent que pour
|
||||
une session. Mettre un nom sur une voix, c'est l'identification (étape 9).
|
||||
|
||||
Elle ne reçoit que la parole trouvée par la VAD, jamais le flux continu du
|
||||
micro : c'est le traitement le plus coûteux de la chaîne.
|
||||
|
||||
Étape 9 : une empreinte vocale par locuteur, calculée ici avec le modèle
|
||||
d'empreintes du même pipeline (WeSpeaker ResNet34). Celles que rend pyannote ne
|
||||
conviennent pas aux questions courtes : il ne garde que les fenêtres de 10 s où
|
||||
le locuteur parle seul au moins 2 s ; en dessous, sa moyenne est NaN.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.settings import DiarizationSettings
|
||||
|
||||
if TYPE_CHECKING: # import réel dans __init__ : l'extra « diarization » est facultatif
|
||||
from pyannote.audio import Inference, Pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_EMBEDDING_S = 0.3 # en dessous, l'empreinte ne vaut rien : le locuteur reste « Inconnu »
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpeakerTurn:
|
||||
"""Un tour de parole : un locuteur, de start_s à end_s."""
|
||||
|
||||
speaker: str
|
||||
start_s: float
|
||||
end_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diarization:
|
||||
"""Résultat de la diarisation d'une séquence parlée."""
|
||||
|
||||
turns: tuple[SpeakerTurn, ...] # dans l'ordre chronologique, sans chevauchement
|
||||
speakers: tuple[str, ...] # étiquettes distinctes, ordre de embeddings
|
||||
embeddings: np.ndarray | None # (locuteurs, dimension), ligne NaN si trop peu de parole
|
||||
audio_s: float
|
||||
elapsed_s: float
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Tours de parole lisibles : « SPEAKER_00 0.00-1.20, SPEAKER_01 1.35-2.60 »."""
|
||||
return ", ".join(f"{t.speaker} {t.start_s:.2f}-{t.end_s:.2f}" for t in self.turns)
|
||||
|
||||
|
||||
def disable_telemetry() -> None:
|
||||
"""Coupe la télémétrie de pyannote.
|
||||
|
||||
pyannote 4 envoie par défaut des statistiques d'usage (durée de l'audio,
|
||||
nombre de locuteurs) à otel.pyannote.ai : contraire à un assistant 100 % local.
|
||||
La variable est relue à chaque traitement : la forcer ici suffit.
|
||||
"""
|
||||
os.environ["PYANNOTE_METRICS_ENABLED"] = "false"
|
||||
|
||||
|
||||
class Diarizer:
|
||||
"""Applique le pipeline pyannote à un PCM s16le mono, sur CPU."""
|
||||
|
||||
def __init__(self, settings: DiarizationSettings, sample_rate: int) -> None:
|
||||
"""
|
||||
Raises:
|
||||
FileNotFoundError: le modèle n'a pas été téléchargé.
|
||||
ImportError: l'extra « diarization » n'est pas installé.
|
||||
"""
|
||||
if not (settings.model_dir / "config.yaml").exists():
|
||||
raise FileNotFoundError(f"modele de diarisation absent de {settings.model_dir} : "
|
||||
"HF_TOKEN dans server/.env, puis 'make models'")
|
||||
disable_telemetry()
|
||||
from pyannote.audio import Pipeline
|
||||
|
||||
began = time.monotonic()
|
||||
# Dossier local : pyannote n'interroge pas le hub (ses sous-modèles sont des sous-dossiers).
|
||||
pipeline = Pipeline.from_pretrained(settings.model_dir)
|
||||
if pipeline is None:
|
||||
raise RuntimeError(f"pipeline pyannote illisible : {settings.model_dir}")
|
||||
self._pipeline: Pipeline = pipeline
|
||||
from pyannote.audio import Inference, Model
|
||||
|
||||
# Empreintes : le sous-modèle du pipeline, appliqué à tout l'audio d'un locuteur.
|
||||
embedding_model = Model.from_pretrained(settings.model_dir / "embedding")
|
||||
if embedding_model is None:
|
||||
raise RuntimeError(f"modele d'empreintes illisible : {settings.model_dir / 'embedding'}")
|
||||
self._embedding: Inference = Inference(embedding_model, window="whole")
|
||||
dimension: Any = embedding_model.dimension # propriété pyannote, mal typée par torch.nn.Module
|
||||
self._dimension = int(dimension)
|
||||
self._settings = settings
|
||||
self._sample_rate = sample_rate
|
||||
# Premier passage à vide : les calculs s'initialisent ici, pas à la première question.
|
||||
self.diarize(bytes(sample_rate * 2))
|
||||
logger.info("diarisation %s chargee en %.1f s", settings.model, time.monotonic() - began)
|
||||
|
||||
def diarize(self, pcm: bytes) -> Diarization:
|
||||
"""Rend les tours de parole et une empreinte par locuteur ; instants relatifs au début du PCM."""
|
||||
began = time.monotonic()
|
||||
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||
waveform = self._waveform(audio)
|
||||
with warnings.catch_warnings():
|
||||
# torch prévient quand un morceau de parole est trop court pour un écart-type :
|
||||
# sans conséquence sur le résultat, mais une ligne de journal à chaque session.
|
||||
warnings.filterwarnings("ignore", message=r"std\(\): degrees of freedom")
|
||||
# Ses propres empreintes sont NaN sur une question courte (voir en tête du module) :
|
||||
# numpy le signale, mais on ne s'en sert pas.
|
||||
warnings.filterwarnings("ignore", message="Mean of empty slice", category=RuntimeWarning)
|
||||
warnings.filterwarnings("ignore", message="invalid value encountered in divide",
|
||||
category=RuntimeWarning)
|
||||
output: Any = self._pipeline({"waveform": waveform, "sample_rate": self._sample_rate},
|
||||
max_speakers=self._settings.max_speakers)
|
||||
|
||||
# Version « exclusive » : un seul locuteur à la fois, pour découper le texte (étape 10)
|
||||
# et pour que chaque empreinte ne contienne qu'une voix.
|
||||
annotation = output.exclusive_speaker_diarization
|
||||
turns = tuple(
|
||||
SpeakerTurn(speaker=str(label), start_s=float(segment.start), end_s=float(segment.end))
|
||||
for segment, _, label in annotation.itertracks(yield_label=True)
|
||||
)
|
||||
speakers = tuple(str(label) for label in annotation.labels())
|
||||
embeddings = np.stack([self._speaker_embedding(audio, turns, speaker) for speaker in speakers]) \
|
||||
if speakers else None
|
||||
return Diarization(
|
||||
turns=turns,
|
||||
speakers=speakers,
|
||||
embeddings=embeddings,
|
||||
audio_s=audio.size / self._sample_rate,
|
||||
elapsed_s=time.monotonic() - began,
|
||||
)
|
||||
|
||||
def embed(self, pcm: bytes) -> np.ndarray:
|
||||
"""Empreinte vocale d'un PCM entier (enrôlement : une seule voix attendue)."""
|
||||
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||
return self._embed(audio)
|
||||
|
||||
def _speaker_embedding(self, audio: np.ndarray, turns: tuple[SpeakerTurn, ...], speaker: str) -> np.ndarray:
|
||||
"""Empreinte de toute la parole d'un locuteur, ses tours mis bout à bout."""
|
||||
pieces = [audio[round(t.start_s * self._sample_rate):round(t.end_s * self._sample_rate)]
|
||||
for t in turns if t.speaker == speaker]
|
||||
return self._embed(np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32))
|
||||
|
||||
def _embed(self, audio: np.ndarray) -> np.ndarray:
|
||||
if audio.size < MIN_EMBEDDING_S * self._sample_rate:
|
||||
return np.full(self._dimension, np.nan, dtype=np.float32)
|
||||
embedding: Any = self._embedding({"waveform": self._waveform(audio), "sample_rate": self._sample_rate})
|
||||
return np.asarray(embedding, dtype=np.float32).reshape(-1)
|
||||
|
||||
@staticmethod
|
||||
def _waveform(audio: np.ndarray) -> Any:
|
||||
"""Audio en mémoire au format pyannote : tenseur (canaux, échantillons), sans fichier."""
|
||||
import torch
|
||||
|
||||
return torch.from_numpy(audio.copy()).unsqueeze(0)
|
||||
|
||||
|
||||
def timeline(diarization: Diarization, step_s: float = 0.1) -> list[str]:
|
||||
"""Une ligne par locuteur, un caractère par step_s : « # » quand il parle."""
|
||||
width = max(1, round(diarization.audio_s / step_s))
|
||||
lines: list[str] = []
|
||||
for speaker in diarization.speakers:
|
||||
row = ["_"] * width
|
||||
for turn in diarization.turns:
|
||||
if turn.speaker == speaker:
|
||||
for index in range(round(turn.start_s / step_s), min(width, round(turn.end_s / step_s))):
|
||||
row[index] = "#"
|
||||
lines.append(f"{speaker} {''.join(row)}")
|
||||
return lines
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Écoute mains libres : du flux continu du micro à une session de parole (étape 13).
|
||||
|
||||
Chaque carte suit trois états :
|
||||
|
||||
VEILLE le flux passe dans le détecteur de mot de réveil ; seules les
|
||||
dernières preroll_ms restent en mémoire, RIEN n'est écrit sur disque ;
|
||||
COMMANDE mot entendu : on garde l'audio jusqu'au silence qui suit la commande
|
||||
(VAD de l'étape 7), puis on en fait une session ordinaire ;
|
||||
RÉPONSE la carte parle : on n'écoute plus (elle s'entendrait elle-même)
|
||||
jusqu'à son bilan « played », ou jusqu'au délai de garde.
|
||||
|
||||
La session produite est identique à celle d'un appui sur le bouton : la suite
|
||||
de la chaîne (VAD, diarisation, Whisper, assistant) ne change pas.
|
||||
|
||||
Les durées d'une commande (silence final, abandon, durée maximale) se mesurent
|
||||
en AUDIO REÇU, pas à l'horloge : le Wi-Fi livre souvent les chunks en rafale, et
|
||||
2 s de son arrivées en 10 ms restent 2 s de parole. L'horloge ne sert qu'aux
|
||||
délais de garde (réponse sans bilan, flux interrompu).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from voice_server.audio_buffer import AudioBuffer
|
||||
from voice_server.audio_sender import Publisher
|
||||
from voice_server.session_manager import Outcome, SessionResult
|
||||
from voice_server.settings import AudioSettings, WakeSettings
|
||||
from voice_server.voice_activity import VadResult
|
||||
from voice_server.wake_word import Detection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_EVERY_CHUNKS = 5 # recherche du silence final toutes les 250 ms d'audio
|
||||
COMMAND_AFTER_WAKE_S = 0.3 # parole qui continue au moins 0,3 s après le mot : il y a une commande
|
||||
STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché
|
||||
|
||||
|
||||
class Detector(Protocol):
|
||||
"""Ce que l'écoute attend du détecteur de mot de réveil."""
|
||||
|
||||
def process(self, pcm: bytes) -> Detection | None: ...
|
||||
|
||||
def reset(self) -> None: ...
|
||||
|
||||
|
||||
class SpeechDetector(Protocol):
|
||||
"""Ce que l'écoute attend de la VAD."""
|
||||
|
||||
def detect(self, pcm: bytes) -> VadResult: ...
|
||||
|
||||
|
||||
class Mode(Enum):
|
||||
"""État d'une carte."""
|
||||
|
||||
STANDBY = "veille"
|
||||
COMMAND = "commande"
|
||||
ANSWER = "reponse"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Board:
|
||||
"""État d'écoute d'une carte."""
|
||||
|
||||
detector: Detector
|
||||
preroll: deque[bytes]
|
||||
mode: Mode = Mode.STANDBY
|
||||
buffer: bytearray = field(default_factory=bytearray)
|
||||
wake_offset: int = 0 # octets du tampon qui précèdent la détection
|
||||
word: str = ""
|
||||
session_id: str = ""
|
||||
started_at: datetime = field(default_factory=datetime.now)
|
||||
last_chunk_time: float = 0.0 # horloge : détecte un flux interrompu
|
||||
unchecked: int = 0 # chunks reçus depuis la dernière recherche du silence final
|
||||
chunks: int = 0
|
||||
answer_until: float = 0.0
|
||||
last_uptime: int = -1
|
||||
|
||||
|
||||
class HandsFreeListener:
|
||||
"""Transforme le flux continu des cartes en sessions, sur mot de réveil."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wake: WakeSettings,
|
||||
audio: AudioSettings,
|
||||
vad: SpeechDetector,
|
||||
recordings_dir: Path,
|
||||
link: Publisher,
|
||||
on_command: Callable[[SessionResult], None],
|
||||
detector_factory: Callable[[str], Detector],
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
on_command: reçoit chaque commande, comme une session du bouton ; doit rendre la main vite.
|
||||
detector_factory: un détecteur neuf par carte (historique audio propre à chacune) ;
|
||||
reçoit le nom de la carte.
|
||||
"""
|
||||
self._wake = wake
|
||||
self._audio = audio
|
||||
self._vad = vad
|
||||
self._recordings_dir = recordings_dir
|
||||
self._link = link
|
||||
self._on_command = on_command
|
||||
self._new_detector = detector_factory
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._boards: dict[str, _Board] = {}
|
||||
chunk_bytes = audio.bytes_per_second * audio.chunk_ms // 1000
|
||||
self._preroll_chunks = max(1, wake.preroll_ms * audio.bytes_per_second // 1000 // chunk_bytes)
|
||||
|
||||
# ------------------------------------------------------------ cartes
|
||||
|
||||
def on_state(self, device: str, payload: bytes) -> None:
|
||||
"""État publié par une carte : on (ré)active son écoute à la connexion ou au redémarrage."""
|
||||
try:
|
||||
state = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
if state.get("status") != "online":
|
||||
with self._lock:
|
||||
self._boards.pop(device, None) # la carte réactivera l'écoute à son retour
|
||||
return
|
||||
uptime = int(state.get("uptime_s", 0))
|
||||
with self._lock:
|
||||
board = self._board(device)
|
||||
first_time = board.last_uptime < 0 or uptime < board.last_uptime # nouvelle carte ou redémarrage
|
||||
board.last_uptime = uptime
|
||||
if first_time:
|
||||
self._send(device, {"cmd": "listen", "enabled": True})
|
||||
logger.info("%s : ecoute mains libres activee (mots : %s)", device, ", ".join(self._wake.words))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Arrêt du serveur : les cartes cessent d'envoyer leur micro."""
|
||||
with self._lock:
|
||||
devices = list(self._boards)
|
||||
for device in devices:
|
||||
self._send(device, {"cmd": "listen", "enabled": False})
|
||||
|
||||
# ------------------------------------------------------------ flux audio
|
||||
|
||||
def on_stream(self, device: str, payload: bytes) -> None:
|
||||
"""Chunk du flux continu (voice/<carte>/audio/stream). Fil réseau : ~1 ms par chunk."""
|
||||
if len(payload) % 2 != 0:
|
||||
return
|
||||
result: SessionResult | None = None
|
||||
with self._lock:
|
||||
board = self._board(device)
|
||||
if board.mode is Mode.STANDBY:
|
||||
board.preroll.append(payload) # seule mémoire de ce qui précède le mot
|
||||
detection = board.detector.process(payload)
|
||||
if detection is not None:
|
||||
self._start_command(device, board, detection)
|
||||
elif board.mode is Mode.COMMAND:
|
||||
board.buffer.extend(payload)
|
||||
board.chunks += 1
|
||||
board.unchecked += 1
|
||||
board.last_chunk_time = self._clock()
|
||||
result = self._check_command(device, board)
|
||||
# Mode.ANSWER : la carte parle ; ce que capte son micro est ignoré.
|
||||
if result is not None:
|
||||
self._on_command(result)
|
||||
|
||||
def on_event(self, device: str, payload: bytes) -> None:
|
||||
"""Bilan « played » d'une carte : sa réponse est finie, on réécoute."""
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
if event.get("event") not in ("played", "rejected"):
|
||||
return
|
||||
with self._lock:
|
||||
board = self._boards.get(device)
|
||||
if board is not None and board.mode is Mode.ANSWER:
|
||||
self._standby(board)
|
||||
|
||||
def sweep(self) -> list[SessionResult]:
|
||||
"""Fil principal : fin de réponse sans bilan, commande dont le flux s'est arrêté."""
|
||||
now = self._clock()
|
||||
results: list[SessionResult] = []
|
||||
with self._lock:
|
||||
for device, board in self._boards.items():
|
||||
if board.mode is Mode.ANSWER and now >= board.answer_until:
|
||||
logger.warning("%s : pas de bilan de lecture, reprise de l'ecoute", device)
|
||||
self._standby(board)
|
||||
elif board.mode is Mode.COMMAND and now - board.last_chunk_time > STREAM_LOST_S:
|
||||
result = self._finish(device, board, "flux interrompu")
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
for result in results:
|
||||
self._on_command(result)
|
||||
return results
|
||||
|
||||
def mode(self, device: str) -> Mode | None:
|
||||
"""État d'écoute d'une carte (journal, tests)."""
|
||||
with self._lock:
|
||||
board = self._boards.get(device)
|
||||
return board.mode if board is not None else None
|
||||
|
||||
# ------------------------------------------------------------ interne (verrou tenu)
|
||||
|
||||
def _board(self, device: str) -> _Board:
|
||||
board = self._boards.get(device)
|
||||
if board is None:
|
||||
board = _Board(detector=self._new_detector(device), preroll=deque(maxlen=self._preroll_chunks))
|
||||
self._boards[device] = board
|
||||
return board
|
||||
|
||||
def _start_command(self, device: str, board: _Board, detection: Detection) -> None:
|
||||
board.mode = Mode.COMMAND
|
||||
board.buffer = bytearray(b"".join(board.preroll))
|
||||
board.preroll.clear()
|
||||
board.wake_offset = len(board.buffer)
|
||||
board.word = detection.word
|
||||
board.session_id = f"w{uuid.uuid4().hex[:5]}"
|
||||
board.started_at = datetime.now()
|
||||
board.last_chunk_time = self._clock()
|
||||
board.unchecked = 0
|
||||
board.chunks = self._preroll_chunks
|
||||
logger.info("%s : mot de reveil « %s » (%.2f), session %s : j'ecoute la commande",
|
||||
device, detection.word, detection.score, board.session_id)
|
||||
self._send(device, {"cmd": "capture", "active": True})
|
||||
|
||||
def _check_command(self, device: str, board: _Board) -> SessionResult | None:
|
||||
"""Commande finie ? Silence après la parole, rien dit, ou durée maximale (en audio reçu)."""
|
||||
elapsed = (len(board.buffer) - board.wake_offset) / self._audio.bytes_per_second
|
||||
if elapsed >= self._wake.max_command_s:
|
||||
return self._finish(device, board, "duree maximale")
|
||||
if board.unchecked < CHECK_EVERY_CHUNKS:
|
||||
return None
|
||||
board.unchecked = 0
|
||||
|
||||
vad = self._vad.detect(bytes(board.buffer))
|
||||
rate = self._audio.sample_rate
|
||||
wake_sample = board.wake_offset // 2
|
||||
after_wake = [s for s in vad.segments if s.end > wake_sample + COMMAND_AFTER_WAKE_S * rate]
|
||||
if not after_wake:
|
||||
if elapsed >= self._wake.no_speech_timeout_s:
|
||||
logger.info("%s : mot de reveil sans commande, retour en veille", device)
|
||||
self._send(device, {"cmd": "capture", "active": False})
|
||||
self._standby(board)
|
||||
return None
|
||||
silence_s = (len(board.buffer) // 2 - after_wake[-1].end) / rate
|
||||
if silence_s * 1000 >= self._wake.end_silence_ms:
|
||||
return self._finish(device, board, "fin de phrase")
|
||||
return None
|
||||
|
||||
def _finish(self, device: str, board: _Board, reason: str) -> SessionResult | None:
|
||||
"""La commande devient une session ordinaire, écrite en WAV comme celles du bouton."""
|
||||
audio = AudioBuffer(self._audio, self._wake.max_command_s + 1)
|
||||
audio.append(bytes(board.buffer))
|
||||
stamp = board.started_at.strftime("%Y%m%d-%H%M%S")
|
||||
path = self._recordings_dir / f"{device}_{stamp}_{board.session_id}.wav"
|
||||
audio.write_wav(path)
|
||||
result = SessionResult(device=device, session_id=board.session_id, outcome=Outcome.COMPLETE,
|
||||
path=path, duration_s=audio.duration_s, chunks_received=board.chunks,
|
||||
chunks_announced=None, levels=audio.levels())
|
||||
logger.info("%s : commande %s finie (%s), %.2f s apres « %s » -> %s", device, board.session_id,
|
||||
reason, audio.duration_s, board.word, path.name)
|
||||
self._send(device, {"cmd": "capture", "active": False})
|
||||
board.mode = Mode.ANSWER
|
||||
board.buffer = bytearray()
|
||||
board.answer_until = self._clock() + self._wake.cooldown_s
|
||||
return result
|
||||
|
||||
def _standby(self, board: _Board) -> None:
|
||||
board.mode = Mode.STANDBY
|
||||
board.buffer = bytearray()
|
||||
board.preroll.clear()
|
||||
board.detector.reset() # sinon l'historique du mot précédent pourrait redéclencher
|
||||
|
||||
def _send(self, device: str, message: dict[str, object]) -> None:
|
||||
self._link.publish(device, "control", json.dumps(message).encode(), 1)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Domotique : de la phrase à la commande MQTT (étape 11).
|
||||
|
||||
Une commande tient en trois mots : une ACTION (allumer, éteindre, ouvrir,
|
||||
fermer), un APPAREIL (lumière, volets, porte) et une PIÈCE (salon, cuisine…).
|
||||
L'analyse cherche ces mots dans la phrase transcrite, sans tenir compte des
|
||||
accents ni de la conjugaison exacte : Whisper écrit « fermé » pour « fermez ».
|
||||
|
||||
Sans pièce, c'est celle de la carte qui a entendu la phrase (« ici »).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from voice_server.settings import HomeSettings
|
||||
from voice_server.text_normalize import normalize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mots normalisés (minuscules, sans accents) → identifiant. Identifiants en anglais :
|
||||
# ce sont eux qui voyagent dans les topics et le JSON, comme dans le firmware.
|
||||
ACTION_WORDS = {
|
||||
"on": ("allume", "allumer", "allumez", "allumes", "rallume", "rallumer"),
|
||||
"off": ("eteins", "eteint", "eteindre", "eteignez", "coupe", "couper", "coupez"),
|
||||
"open": ("ouvre", "ouvrir", "ouvrez", "ouvres", "monte", "monter", "montez", "leve", "lever", "levez",
|
||||
"remonte", "remonter"),
|
||||
"close": ("ferme", "fermer", "fermez", "fermes", "baisse", "baisser", "baissez", "descends",
|
||||
"descendre", "descendez"),
|
||||
}
|
||||
DEVICE_WORDS = {
|
||||
"light": ("lumiere", "lumieres", "lampe", "lampes", "eclairage", "plafonnier"),
|
||||
"shutter": ("volet", "volets", "store", "stores"),
|
||||
"door": ("porte", "portes"),
|
||||
}
|
||||
ALLOWED = {"light": ("on", "off"), "shutter": ("open", "close"), "door": ("open", "close")}
|
||||
|
||||
DEVICE_PHRASE = {"light": "la lumière", "shutter": "les volets", "door": "la porte"}
|
||||
FIRST_PERSON = {"on": "J'allume", "off": "J'éteins", "open": "J'ouvre", "close": "Je ferme"}
|
||||
INFINITIVE = {"on": "allumer", "off": "éteindre", "open": "ouvrir", "close": "fermer"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceIntent:
|
||||
"""Ce que la phrase demande ; un champ à None n'a pas été trouvé."""
|
||||
|
||||
action: str | None # on | off | open | close
|
||||
device: str | None # light | shutter | door
|
||||
room: str | None # pièce de config.yaml, telle qu'écrite (« salle de bain »)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceCommand:
|
||||
"""Commande complète et valide, prête à publier."""
|
||||
|
||||
action: str
|
||||
device: str
|
||||
room: str
|
||||
|
||||
|
||||
def slug(room: str) -> str:
|
||||
"""Pièce utilisable dans un topic : « salle de bain » → « salle-de-bain », « entrée » → « entree »."""
|
||||
ascii_room = unicodedata.normalize("NFKD", room).encode("ascii", "ignore").decode()
|
||||
return re.sub(r"[^a-z0-9]+", "-", ascii_room.lower()).strip("-")
|
||||
|
||||
|
||||
def _first(words: list[str], vocabulary: dict[str, tuple[str, ...]]) -> str | None:
|
||||
"""Premier identifiant dont un mot apparaît dans la phrase (dans l'ordre de la phrase)."""
|
||||
for word in words:
|
||||
for key, forms in vocabulary.items():
|
||||
if word in forms:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def parse_intent(text: str, rooms: list[str]) -> DeviceIntent | None:
|
||||
"""Action, appareil et pièce de la phrase ; None si elle ne parle pas de domotique."""
|
||||
words = normalize(text).split()
|
||||
action = _first(words, ACTION_WORDS)
|
||||
device = _first(words, DEVICE_WORDS)
|
||||
if action is None and device is None:
|
||||
return None
|
||||
padded = f" {' '.join(words)} "
|
||||
# Pièces les plus longues d'abord : « salle de bain » avant un éventuel « bain ».
|
||||
room = next((name for name in sorted(rooms, key=len, reverse=True)
|
||||
if f" {normalize(name)} " in padded), None)
|
||||
return DeviceIntent(action=action, device=device, room=room)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""Réponse à dire, et commande à exécuter si la demande est complète."""
|
||||
|
||||
answer: str
|
||||
command: DeviceCommand | None
|
||||
|
||||
|
||||
def announce(command: DeviceCommand, home: HomeSettings) -> str:
|
||||
"""Ce que dit l'assistant en exécutant : « Je ferme les volets du salon. »"""
|
||||
return f"{FIRST_PERSON[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}."
|
||||
|
||||
|
||||
def request(command: DeviceCommand, home: HomeSettings) -> str:
|
||||
"""La demande à l'infinitif : « ouvrir la porte du salon »."""
|
||||
return f"{INFINITIVE[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}"
|
||||
|
||||
|
||||
def decide(intent: DeviceIntent, home: HomeSettings, board: str) -> Decision:
|
||||
"""Complète l'intention (appareil implicite, pièce de la carte) ou dit ce qui manque."""
|
||||
action, device = intent.action, intent.device
|
||||
if device is None and action in ("on", "off"):
|
||||
device = "light" # « allume », « éteins » : c'est de la lumière qu'on parle
|
||||
if action is None:
|
||||
return Decision(f"Que dois-je faire avec {DEVICE_PHRASE[device or 'light']} ?", None)
|
||||
if device is None:
|
||||
return Decision(f"Que dois-je {INFINITIVE[action]} ?", None)
|
||||
if action not in ALLOWED[device]:
|
||||
return Decision(f"Je ne sais pas {INFINITIVE[action]} {DEVICE_PHRASE[device]}.", None)
|
||||
room = intent.room or home.boards.get(board)
|
||||
if room is None or room not in home.rooms:
|
||||
return Decision("Dans quelle pièce ?", None)
|
||||
command = DeviceCommand(action=action, device=device, room=room)
|
||||
return Decision(announce(command, home), command)
|
||||
|
||||
|
||||
class TopicPublisher(Protocol):
|
||||
"""Ce que la domotique attend du lien MQTT."""
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
|
||||
|
||||
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None: ...
|
||||
|
||||
|
||||
class HomeController:
|
||||
"""Publie les commandes : pour la domotique, et pour la carte si c'est sa pièce."""
|
||||
|
||||
def __init__(self, home: HomeSettings, link: TopicPublisher) -> None:
|
||||
self._home = home
|
||||
self._link = link
|
||||
|
||||
def execute(self, board: str, command: DeviceCommand, speaker: str | None, session: str) -> list[str]:
|
||||
"""Publie la commande ; rend les topics utilisés (journal, tests).
|
||||
|
||||
home/<pièce>/<appareil>/set, QoS 1, non retenu : une commande rejouée à un
|
||||
abonné qui arrive plus tard ferait bouger un volet sans que personne l'ait demandé.
|
||||
"""
|
||||
topic = f"{self._home.topic_prefix}/{slug(command.room)}/{command.device}/set"
|
||||
payload = {"action": command.action, "by": speaker, "board": board, "session": session}
|
||||
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
|
||||
topics = [topic]
|
||||
if self._home.boards.get(board) == command.room:
|
||||
local = {"cmd": "device", "device": command.device, "action": command.action}
|
||||
self._link.publish(board, "control", json.dumps(local).encode(), 1)
|
||||
topics.append(f"{board}/control")
|
||||
logger.info("%s : commande %s %s %s -> %s", board, command.action, command.device,
|
||||
command.room, ", ".join(topics))
|
||||
return topics
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Machines Linux du réseau : de la phrase à l'agent de la machine (étape 15b).
|
||||
|
||||
Chaque machine fait tourner un AGENT (agent/voice_agent.py) : un petit client MQTT
|
||||
qui n'exécute que les actions de SA liste, définie sur la machine elle-même. Le
|
||||
serveur n'envoie qu'un NOM d'action (« shutdown »), jamais une commande shell :
|
||||
même un message forgé ne peut rien faire d'autre que ce que la machine a permis.
|
||||
|
||||
agent/<machine>/state agent → tous {"status":"online","actions":[...]}, retenu + testament
|
||||
agent/<machine>/command serveur → agent {"action":"shutdown","id":...,"by":"Denis"}
|
||||
agent/<machine>/result agent → serveur {"action":...,"ok":true,"code":0,"output":"..."}
|
||||
|
||||
« Allume le PC » est à part : éteinte, la machine n'a plus d'agent. Le serveur
|
||||
envoie alors un paquet Wake-on-LAN (réveil par le réseau) à son adresse MAC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from voice_server.home_control import DeviceCommand, TopicPublisher
|
||||
from voice_server.settings import MachinesSettings
|
||||
from voice_server.text_normalize import normalize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WAKE = "wake" # action du serveur lui-même (Wake-on-LAN), pas de l'agent
|
||||
WOL_PORT = 9 # port « discard » : l'usage pour le paquet magique
|
||||
MACHINE_DEVICE = "machine" # clé des droits : access.rules.machine.<action>
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineIntent:
|
||||
"""Ce que la phrase demande à une machine ; action None : pas trouvée."""
|
||||
|
||||
machine: str # identifiant de l'agent (pc-bureau)
|
||||
action: str | None # shutdown, reboot, wake...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineCommand:
|
||||
"""Commande complète pour une machine, avec ses phrases (fixées à la décision)."""
|
||||
|
||||
action: str
|
||||
machine: str
|
||||
said: str # à l'exécution : « J'éteins le PC du bureau. »
|
||||
asked: str # refus, confirmation : « éteindre le PC du bureau »
|
||||
device: str = MACHINE_DEVICE # pour les droits, comme DeviceCommand.device
|
||||
|
||||
|
||||
# Toute commande que l'assistant peut produire : domotique (étape 11) ou machine (15b).
|
||||
Command = DeviceCommand | MachineCommand
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineDecision:
|
||||
"""Réponse à dire, et commande à exécuter si la demande est possible."""
|
||||
|
||||
answer: str
|
||||
command: MachineCommand | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentStatus:
|
||||
"""Dernier état publié par un agent (retenu par le broker)."""
|
||||
|
||||
online: bool
|
||||
actions: tuple[str, ...] # actions que la machine accepte
|
||||
|
||||
|
||||
def agent_status(payload: bytes) -> AgentStatus | None:
|
||||
"""{"status":"online","actions":[...]} → AgentStatus ; None si illisible."""
|
||||
try:
|
||||
state: dict[str, Any] = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(state, dict): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
return None
|
||||
actions = state.get("actions") or []
|
||||
return AgentStatus(online=state.get("status") == "online",
|
||||
actions=tuple(str(action) for action in actions))
|
||||
|
||||
|
||||
def capitalized(phrase: str) -> str:
|
||||
"""« le PC du bureau » → « Le PC du bureau » (en début de phrase)."""
|
||||
return phrase[:1].upper() + phrase[1:]
|
||||
|
||||
|
||||
def parse_machine_intent(text: str, settings: MachinesSettings) -> MachineIntent | None:
|
||||
"""Machine et action de la phrase ; None si elle ne nomme aucune machine."""
|
||||
words = normalize(text).split()
|
||||
padded = f" {' '.join(words)} "
|
||||
# Noms les plus longs d'abord : « ordinateur du bureau » avant un éventuel « ordinateur ».
|
||||
names = sorted(((name, machine) for machine, spec in settings.machines.items() for name in spec.names),
|
||||
key=lambda item: len(item[0]), reverse=True)
|
||||
machine = next((machine for name, machine in names if f" {name} " in padded), None)
|
||||
if machine is None:
|
||||
return None
|
||||
action = next((action for word in words for action, spec in settings.actions.items()
|
||||
if word in spec.words), None)
|
||||
return MachineIntent(machine=machine, action=action)
|
||||
|
||||
|
||||
def decide_machine(intent: MachineIntent, settings: MachinesSettings,
|
||||
status: AgentStatus | None) -> MachineDecision:
|
||||
"""Vérifie que la demande est possible, d'après l'état annoncé par l'agent."""
|
||||
machine = settings.machines[intent.machine]
|
||||
name = machine.name
|
||||
if intent.action is None:
|
||||
return MachineDecision(f"Que dois-je faire avec {name} ?", None)
|
||||
spec = settings.actions[intent.action]
|
||||
online = status is not None and status.online
|
||||
if intent.action == WAKE:
|
||||
if online:
|
||||
return MachineDecision(f"{capitalized(name)} est déjà en marche.", None)
|
||||
if not machine.mac:
|
||||
return MachineDecision(f"Je ne sais pas {spec.verb} {name} à distance.", None)
|
||||
elif not online:
|
||||
# Tournures sans genre : le nom peut être « la tour du garage ».
|
||||
return MachineDecision(f"{capitalized(name)} ne répond pas : la machine est éteinte, "
|
||||
"ou son agent est arrêté.", None)
|
||||
elif status is not None and intent.action not in status.actions:
|
||||
return MachineDecision(f"Je ne peux pas {spec.verb} {name} : son agent ne le permet pas.", None)
|
||||
command = MachineCommand(action=intent.action, machine=intent.machine,
|
||||
said=f"{spec.does} {name}.", asked=f"{spec.verb} {name}")
|
||||
return MachineDecision(command.said, command)
|
||||
|
||||
|
||||
def magic_packet(mac: str) -> bytes:
|
||||
"""Paquet magique Wake-on-LAN : 6 octets 0xFF, puis 16 fois l'adresse MAC (102 octets).
|
||||
|
||||
Raises:
|
||||
ValueError: adresse MAC mal écrite.
|
||||
"""
|
||||
if not re.fullmatch(r"([0-9a-fA-F]{2}[:-]?){5}[0-9a-fA-F]{2}", mac.strip()):
|
||||
raise ValueError(f"adresse MAC invalide : « {mac} » (attendu : aa:bb:cc:dd:ee:ff)")
|
||||
return b"\xff" * 6 + bytes.fromhex(re.sub(r"[:-]", "", mac.strip())) * 16
|
||||
|
||||
|
||||
def send_broadcast(packet: bytes, address: str) -> None:
|
||||
"""Envoie un datagramme UDP en diffusion sur le réseau local."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.sendto(packet, (address, WOL_PORT))
|
||||
|
||||
|
||||
class MachineController:
|
||||
"""Suit l'état des agents, interprète les phrases qui nomment une machine, publie les commandes.
|
||||
|
||||
on_state et on_result sont appelés depuis le fil réseau ; interpret et execute
|
||||
depuis le fil de travail : un verrou protège les états.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: MachinesSettings, link: TopicPublisher,
|
||||
send_udp: Callable[[bytes, str], None] = send_broadcast) -> None:
|
||||
"""
|
||||
Args:
|
||||
send_udp: envoi du paquet Wake-on-LAN, remplaçable dans les tests.
|
||||
"""
|
||||
self._settings = settings
|
||||
self._link = link
|
||||
self._send_udp = send_udp
|
||||
self._lock = threading.Lock()
|
||||
self._states: dict[str, AgentStatus] = {}
|
||||
|
||||
# ------------------------------------------------------------ messages des agents
|
||||
|
||||
def on_state(self, topic: str, payload: bytes) -> None:
|
||||
"""État d'un agent (agent/<machine>/state), retenu : reçu dès l'abonnement."""
|
||||
machine = self._machine_of(topic)
|
||||
status = agent_status(payload)
|
||||
if machine is None or status is None:
|
||||
return
|
||||
with self._lock:
|
||||
previous = self._states.get(machine)
|
||||
self._states[machine] = status
|
||||
if previous == status:
|
||||
return
|
||||
if machine not in self._settings.machines:
|
||||
logger.warning("agent %s : absent de config.yaml (machines.list), on ne peut pas le nommer", machine)
|
||||
elif status.online:
|
||||
unknown = [action for action in status.actions if action not in self._settings.actions]
|
||||
logger.info("agent %s : en ligne, actions %s%s", machine, ", ".join(status.actions) or "aucune",
|
||||
f" (sans mots dans config.yaml : {', '.join(unknown)})" if unknown else "")
|
||||
else:
|
||||
logger.warning("agent %s : hors ligne", machine)
|
||||
|
||||
def on_result(self, topic: str, payload: bytes) -> None:
|
||||
"""Compte rendu d'exécution (agent/<machine>/result) : pour le journal."""
|
||||
machine = self._machine_of(topic)
|
||||
try:
|
||||
result: dict[str, Any] = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
log = logger.info if result.get("ok") else logger.warning
|
||||
log("agent %s : %s %s (code %s)%s", machine, result.get("action"),
|
||||
"executee" if result.get("ok") else "ECHOUEE", result.get("code"),
|
||||
f" : {result['output']}" if result.get("output") else "")
|
||||
|
||||
def status(self, machine: str) -> AgentStatus | None:
|
||||
"""Dernier état connu de l'agent ; None s'il ne s'est jamais annoncé."""
|
||||
with self._lock:
|
||||
return self._states.get(machine)
|
||||
|
||||
# ------------------------------------------------------------ phrases et commandes
|
||||
|
||||
def interpret(self, text: str) -> MachineDecision | None:
|
||||
"""Réponse à une phrase qui nomme une machine ; None si elle n'en nomme aucune."""
|
||||
intent = parse_machine_intent(text, self._settings)
|
||||
if intent is None:
|
||||
return None
|
||||
return decide_machine(intent, self._settings, self.status(intent.machine))
|
||||
|
||||
def execute(self, board: str, command: MachineCommand, speaker: str | None, session: str) -> list[str]:
|
||||
"""Réveil par le réseau, ou commande à l'agent ; rend les destinations (journal, tests)."""
|
||||
if command.action == WAKE:
|
||||
mac = self._settings.machines[command.machine].mac
|
||||
self._send_udp(magic_packet(mac), self._settings.broadcast)
|
||||
logger.info("%s : Wake-on-LAN %s (%s) -> %s:%d", board, command.machine, mac,
|
||||
self._settings.broadcast, WOL_PORT)
|
||||
return [f"wol:{mac}"]
|
||||
# QoS 1, non retenu, et l'agent se connecte en session propre : une commande
|
||||
# n'est jamais rejouée plus tard (un « éteins » qui attendrait le prochain démarrage...).
|
||||
topic = f"{self._settings.topic_prefix}/{command.machine}/command"
|
||||
payload = {"action": command.action, "id": session, "by": speaker, "board": board}
|
||||
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
|
||||
logger.info("%s : commande %s %s -> %s", board, command.action, command.machine, topic)
|
||||
return [topic]
|
||||
|
||||
def _machine_of(self, topic: str) -> str | None:
|
||||
parts = topic.split("/")
|
||||
if len(parts) != 3 or parts[0] != self._settings.topic_prefix or not parts[1]:
|
||||
return None
|
||||
return parts[1]
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Connexion du serveur au broker MQTT (paho-mqtt 2.x)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
from voice_server.settings import MqttSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rappel : (identifiant de la carte, payload brut)
|
||||
Handler = Callable[[str, bytes], Any]
|
||||
# Rappel d'un topic hors voice/ (agents Linux, étape 15b) : (topic complet, payload brut)
|
||||
TopicHandler = Callable[[str, bytes], Any]
|
||||
|
||||
ONLINE = json.dumps({"status": "online"}).encode()
|
||||
OFFLINE = json.dumps({"status": "offline"}).encode()
|
||||
REFUSED_CREDENTIALS = (134, 135) # « Bad user name or password », « Not authorized » (MQTT 5 / 3.1.1)
|
||||
STOP_PUBLISH_TIMEOUT_S = 2.0
|
||||
|
||||
|
||||
def presence_topic(prefix: str) -> str:
|
||||
"""Topic de présence du serveur, lu par les cartes (étape 15).
|
||||
|
||||
Hors du motif <préfixe>/<carte>/<suffixe> auquel le serveur s'abonne
|
||||
(voice/+/state...) : sinon il se prendrait lui-même pour une carte.
|
||||
|
||||
>>> presence_topic("voice")
|
||||
'voice/server/status'
|
||||
"""
|
||||
return f"{prefix}/server/status"
|
||||
|
||||
|
||||
def refusal_hint(reason: ReasonCode, client_id: str) -> str:
|
||||
"""Explication d'une connexion refusée, pour le journal."""
|
||||
if reason.value in REFUSED_CREDENTIALS:
|
||||
return (f"connexion refusee par le broker : {reason}. Verifiez MQTT_USERNAME et MQTT_PASSWORD "
|
||||
"dans server/.env, et que ce compte existe (make mqtt-user NAME=voice-server, "
|
||||
"puis make mosquitto-docker)")
|
||||
return f"connexion refusee par le broker : {reason} (client « {client_id} »)"
|
||||
|
||||
|
||||
def split_topic(topic: str, prefix: str) -> tuple[str, str] | None:
|
||||
"""Découpe « <préfixe>/<carte>/<suffixe> » en (carte, suffixe), sinon None.
|
||||
|
||||
>>> split_topic("voice/esp32-01/audio/in", "voice")
|
||||
('esp32-01', 'audio/in')
|
||||
"""
|
||||
parts = topic.split("/", 2)
|
||||
if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]:
|
||||
return None
|
||||
return parts[1], parts[2]
|
||||
|
||||
|
||||
class MqttLink:
|
||||
"""Abonne le serveur aux topics de toutes les cartes et répartit les messages.
|
||||
|
||||
Le réseau tourne dans un fil créé par paho (loop_start) : les rappels sont
|
||||
donc appelés depuis ce fil. Reconnexion automatique, abonnements refaits à
|
||||
chaque connexion.
|
||||
|
||||
Avec presence=True (le vrai serveur seulement, jamais un outil de test),
|
||||
le lien publie « online » retenu sur voice/server/status et confie au broker
|
||||
un testament « offline » : si le serveur meurt, les cartes le savent et
|
||||
coupent leur micro.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: MqttSettings, presence: bool = False) -> None:
|
||||
"""
|
||||
Args:
|
||||
presence: annonce la présence du serveur (étape 15) ; False pour les outils,
|
||||
dont l'arrêt ne doit pas faire croire aux cartes que le serveur est parti.
|
||||
"""
|
||||
self._settings = settings
|
||||
self._presence = presence_topic(settings.topic_prefix) if presence else None
|
||||
self._handlers: dict[str, Handler] = {}
|
||||
self._topic_handlers: dict[str, TopicHandler] = {}
|
||||
self._connected = threading.Event()
|
||||
self._client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=settings.client_id,
|
||||
)
|
||||
if settings.username:
|
||||
self._client.username_pw_set(settings.username, settings.password)
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._client.on_message = self._on_message
|
||||
self._client.reconnect_delay_set(min_delay=1, max_delay=10)
|
||||
if self._presence is not None:
|
||||
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
|
||||
# (plantage, coupure) ; retenu, pour une carte qui se connecte plus tard.
|
||||
self._client.will_set(self._presence, OFFLINE, qos=1, retain=True)
|
||||
|
||||
def on(self, suffix: str, handler: Handler) -> None:
|
||||
"""Associe un rappel à un suffixe : « event », « audio/in », « state »..."""
|
||||
self._handlers[suffix] = handler
|
||||
|
||||
def on_topic(self, pattern: str, handler: TopicHandler) -> None:
|
||||
"""Associe un rappel à un filtre complet hors voice/ : « agent/+/state » (étape 15b), QoS 1."""
|
||||
self._topic_handlers[pattern] = handler
|
||||
|
||||
def start(self) -> None:
|
||||
"""Lance la connexion (asynchrone) et le fil réseau."""
|
||||
logger.info("connexion au broker %s:%d", self._settings.host, self._settings.port)
|
||||
self._client.connect_async(self._settings.host, self._settings.port,
|
||||
keepalive=self._settings.keepalive)
|
||||
self._client.loop_start()
|
||||
|
||||
def wait_connected(self, timeout: float) -> bool:
|
||||
"""Attend la première connexion au broker ; False si le délai expire."""
|
||||
return self._connected.wait(timeout)
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
"""Publie vers une carte : <préfixe>/<carte>/<suffixe>. Utilisable depuis tout fil."""
|
||||
topic = f"{self._settings.topic_prefix}/{device}/{suffix}"
|
||||
self._client.publish(topic, payload, qos=qos)
|
||||
|
||||
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
|
||||
"""Publie sur un topic complet, hors de voice/ : commandes domotiques home/... (étape 11)."""
|
||||
self._client.publish(topic, payload, qos=qos)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Déconnexion propre et arrêt du fil réseau."""
|
||||
if self._presence is not None and self._connected.is_set():
|
||||
# Une déconnexion propre ne déclenche PAS le testament : on annonce le départ.
|
||||
info = self._client.publish(self._presence, OFFLINE, qos=1, retain=True)
|
||||
try:
|
||||
info.wait_for_publish(STOP_PUBLISH_TIMEOUT_S) # ne lève rien si le délai expire
|
||||
except (RuntimeError, ValueError):
|
||||
pass
|
||||
if not info.is_published():
|
||||
logger.warning("depart du serveur non annonce aux cartes (broker injoignable)")
|
||||
self._client.disconnect()
|
||||
self._client.loop_stop()
|
||||
|
||||
# ------------------------------------------------------------ rappels paho
|
||||
|
||||
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags,
|
||||
reason_code: ReasonCode, properties: Properties | None) -> None:
|
||||
if reason_code.is_failure:
|
||||
logger.error("%s", refusal_hint(reason_code, self._settings.client_id))
|
||||
return
|
||||
logger.info("connecte au broker")
|
||||
self._connected.set()
|
||||
if self._presence is not None:
|
||||
# À chaque connexion : un broker redémarré (sans persistance) a oublié l'état retenu.
|
||||
client.publish(self._presence, ONLINE, qos=1, retain=True)
|
||||
logger.info("presence annoncee sur %s", self._presence)
|
||||
# Abonnement refait à chaque connexion : une reconnexion repart de zéro.
|
||||
for suffix in self._handlers:
|
||||
topic = f"{self._settings.topic_prefix}/+/{suffix}"
|
||||
qos = 0 if suffix.startswith("audio") else 1
|
||||
client.subscribe(topic, qos=qos)
|
||||
logger.info("abonne a %s (QoS %d)", topic, qos)
|
||||
for pattern in self._topic_handlers:
|
||||
client.subscribe(pattern, qos=1)
|
||||
logger.info("abonne a %s (QoS 1)", pattern)
|
||||
|
||||
def _on_disconnect(self, client: mqtt.Client, userdata: Any,
|
||||
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
|
||||
properties: Properties | None) -> None:
|
||||
self._connected.clear()
|
||||
if reason_code.is_failure:
|
||||
# Cause fréquente en TP : un second programme avec le même identifiant
|
||||
# client ; le broker n'en garde qu'un et les deux s'éjectent en boucle.
|
||||
logger.warning("connexion au broker perdue (%s), reconnexion... Si cela se repete, "
|
||||
"un autre client utilise-t-il l'identifiant « %s » ?",
|
||||
reason_code, self._settings.client_id)
|
||||
|
||||
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
|
||||
parsed = split_topic(message.topic, self._settings.topic_prefix)
|
||||
try:
|
||||
if parsed is not None:
|
||||
handler = self._handlers.get(parsed[1])
|
||||
if handler is not None:
|
||||
handler(parsed[0], message.payload)
|
||||
return
|
||||
for pattern, topic_handler in self._topic_handlers.items():
|
||||
if mqtt.topic_matches_sub(pattern, message.topic):
|
||||
topic_handler(message.topic, message.payload)
|
||||
except Exception: # un message fautif ne doit jamais arrêter le serveur
|
||||
logger.exception("erreur en traitant %s", message.topic)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Reconstitution des sessions de parole reçues des ESP32 : START, chunks, END."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from voice_server.audio_buffer import AudioBuffer, Levels
|
||||
from voice_server.settings import AudioSettings, SessionSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Outcome(Enum):
|
||||
"""Façon dont une session s'est terminée."""
|
||||
|
||||
COMPLETE = "complete" # END reçu, tous les chunks annoncés sont arrivés
|
||||
LOST_CHUNKS = "lost_chunks" # END reçu, mais des chunks manquent (QoS 0 : perte réseau)
|
||||
TIMEOUT = "timeout" # jamais de END : l'ESP32 a disparu en cours de session
|
||||
INTERRUPTED = "interrupted" # un nouveau START est arrivé avant le END
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionResult:
|
||||
"""Bilan d'une session terminée."""
|
||||
|
||||
device: str
|
||||
session_id: str
|
||||
outcome: Outcome
|
||||
path: Path
|
||||
duration_s: float
|
||||
chunks_received: int
|
||||
chunks_announced: int | None # None si aucun END n'a été reçu
|
||||
levels: Levels
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Session:
|
||||
"""Session en cours de réception."""
|
||||
|
||||
device: str
|
||||
session_id: str
|
||||
buffer: AudioBuffer
|
||||
started_at: datetime
|
||||
last_activity: float
|
||||
chunks_received: int = 0
|
||||
announced: int | None = None # nombre de chunks annoncé par le END
|
||||
end_seen_at: float | None = None
|
||||
end_report: dict[str, Any] = field(default_factory=dict[str, Any])
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Assemble les sessions de chaque carte et les écrit en WAV.
|
||||
|
||||
Appelée depuis le fil réseau (messages MQTT) et depuis le fil principal
|
||||
(sweep) : un verrou protège l'état.
|
||||
|
||||
Les messages audio sont en QoS 0 : ils peuvent se perdre. Le END annonce le
|
||||
nombre de chunks envoyés ; la comparaison avec le nombre reçu mesure la perte.
|
||||
Et comme le START et le END peuvent eux-mêmes se perdre (l'ESP32 ne sait
|
||||
publier qu'en QoS 0), une session sans END est close après un délai d'inactivité.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio: AudioSettings,
|
||||
sessions: SessionSettings,
|
||||
recordings_dir: Path,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
on_finished: Callable[[SessionResult], None] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
on_finished: appelé pour chaque session terminée, depuis le fil qui la
|
||||
termine (réseau ou principal) : il doit rendre la main aussitôt.
|
||||
"""
|
||||
self._on_finished = on_finished
|
||||
self._audio = audio
|
||||
self._settings = sessions
|
||||
self._recordings_dir = recordings_dir
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._active: dict[str, _Session] = {}
|
||||
self.orphan_chunks = 0 # chunks reçus hors de toute session
|
||||
|
||||
# ------------------------------------------------------------ messages reçus
|
||||
|
||||
def on_event(self, device: str, payload: bytes) -> list[SessionResult]:
|
||||
"""Traite un message JSON de voice/<device>/event."""
|
||||
try:
|
||||
event: dict[str, Any] = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
logger.warning("%s : evenement illisible : %r", device, payload[:80])
|
||||
return []
|
||||
|
||||
kind = event.get("event")
|
||||
with self._lock:
|
||||
if kind == "start":
|
||||
return self._start(device, event)
|
||||
if kind == "end":
|
||||
return self._end(device, event)
|
||||
if kind == "boot":
|
||||
# Tout redémarrage autre qu'une mise sous tension mérite l'attention :
|
||||
# brownout = alimentation trop faible, panic = plantage, watchdog = blocage.
|
||||
reason = event.get("reason")
|
||||
log = logger.info if reason in ("poweron", "external", "software") else logger.warning
|
||||
log("%s : demarrage de la carte, cause : %s", device, reason)
|
||||
elif kind == "device":
|
||||
# Étape 11 : la carte confirme qu'elle a exécuté une commande de sa pièce.
|
||||
log = logger.info if event.get("ok") else logger.warning
|
||||
log("%s : commande %s %s %s, etat %s", device, event.get("device"), event.get("action"),
|
||||
"executee" if event.get("ok") else "REFUSEE", event.get("state"))
|
||||
elif kind == "played":
|
||||
# Étape 12 : « underruns » compte les réamorçages (réserve épuisée, pause propre) ;
|
||||
# la marge minimale dit combien de retard réseau restait absorbable.
|
||||
log = logger.warning if event.get("underruns") else logger.info
|
||||
log("%s : lecture %s jouee, chunks %s/%s, reamorcages %s %s, premier son a %s ms, marge mini %s ms",
|
||||
device, event.get("session"), event.get("chunks"), event.get("announced"),
|
||||
event.get("underruns"), event.get("gaps_ms") or "", event.get("start_ms"),
|
||||
event.get("min_margin_ms"))
|
||||
else:
|
||||
logger.info("%s : evenement %s", device, event)
|
||||
return []
|
||||
|
||||
def on_audio(self, device: str, payload: bytes) -> None:
|
||||
"""Traite un chunk PCM de voice/<device>/audio/in."""
|
||||
with self._lock:
|
||||
session = self._active.get(device)
|
||||
if session is None:
|
||||
self.orphan_chunks += 1
|
||||
return
|
||||
if len(payload) % 2 != 0:
|
||||
logger.warning("%s : chunk de %d octets ignore (impair)", device, len(payload))
|
||||
return
|
||||
session.buffer.append(payload)
|
||||
session.chunks_received += 1
|
||||
session.last_activity = self._clock()
|
||||
|
||||
def sweep(self) -> list[SessionResult]:
|
||||
"""Clôt les sessions dont le END est passé (délai de grâce) ou restées muettes."""
|
||||
now = self._clock()
|
||||
results: list[SessionResult] = []
|
||||
with self._lock:
|
||||
for device, session in list(self._active.items()):
|
||||
if session.end_seen_at is not None:
|
||||
if now - session.end_seen_at >= self._settings.end_grace_s:
|
||||
results.append(self._finish(device, self._outcome_after_end(session)))
|
||||
elif now - session.last_activity >= self._settings.idle_timeout_s:
|
||||
results.append(self._finish(device, Outcome.TIMEOUT))
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------ interne
|
||||
|
||||
def _start(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
|
||||
results: list[SessionResult] = []
|
||||
previous = self._active.get(device)
|
||||
if previous is not None:
|
||||
# END déjà reçu, délai de grâce en cours : la session précédente est
|
||||
# finie, pas interrompue. Son bilan se juge sur ce que le END annonçait.
|
||||
if previous.end_seen_at is not None:
|
||||
outcome = self._outcome_after_end(previous)
|
||||
else:
|
||||
outcome = Outcome.INTERRUPTED
|
||||
results.append(self._finish(device, outcome))
|
||||
|
||||
announced = (event.get("rate"), event.get("bits"), event.get("channels"), event.get("codec"))
|
||||
expected = (self._audio.sample_rate, self._audio.bits, self._audio.channels, self._audio.codec)
|
||||
if announced != expected:
|
||||
logger.error("%s : session refusee, format %s au lieu de %s", device, announced, expected)
|
||||
return results
|
||||
|
||||
session_id = str(event.get("session", "inconnue"))
|
||||
self._active[device] = _Session(
|
||||
device=device,
|
||||
session_id=session_id,
|
||||
buffer=AudioBuffer(self._audio, self._settings.max_seconds),
|
||||
started_at=datetime.now(),
|
||||
last_activity=self._clock(),
|
||||
)
|
||||
logger.info("%s : session %s ouverte", device, session_id)
|
||||
return results
|
||||
|
||||
def _end(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
|
||||
session = self._active.get(device)
|
||||
if session is None or event.get("session") != session.session_id:
|
||||
logger.warning("%s : END sans session correspondante (%s)", device, event.get("session"))
|
||||
return []
|
||||
|
||||
session.announced = int(event.get("chunks", 0))
|
||||
session.end_report = event
|
||||
session.end_seen_at = self._clock()
|
||||
|
||||
# Tout est arrivé : inutile d'attendre le délai de grâce.
|
||||
if session.chunks_received >= session.announced:
|
||||
return [self._finish(device, Outcome.COMPLETE)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _outcome_after_end(session: _Session) -> Outcome:
|
||||
if session.announced is not None and session.chunks_received < session.announced:
|
||||
return Outcome.LOST_CHUNKS
|
||||
return Outcome.COMPLETE
|
||||
|
||||
def _finish(self, device: str, outcome: Outcome) -> SessionResult:
|
||||
session = self._active.pop(device)
|
||||
stamp = session.started_at.strftime("%Y%m%d-%H%M%S")
|
||||
path = self._recordings_dir / f"{device}_{stamp}_{session.session_id}.wav"
|
||||
session.buffer.write_wav(path)
|
||||
|
||||
result = SessionResult(
|
||||
device=device,
|
||||
session_id=session.session_id,
|
||||
outcome=outcome,
|
||||
path=path,
|
||||
duration_s=session.buffer.duration_s,
|
||||
chunks_received=session.chunks_received,
|
||||
chunks_announced=session.announced,
|
||||
levels=session.buffer.levels(),
|
||||
)
|
||||
self._log(result, session)
|
||||
if self._on_finished is not None:
|
||||
self._on_finished(result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _log(result: SessionResult, session: _Session) -> None:
|
||||
announced = "?" if result.chunks_announced is None else str(result.chunks_announced)
|
||||
logger.info(
|
||||
"%s : session %s %s : %s, %.2f s, chunks %d/%s, crete %.1f dBFS, RMS %.1f dBFS",
|
||||
result.device, result.session_id, result.outcome.value, result.path.name,
|
||||
result.duration_s, result.chunks_received, announced,
|
||||
result.levels.peak_dbfs, result.levels.rms_dbfs,
|
||||
)
|
||||
report = session.end_report
|
||||
if report.get("send_failures") or report.get("capture_overruns"):
|
||||
logger.warning(
|
||||
"%s : cote ESP32, %s echecs d'envoi et %s chunks perdus faute de place",
|
||||
result.device, report.get("send_failures"), report.get("capture_overruns"),
|
||||
)
|
||||
if result.outcome is Outcome.LOST_CHUNKS and result.chunks_announced:
|
||||
lost = result.chunks_announced - result.chunks_received
|
||||
logger.warning("%s : %d chunks perdus en route (%.0f %%) : signal Wi-Fi ?",
|
||||
result.device, lost, 100.0 * lost / result.chunks_announced)
|
||||
if session.buffer.truncated:
|
||||
logger.warning("%s : session tronquee a sa duree maximale", result.device)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Chargement de la configuration : config.yaml pour les réglages, .env pour les secrets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from voice_server.text_normalize import normalize
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioSettings:
|
||||
"""Format audio unique du système."""
|
||||
|
||||
sample_rate: int
|
||||
bits: int
|
||||
channels: int
|
||||
codec: str
|
||||
chunk_ms: int
|
||||
|
||||
@property
|
||||
def bytes_per_second(self) -> int:
|
||||
"""Débit en octets par seconde (32 000 pour 16 kHz, 16 bits, mono)."""
|
||||
return self.sample_rate * self.channels * self.bits // 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MqttSettings:
|
||||
"""Connexion au broker."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
keepalive: int
|
||||
client_id: str
|
||||
topic_prefix: str
|
||||
username: str | None
|
||||
password: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionSettings:
|
||||
"""Réception des sessions de parole."""
|
||||
|
||||
max_seconds: float
|
||||
idle_timeout_s: float
|
||||
end_grace_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SttSettings:
|
||||
"""Transcription par faster-whisper (étape 6)."""
|
||||
|
||||
model: str # tiny, base, small, medium…
|
||||
language: str
|
||||
compute_type: str # int8 : le plus rapide sur CPU
|
||||
beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis
|
||||
no_speech_threshold: float
|
||||
model_dir: Path # dossier du modèle téléchargé par « make models »
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TtsSettings:
|
||||
"""Synthèse vocale par Piper (étape 6)."""
|
||||
|
||||
voice: str
|
||||
length_scale: float # > 1 : débit plus lent
|
||||
model_path: Path # fichier .onnx, accompagné de son .onnx.json
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VadSettings:
|
||||
"""Détection d'activité vocale par Silero (étape 7)."""
|
||||
|
||||
threshold: float # probabilité de parole à partir de laquelle une fenêtre compte
|
||||
min_silence_ms: int # silence plus court : pause entre deux mots, la phrase continue
|
||||
min_speech_ms: int # parole plus courte : bruit (clic du bouton, toux), ignorée
|
||||
speech_pad_ms: int # marge gardée autour de chaque segment (attaques et fins de mots)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiarizationSettings:
|
||||
"""Diarisation par pyannote : qui parle quand (étape 8)."""
|
||||
|
||||
enabled: bool # false : pas de diarisation (Raspberry Pi 4, extra non installé)
|
||||
model: str # dépôt Hugging Face du pipeline
|
||||
max_speakers: int
|
||||
model_dir: Path # copie locale téléchargée par « make models »
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpeakerSettings:
|
||||
"""Identification du locuteur par empreinte vocale (étape 9). Probabiliste : jamais une sécurité."""
|
||||
|
||||
profiles_dir: Path # un fichier JSON par personne enrôlée (donnée biométrique)
|
||||
similarity_threshold: float # cosinus minimal avec un profil pour donner un nom
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HomeSettings:
|
||||
"""Domotique (étape 11) : pièces connues et pièce de chaque carte."""
|
||||
|
||||
topic_prefix: str # commandes publiées sur <préfixe>/<pièce>/<appareil>/set
|
||||
rooms: dict[str, str] # nom dit à voix haute → complément : « salon » → « du salon »
|
||||
boards: dict[str, str] # carte → pièce où elle se trouve (« ici », pièce par défaut)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WakeSettings:
|
||||
"""Mot de réveil et écoute mains libres (étape 13)."""
|
||||
|
||||
enabled: bool
|
||||
words: tuple[str, ...] # modèles openWakeWord : alexa, hey_jarvis, hey_mycroft
|
||||
threshold: float # score au-delà duquel le mot est reconnu
|
||||
thresholds: dict[str, float] # seuil propre à un mot, prioritaire sur threshold
|
||||
preroll_ms: int # audio gardé AVANT la détection (en mémoire seulement)
|
||||
end_silence_ms: int # silence qui clôt la commande
|
||||
no_speech_timeout_s: float # rien dit après le mot : on abandonne
|
||||
max_command_s: float # une commande plus longue est coupée
|
||||
cooldown_s: float # au plus, attente de la fin de la réponse avant de réécouter
|
||||
model_dir: Path # models/openwakeword/ (« make models »)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessSettings:
|
||||
"""Droits par profil (étape 14). La voix n'est pas une preuve : c'est une commodité."""
|
||||
|
||||
unknown_level: str # voix non reconnue
|
||||
default_level: str # profil enrôlé sans niveau dans users
|
||||
users: dict[str, str] # nom du profil → complet | standard | limite
|
||||
full_min_score: float # niveau complet seulement si l'identification est nette
|
||||
rules: dict[str, dict[str, str]] # appareil → action → niveau minimal
|
||||
confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter
|
||||
confirm_timeout_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineAction:
|
||||
"""Une action d'agent Linux telle qu'on la dit (étape 15b)."""
|
||||
|
||||
words: tuple[str, ...] # mots normalisés qui la désignent : « eteins », « arrete »
|
||||
does: str # début de la réponse : « J'éteins »
|
||||
verb: str # infinitif, pour les questions et refus : « éteindre »
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Machine:
|
||||
"""Une machine du réseau, commandée par son agent (étape 15b)."""
|
||||
|
||||
names: tuple[str, ...] # comment on l'appelle : « pc du bureau », « ordinateur du bureau »
|
||||
name: str # comment l'assistant la nomme : « le PC du bureau »
|
||||
mac: str # Wake-on-LAN (« allume ») ; vide : pas de réveil à distance
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachinesSettings:
|
||||
"""Machines Linux commandées par un agent MQTT (étape 15b)."""
|
||||
|
||||
topic_prefix: str # <préfixe>/<machine>/state, command, result
|
||||
broadcast: str # adresse de diffusion du paquet Wake-on-LAN
|
||||
actions: dict[str, MachineAction] # identifiant (shutdown...) → mots et phrases
|
||||
machines: dict[str, Machine] # identifiant de l'agent (pc-bureau) → noms
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Configuration complète du serveur."""
|
||||
|
||||
audio: AudioSettings
|
||||
mqtt: MqttSettings
|
||||
sessions: SessionSettings
|
||||
stt: SttSettings
|
||||
tts: TtsSettings
|
||||
vad: VadSettings
|
||||
diarization: DiarizationSettings
|
||||
speaker: SpeakerSettings
|
||||
home: HomeSettings
|
||||
wake: WakeSettings
|
||||
access: AccessSettings
|
||||
machines: MachinesSettings
|
||||
recordings_dir: Path
|
||||
models_dir: Path
|
||||
log_level: str
|
||||
log_format: str
|
||||
log_file: Path | None # copie tournante du journal (étape 15), None : écran seul
|
||||
|
||||
|
||||
def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
"""Rend une section du YAML, avec une erreur claire si elle manque."""
|
||||
value = data.get(name)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"config.yaml : section « {name} » absente ou invalide")
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
def _key(value: object) -> str:
|
||||
"""Clé YAML en texte ; YAML 1.1 lit on/off/yes/no nus comme des booléens : on les rend tels quels."""
|
||||
if isinstance(value, bool):
|
||||
return "on" if value else "off"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _machines(data: dict[str, Any]) -> MachinesSettings:
|
||||
"""Section « machines » (étape 15b), facultative : sans elle, aucune machine."""
|
||||
section: dict[str, Any] = data.get("machines") or {}
|
||||
actions = {
|
||||
str(action): MachineAction(
|
||||
words=tuple(normalize(str(word)) for word in spec["words"]),
|
||||
does=str(spec["does"]),
|
||||
verb=str(spec["verb"]),
|
||||
)
|
||||
for action, spec in dict(section.get("actions") or {}).items()
|
||||
}
|
||||
machines = {
|
||||
str(machine): Machine(
|
||||
names=tuple(normalize(str(name)) for name in spec["names"]),
|
||||
name=str(spec["name"]),
|
||||
mac=str(spec.get("mac") or ""),
|
||||
)
|
||||
for machine, spec in dict(section.get("list") or {}).items()
|
||||
}
|
||||
return MachinesSettings(
|
||||
topic_prefix=str(section.get("topic_prefix", "agent")),
|
||||
broadcast=str(section.get("broadcast", "255.255.255.255")),
|
||||
actions=actions,
|
||||
machines=machines,
|
||||
)
|
||||
|
||||
|
||||
def load_settings(path: Path) -> Settings:
|
||||
"""Lit config.yaml, puis le .env voisin s'il existe (identifiants MQTT)."""
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
data: dict[str, Any] = yaml.safe_load(stream) or {}
|
||||
|
||||
# Les secrets ne sont jamais dans config.yaml : ils viennent de l'environnement.
|
||||
load_dotenv(path.parent / ".env")
|
||||
|
||||
audio = _section(data, "audio")
|
||||
mqtt = _section(data, "mqtt")
|
||||
sessions = _section(data, "sessions")
|
||||
storage = _section(data, "storage")
|
||||
logging_cfg = _section(data, "logging")
|
||||
stt = _section(data, "stt")
|
||||
tts = _section(data, "tts")
|
||||
vad = _section(data, "vad")
|
||||
diarization = _section(data, "diarization")
|
||||
speaker = _section(data, "speaker")
|
||||
home = _section(data, "home")
|
||||
wake = _section(data, "wake")
|
||||
access = _section(data, "access")
|
||||
|
||||
base = path.parent # les chemins relatifs le sont par rapport à config.yaml
|
||||
models_dir = (base / str(storage["models_dir"])).resolve()
|
||||
return Settings(
|
||||
audio=AudioSettings(
|
||||
sample_rate=int(audio["sample_rate"]),
|
||||
bits=int(audio["bits"]),
|
||||
channels=int(audio["channels"]),
|
||||
codec=str(audio["codec"]),
|
||||
chunk_ms=int(audio["chunk_ms"]),
|
||||
),
|
||||
mqtt=MqttSettings(
|
||||
host=str(mqtt["host"]),
|
||||
port=int(mqtt["port"]),
|
||||
keepalive=int(mqtt["keepalive"]),
|
||||
client_id=str(mqtt["client_id"]),
|
||||
topic_prefix=str(mqtt["topic_prefix"]),
|
||||
username=os.environ.get("MQTT_USERNAME") or None,
|
||||
password=os.environ.get("MQTT_PASSWORD") or None,
|
||||
),
|
||||
sessions=SessionSettings(
|
||||
max_seconds=float(sessions["max_seconds"]),
|
||||
idle_timeout_s=float(sessions["idle_timeout_s"]),
|
||||
end_grace_s=float(sessions["end_grace_s"]),
|
||||
),
|
||||
stt=SttSettings(
|
||||
model=str(stt["model"]),
|
||||
language=str(stt["language"]),
|
||||
compute_type=str(stt["compute_type"]),
|
||||
beam_size=int(stt["beam_size"]),
|
||||
no_speech_threshold=float(stt["no_speech_threshold"]),
|
||||
model_dir=models_dir / "whisper" / str(stt["model"]),
|
||||
),
|
||||
tts=TtsSettings(
|
||||
voice=str(tts["voice"]),
|
||||
length_scale=float(tts["length_scale"]),
|
||||
model_path=models_dir / "piper" / f"{tts['voice']}.onnx",
|
||||
),
|
||||
vad=VadSettings(
|
||||
threshold=float(vad["threshold"]),
|
||||
min_silence_ms=int(vad["min_silence_ms"]),
|
||||
min_speech_ms=int(vad["min_speech_ms"]),
|
||||
speech_pad_ms=int(vad["speech_pad_ms"]),
|
||||
),
|
||||
diarization=DiarizationSettings(
|
||||
enabled=bool(diarization["enabled"]),
|
||||
model=str(diarization["model"]),
|
||||
max_speakers=int(diarization["max_speakers"]),
|
||||
# « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1
|
||||
model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1],
|
||||
),
|
||||
speaker=SpeakerSettings(
|
||||
profiles_dir=(base / str(speaker["profiles_dir"])).resolve(),
|
||||
similarity_threshold=float(speaker["similarity_threshold"]),
|
||||
),
|
||||
home=HomeSettings(
|
||||
topic_prefix=str(home["topic_prefix"]),
|
||||
rooms={str(name): str(complement) for name, complement in dict(home["rooms"]).items()},
|
||||
boards={str(board): str(room) for board, room in dict(home["boards"]).items()},
|
||||
),
|
||||
wake=WakeSettings(
|
||||
enabled=bool(wake["enabled"]),
|
||||
words=tuple(str(word) for word in wake["words"]),
|
||||
threshold=float(wake["threshold"]),
|
||||
thresholds={str(word): float(value) for word, value in dict(wake.get("thresholds") or {}).items()},
|
||||
preroll_ms=int(wake["preroll_ms"]),
|
||||
end_silence_ms=int(wake["end_silence_ms"]),
|
||||
no_speech_timeout_s=float(wake["no_speech_timeout_s"]),
|
||||
max_command_s=float(wake["max_command_s"]),
|
||||
cooldown_s=float(wake["cooldown_s"]),
|
||||
model_dir=models_dir / "openwakeword",
|
||||
),
|
||||
access=AccessSettings(
|
||||
unknown_level=str(access["unknown"]),
|
||||
default_level=str(access["default"]),
|
||||
users={str(name): str(level) for name, level in dict(access.get("users") or {}).items()},
|
||||
full_min_score=float(access["full_min_score"]),
|
||||
rules={str(device): {_key(action): str(level) for action, level in dict(actions).items()}
|
||||
for device, actions in dict(access["rules"]).items()},
|
||||
confirm=tuple(str(item) for item in access.get("confirm") or ()),
|
||||
confirm_timeout_s=float(access["confirm_timeout_s"]),
|
||||
),
|
||||
machines=_machines(data),
|
||||
recordings_dir=(base / str(storage["recordings_dir"])).resolve(),
|
||||
models_dir=models_dir,
|
||||
log_level=str(logging_cfg["level"]),
|
||||
log_format=str(logging_cfg["format"]),
|
||||
log_file=(base / str(logging_cfg["file"])).resolve() if logging_cfg.get("file") else None,
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Association locuteur + texte : qui a dit quoi (étape 10).
|
||||
|
||||
Whisper donne l'instant de chaque mot, la diarisation l'instant de chaque tour
|
||||
de parole. Les deux ont reçu exactement le même audio (la parole trouvée par la
|
||||
VAD) : leurs horloges coïncident. Chaque mot va au tour qui le recouvre le plus,
|
||||
puis les mots consécutifs d'un même locuteur forment une intervention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from voice_server.diarization import SpeakerTurn
|
||||
from voice_server.speaker_identifier import UNKNOWN, Identification
|
||||
from voice_server.speech_to_text import Word
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Utterance:
|
||||
"""Une intervention : ce qu'une personne a dit d'un seul tenant."""
|
||||
|
||||
speaker: str # nom, ou « Inconnu »
|
||||
text: str
|
||||
start_s: float # instants dans la parole sans silences (ceux de Whisper et de la diarisation)
|
||||
end_s: float
|
||||
score: float | None # similarité avec le profil (None : pas d'identification)
|
||||
label: str = "" # étiquette de diarisation (SPEAKER_00), vide sans diarisation
|
||||
|
||||
|
||||
def turn_of(word: Word, turns: Sequence[SpeakerTurn]) -> SpeakerTurn | None:
|
||||
"""Le tour qui recouvre le plus le mot ; à défaut (mot dans un blanc), le plus proche."""
|
||||
if not turns:
|
||||
return None
|
||||
|
||||
def overlap(turn: SpeakerTurn) -> float:
|
||||
return min(word.end_s, turn.end_s) - max(word.start_s, turn.start_s)
|
||||
|
||||
best = max(turns, key=overlap)
|
||||
if overlap(best) > 0:
|
||||
return best
|
||||
middle = (word.start_s + word.end_s) / 2
|
||||
return min(turns, key=lambda turn: min(abs(middle - turn.start_s), abs(middle - turn.end_s)))
|
||||
|
||||
|
||||
def attribute(words: Sequence[Word], turns: Sequence[SpeakerTurn],
|
||||
verdicts: dict[str, Identification]) -> list[Utterance]:
|
||||
"""Regroupe les mots en interventions, une par changement de locuteur.
|
||||
|
||||
Args:
|
||||
words: mots horodatés de Whisper.
|
||||
turns: tours de parole de la diarisation (vide : diarisation désactivée).
|
||||
verdicts: identification de chaque étiquette de diarisation.
|
||||
"""
|
||||
utterances: list[Utterance] = []
|
||||
group: list[Word] = []
|
||||
current: str | None = None
|
||||
|
||||
def close() -> None:
|
||||
if not group:
|
||||
return
|
||||
verdict = verdicts.get(current or "")
|
||||
score = verdict.score if verdict is not None and not math.isnan(verdict.score) else None
|
||||
utterances.append(Utterance(
|
||||
speaker=verdict.label if verdict is not None else UNKNOWN,
|
||||
text="".join(word.text for word in group).strip(),
|
||||
start_s=group[0].start_s,
|
||||
end_s=group[-1].end_s,
|
||||
score=score,
|
||||
label=current or "",
|
||||
))
|
||||
|
||||
for word in words:
|
||||
turn = turn_of(word, turns)
|
||||
label = turn.speaker if turn is not None else None
|
||||
if group and label != current:
|
||||
close()
|
||||
group = []
|
||||
current = label
|
||||
group.append(word)
|
||||
close()
|
||||
return utterances
|
||||
|
||||
|
||||
def transcript_json(session: str, utterances: Sequence[Utterance]) -> bytes:
|
||||
"""Message publié sur voice/<carte>/transcript.
|
||||
|
||||
{"session": "a1b2c3", "utterances": [{"speaker": "Denis", "text": "...",
|
||||
"start": 0.0, "end": 1.66, "score": 0.74}, ...]}
|
||||
"""
|
||||
return json.dumps({
|
||||
"session": session,
|
||||
"utterances": [
|
||||
{"speaker": u.speaker, "text": u.text, "start": round(u.start_s, 2), "end": round(u.end_s, 2),
|
||||
"score": None if u.score is None else round(u.score, 2)}
|
||||
for u in utterances
|
||||
],
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Transcription de la parole en texte avec faster-whisper (étape 6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.settings import SttSettings
|
||||
|
||||
if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'extra « speech »
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Phrases que Whisper « entend » dans le silence ou le bruit : elles viennent des
|
||||
# sous-titres de vidéos qui ont servi à l'entraîner. Comparaison en minuscules.
|
||||
KNOWN_HALLUCINATIONS = (
|
||||
"amara.org",
|
||||
"sous-titrage",
|
||||
"sous-titres",
|
||||
"merci d'avoir regardé",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Word:
|
||||
"""Un mot et son instant dans l'audio transcrit (étape 10 : à qui l'attribuer)."""
|
||||
|
||||
text: str # tel que Whisper l'écrit, espace initiale comprise : « Ferme », « -moi »
|
||||
start_s: float
|
||||
end_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transcript:
|
||||
"""Résultat d'une transcription."""
|
||||
|
||||
text: str # vide si rien d'exploitable n'a été reconnu
|
||||
audio_s: float # durée de l'audio transcrit
|
||||
elapsed_s: float # temps de calcul
|
||||
dropped: tuple[str, ...] = () # segments écartés comme hallucinations
|
||||
words: tuple[Word, ...] = () # mots des segments gardés, dans l'ordre
|
||||
|
||||
|
||||
class SpeechToText:
|
||||
"""Transcrit un PCM s16le 16 kHz mono en texte, avec un modèle local.
|
||||
|
||||
Le modèle est lu dans le dossier rempli par « make models » : aucun accès
|
||||
réseau au démarrage, ni pendant la transcription.
|
||||
"""
|
||||
|
||||
def __init__(self, stt: SttSettings, sample_rate: int) -> None:
|
||||
"""
|
||||
Args:
|
||||
stt: réglages (modèle, langue, précision de calcul).
|
||||
sample_rate: fréquence du PCM reçu ; Whisper exige 16 kHz.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: le modèle n'a pas été téléchargé.
|
||||
ValueError: fréquence différente de 16 kHz.
|
||||
"""
|
||||
if sample_rate != 16000:
|
||||
raise ValueError(f"Whisper attend du 16 kHz, pas {sample_rate} Hz")
|
||||
if not (stt.model_dir / "model.bin").exists():
|
||||
raise FileNotFoundError(f"modele whisper absent de {stt.model_dir} : lancez 'make models'")
|
||||
self._stt = stt
|
||||
self._sample_rate = sample_rate
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
began = time.monotonic()
|
||||
# int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32.
|
||||
self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type)
|
||||
# Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question.
|
||||
self.transcribe(bytes(sample_rate * 2))
|
||||
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
|
||||
|
||||
def transcribe(self, pcm: bytes) -> Transcript:
|
||||
"""Transcrit le PCM et écarte les segments suspects."""
|
||||
began = time.monotonic()
|
||||
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||
segments, _ = self._model.transcribe(
|
||||
audio,
|
||||
language=self._stt.language,
|
||||
beam_size=self._stt.beam_size,
|
||||
# Chaque question est indépendante : sans cela, une erreur se propage d'un segment à l'autre.
|
||||
condition_on_previous_text=False,
|
||||
no_speech_threshold=self._stt.no_speech_threshold,
|
||||
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
|
||||
word_timestamps=True, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
|
||||
)
|
||||
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
words: list[Word] = []
|
||||
for segment in segments: # générateur : le calcul se fait pendant ce parcours
|
||||
text = segment.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
if any(marker in text.lower() for marker in KNOWN_HALLUCINATIONS):
|
||||
dropped.append(text)
|
||||
else:
|
||||
kept.append(text)
|
||||
words.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
|
||||
for w in segment.words or ())
|
||||
|
||||
return Transcript(
|
||||
text=" ".join(kept),
|
||||
audio_s=audio.size / self._sample_rate,
|
||||
elapsed_s=time.monotonic() - began,
|
||||
dropped=tuple(dropped),
|
||||
words=tuple(words),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Normalisation du texte transcrit, commune à l'assistant et à la domotique."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Minuscules, sans accents ni ponctuation : « Quelle heure est-il ? » → « quelle heure est il »."""
|
||||
decomposed = unicodedata.normalize("NFD", text.lower())
|
||||
without_accents = "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
|
||||
return " ".join(re.sub(r"[^a-z0-9]+", " ", without_accents).split())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Détection d'activité vocale (VAD) avec Silero (étape 7).
|
||||
|
||||
Silero donne, pour chaque fenêtre de 512 échantillons (32 ms à 16 kHz), la
|
||||
probabilité qu'elle contienne de la parole. Ce module en déduit les segments
|
||||
de parole, puis ne garde qu'eux : silences de début et de fin retirés, longs
|
||||
silences intérieurs raccourcis.
|
||||
|
||||
Le modèle ONNX est celui que livre faster-whisper : pas de torch, rien à télécharger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.settings import VadSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WINDOW_SAMPLES = 512 # imposé par Silero à 16 kHz
|
||||
HYSTERESIS = 0.15 # on sort de la parole sous threshold - 0,15 : pas de battement au seuil
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Segment:
|
||||
"""Segment de parole, en échantillons : [start, end[."""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VadResult:
|
||||
"""Parole trouvée dans une session."""
|
||||
|
||||
segments: tuple[Segment, ...] # bornes après marge, dans l'audio d'origine
|
||||
speech_pcm: bytes # la parole seule, segments mis bout à bout
|
||||
probabilities: np.ndarray # une probabilité par fenêtre, pour le diagnostic
|
||||
total_s: float # durée de la session
|
||||
speech_s: float # durée de parole conservée
|
||||
elapsed_s: float # temps de calcul
|
||||
sample_rate: int
|
||||
|
||||
@property
|
||||
def has_speech(self) -> bool:
|
||||
"""Faux si la session ne contient aucune parole (appui muet, bruit)."""
|
||||
return bool(self.segments)
|
||||
|
||||
def bounds(self) -> str:
|
||||
"""Bornes des segments en secondes : « 0.45-1.80, 2.10-2.60 »."""
|
||||
return ", ".join(f"{s.start / self.sample_rate:.2f}-{s.end / self.sample_rate:.2f}"
|
||||
for s in self.segments)
|
||||
|
||||
|
||||
def find_segments(probabilities: Sequence[float], threshold: float,
|
||||
min_silence_windows: int, min_speech_windows: int) -> list[Segment]:
|
||||
"""Segments de parole, en fenêtres, à partir des probabilités de Silero.
|
||||
|
||||
Entrée en parole dès threshold ; sortie après min_silence_windows fenêtres
|
||||
sous threshold - HYSTERESIS (une pause entre deux mots ne coupe pas la phrase).
|
||||
Les segments plus courts que min_speech_windows sont des bruits : clic, toux.
|
||||
"""
|
||||
low = threshold - HYSTERESIS
|
||||
segments: list[Segment] = []
|
||||
start: int | None = None
|
||||
quiet = 0 # fenêtres calmes consécutives depuis la dernière parole
|
||||
for index, probability in enumerate(probabilities):
|
||||
if start is None:
|
||||
if probability >= threshold:
|
||||
start, quiet = index, 0
|
||||
continue
|
||||
quiet = quiet + 1 if probability < low else 0
|
||||
if quiet >= min_silence_windows:
|
||||
end = index - quiet + 1
|
||||
if end - start >= min_speech_windows:
|
||||
segments.append(Segment(start, end))
|
||||
start = None
|
||||
if start is not None and len(probabilities) - quiet - start >= min_speech_windows:
|
||||
segments.append(Segment(start, len(probabilities) - quiet))
|
||||
return segments
|
||||
|
||||
|
||||
def pad_segments(segments: Sequence[Segment], pad: int, total: int) -> list[Segment]:
|
||||
"""Élargit chaque segment de pad (début et fin de mot sont faibles) et fusionne les chevauchements."""
|
||||
padded: list[Segment] = []
|
||||
for segment in segments:
|
||||
start, end = max(0, segment.start - pad), min(total, segment.end + pad)
|
||||
if padded and start <= padded[-1].end:
|
||||
padded[-1] = Segment(padded[-1].start, end)
|
||||
else:
|
||||
padded.append(Segment(start, end))
|
||||
return padded
|
||||
|
||||
|
||||
class VoiceActivityDetector:
|
||||
"""Trouve la parole dans un PCM s16le 16 kHz mono et retire les silences."""
|
||||
|
||||
def __init__(self, vad: VadSettings, sample_rate: int,
|
||||
model: Callable[[np.ndarray], np.ndarray] | None = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
vad: seuil et durées minimales.
|
||||
sample_rate: fréquence du PCM ; Silero exige 16 kHz avec des fenêtres de 512.
|
||||
model: probabilités par fenêtre ; par défaut Silero, injectable pour les tests.
|
||||
|
||||
Raises:
|
||||
ValueError: fréquence différente de 16 kHz.
|
||||
"""
|
||||
if sample_rate != 16000:
|
||||
raise ValueError(f"Silero attend du 16 kHz avec des fenetres de 512, pas {sample_rate} Hz")
|
||||
if model is None:
|
||||
# Import tardif : le mode écho se passe de l'extra « speech ».
|
||||
from faster_whisper.vad import get_vad_model
|
||||
model = get_vad_model()
|
||||
self._model = model
|
||||
self._vad = vad
|
||||
self._sample_rate = sample_rate
|
||||
window_ms = WINDOW_SAMPLES * 1000 / sample_rate
|
||||
self._min_silence = max(1, round(vad.min_silence_ms / window_ms))
|
||||
self._min_speech = max(1, round(vad.min_speech_ms / window_ms))
|
||||
self._pad = vad.speech_pad_ms * sample_rate // 1000
|
||||
|
||||
def detect(self, pcm: bytes) -> VadResult:
|
||||
"""Analyse le PCM et rend la parole seule."""
|
||||
began = time.monotonic()
|
||||
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
|
||||
audio = samples.astype(np.float32) / 32768.0
|
||||
# Dernière fenêtre complétée par du silence : Silero n'accepte que des fenêtres entières.
|
||||
padded = np.pad(audio, (0, -audio.size % WINDOW_SAMPLES))
|
||||
probabilities = (np.asarray(self._model(padded), dtype=np.float32).reshape(-1)
|
||||
if padded.size else np.zeros(0, dtype=np.float32))
|
||||
|
||||
windows = find_segments(probabilities.tolist(), self._vad.threshold,
|
||||
self._min_silence, self._min_speech)
|
||||
in_samples = [Segment(s.start * WINDOW_SAMPLES, min(samples.size, s.end * WINDOW_SAMPLES))
|
||||
for s in windows]
|
||||
segments = pad_segments(in_samples, self._pad, samples.size)
|
||||
speech = b"".join(samples[s.start:s.end].tobytes() for s in segments)
|
||||
return VadResult(
|
||||
segments=tuple(segments),
|
||||
speech_pcm=speech,
|
||||
probabilities=probabilities,
|
||||
total_s=samples.size / self._sample_rate,
|
||||
speech_s=len(speech) / 2 / self._sample_rate,
|
||||
elapsed_s=time.monotonic() - began,
|
||||
sample_rate=self._sample_rate,
|
||||
)
|
||||
|
||||
|
||||
def envelope(probabilities: np.ndarray, threshold: float) -> str:
|
||||
"""Une lettre par fenêtre de 32 ms : « # » parole, « . » hésitation, « _ » silence."""
|
||||
low = threshold - HYSTERESIS
|
||||
return "".join("#" if p >= threshold else "." if p >= low else "_" for p in probabilities)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Boucle complète d'une question (étapes 6 à 14).
|
||||
|
||||
VAD → diarisation → identification → transcription → qui a dit quoi → réponse
|
||||
(+ commande domotique, si les droits de la personne le permettent) → synthèse → envoi.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.access_control import Resolution, Verdict
|
||||
from voice_server.assistant import NOTHING_HEARD, Reply
|
||||
from voice_server.audio_buffer import read_pcm
|
||||
from voice_server.audio_sender import Publisher
|
||||
from voice_server.diarization import Diarization, SpeakerTurn
|
||||
from voice_server.machine_control import Command
|
||||
from voice_server.session_manager import SessionResult
|
||||
from voice_server.speaker_attribution import Utterance, attribute, transcript_json
|
||||
from voice_server.speaker_identifier import UNKNOWN, Identification
|
||||
from voice_server.speech_to_text import Transcript
|
||||
from voice_server.text_to_speech import Speech
|
||||
from voice_server.voice_activity import VadResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot
|
||||
|
||||
|
||||
class SpeechDetector(Protocol):
|
||||
"""Ce que la boucle attend de la détection d'activité vocale."""
|
||||
|
||||
def detect(self, pcm: bytes) -> VadResult: ...
|
||||
|
||||
|
||||
class SpeakerSplitter(Protocol):
|
||||
"""Ce que la boucle attend de la diarisation."""
|
||||
|
||||
def diarize(self, pcm: bytes) -> Diarization: ...
|
||||
|
||||
|
||||
class Identifier(Protocol):
|
||||
"""Ce que la boucle attend de l'identification du locuteur."""
|
||||
|
||||
def identify(self, embedding: np.ndarray) -> Identification: ...
|
||||
|
||||
|
||||
class Policy(Protocol):
|
||||
"""Ce que la boucle attend des droits par profil (AccessPolicy)."""
|
||||
|
||||
def check(self, command: Command, speaker: str | None, score: float | None) -> Verdict: ...
|
||||
|
||||
def ask(self, board: str, command: Command, speaker: str | None) -> None: ...
|
||||
|
||||
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: ...
|
||||
|
||||
|
||||
class Transcriber(Protocol):
|
||||
"""Ce que la boucle attend de la transcription."""
|
||||
|
||||
def transcribe(self, pcm: bytes) -> Transcript: ...
|
||||
|
||||
|
||||
class Responder(Protocol):
|
||||
"""Ce que la boucle attend de l'assistant."""
|
||||
|
||||
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply: ...
|
||||
|
||||
|
||||
class Controller(Protocol):
|
||||
"""Ce que la boucle attend de la domotique (HomeController)."""
|
||||
|
||||
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]: ...
|
||||
|
||||
|
||||
class Synthesizer(Protocol):
|
||||
"""Ce que la boucle attend de la synthèse vocale."""
|
||||
|
||||
def synthesize(self, text: str) -> Speech: ...
|
||||
|
||||
|
||||
class Player(Protocol):
|
||||
"""Ce que la boucle attend de l'envoi vers la carte (AudioSender)."""
|
||||
|
||||
def send(self, device: str, pcm: bytes, session: str | None = None) -> str: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Turn:
|
||||
"""Bilan d'un échange, pour le journal et les tests."""
|
||||
|
||||
heard: str # texte transcrit ; vide si rien de reconnu
|
||||
answer: str # texte dit par la carte
|
||||
speech_s: float # parole trouvée par la VAD (0 : appui muet)
|
||||
speakers: int # locuteurs trouvés par la diarisation (0 : non faite)
|
||||
names: tuple[str, ...] # nom ou « Inconnu » de chaque locuteur, ordre de la diarisation
|
||||
utterances: tuple[Utterance, ...] # qui a dit quoi (étape 10)
|
||||
command: Command | None # commande exécutée : domotique (11), machine (15b)
|
||||
refused: bool # commande refusée ou mise en attente de confirmation (étape 14)
|
||||
stt_s: float # temps de transcription (0 si Whisper n'a pas été appelé)
|
||||
tts_s: float # temps de synthèse
|
||||
ready_s: float # de la fin de session au départ du premier chunk
|
||||
answer_s: float # durée de la réponse parlée
|
||||
|
||||
|
||||
class VoicePipeline:
|
||||
"""Traite une session terminée et fait dire la réponse par la carte.
|
||||
|
||||
L'appel est bloquant (transcription, puis envoi au rythme réel) : il
|
||||
s'exécute dans le fil de travail du serveur, jamais dans le fil réseau.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vad: SpeechDetector,
|
||||
stt: Transcriber,
|
||||
assistant: Responder,
|
||||
tts: Synthesizer,
|
||||
player: Player,
|
||||
diarizer: SpeakerSplitter | None = None,
|
||||
identifier: Identifier | None = None,
|
||||
publisher: Publisher | None = None,
|
||||
controller: Controller | None = None,
|
||||
policy: Policy | None = None,
|
||||
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
diarizer: diarisation, None pour s'en passer (Raspberry Pi 4).
|
||||
identifier: identification ; sans diarisation, elle n'a pas d'empreintes.
|
||||
publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul).
|
||||
controller: exécute les commandes domotiques (None : elles sont seulement annoncées).
|
||||
policy: droits par profil (None : toute commande est permise).
|
||||
read: lecture du PCM d'une session, injectable pour les tests.
|
||||
clock: horloge, injectable pour les tests.
|
||||
"""
|
||||
self._vad = vad
|
||||
self._stt = stt
|
||||
self._assistant = assistant
|
||||
self._tts = tts
|
||||
self._player = player
|
||||
self._diarizer = diarizer
|
||||
self._identifier = identifier
|
||||
self._publisher = publisher
|
||||
self._controller = controller
|
||||
self._policy = policy
|
||||
self._read = read
|
||||
self._clock = clock
|
||||
|
||||
def handle(self, result: SessionResult) -> Turn | None:
|
||||
"""Répond à la session ; None si elle était trop courte pour être une question."""
|
||||
began = self._clock()
|
||||
if result.duration_s < MIN_SPEECH_S:
|
||||
logger.warning("%s : session %s trop courte (%.2f s) : bouton relache trop tot ?",
|
||||
result.device, result.session_id, result.duration_s)
|
||||
return None
|
||||
|
||||
speakers = 0
|
||||
turns: tuple[SpeakerTurn, ...] = ()
|
||||
verdicts: dict[str, Identification] = {}
|
||||
# Sans parole, Whisper n'est pas appelé : 1 s de calcul pour un texte souvent inventé.
|
||||
vad = self._vad.detect(self._read(result))
|
||||
if vad.has_speech:
|
||||
logger.info("%s : parole %.2f s sur %.2f s (segments %s s), VAD %.0f ms", result.device,
|
||||
vad.speech_s, vad.total_s, vad.bounds(), vad.elapsed_s * 1000)
|
||||
# La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10).
|
||||
if self._diarizer is not None:
|
||||
diarization = self._diarizer.diarize(vad.speech_pcm)
|
||||
speakers = len(diarization.speakers)
|
||||
logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers,
|
||||
diarization.describe() or "aucun tour", diarization.elapsed_s)
|
||||
turns = diarization.turns
|
||||
verdicts = self._identify(result.device, diarization)
|
||||
transcript = self._stt.transcribe(vad.speech_pcm)
|
||||
else:
|
||||
logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device,
|
||||
result.session_id, result.levels.peak_dbfs)
|
||||
transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0)
|
||||
if transcript.dropped:
|
||||
logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped))
|
||||
|
||||
utterances = self._who_said_what(result, transcript, turns, verdicts)
|
||||
# On répond à la dernière personne qui a parlé : c'est elle qui attend la réponse,
|
||||
# et ce sont ses droits qui s'appliquent.
|
||||
last = utterances[-1] if utterances else None
|
||||
speaker = last.speaker if last is not None and last.speaker != UNKNOWN else None
|
||||
score = last.score if last is not None else None
|
||||
reply, refused = self._decide(result.device, transcript.text, speaker, score)
|
||||
answer = reply.text
|
||||
if reply.command is not None and self._controller is not None:
|
||||
# La commande part avant la réponse parlée : la lumière s'allume pendant que la carte le dit.
|
||||
self._controller.execute(result.device, reply.command, speaker, result.session_id)
|
||||
speech = self._tts.synthesize(answer)
|
||||
ready_s = self._clock() - began
|
||||
|
||||
logger.info("%s : entendu « %s » -> reponse « %s »", result.device, transcript.text, answer)
|
||||
logger.info("%s : transcription %.2f s (audio %.1f s), synthese %.2f s, reponse prete en %.2f s",
|
||||
result.device, transcript.elapsed_s, transcript.audio_s, speech.elapsed_s, ready_s)
|
||||
self._player.send(result.device, speech.pcm, f"rep-{result.session_id}")
|
||||
return Turn(heard=transcript.text, answer=answer, speech_s=vad.speech_s, speakers=speakers,
|
||||
names=tuple(verdict.label for verdict in verdicts.values()), utterances=tuple(utterances),
|
||||
command=reply.command, refused=refused,
|
||||
stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s,
|
||||
answer_s=speech.audio_s)
|
||||
|
||||
def _decide(self, board: str, text: str, speaker: str | None, score: float | None) -> tuple[Reply, bool]:
|
||||
"""Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente."""
|
||||
if not text:
|
||||
return Reply(NOTHING_HEARD), False
|
||||
if self._policy is not None:
|
||||
# Une confirmation attendue passe avant tout : « oui » n'est pas une requête.
|
||||
resolution = self._policy.resolve(board, text, speaker)
|
||||
if resolution is not None:
|
||||
return Reply(resolution.answer, resolution.command), resolution.command is None
|
||||
reply = self._assistant.respond(text, speaker, board)
|
||||
if reply.command is None or self._policy is None:
|
||||
return reply, False
|
||||
verdict = self._policy.check(reply.command, speaker, score)
|
||||
if not verdict.allowed:
|
||||
return Reply(verdict.answer or "Je ne peux pas faire cela."), True
|
||||
if verdict.confirm:
|
||||
self._policy.ask(board, reply.command, speaker)
|
||||
return Reply(verdict.answer or "Confirmez-vous ?"), True
|
||||
return reply, False
|
||||
|
||||
def _identify(self, device: str, diarization: Diarization) -> dict[str, Identification]:
|
||||
"""Un verdict (nom ou « Inconnu ») par étiquette de diarisation."""
|
||||
if self._identifier is None or diarization.embeddings is None:
|
||||
return {}
|
||||
verdicts = {speaker: self._identifier.identify(embedding)
|
||||
for speaker, embedding in zip(diarization.speakers, diarization.embeddings)}
|
||||
logger.info("%s : %s", device, ", ".join(
|
||||
f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items()))
|
||||
return verdicts
|
||||
|
||||
def _who_said_what(self, result: SessionResult, transcript: Transcript,
|
||||
turns: tuple[SpeakerTurn, ...], verdicts: dict[str, Identification]) -> list[Utterance]:
|
||||
"""Interventions de la session, journalisées et publiées sur voice/<carte>/transcript."""
|
||||
if not transcript.text:
|
||||
return []
|
||||
utterances = attribute(transcript.words, turns, verdicts)
|
||||
if not utterances: # texte sans horodatage des mots : une seule intervention anonyme
|
||||
utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0,
|
||||
end_s=transcript.audio_s, score=None)]
|
||||
for utterance in utterances:
|
||||
logger.info("%s : %s : « %s »", result.device, utterance.speaker, utterance.text)
|
||||
if self._publisher is not None:
|
||||
self._publisher.publish(result.device, "transcript", transcript_json(result.session_id, utterances), 1)
|
||||
return utterances
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Détection du mot de réveil avec openWakeWord (étape 13).
|
||||
|
||||
openWakeWord calcule un spectrogramme, puis une empreinte de chaque tranche de
|
||||
80 ms, puis un petit modèle par mot donne un score entre 0 et 1. Les modèles
|
||||
prêts à l'emploi sont anglais : « Alexa », « Hey Jarvis », « Hey Mycroft ».
|
||||
|
||||
Exécution en ONNX : la version TensorFlow Lite n'existe pas pour Python 3.13.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.settings import WakeSettings
|
||||
|
||||
if TYPE_CHECKING: # import réel dans __init__ : extra « wakeword » facultatif
|
||||
from openwakeword.model import Model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_VERSION = "v0.1"
|
||||
FEATURE_MODELS = ("melspectrogram.onnx", "embedding_model.onnx")
|
||||
NEAR_MISS_FLOOR = 0.15 # un pic de score au-dessus, sans atteindre le seuil : « presque reconnu »
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Detection:
|
||||
"""Un mot de réveil reconnu."""
|
||||
|
||||
word: str # « alexa », « hey_jarvis »…
|
||||
score: float
|
||||
|
||||
|
||||
def model_files(settings: WakeSettings) -> list[str]:
|
||||
"""Fichiers nécessaires : les deux modèles communs, puis un par mot."""
|
||||
return [*FEATURE_MODELS, *(f"{word}_{MODEL_VERSION}.onnx" for word in settings.words)]
|
||||
|
||||
|
||||
class WakeWordDetector:
|
||||
"""Donne au fil de l'eau les scores du mot de réveil, pour UNE carte.
|
||||
|
||||
Le modèle garde un historique audio interne : un détecteur par carte, sinon
|
||||
les flux de deux cartes se mélangeraient.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: WakeSettings, device: str = "") -> None:
|
||||
"""
|
||||
Args:
|
||||
device: carte écoutée, pour le journal.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: modèles absents (« make models »).
|
||||
ImportError: openwakeword non installé (« make install-wakeword »).
|
||||
"""
|
||||
missing = [name for name in model_files(settings) if not (settings.model_dir / name).exists()]
|
||||
if missing:
|
||||
raise FileNotFoundError(f"modeles openWakeWord absents de {settings.model_dir} "
|
||||
f"({', '.join(missing)}) : lancez 'make models'")
|
||||
from openwakeword.model import Model
|
||||
|
||||
directory = settings.model_dir
|
||||
self._model: Model = Model(
|
||||
wakeword_models=[str(directory / f"{word}_{MODEL_VERSION}.onnx") for word in settings.words],
|
||||
inference_framework="onnx",
|
||||
melspec_model_path=str(directory / FEATURE_MODELS[0]),
|
||||
embedding_model_path=str(directory / FEATURE_MODELS[1]),
|
||||
)
|
||||
self._thresholds = {word: settings.thresholds.get(word, settings.threshold) for word in settings.words}
|
||||
self._device = device
|
||||
self._peaks: dict[str, float] = {} # pic de score en cours, par mot (diagnostic)
|
||||
|
||||
def process(self, pcm: bytes) -> Detection | None:
|
||||
"""Ajoute un chunk (s16le, 16 kHz) ; rend le mot s'il vient d'être reconnu."""
|
||||
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
|
||||
raw: Any = self._model.predict(samples) # dict des scores (tuple seulement si timing=True)
|
||||
scores = {str(key).removesuffix(f"_{MODEL_VERSION}"): float(value) for key, value in raw.items()}
|
||||
detected: Detection | None = None
|
||||
for word, score in scores.items():
|
||||
threshold = self._thresholds.get(word, 1.0)
|
||||
if score >= threshold:
|
||||
if detected is None or score > detected.score:
|
||||
detected = Detection(word=word, score=score)
|
||||
self._track_near_miss(word, score, threshold)
|
||||
if detected is not None:
|
||||
self._peaks.clear()
|
||||
return detected
|
||||
|
||||
def _track_near_miss(self, word: str, score: float, threshold: float) -> None:
|
||||
"""Journalise un mot presque reconnu : la mesure qui permet de régler son seuil."""
|
||||
if score >= NEAR_MISS_FLOOR:
|
||||
self._peaks[word] = max(self._peaks.get(word, 0.0), score)
|
||||
return
|
||||
peak = self._peaks.pop(word, 0.0) # le pic vient de retomber
|
||||
if NEAR_MISS_FLOOR <= peak < threshold:
|
||||
logger.info("%s : mot presque reconnu « %s » : %.2f (seuil %.2f)", self._device, word, peak, threshold)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Oublie l'historique : à faire après une réponse, pour ne pas redétecter l'ancien mot."""
|
||||
self._model.reset()
|
||||
self._peaks.clear()
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Qui parle quand, qui est-ce, qui a dit quoi : VAD, diarisation, identification, texte (étapes 8-10).
|
||||
|
||||
Une ligne par locuteur, un caractère par 100 ms de parole (« # » : il parle).
|
||||
Les instants sont ceux de la parole seule, silences retirés par la VAD : c'est
|
||||
ce que reçoivent la diarisation et Whisper dans le serveur.
|
||||
|
||||
--demo fabrique une séquence à deux voix sans seconde personne : votre voix
|
||||
(le dernier enregistrement de l'ESP32), la voix Piper, puis de nouveau la vôtre.
|
||||
Elle est écrite dans --out pour l'écouter.
|
||||
|
||||
Exemples :
|
||||
python3 tools/diarize_check.py server/recordings/esp32-01_xxx.wav
|
||||
python3 tools/diarize_check.py --demo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from voice_server.audio_resampler import AudioFormatError, AudioResampler # noqa: E402
|
||||
from voice_server.diarization import Diarizer, timeline # noqa: E402
|
||||
from voice_server.settings import Settings, load_settings # noqa: E402
|
||||
from voice_server.speaker_attribution import attribute, transcript_json # noqa: E402
|
||||
from voice_server.speaker_identifier import SpeakerIdentifier # noqa: E402
|
||||
from voice_server.speech_to_text import SpeechToText # noqa: E402
|
||||
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
|
||||
|
||||
logger = logging.getLogger("diarize_check")
|
||||
|
||||
|
||||
def demo_sequence(settings: Settings, vad: VoiceActivityDetector, diarizer: Diarizer,
|
||||
voice: Path | None) -> bytes:
|
||||
"""Votre voix, la voix Piper, votre voix : deux locuteurs attendus."""
|
||||
from voice_server.text_to_speech import TextToSpeech
|
||||
|
||||
# Par défaut, la question la plus récente : 1 à 4 s de parole, une seule voix. Les
|
||||
# sessions plus longues sont souvent des essais à plusieurs voix, que la diarisation
|
||||
# ne sépare pas toujours.
|
||||
recordings = [voice] if voice else sorted(settings.recordings_dir.glob("esp32-01_*.wav"), reverse=True)
|
||||
reader = AudioResampler(settings.audio, None)
|
||||
second = settings.audio.bytes_per_second
|
||||
mine = b""
|
||||
for path in recordings:
|
||||
mine = vad.detect(reader.from_wav(path)).speech_pcm
|
||||
if (voice or second <= len(mine) <= 4 * second) and len(diarizer.diarize(mine).speakers) == 1:
|
||||
logger.info("votre voix : %s", path.name)
|
||||
break
|
||||
mine = b""
|
||||
if not mine:
|
||||
raise FileNotFoundError(f"aucun enregistrement de l'ESP32 avec de la parole dans {settings.recordings_dir}")
|
||||
|
||||
# Même niveau que votre enregistrement, pour que la diarisation juge la voix et non le volume.
|
||||
tts = TextToSpeech(settings.tts, AudioResampler(settings.audio, -10.0), settings.audio.sample_rate)
|
||||
piper = tts.synthesize("Bonjour, je suis la voix de synthèse, et je parle entre vos deux phrases.").pcm
|
||||
pause = bytes(settings.audio.bytes_per_second // 2)
|
||||
return mine + pause + piper + pause + mine
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Diarise le fichier (ou la démonstration) et affiche la chronologie."""
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("wav", type=Path, nargs="?", help="enregistrement a analyser")
|
||||
parser.add_argument("--demo", action="store_true", help="sequence a deux voix : vous et Piper")
|
||||
parser.add_argument("--voice", type=Path, help="votre voix pour --demo (sinon la derniere question)")
|
||||
parser.add_argument("--out", type=Path, default=Path("recordings/deux-voix.wav"),
|
||||
help="WAV de la demonstration")
|
||||
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
if (args.wav is None) == (not args.demo):
|
||||
parser.error("donnez soit un fichier WAV, soit --demo")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
settings = load_settings(args.config)
|
||||
audio = settings.audio
|
||||
vad = VoiceActivityDetector(settings.vad, audio.sample_rate)
|
||||
try:
|
||||
diarizer = Diarizer(settings.diarization, audio.sample_rate)
|
||||
if args.demo:
|
||||
pcm = demo_sequence(settings, vad, diarizer, args.voice)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(args.out), "wb") as wav:
|
||||
wav.setnchannels(audio.channels)
|
||||
wav.setsampwidth(audio.bits // 8)
|
||||
wav.setframerate(audio.sample_rate)
|
||||
wav.writeframes(pcm)
|
||||
logger.info("demonstration : make play OUT=%s", args.out)
|
||||
else:
|
||||
pcm = AudioResampler(audio, None).from_wav(args.wav)
|
||||
except (AudioFormatError, FileNotFoundError, ImportError) as error:
|
||||
logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
speech = vad.detect(pcm)
|
||||
if not speech.has_speech:
|
||||
print("\naucune parole : rien a diariser")
|
||||
return 0
|
||||
result = diarizer.diarize(speech.speech_pcm)
|
||||
|
||||
print(f"\nparole {speech.speech_s:.2f} s (sur {speech.total_s:.2f} s), "
|
||||
f"{len(result.speakers)} locuteur(s), diarisation {result.elapsed_s:.2f} s")
|
||||
for line in timeline(result):
|
||||
print(f" {line}")
|
||||
for turn in result.turns:
|
||||
print(f" {turn.speaker} {turn.start_s:6.2f} -> {turn.end_s:6.2f} s")
|
||||
|
||||
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
|
||||
verdicts = {} if result.embeddings is None else {
|
||||
speaker: identifier.identify(embedding) for speaker, embedding in zip(result.speakers, result.embeddings)}
|
||||
for speaker, verdict in verdicts.items():
|
||||
print(f" {speaker} = {verdict.describe()}")
|
||||
|
||||
# Étape 10 : les mots de Whisper répartis entre les locuteurs.
|
||||
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
|
||||
transcript = SpeechToText(settings.stt, audio.sample_rate).transcribe(speech.speech_pcm)
|
||||
utterances = attribute(transcript.words, result.turns, verdicts)
|
||||
print(f"\nqui a dit quoi (transcription {transcript.elapsed_s:.2f} s) :")
|
||||
for utterance in utterances:
|
||||
print(f" {utterance.start_s:5.2f}-{utterance.end_s:5.2f} s {utterance.speaker:10s} « {utterance.text} »")
|
||||
print("\nmessage MQTT voice/<carte>/transcript :")
|
||||
print(f" {transcript_json('essai', utterances).decode()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Télécharge les modèles de config.yaml dans server/models/ (étape 6).
|
||||
|
||||
models/whisper/<stt.model>/ modèle faster-whisper (CTranslate2)
|
||||
models/piper/<tts.voice>.onnx voix Piper et sa configuration .onnx.json
|
||||
models/pyannote/<nom>/ diarisation (étape 8) : jeton HF_TOKEN dans server/.env
|
||||
models/openwakeword/*.onnx mots de réveil (étape 13)
|
||||
|
||||
Les modèles déjà présents ne sont pas retéléchargés. Ensuite, le serveur
|
||||
fonctionne sans Internet : il ne lit que ces fichiers locaux.
|
||||
|
||||
Exemple :
|
||||
python3 tools/download_models.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from voice_server.settings import ( # noqa: E402
|
||||
DiarizationSettings,
|
||||
SttSettings,
|
||||
TtsSettings,
|
||||
WakeSettings,
|
||||
load_settings,
|
||||
)
|
||||
from voice_server.wake_word import model_files # noqa: E402
|
||||
|
||||
OPENWAKEWORD_URL = "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/{name}"
|
||||
|
||||
logger = logging.getLogger("download_models")
|
||||
|
||||
|
||||
def download_whisper(stt: SttSettings) -> None:
|
||||
"""Télécharge le modèle faster-whisper depuis Hugging Face (Systran)."""
|
||||
if (stt.model_dir / "model.bin").exists():
|
||||
logger.info("whisper %s deja present : %s", stt.model, stt.model_dir)
|
||||
return
|
||||
from faster_whisper import download_model
|
||||
|
||||
logger.info("telechargement de whisper %s vers %s…", stt.model, stt.model_dir)
|
||||
download_model(stt.model, output_dir=str(stt.model_dir))
|
||||
|
||||
|
||||
def download_piper(tts: TtsSettings) -> None:
|
||||
"""Télécharge la voix Piper (.onnx et .onnx.json) depuis Hugging Face (rhasspy)."""
|
||||
config_path = tts.model_path.with_name(tts.model_path.name + ".json")
|
||||
if tts.model_path.exists() and config_path.exists():
|
||||
logger.info("voix piper %s deja presente : %s", tts.voice, tts.model_path)
|
||||
return
|
||||
from piper.download_voices import download_voice
|
||||
|
||||
logger.info("telechargement de la voix piper %s vers %s…", tts.voice, tts.model_path.parent)
|
||||
tts.model_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
download_voice(tts.voice, tts.model_path.parent)
|
||||
|
||||
|
||||
def download_pyannote(diarization: DiarizationSettings) -> bool:
|
||||
"""Télécharge le pipeline de diarisation ; False si le jeton ou l'accord manque."""
|
||||
if not diarization.enabled:
|
||||
logger.info("diarisation desactivee dans config.yaml : rien a telecharger")
|
||||
return True
|
||||
if (diarization.model_dir / "config.yaml").exists():
|
||||
logger.info("diarisation %s deja presente : %s", diarization.model, diarization.model_dir)
|
||||
return True
|
||||
page = f"https://huggingface.co/{diarization.model}"
|
||||
token = os.environ.get("HF_TOKEN") # chargé depuis server/.env par load_settings
|
||||
if not token:
|
||||
logger.error("HF_TOKEN absent de server/.env. Pour la diarisation :\n"
|
||||
" 1. compte sur https://huggingface.co, puis accepter les conditions sur %s\n"
|
||||
" 2. jeton « Read » sur https://huggingface.co/settings/tokens\n"
|
||||
" 3. cp server/.env.example server/.env, y renseigner HF_TOKEN=..., relancer 'make models'",
|
||||
page)
|
||||
return False
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.errors import GatedRepoError, HfHubHTTPError
|
||||
|
||||
logger.info("telechargement de %s vers %s…", diarization.model, diarization.model_dir)
|
||||
try:
|
||||
snapshot_download(diarization.model, local_dir=diarization.model_dir, token=token,
|
||||
allow_patterns=["config.yaml", "*/pytorch_model.bin", "plda/*.npz"])
|
||||
except GatedRepoError:
|
||||
logger.error("acces refuse : acceptez les conditions du modele sur %s", page)
|
||||
return False
|
||||
except HfHubHTTPError as error:
|
||||
logger.error("echec du telechargement (%s) : jeton HF_TOKEN valide ?", error)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def download_openwakeword(wake: WakeSettings) -> None:
|
||||
"""Télécharge les modèles ONNX d'openWakeWord (communs + un par mot) depuis GitHub."""
|
||||
if not wake.enabled:
|
||||
return
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
wake.model_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in model_files(wake):
|
||||
target = wake.model_dir / name
|
||||
if target.exists():
|
||||
continue
|
||||
logger.info("telechargement de %s", name)
|
||||
urlretrieve(OPENWAKEWORD_URL.format(name=name), target)
|
||||
logger.info("mots de reveil prets : %s", ", ".join(wake.words))
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Télécharge ce qui manque, puis affiche la taille totale."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING) # une ligne par requête HTTP sinon
|
||||
|
||||
settings = load_settings(args.config)
|
||||
try:
|
||||
download_whisper(settings.stt)
|
||||
download_piper(settings.tts)
|
||||
except ImportError as error:
|
||||
logger.error("%s : lancez d'abord 'make install-speech'", error)
|
||||
return 1
|
||||
download_openwakeword(settings.wake)
|
||||
diarization_ready = download_pyannote(settings.diarization)
|
||||
|
||||
size = sum(f.stat().st_size for f in settings.models_dir.rglob("*") if f.is_file())
|
||||
logger.info("modeles dans %s (%.0f Mo)", settings.models_dir, size / 1e6)
|
||||
return 0 if diarization_ready else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enrôlement vocal : crée, liste ou supprime les profils de voix (étape 9).
|
||||
|
||||
Un profil est la moyenne des empreintes de plusieurs sessions d'une même
|
||||
personne. Par défaut, on prend les N dernières sessions de l'ESP32 : dites
|
||||
d'abord 5 phrases variées avec « make run », puis enrôlez.
|
||||
|
||||
Chaque session est contrôlée : assez de parole, une seule voix, et cohérente
|
||||
avec les autres (une session où une autre voix s'est glissée est écartée).
|
||||
|
||||
Les profils sont des données biométriques : server/profiles/ n'est jamais
|
||||
versionné, et « --forget » supprime un profil.
|
||||
|
||||
Exemples :
|
||||
python3 tools/enroll.py --name Denis # 5 dernières sessions
|
||||
python3 tools/enroll.py --name Denis a.wav b.wav c.wav
|
||||
python3 tools/enroll.py --list
|
||||
python3 tools/enroll.py --forget Denis
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from voice_server.audio_resampler import AudioFormatError, AudioResampler # noqa: E402
|
||||
from voice_server.diarization import Diarizer # noqa: E402
|
||||
from voice_server.settings import Settings, load_settings # noqa: E402
|
||||
from voice_server.speaker_identifier import ( # noqa: E402
|
||||
SpeakerIdentifier,
|
||||
load_profiles,
|
||||
normalized,
|
||||
profile_path,
|
||||
save_profile,
|
||||
)
|
||||
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
|
||||
|
||||
logger = logging.getLogger("enroll")
|
||||
|
||||
MIN_SPEECH_S = 1.0 # en dessous, l'empreinte est trop instable pour servir de référence
|
||||
MIN_SAMPLES = 3
|
||||
|
||||
|
||||
def collect(settings: Settings, wavs: list[Path]) -> list[tuple[str, np.ndarray]]:
|
||||
"""Une empreinte par session valable : assez de parole, une seule voix."""
|
||||
audio = settings.audio
|
||||
vad = VoiceActivityDetector(settings.vad, audio.sample_rate)
|
||||
diarizer = Diarizer(settings.diarization, audio.sample_rate)
|
||||
reader = AudioResampler(audio, None)
|
||||
samples: list[tuple[str, np.ndarray]] = []
|
||||
for path in wavs:
|
||||
try:
|
||||
speech = vad.detect(reader.from_wav(path))
|
||||
except (AudioFormatError, FileNotFoundError) as error:
|
||||
logger.warning(" %s ignore : %s", path.name, error)
|
||||
continue
|
||||
if speech.speech_s < MIN_SPEECH_S:
|
||||
logger.warning(" %s ignore : %.1f s de parole (minimum %.0f s)", path.name, speech.speech_s, MIN_SPEECH_S)
|
||||
continue
|
||||
diarization = diarizer.diarize(speech.speech_pcm)
|
||||
if len(diarization.speakers) != 1 or diarization.embeddings is None:
|
||||
logger.warning(" %s ignore : %d voix trouvees, une seule attendue", path.name, len(diarization.speakers))
|
||||
continue
|
||||
embedding = diarization.embeddings[0]
|
||||
if not np.all(np.isfinite(embedding)):
|
||||
logger.warning(" %s ignore : empreinte impossible", path.name)
|
||||
continue
|
||||
logger.info(" %s : %.1f s de parole", path.name, speech.speech_s)
|
||||
samples.append((path.name, normalized(embedding)))
|
||||
return samples
|
||||
|
||||
|
||||
def drop_outliers(samples: list[tuple[str, np.ndarray]], threshold: float) -> list[tuple[str, np.ndarray]]:
|
||||
"""Écarte les sessions qui ne ressemblent pas aux autres (autre voix, mélange)."""
|
||||
kept: list[tuple[str, np.ndarray]] = []
|
||||
for index, (name, embedding) in enumerate(samples):
|
||||
others = normalized(np.mean([e for i, (_, e) in enumerate(samples) if i != index], axis=0))
|
||||
score = float(others @ embedding)
|
||||
if score < threshold:
|
||||
logger.warning(" %s ecarte : ressemblance %.2f avec les autres sessions (seuil %.2f)",
|
||||
name, score, threshold)
|
||||
else:
|
||||
kept.append((name, embedding))
|
||||
return kept
|
||||
|
||||
|
||||
def enroll(settings: Settings, name: str, wavs: list[Path]) -> int:
|
||||
"""Crée (ou remplace) le profil de name à partir des sessions données."""
|
||||
logger.info("enrolement de %s sur %d session(s) :", name, len(wavs))
|
||||
samples = collect(settings, wavs)
|
||||
if len(samples) >= MIN_SAMPLES:
|
||||
samples = drop_outliers(samples, settings.speaker.similarity_threshold)
|
||||
if len(samples) < MIN_SAMPLES:
|
||||
logger.error("%d session(s) valable(s), %d au minimum. Soit dites %d phrases seul avec 'make run' "
|
||||
"puis relancez, soit remontez plus loin : 'make enroll NAME=%s LAST=%d'",
|
||||
len(samples), MIN_SAMPLES, MIN_SAMPLES + 2, name, len(wavs) * 2)
|
||||
return 1
|
||||
|
||||
# Avant d'écrire : ce nouveau profil ressemble-t-il trop à quelqu'un d'autre ?
|
||||
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
|
||||
mean = normalized(np.mean([e for _, e in samples], axis=0))
|
||||
for other in identifier.profiles:
|
||||
score = float(other.embedding @ mean)
|
||||
if other.name != name and score >= settings.speaker.similarity_threshold:
|
||||
logger.warning("attention : ressemble a %s (%.2f) ; meme personne, ou seuil trop bas ?",
|
||||
other.name, score)
|
||||
|
||||
profile = save_profile(settings.speaker.profiles_dir, name, [e for _, e in samples], settings.diarization.model)
|
||||
scores = [float(profile.embedding @ e) for _, e in samples]
|
||||
logger.info("profil %s enregistre : %d sessions, ressemblance %.2f a %.2f -> %s",
|
||||
name, profile.samples, min(scores), max(scores),
|
||||
profile_path(settings.speaker.profiles_dir, name))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Aiguille entre enrôlement, liste et suppression."""
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("wavs", type=Path, nargs="*", help="sessions a utiliser (sinon les --last dernieres)")
|
||||
parser.add_argument("--name", help="nom de la personne a enroler")
|
||||
parser.add_argument("--last", type=int, default=5, help="nombre de sessions recentes de l'ESP32")
|
||||
parser.add_argument("--device", default="esp32-01", help="carte dont on prend les sessions")
|
||||
parser.add_argument("--list", action="store_true", help="affiche les profils")
|
||||
parser.add_argument("--forget", metavar="NOM", help="supprime le profil de NOM")
|
||||
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
settings = load_settings(args.config)
|
||||
profiles_dir = settings.speaker.profiles_dir
|
||||
|
||||
if args.list:
|
||||
profiles = load_profiles(profiles_dir, settings.diarization.model)
|
||||
for profile in profiles:
|
||||
print(f" {profile.name:20s} {profile.samples} sessions, cree le {profile.created}")
|
||||
print(f"{len(profiles)} profil(s) dans {profiles_dir}")
|
||||
return 0
|
||||
if args.forget:
|
||||
path = profile_path(profiles_dir, args.forget)
|
||||
if not path.exists():
|
||||
logger.error("aucun profil %s (%s)", args.forget, path)
|
||||
return 1
|
||||
path.unlink()
|
||||
logger.info("profil %s supprime (%s)", args.forget, path)
|
||||
return 0
|
||||
if not args.name:
|
||||
parser.error("--name, --list ou --forget")
|
||||
|
||||
wavs = args.wavs or sorted(settings.recordings_dir.glob(f"{args.device}_*.wav"))[-args.last:]
|
||||
if not wavs:
|
||||
logger.error("aucune session dans %s : parlez d'abord avec 'make run'", settings.recordings_dir)
|
||||
return 1
|
||||
try:
|
||||
return enroll(settings, args.name, wavs)
|
||||
except (FileNotFoundError, ImportError) as error:
|
||||
logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user