Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite

État du projet au premier commit :
- firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament),
  mains libres, domotique de démonstration, chien de garde, présence du serveur ;
- serveur Python : sessions, VAD, faster-whisper, pyannote, identification,
  droits par profil, domotique, mot de réveil, Piper ;
- broker Mosquitto : configuration, comptes et ACL (non activés en développement) ;
- étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux.

Étapes 1 à 15 validées. 203 tests, pyright sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-09-30 17:40:55 +02:00
commit d20976fea2
108 changed files with 15022 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
"""Serveur d'assistant vocal local.
Les modules de ce paquet sont ajoutés étape par étape :
étape 4 : MqttLink, SessionManager, AudioBuffer
étape 5 : AudioResampler, AudioSender (mode écho)
étape 6 : SpeechToText, TextToSpeech, Assistant, VoicePipeline
étape 7 : VoiceActivityDetector
étape 8 : Diarizer
étape 9 : SpeakerIdentifier
"""
__version__ = "0.1.0"
+261
View File
@@ -0,0 +1,261 @@
"""Point d'entrée du serveur vocal : python -m voice_server --config config.yaml
Étape 4 : reçoit les sessions de parole des ESP32 par MQTT et les écrit en WAV.
Étape 5 : avec --echo, renvoie chaque session à la carte, qui la joue.
Étape 6 : par défaut, répond à chaque session : faster-whisper → Assistant → Piper.
Étape 7 : la VAD (Silero) retire les silences et écarte les sessions sans parole.
Étape 8 : la diarisation (pyannote) découpe la parole par locuteur.
Étape 9 : chaque locuteur est comparé aux profils vocaux : un nom, ou « Inconnu ».
Étape 10 : qui a dit quoi, publié sur voice/<carte>/transcript ; l'assistant appelle par son nom.
Étape 11 : domotique : « ferme les volets du salon » → home/salon/shutter/set (+ la carte si c'est sa pièce).
Étape 13 : mains libres : « Alexa, allume la lumière », sans le bouton.
Étape 14 : droits par profil (complet / standard / limite), confirmation des actions sensibles.
Étape 15 : présence + testament (les cartes coupent leur micro si le serveur meurt), journal fichier.
Étape 15b : machines Linux : « éteins le PC du bureau » → agent/pc-bureau/command ; « allume » → Wake-on-LAN.
"""
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
import queue
import signal
import sys
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING
from voice_server.audio_resampler import AudioResampler
from voice_server.audio_sender import AudioSender
from voice_server.mqtt_link import MqttLink
from voice_server.session_manager import Outcome, SessionManager, SessionResult
from voice_server.settings import Settings, load_settings
if TYPE_CHECKING:
from voice_server.hands_free import HandsFreeListener
logger = logging.getLogger("voice_server")
SWEEP_PERIOD_S = 0.2 # fréquence de vérification des sessions terminées ou muettes
LOG_FILE_BYTES = 1_000_000 # journal fichier : 1 Mo par fichier...
LOG_FILE_COUNT = 3 # ... et 3 anciens gardés : 4 Mo au plus
def setup_logging(settings: Settings) -> None:
"""Journal à l'écran, et copie dans un fichier tournant si config.yaml le demande."""
handlers: list[logging.Handler] = [logging.StreamHandler()]
if settings.log_file is not None:
settings.log_file.parent.mkdir(parents=True, exist_ok=True)
# Tournant : au-delà de 1 Mo, le fichier est renommé .1 et un nouveau commence ;
# le disque (carte SD d'un Raspberry Pi) ne se remplit jamais.
handlers.append(logging.handlers.RotatingFileHandler(
settings.log_file, maxBytes=LOG_FILE_BYTES, backupCount=LOG_FILE_COUNT, encoding="utf-8"))
logging.basicConfig(level=settings.log_level, format=settings.log_format, handlers=handlers, force=True)
def log_state(device: str, payload: bytes) -> None:
"""Journalise l'état publié par une carte (retenu : reçu dès l'abonnement)."""
try:
state = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
if state.get("status") == "online":
# La durée de fonctionnement distingue une republication périodique d'un redémarrage.
logger.info("%s : en ligne depuis %s s, IP %s, signal %s dBm", device,
state.get("uptime_s"), state.get("ip"), state.get("rssi"))
else:
logger.warning("%s : %s", device, state.get("status"))
def make_echo(resampler: AudioResampler, sender: AudioSender) -> Callable[[SessionResult], object]:
"""Mode écho (étape 5) : la carte rejoue ce qu'elle vient d'enregistrer."""
def echo(result: SessionResult) -> None:
if result.duration_s < 0.2:
logger.warning("%s : session %s trop courte pour l'echo (%.2f s) : bouton relache trop tot ?",
result.device, result.session_id, result.duration_s)
return
sender.send(result.device, resampler.from_wav(result.path), f"echo-{result.session_id}")
return echo
def make_assistant(settings: Settings, resampler: AudioResampler, sender: AudioSender,
link: MqttLink | None = None) -> Callable[[SessionResult], object]:
"""Mode assistant (étapes 6-14) : charge les modèles, puis répond à chaque session."""
# Imports tardifs : le mode écho fonctionne sans l'extra « speech ».
from voice_server.access_control import AccessPolicy
from voice_server.assistant import Assistant
from voice_server.command_router import CommandRouter
from voice_server.diarization import Diarizer
from voice_server.home_control import HomeController
from voice_server.machine_control import MachineController
from voice_server.speaker_identifier import SpeakerIdentifier
from voice_server.speech_to_text import SpeechToText
from voice_server.text_to_speech import TextToSpeech
from voice_server.voice_activity import VoiceActivityDetector
from voice_server.voice_pipeline import VoicePipeline
try:
stt = SpeechToText(settings.stt, settings.audio.sample_rate)
tts = TextToSpeech(settings.tts, resampler, settings.audio.sample_rate)
vad = VoiceActivityDetector(settings.vad, settings.audio.sample_rate)
except ImportError as error:
raise ImportError(f"{error} : lancez 'make install-speech'") from error
diarizer = None
identifier = None
if settings.diarization.enabled:
try:
diarizer = Diarizer(settings.diarization, settings.audio.sample_rate)
except ImportError as error:
raise ImportError(f"{error} : lancez 'make install-diarization', ou "
"diarization.enabled: false dans config.yaml") from error
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
else:
logger.info("diarisation desactivee (config.yaml) : pas d'identification du locuteur")
machines = None
if link is not None:
machines = MachineController(settings.machines, link)
# États retenus des agents : reçus dès l'abonnement, même démarrés avant le serveur.
link.on_topic(f"{settings.machines.topic_prefix}/+/state", machines.on_state)
link.on_topic(f"{settings.machines.topic_prefix}/+/result", machines.on_result)
logger.info("machines : %s", ", ".join(settings.machines.machines) or "aucune (config.yaml)")
pipeline = VoicePipeline(
vad=vad,
stt=stt,
assistant=Assistant(home=settings.home, machines=machines),
tts=tts,
player=sender,
diarizer=diarizer,
identifier=identifier,
publisher=link,
controller=CommandRouter(HomeController(settings.home, link), machines) if link is not None else None,
policy=AccessPolicy(settings.access, settings.home, settings.machines),
)
return pipeline.handle
def make_listener(settings: Settings, link: MqttLink,
on_command: Callable[[SessionResult], None]) -> "HandsFreeListener":
"""Écoute mains libres (étape 13) : un détecteur de mot de réveil par carte."""
from voice_server.hands_free import HandsFreeListener
from voice_server.voice_activity import VoiceActivityDetector
from voice_server.wake_word import WakeWordDetector
WakeWordDetector(settings.wake) # vérifie modèles et installation dès le démarrage
return HandsFreeListener(
settings.wake, settings.audio, VoiceActivityDetector(settings.vad, settings.audio.sample_rate),
settings.recordings_dir, link, on_command,
detector_factory=lambda device: WakeWordDetector(settings.wake, device),
)
def worker(jobs: "queue.Queue[SessionResult | None]", handle: Callable[[SessionResult], object]) -> None:
"""Traite les sessions une à une, hors du fil réseau : chacune dure des secondes."""
while True:
result = jobs.get()
if result is None:
return
try:
handle(result)
except Exception: # une session ratée ne doit jamais arrêter le serveur
logger.exception("%s : echec du traitement de %s", result.device, result.path.name)
def main(argv: list[str]) -> int:
"""Lance le serveur jusqu'à Ctrl-C."""
parser = argparse.ArgumentParser(description="serveur de l'assistant vocal")
parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parents[1] / "config.yaml")
parser.add_argument("--echo", action="store_true",
help="renvoie chaque session a la carte, qui la joue (etape 5), sans modeles")
args = parser.parse_args(argv)
settings = load_settings(args.config)
setup_logging(settings)
logging.getLogger("faster_whisper").setLevel(logging.WARNING) # une ligne par transcription sinon
logger.info("enregistrements dans %s", settings.recordings_dir)
if settings.log_file is not None:
logger.info("journal copie dans %s", settings.log_file)
if settings.mqtt.username is None:
logger.warning("pas d'identifiants MQTT (server/.env) : refuse par un broker securise (etape 15)")
link = MqttLink(settings.mqtt, presence=True)
resampler = AudioResampler(settings.audio)
sender = AudioSender(link, settings.audio)
if args.echo:
logger.info("mode echo : chaque session sera rejouee par la carte")
handle = make_echo(resampler, sender)
else:
try:
handle = make_assistant(settings, resampler, sender, link)
except ImportError as error:
logger.error("%s", error)
return 1
except FileNotFoundError as error:
logger.error("%s (ou diarization.enabled: false dans config.yaml)", error)
return 1
logger.info("mode assistant : appuyez sur le bouton et posez une question")
jobs: "queue.Queue[SessionResult | None]" = queue.Queue()
def on_finished(result: SessionResult) -> None:
if result.outcome is not Outcome.INTERRUPTED:
jobs.put(result) # rend la main aussitôt : le traitement dure des secondes
sessions = SessionManager(settings.audio, settings.sessions, settings.recordings_dir,
on_finished=on_finished)
listener = None
if settings.wake.enabled and not args.echo:
try:
listener = make_listener(settings, link, on_finished)
except (ImportError, FileNotFoundError) as error:
logger.error("%s : lancez 'make install-wakeword' et 'make models', "
"ou wake.enabled: false dans config.yaml", error)
return 1
logger.info("mains libres : dites %s", ", ".join(f"« {w} »" for w in settings.wake.words))
threading.Thread(target=worker, args=(jobs, handle), daemon=True).start()
def on_state(device: str, payload: bytes) -> None:
log_state(device, payload)
if listener is not None:
listener.on_state(device, payload)
def on_event(device: str, payload: bytes) -> None:
sessions.on_event(device, payload)
if listener is not None:
listener.on_event(device, payload)
link.on("state", on_state)
link.on("event", on_event)
link.on("audio/in", sessions.on_audio)
if listener is not None:
link.on("audio/stream", listener.on_stream)
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
link.start()
try:
while not stop.wait(SWEEP_PERIOD_S):
sessions.sweep()
if listener is not None:
listener.sweep()
except KeyboardInterrupt:
pass
finally:
jobs.put(None)
if listener is not None:
listener.stop() # les cartes cessent d'envoyer leur micro
time.sleep(0.5) # laisse partir ces messages avant la déconnexion
link.stop()
logger.info("arret du serveur")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+179
View File
@@ -0,0 +1,179 @@
"""Droits par profil : qui peut demander quoi (étape 14).
Trois niveaux, du plus au moins permissif :
complet tout, y compris les actions sensibles, qui demandent une confirmation ;
standard la domotique courante (lumières, volets, fermer la porte), réveiller
ou verrouiller une machine (étape 15b) ;
limite la conversation seulement (heure, date…).
L'identification vocale est une ESTIMATION : une imitation ou un enregistrement
peut tromper. Ces droits évitent les erreurs et les commandes d'un invité, pas
un attaquant déterminé. D'où deux garde-fous : le niveau complet exige une voix
reconnue nettement, et les actions sensibles une confirmation par la même personne.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from voice_server.home_control import ALLOWED, announce, request
from voice_server.machine_control import MACHINE_DEVICE, Command, MachineCommand
from voice_server.settings import AccessSettings, HomeSettings, MachinesSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
YES = ("oui", "confirme", "confirmer", "ok", "okay", "valide")
NO = ("non", "annule", "annuler", "stop", "laisse")
class Level(IntEnum):
"""Niveau de droits ; l'ordre permet de comparer (complet > standard > limite)."""
LIMITED = 1
STANDARD = 2
FULL = 3
LEVELS = {"limite": Level.LIMITED, "standard": Level.STANDARD, "complet": Level.FULL}
LEVEL_NAMES = {level: name for name, level in LEVELS.items()}
def of(phrase: str) -> str:
"""« de » élidé devant une voyelle : « d'ouvrir », « d'éteindre », mais « de fermer »."""
return f"d'{phrase}" if normalize(phrase)[:1] in "aeiouyh" else f"de {phrase}"
def parse_level(name: str) -> Level:
"""« complet » → Level.FULL ; erreur claire si le nom est inconnu."""
level = LEVELS.get(normalize(name))
if level is None:
raise ValueError(f"niveau de droits inconnu : « {name} » (complet, standard ou limite)")
return level
@dataclass(frozen=True)
class Verdict:
"""Décision pour une commande."""
allowed: bool
confirm: bool # exécuter seulement après un « oui »
answer: str | None # refus ou question de confirmation ; None : on exécute
@dataclass(frozen=True)
class Resolution:
"""Réponse à une confirmation en attente."""
answer: str
command: Command | None # à exécuter (oui), None (non, autre personne)
@dataclass(frozen=True)
class _Pending:
command: Command
speaker: str
expires: float
class AccessPolicy:
"""Applique les droits de config.yaml et garde les confirmations en attente, par carte."""
def __init__(self, access: AccessSettings, home: HomeSettings, machines: MachinesSettings | None = None,
clock: Callable[[], float] = time.monotonic) -> None:
"""
Args:
machines: actions des machines (étape 15b), pour valider access.rules.machine.
Raises:
ValueError: niveau ou action inconnus dans config.yaml.
"""
self._access = access
self._home = home
self._clock = clock
self._unknown = parse_level(access.unknown_level)
self._default = parse_level(access.default_level)
self._users = {name: parse_level(level) for name, level in access.users.items()}
self._rules = {device: {action: parse_level(level) for action, level in actions.items()}
for device, actions in access.rules.items()}
allowed = {**ALLOWED, MACHINE_DEVICE: tuple(machines.actions) if machines is not None else ()}
for device, actions in self._rules.items():
# Une règle mal écrite serait ignorée en silence (action exigeant alors « complet »).
unknown = [f"{device}.{action}" for action in actions if action not in allowed.get(device, ())]
if unknown:
raise ValueError(f"config.yaml, access.rules : action inconnue {', '.join(unknown)} "
f"(connues : {', '.join(f'{d}.{a}' for d, acts in allowed.items() for a in acts)})")
self._pending: dict[str, _Pending] = {}
def level(self, speaker: str | None, score: float | None) -> Level:
"""Niveau de la personne ; le niveau complet exige une identification nette."""
if speaker is None:
return self._unknown
level = self._users.get(speaker, self._default)
if level is Level.FULL and (score is None or score < self._access.full_min_score):
return Level.STANDARD # voix reconnue de justesse : pas assez sûr pour tout permettre
return level
def check(self, command: Command, speaker: str | None, score: float | None) -> Verdict:
"""La personne peut-elle faire cette commande, et faut-il confirmer ?"""
# Action absente des règles : réservée au niveau complet (on ne permet pas par oubli).
required = self._rules.get(command.device, {}).get(command.action, Level.FULL)
level = self._level_logged(speaker, score, command, required)
what = self._request(command)
if level < required:
if speaker is None:
return Verdict(False, False, f"Je ne vous ai pas reconnu : je ne peux pas {what}.")
if self._users.get(speaker, self._default) >= required:
# Le profil le permet, mais la voix a été reconnue de justesse (full_min_score).
return Verdict(False, False, f"{speaker}, je ne reconnais pas assez nettement votre voix "
f"pour {what}. Répétez, plus près du micro.")
return Verdict(False, False, f"Désolé {speaker}, votre profil ne permet pas {of(what)}.")
if f"{command.device}.{command.action}" in self._access.confirm:
return Verdict(True, True, f"Confirmez-vous : {what} ? Dites oui ou non.")
return Verdict(True, False, None)
def ask(self, board: str, command: Command, speaker: str | None) -> None:
"""Retient une commande en attente de « oui » de la même personne."""
if speaker is not None:
self._pending[board] = _Pending(command, speaker, self._clock() + self._access.confirm_timeout_s)
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None:
"""Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire."""
pending = self._pending.pop(board, None)
if pending is None or self._clock() > pending.expires:
return None
words = normalize(text).split()
said_yes = any(word in YES for word in words) or "d accord" in " ".join(words)
said_no = any(word in NO for word in words)
if not (said_yes or said_no):
return None # autre chose : la demande en attente est abandonnée, on traite la nouvelle
if speaker != pending.speaker:
logger.warning("%s : confirmation de %s refusee, donnee par %s", board, pending.speaker,
speaker or "une voix inconnue")
return Resolution(f"Seul {pending.speaker} peut confirmer. J'annule.", None)
if said_no:
return Resolution("D'accord, j'annule.", None)
return Resolution(self._announce(pending.command), pending.command)
def _request(self, command: Command) -> str:
"""« ouvrir la porte du salon », « éteindre le PC du bureau »."""
return command.asked if isinstance(command, MachineCommand) else request(command, self._home)
def _announce(self, command: Command) -> str:
"""« J'ouvre la porte du salon. », « J'éteins le PC du bureau. »"""
return command.said if isinstance(command, MachineCommand) else announce(command, self._home)
def _level_logged(self, speaker: str | None, score: float | None, command: Command,
required: Level) -> Level:
level = self.level(speaker, score)
verdict = "autorise" if level >= required else "REFUSE"
target = command.machine if isinstance(command, MachineCommand) else command.room
logger.info("droits : %s (niveau %s, score %s) %s %s %s : %s (requis %s)",
speaker or "inconnu", LEVEL_NAMES[level], "?" if score is None else f"{score:.2f}",
command.action, command.device, target, verdict, LEVEL_NAMES[required])
return level
+129
View File
@@ -0,0 +1,129 @@
"""Choix de la réponse à une phrase transcrite (étapes 6, 10, 11 et 15b).
D'abord les machines (étape 15b) : une phrase qui nomme une machine (« le PC du
bureau ») la concerne, même si elle contient « éteins ». Puis la domotique
(étape 11) : une action, un appareil, une pièce donnent une commande. Sinon,
quelques règles de conversation dont on connaît d'avance la réponse (heure,
date, salutations) ; à défaut, la phrase est répétée.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from voice_server.home_control import decide, parse_intent
from voice_server.machine_control import Command, MachineDecision
from voice_server.settings import HomeSettings
from voice_server.text_normalize import normalize
DAYS = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
MONTHS = ("janvier", "février", "mars", "avril", "mai", "juin", "juillet",
"août", "septembre", "octobre", "novembre", "décembre")
NOTHING_HEARD = "Je n'ai rien entendu."
@dataclass(frozen=True)
class Reply:
"""Ce que l'assistant dit, et la commande domotique à exécuter s'il y en a une."""
text: str
command: Command | None = None
class MachineInterpreter(Protocol):
"""Ce que l'assistant attend des machines (MachineController)."""
def interpret(self, text: str) -> MachineDecision | None: ...
def say_time(now: datetime) -> str:
"""L'heure écrite comme on la dit : Piper lit mal « 14:05 »."""
if now.hour == 0:
hours = "minuit"
elif now.hour == 12:
hours = "midi"
elif now.hour == 1:
hours = "une heure" # écrit « 1 heure », Piper dit « un heure »
else:
hours = f"{now.hour} heures"
return f"Il est {hours} {now.minute}." if now.minute else f"Il est {hours}."
def say_date(now: datetime) -> str:
"""La date en toutes lettres, « premier » pour le 1er du mois."""
day = "premier" if now.day == 1 else str(now.day)
return f"Nous sommes le {DAYS[now.weekday()]} {day} {MONTHS[now.month - 1]} {now.year}."
class Assistant:
"""Associe une réponse à une phrase, par mots-clés.
Les règles sont essayées dans l'ordre : la plus précise d'abord, pour que
« bonjour, quelle heure est-il ? » donne l'heure et non une salutation.
"""
def __init__(self, clock: Callable[[], datetime] = datetime.now, home: HomeSettings | None = None,
machines: MachineInterpreter | None = None) -> None:
"""
Args:
clock: source de l'heure, injectable pour les tests.
home: pièces et cartes (étape 11) ; None : pas de domotique.
machines: machines du réseau (étape 15b) ; None : pas de machines.
"""
self._clock = clock
self._home = home
self._machines = machines
# (mots-clés normalisés, réponse) : une seule occurrence suffit.
self._rules: list[tuple[tuple[str, ...], Callable[[str | None], str]]] = [
# Whisper écrit parfois « Quel heure » : on accepte les deux accords.
(("quelle heure", "quel heure", "l heure", "heure est il"), lambda who: say_time(self._clock())),
(("quel jour", "la date", "quelle date"), lambda who: say_date(self._clock())),
(("comment tu t appelles", "qui es tu", "ton nom"),
lambda who: "Je suis votre assistant vocal, et je fonctionne sans Internet."),
# Étape 10 : l'assistant sait qui parle, il l'appelle par son nom.
(("bonjour", "bonsoir", "salut"),
lambda who: f"Bonjour {who} ! Que puis-je faire pour vous ?" if who
else "Bonjour ! Que puis-je faire pour vous ?"),
(("merci",), lambda who: f"Avec plaisir, {who}." if who else "Avec plaisir."),
# Étape 11 : sans Internet, pas de météo ; mieux vaut le dire que répéter la question.
(("meteo", "quel temps"), lambda who: "Je fonctionne sans Internet : je n'ai pas accès à la météo."),
]
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply:
"""Machines, puis domotique, puis conversation.
Args:
text: phrase transcrite.
speaker: nom de la personne reconnue, None si inconnue.
board: carte qui a entendu la phrase : sa pièce est la pièce par défaut.
"""
if self._machines is not None:
decision = self._machines.interpret(text)
if decision is not None:
return Reply(decision.answer, decision.command)
if self._home is not None:
intent = parse_intent(text, list(self._home.rooms))
if intent is not None:
decision = decide(intent, self._home, board)
return Reply(decision.answer, decision.command)
return Reply(self.reply(text, speaker))
def reply(self, text: str, speaker: str | None = None) -> str:
"""Rend la réponse à dire ; à défaut de règle, répète ce qui a été compris.
Args:
text: phrase transcrite.
speaker: nom de la personne reconnue, None si inconnue.
"""
words = normalize(text)
if not words:
return NOTHING_HEARD
padded = f" {words} " # espaces : « salut » ne doit pas reconnaître « salutations »
for keywords, answer in self._rules:
if any(f" {keyword} " in padded for keyword in keywords):
return answer(speaker)
return f"Vous avez dit : {text.strip()}"
+76
View File
@@ -0,0 +1,76 @@
"""Accumulation du PCM d'une session et écriture en WAV."""
from __future__ import annotations
import math
import wave
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from voice_server.settings import AudioSettings
@dataclass(frozen=True)
class Levels:
"""Niveaux d'un signal, en dBFS (0 = pleine échelle)."""
peak_dbfs: float
rms_dbfs: float
class AudioBuffer:
"""Tampon PCM s16le borné en taille, pour une seule session."""
def __init__(self, audio: AudioSettings, max_seconds: float) -> None:
self._audio = audio
self._max_bytes = int(audio.bytes_per_second * max_seconds)
self._data = bytearray()
self.truncated = False # vrai si des données ont été refusées (trop long)
def append(self, pcm: bytes) -> None:
"""Ajoute un chunk ; au-delà de la durée maximale, le surplus est ignoré."""
room = self._max_bytes - len(self._data)
if len(pcm) > room:
self.truncated = True
pcm = pcm[: max(0, room)]
self._data.extend(pcm)
@property
def byte_count(self) -> int:
"""Nombre d'octets accumulés."""
return len(self._data)
@property
def duration_s(self) -> float:
"""Durée accumulée, en secondes."""
return len(self._data) / self._audio.bytes_per_second
def levels(self) -> Levels:
"""Crête et valeur efficace du contenu."""
samples = np.frombuffer(bytes(self._data[: len(self._data) // 2 * 2]), dtype="<i2")
if samples.size == 0:
return Levels(peak_dbfs=-math.inf, rms_dbfs=-math.inf)
normalized = samples.astype(np.float64) / 32768.0
peak = float(np.max(np.abs(normalized)))
rms = float(np.sqrt(np.mean(normalized**2)))
return Levels(
peak_dbfs=20.0 * math.log10(peak) if peak > 0 else -math.inf,
rms_dbfs=20.0 * math.log10(rms) if rms > 0 else -math.inf,
)
def write_wav(self, path: Path) -> None:
"""Écrit le contenu dans un fichier WAV (dossiers créés au besoin)."""
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(self._audio.channels)
wav.setsampwidth(self._audio.bits // 8)
wav.setframerate(self._audio.sample_rate)
wav.writeframes(bytes(self._data))
def read_pcm(path: Path) -> bytes:
"""Relit le PCM d'un WAV écrit par write_wav (format du système, sans conversion)."""
with wave.open(str(path), "rb") as wav:
return wav.readframes(wav.getnframes())
+91
View File
@@ -0,0 +1,91 @@
"""Mise au format du système de tout audio destiné à un ESP32."""
from __future__ import annotations
import math
import wave
from pathlib import Path
import numpy as np
import soxr
from voice_server.settings import AudioSettings
class AudioFormatError(ValueError):
"""Fichier audio que le module wave ne sait pas lire."""
class AudioResampler:
"""Convertit en PCM s16le mono au format du système, crête normalisée.
L'ESP32 reste simple : il ne sait jouer que du 16 kHz mono. Tout le reste
se fait ici — Piper, par exemple, produit du 22 050 Hz (étape 6).
La normalisation de crête fixe le volume d'écoute : sans elle, une voix
enregistrée à -30 dBFS sort presque inaudible du haut-parleur (étape 2).
"""
def __init__(self, audio: AudioSettings, target_peak_dbfs: float | None = -3.0) -> None:
"""
Args:
audio: format de sortie (fréquence du système).
target_peak_dbfs: crête visée, None pour ne pas toucher au niveau.
"""
self._audio = audio
self._target_peak = None if target_peak_dbfs is None else 10.0 ** (target_peak_dbfs / 20.0)
def from_wav(self, path: Path) -> bytes:
"""Lit un WAV PCM (8, 16, 24 ou 32 bits, mono ou stéréo, toute fréquence)."""
try:
with wave.open(str(path), "rb") as wav:
raw = wav.readframes(wav.getnframes())
width = wav.getsampwidth()
channels = wav.getnchannels()
rate = wav.getframerate()
except (wave.Error, EOFError) as error: # EOFError : fichier tronqué
raise AudioFormatError(
f"{path} : {error}. Conversion : ffmpeg -i {path} -ar 16000 -ac 1 -sample_fmt s16 sortie.wav"
) from error
samples = _decode_pcm(raw, width).reshape(-1, channels)
return self.convert(samples, rate)
def convert(self, samples: np.ndarray, rate: int) -> bytes:
"""Convertit des échantillons flottants dans [-1, 1], forme (n,) ou (n, canaux)."""
signal = np.asarray(samples, dtype=np.float64)
if signal.ndim == 2:
signal = signal.mean(axis=1) # mono : moyenne des canaux
if rate != self._audio.sample_rate and signal.size > 0:
# soxr : rééchantillonneur de référence, sans repliement audible.
signal = soxr.resample(signal, rate, self._audio.sample_rate, quality="HQ")
peak = float(np.max(np.abs(signal))) if signal.size else 0.0
if self._target_peak is not None and peak > 0.0:
signal = signal * (self._target_peak / peak)
clipped = np.clip(np.round(signal * 32767.0), -32768, 32767)
return clipped.astype("<i2").tobytes()
def _decode_pcm(raw: bytes, width: int) -> np.ndarray:
"""Décode du PCM entier little-endian en flottants dans [-1, 1]."""
if width == 1: # 8 bits : non signé, centré sur 128
return (np.frombuffer(raw, dtype=np.uint8).astype(np.float64) - 128.0) / 128.0
if width == 2:
return np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
if width == 3: # 24 bits : on complète chaque échantillon en 32 bits
bytes3 = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
padded = np.zeros((bytes3.shape[0], 4), dtype=np.uint8)
padded[:, 1:] = bytes3
return padded.view("<i4").reshape(-1).astype(np.float64) / 2147483648.0
if width == 4:
return np.frombuffer(raw, dtype="<i4").astype(np.float64) / 2147483648.0
raise AudioFormatError(f"largeur d'echantillon non geree : {width * 8} bits")
def peak_dbfs(pcm: bytes) -> float:
"""Crête d'un PCM s16le, en dBFS."""
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
peak = int(np.max(np.abs(samples.astype(np.int32)))) if samples.size else 0
return 20.0 * math.log10(peak / 32768.0) if peak > 0 else -math.inf
+99
View File
@@ -0,0 +1,99 @@
"""Envoi de PCM à un ESP32 par MQTT, au rythme réel (étape 5)."""
from __future__ import annotations
import json
import logging
import time
import uuid
from collections.abc import Callable
from typing import Protocol
from voice_server.settings import AudioSettings
logger = logging.getLogger(__name__)
class Publisher(Protocol):
"""Ce dont l'envoi a besoin du lien MQTT : publier vers une carte."""
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
def send_times(count: int, chunk_s: float, prefill: int) -> list[float]:
"""Instants d'envoi de chaque chunk, en secondes depuis le début.
Les `prefill` premiers partent aussitôt : c'est l'avance qui protège l'ESP32
des irrégularités du Wi-Fi. Chaque suivant part quand l'ESP32 a joué
l'équivalent d'un chunk, ce qui maintient l'avance constante.
"""
return [max(0.0, (index - prefill + 1) * chunk_s) for index in range(count)]
class AudioSender:
"""Envoie un PCM déjà au format du système : START, chunks, END.
START et END passent sur voice/<carte>/control en QoS 1 (garantis) ;
les chunks sur voice/<carte>/audio/out en QoS 0 (rapides, perte possible,
comptée par l'ESP32 grâce au nombre annoncé dans le END).
L'appel est bloquant pendant toute la durée du son : à lancer dans un fil
dédié, jamais dans le fil réseau de paho.
"""
def __init__(
self,
link: Publisher,
audio: AudioSettings,
prefill_chunks: int = 6,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
link: lien MQTT.
audio: format du système.
prefill_chunks: avance envoyée d'un coup ; 6 chunks = 300 ms, la FIFO
de l'ESP32 en contient 512.
sleep, clock: injectables pour les tests.
"""
self._link = link
self._audio = audio
self._prefill = prefill_chunks
self._sleep = sleep
self._clock = clock
@property
def chunk_bytes(self) -> int:
"""Taille d'un chunk : 1600 octets pour 50 ms à 16 kHz."""
return self._audio.bytes_per_second * self._audio.chunk_ms // 1000
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
"""Envoie le PCM et rend l'identifiant de la session de lecture."""
session_id = session or f"srv-{uuid.uuid4().hex[:6]}"
even = pcm[: len(pcm) // 2 * 2] # jamais de demi-échantillon
size = self.chunk_bytes
chunks = [even[i : i + size] for i in range(0, len(even), size)]
schedule = send_times(len(chunks), self._audio.chunk_ms / 1000.0, self._prefill)
start = {"event": "start", "session": session_id, "rate": self._audio.sample_rate,
"bits": self._audio.bits, "channels": self._audio.channels,
"codec": self._audio.codec, "chunk_ms": self._audio.chunk_ms}
self._link.publish(device, "control", json.dumps(start).encode(), 1)
began = self._clock()
worst_delay = 0.0
for chunk, due in zip(chunks, schedule):
wait = began + due - self._clock()
if wait > 0:
self._sleep(wait)
else:
worst_delay = max(worst_delay, -wait)
self._link.publish(device, "audio/out", chunk, 0)
end = {"event": "end", "session": session_id, "chunks": len(chunks)}
self._link.publish(device, "control", json.dumps(end).encode(), 1)
logger.info("%s : lecture %s envoyee, %d chunks (%.2f s), pire retard d'envoi %.0f ms",
device, session_id, len(chunks), len(even) / self._audio.bytes_per_second,
worst_delay * 1000)
return session_id
+33
View File
@@ -0,0 +1,33 @@
"""Aiguillage des commandes autorisées vers leur exécutant (étapes 11 et 15b)."""
from __future__ import annotations
import logging
from voice_server.home_control import HomeController
from voice_server.machine_control import Command, MachineCommand, MachineController
logger = logging.getLogger(__name__)
class CommandRouter:
"""Domotique vers HomeController, machines vers MachineController."""
def __init__(self, home: HomeController | None, machines: MachineController | None) -> None:
"""
Args:
home: domotique ; None si le serveur n'a pas de lien MQTT.
machines: agents Linux ; None si le serveur n'a pas de lien MQTT.
"""
self._home = home
self._machines = machines
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]:
"""Exécute la commande ; rend les destinations (journal, tests)."""
if isinstance(command, MachineCommand):
if self._machines is not None:
return self._machines.execute(board, command, speaker, session)
elif self._home is not None:
return self._home.execute(board, command, speaker, session)
logger.warning("%s : pas d'executant pour %s %s", board, command.action, command.device)
return []
+178
View File
@@ -0,0 +1,178 @@
"""Diarisation : qui parle quand, avec pyannote (étape 8).
La diarisation découpe l'audio en tours de parole étiquetés SPEAKER_00,
SPEAKER_01… Elle ne sait pas QUI parle : les étiquettes ne valent que pour
une session. Mettre un nom sur une voix, c'est l'identification (étape 9).
Elle ne reçoit que la parole trouvée par la VAD, jamais le flux continu du
micro : c'est le traitement le plus coûteux de la chaîne.
Étape 9 : une empreinte vocale par locuteur, calculée ici avec le modèle
d'empreintes du même pipeline (WeSpeaker ResNet34). Celles que rend pyannote ne
conviennent pas aux questions courtes : il ne garde que les fenêtres de 10 s où
le locuteur parle seul au moins 2 s ; en dessous, sa moyenne est NaN.
"""
from __future__ import annotations
import logging
import os
import time
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
from voice_server.settings import DiarizationSettings
if TYPE_CHECKING: # import réel dans __init__ : l'extra « diarization » est facultatif
from pyannote.audio import Inference, Pipeline
logger = logging.getLogger(__name__)
MIN_EMBEDDING_S = 0.3 # en dessous, l'empreinte ne vaut rien : le locuteur reste « Inconnu »
@dataclass(frozen=True)
class SpeakerTurn:
"""Un tour de parole : un locuteur, de start_s à end_s."""
speaker: str
start_s: float
end_s: float
@dataclass(frozen=True)
class Diarization:
"""Résultat de la diarisation d'une séquence parlée."""
turns: tuple[SpeakerTurn, ...] # dans l'ordre chronologique, sans chevauchement
speakers: tuple[str, ...] # étiquettes distinctes, ordre de embeddings
embeddings: np.ndarray | None # (locuteurs, dimension), ligne NaN si trop peu de parole
audio_s: float
elapsed_s: float
def describe(self) -> str:
"""Tours de parole lisibles : « SPEAKER_00 0.00-1.20, SPEAKER_01 1.35-2.60 »."""
return ", ".join(f"{t.speaker} {t.start_s:.2f}-{t.end_s:.2f}" for t in self.turns)
def disable_telemetry() -> None:
"""Coupe la télémétrie de pyannote.
pyannote 4 envoie par défaut des statistiques d'usage (durée de l'audio,
nombre de locuteurs) à otel.pyannote.ai : contraire à un assistant 100 % local.
La variable est relue à chaque traitement : la forcer ici suffit.
"""
os.environ["PYANNOTE_METRICS_ENABLED"] = "false"
class Diarizer:
"""Applique le pipeline pyannote à un PCM s16le mono, sur CPU."""
def __init__(self, settings: DiarizationSettings, sample_rate: int) -> None:
"""
Raises:
FileNotFoundError: le modèle n'a pas été téléchargé.
ImportError: l'extra « diarization » n'est pas installé.
"""
if not (settings.model_dir / "config.yaml").exists():
raise FileNotFoundError(f"modele de diarisation absent de {settings.model_dir} : "
"HF_TOKEN dans server/.env, puis 'make models'")
disable_telemetry()
from pyannote.audio import Pipeline
began = time.monotonic()
# Dossier local : pyannote n'interroge pas le hub (ses sous-modèles sont des sous-dossiers).
pipeline = Pipeline.from_pretrained(settings.model_dir)
if pipeline is None:
raise RuntimeError(f"pipeline pyannote illisible : {settings.model_dir}")
self._pipeline: Pipeline = pipeline
from pyannote.audio import Inference, Model
# Empreintes : le sous-modèle du pipeline, appliqué à tout l'audio d'un locuteur.
embedding_model = Model.from_pretrained(settings.model_dir / "embedding")
if embedding_model is None:
raise RuntimeError(f"modele d'empreintes illisible : {settings.model_dir / 'embedding'}")
self._embedding: Inference = Inference(embedding_model, window="whole")
dimension: Any = embedding_model.dimension # propriété pyannote, mal typée par torch.nn.Module
self._dimension = int(dimension)
self._settings = settings
self._sample_rate = sample_rate
# Premier passage à vide : les calculs s'initialisent ici, pas à la première question.
self.diarize(bytes(sample_rate * 2))
logger.info("diarisation %s chargee en %.1f s", settings.model, time.monotonic() - began)
def diarize(self, pcm: bytes) -> Diarization:
"""Rend les tours de parole et une empreinte par locuteur ; instants relatifs au début du PCM."""
began = time.monotonic()
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
waveform = self._waveform(audio)
with warnings.catch_warnings():
# torch prévient quand un morceau de parole est trop court pour un écart-type :
# sans conséquence sur le résultat, mais une ligne de journal à chaque session.
warnings.filterwarnings("ignore", message=r"std\(\): degrees of freedom")
# Ses propres empreintes sont NaN sur une question courte (voir en tête du module) :
# numpy le signale, mais on ne s'en sert pas.
warnings.filterwarnings("ignore", message="Mean of empty slice", category=RuntimeWarning)
warnings.filterwarnings("ignore", message="invalid value encountered in divide",
category=RuntimeWarning)
output: Any = self._pipeline({"waveform": waveform, "sample_rate": self._sample_rate},
max_speakers=self._settings.max_speakers)
# Version « exclusive » : un seul locuteur à la fois, pour découper le texte (étape 10)
# et pour que chaque empreinte ne contienne qu'une voix.
annotation = output.exclusive_speaker_diarization
turns = tuple(
SpeakerTurn(speaker=str(label), start_s=float(segment.start), end_s=float(segment.end))
for segment, _, label in annotation.itertracks(yield_label=True)
)
speakers = tuple(str(label) for label in annotation.labels())
embeddings = np.stack([self._speaker_embedding(audio, turns, speaker) for speaker in speakers]) \
if speakers else None
return Diarization(
turns=turns,
speakers=speakers,
embeddings=embeddings,
audio_s=audio.size / self._sample_rate,
elapsed_s=time.monotonic() - began,
)
def embed(self, pcm: bytes) -> np.ndarray:
"""Empreinte vocale d'un PCM entier (enrôlement : une seule voix attendue)."""
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
return self._embed(audio)
def _speaker_embedding(self, audio: np.ndarray, turns: tuple[SpeakerTurn, ...], speaker: str) -> np.ndarray:
"""Empreinte de toute la parole d'un locuteur, ses tours mis bout à bout."""
pieces = [audio[round(t.start_s * self._sample_rate):round(t.end_s * self._sample_rate)]
for t in turns if t.speaker == speaker]
return self._embed(np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32))
def _embed(self, audio: np.ndarray) -> np.ndarray:
if audio.size < MIN_EMBEDDING_S * self._sample_rate:
return np.full(self._dimension, np.nan, dtype=np.float32)
embedding: Any = self._embedding({"waveform": self._waveform(audio), "sample_rate": self._sample_rate})
return np.asarray(embedding, dtype=np.float32).reshape(-1)
@staticmethod
def _waveform(audio: np.ndarray) -> Any:
"""Audio en mémoire au format pyannote : tenseur (canaux, échantillons), sans fichier."""
import torch
return torch.from_numpy(audio.copy()).unsqueeze(0)
def timeline(diarization: Diarization, step_s: float = 0.1) -> list[str]:
"""Une ligne par locuteur, un caractère par step_s : « # » quand il parle."""
width = max(1, round(diarization.audio_s / step_s))
lines: list[str] = []
for speaker in diarization.speakers:
row = ["_"] * width
for turn in diarization.turns:
if turn.speaker == speaker:
for index in range(round(turn.start_s / step_s), min(width, round(turn.end_s / step_s))):
row[index] = "#"
lines.append(f"{speaker} {''.join(row)}")
return lines
+285
View File
@@ -0,0 +1,285 @@
"""Écoute mains libres : du flux continu du micro à une session de parole (étape 13).
Chaque carte suit trois états :
VEILLE le flux passe dans le détecteur de mot de réveil ; seules les
dernières preroll_ms restent en mémoire, RIEN n'est écrit sur disque ;
COMMANDE mot entendu : on garde l'audio jusqu'au silence qui suit la commande
(VAD de l'étape 7), puis on en fait une session ordinaire ;
RÉPONSE la carte parle : on n'écoute plus (elle s'entendrait elle-même)
jusqu'à son bilan « played », ou jusqu'au délai de garde.
La session produite est identique à celle d'un appui sur le bouton : la suite
de la chaîne (VAD, diarisation, Whisper, assistant) ne change pas.
Les durées d'une commande (silence final, abandon, durée maximale) se mesurent
en AUDIO REÇU, pas à l'horloge : le Wi-Fi livre souvent les chunks en rafale, et
2 s de son arrivées en 10 ms restent 2 s de parole. L'horloge ne sert qu'aux
délais de garde (réponse sans bilan, flux interrompu).
"""
from __future__ import annotations
import json
import logging
import threading
import time
import uuid
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Protocol
from voice_server.audio_buffer import AudioBuffer
from voice_server.audio_sender import Publisher
from voice_server.session_manager import Outcome, SessionResult
from voice_server.settings import AudioSettings, WakeSettings
from voice_server.voice_activity import VadResult
from voice_server.wake_word import Detection
logger = logging.getLogger(__name__)
CHECK_EVERY_CHUNKS = 5 # recherche du silence final toutes les 250 ms d'audio
COMMAND_AFTER_WAKE_S = 0.3 # parole qui continue au moins 0,3 s après le mot : il y a une commande
STREAM_LOST_S = 2.0 # plus aucun chunk pendant une commande : la carte a décroché
class Detector(Protocol):
"""Ce que l'écoute attend du détecteur de mot de réveil."""
def process(self, pcm: bytes) -> Detection | None: ...
def reset(self) -> None: ...
class SpeechDetector(Protocol):
"""Ce que l'écoute attend de la VAD."""
def detect(self, pcm: bytes) -> VadResult: ...
class Mode(Enum):
"""État d'une carte."""
STANDBY = "veille"
COMMAND = "commande"
ANSWER = "reponse"
@dataclass
class _Board:
"""État d'écoute d'une carte."""
detector: Detector
preroll: deque[bytes]
mode: Mode = Mode.STANDBY
buffer: bytearray = field(default_factory=bytearray)
wake_offset: int = 0 # octets du tampon qui précèdent la détection
word: str = ""
session_id: str = ""
started_at: datetime = field(default_factory=datetime.now)
last_chunk_time: float = 0.0 # horloge : détecte un flux interrompu
unchecked: int = 0 # chunks reçus depuis la dernière recherche du silence final
chunks: int = 0
answer_until: float = 0.0
last_uptime: int = -1
class HandsFreeListener:
"""Transforme le flux continu des cartes en sessions, sur mot de réveil."""
def __init__(
self,
wake: WakeSettings,
audio: AudioSettings,
vad: SpeechDetector,
recordings_dir: Path,
link: Publisher,
on_command: Callable[[SessionResult], None],
detector_factory: Callable[[str], Detector],
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
on_command: reçoit chaque commande, comme une session du bouton ; doit rendre la main vite.
detector_factory: un détecteur neuf par carte (historique audio propre à chacune) ;
reçoit le nom de la carte.
"""
self._wake = wake
self._audio = audio
self._vad = vad
self._recordings_dir = recordings_dir
self._link = link
self._on_command = on_command
self._new_detector = detector_factory
self._clock = clock
self._lock = threading.Lock()
self._boards: dict[str, _Board] = {}
chunk_bytes = audio.bytes_per_second * audio.chunk_ms // 1000
self._preroll_chunks = max(1, wake.preroll_ms * audio.bytes_per_second // 1000 // chunk_bytes)
# ------------------------------------------------------------ cartes
def on_state(self, device: str, payload: bytes) -> None:
"""État publié par une carte : on (ré)active son écoute à la connexion ou au redémarrage."""
try:
state = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
if state.get("status") != "online":
with self._lock:
self._boards.pop(device, None) # la carte réactivera l'écoute à son retour
return
uptime = int(state.get("uptime_s", 0))
with self._lock:
board = self._board(device)
first_time = board.last_uptime < 0 or uptime < board.last_uptime # nouvelle carte ou redémarrage
board.last_uptime = uptime
if first_time:
self._send(device, {"cmd": "listen", "enabled": True})
logger.info("%s : ecoute mains libres activee (mots : %s)", device, ", ".join(self._wake.words))
def stop(self) -> None:
"""Arrêt du serveur : les cartes cessent d'envoyer leur micro."""
with self._lock:
devices = list(self._boards)
for device in devices:
self._send(device, {"cmd": "listen", "enabled": False})
# ------------------------------------------------------------ flux audio
def on_stream(self, device: str, payload: bytes) -> None:
"""Chunk du flux continu (voice/<carte>/audio/stream). Fil réseau : ~1 ms par chunk."""
if len(payload) % 2 != 0:
return
result: SessionResult | None = None
with self._lock:
board = self._board(device)
if board.mode is Mode.STANDBY:
board.preroll.append(payload) # seule mémoire de ce qui précède le mot
detection = board.detector.process(payload)
if detection is not None:
self._start_command(device, board, detection)
elif board.mode is Mode.COMMAND:
board.buffer.extend(payload)
board.chunks += 1
board.unchecked += 1
board.last_chunk_time = self._clock()
result = self._check_command(device, board)
# Mode.ANSWER : la carte parle ; ce que capte son micro est ignoré.
if result is not None:
self._on_command(result)
def on_event(self, device: str, payload: bytes) -> None:
"""Bilan « played » d'une carte : sa réponse est finie, on réécoute."""
try:
event = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
if event.get("event") not in ("played", "rejected"):
return
with self._lock:
board = self._boards.get(device)
if board is not None and board.mode is Mode.ANSWER:
self._standby(board)
def sweep(self) -> list[SessionResult]:
"""Fil principal : fin de réponse sans bilan, commande dont le flux s'est arrêté."""
now = self._clock()
results: list[SessionResult] = []
with self._lock:
for device, board in self._boards.items():
if board.mode is Mode.ANSWER and now >= board.answer_until:
logger.warning("%s : pas de bilan de lecture, reprise de l'ecoute", device)
self._standby(board)
elif board.mode is Mode.COMMAND and now - board.last_chunk_time > STREAM_LOST_S:
result = self._finish(device, board, "flux interrompu")
if result is not None:
results.append(result)
for result in results:
self._on_command(result)
return results
def mode(self, device: str) -> Mode | None:
"""État d'écoute d'une carte (journal, tests)."""
with self._lock:
board = self._boards.get(device)
return board.mode if board is not None else None
# ------------------------------------------------------------ interne (verrou tenu)
def _board(self, device: str) -> _Board:
board = self._boards.get(device)
if board is None:
board = _Board(detector=self._new_detector(device), preroll=deque(maxlen=self._preroll_chunks))
self._boards[device] = board
return board
def _start_command(self, device: str, board: _Board, detection: Detection) -> None:
board.mode = Mode.COMMAND
board.buffer = bytearray(b"".join(board.preroll))
board.preroll.clear()
board.wake_offset = len(board.buffer)
board.word = detection.word
board.session_id = f"w{uuid.uuid4().hex[:5]}"
board.started_at = datetime.now()
board.last_chunk_time = self._clock()
board.unchecked = 0
board.chunks = self._preroll_chunks
logger.info("%s : mot de reveil « %s » (%.2f), session %s : j'ecoute la commande",
device, detection.word, detection.score, board.session_id)
self._send(device, {"cmd": "capture", "active": True})
def _check_command(self, device: str, board: _Board) -> SessionResult | None:
"""Commande finie ? Silence après la parole, rien dit, ou durée maximale (en audio reçu)."""
elapsed = (len(board.buffer) - board.wake_offset) / self._audio.bytes_per_second
if elapsed >= self._wake.max_command_s:
return self._finish(device, board, "duree maximale")
if board.unchecked < CHECK_EVERY_CHUNKS:
return None
board.unchecked = 0
vad = self._vad.detect(bytes(board.buffer))
rate = self._audio.sample_rate
wake_sample = board.wake_offset // 2
after_wake = [s for s in vad.segments if s.end > wake_sample + COMMAND_AFTER_WAKE_S * rate]
if not after_wake:
if elapsed >= self._wake.no_speech_timeout_s:
logger.info("%s : mot de reveil sans commande, retour en veille", device)
self._send(device, {"cmd": "capture", "active": False})
self._standby(board)
return None
silence_s = (len(board.buffer) // 2 - after_wake[-1].end) / rate
if silence_s * 1000 >= self._wake.end_silence_ms:
return self._finish(device, board, "fin de phrase")
return None
def _finish(self, device: str, board: _Board, reason: str) -> SessionResult | None:
"""La commande devient une session ordinaire, écrite en WAV comme celles du bouton."""
audio = AudioBuffer(self._audio, self._wake.max_command_s + 1)
audio.append(bytes(board.buffer))
stamp = board.started_at.strftime("%Y%m%d-%H%M%S")
path = self._recordings_dir / f"{device}_{stamp}_{board.session_id}.wav"
audio.write_wav(path)
result = SessionResult(device=device, session_id=board.session_id, outcome=Outcome.COMPLETE,
path=path, duration_s=audio.duration_s, chunks_received=board.chunks,
chunks_announced=None, levels=audio.levels())
logger.info("%s : commande %s finie (%s), %.2f s apres « %s » -> %s", device, board.session_id,
reason, audio.duration_s, board.word, path.name)
self._send(device, {"cmd": "capture", "active": False})
board.mode = Mode.ANSWER
board.buffer = bytearray()
board.answer_until = self._clock() + self._wake.cooldown_s
return result
def _standby(self, board: _Board) -> None:
board.mode = Mode.STANDBY
board.buffer = bytearray()
board.preroll.clear()
board.detector.reset() # sinon l'historique du mot précédent pourrait redéclencher
def _send(self, device: str, message: dict[str, object]) -> None:
self._link.publish(device, "control", json.dumps(message).encode(), 1)
+161
View File
@@ -0,0 +1,161 @@
"""Domotique : de la phrase à la commande MQTT (étape 11).
Une commande tient en trois mots : une ACTION (allumer, éteindre, ouvrir,
fermer), un APPAREIL (lumière, volets, porte) et une PIÈCE (salon, cuisine…).
L'analyse cherche ces mots dans la phrase transcrite, sans tenir compte des
accents ni de la conjugaison exacte : Whisper écrit « fermé » pour « fermez ».
Sans pièce, c'est celle de la carte qui a entendu la phrase (« ici »).
"""
from __future__ import annotations
import json
import logging
import re
import unicodedata
from dataclasses import dataclass
from typing import Protocol
from voice_server.settings import HomeSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
# Mots normalisés (minuscules, sans accents) → identifiant. Identifiants en anglais :
# ce sont eux qui voyagent dans les topics et le JSON, comme dans le firmware.
ACTION_WORDS = {
"on": ("allume", "allumer", "allumez", "allumes", "rallume", "rallumer"),
"off": ("eteins", "eteint", "eteindre", "eteignez", "coupe", "couper", "coupez"),
"open": ("ouvre", "ouvrir", "ouvrez", "ouvres", "monte", "monter", "montez", "leve", "lever", "levez",
"remonte", "remonter"),
"close": ("ferme", "fermer", "fermez", "fermes", "baisse", "baisser", "baissez", "descends",
"descendre", "descendez"),
}
DEVICE_WORDS = {
"light": ("lumiere", "lumieres", "lampe", "lampes", "eclairage", "plafonnier"),
"shutter": ("volet", "volets", "store", "stores"),
"door": ("porte", "portes"),
}
ALLOWED = {"light": ("on", "off"), "shutter": ("open", "close"), "door": ("open", "close")}
DEVICE_PHRASE = {"light": "la lumière", "shutter": "les volets", "door": "la porte"}
FIRST_PERSON = {"on": "J'allume", "off": "J'éteins", "open": "J'ouvre", "close": "Je ferme"}
INFINITIVE = {"on": "allumer", "off": "éteindre", "open": "ouvrir", "close": "fermer"}
@dataclass(frozen=True)
class DeviceIntent:
"""Ce que la phrase demande ; un champ à None n'a pas été trouvé."""
action: str | None # on | off | open | close
device: str | None # light | shutter | door
room: str | None # pièce de config.yaml, telle qu'écrite (« salle de bain »)
@dataclass(frozen=True)
class DeviceCommand:
"""Commande complète et valide, prête à publier."""
action: str
device: str
room: str
def slug(room: str) -> str:
"""Pièce utilisable dans un topic : « salle de bain » → « salle-de-bain », « entrée » → « entree »."""
ascii_room = unicodedata.normalize("NFKD", room).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", ascii_room.lower()).strip("-")
def _first(words: list[str], vocabulary: dict[str, tuple[str, ...]]) -> str | None:
"""Premier identifiant dont un mot apparaît dans la phrase (dans l'ordre de la phrase)."""
for word in words:
for key, forms in vocabulary.items():
if word in forms:
return key
return None
def parse_intent(text: str, rooms: list[str]) -> DeviceIntent | None:
"""Action, appareil et pièce de la phrase ; None si elle ne parle pas de domotique."""
words = normalize(text).split()
action = _first(words, ACTION_WORDS)
device = _first(words, DEVICE_WORDS)
if action is None and device is None:
return None
padded = f" {' '.join(words)} "
# Pièces les plus longues d'abord : « salle de bain » avant un éventuel « bain ».
room = next((name for name in sorted(rooms, key=len, reverse=True)
if f" {normalize(name)} " in padded), None)
return DeviceIntent(action=action, device=device, room=room)
@dataclass(frozen=True)
class Decision:
"""Réponse à dire, et commande à exécuter si la demande est complète."""
answer: str
command: DeviceCommand | None
def announce(command: DeviceCommand, home: HomeSettings) -> str:
"""Ce que dit l'assistant en exécutant : « Je ferme les volets du salon. »"""
return f"{FIRST_PERSON[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}."
def request(command: DeviceCommand, home: HomeSettings) -> str:
"""La demande à l'infinitif : « ouvrir la porte du salon »."""
return f"{INFINITIVE[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}"
def decide(intent: DeviceIntent, home: HomeSettings, board: str) -> Decision:
"""Complète l'intention (appareil implicite, pièce de la carte) ou dit ce qui manque."""
action, device = intent.action, intent.device
if device is None and action in ("on", "off"):
device = "light" # « allume », « éteins » : c'est de la lumière qu'on parle
if action is None:
return Decision(f"Que dois-je faire avec {DEVICE_PHRASE[device or 'light']} ?", None)
if device is None:
return Decision(f"Que dois-je {INFINITIVE[action]} ?", None)
if action not in ALLOWED[device]:
return Decision(f"Je ne sais pas {INFINITIVE[action]} {DEVICE_PHRASE[device]}.", None)
room = intent.room or home.boards.get(board)
if room is None or room not in home.rooms:
return Decision("Dans quelle pièce ?", None)
command = DeviceCommand(action=action, device=device, room=room)
return Decision(announce(command, home), command)
class TopicPublisher(Protocol):
"""Ce que la domotique attend du lien MQTT."""
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None: ...
class HomeController:
"""Publie les commandes : pour la domotique, et pour la carte si c'est sa pièce."""
def __init__(self, home: HomeSettings, link: TopicPublisher) -> None:
self._home = home
self._link = link
def execute(self, board: str, command: DeviceCommand, speaker: str | None, session: str) -> list[str]:
"""Publie la commande ; rend les topics utilisés (journal, tests).
home/<pièce>/<appareil>/set, QoS 1, non retenu : une commande rejouée à un
abonné qui arrive plus tard ferait bouger un volet sans que personne l'ait demandé.
"""
topic = f"{self._home.topic_prefix}/{slug(command.room)}/{command.device}/set"
payload = {"action": command.action, "by": speaker, "board": board, "session": session}
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
topics = [topic]
if self._home.boards.get(board) == command.room:
local = {"cmd": "device", "device": command.device, "action": command.action}
self._link.publish(board, "control", json.dumps(local).encode(), 1)
topics.append(f"{board}/control")
logger.info("%s : commande %s %s %s -> %s", board, command.action, command.device,
command.room, ", ".join(topics))
return topics
+240
View File
@@ -0,0 +1,240 @@
"""Machines Linux du réseau : de la phrase à l'agent de la machine (étape 15b).
Chaque machine fait tourner un AGENT (agent/voice_agent.py) : un petit client MQTT
qui n'exécute que les actions de SA liste, définie sur la machine elle-même. Le
serveur n'envoie qu'un NOM d'action (« shutdown »), jamais une commande shell :
même un message forgé ne peut rien faire d'autre que ce que la machine a permis.
agent/<machine>/state agent → tous {"status":"online","actions":[...]}, retenu + testament
agent/<machine>/command serveur → agent {"action":"shutdown","id":...,"by":"Denis"}
agent/<machine>/result agent → serveur {"action":...,"ok":true,"code":0,"output":"..."}
« Allume le PC » est à part : éteinte, la machine n'a plus d'agent. Le serveur
envoie alors un paquet Wake-on-LAN (réveil par le réseau) à son adresse MAC.
"""
from __future__ import annotations
import json
import logging
import re
import socket
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from voice_server.home_control import DeviceCommand, TopicPublisher
from voice_server.settings import MachinesSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
WAKE = "wake" # action du serveur lui-même (Wake-on-LAN), pas de l'agent
WOL_PORT = 9 # port « discard » : l'usage pour le paquet magique
MACHINE_DEVICE = "machine" # clé des droits : access.rules.machine.<action>
@dataclass(frozen=True)
class MachineIntent:
"""Ce que la phrase demande à une machine ; action None : pas trouvée."""
machine: str # identifiant de l'agent (pc-bureau)
action: str | None # shutdown, reboot, wake...
@dataclass(frozen=True)
class MachineCommand:
"""Commande complète pour une machine, avec ses phrases (fixées à la décision)."""
action: str
machine: str
said: str # à l'exécution : « J'éteins le PC du bureau. »
asked: str # refus, confirmation : « éteindre le PC du bureau »
device: str = MACHINE_DEVICE # pour les droits, comme DeviceCommand.device
# Toute commande que l'assistant peut produire : domotique (étape 11) ou machine (15b).
Command = DeviceCommand | MachineCommand
@dataclass(frozen=True)
class MachineDecision:
"""Réponse à dire, et commande à exécuter si la demande est possible."""
answer: str
command: MachineCommand | None
@dataclass(frozen=True)
class AgentStatus:
"""Dernier état publié par un agent (retenu par le broker)."""
online: bool
actions: tuple[str, ...] # actions que la machine accepte
def agent_status(payload: bytes) -> AgentStatus | None:
"""{"status":"online","actions":[...]} → AgentStatus ; None si illisible."""
try:
state: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(state, dict): # pyright: ignore[reportUnnecessaryIsInstance]
return None
actions = state.get("actions") or []
return AgentStatus(online=state.get("status") == "online",
actions=tuple(str(action) for action in actions))
def capitalized(phrase: str) -> str:
"""« le PC du bureau » → « Le PC du bureau » (en début de phrase)."""
return phrase[:1].upper() + phrase[1:]
def parse_machine_intent(text: str, settings: MachinesSettings) -> MachineIntent | None:
"""Machine et action de la phrase ; None si elle ne nomme aucune machine."""
words = normalize(text).split()
padded = f" {' '.join(words)} "
# Noms les plus longs d'abord : « ordinateur du bureau » avant un éventuel « ordinateur ».
names = sorted(((name, machine) for machine, spec in settings.machines.items() for name in spec.names),
key=lambda item: len(item[0]), reverse=True)
machine = next((machine for name, machine in names if f" {name} " in padded), None)
if machine is None:
return None
action = next((action for word in words for action, spec in settings.actions.items()
if word in spec.words), None)
return MachineIntent(machine=machine, action=action)
def decide_machine(intent: MachineIntent, settings: MachinesSettings,
status: AgentStatus | None) -> MachineDecision:
"""Vérifie que la demande est possible, d'après l'état annoncé par l'agent."""
machine = settings.machines[intent.machine]
name = machine.name
if intent.action is None:
return MachineDecision(f"Que dois-je faire avec {name} ?", None)
spec = settings.actions[intent.action]
online = status is not None and status.online
if intent.action == WAKE:
if online:
return MachineDecision(f"{capitalized(name)} est déjà en marche.", None)
if not machine.mac:
return MachineDecision(f"Je ne sais pas {spec.verb} {name} à distance.", None)
elif not online:
# Tournures sans genre : le nom peut être « la tour du garage ».
return MachineDecision(f"{capitalized(name)} ne répond pas : la machine est éteinte, "
"ou son agent est arrêté.", None)
elif status is not None and intent.action not in status.actions:
return MachineDecision(f"Je ne peux pas {spec.verb} {name} : son agent ne le permet pas.", None)
command = MachineCommand(action=intent.action, machine=intent.machine,
said=f"{spec.does} {name}.", asked=f"{spec.verb} {name}")
return MachineDecision(command.said, command)
def magic_packet(mac: str) -> bytes:
"""Paquet magique Wake-on-LAN : 6 octets 0xFF, puis 16 fois l'adresse MAC (102 octets).
Raises:
ValueError: adresse MAC mal écrite.
"""
if not re.fullmatch(r"([0-9a-fA-F]{2}[:-]?){5}[0-9a-fA-F]{2}", mac.strip()):
raise ValueError(f"adresse MAC invalide : « {mac} » (attendu : aa:bb:cc:dd:ee:ff)")
return b"\xff" * 6 + bytes.fromhex(re.sub(r"[:-]", "", mac.strip())) * 16
def send_broadcast(packet: bytes, address: str) -> None:
"""Envoie un datagramme UDP en diffusion sur le réseau local."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(packet, (address, WOL_PORT))
class MachineController:
"""Suit l'état des agents, interprète les phrases qui nomment une machine, publie les commandes.
on_state et on_result sont appelés depuis le fil réseau ; interpret et execute
depuis le fil de travail : un verrou protège les états.
"""
def __init__(self, settings: MachinesSettings, link: TopicPublisher,
send_udp: Callable[[bytes, str], None] = send_broadcast) -> None:
"""
Args:
send_udp: envoi du paquet Wake-on-LAN, remplaçable dans les tests.
"""
self._settings = settings
self._link = link
self._send_udp = send_udp
self._lock = threading.Lock()
self._states: dict[str, AgentStatus] = {}
# ------------------------------------------------------------ messages des agents
def on_state(self, topic: str, payload: bytes) -> None:
"""État d'un agent (agent/<machine>/state), retenu : reçu dès l'abonnement."""
machine = self._machine_of(topic)
status = agent_status(payload)
if machine is None or status is None:
return
with self._lock:
previous = self._states.get(machine)
self._states[machine] = status
if previous == status:
return
if machine not in self._settings.machines:
logger.warning("agent %s : absent de config.yaml (machines.list), on ne peut pas le nommer", machine)
elif status.online:
unknown = [action for action in status.actions if action not in self._settings.actions]
logger.info("agent %s : en ligne, actions %s%s", machine, ", ".join(status.actions) or "aucune",
f" (sans mots dans config.yaml : {', '.join(unknown)})" if unknown else "")
else:
logger.warning("agent %s : hors ligne", machine)
def on_result(self, topic: str, payload: bytes) -> None:
"""Compte rendu d'exécution (agent/<machine>/result) : pour le journal."""
machine = self._machine_of(topic)
try:
result: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
log = logger.info if result.get("ok") else logger.warning
log("agent %s : %s %s (code %s)%s", machine, result.get("action"),
"executee" if result.get("ok") else "ECHOUEE", result.get("code"),
f" : {result['output']}" if result.get("output") else "")
def status(self, machine: str) -> AgentStatus | None:
"""Dernier état connu de l'agent ; None s'il ne s'est jamais annoncé."""
with self._lock:
return self._states.get(machine)
# ------------------------------------------------------------ phrases et commandes
def interpret(self, text: str) -> MachineDecision | None:
"""Réponse à une phrase qui nomme une machine ; None si elle n'en nomme aucune."""
intent = parse_machine_intent(text, self._settings)
if intent is None:
return None
return decide_machine(intent, self._settings, self.status(intent.machine))
def execute(self, board: str, command: MachineCommand, speaker: str | None, session: str) -> list[str]:
"""Réveil par le réseau, ou commande à l'agent ; rend les destinations (journal, tests)."""
if command.action == WAKE:
mac = self._settings.machines[command.machine].mac
self._send_udp(magic_packet(mac), self._settings.broadcast)
logger.info("%s : Wake-on-LAN %s (%s) -> %s:%d", board, command.machine, mac,
self._settings.broadcast, WOL_PORT)
return [f"wol:{mac}"]
# QoS 1, non retenu, et l'agent se connecte en session propre : une commande
# n'est jamais rejouée plus tard (un « éteins » qui attendrait le prochain démarrage...).
topic = f"{self._settings.topic_prefix}/{command.machine}/command"
payload = {"action": command.action, "id": session, "by": speaker, "board": board}
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
logger.info("%s : commande %s %s -> %s", board, command.action, command.machine, topic)
return [topic]
def _machine_of(self, topic: str) -> str | None:
parts = topic.split("/")
if len(parts) != 3 or parts[0] != self._settings.topic_prefix or not parts[1]:
return None
return parts[1]
+191
View File
@@ -0,0 +1,191 @@
"""Connexion du serveur au broker MQTT (paho-mqtt 2.x)."""
from __future__ import annotations
import json
import logging
import threading
from collections.abc import Callable
from typing import Any
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
from voice_server.settings import MqttSettings
logger = logging.getLogger(__name__)
# Rappel : (identifiant de la carte, payload brut)
Handler = Callable[[str, bytes], Any]
# Rappel d'un topic hors voice/ (agents Linux, étape 15b) : (topic complet, payload brut)
TopicHandler = Callable[[str, bytes], Any]
ONLINE = json.dumps({"status": "online"}).encode()
OFFLINE = json.dumps({"status": "offline"}).encode()
REFUSED_CREDENTIALS = (134, 135) # « Bad user name or password », « Not authorized » (MQTT 5 / 3.1.1)
STOP_PUBLISH_TIMEOUT_S = 2.0
def presence_topic(prefix: str) -> str:
"""Topic de présence du serveur, lu par les cartes (étape 15).
Hors du motif <préfixe>/<carte>/<suffixe> auquel le serveur s'abonne
(voice/+/state...) : sinon il se prendrait lui-même pour une carte.
>>> presence_topic("voice")
'voice/server/status'
"""
return f"{prefix}/server/status"
def refusal_hint(reason: ReasonCode, client_id: str) -> str:
"""Explication d'une connexion refusée, pour le journal."""
if reason.value in REFUSED_CREDENTIALS:
return (f"connexion refusee par le broker : {reason}. Verifiez MQTT_USERNAME et MQTT_PASSWORD "
"dans server/.env, et que ce compte existe (make mqtt-user NAME=voice-server, "
"puis make mosquitto-docker)")
return f"connexion refusee par le broker : {reason} (client « {client_id} »)"
def split_topic(topic: str, prefix: str) -> tuple[str, str] | None:
"""Découpe « <préfixe>/<carte>/<suffixe> » en (carte, suffixe), sinon None.
>>> split_topic("voice/esp32-01/audio/in", "voice")
('esp32-01', 'audio/in')
"""
parts = topic.split("/", 2)
if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]:
return None
return parts[1], parts[2]
class MqttLink:
"""Abonne le serveur aux topics de toutes les cartes et répartit les messages.
Le réseau tourne dans un fil créé par paho (loop_start) : les rappels sont
donc appelés depuis ce fil. Reconnexion automatique, abonnements refaits à
chaque connexion.
Avec presence=True (le vrai serveur seulement, jamais un outil de test),
le lien publie « online » retenu sur voice/server/status et confie au broker
un testament « offline » : si le serveur meurt, les cartes le savent et
coupent leur micro.
"""
def __init__(self, settings: MqttSettings, presence: bool = False) -> None:
"""
Args:
presence: annonce la présence du serveur (étape 15) ; False pour les outils,
dont l'arrêt ne doit pas faire croire aux cartes que le serveur est parti.
"""
self._settings = settings
self._presence = presence_topic(settings.topic_prefix) if presence else None
self._handlers: dict[str, Handler] = {}
self._topic_handlers: dict[str, TopicHandler] = {}
self._connected = threading.Event()
self._client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=settings.client_id,
)
if settings.username:
self._client.username_pw_set(settings.username, settings.password)
self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect
self._client.on_message = self._on_message
self._client.reconnect_delay_set(min_delay=1, max_delay=10)
if self._presence is not None:
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
# (plantage, coupure) ; retenu, pour une carte qui se connecte plus tard.
self._client.will_set(self._presence, OFFLINE, qos=1, retain=True)
def on(self, suffix: str, handler: Handler) -> None:
"""Associe un rappel à un suffixe : « event », « audio/in », « state »..."""
self._handlers[suffix] = handler
def on_topic(self, pattern: str, handler: TopicHandler) -> None:
"""Associe un rappel à un filtre complet hors voice/ : « agent/+/state » (étape 15b), QoS 1."""
self._topic_handlers[pattern] = handler
def start(self) -> None:
"""Lance la connexion (asynchrone) et le fil réseau."""
logger.info("connexion au broker %s:%d", self._settings.host, self._settings.port)
self._client.connect_async(self._settings.host, self._settings.port,
keepalive=self._settings.keepalive)
self._client.loop_start()
def wait_connected(self, timeout: float) -> bool:
"""Attend la première connexion au broker ; False si le délai expire."""
return self._connected.wait(timeout)
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
"""Publie vers une carte : <préfixe>/<carte>/<suffixe>. Utilisable depuis tout fil."""
topic = f"{self._settings.topic_prefix}/{device}/{suffix}"
self._client.publish(topic, payload, qos=qos)
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
"""Publie sur un topic complet, hors de voice/ : commandes domotiques home/... (étape 11)."""
self._client.publish(topic, payload, qos=qos)
def stop(self) -> None:
"""Déconnexion propre et arrêt du fil réseau."""
if self._presence is not None and self._connected.is_set():
# Une déconnexion propre ne déclenche PAS le testament : on annonce le départ.
info = self._client.publish(self._presence, OFFLINE, qos=1, retain=True)
try:
info.wait_for_publish(STOP_PUBLISH_TIMEOUT_S) # ne lève rien si le délai expire
except (RuntimeError, ValueError):
pass
if not info.is_published():
logger.warning("depart du serveur non annonce aux cartes (broker injoignable)")
self._client.disconnect()
self._client.loop_stop()
# ------------------------------------------------------------ rappels paho
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags,
reason_code: ReasonCode, properties: Properties | None) -> None:
if reason_code.is_failure:
logger.error("%s", refusal_hint(reason_code, self._settings.client_id))
return
logger.info("connecte au broker")
self._connected.set()
if self._presence is not None:
# À chaque connexion : un broker redémarré (sans persistance) a oublié l'état retenu.
client.publish(self._presence, ONLINE, qos=1, retain=True)
logger.info("presence annoncee sur %s", self._presence)
# Abonnement refait à chaque connexion : une reconnexion repart de zéro.
for suffix in self._handlers:
topic = f"{self._settings.topic_prefix}/+/{suffix}"
qos = 0 if suffix.startswith("audio") else 1
client.subscribe(topic, qos=qos)
logger.info("abonne a %s (QoS %d)", topic, qos)
for pattern in self._topic_handlers:
client.subscribe(pattern, qos=1)
logger.info("abonne a %s (QoS 1)", pattern)
def _on_disconnect(self, client: mqtt.Client, userdata: Any,
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
properties: Properties | None) -> None:
self._connected.clear()
if reason_code.is_failure:
# Cause fréquente en TP : un second programme avec le même identifiant
# client ; le broker n'en garde qu'un et les deux s'éjectent en boucle.
logger.warning("connexion au broker perdue (%s), reconnexion... Si cela se repete, "
"un autre client utilise-t-il l'identifiant « %s » ?",
reason_code, self._settings.client_id)
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
parsed = split_topic(message.topic, self._settings.topic_prefix)
try:
if parsed is not None:
handler = self._handlers.get(parsed[1])
if handler is not None:
handler(parsed[0], message.payload)
return
for pattern, topic_handler in self._topic_handlers.items():
if mqtt.topic_matches_sub(pattern, message.topic):
topic_handler(message.topic, message.payload)
except Exception: # un message fautif ne doit jamais arrêter le serveur
logger.exception("erreur en traitant %s", message.topic)
+253
View File
@@ -0,0 +1,253 @@
"""Reconstitution des sessions de parole reçues des ESP32 : START, chunks, END."""
from __future__ import annotations
import json
import logging
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
from voice_server.audio_buffer import AudioBuffer, Levels
from voice_server.settings import AudioSettings, SessionSettings
logger = logging.getLogger(__name__)
class Outcome(Enum):
"""Façon dont une session s'est terminée."""
COMPLETE = "complete" # END reçu, tous les chunks annoncés sont arrivés
LOST_CHUNKS = "lost_chunks" # END reçu, mais des chunks manquent (QoS 0 : perte réseau)
TIMEOUT = "timeout" # jamais de END : l'ESP32 a disparu en cours de session
INTERRUPTED = "interrupted" # un nouveau START est arrivé avant le END
@dataclass(frozen=True)
class SessionResult:
"""Bilan d'une session terminée."""
device: str
session_id: str
outcome: Outcome
path: Path
duration_s: float
chunks_received: int
chunks_announced: int | None # None si aucun END n'a été reçu
levels: Levels
@dataclass
class _Session:
"""Session en cours de réception."""
device: str
session_id: str
buffer: AudioBuffer
started_at: datetime
last_activity: float
chunks_received: int = 0
announced: int | None = None # nombre de chunks annoncé par le END
end_seen_at: float | None = None
end_report: dict[str, Any] = field(default_factory=dict[str, Any])
class SessionManager:
"""Assemble les sessions de chaque carte et les écrit en WAV.
Appelée depuis le fil réseau (messages MQTT) et depuis le fil principal
(sweep) : un verrou protège l'état.
Les messages audio sont en QoS 0 : ils peuvent se perdre. Le END annonce le
nombre de chunks envoyés ; la comparaison avec le nombre reçu mesure la perte.
Et comme le START et le END peuvent eux-mêmes se perdre (l'ESP32 ne sait
publier qu'en QoS 0), une session sans END est close après un délai d'inactivité.
"""
def __init__(
self,
audio: AudioSettings,
sessions: SessionSettings,
recordings_dir: Path,
clock: Callable[[], float] = time.monotonic,
on_finished: Callable[[SessionResult], None] | None = None,
) -> None:
"""
Args:
on_finished: appelé pour chaque session terminée, depuis le fil qui la
termine (réseau ou principal) : il doit rendre la main aussitôt.
"""
self._on_finished = on_finished
self._audio = audio
self._settings = sessions
self._recordings_dir = recordings_dir
self._clock = clock
self._lock = threading.Lock()
self._active: dict[str, _Session] = {}
self.orphan_chunks = 0 # chunks reçus hors de toute session
# ------------------------------------------------------------ messages reçus
def on_event(self, device: str, payload: bytes) -> list[SessionResult]:
"""Traite un message JSON de voice/<device>/event."""
try:
event: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
logger.warning("%s : evenement illisible : %r", device, payload[:80])
return []
kind = event.get("event")
with self._lock:
if kind == "start":
return self._start(device, event)
if kind == "end":
return self._end(device, event)
if kind == "boot":
# Tout redémarrage autre qu'une mise sous tension mérite l'attention :
# brownout = alimentation trop faible, panic = plantage, watchdog = blocage.
reason = event.get("reason")
log = logger.info if reason in ("poweron", "external", "software") else logger.warning
log("%s : demarrage de la carte, cause : %s", device, reason)
elif kind == "device":
# Étape 11 : la carte confirme qu'elle a exécuté une commande de sa pièce.
log = logger.info if event.get("ok") else logger.warning
log("%s : commande %s %s %s, etat %s", device, event.get("device"), event.get("action"),
"executee" if event.get("ok") else "REFUSEE", event.get("state"))
elif kind == "played":
# Étape 12 : « underruns » compte les réamorçages (réserve épuisée, pause propre) ;
# la marge minimale dit combien de retard réseau restait absorbable.
log = logger.warning if event.get("underruns") else logger.info
log("%s : lecture %s jouee, chunks %s/%s, reamorcages %s %s, premier son a %s ms, marge mini %s ms",
device, event.get("session"), event.get("chunks"), event.get("announced"),
event.get("underruns"), event.get("gaps_ms") or "", event.get("start_ms"),
event.get("min_margin_ms"))
else:
logger.info("%s : evenement %s", device, event)
return []
def on_audio(self, device: str, payload: bytes) -> None:
"""Traite un chunk PCM de voice/<device>/audio/in."""
with self._lock:
session = self._active.get(device)
if session is None:
self.orphan_chunks += 1
return
if len(payload) % 2 != 0:
logger.warning("%s : chunk de %d octets ignore (impair)", device, len(payload))
return
session.buffer.append(payload)
session.chunks_received += 1
session.last_activity = self._clock()
def sweep(self) -> list[SessionResult]:
"""Clôt les sessions dont le END est passé (délai de grâce) ou restées muettes."""
now = self._clock()
results: list[SessionResult] = []
with self._lock:
for device, session in list(self._active.items()):
if session.end_seen_at is not None:
if now - session.end_seen_at >= self._settings.end_grace_s:
results.append(self._finish(device, self._outcome_after_end(session)))
elif now - session.last_activity >= self._settings.idle_timeout_s:
results.append(self._finish(device, Outcome.TIMEOUT))
return results
# ------------------------------------------------------------ interne
def _start(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
results: list[SessionResult] = []
previous = self._active.get(device)
if previous is not None:
# END déjà reçu, délai de grâce en cours : la session précédente est
# finie, pas interrompue. Son bilan se juge sur ce que le END annonçait.
if previous.end_seen_at is not None:
outcome = self._outcome_after_end(previous)
else:
outcome = Outcome.INTERRUPTED
results.append(self._finish(device, outcome))
announced = (event.get("rate"), event.get("bits"), event.get("channels"), event.get("codec"))
expected = (self._audio.sample_rate, self._audio.bits, self._audio.channels, self._audio.codec)
if announced != expected:
logger.error("%s : session refusee, format %s au lieu de %s", device, announced, expected)
return results
session_id = str(event.get("session", "inconnue"))
self._active[device] = _Session(
device=device,
session_id=session_id,
buffer=AudioBuffer(self._audio, self._settings.max_seconds),
started_at=datetime.now(),
last_activity=self._clock(),
)
logger.info("%s : session %s ouverte", device, session_id)
return results
def _end(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
session = self._active.get(device)
if session is None or event.get("session") != session.session_id:
logger.warning("%s : END sans session correspondante (%s)", device, event.get("session"))
return []
session.announced = int(event.get("chunks", 0))
session.end_report = event
session.end_seen_at = self._clock()
# Tout est arrivé : inutile d'attendre le délai de grâce.
if session.chunks_received >= session.announced:
return [self._finish(device, Outcome.COMPLETE)]
return []
@staticmethod
def _outcome_after_end(session: _Session) -> Outcome:
if session.announced is not None and session.chunks_received < session.announced:
return Outcome.LOST_CHUNKS
return Outcome.COMPLETE
def _finish(self, device: str, outcome: Outcome) -> SessionResult:
session = self._active.pop(device)
stamp = session.started_at.strftime("%Y%m%d-%H%M%S")
path = self._recordings_dir / f"{device}_{stamp}_{session.session_id}.wav"
session.buffer.write_wav(path)
result = SessionResult(
device=device,
session_id=session.session_id,
outcome=outcome,
path=path,
duration_s=session.buffer.duration_s,
chunks_received=session.chunks_received,
chunks_announced=session.announced,
levels=session.buffer.levels(),
)
self._log(result, session)
if self._on_finished is not None:
self._on_finished(result)
return result
@staticmethod
def _log(result: SessionResult, session: _Session) -> None:
announced = "?" if result.chunks_announced is None else str(result.chunks_announced)
logger.info(
"%s : session %s %s : %s, %.2f s, chunks %d/%s, crete %.1f dBFS, RMS %.1f dBFS",
result.device, result.session_id, result.outcome.value, result.path.name,
result.duration_s, result.chunks_received, announced,
result.levels.peak_dbfs, result.levels.rms_dbfs,
)
report = session.end_report
if report.get("send_failures") or report.get("capture_overruns"):
logger.warning(
"%s : cote ESP32, %s echecs d'envoi et %s chunks perdus faute de place",
result.device, report.get("send_failures"), report.get("capture_overruns"),
)
if result.outcome is Outcome.LOST_CHUNKS and result.chunks_announced:
lost = result.chunks_announced - result.chunks_received
logger.warning("%s : %d chunks perdus en route (%.0f %%) : signal Wi-Fi ?",
result.device, lost, 100.0 * lost / result.chunks_announced)
if session.buffer.truncated:
logger.warning("%s : session tronquee a sa duree maximale", result.device)
+343
View File
@@ -0,0 +1,343 @@
"""Chargement de la configuration : config.yaml pour les réglages, .env pour les secrets."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from dotenv import load_dotenv
from voice_server.text_normalize import normalize
@dataclass(frozen=True)
class AudioSettings:
"""Format audio unique du système."""
sample_rate: int
bits: int
channels: int
codec: str
chunk_ms: int
@property
def bytes_per_second(self) -> int:
"""Débit en octets par seconde (32 000 pour 16 kHz, 16 bits, mono)."""
return self.sample_rate * self.channels * self.bits // 8
@dataclass(frozen=True)
class MqttSettings:
"""Connexion au broker."""
host: str
port: int
keepalive: int
client_id: str
topic_prefix: str
username: str | None
password: str | None
@dataclass(frozen=True)
class SessionSettings:
"""Réception des sessions de parole."""
max_seconds: float
idle_timeout_s: float
end_grace_s: float
@dataclass(frozen=True)
class SttSettings:
"""Transcription par faster-whisper (étape 6)."""
model: str # tiny, base, small, medium…
language: str
compute_type: str # int8 : le plus rapide sur CPU
beam_size: int # 1 = décodage glouton, plus rapide ; 5 = plus précis
no_speech_threshold: float
model_dir: Path # dossier du modèle téléchargé par « make models »
@dataclass(frozen=True)
class TtsSettings:
"""Synthèse vocale par Piper (étape 6)."""
voice: str
length_scale: float # > 1 : débit plus lent
model_path: Path # fichier .onnx, accompagné de son .onnx.json
@dataclass(frozen=True)
class VadSettings:
"""Détection d'activité vocale par Silero (étape 7)."""
threshold: float # probabilité de parole à partir de laquelle une fenêtre compte
min_silence_ms: int # silence plus court : pause entre deux mots, la phrase continue
min_speech_ms: int # parole plus courte : bruit (clic du bouton, toux), ignorée
speech_pad_ms: int # marge gardée autour de chaque segment (attaques et fins de mots)
@dataclass(frozen=True)
class DiarizationSettings:
"""Diarisation par pyannote : qui parle quand (étape 8)."""
enabled: bool # false : pas de diarisation (Raspberry Pi 4, extra non installé)
model: str # dépôt Hugging Face du pipeline
max_speakers: int
model_dir: Path # copie locale téléchargée par « make models »
@dataclass(frozen=True)
class SpeakerSettings:
"""Identification du locuteur par empreinte vocale (étape 9). Probabiliste : jamais une sécurité."""
profiles_dir: Path # un fichier JSON par personne enrôlée (donnée biométrique)
similarity_threshold: float # cosinus minimal avec un profil pour donner un nom
@dataclass(frozen=True)
class HomeSettings:
"""Domotique (étape 11) : pièces connues et pièce de chaque carte."""
topic_prefix: str # commandes publiées sur <préfixe>/<pièce>/<appareil>/set
rooms: dict[str, str] # nom dit à voix haute → complément : « salon » → « du salon »
boards: dict[str, str] # carte → pièce où elle se trouve (« ici », pièce par défaut)
@dataclass(frozen=True)
class WakeSettings:
"""Mot de réveil et écoute mains libres (étape 13)."""
enabled: bool
words: tuple[str, ...] # modèles openWakeWord : alexa, hey_jarvis, hey_mycroft
threshold: float # score au-delà duquel le mot est reconnu
thresholds: dict[str, float] # seuil propre à un mot, prioritaire sur threshold
preroll_ms: int # audio gardé AVANT la détection (en mémoire seulement)
end_silence_ms: int # silence qui clôt la commande
no_speech_timeout_s: float # rien dit après le mot : on abandonne
max_command_s: float # une commande plus longue est coupée
cooldown_s: float # au plus, attente de la fin de la réponse avant de réécouter
model_dir: Path # models/openwakeword/ (« make models »)
@dataclass(frozen=True)
class AccessSettings:
"""Droits par profil (étape 14). La voix n'est pas une preuve : c'est une commodité."""
unknown_level: str # voix non reconnue
default_level: str # profil enrôlé sans niveau dans users
users: dict[str, str] # nom du profil → complet | standard | limite
full_min_score: float # niveau complet seulement si l'identification est nette
rules: dict[str, dict[str, str]] # appareil → action → niveau minimal
confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter
confirm_timeout_s: float
@dataclass(frozen=True)
class MachineAction:
"""Une action d'agent Linux telle qu'on la dit (étape 15b)."""
words: tuple[str, ...] # mots normalisés qui la désignent : « eteins », « arrete »
does: str # début de la réponse : « J'éteins »
verb: str # infinitif, pour les questions et refus : « éteindre »
@dataclass(frozen=True)
class Machine:
"""Une machine du réseau, commandée par son agent (étape 15b)."""
names: tuple[str, ...] # comment on l'appelle : « pc du bureau », « ordinateur du bureau »
name: str # comment l'assistant la nomme : « le PC du bureau »
mac: str # Wake-on-LAN (« allume ») ; vide : pas de réveil à distance
@dataclass(frozen=True)
class MachinesSettings:
"""Machines Linux commandées par un agent MQTT (étape 15b)."""
topic_prefix: str # <préfixe>/<machine>/state, command, result
broadcast: str # adresse de diffusion du paquet Wake-on-LAN
actions: dict[str, MachineAction] # identifiant (shutdown...) → mots et phrases
machines: dict[str, Machine] # identifiant de l'agent (pc-bureau) → noms
@dataclass(frozen=True)
class Settings:
"""Configuration complète du serveur."""
audio: AudioSettings
mqtt: MqttSettings
sessions: SessionSettings
stt: SttSettings
tts: TtsSettings
vad: VadSettings
diarization: DiarizationSettings
speaker: SpeakerSettings
home: HomeSettings
wake: WakeSettings
access: AccessSettings
machines: MachinesSettings
recordings_dir: Path
models_dir: Path
log_level: str
log_format: str
log_file: Path | None # copie tournante du journal (étape 15), None : écran seul
def _section(data: dict[str, Any], name: str) -> dict[str, Any]:
"""Rend une section du YAML, avec une erreur claire si elle manque."""
value = data.get(name)
if not isinstance(value, dict):
raise ValueError(f"config.yaml : section « {name} » absente ou invalide")
return value # type: ignore[return-value]
def _key(value: object) -> str:
"""Clé YAML en texte ; YAML 1.1 lit on/off/yes/no nus comme des booléens : on les rend tels quels."""
if isinstance(value, bool):
return "on" if value else "off"
return str(value)
def _machines(data: dict[str, Any]) -> MachinesSettings:
"""Section « machines » (étape 15b), facultative : sans elle, aucune machine."""
section: dict[str, Any] = data.get("machines") or {}
actions = {
str(action): MachineAction(
words=tuple(normalize(str(word)) for word in spec["words"]),
does=str(spec["does"]),
verb=str(spec["verb"]),
)
for action, spec in dict(section.get("actions") or {}).items()
}
machines = {
str(machine): Machine(
names=tuple(normalize(str(name)) for name in spec["names"]),
name=str(spec["name"]),
mac=str(spec.get("mac") or ""),
)
for machine, spec in dict(section.get("list") or {}).items()
}
return MachinesSettings(
topic_prefix=str(section.get("topic_prefix", "agent")),
broadcast=str(section.get("broadcast", "255.255.255.255")),
actions=actions,
machines=machines,
)
def load_settings(path: Path) -> Settings:
"""Lit config.yaml, puis le .env voisin s'il existe (identifiants MQTT)."""
with path.open(encoding="utf-8") as stream:
data: dict[str, Any] = yaml.safe_load(stream) or {}
# Les secrets ne sont jamais dans config.yaml : ils viennent de l'environnement.
load_dotenv(path.parent / ".env")
audio = _section(data, "audio")
mqtt = _section(data, "mqtt")
sessions = _section(data, "sessions")
storage = _section(data, "storage")
logging_cfg = _section(data, "logging")
stt = _section(data, "stt")
tts = _section(data, "tts")
vad = _section(data, "vad")
diarization = _section(data, "diarization")
speaker = _section(data, "speaker")
home = _section(data, "home")
wake = _section(data, "wake")
access = _section(data, "access")
base = path.parent # les chemins relatifs le sont par rapport à config.yaml
models_dir = (base / str(storage["models_dir"])).resolve()
return Settings(
audio=AudioSettings(
sample_rate=int(audio["sample_rate"]),
bits=int(audio["bits"]),
channels=int(audio["channels"]),
codec=str(audio["codec"]),
chunk_ms=int(audio["chunk_ms"]),
),
mqtt=MqttSettings(
host=str(mqtt["host"]),
port=int(mqtt["port"]),
keepalive=int(mqtt["keepalive"]),
client_id=str(mqtt["client_id"]),
topic_prefix=str(mqtt["topic_prefix"]),
username=os.environ.get("MQTT_USERNAME") or None,
password=os.environ.get("MQTT_PASSWORD") or None,
),
sessions=SessionSettings(
max_seconds=float(sessions["max_seconds"]),
idle_timeout_s=float(sessions["idle_timeout_s"]),
end_grace_s=float(sessions["end_grace_s"]),
),
stt=SttSettings(
model=str(stt["model"]),
language=str(stt["language"]),
compute_type=str(stt["compute_type"]),
beam_size=int(stt["beam_size"]),
no_speech_threshold=float(stt["no_speech_threshold"]),
model_dir=models_dir / "whisper" / str(stt["model"]),
),
tts=TtsSettings(
voice=str(tts["voice"]),
length_scale=float(tts["length_scale"]),
model_path=models_dir / "piper" / f"{tts['voice']}.onnx",
),
vad=VadSettings(
threshold=float(vad["threshold"]),
min_silence_ms=int(vad["min_silence_ms"]),
min_speech_ms=int(vad["min_speech_ms"]),
speech_pad_ms=int(vad["speech_pad_ms"]),
),
diarization=DiarizationSettings(
enabled=bool(diarization["enabled"]),
model=str(diarization["model"]),
max_speakers=int(diarization["max_speakers"]),
# « pyannote/speaker-diarization-community-1 » → models/pyannote/speaker-diarization-community-1
model_dir=models_dir / "pyannote" / str(diarization["model"]).split("/")[-1],
),
speaker=SpeakerSettings(
profiles_dir=(base / str(speaker["profiles_dir"])).resolve(),
similarity_threshold=float(speaker["similarity_threshold"]),
),
home=HomeSettings(
topic_prefix=str(home["topic_prefix"]),
rooms={str(name): str(complement) for name, complement in dict(home["rooms"]).items()},
boards={str(board): str(room) for board, room in dict(home["boards"]).items()},
),
wake=WakeSettings(
enabled=bool(wake["enabled"]),
words=tuple(str(word) for word in wake["words"]),
threshold=float(wake["threshold"]),
thresholds={str(word): float(value) for word, value in dict(wake.get("thresholds") or {}).items()},
preroll_ms=int(wake["preroll_ms"]),
end_silence_ms=int(wake["end_silence_ms"]),
no_speech_timeout_s=float(wake["no_speech_timeout_s"]),
max_command_s=float(wake["max_command_s"]),
cooldown_s=float(wake["cooldown_s"]),
model_dir=models_dir / "openwakeword",
),
access=AccessSettings(
unknown_level=str(access["unknown"]),
default_level=str(access["default"]),
users={str(name): str(level) for name, level in dict(access.get("users") or {}).items()},
full_min_score=float(access["full_min_score"]),
rules={str(device): {_key(action): str(level) for action, level in dict(actions).items()}
for device, actions in dict(access["rules"]).items()},
confirm=tuple(str(item) for item in access.get("confirm") or ()),
confirm_timeout_s=float(access["confirm_timeout_s"]),
),
machines=_machines(data),
recordings_dir=(base / str(storage["recordings_dir"])).resolve(),
models_dir=models_dir,
log_level=str(logging_cfg["level"]),
log_format=str(logging_cfg["format"]),
log_file=(base / str(logging_cfg["file"])).resolve() if logging_cfg.get("file") else None,
)
+100
View File
@@ -0,0 +1,100 @@
"""Association locuteur + texte : qui a dit quoi (étape 10).
Whisper donne l'instant de chaque mot, la diarisation l'instant de chaque tour
de parole. Les deux ont reçu exactement le même audio (la parole trouvée par la
VAD) : leurs horloges coïncident. Chaque mot va au tour qui le recouvre le plus,
puis les mots consécutifs d'un même locuteur forment une intervention.
"""
from __future__ import annotations
import json
import math
from collections.abc import Sequence
from dataclasses import dataclass
from voice_server.diarization import SpeakerTurn
from voice_server.speaker_identifier import UNKNOWN, Identification
from voice_server.speech_to_text import Word
@dataclass(frozen=True)
class Utterance:
"""Une intervention : ce qu'une personne a dit d'un seul tenant."""
speaker: str # nom, ou « Inconnu »
text: str
start_s: float # instants dans la parole sans silences (ceux de Whisper et de la diarisation)
end_s: float
score: float | None # similarité avec le profil (None : pas d'identification)
label: str = "" # étiquette de diarisation (SPEAKER_00), vide sans diarisation
def turn_of(word: Word, turns: Sequence[SpeakerTurn]) -> SpeakerTurn | None:
"""Le tour qui recouvre le plus le mot ; à défaut (mot dans un blanc), le plus proche."""
if not turns:
return None
def overlap(turn: SpeakerTurn) -> float:
return min(word.end_s, turn.end_s) - max(word.start_s, turn.start_s)
best = max(turns, key=overlap)
if overlap(best) > 0:
return best
middle = (word.start_s + word.end_s) / 2
return min(turns, key=lambda turn: min(abs(middle - turn.start_s), abs(middle - turn.end_s)))
def attribute(words: Sequence[Word], turns: Sequence[SpeakerTurn],
verdicts: dict[str, Identification]) -> list[Utterance]:
"""Regroupe les mots en interventions, une par changement de locuteur.
Args:
words: mots horodatés de Whisper.
turns: tours de parole de la diarisation (vide : diarisation désactivée).
verdicts: identification de chaque étiquette de diarisation.
"""
utterances: list[Utterance] = []
group: list[Word] = []
current: str | None = None
def close() -> None:
if not group:
return
verdict = verdicts.get(current or "")
score = verdict.score if verdict is not None and not math.isnan(verdict.score) else None
utterances.append(Utterance(
speaker=verdict.label if verdict is not None else UNKNOWN,
text="".join(word.text for word in group).strip(),
start_s=group[0].start_s,
end_s=group[-1].end_s,
score=score,
label=current or "",
))
for word in words:
turn = turn_of(word, turns)
label = turn.speaker if turn is not None else None
if group and label != current:
close()
group = []
current = label
group.append(word)
close()
return utterances
def transcript_json(session: str, utterances: Sequence[Utterance]) -> bytes:
"""Message publié sur voice/<carte>/transcript.
{"session": "a1b2c3", "utterances": [{"speaker": "Denis", "text": "...",
"start": 0.0, "end": 1.66, "score": 0.74}, ...]}
"""
return json.dumps({
"session": session,
"utterances": [
{"speaker": u.speaker, "text": u.text, "start": round(u.start_s, 2), "end": round(u.end_s, 2),
"score": None if u.score is None else round(u.score, 2)}
for u in utterances
],
}, ensure_ascii=False).encode("utf-8")
+164
View File
@@ -0,0 +1,164 @@
"""Identification du locuteur : empreinte vocale comparée aux profils enrôlés (étape 9).
Une empreinte est un vecteur de 256 nombres qui résume le timbre d'une voix.
Deux empreintes de la même personne pointent à peu près dans la même direction :
on les compare par similarité cosinus (1 = même direction, 0 = sans rapport).
Au-dessus du seuil, on donne le nom du profil le plus proche ; sinon « Inconnu ».
C'est une estimation, jamais une preuve : une voix enregistrée, imitée ou
synthétisée peut tromper le système. Ne jamais s'en servir comme sécurité.
"""
from __future__ import annotations
import json
import logging
import math
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import numpy as np
from voice_server.settings import SpeakerSettings
logger = logging.getLogger(__name__)
UNKNOWN = "Inconnu"
@dataclass(frozen=True)
class VoiceProfile:
"""Profil vocal d'une personne : la moyenne de ses empreintes d'enrôlement."""
name: str
embedding: np.ndarray # normalisé (norme 1)
samples: int # nombre de sessions moyennées
model: str # modèle d'empreintes : un profil ne se compare qu'au même modèle
created: str
@dataclass(frozen=True)
class Identification:
"""Verdict pour une voix."""
name: str | None # None : personne n'atteint le seuil
score: float # similarité avec le profil le plus proche (NaN : pas d'empreinte)
closest: str | None # profil le plus proche, même sous le seuil (diagnostic)
@property
def label(self) -> str:
"""Le nom, ou « Inconnu »."""
return self.name or UNKNOWN
def describe(self) -> str:
"""« Denis (0.74) », « Inconnu (0.21, proche de Denis) », « Inconnu (trop peu de parole) »."""
if math.isnan(self.score):
return f"{UNKNOWN} (trop peu de parole)"
if self.name is not None:
return f"{self.name} ({self.score:.2f})"
if self.closest is not None:
return f"{UNKNOWN} ({self.score:.2f}, proche de {self.closest})"
return f"{UNKNOWN} (aucun profil)"
def normalized(vector: np.ndarray) -> np.ndarray:
"""Vecteur ramené à la norme 1 : le cosinus devient un simple produit scalaire."""
norm = float(np.linalg.norm(vector))
return vector / norm if norm > 0 and math.isfinite(norm) else np.full_like(vector, np.nan)
def profile_path(profiles_dir: Path, name: str) -> Path:
"""Fichier du profil : « Élodie Martin » → profiles/elodie-martin.json."""
ascii_name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", ascii_name.lower()).strip("-")
if not slug:
raise ValueError(f"nom de profil inutilisable : {name!r}")
return profiles_dir / f"{slug}.json"
def save_profile(profiles_dir: Path, name: str, embeddings: list[np.ndarray], model: str) -> VoiceProfile:
"""Moyenne les empreintes d'enrôlement et écrit le profil (remplace l'ancien)."""
mean = normalized(np.mean([normalized(e) for e in embeddings], axis=0))
profile = VoiceProfile(name=name, embedding=mean, samples=len(embeddings), model=model,
created=datetime.now().isoformat(timespec="seconds"))
path = profile_path(profiles_dir, name)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps({
"name": profile.name, "model": profile.model, "samples": profile.samples,
"created": profile.created, "embedding": [round(float(v), 6) for v in profile.embedding],
}, ensure_ascii=False, indent=1), encoding="utf-8")
temporary.replace(path) # écriture atomique : le serveur ne lit jamais un fichier à moitié écrit
return profile
def load_profiles(profiles_dir: Path, model: str) -> list[VoiceProfile]:
"""Lit les profils ; ceux d'un autre modèle d'empreintes sont ignorés (incomparables)."""
profiles: list[VoiceProfile] = []
for path in sorted(profiles_dir.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
profile = VoiceProfile(name=str(data["name"]), embedding=normalized(np.asarray(data["embedding"], dtype=np.float32)),
samples=int(data["samples"]), model=str(data["model"]), created=str(data["created"]))
except (OSError, ValueError, KeyError) as error:
logger.warning("profil illisible %s : %s", path.name, error)
continue
if profile.model != model:
logger.warning("profil %s ignore : cree avec %s, pas %s (re-enrolez)", path.name, profile.model, model)
continue
profiles.append(profile)
return profiles
class SpeakerIdentifier:
"""Donne un nom à une empreinte, d'après les profils du dossier.
Le dossier est relu dès qu'il change : un enrôlement fait pendant que le
serveur tourne est pris en compte sans le redémarrer.
"""
def __init__(self, settings: SpeakerSettings, model: str) -> None:
"""
Args:
settings: dossier des profils et seuil.
model: nom du modèle d'empreintes utilisé par la diarisation.
"""
self._settings = settings
self._model = model
self._stamp: float | None = None
self._profiles: list[VoiceProfile] = []
self._reload_if_changed()
@property
def profiles(self) -> list[VoiceProfile]:
"""Profils chargés."""
self._reload_if_changed()
return self._profiles
def identify(self, embedding: np.ndarray) -> Identification:
"""Profil le plus proche, s'il dépasse le seuil."""
self._reload_if_changed()
vector = normalized(np.asarray(embedding, dtype=np.float32))
if not np.all(np.isfinite(vector)):
return Identification(name=None, score=math.nan, closest=None)
if not self._profiles:
return Identification(name=None, score=0.0, closest=None)
scores = [float(profile.embedding @ vector) for profile in self._profiles]
best = int(np.argmax(scores))
closest = self._profiles[best].name
if scores[best] >= self._settings.similarity_threshold:
return Identification(name=closest, score=scores[best], closest=closest)
return Identification(name=None, score=scores[best], closest=closest)
def _reload_if_changed(self) -> None:
directory = self._settings.profiles_dir
stamp = directory.stat().st_mtime if directory.is_dir() else None
if stamp == self._stamp:
return
self._stamp = stamp
self._profiles = load_profiles(directory, self._model) if stamp is not None else []
names = ", ".join(f"{p.name} ({p.samples} sessions)" for p in self._profiles) or "aucun"
logger.info("profils vocaux : %s", names)
+117
View File
@@ -0,0 +1,117 @@
"""Transcription de la parole en texte avec faster-whisper (étape 6)."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from voice_server.settings import SttSettings
if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'extra « speech »
from faster_whisper import WhisperModel
logger = logging.getLogger(__name__)
# Phrases que Whisper « entend » dans le silence ou le bruit : elles viennent des
# sous-titres de vidéos qui ont servi à l'entraîner. Comparaison en minuscules.
KNOWN_HALLUCINATIONS = (
"amara.org",
"sous-titrage",
"sous-titres",
"merci d'avoir regardé",
)
@dataclass(frozen=True)
class Word:
"""Un mot et son instant dans l'audio transcrit (étape 10 : à qui l'attribuer)."""
text: str # tel que Whisper l'écrit, espace initiale comprise : « Ferme », « -moi »
start_s: float
end_s: float
@dataclass(frozen=True)
class Transcript:
"""Résultat d'une transcription."""
text: str # vide si rien d'exploitable n'a été reconnu
audio_s: float # durée de l'audio transcrit
elapsed_s: float # temps de calcul
dropped: tuple[str, ...] = () # segments écartés comme hallucinations
words: tuple[Word, ...] = () # mots des segments gardés, dans l'ordre
class SpeechToText:
"""Transcrit un PCM s16le 16 kHz mono en texte, avec un modèle local.
Le modèle est lu dans le dossier rempli par « make models » : aucun accès
réseau au démarrage, ni pendant la transcription.
"""
def __init__(self, stt: SttSettings, sample_rate: int) -> None:
"""
Args:
stt: réglages (modèle, langue, précision de calcul).
sample_rate: fréquence du PCM reçu ; Whisper exige 16 kHz.
Raises:
FileNotFoundError: le modèle n'a pas été téléchargé.
ValueError: fréquence différente de 16 kHz.
"""
if sample_rate != 16000:
raise ValueError(f"Whisper attend du 16 kHz, pas {sample_rate} Hz")
if not (stt.model_dir / "model.bin").exists():
raise FileNotFoundError(f"modele whisper absent de {stt.model_dir} : lancez 'make models'")
self._stt = stt
self._sample_rate = sample_rate
from faster_whisper import WhisperModel
began = time.monotonic()
# int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32.
self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type)
# Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question.
self.transcribe(bytes(sample_rate * 2))
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
def transcribe(self, pcm: bytes) -> Transcript:
"""Transcrit le PCM et écarte les segments suspects."""
began = time.monotonic()
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
segments, _ = self._model.transcribe(
audio,
language=self._stt.language,
beam_size=self._stt.beam_size,
# Chaque question est indépendante : sans cela, une erreur se propage d'un segment à l'autre.
condition_on_previous_text=False,
no_speech_threshold=self._stt.no_speech_threshold,
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
word_timestamps=True, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
)
kept: list[str] = []
dropped: list[str] = []
words: list[Word] = []
for segment in segments: # générateur : le calcul se fait pendant ce parcours
text = segment.text.strip()
if not text:
continue
if any(marker in text.lower() for marker in KNOWN_HALLUCINATIONS):
dropped.append(text)
else:
kept.append(text)
words.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
for w in segment.words or ())
return Transcript(
text=" ".join(kept),
audio_s=audio.size / self._sample_rate,
elapsed_s=time.monotonic() - began,
dropped=tuple(dropped),
words=tuple(words),
)
+13
View File
@@ -0,0 +1,13 @@
"""Normalisation du texte transcrit, commune à l'assistant et à la domotique."""
from __future__ import annotations
import re
import unicodedata
def normalize(text: str) -> str:
"""Minuscules, sans accents ni ponctuation : « Quelle heure est-il ? » → « quelle heure est il »."""
decomposed = unicodedata.normalize("NFD", text.lower())
without_accents = "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
return " ".join(re.sub(r"[^a-z0-9]+", " ", without_accents).split())
+73
View File
@@ -0,0 +1,73 @@
"""Synthèse vocale avec Piper (étape 6)."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from voice_server.audio_resampler import AudioResampler
from voice_server.settings import TtsSettings
if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'extra « speech »
from piper import PiperVoice, SynthesisConfig
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Speech:
"""Parole synthétisée, déjà au format du système."""
pcm: bytes # s16le mono à la fréquence du système
audio_s: float # durée de la parole
elapsed_s: float # temps de calcul (synthèse et rééchantillonnage)
class TextToSpeech:
"""Transforme un texte en PCM prêt à envoyer à l'ESP32.
Piper produit du 22 050 Hz en flottants ; AudioResampler le ramène au
format du système (16 kHz, s16le) et fixe sa crête à -3 dBFS.
"""
def __init__(self, tts: TtsSettings, resampler: AudioResampler, sample_rate: int) -> None:
"""
Args:
tts: réglages (voix, débit).
resampler: mise au format du système.
sample_rate: fréquence du système, pour calculer la durée produite.
Raises:
FileNotFoundError: la voix n'a pas été téléchargée.
"""
if not tts.model_path.exists():
raise FileNotFoundError(f"voix piper absente : {tts.model_path} : lancez 'make models'")
from piper import PiperVoice, SynthesisConfig
began = time.monotonic()
self._voice: PiperVoice = PiperVoice.load(tts.model_path)
self._config: SynthesisConfig = SynthesisConfig(length_scale=tts.length_scale)
self._resampler = resampler
self._sample_rate = sample_rate
logger.info("voix piper %s chargee en %.1f s (%d Hz)", tts.voice,
time.monotonic() - began, self._voice.config.sample_rate)
def synthesize(self, text: str) -> Speech:
"""Synthétise le texte ; Piper le découpe en phrases, qu'on remet bout à bout."""
began = time.monotonic()
parts: list[np.ndarray] = []
rate = self._voice.config.sample_rate
for chunk in self._voice.synthesize(text, syn_config=self._config):
parts.append(chunk.audio_float_array)
rate = chunk.sample_rate
signal = np.concatenate(parts) if parts else np.zeros(0, dtype=np.float32)
pcm = self._resampler.convert(signal, rate)
return Speech(
pcm=pcm,
audio_s=len(pcm) / 2 / self._sample_rate,
elapsed_s=time.monotonic() - began,
)
+157
View File
@@ -0,0 +1,157 @@
"""Détection d'activité vocale (VAD) avec Silero (étape 7).
Silero donne, pour chaque fenêtre de 512 échantillons (32 ms à 16 kHz), la
probabilité qu'elle contienne de la parole. Ce module en déduit les segments
de parole, puis ne garde qu'eux : silences de début et de fin retirés, longs
silences intérieurs raccourcis.
Le modèle ONNX est celui que livre faster-whisper : pas de torch, rien à télécharger.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import numpy as np
from voice_server.settings import VadSettings
logger = logging.getLogger(__name__)
WINDOW_SAMPLES = 512 # imposé par Silero à 16 kHz
HYSTERESIS = 0.15 # on sort de la parole sous threshold - 0,15 : pas de battement au seuil
@dataclass(frozen=True)
class Segment:
"""Segment de parole, en échantillons : [start, end[."""
start: int
end: int
@dataclass(frozen=True)
class VadResult:
"""Parole trouvée dans une session."""
segments: tuple[Segment, ...] # bornes après marge, dans l'audio d'origine
speech_pcm: bytes # la parole seule, segments mis bout à bout
probabilities: np.ndarray # une probabilité par fenêtre, pour le diagnostic
total_s: float # durée de la session
speech_s: float # durée de parole conservée
elapsed_s: float # temps de calcul
sample_rate: int
@property
def has_speech(self) -> bool:
"""Faux si la session ne contient aucune parole (appui muet, bruit)."""
return bool(self.segments)
def bounds(self) -> str:
"""Bornes des segments en secondes : « 0.45-1.80, 2.10-2.60 »."""
return ", ".join(f"{s.start / self.sample_rate:.2f}-{s.end / self.sample_rate:.2f}"
for s in self.segments)
def find_segments(probabilities: Sequence[float], threshold: float,
min_silence_windows: int, min_speech_windows: int) -> list[Segment]:
"""Segments de parole, en fenêtres, à partir des probabilités de Silero.
Entrée en parole dès threshold ; sortie après min_silence_windows fenêtres
sous threshold - HYSTERESIS (une pause entre deux mots ne coupe pas la phrase).
Les segments plus courts que min_speech_windows sont des bruits : clic, toux.
"""
low = threshold - HYSTERESIS
segments: list[Segment] = []
start: int | None = None
quiet = 0 # fenêtres calmes consécutives depuis la dernière parole
for index, probability in enumerate(probabilities):
if start is None:
if probability >= threshold:
start, quiet = index, 0
continue
quiet = quiet + 1 if probability < low else 0
if quiet >= min_silence_windows:
end = index - quiet + 1
if end - start >= min_speech_windows:
segments.append(Segment(start, end))
start = None
if start is not None and len(probabilities) - quiet - start >= min_speech_windows:
segments.append(Segment(start, len(probabilities) - quiet))
return segments
def pad_segments(segments: Sequence[Segment], pad: int, total: int) -> list[Segment]:
"""Élargit chaque segment de pad (début et fin de mot sont faibles) et fusionne les chevauchements."""
padded: list[Segment] = []
for segment in segments:
start, end = max(0, segment.start - pad), min(total, segment.end + pad)
if padded and start <= padded[-1].end:
padded[-1] = Segment(padded[-1].start, end)
else:
padded.append(Segment(start, end))
return padded
class VoiceActivityDetector:
"""Trouve la parole dans un PCM s16le 16 kHz mono et retire les silences."""
def __init__(self, vad: VadSettings, sample_rate: int,
model: Callable[[np.ndarray], np.ndarray] | None = None) -> None:
"""
Args:
vad: seuil et durées minimales.
sample_rate: fréquence du PCM ; Silero exige 16 kHz avec des fenêtres de 512.
model: probabilités par fenêtre ; par défaut Silero, injectable pour les tests.
Raises:
ValueError: fréquence différente de 16 kHz.
"""
if sample_rate != 16000:
raise ValueError(f"Silero attend du 16 kHz avec des fenetres de 512, pas {sample_rate} Hz")
if model is None:
# Import tardif : le mode écho se passe de l'extra « speech ».
from faster_whisper.vad import get_vad_model
model = get_vad_model()
self._model = model
self._vad = vad
self._sample_rate = sample_rate
window_ms = WINDOW_SAMPLES * 1000 / sample_rate
self._min_silence = max(1, round(vad.min_silence_ms / window_ms))
self._min_speech = max(1, round(vad.min_speech_ms / window_ms))
self._pad = vad.speech_pad_ms * sample_rate // 1000
def detect(self, pcm: bytes) -> VadResult:
"""Analyse le PCM et rend la parole seule."""
began = time.monotonic()
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
audio = samples.astype(np.float32) / 32768.0
# Dernière fenêtre complétée par du silence : Silero n'accepte que des fenêtres entières.
padded = np.pad(audio, (0, -audio.size % WINDOW_SAMPLES))
probabilities = (np.asarray(self._model(padded), dtype=np.float32).reshape(-1)
if padded.size else np.zeros(0, dtype=np.float32))
windows = find_segments(probabilities.tolist(), self._vad.threshold,
self._min_silence, self._min_speech)
in_samples = [Segment(s.start * WINDOW_SAMPLES, min(samples.size, s.end * WINDOW_SAMPLES))
for s in windows]
segments = pad_segments(in_samples, self._pad, samples.size)
speech = b"".join(samples[s.start:s.end].tobytes() for s in segments)
return VadResult(
segments=tuple(segments),
speech_pcm=speech,
probabilities=probabilities,
total_s=samples.size / self._sample_rate,
speech_s=len(speech) / 2 / self._sample_rate,
elapsed_s=time.monotonic() - began,
sample_rate=self._sample_rate,
)
def envelope(probabilities: np.ndarray, threshold: float) -> str:
"""Une lettre par fenêtre de 32 ms : « # » parole, « . » hésitation, « _ » silence."""
low = threshold - HYSTERESIS
return "".join("#" if p >= threshold else "." if p >= low else "_" for p in probabilities)
+255
View File
@@ -0,0 +1,255 @@
"""Boucle complète d'une question (étapes 6 à 14).
VAD → diarisation → identification → transcription → qui a dit quoi → réponse
(+ commande domotique, si les droits de la personne le permettent) → synthèse → envoi.
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol
import numpy as np
from voice_server.access_control import Resolution, Verdict
from voice_server.assistant import NOTHING_HEARD, Reply
from voice_server.audio_buffer import read_pcm
from voice_server.audio_sender import Publisher
from voice_server.diarization import Diarization, SpeakerTurn
from voice_server.machine_control import Command
from voice_server.session_manager import SessionResult
from voice_server.speaker_attribution import Utterance, attribute, transcript_json
from voice_server.speaker_identifier import UNKNOWN, Identification
from voice_server.speech_to_text import Transcript
from voice_server.text_to_speech import Speech
from voice_server.voice_activity import VadResult
logger = logging.getLogger(__name__)
MIN_SPEECH_S = 0.3 # en dessous, appui trop bref pour contenir un mot
class SpeechDetector(Protocol):
"""Ce que la boucle attend de la détection d'activité vocale."""
def detect(self, pcm: bytes) -> VadResult: ...
class SpeakerSplitter(Protocol):
"""Ce que la boucle attend de la diarisation."""
def diarize(self, pcm: bytes) -> Diarization: ...
class Identifier(Protocol):
"""Ce que la boucle attend de l'identification du locuteur."""
def identify(self, embedding: np.ndarray) -> Identification: ...
class Policy(Protocol):
"""Ce que la boucle attend des droits par profil (AccessPolicy)."""
def check(self, command: Command, speaker: str | None, score: float | None) -> Verdict: ...
def ask(self, board: str, command: Command, speaker: str | None) -> None: ...
def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: ...
class Transcriber(Protocol):
"""Ce que la boucle attend de la transcription."""
def transcribe(self, pcm: bytes) -> Transcript: ...
class Responder(Protocol):
"""Ce que la boucle attend de l'assistant."""
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply: ...
class Controller(Protocol):
"""Ce que la boucle attend de la domotique (HomeController)."""
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]: ...
class Synthesizer(Protocol):
"""Ce que la boucle attend de la synthèse vocale."""
def synthesize(self, text: str) -> Speech: ...
class Player(Protocol):
"""Ce que la boucle attend de l'envoi vers la carte (AudioSender)."""
def send(self, device: str, pcm: bytes, session: str | None = None) -> str: ...
@dataclass(frozen=True)
class Turn:
"""Bilan d'un échange, pour le journal et les tests."""
heard: str # texte transcrit ; vide si rien de reconnu
answer: str # texte dit par la carte
speech_s: float # parole trouvée par la VAD (0 : appui muet)
speakers: int # locuteurs trouvés par la diarisation (0 : non faite)
names: tuple[str, ...] # nom ou « Inconnu » de chaque locuteur, ordre de la diarisation
utterances: tuple[Utterance, ...] # qui a dit quoi (étape 10)
command: Command | None # commande exécutée : domotique (11), machine (15b)
refused: bool # commande refusée ou mise en attente de confirmation (étape 14)
stt_s: float # temps de transcription (0 si Whisper n'a pas été appelé)
tts_s: float # temps de synthèse
ready_s: float # de la fin de session au départ du premier chunk
answer_s: float # durée de la réponse parlée
class VoicePipeline:
"""Traite une session terminée et fait dire la réponse par la carte.
L'appel est bloquant (transcription, puis envoi au rythme réel) : il
s'exécute dans le fil de travail du serveur, jamais dans le fil réseau.
"""
def __init__(
self,
vad: SpeechDetector,
stt: Transcriber,
assistant: Responder,
tts: Synthesizer,
player: Player,
diarizer: SpeakerSplitter | None = None,
identifier: Identifier | None = None,
publisher: Publisher | None = None,
controller: Controller | None = None,
policy: Policy | None = None,
read: Callable[[SessionResult], bytes] = lambda result: read_pcm(result.path),
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
diarizer: diarisation, None pour s'en passer (Raspberry Pi 4).
identifier: identification ; sans diarisation, elle n'a pas d'empreintes.
publisher: publie « qui a dit quoi » sur voice/<carte>/transcript (None : journal seul).
controller: exécute les commandes domotiques (None : elles sont seulement annoncées).
policy: droits par profil (None : toute commande est permise).
read: lecture du PCM d'une session, injectable pour les tests.
clock: horloge, injectable pour les tests.
"""
self._vad = vad
self._stt = stt
self._assistant = assistant
self._tts = tts
self._player = player
self._diarizer = diarizer
self._identifier = identifier
self._publisher = publisher
self._controller = controller
self._policy = policy
self._read = read
self._clock = clock
def handle(self, result: SessionResult) -> Turn | None:
"""Répond à la session ; None si elle était trop courte pour être une question."""
began = self._clock()
if result.duration_s < MIN_SPEECH_S:
logger.warning("%s : session %s trop courte (%.2f s) : bouton relache trop tot ?",
result.device, result.session_id, result.duration_s)
return None
speakers = 0
turns: tuple[SpeakerTurn, ...] = ()
verdicts: dict[str, Identification] = {}
# Sans parole, Whisper n'est pas appelé : 1 s de calcul pour un texte souvent inventé.
vad = self._vad.detect(self._read(result))
if vad.has_speech:
logger.info("%s : parole %.2f s sur %.2f s (segments %s s), VAD %.0f ms", result.device,
vad.speech_s, vad.total_s, vad.bounds(), vad.elapsed_s * 1000)
# La diarisation et Whisper reçoivent le même audio : leurs instants concordent (étape 10).
if self._diarizer is not None:
diarization = self._diarizer.diarize(vad.speech_pcm)
speakers = len(diarization.speakers)
logger.info("%s : %d locuteur(s) : %s, diarisation %.2f s", result.device, speakers,
diarization.describe() or "aucun tour", diarization.elapsed_s)
turns = diarization.turns
verdicts = self._identify(result.device, diarization)
transcript = self._stt.transcribe(vad.speech_pcm)
else:
logger.warning("%s : session %s sans parole (VAD, crete %.1f dBFS)", result.device,
result.session_id, result.levels.peak_dbfs)
transcript = Transcript(text="", audio_s=0.0, elapsed_s=0.0)
if transcript.dropped:
logger.warning("%s : hallucination ecartee : %s", result.device, " | ".join(transcript.dropped))
utterances = self._who_said_what(result, transcript, turns, verdicts)
# On répond à la dernière personne qui a parlé : c'est elle qui attend la réponse,
# et ce sont ses droits qui s'appliquent.
last = utterances[-1] if utterances else None
speaker = last.speaker if last is not None and last.speaker != UNKNOWN else None
score = last.score if last is not None else None
reply, refused = self._decide(result.device, transcript.text, speaker, score)
answer = reply.text
if reply.command is not None and self._controller is not None:
# La commande part avant la réponse parlée : la lumière s'allume pendant que la carte le dit.
self._controller.execute(result.device, reply.command, speaker, result.session_id)
speech = self._tts.synthesize(answer)
ready_s = self._clock() - began
logger.info("%s : entendu « %s » -> reponse « %s »", result.device, transcript.text, answer)
logger.info("%s : transcription %.2f s (audio %.1f s), synthese %.2f s, reponse prete en %.2f s",
result.device, transcript.elapsed_s, transcript.audio_s, speech.elapsed_s, ready_s)
self._player.send(result.device, speech.pcm, f"rep-{result.session_id}")
return Turn(heard=transcript.text, answer=answer, speech_s=vad.speech_s, speakers=speakers,
names=tuple(verdict.label for verdict in verdicts.values()), utterances=tuple(utterances),
command=reply.command, refused=refused,
stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s,
answer_s=speech.audio_s)
def _decide(self, board: str, text: str, speaker: str | None, score: float | None) -> tuple[Reply, bool]:
"""Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente."""
if not text:
return Reply(NOTHING_HEARD), False
if self._policy is not None:
# Une confirmation attendue passe avant tout : « oui » n'est pas une requête.
resolution = self._policy.resolve(board, text, speaker)
if resolution is not None:
return Reply(resolution.answer, resolution.command), resolution.command is None
reply = self._assistant.respond(text, speaker, board)
if reply.command is None or self._policy is None:
return reply, False
verdict = self._policy.check(reply.command, speaker, score)
if not verdict.allowed:
return Reply(verdict.answer or "Je ne peux pas faire cela."), True
if verdict.confirm:
self._policy.ask(board, reply.command, speaker)
return Reply(verdict.answer or "Confirmez-vous ?"), True
return reply, False
def _identify(self, device: str, diarization: Diarization) -> dict[str, Identification]:
"""Un verdict (nom ou « Inconnu ») par étiquette de diarisation."""
if self._identifier is None or diarization.embeddings is None:
return {}
verdicts = {speaker: self._identifier.identify(embedding)
for speaker, embedding in zip(diarization.speakers, diarization.embeddings)}
logger.info("%s : %s", device, ", ".join(
f"{speaker} = {verdict.describe()}" for speaker, verdict in verdicts.items()))
return verdicts
def _who_said_what(self, result: SessionResult, transcript: Transcript,
turns: tuple[SpeakerTurn, ...], verdicts: dict[str, Identification]) -> list[Utterance]:
"""Interventions de la session, journalisées et publiées sur voice/<carte>/transcript."""
if not transcript.text:
return []
utterances = attribute(transcript.words, turns, verdicts)
if not utterances: # texte sans horodatage des mots : une seule intervention anonyme
utterances = [Utterance(speaker=UNKNOWN, text=transcript.text, start_s=0.0,
end_s=transcript.audio_s, score=None)]
for utterance in utterances:
logger.info("%s : %s : « %s »", result.device, utterance.speaker, utterance.text)
if self._publisher is not None:
self._publisher.publish(result.device, "transcript", transcript_json(result.session_id, utterances), 1)
return utterances
+104
View File
@@ -0,0 +1,104 @@
"""Détection du mot de réveil avec openWakeWord (étape 13).
openWakeWord calcule un spectrogramme, puis une empreinte de chaque tranche de
80 ms, puis un petit modèle par mot donne un score entre 0 et 1. Les modèles
prêts à l'emploi sont anglais : « Alexa », « Hey Jarvis », « Hey Mycroft ».
Exécution en ONNX : la version TensorFlow Lite n'existe pas pour Python 3.13.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
from voice_server.settings import WakeSettings
if TYPE_CHECKING: # import réel dans __init__ : extra « wakeword » facultatif
from openwakeword.model import Model
logger = logging.getLogger(__name__)
MODEL_VERSION = "v0.1"
FEATURE_MODELS = ("melspectrogram.onnx", "embedding_model.onnx")
NEAR_MISS_FLOOR = 0.15 # un pic de score au-dessus, sans atteindre le seuil : « presque reconnu »
@dataclass(frozen=True)
class Detection:
"""Un mot de réveil reconnu."""
word: str # « alexa », « hey_jarvis »…
score: float
def model_files(settings: WakeSettings) -> list[str]:
"""Fichiers nécessaires : les deux modèles communs, puis un par mot."""
return [*FEATURE_MODELS, *(f"{word}_{MODEL_VERSION}.onnx" for word in settings.words)]
class WakeWordDetector:
"""Donne au fil de l'eau les scores du mot de réveil, pour UNE carte.
Le modèle garde un historique audio interne : un détecteur par carte, sinon
les flux de deux cartes se mélangeraient.
"""
def __init__(self, settings: WakeSettings, device: str = "") -> None:
"""
Args:
device: carte écoutée, pour le journal.
Raises:
FileNotFoundError: modèles absents (« make models »).
ImportError: openwakeword non installé (« make install-wakeword »).
"""
missing = [name for name in model_files(settings) if not (settings.model_dir / name).exists()]
if missing:
raise FileNotFoundError(f"modeles openWakeWord absents de {settings.model_dir} "
f"({', '.join(missing)}) : lancez 'make models'")
from openwakeword.model import Model
directory = settings.model_dir
self._model: Model = Model(
wakeword_models=[str(directory / f"{word}_{MODEL_VERSION}.onnx") for word in settings.words],
inference_framework="onnx",
melspec_model_path=str(directory / FEATURE_MODELS[0]),
embedding_model_path=str(directory / FEATURE_MODELS[1]),
)
self._thresholds = {word: settings.thresholds.get(word, settings.threshold) for word in settings.words}
self._device = device
self._peaks: dict[str, float] = {} # pic de score en cours, par mot (diagnostic)
def process(self, pcm: bytes) -> Detection | None:
"""Ajoute un chunk (s16le, 16 kHz) ; rend le mot s'il vient d'être reconnu."""
samples = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2")
raw: Any = self._model.predict(samples) # dict des scores (tuple seulement si timing=True)
scores = {str(key).removesuffix(f"_{MODEL_VERSION}"): float(value) for key, value in raw.items()}
detected: Detection | None = None
for word, score in scores.items():
threshold = self._thresholds.get(word, 1.0)
if score >= threshold:
if detected is None or score > detected.score:
detected = Detection(word=word, score=score)
self._track_near_miss(word, score, threshold)
if detected is not None:
self._peaks.clear()
return detected
def _track_near_miss(self, word: str, score: float, threshold: float) -> None:
"""Journalise un mot presque reconnu : la mesure qui permet de régler son seuil."""
if score >= NEAR_MISS_FLOOR:
self._peaks[word] = max(self._peaks.get(word, 0.0), score)
return
peak = self._peaks.pop(word, 0.0) # le pic vient de retomber
if NEAR_MISS_FLOOR <= peak < threshold:
logger.info("%s : mot presque reconnu « %s » : %.2f (seuil %.2f)", self._device, word, peak, threshold)
def reset(self) -> None:
"""Oublie l'historique : à faire après une réponse, pour ne pas redétecter l'ancien mot."""
self._model.reset()
self._peaks.clear()