Browse Source

Type-hint voice_client / player

pull/7122/head
Josh 4 years ago
committed by GitHub
parent
commit
5acea453cc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 167
      discord/player.py
  2. 26
      discord/types/voice.py
  3. 164
      discord/voice_client.py

167
discord/player.py

@ -21,6 +21,7 @@ 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 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
""" """
from __future__ import annotations
import threading import threading
import traceback import traceback
@ -33,12 +34,23 @@ import time
import json import json
import sys import sys
import re import re
import io
from typing import Any, Callable, Generic, IO, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union
from .errors import ClientException from .errors import ClientException
from .opus import Encoder as OpusEncoder from .opus import Encoder as OpusEncoder
from .oggparse import OggStream from .oggparse import OggStream
from .utils import MISSING
if TYPE_CHECKING:
from .voice_client import VoiceClient
log = logging.getLogger(__name__) AT = TypeVar('AT', bound='AudioSource')
FT = TypeVar('FT', bound='FFmpegOpusAudio')
log: logging.Logger = logging.getLogger(__name__)
__all__ = ( __all__ = (
'AudioSource', 'AudioSource',
@ -49,6 +61,8 @@ __all__ = (
'PCMVolumeTransformer', 'PCMVolumeTransformer',
) )
CREATE_NO_WINDOW: int
if sys.platform != 'win32': if sys.platform != 'win32':
CREATE_NO_WINDOW = 0 CREATE_NO_WINDOW = 0
else: else:
@ -65,7 +79,7 @@ class AudioSource:
The audio source reads are done in a separate thread. The audio source reads are done in a separate thread.
""" """
def read(self): def read(self) -> bytes:
"""Reads 20ms worth of audio. """Reads 20ms worth of audio.
Subclasses must implement this. Subclasses must implement this.
@ -85,11 +99,11 @@ class AudioSource:
""" """
raise NotImplementedError raise NotImplementedError
def is_opus(self): def is_opus(self) -> bool:
"""Checks if the audio source is already encoded in Opus.""" """Checks if the audio source is already encoded in Opus."""
return False return False
def cleanup(self): def cleanup(self) -> None:
"""Called when clean-up is needed to be done. """Called when clean-up is needed to be done.
Useful for clearing buffer data or processes after Useful for clearing buffer data or processes after
@ -97,7 +111,7 @@ class AudioSource:
""" """
pass pass
def __del__(self): def __del__(self) -> None:
self.cleanup() self.cleanup()
class PCMAudio(AudioSource): class PCMAudio(AudioSource):
@ -108,10 +122,10 @@ class PCMAudio(AudioSource):
stream: :term:`py:file object` stream: :term:`py:file object`
A file-like object that reads byte data representing raw PCM. A file-like object that reads byte data representing raw PCM.
""" """
def __init__(self, stream): def __init__(self, stream: io.BufferedIOBase) -> None:
self.stream = stream self.stream: io.BufferedIOBase = stream
def read(self): def read(self) -> bytes:
ret = self.stream.read(OpusEncoder.FRAME_SIZE) ret = self.stream.read(OpusEncoder.FRAME_SIZE)
if len(ret) != OpusEncoder.FRAME_SIZE: if len(ret) != OpusEncoder.FRAME_SIZE:
return b'' return b''
@ -126,17 +140,15 @@ class FFmpegAudio(AudioSource):
.. versionadded:: 1.3 .. versionadded:: 1.3
""" """
def __init__(self, source, *, executable='ffmpeg', args, **subprocess_kwargs): def __init__(self, source: str, *, executable: str = 'ffmpeg', args: Any, **subprocess_kwargs: Any):
self._process = self._stdout = None
args = [executable, *args] args = [executable, *args]
kwargs = {'stdout': subprocess.PIPE} kwargs = {'stdout': subprocess.PIPE}
kwargs.update(subprocess_kwargs) kwargs.update(subprocess_kwargs)
self._process = self._spawn_process(args, **kwargs) self._process: subprocess.Popen = self._spawn_process(args, **kwargs)
self._stdout = self._process.stdout self._stdout: IO[bytes] = self._process.stdout # type: ignore
def _spawn_process(self, args, **subprocess_kwargs): def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Popen:
process = None process = None
try: try:
process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, **subprocess_kwargs) process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, **subprocess_kwargs)
@ -148,9 +160,9 @@ class FFmpegAudio(AudioSource):
else: else:
return process return process
def cleanup(self): def cleanup(self) -> None:
proc = self._process proc = self._process
if proc is None: if proc is MISSING:
return return
log.info('Preparing to terminate ffmpeg process %s.', proc.pid) log.info('Preparing to terminate ffmpeg process %s.', proc.pid)
@ -167,7 +179,7 @@ class FFmpegAudio(AudioSource):
else: else:
log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode)
self._process = self._stdout = None self._process = self._stdout = MISSING
class FFmpegPCMAudio(FFmpegAudio): class FFmpegPCMAudio(FFmpegAudio):
"""An audio source from FFmpeg (or AVConv). """An audio source from FFmpeg (or AVConv).
@ -204,7 +216,16 @@ class FFmpegPCMAudio(FFmpegAudio):
The subprocess failed to be created. The subprocess failed to be created.
""" """
def __init__(self, source, *, executable='ffmpeg', pipe=False, stderr=None, before_options=None, options=None): def __init__(
self,
source: str,
*,
executable: str = 'ffmpeg',
pipe: bool = False,
stderr: Optional[IO[str]] = None,
before_options: Optional[str] = None,
options: Optional[str] = None
) -> None:
args = [] args = []
subprocess_kwargs = {'stdin': source if pipe else subprocess.DEVNULL, 'stderr': stderr} subprocess_kwargs = {'stdin': source if pipe else subprocess.DEVNULL, 'stderr': stderr}
@ -222,13 +243,13 @@ class FFmpegPCMAudio(FFmpegAudio):
super().__init__(source, executable=executable, args=args, **subprocess_kwargs) super().__init__(source, executable=executable, args=args, **subprocess_kwargs)
def read(self): def read(self) -> bytes:
ret = self._stdout.read(OpusEncoder.FRAME_SIZE) ret = self._stdout.read(OpusEncoder.FRAME_SIZE)
if len(ret) != OpusEncoder.FRAME_SIZE: if len(ret) != OpusEncoder.FRAME_SIZE:
return b'' return b''
return ret return ret
def is_opus(self): def is_opus(self) -> bool:
return False return False
class FFmpegOpusAudio(FFmpegAudio): class FFmpegOpusAudio(FFmpegAudio):
@ -292,8 +313,18 @@ class FFmpegOpusAudio(FFmpegAudio):
The subprocess failed to be created. The subprocess failed to be created.
""" """
def __init__(self, source, *, bitrate=128, codec=None, executable='ffmpeg', def __init__(
pipe=False, stderr=None, before_options=None, options=None): self,
source: str,
*,
bitrate: int = 128,
codec: Optional[str] = None,
executable: str = 'ffmpeg',
pipe=False,
stderr=None,
before_options=None,
options=None,
) -> None:
args = [] args = []
subprocess_kwargs = {'stdin': source if pipe else subprocess.DEVNULL, 'stderr': stderr} subprocess_kwargs = {'stdin': source if pipe else subprocess.DEVNULL, 'stderr': stderr}
@ -323,7 +354,13 @@ class FFmpegOpusAudio(FFmpegAudio):
self._packet_iter = OggStream(self._stdout).iter_packets() self._packet_iter = OggStream(self._stdout).iter_packets()
@classmethod @classmethod
async def from_probe(cls, source, *, method=None, **kwargs): async def from_probe(
cls: Type[FT],
source: str,
*,
method: Optional[Union[str, Callable[[str, str], Tuple[Optional[str], Optional[int]]]]] = None,
**kwargs: Any,
) -> FT:
"""|coro| """|coro|
A factory method that creates a :class:`FFmpegOpusAudio` after probing A factory method that creates a :class:`FFmpegOpusAudio` after probing
@ -382,10 +419,16 @@ class FFmpegOpusAudio(FFmpegAudio):
executable = kwargs.get('executable') executable = kwargs.get('executable')
codec, bitrate = await cls.probe(source, method=method, executable=executable) codec, bitrate = await cls.probe(source, method=method, executable=executable)
return cls(source, bitrate=bitrate, codec=codec, **kwargs) return cls(source, bitrate=bitrate, codec=codec, **kwargs) # type: ignore
@classmethod @classmethod
async def probe(cls, source, *, method=None, executable=None): async def probe(
cls,
source: str,
*,
method: Optional[Union[str, Callable[[str, str], Tuple[Optional[str], Optional[int]]]]] = None,
executable: Optional[str] = None,
) -> Tuple[Optional[str], Optional[int]]:
"""|coro| """|coro|
Probes the input source for bitrate and codec information. Probes the input source for bitrate and codec information.
@ -408,7 +451,7 @@ class FFmpegOpusAudio(FFmpegAudio):
Returns Returns
--------- ---------
Tuple[Optional[:class:`str`], Optional[:class:`int`]] Optional[Tuple[Optional[:class:`str`], Optional[:class:`int`]]]
A 2-tuple with the codec and bitrate of the input source. A 2-tuple with the codec and bitrate of the input source.
""" """
@ -434,15 +477,15 @@ class FFmpegOpusAudio(FFmpegAudio):
codec = bitrate = None codec = bitrate = None
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: try:
codec, bitrate = await loop.run_in_executor(None, lambda: probefunc(source, executable)) codec, bitrate = await loop.run_in_executor(None, lambda: probefunc(source, executable)) # type: ignore
except Exception: except Exception:
if not fallback: if not fallback:
log.exception("Probe '%s' using '%s' failed", method, executable) log.exception("Probe '%s' using '%s' failed", method, executable)
return return # type: ignore
log.exception("Probe '%s' using '%s' failed, trying fallback", method, executable) log.exception("Probe '%s' using '%s' failed, trying fallback", method, executable)
try: try:
codec, bitrate = await loop.run_in_executor(None, lambda: fallback(source, executable)) codec, bitrate = await loop.run_in_executor(None, lambda: fallback(source, executable)) # type: ignore
except Exception: except Exception:
log.exception("Fallback probe using '%s' failed", executable) log.exception("Fallback probe using '%s' failed", executable)
else: else:
@ -453,7 +496,7 @@ class FFmpegOpusAudio(FFmpegAudio):
return codec, bitrate return codec, bitrate
@staticmethod @staticmethod
def _probe_codec_native(source, executable='ffmpeg'): def _probe_codec_native(source, executable: str = 'ffmpeg') -> Tuple[Optional[str], Optional[int]]:
exe = executable[:2] + 'probe' if executable in ('ffmpeg', 'avconv') else executable 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] args = [exe, '-v', 'quiet', '-print_format', 'json', '-show_streams', '-select_streams', 'a:0', source]
output = subprocess.check_output(args, timeout=20) output = subprocess.check_output(args, timeout=20)
@ -465,12 +508,12 @@ class FFmpegOpusAudio(FFmpegAudio):
codec = streamdata.get('codec_name') codec = streamdata.get('codec_name')
bitrate = int(streamdata.get('bit_rate', 0)) bitrate = int(streamdata.get('bit_rate', 0))
bitrate = max(round(bitrate/1000, 0), 512) bitrate = max(round(bitrate/1000), 512)
return codec, bitrate return codec, bitrate
@staticmethod @staticmethod
def _probe_codec_fallback(source, executable='ffmpeg'): def _probe_codec_fallback(source, executable: str = 'ffmpeg') -> Tuple[Optional[str], Optional[int]]:
args = [executable, '-hide_banner', '-i', source] args = [executable, '-hide_banner', '-i', source]
proc = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) proc = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
out, _ = proc.communicate(timeout=20) out, _ = proc.communicate(timeout=20)
@ -487,13 +530,13 @@ class FFmpegOpusAudio(FFmpegAudio):
return codec, bitrate return codec, bitrate
def read(self): def read(self) -> bytes:
return next(self._packet_iter, b'') return next(self._packet_iter, b'')
def is_opus(self): def is_opus(self) -> bool:
return True return True
class PCMVolumeTransformer(AudioSource): class PCMVolumeTransformer(AudioSource, Generic[AT]):
"""Transforms a previous :class:`AudioSource` to have volume controls. """Transforms a previous :class:`AudioSource` to have volume controls.
This does not work on audio sources that have :meth:`AudioSource.is_opus` This does not work on audio sources that have :meth:`AudioSource.is_opus`
@ -515,53 +558,53 @@ class PCMVolumeTransformer(AudioSource):
The audio source is opus encoded. The audio source is opus encoded.
""" """
def __init__(self, original, volume=1.0): def __init__(self, original: AT, volume: float = 1.0):
if not isinstance(original, AudioSource): if not isinstance(original, AudioSource):
raise TypeError(f'expected AudioSource not {original.__class__.__name__}.') raise TypeError(f'expected AudioSource not {original.__class__.__name__}.')
if original.is_opus(): if original.is_opus():
raise ClientException('AudioSource must not be Opus encoded.') raise ClientException('AudioSource must not be Opus encoded.')
self.original = original self.original: AT = original
self.volume = volume self.volume = volume
@property @property
def volume(self): def volume(self) -> float:
"""Retrieves or sets the volume as a floating point percentage (e.g. ``1.0`` for 100%).""" """Retrieves or sets the volume as a floating point percentage (e.g. ``1.0`` for 100%)."""
return self._volume return self._volume
@volume.setter @volume.setter
def volume(self, value): def volume(self, value: float) -> None:
self._volume = max(value, 0.0) self._volume = max(value, 0.0)
def cleanup(self): def cleanup(self) -> None:
self.original.cleanup() self.original.cleanup()
def read(self): def read(self) -> bytes:
ret = self.original.read() ret = self.original.read()
return audioop.mul(ret, 2, min(self._volume, 2.0)) return audioop.mul(ret, 2, min(self._volume, 2.0))
class AudioPlayer(threading.Thread): class AudioPlayer(threading.Thread):
DELAY = OpusEncoder.FRAME_LENGTH / 1000.0 DELAY: float = OpusEncoder.FRAME_LENGTH / 1000.0
def __init__(self, source, client, *, after=None): def __init__(self, source: AudioSource, client: VoiceClient, *, after=None):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.daemon = True self.daemon: bool = True
self.source = source self.source: AudioSource = source
self.client = client self.client: VoiceClient = client
self.after = after self.after: Optional[Callable[[Optional[Exception]], Any]] = after
self._end = threading.Event() self._end: threading.Event = threading.Event()
self._resumed = threading.Event() self._resumed: threading.Event = threading.Event()
self._resumed.set() # we are not paused self._resumed.set() # we are not paused
self._current_error = None self._current_error: Optional[Exception] = None
self._connected = client._connected self._connected: threading.Event = client._connected
self._lock = threading.Lock() self._lock: threading.Lock = threading.Lock()
if after is not None and not callable(after): if after is not None and not callable(after):
raise TypeError('Expected a callable for the "after" parameter.') raise TypeError('Expected a callable for the "after" parameter.')
def _do_run(self): def _do_run(self) -> None:
self.loops = 0 self.loops = 0
self._start = time.perf_counter() self._start = time.perf_counter()
@ -596,7 +639,7 @@ class AudioPlayer(threading.Thread):
delay = max(0, self.DELAY + (next_time - time.perf_counter())) delay = max(0, self.DELAY + (next_time - time.perf_counter()))
time.sleep(delay) time.sleep(delay)
def run(self): def run(self) -> None:
try: try:
self._do_run() self._do_run()
except Exception as exc: except Exception as exc:
@ -606,7 +649,7 @@ class AudioPlayer(threading.Thread):
self.source.cleanup() self.source.cleanup()
self._call_after() self._call_after()
def _call_after(self): def _call_after(self) -> None:
error = self._current_error error = self._current_error
if self.after is not None: if self.after is not None:
@ -622,36 +665,36 @@ class AudioPlayer(threading.Thread):
print(msg, file=sys.stderr) print(msg, file=sys.stderr)
traceback.print_exception(type(error), error, error.__traceback__) traceback.print_exception(type(error), error, error.__traceback__)
def stop(self): def stop(self) -> None:
self._end.set() self._end.set()
self._resumed.set() self._resumed.set()
self._speak(False) self._speak(False)
def pause(self, *, update_speaking=True): def pause(self, *, update_speaking: bool = True) -> None:
self._resumed.clear() self._resumed.clear()
if update_speaking: if update_speaking:
self._speak(False) self._speak(False)
def resume(self, *, update_speaking=True): def resume(self, *, update_speaking: bool = True) -> None:
self.loops = 0 self.loops = 0
self._start = time.perf_counter() self._start = time.perf_counter()
self._resumed.set() self._resumed.set()
if update_speaking: if update_speaking:
self._speak(True) self._speak(True)
def is_playing(self): def is_playing(self) -> bool:
return self._resumed.is_set() and not self._end.is_set() return self._resumed.is_set() and not self._end.is_set()
def is_paused(self): def is_paused(self) -> bool:
return not self._end.is_set() and not self._resumed.is_set() return not self._end.is_set() and not self._resumed.is_set()
def _set_source(self, source): def _set_source(self, source: AudioSource) -> None:
with self._lock: with self._lock:
self.pause(update_speaking=False) self.pause(update_speaking=False)
self.source = source self.source = source
self.resume(update_speaking=False) self.resume(update_speaking=False)
def _speak(self, speaking): def _speak(self, speaking: bool) -> None:
try: try:
asyncio.run_coroutine_threadsafe(self.client.ws.speak(speaking), self.client.loop) asyncio.run_coroutine_threadsafe(self.client.ws.speak(speaking), self.client.loop)
except Exception as e: except Exception as e:

