""" Runtime configuration for the voice-cloning app. All values come from environment variables. Nothing is hard-coded except the canonical language catalogue (see LANGUAGE_CATALOG) which defines the UI-level language options we want to expose; the backend `/languages` endpoint is still queried at startup and the intersection is used. """ from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from typing import Dict # Load .env from the repo root (or current working directory) before any # os.getenv() calls in this module run. Silent no-op if python-dotenv isn't # installed or .env is absent. try: from dotenv import load_dotenv as _load_dotenv # Look for .env walking up from this file's directory first, then CWD. _here = Path(__file__).resolve().parent for _candidate in [_here.parent / ".env", _here / ".env", Path.cwd() / ".env"]: if _candidate.is_file(): _load_dotenv(_candidate, override=False) break else: # Fallback: let python-dotenv use its own search (walks up from CWD). _load_dotenv(override=False) except ImportError: pass # --------------------------------------------------------------------------- # Canonical language catalogue # --------------------------------------------------------------------------- # Maps ISO-639 codes used by our UI to a (native name, english name, provider- # hint) triple. `provider_hint` indicates which backend natively handles the # language: # - "chatterbox": native in Chatterbox Multilingual (23 langs). # - "minimax": only served cleanly by MiniMax (language_boost=Chinese,Yue). # The client auto-routes per hint. # # Cantonese (yue) is intentionally at the top because it is a first-class # requirement for this project (Hong Kong users). LANGUAGE_CATALOG: Dict[str, Dict[str, str]] = { "yue": {"native": "粵語 (Cantonese)", "english": "Cantonese", "provider_hint": "minimax"}, "en": {"native": "English", "english": "English", "provider_hint": "chatterbox"}, "zh": {"native": "普通話 (Mandarin)", "english": "Mandarin", "provider_hint": "chatterbox"}, "ja": {"native": "日本語", "english": "Japanese", "provider_hint": "chatterbox"}, "ko": {"native": "한국어", "english": "Korean", "provider_hint": "chatterbox"}, "fr": {"native": "Français", "english": "French", "provider_hint": "chatterbox"}, "es": {"native": "Español", "english": "Spanish", "provider_hint": "chatterbox"}, } def _as_bool(val: str | None, default: bool = False) -> bool: if val is None: return default return val.strip().lower() in {"1", "true", "yes", "on"} def _as_int(val: str | None, default: int) -> int: try: return int(val) if val is not None and val.strip() != "" else default except ValueError: return default def _as_float(val: str | None, default: float) -> float: try: return float(val) if val is not None and val.strip() != "" else default except ValueError: return default @dataclass class ChatterboxProviderConfig: """Config for the Chatterbox-compatible HTTP backend (our Modal deploy).""" base_url: str = "" api_key: str | None = None default_model: str = "chatterbox-multilingual" timeout_seconds: int = 180 @classmethod def from_env(cls) -> "ChatterboxProviderConfig": return cls( base_url=(os.getenv("CHATTERBOX_API_BASE_URL", "") or "").rstrip("/"), api_key=os.getenv("CHATTERBOX_API_KEY") or None, default_model=os.getenv("CHATTERBOX_DEFAULT_MODEL", "chatterbox-multilingual"), timeout_seconds=_as_int(os.getenv("CHATTERBOX_TIMEOUT_SECONDS"), 180), ) @property def is_configured(self) -> bool: return bool(self.base_url) @dataclass class MinimaxProviderConfig: """Config for the MiniMax provider (Cantonese-capable).""" api_base_url: str = "https://api.minimax.io" api_key: str | None = None group_id: str | None = None default_model: str = "speech-2.8-hd" timeout_seconds: int = 180 @classmethod def from_env(cls) -> "MinimaxProviderConfig": return cls( api_base_url=(os.getenv("MINIMAX_API_BASE_URL", "https://api.minimax.io") or "").rstrip("/"), api_key=os.getenv("MINIMAX_API_KEY") or None, group_id=os.getenv("MINIMAX_GROUP_ID") or None, default_model=os.getenv("MINIMAX_DEFAULT_MODEL", "speech-2.8-hd"), timeout_seconds=_as_int(os.getenv("MINIMAX_TIMEOUT_SECONDS"), 180), ) @property def is_configured(self) -> bool: # group_id is optional for plain T2A, but api_key is mandatory. return bool(self.api_key) @dataclass class Settings: """Top-level settings object used by the app.""" # Upload limits (reference audio). max_reference_mb: int = 25 min_reference_seconds: float = 3.0 recommended_reference_seconds_min: float = 10.0 recommended_reference_seconds_max: float = 30.0 max_reference_seconds: float = 60.0 # Generation defaults (RESOLVED CONFLICT between notes and UI). # Chatterbox docs: exaggeration default 0.5 (range 0.25-2.0), # cfg_weight default 0.5 (range 0.0-1.0). The old report's "3.0" was # a documentation error. We honour the upstream-documented ranges. default_exaggeration: float = 0.5 default_cfg_weight: float = 0.5 default_temperature: float = 0.8 exaggeration_range: tuple = (0.25, 2.0) cfg_weight_range: tuple = (0.0, 1.0) temperature_range: tuple = (0.05, 5.0) # Runtime behaviour. temp_dir: Path = field(default_factory=lambda: Path(os.getenv("TEMP_DIR", "/tmp/voice-clone-cloud"))) retry_on_transient: bool = True request_retry_once: bool = True log_provider_errors: bool = True # Feature flags. # Watermarking: we honour the backend's default. Only disable if a # provider explicitly cannot install the `perth` library. The old HPC # build disabled it because HF Hub was offline and `perth` wasn't # vendored in the cluster cache — not a real runtime constraint. disable_watermark: bool = False chatterbox: ChatterboxProviderConfig = field(default_factory=ChatterboxProviderConfig.from_env) minimax: MinimaxProviderConfig = field(default_factory=MinimaxProviderConfig.from_env) # Gradio server. gradio_server_name: str = "0.0.0.0" gradio_server_port: int = 7860 @classmethod def from_env(cls) -> "Settings": s = cls( max_reference_mb=_as_int(os.getenv("MAX_REFERENCE_MB"), 25), min_reference_seconds=_as_float(os.getenv("MIN_REFERENCE_SECONDS"), 3.0), max_reference_seconds=_as_float(os.getenv("MAX_REFERENCE_SECONDS"), 60.0), default_exaggeration=_as_float(os.getenv("DEFAULT_EXAGGERATION"), 0.5), default_cfg_weight=_as_float(os.getenv("DEFAULT_CFG_WEIGHT"), 0.5), default_temperature=_as_float(os.getenv("DEFAULT_TEMPERATURE"), 0.8), disable_watermark=_as_bool(os.getenv("DISABLE_WATERMARK"), False), gradio_server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), gradio_server_port=_as_int(os.getenv("GRADIO_SERVER_PORT"), 7860), ) s.temp_dir.mkdir(parents=True, exist_ok=True) return s # Cached singleton (lazy). _settings: Settings | None = None def get_settings() -> Settings: global _settings if _settings is None: _settings = Settings.from_env() return _settings def reset_settings() -> None: """For tests: drop the cached settings so env changes take effect.""" global _settings _settings = None