From 4d8cc1968df414d1578cc333572c66bf32f10464 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Sat, 20 Jun 2026 14:28:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-client):=20ASR=20enfichable=20?= =?UTF-8?q?=E2=80=94=20backend=20onnx=20Parakeet/Nemotron=20en=20plus=20de?= =?UTF-8?q?=20whisper=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 (abstraction) + Phase 1 (backend onnx), additif et rétrocompatible. - voice/asr/ : protocole ASRBackend (load + transcribe) + factory make_backend pilotée par cfg["asr_engine"]. Imports lourds lazy → stt --help/config OK sans aucun moteur installé. - whisper.py : faster-whisper extrait tel quel (défaut, comportement inchangé). - onnx_parakeet.py : onnx-asr (Parakeet/Canary/Nemotron ONNX, multilingue FR, ~600M, CPU rapide / GPU AMD via onnxruntime-rocm). Providers configurables ; message d'install actionnable si onnx-asr absent. - engine.py : délègue load()/_transcribe() au backend (le reste — VAD, wake word, TTS — inchangé). - config : [voice] asr_engine="whisper" (défaut), onnx_model, onnx_providers. - pyproject : extras optionnels onnx / onnx-rocm ; bump 0.6.1 → 0.7.0. Bascule à chaud par config, rollback = asr_engine="whisper". Streaming = phase 2 (spike séparé). admin/ia/stt.md mis à jour. Co-authored-by: Claude Opus 4.8 --- admin/ia/stt.md | 3 +- stt/client/pyproject.toml | 8 ++- stt/client/stt/config.py | 6 +++ stt/client/stt/voice/asr/__init__.py | 36 ++++++++++++++ stt/client/stt/voice/asr/onnx_parakeet.py | 60 +++++++++++++++++++++++ stt/client/stt/voice/asr/whisper.py | 36 ++++++++++++++ stt/client/stt/voice/engine.py | 24 +++------ 7 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 stt/client/stt/voice/asr/__init__.py create mode 100644 stt/client/stt/voice/asr/onnx_parakeet.py create mode 100644 stt/client/stt/voice/asr/whisper.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 71bc179..b897742 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -144,7 +144,8 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe | Composant | Emplacement | Description | |---|---|---| | Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server `, `stt --model ` | -| — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper | +| — voix | `stt/client/stt/voice/` | wake word, ASR enfichable (`voice/asr/`), TTS Piper | +| — ASR | `stt/client/stt/voice/asr/` | backend enfichable : `whisper` (faster-whisper CPU, défaut) \| `onnx` (Parakeet/Canary/Nemotron via onnx-asr, multilingue, streaming-ready). Choisi par `[voice] asr_engine` | | — api | `stt/client/stt/api.py` | `ServerClient` → `POST /v1/ask` | | — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → backend) ; HUD embarqué dans le package | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM | diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 4f6d51e..687e825 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.6.1" +version = "0.7.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" @@ -16,6 +16,12 @@ dependencies = [ "tomli-w>=1.0.0", # écriture config TOML ] +# Backend ASR alternatif (asr_engine="onnx") : NVIDIA Parakeet/Canary/Nemotron en ONNX. +# Hors du core pour garder l'install légère. CPU : onnx ; GPU AMD : onnx-rocm. +[project.optional-dependencies] +onnx = ["onnx-asr>=0.11", "onnxruntime>=1.18"] +onnx-rocm = ["onnx-asr>=0.11", "onnxruntime-rocm>=1.18"] + [project.scripts] stt = "stt.cli:main" diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index dfde4e5..a112d16 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -39,7 +39,13 @@ DEFAULT_CONFIG: dict[str, Any] = { }, "voice": { "wake_word": "hermes", + # ASR : "whisper" (faster-whisper CPU, défaut) | "onnx" (Parakeet/Canary/Nemotron) + "asr_engine": "whisper", "whisper_model": "large-v3", + # onnx-asr (si asr_engine="onnx") — modèle multilingue + execution providers. + # CPU par défaut ; pour le GPU AMD : ["ROCMExecutionProvider", "CPUExecutionProvider"]. + "onnx_model": "nemo-parakeet-tdt-0.6b-v3", + "onnx_providers": ["CPUExecutionProvider"], "piper_voice": "fr_FR-upmc-medium", "language": "fr", "silence_sec": 1.2, diff --git a/stt/client/stt/voice/asr/__init__.py b/stt/client/stt/voice/asr/__init__.py new file mode 100644 index 0000000..9253254 --- /dev/null +++ b/stt/client/stt/voice/asr/__init__.py @@ -0,0 +1,36 @@ +"""Backends ASR enfichables. + +L'`VoiceEngine` ne connaît que l'interface `ASRBackend` (`load()` + `transcribe()`) : +le choix du moteur est piloté par `cfg["asr_engine"]`. Les imports lourds +(faster-whisper, onnx-asr) restent dans les sous-modules, chargés à la volée par la +factory → `stt --help` et la config marchent sans aucun moteur installé. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class ASRBackend(Protocol): + """Transcripteur d'un énoncé entier (audio `np.int16` @16 kHz mono → texte).""" + + def load(self) -> None: ... + + def transcribe(self, audio_int16) -> str: ... + + +def make_backend(cfg: dict[str, Any]) -> "ASRBackend": + """Construit le backend ASR d'après `cfg["asr_engine"]` (défaut : whisper).""" + engine = (cfg.get("asr_engine") or "whisper").lower() + if engine in ("whisper", "faster-whisper"): + from .whisper import WhisperBackend + + return WhisperBackend(cfg) + if engine in ("onnx", "onnx-asr", "parakeet", "nemotron", "canary"): + from .onnx_parakeet import OnnxAsrBackend + + return OnnxAsrBackend(cfg) + raise ValueError( + f"asr_engine inconnu : {engine!r} (attendu 'whisper' ou 'onnx')" + ) diff --git a/stt/client/stt/voice/asr/onnx_parakeet.py b/stt/client/stt/voice/asr/onnx_parakeet.py new file mode 100644 index 0000000..6347c5f --- /dev/null +++ b/stt/client/stt/voice/asr/onnx_parakeet.py @@ -0,0 +1,60 @@ +"""Backend ASR via onnx-asr : modèles NVIDIA Parakeet / Canary / Nemotron en ONNX. + +Multilingue (FR), léger (~600M), rapide en CPU ; GPU AMD possible via +onnxruntime-rocm. Modèle et execution providers pilotés par la config : + + [voice] + asr_engine = "onnx" + onnx_model = "nemo-parakeet-tdt-0.6b-v3" # multilingue (FR) + onnx_providers = ["CPUExecutionProvider"] # ou ["ROCMExecutionProvider", "CPUExecutionProvider"] + +Dépendances hors du core (install légère) — voir l'extra `stt[onnx]` ou +`pipx inject stt onnx-asr onnxruntime`. +""" + +from __future__ import annotations + +from typing import Any + +_INSTALL_HINT = ( + "Backend ASR 'onnx' indisponible : onnx-asr / onnxruntime ne sont pas installés.\n" + " pipx inject stt onnx-asr onnxruntime # CPU\n" + " pipx inject stt onnx-asr onnxruntime-rocm # GPU AMD (ROCm)\n" + "ou réinstalle avec l'extra : pipx install 'stt[onnx]'" +) + +DEFAULT_MODEL = "nemo-parakeet-tdt-0.6b-v3" + + +class OnnxAsrBackend: + def __init__(self, cfg: dict[str, Any]): + self.cfg = cfg + self._model = None + + def _providers(self): + # onnx-asr accepte des str ("CPUExecutionProvider") ou des tuples (name, options). + provs = self.cfg.get("onnx_providers") or ["CPUExecutionProvider"] + return [p if isinstance(p, (list, tuple)) else (p, {}) for p in provs] + + def load(self) -> None: + try: + import onnx_asr + except ImportError as e: # message actionnable plutôt qu'un ImportError brut + raise RuntimeError(_INSTALL_HINT) from e + self._model = onnx_asr.load_model( + self.cfg.get("onnx_model", DEFAULT_MODEL), + providers=self._providers(), + ) + + def transcribe(self, audio_int16) -> str: + import numpy as np + + audio_f32 = audio_int16.astype(np.float32) / 32768.0 + lang = self.cfg.get("language", "fr") + # Les modèles multilingues acceptent `language=` ; les mono-langue non → + # on retombe sur l'appel sans langue plutôt que de planter. + try: + text = self._model.recognize(audio_f32, language=lang) + except TypeError: + text = self._model.recognize(audio_f32) + return (text or "").strip() diff --git a/stt/client/stt/voice/asr/whisper.py b/stt/client/stt/voice/asr/whisper.py new file mode 100644 index 0000000..0b0f6f7 --- /dev/null +++ b/stt/client/stt/voice/asr/whisper.py @@ -0,0 +1,36 @@ +"""Backend ASR par défaut : faster-whisper (CTranslate2), CPU int8. + +Extrait tel quel de l'ancien `VoiceEngine.load()`/`_transcribe()` — comportement +inchangé (c'est le défaut tant que `asr_engine != "onnx"`). +""" + +from __future__ import annotations + +from typing import Any + + +class WhisperBackend: + def __init__(self, cfg: dict[str, Any]): + self.cfg = cfg + self._model = None + + def load(self) -> None: + from faster_whisper import WhisperModel + + self._model = WhisperModel( + self.cfg["whisper_model"], device="cpu", compute_type="int8" + ) + + def transcribe(self, audio_int16) -> str: + import numpy as np + + audio_f32 = audio_int16.astype(np.float32) / 32768.0 + segments, _ = self._model.transcribe( + audio_f32, + language=self.cfg.get("language", "fr"), + task="transcribe", + beam_size=3, + vad_filter=True, + initial_prompt="Transcription en français.", + ) + return " ".join(seg.text for seg in segments).strip() diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 8582521..33d2840 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -20,6 +20,8 @@ from collections import deque from pathlib import Path from typing import Any, Callable +from .asr import make_backend + SAMPLE_RATE = 16000 PRE_ROLL = 15 CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"} @@ -63,7 +65,8 @@ class VoiceEngine: self.no_tts = no_tts # callable renvoyant l'alias modèle courant → affiché en tag dans le HUD self.model_label = model_label - self._whisper = None + # backend ASR enfichable (whisper par défaut, onnx/parakeet en option) + self._asr = make_backend(cfg) self._stop = False # sérialise un tour (voix ou texte) : pas deux réponses concurrentes self._respond_lock = threading.Lock() @@ -80,26 +83,11 @@ class VoiceEngine: # ── chargement modèle ──────────────────────────────────────────────── def load(self) -> None: - from faster_whisper import WhisperModel - - self._whisper = WhisperModel( - self.cfg["whisper_model"], device="cpu", compute_type="int8" - ) + self._asr.load() # ── STT ────────────────────────────────────────────────────────────── def _transcribe(self, audio) -> str: - import numpy as np - - audio_f32 = audio.astype(np.float32) / 32768.0 - segments, _ = self._whisper.transcribe( - audio_f32, - language=self.cfg.get("language", "fr"), - task="transcribe", - beam_size=3, - vad_filter=True, - initial_prompt="Transcription en français.", - ) - return " ".join(seg.text for seg in segments).strip() + return self._asr.transcribe(audio) def _detect_wake(self, text: str) -> tuple[bool, str]: wake = self.cfg.get("wake_word", "hermes")