tts-voice-creator-clone-and.../core/config.py
mARTin-B78 c7a1e35539 Security audit, modular refactor, and container-name field
Security fixes:
- Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal)
- Verify yt-dlp output stays inside TEMP_DIR before registration
- Remove Access-Control-Allow-Origin: * from /api/proxy-audio
- TTL-based temp file registry (default 2h) to prevent disk fill

Performance:
- Cache settings + routing rules in memory (mtime-checked); eliminates
  per-request disk reads on every TTS call

UI:
- Add container name (optional) field to Docker stack TTS/STT engine
  cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming,
  NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on
  all engine cards, matching the existing Other Local TTS/STT cards

Refactor — backend:
- server.py: 5560 lines → 43-line entry point
- core/ package: constants, registry, validation, docker_client,
  config, routing, audio, voice, presets, tts_helpers
- routes/ package: admin, settings, library, stt, sources, docker,
  tts, conversation (FastAPI APIRouter modules)
- Dockerfile + docker-compose.yml updated to include core/ and routes/

Refactor — frontend:
- static/app.js: 8744 lines → 16 modules in static/js/
  utils, voice-inspector, voice-sources, integrations, routing,
  settings, voice-clone, voice-library, tts-preview, benchmark,
  stt, init, engines, ai-backends, generation, conversation
- static/loader.js updated to load modules sequentially

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:13:07 +02:00

283 lines
11 KiB
Python

"""Settings management: load, save, normalize, TTS stability helpers."""
from __future__ import annotations
import json
import re
from pathlib import Path
import requests
from core.constants import (
CONFIG_DIR, CONFIG_FILE,
_VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT,
_WHISPER_DEFAULT, _TTS_DEFAULT, _TTS_STREAM_DEFAULT,
_CUSTOMVOICE_DEFAULT, _VOICE_DESIGN_DEFAULT,
_NVIDIA_ROUTER_DEFAULT, _NVIDIA_TTS_DEFAULT, _NVIDIA_ASR_DEFAULT,
_NVIDIA_CLONE_DEFAULT, _NVIDIA_ZEROSHOT_DEFAULT, _NVIDIA_FLOW_DEFAULT,
_FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT,
_TTS_STREAM_DEFAULT,
)
# ── Settings key whitelist ────────────────────────────────────────────────────
_SETTINGS_KEYS = {
"whisper_url", "tts_url", "tts_stream_url", "tts_stream_mode", "tts_backend",
"output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url",
"nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url",
"nvidia_zeroshot_url", "nvidia_flow_url",
"faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "vibevoice_url", "xtts_url",
"whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key",
"tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend",
# Captures settings
"stt_language", "stt_preferred_backend",
"auto_refine", "refine_model",
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
"captures_default_voice",
"client_voice_bindings",
"llm_url",
# Browser-persistent UI state
"engine_local_urls", "custom_engine_cards", "refine_llm_url", "conv_llm_url",
}
# ── TTS stability defaults ────────────────────────────────────────────────────
_TTS_STABILITY_DEFAULT = {"temperature": 0.1, "top_p": 0.8, "seed": 0}
_TTS_STABILITY_BY_BACKEND_DEFAULT = {
"voice_clone": dict(_TTS_STABILITY_DEFAULT),
"streaming": dict(_TTS_STABILITY_DEFAULT),
"customvoice": dict(_TTS_STABILITY_DEFAULT),
"voice_design": dict(_TTS_STABILITY_DEFAULT),
"nvidia_magpie": {},
"nvidia_zeroshot": {},
"nvidia_flow": {},
"kokoro": {},
"vibevoice": {},
}
_TTS_PAYLOAD_CORE_KEYS = {"model", "input", "voice", "response_format", "instruct", "language"}
def _settings_bool(value, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() not in {"0", "false", "off", "no"}
def _clean_tts_param_dict(raw, fallback: dict | None = None) -> dict:
if raw in (None, ""):
raw = fallback or {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
raw = fallback or {}
if not isinstance(raw, dict):
return {}
params = {}
for key, value in raw.items():
if key in _TTS_PAYLOAD_CORE_KEYS or str(key).startswith("_"):
continue
if isinstance(value, (str, int, float, bool)) or value is None:
params[str(key)] = value
return params
def _clean_preview_backend(value: str) -> str:
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
aliases = {
"clone": "voice_clone",
"base": "voice_clone",
"voiceclone": "voice_clone",
"voice_clone_base": "voice_clone",
"stream": "streaming",
"tts_streaming": "streaming",
"custom": "customvoice",
"custom_voice": "customvoice",
"voice_design": "voice_design",
"voicedesign": "voice_design",
"design": "voice_design",
"nvidia": "nvidia_magpie",
"magpie": "nvidia_magpie",
"nvidia_tts": "nvidia_magpie",
"nvidia_magpie_tts": "nvidia_magpie",
"nvidia_clone": "nvidia_zeroshot",
"nvidia_zeroshot_tts": "nvidia_zeroshot",
"magpie_zeroshot": "nvidia_zeroshot",
"zeroshot": "nvidia_zeroshot",
"zero_shot": "nvidia_zeroshot",
"nvidia_flow_tts": "nvidia_flow",
"magpie_flow": "nvidia_flow",
"flow": "nvidia_flow",
"kokoro_fastapi": "kokoro",
"kokoro_tts": "kokoro",
"kokoro_local": "kokoro",
"vibevoice_service": "vibevoice",
"vibe_voice": "vibevoice",
"vibetts": "vibevoice",
"xtts_v2": "xtts",
"xtts2": "xtts",
"coqui_xtts": "xtts",
}
key = aliases.get(key, key)
return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts"} else "voice_clone"
def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict:
if not _settings_bool(settings.get("tts_stability_enabled"), True):
return {}
backend = _clean_preview_backend(backend)
by_backend = settings.get("tts_extra_params_by_backend")
if isinstance(by_backend, str):
try:
by_backend = json.loads(by_backend)
except Exception:
by_backend = None
if isinstance(by_backend, dict):
fallback = _TTS_STABILITY_BY_BACKEND_DEFAULT.get(backend, _TTS_STABILITY_DEFAULT)
if backend in by_backend:
return _clean_tts_param_dict(by_backend.get(backend), fallback)
return _clean_tts_param_dict(fallback, fallback)
return _clean_tts_param_dict(settings.get("tts_extra_params", _TTS_STABILITY_BY_BACKEND_DEFAULT.get(backend, _TTS_STABILITY_DEFAULT)), _TTS_STABILITY_BY_BACKEND_DEFAULT.get(backend, _TTS_STABILITY_DEFAULT))
def _apply_tts_extra_params(payload: dict, settings: dict, backend: str = "voice_clone") -> dict:
params = _tts_extra_params(settings, backend)
if params:
payload.update(params)
return payload
def _strip_tts_extra_params(payload: dict) -> dict:
return {k: v for k, v in payload.items() if k in _TTS_PAYLOAD_CORE_KEYS}
def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response:
resp = requests.post(endpoint, json=payload, headers=headers, **kwargs)
fallback = _strip_tts_extra_params(payload)
if resp.status_code in {400, 404, 415, 422} and fallback != payload:
try:
resp.close()
except Exception:
pass
import logging
logging.getLogger("uvicorn.error").warning("TTS backend rejected extra generation params; retrying without them")
return requests.post(endpoint, json=fallback, headers=headers, **kwargs)
return resp
def _preview_backend_base_url(settings: dict, backend: str) -> str:
backend = _clean_preview_backend(backend)
if backend == "streaming":
return settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT
if backend == "customvoice":
return settings.get("customvoice_url") or _CUSTOMVOICE_DEFAULT
if backend == "voice_design":
return settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT
if backend == "nvidia_magpie":
return settings.get("nvidia_tts_url") or settings.get("nvidia_router_url") or _NVIDIA_TTS_DEFAULT
if backend == "nvidia_zeroshot":
return settings.get("nvidia_zeroshot_url") or settings.get("nvidia_clone_url") or settings.get("nvidia_router_url") or _NVIDIA_ZEROSHOT_DEFAULT
if backend == "nvidia_flow":
return settings.get("nvidia_flow_url") or settings.get("nvidia_clone_url") or settings.get("nvidia_router_url") or _NVIDIA_FLOW_DEFAULT
if backend == "kokoro":
return settings.get("kokoro_url") or _KOKORO_DEFAULT
if backend == "vibevoice":
return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT
if backend == "xtts":
return settings.get("xtts_url") or _XTTS_DEFAULT
return settings.get("tts_url") or _TTS_DEFAULT
def _normalize_settings(s: dict) -> dict:
scan_dir = Path(s.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
output_dir = Path(s.get("output_dir", _OUTPUT_DIR_DEFAULT))
legacy_output_dirs = {
Path("/voices/speakers"),
scan_dir / "speakers",
}
if output_dir in legacy_output_dirs:
s["output_dir"] = str(scan_dir / "active_voices")
if s.get("tts_stream_mode") not in {"auto", "streaming", "buffered"}:
s["tts_stream_mode"] = "auto"
if not isinstance(s.get("engine_local_urls"), dict):
s["engine_local_urls"] = {}
if not isinstance(s.get("custom_engine_cards"), list):
s["custom_engine_cards"] = []
return s
_settings_cache: dict | None = None
_settings_cache_mtime: float = -1.0
def _load_settings() -> dict:
global _settings_cache, _settings_cache_mtime
mtime = CONFIG_FILE.stat().st_mtime if CONFIG_FILE.exists() else 0.0
if _settings_cache is not None and mtime == _settings_cache_mtime:
return dict(_settings_cache)
defaults = {
"whisper_url": _WHISPER_DEFAULT,
"tts_url": _TTS_DEFAULT,
"tts_stream_url": _TTS_STREAM_DEFAULT,
"tts_stream_mode": "auto",
"tts_backend": "openai",
"output_dir": _OUTPUT_DIR_DEFAULT,
"voices_scan_dir": _VOICES_DIR_DEFAULT,
"voice_design_url": _VOICE_DESIGN_DEFAULT,
"customvoice_url": _CUSTOMVOICE_DEFAULT,
"nvidia_router_url": _NVIDIA_ROUTER_DEFAULT,
"nvidia_tts_url": _NVIDIA_TTS_DEFAULT,
"nvidia_asr_url": _NVIDIA_ASR_DEFAULT,
"nvidia_clone_url": _NVIDIA_CLONE_DEFAULT,
"nvidia_zeroshot_url": _NVIDIA_ZEROSHOT_DEFAULT,
"nvidia_flow_url": _NVIDIA_FLOW_DEFAULT,
"faster_whisper_url": _FASTER_WHISPER_DEFAULT,
"whisper_cpp_url": _WHISPER_CPP_DEFAULT,
"groq_api_key": "",
"kokoro_url": _KOKORO_DEFAULT,
"vibevoice_url": _VIBEVOICE_DEFAULT,
"xtts_url": _XTTS_DEFAULT,
"whisper_api_key": "",
"tts_api_key": "",
"voice_design_api_key": "",
"tts_stability_enabled": True,
"tts_extra_params": _TTS_STABILITY_DEFAULT,
"tts_extra_params_by_backend": _TTS_STABILITY_BY_BACKEND_DEFAULT,
# Captures
"stt_language": "",
"stt_preferred_backend": "",
"auto_refine": "off",
"refine_model": "",
"refine_fillers": True,
"refine_repetitions": True,
"refine_corrections": True,
"refine_punctuation": True,
"captures_default_voice": "",
"client_voice_bindings": {},
"llm_url": "http://localhost:11434/v1",
"engine_local_urls": {},
"custom_engine_cards": [],
"refine_llm_url": "",
"conv_llm_url": "",
}
if CONFIG_FILE.exists():
try:
saved = json.loads(CONFIG_FILE.read_text())
defaults.update({k: v for k, v in saved.items() if k in _SETTINGS_KEYS})
except Exception:
pass
result = _normalize_settings(defaults)
_settings_cache = result
_settings_cache_mtime = mtime
return dict(result)
def _save_settings(s: dict) -> None:
global _settings_cache, _settings_cache_mtime
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(s, indent=2))
_settings_cache = s
_settings_cache_mtime = CONFIG_FILE.stat().st_mtime