Voice et bot modif

This commit is contained in:
pi 2026-06-16 17:09:34 +00:00
parent 189d56026b
commit 7333a22bcd
10774 changed files with 634644 additions and 933308 deletions

View file

@ -47,7 +47,7 @@ from typing import (
Union,
)
from ._types import _BaseCommand, BotT
from ._types import _BaseCommand, BotT, MaybeCoro
if TYPE_CHECKING:
from typing_extensions import Self
@ -583,7 +583,7 @@ class Cog(metaclass=CogMeta):
pass
@_cog_special_method
def bot_check_once(self, ctx: Context[BotT]) -> bool:
def bot_check_once(self, ctx: Context[BotT]) -> MaybeCoro[bool]:
"""A special method that registers as a :meth:`.Bot.check_once`
check.
@ -593,7 +593,7 @@ class Cog(metaclass=CogMeta):
return True
@_cog_special_method
def bot_check(self, ctx: Context[BotT]) -> bool:
def bot_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]:
"""A special method that registers as a :meth:`.Bot.check`
check.
@ -603,7 +603,7 @@ class Cog(metaclass=CogMeta):
return True
@_cog_special_method
def cog_check(self, ctx: Context[BotT]) -> bool:
def cog_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]:
"""A special method that registers as a :func:`~discord.ext.commands.check`
for every command and subcommand in this cog.
@ -613,7 +613,7 @@ class Cog(metaclass=CogMeta):
return True
@_cog_special_method
def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> bool:
def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> MaybeCoro[bool]:
"""A special method that registers as a :func:`discord.app_commands.check`
for every app command and subcommand in this cog.
@ -646,7 +646,9 @@ class Cog(metaclass=CogMeta):
pass
@_cog_special_method
async def cog_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
async def cog_app_command_error(
self, interaction: discord.Interaction[ClientT], error: app_commands.AppCommandError
) -> None:
"""|coro|
A special method that is called whenever an error within

View file

@ -259,6 +259,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
bot: BotT = interaction.client
data: ApplicationCommandInteractionData = interaction.data # type: ignore
type_ = data.get('type', 1)
if interaction.message is None:
synthetic_payload = {
'id': interaction.id,
@ -268,7 +269,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
'tts': False,
'pinned': False,
'edited_timestamp': None,
'type': MessageType.chat_input_command if data.get('type', 1) == 1 else MessageType.context_menu_command,
'type': MessageType.chat_input_command.value if type_ == 1 else MessageType.context_menu_command.value,
'flags': 64,
'content': '',
'mentions': [],
@ -288,7 +289,7 @@ class Context(discord.abc.Messageable, Generic[BotT]):
else:
message = interaction.message
prefix = '/' if data.get('type', 1) == 1 else '\u200b' # Mock the prefix
prefix = '/' if type_ == 1 else '\u200b' # Mock the prefix
ctx = cls(
message=message,
bot=bot,

View file

@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import datetime
import inspect
import re
from typing import (
@ -86,6 +87,7 @@ __all__ = (
'clean_content',
'Greedy',
'Range',
'Timestamp',
'run_converters',
)
@ -893,6 +895,28 @@ class GuildStickerConverter(IDConverter[discord.GuildSticker]):
return result
if TYPE_CHECKING:
Timestamp = datetime.datetime
else:
class Timestamp(Converter[str]):
"""Converts to a :class:`datetime.datetime`.
Conversion is attempted based on the :ddocs:`Discord style timestamp <reference#message-formatting>` input format.
.. versionadded:: 2.7
.. warning::
Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead.
"""
async def convert(self, ctx: Context[BotT], argument: str) -> datetime.datetime:
match = discord.utils.TIMESTAMP_PATTERN.match(argument)
if not match:
raise BadTimestampArgument(argument)
return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc)
class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]):
"""Converts to a :class:`~discord.ScheduledEvent`.

View file

