emmental/emmental/audio/__init__.py

237 lines
9.4 KiB
Python

# Copyright 2022 (c) Anna Schumaker.
"""A custom GObject managing a GStreamer playbin."""
import pathlib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gst
from . import filter
from . import stopwatch
from .. import path
from .. import tmpdir
UPDATE_INTERVAL = 100
SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT
class Player(GObject.GObject):
"""Wraps a GStreamer Playbin with an interface for our application."""
artist = GObject.Property(type=str)
album = GObject.Property(type=str)
album_artist = GObject.Property(type=str)
album_disc_number = GObject.Property(type=int)
title = GObject.Property(type=str)
track_number = GObject.Property(type=int)
position = GObject.Property(type=float, default=0)
duration = GObject.Property(type=float, default=0)
volume = GObject.Property(type=float, default=1.0)
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
file = GObject.Property(type=GObject.TYPE_PYOBJECT)
playing = GObject.Property(type=bool, default=False)
status = GObject.Property(type=str, default="Stopped")
have_track = GObject.Property(type=bool, default=False)
almost_done = GObject.Property(type=bool, default=False)
playtime = GObject.Property(type=float)
stopwatch = GObject.Property(type=stopwatch.StopWatch)
bg_enabled = GObject.Property(type=bool, default=False)
bg_volume = GObject.Property(type=float, default=0.5)
pause_on_load = GObject.Property(type=bool, default=False)
def __init__(self):
"""Initialize the audio Player."""
super().__init__(stopwatch=stopwatch.StopWatch())
self._filter = filter.Filter()
self._timeout = None
self._playbin = Gst.ElementFactory.make("playbin")
self._playbin.set_property("audio-filter", self._filter)
self._playbin.set_property("video-sink",
Gst.ElementFactory.make("fakesink"))
self._playbin.set_state(Gst.State.READY)
bus = self._playbin.get_bus()
bus.add_signal_watch()
bus.connect("message::async-done", self.__msg_async_done)
bus.connect("message::eos", self.__msg_eos)
bus.connect("message::state-changed", self.__msg_state_changed)
bus.connect("message::stream-start", self.__msg_stream_start)
bus.connect("message::tag", self.__msg_tags)
self.bind_property("volume", self._playbin, "volume")
self.bind_property("bg-enabled", self._filter, "bg-enabled")
self.bind_property("bg-volume", self._filter, "bg-volume")
self.connect("notify::file", self.__notify_file)
def __check_last_second(self) -> None:
if self.duration - self.position <= 2 * (Gst.SECOND / Gst.USECOND):
if not self.almost_done:
self.emit("about-to-finish")
def __msg_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None:
self.__update_position()
def __msg_eos(self, bus: Gst.Bus, message: Gst.Message) -> None:
self.emit("eos")
def __msg_state_changed(self, bus: Gst.Bus, message: Gst.Message) -> None:
if message.src == self._playbin:
(old, new, pending) = message.parse_state_changed()
match (self.status, new, pending):
case ("Playing", Gst.State.PLAYING, _) | \
("Paused", Gst.State.PAUSED, _) | \
("Stopped", Gst.State.READY, _) | \
("Stopped", Gst.State.NULL, _):
pass
case (_, Gst.State.PLAYING, Gst.State.VOID_PENDING):
print("audio: state changed to 'playing'")
self.status = "Playing"
self.playing = True
self.stopwatch.start()
case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING):
print("audio: state changed to 'paused'")
self.status = "Paused"
self.playing = False
self.stopwatch.stop()
case (_, Gst.State.READY, Gst.State.VOID_PENDING) | \
(_, Gst.State.NULL, Gst.State.VOID_PENDING):
print("audio: state changed to 'stopped'")
self.status = "Stopped"
self.playing = False
self.stopwatch.stop()
self.__update_timeout()
def __msg_stream_start(self, bus: Gst.Bus, message: Gst.Message) -> None:
self.emit("file-loaded",
path.from_uri(self._playbin.get_property("current-uri")))
def __msg_tags(self, bus: Gst.Bus, message: Gst.Message) -> None:
taglist = message.parse_tag()
for tag in ["artist", "album", "album-artist", "album-disc-number",
"title", "track-number", "artwork"]:
match tag:
case "artwork":
(res, sample) = taglist.get_sample("image")
if res:
buffer = sample.get_buffer()
(res, map) = buffer.map(Gst.MapFlags.READ)
if res:
value = tmpdir.cover_jpg(map.data)
buffer.unmap(map)
case "track-number" | "album-disc-number":
(res, value) = taglist.get_uint(tag)
case _:
(res, value) = taglist.get_string(tag)
if res and self.get_property(tag) != value:
self.set_property(tag, value)
def __notify_file(self, player: GObject.GObject, param) -> None:
if self.file:
uri = self.file.as_uri()
print(f"audio: loading {uri}")
self._playbin.set_property("uri", uri)
def __reset_properties(self, *, duration: float = 0.0,
artwork: pathlib.Path | None = None) -> None:
for tag in ["artist", "album-artist", "album", "title"]:
self.set_property(tag, "")
for tag in ["album-disc-number", "track-number",
"position", "playtime"]:
self.set_property(tag, 0)
self.stopwatch.reset()
self.almost_done = False
self.pause_on_load = False
self.artwork = artwork
self.duration = duration
def __update_position(self) -> bool:
(res, pos) = self._playbin.query_position(Gst.Format.TIME)
self.position = pos / Gst.USECOND if res else 0
self.playtime = self.stopwatch.elapsed_time()
self.__check_last_second()
return GLib.SOURCE_CONTINUE
def __update_timeout(self) -> None:
if self.playing and self._timeout is None:
self._timeout = GLib.timeout_add(UPDATE_INTERVAL,
self.__update_position)
elif self.playing is False and self._timeout is not None:
GLib.source_remove(self._timeout)
self._timeout = None
def get_replaygain(self) -> tuple[bool, str | None]:
"""Get the current ReplayGain mode."""
mode = self._filter.rg_mode
return (False, None) if mode == "disabled" else (True, mode)
def get_state(self) -> Gst.State:
"""Get the current state of the Player."""
return self._playbin.get_state(Gst.CLOCK_TIME_NONE).state
def pause(self, *args) -> None:
"""Pause playback."""
self.set_state_sync(Gst.State.PAUSED)
def play(self, *args) -> None:
"""Start playback."""
self.set_state_sync(Gst.State.PLAYING)
def play_pause(self, *args) -> None:
"""Start or Pause playback."""
state = Gst.State.PAUSED if self.playing else Gst.State.PLAYING
self.set_state_sync(state)
def seek(self, newpos: float, *args) -> None:
"""Seek to a different point in the stream."""
self._playbin.seek_simple(Gst.Format.TIME, SEEK_FLAGS,
newpos * Gst.USECOND)
def set_replaygain(self, enabled: bool, mode: str) -> None:
"""Set the ReplayGain mode."""
self._filter.rg_mode = mode if enabled else "disabled"
def set_state_sync(self, state: Gst.State) -> None:
"""Set the state of the playbin, and wait for it to change."""
if self._playbin.set_state(state) == Gst.StateChangeReturn.ASYNC:
self.get_state()
def shutdown(self) -> None:
"""Shut down the player."""
self._playbin.set_state(Gst.State.NULL)
def stop(self, *args) -> None:
"""Stop playback."""
self.set_state_sync(Gst.State.READY)
@GObject.Signal
def about_to_finish(self) -> None:
"""Signal that playback is almost done."""
print("audio: about to finish")
self.almost_done = True
@GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,))
def file_loaded(self, file: pathlib.Path) -> None:
"""Signal that a new URI has started."""
print("audio: file loaded")
if self.pause_on_load:
self._playbin.set_state(Gst.State.PAUSED)
(res, dur) = self._playbin.query_duration(Gst.Format.TIME)
cover = self.file.parent / "cover.jpg"
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0),
artwork=(cover if cover.is_file() else None))
self.have_track = True
@GObject.Signal
def eos(self) -> None:
"""Signal that the current track has ended."""
print("audio: end of stream")
self.set_state_sync(Gst.State.READY)
self.__reset_properties()
self.have_track = False
self.file = None