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

179 lines
5.6 KiB
Python

"""Tests du décodeur de trames série utilisé à l'étape 1.
Ces tests ne nécessitent aucun matériel : un faux port série rejoue des octets.
"""
from __future__ import annotations
import struct
import sys
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from serial_protocol import ( # noqa: E402
AudioFormat,
Frame,
FrameReader,
FrameType,
encode_frame,
)
from serial_to_wav import measure_levels, suggested_gain_factor # noqa: E402
from wav_inspect import column_peaks, render, speech_ratio # noqa: E402
class FakeSerial:
"""Faux port série : restitue une suite d'octets par petits morceaux."""
def __init__(self, data: bytes, block_size: int = 7) -> None:
self._data = data
self._block_size = block_size
self._position = 0
def read(self, size: int = 1) -> bytes:
"""Retourne au plus block_size octets, puis des chaînes vides."""
count = min(size, self._block_size, len(self._data) - self._position)
if count <= 0:
return b""
chunk = self._data[self._position : self._position + count]
self._position += count
return chunk
def build_frame(frame_type: FrameType, payload: bytes) -> bytes:
"""Construit une trame avec l'encodeur partagé, celui qu'utilise wav_to_serial."""
return encode_frame(frame_type, payload)
def descriptor(sample_rate: int = 16000, bits: int = 16, channels: int = 1) -> bytes:
"""Construit le descripteur de 8 octets de la trame START."""
return (
sample_rate.to_bytes(4, "little")
+ bits.to_bytes(2, "little")
+ channels.to_bytes(2, "little")
)
def collect(data: bytes, expected: int) -> list[Frame]:
"""Décode le flux et retourne les `expected` premières trames."""
reader = FrameReader(FakeSerial(data))
frames: list[Frame] = []
for frame in reader.frames():
frames.append(frame)
if len(frames) == expected:
break
return frames
def test_decodes_a_full_session() -> None:
"""START + deux chunks + END sont décodés dans l'ordre."""
pcm = bytes(range(0, 200)) * 8 # 1600 octets
stream = (
build_frame(FrameType.START, descriptor())
+ build_frame(FrameType.AUDIO, pcm)
+ build_frame(FrameType.AUDIO, pcm)
+ build_frame(FrameType.END, b"")
)
frames = collect(stream, 4)
assert [f.type for f in frames] == [
FrameType.START,
FrameType.AUDIO,
FrameType.AUDIO,
FrameType.END,
]
assert frames[1].payload == pcm
assert AudioFormat.from_payload(frames[0].payload) == AudioFormat(16000, 16, 1)
def test_skips_interleaved_text() -> None:
"""Les messages texte de l'ESP32 n'empêchent pas le décodage."""
stream = (
b"# micro pret\n"
+ build_frame(FrameType.START, descriptor())
+ b"\n# session terminee\n"
+ build_frame(FrameType.END, b"")
)
frames = collect(stream, 2)
assert [f.type for f in frames] == [FrameType.START, FrameType.END]
def test_rejects_bad_checksum() -> None:
"""Une trame corrompue est ignorée, la suivante est retrouvée."""
broken = bytearray(build_frame(FrameType.AUDIO, b"\x01\x02\x03\x04"))
broken[-1] ^= 0xFF # checksum faux
stream = bytes(broken) + build_frame(FrameType.END, b"")
frames = collect(stream, 1)
assert frames[0].type is FrameType.END
def test_measure_levels_on_silence() -> None:
"""Un flux nul est signalé comme absence totale de signal."""
level = measure_levels(b"\x00\x00" * 1000)
assert level.peak == 0.0
assert level.rms == 0.0
def test_measure_levels_on_known_amplitude() -> None:
"""Un carré à la moitié de l'échelle donne crête et RMS à 50 %."""
pcm = (struct.pack("<h", 16384) + struct.pack("<h", -16384)) * 500
level = measure_levels(pcm)
assert abs(level.peak - 0.5) < 0.001
assert abs(level.rms - 0.5) < 0.001
assert abs(level.dc) < 0.001
def test_suggested_gain_factor() -> None:
"""Le facteur conseillé est une puissance de 2 qui vise 25 % de crête."""
assert suggested_gain_factor(0.015) == 16
assert suggested_gain_factor(0.12) == 2
assert suggested_gain_factor(0.0) == 0
def test_envelope_follows_amplitude() -> None:
"""L'enveloppe dessine un creux entre deux bouffées de signal."""
loud = struct.pack("<h", 16000) * 1000
quiet = struct.pack("<h", 0) * 1000
peaks = column_peaks(loud + quiet + loud, columns=3)
assert peaks[0] > 0.4 and peaks[2] > 0.4
assert peaks[1] == 0.0
drawing = render(peaks)
assert drawing[0] == drawing[2]
assert drawing[1] != drawing[0]
def test_speech_ratio_detects_alternation() -> None:
"""Un signal constant n'est pas de la parole, une alternance oui."""
steady = struct.pack("<h", 8000) * 3000
assert speech_ratio(column_peaks(steady, columns=6)) == 0.0
burst = struct.pack("<h", 0) * 2000 + struct.pack("<h", 8000) * 1000
assert speech_ratio(column_peaks(burst, columns=6)) > 0.0
def test_audio_format_round_trip() -> None:
"""Le descripteur START s'encode et se décode sans perte."""
fmt = AudioFormat(16000, 16, 1)
assert AudioFormat.from_payload(fmt.to_payload()) == fmt
def test_encode_frame_rejects_oversized_payload() -> None:
"""Un payload plus grand que ce que l'ESP32 accepte est refusé dès l'envoi."""
try:
encode_frame(FrameType.AUDIO, bytes(4096))
except ValueError:
return
raise AssertionError("un payload de 4096 octets aurait du etre refuse")