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

@@ -35,6 +35,13 @@ class _FakeState:
return list(self._store.get(ns, {}).keys())
class _FakeRegistry:
"""Minimal registry with shared voice timestamp."""
def __init__(self):
self._voice_ts: float = 0.0
class _FakeBot:
"""Minimal bot for music plugin testing."""
@@ -45,6 +52,7 @@ class _FakeBot:
self.config: dict = {}
self._pstate: dict = {}
self._tasks: set[asyncio.Task] = set()
self.registry = _FakeRegistry()
if mumble:
self.stream_audio = AsyncMock()
@@ -299,6 +307,19 @@ class TestNpCommand:
asyncio.run(_mod.cmd_np(bot, msg))
assert any("Cool Song" in r for r in bot.replied)
assert any("DJ" in r for r in bot.replied)
assert any("0:00" in r for r in bot.replied)
def test_np_shows_elapsed(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(
url="x", title="Cool Song", requester="DJ",
)
ps["cur_seek"] = 60.0
ps["progress"] = [1500] # 1500 * 0.02 = 30s
msg = _Msg(text="!np")
asyncio.run(_mod.cmd_np(bot, msg))
assert any("1:30" in r for r in bot.replied)
# ---------------------------------------------------------------------------
@@ -527,6 +548,21 @@ class TestPlaylistExpansion:
assert tracks[0] == ("https://example.com/1", "First")
assert tracks[1] == ("https://example.com/2", "Second")
def test_resolve_tracks_preserves_playlist_url(self):
"""Video+playlist URL passes through to yt-dlp intact."""
result = MagicMock()
result.stdout = (
"https://youtube.com/watch?v=a\nFirst\n"
"https://youtube.com/watch?v=b\nSecond\n"
)
url = "https://www.youtube.com/watch?v=a&list=PLxyz&index=1"
with patch("subprocess.run", return_value=result) as mock_run:
tracks = _mod._resolve_tracks(url)
# URL must reach yt-dlp with &list= intact
called_url = mock_run.call_args[0][0][-1]
assert "list=PLxyz" in called_url
assert len(tracks) == 2
def test_resolve_tracks_error_fallback(self):
"""On error, returns [(url, url)]."""
with patch("subprocess.run", side_effect=Exception("fail")):
@@ -580,6 +616,56 @@ class TestResumeState:
bot.state.set("music", "resume", '{"title": "x"}')
assert _mod._load_resume(bot) is None
def test_save_strips_youtube_playlist_params(self):
"""_save_resume strips &list= and other playlist params from YouTube URLs."""
bot = _FakeBot()
track = _mod._Track(
url="https://www.youtube.com/watch?v=abc123&list=RDabc123&start_radio=1&pp=xyz",
title="Song", requester="Alice",
)
_mod._save_resume(bot, track, 60.0)
data = _mod._load_resume(bot)
assert data is not None
assert data["url"] == "https://www.youtube.com/watch?v=abc123"
def test_save_preserves_non_youtube_urls(self):
"""_save_resume leaves non-YouTube URLs unchanged."""
bot = _FakeBot()
track = _mod._Track(
url="https://soundcloud.com/artist/track?ref=playlist",
title="Song", requester="Alice",
)
_mod._save_resume(bot, track, 30.0)
data = _mod._load_resume(bot)
assert data["url"] == "https://soundcloud.com/artist/track?ref=playlist"
# ---------------------------------------------------------------------------
# TestStripPlaylistParams
# ---------------------------------------------------------------------------
class TestStripPlaylistParams:
def test_strips_list_param(self):
url = "https://www.youtube.com/watch?v=abc&list=PLxyz&index=3"
assert _mod._strip_playlist_params(url) == "https://www.youtube.com/watch?v=abc"
def test_strips_radio_params(self):
url = "https://www.youtube.com/watch?v=abc&list=RDabc&start_radio=1&pp=xyz"
assert _mod._strip_playlist_params(url) == "https://www.youtube.com/watch?v=abc"
def test_preserves_plain_url(self):
url = "https://www.youtube.com/watch?v=abc123"
assert _mod._strip_playlist_params(url) == "https://www.youtube.com/watch?v=abc123"
def test_non_youtube_unchanged(self):
url = "https://soundcloud.com/track?list=abc"
assert _mod._strip_playlist_params(url) == url
def test_youtu_be_without_v_param(self):
url = "https://youtu.be/abc123"
assert _mod._strip_playlist_params(url) == url
# ---------------------------------------------------------------------------
# TestResumeCommand
@@ -740,7 +826,7 @@ class TestDuckMonitor:
ps = _mod._ps(bot)
ps["duck_enabled"] = True
ps["duck_floor"] = 5
bot._last_voice_ts = time.monotonic()
bot.registry._voice_ts = time.monotonic()
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
@@ -760,7 +846,7 @@ class TestDuckMonitor:
ps["duck_floor"] = 1
ps["duck_restore"] = 10 # 10s total restore
ps["volume"] = 50
bot._last_voice_ts = time.monotonic() - 100
bot.registry._voice_ts = time.monotonic() - 100
ps["duck_vol"] = 1.0 # already ducked
async def _check():
@@ -783,7 +869,7 @@ class TestDuckMonitor:
ps["duck_floor"] = 1
ps["duck_restore"] = 1 # 1s restore -- completes quickly
ps["volume"] = 50
bot._last_voice_ts = time.monotonic() - 100
bot.registry._voice_ts = time.monotonic() - 100
ps["duck_vol"] = 1.0
async def _check():
@@ -805,14 +891,14 @@ class TestDuckMonitor:
ps["duck_floor"] = 5
ps["duck_restore"] = 30
ps["volume"] = 50
bot._last_voice_ts = time.monotonic() - 100
bot.registry._voice_ts = time.monotonic() - 100
ps["duck_vol"] = 30.0 # mid-restore
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
await asyncio.sleep(0.5)
# Simulate voice arriving now
bot._last_voice_ts = time.monotonic()
bot.registry._voice_ts = time.monotonic()
await asyncio.sleep(1.5)
assert ps["duck_vol"] == 5.0 # re-ducked to floor
task.cancel()
@@ -826,7 +912,7 @@ class TestDuckMonitor:
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = False
bot._last_voice_ts = time.monotonic()
bot.registry._voice_ts = time.monotonic()
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
@@ -850,7 +936,7 @@ class TestAutoResume:
"""Auto-resume loads saved state when channel is silent."""
bot = _FakeBot()
bot._connect_count = 2
bot._last_voice_ts = 0.0
bot.registry._voice_ts = 0.0
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
_mod._save_resume(bot, track, 120.0)
@@ -878,7 +964,7 @@ class TestAutoResume:
def test_no_resume_if_no_state(self):
"""Auto-resume returns early when nothing is saved."""
bot = _FakeBot()
bot._last_voice_ts = 0.0
bot.registry._voice_ts = 0.0
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._auto_resume(bot))
mock_loop.assert_not_called()
@@ -887,7 +973,7 @@ class TestAutoResume:
"""Auto-resume aborts if voice never goes silent within deadline."""
bot = _FakeBot()
now = time.monotonic()
bot._last_voice_ts = now
bot.registry._voice_ts = now
ps = _mod._ps(bot)
ps["duck_silence"] = 15
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
@@ -903,7 +989,7 @@ class TestAutoResume:
async def _fast_sleep(s):
mono_val[0] += s
bot._last_voice_ts = mono_val[0]
bot.registry._voice_ts = mono_val[0]
await _real_sleep(0)
with patch.object(time, "monotonic", side_effect=_fast_mono):
@@ -918,6 +1004,9 @@ class TestAutoResume:
"""Watcher detects connect_count increment and calls _auto_resume."""
bot = _FakeBot()
bot._connect_count = 1
# Resume state must exist for watcher to call _auto_resume
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
_mod._save_resume(bot, track, 60.0)
async def _check():
with patch.object(_mod, "_auto_resume", new_callable=AsyncMock) as mock_ar:
@@ -1014,6 +1103,111 @@ class TestAutoResume:
assert spawned.count("music-reconnect-watcher") == 1
# ---------------------------------------------------------------------------
# TestAutoplayKept
# ---------------------------------------------------------------------------
class TestAutoplayKept:
def test_shuffles_kept_tracks(self, tmp_path):
"""Autoplay loads kept tracks, shuffles, and starts playback."""
bot = _FakeBot()
bot.registry._voice_ts = 0.0
music_dir = tmp_path / "music"
music_dir.mkdir()
# Create two kept files
(music_dir / "a.opus").write_bytes(b"audio")
(music_dir / "b.opus").write_bytes(b"audio")
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/a", "title": "Track A",
"filename": "a.opus", "id": 1,
}))
bot.state.set("music", "keep:2", json.dumps({
"url": "https://example.com/b", "title": "Track B",
"filename": "b.opus", "id": 2,
}))
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._autoplay_kept(bot))
mock_loop.assert_called_once_with(bot)
ps = _mod._ps(bot)
assert len(ps["queue"]) == 2
titles = {t.title for t in ps["queue"]}
assert titles == {"Track A", "Track B"}
# All tracks marked keep=True
assert all(t.keep for t in ps["queue"])
def test_skips_when_already_playing(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="x", title="Playing", requester="a")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._autoplay_kept(bot))
mock_loop.assert_not_called()
def test_skips_when_no_kept_tracks(self):
bot = _FakeBot()
bot.registry._voice_ts = 0.0
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._autoplay_kept(bot))
mock_loop.assert_not_called()
def test_load_kept_tracks_skips_missing_files(self, tmp_path):
"""Tracks with missing local files are excluded."""
bot = _FakeBot()
music_dir = tmp_path / "music"
music_dir.mkdir()
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/a", "title": "Gone",
"filename": "missing.opus", "id": 1,
}))
with patch.object(_mod, "_MUSIC_DIR", music_dir):
tracks = _mod._load_kept_tracks(bot)
assert tracks == []
def test_watcher_autoplay_on_boot_no_resume(self):
"""Watcher triggers autoplay on boot when no resume state exists."""
bot = _FakeBot()
bot._connect_count = 0
async def _check():
with patch.object(_mod, "_autoplay_kept",
new_callable=AsyncMock) as mock_ap:
task = asyncio.create_task(_mod._reconnect_watcher(bot))
await asyncio.sleep(0.5)
bot._connect_count = 1
await asyncio.sleep(3)
mock_ap.assert_called_once_with(bot)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_watcher_autoplay_on_reconnect_no_resume(self):
"""Watcher triggers autoplay on reconnect when no resume state."""
bot = _FakeBot()
bot._connect_count = 1
async def _check():
with patch.object(_mod, "_autoplay_kept",
new_callable=AsyncMock) as mock_ap:
task = asyncio.create_task(_mod._reconnect_watcher(bot))
await asyncio.sleep(0.5)
bot._connect_count = 2
await asyncio.sleep(3)
mock_ap.assert_called_once_with(bot)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
# ---------------------------------------------------------------------------
# TestDownloadTrack
# ---------------------------------------------------------------------------
@@ -1143,6 +1337,49 @@ class TestKeepCommand:
assert track.keep is True
assert any("Keeping" in r for r in bot.replied)
def test_keep_duplicate_blocked(self, tmp_path):
bot = _FakeBot()
ps = _mod._ps(bot)
f = tmp_path / "abc123.opus"
f.write_bytes(b"audio")
track = _mod._Track(
url="https://example.com/v", title="t", requester="a",
local_path=f,
)
ps["current"] = track
# Pre-existing kept entry with same URL
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/v", "id": 1,
}))
bot.state.set("music", "keep_next_id", "2")
msg = _Msg(text="!keep")
asyncio.run(_mod.cmd_keep(bot, msg))
assert any("Already kept" in r for r in bot.replied)
assert any("#1" in r for r in bot.replied)
# ID counter should not have incremented
assert bot.state.get("music", "keep_next_id") == "2"
def test_keep_duplicate_with_playlist_params(self, tmp_path):
bot = _FakeBot()
ps = _mod._ps(bot)
f = tmp_path / "abc123.opus"
f.write_bytes(b"audio")
# Track URL has playlist cruft
track = _mod._Track(
url="https://www.youtube.com/watch?v=abc&list=RDabc&start_radio=1",
title="t", requester="a", local_path=f,
)
ps["current"] = track
# Existing entry stored with clean URL
bot.state.set("music", "keep:1", json.dumps({
"url": "https://www.youtube.com/watch?v=abc", "id": 1,
}))
bot.state.set("music", "keep_next_id", "2")
msg = _Msg(text="!keep")
asyncio.run(_mod.cmd_keep(bot, msg))
assert any("Already kept" in r for r in bot.replied)
assert bot.state.get("music", "keep_next_id") == "2"
def test_keep_non_mumble(self):
bot = _FakeBot(mumble=False)
msg = _Msg(text="!keep")
@@ -1267,50 +1504,43 @@ class TestSeekCommand:
def test_seek_absolute(self):
bot = _FakeBot()
ps = _mod._ps(bot)
track = _mod._Track(url="x", title="Song", requester="a")
ps["current"] = track
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
ps["current"] = _mod._Track(url="x", title="Song", requester="a")
ps["seek_req"] = [None]
ps["progress"] = [100]
msg = _Msg(text="!seek 1:30")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_seek(bot, msg))
mock_loop.assert_called_once_with(bot, seek=90.0)
assert ps["queue"][0] is track
asyncio.run(_mod.cmd_seek(bot, msg))
assert ps["seek_req"][0] == 90.0
assert ps["cur_seek"] == 90.0
assert ps["progress"][0] == 0
assert any("1:30" in r for r in bot.replied)
mock_task.cancel.assert_called_once()
def test_seek_relative_forward(self):
bot = _FakeBot()
ps = _mod._ps(bot)
track = _mod._Track(url="x", title="Song", requester="a")
ps["current"] = track
ps["current"] = _mod._Track(url="x", title="Song", requester="a")
ps["seek_req"] = [None]
ps["progress"] = [1500] # 1500 * 0.02 = 30s
ps["cur_seek"] = 60.0 # started at 60s
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!seek +30")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_seek(bot, msg))
# elapsed = 60 + 30 = 90, target = 90 + 30 = 120
mock_loop.assert_called_once_with(bot, seek=120.0)
asyncio.run(_mod.cmd_seek(bot, msg))
# elapsed = 60 + 30 = 90, target = 90 + 30 = 120
assert ps["seek_req"][0] == 120.0
assert ps["cur_seek"] == 120.0
assert ps["progress"][0] == 0
def test_seek_relative_backward_clamps(self):
bot = _FakeBot()
ps = _mod._ps(bot)
track = _mod._Track(url="x", title="Song", requester="a")
ps["current"] = track
ps["current"] = _mod._Track(url="x", title="Song", requester="a")
ps["seek_req"] = [None]
ps["progress"] = [500] # 500 * 0.02 = 10s
ps["cur_seek"] = 0.0
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!seek -30")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_seek(bot, msg))
# elapsed = 0 + 10 = 10, target = 10 - 30 = -20, clamped to 0
mock_loop.assert_called_once_with(bot, seek=0.0)
asyncio.run(_mod.cmd_seek(bot, msg))
# elapsed = 0 + 10 = 10, target = 10 - 30 = -20, clamped to 0
assert ps["seek_req"][0] == 0.0
assert ps["cur_seek"] == 0.0
assert ps["progress"][0] == 0
# ---------------------------------------------------------------------------