From 5a95f9840edbba140794cff59cb5faf36b6777fb Mon Sep 17 00:00:00 2001 From: vito1317 Date: Wed, 18 Mar 2026 02:35:00 +0800 Subject: [PATCH 01/14] fix(voice): implement DAVE E2E decryption for voice reception Discord enforces DAVE (E2E encryption) on all voice channels. py-cord currently decodes Opus packets before stripping the DAVE application-layer encryption, causing OpusError: corrupted stream and making voice reception completely non-functional. Changes: - reader.py: Rewrite decrypt_rtp() to call dave.decrypt() BEFORE Opus decoding. Handles SSRC-to-user_id race condition by trying all known DAVE user IDs when the mapping is not yet populated, then caching the result. Falls back to OPUS_SILENCE on decrypt failure. - opus.py: Remove erroneous dave.decrypt() call in _decode_packet() that was applied to already-decoded PCM data, corrupting the audio stream. - router.py: Catch OpusError and AssertionError in _do_run() gracefully, emitting a single warning instead of crashing the router thread. Fixes: https://github.com/Pycord-Development/pycord/issues/3139 --- discord/opus.py | 5 --- discord/voice/receive/reader.py | 56 ++++++++++++++++++--------------- discord/voice/receive/router.py | 21 +++++++++++-- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/discord/opus.py b/discord/opus.py index f6c42fdd04..fa27a5a112 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -725,9 +725,4 @@ def _decode_packet(self, packet: Packet) -> tuple[Packet, bytes]: else: pcm = self._decoder.decode(None, fec=False) - if HAS_DAVEY: - if user_id is not None and in_dave and dave.can_passthrough(user_id): - _log.debug("User ID %s can passthrough, decrypting with DAVE", user_id) - pcm = dave.decrypt(user_id, davey.MediaType.audio, pcm) - return packet, pcm diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 7ec0300c66..0f07ed9c22 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -271,21 +271,6 @@ def _make_box(self, secret_key: bytes) -> EncryptionBox: else: return nacl.secret.SecretBox(secret_key) - """def decrypt_rtp(self, packet: RTPPacket) -> bytes: - state = self.client._connection - dave = state.dave_session - data = self._decryptor_rtp(packet) - - if dave is not None and dave.ready and packet.ssrc in state.ssrc_user_map: - data = dave.decrypt( - state.ssrc_user_map[packet.ssrc], davey.MediaType.audio, data - ) - - if packet.extended: - offset = packet.update_extended_header(data) - data = data[offset:] - - return data""" def decrypt_rtp(self, packet: RTPPacket) -> bytes: state = self.client._connection @@ -293,8 +278,32 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: raw_payload = self._decryptor_rtp(packet) + if dave is not None and dave.ready: uid = state.ssrc_user_map.get(packet.ssrc) + + if not uid: + # SSRC→user_id mapping not yet populated (race with member_connect). + # Try every user ID known to the DAVE session until one decrypts. + for candidate_uid in dave.get_user_ids(): + try: + decrypted_audio = dave.decrypt( + candidate_uid, + davey.MediaType.audio, + raw_payload, + ) + # Successfully decrypted — cache the mapping for next time + self.client._connection.user_ssrc_map[candidate_uid] = packet.ssrc + uid = candidate_uid + raw_payload = decrypted_audio + _log.debug( + "DAVE: inferred ssrc %s -> user_id %s from decryption", + packet.ssrc, uid, + ) + break + except Exception: + continue + if uid: try: decrypted_audio = dave.decrypt( @@ -302,16 +311,10 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: davey.MediaType.audio, raw_payload, ) - - if packet.extended: - offset = packet.update_extended_header(decrypted_audio) - packet.decrypted_data = decrypted_audio[offset:] - else: - packet.decrypted_data = decrypted_audio + # dave.decrypt() returns raw Opus — no extension headers. + # The extension was already stripped by _decryptor_rtp above. + packet.decrypted_data = decrypted_audio except Exception as exc: - _log.debug( - "Ignoring exception while decoding DAVE packet", exc_info=exc - ) packet.decrypted_data = OPUS_SILENCE return packet.decrypted_data @@ -425,9 +428,10 @@ def _decrypt_rtp_aead_xchacha20_poly1305_rtpsize(self, packet: RTPPacket) -> byt raise CryptoError(exc) if packet.extended: - packet.update_extended_header(result) + offset = packet.update_extended_header(result) + return result[offset:] - return result[8:] + return result def _decrypt_rtcp_aead_xchacha20_poly1305_rtpsize(self, data: bytes) -> bytes: _log.debug("Decrypting RTCP AEAD XChaCha20 Poly1305 RTPSize") diff --git a/discord/voice/receive/router.py b/discord/voice/receive/router.py index b7f2f28064..3dd2a6e35a 100644 --- a/discord/voice/receive/router.py +++ b/discord/voice/receive/router.py @@ -125,14 +125,29 @@ def run(self) -> None: self.waiter.clear() def _do_run(self) -> None: + from discord.opus import OpusError + dave_warned = False 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, data.source) + try: + data = decoder.pop_data() + if data is not None: + self.sink.write(data, data.source) + except (OpusError, AssertionError) as exc: + # Under DAVE E2E encryption, audio payloads are still + # encrypted at the application layer — Opus decode fails. + # Skip the packet gracefully instead of crashing. + if not dave_warned: + _log.warning( + "Skipping DAVE-encrypted packet (OpusError: %s). " + "Voice reception unavailable until py-cord adds DAVE decryption. " + "See https://github.com/Pycord-Development/pycord/issues/3139", + exc, + ) + dave_warned = True class SinkEventRouter(threading.Thread): From c9e40d93526360f61c4944853af6c0e5850c3efb Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Thu, 19 Mar 2026 09:31:41 +0100 Subject: [PATCH 02/14] feat: Fixes and typesafe discord/voice --- discord/client.py | 3 +- discord/errors.py | 13 ++++ discord/sinks/core.py | 31 ++++++++- discord/sinks/m4a.py | 2 +- discord/sinks/mka.py | 2 +- discord/sinks/mkv.py | 2 +- discord/sinks/mp3.py | 2 +- discord/sinks/mp4.py | 2 +- discord/sinks/ogg.py | 2 +- discord/sinks/wave.py | 21 ++++-- discord/utils.py | 49 ++++++++++++-- discord/voice/__init__.py | 6 ++ discord/voice/client.py | 90 +++++++++++-------------- discord/voice/gateway.py | 38 ++++++----- discord/voice/packets/core.py | 4 +- discord/voice/packets/rtp.py | 72 +++++++++++--------- discord/voice/receive/reader.py | 68 +++++++++---------- discord/voice/receive/router.py | 26 ++----- discord/voice/state.py | 31 ++++----- discord/voice/utils/buffer.py | 49 ++------------ discord/voice/utils/dependencies.py | 51 +++----------- discord/voice/utils/multidataevent.py | 2 +- tests/voice/test_dependency_behavior.py | 25 ++++--- tests/voice/test_imports.py | 9 +-- 24 files changed, 310 insertions(+), 290 deletions(-) diff --git a/discord/client.py b/discord/client.py index 2f657dbd92..77f9b84c42 100644 --- a/discord/client.py +++ b/discord/client.py @@ -70,8 +70,7 @@ from .threads import Thread from .ui.view import BaseView from .user import ClientUser, User -from .utils import _D, _FETCHABLE, MISSING -from .voice.utils.dependencies import warn_if_voice_dependencies_missing +from .utils import _D, _FETCHABLE, MISSING, warn_if_voice_dependencies_missing from .webhook import Webhook from .widget import Widget diff --git a/discord/errors.py b/discord/errors.py index 589f4f2014..d0d46db5f9 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -64,6 +64,7 @@ "ApplicationCommandError", "CheckFailure", "ApplicationCommandInvokeError", + "MissingVoiceDependencies", ) @@ -411,3 +412,15 @@ def __init__(self, e: Exception) -> None: super().__init__( f"Application Command raised an exception: {e.__class__.__name__}: {e}" ) + + +class MissingVoiceDependencies(RuntimeError): + """Raised when required voice dependencies are not installed.""" + + def __init__(self, missing: tuple[str, ...]) -> None: + self.missing = missing + deps = ", ".join(missing) + super().__init__( + f"{deps} {'is' if len(missing) == 1 else 'are'} required for voice support. " + 'Install them with "pip install py-cord[voice]".' + ) diff --git a/discord/sinks/core.py b/discord/sinks/core.py index d22969a675..1525ea16d8 100644 --- a/discord/sinks/core.py +++ b/discord/sinks/core.py @@ -37,7 +37,10 @@ from .errors import SinkException if TYPE_CHECKING: + from ..member import Member + from ..user import User from ..voice import VoiceClient + from ..voice.packets import VoiceData __all__ = ( "Filters", @@ -210,6 +213,8 @@ class Sink(Filters): Audio may only be formatted after recording is finished. """ + __sink_listeners__: list[tuple[str, str]] = [] + def __init__(self, *, filters=None): if filters is None: filters = default_filters @@ -222,18 +227,38 @@ def __init__(self, *, filters=None): def client(self) -> VoiceClient | None: return self.vc + @property + def recording(self) -> bool: + """Whether the voice client is currently recording.""" + return self.vc is not None and self.vc.is_recording() + + def is_opus(self) -> bool: + """Whether this sink accepts raw opus packets instead of decoded PCM.""" + return False + + def walk_children(self): + """Yields child sinks. Base implementation yields nothing.""" + return + yield # make it a generator + def init(self, vc: VoiceClient): # called under listen self.vc = vc super().init() @Filters.container - def write(self, data, user): + def write(self, data: VoiceData | bytes, user: User | Member | None) -> None: + from ..voice.packets import VoiceData + + if isinstance(data, VoiceData): + pcm_data = data.pcm + else: + pcm_data = data + if user not in self.audio_data: file = io.BytesIO() self.audio_data.update({user: AudioData(file)}) - file = self.audio_data[user] - file.write(data) + self.audio_data[user].write(pcm_data) def cleanup(self): self.finished = True diff --git a/discord/sinks/m4a.py b/discord/sinks/m4a.py index 1cff9da538..a857ffffc8 100644 --- a/discord/sinks/m4a.py +++ b/discord/sinks/m4a.py @@ -57,7 +57,7 @@ def format_audio(self, audio): M4ASinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise M4ASinkError( "Audio may only be formatted after recording is finished." ) diff --git a/discord/sinks/mka.py b/discord/sinks/mka.py index c2bbefb923..cb8c2dcd74 100644 --- a/discord/sinks/mka.py +++ b/discord/sinks/mka.py @@ -55,7 +55,7 @@ def format_audio(self, audio): MKASinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise MKASinkError( "Audio may only be formatted after recording is finished." ) diff --git a/discord/sinks/mkv.py b/discord/sinks/mkv.py index 93f4cc7444..0405758a35 100644 --- a/discord/sinks/mkv.py +++ b/discord/sinks/mkv.py @@ -55,7 +55,7 @@ def format_audio(self, audio): MKVSinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise MKVSinkError( "Audio may only be formatted after recording is finished." ) diff --git a/discord/sinks/mp3.py b/discord/sinks/mp3.py index 74386a2738..a356009a1c 100644 --- a/discord/sinks/mp3.py +++ b/discord/sinks/mp3.py @@ -55,7 +55,7 @@ def format_audio(self, audio): MP3SinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise MP3SinkError( "Audio may only be formatted after recording is finished." ) diff --git a/discord/sinks/mp4.py b/discord/sinks/mp4.py index c4d0ed2b63..3158dc5572 100644 --- a/discord/sinks/mp4.py +++ b/discord/sinks/mp4.py @@ -57,7 +57,7 @@ def format_audio(self, audio): MP4SinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise MP4SinkError( "Audio may only be formatted after recording is finished." ) diff --git a/discord/sinks/ogg.py b/discord/sinks/ogg.py index 7b531464bd..57232cb5c0 100644 --- a/discord/sinks/ogg.py +++ b/discord/sinks/ogg.py @@ -55,7 +55,7 @@ def format_audio(self, audio): OGGSinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise OGGSinkError( "Audio may only be formatted after recording is finished." ) diff --git a/discord/sinks/wave.py b/discord/sinks/wave.py index 37f5aac933..5c4fa18f8d 100644 --- a/discord/sinks/wave.py +++ b/discord/sinks/wave.py @@ -24,6 +24,7 @@ import wave +from ..opus import Decoder as OpusDecoder from .core import Filters, Sink, default_filters from .errors import WaveSinkError @@ -54,16 +55,22 @@ def format_audio(self, audio): WaveSinkError Formatting the audio failed. """ - if self.vc.recording: + if self.recording: raise WaveSinkError( "Audio may only be formatted after recording is finished." ) - data = audio.file + from io import BytesIO - with wave.open(data, "wb") as f: - f.setnchannels(self.vc.decoder.CHANNELS) - f.setsampwidth(self.vc.decoder.SAMPLE_SIZE // self.vc.decoder.CHANNELS) - f.setframerate(self.vc.decoder.SAMPLING_RATE) + audio.file.seek(0) + pcm_data = audio.file.read() - data.seek(0) + output = BytesIO() + with wave.open(output, "wb") as f: + f.setnchannels(OpusDecoder.CHANNELS) + f.setsampwidth(OpusDecoder.SAMPLE_SIZE // OpusDecoder.CHANNELS) + f.setframerate(OpusDecoder.SAMPLING_RATE) + f.writeframes(pcm_data) + + output.seek(0) + audio.file = output audio.on_format(self.encoding) diff --git a/discord/utils.py b/discord/utils.py index 8c6417efe0..02773b27d4 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -58,6 +58,7 @@ Iterator, Literal, Mapping, + ParamSpec, Protocol, Sequence, TypeVar, @@ -93,7 +94,6 @@ else: HAS_MSGSPEC = True - __all__ = ( "parse_time", "warn_deprecated", @@ -140,7 +140,6 @@ ) EMOJIS_MAP = {} - UNICODE_EMOJIS = set(EMOJIS_MAP.values()) @@ -176,9 +175,10 @@ class _RequestLike(Protocol): AutocompleteContext = Any OptionChoice = Any - T = TypeVar("T") T_co = TypeVar("T_co", covariant=True) +_MC_P = ParamSpec("_MC_P") +_MC_T = TypeVar("_MC_T") _Iter = Union[Iterator[T], AsyncIterator[T]] @@ -883,7 +883,11 @@ def _parse_ratelimit_header(request: Any, *, use_clock: bool = False) -> float: return (reset - now).total_seconds() -async def maybe_coroutine(f, *args, **kwargs): +async def maybe_coroutine( + f: Callable[_MC_P, _MC_T | Awaitable[_MC_T]], + *args: _MC_P.args, + **kwargs: _MC_P.kwargs, +) -> _MC_T: value = f(*args, **kwargs) if _isawaitable(value): return await value @@ -1632,3 +1636,40 @@ def users_to_csv(users: Iterable[Snowflake]) -> io.BytesIO: A file-like object containing the CSV data. """ return io.BytesIO("\n".join(map(lambda u: str(u.id), users)).encode("utf-8")) + + +def get_missing_voice_dependencies() -> tuple[str, ...]: + missing: list[str] = [] + try: + import nacl.secret # noqa: F401 + import nacl.utils # noqa: F401 + except ImportError: + missing.append("PyNaCl") + try: + import davey + + _ = davey.DAVE_PROTOCOL_VERSION + except ImportError: + missing.append("davey") + return tuple(missing) + + +_voice_dep_warning_emitted = False + + +def warn_if_voice_dependencies_missing() -> None: + global _voice_dep_warning_emitted + if _voice_dep_warning_emitted: + return + + missing = get_missing_voice_dependencies() + if not missing: + return + + _voice_dep_warning_emitted = True + deps = ", ".join(missing) + logging.getLogger("discord.client").warning( + "%s %s not installed, voice will NOT be supported", + deps, + "is" if len(missing) == 1 else "are", + ) diff --git a/discord/voice/__init__.py b/discord/voice/__init__.py index d6d13ab0df..3bcc852d36 100644 --- a/discord/voice/__init__.py +++ b/discord/voice/__init__.py @@ -8,6 +8,12 @@ :license: MIT, see LICENSE for more details. """ +from ..errors import MissingVoiceDependencies +from ..utils import get_missing_voice_dependencies + +if _missing := get_missing_voice_dependencies(): + raise MissingVoiceDependencies(_missing) + from ._types import * from .client import * from .packets import * diff --git a/discord/voice/client.py b/discord/voice/client.py index e54ce54a93..372e9027f1 100644 --- a/discord/voice/client.py +++ b/discord/voice/client.py @@ -30,7 +30,11 @@ import logging import struct import warnings -from typing import TYPE_CHECKING, Any, Literal, overload +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +import nacl.secret +import nacl.utils from discord import opus from discord.enums import SpeakingState, try_enum @@ -40,15 +44,12 @@ from discord.sinks.errors import RecordingException from discord.utils import MISSING +from ..errors import MissingVoiceDependencies +from ..utils import get_missing_voice_dependencies from ._types import VoiceProtocol from .enums import OpCodes from .receive import AudioReader from .state import VoiceConnectionState -from .utils.dependencies import HAS_DAVEY, HAS_NACL, get_missing_voice_dependencies - -if HAS_NACL: - import nacl.secret - import nacl.utils if TYPE_CHECKING: from typing_extensions import ParamSpec @@ -76,7 +77,7 @@ __all__ = ("VoiceClient",) -class VoiceClient(VoiceProtocol): +class VoiceClient(VoiceProtocol["Client"]): """Represents a Discord voice connection. You do not create these, you typically get them from e.g. @@ -106,15 +107,8 @@ def __init__( client: Client, channel: abc.Connectable, ) -> None: - missing = get_missing_voice_dependencies() - if missing: - deps = ", ".join(missing) - raise RuntimeError( - f"{deps} {'library is' if len(missing) == 1 else 'libraries are'} needed " - "in order to use voice related features, " - 'you can run "pip install py-cord[voice]" to install all voice-related ' - "dependencies." - ) + if missing := get_missing_voice_dependencies(): + raise MissingVoiceDependencies(missing) super().__init__(client, channel) state = client._connection @@ -135,7 +129,9 @@ def __init__( self._ssrc_to_id: dict[int, int] = {} self._id_to_ssrc: dict[int, int] = {} - self._event_listeners: dict[str, list] = {} + self._event_listeners: dict[ + str, list[Callable[..., Coroutine[Any, Any, Any]]] + ] = {} self._reader: AudioReader = MISSING @staticmethod @@ -155,7 +151,7 @@ def _set_future_result_if_pending( @property def guild(self) -> Guild: """Returns the guild the channel we're connecting to is bound to.""" - channel: VocalGuildChannel = self.channel + channel = cast("VocalGuildChannel", self.channel) return channel.guild @property @@ -258,7 +254,11 @@ async def _recv_hook(self, ws: VoiceWebSocket, msg: dict[str, Any]) -> None: # maybe handle video and such things? async def _run_event( - self, coro, event_name: str, *args: Any, **kwargs: Any + self, + coro: Callable[..., Coroutine[Any, Any, None]], + event_name: str, + *args: Any, + **kwargs: Any, ) -> None: try: await coro(*args, **kwargs) @@ -268,8 +268,12 @@ async def _run_event( _log.exception("Error calling %s", event_name) def _schedule_event( - self, coro, event_name: str, *args: Any, **kwargs: Any - ) -> asyncio.Task: + self, + coro: Callable[..., Coroutine[Any, Any, None]], + event_name: str, + *args: Any, + **kwargs: Any, + ) -> asyncio.Task[None]: wrapped = self._run_event(coro, event_name, *args, **kwargs) return self.client.loop.create_task( wrapped, name=f"voice-receiver-event-dispatch: {event_name}" @@ -432,6 +436,7 @@ def _get_voice_packet(self, data: Any) -> bytes: return encrypt_packet(header, packet) # encryption methods + # nacl is guaranteed to be available here because __init__ raises if missing def _encrypt_xsalsa20_poly1305(self, header: bytes, data: Any) -> bytes: # deprecated @@ -568,8 +573,10 @@ def play( raise ClientException("Not connected to voice") if self.is_playing(): raise ClientException("Already playing audio") - if not isinstance(source, AudioSource): - raise TypeError( + if not isinstance( + source, AudioSource + ): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( # pyright: ignore[reportUnreachable] f"Source must be an AudioSource, not {source.__class__.__name__}", ) if not self.encoder and not source.is_opus(): @@ -635,8 +642,12 @@ def source(self) -> AudioSource | None: @source.setter def source(self, value: AudioSource) -> None: - if not isinstance(value, AudioSource): - raise TypeError(f"expected AudioSource, not {value.__class__.__name__}") + if not isinstance( + value, AudioSource + ): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"expected AudioSource, not {value.__class__.__name__}" + ) # pyright: ignore[reportUnreachable] if self._player is None: raise ValueError("the client is not playing anything") @@ -698,10 +709,6 @@ def start_recording( .. versionadded:: 2.0 - .. warning:: - - Recording may not work as expected due to the new DAVE (End-to-End Encryption) for voice calls. - Parameters ---------- sink: :class:`~.Sink` @@ -731,17 +738,12 @@ def start_recording( TypeError You did not provide a Sink object. """ - warnings.warn( - "Voice reception is currently broken due to Discord's DAVE (End-to-End Encryption) protocol. " - + "Follow development progress at https://github.com/Pycord-Development/pycord/issues/3139", - RuntimeWarning, - stacklevel=2, - ) - # TODO: remove warning in voice-recv fix PR if not self.is_connected(): raise RecordingException("not connected to a voice channel") - if not isinstance(sink, Sink): - raise TypeError(f"expected a Sink object, got {sink.__class__.__name__}") + if not isinstance(sink, Sink): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"expected a Sink object, got {sink.__class__.__name__}" + ) # pyright: ignore[reportUnreachable] if self.is_recording(): raise ClientException("Already recording audio") @@ -769,12 +771,6 @@ def stop_recording(self) -> None: RecordingException You are not recording. """ - warnings.warn( - "Voice reception is currently broken due to Discord's DAVE (End-to-End Encryption) protocol. " - + "Follow development progress at https://github.com/Pycord-Development/pycord/issues/3139", - RuntimeWarning, - stacklevel=2, - ) if self._reader is not MISSING: self._reader.stop() self._reader = MISSING @@ -796,12 +792,6 @@ def is_speaking(self, member: Member | User) -> bool | None: .. versionadded:: 2.7 """ - warnings.warn( - "Voice reception is currently broken due to Discord's DAVE (End-to-End Encryption) protocol. " - + "Follow development progress at https://github.com/Pycord-Development/pycord/issues/3139", - RuntimeWarning, - stacklevel=2, - ) ssrc = self._id_to_ssrc.get(member.id) if ssrc is None: return None diff --git a/discord/voice/gateway.py b/discord/voice/gateway.py index d277917d2f..8ebb8b88c4 100644 --- a/discord/voice/gateway.py +++ b/discord/voice/gateway.py @@ -35,11 +35,7 @@ from typing import TYPE_CHECKING, Any import aiohttp - -from .utils.dependencies import HAS_DAVEY - -if HAS_DAVEY: - import davey +import davey from discord import utils from discord.enums import SpeakingState @@ -60,7 +56,7 @@ class KeepAliveHandler(KeepAliveHandlerBase): if TYPE_CHECKING: - ws: VoiceWebSocket + ws: VoiceWebSocket # pyright: ignore[reportIncompatibleVariableOverride] def __init__( self, @@ -129,24 +125,34 @@ def __init__( self._hook = hook or state.ws_hook # type: ignore @property - def token(self) -> str | None: + def token( + self, + ) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] return self.state.token @token.setter - def token(self, value: str | None) -> None: + def token( + self, value: str | None + ) -> None: # pyright: ignore[reportIncompatibleVariableOverride] self.state.token = value @property - def session_id(self) -> str | None: + def session_id( + self, + ) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] return self.state.session_id @session_id.setter - def session_id(self, value: str | None) -> None: + def session_id( + self, value: str | None + ) -> None: # pyright: ignore[reportIncompatibleVariableOverride] self.state.session_id = value @property def self_id(self) -> int: - return self._connection.self_id + self_id = self._connection.self_id + assert self_id is not None + return self_id async def _hook(self, *args: Any) -> Any: pass @@ -178,7 +184,7 @@ async def resume(self) -> None: } await self.send_as_json(payload) - async def received_message(self, msg: Any, /): + async def received_message(self, msg: Any, /) -> None: _log.debug("Voice websocket frame received: %s", msg) op = msg["op"] data = msg.get("d", {}) # this key should ALWAYS be given, but guard anyways @@ -206,9 +212,11 @@ async def received_message(self, msg: Any, /): await state.reinit_dave_session() elif op == OpCodes.hello: interval = data["heartbeat_interval"] / 1000.0 - self._keep_alive = KeepAliveHandler( - ws=self, - interval=min(interval, 5), + self._keep_alive = ( + KeepAliveHandler( # pyright: ignore[reportIncompatibleVariableOverride] + ws=self, + interval=min(interval, 5), + ) ) self._keep_alive.start() elif state.dave_session: diff --git a/discord/voice/packets/core.py b/discord/voice/packets/core.py index c38fc9face..9b45fea2ad 100644 --- a/discord/voice/packets/core.py +++ b/discord/voice/packets/core.py @@ -46,8 +46,8 @@ class Packet: ssrc: int sequence: int timestamp: int - type: int - decrypted_data: bytes + type: int | None + decrypted_data: bytes | None def __init__(self, data: bytes) -> None: self.data: bytes = data diff --git a/discord/voice/packets/rtp.py b/discord/voice/packets/rtp.py index dd9520cf4e..c7417ae255 100644 --- a/discord/voice/packets/rtp.py +++ b/discord/voice/packets/rtp.py @@ -26,14 +26,10 @@ from __future__ import annotations import struct -from collections import namedtuple -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, NamedTuple from .core import OPUS_SILENCE, Packet -if TYPE_CHECKING: - from typing_extensions import Final - MAX_UINT_32 = 0xFFFFFFFF MAX_UINT_16 = 0xFFFF @@ -48,8 +44,8 @@ def decode(data: bytes) -> Packet: class FakePacket(Packet): data = b"" - decrypted_data: bytes = b"" - extension_data: dict = {} + decrypted_data: bytes | None = b"" + extension_data: dict[int, bytes] = {} def __init__( self, @@ -66,8 +62,8 @@ def __bool__(self) -> Literal[False]: class SilencePacket(Packet): - decrypted_data: Final = OPUS_SILENCE - extension_data: Final[dict[int, Any]] = {} + decrypted_data: bytes | None = OPUS_SILENCE + extension_data: dict[int, Any] = {} sequence: int = -1 def __init__(self, ssrc: int, timestamp: int) -> None: @@ -90,7 +86,12 @@ class RTPPacket(Packet): """ _hstruct = struct.Struct(">xxHII") - _ext_header = namedtuple("Extension", "profile length values") + + class _ext_header(NamedTuple): + profile: bytes + length: int + values: tuple[int, ...] | list[int] + _ext_magic = b"\xbe\xde" def __init__(self, data: bytes) -> None: @@ -210,7 +211,7 @@ def __repr__(self) -> str: class RTCPPacket(Packet): _header = struct.Struct(">BBH") _ssrc_fmt = struct.Struct(">I") - type = None + type: int | None = None def __init__(self, data: bytes) -> None: super().__init__(data) @@ -234,18 +235,26 @@ def _parse_low(x: int, bitlen: int = 32) -> float: return x / 2.0**bitlen -def _to_low(x: float, bitlen: int = 32) -> int: - return int(x * 2.0**bitlen) - - class SenderReportPacket(RTCPPacket): _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" - ) + + class _info(NamedTuple): + ntp_ts: float + rtp_ts: int + packet_count: int + octet_count: int + + class _report(NamedTuple): + ssrc: int + perc_loss: int + total_lost: int + last_seq: int + jitter: int + lsr: int + dlsr: int + type = 200 if TYPE_CHECKING: @@ -257,13 +266,12 @@ def __init__(self, data: bytes) -> None: self.ssrc = self._ssrc_fmt.unpack_from(data, 4)[0] self.info = self._read_sender_info(data, 8) - _report = self._report - reports: list[_report] = [] + reports: list[SenderReportPacket._report] = [] for x in range(self.report_count): offset = 28 + 24 * x reports.append(self._read_report(data, offset)) - self.reports: tuple[_report, ...] = tuple(reports) + self.reports: tuple[SenderReportPacket._report, ...] = tuple(reports) self.extension = None if len(data) > 28 + 24 * self.report_count: self.extension = data[28 + 24 * self.report_count :] @@ -282,12 +290,17 @@ def _read_report(self, data: bytes, offset: int) -> _report: class ReceiverReportPacket(RTCPPacket): _report_fmt = struct.Struct(">IB3x4I") _24bit_int_fmt = struct.Struct(">4xI") - _report = namedtuple( - "RReport", "ssrc perc_loss total_loss last_seq jitter lsr dlsr" - ) - type = 201 - reports: tuple[_report, ...] + class _report(NamedTuple): + ssrc: int + perc_loss: int + total_loss: int + last_seq: int + jitter: int + lsr: int + dlsr: int + + type = 201 if TYPE_CHECKING: report_count: int @@ -296,13 +309,12 @@ def __init__(self, data: bytes) -> None: super().__init__(data) self.ssrc: int = self._ssrc_fmt.unpack_from(data, 4)[0] - _report = self._report - reports: list[_report] = [] + reports: list[ReceiverReportPacket._report] = [] for x in range(self.report_count): offset = 8 + 24 * x reports.append(self._read_report(data, offset)) - self.reports = tuple(reports) + self.reports: tuple[ReceiverReportPacket._report, ...] = tuple(reports) self.extension: bytes | None = None if len(data) > 8 + 24 * self.report_count: diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 0f07ed9c22..9f5837970e 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -32,19 +32,14 @@ from operator import itemgetter from typing import TYPE_CHECKING, Any, Literal +import davey +import nacl.secret +from nacl.exceptions import CryptoError + from ..packets.core import OPUS_SILENCE from ..packets.rtp import ReceiverReportPacket, RTCPPacket, decode -from ..utils.dependencies import HAS_DAVEY, HAS_NACL from .router import PacketRouter, SinkEventRouter -if HAS_DAVEY: - import davey - -if HAS_NACL: - import nacl.secret - from nacl.exceptions import CryptoError - - if TYPE_CHECKING: from discord.member import Member from discord.sinks import Sink @@ -77,8 +72,10 @@ def __init__( after: AfterCallback | None = None, start: bool = False, ) -> None: - if after is not None and not callable(after): - raise TypeError( + if after is not None and not callable( + after + ): # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( # pyright: ignore[reportUnreachable] f"expected a callable for the 'after' parameter, got {after.__class__.__name__!r} instead" ) @@ -86,7 +83,7 @@ def __init__( self.client: VoiceClient = client self.after: AfterCallback | None = after - # self.sink._client = client + self.sink.init(client) self.active: bool = False self.error: Exception | None = None @@ -124,10 +121,10 @@ def stop(self) -> None: _log.debug("Reader is not active") return + self.active = False self.client._connection.remove_socket_listener(self.callback) self.speaking_timer.notify() self._stop() - self.active = False def _stop(self) -> None: try: @@ -154,16 +151,13 @@ def _stop(self) -> None: "An error ocurred while calling the after callback on audio reader" ) - """for sink in self.sink.root.walk_children(with_self=True): - try: - sink.cleanup() - except Exception as exc: - _log.exception("Error calling cleanup() for %s", sink, exc_info=exc)""" + try: + self.sink.cleanup() + except Exception as exc: + _log.exception("Error calling cleanup() for %s", self.sink, exc_info=exc) def set_sink(self, sink: Sink) -> Sink: old_sink = self.sink - # old_sink._client = None - # sink._client = self.client self.packet_router.set_sink(sink) self.sink = sink return old_sink @@ -271,53 +265,51 @@ def _make_box(self, secret_key: bytes) -> EncryptionBox: else: return nacl.secret.SecretBox(secret_key) - def decrypt_rtp(self, packet: RTPPacket) -> bytes: state = self.client._connection dave = state.dave_session raw_payload = self._decryptor_rtp(packet) - if dave is not None and dave.ready: uid = state.ssrc_user_map.get(packet.ssrc) if not uid: - # SSRC→user_id mapping not yet populated (race with member_connect). + # SSRC -> user_id mapping not yet populated (race with member_connect). # Try every user ID known to the DAVE session until one decrypts. for candidate_uid in dave.get_user_ids(): try: + int_uid = int(candidate_uid) decrypted_audio = dave.decrypt( - candidate_uid, + int_uid, davey.MediaType.audio, raw_payload, ) - # Successfully decrypted — cache the mapping for next time - self.client._connection.user_ssrc_map[candidate_uid] = packet.ssrc - uid = candidate_uid + # Successfully decrypted - cache the mapping for next time + self.client._connection.user_ssrc_map[int_uid] = packet.ssrc + uid = int_uid raw_payload = decrypted_audio _log.debug( "DAVE: inferred ssrc %s -> user_id %s from decryption", - packet.ssrc, uid, + packet.ssrc, + uid, ) break - except Exception: + except ValueError: continue - - if uid: + elif uid: try: - decrypted_audio = dave.decrypt( + raw_payload = dave.decrypt( uid, davey.MediaType.audio, raw_payload, ) - # dave.decrypt() returns raw Opus — no extension headers. - # The extension was already stripped by _decryptor_rtp above. - packet.decrypted_data = decrypted_audio - except Exception as exc: - packet.decrypted_data = OPUS_SILENCE + except ValueError: + raw_payload = OPUS_SILENCE + + packet.decrypted_data = raw_payload - return packet.decrypted_data + return packet.decrypted_data or b"" def decrypt_rtcp(self, packet: bytes) -> bytes: data = self._decryptor_rtcp(packet) diff --git a/discord/voice/receive/router.py b/discord/voice/receive/router.py index 3dd2a6e35a..959c78876e 100644 --- a/discord/voice/receive/router.py +++ b/discord/voice/receive/router.py @@ -121,33 +121,21 @@ def run(self) -> None: _log.exception("Error in %s loop", self) self.reader.error = exc finally: - self.reader.client.stop_recording() + try: + self.reader.client.stop_recording() + except Exception: + pass self.waiter.clear() def _do_run(self) -> None: - from discord.opus import OpusError - dave_warned = False while not self._end_thread.is_set(): self.waiter.wait() with self._lock: for decoder in self.waiter.items: - try: - data = decoder.pop_data() - if data is not None: - self.sink.write(data, data.source) - except (OpusError, AssertionError) as exc: - # Under DAVE E2E encryption, audio payloads are still - # encrypted at the application layer — Opus decode fails. - # Skip the packet gracefully instead of crashing. - if not dave_warned: - _log.warning( - "Skipping DAVE-encrypted packet (OpusError: %s). " - "Voice reception unavailable until py-cord adds DAVE decryption. " - "See https://github.com/Pycord-Development/pycord/issues/3139", - exc, - ) - dave_warned = True + data = decoder.pop_data() + if data is not None: + self.sink.write(data, data.source) class SinkEventRouter(threading.Thread): diff --git a/discord/voice/state.py b/discord/voice/state.py index 7f659899ad..742825b9d9 100644 --- a/discord/voice/state.py +++ b/discord/voice/state.py @@ -33,10 +33,12 @@ from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any +import davey + from discord import utils from discord.backoff import ExponentialBackoff from discord.errors import ConnectionClosed -from discord.voice.utils.dependencies import DAVE_PROTOCOL_VERSION, HAS_DAVEY +from discord.voice.utils.dependencies import dave_protocol_version from .enums import ConnectionFlowState, OpCodes from .gateway import VoiceWebSocket @@ -57,9 +59,6 @@ _log = logging.getLogger(__name__) _recv_log = logging.getLogger("discord.voice.receiver") -if HAS_DAVEY: - import davey - class SocketReader(threading.Thread): def __init__( @@ -261,7 +260,7 @@ def __init__( self.recording_done_callbacks: list[ tuple[Callable[..., Coroutine[Any, Any, Any]], tuple[Any, ...]] ] = [] - self._dispatch_task_set: set[asyncio.Task] = set() + self._dispatch_task_set: set[asyncio.Task[None]] = set() if not self._connection.self_id: raise RuntimeError("client self ID is not set") @@ -283,7 +282,7 @@ def ssrc_user_map(self) -> dict[int, int]: @property def max_dave_proto_version(self) -> int: - return DAVE_PROTOCOL_VERSION + return dave_protocol_version @property def state(self) -> ConnectionFlowState: @@ -312,8 +311,8 @@ def user(self) -> ClientUser: return self.client.user @property - def channel_id(self) -> int | None: - return self.client.channel is not None and self.client.channel.id + def channel_id(self) -> int: + return self.client.channel.id @property def guild_id(self) -> int: @@ -393,7 +392,7 @@ async def voice_server_update(self, data: RawVoiceServerUpdateEvent) -> None: self.server_id = data.guild_id endpoint = data.endpoint - if self.token is None or endpoint is None: + if endpoint is None: _log.warning( "Awaiting endpoint... This requires waiting. " "If timeout occurred considering raising the timeout and reconnecting." @@ -713,8 +712,10 @@ async def _voice_connect( self, *, self_deaf: bool = False, self_mute: bool = False ) -> None: channel = self.client.channel - await channel.guild.change_voice_state( - channel=channel, self_deaf=self_deaf, self_mute=self_mute + await self.guild.change_voice_state( + channel=channel, + self_deaf=self_deaf, + self_mute=self_mute, # pyright: ignore[reportArgumentType] ) async def _voice_disconnect(self) -> None: @@ -725,7 +726,7 @@ async def _voice_disconnect(self) -> None: ) self.state = ConnectionFlowState.disconnected - await self.client.channel.guild.change_voice_state( + await self.guild.change_voice_state( channel=None ) # pyright: ignore[reportAttributeAccessIssue] self._expecting_disconnect = True @@ -901,9 +902,9 @@ async def _potential_reconnect(self) -> bool: await previous_ws.close() async def _move_to(self, channel: abc.Snowflake) -> None: - await self.client.channel.guild.change_voice_state( - channel=channel - ) # pyright: ignore[reportAttributeAccessIssue] + await self.guild.change_voice_state( + channel=channel # pyright: ignore[reportArgumentType] + ) self.state = ConnectionFlowState.set_guild_voice_state def _update_voice_channel(self, channel_id: int | None) -> None: diff --git a/discord/voice/utils/buffer.py b/discord/voice/utils/buffer.py index f12fb0ae6e..fd59eecd08 100644 --- a/discord/voice/utils/buffer.py +++ b/discord/voice/utils/buffer.py @@ -28,57 +28,16 @@ import heapq import logging import threading -from typing import Protocol, TypeVar from ..packets import Packet from .wrapped import add_wrapped, gap_wrapped -__all__ = ( - "Buffer", - "JitterBuffer", -) +__all__ = ("JitterBuffer",) - -T = TypeVar("T") -PacketT = TypeVar("PacketT", bound=Packet) _log = logging.getLogger(__name__) -class Buffer(Protocol[T]): - def __len__(self) -> int: ... - def push(self, item: T) -> None: ... - def pop(self) -> T | None: ... - def peek(self) -> T | None: ... - def flush(self) -> list[T]: ... - def reset(self) -> None: ... - - -class BaseBuff(Buffer[PacketT]): - def __init__(self) -> None: - 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) -> PacketT | None: - return self._buffer.pop() - - def peek(self) -> PacketT | None: - 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 JitterBuffer(BaseBuff[PacketT]): +class JitterBuffer: _threshold: int = 10000 def __init__( @@ -96,9 +55,11 @@ def __init__( self._prefill: int = prefill self._last_tx_seq: int = -1 self._has_item: threading.Event = threading.Event() - # self._lock: threading.Lock = threading.Lock() self._buffer: list[Packet] = [] + def __len__(self) -> int: + return len(self._buffer) + def _push(self, packet: Packet) -> None: heapq.heappush(self._buffer, packet) diff --git a/discord/voice/utils/dependencies.py b/discord/voice/utils/dependencies.py index 7247485b86..b373c84ffd 100644 --- a/discord/voice/utils/dependencies.py +++ b/discord/voice/utils/dependencies.py @@ -22,52 +22,21 @@ DEALINGS IN THE SOFTWARE. """ -import logging - try: import davey + + _ = davey.DAVE_PROTOCOL_VERSION except ImportError: - HAS_DAVEY = False - DAVE_PROTOCOL_VERSION = 0 + has_davey = False + dave_protocol_version = 0 else: - HAS_DAVEY = True - DAVE_PROTOCOL_VERSION = davey.DAVE_PROTOCOL_VERSION + has_davey = True + dave_protocol_version = davey.DAVE_PROTOCOL_VERSION try: - import nacl.secret - import nacl.utils + import nacl.secret # pyright: ignore[reportUnusedImport] + import nacl.utils # pyright: ignore[reportUnusedImport] except ImportError: - HAS_NACL = False + has_nacl = False else: - HAS_NACL = True - -VOICE_DEPENDENCY_WARNING_EMITTED = False - -_log = logging.getLogger("discord.client") - - -def get_missing_voice_dependencies() -> tuple[str, ...]: - missing: list[str] = [] - if not HAS_NACL: - missing.append("PyNaCl") - if not HAS_DAVEY: - missing.append("davey") - return tuple(missing) - - -def warn_if_voice_dependencies_missing() -> None: - global VOICE_DEPENDENCY_WARNING_EMITTED - if VOICE_DEPENDENCY_WARNING_EMITTED: - return - - missing = get_missing_voice_dependencies() - if not missing: - return - - VOICE_DEPENDENCY_WARNING_EMITTED = True - deps = ", ".join(missing) - _log.warning( - "%s %s not installed, voice will NOT be supported", - deps, - "is" if len(missing) == 1 else "are", - ) + has_nacl = True diff --git a/discord/voice/utils/multidataevent.py b/discord/voice/utils/multidataevent.py index e0079b5a54..b266c2b304 100644 --- a/discord/voice/utils/multidataevent.py +++ b/discord/voice/utils/multidataevent.py @@ -37,7 +37,7 @@ class MultiDataEvent(Generic[T]): with accompanying data object for convenience. """ - def __init__(self): + def __init__(self) -> None: self._items: list[T] = [] self._ready: threading.Event = threading.Event() diff --git a/tests/voice/test_dependency_behavior.py b/tests/voice/test_dependency_behavior.py index 383c3a4097..5f40bea471 100644 --- a/tests/voice/test_dependency_behavior.py +++ b/tests/voice/test_dependency_behavior.py @@ -27,14 +27,15 @@ import pytest import discord -from discord.voice.utils import dependencies as voice_dependencies +import discord.utils +from discord.utils import get_missing_voice_dependencies def test_client_warns_once_when_voice_dependencies_are_missing(caplog): - if not voice_dependencies.get_missing_voice_dependencies(): + if not get_missing_voice_dependencies(): pytest.skip("requires an environment without the voice extra") - voice_dependencies.VOICE_DEPENDENCY_WARNING_EMITTED = False + discord.utils._voice_dep_warning_emitted = False with caplog.at_level(logging.WARNING, logger="discord.client"): discord.Client() @@ -47,15 +48,21 @@ def test_client_warns_once_when_voice_dependencies_are_missing(caplog): ] assert len(warnings) == 1 assert warnings[0].endswith("voice will NOT be supported") - for dependency in voice_dependencies.get_missing_voice_dependencies(): + for dependency in get_missing_voice_dependencies(): assert dependency in warnings[0] -def test_voice_modules_remain_importable_without_voice_dependencies(): - if not voice_dependencies.get_missing_voice_dependencies(): +def test_voice_modules_imporst_without_voice_dependencies(): + if not get_missing_voice_dependencies(): pytest.skip("requires an environment without the voice extra") __import__("discord") - __import__("discord.voice") - __import__("discord.voice.gateway") - __import__("discord.voice.receive.reader") + + with pytest.raises(discord.MissingVoiceDependencies): + __import__("discord.voice") + + with pytest.raises(discord.MissingVoiceDependencies): + __import__("discord.voice.gateway") + + with pytest.raises(discord.MissingVoiceDependencies): + __import__("discord.voice.receive.reader") diff --git a/tests/voice/test_imports.py b/tests/voice/test_imports.py index 364292c9c5..6720ed63aa 100644 --- a/tests/voice/test_imports.py +++ b/tests/voice/test_imports.py @@ -27,14 +27,15 @@ import pytest import discord -from discord.voice.utils import dependencies as voice_dependencies +import discord.utils +from discord.utils import get_missing_voice_dependencies def test_client_does_not_warn_when_voice_dependencies_are_available(caplog): - if voice_dependencies.get_missing_voice_dependencies(): + if get_missing_voice_dependencies(): pytest.skip("requires an environment with the voice extra") - voice_dependencies.VOICE_DEPENDENCY_WARNING_EMITTED = False + discord.utils._voice_dep_warning_emitted = False with caplog.at_level(logging.WARNING, logger="discord.client"): discord.Client() @@ -48,7 +49,7 @@ def test_client_does_not_warn_when_voice_dependencies_are_available(caplog): def test_voice_modules_import_with_voice_extra(): - if voice_dependencies.get_missing_voice_dependencies(): + if get_missing_voice_dependencies(): pytest.skip("requires an environment with the voice extra") __import__("discord.voice") From 58518f089abbf1e30bdb152817976fd0a022415a Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Thu, 19 Mar 2026 15:41:47 +0100 Subject: [PATCH 03/14] fix: TypeVar ParamSpec --- discord/voice/client.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/discord/voice/client.py b/discord/voice/client.py index 372e9027f1..dc91957aa8 100644 --- a/discord/voice/client.py +++ b/discord/voice/client.py @@ -52,6 +52,8 @@ from .state import VoiceConnectionState if TYPE_CHECKING: + from typing import TypeVar + from typing_extensions import ParamSpec from discord import abc @@ -71,6 +73,7 @@ from .receive.reader import AfterCallback P = ParamSpec("P") + T = TypeVar("T") _log = logging.getLogger(__name__) @@ -255,10 +258,10 @@ async def _recv_hook(self, ws: VoiceWebSocket, msg: dict[str, Any]) -> None: async def _run_event( self, - coro: Callable[..., Coroutine[Any, Any, None]], + coro: Callable[..., Coroutine[P, None]], event_name: str, - *args: Any, - **kwargs: Any, + *args: P.args, + **kwargs: P.kwargs, ) -> None: try: await coro(*args, **kwargs) @@ -269,11 +272,11 @@ async def _run_event( def _schedule_event( self, - coro: Callable[..., Coroutine[Any, Any, None]], + coro: Callable[..., Coroutine[Any, Any, T]], event_name: str, *args: Any, **kwargs: Any, - ) -> asyncio.Task[None]: + ) -> asyncio.Task[T]: wrapped = self._run_event(coro, event_name, *args, **kwargs) return self.client.loop.create_task( wrapped, name=f"voice-receiver-event-dispatch: {event_name}" From fee38b7c8f6b369148dadc95ef0a971227a07273 Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Thu, 19 Mar 2026 15:51:24 +0100 Subject: [PATCH 04/14] chore: Move BytesIO import to the top --- discord/sinks/wave.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/sinks/wave.py b/discord/sinks/wave.py index 5c4fa18f8d..cb87b0deee 100644 --- a/discord/sinks/wave.py +++ b/discord/sinks/wave.py @@ -23,6 +23,7 @@ """ import wave +from io import BytesIO from ..opus import Decoder as OpusDecoder from .core import Filters, Sink, default_filters @@ -59,7 +60,6 @@ def format_audio(self, audio): raise WaveSinkError( "Audio may only be formatted after recording is finished." ) - from io import BytesIO audio.file.seek(0) pcm_data = audio.file.read() From 6caf18f639c2eee5b4ff4b97bdceb04527bc3228 Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Thu, 19 Mar 2026 16:01:57 +0100 Subject: [PATCH 05/14] fix: Better exception catch --- discord/voice/receive/router.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/voice/receive/router.py b/discord/voice/receive/router.py index 959c78876e..f73c2e51b0 100644 --- a/discord/voice/receive/router.py +++ b/discord/voice/receive/router.py @@ -34,6 +34,7 @@ from discord.opus import PacketDecoder +from ...sinks.errors import RecordingException from ..utils.multidataevent import MultiDataEvent if TYPE_CHECKING: @@ -123,7 +124,7 @@ def run(self) -> None: finally: try: self.reader.client.stop_recording() - except Exception: + except RecordingException: pass self.waiter.clear() From 203a69142e77303e075804f2d6ebe4fdfd7be6b0 Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Thu, 19 Mar 2026 16:30:09 +0100 Subject: [PATCH 06/14] fix: Stage channels don't have DAVE --- discord/voice/receive/reader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 9f5837970e..af3c08bab2 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -308,6 +308,8 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: raw_payload = OPUS_SILENCE packet.decrypted_data = raw_payload + else: # e.g., stage channels + packet.decrypted_data = raw_payload return packet.decrypted_data or b"" From fd339e6f97b7ddba07926d868c61891c5b7635e4 Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Thu, 19 Mar 2026 16:30:56 +0100 Subject: [PATCH 07/14] fix: Problem --- discord/voice/receive/reader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index af3c08bab2..0163858826 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -130,6 +130,8 @@ def _stop(self) -> None: try: if self.packet_router.is_alive(): self.packet_router.stop() + if threading.current_thread() is not self.packet_router: + self.packet_router.join(timeout=5) except Exception as exc: self.error = exc _log.exception("An error ocurred while stopping packet router.") From 884cdc944ce760a14c0ee90d8d7b1175b3be03c9 Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Mon, 23 Mar 2026 17:34:35 +0100 Subject: [PATCH 08/14] chore: Better logging and comments --- discord/voice/receive/reader.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 0163858826..36b14807e6 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -279,6 +279,8 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: if not uid: # SSRC -> user_id mapping not yet populated (race with member_connect). # Try every user ID known to the DAVE session until one decrypts. + # This is ugly but I didn't manage to get it to work otherwise. If you have a better implementation, + # please open a PR. for candidate_uid in dave.get_user_ids(): try: int_uid = int(candidate_uid) @@ -299,14 +301,20 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: break except ValueError: continue - elif uid: + else: try: raw_payload = dave.decrypt( uid, davey.MediaType.audio, raw_payload, ) - except ValueError: + except ValueError as e: + # UnencryptedWhenPassthroughDisabled here is actually misleading, we can't passthrough, + # it gives a corrupted stream. + _log.debug( + "DAVE: Decryption failed, falling back to OPUS_SILENCE", + exc_info=True, + ) raw_payload = OPUS_SILENCE packet.decrypted_data = raw_payload From 63b6d6abf2ce027e8865b9030235ceb15467d52c Mon Sep 17 00:00:00 2001 From: Paillat-dev Date: Mon, 23 Mar 2026 17:38:48 +0100 Subject: [PATCH 09/14] chore: Minimize WaveSink changes --- discord/sinks/wave.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/discord/sinks/wave.py b/discord/sinks/wave.py index cb87b0deee..b9b53bb34b 100644 --- a/discord/sinks/wave.py +++ b/discord/sinks/wave.py @@ -64,13 +64,12 @@ def format_audio(self, audio): audio.file.seek(0) pcm_data = audio.file.read() - output = BytesIO() - with wave.open(output, "wb") as f: - f.setnchannels(OpusDecoder.CHANNELS) - f.setsampwidth(OpusDecoder.SAMPLE_SIZE // OpusDecoder.CHANNELS) - f.setframerate(OpusDecoder.SAMPLING_RATE) + data = BytesIO() + with wave.open(data, "wb") as f: + f.setnchannels(self.vc.decoder.CHANNELS) + f.setsampwidth(self.vc.decoder.SAMPLE_SIZE // self.vc.decoder.CHANNELS) + f.setframerate(self.vc.decoder.SAMPLING_RATE) f.writeframes(pcm_data) - - output.seek(0) - audio.file = output + data.seek(0) + audio.file = data audio.on_format(self.encoding) From e0130cbab2a993cb7e2c4a5926d39a890af2137f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:51:26 +0000 Subject: [PATCH 10/14] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/voice/receive/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 36b14807e6..5248d5b69a 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -308,7 +308,7 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: davey.MediaType.audio, raw_payload, ) - except ValueError as e: + except ValueError: # UnencryptedWhenPassthroughDisabled here is actually misleading, we can't passthrough, # it gives a corrupted stream. _log.debug( From 923cd8a04c77b0990f17b003a5a19ff8da6bd28e Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 24 Mar 2026 11:08:19 +0100 Subject: [PATCH 11/14] Update discord/voice/client.py Signed-off-by: Paillat --- discord/voice/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/discord/voice/client.py b/discord/voice/client.py index 5c833bbddd..f4f3f22820 100644 --- a/discord/voice/client.py +++ b/discord/voice/client.py @@ -114,8 +114,6 @@ def __init__( client: Client, channel: abc.Connectable, ) -> None: - if missing := get_missing_voice_dependencies(): - raise MissingVoiceDependencies(missing) super().__init__(client, channel) state = client._connection From ec0864795b5975d07866767c140d682a41a9662a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=82=89=E3=82=89=E3=82=93?= <81461409+orarange@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:15:30 +0900 Subject: [PATCH 12/14] fix(voice): fall back to OPUS_SILENCE when DAVE brute-force uid lookup fails (#3179) --- discord/voice/receive/reader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 5248d5b69a..afc38ee9e8 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -301,6 +301,8 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: break except ValueError: continue + else: + raw_payload = OPUS_SILENCE else: try: raw_payload = dave.decrypt( From 28f404b24e1c6d34c5d621c12b88de36b4f2b8ca Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 24 Mar 2026 17:59:27 +0100 Subject: [PATCH 13/14] fix: Change `HAS_NACL` to `has_nacl` in voice client imports --- discord/voice/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/voice/client.py b/discord/voice/client.py index f4f3f22820..7ff9f6298d 100644 --- a/discord/voice/client.py +++ b/discord/voice/client.py @@ -49,9 +49,9 @@ from .enums import OpCodes from .receive import AudioReader from .state import VoiceConnectionState -from .utils.dependencies import HAS_DAVEY, HAS_NACL +from .utils.dependencies import has_nacl -if HAS_NACL: +if has_nacl: import nacl.secret import nacl.utils From 757c5cef121db160de64670e5b5270253d2e4535 Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 24 Mar 2026 18:02:39 +0100 Subject: [PATCH 14/14] docs: CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4209b0dbb0..f1394c8aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ These changes are available on the `master` branch, but have not yet been releas ### Changed +- Added support for Discord DAVE (Audio & Video E2EE) for voice-receive related features + and refactored the voice-reception system. + ([#3159](https://github.com/Pycord-Development/pycord/pull/3159)) + ### Fixed - Fixed a `TypeError` when using `Label.set_select` and not providing `default_values`.