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.
This commit is contained in:
JB
2025-10-26 07:42:12 -07:00
parent 0a6d09445d
commit 6b5255a80d
11 changed files with 1848 additions and 176 deletions
+11
View File
@@ -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.
+17
View File
@@ -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/<id>` 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.
+4 -1
View File
@@ -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)
+142 -11
View File
@@ -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)
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 ----------
+148
View File
@@ -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")
+151
View File
@@ -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
+106 -15
View File
@@ -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)(?<!\w){escaped}(?P<possessive>'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"
+384 -1
View File
@@ -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()
+382
View File
@@ -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();
}
+201 -3
View File
@@ -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 {
+157
View File
@@ -11,7 +11,18 @@
<div class="alert alert--success">Settings saved successfully.</div>
{% endif %}
<div class="settings-layout">
<nav class="settings-nav" aria-label="Settings sections">
<button type="button" class="settings-nav__item is-active" data-section="narration">Narration</button>
<button type="button" class="settings-nav__item" data-section="audio">Audio &amp; Delivery</button>
<button type="button" class="settings-nav__item" data-section="subtitles">Subtitles &amp; Text</button>
<button type="button" class="settings-nav__item" data-section="performance">Performance</button>
<button type="button" class="settings-nav__item{% if not llm_ready %} is-disabled{% endif %}" data-section="llm">LLM</button>
<button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button>
</nav>
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
<div class="settings-panels">
<section class="settings-panel is-active" data-section="narration">
<fieldset class="settings__section">
<legend>Narration Defaults</legend>
<div class="field">
@@ -59,7 +70,9 @@
<p class="hint">Limits random voice selection for speakers marked as random. Leave empty to allow any language.</p>
</div>
</fieldset>
</section>
<section class="settings-panel" data-section="audio">
<fieldset class="settings__section">
<legend>Audio &amp; Delivery</legend>
<div class="field">
@@ -118,7 +131,9 @@
<p class="hint">Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.</p>
</div>
</fieldset>
</section>
<section class="settings-panel" data-section="subtitles">
<fieldset class="settings__section">
<legend>Subtitles &amp; Text</legend>
<div class="field">
@@ -144,7 +159,9 @@
</label>
</div>
</fieldset>
</section>
<section class="settings-panel" data-section="performance">
<fieldset class="settings__section">
<legend>Performance</legend>
<div class="field field--choices">
@@ -154,10 +171,150 @@
</label>
</div>
</fieldset>
</section>
<section class="settings-panel" data-section="llm">
<fieldset class="settings__section">
<legend>Endpoint</legend>
<div class="field">
<label for="llm_base_url">Base URL</label>
<input type="url" id="llm_base_url" name="llm_base_url" value="{{ settings.llm_base_url }}" placeholder="https://localhost:11434/v1">
<p class="hint">Point to an OpenAI-compatible endpoint such as Ollama or a proxy.</p>
</div>
<div class="field">
<label for="llm_api_key">API Key</label>
<input type="text" id="llm_api_key" name="llm_api_key" value="{{ settings.llm_api_key }}" autocomplete="off" placeholder="ollama">
<p class="hint">Leave blank or use <code>ollama</code> for local servers that do not require keys.</p>
</div>
<div class="field field--inline">
<div class="field__group">
<label for="llm_model">Default Model</label>
<select id="llm_model" name="llm_model" data-current-model="{{ settings.llm_model }}">
{% if settings.llm_model %}
<option value="{{ settings.llm_model }}" selected>{{ settings.llm_model }}</option>
{% else %}
<option value="" selected disabled>Select a model</option>
{% endif %}
</select>
</div>
<div class="field__group">
<label for="llm_timeout">Timeout (seconds)</label>
<input type="number" step="1" min="1" id="llm_timeout" name="llm_timeout" value="{{ settings.llm_timeout }}">
</div>
<button type="button" class="button button--ghost" data-action="llm-refresh-models">Refresh models</button>
</div>
</fieldset>
<fieldset class="settings__section">
<legend>Normalization Prompt</legend>
<div class="field">
<label for="llm_prompt">Prompt Template</label>
<textarea id="llm_prompt" name="llm_prompt" rows="6">{{ settings.llm_prompt }}</textarea>
<p class="hint">Use placeholders like <code>{{ '{{sentence}}' }}</code> and <code>{{ '{{paragraph}}' }}</code> to inject content.</p>
</div>
<div class="field field--choices">
<span class="field__label">Context Mode</span>
<div class="choices choices--inline">
{% for option in llm_context_options %}
<label class="radio-pill">
<input type="radio" name="llm_context_mode" value="{{ option.value }}" {% if settings.llm_context_mode == option.value %}checked{% endif %}>
<span>{{ option.label }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="preview-card" data-preview="llm">
<label for="llm_preview_text">Try the prompt</label>
<textarea id="llm_preview_text" rows="3">I've been waiting all day.</textarea>
<div class="preview-card__actions">
<button type="button" class="button" data-action="llm-preview">Preview</button>
<span class="preview-card__status" data-role="llm-preview-status"></span>
</div>
<div class="preview-card__output" data-role="llm-preview-output"></div>
</div>
</fieldset>
</section>
<section class="settings-panel" data-section="normalization">
<fieldset class="settings__section">
<legend>Normalization Rules</legend>
<div class="field field--choices">
<label class="toggle-pill">
<input type="checkbox" name="normalization_numbers" value="true" {% if settings.normalization_numbers %}checked{% endif %}>
<span>Convert grouped numbers to words</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="normalization_titles" value="true" {% if settings.normalization_titles %}checked{% endif %}>
<span>Expand titles and suffixes (Dr., St., Jr., …)</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="normalization_terminal" value="true" {% if settings.normalization_terminal %}checked{% endif %}>
<span>Ensure sentences end with terminal punctuation</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="normalization_phoneme_hints" value="true" {% if settings.normalization_phoneme_hints %}checked{% endif %}>
<span>Add phoneme hints for possessives</span>
</label>
</div>
<div class="field">
<span class="field__label">Apostrophe strategy</span>
<div class="choices choices--inline">
{% for option in apostrophe_modes %}
<label class="radio-pill">
<input type="radio" name="normalization_apostrophe_mode" value="{{ option.value }}" {% if settings.normalization_apostrophe_mode == option.value %}checked{% endif %}>
<span>{{ option.label }}</span>
</label>
{% endfor %}
</div>
{% if settings.normalization_apostrophe_mode == 'llm' and not llm_ready %}
<p class="hint hint--warning">Configure the LLM connection before using it for audiobook runs.</p>
{% endif %}
</div>
</fieldset>
<fieldset class="settings__section">
<legend>Sample &amp; Preview</legend>
<div class="field field--inline">
<div class="field__group">
<label for="normalization_sample_select">Sample</label>
<select id="normalization_sample_select">
{% for key, text in normalization_samples.items() %}
<option value="{{ text }}" {% if loop.first %}selected{% endif %}>{{ key|capitalize }}</option>
{% endfor %}
</select>
</div>
<div class="field__group">
<label for="normalization_sample_voice">Voice</label>
<select id="normalization_sample_voice">
{% for voice in options.voices %}
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label for="normalization_sample_text">Sample text</label>
<textarea id="normalization_sample_text" rows="4">{{ normalization_samples['apostrophes'] }}</textarea>
</div>
<div class="preview-card" data-preview="normalization">
<div class="preview-card__actions">
<button type="button" class="button" data-action="normalization-preview">Preview with current settings</button>
<span class="preview-card__status" data-role="normalization-preview-status"></span>
</div>
<pre class="preview-card__output" data-role="normalization-preview-output"></pre>
<audio controls class="preview-card__audio" data-role="normalization-preview-audio" hidden></audio>
</div>
</fieldset>
</section>
</div>
<div class="settings__actions">
<button type="submit" class="button">Save Settings</button>
</div>
</form>
</div>
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='settings.js') }}"></script>
{% endblock %}