From d114b9818e9060dfb84e136d5be3cf887345ea9f Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Tue, 6 Oct 2026 19:24:22 +0200 Subject: [PATCH] step-16 --- CONTEXT.md | 21 ++++- Makefile | 6 +- README-EN.md | 29 +++++- README.md | 29 +++++- mosquitto/voice.conf | 5 + server/config.yaml | 8 ++ server/tests/test_speech_models.py | 27 ++++++ server/tests/test_voice_pipeline.py | 50 +++++++++- server/voice_server/__main__.py | 2 + server/voice_server/hands_free.py | 4 +- server/voice_server/mqtt_link.py | 11 +++ server/voice_server/settings.py | 4 + server/voice_server/speech_to_text.py | 20 ++-- server/voice_server/text_to_speech.py | 16 +++- server/voice_server/voice_pipeline.py | 47 ++++++++-- tools/latency_check.py | 126 ++++++++++++++++++++++++++ 16 files changed, 381 insertions(+), 24 deletions(-) create mode 100644 tools/latency_check.py diff --git a/CONTEXT.md b/CONTEXT.md index f8d0b7c..6e0f21c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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)** | | 14 | Multi-utilisateurs | **fait** (serveur seul) | **oui (2026-09-30)** | | 15 | Fiabilisation | **fait** (broker + serveur + firmware) | **oui (2026-09-30)** | -| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | en attente | -| 16 | Optimisation | à faire | — | +| 15b | **Agents Linux** (ajout utilisateur) : commander des machines du réseau | **fait** (serveur + agent) | **oui (2026-10-06)** | +| 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. @@ -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 | | 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) | +| É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) | | 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) | @@ -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/access_control.py AccessPolicy : niveaux, refus, confirmation [étape 14] 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] docs/MOSQUITTO.md broker : installation, pièges, vérification [étape 3] 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 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. +- **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). diff --git a/Makefile b/Makefile index 0ed9883..ccf417a 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ OUT ?= recordings/test.wav TONE ?= 440 TEXT ?= Quelle heure est-il ? LAST ?= 5 +COUNT ?= 30 JITTER ?= 0 VOICE ?= fr_FR-tom-medium SPEAKER ?= @@ -41,7 +42,7 @@ MQTT_AS = @echo "(compte MQTT : $(if $(MQTT_USERNAME),$(MQTT_USERNAME) de s .DEFAULT_GOAL := help .PHONY: help install-system mosquitto-config mosquitto-docker mqtt-user agent-run agent-install agent-log mqtt-agents mqtt-watch mqtt-transcript mqtt-home mqtt-control venv install install-dev install-speech install-diarization install-wakeword models \ - say speech-check transcribe vad vad-all diarize diarize-demo enroll profiles forget run test lint record play inspect play-esp tone-esp play-mqtt tone-mqtt run-echo pio-venv fw-build fw-check fw-upload \ + 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 ## ------------------------------------------------------------------ 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)) \ $(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) $(PY) tools/speech_check.py "$(TEXT)" diff --git a/README-EN.md b/README-EN.md index 0b52191..74cb293 100644 --- a/README-EN.md +++ b/README-EN.md @@ -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 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 latency # server latency before/after step 16, on real recordings make transcribe OUT=server/recordings/.wav make vad OUT=server/recordings/.wav # speech found by the VAD (step 7) make vad-all # VAD over every server recording @@ -314,7 +315,33 @@ make lint # pyright type check 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 diff --git a/README.md b/README.md index d31c1f4..3dda19b 100644 --- a/README.md +++ b/README.md @@ -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 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 latency # latence du serveur avant/après l'étape 16, sur de vrais enregistrements make transcribe OUT=server/recordings/.wav make vad OUT=server/recordings/.wav # parole trouvée par la VAD (étape 7) make vad-all # VAD sur tous les enregistrements du serveur @@ -317,7 +318,33 @@ make lint # typage pyright 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 diff --git a/mosquitto/voice.conf b/mosquitto/voice.conf index b52bc84..32d6661 100644 --- a/mosquitto/voice.conf +++ b/mosquitto/voice.conf @@ -27,6 +27,11 @@ allow_anonymous false password_file /mosquitto/config/passwd 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). # Un chunk audio fait 1600 octets : 8192 laisse une large marge. # (remplace « message_size_limit », obsolète depuis Mosquitto 2.0) diff --git a/server/config.yaml b/server/config.yaml index 3aac876..b629140 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -42,6 +42,9 @@ stt: compute_type: int8 beam_size: 5 # 1 = plus rapide, 5 = plus précis no_speech_threshold: 0.6 # segment écarté au-delà : Whisper invente du texte sur le silence + # étape 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/.onnx, « make models ») # 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 » model: pyannote/speaker-diarization-community-1 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é). # Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique). diff --git a/server/tests/test_speech_models.py b/server/tests/test_speech_models.py index 1ed8161..588e8e3 100644 --- a/server/tests/test_speech_models.py +++ b/server/tests/test_speech_models.py @@ -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 "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() diff --git a/server/tests/test_voice_pipeline.py b/server/tests/test_voice_pipeline.py index 545ec1b..5140e3c 100644 --- a/server/tests/test_voice_pipeline.py +++ b/server/tests/test_voice_pipeline.py @@ -42,6 +42,11 @@ class FakeVad: class FakeDiarizer: def __init__(self) -> None: 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: self.lengths.append(len(pcm)) @@ -67,10 +72,12 @@ class FakeStt: self.words = words self.calls = 0 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.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, 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, diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | ShortYesIdentifier | 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 return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer, identifier=identifier, publisher=publisher, controller=controller, policy=policy, - read=lambda result: PCM, clock=lambda: next(ticks)) + read=lambda result: PCM, clock=lambda: next(ticks), direct_below_s=direct_below_s) 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 [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.") + + +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") diff --git a/server/voice_server/__main__.py b/server/voice_server/__main__.py index 42a1954..66098d5 100644 --- a/server/voice_server/__main__.py +++ b/server/voice_server/__main__.py @@ -11,6 +11,7 @@ Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton. Étape 14 : droits par profil (complet / standard / limite), confirmation des actions sensibles. Étape 15 : présence + testament (les cartes coupent leur micro si le serveur meurt), journal fichier. +Étape 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. """ @@ -136,6 +137,7 @@ def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioS publisher=link, controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None, policy=AccessPolicy(settings.access, settings.home, settings.machines), + direct_below_s=settings.diarization.direct_below_s, ) return pipeline.handle diff --git a/server/voice_server/hands_free.py b/server/voice_server/hands_free.py index a21aff5..c9960b2 100644 --- a/server/voice_server/hands_free.py +++ b/server/voice_server/hands_free.py @@ -42,7 +42,9 @@ from voice_server.wake_word import Detection logger = logging.getLogger(__name__) -CHECK_EVERY_CHUNKS = 5 # recherche du silence final toutes les 250 ms d'audio +# 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 STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché diff --git a/server/voice_server/mqtt_link.py b/server/voice_server/mqtt_link.py index e8f54d7..e88582e 100644 --- a/server/voice_server/mqtt_link.py +++ b/server/voice_server/mqtt_link.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import socket import threading from collections.abc import Callable from typing import Any @@ -94,6 +95,7 @@ class MqttLink: self._client.on_connect = self._on_connect self._client.on_disconnect = self._on_disconnect 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) if self._presence is not None: # Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir @@ -165,6 +167,15 @@ class MqttLink: client.subscribe(pattern, qos=1) 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, flags: mqtt.DisconnectFlags, reason_code: ReasonCode, properties: Properties | None) -> None: diff --git a/server/voice_server/settings.py b/server/voice_server/settings.py index b785543..a754fd8 100644 --- a/server/voice_server/settings.py +++ b/server/voice_server/settings.py @@ -61,6 +61,7 @@ class SttSettings: beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis no_speech_threshold: float model_dir: Path # dossier du modèle téléchargé par « make models » + cpu_threads: int = 0 # fils de calcul de Whisper ; 0 : défaut de la bibliothèque (4) @dataclass(frozen=True) @@ -90,6 +91,7 @@ class DiarizationSettings: model: str # dépôt Hugging Face du pipeline max_speakers: int 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) @@ -285,6 +287,7 @@ def load_settings(path: Path) -> Settings: beam_size=int(stt["beam_size"]), no_speech_threshold=float(stt["no_speech_threshold"]), model_dir=models_dir / "whisper" / str(stt["model"]), + cpu_threads=int(stt.get("cpu_threads", 0)), ), tts=TtsSettings( voice=str(tts["voice"]), @@ -303,6 +306,7 @@ def load_settings(path: Path) -> Settings: max_speakers=int(diarization["max_speakers"]), # « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1 model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1], + direct_below_s=float(diarization.get("direct_below_s", 0.0)), ), speaker=SpeakerSettings( profiles_dir=(base / str(speaker["profiles_dir"])).resolve(), diff --git a/server/voice_server/speech_to_text.py b/server/voice_server/speech_to_text.py index 288a8d0..1be7156 100644 --- a/server/voice_server/speech_to_text.py +++ b/server/voice_server/speech_to_text.py @@ -74,13 +74,19 @@ class SpeechToText: began = time.monotonic() # int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32. - self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type) + 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. self.transcribe(bytes(sample_rate * 2)) logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began) - def transcribe(self, pcm: bytes) -> Transcript: - """Transcrit le PCM et écarte les segments suspects.""" + def transcribe(self, pcm: bytes, words: bool = True) -> Transcript: + """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() audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype=" None: @@ -53,12 +59,16 @@ class TextToSpeech: self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale) self._resampler = resampler 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, time.monotonic() - began, self._voice.config.sample_rate) def synthesize(self, text: str) -> Speech: """Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout.""" began = time.monotonic() + 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] = [] rate = self._voice.config.sample_rate for chunk in self._voice.synthesize(text, syn_config=self._config): @@ -66,8 +76,12 @@ class TextToSpeech: rate = chunk.sample_rate signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32) pcm = self._resampler.convert(signal, rate) - return Speech( + speech = Speech( pcm=pcm, audio_s=len(pcm) / 2 / self._sample_rate, 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 diff --git a/server/voice_server/voice_pipeline.py b/server/voice_server/voice_pipeline.py index 73d5869..d252ae9 100644 --- a/server/voice_server/voice_pipeline.py +++ b/server/voice_server/voice_pipeline.py @@ -7,6 +7,7 @@ VAD → diarisation → identification → transcription → qui a dit quoi → from __future__ import annotations import logging +import math import time from collections.abc import Callable from dataclasses import dataclass @@ -30,6 +31,7 @@ from voice_server.voice_activity import VadResult logger = logging.getLogger(__name__) MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot +DIRECT_LABEL = "SPEAKER_00" # étiquette de l'unique locuteur d'une parole courte (étape 16) class SpeechDetector(Protocol): @@ -43,6 +45,8 @@ class SpeakerSplitter(Protocol): def diarize(self, pcm: bytes) -> Diarization: ... + def embed(self, pcm: bytes) -> np.ndarray: ... + class Identifier(Protocol): """Ce que la boucle attend de l'identification du locuteur.""" @@ -64,7 +68,7 @@ class Policy(Protocol): class Transcriber(Protocol): """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): @@ -130,9 +134,12 @@ class VoicePipeline: policy: Policy | None = None, read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path), clock: Callable[[], float] = time.monotonic, + direct_below_s: float = 0.0, ) -> None: """ 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). identifier: identification ; sans diarisation, elle n'a pas d'empreintes. publisher: publie « qui a dit quoi » sur voice//transcript (None : journal seul). @@ -153,6 +160,7 @@ class VoicePipeline: self._policy = policy self._read = read self._clock = clock + self._direct_below_s = direct_below_s def handle(self, result: SessionResult) -> Turn | None: """Répond à la session ; None si elle était trop courte pour être une question.""" @@ -170,23 +178,30 @@ class VoicePipeline: if vad.has_speech: logger.info("%s : parole %.2f s sur %.2f s (segments %s s), VAD %.0f ms", result.device, vad.speech_s, vad.total_s, vad.bounds(), vad.elapsed_s * 1000) + # 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). - if self._diarizer is not None: + elif self._diarizer is not None: diarization = self._diarizer.diarize(vad.speech_pcm) speakers = len(diarization.speakers) logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers, diarization.describe() or "aucun tour", diarization.elapsed_s) turns = diarization.turns verdicts = self._identify(result.device, diarization) - transcript = self._stt.transcribe(vad.speech_pcm) + transcript = self._stt.transcribe(vad.speech_pcm, words=not direct) else: + direct = False logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device, result.session_id, result.levels.peak_dbfs) transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0) if transcript.dropped: logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped)) - utterances = self._who_said_what(result, transcript, turns, verdicts) + 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, # et ce sont ses droits qui s'appliquent. last = utterances[-1] if utterances else None @@ -243,12 +258,30 @@ class VoicePipeline: f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items())) return verdicts - def _who_said_what(self, result: SessionResult, transcript: Transcript, - turns: tuple[SpeakerTurn, ...], verdicts: dict[str, Identification]) -> list[Utterance]: + def _identify_directly(self, device: str, pcm: bytes, diarizer: SpeakerSplitter) -> dict[str, Identification]: + """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//transcript.""" if not transcript.text: 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 utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0, end_s=transcript.audio_s, score=None)] diff --git a/tools/latency_check.py b/tools/latency_check.py new file mode 100644 index 0000000..c9ee04f --- /dev/null +++ b/tools/latency_check.py @@ -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:]))