From dbe73254a4c87ee83f2770f5ab8a954a0c89baa8 Mon Sep 17 00:00:00 2001 From: Artem Akymenko Date: Sun, 19 Jul 2026 13:24:52 +0000 Subject: [PATCH] refactor: add SETTINGS_REGISTRY contract to domain/settings_core.py - Setting dataclass: key, type, default, min/max, valid_values, scope - 72 settings total: 54 shared, 18 PyQt-only - validate_setting() checks types and ranges - Setting.coerce() handles type conversion with bounds - settings_defaults() / all_settings_defaults() derived from registry - BOOLEAN_SETTINGS, FLOAT_SETTINGS, INT_SETTINGS now auto-derived - 17 tests validating schema, coercion, and validation --- abogen/domain/settings_core.py | 732 ++++++++++++++++++++------------ tests/test_settings_registry.py | 119 ++++++ 2 files changed, 582 insertions(+), 269 deletions(-) create mode 100644 tests/test_settings_registry.py diff --git a/abogen/domain/settings_core.py b/abogen/domain/settings_core.py index 542d0be..0e65520 100644 --- a/abogen/domain/settings_core.py +++ b/abogen/domain/settings_core.py @@ -1,14 +1,16 @@ """Shared settings core. -Pure settings logic (defaults, coercion, normalization) used by both -Web UI and Desktop GUI. No Flask dependencies. +Defines the SETTINGS_REGISTRY — the single source of truth for all settings. +Every setting has a key, type, default, validation rules, and UI scope. +Both Web UI and Desktop GUI must reference this registry. """ from __future__ import annotations import os import re -from typing import Any, Dict, Mapping +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Mapping, Optional, Sequence from abogen.constants import ( LANGUAGE_DESCRIPTIONS, @@ -21,7 +23,439 @@ from abogen.normalization_settings import ( environment_llm_defaults, ) -# ── Constants ──────────────────────────────────────────────────────── + +# ── Schema ─────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Setting: + """Contract for a single setting. + + Attributes: + key: Config dict key (e.g. "output_format"). + type_: Python type (bool, int, float, str, list). + default: Default value or callable returning one. + min_value: Minimum for numeric types. + max_value: Maximum for numeric types. + valid_values: Allowed values for str types (None = any). + gui_only: True if only used by PyQt Desktop GUI. + web_only: True if only used by Web UI. + description: Human-readable explanation. + """ + key: str + type_: type + default: Any + min_value: float | None = None + max_value: float | None = None + valid_values: tuple[Any, ...] | None = None + gui_only: bool = False + web_only: bool = False + description: str = "" + + def coerce(self, value: Any, fallback: Any | None = None) -> Any: + """Coerce value to the declared type, returning fallback on failure.""" + fb = fallback if fallback is not None else self.default + if self.type_ is bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"true", "1", "yes", "on"} + if value is None: + return fb + return bool(value) + if self.type_ is int: + try: + v = int(value) + except (TypeError, ValueError): + return fb + if self.min_value is not None: + v = max(int(self.min_value), v) + if self.max_value is not None: + v = min(int(self.max_value), v) + return v + if self.type_ is float: + try: + v = float(value) + except (TypeError, ValueError): + return fb + if self.min_value is not None: + v = max(self.min_value, v) + if self.max_value is not None: + v = min(self.max_value, v) + return v + if self.type_ is str: + if isinstance(value, str): + v = value.strip() + if self.valid_values and v not in self.valid_values: + return fb + return v + return fb + if self.type_ is list: + if isinstance(value, (list, tuple, set)): + return list(value) + return fb + return value + + +# ── Registry ───────────────────────────────────────────────────────── + +def _default_output_format() -> str: + return "wav" + + +def _default_save_mode() -> str: + return "default_output" if has_output_override() else "save_next_to_input" + + +def _default_llm(key: str) -> str: + return environment_llm_defaults().get(key, "") + + +SETTINGS_REGISTRY: list[Setting] = [ + # ── Core output ────────────────────────────────────────────── + Setting("output_format", str, "wav", + valid_values=tuple(SUPPORTED_SOUND_FORMATS), + description="Audio output format"), + Setting("subtitle_format", str, "srt", + valid_values=tuple(item[0] for item in SUBTITLE_FORMATS), + description="Subtitle file format"), + Setting("save_mode", str, _default_save_mode, + description="Where to save output files"), + Setting("separate_chapters_format", str, "wav", + valid_values=("wav", "flac", "mp3", "opus"), + description="Format for separately saved chapters"), + Setting("chunk_level", str, "paragraph", + valid_values=("paragraph", "sentence"), + description="Text chunking granularity"), + + # ── Voice ──────────────────────────────────────────────────── + Setting("default_speaker", str, "", + description="Default speaker name"), + Setting("default_voice", str, lambda: get_default_voice("kokoro"), + description="Default TTS voice"), + Setting("speed", float, 1.0, min_value=0.5, max_value=3.0, + gui_only=True, + description="TTS speed multiplier"), + Setting("supertonic_total_steps", int, 5, min_value=2, max_value=15, + description="SuperTonic processing steps"), + Setting("supertonic_speed", float, 1.0, min_value=0.7, max_value=2.0, + description="SuperTonic speed"), + + # ── Chapter handling ───────────────────────────────────────── + Setting("silence_between_chapters", float, 2.0, min_value=0.0, + description="Silence gap between chapters (seconds)"), + Setting("chapter_intro_delay", float, 0.5, min_value=0.0, + description="Delay after chapter heading (seconds)"), + Setting("read_title_intro", bool, False, + description="Read chapter title as intro"), + Setting("read_closing_outro", bool, True, + description="Read closing/outro text"), + Setting("normalize_chapter_opening_caps", bool, True, + description="Normalize chapter opening caps"), + Setting("auto_prefix_chapter_titles", bool, True, + description="Auto-prefix chapter titles"), + Setting("save_chapters_separately", bool, False, + description="Save each chapter as separate file"), + Setting("merge_chapters_at_end", bool, True, + description="Merge chapters into single file"), + Setting("save_as_project", bool, False, + description="Save as editable project"), + Setting("generate_epub3", bool, False, + description="Generate EPUB3 output"), + + # ── GPU / performance ──────────────────────────────────────── + Setting("use_gpu", bool, True, + description="Use GPU acceleration"), + + # ── Text processing ────────────────────────────────────────── + Setting("replace_single_newlines", bool, False, + description="Replace single newlines with spaces"), + Setting("max_subtitle_words", int, 50, min_value=1, max_value=500, + description="Max words per subtitle"), + Setting("enable_entity_recognition", bool, True, + description="Enable entity recognition"), + + # ── Speaker analysis ───────────────────────────────────────── + Setting("speaker_analysis_threshold", int, 3, min_value=1, max_value=25, + description="Speaker analysis threshold"), + Setting("speaker_pronunciation_sentence", str, "This is {{name}} speaking.", + description="Template for pronunciation samples"), + Setting("speaker_random_languages", list, [], + description="Languages for random speaker assignment"), + + # ── LLM ────────────────────────────────────────────────────── + Setting("llm_base_url", str, lambda: _default_llm("llm_base_url"), + description="LLM API base URL"), + Setting("llm_api_key", str, lambda: _default_llm("llm_api_key"), + description="LLM API key"), + Setting("llm_model", str, lambda: _default_llm("llm_model"), + description="LLM model name"), + Setting("llm_timeout", float, lambda: _default_llm("llm_timeout") or 30.0, + min_value=1.0, + description="LLM request timeout"), + Setting("llm_prompt", str, lambda: _default_llm("llm_prompt") or DEFAULT_LLM_PROMPT, + description="LLM normalization prompt"), + Setting("llm_context_mode", str, lambda: _default_llm("llm_context_mode") or "sentence", + valid_values=("sentence",), + description="LLM context mode"), + + # ── Normalization (booleans) ───────────────────────────────── + Setting("normalization_numbers", bool, True, + description="Convert grouped numbers to words"), + Setting("normalization_currency", bool, True, + description="Convert currency symbols"), + Setting("normalization_footnotes", bool, True, + description="Remove footnote indicators"), + Setting("normalization_titles", bool, True, + description="Expand titles and suffixes"), + Setting("normalization_terminal", bool, True, + description="Ensure terminal punctuation"), + Setting("normalization_phoneme_hints", bool, True, + description="Add phoneme hints for possessives"), + Setting("normalization_caps_quotes", bool, True, + description="Convert ALL CAPS in quotes"), + Setting("normalization_internet_slang", bool, False, + description="Expand internet slang"), + Setting("normalization_apostrophes_contractions", bool, True, + description="Expand contractions"), + Setting("normalization_apostrophes_plural_possessives", bool, True, + description="Collapse plural possessives"), + Setting("normalization_apostrophes_sibilant_possessives", bool, True, + description="Mark sibilant possessives"), + Setting("normalization_apostrophes_decades", bool, True, + description="Expand decades"), + Setting("normalization_apostrophes_leading_elisions", bool, True, + description="Expand leading elisions"), + Setting("normalization_contraction_aux_be", bool, True, + description="Expand auxiliary 'be'"), + Setting("normalization_contraction_aux_have", bool, True, + description="Expand auxiliary 'have'"), + Setting("normalization_contraction_modal_will", bool, True, + description="Expand modal 'will'"), + Setting("normalization_contraction_modal_would", bool, True, + description="Expand modal 'would'"), + Setting("normalization_contraction_negation_not", bool, True, + description="Expand negation 'not'"), + Setting("normalization_contraction_let_us", bool, True, + description="Expand 'let's'"), + + # ── Normalization (strings) ────────────────────────────────── + Setting("normalization_apostrophe_mode", str, "spacy", + valid_values=("off", "spacy", "llm"), + description="Apostrophe handling mode"), + Setting("normalization_numbers_year_style", str, "american", + valid_values=("american", "off"), + description="Year style for number normalization"), + + # ── PyQt GUI-only ──────────────────────────────────────────── + Setting("theme", str, "system", + gui_only=True, + description="UI theme"), + Setting("check_updates", bool, True, + gui_only=True, + description="Check for updates on startup"), + Setting("subtitle_mode", str, "Sentence", + gui_only=True, + description="Subtitle display mode"), + Setting("selected_format", str, "wav", + gui_only=True, + description="Last selected audio format"), + Setting("selected_voice", str, "af_heart", + gui_only=True, + description="Last selected voice"), + Setting("selected_profile_name", str, None, + gui_only=True, + description="Last selected profile name"), + Setting("log_window_max_lines", int, 2000, min_value=100, + gui_only=True, + description="Max lines in log window"), + Setting("use_silent_gaps", bool, True, + gui_only=True, + description="Use silent gaps between chunks"), + Setting("subtitle_speed_method", str, "tts", + gui_only=True, + valid_values=("tts", "ffmpeg"), + description="Speed adjustment method for subtitles"), + Setting("use_spacy_segmentation", bool, True, + gui_only=True, + description="Use spaCy for sentence segmentation"), + Setting("word_substitutions_enabled", bool, False, + gui_only=True, + description="Enable word substitutions"), + Setting("word_substitutions_list", str, "", + gui_only=True, + description="Word substitutions list"), + Setting("case_sensitive_substitutions", bool, False, + gui_only=True, + description="Case-sensitive substitutions"), + Setting("replace_all_caps", bool, False, + gui_only=True, + description="Replace ALL CAPS text"), + Setting("replace_numerals", bool, False, + gui_only=True, + description="Replace numerals with words"), + Setting("fix_nonstandard_punctuation", bool, False, + gui_only=True, + description="Fix nonstandard punctuation"), + Setting("queue_override_settings", bool, False, + gui_only=True, + description="Override settings per queue item"), + Setting("disable_kokoro_internet", bool, False, + description="Disable Kokoro internet access"), +] + + +# ── Registry helpers ───────────────────────────────────────────────── + +_REGISTRY_BY_KEY: dict[str, Setting] = {s.key: s for s in SETTINGS_REGISTRY} + +SETTING_KEYS: frozenset[str] = frozenset(_REGISTRY_BY_KEY.keys()) +GUI_ONLY_KEYS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.gui_only) +WEB_ONLY_KEYS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.web_only) +SHARED_KEYS: frozenset[str] = SETTING_KEYS - GUI_ONLY_KEYS - WEB_ONLY_KEYS + +BOOLEAN_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is bool) +FLOAT_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is float) +INT_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is int) + +# Backward-compatible aliases (used by existing code) +_NORMALIZATION_BOOLEAN_KEYS: frozenset[str] = frozenset( + s.key for s in SETTINGS_REGISTRY + if s.type_ is bool and s.key.startswith("normalization_") +) +_NORMALIZATION_STRING_KEYS: frozenset[str] = frozenset( + s.key for s in SETTINGS_REGISTRY + if s.type_ is str and s.key.startswith("normalization_") +) + + +def get_setting(key: str) -> Setting | None: + """Look up a setting by key.""" + return _REGISTRY_BY_KEY.get(key) + + +def has_output_override() -> bool: + return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT")) + + +# ── Defaults ───────────────────────────────────────────────────────── + +def settings_defaults() -> Dict[str, Any]: + """Default values for all shared settings (excludes gui_only).""" + result: Dict[str, Any] = {} + for s in SETTINGS_REGISTRY: + if s.gui_only: + continue + result[s.key] = s.default() if callable(s.default) else s.default + return result + + +def all_settings_defaults() -> Dict[str, Any]: + """Default values for ALL settings (including gui_only).""" + result: Dict[str, Any] = {} + for s in SETTINGS_REGISTRY: + result[s.key] = s.default() if callable(s.default) else s.default + return result + + +# ── Normalization (delegates to Setting.coerce) ────────────────────── + +def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any: + """Normalize a single setting value using the registry schema.""" + setting = _REGISTRY_BY_KEY.get(key) + if setting is None: + return value if value is not None else defaults.get(key) + + # Special handling for keys with complex normalization + if key == "save_mode": + return _normalize_save_mode(value, defaults[key]) + if key == "default_voice": + return _normalize_default_voice(value, defaults[key]) + if key == "default_speaker": + return _normalize_default_speaker(value) + if key == "speaker_random_languages": + return _normalize_language_list(value, defaults.get(key, [])) + if key in {"llm_base_url", "llm_api_key", "llm_model"}: + return str(value or "").strip() + if key == "llm_prompt": + candidate = str(value or "").strip() + return candidate if candidate else defaults[key] + + return setting.coerce(value, defaults.get(key)) + + +def _normalize_save_mode(value: Any, default: str) -> str: + if isinstance(value, str): + if value in SAVE_MODE_LABELS: + return value + if value in LEGACY_SAVE_MODE_MAP: + return LEGACY_SAVE_MODE_MAP[value] + return default + + +def _normalize_default_voice(value: Any, default: str) -> str: + if isinstance(value, str): + text = value.strip() + if not text: + return default + spec, profile_name = split_profile_spec(text) + if profile_name: + return f"speaker:{profile_name}" + return spec + return default + + +def _normalize_default_speaker(value: Any) -> str: + if isinstance(value, str): + text = value.strip() + if not text: + return "" + spec, profile_name = split_profile_spec(text) + if profile_name: + return f"speaker:{profile_name}" + return spec + return "" + + +def _normalize_language_list(value: Any, default: list) -> list: + if isinstance(value, (list, tuple, set)): + return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS] + if isinstance(value, str): + parts = [item.strip().lower() for item in value.split(",") if item.strip()] + return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] + return default + + +def validate_setting(key: str, value: Any) -> tuple[bool, str]: + """Validate a setting value against its schema. Returns (ok, error_message).""" + setting = _REGISTRY_BY_KEY.get(key) + if setting is None: + return False, f"Unknown setting: {key}" + if setting.type_ is str and setting.valid_values is not None: + v = str(value or "").strip() + if v and v not in setting.valid_values: + return False, f"Invalid value '{v}' for {key}. Allowed: {setting.valid_values}" + if setting.type_ is int: + try: + iv = int(value) + except (TypeError, ValueError): + return False, f"Invalid integer value for {key}: {value!r}" + if setting.min_value is not None and iv < setting.min_value: + return False, f"{key} must be >= {setting.min_value}, got {iv}" + if setting.max_value is not None and iv > setting.max_value: + return False, f"{key} must be <= {setting.max_value}, got {iv}" + if setting.type_ is float: + try: + fv = float(value) + except (TypeError, ValueError): + return False, f"Invalid float value for {key}: {value!r}" + if setting.min_value is not None and fv < setting.min_value: + return False, f"{key} must be >= {setting.min_value}, got {fv}" + if setting.max_value is not None and fv > setting.max_value: + return False, f"{key} must be <= {setting.max_value}, got {fv}" + return True, "" + + +# ── Constants (backward-compatible) ────────────────────────────────── SAVE_MODE_LABELS = { "save_next_to_input": "Save next to input file", @@ -37,104 +471,25 @@ CHUNK_LEVEL_OPTIONS = [ {"value": "sentence", "label": "Sentences"}, ] -CHUNK_LEVEL_VALUES = {option["value"] for option in CHUNK_LEVEL_OPTIONS} +CHUNK_LEVEL_VALUES = frozenset(option["value"] for option in CHUNK_LEVEL_OPTIONS) DEFAULT_ANALYSIS_THRESHOLD = 3 -BOOLEAN_SETTINGS = { - "replace_single_newlines", - "use_gpu", - "save_chapters_separately", - "merge_chapters_at_end", - "save_as_project", - "read_title_intro", - "read_closing_outro", - "normalize_chapter_opening_caps", - "generate_epub3", - "auto_prefix_chapter_titles", - "enable_entity_recognition", - "normalization_numbers", - "normalization_titles", - "normalization_terminal", - "normalization_phoneme_hints", - "normalization_caps_quotes", - "normalization_currency", - "normalization_footnotes", - "normalization_internet_slang", - "normalization_apostrophes_contractions", - "normalization_apostrophes_plural_possessives", - "normalization_apostrophes_sibilant_possessives", - "normalization_apostrophes_decades", - "normalization_apostrophes_leading_elisions", - "normalization_contraction_aux_be", - "normalization_contraction_aux_have", - "normalization_contraction_modal_will", - "normalization_contraction_modal_would", - "normalization_contraction_negation_not", - "normalization_contraction_let_us", -} -FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"} - -INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"} - -_NORMALIZATION_BOOLEAN_KEYS = { - "normalization_numbers", - "normalization_titles", - "normalization_terminal", - "normalization_phoneme_hints", - "normalization_caps_quotes", - "normalization_currency", - "normalization_footnotes", - "normalization_internet_slang", - "normalization_apostrophes_contractions", - "normalization_apostrophes_plural_possessives", - "normalization_apostrophes_sibilant_possessives", - "normalization_apostrophes_decades", - "normalization_apostrophes_leading_elisions", - "normalization_contraction_aux_be", - "normalization_contraction_aux_have", - "normalization_contraction_modal_will", - "normalization_contraction_modal_would", - "normalization_contraction_negation_not", - "normalization_contraction_let_us", -} - -_NORMALIZATION_STRING_KEYS = { - "normalization_apostrophe_mode", - "normalization_numbers_year_style", -} - - -# ── Coercion helpers ───────────────────────────────────────────────── +# ── Coercion helpers (backward-compatible, delegate to Setting.coerce) ── def coerce_bool(value: Any, default: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in {"true", "1", "yes", "on"} - if value is None: - return default - return bool(value) + return Setting("_", bool, default).coerce(value, default) def coerce_float(value: Any, default: float) -> float: - try: - return max(0.0, float(value)) - except (TypeError, ValueError): - return default + return Setting("_", float, default).coerce(value, default) def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int: - try: - parsed = int(value) - except (TypeError, ValueError): - return default - return max(minimum, min(parsed, maximum)) + return Setting("_", int, default, min_value=minimum, max_value=maximum).coerce(value, default) -# ── Profile/speaker spec helpers ───────────────────────────────────── - def split_profile_spec(value: Any) -> tuple[str, str | None]: """Split 'speaker:Name' or 'profile:Name' into (raw, name).""" text = str(value or "").strip() @@ -148,172 +503,32 @@ def split_profile_spec(value: Any) -> tuple[str, str | None]: return text, None -# ── Normalization ──────────────────────────────────────────────────── - def normalize_save_mode(value: Any, default: str) -> str: - if isinstance(value, str): - if value in SAVE_MODE_LABELS: - return value - if value in LEGACY_SAVE_MODE_MAP: - return LEGACY_SAVE_MODE_MAP[value] - return default + return _normalize_save_mode(value, default) -def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any: - """Normalize a single setting value to its expected type.""" - if key in BOOLEAN_SETTINGS: - return coerce_bool(value, defaults[key]) - if key in FLOAT_SETTINGS: - return coerce_float(value, defaults[key]) - if key in INT_SETTINGS: - return coerce_int(value, defaults[key]) - if key == "save_mode": - return normalize_save_mode(value, defaults[key]) - if key == "output_format": - return value if value in SUPPORTED_SOUND_FORMATS else defaults[key] - if key == "subtitle_format": - valid = {item[0] for item in SUBTITLE_FORMATS} - return value if value in valid else defaults[key] - if key == "separate_chapters_format": - if isinstance(value, str): - normalized = value.lower() - if normalized in {"wav", "flac", "mp3", "opus"}: - return normalized - return defaults[key] - if key == "default_voice": - if isinstance(value, str): - text = value.strip() - if not text: - return defaults[key] - spec, profile_name = split_profile_spec(text) - if profile_name: - return f"speaker:{profile_name}" - return spec - return defaults[key] - if key == "default_speaker": - if isinstance(value, str): - text = value.strip() - if not text: - return "" - spec, profile_name = split_profile_spec(text) - if profile_name: - return f"speaker:{profile_name}" - return spec +# ── LLM helpers ────────────────────────────────────────────────────── + +_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") + + +def llm_ready(settings: Mapping[str, Any]) -> bool: + base_url = str(settings.get("llm_base_url") or "").strip() + return bool(base_url) + + +def render_prompt_template(template: str, context: Mapping[str, str]) -> str: + if not template: return "" - if key == "chunk_level": - if isinstance(value, str) and value in CHUNK_LEVEL_VALUES: - return value - return defaults[key] - if key == "normalization_apostrophe_mode": - if isinstance(value, str): - normalized_mode = value.strip().lower() - if normalized_mode in {"off", "spacy", "llm"}: - return normalized_mode - return defaults[key] - if key == "normalization_numbers_year_style": - if isinstance(value, str): - normalized_style = value.strip().lower() - if normalized_style in {"american", "off"}: - return normalized_style - return defaults[key] - if key == "llm_context_mode": - if isinstance(value, str): - normalized_scope = value.strip().lower() - if normalized_scope == "sentence": - return normalized_scope - return defaults[key] - if key == "llm_prompt": - candidate = str(value or "").strip() - return candidate if candidate else defaults[key] - if key in {"llm_base_url", "llm_api_key", "llm_model"}: - return str(value or "").strip() - if key == "speaker_random_languages": - if isinstance(value, (list, tuple, set)): - return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS] - if isinstance(value, str): - parts = [item.strip().lower() for item in value.split(",") if item.strip()] - return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] - return defaults.get(key, []) - if key == "supertonic_total_steps": - try: - steps = int(value) - except (TypeError, ValueError): - return defaults.get(key, 5) - return max(2, min(15, steps)) - if key == "supertonic_speed": - try: - speed = float(value) - except (TypeError, ValueError): - return defaults.get(key, 1.0) - return max(0.7, min(2.0, speed)) - return value if value is not None else defaults.get(key) + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + return context.get(key, "") + + return _PROMPT_TOKEN_RE.sub(_replace, template) -# ── Defaults ───────────────────────────────────────────────────────── - -def has_output_override() -> bool: - return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT")) - - -def settings_defaults() -> Dict[str, Any]: - """Default values for all settings keys.""" - llm_env_defaults = environment_llm_defaults() - return { - "output_format": "wav", - "subtitle_format": "srt", - "save_mode": "default_output" if has_output_override() else "save_next_to_input", - "default_speaker": "", - "default_voice": get_default_voice("kokoro"), - "supertonic_total_steps": 5, - "supertonic_speed": 1.0, - "replace_single_newlines": False, - "use_gpu": True, - "save_chapters_separately": False, - "merge_chapters_at_end": True, - "save_as_project": False, - "separate_chapters_format": "wav", - "silence_between_chapters": 2.0, - "chapter_intro_delay": 0.5, - "read_title_intro": False, - "read_closing_outro": True, - "normalize_chapter_opening_caps": True, - "max_subtitle_words": 50, - "chunk_level": "paragraph", - "enable_entity_recognition": True, - "generate_epub3": False, - "auto_prefix_chapter_titles": True, - "speaker_analysis_threshold": DEFAULT_ANALYSIS_THRESHOLD, - "speaker_pronunciation_sentence": "This is {{name}} speaking.", - "speaker_random_languages": [], - "llm_base_url": llm_env_defaults.get("llm_base_url", ""), - "llm_api_key": llm_env_defaults.get("llm_api_key", ""), - "llm_model": llm_env_defaults.get("llm_model", ""), - "llm_timeout": llm_env_defaults.get("llm_timeout", 30.0), - "llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT), - "llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"), - "normalization_numbers": True, - "normalization_currency": True, - "normalization_footnotes": True, - "normalization_titles": True, - "normalization_terminal": True, - "normalization_phoneme_hints": True, - "normalization_caps_quotes": True, - "normalization_internet_slang": False, - "normalization_apostrophes_contractions": True, - "normalization_apostrophes_plural_possessives": True, - "normalization_apostrophes_sibilant_possessives": True, - "normalization_apostrophes_decades": True, - "normalization_apostrophes_leading_elisions": True, - "normalization_apostrophe_mode": "spacy", - "normalization_numbers_year_style": "american", - "normalization_contraction_aux_be": True, - "normalization_contraction_aux_have": True, - "normalization_contraction_modal_will": True, - "normalization_contraction_modal_would": True, - "normalization_contraction_negation_not": True, - "normalization_contraction_let_us": True, - } - +# ── Integration defaults ───────────────────────────────────────────── def integration_defaults() -> Dict[str, Dict[str, Any]]: """Default values for integration settings.""" @@ -340,24 +555,3 @@ def integration_defaults() -> Dict[str, Dict[str, Any]]: "timeout": 30.0, }, } - - -# ── LLM helpers ────────────────────────────────────────────────────── - -_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") - - -def llm_ready(settings: Mapping[str, Any]) -> bool: - base_url = str(settings.get("llm_base_url") or "").strip() - return bool(base_url) - - -def render_prompt_template(template: str, context: Mapping[str, str]) -> str: - if not template: - return "" - - def _replace(match: re.Match[str]) -> str: - key = match.group(1) - return context.get(key, "") - - return _PROMPT_TOKEN_RE.sub(_replace, template) diff --git a/tests/test_settings_registry.py b/tests/test_settings_registry.py new file mode 100644 index 0000000..add63bb --- /dev/null +++ b/tests/test_settings_registry.py @@ -0,0 +1,119 @@ +"""Tests for domain/settings_core.py — SETTINGS_REGISTRY contract.""" + +from abogen.domain.settings_core import ( + SETTINGS_REGISTRY, + SETTING_KEYS, + GUI_ONLY_KEYS, + SHARED_KEYS, + BOOLEAN_SETTINGS, + FLOAT_SETTINGS, + INT_SETTINGS, + get_setting, + validate_setting, + settings_defaults, + all_settings_defaults, + coerce_bool, + coerce_int, + coerce_float, + Setting, +) + + +class TestSettingSchema: + def test_coerce_bool_from_str(self): + s = Setting("x", bool, False) + assert s.coerce("true") is True + assert s.coerce("false") is False + assert s.coerce("1") is True + assert s.coerce("on") is True + assert s.coerce(None) is False + + def test_coerce_int_with_bounds(self): + s = Setting("x", int, 5, min_value=2, max_value=10) + assert s.coerce(7) == 7 + assert s.coerce(1) == 2 # clamped to min + assert s.coerce(20) == 10 # clamped to max + assert s.coerce("abc") == 5 # fallback to default + + def test_coerce_float_with_bounds(self): + s = Setting("x", float, 1.0, min_value=0.5, max_value=3.0) + assert s.coerce(2.5) == 2.5 + assert s.coerce(0.1) == 0.5 + assert s.coerce(5.0) == 3.0 + + def test_coerce_str_valid_values(self): + s = Setting("x", str, "a", valid_values=("a", "b", "c")) + assert s.coerce("a") == "a" + assert s.coerce("b") == "b" + assert s.coerce("x") == "a" # invalid, fallback + assert s.coerce("") == "a" # empty, fallback + + def test_coerce_list(self): + s = Setting("x", list, []) + assert s.coerce([1, 2]) == [1, 2] + assert s.coerce((1, 2)) == [1, 2] + assert s.coerce(None) == [] + + +class TestRegistry: + def test_no_duplicate_keys(self): + keys = [s.key for s in SETTINGS_REGISTRY] + assert len(keys) == len(set(keys)) + + def test_all_settings_have_valid_type(self): + valid_types = {bool, int, float, str, list} + for s in SETTINGS_REGISTRY: + assert s.type_ in valid_types, f"{s.key} has invalid type {s.type_}" + + def test_min_less_than_max(self): + for s in SETTINGS_REGISTRY: + if s.min_value is not None and s.max_value is not None: + assert s.min_value <= s.max_value, f"{s.key}: min > max" + + def test_default_matches_type(self): + for s in SETTINGS_REGISTRY: + default = s.default() if callable(s.default) else s.default + if default is not None: + assert isinstance(default, s.type_), ( + f"{s.key}: default {default!r} is not {s.type_.__name__}" + ) + + def test_settings_excludes_gui_only(self): + d = settings_defaults() + for key in GUI_ONLY_KEYS: + assert key not in d, f"gui_only key '{key}' should not be in settings_defaults()" + + def test_all_settings_includes_gui_only(self): + d = all_settings_defaults() + for s in SETTINGS_REGISTRY: + assert s.key in d, f"missing key '{s.key}' in all_settings_defaults()" + + def test_coercion_consistency(self): + """Every boolean setting should coerce 'true' to True.""" + for s in SETTINGS_REGISTRY: + if s.type_ is bool: + assert s.coerce("true") is True, f"{s.key} should coerce 'true' to True" + + +class TestValidation: + def test_valid_output_format(self): + ok, msg = validate_setting("output_format", "wav") + assert ok + + def test_invalid_output_format(self): + ok, msg = validate_setting("output_format", "xxx") + assert not ok + assert "xxx" in msg + + def test_valid_int_range(self): + ok, _ = validate_setting("silence_between_chapters", 2.0) + assert ok + + def test_invalid_int_below_min(self): + ok, msg = validate_setting("silence_between_chapters", -1) + assert not ok + + def test_unknown_setting(self): + ok, msg = validate_setting("nonexistent_key", "value") + assert not ok + assert "Unknown" in msg