client.py 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  1. # -*- coding: utf-8 -*-
  2. """
  3. Client/Satellite-Anwendung für Trixy.
  4. Der Client ist ein leichtgewichtiger Satellite, der:
  5. - Wakeword-Erkennung durchführt
  6. - Audio zum Server streamt
  7. - Audio-Ausgaben abspielt
  8. """
  9. import asyncio
  10. import wave
  11. import time
  12. from pathlib import Path
  13. from trixy_core.application import IApplication
  14. from trixy_core.config.datasets.client import ClientConfig
  15. from trixy_core.events.event_data.basic import SystemShutdown
  16. from trixy_core.network.client_connection import ClientConnection, ConnectionConfig, get_mac_address
  17. from trixy_core.network.protocol import ProtocolMessage
  18. from trixy_core.audio.player import AudioOutputManager
  19. from trixy_core.input.keyboard_input import create_keyboard_input, KeyboardInputService, SimpleKeyboardInput
  20. from trixy_core.wakeword.service import WakewordService, WakewordServiceConfig
  21. from trixy_core.wakeword.detector import DetectorConfig
  22. from trixy_core.wakeword.audio_buffer import BufferConfig
  23. from trixy_core.wakeword.vad import VADConfig
  24. from trixy_core.audio.microphone import MicrophoneCapture
  25. from trixy_core.network.config_listener import ConfigListener
  26. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  27. class ClientApplication(IApplication):
  28. """
  29. Client/Satellite-Anwendung für Trixy.
  30. Verbindet sich mit einem Server und führt
  31. lokale Wakeword-Erkennung durch.
  32. """
  33. def __init__(
  34. self,
  35. host: str | None = None,
  36. port: int | None = None,
  37. room: str | None = None,
  38. alias: str | None = None,
  39. config_path: str | Path = "config/client_config.json",
  40. debug: bool = False,
  41. keyboard_input: bool = False,
  42. ) -> None:
  43. """
  44. Initialisiert den Client.
  45. Args:
  46. host: Server-Host
  47. port: Server-Port
  48. room: Raum-Kennung
  49. alias: Anzeigename
  50. config_path: Pfad zur Konfigurationsdatei
  51. debug: Debug-Modus aktivieren
  52. keyboard_input: Keyboard-Eingabe aktivieren (Ctrl+I oder Enter)
  53. """
  54. super().__init__(debug)
  55. self._config_path = Path(config_path)
  56. self._client_config: ClientConfig | None = None
  57. # Überschreibbare CLI-Parameter (None = aus Config lesen)
  58. self._cli_host: str | None = host
  59. self._cli_port: int | None = port
  60. self._cli_room: str | None = room
  61. self._cli_alias: str | None = alias
  62. # Im Debug-Modus immer Keyboard-Shortcuts aktivieren (Ctrl+P/N/S)
  63. self._keyboard_input_enabled = keyboard_input or debug
  64. # Verbindungsstatus
  65. self._connected = False
  66. self._reconnect_task: asyncio.Task | None = None
  67. self._connection: ClientConnection | None = None
  68. # Audio-Ausgabe Manager
  69. self._audio_manager: AudioOutputManager | None = None
  70. # Keyboard-Input Service
  71. self._keyboard_service: KeyboardInputService | SimpleKeyboardInput | None = None
  72. # Wakeword-Service
  73. self._wakeword_service: WakewordService | None = None
  74. self._microphone: MicrophoneCapture | None = None
  75. # ConfigListener
  76. self._config_listener: ConfigListener | None = None
  77. # Server-gesteuerte Aufnahme (Fallback ohne WakewordService)
  78. self._direct_streaming_active: bool = False
  79. # DEBUG: Audio-Chunks für WAV-Export sammeln
  80. self._debug_tts_chunks: list[bytes] = []
  81. self._debug_music_chunks: list[bytes] = []
  82. @property
  83. def client_config(self) -> ClientConfig:
  84. """Client-Konfiguration."""
  85. if self._client_config is None:
  86. raise RuntimeError("Client nicht initialisiert")
  87. return self._client_config
  88. @property
  89. def is_connected(self) -> bool:
  90. """Ist der Client mit dem Server verbunden?"""
  91. return self._connected and self._connection is not None and self._connection.is_connected
  92. @property
  93. def connection(self) -> ClientConnection | None:
  94. """Die aktive Verbindung zum Server."""
  95. return self._connection
  96. @property
  97. def audio_manager(self) -> AudioOutputManager | None:
  98. """Audio-Ausgabe Manager."""
  99. return self._audio_manager
  100. @property
  101. def server_host(self) -> str:
  102. """Server-Host."""
  103. if self._cli_host is not None:
  104. return self._cli_host
  105. if self._client_config:
  106. return self._client_config.server.host
  107. return "localhost"
  108. @property
  109. def server_port(self) -> int:
  110. """Server-Port."""
  111. if self._cli_port is not None:
  112. return self._cli_port
  113. if self._client_config:
  114. return self._client_config.server.port
  115. return 2101
  116. @property
  117. def room(self) -> str:
  118. """Raum-Kennung."""
  119. if self._cli_room is not None:
  120. return self._cli_room
  121. if self._client_config:
  122. return self._client_config.identity.room
  123. return ""
  124. @property
  125. def alias(self) -> str:
  126. """Anzeigename."""
  127. if self._cli_alias is not None:
  128. return self._cli_alias
  129. if self._client_config:
  130. return self._client_config.identity.alias
  131. return ""
  132. async def initialize(self) -> None:
  133. """Initialisiert den Client."""
  134. pinfo("Initialisiere Client...")
  135. # Konfiguration laden (optional, kann fehlen)
  136. if self._config_path.exists():
  137. self._client_config = self.config_manager.load(
  138. self._config_path,
  139. ClientConfig,
  140. name="client",
  141. auto_reload=True
  142. )
  143. else:
  144. self._client_config = ClientConfig()
  145. pwarn(f"Keine Konfigurationsdatei gefunden: {self._config_path}")
  146. # CLI-Parameter in Konfiguration überschreiben
  147. if self._cli_host is not None:
  148. self._client_config.server.host = self._cli_host
  149. if self._cli_port is not None:
  150. self._client_config.server.port = self._cli_port
  151. if self._cli_room is not None:
  152. self._client_config.identity.room = self._cli_room
  153. if self._cli_alias is not None:
  154. self._client_config.identity.alias = self._cli_alias
  155. # Audio-Ausgabe Manager initialisieren (Sample-Rates aus Config)
  156. audio_config = self._client_config.audio
  157. music_sample_rate = getattr(audio_config, 'music_sample_rate', 48000)
  158. music_channels = getattr(audio_config, 'music_channels', 2)
  159. self._audio_manager = AudioOutputManager(
  160. tts_sample_rate=22050, # Piper Standard (wird vom Server überschrieben)
  161. music_sample_rate=music_sample_rate,
  162. )
  163. audio_ok = await self._audio_manager.initialize()
  164. if audio_ok:
  165. pinfo(f"Audio-Manager initialisiert: Musik={music_sample_rate}Hz, {music_channels}ch")
  166. else:
  167. pwarn("Audio-Manager konnte nicht initialisiert werden (keine Audio-Wiedergabe)")
  168. # Wakeword-Service erstellen
  169. ww_config = self._client_config.wakeword
  170. if ww_config.enabled:
  171. try:
  172. service_config = WakewordServiceConfig(
  173. detector=DetectorConfig(
  174. model_directory=ww_config.model_directory,
  175. models=ww_config.models,
  176. threshold=ww_config.threshold,
  177. use_onnx=ww_config.use_onnx,
  178. ),
  179. buffer=BufferConfig(
  180. pre_buffer_seconds=ww_config.pre_buffer_seconds,
  181. max_duration_seconds=ww_config.max_recording_seconds,
  182. ),
  183. vad=VADConfig(
  184. silence_duration_ms=int(ww_config.silence_timeout_seconds * 1000),
  185. max_duration_ms=int(ww_config.max_recording_seconds * 1000),
  186. no_speech_timeout_ms=int(ww_config.no_speech_timeout_seconds * 1000),
  187. ),
  188. standalone_mode=False,
  189. wakeword_cooldown_seconds=ww_config.wakeword_cooldown_seconds,
  190. )
  191. self._wakeword_service = WakewordService(self, service_config)
  192. self._microphone = MicrophoneCapture(
  193. device=self._client_config.audio.input_device,
  194. )
  195. pinfo("Wakeword-Service erstellt")
  196. except Exception as e:
  197. pwarn(f"Wakeword-Service nicht verfügbar: {e}")
  198. # HID Media Key Service (Konferenzmikrofone, Headsets)
  199. try:
  200. from trixy_core.hid import HIDService, HIDServiceConfig
  201. self._hid_service = HIDService(self, HIDServiceConfig())
  202. self.services.register_instance(self._hid_service)
  203. pdebug("HID-Service registriert")
  204. except Exception as e:
  205. pdebug(f"HID-Service nicht verfuegbar: {e}")
  206. # ALSA Volume Handler (Hardware-Lautstaerke via amixer)
  207. try:
  208. from trixy_core.audio.volume import VolumeHandler
  209. self._volume_handler = VolumeHandler(self)
  210. pdebug("Volume Handler erstellt")
  211. except Exception as e:
  212. pdebug(f"Volume Handler nicht verfuegbar: {e}")
  213. # ConfigListener erstellen und registrieren (Remote-Config-Tool Zugriff)
  214. if self._client_config.config_listener_enabled:
  215. self._config_listener = ConfigListener(
  216. self,
  217. port=self._client_config.config_port,
  218. bind_address="0.0.0.0",
  219. encryption_key_path="certs/encryption.key",
  220. instance_type="client",
  221. )
  222. self.services.register_instance(self._config_listener)
  223. else:
  224. pdebug("ConfigListener deaktiviert")
  225. pdebug(f"Client initialisiert: {self.alias} in {self.room}")
  226. async def start(self) -> None:
  227. """Startet den Client."""
  228. pinfo(f"Starte Client als '{self.alias}' in '{self.room}'...")
  229. from trixy_core.utils.version import VERSION_STRING
  230. await self.events.emit("application_starting", {
  231. "mode": "client",
  232. "version": VERSION_STRING,
  233. })
  234. # Services starten
  235. await self.services.start_all()
  236. # Keyboard-Input vorbereiten (falls aktiviert)
  237. # Wird nach Verbindungsaufbau gestartet um Connection zu haben
  238. if self._keyboard_input_enabled:
  239. self._keyboard_service = create_keyboard_input(
  240. self,
  241. connection=None, # Wird nach Connect gesetzt
  242. prompt=f"{self.alias}> ",
  243. )
  244. # Verbindung zum Server herstellen
  245. await self._connect()
  246. # Volume Handler starten (nach Services, braucht Events)
  247. if hasattr(self, "_volume_handler"):
  248. await self._volume_handler.start()
  249. pinfo(f"Client gestartet, verbinde zu {self.server_host}:{self.server_port}")
  250. from trixy_core.utils.version import VERSION_STRING
  251. await self.events.emit("application_ready", {
  252. "mode": "client",
  253. "version": VERSION_STRING,
  254. "services_count": len(self.services.services),
  255. })
  256. async def stop(self) -> None:
  257. """Stoppt den Client."""
  258. pinfo("Stoppe Client...")
  259. await self.events.emit("application_stopping", {"reason": "shutdown"})
  260. # DEBUG: Gesammelte Audio-Chunks als WAV speichern
  261. if self._debug_tts_chunks:
  262. self._debug_save_wav(self._debug_tts_chunks, "tts")
  263. self._debug_tts_chunks = []
  264. if self._debug_music_chunks:
  265. self._debug_save_wav(self._debug_music_chunks, "music")
  266. self._debug_music_chunks = []
  267. # Wakeword-Service stoppen
  268. if self._wakeword_service:
  269. await self._wakeword_service.stop_listening()
  270. await self._wakeword_service.on_stop()
  271. if self._microphone:
  272. self._microphone.stop()
  273. # Keyboard-Input stoppen
  274. if self._keyboard_service:
  275. await self._keyboard_service.stop()
  276. self._keyboard_service = None
  277. # Reconnect-Task abbrechen
  278. if self._reconnect_task and not self._reconnect_task.done():
  279. self._reconnect_task.cancel()
  280. try:
  281. await self._reconnect_task
  282. except asyncio.CancelledError:
  283. pass
  284. # Shutdown-Event auslösen
  285. await self.events.trigger(
  286. "system_shutdown",
  287. SystemShutdown(reason="Client shutdown", source="client")
  288. )
  289. # Verbindung trennen
  290. await self._disconnect()
  291. # Audio-Manager beenden
  292. if self._audio_manager:
  293. await self._audio_manager.shutdown()
  294. self._audio_manager = None
  295. # Services stoppen
  296. await self.services.stop_all()
  297. # ConfigManager aufräumen
  298. self.config_manager.stop_watching()
  299. pinfo("Client gestoppt")
  300. async def _connect(self) -> bool:
  301. """
  302. Verbindet sich mit dem Server.
  303. Returns:
  304. True bei erfolgreicher Verbindung
  305. """
  306. try:
  307. pdebug(f"Verbinde zu {self.server_host}:{self.server_port}...")
  308. # MAC-Adresse ermitteln
  309. mac_address = self._client_config.identity.mac_address if self._client_config else ""
  310. if not mac_address:
  311. mac_address = get_mac_address()
  312. pdebug(f"MAC-Adresse ermittelt: {mac_address}")
  313. # Verbindungskonfiguration
  314. connection_config = ConnectionConfig(
  315. connect_timeout=self._client_config.server.connection_timeout_seconds if self._client_config else 10.0,
  316. reconnect_interval=self._client_config.server.reconnect_interval_seconds if self._client_config else 5.0,
  317. )
  318. # ClientConnection erstellen
  319. self._connection = ClientConnection(
  320. host=self.server_host,
  321. port=self.server_port,
  322. room=self.room,
  323. alias=self.alias,
  324. mac_address=mac_address,
  325. config=connection_config,
  326. )
  327. # Callbacks registrieren
  328. self._connection.on_connected(self._on_connected)
  329. self._connection.on_disconnected(self._on_disconnected)
  330. self._connection.on_message(self._on_message)
  331. self._connection.on_audio_out(self._on_audio_out)
  332. self._connection.on_music(self._on_music)
  333. # Verbinden
  334. success = await self._connection.connect()
  335. if success:
  336. self._connected = True
  337. pinfo(f"Verbunden mit Server {self.server_host}:{self.server_port}")
  338. # Audio-Format vom Server anwenden
  339. if self._audio_manager:
  340. tts_format = self._connection.tts_format
  341. music_format = self._connection.music_format
  342. await self._audio_manager.update_tts_format(
  343. sample_rate=tts_format["sample_rate"],
  344. channels=tts_format["channels"],
  345. sample_width=tts_format["sample_width"]
  346. )
  347. await self._audio_manager.update_music_format(
  348. sample_rate=music_format["sample_rate"],
  349. channels=music_format["channels"],
  350. sample_width=music_format["sample_width"]
  351. )
  352. pinfo(f"Audio-Format: TTS={tts_format['sample_rate']}Hz, Musik={music_format['sample_rate']}Hz")
  353. # Event auslösen
  354. await self.events.emit("client_connected", {
  355. "host": self.server_host,
  356. "port": self.server_port,
  357. "satellite_id": self._connection.satellite_id,
  358. })
  359. return True
  360. else:
  361. self._connected = False
  362. self._schedule_reconnect()
  363. return False
  364. except Exception as e:
  365. perror(f"Verbindungsfehler: {e}")
  366. self._connected = False
  367. self._schedule_reconnect()
  368. return False
  369. async def _on_connected(self) -> None:
  370. """Callback wenn Verbindung hergestellt."""
  371. pdebug("Verbindungs-Callback: connected")
  372. # Keyboard-Input mit Verbindung starten
  373. if self._keyboard_service and self._connection:
  374. self._keyboard_service.set_connection(self._connection)
  375. await self._keyboard_service.start()
  376. # Wakeword-Service konfigurieren und starten
  377. if self._wakeword_service and self._connection and self._microphone:
  378. self._wakeword_service.setup_for_client(self._connection, self._microphone)
  379. # Callback für Wakeword-getriggertes Recording-Stop registrieren
  380. connection = self._connection
  381. async def on_recording_stopped_by_wakeword(recording_id: str, duration: float) -> None:
  382. if connection:
  383. from trixy_core.network.cmd import SatelliteRecordStopped
  384. cmd = SatelliteRecordStopped(
  385. recording_id=recording_id,
  386. reason="wakeword",
  387. duration_seconds=duration,
  388. )
  389. await connection.send_command(cmd)
  390. self._wakeword_service.set_server_recording_stopped_callback(
  391. on_recording_stopped_by_wakeword
  392. )
  393. try:
  394. await self._wakeword_service.on_start()
  395. await self._wakeword_service.start_listening()
  396. pinfo("Wakeword-Erkennung aktiv")
  397. except Exception as e:
  398. pwarn(f"Wakeword-Start fehlgeschlagen: {e}")
  399. async def _on_disconnected(self, reason: str) -> None:
  400. """Callback wenn Verbindung getrennt."""
  401. pwarn(f"Verbindung getrennt: {reason}")
  402. self._connected = False
  403. # Event auslösen
  404. await self.events.emit("client_disconnected", {
  405. "reason": reason,
  406. })
  407. # Wiederverbindung planen
  408. if self._running:
  409. self._schedule_reconnect()
  410. async def _on_message(self, message: ProtocolMessage) -> None:
  411. """Callback für eingehende Nachrichten vom Server."""
  412. pdebug(f"Nachricht empfangen: {message.class_name}")
  413. # Spezielle Behandlung für bestimmte Commands
  414. if message.class_name == "StreamStop":
  415. await self._handle_stream_stop(message.data)
  416. elif message.class_name == "TTSStop":
  417. await self._handle_tts_stop()
  418. elif message.class_name == "AudioConfigUpdate":
  419. await self._handle_audio_config_update(message.data)
  420. elif message.class_name == "FollowUpRequest":
  421. await self._handle_follow_up_request(message.data)
  422. elif message.class_name == "WakewordSelected":
  423. if self._wakeword_service:
  424. await self._wakeword_service.handle_wakeword_selected(message.data)
  425. elif message.class_name == "WakewordAbort":
  426. if self._wakeword_service:
  427. await self._wakeword_service.handle_wakeword_abort(message.data)
  428. elif message.class_name == "RecordingStop":
  429. if self._wakeword_service:
  430. await self._wakeword_service.handle_conversation_end()
  431. elif message.class_name == "ConversationEnd":
  432. await self._handle_conversation_end(message.data)
  433. if self._wakeword_service:
  434. await self._wakeword_service.handle_conversation_end()
  435. elif message.class_name == "WakewordPause":
  436. if self._wakeword_service:
  437. self._wakeword_service.external_pause()
  438. elif message.class_name == "WakewordResume":
  439. if self._wakeword_service:
  440. self._wakeword_service.external_resume()
  441. elif message.class_name == "WakewordControlStop":
  442. if self._wakeword_service:
  443. await self._wakeword_service.external_stop()
  444. elif message.class_name == "WakewordControlStart":
  445. if self._wakeword_service:
  446. await self._wakeword_service.external_start()
  447. elif message.class_name == "TextInputAck":
  448. await self._handle_text_input_ack(message.data)
  449. elif message.class_name == "ServerShutdown":
  450. await self._handle_server_shutdown(message.data)
  451. # Server-gesteuerte Aufnahme
  452. elif message.class_name == "SatelliteRecordStart":
  453. await self._handle_satellite_record_start(message.data)
  454. elif message.class_name == "SatelliteRecordStop":
  455. await self._handle_satellite_record_stop(message.data)
  456. # Media-Commands
  457. elif message.class_name == "MusicPlayPause":
  458. await self._handle_music_play_pause()
  459. elif message.class_name == "MusicNext":
  460. await self._handle_music_next(message.data)
  461. elif message.class_name == "MusicStop":
  462. await self._handle_music_stop()
  463. elif message.class_name == "MediaStopAll":
  464. await self._handle_media_stop_all()
  465. # Config-Proxy-Anfragen vom Server (fuer Config-Tool)
  466. elif message.class_name == "ConfigReadRequest":
  467. await self._handle_config_read_proxy(message.data)
  468. elif message.class_name == "ConfigWriteRequest":
  469. await self._handle_config_write_proxy(message.data)
  470. # Event auslösen
  471. await self.events.emit("network_message_received", {
  472. "class_name": message.class_name,
  473. "data": message.data,
  474. "flags": int(message.flags),
  475. })
  476. async def _handle_stream_stop(self, data: dict | object) -> None:
  477. """Behandelt Stream-Stop-Befehle vom Server."""
  478. if not self._audio_manager:
  479. return
  480. # Stream-Type und immediate Flag ermitteln
  481. stream_type = getattr(data, "stream_type", None) or "all"
  482. immediate = getattr(data, "immediate", False)
  483. if isinstance(data, dict):
  484. stream_type = data.get("stream_type", "all")
  485. immediate = data.get("immediate", False)
  486. pdebug(f"Stream-Stop empfangen: {stream_type}, immediate={immediate}")
  487. # DEBUG: WAV speichern vor Stop
  488. if stream_type in ("all", "tts") and self._debug_tts_chunks:
  489. self._debug_save_wav(self._debug_tts_chunks, "tts")
  490. self._debug_tts_chunks = []
  491. if stream_type in ("all", "music") and self._debug_music_chunks:
  492. self._debug_save_wav(self._debug_music_chunks, "music")
  493. self._debug_music_chunks = []
  494. # Bei immediate=True sofort stoppen, sonst Buffer abspielen (finish)
  495. if immediate:
  496. if stream_type == "all":
  497. self._audio_manager.stop_all()
  498. elif stream_type == "tts":
  499. self._audio_manager.stop_tts()
  500. elif stream_type == "music":
  501. self._audio_manager.stop_music()
  502. else:
  503. if stream_type == "all":
  504. self._audio_manager.finish_tts()
  505. self._audio_manager.finish_music()
  506. elif stream_type == "tts":
  507. self._audio_manager.finish_tts()
  508. elif stream_type == "music":
  509. self._audio_manager.finish_music()
  510. # Flags zurücksetzen
  511. if stream_type in ("all", "tts"):
  512. self._tts_playing = False
  513. if stream_type in ("all", "music"):
  514. self._music_playing = False
  515. async def _handle_tts_stop(self) -> None:
  516. """Behandelt TTS-Stop-Befehle vom Server."""
  517. # DEBUG: WAV speichern vor Stop
  518. if self._debug_tts_chunks:
  519. self._debug_save_wav(self._debug_tts_chunks, "tts")
  520. self._debug_tts_chunks = []
  521. # Flag zurücksetzen für nächsten TTS-Stream
  522. self._tts_playing = False
  523. if self._audio_manager:
  524. # finish_tts() signalisiert Ende und spielt Rest ab
  525. # stop_tts() würde sofort stoppen und Buffer leeren
  526. self._audio_manager.finish_tts()
  527. pdebug("TTS-Stream beendet (finish)")
  528. async def _handle_audio_config_update(self, data: dict | object) -> None:
  529. """
  530. Behandelt Audio-Konfigurationsänderungen vom Server.
  531. Wird aufgerufen wenn sich das TTS-Plugin ändert und
  532. der Client seine Audio-Ausgabe anpassen muss.
  533. """
  534. if not self._audio_manager:
  535. return
  536. # TTS-Format aktualisieren
  537. tts_sample_rate = getattr(data, "tts_sample_rate", 0)
  538. tts_channels = getattr(data, "tts_channels", 0)
  539. tts_sample_width = getattr(data, "tts_sample_width", 0)
  540. if isinstance(data, dict):
  541. tts_sample_rate = data.get("tts_sample_rate", 0)
  542. tts_channels = data.get("tts_channels", 0)
  543. tts_sample_width = data.get("tts_sample_width", 0)
  544. if tts_sample_rate > 0:
  545. await self._audio_manager.update_tts_format(
  546. sample_rate=tts_sample_rate,
  547. channels=tts_channels or 1,
  548. sample_width=tts_sample_width or 2
  549. )
  550. pinfo(f"TTS-Format aktualisiert: {tts_sample_rate}Hz, {tts_channels}ch")
  551. # Musik-Format aktualisieren (falls angegeben)
  552. music_sample_rate = getattr(data, "music_sample_rate", 0)
  553. music_channels = getattr(data, "music_channels", 0)
  554. music_sample_width = getattr(data, "music_sample_width", 0)
  555. if isinstance(data, dict):
  556. music_sample_rate = data.get("music_sample_rate", 0)
  557. music_channels = data.get("music_channels", 0)
  558. music_sample_width = data.get("music_sample_width", 0)
  559. if music_sample_rate > 0:
  560. await self._audio_manager.update_music_format(
  561. sample_rate=music_sample_rate,
  562. channels=music_channels or 2,
  563. sample_width=music_sample_width or 2
  564. )
  565. pinfo(f"Musik-Format aktualisiert: {music_sample_rate}Hz, {music_channels}ch")
  566. async def _handle_follow_up_request(self, data: dict | object) -> None:
  567. """
  568. Behandelt Follow-Up-Anfragen vom Server.
  569. Bei Keyboard-Input wird automatisch ein neuer Input-Prompt geöffnet.
  570. """
  571. from trixy_core.network.cmd import FollowUpRequest
  572. # Daten extrahieren
  573. if isinstance(data, FollowUpRequest):
  574. request = data
  575. else:
  576. session_id = getattr(data, "session_id", "") or data.get("session_id", "")
  577. question = getattr(data, "question", "") or data.get("question", "")
  578. request = FollowUpRequest(session_id=session_id, question=question)
  579. pdebug(f"Follow-Up-Request empfangen: {request.question[:50]}...")
  580. # Keyboard-Input für Antwort öffnen (falls aktiv)
  581. if self._keyboard_service:
  582. self._keyboard_service.handle_follow_up_request(request)
  583. async def _handle_conversation_end(self, data: dict | object) -> None:
  584. """
  585. Behandelt das Ende einer Conversation.
  586. Setzt den Keyboard-Input-Status zurück.
  587. """
  588. session_id = getattr(data, "session_id", "") or ""
  589. if isinstance(data, dict):
  590. session_id = data.get("session_id", "")
  591. pdebug(f"Conversation beendet: {session_id}")
  592. # Keyboard-Input zurücksetzen
  593. if self._keyboard_service:
  594. self._keyboard_service.handle_conversation_end(session_id)
  595. async def _handle_text_input_ack(self, data: dict | object) -> None:
  596. """
  597. Behandelt Bestätigung für Text-Eingabe.
  598. """
  599. session_id = getattr(data, "session_id", "") or ""
  600. received = getattr(data, "received", True)
  601. error = getattr(data, "error", "") or ""
  602. if isinstance(data, dict):
  603. session_id = data.get("session_id", "")
  604. received = data.get("received", True)
  605. error = data.get("error", "")
  606. if received:
  607. pdebug(f"TextInput bestätigt: session={session_id}")
  608. else:
  609. perror(f"TextInput fehlgeschlagen: {error}")
  610. async def _handle_server_shutdown(self, data: dict | object) -> None:
  611. """
  612. Behandelt Server-Shutdown-Benachrichtigung.
  613. Der Server fährt herunter, keine Wiederverbindung versuchen.
  614. """
  615. reason = getattr(data, "reason", "") or ""
  616. reconnect_after = getattr(data, "reconnect_after_seconds", 0) or 0
  617. if isinstance(data, dict):
  618. reason = data.get("reason", "")
  619. reconnect_after = data.get("reconnect_after_seconds", 0)
  620. pinfo(f"Server-Shutdown empfangen: {reason}")
  621. # Keine Wiederverbindung wenn reconnect_after_seconds = 0
  622. if reconnect_after == 0:
  623. # Anwendung beenden
  624. pinfo("Server fährt herunter, beende Client...")
  625. self.shutdown()
  626. else:
  627. # Wiederverbindung nach Wartezeit
  628. pinfo(f"Server-Neustart erwartet, Wiederverbindung in {reconnect_after}s...")
  629. await asyncio.sleep(reconnect_after)
  630. if self._running:
  631. self._schedule_reconnect()
  632. async def _handle_music_play_pause(self) -> None:
  633. """Toggle Musik Play/Pause."""
  634. if self._audio_manager:
  635. is_paused = self._audio_manager.toggle_music_pause()
  636. status = "pausiert" if is_paused else "fortgesetzt"
  637. pinfo(f"Musik {status}")
  638. await self.events.emit("music_paused" if is_paused else "music_resumed", {})
  639. async def _handle_music_next(self, data: dict | object) -> None:
  640. """Nächstes Lied - Signal an Musik-Plugin weitergeben."""
  641. use_crossfade = getattr(data, "use_crossfade", True)
  642. if isinstance(data, dict):
  643. use_crossfade = data.get("use_crossfade", True)
  644. pdebug(f"MusicNext empfangen, crossfade={use_crossfade}")
  645. await self.events.emit("music_next", {
  646. "use_crossfade": use_crossfade,
  647. })
  648. async def _handle_music_stop(self) -> None:
  649. """Musik stoppen."""
  650. if self._audio_manager:
  651. self._audio_manager.stop_music()
  652. pinfo("Musik gestoppt")
  653. await self.events.emit("music_stopped", {})
  654. async def _handle_media_stop_all(self) -> None:
  655. """Alle Medien stoppen (Musik + TTS)."""
  656. if self._audio_manager:
  657. self._audio_manager.stop_all()
  658. pinfo("Alle Wiedergabe gestoppt")
  659. await self.events.emit("media_stopped", {})
  660. async def _handle_satellite_record_start(self, data: dict | object) -> None:
  661. """
  662. Behandelt SatelliteRecordStart vom Server.
  663. Aktiviert Audio-Streaming zum Server — entweder über den
  664. WakewordService oder als Fallback direkt über das Mikrofon.
  665. """
  666. recording_id = getattr(data, "recording_id", "") or ""
  667. timeout_seconds = getattr(data, "timeout_seconds", 300.0)
  668. if isinstance(data, dict):
  669. recording_id = data.get("recording_id", "")
  670. timeout_seconds = data.get("timeout_seconds", 300.0)
  671. pinfo(f"Server-Recording empfangen: {recording_id} (timeout={timeout_seconds}s)")
  672. if self._wakeword_service:
  673. # Mit WakewordService: Audio-Streaming im LISTENING-Zustand aktivieren
  674. self._wakeword_service.start_server_recording(recording_id)
  675. elif self._microphone and self._connection:
  676. # Fallback ohne WakewordService: Mikrofon direkt streamen
  677. connection = self._connection
  678. loop = asyncio.get_event_loop()
  679. self._microphone.set_callback(
  680. lambda audio: loop.call_soon_threadsafe(
  681. asyncio.ensure_future, connection.send_audio(audio)
  682. )
  683. )
  684. self._microphone.start()
  685. self._direct_streaming_active = True
  686. pinfo("Direktes Mikrofon-Streaming gestartet (ohne WakewordService)")
  687. async def _handle_satellite_record_stop(self, data: dict | object) -> None:
  688. """
  689. Behandelt SatelliteRecordStop vom Server.
  690. Beendet das Audio-Streaming und sendet Bestätigung.
  691. """
  692. recording_id = getattr(data, "recording_id", "") or ""
  693. reason = getattr(data, "reason", "manual") or "manual"
  694. if isinstance(data, dict):
  695. recording_id = data.get("recording_id", "")
  696. reason = data.get("reason", "manual")
  697. pinfo(f"Server-Recording-Stop empfangen: {recording_id} (reason={reason})")
  698. duration = 0.0
  699. if self._wakeword_service:
  700. # WakewordService: Recording stoppen
  701. duration = self._wakeword_service.stop_server_recording()
  702. elif self._direct_streaming_active:
  703. # Fallback: Direktes Streaming stoppen
  704. if self._microphone:
  705. self._microphone.stop()
  706. self._direct_streaming_active = False
  707. pinfo("Direktes Mikrofon-Streaming gestoppt")
  708. # Bestätigung an Server senden
  709. if self._connection:
  710. from trixy_core.network.cmd import SatelliteRecordStopped
  711. cmd = SatelliteRecordStopped(
  712. recording_id=recording_id,
  713. reason=reason,
  714. duration_seconds=duration,
  715. )
  716. await self._connection.send_command(cmd)
  717. async def _on_audio_out(self, audio_data: bytes) -> None:
  718. """
  719. Callback für Audio-Ausgabe (TTS) vom Server.
  720. Die Chunks werden direkt in den Streaming-Player eingespeist
  721. und sofort abgespielt.
  722. """
  723. # DEBUG: Chunks sammeln
  724. self._debug_tts_chunks.append(audio_data)
  725. # In Audio-Manager einspeisen
  726. if self._audio_manager:
  727. # Debug: Ersten Chunk und Playback-Start loggen
  728. if not hasattr(self, "_tts_playing"):
  729. self._tts_playing = False
  730. was_playing = self._tts_playing
  731. success = self._audio_manager.play_tts_chunk(audio_data)
  732. if not success:
  733. pwarn(f"[CLIENT] TTS-Chunk abgelehnt! ({len(audio_data)} bytes)")
  734. if success and not was_playing:
  735. pdebug(f"[CLIENT] TTS-Wiedergabe gestartet ({len(audio_data)} bytes erster Chunk)")
  736. self._tts_playing = True
  737. # Event für zusätzliche Verarbeitung auslösen (fire-and-forget, blockiert nicht)
  738. tts_format = self._audio_manager.tts_format
  739. asyncio.create_task(self.events.emit("audio_output_received", {
  740. "audio_data": audio_data,
  741. "sample_rate": tts_format["sample_rate"],
  742. "channels": tts_format["channels"],
  743. }))
  744. async def _on_music(self, audio_data: bytes) -> None:
  745. """
  746. Callback für Musik-Stream vom Server.
  747. Die Chunks werden direkt in den Streaming-Player eingespeist
  748. und sofort abgespielt.
  749. """
  750. # DEBUG: Chunks sammeln
  751. self._debug_music_chunks.append(audio_data)
  752. # In Audio-Manager einspeisen
  753. if self._audio_manager:
  754. # Debug: Ersten Chunk und Playback-Start loggen
  755. if not hasattr(self, "_music_playing"):
  756. self._music_playing = False
  757. was_playing = self._music_playing
  758. success = self._audio_manager.play_music_chunk(audio_data)
  759. if success and not was_playing:
  760. pdebug(f"[CLIENT] Music-Wiedergabe gestartet ({len(audio_data)} bytes erster Chunk)")
  761. self._music_playing = True
  762. # Event für zusätzliche Verarbeitung auslösen (fire-and-forget, blockiert nicht)
  763. music_format = self._audio_manager.music_format
  764. asyncio.create_task(self.events.emit("music_output_received", {
  765. "audio_data": audio_data,
  766. "sample_rate": music_format["sample_rate"],
  767. "channels": music_format["channels"],
  768. }))
  769. def _debug_save_wav(self, chunks: list[bytes], stream_type: str) -> None:
  770. """DEBUG: Speichert gesammelte Audio-Chunks als WAV-Datei."""
  771. if not chunks:
  772. pdebug(f"[DEBUG] Keine {stream_type}-Chunks zum Speichern")
  773. return
  774. if not self._audio_manager:
  775. return
  776. # Format ermitteln
  777. if stream_type == "tts":
  778. fmt = self._audio_manager.tts_format
  779. else:
  780. fmt = self._audio_manager.music_format
  781. sample_rate = fmt["sample_rate"]
  782. channels = fmt["channels"]
  783. sample_width = fmt.get("sample_width", 2)
  784. # Alle Chunks zusammenfügen
  785. all_data = b"".join(chunks)
  786. # Dateiname mit Timestamp
  787. timestamp = int(time.time())
  788. filename = f"/tmp/debug_{stream_type}_{timestamp}.wav"
  789. try:
  790. with wave.open(filename, "wb") as wf:
  791. wf.setnchannels(channels)
  792. wf.setsampwidth(sample_width)
  793. wf.setframerate(sample_rate)
  794. wf.writeframes(all_data)
  795. pinfo(f"[DEBUG] WAV gespeichert: {filename} ({len(all_data)} bytes, {sample_rate}Hz, {channels}ch)")
  796. except Exception as e:
  797. perror(f"[DEBUG] WAV speichern fehlgeschlagen: {e}")
  798. async def _handle_config_read_proxy(self, data: object) -> None:
  799. """Beantwortet eine Config-Lese-Anfrage vom Server (Config-Tool Proxy)."""
  800. import dataclasses
  801. from trixy_core.network.cmd.config_cmd import ConfigReadResponse
  802. message_id = getattr(data, "message_id", "") or ""
  803. config_name = getattr(data, "config_name", "") or ""
  804. section = getattr(data, "section", "") or ""
  805. response = ConfigReadResponse(
  806. message_id=message_id,
  807. config_name=config_name,
  808. section=section,
  809. )
  810. try:
  811. config_obj = self._client_config
  812. if config_obj is None:
  813. response.data = {"error": "Keine Client-Konfiguration verfuegbar"}
  814. else:
  815. config_dict = dataclasses.asdict(config_obj)
  816. if section and section in config_dict:
  817. response.data = {section: config_dict[section]}
  818. else:
  819. response.data = config_dict
  820. except Exception as e:
  821. response.data = {"error": str(e)}
  822. if self._connection:
  823. await self._connection.send_command(response)
  824. async def _handle_config_write_proxy(self, data: object) -> None:
  825. """Beantwortet eine Config-Schreib-Anfrage vom Server (Config-Tool Proxy)."""
  826. from trixy_core.network.cmd.config_cmd import ConfigWriteResponse
  827. message_id = getattr(data, "message_id", "") or ""
  828. key_path = getattr(data, "key_path", "") or ""
  829. value = getattr(data, "value", None)
  830. response = ConfigWriteResponse(message_id=message_id)
  831. try:
  832. config_obj = self._client_config
  833. if config_obj is None:
  834. response.error = "Keine Client-Konfiguration verfuegbar"
  835. else:
  836. # Punkt-Pfad navigieren und Wert setzen
  837. parts = key_path.split(".")
  838. target = config_obj
  839. for part in parts[:-1]:
  840. if hasattr(target, part):
  841. target = getattr(target, part)
  842. else:
  843. response.error = f"Pfad '{key_path}' nicht gefunden"
  844. break
  845. if not response.error:
  846. field_name = parts[-1]
  847. if not hasattr(target, field_name):
  848. response.error = f"Feld '{field_name}' nicht gefunden"
  849. else:
  850. # Typ-Konvertierung
  851. current_value = getattr(target, field_name)
  852. if isinstance(current_value, bool):
  853. if isinstance(value, str):
  854. value = value.lower() in ("true", "1", "ja", "yes")
  855. else:
  856. value = bool(value)
  857. elif isinstance(current_value, int):
  858. value = int(value)
  859. elif isinstance(current_value, float):
  860. value = float(value)
  861. elif isinstance(current_value, list):
  862. if isinstance(value, str):
  863. value = [v.strip() for v in value.split(",") if v.strip()]
  864. elif isinstance(current_value, str):
  865. value = str(value)
  866. setattr(target, field_name, value)
  867. # Konfiguration speichern
  868. self.config_manager.save("client")
  869. response.success = True
  870. if any(p in key_path for p in ("port", "bind_address", "host")):
  871. response.requires_restart = True
  872. except Exception as e:
  873. response.error = str(e)
  874. if self._connection:
  875. await self._connection.send_command(response)
  876. async def _disconnect(self) -> None:
  877. """Trennt die Verbindung zum Server."""
  878. if self._connection:
  879. await self._connection.disconnect("Client shutdown")
  880. self._connection = None
  881. self._connected = False
  882. pdebug("Verbindung getrennt")
  883. def _schedule_reconnect(self) -> None:
  884. """Plant einen Wiederverbindungsversuch."""
  885. if self._reconnect_task and not self._reconnect_task.done():
  886. return
  887. async def reconnect() -> None:
  888. interval = self._client_config.server.reconnect_interval_seconds if self._client_config else 5.0
  889. while self._running and not self._connected:
  890. pwarn(f"Wiederverbindung in {interval} Sekunden...")
  891. await asyncio.sleep(interval)
  892. if self._running:
  893. await self._connect()
  894. self._reconnect_task = asyncio.create_task(reconnect())