mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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 & 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 & 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">
|
||||
|
||||
Reference in New Issue
Block a user