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