26
discord/types/voice.py

@ -22,11 +22,14 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
""" """
from typing import Optional, TypedDict from typing import Optional, TypedDict, List, Literal
from .snowflake import Snowflake from .snowflake import Snowflake
from .member import Member from .member import Member
SupportedModes = Literal['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305']
class _PartialVoiceStateOptional(TypedDict, total=False): class _PartialVoiceStateOptional(TypedDict, total=False):
member: Member member: Member
self_stream: bool self_stream: bool
@ -59,3 +62,24 @@ class VoiceRegion(TypedDict):
optimal: bool optimal: bool
deprecated: bool deprecated: bool
custom: bool custom: bool
class VoiceServerUpdate(TypedDict):
token: str
guild_id: Snowflake
endpoint: Optional[str]
class VoiceIdentify(TypedDict):
server_id: Snowflake
user_id: Snowflake
session_id: str
token: str
class VoiceReady(TypedDict):
ssrc: int
ip: str
port: int
modes: List[SupportedModes]
heartbeat_interval: int

164
discord/voice_client.py

@ -20,9 +20,9 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 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 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
"""
"""Some documentation to refer to:
Some documentation to refer to:
- Our main web socket (mWS) sends opcode 4 with a guild ID and channel ID. - Our main web socket (mWS) sends opcode 4 with a guild ID and channel ID.
- The mWS receives VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. - The mWS receives VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE.
@ -37,21 +37,41 @@ DEALINGS IN THE SOFTWARE.
- Finally we can transmit data to endpoint:port. - Finally we can transmit data to endpoint:port.
""" """
from __future__ import annotations
import asyncio import asyncio
import socket import socket
import logging import logging
import struct import struct
import threading import threading
from typing import Any, Callable from typing import Any, Callable, List, Optional, TYPE_CHECKING, Tuple
from . import opus, utils from . import opus, utils
from .backoff import ExponentialBackoff from .backoff import ExponentialBackoff
from .gateway import * from .gateway import *
from .errors import ClientException, ConnectionClosed from .errors import ClientException, ConnectionClosed
from .player import AudioPlayer, AudioSource from .player import AudioPlayer, AudioSource
from .utils import MISSING
if TYPE_CHECKING:
from .client import Client
from .guild import Guild
from .state import ConnectionState
from .user import ClientUser
from .opus import Encoder
from . import abc
from .types.voice import (
GuildVoiceState as GuildVoiceStatePayload,
VoiceServerUpdate as VoiceServerUpdatePayload,
SupportedModes,
)
has_nacl: bool
try: try:
import nacl.secret import nacl.secret # type: ignore
has_nacl = True has_nacl = True
except ImportError: except ImportError:
has_nacl = False has_nacl = False
@ -61,7 +81,10 @@ __all__ = (
'VoiceClient', 'VoiceClient',
) )
log = logging.getLogger(__name__)
log: logging.Logger = logging.getLogger(__name__)
class VoiceProtocol: class VoiceProtocol:
"""A class that represents the Discord voice protocol. """A class that represents the Discord voice protocol.
@ -84,11 +107,11 @@ class VoiceProtocol:
The voice channel that is being connected to. The voice channel that is being connected to.
""" """
def __init__(self, client, channel): def __init__(self, client: Client, channel: abc.Connectable) -> None:
self.client = client self.client: Client = client
self.channel = channel self.channel: abc.Connectable = channel
async def on_voice_state_update(self, data): async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None:
"""|coro| """|coro|
An abstract method that is called when the client's voice state An abstract method that is called when the client's voice state
@ -105,7 +128,7 @@ class VoiceProtocol:
""" """
raise NotImplementedError raise NotImplementedError
async def on_voice_server_update(self, data): async def on_voice_server_update(self, data: VoiceServerUpdatePayload) -> None:
"""|coro| """|coro|
An abstract method that is called when initially connecting to voice. An abstract method that is called when initially connecting to voice.
@ -122,7 +145,7 @@ class VoiceProtocol:
""" """
raise NotImplementedError raise NotImplementedError
async def connect(self, *, timeout: float, reconnect: bool): async def connect(self, *, timeout: float, reconnect: bool) -> None:
"""|coro| """|coro|
An abstract method called when the client initiates the connection request. An abstract method called when the client initiates the connection request.
@ -145,7 +168,7 @@ class VoiceProtocol:
""" """
raise NotImplementedError raise NotImplementedError
async def disconnect(self, *, force: bool): async def disconnect(self, *, force: bool) -> None:
"""|coro| """|coro|
An abstract method called when the client terminates the connection. An abstract method called when the client terminates the connection.
@ -159,7 +182,7 @@ class VoiceProtocol:
""" """
raise NotImplementedError raise NotImplementedError
def cleanup(self): def cleanup(self) -> None:
"""This method *must* be called to ensure proper clean-up during a disconnect. """This method *must* be called to ensure proper clean-up during a disconnect.
It is advisable to call this from within :meth:`disconnect` when you are It is advisable to call this from within :meth:`disconnect` when you are
@ -198,48 +221,55 @@ class VoiceClient(VoiceProtocol):
loop: :class:`asyncio.AbstractEventLoop` loop: :class:`asyncio.AbstractEventLoop`
The event loop that the voice client is running on. The event loop that the voice client is running on.
""" """
def __init__(self, client, channel): endpoint_ip: str
voice_port: int
secret_key: List[int]
ssrc: int
def __init__(self, client: Client, channel: abc.Connectable):
if not has_nacl: if not has_nacl:
raise RuntimeError("PyNaCl library needed in order to use voice") raise RuntimeError("PyNaCl library needed in order to use voice")
super().__init__(client, channel) super().__init__(client, channel)
state = client._connection state = client._connection
self.token = None self.token: str = MISSING
self.socket = None self.socket = MISSING
self.loop = state.loop self.loop: asyncio.AbstractEventLoop = state.loop
self._state = state self._state: ConnectionState = state
# this will be used in the AudioPlayer thread # this will be used in the AudioPlayer thread
self._connected = threading.Event() self._connected: threading.Event = threading.Event()
self._handshaking = False self._handshaking: bool = False
self._potentially_reconnecting = False self._potentially_reconnecting: bool = False
self._voice_state_complete = asyncio.Event() self._voice_state_complete: asyncio.Event = asyncio.Event()
self._voice_server_complete = asyncio.Event() self._voice_server_complete: asyncio.Event = asyncio.Event()
self.mode = None self.mode: str = MISSING
self._connections = 0 self._connections: int = 0
self.sequence = 0 self.sequence: int = 0
self.timestamp = 0 self.timestamp: int = 0
self._runner = None self.timeout: float = 0
self._player = None self._runner: asyncio.Task = MISSING
self.encoder = None self._player: Optional[AudioPlayer] = None
self._lite_nonce = 0 self.encoder: Encoder = MISSING
self.ws = None self._lite_nonce: int = 0
self.ws: DiscordVoiceWebSocket = MISSING
warn_nacl = not has_nacl warn_nacl = not has_nacl
supported_modes = ( supported_modes: Tuple[SupportedModes, ...] = (
'xsalsa20_poly1305_lite', 'xsalsa20_poly1305_lite',
'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305_suffix',
'xsalsa20_poly1305', 'xsalsa20_poly1305',
) )
@property @property
def guild(self): def guild(self) -> Optional[Guild]:
"""Optional[:class:`Guild`]: The guild we're connected to, if applicable.""" """Optional[:class:`Guild`]: The guild we're connected to, if applicable."""
return getattr(self.channel, 'guild', None) return getattr(self.channel, 'guild', None)
@property @property
def user(self): def user(self) -> ClientUser:
""":class:`ClientUser`: The user connected to voice (i.e. ourselves).""" """:class:`ClientUser`: The user connected to voice (i.e. ourselves)."""
return self._state.user return self._state.user
@ -252,7 +282,7 @@ class VoiceClient(VoiceProtocol):
# connection related # connection related
async def on_voice_state_update(self, data): async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None:
self.session_id = data['session_id'] self.session_id = data['session_id']
channel_id = data['channel_id'] channel_id = data['channel_id']
@ -265,11 +295,11 @@ class VoiceClient(VoiceProtocol):
await self.disconnect() await self.disconnect()
else: else:
guild = self.guild guild = self.guild
self.channel = channel_id and guild and guild.get_channel(int(channel_id)) self.channel = channel_id and guild and guild.get_channel(int(channel_id)) # type: ignore
else: else:
self._voice_state_complete.set() self._voice_state_complete.set()
async def on_voice_server_update(self, data): async def on_voice_server_update(self, data: VoiceServerUpdatePayload) -> None:
if self._voice_server_complete.is_set(): if self._voice_server_complete.is_set():
log.info('Ignoring extraneous voice server update.') log.info('Ignoring extraneous voice server update.')
return return
@ -289,7 +319,7 @@ class VoiceClient(VoiceProtocol):
self.endpoint = self.endpoint[6:] self.endpoint = self.endpoint[6:]
# This gets set later # This gets set later
self.endpoint_ip = None self.endpoint_ip = MISSING
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.socket.setblocking(False) self.socket.setblocking(False)
@ -301,27 +331,27 @@ class VoiceClient(VoiceProtocol):
self._voice_server_complete.set() self._voice_server_complete.set()
async def voice_connect(self): async def voice_connect(self) -> None:
await self.channel.guild.change_voice_state(channel=self.channel) await self.channel.guild.change_voice_state(channel=self.channel)
async def voice_disconnect(self): async def voice_disconnect(self) -> None:
log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', self.channel.id, self.guild.id) log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', self.channel.id, self.guild.id)
await self.channel.guild.change_voice_state(channel=None) await self.channel.guild.change_voice_state(channel=None)
def prepare_handshake(self): def prepare_handshake(self) -> None:
self._voice_state_complete.clear() self._voice_state_complete.clear()
self._voice_server_complete.clear() self._voice_server_complete.clear()
self._handshaking = True self._handshaking = True
log.info('Starting voice handshake... (connection attempt %d)', self._connections + 1) log.info('Starting voice handshake... (connection attempt %d)', self._connections + 1)
self._connections += 1 self._connections += 1
def finish_handshake(self): def finish_handshake(self) -> None:
log.info('Voice handshake complete. Endpoint found %s', self.endpoint) log.info('Voice handshake complete. Endpoint found %s', self.endpoint)
self._handshaking = False self._handshaking = False
self._voice_server_complete.clear() self._voice_server_complete.clear()
self._voice_state_complete.clear() self._voice_state_complete.clear()
async def connect_websocket(self): async def connect_websocket(self) -> DiscordVoiceWebSocket:
ws = await DiscordVoiceWebSocket.from_client(self) ws = await DiscordVoiceWebSocket.from_client(self)
self._connected.clear() self._connected.clear()
while ws.secret_key is None: while ws.secret_key is None:
@ -329,7 +359,7 @@ class VoiceClient(VoiceProtocol):
self._connected.set() self._connected.set()
return ws return ws
async def connect(self, *, reconnect: bool, timeout: bool): async def connect(self, *, reconnect: bool, timeout: float) ->None:
log.info('Connecting to voice...') log.info('Connecting to voice...')
self.timeout = timeout self.timeout = timeout
@ -365,10 +395,10 @@ class VoiceClient(VoiceProtocol):
else: else:
raise raise
if self._runner is None: if self._runner is MISSING:
self._runner = self.loop.create_task(self.poll_voice_ws(reconnect)) self._runner = self.loop.create_task(self.poll_voice_ws(reconnect))
async def potential_reconnect(self): async def potential_reconnect(self) -> bool:
# Attempt to stop the player thread from playing early # Attempt to stop the player thread from playing early
self._connected.clear() self._connected.clear()
self.prepare_handshake() self.prepare_handshake()
@ -391,7 +421,7 @@ class VoiceClient(VoiceProtocol):
return True return True
@property @property
def latency(self): def latency(self) -> float:
""":class:`float`: Latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. """:class:`float`: Latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.
This could be referred to as the Discord Voice WebSocket latency and is This could be referred to as the Discord Voice WebSocket latency and is
@ -403,7 +433,7 @@ class VoiceClient(VoiceProtocol):
return float("inf") if not ws else ws.latency return float("inf") if not ws else ws.latency
@property @property
def average_latency(self): def average_latency(self) -> float:
""":class:`float`: Average of most recent 20 HEARTBEAT latencies in seconds. """:class:`float`: Average of most recent 20 HEARTBEAT latencies in seconds.
.. versionadded:: 1.4 .. versionadded:: 1.4
@ -411,7 +441,7 @@ class VoiceClient(VoiceProtocol):
ws = self.ws ws = self.ws
return float("inf") if not ws else ws.average_latency return float("inf") if not ws else ws.average_latency
async def poll_voice_ws(self, reconnect): async def poll_voice_ws(self, reconnect: bool) -> None:
backoff = ExponentialBackoff() backoff = ExponentialBackoff()
while True: while True:
try: try:
@ -452,7 +482,7 @@ class VoiceClient(VoiceProtocol):
log.warning('Could not connect to voice... Retrying...') log.warning('Could not connect to voice... Retrying...')
continue continue
async def disconnect(self, *, force: bool = False): async def disconnect(self, *, force: bool = False) -> None:
"""|coro| """|coro|
Disconnects this voice client from voice. Disconnects this voice client from voice.
@ -473,7 +503,7 @@ class VoiceClient(VoiceProtocol):
if self.socket: if self.socket:
self.socket.close() self.socket.close()
async def move_to(self, channel): async def move_to(self, channel: abc.Snowflake) -> None:
"""|coro| """|coro|
Moves you to a different voice channel. Moves you to a different voice channel.
@ -485,7 +515,7 @@ class VoiceClient(VoiceProtocol):
""" """
await self.channel.guild.change_voice_state(channel=channel) await self.channel.guild.change_voice_state(channel=channel)
def is_connected(self): def is_connected(self) -> bool:
"""Indicates if the voice client is connected to voice.""" """Indicates if the voice client is connected to voice."""
return self._connected.is_set() return self._connected.is_set()
@ -504,20 +534,20 @@ class VoiceClient(VoiceProtocol):
encrypt_packet = getattr(self, '_encrypt_' + self.mode) encrypt_packet = getattr(self, '_encrypt_' + self.mode)
return encrypt_packet(header, data) return encrypt_packet(header, data)
def _encrypt_xsalsa20_poly1305(self, header, data): def _encrypt_xsalsa20_poly1305(self, header: bytes, data) -> bytes:
box = nacl.secret.SecretBox(bytes(self.secret_key)) box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24) nonce = bytearray(24)
nonce[:12] = header nonce[:12] = header
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext
def _encrypt_xsalsa20_poly1305_suffix(self, header, data): def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes:
box = nacl.secret.SecretBox(bytes(self.secret_key)) box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
return header + box.encrypt(bytes(data), nonce).ciphertext + nonce return header + box.encrypt(bytes(data), nonce).ciphertext + nonce
def _encrypt_xsalsa20_poly1305_lite(self, header, data): def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes:
box = nacl.secret.SecretBox(bytes(self.secret_key)) box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24) nonce = bytearray(24)
@ -526,7 +556,7 @@ class VoiceClient(VoiceProtocol):
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4] return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4]
def play(self, source: AudioSource, *, after: Callable[[Exception], Any]=None): def play(self, source: AudioSource, *, after: Callable[[Optional[Exception]], Any]=None) -> None:
"""Plays an :class:`AudioSource`. """Plays an :class:`AudioSource`.
The finalizer, ``after`` is called after the source has been exhausted The finalizer, ``after`` is called after the source has been exhausted
@ -570,32 +600,32 @@ class VoiceClient(VoiceProtocol):
self._player = AudioPlayer(source, self, after=after) self._player = AudioPlayer(source, self, after=after)
self._player.start() self._player.start()
def is_playing(self): def is_playing(self) -> bool:
"""Indicates if we're currently playing audio.""" """Indicates if we're currently playing audio."""
return self._player is not None and self._player.is_playing() return self._player is not None and self._player.is_playing()
def is_paused(self): def is_paused(self) -> bool:
"""Indicates if we're playing audio, but if we're paused.""" """Indicates if we're playing audio, but if we're paused."""
return self._player is not None and self._player.is_paused() return self._player is not None and self._player.is_paused()
def stop(self): def stop(self) -> None:
"""Stops playing audio.""" """Stops playing audio."""
if self._player: if self._player:
self._player.stop() self._player.stop()
self._player = None self._player = None
def pause(self): def pause(self) -> None:
"""Pauses the audio playing.""" """Pauses the audio playing."""
if self._player: if self._player:
self._player.pause() self._player.pause()
def resume(self): def resume(self) -> None:
"""Resumes the audio playing.""" """Resumes the audio playing."""
if self._player: if self._player:
self._player.resume() self._player.resume()
@property @property
def source(self): def source(self) -> Optional[AudioSource]:
"""Optional[:class:`AudioSource`]: The audio source being played, if playing. """Optional[:class:`AudioSource`]: The audio source being played, if playing.
This property can also be used to change the audio source currently being played. This property can also be used to change the audio source currently being played.
@ -603,7 +633,7 @@ class VoiceClient(VoiceProtocol):
return self._player.source if self._player else None return self._player.source if self._player else None
@source.setter @source.setter
def source(self, value): def source(self, value: AudioSource) -> None:
if not isinstance(value, AudioSource): if not isinstance(value, AudioSource):
raise TypeError(f'expected AudioSource not {value.__class__.__name__}.') raise TypeError(f'expected AudioSource not {value.__class__.__name__}.')
@ -612,7 +642,7 @@ class VoiceClient(VoiceProtocol):
self._player._set_source(value) self._player._set_source(value)
def send_audio_packet(self, data, *, encode=True): def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None:
"""Sends an audio packet composed of the data. """Sends an audio packet composed of the data.
You must be connected to play audio. You must be connected to play audio.

Loading…
Cancel
Save