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
+56 -11
View File
@@ -78,28 +78,40 @@ class SpeakerGuess:
label: str label: str
count: int = 0 count: int = 0
confidence: str = "low" confidence: str = "low"
sample_quotes: List[str] = field(default_factory=list) sample_quotes: List[Dict[str, str]] = field(default_factory=list)
suppressed: bool = False suppressed: bool = False
gender: str = "unknown" gender: str = "unknown"
detected_gender: str = "unknown"
male_votes: int = 0 male_votes: int = 0
female_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 self.count += 1
if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0): if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0):
self.confidence = confidence self.confidence = confidence
if quote:
normalized = quote.strip() excerpt = _build_excerpt(text, quote)
if normalized and normalized not in self.sample_quotes: gender_hint = _format_gender_hint(male_votes, female_votes)
self.sample_quotes.append(normalized[:240]) 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: if len(self.sample_quotes) > 3:
self.sample_quotes = 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: if male_votes:
self.male_votes += male_votes self.male_votes += male_votes
if female_votes: if female_votes:
self.female_votes += female_votes self.female_votes += female_votes
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) self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender)
def as_dict(self) -> Dict[str, Any]: def as_dict(self) -> Dict[str, Any]:
@@ -108,9 +120,10 @@ class SpeakerGuess:
"label": self.label, "label": self.label,
"count": self.count, "count": self.count,
"confidence": self.confidence, "confidence": self.confidence,
"sample_quotes": list(self.sample_quotes), "sample_quotes": [dict(sample) for sample in self.sample_quotes],
"suppressed": self.suppressed, "suppressed": self.suppressed,
"gender": self.gender, "gender": self.gender,
"detected_gender": self.detected_gender,
} }
@@ -188,9 +201,7 @@ def analyze_speakers(
label_index[label] = record_id label_index[label] = record_id
speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label)
guess = speaker_guesses[record_id] guess = speaker_guesses[record_id]
guess.register_occurrence(confidence, quote) guess.register_occurrence(confidence, text, quote, male_votes, female_votes)
if record_id != narrator_id and (male_votes or female_votes):
guess.register_gender(male_votes, female_votes)
if record_id != speaker_id: if record_id != speaker_id:
# Maintain mapping to canonical ID in assignments. # Maintain mapping to canonical ID in assignments.
assignments[chunk_id] = record_id assignments[chunk_id] = record_id
@@ -400,6 +411,40 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
return "unknown" 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]: def _normalize_candidate_name(raw: str) -> Optional[str]:
if not raw: if not raw:
return None return None
+27 -6
View File
@@ -1965,7 +1965,7 @@ def prepare_job(pending_id: str) -> str:
if not pending: if not pending:
abort(404) abort(404)
pending = cast(PendingJob, pending) 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") @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) ) = _apply_prepare_form(pending, request.form)
if errors: 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": if pending.speaker_mode != "multi":
setattr(pending, "analysis_requested", False) setattr(pending, "analysis_requested", False)
@@ -1998,13 +1998,18 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
return _render_prepare_page( return _render_prepare_page(
pending, pending,
error="Switch to multi-speaker mode to analyze speakers.", error="Switch to multi-speaker mode to analyze speakers.",
active_step="chapters",
) )
if not enabled_overrides: if not enabled_overrides:
setattr(pending, "analysis_requested", False) setattr(pending, "analysis_requested", False)
pending.chunks = [] pending.chunks = []
pending.speaker_analysis = {} 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) raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") 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." notice_message = "Speaker analysis updated."
if persist_config_requested and config_name: if persist_config_requested and config_name:
notice_message = "Speaker analysis updated and configuration saved." 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>") @web_bp.post("/jobs/prepare/<pending_id>")
@@ -2078,14 +2083,22 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
) = _apply_prepare_form(pending, request.form) ) = _apply_prepare_form(pending, request.form)
if errors: 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": if pending.speaker_mode != "multi":
setattr(pending, "analysis_requested", False) setattr(pending, "analysis_requested", False)
if not enabled_overrides: if not enabled_overrides:
pending.chunks = [] 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) raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
@@ -2217,7 +2230,14 @@ def _render_prepare_page(
*, *,
error: Optional[str] = None, error: Optional[str] = None,
notice: Optional[str] = None, notice: Optional[str] = None,
active_step: Optional[str] = None,
) -> str: ) -> 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( return render_template(
"prepare_job.html", "prepare_job.html",
pending=pending, pending=pending,
@@ -2225,6 +2245,7 @@ def _render_prepare_page(
settings=_load_settings(), settings=_load_settings(),
error=error, error=error,
notice=notice, notice=notice,
active_step=active_step,
) )
+64 -11
View File
@@ -1,16 +1,69 @@
const initDashboard = () => { const initDashboard = () => {
const profileSelect = document.querySelector('[data-role="voice-profile"]'); const uploadModal = document.querySelector('[data-role="upload-modal"]');
const voiceField = document.querySelector('[data-role="voice-field"]'); const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
const voiceSelect = document.querySelector('[data-role="voice-select"]'); const scope = uploadModal || document;
const formulaField = document.querySelector('[data-role="formula-field"]');
const formulaInput = document.querySelector('[data-role="voice-formula"]');
const languageSelect = document.getElementById("language");
const sourceText = document.querySelector('[data-role="source-text"]'); const profileSelect = scope.querySelector('[data-role="voice-profile"]');
const previewEl = document.querySelector('[data-role="text-preview"]'); const voiceField = scope.querySelector('[data-role="voice-field"]');
const previewBody = document.querySelector('[data-role="preview-body"]'); const voiceSelect = scope.querySelector('[data-role="voice-select"]');
const charCountEl = document.querySelector('[data-role="char-count"]'); const formulaField = scope.querySelector('[data-role="formula-field"]');
const wordCountEl = document.querySelector('[data-role="word-count"]'); 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 = () => { const hydrateDefaultVoice = () => {
if (!voiceSelect) return; if (!voiceSelect) return;
+478 -62
View File
@@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", () => {
const updateRowState = (row) => { const updateRowState = (row) => {
const enabled = row.querySelector('[data-role=chapter-enabled]'); const enabled = row.querySelector('[data-role=chapter-enabled]');
const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea")); 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; const isChecked = enabled ? enabled.checked : true;
row.dataset.disabled = isChecked ? "false" : "true"; row.dataset.disabled = isChecked ? "false" : "true";
@@ -46,6 +47,11 @@ document.addEventListener("DOMContentLoaded", () => {
const select = row.querySelector("select[data-role=voice-select]"); const select = row.querySelector("select[data-role=voice-select]");
toggleFormula(select); toggleFormula(select);
if (warning) {
warning.hidden = isChecked;
warning.setAttribute("aria-hidden", isChecked ? "true" : "false");
}
}; };
const toggleFormula = (select) => { 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 updatePreviewVoice = (select) => {
const container = select.closest(".speaker-list__item"); const container = select.closest(".speaker-list__item");
if (!container) return; if (!container) return;
const previewButton = container.querySelector('[data-role="speaker-preview"]'); const previewButton = container.querySelector('[data-role="speaker-preview"]');
if (!previewButton) return; 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 defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || "";
const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice); const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice);
previewButton.dataset.voice = currentVoice || defaultVoice; previewButton.dataset.voice = currentVoice || defaultVoice;
@@ -112,6 +120,14 @@ document.addEventListener("DOMContentLoaded", () => {
if (!select.dataset.prevManual) { if (!select.dataset.prevManual) {
select.dataset.prevManual = select.value; 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.dataset.suppressRandomize = "1";
select.disabled = true; select.disabled = true;
select.value = ""; select.value = "";
@@ -139,10 +155,21 @@ document.addEventListener("DOMContentLoaded", () => {
handleRandomizeToggle(randomToggle); 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) { if (!target.dataset.suppressRandomize) {
target.dataset.prevManual = target.value || ""; target.dataset.prevManual = target.value || "";
} }
updatePreviewVoice(target); updatePreviewVoice(target);
delete target.dataset.suppressFormulaClear;
}); });
updatePreviewVoice(select); updatePreviewVoice(select);
}); });
@@ -153,6 +180,9 @@ document.addEventListener("DOMContentLoaded", () => {
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox)); 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"]'); const wizard = document.querySelector('[data-role="prepare-wizard"]');
if (wizard) { if (wizard) {
const stepOrder = ["chapters", "speakers"]; const stepOrder = ["chapters", "speakers"];
@@ -163,17 +193,22 @@ document.addEventListener("DOMContentLoaded", () => {
const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]'));
const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]'));
const panels = new Map(); const panels = new Map();
const initialStep = wizard.dataset.initialStep || "chapters";
stepOrder.forEach((step) => { stepOrder.forEach((step) => {
const panel = wizard.querySelector(`[data-step-panel="${step}"]`); const panel = wizard.querySelector(`[data-step-panel="${step}"]`);
if (panel) { if (panel) {
panels.set(step, panel); panels.set(step, panel);
panel.hidden = step !== "chapters"; const isInitial = step === initialStep;
panel.setAttribute("aria-hidden", step === "chapters" ? "false" : "true"); panel.hidden = !isInitial;
panel.setAttribute("aria-hidden", isInitial ? "false" : "true");
} }
}); });
const unlockedSteps = new Set(["chapters"]); const unlockedSteps = new Set(["chapters"]);
let currentStep = "chapters"; if (initialStep === "speakers") {
unlockedSteps.add("speakers");
}
let currentStep = initialStep;
const updateIndicator = (activeStep) => { const updateIndicator = (activeStep) => {
const activeIndex = indicatorOrder.indexOf(activeStep); const activeIndex = indicatorOrder.indexOf(activeStep);
@@ -210,6 +245,9 @@ document.addEventListener("DOMContentLoaded", () => {
} }
currentStep = step; currentStep = step;
wizard.dataset.activeStep = step; wizard.dataset.activeStep = step;
if (activeStepInput) {
activeStepInput.value = step;
}
panels.forEach((panel, key) => { panels.forEach((panel, key) => {
const isActive = key === step; const isActive = key === step;
panel.hidden = !isActive; panel.hidden = !isActive;
@@ -231,6 +269,30 @@ document.addEventListener("DOMContentLoaded", () => {
updateIndicator(step); 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) => { navButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (button.disabled) { if (button.disabled) {
@@ -246,6 +308,10 @@ document.addEventListener("DOMContentLoaded", () => {
nextButtons.forEach((button) => { nextButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const target = button.dataset.stepTarget || "speakers"; const target = button.dataset.stepTarget || "speakers";
if (target === "speakers") {
submitForAnalysis();
return;
}
unlockStep(target); unlockStep(target);
setStep(target); setStep(target);
}); });
@@ -264,11 +330,183 @@ document.addEventListener("DOMContentLoaded", () => {
const voiceModal = document.querySelector('[data-role="voice-modal"]'); const voiceModal = document.querySelector('[data-role="voice-modal"]');
let activeGenderFilter = ""; 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 = { const modalState = {
speakerItem: null, speakerItem: null,
samples: [], samples: [],
recommended: new Set(), recommended: new Set(),
selectedVoice: "", mix: new Map(),
highlighted: "",
defaultVoice: "", defaultVoice: "",
previewSettings: { language: "a", speed: "1", useGpu: "true" }, previewSettings: { language: "a", speed: "1", useGpu: "true" },
}; };
@@ -277,11 +515,14 @@ document.addEventListener("DOMContentLoaded", () => {
modalState.speakerItem = null; modalState.speakerItem = null;
modalState.samples = []; modalState.samples = [];
modalState.recommended = new Set(); modalState.recommended = new Set();
modalState.selectedVoice = ""; modalState.mix = new Map();
modalState.highlighted = "";
modalState.defaultVoice = ""; modalState.defaultVoice = "";
modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" }; modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" };
}; };
const getMixFormula = () => formatMix(normaliseMix(new Map(modalState.mix)));
const renderVoiceList = (elements) => { const renderVoiceList = (elements) => {
if (!elements) return; if (!elements) return;
const { list, searchInput, languageSelect } = elements; const { list, searchInput, languageSelect } = elements;
@@ -289,7 +530,8 @@ document.addEventListener("DOMContentLoaded", () => {
list.innerHTML = ""; list.innerHTML = "";
const term = (searchInput?.value || "").trim().toLowerCase(); const term = (searchInput?.value || "").trim().toLowerCase();
const languageFilter = languageSelect?.value || ""; const languageFilter = languageSelect?.value || "";
const filtered = voiceCatalog.filter((voice) => { const filtered = voiceCatalog
.filter((voice) => {
if (languageFilter && voice.language !== languageFilter) return false; if (languageFilter && voice.language !== languageFilter) return false;
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false; if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
if (term) { if (term) {
@@ -301,7 +543,8 @@ document.addEventListener("DOMContentLoaded", () => {
} }
} }
return true; return true;
}).sort((a, b) => { })
.sort((a, b) => {
const aRecommended = modalState.recommended.has(a.id) ? 0 : 1; const aRecommended = modalState.recommended.has(a.id) ? 0 : 1;
const bRecommended = modalState.recommended.has(b.id) ? 0 : 1; const bRecommended = modalState.recommended.has(b.id) ? 0 : 1;
if (aRecommended !== bRecommended) { if (aRecommended !== bRecommended) {
@@ -325,7 +568,10 @@ document.addEventListener("DOMContentLoaded", () => {
button.className = "voice-browser__entry"; button.className = "voice-browser__entry";
button.dataset.role = "voice-modal-item"; button.dataset.role = "voice-modal-item";
button.dataset.voiceId = voice.id; 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"); button.setAttribute("aria-current", "true");
} }
if (modalState.recommended.has(voice.id)) { 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) => { const renderSamples = (elements) => {
if (!elements) return; if (!elements) return;
const { container } = elements; const { samplesContainer } = elements;
if (!container) return; if (!samplesContainer) return;
container.innerHTML = ""; samplesContainer.innerHTML = "";
if (!modalState.samples.length) { if (!modalState.samples.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "hint"; empty.className = "hint";
empty.textContent = "No sample paragraphs available yet."; empty.textContent = "No sample paragraphs available yet.";
container.appendChild(empty); samplesContainer.appendChild(empty);
return; return;
} }
const formula = getMixFormula();
modalState.samples.forEach((text, index) => { modalState.samples.forEach((text, index) => {
const sample = document.createElement("article"); const sample = document.createElement("article");
sample.className = "voice-browser__sample"; sample.className = "voice-browser__sample";
@@ -379,28 +696,32 @@ document.addEventListener("DOMContentLoaded", () => {
previewButton.dataset.language = modalState.previewSettings.language; previewButton.dataset.language = modalState.previewSettings.language;
previewButton.dataset.speed = modalState.previewSettings.speed; previewButton.dataset.speed = modalState.previewSettings.speed;
previewButton.dataset.useGpu = modalState.previewSettings.useGpu; previewButton.dataset.useGpu = modalState.previewSettings.useGpu;
previewButton.dataset.voice = modalState.selectedVoice || modalState.defaultVoice || ""; previewButton.dataset.voice = formula || modalState.defaultVoice || "";
previewButton.textContent = "Preview sample"; previewButton.textContent = "Preview sample";
actions.appendChild(previewButton); actions.appendChild(previewButton);
sample.appendChild(paragraph); sample.appendChild(paragraph);
sample.appendChild(actions); sample.appendChild(actions);
container.appendChild(sample); samplesContainer.appendChild(sample);
}); });
}; };
const updateModalVoiceMeta = (elements) => { const updateModalMeta = (elements) => {
if (!elements) return; if (!elements) return;
const { nameLabel, metaLabel } = elements; const { nameLabel, metaLabel } = elements;
if (!nameLabel || !metaLabel) return; if (!nameLabel || !metaLabel) return;
if (!modalState.selectedVoice) { if (!modalState.mix.size) {
nameLabel.textContent = "Select a voice to preview"; nameLabel.textContent = "Select voices to build a blend";
metaLabel.textContent = ""; metaLabel.textContent = "";
return; 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) { if (!voice) {
nameLabel.textContent = modalState.selectedVoice; nameLabel.textContent = highlight;
metaLabel.textContent = ""; metaLabel.textContent = "";
return; return;
} }
@@ -408,13 +729,19 @@ document.addEventListener("DOMContentLoaded", () => {
metaLabel.textContent = `${voice.language_label} · ${voice.gender}`; 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) => { const refreshModal = (elements) => {
renderVoiceList(elements); renderVoiceList(elements);
renderMix(elements);
renderSamples(elements); renderSamples(elements);
updateModalVoiceMeta(elements); updateModalMeta(elements);
if (elements?.applyButton) { updateApplyState(elements);
elements.applyButton.disabled = !modalState.selectedVoice;
}
}; };
const openVoiceBrowser = (speakerItem, sampleIndex = 0) => { const openVoiceBrowser = (speakerItem, sampleIndex = 0) => {
@@ -422,8 +749,13 @@ document.addEventListener("DOMContentLoaded", () => {
modalState.speakerItem = speakerItem; modalState.speakerItem = speakerItem;
const select = speakerItem.querySelector('[data-role="speaker-voice"]'); const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]'); 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.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 = { modalState.previewSettings = {
language: previewTrigger?.dataset.language || "a", language: previewTrigger?.dataset.language || "a",
speed: previewTrigger?.dataset.speed || "1", speed: previewTrigger?.dataset.speed || "1",
@@ -437,7 +769,9 @@ document.addEventListener("DOMContentLoaded", () => {
const raw = samplesTemplate.innerHTML || "[]"; const raw = samplesTemplate.innerHTML || "[]";
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) { 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) { } catch (error) {
console.warn("Unable to parse speaker samples", error); console.warn("Unable to parse speaker samples", error);
@@ -462,7 +796,9 @@ document.addEventListener("DOMContentLoaded", () => {
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), 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"]'), applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
@@ -498,10 +834,14 @@ document.addEventListener("DOMContentLoaded", () => {
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), 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"]'), applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), 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) { if (elements.searchInput) {
@@ -522,29 +862,44 @@ document.addEventListener("DOMContentLoaded", () => {
const target = event.target.closest('[data-role="voice-modal-item"]'); const target = event.target.closest('[data-role="voice-modal-item"]');
if (!target) return; if (!target) return;
event.preventDefault(); 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); refreshModal(elements);
}); });
} }
if (elements.applyButton) { if (elements.applyButton) {
elements.applyButton.addEventListener("click", (event) => { elements.applyButton.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
if (!modalState.speakerItem || !modalState.selectedVoice) { if (!modalState.speakerItem) return;
return; const formula = getMixFormula();
} if (!formula) return;
const select = modalState.speakerItem.querySelector('[data-role="speaker-voice"]'); applyFormulaToSpeaker(modalState.speakerItem, formula);
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;
closeVoiceBrowser(); closeVoiceBrowser();
}); });
} }
@@ -554,11 +909,13 @@ document.addEventListener("DOMContentLoaded", () => {
closeVoiceBrowser(); closeVoiceBrowser();
} }
}); });
if (elements.container) { if (elements.samplesContainer) {
elements.container.addEventListener("click", (event) => { elements.samplesContainer.addEventListener("click", (event) => {
const sample = event.target.closest(".voice-browser__sample"); const sample = event.target.closest(".voice-browser__sample");
if (!sample) return; 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"; sample.dataset.active = "true";
}); });
} }
@@ -572,6 +929,59 @@ document.addEventListener("DOMContentLoaded", () => {
} }
form.addEventListener("click", (event) => { 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"]'); const modalTrigger = event.target.closest('[data-role="open-voice-browser"]');
if (modalTrigger) { if (modalTrigger) {
event.preventDefault(); event.preventDefault();
@@ -601,4 +1011,10 @@ document.addEventListener("DOMContentLoaded", () => {
delete select.dataset.suppressRandomize; delete select.dataset.suppressRandomize;
select.dataset.prevManual = select.value || ""; 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; 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 { .step-indicator {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+29 -6
View File
@@ -3,7 +3,7 @@
{% block title %}abogen · Dashboard{% endblock %} {% block title %}abogen · Dashboard{% endblock %}
{% block content %} {% block content %}
<section class="card"> <section class="card card--workflow">
<div class="step-indicator" aria-label="Audiobook workflow"> <div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-active"> <span class="step-indicator__item is-active">
<span class="step-indicator__index">1</span> <span class="step-indicator__index">1</span>
@@ -19,7 +19,26 @@
</span> </span>
</div> </div>
<h1 class="card__title">Create a New Audiobook</h1> <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"> <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>
<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="grid">
<div class="field field--file"> <div class="field field--file">
<label for="source_file">Source File</label> <label for="source_file">Source File</label>
@@ -126,12 +145,16 @@
</div> </div>
<pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre> <pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre>
</div> </div>
<div class="field field--actions"> </div>
</div>
</div>
<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> <button type="submit" class="button">Queue Conversion</button>
</div> </footer>
</div>
</form> </form>
</section> </div>
</div>
<section class="card" id="queue"> <section class="card" id="queue">
<div id="jobs-panel" <div id="jobs-panel"
+73 -109
View File
@@ -3,17 +3,20 @@
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} {% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% block content %} {% 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"> <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__item is-complete" data-role="wizard-step" data-step-key="setup">
<span class="step-indicator__index">1</span> <span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span> <span class="step-indicator__label">Upload &amp; settings</span>
</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__index">2</span>
<span class="step-indicator__label">Chapters</span> <span class="step-indicator__label">Chapters</span>
</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__index">3</span>
<span class="step-indicator__label">Speakers</span> <span class="step-indicator__label">Speakers</span>
</span> </span>
@@ -30,92 +33,8 @@
{% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %} {% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %}
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} {% 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"> <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) }}">
<div class="prepare-summary"> <input type="hidden" name="active_step" value="{{ wizard_step }}" data-role="active-step-input">
<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>
{% if error %} {% if error %}
<div class="alert alert--error">{{ error }}</div> <div class="alert alert--error">{{ error }}</div>
@@ -125,15 +44,20 @@
{% endif %} {% endif %}
<div class="prepare-step-nav" data-role="prepare-step-nav"> <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{% if is_chapters %} is-active{% endif %}" 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_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>
<div class="prepare-steps" data-role="prepare-step-panels"> <div class="prepare-steps" data-role="prepare-step-panels">
<section class="prepare-step" data-step-panel="chapters"> <section class="prepare-step" data-step-panel="chapters">
<header class="prepare-step__header"> <header class="prepare-step__header">
<h2>Step 2 · Select chapters</h2> <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> </header>
<div class="chapter-grid"> <div class="chapter-grid">
@@ -190,6 +114,9 @@
</select> </select>
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}> <input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
</div> </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> </div>
</article> </article>
{% endfor %} {% endfor %}
@@ -300,6 +227,10 @@
{% set selected_voice = speaker.resolved_voice or speaker.voice %} {% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %} {% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %} {% 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 }}"> <li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
<div class="speaker-line speaker-list__header"> <div class="speaker-line speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span> <span class="speaker-list__name">{{ speaker.label }}</span>
@@ -319,11 +250,27 @@
</div> </div>
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template> <template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
<div class="speaker-list__meta"> <div class="speaker-list__meta">
{% if speaker.gender and speaker.gender != 'unknown' %} <div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
<span class="badge badge--info">{{ speaker.gender|title }}</span> <button type="button"
{% else %} class="chip speaker-gender__pill"
<span class="badge badge--muted">Gender unknown</span> data-role="gender-pill"
{% endif %} 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') %} {% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span> <span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %} {% endif %}
@@ -380,6 +327,12 @@
data-speaker-id="{{ speaker_id }}"> data-speaker-id="{{ speaker_id }}">
Browse voices Browse voices
</button> </button>
<button type="button"
class="button button--ghost button--small"
data-role="generate-voice"
data-speaker-id="{{ speaker_id }}">
Generate voice
</button>
</div> </div>
<div class="speaker-list__inline"> <div class="speaker-list__inline">
<label class="toggle-pill"> <label class="toggle-pill">
@@ -391,13 +344,24 @@
<span>Randomize compatible voice on analyze</span> <span>Randomize compatible voice on analyze</span>
</label> </label>
</div> </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> </div>
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}> <details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary> <summary>Sample paragraphs</summary>
{% if sample_quotes %} {% if sample_quotes %}
{% for quote in 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 }}"> <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"> <div class="speaker-sample__actions">
<button type="button" <button type="button"
class="button button--ghost button--small" class="button button--ghost button--small"
@@ -421,7 +385,7 @@
</article> </article>
{% endfor %} {% endfor %}
{% else %} {% 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 %} {% endif %}
</details> </details>
{% if speaker.recommended_voices %} {% if speaker.recommended_voices %}
@@ -449,15 +413,6 @@
<div class="prepare-actions"> <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="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">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button> <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div> </div>
@@ -492,18 +447,27 @@
<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="f">Female</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button> <button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
</div> </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> </aside>
<section class="voice-browser__catalog" aria-label="Voice catalog"> <section class="voice-browser__catalog" aria-label="Voice catalog">
<ul class="voice-browser__list" data-role="voice-modal-list"></ul> <ul class="voice-browser__list" data-role="voice-modal-list"></ul>
</section> </section>
<section class="voice-browser__mix" data-role="voice-modal-mix">
<header class="voice-browser__mix-header">
<h3>Selected voices</h3>
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
</header>
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
<section class="voice-browser__preview" data-role="voice-modal-preview"> <section class="voice-browser__preview" data-role="voice-modal-preview">
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3> <h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
<p class="tag" data-role="voice-modal-selected-meta"></p> <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__samples" data-role="voice-modal-samples"></div>
<div class="voice-browser__actions"> <div class="voice-browser__actions">
<button type="button" class="button" data-role="voice-modal-apply" disabled>Use this voice</button> <button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button>
</div> </div>
</section> </section>
</section>
</div> </div>
</div> </div>
</div> </div>