Files
musicmouse/python-backend/musicmouse/devices/player.py
2026-08-27 23:46:19 +02:00

362 lines
13 KiB
Python

"""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()