From 6b5255a80d160d92df12652a5fa6ae5f69ead190 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 26 Oct 2025 07:42:12 -0700 Subject: [PATCH] Implement LLM client and normalization settings - Added LLMClient class for handling requests to LLM API, including methods for listing models and generating completions. - Introduced LLMConfiguration dataclass for managing LLM configuration settings. - Created normalization_settings module to manage normalization configurations and environment variable overrides. - Developed JavaScript functionality for the settings interface, including model fetching and preview generation for LLM and normalization. - Enhanced user experience with status messages and error handling in the settings UI. --- .env.example | 11 + README.md | 17 ++ abogen/chunking.py | 5 +- abogen/kokoro_text_normalization.py | 155 +++++++++- abogen/llm_client.py | 148 +++++++++ abogen/normalization_settings.py | 151 ++++++++++ abogen/web/conversion_runner.py | 121 +++++++- abogen/web/routes.py | 385 +++++++++++++++++++++++- abogen/web/static/settings.js | 382 ++++++++++++++++++++++++ abogen/web/static/styles.css | 204 ++++++++++++- abogen/web/templates/settings.html | 445 +++++++++++++++++++--------- 11 files changed, 1848 insertions(+), 176 deletions(-) create mode 100644 abogen/llm_client.py create mode 100644 abogen/normalization_settings.py create mode 100644 abogen/web/static/settings.js diff --git a/.env.example b/.env.example index 8bcecc0..4be537c 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,14 @@ ABOGEN_TEMP_DIR=./storage/tmp # id -g # GID ABOGEN_UID=1000 ABOGEN_GID=1000 + +# Optional: Seed the web UI with working defaults for the LLM-powered +# text normalization features. Leave these blank to configure everything +# from the Settings page. +ABOGEN_LLM_BASE_URL=https://localhost:11434/v1 +ABOGEN_LLM_API_KEY=ollama +ABOGEN_LLM_MODEL=llama3.1:8b +ABOGEN_LLM_TIMEOUT=45 +ABOGEN_LLM_CONTEXT_MODE=paragraph +# For custom prompts, keep the text on a single line or escape newlines. +#ABOGEN_LLM_PROMPT=You are assisting with audiobook preparation. Rewrite {{sentence}} for narration. diff --git a/README.md b/README.md index 66c0c3f..fbf57b9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Abogen is a web-first text-to-speech workstation. Drop in an EPUB, PDF, Markdown - Natural-sounding speech powered by Kokoro-82M with per-job voice, speed, GPU toggle, and subtitle style controls - Clean dashboard that tracks the status, progress, and logs of every job in real time (thanks to htmx partial updates) - Automatic chapter detection and subtitle generation with SRT/ASS exports +- LLM-assisted text normalization with live previews and configurable prompts - Runs well in Docker, ships a REST-style JSON API, and works across macOS, Linux, and Windows ## Quick start @@ -55,6 +56,12 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary audio working files | | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | +| `ABOGEN_LLM_BASE_URL` | `""` | OpenAI-compatible endpoint used to seed the Settings → LLM panel | +| `ABOGEN_LLM_API_KEY` | `""` | API key passed to the endpoint above | +| `ABOGEN_LLM_MODEL` | `""` | Default model selected when you refresh the model list | +| `ABOGEN_LLM_TIMEOUT` | `30` | Timeout (seconds) for server-side LLM requests | +| `ABOGEN_LLM_CONTEXT_MODE` | `sentence` | Default prompt context window (`sentence`, `paragraph`, `document`) | +| `ABOGEN_LLM_PROMPT` | `""` | Custom normalization prompt template seeded into the UI | Set any of these with `-e VAR=value` when starting the container. @@ -123,6 +130,15 @@ Abogen falls back to CPU rendering if no GPU is available. Multiple jobs can run sequentially; the worker processes them in order. +## LLM-assisted text normalization +Abogen can hand tricky apostrophes and contractions to an OpenAI-compatible large language model. Configure it from **Settings → LLM**: + +1. Enter the base URL for your endpoint (Ollama, OpenAI proxy, etc.) and an API key if required. +2. Click **Refresh models** to load the catalog, pick a default model, and adjust the timeout or prompt template. +3. Use the preview box to test the prompt, then save the settings. The Normalization panel can synthesize a short audio preview with the current configuration. + +When you are running inside Docker or a CI pipeline, seed the form automatically with `ABOGEN_LLM_*` variables in your `.env` file. The `.env.example` file includes sample values for a local Ollama server. + ## JSON endpoints Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse: - `GET /api/jobs/` returns job metadata, progress, and log lines in JSON. @@ -138,6 +154,7 @@ Most behaviour is controlled through the UI, but a few environment variables are - `ABOGEN_SETTINGS_DIR` – change where Abogen stores its JSON settings/configuration files. - `ABOGEN_TEMP_DIR` – change where temporary uploads and cache files are stored. - `ABOGEN_OUTPUT_DIR` – change where rendered audio/subtitles are written. +- `ABOGEN_LLM_*` – seed the Settings → LLM panel with defaults for base URL, API key, model, timeout, prompt, and context mode. If unset, Abogen picks sensible defaults suitable for local usage. diff --git a/abogen/chunking.py b/abogen/chunking.py index 9e169e5..db914ed 100644 --- a/abogen/chunking.py +++ b/abogen/chunking.py @@ -7,6 +7,7 @@ from typing import Pattern import re from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline +from abogen.normalization_settings import build_apostrophe_config, get_runtime_settings ChunkLevel = Literal["paragraph", "sentence"] @@ -78,7 +79,9 @@ def _normalize_whitespace(value: str) -> str: def _normalize_chunk_text(value: str) -> str: - normalized = normalize_for_pipeline(value, config=_PIPELINE_APOSTROPHE_CONFIG) + settings = get_runtime_settings() + config = build_apostrophe_config(settings=settings, base=_PIPELINE_APOSTROPHE_CONFIG) + normalized = normalize_for_pipeline(value, config=config, settings=settings) return _normalize_whitespace(normalized) diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 4c7b9fb..264445b 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -2,7 +2,7 @@ from __future__ import annotations import re import unicodedata from dataclasses import dataclass -from typing import Callable, Iterable, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple try: # pragma: no cover - optional dependency guard from num2words import num2words except Exception: # pragma: no cover - graceful degradation @@ -598,21 +598,152 @@ def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str: DEFAULT_APOSTROPHE_CONFIG = ApostropheConfig() -def normalize_for_pipeline(text: str, *, config: Optional[ApostropheConfig] = None) -> str: - """Normalize text for the synthesis pipeline. +_MUSTACHE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") +_LLM_SYSTEM_PROMPT = ( + "You rewrite text for audiobook narration. Expand or clarify contractions and apostrophes " + "so the output is explicit and natural to read aloud. Respond with only the rewritten text." +) - This expands contractions, normalizes apostrophes, and adds phoneme hints - using the provided configuration so downstream chunking and synthesis share - the same representation. - """ - cfg = config or DEFAULT_APOSTROPHE_CONFIG - normalized, _details = normalize_apostrophes(text, cfg) - normalized = expand_titles_and_suffixes(normalized) - normalized = ensure_terminal_punctuation(normalized) - normalized = _normalize_grouped_numbers(normalized, cfg) +def _render_mustache(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 _MUSTACHE_PATTERN.sub(_replace, template) + + +_SENTENCE_CAPTURE_RE = re.compile(r"[^.!?]+[.!?]+|[^.!?]+$", re.MULTILINE) + + +def _split_sentences_for_llm(text: str) -> List[str]: + sentences = [segment.strip() for segment in _SENTENCE_CAPTURE_RE.findall(text or "")] + return [segment for segment in sentences if segment] + + +def _normalize_with_llm( + text: str, + *, + settings: Mapping[str, Any], + config: ApostropheConfig, +) -> str: + from abogen.normalization_settings import build_llm_configuration, DEFAULT_LLM_PROMPT + from abogen.llm_client import generate_completion, LLMClientError + + llm_config = build_llm_configuration(settings) + if not llm_config.is_configured(): + raise LLMClientError("LLM configuration is incomplete") + + prompt_template = str(settings.get("llm_prompt") or DEFAULT_LLM_PROMPT) + context_mode = str(settings.get("llm_context_mode") or "sentence").strip().lower() + lines = text.splitlines(keepends=True) + if not lines: + return text + + normalized_lines: List[str] = [] + for raw_line in lines: + newline = "" + if raw_line.endswith(("\r", "\n")): + stripped_newline = raw_line.rstrip("\r\n") + newline = raw_line[len(stripped_newline):] + line_body = stripped_newline + else: + line_body = raw_line + + if not line_body.strip(): + normalized_lines.append(line_body + newline) + continue + + leading_ws = line_body[: len(line_body) - len(line_body.lstrip())] + trailing_ws = line_body[len(line_body.rstrip()):] + core = line_body[len(leading_ws) : len(line_body) - len(trailing_ws)] + + paragraph_context = core + if context_mode == "sentence": + sentences = _split_sentences_for_llm(core) + if not sentences: + normalized_lines.append(line_body + newline) + continue + rewritten_sentences: List[str] = [] + for sentence in sentences: + prompt_context = { + "text": sentence, + "sentence": sentence, + "paragraph": paragraph_context, + } + prompt = _render_mustache(prompt_template, prompt_context) + completion = generate_completion( + llm_config, + system_message=_LLM_SYSTEM_PROMPT, + user_message=prompt, + ) + rewritten_sentences.append(completion.strip()) + normalized_core = " ".join(filter(None, rewritten_sentences)) or core + else: + prompt_context = { + "text": core, + "sentence": core, + "paragraph": paragraph_context, + } + prompt = _render_mustache(prompt_template, prompt_context) + normalized_core = generate_completion( + llm_config, + system_message=_LLM_SYSTEM_PROMPT, + user_message=prompt, + ).strip() or core + + rebuilt = f"{leading_ws}{normalized_core}{trailing_ws}{newline}" + normalized_lines.append(rebuilt) + + result = "".join(normalized_lines) + return result if result else text + + +def normalize_for_pipeline( + text: str, + *, + config: Optional[ApostropheConfig] = None, + settings: Optional[Mapping[str, Any]] = None, +) -> str: + """Normalize text for the synthesis pipeline with runtime settings.""" + + from abogen.normalization_settings import build_apostrophe_config, get_runtime_settings + from abogen.llm_client import LLMClientError + + runtime_settings = settings or get_runtime_settings() + base_config = config or DEFAULT_APOSTROPHE_CONFIG + cfg = build_apostrophe_config(settings=runtime_settings, base=base_config) + + mode = str(runtime_settings.get("normalization_apostrophe_mode", "spacy")).lower() + normalized = text + + if mode == "off": + normalized = normalize_unicode_apostrophes(text) + if cfg.convert_numbers: + normalized = _normalize_grouped_numbers(normalized, cfg) + normalized = _cleanup_spacing(normalized) + elif mode == "llm": + try: + normalized = _normalize_with_llm(text, settings=runtime_settings, config=cfg) + except LLMClientError: + raise + if cfg.convert_numbers: + normalized = _normalize_grouped_numbers(normalized, cfg) + normalized = _cleanup_spacing(normalized) + else: + normalized, _ = normalize_apostrophes(text, cfg) + + if runtime_settings.get("normalization_titles", True): + normalized = expand_titles_and_suffixes(normalized) + if runtime_settings.get("normalization_terminal", True): + normalized = ensure_terminal_punctuation(normalized) + if cfg.add_phoneme_hints: normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker) + return normalized # ---------- Example Usage ---------- diff --git a/abogen/llm_client.py b/abogen/llm_client.py new file mode 100644 index 0000000..1855a5d --- /dev/null +++ b/abogen/llm_client.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional +from urllib import error, parse, request + + +class LLMClientError(RuntimeError): + """Raised when an LLM request fails.""" + + +@dataclass(frozen=True) +class LLMConfiguration: + base_url: str + api_key: str + model: str + timeout: float = 30.0 + + def is_configured(self) -> bool: + return bool(self.base_url.strip() and self.model.strip()) + + +_DEFAULT_HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +def _normalized_base_url(base_url: str) -> str: + trimmed = (base_url or "").strip() + if not trimmed: + raise LLMClientError("LLM base URL is required") + if not trimmed.endswith("/"): + trimmed += "/" + return trimmed + + +def _build_url(base_url: str, path: str) -> str: + normalized = _normalized_base_url(base_url) + return parse.urljoin(normalized, path.lstrip("/")) + + +def _build_headers(api_key: str) -> Dict[str, str]: + headers = dict(_DEFAULT_HEADERS) + token = (api_key or "").strip() + if token and token.lower() != "ollama": + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _perform_request( + method: str, + url: str, + *, + headers: Optional[Mapping[str, str]] = None, + payload: Optional[Mapping[str, Any]] = None, + timeout: float = 30.0, +) -> Any: + data_bytes: Optional[bytes] = None + if payload is not None: + data_bytes = json.dumps(payload).encode("utf-8") + request_headers = dict(headers or {}) + req = request.Request(url, data=data_bytes, headers=request_headers, method=method.upper()) + try: + with request.urlopen(req, timeout=timeout) as response: + body = response.read() + except error.HTTPError as exc: # pragma: no cover - defensive network guard + message = exc.read().decode("utf-8", "ignore") if exc.fp else exc.reason + raise LLMClientError(f"LLM request failed ({exc.code}): {message}") from exc + except error.URLError as exc: # pragma: no cover - defensive network guard + raise LLMClientError(f"LLM request failed: {exc.reason}") from exc + except Exception as exc: # pragma: no cover - defensive network guard + raise LLMClientError("LLM request failed") from exc + + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError as exc: + raise LLMClientError("LLM response was not valid JSON") from exc + + +def list_models(configuration: LLMConfiguration) -> List[Dict[str, str]]: + if not configuration.is_configured() and not configuration.base_url.strip(): + raise LLMClientError("LLM configuration is incomplete") + url = _build_url(configuration.base_url, "v1/models") + headers = _build_headers(configuration.api_key) + payload = _perform_request("GET", url, headers=headers, timeout=configuration.timeout) + if not isinstance(payload, Mapping): + raise LLMClientError("Unexpected response when listing models") + data = payload.get("data") + if not isinstance(data, list): + return [] + models: List[Dict[str, str]] = [] + for entry in data: + if not isinstance(entry, Mapping): + continue + identifier = str(entry.get("id") or "").strip() + if not identifier: + continue + description = str(entry.get("name") or entry.get("description") or identifier) + models.append({"id": identifier, "label": description}) + return models + + +def generate_completion( + configuration: LLMConfiguration, + *, + system_message: str, + user_message: str, + temperature: float = 0.2, + max_tokens: Optional[int] = None, +) -> str: + if not configuration.is_configured(): + raise LLMClientError("LLM configuration is incomplete") + + url = _build_url(configuration.base_url, "v1/chat/completions") + headers = _build_headers(configuration.api_key) + payload: Dict[str, Any] = { + "model": configuration.model, + "messages": [ + {"role": "system", "content": system_message}, + {"role": "user", "content": user_message}, + ], + "temperature": temperature, + } + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + response = _perform_request("POST", url, headers=headers, payload=payload, timeout=configuration.timeout) + if not isinstance(response, Mapping): + raise LLMClientError("Unexpected response from LLM") + choices = response.get("choices") + if not isinstance(choices, list) or not choices: + raise LLMClientError("LLM response did not include choices") + first = choices[0] + if not isinstance(first, Mapping): + raise LLMClientError("LLM response choice was invalid") + message = first.get("message") + if isinstance(message, Mapping): + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + text = first.get("text") + if isinstance(text, str) and text.strip(): + return text.strip() + raise LLMClientError("LLM response did not include text content") diff --git a/abogen/normalization_settings.py b/abogen/normalization_settings.py new file mode 100644 index 0000000..a73316f --- /dev/null +++ b/abogen/normalization_settings.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import os +from dataclasses import replace +from functools import lru_cache +from typing import Any, Dict, Mapping, Optional + +from abogen.kokoro_text_normalization import ApostropheConfig +from abogen.llm_client import LLMConfiguration +from abogen.utils import load_config + +DEFAULT_LLM_PROMPT = ( + "You are assisting with audiobook preparation. Rewrite the provided sentence so apostrophes and " + "contractions are unambiguous for text-to-speech. Respond with only the rewritten sentence.\n" + "Sentence: {{ sentence }}\n" + "Context: {{ paragraph }}" +) + +_SETTINGS_DEFAULTS: Dict[str, Any] = { + "llm_base_url": "", + "llm_api_key": "", + "llm_model": "", + "llm_timeout": 30.0, + "llm_prompt": DEFAULT_LLM_PROMPT, + "llm_context_mode": "sentence", + "normalization_numbers": True, + "normalization_titles": True, + "normalization_terminal": True, + "normalization_phoneme_hints": True, + "normalization_apostrophe_mode": "spacy", +} + +_ENVIRONMENT_KEYS: Dict[str, str] = { + "llm_base_url": "ABOGEN_LLM_BASE_URL", + "llm_api_key": "ABOGEN_LLM_API_KEY", + "llm_model": "ABOGEN_LLM_MODEL", + "llm_timeout": "ABOGEN_LLM_TIMEOUT", + "llm_prompt": "ABOGEN_LLM_PROMPT", + "llm_context_mode": "ABOGEN_LLM_CONTEXT_MODE", +} + +NORMALIZATION_SAMPLE_TEXTS: Dict[str, str] = { + "apostrophes": "I've heard the captain'll arrive by dusk, but they'd said the same yesterday.", + "numbers": "The ledger listed 1,204 outstanding debts totaling $57,890.", + "titles": "Dr. Smith met Mr. O'Leary outside St. John's Church on Jan. 4th.", + "punctuation": "Meet me at the docks tonight We'll decide then", # missing punctuation +} + + +@lru_cache(maxsize=1) +def _environment_defaults() -> Dict[str, Any]: + overrides: Dict[str, Any] = {} + for key, env_var in _ENVIRONMENT_KEYS.items(): + default = _SETTINGS_DEFAULTS.get(key) + if default is None: + continue + value = os.environ.get(env_var) + if value is None or value == "": + continue + if isinstance(default, bool): + overrides[key] = _coerce_bool(value, default) + elif isinstance(default, float): + overrides[key] = _coerce_float(value, float(default)) + else: + overrides[key] = value + return overrides + + +def environment_llm_defaults() -> Dict[str, Any]: + return dict(_environment_defaults()) + + +def _coerce_bool(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def _coerce_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _extract_settings(source: Mapping[str, Any]) -> Dict[str, Any]: + env_defaults = _environment_defaults() + extracted: Dict[str, Any] = {} + for key, default in _SETTINGS_DEFAULTS.items(): + if key in source: + raw_value = source.get(key) + elif key in env_defaults: + raw_value = env_defaults[key] + else: + raw_value = default + if isinstance(default, bool): + extracted[key] = _coerce_bool(raw_value, default) + elif isinstance(default, float): + extracted[key] = _coerce_float(raw_value, default) + else: + extracted[key] = str(raw_value or "") if isinstance(default, str) else raw_value + return extracted + + +@lru_cache(maxsize=1) +def _cached_settings() -> Dict[str, Any]: + config = load_config() or {} + return _extract_settings(config) + + +def get_runtime_settings() -> Dict[str, Any]: + return dict(_cached_settings()) + + +def clear_cached_settings() -> None: + _cached_settings.cache_clear() + + +def build_apostrophe_config( + *, + settings: Mapping[str, Any], + base: Optional[ApostropheConfig] = None, +) -> ApostropheConfig: + config = replace(base or ApostropheConfig()) + config.convert_numbers = bool(settings.get("normalization_numbers", True)) + config.add_phoneme_hints = bool(settings.get("normalization_phoneme_hints", True)) + return config + + +def build_llm_configuration(settings: Mapping[str, Any]) -> LLMConfiguration: + return LLMConfiguration( + base_url=str(settings.get("llm_base_url") or ""), + api_key=str(settings.get("llm_api_key") or ""), + model=str(settings.get("llm_model") or ""), + timeout=_coerce_float(settings.get("llm_timeout"), float(_SETTINGS_DEFAULTS["llm_timeout"])), + ) + + +def apply_overrides(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> Dict[str, Any]: + merged: Dict[str, Any] = dict(base) + for key, value in overrides.items(): + if key not in _SETTINGS_DEFAULTS: + continue + merged[key] = value + return merged diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 3d21459..aff0119 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -21,6 +21,11 @@ import static_ffmpeg from abogen.constants import VOICES_INTERNAL from abogen.epub3.exporter import build_epub3_package from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline +from abogen.normalization_settings import ( + build_apostrophe_config, + build_llm_configuration, + get_runtime_settings, +) from abogen.entity_analysis import normalize_token as normalize_entity_token from abogen.text_extractor import ExtractedChapter, extract_from_path from abogen.utils import ( @@ -34,6 +39,8 @@ from abogen.utils import ( ) from abogen.voice_cache import ensure_voice_assets from abogen.voice_formulas import extract_voice_ids, get_new_voice +from abogen.pronunciation_store import increment_usage +from abogen.llm_client import LLMClientError from .service import Job, JobStatus @@ -460,17 +467,13 @@ def _merge_metadata( _APOSTROPHE_CONFIG = ApostropheConfig() -def _normalize_for_pipeline(text: str) -> str: - return normalize_for_pipeline(text, config=_APOSTROPHE_CONFIG) - - def _compile_pronunciation_rules( overrides: Optional[Iterable[Mapping[str, Any]]], -) -> List[tuple[re.Pattern[str], str]]: +) -> List[Dict[str, Any]]: if not overrides: return [] - candidates: List[tuple[str, str]] = [] + candidates: List[Dict[str, Any]] = [] seen: set[str] = set() for entry in overrides: @@ -499,34 +502,64 @@ def _compile_pronunciation_rules( if not token_values: continue + usage_normalized = str(entry.get("normalized") or "").strip() + if not usage_normalized and token_values: + usage_normalized = normalize_entity_token(token_values[0]) or token_values[0] + usage_token = str(entry.get("token") or token_values[0]) + for token_value in token_values: key = token_value.casefold() if key in seen: continue seen.add(key) - candidates.append((token_value, pronunciation_value)) + candidates.append( + { + "token": token_value, + "normalized": usage_normalized, + "replacement": pronunciation_value, + } + ) if not candidates: return [] - candidates.sort(key=lambda item: len(item[0]), reverse=True) - compiled: List[tuple[re.Pattern[str], str]] = [] - for token_value, pronunciation_value in candidates: + candidates.sort(key=lambda item: len(item["token"]), reverse=True) + compiled: List[Dict[str, Any]] = [] + for candidate in candidates: + token_value = candidate["token"] + pronunciation_value = candidate["replacement"] escaped = re.escape(token_value) pattern = re.compile(rf"(?i)(?'s|\u2019s|\u2019)?(?!\w)") - compiled.append((pattern, pronunciation_value)) + compiled.append( + { + "pattern": pattern, + "replacement": pronunciation_value, + "normalized": candidate.get("normalized") or token_value, + "token": candidate.get("token") or token_value, + } + ) return compiled -def _apply_pronunciation_rules(text: str, rules: List[tuple[re.Pattern[str], str]]) -> str: +def _apply_pronunciation_rules( + text: str, + rules: List[Dict[str, Any]], + usage_counter: Optional[Dict[str, int]] = None, +) -> str: if not text or not rules: return text result = text - for pattern, pronunciation_value in rules: + for rule in rules: + pattern = rule["pattern"] + pronunciation_value = rule["replacement"] + usage_key = str(rule.get("normalized") or "").strip() + def _replacement(match: re.Match[str]) -> str: suffix = match.group("possessive") or "" + if usage_counter is not None and usage_key: + usage_counter[usage_key] = usage_counter.get(usage_key, 0) + 1 return pronunciation_value + suffix result = pattern.sub(_replacement, result) @@ -604,6 +637,25 @@ def _group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List return grouped +def _record_override_usage( + job: Job, + usage_counter: Mapping[str, int], + token_map: Mapping[str, str], +) -> None: + if not usage_counter: + return + + language = getattr(job, "language", "") or "a" + for normalized, amount in usage_counter.items(): + if amount <= 0: + continue + token_value = token_map.get(normalized, normalized) + try: + increment_usage(language=language, token=token_value, amount=int(amount)) + except Exception: # pragma: no cover - defensive logging + job.add_log(f"Failed to record usage for override {token_value}", level="warning") + + def _safe_int(value: Any, default: int = 0) -> int: try: return int(value) @@ -846,6 +898,19 @@ def run_conversion_job(job: Job) -> None: job.add_log("Preparing conversion pipeline") canceller = _make_canceller(job) + normalization_settings = get_runtime_settings() + apostrophe_config = build_apostrophe_config( + settings=normalization_settings, + base=_APOSTROPHE_CONFIG, + ) + apostrophe_mode = str(normalization_settings.get("normalization_apostrophe_mode", "spacy")).lower() + if apostrophe_mode == "llm": + llm_config = build_llm_configuration(normalization_settings) + if not llm_config.is_configured(): + raise RuntimeError( + "LLM-based apostrophe normalization is selected, but the LLM configuration is incomplete." + ) + sink_stack = ExitStack() subtitle_writer: Optional[SubtitleWriter] = None chapter_paths: list[Path] = [] @@ -857,6 +922,8 @@ def run_conversion_job(job: Job) -> None: pipeline: Any = None chunk_groups: Dict[int, List[Dict[str, Any]]] = {} active_chapter_configs: List[Dict[str, Any]] = [] + usage_counter: Dict[str, int] = defaultdict(int) + override_token_map: Dict[str, str] = {} try: pipeline = _load_pipeline(job) _initialize_voice_cache(job) @@ -869,6 +936,15 @@ def run_conversion_job(job: Job) -> None: f"Applying {count} pronunciation override{'s' if count != 1 else ''} during conversion.", level="debug", ) + for override_entry in job.pronunciation_overrides or []: + if not isinstance(override_entry, Mapping): + continue + raw_token = str(override_entry.get("token") or "").strip() + normalized_value = str(override_entry.get("normalized") or "").strip() + if not normalized_value and raw_token: + normalized_value = normalize_entity_token(raw_token) or raw_token + if normalized_value: + override_token_map.setdefault(normalized_value, raw_token or normalized_value) if not job.chapters: filtered, skipped_info = _auto_select_relevant_chapters(extraction.chapters, file_type) @@ -986,8 +1062,20 @@ def run_conversion_job(job: Job) -> None: nonlocal processed_chars, subtitle_index, current_time source_text = str(text or "") if pronunciation_rules: - source_text = _apply_pronunciation_rules(source_text, pronunciation_rules) - normalized = _normalize_for_pipeline(source_text) + source_text = _apply_pronunciation_rules( + source_text, + pronunciation_rules, + usage_counter, + ) + try: + normalized = normalize_for_pipeline( + source_text, + config=apostrophe_config, + settings=normalization_settings, + ) + except LLMClientError as exc: + job.add_log(f"LLM normalization failed: {exc}", level="error") + raise local_segments = 0 for segment in pipeline( @@ -1278,6 +1366,9 @@ def run_conversion_job(job: Job) -> None: "generate_epub3": job.generate_epub3, } + if usage_counter: + _record_override_usage(job, usage_counter, override_token_map) + if metadata_dir: metadata_dir.mkdir(parents=True, exist_ok=True) metadata_file = metadata_dir / "metadata.json" diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 6e1349e..9acb254 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import io import json import math @@ -46,6 +47,17 @@ from abogen.constants import ( VOICES_INTERNAL, ) from abogen.kokoro_text_normalization import normalize_for_pipeline, normalize_roman_numeral_titles +from abogen.normalization_settings import ( + DEFAULT_LLM_PROMPT, + NORMALIZATION_SAMPLE_TEXTS, + apply_overrides as apply_normalization_overrides, + build_apostrophe_config, + build_llm_configuration, + clear_cached_settings, + environment_llm_defaults, + get_runtime_settings, +) +from abogen.llm_client import LLMClientError, LLMConfiguration, generate_completion, list_models from abogen.utils import ( calculate_text_length, get_user_output_path, @@ -1853,6 +1865,17 @@ SAVE_MODE_LABELS = { LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()} +_APOSTROPHE_MODE_OPTIONS = [ + {"value": "off", "label": "Off"}, + {"value": "spacy", "label": "spaCy (built-in)"}, + {"value": "llm", "label": "LLM assisted"}, +] + +_LLM_CONTEXT_OPTIONS = [ + {"value": "sentence", "label": "Sentence only"}, + {"value": "paragraph", "label": "Sentence with paragraph context"}, +] + BOOLEAN_SETTINGS = { "replace_single_newlines", "use_gpu", @@ -1862,9 +1885,13 @@ BOOLEAN_SETTINGS = { "generate_epub3", "enable_entity_recognition", "auto_prefix_chapter_titles", + "normalization_numbers", + "normalization_titles", + "normalization_terminal", + "normalization_phoneme_hints", } -FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"} +FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"} INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"} @@ -1873,6 +1900,7 @@ def _has_output_override() -> bool: def _settings_defaults() -> Dict[str, Any]: + llm_env_defaults = environment_llm_defaults() return { "output_format": "wav", "subtitle_format": "srt", @@ -1894,9 +1922,39 @@ def _settings_defaults() -> Dict[str, Any]: "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_titles": True, + "normalization_terminal": True, + "normalization_phoneme_hints": True, + "normalization_apostrophe_mode": "spacy", } +def _llm_ready(settings: Mapping[str, Any]) -> bool: + base_url = str(settings.get("llm_base_url") or "").strip() + return bool(base_url) + + +_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") + + +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) + + def _coerce_bool(value: Any, default: bool) -> bool: if isinstance(value, bool): return value @@ -1959,6 +2017,23 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> 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 == "llm_context_mode": + if isinstance(value, str): + normalized_scope = value.strip().lower() + if normalized_scope in {"sentence", "paragraph"}: + 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] @@ -2100,6 +2175,68 @@ def _get_preview_pipeline(language: str, device: str): return pipeline +def _synthesize_audio_from_normalized( + *, + normalized_text: str, + voice_spec: str, + language: str, + speed: float, + use_gpu: bool, + max_seconds: float, +) -> np.ndarray: + if not normalized_text.strip(): + raise ValueError("Preview text is required") + + device = "cpu" + if use_gpu: + try: + device = _select_device() + except Exception: + device = "cpu" + use_gpu = False + + pipeline = _get_preview_pipeline(language, device) + if pipeline is None: + raise RuntimeError("Preview pipeline is unavailable") + + voice_choice: Any = voice_spec + if voice_spec and "*" in voice_spec: + voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) + + segments = pipeline( + normalized_text, + voice=voice_choice, + speed=speed, + split_pattern=SPLIT_PATTERN, + ) + + audio_chunks: List[np.ndarray] = [] + accumulated = 0 + max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE) + + for segment in segments: + graphemes = getattr(segment, "graphemes", "").strip() + if not graphemes: + continue + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size == 0: + continue + remaining = max_samples - accumulated + if remaining <= 0: + break + if audio.shape[0] > remaining: + audio = audio[:remaining] + audio_chunks.append(audio) + accumulated += audio.shape[0] + if accumulated >= max_samples: + break + + if not audio_chunks: + raise RuntimeError("Preview could not be generated") + + return np.concatenate(audio_chunks) + + @web_bp.app_template_filter("datetimeformat") def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: if not value: @@ -2192,9 +2329,28 @@ def settings_page() -> ResponseReturnValue: ] updated["speaker_random_languages"] = random_languages + updated["llm_base_url"] = _normalize_setting_value( + "llm_base_url", form.get("llm_base_url"), defaults + ) + updated["llm_api_key"] = _normalize_setting_value( + "llm_api_key", form.get("llm_api_key"), defaults + ) + updated["llm_model"] = _normalize_setting_value("llm_model", form.get("llm_model"), defaults) + updated["llm_prompt"] = _normalize_setting_value("llm_prompt", form.get("llm_prompt"), defaults) + updated["llm_context_mode"] = _normalize_setting_value( + "llm_context_mode", form.get("llm_context_mode"), defaults + ) + updated["llm_timeout"] = _normalize_setting_value("llm_timeout", form.get("llm_timeout"), defaults) + updated["normalization_apostrophe_mode"] = _normalize_setting_value( + "normalization_apostrophe_mode", + form.get("normalization_apostrophe_mode"), + defaults, + ) + cfg = load_config() or {} cfg.update(updated) save_config(cfg) + clear_cached_settings() return redirect(url_for("web.settings_page", saved="1")) save_locations = [ @@ -2206,10 +2362,174 @@ def settings_page() -> ResponseReturnValue: "save_locations": save_locations, "default_output_dir": get_user_output_path(), "saved": request.args.get("saved") == "1", + "apostrophe_modes": _APOSTROPHE_MODE_OPTIONS, + "llm_context_options": _LLM_CONTEXT_OPTIONS, + "llm_ready": _llm_ready(current_settings), + "normalization_samples": NORMALIZATION_SAMPLE_TEXTS, } return render_template("settings.html", **context) +@api_bp.post("/llm/models") +def api_llm_models() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + current_settings = get_runtime_settings() + + base_url = str(payload.get("base_url") or payload.get("llm_base_url") or current_settings.get("llm_base_url") or "").strip() + if not base_url: + return jsonify({"error": "LLM base URL is required."}), 400 + + api_key = str(payload.get("api_key") or payload.get("llm_api_key") or current_settings.get("llm_api_key") or "") + timeout = _coerce_float(payload.get("timeout"), current_settings.get("llm_timeout", 30.0)) + + overrides = { + "llm_base_url": base_url, + "llm_api_key": api_key, + "llm_timeout": timeout, + } + + merged = apply_normalization_overrides(current_settings, overrides) + configuration = build_llm_configuration(merged) + try: + models = list_models(configuration) + except LLMClientError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"models": models}) + + +@api_bp.post("/llm/preview") +def api_llm_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + sample_text = str(payload.get("text") or "").strip() + if not sample_text: + return jsonify({"error": "Text is required."}), 400 + + base_settings = get_runtime_settings() + overrides: Dict[str, Any] = { + "llm_base_url": str( + payload.get("base_url") + or payload.get("llm_base_url") + or base_settings.get("llm_base_url") + or "" + ).strip(), + "llm_api_key": str( + payload.get("api_key") + or payload.get("llm_api_key") + or base_settings.get("llm_api_key") + or "" + ), + "llm_model": str( + payload.get("model") + or payload.get("llm_model") + or base_settings.get("llm_model") + or "" + ), + "llm_prompt": payload.get("prompt") or payload.get("llm_prompt") or base_settings.get("llm_prompt"), + "llm_context_mode": payload.get("context_mode") or base_settings.get("llm_context_mode"), + "llm_timeout": _coerce_float(payload.get("timeout"), base_settings.get("llm_timeout", 30.0)), + "normalization_apostrophe_mode": "llm", + } + + merged = apply_normalization_overrides(base_settings, overrides) + if not merged.get("llm_base_url"): + return jsonify({"error": "LLM base URL is required."}), 400 + if not merged.get("llm_model"): + return jsonify({"error": "Select an LLM model before previewing."}), 400 + + apostrophe_config = build_apostrophe_config(settings=merged) + try: + normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged) + except LLMClientError as exc: + return jsonify({"error": str(exc)}), 400 + + context = { + "text": sample_text, + "normalized_text": normalized_text, + } + return jsonify(context) + + +@api_bp.post("/normalization/preview") +def api_normalization_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + sample_text = str(payload.get("text") or "").strip() + if not sample_text: + return jsonify({"error": "Sample text is required."}), 400 + + base_settings = get_runtime_settings() + normalization_payload = payload.get("normalization") or {} + overrides: Dict[str, Any] = {} + + boolean_keys = ( + "normalization_numbers", + "normalization_titles", + "normalization_terminal", + "normalization_phoneme_hints", + ) + for key in boolean_keys: + if key in normalization_payload: + overrides[key] = _coerce_bool(normalization_payload.get(key), base_settings.get(key, True)) + if "normalization_apostrophe_mode" in normalization_payload: + overrides["normalization_apostrophe_mode"] = normalization_payload.get("normalization_apostrophe_mode") + + llm_payload = payload.get("llm") or {} + for field in ("llm_base_url", "llm_api_key", "llm_model", "llm_prompt", "llm_context_mode"): + if field in llm_payload: + overrides[field] = llm_payload[field] + if "llm_timeout" in llm_payload: + overrides["llm_timeout"] = llm_payload.get("llm_timeout") + + merged = apply_normalization_overrides(base_settings, overrides) + + apostrophe_config = build_apostrophe_config(settings=merged) + try: + normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged) + except LLMClientError as exc: + return jsonify({"error": str(exc)}), 400 + + voice_spec = str(payload.get("voice") or base_settings.get("default_voice") or "").strip() + if not voice_spec and VOICES_INTERNAL: + voice_spec = VOICES_INTERNAL[0] + language = str(payload.get("language") or base_settings.get("language") or "a").strip() or "a" + try: + speed = float(payload.get("speed", 1.0) or 1.0) + except (TypeError, ValueError): + speed = 1.0 + try: + max_seconds = max(1.0, min(15.0, float(payload.get("max_seconds", 8.0) or 8.0))) + except (TypeError, ValueError): + max_seconds = 8.0 + + use_gpu_default = base_settings.get("use_gpu", True) + use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default) + + try: + audio_data = _synthesize_audio_from_normalized( + normalized_text=normalized_text, + voice_spec=voice_spec, + language=language, + speed=speed, + use_gpu=use_gpu, + max_seconds=max_seconds, + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 500 + + buffer = io.BytesIO() + sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV") + audio_base64 = base64.b64encode(buffer.getvalue()).decode("ascii") + + return jsonify( + { + "normalized_text": normalized_text, + "audio_base64": audio_base64, + "sample_rate": SAMPLE_RATE, + } + ) + + @web_bp.get("/voices") def voice_profiles_page() -> str: options = _template_options() @@ -2367,6 +2687,69 @@ def entities_override_update() -> ResponseReturnValue: return redirect(url_for("web.entities_page", **redirect_params)) +@api_bp.post("/entities/preview") +def api_entity_pronunciation_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + token = str(payload.get("token") or "").strip() + pronunciation = str(payload.get("pronunciation") or "").strip() + if not token and not pronunciation: + return jsonify({"error": "Provide a token or pronunciation to preview."}), 400 + + settings = _load_settings() + sample_template = settings.get("speaker_pronunciation_sentence", "This is {{name}} speaking.") + spoken_label = pronunciation or token or "" + preview_text = _render_prompt_template(sample_template, {"name": spoken_label, "token": token}) + if not preview_text.strip(): + preview_text = spoken_label or token + if not preview_text: + return jsonify({"error": "Unable to construct preview text."}), 400 + + runtime_settings = get_runtime_settings() + apostrophe_config = build_apostrophe_config(settings=runtime_settings) + try: + normalized_text = normalize_for_pipeline(preview_text, config=apostrophe_config, settings=runtime_settings) + except LLMClientError as exc: + return jsonify({"error": str(exc)}), 400 + + voice_spec = str(payload.get("voice") or settings.get("default_voice") or "").strip() + if not voice_spec and VOICES_INTERNAL: + voice_spec = VOICES_INTERNAL[0] + + language = str(payload.get("language") or runtime_settings.get("language") or "a").strip() or "a" + use_gpu = runtime_settings.get("use_gpu", True) + max_seconds = 6.0 + try: + preview_speed = float(payload.get("speed", 1.0) or 1.0) + except (TypeError, ValueError): + preview_speed = 1.0 + try: + audio_data = _synthesize_audio_from_normalized( + normalized_text=normalized_text, + voice_spec=voice_spec, + language=language, + speed=preview_speed, + use_gpu=use_gpu, + max_seconds=max_seconds, + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 500 + + buffer = io.BytesIO() + sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV") + audio_base64 = base64.b64encode(buffer.getvalue()).decode("ascii") + + return jsonify( + { + "text": preview_text, + "normalized_text": normalized_text, + "audio_base64": audio_base64, + "sample_rate": SAMPLE_RATE, + } + ) + + @web_bp.route("/speakers", methods=["GET", "POST"]) def speaker_configs_page() -> ResponseReturnValue: options = _template_options() diff --git a/abogen/web/static/settings.js b/abogen/web/static/settings.js new file mode 100644 index 0000000..826a94e --- /dev/null +++ b/abogen/web/static/settings.js @@ -0,0 +1,382 @@ +const form = document.querySelector('.settings__form'); +const navButtons = Array.from(document.querySelectorAll('.settings-nav__item')); +const panels = Array.from(document.querySelectorAll('.settings-panel')); +const llmNavButton = navButtons.find((button) => button.dataset.section === 'llm'); + +const statusSelectors = { + llm: document.querySelector('[data-role="llm-preview-status"]'), + normalization: document.querySelector('[data-role="normalization-preview-status"]'), +}; + +const outputAreas = { + llm: document.querySelector('[data-role="llm-preview-output"]'), + normalization: document.querySelector('[data-role="normalization-preview-output"]'), +}; + +const normalizationAudio = document.querySelector('[data-role="normalization-preview-audio"]'); + +function setStatus(target, message, state) { + if (!target) { + return; + } + target.textContent = message || ''; + if (state) { + target.dataset.state = state; + } else { + delete target.dataset.state; + } +} + +function clearStatus(target) { + setStatus(target, '', null); +} + +function activatePanel(section) { + if (!section) { + return; + } + navButtons.forEach((button) => { + const isActive = button.dataset.section === section; + button.classList.toggle('is-active', isActive); + }); + let activePanel = null; + panels.forEach((panel) => { + const isActive = panel.dataset.section === section; + panel.classList.toggle('is-active', isActive); + if (isActive) { + activePanel = panel; + } + }); + if (activePanel) { + const focusable = activePanel.querySelector('input, select, textarea'); + if (focusable) { + window.requestAnimationFrame(() => { + focusable.focus({ preventScroll: false }); + }); + } + } +} + +function initNavigation() { + if (!navButtons.length || !panels.length) { + return; + } + navButtons.forEach((button) => { + button.addEventListener('click', () => { + activatePanel(button.dataset.section); + if (button.dataset.section) { + window.history.replaceState(null, '', `#${button.dataset.section}`); + } + }); + }); + const hash = window.location.hash.replace('#', ''); + if (hash && panels.some((panel) => panel.dataset.section === hash)) { + activatePanel(hash); + } else { + const current = navButtons.find((button) => button.classList.contains('is-active')); + if (current) { + activatePanel(current.dataset.section); + } + } + window.addEventListener('hashchange', () => { + const section = window.location.hash.replace('#', ''); + if (section) { + activatePanel(section); + } + }); +} + +function parseNumber(value, fallback) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function collectLLMFields() { + const baseUrl = form.querySelector('#llm_base_url'); + const apiKey = form.querySelector('#llm_api_key'); + const model = form.querySelector('#llm_model'); + const prompt = form.querySelector('#llm_prompt'); + const timeout = form.querySelector('#llm_timeout'); + const context = form.querySelector('input[name="llm_context_mode"]:checked'); + return { + base_url: baseUrl ? baseUrl.value.trim() : '', + api_key: apiKey ? apiKey.value.trim() : '', + model: model ? model.value.trim() : '', + prompt: prompt ? prompt.value : '', + context_mode: context ? context.value : 'sentence', + timeout: timeout ? parseNumber(timeout.value, 30) : 30, + }; +} + +function updateModelOptions(models) { + const select = form.querySelector('#llm_model'); + if (!select) { + return; + } + const current = select.dataset.currentModel || select.value; + select.innerHTML = ''; + if (!Array.isArray(models) || !models.length) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No models found'; + select.appendChild(option); + select.dataset.currentModel = ''; + select.disabled = true; + return; + } + const fragment = document.createDocumentFragment(); + models.forEach((modelName) => { + const option = document.createElement('option'); + option.value = modelName; + option.textContent = modelName; + if (modelName === current) { + option.selected = true; + } + fragment.appendChild(option); + }); + select.appendChild(fragment); + select.dataset.currentModel = select.value || ''; + select.disabled = false; +} + +async function refreshModels(button) { + const status = statusSelectors.llm; + const llmFields = collectLLMFields(); + if (!llmFields.base_url) { + setStatus(status, 'Enter a base URL before refreshing models.', 'error'); + return; + } + clearStatus(status); + setStatus(status, 'Fetching models…'); + button.disabled = true; + try { + const response = await fetch('/api/llm/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + base_url: llmFields.base_url, + api_key: llmFields.api_key, + timeout: llmFields.timeout, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Unable to load models.'); + } + updateModelOptions(payload.models || []); + const count = Array.isArray(payload.models) ? payload.models.length : 0; + if (count) { + setStatus(status, `Loaded ${count} model${count === 1 ? '' : 's'}.`, 'success'); + } else { + setStatus(status, 'No models were returned.', 'error'); + } + } catch (error) { + setStatus(status, error instanceof Error ? error.message : 'Failed to load models.', 'error'); + } finally { + button.disabled = false; + } +} + +async function previewLLM(button) { + const status = statusSelectors.llm; + const output = outputAreas.llm; + const previewText = document.querySelector('#llm_preview_text'); + if (!previewText) { + return; + } + const llmFields = collectLLMFields(); + if (!llmFields.base_url) { + setStatus(status, 'Enter a base URL to preview.', 'error'); + return; + } + if (!llmFields.model) { + setStatus(status, 'Select a model to preview.', 'error'); + return; + } + const sample = previewText.value.trim(); + if (!sample) { + setStatus(status, 'Add some sample text first.', 'error'); + return; + } + clearStatus(status); + if (output) { + output.textContent = ''; + } + setStatus(status, 'Generating preview…'); + button.disabled = true; + try { + const response = await fetch('/api/llm/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: sample, + base_url: llmFields.base_url, + api_key: llmFields.api_key, + model: llmFields.model, + prompt: llmFields.prompt, + context_mode: llmFields.context_mode, + timeout: llmFields.timeout, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Preview failed.'); + } + if (output) { + output.textContent = payload.normalized_text || ''; + } + setStatus(status, 'Preview ready.', 'success'); + } catch (error) { + if (output) { + output.textContent = ''; + } + setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error'); + } finally { + button.disabled = false; + } +} + +function collectNormalizationSettings() { + const normalization = { + normalization_numbers: Boolean(form.querySelector('input[name="normalization_numbers"]')?.checked), + normalization_titles: Boolean(form.querySelector('input[name="normalization_titles"]')?.checked), + normalization_terminal: Boolean(form.querySelector('input[name="normalization_terminal"]')?.checked), + normalization_phoneme_hints: Boolean(form.querySelector('input[name="normalization_phoneme_hints"]')?.checked), + normalization_apostrophe_mode: form.querySelector('input[name="normalization_apostrophe_mode"]:checked')?.value || 'spacy', + }; + return normalization; +} + +function updateLLMNavState() { + if (!llmNavButton) { + return; + } + const fields = collectLLMFields(); + if (fields.base_url && fields.api_key) { + llmNavButton.classList.remove('is-disabled'); + } else { + llmNavButton.classList.add('is-disabled'); + } +} + +async function previewNormalization(button) { + const status = statusSelectors.normalization; + const output = outputAreas.normalization; + const textArea = document.querySelector('#normalization_sample_text'); + const voiceSelect = document.querySelector('#normalization_sample_voice'); + if (!textArea) { + return; + } + const sample = textArea.value.trim(); + if (!sample) { + setStatus(status, 'Enter some text to preview.', 'error'); + return; + } + clearStatus(status); + if (output) { + output.textContent = ''; + } + if (normalizationAudio) { + normalizationAudio.hidden = true; + normalizationAudio.removeAttribute('src'); + } + setStatus(status, 'Building preview…'); + button.disabled = true; + try { + const normalization = collectNormalizationSettings(); + const llmFields = collectLLMFields(); + const response = await fetch('/api/normalization/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: sample, + voice: voiceSelect ? voiceSelect.value : undefined, + normalization, + llm: { + llm_base_url: llmFields.base_url, + llm_api_key: llmFields.api_key, + llm_model: llmFields.model, + llm_prompt: llmFields.prompt, + llm_context_mode: llmFields.context_mode, + llm_timeout: llmFields.timeout, + }, + max_seconds: 8, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Preview failed.'); + } + if (output) { + output.textContent = payload.normalized_text || ''; + } + if (payload.audio_base64 && normalizationAudio) { + normalizationAudio.src = `data:audio/wav;base64,${payload.audio_base64}`; + normalizationAudio.hidden = false; + normalizationAudio.load(); + normalizationAudio.play().catch(() => { + /* autoplay can fail; ignore */ + }); + } + setStatus(status, 'Preview updated.', 'success'); + } catch (error) { + if (output) { + output.textContent = ''; + } + if (normalizationAudio) { + normalizationAudio.hidden = true; + normalizationAudio.removeAttribute('src'); + } + setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error'); + } finally { + button.disabled = false; + } +} + +function initSampleSelector() { + const select = document.querySelector('#normalization_sample_select'); + const textArea = document.querySelector('#normalization_sample_text'); + if (!select || !textArea) { + return; + } + select.addEventListener('change', () => { + const option = select.selectedOptions[0]; + if (option) { + textArea.value = option.value; + } + }); +} + +function initActions() { + const refreshButton = document.querySelector('[data-action="llm-refresh-models"]'); + if (refreshButton) { + refreshButton.addEventListener('click', () => refreshModels(refreshButton)); + } + const llmPreviewButton = document.querySelector('[data-action="llm-preview"]'); + if (llmPreviewButton) { + llmPreviewButton.addEventListener('click', () => previewLLM(llmPreviewButton)); + } + const normalizationButton = document.querySelector('[data-action="normalization-preview"]'); + if (normalizationButton) { + normalizationButton.addEventListener('click', () => previewNormalization(normalizationButton)); + } +} + +function initLLMStateWatchers() { + const baseUrlInput = form.querySelector('#llm_base_url'); + const apiKeyInput = form.querySelector('#llm_api_key'); + if (!baseUrlInput || !apiKeyInput) { + return; + } + const handler = () => updateLLMNavState(); + baseUrlInput.addEventListener('input', handler); + apiKeyInput.addEventListener('input', handler); + updateLLMNavState(); +} + +if (form) { + initNavigation(); + initSampleSelector(); + initActions(); + initLLMStateWatchers(); +} diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 2f6119e..77c5c1d 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -1496,11 +1496,74 @@ button.step-indicator__item:focus-visible { box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2); } + +.settings-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 2rem; + align-items: start; +} + +.settings-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; + position: sticky; + top: 100px; +} + +.settings-nav__item { + display: inline-flex; + justify-content: flex-start; + align-items: center; + padding: 0.65rem 0.9rem; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.55); + color: var(--muted); + font-size: 0.95rem; + cursor: pointer; + transition: border 0.2s ease, color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.settings-nav__item:hover, +.settings-nav__item:focus-visible { + border-color: rgba(56, 189, 248, 0.5); + color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1); +} + +.settings-nav__item.is-active { + background: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.28); + color: #fff; + box-shadow: 0 10px 20px rgba(56, 189, 248, 0.18); +} + +.settings-nav__item.is-disabled { + opacity: 0.6; + cursor: pointer; +} + .settings__form { display: grid; gap: 1.75rem; } +.settings-panels { + display: grid; +} + +.settings-panel { + display: none; + gap: 1.75rem; +} + +.settings-panel.is-active { + display: grid; + gap: 1.75rem; +} + .settings__section { border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 18px; @@ -1586,6 +1649,140 @@ button.step-indicator__item:focus-visible { box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); } +.choices { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.choices--inline { + display: inline-flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.radio-pill { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.radio-pill input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.radio-pill span { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.9rem; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.4); + color: var(--muted); + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.radio-pill:hover span { + border-color: rgba(56, 189, 248, 0.5); + color: var(--accent); +} + +.radio-pill input:checked + span { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.16); + color: #fff; + box-shadow: 0 8px 18px rgba(56, 189, 248, 0.18); +} + +.radio-pill input:focus-visible + span { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.field__group { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.preview-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 1rem 1.1rem; + background: rgba(15, 23, 42, 0.52); + display: grid; + gap: 0.75rem; +} + +.preview-card__actions { + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; +} + +.preview-card__status { + font-size: 0.85rem; + color: var(--muted); +} + +.preview-card__status[data-state="error"] { + color: var(--danger); +} + +.preview-card__status[data-state="success"] { + color: var(--success); +} + +.preview-card__output { + border-radius: 12px; + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(148, 163, 184, 0.2); + padding: 0.9rem 1rem; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.9rem; + line-height: 1.5; + color: var(--text); + min-height: 3.5rem; + white-space: pre-wrap; +} + +.preview-card__output:empty { + display: none; +} + +.preview-card__audio { + width: 100%; + margin-top: 0.25rem; +} + +.hint--warning { + color: var(--warning); +} + +@media (max-width: 860px) { + .settings-layout { + grid-template-columns: 1fr; + } + + .settings-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + gap: 0.65rem; + } + + .settings-nav__item { + flex: 1 1 calc(50% - 0.65rem); + justify-content: center; + text-align: center; + } +} + .prepare-summary { display: grid; grid-template-columns: minmax(0, 320px) minmax(0, 1fr); @@ -1828,9 +2025,10 @@ button.step-indicator__item:focus-visible { } .field--inline { - display: inline-flex; - flex-direction: column; - gap: 0.35rem; + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + align-items: flex-end; } .entity-summary__titles h2 { diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 46f56e6..431cfeb 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -11,153 +11,310 @@
Settings saved successfully.
{% endif %} -
-
- Narration Defaults -
- - -

Used whenever “Standard voice” is selected for a new job.

-
-
- - -
-
- - -

Speakers detected fewer times fall back to the narrator voice.

-
-
- - -

Include {{ '{{name}}' }} where the speaker name should be inserted.

-
-
- -

Disable if you prefer to skip entity extraction in the job wizard.

-
-
- - {% set selected_languages = settings.speaker_random_languages or [] %} - -

Limits random voice selection for speakers marked as random. Leave empty to allow any language.

-
-
+
+ + +
+
+
+ Narration Defaults +
+ + +

Used whenever “Standard voice” is selected for a new job.

+
+
+ + +
+
+ + +

Speakers detected fewer times fall back to the narrator voice.

+
+
+ + +

Include {{ '{{name}}' }} where the speaker name should be inserted.

+
+
+ +

Disable if you prefer to skip entity extraction in the job wizard.

+
+
+ + {% set selected_languages = settings.speaker_random_languages or [] %} + +

Limits random voice selection for speakers marked as random. Leave empty to allow any language.

+
+
+
-
- Audio & Delivery -
- - -
-
- - -

Default output: {{ default_output_dir }}

-
-
- - -
-
- - - -
-
- - -
-
- - -

Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

-
-
- -

Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.

-
-
+
+
+ Audio & Delivery +
+ + +
+
+ + +

Default output: {{ default_output_dir }}

+
+
+ + +
+
+ + + +
+
+ + +
+
+ + +

Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

+
+
+ +

Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.

+
+
+
-
- Subtitles & Text -
- - -
-
- - -
-
- - -
-
+
+
+ Subtitles & Text +
+ + +
+
+ + +
+
+ + +
+
+
-
- Performance -
- -
-
+
+
+ Performance +
+ +
+
+
-
- -
- +
+
+ Endpoint +
+ + +

Point to an OpenAI-compatible endpoint such as Ollama or a proxy.

+
+
+ + +

Leave blank or use ollama for local servers that do not require keys.

+
+
+
+ + +
+
+ + +
+ +
+
+
+ Normalization Prompt +
+ + +

Use placeholders like {{ '{{sentence}}' }} and {{ '{{paragraph}}' }} to inject content.

+
+
+ Context Mode +
+ {% for option in llm_context_options %} + + {% endfor %} +
+
+
+ + +
+ + +
+
+
+
+
+ +
+
+ Normalization Rules +
+ + + + +
+
+ Apostrophe strategy +
+ {% for option in apostrophe_modes %} + + {% endfor %} +
+ {% if settings.normalization_apostrophe_mode == 'llm' and not llm_ready %} +

Configure the LLM connection before using it for audiobook runs.

+ {% endif %} +
+
+
+ Sample & Preview +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+

+              
+            
+
+
+
+ +
+ +
+ +
{% endblock %} + +{% block scripts %} +{{ super() }} + +{% endblock %}