Files
Denis Defolie d20976fea2 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>
2026-09-30 17:41:11 +02:00

100 lines
3.8 KiB
Python

"""Envoi de PCM à un ESP32 par MQTT, au rythme réel (étape 5)."""
from __future__ import annotations
import json
import logging
import time
import uuid
from collections.abc import Callable
from typing import Protocol
from voice_server.settings import AudioSettings
logger = logging.getLogger(__name__)
class Publisher(Protocol):
"""Ce dont l'envoi a besoin du lien MQTT : publier vers une carte."""
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
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 aussitôt : c'est l'avance qui protège l'ESP32
des irrégularités du Wi-Fi. 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)]
class AudioSender:
"""Envoie un PCM déjà au format du système : START, chunks, END.
START et END passent sur voice/<carte>/control en QoS 1 (garantis) ;
les chunks sur voice/<carte>/audio/out en QoS 0 (rapides, perte possible,
comptée par l'ESP32 grâce au nombre annoncé dans le END).
L'appel est bloquant pendant toute la durée du son : à lancer dans un fil
dédié, jamais dans le fil réseau de paho.
"""
def __init__(
self,
link: Publisher,
audio: AudioSettings,
prefill_chunks: int = 6,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""
Args:
link: lien MQTT.
audio: format du système.
prefill_chunks: avance envoyée d'un coup ; 6 chunks = 300 ms, la FIFO
de l'ESP32 en contient 512.
sleep, clock: injectables pour les tests.
"""
self._link = link
self._audio = audio
self._prefill = prefill_chunks
self._sleep = sleep
self._clock = clock
@property
def chunk_bytes(self) -> int:
"""Taille d'un chunk : 1600 octets pour 50 ms à 16 kHz."""
return self._audio.bytes_per_second * self._audio.chunk_ms // 1000
def send(self, device: str, pcm: bytes, session: str | None = None) -> str:
"""Envoie le PCM et rend l'identifiant de la session de lecture."""
session_id = session or f"srv-{uuid.uuid4().hex[:6]}"
even = pcm[: len(pcm) // 2 * 2] # jamais de demi-échantillon
size = self.chunk_bytes
chunks = [even[i : i + size] for i in range(0, len(even), size)]
schedule = send_times(len(chunks), self._audio.chunk_ms / 1000.0, self._prefill)
start = {"event": "start", "session": session_id, "rate": self._audio.sample_rate,
"bits": self._audio.bits, "channels": self._audio.channels,
"codec": self._audio.codec, "chunk_ms": self._audio.chunk_ms}
self._link.publish(device, "control", json.dumps(start).encode(), 1)
began = self._clock()
worst_delay = 0.0
for chunk, due in zip(chunks, schedule):
wait = began + due - self._clock()
if wait > 0:
self._sleep(wait)
else:
worst_delay = max(worst_delay, -wait)
self._link.publish(device, "audio/out", chunk, 0)
end = {"event": "end", "session": session_id, "chunks": len(chunks)}
self._link.publish(device, "control", json.dumps(end).encode(), 1)
logger.info("%s : lecture %s envoyee, %d chunks (%.2f s), pire retard d'envoi %.0f ms",
device, session_id, len(chunks), len(even) / self._audio.bytes_per_second,
worst_delay * 1000)
return session_id