Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5a95f98
fix(voice): implement DAVE E2E decryption for voice reception
vito1317 Mar 17, 2026
c9e40d9
feat: Fixes and typesafe discord/voice
Paillat-dev Mar 19, 2026
58518f0
fix: TypeVar ParamSpec
Paillat-dev Mar 19, 2026
fee38b7
chore: Move BytesIO import to the top
Paillat-dev Mar 19, 2026
6caf18f
fix: Better exception catch
Paillat-dev Mar 19, 2026
203a691
fix: Stage channels don't have DAVE
Paillat-dev Mar 19, 2026
fd339e6
fix: Problem
Paillat-dev Mar 19, 2026
884cdc9
chore: Better logging and comments
Paillat-dev Mar 23, 2026
63b6d6a
chore: Minimize WaveSink changes
Paillat-dev Mar 23, 2026
9bb2930
Merge branch 'master' into fix/voice-rec-2
Paillat-dev Mar 23, 2026
e0130cb
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 23, 2026
ce9fbdc
Merge branch 'master' into fix/voice-rec-2
Paillat-dev Mar 24, 2026
923cd8a
Update discord/voice/client.py
Paillat-dev Mar 24, 2026
ec08647
fix(voice): fall back to OPUS_SILENCE when DAVE brute-force uid looku…
orarange Mar 24, 2026
28f404b
fix: Change `HAS_NACL` to `has_nacl` in voice client imports
Paillat-dev Mar 24, 2026
9bf8826
Merge branch 'master' into fix/voice-rec-2
Paillat-dev Mar 24, 2026
757c5ce
docs: CHANGELOG.md
Paillat-dev Mar 24, 2026
7889ca3
Merge branch 'master' into fix/voice-rec-2
Paillat-dev Mar 27, 2026
8dff186
fix: Actually mb that doesn't exist
Paillat-dev Apr 7, 2026
7b2cbea
fix: use client._ssrc_to_id for ssrc_user_map retrieval
Paillat-dev Apr 7, 2026
c48341e
fix: Use PLC instead of silence when buggy packets arrive
Paillat-dev Apr 7, 2026
bdba6a4
feat: Gracefully handle OpusError s
Paillat-dev Apr 7, 2026
1a4e767
fix: Set passthrough 120 bc that's what davey usage docs suggest
Paillat-dev Apr 7, 2026
dda3098
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 7, 2026
374e7f2
fix: Undo VoiceClient Deprecations
Paillat-dev Apr 7, 2026
42cd1b4
Merge branch 'master' into fix/voice-rec-2
Paillat-dev Apr 7, 2026
a61fdc9
fix: Bunch of small fixes and misc stuff
Paillat-dev May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions discord/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions discord/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"ApplicationCommandError",
"CheckFailure",
"ApplicationCommandInvokeError",
"MissingVoiceDependencies",
)


Expand Down Expand Up @@ -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]".'
)
5 changes: 0 additions & 5 deletions discord/opus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 28 additions & 3 deletions discord/sinks/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion discord/sinks/m4a.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
2 changes: 1 addition & 1 deletion discord/sinks/mka.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
2 changes: 1 addition & 1 deletion discord/sinks/mkv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
2 changes: 1 addition & 1 deletion discord/sinks/mp3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
2 changes: 1 addition & 1 deletion discord/sinks/mp4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
2 changes: 1 addition & 1 deletion discord/sinks/ogg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
21 changes: 14 additions & 7 deletions discord/sinks/wave.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"""

import wave
from io import BytesIO

from ..opus import Decoder as OpusDecoder
from .core import Filters, Sink, default_filters
from .errors import WaveSinkError

Expand Down Expand Up @@ -54,16 +56,21 @@ 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

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:
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See if similar is required by other sinks or if it's even needed here idk if we might actually be better of undoing this. Test the other sinks

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)
49 changes: 45 additions & 4 deletions discord/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
Iterator,
Literal,
Mapping,
ParamSpec,
Protocol,
Sequence,
TypeVar,
Expand Down Expand Up @@ -93,7 +94,6 @@
else:
HAS_MSGSPEC = True


__all__ = (
"parse_time",
"warn_deprecated",
Expand Down Expand Up @@ -140,7 +140,6 @@
)
EMOJIS_MAP = {}


UNICODE_EMOJIS = set(EMOJIS_MAP.values())


Expand Down Expand Up @@ -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]]


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
6 changes: 6 additions & 0 deletions discord/voice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Loading
Loading