main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. # -*- coding: utf-8 -*-
  2. """
  3. Vosk STT Plugin - Speech-to-Text mit Vosk.
  4. Unterstützt:
  5. - Offline-Erkennung
  6. - Schnell und ressourcenschonend
  7. - Mehrere Sprachen
  8. - Streaming
  9. - Auto-Download von Modellen
  10. """
  11. import asyncio
  12. import json
  13. import time
  14. from pathlib import Path
  15. from typing import Any, AsyncIterator
  16. from trixy_core.plugins import TrixyPlugin
  17. from trixy_core.audio.stt import STTProvider, STTConfig, STTResult, STTState, WordTiming
  18. # Vosk Modell-Katalog mit Download-URLs
  19. VOSK_MODELS = {
  20. "de": {
  21. "name": "vosk-model-de-0.21",
  22. "url": "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip",
  23. "size": "1.9 GB",
  24. "description": "Großes deutsches Modell",
  25. },
  26. "de-small": {
  27. "name": "vosk-model-small-de-0.15",
  28. "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip",
  29. "size": "45 MB",
  30. "description": "Kleines deutsches Modell (schnell)",
  31. },
  32. "en": {
  33. "name": "vosk-model-en-us-0.22",
  34. "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip",
  35. "size": "1.8 GB",
  36. "description": "Großes englisches Modell (US)",
  37. },
  38. "en-small": {
  39. "name": "vosk-model-small-en-us-0.15",
  40. "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip",
  41. "size": "40 MB",
  42. "description": "Kleines englisches Modell (US)",
  43. },
  44. "fr": {
  45. "name": "vosk-model-fr-0.22",
  46. "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.22.zip",
  47. "size": "1.4 GB",
  48. "description": "Großes französisches Modell",
  49. },
  50. "fr-small": {
  51. "name": "vosk-model-small-fr-0.22",
  52. "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip",
  53. "size": "41 MB",
  54. "description": "Kleines französisches Modell",
  55. },
  56. "es": {
  57. "name": "vosk-model-es-0.42",
  58. "url": "https://alphacephei.com/vosk/models/vosk-model-es-0.42.zip",
  59. "size": "1.4 GB",
  60. "description": "Großes spanisches Modell",
  61. },
  62. "it": {
  63. "name": "vosk-model-it-0.22",
  64. "url": "https://alphacephei.com/vosk/models/vosk-model-it-0.22.zip",
  65. "size": "1.2 GB",
  66. "description": "Großes italienisches Modell",
  67. },
  68. "ru": {
  69. "name": "vosk-model-ru-0.42",
  70. "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.42.zip",
  71. "size": "1.8 GB",
  72. "description": "Großes russisches Modell",
  73. },
  74. "cn": {
  75. "name": "vosk-model-cn-0.22",
  76. "url": "https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip",
  77. "size": "1.3 GB",
  78. "description": "Großes chinesisches Modell",
  79. },
  80. }
  81. class VoskSTTProvider(STTProvider):
  82. """Vosk-basierter STT-Provider."""
  83. def __init__(
  84. self,
  85. config: STTConfig | None = None,
  86. models_dir: Path | None = None,
  87. model_name: str = "de",
  88. auto_download: bool = True,
  89. event_manager: Any = None,
  90. ):
  91. super().__init__(config)
  92. self._models_dir = models_dir
  93. self._model_name = model_name
  94. self._auto_download = auto_download
  95. self._event_manager = event_manager
  96. self._model = None
  97. self._recognizer = None
  98. self._model_path: Path | None = None
  99. @property
  100. def name(self) -> str:
  101. return "vosk"
  102. @property
  103. def supported_languages(self) -> list[str]:
  104. return ["de-DE", "en-US", "fr-FR", "es-ES", "it-IT", "ru-RU", "zh-CN"]
  105. @property
  106. def supports_streaming(self) -> bool:
  107. return True
  108. @property
  109. def supports_word_timings(self) -> bool:
  110. return True
  111. async def initialize(self) -> None:
  112. """Lädt das Vosk-Modell."""
  113. try:
  114. from vosk import Model, KaldiRecognizer, SetLogLevel
  115. # Vosk-Logging reduzieren
  116. SetLogLevel(-1)
  117. self._state = STTState.PROCESSING
  118. # Modell-Info
  119. if self._model_name not in VOSK_MODELS:
  120. raise RuntimeError(
  121. f"Unbekanntes Modell: {self._model_name}. "
  122. f"Verfügbar: {', '.join(VOSK_MODELS.keys())}"
  123. )
  124. model_info = VOSK_MODELS[self._model_name]
  125. model_dir_name = model_info["name"]
  126. self._model_path = self._models_dir / model_dir_name
  127. # Auto-Download wenn nicht vorhanden
  128. if not self._model_path.exists():
  129. if self._auto_download:
  130. await self._download_model()
  131. else:
  132. raise RuntimeError(
  133. f"Vosk-Modell nicht gefunden: {self._model_path}. "
  134. "Auto-Download ist deaktiviert."
  135. )
  136. # Modell laden (in Thread)
  137. loop = asyncio.get_event_loop()
  138. self._model = await loop.run_in_executor(
  139. None,
  140. lambda: Model(str(self._model_path))
  141. )
  142. # Recognizer erstellen
  143. self._recognizer = KaldiRecognizer(
  144. self._model,
  145. self._config.sample_rate
  146. )
  147. self._recognizer.SetWords(True)
  148. self._model_loaded = True
  149. self._state = STTState.READY
  150. except ImportError:
  151. raise RuntimeError(
  152. "Vosk nicht installiert. "
  153. "Installieren mit: pip install vosk"
  154. )
  155. except Exception as e:
  156. self._state = STTState.ERROR
  157. raise RuntimeError(f"Fehler beim Laden des Vosk-Modells: {e}")
  158. async def _download_model(self) -> None:
  159. """Lädt das Modell herunter und extrahiert es."""
  160. from trixy_core.utils.debug import pinfo
  161. from trixy_core.utils.download import download_and_extract
  162. model_info = VOSK_MODELS[self._model_name]
  163. url = model_info["url"]
  164. size = model_info["size"]
  165. self._models_dir.mkdir(parents=True, exist_ok=True)
  166. pinfo(f"Lade Vosk-Modell: {self._model_name} ({size})...")
  167. # Progressbar wird automatisch von DownloadProgress angezeigt
  168. success = await download_and_extract(
  169. url=url,
  170. dest_dir=self._models_dir,
  171. event_manager=self._event_manager,
  172. download_id=f"vosk-{self._model_name}",
  173. )
  174. if not success:
  175. raise RuntimeError(f"Download des Modells fehlgeschlagen: {self._model_name}")
  176. pinfo(f"Vosk-Modell heruntergeladen: {self._model_name}")
  177. async def shutdown(self) -> None:
  178. """Gibt Ressourcen frei."""
  179. self._model = None
  180. self._recognizer = None
  181. self._model_loaded = False
  182. self._state = STTState.UNINITIALIZED
  183. async def transcribe(
  184. self,
  185. audio_data: bytes,
  186. language: str | None = None,
  187. ) -> STTResult:
  188. """Transkribiert Audio mit Vosk."""
  189. if not self._model or not self._recognizer:
  190. raise RuntimeError("Vosk-Modell nicht geladen")
  191. self._state = STTState.PROCESSING
  192. start_time = time.time()
  193. try:
  194. from vosk import KaldiRecognizer
  195. # Neuen Recognizer für diese Anfrage
  196. recognizer = KaldiRecognizer(self._model, self._config.sample_rate)
  197. recognizer.SetWords(True)
  198. # Audio verarbeiten (in Thread)
  199. loop = asyncio.get_event_loop()
  200. def do_recognize():
  201. recognizer.AcceptWaveform(audio_data)
  202. return recognizer.FinalResult()
  203. result_json = await loop.run_in_executor(None, do_recognize)
  204. result = json.loads(result_json)
  205. processing_time = (time.time() - start_time) * 1000
  206. # Wort-Timings extrahieren
  207. word_timings = []
  208. if "result" in result:
  209. for word_info in result["result"]:
  210. word_timings.append(WordTiming(
  211. word=word_info["word"],
  212. start_time=word_info["start"],
  213. end_time=word_info["end"],
  214. confidence=word_info.get("conf", 1.0),
  215. ))
  216. self._state = STTState.READY
  217. return STTResult(
  218. text=result.get("text", "").strip(),
  219. confidence=self._calculate_confidence(result),
  220. language=language or self._config.language,
  221. duration_seconds=self.get_audio_duration(audio_data),
  222. processing_time_ms=processing_time,
  223. word_timings=word_timings,
  224. provider="vosk",
  225. model=self._model_name,
  226. raw_result=result,
  227. )
  228. except Exception as e:
  229. self._state = STTState.ERROR
  230. raise RuntimeError(f"Vosk-Transkription fehlgeschlagen: {e}")
  231. def _calculate_confidence(self, result: dict) -> float:
  232. """Berechnet Durchschnitts-Confidence."""
  233. if "result" not in result or not result["result"]:
  234. return 0.0
  235. confidences = [w.get("conf", 1.0) for w in result["result"]]
  236. return sum(confidences) / len(confidences)
  237. async def transcribe_stream(
  238. self,
  239. audio_stream: AsyncIterator[bytes],
  240. language: str | None = None,
  241. ) -> AsyncIterator[STTResult]:
  242. """Transkribiert Audio-Stream mit Vosk."""
  243. if not self._model:
  244. raise RuntimeError("Vosk-Modell nicht geladen")
  245. from vosk import KaldiRecognizer
  246. recognizer = KaldiRecognizer(self._model, self._config.sample_rate)
  247. recognizer.SetWords(True)
  248. start_time = time.time()
  249. all_audio = b""
  250. async for chunk in audio_stream:
  251. all_audio += chunk
  252. # Partial result
  253. if recognizer.AcceptWaveform(chunk):
  254. result_json = recognizer.Result()
  255. result = json.loads(result_json)
  256. if result.get("text"):
  257. yield STTResult(
  258. text=result["text"].strip(),
  259. confidence=self._calculate_confidence(result),
  260. language=language or self._config.language,
  261. is_final=False,
  262. provider="vosk",
  263. model=self._model_name,
  264. )
  265. # Final result
  266. result_json = recognizer.FinalResult()
  267. result = json.loads(result_json)
  268. processing_time = (time.time() - start_time) * 1000
  269. word_timings = []
  270. if "result" in result:
  271. for word_info in result["result"]:
  272. word_timings.append(WordTiming(
  273. word=word_info["word"],
  274. start_time=word_info["start"],
  275. end_time=word_info["end"],
  276. confidence=word_info.get("conf", 1.0),
  277. ))
  278. yield STTResult(
  279. text=result.get("text", "").strip(),
  280. confidence=self._calculate_confidence(result),
  281. language=language or self._config.language,
  282. duration_seconds=self.get_audio_duration(all_audio),
  283. processing_time_ms=processing_time,
  284. word_timings=word_timings,
  285. is_final=True,
  286. provider="vosk",
  287. model=self._model_name,
  288. raw_result=result,
  289. )
  290. class VoskSTTPlugin(TrixyPlugin):
  291. """Vosk STT Plugin für Trixy."""
  292. def __init__(self, application, plugin_path, config: dict | None = None):
  293. super().__init__(application, plugin_path, config)
  294. self._provider: VoskSTTProvider | None = None
  295. async def on_load(self) -> None:
  296. """Plugin wird geladen."""
  297. from trixy_core.utils.debug import pinfo
  298. pinfo("Vosk STT Plugin: Lade...")
  299. # Konfiguration
  300. model_name = self.config.get("model", "de-small")
  301. language = self.config.get("language", "de-DE")
  302. auto_download = self.config.get("auto_download", True)
  303. # Models-Verzeichnis im Plugin-Ordner
  304. models_dir = self.plugin_path / "models"
  305. models_dir.mkdir(parents=True, exist_ok=True)
  306. stt_config = STTConfig(
  307. language=language,
  308. )
  309. # Provider erstellen
  310. # Provider erstellen (mit EventManager für Download-Events)
  311. self._provider = VoskSTTProvider(
  312. stt_config,
  313. models_dir=models_dir,
  314. model_name=model_name,
  315. auto_download=auto_download,
  316. event_manager=self.application.events,
  317. )
  318. # Modell laden (und ggf. downloaden)
  319. await self._provider.initialize()
  320. # Extension registrieren
  321. if hasattr(self.application, "extension_points"):
  322. ext_point = self.application.extension_points.get("conversation.stt")
  323. if ext_point:
  324. ext_point.register(self._provider)
  325. pinfo("Vosk STT Plugin: Extension registriert")
  326. # Event-Handler registrieren
  327. self._register_event_handlers()
  328. pinfo(f"Vosk STT Plugin: Geladen (Modell: {model_name})")
  329. def _register_event_handlers(self) -> None:
  330. """Registriert Event-Handler."""
  331. em = self.application.events
  332. @em.on("raw_audio_received")
  333. async def on_audio_received(event_name: str, data: dict) -> None:
  334. """Verarbeitet empfangene Audio-Daten."""
  335. if not self._provider or not self._provider.is_ready:
  336. return
  337. audio_data = data.get("audio_data")
  338. satellite_id = data.get("satellite_id")
  339. session_id = data.get("session_id")
  340. if not audio_data:
  341. return
  342. if isinstance(audio_data, str):
  343. audio_data = bytes.fromhex(audio_data)
  344. from trixy_core.utils.debug import pinfo
  345. pinfo(f"Vosk STT: Verarbeite Audio ({len(audio_data)} bytes)")
  346. try:
  347. result = await self._provider.transcribe(audio_data)
  348. await em.emit("stt_completed", {
  349. "text": result.text,
  350. "confidence": result.confidence,
  351. "language": result.language,
  352. "provider": "vosk",
  353. "satellite_id": satellite_id,
  354. "session_id": session_id,
  355. "duration_seconds": result.duration_seconds,
  356. "processing_time_ms": result.processing_time_ms,
  357. "word_timings": [w.__dict__ for w in result.word_timings],
  358. })
  359. pinfo(f"Vosk STT: '{result.text}' ({result.processing_time_ms:.0f}ms)")
  360. # Event-Brücke: speech_recognized für NLP-Pipeline
  361. if result.text.strip():
  362. from trixy_core.events.event_data.basic import SpeechRecognized
  363. speech_event = SpeechRecognized(
  364. satellite_id=satellite_id or "",
  365. text=result.text,
  366. confidence=result.confidence,
  367. language=result.language or "de",
  368. is_final=True,
  369. source="stt",
  370. )
  371. speech_event.metadata["session_id"] = session_id or ""
  372. await em.trigger("speech_recognized", speech_event)
  373. except Exception as e:
  374. from trixy_core.utils.debug import perror
  375. perror(f"Vosk STT Fehler: {e}")
  376. await em.emit("stt_error", {
  377. "error": str(e),
  378. "provider": "vosk",
  379. "satellite_id": satellite_id,
  380. "session_id": session_id,
  381. })
  382. async def on_unload(self) -> None:
  383. """Plugin wird entladen."""
  384. if self._provider:
  385. await self._provider.shutdown()
  386. self._provider = None
  387. from trixy_core.utils.debug import pinfo
  388. pinfo("Vosk STT Plugin: Entladen")
  389. @property
  390. def provider(self) -> VoskSTTProvider | None:
  391. """STT-Provider."""
  392. return self._provider
  393. # Plugin-Export
  394. Plugin = VoskSTTPlugin