feat(voice): Pas 4 — _discord_voice_adapter.py thin layer + contract test

Adapter layer peste vendored discord-ext-voice-recv. Re-exports:
VoiceReceiveClient, AudioSink, VoiceData, plus async helper
connect_voice(channel). Discord voice protocol e fragil, upstream e
hobby fork — dacă pică, swap la py-cord = doar acest fișier rescris.

Contract test (22 assertions) prinde drift la upgrade vendor:
- VoiceReceiveClient methods: connect/disconnect/listen/stop_listening/
  is_listening/stop/cleanup
- listen(sink, *, after=None) signature
- sink property (read/write)
- AudioSink methods: write/cleanup/wants_opus + write(self, user, data) arity
- VoiceData slots (packet/source/pcm) + .opus property

Critical pentru Lane PIPE downstream: write() e called from audio thread
(NOT asyncio loop) — threading primitives mandatory pentru EchoVoiceSink.

22/22 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:42:50 +00:00
parent a48562b2f5
commit a3eefbc799
2 changed files with 238 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""Adapter layer over `discord-ext-voice-recv` (vendored at vendor/).
If discord-ext-voice-recv breaks, swap to py-cord by rewriting only this file.
Contract test in tests/test_voice_adapter_contract.py guards drift.
Downstream consumers (`src/voice/*`, `src/adapters/discord_voice.py`) MUST
import from this file — never from `discord.ext.voice_recv` directly.
## Public API surface (stable across upstream changes)
- ``VoiceReceiveClient`` — alias for ``voice_recv.VoiceRecvClient``. Subclass
of ``discord.VoiceClient`` with extra audio-receive plumbing.
Key methods used by the pipeline:
* ``await client.disconnect(force: bool = False)`` (from discord.VoiceClient)
* ``client.listen(sink, *, after=None)`` — attach an ``AudioSink``;
raises ``discord.ClientException`` if not connected or already listening
* ``client.stop_listening()`` — detach the current sink
* ``client.is_listening() -> bool``
* ``client.stop()`` — stop both playing and listening
* ``client.sink`` (property, getter+setter) — swap the active sink in place
- ``AudioSink`` — abstract base. Subclasses MUST implement:
* ``write(user: Optional[discord.User|Member], data: VoiceData) -> None``
* ``wants_opus() -> bool`` (True → receive opus bytes; False → receive PCM)
* ``cleanup() -> None``
- ``VoiceData`` — per-packet container. Slots: ``packet``, ``source``, ``pcm``.
``.pcm`` is decoded 48kHz s16le stereo bytes when ``wants_opus()`` is False.
``.opus`` property returns the raw opus bytes from the underlying RTP packet.
- ``connect_voice(channel) -> VoiceReceiveClient`` — async helper, returns a
connected receive-capable voice client.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from discord.ext import voice_recv
if TYPE_CHECKING:
import discord
# --- Stable re-exports -------------------------------------------------------
VoiceReceiveClient = voice_recv.VoiceRecvClient
AudioSink = voice_recv.AudioSink
VoiceData = voice_recv.VoiceData
__all__ = [
"VoiceReceiveClient",
"AudioSink",
"VoiceData",
"connect_voice",
]
async def connect_voice(channel: "discord.VoiceChannel") -> VoiceReceiveClient:
"""Connect to a Discord voice channel with the receive-capable client.
Thin wrapper around ``channel.connect(cls=VoiceRecvClient)`` so callers
don't have to import the vendored class directly.
"""
return await channel.connect(cls=VoiceReceiveClient)