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,117 @@
|
||||
"""Transcription de la parole en texte avec faster-whisper (étape 6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from voice_server.settings import SttSettings
|
||||
|
||||
if TYPE_CHECKING: # import réel dans __init__ : le mode écho se passe de l'extra « speech »
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Phrases que Whisper « entend » dans le silence ou le bruit : elles viennent des
|
||||
# sous-titres de vidéos qui ont servi à l'entraîner. Comparaison en minuscules.
|
||||
KNOWN_HALLUCINATIONS = (
|
||||
"amara.org",
|
||||
"sous-titrage",
|
||||
"sous-titres",
|
||||
"merci d'avoir regardé",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Word:
|
||||
"""Un mot et son instant dans l'audio transcrit (étape 10 : à qui l'attribuer)."""
|
||||
|
||||
text: str # tel que Whisper l'écrit, espace initiale comprise : « Ferme », « -moi »
|
||||
start_s: float
|
||||
end_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transcript:
|
||||
"""Résultat d'une transcription."""
|
||||
|
||||
text: str # vide si rien d'exploitable n'a été reconnu
|
||||
audio_s: float # durée de l'audio transcrit
|
||||
elapsed_s: float # temps de calcul
|
||||
dropped: tuple[str, ...] = () # segments écartés comme hallucinations
|
||||
words: tuple[Word, ...] = () # mots des segments gardés, dans l'ordre
|
||||
|
||||
|
||||
class SpeechToText:
|
||||
"""Transcrit un PCM s16le 16 kHz mono en texte, avec un modèle local.
|
||||
|
||||
Le modèle est lu dans le dossier rempli par « make models » : aucun accès
|
||||
réseau au démarrage, ni pendant la transcription.
|
||||
"""
|
||||
|
||||
def __init__(self, stt: SttSettings, sample_rate: int) -> None:
|
||||
"""
|
||||
Args:
|
||||
stt: réglages (modèle, langue, précision de calcul).
|
||||
sample_rate: fréquence du PCM reçu ; Whisper exige 16 kHz.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: le modèle n'a pas été téléchargé.
|
||||
ValueError: fréquence différente de 16 kHz.
|
||||
"""
|
||||
if sample_rate != 16000:
|
||||
raise ValueError(f"Whisper attend du 16 kHz, pas {sample_rate} Hz")
|
||||
if not (stt.model_dir / "model.bin").exists():
|
||||
raise FileNotFoundError(f"modele whisper absent de {stt.model_dir} : lancez 'make models'")
|
||||
self._stt = stt
|
||||
self._sample_rate = sample_rate
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
began = time.monotonic()
|
||||
# int8 : poids quantifiés sur 8 bits, 2 à 4 fois plus rapide sur CPU que float32.
|
||||
self._model: WhisperModel = WhisperModel(str(stt.model_dir), device="cpu", compute_type=stt.compute_type)
|
||||
# Le premier appel initialise les calculs : on le fait ici plutôt qu'à la première question.
|
||||
self.transcribe(bytes(sample_rate * 2))
|
||||
logger.info("whisper %s charge en %.1f s", stt.model, time.monotonic() - began)
|
||||
|
||||
def transcribe(self, pcm: bytes) -> Transcript:
|
||||
"""Transcrit le PCM et écarte les segments suspects."""
|
||||
began = time.monotonic()
|
||||
audio = np.frombuffer(pcm[: len(pcm) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||
segments, _ = self._model.transcribe(
|
||||
audio,
|
||||
language=self._stt.language,
|
||||
beam_size=self._stt.beam_size,
|
||||
# Chaque question est indépendante : sans cela, une erreur se propage d'un segment à l'autre.
|
||||
condition_on_previous_text=False,
|
||||
no_speech_threshold=self._stt.no_speech_threshold,
|
||||
vad_filter=False, # la VAD (étape 7) a déjà retiré les silences
|
||||
word_timestamps=True, # instant de chaque mot : +5 à 10 % de calcul (mesuré)
|
||||
)
|
||||
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
words: list[Word] = []
|
||||
for segment in segments: # générateur : le calcul se fait pendant ce parcours
|
||||
text = segment.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
if any(marker in text.lower() for marker in KNOWN_HALLUCINATIONS):
|
||||
dropped.append(text)
|
||||
else:
|
||||
kept.append(text)
|
||||
words.extend(Word(text=w.word, start_s=float(w.start), end_s=float(w.end))
|
||||
for w in segment.words or ())
|
||||
|
||||
return Transcript(
|
||||
text=" ".join(kept),
|
||||
audio_s=audio.size / self._sample_rate,
|
||||
elapsed_s=time.monotonic() - began,
|
||||
dropped=tuple(dropped),
|
||||
words=tuple(words),
|
||||
)
|
||||
Reference in New Issue
Block a user