feat: voice profiles, rubberband FX, per-bot plugin filtering
- Add rubberband package to container for pitch-shifting FX - Split FX chain: rubberband CLI for pitch, ffmpeg for filters - Configurable voice profile (voice, fx, piper params) in [voice] - Extra bots inherit voice config (minus trigger) for own TTS - Greeting is voice-only, spoken directly by the greeting bot - Per-bot only_plugins/except_plugins filtering on Mumble - Alias plugin, core plugin tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
85
plugins/alias.py
Normal file
85
plugins/alias.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Plugin: user-defined command aliases (persistent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_NS = "alias"
|
||||
|
||||
|
||||
@command("alias", help="Aliases: !alias add|del|list|clear")
|
||||
async def cmd_alias(bot, message):
|
||||
"""Create short aliases for existing bot commands.
|
||||
|
||||
Usage:
|
||||
!alias add <name> <target> Create alias (e.g. !alias add s skip)
|
||||
!alias del <name> Remove alias
|
||||
!alias list Show all aliases
|
||||
!alias clear Remove all aliases (admin only)
|
||||
"""
|
||||
parts = message.text.split(None, 3)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !alias <add|del|list|clear> [args]")
|
||||
return
|
||||
|
||||
sub = parts[1].lower()
|
||||
|
||||
if sub == "add":
|
||||
if len(parts) < 4:
|
||||
await bot.reply(message, "Usage: !alias add <name> <target>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
target = parts[3].lower()
|
||||
|
||||
# Cannot shadow an existing registered command
|
||||
if name in bot.registry.commands:
|
||||
await bot.reply(message, f"'{name}' is already a registered command")
|
||||
return
|
||||
|
||||
# Cannot alias to another alias (single-level only)
|
||||
if bot.state.get(_NS, target) is not None:
|
||||
await bot.reply(message, f"'{target}' is itself an alias; no chaining")
|
||||
return
|
||||
|
||||
# Target must resolve to a real command
|
||||
if target not in bot.registry.commands:
|
||||
await bot.reply(message, f"unknown command: {target}")
|
||||
return
|
||||
|
||||
bot.state.set(_NS, name, target)
|
||||
await bot.reply(message, f"alias: {name} -> {target}")
|
||||
|
||||
elif sub == "del":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !alias del <name>")
|
||||
return
|
||||
name = parts[2].lower()
|
||||
if bot.state.delete(_NS, name):
|
||||
await bot.reply(message, f"alias removed: {name}")
|
||||
else:
|
||||
await bot.reply(message, f"no alias: {name}")
|
||||
|
||||
elif sub == "list":
|
||||
keys = bot.state.keys(_NS)
|
||||
if not keys:
|
||||
await bot.reply(message, "No aliases defined")
|
||||
return
|
||||
entries = []
|
||||
for key in sorted(keys):
|
||||
target = bot.state.get(_NS, key)
|
||||
entries.append(f"{key} -> {target}")
|
||||
await bot.reply(message, "Aliases: " + ", ".join(entries))
|
||||
|
||||
elif sub == "clear":
|
||||
if not bot._is_admin(message):
|
||||
await bot.reply(message, "Permission denied: clear requires admin")
|
||||
return
|
||||
count = bot.state.clear(_NS)
|
||||
await bot.reply(message, f"Cleared {count} alias(es)")
|
||||
|
||||
else:
|
||||
await bot.reply(message, "Usage: !alias <add|del|list|clear> [args]")
|
||||
@@ -174,6 +174,34 @@ async def cmd_admins(bot, message):
|
||||
await bot.reply(message, " | ".join(parts))
|
||||
|
||||
|
||||
@command("deaf", help="Toggle voice listener deaf on Mumble")
|
||||
async def cmd_deaf(bot, message):
|
||||
"""Toggle the voice listener's deaf state on Mumble.
|
||||
|
||||
Targets the bot with ``receive_sound = true`` (merlin) so that
|
||||
deafening stops ducking without affecting the music bot's playback.
|
||||
"""
|
||||
# Find the listener bot (receive_sound=true) among registered peers
|
||||
listener = None
|
||||
bots = getattr(bot.registry, "_bots", {})
|
||||
for peer in bots.values():
|
||||
if getattr(peer, "_receive_sound", False):
|
||||
listener = peer
|
||||
break
|
||||
mumble = getattr(listener or bot, "_mumble", None)
|
||||
if mumble is None:
|
||||
return
|
||||
myself = mumble.users.myself
|
||||
name = getattr(listener, "nick", "bot")
|
||||
if myself.get("self_deaf", False):
|
||||
myself.undeafen()
|
||||
myself.unmute()
|
||||
await bot.reply(message, f"{name}: undeafened")
|
||||
else:
|
||||
myself.deafen()
|
||||
await bot.reply(message, f"{name}: deafened")
|
||||
|
||||
|
||||
@command("state", help="Inspect plugin state: !state <list|get|del|clear> ...", admin=True)
|
||||
async def cmd_state(bot, message):
|
||||
"""Manage the plugin state store.
|
||||
|
||||
181
plugins/music.py
181
plugins/music.py
@@ -13,6 +13,7 @@ import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
@@ -53,6 +54,7 @@ def _ps(bot):
|
||||
"fade_vol": None,
|
||||
"fade_step": None,
|
||||
"history": [],
|
||||
"autoplay": cfg.get("autoplay", True),
|
||||
"_watcher_task": None,
|
||||
})
|
||||
|
||||
@@ -122,10 +124,27 @@ def _parse_seek(arg: str) -> tuple[str, float]:
|
||||
# -- Resume state persistence ------------------------------------------------
|
||||
|
||||
|
||||
def _strip_playlist_params(url: str) -> str:
|
||||
"""Strip playlist context params from a YouTube URL.
|
||||
|
||||
Keeps only the video identifier so resume/download targets the
|
||||
exact video instead of resolving through a radio mix or playlist.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if "youtube.com" not in parsed.netloc and "youtu.be" not in parsed.netloc:
|
||||
return url
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
# Keep only the video ID; drop list, index, start_radio, pp, etc.
|
||||
clean = {k: v for k, v in params.items() if k == "v"}
|
||||
if not clean:
|
||||
return url
|
||||
return urlunparse(parsed._replace(query=urlencode(clean, doseq=True)))
|
||||
|
||||
|
||||
def _save_resume(bot, track: _Track, elapsed: float) -> None:
|
||||
"""Persist current track and elapsed position for later resumption."""
|
||||
data = json.dumps({
|
||||
"url": track.origin or track.url,
|
||||
"url": _strip_playlist_params(track.url),
|
||||
"title": track.title,
|
||||
"requester": track.requester,
|
||||
"elapsed": round(elapsed, 2),
|
||||
@@ -157,6 +176,10 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
|
||||
|
||||
Handles both single videos and playlists. For playlists, returns up to
|
||||
``max_tracks`` individual entries. Falls back to ``[(url, url)]`` on error.
|
||||
|
||||
YouTube URLs with ``&list=`` are passed through intact so yt-dlp can
|
||||
resolve the full playlist. Playlist params are only stripped in
|
||||
``_save_resume()`` where we need the exact video for resume.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -234,18 +257,21 @@ def _download_track(url: str, track_id: str, title: str = "") -> Path | None:
|
||||
go to the cache dir and are cleaned up after playback unless kept.
|
||||
"""
|
||||
filename = _sanitize_filename(title, track_id) if title else track_id
|
||||
_MIN_CACHE_SIZE = 100 * 1024 # 100 KB -- skip partial downloads
|
||||
# Reuse existing kept or cached file
|
||||
for d in (_MUSIC_DIR, _CACHE_DIR):
|
||||
for name in (filename, track_id):
|
||||
existing = list(d.glob(f"{name}.*")) if d.is_dir() else []
|
||||
if existing:
|
||||
return existing[0]
|
||||
for f in existing:
|
||||
# Trust kept files; skip suspiciously small cache files
|
||||
if d == _MUSIC_DIR or f.stat().st_size >= _MIN_CACHE_SIZE:
|
||||
return f
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
template = str(_CACHE_DIR / f"{track_id}.%(ext)s")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "-f", "bestaudio", "-x", "-c", "--no-overwrites",
|
||||
"--no-warnings", "-o", template,
|
||||
"--no-playlist", "--no-warnings", "-o", template,
|
||||
"--print", "after_move:filepath", url],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
@@ -292,7 +318,7 @@ async def _duck_monitor(bot) -> None:
|
||||
ps["duck_vol"] = None
|
||||
restore_start = 0.0
|
||||
continue
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
ts = getattr(bot.registry, "_voice_ts", 0.0)
|
||||
if ts == 0.0:
|
||||
continue
|
||||
silence = time.monotonic() - ts
|
||||
@@ -346,7 +372,7 @@ async def _auto_resume(bot) -> None:
|
||||
deadline = time.monotonic() + 60
|
||||
silence_needed = ps.get("duck_silence", 15)
|
||||
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
ts = getattr(bot.registry, "_voice_ts", 0.0)
|
||||
if ts != 0.0 and time.monotonic() - ts < silence_needed:
|
||||
await bot.send("0",
|
||||
f"Resuming '{title}' at {pos} once silent for "
|
||||
@@ -356,7 +382,7 @@ async def _auto_resume(bot) -> None:
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(2)
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
ts = getattr(bot.registry, "_voice_ts", 0.0)
|
||||
if ts == 0.0:
|
||||
break
|
||||
if time.monotonic() - ts >= silence_needed:
|
||||
@@ -387,6 +413,76 @@ async def _auto_resume(bot) -> None:
|
||||
_ensure_loop(bot, seek=elapsed)
|
||||
|
||||
|
||||
def _load_kept_tracks(bot) -> list[_Track]:
|
||||
"""Load all kept tracks from state with valid local files."""
|
||||
tracks = []
|
||||
for key in bot.state.keys("music"):
|
||||
if not key.startswith("keep:"):
|
||||
continue
|
||||
raw = bot.state.get("music", key)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
filename = meta.get("filename", "")
|
||||
if not filename:
|
||||
continue
|
||||
fpath = _MUSIC_DIR / filename
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
tracks.append(_Track(
|
||||
url=meta.get("url", str(fpath)),
|
||||
title=meta.get("title") or filename,
|
||||
requester="autoplay",
|
||||
local_path=fpath,
|
||||
keep=True,
|
||||
))
|
||||
return tracks
|
||||
|
||||
|
||||
async def _autoplay_kept(bot) -> None:
|
||||
"""Shuffle kept tracks and start playback when idle after reconnect."""
|
||||
ps = _ps(bot)
|
||||
if ps["current"] is not None:
|
||||
return
|
||||
|
||||
kept = _load_kept_tracks(bot)
|
||||
if not kept:
|
||||
return
|
||||
|
||||
# Let pymumble fully stabilize
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# Wait for silence
|
||||
deadline = time.monotonic() + 60
|
||||
silence_needed = ps.get("duck_silence", 15)
|
||||
ts = getattr(bot.registry, "_voice_ts", 0.0)
|
||||
if ts != 0.0 and time.monotonic() - ts < silence_needed:
|
||||
await bot.send("0",
|
||||
f"Shuffling {len(kept)} kept tracks once silent")
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(2)
|
||||
ts = getattr(bot.registry, "_voice_ts", 0.0)
|
||||
if ts == 0.0:
|
||||
break
|
||||
if time.monotonic() - ts >= silence_needed:
|
||||
break
|
||||
else:
|
||||
log.info("music: autoplay aborted, channel not silent after 60s")
|
||||
return
|
||||
|
||||
if ps["current"] is not None:
|
||||
return
|
||||
|
||||
random.shuffle(kept)
|
||||
ps["queue"].extend(kept)
|
||||
log.info("music: autoplay %d kept tracks (shuffled)", len(kept))
|
||||
_ensure_loop(bot)
|
||||
|
||||
|
||||
async def _reconnect_watcher(bot) -> None:
|
||||
"""Poll for reconnections and trigger auto-resume.
|
||||
|
||||
@@ -399,30 +495,37 @@ async def _reconnect_watcher(bot) -> None:
|
||||
await asyncio.sleep(2)
|
||||
count = getattr(bot, "_connect_count", 0)
|
||||
|
||||
# Cold-start: resume saved state after first connection
|
||||
# Cold-start: resume or autoplay after first connection
|
||||
if not boot_checked and count >= 1:
|
||||
boot_checked = True
|
||||
if _load_resume(bot) is not None:
|
||||
log.info("music: saved state found on boot, attempting auto-resume")
|
||||
await _auto_resume(bot)
|
||||
continue
|
||||
elif _ps(bot).get("autoplay", True):
|
||||
await _autoplay_kept(bot)
|
||||
continue
|
||||
|
||||
if count > last_seen and count > 1:
|
||||
last_seen = count
|
||||
log.info("music: reconnection detected, attempting auto-resume")
|
||||
await _auto_resume(bot)
|
||||
if _load_resume(bot) is not None:
|
||||
log.info("music: reconnection detected, attempting auto-resume")
|
||||
await _auto_resume(bot)
|
||||
elif _ps(bot).get("autoplay", True):
|
||||
await _autoplay_kept(bot)
|
||||
last_seen = max(last_seen, count)
|
||||
|
||||
|
||||
# -- Play loop ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) -> None:
|
||||
"""Pop tracks from queue and stream them sequentially."""
|
||||
ps = _ps(bot)
|
||||
duck_task = bot._spawn(_duck_monitor(bot), name="music-duck-monitor")
|
||||
ps["duck_task"] = duck_task
|
||||
first = True
|
||||
seek_req = [None]
|
||||
ps["seek_req"] = seek_req
|
||||
try:
|
||||
while ps["queue"]:
|
||||
track = ps["queue"].pop(0)
|
||||
@@ -434,6 +537,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
ps["done_event"] = done
|
||||
|
||||
cur_seek = seek if first else 0.0
|
||||
if not first:
|
||||
fade_in = True # always fade in after first track
|
||||
first = False
|
||||
progress = [0]
|
||||
ps["progress"] = progress
|
||||
@@ -470,7 +575,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
seek=cur_seek,
|
||||
progress=progress,
|
||||
fade_step=lambda: ps.get("fade_step"),
|
||||
fade_in=True,
|
||||
fade_in=fade_in,
|
||||
seek_req=seek_req,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
elapsed = cur_seek + progress[0] * 0.02
|
||||
@@ -512,16 +618,17 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
ps["fade_step"] = None
|
||||
ps["progress"] = None
|
||||
ps["cur_seek"] = 0.0
|
||||
ps["seek_req"] = None
|
||||
|
||||
|
||||
def _ensure_loop(bot, *, seek: float = 0.0) -> None:
|
||||
def _ensure_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) -> None:
|
||||
"""Start the play loop if not already running."""
|
||||
ps = _ps(bot)
|
||||
task = ps.get("task")
|
||||
if task and not task.done():
|
||||
return
|
||||
ps["task"] = bot._spawn(
|
||||
_play_loop(bot, seek=seek), name="music-play-loop",
|
||||
_play_loop(bot, seek=seek, fade_in=fade_in), name="music-play-loop",
|
||||
)
|
||||
|
||||
|
||||
@@ -723,7 +830,7 @@ async def cmd_resume(bot, message):
|
||||
_ensure_loop(bot, seek=elapsed)
|
||||
|
||||
|
||||
@command("skip", help="Music: !skip")
|
||||
@command("skip", help="Music: !skip", aliases=["next"])
|
||||
async def cmd_skip(bot, message):
|
||||
"""Skip current track, advance to next in queue."""
|
||||
if not _is_mumble(bot):
|
||||
@@ -807,11 +914,6 @@ async def cmd_seek(bot, message):
|
||||
await bot.reply(message, "Usage: !seek <offset> (e.g. 1:30, +30, -30)")
|
||||
return
|
||||
|
||||
track = ps["current"]
|
||||
if track is None:
|
||||
await bot.reply(message, "Nothing playing")
|
||||
return
|
||||
|
||||
# Compute target position
|
||||
if mode == "abs":
|
||||
target = seconds
|
||||
@@ -823,12 +925,14 @@ async def cmd_seek(bot, message):
|
||||
|
||||
target = max(0.0, target)
|
||||
|
||||
# Re-insert current track at front of queue (local_path intact)
|
||||
ps["queue"].insert(0, track)
|
||||
|
||||
await _fade_and_cancel(bot)
|
||||
|
||||
_ensure_loop(bot, seek=target)
|
||||
seek_req = ps.get("seek_req")
|
||||
if not seek_req:
|
||||
await bot.reply(message, "Nothing playing")
|
||||
return
|
||||
seek_req[0] = target
|
||||
ps["cur_seek"] = target
|
||||
if ps.get("progress"):
|
||||
ps["progress"][0] = 0
|
||||
await bot.reply(message, f"Seeking to {_fmt_time(target)}")
|
||||
|
||||
|
||||
@@ -881,9 +985,13 @@ async def cmd_np(bot, message):
|
||||
return
|
||||
|
||||
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)
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Now playing: {_truncate(track.title)} [{track.requester}]",
|
||||
f"Now playing: {_truncate(track.title)} [{track.requester}]"
|
||||
f" ({_fmt_time(elapsed)})",
|
||||
)
|
||||
|
||||
|
||||
@@ -1047,6 +1155,23 @@ async def cmd_keep(bot, message):
|
||||
return
|
||||
track.keep = True
|
||||
|
||||
# Check if this track is already kept (by normalized URL)
|
||||
norm_url = _strip_playlist_params(track.url)
|
||||
for key in bot.state.keys("music"):
|
||||
if not key.startswith("keep:"):
|
||||
continue
|
||||
raw = bot.state.get("music", key)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
existing = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if _strip_playlist_params(existing.get("url", "")) == norm_url:
|
||||
kid = existing.get("id", key.split(":", 1)[1])
|
||||
await bot.reply(message, f"Already kept as #{kid}")
|
||||
return
|
||||
|
||||
# Assign a unique short ID
|
||||
last_id = int(bot.state.get("music", "keep_next_id") or "1")
|
||||
keep_id = last_id
|
||||
|
||||
257
plugins/voice.py
257
plugins/voice.py
@@ -54,6 +54,11 @@ def _ps(bot):
|
||||
"silence_gap": cfg.get("silence_gap", _SILENCE_GAP),
|
||||
"whisper_url": cfg.get("whisper_url", _WHISPER_URL),
|
||||
"piper_url": cfg.get("piper_url", _PIPER_URL),
|
||||
"voice": cfg.get("voice", ""),
|
||||
"length_scale": cfg.get("length_scale", 1.0),
|
||||
"noise_scale": cfg.get("noise_scale", 0.667),
|
||||
"noise_w": cfg.get("noise_w", 0.8),
|
||||
"fx": cfg.get("fx", ""),
|
||||
"_listener_registered": False,
|
||||
})
|
||||
|
||||
@@ -210,14 +215,30 @@ def _fetch_tts(piper_url: str, text: str) -> str | None:
|
||||
|
||||
|
||||
async def _tts_play(bot, text: str):
|
||||
"""Fetch TTS audio and play it via stream_audio."""
|
||||
"""Fetch TTS audio and play it via stream_audio.
|
||||
|
||||
Uses the configured voice profile (voice, fx, piper params) when set,
|
||||
otherwise falls back to Piper's default voice.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ps = _ps(bot)
|
||||
loop = asyncio.get_running_loop()
|
||||
wav_path = await loop.run_in_executor(
|
||||
None, _fetch_tts, ps["piper_url"], text,
|
||||
)
|
||||
if ps["voice"] or ps["fx"]:
|
||||
wav_path = await loop.run_in_executor(
|
||||
None, lambda: _fetch_tts_voice(
|
||||
ps["piper_url"], text,
|
||||
voice=ps["voice"],
|
||||
length_scale=ps["length_scale"],
|
||||
noise_scale=ps["noise_scale"],
|
||||
noise_w=ps["noise_w"],
|
||||
fx=ps["fx"],
|
||||
),
|
||||
)
|
||||
else:
|
||||
wav_path = await loop.run_in_executor(
|
||||
None, _fetch_tts, ps["piper_url"], text,
|
||||
)
|
||||
if wav_path is None:
|
||||
return
|
||||
try:
|
||||
@@ -322,26 +343,228 @@ async def cmd_say(bot, message):
|
||||
bot._spawn(_tts_play(bot, text), name="voice-tts")
|
||||
|
||||
|
||||
def _split_fx(fx: str) -> tuple[list[str], str]:
|
||||
"""Split FX chain into rubberband CLI args and ffmpeg filter string.
|
||||
|
||||
Alpine's ffmpeg lacks librubberband, so pitch shifting is handled by
|
||||
the ``rubberband`` CLI tool and remaining filters by ffmpeg.
|
||||
"""
|
||||
import math
|
||||
parts = fx.split(",")
|
||||
rb_args: list[str] = []
|
||||
ff_parts: list[str] = []
|
||||
for part in parts:
|
||||
if part.startswith("rubberband="):
|
||||
opts: dict[str, str] = {}
|
||||
for kv in part[len("rubberband="):].split(":"):
|
||||
k, _, v = kv.partition("=")
|
||||
opts[k] = v
|
||||
if "pitch" in opts:
|
||||
semitones = 12 * math.log2(float(opts["pitch"]))
|
||||
rb_args += ["--pitch", f"{semitones:.2f}"]
|
||||
if opts.get("formant") == "1":
|
||||
rb_args.append("--formant")
|
||||
else:
|
||||
ff_parts.append(part)
|
||||
return rb_args, ",".join(ff_parts)
|
||||
|
||||
|
||||
def _fetch_tts_voice(piper_url: str, text: str, *, voice: str = "",
|
||||
speaker_id: int = 0, length_scale: float = 1.0,
|
||||
noise_scale: float = 0.667, noise_w: float = 0.8,
|
||||
fx: str = "") -> str | None:
|
||||
"""Fetch TTS with explicit voice params and optional FX. Blocking.
|
||||
|
||||
Pitch shifting uses the ``rubberband`` CLI (Alpine ffmpeg has no
|
||||
librubberband); remaining audio filters go through ffmpeg.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
payload = {"text": text}
|
||||
if voice:
|
||||
payload["voice"] = voice
|
||||
if speaker_id:
|
||||
payload["speaker_id"] = speaker_id
|
||||
payload["length_scale"] = length_scale
|
||||
payload["noise_scale"] = noise_scale
|
||||
payload["noise_w"] = noise_w
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(piper_url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
resp = _urlopen(req, timeout=30, proxy=False)
|
||||
wav_data = resp.read()
|
||||
resp.close()
|
||||
if not wav_data:
|
||||
return None
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="derp_aud_", delete=False)
|
||||
tmp.write(wav_data)
|
||||
tmp.close()
|
||||
if not fx:
|
||||
return tmp.name
|
||||
|
||||
rb_args, ff_filters = _split_fx(fx)
|
||||
current = tmp.name
|
||||
|
||||
# Pitch shift via rubberband CLI
|
||||
if rb_args:
|
||||
rb_out = tempfile.NamedTemporaryFile(
|
||||
suffix=".wav", prefix="derp_aud_", delete=False,
|
||||
)
|
||||
rb_out.close()
|
||||
r = subprocess.run(
|
||||
["rubberband"] + rb_args + [current, rb_out.name],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
os.unlink(current)
|
||||
if r.returncode != 0:
|
||||
log.warning("voice: rubberband failed: %s", r.stderr[:200])
|
||||
os.unlink(rb_out.name)
|
||||
return None
|
||||
current = rb_out.name
|
||||
|
||||
# Remaining filters via ffmpeg
|
||||
if ff_filters:
|
||||
ff_out = tempfile.NamedTemporaryFile(
|
||||
suffix=".wav", prefix="derp_aud_", delete=False,
|
||||
)
|
||||
ff_out.close()
|
||||
r = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", current, "-af", ff_filters, ff_out.name],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
os.unlink(current)
|
||||
if r.returncode != 0:
|
||||
log.warning("voice: ffmpeg failed: %s", r.stderr[:200])
|
||||
os.unlink(ff_out.name)
|
||||
return None
|
||||
current = ff_out.name
|
||||
|
||||
return current
|
||||
|
||||
|
||||
@command("audition", help="Voice: !audition -- play voice samples", tier="admin")
|
||||
async def cmd_audition(bot, message):
|
||||
"""Play voice samples through Mumble for comparison."""
|
||||
if not _is_mumble(bot):
|
||||
return
|
||||
|
||||
ps = _ps(bot)
|
||||
piper_url = ps["piper_url"]
|
||||
phrase = "The sorcerer has arrived. I have seen things beyond your understanding."
|
||||
|
||||
# FX building blocks
|
||||
_deep = "rubberband=pitch=0.87:formant=1"
|
||||
_bass = "bass=g=6:f=110:w=0.6"
|
||||
_bass_heavy = "equalizer=f=80:t=h:w=150:g=8"
|
||||
_echo_subtle = "aecho=0.8:0.6:25|40:0.25|0.15"
|
||||
_echo_chamber = "aecho=0.8:0.88:60:0.35"
|
||||
_echo_cave = "aecho=0.8:0.7:40|70|100:0.3|0.2|0.1"
|
||||
|
||||
samples = [
|
||||
# -- Base voices (no FX) for reference
|
||||
("ryan-high raw", "en_US-ryan-high", 0, ""),
|
||||
("lessac-high raw", "en_US-lessac-high", 0, ""),
|
||||
# -- Deep pitch only
|
||||
("ryan deep", "en_US-ryan-high", 0,
|
||||
_deep),
|
||||
("lessac deep", "en_US-lessac-high", 0,
|
||||
_deep),
|
||||
# -- Deep + bass boost
|
||||
("ryan deep+bass", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass}"),
|
||||
("lessac deep+bass", "en_US-lessac-high", 0,
|
||||
f"{_deep},{_bass}"),
|
||||
# -- Deep + heavy bass
|
||||
("ryan deep+heavy bass", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass_heavy}"),
|
||||
# -- Deep + bass + subtle echo
|
||||
("ryan deep+bass+echo", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
("lessac deep+bass+echo", "en_US-lessac-high", 0,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
# -- Deep + bass + chamber reverb
|
||||
("ryan deep+bass+chamber", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass},{_echo_chamber}"),
|
||||
("lessac deep+bass+chamber", "en_US-lessac-high", 0,
|
||||
f"{_deep},{_bass},{_echo_chamber}"),
|
||||
# -- Deep + heavy bass + cave reverb
|
||||
("ryan deep+heavybass+cave", "en_US-ryan-high", 0,
|
||||
f"{_deep},{_bass_heavy},{_echo_cave}"),
|
||||
# -- Libritts best candidates with full sorcerer chain
|
||||
("libritts #20 deep+bass+echo", "en_US-libritts_r-medium", 20,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
("libritts #22 deep+bass+echo", "en_US-libritts_r-medium", 22,
|
||||
f"{_deep},{_bass},{_echo_subtle}"),
|
||||
("libritts #79 deep+bass+chamber", "en_US-libritts_r-medium", 79,
|
||||
f"{_deep},{_bass},{_echo_chamber}"),
|
||||
]
|
||||
|
||||
# Find merlin (the listener bot) -- plays the audition samples
|
||||
merlin = None
|
||||
for peer in getattr(bot.registry, "_bots", {}).values():
|
||||
if getattr(peer, "_receive_sound", False):
|
||||
merlin = peer
|
||||
break
|
||||
|
||||
await bot.reply(message, f"Auditioning {len(samples)} voice samples...")
|
||||
loop = asyncio.get_running_loop()
|
||||
from pathlib import Path
|
||||
|
||||
# Pre-generate derp's default voice (same phrase, no FX)
|
||||
derp_wav = await loop.run_in_executor(
|
||||
None, lambda: _fetch_tts_voice(piper_url, phrase),
|
||||
)
|
||||
|
||||
for i, (label, voice, sid, fx) in enumerate(samples, 1):
|
||||
announcer = merlin or bot
|
||||
await announcer.send("0", f"[{i}/{len(samples)}] {label}")
|
||||
await asyncio.sleep(1)
|
||||
# Generate the audition sample (merlin's candidate voice)
|
||||
sample_wav = await loop.run_in_executor(
|
||||
None, lambda v=voice, s=sid, f=fx: _fetch_tts_voice(
|
||||
piper_url, phrase, voice=v, speaker_id=s,
|
||||
length_scale=1.15, noise_scale=0.4, noise_w=0.5, fx=f,
|
||||
),
|
||||
)
|
||||
if sample_wav is None:
|
||||
await bot.send("0", " (failed)")
|
||||
continue
|
||||
try:
|
||||
# Both bots speak simultaneously:
|
||||
# merlin plays the audition sample, derp plays its default voice
|
||||
merlin_done = asyncio.Event()
|
||||
derp_done = asyncio.Event()
|
||||
if merlin:
|
||||
merlin_task = asyncio.create_task(
|
||||
merlin.stream_audio(sample_wav, volume=1.0,
|
||||
on_done=merlin_done))
|
||||
derp_task = asyncio.create_task(
|
||||
bot.stream_audio(derp_wav, volume=1.0,
|
||||
on_done=derp_done))
|
||||
await asyncio.gather(merlin_task, derp_task)
|
||||
else:
|
||||
await bot.stream_audio(sample_wav, volume=1.0,
|
||||
on_done=merlin_done)
|
||||
await merlin_done.wait()
|
||||
finally:
|
||||
Path(sample_wav).unlink(missing_ok=True)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if derp_wav:
|
||||
Path(derp_wav).unlink(missing_ok=True)
|
||||
announcer = merlin or bot
|
||||
await announcer.send("0", "Audition complete.")
|
||||
|
||||
|
||||
# -- Plugin lifecycle --------------------------------------------------------
|
||||
|
||||
|
||||
async def on_connected(bot) -> None:
|
||||
"""Re-register listener after reconnect; play TTS greeting on first join."""
|
||||
"""Re-register listener after reconnect."""
|
||||
if not _is_mumble(bot):
|
||||
return
|
||||
ps = _ps(bot)
|
||||
|
||||
# TTS greeting on first connect
|
||||
greet = bot.config.get("mumble", {}).get("greet")
|
||||
if greet and not ps.get("_greeted"):
|
||||
ps["_greeted"] = True
|
||||
# Wait for audio subsystem to be ready
|
||||
for _ in range(20):
|
||||
if bot._is_audio_ready():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
bot._spawn(_tts_play(bot, greet), name="voice-greet")
|
||||
|
||||
if ps["listen"] or ps["trigger"]:
|
||||
_ensure_listener(bot)
|
||||
_ensure_flush_task(bot)
|
||||
|
||||
Reference in New Issue
Block a user