feat: Add support for reading book title and authors before narration

This commit is contained in:
JB
2025-10-26 10:49:51 -07:00
parent a81ed70b14
commit 9d35e39e89
10 changed files with 197 additions and 9 deletions
+117 -5
View File
@@ -130,11 +130,98 @@ def _strip_duplicate_heading_line(text: str, heading: str) -> tuple[str, bool]:
removed = True removed = True
continue continue
new_lines.append(line) new_lines.append(line)
if not removed: if not removed:
return source_text, False return source_text, False
while new_lines and not new_lines[0].strip(): while new_lines and not new_lines[0].strip():
new_lines.pop(0) new_lines.pop(0)
return "\n".join(new_lines), True 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]: 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 ''}") job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}")
auto_prefix_titles = getattr(job, "auto_prefix_chapter_titles", True) 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( def emit_text(
text: str, 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)): if _headings_equivalent(first_line, heading_text) or (raw_title and _headings_equivalent(first_line, raw_title)):
remove_heading_from_body = True 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: if speak_heading:
heading_segments = emit_text( heading_segments = emit_text(
heading_text, heading_text,
+38
View File
@@ -1528,6 +1528,21 @@ def _apply_prepare_form(
else: else:
pending.chapter_intro_delay = 0.0 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]] = [] overrides: List[Dict[str, Any]] = []
selected_total = 0 selected_total = 0
@@ -1636,6 +1651,24 @@ def _apply_book_step_form(
except ValueError: except ValueError:
pass 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") speed_value = form.get("speed")
if speed_value is not None: if speed_value is not None:
try: try:
@@ -1883,6 +1916,7 @@ BOOLEAN_SETTINGS = {
"save_as_project", "save_as_project",
"generate_epub3", "generate_epub3",
"enable_entity_recognition", "enable_entity_recognition",
"read_title_intro",
"auto_prefix_chapter_titles", "auto_prefix_chapter_titles",
"normalization_numbers", "normalization_numbers",
"normalization_titles", "normalization_titles",
@@ -1913,6 +1947,7 @@ def _settings_defaults() -> Dict[str, Any]:
"separate_chapters_format": "wav", "separate_chapters_format": "wav",
"silence_between_chapters": 2.0, "silence_between_chapters": 2.0,
"chapter_intro_delay": 0.5, "chapter_intro_delay": 0.5,
"read_title_intro": False,
"max_subtitle_words": 50, "max_subtitle_words": 50,
"chunk_level": "paragraph", "chunk_level": "paragraph",
"enable_entity_recognition": True, "enable_entity_recognition": True,
@@ -3375,6 +3410,7 @@ def enqueue_job() -> ResponseReturnValue:
separate_chapters_format = settings["separate_chapters_format"] separate_chapters_format = settings["separate_chapters_format"]
silence_between_chapters = settings["silence_between_chapters"] silence_between_chapters = settings["silence_between_chapters"]
chapter_intro_delay = settings["chapter_intro_delay"] chapter_intro_delay = settings["chapter_intro_delay"]
read_title_intro = settings["read_title_intro"]
max_subtitle_words = settings["max_subtitle_words"] max_subtitle_words = settings["max_subtitle_words"]
auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
@@ -3442,6 +3478,7 @@ def enqueue_job() -> ResponseReturnValue:
cover_image_path=cover_path, cover_image_path=cover_path,
cover_image_mime=cover_mime, cover_image_mime=cover_mime,
chapter_intro_delay=chapter_intro_delay, chapter_intro_delay=chapter_intro_delay,
read_title_intro=bool(read_title_intro),
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
chunk_level=chunk_level_value, chunk_level=chunk_level_value,
speaker_mode=speaker_mode_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_path=pending.cover_image_path,
cover_image_mime=pending.cover_image_mime, cover_image_mime=pending.cover_image_mime,
chapter_intro_delay=pending.chapter_intro_delay, 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), auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True),
chunk_level=pending.chunk_level, chunk_level=pending.chunk_level,
chunks=processed_chunks, chunks=processed_chunks,
+12 -4
View File
@@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event:
return event return event
STATE_VERSION = 6 STATE_VERSION = 7
_JOB_LOGGER = logging.getLogger("abogen.jobs") _JOB_LOGGER = logging.getLogger("abogen.jobs")
@@ -105,6 +105,7 @@ class Job:
metadata_tags: Dict[str, str] = field(default_factory=dict) metadata_tags: Dict[str, str] = field(default_factory=dict)
max_subtitle_words: int = 50 max_subtitle_words: int = 50
chapter_intro_delay: float = 0.5 chapter_intro_delay: float = 0.5
read_title_intro: bool = False
auto_prefix_chapter_titles: bool = True auto_prefix_chapter_titles: bool = True
status: JobStatus = JobStatus.PENDING status: JobStatus = JobStatus.PENDING
started_at: Optional[float] = None started_at: Optional[float] = None
@@ -172,6 +173,7 @@ class Job:
"voice_profile": self.voice_profile, "voice_profile": self.voice_profile,
"max_subtitle_words": self.max_subtitle_words, "max_subtitle_words": self.max_subtitle_words,
"chapter_intro_delay": self.chapter_intro_delay, "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), "auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True),
}, },
"metadata_tags": dict(self.metadata_tags), "metadata_tags": dict(self.metadata_tags),
@@ -235,6 +237,7 @@ class PendingJob:
cover_image_path: Optional[Path] = None cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None cover_image_mime: Optional[str] = None
chapter_intro_delay: float = 0.5 chapter_intro_delay: float = 0.5
read_title_intro: bool = False
auto_prefix_chapter_titles: bool = True auto_prefix_chapter_titles: bool = True
chunk_level: str = "paragraph" chunk_level: str = "paragraph"
chunks: List[Dict[str, Any]] = field(default_factory=list) chunks: List[Dict[str, Any]] = field(default_factory=list)
@@ -313,9 +316,10 @@ class ConversionService:
max_subtitle_words: int = 50, max_subtitle_words: int = 50,
metadata_tags: Optional[Mapping[str, Any]] = None, metadata_tags: Optional[Mapping[str, Any]] = None,
cover_image_path: Optional[Path] = None, cover_image_path: Optional[Path] = None,
cover_image_mime: Optional[str] = None, cover_image_mime: Optional[str] = None,
chapter_intro_delay: float = 0.5, chapter_intro_delay: float = 0.5,
auto_prefix_chapter_titles: bool = True, read_title_intro: bool = False,
auto_prefix_chapter_titles: bool = True,
chunk_level: str = "paragraph", chunk_level: str = "paragraph",
chunks: Optional[Iterable[Any]] = None, chunks: Optional[Iterable[Any]] = None,
speakers: Optional[Mapping[str, Any]] = None, speakers: Optional[Mapping[str, Any]] = None,
@@ -362,6 +366,7 @@ class ConversionService:
cover_image_path=cover_image_path, cover_image_path=cover_image_path,
cover_image_mime=cover_image_mime, cover_image_mime=cover_image_mime,
chapter_intro_delay=chapter_intro_delay, chapter_intro_delay=chapter_intro_delay,
read_title_intro=bool(read_title_intro),
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
chunk_level=chunk_level, chunk_level=chunk_level,
chunks=normalized_chunks, chunks=normalized_chunks,
@@ -517,6 +522,7 @@ class ConversionService:
cover_image_path=job.cover_image_path, cover_image_path=job.cover_image_path,
cover_image_mime=job.cover_image_mime, cover_image_mime=job.cover_image_mime,
chapter_intro_delay=job.chapter_intro_delay, chapter_intro_delay=job.chapter_intro_delay,
read_title_intro=job.read_title_intro,
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
chunk_level=job.chunk_level, chunk_level=job.chunk_level,
chunks=job.chunks, 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_path": str(job.cover_image_path) if job.cover_image_path else None,
"cover_image_mime": job.cover_image_mime, "cover_image_mime": job.cover_image_mime,
"chapter_intro_delay": job.chapter_intro_delay, "chapter_intro_delay": job.chapter_intro_delay,
"read_title_intro": job.read_title_intro,
"auto_prefix_chapter_titles": job.auto_prefix_chapter_titles, "auto_prefix_chapter_titles": job.auto_prefix_chapter_titles,
"chunk_level": job.chunk_level, "chunk_level": job.chunk_level,
"chunks": [dict(entry) for entry in job.chunks], "chunks": [dict(entry) for entry in job.chunks],
@@ -835,6 +842,7 @@ class ConversionService:
metadata_tags=payload.get("metadata_tags", {}), metadata_tags=payload.get("metadata_tags", {}),
max_subtitle_words=int(payload.get("max_subtitle_words", 50)), max_subtitle_words=int(payload.get("max_subtitle_words", 50)),
chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)), 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)), auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)),
) )
job.status = JobStatus(payload.get("status", job.status.value)) job.status = JobStatus(payload.get("status", job.status.value))
+1
View File
@@ -24,6 +24,7 @@
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li> <li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li> <li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li> <li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
<li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li>
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li> <li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li> <li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li> <li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
@@ -55,6 +55,16 @@
{% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %} {% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %}
{% endif %} {% endif %}
{% 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 %} {% set selected_config = form_values.get('speaker_config') if form_values else None %}
{% if selected_config is none %} {% if selected_config is none %}
{% if pending and pending.applied_speaker_config %} {% if pending and pending.applied_speaker_config %}
@@ -265,6 +275,14 @@
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(chapter_delay_value|float if chapter_delay_value is not none else 0.5) }}" {{ 'disabled' if readonly else '' }}> <input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(chapter_delay_value|float if chapter_delay_value is not none else 0.5) }}" {{ 'disabled' if readonly else '' }}>
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p> <p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
</div> </div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="hidden" name="read_title_intro" value="false">
<input type="checkbox" name="read_title_intro" value="true" {% if read_title_intro %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
<span>Read title and authors before narration</span>
</label>
<p class="hint">When enabled, the narrator speaks the book title and author list before chapter one begins.</p>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -12,6 +12,7 @@
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}"> <input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}"> <input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
{% if pending.generate_epub3 %} {% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true"> <input type="hidden" name="generate_epub3" value="true">
{% endif %} {% endif %}
@@ -25,6 +25,7 @@
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}"> <input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}"> <input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
{% if pending.generate_epub3 %} {% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true"> <input type="hidden" name="generate_epub3" value="true">
{% endif %} {% endif %}
@@ -52,6 +52,7 @@
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}"> <input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}"> <input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}"> <input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
{% if pending.generate_epub3 %} {% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true"> <input type="hidden" name="generate_epub3" value="true">
{% endif %} {% endif %}
+7
View File
@@ -123,6 +123,13 @@
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}"> <input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p> <p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
</div> </div>
<div class="field field--stack">
<label class="toggle-pill">
<input type="checkbox" name="read_title_intro" value="true" {% if settings.read_title_intro %}checked{% endif %}>
<span>Read Book Title Before Narration</span>
</label>
<p class="hint">When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.</p>
</div>
<div class="field field--stack"> <div class="field field--stack">
<label class="toggle-pill"> <label class="toggle-pill">
<input type="checkbox" name="auto_prefix_chapter_titles" value="true" {% if settings.auto_prefix_chapter_titles %}checked{% endif %}> <input type="checkbox" name="auto_prefix_chapter_titles" value="true" {% if settings.auto_prefix_chapter_titles %}checked{% endif %}>
+1
View File
@@ -32,6 +32,7 @@ def _make_pending_job() -> PendingJob:
metadata_tags={}, metadata_tags={},
chapters=[], chapters=[],
created_at=0.0, created_at=0.0,
read_title_intro=False,
) )