Passed
Pull Request — main (#228)
by
unknown
02:10
created

pincer.player.FFmpegAudio._spawn_process()   A

Complexity

Conditions 5

Size

Total Lines 11
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 10
dl 0
loc 11
rs 9.3333
c 0
b 0
f 0
cc 5
nop 3
1
"""
2
This is a modified version of discord.py's Player module
3
Copyright (c) 2015-2021 Rapptz
4
Copyright (c) 2021-present Pincer
5
:license: MIT, see LICENSE for details
6
"""
7
8
from __future__ import annotations
9
10
import threading
11
import traceback
12
import subprocess
13
import audioop
14
import asyncio
15
import logging
16
import shlex
17
import time
18
import json
19
import sys
20
import re
21
import io
22
23
from typing import Any, Callable,  Generic, IO, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union
0 ignored issues
show
Coding Style introduced by
Exactly one space required after comma
Loading history...
24
25
from .exceptions import ClientException
26
from .opus import Encoder as OpusEncoder
27
from .oggparse import OggStream
28
from .utils import MISSING
29
30
if TYPE_CHECKING:
31
    from .voice_client import VoiceClient
32
33
34
AT = TypeVar('AT', bound='AudioSource')
35
FT = TypeVar('FT', bound='FFmpegOpusAudio')
36
37
_log = logging.getLogger(__name__)
38
39
__all__ = (
40
    'AudioSource',
41
    'PCMAudio',
42
    'FFmpegAudio',
43
    'FFmpegPCMAudio',
44
    'FFmpegOpusAudio',
45
    'PCMVolumeTransformer',
46
)
47
48
CREATE_NO_WINDOW: int
49
50
if sys.platform != 'win32':
51
    CREATE_NO_WINDOW = 0
52
else:
53
    CREATE_NO_WINDOW = 0x08000000
54
55
class AudioSource:
56
    """Represents an audio stream.
57
    The audio stream can be Opus encoded or not, however if the audio stream
58
    is not Opus encoded then the audio format must be 16-bit 48KHz stereo PCM.
59
    .. warning::
60
        The audio source reads are done in a separate thread.
61
    """
62
63
    def read(self) -> bytes:
64
        """Reads 20ms worth of audio.
65
        Subclasses must implement this.
66
        If the audio is complete, then returning an empty
67
        :term:`py:bytes-like object` to signal this is the way to do so.
68
        If :meth:`~AudioSource.is_opus` method returns ``True``, then it must return
69
        20ms worth of Opus encoded audio. Otherwise, it must be 20ms
70
        worth of 16-bit 48KHz stereo PCM, which is about 3,840 bytes
71
        per frame (20ms worth of audio).
72
        Returns
73
        --------
74
        :class:`bytes`
75
            A bytes like object that represents the PCM or Opus data.
76
        """
77
        raise NotImplementedError
78
79
    def is_opus(self) -> bool:
0 ignored issues
show
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
80
        """Checks if the audio source is already encoded in Opus."""
81
        return False
82
83
    def cleanup(self) -> None:
84
        """Called when clean-up is needed to be done.
85
        Useful for clearing buffer data or processes after
86
        it is done playing audio.
87
        """
88
        pass
0 ignored issues
show
Unused Code introduced by
Unnecessary pass statement
Loading history...
89
90
    def __del__(self) -> None:
91
        self.cleanup()
92
93
class PCMAudio(AudioSource):
94
    """Represents raw 16-bit 48KHz stereo PCM audio source.
95
    Attributes
96
    -----------
97
    stream: :term:`py:file object`
98
        A file-like object that reads byte data representing raw PCM.
99
    """
100
    def __init__(self, stream: io.BufferedIOBase) -> None:
101
        self.stream: io.BufferedIOBase = stream
102
103
    def read(self) -> bytes:
104
        ret = self.stream.read(OpusEncoder.FRAME_SIZE)
105
        if len(ret) != OpusEncoder.FRAME_SIZE:
106
            return b''
107
        return ret
108
109
class FFmpegAudio(AudioSource):
0 ignored issues
show
Bug introduced by
The method read which was declared abstract in the super-class AudioSource
was not overridden.

Methods which raise NotImplementedError should be overridden in concrete child classes.

Loading history...
110
    """Represents an FFmpeg (or AVConv) based AudioSource.
111
    User created AudioSources using FFmpeg differently from how :class:`FFmpegPCMAudio` and
112
    :class:`FFmpegOpusAudio` work should subclass this.
113
    .. versionadded:: 1.3
114
    """
115
116
    def __init__(self, source: Union[str, io.BufferedIOBase], *, executable: str = 'ffmpeg', args: Any, **subprocess_kwargs: Any):
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (130/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
117
        piping = subprocess_kwargs.get('stdin') == subprocess.PIPE
118
        if piping and isinstance(source, str):
119
            raise TypeError("parameter conflict: 'source' parameter cannot be a string when piping to stdin")
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (109/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
120
121
        args = [executable, *args]
122
        kwargs = {'stdout': subprocess.PIPE}
123
        kwargs.update(subprocess_kwargs)
124
125
        self._process: subprocess.Popen = self._spawn_process(args, **kwargs)
126
        self._stdout: IO[bytes] = self._process.stdout  # type: ignore
127
        self._stdin: Optional[IO[Bytes]] = None
0 ignored issues
show
Comprehensibility Best Practice introduced by
Undefined variable 'Bytes'
Loading history...
128
        self._pipe_thread: Optional[threading.Thread] = None
129
130
        if piping:
131
            n = f'popen-stdin-writer:{id(self):#x}'
132
            self._stdin = self._process.stdin
133
            self._pipe_thread = threading.Thread(target=self._pipe_writer, args=(source,), daemon=True, name=n)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (111/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
134
            self._pipe_thread.start()
135
136
    def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Popen:
0 ignored issues
show
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
137
        process = None
138
        try:
139
            process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, **subprocess_kwargs)
140
        except FileNotFoundError:
141
            executable = args.partition(' ')[0] if isinstance(args, str) else args[0]
142
            raise ClientException(executable + ' was not found.') from None
143
        except subprocess.SubprocessError as exc:
144
            raise ClientException(f'Popen failed: {exc.__class__.__name__}: {exc}') from exc
145
        else:
146
            return process
147
148
    def _kill_process(self) -> None:
149
        proc = self._process
150
        if proc is MISSING:
151
            return
152
153
        _log.info('Preparing to terminate ffmpeg process %s.', proc.pid)
154
155
        try:
156
            proc.kill()
157
        except Exception:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
158
            _log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid)
159
160
        if proc.poll() is None:
161
            _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid)
162
            proc.communicate()
163
            _log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (118/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
164
        else:
165
            _log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (117/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
166
167
168
    def _pipe_writer(self, source: io.BufferedIOBase) -> None:
169
        while self._process:
170
            # arbitrarily large read size
171
            data = source.read(8192)
172
            if not data:
173
                self._process.terminate()
174
                return
175
            try:
176
                self._stdin.write(data)
177
            except Exception:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
178
                _log.debug('Write error for %s, this is probably not a problem', self, exc_info=True)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (101/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
179
                # at this point the source data is either exhausted or the process is fubar
180
                self._process.terminate()
181
                return
182
183
    def cleanup(self) -> None:
184
        self._kill_process()
185
        self._process = self._stdout = self._stdin = MISSING
186
187
class FFmpegPCMAudio(FFmpegAudio):
188
    """An audio source from FFmpeg (or AVConv).
189
    This launches a sub-process to a specific input file given.
190
    .. warning::
191
        You must have the ffmpeg or avconv executable in your path environment
192
        variable in order for this to work.
193
    Parameters
194
    ------------
195
    source: Union[:class:`str`, :class:`io.BufferedIOBase`]
196
        The input that ffmpeg will take and convert to PCM bytes.
197
        If ``pipe`` is ``True`` then this is a file-like object that is
198
        passed to the stdin of ffmpeg.
199
    executable: :class:`str`
200
        The executable name (and path) to use. Defaults to ``ffmpeg``.
201
    pipe: :class:`bool`
202
        If ``True``, denotes that ``source`` parameter will be passed
203
        to the stdin of ffmpeg. Defaults to ``False``.
204
    stderr: Optional[:term:`py:file object`]
205
        A file-like object to pass to the Popen constructor.
206
        Could also be an instance of ``subprocess.PIPE``.
207
    before_options: Optional[:class:`str`]
208
        Extra command line arguments to pass to ffmpeg before the ``-i`` flag.
209
    options: Optional[:class:`str`]
210
        Extra command line arguments to pass to ffmpeg after the ``-i`` flag.
211
    Raises
212
    --------
213
    ClientException
214
        The subprocess failed to be created.
215
    """
216
217
    def __init__(
218
        self,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
219
        source: Union[str, io.BufferedIOBase],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
220
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
221
        executable: str = 'ffmpeg',
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
222
        pipe: bool = False,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
223
        stderr: Optional[IO[str]] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
224
        before_options: Optional[str] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
225
        options: Optional[str] = None
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
226
    ) -> None:
227
        args = []
228
        subprocess_kwargs = {'stdin': subprocess.PIPE if pipe else subprocess.DEVNULL, 'stderr': stderr}
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (104/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
229
230
        if isinstance(before_options, str):
231
            args.extend(shlex.split(before_options))
232
233
        args.append('-i')
234
        args.append('-' if pipe else source)
235
        args.extend(('-f', 's16le', '-ar', '48000', '-ac', '2', '-loglevel', 'warning'))
236
237
        if isinstance(options, str):
238
            args.extend(shlex.split(options))
239
240
        args.append('pipe:1')
241
242
        super().__init__(source, executable=executable, args=args, **subprocess_kwargs)
243
244
    def read(self) -> bytes:
245
        ret = self._stdout.read(OpusEncoder.FRAME_SIZE)
246
        if len(ret) != OpusEncoder.FRAME_SIZE:
247
            return b''
248
        return ret
249
250
    def is_opus(self) -> bool:
251
        return False
252
253
class FFmpegOpusAudio(FFmpegAudio):
254
    """An audio source from FFmpeg (or AVConv).
255
    This launches a sub-process to a specific input file given.  However, rather than
256
    producing PCM packets like :class:`FFmpegPCMAudio` does that need to be encoded to
257
    Opus, this class produces Opus packets, skipping the encoding step done by the library.
258
    Alternatively, instead of instantiating this class directly, you can use
259
    :meth:`FFmpegOpusAudio.from_probe` to probe for bitrate and codec information.  This
260
    can be used to opportunistically skip pointless re-encoding of existing Opus audio data
261
    for a boost in performance at the cost of a short initial delay to gather the information.
262
    The same can be achieved by passing ``copy`` to the ``codec`` parameter, but only if you
263
    know that the input source is Opus encoded beforehand.
264
    .. versionadded:: 1.3
265
    .. warning::
266
        You must have the ffmpeg or avconv executable in your path environment
267
        variable in order for this to work.
268
    Parameters
269
    ------------
270
    source: Union[:class:`str`, :class:`io.BufferedIOBase`]
271
        The input that ffmpeg will take and convert to Opus bytes.
272
        If ``pipe`` is ``True`` then this is a file-like object that is
273
        passed to the stdin of ffmpeg.
274
    bitrate: :class:`int`
275
        The bitrate in kbps to encode the output to.  Defaults to ``128``.
276
    codec: Optional[:class:`str`]
277
        The codec to use to encode the audio data.  Normally this would be
278
        just ``libopus``, but is used by :meth:`FFmpegOpusAudio.from_probe` to
279
        opportunistically skip pointlessly re-encoding Opus audio data by passing
280
        ``copy`` as the codec value.  Any values other than ``copy``, ``opus``, or
281
        ``libopus`` will be considered ``libopus``.  Defaults to ``libopus``.
282
        .. warning::
283
            Do not provide this parameter unless you are certain that the audio input is
284
            already Opus encoded.  For typical use :meth:`FFmpegOpusAudio.from_probe`
285
            should be used to determine the proper value for this parameter.
286
    executable: :class:`str`
287
        The executable name (and path) to use. Defaults to ``ffmpeg``.
288
    pipe: :class:`bool`
289
        If ``True``, denotes that ``source`` parameter will be passed
290
        to the stdin of ffmpeg. Defaults to ``False``.
291
    stderr: Optional[:term:`py:file object`]
292
        A file-like object to pass to the Popen constructor.
293
        Could also be an instance of ``subprocess.PIPE``.
294
    before_options: Optional[:class:`str`]
295
        Extra command line arguments to pass to ffmpeg before the ``-i`` flag.
296
    options: Optional[:class:`str`]
297
        Extra command line arguments to pass to ffmpeg after the ``-i`` flag.
298
    Raises
299
    --------
300
    ClientException
301
        The subprocess failed to be created.
302
    """
303
304
    def __init__(
305
        self,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
306
        source: Union[str, io.BufferedIOBase],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
307
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
308
        bitrate: int = 128,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
309
        codec: Optional[str] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
310
        executable: str = 'ffmpeg',
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
311
        pipe=False,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
312
        stderr=None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
313
        before_options=None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
314
        options=None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
315
    ) -> None:
316
317
        args = []
318
        subprocess_kwargs = {'stdin': subprocess.PIPE if pipe else subprocess.DEVNULL, 'stderr': stderr}
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (104/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
319
320
        if isinstance(before_options, str):
321
            args.extend(shlex.split(before_options))
322
323
        args.append('-i')
324
        args.append('-' if pipe else source)
325
326
        codec = 'copy' if codec in ('opus', 'libopus') else 'libopus'
327
328
        args.extend(('-map_metadata', '-1',
329
                     '-f', 'opus',
330
                     '-c:a', codec,
331
                     '-ar', '48000',
332
                     '-ac', '2',
333
                     '-b:a', f'{bitrate}k',
334
                     '-loglevel', 'warning'))
335
336
        if isinstance(options, str):
337
            args.extend(shlex.split(options))
338
339
        args.append('pipe:1')
340
341
        super().__init__(source, executable=executable, args=args, **subprocess_kwargs)
342
        self._packet_iter = OggStream(self._stdout).iter_packets()
343
344
    @classmethod
345
    async def from_probe(
346
        cls: Type[FT],
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
347
        source: str,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
348
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
349
        method: Optional[Union[str, Callable[[str, str], Tuple[Optional[str], Optional[int]]]]] = None,
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (103/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
350
        **kwargs: Any,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
351
    ) -> FT:
352
        """|coro|
353
        A factory method that creates a :class:`FFmpegOpusAudio` after probing
354
        the input source for audio codec and bitrate information.
355
        Examples
356
        ----------
357
        Use this function to create an :class:`FFmpegOpusAudio` instance instead of the constructor: ::
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (103/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
358
            source = await discord.FFmpegOpusAudio.from_probe("song.webm")
359
            voice_client.play(source)
360
        If you are on Windows and don't have ffprobe installed, use the ``fallback`` method
361
        to probe using ffmpeg instead: ::
362
            source = await discord.FFmpegOpusAudio.from_probe("song.webm", method='fallback')
363
            voice_client.play(source)
364
        Using a custom method of determining codec and bitrate: ::
365
            def custom_probe(source, executable):
366
                # some analysis code here
367
                return codec, bitrate
368
            source = await discord.FFmpegOpusAudio.from_probe("song.webm", method=custom_probe)
369
            voice_client.play(source)
370
        Parameters
371
        ------------
372
        source
373
            Identical to the ``source`` parameter for the constructor.
374
        method: Optional[Union[:class:`str`, Callable[:class:`str`, :class:`str`]]]
375
            The probing method used to determine bitrate and codec information. As a string, valid
376
            values are ``native`` to use ffprobe (or avprobe) and ``fallback`` to use ffmpeg
377
            (or avconv).  As a callable, it must take two string arguments, ``source`` and
378
            ``executable``.  Both parameters are the same values passed to this factory function.
379
            ``executable`` will default to ``ffmpeg`` if not provided as a keyword argument.
380
        kwargs
381
            The remaining parameters to be passed to the :class:`FFmpegOpusAudio` constructor,
382
            excluding ``bitrate`` and ``codec``.
383
        Raises
384
        --------
385
        AttributeError
386
            Invalid probe method, must be ``'native'`` or ``'fallback'``.
387
        TypeError
388
            Invalid value for ``probe`` parameter, must be :class:`str` or a callable.
389
        Returns
390
        --------
391
        :class:`FFmpegOpusAudio`
392
            An instance of this class.
393
        """
394
395
        executable = kwargs.get('executable')
396
        codec, bitrate = await cls.probe(source, method=method, executable=executable)
397
        return cls(source, bitrate=bitrate, codec=codec, **kwargs)  # type: ignore
398
399
    @classmethod
400
    async def probe(
401
        cls,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
402
        source: str,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
403
        *,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
404
        method: Optional[Union[str, Callable[[str, str], Tuple[Optional[str], Optional[int]]]]] = None,
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (103/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
405
        executable: Optional[str] = None,
0 ignored issues
show
Coding Style introduced by
Wrong hanging indentation before block (add 4 spaces).
Loading history...
406
    ) -> Tuple[Optional[str], Optional[int]]:
407
        """|coro|
408
        Probes the input source for bitrate and codec information.
409
        Parameters
410
        ------------
411
        source
412
            Identical to the ``source`` parameter for :class:`FFmpegOpusAudio`.
413
        method
414
            Identical to the ``method`` parameter for :meth:`FFmpegOpusAudio.from_probe`.
415
        executable: :class:`str`
416
            Identical to the ``executable`` parameter for :class:`FFmpegOpusAudio`.
417
        Raises
418
        --------
419
        AttributeError
420
            Invalid probe method, must be ``'native'`` or ``'fallback'``.
421
        TypeError
422
            Invalid value for ``probe`` parameter, must be :class:`str` or a callable.
423
        Returns
424
        ---------
425
        Optional[Tuple[Optional[:class:`str`], Optional[:class:`int`]]]
426
            A 2-tuple with the codec and bitrate of the input source.
427
        """
428
429
        method = method or 'native'
430
        executable = executable or 'ffmpeg'
431
        probefunc = fallback = None
432
433
        if isinstance(method, str):
434
            probefunc = getattr(cls, '_probe_codec_' + method, None)
435
            if probefunc is None:
436
                raise AttributeError(f"Invalid probe method {method!r}")
437
438
            if probefunc is cls._probe_codec_native:
439
                fallback = cls._probe_codec_fallback
440
441
        elif callable(method):
442
            probefunc = method
443
            fallback = cls._probe_codec_fallback
444
        else:
445
            raise TypeError("Expected str or callable for parameter 'probe', " \
446
                            f"not '{method.__class__.__name__}'")
447
448
        codec = bitrate = None
449
        loop = asyncio.get_event_loop()
450
        try:
451
            codec, bitrate = await loop.run_in_executor(None, lambda: probefunc(source, executable))  # type: ignore
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (116/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
452
        except Exception:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
453
            if not fallback:
454
                _log.exception("Probe '%s' using '%s' failed", method, executable)
455
                return  # type: ignore
456
457
            _log.exception("Probe '%s' using '%s' failed, trying fallback", method, executable)
458
            try:
459
                codec, bitrate = await loop.run_in_executor(None, lambda: fallback(source, executable))  # type: ignore
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (119/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
460
            except Exception:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
461
                _log.exception("Fallback probe using '%s' failed", executable)
462
            else:
463
                _log.info("Fallback probe found codec=%s, bitrate=%s", codec, bitrate)
464
        else:
465
            _log.info("Probe found codec=%s, bitrate=%s", codec, bitrate)
466
        finally:
467
            return codec, bitrate
0 ignored issues
show
Bug Best Practice introduced by
return statements in finally blocks should be avoided.

Placing a return statement inside finally will swallow all exceptions that may have been thrown in the try block.

Loading history...
468
469
    @staticmethod
470
    def _probe_codec_native(source, executable: str = 'ffmpeg') -> Tuple[Optional[str], Optional[int]]:
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (103/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
471
        exe = executable[:2] + 'probe' if executable in ('ffmpeg', 'avconv') else executable
472
        args = [exe, '-v', 'quiet', '-print_format', 'json', '-show_streams', '-select_streams', 'a:0', source]
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (111/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
473
        output = subprocess.check_output(args, timeout=20)
474
        codec = bitrate = None
475
476
        if output:
477
            data = json.loads(output)
478
            streamdata = data['streams'][0]
479
480
            codec = streamdata.get('codec_name')
481
            bitrate = int(streamdata.get('bit_rate', 0))
482
            bitrate = max(round(bitrate/1000), 512)
483
484
        return codec, bitrate
485
486
    @staticmethod
487
    def _probe_codec_fallback(source, executable: str = 'ffmpeg') -> Tuple[Optional[str], Optional[int]]:
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (105/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
488
        args = [executable, '-hide_banner', '-i',  source]
0 ignored issues
show
Coding Style introduced by
Exactly one space required after comma
Loading history...
489
        proc = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (119/100).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
490
        out, _ = proc.communicate(timeout=20)
491
        output = out.decode('utf8')
492
        codec = bitrate = None
493
494
        codec_match = re.search(r"Stream #0.*?Audio: (\w+)", output)
495
        if codec_match:
496
            codec = codec_match.group(1)
497
498
        br_match = re.search(r"(\d+) [kK]b/s", output)
499
        if br_match:
500
            bitrate = max(int(br_match.group(1)), 512)
501
502
        return codec, bitrate
503
504
    def read(self) -> bytes:
505
        return next(self._packet_iter, b'')
506
507
    def is_opus(self) -> bool:
508
        return True
509
510
class PCMVolumeTransformer(AudioSource, Generic[AT]):
511
    """Transforms a previous :class:`AudioSource` to have volume controls.
512
    This does not work on audio sources that have :meth:`AudioSource.is_opus`
513
    set to ``True``.
514
    Parameters
515
    ------------
516
    original: :class:`AudioSource`
517
        The original AudioSource to transform.
518
    volume: :class:`float`
519
        The initial volume to set it to.
520
        See :attr:`volume` for more info.
521
    Raises
522
    -------
523
    TypeError
524
        Not an audio source.
525
    ClientException
526
        The audio source is opus encoded.
527
    """
528
529
    def __init__(self, original: AT, volume: float = 1.0):
530
        if not isinstance(original, AudioSource):
531
            raise TypeError(f'expected AudioSource not {original.__class__.__name__}.')
532
533
        if original.is_opus():
534
            raise ClientException('AudioSource must not be Opus encoded.')
535
536
        self.original: AT = original
537
        self.volume = volume
538
539
    @property
540
    def volume(self) -> float:
541
        """Retrieves or sets the volume as a floating point percentage (e.g. ``1.0`` for 100%)."""
542
        return self._volume
543
544
    @volume.setter
545
    def volume(self, value: float) -> None:
546
        self._volume = max(value, 0.0)
547
548
    def cleanup(self) -> None:
549
        self.original.cleanup()
550
551
    def read(self) -> bytes:
552
        ret = self.original.read()
553
        return audioop.mul(ret, 2, min(self._volume, 2.0))
554
555
class AudioPlayer(threading.Thread):
0 ignored issues
show
best-practice introduced by
Too many instance attributes (11/7)
Loading history...
introduced by
Missing class docstring
Loading history...
556
    DELAY: float = OpusEncoder.FRAME_LENGTH / 1000.0
557
558
    def __init__(self, source: AudioSource, client: VoiceClient, *, after=None):
559
        threading.Thread.__init__(self)
560
        self.daemon: bool = True
561
        self.source: AudioSource = source
562
        self.client: VoiceClient = client
563
        self.after: Optional[Callable[[Optional[Exception]], Any]] = after
564
565
        self._end: threading.Event = threading.Event()
566
        self._resumed: threading.Event = threading.Event()
567
        self._resumed.set() # we are not paused
568
        self._current_error: Optional[Exception] = None
569
        self._connected: threading.Event = client._connected
570
        self._lock: threading.Lock = threading.Lock()
571
572
        if after is not None and not callable(after):
573
            raise TypeError('Expected a callable for the "after" parameter.')
574
575
    def _do_run(self) -> None:
576
        self.loops = 0
0 ignored issues
show
Coding Style introduced by
The attribute loops was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
577
        self._start = time.perf_counter()
0 ignored issues
show
Coding Style introduced by
The attribute _start was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
578
579
        # getattr lookup speed ups
580
        play_audio = self.client.send_audio_packet
581
        self._speak(True)
582
583
        while not self._end.is_set():
584
            # are we paused?
585
            if not self._resumed.is_set():
586
                # wait until we aren't
587
                self._resumed.wait()
588
                continue
589
590
            # are we disconnected from voice?
591
            if not self._connected.is_set():
592
                # wait until we are connected
593
                self._connected.wait()
594
                # reset our internal data
595
                self.loops = 0
0 ignored issues
show
Coding Style introduced by
The attribute loops was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
596
                self._start = time.perf_counter()
0 ignored issues
show
Coding Style introduced by
The attribute _start was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
597
598
            self.loops += 1
599
            data = self.source.read()
600
601
            if not data:
602
                self.stop()
603
                break
604
605
            play_audio(data, encode=not self.source.is_opus())
606
            next_time = self._start + self.DELAY * self.loops
607
            delay = max(0, self.DELAY + (next_time - time.perf_counter()))
608
            time.sleep(delay)
609
610
    def run(self) -> None:
611
        try:
612
            self._do_run()
613
        except Exception as exc:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
614
            self._current_error = exc
615
            self.stop()
616
        finally:
617
            self.source.cleanup()
618
            self._call_after()
619
620
    def _call_after(self) -> None:
621
        error = self._current_error
622
623
        if self.after is not None:
624
            try:
625
                self.after(error)
626
            except Exception as exc:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
627
                _log.exception('Calling the after function failed.')
628
                exc.__context__ = error
629
                traceback.print_exception(type(exc), exc, exc.__traceback__)
630
        elif error:
631
            msg = f'Exception in voice thread {self.name}'
632
            _log.exception(msg, exc_info=error)
633
            print(msg, file=sys.stderr)
634
            traceback.print_exception(type(error), error, error.__traceback__)
635
636
    def stop(self) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
637
        self._end.set()
638
        self._resumed.set()
639
        self._speak(False)
640
641
    def pause(self, *, update_speaking: bool = True) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
642
        self._resumed.clear()
643
        if update_speaking:
644
            self._speak(False)
645
646
    def resume(self, *, update_speaking: bool = True) -> None:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
647
        self.loops = 0
0 ignored issues
show
Coding Style introduced by
The attribute loops was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
648
        self._start = time.perf_counter()
0 ignored issues
show
Coding Style introduced by
The attribute _start was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
649
        self._resumed.set()
650
        if update_speaking:
651
            self._speak(True)
652
653
    def is_playing(self) -> bool:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
654
        return self._resumed.is_set() and not self._end.is_set()
655
656
    def is_paused(self) -> bool:
0 ignored issues
show
introduced by
Missing function or method docstring
Loading history...
657
        return not self._end.is_set() and not self._resumed.is_set()
658
659
    def _set_source(self, source: AudioSource) -> None:
660
        with self._lock:
661
            self.pause(update_speaking=False)
662
            self.source = source
663
            self.resume(update_speaking=False)
664
665
    def _speak(self, speaking: bool) -> None:
666
        try:
667
            asyncio.run_coroutine_threadsafe(self.client.ws.speak(speaking), self.client.loop)
668
        except Exception as e:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
669
            _log.info("Speaking call in player failed: %s", e)
0 ignored issues
show
Coding Style introduced by
Final newline missing
Loading history...