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

77 lines
2.6 KiB
Python

"""Accumulation du PCM d'une session et écriture en WAV."""
from __future__ import annotations
import math
import wave
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from voice_server.settings import AudioSettings
@dataclass(frozen=True)
class Levels:
"""Niveaux d'un signal, en dBFS (0 = pleine échelle)."""
peak_dbfs: float
rms_dbfs: float
class AudioBuffer:
"""Tampon PCM s16le borné en taille, pour une seule session."""
def __init__(self, audio: AudioSettings, max_seconds: float) -> None:
self._audio = audio
self._max_bytes = int(audio.bytes_per_second * max_seconds)
self._data = bytearray()
self.truncated = False # vrai si des données ont été refusées (trop long)
def append(self, pcm: bytes) -> None:
"""Ajoute un chunk ; au-delà de la durée maximale, le surplus est ignoré."""
room = self._max_bytes - len(self._data)
if len(pcm) > room:
self.truncated = True
pcm = pcm[: max(0, room)]
self._data.extend(pcm)
@property
def byte_count(self) -> int:
"""Nombre d'octets accumulés."""
return len(self._data)
@property
def duration_s(self) -> float:
"""Durée accumulée, en secondes."""
return len(self._data) / self._audio.bytes_per_second
def levels(self) -> Levels:
"""Crête et valeur efficace du contenu."""
samples = np.frombuffer(bytes(self._data[: len(self._data) // 2 * 2]), dtype="<i2")
if samples.size == 0:
return Levels(peak_dbfs=-math.inf, rms_dbfs=-math.inf)
normalized = samples.astype(np.float64) / 32768.0
peak = float(np.max(np.abs(normalized)))
rms = float(np.sqrt(np.mean(normalized**2)))
return Levels(
peak_dbfs=20.0 * math.log10(peak) if peak > 0 else -math.inf,
rms_dbfs=20.0 * math.log10(rms) if rms > 0 else -math.inf,
)
def write_wav(self, path: Path) -> None:
"""Écrit le contenu dans un fichier WAV (dossiers créés au besoin)."""
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(self._audio.channels)
wav.setsampwidth(self._audio.bits // 8)
wav.setframerate(self._audio.sample_rate)
wav.writeframes(bytes(self._data))
def read_pcm(path: Path) -> bytes:
"""Relit le PCM d'un WAV écrit par write_wav (format du système, sans conversion)."""
with wave.open(str(path), "rb") as wav:
return wav.readframes(wav.getnframes())