feat: Enhance speaker analysis with gender inference and update related tests

This commit is contained in:
JB
2025-10-08 07:09:12 -07:00
parent 3b07df9708
commit 1bae37477b
9 changed files with 361 additions and 155 deletions
+1
View File
@@ -36,3 +36,4 @@ storage/
build/
dist/
.old/
test_assets/
+56 -9
View File
@@ -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
@@ -294,3 +316,28 @@ def _reassign(assignments: Dict[str, str], old: str, new: str) -> None:
for key, value in list(assignments.items()):
if value == old:
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"
+22 -13
View File
@@ -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"]):
+3 -3
View File
@@ -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,
}
)
+69 -12
View File
@@ -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;
+125 -117
View File
@@ -7,130 +7,138 @@
<div class="card__title">Prepare audiobook</div>
<p class="card__subtitle">Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.</p>
{% 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 %}
<div class="prepare-summary">
<dl>
<div>
<dt>Source</dt>
<dd>{{ pending.original_filename }}</dd>
<div class="prepare-summary__stats">
<dl>
<div>
<dt>Source</dt>
<dd>{{ pending.original_filename }}</dd>
</div>
<div>
<dt>Language</dt>
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
</div>
<div>
<dt>Default voice</dt>
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd>{{ pending.chapters|length }}</dd>
</div>
<div>
<dt>Characters</dt>
<dd>{{ '{:,}'.format(pending.total_characters|default(0)) }}</dd>
</div>
<div>
<dt>Chapter intro delay</dt>
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
</div>
<div>
<dt>Chunk granularity</dt>
<dd>{{ pending.chunk_level|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker mode</dt>
<dd>{{ pending.speaker_mode|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker analysis threshold</dt>
<dd>{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}</dd>
</div>
<div>
<dt>EPUB 3 package</dt>
<dd>{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}</dd>
</div>
</dl>
</div>
{% if analysis_speakers or show_analysis_prompt or has_metadata %}
<div class="prepare-summary__insights">
{% 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 %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
{% if active.items %}
<ul>
{% for speaker in active.items|sort(attribute='label') %}
<li><strong>{{ speaker.label }}</strong> · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}</li>
{% endfor %}
</ul>
{% else %}
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
{% endif %}
</div>
<div>
<dt>Language</dt>
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
{% elif show_analysis_prompt %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
</div>
<div>
<dt>Default voice</dt>
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd>{{ pending.chapters|length }}</dd>
</div>
<div>
<dt>Characters</dt>
<dd>{{ pending.total_characters|default(0) }}</dd>
</div>
<div>
<dt>Chapter intro delay</dt>
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
</div>
<div>
<dt>Chunk granularity</dt>
<dd>{{ pending.chunk_level|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker mode</dt>
<dd>{{ pending.speaker_mode|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker analysis threshold</dt>
<dd>{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}</dd>
</div>
<div>
<dt>EPUB 3 package</dt>
<dd>{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}</dd>
</div>
</dl>
{% 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 %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
{% if active.items %}
<ul>
{% for speaker in active.items|sort(attribute='label') %}
<li><strong>{{ speaker.label }}</strong> · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}</li>
{% endfor %}
</ul>
{% else %}
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
{% if has_metadata %}
<div class="prepare-metadata">
<h2>Metadata</h2>
<ul>
{% for key, value in pending.metadata_tags.items() %}
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% elif pending.speaker_mode == 'multi' and not pending.analysis_requested %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
</div>
{% endif %}
{% set roster = pending.speakers or {} %}
{% if roster %}
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
<div class="prepare-speakers">
<h2>Speaker pronunciation guide</h2>
<p class="hint">Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = preview_template | replace("{{name}}", spoken_name) %}
<li class="speaker-list__item">
<div class="speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.resolved_voice or speaker.voice_formula or speaker.voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}"
placeholder="{{ speaker.label }}">
</label>
{% if speaker.get('analysis_count') %}
<p class="hint speaker-list__stats">{{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if pending.metadata_tags %}
<div class="prepare-metadata">
<h2>Metadata</h2>
<ul>
{% for key, value in pending.metadata_tags.items() %}
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% set roster = pending.speakers or {} %}
{% if roster %}
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
<div class="prepare-speakers">
<h2>Speaker pronunciation guide</h2>
<p class="hint">Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = preview_template | replace("{{name}}", spoken_name) %}
<li class="speaker-list__item">
<div class="speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.resolved_voice or speaker.voice_formula or speaker.voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}"
placeholder="{{ speaker.label }}">
</label>
{% if speaker.get('analysis_count') %}
<p class="hint speaker-list__stats">{{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if error %}
<div class="alert alert--error">{{ error }}</div>
@@ -160,7 +168,7 @@
</label>
</div>
<div class="chapter-card__metrics">
{{ chapter.characters }} characters
{{ '{:,}'.format(chapter.characters) }} characters
</div>
</header>
<div class="chapter-card__body">
+13
View File
@@ -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
+44
View File
@@ -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"
+27
View File
@@ -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"