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:]))
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Télécharge les modèles de config.yaml dans server/models/ (étape 6).
models/whisper/<stt.model>/ modèle faster-whisper (CTranslate2)
models/piper/<tts.voice>.onnx voix Piper et sa configuration .onnx.json
models/pyannote/<nom>/ diarisation (étape 8) : jeton HF_TOKEN dans server/.env
models/openwakeword/*.onnx mots de réveil (étape 13)
Les modèles déjà présents ne sont pas retéléchargés. Ensuite, le serveur
fonctionne sans Internet : il ne lit que ces fichiers locaux.
Exemple :
python3 tools/download_models.py
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
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.settings import ( # noqa: E402
DiarizationSettings,
SttSettings,
TtsSettings,
WakeSettings,
load_settings,
)
from voice_server.wake_word import model_files # noqa: E402
OPENWAKEWORD_URL = "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/{name}"
logger = logging.getLogger("download_models")
def download_whisper(stt: SttSettings) -> None:
"""Télécharge le modèle faster-whisper depuis Hugging Face (Systran)."""
if (stt.model_dir / "model.bin").exists():
logger.info("whisper %s deja present : %s", stt.model, stt.model_dir)
return
from faster_whisper import download_model
logger.info("telechargement de whisper %s vers %s…", stt.model, stt.model_dir)
download_model(stt.model, output_dir=str(stt.model_dir))
def download_piper(tts: TtsSettings) -> None:
"""Télécharge la voix Piper (.onnx et .onnx.json) depuis Hugging Face (rhasspy)."""
config_path = tts.model_path.with_name(tts.model_path.name + ".json")
if tts.model_path.exists() and config_path.exists():
logger.info("voix piper %s deja presente : %s", tts.voice, tts.model_path)
return
from piper.download_voices import download_voice
logger.info("telechargement de la voix piper %s vers %s…", tts.voice, tts.model_path.parent)
tts.model_path.parent.mkdir(parents=True, exist_ok=True)
download_voice(tts.voice, tts.model_path.parent)
def download_pyannote(diarization: DiarizationSettings) -> bool:
"""Télécharge le pipeline de diarisation ; False si le jeton ou l'accord manque."""
if not diarization.enabled:
logger.info("diarisation desactivee dans config.yaml : rien a telecharger")
return True
if (diarization.model_dir / "config.yaml").exists():
logger.info("diarisation %s deja presente : %s", diarization.model, diarization.model_dir)
return True
page = f"https://huggingface.co/{diarization.model}"
token = os.environ.get("HF_TOKEN") # chargé depuis server/.env par load_settings
if not token:
logger.error("HF_TOKEN absent de server/.env. Pour la diarisation :\n"
" 1. compte sur https://huggingface.co, puis accepter les conditions sur %s\n"
" 2. jeton « Read » sur https://huggingface.co/settings/tokens\n"
" 3. cp server/.env.example server/.env, y renseigner HF_TOKEN=..., relancer 'make models'",
page)
return False
from huggingface_hub import snapshot_download
from huggingface_hub.errors import GatedRepoError, HfHubHTTPError
logger.info("telechargement de %s vers %s…", diarization.model, diarization.model_dir)
try:
snapshot_download(diarization.model, local_dir=diarization.model_dir, token=token,
allow_patterns=["config.yaml", "*/pytorch_model.bin", "plda/*.npz"])
except GatedRepoError:
logger.error("acces refuse : acceptez les conditions du modele sur %s", page)
return False
except HfHubHTTPError as error:
logger.error("echec du telechargement (%s) : jeton HF_TOKEN valide ?", error)
return False
return True
def download_openwakeword(wake: WakeSettings) -> None:
"""Télécharge les modèles ONNX d'openWakeWord (communs + un par mot) depuis GitHub."""
if not wake.enabled:
return
from urllib.request import urlretrieve
wake.model_dir.mkdir(parents=True, exist_ok=True)
for name in model_files(wake):
target = wake.model_dir / name
if target.exists():
continue
logger.info("telechargement de %s", name)
urlretrieve(OPENWAKEWORD_URL.format(name=name), target)
logger.info("mots de reveil prets : %s", ", ".join(wake.words))
def main(argv: list[str]) -> int:
"""Télécharge ce qui manque, puis affiche la taille totale."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # une ligne par requête HTTP sinon
settings = load_settings(args.config)
try:
download_whisper(settings.stt)
download_piper(settings.tts)
except ImportError as error:
logger.error("%s : lancez d'abord 'make install-speech'", error)
return 1
download_openwakeword(settings.wake)
diarization_ready = download_pyannote(settings.diarization)
size = sum(f.stat().st_size for f in settings.models_dir.rglob("*") if f.is_file())
logger.info("modeles dans %s (%.0f Mo)", settings.models_dir, size / 1e6)
return 0 if diarization_ready else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+168
View File
@@ -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:]))
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Fait dire une phrase par une voix Piper, en WAV et MP3 : pour la jouer au micro depuis un téléphone.
Sert à tester l'identification (étape 9) et les droits (étape 14) sans seconde
personne. Les voix sont téléchargées au premier usage dans server/models/piper/.
Voix françaises : fr_FR-siwis-medium, fr_FR-tom-medium, fr_FR-upmc-medium
(SPEAKER 0 = Jessica, 1 = Pierre), fr_FR-gilles-low.
Mot de réveil (--wake) : une voix française le dit « à la française » et le
modèle ne le reconnaît pas (score 0,00 mesuré, même en graphie phonétique ;
les rares réussites retombent à 0 au moindre bruit). Il est donc dit par une
voix ANGLAISE (--wake-voice), puis la voix choisie dit la commande. Le serveur
identifie la personne sur la dernière intervention : c'est bien la voix de la
commande qui compte. Mais les 0,5 s d'audio gardées avant la détection
contiennent la fin du mot : une commande d'au moins 2 s garde l'identification
nette (« Ouvre la porte du salon, s'il te plaît. »). Le fichier est vérifié par
le vrai détecteur de mot de réveil.
0,5 s de silence avant et après : le temps d'appuyer sur le bouton, ou de laisser
le mot de réveil être reconnu. Sortie en 16 kHz, comme le micro.
Exemples :
python3 tools/say.py "Oui, je confirme." --voice fr_FR-upmc-medium --speaker 1
python3 tools/say.py "Ouvre la porte du salon, s'il te plaît." --wake "Hey Mycroft"
"""
from __future__ import annotations
import argparse
import logging
import shutil
import subprocess
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 AudioResampler # noqa: E402
from voice_server.settings import Settings, load_settings # noqa: E402
logger = logging.getLogger("say")
PAUSE_AFTER_WAKE_S = 0.6 # pause naturelle entre « Hey Mycroft » et la commande
EDGE_SILENCE_S = 0.5
def synthesize(voices_dir: Path, name: str, text: str, speaker: int | None, resampler: AudioResampler) -> bytes:
"""Texte → PCM 16 kHz, crête -3 dBFS ; télécharge la voix si besoin."""
from piper import PiperVoice, SynthesisConfig
from piper.download_voices import download_voice
model = voices_dir / f"{name}.onnx"
if not model.exists():
logger.info("telechargement de la voix %s (une fois, ~60 Mo)…", name)
voices_dir.mkdir(parents=True, exist_ok=True)
download_voice(name, voices_dir)
voice = PiperVoice.load(model)
import numpy as np
chunks = list(voice.synthesize(text, syn_config=SynthesisConfig(speaker_id=speaker)))
audio = np.concatenate([chunk.audio_float_array for chunk in chunks])
return resampler.convert(audio, chunks[0].sample_rate) # normalisé : les deux voix au même niveau
def check_wake(settings: Settings, pcm: bytes) -> bool:
"""Passe le fichier dans le vrai détecteur du serveur ; False : le mot ne sera pas reconnu."""
try:
from voice_server.wake_word import WakeWordDetector
detector = WakeWordDetector(settings.wake)
except (ImportError, FileNotFoundError) as error:
logger.warning("verification du mot de reveil impossible : %s", error)
return True
chunk = settings.audio.bytes_per_second * settings.audio.chunk_ms // 1000
for start in range(0, len(pcm) - chunk + 1, chunk):
detection = detector.process(pcm[start:start + chunk])
if detection is not None:
logger.info("mot de reveil reconnu : %s (%.2f)", detection.word, detection.score)
return True
logger.error("mot de reveil NON reconnu par le detecteur : essayez une autre --wake-voice")
return False
def main(argv: list[str]) -> int:
"""Synthétise la phrase et écrit WAV (+ MP3 si ffmpeg est présent)."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("text", help="phrase a dire")
parser.add_argument("--voice", default="fr_FR-tom-medium", help="voix Piper")
parser.add_argument("--speaker", type=int, help="locuteur, pour les voix a plusieurs locuteurs")
parser.add_argument("--wake", help="mot de reveil dit avant la phrase, par --wake-voice (ex. 'Hey Mycroft')")
parser.add_argument("--wake-voice", default="en_GB-alan-medium", help="voix anglaise du mot de reveil")
parser.add_argument("--out", type=Path, default=Path("recordings/voix-test/phrase.wav"))
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)
voices_dir = settings.models_dir / "piper"
resampler = AudioResampler(settings.audio)
rate = settings.audio.sample_rate
try:
pieces = []
if args.wake:
pieces.append(synthesize(voices_dir, args.wake_voice, args.wake, None, resampler))
pieces.append(bytes(2 * int(PAUSE_AFTER_WAKE_S * rate)))
pieces.append(synthesize(voices_dir, args.voice, args.text, args.speaker, resampler))
except ImportError as error:
logger.error("%s : lancez 'make install-speech'", error)
return 1
edge = bytes(2 * int(EDGE_SILENCE_S * rate))
pcm = edge + b"".join(pieces) + edge
args.out.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(args.out), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(rate)
wav.writeframes(pcm)
said = f"{args.wake} ({args.wake_voice}), {args.text} ({args.voice})" if args.wake else f"{args.text} ({args.voice})"
logger.info("« %s », %.1f s -> %s", said, len(pcm) / 2 / rate, args.out)
if args.wake and not check_wake(settings, pcm):
return 1
if shutil.which("ffmpeg"):
mp3 = args.out.with_suffix(".mp3")
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(args.out), "-codec:a", "libmp3lame",
"-q:a", "2", str(mp3)], check=True)
logger.info("pour le telephone : %s", mp3)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+165
View File
@@ -0,0 +1,165 @@
"""Protocole série partagé entre le PC et l'ESP32 (étapes 1 et 2).
Même format dans les deux sens, et identique à firmware/src/SerialProtocol.h :
"VSA1" | type (1 octet) | length (uint16 LE) | payload | checksum (XOR, 1 octet)
Les lignes de texte de l'ESP32 (commençant par « # ») peuvent s'intercaler entre
les trames : le décodeur se resynchronise sur le mot magique.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import IntEnum
from typing import Iterator, Protocol
logger = logging.getLogger("serial_protocol")
MAGIC = b"VSA1"
HEADER_SIZE = 7 # magic (4) + type (1) + length (2)
MAX_PAYLOAD = 2048 # garde-fou, identique au firmware : un chunk légitime fait 1600 octets
class FrameType(IntEnum):
"""Types de trames."""
START = 1
AUDIO = 2
END = 3
@dataclass(frozen=True)
class Frame:
"""Trame décodée."""
type: FrameType
payload: bytes
@dataclass(frozen=True)
class AudioFormat:
"""Format audio annoncé par la trame START."""
sample_rate: int
bits: int
channels: int
@classmethod
def from_payload(cls, payload: bytes) -> "AudioFormat":
"""Décode le descripteur de 8 octets de la trame START."""
if len(payload) != 8:
raise ValueError(f"descripteur START invalide ({len(payload)} octets)")
sample_rate = int.from_bytes(payload[0:4], "little")
bits = int.from_bytes(payload[4:6], "little")
channels = int.from_bytes(payload[6:8], "little")
return cls(sample_rate=sample_rate, bits=bits, channels=channels)
def to_payload(self) -> bytes:
"""Encode le descripteur de 8 octets de la trame START."""
return (
self.sample_rate.to_bytes(4, "little")
+ self.bits.to_bytes(2, "little")
+ self.channels.to_bytes(2, "little")
)
def encode_frame(frame_type: FrameType, payload: bytes = b"") -> bytes:
"""Construit une trame complète, prête à écrire sur le port série."""
if len(payload) > MAX_PAYLOAD:
raise ValueError(f"payload de {len(payload)} octets, maximum {MAX_PAYLOAD}")
checksum = 0
for byte in payload:
checksum ^= byte
header = MAGIC + bytes([int(frame_type)]) + len(payload).to_bytes(2, "little")
return header + payload + bytes([checksum])
class ByteSource(Protocol):
"""Ce dont le décodeur a besoin d'un port série : lire des octets."""
def read(self, size: int = 1) -> bytes: ...
class TextEcho:
"""Affiche ligne par ligne le texte émis par l'ESP32."""
def __init__(self) -> None:
self._pending = bytearray()
def feed(self, data: bytes) -> None:
"""Ajoute des octets de texte ; journalise chaque ligne complète."""
self._pending.extend(data)
while True:
end = self._pending.find(b"\n")
if end < 0:
break
line = bytes(self._pending[:end]).decode("utf-8", errors="replace").strip()
del self._pending[: end + 1]
if line:
logger.info("esp32 | %s", line)
class FrameReader:
"""Décode le flux série en trames, en tolérant du texte intercalé."""
def __init__(self, port: ByteSource) -> None:
self._port = port
self._buffer = bytearray()
self._echo = TextEcho()
def frames(self) -> Iterator[Frame]:
"""Produit les trames au fil de leur arrivée (générateur infini)."""
while True:
chunk = self._port.read(1024)
if chunk:
self._buffer.extend(chunk)
yield from self._drain()
def _drain(self) -> Iterator[Frame]:
"""Extrait toutes les trames complètes présentes dans le tampon."""
while True:
index = self._buffer.find(MAGIC)
if index < 0:
# Pas de mot magique : tout est du texte, sauf les derniers octets
# qui pourraient être un mot magique coupé en deux lectures.
keep = len(MAGIC) - 1
if len(self._buffer) > keep:
self._echo.feed(bytes(self._buffer[:-keep]))
del self._buffer[:-keep]
return
if index > 0:
self._echo.feed(bytes(self._buffer[:index]))
del self._buffer[:index]
if len(self._buffer) < HEADER_SIZE:
return # en-tête incomplet : on attend la suite
raw_type = self._buffer[4]
length = int.from_bytes(self._buffer[5:7], "little")
if raw_type not in (int(t) for t in FrameType) or length > MAX_PAYLOAD:
# Fausse détection : on avance d'un octet et on cherche à nouveau.
del self._buffer[:1]
continue
total = HEADER_SIZE + length + 1 # + checksum
if len(self._buffer) < total:
return # trame incomplète : on attend la suite
payload = bytes(self._buffer[HEADER_SIZE : HEADER_SIZE + length])
checksum = self._buffer[total - 1]
computed = 0
for byte in payload:
computed ^= byte
if checksum != computed:
logger.warning("checksum invalide, trame ignoree")
del self._buffer[:1]
continue
del self._buffer[:total]
yield Frame(type=FrameType(raw_type), payload=payload)
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Reçoit une session audio de l'ESP32 par le port série et écrit un fichier WAV.
Outil de validation de l'étape 1 : il ne dépend ni de MQTT ni du serveur vocal.
Format des trames : voir tools/serial_protocol.py (identique au firmware).
Les lignes de texte émises par l'ESP32 (messages commençant par « # ») sont
affichées telles quelles : le lecteur se resynchronise sur le mot magique.
Exemple :
python3 tools/serial_to_wav.py --port /dev/ttyUSB0 --baud 921600 \
--output recordings/test.wav
"""
from __future__ import annotations
import argparse
import logging
import math
import sys
import wave
from array import array
from dataclasses import dataclass
from pathlib import Path
import serial
from serial_protocol import AudioFormat, FrameReader, FrameType
logger = logging.getLogger("serial_to_wav")
class WavSessionRecorder:
"""Assemble les chunks PCM d'une session et les écrit dans un fichier WAV."""
def __init__(self, output: Path) -> None:
self._output = output
self._chunks: list[bytes] = []
self._format: AudioFormat | None = None
def begin(self, audio_format: AudioFormat) -> None:
"""Démarre une session avec le format annoncé."""
self._format = audio_format
self._chunks = []
logger.info(
"session ouverte : %d Hz, %d bits, %d canal/canaux",
audio_format.sample_rate,
audio_format.bits,
audio_format.channels,
)
def add(self, pcm: bytes) -> None:
"""Ajoute un chunk PCM à la session en cours."""
self._chunks.append(pcm)
@property
def active(self) -> bool:
"""true si une session est ouverte."""
return self._format is not None
@property
def byte_count(self) -> int:
"""Nombre d'octets PCM accumulés."""
return sum(len(chunk) for chunk in self._chunks)
def finish(self) -> Path:
"""Écrit le fichier WAV et retourne son chemin."""
if self._format is None:
raise RuntimeError("aucune session ouverte")
pcm = b"".join(self._chunks)
duration = self.byte_count / (
self._format.sample_rate * self._format.channels * self._format.bits / 8
)
self._output.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(self._output), "wb") as wav:
wav.setnchannels(self._format.channels)
wav.setsampwidth(self._format.bits // 8)
wav.setframerate(self._format.sample_rate)
wav.writeframes(pcm)
logger.info("ecrit %s : %d octets, %.2f s", self._output, len(pcm), duration)
diagnose(measure_levels(pcm))
self._format = None
return self._output
@dataclass(frozen=True)
class LevelReport:
"""Mesures de niveau d'un flux PCM, normalisées dans [0, 1]."""
peak: float # crête absolue
rms: float # valeur efficace (perception du volume)
dc: float # composante continue résiduelle
def measure_levels(pcm: bytes) -> LevelReport:
"""Mesure crête, valeur efficace et offset continu d'un flux PCM s16le."""
samples = array("h")
samples.frombytes(pcm[: len(pcm) - (len(pcm) % 2)])
if sys.byteorder == "big":
samples.byteswap() # le flux est little-endian par convention
if len(samples) == 0:
return LevelReport(peak=0.0, rms=0.0, dc=0.0)
peak = max(max(samples), -min(samples))
total = 0
squares = 0
for sample in samples:
total += sample
squares += sample * sample
count = len(samples)
return LevelReport(
peak=peak / 32768.0,
rms=math.sqrt(squares / count) / 32768.0,
dc=(total / count) / 32768.0,
)
def _dbfs(value: float) -> str:
"""Convertit un niveau normalisé en décibels pleine échelle."""
if value <= 0.0:
return "-inf dBFS"
return f"{20.0 * math.log10(value):+.1f} dBFS"
def suggested_gain_factor(peak: float, target: float = 0.25) -> int:
"""Facteur (puissance de 2) à appliquer a MIC_GAIN pour viser `target` de crête."""
if peak <= 0.0:
return 0
exponent = round(math.log2(target / peak))
return int(min(64, max(2, 2**exponent)))
def diagnose(level: LevelReport) -> None:
"""Journalise le niveau mesuré et le correctif à appliquer."""
logger.info(
"niveau : crete %.2f %% (%s) | RMS %s | offset continu %+.3f %%",
level.peak * 100.0,
_dbfs(level.peak),
_dbfs(level.rms),
level.dc * 100.0,
)
if level.peak <= 0.0:
logger.error("AUCUN signal : tous les echantillons sont nuls.")
logger.error(" -> le micro ne transmet rien. Verifiez dans cet ordre :")
logger.error(" 1. L/R de l'INMP441 relie a GND (sinon silence total)")
logger.error(" 2. SD du micro sur GPIO 33, SCK sur 25, WS sur 26")
logger.error(" 3. VDD du micro sur 3V3 (jamais 5 V) et GND commun")
return
if level.peak < 0.02:
factor = suggested_gain_factor(level.peak)
logger.warning(
"niveau tres faible : multipliez MIC_GAIN par %d dans firmware/include/config.h,",
factor,
)
logger.warning("puis 'make fw-upload'. Parlez a 20-30 cm du micro.")
return
if level.peak > 0.99:
logger.warning("SATURATION : divisez MIC_GAIN par 2 dans firmware/include/config.h.")
return
if abs(level.dc) > 0.02:
logger.warning(
"offset continu residuel eleve (%+.2f %%) : le micro vient peut-etre de demarrer.",
level.dc * 100.0,
)
logger.info("niveau correct (cible : crete entre 10 % et 90 %).")
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Analyse la ligne de commande."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", default="/dev/ttyUSB0", help="port serie de l'ESP32")
parser.add_argument("--baud", type=int, default=921600, help="debit serie")
parser.add_argument(
"--output", type=Path, default=Path("recordings/test.wav"), help="fichier WAV de sortie"
)
parser.add_argument(
"--sessions", type=int, default=1, help="nombre de sessions a enregistrer avant de quitter"
)
parser.add_argument("--verbose", action="store_true", help="journalisation detaillee")
return parser.parse_args(argv)
def output_path(base: Path, index: int, total: int) -> Path:
"""Numérote le fichier de sortie si plusieurs sessions sont demandées."""
if total <= 1:
return base
return base.with_name(f"{base.stem}-{index:02d}{base.suffix}")
def main(argv: list[str]) -> int:
"""Point d'entrée : lit le port série jusqu'à la fin des sessions demandées."""
args = parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S",
)
logger.info("ouverture de %s a %d baud", args.port, args.baud)
try:
port = serial.Serial(args.port, args.baud, timeout=0.2)
except serial.SerialException as error:
logger.error("port serie inaccessible : %s", error)
return 1
recorder: WavSessionRecorder | None = None
completed = 0
logger.info("en attente d'une session : appuyez sur le bouton PTT et parlez.")
try:
with port:
for frame in FrameReader(port).frames():
if frame.type is FrameType.START:
recorder = WavSessionRecorder(
output_path(args.output, completed + 1, args.sessions)
)
recorder.begin(AudioFormat.from_payload(frame.payload))
elif frame.type is FrameType.AUDIO:
if recorder is None or not recorder.active:
logger.debug("chunk recu hors session, ignore")
continue
recorder.add(frame.payload)
elif frame.type is FrameType.END:
if recorder is None or not recorder.active:
continue
recorder.finish()
recorder = None
completed += 1
if completed >= args.sessions:
return 0
logger.info("en attente de la session suivante.")
except KeyboardInterrupt:
logger.info("interruption clavier")
if recorder is not None and recorder.active and recorder.byte_count > 0:
recorder.finish()
return 0
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Vérifie VAD, transcription, réponse et synthèse sans ESP32 ni broker (étapes 6-7).
Deux usages :
texte → Piper → Whisper → Assistant → Piper (la chaîne se relit elle-même)
WAV → VAD → Whisper → Assistant → Piper (un enregistrement de l'ESP32, comme le serveur)
La réponse synthétisée est écrite dans --out, à écouter avec « make play ».
Exemples :
python3 tools/speech_check.py "Quelle heure est-il ?"
python3 tools/speech_check.py --wav server/recordings/esp32-01_xxx.wav
"""
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.assistant import Assistant # noqa: E402
from voice_server.audio_resampler import AudioFormatError, AudioResampler # noqa: E402
from voice_server.settings import load_settings # noqa: E402
from voice_server.speech_to_text import SpeechToText # noqa: E402
from voice_server.text_to_speech import TextToSpeech # noqa: E402
from voice_server.voice_activity import VoiceActivityDetector # noqa: E402
logger = logging.getLogger("speech_check")
def main(argv: list[str]) -> int:
"""Déroule la chaîne et affiche le temps de chaque maillon."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("text", nargs="?", help="phrase a synthetiser puis transcrire")
parser.add_argument("--wav", type=Path, help="enregistrement a transcrire")
parser.add_argument("--out", type=Path, default=Path("recordings/reponse.wav"),
help="WAV de la reponse synthetisee")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
if (args.text is None) == (args.wav is None):
parser.error("donnez soit une phrase, soit --wav")
logging.basicConfig(level=logging.INFO, format="%(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
settings = load_settings(args.config)
audio = settings.audio
try:
tts = TextToSpeech(settings.tts, AudioResampler(audio), audio.sample_rate)
stt = SpeechToText(settings.stt, audio.sample_rate)
except FileNotFoundError as error:
logger.error("%s", error)
return 1
if args.wav is not None:
try:
# Sans normalisation : Whisper doit entendre le niveau réel du micro.
pcm = AudioResampler(audio, None).from_wav(args.wav)
except (AudioFormatError, FileNotFoundError) as error:
logger.error("%s", error)
return 1
vad = VoiceActivityDetector(settings.vad, audio.sample_rate).detect(pcm)
logger.info("VAD : parole %.2f s sur %.2f s, segments %s", vad.speech_s, vad.total_s,
f"{vad.bounds()} s" if vad.has_speech else "aucun")
pcm = vad.speech_pcm # sans parole, Whisper reçoit un audio vide et ne rend rien
else:
spoken = tts.synthesize(args.text)
logger.info("synthese de la question : %.2f s de calcul pour %.2f s de parole",
spoken.elapsed_s, spoken.audio_s)
pcm = spoken.pcm
transcript = stt.transcribe(pcm)
logger.info("transcription : %.2f s de calcul pour %.2f s d'audio", transcript.elapsed_s, transcript.audio_s)
if transcript.dropped:
logger.warning("hallucination ecartee : %s", " | ".join(transcript.dropped))
answer = Assistant().reply(transcript.text)
speech = tts.synthesize(answer)
logger.info("synthese de la reponse : %.2f s de calcul pour %.2f s de parole",
speech.elapsed_s, speech.audio_s)
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(speech.pcm)
print(f"\n entendu : « {transcript.text} »")
print(f" reponse : « {answer} »")
print(f" delai : {transcript.elapsed_s + speech.elapsed_s:.2f} s de calcul avant le premier son")
print(f" ecoute : make play OUT={args.out}\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Montre ce que la VAD (Silero) trouve dans des enregistrements (étape 7).
Pour chaque WAV : une lettre par fenêtre de 32 ms (« # » parole, « . » hésitation,
« _ » silence), les segments gardés et la part de silence retirée.
Avec un seul fichier, la parole seule est écrite dans --out, à écouter avec « make play ».
Exemples :
python3 tools/vad_check.py server/recordings/esp32-01_xxx.wav
python3 tools/vad_check.py server/recordings/*.wav
"""
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.settings import load_settings # noqa: E402
from voice_server.voice_activity import VoiceActivityDetector, envelope # noqa: E402
logger = logging.getLogger("vad_check")
def main(argv: list[str]) -> int:
"""Analyse chaque fichier et affiche un bilan."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("wavs", type=Path, nargs="+", help="enregistrements a analyser")
parser.add_argument("--out", type=Path, default=Path("recordings/parole.wav"),
help="parole seule (un seul fichier analyse)")
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)
audio = settings.audio
detector = VoiceActivityDetector(settings.vad, audio.sample_rate)
reader = AudioResampler(audio, None) # niveau d'origine : la VAD voit ce que voit le serveur
without_speech = 0
last = None
for path in args.wavs:
try:
result = detector.detect(reader.from_wav(path))
except (AudioFormatError, FileNotFoundError) as error:
logger.error("%s", error)
continue
last = result
print(f"\n{path.name}")
print(f" {envelope(result.probabilities, settings.vad.threshold)}")
if result.has_speech:
removed = 100.0 * (1.0 - result.speech_s / result.total_s) if result.total_s else 0.0
print(f" parole {result.speech_s:.2f} s sur {result.total_s:.2f} s "
f"({removed:.0f} % retire), segments {result.bounds()} s, "
f"calcul {result.elapsed_s * 1000:.0f} ms")
else:
without_speech += 1
print(f" AUCUNE PAROLE ({result.total_s:.2f} s) : la session ne sera pas transcrite")
print(f"\n{len(args.wavs)} fichier(s), {without_speech} sans parole")
if len(args.wavs) == 1 and last is not None and last.has_speech:
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(last.speech_pcm)
print(f"parole seule : make play OUT={args.out}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Analyse un fichier WAV et affiche son enveloppe en ASCII.
Permet de valider un enregistrement **sans écouter** : indispensable sur une
machine sans sortie audio, et plus tard sur un Raspberry Pi sans écran.
Exemple :
python3 tools/wav_inspect.py recordings/essai.wav
"""
from __future__ import annotations
import argparse
import logging
import math
import sys
import wave
from array import array
from pathlib import Path
from serial_to_wav import LevelReport, measure_levels
logger = logging.getLogger("wav_inspect")
# Du plus faible au plus fort : huit niveaux de remplissage.
BLOCKS = " ▁▂▃▄▅▆▇█"
# Plage affichée par l'enveloppe, en décibels pleine échelle.
FLOOR_DBFS = -60.0
# Un segment dépassant le plancher de bruit de cette marge est considéré parlé.
SPEECH_MARGIN_DB = 12.0
def read_pcm(path: Path) -> tuple[bytes, int]:
"""Lit un WAV PCM 16 bits mono et retourne (octets, fréquence)."""
with wave.open(str(path), "rb") as wav:
if wav.getsampwidth() != 2:
raise ValueError(f"{path} : {wav.getsampwidth() * 8} bits, 16 attendus")
if wav.getnchannels() != 1:
raise ValueError(f"{path} : {wav.getnchannels()} canaux, 1 attendu")
return wav.readframes(wav.getnframes()), wav.getframerate()
def column_peaks(pcm: bytes, columns: int) -> list[float]:
"""Découpe le flux en `columns` tranches et retourne la crête de chacune."""
samples = array("h")
samples.frombytes(pcm[: len(pcm) - (len(pcm) % 2)])
if sys.byteorder == "big":
samples.byteswap()
total = len(samples)
if total == 0 or columns <= 0:
return []
peaks: list[float] = []
for index in range(columns):
start = (index * total) // columns
stop = max(start + 1, ((index + 1) * total) // columns)
window = samples[start:stop]
peak = max(max(window), -min(window))
peaks.append(peak / 32768.0)
return peaks
def to_dbfs(value: float) -> float:
"""Convertit un niveau normalisé en dBFS, borné au plancher d'affichage."""
if value <= 0.0:
return FLOOR_DBFS
return max(FLOOR_DBFS, 20.0 * math.log10(value))
def render(peaks: list[float]) -> str:
"""Dessine l'enveloppe : un caractère par tranche, échelle logarithmique."""
glyphs: list[str] = []
for peak in peaks:
ratio = (to_dbfs(peak) - FLOOR_DBFS) / (0.0 - FLOOR_DBFS)
index = int(round(ratio * (len(BLOCKS) - 1)))
glyphs.append(BLOCKS[min(len(BLOCKS) - 1, max(0, index))])
return "".join(glyphs)
def speech_ratio(peaks: list[float]) -> float:
"""Proportion de tranches nettement au-dessus du plancher de bruit."""
if not peaks:
return 0.0
quiet = min(to_dbfs(peak) for peak in peaks)
loud = [peak for peak in peaks if to_dbfs(peak) > quiet + SPEECH_MARGIN_DB]
return len(loud) / len(peaks)
def describe(level: LevelReport, peaks: list[float], duration: float) -> None:
"""Journalise le verdict sur l'enregistrement."""
ratio = speech_ratio(peaks)
logger.info("duree %.2f s | crete %.1f %% | RMS %.1f %% | offset %+.3f %%",
duration, level.peak * 100.0, level.rms * 100.0, level.dc * 100.0)
logger.info("tranches actives : %.0f %% de la duree", ratio * 100.0)
if level.peak <= 0.0:
logger.error("fichier totalement silencieux.")
elif level.peak > 0.99:
logger.warning("saturation : diminuez MIC_GAIN.")
elif level.peak < 0.02:
logger.warning("niveau tres faible : augmentez MIC_GAIN.")
elif ratio < 0.05:
logger.warning("aucune variation nette : bruit de fond plutot que parole ?")
elif ratio > 0.95:
logger.warning("signal actif partout : bruit continu plutot que parole ?")
else:
logger.info("alternance silence / parole coherente avec une phrase.")
def main(argv: list[str]) -> int:
"""Point d'entrée : analyse le fichier passé en argument."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("wav", type=Path, help="fichier WAV a analyser")
parser.add_argument("--columns", type=int, default=64, help="largeur de l'enveloppe")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
try:
pcm, rate = read_pcm(args.wav)
except (OSError, wave.Error, ValueError) as error:
logger.error("lecture impossible : %s", error)
return 1
duration = len(pcm) / (2.0 * rate) if rate else 0.0
peaks = column_peaks(pcm, args.columns)
print(f"\n{args.wav} ({rate} Hz, 16 bits, mono)")
print(f" 0 dBFS |{'-' * len(peaks)}|")
print(f" |{render(peaks)}|")
print(f"{FLOOR_DBFS:.0f} dBFS |{'-' * len(peaks)}|")
print(f" 0 s{' ' * max(0, len(peaks) - 8)}{duration:.2f} s\n")
sys.stdout.flush() # le dessin doit sortir avant les lignes de journalisation
describe(measure_levels(pcm), peaks, duration)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Envoie un WAV (ou un son pur) à un ESP32 par MQTT, et affiche le bilan de lecture.
Outil de validation de l'étape 5. Il réutilise les classes du serveur :
AudioResampler (mise au format, crête à -3 dBFS) et AudioSender (rythme réel).
N'importe quel WAV PCM convient : il est converti en 16 kHz mono.
Étape 12 : --jitter MS simule un réseau irrégulier. Un chunk sur dix (toutes les
500 ms) est retenu MS millisecondes, comme lors d'un blocage Wi-Fi, puis l'envoi
rattrape son retard en rafale. Le bilan de la carte donne alors la marge minimale
(≈ 300 - MS ms) et le nombre de réamorçages (0 tant que MS < 300 ms).
Exemples :
python3 tools/wav_to_mqtt.py server/recordings/esp32-01_xxx.wav
python3 tools/wav_to_mqtt.py --tone 440 --seconds 3
python3 tools/wav_to_mqtt.py --tone 440 --seconds 5 --jitter 200
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import logging
import sys
import threading
import time
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, peak_dbfs # noqa: E402
from voice_server.audio_sender import AudioSender, Publisher # noqa: E402
from voice_server.mqtt_link import MqttLink # noqa: E402
from voice_server.settings import load_settings # noqa: E402
logger = logging.getLogger("wav_to_mqtt")
STALL_EVERY = 10 # un chunk sur dix : un blocage toutes les 500 ms
class JitteryLink:
"""Lien MQTT qui retient certains chunks audio : banc d'essai du tampon anti-gigue."""
def __init__(self, link: Publisher, stall_ms: float) -> None:
self._link = link
self._stall_s = stall_ms / 1000.0
self._chunks = 0
self.stalls = 0
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
if suffix == "audio/out":
self._chunks += 1
if self._stall_s > 0 and self._chunks % STALL_EVERY == 0:
time.sleep(self._stall_s) # blocage : les chunks suivants partiront en rafale
self.stalls += 1
self._link.publish(device, suffix, payload, qos)
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Analyse la ligne de commande."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("wav", type=Path, nargs="?", help="fichier WAV a jouer")
parser.add_argument("--device", default="esp32-01", help="carte destinataire")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
parser.add_argument("--tone", type=float, help="son pur de cette frequence (Hz)")
parser.add_argument("--seconds", type=float, default=3.0, help="duree du son pur")
parser.add_argument("--no-normalize", action="store_true", help="garder le niveau d'origine")
parser.add_argument("--jitter", type=float, default=0.0,
help="retient un chunk sur dix pendant ce nombre de ms (etape 12)")
args = parser.parse_args(argv)
if (args.wav is None) == (args.tone is None):
parser.error("donnez soit un fichier WAV, soit --tone")
return args
def main(argv: list[str]) -> int:
"""Envoie l'audio, puis attend le bilan publié par la carte."""
args = parse_args(argv)
settings = load_settings(args.config)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S")
resampler = AudioResampler(settings.audio, None if args.no_normalize else -3.0)
try:
if args.tone is not None:
t = np.arange(int(settings.audio.sample_rate * args.seconds)) / settings.audio.sample_rate
fade = np.minimum(1.0, np.minimum(t, t[-1] - t) / 0.01) # fondus de 10 ms
pcm = resampler.convert(np.sin(2 * np.pi * args.tone * t) * fade, settings.audio.sample_rate)
else:
pcm = resampler.from_wav(args.wav)
except (OSError, AudioFormatError) as error:
logger.error("%s", error)
return 1
logger.info("audio pret : %.2f s, crete %.1f dBFS", len(pcm) / settings.audio.bytes_per_second,
peak_dbfs(pcm))
# Client distinct du serveur : deux clients ne doivent jamais partager un identifiant.
link = MqttLink(dataclasses.replace(settings.mqtt, client_id="voice-wav-to-mqtt"))
report = threading.Event()
def on_event(device: str, payload: bytes) -> None:
if device != args.device:
return
event = json.loads(payload)
if event.get("event") in ("played", "rejected"):
logger.info("bilan de %s : %s", device, event)
report.set()
link.on("event", on_event)
link.start()
if not link.wait_connected(5.0):
logger.error("broker %s:%d injoignable", settings.mqtt.host, settings.mqtt.port)
link.stop()
return 1
sender_link = JitteryLink(link, args.jitter)
AudioSender(sender_link, settings.audio).send(args.device, pcm)
if args.jitter > 0:
logger.info("gigue simulee : %d blocages de %.0f ms ; marge attendue ~%.0f ms, reamorcage si <= 0",
sender_link.stalls, args.jitter, 300 - args.jitter)
if not report.wait(5.0):
logger.warning("aucun bilan de %s : carte hors ligne, ou firmware anterieur a l'etape 5",
args.device)
link.stop()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Envoie de l'audio à l'ESP32 par le port série, pour lecture sur le haut-parleur.
Outil de validation de l'étape 2. Deux sources possibles :
- un fichier WAV 16 kHz, 16 bits, mono (par exemple un enregistrement de l'étape 1) ;
- un son pur généré ici (--tone) : la moindre coupure s'y entend comme un clic.
L'envoi suit le **rythme réel** : l'ESP32 ne peut pas jouer plus vite que 16 000
échantillons par seconde, et sa FIFO déborderait si on lui envoyait tout d'un coup.
On envoie d'abord une avance de quelques chunks (le « préremplissage »), puis un
chunk toutes les 50 ms.
Exemples :
python3 tools/wav_to_serial.py --port /dev/ttyUSB0 recordings/essai.wav
python3 tools/wav_to_serial.py --port /dev/ttyUSB0 --tone 440 --seconds 3
"""
from __future__ import annotations
import argparse
import logging
import math
import sys
import time
import wave
from pathlib import Path
import serial
from serial_protocol import AudioFormat, FrameType, TextEcho, encode_frame
logger = logging.getLogger("wav_to_serial")
EXPECTED = AudioFormat(sample_rate=16000, bits=16, channels=1)
CHUNK_MS = 50 # identique au firmware : 800 échantillons = 1600 octets
PREFILL_CHUNKS = 4 # 200 ms d'avance pour absorber les irrégularités d'envoi
class FormatError(ValueError):
"""Le fichier n'est pas dans le format unique du système."""
def read_wav(path: Path) -> bytes:
"""Lit un WAV et vérifie qu'il est en 16 kHz, 16 bits, mono."""
with wave.open(str(path), "rb") as wav:
found = AudioFormat(wav.getframerate(), wav.getsampwidth() * 8, wav.getnchannels())
if found != EXPECTED:
raise FormatError(
f"{path} : {found.sample_rate} Hz, {found.bits} bits, {found.channels} canal(aux) ; "
"attendu 16000 Hz, 16 bits, mono. Conversion : "
f"ffmpeg -i {path} -ar 16000 -ac 1 -sample_fmt s16 converti.wav"
)
return wav.readframes(wav.getnframes())
def tone_pcm(frequency: float, seconds: float, amplitude: float = 0.25,
rate: int = EXPECTED.sample_rate) -> bytes:
"""Génère un son pur en PCM s16le, avec fondus de 10 ms pour éviter les clics."""
count = int(rate * seconds)
fade = max(1, rate // 100)
out = bytearray()
for n in range(count):
gain = min(1.0, n / fade, (count - 1 - n) / fade)
value = amplitude * gain * math.sin(2.0 * math.pi * frequency * n / rate)
out += int(round(value * 32767)).to_bytes(2, "little", signed=True)
return bytes(out)
def split_chunks(pcm: bytes, chunk_bytes: int) -> list[bytes]:
"""Découpe le PCM en chunks ; le dernier peut être plus court, jamais impair."""
even = pcm[: len(pcm) - (len(pcm) % 2)] # un octet orphelin serait un demi-échantillon
return [even[i : i + chunk_bytes] for i in range(0, len(even), chunk_bytes)]
def send_times(count: int, chunk_s: float, prefill: int) -> list[float]:
"""Instants d'envoi de chaque chunk, en secondes depuis le début.
Les `prefill` premiers partent immédiatement ; chaque suivant part quand
l'ESP32 a joué l'équivalent d'un chunk, ce qui maintient l'avance constante.
"""
return [max(0.0, (index - prefill + 1) * chunk_s) for index in range(count)]
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Analyse la ligne de commande."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("wav", type=Path, nargs="?", help="fichier WAV 16 kHz mono a jouer")
parser.add_argument("--port", default="/dev/ttyUSB0", help="port serie de l'ESP32")
parser.add_argument("--baud", type=int, default=921600, help="debit serie")
parser.add_argument("--tone", type=float, help="joue un son pur de cette frequence (Hz)")
parser.add_argument("--seconds", type=float, default=3.0, help="duree du son pur")
parser.add_argument("--amplitude", type=float, default=0.25,
help="amplitude du son pur, 0..1 (0.25 = -12 dBFS)")
args = parser.parse_args(argv)
if (args.wav is None) == (args.tone is None):
parser.error("donnez soit un fichier WAV, soit --tone")
return args
def main(argv: list[str]) -> int:
"""Point d'entrée : envoie l'audio au rythme réel, puis affiche le bilan de l'ESP32."""
args = parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S")
try:
if args.tone is not None:
pcm = tone_pcm(args.tone, args.seconds, args.amplitude)
logger.info("son pur %.0f Hz, %.1f s, amplitude %.0f %%", args.tone, args.seconds,
args.amplitude * 100)
else:
pcm = read_wav(args.wav)
logger.info("fichier %s : %.2f s", args.wav, len(pcm) / 32000)
except (OSError, wave.Error, FormatError) as error:
logger.error("%s", error)
return 1
chunk_bytes = EXPECTED.sample_rate * CHUNK_MS // 1000 * 2
chunks = split_chunks(pcm, chunk_bytes)
schedule = send_times(len(chunks), CHUNK_MS / 1000.0, PREFILL_CHUNKS)
try:
port = serial.Serial(args.port, args.baud, timeout=0, write_timeout=1)
except serial.SerialException as error:
logger.error("port serie inaccessible : %s", error)
return 1
echo = TextEcho()
with port:
port.write(encode_frame(FrameType.START, EXPECTED.to_payload()))
start = time.monotonic()
late = 0.0
for chunk, due in zip(chunks, schedule):
wait = start + due - time.monotonic()
if wait > 0:
time.sleep(wait)
else:
late = max(late, -wait)
port.write(encode_frame(FrameType.AUDIO, chunk))
echo.feed(port.read(port.in_waiting or 0))
port.write(encode_frame(FrameType.END))
logger.info("envoi termine : %d chunks, pire retard d'envoi %.0f ms", len(chunks), late * 1000)
# L'ESP32 joue encore son avance, puis affiche son bilan : on l'attend.
deadline = time.monotonic() + PREFILL_CHUNKS * CHUNK_MS / 1000.0 + 1.0
while time.monotonic() < deadline:
echo.feed(port.read(port.in_waiting or 0))
time.sleep(0.05)
echo.feed(b"\n")
if late * 1000 > PREFILL_CHUNKS * CHUNK_MS:
logger.warning("le PC a pris plus de retard que l'avance de l'ESP32 : coupure probable")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))