mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
3583 lines
128 KiB
Python
3583 lines
128 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import zipfile
|
|
from datetime import datetime
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, cast
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from flask import (
|
|
Blueprint,
|
|
abort,
|
|
current_app,
|
|
jsonify,
|
|
redirect,
|
|
render_template,
|
|
request,
|
|
send_file,
|
|
url_for,
|
|
)
|
|
from flask.typing import ResponseReturnValue
|
|
from werkzeug.utils import secure_filename
|
|
|
|
import numpy as np
|
|
import soundfile as sf
|
|
from abogen.constants import (
|
|
LANGUAGE_DESCRIPTIONS,
|
|
SAMPLE_VOICE_TEXTS,
|
|
SUBTITLE_FORMATS,
|
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
|
SUPPORTED_SOUND_FORMATS,
|
|
VOICES_INTERNAL,
|
|
)
|
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
|
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
|
from abogen.utils import (
|
|
calculate_text_length,
|
|
clean_text,
|
|
get_user_output_path,
|
|
load_config,
|
|
load_numpy_kpipeline,
|
|
save_config,
|
|
)
|
|
from abogen.entity_analysis import (
|
|
extract_entities,
|
|
merge_override,
|
|
normalize_token as normalize_entity_token,
|
|
search_tokens as search_entity_tokens,
|
|
)
|
|
from abogen.pronunciation_store import (
|
|
delete_override as delete_pronunciation_override,
|
|
load_overrides as load_pronunciation_overrides,
|
|
all_overrides as all_pronunciation_overrides,
|
|
save_override as save_pronunciation_override,
|
|
search_overrides as search_pronunciation_overrides,
|
|
)
|
|
from abogen.voice_profiles import (
|
|
delete_profile,
|
|
duplicate_profile,
|
|
export_profiles_payload,
|
|
import_profiles_data,
|
|
load_profiles,
|
|
normalize_voice_entries,
|
|
remove_profile,
|
|
save_profile,
|
|
save_profiles,
|
|
serialize_profiles,
|
|
)
|
|
|
|
from abogen.voice_formulas import get_new_voice, parse_formula_terms
|
|
from abogen.speaker_analysis import analyze_speakers
|
|
from abogen.speaker_configs import (
|
|
delete_config,
|
|
get_config,
|
|
list_configs,
|
|
load_configs,
|
|
save_configs,
|
|
upsert_config,
|
|
slugify_label,
|
|
)
|
|
from abogen.text_extractor import extract_from_path
|
|
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
|
from .service import ConversionService, Job, JobStatus, PendingJob
|
|
|
|
web_bp = Blueprint("web", __name__)
|
|
api_bp = Blueprint("api", __name__)
|
|
|
|
|
|
_preview_pipeline_lock = threading.RLock()
|
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
|
|
|
|
|
_CHUNK_LEVEL_OPTIONS = [
|
|
{"value": "paragraph", "label": "Paragraphs"},
|
|
{"value": "sentence", "label": "Sentences"},
|
|
]
|
|
|
|
_SPEAKER_MODE_OPTIONS = [
|
|
{"value": "single", "label": "Single Speaker"},
|
|
{"value": "multi", "label": "Multi-Speaker"},
|
|
]
|
|
|
|
_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
|
|
_SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS}
|
|
|
|
|
|
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
|
|
|
|
|
_WIZARD_STEP_ORDER = ["book", "chapters", "entities"]
|
|
_WIZARD_STEP_META = {
|
|
"book": {
|
|
"index": 1,
|
|
"title": "Book parameters",
|
|
"hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.",
|
|
},
|
|
"chapters": {
|
|
"index": 2,
|
|
"title": "Select chapters",
|
|
"hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
|
|
},
|
|
"entities": {
|
|
"index": 3,
|
|
"title": "Review entities",
|
|
"hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.",
|
|
},
|
|
}
|
|
|
|
|
|
def _coerce_path(value: Any) -> Optional[Path]:
|
|
if isinstance(value, Path):
|
|
return value
|
|
if isinstance(value, str):
|
|
candidate = Path(value)
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _normalize_epub_path(base_dir: str, href: str) -> str:
|
|
if not href:
|
|
return ""
|
|
sanitized = href.split("#", 1)[0].split("?", 1)[0].strip()
|
|
sanitized = sanitized.replace("\\", "/")
|
|
if not sanitized:
|
|
return ""
|
|
if sanitized.startswith("/"):
|
|
sanitized = sanitized[1:]
|
|
base_dir = ""
|
|
base = base_dir.strip("/")
|
|
combined = posixpath.join(base, sanitized) if base else sanitized
|
|
normalized = posixpath.normpath(combined)
|
|
if normalized in {"", "."}:
|
|
return ""
|
|
if normalized.startswith("../") or normalized == "..":
|
|
return ""
|
|
return normalized
|
|
|
|
|
|
def _decode_text(payload: bytes) -> str:
|
|
for encoding in ("utf-8", "utf-16", "windows-1252"):
|
|
try:
|
|
return payload.decode(encoding)
|
|
except UnicodeDecodeError:
|
|
continue
|
|
return payload.decode("utf-8", "ignore")
|
|
|
|
|
|
class _NavMapParser(HTMLParser):
|
|
def __init__(self, base_dir: str) -> None:
|
|
super().__init__()
|
|
self._base_dir = base_dir
|
|
self._in_nav = False
|
|
self._nav_depth = 0
|
|
self._current_href: Optional[str] = None
|
|
self._buffer: List[str] = []
|
|
self.links: Dict[str, str] = {}
|
|
|
|
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
|
tag_lower = tag.lower()
|
|
if tag_lower == "nav":
|
|
attributes = dict(attrs)
|
|
nav_type = (attributes.get("epub:type") or attributes.get("type") or "").strip().lower()
|
|
nav_role = (attributes.get("role") or "").strip().lower()
|
|
type_tokens = {token.strip() for token in nav_type.split() if token}
|
|
role_tokens = {token.strip() for token in nav_role.split() if token}
|
|
if "toc" in type_tokens or "doc-toc" in role_tokens:
|
|
self._in_nav = True
|
|
self._nav_depth = 1
|
|
return
|
|
if self._in_nav:
|
|
self._nav_depth += 1
|
|
return
|
|
if not self._in_nav:
|
|
return
|
|
if tag_lower == "a":
|
|
attributes = dict(attrs)
|
|
href = attributes.get("href") or ""
|
|
normalized = _normalize_epub_path(self._base_dir, href)
|
|
if normalized:
|
|
self._current_href = normalized
|
|
self._buffer = []
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
tag_lower = tag.lower()
|
|
if tag_lower == "nav" and self._in_nav:
|
|
self._nav_depth -= 1
|
|
if self._nav_depth <= 0:
|
|
self._in_nav = False
|
|
return
|
|
if not self._in_nav:
|
|
return
|
|
if tag_lower == "a" and self._current_href:
|
|
text = "".join(self._buffer).strip()
|
|
if text:
|
|
self.links.setdefault(self._current_href, text)
|
|
self._current_href = None
|
|
self._buffer = []
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if self._in_nav and self._current_href and data:
|
|
self._buffer.append(data)
|
|
|
|
|
|
def _parse_nav_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
|
parser = _NavMapParser(base_dir)
|
|
parser.feed(_decode_text(payload))
|
|
parser.close()
|
|
return parser.links
|
|
|
|
|
|
def _parse_ncx_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
|
try:
|
|
root = ET.fromstring(payload)
|
|
except ET.ParseError:
|
|
return {}
|
|
nav_map: Dict[str, str] = {}
|
|
for nav_point in root.findall(".//{*}navPoint"):
|
|
content = nav_point.find(".//{*}content")
|
|
if content is None:
|
|
continue
|
|
src = content.attrib.get("src", "")
|
|
normalized = _normalize_epub_path(base_dir, src)
|
|
if not normalized:
|
|
continue
|
|
label_el = nav_point.find(".//{*}text")
|
|
label = (label_el.text or "").strip() if label_el is not None and label_el.text else ""
|
|
if not label:
|
|
label = posixpath.basename(normalized) or f"Section {len(nav_map) + 1}"
|
|
nav_map.setdefault(normalized, label)
|
|
return nav_map
|
|
|
|
|
|
def _extract_epub_chapters(epub_path: Path) -> List[Dict[str, str]]:
|
|
chapters: List[Dict[str, str]] = []
|
|
if not epub_path or not epub_path.exists():
|
|
return chapters
|
|
try:
|
|
with zipfile.ZipFile(epub_path, "r") as archive:
|
|
container_bytes = archive.read("META-INF/container.xml")
|
|
container_root = ET.fromstring(container_bytes)
|
|
rootfile = container_root.find(".//{*}rootfile")
|
|
if rootfile is None:
|
|
return chapters
|
|
opf_path = (rootfile.attrib.get("full-path") or "").strip()
|
|
if not opf_path:
|
|
return chapters
|
|
opf_dir = posixpath.dirname(opf_path)
|
|
opf_bytes = archive.read(opf_path)
|
|
opf_root = ET.fromstring(opf_bytes)
|
|
|
|
manifest: Dict[str, Dict[str, str]] = {}
|
|
for item in opf_root.findall(".//{*}manifest/{*}item"):
|
|
item_id = item.attrib.get("id")
|
|
href = item.attrib.get("href")
|
|
if not item_id or not href:
|
|
continue
|
|
manifest[item_id] = {
|
|
"href": _normalize_epub_path(opf_dir, href),
|
|
"properties": item.attrib.get("properties", ""),
|
|
"media_type": item.attrib.get("media-type", ""),
|
|
}
|
|
|
|
spine_hrefs: List[str] = []
|
|
nav_id: Optional[str] = None
|
|
spine = opf_root.find(".//{*}spine")
|
|
if spine is not None:
|
|
nav_id = spine.attrib.get("toc")
|
|
for itemref in spine.findall(".//{*}itemref"):
|
|
idref = itemref.attrib.get("idref")
|
|
if not idref:
|
|
continue
|
|
entry = manifest.get(idref)
|
|
if not entry:
|
|
continue
|
|
href = entry["href"]
|
|
if href and href not in spine_hrefs:
|
|
spine_hrefs.append(href)
|
|
|
|
nav_href: Optional[str] = None
|
|
for entry in manifest.values():
|
|
properties = entry.get("properties") or ""
|
|
if "nav" in {token.strip() for token in properties.split() if token}:
|
|
nav_href = entry["href"]
|
|
break
|
|
if not nav_href and nav_id:
|
|
toc_entry = manifest.get(nav_id)
|
|
if toc_entry:
|
|
nav_href = toc_entry["href"]
|
|
|
|
nav_titles: Dict[str, str] = {}
|
|
if nav_href:
|
|
nav_base = posixpath.dirname(nav_href)
|
|
try:
|
|
nav_bytes = archive.read(nav_href)
|
|
except KeyError:
|
|
nav_bytes = None
|
|
if nav_bytes is not None:
|
|
if nav_href.lower().endswith(".ncx"):
|
|
nav_titles = _parse_ncx_document(nav_bytes, nav_base)
|
|
else:
|
|
nav_titles = _parse_nav_document(nav_bytes, nav_base)
|
|
|
|
if not nav_titles and nav_id and nav_id in manifest:
|
|
toc_entry = manifest[nav_id]
|
|
nav_base = posixpath.dirname(toc_entry["href"])
|
|
try:
|
|
nav_bytes = archive.read(toc_entry["href"])
|
|
except KeyError:
|
|
nav_bytes = None
|
|
if nav_bytes is not None:
|
|
nav_titles = _parse_ncx_document(nav_bytes, nav_base)
|
|
|
|
for index, href in enumerate(spine_hrefs, start=1):
|
|
normalized = _normalize_epub_path("", href)
|
|
if not normalized:
|
|
continue
|
|
title = (
|
|
nav_titles.get(normalized)
|
|
or nav_titles.get(normalized.split("#", 1)[0])
|
|
or posixpath.basename(normalized)
|
|
or f"Chapter {index}"
|
|
)
|
|
chapters.append({"href": normalized, "title": title})
|
|
|
|
if not chapters and nav_titles:
|
|
for index, (href, title) in enumerate(nav_titles.items(), start=1):
|
|
normalized = _normalize_epub_path("", href)
|
|
if not normalized:
|
|
continue
|
|
label = title or posixpath.basename(normalized) or f"Chapter {index}"
|
|
chapters.append({"href": normalized, "title": label})
|
|
|
|
return chapters
|
|
except (FileNotFoundError, zipfile.BadZipFile, KeyError, ET.ParseError, UnicodeDecodeError):
|
|
return []
|
|
return chapters
|
|
|
|
|
|
def _read_epub_bytes(epub_path: Path, raw_href: str) -> bytes:
|
|
normalized = _normalize_epub_path("", raw_href)
|
|
if not normalized:
|
|
raise ValueError("Invalid resource path")
|
|
with zipfile.ZipFile(epub_path, "r") as archive:
|
|
return archive.read(normalized)
|
|
|
|
|
|
def _iter_job_result_paths(job: Job) -> List[Path]:
|
|
result = getattr(job, "result", None)
|
|
if result is None:
|
|
return []
|
|
resolved_seen: Set[Path] = set()
|
|
collected: List[Path] = []
|
|
|
|
def _remember(candidate: Optional[Path]) -> None:
|
|
if not candidate:
|
|
return
|
|
try:
|
|
resolved = candidate.resolve()
|
|
except OSError:
|
|
return
|
|
if resolved in resolved_seen:
|
|
return
|
|
resolved_seen.add(resolved)
|
|
collected.append(candidate)
|
|
|
|
artifacts = getattr(result, "artifacts", None)
|
|
if isinstance(artifacts, Mapping):
|
|
for value in artifacts.values():
|
|
candidate = _coerce_path(value)
|
|
if candidate and candidate.exists() and candidate.is_file():
|
|
_remember(candidate)
|
|
|
|
for attr in ("audio_path", "epub_path"):
|
|
candidate = _coerce_path(getattr(result, attr, None))
|
|
if candidate and candidate.exists() and candidate.is_file():
|
|
_remember(candidate)
|
|
|
|
return collected
|
|
|
|
|
|
def _iter_job_artifact_dirs(job: Job) -> List[Path]:
|
|
result = getattr(job, "result", None)
|
|
if result is None:
|
|
return []
|
|
artifacts = getattr(result, "artifacts", None)
|
|
directories: List[Path] = []
|
|
if isinstance(artifacts, Mapping):
|
|
for value in artifacts.values():
|
|
candidate = _coerce_path(value)
|
|
if candidate and candidate.exists() and candidate.is_dir():
|
|
directories.append(candidate)
|
|
return directories
|
|
|
|
|
|
def _normalize_suffixes(suffixes: Iterable[str]) -> List[str]:
|
|
normalized: List[str] = []
|
|
for suffix in suffixes:
|
|
if not suffix:
|
|
continue
|
|
cleaned = suffix.lower().strip()
|
|
if not cleaned:
|
|
continue
|
|
if not cleaned.startswith("."):
|
|
cleaned = f".{cleaned.lstrip('.')}"
|
|
normalized.append(cleaned)
|
|
return normalized
|
|
|
|
|
|
def _find_job_file(job: Job, suffixes: Iterable[str]) -> Optional[Path]:
|
|
ordered_suffixes = _normalize_suffixes(suffixes)
|
|
if not ordered_suffixes:
|
|
return None
|
|
files = _iter_job_result_paths(job)
|
|
for suffix in ordered_suffixes:
|
|
for candidate in files:
|
|
if candidate.suffix.lower() == suffix:
|
|
return candidate
|
|
directories = _iter_job_artifact_dirs(job)
|
|
for suffix in ordered_suffixes:
|
|
pattern = f"*{suffix}"
|
|
for directory in directories:
|
|
try:
|
|
match = next((path for path in directory.rglob(pattern) if path.is_file()), None)
|
|
except OSError:
|
|
match = None
|
|
if match:
|
|
return match
|
|
return None
|
|
|
|
|
|
def _locate_job_epub(job: Job) -> Optional[Path]:
|
|
path = _find_job_file(job, [".epub"])
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
|
|
def _locate_job_m4b(job: Job) -> Optional[Path]:
|
|
return _find_job_file(job, [".m4b"])
|
|
|
|
|
|
def _locate_job_audio(job: Job, preferred_suffixes: Optional[Iterable[str]] = None) -> Optional[Path]:
|
|
suffix_order: List[str] = []
|
|
if preferred_suffixes:
|
|
suffix_order.extend(preferred_suffixes)
|
|
suffix_order.extend([".m4b", ".mp3", ".flac", ".opus", ".ogg", ".m4a", ".wav"])
|
|
path = _find_job_file(job, suffix_order)
|
|
if path:
|
|
return path
|
|
files = _iter_job_result_paths(job)
|
|
return files[0] if files else None
|
|
|
|
|
|
def _job_download_flags(job: Job) -> Dict[str, bool]:
|
|
if job.status != JobStatus.COMPLETED:
|
|
return {"audio": False, "m4b": False, "epub3": False}
|
|
return {
|
|
"audio": _locate_job_audio(job) is not None,
|
|
"m4b": _locate_job_m4b(job) is not None,
|
|
"epub3": _locate_job_epub(job) is not None,
|
|
}
|
|
def _build_narrator_roster(
|
|
voice: str,
|
|
voice_profile: Optional[str],
|
|
existing: Optional[Mapping[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
roster: Dict[str, Any] = {
|
|
"narrator": {
|
|
"id": "narrator",
|
|
"label": "Narrator",
|
|
"voice": voice,
|
|
}
|
|
}
|
|
if voice_profile:
|
|
roster["narrator"]["voice_profile"] = voice_profile
|
|
existing_entry: Optional[Mapping[str, Any]] = None
|
|
if existing is not None:
|
|
existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None
|
|
if isinstance(existing_entry, Mapping):
|
|
roster_entry = roster["narrator"]
|
|
for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"):
|
|
value = existing_entry.get(key)
|
|
if value is not None and value != "":
|
|
roster_entry[key] = value
|
|
return roster
|
|
|
|
|
|
def _build_speaker_roster(
|
|
analysis: Dict[str, Any],
|
|
base_voice: str,
|
|
voice_profile: Optional[str],
|
|
existing: Optional[Mapping[str, Any]] = None,
|
|
order: Optional[Iterable[str]] = None,
|
|
) -> Dict[str, Any]:
|
|
roster = _build_narrator_roster(base_voice, voice_profile, existing)
|
|
existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
|
|
speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
|
|
ordered_ids: Iterable[str]
|
|
if order is not None:
|
|
ordered_ids = [sid for sid in order if sid in speakers]
|
|
else:
|
|
ordered_ids = speakers.keys()
|
|
|
|
for speaker_id in ordered_ids:
|
|
payload = speakers.get(speaker_id, {})
|
|
if speaker_id == "narrator":
|
|
continue
|
|
if isinstance(payload, Mapping) and payload.get("suppressed"):
|
|
continue
|
|
previous = existing_map.get(speaker_id)
|
|
roster[speaker_id] = {
|
|
"id": speaker_id,
|
|
"label": payload.get("label") or speaker_id.replace("_", " ").title(),
|
|
"analysis_confidence": payload.get("confidence"),
|
|
"analysis_count": payload.get("count"),
|
|
"gender": payload.get("gender", "unknown"),
|
|
}
|
|
detected_gender = payload.get("detected_gender")
|
|
if detected_gender:
|
|
roster[speaker_id]["detected_gender"] = detected_gender
|
|
samples = payload.get("sample_quotes")
|
|
if isinstance(samples, list):
|
|
roster[speaker_id]["sample_quotes"] = samples
|
|
if isinstance(previous, Mapping):
|
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
|
value = previous.get(key)
|
|
if value is not None and value != "":
|
|
roster[speaker_id][key] = value
|
|
if "sample_quotes" not in roster[speaker_id]:
|
|
prev_samples = previous.get("sample_quotes")
|
|
if isinstance(prev_samples, list):
|
|
roster[speaker_id]["sample_quotes"] = prev_samples
|
|
if "detected_gender" not in roster[speaker_id]:
|
|
prev_detected = previous.get("detected_gender")
|
|
if isinstance(prev_detected, str) and prev_detected:
|
|
roster[speaker_id]["detected_gender"] = prev_detected
|
|
return roster
|
|
|
|
|
|
def _match_configured_speaker(
|
|
config_speakers: Mapping[str, Any],
|
|
roster_id: str,
|
|
roster_label: str,
|
|
) -> Optional[Mapping[str, Any]]:
|
|
if not config_speakers:
|
|
return None
|
|
entry = config_speakers.get(roster_id)
|
|
if entry:
|
|
return cast(Mapping[str, Any], entry)
|
|
slug = slugify_label(roster_label)
|
|
if slug != roster_id and slug in config_speakers:
|
|
return cast(Mapping[str, Any], config_speakers[slug])
|
|
lower_label = roster_label.strip().lower()
|
|
for record in config_speakers.values():
|
|
if not isinstance(record, Mapping):
|
|
continue
|
|
if str(record.get("label", "")).strip().lower() == lower_label:
|
|
return record
|
|
return None
|
|
|
|
|
|
def _apply_speaker_config_to_roster(
|
|
roster: Mapping[str, Any],
|
|
config: Optional[Mapping[str, Any]],
|
|
*,
|
|
persist_changes: bool = False,
|
|
fallback_languages: Optional[Iterable[str]] = None,
|
|
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
|
if not isinstance(roster, Mapping):
|
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
return {}, effective_languages, None
|
|
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
|
if not config:
|
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
return updated_roster, effective_languages, None
|
|
|
|
speakers_map = config.get("speakers")
|
|
if not isinstance(speakers_map, Mapping):
|
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
return updated_roster, effective_languages, None
|
|
|
|
config_languages = config.get("languages")
|
|
if isinstance(config_languages, list):
|
|
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
|
else:
|
|
allowed_languages = []
|
|
if not allowed_languages and fallback_languages:
|
|
allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
|
|
|
|
default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
|
|
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
|
narrator_voice = ""
|
|
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
|
if isinstance(narrator_entry, Mapping):
|
|
narrator_voice = str(
|
|
narrator_entry.get("resolved_voice")
|
|
or narrator_entry.get("default_voice")
|
|
or ""
|
|
).strip()
|
|
if narrator_voice:
|
|
used_voices.add(narrator_voice)
|
|
|
|
config_changed = False
|
|
new_config_payload: Dict[str, Any] = {
|
|
"language": config.get("language", "a"),
|
|
"languages": allowed_languages,
|
|
"default_voice": default_voice,
|
|
"speakers": dict(speakers_map),
|
|
"version": config.get("version", 1),
|
|
"notes": config.get("notes", ""),
|
|
}
|
|
|
|
speakers_payload = new_config_payload["speakers"]
|
|
|
|
for speaker_id, roster_entry in updated_roster.items():
|
|
if speaker_id == "narrator":
|
|
continue
|
|
label = str(roster_entry.get("label") or speaker_id)
|
|
config_entry = _match_configured_speaker(speakers_map, speaker_id, label)
|
|
if config_entry is None:
|
|
continue
|
|
voice_id = str(config_entry.get("voice") or "").strip()
|
|
voice_profile = str(config_entry.get("voice_profile") or "").strip()
|
|
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
|
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
|
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
|
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
|
usable_languages = languages or allowed_languages
|
|
|
|
if chosen_voice:
|
|
roster_entry["resolved_voice"] = chosen_voice
|
|
roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
|
|
if voice_profile:
|
|
roster_entry["voice_profile"] = voice_profile
|
|
if voice_formula:
|
|
roster_entry["voice_formula"] = voice_formula
|
|
roster_entry["resolved_voice"] = voice_formula
|
|
if not voice_formula and not voice_profile and resolved_voice:
|
|
roster_entry["resolved_voice"] = resolved_voice
|
|
roster_entry["config_languages"] = usable_languages or []
|
|
|
|
if chosen_voice:
|
|
used_voices.add(chosen_voice)
|
|
|
|
# persist updates back to config payload if required
|
|
if persist_changes:
|
|
slug = config_entry.get("id") or slugify_label(label)
|
|
speakers_payload[slug] = {
|
|
"id": slug,
|
|
"label": label,
|
|
"gender": config_entry.get("gender", "unknown"),
|
|
"voice": voice_id,
|
|
"voice_profile": voice_profile,
|
|
"voice_formula": voice_formula,
|
|
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
|
"languages": usable_languages,
|
|
}
|
|
|
|
new_config = new_config_payload if (persist_changes and config_changed) else None
|
|
return updated_roster, allowed_languages, new_config
|
|
|
|
|
|
def _filter_voice_catalog(
|
|
catalog: Iterable[Mapping[str, Any]],
|
|
*,
|
|
gender: str,
|
|
allowed_languages: Optional[Iterable[str]] = None,
|
|
) -> List[str]:
|
|
allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
|
|
gender_normalized = (gender or "unknown").lower()
|
|
gender_code = ""
|
|
if gender_normalized == "male":
|
|
gender_code = "m"
|
|
elif gender_normalized == "female":
|
|
gender_code = "f"
|
|
|
|
matches: List[str] = []
|
|
seen: set[str] = set()
|
|
|
|
def _consider(entry: Mapping[str, Any]) -> None:
|
|
voice_id = entry.get("id")
|
|
if not isinstance(voice_id, str) or not voice_id:
|
|
return
|
|
if voice_id in seen:
|
|
return
|
|
seen.add(voice_id)
|
|
matches.append(voice_id)
|
|
|
|
primary: List[Mapping[str, Any]] = []
|
|
fallback: List[Mapping[str, Any]] = []
|
|
for entry in catalog:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
voice_lang = str(entry.get("language", "")).lower()
|
|
voice_gender_code = str(entry.get("gender_code", "")).lower()
|
|
if allowed_set and voice_lang not in allowed_set:
|
|
continue
|
|
if gender_code and voice_gender_code != gender_code:
|
|
fallback.append(entry)
|
|
continue
|
|
primary.append(entry)
|
|
|
|
for entry in primary:
|
|
_consider(entry)
|
|
|
|
if not matches:
|
|
for entry in fallback:
|
|
_consider(entry)
|
|
|
|
if not matches:
|
|
for entry in catalog:
|
|
if isinstance(entry, Mapping):
|
|
_consider(entry)
|
|
|
|
return matches
|
|
|
|
|
|
def _inject_recommended_voices(
|
|
roster: Mapping[str, Any],
|
|
*,
|
|
fallback_languages: Optional[Iterable[str]] = None,
|
|
) -> None:
|
|
voice_catalog = _build_voice_catalog()
|
|
fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
for speaker_id, payload in roster.items():
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
languages = payload.get("config_languages")
|
|
if isinstance(languages, list) and languages:
|
|
language_list = languages
|
|
else:
|
|
language_list = fallback_list
|
|
gender = str(payload.get("gender", "unknown"))
|
|
payload["recommended_voices"] = _filter_voice_catalog(
|
|
voice_catalog,
|
|
gender=gender,
|
|
allowed_languages=language_list,
|
|
)
|
|
|
|
|
|
def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]:
|
|
getter = getattr(form, "getlist", None)
|
|
|
|
def _get_list(name: str) -> List[str]:
|
|
if callable(getter):
|
|
values = cast(Iterable[Any], getter(name))
|
|
return [str(value).strip() for value in values if value]
|
|
raw_value = form.get(name)
|
|
if isinstance(raw_value, str):
|
|
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
|
return []
|
|
|
|
name = (form.get("config_name") or "").strip()
|
|
language = str(form.get("config_language") or "a").strip() or "a"
|
|
allowed_languages = []
|
|
default_voice = (form.get("config_default_voice") or "").strip()
|
|
notes = (form.get("config_notes") or "").strip()
|
|
version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999)
|
|
|
|
speaker_rows = _get_list("speaker_rows")
|
|
speakers: Dict[str, Dict[str, Any]] = {}
|
|
for row_key in speaker_rows:
|
|
prefix = f"speaker-{row_key}-"
|
|
label = (form.get(prefix + "label") or "").strip()
|
|
if not label:
|
|
continue
|
|
raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower()
|
|
gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown"
|
|
voice = (form.get(prefix + "voice") or "").strip()
|
|
voice_profile = (form.get(prefix + "profile") or "").strip()
|
|
voice_formula = (form.get(prefix + "formula") or "").strip()
|
|
speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
|
|
speakers[speaker_id] = {
|
|
"id": speaker_id,
|
|
"label": label,
|
|
"gender": gender,
|
|
"voice": voice,
|
|
"voice_profile": voice_profile,
|
|
"voice_formula": voice_formula,
|
|
"resolved_voice": voice_formula or voice,
|
|
"languages": [],
|
|
}
|
|
|
|
payload = {
|
|
"language": language,
|
|
"languages": allowed_languages,
|
|
"default_voice": default_voice,
|
|
"speakers": speakers,
|
|
"notes": notes,
|
|
"version": version,
|
|
}
|
|
|
|
errors: List[str] = []
|
|
if not name:
|
|
errors.append("Configuration name is required.")
|
|
if not speakers:
|
|
errors.append("Add at least one speaker to the configuration.")
|
|
|
|
return name, payload, errors
|
|
|
|
|
|
def _prepare_speaker_metadata(
|
|
*,
|
|
chapters: List[Dict[str, Any]],
|
|
chunks: List[Dict[str, Any]],
|
|
analysis_chunks: Optional[List[Dict[str, Any]]] = None,
|
|
speaker_mode: str,
|
|
voice: str,
|
|
voice_profile: Optional[str],
|
|
threshold: int,
|
|
existing_roster: Optional[Mapping[str, Any]] = None,
|
|
run_analysis: bool = True,
|
|
speaker_config: Optional[Mapping[str, Any]] = None,
|
|
apply_config: bool = False,
|
|
persist_config: bool = False,
|
|
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
|
chunk_list = [dict(chunk) for chunk in chunks]
|
|
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
|
threshold_value = max(1, int(threshold))
|
|
analysis_enabled = speaker_mode == "multi" and run_analysis
|
|
settings_state = _load_settings()
|
|
global_random_languages = [
|
|
code
|
|
for code in settings_state.get("speaker_random_languages", [])
|
|
if isinstance(code, str) and code
|
|
]
|
|
|
|
if not analysis_enabled:
|
|
for chunk in chunk_list:
|
|
chunk["speaker_id"] = "narrator"
|
|
chunk["speaker_label"] = "Narrator"
|
|
analysis_payload = {
|
|
"version": "1.0",
|
|
"narrator": "narrator",
|
|
"assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list},
|
|
"speakers": {
|
|
"narrator": {
|
|
"id": "narrator",
|
|
"label": "Narrator",
|
|
"count": len(chunk_list),
|
|
"confidence": "low",
|
|
"sample_quotes": [],
|
|
"suppressed": False,
|
|
}
|
|
},
|
|
"suppressed": [],
|
|
"stats": {
|
|
"total_chunks": len(chunk_list),
|
|
"explicit_chunks": 0,
|
|
"active_speakers": 0,
|
|
"unique_speakers": 1,
|
|
"suppressed": 0,
|
|
},
|
|
}
|
|
roster = _build_narrator_roster(voice, voice_profile, existing_roster)
|
|
narrator_pron = roster["narrator"].get("pronunciation")
|
|
if narrator_pron:
|
|
analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
|
|
return chunk_list, roster, analysis_payload, [], None
|
|
|
|
analysis_result = analyze_speakers(
|
|
chapters,
|
|
analysis_source,
|
|
threshold=threshold_value,
|
|
max_speakers=0,
|
|
)
|
|
analysis_payload = analysis_result.to_dict()
|
|
speakers_payload = analysis_payload.get("speakers", {})
|
|
ordered_ids = [
|
|
sid
|
|
for sid, meta in sorted(
|
|
(
|
|
(sid, meta)
|
|
for sid, meta in speakers_payload.items()
|
|
if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
|
|
),
|
|
key=lambda item: item[1].get("count", 0),
|
|
reverse=True,
|
|
)
|
|
]
|
|
analysis_payload["ordered_speakers"] = ordered_ids
|
|
assignments = analysis_payload.get("assignments", {})
|
|
suppressed_ids = analysis_payload.get("suppressed", [])
|
|
suppressed_details: List[Dict[str, Any]] = []
|
|
speakers_payload = analysis_payload.get("speakers", {})
|
|
if isinstance(suppressed_ids, Iterable):
|
|
for suppressed_id in suppressed_ids:
|
|
speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None
|
|
if isinstance(speaker_meta, dict):
|
|
suppressed_details.append(
|
|
{
|
|
"id": suppressed_id,
|
|
"label": speaker_meta.get("label")
|
|
or str(suppressed_id).replace("_", " ").title(),
|
|
"pronunciation": speaker_meta.get("pronunciation"),
|
|
}
|
|
)
|
|
else:
|
|
suppressed_details.append(
|
|
{
|
|
"id": suppressed_id,
|
|
"label": str(suppressed_id).replace("_", " ").title(),
|
|
"pronunciation": None,
|
|
}
|
|
)
|
|
analysis_payload["suppressed_details"] = suppressed_details
|
|
roster = _build_speaker_roster(
|
|
analysis_payload,
|
|
voice,
|
|
voice_profile,
|
|
existing=existing_roster,
|
|
order=analysis_payload.get("ordered_speakers"),
|
|
)
|
|
applied_languages: List[str] = []
|
|
updated_config: Optional[Dict[str, Any]] = None
|
|
if apply_config and speaker_config:
|
|
roster, applied_languages, updated_config = _apply_speaker_config_to_roster(
|
|
roster,
|
|
speaker_config,
|
|
persist_changes=persist_config,
|
|
fallback_languages=global_random_languages,
|
|
)
|
|
speakers_payload = analysis_payload.get("speakers")
|
|
if isinstance(speakers_payload, dict):
|
|
for roster_id, roster_payload in roster.items():
|
|
speaker_meta = speakers_payload.get(roster_id)
|
|
if isinstance(speaker_meta, dict):
|
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
|
|
value = roster_payload.get(key)
|
|
if value:
|
|
speaker_meta[key] = value
|
|
effective_languages: List[str] = []
|
|
if applied_languages:
|
|
effective_languages = applied_languages
|
|
elif isinstance(analysis_payload.get("config_languages"), list):
|
|
effective_languages = [
|
|
code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
|
|
]
|
|
elif global_random_languages:
|
|
effective_languages = list(global_random_languages)
|
|
|
|
if effective_languages:
|
|
analysis_payload["config_languages"] = effective_languages
|
|
speakers_payload = analysis_payload.get("speakers")
|
|
if isinstance(speakers_payload, dict):
|
|
for roster_id, roster_payload in roster.items():
|
|
if roster_id in speakers_payload and isinstance(roster_payload, dict):
|
|
pronunciation_value = roster_payload.get("pronunciation")
|
|
if pronunciation_value:
|
|
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
|
|
|
fallback_languages = effective_languages or []
|
|
_inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
|
|
|
for chunk in chunk_list:
|
|
chunk_id = str(chunk.get("id"))
|
|
speaker_id = assignments.get(chunk_id, "narrator")
|
|
chunk["speaker_id"] = speaker_id
|
|
speaker_meta = roster.get(speaker_id)
|
|
chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
|
|
|
|
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
|
|
|
|
|
def _collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
|
|
language = pending.language or "en"
|
|
collected: Dict[str, Dict[str, Any]] = {}
|
|
|
|
summary = pending.entity_summary or {}
|
|
for group in ("people", "entities"):
|
|
entries = summary.get(group)
|
|
if not isinstance(entries, list):
|
|
continue
|
|
for entry in entries:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
override_payload = entry.get("override")
|
|
if not isinstance(override_payload, Mapping):
|
|
continue
|
|
token_value = str(entry.get("label") or override_payload.get("token") or "").strip()
|
|
pronunciation_value = str(override_payload.get("pronunciation") or "").strip()
|
|
if not token_value or not pronunciation_value:
|
|
continue
|
|
normalized = normalize_entity_token(entry.get("normalized") or token_value)
|
|
if not normalized:
|
|
continue
|
|
collected[normalized] = {
|
|
"token": token_value,
|
|
"normalized": normalized,
|
|
"pronunciation": pronunciation_value,
|
|
"voice": str(override_payload.get("voice") or "").strip() or None,
|
|
"notes": str(override_payload.get("notes") or "").strip() or None,
|
|
"context": str(override_payload.get("context") or "").strip() or None,
|
|
"source": f"{group}-override",
|
|
"language": language,
|
|
}
|
|
|
|
if isinstance(pending.speakers, Mapping):
|
|
for speaker_payload in pending.speakers.values():
|
|
if not isinstance(speaker_payload, Mapping):
|
|
continue
|
|
token_value = str(speaker_payload.get("label") or "").strip()
|
|
pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip()
|
|
if not token_value or not pronunciation_value:
|
|
continue
|
|
normalized = normalize_entity_token(token_value)
|
|
if not normalized:
|
|
continue
|
|
collected[normalized] = {
|
|
"token": token_value,
|
|
"normalized": normalized,
|
|
"pronunciation": pronunciation_value,
|
|
"voice": str(
|
|
speaker_payload.get("resolved_voice")
|
|
or speaker_payload.get("voice")
|
|
or pending.voice
|
|
).strip()
|
|
or None,
|
|
"notes": None,
|
|
"context": None,
|
|
"source": "speaker",
|
|
"language": language,
|
|
}
|
|
|
|
for manual_entry in pending.manual_overrides or []:
|
|
if not isinstance(manual_entry, Mapping):
|
|
continue
|
|
token_value = str(manual_entry.get("token") or "").strip()
|
|
pronunciation_value = str(manual_entry.get("pronunciation") or "").strip()
|
|
if not token_value or not pronunciation_value:
|
|
continue
|
|
normalized = manual_entry.get("normalized") or normalize_entity_token(token_value)
|
|
if not normalized:
|
|
continue
|
|
collected[normalized] = {
|
|
"token": token_value,
|
|
"normalized": normalized,
|
|
"pronunciation": pronunciation_value,
|
|
"voice": str(manual_entry.get("voice") or "").strip() or None,
|
|
"notes": str(manual_entry.get("notes") or "").strip() or None,
|
|
"context": str(manual_entry.get("context") or "").strip() or None,
|
|
"source": str(manual_entry.get("source") or "manual"),
|
|
"language": language,
|
|
}
|
|
|
|
return list(collected.values())
|
|
|
|
|
|
def _sync_pronunciation_overrides(pending: PendingJob) -> None:
|
|
pending.pronunciation_overrides = _collect_pronunciation_overrides(pending)
|
|
|
|
if not pending.pronunciation_overrides:
|
|
return
|
|
|
|
summary = pending.entity_summary or {}
|
|
manual_map: Dict[str, Mapping[str, Any]] = {}
|
|
for override in pending.manual_overrides or []:
|
|
if not isinstance(override, Mapping):
|
|
continue
|
|
normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "")
|
|
pronunciation_value = str(override.get("pronunciation") or "").strip()
|
|
if not normalized or not pronunciation_value:
|
|
continue
|
|
manual_map[normalized] = override
|
|
for group in ("people", "entities"):
|
|
entries = summary.get(group)
|
|
if not isinstance(entries, list):
|
|
continue
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "")
|
|
manual_override = manual_map.get(normalized)
|
|
if manual_override:
|
|
entry["override"] = {
|
|
"token": manual_override.get("token"),
|
|
"pronunciation": manual_override.get("pronunciation"),
|
|
"voice": manual_override.get("voice"),
|
|
"notes": manual_override.get("notes"),
|
|
"context": manual_override.get("context"),
|
|
"source": manual_override.get("source"),
|
|
}
|
|
|
|
|
|
def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
|
|
language = pending.language or "en"
|
|
result = extract_entities(chapters, language=language)
|
|
summary = dict(result.summary)
|
|
tokens: List[str] = []
|
|
for group in ("people", "entities"):
|
|
entries = summary.get(group)
|
|
if not isinstance(entries, list):
|
|
continue
|
|
for entry in entries:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
token_value = str(entry.get("normalized") or entry.get("label") or "").strip()
|
|
if token_value:
|
|
tokens.append(token_value)
|
|
|
|
overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens)
|
|
merged_summary = merge_override(summary, overrides_from_store)
|
|
if result.errors:
|
|
merged_summary["errors"] = list(result.errors)
|
|
merged_summary["cache_key"] = result.cache_key
|
|
pending.entity_summary = merged_summary
|
|
pending.entity_cache_key = result.cache_key
|
|
_sync_pronunciation_overrides(pending)
|
|
|
|
|
|
def _find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]:
|
|
for entry in pending.manual_overrides or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("id") == identifier or entry.get("normalized") == identifier:
|
|
return entry
|
|
return None
|
|
|
|
|
|
def _upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
|
token_value = str(payload.get("token") or "").strip()
|
|
if not token_value:
|
|
raise ValueError("Token is required")
|
|
pronunciation_value = str(payload.get("pronunciation") or "").strip()
|
|
voice_value = str(payload.get("voice") or "").strip()
|
|
notes_value = str(payload.get("notes") or "").strip()
|
|
context_value = str(payload.get("context") or "").strip()
|
|
normalized = payload.get("normalized") or normalize_entity_token(token_value)
|
|
if not normalized:
|
|
raise ValueError("Token is required")
|
|
|
|
existing = _find_manual_override(pending, payload.get("id", "")) or _find_manual_override(pending, normalized)
|
|
timestamp = time.time()
|
|
language = pending.language or "en"
|
|
|
|
if existing:
|
|
existing.update(
|
|
{
|
|
"token": token_value,
|
|
"normalized": normalized,
|
|
"pronunciation": pronunciation_value,
|
|
"voice": voice_value,
|
|
"notes": notes_value,
|
|
"context": context_value,
|
|
"updated_at": timestamp,
|
|
}
|
|
)
|
|
manual_entry = existing
|
|
else:
|
|
manual_entry = {
|
|
"id": payload.get("id") or uuid.uuid4().hex,
|
|
"token": token_value,
|
|
"normalized": normalized,
|
|
"pronunciation": pronunciation_value,
|
|
"voice": voice_value,
|
|
"notes": notes_value,
|
|
"context": context_value,
|
|
"language": language,
|
|
"source": payload.get("source") or "manual",
|
|
"created_at": timestamp,
|
|
"updated_at": timestamp,
|
|
}
|
|
if isinstance(pending.manual_overrides, list):
|
|
pending.manual_overrides.append(manual_entry)
|
|
else:
|
|
pending.manual_overrides = [manual_entry]
|
|
|
|
save_pronunciation_override(
|
|
language=language,
|
|
token=token_value,
|
|
pronunciation=pronunciation_value or None,
|
|
voice=voice_value or None,
|
|
notes=notes_value or None,
|
|
context=context_value or None,
|
|
)
|
|
|
|
_sync_pronunciation_overrides(pending)
|
|
return dict(manual_entry)
|
|
|
|
|
|
def _delete_manual_override(pending: PendingJob, override_id: str) -> bool:
|
|
if not override_id:
|
|
return False
|
|
entries = pending.manual_overrides or []
|
|
for index, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("id") == override_id:
|
|
token_value = entry.get("token") or ""
|
|
language = pending.language or "en"
|
|
delete_pronunciation_override(language=language, token=token_value)
|
|
entries.pop(index)
|
|
pending.manual_overrides = entries
|
|
_sync_pronunciation_overrides(pending)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
|
|
normalized_query = (query or "").strip()
|
|
summary_index = (pending.entity_summary or {}).get("index", {})
|
|
matches = search_entity_tokens(summary_index, normalized_query, limit=limit)
|
|
registry: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for entry in matches:
|
|
normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "")
|
|
if not normalized:
|
|
continue
|
|
registry.setdefault(
|
|
normalized,
|
|
{
|
|
"token": entry.get("token"),
|
|
"normalized": normalized,
|
|
"category": entry.get("category") or "entity",
|
|
"count": entry.get("count", 0),
|
|
"samples": entry.get("samples", []),
|
|
"source": "entity",
|
|
},
|
|
)
|
|
|
|
language = pending.language or "en"
|
|
store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit)
|
|
for entry in store_matches:
|
|
normalized = entry.get("normalized")
|
|
if not normalized:
|
|
continue
|
|
registry.setdefault(
|
|
normalized,
|
|
{
|
|
"token": entry.get("token"),
|
|
"normalized": normalized,
|
|
"category": "history",
|
|
"count": entry.get("usage_count", 0),
|
|
"samples": [entry.get("context")] if entry.get("context") else [],
|
|
"source": "history",
|
|
"pronunciation": entry.get("pronunciation"),
|
|
"voice": entry.get("voice"),
|
|
},
|
|
)
|
|
|
|
for entry in pending.manual_overrides or []:
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
normalized = entry.get("normalized")
|
|
if not normalized:
|
|
continue
|
|
registry.setdefault(
|
|
normalized,
|
|
{
|
|
"token": entry.get("token"),
|
|
"normalized": normalized,
|
|
"category": "manual",
|
|
"count": 0,
|
|
"samples": [entry.get("context")] if entry.get("context") else [],
|
|
"source": "manual",
|
|
"pronunciation": entry.get("pronunciation"),
|
|
"voice": entry.get("voice"),
|
|
},
|
|
)
|
|
|
|
ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or ""))
|
|
if limit:
|
|
return ordered[:limit]
|
|
return ordered
|
|
|
|
|
|
def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
|
|
return {
|
|
"summary": pending.entity_summary or {},
|
|
"manual_overrides": pending.manual_overrides or [],
|
|
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
|
"cache_key": pending.entity_cache_key,
|
|
"language": pending.language or "en",
|
|
}
|
|
|
|
|
|
def _apply_prepare_form(
|
|
pending: PendingJob, form: Mapping[str, Any]
|
|
) -> tuple[
|
|
ChunkLevel,
|
|
List[Dict[str, Any]],
|
|
List[Dict[str, Any]],
|
|
List[str],
|
|
int,
|
|
str,
|
|
bool,
|
|
bool,
|
|
]:
|
|
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
|
|
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
|
raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph"
|
|
pending.chunk_level = raw_chunk_level
|
|
chunk_level_literal = cast(ChunkLevel, pending.chunk_level)
|
|
|
|
raw_speaker_mode = (form.get("speaker_mode") or pending.speaker_mode or "single").strip().lower()
|
|
if raw_speaker_mode not in _SPEAKER_MODE_VALUES:
|
|
raw_speaker_mode = "single"
|
|
pending.speaker_mode = raw_speaker_mode
|
|
|
|
pending.generate_epub3 = _coerce_bool(form.get("generate_epub3"), False)
|
|
|
|
threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
|
|
raw_threshold = form.get("speaker_analysis_threshold")
|
|
if raw_threshold is not None:
|
|
pending.speaker_analysis_threshold = _coerce_int(
|
|
raw_threshold,
|
|
threshold_default,
|
|
minimum=1,
|
|
maximum=25,
|
|
)
|
|
else:
|
|
pending.speaker_analysis_threshold = threshold_default
|
|
|
|
if not pending.speakers:
|
|
narrator: Dict[str, Any] = {
|
|
"id": "narrator",
|
|
"label": "Narrator",
|
|
"voice": pending.voice,
|
|
}
|
|
if pending.voice_profile:
|
|
narrator["voice_profile"] = pending.voice_profile
|
|
pending.speakers = {"narrator": narrator}
|
|
else:
|
|
existing_narrator = pending.speakers.get("narrator")
|
|
if isinstance(existing_narrator, dict):
|
|
existing_narrator.setdefault("id", "narrator")
|
|
existing_narrator["label"] = existing_narrator.get("label", "Narrator")
|
|
existing_narrator["voice"] = pending.voice
|
|
if pending.voice_profile:
|
|
existing_narrator["voice_profile"] = pending.voice_profile
|
|
pending.speakers["narrator"] = existing_narrator
|
|
|
|
selected_config = (form.get("applied_speaker_config") or "").strip()
|
|
apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
|
|
persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
|
|
|
|
pending.applied_speaker_config = selected_config or None
|
|
|
|
errors: List[str] = []
|
|
|
|
if isinstance(pending.speakers, dict):
|
|
for speaker_id, payload in list(pending.speakers.items()):
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
field_key = f"speaker-{speaker_id}-pronunciation"
|
|
raw_value = form.get(field_key, "")
|
|
pronunciation = raw_value.strip()
|
|
if pronunciation:
|
|
payload["pronunciation"] = pronunciation
|
|
else:
|
|
payload.pop("pronunciation", None)
|
|
|
|
voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip()
|
|
formula_key = f"speaker-{speaker_id}-formula"
|
|
formula_value = (form.get(formula_key) or "").strip()
|
|
has_formula = False
|
|
if formula_value:
|
|
try:
|
|
_parse_voice_formula(formula_value)
|
|
except ValueError as exc:
|
|
label = payload.get("label") or speaker_id.replace("_", " ").title()
|
|
errors.append(f"Invalid custom mix for {label}: {exc}")
|
|
else:
|
|
payload["voice_formula"] = formula_value
|
|
payload["resolved_voice"] = formula_value
|
|
payload.pop("voice_profile", None)
|
|
has_formula = True
|
|
else:
|
|
payload.pop("voice_formula", None)
|
|
|
|
if voice_value == "__custom_mix":
|
|
voice_value = ""
|
|
|
|
if voice_value:
|
|
payload["voice"] = voice_value
|
|
if not has_formula:
|
|
payload["resolved_voice"] = voice_value
|
|
else:
|
|
payload.pop("voice", None)
|
|
if not has_formula:
|
|
payload.pop("resolved_voice", None)
|
|
|
|
lang_key = f"speaker-{speaker_id}-languages"
|
|
languages: List[str] = []
|
|
getter = getattr(form, "getlist", None)
|
|
if callable(getter):
|
|
values = cast(Iterable[str], getter(lang_key))
|
|
languages = [code.strip() for code in values if code]
|
|
else:
|
|
raw_langs = form.get(lang_key)
|
|
if isinstance(raw_langs, str):
|
|
languages = [item.strip() for item in raw_langs.split(",") if item.strip()]
|
|
payload["config_languages"] = languages
|
|
|
|
profiles = serialize_profiles()
|
|
raw_delay = form.get("chapter_intro_delay")
|
|
if raw_delay is not None:
|
|
raw_normalized = raw_delay.strip()
|
|
if raw_normalized:
|
|
try:
|
|
pending.chapter_intro_delay = max(0.0, float(raw_normalized))
|
|
except ValueError:
|
|
errors.append("Enter a valid number for the chapter intro delay.")
|
|
else:
|
|
pending.chapter_intro_delay = 0.0
|
|
|
|
overrides: List[Dict[str, Any]] = []
|
|
selected_total = 0
|
|
|
|
for index, chapter in enumerate(pending.chapters):
|
|
enabled = form.get(f"chapter-{index}-enabled") == "on"
|
|
title_input = (form.get(f"chapter-{index}-title") or "").strip()
|
|
title = title_input or chapter.get("title") or f"Chapter {index + 1}"
|
|
voice_selection = form.get(f"chapter-{index}-voice", "__default")
|
|
formula_input = (form.get(f"chapter-{index}-formula") or "").strip()
|
|
|
|
entry: Dict[str, Any] = {
|
|
"id": chapter.get("id") or f"{index:04d}",
|
|
"index": index,
|
|
"order": index,
|
|
"source_title": chapter.get("title") or title,
|
|
"title": title,
|
|
"text": chapter.get("text", ""),
|
|
"enabled": enabled,
|
|
}
|
|
entry["characters"] = calculate_text_length(entry["text"])
|
|
|
|
if enabled:
|
|
if voice_selection.startswith("voice:"):
|
|
entry["voice"] = voice_selection.split(":", 1)[1]
|
|
entry["resolved_voice"] = entry["voice"]
|
|
elif voice_selection.startswith("profile:"):
|
|
profile_name = voice_selection.split(":", 1)[1]
|
|
entry["voice_profile"] = profile_name
|
|
profile_entry = profiles.get(profile_name) or {}
|
|
formula_value = _formula_from_profile(profile_entry)
|
|
if formula_value:
|
|
entry["voice_formula"] = formula_value
|
|
entry["resolved_voice"] = formula_value
|
|
else:
|
|
errors.append(f"Profile '{profile_name}' has no configured voices.")
|
|
elif voice_selection == "formula":
|
|
if not formula_input:
|
|
errors.append(f"Provide a custom formula for chapter {index + 1}.")
|
|
else:
|
|
try:
|
|
_parse_voice_formula(formula_input)
|
|
except ValueError as exc:
|
|
errors.append(str(exc))
|
|
else:
|
|
entry["voice_formula"] = formula_input
|
|
entry["resolved_voice"] = formula_input
|
|
selected_total += entry["characters"]
|
|
|
|
overrides.append(entry)
|
|
pending.chapters[index] = dict(entry)
|
|
|
|
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
|
|
|
|
_sync_pronunciation_overrides(pending)
|
|
|
|
return (
|
|
chunk_level_literal,
|
|
overrides,
|
|
enabled_overrides,
|
|
errors,
|
|
selected_total,
|
|
selected_config,
|
|
apply_config_requested,
|
|
persist_config_requested,
|
|
)
|
|
_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
|
|
(re.compile(r"\btitle\s+page\b"), 3.0),
|
|
(re.compile(r"\bcopyright\b"), 2.4),
|
|
(re.compile(r"\btable\s+of\s+contents\b"), 2.8),
|
|
(re.compile(r"\bcontents\b"), 2.0),
|
|
(re.compile(r"\backnowledg(e)?ments?\b"), 2.0),
|
|
(re.compile(r"\bdedication\b"), 2.0),
|
|
(re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4),
|
|
(re.compile(r"\balso\s+by\b"), 2.0),
|
|
(re.compile(r"\bpraise\s+for\b"), 2.0),
|
|
(re.compile(r"\bcolophon\b"), 2.2),
|
|
(re.compile(r"\bpublication\s+data\b"), 2.2),
|
|
(re.compile(r"\btranscriber'?s?\s+note\b"), 2.2),
|
|
(re.compile(r"\bglossary\b"), 2.0),
|
|
(re.compile(r"\bindex\b"), 2.0),
|
|
(re.compile(r"\bbibliograph(y|ies)\b"), 2.0),
|
|
(re.compile(r"\breferences\b"), 1.8),
|
|
(re.compile(r"\bappendix\b"), 1.9),
|
|
]
|
|
|
|
_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [
|
|
re.compile(r"\bchapter\b"),
|
|
re.compile(r"\bbook\b"),
|
|
re.compile(r"\bpart\b"),
|
|
re.compile(r"\bsection\b"),
|
|
re.compile(r"\bscene\b"),
|
|
re.compile(r"\bprologue\b"),
|
|
re.compile(r"\bepilogue\b"),
|
|
re.compile(r"\bintroduction\b"),
|
|
re.compile(r"\bstory\b"),
|
|
]
|
|
|
|
_SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [
|
|
("copyright", 1.2),
|
|
("all rights reserved", 1.1),
|
|
("isbn", 0.9),
|
|
("library of congress", 1.0),
|
|
("table of contents", 1.0),
|
|
("dedicated to", 0.8),
|
|
("acknowledg", 0.8),
|
|
("printed in", 0.6),
|
|
("permission", 0.6),
|
|
("publisher", 0.5),
|
|
("praise for", 0.9),
|
|
("also by", 0.9),
|
|
("glossary", 0.8),
|
|
("index", 0.8),
|
|
("newsletter", 3.2),
|
|
("mailing list", 2.6),
|
|
("sign-up", 2.2),
|
|
]
|
|
|
|
|
|
def _supplement_score(title: str, text: str, index: int) -> float:
|
|
normalized_title = (title or "").lower()
|
|
score = 0.0
|
|
|
|
for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS:
|
|
if pattern.search(normalized_title):
|
|
score += weight
|
|
|
|
for pattern in _CONTENT_TITLE_PATTERNS:
|
|
if pattern.search(normalized_title):
|
|
score -= 2.0
|
|
|
|
stripped_text = (text or "").strip()
|
|
length = len(stripped_text)
|
|
if length <= 150:
|
|
score += 0.9
|
|
elif length <= 400:
|
|
score += 0.6
|
|
elif length <= 800:
|
|
score += 0.35
|
|
|
|
lowercase_text = stripped_text.lower()
|
|
for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS:
|
|
if keyword in lowercase_text:
|
|
score += weight
|
|
|
|
if index == 0 and score > 0:
|
|
score += 0.25
|
|
|
|
return score
|
|
|
|
|
|
def _should_preselect_chapter(
|
|
title: str,
|
|
text: str,
|
|
index: int,
|
|
total_count: int,
|
|
) -> bool:
|
|
if total_count <= 1:
|
|
return True
|
|
score = _supplement_score(title, text, index)
|
|
return score < 1.9
|
|
|
|
|
|
def _ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None:
|
|
if not chapters:
|
|
return
|
|
if any(chapter.get("enabled") for chapter in chapters):
|
|
return
|
|
best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0))
|
|
chapters[best_index]["enabled"] = True
|
|
|
|
|
|
def _service() -> ConversionService:
|
|
return current_app.extensions["conversion_service"]
|
|
|
|
|
|
def _require_pending_job(pending_id: str) -> PendingJob:
|
|
pending = _service().get_pending_job(pending_id)
|
|
if not pending:
|
|
abort(404)
|
|
return cast(PendingJob, pending)
|
|
|
|
|
|
def _build_voice_catalog() -> List[Dict[str, str]]:
|
|
catalog: List[Dict[str, str]] = []
|
|
gender_map = {"f": "Female", "m": "Male"}
|
|
for voice_id in VOICES_INTERNAL:
|
|
prefix, _, rest = voice_id.partition("_")
|
|
language_code = prefix[0] if prefix else "a"
|
|
gender_code = prefix[1] if len(prefix) > 1 else ""
|
|
catalog.append(
|
|
{
|
|
"id": voice_id,
|
|
"language": language_code,
|
|
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
|
|
"gender": gender_map.get(gender_code, "Unknown"),
|
|
"gender_code": gender_code,
|
|
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
|
}
|
|
)
|
|
return catalog
|
|
|
|
|
|
def _template_options() -> Dict[str, Any]:
|
|
current_settings = _load_settings()
|
|
profiles = serialize_profiles()
|
|
ordered_profiles = sorted(profiles.items())
|
|
profile_options = []
|
|
for name, entry in ordered_profiles:
|
|
profile_options.append(
|
|
{
|
|
"name": name,
|
|
"language": (entry or {}).get("language", ""),
|
|
"formula": _formula_from_profile(entry or {}) or "",
|
|
}
|
|
)
|
|
voice_catalog = _build_voice_catalog()
|
|
return {
|
|
"languages": LANGUAGE_DESCRIPTIONS,
|
|
"voices": VOICES_INTERNAL,
|
|
"subtitle_formats": SUBTITLE_FORMATS,
|
|
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
|
"output_formats": SUPPORTED_SOUND_FORMATS,
|
|
"voice_profiles": ordered_profiles,
|
|
"voice_profile_options": profile_options,
|
|
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
|
"voice_catalog": voice_catalog,
|
|
"voice_catalog_map": {entry["id"]: entry for entry in voice_catalog},
|
|
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
|
|
"voice_profiles_data": profiles,
|
|
"speaker_configs": list_configs(),
|
|
"chunk_levels": _CHUNK_LEVEL_OPTIONS,
|
|
"speaker_modes": _SPEAKER_MODE_OPTIONS,
|
|
"speaker_analysis_threshold": current_settings.get(
|
|
"speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD
|
|
),
|
|
"speaker_pronunciation_sentence": current_settings.get(
|
|
"speaker_pronunciation_sentence", _settings_defaults()["speaker_pronunciation_sentence"]
|
|
),
|
|
}
|
|
|
|
|
|
SAVE_MODE_LABELS = {
|
|
"save_next_to_input": "Save next to input file",
|
|
"save_to_desktop": "Save to Desktop",
|
|
"choose_output_folder": "Choose output folder",
|
|
"default_output": "Use default save location",
|
|
}
|
|
|
|
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
|
|
|
|
BOOLEAN_SETTINGS = {
|
|
"replace_single_newlines",
|
|
"use_gpu",
|
|
"save_chapters_separately",
|
|
"merge_chapters_at_end",
|
|
"save_as_project",
|
|
"generate_epub3",
|
|
}
|
|
|
|
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"}
|
|
INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"}
|
|
|
|
|
|
def _has_output_override() -> bool:
|
|
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
|
|
|
|
|
|
def _settings_defaults() -> Dict[str, Any]:
|
|
return {
|
|
"output_format": "wav",
|
|
"subtitle_format": "srt",
|
|
"save_mode": "default_output" if _has_output_override() else "save_next_to_input",
|
|
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
|
"replace_single_newlines": False,
|
|
"use_gpu": True,
|
|
"save_chapters_separately": False,
|
|
"merge_chapters_at_end": True,
|
|
"save_as_project": False,
|
|
"separate_chapters_format": "wav",
|
|
"silence_between_chapters": 2.0,
|
|
"chapter_intro_delay": 0.5,
|
|
"max_subtitle_words": 50,
|
|
"chunk_level": "paragraph",
|
|
"speaker_mode": "single",
|
|
"generate_epub3": False,
|
|
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
|
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
|
|
"speaker_random_languages": [],
|
|
}
|
|
|
|
|
|
def _coerce_bool(value: Any, default: bool) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
return value.lower() in {"true", "1", "yes", "on"}
|
|
if value is None:
|
|
return default
|
|
return bool(value)
|
|
|
|
|
|
def _coerce_float(value: Any, default: float) -> float:
|
|
try:
|
|
return max(0.0, float(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(minimum, min(parsed, maximum))
|
|
|
|
|
|
def _normalize_save_mode(value: Any, default: str) -> str:
|
|
if isinstance(value, str):
|
|
if value in SAVE_MODE_LABELS:
|
|
return value
|
|
if value in LEGACY_SAVE_MODE_MAP:
|
|
return LEGACY_SAVE_MODE_MAP[value]
|
|
return default
|
|
|
|
|
|
def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
|
|
if key in BOOLEAN_SETTINGS:
|
|
return _coerce_bool(value, defaults[key])
|
|
if key in FLOAT_SETTINGS:
|
|
return _coerce_float(value, defaults[key])
|
|
if key in INT_SETTINGS:
|
|
return _coerce_int(value, defaults[key])
|
|
if key == "save_mode":
|
|
return _normalize_save_mode(value, defaults[key])
|
|
if key == "output_format":
|
|
return value if value in SUPPORTED_SOUND_FORMATS else defaults[key]
|
|
if key == "subtitle_format":
|
|
valid = {item[0] for item in SUBTITLE_FORMATS}
|
|
return value if value in valid else defaults[key]
|
|
if key == "separate_chapters_format":
|
|
if isinstance(value, str):
|
|
normalized = value.lower()
|
|
if normalized in {"wav", "flac", "mp3", "opus"}:
|
|
return normalized
|
|
return defaults[key]
|
|
if key == "default_voice":
|
|
if isinstance(value, str) and value in VOICES_INTERNAL:
|
|
return value
|
|
return defaults[key]
|
|
if key == "chunk_level":
|
|
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
|
return value
|
|
return defaults[key]
|
|
if key == "speaker_mode":
|
|
if isinstance(value, str) and value in _SPEAKER_MODE_VALUES:
|
|
return value
|
|
return defaults[key]
|
|
if key == "speaker_random_languages":
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
|
|
if isinstance(value, str):
|
|
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
|
|
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
|
return defaults.get(key, [])
|
|
return value if value is not None else defaults.get(key)
|
|
|
|
|
|
def _load_settings() -> Dict[str, Any]:
|
|
defaults = _settings_defaults()
|
|
cfg = load_config() or {}
|
|
settings: Dict[str, Any] = {}
|
|
for key, default in defaults.items():
|
|
raw_value = cfg.get(key, default)
|
|
settings[key] = _normalize_setting_value(key, raw_value, defaults)
|
|
return settings
|
|
|
|
def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
|
voices = entry.get("voices") or []
|
|
if not voices:
|
|
return None
|
|
total = sum(weight for _, weight in voices)
|
|
if total <= 0:
|
|
return None
|
|
|
|
def _format_weight(value: float) -> str:
|
|
normalized = value / total if total else 0.0
|
|
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
|
|
|
parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
|
|
return "+".join(parts) if parts else None
|
|
|
|
|
|
def _resolve_voice_choice(
|
|
language: str,
|
|
base_voice: str,
|
|
profile_name: str,
|
|
custom_formula: str,
|
|
profiles: Dict[str, Any],
|
|
) -> tuple[str, str, Optional[str]]:
|
|
resolved_voice = base_voice
|
|
resolved_language = language
|
|
selected_profile = None
|
|
|
|
if profile_name:
|
|
entry = profiles.get(profile_name)
|
|
formula = _formula_from_profile(entry or {}) if entry else None
|
|
if formula:
|
|
resolved_voice = formula
|
|
selected_profile = profile_name
|
|
profile_language = (entry or {}).get("language")
|
|
if profile_language:
|
|
resolved_language = profile_language
|
|
|
|
if custom_formula:
|
|
resolved_voice = custom_formula
|
|
selected_profile = None
|
|
|
|
return resolved_voice, resolved_language, selected_profile
|
|
|
|
|
|
def _persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]:
|
|
cover_bytes = getattr(extraction_result, "cover_image", None)
|
|
if not cover_bytes:
|
|
return None, None
|
|
|
|
mime = getattr(extraction_result, "cover_mime", None)
|
|
extension = mimetypes.guess_extension(mime or "") or ".png"
|
|
base_stem = Path(stored_path).stem or "cover"
|
|
candidate = stored_path.parent / f"{base_stem}_cover{extension}"
|
|
counter = 1
|
|
while candidate.exists():
|
|
candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}"
|
|
counter += 1
|
|
|
|
try:
|
|
candidate.write_bytes(cover_bytes)
|
|
except OSError:
|
|
return None, None
|
|
|
|
return candidate, mime
|
|
|
|
|
|
def _parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
|
voices = parse_formula_terms(formula)
|
|
total = sum(weight for _, weight in voices)
|
|
if total <= 0:
|
|
raise ValueError("Voice weights must sum to a positive value")
|
|
return voices
|
|
|
|
|
|
def _sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
|
|
sanitized: List[Dict[str, Any]] = []
|
|
for entry in entries or []:
|
|
if isinstance(entry, dict):
|
|
voice_id = entry.get("id") or entry.get("voice")
|
|
if not voice_id:
|
|
continue
|
|
enabled = entry.get("enabled", True)
|
|
if not enabled:
|
|
continue
|
|
sanitized.append({"voice": voice_id, "weight": entry.get("weight")})
|
|
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
|
sanitized.append({"voice": entry[0], "weight": entry[1]})
|
|
return sanitized
|
|
|
|
|
|
def _pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
|
|
voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0]
|
|
if not voices:
|
|
return None
|
|
total = sum(weight for _, weight in voices)
|
|
if total <= 0:
|
|
return None
|
|
|
|
def _format_value(value: float) -> str:
|
|
normalized = value / total if total else 0.0
|
|
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
|
|
|
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
|
|
return "+".join(parts)
|
|
|
|
|
|
def _profiles_payload() -> Dict[str, Any]:
|
|
return {"profiles": serialize_profiles()}
|
|
|
|
|
|
def _get_preview_pipeline(language: str, device: str):
|
|
key = (language, device)
|
|
with _preview_pipeline_lock:
|
|
pipeline = _preview_pipelines.get(key)
|
|
if pipeline is not None:
|
|
return pipeline
|
|
_, KPipeline = load_numpy_kpipeline()
|
|
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
|
_preview_pipelines[key] = pipeline
|
|
return pipeline
|
|
|
|
|
|
@web_bp.app_template_filter("datetimeformat")
|
|
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|
if not value:
|
|
return "—"
|
|
from datetime import datetime
|
|
|
|
return datetime.fromtimestamp(value).strftime(fmt)
|
|
|
|
|
|
@web_bp.get("/")
|
|
def index() -> str:
|
|
return render_template(
|
|
"index.html",
|
|
options=_template_options(),
|
|
settings=_load_settings(),
|
|
)
|
|
|
|
|
|
@web_bp.get("/queue")
|
|
def queue_page() -> ResponseReturnValue:
|
|
return render_template(
|
|
"queue.html",
|
|
jobs_panel=_render_jobs_panel(),
|
|
)
|
|
|
|
|
|
@web_bp.get("/find-books")
|
|
def find_books_page() -> ResponseReturnValue:
|
|
# Potential integration target: Standard Ebooks OPDS feed
|
|
# https://standardebooks.org/feeds/opds
|
|
# Potential integration target: Project Gutenberg OPDS search
|
|
# https://www.gutenberg.org/ebooks/search.opds/
|
|
return render_template("find_books.html")
|
|
|
|
|
|
@web_bp.route("/settings", methods=["GET", "POST"])
|
|
def settings_page() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
current_settings = _load_settings()
|
|
|
|
if request.method == "POST":
|
|
form = request.form
|
|
defaults = _settings_defaults()
|
|
updated: Dict[str, Any] = {}
|
|
|
|
updated["output_format"] = _normalize_setting_value(
|
|
"output_format", form.get("output_format"), defaults
|
|
)
|
|
updated["subtitle_format"] = _normalize_setting_value(
|
|
"subtitle_format", form.get("subtitle_format"), defaults
|
|
)
|
|
updated["save_mode"] = _normalize_setting_value(
|
|
"save_mode", form.get("save_mode"), defaults
|
|
)
|
|
updated["default_voice"] = _normalize_setting_value(
|
|
"default_voice", form.get("default_voice"), defaults
|
|
)
|
|
for key in sorted(BOOLEAN_SETTINGS):
|
|
updated[key] = _coerce_bool(form.get(key), False)
|
|
updated["chunk_level"] = _normalize_setting_value(
|
|
"chunk_level", form.get("chunk_level"), defaults
|
|
)
|
|
updated["speaker_mode"] = _normalize_setting_value(
|
|
"speaker_mode", form.get("speaker_mode"), defaults
|
|
)
|
|
updated["separate_chapters_format"] = _normalize_setting_value(
|
|
"separate_chapters_format", form.get("separate_chapters_format"), defaults
|
|
)
|
|
updated["silence_between_chapters"] = _coerce_float(
|
|
form.get("silence_between_chapters"), defaults["silence_between_chapters"]
|
|
)
|
|
updated["chapter_intro_delay"] = _coerce_float(
|
|
form.get("chapter_intro_delay"), defaults["chapter_intro_delay"]
|
|
)
|
|
updated["max_subtitle_words"] = _coerce_int(
|
|
form.get("max_subtitle_words"), defaults["max_subtitle_words"]
|
|
)
|
|
updated["speaker_analysis_threshold"] = _coerce_int(
|
|
form.get("speaker_analysis_threshold"),
|
|
defaults["speaker_analysis_threshold"],
|
|
minimum=1,
|
|
maximum=25,
|
|
)
|
|
sentence_value = (form.get("speaker_pronunciation_sentence") or "").strip()
|
|
if not sentence_value:
|
|
sentence_value = defaults["speaker_pronunciation_sentence"]
|
|
updated["speaker_pronunciation_sentence"] = sentence_value
|
|
|
|
random_languages = [
|
|
code.lower()
|
|
for code in form.getlist("speaker_random_languages")
|
|
if isinstance(code, str) and code.lower() in LANGUAGE_DESCRIPTIONS
|
|
]
|
|
updated["speaker_random_languages"] = random_languages
|
|
|
|
cfg = load_config() or {}
|
|
cfg.update(updated)
|
|
save_config(cfg)
|
|
return redirect(url_for("web.settings_page", saved="1"))
|
|
|
|
save_locations = [
|
|
{"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items()
|
|
]
|
|
context = {
|
|
"options": options,
|
|
"settings": current_settings,
|
|
"save_locations": save_locations,
|
|
"default_output_dir": get_user_output_path(),
|
|
"saved": request.args.get("saved") == "1",
|
|
}
|
|
return render_template("settings.html", **context)
|
|
|
|
|
|
@web_bp.get("/voices")
|
|
def voice_profiles_page() -> str:
|
|
options = _template_options()
|
|
return render_template("voices.html", options=options)
|
|
|
|
|
|
@web_bp.get("/entities")
|
|
def entities_page() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
settings = _load_settings()
|
|
languages_map = options.get("languages", {})
|
|
|
|
raw_language = (request.args.get("lang") or settings.get("language") or "a").strip().lower()
|
|
language = raw_language if raw_language in languages_map else "a"
|
|
|
|
query = (request.args.get("q") or "").strip()
|
|
voice_filter = (request.args.get("voice") or "all").strip().lower()
|
|
pronunciation_filter = (request.args.get("pronunciation") or "all").strip().lower()
|
|
limit_value = _coerce_int(request.args.get("limit"), 200, minimum=10, maximum=500)
|
|
|
|
if query:
|
|
overrides = search_pronunciation_overrides(language, query, limit=limit_value)
|
|
else:
|
|
overrides = all_pronunciation_overrides(language)
|
|
if limit_value and len(overrides) > limit_value:
|
|
overrides = overrides[:limit_value]
|
|
|
|
display_rows: List[Dict[str, Any]] = []
|
|
for entry in overrides:
|
|
has_voice = bool((entry.get("voice") or "").strip())
|
|
has_pronunciation = bool((entry.get("pronunciation") or "").strip())
|
|
if voice_filter == "with-voice" and not has_voice:
|
|
continue
|
|
if voice_filter == "without-voice" and has_voice:
|
|
continue
|
|
if pronunciation_filter == "with-pronunciation" and not has_pronunciation:
|
|
continue
|
|
if pronunciation_filter == "without-pronunciation" and has_pronunciation:
|
|
continue
|
|
row = dict(entry)
|
|
row["has_voice"] = has_voice
|
|
row["has_pronunciation"] = has_pronunciation
|
|
try:
|
|
updated_dt = datetime.fromtimestamp(float(entry.get("updated_at") or 0))
|
|
created_dt = datetime.fromtimestamp(float(entry.get("created_at") or 0))
|
|
except (TypeError, ValueError):
|
|
updated_dt = datetime.fromtimestamp(0)
|
|
created_dt = datetime.fromtimestamp(0)
|
|
row["updated_at_label"] = updated_dt.strftime("%Y-%m-%d %H:%M")
|
|
row["created_at_label"] = created_dt.strftime("%Y-%m-%d %H:%M")
|
|
display_rows.append(row)
|
|
|
|
stats = {
|
|
"total": len(overrides),
|
|
"filtered": len(display_rows),
|
|
"with_voice": sum(1 for row in display_rows if row["has_voice"]),
|
|
"with_pronunciation": sum(1 for row in display_rows if row["has_pronunciation"]),
|
|
}
|
|
|
|
language_options = sorted(languages_map.items(), key=lambda item: item[1])
|
|
voice_filters = [
|
|
{"value": "all", "label": "All voices"},
|
|
{"value": "with-voice", "label": "Assigned voice"},
|
|
{"value": "without-voice", "label": "No voice"},
|
|
]
|
|
pronunciation_filters = [
|
|
{"value": "all", "label": "All pronunciations"},
|
|
{"value": "with-pronunciation", "label": "Has pronunciation"},
|
|
{"value": "without-pronunciation", "label": "No pronunciation"},
|
|
]
|
|
|
|
context = {
|
|
"options": options,
|
|
"language": language,
|
|
"language_label": languages_map.get(language, language.upper()),
|
|
"languages": language_options,
|
|
"query": query,
|
|
"voice_filter": voice_filter,
|
|
"pronunciation_filter": pronunciation_filter,
|
|
"voice_filter_options": voice_filters,
|
|
"pronunciation_filter_options": pronunciation_filters,
|
|
"limit": limit_value,
|
|
"overrides": display_rows,
|
|
"stats": stats,
|
|
}
|
|
return render_template("entities.html", **context)
|
|
|
|
|
|
@web_bp.route("/speakers", methods=["GET", "POST"])
|
|
def speaker_configs_page() -> ResponseReturnValue:
|
|
options = _template_options()
|
|
configs = list_configs()
|
|
message = None
|
|
error = None
|
|
|
|
if request.method == "POST":
|
|
name, config_payload, errors = _extract_speaker_config_form(request.form)
|
|
editing_payload = config_payload
|
|
editing_name = name
|
|
if errors:
|
|
error = " ".join(errors)
|
|
context = {
|
|
"options": options,
|
|
"configs": configs,
|
|
"editing_name": editing_name,
|
|
"editing": editing_payload,
|
|
"message": message,
|
|
"error": error,
|
|
}
|
|
return render_template("speakers.html", **context)
|
|
upsert_config(name, config_payload)
|
|
return redirect(url_for("web.speaker_configs_page", config=name, saved="1"))
|
|
|
|
editing_name = request.args.get("config") or ""
|
|
editing_payload = get_config(editing_name) if editing_name else None
|
|
if editing_payload is None and configs:
|
|
editing_name = configs[0]["name"]
|
|
editing_payload = get_config(editing_name)
|
|
if editing_payload is None:
|
|
editing_payload = {
|
|
"language": "a",
|
|
"languages": [],
|
|
"default_voice": "",
|
|
"speakers": {},
|
|
"notes": "",
|
|
"version": 1,
|
|
}
|
|
|
|
if request.args.get("saved") == "1":
|
|
message = "Speaker configuration saved."
|
|
|
|
context = {
|
|
"options": options,
|
|
"configs": configs,
|
|
"editing_name": editing_name,
|
|
"editing": editing_payload,
|
|
"message": message,
|
|
"error": error,
|
|
}
|
|
return render_template("speakers.html", **context)
|
|
|
|
|
|
@web_bp.post("/speakers/<name>/delete")
|
|
def delete_speaker_config_route(name: str) -> ResponseReturnValue:
|
|
delete_config(name)
|
|
return redirect(url_for("web.speaker_configs_page"))
|
|
|
|
|
|
@web_bp.post("/voices")
|
|
def save_voice_profile_route() -> ResponseReturnValue:
|
|
name = request.form.get("name", "").strip()
|
|
language = request.form.get("language", "a").strip() or "a"
|
|
formula = request.form.get("formula", "").strip()
|
|
if not name or not formula:
|
|
abort(400, "Name and formula are required")
|
|
voices = _parse_voice_formula(formula)
|
|
profiles = load_profiles()
|
|
profiles[name] = {"voices": voices, "language": language}
|
|
save_profiles(profiles)
|
|
return redirect(url_for("web.voice_profiles_page"))
|
|
|
|
|
|
@web_bp.post("/voices/<name>/delete")
|
|
def delete_voice_profile_route(name: str) -> ResponseReturnValue:
|
|
delete_profile(name)
|
|
return redirect(url_for("web.voice_profiles_page"))
|
|
|
|
|
|
@api_bp.get("/voice-profiles")
|
|
def api_list_voice_profiles() -> ResponseReturnValue:
|
|
return jsonify(_profiles_payload())
|
|
|
|
|
|
@api_bp.post("/voice-profiles")
|
|
def api_save_voice_profile() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False)
|
|
name = (payload.get("name") or "").strip()
|
|
if not name:
|
|
abort(400, "Profile name is required")
|
|
|
|
original = (payload.get("originalName") or "").strip()
|
|
language = (payload.get("language") or "a").strip() or "a"
|
|
formula = (payload.get("formula") or "").strip()
|
|
|
|
try:
|
|
if formula:
|
|
voices = _parse_voice_formula(formula)
|
|
else:
|
|
voices_raw = _sanitize_voice_entries(payload.get("voices", []))
|
|
voices = normalize_voice_entries(voices_raw)
|
|
if not voices:
|
|
raise ValueError("At least one voice must be enabled with a weight above zero")
|
|
save_profile(name, language=language, voices=voices)
|
|
if original and original != name:
|
|
remove_profile(original)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
|
|
return jsonify({"ok": True, "profile": name, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.delete("/voice-profiles/<name>")
|
|
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
|
|
remove_profile(name)
|
|
return jsonify({"ok": True, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.post("/voice-profiles/<name>/duplicate")
|
|
def api_duplicate_voice_profile(name: str) -> ResponseReturnValue:
|
|
payload = request.get_json(silent=True) or {}
|
|
new_name = (payload.get("name") or payload.get("new_name") or "").strip()
|
|
if not new_name:
|
|
abort(400, "Duplicate name is required")
|
|
duplicate_profile(name, new_name)
|
|
return jsonify({"ok": True, "profile": new_name, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.post("/voice-profiles/import")
|
|
def api_import_voice_profiles() -> ResponseReturnValue:
|
|
replace = False
|
|
data: Optional[Dict[str, Any]] = None
|
|
if "file" in request.files:
|
|
file_storage = request.files["file"]
|
|
try:
|
|
file_storage.stream.seek(0)
|
|
raw_bytes = file_storage.read()
|
|
text_payload = raw_bytes.decode("utf-8")
|
|
data = json.loads(text_payload)
|
|
except UnicodeDecodeError as exc:
|
|
abort(400, f"JSON file must be UTF-8 encoded: {exc}")
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
abort(400, f"Invalid JSON file: {exc}")
|
|
replace = request.form.get("replace_existing") in {"true", "1", "on"}
|
|
else:
|
|
payload = request.get_json(force=True, silent=False)
|
|
replace = bool(payload.get("replace_existing", False))
|
|
data = payload.get("profiles") or payload.get("data") or payload
|
|
if not isinstance(data, dict):
|
|
data = None
|
|
if data is None:
|
|
abort(400, "Import payload must be a dictionary")
|
|
data_dict = cast(Dict[str, Any], data)
|
|
imported: List[str] = []
|
|
try:
|
|
imported = import_profiles_data(data_dict, replace_existing=replace)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
return jsonify({"ok": True, "imported": imported, **_profiles_payload()})
|
|
|
|
|
|
@api_bp.get("/voice-profiles/export")
|
|
def api_export_voice_profiles() -> ResponseReturnValue:
|
|
names_param = request.args.get("names")
|
|
names = None
|
|
if names_param:
|
|
names = [name.strip() for name in names_param.split(",") if name.strip()]
|
|
payload = export_profiles_payload(names)
|
|
buffer = io.BytesIO()
|
|
buffer.write(json.dumps(payload, indent=2).encode("utf-8"))
|
|
buffer.seek(0)
|
|
filename = request.args.get("filename") or "voice_profiles.json"
|
|
return send_file(
|
|
buffer,
|
|
mimetype="application/json",
|
|
as_attachment=True,
|
|
download_name=filename,
|
|
)
|
|
|
|
|
|
@api_bp.post("/voice-profiles/preview")
|
|
def api_preview_voice_mix() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False)
|
|
language = (payload.get("language") or "a").strip() or "a"
|
|
text = (payload.get("text") or "").strip()
|
|
speed = float(payload.get("speed", 1.0) or 1.0)
|
|
try:
|
|
requested_preview = float(payload.get("max_seconds", 60.0) or 60.0)
|
|
except (TypeError, ValueError):
|
|
requested_preview = 60.0
|
|
max_seconds = max(1.0, min(60.0, requested_preview))
|
|
profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip()
|
|
formula = (payload.get("formula") or "").strip()
|
|
|
|
voices: List[Tuple[str, float]] = []
|
|
if profile_name:
|
|
profiles = load_profiles()
|
|
entry = profiles.get(profile_name)
|
|
if entry is None:
|
|
abort(404, "Profile not found")
|
|
if not isinstance(entry, dict):
|
|
abort(400, "Profile data is invalid")
|
|
entry_dict = cast(Dict[str, Any], entry)
|
|
language = entry_dict.get("language", language)
|
|
profile_voices = entry_dict.get("voices", [])
|
|
for item in profile_voices:
|
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
try:
|
|
voices.append((str(item[0]), float(item[1])))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
else:
|
|
try:
|
|
if formula:
|
|
voices = _parse_voice_formula(formula)
|
|
else:
|
|
voices_raw = _sanitize_voice_entries(payload.get("voices", []))
|
|
voices = normalize_voice_entries(voices_raw)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
|
|
if not voices:
|
|
abort(400, "At least one voice must be provided for preview")
|
|
|
|
if not text:
|
|
text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice."))
|
|
|
|
settings = _load_settings()
|
|
use_gpu_default = settings.get("use_gpu", True)
|
|
if "use_gpu" in payload:
|
|
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
|
|
else:
|
|
use_gpu = use_gpu_default
|
|
device = "cpu"
|
|
if use_gpu:
|
|
try:
|
|
device = _select_device()
|
|
except Exception: # pragma: no cover - fallback
|
|
device = "cpu"
|
|
use_gpu = False
|
|
|
|
pipeline: Any = None
|
|
try:
|
|
pipeline = _get_preview_pipeline(language, device)
|
|
except Exception as exc: # pragma: no cover - defensive guard
|
|
abort(500, f"Failed to initialise preview pipeline: {exc}")
|
|
if pipeline is None: # pragma: no cover - defensive double-check
|
|
abort(500, "Preview pipeline initialisation failed")
|
|
|
|
voice_choice: Any = None
|
|
if len(voices) == 1:
|
|
voice_choice = voices[0][0]
|
|
else:
|
|
formula_value = _pairs_to_formula(voices)
|
|
if not formula_value:
|
|
abort(400, "Invalid voice weights provided")
|
|
try:
|
|
voice_choice = get_new_voice(pipeline, formula_value, use_gpu)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
if voice_choice is None:
|
|
abort(400, "Unable to resolve voice selection")
|
|
|
|
segments = pipeline(
|
|
text,
|
|
voice=voice_choice,
|
|
speed=speed,
|
|
split_pattern=SPLIT_PATTERN,
|
|
)
|
|
|
|
audio_chunks: List[np.ndarray] = []
|
|
accumulated = 0
|
|
max_samples = int(max_seconds * SAMPLE_RATE)
|
|
|
|
for segment in segments:
|
|
graphemes = segment.graphemes.strip()
|
|
if not graphemes:
|
|
continue
|
|
audio = _to_float32(segment.audio)
|
|
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:
|
|
abort(500, "Preview could not be generated")
|
|
|
|
audio_data = np.concatenate(audio_chunks)
|
|
buffer = io.BytesIO()
|
|
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
|
buffer.seek(0)
|
|
response = send_file(
|
|
buffer,
|
|
mimetype="audio/wav",
|
|
as_attachment=False,
|
|
download_name="voice_preview.wav",
|
|
)
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
@api_bp.post("/speaker-preview")
|
|
def api_speaker_preview() -> ResponseReturnValue:
|
|
payload = request.get_json(force=True, silent=False)
|
|
text = (payload.get("text") or "").strip()
|
|
voice_spec = (payload.get("voice") or "").strip()
|
|
language = (payload.get("language") or "a").strip() or "a"
|
|
speed_input = payload.get("speed", 1.0)
|
|
try:
|
|
speed = float(speed_input)
|
|
except (TypeError, ValueError):
|
|
speed = 1.0
|
|
max_seconds_input = payload.get("max_seconds", 8.0)
|
|
try:
|
|
max_seconds = max(1.0, min(15.0, float(max_seconds_input)))
|
|
except (TypeError, ValueError):
|
|
max_seconds = 8.0
|
|
|
|
if not text:
|
|
abort(400, "Preview text is required")
|
|
if not voice_spec:
|
|
abort(400, "Voice selection is required")
|
|
|
|
settings = _load_settings()
|
|
use_gpu_default = settings.get("use_gpu", True)
|
|
if "use_gpu" in payload:
|
|
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
|
|
else:
|
|
use_gpu = use_gpu_default
|
|
|
|
device = "cpu"
|
|
if use_gpu:
|
|
try:
|
|
device = _select_device()
|
|
except Exception: # pragma: no cover - fallback
|
|
device = "cpu"
|
|
use_gpu = False
|
|
|
|
try:
|
|
pipeline = _get_preview_pipeline(language, device)
|
|
except Exception as exc: # pragma: no cover - defensive guard
|
|
abort(500, f"Failed to initialise preview pipeline: {exc}")
|
|
if pipeline is None: # pragma: no cover - defensive double-check
|
|
abort(500, "Preview pipeline initialisation failed")
|
|
|
|
voice_choice: Any = voice_spec
|
|
if "*" in voice_spec:
|
|
try:
|
|
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
|
|
segments = pipeline(
|
|
text,
|
|
voice=voice_choice,
|
|
speed=speed,
|
|
split_pattern=SPLIT_PATTERN,
|
|
)
|
|
|
|
audio_chunks: List[np.ndarray] = []
|
|
accumulated = 0
|
|
max_samples = int(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:
|
|
abort(500, "Preview could not be generated")
|
|
|
|
audio_data = np.concatenate(audio_chunks)
|
|
buffer = io.BytesIO()
|
|
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
|
buffer.seek(0)
|
|
response = send_file(
|
|
buffer,
|
|
mimetype="audio/wav",
|
|
as_attachment=False,
|
|
download_name="speaker_preview.wav",
|
|
)
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
@api_bp.get("/pending/<pending_id>/entities")
|
|
def api_pending_entities(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
refresh_flag = (request.args.get("refresh") or "").strip().lower()
|
|
expected_cache = (request.args.get("cache_key") or "").strip()
|
|
refresh_requested = refresh_flag in {"1", "true", "yes", "force"}
|
|
if expected_cache and expected_cache != (pending.entity_cache_key or ""):
|
|
refresh_requested = True
|
|
if refresh_requested or not pending.entity_summary:
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
_service().store_pending_job(pending)
|
|
return jsonify(_pending_entities_payload(pending))
|
|
|
|
|
|
@api_bp.post("/pending/<pending_id>/entities/refresh")
|
|
def api_refresh_pending_entities(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
_service().store_pending_job(pending)
|
|
return jsonify(_pending_entities_payload(pending))
|
|
|
|
|
|
@api_bp.get("/pending/<pending_id>/manual-overrides")
|
|
def api_list_manual_overrides(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
return jsonify(
|
|
{
|
|
"overrides": pending.manual_overrides or [],
|
|
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
|
"language": pending.language or "en",
|
|
}
|
|
)
|
|
|
|
|
|
@api_bp.post("/pending/<pending_id>/manual-overrides")
|
|
def api_upsert_manual_override(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
payload = request.get_json(silent=True) or {}
|
|
if not isinstance(payload, Mapping):
|
|
abort(400, "Invalid override payload")
|
|
try:
|
|
override = _upsert_manual_override(pending, payload)
|
|
except ValueError as exc:
|
|
abort(400, str(exc))
|
|
_service().store_pending_job(pending)
|
|
return jsonify({"override": override, **_pending_entities_payload(pending)})
|
|
|
|
|
|
@api_bp.delete("/pending/<pending_id>/manual-overrides/<override_id>")
|
|
def api_delete_manual_override(pending_id: str, override_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
deleted = _delete_manual_override(pending, override_id)
|
|
if not deleted:
|
|
abort(404)
|
|
_service().store_pending_job(pending)
|
|
return jsonify({"deleted": True, **_pending_entities_payload(pending)})
|
|
|
|
|
|
@api_bp.get("/pending/<pending_id>/manual-overrides/search")
|
|
def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
query = (request.args.get("q") or request.args.get("query") or "").strip()
|
|
limit_param = request.args.get("limit")
|
|
limit_value = _coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15
|
|
results = _search_manual_override_candidates(pending, query, limit=limit_value)
|
|
return jsonify({"query": query, "limit": limit_value, "results": results})
|
|
|
|
|
|
@web_bp.post("/jobs")
|
|
def enqueue_job() -> ResponseReturnValue:
|
|
service = _service()
|
|
uploads_dir = Path(current_app.config["UPLOAD_FOLDER"])
|
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file = request.files.get("source_file")
|
|
text_input = request.form.get("source_text", "").strip()
|
|
|
|
if not file and not text_input:
|
|
return redirect(url_for("web.index"))
|
|
|
|
stored_path: Path
|
|
original_name: str
|
|
|
|
if file and file.filename:
|
|
filename = secure_filename(file.filename)
|
|
if not filename:
|
|
return redirect(url_for("web.index"))
|
|
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}"
|
|
file.save(stored_path)
|
|
original_name = filename
|
|
else:
|
|
original_name = "direct_text.txt"
|
|
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}"
|
|
stored_path.write_text(text_input, encoding="utf-8")
|
|
|
|
extraction = None
|
|
try:
|
|
extraction = extract_from_path(stored_path)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
try:
|
|
stored_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
abort(400, f"Unable to read the supplied content: {exc}")
|
|
|
|
if extraction is None: # pragma: no cover - defensive
|
|
abort(400, "Unable to read the supplied content")
|
|
|
|
assert extraction is not None
|
|
|
|
cover_path, cover_mime = _persist_cover_image(extraction, stored_path)
|
|
|
|
if extraction.chapters:
|
|
original_titles = [chapter.title for chapter in extraction.chapters]
|
|
normalized_titles = normalize_roman_numeral_titles(original_titles)
|
|
if normalized_titles != original_titles:
|
|
for chapter, new_title in zip(extraction.chapters, normalized_titles):
|
|
chapter.title = new_title
|
|
|
|
metadata_tags = extraction.metadata or {}
|
|
total_chars = extraction.total_characters or calculate_text_length(extraction.combined_text)
|
|
total_chapter_count = len(extraction.chapters)
|
|
chapters_payload: List[Dict[str, Any]] = []
|
|
for index, chapter in enumerate(extraction.chapters):
|
|
enabled = _should_preselect_chapter(
|
|
chapter.title,
|
|
chapter.text,
|
|
index,
|
|
total_chapter_count,
|
|
)
|
|
chapters_payload.append(
|
|
{
|
|
"id": f"{index:04d}",
|
|
"index": index,
|
|
"title": chapter.title,
|
|
"text": chapter.text,
|
|
"characters": calculate_text_length(chapter.text),
|
|
"enabled": enabled,
|
|
}
|
|
)
|
|
|
|
if not chapters_payload:
|
|
chapters_payload.append(
|
|
{
|
|
"id": "0000",
|
|
"index": 0,
|
|
"title": original_name,
|
|
"text": "",
|
|
"characters": 0,
|
|
"enabled": True,
|
|
}
|
|
)
|
|
|
|
_ensure_at_least_one_chapter_enabled(chapters_payload)
|
|
|
|
profiles = load_profiles()
|
|
settings = _load_settings()
|
|
|
|
language = request.form.get("language", "a")
|
|
base_voice = request.form.get("voice", "af_alloy")
|
|
profile_selection = (request.form.get("voice_profile") or "__standard").strip()
|
|
custom_formula_raw = request.form.get("voice_formula", "").strip()
|
|
selected_speaker_config = (request.form.get("speaker_config") or "").strip()
|
|
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
|
|
|
if profile_selection in {"__standard", ""}:
|
|
profile_name = ""
|
|
custom_formula = ""
|
|
elif profile_selection == "__formula":
|
|
profile_name = ""
|
|
custom_formula = custom_formula_raw
|
|
else:
|
|
profile_name = profile_selection
|
|
custom_formula = ""
|
|
|
|
voice, language, selected_profile = _resolve_voice_choice(
|
|
language,
|
|
base_voice,
|
|
profile_name,
|
|
custom_formula,
|
|
profiles,
|
|
)
|
|
speed = float(request.form.get("speed", "1.0"))
|
|
subtitle_mode = request.form.get("subtitle_mode", "Disabled")
|
|
output_format = settings["output_format"]
|
|
subtitle_format = settings["subtitle_format"]
|
|
save_mode_key = settings["save_mode"]
|
|
save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"])
|
|
replace_single_newlines = settings["replace_single_newlines"]
|
|
use_gpu = settings["use_gpu"]
|
|
save_chapters_separately = settings["save_chapters_separately"]
|
|
merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately
|
|
save_as_project = settings["save_as_project"]
|
|
separate_chapters_format = settings["separate_chapters_format"]
|
|
silence_between_chapters = settings["silence_between_chapters"]
|
|
chapter_intro_delay = settings["chapter_intro_delay"]
|
|
max_subtitle_words = settings["max_subtitle_words"]
|
|
|
|
chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
|
|
raw_chunk_level = (request.form.get("chunk_level") or chunk_level_default).strip().lower()
|
|
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
|
raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph"
|
|
chunk_level_value = raw_chunk_level
|
|
chunk_level_literal = cast(ChunkLevel, chunk_level_value)
|
|
|
|
speaker_mode_default = str(settings.get("speaker_mode", "single")).strip().lower()
|
|
raw_speaker_mode = (request.form.get("speaker_mode") or speaker_mode_default).strip().lower()
|
|
if raw_speaker_mode not in _SPEAKER_MODE_VALUES:
|
|
raw_speaker_mode = "single"
|
|
speaker_mode_value = raw_speaker_mode
|
|
|
|
generate_epub3_default = bool(settings.get("generate_epub3", False))
|
|
generate_epub3 = _coerce_bool(request.form.get("generate_epub3"), generate_epub3_default)
|
|
|
|
selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")]
|
|
raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal)
|
|
analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence")
|
|
|
|
analysis_threshold = _coerce_int(
|
|
settings.get("speaker_analysis_threshold"),
|
|
_DEFAULT_ANALYSIS_THRESHOLD,
|
|
minimum=1,
|
|
maximum=25,
|
|
)
|
|
|
|
initial_analysis = speaker_mode_value == "multi"
|
|
processed_chunks, speakers, analysis_payload, config_languages, _ = _prepare_speaker_metadata(
|
|
chapters=selected_chapter_sources,
|
|
chunks=raw_chunks,
|
|
analysis_chunks=analysis_chunks,
|
|
speaker_mode=speaker_mode_value,
|
|
voice=voice,
|
|
voice_profile=selected_profile or None,
|
|
threshold=analysis_threshold,
|
|
run_analysis=initial_analysis,
|
|
speaker_config=speaker_config_payload,
|
|
apply_config=bool(speaker_config_payload),
|
|
)
|
|
|
|
pending = PendingJob(
|
|
id=uuid.uuid4().hex,
|
|
original_filename=original_name,
|
|
stored_path=stored_path,
|
|
language=language,
|
|
voice=voice,
|
|
speed=speed,
|
|
use_gpu=use_gpu,
|
|
subtitle_mode=subtitle_mode,
|
|
output_format=output_format,
|
|
save_mode=save_mode,
|
|
output_folder=None,
|
|
replace_single_newlines=replace_single_newlines,
|
|
subtitle_format=subtitle_format,
|
|
total_characters=total_chars,
|
|
save_chapters_separately=save_chapters_separately,
|
|
merge_chapters_at_end=merge_chapters_at_end,
|
|
separate_chapters_format=separate_chapters_format,
|
|
silence_between_chapters=silence_between_chapters,
|
|
save_as_project=save_as_project,
|
|
voice_profile=selected_profile or None,
|
|
max_subtitle_words=max_subtitle_words,
|
|
metadata_tags=metadata_tags,
|
|
chapters=chapters_payload,
|
|
created_at=time.time(),
|
|
cover_image_path=cover_path,
|
|
cover_image_mime=cover_mime,
|
|
chapter_intro_delay=chapter_intro_delay,
|
|
chunk_level=chunk_level_value,
|
|
speaker_mode=speaker_mode_value,
|
|
generate_epub3=generate_epub3,
|
|
chunks=processed_chunks,
|
|
speakers=speakers,
|
|
speaker_analysis=analysis_payload,
|
|
speaker_analysis_threshold=analysis_threshold,
|
|
analysis_requested=initial_analysis,
|
|
)
|
|
|
|
_refresh_entity_summary(pending, pending.chapters)
|
|
|
|
service.store_pending_job(pending)
|
|
pending.applied_speaker_config = selected_speaker_config or None
|
|
if config_languages:
|
|
pending.speaker_voice_languages = list(config_languages)
|
|
elif isinstance(speaker_config_payload, Mapping):
|
|
languages = speaker_config_payload.get("languages")
|
|
if isinstance(languages, list):
|
|
pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)]
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(pending, "chapters", embed_scripts=False)
|
|
return redirect(url_for("web.prepare_job", pending_id=pending.id))
|
|
|
|
|
|
@web_bp.get("/jobs/prepare/<pending_id>")
|
|
def prepare_job(pending_id: str) -> ResponseReturnValue:
|
|
pending = _require_pending_job(pending_id)
|
|
requested_step = request.args.get("step") or "chapters"
|
|
normalized_step = _normalize_wizard_step(requested_step, pending)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(pending, normalized_step, embed_scripts=False)
|
|
return _render_prepare_page(pending, active_step=normalized_step)
|
|
|
|
|
|
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
|
|
def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|
service = _service()
|
|
pending = _require_pending_job(pending_id)
|
|
|
|
(
|
|
chunk_level_literal,
|
|
overrides,
|
|
enabled_overrides,
|
|
errors,
|
|
selected_total,
|
|
selected_config,
|
|
apply_config_requested,
|
|
persist_config_requested,
|
|
) = _apply_prepare_form(pending, request.form)
|
|
|
|
if errors:
|
|
message = " ".join(errors)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=message,
|
|
embed_scripts=False,
|
|
status=400,
|
|
)
|
|
return _render_prepare_page(pending, error=message, active_step="chapters")
|
|
|
|
if pending.speaker_mode != "multi":
|
|
setattr(pending, "analysis_requested", False)
|
|
pending.chunks = []
|
|
pending.speaker_analysis = {}
|
|
error_message = "Switch to multi-speaker mode to analyze speakers."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=error_message,
|
|
embed_scripts=False,
|
|
status=400,
|
|
)
|
|
return _render_prepare_page(
|
|
pending,
|
|
error=error_message,
|
|
active_step="chapters",
|
|
)
|
|
|
|
if not enabled_overrides:
|
|
setattr(pending, "analysis_requested", False)
|
|
pending.chunks = []
|
|
pending.speaker_analysis = {}
|
|
error_message = "Select at least one chapter to analyze."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=error_message,
|
|
embed_scripts=False,
|
|
status=400,
|
|
)
|
|
return _render_prepare_page(
|
|
pending,
|
|
error=error_message,
|
|
active_step="chapters",
|
|
)
|
|
|
|
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
|
|
|
existing_roster: Optional[Mapping[str, Any]]
|
|
if getattr(pending, "analysis_requested", False):
|
|
existing_roster = pending.speakers
|
|
else:
|
|
existing_roster = None
|
|
|
|
config_name = pending.applied_speaker_config or selected_config
|
|
speaker_config_payload = get_config(config_name) if config_name else None
|
|
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
|
|
chapters=enabled_overrides,
|
|
chunks=raw_chunks,
|
|
analysis_chunks=analysis_chunks,
|
|
speaker_mode=pending.speaker_mode,
|
|
voice=pending.voice,
|
|
voice_profile=pending.voice_profile,
|
|
threshold=pending.speaker_analysis_threshold,
|
|
existing_roster=existing_roster,
|
|
run_analysis=True,
|
|
speaker_config=speaker_config_payload,
|
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
|
persist_config=persist_config_requested,
|
|
)
|
|
|
|
pending.chunks = processed_chunks
|
|
pending.speakers = roster
|
|
pending.speaker_analysis = analysis_payload
|
|
if config_languages:
|
|
pending.speaker_voice_languages = list(config_languages)
|
|
config_name = getattr(pending, "applied_speaker_config", None)
|
|
if updated_config and isinstance(config_name, str) and config_name:
|
|
configs = load_configs()
|
|
configs[config_name] = updated_config
|
|
save_configs(configs)
|
|
setattr(pending, "analysis_requested", True)
|
|
if selected_total:
|
|
pending.total_characters = selected_total
|
|
|
|
_sync_pronunciation_overrides(pending)
|
|
|
|
service.store_pending_job(pending)
|
|
|
|
notice_message = "Entity insights updated."
|
|
if persist_config_requested and config_name:
|
|
notice_message = "Entity insights updated and configuration saved."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"entities",
|
|
notice=notice_message,
|
|
embed_scripts=False,
|
|
)
|
|
return _render_prepare_page(pending, notice=notice_message, active_step="entities")
|
|
|
|
|
|
@web_bp.post("/jobs/prepare/<pending_id>")
|
|
def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|
service = _service()
|
|
pending = _require_pending_job(pending_id)
|
|
|
|
(
|
|
chunk_level_literal,
|
|
overrides,
|
|
enabled_overrides,
|
|
errors,
|
|
selected_total,
|
|
selected_config,
|
|
apply_config_requested,
|
|
persist_config_requested,
|
|
) = _apply_prepare_form(pending, request.form)
|
|
|
|
if errors:
|
|
active_hint = request.form.get("active_step") or "entities"
|
|
normalized_step = _normalize_wizard_step(active_hint, pending)
|
|
message = " ".join(errors)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
normalized_step,
|
|
error=message,
|
|
embed_scripts=False,
|
|
status=400,
|
|
)
|
|
return _render_prepare_page(
|
|
pending,
|
|
error=message,
|
|
active_step=normalized_step,
|
|
)
|
|
|
|
if pending.speaker_mode != "multi":
|
|
setattr(pending, "analysis_requested", False)
|
|
|
|
if not enabled_overrides:
|
|
pending.chunks = []
|
|
error_message = "Select at least one chapter to convert."
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"chapters",
|
|
error=error_message,
|
|
embed_scripts=False,
|
|
status=400,
|
|
)
|
|
return _render_prepare_page(
|
|
pending,
|
|
error=error_message,
|
|
active_step="chapters",
|
|
)
|
|
|
|
active_step = (request.form.get("active_step") or "chapters").strip().lower()
|
|
if active_step == "speakers":
|
|
active_step = "entities"
|
|
|
|
normalized_step = _normalize_wizard_step(active_step, pending)
|
|
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
|
is_multi = pending.speaker_mode == "multi"
|
|
analysis_requested = bool(getattr(pending, "analysis_requested", False))
|
|
should_force_entities = is_multi and normalized_step != "entities"
|
|
|
|
if analysis_requested:
|
|
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
|
|
else:
|
|
narrator_only: Dict[str, Any] = {}
|
|
if isinstance(pending.speakers, dict):
|
|
narrator_payload = pending.speakers.get("narrator")
|
|
if isinstance(narrator_payload, Mapping):
|
|
narrator_only["narrator"] = dict(narrator_payload)
|
|
existing_roster = narrator_only or None
|
|
|
|
config_name = pending.applied_speaker_config or selected_config
|
|
speaker_config_payload = get_config(config_name) if config_name else None
|
|
run_analysis = is_multi and (should_force_entities or analysis_requested)
|
|
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
|
|
chapters=enabled_overrides,
|
|
chunks=raw_chunks,
|
|
analysis_chunks=analysis_chunks,
|
|
speaker_mode=pending.speaker_mode,
|
|
voice=pending.voice,
|
|
voice_profile=pending.voice_profile,
|
|
threshold=pending.speaker_analysis_threshold,
|
|
existing_roster=existing_roster,
|
|
run_analysis=run_analysis,
|
|
speaker_config=speaker_config_payload,
|
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
|
persist_config=persist_config_requested,
|
|
)
|
|
|
|
pending.chunks = processed_chunks
|
|
pending.speakers = roster
|
|
if analysis_payload:
|
|
pending.speaker_analysis = analysis_payload
|
|
if run_analysis:
|
|
setattr(pending, "analysis_requested", True)
|
|
|
|
if config_languages:
|
|
pending.speaker_voice_languages = list(config_languages)
|
|
config_key = getattr(pending, "applied_speaker_config", None)
|
|
if updated_config and isinstance(config_key, str) and config_key:
|
|
configs = load_configs()
|
|
configs[config_key] = updated_config
|
|
save_configs(configs)
|
|
|
|
if selected_total:
|
|
pending.total_characters = selected_total
|
|
|
|
_sync_pronunciation_overrides(pending)
|
|
|
|
requested_step = normalized_step
|
|
should_render_entities = should_force_entities or requested_step == "entities"
|
|
if should_render_entities:
|
|
notice_message = ""
|
|
if should_force_entities:
|
|
notice_message = "Review entity settings before queuing."
|
|
if persist_config_requested and config_key:
|
|
notice_message = "Configuration saved. Review entity settings before queuing."
|
|
elif persist_config_requested and config_key:
|
|
notice_message = "Configuration saved."
|
|
service.store_pending_job(pending)
|
|
if _wants_wizard_json():
|
|
return _wizard_json_response(
|
|
pending,
|
|
"entities",
|
|
notice=notice_message or None,
|
|
embed_scripts=False,
|
|
)
|
|
return _render_prepare_page(
|
|
pending,
|
|
notice=notice_message or None,
|
|
active_step="entities",
|
|
)
|
|
|
|
total_characters = selected_total or pending.total_characters
|
|
service.pop_pending_job(pending_id)
|
|
|
|
job = service.enqueue(
|
|
original_filename=pending.original_filename,
|
|
stored_path=pending.stored_path,
|
|
language=pending.language,
|
|
voice=pending.voice,
|
|
speed=pending.speed,
|
|
use_gpu=pending.use_gpu,
|
|
subtitle_mode=pending.subtitle_mode,
|
|
output_format=pending.output_format,
|
|
save_mode=pending.save_mode,
|
|
output_folder=pending.output_folder,
|
|
replace_single_newlines=pending.replace_single_newlines,
|
|
subtitle_format=pending.subtitle_format,
|
|
total_characters=total_characters,
|
|
chapters=overrides,
|
|
save_chapters_separately=pending.save_chapters_separately,
|
|
merge_chapters_at_end=pending.merge_chapters_at_end,
|
|
separate_chapters_format=pending.separate_chapters_format,
|
|
silence_between_chapters=pending.silence_between_chapters,
|
|
save_as_project=pending.save_as_project,
|
|
voice_profile=pending.voice_profile,
|
|
max_subtitle_words=pending.max_subtitle_words,
|
|
metadata_tags=pending.metadata_tags,
|
|
cover_image_path=pending.cover_image_path,
|
|
cover_image_mime=pending.cover_image_mime,
|
|
chapter_intro_delay=pending.chapter_intro_delay,
|
|
chunk_level=pending.chunk_level,
|
|
chunks=processed_chunks,
|
|
speakers=roster,
|
|
speaker_mode=pending.speaker_mode,
|
|
generate_epub3=pending.generate_epub3,
|
|
speaker_analysis=pending.speaker_analysis,
|
|
speaker_analysis_threshold=pending.speaker_analysis_threshold,
|
|
analysis_requested=getattr(pending, "analysis_requested", False),
|
|
entity_summary=pending.entity_summary,
|
|
manual_overrides=pending.manual_overrides,
|
|
pronunciation_overrides=pending.pronunciation_overrides,
|
|
)
|
|
|
|
if config_languages:
|
|
job.speaker_voice_languages = list(config_languages)
|
|
elif pending.speaker_voice_languages:
|
|
job.speaker_voice_languages = list(pending.speaker_voice_languages)
|
|
|
|
if isinstance(config_key, str) and config_key:
|
|
job.applied_speaker_config = config_key
|
|
|
|
redirect_url = url_for("web.index", _anchor="queue")
|
|
if _wants_wizard_json():
|
|
return jsonify({"redirect_url": redirect_url})
|
|
return redirect(redirect_url)
|
|
|
|
|
|
@web_bp.post("/jobs/prepare/<pending_id>/cancel")
|
|
def cancel_pending_job(pending_id: str) -> ResponseReturnValue:
|
|
pending = _service().pop_pending_job(pending_id)
|
|
if pending and pending.stored_path.exists():
|
|
try:
|
|
pending.stored_path.unlink()
|
|
except OSError:
|
|
pass
|
|
if pending and pending.cover_image_path and pending.cover_image_path.exists():
|
|
try:
|
|
pending.cover_image_path.unlink()
|
|
except OSError:
|
|
pass
|
|
redirect_url = url_for("web.index", _anchor="queue")
|
|
if _wants_wizard_json():
|
|
return jsonify({"cancelled": True, "redirect_url": redirect_url})
|
|
return redirect(redirect_url)
|
|
|
|
|
|
def _render_jobs_panel() -> str:
|
|
jobs = _service().list_jobs()
|
|
active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED}
|
|
active_jobs = [job for job in jobs if job.status in active_statuses]
|
|
active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at))
|
|
finished_jobs = [job for job in jobs if job.status not in active_statuses]
|
|
download_flags = {job.id: _job_download_flags(job) for job in jobs}
|
|
return render_template(
|
|
"partials/jobs.html",
|
|
active_jobs=active_jobs,
|
|
finished_jobs=finished_jobs[:5],
|
|
total_finished=len(finished_jobs),
|
|
JobStatus=JobStatus,
|
|
download_flags=download_flags,
|
|
)
|
|
|
|
|
|
def _normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str:
|
|
if pending is None:
|
|
default_step = "book"
|
|
else:
|
|
default_step = "chapters"
|
|
if not step:
|
|
chosen = default_step
|
|
else:
|
|
normalized = step.strip().lower()
|
|
if normalized in {"", "upload", "settings"}:
|
|
chosen = default_step
|
|
elif normalized == "speakers":
|
|
chosen = "entities"
|
|
elif normalized in _WIZARD_STEP_ORDER:
|
|
chosen = normalized
|
|
else:
|
|
chosen = default_step
|
|
if chosen == "entities" and pending is not None and pending.speaker_mode != "multi":
|
|
return "chapters"
|
|
return chosen
|
|
|
|
|
|
def _wants_wizard_json() -> bool:
|
|
format_hint = request.args.get("format", "").strip().lower()
|
|
if format_hint == "json":
|
|
return True
|
|
accept_header = (request.headers.get("Accept") or "").lower()
|
|
if "application/json" in accept_header:
|
|
return True
|
|
requested_with = (request.headers.get("X-Requested-With") or "").lower()
|
|
if requested_with in {"xmlhttprequest", "fetch"}:
|
|
return True
|
|
wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower()
|
|
return wizard_header == "json"
|
|
|
|
|
|
def _render_wizard_partial(
|
|
pending: Optional[PendingJob],
|
|
step: str,
|
|
*,
|
|
error: Optional[str] = None,
|
|
notice: Optional[str] = None,
|
|
embed_scripts: bool = False,
|
|
) -> str:
|
|
templates = {
|
|
"book": "partials/new_job_step_book.html",
|
|
"chapters": "partials/new_job_step_chapters.html",
|
|
"entities": "partials/new_job_step_entities.html",
|
|
}
|
|
template_name = templates[step]
|
|
context: Dict[str, Any] = {
|
|
"pending": pending,
|
|
"readonly": False,
|
|
"options": _template_options(),
|
|
"settings": _load_settings(),
|
|
"error": error,
|
|
"notice": notice,
|
|
"embed_scripts": embed_scripts,
|
|
}
|
|
return render_template(template_name, **context)
|
|
|
|
|
|
def _wizard_step_payload(
|
|
pending: Optional[PendingJob],
|
|
step: str,
|
|
html: str,
|
|
*,
|
|
error: Optional[str] = None,
|
|
notice: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
meta = _WIZARD_STEP_META.get(step, {})
|
|
try:
|
|
active_index = _WIZARD_STEP_ORDER.index(step)
|
|
except ValueError:
|
|
active_index = 0
|
|
completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx < active_index]
|
|
return {
|
|
"step": step,
|
|
"step_index": int(meta.get("index", active_index + 1)),
|
|
"total_steps": len(_WIZARD_STEP_ORDER),
|
|
"title": meta.get("title", ""),
|
|
"hint": meta.get("hint", ""),
|
|
"html": html,
|
|
"completed_steps": completed,
|
|
"pending_id": pending.id if pending else "",
|
|
"filename": pending.original_filename if pending and pending.original_filename else "",
|
|
"error": error or "",
|
|
"notice": notice or "",
|
|
}
|
|
|
|
|
|
def _wizard_json_response(
|
|
pending: Optional[PendingJob],
|
|
step: str,
|
|
*,
|
|
error: Optional[str] = None,
|
|
notice: Optional[str] = None,
|
|
embed_scripts: bool = False,
|
|
status: int = 200,
|
|
) -> ResponseReturnValue:
|
|
html = _render_wizard_partial(pending, step, error=error, notice=notice, embed_scripts=embed_scripts)
|
|
payload = _wizard_step_payload(pending, step, html, error=error, notice=notice)
|
|
return jsonify(payload), status
|
|
|
|
|
|
def _render_prepare_page(
|
|
pending: PendingJob,
|
|
*,
|
|
error: Optional[str] = None,
|
|
notice: Optional[str] = None,
|
|
active_step: Optional[str] = None,
|
|
) -> str:
|
|
if not active_step:
|
|
active_step = (
|
|
request.form.get("active_step")
|
|
if request.method == "POST"
|
|
else request.args.get("step")
|
|
) or "chapters"
|
|
|
|
normalized_step = _normalize_wizard_step(active_step, pending)
|
|
|
|
template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html"
|
|
|
|
return render_template(
|
|
template_name,
|
|
pending=pending,
|
|
options=_template_options(),
|
|
settings=_load_settings(),
|
|
error=error,
|
|
notice=notice,
|
|
active_step=normalized_step,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>")
|
|
def job_detail(job_id: str) -> str:
|
|
job = _service().get_job(job_id)
|
|
if not job:
|
|
abort(404)
|
|
return render_template(
|
|
"job_detail.html",
|
|
job=job,
|
|
options=_template_options(),
|
|
JobStatus=JobStatus,
|
|
downloads=_job_download_flags(job),
|
|
)
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/pause")
|
|
def pause_job(job_id: str) -> ResponseReturnValue:
|
|
_service().pause(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/resume")
|
|
def resume_job(job_id: str) -> ResponseReturnValue:
|
|
_service().resume(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/cancel")
|
|
def cancel_job(job_id: str) -> ResponseReturnValue:
|
|
_service().cancel(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/delete")
|
|
def delete_job(job_id: str) -> ResponseReturnValue:
|
|
_service().delete(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.index"))
|
|
|
|
|
|
@web_bp.post("/jobs/<job_id>/retry")
|
|
def retry_job(job_id: str) -> ResponseReturnValue:
|
|
new_job = _service().retry(job_id)
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
if new_job:
|
|
return redirect(url_for("web.job_detail", job_id=new_job.id))
|
|
return redirect(url_for("web.job_detail", job_id=job_id))
|
|
|
|
|
|
@web_bp.post("/jobs/clear-finished")
|
|
def clear_finished_jobs() -> ResponseReturnValue:
|
|
_service().clear_finished()
|
|
if request.headers.get("HX-Request"):
|
|
return _render_jobs_panel()
|
|
return redirect(url_for("web.index", _anchor="queue"))
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/epub")
|
|
def job_epub(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
epub_path = _locate_job_epub(job)
|
|
if not epub_path:
|
|
abort(404)
|
|
return send_file(
|
|
epub_path,
|
|
mimetype="application/epub+zip",
|
|
as_attachment=False,
|
|
download_name=epub_path.name,
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/audio-stream")
|
|
def job_audio_stream(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
audio_path = _locate_job_audio(job)
|
|
if not audio_path:
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=mime_type or "audio/mpeg",
|
|
as_attachment=False,
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/reader")
|
|
def job_reader(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
epub_path = _locate_job_epub(job)
|
|
if not epub_path:
|
|
abort(404)
|
|
chapters = _extract_epub_chapters(epub_path)
|
|
audio_path = _locate_job_audio(job)
|
|
chapter_url = url_for("web.job_reader_chapter", job_id=job.id)
|
|
asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/"
|
|
audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else ""
|
|
epub_url = url_for("web.job_epub", job_id=job.id)
|
|
return render_template(
|
|
"reader_embed.html",
|
|
job=job,
|
|
audio_url=audio_url,
|
|
epub_url=epub_url,
|
|
chapters=chapters,
|
|
chapter_url=chapter_url,
|
|
asset_base=asset_base,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/reader/chapter")
|
|
def job_reader_chapter(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
epub_path = _locate_job_epub(job)
|
|
if not epub_path:
|
|
abort(404)
|
|
raw_href = request.args.get("href", "").strip()
|
|
if not raw_href:
|
|
abort(400)
|
|
try:
|
|
chapter_bytes = _read_epub_bytes(epub_path, raw_href)
|
|
except (ValueError, FileNotFoundError, KeyError):
|
|
abort(404)
|
|
content = _decode_text(chapter_bytes)
|
|
return jsonify({"content": content})
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/reader/asset/<path:asset_path>")
|
|
def job_reader_asset(job_id: str, asset_path: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
epub_path = _locate_job_epub(job)
|
|
if not epub_path:
|
|
abort(404)
|
|
try:
|
|
payload = _read_epub_bytes(epub_path, asset_path)
|
|
except (ValueError, FileNotFoundError, KeyError):
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(asset_path)
|
|
buffer = io.BytesIO(payload)
|
|
buffer.seek(0)
|
|
return send_file(
|
|
buffer,
|
|
mimetype=mime_type or "application/octet-stream",
|
|
as_attachment=False,
|
|
download_name=posixpath.basename(asset_path) or "asset",
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/download")
|
|
def download_job(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
audio_path = _locate_job_audio(job)
|
|
if not audio_path:
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=mime_type or "application/octet-stream",
|
|
as_attachment=True,
|
|
download_name=audio_path.name,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/download/m4b")
|
|
def download_job_m4b(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
audio_path = _locate_job_m4b(job)
|
|
if not audio_path:
|
|
abort(404)
|
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
|
return send_file(
|
|
audio_path,
|
|
mimetype=mime_type or "audio/mpeg",
|
|
as_attachment=True,
|
|
download_name=audio_path.name,
|
|
)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/download/epub3")
|
|
def download_job_epub3(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None or job.status != JobStatus.COMPLETED:
|
|
abort(404)
|
|
epub_path = _locate_job_epub(job)
|
|
if not epub_path:
|
|
abort(404)
|
|
return send_file(
|
|
epub_path,
|
|
mimetype="application/epub+zip",
|
|
as_attachment=True,
|
|
download_name=epub_path.name,
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
@web_bp.get("/partials/jobs")
|
|
def jobs_partial() -> str:
|
|
return _render_jobs_panel()
|
|
|
|
@web_bp.get("/partials/jobs/<job_id>/logs")
|
|
def job_logs_partial(job_id: str) -> str:
|
|
job = _service().get_job(job_id)
|
|
if not job:
|
|
abort(404)
|
|
return render_template("partials/logs.html", job=job, static_view=False)
|
|
|
|
|
|
@web_bp.get("/jobs/<job_id>/logs/static")
|
|
def job_logs_static(job_id: str) -> str:
|
|
job = _service().get_job(job_id)
|
|
if not job:
|
|
abort(404)
|
|
log_lines = [
|
|
f"{datetime.fromtimestamp(entry.timestamp).strftime('%Y-%m-%d %H:%M:%S')} [{entry.level.upper()}] {entry.message}"
|
|
for entry in job.logs
|
|
]
|
|
return render_template(
|
|
"job_logs_static.html",
|
|
job=job,
|
|
log_text="\n".join(log_lines),
|
|
static_view=True,
|
|
)
|
|
|
|
|
|
@api_bp.get("/jobs/<job_id>")
|
|
def job_json(job_id: str) -> ResponseReturnValue:
|
|
job = _service().get_job(job_id)
|
|
if job is None:
|
|
abort(404)
|
|
if not isinstance(job, Job): # pragma: no cover - defensive guard
|
|
abort(404)
|
|
job_obj = cast(Job, job)
|
|
payload = job_obj.as_dict()
|
|
return jsonify(payload)
|