362 lines
13 KiB
Python
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()
|