main.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  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 struct
  15. import time
  16. import wave
  17. from pathlib import Path
  18. from typing import Any, AsyncIterator
  19. from trixy_core.plugins import TrixyPlugin
  20. from trixy_core.audio.tts import TTSProvider, TTSConfig, TTSResult, TTSState, Voice
  21. # Piper Voice Katalog mit Hugging Face Pfaden
  22. PIPER_VOICES = {
  23. # Deutsche Stimmen
  24. "de_DE-thorsten-high": {
  25. "name": "Thorsten (High)",
  26. "language": "de-DE",
  27. "gender": "male",
  28. "description": "Deutsche männliche Stimme, hohe Qualität",
  29. "sample_rate": 22050,
  30. "hf_path": "de/de_DE/thorsten/high/de_DE-thorsten-high.onnx",
  31. },
  32. "de_DE-thorsten-medium": {
  33. "name": "Thorsten (Medium)",
  34. "language": "de-DE",
  35. "gender": "male",
  36. "description": "Deutsche männliche Stimme, mittlere Qualität",
  37. "sample_rate": 22050,
  38. "hf_path": "de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx",
  39. },
  40. "de_DE-thorsten-low": {
  41. "name": "Thorsten (Low)",
  42. "language": "de-DE",
  43. "gender": "male",
  44. "description": "Deutsche männliche Stimme, niedrige Qualität (schnell)",
  45. "sample_rate": 22050,
  46. "hf_path": "de/de_DE/thorsten/low/de_DE-thorsten-low.onnx",
  47. },
  48. "de_DE-eva_k-x_low": {
  49. "name": "Eva",
  50. "language": "de-DE",
  51. "gender": "female",
  52. "description": "Deutsche weibliche Stimme",
  53. "sample_rate": 22050,
  54. "hf_path": "de/de_DE/eva_k/x_low/de_DE-eva_k-x_low.onnx",
  55. },
  56. "de_DE-kerstin-low": {
  57. "name": "Kerstin",
  58. "language": "de-DE",
  59. "gender": "female",
  60. "description": "Deutsche weibliche Stimme",
  61. "sample_rate": 16000,
  62. "hf_path": "de/de_DE/kerstin/low/de_DE-kerstin-low.onnx",
  63. },
  64. "de_DE-ramona-low": {
  65. "name": "Ramona",
  66. "language": "de-DE",
  67. "gender": "female",
  68. "description": "Deutsche weibliche Stimme",
  69. "sample_rate": 16000,
  70. "hf_path": "de/de_DE/ramona/low/de_DE-ramona-low.onnx",
  71. },
  72. "de_DE-karlsson-low": {
  73. "name": "Karlsson",
  74. "language": "de-DE",
  75. "gender": "male",
  76. "description": "Deutsche männliche Stimme",
  77. "sample_rate": 16000,
  78. "hf_path": "de/de_DE/karlsson/low/de_DE-karlsson-low.onnx",
  79. },
  80. "de_DE-pavoque-low": {
  81. "name": "Pavoque",
  82. "language": "de-DE",
  83. "gender": "male",
  84. "description": "Deutsche männliche Stimme",
  85. "sample_rate": 16000,
  86. "hf_path": "de/de_DE/pavoque/low/de_DE-pavoque-low.onnx",
  87. },
  88. "de_DE-mls-medium": {
  89. "name": "MLS",
  90. "language": "de-DE",
  91. "gender": "male",
  92. "description": "Deutsche männliche Stimme (Multi-Speaker)",
  93. "sample_rate": 22050,
  94. "hf_path": "de/de_DE/mls/medium/de_DE-mls-medium.onnx",
  95. },
  96. "de_DE-thorsten_emotional-medium": {
  97. "name": "Thorsten Emotional",
  98. "language": "de-DE",
  99. "gender": "male",
  100. "description": "Deutsche männliche Stimme mit Emotionen",
  101. "sample_rate": 22050,
  102. "hf_path": "de/de_DE/thorsten_emotional/medium/de_DE-thorsten_emotional-medium.onnx",
  103. },
  104. # Englische Stimmen
  105. "en_US-lessac-medium": {
  106. "name": "Lessac (Medium)",
  107. "language": "en-US",
  108. "gender": "female",
  109. "description": "Englische weibliche Stimme (US)",
  110. "sample_rate": 22050,
  111. "hf_path": "en/en_US/lessac/medium/en_US-lessac-medium.onnx",
  112. },
  113. "en_US-amy-medium": {
  114. "name": "Amy (Medium)",
  115. "language": "en-US",
  116. "gender": "female",
  117. "description": "Englische weibliche Stimme (US)",
  118. "sample_rate": 22050,
  119. "hf_path": "en/en_US/amy/medium/en_US-amy-medium.onnx",
  120. },
  121. "en_GB-alan-medium": {
  122. "name": "Alan (Medium)",
  123. "language": "en-GB",
  124. "gender": "male",
  125. "description": "Englische männliche Stimme (UK)",
  126. "sample_rate": 22050,
  127. "hf_path": "en/en_GB/alan/medium/en_GB-alan-medium.onnx",
  128. },
  129. }
  130. HUGGINGFACE_REPO = "rhasspy/piper-voices"
  131. def _resample_wav(wav_data: bytes, source_rate: int, target_rate: int) -> bytes:
  132. """Resampled WAV-Daten von source_rate auf target_rate.
  133. Verwendet lineare Interpolation (schnell, gute Qualitaet fuer Sprache).
  134. """
  135. # PCM aus WAV extrahieren (44 Bytes Header)
  136. pcm = wav_data[44:]
  137. # int16 → float
  138. n_samples = len(pcm) // 2
  139. samples = struct.unpack(f"<{n_samples}h", pcm)
  140. # Resample-Faktor
  141. ratio = target_rate / source_rate
  142. new_length = int(n_samples * ratio)
  143. # Lineare Interpolation
  144. resampled = []
  145. for i in range(new_length):
  146. src_pos = i / ratio
  147. idx = int(src_pos)
  148. frac = src_pos - idx
  149. if idx + 1 < n_samples:
  150. val = samples[idx] * (1.0 - frac) + samples[idx + 1] * frac
  151. elif idx < n_samples:
  152. val = samples[idx]
  153. else:
  154. val = 0
  155. resampled.append(int(max(-32768, min(32767, val))))
  156. # Neues WAV bauen
  157. out = io.BytesIO()
  158. with wave.open(out, "wb") as wf:
  159. wf.setnchannels(1)
  160. wf.setsampwidth(2)
  161. wf.setframerate(target_rate)
  162. wf.writeframes(struct.pack(f"<{len(resampled)}h", *resampled))
  163. return out.getvalue()
  164. class PiperTTSProvider(TTSProvider):
  165. """Piper-basierter TTS-Provider."""
  166. def __init__(
  167. self,
  168. config: TTSConfig | None = None,
  169. models_dir: Path | None = None,
  170. voice_id: str = "de_DE-thorsten-medium",
  171. auto_download: bool = True,
  172. event_manager: Any = None,
  173. shared_models_dir: Path | None = None,
  174. ):
  175. super().__init__(config)
  176. self._models_dir = models_dir # plugins/tts_piper/models/
  177. self._shared_models_dir = shared_models_dir # ./models/piper/ (persistent)
  178. self._voice_id = voice_id
  179. self._auto_download = auto_download
  180. self._event_manager = event_manager
  181. self._piper = None
  182. self._model_path: Path | None = None
  183. def _search_dirs(self) -> list[Path]:
  184. """Gibt alle Verzeichnisse zurueck in denen nach Modellen gesucht wird."""
  185. dirs: list[Path] = []
  186. if self._shared_models_dir and self._shared_models_dir.exists():
  187. dirs.append(self._shared_models_dir)
  188. if self._models_dir:
  189. dirs.append(self._models_dir)
  190. return dirs
  191. def _find_model_file(self, voice_id: str) -> Path | None:
  192. """Sucht eine .onnx-Datei fuer die gegebene voice_id in allen Verzeichnissen."""
  193. filename = f"{voice_id}.onnx"
  194. for d in self._search_dirs():
  195. candidate = d / filename
  196. if candidate.exists():
  197. return candidate
  198. return None
  199. @property
  200. def name(self) -> str:
  201. return "piper"
  202. @property
  203. def supported_languages(self) -> list[str]:
  204. return ["de-DE", "en-US", "en-GB", "fr-FR", "es-ES", "it-IT", "nl-NL", "pl-PL"]
  205. @property
  206. def supports_streaming(self) -> bool:
  207. return True
  208. async def initialize(self) -> None:
  209. """Lädt das Piper-Modell."""
  210. try:
  211. from piper import PiperVoice
  212. self._state = TTSState.SYNTHESIZING
  213. # Lokale Modelle in allen Such-Verzeichnissen automatisch registrieren
  214. self._discover_local_models()
  215. # Modell-Pfad bestimmen: zuerst in allen Such-Verzeichnissen
  216. # (./models/piper/ hat Vorrang vor plugins/tts_piper/models/)
  217. existing = self._find_model_file(self._voice_id)
  218. if existing:
  219. self._model_path = existing
  220. else:
  221. # Nicht lokal vorhanden — Download-Pfad im Plugin-Ordner
  222. self._model_path = self._models_dir / f"{self._voice_id}.onnx"
  223. # Auto-Download wenn nicht vorhanden
  224. if not self._model_path.exists():
  225. if self._auto_download:
  226. await self._download_model()
  227. else:
  228. raise RuntimeError(
  229. f"Piper-Modell nicht gefunden: {self._model_path}. "
  230. "Auto-Download ist deaktiviert."
  231. )
  232. # Piper laden (in Thread)
  233. loop = asyncio.get_event_loop()
  234. self._piper = await loop.run_in_executor(
  235. None,
  236. lambda: PiperVoice.load(str(self._model_path))
  237. )
  238. # Aktuelle Stimme setzen
  239. if self._voice_id in PIPER_VOICES:
  240. voice_info = PIPER_VOICES[self._voice_id]
  241. self._current_voice = Voice(
  242. id=self._voice_id,
  243. name=voice_info["name"],
  244. language=voice_info["language"],
  245. gender=voice_info["gender"],
  246. description=voice_info["description"],
  247. sample_rate=voice_info["sample_rate"],
  248. )
  249. self._model_loaded = True
  250. self._state = TTSState.READY
  251. except ImportError:
  252. raise RuntimeError(
  253. "Piper nicht installiert. "
  254. "Installieren mit: pip install piper-tts"
  255. )
  256. except Exception as e:
  257. self._state = TTSState.ERROR
  258. raise RuntimeError(f"Fehler beim Laden des Piper-Modells: {e}")
  259. async def _download_model(self) -> None:
  260. """Lädt das Modell von Hugging Face herunter."""
  261. from trixy_core.utils.debug import pinfo
  262. from trixy_core.utils.download import download_from_huggingface
  263. if self._voice_id not in PIPER_VOICES:
  264. raise RuntimeError(
  265. f"Unbekannte Stimme: {self._voice_id}. "
  266. f"Verfügbar: {', '.join(PIPER_VOICES.keys())}"
  267. )
  268. voice_info = PIPER_VOICES[self._voice_id]
  269. hf_path = voice_info["hf_path"]
  270. hf_json_path = hf_path + ".json"
  271. self._models_dir.mkdir(parents=True, exist_ok=True)
  272. # ONNX-Modell herunterladen
  273. pinfo(f"Lade Piper-Modell: {self._voice_id}...")
  274. model_dest = self._models_dir / f"{self._voice_id}.onnx"
  275. success = await download_from_huggingface(
  276. repo_id=HUGGINGFACE_REPO,
  277. filename=hf_path,
  278. dest_path=model_dest,
  279. event_manager=self._event_manager,
  280. download_id=f"piper-{self._voice_id}",
  281. )
  282. if not success:
  283. raise RuntimeError(f"Download des Modells fehlgeschlagen: {self._voice_id}")
  284. # JSON-Config herunterladen
  285. json_dest = self._models_dir / f"{self._voice_id}.onnx.json"
  286. await download_from_huggingface(
  287. repo_id=HUGGINGFACE_REPO,
  288. filename=hf_json_path,
  289. dest_path=json_dest,
  290. event_manager=self._event_manager,
  291. download_id=f"piper-{self._voice_id}-json",
  292. )
  293. pinfo(f"Piper-Modell heruntergeladen: {self._voice_id}")
  294. def _discover_local_models(self) -> None:
  295. """Erkennt lokale Modelle in allen Such-Verzeichnissen.
  296. Scannt sowohl plugins/tts_piper/models/ als auch (falls gesetzt)
  297. ./models/piper/. Liest .onnx.json fuer Metadaten.
  298. Bereits in PIPER_VOICES eingetragene Stimmen werden nicht ueberschrieben.
  299. """
  300. from trixy_core.utils.debug import pinfo
  301. import json as _json
  302. discovered = 0
  303. seen_voices: set[str] = set()
  304. for models_dir in self._search_dirs():
  305. for onnx_file in sorted(models_dir.glob("*.onnx")):
  306. voice_id = onnx_file.stem # z.B. "de_DE-Feli-medium"
  307. if voice_id in PIPER_VOICES or voice_id in seen_voices:
  308. continue # Bereits bekannt
  309. json_file = onnx_file.with_suffix(".onnx.json")
  310. if not json_file.exists():
  311. continue # Ohne Config nicht nutzbar
  312. try:
  313. with open(json_file, "r", encoding="utf-8") as f:
  314. meta = _json.load(f)
  315. except Exception:
  316. continue
  317. audio = meta.get("audio", {})
  318. language = meta.get("language", {})
  319. dataset = meta.get("dataset", "")
  320. # Stimmenname aus voice_id extrahieren: "de_DE-Feli-medium" → "Feli"
  321. parts = voice_id.split("-")
  322. if len(parts) >= 2:
  323. name = parts[1].replace("_", " ").title()
  324. else:
  325. name = voice_id
  326. quality = parts[-1] if len(parts) >= 3 else "unknown"
  327. lang_code = language.get("code", "")
  328. if lang_code:
  329. lang_formatted = lang_code.replace("_", "-")
  330. else:
  331. lang_formatted = parts[0].replace("_", "-") if parts else ""
  332. sample_rate = audio.get("sample_rate", 22050)
  333. PIPER_VOICES[voice_id] = {
  334. "name": f"{name} ({quality.title()})",
  335. "language": lang_formatted,
  336. "gender": "unknown",
  337. "description": f"Lokales Modell: {dataset or name}",
  338. "sample_rate": sample_rate,
  339. "hf_path": "",
  340. }
  341. seen_voices.add(voice_id)
  342. discovered += 1
  343. if discovered > 0:
  344. pinfo(f"Piper TTS: {discovered} lokale Modelle erkannt")
  345. async def shutdown(self) -> None:
  346. """Gibt Ressourcen frei."""
  347. self._piper = None
  348. self._model_loaded = False
  349. self._state = TTSState.UNINITIALIZED
  350. async def get_voices(self, language: str | None = None) -> list[Voice]:
  351. """Gibt verfügbare Stimmen zurück."""
  352. voices = []
  353. for voice_id, info in PIPER_VOICES.items():
  354. voices.append(Voice(
  355. id=voice_id,
  356. name=info["name"],
  357. language=info["language"],
  358. gender=info["gender"],
  359. description=info["description"],
  360. sample_rate=info["sample_rate"],
  361. ))
  362. if language:
  363. lang_prefix = language.replace("-", "_").split("_")[0]
  364. voices = [v for v in voices if v.language.replace("-", "_").startswith(lang_prefix)]
  365. return voices
  366. async def synthesize(
  367. self,
  368. text: str,
  369. voice_id: str | None = None,
  370. language: str | None = None,
  371. ) -> TTSResult:
  372. """Synthetisiert Text mit Piper."""
  373. if not self._piper:
  374. raise RuntimeError("Piper nicht geladen")
  375. self._state = TTSState.SYNTHESIZING
  376. start_time = time.time()
  377. try:
  378. # Audio-Buffer
  379. audio_buffer = io.BytesIO()
  380. # WAV-Writer
  381. with wave.open(audio_buffer, "wb") as wav_file:
  382. wav_file.setnchannels(1)
  383. wav_file.setsampwidth(2) # 16-bit
  384. wav_file.setframerate(self._piper.config.sample_rate)
  385. # Synthese in Thread
  386. loop = asyncio.get_event_loop()
  387. def do_synthesize():
  388. for audio_chunk in self._piper.synthesize(text):
  389. wav_file.writeframes(audio_chunk.audio_int16_bytes)
  390. await loop.run_in_executor(None, do_synthesize)
  391. processing_time = (time.time() - start_time) * 1000
  392. audio_data = audio_buffer.getvalue()
  393. self._state = TTSState.READY
  394. source_rate = self._piper.config.sample_rate
  395. target_rate = 22050 # Standard-Ausgaberate fuer TTS-Socket
  396. # Resampling wenn Modell-Rate != Ausgabe-Rate
  397. if source_rate != target_rate:
  398. audio_data = _resample_wav(audio_data, source_rate, target_rate)
  399. # Dauer berechnen
  400. sample_rate = target_rate
  401. # WAV hat 44 bytes Header
  402. pcm_data = audio_data[44:]
  403. num_samples = len(pcm_data) // 2 # 16-bit = 2 bytes
  404. duration = num_samples / sample_rate
  405. return TTSResult(
  406. audio_data=audio_data,
  407. sample_rate=sample_rate,
  408. channels=1,
  409. text=text,
  410. duration_seconds=duration,
  411. processing_time_ms=processing_time,
  412. voice_id=voice_id or self._voice_id,
  413. voice_name=self._current_voice.name if self._current_voice else "",
  414. language=language or self._config.language,
  415. provider="piper",
  416. model=self._voice_id,
  417. )
  418. except Exception as e:
  419. self._state = TTSState.ERROR
  420. raise RuntimeError(f"Piper-Synthese fehlgeschlagen: {e}")
  421. async def synthesize_stream(
  422. self,
  423. text: str,
  424. voice_id: str | None = None,
  425. language: str | None = None,
  426. chunk_size: int = 4096,
  427. ) -> AsyncIterator[bytes]:
  428. """Synthetisiert Text als Stream."""
  429. if not self._piper:
  430. raise RuntimeError("Piper nicht geladen")
  431. self._state = TTSState.SYNTHESIZING
  432. try:
  433. loop = asyncio.get_event_loop()
  434. # Generator für Chunks
  435. def generate_chunks():
  436. for audio_chunk in self._piper.synthesize(text):
  437. audio_bytes = audio_chunk.audio_int16_bytes
  438. # In Chunks aufteilen
  439. for i in range(0, len(audio_bytes), chunk_size):
  440. yield audio_bytes[i:i + chunk_size]
  441. # Chunks yielden
  442. chunks = await loop.run_in_executor(None, lambda: list(generate_chunks()))
  443. for chunk in chunks:
  444. yield chunk
  445. self._state = TTSState.READY
  446. except Exception as e:
  447. self._state = TTSState.ERROR
  448. raise RuntimeError(f"Piper-Stream fehlgeschlagen: {e}")
  449. class PiperTTSPlugin(TrixyPlugin):
  450. """Piper TTS Plugin für Trixy."""
  451. def __init__(self, application, plugin_path, config: dict | None = None):
  452. super().__init__(application, plugin_path, config)
  453. self._provider: PiperTTSProvider | None = None
  454. async def on_load(self) -> None:
  455. """Plugin wird geladen."""
  456. from trixy_core.utils.debug import pinfo
  457. pinfo("Piper TTS Plugin: Lade...")
  458. # Konfiguration
  459. voice_id = self.config.get("voice", "de_DE-thorsten-medium")
  460. language = self.config.get("language", "de-DE")
  461. auto_download = self.config.get("auto_download", True)
  462. # Models-Verzeichnis im Plugin-Ordner (fuer Standard-Downloads)
  463. models_dir = self.plugin_path / "models"
  464. models_dir.mkdir(parents=True, exist_ok=True)
  465. # Shared Models-Verzeichnis: projekt-weit fuer Custom-Voices
  466. # ./models/piper/ — wird nicht vom Installer ueberschrieben
  467. # plugin_path ist z.B. /home/pi/trixy/source/plugins/tts_piper
  468. # parents[1] → /home/pi/trixy/source
  469. shared_models_dir = self.plugin_path.parents[1] / "models" / "piper"
  470. tts_config = TTSConfig(
  471. language=language,
  472. voice_id=voice_id,
  473. )
  474. # Provider erstellen (mit EventManager für Download-Events)
  475. self._provider = PiperTTSProvider(
  476. tts_config,
  477. models_dir=models_dir,
  478. shared_models_dir=shared_models_dir,
  479. voice_id=voice_id,
  480. auto_download=auto_download,
  481. event_manager=self.application.events,
  482. )
  483. # Modell laden (und ggf. downloaden)
  484. await self._provider.initialize()
  485. # Extension registrieren
  486. if hasattr(self.application, "extension_points"):
  487. ext_point = self.application.extension_points.get("conversation.tts")
  488. if ext_point:
  489. ext_point.register(self._provider)
  490. pinfo("Piper TTS Plugin: Extension registriert")
  491. # Event-Handler registrieren
  492. self._register_event_handlers()
  493. pinfo(f"Piper TTS Plugin: Geladen (Stimme: {voice_id})")
  494. def _register_event_handlers(self) -> None:
  495. """Registriert Event-Handler."""
  496. em = self.application.events
  497. @em.on("tts_request")
  498. async def on_tts_request(event_name: str, data: dict) -> None:
  499. """Verarbeitet TTS-Anfragen."""
  500. if not self._provider or not self._provider.is_ready:
  501. return
  502. text = data.get("text")
  503. satellite_id = data.get("satellite_id")
  504. session_id = data.get("session_id")
  505. voice_id = data.get("voice_id")
  506. request_id = data.get("request_id", "")
  507. if not text:
  508. return
  509. from trixy_core.utils.debug import pinfo
  510. pinfo(f"Piper TTS: Synthetisiere '{text[:50]}...'")
  511. try:
  512. result = await self._provider.synthesize(text, voice_id=voice_id)
  513. await em.emit("tts_completed", {
  514. "request_id": request_id,
  515. "audio_data": result.audio_data.hex(),
  516. "sample_rate": result.sample_rate,
  517. "duration_seconds": result.duration_seconds,
  518. "provider": "piper",
  519. "text": text,
  520. "satellite_id": satellite_id,
  521. "session_id": session_id,
  522. "processing_time_ms": result.processing_time_ms,
  523. })
  524. pinfo(f"Piper TTS: Fertig ({result.duration_seconds:.1f}s Audio, "
  525. f"{result.processing_time_ms:.0f}ms)")
  526. except Exception as e:
  527. from trixy_core.utils.debug import perror
  528. perror(f"Piper TTS Fehler: {e}")
  529. await em.emit("tts_error", {
  530. "error": str(e),
  531. "provider": "piper",
  532. "text": text,
  533. "satellite_id": satellite_id,
  534. "session_id": session_id,
  535. })
  536. async def on_unload(self) -> None:
  537. """Plugin wird entladen."""
  538. if self._provider:
  539. await self._provider.shutdown()
  540. self._provider = None
  541. from trixy_core.utils.debug import pinfo
  542. pinfo("Piper TTS Plugin: Entladen")
  543. @property
  544. def provider(self) -> PiperTTSProvider | None:
  545. """TTS-Provider."""
  546. return self._provider
  547. # Plugin-Export
  548. Plugin = PiperTTSPlugin