#!/usr/bin/env python3 """Audio module for BLC. This module defines an AudioPlayer interface which allows for various audio backends to be used interchangeably. It also defines a bare-bones better-than-nothing "FFPlayer" implementation and a better "MPVPlayer" implementation. "DefaultAudioPlayer" should be used in general and will refer to MPVPlayer if available and FFPlayer otherwise. """ import atexit import subprocess as subp import time import warnings from abc import ABC, abstractmethod, abstractproperty def ttoti(t): """Convert seconds to milliseconds.""" return int(1000*t + 0.5) def titot(ti): """Convert milliseconds to seconds.""" return ti/1000 class AudioPlayer(ABC): """Class for playing audio. All time indices must be integers in milliseconds. """ @abstractmethod def play(self, start=-1): """Play the audio from the given time. If start is -1, play it from the current time index (e.g. if paused). If the player is already playing, throw an error. """ return @abstractmethod def seek(self, t): """Seek to the given time index.""" return @abstractmethod def pause(self): """Pause the player.""" return @abstractmethod def stop(self): """Stop the player and reset to the first time index.""" return @abstractproperty def volume(self): """Get or set the current volume.""" return @abstractproperty def position(self) -> int: """The current position in milliseconds.""" return @abstractproperty def playing(self) -> bool: """Return if the player is playing or not.""" return def __init__(self, fname, args=()): self.fname = fname self.args = args class FFPlayer(AudioPlayer): """Audio player using ffplay. Note that this is incredibly bad: the current position is guessed based on the start time of the subprocess (meaning startup time of the ffplay process is counted in the current position), no preloading of files is done, seeking is inaccurate and requires killing and restarting the ffplay process, volume is ignored, and more. This is due to the fact that you can't provide input to ffplay because it uses SDL exclusively for input (even though it can be run without SDL?) so any change requires restarting the process. Use MPVPlayer if possible. """ def play(self, start=-1): if self.playing: raise ValueError("Already playing") if start != -1: self.start = titot(start) self.player = subp.Popen(["ffplay", "-nodisp", "-autoexit", "-ss", str(self.start), *self.args, self.fname], stdin=subp.DEVNULL, stdout=subp.DEVNULL, stderr=subp.DEVNULL) atexit.register(self.stop) self.start_time = time.monotonic() def stop(self): if not self.playing: return self.player.terminate() atexit.unregister(self.stop) self.player = None self.start = 0 def seek(self, t): if self.playing: self.stop() self.start = titot(t) self.play() else: self.start = titot(t) def pause(self): if not self.playing: return self.stop() self.start = self.start + time.monotonic() @property def position(self): if not self.playing: return self.start return ttoti(self.start + time.monotonic() - self.start_time) @property def volume(self): return 100 @volume.setter def volume(self, vol): return @property def playing(self): if self.player is not None: if self.player.poll() is not None: self.player = None return self.player is not None def __init__(self, fname, args=()): super().__init__(fname, args=args) self.player = None self.start = 0 self.start_time = 0 try: import mpv except (OSError, ImportError): warnings.warn("mpv backend unavailable, falling back to ffplay", RuntimeWarning) DefaultAudioPlayer = FFPlayer