From 9b2b32d2c32118f59e531fe93c5ccaf3c76e8412 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 22:31:01 -0500 Subject: [PATCH] Migrate voice redesign --- discord/gateway.py | 46 +++-- discord/recorder.py | 49 +++++ discord/voice_client.py | 432 ++++++++++++++++++++++++++++++---------- 3 files changed, 408 insertions(+), 119 deletions(-) create mode 100644 discord/recorder.py diff --git a/discord/gateway.py b/discord/gateway.py index 571265b79..6a49b9e0a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -39,6 +39,7 @@ from . import utils from .activity import BaseActivity from .enums import SpeakingState from .errors import ConnectionClosed, InvalidArgument +from .recorder import SSRC _log = logging.getLogger(__name__) @@ -557,10 +558,10 @@ class DiscordWebSocket: elif msg.type is aiohttp.WSMsgType.BINARY: await self.received_message(msg.data) elif msg.type is aiohttp.WSMsgType.ERROR: - _log.debug('Received %s', msg) + _log.debug('Received %s.', msg) raise msg.data elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSE): - _log.debug('Received %s', msg) + _log.debug('Received %s.', msg) raise WebSocketClosure except (asyncio.TimeoutError, WebSocketClosure) as e: # Ensure the keep alive handler is closed @@ -712,7 +713,7 @@ class DiscordVoiceWebSocket: SESSION_DESCRIPTION Receive only. Gives you the secret key required for voice. SPEAKING - Send only. Notifies the client if you are currently speaking. + Send and receive. Notifies the client if anyone begins speaking. HEARTBEAT_ACK Receive only. Tells you your heartbeat has been acknowledged. RESUME @@ -724,7 +725,7 @@ class DiscordVoiceWebSocket: CLIENT_CONNECT Indicates a user has connected to voice. CLIENT_DISCONNECT - Receive only. Indicates a user has disconnected from voice. + Receive only. Indicates a user has disconnected from voice. """ IDENTIFY = 0 @@ -753,7 +754,7 @@ class DiscordVoiceWebSocket: pass async def send_as_json(self, data): - _log.debug('Sending voice websocket frame: %s.', data) + _log.debug('Voice gateway sending: %s.', data) await self.ws.send_str(utils._to_json(data)) send_heartbeat = send_as_json @@ -788,7 +789,7 @@ class DiscordVoiceWebSocket: """Creates a voice websocket for the :class:`VoiceClient`.""" gateway = 'wss://' + client.endpoint + '/?v=4' http = client._state.http - socket = await http.ws_connect(gateway, compress=15) + socket = await http.ws_connect(gateway, compress=15, host=client.endpoint) ws = cls(socket, loop=client.loop, hook=hook) ws.gateway = gateway ws._connection = client @@ -839,7 +840,7 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) async def received_message(self, msg): - _log.debug('Voice websocket frame received: %s', msg) + _log.debug('Voice gateway event: %s.', msg) op = msg['op'] data = msg.get('d') @@ -849,6 +850,7 @@ class DiscordVoiceWebSocket: self._keep_alive.ack() elif op == self.RESUMED: _log.info('Voice RESUME succeeded.') + self.secret_key = self._connection.secret_key elif op == self.SESSION_DESCRIPTION: self._connection.mode = data['mode'] await self.load_secret_key(data) @@ -856,6 +858,18 @@ class DiscordVoiceWebSocket: interval = data['heartbeat_interval'] / 1000.0 self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) self._keep_alive.start() + elif op == self.SPEAKING: + state = self._connection + user_id = int(data['user_id']) + speaking = data['speaking'] + ssrc = state._flip_ssrc(user_id) + if ssrc is None: + state._set_ssrc(user_id, SSRC(data['ssrc'], speaking)) + else: + ssrc.speaking = speaking + + #item = state.guild or state._state + #item._update_speaking_status(user_id, speaking) await self._hook(self, msg) @@ -871,23 +885,23 @@ class DiscordVoiceWebSocket: struct.pack_into('>I', packet, 4, state.ssrc) state.socket.sendto(packet, (state.endpoint_ip, state.voice_port)) recv = await self.loop.sock_recv(state.socket, 70) - _log.debug('received packet in initial_connection: %s', recv) + _log.debug('Received packet in initial_connection: %s.', recv) - # the ip is ascii starting at the 4th byte and ending at the first null + # The IP is ascii starting at the 4th byte and ending at the first null ip_start = 4 ip_end = recv.index(0, ip_start) state.ip = recv[ip_start:ip_end].decode('ascii') state.port = struct.unpack_from('>H', recv, len(recv) - 2)[0] - _log.debug('detected ip: %s port: %s', state.ip, state.port) + _log.debug('Detected ip: %s, port: %s.', state.ip, state.port) - # there *should* always be at least one supported mode (xsalsa20_poly1305) + # There *should* always be at least one supported mode (xsalsa20_poly1305) modes = [mode for mode in data['modes'] if mode in self._connection.supported_modes] - _log.debug('received supported encryption modes: %s', ", ".join(modes)) + _log.debug('Received supported encryption modes: %s.', ", ".join(modes)) mode = modes[0] await self.select_protocol(state.ip, state.port, mode) - _log.info('selected the voice protocol for use (%s)', mode) + _log.info('Selected the voice protocol for use: %s.', mode) @property def latency(self): @@ -905,7 +919,7 @@ class DiscordVoiceWebSocket: return sum(heartbeat.recent_ack_latencies) / len(heartbeat.recent_ack_latencies) async def load_secret_key(self, data): - _log.info('received secret key for voice connection') + _log.info('Received secret key for voice connection.') self.secret_key = self._connection.secret_key = data.get('secret_key') await self.speak() await self.speak(False) @@ -916,10 +930,10 @@ class DiscordVoiceWebSocket: if msg.type is aiohttp.WSMsgType.TEXT: await self.received_message(utils._from_json(msg.data)) elif msg.type is aiohttp.WSMsgType.ERROR: - _log.debug('Received %s', msg) + _log.debug('Voice received %s.', msg) raise ConnectionClosed(self.ws) from msg.data elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING): - _log.debug('Received %s', msg) + _log.debug('Voice received %s.', msg) raise ConnectionClosed(self.ws, code=self._close_code) async def close(self, code=1000): diff --git a/discord/recorder.py b/discord/recorder.py new file mode 100644 index 000000000..087c16fac --- /dev/null +++ b/discord/recorder.py @@ -0,0 +1,49 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Who do I put here??? + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import struct +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .voice_client import VoiceClient + +unpacker = struct.Struct('>xxHII') + + +class SSRC: + def __init__(self, ssrc: int, speaking: bool) -> None: + self._ssrc = ssrc + self.speaking = speaking + + def __repr__(self) -> str: + return str(self._ssrc) + + +class VoicePacket: # IN-PROGRESS + def __init__(self, client: VoiceClient, data: bytes): + self.client = client + _data = bytearray(data) + + self.data: bytearray = data[12:] + self.header: bytearray = data[:12] diff --git a/discord/voice_client.py b/discord/voice_client.py index d382a74d7..c866c310f 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -40,11 +40,12 @@ 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, List, Optional, TYPE_CHECKING, Tuple +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Tuple from . import opus, utils from .backoff import ExponentialBackoff @@ -66,7 +67,7 @@ if TYPE_CHECKING: VoiceServerUpdate as VoiceServerUpdatePayload, SupportedModes, ) - + has_nacl: bool @@ -81,11 +82,9 @@ __all__ = ( 'VoiceClient', ) - - - _log = logging.getLogger(__name__) + class VoiceProtocol: """A class that represents the Discord voice protocol. @@ -195,6 +194,178 @@ class VoiceProtocol: key_id, _ = self.channel._get_voice_client_key() 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) + + @property + def playing(self) -> bool: + return self.is_playing() + + def is_playing(self) -> bool: + """Indicates if we're currently playing audio.""" + return self._player is not None and self._player.is_playing() + + @property + def paused(self) -> bool: + return self.is_paused() + + 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 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 = None + + +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 + + @property + def listening(self) -> bool: + return self.is_playing() + + def is_listening(self) -> bool: + """Indicates if we're currently listening.""" + return self._listener is not None and self._listener.is_listening() + + @property + def paused(self) -> bool: + return self.is_paused() + + 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. @@ -226,7 +397,6 @@ class VoiceClient(VoiceProtocol): secret_key: List[int] ssrc: int - def __init__(self, client: Client, channel: abc.Connectable): if not has_nacl: raise RuntimeError("PyNaCl library needed in order to use voice") @@ -237,7 +407,7 @@ class VoiceClient(VoiceProtocol): self.socket = MISSING self.loop: asyncio.AbstractEventLoop = state.loop self._state: ConnectionState = state - # this will be used in the AudioPlayer thread + # This will be used in the threads self._connected: threading.Event = threading.Event() self._handshaking: bool = False @@ -249,12 +419,14 @@ 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 = not has_nacl supported_modes: Tuple[SupportedModes, ...] = ( @@ -263,6 +435,16 @@ class VoiceClient(VoiceProtocol): 'xsalsa20_poly1305', ) + @property + def ssrc(self) -> int: + """:class:`str`: Our ssrc.""" + return self.idrcs.get(self.user.id) + + @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.""" @@ -273,14 +455,7 @@ class VoiceClient(VoiceProtocol): """:class:`ClientUser`: The user connected to voice (i.e. ourselves).""" return self._state.user - def checked_add(self, attr, value, limit): - val = getattr(self, attr) - if val + value > limit: - setattr(self, attr, 0) - else: - setattr(self, attr, val + value) - - # connection related + # Connection related async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None: self.session_id = data['session_id'] @@ -295,7 +470,10 @@ class VoiceClient(VoiceProtocol): await self.disconnect() else: guild = self.guild - self.channel = channel_id and guild and guild.get_channel(int(channel_id)) # type: ignore + if guild is not None: + self.channel = channel_id and guild.get_channel(int(channel_id)) # type: ignore + else: + self.channel = channel_id and self._state._get_private_channel(int(channel_id)) # type: ignore else: self._voice_state_complete.set() @@ -305,7 +483,9 @@ class VoiceClient(VoiceProtocol): return self.token = data.get('token') - self.server_id = int(data['guild_id']) + 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') endpoint = data.get('endpoint') if endpoint is None or self.token is None: @@ -315,44 +495,48 @@ class VoiceClient(VoiceProtocol): self.endpoint, _, _ = endpoint.rpartition(':') if self.endpoint.startswith('wss://'): - # Just in case, strip it off since we're going to add it later - self.endpoint = self.endpoint[6:] + self.endpoint = self.endpoint[6:] # Shouldn't ever be there... - # This gets set later self.endpoint_ip = MISSING self.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 in the websocket + # If we're not handshaking then we need to terminate our previous connection to the websocket await self.ws.close(4000) return self._voice_server_complete.set() async def voice_connect(self) -> None: - await self.channel.guild.change_voice_state(channel=self.channel) + if self.guild: + await self.guild.change_voice_state(channel=self.channel) + else: + await self._state.client.change_voice_state(channel=self.channel) 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) - await self.channel.guild.change_voice_state(channel=None) + _log.info('The voice handshake is being terminated for channel ID %s (guild ID %s).', self.channel.id, getattr(self.guild, 'id', None)) + if self.guild: + await self.guild.change_voice_state(channel=None) + else: + await self._state.client.change_voice_state(channel=None) def prepare_handshake(self) -> None: 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) -> DiscordVoiceWebSocket: - ws = await DiscordVoiceWebSocket.from_client(self) + async def connect_websocket(self, resume=False) -> DiscordVoiceWebSocket: + ws = await DiscordVoiceWebSocket.from_client(self, resume=resume) self._connected.clear() while ws.secret_key is None: await ws.poll_event() @@ -388,11 +572,12 @@ 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: @@ -420,6 +605,20 @@ 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. @@ -448,16 +647,21 @@ class VoiceClient(VoiceProtocol): await self.ws.poll_event() except (ConnectionClosed, asyncio.TimeoutError) as exc: if isinstance(exc, ConnectionClosed): - # 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): + if exc.code == 1000: _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... potentially reconnecting.') + _log.info('Disconnected from voice by force (close code %d)... potentially reconnecting.', exc.code) successful = await self.potential_reconnect() if not successful: _log.info('Reconnect was unsuccessful, disconnecting from voice normally...') @@ -474,12 +678,11 @@ 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: - # at this point we've retried 5 times... let's continue the loop. - _log.warning('Could not connect to voice... Retrying...') + # 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: @@ -490,7 +693,7 @@ class VoiceClient(VoiceProtocol): if not force and not self.is_connected(): return - self.stop() + self.player.stop() # TODO: Stop listener self._connected.clear() try: @@ -511,20 +714,52 @@ class VoiceClient(VoiceProtocol): Parameters ----------- channel: :class:`abc.Snowflake` - The channel to move to. Must be a voice channel. + The channel to move to. Must be a :class:`Connectable`. """ - await self.channel.guild.change_voice_state(channel=channel) + if self.guild: + await self.guild.change_voice_state(channel=channel) + else: + await self._state.client.change_voice_state(channel=channel) + + @property + def connected(self) -> bool: + return self.is_connected() def is_connected(self) -> bool: """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): + def _get_voice_packet(self, data) -> bytes: header = bytearray(12) - # Formulate rtp header + # Formulate RTP header header[0] = 0x80 header[1] = 0x78 struct.pack_into('>H', header, 2, self.sequence) @@ -541,21 +776,43 @@ 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: """Plays an :class:`AudioSource`. @@ -584,45 +841,10 @@ class VoiceClient(VoiceProtocol): OpusNotLoaded Source is not opus encoded and opus is not loaded. """ + return self.player.play(source, after=after) - 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() + def listen(self, *args, **kwargs): + return self.listener.listen(*args, **kwargs) @property def source(self) -> Optional[AudioSource]: @@ -630,19 +852,29 @@ class VoiceClient(VoiceProtocol): This property can also be used to change the audio source currently being played. """ - return self._player.source if self._player else None + return self.player.source @source.setter def source(self, value: AudioSource) -> None: - if not isinstance(value, AudioSource): - raise TypeError(f'expected AudioSource not {value.__class__.__name__}.') + self.player.source = value - if self._player is None: - raise ValueError('Not playing anything.') + @property + def sink(self) -> Optional[AudioSink]: + """Optional[:class:`AudioSink`]: Where received audio is being sent. - self._player._set_source(value) + This property can also be used to change the value. + """ + return self.listener.sink + + @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') - def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None: + def send_audio_packet(self, data: bytes) -> None: """Sends an audio packet composed of the data. You must be connected to play audio. @@ -650,9 +882,7 @@ class VoiceClient(VoiceProtocol): 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. + The :term:`py:bytes-like object` denoting Opus voice data. Raises ------- @@ -661,16 +891,12 @@ 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)