diff --git a/ttstoy/ttstoy.py b/ttstoy/ttstoy.py index fc32d37..de7338d 100644 --- a/ttstoy/ttstoy.py +++ b/ttstoy/ttstoy.py @@ -12,6 +12,11 @@ from pathlib import Path import lavalink from typing import Optional +try: + import fcntl as _fcntl +except ImportError: # pragma: no cover - Windows + _fcntl = None + import discord from discord.ext import tasks import requests @@ -384,7 +389,6 @@ class TtsToy(Cog): self._health_server: HTTPServer | None = None self._health_thread: threading.Thread | None = None - self._webui_process = None # subprocess for the Flask web UI self.config: Config = Config.get_conf( self, identifier=0x0A0A0A0A, force_registration=True @@ -437,7 +441,7 @@ class TtsToy(Cog): self.clear_old_tts.stop() if self._webui_state_processor.is_running(): self._webui_state_processor.stop() - await self._stop_webui() + self._kill_leftover_webui() await self._stop_dectalk_server() await self._stop_morshu_server() await self._stop_vox_server() @@ -455,6 +459,20 @@ class TtsToy(Cog): if self.tts_processors: await asyncio.gather(*self.tts_processors.values(), return_exceptions=True) + def _kill_leftover_webui(self): + """Terminate any web UI subprocess left over from before the web UI + moved out of the bot. It ran from /webui/app.py, which no + longer exists — so a matching process is guaranteed stale.""" + try: + pattern = str(Path(__file__).resolve().parent / "webui" / "app.py") + result = subprocess.run( + ["pkill", "-f", pattern], capture_output=True, text=True + ) + if result.returncode == 0: + log.info("[WebUI] Killed leftover web UI subprocess") + except Exception as e: + log.warning(f"[WebUI] Could not kill leftover web UI subprocess: {e}") + # ----------------------------------------- async def _get_minimax_settings(self): return ( @@ -3088,40 +3106,31 @@ class TtsToy(Cog): @ttstoy_group.command(name="webuistatus") @commands.is_owner() async def webui_status_cmd(self, ctx: commands.Context): - """Check the status of the TtsToy web UI.""" + """Check the status of the TtsToy web UI (now a scrapyard service).""" webui_url = self._get_webui_url() - process_running = self._webui_process and self._webui_process.poll() is None - pid = self._webui_process.pid if process_running else None # Try to hit the login page reachable = False try: - r = await asyncio.to_thread(requests.get, f"{webui_url}/login", timeout=3) + r = await asyncio.to_thread(requests.get, f"{webui_url.rstrip('/')}/login", timeout=3) reachable = r.status_code == 200 except Exception: pass lines = [ "**TtsToy Web UI Status**", - f"Process running: {'✅ Yes' if process_running else '❌ No'}{f' (pid {pid})' if pid else ''}", f"Reachable at URL: {'✅ Yes' if reachable else '❌ No'}", f"URL: {webui_url}", + "The web UI now runs as a scrapyard service (not a bot subprocess).", + f"Restart with: `sudo systemctl restart scrapyard`", ] - if not process_running: - lines.append(f"\nRestart with: `{ctx.clean_prefix}ttstoy webuirestart`") await ctx.send("\n".join(lines)) @ttstoy_group.command(name="webuirestart") @commands.is_owner() async def webui_restart_cmd(self, ctx: commands.Context): - """Restart the TtsToy web UI.""" - await ctx.send("🔄 Restarting web UI...") - await self._stop_webui() - success = await self._start_webui() - if success: - await ctx.send(f"✅ Web UI restarted at {self._get_webui_url()}") - else: - await ctx.send("❌ Failed to start web UI. Check bot logs.") + """Restart the TtsToy web UI (now a scrapyard service).""" + await ctx.send("🔄 The web UI is now a scrapyard service.\nRestart it with: `sudo systemctl restart scrapyard`") # ------------------------------------------------------------------------- # Background task — poll shared state for pending commands and posts @@ -3131,38 +3140,13 @@ class TtsToy(Cog): async def _webui_state_processor(self): """Poll the shared state file and execute pending commands/posts from the web UI.""" try: - state = self._read_shared_state() - changed = False + pending_cmds, pending_posts = await asyncio.to_thread(self._take_pending_state_updates) - # --- Process pending commands (voice/mode changes from web UI) --- - pending_cmds = state.pop("pending_commands", []) - if pending_cmds: - changed = True - for cmd in pending_cmds: - await self._handle_webui_command(cmd) + for cmd in pending_cmds: + await self._handle_webui_command(cmd) - # --- Process pending posts (TTS audio to post to Discord channel) --- - pending_posts = state.pop("pending_posts", []) - if pending_posts: - changed = True - for post in pending_posts: - await self._handle_webui_post(post) - - # --- Write updated user info so the profile page stays fresh --- - users_info = state.get("users", {}) - for guild in self.bot.guilds: - for member in guild.members: - uid = str(member.id) - if uid not in users_info: - users_info[uid] = {} - users_info[uid]["display_name"] = member.display_name - users_info[uid]["username"] = str(member) - users_info[uid]["joined_at"] = str(member.joined_at)[:10] if member.joined_at else "unknown" - users_info[uid]["roles"] = ", ".join(r.name for r in member.roles if r.name != "@everyone") - state["users"] = users_info - - if changed or True: # always write to keep user info fresh - self._write_shared_state(state) + for post in pending_posts: + await self._handle_webui_post(post) # Reload emoji→SFX mapping in case it was changed via the web UI _reload_emoji_sfx_map() @@ -3170,6 +3154,40 @@ class TtsToy(Cog): except Exception as e: log.exception("[WebUI] State processor error: %s", e) + def _take_pending_state_updates(self): + """Under a cross-process lock: pop pending commands/posts and refresh user info. + + Returns (pending_cmds, pending_posts). Runs in a worker thread — the + lock matches the one the web UI uses when appending, so no updates are + lost between the read and the write. + """ + lock_file = Path(os.environ.get("TTSTOY_SHARED_STATE_LOCK", "/tmp/ttstoy_webui_state.lock")) + with open(lock_file, "w") as lf: + if _fcntl is not None: + _fcntl.flock(lf, _fcntl.LOCK_EX) + try: + state = self._read_shared_state() + pending_cmds = state.pop("pending_commands", []) + pending_posts = state.pop("pending_posts", []) + + # Refresh user info so the profile page stays current + users_info = state.get("users", {}) + for guild in self.bot.guilds: + for member in guild.members: + uid = str(member.id) + info = users_info.setdefault(uid, {}) + info["display_name"] = member.display_name + info["username"] = str(member) + info["joined_at"] = str(member.joined_at)[:10] if member.joined_at else "unknown" + info["roles"] = ", ".join(r.name for r in member.roles if r.name != "@everyone") + state["users"] = users_info + + self._write_shared_state(state) + finally: + if _fcntl is not None: + _fcntl.flock(lf, _fcntl.LOCK_UN) + return pending_cmds, pending_posts + async def _handle_webui_command(self, cmd: dict): """Execute a command queued by the web UI.""" user_id = int(cmd.get("user_id", 0)) @@ -3182,10 +3200,7 @@ class TtsToy(Cog): return try: - if action == "speak_in_vc": - await self._webui_speak_in_vc(user, payload) - - elif action == "set_voice": + if action == "set_voice": voice = payload.get("voice", "") await self.config.user(user).minimax_voice.set(voice) log.info(f"[WebUI] Set voice for {user} → {voice}") @@ -3245,159 +3260,40 @@ class TtsToy(Cog): except Exception as e: log.exception(f"[WebUI] Error handling command {action} for user {user_id}: {e}") - async def _webui_speak_in_vc(self, user: discord.User, payload: dict): - """Generate TTS from a web UI request and play it in the user's current VC.""" - job_id = payload.get("job_id") - text = payload.get("text", "") - discord_name = payload.get("user", str(user)) - mode_override = payload.get("mode_override") # per-request mode from web UI - voice_override = payload.get("voice_override") # per-request voice from web UI - skip_vc = payload.get("skip_vc", False) - skip_post = payload.get("skip_post", False) - guild_id_str = payload.get("guild_id", "") - log.info(f"[WebUI] speak_in_vc: user={user} job={job_id} guild={guild_id_str} mode_override={mode_override} voice_override={voice_override} skip_vc={skip_vc} skip_post={skip_post} text={text[:60]!r}") - - webui_url = self._get_webui_internal_url() - secret = self._get_internal_secret() - - def _update_job(status, **kwargs): - try: - requests.post( - f"{webui_url.rstrip('/')}/api/tts/{job_id}/update", - headers={"X-Internal-Secret": secret, "Content-Type": "application/json"}, - json={"status": status, **kwargs}, - timeout=5, - ) - log.debug(f"[WebUI] Job {job_id} updated → {status}") - except Exception as e: - log.warning(f"[WebUI] Could not update job {job_id}: {e}") - - # Find which guild/VC the user is currently in - # Prefer the guild they logged in from - member_vc = None - member_guild = None - target_guild_id = int(guild_id_str) if guild_id_str else None - if target_guild_id: - guild = self.bot.get_guild(target_guild_id) - if guild: - member_guild = guild - member = guild.get_member(user.id) - if member and member.voice and member.voice.channel: - member_vc = member.voice.channel - # Fallback: search all guilds for VC presence - if not member_vc: - for guild in self.bot.guilds: - member = guild.get_member(user.id) - if member and member.voice and member.voice.channel: - member_vc = member.voice.channel - if not member_guild: - member_guild = guild - break - - if not member_vc: - log.info(f"[WebUI] {user} is not in a VC — will generate audio only") - - log.info(f"[WebUI] Target VC: {member_vc} in {member_guild}") - - # Gather TTS settings — respect web UI overrides - mode = mode_override if mode_override else await self._get_tts_mode() - chatterbox_url = await self._get_chatterbox_api_url() - dectalk_url = await self._get_dectalk_api_url() - api_key, model, _ = await self._get_minimax_settings() - # Voice: use override if provided, otherwise user's saved voice - if voice_override: - voice_id = voice_override - else: - voice_id = await self._get_effective_minimax_voice(user) - sfx_volume = await self.config.sfx_volume() - vox_pack = await self.config.vox_pack() - cb_temp = cb_exag = cb_speed = None - cb_vol_db = 0.0 - if mode == "chatterbox": - cb_temp = (await self.config.user(user).chatterbox_temperature_offsets()).get(voice_id) - cb_exag = (await self.config.user(user).chatterbox_exaggeration_offsets()).get(voice_id) - cb_vol_db = (await self.config.user(user).chatterbox_volume_offsets()).get(voice_id, 0.0) - cb_speed = (await self.config.user(user).chatterbox_speed_offsets()).get(voice_id) - - log.debug(f"[WebUI] TTS params: mode={mode} voice={voice_id} temp={cb_temp} exag={cb_exag}") - - self.tts_storage.mkdir(parents=True, exist_ok=True) - audio_path = str(self.tts_storage / f"webui_{job_id}.mp3") - - all_cb_voices = await self._get_all_chatterbox_voices() - - try: - await asyncio.to_thread( - self._save_prompt_audio, - api_key, model, voice_id, text, audio_path, - sfx_volume, mode, dectalk_url, vox_pack, - chatterbox_url, cb_temp, cb_exag, cb_vol_db, cb_speed, - all_cb_voices, - ) - log.info(f"[WebUI] Audio generated: {audio_path}") - except Exception as e: - log.exception(f"[WebUI] TTS generation failed for job {job_id}: {e}") - await asyncio.to_thread(_update_job, "error", error=str(e)) - return - - # Queue for VC playback only if user is in a voice channel and skip_vc is not set - if member_vc and member_guild and not skip_vc: - try: - audio_cog: Optional[Audio] = self.bot.get_cog("Audio") - if audio_cog: - text_channel = member_guild.system_channel or next( - (c for c in member_guild.text_channels - if c.permissions_for(member_guild.me).send_messages), None - ) - if text_channel: - if not member_guild.me.voice or member_guild.me.voice.channel != member_vc: - await member_vc.connect() - - class _FakeCtx: - guild = member_guild - channel = text_channel - author = member_guild.get_member(user.id) or user - interaction = None - async def react_quietly(self, *a, **kw): pass - - queue = self._get_or_create_tts_queue(member_guild.id) - await queue.put((_FakeCtx(), audio_path)) - log.info(f"[WebUI] Queued audio for VC playback: job={job_id}") - except Exception as e: - log.exception(f"[WebUI] VC playback failed for job {job_id}: {e}") - - # Post transcript to Discord channel and mark job done - # Resolve the best guild ID for posting: login guild > VC guild > empty - post_guild_id = guild_id_str or (str(member_guild.id) if member_guild else "") - if not skip_post: - await self._handle_webui_post({ - "job_id": job_id, "path": audio_path, - "text": text, "user": discord_name, "engine": mode, - "guild_id": post_guild_id, - }) - await asyncio.to_thread(_update_job, "done", path=audio_path) - async def _handle_webui_post(self, post: dict): - """Post a TTS audio file to the configured bot channel.""" + """Deliver a pre-generated TTS file queued by the web UI. + + If `play_vc` is set and the user is in a voice channel, the audio is + queued for VC playback (via the standard ttstoy queue). Either way it is + also posted as a message to the configured TTS channel. + """ try: audio_path = post.get("path") text = post.get("text", "") user = post.get("user", "Unknown") engine = post.get("engine", "tts") guild_id_str = post.get("guild_id", "") + user_id_str = post.get("user_id", "") + play_vc = bool(post.get("play_vc", False)) if not audio_path or not os.path.exists(audio_path): log.warning(f"[WebUI] Audio file not found: {audio_path}") return - # Find the designated TTS channel in the user's login guild target_guild_id = int(guild_id_str) if guild_id_str else None + + # --- VC playback (optional) --- + if play_vc and user_id_str: + await self._play_webui_audio_in_vc(user_id_str, audio_path, target_guild_id) + + # --- Post transcript to the configured TTS channel --- channel = await self._get_webui_tts_channel(target_guild_id) if not channel: log.warning("[WebUI] No TTS channel configured. Set TTSTOY_WEBUI_CHANNEL_ID env var.") return - filename = f"webui-{engine}-tts.mp3" + suffix = Path(audio_path).suffix or ".mp3" + filename = f"webui-{engine}-tts{suffix}" content = f"🗣 **{discord.utils.escape_markdown(user)}** via web UI [{engine}]\n> {discord.utils.escape_markdown(text[:200])}" await channel.send(content=content, file=discord.File(audio_path, filename=filename)) log.info(f"[WebUI] Posted TTS from {user} to channel {channel.id}") @@ -3405,6 +3301,61 @@ class TtsToy(Cog): except Exception as e: log.exception(f"[WebUI] Error posting TTS: {e}") + async def _play_webui_audio_in_vc(self, user_id_str: str, audio_path: str, target_guild_id: int = None): + """Queue a pre-generated audio file for VC playback in the user's current VC.""" + try: + user_id = int(user_id_str) + member_vc = None + member_guild = None + + if target_guild_id: + guild = self.bot.get_guild(target_guild_id) + if guild: + member_guild = guild + member = guild.get_member(user_id) + if member and member.voice and member.voice.channel: + member_vc = member.voice.channel + if not member_vc: + for guild in self.bot.guilds: + member = guild.get_member(user_id) + if member and member.voice and member.voice.channel: + member_vc = member.voice.channel + if not member_guild: + member_guild = guild + break + + if not member_vc or not member_guild: + log.info(f"[WebUI] {user_id} is not in a VC — skipping playback") + return + + member = member_guild.get_member(user_id) + audio_cog: Optional[Audio] = self.bot.get_cog("Audio") + if not audio_cog: + log.warning("[WebUI] Audio cog not loaded — skipping VC playback") + return + text_channel = member_guild.system_channel or next( + (c for c in member_guild.text_channels + if c.permissions_for(member_guild.me).send_messages), None + ) + if not text_channel: + log.warning("[WebUI] No text channel available for VC playback") + return + if not member_guild.me.voice or member_guild.me.voice.channel != member_vc: + await member_vc.connect() + + class _FakeCtx: + guild = member_guild + channel = text_channel + author = member or user_id + interaction = None + async def react_quietly(self, *a, **kw): pass + + queue = self._get_or_create_tts_queue(member_guild.id) + await queue.put((_FakeCtx(), audio_path)) + log.info(f"[WebUI] Queued audio for VC playback (user={user_id} guild={member_guild.id})") + except Exception as e: + log.exception(f"[WebUI] VC playback failed: {e}") + async def _get_webui_tts_channel(self, guild_id: int = None) -> Optional[discord.TextChannel]: """Get the channel to post web UI TTS messages to. @@ -3468,84 +3419,6 @@ class TtsToy(Cog): await self.config.guild(ctx.guild).webui_tts_channel_id.set(channel.id) await ctx.send(f"✅ Web UI TTS audio will be posted to {channel.mention} in this server") - # ------------------------------------------------------------------------- - # Web UI subprocess management - # ------------------------------------------------------------------------- - - def _webui_app_path(self) -> Path: - return Path(__file__).resolve().parent / "webui" / "app.py" - - async def _start_webui(self): - """Launch the Flask web UI as a subprocess.""" - if self._webui_process and self._webui_process.poll() is None: - log.info("[WebUI] Already running") - return True - - app_path = self._webui_app_path() - if not app_path.exists(): - log.error(f"[WebUI] app.py not found at {app_path}") - return False - - env = os.environ.copy() - env.setdefault("WEBUI_PORT", "8098") - env.setdefault("WEBUI_URL", f"http://localhost:{env['WEBUI_PORT']}") - env.setdefault("TTSTOY_SHARED_STATE", str(self.WEBUI_SHARED_STATE)) - if "BOT_INSTANCE" not in env: - try: - from redbot.core.data_manager import instance_name - env["BOT_INSTANCE"] = instance_name() - except Exception: - env.setdefault("BOT_INSTANCE", "redbot") - - try: - import sys - python = sys.executable - req_file = app_path.parent / "requirements.txt" - if req_file.exists(): - subprocess.run( - [python, "-m", "pip", "install", "-q", "-r", str(req_file)], - check=True, capture_output=True, - ) - self._webui_process = subprocess.Popen( - [python, str(app_path)], - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - await asyncio.sleep(2) - if self._webui_process.poll() is not None: - out = self._webui_process.stdout.read(500) if self._webui_process.stdout else "" - log.error(f"[WebUI] Process exited immediately: {out}") - return False - log.info(f"[WebUI] Started on port {env['WEBUI_PORT']} (pid {self._webui_process.pid})") - - # Pipe subprocess stdout → RedBot logger in a background thread - def _pipe_logs(proc): - for line in proc.stdout: - line = line.rstrip() - if line: - log.info(f"[WebUI] {line}") - threading.Thread(target=_pipe_logs, args=(self._webui_process,), daemon=True).start() - - return True - except Exception as e: - log.exception(f"[WebUI] Failed to start: {e}") - return False - - async def _stop_webui(self): - """Stop the Flask web UI subprocess.""" - if self._webui_process and self._webui_process.poll() is None: - try: - self._webui_process.terminate() - await asyncio.sleep(1) - if self._webui_process.poll() is None: - self._webui_process.kill() - log.info("[WebUI] Stopped") - except Exception as e: - log.exception(f"[WebUI] Error stopping: {e}") - self._webui_process = None - # ------------------------------------------------------------------------- # Cog lifecycle # ------------------------------------------------------------------------- @@ -3576,9 +3449,6 @@ class TtsToy(Cog): except Exception: log.exception("Failed to start TtsToy health server") - # Start web UI subprocess - await self._start_webui() - # Start web UI state processor self._webui_state_processor.start() log.info("[WebUI] State processor started") diff --git a/ttstoy/webui/app.py b/ttstoy/webui/app.py deleted file mode 100644 index b515a90..0000000 --- a/ttstoy/webui/app.py +++ /dev/null @@ -1,1279 +0,0 @@ -""" -TtsToy Web UI — Flask app -Reads RedBot config directly from disk. No RedBot dependency at runtime. -Run: python app.py (or gunicorn app:app) -""" -import os -import json -import logging -import secrets -import tempfile -import threading -import time -import uuid -from pathlib import Path -from functools import wraps - -import requests -from flask import ( - Flask, render_template, request, redirect, url_for, - session, jsonify, send_file, abort, flash -) - -log = logging.getLogger("ttstoy.webui") - -app = Flask(__name__) - -# ── Load stack config for shared session ── -_STACK_FILE = Path(__file__).parent / "stack.json" -def _load_stack(): - try: - with open(_STACK_FILE) as f: - return json.load(f) - except Exception: - return {} - -_stack = _load_stack() -app.secret_key = _stack.get("session_secret", os.environ.get("WEBUI_SECRET", secrets.token_hex(32))) -app.config["SESSION_COOKIE_DOMAIN"] = _stack.get("cookie_domain", None) -app.config["SESSION_COOKIE_NAME"] = "scrapyard_session" -app.config["SESSION_COOKIE_SAMESITE"] = "Lax" -app.config["SESSION_COOKIE_SECURE"] = True - -# --------------------------------------------------------------------------- -# Config — edit these or set via environment variables -# --------------------------------------------------------------------------- -REDBOT_DATA_PATH = Path(os.environ.get( - "REDBOT_DATA_PATH", - Path.home() / ".local/share/Red-DiscordBot/data" -)) -BOT_INSTANCE = os.environ.get("BOT_INSTANCE", "redbot") -WEBUI_PORT = int(os.environ.get("WEBUI_PORT", 8098)) -# Hardcoded public URL used for login links (Discord DM). -# This web UI is typically reached via Cloudflared at this hostname. -WEBUI_URL = "https://ttstoy.kingstons-scrapyard.net" - -# Path where RedBot stores cog config -COG_CONFIG_PATH = REDBOT_DATA_PATH / BOT_INSTANCE / "cogs" / "TtsToy" -# Path where RedBot stores per-user config -CORE_CONFIG_PATH = REDBOT_DATA_PATH / BOT_INSTANCE / "core" - -# --------------------------------------------------------------------------- -# Shared state init — runs at import time so gunicorn workers pick it up -# --------------------------------------------------------------------------- -_internal_secret = os.environ.get("TTSTOY_INTERNAL_SECRET") or secrets.token_hex(16) -app.config["INTERNAL_SECRET"] = _internal_secret -SHARED_STATE_FILE = Path(os.environ.get("TTSTOY_SHARED_STATE", "/tmp/ttstoy_webui_state.json")) - -# --------------------------------------------------------------------------- -# Helpers — RedBot config reading (defined early; needed by _init_shared_state) -# --------------------------------------------------------------------------- - -def _read_json(path: Path) -> dict: - try: - with open(path) as f: - return json.load(f) - except FileNotFoundError: - log.debug(f"Config file not found: {path}") - return {} - except Exception as e: - log.warning(f"Failed to read {path}: {e}") - return {} - - -def _init_shared_state(): - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - state["internal_secret"] = _internal_secret - state["webui_url"] = WEBUI_URL - SHARED_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - except Exception as e: - log.warning(f"Could not init shared state: {e}") - # Also ensure shared_auth.json exists with the secret - try: - auth = _read_auth_file() - auth["secret"] = _internal_secret - _write_auth_file(auth) - except Exception: - pass - -_init_shared_state() - -# --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# File-based login token store: ttstoy/shared_auth.json -# All apps in the stack read/write this file via stack.json paths. -# --------------------------------------------------------------------------- - -def _get_auth_file() -> Path: - """Resolve shared_auth.json location from stack.json, fallback to relative.""" - ttstoy_path = _stack.get("ttstoy", "") - if ttstoy_path: - return Path(ttstoy_path) / "shared_auth.json" - return Path(__file__).parent.parent / "shared_auth.json" - -def _read_auth_file() -> dict: - p = _get_auth_file() - try: - with open(p) as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {"tokens": {}, "secret": _internal_secret} - -def _write_auth_file(data: dict): - p = _get_auth_file() - p.parent.mkdir(parents=True, exist_ok=True) - with open(p, "w") as f: - json.dump(data, f, indent=2) - -_token_lock = threading.Lock() -TOKEN_TTL = 300 # 5 minutes to use the token - -# In-memory TTS output store: {job_id: {status, path, text, user, engine}} -_tts_jobs: dict = {} - - -# RedBot stores TtsToy config under the cog's numeric identifier (0x0A0A0A0A) -COG_IDENTIFIER = "168430090" - - -def get_global_config() -> dict: - """Read TtsToy global config from RedBot data dir.""" - p = COG_CONFIG_PATH / "settings.json" - data = _read_json(p) - if not data: - return {} - cfg = data.get(COG_IDENTIFIER, {}).get("GLOBAL", {}) - log.debug(f"Global config: mode={cfg.get('tts_mode')} chatterbox={cfg.get('chatterbox_api_url')}") - return cfg - - -def get_user_config(user_id: str) -> dict: - """Read TtsToy per-user config.""" - p = COG_CONFIG_PATH / "settings.json" - data = _read_json(p) - if not data: - return {} - cfg = data.get(COG_IDENTIFIER, {}).get("USER", {}).get(str(user_id), {}) - log.debug(f"User config for {user_id}: voice={cfg.get('minimax_voice')} voices={cfg.get('chatterbox_voices')}") - return cfg - - -def get_shared_state() -> dict: - return _read_json(SHARED_STATE_FILE) - - -def get_chatterbox_url() -> str: - cfg = get_global_config() - return cfg.get("chatterbox_api_url", "http://192.168.0.200:8099") - - -def get_dectalk_url() -> str: - cfg = get_global_config() - return cfg.get("dectalk_api_url", "http://127.0.0.1:33001") - - -# --------------------------------------------------------------------------- -# Auth helpers -# --------------------------------------------------------------------------- - -def create_login_token(user_id: str, discord_name: str, avatar_url: str = "", is_owner: bool = False, guild_id: str = "", guild_name: str = "") -> str: - token = secrets.token_urlsafe(24) - with _token_lock: - auth = _read_auth_file() - auth.setdefault("tokens", {})[token] = { - "user_id": user_id, - "discord_name": discord_name, - "avatar_url": avatar_url, - "is_owner": is_owner, - "guild_id": guild_id, - "guild_name": guild_name, - "expires": time.time() + TOKEN_TTL, - } - auth["secret"] = _internal_secret - _write_auth_file(auth) - log.debug(f"Login token created for user {user_id} ({discord_name}) guild={guild_id}, expires in {TOKEN_TTL}s") - return token - - -def consume_login_token(token: str) -> dict | None: - """Validate a token. Does NOT delete it — the session cookie handles auth - across all apps via the shared cookie domain.""" - with _token_lock: - auth = _read_auth_file() - tokens = auth.get("tokens", {}) - entry = tokens.get(token) - if not entry: - log.debug("consume_login_token: token not found") - return None - if time.time() > entry["expires"]: - log.debug(f"consume_login_token: token expired for user {entry['user_id']}") - del tokens[token] - _write_auth_file(auth) - return None - log.debug(f"consume_login_token: success for user {entry['user_id']} ({entry['discord_name']})") - return entry - - -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if "user_id" not in session: - return redirect(url_for("login_page")) - return f(*args, **kwargs) - return decorated - - -# --------------------------------------------------------------------------- -# Routes — Auth -# --------------------------------------------------------------------------- - -@app.route("/") -def index(): - if "user_id" in session: - return redirect(url_for("dashboard")) - return redirect(url_for("login_page")) - - -@app.route("/login") -def login_page(): - return render_template("login.html", webui_url=WEBUI_URL) - - -@app.route("/login", methods=["POST"]) -def login_submit(): - token = request.form.get("token", "").strip() - log.debug(f"Login attempt with token length={len(token)}") - entry = consume_login_token(token) - if not entry: - log.warning("Login failed: invalid or expired token") - flash("Invalid or expired token. Run [p]ttstoy login in Discord to get a new one.", "error") - return redirect(url_for("login_page")) - session["user_id"] = entry["user_id"] - session["discord_name"] = entry["discord_name"] - session["avatar_url"] = entry["avatar_url"] - session["is_owner"] = entry.get("is_owner", False) - session["guild_id"] = entry.get("guild_id", "") - session["guild_name"] = entry.get("guild_name", "") - # Persist user→guild mapping server-side so it survives session overwrites - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - ugm = state.setdefault("user_guild_map", {}) - if entry.get("guild_id"): - ugm[entry["user_id"]] = entry["guild_id"] - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - except Exception: - pass - log.info(f"User logged in: {entry['discord_name']} ({entry['user_id']}) owner={session['is_owner']} guild={session['guild_id']}") - return redirect(url_for("dashboard")) - - -@app.route("/logout") -def logout(): - session.clear() - return redirect(url_for("login_page")) - - -# --------------------------------------------------------------------------- -# Routes — Dashboard -# --------------------------------------------------------------------------- - -@app.route("/dashboard") -@login_required -def dashboard(): - user_id = session["user_id"] - gcfg = get_global_config() - ucfg = get_user_config(user_id) - global_mode = gcfg.get("tts_mode", "minimax") - # Per-user mode override stored in shared state - state = get_shared_state() - user_mode_overrides = state.get("user_mode_overrides", {}) - mode = user_mode_overrides.get(str(user_id), global_mode) - user_voice = ucfg.get("minimax_voice") - # Build display name for active voice - if user_voice and "_" in user_voice: - active_voice_display = user_voice.split("_", 1)[1].rsplit(".", 1)[0] - else: - active_voice_display = user_voice or "Global default" - return render_template("dashboard.html", - mode=mode, - global_mode=global_mode, - user_voice=user_voice, - active_voice_display=active_voice_display, - gcfg=gcfg, - ucfg=ucfg, - ) - - -# --------------------------------------------------------------------------- -# Routes — TTS Submit -# --------------------------------------------------------------------------- - -@app.route("/api/tts", methods=["POST"]) -@login_required -def api_tts(): - """Queue a TTS request. If generate_only is true, generate directly without VC.""" - data = request.get_json(force=True) - text = (data.get("text") or "").strip() - if not text: - return jsonify({"error": "No text provided"}), 400 - - # If generate_only, redirect to the direct generation endpoint - if data.get("generate_only"): - return api_tts_generate() - - user_id = session["user_id"] - discord_name = session.get("discord_name", "Unknown") - gcfg = get_global_config() - - # Allow per-request mode/voice override from the web UI - state = get_shared_state() - user_mode_overrides = state.get("user_mode_overrides", {}) - global_mode = gcfg.get("tts_mode", "minimax") - mode = data.get("mode") or user_mode_overrides.get(str(user_id), global_mode) - voice = data.get("voice") or None # None = use user's saved voice - - job_id = str(uuid.uuid4()) - log.info(f"TTS job {job_id}: user={discord_name} ({user_id}) mode={mode} voice={voice} text={text[:80]!r}") - _tts_jobs[job_id] = {"status": "pending", "text": text, "user": discord_name, "engine": mode} - - _write_bot_command(user_id, "speak_in_vc", { - "job_id": job_id, - "text": text, - "user": discord_name, - "mode_override": mode, - "voice_override": voice, - "guild_id": _resolve_guild_id(), - }) - - return jsonify({"job_id": job_id}) - - -@app.route("/api/tts//status") -@login_required -def tts_job_status(job_id): - job = _tts_jobs.get(job_id) - if not job: - return jsonify({"status": "not_found"}), 404 - return jsonify({k: v for k, v in job.items() if k != "path"}) - - -@app.route("/api/tts//audio") -@login_required -def tts_job_audio(job_id): - job = _tts_jobs.get(job_id) - if not job or job.get("status") != "done": - abort(404) - return send_file(job["path"], mimetype="audio/mpeg", as_attachment=False) - - -@app.route("/api/tts//update", methods=["POST"]) -def tts_job_update(job_id): - """Called by the bot to update a job's status (done/error) and provide the audio path.""" - secret = request.headers.get("X-Internal-Secret", "") - if secret != app.config.get("INTERNAL_SECRET", ""): - abort(403) - data = request.get_json(force=True) - if job_id in _tts_jobs: - _tts_jobs[job_id].update(data) - log.debug(f"Job {job_id} updated by bot: {data}") - return jsonify({"ok": True}) - - -@app.route("/api/tts/generate", methods=["POST"]) -@login_required -def api_tts_generate(): - """Generate TTS via the bot or directly via TTS servers.""" - data = request.get_json(force=True) - text = (data.get("text") or "").strip() - if not text: - return jsonify({"error": "No text provided"}), 400 - - user_id = session["user_id"] - discord_name = session.get("discord_name", "Unknown") - gcfg = get_global_config() - state = get_shared_state() - user_mode_overrides = state.get("user_mode_overrides", {}) - global_mode = gcfg.get("tts_mode", "minimax") - mode = data.get("mode") or user_mode_overrides.get(str(user_id), global_mode) - - # Direct server modes — generate audio without the bot - if mode == "morshu": - try: - r = requests.get("http://127.0.0.1:33002/say", params={"text": text}, timeout=60) - r.raise_for_status() - job_id = str(uuid.uuid4()) - out_path = tempfile.mktemp(suffix=".wav") - with open(out_path, "wb") as f: - f.write(r.content) - _tts_jobs[job_id] = {"status": "done", "path": out_path, "text": text, "user": discord_name, "engine": "morshu"} - return jsonify({"job_id": job_id}) - except Exception as e: - return jsonify({"error": f"Morshu server error: {e}"}), 500 - - if mode == "dectalk": - try: - voice = data.get("voice") or "" - dectalk_text = f"{voice}{text}" if voice else text - dectalk_url = gcfg.get("dectalk_api_url", "http://127.0.0.1:33001") - r = requests.get(f"{dectalk_url}/say", params={"text": dectalk_text}, timeout=30) - r.raise_for_status() - job_id = str(uuid.uuid4()) - out_path = tempfile.mktemp(suffix=".wav") - with open(out_path, "wb") as f: - f.write(r.content) - _tts_jobs[job_id] = {"status": "done", "path": out_path, "text": text, "user": discord_name, "engine": "dectalk"} - return jsonify({"job_id": job_id}) - except Exception as e: - return jsonify({"error": f"DECTalk server error: {e}"}), 500 - - if mode == "vox": - try: - pack = gcfg.get("vox_pack", "vox") - r = requests.get("http://127.0.0.1:33003/say", params={"text": text, "pack": pack}, timeout=60) - r.raise_for_status() - job_id = str(uuid.uuid4()) - out_path = tempfile.mktemp(suffix=".wav") - with open(out_path, "wb") as f: - f.write(r.content) - _tts_jobs[job_id] = {"status": "done", "path": out_path, "text": text, "user": discord_name, "engine": "vox"} - return jsonify({"job_id": job_id}) - except Exception as e: - return jsonify({"error": f"VOX server error: {e}"}), 500 - - if mode != "chatterbox": - return jsonify({"error": f"Generate Only is not available for '{mode}' mode via web UI."}), 400 - - voice = data.get("voice") or None - job_id = str(uuid.uuid4()) - _tts_jobs[job_id] = {"status": "pending", "text": text, "user": discord_name, "engine": mode} - - _write_bot_command(user_id, "speak_in_vc", { - "job_id": job_id, - "text": text, - "user": discord_name, - "mode_override": mode, - "voice_override": voice, - "skip_vc": True, - "skip_post": not data.get("post_to_discord", True), - "guild_id": _resolve_guild_id(), - }) - - return jsonify({"job_id": job_id}) - - -def _notify_bot_post(job_id, path, text, user, engine): - """Write a pending post request to the shared state file for the bot to pick up.""" - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - pending = state.get("pending_posts", []) - pending.append({"job_id": job_id, "path": path, "text": text, "user": user, "engine": engine}) - state["pending_posts"] = pending - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - log.debug(f"Queued Discord post for job {job_id} (user={user} engine={engine})") - except Exception as e: - log.error(f"Failed to queue Discord post for job {job_id}: {e}") - - -# --------------------------------------------------------------------------- -# Routes — Settings API -# --------------------------------------------------------------------------- - -@app.route("/api/settings", methods=["GET"]) -@login_required -def api_get_settings(): - user_id = session["user_id"] - gcfg = get_global_config() - ucfg = get_user_config(user_id) - from ttstoy_constants import MINIMAX_VOICES - - # Merge in any locally-cached param changes (written immediately on save, - # before the bot has had a chance to process the command and update RedBot config) - state = get_shared_state() - param_cache = state.get("chatterbox_params_cache", {}).get(str(user_id), {}) - - # Cache keys match the params keys: temperature, exaggeration, volume_db, speed - # RedBot config keys: chatterbox_temperature_offsets, etc. - def _merged(redbot_key, cache_key): - from_disk = ucfg.get(redbot_key, {}) - from_cache = param_cache.get(cache_key, {}) - return {**from_disk, **from_cache} - - return jsonify({ - "mode": gcfg.get("tts_mode", "minimax"), - "user_voice": ucfg.get("minimax_voice"), - "chatterbox_voices": ucfg.get("chatterbox_voices", []), - "chatterbox_temperature_offsets": _merged("chatterbox_temperature_offsets", "temperature"), - "chatterbox_exaggeration_offsets": _merged("chatterbox_exaggeration_offsets", "exaggeration"), - "chatterbox_volume_offsets": _merged("chatterbox_volume_offsets", "volume_db"), - "chatterbox_speed_offsets": _merged("chatterbox_speed_offsets", "speed"), - "global_voice": gcfg.get("minimax_voice"), - "vox_pack": gcfg.get("vox_pack", "vox"), - "sfx_volume": gcfg.get("sfx_volume", 100), - "minimax_voices": MINIMAX_VOICES, - }) - - -@app.route("/api/settings/voice", methods=["POST"]) -@login_required -def api_set_voice(): - """Tell the bot to update the user's voice via shared state.""" - data = request.get_json(force=True) - voice = data.get("voice", "").strip() - if not voice: - return jsonify({"error": "No voice provided"}), 400 - _write_bot_command(session["user_id"], "set_voice", {"voice": voice}) - return jsonify({"ok": True}) - - -@app.route("/api/settings/mode", methods=["POST"]) -@login_required -def api_set_mode(): - data = request.get_json(force=True) - mode = data.get("mode", "").strip() - if mode not in ("minimax", "chatterbox", "dectalk", "morshu", "vox"): - return jsonify({"error": "Invalid mode"}), 400 - # Store per-user mode override in shared state (doesn't change global bot mode) - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - overrides = state.get("user_mode_overrides", {}) - overrides[str(session["user_id"])] = mode - state["user_mode_overrides"] = overrides - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - log.info(f"User {session['user_id']} set mode override → {mode}") - return jsonify({"ok": True}) - - -@app.route("/api/settings/chatterbox_params", methods=["POST"]) -@login_required -def api_set_chatterbox_params(): - data = request.get_json(force=True) - voice = data.get("voice", "").strip() - raw = data.get("params") or {} - params = {k: raw[k] for k in ("temperature", "exaggeration", "volume_db", "speed") if raw.get(k) is not None} - - # Write to shared state immediately so /api/settings reflects the change right away, - # without waiting for the bot to process the command and write back to RedBot config. - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - user_id = str(session["user_id"]) - cache = state.setdefault("chatterbox_params_cache", {}).setdefault(user_id, {}) - for k, v in params.items(): - # params keys: temperature, exaggeration, volume_db, speed - # store flat: {voice: value} under each key - cache.setdefault(k, {})[voice] = v - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - except Exception as e: - log.warning(f"Could not cache chatterbox params: {e}") - - _write_bot_command(session["user_id"], "set_chatterbox_params", {"voice": voice, "params": params}) - return jsonify({"ok": True}) - - -def _write_bot_command(user_id, cmd, payload): - """Queue a command for the bot cog to execute.""" - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - cmds = state.get("pending_commands", []) - cmds.append({"user_id": str(user_id), "cmd": cmd, "payload": payload, "ts": time.time()}) - state["pending_commands"] = cmds - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - log.debug(f"Queued bot command: user={user_id} cmd={cmd} payload={payload}") - except Exception as e: - log.error(f"Failed to write bot command {cmd} for user {user_id}: {e}") - - -def _resolve_guild_id() -> str: - """Get the guild_id for the current user: session first, then server-side map.""" - gid = session.get("guild_id", "") - if gid: - return gid - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - return state.get("user_guild_map", {}).get(session.get("user_id", ""), "") - except Exception: - return "" - - -# --------------------------------------------------------------------------- -# Routes — Minecraft Mod API -# --------------------------------------------------------------------------- - -@app.route("/api/mc/login", methods=["POST"]) -def api_mc_login(): - """Minecraft mod login endpoint. Accepts JSON {"token": "..."} and returns user info + personal voices.""" - data = request.get_json(force=True) if request.is_json else {} - token = data.get("token", "").strip() - if not token: - return jsonify({"error": "Missing token"}), 400 - - entry = consume_login_token(token) - if not entry: - return jsonify({"error": "Invalid or expired token. Run [p]ttstoy login in Discord."}), 401 - - user_id = entry["user_id"] - ucfg = get_user_config(user_id) - voices = ucfg.get("chatterbox_voices", []) - # Only return voices that belong to this user (prefixed with their user_id) - result = [] - for v in voices: - if v.startswith(f"{user_id}_"): - display = v.split("_", 1)[1].rsplit(".", 1)[0] - result.append({"filename": v, "display": display}) - - return jsonify({ - "user_id": user_id, - "discord_name": entry.get("discord_name", "Unknown"), - "voices": result, - }) - - -@app.route("/api/mc/voices", methods=["GET"]) -def api_mc_voices(): - """List predefined voices from the Chatterbox server (excludes user-uploaded voices).""" - try: - chatterbox_url = get_chatterbox_url() - r = requests.get(f"{chatterbox_url}/get_predefined_voices", timeout=10) - r.raise_for_status() - voices = r.json() - # Filter out user-uploaded voices (format: userid_name.wav where userid is numeric) - filtered = [v for v in voices if not v.get("filename", "").split("_")[0].isdigit()] - return jsonify(filtered) - except Exception as e: - return jsonify({"error": str(e)}), 502 - - -@app.route("/api/mc/tts", methods=["POST"]) -def api_mc_tts(): - """Generate TTS audio with full ttstoy features. Returns WAV bytes. - JSON body: {"text": "...", "mode": "chatterbox", "voice": "Emily.wav"} - - Supports: - - SFX emojis inline (🎉, 💀, etc.) → interleaved SFX audio - - Voice switching: [dectalk|Harry]hello [chatterbox|Emily]world - - Chatterbox turbo tokens: [laugh], [cough], etc. (passed through to model) - """ - import io - import re - import wave - import random - import subprocess as _sp - - data = request.get_json(force=True) if request.is_json else {} - text = (data.get("text") or "").strip() - if not text: - return jsonify({"error": "Missing text"}), 400 - - default_mode = data.get("mode", "chatterbox") - default_voice = data.get("voice", "Emily.wav") - gcfg = get_global_config() - - # --- SFX setup --- - sfx_root = Path(__file__).resolve().parent.parent / "sfx" - emoji_map_path = sfx_root / "emoji_map.json" - try: - emoji_map = json.load(open(emoji_map_path)) if emoji_map_path.exists() else {} - except Exception: - emoji_map = {} - - EMOJI_RE = re.compile( - r"(|:[A-Za-z0-9_]+:|[\U0001F300-\U0001FAFF\U00002600-\U000026FF\U00002700-\U000027BF][\uFE0E\uFE0F]?(?:\u200D[\U0001F300-\U0001FAFF\U00002600-\U000026FF\U00002700-\U000027BF][\uFE0E\uFE0F]?)*)" - ) - VOICE_SWITCH_RE = re.compile(r"\[([a-zA-Z]+)(?:\|([^\]]+))?\]") - VALID_MODES = {"chatterbox", "clone", "dectalk", "morshu", "vox", "minimax"} - # Turbo tokens — these look like voice switches but should pass through to chatterbox - TURBO_TOKENS = {"laugh", "chuckle", "sigh", "gasp", "cough", "cry", "groan", "yawn", "sniff"} - - def normalize_trigger(token): - if token.startswith("<") and token.endswith(">"): - parts = token.strip("<>").split(":") - if len(parts) == 3: - return f":{parts[1]}:" - return token - - def get_sfx_file(trigger): - norm = normalize_trigger(trigger) - folder_name = emoji_map.get(norm) - if not folder_name: - return None - folder = sfx_root / folder_name - files = sorted(folder.glob("*.mp3")) + sorted(folder.glob("*.wav")) + sorted(folder.glob("*.ogg")) - return random.choice(files) if files else None - - def generate_for_mode(seg_text, mode, voice): - """Generate WAV bytes for a text segment with given mode/voice.""" - if mode == "chatterbox" or mode == "clone": - chatterbox_url = get_chatterbox_url() - payload = { - "text": seg_text, - "voice_mode": "clone" if mode == "clone" else "predefined", - ("reference_audio_filename" if mode == "clone" else "predefined_voice_id"): voice or default_voice, - "output_format": "wav", - "split_text": False, - } - r = requests.post(f"{chatterbox_url}/tts", json=payload, timeout=120) - r.raise_for_status() - return r.content - elif mode == "dectalk": - dectalk_url = get_dectalk_url() - # Resolve voice name to DECTalk command - dectalk_voices = {"paul": "[:np]", "betty": "[:nb]", "harry": "[:nh]", "frank": "[:nf]", - "dennis": "[:nd]", "kit": "[:nk]", "ursula": "[:nu]", "rita": "[:nr]", "wendy": "[:nw]"} - prefix = dectalk_voices.get((voice or "").lower(), "") - r = requests.get(f"{dectalk_url}/say", params={"text": f"{prefix}{seg_text}"}, timeout=30) - r.raise_for_status() - return r.content - elif mode == "morshu": - r = requests.get("http://127.0.0.1:33002/say", params={"text": seg_text}, timeout=60) - r.raise_for_status() - return r.content - elif mode == "vox": - pack = gcfg.get("vox_pack", "vox") - r = requests.get("http://127.0.0.1:33003/say", params={"text": seg_text, "pack": pack}, timeout=60) - r.raise_for_status() - return r.content - elif mode == "minimax": - api_key = gcfg.get("minimax_api_key") - if not api_key: - raise RuntimeError("MiniMax API key not configured in ttstoy") - model = gcfg.get("minimax_model", "speech-01-turbo") - payload = { - "model": model, - "text": seg_text, - "stream": False, - "language_boost": "auto", - "output_format": "hex", - "voice_setting": { - "voice_id": voice or "male-qn-qingse", - "speed": 1, - "vol": 1, - "pitch": 0, - }, - "audio_setting": { - "sample_rate": 32000, - "bitrate": 128000, - "format": "mp3", - }, - } - headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - r = requests.post("https://api.minimax.io/v1/t2a_v2", json=payload, headers=headers, timeout=60) - r.raise_for_status() - resp = r.json() - audio_hex = resp.get("data", {}).get("audio", "") - if not audio_hex: - raise RuntimeError("MiniMax returned no audio") - return bytes.fromhex(audio_hex) - else: - raise RuntimeError(f"Unknown mode: {mode}") - - def sfx_to_wav(sfx_path): - """Convert any audio file to WAV bytes via ffmpeg.""" - result = _sp.run( - ["ffmpeg", "-y", "-i", str(sfx_path), "-ac", "1", "-ar", "24000", "-f", "wav", "-"], - capture_output=True - ) - if result.returncode != 0: - return None - return result.stdout - - def ensure_wav_format(audio_bytes): - """Normalize audio bytes to consistent WAV format (mono 24kHz 16-bit) via ffmpeg.""" - result = _sp.run( - ["ffmpeg", "-y", "-i", "pipe:0", "-ac", "1", "-ar", "24000", "-sample_fmt", "s16", "-f", "wav", "pipe:1"], - input=audio_bytes, capture_output=True - ) - return result.stdout if result.returncode == 0 else audio_bytes - - def concat_wavs(wav_list): - """Concatenate multiple WAV byte arrays into one.""" - if len(wav_list) == 1: - return wav_list[0] - # Use ffmpeg to concat - import tempfile, shutil - tmp_dir = tempfile.mkdtemp() - try: - paths = [] - for i, wav_bytes in enumerate(wav_list): - p = os.path.join(tmp_dir, f"{i:03}.wav") - with open(p, "wb") as f: - f.write(wav_bytes) - paths.append(p) - list_file = os.path.join(tmp_dir, "list.txt") - with open(list_file, "w") as f: - for p in paths: - f.write(f"file '{p}'\n") - out_path = os.path.join(tmp_dir, "out.wav") - _sp.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file, - "-ac", "1", "-ar", "24000", "-sample_fmt", "s16", out_path], - capture_output=True, check=True) - with open(out_path, "rb") as f: - return f.read() - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) - - try: - # Step 1: Split on voice switch tags (but NOT turbo tokens) - voice_segments = [] - last_end = 0 - for m in VOICE_SWITCH_RE.finditer(text): - tag_mode = m.group(1).lower() - tag_voice = (m.group(2) or "").strip() - # Skip turbo tokens — they stay in the text - if tag_mode in TURBO_TOKENS: - continue - if tag_mode not in VALID_MODES: - continue - before = text[last_end:m.start()] - if before: - if not voice_segments: - voice_segments.append((default_mode, default_voice, before)) - else: - prev = voice_segments[-1] - voice_segments[-1] = (prev[0], prev[1], prev[2] + before) - voice_segments.append((tag_mode, tag_voice or default_voice, "")) - last_end = m.end() - tail = text[last_end:] - if tail: - if not voice_segments: - voice_segments.append((default_mode, default_voice, tail)) - else: - prev = voice_segments[-1] - voice_segments[-1] = (prev[0], prev[1], prev[2] + tail) - if not voice_segments: - voice_segments = [(default_mode, default_voice, text)] - - # Step 2: For each voice segment, split on SFX emojis and generate - all_wav_parts = [] - for seg_mode, seg_voice, seg_text in voice_segments: - seg_text = seg_text.strip() - if not seg_text: - continue - - # Split segment into TTS and SFX parts - parts = [] - cursor = 0 - text_buf = [] - for match in EMOJI_RE.finditer(seg_text): - if match.start() > cursor: - text_buf.append(seg_text[cursor:match.start()]) - token = match.group(0) - if normalize_trigger(token) in emoji_map: - if text_buf: - parts.append(("tts", "".join(text_buf))) - text_buf = [] - parts.append(("sfx", token)) - else: - text_buf.append(token) - cursor = match.end() - if cursor < len(seg_text): - text_buf.append(seg_text[cursor:]) - if text_buf: - parts.append(("tts", "".join(text_buf))) - - if not parts: - parts = [("tts", seg_text)] - - # Generate audio for each part - for kind, value in parts: - if kind == "tts": - value = value.strip() - if not value: - continue - wav = generate_for_mode(value, seg_mode, seg_voice) - all_wav_parts.append(ensure_wav_format(wav)) - else: - sfx_file = get_sfx_file(value) - if sfx_file: - wav = sfx_to_wav(sfx_file) - if wav: - all_wav_parts.append(wav) - - if not all_wav_parts: - return jsonify({"error": "No audio generated"}), 400 - - final_wav = concat_wavs(all_wav_parts) if len(all_wav_parts) > 1 else all_wav_parts[0] - return final_wav, 200, {"Content-Type": "audio/wav"} - - except requests.exceptions.ConnectionError as e: - return jsonify({"error": f"TTS server not reachable: {e}"}), 502 - except Exception as e: - log.exception(f"mc/tts error: {e}") - return jsonify({"error": str(e)}), 500 - - -# --------------------------------------------------------------------------- -# Routes — Chatterbox voice management -# --------------------------------------------------------------------------- - -@app.route("/api/chatterbox/voices") -@login_required -def api_chatterbox_voices(): - user_id = session["user_id"] - ucfg = get_user_config(user_id) - voices = ucfg.get("chatterbox_voices", []) - result = [] - for v in voices: - parts = v.split("_", 1) - display = parts[1].rsplit(".", 1)[0] if len(parts) > 1 else v.rsplit(".", 1)[0] - result.append({"filename": v, "display": display}) - return jsonify(result) - - -@app.route("/api/chatterbox/voices/upload", methods=["POST"]) -@login_required -def api_chatterbox_upload(): - if "file" not in request.files: - log.warning("Voice upload: no file in request") - return jsonify({"error": "No file"}), 400 - f = request.files["file"] - name = request.form.get("name", "").strip() - if not name: - name = f.filename.rsplit(".", 1)[0] - import re - safe_name = re.sub(r'[^a-zA-Z0-9_]', '', name) or "voice" - ext = ".wav" if f.filename.lower().endswith(".wav") else ".mp3" - filename = f"{session['user_id']}_{safe_name}{ext}" - cb_url = get_chatterbox_url() - log.debug(f"Voice upload: filename={filename} cb_url={cb_url}") - audio_bytes = f.read() - log.debug(f"Voice upload: raw audio size={len(audio_bytes)} bytes") - try: - from pydub import AudioSegment - import io as _io - seg = AudioSegment.from_file(_io.BytesIO(audio_bytes)) - log.debug(f"Voice upload: clip duration={len(seg)}ms") - if len(seg) < 6000: - loops = (6000 // len(seg)) + 1 - seg = seg * loops - log.debug(f"Voice upload: looped clip to {len(seg)}ms") - if len(seg) > 30000: - trimmed = seg[:30000] - try: - from pydub.silence import detect_silence - search_start = max(0, 30000 - 1500) - silences = detect_silence( - seg[search_start:30000], min_silence_len=300, silence_thresh=-40 - ) - if silences: - cutoff_local = silences[0][0] - if cutoff_local > 100: - trimmed = seg[: search_start + cutoff_local] - except Exception: - pass - seg = trimmed - log.debug(f"Voice upload: trimmed clip to {len(seg)}ms") - buf = _io.BytesIO() - seg.export(buf, format="wav") - audio_bytes = buf.getvalue() - filename = f"{session['user_id']}_{safe_name}.wav" - log.debug(f"Voice upload: processed wav size={len(audio_bytes)} bytes") - except Exception as e: - log.warning(f"Voice upload: pydub processing failed ({e}), uploading raw") - try: - r = requests.post(f"{cb_url.rstrip('/')}/upload_predefined_voice", - files={"files": (filename, audio_bytes, "audio/wav")}, timeout=30) - log.debug(f"Voice upload: Chatterbox response {r.status_code}") - r.raise_for_status() - except Exception as ex: - log.error(f"Voice upload failed: {ex}") - return jsonify({"error": str(ex)}), 500 - _write_bot_command(session["user_id"], "add_voice", {"filename": filename}) - log.info(f"Voice uploaded: {filename} for user {session['user_id']}") - return jsonify({"ok": True, "filename": filename}) - - -@app.route("/api/chatterbox/voices/", methods=["DELETE"]) -@login_required -def api_chatterbox_delete_voice(filename): - user_id = session["user_id"] - if not filename.startswith(f"{user_id}_"): - return jsonify({"error": "Not your voice"}), 403 - cb_url = get_chatterbox_url() - try: - r = requests.delete(f"{cb_url.rstrip('/')}/delete_predefined_voice/{filename}", timeout=10) - if r.status_code not in (200, 204, 404): - return jsonify({"error": f"Server returned {r.status_code}"}), 500 - except Exception as ex: - return jsonify({"error": str(ex)}), 500 - _write_bot_command(user_id, "remove_voice", {"filename": filename}) - return jsonify({"ok": True}) - - -# --------------------------------------------------------------------------- -# Routes — Pages -# --------------------------------------------------------------------------- - -@app.route("/api/sfx_emojis") -@login_required -def api_sfx_emojis(): - """Return the emoji→sfx folder mapping for the SFX picker.""" - mapping = _load_emoji_map() - return jsonify([{"emoji": e, "name": n} for e, n in mapping.items()]) - - -# --------------------------------------------------------------------------- -# SFX helpers -# --------------------------------------------------------------------------- -def _sfx_root() -> Path: - return Path(__file__).resolve().parent.parent / "sfx" - - -def _emoji_map_path() -> Path: - return _sfx_root() / "emoji_map.json" - - -def _load_emoji_map() -> dict: - """Load emoji→folder mapping from the shared JSON file.""" - p = _emoji_map_path() - if p.exists(): - try: - with open(p) as f: - return json.load(f) - except Exception: - pass - # Fallback: try importing the live dict from the cog - try: - import sys - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from ttstoy import EMOJI_SFX_FOLDERS - return dict(EMOJI_SFX_FOLDERS) - except Exception: - return {} - - -def _save_emoji_map(mapping: dict): - """Save emoji→folder mapping to the shared JSON file and reload in the cog.""" - p = _emoji_map_path() - p.parent.mkdir(parents=True, exist_ok=True) - with open(p, "w") as f: - json.dump(mapping, f, ensure_ascii=False, indent=2) - # Try to reload the live dict in the cog (same process won't help for - # the bot, but the bot's state processor or next read will pick it up) - try: - import sys - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from ttstoy import _reload_emoji_sfx_map - _reload_emoji_sfx_map() - except Exception: - pass - - -def _owner_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if not session.get("is_owner"): - return jsonify({"error": "Owner only"}), 403 - return f(*args, **kwargs) - return decorated - - -@app.route("/api/sfx", methods=["GET"]) -@login_required -@_owner_required -def api_sfx_list(): - """List all SFX entries: one emoji + one file per folder.""" - mapping = _load_emoji_map() - - root = _sfx_root() - # Reverse map: folder → emoji - folder_emoji = {} - for emoji, folder in mapping.items(): - folder_emoji[folder] = emoji # last one wins (1:1 model) - - result = [] - if root.exists(): - for d in sorted(root.iterdir()): - if d.is_dir(): - files = sorted(f.name for f in d.iterdir() if f.is_file() and f.suffix in (".mp3", ".wav", ".ogg")) - result.append({ - "name": d.name, - "emoji": folder_emoji.get(d.name, ""), - "file": files[0] if files else "", - }) - return jsonify(result) - - -@app.route("/api/sfx//upload", methods=["POST"]) -@login_required -@_owner_required -def api_sfx_upload(folder_name): - """Upload an audio file to an SFX folder. Creates the folder if needed.""" - import re - if not re.match(r'^[a-zA-Z0-9_-]+$', folder_name): - return jsonify({"error": "Invalid folder name"}), 400 - if "file" not in request.files: - return jsonify({"error": "No file"}), 400 - f = request.files["file"] - if not f.filename: - return jsonify({"error": "No filename"}), 400 - ext = Path(f.filename).suffix.lower() - if ext not in (".mp3", ".wav", ".ogg"): - return jsonify({"error": "Only .mp3, .wav, .ogg allowed"}), 400 - - safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', f.filename) - folder = _sfx_root() / folder_name - folder.mkdir(parents=True, exist_ok=True) - dest = folder / safe_name - f.save(str(dest)) - log.info(f"SFX uploaded: {dest}") - return jsonify({"ok": True, "path": str(dest.relative_to(_sfx_root()))}) - - -@app.route("/api/sfx/", methods=["DELETE"]) -@login_required -@_owner_required -def api_sfx_delete_folder(folder_name): - """Delete an SFX and its emoji mapping.""" - import shutil - folder = _sfx_root() / folder_name - if not folder.exists(): - return jsonify({"error": "Folder not found"}), 404 - shutil.rmtree(folder) - log.info(f"SFX folder deleted: {folder}") - # Also remove any emoji mappings pointing to this folder - mapping = _load_emoji_map() - changed = {e: f for e, f in mapping.items() if f != folder_name} - if len(changed) != len(mapping): - _save_emoji_map(changed) - return jsonify({"ok": True}) - - -@app.route("/api/sfx/emoji_map", methods=["POST"]) -@login_required -@_owner_required -def api_emoji_map_set(): - """Set or clear the emoji for an SFX folder. Enforces 1 emoji per folder.""" - data = request.get_json(force=True) - emoji = (data.get("emoji") or "").strip() - folder = (data.get("folder") or "").strip() - name = (data.get("name") or folder or "").strip() - - mapping = _load_emoji_map() - - # Remove any existing emoji that pointed to this folder (1:1) - if name: - mapping = {e: f for e, f in mapping.items() if f != name} - - if emoji and folder: - # Also remove this emoji from any other folder - mapping = {e: f for e, f in mapping.items() if e != emoji} - mapping[emoji] = folder - # If emoji is empty, we already removed the folder's old mapping above - - _save_emoji_map(mapping) - log.info(f"Emoji map updated: {emoji!r} → {folder!r}") - return jsonify({"ok": True}) - - -@app.route("/sfx") -@login_required -def page_sfx(): - if not session.get("is_owner"): - return redirect(url_for("dashboard")) - return render_template("sfx.html") - - -@app.route("/chatterbox") -@login_required -def page_chatterbox(): - return render_template("chatterbox.html") - - -@app.route("/minimax") -@login_required -def page_minimax(): - gcfg = get_global_config() - from ttstoy_constants import MINIMAX_VOICES - return render_template("minimax.html", voices=MINIMAX_VOICES, gcfg=gcfg) - - -@app.route("/dectalk") -@login_required -def page_dectalk(): - gcfg = get_global_config() - return render_template("dectalk.html", gcfg=gcfg) - - -@app.route("/vox") -@login_required -def page_vox(): - gcfg = get_global_config() - try: - import sys - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from vox_engine import get_available_words, get_available_packs - pack = gcfg.get("vox_pack", "vox") - words = get_available_words(pack) - packs = get_available_packs() - except Exception: - words, packs, pack = [], ["vox"], "vox" - return render_template("vox.html", words=words, packs=packs, current_pack=pack) - - -@app.route("/profile") -@login_required -def page_profile(): - user_id = session["user_id"] - gcfg = get_global_config() - ucfg = get_user_config(user_id) - state = get_shared_state() - user_info = state.get("users", {}).get(str(user_id), {}) - return render_template("profile.html", gcfg=gcfg, ucfg=ucfg, user_info=user_info) - - -# --------------------------------------------------------------------------- -# Token endpoint — called by the bot cog to register a login token -# --------------------------------------------------------------------------- - -@app.route("/api/register_token", methods=["POST"]) -def api_register_token(): - """Called by the bot (locally) to register a login token for a user.""" - secret = request.headers.get("X-Internal-Secret", "") - if secret != app.config.get("INTERNAL_SECRET", ""): - log.warning("api_register_token: rejected request with wrong internal secret") - abort(403) - data = request.get_json(force=True) - log.debug(f"api_register_token: creating token for user {data.get('user_id')} ({data.get('discord_name')})") - token = create_login_token( - str(data["user_id"]), - data.get("discord_name", "Unknown"), - data.get("avatar_url", ""), - data.get("is_owner", False), - data.get("guild_id", ""), - data.get("guild_name", ""), - ) - guild_id = data.get("guild_id", "") - if guild_id: - try: - state = _read_json(SHARED_STATE_FILE) if SHARED_STATE_FILE.exists() else {} - ugm = state.setdefault("user_guild_map", {}) - ugm[str(data["user_id"])] = guild_id - with open(SHARED_STATE_FILE, "w") as f: - json.dump(state, f) - log.debug(f"api_register_token: persisted user_guild_map[{data['user_id']}]={guild_id}") - except Exception as e: - log.warning(f"api_register_token: could not persist user_guild_map: {e}") - return jsonify({"token": token}) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - import sys - import logging as _logging - _logging.basicConfig( - stream=sys.stdout, - level=_logging.DEBUG, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - ) - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - log.info(f"TtsToy WebUI starting — port={WEBUI_PORT} url={WEBUI_URL}") - app.run(host="0.0.0.0", port=WEBUI_PORT, debug=False) diff --git a/ttstoy/webui/requirements.txt b/ttstoy/webui/requirements.txt deleted file mode 100644 index 3b0b408..0000000 --- a/ttstoy/webui/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -flask>=3.0 -requests>=2.31 -pydub>=0.25 -gunicorn diff --git a/ttstoy/webui/stack.json b/ttstoy/webui/stack.json deleted file mode 100644 index ff667a0..0000000 --- a/ttstoy/webui/stack.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "homepage": "/home/owen/Desktop/Homepage", - "webtable": "/home/owen/Desktop/WebTable", - "porgupicker": "/home/owen/Desktop/PorguPicker", - "galaxy": "/home/owen/Desktop/Something", - "ttstoy": "/home/owen/Desktop/ttstoy", - "ttstoy_webui": "/home/owen/Desktop/ttstoy/webui", - "cookie_domain": ".kingstons-scrapyard.net", - "session_secret": "scrapyard-shared-session-k8x7m2p4q9w1" -} diff --git a/ttstoy/webui/start.sh b/ttstoy/webui/start.sh deleted file mode 100755 index e87bd87..0000000 --- a/ttstoy/webui/start.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Start the TtsToy Web UI -# Edit these or export them before running - -# Path to your RedBot data directory -export REDBOT_DATA_PATH="${REDBOT_DATA_PATH:-$HOME/.local/share/Red-DiscordBot/data}" - -# Your RedBot instance name (the name you used when creating it) -export BOT_INSTANCE="${BOT_INSTANCE:-redbot}" - -# Port the web UI listens on -export WEBUI_PORT="${WEBUI_PORT:-8098}" - -# Public URL users will visit. -# Kept in sync with the hardcoded value in webui/app.py for the Discord login links. -export WEBUI_URL="https://ttstoy.kingstons-scrapyard.net" - -# Discord channel ID to post TTS messages to (get it by right-clicking a channel) -export TTSTOY_WEBUI_CHANNEL_ID="${TTSTOY_WEBUI_CHANNEL_ID:-0}" - -# Shared state file path (must match TTSTOY_SHARED_STATE in the bot env if changed) -export TTSTOY_SHARED_STATE="${TTSTOY_SHARED_STATE:-/tmp/ttstoy_webui_state.json}" - -cd "$(dirname "$0")" - -# ── Setup venv ── -if [ ! -d "venv" ]; then - echo "[setup] Creating ttstoy webui venv..." - python3 -m venv venv -fi -source venv/bin/activate -pip install -q -r requirements.txt - -exec gunicorn "app:app" \ - --workers 1 \ - --bind "0.0.0.0:${WEBUI_PORT}" \ - --log-level info diff --git a/ttstoy/webui/templates/base.html b/ttstoy/webui/templates/base.html deleted file mode 100644 index b4f64b5..0000000 --- a/ttstoy/webui/templates/base.html +++ /dev/null @@ -1,247 +0,0 @@ - - - - - - {% block title %}TtsToy{% endblock %} - - {% block head %}{% endblock %} - - - - - - -
-{% if session.get('user_id') %} - -{% endif %} -
- {% with messages = get_flashed_messages(with_categories=true) %} - {% for cat, msg in messages %} -
{{ msg }}
- {% endfor %} - {% endwith %} - {% block content %}{% endblock %} -
- - -{% block scripts %}{% endblock %} - - diff --git a/ttstoy/webui/templates/chatterbox.html b/ttstoy/webui/templates/chatterbox.html deleted file mode 100644 index 0b8b621..0000000 --- a/ttstoy/webui/templates/chatterbox.html +++ /dev/null @@ -1,194 +0,0 @@ -{% extends "base.html" %} -{% block title %}Chatterbox — TtsToy{% endblock %} -{% block content %} -
-

