mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-22 07:10:28 +02:00
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:
+463
-269
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user