This commit is contained in:
Denis Defolie
2026-10-06 19:24:22 +02:00
parent 79d45587ba
commit d114b9818e
16 changed files with 381 additions and 24 deletions
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Mesure la latence du serveur sur des enregistrements réels, avant et après l'étape 16.
Pour chaque enregistrement de server/recordings/ (lecture seule, rien n'est écrit) :
avant VAD → diarisation → identification → Whisper avec mots horodatés
après VAD → empreinte directe si la parole est courte (diarization.direct_below_s),
diarisation sinon → identification → Whisper sans mots si parole courte
Le temps affiché est celui du calcul, de la fin de la session au texte reconnu :
il ne compte ni le réseau, ni la synthèse (quelques dizaines de ms, en cache
pour les réponses répétées). À lancer sur la machine qui fait tourner le serveur.
Exemples :
python3 tools/latency_check.py # les 30 derniers enregistrements
python3 tools/latency_check.py --last 100
"""
from __future__ import annotations
import argparse
import logging
import statistics
import sys
import time
import warnings
from collections.abc import Callable
from pathlib import Path
from typing import TypeVar
SERVER_DIR = Path(__file__).resolve().parents[1] / "server"
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from voice_server.audio_buffer import read_pcm # noqa: E402
from voice_server.settings import load_settings # noqa: E402
logger = logging.getLogger("latency_check")
T = TypeVar("T")
def timed(function: Callable[[], T]) -> tuple[float, T]:
"""Durée d'un appel, et son résultat."""
began = time.monotonic()
result = function()
return time.monotonic() - began, result
def main(argv: list[str]) -> int:
"""Rejoue les enregistrements et affiche les médianes."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--last", type=int, default=30, help="nombre d'enregistrements, les plus recents")
parser.add_argument("--config", type=Path, default=SERVER_DIR / "config.yaml")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
warnings.filterwarnings("ignore") # avertissements de torch sur les extraits très courts
logging.getLogger("faster_whisper").setLevel(logging.WARNING) # une ligne par transcription sinon
settings = load_settings(args.config)
try:
from voice_server.diarization import Diarizer
from voice_server.speaker_identifier import SpeakerIdentifier
from voice_server.speech_to_text import SpeechToText
from voice_server.voice_activity import VoiceActivityDetector
vad = VoiceActivityDetector(settings.vad, settings.audio.sample_rate)
stt = SpeechToText(settings.stt, settings.audio.sample_rate)
diarizer = Diarizer(settings.diarization, settings.audio.sample_rate)
except (ImportError, FileNotFoundError) as error:
logger.error("%s : lancez 'make install-speech', 'make install-diarization' et 'make models'", error)
return 1
identifier = SpeakerIdentifier(settings.speaker, settings.diarization.model)
threshold = settings.diarization.direct_below_s
files = sorted(settings.recordings_dir.glob("*.wav"), key=lambda path: path.stat().st_mtime)[-args.last:]
clips = [result.speech_pcm for result in (vad.detect(read_pcm(path)) for path in files) if result.has_speech]
if not clips:
logger.error("aucun enregistrement avec de la parole dans %s", settings.recordings_dir)
return 1
diarizer.diarize(clips[0]) # premier appel plus lent : hors mesure
diarizer.embed(clips[0])
before: list[float] = []
after: list[float] = []
short = same_text = same_name = 0
changes: list[str] = []
for pcm in clips:
speech_s = len(pcm) / 2 / settings.audio.sample_rate
diar_s, diarization = timed(lambda: diarizer.diarize(pcm))
words_s, with_words = timed(lambda: stt.transcribe(pcm))
before.append(diar_s + words_s)
old_name = (identifier.identify(diarization.embeddings[0]).label
if diarization.embeddings is not None and len(diarization.speakers) == 1 else None)
if speech_s < threshold:
short += 1
embed_s, embedding = timed(lambda: diarizer.embed(pcm))
plain_s, plain = timed(lambda: stt.transcribe(pcm, words=False))
after.append(embed_s + plain_s)
same_text += plain.text == with_words.text
verdict = identifier.identify(embedding)
same_name += verdict.label == old_name
if verdict.label != old_name:
changes.append(f"{old_name} -> {verdict.describe()} ({speech_s:.1f} s de parole)")
else:
after.append(diar_s + words_s)
def median(values: list[float]) -> float:
return statistics.median(values)
logger.info("%d enregistrements avec parole (%.1f s de parole en mediane), dont %d sous %.1f s",
len(clips), median([len(pcm) / 2 / settings.audio.sample_rate for pcm in clips]), short, threshold)
logger.info("avant : %.2f s (mediane), %.2f s (pire)", median(before), max(before))
logger.info("apres : %.2f s (mediane), %.2f s (pire)", median(after), max(after))
logger.info("gain : %.2f s, soit %.0f %%", median(before) - median(after),
100 * (1 - median(after) / median(before)))
if short:
logger.info("paroles courtes : meme texte %d/%d, meme identification %d/%d", same_text, short,
same_name, short)
for change in changes: # attendu : des voix à la limite du seuil, dans les deux sens
logger.info(" identification differente : %s", change)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))