Chatterbox Voices

-
- -
-

Upload New Voice

-
Upload a 5–15 second .wav or .mp3 clip of a single speaker. Short clips are auto-looped.
-
-
- - -
-
- - -
-
-
- - -
-
- -
-

Your Voices

-
  • Loading...
-
- - -{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/ttstoy/webui/templates/dashboard.html b/ttstoy/webui/templates/dashboard.html deleted file mode 100644 index a4e7bbd..0000000 --- a/ttstoy/webui/templates/dashboard.html +++ /dev/null @@ -1,442 +0,0 @@ -{% extends "base.html" %} -{% block title %}Dashboard — TtsToy{% endblock %} -{% block content %} -
-

Dashboard

- {{ session.get('discord_name','') }} -
- -
-
-
-

Active Mode

-
- {{ mode.upper() }} -
-
Global bot mode: {{ global_mode }}
-
-
-
-
-

Active Voice

-
{{ active_voice_display }}
-
- {% if mode == "chatterbox" %}Manage voices - {% elif mode == "minimax" %}Browse voices - {% elif mode == "dectalk" %}Voice commands - {% elif mode == "vox" %}Word list - {% endif %} -
-
-
-
- -
-

Submit TTS Message

-
Speak in VC plays in your voice channel. Generate Only creates the audio for playback here — no VC needed.
- -
-
- - -
-
- - -
-
- - - - -
- -
- - -
- -
-
-
- -
- - -
-
- -
- -
-

Mode Preference

-
Your personal mode override — does not affect other users.
-
- - -
-
- -
-

TTS Tips

- - - - - - - - - - - - - - - - - - - - - -
Voice switchingUse [mode|voice] tags to change voice mid-message. Everything after the tag uses that voice until the next tag. The voice part is optional for modes that don't need it.
- Example: [minimax|robotnik] I am the eggman [dectalk] [:nh]now I'm Harry -
Available modes - minimax · chatterbox · dectalk · morshu · vox -
Voice names - MiniMax: [minimax|Robotnik], [minimax|Gnome], etc.
- Chatterbox: [chatterbox|YourVoiceName] (uploaded filename)
- DECTalk: just [dectalk] — control voices in the text with [:np] Paul, [:nb] Betty, [:nh] Harry, etc.
- Morshu / VOX: [morshu] or [vox] — no voice selection needed -
Sound effectsInsert SFX emoji anywhere in your text — click the 🔊 button on the message box to browse available sounds.
Chatterbox tokens[laugh] [chuckle] [sigh] [gasp] [cough] [clear throat] [sniff] [groan] [shush]
-
-{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/ttstoy/webui/templates/dectalk.html b/ttstoy/webui/templates/dectalk.html deleted file mode 100644 index 392e848..0000000 --- a/ttstoy/webui/templates/dectalk.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends "base.html" %} -{% block title %}DECTalk — TtsToy{% endblock %} -{% block content %} -
-

DECTalk

-
- -
-

About

-
Classic 1980s robotic speech synthesizer. Voices are controlled inline using commands embedded in your text.
-
Server URL: {{ gcfg.get('dectalk_api_url', 'http://127.0.0.1:33001') }}
-
- -
-

Voice Commands

-
Embed these in your TTS text to switch voices mid-sentence.
- - - - {% for cmd, name, desc in [ - ("[:np]","Paul","Perfect Paul — default male"), - ("[:nb]","Betty","Beautiful Betty — female"), - ("[:nh]","Harry","Huge Harry — deep male"), - ("[:nf]","Frank","Frail Frank — male"), - ("[:nd]","Dennis","Doctor Dennis — male"), - ("[:nk]","Kit","Kit the Kid — child"), - ("[:nu]","Ursula","Uppity Ursula — female"), - ("[:nr]","Rita","Rough Rita — female"), - ("[:nw]","Wendy","Whispering Wendy — female"), - ] %} - - - - - - {% endfor %} - -
CommandVoiceDescription
{{ cmd }}{{ name }}{{ desc }}
-
- -
-

Example

- - [:np]Hello, I am Paul. [:nb]And I am Betty. [:nh]I am Harry, the big one. - -
- See the DECTalk manual for advanced rate, pitch, and phoneme control. -
-
-{% endblock %} diff --git a/ttstoy/webui/templates/login.html b/ttstoy/webui/templates/login.html deleted file mode 100644 index ea63f86..0000000 --- a/ttstoy/webui/templates/login.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block title %}Login — TtsToy{% endblock %} -{% block content %} -
-
-
-
TTSTOY
-
Web Interface
-
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% for cat, msg in messages %} -
{{ msg }}
- {% endfor %} - {% endwith %} - -
- Run [p]login in Discord.
- The bot will DM you a one-time key.
- Paste it below and click Login. -
- -
-
- - -
- -
-
-
-{% endblock %} diff --git a/ttstoy/webui/templates/minimax.html b/ttstoy/webui/templates/minimax.html deleted file mode 100644 index 4388f6b..0000000 --- a/ttstoy/webui/templates/minimax.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "base.html" %} -{% block title %}MiniMax — TtsToy{% endblock %} -{% block content %} -
-

