feat: voice profiles, rubberband FX, per-bot plugin filtering

- Add rubberband package to container for pitch-shifting FX
- Split FX chain: rubberband CLI for pitch, ffmpeg for filters
- Configurable voice profile (voice, fx, piper params) in [voice]
- Extra bots inherit voice config (minus trigger) for own TTS
- Greeting is voice-only, spoken directly by the greeting bot
- Per-bot only_plugins/except_plugins filtering on Mumble
- Alias plugin, core plugin tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 11:41:00 +01:00
parent 3afeace6e7
commit e9d17e8b00
13 changed files with 1398 additions and 111 deletions

View File

@@ -13,6 +13,7 @@ import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from derp.plugin import command
@@ -53,6 +54,7 @@ def _ps(bot):
"fade_vol": None,
"fade_step": None,
"history": [],
"autoplay": cfg.get("autoplay", True),
"_watcher_task": None,
})
@@ -122,10 +124,27 @@ def _parse_seek(arg: str) -> tuple[str, float]:
# -- Resume state persistence ------------------------------------------------
def _strip_playlist_params(url: str) -> str:
"""Strip playlist context params from a YouTube URL.
Keeps only the video identifier so resume/download targets the
exact video instead of resolving through a radio mix or playlist.
"""
parsed = urlparse(url)
if "youtube.com" not in parsed.netloc and "youtu.be" not in parsed.netloc:
return url
params = parse_qs(parsed.query, keep_blank_values=True)
# Keep only the video ID; drop list, index, start_radio, pp, etc.
clean = {k: v for k, v in params.items() if k == "v"}
if not clean:
return url
return urlunparse(parsed._replace(query=urlencode(clean, doseq=True)))
def _save_resume(bot, track: _Track, elapsed: float) -> None:
"""Persist current track and elapsed position for later resumption."""
data = json.dumps({
"url": track.origin or track.url,
"url": _strip_playlist_params(track.url),
"title": track.title,
"requester": track.requester,
"elapsed": round(elapsed, 2),
@@ -157,6 +176,10 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
Handles both single videos and playlists. For playlists, returns up to
``max_tracks`` individual entries. Falls back to ``[(url, url)]`` on error.
YouTube URLs with ``&list=`` are passed through intact so yt-dlp can
resolve the full playlist. Playlist params are only stripped in
``_save_resume()`` where we need the exact video for resume.
"""
try:
result = subprocess.run(
@@ -234,18 +257,21 @@ def _download_track(url: str, track_id: str, title: str = "") -> Path | None:
go to the cache dir and are cleaned up after playback unless kept.
"""
filename = _sanitize_filename(title, track_id) if title else track_id
_MIN_CACHE_SIZE = 100 * 1024 # 100 KB -- skip partial downloads
# Reuse existing kept or cached file
for d in (_MUSIC_DIR, _CACHE_DIR):
for name in (filename, track_id):
existing = list(d.glob(f"{name}.*")) if d.is_dir() else []
if existing:
return existing[0]
for f in existing:
# Trust kept files; skip suspiciously small cache files
if d == _MUSIC_DIR or f.stat().st_size >= _MIN_CACHE_SIZE:
return f
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
template = str(_CACHE_DIR / f"{track_id}.%(ext)s")
try:
result = subprocess.run(
["yt-dlp", "-f", "bestaudio", "-x", "-c", "--no-overwrites",
"--no-warnings", "-o", template,
"--no-playlist", "--no-warnings", "-o", template,
"--print", "after_move:filepath", url],
capture_output=True, text=True, timeout=300,
)
@@ -292,7 +318,7 @@ async def _duck_monitor(bot) -> None:
ps["duck_vol"] = None
restore_start = 0.0
continue
ts = getattr(bot, "_last_voice_ts", 0.0)
ts = getattr(bot.registry, "_voice_ts", 0.0)
if ts == 0.0:
continue
silence = time.monotonic() - ts
@@ -346,7 +372,7 @@ async def _auto_resume(bot) -> None:
deadline = time.monotonic() + 60
silence_needed = ps.get("duck_silence", 15)
ts = getattr(bot, "_last_voice_ts", 0.0)
ts = getattr(bot.registry, "_voice_ts", 0.0)
if ts != 0.0 and time.monotonic() - ts < silence_needed:
await bot.send("0",
f"Resuming '{title}' at {pos} once silent for "
@@ -356,7 +382,7 @@ async def _auto_resume(bot) -> None:
while time.monotonic() < deadline:
await asyncio.sleep(2)
ts = getattr(bot, "_last_voice_ts", 0.0)
ts = getattr(bot.registry, "_voice_ts", 0.0)
if ts == 0.0:
break
if time.monotonic() - ts >= silence_needed:
@@ -387,6 +413,76 @@ async def _auto_resume(bot) -> None:
_ensure_loop(bot, seek=elapsed)
def _load_kept_tracks(bot) -> list[_Track]:
"""Load all kept tracks from state with valid local files."""
tracks = []
for key in bot.state.keys("music"):
if not key.startswith("keep:"):
continue
raw = bot.state.get("music", key)
if not raw:
continue
try:
meta = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
filename = meta.get("filename", "")
if not filename:
continue
fpath = _MUSIC_DIR / filename
if not fpath.is_file():
continue
tracks.append(_Track(
url=meta.get("url", str(fpath)),
title=meta.get("title") or filename,
requester="autoplay",
local_path=fpath,
keep=True,
))
return tracks
async def _autoplay_kept(bot) -> None:
"""Shuffle kept tracks and start playback when idle after reconnect."""
ps = _ps(bot)
if ps["current"] is not None:
return
kept = _load_kept_tracks(bot)
if not kept:
return
# Let pymumble fully stabilize
await asyncio.sleep(10)
# Wait for silence
deadline = time.monotonic() + 60
silence_needed = ps.get("duck_silence", 15)
ts = getattr(bot.registry, "_voice_ts", 0.0)
if ts != 0.0 and time.monotonic() - ts < silence_needed:
await bot.send("0",
f"Shuffling {len(kept)} kept tracks once silent")
while time.monotonic() < deadline:
await asyncio.sleep(2)
ts = getattr(bot.registry, "_voice_ts", 0.0)
if ts == 0.0:
break
if time.monotonic() - ts >= silence_needed:
break
else:
log.info("music: autoplay aborted, channel not silent after 60s")
return
if ps["current"] is not None:
return
random.shuffle(kept)
ps["queue"].extend(kept)
log.info("music: autoplay %d kept tracks (shuffled)", len(kept))
_ensure_loop(bot)
async def _reconnect_watcher(bot) -> None:
"""Poll for reconnections and trigger auto-resume.
@@ -399,30 +495,37 @@ async def _reconnect_watcher(bot) -> None:
await asyncio.sleep(2)
count = getattr(bot, "_connect_count", 0)
# Cold-start: resume saved state after first connection
# Cold-start: resume or autoplay after first connection
if not boot_checked and count >= 1:
boot_checked = True
if _load_resume(bot) is not None:
log.info("music: saved state found on boot, attempting auto-resume")
await _auto_resume(bot)
continue
elif _ps(bot).get("autoplay", True):
await _autoplay_kept(bot)
continue
if count > last_seen and count > 1:
last_seen = count
log.info("music: reconnection detected, attempting auto-resume")
await _auto_resume(bot)
if _load_resume(bot) is not None:
log.info("music: reconnection detected, attempting auto-resume")
await _auto_resume(bot)
elif _ps(bot).get("autoplay", True):
await _autoplay_kept(bot)
last_seen = max(last_seen, count)
# -- Play loop ---------------------------------------------------------------
async def _play_loop(bot, *, seek: float = 0.0) -> None:
async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) -> None:
"""Pop tracks from queue and stream them sequentially."""
ps = _ps(bot)
duck_task = bot._spawn(_duck_monitor(bot), name="music-duck-monitor")
ps["duck_task"] = duck_task
first = True
seek_req = [None]
ps["seek_req"] = seek_req
try:
while ps["queue"]:
track = ps["queue"].pop(0)
@@ -434,6 +537,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
ps["done_event"] = done
cur_seek = seek if first else 0.0
if not first:
fade_in = True # always fade in after first track
first = False
progress = [0]
ps["progress"] = progress
@@ -470,7 +575,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
seek=cur_seek,
progress=progress,
fade_step=lambda: ps.get("fade_step"),
fade_in=True,
fade_in=fade_in,
seek_req=seek_req,
)
except asyncio.CancelledError:
elapsed = cur_seek + progress[0] * 0.02
@@ -512,16 +618,17 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
ps["fade_step"] = None
ps["progress"] = None
ps["cur_seek"] = 0.0
ps["seek_req"] = None
def _ensure_loop(bot, *, seek: float = 0.0) -> None:
def _ensure_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) -> 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, seek=seek), name="music-play-loop",
_play_loop(bot, seek=seek, fade_in=fade_in), name="music-play-loop",
)
@@ -723,7 +830,7 @@ async def cmd_resume(bot, message):
_ensure_loop(bot, seek=elapsed)
@command("skip", help="Music: !skip")
@command("skip", help="Music: !skip", aliases=["next"])
async def cmd_skip(bot, message):
"""Skip current track, advance to next in queue."""
if not _is_mumble(bot):
@@ -807,11 +914,6 @@ async def cmd_seek(bot, message):
await bot.reply(message, "Usage: !seek <offset> (e.g. 1:30, +30, -30)")
return
track = ps["current"]
if track is None:
await bot.reply(message, "Nothing playing")
return
# Compute target position
if mode == "abs":
target = seconds
@@ -823,12 +925,14 @@ async def cmd_seek(bot, message):
target = max(0.0, target)
# Re-insert current track at front of queue (local_path intact)
ps["queue"].insert(0, track)
await _fade_and_cancel(bot)
_ensure_loop(bot, seek=target)
seek_req = ps.get("seek_req")
if not seek_req:
await bot.reply(message, "Nothing playing")
return
seek_req[0] = target
ps["cur_seek"] = target
if ps.get("progress"):
ps["progress"][0] = 0
await bot.reply(message, f"Seeking to {_fmt_time(target)}")
@@ -881,9 +985,13 @@ async def cmd_np(bot, message):
return
track = ps["current"]
progress = ps.get("progress")
cur_seek = ps.get("cur_seek", 0.0)
elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0)
await bot.reply(
message,
f"Now playing: {_truncate(track.title)} [{track.requester}]",
f"Now playing: {_truncate(track.title)} [{track.requester}]"
f" ({_fmt_time(elapsed)})",
)
@@ -1047,6 +1155,23 @@ async def cmd_keep(bot, message):
return
track.keep = True
# Check if this track is already kept (by normalized URL)
norm_url = _strip_playlist_params(track.url)
for key in bot.state.keys("music"):
if not key.startswith("keep:"):
continue
raw = bot.state.get("music", key)
if not raw:
continue
try:
existing = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if _strip_playlist_params(existing.get("url", "")) == norm_url:
kid = existing.get("id", key.split(":", 1)[1])
await bot.reply(message, f"Already kept as #{kid}")
return
# Assign a unique short ID
last_id = int(bot.state.get("music", "keep_next_id") or "1")
keep_id = last_id