|
|
@ -40,12 +40,11 @@ Some documentation to refer to: |
|
|
|
from __future__ import annotations |
|
|
|
|
|
|
|
import asyncio |
|
|
|
from dataclasses import dataclass |
|
|
|
import socket |
|
|
|
import logging |
|
|
|
import struct |
|
|
|
import threading |
|
|
|
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Tuple, Union |
|
|
|
from typing import Any, Callable, List, Optional, TYPE_CHECKING, Tuple, Union |
|
|
|
|
|
|
|
from . import opus, utils |
|
|
|
from .backoff import ExponentialBackoff |
|
|
@ -60,7 +59,7 @@ if TYPE_CHECKING: |
|
|
|
from .state import ConnectionState |
|
|
|
from .user import ClientUser |
|
|
|
from .opus import Encoder |
|
|
|
from .channel import StageChannel, VoiceChannel |
|
|
|
from .channel import StageChannel, VoiceChannel, DMChannel, GroupChannel |
|
|
|
from . import abc |
|
|
|
|
|
|
|
from .types.voice import ( |
|
|
@ -69,6 +68,8 @@ if TYPE_CHECKING: |
|
|
|
SupportedModes, |
|
|
|
) |
|
|
|
|
|
|
|
VocalChannel = Union[VoiceChannel, StageChannel, DMChannel, GroupChannel] |
|
|
|
|
|
|
|
|
|
|
|
has_nacl: bool |
|
|
|
|
|
|
@ -85,6 +86,7 @@ __all__ = ( |
|
|
|
'VoiceClient', |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
_log = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
|
|
@ -105,13 +107,13 @@ class VoiceProtocol: |
|
|
|
------------ |
|
|
|
client: :class:`Client` |
|
|
|
The client (or its subclasses) that started the connection request. |
|
|
|
channel: :class:`abc.Connectable` |
|
|
|
channel: Union[:class:`VoiceChannel`, :class:`StageChannel`, :class:`DMChannel`, :class:`GroupChannel`] |
|
|
|
The voice channel that is being connected to. |
|
|
|
""" |
|
|
|
|
|
|
|
def __init__(self, client: Client, channel: abc.Connectable) -> None: |
|
|
|
def __init__(self, client: Client, channel: VocalChannel) -> None: |
|
|
|
self.client: Client = client |
|
|
|
self.channel: abc.Connectable = channel |
|
|
|
self.channel: VocalChannel = channel |
|
|
|
|
|
|
|
async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None: |
|
|
|
"""|coro| |
|
|
@ -198,159 +200,6 @@ class VoiceProtocol: |
|
|
|
self.client._connection._remove_voice_client(key_id) |
|
|
|
|
|
|
|
|
|
|
|
class Player: |
|
|
|
def __init__(self, client: VoiceClient) -> None: |
|
|
|
self.client = client |
|
|
|
self.loop: asyncio.AbstractEventLoop = client.loop |
|
|
|
|
|
|
|
self.encoder: Encoder = MISSING |
|
|
|
self._player: AudioPlayer = MISSING |
|
|
|
|
|
|
|
def send(self, data: bytes, encode: bool = True) -> None: |
|
|
|
"""Sends an audio packet composed of the data. |
|
|
|
|
|
|
|
You must be connected to play audio. |
|
|
|
|
|
|
|
Parameters |
|
|
|
---------- |
|
|
|
data: :class:`bytes` |
|
|
|
The :term:`py:bytes-like object` denoting PCM or Opus voice data. |
|
|
|
encode: :class:`bool` |
|
|
|
Indicates if ``data`` should be encoded into Opus. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
|
ClientException |
|
|
|
You are not connected. |
|
|
|
opus.OpusError |
|
|
|
Encoding the data failed. |
|
|
|
""" |
|
|
|
if encode: |
|
|
|
data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME) |
|
|
|
|
|
|
|
self.client.send_audio_packet(data) |
|
|
|
|
|
|
|
@property |
|
|
|
def ws(self) -> DiscordVoiceWebSocket: |
|
|
|
return self.client.ws |
|
|
|
|
|
|
|
@property |
|
|
|
def source(self) -> Optional[AudioSource]: |
|
|
|
"""Optional[:class:`AudioSource`]: The audio source being played, if playing. |
|
|
|
|
|
|
|
This property can also be used to change the audio source currently being played. |
|
|
|
""" |
|
|
|
return self._player.source if self._player else None |
|
|
|
|
|
|
|
@source.setter |
|
|
|
def source(self, value) -> None: |
|
|
|
if not isinstance(value, AudioSource): |
|
|
|
raise TypeError('Expected AudioSource not {0.__class__.__name__}'.format(value)) |
|
|
|
|
|
|
|
if self._player is None: |
|
|
|
raise ValueError('Not playing anything') |
|
|
|
|
|
|
|
self._player._set_source(value) |
|
|
|
|
|
|
|
def is_playing(self) -> bool: |
|
|
|
"""Indicates if we're currently playing audio.""" |
|
|
|
return self._player and self._player.is_playing() |
|
|
|
|
|
|
|
def is_paused(self) -> bool: |
|
|
|
"""Indicates if we're playing audio, but if we're paused.""" |
|
|
|
return self._player and self._player.is_paused() |
|
|
|
|
|
|
|
def play(self, source: AudioSource, *, after: Callable[[Optional[Exception]], Any] = None) -> None: |
|
|
|
"""Plays an :class:`AudioSource`. |
|
|
|
|
|
|
|
The finalizer, ``after`` is called after the source has been exhausted |
|
|
|
or an error occurred. |
|
|
|
|
|
|
|
If an error happens while the audio player is running, the exception is |
|
|
|
caught and the audio player is then stopped. If no after callback is |
|
|
|
passed, any caught exception will be displayed as if it were raised. |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
source: :class:`AudioSource` |
|
|
|
The audio source we're reading from. |
|
|
|
after: Callable[[:class:`Exception`], Any] |
|
|
|
The finalizer that is called after the stream is exhausted. |
|
|
|
This function must have a single parameter, ``error``, that |
|
|
|
denotes an optional exception that was raised during playing. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
|
ClientException |
|
|
|
Already playing audio or not connected. |
|
|
|
TypeError |
|
|
|
Source is not a :class:`AudioSource` or after is not a callable. |
|
|
|
OpusNotLoaded |
|
|
|
Source is not opus encoded and opus is not loaded. |
|
|
|
""" |
|
|
|
if not self.client.is_connected(): |
|
|
|
raise ClientException('Not connected to voice') |
|
|
|
|
|
|
|
if self.is_playing(): |
|
|
|
raise ClientException('Already playing audio') |
|
|
|
|
|
|
|
if not isinstance(source, AudioSource): |
|
|
|
raise TypeError(f'source must be an AudioSource not {source.__class__.__name__}') |
|
|
|
|
|
|
|
if not self.encoder and not source.is_opus(): |
|
|
|
self.encoder = opus.Encoder() |
|
|
|
|
|
|
|
self._player = AudioPlayer(source, self, after=after) |
|
|
|
self._player.start() |
|
|
|
|
|
|
|
def pause(self) -> None: |
|
|
|
"""Pauses the audio playing.""" |
|
|
|
if self._player: |
|
|
|
self._player.pause() |
|
|
|
|
|
|
|
def resume(self) -> None: |
|
|
|
"""Resumes the audio playing.""" |
|
|
|
if self._player: |
|
|
|
self._player.resume() |
|
|
|
|
|
|
|
def stop(self) -> None: |
|
|
|
"""Stops playing audio.""" |
|
|
|
if self._player: |
|
|
|
self._player.stop() |
|
|
|
self._player = MISSING |
|
|
|
|
|
|
|
|
|
|
|
class Listener: |
|
|
|
def __init__(self, client: VoiceClient) -> None: |
|
|
|
self.client = client |
|
|
|
self.loop: asyncio.AbstractEventLoop = client.loop |
|
|
|
|
|
|
|
self.decoder = None |
|
|
|
self._listener = None |
|
|
|
|
|
|
|
@property |
|
|
|
def ws(self) -> DiscordVoiceWebSocket: |
|
|
|
return self.client.ws |
|
|
|
|
|
|
|
def is_listening(self) -> bool: |
|
|
|
"""Indicates if we're currently listening.""" |
|
|
|
return self._listener is not None and self._listener.is_listening() |
|
|
|
|
|
|
|
def is_paused(self) -> bool: |
|
|
|
"""Indicates if we're listening, but we're paused.""" |
|
|
|
return self._listener is not None and self._listener.is_paused() |
|
|
|
|
|
|
|
def listen(self, sink, *, callback=None) -> None: |
|
|
|
if not self.client.is_connected(): |
|
|
|
raise ClientException('Not connected to voice') |
|
|
|
|
|
|
|
if self.is_listening(): |
|
|
|
raise ClientException('Already listening') |
|
|
|
|
|
|
|
if not isinstance(sink, AudioSink): |
|
|
|
raise TypeError(f'sink must an AudioSink not {sink.__class__.__name__}') |
|
|
|
|
|
|
|
|
|
|
|
class VoiceClient(VoiceProtocol): |
|
|
|
"""Represents a Discord voice connection. |
|
|
|
|
|
|
@ -372,18 +221,19 @@ class VoiceClient(VoiceProtocol): |
|
|
|
The voice connection token. |
|
|
|
endpoint: :class:`str` |
|
|
|
The endpoint we are connecting to. |
|
|
|
channel: :class:`abc.Connectable` |
|
|
|
channel: Union[:class:`VoiceChannel`, :class:`StageChannel`, :class:`DMChannel`, :class:`GroupChannel`] |
|
|
|
The voice channel connected to. |
|
|
|
""" |
|
|
|
|
|
|
|
channel: abc.Connectable |
|
|
|
channel: VocalChannel |
|
|
|
endpoint_ip: str |
|
|
|
voice_port: int |
|
|
|
ip: str |
|
|
|
port: int |
|
|
|
secret_key: Optional[str] |
|
|
|
secret_key: List[int] |
|
|
|
ssrc: int |
|
|
|
|
|
|
|
def __init__(self, client: Client, channel: abc.Connectable): |
|
|
|
def __init__(self, client: Client, channel: VocalChannel) -> None: |
|
|
|
if not has_nacl: |
|
|
|
raise RuntimeError("PyNaCl library needed in order to use voice") |
|
|
|
|
|
|
@ -394,7 +244,7 @@ class VoiceClient(VoiceProtocol): |
|
|
|
self.socket = MISSING |
|
|
|
self.loop: asyncio.AbstractEventLoop = state.loop |
|
|
|
self._state: ConnectionState = state |
|
|
|
# This will be used in the threads |
|
|
|
# this will be used in the AudioPlayer thread |
|
|
|
self._connected: threading.Event = threading.Event() |
|
|
|
|
|
|
|
self._handshaking: bool = False |
|
|
@ -406,14 +256,12 @@ class VoiceClient(VoiceProtocol): |
|
|
|
self._connections: int = 0 |
|
|
|
self.sequence: int = 0 |
|
|
|
self.timestamp: int = 0 |
|
|
|
self.player = Player(self) |
|
|
|
self.listener = Listener(self) |
|
|
|
self.timeout: float = 0 |
|
|
|
self._runner: asyncio.Task = MISSING |
|
|
|
self._player: Optional[AudioPlayer] = None |
|
|
|
self.encoder: Encoder = MISSING |
|
|
|
self._lite_nonce: int = 0 |
|
|
|
self.ws: DiscordVoiceWebSocket = MISSING |
|
|
|
self.idrcs: Dict[int, int] = {} |
|
|
|
self.ssids: Dict[int, int] = {} |
|
|
|
|
|
|
|
warn_nacl: bool = not has_nacl |
|
|
|
supported_modes: Tuple[SupportedModes, ...] = ( |
|
|
@ -422,16 +270,6 @@ class VoiceClient(VoiceProtocol): |
|
|
|
'xsalsa20_poly1305', |
|
|
|
) |
|
|
|
|
|
|
|
@property |
|
|
|
def ssrc(self) -> int: |
|
|
|
""":class:`str`: Our ssrc.""" |
|
|
|
return self.idrcs.get(self.user.id) # type: ignore |
|
|
|
|
|
|
|
@ssrc.setter |
|
|
|
def ssrc(self, value): |
|
|
|
self.idrcs[self.user.id] = value |
|
|
|
self.ssids[value] = self.user.id |
|
|
|
|
|
|
|
@property |
|
|
|
def guild(self) -> Optional[Guild]: |
|
|
|
"""Optional[:class:`Guild`]: The guild we're connected to, if applicable.""" |
|
|
@ -442,8 +280,6 @@ class VoiceClient(VoiceProtocol): |
|
|
|
""":class:`ClientUser`: The user connected to voice (i.e. ourselves).""" |
|
|
|
return self._state.user # type: ignore |
|
|
|
|
|
|
|
# Connection related |
|
|
|
|
|
|
|
def checked_add(self, attr: str, value: int, limit: int) -> None: |
|
|
|
val = getattr(self, attr) |
|
|
|
if val + value > limit: |
|
|
@ -451,6 +287,8 @@ class VoiceClient(VoiceProtocol): |
|
|
|
else: |
|
|
|
setattr(self, attr, val + value) |
|
|
|
|
|
|
|
# connection related |
|
|
|
|
|
|
|
async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None: |
|
|
|
self.session_id: str = data['session_id'] |
|
|
|
channel_id = data['channel_id'] |
|
|
@ -463,11 +301,7 @@ class VoiceClient(VoiceProtocol): |
|
|
|
# We're being disconnected so cleanup |
|
|
|
await self.disconnect() |
|
|
|
else: |
|
|
|
guild = self.guild |
|
|
|
if guild is not None: |
|
|
|
self.channel = channel_id and guild.get_channel(int(channel_id)) # type: ignore # This won't be None |
|
|
|
else: |
|
|
|
self.channel = channel_id and self._state._get_private_channel(int(channel_id)) # type: ignore # This won't be None |
|
|
|
self.channel = channel_id and self.guild.get_channel(int(channel_id)) # type: ignore |
|
|
|
else: |
|
|
|
self._voice_state_complete.set() |
|
|
|
|
|
|
@ -476,11 +310,8 @@ class VoiceClient(VoiceProtocol): |
|
|
|
_log.info('Ignoring extraneous voice server update.') |
|
|
|
return |
|
|
|
|
|
|
|
if 'token' in data: |
|
|
|
self.token = data['token'] |
|
|
|
self.server_id = server_id = utils._get_as_snowflake(data, 'guild_id') |
|
|
|
if server_id is None: |
|
|
|
self.server_id = utils._get_as_snowflake(data, 'channel_id') |
|
|
|
self.token = data['token'] |
|
|
|
self.server_id = int(data['guild_id']) |
|
|
|
endpoint = data.get('endpoint') |
|
|
|
|
|
|
|
if endpoint is None or self.token is None: |
|
|
@ -495,33 +326,35 @@ class VoiceClient(VoiceProtocol): |
|
|
|
# Just in case, strip it off since we're going to add it later |
|
|
|
self.endpoint: str = self.endpoint[6:] |
|
|
|
|
|
|
|
# This gets set later |
|
|
|
self.endpoint_ip = MISSING |
|
|
|
|
|
|
|
self.socket: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
|
|
|
self.socket.setblocking(False) |
|
|
|
|
|
|
|
if not self._handshaking: |
|
|
|
# If we're not handshaking then we need to terminate our previous connection to the websocket |
|
|
|
# If we're not handshaking then we need to terminate our previous connection in the websocket |
|
|
|
await self.ws.close(4000) |
|
|
|
return |
|
|
|
|
|
|
|
self._voice_server_complete.set() |
|
|
|
|
|
|
|
async def voice_connect(self) -> None: |
|
|
|
channel = await self.channel._get_channel() if self.channel else None |
|
|
|
channel = self.channel |
|
|
|
if self.guild: |
|
|
|
await self.guild.change_voice_state(channel=channel) |
|
|
|
else: |
|
|
|
await self._state.client.change_voice_state(channel=channel) |
|
|
|
|
|
|
|
async def voice_disconnect(self) -> None: |
|
|
|
guild = self.guild |
|
|
|
_log.info( |
|
|
|
'The voice handshake is being terminated for channel ID %s (guild ID %s).', |
|
|
|
(await self.channel._get_channel()).id, |
|
|
|
getattr(self.guild, 'id', None), |
|
|
|
self.channel.id, |
|
|
|
getattr(guild, 'id', None), |
|
|
|
) |
|
|
|
if self.guild: |
|
|
|
await self.guild.change_voice_state(channel=None) |
|
|
|
if guild: |
|
|
|
await guild.change_voice_state(channel=None) |
|
|
|
else: |
|
|
|
await self._state.client.change_voice_state(channel=None) |
|
|
|
|
|
|
@ -529,17 +362,17 @@ class VoiceClient(VoiceProtocol): |
|
|
|
self._voice_state_complete.clear() |
|
|
|
self._voice_server_complete.clear() |
|
|
|
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 |
|
|
|
|
|
|
|
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._voice_server_complete.clear() |
|
|
|
self._voice_state_complete.clear() |
|
|
|
|
|
|
|
async def connect_websocket(self, resume=False) -> DiscordVoiceWebSocket: |
|
|
|
ws = await DiscordVoiceWebSocket.from_client(self, resume=resume) |
|
|
|
async def connect_websocket(self) -> DiscordVoiceWebSocket: |
|
|
|
ws = await DiscordVoiceWebSocket.from_client(self) |
|
|
|
self._connected.clear() |
|
|
|
while ws.secret_key is None: |
|
|
|
await ws.poll_event() |
|
|
@ -575,12 +408,11 @@ class VoiceClient(VoiceProtocol): |
|
|
|
break |
|
|
|
except (ConnectionClosed, asyncio.TimeoutError): |
|
|
|
if reconnect: |
|
|
|
_log.exception('Failed to connect to voice. Retrying...') |
|
|
|
_log.exception('Failed to connect to voice... Retrying...') |
|
|
|
await asyncio.sleep(1 + i * 2.0) |
|
|
|
await self.voice_disconnect() |
|
|
|
continue |
|
|
|
else: |
|
|
|
await self.disconnect(force=True) |
|
|
|
raise |
|
|
|
|
|
|
|
if self._runner is MISSING: |
|
|
@ -608,20 +440,6 @@ class VoiceClient(VoiceProtocol): |
|
|
|
else: |
|
|
|
return True |
|
|
|
|
|
|
|
async def potential_resume(self) -> bool: |
|
|
|
# Attempt to stop the player thread from playing early |
|
|
|
self._connected.clear() |
|
|
|
self._potentially_reconnecting = True |
|
|
|
|
|
|
|
try: |
|
|
|
self.ws = await self.connect_websocket(resume=True) |
|
|
|
except (ConnectionClosed, asyncio.TimeoutError): |
|
|
|
return False # Reconnect normally if RESUME failed |
|
|
|
else: |
|
|
|
return True |
|
|
|
finally: |
|
|
|
self._potentially_reconnecting = False |
|
|
|
|
|
|
|
@property |
|
|
|
def latency(self) -> float: |
|
|
|
""":class:`float`: Latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. |
|
|
@ -650,21 +468,16 @@ class VoiceClient(VoiceProtocol): |
|
|
|
await self.ws.poll_event() |
|
|
|
except (ConnectionClosed, asyncio.TimeoutError) as exc: |
|
|
|
if isinstance(exc, ConnectionClosed): |
|
|
|
if exc.code == 1000: |
|
|
|
# The following close codes are undocumented so I will document them here. |
|
|
|
# 1000 - normal closure (obviously) |
|
|
|
# 4014 - voice channel has been deleted. |
|
|
|
# 4015 - voice server has crashed |
|
|
|
if exc.code in (1000, 4015): |
|
|
|
_log.info('Disconnecting from voice normally, close code %d.', exc.code) |
|
|
|
await self.disconnect() |
|
|
|
break |
|
|
|
if exc.code == 4015: |
|
|
|
_log.info('Disconnected from voice (close code %d), potentially RESUMEing...', exc.code) |
|
|
|
successful = await self.potential_resume() |
|
|
|
if not successful: |
|
|
|
_log.info('RESUME was unsuccessful, disconnecting from voice normally...') |
|
|
|
await self.disconnect() |
|
|
|
break |
|
|
|
else: |
|
|
|
continue |
|
|
|
if exc.code == 4014: |
|
|
|
_log.info('Disconnected from voice by force (close code %d)... potentially reconnecting.', exc.code) |
|
|
|
_log.info('Disconnected from voice by force... potentially reconnecting.') |
|
|
|
successful = await self.potential_reconnect() |
|
|
|
if not successful: |
|
|
|
_log.info('Reconnect was unsuccessful, disconnecting from voice normally...') |
|
|
@ -681,11 +494,12 @@ class VoiceClient(VoiceProtocol): |
|
|
|
_log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry) |
|
|
|
self._connected.clear() |
|
|
|
await asyncio.sleep(retry) |
|
|
|
await self.voice_disconnect() |
|
|
|
try: |
|
|
|
await self.connect(reconnect=True, timeout=self.timeout) |
|
|
|
except asyncio.TimeoutError: |
|
|
|
# We've retried 5 times, let's continue the loop |
|
|
|
_log.warning('Could not connect to voice. Retrying...') |
|
|
|
# at this point we've retried 5 times... let's continue the loop. |
|
|
|
_log.warning('Could not connect to voice... Retrying...') |
|
|
|
continue |
|
|
|
|
|
|
|
async def disconnect(self, *, force: bool = False) -> None: |
|
|
@ -696,7 +510,7 @@ class VoiceClient(VoiceProtocol): |
|
|
|
if not force and not self.is_connected(): |
|
|
|
return |
|
|
|
|
|
|
|
self.player.stop() # TODO: Stop listener |
|
|
|
self.stop() |
|
|
|
self._connected.clear() |
|
|
|
|
|
|
|
try: |
|
|
@ -716,8 +530,8 @@ class VoiceClient(VoiceProtocol): |
|
|
|
|
|
|
|
Parameters |
|
|
|
----------- |
|
|
|
channel: Optional[:class:`abc.Snowflake`] |
|
|
|
The channel to move to. Must be a :class:`abc.Connectable`. |
|
|
|
channel: Optional[:class:`~abc.Snowflake`] |
|
|
|
The channel to move to. Must be a voice channel. |
|
|
|
""" |
|
|
|
if self.guild: |
|
|
|
await self.guild.change_voice_state(channel=channel) |
|
|
@ -728,37 +542,12 @@ class VoiceClient(VoiceProtocol): |
|
|
|
"""Indicates if the voice client is connected to voice.""" |
|
|
|
return self._connected.is_set() |
|
|
|
|
|
|
|
# Audio related |
|
|
|
# audio related |
|
|
|
|
|
|
|
def _flip_ssrc(self, query) -> Optional[int]: |
|
|
|
value = self.idrcs.get(query) |
|
|
|
if value is None: |
|
|
|
value = self.ssids.get(query) |
|
|
|
return value |
|
|
|
|
|
|
|
def _set_ssrc(self, user_id, ssrc) -> None: |
|
|
|
self.idrcs[user_id] = ssrc |
|
|
|
self.ssids[ssrc] = user_id |
|
|
|
|
|
|
|
def _checked_add(self, attr, value, limit) -> None: |
|
|
|
val = getattr(self, attr) |
|
|
|
if val + value > limit: |
|
|
|
setattr(self, attr, 0) |
|
|
|
else: |
|
|
|
setattr(self, attr, val + value) |
|
|
|
|
|
|
|
@staticmethod |
|
|
|
def _strip_header(data) -> bytes: |
|
|
|
if data[0] == 0xBE and data[1] == 0xDE and len(data) > 4: |
|
|
|
_, length = struct.unpack_from('>HH', data) |
|
|
|
offset = 4 + length * 4 |
|
|
|
data = data[offset:] |
|
|
|
return data |
|
|
|
|
|
|
|
def _get_voice_packet(self, data) -> bytes: |
|
|
|
def _get_voice_packet(self, data): |
|
|
|
header = bytearray(12) |
|
|
|
|
|
|
|
# Formulate RTP header |
|
|
|
# Formulate rtp header |
|
|
|
header[0] = 0x80 |
|
|
|
header[1] = 0x78 |
|
|
|
struct.pack_into('>H', header, 2, self.sequence) |
|
|
@ -775,44 +564,22 @@ class VoiceClient(VoiceProtocol): |
|
|
|
|
|
|
|
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext |
|
|
|
|
|
|
|
def _decrypt_xsalsa20_poly1305(self, header, data): |
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
|
|
|
nonce = bytearray(24) |
|
|
|
nonce[:12] = header |
|
|
|
|
|
|
|
return self._strip_header(box.decrypt(bytes(data), bytes(nonce))) |
|
|
|
|
|
|
|
def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes: |
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
|
|
|
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) |
|
|
|
|
|
|
|
return header + box.encrypt(bytes(data), nonce).ciphertext + nonce |
|
|
|
|
|
|
|
def _decrypt_xsalsa20_poly1305_suffix(self, header, data): |
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
|
|
|
nonce_size = nacl.secret.SecretBox.NONCE_SIZE |
|
|
|
nonce = data[-nonce_size:] |
|
|
|
|
|
|
|
return self._strip_header(box.decrypt(bytes(data[:-nonce_size]), nonce)) |
|
|
|
|
|
|
|
def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes: |
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
|
|
|
nonce = bytearray(24) |
|
|
|
|
|
|
|
nonce[:4] = struct.pack('>I', self._lite_nonce) |
|
|
|
self._checked_add('_lite_nonce', 1, 4294967295) |
|
|
|
self.checked_add('_lite_nonce', 1, 4294967295) |
|
|
|
|
|
|
|
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4] |
|
|
|
|
|
|
|
def _decrypt_xsalsa20_poly1305_lite(self, header, data): |
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
|
|
|
nonce = bytearray(24) |
|
|
|
nonce[:4] = data[-4:] |
|
|
|
data = data[:-4] |
|
|
|
|
|
|
|
return self._strip_header(box.decrypt(bytes(data), bytes(nonce))) |
|
|
|
|
|
|
|
def play(self, source: AudioSource, *, after: Callable[[Optional[Exception]], Any] = None) -> None: |
|
|
|
def play(self, source: AudioSource, *, after: Optional[Callable[[Optional[Exception]], Any]] = None) -> None: |
|
|
|
"""Plays an :class:`AudioSource`. |
|
|
|
|
|
|
|
The finalizer, ``after`` is called after the source has been exhausted |
|
|
@ -840,10 +607,45 @@ class VoiceClient(VoiceProtocol): |
|
|
|
OpusNotLoaded |
|
|
|
Source is not opus encoded and opus is not loaded. |
|
|
|
""" |
|
|
|
return self.player.play(source, after=after) |
|
|
|
|
|
|
|
def listen(self, *args, **kwargs): |
|
|
|
return self.listener.listen(*args, **kwargs) |
|
|
|
if not self.is_connected(): |
|
|
|
raise ClientException('Not connected to voice.') |
|
|
|
|
|
|
|
if self.is_playing(): |
|
|
|
raise ClientException('Already playing audio.') |
|
|
|
|
|
|
|
if not isinstance(source, AudioSource): |
|
|
|
raise TypeError(f'source must be an AudioSource not {source.__class__.__name__}') |
|
|
|
|
|
|
|
if not self.encoder and not source.is_opus(): |
|
|
|
self.encoder = opus.Encoder() |
|
|
|
|
|
|
|
self._player = AudioPlayer(source, self, after=after) |
|
|
|
self._player.start() |
|
|
|
|
|
|
|
def is_playing(self) -> bool: |
|
|
|
"""Indicates if we're currently playing audio.""" |
|
|
|
return self._player is not None and self._player.is_playing() |
|
|
|
|
|
|
|
def is_paused(self) -> bool: |
|
|
|
"""Indicates if we're playing audio, but if we're paused.""" |
|
|
|
return self._player is not None and self._player.is_paused() |
|
|
|
|
|
|
|
def stop(self) -> None: |
|
|
|
"""Stops playing audio.""" |
|
|
|
if self._player: |
|
|
|
self._player.stop() |
|
|
|
self._player = None |
|
|
|
|
|
|
|
def pause(self) -> None: |
|
|
|
"""Pauses the audio playing.""" |
|
|
|
if self._player: |
|
|
|
self._player.pause() |
|
|
|
|
|
|
|
def resume(self) -> None: |
|
|
|
"""Resumes the audio playing.""" |
|
|
|
if self._player: |
|
|
|
self._player.resume() |
|
|
|
|
|
|
|
@property |
|
|
|
def source(self) -> Optional[AudioSource]: |
|
|
@ -851,29 +653,19 @@ class VoiceClient(VoiceProtocol): |
|
|
|
|
|
|
|
This property can also be used to change the audio source currently being played. |
|
|
|
""" |
|
|
|
return self.player.source |
|
|
|
return self._player.source if self._player else None |
|
|
|
|
|
|
|
@source.setter |
|
|
|
def source(self, value: AudioSource) -> None: |
|
|
|
self.player.source = value |
|
|
|
|
|
|
|
@property |
|
|
|
def sink(self) -> Optional[AudioSink]: |
|
|
|
"""Optional[:class:`AudioSink`]: Where received audio is being sent. |
|
|
|
if not isinstance(value, AudioSource): |
|
|
|
raise TypeError(f'expected AudioSource not {value.__class__.__name__}.') |
|
|
|
|
|
|
|
This property can also be used to change the value. |
|
|
|
""" |
|
|
|
return self.listener.sink |
|
|
|
if self._player is None: |
|
|
|
raise ValueError('Not playing anything.') |
|
|
|
|
|
|
|
@sink.setter |
|
|
|
def sink(self, value): |
|
|
|
self.listener.sink = value |
|
|
|
# if not isinstance(value, AudioSink): |
|
|
|
# raise TypeError('Expected AudioSink not {value.__class__.__name__}') |
|
|
|
# if self._recorder is None: |
|
|
|
# raise ValueError('Not listening') |
|
|
|
self._player._set_source(value) |
|
|
|
|
|
|
|
def send_audio_packet(self, data: bytes) -> None: |
|
|
|
def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None: |
|
|
|
"""Sends an audio packet composed of the data. |
|
|
|
|
|
|
|
You must be connected to play audio. |
|
|
@ -881,7 +673,9 @@ class VoiceClient(VoiceProtocol): |
|
|
|
Parameters |
|
|
|
---------- |
|
|
|
data: :class:`bytes` |
|
|
|
The :term:`py:bytes-like object` denoting Opus voice data. |
|
|
|
The :term:`py:bytes-like object` denoting PCM or Opus voice data. |
|
|
|
encode: :class:`bool` |
|
|
|
Indicates if ``data`` should be encoded into Opus. |
|
|
|
|
|
|
|
Raises |
|
|
|
------- |
|
|
@ -890,12 +684,16 @@ class VoiceClient(VoiceProtocol): |
|
|
|
opus.OpusError |
|
|
|
Encoding the data failed. |
|
|
|
""" |
|
|
|
self._checked_add('sequence', 1, 65535) |
|
|
|
packet = self._get_voice_packet(data) |
|
|
|
|
|
|
|
self.checked_add('sequence', 1, 65535) |
|
|
|
if encode: |
|
|
|
encoded_data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME) |
|
|
|
else: |
|
|
|
encoded_data = data |
|
|
|
packet = self._get_voice_packet(encoded_data) |
|
|
|
try: |
|
|
|
self.socket.sendto(packet, (self.endpoint_ip, self.voice_port)) |
|
|
|
except BlockingIOError: |
|
|
|
_log.warning('A packet has been dropped (seq: %s, timestamp: %s).', self.sequence, self.timestamp) |
|
|
|
_log.warning('A packet has been dropped (seq: %s, timestamp: %s)', self.sequence, self.timestamp) |
|
|
|
|
|
|
|
self._checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295) |
|
|
|
self.checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295) |
|
|
|