This commit is contained in:
Denis Defolie
2026-10-06 19:24:22 +02:00
parent 79d45587ba
commit d114b9818e
16 changed files with 381 additions and 24 deletions
+19 -2
View File
@@ -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).
+5 -1
View File
@@ -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
View File
@@ -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
+28 -1
View File
@@ -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
+5
View File
@@ -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)
+8
View File
@@ -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).
+27
View File
@@ -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()
+47 -3
View File
@@ -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")
+2
View File
@@ -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
+3 -1
View File
@@ -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é
+11
View File
@@ -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:
+4
View File
@@ -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(),
+13 -7
View File
@@ -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),
) )
+15 -1
View File
@@ -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
+39 -6
View File
@@ -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,11 +258,29 @@ 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 []
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) 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,
+126
View File
@@ -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:]))