From d01887f31bd5f59d4ef48b1c2db396286e5d6094 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 11:19:52 -0700 Subject: [PATCH] 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. --- abogen/speaker_analysis.py | 61 ++- abogen/speaker_configs.py | 228 ++++++++++ abogen/web/routes.py | 579 ++++++++++++++++++++++++-- abogen/web/service.py | 8 + abogen/web/static/prepare.js | 74 ++++ abogen/web/static/speaker-configs.js | 97 +++++ abogen/web/static/styles.css | 318 +++++++++++++- abogen/web/templates/base.html | 1 + abogen/web/templates/index.html | 24 ++ abogen/web/templates/prepare_job.html | 431 +++++++++++++------ abogen/web/templates/queue.html | 24 +- abogen/web/templates/speakers.html | 178 ++++++++ 12 files changed, 1850 insertions(+), 173 deletions(-) create mode 100644 abogen/speaker_configs.py create mode 100644 abogen/web/static/speaker-configs.js create mode 100644 abogen/web/templates/speakers.html diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index adf2916..9f2352c 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -38,6 +38,36 @@ _PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE) _QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]') _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) +_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} @@ -216,8 +246,11 @@ def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optio colon_match = _COLON_PATTERN.match(normalized) if colon_match: 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() - return raw_label, "high", quote + return cleaned, "high", quote quote = _extract_quote(normalized) 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) if candidate: - return candidate, "high", quote + cleaned = _normalize_candidate_name(candidate) + if cleaned: + return cleaned, "high", quote if last_explicit: 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: - parts = value.strip().split() + normalized = _normalize_candidate_name(value) + if not normalized: + return False + parts = normalized.split() if not parts: 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]: @@ -340,4 +378,17 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: if current in {"male", "female"}: return current - return "unknown" \ No newline at end of file + 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 \ No newline at end of file diff --git a/abogen/speaker_configs.py b/abogen/speaker_configs.py new file mode 100644 index 0000000..54641b4 --- /dev/null +++ b/abogen/speaker_configs.py @@ -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()) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index e3c46d5..06f313b 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -59,6 +59,16 @@ from abogen.voice_profiles import ( from abogen.voice_formulas import get_new_voice 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 .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32 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 -_MAX_ANALYSIS_SPEAKERS = 6 - - def _build_narrator_roster( voice: str, voice_profile: Optional[str], @@ -120,11 +127,19 @@ def _build_speaker_roster( base_voice: str, voice_profile: Optional[str], existing: Optional[Mapping[str, Any]] = None, + order: Optional[Iterable[str]] = None, ) -> Dict[str, Any]: roster = _build_narrator_roster(base_voice, voice_profile, existing) existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {} speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {} - 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": continue if payload.get("suppressed"): @@ -136,6 +151,7 @@ def _build_speaker_roster( "voice": base_voice, "analysis_confidence": payload.get("confidence"), "analysis_count": payload.get("count"), + "gender": payload.get("gender", "unknown"), } if isinstance(previous, Mapping): for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"): @@ -145,18 +161,286 @@ def _build_speaker_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( *, chapters: List[Dict[str, Any]], chunks: List[Dict[str, Any]], + analysis_chunks: Optional[List[Dict[str, Any]]] = None, speaker_mode: str, voice: str, voice_profile: Optional[str], threshold: int, existing_roster: Optional[Mapping[str, Any]] = None, run_analysis: bool = True, -) -> 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] + analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)] threshold_value = max(1, int(threshold)) analysis_enabled = speaker_mode == "multi" and run_analysis @@ -191,12 +475,25 @@ def _prepare_speaker_metadata( narrator_pron = roster["narrator"].get("pronunciation") if 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( - 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() + 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", {}) suppressed_ids = analysis_payload.get("suppressed", []) suppressed_details: List[Dict[str, Any]] = [] @@ -222,7 +519,33 @@ def _prepare_speaker_metadata( } ) 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") if isinstance(speakers_payload, dict): for roster_id, roster_payload in roster.items(): @@ -231,6 +554,13 @@ def _prepare_speaker_metadata( if 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: chunk_id = str(chunk.get("id")) speaker_id = assignments.get(chunk_id, "narrator") @@ -238,12 +568,22 @@ def _prepare_speaker_metadata( speaker_meta = roster.get(speaker_id) chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id - return chunk_list, roster, analysis_payload + return chunk_list, roster, analysis_payload, applied_languages, updated_config def _apply_prepare_form( 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() 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" @@ -288,6 +628,16 @@ def _apply_prepare_form( existing_narrator["voice_profile"] = pending.voice_profile 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): for speaker_id, payload in list(pending.speakers.items()): if not isinstance(payload, dict): @@ -300,6 +650,29 @@ def _apply_prepare_form( else: 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() errors: List[str] = [] 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")] - 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]] = [ (re.compile(r"\btitle\s+page\b"), 3.0), (re.compile(r"\bcopyright\b"), 2.4), @@ -487,6 +870,7 @@ def _build_voice_catalog() -> List[Dict[str, str]]: "language": language_code, "language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()), "gender": gender_map.get(gender_code, "Unknown"), + "gender_code": gender_code, "display_name": rest.replace("_", " ").title() if rest else voice_id, } ) @@ -506,6 +890,7 @@ def _template_options() -> Dict[str, Any]: "formula": _formula_from_profile(entry or {}) or "", } ) + voice_catalog = _build_voice_catalog() return { "languages": LANGUAGE_DESCRIPTIONS, "voices": VOICES_INTERNAL, @@ -515,9 +900,11 @@ def _template_options() -> Dict[str, Any]: "voice_profiles": ordered_profiles, "voice_profile_options": profile_options, "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, "voice_profiles_data": profiles, + "speaker_configs": list_configs(), "chunk_levels": _CHUNK_LEVEL_OPTIONS, "speaker_modes": _SPEAKER_MODE_OPTIONS, "speaker_analysis_threshold": current_settings.get( @@ -893,6 +1280,66 @@ def voice_profiles_page() -> str: 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//delete") +def delete_speaker_config_route(name: str) -> ResponseReturnValue: + delete_config(name) + return redirect(url_for("web.speaker_configs_page")) + + @web_bp.post("/voices") def save_voice_profile_route() -> ResponseReturnValue: name = request.form.get("name", "").strip() @@ -1325,6 +1772,16 @@ def enqueue_job() -> ResponseReturnValue: base_voice = request.form.get("voice", "af_alloy") profile_selection = (request.form.get("voice_profile") or "__standard").strip() custom_formula_raw = request.form.get("voice_formula", "").strip() + selected_speaker_config = (request.form.get("speaker_config") or "").strip() + speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None + 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", ""}: profile_name = "" @@ -1377,6 +1834,7 @@ def enqueue_job() -> ResponseReturnValue: selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")] raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal) + analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence") analysis_threshold = _coerce_int( settings.get("speaker_analysis_threshold"), @@ -1385,15 +1843,19 @@ def enqueue_job() -> ResponseReturnValue: maximum=25, ) - initial_analysis = speaker_mode_value != "multi" - processed_chunks, speakers, analysis_payload = _prepare_speaker_metadata( + initial_analysis = speaker_mode_value == "multi" + processed_chunks, speakers, analysis_payload, config_languages, _ = _prepare_speaker_metadata( chapters=selected_chapter_sources, chunks=raw_chunks, + analysis_chunks=analysis_chunks, speaker_mode=speaker_mode_value, voice=voice, voice_profile=selected_profile or None, threshold=analysis_threshold, run_analysis=initial_analysis, + speaker_config=speaker_config_payload, + apply_config=bool(speaker_config_payload), + allow_randomize=config_randomize_default, ) pending = PendingJob( @@ -1431,10 +1893,17 @@ def enqueue_job() -> ResponseReturnValue: speakers=speakers, speaker_analysis=analysis_payload, speaker_analysis_threshold=analysis_threshold, - analysis_requested=False, + analysis_requested=initial_analysis, ) 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)) @@ -1455,9 +1924,17 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: abort(404) 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: 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.") 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]] if getattr(pending, "analysis_requested", False): @@ -1485,27 +1965,44 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: else: 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, chunks=raw_chunks, + analysis_chunks=analysis_chunks, speaker_mode=pending.speaker_mode, voice=pending.voice, voice_profile=pending.voice_profile, threshold=pending.speaker_analysis_threshold, existing_roster=existing_roster, run_analysis=True, + speaker_config=speaker_config_payload, + apply_config=apply_config_requested or bool(speaker_config_payload), + allow_randomize=randomize_requested, + persist_config=persist_config_requested, ) pending.chunks = processed_chunks pending.speakers = roster pending.speaker_analysis = analysis_payload + if config_languages: + pending.speaker_voice_languages = list(config_languages) + config_name = getattr(pending, "applied_speaker_config", None) + if updated_config and isinstance(config_name, str) and config_name: + configs = load_configs() + configs[config_name] = updated_config + save_configs(configs) setattr(pending, "analysis_requested", True) if selected_total: pending.total_characters = selected_total 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/") @@ -1516,9 +2013,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: abort(404) 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: 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.") 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) if analysis_active: existing_roster: Optional[Mapping[str, Any]] = pending.speakers @@ -1541,19 +2047,33 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if isinstance(narrator_payload, Mapping): narrator_only["narrator"] = dict(narrator_payload) 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, chunks=raw_chunks, + analysis_chunks=analysis_chunks, speaker_mode=pending.speaker_mode, voice=pending.voice, voice_profile=pending.voice_profile, threshold=pending.speaker_analysis_threshold, existing_roster=existing_roster, run_analysis=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.speakers = roster pending.speaker_analysis = analysis_payload + if config_languages: + pending.speaker_voice_languages = list(config_languages) + config_name = getattr(pending, "applied_speaker_config", None) + if updated_config and isinstance(config_name, str) and config_name: + configs = load_configs() + configs[config_name] = updated_config + save_configs(configs) total_characters = selected_total or pending.total_characters @@ -1594,6 +2114,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: generate_epub3=pending.generate_epub3, 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")) diff --git a/abogen/web/service.py b/abogen/web/service.py index f140f6d..380a7fa 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -98,6 +98,10 @@ class Job: speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 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: 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_threshold": self.speaker_analysis_threshold, "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_threshold: int = 3 analysis_requested: bool = False + speaker_voice_languages: List[str] = field(default_factory=list) + applied_speaker_config: Optional[str] = None class ConversionService: diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 329c9a2..091e090 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -76,4 +76,78 @@ document.addEventListener("DOMContentLoaded", () => { speakerModeSelect.addEventListener("change", 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 })); + }); }); diff --git a/abogen/web/static/speaker-configs.js b/abogen/web/static/speaker-configs.js new file mode 100644 index 0000000..4185f21 --- /dev/null +++ b/abogen/web/static/speaker-configs.js @@ -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(); +} diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 242198d..bfe751d 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -125,6 +125,65 @@ body { 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 { font-size: 1.4rem; font-weight: 600; @@ -157,6 +216,10 @@ body { justify-self: stretch; } +.form-grid > .grid:last-child { + width: min(100%, 540px); +} + .button { appearance: none; border: none; @@ -223,6 +286,7 @@ body { .field select, .field textarea { width: 100%; + box-sizing: border-box; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.2); background: rgba(15, 23, 42, 0.45); @@ -310,6 +374,16 @@ body { 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 { list-style: none; margin: 0; @@ -529,7 +603,9 @@ body { } .field--file input[type="file"] { - width: 100%; + width: min(100%, 420px); + max-width: 420px; + align-self: flex-start; border-radius: 14px; border: 1px dashed rgba(148, 163, 184, 0.35); background: rgba(15, 23, 42, 0.45); @@ -711,6 +787,53 @@ body { 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 { list-style: none; margin: 0; @@ -802,6 +925,60 @@ body { 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 { margin: 0; color: var(--muted); @@ -871,19 +1048,143 @@ body { accent-color: var(--accent); } -.chapter-card__metrics { - color: var(--muted); - font-size: 0.85rem; +.speaker-configs__layout { + display: grid; + gap: 2rem; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + align-items: start; } -.chapter-card__body { +.speaker-configs__sidebar { display: grid; 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; - 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 { @@ -1603,6 +1904,9 @@ progress.progress::-moz-progress-bar { border: 1px solid rgba(148, 163, 184, 0.25); background: rgba(11, 18, 34, 0.85); min-height: 140px; + width: min(100%, 420px); + max-width: 420px; + align-self: flex-start; } .voice-status { diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index bc8a09f..f7de887 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -19,6 +19,7 @@ {% set endpoint = request.endpoint or '' %} Dashboard Voice Mixer + Speakers Queue Settings diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index d7cf990..31e17c4 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -4,6 +4,20 @@ {% block content %}
+
+ + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +

