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).
This commit is contained in:
user
2026-02-21 21:42:28 +01:00
parent b074356ec6
commit 47b13c3f1f
6 changed files with 1232 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ from derp.mumble import (
MSG_PING,
MSG_SERVER_SYNC,
MSG_TEXT_MESSAGE,
MSG_UDPTUNNEL,
MSG_USER_REMOVE,
MSG_USER_STATE,
MumbleBot,
@@ -20,15 +21,19 @@ from derp.mumble import (
_build_ping_payload,
_build_text_message_payload,
_build_version_payload,
_build_voice_packet,
_decode_fields,
_decode_varint,
_encode_field,
_encode_varint,
_encode_voice_varint,
_escape_html,
_field_int,
_field_ints,
_field_str,
_pack_msg,
_scale_pcm,
_shell_quote,
_strip_html,
_unpack_header,
)
@@ -912,3 +917,181 @@ class TestMumbleBotConfig:
}
bot = MumbleBot("test", config, PluginRegistry())
assert bot._proxy is False
# ---------------------------------------------------------------------------
# TestVoiceVarint
# ---------------------------------------------------------------------------
class TestVoiceVarint:
def test_zero(self):
assert _encode_voice_varint(0) == b"\x00"
def test_7bit_max(self):
assert _encode_voice_varint(127) == b"\x7f"
def test_7bit_small(self):
assert _encode_voice_varint(1) == b"\x01"
assert _encode_voice_varint(42) == b"\x2a"
def test_14bit_min(self):
# 128 = 0x80 -> prefix 10, value 128
result = _encode_voice_varint(128)
assert len(result) == 2
assert result[0] & 0xC0 == 0x80 # top 2 bits = 10
def test_14bit_max(self):
result = _encode_voice_varint(0x3FFF)
assert len(result) == 2
def test_21bit(self):
result = _encode_voice_varint(0x4000)
assert len(result) == 3
assert result[0] & 0xE0 == 0xC0 # top 3 bits = 110
def test_28bit(self):
result = _encode_voice_varint(0x200000)
assert len(result) == 4
assert result[0] & 0xF0 == 0xE0 # top 4 bits = 1110
def test_64bit(self):
result = _encode_voice_varint(0x10000000)
assert len(result) == 9
assert result[0] == 0xF0
def test_negative_raises(self):
import pytest
with pytest.raises(ValueError, match="non-negative"):
_encode_voice_varint(-1)
def test_14bit_roundtrip(self):
"""Value encoded and decoded back correctly (manual decode)."""
val = 300
data = _encode_voice_varint(val)
assert len(data) == 2
decoded = ((data[0] & 0x3F) << 8) | data[1]
assert decoded == val
def test_7bit_roundtrip(self):
for v in range(128):
data = _encode_voice_varint(v)
assert len(data) == 1
assert data[0] == v
# ---------------------------------------------------------------------------
# TestVoicePacket
# ---------------------------------------------------------------------------
class TestVoicePacket:
def test_header_byte(self):
pkt = _build_voice_packet(0, b"\xaa\xbb")
assert pkt[0] == 0x80 # type=4, target=0
def test_sequence_encoding(self):
pkt = _build_voice_packet(42, b"\x00")
# byte 0: header, byte 1: sequence=42 (7-bit)
assert pkt[1] == 42
def test_opus_data_present(self):
opus = b"\xde\xad\xbe\xef"
pkt = _build_voice_packet(0, opus)
assert pkt.endswith(opus)
def test_length_field(self):
opus = b"\x00" * 10
pkt = _build_voice_packet(0, opus)
# header(1) + seq(1, val=0) + length(1, val=10) + data(10) = 13
assert len(pkt) == 13
assert pkt[2] == 10 # length varint
def test_terminator_flag(self):
opus = b"\x00" * 5
pkt = _build_voice_packet(0, opus, last=True)
# length with bit 13 set: 5 | 0x2000 = 0x2005
# 0x2005 in 14-bit varint: 10_100000 00000101
length_bytes = _encode_voice_varint(5 | 0x2000)
assert length_bytes in pkt
def test_no_terminator_by_default(self):
opus = b"\x00" * 5
pkt = _build_voice_packet(0, opus, last=False)
# length=5, no bit 13
assert pkt[2] == 5
# ---------------------------------------------------------------------------
# TestPcmScaling
# ---------------------------------------------------------------------------
class TestPcmScaling:
def test_unity_volume(self):
import struct as _s
pcm = _s.pack("<hh", 1000, -1000)
result = _scale_pcm(pcm, 1.0)
assert result == pcm
def test_half_volume(self):
import struct as _s
pcm = _s.pack("<h", 1000)
result = _scale_pcm(pcm, 0.5)
samples = _s.unpack("<h", result)
assert samples[0] == 500
def test_clamp_positive(self):
import struct as _s
pcm = _s.pack("<h", 32767)
result = _scale_pcm(pcm, 2.0)
samples = _s.unpack("<h", result)
assert samples[0] == 32767
def test_clamp_negative(self):
import struct as _s
pcm = _s.pack("<h", -32768)
result = _scale_pcm(pcm, 2.0)
samples = _s.unpack("<h", result)
assert samples[0] == -32768
def test_zero_volume(self):
import struct as _s
pcm = _s.pack("<hh", 32767, -32768)
result = _scale_pcm(pcm, 0.0)
samples = _s.unpack("<hh", result)
assert samples == (0, 0)
def test_preserves_length(self):
pcm = b"\x00" * 1920 # 960 samples
result = _scale_pcm(pcm, 0.5)
assert len(result) == 1920
# ---------------------------------------------------------------------------
# TestShellQuote
# ---------------------------------------------------------------------------
class TestShellQuote:
def test_simple(self):
assert _shell_quote("hello") == "'hello'"
def test_single_quote(self):
assert _shell_quote("it's") == "'it'\\''s'"
def test_url(self):
url = "https://youtube.com/watch?v=abc&t=10"
quoted = _shell_quote(url)
assert quoted.startswith("'")
assert quoted.endswith("'")
# ---------------------------------------------------------------------------
# TestMsgUdpTunnel
# ---------------------------------------------------------------------------
class TestMsgUdpTunnel:
def test_constant(self):
assert MSG_UDPTUNNEL == 1