player.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. # -*- coding: utf-8 -*-
  2. """
  3. Streaming Audio Player für Trixy Satellites.
  4. Einfacher Thread-basierter Player mit sounddevice.
  5. """
  6. import threading
  7. import queue
  8. import time
  9. from dataclasses import dataclass
  10. from enum import Enum, auto
  11. from typing import Callable
  12. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  13. # Backend
  14. try:
  15. import sounddevice as sd
  16. _HAS_SOUNDDEVICE = True
  17. except ImportError:
  18. _HAS_SOUNDDEVICE = False
  19. pwarn("[AUDIO] sounddevice nicht verfügbar")
  20. class PlayerState(Enum):
  21. """Zustand des Audio-Players."""
  22. STOPPED = auto()
  23. BUFFERING = auto()
  24. PLAYING = auto()
  25. PAUSED = auto()
  26. @dataclass
  27. class PlayerConfig:
  28. """Konfiguration für den Audio-Player."""
  29. sample_rate: int = 44100
  30. channels: int = 2
  31. sample_width: int = 2 # 16-bit
  32. prebuffer_ms: int = 1000
  33. @property
  34. def bytes_per_second(self) -> int:
  35. return self.sample_rate * self.channels * self.sample_width
  36. @property
  37. def prebuffer_bytes(self) -> int:
  38. return (self.bytes_per_second * self.prebuffer_ms) // 1000
  39. @property
  40. def frame_size(self) -> int:
  41. return self.channels * self.sample_width
  42. class BlockingStreamPlayer:
  43. """Thread-basierter Streaming Player."""
  44. def __init__(self, config: PlayerConfig | None = None):
  45. self._config = config or PlayerConfig()
  46. self._state = PlayerState.STOPPED
  47. self._lock = threading.Lock()
  48. self._queue: queue.Queue[bytes | None] = queue.Queue()
  49. self._prebuffer: list[bytes] = []
  50. self._prebuffer_size = 0
  51. self._thread: threading.Thread | None = None
  52. self._stop_event = threading.Event()
  53. self._pause_event = threading.Event() # Für Pause-Funktionalität
  54. self._stream: "sd.RawOutputStream | None" = None # Referenz für abort() bei Stop
  55. self._bytes_played = 0
  56. self._on_finished: Callable[[], None] | None = None
  57. @property
  58. def state(self) -> PlayerState:
  59. with self._lock:
  60. return self._state
  61. @property
  62. def is_playing(self) -> bool:
  63. with self._lock:
  64. return self._state == PlayerState.PLAYING
  65. @property
  66. def buffer_ms(self) -> int:
  67. with self._lock:
  68. queue_bytes = self._queue.qsize() * 19200
  69. return ((self._prebuffer_size + queue_bytes) * 1000) // self._config.bytes_per_second
  70. @property
  71. def stats(self) -> dict:
  72. return {
  73. "state": self.state.name,
  74. "bytes_played": self._bytes_played,
  75. "queue_size": self._queue.qsize(),
  76. }
  77. def on_finished(self, callback: Callable[[], None]) -> None:
  78. self._on_finished = callback
  79. def add_chunk(self, audio_data: bytes) -> bool:
  80. if not audio_data:
  81. return False
  82. should_start = False
  83. with self._lock:
  84. if self._state == PlayerState.STOPPED:
  85. self._state = PlayerState.BUFFERING
  86. if self._state == PlayerState.BUFFERING:
  87. self._prebuffer.append(audio_data)
  88. self._prebuffer_size += len(audio_data)
  89. if self._prebuffer_size >= self._config.prebuffer_bytes:
  90. for chunk in self._prebuffer:
  91. self._queue.put(chunk)
  92. self._prebuffer.clear()
  93. self._prebuffer_size = 0
  94. should_start = True
  95. else:
  96. self._queue.put(audio_data)
  97. if should_start:
  98. self._start_playback_thread()
  99. return True
  100. def finish(self) -> None:
  101. should_start = False
  102. with self._lock:
  103. if self._state == PlayerState.BUFFERING and self._prebuffer_size > 0:
  104. for chunk in self._prebuffer:
  105. self._queue.put(chunk)
  106. self._prebuffer.clear()
  107. self._prebuffer_size = 0
  108. should_start = True
  109. if should_start:
  110. self._start_playback_thread()
  111. self._queue.put(None)
  112. def stop(self) -> None:
  113. self._stop_event.set()
  114. self._pause_event.clear() # Pause aufheben falls aktiv
  115. # Stream sofort abbrechen - befreit blockierende stream.write() Aufrufe
  116. stream = self._stream
  117. if stream is not None:
  118. try:
  119. stream.abort()
  120. except Exception:
  121. pass
  122. # Queue leeren und None einfügen um queue.get() aufzuwecken
  123. try:
  124. while True:
  125. self._queue.get_nowait()
  126. except queue.Empty:
  127. pass
  128. self._queue.put(None)
  129. if self._thread and self._thread.is_alive():
  130. self._thread.join(timeout=2.0)
  131. if self._thread.is_alive():
  132. pdebug("[PLAYER] Thread reagiert nicht nach abort()")
  133. self._thread.join(timeout=3.0)
  134. with self._lock:
  135. self._state = PlayerState.STOPPED
  136. self._prebuffer.clear()
  137. self._prebuffer_size = 0
  138. self._stop_event.clear()
  139. def pause(self) -> None:
  140. """Pausiert die Wiedergabe."""
  141. with self._lock:
  142. if self._state == PlayerState.PLAYING:
  143. self._state = PlayerState.PAUSED
  144. self._pause_event.set()
  145. pdebug("[PLAYER] Wiedergabe pausiert")
  146. def resume(self) -> None:
  147. """Setzt die Wiedergabe fort."""
  148. with self._lock:
  149. if self._state == PlayerState.PAUSED:
  150. self._state = PlayerState.PLAYING
  151. self._pause_event.clear()
  152. pdebug("[PLAYER] Wiedergabe fortgesetzt")
  153. def toggle_pause(self) -> bool:
  154. """Wechselt zwischen Play und Pause. Gibt True zurück wenn jetzt pausiert."""
  155. with self._lock:
  156. if self._state == PlayerState.PLAYING:
  157. self._state = PlayerState.PAUSED
  158. self._pause_event.set()
  159. pdebug("[PLAYER] Wiedergabe pausiert")
  160. return True
  161. elif self._state == PlayerState.PAUSED:
  162. self._state = PlayerState.PLAYING
  163. self._pause_event.clear()
  164. pdebug("[PLAYER] Wiedergabe fortgesetzt")
  165. return False
  166. return False
  167. @property
  168. def is_paused(self) -> bool:
  169. with self._lock:
  170. return self._state == PlayerState.PAUSED
  171. def _start_playback_thread(self) -> None:
  172. with self._lock:
  173. if self._state == PlayerState.PLAYING:
  174. return
  175. self._state = PlayerState.PLAYING
  176. self._stop_event.clear()
  177. self._thread = threading.Thread(target=self._playback_loop, daemon=True)
  178. self._thread.start()
  179. def _playback_loop(self) -> None:
  180. if not _HAS_SOUNDDEVICE:
  181. perror("[PLAYER] sounddevice nicht verfügbar")
  182. return
  183. stream = None
  184. try:
  185. stream = sd.RawOutputStream(
  186. samplerate=self._config.sample_rate,
  187. channels=self._config.channels,
  188. dtype='int16',
  189. latency='high',
  190. )
  191. self._stream = stream # Referenz für stop() -> abort()
  192. stream.start()
  193. frame_size = self._config.frame_size
  194. pending_data = b''
  195. while not self._stop_event.is_set():
  196. # Pause-Handling: Warten während pausiert
  197. while self._pause_event.is_set() and not self._stop_event.is_set():
  198. stream.stop() # Stream pausieren
  199. time.sleep(0.1)
  200. if not self._pause_event.is_set():
  201. stream.start() # Stream fortsetzen
  202. break
  203. if self._stop_event.is_set():
  204. break
  205. try:
  206. chunk = self._queue.get(timeout=0.1)
  207. if chunk is None:
  208. if pending_data:
  209. padding = frame_size - (len(pending_data) % frame_size)
  210. if padding < frame_size:
  211. pending_data += b'\x00' * padding
  212. stream.write(pending_data)
  213. self._bytes_played += len(pending_data)
  214. break
  215. data = pending_data + chunk
  216. aligned_len = (len(data) // frame_size) * frame_size
  217. if aligned_len > 0:
  218. stream.write(data[:aligned_len])
  219. self._bytes_played += aligned_len
  220. pending_data = data[aligned_len:]
  221. except queue.Empty:
  222. continue
  223. except sd.PortAudioError:
  224. # stream.abort() wurde aufgerufen - normaler Shutdown
  225. break
  226. except Exception as e:
  227. perror(f"[PLAYER] Fehler: {e}")
  228. break
  229. except Exception as e:
  230. perror(f"[PLAYER] Stream-Fehler: {e}")
  231. finally:
  232. self._stream = None
  233. if stream is not None:
  234. try:
  235. stream.close()
  236. except Exception:
  237. pass
  238. with self._lock:
  239. self._state = PlayerState.STOPPED
  240. if self._on_finished:
  241. try:
  242. self._on_finished()
  243. except Exception:
  244. pass
  245. # Aliase für Kompatibilität
  246. SimpleStreamingPlayer = BlockingStreamPlayer
  247. RobustStreamingPlayer = BlockingStreamPlayer
  248. class AudioOutputManager:
  249. """Manager für Audio-Ausgabe."""
  250. def __init__(
  251. self,
  252. tts_sample_rate: int = 22050,
  253. music_sample_rate: int = 44100,
  254. ):
  255. self._tts_sample_rate = tts_sample_rate
  256. self._music_sample_rate = music_sample_rate
  257. self._tts_player = BlockingStreamPlayer(PlayerConfig(
  258. sample_rate=tts_sample_rate,
  259. channels=1,
  260. sample_width=2,
  261. prebuffer_ms=500,
  262. ))
  263. self._music_player = BlockingStreamPlayer(PlayerConfig(
  264. sample_rate=music_sample_rate,
  265. channels=2,
  266. sample_width=2,
  267. prebuffer_ms=2000,
  268. ))
  269. self._initialized = False
  270. async def initialize(self) -> bool:
  271. self._initialized = True
  272. return True
  273. async def shutdown(self) -> None:
  274. pdebug("[AUDIO] AudioOutputManager wird heruntergefahren...")
  275. self._tts_player.stop()
  276. self._music_player.stop()
  277. self._initialized = False
  278. pdebug("[AUDIO] AudioOutputManager heruntergefahren")
  279. # TTS
  280. def play_tts_chunk(self, audio_data: bytes) -> bool:
  281. return self._tts_player.add_chunk(audio_data)
  282. def finish_tts(self) -> None:
  283. self._tts_player.finish()
  284. def stop_tts(self) -> None:
  285. self._tts_player.stop()
  286. @property
  287. def tts_playing(self) -> bool:
  288. return self._tts_player.is_playing
  289. # Musik
  290. def play_music_chunk(self, audio_data: bytes) -> bool:
  291. return self._music_player.add_chunk(audio_data)
  292. def finish_music(self) -> None:
  293. self._music_player.finish()
  294. def stop_music(self) -> None:
  295. self._music_player.stop()
  296. @property
  297. def music_playing(self) -> bool:
  298. return self._music_player.is_playing
  299. @property
  300. def music_paused(self) -> bool:
  301. return self._music_player.is_paused
  302. def pause_music(self) -> None:
  303. """Pausiert die Musik-Wiedergabe."""
  304. self._music_player.pause()
  305. def resume_music(self) -> None:
  306. """Setzt die Musik-Wiedergabe fort."""
  307. self._music_player.resume()
  308. def toggle_music_pause(self) -> bool:
  309. """Wechselt zwischen Play und Pause. Gibt True zurück wenn jetzt pausiert."""
  310. return self._music_player.toggle_pause()
  311. # Gemeinsam
  312. def stop_all(self) -> None:
  313. self._tts_player.stop()
  314. self._music_player.stop()
  315. @property
  316. def stats(self) -> dict:
  317. return {
  318. "tts": self._tts_player.stats,
  319. "music": self._music_player.stats,
  320. }
  321. @property
  322. def music_buffer_full(self) -> bool:
  323. return self._music_player.buffer_ms > 5000
  324. @property
  325. def music_buffer_low(self) -> bool:
  326. return self._music_player.buffer_ms < 500
  327. @property
  328. def music_buffer_ms(self) -> int:
  329. return self._music_player.buffer_ms
  330. @property
  331. def tts_buffer_full(self) -> bool:
  332. return self._tts_player.buffer_ms > 5000
  333. def can_accept_music_chunk(self) -> bool:
  334. return not self.music_buffer_full
  335. def can_accept_tts_chunk(self) -> bool:
  336. return not self.tts_buffer_full
  337. # Format-Update
  338. async def update_tts_format(
  339. self,
  340. sample_rate: int,
  341. channels: int = 1,
  342. sample_width: int = 2
  343. ) -> None:
  344. self._tts_player.stop()
  345. self._tts_sample_rate = sample_rate
  346. self._tts_player = BlockingStreamPlayer(PlayerConfig(
  347. sample_rate=sample_rate,
  348. channels=channels,
  349. sample_width=sample_width,
  350. prebuffer_ms=500,
  351. ))
  352. async def update_music_format(
  353. self,
  354. sample_rate: int,
  355. channels: int = 2,
  356. sample_width: int = 2
  357. ) -> None:
  358. self._music_player.stop()
  359. self._music_sample_rate = sample_rate
  360. self._music_player = BlockingStreamPlayer(PlayerConfig(
  361. sample_rate=sample_rate,
  362. channels=channels,
  363. sample_width=sample_width,
  364. prebuffer_ms=2000,
  365. ))
  366. @property
  367. def tts_format(self) -> dict:
  368. cfg = self._tts_player._config
  369. return {"sample_rate": cfg.sample_rate, "channels": cfg.channels, "sample_width": cfg.sample_width}
  370. @property
  371. def music_format(self) -> dict:
  372. cfg = self._music_player._config
  373. return {"sample_rate": cfg.sample_rate, "channels": cfg.channels, "sample_width": cfg.sample_width}