diff --git a/discord/oggparse.py b/discord/oggparse.py new file mode 100644 index 000000000..6f50502f0 --- /dev/null +++ b/discord/oggparse.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2019 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import struct + +from .errors import DiscordException + +class OggError(DiscordException): + """An exception that is thrown for Ogg stream parsing errors.""" + pass + +# https://tools.ietf.org/html/rfc3533 +# https://tools.ietf.org/html/rfc7845 + +class OggPage: + _header = struct.Struct(' 2**32 else 'x86' - _filename = os.path.join(_basedir, 'bin', 'libopus-0.{}.dll'.format(_bitness)) - _lib = libopus_loader(_filename) - else: - _lib = libopus_loader(ctypes.util.find_library('opus')) -except Exception: - _lib = None +def _load_default(): + global _lib + try: + if sys.platform == 'win32': + _basedir = os.path.dirname(os.path.abspath(__file__)) + _bitness = 'x64' if sys.maxsize > 2**32 else 'x86' + _filename = os.path.join(_basedir, 'bin', 'libopus-0.{}.dll'.format(_bitness)) + _lib = libopus_loader(_filename) + else: + _lib = libopus_loader(ctypes.util.find_library('opus')) + except Exception: + _lib = None + + return _lib is not None def load_opus(name): """Loads the libopus shared library for use with voice. If this function is not called then the library uses the function - :func:`ctypes.util.find_library` and then loads that one - if available. + :func:`ctypes.util.find_library` and then loads that one if available. - Not loading a library leads to voice not working. + Not loading a library and attempting to use PCM based AudioSources will + lead to voice not working. This function propagates the exceptions thrown. @@ -221,7 +227,8 @@ class Encoder: self.application = application if not is_loaded(): - raise OpusNotLoaded() + if not _load_default(): + raise OpusNotLoaded() self._state = self._create_state() self.set_bitrate(128) diff --git a/discord/player.py b/discord/player.py index 9d9311e66..bf27b5811 100644 --- a/discord/player.py +++ b/discord/player.py @@ -31,16 +31,21 @@ import asyncio import logging import shlex import time +import json +import re from .errors import ClientException from .opus import Encoder as OpusEncoder +from .oggparse import OggStream log = logging.getLogger(__name__) __all__ = ( 'AudioSource', 'PCMAudio', + 'FFmpegAudio', 'FFmpegPCMAudio', + 'FFmpegOpusAudio', 'PCMVolumeTransformer', ) @@ -107,7 +112,55 @@ class PCMAudio(AudioSource): return b'' return ret -class FFmpegPCMAudio(AudioSource): +class FFmpegAudio(AudioSource): + """Represents an FFmpeg (or AVConv) based AudioSource. + + User created AudioSources using FFmpeg differently from how :class:`FFmpegPCMAudio` and + :class:`FFmpegOpusAudio` work should subclass this. + """ + + def __init__(self, source, *, executable='ffmpeg', args, **subprocess_kwargs): + args = [executable, *args] + kwargs = {'stdout': subprocess.PIPE} + kwargs.update(subprocess_kwargs) + + self._process = self._spawn_process(args, **kwargs) + self._stdout = self._process.stdout + + def _spawn_process(self, args, **subprocess_kwargs): + process = None + try: + process = subprocess.Popen(args, **subprocess_kwargs) + except FileNotFoundError: + executable = args.partition(' ')[0] if isinstance(args, str) else args[0] + raise ClientException(executable + ' was not found.') from None + except subprocess.SubprocessError as exc: + raise ClientException('Popen failed: {0.__class__.__name__}: {0}'.format(exc)) from exc + else: + return process + + def cleanup(self): + proc = self._process + if proc is None: + return + + log.info('Preparing to terminate ffmpeg process %s.', proc.pid) + + try: + proc.kill() + except Exception: + log.exception("Ignoring error attempting to kill ffmpeg process %s", proc.pid) + + if proc.poll() is None: + log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) + proc.communicate() + log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) + else: + log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) + + self._process = self._stdout = None + +class FFmpegPCMAudio(FFmpegAudio): """An audio source from FFmpeg (or AVConv). This launches a sub-process to a specific input file given. @@ -131,10 +184,10 @@ class FFmpegPCMAudio(AudioSource): stderr: Optional[:term:`py:file object`] A file-like object to pass to the Popen constructor. Could also be an instance of ``subprocess.PIPE``. - options: Optional[:class:`str`] - Extra command line arguments to pass to ffmpeg after the ``-i`` flag. before_options: Optional[:class:`str`] Extra command line arguments to pass to ffmpeg before the ``-i`` flag. + options: Optional[:class:`str`] + Extra command line arguments to pass to ffmpeg after the ``-i`` flag. Raises -------- @@ -143,9 +196,8 @@ class FFmpegPCMAudio(AudioSource): """ def __init__(self, source, *, executable='ffmpeg', pipe=False, stderr=None, before_options=None, options=None): - stdin = None if not pipe else source - - args = [executable] + args = [] + subprocess_kwargs = {'stdin': source if pipe else None, 'stderr': stderr} if isinstance(before_options, str): args.extend(shlex.split(before_options)) @@ -159,14 +211,7 @@ class FFmpegPCMAudio(AudioSource): args.append('pipe:1') - self._process = None - try: - self._process = subprocess.Popen(args, stdin=stdin, stdout=subprocess.PIPE, stderr=stderr) - self._stdout = self._process.stdout - except FileNotFoundError: - raise ClientException(executable + ' was not found.') from None - except subprocess.SubprocessError as exc: - raise ClientException('Popen failed: {0.__class__.__name__}: {0}'.format(exc)) from exc + super().__init__(source, executable=executable, args=args, **subprocess_kwargs) def read(self): ret = self._stdout.read(OpusEncoder.FRAME_SIZE) @@ -174,21 +219,268 @@ class FFmpegPCMAudio(AudioSource): return b'' return ret - def cleanup(self): - proc = self._process - if proc is None: - return + def is_opus(self): + return False - log.info('Preparing to terminate ffmpeg process %s.', proc.pid) - proc.kill() - if proc.poll() is None: - log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) - proc.communicate() - log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) +class FFmpegOpusAudio(FFmpegAudio): + """An audio source from FFmpeg (or AVConv). + + This launches a sub-process to a specific input file given. However, rather than + producing PCM packets like :class:`FFmpegPCMAudio` does that need to be encoded to + opus, this class produces opus packets, skipping the encoding step done by the library. + + Alternatively, instead of instantiating this class directly, you can use + :meth:`FFmpegOpusAudio.from_probe` to probe for bitrate and codec information. This + can be used to opportunistically skip pointless re-encoding of existing opus audio data + for a boost in performance at the cost of a short initial delay to gather the information. + The same can be achieved by passing ``copy`` to the ``codec`` parameter, but only if you + know that the input source is opus encoded beforehand. + + .. warning:: + + You must have the ffmpeg or avconv executable in your path environment + variable in order for this to work. + + Parameters + ------------ + source: Union[:class:`str`, :class:`io.BufferedIOBase`] + The input that ffmpeg will take and convert to PCM bytes. + If ``pipe`` is True then this is a file-like object that is + passed to the stdin of ffmpeg. + bitrate: :class:`int` + The bitrate in kbps to encode the output to. Defaults to ``128``. + codec: Optional[:class:`str`] + The codec to use to encode the audio data. Normally this would be + just ``libopus``, but is used by :meth:`FFmpegOpusAudio.from_probe` to + opportunistically skip pointlessly re-encoding opus audio data by passing + ``copy`` as the codec value. Any values other than ``copy``, ``opus``, or + ``libopus`` will be considered ``libopus``. Defaults to ``libopus``. + + .. warning:: + + Do not provide this parameter unless you are certain that the audio input is + already opus encoded. For typical use :meth:`FFmpegOpusAudio.from_probe` + should be used to determine the proper value for this parameter. + + executable: :class:`str` + The executable name (and path) to use. Defaults to ``ffmpeg``. + pipe: :class:`bool` + If ``True``, denotes that ``source`` parameter will be passed + to the stdin of ffmpeg. Defaults to ``False``. + stderr: Optional[:term:`py:file object`] + A file-like object to pass to the Popen constructor. + Could also be an instance of ``subprocess.PIPE``. + before_options: Optional[:class:`str`] + Extra command line arguments to pass to ffmpeg before the ``-i`` flag. + options: Optional[:class:`str`] + Extra command line arguments to pass to ffmpeg after the ``-i`` flag. + + Raises + -------- + ClientException + The subprocess failed to be created. + """ + + def __init__(self, source, *, bitrate=128, codec=None, executable='ffmpeg', + pipe=False, stderr=None, before_options=None, options=None): + + args = [] + subprocess_kwargs = {'stdin': source if pipe else None, 'stderr': stderr} + + if isinstance(before_options, str): + args.extend(shlex.split(before_options)) + + args.append('-i') + args.append('-' if pipe else source) + + codec = 'copy' if codec in ('opus', 'libopus') else 'libopus' + + args.extend(('-map_metadata', '-1', + '-f', 'opus', + '-c:a', codec, + '-ar', '48000', + '-ac', '2', + '-b:a', '%sk' % bitrate, + '-loglevel', 'warning')) + + if isinstance(options, str): + args.extend(shlex.split(options)) + + args.append('pipe:1') + + super().__init__(source, executable=executable, args=args, **subprocess_kwargs) + self._packet_iter = OggStream(self._stdout).iter_packets() + + @classmethod + async def from_probe(cls, source, *, method=None, **kwargs): + """|coro| + + A factory method that creates a :class:`FFmpegOpusAudio` after probing + the input source for audio codec and bitrate information. + + Examples + ---------- + + Use this function to create an :class:`FFmpegOpusAudio` instance instead of the constructor: :: + + source = await discord.FFmpegOpusAudio.from_probe("song.webm") + voice_client.play(source) + + If you are on Windows and don't have ffprobe installed, use the ``fallback`` method + to probe using ffmpeg instead: :: + + source = await discord.FFmpegOpusAudio.from_probe("song.webm", method='fallback') + voice_client.play(source) + + Using a custom method of determining codec and bitrate: :: + + def custom_probe(source, executable): + # some analysis code here + + return codec, bitrate + + source = await discord.FFmpegOpusAudio.from_probe("song.webm", method=custom_probe) + voice_client.play(source) + + Parameters + ------------ + source + Identical to the ``source`` parameter for the constructor. + method: Optional[Union[:class:`str`, Callable[:class:`str`, :class:`str`]]] + The probing method used to determine bitrate and codec information. As a string, valid + values are ``native`` to use ffprobe (or avprobe) and ``fallback`` to use ffmpeg + (or avconv). As a callable, it must take two string arguments, ``source`` and + ``executable``. Both parameters are the same values passed to this factory function. + ``executable`` will default to ``ffmpeg`` if not provided as a keyword argument. + kwargs + The remaining parameters to be passed to the :class:`FFmpegOpusAudio` constructor, + excluding ``bitrate`` and ``codec``. + + Raises + -------- + AttributeError + Invalid probe method, must be ``'native'`` or ``'fallback'``. + TypeError + Invalid value for ``probe`` parameter, must be :class:`str` or a callable. + + Returns + -------- + :class:`FFmpegOpusAudio` + An instance of this class. + """ + + executable = kwargs.get('executable') + codec, bitrate = await cls.probe(source, method=method, executable=executable) + return cls(source, bitrate=bitrate, codec=codec, **kwargs) + + @classmethod + async def probe(cls, source, *, method=None, executable=None): + """|coro| + + Probes the input source for bitrate and codec information. + + Parameters + ------------ + source + Identical to the ``source`` parameter for :class:`FFmpegOpusAudio`. + method + Identical to the ``method`` parameter for :meth:`FFmpegOpusAudio.from_probe`. + executable: :class:`str` + Identical to the ``executable`` parameter for :class:`FFmpegOpusAudio`. + + Raises + -------- + AttributeError + Invalid probe method, must be ``'native'`` or ``'fallback'``. + TypeError + Invalid value for ``probe`` parameter, must be :class:`str` or a callable. + + Returns + --------- + Tuple[Optional[:class:`str`], Optional[:class:`int`]] + A 2-tuple with the codec and bitrate of the input source. + """ + + method = method or 'native' + executable = executable or 'ffmpeg' + probefunc = fallback = None + + if isinstance(method, str): + probefunc = getattr(cls, '_probe_codec_' + method, None) + if probefunc is None: + raise AttributeError("Invalid probe method '%s'" % method) + + if probefunc is cls._probe_codec_native: + fallback = cls._probe_codec_fallback + + elif callable(method): + probefunc = method + fallback = cls._probe_codec_fallback else: - log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) + raise TypeError("Expected str or callable for parameter 'probe', " \ + "not '{0.__class__.__name__}'" .format(method)) + + codec = bitrate = None + loop = asyncio.get_event_loop() + try: + codec, bitrate = await loop.run_in_executor(None, lambda: probefunc(source, executable)) + except Exception: + if not fallback: + log.exception("Probe '%s' using '%s' failed", method, executable) + return + + log.exception("Probe '%s' using '%s' failed, trying fallback", method, executable) + try: + codec, bitrate = await loop.run_in_executor(None, lambda: fallback(source, executable)) + except Exception: + log.exception("Fallback probe using '%s' failed", executable) + else: + log.info("Fallback probe found codec=%s, bitrate=%s", codec, bitrate) + else: + log.info("Probe found codec=%s, bitrate=%s", codec, bitrate) + finally: + return codec, bitrate + + @staticmethod + def _probe_codec_native(source, executable='ffmpeg'): + exe = executable[:2] + 'probe' if executable in ('ffmpeg', 'avconv') else executable + args = [exe, '-v', 'quiet', '-print_format', 'json', '-show_streams', '-select_streams', 'a:0', source] + output = subprocess.check_output(args, timeout=20) + codec = bitrate = None + + if output: + data = json.loads(output) + streamdata = data['streams'][0] - self._process = None + codec = streamdata.get('codec_name') + bitrate = int(streamdata.get('bit_rate', 0)) + bitrate = max(round(bitrate/1000, 0), 512) + + return codec, bitrate + + @staticmethod + def _probe_codec_fallback(source, executable='ffmpeg'): + args = [executable, '-hide_banner', '-i', source] + proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out, _ = proc.communicate(timeout=20) + output = out.decode('utf8') + codec = bitrate = None + + codec_match = re.search(r"Stream #0.*?Audio: (\w+)", output) + if codec_match: + codec = codec_match.group(1) + + br_match = re.search(r"(\d+) [kK]b/s", output) + if br_match: + bitrate = max(int(br_match.group(1)), 512) + + return codec, bitrate + + def read(self): + return next(self._packet_iter, b'') + + def is_opus(self): + return True class PCMVolumeTransformer(AudioSource): """Transforms a previous :class:`AudioSource` to have volume controls. @@ -260,7 +552,7 @@ class AudioPlayer(threading.Thread): def _do_run(self): self.loops = 0 - self._start = time.time() + self._start = time.perf_counter() # getattr lookup speed ups play_audio = self.client.send_audio_packet @@ -279,7 +571,7 @@ class AudioPlayer(threading.Thread): self._connected.wait() # reset our internal data self.loops = 0 - self._start = time.time() + self._start = time.perf_counter() self.loops += 1 data = self.source.read() @@ -290,7 +582,7 @@ class AudioPlayer(threading.Thread): play_audio(data, encode=not self.source.is_opus()) next_time = self._start + self.DELAY * self.loops - delay = max(0, self.DELAY + (next_time - time.time())) + delay = max(0, self.DELAY + (next_time - time.perf_counter())) time.sleep(delay) def run(self): @@ -322,7 +614,7 @@ class AudioPlayer(threading.Thread): def resume(self, *, update_speaking=True): self.loops = 0 - self._start = time.time() + self._start = time.perf_counter() self._resumed.set() if update_speaking: self._speak(True) diff --git a/discord/voice_client.py b/discord/voice_client.py index b014de53f..4b6791584 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -68,11 +68,10 @@ class VoiceClient: Warning -------- - In order to play audio, you must have loaded the opus library - through :func:`opus.load_opus`. - - If you don't do this then the library will not be able to - transmit audio. + In order to use PCM based AudioSources, you must have the opus library + installed on your system and loaded through :func:`opus.load_opus`. + Otherwise, your AudioSources must be opus encoded (e.g. using :class:`FFmpegOpusAudio`) + or the library will not be able to transmit audio. Attributes ----------- @@ -111,7 +110,7 @@ class VoiceClient: self.timestamp = 0 self._runner = None self._player = None - self.encoder = opus.Encoder() + self.encoder = None warn_nacl = not has_nacl supported_modes = ( @@ -356,7 +355,9 @@ class VoiceClient: ClientException Already playing audio or not connected. TypeError - source is not a :class:`AudioSource` or after is not a callable. + Source is not a :class:`AudioSource` or after is not a callable. + OpusNotLoaded + Source is not opus encoded and opus is not loaded. """ if not self.is_connected(): @@ -368,6 +369,9 @@ class VoiceClient: if not isinstance(source, AudioSource): raise TypeError('source must an AudioSource not {0.__class__.__name__}'.format(source)) + if not self.encoder and not source.is_opus(): + self.encoder = opus.Encoder() + self._player = AudioPlayer(source, self, after=after) self._player.start() @@ -444,4 +448,4 @@ class VoiceClient: except BlockingIOError: log.warning('A packet has been dropped (seq: %s, timestamp: %s)', self.sequence, self.timestamp) - self.checked_add('timestamp', self.encoder.SAMPLES_PER_FRAME, 4294967295) + self.checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295) diff --git a/docs/api.rst b/docs/api.rst index 6516a14a2..b86ef02c6 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -60,9 +60,15 @@ Voice .. autoclass:: PCMAudio :members: +.. autoclass:: FFmpegAudio + :members: + .. autoclass:: FFmpegPCMAudio :members: +.. autoclass:: FFmpegOpusAudio + :members: + .. autoclass:: PCMVolumeTransformer :members: