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,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyse un fichier WAV et affiche son enveloppe en ASCII.
|
||||
|
||||
Permet de valider un enregistrement **sans écouter** : indispensable sur une
|
||||
machine sans sortie audio, et plus tard sur un Raspberry Pi sans écran.
|
||||
|
||||
Exemple :
|
||||
python3 tools/wav_inspect.py recordings/essai.wav
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import wave
|
||||
from array import array
|
||||
from pathlib import Path
|
||||
|
||||
from serial_to_wav import LevelReport, measure_levels
|
||||
|
||||
logger = logging.getLogger("wav_inspect")
|
||||
|
||||
# Du plus faible au plus fort : huit niveaux de remplissage.
|
||||
BLOCKS = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
# Plage affichée par l'enveloppe, en décibels pleine échelle.
|
||||
FLOOR_DBFS = -60.0
|
||||
|
||||
# Un segment dépassant le plancher de bruit de cette marge est considéré parlé.
|
||||
SPEECH_MARGIN_DB = 12.0
|
||||
|
||||
|
||||
def read_pcm(path: Path) -> tuple[bytes, int]:
|
||||
"""Lit un WAV PCM 16 bits mono et retourne (octets, fréquence)."""
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
if wav.getsampwidth() != 2:
|
||||
raise ValueError(f"{path} : {wav.getsampwidth() * 8} bits, 16 attendus")
|
||||
if wav.getnchannels() != 1:
|
||||
raise ValueError(f"{path} : {wav.getnchannels()} canaux, 1 attendu")
|
||||
return wav.readframes(wav.getnframes()), wav.getframerate()
|
||||
|
||||
|
||||
def column_peaks(pcm: bytes, columns: int) -> list[float]:
|
||||
"""Découpe le flux en `columns` tranches et retourne la crête de chacune."""
|
||||
samples = array("h")
|
||||
samples.frombytes(pcm[: len(pcm) - (len(pcm) % 2)])
|
||||
if sys.byteorder == "big":
|
||||
samples.byteswap()
|
||||
|
||||
total = len(samples)
|
||||
if total == 0 or columns <= 0:
|
||||
return []
|
||||
|
||||
peaks: list[float] = []
|
||||
for index in range(columns):
|
||||
start = (index * total) // columns
|
||||
stop = max(start + 1, ((index + 1) * total) // columns)
|
||||
window = samples[start:stop]
|
||||
peak = max(max(window), -min(window))
|
||||
peaks.append(peak / 32768.0)
|
||||
return peaks
|
||||
|
||||
|
||||
def to_dbfs(value: float) -> float:
|
||||
"""Convertit un niveau normalisé en dBFS, borné au plancher d'affichage."""
|
||||
if value <= 0.0:
|
||||
return FLOOR_DBFS
|
||||
return max(FLOOR_DBFS, 20.0 * math.log10(value))
|
||||
|
||||
|
||||
def render(peaks: list[float]) -> str:
|
||||
"""Dessine l'enveloppe : un caractère par tranche, échelle logarithmique."""
|
||||
glyphs: list[str] = []
|
||||
for peak in peaks:
|
||||
ratio = (to_dbfs(peak) - FLOOR_DBFS) / (0.0 - FLOOR_DBFS)
|
||||
index = int(round(ratio * (len(BLOCKS) - 1)))
|
||||
glyphs.append(BLOCKS[min(len(BLOCKS) - 1, max(0, index))])
|
||||
return "".join(glyphs)
|
||||
|
||||
|
||||
def speech_ratio(peaks: list[float]) -> float:
|
||||
"""Proportion de tranches nettement au-dessus du plancher de bruit."""
|
||||
if not peaks:
|
||||
return 0.0
|
||||
quiet = min(to_dbfs(peak) for peak in peaks)
|
||||
loud = [peak for peak in peaks if to_dbfs(peak) > quiet + SPEECH_MARGIN_DB]
|
||||
return len(loud) / len(peaks)
|
||||
|
||||
|
||||
def describe(level: LevelReport, peaks: list[float], duration: float) -> None:
|
||||
"""Journalise le verdict sur l'enregistrement."""
|
||||
ratio = speech_ratio(peaks)
|
||||
logger.info("duree %.2f s | crete %.1f %% | RMS %.1f %% | offset %+.3f %%",
|
||||
duration, level.peak * 100.0, level.rms * 100.0, level.dc * 100.0)
|
||||
logger.info("tranches actives : %.0f %% de la duree", ratio * 100.0)
|
||||
|
||||
if level.peak <= 0.0:
|
||||
logger.error("fichier totalement silencieux.")
|
||||
elif level.peak > 0.99:
|
||||
logger.warning("saturation : diminuez MIC_GAIN.")
|
||||
elif level.peak < 0.02:
|
||||
logger.warning("niveau tres faible : augmentez MIC_GAIN.")
|
||||
elif ratio < 0.05:
|
||||
logger.warning("aucune variation nette : bruit de fond plutot que parole ?")
|
||||
elif ratio > 0.95:
|
||||
logger.warning("signal actif partout : bruit continu plutot que parole ?")
|
||||
else:
|
||||
logger.info("alternance silence / parole coherente avec une phrase.")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Point d'entrée : analyse le fichier passé en argument."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("wav", type=Path, help="fichier WAV a analyser")
|
||||
parser.add_argument("--columns", type=int, default=64, help="largeur de l'enveloppe")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
|
||||
|
||||
try:
|
||||
pcm, rate = read_pcm(args.wav)
|
||||
except (OSError, wave.Error, ValueError) as error:
|
||||
logger.error("lecture impossible : %s", error)
|
||||
return 1
|
||||
|
||||
duration = len(pcm) / (2.0 * rate) if rate else 0.0
|
||||
peaks = column_peaks(pcm, args.columns)
|
||||
|
||||
print(f"\n{args.wav} ({rate} Hz, 16 bits, mono)")
|
||||
print(f" 0 dBFS |{'-' * len(peaks)}|")
|
||||
print(f" |{render(peaks)}|")
|
||||
print(f"{FLOOR_DBFS:.0f} dBFS |{'-' * len(peaks)}|")
|
||||
print(f" 0 s{' ' * max(0, len(peaks) - 8)}{duration:.2f} s\n")
|
||||
|
||||
sys.stdout.flush() # le dessin doit sortir avant les lignes de journalisation
|
||||
describe(measure_levels(pcm), peaks, duration)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user