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