Files
voice-assistant/tools/wav_inspect.py
T
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

144 lines
5.0 KiB
Python

#!/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:]))