Enhance audiobook workflow UI with modal and styling updates

- Updated styles.css to introduce new modal and card styles for improved layout and responsiveness.
- Modified index.html to implement a modal for uploading files and settings, enhancing user experience.
- Refactored prepare_job.html to support a wizard-like interface for preparing jobs, including step indicators and dynamic content updates.
- Added functionality for gender selection and voice mixing in the speaker preparation section.
- Improved accessibility and usability with better hints and instructions throughout the forms.
This commit is contained in:
JB
2025-10-08 15:15:10 -07:00
parent 881717c4cb
commit b0875c7486
7 changed files with 1140 additions and 333 deletions
+57 -12
View File
@@ -78,29 +78,41 @@ class SpeakerGuess:
label: str
count: int = 0
confidence: str = "low"
sample_quotes: List[str] = field(default_factory=list)
sample_quotes: List[Dict[str, str]] = field(default_factory=list)
suppressed: bool = False
gender: str = "unknown"
detected_gender: str = "unknown"
male_votes: int = 0
female_votes: int = 0
def register_occurrence(self, confidence: str, quote: Optional[str]) -> None:
def register_occurrence(
self,
confidence: str,
text: str,
quote: Optional[str],
male_votes: int,
female_votes: int,
) -> None:
self.count += 1
if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0):
self.confidence = confidence
if quote:
normalized = quote.strip()
if normalized and normalized not in self.sample_quotes:
self.sample_quotes.append(normalized[:240])
excerpt = _build_excerpt(text, quote)
gender_hint = _format_gender_hint(male_votes, female_votes)
if excerpt:
payload = {"excerpt": excerpt, "gender_hint": gender_hint}
if payload not in self.sample_quotes:
self.sample_quotes.append(payload)
if len(self.sample_quotes) > 3:
self.sample_quotes = self.sample_quotes[:3]
def register_gender(self, male_votes: int, female_votes: int) -> None:
if male_votes:
self.male_votes += male_votes
if female_votes:
self.female_votes += female_votes
self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender)
self.detected_gender = _derive_gender(self.male_votes, self.female_votes, self.detected_gender)
if self.gender in {"unknown", "male", "female"}:
self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender)
def as_dict(self) -> Dict[str, Any]:
return {
@@ -108,9 +120,10 @@ class SpeakerGuess:
"label": self.label,
"count": self.count,
"confidence": self.confidence,
"sample_quotes": list(self.sample_quotes),
"sample_quotes": [dict(sample) for sample in self.sample_quotes],
"suppressed": self.suppressed,
"gender": self.gender,
"detected_gender": self.detected_gender,
}
@@ -188,9 +201,7 @@ def analyze_speakers(
label_index[label] = record_id
speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label)
guess = speaker_guesses[record_id]
guess.register_occurrence(confidence, quote)
if record_id != narrator_id and (male_votes or female_votes):
guess.register_gender(male_votes, female_votes)
guess.register_occurrence(confidence, text, quote, male_votes, female_votes)
if record_id != speaker_id:
# Maintain mapping to canonical ID in assignments.
assignments[chunk_id] = record_id
@@ -400,6 +411,40 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
return "unknown"
def _build_excerpt(text: str, quote: Optional[str]) -> str:
normalized = (text or "").strip()
if not normalized:
return ""
if quote:
location = normalized.find(quote)
if location != -1:
start = max(0, location - 120)
end = min(len(normalized), location + len(quote) + 120)
snippet = normalized[start:end].strip()
if start > 0:
snippet = "" + snippet
if end < len(normalized):
snippet = snippet + ""
return snippet
if len(normalized) > 240:
return normalized[:240].rstrip() + ""
return normalized
def _format_gender_hint(male_votes: int, female_votes: int) -> str:
if male_votes and female_votes:
return "Context mentions both male and female pronouns."
if male_votes:
if male_votes >= 3:
return "Multiple male pronouns detected nearby."
return "Some male pronouns detected in the surrounding text."
if female_votes:
if female_votes >= 3:
return "Multiple female pronouns detected nearby."
return "Some female pronouns detected in the surrounding text."
return "No clear pronoun signal detected."
def _normalize_candidate_name(raw: str) -> Optional[str]:
if not raw:
return None
+27 -6
View File
@@ -1965,7 +1965,7 @@ def prepare_job(pending_id: str) -> str:
if not pending:
abort(404)
pending = cast(PendingJob, pending)
return _render_prepare_page(pending)
return _render_prepare_page(pending, active_step="chapters")
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
@@ -1989,7 +1989,7 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
) = _apply_prepare_form(pending, request.form)
if errors:
return _render_prepare_page(pending, error=" ".join(errors))
return _render_prepare_page(pending, error=" ".join(errors), active_step="chapters")
if pending.speaker_mode != "multi":
setattr(pending, "analysis_requested", False)
@@ -1998,13 +1998,18 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
return _render_prepare_page(
pending,
error="Switch to multi-speaker mode to analyze speakers.",
active_step="chapters",
)
if not enabled_overrides:
setattr(pending, "analysis_requested", False)
pending.chunks = []
pending.speaker_analysis = {}
return _render_prepare_page(pending, error="Select at least one chapter to analyze.")
return _render_prepare_page(
pending,
error="Select at least one chapter to analyze.",
active_step="chapters",
)
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
@@ -2054,7 +2059,7 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
notice_message = "Speaker analysis updated."
if persist_config_requested and config_name:
notice_message = "Speaker analysis updated and configuration saved."
return _render_prepare_page(pending, notice=notice_message)
return _render_prepare_page(pending, notice=notice_message, active_step="speakers")
@web_bp.post("/jobs/prepare/<pending_id>")
@@ -2078,14 +2083,22 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
) = _apply_prepare_form(pending, request.form)
if errors:
return _render_prepare_page(pending, error=" ".join(errors))
return _render_prepare_page(
pending,
error=" ".join(errors),
active_step=request.form.get("active_step") or "speakers",
)
if pending.speaker_mode != "multi":
setattr(pending, "analysis_requested", False)
if not enabled_overrides:
pending.chunks = []
return _render_prepare_page(pending, error="Select at least one chapter to convert.")
return _render_prepare_page(
pending,
error="Select at least one chapter to convert.",
active_step="chapters",
)
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
@@ -2217,7 +2230,14 @@ def _render_prepare_page(
*,
error: Optional[str] = None,
notice: Optional[str] = None,
active_step: Optional[str] = None,
) -> str:
if not active_step:
active_step = (
request.form.get("active_step")
if request.method == "POST"
else request.args.get("step")
) or "chapters"
return render_template(
"prepare_job.html",
pending=pending,
@@ -2225,6 +2245,7 @@ def _render_prepare_page(
settings=_load_settings(),
error=error,
notice=notice,
active_step=active_step,
)
+64 -11
View File
@@ -1,16 +1,69 @@
const initDashboard = () => {
const profileSelect = document.querySelector('[data-role="voice-profile"]');
const voiceField = document.querySelector('[data-role="voice-field"]');
const voiceSelect = document.querySelector('[data-role="voice-select"]');
const formulaField = document.querySelector('[data-role="formula-field"]');
const formulaInput = document.querySelector('[data-role="voice-formula"]');
const languageSelect = document.getElementById("language");
const uploadModal = document.querySelector('[data-role="upload-modal"]');
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
const scope = uploadModal || document;
const sourceText = document.querySelector('[data-role="source-text"]');
const previewEl = document.querySelector('[data-role="text-preview"]');
const previewBody = document.querySelector('[data-role="preview-body"]');
const charCountEl = document.querySelector('[data-role="char-count"]');
const wordCountEl = document.querySelector('[data-role="word-count"]');
const profileSelect = scope.querySelector('[data-role="voice-profile"]');
const voiceField = scope.querySelector('[data-role="voice-field"]');
const voiceSelect = scope.querySelector('[data-role="voice-select"]');
const formulaField = scope.querySelector('[data-role="formula-field"]');
const formulaInput = scope.querySelector('[data-role="voice-formula"]');
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
const sourceText = scope.querySelector('[data-role="source-text"]');
const previewEl = scope.querySelector('[data-role="text-preview"]');
const previewBody = scope.querySelector('[data-role="preview-body"]');
const charCountEl = scope.querySelector('[data-role="char-count"]');
const wordCountEl = scope.querySelector('[data-role="word-count"]');
let lastTrigger = null;
const openUploadModal = (trigger) => {
if (!uploadModal) return;
lastTrigger = trigger || null;
uploadModal.hidden = false;
uploadModal.dataset.open = "true";
document.body.classList.add("modal-open");
const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal;
if (focusTarget instanceof HTMLElement) {
focusTarget.focus({ preventScroll: true });
}
};
const closeUploadModal = () => {
if (!uploadModal || uploadModal.hidden) {
return;
}
uploadModal.hidden = true;
delete uploadModal.dataset.open;
document.body.classList.remove("modal-open");
if (lastTrigger && lastTrigger instanceof HTMLElement) {
lastTrigger.focus({ preventScroll: true });
}
};
openModalButtons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
openUploadModal(button);
});
});
if (uploadModal) {
uploadModal.addEventListener("click", (event) => {
const target = event.target;
if (target instanceof Element && target.closest('[data-role="upload-modal-close"]')) {
event.preventDefault();
closeUploadModal();
}
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && uploadModal && !uploadModal.hidden) {
closeUploadModal();
}
});
const hydrateDefaultVoice = () => {
if (!voiceSelect) return;
+495 -79
View File
@@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", () => {
const updateRowState = (row) => {
const enabled = row.querySelector('[data-role=chapter-enabled]');
const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea"));
const warning = row.querySelector('[data-role=chapter-warning]');
const isChecked = enabled ? enabled.checked : true;
row.dataset.disabled = isChecked ? "false" : "true";
@@ -46,6 +47,11 @@ document.addEventListener("DOMContentLoaded", () => {
const select = row.querySelector("select[data-role=voice-select]");
toggleFormula(select);
if (warning) {
warning.hidden = isChecked;
warning.setAttribute("aria-hidden", isChecked ? "true" : "false");
}
};
const toggleFormula = (select) => {
@@ -78,26 +84,28 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
const analyzeButton = form.querySelector('[data-role="analyze-button"]');
const speakerModeSelect = form.querySelector("#speaker_mode");
const updateAnalyzeVisibility = () => {
if (!analyzeButton || !speakerModeSelect) return;
const isMulti = speakerModeSelect.value === "multi";
analyzeButton.hidden = !isMulti;
analyzeButton.setAttribute("aria-hidden", isMulti ? "false" : "true");
analyzeButton.disabled = !isMulti;
};
if (analyzeButton && speakerModeSelect) {
speakerModeSelect.addEventListener("change", updateAnalyzeVisibility);
updateAnalyzeVisibility();
}
const updatePreviewVoice = (select) => {
const container = select.closest(".speaker-list__item");
if (!container) return;
const previewButton = container.querySelector('[data-role="speaker-preview"]');
if (!previewButton) return;
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
const formulaValue = formulaInput?.value?.trim();
if (formulaValue) {
previewButton.dataset.voice = formulaValue;
if (mixContainer) {
mixContainer.hidden = false;
}
if (mixLabel) {
mixLabel.textContent = formulaValue;
}
return;
}
if (mixContainer) {
mixContainer.hidden = true;
}
const defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || "";
const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice);
previewButton.dataset.voice = currentVoice || defaultVoice;
@@ -112,6 +120,14 @@ document.addEventListener("DOMContentLoaded", () => {
if (!select.dataset.prevManual) {
select.dataset.prevManual = select.value;
}
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
if (formulaInput) {
formulaInput.value = "";
}
if (mixContainer) {
mixContainer.hidden = true;
}
select.dataset.suppressRandomize = "1";
select.disabled = true;
select.value = "";
@@ -139,10 +155,21 @@ document.addEventListener("DOMContentLoaded", () => {
handleRandomizeToggle(randomToggle);
}
}
if (container && !target.dataset.suppressFormulaClear) {
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
if (formulaInput) {
formulaInput.value = "";
}
if (mixContainer) {
mixContainer.hidden = true;
}
}
if (!target.dataset.suppressRandomize) {
target.dataset.prevManual = target.value || "";
}
updatePreviewVoice(target);
delete target.dataset.suppressFormulaClear;
});
updatePreviewVoice(select);
});
@@ -153,6 +180,9 @@ document.addEventListener("DOMContentLoaded", () => {
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox));
});
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"];
@@ -163,17 +193,22 @@ document.addEventListener("DOMContentLoaded", () => {
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";
stepOrder.forEach((step) => {
const panel = wizard.querySelector(`[data-step-panel="${step}"]`);
if (panel) {
panels.set(step, panel);
panel.hidden = step !== "chapters";
panel.setAttribute("aria-hidden", step === "chapters" ? "false" : "true");
const isInitial = step === initialStep;
panel.hidden = !isInitial;
panel.setAttribute("aria-hidden", isInitial ? "false" : "true");
}
});
const unlockedSteps = new Set(["chapters"]);
let currentStep = "chapters";
if (initialStep === "speakers") {
unlockedSteps.add("speakers");
}
let currentStep = initialStep;
const updateIndicator = (activeStep) => {
const activeIndex = indicatorOrder.indexOf(activeStep);
@@ -210,6 +245,9 @@ document.addEventListener("DOMContentLoaded", () => {
}
currentStep = step;
wizard.dataset.activeStep = step;
if (activeStepInput) {
activeStepInput.value = step;
}
panels.forEach((panel, key) => {
const isActive = key === step;
panel.hidden = !isActive;
@@ -231,6 +269,30 @@ document.addEventListener("DOMContentLoaded", () => {
updateIndicator(step);
};
const submitForAnalysis = () => {
if (!analyzeUrl) {
unlockStep("speakers");
setStep("speakers");
return;
}
if (!form.reportValidity()) {
return;
}
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) {
@@ -246,6 +308,10 @@ document.addEventListener("DOMContentLoaded", () => {
nextButtons.forEach((button) => {
button.addEventListener("click", () => {
const target = button.dataset.stepTarget || "speakers";
if (target === "speakers") {
submitForAnalysis();
return;
}
unlockStep(target);
setStep(target);
});
@@ -264,11 +330,183 @@ document.addEventListener("DOMContentLoaded", () => {
const voiceModal = document.querySelector('[data-role="voice-modal"]');
let activeGenderFilter = "";
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const parseFormula = (formula) => {
const mix = new Map();
if (!formula) return mix;
const parts = formula.split("+");
parts.forEach((part) => {
const segment = part.trim();
if (!segment) return;
const pieces = segment.split("*");
const voiceId = pieces[0].trim();
if (!voiceId) return;
let weight = 1;
if (pieces[1]) {
const parsed = Number.parseFloat(pieces[1].trim());
if (!Number.isNaN(parsed) && parsed > 0) {
weight = parsed;
}
}
mix.set(voiceId, clamp(weight, 0.05, 1));
});
return mix;
};
const normaliseMix = (mix) => {
const entries = Array.from(mix.entries());
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (!total) return mix;
entries.forEach(([voiceId, weight]) => {
mix.set(voiceId, weight / total);
});
return mix;
};
const formatMix = (mix) => {
const entries = Array.from(mix.entries());
if (!entries.length) return "";
let total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (total < 0.5) {
const scale = 0.5 / total;
entries.forEach(([voiceId, weight]) => {
mix.set(voiceId, clamp(weight * scale, 0.05, 1));
});
total = entries.reduce((sum, [, weight]) => sum + weight, 0);
}
return entries
.map(([voiceId, weight]) => `${voiceId}*${(weight / total).toFixed(2)}`)
.join("+");
};
const genderLabel = (value) => {
switch ((value || "unknown").toLowerCase()) {
case "male":
return "Male";
case "female":
return "Female";
case "either":
return "Either";
default:
return "Unknown";
}
};
const buildRandomMix = (gender, countOverride) => {
const genderCode = (gender || "unknown").toLowerCase();
const pool = voiceCatalog.filter((voice) => {
const code = (voice.gender_code || "").toLowerCase();
if (genderCode === "female") return code === "f";
if (genderCode === "male") return code === "m";
if (genderCode === "either") return code === "f" || code === "m";
return true;
});
if (!pool.length) {
return null;
}
const voices = [...pool];
for (let i = voices.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[voices[i], voices[j]] = [voices[j], voices[i]];
}
const count = clamp(countOverride || Math.floor(Math.random() * 4) + 1, 1, 4);
const selected = voices.slice(0, count);
const mix = new Map();
const rawWeights = selected.map(() => Math.random() + 0.2);
const total = rawWeights.reduce((sum, weight) => sum + weight, 0);
selected.forEach((voice, index) => {
mix.set(voice.id, rawWeights[index] / total);
});
return mix;
};
const applyFormulaToSpeaker = (speakerItem, formula) => {
if (!speakerItem) return;
const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]');
const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]');
const previewButton = speakerItem.querySelector('[data-role="speaker-preview"]');
const randomToggle = speakerItem.querySelector('[data-role="randomize-toggle"]');
if (randomToggle && randomToggle.checked) {
randomToggle.checked = false;
handleRandomizeToggle(randomToggle);
}
if (formulaInput) {
formulaInput.value = formula || "";
}
if (mixLabel) {
mixLabel.textContent = formula || "";
}
if (mixContainer) {
mixContainer.hidden = !formula;
}
if (select) {
select.disabled = false;
select.dataset.suppressRandomize = "1";
select.dataset.suppressFormulaClear = "1";
if (formula) {
select.value = "";
} else if (!select.value && select.dataset.prevManual) {
select.value = select.dataset.prevManual;
}
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
}
if (previewButton) {
if (formula) {
previewButton.dataset.voice = formula;
} else {
const defaultVoice = select?.dataset.defaultVoice || previewButton.dataset.voice || "";
previewButton.dataset.voice = select?.value || defaultVoice;
}
}
};
const hideGenderMenus = () => {
form.querySelectorAll('[data-role="gender-menu"]').forEach((menu) => {
menu.hidden = true;
menu.setAttribute("aria-hidden", "true");
});
form.querySelectorAll('[data-role="gender-pill"]').forEach((pill) => {
pill.classList.remove("is-open");
});
};
const setGenderForSpeaker = (genderContainer, value) => {
if (!genderContainer) return;
const normalized = value || "unknown";
const input = genderContainer.querySelector('[data-role="gender-input"]');
if (input) {
input.value = normalized;
}
const pill = genderContainer.querySelector('[data-role="gender-pill"]');
if (pill) {
pill.dataset.current = normalized;
pill.textContent = `${genderLabel(normalized)} voice`;
}
const options = genderContainer.querySelectorAll('[data-role="gender-option"]');
options.forEach((option) => {
if ((option.dataset.value || "unknown") === normalized) {
option.dataset.state = "active";
} else {
option.removeAttribute("data-state");
}
});
};
Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => {
const input = container.querySelector('[data-role="gender-input"]');
setGenderForSpeaker(container, input?.value || "unknown");
});
const modalState = {
speakerItem: null,
samples: [],
recommended: new Set(),
selectedVoice: "",
mix: new Map(),
highlighted: "",
defaultVoice: "",
previewSettings: { language: "a", speed: "1", useGpu: "true" },
};
@@ -277,11 +515,14 @@ document.addEventListener("DOMContentLoaded", () => {
modalState.speakerItem = null;
modalState.samples = [];
modalState.recommended = new Set();
modalState.selectedVoice = "";
modalState.mix = new Map();
modalState.highlighted = "";
modalState.defaultVoice = "";
modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" };
};
const getMixFormula = () => formatMix(normaliseMix(new Map(modalState.mix)));
const renderVoiceList = (elements) => {
if (!elements) return;
const { list, searchInput, languageSelect } = elements;
@@ -289,26 +530,28 @@ document.addEventListener("DOMContentLoaded", () => {
list.innerHTML = "";
const term = (searchInput?.value || "").trim().toLowerCase();
const languageFilter = languageSelect?.value || "";
const filtered = voiceCatalog.filter((voice) => {
if (languageFilter && voice.language !== languageFilter) return false;
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
if (term) {
const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]]
.filter(Boolean)
.map((value) => value.toLowerCase());
if (!haystacks.some((value) => value.includes(term))) {
return false;
const filtered = voiceCatalog
.filter((voice) => {
if (languageFilter && voice.language !== languageFilter) return false;
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
if (term) {
const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]]
.filter(Boolean)
.map((value) => value.toLowerCase());
if (!haystacks.some((value) => value.includes(term))) {
return false;
}
}
}
return true;
}).sort((a, b) => {
const aRecommended = modalState.recommended.has(a.id) ? 0 : 1;
const bRecommended = modalState.recommended.has(b.id) ? 0 : 1;
if (aRecommended !== bRecommended) {
return aRecommended - bRecommended;
}
return a.display_name.localeCompare(b.display_name);
});
return true;
})
.sort((a, b) => {
const aRecommended = modalState.recommended.has(a.id) ? 0 : 1;
const bRecommended = modalState.recommended.has(b.id) ? 0 : 1;
if (aRecommended !== bRecommended) {
return aRecommended - bRecommended;
}
return a.display_name.localeCompare(b.display_name);
});
if (!filtered.length) {
const emptyItem = document.createElement("li");
@@ -325,7 +568,10 @@ document.addEventListener("DOMContentLoaded", () => {
button.className = "voice-browser__entry";
button.dataset.role = "voice-modal-item";
button.dataset.voiceId = voice.id;
if (modalState.selectedVoice === voice.id) {
if (modalState.mix.has(voice.id)) {
button.dataset.inMix = "true";
}
if (modalState.highlighted === voice.id) {
button.setAttribute("aria-current", "true");
}
if (modalState.recommended.has(voice.id)) {
@@ -344,20 +590,91 @@ document.addEventListener("DOMContentLoaded", () => {
});
};
const renderMix = (elements) => {
const { mixList, mixTotal } = elements;
if (!mixList) return;
mixList.innerHTML = "";
const entries = Array.from(normaliseMix(new Map(modalState.mix)).entries());
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (mixTotal) {
mixTotal.textContent = `Total weight: ${total.toFixed(2)}`;
}
if (!entries.length) {
const empty = document.createElement("p");
empty.className = "voice-browser__empty";
empty.textContent = "Add voices from the list to build a blend.";
mixList.appendChild(empty);
return;
}
entries.forEach(([voiceId, weight]) => {
const wrapper = document.createElement("div");
wrapper.className = "voice-browser__mix-item";
wrapper.dataset.voiceId = voiceId;
const header = document.createElement("div");
header.className = "voice-browser__mix-header";
const voiceMeta = voiceCatalogMap.get(voiceId) || {};
const title = document.createElement("span");
title.className = "voice-browser__mix-name";
title.textContent = voiceMeta.display_name || voiceId;
const weightLabel = document.createElement("span");
weightLabel.className = "voice-browser__mix-weight";
weightLabel.textContent = weight.toFixed(2);
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "voice-browser__mix-remove";
removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from blend`);
removeBtn.textContent = "✕";
removeBtn.addEventListener("click", () => {
modalState.mix.delete(voiceId);
if (modalState.highlighted === voiceId) {
modalState.highlighted = "";
}
renderMix(elements);
renderVoiceList(elements);
updateModalMeta(elements);
updateApplyState(elements);
});
header.appendChild(title);
header.appendChild(weightLabel);
header.appendChild(removeBtn);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "5";
slider.max = "100";
slider.step = "1";
slider.value = String(Math.round(weight * 100));
slider.addEventListener("input", () => {
const value = clamp(Number.parseInt(slider.value, 10) / 100, 0.05, 1);
modalState.mix.set(voiceId, value);
modalState.highlighted = voiceId;
renderMix(elements);
updateModalMeta(elements);
updateApplyState(elements);
});
wrapper.appendChild(header);
wrapper.appendChild(slider);
mixList.appendChild(wrapper);
});
};
const renderSamples = (elements) => {
if (!elements) return;
const { container } = elements;
if (!container) return;
container.innerHTML = "";
const { samplesContainer } = elements;
if (!samplesContainer) return;
samplesContainer.innerHTML = "";
if (!modalState.samples.length) {
const empty = document.createElement("p");
empty.className = "hint";
empty.textContent = "No sample paragraphs available yet.";
container.appendChild(empty);
samplesContainer.appendChild(empty);
return;
}
const formula = getMixFormula();
modalState.samples.forEach((text, index) => {
const sample = document.createElement("article");
sample.className = "voice-browser__sample";
@@ -379,28 +696,32 @@ document.addEventListener("DOMContentLoaded", () => {
previewButton.dataset.language = modalState.previewSettings.language;
previewButton.dataset.speed = modalState.previewSettings.speed;
previewButton.dataset.useGpu = modalState.previewSettings.useGpu;
previewButton.dataset.voice = modalState.selectedVoice || modalState.defaultVoice || "";
previewButton.dataset.voice = formula || modalState.defaultVoice || "";
previewButton.textContent = "Preview sample";
actions.appendChild(previewButton);
sample.appendChild(paragraph);
sample.appendChild(actions);
container.appendChild(sample);
samplesContainer.appendChild(sample);
});
};
const updateModalVoiceMeta = (elements) => {
const updateModalMeta = (elements) => {
if (!elements) return;
const { nameLabel, metaLabel } = elements;
if (!nameLabel || !metaLabel) return;
if (!modalState.selectedVoice) {
nameLabel.textContent = "Select a voice to preview";
if (!modalState.mix.size) {
nameLabel.textContent = "Select voices to build a blend";
metaLabel.textContent = "";
return;
}
const voice = voiceCatalogMap.get(modalState.selectedVoice);
const highlight = modalState.highlighted && modalState.mix.has(modalState.highlighted)
? modalState.highlighted
: Array.from(modalState.mix.keys())[0];
modalState.highlighted = highlight;
const voice = voiceCatalogMap.get(highlight);
if (!voice) {
nameLabel.textContent = modalState.selectedVoice;
nameLabel.textContent = highlight;
metaLabel.textContent = "";
return;
}
@@ -408,13 +729,19 @@ document.addEventListener("DOMContentLoaded", () => {
metaLabel.textContent = `${voice.language_label} · ${voice.gender}`;
};
const updateApplyState = (elements) => {
const { applyButton } = elements || {};
if (!applyButton) return;
const formula = getMixFormula();
applyButton.disabled = !formula;
};
const refreshModal = (elements) => {
renderVoiceList(elements);
renderMix(elements);
renderSamples(elements);
updateModalVoiceMeta(elements);
if (elements?.applyButton) {
elements.applyButton.disabled = !modalState.selectedVoice;
}
updateModalMeta(elements);
updateApplyState(elements);
};
const openVoiceBrowser = (speakerItem, sampleIndex = 0) => {
@@ -422,8 +749,13 @@ document.addEventListener("DOMContentLoaded", () => {
modalState.speakerItem = speakerItem;
const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]');
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || "";
modalState.selectedVoice = select && !select.disabled ? (select.value || modalState.defaultVoice) : modalState.defaultVoice;
modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map();
if (!modalState.mix.size && select && select.value) {
modalState.mix.set(select.value, 1);
}
modalState.mix = normaliseMix(modalState.mix);
modalState.previewSettings = {
language: previewTrigger?.dataset.language || "a",
speed: previewTrigger?.dataset.speed || "1",
@@ -437,7 +769,9 @@ document.addEventListener("DOMContentLoaded", () => {
const raw = samplesTemplate.innerHTML || "[]";
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
samples = parsed.filter((value) => typeof value === "string" && value.trim().length);
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);
@@ -462,7 +796,9 @@ document.addEventListener("DOMContentLoaded", () => {
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')),
container: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'),
mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'),
samplesContainer: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
@@ -498,10 +834,14 @@ document.addEventListener("DOMContentLoaded", () => {
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')),
container: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'),
mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'),
samplesContainer: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
randomButton: voiceModal.querySelector('[data-role="voice-modal-random"]'),
clearButton: voiceModal.querySelector('[data-role="voice-modal-clear"]'),
};
if (elements.searchInput) {
@@ -522,29 +862,44 @@ document.addEventListener("DOMContentLoaded", () => {
const target = event.target.closest('[data-role="voice-modal-item"]');
if (!target) return;
event.preventDefault();
modalState.selectedVoice = target.dataset.voiceId || "";
const voiceId = target.dataset.voiceId;
if (!voiceId) return;
if (!modalState.mix.has(voiceId)) {
modalState.mix.set(voiceId, 0.5);
}
modalState.highlighted = voiceId;
renderMix(elements);
renderVoiceList(elements);
updateModalMeta(elements);
updateApplyState(elements);
});
}
if (elements.randomButton) {
elements.randomButton.addEventListener("click", () => {
const genderInput = modalState.speakerItem?.querySelector('[data-role="gender-input"]');
const gender = genderInput?.value || "unknown";
const mix = buildRandomMix(gender);
if (mix) {
modalState.mix = mix;
modalState.highlighted = Array.from(mix.keys())[0];
refreshModal(elements);
}
});
}
if (elements.clearButton) {
elements.clearButton.addEventListener("click", () => {
modalState.mix.clear();
modalState.highlighted = "";
refreshModal(elements);
});
}
if (elements.applyButton) {
elements.applyButton.addEventListener("click", (event) => {
event.preventDefault();
if (!modalState.speakerItem || !modalState.selectedVoice) {
return;
}
const select = modalState.speakerItem.querySelector('[data-role="speaker-voice"]');
if (!select) return;
const randomToggle = modalState.speakerItem.querySelector('[data-role="randomize-toggle"]');
if (randomToggle && randomToggle.checked) {
randomToggle.checked = false;
handleRandomizeToggle(randomToggle);
}
select.disabled = false;
select.dataset.suppressRandomize = "1";
select.value = modalState.selectedVoice;
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
select.dataset.prevManual = modalState.selectedVoice;
if (!modalState.speakerItem) return;
const formula = getMixFormula();
if (!formula) return;
applyFormulaToSpeaker(modalState.speakerItem, formula);
closeVoiceBrowser();
});
}
@@ -554,11 +909,13 @@ document.addEventListener("DOMContentLoaded", () => {
closeVoiceBrowser();
}
});
if (elements.container) {
elements.container.addEventListener("click", (event) => {
if (elements.samplesContainer) {
elements.samplesContainer.addEventListener("click", (event) => {
const sample = event.target.closest(".voice-browser__sample");
if (!sample) return;
elements.container.querySelectorAll(".voice-browser__sample").forEach((node) => node.removeAttribute("data-active"));
elements.samplesContainer
.querySelectorAll(".voice-browser__sample")
.forEach((node) => node.removeAttribute("data-active"));
sample.dataset.active = "true";
});
}
@@ -572,6 +929,59 @@ document.addEventListener("DOMContentLoaded", () => {
}
form.addEventListener("click", (event) => {
const genderMenu = event.target.closest('[data-role="gender-menu"]');
const genderPill = event.target.closest('[data-role="gender-pill"]');
if (!genderMenu && !genderPill) {
hideGenderMenus();
}
if (genderPill) {
event.preventDefault();
const menu = genderPill.parentElement?.querySelector('[data-role="gender-menu"]');
const isOpen = menu && !menu.hidden;
hideGenderMenus();
if (menu && !isOpen) {
menu.hidden = false;
menu.setAttribute("aria-hidden", "false");
genderPill.classList.add("is-open");
}
return;
}
const genderOption = event.target.closest('[data-role="gender-option"]');
if (genderOption) {
event.preventDefault();
const container = genderOption.closest('[data-role="speaker-gender"]');
setGenderForSpeaker(container, genderOption.dataset.value);
hideGenderMenus();
return;
}
const clearMixButton = event.target.closest('[data-role="clear-mix"]');
if (clearMixButton) {
event.preventDefault();
const container = clearMixButton.closest(".speaker-list__item");
applyFormulaToSpeaker(container, "");
return;
}
const generateButton = event.target.closest('[data-role="generate-voice"]');
if (generateButton) {
event.preventDefault();
const container = generateButton.closest(".speaker-list__item");
if (!container) return;
const genderInput = container.querySelector('[data-role="gender-input"]');
const genderValue = genderInput?.value || "unknown";
const mix = buildRandomMix(genderValue);
if (!mix) {
console.warn("No voices available to generate a mix for", genderValue);
return;
}
const formula = formatMix(normaliseMix(mix));
applyFormulaToSpeaker(container, formula);
return;
}
const modalTrigger = event.target.closest('[data-role="open-voice-browser"]');
if (modalTrigger) {
event.preventDefault();
@@ -601,4 +1011,10 @@ document.addEventListener("DOMContentLoaded", () => {
delete select.dataset.suppressRandomize;
select.dataset.prevManual = select.value || "";
});
document.addEventListener("click", (event) => {
if (!form.contains(event.target)) {
hideGenderMenus();
}
});
});
+285
View File
@@ -125,6 +125,291 @@ body {
z-index: -1;
}
.card--workflow {
display: grid;
gap: 1.25rem;
}
.card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.card--modal {
padding: 0;
border-radius: 28px;
overflow: hidden;
}
.modal {
position: fixed;
inset: 0;
padding: 2rem;
display: none;
align-items: center;
justify-content: center;
z-index: 300;
}
.modal[data-open="true"] {
display: flex;
}
.modal[hidden] {
display: none !important;
}
.modal__overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.74);
backdrop-filter: blur(8px);
}
.modal__content {
position: relative;
width: min(95vw, 1120px);
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 30px 70px rgba(7, 14, 32, 0.55);
border-radius: 24px;
overflow: hidden;
}
.modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 1.75rem 2rem 0;
}
.modal__heading {
display: grid;
gap: 0.5rem;
}
.modal__eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--muted);
}
.modal__title {
margin: 0;
font-size: 1.6rem;
font-weight: 600;
}
.modal__body {
padding: 1.5rem 2rem 2rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0 2rem 2rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.3));
}
.modal-open {
overflow: hidden;
}
.modal__content.voice-browser {
padding: 0;
border-radius: 28px;
}
.voice-browser__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.75rem 1.75rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
}
.voice-browser__body {
display: grid;
grid-template-columns: 260px 1fr 320px;
gap: 1.5rem;
padding: 1.5rem 1.75rem 1.75rem;
overflow-y: auto;
max-height: calc(90vh - 150px);
}
.voice-browser__filters,
.voice-browser__catalog,
.voice-browser__mix {
display: flex;
flex-direction: column;
gap: 1rem;
}
.voice-browser__filters .field {
gap: 0.35rem;
}
.voice-browser__filters .button {
width: 100%;
}
.voice-browser__gender {
display: grid;
gap: 0.5rem;
}
.voice-browser__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.voice-browser__entry {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.85rem 1rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.35);
color: inherit;
text-align: left;
}
.voice-browser__entry:hover,
.voice-browser__entry[aria-current="true"],
.voice-browser__entry[data-in-mix="true"] {
border-color: rgba(56, 189, 248, 0.55);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.18);
}
.voice-browser__entry-name {
font-weight: 600;
}
.voice-browser__entry-meta {
font-size: 0.85rem;
color: var(--muted);
}
.voice-browser__mix {
border: 1px dashed rgba(148, 163, 184, 0.25);
border-radius: 18px;
padding: 1.25rem;
background: rgba(15, 23, 42, 0.25);
gap: 1.25rem;
}
.voice-browser__mix-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.voice-browser__mix-list {
display: grid;
gap: 1rem;
}
.voice-browser__mix-item {
display: grid;
gap: 0.75rem;
padding: 1rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.3);
}
.voice-browser__mix-header h3,
.voice-browser__mix-name {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.voice-browser__mix-remove {
appearance: none;
border: none;
background: none;
color: var(--muted);
font-size: 1.15rem;
cursor: pointer;
}
.voice-browser__mix-remove:hover {
color: var(--danger);
}
.voice-browser__mix-weight {
font-size: 0.85rem;
color: var(--muted);
}
.voice-browser__preview {
display: grid;
gap: 1rem;
}
.voice-browser__samples {
display: grid;
gap: 0.75rem;
}
.voice-browser__sample {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
padding: 0.85rem 1rem;
background: rgba(15, 23, 42, 0.28);
}
.voice-browser__actions {
display: flex;
justify-content: flex-end;
}
.voice-browser__empty {
margin: 0;
text-align: center;
color: var(--muted);
}
@media (max-width: 1100px) {
.voice-browser__body {
grid-template-columns: 1fr;
}
.voice-browser__mix {
order: 3;
}
}
@media (max-width: 768px) {
.modal {
padding: 1rem;
}
.modal__header,
.modal__body,
.modal__footer {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
}
.step-indicator {
display: flex;
flex-wrap: wrap;
+133 -110
View File
@@ -3,7 +3,7 @@
{% block title %}abogen · Dashboard{% endblock %}
{% block content %}
<section class="card">
<section class="card card--workflow">
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-active">
<span class="step-indicator__index">1</span>
@@ -19,119 +19,142 @@
</span>
</div>
<h1 class="card__title">Create a New Audiobook</h1>
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
<div class="grid">
<div class="field field--file">
<label for="source_file">Source File</label>
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.</p>
<div class="card__actions">
<button type="button" class="button" data-role="open-upload-modal">Open upload &amp; settings</button>
</div>
</section>
<div class="modal" data-role="upload-modal" hidden>
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
<header class="modal__header">
<div class="modal__heading">
<p class="modal__eyebrow">Step 1 of 3</p>
<h2 class="modal__title" id="upload-modal-title">Upload &amp; settings</h2>
<p class="hint">Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.</p>
</div>
<div class="field">
<label for="language">Language</label>
<select id="language" name="language">
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="voice_profile">Voice Profile</label>
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard Voice</option>
<option value="__formula">Custom Voice Formula</option>
{% if options.voice_profile_options %}
<optgroup label="Saved mixes">
{% for profile in options.voice_profile_options %}
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if loop.first %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
</div>
<div class="field" data-role="voice-field" {% if options.voice_profile_options %}hidden aria-hidden="true"{% endif %}>
<label for="voice">Voice</label>
<select id="voice" name="voice" data-role="voice-select" data-default="{{ settings.default_voice }}">
{% for voice in options.voices %}
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</select>
</div>
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
<label for="voice_formula">Custom Voice Formula</label>
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
</div>
<div class="field">
<label for="speaker_config">Speaker preset</label>
<select id="speaker_config" name="speaker_config">
<option value="">None</option>
{% for config in options.speaker_configs %}
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
{% endfor %}
</select>
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
</div>
<div class="field">
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
</div>
<div class="field">
<label for="subtitle_mode">Subtitle mode</label>
<select id="subtitle_mode" name="subtitle_mode">
<option value="Disabled">Disabled</option>
<option value="Sentence">Sentence</option>
<option value="Sentence + Comma">Sentence + Comma</option>
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
{% for i in range(1, 11) %}
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
{% endfor %}
</select>
</div>
<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 settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<p class="hint">Controls how chapters are split into TTS-ready chunks.</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 settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="toggle-pill">
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
<span>Generate EPUB 3 (experimental)</span>
</label>
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
</div>
</div>
<div class="grid">
<div class="field field--full">
<label for="source_text">Or Paste Text Directly</label>
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..." data-role="source-text"></textarea>
</div>
<div class="text-preview" data-role="text-preview" aria-live="polite">
<div class="text-preview__header">
<h2>Preview</h2>
<div class="text-preview__meta">
<span data-role="char-count">0 characters</span>
<span>·</span>
<span data-role="word-count">0 words</span>
<button type="button" class="icon-button" data-role="upload-modal-close" aria-label="Close upload settings"></button>
</header>
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="upload-form">
<div class="modal__body">
<div class="grid grid--two form-grid">
<div class="grid">
<div class="field field--file">
<label for="source_file">Source File</label>
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
</div>
<div class="field">
<label for="language">Language</label>
<select id="language" name="language">
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="voice_profile">Voice Profile</label>
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard Voice</option>
<option value="__formula">Custom Voice Formula</option>
{% if options.voice_profile_options %}
<optgroup label="Saved mixes">
{% for profile in options.voice_profile_options %}
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if loop.first %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
</div>
<div class="field" data-role="voice-field" {% if options.voice_profile_options %}hidden aria-hidden="true"{% endif %}>
<label for="voice">Voice</label>
<select id="voice" name="voice" data-role="voice-select" data-default="{{ settings.default_voice }}">
{% for voice in options.voices %}
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</select>
</div>
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
<label for="voice_formula">Custom Voice Formula</label>
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
</div>
<div class="field">
<label for="speaker_config">Speaker preset</label>
<select id="speaker_config" name="speaker_config">
<option value="">None</option>
{% for config in options.speaker_configs %}
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
{% endfor %}
</select>
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
</div>
<div class="field">
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
</div>
<div class="field">
<label for="subtitle_mode">Subtitle mode</label>
<select id="subtitle_mode" name="subtitle_mode">
<option value="Disabled">Disabled</option>
<option value="Sentence">Sentence</option>
<option value="Sentence + Comma">Sentence + Comma</option>
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
{% for i in range(1, 11) %}
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
{% endfor %}
</select>
</div>
<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 settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<p class="hint">Controls how chapters are split into TTS-ready chunks.</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 settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="toggle-pill">
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
<span>Generate EPUB 3 (experimental)</span>
</label>
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
</div>
</div>
<div class="grid">
<div class="field field--full">
<label for="source_text">Or Paste Text Directly</label>
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..." data-role="source-text"></textarea>
</div>
<div class="text-preview" data-role="text-preview" aria-live="polite">
<div class="text-preview__header">
<h2>Preview</h2>
<div class="text-preview__meta">
<span data-role="char-count">0 characters</span>
<span>·</span>
<span data-role="word-count">0 words</span>
</div>
</div>
<pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre>
</div>
</div>
</div>
<pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre>
</div>
<div class="field field--actions">
<button type="submit" class="button">Queue Conversion</button>
</div>
</div>
</form>
</section>
<footer class="modal__footer">
<button type="button" class="button button--ghost" data-role="upload-modal-close">Cancel</button>
<button type="submit" class="button">Queue Conversion</button>
</footer>
</form>
</div>
</div>
<section class="card" id="queue">
<div id="jobs-panel"
+79 -115
View File
@@ -3,17 +3,20 @@
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% block content %}
<section class="card" data-role="prepare-wizard">
{% set wizard_step = (active_step or 'chapters') %}
{% set is_chapters = wizard_step == 'chapters' %}
{% set is_speakers = wizard_step == 'speakers' %}
<section class="card" data-role="prepare-wizard" data-initial-step="{{ wizard_step }}">
<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 &amp; settings</span>
</span>
<span class="step-indicator__item is-active" data-role="wizard-step" data-step-key="chapters">
<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" data-role="wizard-step" data-step-key="speakers">
<span class="step-indicator__item{% if is_speakers %} is-active{% endif %}" data-role="wizard-step" data-step-key="speakers">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</span>
@@ -30,92 +33,8 @@
{% 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">
<div class="prepare-summary">
<div class="prepare-summary__stats">
<dl>
<div>
<dt>Source</dt>
<dd>{{ pending.original_filename }}</dd>
</div>
<div>
<dt>Language</dt>
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
</div>
<div>
<dt>Default voice</dt>
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd>{{ pending.chapters|length }}</dd>
</div>
<div>
<dt>Characters</dt>
<dd>{{ '{:,}'.format(pending.total_characters|default(0)) }}</dd>
</div>
<div>
<dt>Chapter intro delay</dt>
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
</div>
<div>
<dt>Chunk granularity</dt>
<dd>{{ pending.chunk_level|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker mode</dt>
<dd>{{ pending.speaker_mode|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker analysis threshold</dt>
<dd>{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}</dd>
</div>
<div>
<dt>EPUB 3 package</dt>
<dd>{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}</dd>
</div>
{% if applied_languages %}
<div>
<dt>Config languages</dt>
<dd>{{ applied_languages | map('upper') | join(', ') }}</dd>
</div>
{% endif %}
</dl>
</div>
{% if analysis_speakers or show_analysis_prompt or has_metadata %}
<div class="prepare-summary__insights">
{% if analysis_speakers %}
{% set ordered_ids = analysis.get('ordered_speakers', []) %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
{% if ordered_ids %}
<ul>
{% for sid in ordered_ids %}
{% set speaker = analysis_speakers.get(sid) %}
{% if speaker %}
<li>
<strong>{{ speaker.label }}</strong>
{% if speaker.gender and speaker.gender != 'unknown' %}
· {{ speaker.gender|title }}
{% endif %}
· {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
{% endif %}
</div>
{% elif show_analysis_prompt %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
</div>
{% endif %}
</div>
{% endif %}
</div>
<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>
@@ -125,15 +44,20 @@
{% endif %}
<div class="prepare-step-nav" data-role="prepare-step-nav">
<button type="button" class="button button--ghost is-active" data-step-target="chapters">Step 2 · Chapters</button>
<button type="button" class="button button--ghost" data-step-target="speakers" disabled aria-disabled="true" data-state="locked">Step 3 · Speakers</button>
<button type="button" class="button button--ghost{% if is_chapters %} is-active{% endif %}" data-step-target="chapters">Step 2 · Chapters</button>
<button type="button"
class="button button--ghost{% if is_speakers %} is-active{% endif %}"
data-step-target="speakers"
{% if not is_speakers %}disabled aria-disabled="true" data-state="locked"{% else %}data-state="unlocked"{% endif %}>
Step 3 · Speakers
</button>
</div>
<div class="prepare-steps" data-role="prepare-step-panels">
<section class="prepare-step" data-step-panel="chapters">
<header class="prepare-step__header">
<h2>Step 2 · Select chapters</h2>
<p class="hint">Choose which chapters to convert and tweak conversion options.</p>
<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">
@@ -190,6 +114,9 @@
</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 %}
@@ -300,6 +227,10 @@
{% 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>
@@ -319,11 +250,27 @@
</div>
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
<div class="speaker-list__meta">
{% if speaker.gender and speaker.gender != 'unknown' %}
<span class="badge badge--info">{{ speaker.gender|title }}</span>
{% else %}
<span class="badge badge--muted">Gender unknown</span>
{% endif %}
<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 %}
@@ -380,6 +327,12 @@
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>
</div>
<div class="speaker-list__inline">
<label class="toggle-pill">
@@ -391,13 +344,24 @@
<span>Randomize compatible voice on analyze</span>
</label>
</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>
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
</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>{{ quote }}</p>
<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"
@@ -421,7 +385,7 @@
</article>
{% endfor %}
{% else %}
<p class="hint">No paragraphs captured yet. Analyze speakers to collect dialogue samples.</p>
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
{% endif %}
</details>
{% if speaker.recommended_voices %}
@@ -449,15 +413,6 @@
<div class="prepare-actions">
<button type="button" class="button button--ghost" data-role="step-prev" data-step-target="chapters">Back to chapters</button>
<button type="submit"
class="button button--ghost"
data-role="analyze-button"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if pending.speaker_mode != 'multi' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
Analyze speakers
</button>
<button type="submit" class="button">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
@@ -492,17 +447,26 @@
<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-random">Random blend</button>
<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__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>Use this voice</button>
</div>
<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>