Files
scrapyard-cogworks/ttstoy/webui/app.py
T
owen 9b992d6dbe fix: persist user guild map when login token is registered
user_guild_map was only written when a login token was consumed on the
webui login page. Users on stale session cookies never re-login, so
their jobs carried empty guild_id and TTS was posted to whatever global
channel happened to match instead of the server they logged in from.
Persist the guild as soon as the bot registers the token so
_resolve_guild_id() falls back to the correct login guild.
2026-09-12 22:12:21 -05:00

1280 lines
48 KiB
Python

"""
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/<job_id>/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/<job_id>/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/<job_id>/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?:\w+:\d+>|:[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/<filename>", 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/<folder_name>/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/<folder_name>", 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)