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

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

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

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