@ -79,6 +79,7 @@ __all__ = (
'SoundboardSoundNotFound',
'PartialEmojiConversionFailure',
'BadBoolArgument',
'BadTimestampArgument',
'MissingRole',
'BotMissingRole',
'MissingAnyRole',
@ -602,6 +603,24 @@ class BadBoolArgument(BadArgument):
super().__init__(f'{argument} is not a recognised boolean option')
class BadTimestampArgument(BadArgument):
"""Exception raised when a timestamp argument was not convertable.
This inherits from :exc:`BadArgument`
.. versionadded:: 2.7
Attributes
-----------
argument: :class:`str`
The datetime/timestamp argument supplied by the caller that was not a valid timestamp format.
"""
def __init__(self, argument: str) -> None:
self.argument: str = argument
super().__init__(f'{argument} is not a recognised datetime or timestamp option')
class RangeError(BadArgument):
"""Exception raised when an argument is out of range.
@ -870,7 +889,7 @@ class BotMissingPermissions(CheckFailure):
class BadUnionArgument(UserInputError):
"""Exception raised when a :data:`typing.Union` converter fails for all
"""Exception raised when a :obj:`typing.Union` converter fails for all
its associated types.
This inherits from :exc:`UserInputError`

View file

@ -50,6 +50,25 @@ if TYPE_CHECKING:
from .context import Context
from .parameters import Parameter
try:
from annotationlib import call_annotate_function, get_annotate_from_class_namespace # type: ignore
def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]:
# In Python 3.14, classes no longer get `__annotations__` and instead a function
# under __annotate__ is used instead that that takes a format argument on how to
# receive those annotations.
# Format 1 is full value, Format 3 is value and ForwardRef for undefined ones
# So format 3 is the one we're typically used to
annotate = get_annotate_from_class_namespace(namespace)
if annotate is not None:
return call_annotate_function(annotate, 3) # type: ignore
return namespace.get('__annotations__', {})
except ImportError:
def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]:
return namespace.get('__annotations__', {})
@dataclass
class Flag:
@ -177,7 +196,7 @@ def validate_flag_name(name: str, forbidden: Set[str]) -> None:
def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[str, Any]) -> Dict[str, Flag]:
annotations = namespace.get('__annotations__', {})
annotations = get_annotations_from_namespace(namespace)
case_insensitive = namespace['__commands_flag_case_insensitive__']
flags: Dict[str, Flag] = {}
cache: Dict[str, Any] = {}

View file

@ -534,6 +534,10 @@ class HybridCommand(Command[CogT, P, T]):
HybridAppCommand(self) if self.with_app_command else None
)
@property
def __discord_app_commands_unwrap__(self) -> Optional[HybridAppCommand[CogT, Any, T]]:
return self.app_command
@property
def cog(self) -> CogT:
return self._cog
@ -702,6 +706,10 @@ class HybridGroup(Group[CogT, P, T]):
return None
return self.app_command.get_command(self.fallback) # type: ignore
@property
def __discord_app_commands_unwrap__(self) -> Optional[app_commands.Group]:
return self.app_command
@property
def cog(self) -> CogT:
return self._cog

View file

@ -37,6 +37,7 @@ from typing import (
Type,
TypeVar,
Union,
overload,
)
import aiohttp
@ -176,7 +177,7 @@ class Loop(Generic[LF]):
if self.count is not None and self.count <= 0:
raise ValueError('count must be greater than 0 or None.')
self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time)
self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) # type: ignore
self._last_iteration_failed = False
self._last_iteration: datetime.datetime = MISSING
self._next_iteration = None
@ -710,6 +711,22 @@ class Loop(Generic[LF]):
ret = sorted(set(ret)) # de-dupe and sort times
return ret
@overload
def change_interval(
self,
*,
seconds: float = 0,
minutes: float = 0,
hours: float = 0,
) -> None: ...
@overload
def change_interval(
self,
*,
time: Union[datetime.time, Sequence[datetime.time]],
) -> None: ...
def change_interval(
self,
*,
@ -777,6 +794,28 @@ class Loop(Generic[LF]):
self._handle.recalculate(self._next_iteration)
@overload
def loop(
*,
seconds: float = 0,
minutes: float = 0,
hours: float = 0,
count: Optional[int] = None,
reconnect: bool = True,
name: Optional[str] = None,
) -> Callable[[LF], Loop[LF]]: ...
@overload
def loop(
*,
time: Union[datetime.time, Sequence[datetime.time]],
count: Optional[int] = None,
reconnect: bool = True,
name: Optional[str] = None,
) -> Callable[[LF], Loop[LF]]: ...
def loop(
*,
seconds: float = MISSING,

View file

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
from .voice_client import *
from .reader import *
from .sinks import *
from .video import *
from .opus import *
from .rtp import *
from .enums import *
from . import (
rtp as rtp,
extras as extras,
)
__title__ = 'discord.ext.voice_recv'
__author__ = 'Imayhaveborkedit'
__license__ = 'MIT'
__copyright__ = 'Copyright 2021-present Imayhaveborkedit'
__version__ = '0.5.3a'

View file

@ -1,249 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import heapq
import logging
import threading
from .utils import gap_wrapped, add_wrapped
from typing import (
TYPE_CHECKING,
Protocol,
TypeVar,
)
from .rtp import _PacketCmpMixin
if TYPE_CHECKING:
from typing import Optional, List
from .rtp import AudioPacket
__all__ = [
'HeapJitterBuffer',
]
_T = TypeVar('_T')
PacketT = TypeVar('PacketT', bound=_PacketCmpMixin)
log = logging.getLogger(__name__)
class Buffer(Protocol[_T]):
"""The base class representing a simple buffer with no extra features."""
# fmt: off
def __len__(self) -> int: ...
def push(self, item: _T) -> None: ...
def pop(self) -> Optional[_T]: ...
def peek(self) -> Optional[_T]: ...
def flush(self) -> List[_T]: ...
def reset(self) -> None: ...
# fmt: on
class BaseBuffer(Buffer[PacketT]):
"""A basic buffer."""
def __init__(self):
self._buffer: List[PacketT] = []
def __len__(self) -> int:
return len(self._buffer)
def push(self, item: PacketT) -> None:
self._buffer.append(item)
def pop(self) -> Optional[PacketT]:
return self._buffer.pop()
def peek(self) -> Optional[PacketT]:
return self._buffer[-1] if self._buffer else None
def flush(self) -> List[PacketT]:
buf = self._buffer.copy()
self._buffer.clear()
return buf
def reset(self) -> None:
self._buffer.clear()
class HeapJitterBuffer(BaseBuffer[PacketT]):
"""Push item in, pop items out"""
_threshold: int = 10000
def __init__(self, maxsize: int = 10, *, prefsize: int = 1, prefill: int = 1):
if maxsize < 1:
raise ValueError(f'maxsize ({maxsize}) must be greater than 0')
if not 0 <= prefsize <= maxsize:
raise ValueError(f'prefsize must be between 0 and maxsize ({maxsize})')
self.maxsize: int = maxsize
self.prefsize: int = prefsize
self.prefill: int = prefill
self._prefill: int = prefill
self._last_tx_seq: int = -1
self._has_item: threading.Event = threading.Event()
# I sure hope I dont need to add a lock to this
self._buffer: List[AudioPacket] = []
def _push(self, packet: AudioPacket) -> None:
heapq.heappush(self._buffer, packet)
def _pop(self) -> AudioPacket:
return heapq.heappop(self._buffer)
def _get_packet_if_ready(self) -> Optional[AudioPacket]:
return self._buffer[0] if len(self._buffer) > self.prefsize else None
def _pop_if_ready(self) -> Optional[AudioPacket]:
return self._pop() if len(self._buffer) > self.prefsize else None
def _update_has_item(self) -> None:
prefilled = self._prefill == 0
packet_ready = len(self._buffer) > self.prefsize
if not prefilled or not packet_ready:
self._has_item.clear()
return
next_packet = self._buffer[0]
sequential = add_wrapped(self._last_tx_seq, 1) == next_packet.sequence
positive_seq = self._last_tx_seq >= 0
# We have the next packet ready
# OR we havent sent a packet out yet
# OR the buffer is full
if (sequential and positive_seq) or not positive_seq or len(self._buffer) >= self.maxsize:
self._has_item.set()
else:
self._has_item.clear()
def _cleanup(self) -> None:
# Logging this is pointless until I fix the stale remote buffer issue
# if len(self._buffer) > self.maxsize:
# log.debug("Buffer overfilled: %s > %s", len(self._buffer), self.maxsize)
# drop oldest packets if buffer overfilled
while len(self._buffer) > self.maxsize:
packet = heapq.heappop(self._buffer)
# log.debug("Dropped extra packet %s", packet)
def push(self, packet: AudioPacket) -> bool:
"""
Push a packet into the buffer. If the packet would make the buffer
exceed its maxsize, the oldest packet will be dropped.
"""
seq = packet.sequence
# for the gap between _last_tx_seq and the current one, a large gap is old, a small gap is new
# the gap for old packets will generally be very large since they wrap all the way around
if gap_wrapped(self._last_tx_seq, seq) > self._threshold and self._last_tx_seq != -1:
log.debug("Dropping old packet %s", packet)
return False
self._push(packet)
if self._prefill > 0:
self._prefill -= 1
self._cleanup()
self._update_has_item()
return True
def pop(self, *, timeout: float | None = 0) -> Optional[AudioPacket]:
"""
If timeout is a positive number, wait as long as timeout for a packet
to be ready and return that packet, otherwise return None.
"""
ok = self._has_item.wait(timeout)
if not ok:
return None
if self._prefill > 0:
return None
# This function should actually be redundant but i'll leave it for now
packet = self._pop_if_ready()
if packet is not None:
self._last_tx_seq = packet.sequence
self._update_has_item()
return packet
def peek(self, *, all: bool = False) -> Optional[AudioPacket]:
"""
Returns the next packet in the buffer only if it is ready, meaning it can
be popped. When `all` is set to True, it returns the next packet, if any.
"""
if not self._buffer:
return None
if all:
return self._buffer[0]
else:
return self._get_packet_if_ready()
def peek_next(self) -> Optional[AudioPacket]:
"""
Returns the next packet in the buffer only if it is sequential.
"""
packet = self.peek(all=True)
if packet is None:
return
if packet.sequence == add_wrapped(self._last_tx_seq, 1) or self._last_tx_seq < 0:
return packet
def gap(self) -> int:
"""
Returns the number of missing packets between the last packet to be
popped and the currently held next packet. Returns 0 otherwise.
"""
if self._buffer and self._last_tx_seq > 0:
return gap_wrapped(self._last_tx_seq, self._buffer[0].sequence)
return 0
def flush(self) -> List[AudioPacket]:
"""
Return all remaining packets.
"""
packets = sorted(self._buffer)
self._buffer.clear()
if packets:
self._last_tx_seq = packets[-1].sequence
self._prefill = self.prefill
self._has_item.clear()
return packets
def reset(self) -> None:
"""
Clear buffer and reset internal counters.
"""
self._buffer.clear()
self._has_item.clear()
self._prefill = self.prefill
self._last_tx_seq = -1

View file

@ -1,30 +0,0 @@
from discord.flags import BaseFlags, fill_with_flags, flag_value
from discord.enums import Enum
__all__ = (
'VoiceFlags',
'VoicePlatform',
)
@fill_with_flags()
class VoiceFlags(BaseFlags):
__slots__ = ()
@flag_value
def clips_enabled(self):
return 1 << 0
@flag_value
def allow_voice_recording(self):
return 1 << 1
@flag_value
def allow_any_viewer_clips(self):
return 1 << 2
class VoicePlatform(Enum):
desktop = 0
mobile = 1
xbox = 2
playstation = 3

View file

@ -1,2 +0,0 @@
from . import speechrecognition
from . import localplayback

View file

@ -1,132 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from ..sinks import AudioSink
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..opus import VoiceData
from ..types import MemberOrUser
__all__ = [
'LocalPlaybackSink',
'SimpleLocalPlaybackSink',
]
log = logging.getLogger(__name__)
try:
import pyaudio
except ImportError:
def __getattr__(name: str):
if name in __all__:
raise RuntimeError('The pyaudio module is required to use this sink.')
else:
if TYPE_CHECKING:
from typing import Optional, Dict
from discord import Member
PyAudioStream = pyaudio._Stream
class _BaseLocalPlaybackSink(AudioSink):
pa: pyaudio.PyAudio = None # type: ignore
def __init__(self, output_device_id: Optional[int] = None, *, py_audio: Optional[pyaudio.PyAudio] = None):
self._init_pa(py_audio)
if output_device_id is None:
output_device_id = self.pa.get_default_output_device_info().get("index") # type: ignore
self.output_device_id = output_device_id
@classmethod
def _init_pa(cls, pa: Optional[pyaudio.PyAudio]) -> None:
if pa is None:
if cls.pa is None:
cls.pa = pyaudio.PyAudio()
else:
if cls.pa is None:
cls.pa = pa
elif cls.pa is not pa:
raise RuntimeError("Conflicting PyAudio objects")
def write(self, user: Optional[MemberOrUser], data: VoiceData) -> None:
raise NotImplementedError
def wants_opus(self) -> bool:
return False
@classmethod
def terminate_pyaudio(cls):
"""Call this when you are completely done using all instances of LocalPlayback sinks."""
cls.pa.terminate()
cls.pa = None # type: ignore
class SimpleLocalPlaybackSink(_BaseLocalPlaybackSink):
"""
A simplified version of LocalPlaybackSink that only supports one stream of audio.
Convenient for when you have already isolated a single member's audio.
"""
def __init__(self, output_device_id: Optional[int] = None, *, py_audio: Optional[pyaudio.PyAudio] = None):
super().__init__(output_device_id, py_audio=py_audio)
self._stream: PyAudioStream = self.pa.open(
rate=48000,
channels=2,
format=pyaudio.paInt16,
output=True,
output_device_index=output_device_id,
)
def write(self, user: Optional[MemberOrUser], data: VoiceData) -> None:
self._stream.write(data.pcm)
def cleanup(self) -> None:
self._stream.close()
class LocalPlaybackSink(_BaseLocalPlaybackSink):
"""
An AudioSink for playing received audio directly to one of the system's audio output devices using PyAudio.
This sink can handle playback of multiple users' audio without additional stream mixing beforehand.
The `output_device_id` parameter defaults to the system's default audio device, and can otherwise be
acquired via PyAudio functions. A specific `PyAudio` instance can also be passed to use a specific instance.
"""
def __init__(self, output_device_id: Optional[int] = None, *, py_audio: Optional[pyaudio.PyAudio] = None):
super().__init__(output_device_id, py_audio=py_audio)
self._streams: Dict[int, PyAudioStream] = {}
def _get_stream(self, user: MemberOrUser) -> PyAudioStream:
stream = self._streams.get(user.id)
if stream is None:
stream = self._streams[user.id] = self.pa.open(
rate=48000,
channels=2,
format=pyaudio.paInt16,
output=True,
output_device_index=self.output_device_id,
)
return stream
def write(self, user: Optional[MemberOrUser], data: VoiceData) -> None:
if user:
self._get_stream(user).write(data.pcm)
def cleanup(self) -> None:
for stream in tuple(self._streams.values()):
stream.close()
@AudioSink.listener()
def on_voice_member_disconnect(self, member: Member, ssrc: Optional[int]) -> None:
stream = self._streams.pop(member.id, None)
if stream:
stream.close()

View file

@ -1,237 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from ..sinks import AudioSink
log = logging.getLogger(__name__)
__all__ = [
'SpeechRecognitionSink',
]
try:
import speech_recognition as sr
except ImportError:
def __getattr__(name: str):
if name in __all__:
raise RuntimeError('The SpeechRecognition module is required to use this sink.')
else:
import time
import array
import asyncio
import audioop
from collections import defaultdict
from ..rtp import SilencePacket
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from concurrent.futures import Future as CFuture
from typing import Literal, Callable, Optional, Any, Final, Protocol, Awaitable, TypeVar
from discord import Member
from ..opus import VoiceData
from ..types import MemberOrUser as User
T = TypeVar('T')
# [r.split('_', 1)[1] for r in dir(sr.Recognizer()) if r.startswith("recognize")]
SRRecognizerMethod = Literal[
'amazon',
'api',
'assemblyai',
'azure',
'bing',
'faster_whisper',
'google',
'google_cloud',
'groq',
'houndify',
'ibm',
'lex',
'openai',
'sphinx',
'tensorflow',
'vosk',
'whisper',
'wit',
]
class SRStopper(Protocol):
def __call__(self, wait: bool = True, /) -> None: ...
SRProcessDataCB = Callable[[sr.Recognizer, sr.AudioData, User], Optional[str]]
SRTextCB = Callable[[User, str], Any]
class _StreamData(TypedDict):
stopper: Optional[SRStopper]
recognizer: sr.Recognizer
buffer: array.array[int]
class SpeechRecognitionSink(AudioSink):
def __init__(
self,
*,
process_cb: Optional[SRProcessDataCB] = None,
text_cb: Optional[SRTextCB] = None,
default_recognizer: SRRecognizerMethod = 'google',
phrase_time_limit: int = 10,
ignore_silence_packets: bool = True,
):
super().__init__(None)
self.process_cb: Optional[SRProcessDataCB] = process_cb
self.text_cb: Optional[SRTextCB] = text_cb
self.phrase_time_limmit: int = phrase_time_limit
self.ignore_silence_packets: bool = ignore_silence_packets
self.default_recognizer: SRRecognizerMethod = default_recognizer
self._stream_data: defaultdict[int, _StreamData] = defaultdict(
lambda: _StreamData(stopper=None, recognizer=sr.Recognizer(), buffer=array.array('B'))
)
def _await(self, coro: Awaitable[T]) -> CFuture[T]:
assert self.client is not None
return asyncio.run_coroutine_threadsafe(coro, self.client.loop) # type: ignore
def wants_opus(self) -> bool:
return False
def write(self, user: Optional[User], data: VoiceData) -> None:
if self.ignore_silence_packets and isinstance(data.packet, SilencePacket):
return
if user is None:
return
sdata = self._stream_data[user.id]
sdata['buffer'].extend(data.pcm)
if not sdata['stopper']:
sdata['stopper'] = sdata['recognizer'].listen_in_background(
DiscordSRAudioSource(sdata['buffer']), self.background_listener(user), self.phrase_time_limmit
)
def background_listener(self, user: User):
process_cb = self.process_cb or self.get_default_process_callback()
text_cb = self.text_cb or self.get_default_text_callback()
def callback(_recognizer: sr.Recognizer, _audio: sr.AudioData):
output = process_cb(_recognizer, _audio, user)
if output is not None:
text_cb(user, output)
return callback
def get_default_process_callback(self) -> SRProcessDataCB:
def cb(recognizer: sr.Recognizer, audio: sr.AudioData, user: Optional[User]) -> Optional[str]:
log.debug("Got %s, %s, %s", audio, audio.sample_rate, audio.sample_width)
text: Optional[str] = None
try:
# they changed recognize_google to be optionally assigned at runtime...
func = getattr(recognizer, 'recognize_' + self.default_recognizer, recognizer.recognize_google) # type: ignore
text = func(audio)
except sr.UnknownValueError:
log.debug("Bad speech chunk")
# self._debug_audio_chunk(audio)
return text
return cb
def get_default_text_callback(self) -> SRTextCB:
def cb(user: Optional[User], text: Optional[str]) -> Any:
log.info("%s said: %s", user.display_name if user else 'Someone', text)
return cb
@AudioSink.listener()
def on_voice_member_disconnect(self, member: Member, ssrc: Optional[int]) -> None:
if member is not None:
self._drop(member.id)
def cleanup(self) -> None:
for user_id in tuple(self._stream_data.keys()):
self._drop(user_id)
def _drop(self, user_id: int) -> None:
data = self._stream_data.pop(user_id, None)
if data is None:
log.debug("Cannot drop user id: %s, no data", user_id)
return
stopper = data.get('stopper')
if stopper:
stopper()
buffer = data.get('buffer')
if buffer:
# arrays don't have a clear function
del buffer[:]
def _debug_audio_chunk(self, audio: sr.AudioData, filename: str = 'sound.wav') -> None:
import io, wave, discord
with io.BytesIO() as b:
with wave.open(b, 'wb') as writer:
writer.setframerate(48000)
writer.setsampwidth(2)
writer.setnchannels(2)
writer.writeframes(audio.get_wav_data())
b.seek(0)
f = discord.File(b, filename)
self._await(self.voice_client.channel.send(file=f)) # type: ignore
class DiscordSRAudioSource(sr.AudioSource):
little_endian: Final[bool] = True
SAMPLE_RATE: Final[int] = 48_000
SAMPLE_WIDTH: Final[int] = 2
CHANNELS: Final[int] = 2
CHUNK: Final[int] = 960
def __init__(self, buffer: array.array[int]):
self.buffer = buffer
self._entered: bool = False
@property
def stream(self):
return self
def __enter__(self):
if self._entered:
log.warning('Already entered sr audio source')
self._entered = True
return self
def __exit__(self, *exc) -> None:
self._entered = False
if any(exc):
log.exception('Error closing sr audio source')
def read(self, size: int) -> bytes:
# TODO: make this timeout configurable
for _ in range(10):
if len(self.buffer) < size * self.CHANNELS:
time.sleep(0.1)
else:
break
else:
if len(self.buffer) == 0:
return b''
chunksize = size * self.CHANNELS
audiochunk = self.buffer[:chunksize].tobytes()
del self.buffer[: min(chunksize, len(audiochunk))]
audiochunk = audioop.tomono(audiochunk, 2, 1, 1)
return audiochunk
def close(self) -> None:
self.buffer.clear()

View file

@ -1,122 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from discord.enums import SpeakingState, try_enum
from .enums import VoiceFlags, VoicePlatform
from .video import VoiceVideoStreams
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from typing import Dict, Any
from discord.gateway import DiscordVoiceWebSocket
from .voice_client import VoiceRecvClient
from .video import VoiceVideoPayload
log = logging.getLogger(__name__)
# https://cdn.discordapp.com/attachments/381887113391505410/1094473412623204533/image.png
# fmt: off
IDENTIFY = 0
SELECT_PROTOCOL = 1
READY = 2
HEARTBEAT = 3
SESSION_DESCRIPTION = 4 # (aka SELECT_PROTOCOL_ACK)
SPEAKING = 5
HEARTBEAT_ACK = 6
RESUME = 7
HELLO = 8
RESUMED = 9
CLIENT_CONNECT = 11
VIDEO = 12
CLIENT_DISCONNECT = 13
SESSION_UPDATE = 14 # (useless)
MEDIA_SINK_WANTS = 15 # (useless)
VOICE_BACKEND_VERSION = 16 # (useless)
CHANNEL_OPTIONS_UPDATE = 17 # (dead)
FLAGS = 18
SPEED_TEST = 19 # (dead)
PLATFORM = 20
# fmt: on
async def hook(self: DiscordVoiceWebSocket, msg: Dict[str, Any]):
op: int = msg['op']
data: Dict[str, Any] = msg.get('d', {})
vc: VoiceRecvClient = self._connection.voice_client # type: ignore
if op not in (3, 6):
from pprint import pformat
log.debug("Received op %s: \n%s", op, pformat(data, compact=True))
if len(msg.keys()) > 2:
m = msg.copy()
m.pop('op')
m.pop('d')
log.info("WS payload has extra keys: %s", m)
if op == self.READY:
vc._add_ssrc(vc.guild.me.id, data['ssrc'])
elif op == self.SESSION_DESCRIPTION:
if vc._reader:
# TODO: remove bytes cast once type is fixed in dpy
vc._reader.update_secret_key(bytes(self.secret_key)) # type: ignore
elif op == self.SPEAKING:
# this event refers to the speaking MODE, e.g. priority speaker
# it also sends the user's ssrc
uid = int(data['user_id'])
ssrc = data['ssrc']
vc._add_ssrc(uid, ssrc)
member = vc.guild.get_member(uid)
state = try_enum(SpeakingState, data['speaking'])
vc.dispatch("voice_member_speaking_state", member, ssrc, state)
elif op == CLIENT_CONNECT:
uids = [int(uid) for uid in data['user_ids']]
# Multiple user IDs means this is the initial member list
for uid in uids:
member = vc.guild.get_member(uid)
vc.dispatch("voice_member_connect", member)
elif op == VIDEO:
uid = int(data['user_id'])
vc._add_ssrc(uid, data['audio_ssrc'])
member = vc.guild.get_member(uid)
streams = VoiceVideoStreams(data=cast('VoiceVideoPayload', data), vc=vc)
vc.dispatch("voice_member_video", member, streams)
elif op == CLIENT_DISCONNECT:
uid = int(data['user_id'])
ssrc = vc._get_ssrc_from_id(uid)
if vc._reader and ssrc is not None:
log.debug("Destroying decoder for %s, ssrc=%s", uid, ssrc)
vc._reader.packet_router.destroy_decoder(ssrc)
vc._remove_ssrc(user_id=uid)
member = vc.guild.get_member(uid)
vc.dispatch("voice_member_disconnect", member, ssrc)
elif op == FLAGS:
uid = int(data['user_id'])
member = vc.guild.get_member(uid)
vc.dispatch("voice_member_flags", member, VoiceFlags._from_value(data['flags'] or 0))
elif op == PLATFORM:
uid = int(data['user_id'])
member = vc.guild.get_member(uid)
vc.dispatch(
"voice_member_platform",
member,
try_enum(VoicePlatform, data['platform']) if data['platform'] is not None else None,
)

View file

@ -1,174 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final
from .buffer import HeapJitterBuffer as JitterBuffer
from .rtp import FakePacket
from .utils import add_wrapped
from discord.opus import Decoder
if TYPE_CHECKING:
from typing import Optional, Tuple, Dict, Callable, Any
from .rtp import AudioPacket
from .sinks import AudioSink
from .router import PacketRouter
from .voice_client import VoiceRecvClient
from .types import MemberOrUser as User
EventCB = Callable[..., Any]
EventData = Tuple[str, Tuple[Any, ...], Dict[str, Any]]
log = logging.getLogger(__name__)
__all__ = [
'VoiceData',
]
class VoiceData:
"""Container object for audio data and source user."""
__slots__ = ('packet', 'source', 'pcm')
def __init__(self, packet: AudioPacket, source: Optional[User], *, pcm: Optional[bytes] = None):
self.packet: AudioPacket = packet
self.source: Optional[User] = source
self.pcm: bytes = pcm if pcm else b''
@property
def opus(self) -> Optional[bytes]:
return self.packet.decrypted_data
class PacketDecoder:
def __init__(self, router: PacketRouter, ssrc: int):
self.router: PacketRouter = router
self.ssrc: int = ssrc
self._decoder: Optional[Decoder] = None if self.sink.wants_opus() else Decoder()
self._buffer: JitterBuffer = JitterBuffer()
self._cached_id: Optional[int] = None
self._last_seq: int = -1
self._last_ts: int = -1
@property
def sink(self) -> AudioSink:
return self.router.sink
def _get_user(self, user_id: int) -> Optional[User]:
vc: VoiceRecvClient = self.sink.voice_client # type: ignore
return vc.guild.get_member(user_id) or vc.client.get_user(user_id)
def _get_cached_member(self) -> Optional[User]:
return self._get_user(self._cached_id) if self._cached_id else None
def _flag_ready_state(self):
if self._buffer.peek():
self.router.waiter.register(self)
else:
self.router.waiter.unregister(self)
def push_packet(self, packet: AudioPacket) -> None:
self._buffer.push(packet)
self._flag_ready_state()
def pop_data(self, *, timeout: float = 0) -> Optional[VoiceData]:
packet = self._get_next_packet(timeout)
self._flag_ready_state()
if packet is None:
return
return self._process_packet(packet)
def set_user_id(self, user_id: int) -> None:
self._cached_id = user_id
def reset(self) -> None:
self._buffer.reset()
self._decoder = None if self.sink.wants_opus() else Decoder()
self._last_seq = self._last_ts = -1
self._flag_ready_state()
def destroy(self) -> None:
self._buffer.reset()
self._decoder = None
self._flag_ready_state()
def _get_next_packet(self, timeout: float) -> Optional[AudioPacket]:
packet = self._buffer.pop(timeout=timeout)
if packet is None:
# Gets the last (buffered) packet out (i think)
# TODO: revist this, might be an issue
if self._buffer:
packets = self._buffer.flush()
if any(packets[1:]):
log.warning(
"%s packets were lost being flushed in decoder-%s\n --> (last=%s) %s",
len(packets) - 1,
self.ssrc,
self._last_seq,
[p.sequence for p in packets],
)
return packets[0]
return
elif not packet:
packet = self._make_fakepacket()
return packet
def _make_fakepacket(self) -> FakePacket:
seq = add_wrapped(self._last_seq, 1)
ts = add_wrapped(self._last_ts, Decoder.SAMPLES_PER_FRAME, wrap=2**32)
return FakePacket(self.ssrc, seq, ts)
def _process_packet(self, packet: AudioPacket) -> VoiceData:
pcm = None
if not self.sink.wants_opus():
packet, pcm = self._decode_packet(packet)
member = self._get_cached_member()
if member is None:
self._cached_id = self.sink.voice_client._get_id_from_ssrc(self.ssrc) # type: ignore
member = self._get_cached_member()
data = VoiceData(packet, member, pcm=pcm)
self._last_seq = packet.sequence
self._last_ts = packet.timestamp
return data
def _decode_packet(self, packet: AudioPacket) -> Tuple[AudioPacket, bytes]:
assert self._decoder is not None
# Decode as per usual
if packet:
pcm = self._decoder.decode(packet.decrypted_data, fec=False)
return packet, pcm
# Fake packet, need to check next one to use fec
next_packet = self._buffer.peek_next()
if next_packet is not None:
nextdata: bytes = next_packet.decrypted_data # type: ignore
log.debug(
"Generating fec packet: fake=%s, fec=%s",
packet.sequence,
next_packet.sequence,
)
pcm = self._decoder.decode(nextdata, fec=True)
# Need to drop a packet
else:
pcm = self._decoder.decode(None, fec=False)
return packet, pcm

View file

@ -1,422 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import time
import logging
import threading
from operator import itemgetter
from typing import TYPE_CHECKING
from . import rtp
from .sinks import AudioSink
from .router import PacketRouter, SinkEventRouter
try:
import nacl.secret
from nacl.exceptions import CryptoError
except ImportError as e:
raise RuntimeError("pynacl is required") from e
if TYPE_CHECKING:
from typing import Optional, Callable, Any, Dict, Literal, Union
from discord import Member
from discord.types.voice import SupportedModes
from .voice_client import VoiceRecvClient
from .rtp import RTPPacket
DecryptRTP = Callable[[RTPPacket], bytes]
DecryptRTCP = Callable[[bytes], bytes]
AfterCB = Callable[[Optional[Exception]], Any]
SpeakingEvent = Literal['voice_member_speaking_start', 'voice_member_speaking_stop']
EncryptionBox = Union[nacl.secret.SecretBox, nacl.secret.Aead]
log = logging.getLogger(__name__)
__all__ = [
'AudioReader',
]
class AudioReader:
def __init__(self, sink: AudioSink, voice_client: VoiceRecvClient, *, after: Optional[AfterCB] = None):
if after is not None and not callable(after):
raise TypeError('Expected a callable for the "after" parameter.')
self.sink: AudioSink = sink
self.voice_client: VoiceRecvClient = voice_client
self.after: Optional[AfterCB] = after
# No need for the whole set_sink() call
self.sink._voice_client = voice_client
self.active: bool = False
self.error: Optional[Exception] = None
self.packet_router: PacketRouter = PacketRouter(sink, self)
self.event_router: SinkEventRouter = SinkEventRouter(sink, self)
self.decryptor: PacketDecryptor = PacketDecryptor(voice_client.mode, bytes(voice_client.secret_key))
self.speaking_timer: SpeakingTimer = SpeakingTimer(self)
self.keepalive: UDPKeepAlive = UDPKeepAlive(voice_client)
def is_listening(self) -> bool:
return self.active
def update_secret_key(self, secret_key: bytes) -> None:
self.decryptor.update_secret_key(secret_key)
def start(self) -> None:
if self.active:
log.debug('Reader is already started', exc_info=True)
return
self.speaking_timer.start()
self.event_router.start()
self.packet_router.start()
self.voice_client._connection.add_socket_listener(self.callback)
self.keepalive.start()
self.active = True
def stop(self) -> None:
if not self.active:
log.debug('Tried to stop an inactive reader', exc_info=True)
return
self.voice_client._connection.remove_socket_listener(self.callback)
self.active = False
self.speaking_timer.notify()
threading.Thread(target=self._stop, name=f'audioreader-stopper-{id(self):x}').start()
def _stop(self) -> None:
try:
self.packet_router.stop()
except Exception as e:
self.error = e
log.exception('Error stopping packet router')
try:
self.event_router.stop()
except Exception as e:
self.error = e
log.exception('Error stopping event router')
self.speaking_timer.stop()
self.keepalive.stop()
if self.after:
try:
self.after(self.error)
except Exception:
log.exception('Error calling listener after function')
for sink in self.sink.root.walk_children(with_self=True):
try:
sink.cleanup()
except Exception:
log.exception('Error calling cleanup() for %s', sink)
def set_sink(self, sink: AudioSink) -> AudioSink:
"""Sets the new sink for the reader and returns the old one.
Does not call cleanup()
"""
# This whole function is potentially very racy
old_sink = self.sink
old_sink._voice_client = None
sink._voice_client = self.voice_client
self.packet_router.set_sink(sink)
self.sink = sink
return old_sink
def _is_ip_discovery_packet(self, data: bytes) -> bool:
return len(data) == 74 and data[1] == 0x02
def callback(self, packet_data: bytes) -> None:
packet = rtp_packet = rtcp_packet = None
try:
if not rtp.is_rtcp(packet_data):
packet = rtp_packet = rtp.decode_rtp(packet_data)
packet.decrypted_data = self.decryptor.decrypt_rtp(packet)
else:
packet = rtcp_packet = rtp.decode_rtcp(self.decryptor.decrypt_rtcp(packet_data))
if not isinstance(packet, rtp.ReceiverReportPacket):
log.info("Received unexpected rtcp packet: type=%s, %s", packet.type, type(packet))
log.debug("Packet info:\n packet=%s\n data=%s", packet, packet_data)
except CryptoError as e:
log.error("CryptoError decoding packet data")
log.debug("CryptoError details:\n data=%s\n secret_key=%s", packet_data, self.voice_client.secret_key)
return
except Exception as e:
if self._is_ip_discovery_packet(packet_data):
log.debug("Ignoring ip discovery packet")
return
log.exception("Error unpacking packet")
log.debug("Packet data: len=%s data=%s", len(packet_data), packet_data)
finally:
if self.error:
self.stop()
return
if not packet:
return
if rtcp_packet:
self.packet_router.feed_rtcp(rtcp_packet)
elif rtp_packet:
ssrc = rtp_packet.ssrc
if ssrc not in self.voice_client._ssrc_to_id:
if rtp_packet.is_silence():
# TODO: buffer packets from unknown ssrcs, 50 max?
# also remove this log later its pointless
log.debug("Skipping silence packet for unknown ssrc %s", ssrc)
return
else:
log.info("Received packet for unknown ssrc %s:\n%s", ssrc, rtp_packet)
self.speaking_timer.notify(ssrc)
try:
self.packet_router.feed_rtp(rtp_packet)
except Exception as e:
log.exception('Error processing rtp packet')
self.error = e
self.stop()
class PacketDecryptor:
supported_modes: list[SupportedModes] = [
'aead_xchacha20_poly1305_rtpsize',
'xsalsa20_poly1305_lite',
'xsalsa20_poly1305_suffix',
'xsalsa20_poly1305',
]
def __init__(self, mode: SupportedModes, secret_key: bytes) -> None:
self.mode: SupportedModes = mode
try:
self.decrypt_rtp: DecryptRTP = getattr(self, '_decrypt_rtp_' + mode)
self.decrypt_rtcp: DecryptRTCP = getattr(self, '_decrypt_rtcp_' + mode)
except AttributeError as e:
raise NotImplementedError(mode) from e
self.box: EncryptionBox = self._make_box(secret_key)
def _make_box(self, secret_key: bytes) -> EncryptionBox:
if self.mode.startswith("aead"):
return nacl.secret.Aead(secret_key)
else:
return nacl.secret.SecretBox(secret_key)
def update_secret_key(self, secret_key: bytes) -> None:
self.box = self._make_box(secret_key)
def _decrypt_rtp_xsalsa20_poly1305(self, packet: RTPPacket) -> bytes:
nonce = bytearray(24)
nonce[:12] = packet.header
result = self.box.decrypt(bytes(packet.data), bytes(nonce))
if packet.extended:
offset = packet.update_ext_headers(result)
result = result[offset:]
return result
def _decrypt_rtcp_xsalsa20_poly1305(self, data: bytes) -> bytes:
nonce = bytearray(24)
nonce[:8] = data[:8]
result = self.box.decrypt(data[8:], bytes(nonce))
return data[:8] + result
def _decrypt_rtp_xsalsa20_poly1305_suffix(self, packet: RTPPacket) -> bytes:
nonce = packet.data[-24:]
voice_data = packet.data[:-24]
result = self.box.decrypt(bytes(voice_data), bytes(nonce))
if packet.extended:
offset = packet.update_ext_headers(result)
result = result[offset:]
return result
def _decrypt_rtcp_xsalsa20_poly1305_suffix(self, data: bytes) -> bytes:
nonce = data[-24:]
header = data[:8]
result = self.box.decrypt(data[8:-24], nonce)
return header + result
def _decrypt_rtp_xsalsa20_poly1305_lite(self, packet: RTPPacket) -> bytes:
nonce = bytearray(24)
nonce[:4] = packet.data[-4:]
voice_data = packet.data[:-4]
result = self.box.decrypt(bytes(voice_data), bytes(nonce))
if packet.extended:
offset = packet.update_ext_headers(result)
result = result[offset:]
return result
def _decrypt_rtcp_xsalsa20_poly1305_lite(self, data: bytes) -> bytes:
nonce = bytearray(24)
nonce[:4] = data[-4:]
header = data[:8]
result = self.box.decrypt(data[8:-4], bytes(nonce))
return header + result
def _decrypt_rtp_aead_xchacha20_poly1305_rtpsize(self, packet: RTPPacket) -> bytes:
packet.adjust_rtpsize()
nonce = bytearray(24)
nonce[:4] = packet.nonce
voice_data = packet.data
# Blob vomit
assert isinstance(self.box, nacl.secret.Aead)
result = self.box.decrypt(bytes(voice_data), bytes(packet.header), bytes(nonce))
if packet.extended:
offset = packet.update_ext_headers(result)
result = result[offset:]
return result
def _decrypt_rtcp_aead_xchacha20_poly1305_rtpsize(self, data: bytes) -> bytes:
nonce = bytearray(24)
nonce[:4] = data[-4:]
header = data[:8]
assert isinstance(self.box, nacl.secret.Aead)
result = self.box.decrypt(data[8:-4], bytes(header), bytes(nonce))
return header + result
class SpeakingTimer(threading.Thread):
def __init__(self, reader: AudioReader):
super().__init__(daemon=True, name=f'speaking-timer-{id(self):x}')
self.reader: AudioReader = reader
self.voice_client = reader.voice_client
self.speaking_timeout_delay: float = 0.2
self.last_speaking_state: Dict[int, bool] = {}
self.speaking_cache: Dict[int, float] = {}
self.speaking_timer_event: threading.Event = threading.Event()
self._end_thread: threading.Event = threading.Event()
def _lookup_member(self, ssrc: int) -> Optional[Member]:
whoid = self.voice_client._get_id_from_ssrc(ssrc)
return self.voice_client.guild.get_member(whoid) if whoid else None
def maybe_dispatch_speaking_start(self, ssrc: int) -> None:
tlast = self.speaking_cache.get(ssrc)
if tlast is None or tlast + self.speaking_timeout_delay < time.perf_counter():
self.dispatch('voice_member_speaking_start', ssrc)
def dispatch(self, event: SpeakingEvent, ssrc: int) -> None:
who = self._lookup_member(ssrc)
if not who:
return
self.voice_client.dispatch_sink(event, who)
def notify(self, ssrc: Optional[int] = None) -> None:
if ssrc is not None:
self.last_speaking_state[ssrc] = True
self.maybe_dispatch_speaking_start(ssrc)
self.speaking_cache[ssrc] = time.perf_counter()
self.speaking_timer_event.set()
self.speaking_timer_event.clear()
def drop_ssrc(self, ssrc: int) -> None:
self.speaking_cache.pop(ssrc, None)
state = self.last_speaking_state.pop(ssrc, None)
if state:
self.dispatch('voice_member_speaking_stop', ssrc)
self.notify()
def get_speaking(self, ssrc: int) -> Optional[bool]:
return self.last_speaking_state.get(ssrc)
def stop(self) -> None:
self._end_thread.set()
self.notify()
def run(self) -> None:
_i1 = itemgetter(1)
def get_next_entry():
cache = sorted(self.speaking_cache.items(), key=_i1)
for ssrc, tlast in cache:
# only return pair if speaking
if self.last_speaking_state.get(ssrc):
return ssrc, tlast
return None, None
self.speaking_timer_event.wait()
while not self._end_thread.is_set():
if not self.speaking_cache:
self.speaking_timer_event.wait()
tnow = time.perf_counter()
ssrc, tlast = get_next_entry()
# no ssrc has been speaking, nothing to timeout
if ssrc is None or tlast is None:
self.speaking_timer_event.wait()
continue
self.speaking_timer_event.wait(tlast + self.speaking_timeout_delay - tnow)
if time.perf_counter() < tlast + self.speaking_timeout_delay:
continue
self.dispatch('voice_member_speaking_stop', ssrc)
self.last_speaking_state[ssrc] = False
# TODO: unify into a single thread that does all keepalives
class UDPKeepAlive(threading.Thread):
delay: int = 5000
def __init__(self, voice_client: VoiceRecvClient):
super().__init__(daemon=True, name=f"voice-udp-keepalive-{id(self):x}")
self.voice_client: VoiceRecvClient = voice_client
self.last_time: float = 0
self.counter: int = 0
self._end_thread: threading.Event = threading.Event()
def run(self) -> None:
self.voice_client.wait_until_connected()
while not self._end_thread.is_set():
vc = self.voice_client
try:
packet = self.counter.to_bytes(8, 'big')
except OverflowError:
self.counter = 0
continue
try:
vc._connection.socket.sendto(packet, (vc._connection.endpoint_ip, vc._connection.voice_port))
except Exception as e:
log.debug("Error sending keepalive to socket: %s: %s", e.__class__.__name__, e)
# TODO: test connection interruptions
vc.wait_until_connected()
if vc.is_connected():
continue
break
else:
self.counter += 1
time.sleep(self.delay)
def stop(self) -> None:
self._end_thread.set()

View file

@ -1,203 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import queue
import logging
import threading
from collections import deque
from .utils import MultiDataEvent
from .opus import PacketDecoder
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Tuple, Dict, List, Callable, Any, Optional
from .rtp import RTPPacket, RTCPPacket
from .sinks import AudioSink
from .reader import AudioReader
EventCB = Callable[..., Any]
EventData = Tuple[str, Tuple[Any, ...], Dict[str, Any]]
log = logging.getLogger(__name__)
class PacketRouter(threading.Thread):
def __init__(self, sink: AudioSink, reader: AudioReader):
super().__init__(daemon=True, name=f"packet-router-{id(self):x}")
self.sink: AudioSink = sink
self.decoders: Dict[int, PacketDecoder] = {}
self.reader: AudioReader = reader
self.waiter: MultiDataEvent[PacketDecoder] = MultiDataEvent()
self._lock: threading.RLock = threading.RLock()
self._end_thread: threading.Event = threading.Event()
self._dropped_ssrcs: deque[int] = deque(maxlen=16)
def feed_rtp(self, packet: RTPPacket) -> None:
# TODO: stale packet check
if packet.ssrc in self._dropped_ssrcs:
log.debug("Ignoring packet from dropped ssrc %s", packet.ssrc)
return
with self._lock:
decoder = self.get_decoder(packet.ssrc)
if decoder is not None:
decoder.push_packet(packet)
def feed_rtcp(self, packet: RTCPPacket) -> None:
guild = self.sink.voice_client.guild if self.sink.voice_client else None
event_router = self.reader.event_router
event_router.dispatch('rtcp_packet', packet, guild)
def get_decoder(self, ssrc: int) -> Optional[PacketDecoder]:
with self._lock:
decoder = self.decoders.get(ssrc)
if decoder is None:
decoder = self.decoders[ssrc] = PacketDecoder(self, ssrc)
return decoder
def set_sink(self, sink: AudioSink) -> None:
with self._lock:
self.sink = sink
def set_user_id(self, ssrc: int, user_id: int) -> None:
with self._lock:
if ssrc in self._dropped_ssrcs:
self._dropped_ssrcs.remove(ssrc)
decoder = self.decoders.get(ssrc)
if decoder is not None:
decoder.set_user_id(user_id)
def destroy_decoder(self, ssrc: int) -> None:
with self._lock:
decoder = self.decoders.pop(ssrc, None)
if decoder is not None:
self._dropped_ssrcs.append(ssrc)
decoder.destroy()
def destroy_all_decoders(self) -> None:
with self._lock:
for ssrc in list(self.decoders.keys()):
self.destroy_decoder(ssrc)
def stop(self) -> None:
self._end_thread.set()
self.waiter.notify()
def run(self) -> None:
try:
self._do_run()
except Exception as e:
log.exception("Error in %s loop", self)
self.reader.error = e
finally:
self.reader.voice_client.stop_listening()
self.waiter.clear()
def _do_run(self) -> None:
while not self._end_thread.is_set():
self.waiter.wait()
with self._lock:
for decoder in self.waiter.items:
data = decoder.pop_data()
if data is not None:
self.sink.write(data.source, data)
class SinkEventRouter(threading.Thread):
def __init__(self, sink: AudioSink, reader: AudioReader):
super().__init__(daemon=True, name=f"sink-event-router-{id(self):x}")
self.sink: AudioSink = sink
self.reader: AudioReader = reader
self._event_listeners: Dict[str, List[EventCB]] = {}
self._buffer: queue.SimpleQueue[EventData] = queue.SimpleQueue()
self._lock = threading.RLock()
self._end_thread: threading.Event = threading.Event()
self.register_events()
def dispatch(self, event: str, /, *args: Any, **kwargs: Any) -> None:
log.debug("Dispatching voice_client event %s", event)
self._buffer.put_nowait((event, args, kwargs))
def set_sink(self, sink: AudioSink) -> None:
with self._lock:
self.unregister_events()
self.sink = sink
self.register_events()
def register_events(self) -> None:
with self._lock:
self._register_listeners(self.sink)
for child in self.sink.walk_children():
self._register_listeners(child)
def unregister_events(self) -> None:
with self._lock:
self._unregister_listeners(self.sink)
for child in self.sink.walk_children():
self._unregister_listeners(child)
def _register_listeners(self, sink: AudioSink) -> None:
log.debug("Registering events for %s: %s ", sink, sink.__sink_listeners__)
for name, method_name in sink.__sink_listeners__:
func = getattr(sink, method_name)
log.debug("Registering event: %r, func: %r", name, method_name)
if name in self._event_listeners:
self._event_listeners[name].append(func)
else:
self._event_listeners[name] = [func]
def _unregister_listeners(self, sink: AudioSink):
for name, method_name in sink.__sink_listeners__:
func = getattr(sink, method_name)
if name in self._event_listeners:
try:
self._event_listeners[name].remove(func)
except ValueError:
pass
def _dispatch_to_listeners(self, event: str, *args: Any, **kwargs: Any) -> None:
for listener in self._event_listeners.get(f'on_{event}', []):
try:
listener(*args, **kwargs)
except Exception:
log.exception("Unhandled exception dispatching voice listener event %r", event)
log.debug("event=%r, args=%r, kwargs=%r, listener=%r", event, args, kwargs, listener)
def stop(self) -> None:
self._end_thread.set()
def run(self) -> None:
try:
self._do_run()
except Exception as e:
log.exception("Error in %s", self.name)
self.reader.error = e
self.reader.voice_client.stop_listening()
def _do_run(self) -> None:
while not self._end_thread.is_set():
try:
event, args, kwargs = self._buffer.get(timeout=0.5)
except queue.Empty:
continue
else:
with self._lock:
# this looks dumb
with self.reader.packet_router._lock:
self._dispatch_to_listeners(event, *args, **kwargs)

View file

@ -1,471 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import struct
import logging
from math import ceil
from collections import namedtuple
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional, Literal, Union, Final, Dict, Any, Tuple
AudioPacket = Union['RTPPacket', 'FakePacket', 'SilencePacket']
RealPacket = Union['RTPPacket', 'RTCPPacket']
Packet = Union[RealPacket, 'FakePacket', 'SilencePacket']
PacketTypes = Union[
'SenderReportPacket',
'ReceiverReportPacket',
'SDESPacket',
'BYEPacket',
'APPPacket',
]
log = logging.getLogger(__name__)
__all__ = [
'RTPPacket',
'RTCPPacket',
'FakePacket',
'SilencePacket',
'ExtensionID',
]
OPUS_SILENCE: Final = b'\xf8\xff\xfe'
class ExtensionID:
audio_power: Final = 1
speaking_state: Final = 9
def decode(data: bytes) -> RealPacket:
"""Creates an :class:`RTPPacket` or an :class:`RTCPPacket`.
Parameters
-----------
data : bytes
The raw packet data.
"""
# While technically unreliable, discord RTP packets (should)
# always be distinguishable from RTCP packets. RTCP packets
# should always have 200-204 as their second byte, while RTP
# packet are (probably) always 73 (or at least not 200-204).
# check version bits
if not data[0] >> 6 == 2:
raise ValueError(f'Invalid packet header 0b{data[0]:0>8b}')
return _rtcp_map.get(data[1], RTPPacket)(data)
def decode_rtp(data: bytes) -> RTPPacket:
return decode(data) # type: ignore
def decode_rtcp(data: bytes) -> RTCPPacket:
return decode(data) # type: ignore
def is_rtcp(data: bytes) -> bool:
return 200 <= data[1] <= 204
def _parse_low(x: int, bitlen: int = 32) -> float:
return x / 2.0**bitlen
def _into_low(x: float, bitlen: int = 32) -> int:
return int(x * 2.0**bitlen)
class _PacketCmpMixin:
__slots__ = ('ssrc', 'sequence', 'timestamp')
def __lt__(self, other: _PacketCmpMixin) -> bool:
if self.ssrc != other.ssrc:
raise TypeError("packet ssrc mismatch (%s, %s)" % (self.ssrc, other.ssrc))
return self.sequence < other.sequence and self.timestamp < other.timestamp
def __gt__(self, other: _PacketCmpMixin) -> bool:
if self.ssrc != other.ssrc:
raise TypeError("packet ssrc mismatch (%s, %s)" % (self.ssrc, other.ssrc))
return self.sequence > other.sequence or self.timestamp > other.timestamp
def __eq__(self, other: _PacketCmpMixin) -> bool:
if self.ssrc != other.ssrc:
return False
return self.sequence == other.sequence and self.timestamp == other.timestamp
def is_silence(self) -> bool:
data = getattr(self, 'decrypted_data', None)
return data == OPUS_SILENCE
class FakePacket(_PacketCmpMixin):
__slots__ = ('ssrc', 'sequence', 'timestamp')
decrypted_data: bytes = b''
extension_data: dict = {}
def __init__(self, ssrc: int, sequence: int, timestamp: int):
self.ssrc: int = ssrc
self.sequence: int = sequence
self.timestamp: int = timestamp
def __repr__(self) -> str:
return '<FakePacket ssrc={0.ssrc}, sequence={0.sequence}, timestamp={0.timestamp}>'.format(self)
def __bool__(self) -> Literal[False]:
return False
class SilencePacket(_PacketCmpMixin):
__slots__ = ('ssrc', 'timestamp')
decrypted_data: Final = OPUS_SILENCE
extension_data: Final[Dict[int, Any]] = {}
sequence: int = -1
def __init__(self, ssrc: int, timestamp: int):
self.ssrc: int = ssrc
self.timestamp: int = timestamp
def __repr__(self) -> str:
return '<SilencePacket ssrc={0.ssrc}, timestamp={0.timestamp}>'.format(self)
def is_silence(self) -> bool:
return True
class RTPPacket(_PacketCmpMixin):
__slots__ = (
'version',
'padding',
'extended',
'cc',
'marker',
'payload',
'sequence',
'timestamp',
'ssrc',
'csrcs',
'header',
'data',
'decrypted_data',
'nonce',
'extension',
'extension_data',
'_rtpsize',
)
_hstruct = struct.Struct('>xxHII')
_ext_header = namedtuple("Extension", 'profile length values')
_ext_magic = b'\xbe\xde'
def __init__(self, data: bytes):
data = bytearray(data) # type: ignore
# fmt: off
self.version: int = data[0] >> 6
self.padding: bool = bool(data[0] & 0b00100000)
self.extended: bool = bool(data[0] & 0b00010000)
self.cc: int = data[0] & 0b00001111
self.marker: bool = bool(data[1] & 0b10000000)
self.payload: int = data[1] & 0b01111111
# fmt: on
sequence, timestamp, ssrc = self._hstruct.unpack_from(data)
self.sequence: int = sequence
self.timestamp: int = timestamp
self.ssrc: int = ssrc
self.csrcs: Tuple[int, ...] = ()
self.extension = None
self.extension_data: Dict[int, bytes] = {}
self.header = data[:12]
self.data = data[12:]
self.decrypted_data: Optional[bytes] = None
self.nonce: bytes = b''
self._rtpsize: bool = False
if self.cc:
fmt = '>%sI' % self.cc
offset = struct.calcsize(fmt) + 12
self.csrcs = struct.unpack(fmt, data[12:offset])
self.data = data[offset:]
# TODO?: impl padding calculations (though discord doesn't seem to use that bit)
def adjust_rtpsize(self):
"""Adjusts the packet header and data based on the rtpsize format."""
self._rtpsize = True
self.nonce = self.data[-4:]
if not self.extended:
self.data = self.data[:-4]
return
# rtpsize based formats are laid out similarly to SRTP packets, which includes the ext header now
# the nonce also needs to be removed from the end
self.header += self.data[:4]
self.data = self.data[4:-4]
def update_ext_headers(self, data: bytes) -> int:
"""Adds extended header data to this packet, returns payload offset"""
if not self.extended:
return 0
# rtpsize formats have the extension header in the rtp header instead of payload
if self._rtpsize:
data = self.header[-4:] + data
# data is the decrypted packet payload containing the extension header and opus data
profile, length = struct.unpack_from('>2sH', data)
if profile == self._ext_magic:
self._parse_bede_header(data, length)
values = struct.unpack('>%sI' % length, data[4 : 4 + length * 4])
self.extension = self._ext_header(profile, length, values)
offset = 4 + length * 4
if self._rtpsize:
# remove the extra offset from adding the header in
offset -= 4
return offset
# https://www.rfcreader.com/#rfc5285_line186
def _parse_bede_header(self, data: bytes, length: int) -> None:
offset = 4
n = 0
while n < length:
next_byte = data[offset : offset + 1]
if next_byte == b'\x00':
offset += 1
continue
header = struct.unpack('>B', next_byte)[0]
element_id = header >> 4
element_len = 1 + (header & 0b0000_1111)
self.extension_data[element_id] = data[offset + 1 : offset + 1 + element_len]
offset += 1 + element_len
n += 1
def _dump_info(self) -> str:
attrs = {name: getattr(self, name) for name in self.__slots__}
return ''.join(("<RTPPacket ", *['{}={}, '.format(n, v) for n, v in attrs.items()], '>'))
def __repr__(self) -> str:
return (
'<RTPPacket '
'ssrc={0.ssrc}, '
'sequence={0.sequence}, '
'timestamp={0.timestamp}, '
'size={1}, '
'ext={2}'
'>'.format(self, len(self.data), set(self.extension_data))
)
# http://www.rfcreader.com/#rfc3550_line855
class RTCPPacket:
__slots__ = ('version', 'padding', 'length')
_header = struct.Struct('>BBH')
_ssrc_fmt = struct.Struct('>I')
type = None
def __init__(self, data: bytes):
self.length: int
head, _, self.length = self._header.unpack_from(data)
self.version: int = head >> 6
self.padding: bool = bool(head & 0b00100000)
# dubious, yet devious
setattr(self, self.__slots__[0], head & 0b00011111)
def __repr__(self) -> str:
content = ', '.join("{}: {}".format(k, getattr(self, k, None)) for k in self.__slots__)
return "<{} {}>".format(self.__class__.__name__, content)
@classmethod
def from_data(cls, data: bytes) -> PacketTypes:
_, ptype, _ = cls._header.unpack_from(data)
return _rtcp_map[ptype](data)
# TODO?: consider moving repeated code to a ReportPacket type
# http://www.rfcreader.com/#rfc3550_line1614
class SenderReportPacket(RTCPPacket):
__slots__ = ('report_count', 'ssrc', 'info', 'reports', 'extension')
_info_fmt = struct.Struct('>5I')
_report_fmt = struct.Struct('>IB3x4I')
_24bit_int_fmt = struct.Struct('>4xI')
_info = namedtuple('RRSenderInfo', 'ntp_ts rtp_ts packet_count octet_count')
_report = namedtuple("RReport", 'ssrc perc_loss total_lost last_seq jitter lsr dlsr')
type = 200
def __init__(self, data):
super().__init__(data)
self.ssrc = self._ssrc_fmt.unpack_from(data, 4)[0]
self.info = self._read_sender_info(data, 8)
reports = []
for x in range(self.report_count):
offset = 28 + 24 * x
reports.append(self._read_report(data, offset))
self.reports = tuple(reports)
self.extension = None
if len(data) > 28 + 24 * self.report_count:
self.extension = data[28 + 24 * self.report_count :]
def _read_sender_info(self, data, offset):
nhigh, nlow, rtp_ts, pcount, ocount = self._info_fmt.unpack_from(data, offset)
ntotal = nhigh + _parse_low(nlow)
return self._info(ntotal, rtp_ts, pcount, ocount)
def _read_report(self, data, offset):
ssrc, flost, seq, jit, lsr, dlsr = self._report_fmt.unpack_from(data, offset)
clost = self._24bit_int_fmt.unpack_from(data, offset)[0] & 0xFFFFFF
return self._report(ssrc, flost, clost, seq, jit, lsr, dlsr)
# http://www.rfcreader.com/#rfc3550_line1879
class ReceiverReportPacket(RTCPPacket):
__slots__ = ('report_count', 'ssrc', 'reports', 'extension')
_report_fmt = struct.Struct('>IB3x4I')
_24bit_int_fmt = struct.Struct('>4xI')
_report = namedtuple("RReport", 'ssrc perc_loss total_lost last_seq jitter lsr dlsr')
type = 201
reports: Tuple[_report, ...]
def __init__(self, data: bytes):
super().__init__(data)
self.ssrc: int = self._ssrc_fmt.unpack_from(data, 4)[0]
reports = []
for x in range(self.report_count):
offset = 8 + 24 * x
reports.append(self._read_report(data, offset))
self.reports = tuple(reports)
self.extension: Optional[bytes] = None
if len(data) > 8 + 24 * self.report_count:
self.extension = data[8 + 24 * self.report_count :]
def _read_report(self, data: bytes, offset: int) -> _report:
ssrc, flost, seq, jit, lsr, dlsr = self._report_fmt.unpack_from(data, offset)
clost = self._24bit_int_fmt.unpack_from(data, offset)[0] & 0xFFFFFF
return self._report(ssrc, flost, clost, seq, jit, lsr, dlsr)
# UNFORTUNATELY it seems discord only uses the above ~~two packet types~~ packet type.
# Good thing I knew that when I made the rest of these. Haha yes.
# http://www.rfcreader.com/#rfc3550_line2024
class SDESPacket(RTCPPacket):
__slots__ = ('source_count', 'chunks', '_pos')
_item_header = struct.Struct('>BB')
_chunk = namedtuple("SDESChunk", 'ssrc items')
_item = namedtuple("SDESItem", 'type size length text')
type = 202
def __init__(self, data):
super().__init__(data)
_chunks = []
self._pos = 4
for _ in range(self.source_count):
_chunks.append(self._read_chunk(data))
self.chunks = tuple(_chunks)
def _read_chunk(self, data):
ssrc = self._ssrc_fmt.unpack_from(data, self._pos)[0]
self._pos += 4
# check for chunk with no items
if data[self._pos : self._pos + 4] == b'\x00\x00\x00\x00':
self._pos += 4
return self._chunk(ssrc, ())
items = [self._read_item(data)]
# Read items until END type is found
while items[-1].type != 0:
items.append(self._read_item(data))
# pad chunk to 4 bytes
if self._pos % 4:
self._pos = ceil(self._pos / 4) * 4
return self._chunk(ssrc, items)
def _read_item(self, data):
itype, ilen = self._item_header.unpack_from(data, self._pos)
self._pos += 2
text = None
if ilen:
text = data[self._pos : self._pos + ilen].decode()
self._pos += ilen
return self._item(itype, ilen + 2, ilen, text)
def _get_chunk_size(self, chunk):
return 4 + max(4, sum(i.size for i in chunk.items)) # + padding?
# http://www.rfcreader.com/#rfc3550_line2311
class BYEPacket(RTCPPacket):
__slots__ = ('source_count', 'ssrcs', 'reason')
type = 203
def __init__(self, data):
super().__init__(data)
self.ssrcs = struct.unpack_from('>%sI' % self.source_count, data, 4)
self.reason = None
body_length = 4 + len(self.ssrcs) * 4
if len(data) > body_length:
extra_len = struct.unpack_from('B', data, body_length)[0]
reason = struct.unpack_from('%ss' % extra_len, data, body_length + 1)
self.reason = reason.decode()
# http://www.rfcreader.com/#rfc3550_line2353
class APPPacket(RTCPPacket):
__slots__ = ('subtype', 'ssrc', 'name', 'data')
_packet_info = struct.Struct('>I4s')
type = 204
def __init__(self, data):
super().__init__(data)
self.ssrc, name = self._packet_info.unpack_from(data, 4)
self.name = name.decode('ascii')
self.data = data[12:] # should be a multiple of 32 bits but idc
_rtcp_map = {
200: SenderReportPacket,
201: ReceiverReportPacket,
202: SDESPacket,
203: BYEPacket,
204: APPPacket,
}

View file

@ -1,152 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import time
import logging
import threading
from .opus import VoiceData
from .rtp import SilencePacket
from discord.utils import MISSING
from discord.opus import Decoder
from typing import TYPE_CHECKING, Tuple
if TYPE_CHECKING:
from typing import Callable, Any, Dict, Optional, Final, Union
from .rtp import AudioPacket
from .types import MemberOrUser as User
SilenceGenFN = Callable[[Optional[User], VoiceData], Any]
SSRCData = Tuple[float, Optional[User], AudioPacket]
log = logging.getLogger(__name__)
__all__ = [
'SilenceGenerator',
]
SILENCE_PCM: Final = b'\0' * Decoder.FRAME_SIZE
PACKET_INTERVAL: Final = 0.02
class SilenceGenerator(threading.Thread):
"""Generates and sends silence packets."""
def __init__(self, callback: SilenceGenFN, *, grace_period: float = 0.015):
super().__init__(daemon=True, name=f'silencegen-{id(self):x}')
self.callback: SilenceGenFN = callback
self.grace_period: float = grace_period
self._ssrc_data: Dict[int, SSRCData] = {} # {ssrc: (time, _, _)}
self._last_timestamp: Dict[int, int] = {} # {ssrc: timestamp}
self._user_map_backup: Dict[int, int] = {} # {id: ssrc}
self._end: threading.Event = threading.Event()
self._has_data: threading.Event = threading.Event()
self._lock: threading.Lock = threading.Lock()
def push(self, user: Optional[User], packet: AudioPacket) -> None:
"""Updates the last time a packet was received and from whom.
Calling this function will start generating silence packets for `packet.ssrc`
until `drop(ssrc)` or `stop()` is called.
"""
with self._lock:
self._ssrc_data[packet.ssrc] = (time.perf_counter(), user, packet)
self._last_timestamp[packet.ssrc] = packet.timestamp
if user:
self._user_map_backup[user.id] = packet.ssrc
self._has_data.set()
def _get_next_info(self) -> SSRCData:
return min(self._ssrc_data.values())
def drop(self, *, ssrc: Optional[int] = None, user: User = MISSING) -> None:
"""Stop generating silence packets for `ssrc`, or whatever is cached for `user`
if `ssrc` is None, if any.
"""
with self._lock:
if ssrc is None:
ssrc = self._user_map_backup.pop(user.id, None)
if ssrc is None:
return # weird but ok
self._last_timestamp.pop(ssrc, None)
last_data = self._ssrc_data.pop(ssrc, None)
if last_data is None and user is not MISSING:
ssrc = self._user_map_backup.pop(user.id)
self._ssrc_data.pop(ssrc, None)
if not self._ssrc_data:
self._has_data.clear()
def stop(self) -> None:
"""Stops generating silence for everything and clears the cache."""
self._end.set()
self._has_data.set()
with self._lock:
self._ssrc_data.clear()
self._user_map_backup.clear()
self._last_timestamp.clear()
self._has_data.clear()
self.join(1)
def start(self) -> None:
self._end.clear()
super().start()
def run(self) -> None:
try:
self._do_run()
except Exception as e:
log.exception("Error in %s", self)
def _do_run(self) -> None:
while not self._end.is_set():
self._has_data.wait()
if self._end.is_set():
return
with self._lock:
tlast, user, packet = self._get_next_info()
ssrc = packet.ssrc
# prepare the object before the sleep as a little micro optimization
next_packet = SilencePacket(
ssrc, self._last_timestamp.get(ssrc, packet.timestamp) + Decoder.SAMPLES_PER_FRAME
)
# TODO: check if destination wants opus or not
next_data = VoiceData(next_packet, user, pcm=SILENCE_PCM)
tnext = tlast + PACKET_INTERVAL
tnow = time.perf_counter()
# wait a little bit longer than when the next one should be
# so we don't have to race with the next packet
delay = tnext + self.grace_period - tnow
if delay > 0:
time.sleep(delay)
with self._lock:
tlast2, luser, lpacket = self._ssrc_data.get(ssrc, (-1, None, packet))
if next_packet.ssrc != lpacket.ssrc or tlast != tlast2 or self._end.is_set():
continue # another packet came in and bumped up the time
next_data.source = luser # is there any point in doing this?
self.callback(luser, next_data)
with self._lock:
# If there was no packet update during the sleep...
if tlast == tlast2 and ssrc in self._ssrc_data:
# update the existing packet time for the next window
self._ssrc_data[ssrc] = (tlast + PACKET_INTERVAL, user, packet)
self._last_timestamp[ssrc] += Decoder.SAMPLES_PER_FRAME

View file

@ -1,634 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import io
import abc
import time
import wave
import shlex
import inspect
import audioop
import logging
import threading
import subprocess
from .opus import VoiceData
from .silence import SilenceGenerator
import discord
from discord.utils import MISSING, SequenceProxy
from discord.opus import Decoder as OpusDecoder
from typing import TYPE_CHECKING, overload
if TYPE_CHECKING:
from typing import Callable, Optional, Any, IO, Sequence, Tuple, Generator, Union, Dict, List
from .rtp import AudioPacket, RTCPPacket
from .voice_client import VoiceRecvClient
from .opus import VoiceData
from .types import MemberOrUser as User
BasicSinkWriteCB = Callable[[Optional[User], VoiceData], Any]
BasicSinkWriteRTCPCB = Callable[[RTCPPacket], Any]
ConditionalFilterFn = Callable[[Optional[User], VoiceData], bool]
FFmpegErrorCB = Callable[['FFmpegSink', Exception, Optional[VoiceData]], Any]
log = logging.getLogger(__name__)
__all__ = [
'AudioSink',
'MultiAudioSink',
'BasicSink',
'WaveSink',
'FFmpegSink',
'PCMVolumeTransformer',
'ConditionalFilter',
'TimedFilter',
'UserFilter',
'SilenceGeneratorSink',
]
# TODO: use this in more places
class VoiceRecvException(discord.DiscordException):
"""Generic exception for voice recv related errors"""
def __init__(self, message: str):
self.message: str = message
class SinkMeta(abc.ABCMeta):
__sink_listeners__: List[Tuple[str, str]]
def __new__(cls, name: str, bases: Tuple[type, ...], attrs: Dict[str, Any], **kwargs):
listeners: Dict[str, Any] = {}
new_cls = super().__new__(cls, name, bases, attrs, **kwargs)
for base in reversed(new_cls.__mro__):
for elem, value in base.__dict__.items():
# If it exists in a subclass, delete the higher level one
if elem in listeners:
del listeners[elem]
is_static_method = isinstance(value, staticmethod)
if is_static_method:
value = value.__func__
if not hasattr(value, '__sink_listener__'):
continue
listeners[elem] = value
listener_list = []
for listener in listeners.values():
for listener_name in listener.__sink_listener_names__:
listener_list.append((listener_name, listener.__name__))
new_cls.__sink_listeners__ = listener_list
return new_cls
class SinkABC(metaclass=SinkMeta):
__sink_listeners__: List[Tuple[str, str]]
@property
@abc.abstractmethod
def root(self) -> AudioSink:
raise NotImplementedError
@property
@abc.abstractmethod
def parent(self) -> Optional[AudioSink]:
raise NotImplementedError
@property
@abc.abstractmethod
def child(self) -> Optional[AudioSink]:
raise NotImplementedError
@property
@abc.abstractmethod
def children(self) -> Sequence[AudioSink]:
raise NotImplementedError
@property
@abc.abstractmethod
def voice_client(self) -> Optional[VoiceRecvClient]:
raise NotImplementedError
# handling opus vs pcm is not strictly mutually exclusive
# a sink could handle both but idk about that pattern
@abc.abstractmethod
def wants_opus(self) -> bool:
"""If sink handles opus data"""
raise NotImplementedError
@abc.abstractmethod
def write(self, user: Optional[User], data: VoiceData):
"""Callback for when the sink receives data"""
raise NotImplementedError
@abc.abstractmethod
def cleanup(self):
raise NotImplementedError
@abc.abstractmethod
def _register_child(self, child: AudioSink) -> None:
raise NotImplementedError
class AudioSink(SinkABC):
_voice_client: Optional[VoiceRecvClient]
_parent: Optional[AudioSink] = None
_child: Optional[AudioSink] = None
def __init__(self, destination: Optional[AudioSink] = None, /):
if destination is not None:
self._register_child(destination)
else:
self._child = None
def __del__(self):
self.cleanup()
def _register_child(self, child: AudioSink) -> None:
if child in self.root.walk_children():
raise RuntimeError('Sink is already registered.')
self._child = child
child._parent = self
@property
def root(self) -> AudioSink:
if self.parent is None:
return self
return self.parent.root
@property
def parent(self) -> Optional[AudioSink]:
return self._parent
@property
def child(self) -> Optional[AudioSink]:
return self._child
@property
def children(self) -> Sequence[AudioSink]:
return [self._child] if self._child else []
@property
def voice_client(self) -> Optional[VoiceRecvClient]:
"""Guaranteed to not be None inside write()"""
if self.parent is not None:
return self.parent.voice_client
else:
return self._voice_client
@property
def client(self) -> Optional[discord.Client]:
"""Guaranteed to not be None inside write()"""
return self.voice_client and self.voice_client.client
def walk_children(self, *, with_self: bool = False) -> Generator[AudioSink, None, None]:
"""Returns a generator of all the children of this sink, recursively, depth first."""
if with_self:
yield self
for child in self.children:
yield child
yield from child.walk_children()
@classmethod
def listener(cls, name: str = MISSING):
"""Marks a function as an event listener."""
if name is not MISSING and not isinstance(name, str):
raise TypeError(f'AudioSink.listener expected str but received {type(name).__name__} instead.')
def decorator(func):
actual = func
if isinstance(actual, staticmethod):
actual = actual.__func__
if inspect.iscoroutinefunction(actual):
raise TypeError('Listener function must not be a coroutine function.')
actual.__sink_listener__ = True
to_assign = name or actual.__name__
try:
actual.__sink_listener_names__.append(to_assign)
except AttributeError:
actual.__sink_listener_names__ = [to_assign]
return func
return decorator
class MultiAudioSink(AudioSink):
def __init__(self, destinations: Sequence[AudioSink], /):
# Intentionally not calling super().__init__ here
if destinations is not None:
for dest in destinations:
self._register_child(dest)
self._children: List[AudioSink] = list(destinations)
def _register_child(self, child: AudioSink) -> None:
if child in self.root.walk_children():
raise RuntimeError('Sink is already registered.')
child._parent = self
@property
def child(self) -> Optional[AudioSink]:
return self._children[0] if self._children else None
@property
def children(self) -> Sequence[AudioSink]:
return SequenceProxy(self._children)
# TODO: add functions to add/remove children?
class BasicSink(AudioSink):
"""Simple callback based sink."""
def __init__(
self,
event: BasicSinkWriteCB,
*,
rtcp_event: Optional[BasicSinkWriteRTCPCB] = None,
decode: bool = True,
):
super().__init__()
self.cb = event
self.cb_rtcp = rtcp_event
self.decode = decode
def wants_opus(self) -> bool:
return not self.decode
def write(self, user: Optional[User], data: VoiceData) -> None:
self.cb(user, data)
@AudioSink.listener()
def on_rtcp_packet(self, packet: RTCPPacket, guild: discord.Guild) -> None:
self.cb_rtcp(packet) if self.cb_rtcp else None
def cleanup(self) -> None:
pass
class WaveSink(AudioSink):
"""Endpoint AudioSink that generates a wav file.
Best used in conjunction with a silence generating sink. (TBD)
"""
CHANNELS = OpusDecoder.CHANNELS
SAMPLE_WIDTH = OpusDecoder.SAMPLE_SIZE // OpusDecoder.CHANNELS
SAMPLING_RATE = OpusDecoder.SAMPLING_RATE
def __init__(self, destination: wave._File):
super().__init__()
self._file: wave.Wave_write = wave.open(destination, 'wb')
self._file.setnchannels(self.CHANNELS)
self._file.setsampwidth(self.SAMPLE_WIDTH)
self._file.setframerate(self.SAMPLING_RATE)
def wants_opus(self) -> bool:
return False
def write(self, user: Optional[User], data: VoiceData) -> None:
self._file.writeframes(data.pcm)
def cleanup(self) -> None:
try:
self._file.close()
except Exception:
log.warning("WaveSink got error closing file on cleanup", exc_info=True)
WavSink = WaveSink
class FFmpegSink(AudioSink):
@overload
def __init__(
self,
*,
filename: str,
executable: str = 'ffmpeg',
stderr: Optional[IO[bytes]] = None,
before_options: Optional[str] = None,
options: Optional[str] = None,
on_error: Optional[FFmpegErrorCB] = None,
): ...
@overload
def __init__(
self,
*,
buffer: IO[bytes],
executable: str = 'ffmpeg',
stderr: Optional[IO[bytes]] = None,
before_options: Optional[str] = None,
options: Optional[str] = None,
on_error: Optional[FFmpegErrorCB] = None,
): ...
def __init__(
self,
*,
filename: str = MISSING,
buffer: IO[bytes] = MISSING,
executable: str = 'ffmpeg',
stderr: Optional[IO[bytes]] = None,
before_options: Optional[str] = None,
options: Optional[str] = None,
on_error: Optional[FFmpegErrorCB] = None,
):
super().__init__()
self.filename: str = filename or 'pipe:1'
self.buffer: IO[bytes] = buffer
self.on_error: FFmpegErrorCB = on_error or self._on_error
args = [executable, '-hide_banner']
subprocess_kwargs: Dict[str, Any] = {'stdin': subprocess.PIPE}
if self.buffer is not MISSING:
subprocess_kwargs['stdout'] = subprocess.PIPE
piping_stderr = False
if stderr is not None:
try:
stderr.fileno()
except Exception:
piping_stderr = True
subprocess_kwargs['stderr'] = subprocess.PIPE
if isinstance(before_options, str):
args.extend(shlex.split(before_options))
# fmt: off
args.extend((
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-i', 'pipe:0',
'-loglevel', 'warning',
'-blocksize', str(discord.FFmpegAudio.BLOCKSIZE)
))
# fmt: on
if isinstance(options, str):
args.extend(shlex.split(options))
args.append(self.filename)
self._process: subprocess.Popen = MISSING
self._process = self._spawn_process(args, **subprocess_kwargs)
self._stdin: IO[bytes] = self._process.stdin # type: ignore
self._stdout: Optional[IO[bytes]] = None
self._stderr: Optional[IO[bytes]] = None
self._stdout_reader_thread: Optional[threading.Thread] = None
self._stderr_reader_thread: Optional[threading.Thread] = None
if self.buffer:
n = f'popen-stout-reader:pid-{self._process.pid}'
self._stdout = self._process.stdout
_args = (self._stdout, self.buffer)
self._stdout_reader_thread = threading.Thread(target=self._pipe_reader, args=_args, daemon=True, name=n)
self._stdout_reader_thread.start()
if piping_stderr:
n = f'popen-stderr-reader:pid-{self._process.pid}'
self._stderr = self._process.stderr
_args = (self._stderr, stderr)
self._stderr_reader_thread = threading.Thread(target=self._pipe_reader, args=_args, daemon=True, name=n)
self._stderr_reader_thread.start()
@staticmethod
def _on_error(_self: FFmpegSink, error: Exception, data: Optional[VoiceData]) -> None:
_self.voice_client.stop_listening() # type: ignore
def wants_opus(self) -> bool:
return False
def cleanup(self):
self._kill_process()
self._process = self._stdout = self._stdin = self._stderr = MISSING
def write(self, user: Optional[User], data: VoiceData):
if self._process and not self._stdin.closed:
audio = data.opus if self.wants_opus() else data.pcm
assert audio is not None
try:
self._stdin.write(audio)
except Exception as e:
log.exception('Error writing data to ffmpeg')
self._kill_process()
self.on_error(self, e, data)
def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Popen:
log.debug('Spawning ffmpeg process with command: %s, kwargs: %s', args, subprocess_kwargs)
process = None
try:
process = subprocess.Popen(args, creationflags=discord.player.CREATE_NO_WINDOW, **subprocess_kwargs)
except FileNotFoundError:
executable = args.partition(' ')[0] if isinstance(args, str) else args[0]
raise Exception(executable + ' was not found.') from None
except subprocess.SubprocessError as exc:
raise Exception(f'Popen failed: {exc.__class__.__name__}: {exc}') from exc
else:
return process
def _kill_process(self) -> None:
# this function gets called in __del__ so instance attributes might not even exist
proc: subprocess.Popen = getattr(self, '_process', MISSING)
if proc is MISSING:
return
log.debug('Terminating ffmpeg process %s.', proc.pid)
try:
self._stdin.close()
except Exception:
pass
# TODO: extract wait time
log.debug('Waiting for ffmpeg process %s for up to 5 seconds.', proc.pid)
try:
proc.wait(5)
except Exception:
pass
try:
proc.kill()
except Exception:
log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid)
if proc.poll() is None:
log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid)
proc.communicate()
log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode)
else:
log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode)
self._process = MISSING
def _pipe_reader(self, source: IO[bytes], dest: IO[bytes]) -> None:
while self._process:
if source.closed:
return
try:
data = source.read(discord.FFmpegAudio.BLOCKSIZE)
except (OSError, ValueError) as e:
log.debug('FFmpeg stdin pipe closed: %s', e)
return
except Exception:
log.debug('Read error for %s, this is probably not a problem', self, exc_info=True)
return
if data is None:
return
try:
dest.write(data)
except Exception as e:
log.exception('Write error for %s', self)
self._kill_process()
self.on_error(self, e, None)
return
class PCMVolumeTransformer(AudioSink):
"""AudioSink used to change the volume of PCM data, just like
:class:`discord.PCMVolumeTransformer`.
"""
def __init__(self, destination: AudioSink, volume: float = 1.0):
if not isinstance(destination, AudioSink):
raise TypeError(f'expected AudioSink not {type(destination).__name__}')
if destination.wants_opus():
raise VoiceRecvException('AudioSink must not request Opus encoding.')
super().__init__(destination)
self.destination: AudioSink = destination
self._volume: float = volume
def wants_opus(self) -> bool:
return False
@property
def volume(self) -> float:
"""Retrieves or sets the volume as a floating point percentage (e.g. 1.0 for 100%)."""
return self._volume
@volume.setter
def volume(self, value: float):
self._volume = max(value, 0.0)
def write(self, user: Optional[User], data: VoiceData) -> None:
data.pcm = audioop.mul(data.pcm, 2, min(self._volume, 2.0))
self.destination.write(user, data)
def cleanup(self) -> None:
pass
class ConditionalFilter(AudioSink):
"""AudioSink for filtering packets based on an arbitrary predicate function."""
def __init__(self, destination: AudioSink, predicate: ConditionalFilterFn):
super().__init__(destination)
self.destination: AudioSink = destination
self.predicate: ConditionalFilterFn = predicate
def wants_opus(self) -> bool:
return self.destination.wants_opus()
def write(self, user: Optional[User], data: VoiceData) -> None:
if self.predicate(user, data):
self.destination.write(user, data)
def cleanup(self) -> None:
del self.predicate
class UserFilter(ConditionalFilter):
"""A convenience class for a User based ConditionalFilter."""
def __init__(self, destination: AudioSink, user: User):
super().__init__(destination, self._predicate)
self.user: User = user
def _predicate(self, user: Optional[User], data: VoiceData) -> bool:
return user == self.user
class TimedFilter(ConditionalFilter):
"""A convenience class for a timed ConditionalFilter."""
def __init__(self, destination: AudioSink, duration: float, *, start_on_init: bool = False):
super().__init__(destination, self.predicate)
self.duration: float = duration
self.start_time: Optional[float]
if start_on_init:
self.start_time = self.get_time()
else:
self.start_time = None
self.write = self._write_once
def _write_once(self, user: Optional[User], data: VoiceData):
self.start_time = self.get_time()
super().write(user, data)
self.write = super().write
def predicate(self, user: Optional[User], data: VoiceData) -> bool:
return self.start_time is not None and self.get_time() - self.start_time < self.duration
def get_time(self) -> float:
"""Function to generate a timestamp. Defaults to `time.perf_counter()`.
Can be overridden.
"""
return time.perf_counter()
class SilenceGeneratorSink(AudioSink):
"""Generates intermittent silence packets during transmission downtime."""
def __init__(self, destination: AudioSink):
super().__init__(destination)
self.destination: AudioSink = destination
self.silencegen: SilenceGenerator = SilenceGenerator(self.destination.write)
self.silencegen.start()
def wants_opus(self) -> bool:
return self.destination.wants_opus()
def write(self, user: Optional[User], data: VoiceData) -> None:
self.silencegen.push(user, data.packet)
self.destination.write(user, data)
@AudioSink.listener()
def on_voice_member_disconnect(self, member: discord.Member, ssrc: Optional[int]) -> None:
self.silencegen.drop(ssrc=ssrc, user=member)
def cleanup(self) -> None:
self.silencegen.stop()

View file

@ -1,59 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING, List, Literal, Optional, TypedDict
from discord.types.snowflake import Snowflake
if TYPE_CHECKING:
from typing import Union
import discord
MemberOrUser = Union[discord.Member, discord.User]
ResolutionTypes = Literal['fixed', 'source']
StreamTypes = Literal['audio', 'video', 'screen', 'test'] # only video appears to be used
class VideoResolution(TypedDict):
height: int
width: int
type: ResolutionTypes
class VideoStream(TypedDict):
type: StreamTypes
active: bool
max_bitrate: int
max_framerate: int
max_resolution: VideoResolution
quality: int
rid: int
rtx_ssrc: int
ssrc: int
class VoiceVideoPayload(TypedDict):
audio_ssrc: int
video_ssrc: int
user_id: Snowflake
streams: list[VideoStream]
class VoiceClientConnectPayload(TypedDict):
user_ids: List[Snowflake]
class VoiceClientDisconnectPayload(TypedDict):
user_id: Snowflake
class VoiceFlagsPayload(TypedDict):
flags: Optional[int]
user_id: Snowflake
class VoicePlatformPayload(TypedDict):
platform: Optional[Literal[0, 1, 2, 3]]
user_id: Snowflake

View file

@ -1,205 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import time
import threading
from collections import defaultdict
from typing import TYPE_CHECKING, Generic, TypeVar
if TYPE_CHECKING:
from typing import Callable, Sequence
TimeFunc = Callable[[], float]
_dataT = TypeVar("_dataT")
def gap_wrapped(a: int, b: int, *, wrap: int = 65536) -> int:
"""
Returns the gap between two numbers, acounting for unsigned integer wraparound.
"""
return (b - (a + 1) + wrap) % wrap
def add_wrapped(a: int, b: int, *, wrap: int = 65536) -> int:
"""
Returns the sum of two numbers, accounting for unsigned integer wraparound.
"""
return (a + b) % wrap
# May not even be needed if i dont use the dict subclasses
class Bidict(dict):
"""A bi-directional dict"""
_None = object()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super().update({v: k for k, v in self.items()})
def __setitem__(self, key, value):
# Delete related mappings
# if we have 1 <-> 2 and we set 2 <-> 3, 2 is now unrelated to 1
if key in self:
del self[key]
if value in self:
del self[value]
super().__setitem__(key, value)
super().__setitem__(value, key)
def __delitem__(self, key):
value = super().__getitem__(key)
super().__delitem__(value)
if key == value:
return
super().__delitem__(key)
def to_dict(self):
return super().copy()
def pop(self, k, d=_None):
try:
v = super().pop(k)
super().pop(v, d)
return v
except KeyError:
if d is not self._None:
return d
raise
def popitem(self):
item = super().popitem()
super().__delitem__(item[1])
return item
def setdefault(self, k, d=None):
try:
return self[k]
except KeyError:
if d in self:
return d
self[k] = d
return d
def update(self, *args, **F):
try:
E = args[0]
if callable(getattr(E, 'keys', None)):
for k in E:
self[k] = E[k]
else:
for k, v in E:
self[k] = v
except IndexError:
pass
finally:
for k in F:
self[k] = F[k]
def copy(self):
return self.__class__(super().copy())
# incompatible
# https://docs.python.org/3/library/exceptions.html#NotImplementedError, Note 1
fromkeys = None # type: ignore
class Defaultdict(defaultdict):
def __missing__(self, key):
if self.default_factory is None:
raise KeyError((key,))
self[key] = value = self.default_factory(key) # type: ignore
return value
class LoopTimer:
def __init__(self, delay: float, *, timefunc: TimeFunc = time.perf_counter):
self._delay: float = delay
self._time: TimeFunc = timefunc
self._start: float = 0
self._loops: int = 0
@property
def delay(self) -> float:
return self._delay
@property
def loops(self) -> int:
return self._loops
@property
def start_time(self) -> float:
return self._start
@property
def remaining_time(self) -> float:
next_time = self._start + self._delay * self._loops
return self._delay + (next_time - self._time())
def start(self) -> None:
self._loops = 0
self._start = self._time()
def mark(self) -> None:
self._loops += 1
def sleep(self) -> None:
time.sleep(max(0, self.remaining_time))
class MultiDataEvent(Generic[_dataT]):
"""
Something like the inverse of a Condition. A 1-waiting-on-N type of object,
with accompanying data object for convenience.
"""
def __init__(self):
self._items: list[_dataT] = []
self._ready: threading.Event = threading.Event()
@property
def items(self) -> list[_dataT]:
"""A shallow copy of the currently ready objects."""
return self._items.copy()
def is_ready(self) -> bool:
return self._ready.is_set()
def _check_ready(self) -> None:
if self._items:
self._ready.set()
else:
self._ready.clear()
def notify(self) -> None:
self._ready.set()
self._check_ready()
def wait(self, timeout: float | None = None) -> bool:
self._check_ready()
return self._ready.wait(timeout)
def register(self, item: _dataT) -> None:
self._items.append(item)
self._ready.set()
def unregister(self, item: _dataT) -> None:
try:
self._items.remove(item)
except ValueError:
pass
self._check_ready()
def clear(self) -> None:
self._items.clear()
self._ready.clear()

View file

@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .types import (
VoiceVideoPayload,
VideoStream as VideoStreamPayload,
VideoResolution as VideoResolutionPayload,
)
from .voice_client import VoiceRecvClient
__all__ = [
'VoiceVideoStreams',
]
class VoiceVideoStreams:
__slots__ = (
'audio_ssrc',
'video_ssrc',
'member',
'streams',
)
def __init__(self, *, data: VoiceVideoPayload, vc: VoiceRecvClient):
self.audio_ssrc = data['audio_ssrc']
self.video_ssrc = data['video_ssrc']
self.member = vc.guild.get_member(int(data['user_id']))
self.streams = self._get_streams(data['streams'])
def __repr__(self) -> str:
return f"<VoiceVideoStreams member={self.member!s} streams={self._minify_streams()}>"
def _get_streams(self, data: list[VideoStreamPayload]) -> list[VideoStreamInfo]:
return [VideoStreamInfo(data=stream) for stream in data]
def _minify_streams(self) -> str:
streams = [f"<rid={s.rid} active={s.active}>" for s in self.streams]
return f"[{', '.join(streams)}]"
class VideoStreamInfo:
__slots__ = (
'type',
'active',
'max_bitrate',
'max_framerate',
'max_resolution',
'quality',
'rid',
'rtx_ssrc',
'ssrc',
)
def __init__(self, *, data: VideoStreamPayload):
self.type: str = data.get('type', 'video')
self.active = data['active']
self.max_bitrate = data.get('max_bitrate', 0)
self.max_framerate = data['max_framerate']
self.max_resolution = VideoStreamResolution(data['max_resolution'])
self.quality = data['quality']
self.rid = data['rid']
self.rtx_ssrc = data['rtx_ssrc']
self.ssrc = data['ssrc']
def __repr__(self) -> str:
attrs = [
('ssrc', self.ssrc),
('active', self.active),
('quality', self.quality),
('max_bitrate', self.max_bitrate),
('max_framerate', self.max_framerate),
('max_resolution', self.max_resolution),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f'<{self.__class__.__name__} {inner}>'
class VideoStreamResolution:
__slots__ = (
'height',
'width',
'type',
)
def __init__(self, data: VideoResolutionPayload):
self.height = data['height']
self.width = data['width']
self.type = data['type']
def __repr__(self) -> str:
return f"<VideoStreamResolution width={self.width!r} height={self.height!r} type={self.type!r}>"

View file

@ -1,196 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import time
import asyncio
import logging
import discord
from discord.voice_state import VoiceConnectionState
from discord.utils import MISSING
from typing import TYPE_CHECKING
from .gateway import hook
from .reader import AudioReader
from .sinks import AudioSink
if TYPE_CHECKING:
from typing import Optional, Dict, Any, Union
from discord.ext.commands._types import CoroFunc
from .reader import AfterCB
from pprint import pformat
__all__ = [
'VoiceRecvClient',
]
log = logging.getLogger(__name__)
class VoiceRecvClient(discord.VoiceClient):
endpoint_ip: str
voice_port: int
def __init__(self, client: discord.Client, channel: discord.abc.Connectable):
super().__init__(client, channel)
self._reader: AudioReader = MISSING
self._ssrc_to_id: Dict[int, int] = {}
self._id_to_ssrc: Dict[int, int] = {}
self._event_listeners: Dict[str, list] = {}
def create_connection_state(self) -> VoiceConnectionState:
return VoiceConnectionState(self, hook=hook)
async def on_voice_state_update(self, data) -> None:
old_channel_id = self.channel.id if self.channel else None
await super().on_voice_state_update(data)
log.debug("Got voice_client VSU: \n%s", pformat(data, compact=True))
# this can be None
try:
channel_id = int(data['channel_id'])
except TypeError:
return
# if we joined, left, or switched channels, reset the decoders
if self._reader and channel_id != old_channel_id:
log.debug("Destroying all decoders in guild %s", self.guild.id)
self._reader.packet_router.destroy_all_decoders()
def add_listener(self, func: CoroFunc, *, name: str = MISSING) -> None:
name = func.__name__ if name is MISSING else name
if not asyncio.iscoroutinefunction(func):
raise TypeError('Listeners must be coroutines')
if name in self._event_listeners:
self._event_listeners[name].append(func)
else:
self._event_listeners[name] = [func]
def remove_listener(self, func: CoroFunc, *, name: str = MISSING) -> None:
name = func.__name__ if name is MISSING else name
if name in self._event_listeners:
try:
self._event_listeners[name].remove(func)
except ValueError:
pass
async def _run_event(self, coro: CoroFunc, event_name: str, *args: Any, **kwargs: Any) -> None:
try:
await coro(*args, **kwargs)
except asyncio.CancelledError:
pass
except Exception:
log.exception("Error calling %s", event_name)
def _schedule_event(self, coro: CoroFunc, event_name: str, *args: Any, **kwargs: Any) -> asyncio.Task:
wrapped = self._run_event(coro, event_name, *args, **kwargs)
return self.client.loop.create_task(wrapped, name=f"ext.voice_recv: {event_name}")
def dispatch(self, event: str, /, *args: Any, **kwargs: Any) -> None:
log.debug("Dispatching voice_client event %s", event)
event_name = f"on_{event}"
for coro in self._event_listeners.get(event_name, []):
self._schedule_event(coro, event_name, *args, **kwargs)
self.dispatch_sink(event, *args, **kwargs)
self.client.dispatch(event, *args, **kwargs)
def dispatch_sink(self, event: str, /, *args: Any, **kwargs: Any) -> None:
if self._reader:
self._reader.event_router.dispatch(event, *args, **kwargs)
def cleanup(self) -> None:
# TODO: Does the order here matter?
super().cleanup()
self._event_listeners.clear()
self.stop()
def _add_ssrc(self, user_id: int, ssrc: int) -> None:
self._ssrc_to_id[ssrc] = user_id
self._id_to_ssrc[user_id] = ssrc
if self._reader:
self._reader.packet_router.set_user_id(ssrc, user_id)
def _remove_ssrc(self, *, user_id: int) -> None:
ssrc = self._id_to_ssrc.pop(user_id, None)
if ssrc:
self._reader.speaking_timer.drop_ssrc(ssrc)
self._ssrc_to_id.pop(ssrc, None)
def _get_ssrc_from_id(self, user_id: int) -> Optional[int]:
return self._id_to_ssrc.get(user_id)
def _get_id_from_ssrc(self, ssrc: int) -> Optional[int]:
return self._ssrc_to_id.get(ssrc)
def listen(self, sink: AudioSink, *, after: Optional[AfterCB] = None) -> None:
"""Receives audio into a :class:`AudioSink`."""
# TODO: more info
if not self.is_connected():
raise discord.ClientException('Not connected to voice.')
if not isinstance(sink, AudioSink):
raise TypeError('sink must be an AudioSink not {0.__class__.__name__}'.format(sink))
if self.is_listening():
raise discord.ClientException('Already receiving audio.')
self._reader = AudioReader(sink, self, after=after)
self._reader.start()
def is_listening(self) -> bool:
"""Indicates if we're currently receiving audio."""
return self._reader and self._reader.is_listening()
def stop_listening(self) -> None:
"""Stops receiving audio."""
if self._reader:
self._reader.stop()
self._reader = MISSING
def stop_playing(self) -> None:
"""Stops playing audio."""
if self._player:
self._player.stop()
self._player = None
def stop(self) -> None:
"""Stops playing and receiving audio."""
self.stop_playing()
self.stop_listening()
@property
def sink(self) -> Optional[AudioSink]:
return self._reader.sink if self._reader else None
@sink.setter
def sink(self, sink: AudioSink) -> None:
if not isinstance(sink, AudioSink):
raise TypeError('expected AudioSink not {0.__class__.__name__}.'.format(sink))
if not self._reader:
raise ValueError('Not receiving anything.')
self._reader.set_sink(sink)
def get_speaking(self, member: Union[discord.Member, discord.User]) -> Optional[bool]:
"""Returns if a member is speaking (approximately), or None if not found."""
ssrc = self._get_ssrc_from_id(member.id)
if ssrc is None:
return
if self._reader:
return self._reader.speaking_timer.get_speaking(ssrc)