mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +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
|
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.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]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+495
-79
@@ -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,26 +530,28 @@ 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
|
||||||
if (languageFilter && voice.language !== languageFilter) return false;
|
.filter((voice) => {
|
||||||
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
|
if (languageFilter && voice.language !== languageFilter) return false;
|
||||||
if (term) {
|
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
|
||||||
const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]]
|
if (term) {
|
||||||
.filter(Boolean)
|
const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]]
|
||||||
.map((value) => value.toLowerCase());
|
.filter(Boolean)
|
||||||
if (!haystacks.some((value) => value.includes(term))) {
|
.map((value) => value.toLowerCase());
|
||||||
return false;
|
if (!haystacks.some((value) => value.includes(term))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
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) {
|
||||||
return aRecommended - bRecommended;
|
return aRecommended - bRecommended;
|
||||||
}
|
}
|
||||||
return a.display_name.localeCompare(b.display_name);
|
return a.display_name.localeCompare(b.display_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
const emptyItem = document.createElement("li");
|
const emptyItem = document.createElement("li");
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+133
-110
@@ -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,119 +19,142 @@
|
|||||||
</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="grid">
|
<div class="card__actions">
|
||||||
<div class="field field--file">
|
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
||||||
<label for="source_file">Source File</label>
|
</div>
|
||||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
</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>
|
||||||
<div class="field">
|
<button type="button" class="icon-button" data-role="upload-modal-close" aria-label="Close upload settings">✕</button>
|
||||||
<label for="language">Language</label>
|
</header>
|
||||||
<select id="language" name="language">
|
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="upload-form">
|
||||||
{% for key, label in options.languages.items() %}
|
<div class="modal__body">
|
||||||
<option value="{{ key }}">{{ label }}</option>
|
<div class="grid grid--two form-grid">
|
||||||
{% endfor %}
|
<div class="grid">
|
||||||
</select>
|
<div class="field field--file">
|
||||||
</div>
|
<label for="source_file">Source File</label>
|
||||||
<div class="field">
|
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
||||||
<label for="voice_profile">Voice Profile</label>
|
</div>
|
||||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
|
<div class="field">
|
||||||
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard Voice</option>
|
<label for="language">Language</label>
|
||||||
<option value="__formula">Custom Voice Formula</option>
|
<select id="language" name="language">
|
||||||
{% if options.voice_profile_options %}
|
{% for key, label in options.languages.items() %}
|
||||||
<optgroup label="Saved mixes">
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
{% for profile in options.voice_profile_options %}
|
{% endfor %}
|
||||||
<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>
|
</select>
|
||||||
{% endfor %}
|
</div>
|
||||||
</optgroup>
|
<div class="field">
|
||||||
{% endif %}
|
<label for="voice_profile">Voice Profile</label>
|
||||||
</select>
|
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
|
||||||
</div>
|
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard Voice</option>
|
||||||
<div class="field" data-role="voice-field" {% if options.voice_profile_options %}hidden aria-hidden="true"{% endif %}>
|
<option value="__formula">Custom Voice Formula</option>
|
||||||
<label for="voice">Voice</label>
|
{% if options.voice_profile_options %}
|
||||||
<select id="voice" name="voice" data-role="voice-select" data-default="{{ settings.default_voice }}">
|
<optgroup label="Saved mixes">
|
||||||
{% for voice in options.voices %}
|
{% for profile in options.voice_profile_options %}
|
||||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
<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 %}
|
{% endfor %}
|
||||||
</select>
|
</optgroup>
|
||||||
</div>
|
{% endif %}
|
||||||
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
|
</select>
|
||||||
<label for="voice_formula">Custom Voice Formula</label>
|
</div>
|
||||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
<div class="field" data-role="voice-field" {% if options.voice_profile_options %}hidden aria-hidden="true"{% endif %}>
|
||||||
</div>
|
<label for="voice">Voice</label>
|
||||||
<div class="field">
|
<select id="voice" name="voice" data-role="voice-select" data-default="{{ settings.default_voice }}">
|
||||||
<label for="speaker_config">Speaker preset</label>
|
{% for voice in options.voices %}
|
||||||
<select id="speaker_config" name="speaker_config">
|
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||||
<option value="">None</option>
|
{% endfor %}
|
||||||
{% for config in options.speaker_configs %}
|
</select>
|
||||||
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
</div>
|
||||||
{% endfor %}
|
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
|
||||||
</select>
|
<label for="voice_formula">Custom Voice Formula</label>
|
||||||
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
|
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
<label for="speaker_config">Speaker preset</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) + '×';">
|
<select id="speaker_config" name="speaker_config">
|
||||||
</div>
|
<option value="">None</option>
|
||||||
<div class="field">
|
{% for config in options.speaker_configs %}
|
||||||
<label for="subtitle_mode">Subtitle mode</label>
|
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||||
<select id="subtitle_mode" name="subtitle_mode">
|
{% endfor %}
|
||||||
<option value="Disabled">Disabled</option>
|
</select>
|
||||||
<option value="Sentence">Sentence</option>
|
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
|
||||||
<option value="Sentence + Comma">Sentence + Comma</option>
|
</div>
|
||||||
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
<div class="field">
|
||||||
{% for i in range(1, 11) %}
|
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||||
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
<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) + '×';">
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
<div class="field">
|
||||||
</div>
|
<label for="subtitle_mode">Subtitle mode</label>
|
||||||
<div class="field">
|
<select id="subtitle_mode" name="subtitle_mode">
|
||||||
<label for="chunk_level">Chunk granularity</label>
|
<option value="Disabled">Disabled</option>
|
||||||
<select id="chunk_level" name="chunk_level">
|
<option value="Sentence">Sentence</option>
|
||||||
{% for option in options.chunk_levels %}
|
<option value="Sentence + Comma">Sentence + Comma</option>
|
||||||
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
||||||
{% endfor %}
|
{% for i in range(1, 11) %}
|
||||||
</select>
|
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
||||||
<p class="hint">Controls how chapters are split into TTS-ready chunks.</p>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
<div class="field">
|
</div>
|
||||||
<label for="speaker_mode">Speaker mode</label>
|
<div class="field">
|
||||||
<select id="speaker_mode" name="speaker_mode">
|
<label for="chunk_level">Chunk granularity</label>
|
||||||
{% for option in options.speaker_modes %}
|
<select id="chunk_level" name="chunk_level">
|
||||||
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
|
{% for option in options.chunk_levels %}
|
||||||
{% endfor %}
|
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
<div class="field">
|
<p class="hint">Controls how chapters are split into TTS-ready chunks.</p>
|
||||||
<label class="toggle-pill">
|
</div>
|
||||||
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
<div class="field">
|
||||||
<span>Generate EPUB 3 (experimental)</span>
|
<label for="speaker_mode">Speaker mode</label>
|
||||||
</label>
|
<select id="speaker_mode" name="speaker_mode">
|
||||||
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
{% for option in options.speaker_modes %}
|
||||||
</div>
|
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||||
</div>
|
{% endfor %}
|
||||||
<div class="grid">
|
</select>
|
||||||
<div class="field field--full">
|
</div>
|
||||||
<label for="source_text">Or Paste Text Directly</label>
|
<div class="field">
|
||||||
<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>
|
<label class="toggle-pill">
|
||||||
</div>
|
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
||||||
<div class="text-preview" data-role="text-preview" aria-live="polite">
|
<span>Generate EPUB 3 (experimental)</span>
|
||||||
<div class="text-preview__header">
|
</label>
|
||||||
<h2>Preview</h2>
|
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
||||||
<div class="text-preview__meta">
|
</div>
|
||||||
<span data-role="char-count">0 characters</span>
|
</div>
|
||||||
<span>·</span>
|
<div class="grid">
|
||||||
<span data-role="word-count">0 words</span>
|
<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>
|
||||||
</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 class="field field--actions">
|
<footer class="modal__footer">
|
||||||
<button type="submit" class="button">Queue Conversion</button>
|
<button type="button" class="button button--ghost" data-role="upload-modal-close">Cancel</button>
|
||||||
</div>
|
<button type="submit" class="button">Queue Conversion</button>
|
||||||
</div>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="card" id="queue">
|
<section class="card" id="queue">
|
||||||
<div id="jobs-panel"
|
<div id="jobs-panel"
|
||||||
|
|||||||
@@ -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 & settings</span>
|
<span class="step-indicator__label">Upload & 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,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="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__preview" data-role="voice-modal-preview">
|
<section class="voice-browser__mix" data-role="voice-modal-mix">
|
||||||
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
|
<header class="voice-browser__mix-header">
|
||||||
<p class="tag" data-role="voice-modal-selected-meta"></p>
|
<h3>Selected voices</h3>
|
||||||
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
|
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
|
||||||
<div class="voice-browser__actions">
|
</header>
|
||||||
<button type="button" class="button" data-role="voice-modal-apply" disabled>Use this voice</button>
|
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user