MiniMax

-
- -
-

About

-
MiniMax is a cloud TTS API with natural-sounding voices. Requires an API key set by the bot owner.
-
Current model: {{ gcfg.get('minimax_model', 'speech-01-turbo') }}
-
- -
-

Available Voices

-
Click a voice to set it as your personal voice.
- - - - {% for name, vid in voices.items() %} - - - - - - {% endfor %} - -
NameVoice ID
{{ name }}{{ vid }}
-
-
- -
-

Models

- - - - {% for m, desc in [("speech-01-turbo","Fast and cheap"),("speech-01-hd","Higher quality, slower"),("speech-02-turbo","Newer fast model"),("speech-02-hd","Newer high quality model")] %} - - {% endfor %} - -
ModelDescription
{{ m }}{{ desc }}
-
Model can only be changed by the bot owner via [p]ttstoy model.
-
-{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/ttstoy/webui/templates/profile.html b/ttstoy/webui/templates/profile.html deleted file mode 100644 index abbd52f..0000000 --- a/ttstoy/webui/templates/profile.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends "base.html" %} -{% block title %}Profile — TtsToy{% endblock %} -{% block content %} -
-

Profile

-
- -
-

Discord

-
- {% if session.get('avatar_url') %} - - {% endif %} -
-
{{ session.get('discord_name', 'Unknown') }}
-
ID: {{ session.get('user_id') }}
-
-
- {% if user_info %} - - {% for k, v in user_info.items() %} - - {% endfor %} -
{{ k }}{{ v }}
- {% else %} -
No additional info available from the bot yet.
- {% endif %} -
- -
-

