mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
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:
@@ -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=
|
||||
@@ -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 »
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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 ?")
|
||||
@@ -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é
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": ""}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
@@ -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()}"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user