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