TtsToy Settings

- - - - - - - - - - - - - - - - - - - - - -
Active Voice - {% if ucfg.get('minimax_voice') %} - {% set v = ucfg.minimax_voice %} - {{ v.split('_',1)[1].rsplit('.',1)[0] if '_' in v else v }} - {% else %} - Global default - {% endif %} -
Uploaded Voices{{ ucfg.get('chatterbox_voices', []) | length }}
Global TTS Mode{{ gcfg.get('tts_mode','minimax').upper() }}
SFX Volume{{ gcfg.get('sfx_volume', 100) }}%
Accessibility Mode{{ 'On' if gcfg.get('accessibility_mode') else 'Off' }}
-
- -{% if ucfg.get('chatterbox_voices') %} -
-

Per-Voice Settings

- - - - {% for v in ucfg.get('chatterbox_voices', []) %} - {% set display = v.split('_',1)[1].rsplit('.',1)[0] if '_' in v else v %} - - - - - - - - {% endfor %} - -
VoiceTempExagVol dBSpeed
{{ display }}{{ ucfg.get('chatterbox_temperature_offsets',{}).get(v, '—') }}{{ ucfg.get('chatterbox_exaggeration_offsets',{}).get(v, '—') }}{{ ucfg.get('chatterbox_volume_offsets',{}).get(v, '—') }}{{ ucfg.get('chatterbox_speed_offsets',{}).get(v, '—') }}
-
-{% endif %} -{% endblock %} diff --git a/ttstoy/webui/templates/sfx.html b/ttstoy/webui/templates/sfx.html deleted file mode 100644 index 5a71506..0000000 --- a/ttstoy/webui/templates/sfx.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "base.html" %} -{% block title %}SFX Manager — TtsToy{% endblock %} -{% block content %} -
-

