tts-voice-creator-clone-and.../core/config.py
mARTin-B78 813e06c914 feat: complete magazine P1 (images, play-all, karaoke, driving mode, sidebar exclusion) + OCR engines page + second OCR engine
Magazine P1:
- Images shown automatically during playback (assign_images_to_articles
  links each figure to the article whose column it falls in; the client
  shows the last image reached in reading order as playback advances).
- "Play all articles" queues every approved article and advances
  automatically at the end of each rather than stopping.
- Word-level highlighting, explicitly documented as an ESTIMATE (no
  forced-alignment/timestamp source exists): each word's share of the
  sentence's real measured audio duration is proportional to its
  character length. A checkbox turns it off; sentence highlighting
  (measured, not estimated) is unaffected.
- Driving mode: full-screen overlay, two oversized controls, reuses
  whatever is already playing/queued.
- Sidebars/pull-out boxes excluded from narration automatically
  (mark_sidebars: an anomalously narrow column vs. the page's regular
  grid). Fixed a real defect found while building this: a full-width
  headline was left in the column-width analysis and could bridge two
  genuinely separate columns into one, hiding a real sidebar column
  entirely -- caught by a failing test before the fix.

Second OCR engine + fallback ladder:
- KDL Frontier Parser nano wired in via vision_ocr_page_chain (ordered
  engine list, first usable result wins, tesseract last resort).
  Confirmed live: ~3x faster than Baidu (~12s vs ~40s/page) but returns
  plain text with NO coordinate markers, unlike Baidu's labeled regions.
  _whole_page_region treats the whole response as one ambiguous
  full-page block (gated by ocr_quality_score) rather than discarding
  usable text just because it lacks layout structure.

OCR engines page + a real UI bug:
- The OCR & Document Parsing page had no .s-engines-page[data-page="ocr"]
  markup at all -- renderLocalContainers already supported an OCR grid
  generically, but $('dc-grid-ocr') was always null, so the card grid
  silently never rendered. Added an Active OCR Engine panel mirroring
  Active Language Model exactly, plus the Local grid -- both configured
  engines now appear as ready-made cards, no "Add Custom" dialog needed.
- Fixed the audiobook pipeline's "Previous/Next" nav bar bleeding into
  every .section-head in the document (confirmed live on the Engines
  page: a "Cast Audiobook" button and "STEP 1/8" hint that made no sense
  there). Changed from a blocklist (naming each unrelated section as
  noticed) to an allowlist of the pipeline's three actual destinations.

Verified live end-to-end: play-all queue building and manual advance,
word-span markup, driving-mode toggle, OCR page showing both engines as
local cards, zero stray nav bars on the Engines page, no console errors.

20 new backend tests (156 total in the magazine module).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:39:34 +02:00

