standalone.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. # -*- coding: utf-8 -*-
  2. """
  3. Standalone-Anwendung für Trixy.
  4. Kombiniert Server- und Client-Funktionalität für den
  5. Betrieb ohne separate Server-Instanz.
  6. """
  7. from __future__ import annotations
  8. from pathlib import Path
  9. from typing import TYPE_CHECKING
  10. if TYPE_CHECKING:
  11. from trixy_core.sync.service import SyncService
  12. from trixy_core.application import IApplication
  13. from trixy_core.config.datasets.standalone import StandaloneConfig
  14. from trixy_core.plugins.plugin_manager import PluginManager
  15. from trixy_core.events.event_data.basic import SystemShutdown
  16. from trixy_core.audio.config import init_audio_settings_from_config
  17. from trixy_core.audio.player import AudioOutputManager
  18. from trixy_core.audio.microphone import MicrophoneCapture
  19. from trixy_core.input.keyboard_input import create_keyboard_input, KeyboardInputService, SimpleKeyboardInput
  20. from trixy_core.nlp.intent_dispatcher import IntentDispatcherService
  21. from trixy_core.stt.corrector import STTCorrectorService
  22. from trixy_core.conversation.profiler import ConversationProfiler
  23. from trixy_core.wakeword.service import WakewordService, WakewordServiceConfig
  24. from trixy_core.wakeword.detector import DetectorConfig
  25. from trixy_core.wakeword.audio_buffer import BufferConfig
  26. from trixy_core.wakeword.vad import VADConfig
  27. from trixy_core.network.config_listener import ConfigListener
  28. from trixy_core.email.service import EmailService
  29. from trixy_core.activity.tracker import ActivityTracker
  30. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  31. class StandaloneApplication(IApplication):
  32. """
  33. Standalone-Anwendung für Trixy.
  34. Bietet alle Funktionen von Server und Client in
  35. einer einzelnen Anwendung.
  36. """
  37. def __init__(
  38. self,
  39. config_path: str | Path = "config/standalone_config.json",
  40. debug: bool = False,
  41. keyboard_input: bool = False,
  42. ) -> None:
  43. """
  44. Initialisiert die Standalone-Anwendung.
  45. Args:
  46. config_path: Pfad zur Konfigurationsdatei
  47. debug: Debug-Modus aktivieren
  48. keyboard_input: Keyboard-Eingabe aktivieren
  49. """
  50. super().__init__(debug)
  51. self._config_path = Path(config_path)
  52. self._standalone_config: StandaloneConfig | None = None
  53. self._plugin_manager: PluginManager | None = None
  54. # Im Debug-Modus immer Keyboard-Shortcuts aktivieren (Ctrl+P/N/S)
  55. self._keyboard_input_enabled = keyboard_input or debug
  56. self._keyboard_service: KeyboardInputService | SimpleKeyboardInput | None = None
  57. # ConfigListener
  58. self._config_listener: ConfigListener | None = None
  59. # Audio- und Wakeword-Komponenten
  60. self._wakeword_service: WakewordService | None = None
  61. self._microphone: MicrophoneCapture | None = None
  62. self._audio_manager: AudioOutputManager | None = None
  63. # Sync
  64. self._sync_service: "SyncService | None" = None
  65. @property
  66. def standalone_config(self) -> StandaloneConfig:
  67. """Standalone-Konfiguration."""
  68. if self._standalone_config is None:
  69. raise RuntimeError("Standalone nicht initialisiert")
  70. return self._standalone_config
  71. @property
  72. def plugins(self) -> PluginManager:
  73. """Plugin-Manager."""
  74. if self._plugin_manager is None:
  75. raise RuntimeError("Standalone nicht initialisiert")
  76. return self._plugin_manager
  77. @property
  78. def room(self) -> str:
  79. """Raum-Kennung."""
  80. if self._standalone_config:
  81. return self._standalone_config.room
  82. return "standalone"
  83. @property
  84. def alias(self) -> str:
  85. """Anzeigename."""
  86. if self._standalone_config:
  87. return self._standalone_config.alias
  88. return "Trixy"
  89. async def initialize(self) -> None:
  90. """Initialisiert die Standalone-Anwendung."""
  91. pinfo("Initialisiere Standalone...")
  92. # Konfiguration laden
  93. self._standalone_config = self.config_manager.load(
  94. self._config_path,
  95. StandaloneConfig,
  96. name="standalone",
  97. auto_reload=True
  98. )
  99. # File-Logging initialisieren
  100. self._init_file_logging(self._standalone_config.logging)
  101. # Audio-Einstellungen initialisieren (für Plugin-Zugriff)
  102. audio_settings = init_audio_settings_from_config(self._standalone_config)
  103. pdebug(f"Audio-Einstellungen: TTS={audio_settings.tts_sample_rate}Hz, Musik={audio_settings.music_sample_rate}Hz")
  104. # Plugin-Manager initialisieren
  105. self._plugin_manager = PluginManager(
  106. self,
  107. directory=self._standalone_config.plugins.plugin_directory,
  108. timeout_seconds=self._standalone_config.logging.plugin_timeout_seconds,
  109. max_failures=self._standalone_config.logging.plugin_max_failures,
  110. )
  111. # EmailService erstellen und registrieren (SMTP-Versand fuer Plugins)
  112. from dataclasses import asdict
  113. email_cfg = asdict(self._standalone_config.email)
  114. self._email_service = EmailService(self, email_cfg)
  115. self.services.register_instance(self._email_service)
  116. # HID Media Key Service (Konferenzmikrofone, Headsets)
  117. try:
  118. from trixy_core.hid import HIDService, HIDServiceConfig
  119. self._hid_service = HIDService(self, HIDServiceConfig())
  120. self.services.register_instance(self._hid_service)
  121. pdebug("HID-Service registriert")
  122. except Exception as e:
  123. pdebug(f"HID-Service nicht verfuegbar: {e}")
  124. # ALSA Volume Handler
  125. try:
  126. from trixy_core.audio.volume import VolumeHandler
  127. self._volume_handler = VolumeHandler(self)
  128. pdebug("Volume Handler erstellt")
  129. except Exception as e:
  130. pdebug(f"Volume Handler nicht verfuegbar: {e}")
  131. # ConfigListener erstellen und registrieren (Remote-Config-Tool Zugriff)
  132. self._config_listener = ConfigListener(
  133. self,
  134. port=self._standalone_config.config_port,
  135. bind_address="0.0.0.0",
  136. encryption_key_path=self._standalone_config.security.encryption_key_path,
  137. instance_type="standalone",
  138. )
  139. self.services.register_instance(self._config_listener)
  140. # ActivityTracker erstellen und registrieren (zentrales Activity-Logging)
  141. self._activity_tracker = ActivityTracker(self)
  142. self.services.register_instance(self._activity_tracker)
  143. # IntentDispatcher-Service registrieren (verarbeitet intent_received Events)
  144. self._intent_dispatcher = IntentDispatcherService(self)
  145. self.services.register_instance(self._intent_dispatcher)
  146. self.events.register_object(self._intent_dispatcher)
  147. # STT-Korrektur-Service registrieren (korrigiert Text vor NLP mit HIGH-Prioritaet)
  148. self._stt_corrector = STTCorrectorService(self)
  149. self.services.register_instance(self._stt_corrector)
  150. self.events.register_object(self._stt_corrector)
  151. # Conversation-Profiler registrieren (Debug-Modus: misst Phasen-Dauern)
  152. self._conversation_profiler = ConversationProfiler(self)
  153. self.services.register_instance(self._conversation_profiler)
  154. self.events.register_object(self._conversation_profiler)
  155. # SyncStore erstellen (immer, auch ohne Server-Verbindung fuer lokale Persistenz)
  156. from trixy_core.sync.store import SyncStore
  157. sync_dir = self._standalone_config.sync.sync_data_directory
  158. self._sync_store = SyncStore(base_directory=sync_dir)
  159. pdebug(f"SyncStore erstellt: {sync_dir}")
  160. # SyncService erstellen (falls konfiguriert)
  161. if self._standalone_config.sync.enabled and self._standalone_config.sync.server_host:
  162. from trixy_core.sync.service import SyncService as _SyncService
  163. self._sync_service = _SyncService(self, self._standalone_config.sync)
  164. pdebug("SyncService erstellt")
  165. # AudioOutputManager erstellen (für lokale TTS-Wiedergabe)
  166. self._audio_manager = AudioOutputManager(
  167. tts_sample_rate=22050, # Piper-Default, wird ggf. von TTS-Plugin aktualisiert
  168. music_sample_rate=self._standalone_config.audio.music_sample_rate,
  169. )
  170. await self._audio_manager.initialize()
  171. # Wakeword-Service erstellen (falls aktiviert)
  172. ww_config = self._standalone_config.wakeword
  173. if ww_config.enabled:
  174. try:
  175. service_config = WakewordServiceConfig(
  176. detector=DetectorConfig(
  177. model_directory=ww_config.model_directory,
  178. models=ww_config.models,
  179. threshold=ww_config.threshold,
  180. use_onnx=ww_config.use_onnx,
  181. ),
  182. buffer=BufferConfig(
  183. pre_buffer_seconds=ww_config.pre_buffer_seconds,
  184. max_duration_seconds=ww_config.max_recording_seconds,
  185. ),
  186. vad=VADConfig(
  187. silence_duration_ms=int(ww_config.silence_timeout_seconds * 1000),
  188. max_duration_ms=int(ww_config.max_recording_seconds * 1000),
  189. no_speech_timeout_ms=int(ww_config.no_speech_timeout_seconds * 1000),
  190. ),
  191. standalone_mode=True,
  192. wakeword_cooldown_seconds=ww_config.wakeword_cooldown_seconds,
  193. )
  194. self._wakeword_service = WakewordService(self, service_config)
  195. # Mikrofon erstellen
  196. self._microphone = MicrophoneCapture(
  197. device=self._standalone_config.audio.input_device,
  198. )
  199. pinfo("Wakeword-Service erstellt")
  200. except Exception as e:
  201. pwarn(f"Wakeword-Service nicht verfügbar: {e}")
  202. pdebug("Standalone initialisiert")
  203. async def start(self) -> None:
  204. """Startet die Standalone-Anwendung."""
  205. pinfo(f"Starte Standalone als '{self.alias}'...")
  206. from trixy_core.utils.version import VERSION_STRING
  207. await self.events.emit("application_starting", {
  208. "mode": "standalone",
  209. "version": VERSION_STRING,
  210. })
  211. # Services starten
  212. await self.services.start_all()
  213. # Plugins laden
  214. if self._standalone_config and self._standalone_config.plugins.enabled:
  215. await self._plugin_manager.load_all()
  216. # Wakeword-Service starten
  217. if self._wakeword_service and self._microphone:
  218. try:
  219. await self._wakeword_service.on_start()
  220. # Mikrofon direkt mit WakewordService verbinden (ohne Netzwerk)
  221. self._wakeword_service._microphone = self._microphone
  222. await self._wakeword_service.start_listening()
  223. except Exception as e:
  224. pwarn(f"Wakeword-Service konnte nicht gestartet werden: {e}")
  225. # SyncService starten
  226. if self._sync_service:
  227. await self._sync_service.start()
  228. # TTS-Wiedergabe-Handler registrieren
  229. self._setup_standalone_tts_playback()
  230. # Follow-Up-Bridge: followup_expected → WakewordService FOLLOW_UP State
  231. self._setup_standalone_followup()
  232. # Keyboard-Input starten (falls aktiviert)
  233. # Im Standalone-Modus gibt es keine Server-Verbindung,
  234. # daher wird speech_recognized Event direkt emittiert
  235. if self._keyboard_input_enabled:
  236. self._keyboard_service = create_keyboard_input(
  237. self,
  238. connection=None, # Standalone hat keine Server-Verbindung
  239. prompt=f"{self.alias}> ",
  240. )
  241. await self._keyboard_service.start()
  242. # Volume Handler starten
  243. if hasattr(self, "_volume_handler"):
  244. await self._volume_handler.start()
  245. pinfo("Standalone gestartet")
  246. from trixy_core.utils.version import VERSION_STRING
  247. await self.events.emit("application_ready", {
  248. "mode": "standalone",
  249. "version": VERSION_STRING,
  250. "services_count": len(self.services.services),
  251. })
  252. def _setup_standalone_tts_playback(self) -> None:
  253. """Registriert Event-Handler für lokale TTS-Wiedergabe."""
  254. if not self._audio_manager:
  255. return
  256. audio_manager = self._audio_manager
  257. async def on_tts_completed(event_name: str, data) -> None:
  258. """Spielt TTS-Audio lokal ab (data ist EventData, Felder in metadata)."""
  259. audio_data_hex = data.get("audio_data")
  260. if not audio_data_hex:
  261. return
  262. # audio_data ist hex-kodiert (str) — dekodieren zu bytes
  263. audio_bytes = bytes.fromhex(audio_data_hex)
  264. # TTS-Format aktualisieren falls mitgeliefert
  265. sample_rate = data.get("sample_rate")
  266. if sample_rate and sample_rate != audio_manager.tts_format.get("sample_rate"):
  267. channels = data.get("channels") or 1
  268. sample_width = data.get("sample_width") or 2
  269. await audio_manager.update_tts_format(sample_rate, channels, sample_width)
  270. audio_manager.play_tts_chunk(audio_bytes)
  271. audio_manager.finish_tts()
  272. self.events.register("tts_completed", on_tts_completed)
  273. def _setup_standalone_followup(self) -> None:
  274. """Bruecke: followup_expected → WakewordService FOLLOW_UP State.
  275. Im Client-Modus sendet der Server FollowUpRequest als Command.
  276. Im Standalone-Modus muss dieses Event direkt an den WakewordService
  277. weitergeleitet werden, damit dieser in den FOLLOW_UP-State wechselt
  278. und ohne Wakeword-Erkennung aufnimmt.
  279. """
  280. if not self._wakeword_service:
  281. return
  282. wakeword_service = self._wakeword_service
  283. async def on_followup_expected(event_name: str, data) -> None:
  284. """Startet Aufnahme fuer Rueckfrage ohne Wakeword."""
  285. session_id = getattr(data, "session_id", "") or ""
  286. timeout = getattr(data, "timeout_seconds", 30.0)
  287. pdebug(f"Standalone Follow-Up Bridge: session={session_id}, timeout={timeout}")
  288. await wakeword_service._handle_follow_up({
  289. "session_id": session_id,
  290. "timeout_seconds": timeout,
  291. })
  292. self.events.register("followup_expected", on_followup_expected)
  293. async def stop(self) -> None:
  294. """Stoppt die Standalone-Anwendung."""
  295. pinfo("Stoppe Standalone...")
  296. await self.events.emit("application_stopping", {"reason": "shutdown"})
  297. # SyncService stoppen
  298. if self._sync_service:
  299. await self._sync_service.stop()
  300. # Wakeword-Service stoppen
  301. if self._wakeword_service:
  302. try:
  303. await self._wakeword_service.on_stop()
  304. except Exception as e:
  305. perror(f"Fehler beim Stoppen des Wakeword-Service: {e}")
  306. # Mikrofon stoppen
  307. if self._microphone and self._microphone.is_running:
  308. self._microphone.stop()
  309. # AudioOutputManager herunterfahren
  310. if self._audio_manager:
  311. await self._audio_manager.shutdown()
  312. # Keyboard-Input stoppen
  313. if self._keyboard_service:
  314. await self._keyboard_service.stop()
  315. self._keyboard_service = None
  316. # Shutdown-Event auslösen
  317. await self.events.trigger(
  318. "system_shutdown",
  319. SystemShutdown(reason="Standalone shutdown", source="standalone")
  320. )
  321. # Plugins entladen
  322. if self._plugin_manager:
  323. await self._plugin_manager.unload_all()
  324. # Services stoppen
  325. await self.services.stop_all()
  326. # ConfigManager aufräumen
  327. self.config_manager.stop_watching()
  328. # File-Logging herunterfahren
  329. self._shutdown_file_logging()
  330. pinfo("Standalone gestoppt")