Compare commits
5 Commits
6083de13f9
...
8d54322ce1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d54322ce1 | ||
|
|
e920ec5f10 | ||
|
|
c522d30c36 | ||
|
|
068734d931 | ||
|
|
36da191b45 |
@@ -581,6 +581,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
|
||||
!volume 75 # Set volume (0-100, default 50)
|
||||
!keep # Keep current file + save metadata
|
||||
!kept # List kept files with metadata
|
||||
!kept rm <id> # Remove a single kept track
|
||||
!kept clear # Delete all kept files + metadata
|
||||
!kept repair # Re-download missing kept files
|
||||
!duck # Show ducking status
|
||||
|
||||
@@ -1628,7 +1628,7 @@ and voice transmission.
|
||||
!np Now playing
|
||||
!volume [0-100] Get/set volume (persisted across restarts)
|
||||
!keep Keep current track's audio file (with metadata)
|
||||
!kept [clear|repair] List kept files, clear all, or re-download missing
|
||||
!kept [rm <id>|clear|repair] List, remove, clear, or repair kept files
|
||||
!testtone Play 3-second 440Hz test tone
|
||||
```
|
||||
|
||||
@@ -1750,6 +1750,7 @@ file (natural dedup).
|
||||
duration) is fetched via yt-dlp and stored in `bot.state`
|
||||
- Use `!kept` to list preserved files with metadata (title, artist, duration,
|
||||
file size)
|
||||
- Use `!kept rm <id>` to remove a single kept track (file + metadata)
|
||||
- Use `!kept clear` to delete all preserved files and their metadata
|
||||
- Use `!kept repair` to re-download any kept tracks whose local files are
|
||||
missing (e.g. after a cleanup or volume mount issue)
|
||||
|
||||
@@ -48,7 +48,7 @@ def _ps(bot):
|
||||
"task": None,
|
||||
"done_event": None,
|
||||
"duck_enabled": cfg.get("duck_enabled", True),
|
||||
"duck_floor": cfg.get("duck_floor", 1),
|
||||
"duck_floor": cfg.get("duck_floor", 2),
|
||||
"duck_silence": cfg.get("duck_silence", 15),
|
||||
"duck_restore": cfg.get("duck_restore", 30),
|
||||
"duck_vol": None,
|
||||
@@ -79,6 +79,16 @@ def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
|
||||
return text[: max_len - 3].rstrip() + "..."
|
||||
|
||||
|
||||
_YT_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
||||
|
||||
|
||||
def _expand_video_id(text: str) -> str:
|
||||
"""Expand a bare YouTube video ID to a full URL."""
|
||||
if _YT_VIDEO_ID_RE.match(text):
|
||||
return f"https://www.youtube.com/watch?v={text}"
|
||||
return text
|
||||
|
||||
|
||||
def _is_url(text: str) -> bool:
|
||||
"""Check if text looks like a URL rather than a search query."""
|
||||
return text.startswith(("http://", "https://", "ytsearch:"))
|
||||
@@ -309,9 +319,13 @@ def _download_track(url: str, track_id: str, title: str = "") -> Path | None:
|
||||
|
||||
|
||||
def _cleanup_track(track: _Track) -> None:
|
||||
"""Delete the local audio file unless marked to keep."""
|
||||
"""Delete the local audio file unless marked to keep or in kept dir."""
|
||||
if track.local_path is None or track.keep:
|
||||
return
|
||||
# Never delete files from the kept directory -- they may have been
|
||||
# reused by _download_track for a non-kept playback of the same URL.
|
||||
if track.local_path.parent == _MUSIC_DIR:
|
||||
return
|
||||
try:
|
||||
track.local_path.unlink(missing_ok=True)
|
||||
log.info("music: deleted %s", track.local_path.name)
|
||||
@@ -844,6 +858,9 @@ async def cmd_play(bot, message):
|
||||
_ensure_loop(bot)
|
||||
return
|
||||
|
||||
# Expand bare YouTube video IDs (e.g. "U1yQMjFZ6j4")
|
||||
url = _expand_video_id(url)
|
||||
|
||||
# Strip #random fragment before URL classification / resolution
|
||||
shuffle = False
|
||||
if _is_url(url) and url.endswith("#random"):
|
||||
@@ -1074,14 +1091,7 @@ async def cmd_skip(bot, message):
|
||||
|
||||
await _fade_and_cancel(bot)
|
||||
|
||||
if ps["queue"]:
|
||||
_ensure_loop(bot)
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Skipped: {_truncate(skipped.title)}",
|
||||
)
|
||||
else:
|
||||
await bot.reply(message, "Skipped, queue empty")
|
||||
_ensure_loop(bot)
|
||||
|
||||
|
||||
@command("prev", help="Music: !prev -- play previous track")
|
||||
@@ -1405,8 +1415,20 @@ async def cmd_keep(bot, message):
|
||||
await bot.reply(message, "Nothing playing")
|
||||
return
|
||||
if track.local_path is None:
|
||||
await bot.reply(message, "No local file for current track")
|
||||
return
|
||||
if not track.url:
|
||||
await bot.reply(message, "No local file for current track")
|
||||
return
|
||||
# Download on the spot -- track was streaming without a local file
|
||||
loop = asyncio.get_running_loop()
|
||||
tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
|
||||
dl_path = await loop.run_in_executor(
|
||||
None, _download_track, track.url, tid, track.title,
|
||||
)
|
||||
if dl_path:
|
||||
track.local_path = dl_path
|
||||
else:
|
||||
await bot.reply(message, "Download failed, cannot keep track")
|
||||
return
|
||||
track.keep = True
|
||||
|
||||
# Check if this track is already kept (by normalized URL)
|
||||
@@ -1463,14 +1485,15 @@ async def cmd_keep(bot, message):
|
||||
await bot.reply(message, f"Keeping #{keep_id}: {label}")
|
||||
|
||||
|
||||
@command("kept", help="Music: !kept [clear|repair] -- list, clear, or repair kept files")
|
||||
@command("kept", help="Music: !kept [rm <id>|clear|repair] -- manage kept files")
|
||||
async def cmd_kept(bot, message):
|
||||
"""List, clear, or repair kept audio files in data/music/.
|
||||
"""List, clear, remove, or repair kept audio files in data/music/.
|
||||
|
||||
Usage:
|
||||
!kept List kept tracks with metadata and file status
|
||||
!kept clear Delete all kept files and metadata
|
||||
!kept repair Re-download kept tracks whose local files are missing
|
||||
!kept List kept tracks with metadata and file status
|
||||
!kept rm <id> Remove a single kept track by ID
|
||||
!kept clear Delete all kept files and metadata
|
||||
!kept repair Re-download kept tracks whose local files are missing
|
||||
"""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Mumble-only feature")
|
||||
@@ -1479,6 +1502,34 @@ async def cmd_kept(bot, message):
|
||||
parts = message.text.split()
|
||||
sub = parts[1].lower() if len(parts) >= 2 else ""
|
||||
|
||||
if sub == "rm":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !kept rm <id>")
|
||||
return
|
||||
kid = parts[2].lstrip("#")
|
||||
raw = bot.state.get("music", f"keep:{kid}")
|
||||
if not raw:
|
||||
await bot.reply(message, f"No kept track with ID #{kid}")
|
||||
return
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
filename = meta.get("filename", "")
|
||||
if filename:
|
||||
fpath = _MUSIC_DIR / filename
|
||||
fpath.unlink(missing_ok=True)
|
||||
bot.state.delete("music", f"keep:{kid}")
|
||||
title = meta.get("title") or filename or kid
|
||||
await bot.reply(message, f"Removed #{kid}: {_truncate(title)}")
|
||||
# Skip if this track is currently playing
|
||||
ps = _ps(bot)
|
||||
cur = ps.get("current")
|
||||
if cur and filename and cur.local_path and cur.local_path.name == filename:
|
||||
await _fade_and_cancel(bot)
|
||||
_ensure_loop(bot)
|
||||
return
|
||||
|
||||
if sub == "clear":
|
||||
count = 0
|
||||
if _MUSIC_DIR.is_dir():
|
||||
|
||||
@@ -11,6 +11,8 @@ import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -39,17 +41,6 @@ _WHISPER_URL = "http://192.168.129.9:8080/inference"
|
||||
_PIPER_URL = "http://192.168.129.9:5100/"
|
||||
|
||||
|
||||
def _find_voice_peer(bot):
|
||||
"""Find the voice-capable peer (the bot with 'voice' in only_plugins)."""
|
||||
bots = getattr(bot.registry, "_bots", {})
|
||||
for name, b in bots.items():
|
||||
if name == bot._username:
|
||||
continue
|
||||
if getattr(b, "_only_plugins", None) and "voice" in b._only_plugins:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
# -- Per-bot state -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -94,6 +85,50 @@ def _pcm_to_wav(pcm: bytes) -> bytes:
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# -- Acknowledge tone --------------------------------------------------------
|
||||
|
||||
_ACK_FREQ = (880, 1320) # A5 -> E6 ascending
|
||||
_ACK_NOTE_DUR = 0.15 # seconds per note
|
||||
_ACK_AMP = 12000 # gentle amplitude
|
||||
_ACK_FRAME = 960 # 20ms at 48kHz, matches Mumble native
|
||||
|
||||
|
||||
async def _ack_tone(bot) -> None:
|
||||
"""Play a short two-tone ascending chime via pymumble sound_output."""
|
||||
mu = getattr(bot, "_mumble", None)
|
||||
if mu is None:
|
||||
return
|
||||
so = mu.sound_output
|
||||
if so is None:
|
||||
return
|
||||
|
||||
# Unmute if self-muted (stream_audio handles re-mute later)
|
||||
if getattr(bot, "_self_mute", False):
|
||||
if bot._mute_task and not bot._mute_task.done():
|
||||
bot._mute_task.cancel()
|
||||
bot._mute_task = None
|
||||
try:
|
||||
mu.users.myself.unmute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
frames_per_note = int(_ACK_NOTE_DUR / 0.02) # 0.02s per frame
|
||||
for freq in _ACK_FREQ:
|
||||
for i in range(frames_per_note):
|
||||
samples = []
|
||||
for j in range(_ACK_FRAME):
|
||||
t = (i * _ACK_FRAME + j) / _SAMPLE_RATE
|
||||
samples.append(int(_ACK_AMP * math.sin(2 * math.pi * freq * t)))
|
||||
pcm = struct.pack(f"<{_ACK_FRAME}h", *samples)
|
||||
so.add_sound(pcm)
|
||||
while so.get_buffer_size() > 0.5:
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# Wait for tone to finish
|
||||
while so.get_buffer_size() > 0:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
# -- STT: Sound listener (pymumble thread) ----------------------------------
|
||||
|
||||
|
||||
@@ -181,13 +216,11 @@ async def _flush_monitor(bot):
|
||||
|
||||
trigger = ps["trigger"]
|
||||
if trigger and text.lower().startswith(trigger.lower()):
|
||||
remainder = text[len(trigger):].strip()
|
||||
remainder = text[len(trigger):].strip().lstrip(",.;:!?")
|
||||
if remainder:
|
||||
log.info("voice: trigger from %s: %s", name, remainder)
|
||||
# Route TTS through voice-capable peer if available
|
||||
speaker = _find_voice_peer(bot) or bot
|
||||
speaker._spawn(
|
||||
_tts_play(speaker, remainder), name="voice-tts",
|
||||
bot._spawn(
|
||||
_tts_play(bot, remainder), name="voice-tts",
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -256,8 +289,10 @@ async def _tts_play(bot, text: str):
|
||||
if wav_path is None:
|
||||
return
|
||||
try:
|
||||
# Signal music plugin to duck while TTS is playing
|
||||
# Signal music plugin to duck, wait for it to take effect
|
||||
bot.registry._tts_active = True
|
||||
await asyncio.sleep(1.5)
|
||||
await _ack_tone(bot)
|
||||
done = asyncio.Event()
|
||||
await bot.stream_audio(str(wav_path), volume=1.0, on_done=done)
|
||||
await done.wait()
|
||||
|
||||
@@ -287,6 +287,18 @@ class MumbleBot:
|
||||
log.warning("mumble: disconnected")
|
||||
self._last_voice_ts = 0.0
|
||||
|
||||
def _instant_duck(self) -> None:
|
||||
"""Snap music volume to duck floor immediately.
|
||||
|
||||
Called from pymumble thread on voice/unmute events so ducking
|
||||
takes effect on the next audio frame (~20ms) instead of waiting
|
||||
for the 1s duck monitor poll.
|
||||
"""
|
||||
for peer in getattr(self.registry, "_bots", {}).values():
|
||||
ps = getattr(peer, "_pstate", {}).get("music")
|
||||
if ps and ps.get("duck_enabled") and ps.get("task"):
|
||||
ps["duck_vol"] = float(ps["duck_floor"])
|
||||
|
||||
def _on_sound_received(self, user, sound_chunk) -> None:
|
||||
"""Callback from pymumble thread: voice audio received.
|
||||
|
||||
@@ -302,6 +314,7 @@ class MumbleBot:
|
||||
self.registry._voice_ts = self._last_voice_ts
|
||||
if prev == 0.0:
|
||||
log.info("mumble: first voice packet from %s", name or "?")
|
||||
self._instant_duck()
|
||||
for fn in self._sound_listeners:
|
||||
try:
|
||||
fn(user, sound_chunk)
|
||||
|
||||
@@ -126,6 +126,34 @@ class TestMumbleGuard:
|
||||
assert bot.replied == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExpandVideoId
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExpandVideoId:
|
||||
def test_bare_video_id(self):
|
||||
assert _mod._expand_video_id("U1yQMjFZ6j4") == \
|
||||
"https://www.youtube.com/watch?v=U1yQMjFZ6j4"
|
||||
|
||||
def test_id_with_hyphens_underscores(self):
|
||||
assert _mod._expand_video_id("dQw4w9WgXcQ") == \
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
|
||||
def test_too_short_not_expanded(self):
|
||||
assert _mod._expand_video_id("abc") == "abc"
|
||||
|
||||
def test_too_long_not_expanded(self):
|
||||
assert _mod._expand_video_id("abcdefghijkl") == "abcdefghijkl"
|
||||
|
||||
def test_full_url_not_expanded(self):
|
||||
url = "https://www.youtube.com/watch?v=U1yQMjFZ6j4"
|
||||
assert _mod._expand_video_id(url) == url
|
||||
|
||||
def test_search_query_not_expanded(self):
|
||||
assert _mod._expand_video_id("hello world") == "hello world"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestPlayCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -235,10 +263,10 @@ class TestSkipCommand:
|
||||
msg = _Msg(text="!skip")
|
||||
with patch.object(_mod, "_ensure_loop"):
|
||||
asyncio.run(_mod.cmd_skip(bot, msg))
|
||||
assert any("Skipped" in r for r in bot.replied)
|
||||
assert not bot.replied # skip is silent when queue has next track
|
||||
mock_task.cancel.assert_called_once()
|
||||
|
||||
def test_skip_empty_queue(self):
|
||||
def test_skip_empty_queue_restarts_loop(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["current"] = _mod._Track(url="a", title="Only", requester="x")
|
||||
@@ -246,8 +274,9 @@ class TestSkipCommand:
|
||||
mock_task.done.return_value = False
|
||||
ps["task"] = mock_task
|
||||
msg = _Msg(text="!skip")
|
||||
asyncio.run(_mod.cmd_skip(bot, msg))
|
||||
assert any("empty" in r.lower() for r in bot.replied)
|
||||
with patch.object(_mod, "_ensure_loop") as mock_loop:
|
||||
asyncio.run(_mod.cmd_skip(bot, msg))
|
||||
mock_loop.assert_called_once() # loop restarted for autoplay
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1507,6 +1536,20 @@ class TestCleanupTrack:
|
||||
track = _mod._Track(url="x", title="t", requester="a")
|
||||
_mod._cleanup_track(track) # should not raise
|
||||
|
||||
def test_cleanup_preserves_kept_dir_files(self, tmp_path):
|
||||
"""Cleanup never deletes files from the kept music directory."""
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
f = music_dir / "kept-track.opus"
|
||||
f.write_bytes(b"audio")
|
||||
track = _mod._Track(
|
||||
url="x", title="t", requester="a",
|
||||
local_path=f, keep=False,
|
||||
)
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||
_mod._cleanup_track(track)
|
||||
assert f.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestKeepCommand
|
||||
@@ -1520,14 +1563,44 @@ class TestKeepCommand:
|
||||
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||
assert any("Nothing playing" in r for r in bot.replied)
|
||||
|
||||
def test_keep_no_local_file(self):
|
||||
def test_keep_no_local_file_no_url(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["current"] = _mod._Track(url="x", title="t", requester="a")
|
||||
ps["current"] = _mod._Track(url="", title="t", requester="a")
|
||||
msg = _Msg(text="!keep")
|
||||
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||
assert any("No local file" in r for r in bot.replied)
|
||||
|
||||
def test_keep_downloads_when_no_local_file(self, tmp_path):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
track = _mod._Track(url="https://example.com/v", title="t",
|
||||
requester="a")
|
||||
ps["current"] = track
|
||||
dl_file = tmp_path / "abc.opus"
|
||||
dl_file.write_bytes(b"audio")
|
||||
music_dir = tmp_path / "kept"
|
||||
music_dir.mkdir()
|
||||
meta = {"title": "t", "artist": "", "duration": 0}
|
||||
msg = _Msg(text="!keep")
|
||||
with patch.object(_mod, "_download_track", return_value=dl_file), \
|
||||
patch.object(_mod, "_MUSIC_DIR", music_dir), \
|
||||
patch.object(_mod, "_fetch_metadata", return_value=meta):
|
||||
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||
assert track.keep is True
|
||||
assert track.local_path is not None
|
||||
assert any("Keeping" in r for r in bot.replied)
|
||||
|
||||
def test_keep_download_failure(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["current"] = _mod._Track(url="https://example.com/v", title="t",
|
||||
requester="a")
|
||||
msg = _Msg(text="!keep")
|
||||
with patch.object(_mod, "_download_track", return_value=None):
|
||||
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||
assert any("Download failed" in r for r in bot.replied)
|
||||
|
||||
def test_keep_marks_track(self, tmp_path):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
@@ -1653,6 +1726,64 @@ class TestKeptCommand:
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("MISSING" in r for r in bot.replied)
|
||||
|
||||
def test_kept_rm(self, tmp_path):
|
||||
bot = _FakeBot()
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "abc123.opus").write_bytes(b"audio")
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"title": "Test Track", "filename": "abc123.opus", "id": 1,
|
||||
}))
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||
msg = _Msg(text="!kept rm 1")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("Removed #1" in r for r in bot.replied)
|
||||
assert bot.state.get("music", "keep:1") is None
|
||||
assert not (music_dir / "abc123.opus").exists()
|
||||
|
||||
def test_kept_rm_with_hash(self, tmp_path):
|
||||
bot = _FakeBot()
|
||||
bot.state.set("music", "keep:3", json.dumps({
|
||||
"title": "Track 3", "filename": "t3.opus", "id": 3,
|
||||
}))
|
||||
with patch.object(_mod, "_MUSIC_DIR", tmp_path):
|
||||
msg = _Msg(text="!kept rm #3")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("Removed #3" in r for r in bot.replied)
|
||||
assert bot.state.get("music", "keep:3") is None
|
||||
|
||||
def test_kept_rm_skips_if_playing(self, tmp_path):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
f = music_dir / "abc123.opus"
|
||||
f.write_bytes(b"audio")
|
||||
track = _mod._Track(url="x", title="t", requester="a",
|
||||
local_path=f, keep=True)
|
||||
ps["current"] = track
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"title": "Test Track", "filename": "abc123.opus", "id": 1,
|
||||
}))
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
|
||||
patch.object(_mod, "_ensure_loop") as mock_loop:
|
||||
msg = _Msg(text="!kept rm 1")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("Removed #1" in r for r in bot.replied)
|
||||
mock_loop.assert_called_once() # restarts loop for autoplay
|
||||
|
||||
def test_kept_rm_missing_id(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!kept rm")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("Usage" in r for r in bot.replied)
|
||||
|
||||
def test_kept_rm_not_found(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!kept rm 99")
|
||||
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||
assert any("No kept track" in r for r in bot.replied)
|
||||
|
||||
def test_kept_non_mumble(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
msg = _Msg(text="!kept")
|
||||
|
||||
Reference in New Issue
Block a user