| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- # -*- coding: utf-8 -*-
- """
- Streaming Audio Player für Trixy Satellites.
- Einfacher Thread-basierter Player mit sounddevice.
- """
- import threading
- import queue
- import time
- from dataclasses import dataclass
- from enum import Enum, auto
- from typing import Callable
- from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
- # Backend
- try:
- import sounddevice as sd
- _HAS_SOUNDDEVICE = True
- except ImportError:
- _HAS_SOUNDDEVICE = False
- pwarn("[AUDIO] sounddevice nicht verfügbar")
- class PlayerState(Enum):
- """Zustand des Audio-Players."""
- STOPPED = auto()
- BUFFERING = auto()
- PLAYING = auto()
- PAUSED = auto()
- @dataclass
- class PlayerConfig:
- """Konfiguration für den Audio-Player."""
- sample_rate: int = 44100
- channels: int = 2
- sample_width: int = 2 # 16-bit
- prebuffer_ms: int = 1000
- @property
- def bytes_per_second(self) -> int:
- return self.sample_rate * self.channels * self.sample_width
- @property
- def prebuffer_bytes(self) -> int:
- return (self.bytes_per_second * self.prebuffer_ms) // 1000
- @property
- def frame_size(self) -> int:
- return self.channels * self.sample_width
- class BlockingStreamPlayer:
- """Thread-basierter Streaming Player."""
- def __init__(self, config: PlayerConfig | None = None):
- self._config = config or PlayerConfig()
- self._state = PlayerState.STOPPED
- self._lock = threading.Lock()
- self._queue: queue.Queue[bytes | None] = queue.Queue()
- self._prebuffer: list[bytes] = []
- self._prebuffer_size = 0
- self._thread: threading.Thread | None = None
- self._stop_event = threading.Event()
- self._pause_event = threading.Event() # Für Pause-Funktionalität
- self._stream: "sd.RawOutputStream | None" = None # Referenz für abort() bei Stop
- self._bytes_played = 0
- self._on_finished: Callable[[], None] | None = None
- @property
- def state(self) -> PlayerState:
- with self._lock:
- return self._state
- @property
- def is_playing(self) -> bool:
- with self._lock:
- return self._state == PlayerState.PLAYING
- @property
- def buffer_ms(self) -> int:
- with self._lock:
- queue_bytes = self._queue.qsize() * 19200
- return ((self._prebuffer_size + queue_bytes) * 1000) // self._config.bytes_per_second
- @property
- def stats(self) -> dict:
- return {
- "state": self.state.name,
- "bytes_played": self._bytes_played,
- "queue_size": self._queue.qsize(),
- }
- def on_finished(self, callback: Callable[[], None]) -> None:
- self._on_finished = callback
- def add_chunk(self, audio_data: bytes) -> bool:
- if not audio_data:
- return False
- should_start = False
- with self._lock:
- if self._state == PlayerState.STOPPED:
- self._state = PlayerState.BUFFERING
- if self._state == PlayerState.BUFFERING:
- self._prebuffer.append(audio_data)
- self._prebuffer_size += len(audio_data)
- if self._prebuffer_size >= self._config.prebuffer_bytes:
- for chunk in self._prebuffer:
- self._queue.put(chunk)
- self._prebuffer.clear()
- self._prebuffer_size = 0
- should_start = True
- else:
- self._queue.put(audio_data)
- if should_start:
- self._start_playback_thread()
- return True
- def finish(self) -> None:
- should_start = False
- with self._lock:
- if self._state == PlayerState.BUFFERING and self._prebuffer_size > 0:
- for chunk in self._prebuffer:
- self._queue.put(chunk)
- self._prebuffer.clear()
- self._prebuffer_size = 0
- should_start = True
- if should_start:
- self._start_playback_thread()
- self._queue.put(None)
- def stop(self) -> None:
- self._stop_event.set()
- self._pause_event.clear() # Pause aufheben falls aktiv
- # Stream sofort abbrechen - befreit blockierende stream.write() Aufrufe
- stream = self._stream
- if stream is not None:
- try:
- stream.abort()
- except Exception:
- pass
- # Queue leeren und None einfügen um queue.get() aufzuwecken
- try:
- while True:
- self._queue.get_nowait()
- except queue.Empty:
- pass
- self._queue.put(None)
- if self._thread and self._thread.is_alive():
- self._thread.join(timeout=2.0)
- if self._thread.is_alive():
- pdebug("[PLAYER] Thread reagiert nicht nach abort()")
- self._thread.join(timeout=3.0)
- with self._lock:
- self._state = PlayerState.STOPPED
- self._prebuffer.clear()
- self._prebuffer_size = 0
- self._stop_event.clear()
- def pause(self) -> None:
- """Pausiert die Wiedergabe."""
- with self._lock:
- if self._state == PlayerState.PLAYING:
- self._state = PlayerState.PAUSED
- self._pause_event.set()
- pdebug("[PLAYER] Wiedergabe pausiert")
- def resume(self) -> None:
- """Setzt die Wiedergabe fort."""
- with self._lock:
- if self._state == PlayerState.PAUSED:
- self._state = PlayerState.PLAYING
- self._pause_event.clear()
- pdebug("[PLAYER] Wiedergabe fortgesetzt")
- def toggle_pause(self) -> bool:
- """Wechselt zwischen Play und Pause. Gibt True zurück wenn jetzt pausiert."""
- with self._lock:
- if self._state == PlayerState.PLAYING:
- self._state = PlayerState.PAUSED
- self._pause_event.set()
- pdebug("[PLAYER] Wiedergabe pausiert")
- return True
- elif self._state == PlayerState.PAUSED:
- self._state = PlayerState.PLAYING
- self._pause_event.clear()
- pdebug("[PLAYER] Wiedergabe fortgesetzt")
- return False
- return False
- @property
- def is_paused(self) -> bool:
- with self._lock:
- return self._state == PlayerState.PAUSED
- def _start_playback_thread(self) -> None:
- with self._lock:
- if self._state == PlayerState.PLAYING:
- return
- self._state = PlayerState.PLAYING
- self._stop_event.clear()
- self._thread = threading.Thread(target=self._playback_loop, daemon=True)
- self._thread.start()
- def _playback_loop(self) -> None:
- if not _HAS_SOUNDDEVICE:
- perror("[PLAYER] sounddevice nicht verfügbar")
- return
- stream = None
- try:
- stream = sd.RawOutputStream(
- samplerate=self._config.sample_rate,
- channels=self._config.channels,
- dtype='int16',
- latency='high',
- )
- self._stream = stream # Referenz für stop() -> abort()
- stream.start()
- frame_size = self._config.frame_size
- pending_data = b''
- while not self._stop_event.is_set():
- # Pause-Handling: Warten während pausiert
- while self._pause_event.is_set() and not self._stop_event.is_set():
- stream.stop() # Stream pausieren
- time.sleep(0.1)
- if not self._pause_event.is_set():
- stream.start() # Stream fortsetzen
- break
- if self._stop_event.is_set():
- break
- try:
- chunk = self._queue.get(timeout=0.1)
- if chunk is None:
- if pending_data:
- padding = frame_size - (len(pending_data) % frame_size)
- if padding < frame_size:
- pending_data += b'\x00' * padding
- stream.write(pending_data)
- self._bytes_played += len(pending_data)
- break
- data = pending_data + chunk
- aligned_len = (len(data) // frame_size) * frame_size
- if aligned_len > 0:
- stream.write(data[:aligned_len])
- self._bytes_played += aligned_len
- pending_data = data[aligned_len:]
- except queue.Empty:
- continue
- except sd.PortAudioError:
- # stream.abort() wurde aufgerufen - normaler Shutdown
- break
- except Exception as e:
- perror(f"[PLAYER] Fehler: {e}")
- break
- except Exception as e:
- perror(f"[PLAYER] Stream-Fehler: {e}")
- finally:
- self._stream = None
- if stream is not None:
- try:
- stream.close()
- except Exception:
- pass
- with self._lock:
- self._state = PlayerState.STOPPED
- if self._on_finished:
- try:
- self._on_finished()
- except Exception:
- pass
- # Aliase für Kompatibilität
- SimpleStreamingPlayer = BlockingStreamPlayer
- RobustStreamingPlayer = BlockingStreamPlayer
- class AudioOutputManager:
- """Manager für Audio-Ausgabe."""
- def __init__(
- self,
- tts_sample_rate: int = 22050,
- music_sample_rate: int = 44100,
- ):
- self._tts_sample_rate = tts_sample_rate
- self._music_sample_rate = music_sample_rate
- self._tts_player = BlockingStreamPlayer(PlayerConfig(
- sample_rate=tts_sample_rate,
- channels=1,
- sample_width=2,
- prebuffer_ms=500,
- ))
- self._music_player = BlockingStreamPlayer(PlayerConfig(
- sample_rate=music_sample_rate,
- channels=2,
- sample_width=2,
- prebuffer_ms=2000,
- ))
- self._initialized = False
- async def initialize(self) -> bool:
- self._initialized = True
- return True
- async def shutdown(self) -> None:
- pdebug("[AUDIO] AudioOutputManager wird heruntergefahren...")
- self._tts_player.stop()
- self._music_player.stop()
- self._initialized = False
- pdebug("[AUDIO] AudioOutputManager heruntergefahren")
- # TTS
- def play_tts_chunk(self, audio_data: bytes) -> bool:
- return self._tts_player.add_chunk(audio_data)
- def finish_tts(self) -> None:
- self._tts_player.finish()
- def stop_tts(self) -> None:
- self._tts_player.stop()
- @property
- def tts_playing(self) -> bool:
- return self._tts_player.is_playing
- # Musik
- def play_music_chunk(self, audio_data: bytes) -> bool:
- return self._music_player.add_chunk(audio_data)
- def finish_music(self) -> None:
- self._music_player.finish()
- def stop_music(self) -> None:
- self._music_player.stop()
- @property
- def music_playing(self) -> bool:
- return self._music_player.is_playing
- @property
- def music_paused(self) -> bool:
- return self._music_player.is_paused
- def pause_music(self) -> None:
- """Pausiert die Musik-Wiedergabe."""
- self._music_player.pause()
- def resume_music(self) -> None:
- """Setzt die Musik-Wiedergabe fort."""
- self._music_player.resume()
- def toggle_music_pause(self) -> bool:
- """Wechselt zwischen Play und Pause. Gibt True zurück wenn jetzt pausiert."""
- return self._music_player.toggle_pause()
- # Gemeinsam
- def stop_all(self) -> None:
- self._tts_player.stop()
- self._music_player.stop()
- @property
- def stats(self) -> dict:
- return {
- "tts": self._tts_player.stats,
- "music": self._music_player.stats,
- }
- @property
- def music_buffer_full(self) -> bool:
- return self._music_player.buffer_ms > 5000
- @property
- def music_buffer_low(self) -> bool:
- return self._music_player.buffer_ms < 500
- @property
- def music_buffer_ms(self) -> int:
- return self._music_player.buffer_ms
- @property
- def tts_buffer_full(self) -> bool:
- return self._tts_player.buffer_ms > 5000
- def can_accept_music_chunk(self) -> bool:
- return not self.music_buffer_full
- def can_accept_tts_chunk(self) -> bool:
- return not self.tts_buffer_full
- # Format-Update
- async def update_tts_format(
- self,
- sample_rate: int,
- channels: int = 1,
- sample_width: int = 2
- ) -> None:
- self._tts_player.stop()
- self._tts_sample_rate = sample_rate
- self._tts_player = BlockingStreamPlayer(PlayerConfig(
- sample_rate=sample_rate,
- channels=channels,
- sample_width=sample_width,
- prebuffer_ms=500,
- ))
- async def update_music_format(
- self,
- sample_rate: int,
- channels: int = 2,
- sample_width: int = 2
- ) -> None:
- self._music_player.stop()
- self._music_sample_rate = sample_rate
- self._music_player = BlockingStreamPlayer(PlayerConfig(
- sample_rate=sample_rate,
- channels=channels,
- sample_width=sample_width,
- prebuffer_ms=2000,
- ))
- @property
- def tts_format(self) -> dict:
- cfg = self._tts_player._config
- return {"sample_rate": cfg.sample_rate, "channels": cfg.channels, "sample_width": cfg.sample_width}
- @property
- def music_format(self) -> dict:
- cfg = self._music_player._config
- return {"sample_rate": cfg.sample_rate, "channels": cfg.channels, "sample_width": cfg.sample_width}
|