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 |
|
|
|
|
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: |
|
|
|
|
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 |
|
|
|
|
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): |
|
|
|
|
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): |
|
|
|
|
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") |
|
|
|
|
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 |
|
|
|
|
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) |
|
|
|
|
134
|
|
|
self._pipe_thread.start() |
135
|
|
|
|
136
|
|
|
def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Popen: |
|
|
|
|
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: |
|
|
|
|
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) |
|
|
|
|
164
|
|
|
else: |
165
|
|
|
_log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) |
|
|
|
|
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: |
|
|
|
|
178
|
|
|
_log.debug('Write error for %s, this is probably not a problem', self, exc_info=True) |
|
|
|
|
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, |
|
|
|
|
219
|
|
|
source: Union[str, io.BufferedIOBase], |
|
|
|
|
220
|
|
|
*, |
|
|
|
|
221
|
|
|
executable: str = 'ffmpeg', |
|
|
|
|
222
|
|
|
pipe: bool = False, |
|
|
|
|
223
|
|
|
stderr: Optional[IO[str]] = None, |
|
|
|
|
224
|
|
|
before_options: Optional[str] = None, |
|
|
|
|
225
|
|
|
options: Optional[str] = None |
|
|
|
|
226
|
|
|
) -> None: |
227
|
|
|
args = [] |
228
|
|
|
subprocess_kwargs = {'stdin': subprocess.PIPE if pipe else subprocess.DEVNULL, 'stderr': stderr} |
|
|
|
|
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, |
|
|
|
|
306
|
|
|
source: Union[str, io.BufferedIOBase], |
|
|
|
|
307
|
|
|
*, |
|
|
|
|
308
|
|
|
bitrate: int = 128, |
|
|
|
|
309
|
|
|
codec: Optional[str] = None, |
|
|
|
|
310
|
|
|
executable: str = 'ffmpeg', |
|
|
|
|
311
|
|
|
pipe=False, |
|
|
|
|
312
|
|
|
stderr=None, |
|
|
|
|
313
|
|
|
before_options=None, |
|
|
|
|
314
|
|
|
options=None, |
|
|
|
|
315
|
|
|
) -> None: |
316
|
|
|
|
317
|
|
|
args = [] |
318
|
|
|
subprocess_kwargs = {'stdin': subprocess.PIPE if pipe else subprocess.DEVNULL, 'stderr': stderr} |
|
|
|
|
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], |
|
|
|
|
347
|
|
|
source: str, |
|
|
|
|
348
|
|
|
*, |
|
|
|
|
349
|
|
|
method: Optional[Union[str, Callable[[str, str], Tuple[Optional[str], Optional[int]]]]] = None, |
|
|
|
|
350
|
|
|
**kwargs: Any, |
|
|
|
|
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: :: |
|
|
|
|
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, |
|
|
|
|
402
|
|
|
source: str, |
|
|
|
|
403
|
|
|
*, |
|
|
|
|
404
|
|
|
method: Optional[Union[str, Callable[[str, str], Tuple[Optional[str], Optional[int]]]]] = None, |
|
|
|
|
405
|
|
|
executable: Optional[str] = None, |
|
|
|
|
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 |
|
|
|
|
452
|
|
|
except Exception: |
|
|
|
|
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 |
|
|
|
|
460
|
|
|
except Exception: |
|
|
|
|
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 |
|
|
|
|
468
|
|
|
|
469
|
|
|
@staticmethod |
470
|
|
|
def _probe_codec_native(source, executable: str = 'ffmpeg') -> Tuple[Optional[str], Optional[int]]: |
|
|
|
|
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] |
|
|
|
|
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]]: |
|
|
|
|
488
|
|
|
args = [executable, '-hide_banner', '-i', source] |
|
|
|
|
489
|
|
|
proc = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
|
|
|
|
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): |
|
|
|
|
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 |
|
|
|
|
577
|
|
|
self._start = time.perf_counter() |
|
|
|
|
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 |
|
|
|
|
596
|
|
|
self._start = time.perf_counter() |
|
|
|
|
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: |
|
|
|
|
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: |
|
|
|
|
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: |
|
|
|
|
637
|
|
|
self._end.set() |
638
|
|
|
self._resumed.set() |
639
|
|
|
self._speak(False) |
640
|
|
|
|
641
|
|
|
def pause(self, *, update_speaking: bool = True) -> None: |
|
|
|
|
642
|
|
|
self._resumed.clear() |
643
|
|
|
if update_speaking: |
644
|
|
|
self._speak(False) |
645
|
|
|
|
646
|
|
|
def resume(self, *, update_speaking: bool = True) -> None: |
|
|
|
|
647
|
|
|
self.loops = 0 |
|
|
|
|
648
|
|
|
self._start = time.perf_counter() |
|
|
|
|
649
|
|
|
self._resumed.set() |
650
|
|
|
if update_speaking: |
651
|
|
|
self._speak(True) |
652
|
|
|
|
653
|
|
|
def is_playing(self) -> bool: |
|
|
|
|
654
|
|
|
return self._resumed.is_set() and not self._end.is_set() |
655
|
|
|
|
656
|
|
|
def is_paused(self) -> bool: |
|
|
|
|
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: |
|
|
|
|
669
|
|
|
_log.info("Speaking call in player failed: %s", e) |
|
|
|
|