mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 08:21:58 +02:00
d20976fea2
É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>
132 lines
5.5 KiB
Python
132 lines
5.5 KiB
Python
"""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
|