mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
d20976fea2
É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>
144 lines
5.0 KiB
Python
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:]))
|