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] | 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|||||||
144
plugins/music.py
144
plugins/music.py
@@ -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 --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user