mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 06:24:42 +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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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 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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue