tts-voice-creator-clone-and.../routes/tts.py
mARTin-B78 5fecbf06d4 Fix Fish-Speech emotion tags, wire book context into portraits, add emotion controls app-wide (v1.20.5)
Fish-Speech emotion tags were silently ignored on non-English books: per-line
emotions are LLM-generated in the book's own language, but Fish-Speech only
recognizes English [tag] markers, and a double-tagging bug was stacking a
broken server-derived tag on top of the client's own. Added a DE->EN
translation table and removed the double-tagging. Also wires the existing
book-profile context and race_species field into character portrait prompts
(previously only used for voice design), adds a recast-until-threshold loop
for casting, and adds backend-aware emotion quick-picks to Read Aloud, Try a
Voice, and Conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 18:02:41 +02:00

1399 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""TTS backends, preview, style-variation, streaming, voice-design, OpenAI-compat proxy."""
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
import re
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import quote
import requests
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
_routing_log_add, CONFIG_DIR, _VOICE_PEAK_DBFS,
)
from core.routing import (
_load_tts_routes, _resolve_tts_route, _route_backend,
_canonical_app_name, _request_app_name, _detect_text_language,
_routing_log_request,
)
from core.presets import _load_design_presets, _virtual_voice_id, _resolve_virtual_voice
from core.voice import (
_find_voice_audio, _active_voices_dir,
_voice_audio_files, _load_meta, _save_meta,
_AUDIO_EXTS,
_read_reference_text,
_voice_health,
)
from core.voice_index import indexed_voices, refresh_voice_index_background
from core.validation import _validate_http_url
from core.registry import _registry_put, TEMP_DIR
from core.tts_helpers import (
_tts_request_audio, _open_tts_stream_response, _read_tts_stream_response,
_iter_tts_stream_response, _preview_request_audio, _prepare_proxy_audio,
_requested_response_format, _audio_ext_media, _apply_route_sounds,
_route_sound_path, _voice_design_request_audio, _voice_design_voice_request_audio,
_nvidia_clone_request_audio, _parse_voice_design_dialogue,
_infer_voice_design_gender, _voice_design_dialogue_request_audio,
_fishspeech_request_audio,
)
from core.audio import _duration, _export_normalized_wav
from core.docker_client import _docker_post
logger = logging.getLogger("uvicorn.error")
router = APIRouter()
_SEED_FINDER_VOICE_LOCK = threading.Lock()
# ── TTS backend helpers ───────────────────────────────────────────────────────
def _backend_port_label(url: str) -> str:
from urllib.parse import urlsplit
try:
parts = urlsplit(url)
if parts.port:
return str(parts.port)
except Exception:
pass
return ""
def _backend_display_name(backend: str, url: str) -> str:
names = {
"voice_clone": "Voice Clone/Base (WAV File)",
"voice_design": "Voice Design",
"customvoice": "CustomVoice",
"streaming": "Streaming (WAV File)",
"nvidia_magpie": "NVIDIA Magpie TTS",
"nvidia_zeroshot":"NVIDIA Magpie Zeroshot Clone",
"nvidia_flow": "NVIDIA Magpie Flow Clone",
"kokoro": "Kokoro FastAPI (82M)",
"vibevoice": "VibeVoice TTS",
"xtts": "XTTS v2",
"fishspeech": "Fish-Speech (Clone + Emotion)",
}
port = _backend_port_label(url)
return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend)
def _backend_capabilities(backend: str) -> dict:
caps = {
"voice_clone": {
"purpose": "Clone a speaker from a short WAV/reference clip.",
"identity": "Strongest match to saved WAV voices.",
"style": "Weak per-request style; instruct may be ignored.",
"best_for": "Known voices, multilingual cloning, benchmarks, and reliable speaker identity.",
"uses_wav": True, "style_aware": False, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "13 s", "quality": "Premium clone", "ram": "~6 GB",
},
"voice_design": {
"purpose": "Create or reuse prompt-designed voices from natural-language descriptions.",
"identity": "Prompt persona, not the selected WAV speaker unless you first export/clone it.",
"style": "Strong style and emotion control through instruct text.",
"best_for": "New characters, personas, dialogue, and designing reference WAVs to clone later.",
"uses_wav": False, "style_aware": True, "true_streaming": False,
"speed": "~0.4× GPU", "latency": "13 s", "quality": "Premium", "ram": "~5 GB",
},
"customvoice": {
"purpose": "Generate speech with the CustomVoice model voices.",
"identity": "Uses CustomVoice speakers, not arbitrary active WAV voices unless trained/configured there.",
"style": "Good per-request style and emotion control.",
"best_for": "Controlled style with configured CustomVoice speakers.",
"uses_wav": False, "style_aware": True, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "13 s", "quality": "Premium", "ram": "~5 GB",
},
"streaming": {
"purpose": "Low-latency playback from saved WAV/reference voices.",
"identity": "Same WAV voice identity path as Base.",
"style": "Weak per-request style in the current streaming server.",
"best_for": "Long text, assistants, Open WebUI/SillyTavern playback that can start before completion.",
"uses_wav": True, "style_aware": False, "true_streaming": True,
"speed": "~0.1× GPU", "latency": "0.51 s", "quality": "Premium", "ram": "~5 GB",
},
"nvidia_magpie": {
"purpose": "Generate speech with NVIDIA Magpie fixed speaker voices.",
"identity": "Uses Magpie speaker aliases such as sofia, aria, jason, leo, and john; it is not a WAV voice-cloning model.",
"style": "Language and speaker are controlled by the backend voice config; per-request style text is usually ignored.",
"best_for": "Fast local NVIDIA TTS voices and OpenAI-compatible assistant playback.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.05× GPU", "latency": "0.30.8 s", "quality": "High", "ram": "~10 GB",
},
"nvidia_zeroshot": {
"purpose": "Clone a saved library voice through NVIDIA Magpie TTS Zeroshot NIM.",
"identity": "Sends the selected WAV as audio_prompt; no prompt transcript is required.",
"style": "Best with a clear 3-10 second prompt. Optional quality params can be configured in Settings.",
"best_for": "Fast NVIDIA reference-audio cloning, streaming-class use cases, live agents, and games.",
"uses_wav": True, "style_aware": False, "true_streaming": False,
"speed": "~0.1× GPU", "latency": "0.51 s", "quality": "High clone", "ram": "~10 GB",
},
"nvidia_flow": {
"purpose": "Clone a saved library voice through NVIDIA Magpie TTS Flow NIM.",
"identity": "Sends the selected WAV plus its exact saved reference transcript.",
"style": "Offline high-fidelity clone path; prompt transcript must match the reference audio.",
"best_for": "Studio-style dubbing, narration, and podcast-quality offline generation.",
"uses_wav": True, "style_aware": False, "true_streaming": False,
"speed": "~0.2× GPU", "latency": "12 s", "quality": "Studio", "ram": "~10 GB",
},
"kokoro": {
"purpose": "High-quality English TTS with Kokoro 82M model. OpenAI-compatible endpoint.",
"identity": "Uses Kokoro built-in voices (af_bella, bf_emma, am_adam, …); no WAV cloning.",
"style": "Voice selection via voice ID. Style instruction is not supported.",
"best_for": "Fast, high-quality CPU TTS. Low RAM footprint. Easy local Docker setup.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.1× CPU", "latency": "0.20.5 s", "quality": "High (82M)", "ram": "300 MB CPU",
},
"vibevoice": {
"purpose": "VibeVoice TTS service. Simple text-in, audio-out REST endpoint.",
"identity": "Single built-in voice; no WAV cloning or voice selection.",
"style": "Text only — no voice ID or style parameters.",
"best_for": "Lightweight local TTS on port 8027. Minimal setup, fast response.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "fast", "latency": "0.21 s", "quality": "High", "ram": "~4 GB",
},
"xtts": {
"purpose": "XTTS v2 via xtts-api-server. OpenAI-compatible endpoint with speaker selection.",
"identity": "Uses speakers registered in the XTTS server; not WAV voice cloning.",
"style": "Speaker selected by voice ID. Style instruction not supported.",
"best_for": "Local multi-speaker TTS with XTTS v2 model. Coqui/daswer123 docker setup.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "13 s", "quality": "High", "ram": "~5 GB",
},
"fishspeech": {
"purpose": "Clone a saved WAV voice AND control per-line tone via inline emotion markers.",
"identity": "Clones the selected voice's reference WAV — consistent speaker identity across lines.",
"style": "Strong: emotion/tone markers like (angry), (whispering), (excited) are honoured per request.",
"best_for": "Consistent character voices that still react to tone changes — ideal for the Rehearser.",
"uses_wav": True, "style_aware": True, "true_streaming": False,
"speed": "~0.4× GPU", "latency": "14 s", "quality": "Premium", "ram": "~4 GB",
},
}
return caps.get(_clean_preview_backend(backend), {})
def _backend_health(url: str) -> bool:
base = url.rstrip("/")
for ep in ("/health", "/v1/health", "/v1/audio/list_voices"):
try:
r = requests.get(f"{base}{ep}", timeout=2)
if r.status_code == 200:
return True
except Exception:
continue
return False
def _backend_available(backend: str, voices: list, health: bool) -> bool:
if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "fishspeech"}:
return health
return bool(voices) or health
def _seed_finder_voices_json_path(settings: dict) -> Path:
configured = Path(str(settings.get("tts_config_dir") or "/tts-config"))
candidates = [
configured / "voices.json",
Path("/tts-config/voices.json"),
Path("/config/voices.json"),
]
for path in candidates:
if path.exists():
return path
return candidates[0]
def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str:
output_dir = _active_voices_dir(settings)
try:
rel = audio.resolve().relative_to(output_dir.resolve())
return "/voices/" + rel.as_posix()
except Exception:
pass
return f"/voices/{audio.name}"
def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
cfg = dict(voices.get(voice) or {})
if cfg.get("ref_audio"):
return cfg
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio = _find_voice_audio(voice, scan_dir)
if not audio:
raise RuntimeError(f"reference WAV for voice {voice!r} not found")
_has_ref, ref_text = _read_reference_text(audio)
lang = {
"DE": "German", "EN": "English", "GB": "English", "FR": "French",
"ES": "Spanish", "IT": "Italian", "PT": "Portuguese", "NL": "Dutch",
"PL": "Polish", "ZH": "Chinese", "JA": "Japanese", "KO": "Korean",
}.get(voice.split("_", 1)[0].upper(), "Auto")
return {
"ref_audio": _seed_finder_backend_ref_audio(audio, settings),
"language": lang,
"chunk_size": 4,
"ref_text": ref_text,
}
def _seed_finder_request_audio(text: str, voice: str, settings: dict, seed: int) -> tuple[bytes, str]:
"""Generate a Seed Finder sample from the reference WAV, not the voice's cached .pt.
The Qwen3 VoiceClone server does not accept per-request ``seed`` on
/v1/audio/speech; it reads the seed from voices.json. For Seed Finder we create
a temporary voice entry that points at the selected reference WAV, omits the
existing speaker embedding, sets the requested seed, generates once, then
restores voices.json and removes the temporary .pt cache.
"""
voices_path = _seed_finder_voices_json_path(settings)
if not voices_path.exists():
raise RuntimeError(f"TTS voices.json not found at {voices_path}")
safe_voice = re.sub(r"[^A-Za-z0-9_\-.]+", "_", voice).strip("._-")[:60] or "voice"
temp_voice = f"__seedfinder_{safe_voice}_{int(seed)}_{uuid.uuid4().hex[:8]}"
temp_pt = Path("/tts-config/speakers") / f"{temp_voice}.pt"
with _SEED_FINDER_VOICE_LOCK:
original_text = voices_path.read_text(encoding="utf-8")
try:
voices = json.loads(original_text or "{}")
if not isinstance(voices, dict):
raise RuntimeError("voices.json is not an object")
cfg = _seed_finder_voice_config(voice, voices, settings)
cfg = dict(cfg)
cfg.pop("speaker_embeddings", None)
cfg.pop("speaker embeddings", None)
cfg["seed"] = int(seed)
voices[temp_voice] = cfg
voices_path.write_text(json.dumps(voices, indent=2, ensure_ascii=False), encoding="utf-8")
os.utime(voices_path, None)
time.sleep(0.35) # let the backend hot-reload voices.json before resolve_voice()
return _preview_request_audio(text, temp_voice, settings, "", "voice_clone")
finally:
try:
voices_path.write_text(original_text, encoding="utf-8")
os.utime(voices_path, None)
except Exception as exc:
logger.warning("Failed to restore voices.json after Seed Finder sample: %s", exc)
try:
if temp_pt.exists():
temp_pt.unlink()
except Exception:
pass
_TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers", "/voices")
_KOKORO_BUILTIN_VOICES = [
"af", "af_bella", "af_nicole", "af_sarah", "af_sky",
"bf_emma", "bf_isabella", "am_adam", "am_michael",
"bm_george", "bm_lewis",
]
def _voice_ids_from_payload(payload) -> list:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("data", "voices", "speakers"):
value = payload.get(key)
if isinstance(value, list):
if key == "data":
return [m.get("id", m) if isinstance(m, dict) else m for m in value]
return value
grouped = []
for value in payload.values():
if isinstance(value, list):
grouped.extend(value)
elif isinstance(value, dict):
nested = _voice_ids_from_payload(value)
if nested:
grouped.extend(nested)
return grouped
def _active_library_voice_options(settings: dict) -> list[dict]:
try:
indexed = []
for voice in indexed_voices(settings):
if voice.get("enabled", True) is False:
continue
indexed.append({
"id": voice.get("id", ""),
"name": voice.get("name") or voice.get("id", ""),
"duration": voice.get("duration"),
"has_ref": bool(voice.get("has_ref")),
"has_transcript": bool(voice.get("transcript")),
"seed": voice.get("seed"),
})
return [v for v in indexed if v["id"]]
except Exception:
pass
active_dir = _active_voices_dir(settings)
voices = []
seen = set()
if not active_dir.exists():
return voices
from core.voice import _read_reference_text
for audio in sorted(_voice_audio_files(active_dir), key=lambda p: p.stem.lower()):
if audio.stem in seen:
continue
seen.add(audio.stem)
meta = _load_meta(audio)
if meta.get("enabled", True) is False:
continue
has_ref, transcript = _read_reference_text(audio)
try:
dur = round(_duration(audio), 2)
except Exception:
dur = None
voices.append({
"id": audio.stem,
"name": audio.stem,
"duration": dur,
"has_ref": has_ref,
"has_transcript": bool(transcript),
"seed": meta.get("seed"),
})
return voices
def _fetch_backend_voices(settings: dict, backend: str) -> list:
backend = _clean_preview_backend(backend)
if backend in {"voice_clone", "streaming", "nvidia_zeroshot", "nvidia_flow"}:
return _active_library_voice_options(settings)
tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or ""
tts_hdrs = {"Authorization": f"Bearer {key.strip()}"} if key.strip() else {}
for ep in _TTS_VOICE_ENDPOINTS:
try:
r = requests.get(f"{tts_url}{ep}", headers=tts_hdrs, timeout=5)
if r.status_code == 200:
voices = _voice_ids_from_payload(r.json())
if voices:
return voices
except Exception:
continue
if backend == "kokoro":
return _KOKORO_BUILTIN_VOICES
return []
# ── TTS restart helpers ───────────────────────────────────────────────────────
def _clear_tts_restart_flags(settings: dict | None = None) -> int:
cleared = 0
settings = settings or _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
if scan_dir.exists():
seen: set[str] = set()
for audio in _voice_audio_files(scan_dir):
if audio.stem in seen:
continue
seen.add(audio.stem)
meta = _load_meta(audio)
if meta.pop("needs_tts_restart", None) is not None:
_save_meta(audio, meta)
cleared += 1
if cleared:
refresh_voice_index_background(settings, force=True)
return cleared
def _tts_container_names() -> list[str]:
multi = os.environ.get("TTS_CONTAINER_NAMES", _TTS_CONTAINERS_RAW).strip()
if multi:
return [c.strip() for c in multi.split(",") if c.strip()]
single = os.environ.get("TTS_CONTAINER_NAME", _TTS_CONTAINER).strip()
return [single] if single else []
# ── Stream session store ──────────────────────────────────────────────────────
_TTS_STREAM_SESSION_TTL = 15 * 60
_tts_stream_sessions: dict[str, dict] = {}
def _purge_tts_stream_sessions() -> None:
now = time.time()
expired = [sid for sid, item in _tts_stream_sessions.items() if now - item.get("created", 0) > _TTS_STREAM_SESSION_TTL]
for sid in expired:
_tts_stream_sessions.pop(sid, None)
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("/api/tts-voices")
async def tts_voices(backend: str = "voice_clone"):
return _fetch_backend_voices(_load_settings(), backend)
@router.get("/api/tts-backends")
async def tts_backends():
settings = _load_settings()
items = []
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "fishspeech", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"):
url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
voices = _fetch_backend_voices(settings, backend)
health = _backend_health(url)
available = _backend_available(backend, voices, health)
items.append({
"id": backend,
"label": _backend_display_name(backend, url),
"url": url,
"port": _backend_port_label(url),
"available": available,
"voice_count": len(voices) if isinstance(voices, list) else 0,
**_backend_capabilities(backend),
})
return {"backends": items}
@router.post("/api/tts/restart")
async def restart_tts_container():
containers = _tts_container_names()
if not containers:
raise HTTPException(400, "No TTS container names configured (set TTS_CONTAINER_NAMES in docker-compose.yml)")
results = []
errors = []
for container in containers:
path = f"/containers/{quote(container, safe='')}/restart?t=10"
try:
# _docker_post is a raw blocking socket call (core/docker_client.py)
# and this loop can span two containers x up to 10s each — run off
# the event loop thread, or every other request on this server
# (including a plain GET /api/characters) hangs for the whole
# restart instead of just this one call. Confirmed live: the
# Studio Voices tab's own character fetch silently failed and
# rendered "No characters yet" while a restart triggered elsewhere
# was still in flight.
code, raw = await asyncio.to_thread(_docker_post, path)
if code not in (204, 304):
detail = raw.split("\r\n\r\n", 1)[-1].strip() or f"HTTP {code}"
errors.append(f"{container}: {detail}")
else:
results.append(container)
except PermissionError:
raise HTTPException(502, "No permission to access /var/run/docker.sock — is the socket mounted in docker-compose.yml?")
except Exception as e:
errors.append(f"{container}: {e}")
cleared = 0
try:
cleared = _clear_tts_restart_flags()
except Exception as e:
logger.warning("Could not clear TTS restart flags: %s", e)
if errors and not results:
raise HTTPException(502, "; ".join(errors))
return {
"ok": True,
"restarted": results,
"errors": errors,
"cleared_restart_flags": cleared,
}
@router.get("/api/tts/restart-info")
async def tts_restart_info():
containers = _tts_container_names()
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
return {"containers": containers, "socket_available": sock_ok}
@router.post("/api/tts/restart-flags/clear")
async def clear_tts_restart_flags_endpoint():
try:
cleared = _clear_tts_restart_flags()
except Exception as e:
raise HTTPException(500, f"Could not clear TTS restart flags: {e}")
return {"ok": True, "cleared_restart_flags": cleared}
@router.get("/api/tts-stream-health")
async def tts_stream_health():
from core.constants import _TTS_STREAM_DEFAULT
settings = _load_settings()
try:
stream_url = settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT
base_url = _validate_http_url(stream_url, allow_private=True).rstrip("/")
resp = await asyncio.to_thread(requests.get, f"{base_url}/health", timeout=3)
return {"ok": resp.ok, "status_code": resp.status_code, "url": base_url}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.post("/api/tts-stream-session")
async def tts_stream_session(request: Request):
data = await request.json()
text = str(data.get("text", "")).strip()
voice = str(data.get("voice", "")).strip()
if not voice:
raise HTTPException(400, "voice is required")
if not text:
raise HTTPException(400, "text is required")
_purge_tts_stream_sessions()
sid = uuid.uuid4().hex
instruct = str(data.get("instruct") or data.get("style_instruction") or "").strip()
_tts_stream_sessions[sid] = {"created": time.time(), "text": text, "voice": voice, "instruct": instruct}
return {"ok": True, "url": f"/api/tts-stream-session/{sid}"}
@router.get("/api/tts-stream-session/{sid}")
async def tts_stream_playback(sid: str):
_purge_tts_stream_sessions()
item = _tts_stream_sessions.pop(sid, None)
if item is None:
raise HTTPException(404, "stream session expired or not found")
settings = _load_settings()
try:
resp = await asyncio.to_thread(_open_tts_stream_response, item["text"], item["voice"], settings, item.get("instruct", ""))
except Exception as e:
raise HTTPException(502, f"TTS stream error: {e}")
media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav"
return StreamingResponse(
_iter_tts_stream_response(resp),
media_type=media_type,
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
"Content-Disposition": f"inline; filename=\"{item['voice']}_stream.wav\"",
},
)
@router.post("/api/tts-preview")
async def tts_preview(request: Request):
data = await request.json()
text: str = data["text"]
voice: str = data["voice"]
response_format = _requested_response_format(data)
instruct = str(data.get("instruct") or data.get("style_instruction") or "")
backend = _clean_preview_backend(data.get("backend", "voice_clone"))
settings = _load_settings()
# Per-request generation overrides (seed / temperature / top_p). When provided,
# force them onto the stability params for this backend so _apply_tts_extra_params
# forwards them to the engine (backends that reject them fall back gracefully).
_overrides = {}
for _k in ("seed", "temperature", "top_p"):
_v = data.get(_k)
if _v is not None and _v != "":
try:
_overrides[_k] = int(_v) if _k == "seed" else float(_v)
except (TypeError, ValueError):
pass
_speed_v = data.get("speed")
if _speed_v is not None and _speed_v != "":
try:
_speed_f = float(_speed_v)
if _speed_f != 1.0:
_overrides["speed"] = max(0.1, min(4.0, _speed_f))
except (TypeError, ValueError):
pass
if _overrides:
from core.config import _tts_extra_params
settings = dict(settings)
eff = dict(_tts_extra_params(settings, backend) or {})
eff.update(_overrides)
settings["tts_stability_enabled"] = True
by_backend = dict(settings.get("tts_extra_params_by_backend") or {})
by_backend[backend] = eff
settings["tts_extra_params_by_backend"] = by_backend
if data.get("apply_persona"):
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir)
persona = _load_meta(wav).get("persona", "") if wav else ""
if not persona:
# Confirmed live: checking "Apply character persona" on a voice with
# no persona text saved (the common case — persona is a manually-
# typed field on the Voice Inspector page, never auto-filled) used
# to silently do nothing, which looked indistinguishable from the
# feature being broken. Fail loud instead so the user knows to set
# a persona first, rather than "why doesn't this work."
raise HTTPException(400, f"Voice '{voice}' has no character persona saved — set one on the Voice Inspector page first, or uncheck 'Apply character persona.'")
llm_url = (settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
llm_model = settings.get("llm_model") or ""
try:
from routes.conversation import _rewrite_with_persona_sync
text = await asyncio.to_thread(_rewrite_with_persona_sync, text, persona, llm_url, llm_model)
except Exception as e:
raise HTTPException(502, f"Persona rewrite failed: {e}")
try:
if backend == "voice_clone" and data.get("seed_finder") and "seed" in _overrides:
audio, media_type = await asyncio.to_thread(
_seed_finder_request_audio, text, voice, settings, int(_overrides["seed"])
)
else:
audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend)
except Exception as e:
raise HTTPException(502, f"TTS error: {e}")
audio, media_type, ext, duration_sec, clipped = _prepare_proxy_audio(audio, media_type, response_format)
return Response(
content=audio,
media_type=media_type,
headers={
"Content-Disposition": f'inline; filename="{voice}_preview.{ext}"',
"X-TTS-Audio-Duration": f"{duration_sec:.3f}" if duration_sec is not None else "",
"X-TTS-Audio-Clipped": "true" if clipped else "false",
},
)
@router.post("/api/tts-style-variation")
async def tts_style_variation(request: Request):
data = await request.json()
source_voice = str(data.get("source_voice") or data.get("voice") or "").strip()
new_voice_id = str(data.get("voice_id") or data.get("new_voice_id") or "").strip()
text = str(data.get("text") or data.get("transcript") or "").strip()
instruct = str(data.get("instruct") or data.get("style_instruction") or "").strip()
backend = _clean_preview_backend(data.get("backend", "customvoice"))
if not source_voice:
raise HTTPException(400, "source_voice is required")
if not new_voice_id:
raise HTTPException(400, "new voice id is required")
if not re.match(r"^[A-Za-z0-9_\-\.]+$", new_voice_id):
raise HTTPException(400, "Voice ID may only contain A-Z, 0-9, _, -, .")
if not text:
raise HTTPException(400, "text/transcript is required")
if not instruct:
raise HTTPException(400, "style instruction is required")
settings = _load_settings()
try:
audio, media_type = await asyncio.to_thread(_preview_request_audio, text, source_voice, settings, instruct, backend)
audio, media_type, _ext, duration, _clipped = _prepare_proxy_audio(audio, media_type, "wav")
except Exception as e:
raise HTTPException(502, f"Style variation synthesis failed: {e}")
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_style_variation.wav"
tmp.write_bytes(audio)
out_dir = _active_voices_dir(settings)
out_dir.mkdir(parents=True, exist_ok=True)
wav_dest = out_dir / f"{new_voice_id}.wav"
txt_dest = out_dir / f"{new_voice_id}.reference.txt"
from core.voice import _remove_audio_variants
_remove_audio_variants(out_dir, new_voice_id)
loudness = _export_normalized_wav(tmp, wav_dest)
txt_dest.write_text(text, encoding="utf-8")
meta = _load_meta(wav_dest)
meta.update({
"enabled": True,
"loudness": loudness,
"source_voice": source_voice,
"style_instruction": instruct,
"style_backend": backend,
"note": f"Style variation of {source_voice}: {instruct[:180]}",
"needs_tts_restart": True,
})
_save_meta(wav_dest, meta)
return {
"ok": True,
"voice_id": new_voice_id,
"source_voice": source_voice,
"backend": backend,
"wav": str(wav_dest),
"txt": str(txt_dest),
"duration": duration,
"loudness": loudness,
"needs_tts_restart": True,
}
@router.post("/api/voice-design")
async def voice_design(request: Request):
data = await request.json()
instruct: str = data.get("instruct", "").strip()
sample_text: str = data.get("sample_text", "Hello! This is a voice design sample.").strip()
language: str = data.get("language", "Auto")
gender: str = data.get("gender", "")
dialogue = bool(data.get("dialogue"))
if not instruct:
raise HTTPException(400, "instruct (voice description) is required")
settings = _load_settings()
try:
if dialogue:
parsed = _parse_voice_design_dialogue(instruct, sample_text)
if not parsed:
raise RuntimeError("dialogue mode needs speaker profiles and Speaker: text turns")
audio, _media_type = await asyncio.to_thread(
_voice_design_request_audio, instruct, sample_text, language, settings, "",
)
else:
audio, _media_type = await asyncio.to_thread(
_voice_design_request_audio, instruct, sample_text, language, settings, gender,
)
except Exception as e:
raise HTTPException(502, f"Voice design error: {e}")
audio, _media_type, _ext, _duration_sec, _clipped = _prepare_proxy_audio(audio, "audio/wav", "wav")
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_designed.wav"
tmp.write_bytes(audio)
fid = uuid.uuid4().hex
_registry_put(fid, tmp)
return {"id": fid, "duration": _duration(tmp)}
@router.post("/api/tts-route-test")
async def tts_route_test(request: Request):
data = await request.json()
text = str(data.get("input") or data.get("text") or "").strip()
voice = str(data.get("voice") or "default").strip()
app_name = _canonical_app_name(str(data.get("app") or data.get("client") or "Open WebUI").strip())
explicit_lang = str(data.get("language") or data.get("lang") or "").strip()
if not text:
raise HTTPException(400, "input is required")
routed_voice, route = _resolve_tts_route(app_name, voice, text, explicit_lang)
backend = _route_backend(route, routed_voice)
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
routed_audio = None if backend in {"voice_design", "nvidia_magpie"} else _find_voice_audio(routed_voice, scan_dir)
sound_status = {}
for key in ("before_sound", "after_sound"):
raw = str((route or {}).get(key, ""))
if raw:
try:
path = _route_sound_path(settings, raw)
sound_status[key] = {"ok": True, "path": str(path)}
except Exception as e:
sound_status[key] = {"ok": False, "error": str(e), "path": raw}
result = {
"app": app_name,
"requested_voice": voice,
"routed_voice": routed_voice,
"backend": backend,
"detected_language": str(route.get("detected_language", _detect_text_language(text))) if route else _detect_text_language(text),
"matched": bool(route),
"route": route,
"voice_health": _voice_health(routed_audio) if routed_audio else None,
"sounds": sound_status,
}
_routing_log_add(
kind="test",
status="matched" if route else "no_match",
app=app_name,
requested_voice=voice,
routed_voice=routed_voice,
backend=backend,
language=result["detected_language"],
matched=bool(route),
route_id=str((route or {}).get("id", "")),
text_preview=text[:160],
sounds=sound_status,
)
return result
# ── OpenAI-compatible models/voices proxy ─────────────────────────────────────
@router.get("/v1/models")
async def openai_models_proxy():
now = 1686935002
data = []
seen = set()
for name in sorted(_load_design_presets()):
seen.add(_virtual_voice_id(name))
data.append({
"id": _virtual_voice_id(name),
"object": "model",
"created": now,
"owned_by": "voice-design",
})
for rule in _load_tts_routes():
alias = str(rule.get("input_voice", "")).strip()
if rule.get("enabled", True) and alias and alias != "*" and alias not in seen:
seen.add(alias)
data.append({
"id": alias,
"object": "model",
"created": now,
"owned_by": f"route:{rule.get('app', '*')}",
})
try:
voices = await tts_voices()
for item in voices:
if isinstance(item, str):
voice_id = item
elif isinstance(item, dict):
voice_id = item.get("id") or item.get("voice")
else:
voice_id = str(item) if item is not None else ""
if voice_id and voice_id not in seen:
seen.add(str(voice_id))
data.append({
"id": str(voice_id),
"object": "model",
"created": now,
"owned_by": "qwen",
})
except Exception:
pass
return {"object": "list", "data": data}
@router.get("/v1/audio/models")
async def openai_audio_models_proxy():
return await openai_models_proxy()
@router.api_route("/v1/audio/voices", methods=["GET", "POST"])
async def openai_audio_voices_proxy():
models = await openai_models_proxy()
return [m["id"] for m in models["data"]]
# ── OpenAI-compatible speech proxy ───────────────────────────────────────────
@router.post("/v1/audio/speech")
async def openai_speech_proxy(request: Request):
data = await request.json()
text = str(data.get("input") or data.get("text") or "").strip()
voice = str(data.get("voice") or data.get("model") or "").strip()
response_format = _requested_response_format(data)
request_app = _canonical_app_name(str(data.get("app") or data.get("client") or "").strip()) if (data.get("app") or data.get("client")) else _request_app_name(request)
original_voice = voice
if not text:
_routing_log_request(
request, status="error", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend="", route=None, response_format=response_format,
text=text, error="input is required",
)
raise HTTPException(400, "input is required")
settings = _load_settings()
if not voice:
fallback = next((v["id"] for v in _active_library_voice_options(settings)), "")
if not fallback:
_routing_log_request(
request, status="error", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend="", route=None, response_format=response_format,
text=text, error="voice is required",
)
raise HTTPException(400, "voice is required")
voice = fallback
explicit_lang = str(data.get("language") or data.get("lang") or "").strip()
voice, route = _resolve_tts_route(request_app, voice, text, explicit_lang)
backend = _route_backend(route, voice)
style_instruction = str(data.get("instruct") or data.get("style_instruction") or "")
virtual = _resolve_virtual_voice(voice)
def _route_speed(r):
try:
return float((r or {}).get("speed", 1.0) or 1.0)
except (TypeError, ValueError):
return 1.0
route_has_sounds = bool((route or {}).get("before_sound") or (route or {}).get("after_sound"))
route_has_speed = abs(_route_speed(route) - 1.0) > 0.01
if backend == "streaming" and not virtual and response_format == "wav" and not route_has_sounds and not route_has_speed:
try:
resp = await asyncio.to_thread(_open_tts_stream_response, text, voice, settings, style_instruction)
except Exception as e:
_routing_log_request(
request, status="error", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend=backend, route=route, response_format=response_format,
text=text, error=f"TTS stream proxy error: {e}",
)
raise HTTPException(502, f"TTS stream proxy error: {e}")
media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav"
_routing_log_request(
request, status="streaming", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend=backend, route=route, response_format=response_format,
text=text, media_type=media_type, sounds=[],
)
return StreamingResponse(
_iter_tts_stream_response(resp),
media_type=media_type,
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
"Content-Disposition": f'inline; filename="{voice}_speech.wav"',
"X-TTS-Voice-Requested": original_voice,
"X-TTS-Voice-Routed": voice,
"X-TTS-Route-Backend": backend,
"X-TTS-Route-Language": str(route.get("detected_language", "")) if route else "",
"X-TTS-Response-Format": response_format,
"X-TTS-Audio-Clipped": "false",
"X-TTS-Route-Sounds": "",
},
)
try:
if virtual:
_name, preset = virtual
instruct = str(preset.get("description", "")).strip()
if not instruct:
raise RuntimeError(f"Virtual voice '{voice}' has no description")
audio, media_type = await asyncio.to_thread(
_voice_design_request_audio,
instruct, text, str(preset.get("language", "Auto")), settings,
str(preset.get("gender", "")),
)
elif backend == "voice_design":
audio, media_type = await asyncio.to_thread(
_voice_design_voice_request_audio,
voice, text, settings, style_instruction, str(data.get("language") or "Auto"),
)
elif backend == "streaming":
resp = await asyncio.to_thread(_open_tts_stream_response, text, voice, settings, style_instruction)
audio, media_type = await asyncio.to_thread(_read_tts_stream_response, resp)
if not audio:
raise RuntimeError("backend returned empty audio")
elif backend == "nvidia_magpie":
audio, media_type = await asyncio.to_thread(
_tts_request_audio, text, voice, settings, style_instruction,
_preview_backend_base_url(settings, "nvidia_magpie"),
settings.get("tts_api_key", ""), "nvidia_magpie", "nvidia_magpie",
)
elif backend in {"nvidia_zeroshot", "nvidia_flow"}:
audio, media_type = await asyncio.to_thread(
_nvidia_clone_request_audio, text, voice, settings,
"flow" if backend == "nvidia_flow" else "zeroshot",
str(data.get("audio_prompt_transcript") or ""),
str(data.get("language") or ""),
)
elif backend == "fishspeech":
audio, media_type = await asyncio.to_thread(
_fishspeech_request_audio, text, voice, settings,
style_instruction, str(data.get("language") or ""),
)
else:
audio, media_type = await asyncio.to_thread(
_tts_request_audio, text, voice, settings, style_instruction,
)
except Exception as e:
_routing_log_request(
request, status="error", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend=backend, route=route, response_format=response_format,
text=text, error=f"TTS proxy error: {e}",
)
raise HTTPException(502, f"TTS proxy error: {e}")
try:
audio, media_type, applied_sounds = _apply_route_sounds(audio, media_type, route, settings)
except Exception as e:
_routing_log_request(
request, status="error", app=request_app, requested_voice=original_voice,
routed_voice=voice, backend=backend, route=route, response_format=response_format,
text=text, error=f"TTS route sound error: {e}",
)
raise HTTPException(502, f"TTS route sound error: {e}")
audio, media_type, ext, duration, clipped = _prepare_proxy_audio(audio, media_type, response_format)
logger.info(
"TTS proxy app=%s voice=%s routed=%s backend=%s lang=%s format=%s bytes=%s duration=%s clipped=%s sounds=%s",
request_app, original_voice, voice, backend,
str(route.get("detected_language", "")) if route else "",
response_format, len(audio),
f"{duration:.2f}" if duration is not None else "?",
clipped, ",".join(applied_sounds) if applied_sounds else "-",
)
_routing_log_request(
request, status="ok" if route else "no_match", app=request_app,
requested_voice=original_voice, routed_voice=voice, backend=backend,
route=route, response_format=response_format, text=text,
bytes=len(audio), duration=duration, clipped=clipped,
media_type=media_type, sounds=applied_sounds,
)
return Response(
content=audio,
media_type=media_type,
headers={
"Content-Disposition": f'inline; filename="{voice}_speech.{ext}"',
"X-TTS-Voice-Requested": original_voice,
"X-TTS-Voice-Routed": voice,
"X-TTS-Route-Backend": backend,
"X-TTS-Route-Language": str(route.get("detected_language", "")) if route else "",
"X-TTS-Response-Format": response_format,
"X-TTS-Audio-Duration": f"{duration:.3f}" if duration is not None else "",
"X-TTS-Audio-Clipped": "true" if clipped else "false",
"X-TTS-Route-Sounds": ",".join(applied_sounds),
},
)
@router.post("/v1")
async def openai_speech_proxy_v1_shortcut(request: Request):
return await openai_speech_proxy(request)
@router.post("/api/tts-voice-seed")
async def tts_voice_seed(request: Request):
"""Proxy to the faster-qwen3-tts /voice-seed endpoint.
Writes or clears the seed for a voice in the TTS server's voices.json.
Body: {"voice": "EN_F_NatashaNeural", "seed": 7} (seed: null to remove)
"""
data = await request.json()
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.post(
f"{tts_base}/voice-seed",
json=data,
timeout=10,
)
resp.raise_for_status()
voice_name = data.get("voice")
seed = data.get("seed")
if voice_name:
from core.voice import _find_voice_audio, _load_meta, _save_meta
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio = _find_voice_audio(voice_name, scan_dir)
if audio:
meta = _load_meta(audio)
if seed is None:
meta.pop("seed", None)
else:
meta["seed"] = seed
_save_meta(audio, meta)
return resp.json()
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
detail = ""
try:
detail = e.response.json().get("detail", "")
except Exception:
pass
raise HTTPException(e.response.status_code, detail or str(e))
@router.get("/api/seed-samples/{voice_name}")
async def list_seed_samples(voice_name: str):
"""List pre-generated seed sample numbers for a voice (batch script output)."""
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.get(f"{tts_base}/seed-samples/{voice_name}", timeout=5)
resp.raise_for_status()
return resp.json()
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))
@router.get("/api/seed-sample/{voice_name}/{seed}")
async def get_seed_sample(voice_name: str, seed: int):
"""Serve a pre-generated seed WAV file (batch script output)."""
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.get(f"{tts_base}/seed-sample/{voice_name}/{seed}", timeout=30, stream=True)
resp.raise_for_status()
return Response(content=resp.content, media_type="audio/wav")
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))
@router.post("/api/audio/apply-gain")
async def apply_gain(request: Request, gain_db: float = 0.0):
"""Apply a FIXED gain to a single WAV clip — NOT the same as normalizing
each clip independently to one target level.
That was the first version of this fix, and it broke something real:
normalizing every clip to the same -20dBFS average erases a voice's own
internal loudness dynamics along with fixing the cross-voice mismatch —
confirmed live, synthesizing the same line "neutral" vs with a whisper
instruct: the whisper version came out LOUDER than neutral once each was
independently pushed to the same target, exactly backwards. A whisper
should measurably be quieter than a shout from the SAME voice; per-clip
auto-normalization can't preserve that, only a fixed offset can.
The caller instead looks up the voice's OWN already-computed reference
gain (from Calc dB / voice-loudness metadata) ONCE and applies that same
fixed number to every clip from that voice — shifting the whole voice's
baseline level to match others (fixing the Narrator-vs-designed-voice
gap) while leaving each line's own relative loudness — quiet vs shouted —
exactly as the model produced it.
"""
from pydub import AudioSegment
wav_bytes = await request.body()
if not wav_bytes:
raise HTTPException(400, "Empty request body")
if not gain_db:
return Response(content=wav_bytes, media_type="audio/wav")
try:
seg = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav")
# Still peak-limited — a fixed gain derived from a short reference
# clip could clip a louder line (e.g. an already-shouted one).
peak = seg.max_dBFS if seg.max_dBFS != float("-inf") else None
g = gain_db
if peak is not None and peak + g > _VOICE_PEAK_DBFS:
g = _VOICE_PEAK_DBFS - peak
seg2 = seg.apply_gain(g)
out = io.BytesIO()
seg2.export(out, format="wav")
return Response(content=out.getvalue(), media_type="audio/wav")
except Exception as e:
raise HTTPException(400, f"Could not apply gain: {e}")
@router.post("/api/audio/encode-mp3")
async def encode_mp3(request: Request):
"""Encode a raw WAV body into MP3 at an explicit bitrate.
audiobookExport() used to concatenate independently-encoded per-line MP3
byte streams directly into one Blob — each clip carries its own frame/ID3
headers, so most players only decode the first one (confirmed live: an
85MB file that reported as 22s playable). The fix merges lossless WAV
clips client-side (mergeWavBlobs, already correct) and sends the single
merged WAV here for one real encode pass — also fixes the previous
32kbps default (ffmpeg/lame's unset-bitrate fallback, not a deliberate
choice anywhere in this app) without pretending to add quality beyond
the engine's native 24kHz mono output.
"""
wav_bytes = await request.body()
if not wav_bytes:
raise HTTPException(400, "Empty request body")
try:
from pydub import AudioSegment
segment = AudioSegment.from_file(io.BytesIO(wav_bytes), format="wav")
out = io.BytesIO()
segment.export(out, format="mp3", bitrate="96k")
return Response(content=out.getvalue(), media_type="audio/mpeg")
except Exception as e:
raise HTTPException(400, f"Could not encode audio: {e}")
# ── Per-paragraph synthesized-audio cache ───────────────────────────────────
#
# "Synth all" pre-synthesizes every line for instant playback, but only ever
# kept the result in the browser tab's memory — closing the tab (or a crash,
# or just a normal reload) threw all of it away, and every future playback
# or export had to wait on the GPU again from scratch. This persists each
# line's audio to disk, keyed by a hash of its own content (text + voice +
# instruct/tone) rather than its position in the script — editing a
# paragraph changes its hash, so the edited version simply never matches a
# cached file and gets synthesized fresh, while an untouched paragraph
# reuses its file instantly regardless of how the surrounding lines shifted.
# The key is computed client-side (SHA-256 over the exact inputs that affect
# the audio) and treated here as an opaque cache token — this endpoint never
# needs to know what it means, only that the same key always means the same
# audio.
_LINE_AUDIO_DIR = CONFIG_DIR / "line_audio_cache"
_LINE_AUDIO_KEY_RE = re.compile(r"^[a-f0-9]{16,64}$")
def _line_audio_book_dir(book: str) -> Path:
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
d = _LINE_AUDIO_DIR / safe_book
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/line-audio/{book}/{key}")
async def get_line_audio(book: str, key: str):
if not _LINE_AUDIO_KEY_RE.match(key):
raise HTTPException(400, "Invalid cache key")
path = _line_audio_book_dir(book) / f"{key}.wav"
if not path.exists():
raise HTTPException(404, "Not cached")
return Response(content=path.read_bytes(), media_type="audio/wav")
@router.post("/api/line-audio/{book}/check")
async def check_line_audio(book: str, request: Request):
"""Bulk existence check — one request instead of one GET per line — so
the Stage page can mark its "pre-synthesized" dots correctly right after
a reload, instead of every dot looking unsynthesized just because
rehState.synthCache (this browser tab's own memory) starts empty on
every fresh page load even when the audio is sitting on disk already."""
data = await request.json()
keys = data.get("keys") or []
if not isinstance(keys, list):
raise HTTPException(400, "keys must be a list of cache keys")
book_dir = _line_audio_book_dir(book)
existing = [k for k in keys if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k) and (book_dir / f"{k}.wav").exists()]
return {"ok": True, "existing": existing}
@router.post("/api/line-audio/{book}/prune")
async def prune_line_audio(book: str, request: Request):
"""Delete cached files for this book that no longer match any current
line — a paragraph's cache key is its own content hash, so editing it
just makes the old file unreachable rather than actively removing it
(nothing on the write path knows a "previous" key exists to delete).
The client sends every key still valid for the CURRENT script; anything
else on disk for this book is safe to remove.
Registered BEFORE the generic POST /api/line-audio/{book}/{key} route
below — FastAPI matches routes in declaration order, and {key} is just
a plain path segment at the routing level (its regex validation only
runs inside the handler, after routing already picked one), so a
literal "prune" segment would otherwise always match that generic
route first and this one would never be reached at all.
"""
data = await request.json()
keep = data.get("keep") or []
if not isinstance(keep, list):
raise HTTPException(400, "keep must be a list of cache keys")
keep_set = {k for k in keep if isinstance(k, str) and _LINE_AUDIO_KEY_RE.match(k)}
book_dir = _line_audio_book_dir(book)
deleted = 0
for f in book_dir.glob("*.wav"):
if f.stem not in keep_set:
try:
f.unlink()
deleted += 1
except OSError:
pass
return {"ok": True, "deleted": deleted, "kept": len(keep_set)}
@router.post("/api/line-audio/{book}/normalize")
async def normalize_line_audio(book: str):
"""Loudness-normalize every cached clip for this book in place, to the
same target used for voice reference files (_normalize_segment).
Different TTS backends (Voice Clone vs Voice Design) apparently ship
very different default output levels — confirmed live as the Narrator
(cloned) sounding noticeably louder than designed-voice characters in a
finished export, since nothing in the synthesis or merge pipeline ever
leveled clips against each other. Running this against the EXISTING
cache is far cheaper than resynthesizing the whole book: it's pure audio
processing, no TTS calls, so a ~1600-line book normalizes in well under
a minute instead of the hours a full resynth would take. A subsequent
export then hits 100% cache and just needs to merge + encode.
Registered before the generic {key} route below for the same routing
reason as /check and /prune.
"""
from core.audio import _normalize_segment
from pydub import AudioSegment
book_dir = _line_audio_book_dir(book)
normalized = 0
skipped = 0
errors = 0
for f in book_dir.glob("*.wav"):
try:
seg = AudioSegment.from_file(str(f), format="wav")
seg2, info = _normalize_segment(seg)
if abs(info.get("gain_db") or 0.0) < 0.1:
skipped += 1
continue
seg2.export(str(f), format="wav")
normalized += 1
except Exception:
errors += 1
return {"ok": True, "normalized": normalized, "skipped": skipped, "errors": errors}
@router.post("/api/line-audio/{book}/{key}")
async def put_line_audio(book: str, key: str, request: Request):
if not _LINE_AUDIO_KEY_RE.match(key):
raise HTTPException(400, "Invalid cache key")
wav_bytes = await request.body()
if not wav_bytes:
raise HTTPException(400, "Empty request body")
path = _line_audio_book_dir(book) / f"{key}.wav"
path.write_bytes(wav_bytes)
return {"ok": True}
# ── Finished audiobook chapter exports ──────────────────────────────────────
#
# audiobookExport() already triggers a browser download per chapter, but
# that only ever lands wherever the browser's download settings put it —
# confirmed as a real gap: nothing in the app itself says where the files
# went, and re-finding a chapter later means re-running the whole export.
# This additionally saves the exact same file server-side so the app can
# show a real download link (and the on-disk path) right after the export
# finishes, and again any time later without resynthesizing anything.
_AUDIOBOOK_EXPORT_DIR = CONFIG_DIR / "audiobook_exports"
_EXPORT_FILENAME_RE = re.compile(r"^[^/\\]{1,200}$") # any single path segment, no traversal
def _audiobook_export_book_dir(book: str) -> Path:
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
d = _AUDIOBOOK_EXPORT_DIR / safe_book
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/audiobook-export/{book}")
async def list_audiobook_exports(book: str):
"""List previously-saved chapter exports for this book — lets the app
show a "browse what's already been exported" view without re-running
the export, and without any real filesystem access on the user's part.
Registered before the generic GET .../{filename} route below for the
same reason "zip" and "check"/"prune" are elsewhere in this file: a
plain path segment matches ANY literal string at the routing level.
"""
book_dir = _audiobook_export_book_dir(book)
files = sorted(
({"name": f.name, "size": f.stat().st_size} for f in book_dir.iterdir() if f.is_file()),
key=lambda x: x["name"],
)
return {"book": book, "files": files, "dir": str(book_dir)}
@router.get("/api/audiobook-export/{book}/zip")
async def zip_audiobook_exports(book: str):
"""Bundle every saved chapter for this book into one ZIP download —
the "download everything at once" the per-file list doesn't offer."""
import zipfile
book_dir = _audiobook_export_book_dir(book)
files = [f for f in book_dir.iterdir() if f.is_file()]
if not files:
raise HTTPException(404, "No exported files for this book")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
for f in files:
zf.write(f, arcname=f.name)
buf.seek(0)
zip_name = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "audiobook"
return Response(
content=buf.getvalue(), media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_name}.zip"'},
)
@router.get("/api/audiobook-export/{book}/{filename}")
async def get_audiobook_export(book: str, filename: str):
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
raise HTTPException(400, "Invalid filename")
path = _audiobook_export_book_dir(book) / filename
if not path.exists() or not path.is_file():
raise HTTPException(404, "Not found")
media_type = "audio/mpeg" if path.suffix.lower() == ".mp3" else "audio/wav"
return Response(
content=path.read_bytes(), media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/api/audiobook-export/{book}/{filename}")
async def put_audiobook_export(book: str, filename: str, request: Request):
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
raise HTTPException(400, "Invalid filename")
audio_bytes = await request.body()
if not audio_bytes:
raise HTTPException(400, "Empty request body")
path = _audiobook_export_book_dir(book) / filename
path.write_bytes(audio_bytes)
return {"ok": True, "path": str(path)}