diff options
Diffstat (limited to 'audio.py')
-rwxr-xr-x | audio.py | 154 |
1 files changed, 154 insertions, 0 deletions
diff --git a/audio.py b/audio.py new file mode 100755 index 0000000..37b0f7d --- /dev/null +++ b/audio.py @@ -0,0 +1,154 @@ +#!/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 |