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