Create a New Audiobook

@@ -45,6 +59,16 @@
+
+ + +

Optional: reuse a saved roster to keep character voices consistent.

+
diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 66f3dd8..b3da873 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -4,150 +4,309 @@ {% block content %}
+
+ + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +
Prepare audiobook
-

Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.

+

Review the detected chapters, tune the speaker roster, and optionally override the voice per chapter before sending the job to the queue.

{% set analysis = pending.speaker_analysis or {} %} {% set analysis_speakers = analysis.get('speakers', {}) %} {% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %} {% set has_metadata = pending.metadata_tags %} -
-
-
-
-
Source
-
{{ pending.original_filename }}
-
-
-
Language
-
{{ options.languages.get(pending.language, pending.language|upper) }}
-
-
-
Default voice
-
{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
-
-
-
Total chapters
-
{{ pending.chapters|length }}
-
-
-
Characters
-
{{ '{:,}'.format(pending.total_characters|default(0)) }}
-
-
-
Chapter intro delay
-
{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
-
-
-
Chunk granularity
-
{{ pending.chunk_level|replace('_', ' ')|title }}
-
-
-
Speaker mode
-
{{ pending.speaker_mode|replace('_', ' ')|title }}
-
-
-
Speaker analysis threshold
-
{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
-
-
-
EPUB 3 package
-
{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
-
-
-
- {% if analysis_speakers or show_analysis_prompt or has_metadata %} -
- {% 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 %} -
-

Detected speakers

- {% if active.items %} -
    - {% for speaker in active.items|sort(attribute='label') %} -
  • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
  • - {% endfor %} -
- {% else %} -

No additional speakers met the threshold yet. All dialogue will use the narrator voice.

- {% endif %} -
- {% elif show_analysis_prompt %} -
-

Detected speakers

-

Press Analyze speakers after selecting chapters to discover recurring voices.

-
- {% endif %} - {% if has_metadata %} - - {% endif %} -
- {% endif %} -
{% set roster = pending.speakers or {} %} - {% if roster %} - {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} -
-

Speaker pronunciation guide

-

Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

-
    - {% for speaker_id, speaker in roster.items() %} - {% set spoken_name = speaker.pronunciation or speaker.label %} - {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} -
  • -
    - {{ speaker.label }} - -
    - - {% if speaker.get('analysis_count') %} -

    {{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}

    - {% endif %} -
  • - {% endfor %} -
-
- {% endif %} - - {% if error %} -
{{ error }}
- {% endif %} - {% if notice %} -
{{ notice }}
- {% endif %} + {% set speaker_configs = options.speaker_configs or [] %} + {% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %} + {% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
+
+
+
+
Source
+
{{ pending.original_filename }}
+
+
+
Language
+
{{ options.languages.get(pending.language, pending.language|upper) }}
+
+
+
Default voice
+
{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
+
+
+
Total chapters
+
{{ pending.chapters|length }}
+
+
+
Characters
+
{{ '{:,}'.format(pending.total_characters|default(0)) }}
+
+
+
Chapter intro delay
+
{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
+
+
+
Chunk granularity
+
{{ pending.chunk_level|replace('_', ' ')|title }}
+
+
+
Speaker mode
+
{{ pending.speaker_mode|replace('_', ' ')|title }}
+
+
+
Speaker analysis threshold
+
{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
+
+
+
EPUB 3 package
+
{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
+
+ {% if applied_languages %} +
+
Config languages
+
{{ applied_languages | map('upper') | join(', ') }}
+
+ {% endif %} +
+
+ {% if analysis_speakers or show_analysis_prompt or has_metadata %} +
+ {% if analysis_speakers %} + {% set ordered_ids = analysis.get('ordered_speakers', []) %} +
+

Detected speakers

+ {% if ordered_ids %} +
    + {% for sid in ordered_ids %} + {% set speaker = analysis_speakers.get(sid) %} + {% if speaker %} +
  • + {{ speaker.label }} + {% if speaker.gender and speaker.gender != 'unknown' %} + · {{ speaker.gender|title }} + {% endif %} + · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }} +
  • + {% endif %} + {% endfor %} +
+ {% else %} +

No additional speakers met the threshold yet. All dialogue will use the narrator voice.

+ {% endif %} +
+ {% elif show_analysis_prompt %} +
+

Detected speakers

+

Press Analyze speakers after selecting chapters to discover recurring voices.

+
+ {% endif %} + {% if has_metadata %} + + {% endif %} +
+ {% endif %} +
+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if notice %} +
{{ notice }}
+ {% endif %} + +
+
+

Speaker configuration

+

Reuse saved presets to keep character voices consistent between projects.

+
+
+ +
+ + + +
+
+
+ + {% if roster %} +
+

Speaker settings

+

Set pronunciations, lock specific voices, or limit the randomizer by language and gender.

+
    + {% 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=[]) %} +
  • +
    + {{ speaker.label }} + +
    +
    + {% if speaker.gender and speaker.gender != 'unknown' %} + {{ speaker.gender|title }} + {% else %} + Gender unknown + {% endif %} + {% if speaker.get('analysis_count') %} + {{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence + {% endif %} +
    + +
    + +
    + +
    + +
    + {% if speaker.recommended_voices %} +
    + Suggested: + {% for voice_id in speaker.recommended_voices[:6] %} + {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} + + {% endfor %} +
    + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} +
{% for chapter in pending.chapters %} {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} @@ -206,6 +365,7 @@ {% endfor %}
+
@@ -241,6 +401,7 @@
+
+
+
+{% endmacro %} + +{% block title %}Speaker presets{% endblock %} + +{% block content %} +
+
+ + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +
+
Speaker presets
+

Store recurring casts, control randomization defaults, and keep voices consistent between books.

+ + {% if message %} +
{{ message }}
+ {% endif %} + {% if error %} +
{{ error }}
+ {% endif %} + +
+ +
+
+ +
+ + + + + +
+ +
+
+

Speakers

+ +
+

Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.

+
+ {% 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 %} +
No speakers yet. Add your first character.
+ {% endif %} +
+
+ +
+ + Back to dashboard +
+
+
+
+ + +
+{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %}