"""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, _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", "llm_model", # Browser-persistent UI state "engine_local_urls", "engine_container_names", "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", "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 {} 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 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("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, "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_model": "", "engine_local_urls": {}, "engine_container_names": {}, "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