mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add support for reading book title and authors before narration
This commit is contained in:
@@ -136,6 +136,93 @@ def _strip_duplicate_heading_line(text: str, heading: str) -> tuple[str, bool]:
|
||||
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]:
|
||||
text = str(spec or "").strip()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -315,6 +318,7 @@ class ConversionService:
|
||||
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: Optional[Iterable[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))
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<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>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>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</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) %}
|
||||
{% 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 @@
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<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="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 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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="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 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<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="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 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
|
||||
@@ -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) }}">
|
||||
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
|
||||
</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">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="auto_prefix_chapter_titles" value="true" {% if settings.auto_prefix_chapter_titles %}checked{% endif %}>
|
||||
|
||||
@@ -32,6 +32,7 @@ def _make_pending_job() -> PendingJob:
|
||||
metadata_tags={},
|
||||
chapters=[],
|
||||
created_at=0.0,
|
||||
read_title_intro=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user