tts.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. # -*- coding: utf-8 -*-
  2. """
  3. TTS Action - Spricht Text auf Satellites via TTS.
  4. Unterstuetzt Template-Platzhalter im Text ({date.now.hour}, {sys.hostname}, etc.)
  5. und kann an einen bestimmten Satellite oder alle verbundenen Satellites senden.
  6. """
  7. from __future__ import annotations
  8. import uuid
  9. from typing import Any
  10. from trixy_core.scheduler.action.base import Action
  11. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  12. from trixy_core.utils.template_formatter import format_template
  13. from trixy_core.utils.debug import pdebug, perror
  14. # Sonderwert fuer "alle Satellites"
  15. _ALL_SATELLITES = "__all__"
  16. class TTSSpeakAction(Action):
  17. """
  18. Action die Text via TTS auf Satellites ausgibt.
  19. Der Text wird vor der Ausgabe durch den Template-Formatter verarbeitet,
  20. sodass Platzhalter wie {date.now.hour} oder {sys.hostname} aufgeloest werden.
  21. Beispiel:
  22. TTSSpeakAction(
  23. text="Es ist {date.now.hour} Uhr {date.now.minute}.",
  24. target="__all__",
  25. )
  26. """
  27. def __init__(
  28. self,
  29. text: str,
  30. target: str = _ALL_SATELLITES,
  31. voice: str = "default",
  32. speed: float = 1.0,
  33. volume: float = 1.0,
  34. name: str | None = None,
  35. timeout_seconds: float = 30.0,
  36. ):
  37. """
  38. Initialisiert die TTS-Action.
  39. Args:
  40. text: Text-Template mit optionalen {placeholder}-Variablen.
  41. target: Satellite-ID oder "__all__" fuer alle.
  42. voice: TTS-Stimme/Profil.
  43. speed: Sprechgeschwindigkeit (1.0 = normal).
  44. volume: Lautstaerke (1.0 = normal).
  45. name: Action-Name.
  46. timeout_seconds: Timeout.
  47. """
  48. super().__init__(name or "tts_speak", timeout_seconds)
  49. self._text = text
  50. self._target = target
  51. self._voice = voice
  52. self._speed = speed
  53. self._volume = volume
  54. @property
  55. def text(self) -> str:
  56. """Text-Template."""
  57. return self._text
  58. @property
  59. def target(self) -> str:
  60. """Ziel-Satellite-ID oder '__all__'."""
  61. return self._target
  62. async def _execute(self, context: dict) -> dict:
  63. """Fuehrt die TTS-Ausgabe durch."""
  64. # Template-Platzhalter aufloesen
  65. # Hinweis: satellite.speak() formatiert ebenfalls, aber der Standalone-Pfad
  66. # geht direkt ueber event_manager.trigger() — daher hier formatieren.
  67. resolved_text = format_template(
  68. self._text, context=context, application=context.get("application")
  69. )
  70. pdebug(f"TTSSpeakAction: Text='{resolved_text}', Ziel={self._target}")
  71. # Satellite-Manager aus Kontext holen
  72. satellite_manager = context.get("satellite_manager")
  73. event_manager = context.get("event_manager")
  74. spoken_to: list[str] = []
  75. if satellite_manager:
  76. # Server-Modus: Direkt ueber Satellite.speak()
  77. if self._target == _ALL_SATELLITES:
  78. satellites = satellite_manager.get_connected()
  79. else:
  80. sat = satellite_manager.get(self._target)
  81. satellites = [sat] if sat and sat.is_connected else []
  82. pdebug(f"TTSSpeakAction: {len(satellites)} Satellite(s) gefunden")
  83. for satellite in satellites:
  84. try:
  85. await satellite.speak(
  86. resolved_text,
  87. voice=self._voice,
  88. speed=self._speed,
  89. volume=self._volume,
  90. )
  91. spoken_to.append(satellite.satellite_id)
  92. pdebug(f"TTSSpeakAction: Gesendet an {satellite.satellite_id}")
  93. except Exception as e:
  94. perror(f"TTSSpeakAction: Fehler bei {satellite.satellite_id}: {e}")
  95. elif event_manager:
  96. # Standalone-Modus: Event ueber trigger() mit TTSRequest senden
  97. from trixy_core.events.event_data.basic import TTSRequest
  98. request_id = f"sched-tts-{uuid.uuid4().hex[:8]}"
  99. event_data = TTSRequest(
  100. request_id=request_id,
  101. satellite_id=self._target if self._target != _ALL_SATELLITES else "",
  102. text=resolved_text,
  103. voice=self._voice,
  104. speed=self._speed,
  105. volume=self._volume,
  106. source="TTSSpeakAction",
  107. )
  108. await event_manager.trigger("tts_request", event_data)
  109. spoken_to.append("event:" + request_id)
  110. pdebug(f"TTSSpeakAction: Event getriggert (request_id={request_id})")
  111. else:
  112. perror("TTSSpeakAction: Weder satellite_manager noch event_manager im Kontext")
  113. return {
  114. "text": resolved_text,
  115. "template": self._text,
  116. "target": self._target,
  117. "spoken_to": spoken_to,
  118. }
  119. def to_dict(self) -> dict:
  120. """Konvertiert zu Dictionary."""
  121. return {
  122. **self._base_dict(),
  123. "text": self._text,
  124. "target": self._target,
  125. "voice": self._voice,
  126. "speed": self._speed,
  127. "volume": self._volume,
  128. }
  129. @classmethod
  130. def from_dict(cls, data: dict) -> "TTSSpeakAction":
  131. """Erstellt Action aus Dictionary."""
  132. return cls(
  133. text=data.get("text", ""),
  134. target=data.get("target", _ALL_SATELLITES),
  135. voice=data.get("voice", "default"),
  136. speed=data.get("speed", 1.0),
  137. volume=data.get("volume", 1.0),
  138. name=data.get("name"),
  139. timeout_seconds=data.get("timeout_seconds", 30.0),
  140. )
  141. @classmethod
  142. def display_name(cls) -> str:
  143. """Anzeigename fuer die TUI."""
  144. return "TTS-Sprachausgabe"
  145. @classmethod
  146. def form_fields(cls) -> list[FormField]:
  147. """Gibt die Formular-Felder fuer die TUI zurueck."""
  148. return [
  149. FormField(
  150. name="text",
  151. label="Text",
  152. field_type=FieldType.TEXTAREA,
  153. required=True,
  154. lines=4,
  155. placeholder="Es ist {date.now.hour} Uhr {date.now.minute}.",
  156. help_text=(
  157. "Platzhalter: {date.now.hour}, {date.now.weekday}, "
  158. "{sys.hostname}, {env.USER}, {ctx.job_name} ..."
  159. ),
  160. ),
  161. FormField(
  162. name="target",
  163. label="Ziel-Satellite",
  164. field_type=FieldType.TEXT,
  165. default=_ALL_SATELLITES,
  166. placeholder="__all__",
  167. help_text="Satellite-ID oder '__all__' fuer alle verbundenen Satellites",
  168. ),
  169. FormField(
  170. name="voice",
  171. label="Stimme",
  172. field_type=FieldType.TEXT,
  173. default="default",
  174. placeholder="default",
  175. help_text="TTS-Stimmprofil",
  176. ),
  177. FormField(
  178. name="speed",
  179. label="Geschwindigkeit",
  180. field_type=FieldType.NUMBER,
  181. default=1.0,
  182. min_value=0.1,
  183. max_value=3.0,
  184. is_float=True,
  185. help_text="1.0 = normal, 0.5 = langsam, 2.0 = schnell",
  186. ),
  187. FormField(
  188. name="volume",
  189. label="Lautstaerke",
  190. field_type=FieldType.NUMBER,
  191. default=1.0,
  192. min_value=0.0,
  193. max_value=2.0,
  194. is_float=True,
  195. help_text="1.0 = normal",
  196. ),
  197. FormField(
  198. name="timeout_seconds",
  199. label="Timeout (Sek.)",
  200. field_type=FieldType.NUMBER,
  201. default=30.0,
  202. min_value=1.0,
  203. is_float=True,
  204. ),
  205. ]
  206. @classmethod
  207. def validate_form(cls, data: dict) -> list[FormValidationError]:
  208. """Validiert Formular-Daten."""
  209. errors: list[FormValidationError] = []
  210. if not data.get("text", "").strip():
  211. errors.append(FormValidationError("text", "Text ist erforderlich"))
  212. target = data.get("target", "").strip()
  213. if not target:
  214. errors.append(FormValidationError("target", "Ziel ist erforderlich"))
  215. return errors