main.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. # -*- coding: utf-8 -*-
  2. """
  3. Whisper STT Plugin - Speech-to-Text mit OpenAI Whisper.
  4. Unterstützt:
  5. - Lokale Modelle (tiny, base, small, medium, large)
  6. - GPU-Beschleunigung
  7. - Mehrere Sprachen
  8. - Wort-Timings
  9. - Automatischer Modell-Download
  10. """
  11. import asyncio
  12. import os
  13. import time
  14. from pathlib import Path
  15. from typing import Any
  16. from trixy_core.plugins import TrixyPlugin
  17. from trixy_core.audio.stt import STTProvider, STTConfig, STTResult, STTState, WordTiming
  18. # Whisper Modell-Größen
  19. WHISPER_MODELS = {
  20. "tiny": {"size": "39 MB", "description": "Schnellstes Modell, geringste Genauigkeit"},
  21. "tiny.en": {"size": "39 MB", "description": "Nur Englisch, schnell"},
  22. "base": {"size": "74 MB", "description": "Gute Balance zwischen Geschwindigkeit und Genauigkeit"},
  23. "base.en": {"size": "74 MB", "description": "Nur Englisch, gute Balance"},
  24. "small": {"size": "244 MB", "description": "Bessere Genauigkeit, langsamer"},
  25. "small.en": {"size": "244 MB", "description": "Nur Englisch, bessere Genauigkeit"},
  26. "medium": {"size": "769 MB", "description": "Hohe Genauigkeit"},
  27. "medium.en": {"size": "769 MB", "description": "Nur Englisch, hohe Genauigkeit"},
  28. "large": {"size": "1550 MB", "description": "Höchste Genauigkeit, langsam"},
  29. "large-v2": {"size": "1550 MB", "description": "Verbesserte Version von large"},
  30. "large-v3": {"size": "1550 MB", "description": "Neueste Version, beste Genauigkeit"},
  31. }
  32. class WhisperSTTProvider(STTProvider):
  33. """Whisper-basierter STT-Provider."""
  34. def __init__(
  35. self,
  36. config: STTConfig | None = None,
  37. models_dir: Path | None = None,
  38. model_name: str = "base",
  39. ):
  40. super().__init__(config)
  41. self._models_dir = models_dir
  42. self._model_name = model_name
  43. self._model = None
  44. self._device = "cuda" if config and config.use_gpu else "cpu"
  45. @property
  46. def name(self) -> str:
  47. return "whisper"
  48. @property
  49. def supported_languages(self) -> list[str]:
  50. return [
  51. "de-DE", "en-US", "en-GB", "fr-FR", "es-ES", "it-IT",
  52. "pt-PT", "nl-NL", "pl-PL", "ru-RU", "ja-JP", "zh-CN",
  53. "ko-KR", "ar-SA", "hi-IN", "tr-TR", "vi-VN", "th-TH",
  54. ]
  55. @property
  56. def supports_word_timings(self) -> bool:
  57. return True
  58. async def initialize(self) -> None:
  59. """Lädt das Whisper-Modell."""
  60. try:
  61. import whisper
  62. from trixy_core.utils.debug import pinfo
  63. self._state = STTState.PROCESSING
  64. # Modell-Verzeichnis setzen (Whisper cached hier)
  65. if self._models_dir:
  66. self._models_dir.mkdir(parents=True, exist_ok=True)
  67. # Whisper nutzt XDG_CACHE_HOME oder ~/.cache
  68. os.environ["XDG_CACHE_HOME"] = str(self._models_dir)
  69. # Prüfen ob Modell existiert
  70. model_info = WHISPER_MODELS.get(self._model_name, {})
  71. size = model_info.get("size", "unbekannt")
  72. pinfo(f"Lade Whisper-Modell: {self._model_name} ({size})...")
  73. pinfo("Bei erstem Start wird das Modell heruntergeladen...")
  74. # Modell laden (in Thread wegen blocking)
  75. loop = asyncio.get_event_loop()
  76. self._model = await loop.run_in_executor(
  77. None,
  78. lambda: whisper.load_model(
  79. self._model_name,
  80. device=self._device,
  81. download_root=str(self._models_dir) if self._models_dir else None
  82. )
  83. )
  84. self._model_loaded = True
  85. self._state = STTState.READY
  86. pinfo(f"Whisper-Modell geladen: {self._model_name}")
  87. except ImportError:
  88. raise RuntimeError(
  89. "Whisper nicht installiert. "
  90. "Installieren mit: pip install openai-whisper"
  91. )
  92. except Exception as e:
  93. self._state = STTState.ERROR
  94. raise RuntimeError(f"Fehler beim Laden des Whisper-Modells: {e}")
  95. async def shutdown(self) -> None:
  96. """Gibt Ressourcen frei."""
  97. self._model = None
  98. self._model_loaded = False
  99. self._state = STTState.UNINITIALIZED
  100. async def transcribe(
  101. self,
  102. audio_data: bytes,
  103. language: str | None = None,
  104. ) -> STTResult:
  105. """Transkribiert Audio mit Whisper."""
  106. if not self._model:
  107. raise RuntimeError("Whisper-Modell nicht geladen")
  108. self._state = STTState.PROCESSING
  109. start_time = time.time()
  110. try:
  111. import numpy as np
  112. import whisper
  113. # Audio-Daten zu numpy array
  114. audio_np = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
  115. # Sprache bestimmen
  116. lang = None
  117. if language:
  118. lang = language.split("-")[0] # "de-DE" -> "de"
  119. # Transkription in Thread
  120. loop = asyncio.get_event_loop()
  121. def do_transcribe():
  122. return self._model.transcribe(
  123. audio_np,
  124. language=lang,
  125. word_timestamps=True,
  126. fp16=(self._device == "cuda"),
  127. )
  128. result = await loop.run_in_executor(None, do_transcribe)
  129. processing_time = (time.time() - start_time) * 1000
  130. # Wort-Timings extrahieren
  131. word_timings = []
  132. if "segments" in result:
  133. for segment in result["segments"]:
  134. if "words" in segment:
  135. for word_info in segment["words"]:
  136. word_timings.append(WordTiming(
  137. word=word_info["word"].strip(),
  138. start_time=word_info["start"],
  139. end_time=word_info["end"],
  140. confidence=word_info.get("probability", 1.0),
  141. ))
  142. self._state = STTState.READY
  143. return STTResult(
  144. text=result["text"].strip(),
  145. confidence=1.0, # Whisper gibt keine Confidence zurück
  146. language=result.get("language", language or "de"),
  147. duration_seconds=self.get_audio_duration(audio_data),
  148. processing_time_ms=processing_time,
  149. word_timings=word_timings,
  150. provider="whisper",
  151. model=self._model_name,
  152. raw_result=result,
  153. )
  154. except Exception as e:
  155. self._state = STTState.ERROR
  156. raise RuntimeError(f"Whisper-Transkription fehlgeschlagen: {e}")
  157. class WhisperSTTPlugin(TrixyPlugin):
  158. """Whisper STT Plugin für Trixy."""
  159. def __init__(self, application, plugin_path, config: dict | None = None):
  160. super().__init__(application, plugin_path, config)
  161. self._provider: WhisperSTTProvider | None = None
  162. async def on_load(self) -> None:
  163. """Plugin wird geladen."""
  164. from trixy_core.utils.debug import pinfo
  165. pinfo("Whisper STT Plugin: Lade...")
  166. # Konfiguration
  167. model = self.config.get("model", "base")
  168. use_gpu = self.config.get("use_gpu", False)
  169. language = self.config.get("language", "de-DE")
  170. # Models-Verzeichnis im Plugin-Ordner
  171. models_dir = self.plugin_path / "models"
  172. models_dir.mkdir(parents=True, exist_ok=True)
  173. stt_config = STTConfig(
  174. language=language,
  175. use_gpu=use_gpu,
  176. model_name=model,
  177. )
  178. # Provider erstellen
  179. self._provider = WhisperSTTProvider(
  180. stt_config,
  181. models_dir=models_dir,
  182. model_name=model,
  183. )
  184. # Modell laden (und ggf. downloaden)
  185. await self._provider.initialize()
  186. # Extension registrieren
  187. if hasattr(self.application, "extension_points"):
  188. ext_point = self.application.extension_points.get("conversation.stt")
  189. if ext_point:
  190. ext_point.register(self._provider)
  191. pinfo("Whisper STT Plugin: Extension registriert")
  192. # Event-Handler registrieren
  193. self._register_event_handlers()
  194. pinfo(f"Whisper STT Plugin: Geladen (Modell: {model})")
  195. def _register_event_handlers(self) -> None:
  196. """Registriert Event-Handler."""
  197. em = self.application.events
  198. @em.on("raw_audio_received")
  199. async def on_audio_received(event_name: str, data: dict) -> None:
  200. """Verarbeitet empfangene Audio-Daten."""
  201. if not self._provider or not self._provider.is_ready:
  202. return
  203. audio_data = data.get("audio_data")
  204. satellite_id = data.get("satellite_id")
  205. session_id = data.get("session_id")
  206. if not audio_data:
  207. return
  208. # Audio in bytes konvertieren falls hex-encoded
  209. if isinstance(audio_data, str):
  210. audio_data = bytes.fromhex(audio_data)
  211. from trixy_core.utils.debug import pinfo
  212. pinfo(f"Whisper STT: Verarbeite Audio ({len(audio_data)} bytes)")
  213. try:
  214. # Transkribieren
  215. result = await self._provider.transcribe(audio_data)
  216. # Event emittieren
  217. await em.emit("stt_completed", {
  218. "text": result.text,
  219. "confidence": result.confidence,
  220. "language": result.language,
  221. "provider": "whisper",
  222. "satellite_id": satellite_id,
  223. "session_id": session_id,
  224. "duration_seconds": result.duration_seconds,
  225. "processing_time_ms": result.processing_time_ms,
  226. "word_timings": [w.__dict__ for w in result.word_timings],
  227. })
  228. pinfo(f"Whisper STT: '{result.text}' ({result.processing_time_ms:.0f}ms)")
  229. # Event-Brücke: speech_recognized für NLP-Pipeline
  230. if result.text.strip():
  231. from trixy_core.events.event_data.basic import SpeechRecognized
  232. speech_event = SpeechRecognized(
  233. satellite_id=satellite_id or "",
  234. text=result.text,
  235. confidence=result.confidence,
  236. language=result.language or "de",
  237. is_final=True,
  238. source="stt",
  239. )
  240. speech_event.metadata["session_id"] = session_id or ""
  241. await em.trigger("speech_recognized", speech_event)
  242. except Exception as e:
  243. from trixy_core.utils.debug import perror
  244. perror(f"Whisper STT Fehler: {e}")
  245. await em.emit("stt_error", {
  246. "error": str(e),
  247. "provider": "whisper",
  248. "satellite_id": satellite_id,
  249. "session_id": session_id,
  250. })
  251. async def on_unload(self) -> None:
  252. """Plugin wird entladen."""
  253. if self._provider:
  254. await self._provider.shutdown()
  255. self._provider = None
  256. from trixy_core.utils.debug import pinfo
  257. pinfo("Whisper STT Plugin: Entladen")
  258. @property
  259. def provider(self) -> WhisperSTTProvider | None:
  260. """STT-Provider."""
  261. return self._provider
  262. # Plugin-Export
  263. Plugin = WhisperSTTPlugin