Files
derp/tests/test_opus.py
user 47b13c3f1f feat: add Mumble music playback with Opus streaming
ctypes libopus encoder (src/derp/opus.py), voice varint/packet builder
and stream_audio method on MumbleBot (src/derp/mumble.py), music plugin
with play/stop/skip/queue/np/volume commands (plugins/music.py).
Audio pipeline: yt-dlp|ffmpeg subprocess -> PCM -> Opus -> UDPTunnel.
67 new tests (1561 total).
2026-02-21 21:42:28 +01:00

155 lines
4.9 KiB
Python

"""Tests for the Opus ctypes wrapper."""
import math
import struct
from unittest.mock import MagicMock, patch
import pytest
from derp.opus import CHANNELS, FRAME_BYTES, FRAME_SIZE, SAMPLE_RATE
# -- Helpers -----------------------------------------------------------------
def _silence() -> bytes:
"""Generate one frame of silence (1920 bytes of zeros)."""
return b"\x00" * FRAME_BYTES
def _sine_frame(freq: float = 440.0) -> bytes:
"""Generate one 20ms frame of a sine wave at the given frequency."""
samples = []
for i in range(FRAME_SIZE):
t = i / SAMPLE_RATE
val = int(16000 * math.sin(2 * math.pi * freq * t))
samples.append(struct.pack("<h", max(-32768, min(32767, val))))
return b"".join(samples)
# -- Mock libopus for unit testing without system library --------------------
def _mock_lib():
"""Build a mock ctypes CDLL that simulates libopus calls."""
lib = MagicMock()
lib.opus_encoder_get_size.return_value = 256
lib.opus_encoder_init.return_value = 0
lib.opus_encoder_ctl.return_value = 0
lib.opus_encode.return_value = 10 # 10 bytes output
return lib
@pytest.fixture(autouse=True)
def _reset_lib_cache():
"""Reset the cached _lib before each test."""
import derp.opus as _mod
old = _mod._lib
_mod._lib = None
yield
_mod._lib = old
# ---------------------------------------------------------------------------
# TestOpusConstants
# ---------------------------------------------------------------------------
class TestOpusConstants:
def test_sample_rate(self):
assert SAMPLE_RATE == 48000
def test_channels(self):
assert CHANNELS == 1
def test_frame_size(self):
assert FRAME_SIZE == 960
def test_frame_bytes(self):
assert FRAME_BYTES == FRAME_SIZE * CHANNELS * 2
# ---------------------------------------------------------------------------
# TestOpusEncoder (mocked libopus)
# ---------------------------------------------------------------------------
class TestOpusEncoder:
def test_encode_silence(self):
"""Encoding silence produces bytes output."""
lib = _mock_lib()
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder()
result = enc.encode(_silence())
assert isinstance(result, bytes)
assert len(result) > 0
enc.close()
def test_encode_sine(self):
"""Encoding a sine wave produces bytes output."""
lib = _mock_lib()
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder()
result = enc.encode(_sine_frame())
assert isinstance(result, bytes)
enc.close()
def test_encode_wrong_size(self):
"""Passing wrong buffer size raises ValueError."""
lib = _mock_lib()
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder()
with pytest.raises(ValueError, match="expected 1920"):
enc.encode(b"\x00" * 100)
enc.close()
def test_encode_multi_frame(self):
"""Multiple sequential encodes work."""
lib = _mock_lib()
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder()
for _ in range(5):
result = enc.encode(_silence())
assert isinstance(result, bytes)
enc.close()
def test_custom_bitrate(self):
"""Custom bitrate is passed to opus_encoder_ctl."""
lib = _mock_lib()
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder(bitrate=96000)
assert lib.opus_encoder_ctl.called
enc.close()
def test_init_failure(self):
"""RuntimeError on encoder init failure."""
lib = _mock_lib()
lib.opus_encoder_init.return_value = -1
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
with pytest.raises(RuntimeError, match="opus_encoder_init"):
OpusEncoder()
def test_encode_failure(self):
"""RuntimeError on encode failure."""
lib = _mock_lib()
lib.opus_encode.return_value = -1
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder()
with pytest.raises(RuntimeError, match="opus_encode"):
enc.encode(_silence())
def test_close_clears_state(self):
"""close() sets internal state to None."""
lib = _mock_lib()
with patch("derp.opus._load_lib", return_value=lib):
from derp.opus import OpusEncoder
enc = OpusEncoder()
enc.close()
assert enc._state is None