mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +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>
161 lines
6.4 KiB
Python
161 lines
6.4 KiB
Python
#!/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:]))
|