mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add speaker configuration management and UI enhancements
- Introduced a new speaker configuration page with the ability to create, edit, and delete speaker presets. - Added a step indicator to guide users through the audiobook workflow. - Enhanced the audiobook creation process by allowing users to select speaker presets and configure individual speaker settings. - Implemented dynamic UI elements for managing speaker rows, including adding and removing speakers. - Updated existing templates to integrate speaker configuration features and improve user experience. - Added JavaScript functionality for managing speaker rows and ensuring proper form handling. - Created a new module for handling speaker configuration data storage and retrieval.
This commit is contained in:
@@ -38,6 +38,36 @@ _PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE)
|
|||||||
_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]')
|
_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]')
|
||||||
_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE)
|
_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE)
|
||||||
_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE)
|
_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE)
|
||||||
|
_PRONOUN_LABELS = {
|
||||||
|
"he",
|
||||||
|
"she",
|
||||||
|
"they",
|
||||||
|
"them",
|
||||||
|
"theirs",
|
||||||
|
"their",
|
||||||
|
"themselves",
|
||||||
|
"him",
|
||||||
|
"his",
|
||||||
|
"himself",
|
||||||
|
"her",
|
||||||
|
"hers",
|
||||||
|
"herself",
|
||||||
|
"we",
|
||||||
|
"us",
|
||||||
|
"our",
|
||||||
|
"ours",
|
||||||
|
"ourselves",
|
||||||
|
"i",
|
||||||
|
"me",
|
||||||
|
"my",
|
||||||
|
"mine",
|
||||||
|
"myself",
|
||||||
|
"you",
|
||||||
|
"your",
|
||||||
|
"yours",
|
||||||
|
"yourself",
|
||||||
|
"yourselves",
|
||||||
|
}
|
||||||
|
|
||||||
_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
|
_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||||
|
|
||||||
@@ -216,8 +246,11 @@ def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optio
|
|||||||
colon_match = _COLON_PATTERN.match(normalized)
|
colon_match = _COLON_PATTERN.match(normalized)
|
||||||
if colon_match:
|
if colon_match:
|
||||||
raw_label = colon_match.group(1)
|
raw_label = colon_match.group(1)
|
||||||
|
cleaned = _normalize_candidate_name(raw_label)
|
||||||
|
if cleaned is None:
|
||||||
|
return None, "low", colon_match.group(2).strip()
|
||||||
quote = colon_match.group(2).strip()
|
quote = colon_match.group(2).strip()
|
||||||
return raw_label, "high", quote
|
return cleaned, "high", quote
|
||||||
|
|
||||||
quote = _extract_quote(normalized)
|
quote = _extract_quote(normalized)
|
||||||
if not quote:
|
if not quote:
|
||||||
@@ -227,7 +260,9 @@ def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optio
|
|||||||
|
|
||||||
candidate = _match_name_near_quote(before, after)
|
candidate = _match_name_near_quote(before, after)
|
||||||
if candidate:
|
if candidate:
|
||||||
return candidate, "high", quote
|
cleaned = _normalize_candidate_name(candidate)
|
||||||
|
if cleaned:
|
||||||
|
return cleaned, "high", quote
|
||||||
|
|
||||||
if last_explicit:
|
if last_explicit:
|
||||||
pronoun_after = _PRONOUN_PATTERN.search(after)
|
pronoun_after = _PRONOUN_PATTERN.search(after)
|
||||||
@@ -273,10 +308,13 @@ def _match_name_near_quote(before: str, after: str) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _looks_like_name(value: str) -> bool:
|
def _looks_like_name(value: str) -> bool:
|
||||||
parts = value.strip().split()
|
normalized = _normalize_candidate_name(value)
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
parts = normalized.split()
|
||||||
if not parts:
|
if not parts:
|
||||||
return False
|
return False
|
||||||
return all(part[0].isupper() for part in parts)
|
return all(part and part[0].isupper() for part in parts)
|
||||||
|
|
||||||
|
|
||||||
def _extract_quote(text: str) -> Optional[str]:
|
def _extract_quote(text: str) -> Optional[str]:
|
||||||
@@ -340,4 +378,17 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
|
|||||||
|
|
||||||
if current in {"male", "female"}:
|
if current in {"male", "female"}:
|
||||||
return current
|
return current
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_candidate_name(raw: str) -> Optional[str]:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
cleaned = raw.strip().strip('"“”\'’.,:;!')
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
lowered = cleaned.lower()
|
||||||
|
if lowered in _PRONOUN_LABELS:
|
||||||
|
return None
|
||||||
|
return cleaned
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from abogen.constants import LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL
|
||||||
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
|
_CONFIG_WRAPPER_KEY = "abogen_speaker_configs"
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> str:
|
||||||
|
config_path = get_user_config_path()
|
||||||
|
config_dir = os.path.dirname(config_path)
|
||||||
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
|
return os.path.join(config_dir, "speaker_configs.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_configs() -> Dict[str, Dict[str, Any]]:
|
||||||
|
path = _config_path()
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if isinstance(payload, dict) and _CONFIG_WRAPPER_KEY in payload:
|
||||||
|
payload = payload[_CONFIG_WRAPPER_KEY]
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
sanitized: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for name, entry in payload.items():
|
||||||
|
if not isinstance(name, str) or not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
sanitized[name] = _sanitize_config(entry)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def save_configs(configs: Dict[str, Dict[str, Any]]) -> None:
|
||||||
|
path = _config_path()
|
||||||
|
sanitized: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for name, entry in configs.items():
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
continue
|
||||||
|
sanitized[name] = _sanitize_config(entry)
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump({_CONFIG_WRAPPER_KEY: sanitized}, handle, indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
configs = load_configs()
|
||||||
|
data = configs.get(name)
|
||||||
|
return dict(data) if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_config(name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Configuration name is required")
|
||||||
|
configs = load_configs()
|
||||||
|
configs[name] = _sanitize_config(payload or {})
|
||||||
|
save_configs(configs)
|
||||||
|
return configs[name]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_config(name: str) -> None:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
configs = load_configs()
|
||||||
|
if name in configs:
|
||||||
|
del configs[name]
|
||||||
|
save_configs(configs)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_config(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
language = str(entry.get("language") or "a").strip() or "a"
|
||||||
|
speakers_raw = entry.get("speakers")
|
||||||
|
if not isinstance(speakers_raw, dict):
|
||||||
|
speakers_raw = {}
|
||||||
|
speakers: Dict[str, Any] = {}
|
||||||
|
for speaker_id, payload in speakers_raw.items():
|
||||||
|
if not isinstance(speaker_id, str) or not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
record = _sanitize_speaker({"id": speaker_id, **payload})
|
||||||
|
speakers[record["id"]] = record
|
||||||
|
allowed_languages = entry.get("languages") or entry.get("allowed_languages") or []
|
||||||
|
if not isinstance(allowed_languages, list):
|
||||||
|
allowed_languages = []
|
||||||
|
normalized_langs = []
|
||||||
|
for code in allowed_languages:
|
||||||
|
if isinstance(code, str) and code:
|
||||||
|
normalized_langs.append(code.lower())
|
||||||
|
default_voice = entry.get("default_voice")
|
||||||
|
if not isinstance(default_voice, str):
|
||||||
|
default_voice = ""
|
||||||
|
return {
|
||||||
|
"language": language.lower(),
|
||||||
|
"languages": normalized_langs,
|
||||||
|
"default_voice": default_voice,
|
||||||
|
"speakers": speakers,
|
||||||
|
"version": int(entry.get("version", 1)),
|
||||||
|
"notes": entry.get("notes") if isinstance(entry.get("notes"), str) else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def slugify_label(label: str) -> str:
|
||||||
|
normalized = (label or "").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return "speaker"
|
||||||
|
slug = "".join(ch if ch.isalnum() else "_" for ch in normalized)
|
||||||
|
slug = "_".join(filter(None, slug.split("_")))
|
||||||
|
return slug or "speaker"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
label = (entry.get("label") or entry.get("name") or "").strip()
|
||||||
|
gender = (entry.get("gender") or "unknown").strip().lower()
|
||||||
|
if gender not in {"male", "female", "unknown"}:
|
||||||
|
gender = "unknown"
|
||||||
|
voice = entry.get("voice")
|
||||||
|
voice_profile = entry.get("voice_profile")
|
||||||
|
voice_formula = entry.get("voice_formula")
|
||||||
|
voice_languages = entry.get("languages") or []
|
||||||
|
if not isinstance(voice_languages, list):
|
||||||
|
voice_languages = []
|
||||||
|
normalized_langs = []
|
||||||
|
for code in voice_languages:
|
||||||
|
if isinstance(code, str) and code:
|
||||||
|
normalized_langs.append(code.lower())
|
||||||
|
randomize = bool(entry.get("randomize"))
|
||||||
|
resolved_voice = entry.get("resolved_voice") or voice_formula or voice
|
||||||
|
resolved_label = label or entry.get("id") or ""
|
||||||
|
slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label)
|
||||||
|
return {
|
||||||
|
"id": slug,
|
||||||
|
"label": resolved_label,
|
||||||
|
"gender": gender,
|
||||||
|
"voice": voice if isinstance(voice, str) else "",
|
||||||
|
"voice_profile": voice_profile if isinstance(voice_profile, str) else "",
|
||||||
|
"voice_formula": voice_formula if isinstance(voice_formula, str) else "",
|
||||||
|
"resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "",
|
||||||
|
"languages": normalized_langs,
|
||||||
|
"randomize": randomize,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_configs() -> List[Dict[str, Any]]:
|
||||||
|
configs = load_configs()
|
||||||
|
ordered = []
|
||||||
|
for name in sorted(configs):
|
||||||
|
entry = configs[name]
|
||||||
|
ordered.append({"name": name, **entry})
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RandomVoiceOptions:
|
||||||
|
gender: str
|
||||||
|
allowed_languages: List[str] = field(default_factory=list)
|
||||||
|
fallback_voice: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def random_voice(
|
||||||
|
*,
|
||||||
|
gender: str,
|
||||||
|
allowed_languages: Optional[Iterable[str]] = None,
|
||||||
|
fallback_voice: Optional[str] = None,
|
||||||
|
exclude: Optional[Iterable[str]] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
gender = (gender or "unknown").lower()
|
||||||
|
allowed = [code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code]
|
||||||
|
excluded = {voice for voice in (exclude or []) if isinstance(voice, str)}
|
||||||
|
|
||||||
|
def _voice_gender(code: str) -> str:
|
||||||
|
if len(code) >= 2:
|
||||||
|
marker = code[1]
|
||||||
|
if marker == "m":
|
||||||
|
return "male"
|
||||||
|
if marker == "f":
|
||||||
|
return "female"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def _voice_language(code: str) -> str:
|
||||||
|
return code[0] if code else "a"
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
for voice in VOICES_INTERNAL:
|
||||||
|
if voice in excluded:
|
||||||
|
continue
|
||||||
|
voice_gender = _voice_gender(voice)
|
||||||
|
voice_language = _voice_language(voice)
|
||||||
|
if allowed and voice_language not in allowed:
|
||||||
|
continue
|
||||||
|
if gender in {"male", "female"} and voice_gender != gender:
|
||||||
|
continue
|
||||||
|
candidates.append(voice)
|
||||||
|
|
||||||
|
if not candidates and allowed:
|
||||||
|
# retry without language restriction, preserving gender
|
||||||
|
for voice in VOICES_INTERNAL:
|
||||||
|
if voice in excluded:
|
||||||
|
continue
|
||||||
|
voice_gender = _voice_gender(voice)
|
||||||
|
if gender in {"male", "female"} and voice_gender != gender:
|
||||||
|
continue
|
||||||
|
candidates.append(voice)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
candidates = [voice for voice in VOICES_INTERNAL if voice not in excluded]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return fallback_voice
|
||||||
|
|
||||||
|
choice = random.choice(candidates)
|
||||||
|
return choice or fallback_voice
|
||||||
|
|
||||||
|
|
||||||
|
def describe_language(code: str) -> str:
|
||||||
|
code = (code or "a").lower()
|
||||||
|
return LANGUAGE_DESCRIPTIONS.get(code, code.upper())
|
||||||
+555
-24
@@ -59,6 +59,16 @@ from abogen.voice_profiles import (
|
|||||||
|
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.voice_formulas import get_new_voice
|
||||||
from abogen.speaker_analysis import analyze_speakers
|
from abogen.speaker_analysis import analyze_speakers
|
||||||
|
from abogen.speaker_configs import (
|
||||||
|
delete_config,
|
||||||
|
get_config,
|
||||||
|
list_configs,
|
||||||
|
load_configs,
|
||||||
|
random_voice,
|
||||||
|
save_configs,
|
||||||
|
upsert_config,
|
||||||
|
slugify_label,
|
||||||
|
)
|
||||||
from abogen.text_extractor import extract_from_path
|
from abogen.text_extractor import extract_from_path
|
||||||
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
|
||||||
from .service import ConversionService, Job, JobStatus, PendingJob
|
from .service import ConversionService, Job, JobStatus, PendingJob
|
||||||
@@ -86,9 +96,6 @@ _SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS}
|
|||||||
|
|
||||||
|
|
||||||
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
||||||
_MAX_ANALYSIS_SPEAKERS = 6
|
|
||||||
|
|
||||||
|
|
||||||
def _build_narrator_roster(
|
def _build_narrator_roster(
|
||||||
voice: str,
|
voice: str,
|
||||||
voice_profile: Optional[str],
|
voice_profile: Optional[str],
|
||||||
@@ -120,11 +127,19 @@ def _build_speaker_roster(
|
|||||||
base_voice: str,
|
base_voice: str,
|
||||||
voice_profile: Optional[str],
|
voice_profile: Optional[str],
|
||||||
existing: Optional[Mapping[str, Any]] = None,
|
existing: Optional[Mapping[str, Any]] = None,
|
||||||
|
order: Optional[Iterable[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
roster = _build_narrator_roster(base_voice, voice_profile, existing)
|
roster = _build_narrator_roster(base_voice, voice_profile, existing)
|
||||||
existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
|
existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
|
||||||
speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
|
speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
|
||||||
for speaker_id, payload in speakers.items():
|
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":
|
if speaker_id == "narrator":
|
||||||
continue
|
continue
|
||||||
if payload.get("suppressed"):
|
if payload.get("suppressed"):
|
||||||
@@ -136,6 +151,7 @@ def _build_speaker_roster(
|
|||||||
"voice": base_voice,
|
"voice": base_voice,
|
||||||
"analysis_confidence": payload.get("confidence"),
|
"analysis_confidence": payload.get("confidence"),
|
||||||
"analysis_count": payload.get("count"),
|
"analysis_count": payload.get("count"),
|
||||||
|
"gender": payload.get("gender", "unknown"),
|
||||||
}
|
}
|
||||||
if isinstance(previous, Mapping):
|
if isinstance(previous, Mapping):
|
||||||
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
||||||
@@ -145,18 +161,286 @@ def _build_speaker_roster(
|
|||||||
return roster
|
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]],
|
||||||
|
*,
|
||||||
|
allow_randomize: bool = False,
|
||||||
|
persist_changes: bool = False,
|
||||||
|
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
|
if not isinstance(roster, Mapping):
|
||||||
|
return {}, [], None
|
||||||
|
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
||||||
|
if not config:
|
||||||
|
return updated_roster, [], None
|
||||||
|
|
||||||
|
speakers_map = config.get("speakers")
|
||||||
|
if not isinstance(speakers_map, Mapping):
|
||||||
|
return updated_roster, [], 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 = []
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
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 []
|
||||||
|
randomize_flag = bool(config_entry.get("randomize"))
|
||||||
|
|
||||||
|
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
||||||
|
if allow_randomize and randomize_flag:
|
||||||
|
exclusion = used_voices | {voice_id, resolved_voice}
|
||||||
|
randomized = random_voice(
|
||||||
|
gender=config_entry.get("gender", "unknown"),
|
||||||
|
allowed_languages=languages or allowed_languages,
|
||||||
|
fallback_voice=default_voice or roster_entry.get("voice"),
|
||||||
|
exclude=exclusion,
|
||||||
|
)
|
||||||
|
if randomized:
|
||||||
|
chosen_voice = randomized
|
||||||
|
voice_id = randomized
|
||||||
|
resolved_voice = randomized
|
||||||
|
config_changed = True
|
||||||
|
|
||||||
|
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"] = languages or allowed_languages
|
||||||
|
|
||||||
|
# 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": languages,
|
||||||
|
"randomize": randomize_flag,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [code.lower() for code in _get_list("config_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()
|
||||||
|
randomize_flag = form.get(prefix + "randomize") in {"on", "1", "true"}
|
||||||
|
languages = [code.lower() for code in _get_list(prefix + "languages")]
|
||||||
|
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": languages,
|
||||||
|
"randomize": randomize_flag,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
def _prepare_speaker_metadata(
|
||||||
*,
|
*,
|
||||||
chapters: List[Dict[str, Any]],
|
chapters: List[Dict[str, Any]],
|
||||||
chunks: List[Dict[str, Any]],
|
chunks: List[Dict[str, Any]],
|
||||||
|
analysis_chunks: Optional[List[Dict[str, Any]]] = None,
|
||||||
speaker_mode: str,
|
speaker_mode: str,
|
||||||
voice: str,
|
voice: str,
|
||||||
voice_profile: Optional[str],
|
voice_profile: Optional[str],
|
||||||
threshold: int,
|
threshold: int,
|
||||||
existing_roster: Optional[Mapping[str, Any]] = None,
|
existing_roster: Optional[Mapping[str, Any]] = None,
|
||||||
run_analysis: bool = True,
|
run_analysis: bool = True,
|
||||||
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any]]:
|
speaker_config: Optional[Mapping[str, Any]] = None,
|
||||||
|
apply_config: bool = False,
|
||||||
|
allow_randomize: 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]
|
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))
|
threshold_value = max(1, int(threshold))
|
||||||
analysis_enabled = speaker_mode == "multi" and run_analysis
|
analysis_enabled = speaker_mode == "multi" and run_analysis
|
||||||
|
|
||||||
@@ -191,12 +475,25 @@ def _prepare_speaker_metadata(
|
|||||||
narrator_pron = roster["narrator"].get("pronunciation")
|
narrator_pron = roster["narrator"].get("pronunciation")
|
||||||
if narrator_pron:
|
if narrator_pron:
|
||||||
analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
|
analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
|
||||||
return chunk_list, roster, analysis_payload
|
return chunk_list, roster, analysis_payload, [], None
|
||||||
|
|
||||||
analysis_result = analyze_speakers(
|
analysis_result = analyze_speakers(
|
||||||
chapters, chunk_list, threshold=threshold_value, max_speakers=_MAX_ANALYSIS_SPEAKERS
|
chapters,
|
||||||
|
analysis_source,
|
||||||
|
threshold=threshold_value,
|
||||||
|
max_speakers=0,
|
||||||
)
|
)
|
||||||
analysis_payload = analysis_result.to_dict()
|
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"),
|
||||||
|
key=lambda item: item[1].get("count", 0),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
analysis_payload["ordered_speakers"] = ordered_ids
|
||||||
assignments = analysis_payload.get("assignments", {})
|
assignments = analysis_payload.get("assignments", {})
|
||||||
suppressed_ids = analysis_payload.get("suppressed", [])
|
suppressed_ids = analysis_payload.get("suppressed", [])
|
||||||
suppressed_details: List[Dict[str, Any]] = []
|
suppressed_details: List[Dict[str, Any]] = []
|
||||||
@@ -222,7 +519,33 @@ def _prepare_speaker_metadata(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
analysis_payload["suppressed_details"] = suppressed_details
|
analysis_payload["suppressed_details"] = suppressed_details
|
||||||
roster = _build_speaker_roster(analysis_payload, voice, voice_profile, existing=existing_roster)
|
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,
|
||||||
|
allow_randomize=allow_randomize,
|
||||||
|
persist_changes=persist_config,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
if applied_languages:
|
||||||
|
analysis_payload["config_languages"] = applied_languages
|
||||||
speakers_payload = analysis_payload.get("speakers")
|
speakers_payload = analysis_payload.get("speakers")
|
||||||
if isinstance(speakers_payload, dict):
|
if isinstance(speakers_payload, dict):
|
||||||
for roster_id, roster_payload in roster.items():
|
for roster_id, roster_payload in roster.items():
|
||||||
@@ -231,6 +554,13 @@ def _prepare_speaker_metadata(
|
|||||||
if pronunciation_value:
|
if pronunciation_value:
|
||||||
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
||||||
|
|
||||||
|
fallback_languages = applied_languages or []
|
||||||
|
if not fallback_languages:
|
||||||
|
config_langs = analysis_payload.get("config_languages")
|
||||||
|
if isinstance(config_langs, list):
|
||||||
|
fallback_languages = [code for code in config_langs if isinstance(code, str)]
|
||||||
|
_inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
||||||
|
|
||||||
for chunk in chunk_list:
|
for chunk in chunk_list:
|
||||||
chunk_id = str(chunk.get("id"))
|
chunk_id = str(chunk.get("id"))
|
||||||
speaker_id = assignments.get(chunk_id, "narrator")
|
speaker_id = assignments.get(chunk_id, "narrator")
|
||||||
@@ -238,12 +568,22 @@ def _prepare_speaker_metadata(
|
|||||||
speaker_meta = roster.get(speaker_id)
|
speaker_meta = roster.get(speaker_id)
|
||||||
chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
|
chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
|
||||||
|
|
||||||
return chunk_list, roster, analysis_payload
|
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
||||||
|
|
||||||
|
|
||||||
def _apply_prepare_form(
|
def _apply_prepare_form(
|
||||||
pending: PendingJob, form: Mapping[str, Any]
|
pending: PendingJob, form: Mapping[str, Any]
|
||||||
) -> tuple[ChunkLevel, List[Dict[str, Any]], List[Dict[str, Any]], List[str], int]:
|
) -> tuple[
|
||||||
|
ChunkLevel,
|
||||||
|
List[Dict[str, Any]],
|
||||||
|
List[Dict[str, Any]],
|
||||||
|
List[str],
|
||||||
|
int,
|
||||||
|
str,
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
]:
|
||||||
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
|
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
|
||||||
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
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"
|
raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph"
|
||||||
@@ -288,6 +628,16 @@ def _apply_prepare_form(
|
|||||||
existing_narrator["voice_profile"] = pending.voice_profile
|
existing_narrator["voice_profile"] = pending.voice_profile
|
||||||
pending.speakers["narrator"] = existing_narrator
|
pending.speakers["narrator"] = existing_narrator
|
||||||
|
|
||||||
|
selected_config = (form.get("applied_speaker_config") or "").strip()
|
||||||
|
randomize_requested = str(form.get("randomize_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
|
apply_config_requested = (
|
||||||
|
str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
|
or randomize_requested
|
||||||
|
)
|
||||||
|
persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
|
|
||||||
|
pending.applied_speaker_config = selected_config or None
|
||||||
|
|
||||||
if isinstance(pending.speakers, dict):
|
if isinstance(pending.speakers, dict):
|
||||||
for speaker_id, payload in list(pending.speakers.items()):
|
for speaker_id, payload in list(pending.speakers.items()):
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -300,6 +650,29 @@ def _apply_prepare_form(
|
|||||||
else:
|
else:
|
||||||
payload.pop("pronunciation", None)
|
payload.pop("pronunciation", None)
|
||||||
|
|
||||||
|
voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip()
|
||||||
|
if voice_value:
|
||||||
|
payload["voice"] = voice_value
|
||||||
|
payload["resolved_voice"] = voice_value
|
||||||
|
else:
|
||||||
|
payload.pop("voice", None)
|
||||||
|
payload.pop("resolved_voice", None)
|
||||||
|
|
||||||
|
randomize_flag = form.get(f"speaker-{speaker_id}-randomize") in {"on", "1", "true"}
|
||||||
|
payload["randomize"] = randomize_flag
|
||||||
|
|
||||||
|
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()
|
profiles = serialize_profiles()
|
||||||
errors: List[str] = []
|
errors: List[str] = []
|
||||||
raw_delay = form.get("chapter_intro_delay")
|
raw_delay = form.get("chapter_intro_delay")
|
||||||
@@ -366,7 +739,17 @@ def _apply_prepare_form(
|
|||||||
|
|
||||||
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
|
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
|
||||||
|
|
||||||
return chunk_level_literal, overrides, enabled_overrides, errors, selected_total
|
return (
|
||||||
|
chunk_level_literal,
|
||||||
|
overrides,
|
||||||
|
enabled_overrides,
|
||||||
|
errors,
|
||||||
|
selected_total,
|
||||||
|
selected_config,
|
||||||
|
randomize_requested,
|
||||||
|
apply_config_requested,
|
||||||
|
persist_config_requested,
|
||||||
|
)
|
||||||
_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
|
_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
|
||||||
(re.compile(r"\btitle\s+page\b"), 3.0),
|
(re.compile(r"\btitle\s+page\b"), 3.0),
|
||||||
(re.compile(r"\bcopyright\b"), 2.4),
|
(re.compile(r"\bcopyright\b"), 2.4),
|
||||||
@@ -487,6 +870,7 @@ def _build_voice_catalog() -> List[Dict[str, str]]:
|
|||||||
"language": language_code,
|
"language": language_code,
|
||||||
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
|
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
|
||||||
"gender": gender_map.get(gender_code, "Unknown"),
|
"gender": gender_map.get(gender_code, "Unknown"),
|
||||||
|
"gender_code": gender_code,
|
||||||
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -506,6 +890,7 @@ def _template_options() -> Dict[str, Any]:
|
|||||||
"formula": _formula_from_profile(entry or {}) or "",
|
"formula": _formula_from_profile(entry or {}) or "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
voice_catalog = _build_voice_catalog()
|
||||||
return {
|
return {
|
||||||
"languages": LANGUAGE_DESCRIPTIONS,
|
"languages": LANGUAGE_DESCRIPTIONS,
|
||||||
"voices": VOICES_INTERNAL,
|
"voices": VOICES_INTERNAL,
|
||||||
@@ -515,9 +900,11 @@ def _template_options() -> Dict[str, Any]:
|
|||||||
"voice_profiles": ordered_profiles,
|
"voice_profiles": ordered_profiles,
|
||||||
"voice_profile_options": profile_options,
|
"voice_profile_options": profile_options,
|
||||||
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
||||||
"voice_catalog": _build_voice_catalog(),
|
"voice_catalog": voice_catalog,
|
||||||
|
"voice_catalog_map": {entry["id"]: entry for entry in voice_catalog},
|
||||||
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
|
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
|
||||||
"voice_profiles_data": profiles,
|
"voice_profiles_data": profiles,
|
||||||
|
"speaker_configs": list_configs(),
|
||||||
"chunk_levels": _CHUNK_LEVEL_OPTIONS,
|
"chunk_levels": _CHUNK_LEVEL_OPTIONS,
|
||||||
"speaker_modes": _SPEAKER_MODE_OPTIONS,
|
"speaker_modes": _SPEAKER_MODE_OPTIONS,
|
||||||
"speaker_analysis_threshold": current_settings.get(
|
"speaker_analysis_threshold": current_settings.get(
|
||||||
@@ -893,6 +1280,66 @@ def voice_profiles_page() -> str:
|
|||||||
return render_template("voices.html", options=options)
|
return render_template("voices.html", options=options)
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@web_bp.post("/voices")
|
||||||
def save_voice_profile_route() -> ResponseReturnValue:
|
def save_voice_profile_route() -> ResponseReturnValue:
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
@@ -1325,6 +1772,16 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
base_voice = request.form.get("voice", "af_alloy")
|
base_voice = request.form.get("voice", "af_alloy")
|
||||||
profile_selection = (request.form.get("voice_profile") or "__standard").strip()
|
profile_selection = (request.form.get("voice_profile") or "__standard").strip()
|
||||||
custom_formula_raw = request.form.get("voice_formula", "").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
|
||||||
|
config_randomize_default = False
|
||||||
|
if isinstance(speaker_config_payload, Mapping):
|
||||||
|
speakers_map = speaker_config_payload.get("speakers")
|
||||||
|
if isinstance(speakers_map, Mapping):
|
||||||
|
for config_entry in speakers_map.values():
|
||||||
|
if isinstance(config_entry, Mapping) and config_entry.get("randomize"):
|
||||||
|
config_randomize_default = True
|
||||||
|
break
|
||||||
|
|
||||||
if profile_selection in {"__standard", ""}:
|
if profile_selection in {"__standard", ""}:
|
||||||
profile_name = ""
|
profile_name = ""
|
||||||
@@ -1377,6 +1834,7 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
|
|
||||||
selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")]
|
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)
|
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(
|
analysis_threshold = _coerce_int(
|
||||||
settings.get("speaker_analysis_threshold"),
|
settings.get("speaker_analysis_threshold"),
|
||||||
@@ -1385,15 +1843,19 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
maximum=25,
|
maximum=25,
|
||||||
)
|
)
|
||||||
|
|
||||||
initial_analysis = speaker_mode_value != "multi"
|
initial_analysis = speaker_mode_value == "multi"
|
||||||
processed_chunks, speakers, analysis_payload = _prepare_speaker_metadata(
|
processed_chunks, speakers, analysis_payload, config_languages, _ = _prepare_speaker_metadata(
|
||||||
chapters=selected_chapter_sources,
|
chapters=selected_chapter_sources,
|
||||||
chunks=raw_chunks,
|
chunks=raw_chunks,
|
||||||
|
analysis_chunks=analysis_chunks,
|
||||||
speaker_mode=speaker_mode_value,
|
speaker_mode=speaker_mode_value,
|
||||||
voice=voice,
|
voice=voice,
|
||||||
voice_profile=selected_profile or None,
|
voice_profile=selected_profile or None,
|
||||||
threshold=analysis_threshold,
|
threshold=analysis_threshold,
|
||||||
run_analysis=initial_analysis,
|
run_analysis=initial_analysis,
|
||||||
|
speaker_config=speaker_config_payload,
|
||||||
|
apply_config=bool(speaker_config_payload),
|
||||||
|
allow_randomize=config_randomize_default,
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = PendingJob(
|
pending = PendingJob(
|
||||||
@@ -1431,10 +1893,17 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
speaker_analysis=analysis_payload,
|
speaker_analysis=analysis_payload,
|
||||||
speaker_analysis_threshold=analysis_threshold,
|
speaker_analysis_threshold=analysis_threshold,
|
||||||
analysis_requested=False,
|
analysis_requested=initial_analysis,
|
||||||
)
|
)
|
||||||
|
|
||||||
service.store_pending_job(pending)
|
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)]
|
||||||
return redirect(url_for("web.prepare_job", pending_id=pending.id))
|
return redirect(url_for("web.prepare_job", pending_id=pending.id))
|
||||||
|
|
||||||
|
|
||||||
@@ -1455,9 +1924,17 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
abort(404)
|
abort(404)
|
||||||
pending = cast(PendingJob, pending)
|
pending = cast(PendingJob, pending)
|
||||||
|
|
||||||
chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form(
|
(
|
||||||
pending, request.form
|
chunk_level_literal,
|
||||||
)
|
overrides,
|
||||||
|
enabled_overrides,
|
||||||
|
errors,
|
||||||
|
selected_total,
|
||||||
|
selected_config,
|
||||||
|
randomize_requested,
|
||||||
|
apply_config_requested,
|
||||||
|
persist_config_requested,
|
||||||
|
) = _apply_prepare_form(pending, request.form)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return _render_prepare_page(pending, error=" ".join(errors))
|
return _render_prepare_page(pending, error=" ".join(errors))
|
||||||
@@ -1478,6 +1955,9 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
return _render_prepare_page(pending, error="Select at least one chapter to analyze.")
|
return _render_prepare_page(pending, error="Select at least one chapter to analyze.")
|
||||||
|
|
||||||
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
||||||
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
||||||
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
||||||
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
||||||
|
|
||||||
existing_roster: Optional[Mapping[str, Any]]
|
existing_roster: Optional[Mapping[str, Any]]
|
||||||
if getattr(pending, "analysis_requested", False):
|
if getattr(pending, "analysis_requested", False):
|
||||||
@@ -1485,27 +1965,44 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
else:
|
else:
|
||||||
existing_roster = None
|
existing_roster = None
|
||||||
|
|
||||||
processed_chunks, roster, analysis_payload = _prepare_speaker_metadata(
|
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,
|
chapters=enabled_overrides,
|
||||||
chunks=raw_chunks,
|
chunks=raw_chunks,
|
||||||
|
analysis_chunks=analysis_chunks,
|
||||||
speaker_mode=pending.speaker_mode,
|
speaker_mode=pending.speaker_mode,
|
||||||
voice=pending.voice,
|
voice=pending.voice,
|
||||||
voice_profile=pending.voice_profile,
|
voice_profile=pending.voice_profile,
|
||||||
threshold=pending.speaker_analysis_threshold,
|
threshold=pending.speaker_analysis_threshold,
|
||||||
existing_roster=existing_roster,
|
existing_roster=existing_roster,
|
||||||
run_analysis=True,
|
run_analysis=True,
|
||||||
|
speaker_config=speaker_config_payload,
|
||||||
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
||||||
|
allow_randomize=randomize_requested,
|
||||||
|
persist_config=persist_config_requested,
|
||||||
)
|
)
|
||||||
|
|
||||||
pending.chunks = processed_chunks
|
pending.chunks = processed_chunks
|
||||||
pending.speakers = roster
|
pending.speakers = roster
|
||||||
pending.speaker_analysis = analysis_payload
|
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)
|
setattr(pending, "analysis_requested", True)
|
||||||
if selected_total:
|
if selected_total:
|
||||||
pending.total_characters = selected_total
|
pending.total_characters = selected_total
|
||||||
|
|
||||||
service.store_pending_job(pending)
|
service.store_pending_job(pending)
|
||||||
|
|
||||||
return _render_prepare_page(pending, notice="Speaker analysis updated.")
|
notice_message = "Speaker analysis updated."
|
||||||
|
if persist_config_requested and config_name:
|
||||||
|
notice_message = "Speaker analysis updated and configuration saved."
|
||||||
|
return _render_prepare_page(pending, notice=notice_message)
|
||||||
|
|
||||||
|
|
||||||
@web_bp.post("/jobs/prepare/<pending_id>")
|
@web_bp.post("/jobs/prepare/<pending_id>")
|
||||||
@@ -1516,9 +2013,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
abort(404)
|
abort(404)
|
||||||
pending = cast(PendingJob, pending)
|
pending = cast(PendingJob, pending)
|
||||||
|
|
||||||
chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form(
|
(
|
||||||
pending, request.form
|
chunk_level_literal,
|
||||||
)
|
overrides,
|
||||||
|
enabled_overrides,
|
||||||
|
errors,
|
||||||
|
selected_total,
|
||||||
|
selected_config,
|
||||||
|
randomize_requested,
|
||||||
|
apply_config_requested,
|
||||||
|
persist_config_requested,
|
||||||
|
) = _apply_prepare_form(pending, request.form)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return _render_prepare_page(pending, error=" ".join(errors))
|
return _render_prepare_page(pending, error=" ".join(errors))
|
||||||
@@ -1531,6 +2036,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
return _render_prepare_page(pending, error="Select at least one chapter to convert.")
|
return _render_prepare_page(pending, error="Select at least one chapter to convert.")
|
||||||
|
|
||||||
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
||||||
|
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
||||||
analysis_active = pending.speaker_mode == "multi" and getattr(pending, "analysis_requested", False)
|
analysis_active = pending.speaker_mode == "multi" and getattr(pending, "analysis_requested", False)
|
||||||
if analysis_active:
|
if analysis_active:
|
||||||
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
|
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
|
||||||
@@ -1541,19 +2047,33 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
if isinstance(narrator_payload, Mapping):
|
if isinstance(narrator_payload, Mapping):
|
||||||
narrator_only["narrator"] = dict(narrator_payload)
|
narrator_only["narrator"] = dict(narrator_payload)
|
||||||
existing_roster = narrator_only or None
|
existing_roster = narrator_only or None
|
||||||
processed_chunks, roster, analysis_payload = _prepare_speaker_metadata(
|
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,
|
chapters=enabled_overrides,
|
||||||
chunks=raw_chunks,
|
chunks=raw_chunks,
|
||||||
|
analysis_chunks=analysis_chunks,
|
||||||
speaker_mode=pending.speaker_mode,
|
speaker_mode=pending.speaker_mode,
|
||||||
voice=pending.voice,
|
voice=pending.voice,
|
||||||
voice_profile=pending.voice_profile,
|
voice_profile=pending.voice_profile,
|
||||||
threshold=pending.speaker_analysis_threshold,
|
threshold=pending.speaker_analysis_threshold,
|
||||||
existing_roster=existing_roster,
|
existing_roster=existing_roster,
|
||||||
run_analysis=analysis_active,
|
run_analysis=analysis_active,
|
||||||
|
speaker_config=speaker_config_payload,
|
||||||
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
||||||
|
allow_randomize=randomize_requested,
|
||||||
|
persist_config=persist_config_requested,
|
||||||
)
|
)
|
||||||
pending.chunks = processed_chunks
|
pending.chunks = processed_chunks
|
||||||
pending.speakers = roster
|
pending.speakers = roster
|
||||||
pending.speaker_analysis = analysis_payload
|
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)
|
||||||
|
|
||||||
total_characters = selected_total or pending.total_characters
|
total_characters = selected_total or pending.total_characters
|
||||||
|
|
||||||
@@ -1594,6 +2114,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
generate_epub3=pending.generate_epub3,
|
generate_epub3=pending.generate_epub3,
|
||||||
analysis_requested=getattr(pending, "analysis_requested", False),
|
analysis_requested=getattr(pending, "analysis_requested", False),
|
||||||
)
|
)
|
||||||
|
if config_languages:
|
||||||
|
job.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:
|
||||||
|
job.applied_speaker_config = config_name
|
||||||
|
configs = load_configs()
|
||||||
|
configs[config_name] = updated_config
|
||||||
|
save_configs(configs)
|
||||||
|
elif isinstance(config_name, str) and config_name:
|
||||||
|
job.applied_speaker_config = config_name
|
||||||
|
job.speaker_voice_languages = job.speaker_voice_languages or list(pending.speaker_voice_languages)
|
||||||
|
|
||||||
return redirect(url_for("web.queue_page"))
|
return redirect(url_for("web.queue_page"))
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ class Job:
|
|||||||
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
|
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
|
||||||
speaker_analysis_threshold: int = 3
|
speaker_analysis_threshold: int = 3
|
||||||
analysis_requested: bool = False
|
analysis_requested: bool = False
|
||||||
|
speaker_voice_languages: List[str] = field(default_factory=list)
|
||||||
|
applied_speaker_config: Optional[str] = None
|
||||||
|
speaker_voice_languages: List[str] = field(default_factory=list)
|
||||||
|
applied_speaker_config: Optional[str] = None
|
||||||
|
|
||||||
def add_log(self, message: str, level: str = "info") -> None:
|
def add_log(self, message: str, level: str = "info") -> None:
|
||||||
self.logs.append(JobLog(timestamp=time.time(), message=message, level=level))
|
self.logs.append(JobLog(timestamp=time.time(), message=message, level=level))
|
||||||
@@ -156,6 +160,8 @@ class Job:
|
|||||||
"speaker_analysis": dict(self.speaker_analysis),
|
"speaker_analysis": dict(self.speaker_analysis),
|
||||||
"speaker_analysis_threshold": self.speaker_analysis_threshold,
|
"speaker_analysis_threshold": self.speaker_analysis_threshold,
|
||||||
"analysis_requested": self.analysis_requested,
|
"analysis_requested": self.analysis_requested,
|
||||||
|
"speaker_voice_languages": list(self.speaker_voice_languages),
|
||||||
|
"applied_speaker_config": self.applied_speaker_config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,6 +202,8 @@ class PendingJob:
|
|||||||
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
|
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
|
||||||
speaker_analysis_threshold: int = 3
|
speaker_analysis_threshold: int = 3
|
||||||
analysis_requested: bool = False
|
analysis_requested: bool = False
|
||||||
|
speaker_voice_languages: List[str] = field(default_factory=list)
|
||||||
|
applied_speaker_config: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ConversionService:
|
class ConversionService:
|
||||||
|
|||||||
@@ -76,4 +76,78 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
speakerModeSelect.addEventListener("change", updateAnalyzeVisibility);
|
speakerModeSelect.addEventListener("change", updateAnalyzeVisibility);
|
||||||
updateAnalyzeVisibility();
|
updateAnalyzeVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePreviewVoice = (select) => {
|
||||||
|
const container = select.closest(".speaker-list__item");
|
||||||
|
if (!container) return;
|
||||||
|
const previewButton = container.querySelector('[data-role="speaker-preview"]');
|
||||||
|
if (!previewButton) return;
|
||||||
|
const defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || "";
|
||||||
|
const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice);
|
||||||
|
previewButton.dataset.voice = currentVoice || defaultVoice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRandomizeToggle = (checkbox) => {
|
||||||
|
const container = checkbox.closest(".speaker-list__item");
|
||||||
|
if (!container) return;
|
||||||
|
const select = container.querySelector('[data-role="speaker-voice"]');
|
||||||
|
if (!select) return;
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!select.dataset.prevManual) {
|
||||||
|
select.dataset.prevManual = select.value;
|
||||||
|
}
|
||||||
|
select.dataset.suppressRandomize = "1";
|
||||||
|
select.disabled = true;
|
||||||
|
select.value = "";
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
delete select.dataset.suppressRandomize;
|
||||||
|
} else {
|
||||||
|
const previous = select.dataset.prevManual || "";
|
||||||
|
select.disabled = false;
|
||||||
|
select.dataset.suppressRandomize = "1";
|
||||||
|
select.value = previous;
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
delete select.dataset.suppressRandomize;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]'));
|
||||||
|
voiceSelects.forEach((select) => {
|
||||||
|
select.addEventListener("change", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
const container = target.closest(".speaker-list__item");
|
||||||
|
if (container && !target.dataset.suppressRandomize) {
|
||||||
|
const randomToggle = container.querySelector('[data-role="randomize-toggle"]');
|
||||||
|
if (randomToggle && randomToggle.checked && target.value) {
|
||||||
|
randomToggle.checked = false;
|
||||||
|
handleRandomizeToggle(randomToggle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatePreviewVoice(target);
|
||||||
|
});
|
||||||
|
updatePreviewVoice(select);
|
||||||
|
});
|
||||||
|
|
||||||
|
const randomizeToggles = Array.from(form.querySelectorAll('[data-role="randomize-toggle"]'));
|
||||||
|
randomizeToggles.forEach((checkbox) => {
|
||||||
|
handleRandomizeToggle(checkbox);
|
||||||
|
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox));
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("click", (event) => {
|
||||||
|
const chip = event.target.closest('[data-role="recommended-voice"]');
|
||||||
|
if (!chip) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const container = chip.closest(".speaker-list__item");
|
||||||
|
if (!container) return;
|
||||||
|
const select = container.querySelector('[data-role="speaker-voice"]');
|
||||||
|
if (!select) return;
|
||||||
|
const randomToggle = container.querySelector('[data-role="randomize-toggle"]');
|
||||||
|
if (randomToggle && randomToggle.checked) {
|
||||||
|
randomToggle.checked = false;
|
||||||
|
handleRandomizeToggle(randomToggle);
|
||||||
|
}
|
||||||
|
select.value = chip.dataset.voice || "";
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
const initSpeakerConfigsPage = () => {
|
||||||
|
const form = document.getElementById("speaker-config-form");
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const rowsContainer = form.querySelector('[data-role="speaker-rows"]');
|
||||||
|
const template = document.getElementById("speaker-row-template");
|
||||||
|
const addButtons = form.querySelectorAll('[data-action="add-speaker"]');
|
||||||
|
|
||||||
|
const ensureEmptyState = () => {
|
||||||
|
if (!rowsContainer) return;
|
||||||
|
const hasRows = rowsContainer.querySelector('[data-role="speaker-row"]');
|
||||||
|
let emptyState = rowsContainer.querySelector('[data-role="empty-state"]');
|
||||||
|
if (hasRows && emptyState) {
|
||||||
|
emptyState.remove();
|
||||||
|
emptyState = null;
|
||||||
|
}
|
||||||
|
if (!hasRows && !emptyState) {
|
||||||
|
const placeholder = document.createElement("div");
|
||||||
|
placeholder.className = "speaker-config-rows__empty";
|
||||||
|
placeholder.dataset.role = "empty-state";
|
||||||
|
placeholder.textContent = "No speakers yet. Add your first character.";
|
||||||
|
rowsContainer.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hydrateRow = (fragment, key) => {
|
||||||
|
const elements = fragment.querySelectorAll("[name], [id], label[for], [data-row-id]");
|
||||||
|
elements.forEach((el) => {
|
||||||
|
if (el.name) {
|
||||||
|
el.name = el.name.replace(/__ROW__/g, key);
|
||||||
|
}
|
||||||
|
if (el.id) {
|
||||||
|
el.id = el.id.replace(/__ROW__/g, key);
|
||||||
|
}
|
||||||
|
if (el.tagName === "LABEL") {
|
||||||
|
const forValue = el.getAttribute("for");
|
||||||
|
if (forValue) {
|
||||||
|
el.setAttribute("for", forValue.replace(/__ROW__/g, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (el.dataset && el.dataset.rowId) {
|
||||||
|
el.dataset.rowId = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenId = fragment.querySelector(`input[name="speaker-${key}-id"]`);
|
||||||
|
if (hiddenId && !hiddenId.value) {
|
||||||
|
hiddenId.value = key;
|
||||||
|
}
|
||||||
|
const rowMarkers = fragment.querySelectorAll('input[name="speaker_rows"]');
|
||||||
|
rowMarkers.forEach((marker) => {
|
||||||
|
marker.value = key;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
if (!template || !rowsContainer) return;
|
||||||
|
const key = `row-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const fragment = template.content.cloneNode(true);
|
||||||
|
hydrateRow(fragment, key);
|
||||||
|
rowsContainer.appendChild(fragment);
|
||||||
|
ensureEmptyState();
|
||||||
|
const newRow = rowsContainer.querySelector(`[data-row-id="${key}"]`);
|
||||||
|
if (newRow) {
|
||||||
|
const input = newRow.querySelector("input[type=text]");
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
addRow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rowsContainer?.addEventListener("click", (event) => {
|
||||||
|
const removeButton = event.target.closest('[data-action="remove-speaker"]');
|
||||||
|
if (!removeButton) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const row = removeButton.closest('[data-role="speaker-row"]');
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
ensureEmptyState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureEmptyState();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", initSpeakerConfigsPage, { once: true });
|
||||||
|
} else {
|
||||||
|
initSpeakerConfigsPage();
|
||||||
|
}
|
||||||
@@ -125,6 +125,65 @@ body {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__index {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__label {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__item.is-active {
|
||||||
|
background: rgba(56, 189, 248, 0.18);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__item.is-active .step-indicator__index {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__item.is-complete {
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(56, 189, 248, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator__item.is-complete .step-indicator__index {
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.card__title {
|
.card__title {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -157,6 +216,10 @@ body {
|
|||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-grid > .grid:last-child {
|
||||||
|
width: min(100%, 540px);
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -223,6 +286,7 @@ body {
|
|||||||
.field select,
|
.field select,
|
||||||
.field textarea {
|
.field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
background: rgba(15, 23, 42, 0.45);
|
background: rgba(15, 23, 42, 0.45);
|
||||||
@@ -310,6 +374,16 @@ body {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge--info {
|
||||||
|
background: rgba(56, 189, 248, 0.18);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--muted {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.log-list {
|
.log-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -529,7 +603,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field--file input[type="file"] {
|
.field--file input[type="file"] {
|
||||||
width: 100%;
|
width: min(100%, 420px);
|
||||||
|
max-width: 420px;
|
||||||
|
align-self: flex-start;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px dashed rgba(148, 163, 184, 0.35);
|
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
background: rgba(15, 23, 42, 0.45);
|
background: rgba(15, 23, 42, 0.45);
|
||||||
@@ -711,6 +787,53 @@ body {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prepare-speaker-config {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 1px solid var(--panel-border);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-speaker-config__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-speaker-config__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-speaker-config__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-speaker-config__actions .button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-speaker-config__field .hint {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.prepare-speaker-config__grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.prepare-speaker-config__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.speaker-list {
|
.speaker-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -802,6 +925,60 @@ body {
|
|||||||
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speaker-list__field select {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-list__field select[multiple] {
|
||||||
|
min-height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-list__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-list__controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-list__inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-list__recommendations {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.speaker-list__stats {
|
.speaker-list__stats {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -871,19 +1048,143 @@ body {
|
|||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card__metrics {
|
.speaker-configs__layout {
|
||||||
color: var(--muted);
|
display: grid;
|
||||||
font-size: 0.85rem;
|
gap: 2rem;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card__body {
|
.speaker-configs__sidebar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card__field {
|
.speaker-configs__sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.45rem;
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__entry {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__entry.is-active {
|
||||||
|
border-color: rgba(56, 189, 248, 0.45);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__entry a {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__entry a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__delete {
|
||||||
|
margin: 0;
|
||||||
|
justify-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__empty {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-form__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-form .field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-rows {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-rows__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-rows__list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-rows__empty {
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-row {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-row__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-config-row__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-configs__actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--small {
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card__field label {
|
.chapter-card__field label {
|
||||||
@@ -1603,6 +1904,9 @@ progress.progress::-moz-progress-bar {
|
|||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
background: rgba(11, 18, 34, 0.85);
|
background: rgba(11, 18, 34, 0.85);
|
||||||
min-height: 140px;
|
min-height: 140px;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
max-width: 420px;
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-status {
|
.voice-status {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
{% set endpoint = request.endpoint or '' %}
|
{% set endpoint = request.endpoint or '' %}
|
||||||
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
|
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
|
||||||
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
|
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
|
||||||
|
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
|
||||||
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
|
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
|
||||||
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
|
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||||
|
<span class="step-indicator__item is-active">
|
||||||
|
<span class="step-indicator__index">1</span>
|
||||||
|
<span class="step-indicator__label">Upload</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item">
|
||||||
|
<span class="step-indicator__index">2</span>
|
||||||
|
<span class="step-indicator__label">Speakers</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item">
|
||||||
|
<span class="step-indicator__index">3</span>
|
||||||
|
<span class="step-indicator__label">Queue</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<h1 class="card__title">Create a New Audiobook</h1>
|
<h1 class="card__title">Create a New Audiobook</h1>
|
||||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
|
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -45,6 +59,16 @@
|
|||||||
<label for="voice_formula">Custom Voice Formula</label>
|
<label for="voice_formula">Custom Voice Formula</label>
|
||||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="speaker_config">Speaker preset</label>
|
||||||
|
<select id="speaker_config" name="speaker_config">
|
||||||
|
<option value="">None</option>
|
||||||
|
{% for config in options.speaker_configs %}
|
||||||
|
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||||
|
|||||||
@@ -4,150 +4,309 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||||
|
<span class="step-indicator__item is-complete">
|
||||||
|
<span class="step-indicator__index">1</span>
|
||||||
|
<span class="step-indicator__label">Upload</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item is-active">
|
||||||
|
<span class="step-indicator__index">2</span>
|
||||||
|
<span class="step-indicator__label">Speakers</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item">
|
||||||
|
<span class="step-indicator__index">3</span>
|
||||||
|
<span class="step-indicator__label">Queue</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="card__title">Prepare audiobook</div>
|
<div class="card__title">Prepare audiobook</div>
|
||||||
<p class="card__subtitle">Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.</p>
|
<p class="card__subtitle">Review the detected chapters, tune the speaker roster, and optionally override the voice per chapter before sending the job to the queue.</p>
|
||||||
|
|
||||||
{% set analysis = pending.speaker_analysis or {} %}
|
{% set analysis = pending.speaker_analysis or {} %}
|
||||||
{% set analysis_speakers = analysis.get('speakers', {}) %}
|
{% set analysis_speakers = analysis.get('speakers', {}) %}
|
||||||
{% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %}
|
{% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %}
|
||||||
{% set has_metadata = pending.metadata_tags %}
|
{% set has_metadata = pending.metadata_tags %}
|
||||||
<div class="prepare-summary">
|
|
||||||
<div class="prepare-summary__stats">
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Source</dt>
|
|
||||||
<dd>{{ pending.original_filename }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Language</dt>
|
|
||||||
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Default voice</dt>
|
|
||||||
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Total chapters</dt>
|
|
||||||
<dd>{{ pending.chapters|length }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Characters</dt>
|
|
||||||
<dd>{{ '{:,}'.format(pending.total_characters|default(0)) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Chapter intro delay</dt>
|
|
||||||
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Chunk granularity</dt>
|
|
||||||
<dd>{{ pending.chunk_level|replace('_', ' ')|title }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Speaker mode</dt>
|
|
||||||
<dd>{{ pending.speaker_mode|replace('_', ' ')|title }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Speaker analysis threshold</dt>
|
|
||||||
<dd>{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>EPUB 3 package</dt>
|
|
||||||
<dd>{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
{% if analysis_speakers or show_analysis_prompt or has_metadata %}
|
|
||||||
<div class="prepare-summary__insights">
|
|
||||||
{% if analysis_speakers %}
|
|
||||||
{% set active = namespace(items=[]) %}
|
|
||||||
{% for sid, payload in analysis_speakers.items() %}
|
|
||||||
{% if sid != 'narrator' and not payload.get('suppressed') %}
|
|
||||||
{% set _ = active.items.append(payload) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="prepare-analysis">
|
|
||||||
<h2>Detected speakers</h2>
|
|
||||||
{% if active.items %}
|
|
||||||
<ul>
|
|
||||||
{% for speaker in active.items|sort(attribute='label') %}
|
|
||||||
<li><strong>{{ speaker.label }}</strong> · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% elif show_analysis_prompt %}
|
|
||||||
<div class="prepare-analysis">
|
|
||||||
<h2>Detected speakers</h2>
|
|
||||||
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_metadata %}
|
|
||||||
<div class="prepare-metadata">
|
|
||||||
<h2>Metadata</h2>
|
|
||||||
<ul>
|
|
||||||
{% for key, value in pending.metadata_tags.items() %}
|
|
||||||
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% set roster = pending.speakers or {} %}
|
{% set roster = pending.speakers or {} %}
|
||||||
{% if roster %}
|
{% set speaker_configs = options.speaker_configs or [] %}
|
||||||
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
{% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %}
|
||||||
<div class="prepare-speakers">
|
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
||||||
<h2>Speaker pronunciation guide</h2>
|
|
||||||
<p class="hint">Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.</p>
|
|
||||||
<ul class="speaker-list">
|
|
||||||
{% for speaker_id, speaker in roster.items() %}
|
|
||||||
{% set spoken_name = speaker.pronunciation or speaker.label %}
|
|
||||||
{% set preview_text = preview_template | replace("{{name}}", spoken_name) %}
|
|
||||||
<li class="speaker-list__item">
|
|
||||||
<div class="speaker-list__header">
|
|
||||||
<span class="speaker-list__name">{{ speaker.label }}</span>
|
|
||||||
<button type="button"
|
|
||||||
class="icon-button speaker-list__preview"
|
|
||||||
data-role="speaker-preview"
|
|
||||||
data-speaker-id="{{ speaker_id }}"
|
|
||||||
data-preview-text="{{ preview_text|e }}"
|
|
||||||
data-language="{{ pending.language }}"
|
|
||||||
data-voice="{{ speaker.resolved_voice or speaker.voice_formula or speaker.voice or pending.voice }}"
|
|
||||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
|
||||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
|
||||||
aria-label="Preview pronunciation for {{ speaker.label }}"
|
|
||||||
title="Preview pronunciation">
|
|
||||||
🔊
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
|
||||||
<span>Pronunciation</span>
|
|
||||||
<input type="text"
|
|
||||||
id="speaker-{{ speaker_id }}-pronunciation"
|
|
||||||
name="speaker-{{ speaker_id }}-pronunciation"
|
|
||||||
value="{{ speaker.pronunciation or '' }}"
|
|
||||||
placeholder="{{ speaker.label }}">
|
|
||||||
</label>
|
|
||||||
{% if speaker.get('analysis_count') %}
|
|
||||||
<p class="hint speaker-list__stats">{{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert--error">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if notice %}
|
|
||||||
<div class="alert alert--info">{{ notice }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" class="prepare-form" id="prepare-form">
|
<form method="post" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" class="prepare-form" id="prepare-form">
|
||||||
|
<div class="prepare-summary">
|
||||||
|
<div class="prepare-summary__stats">
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Source</dt>
|
||||||
|
<dd>{{ pending.original_filename }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Language</dt>
|
||||||
|
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Default voice</dt>
|
||||||
|
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Total chapters</dt>
|
||||||
|
<dd>{{ pending.chapters|length }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Characters</dt>
|
||||||
|
<dd>{{ '{:,}'.format(pending.total_characters|default(0)) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Chapter intro delay</dt>
|
||||||
|
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Chunk granularity</dt>
|
||||||
|
<dd>{{ pending.chunk_level|replace('_', ' ')|title }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Speaker mode</dt>
|
||||||
|
<dd>{{ pending.speaker_mode|replace('_', ' ')|title }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Speaker analysis threshold</dt>
|
||||||
|
<dd>{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>EPUB 3 package</dt>
|
||||||
|
<dd>{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
{% if applied_languages %}
|
||||||
|
<div>
|
||||||
|
<dt>Config languages</dt>
|
||||||
|
<dd>{{ applied_languages | map('upper') | join(', ') }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% if analysis_speakers or show_analysis_prompt or has_metadata %}
|
||||||
|
<div class="prepare-summary__insights">
|
||||||
|
{% if analysis_speakers %}
|
||||||
|
{% set ordered_ids = analysis.get('ordered_speakers', []) %}
|
||||||
|
<div class="prepare-analysis">
|
||||||
|
<h2>Detected speakers</h2>
|
||||||
|
{% if ordered_ids %}
|
||||||
|
<ul>
|
||||||
|
{% for sid in ordered_ids %}
|
||||||
|
{% set speaker = analysis_speakers.get(sid) %}
|
||||||
|
{% if speaker %}
|
||||||
|
<li>
|
||||||
|
<strong>{{ speaker.label }}</strong>
|
||||||
|
{% if speaker.gender and speaker.gender != 'unknown' %}
|
||||||
|
· {{ speaker.gender|title }}
|
||||||
|
{% endif %}
|
||||||
|
· {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif show_analysis_prompt %}
|
||||||
|
<div class="prepare-analysis">
|
||||||
|
<h2>Detected speakers</h2>
|
||||||
|
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_metadata %}
|
||||||
|
<div class="prepare-metadata">
|
||||||
|
<h2>Metadata</h2>
|
||||||
|
<ul>
|
||||||
|
{% for key, value in pending.metadata_tags.items() %}
|
||||||
|
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert--error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if notice %}
|
||||||
|
<div class="alert alert--info">{{ notice }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="prepare-speaker-config">
|
||||||
|
<div class="prepare-speaker-config__header">
|
||||||
|
<h2>Speaker configuration</h2>
|
||||||
|
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
|
||||||
|
</div>
|
||||||
|
<div class="prepare-speaker-config__grid">
|
||||||
|
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
|
||||||
|
<span>Saved preset</span>
|
||||||
|
<select id="applied_speaker_config" name="applied_speaker_config">
|
||||||
|
<option value="">None</option>
|
||||||
|
{% for config in speaker_configs %}
|
||||||
|
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
|
||||||
|
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
|
||||||
|
</label>
|
||||||
|
<div class="prepare-speaker-config__actions">
|
||||||
|
<button type="submit"
|
||||||
|
class="button button--ghost"
|
||||||
|
name="apply_speaker_config"
|
||||||
|
value="1"
|
||||||
|
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||||
|
formmethod="post"
|
||||||
|
formnovalidate
|
||||||
|
{% if not speaker_configs %}disabled{% endif %}>
|
||||||
|
Apply preset
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="button button--ghost"
|
||||||
|
name="randomize_speaker_config"
|
||||||
|
value="1"
|
||||||
|
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||||
|
formmethod="post"
|
||||||
|
formnovalidate
|
||||||
|
{% if not speaker_configs %}disabled{% endif %}>
|
||||||
|
Randomize compatible voices
|
||||||
|
</button>
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="save_speaker_config" value="1">
|
||||||
|
<span>Save roster updates back to preset</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if roster %}
|
||||||
|
<div class="prepare-speakers">
|
||||||
|
<h2>Speaker settings</h2>
|
||||||
|
<p class="hint">Set pronunciations, lock specific voices, or limit the randomizer by language and gender.</p>
|
||||||
|
<ul class="speaker-list">
|
||||||
|
{% for speaker_id, speaker in roster.items() %}
|
||||||
|
{% set spoken_name = speaker.pronunciation or speaker.label %}
|
||||||
|
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
|
||||||
|
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
|
||||||
|
{% set allowed_langs = speaker.config_languages or speaker.languages or [] %}
|
||||||
|
{% set seen = namespace(values=[]) %}
|
||||||
|
<li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
|
||||||
|
<div class="speaker-line speaker-list__header">
|
||||||
|
<span class="speaker-list__name">{{ speaker.label }}</span>
|
||||||
|
<button type="button"
|
||||||
|
class="icon-button speaker-list__preview"
|
||||||
|
data-role="speaker-preview"
|
||||||
|
data-speaker-id="{{ speaker_id }}"
|
||||||
|
data-preview-text="{{ preview_text|e }}"
|
||||||
|
data-language="{{ pending.language }}"
|
||||||
|
data-voice="{{ selected_voice or pending.voice }}"
|
||||||
|
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||||
|
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||||
|
aria-label="Preview pronunciation for {{ speaker.label }}"
|
||||||
|
title="Preview pronunciation">
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="speaker-list__meta">
|
||||||
|
{% if speaker.gender and speaker.gender != 'unknown' %}
|
||||||
|
<span class="badge badge--info">{{ speaker.gender|title }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge--muted">Gender unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if speaker.get('analysis_count') %}
|
||||||
|
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||||
|
<span>Pronunciation</span>
|
||||||
|
<input type="text"
|
||||||
|
id="speaker-{{ speaker_id }}-pronunciation"
|
||||||
|
name="speaker-{{ speaker_id }}-pronunciation"
|
||||||
|
value="{{ speaker.pronunciation or '' }}"
|
||||||
|
placeholder="{{ speaker.label }}">
|
||||||
|
</label>
|
||||||
|
<div class="speaker-list__controls">
|
||||||
|
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
|
||||||
|
<span>Assigned voice</span>
|
||||||
|
<select id="speaker-{{ speaker_id }}-voice"
|
||||||
|
name="speaker-{{ speaker_id }}-voice"
|
||||||
|
data-role="speaker-voice"
|
||||||
|
data-default-voice="{{ pending.voice }}"
|
||||||
|
{% if speaker.randomize %}disabled{% endif %}>
|
||||||
|
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||||
|
{% if speaker.recommended_voices %}
|
||||||
|
<optgroup label="Recommended">
|
||||||
|
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||||
|
{% if voice_id not in seen.values %}
|
||||||
|
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||||
|
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
|
||||||
|
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
|
||||||
|
</option>
|
||||||
|
{% set _ = seen.values.append(voice_id) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
<optgroup label="All voices">
|
||||||
|
{% for voice in options.voice_catalog %}
|
||||||
|
{% if voice.id not in seen.values %}
|
||||||
|
<option value="{{ voice.id }}"
|
||||||
|
{% if selected_voice == voice.id %}selected{% endif %}>
|
||||||
|
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="speaker-list__inline">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="speaker-{{ speaker_id }}-randomize"
|
||||||
|
value="1"
|
||||||
|
data-role="randomize-toggle"
|
||||||
|
{% if speaker.randomize %}checked{% endif %}>
|
||||||
|
<span>Randomize compatible voice on analyze</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-languages">
|
||||||
|
<span>Allowed languages</span>
|
||||||
|
<select id="speaker-{{ speaker_id }}-languages"
|
||||||
|
name="speaker-{{ speaker_id }}-languages"
|
||||||
|
multiple
|
||||||
|
size="4"
|
||||||
|
data-role="speaker-languages"
|
||||||
|
{% if not allowed_langs %}data-placeholder="All languages"{% endif %}>
|
||||||
|
{% for code, label in options.languages.items() %}
|
||||||
|
<option value="{{ code }}" {% if code in allowed_langs %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">When randomizing, voices will be selected from these languages.</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if speaker.recommended_voices %}
|
||||||
|
<div class="speaker-list__recommendations">
|
||||||
|
<span class="hint">Suggested:</span>
|
||||||
|
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||||
|
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||||
|
<button type="button"
|
||||||
|
class="chip"
|
||||||
|
data-role="recommended-voice"
|
||||||
|
data-voice="{{ voice_id }}"
|
||||||
|
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
|
||||||
|
{{ voice_meta.display_name or voice_id }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="chapter-grid">
|
<div class="chapter-grid">
|
||||||
{% for chapter in pending.chapters %}
|
{% for chapter in pending.chapters %}
|
||||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||||
@@ -206,6 +365,7 @@
|
|||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prepare-options">
|
<div class="prepare-options">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="chunk_level">Chunk granularity</label>
|
<label for="chunk_level">Chunk granularity</label>
|
||||||
@@ -241,6 +401,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prepare-actions">
|
<div class="prepare-actions">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="button button--ghost"
|
class="button button--ghost"
|
||||||
|
|||||||
@@ -3,7 +3,27 @@
|
|||||||
{% block title %}abogen · Queue{% endblock %}
|
{% block title %}abogen · Queue{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
|
<section class="card">
|
||||||
{{ jobs_panel|safe }}
|
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||||
|
<span class="step-indicator__item is-complete">
|
||||||
|
<span class="step-indicator__index">1</span>
|
||||||
|
<span class="step-indicator__label">Upload</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item is-complete">
|
||||||
|
<span class="step-indicator__index">2</span>
|
||||||
|
<span class="step-indicator__label">Speakers</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item is-active">
|
||||||
|
<span class="step-indicator__index">3</span>
|
||||||
|
<span class="step-indicator__label">Queue</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="jobs-panel"
|
||||||
|
hx-get="{{ url_for('web.jobs_partial') }}"
|
||||||
|
hx-trigger="load, every 3s"
|
||||||
|
hx-target="#jobs-panel"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
{{ jobs_panel|safe }}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% macro speaker_row(row_key, speaker, options) %}
|
||||||
|
{% set languages = speaker.languages or speaker.config_languages or [] %}
|
||||||
|
{% set gender = (speaker.gender or 'unknown') %}
|
||||||
|
{% set randomize = speaker.randomize %}
|
||||||
|
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
|
||||||
|
<input type="hidden" name="speaker_rows" value="{{ row_key }}">
|
||||||
|
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
|
||||||
|
<div class="speaker-config-row__grid">
|
||||||
|
<label class="field" for="speaker-{{ row_key }}-label">
|
||||||
|
<span>Label</span>
|
||||||
|
<input type="text" id="speaker-{{ row_key }}-label" name="speaker-{{ row_key }}-label" value="{{ speaker.label or '' }}" placeholder="Character name" required>
|
||||||
|
</label>
|
||||||
|
<label class="field" for="speaker-{{ row_key }}-gender">
|
||||||
|
<span>Gender</span>
|
||||||
|
<select id="speaker-{{ row_key }}-gender" name="speaker-{{ row_key }}-gender">
|
||||||
|
<option value="unknown" {% if gender == 'unknown' %}selected{% endif %}>Unknown</option>
|
||||||
|
<option value="female" {% if gender == 'female' %}selected{% endif %}>Female</option>
|
||||||
|
<option value="male" {% if gender == 'male' %}selected{% endif %}>Male</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field" for="speaker-{{ row_key }}-voice">
|
||||||
|
<span>Preferred voice</span>
|
||||||
|
<select id="speaker-{{ row_key }}-voice" name="speaker-{{ row_key }}-voice">
|
||||||
|
<option value="" {% if not speaker.voice %}selected{% endif %}>Random compatible voice</option>
|
||||||
|
{% for voice in options.voice_catalog %}
|
||||||
|
<option value="{{ voice.id }}" {% if speaker.voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field" for="speaker-{{ row_key }}-languages">
|
||||||
|
<span>Allowed languages</span>
|
||||||
|
<select id="speaker-{{ row_key }}-languages" name="speaker-{{ row_key }}-languages" multiple size="4">
|
||||||
|
{% for code, label in options.languages.items() %}
|
||||||
|
<option value="{{ code }}" {% if code in languages %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">Limit randomization to specific TTS language families.</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="speaker-config-row__footer">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="speaker-{{ row_key }}-randomize" value="1" {% if randomize %}checked{% endif %}>
|
||||||
|
<span>Randomize compatible voice when applied</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="button button--ghost button--small" data-action="remove-speaker">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block title %}Speaker presets{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="card speaker-configs">
|
||||||
|
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||||
|
<span class="step-indicator__item is-complete">
|
||||||
|
<span class="step-indicator__index">1</span>
|
||||||
|
<span class="step-indicator__label">Upload</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item is-active">
|
||||||
|
<span class="step-indicator__index">2</span>
|
||||||
|
<span class="step-indicator__label">Speakers</span>
|
||||||
|
</span>
|
||||||
|
<span class="step-indicator__item">
|
||||||
|
<span class="step-indicator__index">3</span>
|
||||||
|
<span class="step-indicator__label">Queue</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card__title">Speaker presets</div>
|
||||||
|
<p class="card__subtitle">Store recurring casts, control randomization defaults, and keep voices consistent between books.</p>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert--success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert--error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="speaker-configs__layout">
|
||||||
|
<aside class="speaker-configs__sidebar">
|
||||||
|
<div class="speaker-configs__sidebar-header">
|
||||||
|
<h2>Saved presets</h2>
|
||||||
|
<a class="button button--ghost button--small" href="{{ url_for('web.speaker_configs_page') }}">New preset</a>
|
||||||
|
</div>
|
||||||
|
<ul class="speaker-configs__list">
|
||||||
|
{% for config in configs %}
|
||||||
|
<li class="speaker-configs__entry {% if editing_name == config.name %}is-active{% endif %}">
|
||||||
|
<a href="{{ url_for('web.speaker_configs_page', config=config.name) }}">{{ config.name }}</a>
|
||||||
|
<span class="speaker-configs__meta">{{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</span>
|
||||||
|
<form method="post" action="{{ url_for('web.delete_speaker_config_route', name=config.name) }}" class="speaker-configs__delete" onsubmit="return confirm('Delete preset {{ config.name }}?');">
|
||||||
|
<button type="submit" class="link-button">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="speaker-configs__empty">No presets yet. Create your first configuration on the right.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<div class="speaker-configs__editor">
|
||||||
|
<form method="post" class="speaker-config-form" id="speaker-config-form">
|
||||||
|
<input type="hidden" name="config_version" value="{{ editing.version or 1 }}">
|
||||||
|
<div class="speaker-config-form__grid">
|
||||||
|
<label class="field" for="config_name">
|
||||||
|
<span>Preset name</span>
|
||||||
|
<input type="text" id="config_name" name="config_name" value="{{ editing_name }}" placeholder="Mystery duo" required>
|
||||||
|
</label>
|
||||||
|
<label class="field" for="config_language">
|
||||||
|
<span>Primary language</span>
|
||||||
|
<select id="config_language" name="config_language">
|
||||||
|
{% for code, label in options.languages.items() %}
|
||||||
|
<option value="{{ code }}" {% if editing.language == code %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field" for="config_languages">
|
||||||
|
<span>Allowed languages</span>
|
||||||
|
{% set allowed_languages = editing.languages or [] %}
|
||||||
|
<select id="config_languages" name="config_languages" multiple size="5">
|
||||||
|
{% for code, label in options.languages.items() %}
|
||||||
|
<option value="{{ code }}" {% if code in allowed_languages %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">Optional: restrict auto-randomized voices to these languages.</p>
|
||||||
|
</label>
|
||||||
|
<label class="field" for="config_default_voice">
|
||||||
|
<span>Fallback voice</span>
|
||||||
|
<select id="config_default_voice" name="config_default_voice">
|
||||||
|
<option value="" {% if not editing.default_voice %}selected{% endif %}>Use narrator voice</option>
|
||||||
|
{% for voice in options.voice_catalog %}
|
||||||
|
<option value="{{ voice.id }}" {% if editing.default_voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field field--full" for="config_notes">
|
||||||
|
<span>Notes</span>
|
||||||
|
<textarea id="config_notes" name="config_notes" rows="3" placeholder="Pronunciation reminders, references, or character context...">{{ editing.notes or '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="speaker-config-rows">
|
||||||
|
<div class="speaker-config-rows__header">
|
||||||
|
<h3>Speakers</h3>
|
||||||
|
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.</p>
|
||||||
|
<div class="speaker-config-rows__list" data-role="speaker-rows">
|
||||||
|
{% set speakers_map = editing.speakers or {} %}
|
||||||
|
{% if speakers_map %}
|
||||||
|
{% for speaker_id, speaker in speakers_map|dictsort(attribute='1.label') %}
|
||||||
|
{{ speaker_row(speaker_id, speaker, options) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="speaker-config-rows__empty" data-role="empty-state">No speakers yet. Add your first character.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="speaker-configs__actions">
|
||||||
|
<button type="submit" class="button">Save configuration</button>
|
||||||
|
<a class="button button--ghost" href="{{ url_for('web.index') }}">Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="speaker-row-template">
|
||||||
|
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'languages': [], 'voice': '', 'randomize': True}, options) | safe }}
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script type="module" src="{{ url_for('static', filename='speaker-configs.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user