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
|
# id -g # GID
|
||||||
ABOGEN_UID=1000
|
ABOGEN_UID=1000
|
||||||
ABOGEN_GID=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
|
- 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)
|
- 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
|
- 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
|
- Runs well in Docker, ships a REST-style JSON API, and works across macOS, Linux, and Windows
|
||||||
|
|
||||||
## Quick start
|
## 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_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_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_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.
|
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.
|
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
|
## JSON endpoints
|
||||||
Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse:
|
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.
|
- `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_SETTINGS_DIR` – change where Abogen stores its JSON settings/configuration files.
|
||||||
- `ABOGEN_TEMP_DIR` – change where temporary uploads and cache files are stored.
|
- `ABOGEN_TEMP_DIR` – change where temporary uploads and cache files are stored.
|
||||||
- `ABOGEN_OUTPUT_DIR` – change where rendered audio/subtitles are written.
|
- `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.
|
If unset, Abogen picks sensible defaults suitable for local usage.
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -7,6 +7,7 @@ from typing import Pattern
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
|
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"]
|
ChunkLevel = Literal["paragraph", "sentence"]
|
||||||
|
|
||||||
@@ -78,7 +79,9 @@ def _normalize_whitespace(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_chunk_text(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)
|
return _normalize_whitespace(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from dataclasses import dataclass
|
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
|
try: # pragma: no cover - optional dependency guard
|
||||||
from num2words import num2words
|
from num2words import num2words
|
||||||
except Exception: # pragma: no cover - graceful degradation
|
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()
|
DEFAULT_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||||
|
|
||||||
|
|
||||||
def normalize_for_pipeline(text: str, *, config: Optional[ApostropheConfig] = None) -> str:
|
_MUSTACHE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
||||||
"""Normalize text for the synthesis pipeline.
|
_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
|
def _render_mustache(template: str, context: Mapping[str, str]) -> str:
|
||||||
normalized, _details = normalize_apostrophes(text, cfg)
|
if not template:
|
||||||
normalized = expand_titles_and_suffixes(normalized)
|
return ""
|
||||||
normalized = ensure_terminal_punctuation(normalized)
|
|
||||||
|
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 = _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:
|
if cfg.add_phoneme_hints:
|
||||||
normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker)
|
normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
# ---------- Example Usage ----------
|
# ---------- 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.constants import VOICES_INTERNAL
|
||||||
from abogen.epub3.exporter import build_epub3_package
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
|
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.entity_analysis import normalize_token as normalize_entity_token
|
||||||
from abogen.text_extractor import ExtractedChapter, extract_from_path
|
from abogen.text_extractor import ExtractedChapter, extract_from_path
|
||||||
from abogen.utils import (
|
from abogen.utils import (
|
||||||
@@ -34,6 +39,8 @@ from abogen.utils import (
|
|||||||
)
|
)
|
||||||
from abogen.voice_cache import ensure_voice_assets
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
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
|
from .service import Job, JobStatus
|
||||||
|
|
||||||
@@ -460,17 +467,13 @@ def _merge_metadata(
|
|||||||
_APOSTROPHE_CONFIG = ApostropheConfig()
|
_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_pipeline(text: str) -> str:
|
|
||||||
return normalize_for_pipeline(text, config=_APOSTROPHE_CONFIG)
|
|
||||||
|
|
||||||
|
|
||||||
def _compile_pronunciation_rules(
|
def _compile_pronunciation_rules(
|
||||||
overrides: Optional[Iterable[Mapping[str, Any]]],
|
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||||
) -> List[tuple[re.Pattern[str], str]]:
|
) -> List[Dict[str, Any]]:
|
||||||
if not overrides:
|
if not overrides:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
candidates: List[tuple[str, str]] = []
|
candidates: List[Dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
for entry in overrides:
|
for entry in overrides:
|
||||||
@@ -499,34 +502,64 @@ def _compile_pronunciation_rules(
|
|||||||
if not token_values:
|
if not token_values:
|
||||||
continue
|
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:
|
for token_value in token_values:
|
||||||
key = token_value.casefold()
|
key = token_value.casefold()
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
candidates.append((token_value, pronunciation_value))
|
candidates.append(
|
||||||
|
{
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": usage_normalized,
|
||||||
|
"replacement": pronunciation_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
candidates.sort(key=lambda item: len(item[0]), reverse=True)
|
candidates.sort(key=lambda item: len(item["token"]), reverse=True)
|
||||||
compiled: List[tuple[re.Pattern[str], str]] = []
|
compiled: List[Dict[str, Any]] = []
|
||||||
for token_value, pronunciation_value in candidates:
|
for candidate in candidates:
|
||||||
|
token_value = candidate["token"]
|
||||||
|
pronunciation_value = candidate["replacement"]
|
||||||
escaped = re.escape(token_value)
|
escaped = re.escape(token_value)
|
||||||
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
|
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
|
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:
|
if not text or not rules:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
result = 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:
|
def _replacement(match: re.Match[str]) -> str:
|
||||||
suffix = match.group("possessive") or ""
|
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
|
return pronunciation_value + suffix
|
||||||
|
|
||||||
result = pattern.sub(_replacement, result)
|
result = pattern.sub(_replacement, result)
|
||||||
@@ -604,6 +637,25 @@ def _group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List
|
|||||||
return grouped
|
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:
|
def _safe_int(value: Any, default: int = 0) -> int:
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
@@ -846,6 +898,19 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
job.add_log("Preparing conversion pipeline")
|
job.add_log("Preparing conversion pipeline")
|
||||||
canceller = _make_canceller(job)
|
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()
|
sink_stack = ExitStack()
|
||||||
subtitle_writer: Optional[SubtitleWriter] = None
|
subtitle_writer: Optional[SubtitleWriter] = None
|
||||||
chapter_paths: list[Path] = []
|
chapter_paths: list[Path] = []
|
||||||
@@ -857,6 +922,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
pipeline: Any = None
|
pipeline: Any = None
|
||||||
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
|
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
active_chapter_configs: 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:
|
try:
|
||||||
pipeline = _load_pipeline(job)
|
pipeline = _load_pipeline(job)
|
||||||
_initialize_voice_cache(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.",
|
f"Applying {count} pronunciation override{'s' if count != 1 else ''} during conversion.",
|
||||||
level="debug",
|
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:
|
if not job.chapters:
|
||||||
filtered, skipped_info = _auto_select_relevant_chapters(extraction.chapters, file_type)
|
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
|
nonlocal processed_chars, subtitle_index, current_time
|
||||||
source_text = str(text or "")
|
source_text = str(text or "")
|
||||||
if pronunciation_rules:
|
if pronunciation_rules:
|
||||||
source_text = _apply_pronunciation_rules(source_text, pronunciation_rules)
|
source_text = _apply_pronunciation_rules(
|
||||||
normalized = _normalize_for_pipeline(source_text)
|
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
|
local_segments = 0
|
||||||
|
|
||||||
for segment in pipeline(
|
for segment in pipeline(
|
||||||
@@ -1278,6 +1366,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
"generate_epub3": job.generate_epub3,
|
"generate_epub3": job.generate_epub3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if usage_counter:
|
||||||
|
_record_override_usage(job, usage_counter, override_token_map)
|
||||||
|
|
||||||
if metadata_dir:
|
if metadata_dir:
|
||||||
metadata_dir.mkdir(parents=True, exist_ok=True)
|
metadata_dir.mkdir(parents=True, exist_ok=True)
|
||||||
metadata_file = metadata_dir / "metadata.json"
|
metadata_file = metadata_dir / "metadata.json"
|
||||||
|
|||||||
+384
-1
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -46,6 +47,17 @@ from abogen.constants import (
|
|||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
)
|
)
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline, normalize_roman_numeral_titles
|
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 (
|
from abogen.utils import (
|
||||||
calculate_text_length,
|
calculate_text_length,
|
||||||
get_user_output_path,
|
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()}
|
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 = {
|
BOOLEAN_SETTINGS = {
|
||||||
"replace_single_newlines",
|
"replace_single_newlines",
|
||||||
"use_gpu",
|
"use_gpu",
|
||||||
@@ -1862,9 +1885,13 @@ BOOLEAN_SETTINGS = {
|
|||||||
"generate_epub3",
|
"generate_epub3",
|
||||||
"enable_entity_recognition",
|
"enable_entity_recognition",
|
||||||
"auto_prefix_chapter_titles",
|
"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"}
|
INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"}
|
||||||
|
|
||||||
|
|
||||||
@@ -1873,6 +1900,7 @@ def _has_output_override() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _settings_defaults() -> Dict[str, Any]:
|
def _settings_defaults() -> Dict[str, Any]:
|
||||||
|
llm_env_defaults = environment_llm_defaults()
|
||||||
return {
|
return {
|
||||||
"output_format": "wav",
|
"output_format": "wav",
|
||||||
"subtitle_format": "srt",
|
"subtitle_format": "srt",
|
||||||
@@ -1894,9 +1922,39 @@ def _settings_defaults() -> Dict[str, Any]:
|
|||||||
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
|
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
|
||||||
"speaker_random_languages": [],
|
"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:
|
def _coerce_bool(value: Any, default: bool) -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
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:
|
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
||||||
return value
|
return value
|
||||||
return defaults[key]
|
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 key == "speaker_random_languages":
|
||||||
if isinstance(value, (list, tuple, set)):
|
if isinstance(value, (list, tuple, set)):
|
||||||
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
|
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
|
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")
|
@web_bp.app_template_filter("datetimeformat")
|
||||||
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||||
if not value:
|
if not value:
|
||||||
@@ -2192,9 +2329,28 @@ def settings_page() -> ResponseReturnValue:
|
|||||||
]
|
]
|
||||||
updated["speaker_random_languages"] = random_languages
|
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 = load_config() or {}
|
||||||
cfg.update(updated)
|
cfg.update(updated)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
clear_cached_settings()
|
||||||
return redirect(url_for("web.settings_page", saved="1"))
|
return redirect(url_for("web.settings_page", saved="1"))
|
||||||
|
|
||||||
save_locations = [
|
save_locations = [
|
||||||
@@ -2206,10 +2362,174 @@ def settings_page() -> ResponseReturnValue:
|
|||||||
"save_locations": save_locations,
|
"save_locations": save_locations,
|
||||||
"default_output_dir": get_user_output_path(),
|
"default_output_dir": get_user_output_path(),
|
||||||
"saved": request.args.get("saved") == "1",
|
"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)
|
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")
|
@web_bp.get("/voices")
|
||||||
def voice_profiles_page() -> str:
|
def voice_profiles_page() -> str:
|
||||||
options = _template_options()
|
options = _template_options()
|
||||||
@@ -2367,6 +2687,69 @@ def entities_override_update() -> ResponseReturnValue:
|
|||||||
return redirect(url_for("web.entities_page", **redirect_params))
|
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"])
|
@web_bp.route("/speakers", methods=["GET", "POST"])
|
||||||
def speaker_configs_page() -> ResponseReturnValue:
|
def speaker_configs_page() -> ResponseReturnValue:
|
||||||
options = _template_options()
|
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);
|
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 {
|
.settings__form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.75rem;
|
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 {
|
.settings__section {
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
border-radius: 18px;
|
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);
|
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 {
|
.prepare-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
||||||
@@ -1828,9 +2025,10 @@ button.step-indicator__item:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field--inline {
|
.field--inline {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.85rem;
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-summary__titles h2 {
|
.entity-summary__titles h2 {
|
||||||
|
|||||||
@@ -11,7 +11,18 @@
|
|||||||
<div class="alert alert--success">Settings saved successfully.</div>
|
<div class="alert alert--success">Settings saved successfully.</div>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<fieldset class="settings__section">
|
||||||
<legend>Narration Defaults</legend>
|
<legend>Narration Defaults</legend>
|
||||||
<div class="field">
|
<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>
|
<p class="hint">Limits random voice selection for speakers marked as random. Leave empty to allow any language.</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel" data-section="audio">
|
||||||
<fieldset class="settings__section">
|
<fieldset class="settings__section">
|
||||||
<legend>Audio & Delivery</legend>
|
<legend>Audio & Delivery</legend>
|
||||||
<div class="field">
|
<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>
|
<p class="hint">Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel" data-section="subtitles">
|
||||||
<fieldset class="settings__section">
|
<fieldset class="settings__section">
|
||||||
<legend>Subtitles & Text</legend>
|
<legend>Subtitles & Text</legend>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -144,7 +159,9 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel" data-section="performance">
|
||||||
<fieldset class="settings__section">
|
<fieldset class="settings__section">
|
||||||
<legend>Performance</legend>
|
<legend>Performance</legend>
|
||||||
<div class="field field--choices">
|
<div class="field field--choices">
|
||||||
@@ -154,10 +171,150 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<div class="settings__actions">
|
||||||
<button type="submit" class="button">Save Settings</button>
|
<button type="submit" class="button">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script type="module" src="{{ url_for('static', filename='settings.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user