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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user