main.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. # -*- coding: utf-8 -*-
  2. """
  3. Piper TTS Plugin - Text-to-Speech mit Piper.
  4. Piper ist ein schnelles, lokales TTS-System basierend auf VITS.
  5. Unterstützt:
  6. - Lokale Synthese (offline)
  7. - Sehr schnell (Echtzeit auf CPU)
  8. - Hochwertige Stimmen
  9. - Mehrere Sprachen
  10. - Auto-Download von Hugging Face
  11. """
  12. import asyncio
  13. import io
  14. import time
  15. import wave
  16. from pathlib import Path
  17. from typing import Any, AsyncIterator
  18. from trixy_core.plugins import TrixyPlugin
  19. from trixy_core.audio.tts import TTSProvider, TTSConfig, TTSResult, TTSState, Voice
  20. # Piper Voice Katalog mit Hugging Face Pfaden
  21. PIPER_VOICES = {
  22. # Deutsche Stimmen
  23. "de_DE-thorsten-high": {
  24. "name": "Thorsten (High)",
  25. "language": "de-DE",
  26. "gender": "male",
  27. "description": "Deutsche männliche Stimme, hohe Qualität",
  28. "sample_rate": 22050,
  29. "hf_path": "de/de_DE/thorsten/high/de_DE-thorsten-high.onnx",
  30. },
  31. "de_DE-thorsten-medium": {
  32. "name": "Thorsten (Medium)",
  33. "language": "de-DE",
  34. "gender": "male",
  35. "description": "Deutsche männliche Stimme, mittlere Qualität",
  36. "sample_rate": 22050,
  37. "hf_path": "de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx",
  38. },
  39. "de_DE-thorsten-low": {
  40. "name": "Thorsten (Low)",
  41. "language": "de-DE",
  42. "gender": "male",
  43. "description": "Deutsche männliche Stimme, niedrige Qualität (schnell)",
  44. "sample_rate": 22050,
  45. "hf_path": "de/de_DE/thorsten/low/de_DE-thorsten-low.onnx",
  46. },
  47. "de_DE-eva_k-x_low": {
  48. "name": "Eva",
  49. "language": "de-DE",
  50. "gender": "female",
  51. "description": "Deutsche weibliche Stimme",
  52. "sample_rate": 22050,
  53. "hf_path": "de/de_DE/eva_k/x_low/de_DE-eva_k-x_low.onnx",
  54. },
  55. # Englische Stimmen
  56. "en_US-lessac-medium": {
  57. "name": "Lessac (Medium)",
  58. "language": "en-US",
  59. "gender": "female",
  60. "description": "Englische weibliche Stimme (US)",
  61. "sample_rate": 22050,
  62. "hf_path": "en/en_US/lessac/medium/en_US-lessac-medium.onnx",
  63. },
  64. "en_US-amy-medium": {
  65. "name": "Amy (Medium)",
  66. "language": "en-US",
  67. "gender": "female",
  68. "description": "Englische weibliche Stimme (US)",
  69. "sample_rate": 22050,
  70. "hf_path": "en/en_US/amy/medium/en_US-amy-medium.onnx",
  71. },
  72. "en_GB-alan-medium": {
  73. "name": "Alan (Medium)",
  74. "language": "en-GB",
  75. "gender": "male",
  76. "description": "Englische männliche Stimme (UK)",
  77. "sample_rate": 22050,
  78. "hf_path": "en/en_GB/alan/medium/en_GB-alan-medium.onnx",
  79. },
  80. }
  81. HUGGINGFACE_REPO = "rhasspy/piper-voices"
  82. class PiperTTSProvider(TTSProvider):
  83. """Piper-basierter TTS-Provider."""
  84. def __init__(
  85. self,
  86. config: TTSConfig | None = None,
  87. models_dir: Path | None = None,
  88. voice_id: str = "de_DE-thorsten-medium",
  89. auto_download: bool = True,
  90. event_manager: Any = None,
  91. ):
  92. super().__init__(config)
  93. self._models_dir = models_dir
  94. self._voice_id = voice_id
  95. self._auto_download = auto_download
  96. self._event_manager = event_manager
  97. self._piper = None
  98. self._model_path: Path | None = None
  99. @property
  100. def name(self) -> str:
  101. return "piper"
  102. @property
  103. def supported_languages(self) -> list[str]:
  104. return ["de-DE", "en-US", "en-GB", "fr-FR", "es-ES", "it-IT", "nl-NL", "pl-PL"]
  105. @property
  106. def supports_streaming(self) -> bool:
  107. return True
  108. async def initialize(self) -> None:
  109. """Lädt das Piper-Modell."""
  110. try:
  111. from piper import PiperVoice
  112. self._state = TTSState.SYNTHESIZING
  113. # Modell-Pfad bestimmen
  114. model_file = f"{self._voice_id}.onnx"
  115. json_file = f"{self._voice_id}.onnx.json"
  116. self._model_path = self._models_dir / model_file
  117. json_path = self._models_dir / json_file
  118. # Auto-Download wenn nicht vorhanden
  119. if not self._model_path.exists():
  120. if self._auto_download:
  121. await self._download_model()
  122. else:
  123. raise RuntimeError(
  124. f"Piper-Modell nicht gefunden: {self._model_path}. "
  125. "Auto-Download ist deaktiviert."
  126. )
  127. # Piper laden (in Thread)
  128. loop = asyncio.get_event_loop()
  129. self._piper = await loop.run_in_executor(
  130. None,
  131. lambda: PiperVoice.load(str(self._model_path))
  132. )
  133. # Aktuelle Stimme setzen
  134. if self._voice_id in PIPER_VOICES:
  135. voice_info = PIPER_VOICES[self._voice_id]
  136. self._current_voice = Voice(
  137. id=self._voice_id,
  138. name=voice_info["name"],
  139. language=voice_info["language"],
  140. gender=voice_info["gender"],
  141. description=voice_info["description"],
  142. sample_rate=voice_info["sample_rate"],
  143. )
  144. self._model_loaded = True
  145. self._state = TTSState.READY
  146. except ImportError:
  147. raise RuntimeError(
  148. "Piper nicht installiert. "
  149. "Installieren mit: pip install piper-tts"
  150. )
  151. except Exception as e:
  152. self._state = TTSState.ERROR
  153. raise RuntimeError(f"Fehler beim Laden des Piper-Modells: {e}")
  154. async def _download_model(self) -> None:
  155. """Lädt das Modell von Hugging Face herunter."""
  156. from trixy_core.utils.debug import pinfo
  157. from trixy_core.utils.download import download_from_huggingface
  158. if self._voice_id not in PIPER_VOICES:
  159. raise RuntimeError(
  160. f"Unbekannte Stimme: {self._voice_id}. "
  161. f"Verfügbar: {', '.join(PIPER_VOICES.keys())}"
  162. )
  163. voice_info = PIPER_VOICES[self._voice_id]
  164. hf_path = voice_info["hf_path"]
  165. hf_json_path = hf_path + ".json"
  166. self._models_dir.mkdir(parents=True, exist_ok=True)
  167. # ONNX-Modell herunterladen
  168. pinfo(f"Lade Piper-Modell: {self._voice_id}...")
  169. model_dest = self._models_dir / f"{self._voice_id}.onnx"
  170. success = await download_from_huggingface(
  171. repo_id=HUGGINGFACE_REPO,
  172. filename=hf_path,
  173. dest_path=model_dest,
  174. event_manager=self._event_manager,
  175. download_id=f"piper-{self._voice_id}",
  176. )
  177. if not success:
  178. raise RuntimeError(f"Download des Modells fehlgeschlagen: {self._voice_id}")
  179. # JSON-Config herunterladen
  180. json_dest = self._models_dir / f"{self._voice_id}.onnx.json"
  181. await download_from_huggingface(
  182. repo_id=HUGGINGFACE_REPO,
  183. filename=hf_json_path,
  184. dest_path=json_dest,
  185. event_manager=self._event_manager,
  186. download_id=f"piper-{self._voice_id}-json",
  187. )
  188. pinfo(f"Piper-Modell heruntergeladen: {self._voice_id}")
  189. async def shutdown(self) -> None:
  190. """Gibt Ressourcen frei."""
  191. self._piper = None
  192. self._model_loaded = False
  193. self._state = TTSState.UNINITIALIZED
  194. async def get_voices(self, language: str | None = None) -> list[Voice]:
  195. """Gibt verfügbare Stimmen zurück."""
  196. voices = []
  197. for voice_id, info in PIPER_VOICES.items():
  198. voices.append(Voice(
  199. id=voice_id,
  200. name=info["name"],
  201. language=info["language"],
  202. gender=info["gender"],
  203. description=info["description"],
  204. sample_rate=info["sample_rate"],
  205. ))
  206. if language:
  207. lang_prefix = language.replace("-", "_").split("_")[0]
  208. voices = [v for v in voices if v.language.replace("-", "_").startswith(lang_prefix)]
  209. return voices
  210. async def synthesize(
  211. self,
  212. text: str,
  213. voice_id: str | None = None,
  214. language: str | None = None,
  215. ) -> TTSResult:
  216. """Synthetisiert Text mit Piper."""
  217. if not self._piper:
  218. raise RuntimeError("Piper nicht geladen")
  219. self._state = TTSState.SYNTHESIZING
  220. start_time = time.time()
  221. try:
  222. # Audio-Buffer
  223. audio_buffer = io.BytesIO()
  224. # WAV-Writer
  225. with wave.open(audio_buffer, "wb") as wav_file:
  226. wav_file.setnchannels(1)
  227. wav_file.setsampwidth(2) # 16-bit
  228. wav_file.setframerate(self._piper.config.sample_rate)
  229. # Synthese in Thread
  230. loop = asyncio.get_event_loop()
  231. def do_synthesize():
  232. for audio_chunk in self._piper.synthesize(text):
  233. wav_file.writeframes(audio_chunk.audio_int16_bytes)
  234. await loop.run_in_executor(None, do_synthesize)
  235. processing_time = (time.time() - start_time) * 1000
  236. audio_data = audio_buffer.getvalue()
  237. self._state = TTSState.READY
  238. # Dauer berechnen
  239. sample_rate = self._piper.config.sample_rate
  240. # WAV hat 44 bytes Header
  241. pcm_data = audio_data[44:]
  242. num_samples = len(pcm_data) // 2 # 16-bit = 2 bytes
  243. duration = num_samples / sample_rate
  244. return TTSResult(
  245. audio_data=audio_data,
  246. sample_rate=sample_rate,
  247. channels=1,
  248. text=text,
  249. duration_seconds=duration,
  250. processing_time_ms=processing_time,
  251. voice_id=voice_id or self._voice_id,
  252. voice_name=self._current_voice.name if self._current_voice else "",
  253. language=language or self._config.language,
  254. provider="piper",
  255. model=self._voice_id,
  256. )
  257. except Exception as e:
  258. self._state = TTSState.ERROR
  259. raise RuntimeError(f"Piper-Synthese fehlgeschlagen: {e}")
  260. async def synthesize_stream(
  261. self,
  262. text: str,
  263. voice_id: str | None = None,
  264. language: str | None = None,
  265. chunk_size: int = 4096,
  266. ) -> AsyncIterator[bytes]:
  267. """Synthetisiert Text als Stream."""
  268. if not self._piper:
  269. raise RuntimeError("Piper nicht geladen")
  270. self._state = TTSState.SYNTHESIZING
  271. try:
  272. loop = asyncio.get_event_loop()
  273. # Generator für Chunks
  274. def generate_chunks():
  275. for audio_chunk in self._piper.synthesize(text):
  276. audio_bytes = audio_chunk.audio_int16_bytes
  277. # In Chunks aufteilen
  278. for i in range(0, len(audio_bytes), chunk_size):
  279. yield audio_bytes[i:i + chunk_size]
  280. # Chunks yielden
  281. chunks = await loop.run_in_executor(None, lambda: list(generate_chunks()))
  282. for chunk in chunks:
  283. yield chunk
  284. self._state = TTSState.READY
  285. except Exception as e:
  286. self._state = TTSState.ERROR
  287. raise RuntimeError(f"Piper-Stream fehlgeschlagen: {e}")
  288. class PiperTTSPlugin(TrixyPlugin):
  289. """Piper TTS Plugin für Trixy."""
  290. def __init__(self, application, plugin_path, config: dict | None = None):
  291. super().__init__(application, plugin_path, config)
  292. self._provider: PiperTTSProvider | None = None
  293. async def on_load(self) -> None:
  294. """Plugin wird geladen."""
  295. from trixy_core.utils.debug import pinfo
  296. pinfo("Piper TTS Plugin: Lade...")
  297. # Konfiguration
  298. voice_id = self.config.get("voice", "de_DE-thorsten-medium")
  299. language = self.config.get("language", "de-DE")
  300. auto_download = self.config.get("auto_download", True)
  301. # Models-Verzeichnis im Plugin-Ordner
  302. models_dir = self.plugin_path / "models"
  303. models_dir.mkdir(parents=True, exist_ok=True)
  304. tts_config = TTSConfig(
  305. language=language,
  306. voice_id=voice_id,
  307. )
  308. # Provider erstellen (mit EventManager für Download-Events)
  309. self._provider = PiperTTSProvider(
  310. tts_config,
  311. models_dir=models_dir,
  312. voice_id=voice_id,
  313. auto_download=auto_download,
  314. event_manager=self.application.events,
  315. )
  316. # Modell laden (und ggf. downloaden)
  317. await self._provider.initialize()
  318. # Extension registrieren
  319. if hasattr(self.application, "extension_points"):
  320. ext_point = self.application.extension_points.get("conversation.tts")
  321. if ext_point:
  322. ext_point.register(self._provider)
  323. pinfo("Piper TTS Plugin: Extension registriert")
  324. # Event-Handler registrieren
  325. self._register_event_handlers()
  326. pinfo(f"Piper TTS Plugin: Geladen (Stimme: {voice_id})")
  327. def _register_event_handlers(self) -> None:
  328. """Registriert Event-Handler."""
  329. em = self.application.events
  330. @em.on("tts_request")
  331. async def on_tts_request(event_name: str, data: dict) -> None:
  332. """Verarbeitet TTS-Anfragen."""
  333. if not self._provider or not self._provider.is_ready:
  334. return
  335. text = data.get("text")
  336. satellite_id = data.get("satellite_id")
  337. session_id = data.get("session_id")
  338. voice_id = data.get("voice_id")
  339. if not text:
  340. return
  341. from trixy_core.utils.debug import pinfo
  342. pinfo(f"Piper TTS: Synthetisiere '{text[:50]}...'")
  343. try:
  344. result = await self._provider.synthesize(text, voice_id=voice_id)
  345. await em.emit("tts_completed", {
  346. "audio_data": result.audio_data.hex(),
  347. "sample_rate": result.sample_rate,
  348. "duration_seconds": result.duration_seconds,
  349. "provider": "piper",
  350. "text": text,
  351. "satellite_id": satellite_id,
  352. "session_id": session_id,
  353. "processing_time_ms": result.processing_time_ms,
  354. })
  355. pinfo(f"Piper TTS: Fertig ({result.duration_seconds:.1f}s Audio, "
  356. f"{result.processing_time_ms:.0f}ms)")
  357. except Exception as e:
  358. from trixy_core.utils.debug import perror
  359. perror(f"Piper TTS Fehler: {e}")
  360. await em.emit("tts_error", {
  361. "error": str(e),
  362. "provider": "piper",
  363. "text": text,
  364. "satellite_id": satellite_id,
  365. "session_id": session_id,
  366. })
  367. async def on_unload(self) -> None:
  368. """Plugin wird entladen."""
  369. if self._provider:
  370. await self._provider.shutdown()
  371. self._provider = None
  372. from trixy_core.utils.debug import pinfo
  373. pinfo("Piper TTS Plugin: Entladen")
  374. @property
  375. def provider(self) -> PiperTTSProvider | None:
  376. """TTS-Provider."""
  377. return self._provider
  378. # Plugin-Export
  379. Plugin = PiperTTSPlugin