mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Add chapter and speaker preparation templates for audiobook conversion workflow
- Created `prepare_chapters.html` to allow users to select and configure chapters for conversion, including options for voice profiles and chunk granularity. - Developed `prepare_speakers.html` for assigning voices to detected speakers, auditioning samples, and applying saved speaker configurations. - Implemented step indicators and navigation between chapters and speakers in the UI. - Added error and notice handling for user feedback during the preparation process. - Included scripts for voice catalog and language mapping to enhance user experience.
This commit is contained in:
+13
-2
@@ -2613,14 +2613,25 @@ def _render_prepare_page(
|
||||
if request.method == "POST"
|
||||
else request.args.get("step")
|
||||
) or "chapters"
|
||||
|
||||
normalized_step = (active_step or "chapters").strip().lower()
|
||||
if normalized_step not in {"chapters", "speakers"}:
|
||||
normalized_step = "chapters"
|
||||
|
||||
is_multi = pending.speaker_mode == "multi"
|
||||
if normalized_step == "speakers" and not is_multi:
|
||||
normalized_step = "chapters"
|
||||
|
||||
template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html"
|
||||
|
||||
return render_template(
|
||||
"prepare_job.html",
|
||||
template_name,
|
||||
pending=pending,
|
||||
options=_template_options(),
|
||||
settings=_load_settings(),
|
||||
error=error,
|
||||
notice=notice,
|
||||
active_step=active_step,
|
||||
active_step=normalized_step,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+29
-229
@@ -229,246 +229,46 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
updatePreviewVoice(select);
|
||||
});
|
||||
|
||||
const finalizeAction = form.getAttribute("action");
|
||||
const analyzeUrl = form.dataset.analyzeUrl || "";
|
||||
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
|
||||
const wizard = document.querySelector('[data-role="prepare-wizard"]');
|
||||
if (wizard) {
|
||||
const stepOrder = ["chapters", "speakers"];
|
||||
const indicatorOrder = ["setup", ...stepOrder];
|
||||
const indicator = wizard.querySelector('[data-role="wizard-indicator"]');
|
||||
const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : [];
|
||||
const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]'));
|
||||
const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]'));
|
||||
const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]'));
|
||||
const panels = new Map();
|
||||
const initialStep = wizard.dataset.initialStep || "chapters";
|
||||
const speakerModeSelect = form.querySelector("#speaker_mode");
|
||||
const speakerIndicator = indicator ? indicator.querySelector('[data-step-key="speakers"]') : null;
|
||||
const speakerPanel = wizard.querySelector('[data-speaker-panel="true"]');
|
||||
const chapterActions = wizard.querySelector('[data-role="chapter-actions"]');
|
||||
const continueButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="continue"]') : null;
|
||||
const finalizeButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="finalize"]') : null;
|
||||
stepOrder.forEach((step) => {
|
||||
const panel = wizard.querySelector(`[data-step-panel="${step}"]`);
|
||||
if (panel) {
|
||||
panels.set(step, panel);
|
||||
const isInitial = step === initialStep;
|
||||
panel.hidden = !isInitial;
|
||||
panel.setAttribute("aria-hidden", isInitial ? "false" : "true");
|
||||
}
|
||||
});
|
||||
|
||||
const unlockedSteps = new Set(["chapters"]);
|
||||
if (initialStep === "speakers") {
|
||||
unlockedSteps.add("speakers");
|
||||
}
|
||||
let currentStep = initialStep;
|
||||
|
||||
const shouldSkipSpeakersStep = () => {
|
||||
const modeValue = (speakerModeSelect?.value || "").toLowerCase();
|
||||
if (!modeValue) {
|
||||
return true;
|
||||
}
|
||||
return modeValue !== "multi";
|
||||
};
|
||||
|
||||
const submitFinalize = () => {
|
||||
if (!form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
if (activeStepInput) {
|
||||
activeStepInput.value = currentStep;
|
||||
}
|
||||
if (finalizeAction) {
|
||||
form.action = finalizeAction;
|
||||
}
|
||||
form.submit();
|
||||
};
|
||||
|
||||
const updateIndicator = (activeStep) => {
|
||||
const activeIndex = indicatorOrder.indexOf(activeStep);
|
||||
indicatorSteps.forEach((item) => {
|
||||
const key = item.dataset.stepKey;
|
||||
if (!key) return;
|
||||
const index = indicatorOrder.indexOf(key);
|
||||
item.classList.remove("is-active", "is-complete");
|
||||
if (index < activeIndex) {
|
||||
item.classList.add("is-complete");
|
||||
} else if (index === activeIndex) {
|
||||
item.classList.add("is-active");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const unlockStep = (step) => {
|
||||
if (unlockedSteps.has(step)) {
|
||||
return;
|
||||
}
|
||||
unlockedSteps.add(step);
|
||||
navButtons.forEach((button) => {
|
||||
if (button.dataset.stepTarget === step) {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-disabled");
|
||||
button.dataset.state = "unlocked";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setStep = (step) => {
|
||||
let targetStep = step;
|
||||
if (targetStep === "speakers" && shouldSkipSpeakersStep()) {
|
||||
targetStep = "chapters";
|
||||
}
|
||||
if (!panels.has(targetStep)) {
|
||||
return;
|
||||
}
|
||||
currentStep = targetStep;
|
||||
wizard.dataset.activeStep = targetStep;
|
||||
if (activeStepInput) {
|
||||
activeStepInput.value = targetStep;
|
||||
}
|
||||
panels.forEach((panel, key) => {
|
||||
const isActive = key === targetStep;
|
||||
panel.hidden = !isActive;
|
||||
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
|
||||
});
|
||||
navButtons.forEach((button) => {
|
||||
const target = button.dataset.stepTarget;
|
||||
if (!target) return;
|
||||
const isActive = target === targetStep;
|
||||
button.classList.toggle("is-active", isActive);
|
||||
if (button.dataset.state === "locked" && !unlockedSteps.has(target)) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-disabled");
|
||||
}
|
||||
});
|
||||
updateIndicator(targetStep);
|
||||
};
|
||||
|
||||
const updateSpeakerControls = () => {
|
||||
const skipSpeakers = shouldSkipSpeakersStep();
|
||||
if (wizard) {
|
||||
wizard.dataset.speakerStep = skipSpeakers ? "disabled" : "enabled";
|
||||
}
|
||||
if (speakerIndicator) {
|
||||
speakerIndicator.hidden = skipSpeakers;
|
||||
if (skipSpeakers) {
|
||||
speakerIndicator.setAttribute("aria-hidden", "true");
|
||||
} else {
|
||||
speakerIndicator.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
if (!skipSpeakers) {
|
||||
unlockStep("speakers");
|
||||
}
|
||||
if (speakerPanel) {
|
||||
speakerPanel.dataset.speakerEnabled = skipSpeakers ? "false" : "true";
|
||||
}
|
||||
if (continueButton) {
|
||||
if (skipSpeakers) {
|
||||
continueButton.hidden = true;
|
||||
continueButton.setAttribute("aria-hidden", "true");
|
||||
continueButton.setAttribute("tabindex", "-1");
|
||||
} else {
|
||||
continueButton.hidden = false;
|
||||
continueButton.removeAttribute("aria-hidden");
|
||||
continueButton.removeAttribute("tabindex");
|
||||
}
|
||||
}
|
||||
if (finalizeButton) {
|
||||
if (skipSpeakers) {
|
||||
finalizeButton.hidden = false;
|
||||
finalizeButton.removeAttribute("aria-hidden");
|
||||
} else {
|
||||
finalizeButton.hidden = true;
|
||||
finalizeButton.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
if (skipSpeakers && currentStep === "speakers") {
|
||||
setStep("chapters");
|
||||
}
|
||||
};
|
||||
|
||||
const submitForAnalysis = () => {
|
||||
if (!analyzeUrl) {
|
||||
unlockStep("speakers");
|
||||
setStep("speakers");
|
||||
return;
|
||||
}
|
||||
if (!form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
|
||||
analysisButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (activeStepInput) {
|
||||
activeStepInput.value = "speakers";
|
||||
}
|
||||
if (finalizeAction) {
|
||||
form.setAttribute("data-finalize-action", finalizeAction);
|
||||
}
|
||||
form.action = analyzeUrl;
|
||||
form.submit();
|
||||
if (finalizeAction) {
|
||||
window.setTimeout(() => {
|
||||
form.action = finalizeAction;
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
navButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (button.disabled) {
|
||||
return;
|
||||
}
|
||||
const target = button.dataset.stepTarget;
|
||||
if (!target) return;
|
||||
unlockStep(target);
|
||||
setStep(target);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
nextButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const target = button.dataset.stepTarget || "speakers";
|
||||
if (target === "speakers") {
|
||||
if (shouldSkipSpeakersStep()) {
|
||||
submitFinalize();
|
||||
return;
|
||||
}
|
||||
submitForAnalysis();
|
||||
return;
|
||||
}
|
||||
unlockStep(target);
|
||||
setStep(target);
|
||||
});
|
||||
});
|
||||
const speakerModeSelect = form.querySelector("#speaker_mode");
|
||||
const analysisToggleButtons = Array.from(form.querySelectorAll('[data-step-toggle="analysis"]'));
|
||||
const finalizeToggleButtons = Array.from(form.querySelectorAll('[data-step-toggle="finalize"]'));
|
||||
|
||||
prevButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const target = button.dataset.stepTarget || "chapters";
|
||||
setStep(target);
|
||||
});
|
||||
});
|
||||
|
||||
if (speakerModeSelect) {
|
||||
speakerModeSelect.addEventListener("change", () => {
|
||||
updateSpeakerControls();
|
||||
});
|
||||
const setButtonVisibility = (button, isVisible) => {
|
||||
if (!button) return;
|
||||
if (isVisible) {
|
||||
button.hidden = false;
|
||||
button.removeAttribute("aria-hidden");
|
||||
button.removeAttribute("tabindex");
|
||||
} else {
|
||||
button.hidden = true;
|
||||
button.setAttribute("aria-hidden", "true");
|
||||
button.setAttribute("tabindex", "-1");
|
||||
}
|
||||
};
|
||||
|
||||
if (finalizeButton) {
|
||||
finalizeButton.addEventListener("click", (event) => {
|
||||
if (shouldSkipSpeakersStep()) {
|
||||
event.preventDefault();
|
||||
submitFinalize();
|
||||
}
|
||||
});
|
||||
const updateStepButtons = () => {
|
||||
if (!speakerModeSelect) {
|
||||
return;
|
||||
}
|
||||
const modeValue = (speakerModeSelect.value || "").toLowerCase();
|
||||
const isMulti = modeValue === "multi";
|
||||
analysisToggleButtons.forEach((button) => setButtonVisibility(button, isMulti));
|
||||
finalizeToggleButtons.forEach((button) => setButtonVisibility(button, !isMulti));
|
||||
};
|
||||
|
||||
setStep(currentStep);
|
||||
updateSpeakerControls();
|
||||
if (speakerModeSelect) {
|
||||
updateStepButtons();
|
||||
speakerModeSelect.addEventListener("change", updateStepButtons);
|
||||
}
|
||||
|
||||
const voiceModal = document.querySelector('[data-role="voice-modal"]');
|
||||
|
||||
@@ -515,6 +515,89 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem 0 5rem;
|
||||
}
|
||||
|
||||
.wizard-card {
|
||||
width: min(1100px, calc(100% - 2.5rem));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wizard-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.wizard-card__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.wizard-card__links {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wizard-card__filename {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wizard-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.wizard-card__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wizard-card__footer-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-hidden-inputs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wizard-card__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wizard-card__aside {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wizard-card__links {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1310,6 +1393,17 @@ body {
|
||||
margin-bottom: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.form-section__title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-section__title-row .form-section__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prepare-summary__stats dl {
|
||||
margin: 0;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload & settings</span>
|
||||
</span>
|
||||
<span class="step-indicator__item is-active">
|
||||
<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 %}>
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
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="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="form-section__title-row">
|
||||
<h3 class="form-section__title">Detected chapters</h3>
|
||||
<p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p>
|
||||
</div>
|
||||
<div class="chapter-grid">
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% 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>
|
||||
</header>
|
||||
<div class="chapter-card__body">
|
||||
<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>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% 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 %}>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{% 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">
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
{% if is_multi_speaker %}
|
||||
<button type="submit"
|
||||
class="button"
|
||||
data-role="submit-speaker-analysis"
|
||||
data-step-toggle="analysis"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post">
|
||||
Continue to speakers
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
|
||||
Queue conversion
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,498 +1,3 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set wizard_step = (active_step or 'chapters') %}
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
{% if wizard_step == 'speakers' and not is_multi_speaker %}
|
||||
{% set wizard_step = 'chapters' %}
|
||||
{% endif %}
|
||||
{% set is_chapters = wizard_step == 'chapters' %}
|
||||
{% set is_speakers = wizard_step == 'speakers' %}
|
||||
{% set hide_speakers_panel = not is_speakers %}
|
||||
<section class="card" data-role="prepare-wizard" data-initial-step="{{ wizard_step }}" data-speaker-step="{{ 'enabled' if is_multi_speaker else 'disabled' }}">
|
||||
<div class="step-indicator" aria-label="Audiobook workflow" data-role="wizard-indicator">
|
||||
<span class="step-indicator__item is-complete" data-role="wizard-step" data-step-key="setup">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload & settings</span>
|
||||
</span>
|
||||
<span class="step-indicator__item{% if is_chapters %} is-active{% elif is_speakers %} is-complete{% endif %}" data-role="wizard-step" data-step-key="chapters">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</span>
|
||||
</span>
|
||||
<span class="step-indicator__item{% if is_speakers %} is-active{% endif %}" data-role="wizard-step" data-step-key="speakers" {% 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="card__title">Prepare audiobook</div>
|
||||
<p class="card__subtitle">Review the detected chapters, tune the speaker roster, and optionally override the voice per chapter before sending the job to the queue.</p>
|
||||
|
||||
{% set analysis = pending.speaker_analysis or {} %}
|
||||
{% set analysis_speakers = analysis.get('speakers', {}) %}
|
||||
{% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %}
|
||||
{% set has_metadata = pending.metadata_tags %}
|
||||
{% set roster = pending.speakers or {} %}
|
||||
{% set speaker_configs = options.speaker_configs or [] %}
|
||||
{% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %}
|
||||
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
||||
|
||||
<form method="post" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" class="prepare-form" id="prepare-form" data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
|
||||
<input type="hidden" name="active_step" value="{{ wizard_step }}" data-role="active-step-input">
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="prepare-steps" data-role="prepare-step-panels">
|
||||
<section class="prepare-step"
|
||||
data-step-panel="chapters"
|
||||
{% if not is_chapters %}hidden aria-hidden="true"{% endif %}>
|
||||
<header class="prepare-step__header">
|
||||
<h2>Step 2 · Select chapters</h2>
|
||||
<p class="hint">Choose which chapters to convert and tweak conversion options. We'll analyse speakers automatically when you continue.</p>
|
||||
</header>
|
||||
|
||||
<div class="chapter-grid">
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% 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>
|
||||
</header>
|
||||
<div class="chapter-card__body">
|
||||
<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>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% 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 %}>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="prepare-options">
|
||||
<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 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 class="field field--choices">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prepare-step__actions" data-role="chapter-actions">
|
||||
<button type="button"
|
||||
class="button"
|
||||
data-role="step-next"
|
||||
data-step-target="speakers"
|
||||
data-chapter-action="continue"
|
||||
{% if not is_multi_speaker %}hidden aria-hidden="true"{% endif %}>
|
||||
Continue to speakers
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="button"
|
||||
data-chapter-action="finalize"
|
||||
{% if is_multi_speaker %}hidden aria-hidden="true"{% endif %}>
|
||||
Queue conversion
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prepare-step"
|
||||
data-step-panel="speakers"
|
||||
data-speaker-panel="true"
|
||||
{% if hide_speakers_panel %}hidden aria-hidden="true"{% endif %}
|
||||
data-speaker-enabled="{{ 'true' if is_multi_speaker else 'false' }}">
|
||||
<header class="prepare-step__header">
|
||||
<h2>Step 3 · Select speakers</h2>
|
||||
<p class="hint">Assign voices, audition samples, and finalize your roster.</p>
|
||||
</header>
|
||||
|
||||
<div class="prepare-speaker-config">
|
||||
<div class="prepare-speaker-config__header">
|
||||
<h2>Speaker configuration</h2>
|
||||
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
|
||||
</div>
|
||||
<div class="prepare-speaker-config__grid">
|
||||
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
|
||||
<span>Saved preset</span>
|
||||
<select id="applied_speaker_config" name="applied_speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
|
||||
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
|
||||
</label>
|
||||
<div class="prepare-speaker-config__actions">
|
||||
<button type="submit"
|
||||
class="button button--ghost"
|
||||
name="apply_speaker_config"
|
||||
value="1"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if not speaker_configs %}disabled{% endif %}>
|
||||
Apply preset
|
||||
</button>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_speaker_config" value="1">
|
||||
<span>Save roster updates back to preset</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if roster %}
|
||||
<div class="prepare-speakers">
|
||||
<h2>Speaker settings</h2>
|
||||
<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 roster.items() %}
|
||||
{% set spoken_name = speaker.pronunciation or speaker.label %}
|
||||
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
|
||||
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
|
||||
{% set seen = namespace(values=[]) %}
|
||||
{% set sample_quotes = speaker.sample_quotes or [] %}
|
||||
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
|
||||
{% 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 }}">
|
||||
<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-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
aria-label="Preview pronunciation for {{ speaker.label }}"
|
||||
title="Preview pronunciation">
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
|
||||
<div class="speaker-list__meta">
|
||||
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
|
||||
<button type="button"
|
||||
class="chip speaker-gender__pill"
|
||||
data-role="gender-pill"
|
||||
data-current="{{ current_gender }}">
|
||||
{{ gender_label }} voice
|
||||
</button>
|
||||
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
|
||||
<p class="hint">Detected: {{ detected_label }}</p>
|
||||
<div class="speaker-gender__options">
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
|
||||
Use detected ({{ detected_label }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
|
||||
</div>
|
||||
{% if speaker.get('analysis_count') %}
|
||||
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text"
|
||||
id="speaker-{{ speaker_id }}-pronunciation"
|
||||
name="speaker-{{ speaker_id }}-pronunciation"
|
||||
value="{{ speaker.pronunciation or '' }}"
|
||||
placeholder="{{ speaker.label }}">
|
||||
</label>
|
||||
<div class="speaker-list__controls">
|
||||
<div class="speaker-list__selection">
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
|
||||
<span>Assigned voice</span>
|
||||
<select id="speaker-{{ speaker_id }}-voice"
|
||||
name="speaker-{{ speaker_id }}-voice"
|
||||
data-role="speaker-voice"
|
||||
data-default-voice="{{ pending.voice }}">
|
||||
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
|
||||
Custom mix
|
||||
</option>
|
||||
{% if speaker.recommended_voices %}
|
||||
<optgroup label="Recommended">
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% if voice_id not in seen.values %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
|
||||
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
|
||||
</option>
|
||||
{% set _ = seen.values.append(voice_id) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="All voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
{% if voice.id not in seen.values %}
|
||||
<option value="{{ voice.id }}"
|
||||
{% if selected_voice == voice.id %}selected{% endif %}>
|
||||
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Browse voices
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="generate-voice"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Generate voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-kind="generated"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ preview_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
{% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
Preview generated
|
||||
</button>
|
||||
</div>
|
||||
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
<span class="tag">Custom mix</span>
|
||||
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
||||
<div class="speaker-list__mix-actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-context="mix"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ preview_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview mix
|
||||
</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
||||
</div>
|
||||
<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 %}
|
||||
<div class="speaker-sample__actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ quote|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview with assigned voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-sample-index="{{ loop.index0 }}">
|
||||
Preview in voice browser
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% if speaker.recommended_voices %}
|
||||
<div class="speaker-list__recommendations">
|
||||
<span class="hint">Suggested:</span>
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<button type="button"
|
||||
class="chip"
|
||||
data-role="recommended-voice"
|
||||
data-voice="{{ voice_id }}"
|
||||
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
|
||||
{{ voice_meta.display_name or voice_id }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="prepare-actions">
|
||||
<button type="button" class="button button--ghost" data-role="step-prev" data-step-target="chapters">Back to upload & settings</button>
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
<div class="modal" data-role="voice-modal" hidden>
|
||||
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
|
||||
<header class="voice-browser__header">
|
||||
<h2 id="voice-modal-title">Choose a voice</h2>
|
||||
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button>
|
||||
</header>
|
||||
<div class="voice-browser__body">
|
||||
<aside class="voice-browser__filters">
|
||||
<label class="field">
|
||||
<span>Search</span>
|
||||
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Language</span>
|
||||
<select data-role="voice-modal-language">
|
||||
<option value="">All languages</option>
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
|
||||
</aside>
|
||||
<section class="voice-browser__catalog" aria-label="Voice catalog">
|
||||
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
|
||||
</section>
|
||||
<section class="voice-browser__mix" data-role="voice-modal-mix">
|
||||
<header class="voice-browser__mix-header">
|
||||
<h3>Selected voices</h3>
|
||||
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
|
||||
</header>
|
||||
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
|
||||
<section class="voice-browser__preview" data-role="voice-modal-preview">
|
||||
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
|
||||
<p class="tag" data-role="voice-modal-selected-meta"></p>
|
||||
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
|
||||
<div class="voice-browser__actions">
|
||||
<button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
{% endblock %}
|
||||
{# This template is intentionally left empty.
|
||||
Step-specific templates now live in `prepare_chapters.html` and `prepare_speakers.html`.
|
||||
The file is kept as a placeholder to avoid breaking documentation references. #}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
{% set analysis = pending.speaker_analysis or {} %}
|
||||
{% set analysis_speakers = analysis.get('speakers', {}) %}
|
||||
{% set speaker_configs = options.speaker_configs or [] %}
|
||||
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
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">
|
||||
|
||||
<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 %}
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
{% if chapter.enabled %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
|
||||
{% endif %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
|
||||
{% if chapter.voice_formula %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% elif pending.speaker_mode != 'multi' %}
|
||||
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="prepare-speaker-config">
|
||||
<div class="prepare-speaker-config__header">
|
||||
<h2>Speaker configuration</h2>
|
||||
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
|
||||
</div>
|
||||
<div class="prepare-speaker-config__grid">
|
||||
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
|
||||
<span>Saved preset</span>
|
||||
<select id="applied_speaker_config" name="applied_speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
|
||||
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
|
||||
</label>
|
||||
<div class="prepare-speaker-config__actions">
|
||||
<button type="submit"
|
||||
class="button button--ghost"
|
||||
name="apply_speaker_config"
|
||||
data-role="submit-speaker-analysis"
|
||||
value="1"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if not speaker_configs %}disabled{% endif %}>
|
||||
Apply preset
|
||||
</button>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_speaker_config" value="1">
|
||||
<span>Save roster updates back to preset</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if pending.speakers %}
|
||||
<div class="prepare-speakers">
|
||||
<h2>Speaker settings</h2>
|
||||
<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 selected_voice = speaker.resolved_voice or speaker.voice %}
|
||||
{% set seen = namespace(values=[]) %}
|
||||
{% set sample_quotes = speaker.sample_quotes or [] %}
|
||||
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
|
||||
{% 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 }}">
|
||||
<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-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
aria-label="Preview pronunciation for {{ speaker.label }}"
|
||||
title="Preview pronunciation">
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
|
||||
<div class="speaker-list__meta">
|
||||
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
|
||||
<button type="button"
|
||||
class="chip speaker-gender__pill"
|
||||
data-role="gender-pill"
|
||||
data-current="{{ current_gender }}">
|
||||
{{ gender_label }} voice
|
||||
</button>
|
||||
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
|
||||
<p class="hint">Detected: {{ detected_label }}</p>
|
||||
<div class="speaker-gender__options">
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
|
||||
Use detected ({{ detected_label }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
|
||||
</div>
|
||||
{% if speaker.get('analysis_count') %}
|
||||
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text"
|
||||
id="speaker-{{ speaker_id }}-pronunciation"
|
||||
name="speaker-{{ speaker_id }}-pronunciation"
|
||||
value="{{ speaker.pronunciation or '' }}"
|
||||
placeholder="{{ speaker.label }}">
|
||||
</label>
|
||||
<div class="speaker-list__controls">
|
||||
<div class="speaker-list__selection">
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
|
||||
<span>Assigned voice</span>
|
||||
<select id="speaker-{{ speaker_id }}-voice"
|
||||
name="speaker-{{ speaker_id }}-voice"
|
||||
data-role="speaker-voice"
|
||||
data-default-voice="{{ pending.voice }}">
|
||||
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
|
||||
Custom mix
|
||||
</option>
|
||||
{% if speaker.recommended_voices %}
|
||||
<optgroup label="Recommended">
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% if voice_id not in seen.values %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
|
||||
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
|
||||
</option>
|
||||
{% set _ = seen.values.append(voice_id) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="All voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
{% if voice.id not in seen.values %}
|
||||
<option value="{{ voice.id }}"
|
||||
{% if selected_voice == voice.id %}selected{% endif %}>
|
||||
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Browse voices
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="generate-voice"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Generate voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-kind="generated"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ preview_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
{% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
Preview generated
|
||||
</button>
|
||||
</div>
|
||||
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
<span class="tag">Custom mix</span>
|
||||
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
||||
<div class="speaker-list__mix-actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-context="mix"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ preview_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview mix
|
||||
</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
||||
</div>
|
||||
<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 %}
|
||||
<div class="speaker-sample__actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ quote|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview with assigned voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-sample-index="{{ loop.index0 }}">
|
||||
Preview in voice browser
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% if speaker.recommended_voices %}
|
||||
<div class="speaker-list__recommendations">
|
||||
<span class="hint">Suggested:</span>
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<button type="button"
|
||||
class="chip"
|
||||
data-role="recommended-voice"
|
||||
data-voice="{{ voice_id }}"
|
||||
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
|
||||
{{ voice_meta.display_name or voice_id }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
|
||||
<div class="modal" data-role="voice-modal" hidden>
|
||||
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
|
||||
<header class="voice-browser__header">
|
||||
<h2 id="voice-modal-title">Choose a voice</h2>
|
||||
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button>
|
||||
</header>
|
||||
<div class="voice-browser__body">
|
||||
<aside class="voice-browser__filters">
|
||||
<label class="field">
|
||||
<span>Search</span>
|
||||
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Language</span>
|
||||
<select data-role="voice-modal-language">
|
||||
<option value="">All languages</option>
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
|
||||
</aside>
|
||||
<section class="voice-browser__catalog" aria-label="Voice catalog">
|
||||
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
|
||||
</section>
|
||||
<section class="voice-browser__mix" data-role="voice-modal-mix">
|
||||
<header class="voice-browser__mix-header">
|
||||
<h3>Selected voices</h3>
|
||||
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
|
||||
</header>
|
||||
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
|
||||
<section class="voice-browser__preview" data-role="voice-modal-preview">
|
||||
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
|
||||
<p class="tag" data-role="voice-modal-selected-meta"></p>
|
||||
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
|
||||
<div class="voice-browser__actions">
|
||||
<button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user