feat: playlist save/load, queue durations, whisper bias, greet fix
- Move TTS greeting from mumble._play_greet to voice.on_connected (fires once on first connect, gated on _is_audio_ready) - Add initial_prompt multipart field to Whisper STT for trigger word bias (auto-generated from trigger config, overridable) - Enhanced !queue: elapsed/total on now-playing, per-track durations, footer with track count and total time - New !playlist command: save/load/list/del named playlists via bot.state persistence (playlist:<name> keys) - Fix duck floor test (1% -> 2% to match default change) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
6
TASKS.md
6
TASKS.md
@@ -9,8 +9,10 @@
|
||||
| P0 | [x] | Instant packet-based ducking via pymumble sound callback (~20ms) |
|
||||
| P0 | [x] | Duck floor raised to 2% (keep music audible during voice) |
|
||||
| P0 | [x] | Strip leading punctuation from voice trigger remainder |
|
||||
| P1 | [ ] | Queue display improvements (`!queue` shows position, duration, total time) |
|
||||
| P1 | [ ] | Playlist save/load (`!playlist save <name>`, `!playlist load <name>`) |
|
||||
| P0 | [x] | Fix greeting tests: move greet TTS to voice plugin `on_connected` |
|
||||
| P0 | [x] | Whisper `initial_prompt` bias for trigger word recognition |
|
||||
| P1 | [x] | Queue display improvements (`!queue` shows elapsed/duration, totals) |
|
||||
| P1 | [x] | Playlist save/load/list/del (`!playlist save <name>`, etc.) |
|
||||
| P2 | [ ] | Per-channel voice settings (different voice per Mumble channel) |
|
||||
|
||||
## Previous Sprint -- Performance: HTTP + Parsing (2026-02-22)
|
||||
|
||||
@@ -574,7 +574,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
|
||||
!prev # Go back to previous track (fades out)
|
||||
!seek 1:30 # Seek to position (also +30, -30)
|
||||
!resume # Resume last stopped/skipped track
|
||||
!queue # Show queue
|
||||
!queue # Show queue (with durations + totals)
|
||||
!queue <url> # Add to queue (alias for !play)
|
||||
!np # Now playing
|
||||
!volume # Show current volume
|
||||
@@ -587,9 +587,13 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
|
||||
!duck # Show ducking status
|
||||
!duck on # Enable voice ducking
|
||||
!duck off # Disable voice ducking
|
||||
!duck floor 5 # Set duck floor volume (0-100, default 1)
|
||||
!duck floor 5 # Set duck floor volume (0-100, default 2)
|
||||
!duck silence 20 # Set silence timeout seconds (default 15)
|
||||
!duck restore 45 # Set restore ramp duration seconds (default 30)
|
||||
!playlist save <name> # Save current + queued tracks as named playlist
|
||||
!playlist load <name> # Append saved playlist to queue, start if idle
|
||||
!playlist list # Show saved playlists with track counts
|
||||
!playlist del <name> # Delete a saved playlist
|
||||
```
|
||||
|
||||
Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.
|
||||
|
||||
@@ -1623,13 +1623,17 @@ and voice transmission.
|
||||
!prev Go back to the previous track (fade-out)
|
||||
!seek <offset> Seek to position (1:30, 90, +30, -30)
|
||||
!resume Resume last stopped/skipped track from saved position
|
||||
!queue Show queue
|
||||
!queue Show queue (with durations + totals)
|
||||
!queue <url> Add to queue (alias for !play)
|
||||
!np Now playing
|
||||
!volume [0-100] Get/set volume (persisted across restarts)
|
||||
!keep Keep current track's audio file (with metadata)
|
||||
!kept [rm <id>|clear|repair] List, remove, clear, or repair kept files
|
||||
!testtone Play 3-second 440Hz test tone
|
||||
!playlist save <name> Save current + queued tracks as named playlist
|
||||
!playlist load <name> Append saved playlist to queue, start if idle
|
||||
!playlist list Show saved playlists with track counts
|
||||
!playlist del <name> Delete a saved playlist
|
||||
```
|
||||
|
||||
- Queue holds up to 50 tracks
|
||||
@@ -1712,7 +1716,7 @@ volume gradually restores to the user-set level in small steps.
|
||||
!duck Show ducking status and settings
|
||||
!duck on Enable voice ducking
|
||||
!duck off Disable voice ducking
|
||||
!duck floor <0-100> Set floor volume % (default: 1)
|
||||
!duck floor <0-100> Set floor volume % (default: 2)
|
||||
!duck silence <sec> Set silence timeout in seconds (default: 15)
|
||||
!duck restore <sec> Set restore ramp duration in seconds (default: 30)
|
||||
```
|
||||
|
||||
144
plugins/music.py
144
plugins/music.py
@@ -1194,15 +1194,30 @@ async def cmd_queue(bot, message):
|
||||
ps = _ps(bot)
|
||||
lines = []
|
||||
if ps["current"]:
|
||||
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)
|
||||
pos = _fmt_time(elapsed)
|
||||
if track.duration > 0:
|
||||
pos = f"{pos}/{_fmt_time(track.duration)}"
|
||||
lines.append(
|
||||
f"Now: {_truncate(ps['current'].title)}"
|
||||
f" [{ps['current'].requester}]"
|
||||
f"Now: {_truncate(track.title)}"
|
||||
f" [{track.requester}] ({pos})"
|
||||
)
|
||||
if ps["queue"]:
|
||||
total_dur = 0.0
|
||||
for i, track in enumerate(ps["queue"], 1):
|
||||
dur = f" ({_fmt_time(track.duration)})" if track.duration > 0 else ""
|
||||
total_dur += track.duration
|
||||
lines.append(
|
||||
f" {i}. {_truncate(track.title)} [{track.requester}]"
|
||||
f" {i}. {_truncate(track.title)} [{track.requester}]{dur}"
|
||||
)
|
||||
count = len(ps["queue"])
|
||||
footer = f"Queue: {count} track{'s' if count != 1 else ''}"
|
||||
if total_dur > 0:
|
||||
footer += f", {_fmt_time(total_dur)} total"
|
||||
lines.append(footer)
|
||||
else:
|
||||
if not ps["current"]:
|
||||
lines.append("Queue empty")
|
||||
@@ -1659,6 +1674,129 @@ async def _kept_repair(bot, message) -> None:
|
||||
await bot.reply(message, msg)
|
||||
|
||||
|
||||
@command("playlist", help="Music: !playlist save|load|list|del <name>")
|
||||
async def cmd_playlist(bot, message):
|
||||
"""Save, load, list, or delete named playlists.
|
||||
|
||||
Usage:
|
||||
!playlist save <name> Save current + queued tracks as a playlist
|
||||
!playlist load <name> Append saved playlist to queue and start playback
|
||||
!playlist list Show saved playlists with track counts
|
||||
!playlist del <name> Delete a saved playlist
|
||||
"""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Mumble-only feature")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
await bot.reply(
|
||||
message, "Usage: !playlist save|load|list|del <name>",
|
||||
)
|
||||
return
|
||||
|
||||
sub = parts[1].lower()
|
||||
|
||||
if sub == "save":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !playlist save <name>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
ps = _ps(bot)
|
||||
entries = []
|
||||
if ps["current"]:
|
||||
t = ps["current"]
|
||||
entries.append({"url": t.url, "title": t.title,
|
||||
"requester": t.requester})
|
||||
for t in ps["queue"]:
|
||||
entries.append({"url": t.url, "title": t.title,
|
||||
"requester": t.requester})
|
||||
if not entries:
|
||||
await bot.reply(message, "Nothing to save")
|
||||
return
|
||||
bot.state.set("music", f"playlist:{name}", json.dumps(entries))
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Saved playlist '{name}' ({len(entries)} track"
|
||||
f"{'s' if len(entries) != 1 else ''})",
|
||||
)
|
||||
|
||||
elif sub == "load":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !playlist load <name>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
raw = bot.state.get("music", f"playlist:{name}")
|
||||
if not raw:
|
||||
await bot.reply(message, f"No playlist named '{name}'")
|
||||
return
|
||||
try:
|
||||
entries = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
await bot.reply(message, f"Corrupt playlist '{name}'")
|
||||
return
|
||||
ps = _ps(bot)
|
||||
was_idle = ps["current"] is None
|
||||
added = 0
|
||||
for e in entries:
|
||||
if len(ps["queue"]) >= _MAX_QUEUE:
|
||||
break
|
||||
ps["queue"].append(_Track(
|
||||
url=e["url"], title=e.get("title", e["url"]),
|
||||
requester=e.get("requester", "?"),
|
||||
))
|
||||
added += 1
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Loaded '{name}': {added} track{'s' if added != 1 else ''}",
|
||||
)
|
||||
if was_idle:
|
||||
_ensure_loop(bot)
|
||||
|
||||
elif sub == "list":
|
||||
names = []
|
||||
for key in bot.state.keys("music"):
|
||||
if not key.startswith("playlist:"):
|
||||
continue
|
||||
pname = key.split(":", 1)[1]
|
||||
raw = bot.state.get("music", key)
|
||||
count = 0
|
||||
if raw:
|
||||
try:
|
||||
count = len(json.loads(raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
names.append((pname, count))
|
||||
if not names:
|
||||
await bot.reply(message, "No saved playlists")
|
||||
return
|
||||
names.sort()
|
||||
lines = [f"Playlists ({len(names)}):"]
|
||||
for pname, count in names:
|
||||
lines.append(
|
||||
f" {pname} ({count} track{'s' if count != 1 else ''})",
|
||||
)
|
||||
for line in lines:
|
||||
await bot.reply(message, line)
|
||||
|
||||
elif sub == "del":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !playlist del <name>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
raw = bot.state.get("music", f"playlist:{name}")
|
||||
if not raw:
|
||||
await bot.reply(message, f"No playlist named '{name}'")
|
||||
return
|
||||
bot.state.delete("music", f"playlist:{name}")
|
||||
await bot.reply(message, f"Deleted playlist '{name}'")
|
||||
|
||||
else:
|
||||
await bot.reply(
|
||||
message, "Usage: !playlist save|load|list|del <name>",
|
||||
)
|
||||
|
||||
|
||||
# -- Plugin lifecycle --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -47,9 +47,12 @@ _PIPER_URL = "http://192.168.129.9:5100/"
|
||||
def _ps(bot):
|
||||
"""Per-bot plugin runtime state."""
|
||||
cfg = getattr(bot, "config", {}).get("voice", {})
|
||||
trigger = cfg.get("trigger", "")
|
||||
# Bias Whisper toward the trigger word unless explicitly configured
|
||||
default_prompt = f"{trigger.capitalize()}, " if trigger else ""
|
||||
return bot._pstate.setdefault("voice", {
|
||||
"listen": False,
|
||||
"trigger": cfg.get("trigger", ""),
|
||||
"trigger": trigger,
|
||||
"buffers": {}, # {username: bytearray}
|
||||
"last_ts": {}, # {username: float monotonic}
|
||||
"flush_task": None,
|
||||
@@ -62,6 +65,7 @@ def _ps(bot):
|
||||
"noise_scale": cfg.get("noise_scale", 0.667),
|
||||
"noise_w": cfg.get("noise_w", 0.8),
|
||||
"fx": cfg.get("fx", ""),
|
||||
"initial_prompt": cfg.get("initial_prompt", default_prompt),
|
||||
"_listener_registered": False,
|
||||
})
|
||||
|
||||
@@ -170,8 +174,17 @@ def _transcribe(ps, pcm: bytes) -> str:
|
||||
).encode() + wav_data + (
|
||||
f"\r\n--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="response_format"\r\n\r\n'
|
||||
f"json\r\n--{boundary}--\r\n"
|
||||
f"json"
|
||||
).encode()
|
||||
# Bias Whisper toward the trigger word when configured
|
||||
prompt = ps.get("initial_prompt", "")
|
||||
if prompt:
|
||||
body += (
|
||||
f"\r\n--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="initial_prompt"\r\n\r\n'
|
||||
f"{prompt}"
|
||||
).encode()
|
||||
body += f"\r\n--{boundary}--\r\n".encode()
|
||||
req = urllib.request.Request(ps["whisper_url"], data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
resp = _urlopen(req, timeout=30, proxy=False)
|
||||
@@ -613,10 +626,22 @@ async def cmd_audition(bot, message):
|
||||
|
||||
|
||||
async def on_connected(bot) -> None:
|
||||
"""Re-register listener after reconnect."""
|
||||
"""Re-register listener after reconnect; play TTS greeting on first connect."""
|
||||
if not _is_mumble(bot):
|
||||
return
|
||||
ps = _ps(bot)
|
||||
if ps["listen"] or ps["trigger"]:
|
||||
_ensure_listener(bot)
|
||||
_ensure_flush_task(bot)
|
||||
|
||||
# Greet via TTS on first connection only
|
||||
greet = getattr(bot, "config", {}).get("mumble", {}).get("greet")
|
||||
if greet and not ps.get("_greeted"):
|
||||
ps["_greeted"] = True
|
||||
ready = getattr(bot, "_is_audio_ready", None)
|
||||
if ready:
|
||||
for _ in range(20):
|
||||
if ready():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
bot._spawn(_tts_play(bot, greet), name="voice-greet")
|
||||
|
||||
@@ -268,19 +268,7 @@ class MumbleBot:
|
||||
await self._play_greet()
|
||||
|
||||
async def _play_greet(self) -> None:
|
||||
"""Speak the greeting via TTS on connect (voice only, no text)."""
|
||||
greet = self.config.get("mumble", {}).get("greet")
|
||||
if not greet:
|
||||
return
|
||||
voice_mod = self.registry._modules.get("voice")
|
||||
tts_play = getattr(voice_mod, "_tts_play", None) if voice_mod else None
|
||||
if tts_play is None:
|
||||
return
|
||||
for _ in range(20):
|
||||
if self._is_audio_ready():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
self._spawn(tts_play(self, greet), name="voice-greet")
|
||||
"""No-op: greeting is now handled by the voice plugin's on_connected."""
|
||||
|
||||
def _on_disconnected(self) -> None:
|
||||
"""Callback from pymumble thread: connection lost."""
|
||||
|
||||
@@ -956,7 +956,7 @@ class TestDuckCommand:
|
||||
msg = _Msg(text="!duck")
|
||||
asyncio.run(_mod.cmd_duck(bot, msg))
|
||||
assert any("Duck:" in r for r in bot.replied)
|
||||
assert any("floor=1%" in r for r in bot.replied)
|
||||
assert any("floor=2%" in r for r in bot.replied)
|
||||
assert any("restore=30s" in r for r in bot.replied)
|
||||
|
||||
def test_toggle_on(self):
|
||||
|
||||
Reference in New Issue
Block a user