mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & Delivery</button>
|
||||
<button type="button" class="settings-nav__item" data-section="subtitles">Subtitles & 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 & 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 & 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 & 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 %}
|
||||
|
||||
Reference in New Issue
Block a user