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
This commit is contained in:
Artem Akymenko
2026-07-19 13:24:52 +00:00
parent 64e8a8f4e6
commit dbe73254a4
2 changed files with 582 additions and 269 deletions
+463 -269
View File
@@ -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)
+119
View File
@@ -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