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,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
|
||||
Reference in New Issue
Block a user