1
|
|
|
""" |
2
|
|
|
This is a modified version of discord.py's voice_client module |
3
|
|
|
Copyright (c) 2015-2021 Rapptz |
4
|
|
|
Copyright (c) 2021-present Pincer |
5
|
|
|
:license: MIT, see LICENSE for details |
6
|
|
|
""" |
7
|
|
|
|
8
|
|
|
|
9
|
|
|
from __future__ import annotations |
10
|
|
|
|
11
|
|
|
import asyncio |
12
|
|
|
import logging |
13
|
|
|
import socket |
14
|
|
|
import struct |
15
|
|
|
import threading |
16
|
|
|
from typing import Any, Callable, List, Optional, TYPE_CHECKING, Tuple |
17
|
|
|
|
18
|
|
|
from . import opus, utils |
19
|
|
|
from .backoff import ExponentialBackoff |
|
|
|
|
20
|
|
|
from .exceptions import ClientException, ConnectionClosed |
|
|
|
|
21
|
|
|
from .core.gateway import * |
|
|
|
|
22
|
|
|
from .player import AudioPlayer, AudioSource |
23
|
|
|
from .utils import MISSING |
24
|
|
|
|
25
|
|
|
if TYPE_CHECKING: |
26
|
|
|
from .client import Client |
|
|
|
|
27
|
|
|
from .objects.guild import Guild |
28
|
|
|
from .state import ConnectionState |
|
|
|
|
29
|
|
|
from .user import ClientUser |
|
|
|
|
30
|
|
|
from .opus import Encoder |
31
|
|
|
from . import abc |
32
|
|
|
|
33
|
|
|
from .types.voice import ( |
34
|
|
|
GuildVoiceState as GuildVoiceStatePayload, |
35
|
|
|
VoiceServerUpdate as VoiceServerUpdatePayload, |
36
|
|
|
SupportedModes, |
37
|
|
|
) |
38
|
|
|
|
39
|
|
|
has_nacl: bool |
40
|
|
|
|
41
|
|
|
try: |
42
|
|
|
import nacl.secret # type: ignore |
43
|
|
|
|
44
|
|
|
has_nacl = True |
45
|
|
|
except ImportError: |
46
|
|
|
has_nacl = False |
47
|
|
|
|
48
|
|
|
__all__ = ( |
49
|
|
|
'VoiceProtocol', |
50
|
|
|
'VoiceClient', |
51
|
|
|
) |
52
|
|
|
|
53
|
|
|
_log = logging.getLogger(__name__) |
54
|
|
|
|
55
|
|
|
|
56
|
|
|
class VoiceProtocol: |
57
|
|
|
"""A class that represents the Discord voice protocol. |
58
|
|
|
This is an abstract class. The library provides a concrete implementation |
59
|
|
|
under :class:`VoiceClient`. |
60
|
|
|
This class allows you to implement a protocol to allow for an external |
61
|
|
|
method of sending voice, such as Lavalink_ or a native library implementation. |
62
|
|
|
These classes are passed to :meth:`abc.Connectable.connect <VoiceChannel.connect>`. |
63
|
|
|
.. _Lavalink: https://github.com/freyacodes/Lavalink |
64
|
|
|
Parameters |
65
|
|
|
------------ |
66
|
|
|
client: :class:`Client` |
67
|
|
|
The client (or its subclasses) that started the connection request. |
68
|
|
|
channel: :class:`abc.Connectable` |
69
|
|
|
The voice channel that is being connected to. |
70
|
|
|
""" |
71
|
|
|
|
72
|
|
|
def __init__(self, client: Client, channel: abc.Connectable) -> None: |
73
|
|
|
self.client: Client = client |
74
|
|
|
self.channel: abc.Connectable = channel |
75
|
|
|
|
76
|
|
|
async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None: |
77
|
|
|
"""|coro| |
78
|
|
|
An abstract method that is called when the client's voice state |
79
|
|
|
has changed. This corresponds to ``VOICE_STATE_UPDATE``. |
80
|
|
|
Parameters |
81
|
|
|
------------ |
82
|
|
|
data: :class:`dict` |
83
|
|
|
The raw `voice state payload`__. |
84
|
|
|
.. _voice_state_update_payload: https://discord.com/developers/docs/resources/voice#voice-state-object |
|
|
|
|
85
|
|
|
__ voice_state_update_payload_ |
86
|
|
|
""" |
87
|
|
|
raise NotImplementedError |
88
|
|
|
|
89
|
|
|
async def on_voice_server_update(self, |
90
|
|
|
data: VoiceServerUpdatePayload) -> None: |
91
|
|
|
"""|coro| |
92
|
|
|
An abstract method that is called when initially connecting to voice. |
93
|
|
|
This corresponds to ``VOICE_SERVER_UPDATE``. |
94
|
|
|
Parameters |
95
|
|
|
------------ |
96
|
|
|
data: :class:`dict` |
97
|
|
|
The raw `voice server update payload`__. |
98
|
|
|
.. _voice_server_update_payload: https://discord.com/developers/docs/topics/gateway#voice-server-update-voice-server-update-event-fields |
|
|
|
|
99
|
|
|
__ voice_server_update_payload_ |
100
|
|
|
""" |
101
|
|
|
raise NotImplementedError |
102
|
|
|
|
103
|
|
|
async def connect(self, *, timeout: float, reconnect: bool) -> None: |
104
|
|
|
"""|coro| |
105
|
|
|
An abstract method called when the client initiates the connection request. |
106
|
|
|
When a connection is requested initially, the library calls the constructor |
107
|
|
|
under ``__init__`` and then calls :meth:`connect`. If :meth:`connect` fails at |
108
|
|
|
some point then :meth:`disconnect` is called. |
109
|
|
|
Within this method, to start the voice connection flow it is recommended to |
110
|
|
|
use :meth:`Guild.change_voice_state` to start the flow. After which, |
111
|
|
|
:meth:`on_voice_server_update` and :meth:`on_voice_state_update` will be called. |
112
|
|
|
The order that these two are called is unspecified. |
113
|
|
|
Parameters |
114
|
|
|
------------ |
115
|
|
|
timeout: :class:`float` |
116
|
|
|
The timeout for the connection. |
117
|
|
|
reconnect: :class:`bool` |
118
|
|
|
Whether reconnection is expected. |
119
|
|
|
""" |
120
|
|
|
raise NotImplementedError |
121
|
|
|
|
122
|
|
|
async def disconnect(self, *, force: bool) -> None: |
123
|
|
|
"""|coro| |
124
|
|
|
An abstract method called when the client terminates the connection. |
125
|
|
|
See :meth:`cleanup`. |
126
|
|
|
Parameters |
127
|
|
|
------------ |
128
|
|
|
force: :class:`bool` |
129
|
|
|
Whether the disconnection was forced. |
130
|
|
|
""" |
131
|
|
|
raise NotImplementedError |
132
|
|
|
|
133
|
|
|
def cleanup(self) -> None: |
134
|
|
|
"""This method *must* be called to ensure proper clean-up during a disconnect. |
135
|
|
|
It is advisable to call this from within :meth:`disconnect` when you are |
136
|
|
|
completely done with the voice protocol instance. |
137
|
|
|
This method removes it from the internal state cache that keeps track of |
138
|
|
|
currently alive voice clients. Failure to clean-up will cause subsequent |
139
|
|
|
connections to report that it's still connected. |
140
|
|
|
""" |
141
|
|
|
key_id, _ = self.channel._get_voice_client_key() |
|
|
|
|
142
|
|
|
self.client._connection._remove_voice_client(key_id) |
|
|
|
|
143
|
|
|
|
144
|
|
|
|
145
|
|
|
class VoiceClient(VoiceProtocol): |
|
|
|
|
146
|
|
|
"""Represents a Discord voice connection. |
147
|
|
|
You do not create these, you typically get them from |
148
|
|
|
e.g. :meth:`VoiceChannel.connect`. |
149
|
|
|
Warning |
150
|
|
|
-------- |
151
|
|
|
In order to use PCM based AudioSources, you must have the opus library |
152
|
|
|
installed on your system and loaded through :func:`opus.load_opus`. |
153
|
|
|
Otherwise, your AudioSources must be opus encoded (e.g. using :class:`FFmpegOpusAudio`) |
154
|
|
|
or the library will not be able to transmit audio. |
155
|
|
|
Attributes |
156
|
|
|
----------- |
157
|
|
|
session_id: :class:`str` |
158
|
|
|
The voice connection session ID. |
159
|
|
|
token: :class:`str` |
160
|
|
|
The voice connection token. |
161
|
|
|
endpoint: :class:`str` |
162
|
|
|
The endpoint we are connecting to. |
163
|
|
|
channel: :class:`abc.Connectable` |
164
|
|
|
The voice channel connected to. |
165
|
|
|
loop: :class:`asyncio.AbstractEventLoop` |
166
|
|
|
The event loop that the voice client is running on. |
167
|
|
|
""" |
168
|
|
|
endpoint_ip: str |
169
|
|
|
voice_port: int |
170
|
|
|
secret_key: List[int] |
171
|
|
|
ssrc: int |
172
|
|
|
|
173
|
|
|
def __init__(self, client: Client, channel: abc.Connectable): |
174
|
|
|
if not has_nacl: |
175
|
|
|
raise RuntimeError("PyNaCl library needed in order to use voice") |
176
|
|
|
|
177
|
|
|
super().__init__(client, channel) |
178
|
|
|
state = client._connection |
179
|
|
|
self.token: str = MISSING |
180
|
|
|
self.socket = MISSING |
181
|
|
|
self.loop: asyncio.AbstractEventLoop = state.loop |
182
|
|
|
self._state: ConnectionState = state |
183
|
|
|
# this will be used in the AudioPlayer thread |
184
|
|
|
self._connected: threading.Event = threading.Event() |
185
|
|
|
|
186
|
|
|
self._handshaking: bool = False |
187
|
|
|
self._potentially_reconnecting: bool = False |
188
|
|
|
self._voice_state_complete: asyncio.Event = asyncio.Event() |
189
|
|
|
self._voice_server_complete: asyncio.Event = asyncio.Event() |
190
|
|
|
|
191
|
|
|
self.mode: str = MISSING |
192
|
|
|
self._connections: int = 0 |
193
|
|
|
self.sequence: int = 0 |
194
|
|
|
self.timestamp: int = 0 |
195
|
|
|
self.timeout: float = 0 |
196
|
|
|
self._runner: asyncio.Task = MISSING |
197
|
|
|
self._player: Optional[AudioPlayer] = None |
198
|
|
|
self.encoder: Encoder = MISSING |
199
|
|
|
self._lite_nonce: int = 0 |
200
|
|
|
self.ws: DiscordVoiceWebSocket = MISSING |
|
|
|
|
201
|
|
|
|
202
|
|
|
warn_nacl = not has_nacl |
203
|
|
|
supported_modes: Tuple[SupportedModes, ...] = ( |
204
|
|
|
'xsalsa20_poly1305_lite', |
205
|
|
|
'xsalsa20_poly1305_suffix', |
206
|
|
|
'xsalsa20_poly1305', |
207
|
|
|
) |
208
|
|
|
|
209
|
|
|
@property |
210
|
|
|
def guild(self) -> Optional[Guild]: |
211
|
|
|
"""Optional[:class:`Guild`]: The guild we're connected to, if applicable.""" |
212
|
|
|
return getattr(self.channel, 'guild', None) |
213
|
|
|
|
214
|
|
|
@property |
215
|
|
|
def user(self) -> ClientUser: |
216
|
|
|
""":class:`ClientUser`: The user connected to voice (i.e. ourselves).""" |
217
|
|
|
return self._state.user |
218
|
|
|
|
219
|
|
|
def checked_add(self, attr, value, limit): |
|
|
|
|
220
|
|
|
val = getattr(self, attr) |
221
|
|
|
if val + value > limit: |
222
|
|
|
setattr(self, attr, 0) |
223
|
|
|
else: |
224
|
|
|
setattr(self, attr, val + value) |
225
|
|
|
|
226
|
|
|
# connection related |
227
|
|
|
|
228
|
|
|
async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None: |
229
|
|
|
self.session_id = data['session_id'] |
|
|
|
|
230
|
|
|
channel_id = data['channel_id'] |
231
|
|
|
|
232
|
|
|
if not self._handshaking or self._potentially_reconnecting: |
233
|
|
|
# If we're done handshaking then we just need to update ourselves |
234
|
|
|
# If we're potentially reconnecting due to a 4014, then we need to differentiate |
235
|
|
|
# a channel move and an actual force disconnect |
236
|
|
|
if channel_id is None: |
237
|
|
|
# We're being disconnected so cleanup |
238
|
|
|
await self.disconnect() |
239
|
|
|
else: |
240
|
|
|
guild = self.guild |
241
|
|
|
self.channel = channel_id and guild and guild.get_channel( |
242
|
|
|
int(channel_id)) # type: ignore |
243
|
|
|
else: |
244
|
|
|
self._voice_state_complete.set() |
245
|
|
|
|
246
|
|
|
async def on_voice_server_update(self, |
247
|
|
|
data: VoiceServerUpdatePayload) -> None: |
248
|
|
|
if self._voice_server_complete.is_set(): |
249
|
|
|
_log.info('Ignoring extraneous voice server update.') |
250
|
|
|
return |
251
|
|
|
|
252
|
|
|
self.token = data.get('token') |
253
|
|
|
self.server_id = int(data['guild_id']) |
|
|
|
|
254
|
|
|
endpoint = data.get('endpoint') |
255
|
|
|
|
256
|
|
|
if endpoint is None or self.token is None: |
257
|
|
|
_log.warning('Awaiting endpoint... This requires waiting. ' \ |
258
|
|
|
'If timeout occurred considering raising the timeout and reconnecting.') |
259
|
|
|
return |
260
|
|
|
|
261
|
|
|
self.endpoint, _, _ = endpoint.rpartition(':') |
|
|
|
|
262
|
|
|
if self.endpoint.startswith('wss://'): |
263
|
|
|
# Just in case, strip it off since we're going to add it later |
264
|
|
|
self.endpoint = self.endpoint[6:] |
|
|
|
|
265
|
|
|
|
266
|
|
|
# This gets set later |
267
|
|
|
self.endpoint_ip = MISSING |
268
|
|
|
|
269
|
|
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
270
|
|
|
self.socket.setblocking(False) |
271
|
|
|
|
272
|
|
|
if not self._handshaking: |
273
|
|
|
# If we're not handshaking then we need to terminate our previous connection in the websocket |
|
|
|
|
274
|
|
|
await self.ws.close(4000) |
275
|
|
|
return |
276
|
|
|
|
277
|
|
|
self._voice_server_complete.set() |
278
|
|
|
|
279
|
|
|
async def voice_connect(self) -> None: |
|
|
|
|
280
|
|
|
await self.channel.guild.change_voice_state(channel=self.channel) |
281
|
|
|
|
282
|
|
|
async def voice_disconnect(self) -> None: |
|
|
|
|
283
|
|
|
_log.info( |
284
|
|
|
'The voice handshake is being terminated for Channel ID %s (Guild ID %s)', |
285
|
|
|
self.channel.id, self.guild.id) |
286
|
|
|
await self.channel.guild.change_voice_state(channel=None) |
287
|
|
|
|
288
|
|
|
def prepare_handshake(self) -> None: |
|
|
|
|
289
|
|
|
self._voice_state_complete.clear() |
290
|
|
|
self._voice_server_complete.clear() |
291
|
|
|
self._handshaking = True |
292
|
|
|
_log.info('Starting voice handshake... (connection attempt %d)', |
293
|
|
|
self._connections + 1) |
294
|
|
|
self._connections += 1 |
295
|
|
|
|
296
|
|
|
def finish_handshake(self) -> None: |
|
|
|
|
297
|
|
|
_log.info('Voice handshake complete. Endpoint found %s', self.endpoint) |
298
|
|
|
self._handshaking = False |
299
|
|
|
self._voice_server_complete.clear() |
300
|
|
|
self._voice_state_complete.clear() |
301
|
|
|
|
302
|
|
|
async def connect_websocket(self) -> DiscordVoiceWebSocket: |
|
|
|
|
303
|
|
|
ws = await DiscordVoiceWebSocket.from_client(self) |
|
|
|
|
304
|
|
|
self._connected.clear() |
305
|
|
|
while ws.secret_key is None: |
306
|
|
|
await ws.poll_event() |
307
|
|
|
self._connected.set() |
308
|
|
|
return ws |
309
|
|
|
|
310
|
|
|
async def connect(self, *, reconnect: bool, timeout: float) -> None: |
|
|
|
|
311
|
|
|
_log.info('Connecting to voice...') |
312
|
|
|
self.timeout = timeout |
313
|
|
|
|
314
|
|
|
for i in range(5): |
315
|
|
|
self.prepare_handshake() |
316
|
|
|
|
317
|
|
|
# This has to be created before we start the flow. |
318
|
|
|
futures = [ |
319
|
|
|
self._voice_state_complete.wait(), |
320
|
|
|
self._voice_server_complete.wait(), |
321
|
|
|
] |
322
|
|
|
|
323
|
|
|
# Start the connection flow |
324
|
|
|
await self.voice_connect() |
325
|
|
|
|
326
|
|
|
try: |
327
|
|
|
await utils.sane_wait_for(futures, timeout=timeout) |
328
|
|
|
except asyncio.TimeoutError: |
329
|
|
|
await self.disconnect(force=True) |
330
|
|
|
raise |
331
|
|
|
|
332
|
|
|
self.finish_handshake() |
333
|
|
|
|
334
|
|
|
try: |
335
|
|
|
self.ws = await self.connect_websocket() |
336
|
|
|
break |
337
|
|
|
except (ConnectionClosed, asyncio.TimeoutError): |
338
|
|
|
if reconnect: |
|
|
|
|
339
|
|
|
_log.exception('Failed to connect to voice... Retrying...') |
340
|
|
|
await asyncio.sleep(1 + i * 2.0) |
341
|
|
|
await self.voice_disconnect() |
342
|
|
|
continue |
343
|
|
|
else: |
344
|
|
|
raise |
345
|
|
|
|
346
|
|
|
if self._runner is MISSING: |
347
|
|
|
self._runner = self.loop.create_task(self.poll_voice_ws(reconnect)) |
348
|
|
|
|
349
|
|
|
async def potential_reconnect(self) -> bool: |
|
|
|
|
350
|
|
|
# Attempt to stop the player thread from playing early |
351
|
|
|
self._connected.clear() |
352
|
|
|
self.prepare_handshake() |
353
|
|
|
self._potentially_reconnecting = True |
354
|
|
|
try: |
355
|
|
|
# We only care about VOICE_SERVER_UPDATE since VOICE_STATE_UPDATE can come before we get disconnected |
|
|
|
|
356
|
|
|
await asyncio.wait_for(self._voice_server_complete.wait(), |
357
|
|
|
timeout=self.timeout) |
358
|
|
|
except asyncio.TimeoutError: |
359
|
|
|
self._potentially_reconnecting = False |
360
|
|
|
await self.disconnect(force=True) |
361
|
|
|
return False |
362
|
|
|
|
363
|
|
|
self.finish_handshake() |
364
|
|
|
self._potentially_reconnecting = False |
365
|
|
|
try: |
366
|
|
|
self.ws = await self.connect_websocket() |
367
|
|
|
except (ConnectionClosed, asyncio.TimeoutError): |
368
|
|
|
return False |
369
|
|
|
else: |
370
|
|
|
return True |
371
|
|
|
|
372
|
|
|
@property |
373
|
|
|
def latency(self) -> float: |
374
|
|
|
""":class:`float`: Latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. |
375
|
|
|
This could be referred to as the Discord Voice WebSocket latency and is |
376
|
|
|
an analogue of user's voice latencies as seen in the Discord client. |
377
|
|
|
.. versionadded:: 1.4 |
378
|
|
|
""" |
379
|
|
|
ws = self.ws |
380
|
|
|
return float("inf") if not ws else ws.latency |
381
|
|
|
|
382
|
|
|
@property |
383
|
|
|
def average_latency(self) -> float: |
384
|
|
|
""":class:`float`: Average of most recent 20 HEARTBEAT latencies in seconds. |
385
|
|
|
.. versionadded:: 1.4 |
386
|
|
|
""" |
387
|
|
|
ws = self.ws |
388
|
|
|
return float("inf") if not ws else ws.average_latency |
389
|
|
|
|
390
|
|
|
async def poll_voice_ws(self, reconnect: bool) -> None: |
|
|
|
|
391
|
|
|
backoff = ExponentialBackoff() |
392
|
|
|
while True: |
393
|
|
|
try: |
394
|
|
|
await self.ws.poll_event() |
395
|
|
|
except (ConnectionClosed, asyncio.TimeoutError) as exc: |
396
|
|
|
if isinstance(exc, ConnectionClosed): |
397
|
|
|
# The following close codes are undocumented so I will document them here. |
398
|
|
|
# 1000 - normal closure (obviously) |
399
|
|
|
# 4014 - voice channel has been deleted. |
400
|
|
|
# 4015 - voice server has crashed |
401
|
|
|
if exc.code in (1000, 4015): |
402
|
|
|
_log.info( |
403
|
|
|
'Disconnecting from voice normally, close code %d.', |
404
|
|
|
exc.code) |
405
|
|
|
await self.disconnect() |
406
|
|
|
break |
407
|
|
|
if exc.code == 4014: |
408
|
|
|
_log.info( |
409
|
|
|
'Disconnected from voice by force... potentially reconnecting.') |
410
|
|
|
successful = await self.potential_reconnect() |
411
|
|
|
if not successful: |
|
|
|
|
412
|
|
|
_log.info( |
413
|
|
|
'Reconnect was unsuccessful, disconnecting from voice normally...') |
414
|
|
|
await self.disconnect() |
415
|
|
|
break |
416
|
|
|
else: |
417
|
|
|
continue |
418
|
|
|
|
419
|
|
|
if not reconnect: |
420
|
|
|
await self.disconnect() |
421
|
|
|
raise |
422
|
|
|
|
423
|
|
|
retry = backoff.delay() |
424
|
|
|
_log.exception( |
425
|
|
|
'Disconnected from voice... Reconnecting in %.2fs.', retry) |
426
|
|
|
self._connected.clear() |
427
|
|
|
await asyncio.sleep(retry) |
428
|
|
|
await self.voice_disconnect() |
429
|
|
|
try: |
430
|
|
|
await self.connect(reconnect=True, timeout=self.timeout) |
431
|
|
|
except asyncio.TimeoutError: |
432
|
|
|
# at this point we've retried 5 times... let's continue the loop. |
433
|
|
|
_log.warning('Could not connect to voice... Retrying...') |
434
|
|
|
continue |
435
|
|
|
|
436
|
|
|
async def disconnect(self, *, force: bool = False) -> None: |
437
|
|
|
"""|coro| |
438
|
|
|
Disconnects this voice client from voice. |
439
|
|
|
""" |
440
|
|
|
if not force and not self.is_connected(): |
441
|
|
|
return |
442
|
|
|
|
443
|
|
|
self.stop() |
444
|
|
|
self._connected.clear() |
445
|
|
|
|
446
|
|
|
try: |
447
|
|
|
if self.ws: |
448
|
|
|
await self.ws.close() |
449
|
|
|
|
450
|
|
|
await self.voice_disconnect() |
451
|
|
|
finally: |
452
|
|
|
self.cleanup() |
453
|
|
|
if self.socket: |
454
|
|
|
self.socket.close() |
455
|
|
|
|
456
|
|
|
async def move_to(self, channel: abc.Snowflake) -> None: |
457
|
|
|
"""|coro| |
458
|
|
|
Moves you to a different voice channel. |
459
|
|
|
Parameters |
460
|
|
|
----------- |
461
|
|
|
channel: :class:`abc.Snowflake` |
462
|
|
|
The channel to move to. Must be a voice channel. |
463
|
|
|
""" |
464
|
|
|
await self.channel.guild.change_voice_state(channel=channel) |
465
|
|
|
|
466
|
|
|
def is_connected(self) -> bool: |
467
|
|
|
"""Indicates if the voice client is connected to voice.""" |
468
|
|
|
return self._connected.is_set() |
469
|
|
|
|
470
|
|
|
# audio related |
471
|
|
|
|
472
|
|
|
def _get_voice_packet(self, data): |
473
|
|
|
header = bytearray(12) |
474
|
|
|
|
475
|
|
|
# Formulate rtp header |
476
|
|
|
header[0] = 0x80 |
477
|
|
|
header[1] = 0x78 |
478
|
|
|
struct.pack_into('>H', header, 2, self.sequence) |
479
|
|
|
struct.pack_into('>I', header, 4, self.timestamp) |
480
|
|
|
struct.pack_into('>I', header, 8, self.ssrc) |
481
|
|
|
|
482
|
|
|
encrypt_packet = getattr(self, '_encrypt_' + self.mode) |
483
|
|
|
return encrypt_packet(header, data) |
484
|
|
|
|
485
|
|
|
def _encrypt_xsalsa20_poly1305(self, header: bytes, data) -> bytes: |
486
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
487
|
|
|
nonce = bytearray(24) |
488
|
|
|
nonce[:12] = header |
489
|
|
|
|
490
|
|
|
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext |
491
|
|
|
|
492
|
|
|
def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes: |
493
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
494
|
|
|
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) |
495
|
|
|
|
496
|
|
|
return header + box.encrypt(bytes(data), nonce).ciphertext + nonce |
497
|
|
|
|
498
|
|
|
def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes: |
499
|
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key)) |
500
|
|
|
nonce = bytearray(24) |
501
|
|
|
|
502
|
|
|
nonce[:4] = struct.pack('>I', self._lite_nonce) |
503
|
|
|
self.checked_add('_lite_nonce', 1, 4294967295) |
504
|
|
|
|
505
|
|
|
return header + box.encrypt(bytes(data), |
506
|
|
|
bytes(nonce)).ciphertext + nonce[:4] |
507
|
|
|
|
508
|
|
|
def play(self, source: AudioSource, *, |
509
|
|
|
after: Callable[[Optional[Exception]], Any] = None) -> None: |
510
|
|
|
"""Plays an :class:`AudioSource`. |
511
|
|
|
The finalizer, ``after`` is called after the source has been exhausted |
512
|
|
|
or an error occurred. |
513
|
|
|
If an error happens while the audio player is running, the exception is |
514
|
|
|
caught and the audio player is then stopped. If no after callback is |
515
|
|
|
passed, any caught exception will be displayed as if it were raised. |
516
|
|
|
Parameters |
517
|
|
|
----------- |
518
|
|
|
source: :class:`AudioSource` |
519
|
|
|
The audio source we're reading from. |
520
|
|
|
after: Callable[[Optional[:class:`Exception`]], Any] |
521
|
|
|
The finalizer that is called after the stream is exhausted. |
522
|
|
|
This function must have a single parameter, ``error``, that |
523
|
|
|
denotes an optional exception that was raised during playing. |
524
|
|
|
Raises |
525
|
|
|
------- |
526
|
|
|
ClientException |
527
|
|
|
Already playing audio or not connected. |
528
|
|
|
TypeError |
529
|
|
|
Source is not a :class:`AudioSource` or after is not a callable. |
530
|
|
|
OpusNotLoaded |
531
|
|
|
Source is not opus encoded and opus is not loaded. |
532
|
|
|
""" |
533
|
|
|
|
534
|
|
|
if not self.is_connected(): |
535
|
|
|
raise ClientException('Not connected to voice.') |
536
|
|
|
|
537
|
|
|
if self.is_playing(): |
538
|
|
|
raise ClientException('Already playing audio.') |
539
|
|
|
|
540
|
|
|
if not isinstance(source, AudioSource): |
541
|
|
|
raise TypeError( |
542
|
|
|
f'source must be an AudioSource not {source.__class__.__name__}') |
543
|
|
|
|
544
|
|
|
if not self.encoder and not source.is_opus(): |
545
|
|
|
self.encoder = opus.Encoder() |
546
|
|
|
|
547
|
|
|
self._player = AudioPlayer(source, self, after=after) |
548
|
|
|
self._player.start() |
549
|
|
|
|
550
|
|
|
def is_playing(self) -> bool: |
551
|
|
|
"""Indicates if we're currently playing audio.""" |
552
|
|
|
return self._player is not None and self._player.is_playing() |
553
|
|
|
|
554
|
|
|
def is_paused(self) -> bool: |
555
|
|
|
"""Indicates if we're playing audio, but if we're paused.""" |
556
|
|
|
return self._player is not None and self._player.is_paused() |
557
|
|
|
|
558
|
|
|
def stop(self) -> None: |
559
|
|
|
"""Stops playing audio.""" |
560
|
|
|
if self._player: |
561
|
|
|
self._player.stop() |
562
|
|
|
self._player = None |
563
|
|
|
|
564
|
|
|
def pause(self) -> None: |
565
|
|
|
"""Pauses the audio playing.""" |
566
|
|
|
if self._player: |
567
|
|
|
self._player.pause() |
568
|
|
|
|
569
|
|
|
def resume(self) -> None: |
570
|
|
|
"""Resumes the audio playing.""" |
571
|
|
|
if self._player: |
572
|
|
|
self._player.resume() |
573
|
|
|
|
574
|
|
|
@property |
575
|
|
|
def source(self) -> Optional[AudioSource]: |
576
|
|
|
"""Optional[:class:`AudioSource`]: The audio source being played, if playing. |
577
|
|
|
This property can also be used to change the audio source currently being played. |
578
|
|
|
""" |
579
|
|
|
return self._player.source if self._player else None |
580
|
|
|
|
581
|
|
|
@source.setter |
582
|
|
|
def source(self, value: AudioSource) -> None: |
583
|
|
|
if not isinstance(value, AudioSource): |
584
|
|
|
raise TypeError( |
585
|
|
|
f'expected AudioSource not {value.__class__.__name__}.') |
586
|
|
|
|
587
|
|
|
if self._player is None: |
588
|
|
|
raise ValueError('Not playing anything.') |
589
|
|
|
|
590
|
|
|
self._player._set_source(value) |
|
|
|
|
591
|
|
|
|
592
|
|
|
def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None: |
593
|
|
|
"""Sends an audio packet composed of the data. |
594
|
|
|
You must be connected to play audio. |
595
|
|
|
Parameters |
596
|
|
|
---------- |
597
|
|
|
data: :class:`bytes` |
598
|
|
|
The :term:`py:bytes-like object` denoting PCM or Opus voice data. |
599
|
|
|
encode: :class:`bool` |
600
|
|
|
Indicates if ``data`` should be encoded into Opus. |
601
|
|
|
Raises |
602
|
|
|
------- |
603
|
|
|
ClientException |
604
|
|
|
You are not connected. |
605
|
|
|
opus.OpusError |
606
|
|
|
Encoding the data failed. |
607
|
|
|
""" |
608
|
|
|
|
609
|
|
|
self.checked_add('sequence', 1, 65535) |
610
|
|
|
if encode: |
611
|
|
|
encoded_data = self.encoder.encode(data, |
612
|
|
|
self.encoder.SAMPLES_PER_FRAME) |
613
|
|
|
else: |
614
|
|
|
encoded_data = data |
615
|
|
|
packet = self._get_voice_packet(encoded_data) |
616
|
|
|
try: |
617
|
|
|
self.socket.sendto(packet, (self.endpoint_ip, self.voice_port)) |
618
|
|
|
except BlockingIOError: |
619
|
|
|
_log.warning('A packet has been dropped (seq: %s, timestamp: %s)', |
620
|
|
|
self.sequence, self.timestamp) |
621
|
|
|
|
622
|
|
|
self.checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, |
623
|
|
|
4294967295) |
624
|
|
|
|