diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index aff0119..8e7799c 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -130,11 +130,98 @@ 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_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + if not values: + return normalized + for key, value in values.items(): + if value is None: + continue + text = str(value).strip() + if not text: + continue + normalized[str(key).casefold()] = text + return normalized + + +def _format_author_sentence(raw: Optional[str]) -> str: + if raw is None: + return "" + normalized = str(raw).strip() + if not normalized: + return "" + lowered = normalized.casefold() + if lowered in {"unknown", "various"}: + return "" + + working = normalized.replace("&", " and ") + segments = [segment.strip() for segment in working.split(",") if segment.strip()] + tokens: List[str] = [] + + if segments: + for segment in segments: + parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()] + if parts: + tokens.extend(parts) + else: + tokens.append(segment) + else: + parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()] + tokens.extend(parts or [normalized]) + + cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}] + if not cleaned: + return "" + if len(cleaned) == 1: + return f"By {cleaned[0]}" + if len(cleaned) == 2: + return f"By {cleaned[0]} and {cleaned[1]}" + return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}" + + +def _ensure_sentence(text: str) -> str: + cleaned = text.strip() + if not cleaned: + return "" + if cleaned[-1] in ".!?": + return cleaned + return f"{cleaned}." + + +def _build_title_intro_text( + metadata: Optional[Mapping[str, Any]], + fallback_basename: str, +) -> str: + normalized = _normalize_metadata_map(metadata) + fallback_title = Path(fallback_basename).stem if fallback_basename else "" + title = normalized.get("title") or normalized.get("book_title") or normalized.get("album") or fallback_title + if not title: + title = fallback_title + subtitle = normalized.get("subtitle") or normalized.get("sub_title") + if subtitle and title and subtitle.casefold() == title.casefold(): + subtitle = "" + author_value = "" + for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"): + value = normalized.get(candidate) + if value: + author_value = value + break + + sentences: List[str] = [] + if title: + sentences.append(_ensure_sentence(title)) + if subtitle: + sentences.append(_ensure_sentence(subtitle)) + author_sentence = _format_author_sentence(author_value) + if author_sentence: + sentences.append(_ensure_sentence(author_sentence)) + return " ".join(sentences).strip() def _spec_to_voice_ids(spec: Any) -> Set[str]: @@ -1050,6 +1137,16 @@ def run_conversion_job(job: Job) -> None: } job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") auto_prefix_titles = getattr(job, "auto_prefix_chapter_titles", True) + read_title_intro = getattr(job, "read_title_intro", False) + book_intro_text = "" + if read_title_intro: + book_intro_text = _build_title_intro_text(job.metadata_tags, job.original_filename) + if book_intro_text: + preview = book_intro_text if len(book_intro_text) <= 120 else f"{book_intro_text[:117]}…" + job.add_log(f"Title intro enabled: {preview}", level="debug") + else: + job.add_log("Title intro enabled but no usable metadata was found.", level="debug") + intro_emitted = False def emit_text( text: str, @@ -1192,6 +1289,21 @@ def run_conversion_job(job: Job) -> None: if _headings_equivalent(first_line, heading_text) or (raw_title and _headings_equivalent(first_line, raw_title)): remove_heading_from_body = True + if not intro_emitted and book_intro_text: + intro_segments = emit_text( + book_intro_text, + voice_choice=voice_choice, + chapter_sink=chapter_sink, + preview_prefix="Book intro", + ) + intro_emitted = True + if intro_segments > 0 and job.chapter_intro_delay > 0: + append_silence( + job.chapter_intro_delay, + include_in_chapter=True, + chapter_sink=chapter_sink, + ) + if speak_heading: heading_segments = emit_text( heading_text, diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 8dcfedf..f62d311 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1528,6 +1528,21 @@ def _apply_prepare_form( else: pending.chapter_intro_delay = 0.0 + intro_values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_intro_values = getter("read_title_intro") + if raw_intro_values: + intro_values = list(cast(Iterable[str], raw_intro_values)) + else: + raw_intro = form.get("read_title_intro") + if raw_intro is not None: + intro_values = [raw_intro] + if intro_values: + pending.read_title_intro = _coerce_bool(intro_values[-1], pending.read_title_intro) + elif hasattr(form, "__contains__") and "read_title_intro" in form: + pending.read_title_intro = False + overrides: List[Dict[str, Any]] = [] selected_total = 0 @@ -1636,6 +1651,24 @@ def _apply_book_step_form( except ValueError: pass + intro_default = pending.read_title_intro if isinstance(pending.read_title_intro, bool) else bool(settings.get("read_title_intro", False)) + intro_values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_intro_values = getter("read_title_intro") + if raw_intro_values: + intro_values = list(cast(Iterable[str], raw_intro_values)) + else: + raw_intro_flag = form.get("read_title_intro") + if raw_intro_flag is not None: + intro_values = [raw_intro_flag] + if intro_values: + pending.read_title_intro = _coerce_bool(intro_values[-1], intro_default) + elif hasattr(form, "__contains__") and "read_title_intro" in form: + pending.read_title_intro = False + else: + pending.read_title_intro = intro_default + speed_value = form.get("speed") if speed_value is not None: try: @@ -1883,6 +1916,7 @@ BOOLEAN_SETTINGS = { "save_as_project", "generate_epub3", "enable_entity_recognition", + "read_title_intro", "auto_prefix_chapter_titles", "normalization_numbers", "normalization_titles", @@ -1913,6 +1947,7 @@ def _settings_defaults() -> Dict[str, Any]: "separate_chapters_format": "wav", "silence_between_chapters": 2.0, "chapter_intro_delay": 0.5, + "read_title_intro": False, "max_subtitle_words": 50, "chunk_level": "paragraph", "enable_entity_recognition": True, @@ -3375,6 +3410,7 @@ def enqueue_job() -> ResponseReturnValue: separate_chapters_format = settings["separate_chapters_format"] silence_between_chapters = settings["silence_between_chapters"] chapter_intro_delay = settings["chapter_intro_delay"] + read_title_intro = settings["read_title_intro"] max_subtitle_words = settings["max_subtitle_words"] auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] @@ -3442,6 +3478,7 @@ 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), auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), chunk_level=chunk_level_value, speaker_mode=speaker_mode_value, @@ -3722,6 +3759,7 @@ 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, 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 8fd6c99..4e4e649 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 = 6 +STATE_VERSION = 7 _JOB_LOGGER = logging.getLogger("abogen.jobs") @@ -105,6 +105,7 @@ class Job: metadata_tags: Dict[str, str] = field(default_factory=dict) max_subtitle_words: int = 50 chapter_intro_delay: float = 0.5 + read_title_intro: bool = False auto_prefix_chapter_titles: bool = True status: JobStatus = JobStatus.PENDING started_at: Optional[float] = None @@ -172,6 +173,7 @@ class Job: "voice_profile": self.voice_profile, "max_subtitle_words": self.max_subtitle_words, "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), }, "metadata_tags": dict(self.metadata_tags), @@ -235,6 +237,7 @@ class PendingJob: cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None chapter_intro_delay: float = 0.5 + read_title_intro: bool = False auto_prefix_chapter_titles: bool = True chunk_level: str = "paragraph" chunks: List[Dict[str, Any]] = field(default_factory=list) @@ -313,9 +316,10 @@ class ConversionService: max_subtitle_words: int = 50, metadata_tags: Optional[Mapping[str, Any]] = None, cover_image_path: Optional[Path] = None, - cover_image_mime: Optional[str] = None, - chapter_intro_delay: float = 0.5, - auto_prefix_chapter_titles: bool = True, + cover_image_mime: Optional[str] = None, + chapter_intro_delay: float = 0.5, + read_title_intro: bool = False, + auto_prefix_chapter_titles: bool = True, chunk_level: str = "paragraph", chunks: Optional[Iterable[Any]] = None, speakers: Optional[Mapping[str, Any]] = None, @@ -362,6 +366,7 @@ class ConversionService: cover_image_path=cover_image_path, cover_image_mime=cover_image_mime, chapter_intro_delay=chapter_intro_delay, + read_title_intro=bool(read_title_intro), auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), chunk_level=chunk_level, chunks=normalized_chunks, @@ -517,6 +522,7 @@ class ConversionService: cover_image_path=job.cover_image_path, cover_image_mime=job.cover_image_mime, chapter_intro_delay=job.chapter_intro_delay, + read_title_intro=job.read_title_intro, auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, chunk_level=job.chunk_level, chunks=job.chunks, @@ -743,6 +749,7 @@ class ConversionService: "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None, "cover_image_mime": job.cover_image_mime, "chapter_intro_delay": job.chapter_intro_delay, + "read_title_intro": job.read_title_intro, "auto_prefix_chapter_titles": job.auto_prefix_chapter_titles, "chunk_level": job.chunk_level, "chunks": [dict(entry) for entry in job.chunks], @@ -835,6 +842,7 @@ class ConversionService: metadata_tags=payload.get("metadata_tags", {}), max_subtitle_words=int(payload.get("max_subtitle_words", 50)), 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)), ) job.status = JobStatus(payload.get("status", job.status.value)) diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index 6e84146..a4d70e5 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -24,6 +24,7 @@
  • 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' }}
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
  • diff --git a/abogen/web/templates/partials/new_job_step_book.html b/abogen/web/templates/partials/new_job_step_book.html index 8298866..d93b784 100644 --- a/abogen/web/templates/partials/new_job_step_book.html +++ b/abogen/web/templates/partials/new_job_step_book.html @@ -55,6 +55,16 @@ {% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %} {% endif %} {% endif %} +{% set read_intro_value = form_values.get('read_title_intro') if form_values else None %} +{% if read_intro_value is not none %} + {% set read_title_intro = ((read_intro_value|string)|lower) in ['true', '1', 'yes', 'on'] %} +{% else %} + {% if pending is not none %} + {% set read_title_intro = pending.read_title_intro %} + {% else %} + {% set read_title_intro = settings_dict.get('read_title_intro', False) %} + {% 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 %} @@ -265,6 +275,14 @@

    Set to 0 to disable the pause after speaking each chapter title.

    +
    + +

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

    +
    diff --git a/abogen/web/templates/partials/new_job_step_chapters.html b/abogen/web/templates/partials/new_job_step_chapters.html index 18a9a17..d48bfc5 100644 --- a/abogen/web/templates/partials/new_job_step_chapters.html +++ b/abogen/web/templates/partials/new_job_step_chapters.html @@ -12,6 +12,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 f7dc1b9..1cf9dd3 100644 --- a/abogen/web/templates/partials/new_job_step_entities.html +++ b/abogen/web/templates/partials/new_job_step_entities.html @@ -25,6 +25,7 @@ + {% if pending.generate_epub3 %} {% endif %} diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html index b545dfa..5d7efaa 100644 --- a/abogen/web/templates/prepare_chapters.html +++ b/abogen/web/templates/prepare_chapters.html @@ -52,6 +52,7 @@ + {% if pending.generate_epub3 %} {% endif %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 3f781ed..562b435 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -123,6 +123,13 @@

    Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

    +
    + +

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

    +