mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 08:21:58 +02:00
d20976fea2
É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>
137 lines
6.1 KiB
Python
137 lines
6.1 KiB
Python
#!/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:]))
|