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
+2
View File
@@ -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
+3 -1
View File
@@ -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é
+11
View File
@@ -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:
+4
View File
@@ -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(),
+13 -7
View File
@@ -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="<i2").astype(np.float32) / 32768.0
segments, _ = self._model.transcribe(
@@ -91,12 +97,12 @@ class SpeechToText:
condition_on_previous_text=False,
no_speech_threshold=self._stt.no_speech_threshold,
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
word_timestamps=True, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
word_timestamps=words, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
)
kept: 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
text = segment.text.strip()
if not text:
@@ -105,7 +111,7 @@ class SpeechToText:
dropped.append(text)
else:
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 ())
return Transcript(
@@ -113,5 +119,5 @@ class SpeechToText:
audio_s=audio.size / self._sample_rate,
elapsed_s=time.monotonic() - began,
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__)
CACHE_PHRASES = 64 # réponses gardées en mémoire : ~100 Ko chacune, 6 Mo au plus
@dataclass(frozen=True)
class Speech:
@@ -32,6 +34,10 @@ class TextToSpeech:
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.
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:
@@ -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
+40 -7
View File
@@ -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/<carte>/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/<carte>/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)]