"""Audio playback, behind a protocol. :class:`VlcPlayer` is the only real implementation; the simulator supplies another. libVLC fires its callbacks on its own thread, so every one of them goes through ``bus.emit()``, which hops back onto the event loop. The old code called straight into the serial transport from that thread. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, Protocol from musicmouse.bus import EventBus from musicmouse.clock import Clock, RealClock from musicmouse.events import ( EventSource, PlaybackChanged, PlaylistFinished, TrackChanged, VolumeChanged, ) from musicmouse.media import Playlist, Track if TYPE_CHECKING: from musicmouse.config import GeneralConfig _log = logging.getLogger(__name__) __all__ = ["Player", "PlayerBase", "VlcPlayer"] class Player(Protocol): """What reactions and front-ends are allowed to do with the audio player.""" @property def is_playing(self) -> bool: ... @property def volume(self) -> int: ... @property def playlist(self) -> Playlist | None: ... @property def track_index(self) -> int: ... @property def current_track(self) -> Track | None: ... @property def position(self) -> float: """Seconds into the current track. ``0.0`` when nothing is loaded. Read on demand rather than announced: a progress bar wants this twice a second, and an event at that rate would flood the bus, the MQTT service and the log for the benefit of one front-end. """ ... @property def duration(self) -> float: """Length of the current track in seconds, or ``0.0`` when unknown.""" ... def set_playlist(self, playlist: Playlist) -> None: ... def play(self) -> None: ... def play_from_start(self) -> None: ... def play_track(self, index: int) -> None: ... def pause(self) -> None: ... def stop(self) -> None: ... def next_track(self) -> None: ... def previous_track(self) -> None: ... def seek(self, position: float) -> None: ... def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ... def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ... def set_volume_limits(self, minimum: int, maximum: int) -> None: ... async def run(self) -> None: """Long-running task, if the implementation needs one.""" ... def close(self) -> None: ... class PlayerBase: """Volume clamping, playlist bookkeeping and state events, shared by the real and the simulated player.""" def __init__( self, bus: EventBus, *, min_volume: int = 0, max_volume: int = 100, initial_volume: int = 50, ) -> None: self._bus = bus self._min_volume = min_volume self._max_volume = max_volume self._volume = self._clamp(initial_volume) self._playlist: Playlist | None = None self._index = 0 self._playing = False self._playlist_changed = False @classmethod def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]: return { "min_volume": config.min_volume, "max_volume": config.max_volume, "initial_volume": config.initial_volume, } # -------------------------------------------------------------------- state @property def is_playing(self) -> bool: return self._playing @property def volume(self) -> int: return self._volume @property def playlist(self) -> Playlist | None: return self._playlist @property def track_index(self) -> int: return self._index @property def current_track(self) -> Track | None: if self._playlist is None or not 0 <= self._index < len(self._playlist): return None return self._playlist[self._index] # ------------------------------------------------------------------ actions def set_volume(self, volume: int, *, source: EventSource = "system") -> None: clamped = self._clamp(volume) if clamped == self._volume: return self._volume = clamped self._apply_volume(clamped) self._announce_volume(source) def change_volume(self, delta: int, *, source: EventSource = "system") -> None: self.set_volume(self._volume + delta, source=source) def set_volume_limits(self, minimum: int, maximum: int) -> None: """Re-clamp to a new allowed range, and pull the current volume into it. Parent mode edits these while the mouse is playing, so they cannot only be constructor arguments. """ self._min_volume = minimum self._max_volume = maximum self.set_volume(self._volume) def _apply_volume(self, volume: int) -> None: """Push the new volume at whatever actually makes sound. No-op by default.""" # ---------------------------------------------------------------- internals def _clamp(self, volume: int) -> int: # `if self._min_volume and ...` in the old code silently ignored min_volume: 0, # which is what config.yml.example shipped with. return max(self._min_volume, min(self._max_volume, volume)) def _set_playing(self, playing: bool, *, figure: str | None = None) -> None: if playing == self._playing: return self._playing = playing self._playlist_changed = False self._bus.emit( PlaybackChanged( playing=playing, figure=figure, playlist=self._playlist, source="player" ) ) def _set_index(self, index: int) -> None: # A playlist swap while already playing resets the index to 0 without going # through here (see `_load_playlist`), so the following `play_track(0)` looks # like a no-op index change. Force it through in that case - `_set_playing` # will not itself announce anything, since it was already playing before and # after. Starting from idle needs no such push: `_set_playing`'s own True # transition already covers the broadcast. force = self._playlist_changed and self._playing if index == self._index and not force: return self._playlist_changed = False self._index = index self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player")) def _load_playlist(self, playlist: Playlist) -> None: """Bookkeeping shared by every player's ``set_playlist``.""" self._playlist = playlist self._index = 0 self._playlist_changed = True def _announce_volume(self, source: EventSource = "player") -> None: self._bus.emit(VolumeChanged(volume=self._volume, source=source)) def _announce_playlist_finished(self) -> None: self._bus.emit(PlaylistFinished(source="player")) async def run(self) -> None: # pragma: no cover - overridden where needed return def close(self) -> None: # pragma: no cover - overridden where needed return class VlcPlayer(PlayerBase): def __init__( self, bus: EventBus, *, alsa_device: str | None = None, min_volume: int = 0, max_volume: int = 100, initial_volume: int = 50, poll_interval: float = 1.0, clock: Clock | None = None, ) -> None: super().__init__( bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume ) # Imported here rather than at module scope: python-vlc loads libvlc eagerly, # and the simulator must run on machines without it. import vlc self._vlc = vlc self._poll_interval = poll_interval self._clock = clock or RealClock() args = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else [] self._instance = vlc.Instance(*args) self._list_player = self._instance.media_list_player_new() self._media_player = self._list_player.get_media_player() self._mrl_to_index: dict[str, int] = {} self._attach_events() self._media_player.audio_set_volume(self._volume) # -------------------------------------------------------------------- state @property def position(self) -> float: # libVLC reports -1 for both of these until a media is actually opened. return max(0.0, float(self._media_player.get_time()) / 1000) @property def duration(self) -> float: return max(0.0, float(self._media_player.get_length()) / 1000) # ------------------------------------------------------------------ actions def set_playlist(self, playlist: Playlist) -> None: media_list = self._vlc.MediaList() self._mrl_to_index.clear() for index, track in enumerate(playlist.tracks): media = self._instance.media_new(str(track.path)) media_list.add_media(media) self._mrl_to_index[media.get_mrl()] = index self._list_player.set_media_list(media_list) self._list_player.set_playback_mode(self._vlc.PlaybackMode.default) self._load_playlist(playlist) _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) def play(self) -> None: self._list_player.play() def play_from_start(self) -> None: self.play_track(0) def play_track(self, index: int) -> None: if self._playlist is None or not self._playlist: _log.warning("Nothing to play: the playlist is empty") return self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1))) def pause(self) -> None: self._media_player.set_pause(1) def stop(self) -> None: self._list_player.stop() def next_track(self) -> None: self._list_player.next() def previous_track(self) -> None: self._list_player.previous() def seek(self, position: float) -> None: self._media_player.set_time(int(max(0.0, position) * 1000)) def _apply_volume(self, volume: int) -> None: self._media_player.audio_set_volume(volume) async def run(self) -> None: """Poll for state libVLC does not reliably report by event.""" while True: await self._clock.sleep(self._poll_interval) try: self._poll() except Exception: # pragma: no cover - defensive around a C library _log.exception("VLC poll failed") def close(self) -> None: self._list_player.stop() # ---------------------------------------------------------------- internals def _poll(self) -> None: volume = self._media_player.audio_get_volume() if volume >= 0 and volume != self._volume: self._volume = volume self._announce_volume() self._set_playing(bool(self._list_player.is_playing())) self._sync_index() def _sync_index(self) -> None: """Read which track is playing back off libVLC. Asking the media player which media it holds works on every build; the ``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare int rather than a Media, and the index would then never move off zero. """ media = self._media_player.get_media() if media is None: return index = self._mrl_to_index.get(media.get_mrl()) if index is not None: self._set_index(index) def _attach_events(self) -> None: vlc = self._vlc player_events = self._media_player.event_manager() player_events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing) player_events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_stopped) player_events.event_attach(vlc.EventType.MediaPlayerStopped, self._on_stopped) list_events = self._list_player.event_manager() list_events.event_attach(vlc.EventType.MediaListPlayerPlayed, self._on_playlist_end) list_events.event_attach(vlc.EventType.MediaListPlayerNextItemSet, self._on_next_item) # These four run on a libVLC thread. bus.emit() is the thread hop; nothing else # here may touch the loop. def _on_playing(self, _event: Any) -> None: self._set_playing(True) def _on_stopped(self, _event: Any) -> None: self._set_playing(False) def _on_playlist_end(self, _event: Any) -> None: self._set_playing(False) self._announce_playlist_finished() def _on_next_item(self, _event: Any) -> None: # The event says *when* to look; what it carries is not portable, so ignore it. self._sync_index()