Sound Effects Manager

-
Each SFX has one emoji trigger and one audio file. Users insert the emoji in their TTS text to play the sound.
-
- -
-

Add New SFX

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- -
-

SFX Library

- - - -
EmojiNameFile
Loading...
-
-{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/ttstoy/webui/templates/vox.html b/ttstoy/webui/templates/vox.html deleted file mode 100644 index bfeccd5..0000000 --- a/ttstoy/webui/templates/vox.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% block title %}VOX — TtsToy{% endblock %} -{% block content %} -
-

VOX Announcer

-
- -
-

About

-
Black Mesa / Half-Life VOX announcer. Only words in the dictionary are spoken — unknown words are silently skipped.
-
Current pack: {{ current_pack }}  |  Available: {{ packs | join(', ') }}
-
- -
-

Available Words ({{ words | length }})

-
- -
-
- {% for w in words %} - {{ w }} - {% endfor %} -
-
-{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/ttstoy/webui/ttstoy_constants.py b/ttstoy/webui/ttstoy_constants.py deleted file mode 100644 index a93b702..0000000 --- a/ttstoy/webui/ttstoy_constants.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Shared constants mirrored from ttstoy.py — no RedBot import needed.""" - -MINIMAX_VOICES = { - "BigMan": "moss_audio_54f969bf-12c1-11f1-93de-a6f4120d2cc7", - "BlueGnome": "moss_audio_166dfe7c-1390-11f1-b6f2-729162d0a8d2", - "Dracafow": "moss_audio_13cb66d2-12c7-11f1-b6f2-729162d0a8d2", - "Gaben": "moss_audio_5239ca93-115b-11f1-b9c4-4ea5324904c7", - "Gregori": "moss_audio_63a397aa-24c9-11f1-918f-5a2de67f838a", - "Gnome": "moss_audio_b304011b-11b2-11f1-90fe-36953c023630", - "King": "moss_audio_39c16b2c-1158-11f1-995d-b25e2f30d3fa", - "Peppa": "moss_audio_d656489f-114b-11f1-995d-b25e2f30d3fa", - "Robotnik": "moss_audio_123443a8-11b4-11f1-bfa6-763108879732", -} - -DECTALK_VOICES = { - "paul": ("[:np]", "Perfect Paul — default male"), - "betty": ("[:nb]", "Beautiful Betty — female"), - "harry": ("[:nh]", "Huge Harry — deep male"), - "frank": ("[:nf]", "Frail Frank — male"), - "dennis": ("[:nd]", "Doctor Dennis — male"), - "kit": ("[:nk]", "Kit the Kid — child"), - "ursula": ("[:nu]", "Uppity Ursula — female"), - "rita": ("[:nr]", "Rough Rita — female"), - "wendy": ("[:nw]", "Whispering Wendy — female"), -}