diff --git a/src/voice/_discord_voice_adapter.py b/src/voice/_discord_voice_adapter.py new file mode 100644 index 0000000..889496e --- /dev/null +++ b/src/voice/_discord_voice_adapter.py @@ -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) diff --git a/tests/test_voice_adapter_contract.py b/tests/test_voice_adapter_contract.py new file mode 100644 index 0000000..75fae72 --- /dev/null +++ b/tests/test_voice_adapter_contract.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +"""Contract test for `src/voice/_discord_voice_adapter.py`. + +Purpose: catch drift when the vendored `discord-ext-voice-recv` is upgraded. +If upstream renames/removes a method we depend on, this test fails LOUDLY +before any downstream code breaks at runtime in a Discord voice call. + +Per VENDOR_INFO.md: this test MUST PASS after every vendor upgrade. + +Plain `import` + `hasattr` / `callable` checks — no mocks. We're verifying +the SHAPE of the API surface, not behavior. +""" + +from __future__ import annotations + +import inspect + +import pytest + + +# --- Adapter re-exports import cleanly -------------------------------------- + + +def test_adapter_exports_voice_receive_client(): + from src.voice._discord_voice_adapter import VoiceReceiveClient + + assert VoiceReceiveClient is not None + assert inspect.isclass(VoiceReceiveClient) + + +def test_adapter_exports_audio_sink(): + from src.voice._discord_voice_adapter import AudioSink + + assert AudioSink is not None + assert inspect.isclass(AudioSink) + + +def test_adapter_exports_voice_data(): + from src.voice._discord_voice_adapter import VoiceData + + assert VoiceData is not None + assert inspect.isclass(VoiceData) + + +def test_adapter_exports_connect_helper(): + from src.voice._discord_voice_adapter import connect_voice + + assert callable(connect_voice) + assert inspect.iscoroutinefunction(connect_voice) + + +# --- Re-exports point at the real vendored classes (no accidental shadowing) - + + +def test_voice_receive_client_is_voice_recv_client(): + from discord.ext import voice_recv + + from src.voice._discord_voice_adapter import VoiceReceiveClient + + assert VoiceReceiveClient is voice_recv.VoiceRecvClient + + +def test_audio_sink_is_voice_recv_audio_sink(): + from discord.ext import voice_recv + + from src.voice._discord_voice_adapter import AudioSink + + assert AudioSink is voice_recv.AudioSink + + +def test_voice_data_is_voice_recv_voice_data(): + from discord.ext import voice_recv + + from src.voice._discord_voice_adapter import VoiceData + + assert VoiceData is voice_recv.VoiceData + + +# --- VoiceReceiveClient API surface used by the pipeline -------------------- + + +@pytest.mark.parametrize( + "method_name", + [ + "connect", # inherited from discord.VoiceClient + "disconnect", # inherited from discord.VoiceClient + "listen", # voice_recv extension + "stop_listening", # voice_recv extension + "is_listening", # voice_recv extension + "stop", # voice_recv extension (stops play+listen) + "cleanup", # voice_recv extension + ], +) +def test_voice_receive_client_has_method(method_name): + from src.voice._discord_voice_adapter import VoiceReceiveClient + + attr = getattr(VoiceReceiveClient, method_name, None) + assert attr is not None, f"VoiceReceiveClient is missing `.{method_name}()`" + assert callable(attr), f"VoiceReceiveClient.{method_name} is not callable" + + +def test_voice_receive_client_listen_accepts_sink_and_after(): + """`.listen(sink, *, after=None)` is the canonical call shape.""" + from src.voice._discord_voice_adapter import VoiceReceiveClient + + sig = inspect.signature(VoiceReceiveClient.listen) + params = sig.parameters + assert "sink" in params, f"VoiceReceiveClient.listen missing `sink` param; got {list(params)}" + assert "after" in params, f"VoiceReceiveClient.listen missing `after` kwarg; got {list(params)}" + + +def test_voice_receive_client_has_sink_property(): + """`.sink` is read/write so we can swap sinks in place.""" + from src.voice._discord_voice_adapter import VoiceReceiveClient + + sink_attr = inspect.getattr_static(VoiceReceiveClient, "sink", None) + assert isinstance(sink_attr, property), "VoiceReceiveClient.sink must be a property" + assert sink_attr.fget is not None, "VoiceReceiveClient.sink property missing getter" + assert sink_attr.fset is not None, "VoiceReceiveClient.sink property missing setter" + + +# --- AudioSink API surface -------------------------------------------------- + + +@pytest.mark.parametrize( + "method_name", + [ + "write", # write(user, voice_data) — the hot path + "cleanup", + "wants_opus", # bool: opus bytes vs decoded PCM + ], +) +def test_audio_sink_has_method(method_name): + from src.voice._discord_voice_adapter import AudioSink + + attr = getattr(AudioSink, method_name, None) + assert attr is not None, f"AudioSink is missing `.{method_name}()`" + assert callable(attr), f"AudioSink.{method_name} is not callable" + + +def test_audio_sink_write_signature(): + """`.write(self, user, data)` — user is the speaker (Optional), data is VoiceData.""" + from src.voice._discord_voice_adapter import AudioSink + + sig = inspect.signature(AudioSink.write) + params = list(sig.parameters) + # self, user, data + assert len(params) >= 3, f"AudioSink.write expected (self, user, data), got {params}" + + +# --- VoiceData attributes --------------------------------------------------- + + +def test_voice_data_slots(): + """VoiceData uses __slots__ for per-packet allocation. Pipeline reads these.""" + from src.voice._discord_voice_adapter import VoiceData + + assert hasattr(VoiceData, "__slots__"), "VoiceData lost __slots__ — perf regression risk" + slots = set(VoiceData.__slots__) + # Documented attributes the pipeline depends on. + assert "packet" in slots, f"VoiceData missing `packet` slot; got {slots}" + assert "source" in slots, f"VoiceData missing `source` slot (speaker user); got {slots}" + assert "pcm" in slots, f"VoiceData missing `pcm` slot (decoded audio); got {slots}" + + +def test_voice_data_has_opus_property(): + """`.opus` exposes the raw opus bytes from the underlying RTP packet.""" + from src.voice._discord_voice_adapter import VoiceData + + opus_attr = inspect.getattr_static(VoiceData, "opus", None) + assert isinstance(opus_attr, property), "VoiceData.opus must be a property"