diff --git a/TASKS.md b/TASKS.md index 8983fb5..e105d99 100644 --- a/TASKS.md +++ b/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 `, `!playlist load `) | +| 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 `, etc.) | | P2 | [ ] | Per-channel voice settings (different voice per Mumble channel) | ## Previous Sprint -- Performance: HTTP + Parsing (2026-02-22) diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index a0c8ce1..d3c882a 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -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 # 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 # Save current + queued tracks as named playlist +!playlist load # Append saved playlist to queue, start if idle +!playlist list # Show saved playlists with track counts +!playlist del # Delete a saved playlist ``` Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host. diff --git a/docs/USAGE.md b/docs/USAGE.md index 146e33a..87301de 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1623,13 +1623,17 @@ and voice transmission. !prev Go back to the previous track (fade-out) !seek 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 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 |clear|repair] List, remove, clear, or repair kept files !testtone Play 3-second 440Hz test tone +!playlist save Save current + queued tracks as named playlist +!playlist load Append saved playlist to queue, start if idle +!playlist list Show saved playlists with track counts +!playlist del 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 Set silence timeout in seconds (default: 15) !duck restore Set restore ramp duration in seconds (default: 30) ``` diff --git a/plugins/music.py b/plugins/music.py index 4ba338a..d3b4c55 100644 --- a/plugins/music.py +++ b/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 ") +async def cmd_playlist(bot, message): + """Save, load, list, or delete named playlists. + + Usage: + !playlist save Save current + queued tracks as a playlist + !playlist load Append saved playlist to queue and start playback + !playlist list Show saved playlists with track counts + !playlist del 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 ", + ) + return + + sub = parts[1].lower() + + if sub == "save": + if len(parts) < 3: + await bot.reply(message, "Usage: !playlist save ") + 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 ") + 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 ") + 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 ", + ) + + # -- Plugin lifecycle -------------------------------------------------------- diff --git a/plugins/voice.py b/plugins/voice.py index d3132c8..cbec897 100644 --- a/plugins/voice.py +++ b/plugins/voice.py @@ -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") diff --git a/src/derp/mumble.py b/src/derp/mumble.py index 170525a..3a76e2f 100644 --- a/src/derp/mumble.py +++ b/src/derp/mumble.py @@ -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.""" diff --git a/tests/test_music.py b/tests/test_music.py index 5757741..a9dd53a 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -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):