mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:24:43 +02:00
feat(stt-client): ASR enfichable — backend onnx Parakeet/Nemotron en plus de whisper (#32)
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:
parent
470c69881c
commit
4d8cc1968d
7 changed files with 153 additions and 20 deletions
|
|
@ -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 <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` |
|
||||
| — 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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
36
stt/client/stt/voice/asr/__init__.py
Normal file
36
stt/client/stt/voice/asr/__init__.py
Normal 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')"
|
||||
)
|
||||
60
stt/client/stt/voice/asr/onnx_parakeet.py
Normal file
60
stt/client/stt/voice/asr/onnx_parakeet.py
Normal 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()
|
||||
36
stt/client/stt/voice/asr/whisper.py
Normal file
36
stt/client/stt/voice/asr/whisper.py
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue