Skip to content

🤖 Add voice support (send + receive + DAVE/MLS E2EE)#29

Open
Neppkun wants to merge 24 commits into
AlexFlipnote:masterfrom
Neppkun:claude/goofy-germain-5f1bf6
Open

🤖 Add voice support (send + receive + DAVE/MLS E2EE)#29
Neppkun wants to merge 24 commits into
AlexFlipnote:masterfrom
Neppkun:claude/goofy-germain-5f1bf6

Conversation

@Neppkun

@Neppkun Neppkun commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds voice media support to discord.http — previously the data-plane models (VoiceState/PartialVoiceState) and the VOICE_STATE_UPDATE parser existed, but there was no op-4 sender, no voice WebSocket, no UDP/RTP/encryption/Opus/player, and no connect() entry point (the author had deliberately deferred it at gateway/parser.py:1339).

This PR implements joining/leaving/moving voice channels, sending audio, receiving audio, and DAVE/MLS end-to-end encryption, staying faithful to discord.http's design: pure-asyncio (no threads), stdlib-first, minimal dependencies.

What changed

Phase 0 — refactor

  • Moved VoiceState/PartialVoiceState into channel.py and deleted voice.py, freeing the voice/ name for the new media subpackage. Fixed all importers (gateway/parser.py, gateway/cache.py, guild.py, client.py, __init__.py).

Phase 1 — main-gateway plumbing

  • Shard.change_voice_state(...) (op 4), mirroring change_presence.
  • Implemented the VOICE_SERVER_UPDATE parser placeholder + routing of voice state/server updates to the owning voice client (bot-only, scheduled as tasks so the receive loop never blocks).
  • Voice-client registry on Client (_voice_clients, _get/_add/_remove_voice_client, public voice_clients).
  • PartialChannel.connect() (type-guarded — allows unknown/voice/stage-voice).

voice/ subpackage (~4,000 LOC, 14 modules)

  • Voice WebSocket (v8) with an asyncio heartbeat task, IDENTIFY/SELECT_PROTOCOL/SPEAKING/RESUME, and binary DAVE frames.
  • UDP datagram endpoint + IP discovery; RTP framing; RTCP drop.
  • Transport encryption via aead_aes256_gcm_rtpsize (AES-GCM).
  • ctypes libopus binding (optional runtime), Ogg/Opus parser.
  • Async AudioSource/AudioPlayer, FFmpeg Opus passthrough + PCM sources, PCMVolumeTransformer, auto-resolving play().
  • Receive path: per-ssrc decode + per-user AudioSinks (WaveSink, CallbackSink).
  • DAVE/MLS E2EE via the optional davey package, import-guarded.

Phase 7 — robustness

  • ExponentialBackoff helper; voice-close-code handling (4014/4022 leave, 4015 resume, 4021 stop, else backoff); opt-in Client(resume_voice=...) persistence; default insta-leave on shard reset; latency surface.

Phase 8 — exports / packaging / example

  • Public exports (voice/__init__.py + top-level re-export), examples/voice_example.py, and pyproject.toml updates: added the discord_http.voice package, the voice = ["davey>=0.1.0"] optional extra, and the voice package to the ruff/pyright include lists.

Why it was implemented this way

  • No new base dependencies. discord.http already ships cryptography, whose AESGCM implements Discord's first-class aead_aes256_gcm_rtpsize mode — so we avoid PyNaCl entirely (discord.py's voice dep). Opus is a stdlib ctypes binding to a user-installed libopus (no bundled binaries, no pip dep); FFmpeg is an external executable, not a Python dep.
  • davey is the one optional Python dep, gated behind discord.http[voice]. DAVE/MLS cannot be implemented without an MLS library, but default installs add nothing and still work with transport encryption. A clear, actionable error is raised only if Discord forces a DAVE version and davey is absent.
  • Pure asyncio, no threads. AudioSource.read() is async; FFmpeg runs via asyncio.create_subprocess_exec; the player paces with drift-corrected asyncio.sleep; UDP uses a DatagramProtocol; the heartbeat is an asyncio task. PCMVolumeTransformer uses stdlib array since audioop is gone in 3.13.
  • connect() lives on PartialChannel (type-guarded, deny only when the type is known and not voice-capable) so VoiceChannel/StageChannel inherit it.
  • Reboot/kill default = insta-leave, with opt-in resume_voice persistence (intent-gated) for revival on shard READY/RESUMED.

Verification

  • ruff check and pyright are clean across the package (0 errors).
  • Test suite: 117 passing (106 existing + 11 new) — offline round-trip tests for the AES-GCM encryptor and the Ogg/Opus parser (incl. an FFmpeg-backed test).
  • Full public-API import verified with libopus, FFmpeg, and davey all absent — graceful degradation confirmed.
  • Live checks (real send/receive, DAVE handshake, reconnect) require a running bot + voice channel and were not exercised in CI.

Notes

  • Removed an invalid ruff selector (PLW0717) from pyproject.toml — it is not a real rule in current ruff and was aborting every lint run.
  • The local VOICE_DESIGN_DOC.md (untracked planning doc) was updated to mark the receive path and DAVE/MLS as in-scope, but is intentionally not included in this PR since it isn't tracked upstream.

⚠️ Disclaimer: This PR was generated using Claude Opus 4.8 High.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Full voice subsystem: join/leave/move, playback (ffmpeg/Opus), receive/record, sinks (callback/WAV), speaking controls, and voice client lifecycle.
    • Voice transport & security: voice websocket + UDP, RTP encryption, optional end-to-end DAVE support.
    • Client API: per-guild voice client registry and configurable reconnect attempts.
  • Resilience
    • Improved reconnect/session handling with exponential backoff and robust teardown.
  • Examples
    • Added a voice demo script (connect/play/listen/move).
  • Tests
    • New unit tests for encryptor and Ogg parsing.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an end-to-end Discord voice subsystem: voice-state models and connect API, client voice registry, gateway/shard parsing and routing, voice websocket and UDP transport, RTP AES‑GCM encryptor, libopus bindings and Ogg parser, playback/receive pipeline, optional DAVE E2EE, tests, and an example.

Changes

Discord voice implementation

Layer / File(s) Summary
Voice API contracts, errors, enums, packaging
discord_http/errors.py, discord_http/voice/enums.py, discord_http/voice/__init__.py, pyproject.toml
Exports and imports adjusted; OpusError/OpusNotLoaded added; voice package re-exports and packaging/lint/type includes updated.
Channel voice entrypoints and models
discord_http/channel.py
PartialChannel.connect(...) added; PartialVoiceState and VoiceState moved/implemented in channel.py with channel property, fetch() and edit() methods.
Client registry and gateway wiring
discord_http/client.py, discord_http/gateway/parser.py, discord_http/gateway/shard.py, discord_http/gateway/client.py
Client gains voice_reconnect_attempts, _voice_clients registry and accessors; parser adds voice_server_update; shard routes voice events and adds change_voice_state; GatewayClient.close cleans up active voice clients.
Exponential backoff utility
discord_http/utils.py
ExponentialBackoff added with base, max_delay, jitter, delay(), and reset().
Voice websocket protocol
discord_http/voice/socket.py
VoiceSocket implements v8 voice websocket receive loop, JSON/binary dispatch, heartbeat and latency tracking, seq ack handling, and outbound control helpers; VoiceCloseCode added.
UDP protocol and IP discovery
discord_http/voice/gateway_udp.py
VoiceUDPProtocol handles datagrams, IP discovery response parsing, RTCP filtering, and create_udp() helper.
AES‑GCM RTP encryption & tests
discord_http/voice/encryptor.py, tests/test_voice_encryptor.py
Encryptor implements AES-256-GCM rtpsize encryption/decryption using RTP header as AAD; tests validate mode, roundtrips, header/extension preservation, and nonce progression.
Voice connection state machine & DAVE
discord_http/voice/connection.py, discord_http/voice/dave.py
VoiceConnection orchestrates gateway handshake, voice socket/UDP lifecycle, encryptor/DAVE initialization, reconnection/backoff/resume, and disconnect/transport shutdown; DaveManager handles optional DAVE/MLS ops.
Opus bindings, Ogg parsing & tests
discord_http/voice/opus.py, discord_http/voice/oggparse.py, tests/test_voice_oggparse.py
Lazy ctypes libopus loader with Encoder/Decoder and constants; Ogg page/stream parser and tests covering lacing, continuation, resync, and ffmpeg-generated streams.
Audio sources and playback
discord_http/voice/player.py
AudioSource abstraction, PCM and volume transformer, ffmpeg-backed PCMAudio/Opus sources, _resolve_source(), and AudioPlayer paced playback with pause/resume, hot-swap, silence padding, and after-callback.
Inbound audio reception and sinks
discord_http/voice/receiver.py, discord_http/voice/sinks.py
VoiceReceiver decrypts/decodes inbound RTP, maps SSRC→user, performs simple concealment, and dispatches VoiceData to AudioSink implementations (CallbackSink, WaveSink).
Voice client facade
discord_http/voice/client.py
VoiceClient wraps connection lifecycle, exposes connect/disconnect/move, speak/send_audio_packet, playback controls (play/pause/resume/stop), and receiving controls (listen/stop_listening).
Examples & tests
examples/voice_example.py, tests/*
Voice example demonstrating join/pause/leave/move/play/listen flows; unit tests added for encryptor and Ogg parser.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Shard
  participant VoiceConnection
  participant VoiceSocket
  participant VoiceUDPProtocol
  Client->>Shard: change_voice_state(guild_id, channel_id)
  Shard-->>VoiceConnection: voice_state_update, voice_server_update
  VoiceConnection->>VoiceSocket: connect()/IDENTIFY or RESUME
  VoiceSocket-->>VoiceConnection: ready, session_description
  VoiceConnection->>VoiceUDPProtocol: create_udp() and discover_ip(ssrc)
  VoiceConnection->>VoiceConnection: build Encryptor(secret_key) / init DAVE
  VoiceUDPProtocol->>VoiceConnection: incoming RTP -> VoiceReceiver.unpack()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 In burrows deep the codecs hum,

Through webs and UDP the packets run,
Opus whispers, nonces leap,
DAVE keeps secrets snug and deep,
A rabbit cheers — the voice is done!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AlexFlipnote

Copy link
Copy Markdown
Owner
image

@Neppkun

Neppkun commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

my bad

@Neppkun

Neppkun commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

coderabbit review limit is insane

@codex review

@tideter

tideter commented Jun 2, 2026

Copy link
Copy Markdown
qqqqq

@AlexFlipnote AlexFlipnote left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please do test the PR as well and show it working before I take another look, these are the very obvious things I found.

Comment thread discord_http/gateway/parser.py Outdated
bot_user = self.bot.application.bot if self.bot.application else None
if bot_user is not None and int(data["user_id"]) == bot_user.id:
vc = self.bot._get_voice_client(int(data["guild_id"]))
if vc is not None:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is a very odd spot to just randomly create asyncio tasks, as none of the parser methods are even use it. Why even?

Comment thread discord_http/gateway/shard.py Outdated
)

self._revive_voice_clients()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This might cause issues because the websocket for intents and voice are two seperate scenarios. I do not believe the voice is affected by a kill and resume inside of the intent socket

Comment thread discord_http/voice/__init__.py Outdated
from .receiver import *
from .sinks import *

__all__ = (

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Don't use __all__ inside of __init__, move it to each individual files for a cleaner approach to the main file instead, just like all the others.

Comment thread discord_http/voice/client.py Outdated
await self.connection.close_transport()
self.client._remove_voice_client(self.guild_id)

async def move_to(self, channel: "PartialChannel") -> None:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

move_to should accept both a partialchannel or int, for more convinience and less forcing a user to create a partialchannel for one task

Comment thread discord_http/voice/client.py Outdated

def play(
self,
audio: Any, # noqa: ANN401

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Don't use Any.. be more strict

Comment thread discord_http/voice/opus.py Outdated
}


class OpusError(Exception):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

These I would rather move to the universal errors.py file in the root

Comment thread discord_http/voice/opus.py Outdated
OpusError
If an explicit ``name`` was given but the library could not be loaded.
"""
global _lib, _loaded # noqa: PLW0603

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This looks.. a bit sketchy.. please figure out a different way to avoid using global, even you ignoring says a lot



class VoiceCloseCode(IntEnum):
""" The voice gateway websocket close codes that govern reconnect behaviour. """

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Use BaseEnum instead

Comment thread discord_http/voice/socket.py Outdated
The shared HTTP session, or a freshly created one owned by this socket.
"""
try:
session = self.connection.voice_client.client.state.http.session

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please.. find a cleaner approach to this, this is just.. why

Comment thread examples/voice_example.py


@client.command()
async def join(ctx: Context):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Make it instead, where you target a voice channel instead of replying on hard-coded ints

Neppkun added a commit to Neppkun/discord.http that referenced this pull request Jun 2, 2026
Implements AlexFlipnote's review comments on AlexFlipnote#29:

- enums: rename VoiceOp -> VoiceOpType to match the library's *Type naming
- errors: move OpusError/OpusNotLoaded into the root errors.py
- opus: drop the module-level `global` loader state in favour of an
  encapsulated loader object
- socket: VoiceCloseCode now subclasses BaseEnum; reuse the bot's shared
  aiohttp session instead of the deep try/except + self-owned session
- gateway_udp: narrow the transport with isinstance instead of a
  type: ignore, keeping full type safety
- connection: parse the voice endpoint with the library's URL helper,
  import has_dave/DaveManager at module level, and read the reconnect
  attempt cap from the new Client.voice_reconnect_attempts variable
- dave: early-return guard in set_passthrough_mode and a match/case in
  handle_binary
- client (voice): move_to accepts a channel or an int id; play uses a
  strict AudioSourceInput type alias instead of Any
- gateway: voice clients are driven from shard special handlers, not from
  asyncio tasks created inside the parser; the parser methods are pure again
- gateway/client: decouple voice from the intent socket lifecycle (drop the
  READY/RESUMED revive + reset teardown and the resume_voice plumbing), since
  the voice websocket manages its own reconnect
- voice package: drop __all__ from __init__ and rely on each module's __all__
- example: target the caller's current voice channel instead of hard-coded ids
- logging: use f-strings throughout the voice package to match the codebase
- tests: port the ogg parser tests to unittest (the project's runner)

All checks green: ruff clean, pyright 0 errors, 106 unittest tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Neppkun

Neppkun commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Pushed d0350db addressing every comment:

Comment Fix
Parser creating asyncio tasks Parser methods are pure again; voice clients are now driven from shard special handlers (_parse_voice_state_update / _parse_voice_server_update), like the guild handlers
Voice shouldn't revive on the intent socket Removed the READY/RESUMED revive + reset teardown coupling entirely; voice manages its own reconnect via its own ws close codes. Dropped the resume_voice plumbing
__all__ in voice/__init__.py Removed; relies on each module's own __all__, matching gateway/__init__.py
move_to should accept a channel or int Now move_to(channel: PartialChannel | int), resolving ints via get_partial_channel
play(audio: Any) too loose Now uses a strict AudioSourceInput type alias
MAX_RECONNECT_ATTEMPTS hard-coded Now Client(voice_reconnect_attempts=5)
Manual endpoint string slicing Uses the library's URL helper (URL(f"wss://{endpoint}").host)
from .dave import has_dave per call Moved to a module-level import
set_passthrough_mode nesting if self._session is None: return early-return
if/elif spam in handle_binary Converted to match/case
VoiceOp naming Renamed to VoiceOpType
transport = transport # type: ignore Resolved with an isinstance narrow — no ignore
OpusError/OpusNotLoaded Moved to the root errors.py
global _lib loader Replaced with an encapsulated loader object
VoiceCloseCode(IntEnum) Now BaseEnum
_get_session deep try/except Reuses the bot's shared aiohttp session
Example hard-coded ids Now targets the caller's current voice channel

Also converted the whole voice package's logging to f-strings to match the codebase, and ported the ogg-parser tests to unittest (the project's runner — they were importing pytest).

Tested & working (make lint / pyright / make test):

ruff:    All checks passed!
pyright: 0 errors, 0 warnings, 0 informations
tests:   Ran 106 tests ... OK

Functional smoke checks (run via uv run python):

1) exports OK: <enum 'VoiceOpType'> | OpusError from errors: discord_http.errors
2) VoiceCloseCode(BaseEnum) int-compare OK
3) opus loader (no global) OK, is_loaded -> False
4) URL endpoint parse OK
5) Encryptor round-trip OK
6) Client voice_reconnect_attempts OK; resume_voice: rejected
7) DAVE graceful no-op without davey
a) VoiceClient.on_voice_* are synchronous: OK
b) parser voice methods are pure: OK
c) shard special-handler wiring + decoupling: OK
d) VOICE_SERVER_UPDATE -> endpoint parsed via URL helper: OK

Disclaimer: these changes were generated using Opus 4.8 High.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
discord_http/client.py (1)

133-156: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject negative voice_reconnect_attempts values.

discord_http/voice/connection.py:274-322 uses this as a loop bound for reconnects. Negative values currently skip the loop entirely and tear the voice client down immediately, which is a surprising failure mode for a public config knob.

Suggested fix
         debug_events: bool = False,
         voice_reconnect_attempts: int = 5
     ):
+        if voice_reconnect_attempts < 0:
+            raise ValueError("voice_reconnect_attempts must be >= 0")
+
         if application_id is not None:
             _log.warning(
                 "application_id parameter is no longer needed, it will be fetched automatically."
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@discord_http/client.py` around lines 133 - 156, Validate the
voice_reconnect_attempts parameter in the constructor and reject negative
values: in the method that sets voice_reconnect_attempts (look for the
constructor where the voice_reconnect_attempts parameter is accepted and
assigned to self.voice_reconnect_attempts), add a check like if
voice_reconnect_attempts < 0: raise ValueError("voice_reconnect_attempts must be
non-negative") so callers get a clear error instead of silently skipping the
reconnect loop used in discord_http/voice/connection.py.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@discord_http/channel.py`:
- Around line 541-548: The new VoiceClient is registered with
client._add_voice_client before awaiting vc.connect, but if vc.connect raises or
times out the stale entry remains; wrap the await vc.connect(...) in a
try/except (or try/finally) and on any exception remove the registration (call
the matching removal API, e.g. client._remove_voice_client(self.guild_id) or
client._remove_voice_client(self.guild_id, vc) depending on the existing
signature) before re-raising the exception so failed connect attempts do not
poison the voice-client registry; reference VoiceClient,
client._add_voice_client, vc.connect and the removal helper when making the
change.
- Around line 2864-2873: The returned VoiceState is being built using the stale
self.channel_id instead of the authoritative channel_id from the fresh payload
(r.response); update the channel resolution so you read channel_id from
r.response (e.g., channel_id = r.response.get("channel_id") or equivalent), then
call self._state.cache.get_channel using the guild (resolved from self.guild_id)
and that fresh channel_id to produce the channel variable, and pass that channel
into the VoiceState constructor (VoiceState(state=self._state, data=r.response,
guild=guild, channel=channel)).

In `@discord_http/voice/connection.py`:
- Around line 260-269: The resume path never marks the connection as connected
because _resume() clears self._connected_event but on_resumed() only logs;
update the resume completion to set the connected state so waiters unfreeze:
either call self._connected_event.set() (and any internal connected flag) inside
on_resumed() when the RESUME handshake is confirmed, or set the event
immediately after a successful await self.socket.connect(resume=True) in
_resume(); apply the same change to the other resume block (the one around lines
437-446) so is_connected() becomes true after a successful resume.
- Around line 174-179: The reconnect backoff is being reset on every retry
because connect() unconditionally calls self._backoff.reset(), so
_full_reconnect()'s intended exponential backoff never grows; change the logic
so that self._backoff.reset() is only called when starting a fresh reconnect
sequence or after a successful connection, not on each connect() attempt — e.g.,
remove/reset call from connect() and instead call self._backoff.reset() at the
start of _full_reconnect() (or add a parameter to connect() like
reset_backoff=False and only reset when True); update any similar retry loops
referenced (the block around lines 297-315) to use the same pattern.
- Around line 412-417: The code sets self.dave_protocol_version and
reinitializes when dave_version > 0 but does not clear an existing DAVE session
when the new SESSION_DESCRIPTION reports no DAVE; update the SESSION_DESCRIPTION
handling (where self.dave_protocol_version is set and reinit_dave_session is
called) to explicitly tear down any previous DAVE state when dave_version == 0
by clearing self.dave_session and any related keys/metadata (and invoking the
existing DAVE cleanup path if one exists) so can_encrypt() and the Opus wrappers
cannot use stale E2EE state on a transport-only connection.

In `@discord_http/voice/gateway_udp.py`:
- Around line 89-97: The RTCP filtering is wrong because masking data[1] with
0x7F changes RTCP payload types 200–204 to 72–76 so they are not dropped; update
the check in gateway_udp.py to inspect the raw second byte (use payload_type =
data[1] instead of data[1] & 0x7F) before the 200–204 range check and keep the
existing drop behavior so receiver = self.connection.voice_client._receiver only
gets RTP packets; adjust any related logic using payload_type accordingly.

In `@discord_http/voice/player.py`:
- Around line 335-345: The read method in player.py currently returns a trailing
partial PCM frame on asyncio.IncompleteReadError, which can be treated as a full
frame downstream; change the handler in async def read(self) so it never emits
short frames: on IncompleteReadError always return b"" (EOF) rather than
bytes(exc.partial). Locate the read method and the FRAME_SIZE reference and
replace the partial-return behavior with a direct EOF return; ensure _spawn and
_stdout logic remains unchanged.
- Around line 617-637: is_playing() and is_paused() rely on _end and _resumed
but _end is never set when the playback task _task naturally completes, so
finished players still report as active; fix by ensuring the playback task sets
the _end event (and clears/sets _resumed appropriately) when it finishes or
errors: attach a done callback to the _task created where playback starts (refer
to the _task variable and the is_playing/is_paused methods) that calls
self._end.set() and ensures self._resumed.clear() (or the correct resumed state)
so state queries reflect a finished player.
- Around line 321-333: The FFmpeg option parsing in the constructor uses plain
str.split which breaks quoted arguments; replace uses of `.split()` for both
before_options and options with `shlex.split()` to perform shell-aware
tokenization (i.e., compute before_args = shlex.split(before_options) if
before_options is not None else None and extend args with shlex.split(options)
when options is not None) in the class's __init__ so the arguments passed to
super().__init__ are correctly tokenized for create_subprocess_exec.

In `@discord_http/voice/receiver.py`:
- Around line 67-77: The start method currently just assigns self.sink and can
be called multiple times, leaking the previous sink/decoder/sequence state;
before overwriting self.sink in start(self, sink: "AudioSink") call the
receiver's stop() or cleanup() routine (the same cleanup() used to tear down
decoders and sequence state) to stop the previous listening session and free
resources, then set self.sink and reinitialize any decoder/sequence state needed
for the new session (ensure any references to the old decoder or sequence are
cleared/reset so the new session starts fresh).

In `@discord_http/voice/socket.py`:
- Around line 391-393: The code increments self._out_seq but always writes zero
into the 2-byte sequence field when building the binary frame; update the frame
construction to pack the incremented/masked sequence (self._out_seq) into the
first two bytes (replacing the struct.pack(">H", 0) usage) so the DAVE sequence
is actually transmitted before sending via self.ws.send_bytes(frame), ensuring
sequence masking already applied ((self._out_seq) & 0xFFFF) is used.

In `@examples/voice_example.py`:
- Line 59: The example prints seconds but labels them as milliseconds; update
the two return/send_message calls that reference vc.latency and average_latency
to convert seconds->ms (multiply by 1000) before formatting—e.g., use
(vc.latency * 1000) and (average_latency * 1000) with the same .1f formatting
and "ms" suffix; locate the calls using symbols vc.latency and average_latency
in the example (the send_message/return lines) and change the displayed values
accordingly.

---

Outside diff comments:
In `@discord_http/client.py`:
- Around line 133-156: Validate the voice_reconnect_attempts parameter in the
constructor and reject negative values: in the method that sets
voice_reconnect_attempts (look for the constructor where the
voice_reconnect_attempts parameter is accepted and assigned to
self.voice_reconnect_attempts), add a check like if voice_reconnect_attempts <
0: raise ValueError("voice_reconnect_attempts must be non-negative") so callers
get a clear error instead of silently skipping the reconnect loop used in
discord_http/voice/connection.py.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 36ff5bd3-c233-4647-8a1b-dbd5212df41a

📥 Commits

Reviewing files that changed from the base of the PR and between e7c9d9f and d0350db.

📒 Files selected for processing (26)
  • discord_http/channel.py
  • discord_http/client.py
  • discord_http/errors.py
  • discord_http/gateway/cache.py
  • discord_http/gateway/parser.py
  • discord_http/gateway/shard.py
  • discord_http/guild.py
  • discord_http/utils.py
  • discord_http/voice.py
  • discord_http/voice/__init__.py
  • discord_http/voice/client.py
  • discord_http/voice/connection.py
  • discord_http/voice/dave.py
  • discord_http/voice/encryptor.py
  • discord_http/voice/enums.py
  • discord_http/voice/gateway_udp.py
  • discord_http/voice/oggparse.py
  • discord_http/voice/opus.py
  • discord_http/voice/player.py
  • discord_http/voice/receiver.py
  • discord_http/voice/sinks.py
  • discord_http/voice/socket.py
  • examples/voice_example.py
  • pyproject.toml
  • tests/test_voice_encryptor.py
  • tests/test_voice_oggparse.py
💤 Files with no reviewable changes (1)
  • discord_http/voice.py

Comment thread discord_http/channel.py Outdated
Comment thread discord_http/channel.py
Comment thread discord_http/voice/connection.py
Comment thread discord_http/voice/connection.py
Comment thread discord_http/voice/connection.py
Comment thread discord_http/voice/player.py Outdated
Comment thread discord_http/voice/player.py
Comment thread discord_http/voice/receiver.py
Comment thread discord_http/voice/socket.py Outdated
Comment thread examples/voice_example.py Outdated
after:
A callback invoked with any error once playback finishes.
"""
from .player import AudioPlayer, _resolve_source

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I do question the usecase of import inside here a bit

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Did you want it at the top of the file?

Comment thread discord_http/voice/connection.py Outdated
@AlexFlipnote

Copy link
Copy Markdown
Owner

Please test that it works before I review again 🙃

@Neppkun

Neppkun commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

I'm on it

@Neppkun

Neppkun commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the voice handshake (and DAVE framing) — connect() now completes

While dogfooding examples/voice_example.py, /join always failed with a TimeoutError out of connect(). Debugging live against a real voice channel (one that requires DAVE E2EE) surfaced five distinct protocol bugs. They were layered: each only became visible once the previous one was fixed, and the voice gateway close codes walked us right through them:

silent timeout → 4003 → 4006 → 4017 → 4005 → connected ✅

The fixes were validated by comparing our implementation against discord.py's gateway.py / voice_state.py, then confirmed end-to-end: the bot joins, plays audio, stays connected, and negotiates E2EE (can_encrypt() == True).

What was broken & how it was fixed

1. JSON control frames were sent as binarysocket.py::_send_json
The voice gateway (v8) reserves binary frames for DAVE/E2EE opcodes and expects JSON control frames (IDENTIFY, SELECT_PROTOCOL, …) as text. We were sending them via ws.send_bytes(...), so Discord treated IDENTIFY as a malformed DAVE frame and silently dropped it → never replied with READY → handshake timed out.
Fix: send JSON as a text frame (ws.send_str(orjson.dumps(...).decode())). Why this way: mirrors the main gateway shard, which already uses send_json/text, and keeps binary strictly for DAVE.

2. Heartbeat raced ahead of IDENTIFYsocket.py HELLO handling → 4003 Not authenticated
On HELLO we started the heartbeat task before scheduling IDENTIFY. Since asyncio runs tasks in creation order, op 3 (heartbeat) went out before op 0 (IDENTIFY), and Discord rejected the unauthenticated payload.
Fix: route HELLO through a single _handle_hello() coroutine that awaits IDENTIFY/RESUME first, then starts the heartbeat. Why this way: serializing authentication before any other send removes the race entirely, regardless of scheduling.

3. First heartbeat fired immediatelysocket.py::_heartbeat_loop
The loop beat immediately on start, which can interleave a heartbeat into the IDENTIFY → READY → SESSION_DESCRIPTION window.
Fix: sleep one interval before the first beat. Why this way: this is exactly what discord.py's voice keep-alive does, so nothing is ever sent mid-handshake.

4. The endpoint port was being strippedconnection.py::on_voice_server_update4006 Session is no longer valid
Discord assigns voice servers on non-443 ports (we saw c-ams20-…discord.media:2053), and the session/token are bound to that specific host:port. We ran the endpoint through URL(...).host, which dropped the port, so we connected to :443 — a different server instance — and got 4006.
Fix: keep the full host:port; only strip a wss:// scheme if present. Why this way: matches discord.py, which likewise preserves the port and only strips the scheme.

5. Outbound binary DAVE frames had a bogus 2-byte prefixsocket.py::send_binary4005 Already authenticated (the big one)
Voice framing is asymmetric: inbound binary frames are seq(2B) + opcode(1B) + payload, but outbound frames must be opcode(1B) + payload with no seq prefix. We were prefixing outbound frames with struct.pack(">H", 0), so the leading 0x00 was read by Discord as opcode 0 = IDENTIFY the instant we sent the DAVE key package — hence "Already authenticated", right after an otherwise-successful handshake.
Fix: frame outbound binary as bytes([opcode]) + payload (drop the seq prefix and the now-unused _out_seq). Why this way: identical to discord.py's send_binary, and consistent with our own _dispatch_binary, which already strips the inbound seq.

Bonus, found while reading frames: switched the receive loop to ws.receive() so Discord's close reason text is logged (e.g. "E2EE/DAVE protocol required"). That single line is what made the remaining bugs diagnosable.

DAVE/MLS fixes — dave.py

The target channel mandates DAVE (close code 4017 E2EE/DAVE protocol required when davey is absent / max_dave_protocol_version: 0). With davey installed, the MLS handlers then crashed/mis-framed:

  • _handle_proposals: davey.DaveSession.process_proposals(operation_type, proposals) requires the operation type. We were calling it with the whole payload and one arg. Fix: decode the leading operation_type byte (append/revoke), pass (operation_type, proposals), and reply with commit + welcome concatenated (per the protocol).
  • _handle_commit / _handle_welcome: these payloads are transition_id(2B) + blob; we were handing the prefixed bytes straight to davey. Fix: strip the transition_id prefix before process_commit/process_welcome, and acknowledge non-zero transitions.
  • TRANSITION_READY (op 23) is a JSON frame, not binary. Fix: added socket.send_transition_ready(transition_id) (JSON) and routed all three transition acks through it, matching discord.py.

Verification

Run against a live channel that requires DAVE:

CONNECTED is_connected=True
playing 8s
after: is_playing=True is_connected=True dave_ready=True
SUCCESS
disconnected cleanly

The 11 existing voice unit tests (tests/test_voice_encryptor.py, tests/test_voice_oggparse.py) still pass.

Notes / follow-ups (not in this commit)

  • DAVE mid-call transitions (ops 21/22/24 — prepare_transition / execute_transition / prepare_epoch) arrive as JSON in discord.py but are currently parsed as binary here. The initial join + playback path is fully working; this only matters when participants churn in an active E2EE call (hard to exercise without multiple live users). Flagging for a follow-up.
  • ruff couldn't run locally: pyproject.toml has a selector (PLW0717) that newer ruff versions reject — pre-existing and unrelated to these changes.

@AlexFlipnote

Copy link
Copy Markdown
Owner

Does look much better at least, will test and review it myself when I get the time.

@AlexFlipnote

Copy link
Copy Markdown
Owner

There are a few bugs I was able to spot while doing a quick test

  1. Rejoining a VC breaks it instantly
  • Join VC
  • Make the bot join
  • Make it play
  • Leave
  • Join again
[ DEBUG ] 2026-06-03 00:41:06 Voice socket for guild 933704302252408853 received unhandled op client_disconnect
[ DEBUG ] 2026-06-03 00:41:06 Voice socket for guild 933704302252408853 received unhandled op dave_prepare_epoch
[ DEBUG ] 2026-06-03 00:41:06 Voice socket for guild 933704302252408853 received unhandled op dave_prepare_transition
[ DEBUG ] 2026-06-03 00:41:08 Voice socket for guild 933704302252408853 received unhandled op clients_connect
[ DEBUG ] 2026-06-03 00:41:08 Voice socket for guild 933704302252408853 received unhandled op unknown
[ DEBUG ] 2026-06-03 00:41:08 Voice socket for guild 933704302252408853 received unhandled op unknown
[  WARN ] 2026-06-03 00:41:09 Failed to process MLS proposals: Failed to process proposals: MessageProcessingFailed(ValidationError(WrongEpoch))
[ DEBUG ] 2026-06-03 00:41:09 Voice socket for guild 933704302252408853 received close frame (code=4006, reason='Session is no longer valid.')
  1. Shutting down bot attempts to reconnect
    The logs speaks for itself
[ DEBUG ] 2026-06-03 00:42:09 Shutting down discord.http...
[ DEBUG ] 2026-06-03 00:42:09 Shutting down discord.http/gateway...
[ DEBUG ] 2026-06-03 00:42:09 Shard 0 received a kill signal, shutting down
[ DEBUG ] 2026-06-03 00:42:09 Voice socket for guild 933704302252408853 received close frame (code=None, reason=None)
[  INFO ] 2026-06-03 00:42:09 Reconnecting voice for guild 933704302252408853 (close code 1006), attempt 1/5 in 1.00s
  1. Type error for PartialVoiceState
Cannot access attribute "channel" for class "PartialVoiceState"
  Attribute "channel" is unknown

The reason this is very important is doe to a test code like this

user_vc = ctx.guild.get_member_voice_state(ctx.user.id)
if not user_vc:
return ctx.response.send_message("Not in a VC", ephemeral=True)

async def call_after():
    vc = self.bot._get_voice_client(ctx.guild.id)
    if not vc:
        vc = await user_vc.channel.connect()
    vc.play("funny.mp3")
    return await ctx.edit_original_response(content="Now playing...")

return ctx.response.defer(thinking=True, call_after=call_after)

If you are able to find the user, you should definetly be able to connect to it

  1. Random ctype error
    This seems to spit out when
  • Audio got stopped and bot has not disconnected
  • When the bug of 1. appears
Exception ignored while calling deallocator <function _ProactorBasePipeTransport.__del__ at 0x000001DA4A5743B0>:
Traceback (most recent call last):
  File "C:\Users\AlexFlipnote\AppData\Roaming\uv\python\cpython-3.14.0-windows-x86_64-none\Lib\asyncio\proactor_events.py", line 116, in __del__
    _warn(f"unclosed transport {self!r}", ResourceWarning, source=self)
  File "C:\Users\AlexFlipnote\AppData\Roaming\uv\python\cpython-3.14.0-windows-x86_64-none\Lib\asyncio\proactor_events.py", line 80, in __repr__
    info.append(f'fd={self._sock.fileno()}')
  File "C:\Users\AlexFlipnote\AppData\Roaming\uv\python\cpython-3.14.0-windows-x86_64-none\Lib\asyncio\windows_utils.py", line 102, in fileno
    raise ValueError("I/O operation on closed pipe")
ValueError: I/O operation on closed pipe

QoL things

  • Client._get_voice_client should not be an underscore, there are no other ways to get the voice state of the bot. Perhaps make it a "public" one to easily get it

@AlexFlipnote

Copy link
Copy Markdown
Owner

There are some other things I noticed as well while reviewing:

  1. Exponential backoff is always reset by connect() (connection.py:184 and connection.py307)
    _full_reconnect calls self._backoff.reset() at the top (line 299), then for each attempt calls self.connect() (line 315) which also calls self._backoff.reset() (line 184). After the first attempt fails, the backoff is always back to its initial state.. every retry uses the base delay instead of increasing.

  2. Silent fallback to unsupported encryption mode (connection.py:397)
    If Discord doesn't offer aead_aes256_gcm_rtpsize (stage channels I think does not), it silently picks SUPPORTED_MODES[0] anyway and sends SELECT_PROTOCOL with a mode the server didn't advertise. Discord will close with 4016. Should at minimum log a warning.

  3. IP discovery: unguarded bytes.index() and .decode() gateway_udp.py:132-133
    If Discord sends a bad response, the exception bubbles up through _wait_for_handshake with no context, perhaps try/except it with something like "Malformed IP discovery response"

  4. Encryptor: no bounds check before extension header unpack (encryptor.py:75-76)
    A truncated inbound RTP packet raises an unintuitive struct.error instead of being dropped.

@Neppkun

Neppkun commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

There are a few bugs I was able to spot while doing a quick test

  1. Rejoining a VC breaks it instantly

What's the expected behaviour here? Should the bot re-connect when a member joins the VC?

@AlexFlipnote

Copy link
Copy Markdown
Owner

If the bot is playing and you leave, it should continue to play, if you join again, and it still plays, still play

@Neppkun

Neppkun commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Shouldn't the "continue to play" be handled by the developer rather than the library? The bot stops playing because the DAVE connection is reset and needs to be re-established when only the bot is in the voice chat.

@AlexFlipnote

Copy link
Copy Markdown
Owner

Shouldn't the "continue to play" be handled by the developer rather than the library? The bot stops playing because the DAVE connection is reset and needs to be re-established when only the bot is in the voice chat.

warn them that when they go online (or debug) make it tell there "Yeah, cannot do anything" and perhaps we by default, make the bot leave? I mean if they have to re-establish the connection, just make the bot leave at that point instead, or make an option for it to automatically handle it

@Neppkun

Neppkun commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: voice rejoin, shutdown, ResourceWarning + public APIs

Pushed f5488e2 addressing the bugs from the latest round of testing.

1. Rejoining a VC breaks (DAVE WrongEpoch → 4006)

Two parts:

  • DAVE control ops were never handled. The transition/epoch ops DAVE_PREPARE_TRANSITION (21), DAVE_EXECUTE_TRANSITION (22) and DAVE_PREPARE_EPOCH (24) arrive as JSON text frames, not binary DAVE frames (only the MLS data ops 25–31 are binary). They were falling into the "unhandled op" branch, so the local MLS session never advanced its epoch — on rejoin the session was on a stale epoch, producing MessageProcessingFailed(ValidationError(WrongEpoch)) and a 4006 close. They're now decoded and routed to the DAVE manager (on_dave_jsonDaveManager.handle_json), keeping the epoch in sync.

  • Policy for the 4006 "session invalid" close. When a channel empties out Discord tears down the DAVE session and closes with 4006. The session is genuinely gone, so the old behaviour (fall through to full reconnect) just retried and timed out. Per the discussion, the default is now to disconnect on 4006. Callers who want the old behaviour can opt in:

    await channel.connect(reconnect_on_session_invalid=True)

    The new reconnect_on_session_invalid: bool = False flag is threaded through channel.connect()VoiceClient.connect()VoiceConnection.connect() and is gated on reconnect being enabled. Also added VoiceCloseCode.session_invalid = 4006 for readability.

2. Shutting down the bot attempts to reconnect voice

On shutdown the gateway drops, Discord closes the voice socket abnormally (1006), and the voice socket scheduled a reconnect mid-shutdown. GatewayClient.close() now tears down all active voice clients (locally, no op4) before killing the shards, marking them as intentionally closing so no reconnect is scheduled.

3. ResourceWarning / I/O operation on closed pipe

This came from the ffmpeg subprocess pipe transports, not the UDP socket. On the Windows Proactor loop an unclosed pipe transport raises ResourceWarning / ValueError: I/O operation on closed pipe when finalized by the GC. _FFmpegAudio.cleanup() now closes the subprocess transport and reaps the process so the pipe transports are released deterministically.

4. Type error for PartialVoiceState

PartialVoiceState had no channel attribute, so user_vc.channel.connect() failed static analysis. Added a channel property on PartialVoiceState that returns a usable PartialChannel from channel_id; VoiceState overrides it to prefer the resolved (cached) channel object. Both types now expose .channel and it's statically known.

QoL: public voice-client getter

Renamed Client._get_voice_clientClient.get_voice_client (updated all callers and the example), since it was the only way to get the bot's voice client for a guild.


Validation: 11 voice unit tests pass; pyright reports 0 errors/0 warnings on all touched files (the example type-checks clean). The two pre-existing dave.py pyright findings are unrelated and predate this work.

Note

The DAVE JSON field names (transition_id, protocol_version) for ops 21/22/24 are based on the protocol layout — I wasn't able to live-test mid-call membership churn, so an extra eye there would be appreciated.

@AlexFlipnote whenever you have a moment for a review 🙏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
discord_http/voice/dave.py (1)

374-376: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the negotiated target version here.

_handle_prepare_transition() already records the next (transition_id, protocol_version), but these two blocks overwrite that tuple with (transition_id, self._version). When EXECUTE_TRANSITION later reads _pending_transition, it can re-apply the old version instead of the negotiated one, so mid-call DAVE transitions never switch can_encrypt() to the new epoch.

Also applies to: 399-401

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@discord_http/voice/dave.py` around lines 374 - 376, The code is overwriting
the negotiated protocol version by setting self._pending_transition =
(transition_id, self._version) in the send_transition_ready branches; instead,
preserve the negotiated target version that _handle_prepare_transition recorded.
Change those assignments so they keep the existing protocol_version (e.g., if
self._pending_transition exists use its second element) or explicitly set the
tuple to (transition_id, negotiated_protocol_version) instead of self._version;
this ensures EXECUTE_TRANSITION reads the negotiated epoch (refer to
_handle_prepare_transition, _pending_transition, and EXECUTE_TRANSITION).
discord_http/client.py (1)

133-133: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate that voice_reconnect_attempts is non-negative.

The parameter lacks validation to ensure it's >= 0. Negative values could cause unexpected behavior in voice connection retry logic.

🛡️ Proposed fix to add validation
     voice_reconnect_attempts: int = 5
 ):
+    if voice_reconnect_attempts < 0:
+        raise ValueError("voice_reconnect_attempts must be non-negative")
+
     if application_id is not None:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@discord_http/client.py` at line 133, Add a runtime validation for the
voice_reconnect_attempts parameter to ensure it is non-negative: in the function
or class initializer that accepts/sets voice_reconnect_attempts (the constructor
where voice_reconnect_attempts: int = 5 is declared), check if
voice_reconnect_attempts < 0 and raise a ValueError with a clear message like
"voice_reconnect_attempts must be >= 0" (or alternatively clamp to 0 if
preferred) before using or assigning the value so downstream voice retry logic
cannot receive negative values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@discord_http/client.py`:
- Line 133: Add a runtime validation for the voice_reconnect_attempts parameter
to ensure it is non-negative: in the function or class initializer that
accepts/sets voice_reconnect_attempts (the constructor where
voice_reconnect_attempts: int = 5 is declared), check if
voice_reconnect_attempts < 0 and raise a ValueError with a clear message like
"voice_reconnect_attempts must be >= 0" (or alternatively clamp to 0 if
preferred) before using or assigning the value so downstream voice retry logic
cannot receive negative values.

In `@discord_http/voice/dave.py`:
- Around line 374-376: The code is overwriting the negotiated protocol version
by setting self._pending_transition = (transition_id, self._version) in the
send_transition_ready branches; instead, preserve the negotiated target version
that _handle_prepare_transition recorded. Change those assignments so they keep
the existing protocol_version (e.g., if self._pending_transition exists use its
second element) or explicitly set the tuple to (transition_id,
negotiated_protocol_version) instead of self._version; this ensures
EXECUTE_TRANSITION reads the negotiated epoch (refer to
_handle_prepare_transition, _pending_transition, and EXECUTE_TRANSITION).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 57efe455-592e-486a-8bee-6864bcd44034

📥 Commits

Reviewing files that changed from the base of the PR and between dfb41a2 and f5488e2.

📒 Files selected for processing (10)
  • discord_http/channel.py
  • discord_http/client.py
  • discord_http/gateway/client.py
  • discord_http/gateway/shard.py
  • discord_http/voice/client.py
  • discord_http/voice/connection.py
  • discord_http/voice/dave.py
  • discord_http/voice/player.py
  • discord_http/voice/socket.py
  • examples/voice_example.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • discord_http/gateway/shard.py
  • examples/voice_example.py
  • discord_http/channel.py
  • discord_http/voice/client.py
  • discord_http/voice/player.py
  • discord_http/voice/socket.py
  • discord_http/voice/connection.py

@AlexFlipnote

Copy link
Copy Markdown
Owner
  1. VoiceClient.bot alias, unnecessary duplication (voice/client.py:32) just use self.bot since that is the most used term

  2. AudioPlayer.stop() cancels task AND sets _end, cleanup may be skipped (voice/player.py:637-642)
    stop() calls task.cancel() while _run() has except Exception: self._error = exc but re-raises CancelledError. This means _cleanup() in finally will run, but the silence frames and speak(False) will execute after cancellation. This is probably fine, but if _cleanup() itself is cancelled (e.g. on a hard shutdown), the trailing silence and speaking-off are silently skipped. The finally block could wrap with asyncio.shield() or at minimum log when cleanup is interrupted.

  3. _full_reconnect calls self.connect(...) which re-issues the gateway op4 (voice/connection.py:335-340)
    _full_reconnect loops and calls self.connect(...), which calls shard.change_voice_state(...) each iteration. If the shard is also having trouble, this could issue multiple op4 payloads in quick succession. It also resets _backoff at line 319 before the loop, meaning every retry starts from the base delay, the backoff doesn't accumulate across attempts within one _full_reconnect call. This may be intentional but deserves a note.

  4. WaveSink mixes all speakers into one file (voice/sinks.py:157)
    WaveSink.write ignores user and mixes all speakers together. This is a legitimate design choice, but it's easy for users to be surprised by it. A one-line docstring note ("All speakers are mixed into a single stream") would prevent confusion.

  5. FFmpegOpusAudio._drain_buffer, _packets.pop(0) is O(n) (self._packets is a list and pop(0) is O(n). Since this is a FIFO queue, collections.deque with popleft() is the appropriate structure. In practice the list is short, voice/player.py:527)

  6. datagram_received IP discovery check is fragile (voice/gateway_udp.py:85)
    The check data[1] == 0x02 matches the second byte of the response. The actual IP discovery response type field is at bytes 0-1 as a big-endian uint16 (0x0002). If Discord ever sends a type-2 RTP packet coincidentally, this would fire the future incorrectly. Checking data[0] == 0x00 and data[1] == 0x02 and len(data) >= 74 (the full discovery response size) would be better I believe.

  7. VoiceSocket._schedule task reference not retained (voice/socket.py:254-257)
    The # noqa: RUF006 suppresses the "task not stored" lint warning. This is a deliberate fire-and-forget choice but means a crashing dispatch task is silently swallowed (only the _guard logs it). That's acceptable, but it's worth a brief comment explaining the intent rather than relying on the noqa alone, or rather fix the issue instead.

  8. VoiceCloseCode missing 4017 (dave_e2ee_required, voice/socket.py:24-36)
    Discord can close with 4017 "E2EE/DAVE protocol required" (even referenced in a comment in _receive_loop). It's not in VoiceCloseCode, so _handle_close falls through to the generic _full_reconnect path. Probably fine behavior, but the enum should include it for completeness and discoverability.

It seems to be quite more solid at least, handshake, reconned and encryption are implemented quite nice. Might want to look into those and then as well, perhaps resolve the ruff/pylint errors to now have github actions complain lmao. Also just make sure it runs and things before I review again yes yes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
discord_http/channel.py (1)

2890-2900: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve a guild object when the cache misses.

fetch() now uses the fresh channel_id, but guild still comes only from cache.get_guild(). When cache is cold or disabled, the returned VoiceState has guild_id set but guild=None, and VoiceState._from_data() then skips member hydration even if the REST payload includes it. Fall back to a PartialGuild here, like the other fetch paths in this module do.

Suggested fix
         guild = self._state.cache.get_guild(self.guild_id)
+        if guild is None:
+            from .guild import PartialGuild
+            guild = PartialGuild(state=self._state, id=self.guild_id)
+
         channel = None
         channel_id = utils.get_int(r.response, "channel_id")
         if channel_id is not None:
             channel = self._state.cache.get_channel(self.guild_id, channel_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@discord_http/channel.py` around lines 2890 - 2900, The guild lookup when
building the VoiceState uses cache.get_guild(self.guild_id) and can return None,
causing VoiceState to miss member hydration; change the construction to fall
back to a PartialGuild when get_guild returns None (create
PartialGuild(self._state, id=self.guild_id) or the module's equivalent) so
VoiceState(state=self._state, data=r.response, guild=partial_guild,
channel=channel) always has a guild-like object; update the code path that sets
guild (the block using self._state.cache.get_guild and VoiceState(...)) to
perform this fallback so fetch() and VoiceState._from_data() can hydrate members
from the REST payload.
discord_http/voice/connection.py (1)

439-452: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when Discord doesn't offer a supported encryption mode.

Line 441 silently falls back to SUPPORTED_MODES[0] even when none of Discord's advertised modes match. That makes send_select_protocol() announce an unsupported mode and turns a clear incompatibility into a later close/timeout during the handshake.

Proposed fix
-        self.mode = next((m for m in SUPPORTED_MODES if m in modes), SUPPORTED_MODES[0])
+        self.mode = next((m for m in SUPPORTED_MODES if m in modes), None)
+        if self.mode is None:
+            raise RuntimeError(
+                f"Discord did not offer a supported voice encryption mode: {modes!r}"
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@discord_http/voice/connection.py` around lines 439 - 452, The code silently
falls back to SUPPORTED_MODES[0] when none of Discord's advertised modes match,
causing send_select_protocol to announce an unsupported mode; change the logic
in the block that sets self.mode so it validates that at least one mode in
data.get("modes", []) intersects SUPPORTED_MODES and, if none match, raise an
explicit error (or return/close the connection) instead of defaulting, then only
proceed to call create_udp/discover_ip/_ready_event and
socket.send_select_protocol when a supported mode has been chosen (refer to the
variables/methods: modes, SUPPORTED_MODES, self.mode, create_udp, discover_ip,
self._ready_event, and send_select_protocol).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@discord_http/channel.py`:
- Around line 549-559: The except Exception block only catches Exception, so
asyncio.CancelledError (a BaseException subclass on Py3.11+) can skip cleanup
and leave _voice_clients[self.guild_id] populated; update the
PartialChannel.connect flow to also handle cancellation by catching
asyncio.CancelledError or BaseException around the vc.connect call and calling
client._remove_voice_client(self.guild_id) before re-raising; ensure vc.connect,
client._remove_voice_client, and the surrounding connect logic are referenced so
the cleanup runs for cancellations as well as regular exceptions.

In `@discord_http/voice/connection.py`:
- Around line 410-413: The loop variable named `_scheme` triggers RUF052; rename
it to a normal local variable (e.g., `scheme`) in the for loop that iterates
over ("wss://", "https://") and update its uses in the loop body where
`host_port.startswith(_scheme)` and `host_port[len(_scheme):]` are referenced
(the code that manipulates `host_port` in connection logic inside
discord_http/voice/connection.py). Ensure only the loop variable name is changed
and that `host_port` slicing and startswith checks now use `scheme`.

---

Outside diff comments:
In `@discord_http/channel.py`:
- Around line 2890-2900: The guild lookup when building the VoiceState uses
cache.get_guild(self.guild_id) and can return None, causing VoiceState to miss
member hydration; change the construction to fall back to a PartialGuild when
get_guild returns None (create PartialGuild(self._state, id=self.guild_id) or
the module's equivalent) so VoiceState(state=self._state, data=r.response,
guild=partial_guild, channel=channel) always has a guild-like object; update the
code path that sets guild (the block using self._state.cache.get_guild and
VoiceState(...)) to perform this fallback so fetch() and VoiceState._from_data()
can hydrate members from the REST payload.

In `@discord_http/voice/connection.py`:
- Around line 439-452: The code silently falls back to SUPPORTED_MODES[0] when
none of Discord's advertised modes match, causing send_select_protocol to
announce an unsupported mode; change the logic in the block that sets self.mode
so it validates that at least one mode in data.get("modes", []) intersects
SUPPORTED_MODES and, if none match, raise an explicit error (or return/close the
connection) instead of defaulting, then only proceed to call
create_udp/discover_ip/_ready_event and socket.send_select_protocol when a
supported mode has been chosen (refer to the variables/methods: modes,
SUPPORTED_MODES, self.mode, create_udp, discover_ip, self._ready_event, and
send_select_protocol).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fcca89ab-034f-4855-b90e-55ce10029035

📥 Commits

Reviewing files that changed from the base of the PR and between f5488e2 and 40a928f.

📒 Files selected for processing (8)
  • discord_http/channel.py
  • discord_http/client.py
  • discord_http/voice/connection.py
  • discord_http/voice/dave.py
  • discord_http/voice/gateway_udp.py
  • discord_http/voice/player.py
  • discord_http/voice/receiver.py
  • examples/voice_example.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • discord_http/client.py
  • examples/voice_example.py
  • discord_http/voice/gateway_udp.py
  • discord_http/voice/receiver.py
  • discord_http/voice/dave.py
  • discord_http/voice/player.py

Comment thread discord_http/channel.py
Comment thread discord_http/voice/connection.py Outdated
@Neppkun

Neppkun commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

@AlexFlipnote review

@AlexFlipnote

Copy link
Copy Markdown
Owner

Sorry for the delay, I will review this when I get the time to do so, I have had a lot of things happening so I have simply not had time to review and check it. Will try either today, tomorrow or Friday.

@AlexFlipnote

Copy link
Copy Markdown
Owner

Still present from my earlier comments

  • voice/client.py -> Any usage still exists in connection.py (pending_transitions: dict[int, Any]) and dave.py (_Session = Any)
  • voice/connection.py -> backoff values (base=1.0, max_delay=30.0) are still hardcoded, these should come from the Client
  • voice/dave.py -> opcode dispatch in handle_binary/handle_json still uses if/elif chains, use match/case
  • voice/dave.py -> nesting style not updated to the early-return pattern I prefer
  • voice/gateway_udp.py -> type error is still suppressed with # type: ignore instead of resolved
  • voice/opus.py -> global is still used, also double-check whether OpusError/OpusNotLoaded still exist here now that they were added to errors.py
  • voice/socket.py:25 -> VoiceCloseCode should use BaseEnum like every other enum in this project
  • examples/voice_example.py:61 -> still uses raw hardcoded channel IDs

New issues I spotted

VoiceClient.disconnect() and _cleanup() are nearly identical, the player/receiver/encoder teardown block is copy-pasted between both methods. Extract it to a private _stop_media().

Same duplication in VoiceConnection.disconnect() and close_transport()
The socket/transport/UDP/encryptor/ssrc clearing sequence at the end of both is identical. Same fix.

_internal_reconnect parameter on the public connect() method

async def connect(..., _internal_reconnect: bool = False) -> None:

A private-convention name has no place in a public method signature.
_full_reconnect should call a dedicated private method rather than leaking this flag through connect().

VoiceState.__repr__ renders incorrectly

return f"<VoiceState id={self.user} session_id='{self.session_id}'>"
self.user is a PartialUser object, so this prints its repr nested inside the VoiceState repr. Should be self.user.id.

VoiceConnection.mode silently falls back to an unsupported mode

self.mode = next((m for m in SUPPORTED_MODES if m in modes), SUPPORTED_MODES[0])

If Discord doesn't advertise any supported mode, this picks SUPPORTED_MODES[0] anyway and announces it, turning a clear incompatibility into a silent later close.
Should raise a RuntimeError instead of defaulting.

Docstring verbosity across the entire voice/ package
Almost every method has full Parameters / Returns / Raises docblock sections.
The rest of the codebase uses single-line docstrings.
This needs a full sweep across all voice files to match the project style.

PartialVoiceState.str is useless

def __str__(self) -> str:
    return "PartialVoiceState"

Other Partial* classes return something meaningful. This should return the user ID or be removed entirely.

Gateway voice cleanup on shard close

When a shard is killed, Discord already closes the associated voice WebSocket at the network level (close code 1006), so the voice client's own reconnect/teardown logic handles it. The preemptive vc._cleanup() loop added to GatewayClient.close() is likely redundant and could race with the voice client's own reconnect task.

@Neppkun

Neppkun commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 33ed758 addressing this batch. Point-by-point:

Still present from earlier

  • connection.py pending_transitions: dict[int, Any] — it was never read or written anywhere, so removed entirely (the Any goes with it).
  • dave.py _Session = Any — replaced with a TYPE_CHECKING DaveSession(Protocol) declaring exactly the session surface this file touches. davey is an optional C-extension absent in the type-check env; the runtime accesses stay guarded with try/except AttributeError since the installed build can differ.
  • connection.py backoff base/max_delay — now sourced from the Client via new voice_reconnect_base / voice_reconnect_max_delay params (mirrors voice_reconnect_attempts); the ExponentialBackoff is built lazily from them.
  • dave.py if/elif → match/case and early-return nesting — done (the remaining if self._session is not None blocks were flattened to guard clauses).
  • gateway_udp.py # type: ignore — already resolved via isinstance(transport, asyncio.DatagramTransport).
  • opus.py global / OpusError/OpusNotLoaded — no global remains (state lives on an _OpusLoader); both exceptions are defined in errors.py and imported here.
  • socket.py:25 VoiceCloseCode — now subclasses BaseEnum.
  • examples/voice_example.py hardcoded channel IDs — the example now resolves the caller's channel from their cached voice state (ctx.guild.get_member_voice_state(ctx.user.id)); voice_demo() takes channels as parameters rather than hardcoding ids.

New issues

  • VoiceClient.disconnect()/_cleanup() dup → extracted _stop_media(), called by both.
  • VoiceConnection.disconnect()/close_transport() dup → extracted _cancel_reconnect_task() + _clear_transport_state(); the op4-sending behaviour of disconnect() is preserved.
  • _internal_reconnect on public connect() → removed from the public signature; the body moved to a private _connect(preserve_backoff=...), which _full_reconnect calls.
  • VoiceState.__repr__ → now uses self.user.id.
  • VoiceConnection.mode silent fallback → now raises RuntimeError (listing offered vs supported modes) when no supported mode is advertised, instead of announcing SUPPORTED_MODES[0].
  • Docstring verbosity → swept the whole voice/ package down to single-line summaries.
  • PartialVoiceState.__str__ → now returns the user id (str(self.id)) instead of the class name.

Gateway voice cleanup on shard close — I kept this one, it isn't redundant. The loop's purpose is the opposite of relying on the 1006 close: _cleanup()close_transport() sets _closing and cancels the pending reconnect task synchronously before any await, so when Discord's 1006 close later ends the receive loop, _on_socket_closed early-returns and schedules no reconnect. Without it, the 1006 would trigger reconnect attempts mid-shutdown. It's also not racy for the same reason (flag set / task cancelled before the first suspension point). Happy to revisit if you'd still prefer it gone.

make lint, make type (pyright, 0 errors), and make test (106 passing) all green.

@AlexFlipnote

Copy link
Copy Markdown
Owner

I'll ignore the Ruff errors because they are new things that I need to resolve later, namely docstring starting words.

@Neppkun

Neppkun commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Correction on the docstrings (6e87ded): per your clarification I reverted the over-collapse. Now no-arg functions/properties are one-liners, and every function that takes args/kwargs keeps the full Parameters/Returns/Raises docstring per the style guide. make lint / make type / make test still green.

Neppkun and others added 10 commits July 20, 2026 10:30
…ransition version

- Client: raise ValueError when voice_reconnect_attempts is negative, so the
  voice retry logic can't receive an invalid count.

- DaveManager: when handling a commit/welcome that drives a transition, preserve
  the negotiated protocol version recorded by DAVE_PREPARE_TRANSITION instead of
  overwriting it with the current version. A new _pending_transition_version()
  helper keeps the pending version when the transition_id matches and otherwise
  falls back to the current version (e.g. when the commit arrives first), so
  EXECUTE_TRANSITION applies the negotiated epoch.
CodeRabbit findings:
- channel.py: remove stale voice-client registry entry if connect() fails,
  so a failed/timed-out join no longer blocks all future joins.
- channel.py: PartialVoiceState.fetch() now resolves channel from the fresh
  fetched payload's channel_id instead of the stale partial's id.
- connection.py: exponential backoff now grows across reconnect attempts
  (connect() no longer resets backoff on internal reconnect calls).
- connection.py: a successful RESUMED frame re-sets the connected event so
  is_connected() recovers after a server-crash resume.
- connection.py: clear the stale MLS/DAVE session when a new SESSION_DESCRIPTION
  negotiates dave_protocol_version 0.
- gateway_udp.py: detect RTCP control packets on the unmasked second byte so
  types 200-204 are actually dropped.
- player.py: use shlex.split() for ffmpeg before_options/options to preserve
  shell quoting.
- player.py: FFmpegPCMAudio.read() discards any trailing short PCM frame and
  signals EOF instead of forwarding a partial frame into libopus.
- player.py: AudioPlayer marks itself finished on natural EOF so is_playing()
  reports correctly.
- receiver.py: stop the previous listening session before swapping sinks.
- examples/voice_example.py: convert latency (seconds) to ms before labeling.

Maintainer feedback:
- connection.py: parse the voice server endpoint via utils.URL while preserving
  the schemeless host:port semantics required to avoid 4006.
…dings

This addresses a second round of review feedback plus a real reconnect bug
observed in the field, across the voice stack.

== reconnect_on_session_invalid (voice/connection.py) ==

Symptom: a human leaving+rejoining the bot's channel rebuilds the DAVE/MLS
group and Discord closes the voice websocket with 4006 ("session no longer
valid"). The old reconnect re-issued op4 change_voice_state with the SAME
channel_id, which is a no-op for the voice server (the bot never left at the
gateway level), so no fresh VOICE_SERVER_UPDATE ever arrived and every attempt
timed out on _server_event.wait(). A subsequent play command timed out too.

Fix is a hybrid that mirrors discord.py's verified patterns:
- _soft_reconnect(): on 4006, first try a no-leave recovery -- close the old
  socket/UDP locally (WITHOUT sending op4), open a fresh VoiceSocket and
  re-IDENTIFY (op 0) with the still-valid session_id/token/endpoint. This is
  discord.py's _potential_reconnect approach and avoids any user-visible
  leave/rejoin blip when the credentials are still usable.
- _full_reconnect(force_refresh=...): only if the soft attempt fails do we fall
  back to the leave/rejoin bounce. _force_voice_refresh() sends
  change_voice_state(channel_id=None), waits for the gateway leave ack
  (_left_event) so the leave+rejoin is not coalesced into a no-op, then connect()
  rejoins -- forcing Discord to allocate a fresh voice server. The bounce is
  gated behind force_refresh so unrelated reconnect paths (resume fallback,
  generic codes) do NOT cause a spurious leave/rejoin.
- connect() now joins using the stable voice_client.channel.id instead of the
  mutable self.channel_id, because the leave echo (channel_id=None) clobbers
  self.channel_id via on_voice_state_update and would otherwise rejoin nothing.
- _left_event is set/cleared in on_voice_state_update to signal the leave ack.

Why hybrid rather than always-leave: discord.py treats 4006 as an unhandled
code and always leaves+rejoins, but its 4014 path (_potential_reconnect) proves
a no-leave re-IDENTIFY works when creds survive. Soft-first gives a no-blip
recovery in the common case while keeping the robust leave/rejoin fallback. One
soft attempt per 4006 event prevents a soft->4006->soft loop.

== Review findings ==

channel.py (PartialChannel.connect): catch BaseException, not just Exception,
around vc.connect(). asyncio.CancelledError is a BaseException on 3.11+, so a
cancelled/timed-out connect previously skipped cleanup and left a stale
_voice_clients entry that blocked all future connects in the guild.

voice/client.py: removed the VoiceClient.client/.bot duplication, keeping `bot`
to match the library-wide convention (self.bot is used 243x vs self.client 10x,
all in voice; no external/test refs to a voice .client). Updated internal refs
and the VoiceConnection.client property to read self.voice_client.bot.

voice/player.py:
- FFmpegOpusAudio._packets switched from list+pop(0) (O(n)) to collections.deque
  + popleft() for the FIFO packet queue.
- AudioPlayer._run now logs a warning and re-raises if _cleanup() is interrupted
  by CancelledError, so the silently-skipped trailing silence/speaking-off is at
  least observable. Did not use asyncio.shield (risks hanging shutdown).

voice/gateway_udp.py: IP-discovery detection strengthened from a single
data[1]==0x02 check to data[0]==0x00 and data[1]==0x02 and len(data)>=74 (the
fixed discovery response shape), so a coincidental RTP packet can't resolve the
discovery future.

voice/socket.py:
- _schedule now retains strong references to dispatched tasks in
  self._dispatch_tasks (with a done-callback to discard), instead of relying on
  a fire-and-forget create_task that could be GC'd mid-flight; dropped the noqa.
- Added VoiceCloseCode.dave_e2ee_required = 4017 for completeness/discoverability
  (referenced in a _receive_loop comment).

voice/sinks.py: documented that WaveSink mixes all speakers into one stream and
ignores the `user` arg (behavior unchanged) to prevent surprise.

voice/connection.py (also): added a clarifying note in _full_reconnect that the
backoff is reset once before the loop and grows across attempts; renamed a loop
variable _scheme -> scheme (RUF052); reworked a DAVE docstring summary (D205).

== Validation ==

ruff check --config pyproject.toml (ruff 0.15.15, matching uv.lock) -> clean
pyright on all touched files -> 0 errors/warnings (the pre-existing dave.py:141
typing error on the baseline is unrelated and untouched)
pytest -k "voice or channel" -> 11 passed
Routine, automatically-recovered voice events were logging at INFO/WARN and
spamming the console during normal operation (e.g. a member rejoining triggers
a 4006 + MLS rebuild that the library transparently recovers from). Demote those
to DEBUG; keep INFO/WARN/ERROR only for conditions that are NOT auto-recovered
and likely need operator attention.

Demoted to DEBUG (transient / self-healing):
- connection.py: disconnect+teardown (4014/4022), server-crash resume (4015),
  4006 disconnect (when reconnect not opted in), soft-reconnect attempt/success/
  fallback, resume-failed fallback, per-attempt "Reconnecting..." line, failed
  reconnect attempt (will retry), and reconnect success.
- socket.py: websocket ERROR frame (the receive loop breaks and the connection
  auto-reconnects).
- dave.py: "Failed to process MLS proposals/commit/welcome" -- each immediately
  calls _recover_from_invalid_commit() (notify gateway + reinit), so they are
  fully handled.

Kept as-is (genuine, non-auto-recovered failures):
- connection.py: rate-limited give-up (WARN), reconnect exhausted after N
  attempts + teardown (ERROR), DAVE op received without the davey library (WARN).
- socket.py: unexpected exception in a dispatch handler (ERROR).
- dave.py: DAVE session init failure -- degrades to no session (WARN).
- player.py: cleanup interrupted by cancellation -- re-raised, not swallowed (WARN).

No behavior change; logging levels only.
- connection.py: read reconnect backoff base/max_delay from Client; remove
  unused pending_transitions (dropped Any); raise RuntimeError instead of
  silently announcing an unsupported encryption mode; dedup disconnect()/
  close_transport() into _cancel_reconnect_task()/_clear_transport_state();
  remove private _internal_reconnect from the public connect() signature by
  moving the body to _connect(preserve_backoff=...).
- client.py: add voice_reconnect_base / voice_reconnect_max_delay params.
- voice/client.py: extract duplicated player/receiver/encoder teardown into
  _stop_media(), shared by disconnect() and _cleanup().
- dave.py: replace _Session = Any with a TYPE_CHECKING DaveSession Protocol;
  guard None channel_id before creating a session.
- channel.py: VoiceState.__repr__ uses self.user.id; PartialVoiceState.__str__
  returns the user id instead of the class name.
- Collapse multi-section Parameters/Returns/Raises docstrings across the voice
  package to single-line summaries to match the project style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the project style guide, functions/methods that take arguments use the
full NumPy/Google-hybrid docstring (Parameters/Returns/Raises); only no-arg
functions and properties get one-line docstrings. The previous sweep
over-collapsed everything to one line, so restore the structured docstrings
for every argument-taking function across the voice package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- socket.py: close() now cancels the pending dispatch tasks (skipping the
  current task), so a stale on_ready handler awaiting IP discovery can no
  longer outlive a reconnect and overwrite the new transport/udp or send
  SELECT_PROTOCOL with the old socket's ip/port.
- receiver.py: when DAVE E2EE is active but the packet's SSRC has no user
  mapping yet (RTP has no ordering guarantee against SPEAKING), drop the
  packet instead of passing the still-encrypted payload along as plain
  Opus; rate-limited debug log.
- gateway/client.py: the shutdown voice-cleanup loop catches BaseException
  so a mid-loop cancellation cannot skip the remaining voice clients or
  the shard closes below.
- player.py: the play loop detects a source swapped mid-read and discards
  the stale (possibly truncated) result instead of treating it as EOF;
  teardown only sends trailing silence/speaking-off while still the
  client's current player, so a replaced player cannot race the new one's
  speaking-on.
- dave.py: pending transitions are keyed by transition_id in a dict so
  overlapping transitions during member churn cannot clobber each other;
  cleared on reinit.
- encryptor.py: refuse to wrap the 4-byte nonce counter (AES-GCM key/nonce
  reuse) and raise instead, forcing a reconnect that rotates the key.
- sinks.py: WaveSink docs no longer claim mixing; they now describe
  arrival-order interleaving and point to per-user sinks for real mixing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against the installed davey 0.1.5 (runtime dir() + stubs) and a
repo-wide usage sweep before cutting anything:

- dave: davey has no decrypt_opus; the AttributeError guard silently
  returned ciphertext on every inbound E2EE frame. Call
  session.decrypt(user_id, MediaType.audio, packet) instead, drop the
  never-firing AttributeError guards, delete the serialize() fallback
  (no davey type has it), and align the Protocol stub with davey 0.1.5.
- oggparse: delete OggStream (production only uses OggPage).
- player: parse pages by attempting OggPage and catching ValueError on
  truncation instead of pre-computing page lengths by hand.
- opus: delete the unused Encoder.set_* helpers and their CTL constants.
- receiver: delete never-called remove_user.
- encryptor: delete unused mode property.
- enums/socket: delete unreferenced voice op/close-code members and a
  comment that duplicated the send_binary docstring verbatim.
- connection: share one _get_shard() helper across four call sites and
  drop ready-checks duplicated inside DaveManager.

Tests reworked accordingly; the ffmpeg integration test now exercises
FFmpegOpusAudio (the production Ogg path) end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Handle CLIENT_DISCONNECT (op 13) on the voice socket and route the
  user_id to VoiceReceiver.remove_user(), so SSRC mappings and per-SSRC
  decoders are freed when a user leaves instead of accumulating for the
  lifetime of the connection. Restores the op 13 enum member and
  remove_user() that the previous commit had removed as unreferenced --
  they were an unfinished wiring, not dead code.
- docs/api/voice.rst: document each public voice submodule explicitly;
  the package-level automodule documented nothing because star-imported
  names are skipped without :imported-members:. Verified with a local
  sphinx build.
- Add regression tests for the encryptor nonce-exhaustion guard and the
  DAVE _pending_transitions bookkeeping (overlapping transitions,
  execute/pop, id-0 immediate execution, reinit clearing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Neppkun
Neppkun force-pushed the claude/goofy-germain-5f1bf6 branch from 3b0ebda to b4896ad Compare July 20, 2026 07:34
@Neppkun

Neppkun commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

All addressed in b4896ad, after rebasing onto 1b849e9 as asked (14 + 3 commits replayed cleanly).

client_disconnect (op 13) → remove_user() — wired up: the socket match now routes op 13 to VoiceConnection.on_client_disconnect(), which hands the user_id to VoiceReceiver.remove_user(), so the SSRC map, per-SSRC decoder and seq state are freed when a user leaves. Full disclosure: a dead-code sweep I pushed just before your comment (015e8dd) had deleted remove_user() and the op 13 enum member precisely because nothing called them — your read is the right one, they were an unfinished wiring, so this commit restores and connects them.

Docsdocs/api/voice.rst now documents each public voice submodule (client, connection, player, receiver, sinks, dave, opus, enums) explicitly. The old package-level automodule:: discord_http.voice was actually rendering an empty page: autodoc skips star-imported names unless :imported-members: is set, and the package __init__ only re-exports. VoiceState/PartialVoiceState are picked up by channel.rst's automodule at their new home. Verified with a local sphinx build (only the pre-existing guild.py BanEntry forward-ref warning).

Regression tests — added tests/test_voice_dave.py (6 tests: prepare records + acks, execute applies/pops, unknown-id ignored, overlapping transitions don't clobber, id-0 immediate execution without ack, reinit() clears pending) and a nonce-exhaustion test in tests/test_voice_encryptor.py (encrypts fine at 2**32 - 1, raises on the wrap). 111 tests total, all passing.

One thing from 015e8dd worth your attention beyond the cleanup (net −309 lines): inbound DAVE decryption was silently broken. davey 0.1.5 has no decrypt_opus — its API is decrypt(user_id, MediaType, packet) — so the defensive except AttributeError was swallowing the miss on every inbound E2EE frame and passing ciphertext through as if it were opus. It now calls the real API, and the never-firing guards on the other davey calls (all verified present at runtime) are gone.

make lint / make type / make test all green.

CI installs --all-extras, so davey (0.1.6) and its type stubs are
present when pyright runs. Replace the hand-written DaveSession
Protocol with davey's own type under TYPE_CHECKING -- the stubs are
now the authority on the session API -- and drop the import ignore
that davey's presence made unnecessary.

Ruff 0.15.22 (freshly resolved on CI; uv.lock is not committed)
enabled the preview rule RUF105/noqa-comments, flagging all 85
pre-existing `noqa` comments repo-wide. The rule is stylistic and
its own docs suggest disabling it when `noqa` is preferred, so add
it to the ignore list rather than churning every file in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Neppkun

Neppkun commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

CI is green again (2979107). Two unrelated root causes, for the record:

  • Pyright: CI's uv sync --all-extras installs davey, so pyright checked my hand-written DaveSession Protocol against davey's real stubs and they disagreed on decrypt's parameter type and a parameter name. Since the stubs are present in every type-check run anyway, the Protocol is gone — dave.py now just imports DaveSession from davey under TYPE_CHECKING, so the stubs are the single source of truth (this also would have caught the decrypt_opus bug at type time).
  • Ruff: uv.lock isn't committed, so CI resolved the freshly released ruff 0.15.22, which added the preview rule RUF105/noqa-comments — with preview = true and RUF selected it armed itself and flagged all 85 pre-existing noqa comments repo-wide, master included. Its own docs call it opinionated/stylistic and suggest disabling if you prefer noqa, so I added it to the ignore list in pyproject.toml rather than converting every comment in this PR. If you'd rather migrate to ruff:ignore comments, that's probably its own PR — happy to revert the ignore if so. (Committing uv.lock would also make CI immune to surprise ruff releases; your call.)

@AlexFlipnote

Copy link
Copy Markdown
Owner

Went through everything since my last comment, op 13 wiring looks right, docs match the style the rest of docs/api/ uses and both new test files actually test the thing they claim to. Three left, all in the receive path and two of them are interactions with the fixes you just made:

  1. voice/connection.py:760 can_encrypt() doesn't agree with DaveManager.can_encrypt()
    The connection one is dave_session is not None and dave_session.ready, the manager one also requires _version > 0. After an EXECUTE_TRANSITION down to version 0 the session is still alive and still ready, so the connection says True while nothing is actually being encrypted. That was harmless before since encrypt_opus/decrypt_opus both re-guard internally, but the new drop at receiver.py:200 reads the connection's version, so post downgrade it now drops unmapped ssrc packets that were plain opus the whole time. Should just delegate to self.dave_session.can_encrypt() so all three sites agree.

  2. voice/client.py:316-318 receiver is created lazily so early SPEAKING gets thrown away
    listen() is the first thing that constructs the VoiceReceiver, and on_speaking bails with if receiver is None: return, so every SPEAKING before the first listen() is gone. Discord only sends SPEAKING on the start of speech edge, so someone who's already talking when listen() runs has no ssrc mapping until they stop and start again, and with DAVE on that's now a silent drop instead of a user=None packet. Constructing the receiver eagerly fixes it, it allocates nothing until a sink shows up and unpack() already returns early on sink is None.

  3. voice/dave.py:392-400 failed DaveSession construction leaves _version set
    The except logs a warning and sets _session = None but _version stays at whatever was negotiated. From there encrypt_opus returns plaintext into a channel Discord thinks is E2EE, and inbound frames go into the opus decoder as garbage. Same silent passthrough shape as the decrypt_opus bug you just fixed, should either reset _version = 0 or let it propagate.

Keep the RUF105 ignore as is, converting 85 noqa comments doesn't belong in this PR.

- connection.can_encrypt() now delegates to DaveManager.can_encrypt()
  instead of re-deriving it from session readiness alone. The manager
  also requires a non-zero protocol version, so after an
  EXECUTE_TRANSITION down to version 0 the two disagreed: the session
  is still alive and ready while nothing is encrypted, and the
  unmapped-SSRC drop in receiver.unpack() reads the connection's
  answer, so it dropped plain Opus.

- Build VoiceReceiver eagerly in VoiceClient.__init__ rather than on
  the first listen(). Discord only sends SPEAKING on the leading edge
  of speech, so SPEAKING frames arriving before listen() were thrown
  away and anyone already talking stayed unmapped until they stopped
  and started again. The receiver allocates nothing until a sink is
  attached and unpack() returns early while sink is None, so the null
  checks around it go away with it.

- Reset _version to 0 when DaveManager fails to construct a session
  (and on the no-channel path). Leaving the negotiated version set
  made can_encrypt() depend on a session that does not exist, so
  encrypt_opus would pass plaintext into a channel Discord believes is
  E2EE -- the same silent-passthrough shape as the decrypt_opus bug.

Adds regression tests for the version-0 downgrade and the reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Neppkun

Neppkun commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed in ba9923a, CI green.

1. can_encrypt() disagreement — now delegates: self.dave_session is not None and self.dave_session.can_encrypt(), so all three sites share the manager's definition (version > 0 and session and ready). Worth noting this was a deliberate hold-back on my side rather than an oversight: when I did the dead-code pass I checked whether delegating was behaviour-preserving, found it wasn't because of the receiver drop, and left it alone rather than smuggle a behaviour change into a cleanup commit. You've called which behaviour is correct, so it's in — with a comment recording why the two must agree.

2. Eager receiverVoiceReceiver is now built in VoiceClient.__init__, so SPEAKING is mapped from the moment the connection is live. Confirmed the preconditions you named: unpack() returns early while sink is None, stop() is a no-op when never started, and add_ssrc() doesn't care about the sink. Since _receiver is non-optional now, the is None guards in on_speaking, on_client_disconnect and datagram_received are gone too, which is a net simplification. _stop_media() calls stop() instead of dropping the object, so a reconnect keeps receiving without re-listen().

3. _version left set on failed construction — reset to 0 in the except, and on the channel_id is None early return, which had the same hole. You're right that it's the same shape as the decrypt_opus bug: state claiming E2EE is on while plaintext goes out.

Added two regression tests for the invariants behind 1 and 3 (downgrade_to_version_zero_disables_encryption asserts ready stays True while can_encrypt() goes False — the exact split that caused the drop; reinit_without_channel_resets_version). 113 tests, all green, and lint/type clean.

@AlexFlipnote

Copy link
Copy Markdown
Owner

All three are right, the delegation comment on can_encrypt() and the version reset on both early returns in reinit() are exactly what I wanted, and the downgrade test asserts the ready/can_encrypt split rather than just the end state which is the useful part. Two things fell out of the eager receiver though:

  1. voice/receiver.py:87 stop() doesn't clear _ssrc_map, and now nothing else does either
    Before this commit _stop_media() dropped the whole receiver object so the map got reset by construction, now it just calls stop() and stop() only clears the decoders and seq state. So the map is the one bit of per-session state that survives a reconnect forever, which is the leak I brought up with client_disconnect, just moved. Stale entries also aren't harmless, ssrcs get reassigned across sessions, so an old ssrc coming back for a different user gets attributed to whoever held it last.

Careful where you put the clear though, start() calls stop() when a sink is already attached, so clearing it in stop() would wipe the mappings collected before listen() and undo the whole point of making the receiver eager. It wants to be a separate reset on the connection teardown path, called from _stop_media(), not tied to the sink going away. _dave_unmapped_drops may as well reset there too.

  1. the "reconnect keeps receiving without re-listen()" bit in your comment isn't what the code does
    stop() sets self.sink = None and calls sink.cleanup(), so _stop_media() still detaches and tears down the sink on a reconnect exactly like before, is_listening() goes False. Not a regression, the behaviour is the same as when you dropped the object, but the comment reads like receive now survives a reconnect and it doesn't. Either make it survive or don't claim it.

Also tests/test_voice_dave.py test_reinit_without_channel_resets_version only passes with davey installed, reinit() raises the RuntimeError for the missing package before it ever reaches the channel_id check. CI is fine since it's --all-extras, but on a plain install that test fails and every other test in that file doesn't need davey. Either skipUnless(has_dave) or move the channel_id check above the davey check, no channel means there's nothing to do whether davey is there or not.

- Add VoiceReceiver.reset(), called from _stop_media(), which stops and
  then clears _ssrc_map and _dave_unmapped_drops. The map cannot be
  cleared in stop(): start() calls stop() when a sink is swapped, and
  the map is populated from SPEAKING before any sink is attached, which
  is the point of building the receiver eagerly. Dropping the object
  used to reset it by construction; since _stop_media() now keeps the
  object, the map was surviving reconnects, and reassigned SSRCs would
  be attributed to whoever held them last.

- Move reinit()'s channel_id check above the davey check: with no
  channel there is nothing to encrypt for either way. This also makes
  test_reinit_without_channel_resets_version pass without the voice
  extra installed, where it previously hit the RuntimeError first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Neppkun

Neppkun commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed in 54193f8, CI green.

1. SSRC map surviving teardown — added VoiceReceiver.reset() (stop + clear _ssrc_map + zero _dave_unmapped_drops), called from _stop_media(). Kept out of stop() for exactly the reason you gave: start() calls stop() on a sink swap, and the map is filled from SPEAKING before any sink exists. Two tests pin the split — test_stop_keeps_ssrc_map fails if someone later moves the clear into stop().

2. The reconnect claim — my wording, not the code; dropping it. _stop_media() detaches and cleans up the sink exactly as before, is_listening() goes False, receive does not survive a reconnect. No behaviour change intended there and none made.

3. test_reinit_without_channel_resets_version needing davey — took the reorder rather than skipUnless, since as you say no channel means nothing to do either way. Verified with uv run --no-extra voice: 8/8 pass without davey installed.

115 tests, lint and types clean.

@AlexFlipnote

Copy link
Copy Markdown
Owner

Ok this one I will do a bit shorter, but to give credit, reset() and the reinit reorder looks good.

reset() is in the wrong spot, _stop_media() only runs from disconnect() and _cleanup() and both drop the voice client right after anyway. The reconnect paths never touch the receiver, so after a _full_reconnect() everyone has new ssrcs and the old map is still sitting there. Clear it in on_ready instead, and leave on_resumed alone since that's the same session.

Also the reconnect thing I said last time was wrong (oops..), _stop_media() doesn't run on a reconnect at all so the sink was always fine, oopsie

image

Tiny one as well: _Sink.write(self, data) in the new test doesn't match AudioSink.write(self, user, data), works now since nothing calls it but it'll break if that test ever sends a packet.

_stop_media() only runs from disconnect() and _cleanup(), which drop the
voice client immediately afterwards, so clearing there did nothing for
the case that matters: _full_reconnect() never touches the receiver, so
the stale map outlived the reconnect that reassigned every SSRC.

Reset from on_ready() instead, which fires per fresh session. on_resumed()
deliberately does not, since a resume continues the same session with the
same SSRCs. _stop_media() goes back to stop().

Also fix _Sink.write in the new test to match AudioSink.write(user, data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Neppkun

Neppkun commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Moved in 3d3ae40, CI green.

reset() now runs from on_ready(), so it fires per fresh session and covers the _full_reconnect() case that _stop_media() never reached. on_resumed() left alone — same session, same SSRCs. _stop_media() is back to stop().

Test sink signature fixed to write(self, user, data).

@AlexFlipnote

Copy link
Copy Markdown
Owner
  • voice/receiver.py:113 + voice/connection.py:598 / reset() calls stop(), which nulls the sink and calls sink.cleanup(). READY fires on every reconnect, so a reconnect now silently stops receiving. Only clear the ssrc keyed stuff there (_ssrc_map, _last_seq, _decoders, _dave_unmapped_drops), leave the sink alone.
  • voice/sinks.py:183 / worse with WaveSink, cleanup() closes the wav so a reconnect truncates the recording. _ensure_open() at :159 also opens "wb", so listen() again with the same sink wipes what was written.
  • tests/test_voice_receiver.py / test_reset_clears_ssrc_map asserts is_listening() is False, so it pins the broken behaviour. Should assert the sink survives.
  • voice/socket.py:186 / no heartbeat ack timeout, acks only append to _latencies and nothing notices when they stop. Voice server goes quiet without a close frame and the connection just sits there alive with no reconnect.
  • voice/connection.py:544 / mid session VOICE_SERVER_UPDATE updates token/endpoint but nothing acts on it, so a server migration keeps talking to the old endpoint until that socket dies. Self heals after, low prio. move_to() same shape, no READY on a same server move so old ssrc mappings stick.
  • rest of the package looks fine to me, player pacing, source swap, ffmpeg teardown, ogg parsing, rtpsize framing, dave transitions, op 13.

@Neppkun

Neppkun commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Implemented the remaining voice lifecycle fixes from the latest review in db81c9f:

  • Receiver state across reconnects: VoiceReceiver.reset() now clears only SSRC-scoped state (_ssrc_map, _last_seq, _decoders, and _dave_unmapped_drops). It cleans stale decoders but preserves the active sink, so a READY during reconnect does not silently stop an ongoing recording.
  • WaveSink reuse: cleanup is now terminal and idempotent. Attempting to write through the same finalized sink raises a clear RuntimeError instead of reopening the destination with wb and truncating the existing WAV.
  • Heartbeat ACK timeout: the voice socket tracks whether a heartbeat is outstanding. If it is still unacknowledged at the next interval, it hands control to the existing connection reconnect path rather than leaving a half-open connection alive indefinitely.
  • Voice server migrations: a connected VOICE_SERVER_UPDATE with changed token or endpoint now schedules one migration through the existing reconnect task. It first attempts a soft reconnect using the new credentials, then falls back to a full voice-state refresh if that fails.
  • Same-server channel moves: channel_id is no longer changed optimistically in move_to(). The gateway's VOICE_STATE_UPDATE is treated as the authoritative move acknowledgement, and a confirmed channel change clears stale SSRC ownership while preserving the receive sink.

I added focused tests for receiver reset behavior, WaveSink finalization, heartbeat ACK handling, voice-server migration, and gateway-confirmed channel moves.

Validation:

  • 122 unit tests passed
  • Ruff passed
  • Pyright passed with 0 errors (one unrelated pre-existing warning remains in discord_http/tasks.py)

Generated with openai/gpt-5.6-sol

@AlexFlipnote

Copy link
Copy Markdown
Owner

Seems better, but I wonder about another thing as well:

voice/connection.py:534 a same server move resets the receiver but not the DAVE session. Different channel is a different MLS group and there's no READY on a same server move to trigger reinit_dave_session(), so the old channel's MLS state lingers. Does Discord push a fresh session description / epoch op on the move, or do we need to re-init DAVE ourselves? Not sure it re-negotiates on its own.

@Neppkun

Neppkun commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Followed up on the same-server move / DAVE question in the latest commit.

Discord's documented behavior is:

  • A channel change produces a new VOICE_SERVER_UPDATE token even when the endpoint is unchanged, and the previous voice session cannot be reused.
  • DAVE op 24 with epoch = 1 is the authoritative signal that a new MLS group is being created. That is when the client must initialize the new group and send a fresh MLS key package.
  • Later epochs belong to the existing MLS group and must not recreate the session.

So I did not reinitialize DAVE directly from the main-gateway VOICE_STATE_UPDATE; that could race or duplicate the voice gateway's epoch flow. Instead:

  • move_to() tracks the requested channel until both VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE have arrived, in either order.
  • Only then does it reconnect the voice session with the new channel ID, session ID, and token. The fresh SESSION_DESCRIPTION initializes DAVE for that session, while op 24 epoch 1 remains the authoritative retained-socket new-group path.
  • DAVE_PREPARE_EPOCH now reinitializes only for epoch == 1 and synchronizes the connection-level protocol version. Previously it recreated the session for every epoch, which would incorrectly discard an existing MLS group during ordinary membership transitions.

Added tests for both gateway update orderings, epoch-1 recreation, and later-epoch preservation.

Validation:

  • 126 unit tests passed
  • Ruff passed
  • Pyright passed with 0 errors (the same unrelated existing warning remains)

Generated with openai/gpt-5.6-sol

@AlexFlipnote

Copy link
Copy Markdown
Owner

Did a full sweep over the whole voice package this time, not just the recent changes. Reconnect/move/DAVE all checks out, few real things left:

1. connection.py socket leak across failed handshake attempts. _wait_for_handshake (connection.py:285) does self.socket = VoiceSocket(self) without closing the previous socket, and _full_reconnect (connection.py:445) only closes it once before the retry loop, never inside it. So every attempt that times out orphans a socket with its heartbeat and receive tasks still running and the ws still open. The nasty part is after a reconnect finally succeeds and _reconnect_task is done, one of those orphaned sockets can hit a missing heartbeat ack and call _on_socket_closed(None), which is no longer suppressed by the guard, so a zombie socket can tear down a perfectly healthy connection minutes later. Fix is to close/replace self.socket at the top of _wait_for_handshake (or per attempt in the loop).

2. voice/__init__.py from discord_http.voice import VoiceState is broken. VoiceState/PartialVoiceState moved to channel.py but the voice package __init__ never re-exports them, so from discord_http.voice import VoiceState now raises ImportError (it worked on master since the old voice.py had them in __all__). Top level from discord_http import VoiceState still works, so it's just the submodule path. One line re-export fixes it if we consider that path public.

3. receiver.py:289 false concealment on out of order packets. (sequence - last) & 0xFFFF > 1 treats a reordered/late packet (seq < last) as a huge forward gap, so it injects a spurious decode(None) concealment frame and then rewinds _last_seq backwards, which makes the next in-order packet look like another gap and conceal again. UDP reorders packets all the time so this degrades decode quality in normal use, not just on real loss.

Might be worth checking:

  • player.py:472 source.cleanup() runs after the cancellable await speak(False), so a cancel landing mid cleanup leaks the ffmpeg process and skips the after callback. You already log a warning for the interrupted case, just move the ffmpeg kill ahead of the await or into a finally.
  • player.py:173 _pump_stdin does a blocking .read() on the event loop, fine for BytesIO but a real file object or slow stream stalls the whole loop (pacing, heartbeats, receive) per read.
  • dave.py:109 reinit() raises without resetting self._version to 0 when DAVE is mandated but davey is missing, leaves version saying e2ee-on with no session. Not a security issue since encrypt falls back to plaintext and just fails to decrypt remotely, but the sibling except block resets version and this one doesn't.
  • receiver.py:290 the concealment frame is computed then thrown away, so a recording drifts shorter than wall clock on any loss. Might be intentional, flagging just in case.

I am so sorry that this is going back and forth, just things I spot when rechecking again and again..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants