From 1bae37477bd834b0014e98b55ad2a7e48e12ac5d Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 07:09:12 -0700 Subject: [PATCH] feat: Enhance speaker analysis with gender inference and update related tests --- .gitignore | 1 + abogen/speaker_analysis.py | 67 +++++-- abogen/text_extractor.py | 35 ++-- abogen/web/routes.py | 6 +- abogen/web/static/styles.css | 81 +++++++-- abogen/web/templates/prepare_job.html | 242 +++++++++++++------------- tests/conftest.py | 13 ++ tests/test_speaker_analysis.py | 44 +++++ tests/test_text_extractor.py | 27 +++ 9 files changed, 361 insertions(+), 155 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_speaker_analysis.py create mode 100644 tests/test_text_extractor.py diff --git a/.gitignore b/.gitignore index f60d968..257db4c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ storage/ build/ dist/ .old/ +test_assets/ diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index 3a6dcfa..adf2916 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -28,14 +28,16 @@ _DIALOGUE_VERBS = ( ) _VERB_PATTERN = "(?:" + "|".join(_DIALOGUE_VERBS) + ")" -_NAME_FRAGMENT = r"[A-Z][A-Za-z'\-]+" +_NAME_FRAGMENT = r"[A-ZÀ-ÖØ-Þ][\w'’\-]*" _NAME_PATTERN = rf"{_NAME_FRAGMENT}(?:\s+{_NAME_FRAGMENT})*" _COLON_PATTERN = re.compile(rf"^\s*({_NAME_PATTERN})\s*:\s*(.+)$") _NAME_BEFORE_VERB = re.compile(rf"({_NAME_PATTERN})\s+{_VERB_PATTERN}\b", re.IGNORECASE) _VERB_BEFORE_NAME = re.compile(rf"{_VERB_PATTERN}\s+({_NAME_PATTERN})", re.IGNORECASE) _PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE) -_QUOTE_PATTERN = re.compile(r'"([^"\\]*(?:\\.[^"\\]*)*)"') +_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]') +_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE) +_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE) _CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3} @@ -48,6 +50,9 @@ class SpeakerGuess: confidence: str = "low" sample_quotes: List[str] = field(default_factory=list) suppressed: bool = False + gender: str = "unknown" + male_votes: int = 0 + female_votes: int = 0 def register_occurrence(self, confidence: str, quote: Optional[str]) -> None: self.count += 1 @@ -60,6 +65,13 @@ class SpeakerGuess: if len(self.sample_quotes) > 3: self.sample_quotes = self.sample_quotes[:3] + def register_gender(self, male_votes: int, female_votes: int) -> None: + if male_votes: + self.male_votes += male_votes + if female_votes: + self.female_votes += female_votes + self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender) + def as_dict(self) -> Dict[str, Any]: return { "id": self.speaker_id, @@ -68,6 +80,7 @@ class SpeakerGuess: "confidence": self.confidence, "sample_quotes": list(self.sample_quotes), "suppressed": self.suppressed, + "gender": self.gender, } @@ -131,14 +144,23 @@ def analyze_speakers( assignments[chunk_id] = speaker_id unique_speakers.add(speaker_id) - label = _normalize_label(speaker_id) - record_id = label_index.get(label) - if record_id is None: - record_id = _dedupe_slug(_slugify(label), speaker_guesses) - label_index[label] = record_id - speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) - guess = speaker_guesses[record_id] + male_votes, female_votes = _count_gender_votes(text) + + if speaker_id in speaker_guesses: + record_id = speaker_id + guess = speaker_guesses[record_id] + label = guess.label + else: + label = _normalize_label(speaker_id) + record_id = label_index.get(label) + if record_id is None: + record_id = _dedupe_slug(_slugify(label), speaker_guesses) + label_index[label] = record_id + speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) + guess = speaker_guesses[record_id] guess.register_occurrence(confidence, quote) + if record_id != narrator_id and (male_votes or female_votes): + guess.register_gender(male_votes, female_votes) if record_id != speaker_id: # Maintain mapping to canonical ID in assignments. assignments[chunk_id] = record_id @@ -293,4 +315,29 @@ def _safe_int(value: Any, default: int = 0) -> int: def _reassign(assignments: Dict[str, str], old: str, new: str) -> None: for key, value in list(assignments.items()): if value == old: - assignments[key] = new \ No newline at end of file + assignments[key] = new + + +def _count_gender_votes(text: str) -> Tuple[int, int]: + if not text: + return 0, 0 + male = len(_MALE_PRONOUN_PATTERN.findall(text)) + female = len(_FEMALE_PRONOUN_PATTERN.findall(text)) + return male, female + + +def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: + if male_votes == 0 and female_votes == 0: + return current if current != "unknown" else "unknown" + + male_threshold = max(2, female_votes + 1) + female_threshold = max(2, male_votes + 1) + + if male_votes >= male_threshold: + return "male" + if female_votes >= female_threshold: + return "female" + + if current in {"male", "female"}: + return current + return "unknown" \ No newline at end of file diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index 69e0237..9938942 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -8,7 +8,7 @@ import textwrap import urllib.parse from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast import ebooklib # type: ignore[import] import fitz # type: ignore[import] @@ -16,7 +16,7 @@ import markdown # type: ignore[import] from bs4 import BeautifulSoup, NavigableString # type: ignore[import] from ebooklib import epub # type: ignore[import] -from .utils import clean_text, detect_encoding +from .utils import calculate_text_length, clean_text, detect_encoding logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ class ExtractedChapter: @property def characters(self) -> int: - return len(self.text) + return calculate_text_length(self.text) @dataclass @@ -192,7 +192,7 @@ def _build_metadata_payload( "ALBUM": title, "YEAR": metadata_source.publication_year or now_year, "ALBUM_ARTIST": authors_text, - "COMPOSER": "Narrator", + "COMPOSER": authors_text, "GENRE": "Audiobook", "CHAPTER_COUNT": str(chapter_count), } @@ -213,8 +213,10 @@ def _extract_pdf(path: Path) -> ExtractionResult: chapters: List[ExtractedChapter] = [] with fitz.open(str(path)) as document: metadata_source = _collect_pdf_metadata(document) - for index, page in enumerate(document): - text = _clean_pdf_text(page.get_text()) + pages = cast(Iterable[fitz.Page], document) + for index, page in enumerate(pages): + page_obj = cast(Any, page) + text = _clean_pdf_text(page_obj.get_text()) if not text: continue title = f"Page {index + 1}" @@ -544,8 +546,8 @@ class EpubExtractor: chapters.append(ExtractedChapter(title=title, text=text)) return chapters - def _find_navigation_item(self) -> Tuple[Optional[ebooklib.epub.EpubItem], Optional[str]]: - nav_item: Optional[ebooklib.epub.EpubItem] = None + def _find_navigation_item(self) -> Tuple[Optional[epub.EpubItem], Optional[str]]: + nav_item: Optional[epub.EpubItem] = None nav_type: Optional[str] = None nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) @@ -621,12 +623,14 @@ class EpubExtractor: for content_node in nav_soup.find_all("content"): src = content_node.get("src") if src: - targets.append(src.split("#", 1)[0]) + src_value = str(src) + targets.append(src_value.split("#", 1)[0]) else: for link in nav_soup.find_all("a"): href = link.get("href") if href: - targets.append(href.split("#", 1)[0]) + href_value = str(href) + targets.append(href_value.split("#", 1)[0]) return targets def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None: @@ -896,11 +900,16 @@ class EpubExtractor: for tag in soup.find_all(["p", "div"]): tag.append("\n\n") for ol in soup.find_all("ol"): - start = int(ol.get("start", 1)) + start_attr = ol.get("start") + try: + start = int(str(start_attr)) if start_attr is not None else 1 + except (TypeError, ValueError): + start = 1 for idx, li in enumerate(ol.find_all("li", recursive=False)): number_text = f"{start + idx}) " - if li.string: - li.string.replace_with(number_text + li.string) + existing = li.string + if isinstance(existing, NavigableString): + existing.replace_with(NavigableString(number_text + str(existing))) else: li.insert(0, NavigableString(number_text)) for tag in soup.find_all(["sup", "sub"]): diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 59eb3e2..e3c46d5 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -332,7 +332,7 @@ def _apply_prepare_form( "text": chapter.get("text", ""), "enabled": enabled, } - entry["characters"] = len(entry["text"]) + entry["characters"] = calculate_text_length(entry["text"]) if enabled: if voice_selection.startswith("voice:"): @@ -359,7 +359,7 @@ def _apply_prepare_form( else: entry["voice_formula"] = formula_input entry["resolved_voice"] = formula_input - selected_total += len(entry["text"] or "") + selected_total += entry["characters"] overrides.append(entry) pending.chapters[index] = dict(entry) @@ -1299,7 +1299,7 @@ def enqueue_job() -> ResponseReturnValue: "index": index, "title": chapter.title, "text": chapter.text, - "characters": len(chapter.text), + "characters": calculate_text_length(chapter.text), "enabled": enabled, } ) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 05146e9..242198d 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -113,6 +113,7 @@ body { box-shadow: 0 20px 50px rgba(8, 15, 32, 0.35); position: relative; overflow: hidden; + z-index: 0; } .card::before { @@ -121,6 +122,7 @@ body { inset: 0; background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.15), transparent 55%); pointer-events: none; + z-index: -1; } .card__title { @@ -142,11 +144,17 @@ body { align-items: start; } +.form-grid { + grid-template-columns: minmax(0, 420px) minmax(0, 1fr); + align-items: start; + gap: 2rem; +} + .form-grid > .grid { align-items: start; - max-width: 460px; - width: min(100%, 460px); - justify-self: start; + max-width: none; + width: 100%; + justify-self: stretch; } .button { @@ -650,29 +658,50 @@ body { .prepare-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1.5rem; - margin-bottom: 1.5rem; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 2rem; + margin-bottom: 2rem; + align-items: start; } -.prepare-summary dl { +.prepare-summary__stats dl { margin: 0; display: grid; gap: 0.6rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } -.prepare-summary dt { +.prepare-summary__stats dt { font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } -.prepare-summary dd { +.prepare-summary__stats dd { margin: 0; font-weight: 500; } +.prepare-summary__insights { + display: grid; + gap: 1.25rem; + align-content: start; +} + +.prepare-summary__stats dl > div { + display: grid; + gap: 0.25rem; +} + +.prepare-summary__insights > .prepare-analysis, +.prepare-summary__insights > .prepare-metadata { + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.35); + border-radius: 18px; + padding: 1rem 1.25rem; +} + .prepare-speakers { margin-top: 2rem; border-top: 1px solid var(--panel-border); @@ -918,6 +947,26 @@ body { color: var(--accent); } +@media (max-width: 1024px) { + .prepare-summary { + grid-template-columns: 1fr; + } + + .prepare-summary__stats dl { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } +} + +@media (max-width: 880px) { + .form-grid { + grid-template-columns: 1fr; + } + + .voice-preview { + width: 100%; + } +} + .hint { margin: 0; font-size: 0.8rem; @@ -1481,9 +1530,10 @@ progress.progress::-moz-progress-bar { gap: 0.9rem; padding: 1rem 1.25rem; border-radius: 18px; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(15, 23, 42, 0.35); - max-width: 460px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.6); + box-shadow: 0 16px 40px rgba(8, 15, 32, 0.45); + width: min(100%, 520px); } .field--slider { @@ -1548,6 +1598,13 @@ progress.progress::-moz-progress-bar { border: 1px solid rgba(148, 163, 184, 0.18); } +.voice-preview textarea { + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(11, 18, 34, 0.85); + min-height: 140px; +} + .voice-status { min-height: 1.2rem; font-size: 0.9rem; diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 0d9ec72..66f3dd8 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -7,130 +7,138 @@
Prepare audiobook

Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.

+ {% set analysis = pending.speaker_analysis or {} %} + {% set analysis_speakers = analysis.get('speakers', {}) %} + {% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %} + {% set has_metadata = pending.metadata_tags %}
-
-
-
Source
-
{{ pending.original_filename }}
+
+
+
+
Source
+
{{ pending.original_filename }}
+
+
+
Language
+
{{ options.languages.get(pending.language, pending.language|upper) }}
+
+
+
Default voice
+
{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
+
+
+
Total chapters
+
{{ pending.chapters|length }}
+
+
+
Characters
+
{{ '{:,}'.format(pending.total_characters|default(0)) }}
+
+
+
Chapter intro delay
+
{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
+
+
+
Chunk granularity
+
{{ pending.chunk_level|replace('_', ' ')|title }}
+
+
+
Speaker mode
+
{{ pending.speaker_mode|replace('_', ' ')|title }}
+
+
+
Speaker analysis threshold
+
{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
+
+
+
EPUB 3 package
+
{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
+
+
+
+ {% if analysis_speakers or show_analysis_prompt or has_metadata %} +
+ {% if analysis_speakers %} + {% set active = namespace(items=[]) %} + {% for sid, payload in analysis_speakers.items() %} + {% if sid != 'narrator' and not payload.get('suppressed') %} + {% set _ = active.items.append(payload) %} + {% endif %} + {% endfor %} +
+

Detected speakers

+ {% if active.items %} +
    + {% for speaker in active.items|sort(attribute='label') %} +
  • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
  • + {% endfor %} +
+ {% else %} +

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

+ {% endif %}
-
-
Language
-
{{ options.languages.get(pending.language, pending.language|upper) }}
+ {% elif show_analysis_prompt %} +
+

Detected speakers

+

Press Analyze speakers after selecting chapters to discover recurring voices.

-
-
Default voice
-
{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
-
-
-
Total chapters
-
{{ pending.chapters|length }}
-
-
-
Characters
-
{{ pending.total_characters|default(0) }}
-
-
-
Chapter intro delay
-
{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
-
-
-
Chunk granularity
-
{{ pending.chunk_level|replace('_', ' ')|title }}
-
-
-
Speaker mode
-
{{ pending.speaker_mode|replace('_', ' ')|title }}
-
-
-
Speaker analysis threshold
-
{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
-
-
-
EPUB 3 package
-
{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
-
-
- {% set analysis = pending.speaker_analysis or {} %} - {% set analysis_speakers = analysis.get('speakers', {}) %} - {% if analysis_speakers %} - {% set active = namespace(items=[]) %} - {% for sid, payload in analysis_speakers.items() %} - {% if sid != 'narrator' and not payload.get('suppressed') %} - {% set _ = active.items.append(payload) %} {% endif %} - {% endfor %} -
-

Detected speakers

- {% if active.items %} -
    - {% for speaker in active.items|sort(attribute='label') %} -
  • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
  • - {% endfor %} -
- {% else %} -

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

+ {% if has_metadata %} + {% 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 %} - {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} -
-

Speaker pronunciation guide

-

Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

-
    - {% for speaker_id, speaker in roster.items() %} - {% set spoken_name = speaker.pronunciation or speaker.label %} - {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} -
  • -
    - {{ speaker.label }} - -
    - - {% if speaker.get('analysis_count') %} -

    {{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}

    - {% endif %} -
  • - {% endfor %} -
-
- {% endif %} - {% if pending.metadata_tags %} - {% endif %}
+ {% set roster = pending.speakers or {} %} + {% if roster %} + {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
+

Speaker pronunciation guide

+

Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

+ +
+ {% endif %} {% if error %}
{{ error }}
@@ -160,7 +168,7 @@
- {{ chapter.characters }} characters + {{ '{:,}'.format(chapter.characters) }} characters
diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d662d3d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import importlib +import sys + +# Ensure real optional dependencies are imported before tests that install stubs +# so that available packages (like ebooklib, bs4) aren't replaced with dummy modules. +for module_name in ("ebooklib", "bs4"): + if module_name not in sys.modules: + try: + importlib.import_module(module_name) + except Exception: + # On environments without the optional dependency, downstream tests + # will install lightweight stubs as needed. + pass diff --git a/tests/test_speaker_analysis.py b/tests/test_speaker_analysis.py new file mode 100644 index 0000000..986bd1f --- /dev/null +++ b/tests/test_speaker_analysis.py @@ -0,0 +1,44 @@ +from abogen.speaker_analysis import analyze_speakers + + +def _chapters(): + return [ + { + "id": "0001", + "index": 0, + "title": "Test", + "text": "", + "enabled": True, + } + ] + + +def _chunk(text: str, idx: int) -> dict: + return { + "id": f"chunk-{idx}", + "chapter_index": 0, + "chunk_index": idx, + "text": text, + } + + +def test_analyze_speakers_infers_gender_from_pronouns(): + chunks = [ + _chunk("\"Greetings,\" said John. He adjusted his hat as he smiled.", 0), + _chunk("\"Hello,\" said Mary. She straightened her dress as she introduced herself.", 1), + _chunk("\"Nice to meet you,\" said Alex.", 2), + ] + + analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0) + + john = analysis.speakers.get("john") + mary = analysis.speakers.get("mary") + alex = analysis.speakers.get("alex") + + assert john is not None + assert mary is not None + assert alex is not None + + assert john.gender == "male" + assert mary.gender == "female" + assert alex.gender == "unknown" diff --git a/tests/test_text_extractor.py b/tests/test_text_extractor.py new file mode 100644 index 0000000..1faa27e --- /dev/null +++ b/tests/test_text_extractor.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from abogen.text_extractor import extract_from_path +from abogen.utils import calculate_text_length + + +ASSET = Path("test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub") + + +def test_epub_character_counts_align_with_calculated_total(): + result = extract_from_path(ASSET) + + combined_total = calculate_text_length(result.combined_text) + chapter_total = sum(chapter.characters for chapter in result.chapters) + + assert result.total_characters == combined_total == chapter_total + + +def test_epub_metadata_composer_matches_artist(): + result = extract_from_path(ASSET) + + composer = result.metadata.get("composer") or result.metadata.get("COMPOSER") + artist = result.metadata.get("artist") or result.metadata.get("ARTIST") + + assert composer + assert composer == artist + assert composer != "Narrator"