feat(stt-client): ASR enfichable — backend onnx Parakeet/Nemotron en plus de whisper

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 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-20 14:17:31 +02:00
parent 470c69881c
commit f23c52b694
7 changed files with 153 additions and 20 deletions

View file

@ -144,7 +144,8 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe
| Composant | Emplacement | Description | | Composant | Emplacement | Description |
|---|---|---| |---|---|---|
| Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server <url>`, `stt --model <hermes\|claude\|qwen\|opus>` | | Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server <url>`, `stt --model <hermes\|claude\|qwen\|opus>` |
| — 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` | | — 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 | | — 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 | | STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM |

View file

@ -1,6 +1,6 @@
[project] [project]
name = "stt" name = "stt"
version = "0.6.1" version = "0.7.0"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"
@ -16,6 +16,12 @@ dependencies = [
"tomli-w>=1.0.0", # écriture config TOML "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] [project.scripts]
stt = "stt.cli:main" stt = "stt.cli:main"

View file

@ -39,7 +39,13 @@ DEFAULT_CONFIG: dict[str, Any] = {
}, },
"voice": { "voice": {
"wake_word": "hermes", "wake_word": "hermes",
# ASR : "whisper" (faster-whisper CPU, défaut) | "onnx" (Parakeet/Canary/Nemotron)
"asr_engine": "whisper",
"whisper_model": "large-v3", "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", "piper_voice": "fr_FR-upmc-medium",
"language": "fr", "language": "fr",
"silence_sec": 1.2, "silence_sec": 1.2,

View file

@ -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')"
)

View file

@ -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()

View file

@ -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()

View file

@ -20,6 +20,8 @@ from collections import deque
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from .asr import make_backend
SAMPLE_RATE = 16000 SAMPLE_RATE = 16000
PRE_ROLL = 15 PRE_ROLL = 15
CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"} CHAT_EXIT = {"au revoir", "bonne nuit", "stop hermes", "fin"}
@ -63,7 +65,8 @@ class VoiceEngine:
self.no_tts = no_tts self.no_tts = no_tts
# callable renvoyant l'alias modèle courant → affiché en tag dans le HUD # callable renvoyant l'alias modèle courant → affiché en tag dans le HUD
self.model_label = model_label 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 self._stop = False
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes # sérialise un tour (voix ou texte) : pas deux réponses concurrentes
self._respond_lock = threading.Lock() self._respond_lock = threading.Lock()
@ -80,26 +83,11 @@ class VoiceEngine:
# ── chargement modèle ──────────────────────────────────────────────── # ── chargement modèle ────────────────────────────────────────────────
def load(self) -> None: def load(self) -> None:
from faster_whisper import WhisperModel self._asr.load()
self._whisper = WhisperModel(
self.cfg["whisper_model"], device="cpu", compute_type="int8"
)
# ── STT ────────────────────────────────────────────────────────────── # ── STT ──────────────────────────────────────────────────────────────
def _transcribe(self, audio) -> str: def _transcribe(self, audio) -> str:
import numpy as np return self._asr.transcribe(audio)
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()
def _detect_wake(self, text: str) -> tuple[bool, str]: def _detect_wake(self, text: str) -> tuple[bool, str]:
wake = self.cfg.get("wake_word", "hermes") wake = self.cfg.get("wake_word", "hermes")