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:
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enrôlement vocal : crée, liste ou supprime les profils de voix (étape 9).
|
||||
|
||||
Un profil est la moyenne des empreintes de plusieurs sessions d'une même
|
||||
personne. Par défaut, on prend les N dernières sessions de l'ESP32 : dites
|
||||
d'abord 5 phrases variées avec « make run », puis enrôlez.
|
||||
|
||||
Chaque session est contrôlée : assez de parole, une seule voix, et cohérente
|
||||
avec les autres (une session où une autre voix s'est glissée est écartée).
|
||||
|
||||
Les profils sont des données biométriques : server/profiles/ n'est jamais
|
||||
versionné, et « --forget » supprime un profil.
|
||||
|
||||
Exemples :
|
||||
python3 tools/enroll.py --name Denis # 5 dernières sessions
|
||||
python3 tools/enroll.py --name Denis a.wav b.wav c.wav
|
||||
python3 tools/enroll.py --list
|
||||
python3 tools/enroll.py --forget Denis
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from voice_server.audio_resampler import AudioFormatError, AudioResampler # noqa: E402
|
||||
from voice_server.diarization import Diarizer # noqa: E402
|
||||
from voice_server.settings import Settings, load_settings # noqa: E402
|
||||
from voice_server.speaker_identifier import ( # noqa: E402
|
||||
SpeakerIdentifier,
|
||||
load_profiles,
|
||||
normalized,
|
||||
profile_path,
|
||||
save_profile,
|
||||
)
|
||||
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
|
||||
|
||||
logger = logging.getLogger("enroll")
|
||||
|
||||
MIN_SPEECH_S = 1.0 # en dessous, l'empreinte est trop instable pour servir de référence
|
||||
MIN_SAMPLES = 3
|
||||
|
||||
|
||||
def collect(settings: Settings, wavs: list[Path]) -> list[tuple[str, np.ndarray]]:
|
||||
"""Une empreinte par session valable : assez de parole, une seule voix."""
|
||||
audio = settings.audio
|
||||
vad = VoiceActivityDetector(settings.vad, audio.sample_rate)
|
||||
diarizer = Diarizer(settings.diarization, audio.sample_rate)
|
||||
reader = AudioResampler(audio, None)
|
||||
samples: list[tuple[str, np.ndarray]] = []
|
||||
for path in wavs:
|
||||
try:
|
||||
speech = vad.detect(reader.from_wav(path))
|
||||
except (AudioFormatError, FileNotFoundError) as error:
|
||||
logger.warning(" %s ignore : %s", path.name, error)
|
||||
continue
|
||||
if speech.speech_s < MIN_SPEECH_S:
|
||||
logger.warning(" %s ignore : %.1f s de parole (minimum %.0f s)", path.name, speech.speech_s, MIN_SPEECH_S)
|
||||
continue
|
||||
diarization = diarizer.diarize(speech.speech_pcm)
|
||||
if len(diarization.speakers) != 1 or diarization.embeddings is None:
|
||||
logger.warning(" %s ignore : %d voix trouvees, une seule attendue", path.name, len(diarization.speakers))
|
||||
continue
|
||||
embedding = diarization.embeddings[0]
|
||||
if not np.all(np.isfinite(embedding)):
|
||||
logger.warning(" %s ignore : empreinte impossible", path.name)
|
||||
continue
|
||||
logger.info(" %s : %.1f s de parole", path.name, speech.speech_s)
|
||||
samples.append((path.name, normalized(embedding)))
|
||||
return samples
|
||||
|
||||
|
||||
def drop_outliers(samples: list[tuple[str, np.ndarray]], threshold: float) -> list[tuple[str, np.ndarray]]:
|
||||
"""Écarte les sessions qui ne ressemblent pas aux autres (autre voix, mélange)."""
|
||||
kept: list[tuple[str, np.ndarray]] = []
|
||||
for index, (name, embedding) in enumerate(samples):
|
||||
others = normalized(np.mean([e for i, (_, e) in enumerate(samples) if i != index], axis=0))
|
||||
score = float(others @ embedding)
|
||||
if score < threshold:
|
||||
logger.warning(" %s ecarte : ressemblance %.2f avec les autres sessions (seuil %.2f)",
|
||||
name, score, threshold)
|
||||
else:
|
||||
kept.append((name, embedding))
|
||||
return kept
|
||||
|
||||
|
||||
def enroll(settings: Settings, name: str, wavs: list[Path]) -> int:
|
||||
"""Crée (ou remplace) le profil de name à partir des sessions données."""
|
||||
logger.info("enrolement de %s sur %d session(s) :", name, len(wavs))
|
||||
samples = collect(settings, wavs)
|
||||
if len(samples) >= MIN_SAMPLES:
|
||||
samples = drop_outliers(samples, settings.speaker.similarity_threshold)
|
||||
if len(samples) < MIN_SAMPLES:
|
||||
logger.error("%d session(s) valable(s), %d au minimum. Soit dites %d phrases seul avec 'make run' "
|
||||
"puis relancez, soit remontez plus loin : 'make enroll NAME=%s LAST=%d'",
|
||||
len(samples), MIN_SAMPLES, MIN_SAMPLES + 2, name, len(wavs) * 2)
|
||||
return 1
|
||||
|
||||
# Avant d'écrire : ce nouveau profil ressemble-t-il trop à quelqu'un d'autre ?
|
||||
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
|
||||
mean = normalized(np.mean([e for _, e in samples], axis=0))
|
||||
for other in identifier.profiles:
|
||||
score = float(other.embedding @ mean)
|
||||
if other.name != name and score >= settings.speaker.similarity_threshold:
|
||||
logger.warning("attention : ressemble a %s (%.2f) ; meme personne, ou seuil trop bas ?",
|
||||
other.name, score)
|
||||
|
||||
profile = save_profile(settings.speaker.profiles_dir, name, [e for _, e in samples], settings.diarization.model)
|
||||
scores = [float(profile.embedding @ e) for _, e in samples]
|
||||
logger.info("profil %s enregistre : %d sessions, ressemblance %.2f a %.2f -> %s",
|
||||
name, profile.samples, min(scores), max(scores),
|
||||
profile_path(settings.speaker.profiles_dir, name))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Aiguille entre enrôlement, liste et suppression."""
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("wavs", type=Path, nargs="*", help="sessions a utiliser (sinon les --last dernieres)")
|
||||
parser.add_argument("--name", help="nom de la personne a enroler")
|
||||
parser.add_argument("--last", type=int, default=5, help="nombre de sessions recentes de l'ESP32")
|
||||
parser.add_argument("--device", default="esp32-01", help="carte dont on prend les sessions")
|
||||
parser.add_argument("--list", action="store_true", help="affiche les profils")
|
||||
parser.add_argument("--forget", metavar="NOM", help="supprime le profil de NOM")
|
||||
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
settings = load_settings(args.config)
|
||||
profiles_dir = settings.speaker.profiles_dir
|
||||
|
||||
if args.list:
|
||||
profiles = load_profiles(profiles_dir, settings.diarization.model)
|
||||
for profile in profiles:
|
||||
print(f" {profile.name:20s} {profile.samples} sessions, cree le {profile.created}")
|
||||
print(f"{len(profiles)} profil(s) dans {profiles_dir}")
|
||||
return 0
|
||||
if args.forget:
|
||||
path = profile_path(profiles_dir, args.forget)
|
||||
if not path.exists():
|
||||
logger.error("aucun profil %s (%s)", args.forget, path)
|
||||
return 1
|
||||
path.unlink()
|
||||
logger.info("profil %s supprime (%s)", args.forget, path)
|
||||
return 0
|
||||
if not args.name:
|
||||
parser.error("--name, --list ou --forget")
|
||||
|
||||
wavs = args.wavs or sorted(settings.recordings_dir.glob(f"{args.device}_*.wav"))[-args.last:]
|
||||
if not wavs:
|
||||
logger.error("aucune session dans %s : parlez d'abord avec 'make run'", settings.recordings_dir)
|
||||
return 1
|
||||
try:
|
||||
return enroll(settings, args.name, wavs)
|
||||
except (FileNotFoundError, ImportError) as error:
|
||||
logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user