diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 8e7799c..d0e5d29 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -75,6 +75,37 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool: _HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+") _HEADING_NUMBER_PREFIX_RE = re.compile(r"^\s*(?P(?:\d+|[ivxlcdm]+))(?P(?:[\s.:;-].*)?)$", re.IGNORECASE) +_ACRONYM_ALLOWLIST = { + "AI", + "API", + "CPU", + "DIY", + "GPU", + "HTML", + "HTTP", + "HTTPS", + "ID", + "JSON", + "MP3", + "MP4", + "M4B", + "NASA", + "OCR", + "PDF", + "SQL", + "TV", + "TTS", + "UK", + "UN", + "UFO", + "OK", + "URL", + "USA", + "US", + "VR", +} +_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM") +_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*") def _simplify_heading_text(text: str) -> str: @@ -130,11 +161,95 @@ def _strip_duplicate_heading_line(text: str, heading: str) -> tuple[str, bool]: removed = True continue new_lines.append(line) - if not removed: - return source_text, False - while new_lines and not new_lines[0].strip(): - new_lines.pop(0) - return "\n".join(new_lines), True + if not removed: + return source_text, False + while new_lines and not new_lines[0].strip(): + new_lines.pop(0) + return "\n".join(new_lines), True + + +def _normalize_caps_word(word: str) -> str: + upper = word.upper() + letters = [char for char in upper if char.isalpha()] + if not letters: + return word + if upper in _ACRONYM_ALLOWLIST: + return word + if len(letters) <= 1: + return word + if all(char in _ROMAN_NUMERAL_CHARS for char in letters) and len(letters) <= 7: + return word + + parts = re.split(r"(['\-\u2019])", word) + normalized_parts: List[str] = [] + for part in parts: + if part in {"'", "-", "\u2019"}: + normalized_parts.append(part) + continue + if not part: + continue + normalized_parts.append(part[0].upper() + part[1:].lower()) + return "".join(normalized_parts) or word + + +def _normalize_chapter_opening_caps(text: str) -> tuple[str, bool]: + if not text: + return text, False + + leading_len = len(text) - len(text.lstrip()) + leading = text[:leading_len] + working = text[leading_len:] + if not working: + return text, False + + builder: List[str] = [] + pos = 0 + changed = False + + while pos < len(working): + char = working[pos] + if char in "\r\n": + builder.append(working[pos:]) + pos = len(working) + break + if char.isspace(): + builder.append(char) + pos += 1 + continue + if char.islower(): + builder.append(working[pos:]) + pos = len(working) + break + if not char.isalpha(): + builder.append(char) + pos += 1 + continue + + match = _CAPS_WORD_RE.match(working, pos) + if not match: + builder.append(char) + pos += 1 + continue + + word = match.group(0) + if any(ch.islower() for ch in word): + builder.append(working[pos:]) + pos = len(working) + break + + normalized = _normalize_caps_word(word) + if normalized != word: + changed = True + builder.append(normalized) + pos = match.end() + + if pos < len(working): + builder.append(working[pos:]) + + if not changed: + return text, False + + return leading + "".join(builder), True def _normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: normalized: Dict[str, str] = {} @@ -1247,6 +1362,7 @@ def run_conversion_job(job: Job) -> None: heading_text = spoken_title or raw_title chapter_display_title = heading_text or f"Chapter {idx}" job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter_display_title}") + normalize_opening_caps = bool(getattr(job, "normalize_chapter_opening_caps", True)) chapter_start_time = current_time chapter_override = ( @@ -1323,6 +1439,8 @@ def run_conversion_job(job: Job) -> None: chunks_for_chapter = chunk_groups.get(idx - 1, []) if chunk_groups else [] body_segments = 0 pending_heading_strip = remove_heading_from_body + opening_caps_pending = normalize_opening_caps + opening_caps_logged = False if chunks_for_chapter: job.add_log( f"Emitting {len(chunks_for_chapter)} {job.chunk_level} chunks for chapter {idx}.", @@ -1337,15 +1455,34 @@ def run_conversion_job(job: Job) -> None: if not chunk_text: continue + mutated_entry = False if pending_heading_strip and heading_text: chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, heading_text) if removed_heading: pending_heading_strip = False chunk_entry = dict(chunk_entry) chunk_entry["normalized_text"] = chunk_text + mutated_entry = True if not chunk_text.strip(): continue + if opening_caps_pending and chunk_text: + normalized_text, normalized_changed = _normalize_chapter_opening_caps(chunk_text) + if normalized_changed: + if not mutated_entry: + chunk_entry = dict(chunk_entry) + mutated_entry = True + chunk_entry["normalized_text"] = normalized_text + chunk_text = normalized_text + if not opening_caps_logged: + job.add_log( + f"Normalized uppercase chapter opening for chapter {idx}.", + level="debug", + ) + opening_caps_logged = True + if chunk_text.strip(): + opening_caps_pending = False + chunk_voice_spec = _chunk_voice_spec( job, chunk_entry, @@ -1396,11 +1533,23 @@ def run_conversion_job(job: Job) -> None: if body_segments == 0: chapter_body_start = current_time - chapter_text = chapter.text + chapter_text = str(chapter.text or "") if pending_heading_strip and heading_text: chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, heading_text) if removed_heading: pending_heading_strip = False + if opening_caps_pending and chapter_text: + normalized_body, normalized_changed = _normalize_chapter_opening_caps(chapter_text) + if normalized_changed: + chapter_text = normalized_body + if not opening_caps_logged: + job.add_log( + f"Normalized uppercase chapter opening for chapter {idx}.", + level="debug", + ) + opening_caps_logged = True + if str(chapter_text or "").strip(): + opening_caps_pending = False emitted = emit_text( chapter_text, voice_choice=voice_choice, @@ -1418,7 +1567,7 @@ def run_conversion_job(job: Job) -> None: "speaker_id": "narrator", "voice": chapter_voice_spec, "level": job.chunk_level, - "characters": len(chapter.text or ""), + "characters": len(chapter_text or ""), } ) elif chunks_for_chapter: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index f62d311..0a49734 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1543,6 +1543,22 @@ def _apply_prepare_form( elif hasattr(form, "__contains__") and "read_title_intro" in form: pending.read_title_intro = False + caps_values: List[str] = [] + if callable(getter): + raw_caps_values = getter("normalize_chapter_opening_caps") + if raw_caps_values: + caps_values = list(cast(Iterable[str], raw_caps_values)) + else: + raw_caps = form.get("normalize_chapter_opening_caps") + if raw_caps is not None: + caps_values = [raw_caps] + if caps_values: + pending.normalize_chapter_opening_caps = _coerce_bool( + caps_values[-1], getattr(pending, "normalize_chapter_opening_caps", True) + ) + elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form: + pending.normalize_chapter_opening_caps = False + overrides: List[Dict[str, Any]] = [] selected_total = 0 @@ -1669,6 +1685,28 @@ def _apply_book_step_form( else: pending.read_title_intro = intro_default + caps_default = ( + pending.normalize_chapter_opening_caps + if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool) + else bool(settings.get("normalize_chapter_opening_caps", True)) + ) + caps_values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_caps_values = getter("normalize_chapter_opening_caps") + if raw_caps_values: + caps_values = list(cast(Iterable[str], raw_caps_values)) + else: + raw_caps_flag = form.get("normalize_chapter_opening_caps") + if raw_caps_flag is not None: + caps_values = [raw_caps_flag] + if caps_values: + pending.normalize_chapter_opening_caps = _coerce_bool(caps_values[-1], caps_default) + elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form: + pending.normalize_chapter_opening_caps = False + else: + pending.normalize_chapter_opening_caps = caps_default + speed_value = form.get("speed") if speed_value is not None: try: @@ -1918,6 +1956,7 @@ BOOLEAN_SETTINGS = { "enable_entity_recognition", "read_title_intro", "auto_prefix_chapter_titles", + "normalize_chapter_opening_caps", "normalization_numbers", "normalization_titles", "normalization_terminal", @@ -1947,7 +1986,8 @@ def _settings_defaults() -> Dict[str, Any]: "separate_chapters_format": "wav", "silence_between_chapters": 2.0, "chapter_intro_delay": 0.5, - "read_title_intro": False, + "read_title_intro": False, + "normalize_chapter_opening_caps": True, "max_subtitle_words": 50, "chunk_level": "paragraph", "enable_entity_recognition": True, @@ -3411,6 +3451,7 @@ def enqueue_job() -> ResponseReturnValue: silence_between_chapters = settings["silence_between_chapters"] chapter_intro_delay = settings["chapter_intro_delay"] read_title_intro = settings["read_title_intro"] + normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"] max_subtitle_words = settings["max_subtitle_words"] auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] @@ -3478,7 +3519,8 @@ def enqueue_job() -> ResponseReturnValue: cover_image_path=cover_path, cover_image_mime=cover_mime, chapter_intro_delay=chapter_intro_delay, - read_title_intro=bool(read_title_intro), + read_title_intro=bool(read_title_intro), + normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps), auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), chunk_level=chunk_level_value, speaker_mode=speaker_mode_value, @@ -3759,7 +3801,8 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: cover_image_path=pending.cover_image_path, cover_image_mime=pending.cover_image_mime, chapter_intro_delay=pending.chapter_intro_delay, - read_title_intro=pending.read_title_intro, + read_title_intro=pending.read_title_intro, + normalize_chapter_opening_caps=getattr(pending, "normalize_chapter_opening_caps", True), auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True), chunk_level=pending.chunk_level, chunks=processed_chunks, diff --git a/abogen/web/service.py b/abogen/web/service.py index 4e4e649..06128b1 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event: return event -STATE_VERSION = 7 +STATE_VERSION = 8 _JOB_LOGGER = logging.getLogger("abogen.jobs") @@ -107,6 +107,7 @@ class Job: chapter_intro_delay: float = 0.5 read_title_intro: bool = False auto_prefix_chapter_titles: bool = True + normalize_chapter_opening_caps: bool = True status: JobStatus = JobStatus.PENDING started_at: Optional[float] = None finished_at: Optional[float] = None @@ -175,6 +176,7 @@ class Job: "chapter_intro_delay": self.chapter_intro_delay, "read_title_intro": getattr(self, "read_title_intro", False), "auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True), + "normalize_chapter_opening_caps": getattr(self, "normalize_chapter_opening_caps", True), }, "metadata_tags": dict(self.metadata_tags), "chapters": [ @@ -239,6 +241,7 @@ class PendingJob: chapter_intro_delay: float = 0.5 read_title_intro: bool = False auto_prefix_chapter_titles: bool = True + normalize_chapter_opening_caps: bool = True chunk_level: str = "paragraph" chunks: List[Dict[str, Any]] = field(default_factory=list) speakers: Dict[str, Any] = field(default_factory=dict) @@ -320,6 +323,7 @@ class ConversionService: chapter_intro_delay: float = 0.5, read_title_intro: bool = False, auto_prefix_chapter_titles: bool = True, + normalize_chapter_opening_caps: bool = True, chunk_level: str = "paragraph", chunks: Optional[Iterable[Any]] = None, speakers: Optional[Mapping[str, Any]] = None, @@ -368,6 +372,7 @@ class ConversionService: chapter_intro_delay=chapter_intro_delay, read_title_intro=bool(read_title_intro), auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), + normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps), chunk_level=chunk_level, chunks=normalized_chunks, speakers=dict(speakers or {}), @@ -524,6 +529,7 @@ class ConversionService: chapter_intro_delay=job.chapter_intro_delay, read_title_intro=job.read_title_intro, auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, + normalize_chapter_opening_caps=job.normalize_chapter_opening_caps, chunk_level=job.chunk_level, chunks=job.chunks, speakers=job.speakers, @@ -751,6 +757,7 @@ class ConversionService: "chapter_intro_delay": job.chapter_intro_delay, "read_title_intro": job.read_title_intro, "auto_prefix_chapter_titles": job.auto_prefix_chapter_titles, + "normalize_chapter_opening_caps": job.normalize_chapter_opening_caps, "chunk_level": job.chunk_level, "chunks": [dict(entry) for entry in job.chunks], "speakers": dict(job.speakers), @@ -844,6 +851,7 @@ class ConversionService: chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)), read_title_intro=bool(payload.get("read_title_intro", False)), auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)), + normalize_chapter_opening_caps=bool(payload.get("normalize_chapter_opening_caps", True)), ) job.status = JobStatus(payload.get("status", job.status.value)) job.started_at = payload.get("started_at") @@ -898,7 +906,8 @@ class ConversionService: except Exception: return - if payload.get("version") != STATE_VERSION: + version = int(payload.get("version", 0) or 0) + if version not in {STATE_VERSION, STATE_VERSION - 1}: return jobs_payload = payload.get("jobs", []) diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index a4d70e5..9245bc2 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -24,8 +24,9 @@
  • Separate chapter format: {{ job.separate_chapters_format|upper }}
  • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
  • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
  • -
  • Title intro: {{ 'Yes' if job.read_title_intro else 'No' }}
  • -
  • Prefix chapter titles: {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}
  • +
  • Title intro: {{ 'Yes' if job.read_title_intro else 'No' }}
  • +
  • Normalize chapter openings: {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}
  • +
  • Prefix chapter titles: {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
  • Chunk granularity: {{ job.chunk_level|replace('_', ' ')|title }}
  • diff --git a/abogen/web/templates/partials/new_job_step_book.html b/abogen/web/templates/partials/new_job_step_book.html index d93b784..aa30fed 100644 --- a/abogen/web/templates/partials/new_job_step_book.html +++ b/abogen/web/templates/partials/new_job_step_book.html @@ -65,6 +65,16 @@ {% set read_title_intro = settings_dict.get('read_title_intro', False) %} {% endif %} {% endif %} +{% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %} +{% if normalize_caps_value is not none %} + {% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %} +{% else %} + {% if pending is not none %} + {% set normalize_chapter_opening_caps = pending.normalize_chapter_opening_caps %} + {% else %} + {% set normalize_chapter_opening_caps = settings_dict.get('normalize_chapter_opening_caps', True) %} + {% endif %} +{% endif %} {% set selected_config = form_values.get('speaker_config') if form_values else None %} {% if selected_config is none %} {% if pending and pending.applied_speaker_config %} @@ -283,6 +293,14 @@

    When enabled, the narrator speaks the book title and author list before chapter one begins.

    +
    + +

    Converts opening sentences written in uppercase to sentence case while keeping acronyms like AI intact.

    +
    diff --git a/abogen/web/templates/partials/new_job_step_chapters.html b/abogen/web/templates/partials/new_job_step_chapters.html index d48bfc5..70f7401 100644 --- a/abogen/web/templates/partials/new_job_step_chapters.html +++ b/abogen/web/templates/partials/new_job_step_chapters.html @@ -13,6 +13,7 @@ + {% if pending.generate_epub3 %} {% endif %} diff --git a/abogen/web/templates/partials/new_job_step_entities.html b/abogen/web/templates/partials/new_job_step_entities.html index 1cf9dd3..e67734b 100644 --- a/abogen/web/templates/partials/new_job_step_entities.html +++ b/abogen/web/templates/partials/new_job_step_entities.html @@ -26,6 +26,7 @@ + {% if pending.generate_epub3 %} {% endif %} diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html index 5d7efaa..6aaf97f 100644 --- a/abogen/web/templates/prepare_chapters.html +++ b/abogen/web/templates/prepare_chapters.html @@ -53,6 +53,7 @@ + {% if pending.generate_epub3 %} {% endif %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 562b435..acbaf9e 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -130,6 +130,13 @@

    When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.

    +
    + +

    Converts screaming uppercase openings to sentence case while preserving acronyms.

    +