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:
user
2026-02-22 19:23:03 +01:00
parent 5d0e200fbe
commit 717bf59a05
7 changed files with 187 additions and 26 deletions

View File

@@ -9,8 +9,10 @@
| P0 | [x] | Instant packet-based ducking via pymumble sound callback (~20ms) | | 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] | Duck floor raised to 2% (keep music audible during voice) |
| P0 | [x] | Strip leading punctuation from voice trigger remainder | | P0 | [x] | Strip leading punctuation from voice trigger remainder |
| P1 | [ ] | Queue display improvements (`!queue` shows position, duration, total time) | | P0 | [x] | Fix greeting tests: move greet TTS to voice plugin `on_connected` |
| P1 | [ ] | Playlist save/load (`!playlist save <name>`, `!playlist load <name>`) | | 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) | | P2 | [ ] | Per-channel voice settings (different voice per Mumble channel) |
## Previous Sprint -- Performance: HTTP + Parsing (2026-02-22) ## Previous Sprint -- Performance: HTTP + Parsing (2026-02-22)

View File

@@ -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) !prev # Go back to previous track (fades out)
!seek 1:30 # Seek to position (also +30, -30) !seek 1:30 # Seek to position (also +30, -30)
!resume # Resume last stopped/skipped track !resume # Resume last stopped/skipped track
!queue # Show queue !queue # Show queue (with durations + totals)
!queue <url> # Add to queue (alias for !play) !queue <url> # Add to queue (alias for !play)
!np # Now playing !np # Now playing
!volume # Show current volume !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 # Show ducking status
!duck on # Enable voice ducking !duck on # Enable voice ducking
!duck off # Disable 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 silence 20 # Set silence timeout seconds (default 15)
!duck restore 45 # Set restore ramp duration seconds (default 30) !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. Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.

View File

@@ -1623,13 +1623,17 @@ and voice transmission.
!prev Go back to the previous track (fade-out) !prev Go back to the previous track (fade-out)
!seek <offset> Seek to position (1:30, 90, +30, -30) !seek <offset> Seek to position (1:30, 90, +30, -30)
!resume Resume last stopped/skipped track from saved position !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) !queue <url> Add to queue (alias for !play)
!np Now playing !np Now playing
!volume [0-100] Get/set volume (persisted across restarts) !volume [0-100] Get/set volume (persisted across restarts)
!keep Keep current track's audio file (with metadata) !keep Keep current track's audio file (with metadata)
!kept [rm <id>|clear|repair] List, remove, clear, or repair kept files !kept [rm <id>|clear|repair] List, remove, clear, or repair kept files
!testtone Play 3-second 440Hz test tone !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 - 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 Show ducking status and settings
!duck on Enable voice ducking !duck on Enable voice ducking
!duck off Disable 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 silence <sec> Set silence timeout in seconds (default: 15)
!duck restore <sec> Set restore ramp duration in seconds (default: 30) !duck restore <sec> Set restore ramp duration in seconds (default: 30)
``` ```

View File

@@ -1194,15 +1194,30 @@ async def cmd_queue(bot, message):
ps = _ps(bot) ps = _ps(bot)
lines = [] lines = []
if ps["current"]: 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( lines.append(
f"Now: {_truncate(ps['current'].title)}" f"Now: {_truncate(track.title)}"
f" [{ps['current'].requester}]" f" [{track.requester}] ({pos})"
) )
if ps["queue"]: if ps["queue"]:
total_dur = 0.0
for i, track in enumerate(ps["queue"], 1): 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( 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: else:
if not ps["current"]: if not ps["current"]:
lines.append("Queue empty") lines.append("Queue empty")
@@ -1659,6 +1674,129 @@ async def _kept_repair(bot, message) -> None:
await bot.reply(message, msg) 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 -------------------------------------------------------- # -- Plugin lifecycle --------------------------------------------------------

View File

@@ -47,9 +47,12 @@ _PIPER_URL = "http://192.168.129.9:5100/"
def _ps(bot): def _ps(bot):
"""Per-bot plugin runtime state.""" """Per-bot plugin runtime state."""
cfg = getattr(bot, "config", {}).get("voice", {}) 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", { return bot._pstate.setdefault("voice", {
"listen": False, "listen": False,
"trigger": cfg.get("trigger", ""), "trigger": trigger,
"buffers": {}, # {username: bytearray} "buffers": {}, # {username: bytearray}
"last_ts": {}, # {username: float monotonic} "last_ts": {}, # {username: float monotonic}
"flush_task": None, "flush_task": None,
@@ -62,6 +65,7 @@ def _ps(bot):
"noise_scale": cfg.get("noise_scale", 0.667), "noise_scale": cfg.get("noise_scale", 0.667),
"noise_w": cfg.get("noise_w", 0.8), "noise_w": cfg.get("noise_w", 0.8),
"fx": cfg.get("fx", ""), "fx": cfg.get("fx", ""),
"initial_prompt": cfg.get("initial_prompt", default_prompt),
"_listener_registered": False, "_listener_registered": False,
}) })
@@ -170,8 +174,17 @@ def _transcribe(ps, pcm: bytes) -> str:
).encode() + wav_data + ( ).encode() + wav_data + (
f"\r\n--{boundary}\r\n" f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="response_format"\r\n\r\n' f'Content-Disposition: form-data; name="response_format"\r\n\r\n'
f"json\r\n--{boundary}--\r\n" f"json"
).encode() ).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 = urllib.request.Request(ps["whisper_url"], data=body, method="POST")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
resp = _urlopen(req, timeout=30, proxy=False) resp = _urlopen(req, timeout=30, proxy=False)
@@ -613,10 +626,22 @@ async def cmd_audition(bot, message):
async def on_connected(bot) -> None: 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): if not _is_mumble(bot):
return return
ps = _ps(bot) ps = _ps(bot)
if ps["listen"] or ps["trigger"]: if ps["listen"] or ps["trigger"]:
_ensure_listener(bot) _ensure_listener(bot)
_ensure_flush_task(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")

View File

@@ -268,19 +268,7 @@ class MumbleBot:
await self._play_greet() await self._play_greet()
async def _play_greet(self) -> None: async def _play_greet(self) -> None:
"""Speak the greeting via TTS on connect (voice only, no text).""" """No-op: greeting is now handled by the voice plugin's on_connected."""
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")
def _on_disconnected(self) -> None: def _on_disconnected(self) -> None:
"""Callback from pymumble thread: connection lost.""" """Callback from pymumble thread: connection lost."""

View File

@@ -956,7 +956,7 @@ class TestDuckCommand:
msg = _Msg(text="!duck") msg = _Msg(text="!duck")
asyncio.run(_mod.cmd_duck(bot, msg)) asyncio.run(_mod.cmd_duck(bot, msg))
assert any("Duck:" in r for r in bot.replied) 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) assert any("restore=30s" in r for r in bot.replied)
def test_toggle_on(self): def test_toggle_on(self):