409 lines
18 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, _FISHSPEECH_DEFAULT,
_VISION_OCR_DEFAULT, _VISION_OCR_MODEL_DEFAULT,
_VISION_OCR_FALLBACK_DEFAULT, _VISION_OCR_FALLBACK_MODEL_DEFAULT,
_TTS_STREAM_DEFAULT,
)
from core.retry import request_with_retries
from core.database import state_get, state_put, state_updated
# ── 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",
"fishspeech_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", "llm_model", "llm_api_key",
"vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled",
"vision_ocr_fallback_url", "vision_ocr_fallback_model",
"engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards",
# Character portrait generation (cloud APIs + local ComfyUI)
"image_gen_provider", "image_gen_model",
"comfyui_url", "comfyui_workflow", "comfyui_prompt_node_id",
"comfyui_prompt_field", "comfyui_output_node_id",
# Inbound API key — gates non-browser callers (external scripts, MCP
# clients) hitting this app's own /api/* and /mcp routes. Distinct from
# every *outbound* key above, which are credentials this app sends to
# OTHER services. The server-side gate is enabled by default; deployments
# behind another authentication layer may explicitly opt out with
# REQUIRE_EXTERNAL_API_KEY=0.
"external_api_key", "external_api_key_required",
# Browser-persistent UI state
"refine_llm_url", "conv_llm_url", "seed_finder_text",
"seed_finder_dir", "pt_dir", "audiobook_prompt",
}
# ── 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),
# NOT voice_design: the stability block's fixed seed=0 + temperature=0.1
# exists so repeated reads of the SAME cloned voice sound consistent
# across takes — exactly backwards for voice design, where every call is
# supposed to produce a DIFFERENT voice from a different character
# prompt. Pinning the model's random draw meant the prompt text was the
# only source of variation, and low temperature flattened even that —
# confirmed live: auto-designed voices for different characters all
# sounded near-identical. Leaving this empty lets the backend use its
# own natural randomization per call, same as the other creative-voice
# backends below.
"voice_design": {},
# BUT distinct from the above: once a voice has already been designed
# and saved, every ONGOING line of dialogue that reuses it is a
# voice_clone-style read of a fixed identity, not a fresh design — it
# needs the SAME stability as voice_clone, not voice_design's
# deliberately-unconstrained randomness. Both used to share the single
# "voice_design" backend key, so every line of an audiobook synthesized
# with a designed voice ran with fully unconstrained temperature/top_p —
# confirmed live as the actual cause of designed voices (unlike cloned
# ones, which already got the stability block) occasionally repeating
# text or drifting into unrelated words mid-book.
# No "seed" key here, unlike the other stability defaults: the whole
# point of the per-voice Seed Finder / Batch Seeds tools is to pin a
# SPECIFIC seed per designed voice (stored server-side against that
# voice_id) — a hardcoded seed=0 in every request would silently
# override that per-voice choice on every single line. Temperature/top_p
# alone already gives real stability without fighting the per-voice pin.
"voice_design_playback": {k: v for k, v in _TTS_STABILITY_DEFAULT.items() if k != "seed"},
"nvidia_magpie": {},
"nvidia_zeroshot": {},
"nvidia_flow": {},
"kokoro": {},
"vibevoice": {},
"xtts": {},
"fishspeech": {},
}
_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",
"fish": "fishspeech",
"fish_speech": "fishspeech",
"fishaudio": "fishspeech",
"openaudio": "fishspeech",
}
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", "fishspeech"} 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 {}
# "voice_design_playback" is a pseudo-backend used only as a stability-
# profile lookup key (the actual HTTP calls still go to the voice_design
# engine) — it isn't a real routing target, so _clean_preview_backend
# doesn't recognize it and was silently coercing it back to "voice_clone".
# Confirmed live: that meant every designed-voice playback line picked up
# voice_clone's persisted seed:0 default instead of voice_design_playback's
# deliberately seed-less profile, permanently overriding every per-voice
# pinned seed with a hardcoded 0. Only normalize real backend names.
if backend not in _TTS_STABILITY_BY_BACKEND_DEFAULT:
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 = request_with_retries("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 request_with_retries("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
if backend == "fishspeech":
return settings.get("fishspeech_url") or _FISHSPEECH_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("engine_container_names"), dict):
s["engine_container_names"] = {}
if not isinstance(s.get("engine_api_keys"), dict):
s["engine_api_keys"] = {}
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
_settings_cache_db_updated: str = ""
def _load_settings() -> dict:
global _settings_cache, _settings_cache_mtime, _settings_cache_db_updated
mtime = CONFIG_FILE.stat().st_mtime if CONFIG_FILE.exists() else 0.0
db_updated = state_updated("settings")
if (
_settings_cache is not None
and mtime == _settings_cache_mtime
and db_updated == _settings_cache_db_updated
):
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,
"fishspeech_url": _FISHSPEECH_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",
"llm_api_key": "",
"llm_model": "",
"vision_ocr_url": _VISION_OCR_DEFAULT,
"vision_ocr_model": _VISION_OCR_MODEL_DEFAULT,
"vision_ocr_enabled": True,
"vision_ocr_fallback_url": _VISION_OCR_FALLBACK_DEFAULT,
"vision_ocr_fallback_model": _VISION_OCR_FALLBACK_MODEL_DEFAULT,
"engine_local_urls": {},
"engine_container_names": {},
"engine_api_keys": {},
"custom_engine_cards": [],
"image_gen_provider": "",
"image_gen_model": "",
"comfyui_url": "http://host.docker.internal:8188",
"comfyui_workflow": "",
"comfyui_prompt_node_id": "",
"comfyui_prompt_field": "text",
"comfyui_output_node_id": "",
"external_api_key": "",
"external_api_key_required": True,
"refine_llm_url": "",
"conv_llm_url": "",
"seed_finder_text": "",
"seed_finder_dir": "",
"pt_dir": "",
}
file_saved = None
if CONFIG_FILE.exists():
try:
file_saved = json.loads(CONFIG_FILE.read_text())
if isinstance(file_saved, dict):
defaults.update({k: v for k, v in file_saved.items() if k in _SETTINGS_KEYS})
except Exception:
pass
db_saved = state_get("settings", None)
if isinstance(db_saved, dict):
defaults.update({k: v for k, v in db_saved.items() if k in _SETTINGS_KEYS})
result = _normalize_settings(defaults)
if not db_updated and (file_saved is not None or CONFIG_FILE.exists()):
try:
state_put("settings", result)
db_updated = state_updated("settings")
except Exception:
pass
_settings_cache = result
_settings_cache_mtime = mtime
_settings_cache_db_updated = db_updated
return dict(result)
def _ensure_external_api_key() -> str:
"""Returns the app's inbound API key, generating + persisting one on
first use. Called lazily by the auth middleware rather than at startup,
so a fresh install doesn't need a migration step."""
import secrets
settings = _load_settings()
key = (settings.get("external_api_key") or "").strip()
if key:
return key
key = secrets.token_urlsafe(32)
settings["external_api_key"] = key
_save_settings(settings)
return key
def _save_settings(s: dict) -> None:
global _settings_cache, _settings_cache_mtime, _settings_cache_db_updated
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
clean = _normalize_settings({k: v for k, v in s.items() if k in _SETTINGS_KEYS})
state_put("settings", clean)
CONFIG_FILE.write_text(json.dumps(clean, indent=2))
_settings_cache = clean
_settings_cache_mtime = CONFIG_FILE.stat().st_mtime
_settings_cache_db_updated = state_updated("settings")