diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 3cc784e..6067a8b 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -120,6 +120,34 @@ WORD_TOKEN_RE = re.compile(r"[A-Za-z0-9'’]+|[^A-Za-z0-9\s]") APOSTROPHE_CHARS = "’`´ꞌʼ" +TERMINAL_PUNCTUATION = {".", "?", "!", "…", ";", ":"} +CLOSING_PUNCTUATION = '"\'”’)]}»›' +ELLIPSIS_SUFFIXES = ("...", "…") +_LINE_SPLIT_RE = re.compile(r"(\n+)") + +TITLE_ABBREVIATIONS = { + "mr": "mister", + "mrs": "missus", + "ms": "miz", + "dr": "doctor", + "prof": "professor", + "rev": "reverend", +} + +SUFFIX_ABBREVIATIONS = { + "jr": "junior", + "sr": "senior", +} + +_TITLE_PATTERN = re.compile( + r"\b(?P" + "|".join(sorted(TITLE_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + re.IGNORECASE, +) +_SUFFIX_PATTERN = re.compile( + r"\b(?P" + "|".join(sorted(SUFFIX_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + re.IGNORECASE, +) + # ---------- Utility Functions ---------- def normalize_unicode_apostrophes(text: str) -> str: @@ -132,6 +160,73 @@ def tokenize(text: str) -> List[str]: # Simple tokenization preserving punctuation tokens return WORD_TOKEN_RE.findall(text) + +def _match_casing(template: str, replacement: str) -> str: + if template.isupper(): + return replacement.upper() + if template[:1].isupper() and template[1:].islower(): + return replacement.capitalize() + if template[:1].isupper(): + # Mixed case (e.g., Mc), fall back to title case + return replacement.capitalize() + return replacement + + +def expand_titles_and_suffixes(text: str) -> str: + def _replace(match: re.Match[str], mapping: dict[str, str]) -> str: + abbr = match.group("abbr") + lookup = mapping.get(abbr.lower()) + if not lookup: + return match.group(0) + return _match_casing(abbr, lookup) + + text = _TITLE_PATTERN.sub(lambda m: _replace(m, TITLE_ABBREVIATIONS), text) + text = _SUFFIX_PATTERN.sub(lambda m: _replace(m, SUFFIX_ABBREVIATIONS), text) + return text + + +def ensure_terminal_punctuation(text: str) -> str: + def _amend(segment: str) -> str: + if not segment or not segment.strip(): + return segment + + stripped = segment.rstrip() + trailing_ws = segment[len(stripped) :] + + match = re.match(rf"^(.*?)([{re.escape(CLOSING_PUNCTUATION)}]*)$", stripped) + if not match: + return segment + + body, closers = match.groups() + if not body: + return segment + + normalized_body = body.rstrip() + trailing_body_ws = body[len(normalized_body) :] + + if normalized_body.endswith(ELLIPSIS_SUFFIXES): + return normalized_body + trailing_body_ws + closers + trailing_ws + + last_char = normalized_body[-1] + if last_char in TERMINAL_PUNCTUATION: + return normalized_body + trailing_body_ws + closers + trailing_ws + + return normalized_body + "." + trailing_body_ws + closers + trailing_ws + + parts = _LINE_SPLIT_RE.split(text) + amended: List[str] = [] + for part in parts: + if not part: + continue + if part.startswith("\n"): + amended.append(part) + else: + amended.append(_amend(part)) + if not parts: + return _amend(text) + return "".join(amended) + + def is_cultural_name(token: str, cfg: ApostropheConfig) -> bool: if not cfg.protect_cultural_names: return False diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index b822e17..906d079 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -21,6 +21,8 @@ from abogen.epub3.exporter import build_epub3_package from abogen.kokoro_text_normalization import ( ApostropheConfig, apply_phoneme_hints, + expand_titles_and_suffixes, + ensure_terminal_punctuation, normalize_apostrophes, ) from abogen.text_extractor import ExtractedChapter, extract_from_path @@ -298,6 +300,8 @@ _APOSTROPHE_CONFIG = ApostropheConfig() def _normalize_for_pipeline(text: str) -> str: normalized, _details = normalize_apostrophes(text, _APOSTROPHE_CONFIG) + normalized = expand_titles_and_suffixes(normalized) + normalized = ensure_terminal_punctuation(normalized) if _APOSTROPHE_CONFIG.add_phoneme_hints: return apply_phoneme_hints(normalized, iz_marker=_APOSTROPHE_CONFIG.sibilant_iz_marker) return normalized diff --git a/abogen/web/routes.py b/abogen/web/routes.py index e4b6063..59eb3e2 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -13,7 +13,6 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast from flask import ( Blueprint, - Response, abort, current_app, jsonify, @@ -23,6 +22,7 @@ from flask import ( send_file, url_for, ) +from flask.typing import ResponseReturnValue from werkzeug.utils import secure_filename import numpy as np @@ -154,11 +154,13 @@ def _prepare_speaker_metadata( 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]]: chunk_list = [dict(chunk) for chunk in chunks] threshold_value = max(1, int(threshold)) + analysis_enabled = speaker_mode == "multi" and run_analysis - if speaker_mode != "multi": + if not analysis_enabled: for chunk in chunk_list: chunk["speaker_id"] = "narrator" chunk["speaker_label"] = "Narrator" @@ -239,6 +241,132 @@ def _prepare_speaker_metadata( return chunk_list, roster, analysis_payload +def _apply_prepare_form( + pending: PendingJob, form: Mapping[str, Any] +) -> tuple[ChunkLevel, List[Dict[str, Any]], List[Dict[str, Any]], List[str], int]: + raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() + if raw_chunk_level not in _CHUNK_LEVEL_VALUES: + raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph" + pending.chunk_level = raw_chunk_level + chunk_level_literal = cast(ChunkLevel, pending.chunk_level) + + raw_speaker_mode = (form.get("speaker_mode") or pending.speaker_mode or "single").strip().lower() + if raw_speaker_mode not in _SPEAKER_MODE_VALUES: + raw_speaker_mode = "single" + pending.speaker_mode = raw_speaker_mode + + pending.generate_epub3 = _coerce_bool(form.get("generate_epub3"), False) + + threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD) + raw_threshold = form.get("speaker_analysis_threshold") + if raw_threshold is not None: + pending.speaker_analysis_threshold = _coerce_int( + raw_threshold, + threshold_default, + minimum=1, + maximum=25, + ) + else: + pending.speaker_analysis_threshold = threshold_default + + if not pending.speakers: + narrator: Dict[str, Any] = { + "id": "narrator", + "label": "Narrator", + "voice": pending.voice, + } + if pending.voice_profile: + narrator["voice_profile"] = pending.voice_profile + pending.speakers = {"narrator": narrator} + else: + existing_narrator = pending.speakers.get("narrator") + if isinstance(existing_narrator, dict): + existing_narrator.setdefault("id", "narrator") + existing_narrator["label"] = existing_narrator.get("label", "Narrator") + existing_narrator["voice"] = pending.voice + if pending.voice_profile: + existing_narrator["voice_profile"] = pending.voice_profile + pending.speakers["narrator"] = existing_narrator + + if isinstance(pending.speakers, dict): + for speaker_id, payload in list(pending.speakers.items()): + if not isinstance(payload, dict): + continue + field_key = f"speaker-{speaker_id}-pronunciation" + raw_value = form.get(field_key, "") + pronunciation = raw_value.strip() + if pronunciation: + payload["pronunciation"] = pronunciation + else: + payload.pop("pronunciation", None) + + profiles = serialize_profiles() + errors: List[str] = [] + raw_delay = form.get("chapter_intro_delay") + if raw_delay is not None: + raw_normalized = raw_delay.strip() + if raw_normalized: + try: + pending.chapter_intro_delay = max(0.0, float(raw_normalized)) + except ValueError: + errors.append("Enter a valid number for the chapter intro delay.") + else: + pending.chapter_intro_delay = 0.0 + + overrides: List[Dict[str, Any]] = [] + selected_total = 0 + + for index, chapter in enumerate(pending.chapters): + enabled = form.get(f"chapter-{index}-enabled") == "on" + title_input = (form.get(f"chapter-{index}-title") or "").strip() + title = title_input or chapter.get("title") or f"Chapter {index + 1}" + voice_selection = form.get(f"chapter-{index}-voice", "__default") + formula_input = (form.get(f"chapter-{index}-formula") or "").strip() + + entry: Dict[str, Any] = { + "id": chapter.get("id") or f"{index:04d}", + "index": index, + "order": index, + "source_title": chapter.get("title") or title, + "title": title, + "text": chapter.get("text", ""), + "enabled": enabled, + } + entry["characters"] = len(entry["text"]) + + if enabled: + if voice_selection.startswith("voice:"): + entry["voice"] = voice_selection.split(":", 1)[1] + entry["resolved_voice"] = entry["voice"] + elif voice_selection.startswith("profile:"): + profile_name = voice_selection.split(":", 1)[1] + entry["voice_profile"] = profile_name + profile_entry = profiles.get(profile_name) or {} + formula_value = _formula_from_profile(profile_entry) + if formula_value: + entry["voice_formula"] = formula_value + entry["resolved_voice"] = formula_value + else: + errors.append(f"Profile '{profile_name}' has no configured voices.") + elif voice_selection == "formula": + if not formula_input: + errors.append(f"Provide a custom formula for chapter {index + 1}.") + else: + try: + _parse_voice_formula(formula_input) + except ValueError as exc: + errors.append(str(exc)) + else: + entry["voice_formula"] = formula_input + entry["resolved_voice"] = formula_input + selected_total += len(entry["text"] or "") + + overrides.append(entry) + pending.chapters[index] = dict(entry) + + enabled_overrides = [entry for entry in overrides if entry.get("enabled")] + + return chunk_level_literal, overrides, enabled_overrides, errors, selected_total _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), @@ -689,7 +817,7 @@ def queue_page() -> str: @web_bp.route("/settings", methods=["GET", "POST"]) -def settings_page() -> Response | str: +def settings_page() -> ResponseReturnValue: options = _template_options() current_settings = _load_settings() @@ -766,7 +894,7 @@ def voice_profiles_page() -> str: @web_bp.post("/voices") -def save_voice_profile_route() -> Response: +def save_voice_profile_route() -> ResponseReturnValue: name = request.form.get("name", "").strip() language = request.form.get("language", "a").strip() or "a" formula = request.form.get("formula", "").strip() @@ -780,18 +908,18 @@ def save_voice_profile_route() -> Response: @web_bp.post("/voices//delete") -def delete_voice_profile_route(name: str) -> Response: +def delete_voice_profile_route(name: str) -> ResponseReturnValue: delete_profile(name) return redirect(url_for("web.voice_profiles_page")) @api_bp.get("/voice-profiles") -def api_list_voice_profiles() -> Response: +def api_list_voice_profiles() -> ResponseReturnValue: return jsonify(_profiles_payload()) @api_bp.post("/voice-profiles") -def api_save_voice_profile() -> Response: +def api_save_voice_profile() -> ResponseReturnValue: payload = request.get_json(force=True, silent=False) name = (payload.get("name") or "").strip() if not name: @@ -819,13 +947,13 @@ def api_save_voice_profile() -> Response: @api_bp.delete("/voice-profiles/") -def api_delete_voice_profile(name: str) -> Response: +def api_delete_voice_profile(name: str) -> ResponseReturnValue: remove_profile(name) return jsonify({"ok": True, **_profiles_payload()}) @api_bp.post("/voice-profiles//duplicate") -def api_duplicate_voice_profile(name: str) -> Response: +def api_duplicate_voice_profile(name: str) -> ResponseReturnValue: payload = request.get_json(silent=True) or {} new_name = (payload.get("name") or payload.get("new_name") or "").strip() if not new_name: @@ -835,13 +963,18 @@ def api_duplicate_voice_profile(name: str) -> Response: @api_bp.post("/voice-profiles/import") -def api_import_voice_profiles() -> Response: +def api_import_voice_profiles() -> ResponseReturnValue: replace = False data: Optional[Dict[str, Any]] = None if "file" in request.files: file_storage = request.files["file"] try: - data = json.load(file_storage) + file_storage.stream.seek(0) + raw_bytes = file_storage.read() + text_payload = raw_bytes.decode("utf-8") + data = json.loads(text_payload) + except UnicodeDecodeError as exc: + abort(400, f"JSON file must be UTF-8 encoded: {exc}") except Exception as exc: # pragma: no cover - defensive abort(400, f"Invalid JSON file: {exc}") replace = request.form.get("replace_existing") in {"true", "1", "on"} @@ -863,7 +996,7 @@ def api_import_voice_profiles() -> Response: @api_bp.get("/voice-profiles/export") -def api_export_voice_profiles() -> Response: +def api_export_voice_profiles() -> ResponseReturnValue: names_param = request.args.get("names") names = None if names_param: @@ -882,7 +1015,7 @@ def api_export_voice_profiles() -> Response: @api_bp.post("/voice-profiles/preview") -def api_preview_voice_mix() -> Response: +def api_preview_voice_mix() -> ResponseReturnValue: payload = request.get_json(force=True, silent=False) language = (payload.get("language") or "a").strip() or "a" text = (payload.get("text") or "").strip() @@ -1010,7 +1143,7 @@ def api_preview_voice_mix() -> Response: @api_bp.post("/speaker-preview") -def api_speaker_preview() -> Response: +def api_speaker_preview() -> ResponseReturnValue: payload = request.get_json(force=True, silent=False) text = (payload.get("text") or "").strip() voice_spec = (payload.get("voice") or "").strip() @@ -1106,7 +1239,7 @@ def api_speaker_preview() -> Response: @web_bp.post("/jobs") -def enqueue_job() -> Response: +def enqueue_job() -> ResponseReturnValue: service = _service() uploads_dir = Path(current_app.config["UPLOAD_FOLDER"]) uploads_dir.mkdir(parents=True, exist_ok=True) @@ -1252,6 +1385,7 @@ def enqueue_job() -> Response: maximum=25, ) + initial_analysis = speaker_mode_value != "multi" processed_chunks, speakers, analysis_payload = _prepare_speaker_metadata( chapters=selected_chapter_sources, chunks=raw_chunks, @@ -1259,6 +1393,7 @@ def enqueue_job() -> Response: voice=voice, voice_profile=selected_profile or None, threshold=analysis_threshold, + run_analysis=initial_analysis, ) pending = PendingJob( @@ -1296,6 +1431,7 @@ def enqueue_job() -> Response: speakers=speakers, speaker_analysis=analysis_payload, speaker_analysis_threshold=analysis_threshold, + analysis_requested=False, ) service.store_pending_job(pending) @@ -1311,142 +1447,44 @@ def prepare_job(pending_id: str) -> str: return _render_prepare_page(pending) -@web_bp.post("/jobs/prepare/") -def finalize_job(pending_id: str) -> Response: +@web_bp.post("/jobs/prepare//analyze") +def analyze_pending_job(pending_id: str) -> ResponseReturnValue: service = _service() pending = service.get_pending_job(pending_id) if not pending: abort(404) pending = cast(PendingJob, pending) - raw_chunk_level = (request.form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() - if raw_chunk_level not in _CHUNK_LEVEL_VALUES: - raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph" - pending.chunk_level = raw_chunk_level - chunk_level_literal = cast(ChunkLevel, pending.chunk_level) + chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form( + pending, request.form + ) - raw_speaker_mode = (request.form.get("speaker_mode") or pending.speaker_mode or "single").strip().lower() - if raw_speaker_mode not in _SPEAKER_MODE_VALUES: - raw_speaker_mode = "single" - pending.speaker_mode = raw_speaker_mode + if errors: + return _render_prepare_page(pending, error=" ".join(errors)) - pending.generate_epub3 = _coerce_bool(request.form.get("generate_epub3"), False) - - threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD) - raw_threshold = request.form.get("speaker_analysis_threshold") - if raw_threshold is not None: - pending.speaker_analysis_threshold = _coerce_int( - raw_threshold, - threshold_default, - minimum=1, - maximum=25, - ) - else: - pending.speaker_analysis_threshold = threshold_default - - if not pending.speakers: - narrator: Dict[str, Any] = { - "id": "narrator", - "label": "Narrator", - "voice": pending.voice, - } - if pending.voice_profile: - narrator["voice_profile"] = pending.voice_profile - pending.speakers = {"narrator": narrator} - else: - existing_narrator = pending.speakers.get("narrator") - if isinstance(existing_narrator, dict): - existing_narrator.setdefault("id", "narrator") - existing_narrator["label"] = existing_narrator.get("label", "Narrator") - existing_narrator["voice"] = pending.voice - if pending.voice_profile: - existing_narrator["voice_profile"] = pending.voice_profile - pending.speakers["narrator"] = existing_narrator - - if isinstance(pending.speakers, dict): - for speaker_id, payload in list(pending.speakers.items()): - if not isinstance(payload, dict): - continue - field_key = f"speaker-{speaker_id}-pronunciation" - raw_value = request.form.get(field_key, "") - pronunciation = raw_value.strip() - if pronunciation: - payload["pronunciation"] = pronunciation - else: - payload.pop("pronunciation", None) - - profiles = serialize_profiles() - delay_value = pending.chapter_intro_delay - raw_delay = request.form.get("chapter_intro_delay") - if raw_delay is not None: - raw_normalized = raw_delay.strip() - if raw_normalized: - try: - delay_value = max(0.0, float(raw_normalized)) - except ValueError: - return _render_prepare_page(pending, error="Enter a valid number for the chapter intro delay.") - else: - delay_value = 0.0 - pending.chapter_intro_delay = delay_value - - overrides: List[Dict[str, Any]] = [] - selected_total = 0 - errors: List[str] = [] - - for index, chapter in enumerate(pending.chapters): - enabled = request.form.get(f"chapter-{index}-enabled") == "on" - title_input = (request.form.get(f"chapter-{index}-title") or "").strip() - title = title_input or chapter.get("title") or f"Chapter {index + 1}" - voice_selection = request.form.get(f"chapter-{index}-voice", "__default") - formula_input = (request.form.get(f"chapter-{index}-formula") or "").strip() - - entry: Dict[str, Any] = { - "id": chapter.get("id") or f"{index:04d}", - "index": index, - "order": index, - "source_title": chapter.get("title") or title, - "title": title, - "text": chapter.get("text", ""), - "enabled": enabled, - } - entry["characters"] = len(entry["text"]) - - if enabled: - if voice_selection.startswith("voice:"): - entry["voice"] = voice_selection.split(":", 1)[1] - entry["resolved_voice"] = entry["voice"] - elif voice_selection.startswith("profile:"): - profile_name = voice_selection.split(":", 1)[1] - entry["voice_profile"] = profile_name - profile_entry = profiles.get(profile_name) or {} - formula_value = _formula_from_profile(profile_entry) - if formula_value: - entry["voice_formula"] = formula_value - entry["resolved_voice"] = formula_value - else: - errors.append(f"Profile '{profile_name}' has no configured voices.") - elif voice_selection == "formula": - if not formula_input: - errors.append(f"Provide a custom formula for chapter {index + 1}.") - else: - try: - _parse_voice_formula(formula_input) - except ValueError as exc: - errors.append(str(exc)) - else: - entry["voice_formula"] = formula_input - entry["resolved_voice"] = formula_input - selected_total += len(entry["text"] or "") - - overrides.append(entry) - pending.chapters[index] = dict(entry) - - enabled_overrides = [entry for entry in overrides if entry.get("enabled")] - if not enabled_overrides: + if pending.speaker_mode != "multi": + setattr(pending, "analysis_requested", False) pending.chunks = [] - return _render_prepare_page(pending, error="Select at least one chapter to convert.") + pending.speaker_analysis = {} + return _render_prepare_page( + pending, + error="Switch to multi-speaker mode to analyze speakers.", + ) + + if not enabled_overrides: + setattr(pending, "analysis_requested", False) + pending.chunks = [] + pending.speaker_analysis = {} + 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) + + existing_roster: Optional[Mapping[str, Any]] + if getattr(pending, "analysis_requested", False): + existing_roster = pending.speakers + else: + existing_roster = None + processed_chunks, roster, analysis_payload = _prepare_speaker_metadata( chapters=enabled_overrides, chunks=raw_chunks, @@ -1454,15 +1492,69 @@ def finalize_job(pending_id: str) -> Response: voice=pending.voice, voice_profile=pending.voice_profile, threshold=pending.speaker_analysis_threshold, - existing_roster=pending.speakers, + existing_roster=existing_roster, + run_analysis=True, + ) + + pending.chunks = processed_chunks + pending.speakers = roster + pending.speaker_analysis = analysis_payload + 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.") + + +@web_bp.post("/jobs/prepare/") +def finalize_job(pending_id: str) -> ResponseReturnValue: + service = _service() + pending = service.get_pending_job(pending_id) + if not pending: + abort(404) + pending = cast(PendingJob, pending) + + chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form( + pending, request.form + ) + + if errors: + return _render_prepare_page(pending, error=" ".join(errors)) + + if pending.speaker_mode != "multi": + setattr(pending, "analysis_requested", False) + + if not enabled_overrides: + pending.chunks = [] + 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_active = pending.speaker_mode == "multi" and getattr(pending, "analysis_requested", False) + if analysis_active: + existing_roster: Optional[Mapping[str, Any]] = pending.speakers + else: + narrator_only: Dict[str, Any] = {} + if isinstance(pending.speakers, dict): + narrator_payload = pending.speakers.get("narrator") + if isinstance(narrator_payload, Mapping): + narrator_only["narrator"] = dict(narrator_payload) + existing_roster = narrator_only or None + processed_chunks, roster, analysis_payload = _prepare_speaker_metadata( + chapters=enabled_overrides, + chunks=raw_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, ) pending.chunks = processed_chunks pending.speakers = roster pending.speaker_analysis = analysis_payload - if errors: - return _render_prepare_page(pending, error=" ".join(errors)) - total_characters = selected_total or pending.total_characters service.pop_pending_job(pending_id) @@ -1500,13 +1592,14 @@ def finalize_job(pending_id: str) -> Response: speaker_analysis=analysis_payload, speaker_analysis_threshold=pending.speaker_analysis_threshold, generate_epub3=pending.generate_epub3, + analysis_requested=getattr(pending, "analysis_requested", False), ) return redirect(url_for("web.queue_page")) @web_bp.post("/jobs/prepare//cancel") -def cancel_pending_job(pending_id: str) -> Response: +def cancel_pending_job(pending_id: str) -> ResponseReturnValue: pending = _service().pop_pending_job(pending_id) if pending and pending.stored_path.exists(): try: @@ -1536,13 +1629,19 @@ def _render_jobs_panel() -> str: ) -def _render_prepare_page(pending: PendingJob, *, error: Optional[str] = None) -> str: +def _render_prepare_page( + pending: PendingJob, + *, + error: Optional[str] = None, + notice: Optional[str] = None, +) -> str: return render_template( "prepare_job.html", pending=pending, options=_template_options(), settings=_load_settings(), error=error, + notice=notice, ) @@ -1559,7 +1658,7 @@ def job_detail(job_id: str) -> str: @web_bp.post("/jobs//pause") -def pause_job(job_id: str) -> Response: +def pause_job(job_id: str) -> ResponseReturnValue: _service().pause(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1567,7 +1666,7 @@ def pause_job(job_id: str) -> Response: @web_bp.post("/jobs//resume") -def resume_job(job_id: str) -> Response: +def resume_job(job_id: str) -> ResponseReturnValue: _service().resume(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1575,7 +1674,7 @@ def resume_job(job_id: str) -> Response: @web_bp.post("/jobs//cancel") -def cancel_job(job_id: str) -> Response: +def cancel_job(job_id: str) -> ResponseReturnValue: _service().cancel(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1583,7 +1682,7 @@ def cancel_job(job_id: str) -> Response: @web_bp.post("/jobs//delete") -def delete_job(job_id: str) -> Response: +def delete_job(job_id: str) -> ResponseReturnValue: _service().delete(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1591,7 +1690,7 @@ def delete_job(job_id: str) -> Response: @web_bp.post("/jobs/clear-finished") -def clear_finished_jobs() -> Response: +def clear_finished_jobs() -> ResponseReturnValue: _service().clear_finished() if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1599,7 +1698,7 @@ def clear_finished_jobs() -> Response: @web_bp.get("/jobs//download") -def download_job(job_id: str) -> Response: +def download_job(job_id: str) -> ResponseReturnValue: job = _service().get_job(job_id) if job is None or job.status != JobStatus.COMPLETED: abort(404) @@ -1634,7 +1733,7 @@ def job_logs_partial(job_id: str) -> str: @api_bp.get("/jobs/") -def job_json(job_id: str) -> Response: +def job_json(job_id: str) -> ResponseReturnValue: job = _service().get_job(job_id) if job is None: abort(404) diff --git a/abogen/web/service.py b/abogen/web/service.py index 6f6c64d..f140f6d 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -97,6 +97,7 @@ class Job: generate_epub3: bool = False speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 + analysis_requested: bool = False def add_log(self, message: str, level: str = "info") -> None: self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) @@ -154,6 +155,7 @@ class Job: "generate_epub3": self.generate_epub3, "speaker_analysis": dict(self.speaker_analysis), "speaker_analysis_threshold": self.speaker_analysis_threshold, + "analysis_requested": self.analysis_requested, } @@ -193,6 +195,7 @@ class PendingJob: generate_epub3: bool = False speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 + analysis_requested: bool = False class ConversionService: @@ -263,6 +266,7 @@ class ConversionService: generate_epub3: bool = False, speaker_analysis: Optional[Mapping[str, Any]] = None, speaker_analysis_threshold: int = 3, + analysis_requested: bool = False, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -305,6 +309,7 @@ class ConversionService: generate_epub3=bool(generate_epub3), speaker_analysis=dict(speaker_analysis or {}), speaker_analysis_threshold=int(speaker_analysis_threshold or 3), + analysis_requested=bool(analysis_requested), ) with self._lock: self._jobs[job_id] = job @@ -581,6 +586,7 @@ class ConversionService: "generate_epub3": job.generate_epub3, "speaker_analysis": dict(job.speaker_analysis), "speaker_analysis_threshold": job.speaker_analysis_threshold, + "analysis_requested": job.analysis_requested, } def _persist_state(self) -> None: @@ -697,6 +703,7 @@ class ConversionService: job.speaker_analysis_threshold = int( payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3) ) + job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested)) job.pause_event.set() return job diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index b50f3a3..329c9a2 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -61,4 +61,19 @@ document.addEventListener("DOMContentLoaded", () => { toggleFormula(select); } }); + + const analyzeButton = form.querySelector('[data-role="analyze-button"]'); + const speakerModeSelect = form.querySelector("#speaker_mode"); + const updateAnalyzeVisibility = () => { + if (!analyzeButton || !speakerModeSelect) return; + const isMulti = speakerModeSelect.value === "multi"; + analyzeButton.hidden = !isMulti; + analyzeButton.setAttribute("aria-hidden", isMulti ? "false" : "true"); + analyzeButton.disabled = !isMulti; + }; + + if (analyzeButton && speakerModeSelect) { + speakerModeSelect.addEventListener("change", updateAnalyzeVisibility); + updateAnalyzeVisibility(); + } }); diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 9aa99aa..0d9ec72 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -71,6 +71,11 @@

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

{% endif %} + {% elif pending.speaker_mode == 'multi' and not pending.analysis_requested %} +
+

Detected speakers

+

Press Analyze speakers after selecting chapters to discover recurring voices.

+
{% endif %} {% set roster = pending.speakers or {} %} {% if roster %} @@ -130,6 +135,9 @@ {% if error %}
{{ error }}
{% endif %} + {% if notice %} +
{{ notice }}
+ {% endif %}
@@ -226,6 +234,15 @@
+
diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py new file mode 100644 index 0000000..ba79cb8 --- /dev/null +++ b/tests/test_text_normalization.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from abogen.web.conversion_runner import _normalize_for_pipeline + + +def test_title_abbreviations_are_expanded(): + text = "Dr. Watson met Mr. Holmes and Ms. Hudson." + normalized = _normalize_for_pipeline(text) + assert "Doctor" in normalized + assert "Mister" in normalized + assert "Miz" in normalized + + +def test_suffix_abbreviations_are_expanded_with_case_preserved(): + text = "John Doe Jr. spoke to JANE DOE SR. about the estate." + normalized = _normalize_for_pipeline(text) + assert "John Doe Junior" in normalized + assert "JANE DOE SENIOR" in normalized + + +def test_missing_terminal_punctuation_is_added(): + normalized = _normalize_for_pipeline("Chapter 1") + assert normalized.endswith(".") + + +def test_terminal_punctuation_respects_closing_quotes(): + normalized = _normalize_for_pipeline('"Chapter 1"') + compact = normalized.replace(" ", "") + assert compact.endswith('."')