| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951 |
- # -*- coding: utf-8 -*-
- """
- Music-Player.
- Zentraler Musik-Player mit Source-, Decoder- und Streaming-Integration.
- """
- import asyncio
- import logging
- import time
- import uuid
- from collections import deque
- from dataclasses import dataclass, field
- from datetime import datetime
- from enum import Enum
- from typing import Any, Callable, Awaitable
- from trixy_core.music.track import Track, TrackState
- from trixy_core.music.playlist import Playlist, PlaylistManager, RepeatMode, ShuffleMode
- from trixy_core.music.queue import PlayQueue, QueueState
- from trixy_core.music.sources import SourceManager, MusicSource
- from trixy_core.music.formats import DecoderRegistry, get_default_registry
- from trixy_core.music.formats.base import DecodedAudio
- from trixy_core.music.stream import AudioStreamer, StreamConfig, StreamState
- from trixy_core.audio.processing import AudioProcessingPipeline, AudioProcessingContext, AudioType
- from trixy_core.utils.debug import pdebug
- class PlayerState(Enum):
- """Zustand des Players."""
- STOPPED = "stopped"
- LOADING = "loading"
- PLAYING = "playing"
- PAUSED = "paused"
- BUFFERING = "buffering"
- ERROR = "error"
- class PlaybackEvent(Enum):
- """Wiedergabe-Events."""
- TRACK_STARTED = "track_started"
- TRACK_ENDED = "track_ended"
- TRACK_CHANGED = "track_changed"
- POSITION_CHANGED = "position_changed"
- STATE_CHANGED = "state_changed"
- VOLUME_CHANGED = "volume_changed"
- QUEUE_CHANGED = "queue_changed"
- ERROR = "error"
- @dataclass
- class PlayerConfig:
- """Player-Konfiguration."""
- # Audio (44.1kHz - Standard für die meisten Geräte)
- sample_rate: int = 44100
- channels: int = 2
- bit_depth: int = 16
- # Pufferung
- buffer_ms: int = 500
- prebuffer_ms: int = 200
- # Verhalten
- auto_play_next: bool = True
- crossfade_ms: int = 0 # 0 = kein Crossfade
- gapless: bool = True
- # Lautstärke
- default_volume: float = 1.0 # 0.0 - 1.0
- # Limits
- max_queue_size: int = 1000
- # Zusätzlich
- extra: dict[str, Any] = field(default_factory=dict)
- class MusicPlayer:
- """
- Zentraler Musik-Player.
- Integriert:
- - Musikquellen (lokal, USB, URL, etc.)
- - Audio-Decoder (MP3, WAV, OGG, FLAC)
- - Playlists und Queue
- - Streaming an Satellites
- """
- def __init__(
- self,
- config: PlayerConfig | None = None,
- source_manager: SourceManager | None = None,
- decoder_registry: DecoderRegistry | None = None,
- playlist_manager: PlaylistManager | None = None,
- ) -> None:
- """
- Initialisiert den MusicPlayer.
- Args:
- config: Player-Konfiguration
- source_manager: Optionaler Source-Manager
- decoder_registry: Optionale Decoder-Registry
- playlist_manager: Optionaler Playlist-Manager
- """
- self.id = f"player_{uuid.uuid4().hex[:8]}"
- self.config = config or PlayerConfig()
- self.logger = logging.getLogger(__name__)
- # Komponenten
- self.sources = source_manager or SourceManager()
- self.decoders = decoder_registry or get_default_registry()
- self.playlists = playlist_manager or PlaylistManager()
- # Queue und aktuelle Playlist
- self.queue = PlayQueue()
- self._current_playlist: Playlist | None = None
- # Streaming
- self._streamer = AudioStreamer(
- config=StreamConfig(
- sample_rate=self.config.sample_rate,
- channels=self.config.channels,
- bit_depth=self.config.bit_depth,
- prebuffer_ms=self.config.prebuffer_ms,
- )
- )
- # Audio-Processing-Pipeline (für Ducking, Crossfade, etc.)
- self._processing_pipeline = AudioProcessingPipeline()
- # Zustand
- self._state = PlayerState.STOPPED
- self._volume = self.config.default_volume
- self._muted = False
- self._position_ms = 0
- self._duration_ms = 0
- # Crossfade
- self._crossfade_buffer: deque = deque(maxlen=50) # Rolling buffer für Crossfade-Erkennung
- self._crossfade_in_progress = False # Flag ob gerade ein Crossfade läuft
- self._crossfade_to_next = False # Flag für manuelles Crossfade (Ctrl+N)
- # Playback-Task
- self._playback_task: asyncio.Task | None = None
- self._stop_event = asyncio.Event()
- # Event-Callbacks
- self._event_callbacks: list[Callable[[PlaybackEvent, dict], Awaitable[None]]] = []
- # ==========================================================================
- # Properties
- # ==========================================================================
- @property
- def state(self) -> PlayerState:
- """Aktueller Zustand."""
- return self._state
- @property
- def is_playing(self) -> bool:
- """Prüft ob abgespielt wird."""
- return self._state == PlayerState.PLAYING
- @property
- def is_paused(self) -> bool:
- """Prüft ob pausiert."""
- return self._state == PlayerState.PAUSED
- @property
- def current_track(self) -> Track | None:
- """Aktueller Track."""
- return self.queue.current_track
- @property
- def position_ms(self) -> int:
- """Aktuelle Position in Millisekunden."""
- return self._position_ms
- @property
- def duration_ms(self) -> int:
- """Dauer des aktuellen Tracks in Millisekunden."""
- return self._duration_ms
- @property
- def progress(self) -> float:
- """Fortschritt (0.0 - 1.0)."""
- if self._duration_ms > 0:
- return min(1.0, self._position_ms / self._duration_ms)
- return 0.0
- @property
- def volume(self) -> float:
- """Aktuelle Lautstärke (0.0 - 1.0)."""
- return self._volume
- @property
- def is_muted(self) -> bool:
- """Prüft ob stummgeschaltet."""
- return self._muted
- @property
- def current_playlist(self) -> Playlist | None:
- """Aktuelle Playlist."""
- return self._current_playlist
- @property
- def repeat_mode(self) -> RepeatMode:
- """Wiederholungsmodus."""
- if self._current_playlist:
- return self._current_playlist.repeat_mode
- return RepeatMode.OFF
- @property
- def shuffle_mode(self) -> ShuffleMode:
- """Zufallsmodus."""
- if self._current_playlist:
- return self._current_playlist.shuffle_mode
- return ShuffleMode.OFF
- @property
- def processing_pipeline(self) -> AudioProcessingPipeline:
- """Audio-Processing-Pipeline für Plugins (Ducking, Crossfade, etc.)."""
- return self._processing_pipeline
- # ==========================================================================
- # Playback-Steuerung
- # ==========================================================================
- async def play(
- self,
- track: Track | None = None,
- playlist: Playlist | None = None,
- satellite_ids: list[str] | None = None,
- ) -> bool:
- """
- Startet Wiedergabe.
- Args:
- track: Optionaler Track zum Abspielen
- playlist: Optionale Playlist
- satellite_ids: Ziel-Satellites (None = alle)
- Returns:
- True wenn erfolgreich
- """
- # Bestehende Wiedergabe stoppen
- if self._playback_task and not self._playback_task.done():
- await self.stop()
- # Track bestimmen
- if track:
- self.queue.add(track, play_next=True)
- self.queue.next()
- elif playlist:
- self._current_playlist = playlist
- first_track = playlist.start()
- if first_track:
- self.queue.add(first_track, play_next=True)
- self.queue.next()
- elif self.queue.current_item:
- pass # Aktuellen Track weiterspielen
- elif self.queue.has_next:
- self.queue.next()
- else:
- self.logger.warning("Kein Track zum Abspielen")
- return False
- current = self.queue.current_track
- if not current:
- return False
- self._stop_event.clear()
- self._state = PlayerState.LOADING
- await self._emit_event(PlaybackEvent.STATE_CHANGED, {"state": self._state.value})
- try:
- # Playback-Task starten
- self._playback_task = asyncio.create_task(
- self._playback_loop(satellite_ids)
- )
- self.logger.info(f"Wiedergabe gestartet: {current.title}")
- return True
- except Exception as e:
- self.logger.error(f"Wiedergabe fehlgeschlagen: {e}")
- self._state = PlayerState.ERROR
- await self._emit_event(PlaybackEvent.ERROR, {"error": str(e)})
- return False
- async def pause(self) -> None:
- """Pausiert Wiedergabe."""
- if self._state == PlayerState.PLAYING:
- self._state = PlayerState.PAUSED
- await self._streamer.pause()
- await self._emit_event(PlaybackEvent.STATE_CHANGED, {"state": self._state.value})
- self.logger.debug("Wiedergabe pausiert")
- async def resume(self) -> None:
- """Setzt Wiedergabe fort."""
- if self._state == PlayerState.PAUSED:
- self._state = PlayerState.PLAYING
- await self._streamer.resume()
- await self._emit_event(PlaybackEvent.STATE_CHANGED, {"state": self._state.value})
- self.logger.debug("Wiedergabe fortgesetzt")
- async def toggle_pause(self) -> None:
- """Wechselt zwischen Play und Pause."""
- if self._state == PlayerState.PLAYING:
- await self.pause()
- elif self._state == PlayerState.PAUSED:
- await self.resume()
- async def stop(self) -> None:
- """Stoppt Wiedergabe."""
- self._stop_event.set()
- if self._playback_task and not self._playback_task.done():
- self._playback_task.cancel()
- try:
- await self._playback_task
- except asyncio.CancelledError:
- pass
- await self._streamer.stop()
- self._state = PlayerState.STOPPED
- self._position_ms = 0
- await self._emit_event(PlaybackEvent.STATE_CHANGED, {"state": self._state.value})
- self.logger.info("Wiedergabe gestoppt")
- async def next(self, use_crossfade: bool = True) -> Track | None:
- """
- Springt zum nächsten Track.
- Args:
- use_crossfade: Crossfade verwenden wenn möglich
- Returns:
- Nächster Track oder None
- """
- # Prüfen ob Crossfade möglich ist
- crossfade_possible = (
- use_crossfade and
- self.config.crossfade_ms > 0 and
- len(self._crossfade_buffer) > 0 and
- self._state == PlayerState.PLAYING and
- not self._crossfade_in_progress
- )
- if crossfade_possible:
- # Crossfade-Flag setzen - die _playback_loop wird das Crossfade ausführen
- pdebug(f"MusicPlayer: Manuelles Crossfade angefordert (buffer={len(self._crossfade_buffer)})")
- self._crossfade_to_next = True
- # Warte kurz damit die _playback_loop das Flag sehen kann
- await asyncio.sleep(0.05)
- return self.queue.peek_next().track if self.queue.peek_next() else None
- # Normaler Wechsel ohne Crossfade
- # Aus Playlist
- if self._current_playlist and self._current_playlist.has_next:
- track = self._current_playlist.next()
- if track:
- self.queue.add(track, play_next=True)
- # Aus Queue
- item = self.queue.next()
- if item:
- await self._emit_event(PlaybackEvent.TRACK_CHANGED, {"track": item.track.to_dict()})
- if self._state != PlayerState.STOPPED:
- await self.play()
- return item.track
- # Nichts mehr
- await self.stop()
- return None
- async def previous(self) -> Track | None:
- """
- Springt zum vorherigen Track.
- Returns:
- Vorheriger Track oder None
- """
- # Wenn Position > 3s, zum Anfang springen
- if self._position_ms > 3000:
- await self.seek(0)
- return self.current_track
- # Aus Playlist
- if self._current_playlist and self._current_playlist.has_previous:
- track = self._current_playlist.previous()
- if track:
- self.queue.add(track, play_next=True)
- self.queue.next()
- await self._emit_event(PlaybackEvent.TRACK_CHANGED, {"track": track.to_dict()})
- if self._state != PlayerState.STOPPED:
- await self.play()
- return track
- # Aus Queue-History
- item = self.queue.previous()
- if item:
- await self._emit_event(PlaybackEvent.TRACK_CHANGED, {"track": item.track.to_dict()})
- if self._state != PlayerState.STOPPED:
- await self.play()
- return item.track
- return None
- async def seek(self, position_ms: int) -> bool:
- """
- Springt zu Position.
- Args:
- position_ms: Ziel-Position in Millisekunden
- Returns:
- True wenn erfolgreich
- """
- if position_ms < 0:
- position_ms = 0
- if position_ms > self._duration_ms:
- position_ms = self._duration_ms
- self._position_ms = position_ms
- # Streamer informieren
- await self._streamer.seek(position_ms)
- await self._emit_event(PlaybackEvent.POSITION_CHANGED, {"position_ms": position_ms})
- return True
- # ==========================================================================
- # Lautstärke
- # ==========================================================================
- async def set_volume(self, volume: float) -> None:
- """
- Setzt Lautstärke.
- Args:
- volume: Lautstärke (0.0 - 1.0)
- """
- self._volume = max(0.0, min(1.0, volume))
- await self._emit_event(PlaybackEvent.VOLUME_CHANGED, {"volume": self._volume})
- async def mute(self) -> None:
- """Schaltet stumm."""
- self._muted = True
- await self._emit_event(PlaybackEvent.VOLUME_CHANGED, {"volume": 0, "muted": True})
- async def unmute(self) -> None:
- """Hebt Stummschaltung auf."""
- self._muted = False
- await self._emit_event(PlaybackEvent.VOLUME_CHANGED, {"volume": self._volume, "muted": False})
- async def toggle_mute(self) -> None:
- """Wechselt Stummschaltung."""
- if self._muted:
- await self.unmute()
- else:
- await self.mute()
- # ==========================================================================
- # Repeat/Shuffle
- # ==========================================================================
- async def set_repeat(self, mode: RepeatMode) -> None:
- """Setzt Wiederholungsmodus."""
- if self._current_playlist:
- self._current_playlist.repeat_mode = mode
- async def set_shuffle(self, mode: ShuffleMode) -> None:
- """Setzt Zufallsmodus."""
- if self._current_playlist:
- self._current_playlist.set_shuffle(mode)
- async def toggle_repeat(self) -> RepeatMode:
- """Wechselt Wiederholungsmodus."""
- if self._current_playlist:
- current = self._current_playlist.repeat_mode
- modes = list(RepeatMode)
- next_idx = (modes.index(current) + 1) % len(modes)
- new_mode = modes[next_idx]
- self._current_playlist.repeat_mode = new_mode
- return new_mode
- return RepeatMode.OFF
- async def toggle_shuffle(self) -> ShuffleMode:
- """Wechselt Zufallsmodus."""
- if self._current_playlist:
- if self._current_playlist.shuffle_mode == ShuffleMode.OFF:
- self._current_playlist.set_shuffle(ShuffleMode.ON)
- return ShuffleMode.ON
- else:
- self._current_playlist.set_shuffle(ShuffleMode.OFF)
- return ShuffleMode.OFF
- return ShuffleMode.OFF
- # ==========================================================================
- # Queue-Management
- # ==========================================================================
- async def add_to_queue(
- self,
- track: Track,
- play_next: bool = False,
- ) -> None:
- """
- Fügt Track zur Queue hinzu.
- Args:
- track: Track
- play_next: Als nächstes abspielen
- """
- self.queue.add(track, play_next=play_next)
- await self._emit_event(PlaybackEvent.QUEUE_CHANGED, {"action": "add"})
- async def remove_from_queue(self, index: int) -> None:
- """
- Entfernt Track aus Queue.
- Args:
- index: Position
- """
- self.queue.remove(index)
- await self._emit_event(PlaybackEvent.QUEUE_CHANGED, {"action": "remove"})
- async def clear_queue(self) -> None:
- """Leert die Queue."""
- self.queue.clear()
- await self._emit_event(PlaybackEvent.QUEUE_CHANGED, {"action": "clear"})
- # ==========================================================================
- # Satellite-Management
- # ==========================================================================
- def add_satellite(
- self,
- satellite_id: str,
- send_callback: Callable[[bytes], Awaitable[bool]],
- ) -> None:
- """
- Fügt Satellite als Streaming-Ziel hinzu.
- Args:
- satellite_id: ID des Satellites
- send_callback: Callback zum Senden
- """
- self._streamer.add_satellite(satellite_id, send_callback)
- async def remove_satellite(self, satellite_id: str) -> None:
- """
- Entfernt Satellite.
- Args:
- satellite_id: ID des Satellites
- """
- await self._streamer.remove_satellite(satellite_id)
- # ==========================================================================
- # Playback-Loop
- # ==========================================================================
- async def _playback_loop(self, satellite_ids: list[str] | None) -> None:
- """Haupt-Playback-Loop."""
- track = self.queue.current_track
- if not track:
- return
- self._duration_ms = track.duration_ms
- self._position_ms = 0
- # Nächsten Track für Pipeline-Kontext ermitteln
- next_track = None
- if self.queue.has_next:
- next_item = self.queue.peek_next()
- next_track = next_item.track if next_item else None
- pdebug(f"MusicPlayer: next_track aus Queue: {next_track.title if next_track else 'None'}")
- elif self._current_playlist and self._current_playlist.has_next:
- next_track = self._current_playlist.peek_next()
- pdebug(f"MusicPlayer: next_track aus Playlist: {next_track.title if next_track else 'None'}")
- else:
- pdebug(f"MusicPlayer: Kein next_track (queue.has_next={self.queue.has_next}, playlist={self._current_playlist is not None})")
- # Pipeline über Track-Start informieren
- self._processing_pipeline.set_current_track(track)
- self._processing_pipeline.set_next_track(next_track)
- start_context = AudioProcessingContext(
- audio_type=AudioType.MUSIC,
- track=track,
- next_track=next_track,
- duration_ms=self._duration_ms,
- sample_rate=self.config.sample_rate,
- channels=self.config.channels,
- volume=self._volume,
- is_muted=self._muted,
- satellite_ids=satellite_ids or [],
- )
- self._processing_pipeline.notify_track_start(start_context)
- await self._emit_event(PlaybackEvent.TRACK_STARTED, {"track": track.to_dict()})
- try:
- # Streaming starten
- await self._streamer.start(track, satellite_ids)
- # Decoder finden
- decoder = self.decoders.get_best_decoder(
- path=track.path,
- extension=track.metadata.format,
- )
- if not decoder:
- raise Exception(f"Kein Decoder für Format: {track.metadata.format}")
- self._state = PlayerState.PLAYING
- await self._emit_event(PlaybackEvent.STATE_CHANGED, {"state": self._state.value})
- # Audio-Stream dekodieren und senden
- chunk_count = 0
- crossfade_ms = self.config.crossfade_ms
- # Crossfade-Buffer: Speichert die letzten N Sekunden für Fade-Out (Instanzvariable)
- crossfade_chunks_needed = crossfade_ms // 100 if crossfade_ms > 0 else 0
- self._crossfade_buffer = deque(maxlen=crossfade_chunks_needed) if crossfade_chunks_needed > 0 else deque()
- self._current_satellite_ids = satellite_ids # Für Crossfade bei manuellem next()
- pdebug(f"MusicPlayer: Starte '{track.title}' ({self._duration_ms}ms), crossfade_ms={crossfade_ms}, chunks_needed={crossfade_chunks_needed}")
- # Wall-Clock Timing: verhindert ALSA-Underrun durch akkurate Chunk-Taktung
- chunk_interval = 0.1 # 100ms pro Chunk
- next_chunk_time = time.monotonic()
- if track.path:
- # Generator für aktuellen Track
- current_gen = decoder.decode_stream(track.path, chunk_duration_ms=100)
- next_gen = None # Wird bei Crossfade gesetzt
- cf_track = None # Crossfade-Ziel-Track
- cf_chunk_index = 0
- cf_total_chunks = crossfade_ms // 100
- while True:
- if self._stop_event.is_set():
- pdebug(f"MusicPlayer: stop_event bei Chunk {chunk_count}")
- break
- # Auto-Crossfade: N Sekunden vor Track-Ende automatisch starten
- if (next_gen is None and crossfade_ms > 0 and self._duration_ms > 0
- and self._position_ms >= self._duration_ms - crossfade_ms
- and not self._stop_event.is_set()):
- pdebug(f"MusicPlayer: Auto-Crossfade bei {self._position_ms}/{self._duration_ms}ms")
- self._crossfade_to_next = True
- # Crossfade angefordert (manuell oder auto)?
- if self._crossfade_to_next and next_gen is None:
- pdebug(f"MusicPlayer: Crossfade bei Chunk {chunk_count}")
- self._crossfade_to_next = False
- # Nächsten Track ermitteln
- if self._current_playlist and self._current_playlist.has_next:
- cf_track = self._current_playlist.peek_next()
- elif self.queue.has_next:
- cf_next_item = self.queue.peek_next()
- cf_track = cf_next_item.track if cf_next_item else None
- if cf_track and cf_track.path:
- cf_decoder = self.decoders.get_best_decoder(
- path=cf_track.path,
- extension=cf_track.metadata.format,
- )
- if cf_decoder:
- next_gen = cf_decoder.decode_stream(cf_track.path, chunk_duration_ms=100)
- cf_chunk_index = 0
- pdebug(f"MusicPlayer: Crossfade '{track.title}' -> '{cf_track.title}'")
- # Chunks holen
- current_chunk = await anext(current_gen, None)
- next_chunk = None
- if next_gen is not None:
- next_chunk = await anext(next_gen, None)
- # Beide Tracks fertig?
- if current_chunk is None and next_chunk is None:
- break
- # Crossfade-Logik
- if next_gen is not None and next_chunk is not None:
- if cf_chunk_index < cf_total_chunks and current_chunk is not None:
- # Mischen
- mix_factor = cf_chunk_index / cf_total_chunks
- output_data = self._mix_chunks(current_chunk.pcm_data, next_chunk.pcm_data, mix_factor)
- if cf_chunk_index % 10 == 0:
- pdebug(f"MusicPlayer: Crossfade {cf_chunk_index}/{cf_total_chunks}, mix={mix_factor:.2f}")
- else:
- # Nur neuer Track
- output_data = next_chunk.pcm_data
- # Crossfade abgeschlossen
- if cf_chunk_index == cf_total_chunks:
- pdebug("MusicPlayer: Crossfade abgeschlossen")
- await self._emit_event(PlaybackEvent.TRACK_ENDED, {"track": track.to_dict()})
- await self._emit_event(PlaybackEvent.TRACK_CHANGED, {
- "previous_track": track.to_dict(),
- "current_track": cf_track.to_dict(),
- })
- await self._emit_event(PlaybackEvent.TRACK_STARTED, {"track": cf_track.to_dict()})
- # Playlist-Position konsumieren
- if self._current_playlist and self._current_playlist.has_next:
- self._current_playlist.next()
- # Track wechseln
- track = cf_track
- next_track = None
- if self._current_playlist and self._current_playlist.has_next:
- next_track = self._current_playlist.peek_next()
- self._duration_ms = track.duration_ms
- self._position_ms = 0
- # Alten Generator vergessen, neuer ist jetzt current
- current_gen = next_gen
- next_gen = None
- cf_track = None
- cf_chunk_index += 1
- elif current_chunk is not None:
- # Normaler Modus (kein Crossfade)
- # Durch Processing-Pipeline (Ducking, Equalizer, etc.)
- processing_context = AudioProcessingContext(
- audio_type=AudioType.MUSIC,
- position_ms=self._position_ms,
- duration_ms=self._duration_ms,
- chunk_duration_ms=100,
- track=track,
- next_track=next_track,
- sample_rate=self.config.sample_rate,
- channels=self.config.channels,
- volume=self._volume,
- is_muted=self._muted,
- satellite_ids=satellite_ids or [],
- )
- output_data = self._processing_pipeline.process(
- current_chunk.pcm_data,
- processing_context
- )
- # Chunk in Crossfade-Buffer speichern
- if crossfade_ms > 0:
- self._crossfade_buffer.append(output_data)
- else:
- # Aktueller Track fertig, aber Crossfade läuft noch
- if next_chunk is not None:
- output_data = next_chunk.pcm_data
- cf_chunk_index += 1
- else:
- break
- # Pause-Handling
- while self._state == PlayerState.PAUSED:
- await asyncio.sleep(0.1)
- if self._stop_event.is_set():
- break
- if self._stop_event.is_set():
- break
- # Lautstärke
- if not self._muted and self._volume < 1.0:
- output_data = self._apply_volume(output_data, self._volume)
- # Senden
- send_chunk = DecodedAudio(
- pcm_data=output_data,
- sample_rate=self.config.sample_rate,
- channels=self.config.channels,
- )
- await self._streamer.feed_decoded(send_chunk, satellite_ids)
- # Position
- self._position_ms += 100
- chunk_count += 1
- if chunk_count % 100 == 0:
- pdebug(f"MusicPlayer: Chunk {chunk_count}, buffer={len(self._crossfade_buffer)}/{crossfade_chunks_needed}")
- # Wall-Clock Sleep: nur die verbleibende Zeit schlafen
- next_chunk_time += chunk_interval
- sleep_time = next_chunk_time - time.monotonic()
- if sleep_time > 0:
- await asyncio.sleep(sleep_time)
- elif sleep_time < -0.5:
- # Mehr als 500ms hinterher - Timing zurücksetzen
- next_chunk_time = time.monotonic()
- pdebug(f"MusicPlayer: Track '{track.title}' beendet nach {self._position_ms}ms")
- # Pipeline über Track-Ende informieren
- end_context = AudioProcessingContext(
- audio_type=AudioType.MUSIC,
- position_ms=self._position_ms,
- duration_ms=self._duration_ms,
- track=track,
- next_track=next_track,
- sample_rate=self.config.sample_rate,
- channels=self.config.channels,
- )
- self._processing_pipeline.notify_track_end(end_context)
- # Track beendet
- # WICHTIG: Task als beendet markieren, BEVOR next() aufgerufen wird,
- # damit play() nicht versucht den eigenen Task zu canceln
- self._playback_task = None
- await self._emit_event(PlaybackEvent.TRACK_ENDED, {"track": track.to_dict()})
- if self.config.auto_play_next and not self._stop_event.is_set():
- await self.next(use_crossfade=False)
- except asyncio.CancelledError:
- pdebug("MusicPlayer: Playback-Task wurde abgebrochen (CancelledError)")
- except Exception as e:
- pdebug(f"MusicPlayer: Exception in _playback_loop: {e}")
- self.logger.error(f"Playback-Fehler: {e}")
- self._state = PlayerState.ERROR
- await self._emit_event(PlaybackEvent.ERROR, {"error": str(e)})
- def _apply_volume(self, pcm_data: bytes, volume: float) -> bytes:
- """
- Wendet Lautstärke auf PCM-Daten an.
- Args:
- pcm_data: 16-bit PCM-Daten
- volume: Lautstärke (0.0 - 1.0)
- Returns:
- Modifizierte PCM-Daten
- """
- import struct
- if volume >= 1.0:
- return pcm_data
- samples = struct.unpack(f"{len(pcm_data)//2}h", pcm_data)
- adjusted = [int(s * volume) for s in samples]
- # Clipping vermeiden
- adjusted = [max(-32768, min(32767, s)) for s in adjusted]
- return struct.pack(f"{len(adjusted)}h", *adjusted)
- def _mix_chunks(self, chunk_a: bytes, chunk_b: bytes, mix_factor: float) -> bytes:
- """
- Mischt zwei Audio-Chunks für Crossfade.
- Args:
- chunk_a: Erster Chunk (wird ausgeblendet)
- chunk_b: Zweiter Chunk (wird eingeblendet)
- mix_factor: 0.0 = nur A, 1.0 = nur B
- Returns:
- Gemischter Chunk
- """
- import struct
- # Chunks müssen gleich lang sein
- if len(chunk_a) != len(chunk_b):
- # Kürzer auf länger anpassen
- min_len = min(len(chunk_a), len(chunk_b))
- chunk_a = chunk_a[:min_len]
- chunk_b = chunk_b[:min_len]
- num_samples = len(chunk_a) // 2
- samples_a = struct.unpack(f"{num_samples}h", chunk_a)
- samples_b = struct.unpack(f"{num_samples}h", chunk_b)
- # Linear crossfade
- factor_a = 1.0 - mix_factor
- factor_b = mix_factor
- mixed = []
- for sa, sb in zip(samples_a, samples_b):
- sample = int(sa * factor_a + sb * factor_b)
- # Clipping vermeiden
- sample = max(-32768, min(32767, sample))
- mixed.append(sample)
- return struct.pack(f"{len(mixed)}h", *mixed)
- # ==========================================================================
- # Events
- # ==========================================================================
- def on_event(self, callback: Callable[[PlaybackEvent, dict], Awaitable[None]]) -> None:
- """Registriert Event-Callback."""
- self._event_callbacks.append(callback)
- def remove_event_callback(self, callback: Callable) -> None:
- """Entfernt Event-Callback."""
- if callback in self._event_callbacks:
- self._event_callbacks.remove(callback)
- async def _emit_event(self, event: PlaybackEvent, data: dict) -> None:
- """Emittiert Event an alle Callbacks."""
- for callback in self._event_callbacks:
- try:
- await callback(event, data)
- except Exception as e:
- self.logger.error(f"Event-Callback-Fehler: {e}")
- # ==========================================================================
- # Status
- # ==========================================================================
- def get_status(self) -> dict[str, Any]:
- """Liefert Player-Status."""
- return {
- "id": self.id,
- "state": self._state.value,
- "current_track": self.current_track.to_dict() if self.current_track else None,
- "position_ms": self._position_ms,
- "duration_ms": self._duration_ms,
- "progress": self.progress,
- "volume": self._volume,
- "muted": self._muted,
- "repeat_mode": self.repeat_mode.value,
- "shuffle_mode": self.shuffle_mode.value,
- "queue_count": self.queue.count,
- "playlist": self._current_playlist.name if self._current_playlist else None,
- "streaming": {
- "session_count": self._streamer.session_count,
- "is_streaming": self._streamer.is_streaming,
- },
- }
- def __repr__(self) -> str:
- return (
- f"MusicPlayer(id={self.id!r}, "
- f"state={self._state.value!r}, "
- f"track={self.current_track.title if self.current_track else None!r})"
- )
|