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
+12
View File
@@ -0,0 +1,12 @@
# Modèle de secrets du serveur. Copier en « .env » (ignoré par git) :
# cp server/.env.example server/.env
# Compte MQTT du serveur (étape 15), créé par « make mqtt-user NAME=voice-server ».
# Sans guillemets ; mot de passe en hexadécimal (openssl rand -hex 16) : le Makefile
# relit ce fichier pour les cibles mqtt-*, et « # » ou « $ » y seraient mal compris.
MQTT_USERNAME=
MQTT_PASSWORD=
# Jeton Hugging Face, nécessaire aux modèles pyannote (étape 8).
# À créer sur https://huggingface.co/settings/tokens — ne jamais versionner.
HF_TOKEN=
+141
View File
@@ -0,0 +1,141 @@
# Configuration du serveur vocal.
# Les valeurs sensibles (mots de passe, jetons) vont dans .env, jamais ici.
# Les sections marquées « étape N » ne sont lues qu'à partir de cette étape.
audio:
sample_rate: 16000 # Hz, identique dans tout le système
bits: 16
channels: 1
codec: pcm_s16le
chunk_ms: 50 # 800 échantillons = 1600 octets
# étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/...
mqtt:
host: localhost # le broker tourne sur la même machine que le serveur
port: 1883
keepalive: 30
client_id: voice-server # unique sur le broker
topic_prefix: voice
# étape 4 : sessions de parole reçues des ESP32
sessions:
max_seconds: 30 # au-delà, on arrête d'accumuler (garde-fou mémoire)
idle_timeout_s: 5 # plus rien reçu depuis 5 s et pas de END : on clôt
end_grace_s: 0.5 # après le END, attente des derniers chunks en vol
# chemins relatifs à ce fichier
storage:
recordings_dir: recordings
models_dir: models
logging:
level: INFO
format: "%(asctime)s %(levelname)-7s %(name)s | %(message)s"
# étape 15 : copie du journal dans un fichier tournant (3 x 1 Mo), pour
# retrouver ce qui s'est passé sur un Raspberry Pi sans écran. Vide : écran seul.
file: logs/voice-server.log
# étape 6 : transcription (modèle dans models/whisper/<model>, « make models »)
stt:
model: small # « base » sur Raspberry Pi 4, « small » sur Pi 5 / PC
language: fr
compute_type: int8
beam_size: 5 # 1 = plus rapide, 5 = plus précis
no_speech_threshold: 0.6 # segment écarté au-delà : Whisper invente du texte sur le silence
# étape 6 : synthèse vocale (modèle dans models/piper/<voice>.onnx, « make models »)
# Piper produit du 22 050 Hz : le serveur rééchantillonne en audio.sample_rate.
tts:
voice: fr_FR-siwis-medium
length_scale: 1.0 # > 1 : débit plus lent
# étape 7 : détection d'activité vocale (Silero, fenêtres de 512 échantillons = 32 ms,
# imposées à 16 kHz). Une session sans parole n'est pas transcrite.
vad:
threshold: 0.5 # probabilité de parole ; on en sort sous 0,35 (hystérésis)
min_silence_ms: 500 # pause plus courte : la phrase continue
min_speech_ms: 250 # parole plus courte : bruit (clic du bouton), ignorée
speech_pad_ms: 150 # marge autour de chaque segment : attaques et fins de mots
# étape 8 : diarisation (qui parle quand), sur la seule parole trouvée par la VAD.
# Modèle soumis à conditions : les accepter sur huggingface.co, HF_TOKEN dans .env,
# puis « make models ». Ensuite, tout fonctionne hors ligne ; télémétrie pyannote coupée.
diarization:
enabled: true # false sur Raspberry Pi 4 (trop lourd) ou sans l'extra « diarization »
model: pyannote/speaker-diarization-community-1
max_speakers: 3 # borne haute : un assistant de salon, pas une réunion
# étape 9 : identification du locuteur (probabiliste, jamais une sécurité).
# Profils créés par « make enroll NAME=... », un fichier par personne (donnée biométrique).
# Nécessite la diarisation : les empreintes viennent de son modèle (WeSpeaker ResNet34).
speaker:
profiles_dir: profiles
similarity_threshold: 0.45 # mesuré : vous 0,52 à 0,83 contre votre profil, Piper -0,08 à 0,11
# étape 11 : domotique. Commandes publiées sur <topic_prefix>/<pièce>/<appareil>/set ;
# la carte exécute en plus celles de sa propre pièce (LED de démonstration).
home:
topic_prefix: home
rooms: # nom tel qu'on le dit → complément dans les réponses
salon: du salon
cuisine: de la cuisine
chambre: de la chambre
salle de bain: de la salle de bain
bureau: du bureau
garage: du garage
entrée: de l'entrée
boards: # pièce de chaque carte : « allume la lumière » sans pièce = ici
esp32-01: salon
# étape 13 : mot de réveil, mains libres. Le serveur demande à chaque carte de lui envoyer
# son micro en continu (voice/<carte>/audio/stream) et guette le mot. Rien n'est écrit
# sur disque avant le mot : seules les dernières preroll_ms restent en mémoire.
# Prononciation à l'anglaise : « Alexa », « Hé Maïcrofte », « Hey Djâr-viss ».
wake:
enabled: true
words: [alexa, hey_jarvis, hey_mycroft] # models/openwakeword/<mot>_v0.1.onnx
threshold: 0.5 # mesuré : 90 enregistrements sans le mot, score max 0,07
# Seuil propre à un mot, si le journal montre des « mot presque reconnu » répétés.
# Plus bas = plus sensible, mais plus de déclenchements intempestifs. Ex. : {hey_jarvis: 0.35}
thresholds: {}
preroll_ms: 500 # la commande suit souvent le mot sans pause
end_silence_ms: 800 # silence qui clôt la commande
no_speech_timeout_s: 4 # mot entendu mais rien dit ensuite : on abandonne
max_command_s: 10
cooldown_s: 20 # écoute suspendue pendant la réponse (jusqu'au bilan « played »)
# étape 14 : droits par profil. La voix n'est JAMAIS une preuve d'identité : une
# imitation ou un enregistrement peut tromper. Niveaux : complet > standard > limite.
access:
unknown: limite # voix non reconnue : conversation seulement
default: standard # profil enrôlé absent de users
users: # noms des profils (make profiles)
Denis: complet
full_min_score: 0.6 # « complet » seulement si la voix est reconnue nettement
rules: # niveau minimal par appareil et action ; absent = complet
light: {"on": standard, "off": standard} # guillemets : on/off nus = booléens en YAML
shutter: {open: standard, close: standard}
door: {open: complet, close: standard}
machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b
confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui »
confirm_timeout_s: 15
# étape 15b : machines Linux du réseau. Chacune fait tourner un agent (agent/voice_agent.py)
# qui n'exécute QUE les actions de SA liste (/etc/voice-agent/agent.yaml) ; ici, on dit
# seulement comment on les appelle. « allume » = Wake-on-LAN, envoyé par le serveur
# (la machine éteinte n'a pas d'agent). Nouvelle action : l'ajouter à l'agent, ici
# (mots et phrases), puis dans access.rules.machine (sinon : niveau complet exigé).
machines:
topic_prefix: agent # agent/<machine>/state (retenu), command, result
broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local
actions: # mots sans accents ; « does » et « verb » : phrases dites
wake: {words: [allume, allumer, allumez, demarre, demarrer, reveille, reveiller], does: "J'allume", verb: allumer}
shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez], does: "J'éteins", verb: éteindre}
reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer}
lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller}
list: # identifiant de l'agent → comment on l'appelle
pc-bureau:
# Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ».
names: [pc du bureau, p c du bureau, ordinateur du bureau]
name: le PC du bureau
mac: "" # ex. "aa:bb:cc:dd:ee:ff" (ip link) pour « allume le PC du bureau »
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "voice-server"
version = "0.1.0"
description = "Serveur d'assistant vocal local (MQTT, faster-whisper, Piper)"
requires-python = ">=3.11"
license = { text = "MIT" }
# Dépendances épinglées : indispensables dès l'étape 1 (outils série) et l'étape 3 (MQTT).
dependencies = [
"pyserial==3.5",
"paho-mqtt==2.1.0",
"numpy==2.5.3", # >= 2.2.2 exigé par pyannote 4 (étape 8) ; testé avec toute la chaîne
"PyYAML==6.0.2",
"python-dotenv==1.0.1",
"soxr==0.5.0.post1",
]
[project.optional-dependencies]
# Installées à l'étape 6 (transcription et synthèse vocale).
# Versions minimales pour Python 3.13 : faster-whisper 1.0 exige av<13 (compilé
# depuis les sources, sans roue 3.13) et piper-tts 1.2 dépend de piper-phonemize,
# sans roue au-delà de Python 3.11. piper-tts 1.3+ embarque espeak-ng.
speech = [
"faster-whisper==1.2.1",
"piper-tts==1.8.0",
]
# Installées à l'étape 8 (diarisation). La VAD de l'étape 7 n'en a pas besoin :
# Silero est livré avec faster-whisper. torch en version CPU : « make install-diarization »
# passe par l'index de pytorch.org, sinon pip installe plusieurs Go de bibliothèques CUDA.
# pyannote 3.3 est antérieur à ce torch ; pyannote 4 exige numpy >= 2.2.2.
diarization = [
"torch==2.14.0",
"torchaudio==2.11.0",
"pyannote.audio==4.0.7",
]
# Étape 13 (mot de réveil) : dépendances d'openwakeword en mode ONNX. openwakeword 0.6.0
# lui-même s'installe SANS ses dépendances (« make install-wakeword ») : il exige
# tflite-runtime, qui n'existe pas pour Python 3.13 et ne sert pas en ONNX.
wakeword = [
"onnxruntime==1.30.0",
"scipy==1.18.1",
"scikit-learn==1.9.1",
"tqdm==4.70.1",
"requests==2.34.2",
]
dev = [
"pyright==1.1.389",
"pytest==8.3.4",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["voice_server*"]
[tool.pytest.ini_options]
# Les tests importent voice_server depuis server/, sans installation préalable.
pythonpath = ["."]
testpaths = ["tests"]
+23
View File
@@ -0,0 +1,23 @@
{
"include": [
"voice_server",
"tests",
"../tools",
"../agent"
],
"exclude": [
"**/__pycache__",
".venv",
"models"
],
"extraPaths": [
"../tools",
"../agent"
],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.11",
"typeCheckingMode": "standard",
"reportMissingTypeStubs": false,
"reportMissingModuleSource": false
}
+160
View File
@@ -0,0 +1,160 @@
"""Tests des droits par profil, étape 14 : niveaux, refus, confirmation (sans modèle)."""
from __future__ import annotations
from pathlib import Path
import pytest
from voice_server.access_control import AccessPolicy, Level, parse_level
from voice_server.home_control import DeviceCommand
from voice_server.settings import AccessSettings, HomeSettings, load_settings
HOME = HomeSettings(topic_prefix="home", rooms={"salon": "du salon"}, boards={"esp32-01": "salon"})
ACCESS = AccessSettings(
unknown_level="limite", default_level="standard", users={"Denis": "complet", "Tom": "limite"},
full_min_score=0.6,
rules={"light": {"on": "standard", "off": "standard"}, "door": {"open": "complet", "close": "standard"}},
confirm=("door.open",), confirm_timeout_s=15.0,
)
LIGHT_ON = DeviceCommand("on", "light", "salon")
DOOR_OPEN = DeviceCommand("open", "door", "salon")
class Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
@pytest.fixture
def policy(clock: Clock) -> AccessPolicy:
return AccessPolicy(ACCESS, HOME, clock=clock)
@pytest.mark.parametrize(
("speaker", "score", "expected"),
[
(None, None, Level.LIMITED), # voix inconnue
("Denis", 0.74, Level.FULL),
("Denis", 0.52, Level.STANDARD), # reconnu de justesse : pas le niveau complet
("Jessica", 0.9, Level.STANDARD), # enrôlée sans niveau : défaut
("Tom", 0.95, Level.LIMITED),
],
)
def test_levels(policy: AccessPolicy, speaker: str | None, score: float | None, expected: Level) -> None:
assert policy.level(speaker, score) == expected
def test_unknown_level_name_is_rejected() -> None:
with pytest.raises(ValueError, match="complet, standard ou limite"):
parse_level("admin")
def test_unknown_voice_cannot_command(policy: AccessPolicy) -> None:
verdict = policy.check(LIGHT_ON, None, None)
assert not verdict.allowed
assert verdict.answer == "Je ne vous ai pas reconnu : je ne peux pas allumer la lumière du salon."
def test_standard_profile_controls_lights_not_the_door(policy: AccessPolicy) -> None:
assert policy.check(LIGHT_ON, "Jessica", 0.9).allowed
refused = policy.check(DOOR_OPEN, "Jessica", 0.9)
assert not refused.allowed and refused.answer is not None and refused.answer.startswith("Désolé Jessica")
def test_action_missing_from_rules_needs_full_level(policy: AccessPolicy) -> None:
"""On ne permet jamais par oubli : sans règle, seul le niveau complet passe."""
shutter = DeviceCommand("open", "shutter", "salon")
assert not policy.check(shutter, "Jessica", 0.9).allowed
assert policy.check(shutter, "Denis", 0.8).allowed
def test_sensitive_action_asks_for_confirmation(policy: AccessPolicy) -> None:
verdict = policy.check(DOOR_OPEN, "Denis", 0.8)
assert verdict.allowed and verdict.confirm
assert verdict.answer == "Confirmez-vous : ouvrir la porte du salon ? Dites oui ou non."
def test_yes_from_the_same_person_executes(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Oui, vas-y.", "Denis")
assert resolution is not None and resolution.command == DOOR_OPEN
assert resolution.answer == "J'ouvre la porte du salon."
assert policy.resolve("esp32-01", "oui", "Denis") is None # une confirmation ne sert qu'une fois
def test_no_cancels(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Non, annule.", "Denis")
assert resolution is not None and resolution.command is None
def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Oui", "Jessica")
assert resolution is not None and resolution.command is None
assert resolution.answer.startswith("Seul Denis")
def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
clock.now += 16
assert policy.resolve("esp32-01", "oui", "Denis") is None
def test_other_request_drops_the_pending_one(policy: AccessPolicy) -> None:
"""Autre chose que oui/non : la demande en attente est oubliée, la nouvelle est traitée."""
policy.ask("esp32-01", DOOR_OPEN, "Denis")
assert policy.resolve("esp32-01", "Quelle heure est-il ?", "Denis") is None
assert policy.resolve("esp32-01", "oui", "Denis") is None
def test_project_config_is_valid() -> None:
"""Le config.yaml du dépôt se charge, et ses règles s'appliquent vraiment (profil standard)."""
settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
policy = AccessPolicy(settings.access, settings.home, settings.machines)
for action in ("on", "off"): # on/off nus en YAML = booléens : Pierre refusé à l'essai réel
assert policy.check(DeviceCommand(action, "light", "salon"), "Pierre", 0.8).allowed
assert policy.check(DeviceCommand("close", "shutter", "salon"), "Pierre", 0.8).allowed
assert not policy.check(DOOR_OPEN, "Pierre", 0.8).allowed
def test_yaml_boolean_keys_are_read_as_on_off(tmp_path: Path) -> None:
"""« light: {on: standard} » sans guillemets : YAML lit True, le chargeur rend « on »."""
text = (Path(__file__).resolve().parents[1] / "config.yaml").read_text(encoding="utf-8")
config = tmp_path / "config.yaml"
config.write_text(text.replace('{"on": standard, "off": standard}', "{on: standard, off: standard}"),
encoding="utf-8")
assert load_settings(config).access.rules["light"] == {"on": "standard", "off": "standard"}
def test_unknown_rule_action_is_rejected() -> None:
"""Une règle mal écrite est refusée au démarrage plutôt qu'ignorée en silence."""
access = AccessSettings(**{**ACCESS.__dict__, "rules": {"light": {"True": "standard"}}})
with pytest.raises(ValueError, match="action inconnue light.True"):
AccessPolicy(access, HOME)
def test_barely_recognised_full_profile_is_asked_to_repeat(policy: AccessPolicy) -> None:
"""Denis reconnu à 0,59 : son profil permettrait, c'est la voix qui n'est pas assez sûre."""
verdict = policy.check(DOOR_OPEN, "Denis", 0.59)
assert not verdict.allowed
assert verdict.answer == ("Denis, je ne reconnais pas assez nettement votre voix pour ouvrir "
"la porte du salon. Répétez, plus près du micro.")
def test_elision_in_refusal(policy: AccessPolicy) -> None:
"""« ne permet pas d'ouvrir », pas « de ouvrir »."""
answer = policy.check(DOOR_OPEN, "Jessica", 0.9).answer
assert answer == "Désolé Jessica, votre profil ne permet pas d'ouvrir la porte du salon."
close = policy.check(DeviceCommand("close", "door", "salon"), "Tom", 0.9).answer
assert close == "Désolé Tom, votre profil ne permet pas de fermer la porte du salon."
+83
View File
@@ -0,0 +1,83 @@
"""Tests de l'assistant à règles, étape 6 : réponses connues d'avance."""
from __future__ import annotations
from datetime import datetime
import pytest
from voice_server.assistant import NOTHING_HEARD, Assistant, normalize, say_date, say_time
NOW = datetime(2026, 9, 29, 19, 5) # un mardi
@pytest.fixture
def assistant() -> Assistant:
return Assistant(clock=lambda: NOW)
def test_normalize() -> None:
"""Minuscules, accents et ponctuation retirés."""
assert normalize(" Quelle heure est-il ?") == "quelle heure est il"
assert normalize("Ça marche, merci !") == "ca marche merci"
@pytest.mark.parametrize(
("moment", "expected"),
[
(datetime(2026, 1, 1, 0, 0), "Il est minuit."),
(datetime(2026, 1, 1, 1, 0), "Il est une heure."),
(datetime(2026, 1, 1, 12, 30), "Il est midi 30."),
(datetime(2026, 1, 1, 19, 5), "Il est 19 heures 5."),
],
)
def test_say_time(moment: datetime, expected: str) -> None:
"""L'heure est écrite pour être bien lue par Piper."""
assert say_time(moment) == expected
def test_say_date() -> None:
"""« premier » pour le 1er du mois, jour de la semaine en toutes lettres."""
assert say_date(datetime(2026, 10, 1)) == "Nous sommes le jeudi premier octobre 2026."
assert say_date(NOW) == "Nous sommes le mardi 29 septembre 2026."
@pytest.mark.parametrize(
("heard", "expected"),
[
("Quelle heure est-il ?", "Il est 19 heures 5."),
("Quel heure est-il ?", "Il est 19 heures 5."), # faute fréquente de Whisper
("Bonjour, quelle heure est-il ?", "Il est 19 heures 5."), # la règle précise gagne
("On est quel jour ?", "Nous sommes le mardi 29 septembre 2026."),
("Salut !", "Bonjour ! Que puis-je faire pour vous ?"),
("Merci beaucoup.", "Avec plaisir."),
("Allume la lumière.", "Vous avez dit : Allume la lumière."),
],
)
def test_rules(assistant: Assistant, heard: str, expected: str) -> None:
"""Chaque règle répond, sinon la phrase est répétée."""
assert assistant.reply(heard) == expected
def test_whole_words_only(assistant: Assistant) -> None:
"""« salutations » ne déclenche pas la règle de « salut »."""
assert assistant.reply("Salutations distinguées") == "Vous avez dit : Salutations distinguées"
@pytest.mark.parametrize("heard", ["", " ", "?!"])
def test_nothing_heard(assistant: Assistant, heard: str) -> None:
"""Rien d'exploitable : l'assistant le dit plutôt que de se taire."""
assert assistant.reply(heard) == NOTHING_HEARD
def test_known_speaker_is_greeted_by_name(assistant: Assistant) -> None:
"""Étape 10 : l'assistant sait qui parle."""
assert assistant.reply("Bonjour", "Denis") == "Bonjour Denis ! Que puis-je faire pour vous ?"
assert assistant.reply("Merci", "Denis") == "Avec plaisir, Denis."
assert assistant.reply("Bonjour", None) == "Bonjour ! Que puis-je faire pour vous ?"
assert assistant.reply("Quelle heure est-il ?", "Denis") == "Il est 19 heures 5."
def test_weather_is_honest(assistant: Assistant) -> None:
"""Étape 11 : sans Internet, l'assistant le dit plutôt que de répéter la question."""
assert "sans Internet" in assistant.reply("Quel temps fera-t-il demain ?")
+157
View File
@@ -0,0 +1,157 @@
"""Tests du chemin de sortie, étape 5 : mise au format et envoi rythmé (sans broker)."""
from __future__ import annotations
import json
import wave
from pathlib import Path
import numpy as np
import pytest
from voice_server.audio_resampler import AudioFormatError, AudioResampler, peak_dbfs
from voice_server.audio_sender import AudioSender, send_times
from voice_server.settings import AudioSettings
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
def write_wav(path: Path, samples: np.ndarray, rate: int, width: int) -> None:
"""Écrit un WAV PCM entier de largeur donnée (1 à 4 octets) à partir de flottants."""
channels = 1 if samples.ndim == 1 else samples.shape[1]
scale = {1: 127, 2: 32767, 3: 8388607, 4: 2147483647}[width]
ints = np.round(samples.reshape(-1) * scale).astype(np.int64)
if width == 1:
raw = (ints + 128).astype(np.uint8).tobytes()
else:
raw = b"".join(int(v).to_bytes(width, "little", signed=True) for v in ints)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(width)
wav.setframerate(rate)
wav.writeframes(raw)
def sine(rate: int, seconds: float, amplitude: float = 0.1) -> np.ndarray:
t = np.arange(int(rate * seconds)) / rate
return amplitude * np.sin(2 * np.pi * 440 * t)
@pytest.mark.parametrize("width", [1, 2, 3, 4])
def test_any_pcm_width_is_read(tmp_path: Path, width: int) -> None:
"""WAV 8, 16, 24 et 32 bits : lus, mis en 16 kHz, crête à -3 dBFS."""
path = tmp_path / f"w{width}.wav"
write_wav(path, sine(16000, 0.5), 16000, width)
pcm = AudioResampler(AUDIO).from_wav(path)
assert len(pcm) == 16000 # 0,5 s x 16 000 x 2 octets
assert peak_dbfs(pcm) == pytest.approx(-3.0, abs=0.1)
def test_cd_stereo_is_converted_to_system_format(tmp_path: Path) -> None:
"""44,1 kHz stéréo -> 16 kHz mono ; durée conservée."""
path = tmp_path / "cd.wav"
stereo = np.stack([sine(44100, 1.0), sine(44100, 1.0)], axis=1)
write_wav(path, stereo, 44100, 2)
pcm = AudioResampler(AUDIO).from_wav(path)
assert len(pcm) // 2 == pytest.approx(16000, abs=2)
def test_piper_rate_is_resampled(tmp_path: Path) -> None:
"""22 050 Hz (sortie habituelle de Piper, étape 6) -> 16 kHz, fréquence conservée."""
pcm = AudioResampler(AUDIO).convert(sine(22050, 1.0), 22050)
samples = np.frombuffer(pcm, dtype="<i2").astype(np.float64)
spectrum = np.abs(np.fft.rfft(samples))
dominant = np.fft.rfftfreq(samples.size, 1 / 16000)[int(np.argmax(spectrum))]
assert dominant == pytest.approx(440.0, abs=2.0)
def test_normalization_can_be_disabled() -> None:
"""Sans normalisation, le niveau d'origine est gardé (-20 dBFS ici)."""
pcm = AudioResampler(AUDIO, target_peak_dbfs=None).convert(sine(16000, 0.5, 0.1), 16000)
assert peak_dbfs(pcm) == pytest.approx(-20.0, abs=0.1)
def test_unreadable_file_suggests_conversion(tmp_path: Path) -> None:
"""Un fichier qui n'est pas un WAV PCM : message avec la commande ffmpeg."""
path = tmp_path / "faux.wav"
path.write_bytes(b"RIFF....WAVEfmt pas vraiment")
with pytest.raises(AudioFormatError, match="ffmpeg"):
AudioResampler(AUDIO).from_wav(path)
class FakeLink:
"""Lien MQTT factice : mémorise les publications et l'instant de chacune."""
def __init__(self, clock: "FakeTime") -> None:
self.clock = clock
self.sent: list[tuple[float, str, bytes, int]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.sent.append((self.clock.now, suffix, payload, qos))
class FakeTime:
"""Horloge et sommeil simulés : le test dure 0 s quelle que soit la durée du son."""
def __init__(self) -> None:
self.now = 0.0
def clock(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.now += seconds
def test_sender_protocol_and_pacing() -> None:
"""START (QoS 1), 10 chunks (QoS 0) dont 6 d'avance, END annonçant 10 chunks."""
time = FakeTime()
link = FakeLink(time)
sender = AudioSender(link, AUDIO, prefill_chunks=6, sleep=time.sleep, clock=time.clock)
session = sender.send("esp32-01", bytes(1600 * 9 + 801)) # 9 chunks pleins + 1 partiel
kinds = [(suffix, qos) for _, suffix, _, qos in link.sent]
assert kinds[0] == ("control", 1) and kinds[-1] == ("control", 1)
audio = [(t, payload) for t, suffix, payload, _ in link.sent if suffix == "audio/out"]
assert [len(p) for _, p in audio] == [1600] * 9 + [800] # l'octet orphelin est retiré
assert [t for t, _ in audio[:6]] == [0.0] * 6 # l'avance part d'un coup
assert [round(t, 3) for t, _ in audio[6:]] == [0.05, 0.1, 0.15, 0.2]
start = json.loads(link.sent[0][2])
end = json.loads(link.sent[-1][2])
assert (start["event"], start["rate"], start["session"]) == ("start", 16000, session)
assert (end["event"], end["chunks"], end["session"]) == ("end", 10, session)
def test_send_times_matches_serial_tool() -> None:
"""Même calendrier que l'outil série de l'étape 2."""
assert send_times(5, 0.05, 2) == pytest.approx([0.0, 0.0, 0.05, 0.10, 0.15])
def test_jitter_bench_holds_one_chunk_in_ten() -> None:
"""Étape 12 : le banc retient un chunk audio sur dix, jamais START/END."""
from wav_to_mqtt import JitteryLink
class Sink:
def __init__(self) -> None:
self.topics: list[str] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.topics.append(suffix)
sink = Sink()
bench = JitteryLink(sink, stall_ms=1.0)
bench.publish("esp32-01", "control", b"{}", 1)
for _ in range(25):
bench.publish("esp32-01", "audio/out", bytes(1600), 0)
assert bench.stalls == 2 # chunks 10 et 20
assert len(sink.topics) == 26 # rien n'est perdu, seulement retardé
+75
View File
@@ -0,0 +1,75 @@
"""Tests de la diarisation, étape 8 : présentation, télémétrie, modèle réel s'il est présent."""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import pytest
from voice_server.diarization import Diarization, Diarizer, SpeakerTurn, disable_telemetry, timeline
from voice_server.settings import DiarizationSettings, load_settings
SETTINGS = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
def two_speakers() -> Diarization:
return Diarization(
turns=(SpeakerTurn("SPEAKER_00", 0.0, 0.5), SpeakerTurn("SPEAKER_01", 0.6, 1.0)),
speakers=("SPEAKER_00", "SPEAKER_01"),
embeddings=None, audio_s=1.0, elapsed_s=0.2,
)
def test_describe() -> None:
assert two_speakers().describe() == "SPEAKER_00 0.00-0.50, SPEAKER_01 0.60-1.00"
def test_timeline_one_row_per_speaker() -> None:
assert timeline(two_speakers()) == ["SPEAKER_00 #####_____", "SPEAKER_01 ______####"]
def test_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""pyannote 4 envoie des statistiques par défaut : jamais dans ce projet."""
monkeypatch.setenv("PYANNOTE_METRICS_ENABLED", "true")
disable_telemetry()
assert os.environ["PYANNOTE_METRICS_ENABLED"] == "false"
def test_missing_model_says_what_to_do(tmp_path: Path) -> None:
settings = DiarizationSettings(enabled=True, model="x/y", max_speakers=3, model_dir=tmp_path / "y")
with pytest.raises(FileNotFoundError, match="make models"):
Diarizer(settings, 16000)
@pytest.mark.skipif(not (SETTINGS.diarization.model_dir / "config.yaml").exists(),
reason="modele de diarisation absent : HF_TOKEN puis 'make models'")
def test_real_pipeline_runs_offline_without_telemetry() -> None:
"""Le vrai pipeline se charge du dossier local et ne trouve personne dans le silence."""
pytest.importorskip("pyannote.audio")
diarizer = Diarizer(SETTINGS.diarization, 16000)
result = diarizer.diarize(np.zeros(32000, dtype="<i2").tobytes())
assert result.turns == ()
assert os.environ["PYANNOTE_METRICS_ENABLED"] == "false"
@pytest.mark.skipif(not (SETTINGS.diarization.model_dir / "config.yaml").exists(),
reason="modele de diarisation absent : HF_TOKEN puis 'make models'")
def test_embeddings_exist_for_a_short_question() -> None:
"""Une question de 2 s a une empreinte (celles de pyannote seraient NaN sous 2 s de parole)."""
pytest.importorskip("pyannote.audio")
from voice_server.audio_resampler import AudioResampler
from voice_server.text_to_speech import TextToSpeech
tts = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio, -10.0), 16000)
diarizer = Diarizer(SETTINGS.diarization, 16000)
first = diarizer.diarize(tts.synthesize("Quelle heure est-il ?").pcm)
second = diarizer.embed(tts.synthesize("Fermez la porte du garage.").pcm)
assert first.embeddings is not None and first.embeddings.shape == (1, 256)
assert np.all(np.isfinite(first.embeddings))
cosine = float(first.embeddings[0] @ second / np.linalg.norm(first.embeddings[0]) / np.linalg.norm(second))
assert cosine > 0.45 # même voix Piper, deux phrases
+203
View File
@@ -0,0 +1,203 @@
"""Tests de l'écoute mains libres, étape 13 : états, vie privée, fin de commande (sans modèle)."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from voice_server.hands_free import HandsFreeListener, Mode
from voice_server.session_manager import SessionResult
from voice_server.settings import AudioSettings, WakeSettings
from voice_server.voice_activity import Segment, VadResult
from voice_server.wake_word import Detection
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
WAKE = WakeSettings(enabled=True, words=("alexa",), threshold=0.5, thresholds={}, preroll_ms=500, end_silence_ms=800,
no_speech_timeout_s=4.0, max_command_s=10.0, cooldown_s=20.0, model_dir=Path("inutile"))
SILENCE = bytes(1600)
SPEECH = np.full(800, 3000, dtype="<i2").tobytes()
WAKE_WORD = np.full(800, 3001, dtype="<i2").tobytes() # le faux détecteur réagit à cette valeur
class FakeDetector:
def __init__(self) -> None:
self.resets = 0
def process(self, pcm: bytes) -> Detection | None:
return Detection("alexa", 0.9) if pcm == WAKE_WORD else None
def reset(self) -> None:
self.resets += 1
class ChunkVad:
"""VAD de test : un chunk non nul est de la parole."""
def detect(self, pcm: bytes) -> VadResult:
samples = np.frombuffer(pcm, dtype="<i2")
segments: list[Segment] = []
for start in range(0, samples.size, 800):
if np.any(samples[start:start + 800]):
if segments and segments[-1].end == start:
segments[-1] = Segment(segments[-1].start, start + 800)
else:
segments.append(Segment(start, start + 800))
return VadResult(segments=tuple(segments), speech_pcm=b"", probabilities=np.zeros(0),
total_s=samples.size / 16000, speech_s=0.0, elapsed_s=0.0, sample_rate=16000)
class FakeLink:
def __init__(self) -> None:
self.controls: list[dict[str, object]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
assert suffix == "control"
self.controls.append(json.loads(payload))
class Clock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
class Harness:
def __init__(self, tmp_path: Path, clock: Clock) -> None:
self.link = FakeLink()
self.commands: list[SessionResult] = []
self.detectors: list[FakeDetector] = []
self.clock = clock
self.recordings = tmp_path / "recordings"
def factory(device: str) -> FakeDetector:
self.detectors.append(FakeDetector())
return self.detectors[-1]
self.listener = HandsFreeListener(WAKE, AUDIO, ChunkVad(), self.recordings, self.link,
self.commands.append, factory, clock=clock)
def feed(self, chunk: bytes, count: int = 1) -> None:
for _ in range(count):
self.listener.on_stream("esp32-01", chunk)
self.clock.now += 0.05 # un chunk = 50 ms
@pytest.fixture
def h(tmp_path: Path, clock: Clock) -> Harness:
return Harness(tmp_path, clock)
def test_standby_never_writes_anything(h: Harness) -> None:
"""Vie privée : sans mot de réveil, rien sur disque, rien transmis, mémoire bornée à 500 ms."""
h.feed(SPEECH, 200) # 10 s de conversation sans le mot
assert h.commands == []
assert not h.recordings.exists()
assert h.listener.mode("esp32-01") is Mode.STANDBY
def test_wake_then_command_becomes_a_session(h: Harness) -> None:
"""Mot, commande, silence : une session, avec les 500 ms qui précèdent le mot."""
h.feed(SILENCE, 20)
h.feed(WAKE_WORD)
h.feed(SPEECH, 30) # 1,5 s de commande
h.feed(SILENCE, 25) # 1,25 s de silence : > 800 ms
[result] = h.commands
assert result.session_id.startswith("w")
assert result.path.exists()
assert 2.0 < result.duration_s < 3.5
assert h.link.controls == [{"cmd": "capture", "active": True}, {"cmd": "capture", "active": False}]
assert h.listener.mode("esp32-01") is Mode.ANSWER
def test_wake_without_command_goes_back_to_standby(h: Harness) -> None:
"""Mot seul : abandon après 4 s, rien écrit."""
h.feed(WAKE_WORD)
h.feed(SILENCE, 90) # 4,5 s
assert h.commands == [] and not h.recordings.exists()
assert h.listener.mode("esp32-01") is Mode.STANDBY
assert h.link.controls[-1] == {"cmd": "capture", "active": False}
def test_command_is_cut_at_max_duration(h: Harness) -> None:
h.feed(WAKE_WORD)
h.feed(SPEECH, 220) # 11 s sans silence
assert len(h.commands) == 1
def test_answer_is_not_heard_and_played_resumes(h: Harness) -> None:
"""Pendant la réponse, le micro est ignoré ; le bilan « played » relance la veille."""
h.feed(WAKE_WORD)
h.feed(SPEECH, 10)
h.feed(SILENCE, 20)
h.feed(WAKE_WORD, 5) # la carte « s'entend » : ignoré
assert len(h.commands) == 1
h.listener.on_event("esp32-01", b'{"event":"played","session":"rep-w1"}')
assert h.listener.mode("esp32-01") is Mode.STANDBY
assert h.detectors[0].resets == 1 # historique du mot précédent effacé
def test_no_played_report_times_out(h: Harness) -> None:
h.feed(WAKE_WORD)
h.feed(SPEECH, 10)
h.feed(SILENCE, 20)
h.clock.now += 21
h.listener.sweep()
assert h.listener.mode("esp32-01") is Mode.STANDBY
def test_listen_is_sent_once_per_boot(h: Harness) -> None:
"""Écoute activée à la connexion et après un redémarrage, pas à chaque état périodique."""
state = '{{"status":"online","uptime_s":{}}}'
h.listener.on_state("esp32-01", state.format(5).encode())
h.listener.on_state("esp32-01", state.format(35).encode()) # état périodique
h.listener.on_state("esp32-01", state.format(3).encode()) # redémarrage
assert h.link.controls == [{"cmd": "listen", "enabled": True}] * 2
h.listener.stop()
assert h.link.controls[-1] == {"cmd": "listen", "enabled": False}
def test_each_board_has_its_own_detector(h: Harness) -> None:
"""Deux cartes : deux historiques, jamais mélangés."""
h.listener.on_stream("esp32-01", SILENCE)
h.listener.on_stream("esp32-02", SILENCE)
assert len(h.detectors) == 2
def test_burst_delivery_is_judged_on_audio_time(h: Harness) -> None:
"""Wi-Fi en rafale : 2,5 s de son arrivées en même temps donnent la même décision."""
h.listener.on_stream("esp32-01", WAKE_WORD)
for chunk in [SPEECH] * 30 + [SILENCE] * 25: # aucun temps ne s'écoule à l'horloge
h.listener.on_stream("esp32-01", chunk)
assert len(h.commands) == 1
def test_stream_lost_during_command_is_closed(h: Harness) -> None:
"""La carte décroche en pleine commande : on traite ce qu'on a après 2 s sans chunk."""
h.feed(WAKE_WORD)
h.feed(SPEECH, 10)
h.clock.now += 3
h.listener.sweep()
assert len(h.commands) == 1
+108
View File
@@ -0,0 +1,108 @@
"""Tests de la domotique, étape 11 : phrase → intention → commande → topics (sans broker)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from voice_server.assistant import Assistant
from voice_server.home_control import DeviceCommand, DeviceIntent, HomeController, decide, parse_intent, slug
from voice_server.settings import HomeSettings, load_settings
HOME = HomeSettings(topic_prefix="home",
rooms={"salon": "du salon", "cuisine": "de la cuisine", "salle de bain": "de la salle de bain"},
boards={"esp32-01": "salon"})
ROOMS = list(HOME.rooms)
@pytest.mark.parametrize(
("text", "expected"),
[
("Fermez les volets du salon.", DeviceIntent("close", "shutter", "salon")),
("fermé la porte", DeviceIntent("close", "door", None)), # faute de Whisper
("Peux-tu allumer la lumière de la cuisine ?", DeviceIntent("on", "light", "cuisine")),
("Éteins les lampes", DeviceIntent("off", "light", None)),
("Baisse les stores de la salle de bain", DeviceIntent("close", "shutter", "salle de bain")),
("Quelle heure est-il ?", None),
],
)
def test_parse_intent(text: str, expected: DeviceIntent | None) -> None:
assert parse_intent(text, ROOMS) == expected
@pytest.mark.parametrize(
("intent", "answer", "command"),
[
(DeviceIntent("close", "shutter", "cuisine"), "Je ferme les volets de la cuisine.",
DeviceCommand("close", "shutter", "cuisine")),
(DeviceIntent("on", None, None), "J'allume la lumière du salon.", # « allume » : lumière, ici
DeviceCommand("on", "light", "salon")),
(DeviceIntent("open", None, None), "Que dois-je ouvrir ?", None),
(DeviceIntent(None, "door", None), "Que dois-je faire avec la porte ?", None),
(DeviceIntent("on", "shutter", None), "Je ne sais pas allumer les volets.", None),
],
)
def test_decide(intent: DeviceIntent, answer: str, command: DeviceCommand | None) -> None:
decision = decide(intent, HOME, "esp32-01")
assert (decision.answer, decision.command) == (answer, command)
def test_unknown_board_needs_a_room() -> None:
"""Une carte sans pièce déclarée ne devine pas : elle demande."""
assert decide(DeviceIntent("on", "light", None), HOME, "esp32-99").answer == "Dans quelle pièce ?"
def test_slug() -> None:
assert slug("salle de bain") == "salle-de-bain"
assert slug("entrée") == "entree"
class FakeLink:
def __init__(self) -> None:
self.sent: list[tuple[str, bytes, int]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.sent.append((f"voice/{device}/{suffix}", payload, qos))
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
self.sent.append((topic, payload, qos))
def test_command_for_the_board_room_also_goes_to_the_board() -> None:
"""Pièce de la carte : domotique ET carte (LED de démonstration)."""
link = FakeLink()
HomeController(HOME, link).execute("esp32-01", DeviceCommand("on", "light", "salon"), "Denis", "a1b2c3")
[(topic, payload, qos), (local_topic, local, local_qos)] = link.sent
assert (topic, qos) == ("home/salon/light/set", 1)
assert json.loads(payload) == {"action": "on", "by": "Denis", "board": "esp32-01", "session": "a1b2c3"}
assert (local_topic, local_qos) == ("voice/esp32-01/control", 1)
assert json.loads(local) == {"cmd": "device", "device": "light", "action": "on"}
def test_command_for_another_room_only_goes_to_home() -> None:
link = FakeLink()
HomeController(HOME, link).execute("esp32-01", DeviceCommand("close", "shutter", "salle de bain"), None, "s")
assert [topic for topic, _, _ in link.sent] == ["home/salle-de-bain/shutter/set"]
assert json.loads(link.sent[0][1])["by"] is None
def test_assistant_prefers_home_commands_then_chat() -> None:
"""Domotique d'abord ; le reste suit les règles de conversation."""
assistant = Assistant(home=HOME)
order = assistant.respond("Allume la lumière", "Denis", "esp32-01")
chat = assistant.respond("Bonjour", "Denis", "esp32-01")
assert order.command == DeviceCommand("on", "light", "salon") and order.text == "J'allume la lumière du salon."
assert chat.command is None and chat.text.startswith("Bonjour Denis")
assert Assistant().respond("Allume la lumière").command is None # sans config : pas de domotique
def test_project_config_rooms_and_boards() -> None:
"""Le config.yaml du dépôt déclare la pièce de la carte parmi les pièces connues."""
home = load_settings(Path(__file__).resolve().parents[1] / "config.yaml").home
assert home.boards["esp32-01"] in home.rooms
+186
View File
@@ -0,0 +1,186 @@
"""Tests des machines Linux, étape 15b : phrases, décisions, droits, Wake-on-LAN (sans réseau)."""
from __future__ import annotations
import dataclasses
import json
from datetime import datetime
from pathlib import Path
import paho.mqtt.client as mqtt
import pytest
from voice_server.access_control import AccessPolicy
from voice_server.assistant import Assistant
from voice_server.command_router import CommandRouter
from voice_server.home_control import DeviceCommand, HomeController
from voice_server.machine_control import (
AgentStatus,
MachineCommand,
MachineController,
MachineIntent,
decide_machine,
magic_packet,
parse_machine_intent,
)
from voice_server.mqtt_link import MqttLink
from voice_server.settings import MachinesSettings, load_settings
SETTINGS = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
MACHINES = SETTINGS.machines
ONLINE = AgentStatus(online=True, actions=("lock", "reboot", "shutdown"))
ONLINE_PAYLOAD = json.dumps({"status": "online", "actions": ["lock", "reboot", "shutdown"]}).encode()
MAC = "aa:bb:cc:dd:ee:ff"
class FakeLink:
def __init__(self) -> None:
self.topics: list[tuple[str, dict[str, object]]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.topics.append((f"voice/{device}/{suffix}", json.loads(payload)))
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
self.topics.append((topic, json.loads(payload)))
def with_mac(mac: str = MAC) -> MachinesSettings:
"""Machines de config.yaml, avec une adresse MAC : le réveil devient possible."""
machine = dataclasses.replace(MACHINES.machines["pc-bureau"], mac=mac)
return dataclasses.replace(MACHINES, machines={"pc-bureau": machine})
@pytest.mark.parametrize(
("text", "action"),
[
("Éteins le PC du bureau.", "shutdown"),
("Éteins le P.C. du bureau.", "shutdown"),
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
("Verrouille le PC du bureau", "lock"),
("Allume le PC du bureau", "wake"),
("Le PC du bureau", None),
],
)
def test_phrases_naming_a_machine(text: str, action: str | None) -> None:
assert parse_machine_intent(text, MACHINES) == MachineIntent("pc-bureau", action)
def test_room_alone_is_not_a_machine() -> None:
"""« bureau » est une pièce : seule « PC du bureau » désigne la machine."""
assert parse_machine_intent("Éteins la lumière du bureau", MACHINES) is None
def test_command_carries_its_sentences() -> None:
decision = decide_machine(MachineIntent("pc-bureau", "shutdown"), MACHINES, ONLINE)
assert decision.command == MachineCommand("shutdown", "pc-bureau", said="J'éteins le PC du bureau.",
asked="éteindre le PC du bureau")
assert decision.answer == "J'éteins le PC du bureau."
@pytest.mark.parametrize(
("action", "status", "answer"),
[
(None, ONLINE, "Que dois-je faire avec le PC du bureau ?"),
("shutdown", None, "Le PC du bureau ne répond pas : la machine est éteinte, ou son agent est arrêté."),
("shutdown", AgentStatus(False, ()), "Le PC du bureau ne répond pas : la machine est éteinte, "
"ou son agent est arrêté."),
("lock", AgentStatus(True, ("shutdown",)), "Je ne peux pas verrouiller le PC du bureau : "
"son agent ne le permet pas."),
("wake", ONLINE, "Le PC du bureau est déjà en marche."),
("wake", None, "Je ne sais pas allumer le PC du bureau à distance."), # pas d'adresse MAC
],
)
def test_impossible_requests_are_explained(action: str | None, status: AgentStatus | None, answer: str) -> None:
decision = decide_machine(MachineIntent("pc-bureau", action), MACHINES, status)
assert decision.command is None and decision.answer == answer
def test_wake_needs_the_machine_off_and_a_mac() -> None:
decision = decide_machine(MachineIntent("pc-bureau", "wake"), with_mac(), None)
assert decision.command is not None and decision.answer == "J'allume le PC du bureau."
def test_magic_packet() -> None:
packet = magic_packet("AA-BB-CC-DD-EE-FF")
assert len(packet) == 102 and packet[:6] == b"\xff" * 6
assert packet[6:12] == bytes.fromhex("aabbccddeeff") and packet[6:] == packet[6:12] * 16
with pytest.raises(ValueError, match="adresse MAC invalide"):
magic_packet("aa:bb:cc")
def test_controller_follows_agent_states_and_publishes_commands() -> None:
link = FakeLink()
controller = MachineController(MACHINES, link)
unknown = controller.interpret("Éteins le PC du bureau")
assert unknown is not None and unknown.command is None # agent jamais annoncé
controller.on_state("agent/pc-bureau/state", ONLINE_PAYLOAD)
decision = controller.interpret("Éteins le PC du bureau")
assert decision is not None and decision.command is not None
assert controller.execute("esp32-01", decision.command, "Denis", "w123") == ["agent/pc-bureau/command"]
assert link.topics == [("agent/pc-bureau/command",
{"action": "shutdown", "id": "w123", "by": "Denis", "board": "esp32-01"})]
controller.on_state("agent/pc-bureau/state", b'{"status": "offline"}')
offline = controller.interpret("Éteins le PC du bureau")
assert offline is not None and offline.command is None
def test_wake_sends_the_magic_packet_not_a_message() -> None:
sent: list[tuple[bytes, str]] = []
link = FakeLink()
controller = MachineController(with_mac(), link, send_udp=lambda packet, address: sent.append((packet, address)))
command = MachineCommand("wake", "pc-bureau", said="J'allume le PC du bureau.", asked="allumer le PC du bureau")
controller.execute("esp32-01", command, "Denis", "w1")
assert sent == [(magic_packet(MAC), "255.255.255.255")] and link.topics == []
def test_machine_is_matched_before_home() -> None:
"""« Éteins le PC du bureau » ne doit pas éteindre la lumière du bureau."""
controller = MachineController(MACHINES, FakeLink())
controller.on_state("agent/pc-bureau/state", ONLINE_PAYLOAD)
assistant = Assistant(clock=lambda: datetime(2026, 9, 30, 12), home=SETTINGS.home, machines=controller)
assert isinstance(assistant.respond("Éteins le PC du bureau", "Denis", "esp32-01").command, MachineCommand)
home = assistant.respond("Éteins la lumière du bureau", "Denis", "esp32-01").command
assert home == DeviceCommand("off", "light", "bureau")
def test_rights_and_confirmation_for_machines() -> None:
"""config.yaml : verrouiller = standard ; éteindre = complet + confirmation."""
policy = AccessPolicy(SETTINGS.access, SETTINGS.home, MACHINES)
lock = decide_machine(MachineIntent("pc-bureau", "lock"), MACHINES, ONLINE).command
shutdown = decide_machine(MachineIntent("pc-bureau", "shutdown"), MACHINES, ONLINE).command
assert lock is not None and shutdown is not None
assert policy.check(lock, "Pierre", 0.8).allowed
refused = policy.check(shutdown, "Pierre", 0.8)
assert not refused.allowed and refused.answer == ("Désolé Pierre, votre profil ne permet pas "
"d'éteindre le PC du bureau.")
verdict = policy.check(shutdown, "Denis", 0.8)
assert verdict.confirm and verdict.answer == "Confirmez-vous : éteindre le PC du bureau ? Dites oui ou non."
policy.ask("esp32-01", shutdown, "Denis")
resolution = policy.resolve("esp32-01", "oui", "Denis")
assert resolution is not None and resolution.command == shutdown
assert resolution.answer == "J'éteins le PC du bureau."
def test_router_sends_each_command_to_its_controller() -> None:
link = FakeLink()
router = CommandRouter(HomeController(SETTINGS.home, link), MachineController(MACHINES, link))
machine = MachineCommand("lock", "pc-bureau", said="", asked="")
assert router.execute("esp32-01", machine, None, "s") == ["agent/pc-bureau/command"]
assert router.execute("esp32-01", DeviceCommand("on", "light", "cuisine"), None, "s") == \
["home/cuisine/light/set"]
def test_mqtt_link_routes_agent_topics() -> None:
"""Les états des agents (agent/+/state) arrivent au bon rappel, pas à ceux des cartes."""
link = MqttLink(SETTINGS.mqtt)
received: list[str] = []
link.on("state", lambda device, payload: received.append(f"carte {device}"))
link.on_topic("agent/+/state", lambda topic, payload: received.append(topic))
for topic in ("agent/pc-bureau/state", "voice/esp32-01/state", "agent/pc-bureau/result"):
message = mqtt.MQTTMessage(topic=topic.encode())
message.payload = b"{}"
link._on_message(link._client, None, message) # pyright: ignore[reportPrivateUsage]
assert received == ["agent/pc-bureau/state", "carte esp32-01"]
+69
View File
@@ -0,0 +1,69 @@
"""Tests de la liaison MQTT du serveur, étape 15 : présence, refus du broker, journal fichier."""
from __future__ import annotations
import dataclasses
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
from voice_server.__main__ import setup_logging
from voice_server.mqtt_link import presence_topic, refusal_hint, split_topic
from voice_server.settings import load_settings
CONFIG = Path(__file__).resolve().parents[1] / "config.yaml"
def test_presence_topic_is_not_taken_for_a_board() -> None:
"""Le serveur s'abonne à voice/+/state, event... : sa présence ne doit pas y tomber."""
topic = presence_topic("voice")
assert topic == "voice/server/status"
parsed = split_topic(topic, "voice")
assert parsed is not None and parsed[1] not in ("state", "event", "audio/in", "audio/stream")
def test_refused_credentials_point_to_the_fix() -> None:
hint = refusal_hint(ReasonCode(PacketTypes.CONNACK, "Not authorized"), "voice-server")
assert "server/.env" in hint and "make mqtt-user NAME=voice-server" in hint
bad_password = refusal_hint(ReasonCode(PacketTypes.CONNACK, "Bad user name or password"), "voice-server")
assert "server/.env" in bad_password
def test_other_refusal_names_the_client() -> None:
hint = refusal_hint(ReasonCode(PacketTypes.CONNACK, "Server unavailable"), "voice-server")
assert "voice-server" in hint and "server/.env" not in hint
@pytest.fixture
def restore_logging() -> Iterator[None]:
"""setup_logging remplace la configuration globale : on remet celle de pytest après."""
root = logging.getLogger()
saved = (root.level, list(root.handlers))
yield
for handler in root.handlers:
if handler not in saved[1]:
handler.close()
root.handlers[:] = saved[1]
root.setLevel(saved[0])
def test_log_file_receives_the_journal(tmp_path: Path, restore_logging: None) -> None:
settings = dataclasses.replace(load_settings(CONFIG), log_file=tmp_path / "logs" / "voice-server.log")
setup_logging(settings)
logging.getLogger("voice_server").info("essai du journal")
for handler in logging.getLogger().handlers:
handler.flush()
assert settings.log_file is not None
assert "essai du journal" in settings.log_file.read_text(encoding="utf-8")
def test_log_file_is_optional(tmp_path: Path) -> None:
text = CONFIG.read_text(encoding="utf-8").replace("file: logs/voice-server.log", "file:")
config = tmp_path / "config.yaml"
config.write_text(text, encoding="utf-8")
assert load_settings(config).log_file is None
assert load_settings(CONFIG).log_file == (CONFIG.parent / "logs" / "voice-server.log").resolve()
+178
View File
@@ -0,0 +1,178 @@
"""Tests du décodeur de trames série utilisé à l'étape 1.
Ces tests ne nécessitent aucun matériel : un faux port série rejoue des octets.
"""
from __future__ import annotations
import struct
import sys
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from serial_protocol import ( # noqa: E402
AudioFormat,
Frame,
FrameReader,
FrameType,
encode_frame,
)
from serial_to_wav import measure_levels, suggested_gain_factor # noqa: E402
from wav_inspect import column_peaks, render, speech_ratio # noqa: E402
class FakeSerial:
"""Faux port série : restitue une suite d'octets par petits morceaux."""
def __init__(self, data: bytes, block_size: int = 7) -> None:
self._data = data
self._block_size = block_size
self._position = 0
def read(self, size: int = 1) -> bytes:
"""Retourne au plus block_size octets, puis des chaînes vides."""
count = min(size, self._block_size, len(self._data) - self._position)
if count <= 0:
return b""
chunk = self._data[self._position : self._position + count]
self._position += count
return chunk
def build_frame(frame_type: FrameType, payload: bytes) -> bytes:
"""Construit une trame avec l'encodeur partagé, celui qu'utilise wav_to_serial."""
return encode_frame(frame_type, payload)
def descriptor(sample_rate: int = 16000, bits: int = 16, channels: int = 1) -> bytes:
"""Construit le descripteur de 8 octets de la trame START."""
return (
sample_rate.to_bytes(4, "little")
+ bits.to_bytes(2, "little")
+ channels.to_bytes(2, "little")
)
def collect(data: bytes, expected: int) -> list[Frame]:
"""Décode le flux et retourne les `expected` premières trames."""
reader = FrameReader(FakeSerial(data))
frames: list[Frame] = []
for frame in reader.frames():
frames.append(frame)
if len(frames) == expected:
break
return frames
def test_decodes_a_full_session() -> None:
"""START + deux chunks + END sont décodés dans l'ordre."""
pcm = bytes(range(0, 200)) * 8 # 1600 octets
stream = (
build_frame(FrameType.START, descriptor())
+ build_frame(FrameType.AUDIO, pcm)
+ build_frame(FrameType.AUDIO, pcm)
+ build_frame(FrameType.END, b"")
)
frames = collect(stream, 4)
assert [f.type for f in frames] == [
FrameType.START,
FrameType.AUDIO,
FrameType.AUDIO,
FrameType.END,
]
assert frames[1].payload == pcm
assert AudioFormat.from_payload(frames[0].payload) == AudioFormat(16000, 16, 1)
def test_skips_interleaved_text() -> None:
"""Les messages texte de l'ESP32 n'empêchent pas le décodage."""
stream = (
b"# micro pret\n"
+ build_frame(FrameType.START, descriptor())
+ b"\n# session terminee\n"
+ build_frame(FrameType.END, b"")
)
frames = collect(stream, 2)
assert [f.type for f in frames] == [FrameType.START, FrameType.END]
def test_rejects_bad_checksum() -> None:
"""Une trame corrompue est ignorée, la suivante est retrouvée."""
broken = bytearray(build_frame(FrameType.AUDIO, b"\x01\x02\x03\x04"))
broken[-1] ^= 0xFF # checksum faux
stream = bytes(broken) + build_frame(FrameType.END, b"")
frames = collect(stream, 1)
assert frames[0].type is FrameType.END
def test_measure_levels_on_silence() -> None:
"""Un flux nul est signalé comme absence totale de signal."""
level = measure_levels(b"\x00\x00" * 1000)
assert level.peak == 0.0
assert level.rms == 0.0
def test_measure_levels_on_known_amplitude() -> None:
"""Un carré à la moitié de l'échelle donne crête et RMS à 50 %."""
pcm = (struct.pack("<h", 16384) + struct.pack("<h", -16384)) * 500
level = measure_levels(pcm)
assert abs(level.peak - 0.5) < 0.001
assert abs(level.rms - 0.5) < 0.001
assert abs(level.dc) < 0.001
def test_suggested_gain_factor() -> None:
"""Le facteur conseillé est une puissance de 2 qui vise 25 % de crête."""
assert suggested_gain_factor(0.015) == 16
assert suggested_gain_factor(0.12) == 2
assert suggested_gain_factor(0.0) == 0
def test_envelope_follows_amplitude() -> None:
"""L'enveloppe dessine un creux entre deux bouffées de signal."""
loud = struct.pack("<h", 16000) * 1000
quiet = struct.pack("<h", 0) * 1000
peaks = column_peaks(loud + quiet + loud, columns=3)
assert peaks[0] > 0.4 and peaks[2] > 0.4
assert peaks[1] == 0.0
drawing = render(peaks)
assert drawing[0] == drawing[2]
assert drawing[1] != drawing[0]
def test_speech_ratio_detects_alternation() -> None:
"""Un signal constant n'est pas de la parole, une alternance oui."""
steady = struct.pack("<h", 8000) * 3000
assert speech_ratio(column_peaks(steady, columns=6)) == 0.0
burst = struct.pack("<h", 0) * 2000 + struct.pack("<h", 8000) * 1000
assert speech_ratio(column_peaks(burst, columns=6)) > 0.0
def test_audio_format_round_trip() -> None:
"""Le descripteur START s'encode et se décode sans perte."""
fmt = AudioFormat(16000, 16, 1)
assert AudioFormat.from_payload(fmt.to_payload()) == fmt
def test_encode_frame_rejects_oversized_payload() -> None:
"""Un payload plus grand que ce que l'ESP32 accepte est refusé dès l'envoi."""
try:
encode_frame(FrameType.AUDIO, bytes(4096))
except ValueError:
return
raise AssertionError("un payload de 4096 octets aurait du etre refuse")
+190
View File
@@ -0,0 +1,190 @@
"""Tests du serveur, étape 4 : reconstitution des sessions (sans broker)."""
from __future__ import annotations
import json
import wave
from pathlib import Path
import pytest
from voice_server.mqtt_link import split_topic
from voice_server.session_manager import Outcome, SessionManager
from voice_server.settings import AudioSettings, SessionSettings, load_settings
AUDIO = AudioSettings(sample_rate=16000, bits=16, channels=1, codec="pcm_s16le", chunk_ms=50)
SESSIONS = SessionSettings(max_seconds=30.0, idle_timeout_s=5.0, end_grace_s=0.5)
CHUNK = bytes(1600)
class FakeClock:
"""Horloge pilotée par le test : on avance le temps sans attendre."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def start_event(session: str = "a1b2c3", rate: int = 16000) -> bytes:
return json.dumps({"event": "start", "session": session, "rate": rate, "bits": 16,
"channels": 1, "codec": "pcm_s16le", "chunk_ms": 50}).encode()
def end_event(chunks: int, session: str = "a1b2c3") -> bytes:
return json.dumps({"event": "end", "session": session, "chunks": chunks,
"duration_ms": chunks * 50, "send_failures": 0,
"capture_overruns": 0}).encode()
@pytest.fixture
def clock() -> FakeClock:
return FakeClock()
@pytest.fixture
def manager(tmp_path: Path, clock: FakeClock) -> SessionManager:
return SessionManager(AUDIO, SESSIONS, tmp_path, clock=clock)
def test_complete_session_is_written_immediately(manager: SessionManager) -> None:
"""START + 20 chunks + END : WAV d'une seconde, écrit dès le END."""
manager.on_event("esp32-01", start_event())
for _ in range(20):
manager.on_audio("esp32-01", CHUNK)
results = manager.on_event("esp32-01", end_event(20))
assert len(results) == 1
result = results[0]
assert result.outcome is Outcome.COMPLETE
assert result.chunks_received == 20
with wave.open(str(result.path)) as wav:
assert (wav.getframerate(), wav.getnchannels(), wav.getsampwidth()) == (16000, 1, 2)
assert wav.getnframes() == 16000
def test_lost_chunks_are_reported_after_grace(manager: SessionManager, clock: FakeClock) -> None:
"""END annonce 20 chunks, 18 arrivent : bilan « lost_chunks » après le délai de grâce."""
manager.on_event("esp32-01", start_event())
for _ in range(18):
manager.on_audio("esp32-01", CHUNK)
assert manager.on_event("esp32-01", end_event(20)) == []
clock.now += 0.2
assert manager.sweep() == [] # on attend encore les chunks en vol
clock.now += 0.4
results = manager.sweep()
assert [r.outcome for r in results] == [Outcome.LOST_CHUNKS]
assert results[0].chunks_announced == 20
def test_late_chunk_within_grace_completes(manager: SessionManager, clock: FakeClock) -> None:
"""Le END arrive avant le dernier chunk (topics différents) : la session reste complète."""
manager.on_event("esp32-01", start_event())
for _ in range(19):
manager.on_audio("esp32-01", CHUNK)
manager.on_event("esp32-01", end_event(20))
manager.on_audio("esp32-01", CHUNK) # retardataire
clock.now += 0.6
assert [r.outcome for r in manager.sweep()] == [Outcome.COMPLETE]
def test_session_without_end_times_out(manager: SessionManager, clock: FakeClock) -> None:
"""L'ESP32 disparaît en pleine session : clôture après 5 s sans rien recevoir."""
manager.on_event("esp32-01", start_event())
manager.on_audio("esp32-01", CHUNK)
clock.now += 4.9
assert manager.sweep() == []
clock.now += 0.2
results = manager.sweep()
assert [r.outcome for r in results] == [Outcome.TIMEOUT]
assert results[0].chunks_announced is None
def test_new_start_interrupts_previous(manager: SessionManager) -> None:
"""Un START sans END préalable clôt la session précédente."""
manager.on_event("esp32-01", start_event("aaaaaa"))
manager.on_audio("esp32-01", CHUNK)
results = manager.on_event("esp32-01", start_event("bbbbbb"))
assert [(r.session_id, r.outcome) for r in results] == [("aaaaaa", Outcome.INTERRUPTED)]
def test_start_during_grace_keeps_end_verdict(manager: SessionManager) -> None:
"""Nouvel appui pendant le délai de grâce : la session précédente garde son vrai bilan.
Cas trouvé par l'essai de bout en bout : elle était classée « interrompue ».
"""
manager.on_event("esp32-01", start_event("aaaaaa"))
for _ in range(18):
manager.on_audio("esp32-01", CHUNK)
manager.on_event("esp32-01", end_event(20, "aaaaaa")) # 2 chunks manquent
results = manager.on_event("esp32-01", start_event("bbbbbb"))
assert [(r.session_id, r.outcome) for r in results] == [("aaaaaa", Outcome.LOST_CHUNKS)]
def test_wrong_format_is_refused(manager: SessionManager) -> None:
"""Une session annoncée à 48 kHz n'est pas ouverte : ses chunks sont orphelins."""
manager.on_event("esp32-01", start_event(rate=48000))
manager.on_audio("esp32-01", CHUNK)
assert manager.orphan_chunks == 1
def test_devices_are_independent(manager: SessionManager) -> None:
"""Deux cartes parlent en même temps : deux fichiers distincts."""
manager.on_event("esp32-01", start_event("111111"))
manager.on_event("esp32-02", start_event("222222"))
manager.on_audio("esp32-01", CHUNK)
manager.on_audio("esp32-02", CHUNK)
manager.on_audio("esp32-02", CHUNK)
first = manager.on_event("esp32-01", end_event(1, "111111"))[0]
second = manager.on_event("esp32-02", end_event(2, "222222"))[0]
assert (first.chunks_received, second.chunks_received) == (1, 2)
assert first.path != second.path
def test_garbage_event_is_ignored(manager: SessionManager) -> None:
"""Un événement illisible ne fait rien planter."""
assert manager.on_event("esp32-01", b"\xff\x00pas du json") == []
@pytest.mark.parametrize(
("topic", "expected"),
[
("voice/esp32-01/audio/in", ("esp32-01", "audio/in")),
("voice/esp32-01/event", ("esp32-01", "event")),
("other/esp32-01/event", None),
("voice/esp32-01", None),
("voice//event", None),
],
)
def test_split_topic(topic: str, expected: tuple[str, str] | None) -> None:
"""Découpage des topics reçus."""
assert split_topic(topic, "voice") == expected
def test_project_config_loads() -> None:
"""Le config.yaml du dépôt se charge et reste cohérent avec le firmware."""
settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
assert settings.audio.bytes_per_second == 32000
assert settings.mqtt.topic_prefix == "voice"
assert settings.recordings_dir.is_absolute()
def test_finished_sessions_are_notified(tmp_path: Path, clock: FakeClock) -> None:
"""Le rappel on_finished reçoit chaque session terminée (utilisé par le mode écho)."""
finished: list[str] = []
manager = SessionManager(AUDIO, SESSIONS, tmp_path, clock=clock,
on_finished=lambda result: finished.append(result.session_id))
manager.on_event("esp32-01", start_event())
manager.on_audio("esp32-01", CHUNK)
manager.on_event("esp32-01", end_event(1))
assert finished == ["a1b2c3"]
+65
View File
@@ -0,0 +1,65 @@
"""Tests de l'association locuteur + texte, étape 10 (sans modèle)."""
from __future__ import annotations
import json
import math
from voice_server.diarization import SpeakerTurn
from voice_server.speaker_attribution import Utterance, attribute, transcript_json, turn_of
from voice_server.speaker_identifier import Identification
from voice_server.speech_to_text import Word
# Mots et tours relevés sur la session réelle 453cce (vous, un haut-parleur, vous).
WORDS = [Word(" Ouvrir", 0.00, 0.56), Word(" la", 0.56, 0.76), Word(" porte.", 0.76, 1.26),
Word(" Je", 1.50, 1.66), Word(" suis", 1.66, 1.78), Word(" là.", 1.78, 1.92),
Word(" Fermez", 4.92, 5.38), Word(" la", 5.38, 5.52), Word(" porte.", 5.52, 5.86)]
TURNS = [SpeakerTurn("SPEAKER_00", 0.08, 1.50), SpeakerTurn("SPEAKER_01", 1.57, 4.69),
SpeakerTurn("SPEAKER_00", 4.92, 6.17)]
VERDICTS = {"SPEAKER_00": Identification("Denis", 0.72, "Denis"),
"SPEAKER_01": Identification(None, -0.05, "Denis")}
def test_word_goes_to_the_most_overlapping_turn() -> None:
"""« Je » (1,50-1,66) touche SPEAKER_00 sur 0 s et SPEAKER_01 sur 0,09 s."""
turn = turn_of(Word(" Je", 1.50, 1.66), TURNS)
assert turn is not None and turn.speaker == "SPEAKER_01"
def test_word_in_a_gap_goes_to_the_nearest_turn() -> None:
"""Un mot entre deux tours va au plus proche : jamais perdu."""
before = turn_of(Word(" euh", 4.70, 4.76), TURNS) # 0,04 s après la fin de SPEAKER_01
after = turn_of(Word(" euh", 4.84, 4.90), TURNS) # 0,05 s avant la reprise de SPEAKER_00
assert before is not None and before.speaker == "SPEAKER_01"
assert after is not None and after.start_s == 4.92
def test_real_session_is_split_by_speaker() -> None:
utterances = attribute(WORDS, TURNS, VERDICTS)
assert [(u.speaker, u.text) for u in utterances] == [
("Denis", "Ouvrir la porte."), ("Inconnu", "Je suis là."), ("Denis", "Fermez la porte.")]
assert (utterances[0].start_s, utterances[0].end_s) == (0.0, 1.26)
assert utterances[1].label == "SPEAKER_01" and utterances[1].score == -0.05
def test_without_diarization_everything_is_one_unknown_utterance() -> None:
[utterance] = attribute(WORDS, [], {})
assert utterance.speaker == "Inconnu" and utterance.score is None
assert utterance.text == "Ouvrir la porte. Je suis là. Fermez la porte."
def test_speaker_with_too_little_speech_has_no_score() -> None:
"""Empreinte impossible (NaN) : « Inconnu », score null dans le JSON."""
[utterance] = attribute(WORDS[:1], TURNS[:1], {"SPEAKER_00": Identification(None, math.nan, None)})
assert utterance.speaker == "Inconnu" and utterance.score is None
def test_json_message() -> None:
"""Le format attendu par la feuille de route : {"speaker": ..., "text": ...}, accents lisibles."""
payload = transcript_json("a1b2c3", [Utterance("Élodie", "Ferme la porte.", 0.0, 1.234, 0.7456)])
assert json.loads(payload) == {"session": "a1b2c3", "utterances": [
{"speaker": "Élodie", "text": "Ferme la porte.", "start": 0.0, "end": 1.23, "score": 0.75}]}
assert "Élodie".encode() in payload
+124
View File
@@ -0,0 +1,124 @@
"""Tests de l'identification du locuteur, étape 9 : profils, cosinus, seuil (sans modèle)."""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
import numpy as np
import pytest
from enroll import drop_outliers
from voice_server.settings import SpeakerSettings
from voice_server.speaker_identifier import (
SpeakerIdentifier,
load_profiles,
normalized,
profile_path,
save_profile,
)
MODEL = "pyannote/speaker-diarization-community-1"
def voice(seed: int, noise: float = 0.0, base: int | None = None) -> np.ndarray:
"""Empreinte factice : une direction aléatoire, éventuellement bruitée autour d'une autre."""
rng = np.random.default_rng(seed)
center = np.random.default_rng(base).normal(size=256) if base is not None else rng.normal(size=256)
return (center + noise * rng.normal(size=256)).astype(np.float32)
@pytest.fixture
def settings(tmp_path: Path) -> SpeakerSettings:
return SpeakerSettings(profiles_dir=tmp_path / "profiles", similarity_threshold=0.45)
def test_profile_file_name(tmp_path: Path) -> None:
"""Nom lisible, sans accent ni espace ; un nom vide est refusé."""
assert profile_path(tmp_path, "Élodie Martin").name == "elodie-martin.json"
with pytest.raises(ValueError):
profile_path(tmp_path, " ")
def test_save_and_load(settings: SpeakerSettings) -> None:
"""Le profil est la moyenne normalisée ; il se relit tel quel."""
saved = save_profile(settings.profiles_dir, "Denis", [voice(1, 0.3, base=0), voice(2, 0.3, base=0)], MODEL)
[loaded] = load_profiles(settings.profiles_dir, MODEL)
assert loaded.name == "Denis" and loaded.samples == 2
assert float(np.linalg.norm(loaded.embedding)) == pytest.approx(1.0, abs=1e-4)
assert float(loaded.embedding @ saved.embedding) == pytest.approx(1.0, abs=1e-4)
assert not list(settings.profiles_dir.glob("*.tmp")) # écriture atomique terminée
def test_profile_of_another_model_is_ignored(settings: SpeakerSettings) -> None:
"""Deux modèles d'empreintes ne sont pas comparables : le profil est écarté."""
save_profile(settings.profiles_dir, "Denis", [voice(1)], "autre/modele")
assert load_profiles(settings.profiles_dir, MODEL) == []
def test_unreadable_profile_is_skipped(settings: SpeakerSettings) -> None:
settings.profiles_dir.mkdir(parents=True)
(settings.profiles_dir / "casse.json").write_text("{pas du json", encoding="utf-8")
assert load_profiles(settings.profiles_dir, MODEL) == []
def test_identify_known_and_unknown(settings: SpeakerSettings) -> None:
"""La même voix est reconnue ; une autre voix reste « Inconnu », avec le profil le plus proche."""
save_profile(settings.profiles_dir, "Denis", [voice(i, 0.5, base=0) for i in range(1, 6)], MODEL)
save_profile(settings.profiles_dir, "Alice", [voice(i, 0.5, base=100) for i in range(11, 16)], MODEL)
identifier = SpeakerIdentifier(settings, MODEL)
denis = identifier.identify(voice(42, 0.5, base=0))
stranger = identifier.identify(voice(7))
assert denis.name == "Denis" and denis.score > 0.45
assert stranger.name is None and stranger.label == "Inconnu"
assert stranger.closest in ("Denis", "Alice")
assert "proche de" in stranger.describe()
def test_missing_embedding_is_unknown(settings: SpeakerSettings) -> None:
"""Trop peu de parole : empreinte NaN, jamais un nom."""
save_profile(settings.profiles_dir, "Denis", [voice(1)], MODEL)
verdict = SpeakerIdentifier(settings, MODEL).identify(np.full(256, np.nan, dtype=np.float32))
assert verdict.name is None and math.isnan(verdict.score)
assert verdict.describe() == "Inconnu (trop peu de parole)"
def test_no_profile(settings: SpeakerSettings) -> None:
assert SpeakerIdentifier(settings, MODEL).identify(voice(1)).describe() == "Inconnu (aucun profil)"
def test_new_profile_is_seen_without_restart(settings: SpeakerSettings) -> None:
"""Un enrôlement fait pendant que le serveur tourne est pris en compte."""
identifier = SpeakerIdentifier(settings, MODEL)
assert identifier.identify(voice(1)).name is None
save_profile(settings.profiles_dir, "Denis", [voice(1)], MODEL)
stamp = settings.profiles_dir.stat().st_mtime + 1 # horloge des fichiers parfois grossière
os.utime(settings.profiles_dir, (stamp, stamp))
assert identifier.identify(voice(1)).name == "Denis"
def test_profile_file_is_readable_json(settings: SpeakerSettings) -> None:
"""Format ouvert : on sait ce qu'on stocke sur une personne."""
save_profile(settings.profiles_dir, "Denis", [voice(1)], MODEL)
data = json.loads(profile_path(settings.profiles_dir, "Denis").read_text(encoding="utf-8"))
assert set(data) == {"name", "model", "samples", "created", "embedding"}
assert len(data["embedding"]) == 256
def test_enrollment_drops_a_foreign_session() -> None:
"""Enrôlement : une session d'une autre voix parmi les vôtres est écartée."""
samples = [(f"s{i}", normalized(voice(i, 0.5, base=0))) for i in range(1, 6)]
samples.append(("intrus", normalized(voice(99))))
kept = drop_outliers(samples, 0.45)
assert [name for name, _ in kept] == ["s1", "s2", "s3", "s4", "s5"]
+131
View File
@@ -0,0 +1,131 @@
"""Tests des vrais modèles, étape 6 : ignorés tant que « make models » n'a pas tourné.
Piper dit une phrase, Whisper doit la relire : les deux modèles se vérifient
l'un l'autre, sans micro ni haut-parleur.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from voice_server.audio_resampler import AudioResampler, peak_dbfs
from voice_server.settings import Settings, load_settings
from voice_server.speech_to_text import SpeechToText
from voice_server.text_to_speech import TextToSpeech
from voice_server.voice_activity import VoiceActivityDetector
SETTINGS: Settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
pytestmark = pytest.mark.skipif(
not (SETTINGS.stt.model_dir / "model.bin").exists() or not SETTINGS.tts.model_path.exists(),
reason="modeles absents : lancez 'make models'",
)
@pytest.fixture(scope="module")
def tts() -> TextToSpeech:
return TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio), SETTINGS.audio.sample_rate)
@pytest.fixture(scope="module")
def stt() -> SpeechToText:
return SpeechToText(SETTINGS.stt, SETTINGS.audio.sample_rate)
def test_piper_output_is_system_format(tts: TextToSpeech) -> None:
"""Piper produit du 22 050 Hz : la sortie doit être en 16 kHz, crête -3 dBFS."""
speech = tts.synthesize("Bonjour.")
assert len(speech.pcm) % 2 == 0
assert 0.3 < speech.audio_s < 3.0
assert peak_dbfs(speech.pcm) == pytest.approx(-3.0, abs=0.1)
def test_round_trip(tts: TextToSpeech, stt: SpeechToText) -> None:
"""Ce que Piper dit, Whisper le relit."""
transcript = stt.transcribe(tts.synthesize("Bonjour, comment tu t'appelles ?").pcm)
assert "appelles" in transcript.text.lower()
def test_noise_hallucination_is_dropped(stt: SpeechToText) -> None:
"""Sur du bruit, Whisper invente parfois un sous-titre : il ne doit pas passer."""
noise = np.random.default_rng(0).normal(0, 300, 32000).astype("<i2").tobytes()
assert "amara" not in stt.transcribe(noise).text.lower()
def test_vad_finds_speech_between_silences(tts: TextToSpeech) -> None:
"""1 s de silence, une phrase, 1 s de silence : la VAD retrouve la phrase et retire le reste."""
speech = tts.synthesize("Quelle heure est-il ?")
silence = bytes(32000)
detector = VoiceActivityDetector(SETTINGS.vad, SETTINGS.audio.sample_rate)
result = detector.detect(silence + speech.pcm + silence)
assert result.has_speech
start = result.segments[0].start / 16000
end = result.segments[-1].end / 16000
assert 0.7 < start < 1.2 # début de parole, marge comprise
assert 1.0 + speech.audio_s - 0.4 < end < 1.0 + speech.audio_s + 0.3
assert result.speech_s < speech.audio_s + 0.5 # les 2 s de silence sont parties
def test_words_have_increasing_times(tts: TextToSpeech, stt: SpeechToText) -> None:
"""Étape 10 : chaque mot a un instant, dans l'ordre, et les mots recomposent le texte."""
transcript = stt.transcribe(tts.synthesize("Ferme les volets de la cuisine.").pcm)
assert len(transcript.words) >= 5
starts = [word.start_s for word in transcript.words]
assert starts == sorted(starts)
assert "".join(word.text for word in transcript.words).strip() == transcript.text
WAKE_READY = all((SETTINGS.wake.model_dir / name).exists()
for name in ("melspectrogram.onnx", "embedding_model.onnx", "alexa_v0.1.onnx"))
@pytest.mark.skipif(not WAKE_READY, reason="modeles openWakeWord absents : 'make models'")
def test_alexa_is_detected_and_silence_is_not(tts: TextToSpeech) -> None:
"""Étape 13 : « Alexa » dit par Piper déclenche ; le silence et une phrase ordinaire non."""
pytest.importorskip("openwakeword")
from voice_server.wake_word import WakeWordDetector
def first_detection(pcm: bytes) -> str | None:
detector = WakeWordDetector(SETTINGS.wake)
padded = bytes(32000) + pcm + bytes(32000)
for start in range(0, len(padded) - 1599, 1600):
detection = detector.process(padded[start:start + 1600])
if detection is not None:
return detection.word
return None
loud = AudioResampler(SETTINGS.audio, -10.0)
speaker = TextToSpeech(SETTINGS.tts, loud, SETTINGS.audio.sample_rate)
assert first_detection(speaker.synthesize("Alexa, allume la lumière.").pcm) == "alexa"
assert first_detection(speaker.synthesize("Bonjour, quelle heure est-il ?").pcm) is None
assert first_detection(bytes(64000)) is None
@pytest.mark.skipif(not WAKE_READY, reason="modeles openWakeWord absents : 'make models'")
def test_per_word_threshold_and_near_miss(caplog: pytest.LogCaptureFixture, tts: TextToSpeech) -> None:
"""Un seuil propre au mot ; sous le seuil, le pic est journalisé comme « presque reconnu »."""
pytest.importorskip("openwakeword")
import dataclasses
import logging
from voice_server.wake_word import WakeWordDetector
strict = dataclasses.replace(SETTINGS.wake, thresholds={"alexa": 1.01}) # inatteignable
detector = WakeWordDetector(strict, "esp32-test")
speaker = TextToSpeech(SETTINGS.tts, AudioResampler(SETTINGS.audio, -10.0), SETTINGS.audio.sample_rate)
padded = bytes(32000) + speaker.synthesize("Alexa, allume la lumière.").pcm + bytes(64000)
with caplog.at_level(logging.INFO, logger="voice_server.wake_word"):
found = [detector.process(padded[i:i + 1600]) for i in range(0, len(padded) - 1599, 1600)]
assert all(detection is None for detection in found)
assert "presque reconnu « alexa »" in caplog.text
+111
View File
@@ -0,0 +1,111 @@
"""Tests de la VAD, étape 7 : segmentation (sans modèle) puis Silero sur des signaux connus."""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
import pytest
from voice_server.settings import VadSettings
from voice_server.voice_activity import (
WINDOW_SAMPLES,
Segment,
VoiceActivityDetector,
envelope,
find_segments,
pad_segments,
)
VAD = VadSettings(threshold=0.5, min_silence_ms=500, min_speech_ms=250, speech_pad_ms=150)
def probs(pattern: str) -> list[float]:
"""« # » = 0,9 (parole), « . » = 0,4 (entre les deux seuils), « _ » = 0,05 (silence)."""
return [{"#": 0.9, ".": 0.4, "_": 0.05}[c] for c in pattern]
def test_single_phrase() -> None:
"""Silence, parole, silence : un segment aux bonnes bornes."""
assert find_segments(probs("____######______"), 0.5, 3, 2) == [Segment(4, 10)]
def test_short_pause_does_not_split() -> None:
"""Une pause plus courte que min_silence ne coupe pas la phrase."""
assert find_segments(probs("__###__###____"), 0.5, 3, 2) == [Segment(2, 10)]
def test_long_pause_splits() -> None:
"""Une pause plus longue sépare deux segments."""
assert find_segments(probs("__###_____###____"), 0.5, 3, 2) == [Segment(2, 5), Segment(10, 13)]
def test_hysteresis_keeps_speech_between_thresholds() -> None:
"""Entre 0,35 et 0,5, on reste en parole : pas de battement autour du seuil."""
assert find_segments(probs("_##......##____"), 0.5, 3, 2) == [Segment(1, 11)]
def test_short_noise_is_dropped() -> None:
"""Un clic d'une fenêtre n'est pas de la parole."""
assert find_segments(probs("___#_______"), 0.5, 3, 2) == []
def test_speech_until_the_end() -> None:
"""Bouton relâché en pleine phrase : le segment va jusqu'au bout."""
assert find_segments(probs("___#####"), 0.5, 3, 2) == [Segment(3, 8)]
def test_padding_merges_close_segments() -> None:
"""La marge élargit les segments, bornée par l'audio ; les chevauchements fusionnent."""
segments = [Segment(100, 200), Segment(260, 300), Segment(900, 990)]
assert pad_segments(segments, 50, 1000) == [Segment(50, 350), Segment(850, 1000)]
def test_envelope() -> None:
assert envelope(np.array([0.9, 0.4, 0.05]), 0.5) == "#._"
def fake_model(pattern: str) -> Callable[[np.ndarray], np.ndarray]:
"""Modèle qui rend les probabilités d'un motif, quelle que soit l'entrée."""
def model(audio: np.ndarray) -> np.ndarray:
assert audio.size % WINDOW_SAMPLES == 0
return np.array(probs(pattern), dtype=np.float32)
return model
def test_detector_keeps_only_speech() -> None:
"""Le PCM rendu est la parole, marges comprises ; le reste est retiré."""
pattern = "_" * 20 + "#" * 20 + "_" * 20 # 60 fenêtres = 1,92 s
pcm = np.arange(60 * WINDOW_SAMPLES, dtype="<i2").tobytes()
detector = VoiceActivityDetector(VAD, 16000, model=fake_model(pattern))
result = detector.detect(pcm)
pad = 150 * 16
assert result.segments == (Segment(20 * WINDOW_SAMPLES - pad, 40 * WINDOW_SAMPLES + pad),)
assert result.speech_s == pytest.approx((20 * WINDOW_SAMPLES + 2 * pad) / 16000)
first = np.frombuffer(result.speech_pcm[:2], dtype="<i2")[0]
assert first == 20 * WINDOW_SAMPLES - pad # l'échantillon d'origine, pas un décalage
def test_detector_without_speech() -> None:
detector = VoiceActivityDetector(VAD, 16000, model=fake_model("_" * 10))
result = detector.detect(bytes(10 * WINDOW_SAMPLES * 2))
assert not result.has_speech and result.speech_pcm == b""
def test_wrong_rate_is_refused() -> None:
with pytest.raises(ValueError):
VoiceActivityDetector(VAD, 22050, model=fake_model(""))
def test_silero_ignores_silence_and_noise() -> None:
"""Le vrai Silero : ni le silence ni un bruit blanc ne sont de la parole."""
pytest.importorskip("faster_whisper")
detector = VoiceActivityDetector(VAD, 16000)
noise = np.random.default_rng(0).normal(0, 300, 32000).astype("<i2").tobytes()
assert not detector.detect(bytes(32000)).has_speech
assert not detector.detect(noise).has_speech
+82
View File
@@ -0,0 +1,82 @@
"""Tests de l'agent Linux, étape 15b : configuration, liste blanche, exécution (sans broker)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
AGENT_DIR = Path(__file__).resolve().parents[2] / "agent"
if str(AGENT_DIR) not in sys.path:
sys.path.insert(0, str(AGENT_DIR))
from voice_agent import Action, AgentConfig, ConfigError, load_config, run_action, state_payload # noqa: E402
def config(**actions: Action) -> AgentConfig:
return AgentConfig(agent_id="pc-test", host="127.0.0.1", port=1883, username=None, password=None,
topic_prefix="agent", dry_run=False, actions=dict(actions))
def test_example_config_is_valid() -> None:
loaded = load_config(AGENT_DIR / "agent.yaml.example")
assert loaded.agent_id == "pc-bureau" and not loaded.dry_run
assert loaded.actions["shutdown"].command == ("systemctl", "poweroff")
assert loaded.topic("command") == "agent/pc-bureau/command"
@pytest.mark.parametrize(
("text", "message"),
[
("id: PC Bureau\nmqtt: {host: x}\n", "id « PC Bureau » invalide"),
("id: pc\nmqtt: {}\n", "mqtt.host manquant"),
# Une chaîne passerait par un shell : « poweroff; rm -rf / » serait exécutable.
("id: pc\nmqtt: {host: x}\nactions: {off: {command: 'systemctl poweroff'}}\n", "doit être une liste"),
("id: pc\nmqtt: {host: x}\nactions: {off: {command: [x], timeout_s: 0}}\n", "timeout_s"),
],
)
def test_invalid_config_is_explained(tmp_path: Path, text: str, message: str) -> None:
path = tmp_path / "agent.yaml"
path.write_text(text, encoding="utf-8")
with pytest.raises(ConfigError, match=message):
load_config(path)
def test_only_listed_actions_run() -> None:
result = run_action(config(ok=Action(("true",), 5)), "rm", "r1")
assert not result.ok and result.output == "action inconnue : rm"
def test_success_failure_and_output() -> None:
agent = config(ok=Action(("echo", "bonjour"), 5), ko=Action(("false",), 5))
ok = run_action(agent, "ok", "r1")
assert ok.ok and ok.code == 0 and ok.output == "bonjour"
ko = run_action(agent, "ko", "r2")
assert not ko.ok and ko.code == 1
def test_missing_program_and_timeout() -> None:
agent = config(absent=Action(("/nonexistent/prog",), 5), slow=Action(("sleep", "5"), 0.2))
assert run_action(agent, "absent", "r").output == "programme introuvable : /nonexistent/prog"
slow = run_action(agent, "slow", "r")
assert not slow.ok and slow.output == "arretee apres 0.2 s"
def test_dry_run_executes_nothing() -> None:
calls: list[object] = []
agent = AgentConfig(**{**config(off=Action(("systemctl", "poweroff"), 5)).__dict__, "dry_run": True})
result = run_action(agent, "off", "r", runner=lambda *args, **kwargs: calls.append(args)) # type: ignore[arg-type]
assert result.ok and result.output == "essai : systemctl poweroff" and calls == []
def test_state_lists_actions_for_the_server() -> None:
online = json.loads(state_payload(config(b=Action(("x",), 1), a=Action(("y",), 1)), True))
assert online == {"status": "online", "actions": ["a", "b"], "dry_run": False}
assert json.loads(state_payload(config(), False)) == {"status": "offline"}
def test_result_payload() -> None:
result = run_action(config(ok=Action(("true",), 5)), "ok", "w42")
assert json.loads(result.payload()) == {"action": "ok", "id": "w42", "ok": True, "code": 0, "output": ""}
+326
View File
@@ -0,0 +1,326 @@
"""Tests de la boucle complète, étapes 6-7 : modèles et carte remplacés par des doublures."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from voice_server.access_control import AccessPolicy
from voice_server.assistant import NOTHING_HEARD, Reply
from voice_server.audio_buffer import Levels
from voice_server.diarization import Diarization, SpeakerTurn
from voice_server.home_control import DeviceCommand
from voice_server.machine_control import Command
from voice_server.session_manager import Outcome, SessionResult
from voice_server.settings import AccessSettings, HomeSettings
from voice_server.speaker_identifier import Identification
from voice_server.speech_to_text import Transcript, Word
from voice_server.text_to_speech import Speech
from voice_server.voice_activity import Segment, VadResult
from voice_server.voice_pipeline import VoicePipeline
PCM = bytes(32000) # 1 s de silence numérique : le contenu n'importe pas ici
class FakeVad:
"""Trouve la parole entre 0,25 et 0,75 s, ou rien si speech=False."""
def __init__(self, speech: bool = True) -> None:
self.speech = speech
def detect(self, pcm: bytes) -> VadResult:
segments = (Segment(4000, 12000),) if self.speech else ()
kept = pcm[8000:24000] if self.speech else b""
return VadResult(segments=segments, speech_pcm=kept, probabilities=np.zeros(0),
total_s=len(pcm) / 32000, speech_s=len(kept) / 32000, elapsed_s=0.003,
sample_rate=16000)
class FakeDiarizer:
def __init__(self) -> None:
self.lengths: list[int] = []
def diarize(self, pcm: bytes) -> Diarization:
self.lengths.append(len(pcm))
return Diarization(turns=(SpeakerTurn("SPEAKER_00", 0.0, 0.2), SpeakerTurn("SPEAKER_01", 0.2, 0.5)),
speakers=("SPEAKER_00", "SPEAKER_01"),
embeddings=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32),
audio_s=0.5, elapsed_s=0.3)
class FakeIdentifier:
"""Reconnaît la première empreinte (valeur 1), pas les autres."""
def identify(self, embedding: np.ndarray) -> Identification:
if embedding[0] == 1.0:
return Identification(name="Denis", score=0.8, closest="Denis")
return Identification(name=None, score=0.1, closest="Denis")
class FakeStt:
def __init__(self, text: str, dropped: tuple[str, ...] = (), words: tuple[Word, ...] = ()) -> None:
self.text = text
self.dropped = dropped
self.words = words
self.calls = 0
self.lengths: list[int] = []
def transcribe(self, pcm: bytes) -> Transcript:
self.calls += 1
self.lengths.append(len(pcm))
return Transcript(text=self.text, audio_s=len(pcm) / 32000, elapsed_s=0.8, dropped=self.dropped,
words=self.words)
class EchoAssistant:
def __init__(self, command: Command | None = None) -> None:
self.speakers: list[str | None] = []
self.command = command
def respond(self, text: str, speaker: str | None = None, board: str = "") -> Reply:
self.speakers.append(speaker)
return Reply(f"réponse à {text}", self.command)
class FakeController:
def __init__(self, events: list[str]) -> None:
self.events = events
self.calls: list[tuple[str, Command, str | None, str]] = []
def execute(self, board: str, command: Command, speaker: str | None, session: str) -> list[str]:
self.calls.append((board, command, speaker, session))
self.events.append("commande")
return ["home/salon/light/set"]
class FakePublisher:
def __init__(self) -> None:
self.messages: list[tuple[str, str, bytes, int]] = []
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
self.messages.append((device, suffix, payload, qos))
class FakeTts:
def __init__(self) -> None:
self.texts: list[str] = []
def synthesize(self, text: str) -> Speech:
self.texts.append(text)
return Speech(pcm=bytes(16000), audio_s=0.5, elapsed_s=0.1)
class FakePlayer:
def __init__(self, events: list[str] | None = None) -> None:
self.sent: list[tuple[str, int, str | None]] = []
self.events = events if events is not None else []
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
self.sent.append((device, len(pcm), session))
self.events.append("reponse parlee")
return session or "x"
def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult:
return SessionResult(device="esp32-01", session_id="a1b2c3", outcome=Outcome.COMPLETE,
path=Path("inutile.wav"), duration_s=duration_s, chunks_received=30,
chunks_announced=30, levels=Levels(peak_dbfs=peak_dbfs, rms_dbfs=-30.0))
def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None,
diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | None = None,
publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None,
controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline:
ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard
return VoicePipeline(vad or FakeVad(), stt, assistant or EchoAssistant(), tts, player, diarizer=diarizer,
identifier=identifier, publisher=publisher, controller=controller, policy=policy,
read=lambda result: PCM, clock=lambda: next(ticks))
def test_full_turn() -> None:
"""Question transcrite, réponse synthétisée puis envoyée à la bonne carte."""
stt, tts, player = FakeStt("quelle heure"), FakeTts(), FakePlayer()
turn = pipeline(stt, tts, player).handle(session())
assert turn is not None
assert (turn.heard, turn.answer) == ("quelle heure", "réponse à quelle heure")
assert turn.ready_s == pytest.approx(1.2)
assert stt.lengths == [16000] # Whisper ne reçoit que la parole trouvée par la VAD
assert turn.speech_s == pytest.approx(0.5)
assert tts.texts == ["réponse à quelle heure"]
assert player.sent == [("esp32-01", 16000, "rep-a1b2c3")]
def test_too_short_session_is_ignored() -> None:
"""Un appui trop bref ne déclenche ni transcription ni réponse."""
stt, tts, player = FakeStt("x"), FakeTts(), FakePlayer()
assert pipeline(stt, tts, player).handle(session(duration_s=0.1)) is None
assert (stt.calls, player.sent) == (0, [])
def test_session_without_speech_skips_whisper() -> None:
"""Pas de parole selon la VAD : Whisper n'est pas appelé (lent, hallucinations), la carte le dit."""
stt, tts, player = FakeStt("Sous-titres réalisés par Amara.org"), FakeTts(), FakePlayer()
turn = pipeline(stt, tts, player, FakeVad(speech=False)).handle(session(peak_dbfs=-30.0))
assert stt.calls == 0
assert turn is not None and turn.answer == NOTHING_HEARD
assert len(player.sent) == 1
def test_nothing_recognized_is_said() -> None:
"""Transcription vide (hallucination écartée) : « Je n'ai rien entendu. »"""
stt, tts, player = FakeStt("", dropped=("Amara.org",)), FakeTts(), FakePlayer()
turn = pipeline(stt, tts, player).handle(session())
assert turn is not None and turn.answer == NOTHING_HEARD
def test_diarization_gets_the_same_audio_as_whisper() -> None:
"""Diarisation et transcription reçoivent la même parole : leurs instants concordent."""
stt, tts, player, diarizer = FakeStt("bonjour"), FakeTts(), FakePlayer(), FakeDiarizer()
turn = pipeline(stt, tts, player, diarizer=diarizer).handle(session())
assert turn is not None and turn.speakers == 2
assert diarizer.lengths == stt.lengths == [16000]
def test_no_diarization_without_speech() -> None:
"""Appui muet : ni diarisation ni transcription."""
diarizer = FakeDiarizer()
turn = pipeline(FakeStt("x"), FakeTts(), FakePlayer(), FakeVad(speech=False), diarizer).handle(session())
assert turn is not None and turn.speakers == 0
assert diarizer.lengths == []
def test_each_speaker_gets_a_name_or_unknown() -> None:
"""Étape 9 : un nom par locuteur de la diarisation, dans le même ordre."""
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), diarizer=FakeDiarizer(),
identifier=FakeIdentifier()).handle(session())
assert turn is not None and turn.names == ("Denis", "Inconnu")
def test_no_identification_without_diarization() -> None:
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), identifier=FakeIdentifier()).handle(session())
assert turn is not None and turn.names == ()
TWO_VOICES = (Word(" Ferme", 0.0, 0.1), Word(" la", 0.1, 0.18), # SPEAKER_00 : 0,0-0,2
Word(" Bonjour", 0.22, 0.4), Word(" Denis.", 0.4, 0.5)) # SPEAKER_01 : 0,2-0,5
def test_who_said_what_is_published() -> None:
"""Étape 10 : une intervention par locuteur, publiée en JSON sur voice/<carte>/transcript."""
publisher = FakePublisher()
turn = pipeline(FakeStt("Ferme la Bonjour Denis.", words=TWO_VOICES), FakeTts(), FakePlayer(),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), publisher=publisher).handle(session())
assert turn is not None
assert [(u.speaker, u.text) for u in turn.utterances] == [("Denis", "Ferme la"), ("Inconnu", "Bonjour Denis.")]
[(device, suffix, payload, qos)] = publisher.messages
assert (device, suffix, qos) == ("esp32-01", "transcript", 1)
message = json.loads(payload)
assert message["session"] == "a1b2c3"
assert message["utterances"][0] == {"speaker": "Denis", "text": "Ferme la", "start": 0.0, "end": 0.18,
"score": 0.8}
def test_assistant_answers_the_last_speaker_by_name() -> None:
"""L'assistant reçoit le nom de la dernière personne qui a parlé, None si elle est inconnue."""
assistant = EchoAssistant()
words = TWO_VOICES[:2] # seul SPEAKER_00, reconnu comme Denis
pipeline(FakeStt("Ferme la", words=words), FakeTts(), FakePlayer(), diarizer=FakeDiarizer(),
identifier=FakeIdentifier(), assistant=assistant).handle(session())
pipeline(FakeStt("Ferme la Bonjour Denis.", words=TWO_VOICES), FakeTts(), FakePlayer(),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=assistant).handle(session())
assert assistant.speakers == ["Denis", None]
def test_nothing_published_without_speech() -> None:
publisher = FakePublisher()
pipeline(FakeStt("x"), FakeTts(), FakePlayer(), FakeVad(speech=False), publisher=publisher).handle(session())
assert publisher.messages == []
def test_text_without_word_times_is_one_unknown_utterance() -> None:
"""Sans diarisation ni horodatage, une seule intervention « Inconnu » : le JSON reste valide."""
publisher = FakePublisher()
turn = pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(), publisher=publisher).handle(session())
assert turn is not None and [(u.speaker, u.text) for u in turn.utterances] == [("Inconnu", "bonjour")]
assert json.loads(publisher.messages[0][2])["utterances"][0]["score"] is None
def test_command_is_executed_before_the_spoken_answer() -> None:
"""Étape 11 : la commande part d'abord, la lumière s'allume pendant que la carte le dit."""
events: list[str] = []
command = DeviceCommand(action="on", device="light", room="salon")
controller = FakeController(events)
turn = pipeline(FakeStt("allume"), FakeTts(), FakePlayer(events), assistant=EchoAssistant(command),
controller=controller).handle(session())
assert turn is not None and turn.command == command
assert controller.calls == [("esp32-01", command, None, "a1b2c3")]
assert events == ["commande", "reponse parlee"]
def test_no_command_no_controller_call() -> None:
events: list[str] = []
controller = FakeController(events)
pipeline(FakeStt("bonjour"), FakeTts(), FakePlayer(events), controller=controller).handle(session())
assert controller.calls == []
HOME = HomeSettings(topic_prefix="home", rooms={"salon": "du salon"}, boards={"esp32-01": "salon"})
ACCESS = AccessSettings(unknown_level="limite", default_level="standard", users={"Denis": "complet"},
full_min_score=0.6, rules={"door": {"open": "complet"}}, confirm=("door.open",),
confirm_timeout_s=15.0)
DENIS_WORDS = (Word(" ouvre", 0.0, 0.1), Word(" la porte", 0.1, 0.18)) # tour de SPEAKER_00 = Denis (0,8)
def test_refused_command_is_not_executed() -> None:
"""Étape 14 : voix inconnue, pas de commande ; la carte dit pourquoi."""
events: list[str] = []
controller = FakeController(events)
turn = pipeline(FakeStt("ouvre la porte"), FakeTts(), FakePlayer(events),
assistant=EchoAssistant(DeviceCommand("open", "door", "salon")),
controller=controller, policy=AccessPolicy(ACCESS, HOME)).handle(session())
assert turn is not None and turn.refused and turn.command is None
assert controller.calls == []
assert turn.answer.startswith("Je ne vous ai pas reconnu")
def test_sensitive_command_waits_for_yes() -> None:
"""Denis demande d'ouvrir la porte : question, puis « oui » → exécution."""
events: list[str] = []
controller = FakeController(events)
policy = AccessPolicy(ACCESS, HOME)
door = DeviceCommand("open", "door", "salon")
first = pipeline(FakeStt("ouvre la porte", words=DENIS_WORDS), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(door),
controller=controller, policy=policy).handle(session())
assert first is not None and first.answer.startswith("Confirmez-vous") and controller.calls == []
yes_words = (Word(" oui", 0.0, 0.1),)
second = pipeline(FakeStt("oui", words=yes_words), FakeTts(), FakePlayer(events),
diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(),
controller=controller, policy=policy).handle(session())
assert second is not None and second.command == door
assert [call[1] for call in controller.calls] == [door]
+66
View File
@@ -0,0 +1,66 @@
"""Tests de l'outil de lecture de l'étape 2 (sans matériel)."""
from __future__ import annotations
import struct
import sys
import wave
from pathlib import Path
import pytest
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from wav_to_serial import FormatError, read_wav, send_times, split_chunks, tone_pcm # noqa: E402
def test_send_times_keeps_constant_lead() -> None:
"""Les chunks d'avance partent tout de suite, puis un toutes les 50 ms."""
times = send_times(count=7, chunk_s=0.05, prefill=4)
assert times[:4] == [0.0, 0.0, 0.0, 0.0]
assert times[4:] == pytest.approx([0.05, 0.10, 0.15])
def test_split_chunks_never_cuts_a_sample() -> None:
"""Tous les chunks ont une taille paire, le dernier compris."""
chunks = split_chunks(bytes(3203), chunk_bytes=1600)
assert [len(chunk) for chunk in chunks] == [1600, 1600, 2]
def test_tone_has_expected_length_and_level() -> None:
"""Un son pur de 0,5 s à 25 % : 8000 échantillons, crête proche de 8192."""
pcm = tone_pcm(440.0, 0.5, amplitude=0.25)
samples = struct.unpack(f"<{len(pcm) // 2}h", pcm)
assert len(samples) == 8000
assert 8000 < max(samples) <= 8192
assert abs(samples[0]) < 50 and abs(samples[-1]) < 50 # fondus : pas de clic
def test_read_wav_rejects_wrong_format(tmp_path: Path) -> None:
"""Un WAV 44,1 kHz stéréo est refusé, avec la commande de conversion."""
path = tmp_path / "cd.wav"
with wave.open(str(path), "wb") as wav:
wav.setnchannels(2)
wav.setsampwidth(2)
wav.setframerate(44100)
wav.writeframes(bytes(400))
with pytest.raises(FormatError, match="ffmpeg"):
read_wav(path)
def test_read_wav_accepts_system_format(tmp_path: Path) -> None:
"""Un WAV 16 kHz, 16 bits, mono est lu tel quel."""
path = tmp_path / "ok.wav"
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(bytes(320))
assert len(read_wav(path)) == 320
+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()