mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
step-16
This commit is contained in:
+19
-2
@@ -35,8 +35,8 @@ Prompt de référence : `prompt-assistant-vocal-esp32.md` (rôle, style, feuille
|
|||||||
| 13 | Wake word (openWakeWord) | **fait** (serveur + firmware) | **oui (2026-09-30)** |
|
| 13 | Wake word (openWakeWord) | **fait** (serveur + firmware) | **oui (2026-09-30)** |
|
||||||
| 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** |
|
| 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** |
|
||||||
| 15 | Fiabilisation | **fait** (broker + serveur + firmware) | **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 |
|
| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | **oui (2026-10-06)** |
|
||||||
| 16 | Optimisation | à faire | — |
|
| 16 | Optimisation | **fait** (serveur + option broker ; firmware inchangé) | en attente |
|
||||||
|
|
||||||
**Règle absolue** : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N.
|
**Règle absolue** : ne jamais démarrer l'étape N+1 avant validation explicite de l'étape N.
|
||||||
|
|
||||||
@@ -132,6 +132,10 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé
|
|||||||
| Règles par appareil/action ; **action absente = niveau complet** | 2026-09-30 | on ne permet jamais par oubli |
|
| 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) |
|
| Actions sensibles (`confirm: [door.open]`) : question, puis « oui »/« non » **de la même personne**, sur la même carte, en 15 s ; toute autre phrase abandonne la demande | 2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) |
|
||||||
| Confirmation : acceptée si la voix est reconnue comme le demandeur, **ou** si elle est sous le seuil mais que le demandeur reste le **profil le plus proche** avec `access.confirm_min_score` (**0,25**) ; voix reconnue comme quelqu'un d'autre : refusée | 2026-10-06 | mesuré : 6 « oui » seuls de Denis (~0,7 s de parole) à 0,29-0,47, 5 sous le seuil 0,45 ; autres profils face à Denis ≤ 0,12 (profils entiers, surtout voix de synthèse : à revoir avec de vraies personnes) |
|
| Confirmation : acceptée si la voix est reconnue comme le demandeur, **ou** si elle est sous le seuil mais que le demandeur reste le **profil le plus proche** avec `access.confirm_min_score` (**0,25**) ; voix reconnue comme quelqu'un d'autre : refusée | 2026-10-06 | mesuré : 6 « oui » seuls de Denis (~0,7 s de parole) à 0,29-0,47, 5 sous le seuil 0,45 ; autres profils face à Denis ≤ 0,12 (profils entiers, surtout voix de synthèse : à revoir avec de vraies personnes) |
|
||||||
|
| Étape 16 : **parole < `diarization.direct_below_s` (4 s) = une seule personne** → `Diarizer.embed()` au lieu de la diarisation, Whisper sans mots horodatés | 2026-10-06 | mesuré sur 113 sessions réelles : 0,02 s contre 0,42 s, écart de score médian +0,001, 4 verdicts changés (2 en mieux, 2 en moins bien, tous au seuil) ; aucune session récente à 2 voix. Contrepartie : deux voix dans une phrase courte ne sont plus séparées |
|
||||||
|
| Étape 16 : parallélisme diarisation / Whisper **écarté** | 2026-10-06 | mesuré : séquentiel 1,43-1,59 s, parallèle 1,34-1,56 s ; chacun ralentit l'autre (Whisper 1,1 → 1,4 s, diarisation 0,4 → 0,8 s), **même dans deux processus** : saturation du processeur, pas le verrou de Python |
|
||||||
|
| Étape 16 : `beam_size` 5 gardé, `stt.cpu_threads` 0 (défaut, 4 fils) | 2026-10-06 | beam 1 : −0,05 s et 1 texte différent sur 16 ; fils : 4 → 1,08 s, 8 → 0,98 s, 16 → 1,29 s sur le PC ; l'encodeur de Whisper (fenêtre fixe de 30 s) domine |
|
||||||
|
| Étape 16 : cache de synthèse (64 phrases), fin de commande cherchée toutes les 100 ms, `TCP_NODELAY` côté serveur (`on_socket_open`) et broker (`set_tcp_nodelay true`) | 2026-10-06 | cache : 0,03-0,14 s par réponse répétée ; l'effet de `TCP_NODELAY` sur « premier son a ~240 ms » est une **hypothèse** (Nagle + accusés retardés de l'ESP32), à confirmer sur la carte |
|
||||||
| Droits appliqués au **dernier locuteur** de la session | 2026-09-30 | c'est lui qui attend la réponse (cohérent avec l'étape 10) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
@@ -202,6 +206,7 @@ server/voice_server/wake_word.py WakeWordDetector (openWakeWord ONNX)
|
|||||||
server/voice_server/hands_free.py HandsFreeListener : veille / commande / réponse [étape 13]
|
server/voice_server/hands_free.py HandsFreeListener : veille / commande / réponse [étape 13]
|
||||||
server/voice_server/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
|
server/voice_server/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14]
|
||||||
tools/enroll.py make enroll / profiles / forget [étape 9]
|
tools/enroll.py make enroll / profiles / forget [étape 9]
|
||||||
|
tools/latency_check.py make latency : avant/après sur enregistrements réels [étape 16]
|
||||||
tools/say.py make say : phrase Piper en WAV + MP3 (téléphone) [étape 14]
|
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]
|
docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3]
|
||||||
firmware/src/main.cpp boucle PTT → I2S → série [étape 1]
|
firmware/src/main.cpp boucle PTT → I2S → série [étape 1]
|
||||||
@@ -830,3 +835,15 @@ profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements
|
|||||||
profil le plus proche (`confirm_min_score: 0.25`, `AccessPolicy.resolve(..., closest, score)`, le
|
profil le plus proche (`confirm_min_score: 0.25`, `AccessPolicy.resolve(..., closest, score)`, le
|
||||||
pipeline transmet `Identification.closest`). 214 tests, pyright 0 erreur. Serveur à relancer
|
pipeline transmet `Identification.closest`). 214 tests, pyright 0 erreur. Serveur à relancer
|
||||||
(config.yaml lu au démarrage). Étape 15b : essais en cours, pas encore validée.
|
(config.yaml lu au démarrage). Étape 15b : essais en cours, pas encore validée.
|
||||||
|
- **2026-10-06** — **Étape 15b validée** par l'utilisateur (« Agents Linux ok, réponse dans les logs »).
|
||||||
|
Étape 16 lancée : optimisation.
|
||||||
|
- **2026-10-06** — **Étape 16 écrite** (serveur seul, plus une option du broker ; firmware inchangé).
|
||||||
|
Point de départ mesuré sur 63 échanges réels : réponse prête en 1,54 s (Whisper 1,06 + diarisation
|
||||||
|
0,40 + synthèse 0,07). Bancs dans le scratchpad, sur les enregistrements réels en lecture seule.
|
||||||
|
Retenu : empreinte directe pour une parole courte, Whisper sans mots dans ce cas, cache de synthèse,
|
||||||
|
vérification de fin de commande à 100 ms, `TCP_NODELAY`. Écarté sur mesures : parallélisme, beam 1,
|
||||||
|
16 fils. `make latency COUNT=60` : **1,62 s → 1,09 s (−0,53 s, 33 %)**, même texte 54/54, même
|
||||||
|
identification 50/54. 220 tests, pyright 0 erreur. `voice.conf` validé sur un broker jetable
|
||||||
|
(supprimé). **À faire par l'utilisateur** : `make mosquitto-config` sur le Pi pour `set_tcp_nodelay`.
|
||||||
|
**Non fait, à décider** : numéros de séquence des chunks (§3.4 du prompt, change le protocole et le
|
||||||
|
firmware) ; `end_silence_ms` (800 ms d'attente après la phrase, le plus gros délai restant).
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ OUT ?= recordings/test.wav
|
|||||||
TONE ?= 440
|
TONE ?= 440
|
||||||
TEXT ?= Quelle heure est-il ?
|
TEXT ?= Quelle heure est-il ?
|
||||||
LAST ?= 5
|
LAST ?= 5
|
||||||
|
COUNT ?= 30
|
||||||
JITTER ?= 0
|
JITTER ?= 0
|
||||||
VOICE ?= fr_FR-tom-medium
|
VOICE ?= fr_FR-tom-medium
|
||||||
SPEAKER ?=
|
SPEAKER ?=
|
||||||
@@ -41,7 +42,7 @@ MQTT_AS = @echo "(compte MQTT : $(if $(MQTT_USERNAME),$(MQTT_USERNAME) de s
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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 \
|
.PHONY: help install-system mosquitto-config mosquitto-docker mqtt-user agent-run agent-install agent-log mqtt-agents mqtt-watch mqtt-transcript mqtt-home mqtt-control venv install install-dev install-speech install-diarization install-wakeword models \
|
||||||
say speech-check transcribe vad vad-all diarize diarize-demo enroll profiles forget run test lint record play inspect play-esp tone-esp play-mqtt tone-mqtt run-echo pio-venv fw-build fw-check fw-upload \
|
say latency speech-check transcribe vad vad-all diarize diarize-demo enroll profiles forget run test lint record play inspect play-esp tone-esp play-mqtt tone-mqtt run-echo pio-venv fw-build fw-check fw-upload \
|
||||||
fw-monitor clean
|
fw-monitor clean
|
||||||
|
|
||||||
## ------------------------------------------------------------------ aide
|
## ------------------------------------------------------------------ aide
|
||||||
@@ -163,6 +164,9 @@ say: ## Fait dire [WAKE (voix anglaise) puis] TEXT par VOICE (SPEAKER) : WAV +
|
|||||||
$(PY) tools/say.py "$(TEXT)" --voice $(VOICE) $(if $(SPEAKER),--speaker $(SPEAKER)) \
|
$(PY) tools/say.py "$(TEXT)" --voice $(VOICE) $(if $(SPEAKER),--speaker $(SPEAKER)) \
|
||||||
$(if $(WAKE),--wake "$(WAKE)" --wake-voice $(WAKE_VOICE)) --out $(SAY_OUT)
|
$(if $(WAKE),--wake "$(WAKE)" --wake-voice $(WAKE_VOICE)) --out $(SAY_OUT)
|
||||||
|
|
||||||
|
latency: ## Mesure la latence du serveur avant/après l'étape 16 sur les COUNT derniers enregistrements
|
||||||
|
$(PY) tools/latency_check.py --last $(COUNT)
|
||||||
|
|
||||||
speech-check: ## Synthèse, transcription et RÉPONSE de l'assistant à TEXT, sans ESP32 (étape 6)
|
speech-check: ## Synthèse, transcription et RÉPONSE de l'assistant à TEXT, sans ESP32 (étape 6)
|
||||||
$(PY) tools/speech_check.py "$(TEXT)"
|
$(PY) tools/speech_check.py "$(TEXT)"
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -292,6 +292,7 @@ make tone-esp TONE=440 # pure tone on the ESP32 speaker (step 2
|
|||||||
make run # assistant: answers every question (step 6)
|
make run # assistant: answers every question (step 6)
|
||||||
make speech-check TEXT="Quelle heure est-il ?" # recognition and synthesis, no ESP32
|
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 say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 for a phone
|
||||||
|
make latency # server latency before/after step 16, on real recordings
|
||||||
make transcribe OUT=server/recordings/<file>.wav
|
make transcribe OUT=server/recordings/<file>.wav
|
||||||
make vad OUT=server/recordings/<file>.wav # speech found by the VAD (step 7)
|
make vad OUT=server/recordings/<file>.wav # speech found by the VAD (step 7)
|
||||||
make vad-all # VAD over every server recording
|
make vad-all # VAD over every server recording
|
||||||
@@ -314,7 +315,33 @@ make lint # pyright type check
|
|||||||
|
|
||||||
The exact state of the project, step by step, lives in **[CONTEXT.md](CONTEXT.md)**.
|
The exact state of the project, step by step, lives in **[CONTEXT.md](CONTEXT.md)**.
|
||||||
|
|
||||||
Current step: **15b — Linux machines on the network**. Steps 1 to 15 are validated.
|
Current step: **16 — optimisation**. Steps 1 to 15b are validated.
|
||||||
|
|
||||||
|
### Step 16 — optimisation
|
||||||
|
|
||||||
|
Measure first, optimise next: `make latency` replays the latest recordings through the
|
||||||
|
old chain, then the new one. On 54 real commands (PC, 20 logical cores): **1.6 s → 1.1 s**
|
||||||
|
between the end of the sentence and the recognised text, same text 54 times out of 54.
|
||||||
|
|
||||||
|
| Optimisation | Measured gain | Setting |
|
||||||
|
|---|---|---|
|
||||||
|
| Short speech: direct voice embedding, no diarization | 0.40 s (0.42 → 0.02 s) | `diarization.direct_below_s: 4.0` |
|
||||||
|
| Short speech: Whisper without word timestamps | 0.10 s | automatic |
|
||||||
|
| Answers already spoken kept in memory (synthesis cache) | 0.03 to 0.14 s | automatic, 64 sentences |
|
||||||
|
| End of command checked every 100 ms (250 before) | 0.075 s on average | — |
|
||||||
|
| Immediate network sends (`TCP_NODELAY`: server, broker) | to be measured: `premier son a … ms` | `set_tcp_nodelay true` in `voice.conf` |
|
||||||
|
|
||||||
|
Measured, then **rejected**: diarization and Whisper in parallel (0.03 to 0.10 s only: both
|
||||||
|
computations saturate the processor, even in two processes), `beam_size: 1` (0.05 s, and one
|
||||||
|
different text out of 16), 16 threads for Whisper (slower than 4).
|
||||||
|
|
||||||
|
1. On the Raspberry Pi (broker): `git pull`, then `make mosquitto-config`.
|
||||||
|
2. `make run`, a few commands: the log shows `1 locuteur (parole courte, empreinte directe
|
||||||
|
20 ms)` and `reponse prete en ~1.1 s` (1.5 s before).
|
||||||
|
3. `make latency`: before, after, gain, and the identifications that differ.
|
||||||
|
|
||||||
|
Speech shorter than 4 s is assumed to come from **a single person**. Two voices in a short
|
||||||
|
sentence are no longer separated (they were at step 10); beyond 4 s nothing changes.
|
||||||
|
|
||||||
### Step 15b — Linux machines on the network
|
### Step 15b — Linux machines on the network
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ make tone-esp TONE=440 # son pur sur le haut-parleur de l'ESP32
|
|||||||
make run # assistant : répond à chaque question (étape 6)
|
make run # assistant : répond à chaque question (étape 6)
|
||||||
make speech-check TEXT="Quelle heure est-il ?" # transcription et synthèse, sans ESP32
|
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 say WAKE="Hey Mycroft" TEXT="Ouvre la porte du salon." VOICE=fr_FR-tom-medium # WAV + MP3 pour un téléphone
|
||||||
|
make latency # latence du serveur avant/après l'étape 16, sur de vrais enregistrements
|
||||||
make transcribe OUT=server/recordings/<fichier>.wav
|
make transcribe OUT=server/recordings/<fichier>.wav
|
||||||
make vad OUT=server/recordings/<fichier>.wav # parole trouvée par la VAD (étape 7)
|
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 vad-all # VAD sur tous les enregistrements du serveur
|
||||||
@@ -317,7 +318,33 @@ make lint # typage pyright
|
|||||||
|
|
||||||
L'état exact du projet, étape par étape, est dans **[CONTEXT.md](CONTEXT.md)**.
|
L'état exact du projet, étape par étape, est dans **[CONTEXT.md](CONTEXT.md)**.
|
||||||
|
|
||||||
Étape courante : **15b — machines Linux du réseau**. Les étapes 1 à 15 sont validées.
|
Étape courante : **16 — optimisation**. Les étapes 1 à 15b sont validées.
|
||||||
|
|
||||||
|
### Étape 16 — optimisation
|
||||||
|
|
||||||
|
Mesurer d'abord, optimiser ensuite : `make latency` rejoue les derniers enregistrements
|
||||||
|
avec l'ancienne puis la nouvelle chaîne. Sur 54 commandes réelles (PC, 20 cœurs logiques) :
|
||||||
|
**1,6 s → 1,1 s** entre la fin de la phrase et le texte reconnu, même texte 54 fois sur 54.
|
||||||
|
|
||||||
|
| Optimisation | Gain mesuré | Réglage |
|
||||||
|
|---|---|---|
|
||||||
|
| Parole courte : empreinte vocale directe, sans diarisation | 0,40 s (0,42 → 0,02 s) | `diarization.direct_below_s: 4.0` |
|
||||||
|
| Parole courte : Whisper sans horodatage des mots | 0,10 s | automatique |
|
||||||
|
| Réponses déjà dites gardées en mémoire (cache de synthèse) | 0,03 à 0,14 s | automatique, 64 phrases |
|
||||||
|
| Fin de commande cherchée toutes les 100 ms (250 avant) | 0,075 s en moyenne | — |
|
||||||
|
| Envois réseau immédiats (`TCP_NODELAY` : serveur, broker) | à mesurer : `premier son a … ms` | `set_tcp_nodelay true` dans `voice.conf` |
|
||||||
|
|
||||||
|
Pistes mesurées puis **écartées** : diarisation et Whisper en parallèle (0,03 à 0,10 s seulement :
|
||||||
|
les deux calculs saturent le processeur, même dans deux processus), `beam_size: 1` (0,05 s, et
|
||||||
|
un texte différent sur 16), 16 fils pour Whisper (plus lent que 4).
|
||||||
|
|
||||||
|
1. Sur le Raspberry Pi (broker) : `git pull`, puis `make mosquitto-config`.
|
||||||
|
2. `make run`, quelques commandes : le journal affiche `1 locuteur (parole courte, empreinte
|
||||||
|
directe 20 ms)` et `reponse prete en ~1,1 s` (1,5 s avant).
|
||||||
|
3. `make latency` : avant, après, gain, et les identifications qui diffèrent.
|
||||||
|
|
||||||
|
Une parole de moins de 4 s est supposée venir d'**une seule personne**. Deux voix dans une
|
||||||
|
phrase courte ne sont plus séparées (elles l'étaient à l'étape 10) ; au-delà de 4 s, rien ne change.
|
||||||
|
|
||||||
### Étape 15b — machines Linux du réseau
|
### Étape 15b — machines Linux du réseau
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ allow_anonymous false
|
|||||||
password_file /mosquitto/config/passwd
|
password_file /mosquitto/config/passwd
|
||||||
acl_file /mosquitto/config/acl
|
acl_file /mosquitto/config/acl
|
||||||
|
|
||||||
|
# Étape 16 : envoi immédiat des petits paquets (pas d'algorithme de Nagle). Un chunk audio
|
||||||
|
# de 1600 octets tient en deux paquets TCP ; sans cette option, le second attend l'accusé de
|
||||||
|
# réception du premier, que l'ESP32 retarde : la réponse démarre ~200 ms plus tard.
|
||||||
|
set_tcp_nodelay true
|
||||||
|
|
||||||
# Taille maximale d'un paquet MQTT complet (en-tête et topic compris).
|
# Taille maximale d'un paquet MQTT complet (en-tête et topic compris).
|
||||||
# Un chunk audio fait 1600 octets : 8192 laisse une large marge.
|
# Un chunk audio fait 1600 octets : 8192 laisse une large marge.
|
||||||
# (remplace « message_size_limit », obsolète depuis Mosquitto 2.0)
|
# (remplace « message_size_limit », obsolète depuis Mosquitto 2.0)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ stt:
|
|||||||
compute_type: int8
|
compute_type: int8
|
||||||
beam_size: 5 # 1 = plus rapide, 5 = plus précis
|
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
|
no_speech_threshold: 0.6 # segment écarté au-delà : Whisper invente du texte sur le silence
|
||||||
|
# étape 16 : fils de calcul de Whisper ; 0 = défaut de la bibliothèque (4). Mesuré sur un PC
|
||||||
|
# à 20 cœurs logiques : 4 → 1,08 s, 8 → 0,98 s, 16 → 1,29 s (pire). Sur un Pi 5 : laisser 0.
|
||||||
|
cpu_threads: 0
|
||||||
|
|
||||||
# étape 6 : synthèse vocale (modèle dans models/piper/<voice>.onnx, « make models »)
|
# é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.
|
# Piper produit du 22 050 Hz : le serveur rééchantillonne en audio.sample_rate.
|
||||||
@@ -64,6 +67,11 @@ diarization:
|
|||||||
enabled: true # false sur Raspberry Pi 4 (trop lourd) ou sans l'extra « diarization »
|
enabled: true # false sur Raspberry Pi 4 (trop lourd) ou sans l'extra « diarization »
|
||||||
model: pyannote/speaker-diarization-community-1
|
model: pyannote/speaker-diarization-community-1
|
||||||
max_speakers: 3 # borne haute : un assistant de salon, pas une réunion
|
max_speakers: 3 # borne haute : un assistant de salon, pas une réunion
|
||||||
|
# étape 16 : en dessous de cette durée de PAROLE, on suppose une seule personne (une
|
||||||
|
# commande) : son empreinte vocale est calculée directement, sans chercher qui parle quand.
|
||||||
|
# Mesuré sur 113 sessions réelles : 0,02 s au lieu de 0,42 s, même identification
|
||||||
|
# (écart de score médian 0,001). Au-delà, diarisation complète. 0 : toujours la diarisation.
|
||||||
|
direct_below_s: 4.0
|
||||||
|
|
||||||
# étape 9 : identification du locuteur (probabiliste, jamais une sécurité).
|
# é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).
|
# Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique).
|
||||||
|
|||||||
@@ -129,3 +129,30 @@ def test_per_word_threshold_and_near_miss(caplog: pytest.LogCaptureFixture, tts:
|
|||||||
|
|
||||||
assert all(detection is None for detection in found)
|
assert all(detection is None for detection in found)
|
||||||
assert "presque reconnu « alexa »" in caplog.text
|
assert "presque reconnu « alexa »" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_answer_comes_from_the_cache(tts: TextToSpeech) -> None:
|
||||||
|
"""Étape 16 : une réponse déjà dite n'est pas resynthétisée (Piper varie d'un essai à l'autre)."""
|
||||||
|
first = tts.synthesize("J'allume la lumière du salon.")
|
||||||
|
again = tts.synthesize("J'allume la lumière du salon.")
|
||||||
|
other = tts.synthesize("J'éteins la lumière du salon.")
|
||||||
|
|
||||||
|
assert again.pcm == first.pcm and again.audio_s == first.audio_s
|
||||||
|
assert again.elapsed_s < 0.005 < first.elapsed_s
|
||||||
|
assert other.pcm != first.pcm
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_keeps_a_bounded_number_of_answers(tts: TextToSpeech, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("voice_server.text_to_speech.CACHE_PHRASES", 2)
|
||||||
|
fresh = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio), SETTINGS.audio.sample_rate)
|
||||||
|
for text in ("Un.", "Deux.", "Trois."):
|
||||||
|
fresh.synthesize(text)
|
||||||
|
assert fresh.synthesize("Trois.").elapsed_s < 0.005 # encore en mémoire
|
||||||
|
assert fresh.synthesize("Un.").elapsed_s > 0.005 # la plus ancienne est sortie
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcription_without_word_times(tts: TextToSpeech, stt: SpeechToText) -> None:
|
||||||
|
"""Étape 16 : sans horodatage des mots, même texte, pas de mots."""
|
||||||
|
pcm = tts.synthesize("Fermez la porte du garage.").pcm
|
||||||
|
plain = stt.transcribe(pcm, words=False)
|
||||||
|
assert plain.words == () and "garage" in plain.text.lower()
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ class FakeVad:
|
|||||||
class FakeDiarizer:
|
class FakeDiarizer:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.lengths: list[int] = []
|
self.lengths: list[int] = []
|
||||||
|
self.embedded: list[int] = []
|
||||||
|
|
||||||
|
def embed(self, pcm: bytes) -> np.ndarray:
|
||||||
|
self.embedded.append(len(pcm))
|
||||||
|
return np.array([1.0, 0.0], dtype=np.float32) # la voix que FakeIdentifier reconnaît
|
||||||
|
|
||||||
def diarize(self, pcm: bytes) -> Diarization:
|
def diarize(self, pcm: bytes) -> Diarization:
|
||||||
self.lengths.append(len(pcm))
|
self.lengths.append(len(pcm))
|
||||||
@@ -67,10 +72,12 @@ class FakeStt:
|
|||||||
self.words = words
|
self.words = words
|
||||||
self.calls = 0
|
self.calls = 0
|
||||||
self.lengths: list[int] = []
|
self.lengths: list[int] = []
|
||||||
|
self.with_words: list[bool] = []
|
||||||
|
|
||||||
def transcribe(self, pcm: bytes) -> Transcript:
|
def transcribe(self, pcm: bytes, words: bool = True) -> Transcript:
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
self.lengths.append(len(pcm))
|
self.lengths.append(len(pcm))
|
||||||
|
self.with_words.append(words)
|
||||||
return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped,
|
return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped,
|
||||||
words=self.words)
|
words=self.words)
|
||||||
|
|
||||||
@@ -133,11 +140,12 @@ def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult:
|
|||||||
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
|
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
|
||||||
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | ShortYesIdentifier | None = None,
|
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | ShortYesIdentifier | None = None,
|
||||||
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
|
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
|
||||||
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline:
|
controller: FakeController | None = None, policy: AccessPolicy | None = None,
|
||||||
|
direct_below_s: float = 0.0) -> VoicePipeline:
|
||||||
ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard
|
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,
|
return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer,
|
||||||
identifier=identifier, publisher=publisher, controller=controller, policy=policy,
|
identifier=identifier, publisher=publisher, controller=controller, policy=policy,
|
||||||
read=lambda result: PCM, clock=lambda: next(ticks))
|
read=lambda result: PCM, clock=lambda: next(ticks), direct_below_s=direct_below_s)
|
||||||
|
|
||||||
|
|
||||||
def test_full_turn() -> None:
|
def test_full_turn() -> None:
|
||||||
@@ -355,3 +363,39 @@ def test_short_yes_confirms_only_from_the_closest_voice(closest: str, score: flo
|
|||||||
assert second is not None
|
assert second is not None
|
||||||
assert [call[1] for call in controller.calls] == ([door] if executed else [])
|
assert [call[1] for call in controller.calls] == ([door] if executed else [])
|
||||||
assert second.answer == ("J'ouvre la porte du salon." if executed else "Seul Denis peut confirmer. J'annule.")
|
assert second.answer == ("J'ouvre la porte du salon." if executed else "Seul Denis peut confirmer. J'annule.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_speech_is_identified_without_diarization() -> None:
|
||||||
|
"""Étape 16 : 0,5 s de parole = une commande, une personne : empreinte directe, pas de diarisation."""
|
||||||
|
stt, diarizer = FakeStt("allume la lumière"), FakeDiarizer()
|
||||||
|
|
||||||
|
turn = pipeline(stt, FakeTts(), FakePlayer(), diarizer=diarizer, identifier=FakeIdentifier(),
|
||||||
|
direct_below_s=4.0).handle(session())
|
||||||
|
|
||||||
|
assert turn is not None
|
||||||
|
assert diarizer.lengths == [] and diarizer.embedded == [16000] # même audio que Whisper
|
||||||
|
assert stt.with_words == [False] # mots non horodatés : inutiles ici
|
||||||
|
assert (turn.speakers, turn.names) == (1, ("Denis",))
|
||||||
|
assert [(u.speaker, u.text, u.score) for u in turn.utterances] == [("Denis", "allume la lumière", 0.8)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_speech_still_goes_through_diarization() -> None:
|
||||||
|
"""Au-delà du seuil, plusieurs personnes ont pu parler : diarisation complète, mots horodatés."""
|
||||||
|
stt, diarizer = FakeStt("bonjour", words=DENIS_WORDS), FakeDiarizer()
|
||||||
|
|
||||||
|
turn = pipeline(stt, FakeTts(), FakePlayer(), diarizer=diarizer, identifier=FakeIdentifier(),
|
||||||
|
direct_below_s=0.4).handle(session()) # la fausse VAD garde 0,5 s de parole
|
||||||
|
|
||||||
|
assert turn is not None and turn.speakers == 2
|
||||||
|
assert diarizer.embedded == [] and diarizer.lengths == [16000] and stt.with_words == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_speech_applies_rights_to_the_identified_person() -> None:
|
||||||
|
"""Le raccourci ne change rien aux droits : Denis reconnu, porte → confirmation demandée."""
|
||||||
|
events: list[str] = []
|
||||||
|
turn = pipeline(FakeStt("ouvre la porte"), FakeTts(), FakePlayer(events), diarizer=FakeDiarizer(),
|
||||||
|
identifier=FakeIdentifier(), assistant=EchoAssistant(DeviceCommand("open", "door", "salon")),
|
||||||
|
controller=FakeController(events), policy=AccessPolicy(ACCESS, HOME),
|
||||||
|
direct_below_s=4.0).handle(session())
|
||||||
|
|
||||||
|
assert turn is not None and turn.answer.startswith("Confirmez-vous")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton.
|
Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton.
|
||||||
Étape 14 : droits par profil (complet / standard / limite), confirmation des actions sensibles.
|
É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 15 : présence + testament (les cartes coupent leur micro si le serveur meurt), journal fichier.
|
||||||
|
Étape 16 : latence : empreinte directe pour une parole courte, cache de synthèse, envois sans attente.
|
||||||
Étape 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN.
|
Étape 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioS
|
|||||||
publisher=link,
|
publisher=link,
|
||||||
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
|
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
|
||||||
policy=AccessPolicy(settings.access, settings.home, settings.machines),
|
policy=AccessPolicy(settings.access, settings.home, settings.machines),
|
||||||
|
direct_below_s=settings.diarization.direct_below_s,
|
||||||
)
|
)
|
||||||
return pipeline.handle
|
return pipeline.handle
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ from voice_server.wake_word import Detection
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CHECK_EVERY_CHUNKS = 5 # recherche du silence final toutes les 250 ms d'audio
|
# Recherche du silence final toutes les 100 ms d'audio (250 ms avant l'étape 16) : la fin de la
|
||||||
|
# commande est vue en moyenne 75 ms plus tôt, pour une VAD de quelques ms sur le tampon.
|
||||||
|
CHECK_EVERY_CHUNKS = 2
|
||||||
COMMAND_AFTER_WAKE_S = 0.3 # parole qui continue au moins 0,3 s après le mot : il y a une commande
|
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é
|
STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -94,6 +95,7 @@ class MqttLink:
|
|||||||
self._client.on_connect = self._on_connect
|
self._client.on_connect = self._on_connect
|
||||||
self._client.on_disconnect = self._on_disconnect
|
self._client.on_disconnect = self._on_disconnect
|
||||||
self._client.on_message = self._on_message
|
self._client.on_message = self._on_message
|
||||||
|
self._client.on_socket_open = self._on_socket_open
|
||||||
self._client.reconnect_delay_set(min_delay=1, max_delay=10)
|
self._client.reconnect_delay_set(min_delay=1, max_delay=10)
|
||||||
if self._presence is not None:
|
if self._presence is not None:
|
||||||
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
|
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
|
||||||
@@ -165,6 +167,15 @@ class MqttLink:
|
|||||||
client.subscribe(pattern, qos=1)
|
client.subscribe(pattern, qos=1)
|
||||||
logger.info("abonne a %s (QoS 1)", pattern)
|
logger.info("abonne a %s (QoS 1)", pattern)
|
||||||
|
|
||||||
|
def _on_socket_open(self, client: mqtt.Client, userdata: Any, sock: Any) -> None:
|
||||||
|
# Étape 16 : sans cela, le noyau retient les petits paquets en attendant un accusé de
|
||||||
|
# réception (algorithme de Nagle) ; un chunk audio de 1600 octets tient en deux paquets,
|
||||||
|
# et le second attendrait. Un flux temps réel envoie tout de suite.
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except (OSError, AttributeError): # socket TLS ou WebSocket enveloppé : option indisponible
|
||||||
|
logger.debug("TCP_NODELAY non applique")
|
||||||
|
|
||||||
def _on_disconnect(self, client: mqtt.Client, userdata: Any,
|
def _on_disconnect(self, client: mqtt.Client, userdata: Any,
|
||||||
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
|
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
|
||||||
properties: Properties | None) -> None:
|
properties: Properties | None) -> None:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class SttSettings:
|
|||||||
beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis
|
beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis
|
||||||
no_speech_threshold: float
|
no_speech_threshold: float
|
||||||
model_dir: Path # dossier du modèle téléchargé par « make models »
|
model_dir: Path # dossier du modèle téléchargé par « make models »
|
||||||
|
cpu_threads: int = 0 # fils de calcul de Whisper ; 0 : défaut de la bibliothèque (4)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -90,6 +91,7 @@ class DiarizationSettings:
|
|||||||
model: str # dépôt Hugging Face du pipeline
|
model: str # dépôt Hugging Face du pipeline
|
||||||
max_speakers: int
|
max_speakers: int
|
||||||
model_dir: Path # copie locale téléchargée par « make models »
|
model_dir: Path # copie locale téléchargée par « make models »
|
||||||
|
direct_below_s: float = 0.0 # parole plus courte : empreinte directe, sans diarisation (étape 16)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -285,6 +287,7 @@ def load_settings(path: Path) -> Settings:
|
|||||||
beam_size=int(stt["beam_size"]),
|
beam_size=int(stt["beam_size"]),
|
||||||
no_speech_threshold=float(stt["no_speech_threshold"]),
|
no_speech_threshold=float(stt["no_speech_threshold"]),
|
||||||
model_dir=models_dir / "whisper" / str(stt["model"]),
|
model_dir=models_dir / "whisper" / str(stt["model"]),
|
||||||
|
cpu_threads=int(stt.get("cpu_threads", 0)),
|
||||||
),
|
),
|
||||||
tts=TtsSettings(
|
tts=TtsSettings(
|
||||||
voice=str(tts["voice"]),
|
voice=str(tts["voice"]),
|
||||||
@@ -303,6 +306,7 @@ def load_settings(path: Path) -> Settings:
|
|||||||
max_speakers=int(diarization["max_speakers"]),
|
max_speakers=int(diarization["max_speakers"]),
|
||||||
# « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1
|
# « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1
|
||||||
model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1],
|
model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1],
|
||||||
|
direct_below_s=float(diarization.get("direct_below_s", 0.0)),
|
||||||
),
|
),
|
||||||
speaker=SpeakerSettings(
|
speaker=SpeakerSettings(
|
||||||
profiles_dir=(base / str(speaker["profiles_dir"])).resolve(),
|
profiles_dir=(base / str(speaker["profiles_dir"])).resolve(),
|
||||||
|
|||||||
@@ -74,13 +74,19 @@ class SpeechToText:
|
|||||||
|
|
||||||
began = time.monotonic()
|
began = time.monotonic()
|
||||||
# int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32.
|
# int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32.
|
||||||
self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type)
|
self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type,
|
||||||
|
cpu_threads=stt.cpu_threads)
|
||||||
# Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question.
|
# Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question.
|
||||||
self.transcribe(bytes(sample_rate * 2))
|
self.transcribe(bytes(sample_rate * 2))
|
||||||
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
|
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
|
||||||
|
|
||||||
def transcribe(self, pcm: bytes) -> Transcript:
|
def transcribe(self, pcm: bytes, words: bool = True) -> Transcript:
|
||||||
"""Transcrit le PCM et écarte les segments suspects."""
|
"""Transcrit le PCM et écarte les segments suspects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
words: horodater chaque mot. Utile seulement pour répartir le texte entre
|
||||||
|
plusieurs locuteurs (étape 10) ; sans cela, ~0,1 s de moins (mesuré, étape 16).
|
||||||
|
"""
|
||||||
began = time.monotonic()
|
began = time.monotonic()
|
||||||
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||||
segments, _ = self._model.transcribe(
|
segments, _ = self._model.transcribe(
|
||||||
@@ -91,12 +97,12 @@ class SpeechToText:
|
|||||||
condition_on_previous_text=False,
|
condition_on_previous_text=False,
|
||||||
no_speech_threshold=self._stt.no_speech_threshold,
|
no_speech_threshold=self._stt.no_speech_threshold,
|
||||||
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
|
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
|
||||||
word_timestamps=True, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
|
word_timestamps=words, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
|
||||||
)
|
)
|
||||||
|
|
||||||
kept: list[str] = []
|
kept: list[str] = []
|
||||||
dropped: list[str] = []
|
dropped: list[str] = []
|
||||||
words: list[Word] = []
|
found: list[Word] = []
|
||||||
for segment in segments: # générateur : le calcul se fait pendant ce parcours
|
for segment in segments: # générateur : le calcul se fait pendant ce parcours
|
||||||
text = segment.text.strip()
|
text = segment.text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -105,7 +111,7 @@ class SpeechToText:
|
|||||||
dropped.append(text)
|
dropped.append(text)
|
||||||
else:
|
else:
|
||||||
kept.append(text)
|
kept.append(text)
|
||||||
words.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
|
found.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
|
||||||
for w in segment.words or ())
|
for w in segment.words or ())
|
||||||
|
|
||||||
return Transcript(
|
return Transcript(
|
||||||
@@ -113,5 +119,5 @@ class SpeechToText:
|
|||||||
audio_s=audio.size / self._sample_rate,
|
audio_s=audio.size / self._sample_rate,
|
||||||
elapsed_s=time.monotonic() - began,
|
elapsed_s=time.monotonic() - began,
|
||||||
dropped=tuple(dropped),
|
dropped=tuple(dropped),
|
||||||
words=tuple(words),
|
words=tuple(found),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'ex
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CACHE_PHRASES = 64 # réponses gardées en mémoire : ~100 Ko chacune, 6 Mo au plus
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Speech:
|
class Speech:
|
||||||
@@ -32,6 +34,10 @@ class TextToSpeech:
|
|||||||
|
|
||||||
Piper produit du 22 050 Hz en flottants ; AudioResampler le ramène au
|
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.
|
format du système (16 kHz, s16le) et fixe sa crête à -3 dBFS.
|
||||||
|
|
||||||
|
Les réponses déjà synthétisées sont gardées en mémoire (étape 16) : « J'allume
|
||||||
|
la lumière du salon. » ou « Confirmez-vous… » reviennent souvent, et leur
|
||||||
|
synthèse coûte 30 à 140 ms à chaque fois.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, tts: TtsSettings, resampler: AudioResampler, sample_rate: int) -> None:
|
def __init__(self, tts: TtsSettings, resampler: AudioResampler, sample_rate: int) -> None:
|
||||||
@@ -53,12 +59,16 @@ class TextToSpeech:
|
|||||||
self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale)
|
self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale)
|
||||||
self._resampler = resampler
|
self._resampler = resampler
|
||||||
self._sample_rate = sample_rate
|
self._sample_rate = sample_rate
|
||||||
|
self._cache: dict[str, Speech] = {} # texte → parole ; un dict garde l'ordre d'insertion
|
||||||
logger.info("voix piper %s chargee en %.1f s (%d Hz)", tts.voice,
|
logger.info("voix piper %s chargee en %.1f s (%d Hz)", tts.voice,
|
||||||
time.monotonic() - began, self._voice.config.sample_rate)
|
time.monotonic() - began, self._voice.config.sample_rate)
|
||||||
|
|
||||||
def synthesize(self, text: str) -> Speech:
|
def synthesize(self, text: str) -> Speech:
|
||||||
"""Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout."""
|
"""Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout."""
|
||||||
began = time.monotonic()
|
began = time.monotonic()
|
||||||
|
cached = self._cache.get(text)
|
||||||
|
if cached is not None:
|
||||||
|
return Speech(pcm=cached.pcm, audio_s=cached.audio_s, elapsed_s=time.monotonic() - began)
|
||||||
parts: list[np.ndarray] = []
|
parts: list[np.ndarray] = []
|
||||||
rate = self._voice.config.sample_rate
|
rate = self._voice.config.sample_rate
|
||||||
for chunk in self._voice.synthesize(text, syn_config=self._config):
|
for chunk in self._voice.synthesize(text, syn_config=self._config):
|
||||||
@@ -66,8 +76,12 @@ class TextToSpeech:
|
|||||||
rate = chunk.sample_rate
|
rate = chunk.sample_rate
|
||||||
signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32)
|
signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32)
|
||||||
pcm = self._resampler.convert(signal, rate)
|
pcm = self._resampler.convert(signal, rate)
|
||||||
return Speech(
|
speech = Speech(
|
||||||
pcm=pcm,
|
pcm=pcm,
|
||||||
audio_s=len(pcm) / 2 / self._sample_rate,
|
audio_s=len(pcm) / 2 / self._sample_rate,
|
||||||
elapsed_s=time.monotonic() - began,
|
elapsed_s=time.monotonic() - began,
|
||||||
)
|
)
|
||||||
|
if len(self._cache) >= CACHE_PHRASES:
|
||||||
|
del self._cache[next(iter(self._cache))] # la plus ancienne sort
|
||||||
|
self._cache[text] = speech
|
||||||
|
return speech
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ VAD → diarisation → identification → transcription → qui a dit quoi →
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -30,6 +31,7 @@ from voice_server.voice_activity import VadResult
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot
|
MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot
|
||||||
|
DIRECT_LABEL = "SPEAKER_00" # étiquette de l'unique locuteur d'une parole courte (étape 16)
|
||||||
|
|
||||||
|
|
||||||
class SpeechDetector(Protocol):
|
class SpeechDetector(Protocol):
|
||||||
@@ -43,6 +45,8 @@ class SpeakerSplitter(Protocol):
|
|||||||
|
|
||||||
def diarize(self, pcm: bytes) -> Diarization: ...
|
def diarize(self, pcm: bytes) -> Diarization: ...
|
||||||
|
|
||||||
|
def embed(self, pcm: bytes) -> np.ndarray: ...
|
||||||
|
|
||||||
|
|
||||||
class Identifier(Protocol):
|
class Identifier(Protocol):
|
||||||
"""Ce que la boucle attend de l'identification du locuteur."""
|
"""Ce que la boucle attend de l'identification du locuteur."""
|
||||||
@@ -64,7 +68,7 @@ class Policy(Protocol):
|
|||||||
class Transcriber(Protocol):
|
class Transcriber(Protocol):
|
||||||
"""Ce que la boucle attend de la transcription."""
|
"""Ce que la boucle attend de la transcription."""
|
||||||
|
|
||||||
def transcribe(self, pcm: bytes) -> Transcript: ...
|
def transcribe(self, pcm: bytes, words: bool = True) -> Transcript: ...
|
||||||
|
|
||||||
|
|
||||||
class Responder(Protocol):
|
class Responder(Protocol):
|
||||||
@@ -130,9 +134,12 @@ class VoicePipeline:
|
|||||||
policy: Policy | None = None,
|
policy: Policy | None = None,
|
||||||
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
|
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
|
||||||
clock: Callable[[], float] = time.monotonic,
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
direct_below_s: float = 0.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
direct_below_s: parole plus courte que cela : une seule personne supposée, empreinte
|
||||||
|
directe au lieu de la diarisation (étape 16) ; 0 : toujours la diarisation.
|
||||||
diarizer: diarisation, None pour s'en passer (Raspberry Pi 4).
|
diarizer: diarisation, None pour s'en passer (Raspberry Pi 4).
|
||||||
identifier: identification ; sans diarisation, elle n'a pas d'empreintes.
|
identifier: identification ; sans diarisation, elle n'a pas d'empreintes.
|
||||||
publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul).
|
publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul).
|
||||||
@@ -153,6 +160,7 @@ class VoicePipeline:
|
|||||||
self._policy = policy
|
self._policy = policy
|
||||||
self._read = read
|
self._read = read
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
|
self._direct_below_s = direct_below_s
|
||||||
|
|
||||||
def handle(self, result: SessionResult) -> Turn | None:
|
def handle(self, result: SessionResult) -> Turn | None:
|
||||||
"""Répond à la session ; None si elle était trop courte pour être une question."""
|
"""Répond à la session ; None si elle était trop courte pour être une question."""
|
||||||
@@ -170,23 +178,30 @@ class VoicePipeline:
|
|||||||
if vad.has_speech:
|
if vad.has_speech:
|
||||||
logger.info("%s : parole %.2f s sur %.2f s (segments %s s), VAD %.0f ms", result.device,
|
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)
|
vad.speech_s, vad.total_s, vad.bounds(), vad.elapsed_s * 1000)
|
||||||
|
# Parole courte = une commande, une seule personne : son empreinte suffit (20 ms au lieu
|
||||||
|
# de 400 ms de diarisation), et Whisper n'a pas à horodater les mots (étape 16).
|
||||||
|
direct = self._diarizer is not None and vad.speech_s < self._direct_below_s
|
||||||
|
if self._diarizer is not None and direct:
|
||||||
|
speakers = 1
|
||||||
|
verdicts = self._identify_directly(result.device, vad.speech_pcm, self._diarizer)
|
||||||
# La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10).
|
# La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10).
|
||||||
if self._diarizer is not None:
|
elif self._diarizer is not None:
|
||||||
diarization = self._diarizer.diarize(vad.speech_pcm)
|
diarization = self._diarizer.diarize(vad.speech_pcm)
|
||||||
speakers = len(diarization.speakers)
|
speakers = len(diarization.speakers)
|
||||||
logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers,
|
logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers,
|
||||||
diarization.describe() or "aucun tour", diarization.elapsed_s)
|
diarization.describe() or "aucun tour", diarization.elapsed_s)
|
||||||
turns = diarization.turns
|
turns = diarization.turns
|
||||||
verdicts = self._identify(result.device, diarization)
|
verdicts = self._identify(result.device, diarization)
|
||||||
transcript = self._stt.transcribe(vad.speech_pcm)
|
transcript = self._stt.transcribe(vad.speech_pcm, words=not direct)
|
||||||
else:
|
else:
|
||||||
|
direct = False
|
||||||
logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device,
|
logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device,
|
||||||
result.session_id, result.levels.peak_dbfs)
|
result.session_id, result.levels.peak_dbfs)
|
||||||
transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0)
|
transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0)
|
||||||
if transcript.dropped:
|
if transcript.dropped:
|
||||||
logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped))
|
logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped))
|
||||||
|
|
||||||
utterances = self._who_said_what(result, transcript, turns, verdicts)
|
utterances = self._who_said_what(result, transcript, turns, verdicts, direct)
|
||||||
# On répond à la dernière personne qui a parlé : c'est elle qui attend la réponse,
|
# 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.
|
# et ce sont ses droits qui s'appliquent.
|
||||||
last = utterances[-1] if utterances else None
|
last = utterances[-1] if utterances else None
|
||||||
@@ -243,12 +258,30 @@ class VoicePipeline:
|
|||||||
f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items()))
|
f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items()))
|
||||||
return verdicts
|
return verdicts
|
||||||
|
|
||||||
def _who_said_what(self, result: SessionResult, transcript: Transcript,
|
def _identify_directly(self, device: str, pcm: bytes, diarizer: SpeakerSplitter) -> dict[str, Identification]:
|
||||||
turns: tuple[SpeakerTurn, ...], verdicts: dict[str, Identification]) -> list[Utterance]:
|
"""Parole courte : une empreinte pour tout l'extrait, un seul verdict (étape 16)."""
|
||||||
|
began = time.monotonic()
|
||||||
|
embedding = diarizer.embed(pcm)
|
||||||
|
if self._identifier is None:
|
||||||
|
return {}
|
||||||
|
verdict = self._identifier.identify(embedding)
|
||||||
|
logger.info("%s : 1 locuteur (parole courte, empreinte directe %.0f ms) : %s", device,
|
||||||
|
(time.monotonic() - began) * 1000, verdict.describe())
|
||||||
|
return {DIRECT_LABEL: verdict}
|
||||||
|
|
||||||
|
def _who_said_what(self, result: SessionResult, transcript: Transcript, turns: tuple[SpeakerTurn, ...],
|
||||||
|
verdicts: dict[str, Identification], direct: bool = False) -> list[Utterance]:
|
||||||
"""Interventions de la session, journalisées et publiées sur voice/<carte>/transcript."""
|
"""Interventions de la session, journalisées et publiées sur voice/<carte>/transcript."""
|
||||||
if not transcript.text:
|
if not transcript.text:
|
||||||
return []
|
return []
|
||||||
utterances = attribute(transcript.words, turns, verdicts)
|
verdict = verdicts.get(DIRECT_LABEL) if direct else None
|
||||||
|
if verdict is not None:
|
||||||
|
# Une seule personne : tout le texte est à elle, sans horodatage des mots.
|
||||||
|
score = None if math.isnan(verdict.score) else verdict.score
|
||||||
|
utterances = [Utterance(speaker=verdict.label, text=transcript.text, start_s=0.0,
|
||||||
|
end_s=transcript.audio_s, score=score, label=DIRECT_LABEL)]
|
||||||
|
else:
|
||||||
|
utterances = attribute(transcript.words, turns, verdicts)
|
||||||
if not utterances: # texte sans horodatage des mots : une seule intervention anonyme
|
if not utterances: # texte sans horodatage des mots : une seule intervention anonyme
|
||||||
utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0,
|
utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0,
|
||||||
end_s=transcript.audio_s, score=None)]
|
end_s=transcript.audio_s, score=None)]
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mesure la latence du serveur sur des enregistrements réels, avant et après l'étape 16.
|
||||||
|
|
||||||
|
Pour chaque enregistrement de server/recordings/ (lecture seule, rien n'est écrit) :
|
||||||
|
|
||||||
|
avant VAD → diarisation → identification → Whisper avec mots horodatés
|
||||||
|
après VAD → empreinte directe si la parole est courte (diarization.direct_below_s),
|
||||||
|
diarisation sinon → identification → Whisper sans mots si parole courte
|
||||||
|
|
||||||
|
Le temps affiché est celui du calcul, de la fin de la session au texte reconnu :
|
||||||
|
il ne compte ni le réseau, ni la synthèse (quelques dizaines de ms, en cache
|
||||||
|
pour les réponses répétées). À lancer sur la machine qui fait tourner le serveur.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
python3 tools/latency_check.py # les 30 derniers enregistrements
|
||||||
|
python3 tools/latency_check.py --last 100
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
|
||||||
|
if str(SERVER_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SERVER_DIR))
|
||||||
|
|
||||||
|
from voice_server.audio_buffer import read_pcm # noqa: E402
|
||||||
|
from voice_server.settings import load_settings # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger("latency_check")
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def timed(function: Callable[[], T]) -> tuple[float, T]:
|
||||||
|
"""Durée d'un appel, et son résultat."""
|
||||||
|
began = time.monotonic()
|
||||||
|
result = function()
|
||||||
|
return time.monotonic() - began, result
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
"""Rejoue les enregistrements et affiche les médianes."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--last", type=int, default=30, help="nombre d'enregistrements, les plus recents")
|
||||||
|
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
warnings.filterwarnings("ignore") # avertissements de torch sur les extraits très courts
|
||||||
|
logging.getLogger("faster_whisper").setLevel(logging.WARNING) # une ligne par transcription sinon
|
||||||
|
|
||||||
|
settings = load_settings(args.config)
|
||||||
|
try:
|
||||||
|
from voice_server.diarization import Diarizer
|
||||||
|
from voice_server.speaker_identifier import SpeakerIdentifier
|
||||||
|
from voice_server.speech_to_text import SpeechToText
|
||||||
|
from voice_server.voice_activity import VoiceActivityDetector
|
||||||
|
|
||||||
|
vad = VoiceActivityDetector(settings.vad, settings.audio.sample_rate)
|
||||||
|
stt = SpeechToText(settings.stt, settings.audio.sample_rate)
|
||||||
|
diarizer = Diarizer(settings.diarization, settings.audio.sample_rate)
|
||||||
|
except (ImportError, FileNotFoundError) as error:
|
||||||
|
logger.error("%s : lancez 'make install-speech', 'make install-diarization' et 'make models'", error)
|
||||||
|
return 1
|
||||||
|
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
|
||||||
|
threshold = settings.diarization.direct_below_s
|
||||||
|
|
||||||
|
files = sorted(settings.recordings_dir.glob("*.wav"), key=lambda path: path.stat().st_mtime)[-args.last:]
|
||||||
|
clips = [result.speech_pcm for result in (vad.detect(read_pcm(path)) for path in files) if result.has_speech]
|
||||||
|
if not clips:
|
||||||
|
logger.error("aucun enregistrement avec de la parole dans %s", settings.recordings_dir)
|
||||||
|
return 1
|
||||||
|
diarizer.diarize(clips[0]) # premier appel plus lent : hors mesure
|
||||||
|
diarizer.embed(clips[0])
|
||||||
|
|
||||||
|
before: list[float] = []
|
||||||
|
after: list[float] = []
|
||||||
|
short = same_text = same_name = 0
|
||||||
|
changes: list[str] = []
|
||||||
|
for pcm in clips:
|
||||||
|
speech_s = len(pcm) / 2 / settings.audio.sample_rate
|
||||||
|
diar_s, diarization = timed(lambda: diarizer.diarize(pcm))
|
||||||
|
words_s, with_words = timed(lambda: stt.transcribe(pcm))
|
||||||
|
before.append(diar_s + words_s)
|
||||||
|
old_name = (identifier.identify(diarization.embeddings[0]).label
|
||||||
|
if diarization.embeddings is not None and len(diarization.speakers) == 1 else None)
|
||||||
|
if speech_s < threshold:
|
||||||
|
short += 1
|
||||||
|
embed_s, embedding = timed(lambda: diarizer.embed(pcm))
|
||||||
|
plain_s, plain = timed(lambda: stt.transcribe(pcm, words=False))
|
||||||
|
after.append(embed_s + plain_s)
|
||||||
|
same_text += plain.text == with_words.text
|
||||||
|
verdict = identifier.identify(embedding)
|
||||||
|
same_name += verdict.label == old_name
|
||||||
|
if verdict.label != old_name:
|
||||||
|
changes.append(f"{old_name} -> {verdict.describe()} ({speech_s:.1f} s de parole)")
|
||||||
|
else:
|
||||||
|
after.append(diar_s + words_s)
|
||||||
|
|
||||||
|
def median(values: list[float]) -> float:
|
||||||
|
return statistics.median(values)
|
||||||
|
|
||||||
|
logger.info("%d enregistrements avec parole (%.1f s de parole en mediane), dont %d sous %.1f s",
|
||||||
|
len(clips), median([len(pcm) / 2 / settings.audio.sample_rate for pcm in clips]), short, threshold)
|
||||||
|
logger.info("avant : %.2f s (mediane), %.2f s (pire)", median(before), max(before))
|
||||||
|
logger.info("apres : %.2f s (mediane), %.2f s (pire)", median(after), max(after))
|
||||||
|
logger.info("gain : %.2f s, soit %.0f %%", median(before) - median(after),
|
||||||
|
100 * (1 - median(after) / median(before)))
|
||||||
|
if short:
|
||||||
|
logger.info("paroles courtes : meme texte %d/%d, meme identification %d/%d", same_text, short,
|
||||||
|
same_name, short)
|
||||||
|
for change in changes: # attendu : des voix à la limite du seuil, dans les deux sens
|
||||||
|
logger.info(" identification differente : %s", change)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Reference in New Issue
Block a user