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,253 @@
|
||||
"""Reconstitution des sessions de parole reçues des ESP32 : START, chunks, END."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from voice_server.audio_buffer import AudioBuffer, Levels
|
||||
from voice_server.settings import AudioSettings, SessionSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Outcome(Enum):
|
||||
"""Façon dont une session s'est terminée."""
|
||||
|
||||
COMPLETE = "complete" # END reçu, tous les chunks annoncés sont arrivés
|
||||
LOST_CHUNKS = "lost_chunks" # END reçu, mais des chunks manquent (QoS 0 : perte réseau)
|
||||
TIMEOUT = "timeout" # jamais de END : l'ESP32 a disparu en cours de session
|
||||
INTERRUPTED = "interrupted" # un nouveau START est arrivé avant le END
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionResult:
|
||||
"""Bilan d'une session terminée."""
|
||||
|
||||
device: str
|
||||
session_id: str
|
||||
outcome: Outcome
|
||||
path: Path
|
||||
duration_s: float
|
||||
chunks_received: int
|
||||
chunks_announced: int | None # None si aucun END n'a été reçu
|
||||
levels: Levels
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Session:
|
||||
"""Session en cours de réception."""
|
||||
|
||||
device: str
|
||||
session_id: str
|
||||
buffer: AudioBuffer
|
||||
started_at: datetime
|
||||
last_activity: float
|
||||
chunks_received: int = 0
|
||||
announced: int | None = None # nombre de chunks annoncé par le END
|
||||
end_seen_at: float | None = None
|
||||
end_report: dict[str, Any] = field(default_factory=dict[str, Any])
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Assemble les sessions de chaque carte et les écrit en WAV.
|
||||
|
||||
Appelée depuis le fil réseau (messages MQTT) et depuis le fil principal
|
||||
(sweep) : un verrou protège l'état.
|
||||
|
||||
Les messages audio sont en QoS 0 : ils peuvent se perdre. Le END annonce le
|
||||
nombre de chunks envoyés ; la comparaison avec le nombre reçu mesure la perte.
|
||||
Et comme le START et le END peuvent eux-mêmes se perdre (l'ESP32 ne sait
|
||||
publier qu'en QoS 0), une session sans END est close après un délai d'inactivité.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio: AudioSettings,
|
||||
sessions: SessionSettings,
|
||||
recordings_dir: Path,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
on_finished: Callable[[SessionResult], None] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
on_finished: appelé pour chaque session terminée, depuis le fil qui la
|
||||
termine (réseau ou principal) : il doit rendre la main aussitôt.
|
||||
"""
|
||||
self._on_finished = on_finished
|
||||
self._audio = audio
|
||||
self._settings = sessions
|
||||
self._recordings_dir = recordings_dir
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._active: dict[str, _Session] = {}
|
||||
self.orphan_chunks = 0 # chunks reçus hors de toute session
|
||||
|
||||
# ------------------------------------------------------------ messages reçus
|
||||
|
||||
def on_event(self, device: str, payload: bytes) -> list[SessionResult]:
|
||||
"""Traite un message JSON de voice/<device>/event."""
|
||||
try:
|
||||
event: dict[str, Any] = json.loads(payload)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
logger.warning("%s : evenement illisible : %r", device, payload[:80])
|
||||
return []
|
||||
|
||||
kind = event.get("event")
|
||||
with self._lock:
|
||||
if kind == "start":
|
||||
return self._start(device, event)
|
||||
if kind == "end":
|
||||
return self._end(device, event)
|
||||
if kind == "boot":
|
||||
# Tout redémarrage autre qu'une mise sous tension mérite l'attention :
|
||||
# brownout = alimentation trop faible, panic = plantage, watchdog = blocage.
|
||||
reason = event.get("reason")
|
||||
log = logger.info if reason in ("poweron", "external", "software") else logger.warning
|
||||
log("%s : demarrage de la carte, cause : %s", device, reason)
|
||||
elif kind == "device":
|
||||
# Étape 11 : la carte confirme qu'elle a exécuté une commande de sa pièce.
|
||||
log = logger.info if event.get("ok") else logger.warning
|
||||
log("%s : commande %s %s %s, etat %s", device, event.get("device"), event.get("action"),
|
||||
"executee" if event.get("ok") else "REFUSEE", event.get("state"))
|
||||
elif kind == "played":
|
||||
# Étape 12 : « underruns » compte les réamorçages (réserve épuisée, pause propre) ;
|
||||
# la marge minimale dit combien de retard réseau restait absorbable.
|
||||
log = logger.warning if event.get("underruns") else logger.info
|
||||
log("%s : lecture %s jouee, chunks %s/%s, reamorcages %s %s, premier son a %s ms, marge mini %s ms",
|
||||
device, event.get("session"), event.get("chunks"), event.get("announced"),
|
||||
event.get("underruns"), event.get("gaps_ms") or "", event.get("start_ms"),
|
||||
event.get("min_margin_ms"))
|
||||
else:
|
||||
logger.info("%s : evenement %s", device, event)
|
||||
return []
|
||||
|
||||
def on_audio(self, device: str, payload: bytes) -> None:
|
||||
"""Traite un chunk PCM de voice/<device>/audio/in."""
|
||||
with self._lock:
|
||||
session = self._active.get(device)
|
||||
if session is None:
|
||||
self.orphan_chunks += 1
|
||||
return
|
||||
if len(payload) % 2 != 0:
|
||||
logger.warning("%s : chunk de %d octets ignore (impair)", device, len(payload))
|
||||
return
|
||||
session.buffer.append(payload)
|
||||
session.chunks_received += 1
|
||||
session.last_activity = self._clock()
|
||||
|
||||
def sweep(self) -> list[SessionResult]:
|
||||
"""Clôt les sessions dont le END est passé (délai de grâce) ou restées muettes."""
|
||||
now = self._clock()
|
||||
results: list[SessionResult] = []
|
||||
with self._lock:
|
||||
for device, session in list(self._active.items()):
|
||||
if session.end_seen_at is not None:
|
||||
if now - session.end_seen_at >= self._settings.end_grace_s:
|
||||
results.append(self._finish(device, self._outcome_after_end(session)))
|
||||
elif now - session.last_activity >= self._settings.idle_timeout_s:
|
||||
results.append(self._finish(device, Outcome.TIMEOUT))
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------ interne
|
||||
|
||||
def _start(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
|
||||
results: list[SessionResult] = []
|
||||
previous = self._active.get(device)
|
||||
if previous is not None:
|
||||
# END déjà reçu, délai de grâce en cours : la session précédente est
|
||||
# finie, pas interrompue. Son bilan se juge sur ce que le END annonçait.
|
||||
if previous.end_seen_at is not None:
|
||||
outcome = self._outcome_after_end(previous)
|
||||
else:
|
||||
outcome = Outcome.INTERRUPTED
|
||||
results.append(self._finish(device, outcome))
|
||||
|
||||
announced = (event.get("rate"), event.get("bits"), event.get("channels"), event.get("codec"))
|
||||
expected = (self._audio.sample_rate, self._audio.bits, self._audio.channels, self._audio.codec)
|
||||
if announced != expected:
|
||||
logger.error("%s : session refusee, format %s au lieu de %s", device, announced, expected)
|
||||
return results
|
||||
|
||||
session_id = str(event.get("session", "inconnue"))
|
||||
self._active[device] = _Session(
|
||||
device=device,
|
||||
session_id=session_id,
|
||||
buffer=AudioBuffer(self._audio, self._settings.max_seconds),
|
||||
started_at=datetime.now(),
|
||||
last_activity=self._clock(),
|
||||
)
|
||||
logger.info("%s : session %s ouverte", device, session_id)
|
||||
return results
|
||||
|
||||
def _end(self, device: str, event: dict[str, Any]) -> list[SessionResult]:
|
||||
session = self._active.get(device)
|
||||
if session is None or event.get("session") != session.session_id:
|
||||
logger.warning("%s : END sans session correspondante (%s)", device, event.get("session"))
|
||||
return []
|
||||
|
||||
session.announced = int(event.get("chunks", 0))
|
||||
session.end_report = event
|
||||
session.end_seen_at = self._clock()
|
||||
|
||||
# Tout est arrivé : inutile d'attendre le délai de grâce.
|
||||
if session.chunks_received >= session.announced:
|
||||
return [self._finish(device, Outcome.COMPLETE)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _outcome_after_end(session: _Session) -> Outcome:
|
||||
if session.announced is not None and session.chunks_received < session.announced:
|
||||
return Outcome.LOST_CHUNKS
|
||||
return Outcome.COMPLETE
|
||||
|
||||
def _finish(self, device: str, outcome: Outcome) -> SessionResult:
|
||||
session = self._active.pop(device)
|
||||
stamp = session.started_at.strftime("%Y%m%d-%H%M%S")
|
||||
path = self._recordings_dir / f"{device}_{stamp}_{session.session_id}.wav"
|
||||
session.buffer.write_wav(path)
|
||||
|
||||
result = SessionResult(
|
||||
device=device,
|
||||
session_id=session.session_id,
|
||||
outcome=outcome,
|
||||
path=path,
|
||||
duration_s=session.buffer.duration_s,
|
||||
chunks_received=session.chunks_received,
|
||||
chunks_announced=session.announced,
|
||||
levels=session.buffer.levels(),
|
||||
)
|
||||
self._log(result, session)
|
||||
if self._on_finished is not None:
|
||||
self._on_finished(result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _log(result: SessionResult, session: _Session) -> None:
|
||||
announced = "?" if result.chunks_announced is None else str(result.chunks_announced)
|
||||
logger.info(
|
||||
"%s : session %s %s : %s, %.2f s, chunks %d/%s, crete %.1f dBFS, RMS %.1f dBFS",
|
||||
result.device, result.session_id, result.outcome.value, result.path.name,
|
||||
result.duration_s, result.chunks_received, announced,
|
||||
result.levels.peak_dbfs, result.levels.rms_dbfs,
|
||||
)
|
||||
report = session.end_report
|
||||
if report.get("send_failures") or report.get("capture_overruns"):
|
||||
logger.warning(
|
||||
"%s : cote ESP32, %s echecs d'envoi et %s chunks perdus faute de place",
|
||||
result.device, report.get("send_failures"), report.get("capture_overruns"),
|
||||
)
|
||||
if result.outcome is Outcome.LOST_CHUNKS and result.chunks_announced:
|
||||
lost = result.chunks_announced - result.chunks_received
|
||||
logger.warning("%s : %d chunks perdus en route (%.0f %%) : signal Wi-Fi ?",
|
||||
result.device, lost, 100.0 * lost / result.chunks_announced)
|
||||
if session.buffer.truncated:
|
||||
logger.warning("%s : session tronquee a sa duree maximale", result.device)
|
||||
Reference in New Issue
Block a user