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