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

283
plugins/music.py Normal file
View File

@@ -0,0 +1,283 @@
"""Plugin: music playback for Mumble voice channels."""
from __future__ import annotations
import asyncio
import logging
import subprocess
from dataclasses import dataclass
from derp.plugin import command
log = logging.getLogger(__name__)
_MAX_QUEUE = 50
_MAX_TITLE_LEN = 80
@dataclass(slots=True)
class _Track:
url: str
title: str
requester: str
# -- Per-bot runtime state ---------------------------------------------------
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("music", {
"queue": [],
"current": None,
"volume": 50,
"task": None,
"done_event": None,
})
# -- Helpers -----------------------------------------------------------------
def _is_mumble(bot) -> bool:
"""Check if bot supports voice streaming."""
return hasattr(bot, "stream_audio")
def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
"""Truncate text with ellipsis if needed."""
if len(text) <= max_len:
return text
return text[: max_len - 3].rstrip() + "..."
def _resolve_title(url: str) -> str:
"""Resolve track title via yt-dlp. Blocking, run in executor."""
try:
result = subprocess.run(
["yt-dlp", "--get-title", "--no-warnings", url],
capture_output=True, text=True, timeout=15,
)
title = result.stdout.strip()
return title if title else url
except Exception:
return url
# -- Play loop ---------------------------------------------------------------
async def _play_loop(bot) -> None:
"""Pop tracks from queue and stream them sequentially."""
ps = _ps(bot)
try:
while ps["queue"]:
track = ps["queue"].pop(0)
ps["current"] = track
done = asyncio.Event()
ps["done_event"] = done
volume = ps["volume"] / 100.0
try:
await bot.stream_audio(
track.url, volume=volume, on_done=done,
)
except asyncio.CancelledError:
raise
except Exception:
log.exception("music: stream error for %s", track.url)
await done.wait()
except asyncio.CancelledError:
pass
finally:
ps["current"] = None
ps["done_event"] = None
ps["task"] = None
def _ensure_loop(bot) -> None:
"""Start the play loop if not already running."""
ps = _ps(bot)
task = ps.get("task")
if task and not task.done():
return
ps["task"] = bot._spawn(_play_loop(bot), name="music-play-loop")
# -- Commands ----------------------------------------------------------------
@command("play", help="Music: !play <url>")
async def cmd_play(bot, message):
"""Play a URL or add to queue if already playing.
Usage:
!play <url> Play audio from URL (YouTube, SoundCloud, etc.)
"""
if not _is_mumble(bot):
await bot.reply(message, "Music playback is Mumble-only")
return
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !play <url>")
return
url = parts[1].strip()
ps = _ps(bot)
if len(ps["queue"]) >= _MAX_QUEUE:
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
return
loop = asyncio.get_running_loop()
title = await loop.run_in_executor(None, _resolve_title, url)
track = _Track(url=url, title=title, requester=message.nick or "?")
ps["queue"].append(track)
if ps["current"] is not None:
pos = len(ps["queue"])
await bot.reply(
message,
f"Queued #{pos}: {_truncate(title)}",
)
else:
await bot.reply(message, f"Playing: {_truncate(title)}")
_ensure_loop(bot)
@command("stop", help="Music: !stop")
async def cmd_stop(bot, message):
"""Stop playback and clear queue."""
if not _is_mumble(bot):
return
ps = _ps(bot)
ps["queue"].clear()
task = ps.get("task")
if task and not task.done():
task.cancel()
ps["current"] = None
ps["task"] = None
ps["done_event"] = None
await bot.reply(message, "Stopped")
@command("skip", help="Music: !skip")
async def cmd_skip(bot, message):
"""Skip current track, advance to next in queue."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["current"] is None:
await bot.reply(message, "Nothing playing")
return
task = ps.get("task")
if task and not task.done():
task.cancel()
skipped = ps["current"]
ps["current"] = None
ps["task"] = None
if ps["queue"]:
_ensure_loop(bot)
await bot.reply(
message,
f"Skipped: {_truncate(skipped.title)}",
)
else:
await bot.reply(message, "Skipped, queue empty")
@command("queue", help="Music: !queue [url]")
async def cmd_queue(bot, message):
"""Show queue or add a URL.
Usage:
!queue Show current queue
!queue <url> Add URL to queue (alias for !play)
"""
if not _is_mumble(bot):
return
parts = message.text.split(None, 1)
if len(parts) >= 2:
# Alias for !play
await cmd_play(bot, message)
return
ps = _ps(bot)
lines = []
if ps["current"]:
lines.append(
f"Now: {_truncate(ps['current'].title)}"
f" [{ps['current'].requester}]"
)
if ps["queue"]:
for i, track in enumerate(ps["queue"], 1):
lines.append(
f" {i}. {_truncate(track.title)} [{track.requester}]"
)
else:
if not ps["current"]:
lines.append("Queue empty")
for line in lines:
await bot.reply(message, line)
@command("np", help="Music: !np")
async def cmd_np(bot, message):
"""Show now-playing track."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["current"] is None:
await bot.reply(message, "Nothing playing")
return
track = ps["current"]
await bot.reply(
message,
f"Now playing: {_truncate(track.title)} [{track.requester}]",
)
@command("volume", help="Music: !volume [0-100]")
async def cmd_volume(bot, message):
"""Get or set playback volume.
Usage:
!volume Show current volume
!volume <0-100> Set volume (applies on next track)
"""
if not _is_mumble(bot):
return
ps = _ps(bot)
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, f"Volume: {ps['volume']}%")
return
try:
val = int(parts[1])
except ValueError:
await bot.reply(message, "Usage: !volume <0-100>")
return
if val < 0 or val > 100:
await bot.reply(message, "Volume must be 0-100")
return
ps["volume"] = val
await bot.reply(message, f"Volume set to {val}%")