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
+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">