tts-voice-creator-clone-and.../server.py
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

211 lines
9.8 KiB
Python

"""TTS Voice Creator - Clone and Design — FastAPI entry point (thin wrapper)."""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from contextlib import asynccontextmanager
from urllib.parse import parse_qs
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from starlette.datastructures import MutableHeaders
from core.constants import STATIC_DIR, _BufferHandler
from core.config import _load_settings, _ensure_external_api_key
from core.voice_index import refresh_voice_index_background
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db
logger = logging.getLogger("uvicorn.error")
# ── App ───────────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
# Uvicorn resets loggers during startup when run via CLI.
# We must attach our handlers here to ensure they capture the active logs.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
l = logging.getLogger(name)
if _buf_handler not in l.handlers:
l.addHandler(_buf_handler)
if "_file_handler" in globals() and _file_handler not in l.handlers:
l.addHandler(_file_handler)
try:
refresh_voice_index_background(_load_settings())
except Exception as exc:
logger.warning("Could not start voice index refresh: %s", exc)
yield
app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan)
# ── Response compression ──────────────────────────────────────────────────────
# Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the
# large JS bundle and voice-list JSON. Skips already-compressed audio/images.
app.add_middleware(GZipMiddleware, minimum_size=1024)
# ── In-memory log buffer handler ──────────────────────────────────────────────
_buf_handler = _BufferHandler()
_buf_handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(_buf_handler)
# ── File log handler (rotates at 5 MB, keeps 3 files) ────────────────────────
_LOG_DIR = Path(os.environ.get("LOG_DIR", "/logs"))
try:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_file_handler = RotatingFileHandler(
_LOG_DIR / "app.log",
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
_file_handler.setLevel(logging.INFO)
_file_handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
))
logging.getLogger().addHandler(_file_handler)
except Exception as _log_err:
logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err)
# ── Static-asset caching middleware ──────────────────────────────────────────
# JS and CSS are served with ?v=<version> by loader.js — safe to cache for 1 year.
# Section HTML uses ?v=<timestamp> (always fresh) so no long-term cache there.
#
# Pure ASGI middleware, not @app.middleware("http") (= Starlette's
# BaseHTTPMiddleware) — that implementation wraps every request in a task
# group around call_next(), and a client disconnecting mid-StreamingResponse
# (e.g. the live-attribution SSE stream hitting its idle timeout) races that
# task group and raises "RuntimeError: No response returned." This class
# only touches response headers via the raw `send` callable, so it never
# holds the response open the way call_next() does.
class StaticCacheHeadersMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope["path"]
has_version = "v" in parse_qs((scope.get("query_string") or b"").decode())
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = MutableHeaders(scope=message)
if has_version and (path.startswith("/static/js/") or path.endswith(".css")):
headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif path.startswith("/static/sections/"):
headers["Cache-Control"] = "no-store"
elif path.startswith("/static/vendor/"):
headers["Cache-Control"] = "public, max-age=86400"
elif path.startswith("/static/") and not has_version:
# Bootstrap files (style.css, loader.js, nav.js, …) are loaded without a ?v= query.
# Revalidate on every load so GUI edits appear after a normal reload — the server
# returns 304 when the file is unchanged, so this stays cheap.
headers["Cache-Control"] = "no-cache"
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(StaticCacheHeadersMiddleware)
# ── API-key gate for non-browser callers ────────────────────────────────────
# The app's own UI calls /api/* same-origin from the browser and needs no
# key — everything else (curl, scripts, MCP clients, agents) does. Gated by
# Origin/Referer host matching the request's own Host header, which the
# browser sets automatically and a bare script/curl call generally doesn't.
# /mcp always requires the key regardless of origin, since no in-app browser
# code calls it — it exists specifically for external MCP clients.
# Raw ASGI (not @app.middleware("http")/BaseHTTPMiddleware) for the same
# reason as StaticCacheHeadersMiddleware above: that wrapper's task-group
# around call_next() fights with a client disconnecting mid-SSE-stream.
class ApiKeyGateMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope["path"]
if not (path.startswith("/api/") or path == "/mcp"):
await self.app(scope, receive, send)
return
if not _load_settings().get("external_api_key_required"):
# Off by default — see the settings-key comment in core/config.py
# for why. /mcp still needs SOME signal it's being used
# deliberately even while the general gate is off, but that's a
# judgment call for whoever enables it, not a silent bypass.
await self.app(scope, receive, send)
return
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
host = headers.get("host", "")
def _same_origin(url: str) -> bool:
if not url or not host:
return False
try:
from urllib.parse import urlparse
return urlparse(url).netloc == host
except Exception:
return False
is_browser_same_origin = path != "/mcp" and (
_same_origin(headers.get("origin", "")) or _same_origin(headers.get("referer", ""))
)
if is_browser_same_origin:
await self.app(scope, receive, send)
return
expected = _ensure_external_api_key()
provided = headers.get("x-api-key", "")
if not provided and headers.get("authorization", "").lower().startswith("bearer "):
provided = headers["authorization"][7:]
if provided and provided == expected:
await self.app(scope, receive, send)
return
import json as _json
body = _json.dumps({"detail": "Missing or invalid API key — pass it as X-API-Key. Find/regenerate it in Settings > API Keys > External API Access."}).encode()
await send({
"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
})
await send({"type": "http.response.body", "body": body})
app.add_middleware(ApiKeyGateMiddleware)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(admin.router)
app.include_router(settings.router)
app.include_router(library.router)
app.include_router(stt.router)
app.include_router(sources.router)
app.include_router(docker.router)
app.include_router(tts.router)
app.include_router(conversation.router)
app.include_router(reader.router)
app.include_router(characters.router)
app.include_router(rehearsals_db.router)
# ── Static files ──────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ── Dev runner ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7890, log_level="info")