streamer.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. # -*- coding: utf-8 -*-
  2. """
  3. Audio-Streamer.
  4. Verwaltet Audio-Streaming an mehrere Satellites gleichzeitig.
  5. """
  6. import asyncio
  7. import logging
  8. from typing import Any, Callable, Awaitable
  9. from trixy_core.music.stream.config import StreamConfig
  10. from trixy_core.music.stream.session import StreamSession, StreamState
  11. from trixy_core.music.track import Track
  12. from trixy_core.music.formats.base import DecodedAudio
  13. class AudioStreamer:
  14. """
  15. Zentrale Streaming-Verwaltung.
  16. Streamt Audio an einen oder mehrere Satellites gleichzeitig,
  17. mit Synchronisation und Pufferung.
  18. """
  19. def __init__(
  20. self,
  21. config: StreamConfig | None = None,
  22. ) -> None:
  23. """
  24. Initialisiert den AudioStreamer.
  25. Args:
  26. config: Standard-Stream-Konfiguration
  27. """
  28. self.default_config = config or StreamConfig.for_music()
  29. self.logger = logging.getLogger(__name__)
  30. # Sessions pro Satellite
  31. self._sessions: dict[str, StreamSession] = {}
  32. # Aktueller Track
  33. self._current_track: Track | None = None
  34. # Callbacks für Satellite-Kommunikation
  35. self._send_callbacks: dict[str, Callable[[bytes], Awaitable[bool]]] = {}
  36. # Decoder-Task
  37. self._decoder_task: asyncio.Task | None = None
  38. self._stop_event = asyncio.Event()
  39. # ==========================================================================
  40. # Properties
  41. # ==========================================================================
  42. @property
  43. def sessions(self) -> list[StreamSession]:
  44. """Alle aktiven Sessions."""
  45. return list(self._sessions.values())
  46. @property
  47. def session_count(self) -> int:
  48. """Anzahl aktiver Sessions."""
  49. return len(self._sessions)
  50. @property
  51. def is_streaming(self) -> bool:
  52. """Prüft ob aktiv gestreamt wird."""
  53. return any(s.is_streaming for s in self._sessions.values())
  54. @property
  55. def current_track(self) -> Track | None:
  56. """Aktuell gestreamter Track."""
  57. return self._current_track
  58. # ==========================================================================
  59. # Session-Management
  60. # ==========================================================================
  61. def add_satellite(
  62. self,
  63. satellite_id: str,
  64. send_callback: Callable[[bytes], Awaitable[bool]],
  65. config: StreamConfig | None = None,
  66. ) -> StreamSession:
  67. """
  68. Fügt einen Satellite als Streaming-Ziel hinzu.
  69. Args:
  70. satellite_id: ID des Satellites
  71. send_callback: Async Callback zum Senden von Daten
  72. config: Optionale Konfiguration
  73. Returns:
  74. Erstellte Session
  75. """
  76. # Alte Session entfernen
  77. if satellite_id in self._sessions:
  78. asyncio.create_task(self.remove_satellite(satellite_id))
  79. self._send_callbacks[satellite_id] = send_callback
  80. session = StreamSession(
  81. satellite_id=satellite_id,
  82. config=config or self.default_config,
  83. send_callback=send_callback,
  84. )
  85. self._sessions[satellite_id] = session
  86. self.logger.debug(f"Satellite hinzugefügt: {satellite_id}")
  87. return session
  88. async def remove_satellite(self, satellite_id: str) -> bool:
  89. """
  90. Entfernt einen Satellite.
  91. Args:
  92. satellite_id: ID des Satellites
  93. Returns:
  94. True wenn entfernt
  95. """
  96. if satellite_id in self._sessions:
  97. session = self._sessions.pop(satellite_id)
  98. await session.stop()
  99. self._send_callbacks.pop(satellite_id, None)
  100. self.logger.debug(f"Satellite entfernt: {satellite_id}")
  101. return True
  102. return False
  103. def get_session(self, satellite_id: str) -> StreamSession | None:
  104. """
  105. Holt Session für Satellite.
  106. Args:
  107. satellite_id: ID des Satellites
  108. Returns:
  109. Session oder None
  110. """
  111. return self._sessions.get(satellite_id)
  112. # ==========================================================================
  113. # Streaming-Steuerung
  114. # ==========================================================================
  115. async def start(
  116. self,
  117. track: Track,
  118. satellite_ids: list[str] | None = None,
  119. ) -> dict[str, bool]:
  120. """
  121. Startet Streaming für Track an ausgewählte Satellites.
  122. Args:
  123. track: Zu streamender Track
  124. satellite_ids: Ziel-Satellites (None = alle)
  125. Returns:
  126. Dict Satellite-ID → Erfolg
  127. """
  128. self._current_track = track
  129. self._stop_event.clear()
  130. # Ziel-Sessions bestimmen
  131. if satellite_ids:
  132. sessions = [self._sessions[sid] for sid in satellite_ids if sid in self._sessions]
  133. else:
  134. sessions = list(self._sessions.values())
  135. if not sessions:
  136. self.logger.warning("Keine Satellites für Streaming verfügbar")
  137. return {}
  138. # Alle Sessions starten
  139. results = {}
  140. for session in sessions:
  141. try:
  142. success = await session.start(track)
  143. results[session.satellite_id] = success
  144. except Exception as e:
  145. self.logger.error(f"Session-Start fehlgeschlagen: {e}")
  146. results[session.satellite_id] = False
  147. active_count = sum(1 for v in results.values() if v)
  148. self.logger.info(f"Streaming gestartet: {active_count}/{len(sessions)} Sessions")
  149. return results
  150. async def stop(self) -> None:
  151. """Stoppt alle Streaming-Sessions."""
  152. self._stop_event.set()
  153. # Decoder-Task stoppen
  154. if self._decoder_task and not self._decoder_task.done():
  155. self._decoder_task.cancel()
  156. try:
  157. await self._decoder_task
  158. except asyncio.CancelledError:
  159. pass
  160. # Alle Sessions stoppen
  161. for session in self._sessions.values():
  162. await session.stop()
  163. self._current_track = None
  164. self.logger.info("Alle Streams gestoppt")
  165. async def pause(self, satellite_ids: list[str] | None = None) -> None:
  166. """
  167. Pausiert Streaming.
  168. Args:
  169. satellite_ids: Betroffene Satellites (None = alle)
  170. """
  171. sessions = self._get_sessions(satellite_ids)
  172. for session in sessions:
  173. await session.pause()
  174. async def resume(self, satellite_ids: list[str] | None = None) -> None:
  175. """
  176. Setzt Streaming fort.
  177. Args:
  178. satellite_ids: Betroffene Satellites (None = alle)
  179. """
  180. sessions = self._get_sessions(satellite_ids)
  181. for session in sessions:
  182. await session.resume()
  183. async def seek(
  184. self,
  185. position_ms: int,
  186. satellite_ids: list[str] | None = None,
  187. ) -> dict[str, bool]:
  188. """
  189. Springt zu Position.
  190. Args:
  191. position_ms: Ziel-Position
  192. satellite_ids: Betroffene Satellites (None = alle)
  193. Returns:
  194. Dict Satellite-ID → Erfolg
  195. """
  196. sessions = self._get_sessions(satellite_ids)
  197. results = {}
  198. for session in sessions:
  199. results[session.satellite_id] = await session.seek(position_ms)
  200. return results
  201. # ==========================================================================
  202. # Audio-Daten
  203. # ==========================================================================
  204. async def feed(
  205. self,
  206. data: bytes,
  207. satellite_ids: list[str] | None = None,
  208. ) -> dict[str, bool]:
  209. """
  210. Füttert alle Sessions mit Audio-Daten.
  211. Args:
  212. data: PCM-Audio-Daten
  213. satellite_ids: Ziel-Satellites (None = alle)
  214. Returns:
  215. Dict Satellite-ID → Akzeptiert
  216. """
  217. sessions = self._get_sessions(satellite_ids)
  218. results = {}
  219. for session in sessions:
  220. results[session.satellite_id] = await session.feed(data)
  221. return results
  222. async def feed_decoded(
  223. self,
  224. audio: DecodedAudio,
  225. satellite_ids: list[str] | None = None,
  226. ) -> dict[str, bool]:
  227. """
  228. Füttert Sessions mit dekodierten Audio-Daten.
  229. Args:
  230. audio: Dekodierte Audio-Daten
  231. satellite_ids: Ziel-Satellites (None = alle)
  232. Returns:
  233. Dict Satellite-ID → Akzeptiert
  234. """
  235. return await self.feed(audio.pcm_data, satellite_ids)
  236. # ==========================================================================
  237. # Synchronisation
  238. # ==========================================================================
  239. async def sync_all(self) -> dict[str, dict[str, Any]]:
  240. """
  241. Synchronisiert alle Sessions.
  242. Returns:
  243. Dict Satellite-ID → Sync-Info
  244. """
  245. results = {}
  246. for session in self._sessions.values():
  247. if session.is_active:
  248. # Hier würde normalerweise der Client-Time-Stempel
  249. # vom Satellite kommen. Für Demo: eigene Zeit verwenden.
  250. results[session.satellite_id] = await session.sync(session.position_ms)
  251. return results
  252. async def broadcast_sync(self, target_position_ms: int) -> None:
  253. """
  254. Synchronisiert alle Sessions auf Ziel-Position.
  255. Args:
  256. target_position_ms: Ziel-Position
  257. """
  258. for session in self._sessions.values():
  259. if session.is_active:
  260. drift = session.position_ms - target_position_ms
  261. if abs(drift) > session.config.max_drift_ms:
  262. await session.seek(target_position_ms)
  263. # ==========================================================================
  264. # Hilfsmethoden
  265. # ==========================================================================
  266. def _get_sessions(self, satellite_ids: list[str] | None) -> list[StreamSession]:
  267. """Holt Sessions für Satellite-IDs."""
  268. if satellite_ids:
  269. return [self._sessions[sid] for sid in satellite_ids if sid in self._sessions]
  270. return list(self._sessions.values())
  271. # ==========================================================================
  272. # Status
  273. # ==========================================================================
  274. def get_status(self) -> dict[str, Any]:
  275. """Liefert Gesamtstatus."""
  276. return {
  277. "session_count": self.session_count,
  278. "is_streaming": self.is_streaming,
  279. "current_track": self._current_track.to_dict() if self._current_track else None,
  280. "sessions": {
  281. sid: session.get_status()
  282. for sid, session in self._sessions.items()
  283. },
  284. }
  285. def get_session_status(self, satellite_id: str) -> dict[str, Any] | None:
  286. """Liefert Status einer Session."""
  287. session = self._sessions.get(satellite_id)
  288. return session.get_status() if session else None
  289. def __len__(self) -> int:
  290. return len(self._sessions)
  291. def __contains__(self, satellite_id: str) -> bool:
  292. return satellite_id in self._sessions
  293. def __repr__(self) -> str:
  294. return (
  295. f"AudioStreamer(sessions={self.session_count}, "
  296. f"streaming={self.is_streaming})"
  297. )