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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-09-30 17:40:55 +02:00
commit d20976fea2
108 changed files with 15022 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Qui parle quand, qui est-ce, qui a dit quoi : VAD, diarisation, identification, texte (étapes 8-10).
Une ligne par locuteur, un caractère par 100 ms de parole (« # » : il parle).
Les instants sont ceux de la parole seule, silences retirés par la VAD : c'est
ce que reçoivent la diarisation et Whisper dans le serveur.
--demo fabrique une séquence à deux voix sans seconde personne : votre voix
(le dernier enregistrement de l'ESP32), la voix Piper, puis de nouveau la vôtre.
Elle est écrite dans --out pour l'écouter.
Exemples :
python3 tools/diarize_check.py server/recordings/esp32-01_xxx.wav
python3 tools/diarize_check.py --demo
"""
from __future__ import annotations
import argparse
import logging
import sys
import wave
from pathlib import Path
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, timeline # noqa: E402
from voice_server.settings import Settings, load_settings # noqa: E402
from voice_server.speaker_attribution import attribute, transcript_json # noqa: E402
from voice_server.speaker_identifier import SpeakerIdentifier # noqa: E402
from voice_server.speech_to_text import SpeechToText # noqa: E402
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
logger = logging.getLogger("diarize_check")
def demo_sequence(settings: Settings, vad: VoiceActivityDetector, diarizer: Diarizer,
voice: Path | None) -> bytes:
"""Votre voix, la voix Piper, votre voix : deux locuteurs attendus."""
from voice_server.text_to_speech import TextToSpeech
# Par défaut, la question la plus récente : 1 à 4 s de parole, une seule voix. Les
# sessions plus longues sont souvent des essais à plusieurs voix, que la diarisation
# ne sépare pas toujours.
recordings = [voice] if voice else sorted(settings.recordings_dir.glob("esp32-01_*.wav"), reverse=True)
reader = AudioResampler(settings.audio, None)
second = settings.audio.bytes_per_second
mine = b""
for path in recordings:
mine = vad.detect(reader.from_wav(path)).speech_pcm
if (voice or second <= len(mine) <= 4 * second) and len(diarizer.diarize(mine).speakers) == 1:
logger.info("votre voix : %s", path.name)
break
mine = b""
if not mine:
raise FileNotFoundError(f"aucun enregistrement de l'ESP32 avec de la parole dans {settings.recordings_dir}")
# Même niveau que votre enregistrement, pour que la diarisation juge la voix et non le volume.
tts = TextToSpeech(settings.tts, AudioResampler(settings.audio, -10.0), settings.audio.sample_rate)
piper = tts.synthesize("Bonjour, je suis la voix de synthèse, et je parle entre vos deux phrases.").pcm
pause = bytes(settings.audio.bytes_per_second // 2)
return mine + pause + piper + pause + mine
def main(argv: list[str]) -> int:
"""Diarise le fichier (ou la démonstration) et affiche la chronologie."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("wav", type=Path, nargs="?", help="enregistrement a analyser")
parser.add_argument("--demo", action="store_true", help="sequence a deux voix : vous et Piper")
parser.add_argument("--voice", type=Path, help="votre voix pour --demo (sinon la derniere question)")
parser.add_argument("--out", type=Path, default=Path("recordings/deux-voix.wav"),
help="WAV de la demonstration")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
if (args.wav is None) == (not args.demo):
parser.error("donnez soit un fichier WAV, soit --demo")
logging.basicConfig(level=logging.INFO, format="%(message)s")
settings = load_settings(args.config)
audio = settings.audio
vad = VoiceActivityDetector(settings.vad, audio.sample_rate)
try:
diarizer = Diarizer(settings.diarization, audio.sample_rate)
if args.demo:
pcm = demo_sequence(settings, vad, diarizer, args.voice)
args.out.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(args.out), "wb") as wav:
wav.setnchannels(audio.channels)
wav.setsampwidth(audio.bits // 8)
wav.setframerate(audio.sample_rate)
wav.writeframes(pcm)
logger.info("demonstration : make play OUT=%s", args.out)
else:
pcm = AudioResampler(audio, None).from_wav(args.wav)
except (AudioFormatError, FileNotFoundError, ImportError) as error:
logger.error("%s", error)
return 1
speech = vad.detect(pcm)
if not speech.has_speech:
print("\naucune parole : rien a diariser")
return 0
result = diarizer.diarize(speech.speech_pcm)
print(f"\nparole {speech.speech_s:.2f} s (sur {speech.total_s:.2f} s), "
f"{len(result.speakers)} locuteur(s), diarisation {result.elapsed_s:.2f} s")
for line in timeline(result):
print(f" {line}")
for turn in result.turns:
print(f" {turn.speaker} {turn.start_s:6.2f} -> {turn.end_s:6.2f} s")
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
verdicts = {} if result.embeddings is None else {
speaker: identifier.identify(embedding) for speaker, embedding in zip(result.speakers, result.embeddings)}
for speaker, verdict in verdicts.items():
print(f" {speaker} = {verdict.describe()}")
# Étape 10 : les mots de Whisper répartis entre les locuteurs.
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
transcript = SpeechToText(settings.stt, audio.sample_rate).transcribe(speech.speech_pcm)
utterances = attribute(transcript.words, result.turns, verdicts)
print(f"\nqui a dit quoi (transcription {transcript.elapsed_s:.2f} s) :")
for utterance in utterances:
print(f" {utterance.start_s:5.2f}-{utterance.end_s:5.2f} s {utterance.speaker:10s} « {utterance.text} »")
print("\nmessage MQTT voice/<carte>/transcript :")
print(f" {transcript_json('essai', utterances).decode()}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))