connection_notification.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. # -*- coding: utf-8 -*-
  2. """
  3. Connection Notification Handler.
  4. Spielt Benachrichtigungen ab, wenn ein Satellite sich verbindet.
  5. """
  6. import asyncio
  7. from pathlib import Path
  8. from typing import TYPE_CHECKING
  9. from trixy_core.config.datasets.server import ConnectionNotification
  10. from trixy_core.utils.debug import pdebug, pinfo, perror
  11. if TYPE_CHECKING:
  12. from trixy_core.application import IApplication
  13. class ConnectionNotificationHandler:
  14. """
  15. Handler für Verbindungsbenachrichtigungen.
  16. Reagiert auf satellite_connected Events und spielt:
  17. - TTS "Verbunden" auf dem Client
  18. - Wave-Datei "connected.wav" auf dem Music-Stream
  19. - Beides
  20. - Nichts
  21. Konfiguration über server_config.satellite_connection.
  22. """
  23. def __init__(self, application: "IApplication") -> None:
  24. """
  25. Initialisiert den Handler.
  26. Args:
  27. application: Referenz zur Hauptanwendung
  28. """
  29. self._application = application
  30. self._registered = False
  31. @property
  32. def _config(self):
  33. """Holt die Notification-Konfiguration."""
  34. return self._application.server_config.satellite_connection
  35. @property
  36. def _notification_flags(self) -> ConnectionNotification:
  37. """Holt die Notification-Flags."""
  38. return ConnectionNotification(self._config.notification)
  39. def register(self) -> None:
  40. """Registriert den Event-Handler."""
  41. if self._registered:
  42. pdebug("ConnectionNotificationHandler: Bereits registriert")
  43. return
  44. # EventManager ist als 'events' Property verfügbar
  45. events = getattr(self._application, "events", None)
  46. if events:
  47. events.register("satellite_connected", self._on_satellite_connected)
  48. self._registered = True
  49. flags = self._notification_flags
  50. pdebug(f"ConnectionNotificationHandler registriert (Flags: {flags}, TTS: {bool(flags & ConnectionNotification.AUDIO_TTS)}, Wave: {bool(flags & ConnectionNotification.AUDIO_WAVE)})")
  51. else:
  52. perror("ConnectionNotificationHandler: Kein EventManager verfügbar")
  53. def unregister(self) -> None:
  54. """Deregistriert den Event-Handler."""
  55. if not self._registered:
  56. return
  57. events = getattr(self._application, "events", None)
  58. if events:
  59. events.unregister("satellite_connected", self._on_satellite_connected)
  60. self._registered = False
  61. async def _on_satellite_connected(self, event_name: str, event_data) -> None:
  62. """
  63. Handler für satellite_connected Event.
  64. Args:
  65. event_name: Name des Events
  66. event_data: Event-Daten mit satellite_id (dict oder Objekt)
  67. """
  68. pdebug(f"ConnectionNotificationHandler: Event empfangen")
  69. flags = self._notification_flags
  70. pdebug(f"ConnectionNotificationHandler: Flags = {flags} ({int(flags)})")
  71. if flags == ConnectionNotification.NONE:
  72. pdebug("ConnectionNotificationHandler: Notification ist NONE, überspringe")
  73. return
  74. # Event-Data kann dict oder Objekt sein
  75. if isinstance(event_data, dict):
  76. satellite_id = event_data.get("satellite_id")
  77. alias = event_data.get("alias", "")
  78. else:
  79. satellite_id = getattr(event_data, "satellite_id", None)
  80. alias = getattr(event_data, "alias", "")
  81. if not satellite_id:
  82. perror("ConnectionNotificationHandler: Keine satellite_id in Event-Daten")
  83. return
  84. pdebug(f"Verbindungsbenachrichtigung für: {alias} ({satellite_id})")
  85. # Warte bis alle benötigten Sockets verbunden sind
  86. sockets_ready = await self._wait_for_sockets(satellite_id, flags)
  87. if not sockets_ready:
  88. perror(f"Sockets nicht bereit für Benachrichtigung: {alias}")
  89. return
  90. # TTS-Benachrichtigung abspielen falls aktiviert
  91. if flags & ConnectionNotification.AUDIO_TTS:
  92. asyncio.create_task(self._play_tts(satellite_id))
  93. # Musik-Wiedergabe läuft über MusicPlayerService (reagiert auf music_socket_connected)
  94. # Der MusicPlayerService erstellt automatisch eine Playlist aus dem Assets-Ordner
  95. pdebug("Musik-Wiedergabe wird von MusicPlayerService gesteuert")
  96. async def _wait_for_sockets(
  97. self,
  98. satellite_id: str,
  99. flags: ConnectionNotification,
  100. timeout: float = 10.0
  101. ) -> bool:
  102. """
  103. Wartet bis die benötigten Audio-Sockets verbunden sind.
  104. Die Audio-Sockets (audio_out, music_out) verbinden sich separat
  105. nach dem Command-Socket auf eigenen Ports.
  106. Args:
  107. satellite_id: ID des Satellites
  108. flags: Benachrichtigungs-Flags
  109. timeout: Maximale Wartezeit in Sekunden
  110. Returns:
  111. True wenn alle benötigten Sockets bereit sind
  112. """
  113. satellite = self._application.satellites.get(satellite_id)
  114. if not satellite:
  115. return False
  116. start_time = asyncio.get_event_loop().time()
  117. check_interval = 0.2 # Alle 200ms prüfen
  118. while asyncio.get_event_loop().time() - start_time < timeout:
  119. # Prüfe ob Satellite noch verbunden ist
  120. if not satellite.is_connected:
  121. return False
  122. # Prüfe benötigte Sockets
  123. tts_ready = True
  124. wave_ready = True
  125. if flags & ConnectionNotification.AUDIO_TTS:
  126. # TTS braucht audio_out Socket
  127. tts_ready = satellite.sockets.audio_out is not None
  128. if flags & ConnectionNotification.AUDIO_WAVE:
  129. # Wave braucht music_out Socket
  130. wave_ready = satellite.sockets.music_out is not None
  131. if tts_ready and wave_ready:
  132. pdebug(f"Alle Sockets bereit für: {satellite.alias}")
  133. return True
  134. await asyncio.sleep(check_interval)
  135. pdebug(f"Timeout beim Warten auf Sockets für: {satellite.alias}")
  136. return False
  137. async def _play_tts(self, satellite_id: str) -> bool:
  138. """
  139. Spielt TTS-Benachrichtigung auf dem Satellite.
  140. Synthetisiert den Text direkt und sendet das Audio
  141. zum audio_out Socket des Satellites.
  142. Args:
  143. satellite_id: ID des Satellites
  144. Returns:
  145. True bei Erfolg
  146. """
  147. try:
  148. from trixy_core.utils.template_formatter import format_template
  149. text = format_template(self._config.tts_text, application=self._application)
  150. satellite = self._application.satellites.get(satellite_id)
  151. if not satellite or not satellite.is_connected:
  152. perror(f"Satellite nicht verbunden für TTS: {satellite_id}")
  153. return False
  154. if not satellite.sockets.audio_out:
  155. perror(f"Audio-out Socket nicht verfügbar: {satellite_id}")
  156. return False
  157. pdebug(f"TTS-Benachrichtigung: '{text}' an {satellite.alias}")
  158. # Versuche TTS-Provider aus Plugin-System zu bekommen
  159. audio_data = await self._synthesize_tts(text)
  160. if not audio_data:
  161. perror("TTS-Synthese fehlgeschlagen")
  162. return False
  163. # Audio direkt zum Satellite senden
  164. pdebug(f"[SERVER] Sende TTS-Audio ({len(audio_data)} bytes) an {satellite.alias} via audio_out")
  165. success = await satellite.say(audio_data)
  166. if success:
  167. pinfo(f"[SERVER] TTS-Benachrichtigung gesendet: '{text}' an {satellite.alias} ({len(audio_data)} bytes)")
  168. # TTSStop senden damit Client weiß dass Stream beendet ist
  169. from trixy_core.network.cmd import TTSStop
  170. network = getattr(self._application.services, "network", None)
  171. if network and hasattr(network, "send_to_satellite"):
  172. await network.send_to_satellite(satellite_id, TTSStop())
  173. pdebug(f"[SERVER] TTSStop gesendet an {satellite.alias}")
  174. else:
  175. perror(f"[SERVER] TTS-Audio senden fehlgeschlagen für {satellite.alias}")
  176. return success
  177. except Exception as e:
  178. perror(f"TTS-Benachrichtigung fehlgeschlagen: {e}")
  179. return False
  180. async def _synthesize_tts(self, text: str) -> bytes | None:
  181. """
  182. Synthetisiert Text zu Audio.
  183. Versucht verschiedene Methoden:
  184. 1. TTS-Provider aus Plugin-System
  185. 2. Event-basierte TTS-Synthese
  186. Args:
  187. text: Zu synthetisierender Text
  188. Returns:
  189. Audio-Bytes oder None bei Fehler
  190. """
  191. # Versuche über Event-System mit Callback
  192. audio_result: bytes | None = None
  193. synthesis_done = asyncio.Event()
  194. async def on_tts_completed(event_name: str, data) -> None:
  195. nonlocal audio_result
  196. # EventData und dict haben beide .get() Methode
  197. audio_data_hex = data.get("audio_data") if hasattr(data, "get") else None
  198. if audio_data_hex:
  199. try:
  200. audio_result = bytes.fromhex(audio_data_hex)
  201. pdebug(f"TTS-Audio empfangen: {len(audio_result)} bytes")
  202. except (ValueError, AttributeError) as e:
  203. perror(f"TTS-Audio Dekodierung fehlgeschlagen: {e}")
  204. synthesis_done.set()
  205. events = getattr(self._application, "events", None)
  206. if not events:
  207. perror("ConnectionNotificationHandler: Kein EventManager für TTS-Synthese")
  208. return None
  209. # Temporären Handler registrieren
  210. events.register("tts_completed", on_tts_completed)
  211. try:
  212. # TTS-Request auslösen (als dict, wie vom Piper Plugin erwartet)
  213. import uuid
  214. request = {
  215. "request_id": str(uuid.uuid4()),
  216. "satellite_id": "", # Nicht wichtig für Synthese
  217. "text": text,
  218. "voice_id": None,
  219. "source": "connection_notification"
  220. }
  221. pdebug(f"ConnectionNotificationHandler: Triggere tts_request für '{text}'")
  222. await events.emit("tts_request", request)
  223. # Auf Synthese warten (max 10 Sekunden)
  224. try:
  225. await asyncio.wait_for(synthesis_done.wait(), timeout=10.0)
  226. except asyncio.TimeoutError:
  227. pdebug("TTS-Synthese Timeout")
  228. return None
  229. return audio_result
  230. finally:
  231. events.unregister("tts_completed", on_tts_completed)
  232. async def _play_crossfade_mix(self, satellite_id: str) -> bool:
  233. """
  234. Spielt einen Mix mehrerer Songs mit Crossfade.
  235. Lädt mehrere MP3-Dateien aus dem Assets-Ordner und
  236. spielt sie mit Crossfade-Übergängen ab.
  237. Args:
  238. satellite_id: ID des Satellites
  239. Returns:
  240. True bei Erfolg
  241. """
  242. try:
  243. assets_dir = self._application.server_config.assets_directory
  244. profile = self._application.server_config.profile
  245. # Liste der Songs für den Mix (in Reihenfolge)
  246. song_files = [
  247. "06 - Drumsound & Bassline Smith - Cold Turkey (G Dub, Beat City VIP).mp3",
  248. "Alex Clare - Too Close.mp3",
  249. "Alice Cooper - Poison.mp3",
  250. "Apache 207 - Roller.mp3",
  251. ]
  252. # Songs finden
  253. song_paths = []
  254. for song_file in song_files:
  255. path = self._find_music_file(song_file, assets_dir, profile)
  256. if path:
  257. song_paths.append(path)
  258. pdebug(f"Song gefunden: {song_file}")
  259. else:
  260. pdebug(f"Song nicht gefunden: {song_file}")
  261. if len(song_paths) < 2:
  262. perror("Nicht genug Songs für Crossfade-Mix gefunden")
  263. return False
  264. pinfo(f"[SERVER] Starte Crossfade-Mix mit {len(song_paths)} Songs an {satellite_id}")
  265. return await self._stream_crossfade_mix(satellite_id, song_paths)
  266. except Exception as e:
  267. perror(f"Crossfade-Mix fehlgeschlagen: {e}")
  268. import traceback
  269. traceback.print_exc()
  270. return False
  271. async def _stream_crossfade_mix(
  272. self,
  273. satellite_id: str,
  274. song_paths: list[Path],
  275. crossfade_duration_ms: int = 5000,
  276. song_duration_seconds: float = 30.0,
  277. ) -> bool:
  278. """
  279. Streamt einen Crossfade-Mix mehrerer Songs.
  280. Args:
  281. satellite_id: ID des Satellites
  282. song_paths: Liste der Song-Pfade
  283. crossfade_duration_ms: Crossfade-Dauer in Millisekunden
  284. song_duration_seconds: Wie lange jeder Song gespielt wird (0 = komplett)
  285. Returns:
  286. True bei Erfolg
  287. """
  288. from trixy_core.music.formats.mp3 import MP3Decoder
  289. from plugins.crossfade.processor import crossfade_audio
  290. try:
  291. # Satellite und Socket holen
  292. satellite = self._application.satellites.get(satellite_id)
  293. if not satellite or not satellite.is_connected:
  294. perror(f"Satellite nicht verbunden: {satellite_id}")
  295. return False
  296. music_socket = satellite.sockets.music_out
  297. if not music_socket:
  298. perror(f"Music-Socket nicht verfügbar für: {satellite_id}")
  299. return False
  300. # Audio-Format aus Config
  301. audio_config = self._application.server_config.audio
  302. target_sample_rate = audio_config.music_sample_rate
  303. target_channels = audio_config.music_channels
  304. bytes_per_second = target_sample_rate * target_channels * 2 # 16-bit
  305. # MP3-Decoder
  306. decoder = MP3Decoder()
  307. if not decoder._check_dependencies():
  308. perror("MP3-Decoder nicht verfügbar")
  309. return False
  310. # Songs dekodieren (nur Ausschnitte wenn song_duration_seconds > 0)
  311. decoded_songs: list[bytes] = []
  312. for i, song_path in enumerate(song_paths):
  313. pinfo(f"[SERVER] Dekodiere Song {i+1}/{len(song_paths)}: {song_path.name}")
  314. decoded = await decoder.decode(
  315. song_path,
  316. target_sample_rate=target_sample_rate,
  317. target_channels=target_channels
  318. )
  319. audio_data = decoded.pcm_data
  320. # Nur Ausschnitt nehmen wenn gewünscht
  321. if song_duration_seconds > 0:
  322. max_bytes = int(song_duration_seconds * bytes_per_second)
  323. audio_data = audio_data[:max_bytes]
  324. decoded_songs.append(audio_data)
  325. pdebug(f" Dekodiert: {len(audio_data)} bytes ({len(audio_data) / bytes_per_second:.1f}s)")
  326. # Crossfade-Mix erstellen
  327. pinfo(f"[SERVER] Erstelle Crossfade-Mix ({crossfade_duration_ms}ms Übergang)")
  328. mixed_audio = decoded_songs[0]
  329. for i in range(1, len(decoded_songs)):
  330. pinfo(f"[SERVER] Crossfade {i}: {song_paths[i-1].stem[:30]}... -> {song_paths[i].stem[:30]}...")
  331. mixed_audio = crossfade_audio(
  332. mixed_audio,
  333. decoded_songs[i],
  334. crossfade_ms=crossfade_duration_ms,
  335. sample_rate=target_sample_rate,
  336. channels=target_channels,
  337. fade_curve="cosine",
  338. )
  339. total_duration = len(mixed_audio) / bytes_per_second
  340. pinfo(f"[SERVER] Mix erstellt: {len(mixed_audio)} bytes ({total_duration:.1f}s)")
  341. # Mix streamen
  342. return await self._stream_pcm_data(
  343. satellite_id,
  344. mixed_audio,
  345. target_sample_rate,
  346. target_channels,
  347. f"Crossfade-Mix ({len(song_paths)} Songs)",
  348. )
  349. except Exception as e:
  350. perror(f"Fehler beim Crossfade-Mix: {e}")
  351. import traceback
  352. traceback.print_exc()
  353. return False
  354. async def _stream_pcm_data(
  355. self,
  356. satellite_id: str,
  357. audio_data: bytes,
  358. sample_rate: int,
  359. channels: int,
  360. description: str = "Audio",
  361. ) -> bool:
  362. """
  363. Streamt rohe PCM-Daten zum Satellite.
  364. Args:
  365. satellite_id: ID des Satellites
  366. audio_data: PCM-Audio-Daten (16-bit)
  367. sample_rate: Sample-Rate
  368. channels: Anzahl Kanäle
  369. description: Beschreibung für Logs
  370. Returns:
  371. True bei Erfolg
  372. """
  373. import time
  374. try:
  375. satellite = self._application.satellites.get(satellite_id)
  376. if not satellite or not satellite.is_connected:
  377. return False
  378. music_socket = satellite.sockets.music_out
  379. if not music_socket:
  380. return False
  381. sample_width = 2 # 16-bit
  382. bytes_per_second = sample_rate * channels * sample_width
  383. # Chunk-Größe: ~100ms Audio
  384. chunk_duration_ms = 100
  385. chunk_size = (bytes_per_second * chunk_duration_ms) // 1000
  386. frame_size = channels * sample_width
  387. chunk_size = (chunk_size // frame_size) * frame_size
  388. # Prebuffer: 5 Sekunden
  389. prebuffer_bytes = int(bytes_per_second * 5.0)
  390. total_chunks = (len(audio_data) + chunk_size - 1) // chunk_size
  391. duration_seconds = len(audio_data) / bytes_per_second
  392. pinfo(f"[SERVER] Starte Streaming: {description}")
  393. pinfo(f"[SERVER] {len(audio_data)} bytes, {duration_seconds:.1f}s, {total_chunks} chunks")
  394. stream_start_time = time.monotonic()
  395. bytes_sent = 0
  396. chunk_count = 0
  397. offset = 0
  398. while offset < len(audio_data):
  399. chunk = audio_data[offset:offset + chunk_size]
  400. try:
  401. music_socket.write(chunk)
  402. await music_socket.drain()
  403. bytes_sent += len(chunk)
  404. chunk_count += 1
  405. except Exception as e:
  406. perror(f"[SERVER] Fehler beim Senden: {e}")
  407. return False
  408. offset += chunk_size
  409. # Rate-Limiting nach Prebuffer
  410. if bytes_sent <= prebuffer_bytes:
  411. if chunk_count % 50 == 0:
  412. await asyncio.sleep(0)
  413. else:
  414. expected_time = (bytes_sent - prebuffer_bytes) / (bytes_per_second * 1.05)
  415. elapsed_time = time.monotonic() - stream_start_time
  416. if elapsed_time < expected_time:
  417. await asyncio.sleep(expected_time - elapsed_time)
  418. elapsed_total = time.monotonic() - stream_start_time
  419. pinfo(f"[SERVER] Streaming beendet: {description} in {elapsed_total:.1f}s")
  420. # StreamStop senden
  421. from trixy_core.network.cmd import StreamStop
  422. network = getattr(self._application.services, "network", None)
  423. if network and hasattr(network, "send_to_satellite"):
  424. await network.send_to_satellite(satellite_id, StreamStop(stream_type="music"))
  425. return True
  426. except Exception as e:
  427. perror(f"Fehler beim PCM-Streaming: {e}")
  428. return False
  429. async def _play_wave(self, satellite_id: str) -> bool:
  430. """
  431. Spielt Audio-Datei auf dem Music-Stream des Satellites.
  432. Unterstützt Wave und MP3-Dateien.
  433. Args:
  434. satellite_id: ID des Satellites
  435. Returns:
  436. True bei Erfolg
  437. """
  438. try:
  439. assets_dir = self._application.server_config.assets_directory
  440. profile = self._application.server_config.profile
  441. # TEST: MP3-Datei statt Wave für Musik-Stream-Test
  442. music_file = "Alex Clare - Too Close.mp3"
  443. music_path = self._find_music_file(music_file, assets_dir, profile)
  444. if not music_path:
  445. perror(f"Musik-Datei nicht gefunden: {music_file}")
  446. return False
  447. pdebug(f"Musik-Benachrichtigung: '{music_path}' an {satellite_id}")
  448. return await self._stream_music_to_satellite(satellite_id, music_path)
  449. except Exception as e:
  450. perror(f"Musik-Benachrichtigung fehlgeschlagen: {e}")
  451. return False
  452. def _find_asset_file(
  453. self,
  454. filename: str,
  455. assets_dir: str,
  456. profile: str
  457. ) -> Path | None:
  458. """
  459. Sucht eine Asset-Datei im Profil oder Default-Verzeichnis.
  460. Suchpfade (in dieser Reihenfolge):
  461. 1. assets/{profile}/audio/{filename}
  462. 2. assets/{profile}/{filename}
  463. 3. assets/default/audio/{filename}
  464. 4. assets/default/{filename}
  465. 5. assets/{filename}
  466. Args:
  467. filename: Dateiname
  468. assets_dir: Assets-Verzeichnis
  469. profile: Profilname
  470. Returns:
  471. Pfad zur Datei oder None
  472. """
  473. assets_path = Path(assets_dir)
  474. # Suchpfade in Prioritätsreihenfolge
  475. search_paths = [
  476. assets_path / profile / "audio" / filename,
  477. assets_path / profile / filename,
  478. assets_path / "default" / "audio" / filename,
  479. assets_path / "default" / filename,
  480. assets_path / filename,
  481. ]
  482. for path in search_paths:
  483. if path.exists():
  484. return path
  485. return None
  486. def _find_music_file(
  487. self,
  488. filename: str,
  489. assets_dir: str,
  490. profile: str
  491. ) -> Path | None:
  492. """
  493. Sucht eine Musik-Datei im Profil oder Default-Verzeichnis.
  494. Suchpfade (in dieser Reihenfolge):
  495. 1. assets/{profile}/music/{filename}
  496. 2. assets/default/music/{filename}
  497. Args:
  498. filename: Dateiname
  499. assets_dir: Assets-Verzeichnis
  500. profile: Profilname
  501. Returns:
  502. Pfad zur Datei oder None
  503. """
  504. assets_path = Path(assets_dir)
  505. # Suchpfade in Prioritätsreihenfolge
  506. search_paths = [
  507. assets_path / profile / "music" / filename,
  508. assets_path / "default" / "music" / filename,
  509. ]
  510. for path in search_paths:
  511. if path.exists():
  512. return path
  513. return None
  514. async def _stream_wave_to_satellite(
  515. self,
  516. satellite_id: str,
  517. wave_path: Path
  518. ) -> bool:
  519. """
  520. Streamt eine Wave-Datei zum Music-Socket des Satellites.
  521. Args:
  522. satellite_id: ID des Satellites
  523. wave_path: Pfad zur Wave-Datei
  524. Returns:
  525. True bei Erfolg
  526. """
  527. import wave
  528. import struct
  529. try:
  530. # Satellite holen
  531. satellite = self._application.satellites.get(satellite_id)
  532. if not satellite or not satellite.is_connected:
  533. perror(f"Satellite nicht verbunden: {satellite_id}")
  534. return False
  535. music_socket = satellite.sockets.music_out
  536. if not music_socket:
  537. perror(f"Music-Socket nicht verfügbar für: {satellite_id}")
  538. return False
  539. # Wave-Datei lesen und konvertieren
  540. with wave.open(str(wave_path), "rb") as wf:
  541. sample_rate = wf.getframerate()
  542. channels = wf.getnchannels()
  543. sample_width = wf.getsampwidth()
  544. n_frames = wf.getnframes()
  545. pdebug(f"Wave: {sample_rate}Hz, {channels}ch, {sample_width*8}bit, {n_frames} frames")
  546. # Audio-Daten lesen
  547. audio_data = wf.readframes(n_frames)
  548. # Konvertierung zu 44100Hz Stereo falls nötig (Standard für Musik-Stream)
  549. target_rate = 44100
  550. target_channels = 2
  551. if sample_rate != target_rate or channels != target_channels:
  552. audio_data = self._convert_audio(
  553. audio_data,
  554. sample_rate,
  555. channels,
  556. sample_width,
  557. target_rate,
  558. target_channels,
  559. )
  560. # In Chunks aufteilen und senden (passend zur Client Buffer-Größe)
  561. chunk_size = 40960 # Bytes pro Chunk
  562. offset = 0
  563. total_chunks = (len(audio_data) + chunk_size - 1) // chunk_size
  564. pdebug(f"[SERVER] Starte Wave-Streaming: {wave_path.name} ({len(audio_data)} bytes, {total_chunks} chunks) an {satellite.alias} via music_out")
  565. chunk_count = 0
  566. while offset < len(audio_data):
  567. chunk = audio_data[offset:offset + chunk_size]
  568. try:
  569. music_socket.write(chunk)
  570. await music_socket.drain()
  571. chunk_count += 1
  572. except Exception as e:
  573. perror(f"[SERVER] Fehler beim Senden von Audio-Chunk {chunk_count}/{total_chunks}: {e}")
  574. return False
  575. offset += chunk_size
  576. # Minimale Pause für Flow-Control
  577. if offset < len(audio_data):
  578. await asyncio.sleep(0.001)
  579. pinfo(f"[SERVER] Wave-Datei gestreamt: {wave_path.name} ({chunk_count} chunks, {len(audio_data)} bytes) an {satellite.alias}")
  580. # StreamStop senden damit Client weiß dass Stream beendet ist
  581. from trixy_core.network.cmd import StreamStop
  582. network = getattr(self._application.services, "network", None)
  583. if network and hasattr(network, "send_to_satellite"):
  584. await network.send_to_satellite(satellite_id, StreamStop(stream_type="music"))
  585. pdebug(f"[SERVER] StreamStop (music) gesendet an {satellite.alias}")
  586. return True
  587. except Exception as e:
  588. perror(f"Fehler beim Streaming der Wave-Datei: {e}")
  589. return False
  590. async def _stream_music_to_satellite(
  591. self,
  592. satellite_id: str,
  593. music_path: Path
  594. ) -> bool:
  595. """
  596. Streamt eine Musik-Datei (MP3/WAV) zum Music-Socket des Satellites.
  597. Verwendet den MP3Decoder für MP3-Dateien.
  598. Args:
  599. satellite_id: ID des Satellites
  600. music_path: Pfad zur Musik-Datei
  601. Returns:
  602. True bei Erfolg
  603. """
  604. try:
  605. # Satellite holen
  606. satellite = self._application.satellites.get(satellite_id)
  607. if not satellite or not satellite.is_connected:
  608. perror(f"Satellite nicht verbunden: {satellite_id}")
  609. return False
  610. music_socket = satellite.sockets.music_out
  611. if not music_socket:
  612. perror(f"Music-Socket nicht verfügbar für: {satellite_id}")
  613. return False
  614. # Datei-Extension prüfen
  615. suffix = music_path.suffix.lower()
  616. if suffix == ".mp3":
  617. # MP3 mit Decoder dekodieren
  618. from trixy_core.music.formats.mp3 import MP3Decoder
  619. decoder = MP3Decoder()
  620. if not decoder._check_dependencies():
  621. perror("MP3-Decoder nicht verfügbar (pydub oder ffmpeg erforderlich)")
  622. return False
  623. # Audio-Format aus Server-Config holen
  624. audio_config = self._application.server_config.audio
  625. target_sample_rate = audio_config.music_sample_rate
  626. target_channels = audio_config.music_channels
  627. target_bit_depth = getattr(audio_config, 'music_bit_depth', 16)
  628. pinfo(f"[SERVER] Dekodiere MP3: {music_path.name} -> {target_sample_rate}Hz, {target_channels}ch")
  629. decoded = await decoder.decode(
  630. music_path,
  631. target_sample_rate=target_sample_rate,
  632. target_channels=target_channels
  633. )
  634. audio_data = decoded.pcm_data
  635. pdebug(f"[SERVER] MP3 dekodiert: {len(audio_data)} bytes, {decoded.sample_rate}Hz, {decoded.channels}ch")
  636. elif suffix == ".wav":
  637. # Audio-Format aus Server-Config holen
  638. audio_config = self._application.server_config.audio
  639. target_sample_rate = audio_config.music_sample_rate
  640. target_channels = audio_config.music_channels
  641. target_bit_depth = getattr(audio_config, 'music_bit_depth', 16)
  642. target_sample_width = target_bit_depth // 8
  643. # Wave-Datei direkt lesen
  644. import wave
  645. with wave.open(str(music_path), "rb") as wf:
  646. src_sample_rate = wf.getframerate()
  647. src_channels = wf.getnchannels()
  648. src_sample_width = wf.getsampwidth()
  649. n_frames = wf.getnframes()
  650. audio_data = wf.readframes(n_frames)
  651. # Konvertierung zum Zielformat falls nötig
  652. if src_sample_rate != target_sample_rate or src_channels != target_channels:
  653. audio_data = self._convert_audio(
  654. audio_data, src_sample_rate, src_channels, src_sample_width,
  655. target_sample_rate, target_channels
  656. )
  657. else:
  658. perror(f"Nicht unterstütztes Audio-Format: {suffix}")
  659. return False
  660. # Audio-Format aus Config für Rate-Limited Streaming
  661. audio_config = self._application.server_config.audio
  662. sample_rate = audio_config.music_sample_rate
  663. channels = audio_config.music_channels
  664. sample_width = getattr(audio_config, 'music_bit_depth', 16) // 8
  665. bytes_per_second = sample_rate * channels * sample_width
  666. # Chunk-Größe: ~100ms Audio pro Chunk
  667. chunk_duration_ms = 100
  668. chunk_size = (bytes_per_second * chunk_duration_ms) // 1000
  669. # Auf Frame-Grenze ausrichten
  670. frame_size = channels * sample_width
  671. chunk_size = (chunk_size // frame_size) * frame_size
  672. # Prebuffer: Sende zuerst 5 Sekunden sofort (genug für stabilen Start)
  673. prebuffer_bytes = int(bytes_per_second * 5.0)
  674. total_chunks = (len(audio_data) + chunk_size - 1) // chunk_size
  675. duration_seconds = len(audio_data) / bytes_per_second
  676. pinfo(f"[SERVER] Starte Rate-Limited Musik-Streaming: {music_path.name}")
  677. pinfo(f"[SERVER] {len(audio_data)} bytes, {duration_seconds:.1f}s, {total_chunks} chunks à {chunk_duration_ms}ms")
  678. pinfo(f"[SERVER] Prebuffer: {prebuffer_bytes} bytes ({prebuffer_bytes * 1000 // bytes_per_second}ms)")
  679. pinfo(f"[SERVER] Format: {sample_rate}Hz, {channels}ch, {sample_width*8}-bit")
  680. import time
  681. stream_start_time = time.monotonic()
  682. bytes_sent = 0
  683. chunk_count = 0
  684. offset = 0
  685. while offset < len(audio_data):
  686. chunk = audio_data[offset:offset + chunk_size]
  687. try:
  688. music_socket.write(chunk)
  689. await music_socket.drain()
  690. bytes_sent += len(chunk)
  691. chunk_count += 1
  692. except Exception as e:
  693. perror(f"[SERVER] Fehler beim Senden von Musik-Chunk {chunk_count}/{total_chunks}: {e}")
  694. return False
  695. offset += chunk_size
  696. # Da wir als Background-Task laufen, brauchen wir minimale Pausen
  697. if bytes_sent <= prebuffer_bytes:
  698. # Prebuffer: So schnell wie möglich senden, nur yield für Event-Loop
  699. if chunk_count % 50 == 0: # Alle 50 Chunks (5 Sekunden) kurz yielden
  700. await asyncio.sleep(0) # Nur yield, keine echte Pause
  701. else:
  702. # Nach Prebuffer: Rate-Limiting, aber leicht schneller als Echtzeit
  703. # Sende mit 105% der Echtzeit-Rate, damit Buffer sich füllt
  704. expected_time = (bytes_sent - prebuffer_bytes) / (bytes_per_second * 1.05)
  705. elapsed_time = time.monotonic() - stream_start_time
  706. if elapsed_time < expected_time:
  707. wait_time = expected_time - elapsed_time
  708. await asyncio.sleep(wait_time)
  709. # Kein else - wenn wir zu langsam sind, nicht pausieren!
  710. elapsed_total = time.monotonic() - stream_start_time
  711. pinfo(f"[SERVER] Musik gestreamt: {music_path.name} ({chunk_count} chunks, {bytes_sent} bytes) in {elapsed_total:.1f}s an {satellite.alias}")
  712. # StreamStop senden damit Client weiß dass Stream beendet ist
  713. from trixy_core.network.cmd import StreamStop
  714. network = getattr(self._application.services, "network", None)
  715. if network and hasattr(network, "send_to_satellite"):
  716. await network.send_to_satellite(satellite_id, StreamStop(stream_type="music"))
  717. pdebug(f"[SERVER] StreamStop (music) gesendet an {satellite.alias}")
  718. return True
  719. except Exception as e:
  720. perror(f"Fehler beim Musik-Streaming: {e}")
  721. import traceback
  722. traceback.print_exc()
  723. return False
  724. def _convert_audio(
  725. self,
  726. audio_data: bytes,
  727. src_rate: int,
  728. src_channels: int,
  729. sample_width: int,
  730. dst_rate: int,
  731. dst_channels: int,
  732. ) -> bytes:
  733. """
  734. Konvertiert Audio-Daten (einfache Implementierung).
  735. Args:
  736. audio_data: Quell-Audio-Daten
  737. src_rate: Quell-Samplerate
  738. src_channels: Quell-Kanäle
  739. sample_width: Sample-Breite in Bytes
  740. dst_rate: Ziel-Samplerate
  741. dst_channels: Ziel-Kanäle
  742. Returns:
  743. Konvertierte Audio-Daten
  744. """
  745. import array
  746. # Samples extrahieren
  747. if sample_width == 2:
  748. samples = array.array("h", audio_data)
  749. elif sample_width == 1:
  750. # 8-bit zu 16-bit konvertieren
  751. samples = array.array("h", [(s - 128) * 256 for s in audio_data])
  752. else:
  753. # Unsupportet, zurückgeben wie es ist
  754. return audio_data
  755. # Mono zu Stereo
  756. if src_channels == 1 and dst_channels == 2:
  757. stereo_samples = []
  758. for sample in samples:
  759. stereo_samples.extend([sample, sample])
  760. samples = array.array("h", stereo_samples)
  761. # Stereo zu Mono (falls benötigt)
  762. elif src_channels == 2 and dst_channels == 1:
  763. mono_samples = []
  764. for i in range(0, len(samples), 2):
  765. mono_samples.append((samples[i] + samples[i + 1]) // 2)
  766. samples = array.array("h", mono_samples)
  767. # Resampling (einfache lineare Interpolation)
  768. if src_rate != dst_rate:
  769. ratio = dst_rate / src_rate
  770. new_length = int(len(samples) * ratio)
  771. resampled = []
  772. for i in range(new_length):
  773. src_idx = i / ratio
  774. idx = int(src_idx)
  775. frac = src_idx - idx
  776. if idx + 1 < len(samples):
  777. sample = int(samples[idx] * (1 - frac) + samples[idx + 1] * frac)
  778. else:
  779. sample = samples[idx] if idx < len(samples) else 0
  780. resampled.append(sample)
  781. samples = array.array("h", resampled)
  782. return samples.tobytes()
  783. def setup_connection_notification(application: "IApplication") -> ConnectionNotificationHandler:
  784. """
  785. Richtet den Connection Notification Handler ein.
  786. Args:
  787. application: Referenz zur Hauptanwendung
  788. Returns:
  789. Der registrierte Handler
  790. """
  791. handler = ConnectionNotificationHandler(application)
  792. handler.register()
  793. return handler