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
+291 -34
View File
@@ -4,6 +4,8 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
import unicodedata
_DIALOGUE_VERBS = ( _DIALOGUE_VERBS = (
"said", "said",
"asked", "asked",
@@ -71,6 +73,73 @@ _PRONOUN_LABELS = {
_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3} _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) @dataclass(slots=True)
class SpeakerGuess: class SpeakerGuess:
@@ -92,12 +161,13 @@ class SpeakerGuess:
quote: Optional[str], quote: Optional[str],
male_votes: int, male_votes: int,
female_votes: int, female_votes: int,
sample_excerpt: Optional[str] = None,
) -> None: ) -> None:
self.count += 1 self.count += 1
if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0): if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0):
self.confidence = confidence 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) gender_hint = _format_gender_hint(male_votes, female_votes)
if excerpt: if excerpt:
payload = {"excerpt": excerpt, "gender_hint": gender_hint} payload = {"excerpt": excerpt, "gender_hint": gender_hint}
@@ -173,9 +243,9 @@ def analyze_speakers(
explicit_assignments = 0 explicit_assignments = 0
unique_speakers: set[str] = set() unique_speakers: set[str] = set()
for chunk in ordered_chunks: for index, chunk in enumerate(ordered_chunks):
chunk_id = str(chunk.get("id") or "") 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) speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit)
if speaker_id is None: if speaker_id is None:
speaker_id = last_explicit or narrator_id speaker_id = last_explicit or narrator_id
@@ -184,10 +254,6 @@ def analyze_speakers(
if speaker_id != narrator_id: if speaker_id != narrator_id:
last_explicit = speaker_id last_explicit = speaker_id
explicit_assignments += 1 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: if speaker_id in speaker_guesses:
record_id = speaker_id record_id = speaker_id
@@ -201,13 +267,20 @@ def analyze_speakers(
label_index[label] = record_id label_index[label] = record_id
speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label)
guess = speaker_guesses[record_id] 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 assignments[chunk_id] = record_id
if speaker_id == last_explicit: unique_speakers.add(record_id)
if record_id != narrator_id and record_id != speaker_id and speaker_id == last_explicit:
last_explicit = record_id 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] active_speakers = [sid for sid in speaker_guesses if sid != narrator_id]
# Apply minimum occurrence threshold. # Apply minimum occurrence threshold.
for speaker_id in list(active_speakers): for speaker_id in list(active_speakers):
@@ -367,31 +440,125 @@ def _reassign(assignments: Dict[str, str], old: str, new: str) -> None:
assignments[key] = new 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: if not text:
return 0, 0 return 0, 0
male_votes = 0.0 search_text = text
for token in _MALE_PRONOUN_PATTERN.findall(text): windows: List[Tuple[int, int]] = []
lowered = token.lower() degrade_factor = 1.0
if lowered in {"he", "himself"}:
male_votes += 1.0
elif lowered == "his":
male_votes += 0.75
else: # him
male_votes += 0.6
female_votes = 0.0 if label:
for token in _FEMALE_PRONOUN_PATTERN.findall(text): pattern = re.compile(re.escape(label), re.IGNORECASE)
lowered = token.lower() matches = list(pattern.finditer(search_text))
if lowered in {"she", "herself"}: if not matches:
female_votes += 1.0 alt_label = _strip_diacritics(label)
elif lowered == "hers": if alt_label and alt_label != label:
female_votes += 0.75 ascii_text = _strip_diacritics(search_text)
else: # her pattern_alt = re.compile(re.escape(alt_label), re.IGNORECASE)
female_votes += 0.4 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: 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" 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: def _build_excerpt(text: str, quote: Optional[str]) -> str:
normalized = (text or "").strip() normalized = (text or "").strip()
if not normalized: if not normalized:
@@ -452,7 +683,33 @@ def _normalize_candidate_name(raw: str) -> Optional[str]:
cleaned = re.sub(r"\s+", " ", cleaned).strip() cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned: if not cleaned:
return None return None
lowered = cleaned.lower() parts = cleaned.split()
if lowered in _PRONOUN_LABELS: 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 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, {}) payload = speakers.get(speaker_id, {})
if speaker_id == "narrator": if speaker_id == "narrator":
continue continue
if isinstance(payload, Mapping) and payload.get("suppressed"):
continue continue
previous = existing_map.get(speaker_id) previous = existing_map.get(speaker_id)
roster[speaker_id] = { roster[speaker_id] = {
@@ -864,7 +865,11 @@ def _prepare_speaker_metadata(
ordered_ids = [ ordered_ids = [
sid sid
for sid, meta in sorted( 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), key=lambda item: item[1].get("count", 0),
reverse=True, reverse=True,
) )
+138 -22
View File
@@ -18,6 +18,113 @@ document.addEventListener("DOMContentLoaded", () => {
const languageMap = parseJSONScript("voice-language-map") || {}; const languageMap = parseJSONScript("voice-language-map") || {};
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); 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) => { const formatCustomMixLabel = (formula) => {
if (!formula) return "Custom mix"; if (!formula) return "Custom mix";
const segments = formula const segments = formula
@@ -229,6 +336,9 @@ document.addEventListener("DOMContentLoaded", () => {
updatePreviewVoice(select); 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 activeStepInput = form.querySelector('[data-role="active-step-input"]');
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]')); const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
analysisButtons.forEach((button) => { analysisButtons.forEach((button) => {
@@ -690,7 +800,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (!voiceModal) return; if (!voiceModal) return;
modalState.speakerItem = speakerItem; modalState.speakerItem = speakerItem;
const select = speakerItem.querySelector('[data-role="speaker-voice"]'); 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"]'); const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || ""; modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || "";
modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map(); modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map();
@@ -698,36 +808,32 @@ document.addEventListener("DOMContentLoaded", () => {
modalState.mix.set(select.value, 1); modalState.mix.set(select.value, 1);
} }
modalState.mix = normaliseMix(modalState.mix); modalState.mix = normaliseMix(modalState.mix);
modalState.previewSettings = { modalState.previewSettings = {
language: previewTrigger?.dataset.language || "a", language: previewTrigger?.dataset.language || "a",
speed: previewTrigger?.dataset.speed || "1", speed: previewTrigger?.dataset.speed || "1",
useGpu: previewTrigger?.dataset.useGpu || "true", useGpu: previewTrigger?.dataset.useGpu || "true",
}; };
const samplesTemplate = speakerItem.querySelector('template[data-role="speaker-samples"]'); const samples = readSpeakerSamples(speakerItem);
let samples = []; let excerpts = samples.map((sample) => sample.excerpt);
if (samplesTemplate) { const storedIndex = sampleIndexState.get(speakerItem) || 0;
try { let effectiveIndex = Number.isFinite(sampleIndex) ? sampleIndex : 0;
const raw = samplesTemplate.innerHTML || "[]"; if (!Number.isFinite(effectiveIndex) || effectiveIndex < 0 || effectiveIndex >= excerpts.length) {
const parsed = JSON.parse(raw); effectiveIndex = storedIndex;
if (Array.isArray(parsed)) {
samples = parsed
.map((entry) => (typeof entry === "string" ? entry : entry?.excerpt))
.filter((value) => typeof value === "string" && value.trim().length);
} }
} catch (error) { if (excerpts.length && effectiveIndex > 0 && effectiveIndex < excerpts.length) {
console.warn("Unable to parse speaker samples", error); 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) { modalState.samples = Array.from(new Set(excerpts));
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.recommended = new Set( modalState.recommended = new Set(
Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean) Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean)
); );
@@ -899,6 +1005,16 @@ document.addEventListener("DOMContentLoaded", () => {
return; 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"]'); const clearMixButton = event.target.closest('[data-role="clear-mix"]');
if (clearMixButton) { if (clearMixButton) {
event.preventDefault(); event.preventDefault();
+51 -60
View File
@@ -8,26 +8,30 @@
<section class="wizard-page" data-step="chapters"> <section class="wizard-page" data-step="chapters">
<div class="wizard-card card card--modal"> <div class="wizard-card card card--modal">
<header class="modal__header wizard-card__header"> <header class="modal__header wizard-card__header">
<div class="modal__heading"> <div class="wizard-card__headline">
<p class="modal__eyebrow">Step 2 of 3</p> <nav class="step-indicator" aria-label="Audiobook workflow">
<h2 class="modal__title">Select chapters</h2> <a class="step-indicator__item is-complete" href="{{ url_for('web.index') }}">
<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">
<span class="step-indicator__index">1</span> <span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span> <span class="step-indicator__label">Upload &amp; settings</span>
</span> </a>
<span class="step-indicator__item is-active"> <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__index">2</span>
<span class="step-indicator__label">Chapters</span> <span class="step-indicator__label">Chapters</span>
</span> </a>
<span class="step-indicator__item" {% if not is_multi_speaker %}hidden aria-hidden="true"{% endif %}> {% 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__index">3</span>
<span class="step-indicator__label">Speakers</span> <span class="step-indicator__label">Speakers</span>
</span> </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>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p> <p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div> </div>
</header> </header>
@@ -35,8 +39,18 @@
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form" class="prepare-form"
id="prepare-form" id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"> 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"> <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"> <div class="modal__body wizard-card__body">
{% if error %} {% if error %}
@@ -62,23 +76,32 @@
{% elif chapter.voice_formula %} {% elif chapter.voice_formula %}
{% set selected_option = 'formula' %} {% set selected_option = 'formula' %}
{% endif %} {% endif %}
<article class="chapter-card" data-role="chapter-row"> {% set raw_excerpt = (chapter.text or '') | replace('\r', ' ') | replace('\n', ' ') %}
<header class="chapter-card__header"> {% set excerpt = raw_excerpt[:240] %}
<div class="chapter-card__title"> <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"> <label class="chapter-card__checkbox">
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}> <input type="checkbox"
name="chapter-{{ loop.index0 }}-enabled"
data-role="chapter-enabled"
{% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span> <span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label> </label>
</div> <p class="chapter-card__snippet">
{{ excerpt }}{% if raw_excerpt|length > 240 %}…{% endif %}
</p>
</header> </header>
<div class="chapter-card__body"> <div class="chapter-card__details" data-role="chapter-details">
<div class="chapter-card__field"> <div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label> <label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}"> <input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
</div> </div>
<div class="chapter-card__preview"> <div class="chapter-card__preview">
<details> <details>
<summary>Preview text</summary> <summary>Preview full text</summary>
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre> <pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
</details> </details>
</div> </div>
@@ -100,7 +123,13 @@
{% endif %} {% endif %}
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option> <option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
</select> </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> </div>
<p class="chapter-card__notice" data-role="chapter-warning" hidden> <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. 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 %} {% endfor %}
</div> </div>
</section> </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> </div>
<footer class="modal__footer wizard-card__footer"> <footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions"> <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> <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div> </div>
<div class="wizard-card__footer-actions"> <div class="wizard-card__footer-actions">
+50 -38
View File
@@ -12,29 +12,30 @@
<section class="wizard-page" data-step="speakers"> <section class="wizard-page" data-step="speakers">
<div class="wizard-card card card--modal"> <div class="wizard-card card card--modal">
<header class="modal__header wizard-card__header"> <header class="modal__header wizard-card__header">
<div class="modal__heading"> <div class="wizard-card__headline">
<p class="modal__eyebrow">Step 3 of 3</p> <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> <h2 class="modal__title">Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p> <p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p>
</div> </div>
<div class="wizard-card__aside"> <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> <p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div> </div>
</header> </header>
@@ -43,6 +44,7 @@
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form" class="prepare-form"
id="prepare-form" id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"> 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"> <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> <p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
<ul class="speaker-list"> <ul class="speaker-list">
{% for speaker_id, speaker in pending.speakers.items() %} {% for speaker_id, speaker in pending.speakers.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %} {% set pronunciation_text = speaker.pronunciation or speaker.label %}
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %} {% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %} {% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %} {% set sample_quotes = speaker.sample_quotes or [] %}
@@ -137,14 +138,17 @@
{% set current_gender = speaker.gender or detected_gender %} {% 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 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') %} {% 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"> <div class="speaker-line speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span> <span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button" <button type="button"
class="icon-button speaker-list__preview" class="icon-button speaker-list__preview"
data-role="speaker-preview" data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}" 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-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}" data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}" data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -186,7 +190,8 @@
<input type="text" <input type="text"
id="speaker-{{ speaker_id }}-pronunciation" id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation" name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}" value="{{ pronunciation_text }}"
data-role="speaker-pronunciation"
placeholder="{{ speaker.label }}"> placeholder="{{ speaker.label }}">
</label> </label>
<div class="speaker-list__controls"> <div class="speaker-list__controls">
@@ -243,7 +248,8 @@
data-role="speaker-preview" data-role="speaker-preview"
data-preview-kind="generated" data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}" 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-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}" data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -259,9 +265,9 @@
<button type="button" <button type="button"
class="button button--ghost button--small" class="button button--ghost button--small"
data-role="speaker-preview" data-role="speaker-preview"
data-preview-context="mix" data-preview-source="mix"
data-speaker-id="{{ speaker_id }}" data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}" data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}" data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}" data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -276,20 +282,19 @@
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}> <details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary> <summary>Sample paragraphs</summary>
{% if sample_quotes %} {% if sample_quotes %}
{% for quote in sample_quotes %} {% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
{% set excerpt = quote.excerpt if quote is mapping else quote %} {% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
{% set gender_hint = quote.gender_hint if quote is mapping else '' %} {% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
<article class="speaker-sample" data-sample-index="{{ loop.index0 }}"> <article class="speaker-sample" data-role="speaker-sample">
<p>{{ excerpt }}</p> <p data-role="sample-text">{{ first_excerpt }}</p>
{% if gender_hint %} <p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
<p class="hint">{{ gender_hint }}</p>
{% endif %}
<div class="speaker-sample__actions"> <div class="speaker-sample__actions">
<button type="button" <button type="button"
class="button button--ghost button--small" class="button button--ghost button--small"
data-role="speaker-preview" data-role="speaker-preview"
data-preview-source="sample"
data-speaker-id="{{ speaker_id }}" data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ quote|e }}" data-preview-text="{{ first_excerpt }}"
data-language="{{ pending.language }}" data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}" data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}" data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -300,12 +305,18 @@
class="button button--ghost button--small" class="button button--ghost button--small"
data-role="open-voice-browser" data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}" data-speaker-id="{{ speaker_id }}"
data-sample-index="{{ loop.index0 }}"> data-sample-index="0">
Preview in voice browser Preview in voice browser
</button> </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> </div>
</article> </article>
{% endfor %}
{% else %} {% else %}
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p> <p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
{% endif %} {% endif %}
@@ -336,6 +347,7 @@
<footer class="modal__footer wizard-card__footer"> <footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions"> <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> <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div> </div>
<div class="wizard-card__footer-actions"> <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 john.gender == "male"
assert mary.gender == "female" assert mary.gender == "female"
assert alex.gender == "unknown" 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