Enhance speaker analysis and web templates

- Added gender inference improvements in speaker_analysis.py, including handling of titles and diacritics.
- Updated analyze_speakers function to include sample excerpts with context paragraphs.
- Modified routes.py to skip suppressed speakers in the speaker roster.
- Enhanced prepare.js to manage speaker samples and pronunciation previews more effectively.
- Refined prepare_chapters.html and prepare_speakers.html templates for better navigation and user experience.
- Added tests for speaker analysis to ensure proper handling of stopwords and threshold suppression.
This commit is contained in:
JB
2025-10-11 11:14:52 -07:00
parent 8ed5202ab8
commit ccf5a11222
6 changed files with 592 additions and 162 deletions
+293 -36
View File
@@ -4,6 +4,8 @@ import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
import unicodedata
_DIALOGUE_VERBS = (
"said",
"asked",
@@ -71,6 +73,73 @@ _PRONOUN_LABELS = {
_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
_FEMALE_TITLE_HINTS = (
"madame",
"mme",
"madam",
"mrs",
"miss",
"ms",
"lady",
"countess",
"baroness",
"princess",
"queen",
"mademoiselle",
)
_MALE_TITLE_HINTS = (
"monsieur",
"m.",
"mr",
"sir",
"lord",
"count",
"baron",
"prince",
"king",
"abbé",
"abbe",
)
_MALE_TOKEN_WEIGHTS = {
"he": 1.0,
"him": 0.6,
"his": 0.75,
"himself": 1.0,
}
_FEMALE_TOKEN_WEIGHTS = {
"she": 1.0,
"her": 0.4,
"hers": 0.75,
"herself": 1.0,
}
_STOP_LABELS = {
"and",
"but",
"then",
"though",
"meanwhile",
"therefore",
"after",
"before",
"when",
"while",
"because",
"as",
"yet",
"nor",
"so",
"thus",
"suddenly",
"eventually",
"finally",
"until",
"unless",
}
@dataclass(slots=True)
class SpeakerGuess:
@@ -92,12 +161,13 @@ class SpeakerGuess:
quote: Optional[str],
male_votes: int,
female_votes: int,
sample_excerpt: Optional[str] = None,
) -> None:
self.count += 1
if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0):
self.confidence = confidence
excerpt = _build_excerpt(text, quote)
excerpt = sample_excerpt if sample_excerpt is not None else _build_excerpt(text, quote)
gender_hint = _format_gender_hint(male_votes, female_votes)
if excerpt:
payload = {"excerpt": excerpt, "gender_hint": gender_hint}
@@ -173,9 +243,9 @@ def analyze_speakers(
explicit_assignments = 0
unique_speakers: set[str] = set()
for chunk in ordered_chunks:
for index, chunk in enumerate(ordered_chunks):
chunk_id = str(chunk.get("id") or "")
text = str(chunk.get("normalized_text") or chunk.get("text") or "")
text = _get_chunk_text(chunk)
speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit)
if speaker_id is None:
speaker_id = last_explicit or narrator_id
@@ -184,10 +254,6 @@ def analyze_speakers(
if speaker_id != narrator_id:
last_explicit = speaker_id
explicit_assignments += 1
assignments[chunk_id] = speaker_id
unique_speakers.add(speaker_id)
male_votes, female_votes = _count_gender_votes(text)
if speaker_id in speaker_guesses:
record_id = speaker_id
@@ -201,12 +267,19 @@ def analyze_speakers(
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, text, quote, male_votes, female_votes)
if record_id != speaker_id:
# Maintain mapping to canonical ID in assignments.
assignments[chunk_id] = record_id
if speaker_id == last_explicit:
last_explicit = record_id
assignments[chunk_id] = record_id
unique_speakers.add(record_id)
if record_id != narrator_id and record_id != speaker_id and speaker_id == last_explicit:
last_explicit = record_id
sample_excerpt = None
if record_id != narrator_id:
sample_excerpt = _select_sample_excerpt(ordered_chunks, index, guess.label, quote, confidence)
male_votes, female_votes = _count_gender_votes(text, guess.label)
guess.register_occurrence(confidence, text, quote, male_votes, female_votes, sample_excerpt)
active_speakers = [sid for sid in speaker_guesses if sid != narrator_id]
# Apply minimum occurrence threshold.
@@ -367,31 +440,125 @@ def _reassign(assignments: Dict[str, str], old: str, new: str) -> None:
assignments[key] = new
def _count_gender_votes(text: str) -> Tuple[int, int]:
def _strip_diacritics(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
return "".join(char for char in normalized if not unicodedata.combining(char))
def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]:
if not text:
return 0, 0
male_votes = 0.0
for token in _MALE_PRONOUN_PATTERN.findall(text):
lowered = token.lower()
if lowered in {"he", "himself"}:
male_votes += 1.0
elif lowered == "his":
male_votes += 0.75
else: # him
male_votes += 0.6
search_text = text
windows: List[Tuple[int, int]] = []
degrade_factor = 1.0
female_votes = 0.0
for token in _FEMALE_PRONOUN_PATTERN.findall(text):
lowered = token.lower()
if lowered in {"she", "herself"}:
female_votes += 1.0
elif lowered == "hers":
female_votes += 0.75
else: # her
female_votes += 0.4
if label:
pattern = re.compile(re.escape(label), re.IGNORECASE)
matches = list(pattern.finditer(search_text))
if not matches:
alt_label = _strip_diacritics(label)
if alt_label and alt_label != label:
ascii_text = _strip_diacritics(search_text)
pattern_alt = re.compile(re.escape(alt_label), re.IGNORECASE)
windows = [match.span() for match in pattern_alt.finditer(ascii_text)]
# Map spans back roughly using proportional index
if windows:
mapped: List[Tuple[int, int]] = []
for start, end in windows:
start_idx = min(len(search_text) - 1, int(start * len(search_text) / max(len(ascii_text), 1)))
end_idx = min(len(search_text), int(end * len(search_text) / max(len(ascii_text), 1)))
mapped.append((start_idx, end_idx))
windows = mapped
else:
windows = [match.span() for match in matches]
return int(round(male_votes)), int(round(female_votes))
if not windows:
windows = [(0, len(search_text))]
degrade_factor = 0.25
radius = 60
quote_spans: List[Tuple[int, int, str]] = []
for match in _QUOTE_PATTERN.finditer(search_text):
try:
content_start, content_end = match.span(1)
except IndexError:
content_start, content_end = match.span()
if content_start < content_end:
quote_spans.append((content_start, content_end, search_text[content_start:content_end]))
normalized_label = _normalize_candidate_name(label) if label else None
normalized_label_lower = normalized_label.lower() if normalized_label else None
def _window_weight(position: int) -> float:
for start, end in windows:
if position < start - radius or position > end + radius:
continue
if position >= end:
return 1.0
if position <= start:
return 0.2
return 1.0
return 0.0
def _quote_weight(position: int) -> float:
for start, end, content in quote_spans:
if position < start or position >= end:
continue
local_index = position - start
prefix = content[:local_index]
tail = prefix[-80:]
name_matches = list(re.finditer(_NAME_PATTERN, tail))
if name_matches:
last_name = _normalize_candidate_name(name_matches[-1].group(0))
if normalized_label_lower and last_name and last_name.lower() == normalized_label_lower:
return 0.6
return 0.05
if re.search(r"[.!?]\s", prefix):
return 0.2
if prefix.strip():
return 0.15
return 0.1
return 1.0
male_score = 0.0
for match in _MALE_PRONOUN_PATTERN.finditer(search_text):
base_weight = _window_weight(match.start())
if not base_weight:
continue
quote_modifier = _quote_weight(match.start())
weight = base_weight * quote_modifier
if not weight:
continue
token = match.group(0).lower()
male_score += _MALE_TOKEN_WEIGHTS.get(token, 0.6) * weight
female_score = 0.0
for match in _FEMALE_PRONOUN_PATTERN.finditer(search_text):
base_weight = _window_weight(match.start())
if not base_weight:
continue
quote_modifier = _quote_weight(match.start())
weight = base_weight * quote_modifier
if not weight:
continue
if quote_modifier >= 0.95:
weight = max(weight, 0.4)
token = match.group(0).lower()
female_score += _FEMALE_TOKEN_WEIGHTS.get(token, 0.4) * weight
for start, end in windows:
span_start = max(0, start - 40)
span_end = min(len(search_text), end + 40)
span_text = search_text[span_start:span_end].lower()
if any(title in span_text for title in _FEMALE_TITLE_HINTS):
female_score += 2.5
if any(title in span_text for title in _MALE_TITLE_HINTS):
male_score += 2.5
male_votes = int(round(male_score * degrade_factor))
female_votes = int(round(female_score * degrade_factor))
return male_votes, female_votes
def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
@@ -411,6 +578,70 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
return "unknown"
def _get_chunk_text(chunk: Dict[str, Any]) -> str:
if not isinstance(chunk, dict):
return ""
value = chunk.get("normalized_text") or chunk.get("text") or ""
return str(value)
def _trim_paragraph(paragraph: str, limit: int = 600) -> str:
normalized = (paragraph or "").strip()
if not normalized:
return ""
if len(normalized) <= limit:
return normalized
return normalized[: limit - 1].rstrip() + ""
def _compose_context_excerpt(before: str, current: str, after: str) -> str:
segments = []
for value in (before, current, after):
trimmed = _trim_paragraph(value)
if trimmed:
segments.append(trimmed)
return "\n\n".join(segments)
def _contains_dialogue_attribution(label: str, text: str, quote: Optional[str]) -> bool:
if not label or not text:
return False
escaped_label = re.escape(label)
direct_pattern = re.compile(rf"\b{escaped_label}\b\s+(?:{_VERB_PATTERN})\b", re.IGNORECASE)
reverse_pattern = re.compile(rf"(?:{_VERB_PATTERN})\s+\b{escaped_label}\b", re.IGNORECASE)
colon_pattern = re.compile(rf"^\s*{escaped_label}\s*:\s*", re.IGNORECASE)
if colon_pattern.search(text):
return True
if direct_pattern.search(text) or reverse_pattern.search(text):
return True
if quote:
before, after = _split_around_quote(text, quote)
if direct_pattern.search(before) or reverse_pattern.search(after):
return True
return False
def _select_sample_excerpt(
chunks: Sequence[Dict[str, Any]],
index: int,
label: str,
quote: Optional[str],
confidence: str,
) -> Optional[str]:
if confidence != "high" or not label:
return None
if index < 0 or index >= len(chunks):
return None
current = _get_chunk_text(chunks[index])
if not current or not _contains_dialogue_attribution(label, current, quote):
return None
previous = _get_chunk_text(chunks[index - 1]) if index > 0 else ""
following = _get_chunk_text(chunks[index + 1]) if index + 1 < len(chunks) else ""
excerpt = _compose_context_excerpt(previous, current, following)
return excerpt or None
def _build_excerpt(text: str, quote: Optional[str]) -> str:
normalized = (text or "").strip()
if not normalized:
@@ -452,7 +683,33 @@ def _normalize_candidate_name(raw: str) -> Optional[str]:
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned:
return None
lowered = cleaned.lower()
if lowered in _PRONOUN_LABELS:
parts = cleaned.split()
filtered: List[str] = []
for part in parts:
if not part:
continue
if not filtered and part.lower() in _STOP_LABELS:
continue
filtered.append(part)
while filtered and filtered[-1].lower() in _STOP_LABELS:
filtered.pop()
if not filtered:
return None
return cleaned
if all(part.lower() in _STOP_LABELS for part in filtered):
return None
contiguous: List[str] = []
for part in filtered:
if part and part[0].isupper():
contiguous.append(part)
else:
break
if contiguous:
candidate = " ".join(contiguous)
else:
candidate = ""
if not candidate:
return None
lowered = candidate.lower()
if lowered in _PRONOUN_LABELS or lowered in _STOP_LABELS:
return None
return candidate
+6 -1
View File
@@ -501,6 +501,7 @@ def _build_speaker_roster(
payload = speakers.get(speaker_id, {})
if speaker_id == "narrator":
continue
if isinstance(payload, Mapping) and payload.get("suppressed"):
continue
previous = existing_map.get(speaker_id)
roster[speaker_id] = {
@@ -864,7 +865,11 @@ def _prepare_speaker_metadata(
ordered_ids = [
sid
for sid, meta in sorted(
((sid, meta) for sid, meta in speakers_payload.items() if sid != "narrator"),
(
(sid, meta)
for sid, meta in speakers_payload.items()
if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
),
key=lambda item: item[1].get("count", 0),
reverse=True,
)
+139 -23
View File
@@ -18,6 +18,113 @@ document.addEventListener("DOMContentLoaded", () => {
const languageMap = parseJSONScript("voice-language-map") || {};
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
const sampleIndexState = new WeakMap();
const readSpeakerSamples = (speakerItem) => {
if (!speakerItem) return [];
const template = speakerItem.querySelector('template[data-role="speaker-samples"]');
if (!template) return [];
let parsed = [];
try {
const raw = template.innerHTML || "[]";
const data = JSON.parse(raw);
if (Array.isArray(data)) {
parsed = data;
}
} catch (error) {
console.warn("Unable to parse speaker samples", error);
return [];
}
const seen = new Set();
const normalised = [];
for (const entry of parsed) {
let excerpt = "";
let genderHint = "";
if (typeof entry === "string") {
excerpt = entry;
} else if (entry && typeof entry === "object") {
excerpt = String(entry.excerpt || "");
genderHint = typeof entry.gender_hint === "string" ? entry.gender_hint : "";
}
const key = excerpt.trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
normalised.push({ excerpt: key, genderHint });
}
return normalised;
};
const getPronunciationText = (container) => {
if (!container) return "";
const input = container.querySelector('[data-role="speaker-pronunciation"]');
const raw = input?.value?.trim();
if (raw) {
return raw;
}
return (container.dataset.defaultPronunciation || "").trim();
};
const syncPronunciationPreview = (container) => {
if (!container) return;
const text = getPronunciationText(container);
const previewButtons = container.querySelectorAll('[data-role="speaker-preview"][data-preview-source]');
previewButtons.forEach((button) => {
const source = button.dataset.previewSource;
if (["pronunciation", "generated", "mix"].includes(source)) {
button.dataset.previewText = text;
}
});
};
const setSpeakerSample = (speakerItem, index) => {
if (!speakerItem) return;
const samples = readSpeakerSamples(speakerItem);
if (!samples.length) return;
const maxIndex = samples.length;
const normalisedIndex = ((index % maxIndex) + maxIndex) % maxIndex;
sampleIndexState.set(speakerItem, normalisedIndex);
const sample = samples[normalisedIndex];
const article = speakerItem.querySelector('[data-role="speaker-sample"]');
if (!article) return;
const textNode = article.querySelector('[data-role="sample-text"]');
const hintNode = article.querySelector('[data-role="sample-hint"]');
if (textNode) {
textNode.textContent = sample.excerpt;
}
if (hintNode) {
if (sample.genderHint) {
hintNode.hidden = false;
hintNode.textContent = sample.genderHint;
} else {
hintNode.hidden = true;
hintNode.textContent = "";
}
}
const previewButton = article.querySelector('[data-role="speaker-preview"][data-preview-source="sample"]');
if (previewButton) {
previewButton.dataset.previewText = sample.excerpt;
}
const voiceBrowserButton = article.querySelector('[data-role="open-voice-browser"]');
if (voiceBrowserButton) {
voiceBrowserButton.dataset.sampleIndex = String(normalisedIndex);
}
};
const initialiseSpeakerItem = (speakerItem) => {
syncPronunciationPreview(speakerItem);
const samples = readSpeakerSamples(speakerItem);
if (samples.length) {
setSpeakerSample(speakerItem, 0);
const nextButton = speakerItem.querySelector('[data-role="speaker-next-sample"]');
if (nextButton) {
nextButton.disabled = samples.length <= 1;
}
}
};
const formatCustomMixLabel = (formula) => {
if (!formula) return "Custom mix";
const segments = formula
@@ -229,6 +336,9 @@ document.addEventListener("DOMContentLoaded", () => {
updatePreviewVoice(select);
});
const speakerItems = Array.from(form.querySelectorAll(".speaker-list__item"));
speakerItems.forEach((item) => initialiseSpeakerItem(item));
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
analysisButtons.forEach((button) => {
@@ -690,7 +800,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (!voiceModal) return;
modalState.speakerItem = speakerItem;
const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]');
const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"][data-preview-source="pronunciation"]');
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || "";
modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map();
@@ -698,36 +808,32 @@ document.addEventListener("DOMContentLoaded", () => {
modalState.mix.set(select.value, 1);
}
modalState.mix = normaliseMix(modalState.mix);
modalState.previewSettings = {
language: previewTrigger?.dataset.language || "a",
speed: previewTrigger?.dataset.speed || "1",
useGpu: previewTrigger?.dataset.useGpu || "true",
};
const samplesTemplate = speakerItem.querySelector('template[data-role="speaker-samples"]');
let samples = [];
if (samplesTemplate) {
try {
const raw = samplesTemplate.innerHTML || "[]";
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
samples = parsed
.map((entry) => (typeof entry === "string" ? entry : entry?.excerpt))
.filter((value) => typeof value === "string" && value.trim().length);
}
} catch (error) {
console.warn("Unable to parse speaker samples", error);
const samples = readSpeakerSamples(speakerItem);
let excerpts = samples.map((sample) => sample.excerpt);
const storedIndex = sampleIndexState.get(speakerItem) || 0;
let effectiveIndex = Number.isFinite(sampleIndex) ? sampleIndex : 0;
if (!Number.isFinite(effectiveIndex) || effectiveIndex < 0 || effectiveIndex >= excerpts.length) {
effectiveIndex = storedIndex;
}
if (excerpts.length && effectiveIndex > 0 && effectiveIndex < excerpts.length) {
const [selected] = excerpts.splice(effectiveIndex, 1);
excerpts.unshift(selected);
}
if (!excerpts.length) {
const sampleButton = speakerItem.querySelector('[data-role="speaker-preview"][data-preview-source="sample"]');
const previewText = sampleButton?.dataset.previewText?.trim();
if (previewText) {
excerpts = [previewText];
}
}
if (previewTrigger?.dataset.previewText) {
samples.unshift(previewTrigger.dataset.previewText);
}
const uniqueSamples = Array.from(new Set(samples));
if (sampleIndex > 0 && sampleIndex < uniqueSamples.length) {
const [picked] = uniqueSamples.splice(sampleIndex, 1);
uniqueSamples.unshift(picked);
}
modalState.samples = uniqueSamples;
modalState.samples = Array.from(new Set(excerpts));
modalState.recommended = new Set(
Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean)
);
@@ -899,6 +1005,16 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
const nextSampleButton = event.target.closest('[data-role="speaker-next-sample"]');
if (nextSampleButton) {
event.preventDefault();
const container = nextSampleButton.closest(".speaker-list__item");
if (!container) return;
const currentIndex = sampleIndexState.get(container) || 0;
setSpeakerSample(container, currentIndex + 1);
return;
}
const clearMixButton = event.target.closest('[data-role="clear-mix"]');
if (clearMixButton) {
event.preventDefault();
+55 -64
View File
@@ -8,26 +8,30 @@
<section class="wizard-page" data-step="chapters">
<div class="wizard-card card card--modal">
<header class="modal__header wizard-card__header">
<div class="modal__heading">
<p class="modal__eyebrow">Step 2 of 3</p>
<h2 class="modal__title">Select chapters</h2>
<p class="hint">Choose which chapters to convert and tweak conversion options. We'll analyse speakers automatically when you continue.</p>
</div>
<div class="wizard-card__aside">
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-complete">
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<a class="step-indicator__item is-complete" href="{{ url_for('web.index') }}">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</span>
<span class="step-indicator__item is-active">
</a>
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item" {% if not is_multi_speaker %}hidden aria-hidden="true"{% endif %}>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</span>
</div>
</a>
{% endif %}
</nav>
<h2 class="modal__title">Select chapters</h2>
<p class="hint">Choose which chapters to convert. We'll analyse speakers automatically when you continue.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
@@ -35,8 +39,18 @@
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<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) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
</div>
<div class="modal__body wizard-card__body">
{% if error %}
@@ -62,23 +76,32 @@
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
<article class="chapter-card" data-role="chapter-row">
<header class="chapter-card__header">
<div class="chapter-card__title">
<label class="chapter-card__checkbox">
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label>
</div>
{% set raw_excerpt = (chapter.text or '') | replace('\r', ' ') | replace('\n', ' ') %}
{% set excerpt = raw_excerpt[:240] %}
<article class="chapter-card"
data-role="chapter-row"
data-disabled="{{ 'false' if is_enabled else 'true' }}"
data-expanded="{{ 'true' if is_enabled else 'false' }}">
<header class="chapter-card__summary">
<label class="chapter-card__checkbox">
<input type="checkbox"
name="chapter-{{ loop.index0 }}-enabled"
data-role="chapter-enabled"
{% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label>
<p class="chapter-card__snippet">
{{ excerpt }}{% if raw_excerpt|length > 240 %}…{% endif %}
</p>
</header>
<div class="chapter-card__body">
<div class="chapter-card__details" data-role="chapter-details">
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
</div>
<div class="chapter-card__preview">
<details>
<summary>Preview text</summary>
<summary>Preview full text</summary>
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
</details>
</div>
@@ -100,7 +123,13 @@
{% endif %}
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
</select>
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
<input type="text"
name="chapter-{{ loop.index0 }}-formula"
class="chapter-card__formula"
data-role="formula-input"
placeholder="af_nova*0.4+am_liam*0.6"
value="{{ chapter.voice_formula or '' }}"
{% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
</div>
<p class="chapter-card__notice" data-role="chapter-warning" hidden>
If this chapter mentions newsletter sign-ups or marketing content, double-check any references so listeners aren't sent to an outdated link.
@@ -110,48 +139,10 @@
{% endfor %}
</div>
</section>
<section class="form-section">
<h3 class="form-section__title">Conversion defaults</h3>
<div class="field-grid field-grid--two">
<div class="field">
<label for="chunk_level">Chunk granularity</label>
<select id="chunk_level" name="chunk_level">
{% for option in options.chunk_levels %}
<option value="{{ option.value }}" {% if pending.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
</div>
<div class="field">
<label for="speaker_mode">Speaker mode</label>
<select id="speaker_mode" name="speaker_mode">
{% for option in options.speaker_modes %}
<option value="{{ option.value }}" {% if pending.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field-grid field-grid--two">
<div class="field">
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<p class="hint">Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.</p>
</div>
<div class="field">
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
</div>
</div>
<label class="toggle-pill">
<input type="checkbox" name="generate_epub3" value="true" {% if pending.generate_epub3 %}checked{% endif %}>
<span>Generate EPUB 3 (experimental)</span>
</label>
</section>
</div>
<footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions">
<a class="button button--ghost" href="{{ url_for('web.index') }}">Previous</a>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
<div class="wizard-card__footer-actions">
+50 -38
View File
@@ -12,29 +12,30 @@
<section class="wizard-page" data-step="speakers">
<div class="wizard-card card card--modal">
<header class="modal__header wizard-card__header">
<div class="modal__heading">
<p class="modal__eyebrow">Step 3 of 3</p>
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<a class="step-indicator__item is-complete" href="{{ url_for('web.index') }}">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</a>
<a class="step-indicator__item is-complete"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</a>
{% endif %}
</nav>
<h2 class="modal__title">Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p>
</div>
<div class="wizard-card__aside">
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-complete">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</span>
<span class="step-indicator__item is-complete">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item is-active" {% if not is_multi_speaker %}hidden aria-hidden="true"{% endif %}>
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</span>
</div>
<div class="wizard-card__links">
<a class="button button--ghost" href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">Back to chapters</a>
</div>
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
@@ -43,6 +44,7 @@
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="speakers" data-role="active-step-input">
@@ -128,8 +130,7 @@
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in pending.speakers.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
@@ -137,14 +138,17 @@
{% set current_gender = speaker.gender or detected_gender %}
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
<li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
<li class="speaker-list__item"
data-speaker-id="{{ speaker_id }}"
data-default-pronunciation="{{ pronunciation_text }}">
<div class="speaker-line 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-preview-source="pronunciation"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -186,7 +190,8 @@
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}"
value="{{ pronunciation_text }}"
data-role="speaker-pronunciation"
placeholder="{{ speaker.label }}">
</label>
<div class="speaker-list__controls">
@@ -243,7 +248,8 @@
data-role="speaker-preview"
data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-preview-source="generated"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -259,9 +265,9 @@
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-context="mix"
data-preview-source="mix"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -276,20 +282,19 @@
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary>
{% if sample_quotes %}
{% for quote in sample_quotes %}
{% set excerpt = quote.excerpt if quote is mapping else quote %}
{% set gender_hint = quote.gender_hint if quote is mapping else '' %}
<article class="speaker-sample" data-sample-index="{{ loop.index0 }}">
<p>{{ excerpt }}</p>
{% if gender_hint %}
<p class="hint">{{ gender_hint }}</p>
{% endif %}
{% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
{% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
{% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
<article class="speaker-sample" data-role="speaker-sample">
<p data-role="sample-text">{{ first_excerpt }}</p>
<p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
<div class="speaker-sample__actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-source="sample"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ quote|e }}"
data-preview-text="{{ first_excerpt }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -300,12 +305,18 @@
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}"
data-sample-index="{{ loop.index0 }}">
data-sample-index="0">
Preview in voice browser
</button>
{% if sample_quotes|length > 1 %}
<button type="button"
class="button button--ghost button--small"
data-role="speaker-next-sample">
Show another example
</button>
{% endif %}
</div>
</article>
{% endfor %}
{% else %}
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
{% endif %}
@@ -336,6 +347,7 @@
<footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions">
<a class="button button--ghost" href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">Previous</a>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
<div class="wizard-card__footer-actions">
+49
View File
@@ -42,3 +42,52 @@ def test_analyze_speakers_infers_gender_from_pronouns():
assert john.gender == "male"
assert mary.gender == "female"
assert alex.gender == "unknown"
def test_analyze_speakers_ignores_leading_stopwords():
chunks = [
_chunk('But Volescu said, "We march at dawn."', 0),
_chunk('Then Blue Leader shouted, "Hold the perimeter."', 1),
]
analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
speakers = analysis.speakers
assert "volescu" in speakers
assert speakers["volescu"].label == "Volescu"
assert "blue_leader" in speakers
assert speakers["blue_leader"].label == "Blue Leader"
assert "but_volescu" not in speakers
assert "then_blue_leader" not in speakers
def test_analyze_speakers_applies_threshold_suppression():
chunks = [
_chunk("\"Hello there,\" said Narrator.", 0),
_chunk("\"It is lying,\" said Green.", 1),
]
analysis = analyze_speakers(_chapters(), chunks, threshold=3, max_speakers=0)
green = analysis.speakers.get("green")
assert green is not None
assert green.suppressed is True
assert "green" in analysis.suppressed
def test_sample_excerpt_includes_context_paragraphs():
chunks = [
_chunk("The hallway was quiet as footsteps approached.", 0),
_chunk('\"Open the door,\" said John as he reached for the handle.', 1),
_chunk("Mary watched him closely, unsure of his intent.", 2),
]
analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
john = analysis.speakers.get("john")
assert john is not None
assert john.sample_quotes, "Expected John to have at least one sample quote"
excerpt = john.sample_quotes[0]["excerpt"]
assert "The hallway was quiet" in excerpt
assert "\"Open the door,\" said John" in excerpt
assert "Mary watched him closely" in excerpt