Files
abogen/abogen/webui/static/prepare.js
T

2864 lines
109 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const prepareState = (window.AbogenPrepareState = window.AbogenPrepareState || {
modalEventsBound: false,
});
const initPrepare = (root = document) => {
const rootEl = root instanceof HTMLElement ? root : document;
const form = rootEl.querySelector(".prepare-form") || document.querySelector(".prepare-form");
if (!form) return;
if (form.dataset.prepareInitialized === "true") {
return;
}
form.dataset.prepareInitialized = "true";
const wizardModal = document.querySelector('[data-role="wizard-modal"]');
const uploadModal =
document.querySelector('[data-role="new-job-modal"]') ||
document.querySelector('[data-role="upload-modal"]');
const openUploadTriggers = Array.from(document.querySelectorAll('[data-role="open-upload-modal"]'));
const showWizardModal = () => {
if (!wizardModal) return;
wizardModal.hidden = false;
wizardModal.dataset.open = "true";
wizardModal.removeAttribute("aria-hidden");
document.body.classList.add("modal-open");
};
const hideWizardModal = () => {
if (!wizardModal) return;
wizardModal.hidden = true;
delete wizardModal.dataset.open;
wizardModal.setAttribute("aria-hidden", "true");
};
const triggerUploadModal = () => {
const existingTrigger = openUploadTriggers.find((button) => button !== null);
if (existingTrigger) {
existingTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
return;
}
if (!uploadModal) return;
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 });
}
};
showWizardModal();
if (!prepareState.modalEventsBound) {
prepareState.modalEventsBound = true;
document.addEventListener("upload-modal:open", hideWizardModal);
document.addEventListener("upload-modal:close", showWizardModal);
}
const parseJSONScript = (id) => {
const el = document.getElementById(id);
if (!el) return null;
try {
const content = el.textContent || "";
return content ? JSON.parse(content) : null;
} catch (error) {
console.warn(`Failed to parse JSON script for ${id}`, error);
return null;
}
};
const voiceCatalog = parseJSONScript("voice-catalog-data") || [];
const languageMap = parseJSONScript("voice-language-map") || {};
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
const sampleIndexState = new WeakMap();
const speakerHints = new Map();
const canonicalizeEntityKey = (value) => (value || "").toLowerCase().replace(/\s+/g, " ").trim();
const readSpeakerSamples = (speakerItem) => {
if (!speakerItem) return [];
const template = speakerItem.querySelector('template[data-role="speaker-samples"]');
if (!template) return [];
let parsed = [];
try {
const raw = template.innerHTML || "[]";
const data = JSON.parse(raw);
if (Array.isArray(data)) {
parsed = data;
}
} catch (error) {
console.warn("Unable to parse speaker samples", error);
return [];
}
const seen = new Set();
const normalised = [];
for (const entry of parsed) {
let excerpt = "";
let genderHint = "";
if (typeof entry === "string") {
excerpt = entry;
} else if (entry && typeof entry === "object") {
excerpt = String(entry.excerpt || "");
genderHint = typeof entry.gender_hint === "string" ? entry.gender_hint : "";
}
const key = excerpt.trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
normalised.push({ excerpt: key, genderHint });
}
return normalised;
};
const registerSpeakerHintFromNode = (node) => {
if (!node) return;
const nameNode = node.querySelector(".speaker-list__name");
const label = nameNode?.textContent || "";
const key = canonicalizeEntityKey(label);
if (!key) return;
const genderInput = node.querySelector('[data-role="gender-input"]');
const voiceSelect = node.querySelector('[data-role="speaker-voice"]');
const formulaInput = node.querySelector('[data-role="speaker-formula"]');
let resolvedVoice = "";
if (voiceSelect) {
const selectedValue = voiceSelect.value || voiceSelect.dataset.prevManual || "";
if (selectedValue && selectedValue !== "__custom_mix") {
resolvedVoice = selectedValue;
} else if (voiceSelect.dataset.defaultVoice) {
resolvedVoice = voiceSelect.dataset.defaultVoice;
} else if (form.dataset.baseVoice) {
resolvedVoice = form.dataset.baseVoice;
}
} else if (form.dataset.baseVoice) {
resolvedVoice = form.dataset.baseVoice;
}
if (!resolvedVoice && formulaInput?.value?.trim()) {
const formula = formulaInput.value.trim();
const firstTerm = formula.split("+")[0] || "";
const [voiceId] = firstTerm.split("*");
if (voiceId) {
resolvedVoice = voiceId.trim();
}
}
speakerHints.set(key, {
gender: (genderInput?.value || "unknown").toLowerCase(),
voice: resolvedVoice,
});
};
const rebuildSpeakerHints = () => {
speakerHints.clear();
form.querySelectorAll(".speaker-list__item").forEach((item) => registerSpeakerHintFromNode(item));
};
const getPronunciationText = (container) => {
if (!container) return "";
const input = container.querySelector('[data-role="speaker-pronunciation"]');
const raw = input?.value?.trim();
if (raw) {
return raw;
}
return (container.dataset.defaultPronunciation || "").trim();
};
const syncPronunciationPreview = (container) => {
if (!container) return;
const text = getPronunciationText(container);
const previewButtons = container.querySelectorAll('[data-role="speaker-preview"][data-preview-source]');
previewButtons.forEach((button) => {
const source = button.dataset.previewSource;
if (["pronunciation", "generated", "mix"].includes(source)) {
button.dataset.previewText = text;
}
});
};
const setSpeakerSample = (speakerItem, index) => {
if (!speakerItem) return;
const samples = readSpeakerSamples(speakerItem);
if (!samples.length) return;
const maxIndex = samples.length;
const normalisedIndex = ((index % maxIndex) + maxIndex) % maxIndex;
sampleIndexState.set(speakerItem, normalisedIndex);
const sample = samples[normalisedIndex];
const article = speakerItem.querySelector('[data-role="speaker-sample"]');
if (!article) return;
const textNode = article.querySelector('[data-role="sample-text"]');
const hintNode = article.querySelector('[data-role="sample-hint"]');
if (textNode) {
textNode.textContent = sample.excerpt;
}
if (hintNode) {
if (sample.genderHint) {
hintNode.hidden = false;
hintNode.textContent = sample.genderHint;
} else {
hintNode.hidden = true;
hintNode.textContent = "";
}
}
const previewButton = article.querySelector('[data-role="speaker-preview"][data-preview-source="sample"]');
if (previewButton) {
previewButton.dataset.previewText = sample.excerpt;
}
const voiceBrowserButton = article.querySelector('[data-role="open-voice-browser"]');
if (voiceBrowserButton) {
voiceBrowserButton.dataset.sampleIndex = String(normalisedIndex);
}
};
const initialiseSpeakerItem = (speakerItem) => {
syncPronunciationPreview(speakerItem);
const samples = readSpeakerSamples(speakerItem);
if (samples.length) {
setSpeakerSample(speakerItem, 0);
const nextButton = speakerItem.querySelector('[data-role="speaker-next-sample"]');
if (nextButton) {
nextButton.disabled = samples.length <= 1;
}
}
};
const formatCustomMixLabel = (formula) => {
if (!formula) return "Custom mix";
const segments = formula
.split("+")
.map((segment) => segment.trim())
.filter((segment) => segment.length);
if (!segments.length) {
return "Custom mix";
}
const parts = segments.map((segment) => {
const [voiceIdRaw, weightRaw] = segment.split("*").map((token) => token.trim());
const voiceId = voiceIdRaw || "";
const voiceMeta = voiceCatalogMap.get(voiceId);
const displayName = voiceMeta?.display_name || voiceId || "Voice";
const weight = Number.parseFloat(weightRaw || "");
if (!Number.isNaN(weight)) {
return `${displayName} ${(weight * 100).toFixed(0)}%`;
}
return displayName;
});
return parts.join(" + ");
};
const ensureCustomMixOption = (select) => {
if (!select) return null;
let option = select.querySelector('option[data-role="custom-mix-option"]');
if (!option) {
option = document.createElement("option");
option.value = "__custom_mix";
option.dataset.role = "custom-mix-option";
option.hidden = true;
option.disabled = true;
option.textContent = "Custom mix";
const firstOptGroup = select.querySelector("optgroup");
if (firstOptGroup) {
select.insertBefore(option, firstOptGroup);
} else {
select.appendChild(option);
}
}
return option;
};
const updateCustomMixOption = (select, formula) => {
const option = ensureCustomMixOption(select);
if (!option) return;
if (formula) {
option.hidden = false;
option.disabled = false;
option.textContent = formatCustomMixLabel(formula);
} else {
option.hidden = true;
option.disabled = true;
option.textContent = "Custom mix";
}
};
const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
const setRowExpansion = (row, expanded) => {
if (!row) return;
const details = row.querySelector('[data-role="chapter-details"]');
const toggle = row.querySelector('[data-role="chapter-toggle"]');
const isExpanded = Boolean(expanded);
row.dataset.expanded = isExpanded ? "true" : "false";
if (details) {
details.hidden = !isExpanded;
details.setAttribute("aria-hidden", isExpanded ? "false" : "true");
}
if (toggle) {
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
toggle.setAttribute("aria-label", isExpanded ? "Hide chapter details" : "Show chapter details");
}
};
const toggleRowExpansion = (row, force) => {
if (!row) return;
const current = row.dataset.expanded === "true";
const next = typeof force === "boolean" ? force : !current;
setRowExpansion(row, next);
};
const isRowEnabled = (row) => {
const checkbox = row?.querySelector('[data-role="chapter-enabled"]');
return checkbox ? checkbox.checked : true;
};
const updateRowState = (row) => {
const enabled = row.querySelector('[data-role=chapter-enabled]');
const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea"));
const toggle = row.querySelector('[data-role="chapter-toggle"]');
const isChecked = enabled ? enabled.checked : true;
row.dataset.disabled = isChecked ? "false" : "true";
inputs.forEach((input) => {
if (input === enabled) return;
input.disabled = !isChecked;
if (!isChecked) {
if (input.tagName === "SELECT") {
input.dataset.prevValue = input.value;
input.value = "__default";
}
if (input.dataset.role === "formula-input") {
input.value = "";
input.hidden = true;
input.setAttribute("aria-hidden", "true");
}
} else if (input.tagName === "SELECT" && input.dataset.prevValue) {
input.value = input.dataset.prevValue;
}
});
const select = row.querySelector("select[data-role=voice-select]");
toggleFormula(select);
if (!isChecked) {
setRowExpansion(row, false);
}
if (toggle) {
toggle.disabled = !isChecked;
toggle.setAttribute("aria-disabled", isChecked ? "false" : "true");
}
};
const toggleFormula = (select) => {
if (!select) return;
const container = select.closest("[data-role=chapter-row]");
const formulaInput = container.querySelector('[data-role=formula-input]');
const isFormula = select.value === "formula";
formulaInput.hidden = !isFormula;
formulaInput.setAttribute("aria-hidden", isFormula ? "false" : "true");
if (!isFormula) {
formulaInput.value = "";
}
if (isFormula) {
formulaInput.required = true;
} else {
formulaInput.required = false;
}
};
chapterRows.forEach((row) => {
setRowExpansion(row, row.dataset.expanded === "true");
const enabled = row.querySelector('[data-role=chapter-enabled]');
if (enabled) {
enabled.addEventListener("change", () => updateRowState(row));
updateRowState(row);
}
const select = row.querySelector("select[data-role=voice-select]");
if (select) {
select.addEventListener("change", () => toggleFormula(select));
toggleFormula(select);
}
const toggleButton = row.querySelector('[data-role="chapter-toggle"]');
if (toggleButton) {
toggleButton.addEventListener("click", () => {
if (!isRowEnabled(row)) {
setRowExpansion(row, false);
return;
}
toggleRowExpansion(row);
});
}
});
const updatePreviewVoice = (select) => {
const container = select.closest(".speaker-list__item");
if (!container) return;
const previewButtons = container.querySelectorAll('[data-role="speaker-preview"]');
if (!previewButtons.length) 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() || "";
updateCustomMixOption(select, formulaValue);
const defaultVoice = select.dataset.defaultVoice || "";
let assignedVoice = select.value || defaultVoice;
if (select.value === "__custom_mix" || formulaValue) {
assignedVoice = formulaValue || defaultVoice;
}
if (formulaValue) {
if (mixContainer) mixContainer.hidden = false;
if (mixLabel) mixLabel.textContent = formulaValue;
} else {
if (mixContainer) mixContainer.hidden = true;
if (mixLabel) mixLabel.textContent = "";
if (assignedVoice === "__custom_mix" || !assignedVoice) {
assignedVoice = defaultVoice;
}
}
previewButtons.forEach((button) => {
const kind = button.dataset.previewKind || "";
if (kind === "generated") {
button.hidden = !formulaValue;
button.dataset.voice = assignedVoice;
return;
}
const context = button.dataset.previewContext || "";
if (context === "mix") {
button.dataset.voice = formulaValue || assignedVoice;
return;
}
button.dataset.voice = assignedVoice || defaultVoice || button.dataset.voice || "";
});
};
const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]'));
voiceSelects.forEach((select) => {
ensureCustomMixOption(select);
select.addEventListener("change", (event) => {
const target = event.target;
const container = target.closest(".speaker-list__item");
if (!container) 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"]');
if (target.value === "__custom_mix") {
if (!formulaInput?.value?.trim()) {
const previous = target.dataset.prevManual || "";
target.value = previous;
}
updatePreviewVoice(target);
registerSpeakerHintFromNode(container);
return;
}
if (!target.dataset.suppressFormulaClear) {
if (formulaInput) {
formulaInput.value = "";
}
if (mixLabel) {
mixLabel.textContent = "";
}
if (mixContainer) {
mixContainer.hidden = true;
}
updateCustomMixOption(target, "");
}
target.dataset.prevManual = target.value || "";
updatePreviewVoice(target);
delete target.dataset.suppressFormulaClear;
if (container) {
registerSpeakerHintFromNode(container);
}
});
updatePreviewVoice(select);
});
const speakerItems = Array.from(form.querySelectorAll(".speaker-list__item"));
speakerItems.forEach((item) => {
initialiseSpeakerItem(item);
const pronunciationInput = item.querySelector('[data-role="speaker-pronunciation"]');
if (pronunciationInput) {
const sync = () => syncPronunciationPreview(item);
pronunciationInput.addEventListener("input", sync);
pronunciationInput.addEventListener("change", sync);
}
registerSpeakerHintFromNode(item);
});
rebuildSpeakerHints();
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
analysisButtons.forEach((button) => {
button.addEventListener("click", () => {
if (activeStepInput) {
activeStepInput.value = "entities";
}
});
});
const voiceModal = document.querySelector('[data-role="voice-modal"]');
let activeGenderFilter = "";
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const parseFormula = (formula) => {
const mix = new Map();
if (!formula) return mix;
const parts = formula.split("+");
parts.forEach((part) => {
const segment = part.trim();
if (!segment) return;
const pieces = segment.split("*");
const voiceId = pieces[0].trim();
if (!voiceId) return;
let weight = 1;
if (pieces[1]) {
const parsed = Number.parseFloat(pieces[1].trim());
if (!Number.isNaN(parsed) && parsed > 0) {
weight = parsed;
}
}
mix.set(voiceId, clamp(weight, 0.05, 1));
});
return mix;
};
const normaliseMix = (mix) => {
const entries = Array.from(mix.entries());
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (!total) return mix;
entries.forEach(([voiceId, weight]) => {
mix.set(voiceId, weight / total);
});
return mix;
};
const formatMix = (mix) => {
const entries = Array.from(mix.entries());
if (!entries.length) return "";
let total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (total < 0.5) {
const scale = 0.5 / total;
entries.forEach(([voiceId, weight]) => {
mix.set(voiceId, clamp(weight * scale, 0.05, 1));
});
total = entries.reduce((sum, [, weight]) => sum + weight, 0);
}
return entries
.map(([voiceId, weight]) => `${voiceId}*${(weight / total).toFixed(2)}`)
.join("+");
};
const genderLabel = (value) => {
switch ((value || "unknown").toLowerCase()) {
case "male":
return "Male";
case "female":
return "Female";
case "either":
return "Either";
default:
return "Unknown";
}
};
const buildRandomMix = (gender, countOverride) => {
const genderCode = (gender || "unknown").toLowerCase();
const pool = voiceCatalog.filter((voice) => {
const code = (voice.gender_code || "").toLowerCase();
if (genderCode === "female") return code === "f";
if (genderCode === "male") return code === "m";
if (genderCode === "either") return code === "f" || code === "m";
return true;
});
if (!pool.length) {
return null;
}
const voices = [...pool];
for (let i = voices.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[voices[i], voices[j]] = [voices[j], voices[i]];
}
const count = clamp(countOverride || Math.floor(Math.random() * 4) + 1, 1, 4);
const selected = voices.slice(0, count);
const mix = new Map();
const rawWeights = selected.map(() => Math.random() + 0.2);
const total = rawWeights.reduce((sum, weight) => sum + weight, 0);
selected.forEach((voice, index) => {
mix.set(voice.id, rawWeights[index] / total);
});
return mix;
};
const applyFormulaToSpeaker = (speakerItem, formula) => {
if (!speakerItem) return;
const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]');
const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]');
if (formulaInput) {
formulaInput.value = formula || "";
}
if (mixLabel) {
mixLabel.textContent = formula || "";
}
if (mixContainer) {
mixContainer.hidden = !formula;
}
if (!select) {
return;
}
ensureCustomMixOption(select);
if (formula) {
if (select.value !== "__custom_mix") {
select.dataset.prevManual = select.value || select.dataset.prevManual || select.dataset.defaultVoice || "";
}
select.dataset.suppressFormulaClear = "1";
updateCustomMixOption(select, formula);
select.value = "__custom_mix";
} else {
const fallback = select.dataset.prevManual || select.dataset.defaultVoice || "";
select.dataset.suppressFormulaClear = "1";
updateCustomMixOption(select, "");
select.value = fallback;
}
updatePreviewVoice(select);
delete select.dataset.suppressFormulaClear;
registerSpeakerHintFromNode(speakerItem);
};
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");
}
});
const speakerItem = genderContainer.closest(".speaker-list__item");
registerSpeakerHintFromNode(speakerItem);
};
Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => {
const input = container.querySelector('[data-role="gender-input"]');
setGenderForSpeaker(container, input?.value || "unknown");
});
const modalState = {
speakerItem: null,
samples: [],
recommended: new Set(),
mix: new Map(),
highlighted: "",
defaultVoice: "",
previewSettings: { language: "a", speed: "1", useGpu: "true" },
};
const resetModalState = () => {
modalState.speakerItem = null;
modalState.samples = [];
modalState.recommended = new Set();
modalState.mix = new Map();
modalState.highlighted = "";
modalState.defaultVoice = "";
modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" };
};
const getMixFormula = () => formatMix(normaliseMix(new Map(modalState.mix)));
const renderVoiceList = (elements) => {
if (!elements) return;
const { list, searchInput, languageSelect } = elements;
if (!list) return;
list.innerHTML = "";
const term = (searchInput?.value || "").trim().toLowerCase();
const languageFilter = languageSelect?.value || "";
const filtered = voiceCatalog
.filter((voice) => {
if (languageFilter && voice.language !== languageFilter) return false;
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
if (term) {
const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]]
.filter(Boolean)
.map((value) => value.toLowerCase());
if (!haystacks.some((value) => value.includes(term))) {
return false;
}
}
return true;
})
.sort((a, b) => {
const aRecommended = modalState.recommended.has(a.id) ? 0 : 1;
const bRecommended = modalState.recommended.has(b.id) ? 0 : 1;
if (aRecommended !== bRecommended) {
return aRecommended - bRecommended;
}
return a.display_name.localeCompare(b.display_name);
});
if (!filtered.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "voice-browser__empty";
emptyItem.textContent = "No voices matched your filters.";
list.appendChild(emptyItem);
return;
}
filtered.forEach((voice) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "voice-browser__entry";
button.dataset.role = "voice-modal-item";
button.dataset.voiceId = voice.id;
if (modalState.mix.has(voice.id)) {
button.dataset.inMix = "true";
}
if (modalState.highlighted === voice.id) {
button.setAttribute("aria-current", "true");
}
if (modalState.recommended.has(voice.id)) {
button.dataset.recommended = "true";
}
const nameSpan = document.createElement("span");
nameSpan.className = "voice-browser__entry-name";
nameSpan.textContent = voice.display_name;
const metaSpan = document.createElement("span");
metaSpan.className = "voice-browser__entry-meta";
metaSpan.textContent = `${voice.language_label} · ${voice.gender}`;
button.appendChild(nameSpan);
button.appendChild(metaSpan);
item.appendChild(button);
list.appendChild(item);
});
};
const renderMix = (elements) => {
const { mixList, mixTotal } = elements;
if (!mixList) return;
mixList.innerHTML = "";
const entries = Array.from(normaliseMix(new Map(modalState.mix)).entries());
const total = entries.reduce((sum, [, weight]) => sum + weight, 0);
if (mixTotal) {
mixTotal.textContent = `Total weight: ${total.toFixed(2)}`;
}
if (!entries.length) {
const empty = document.createElement("p");
empty.className = "voice-browser__empty";
empty.textContent = "Add voices from the list to build a blend.";
mixList.appendChild(empty);
return;
}
entries.forEach(([voiceId, weight]) => {
const wrapper = document.createElement("div");
wrapper.className = "voice-browser__mix-item";
wrapper.dataset.voiceId = voiceId;
const header = document.createElement("div");
header.className = "voice-browser__mix-header";
const voiceMeta = voiceCatalogMap.get(voiceId) || {};
const title = document.createElement("span");
title.className = "voice-browser__mix-name";
title.textContent = voiceMeta.display_name || voiceId;
const weightLabel = document.createElement("span");
weightLabel.className = "voice-browser__mix-weight";
weightLabel.textContent = weight.toFixed(2);
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "voice-browser__mix-remove";
removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from blend`);
removeBtn.textContent = "✕";
removeBtn.addEventListener("click", () => {
modalState.mix.delete(voiceId);
if (modalState.highlighted === voiceId) {
modalState.highlighted = "";
}
renderMix(elements);
renderVoiceList(elements);
updateModalMeta(elements);
updateApplyState(elements);
});
header.appendChild(title);
header.appendChild(weightLabel);
header.appendChild(removeBtn);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "5";
slider.max = "100";
slider.step = "1";
slider.value = String(Math.round(weight * 100));
slider.addEventListener("input", () => {
const value = clamp(Number.parseInt(slider.value, 10) / 100, 0.05, 1);
modalState.mix.set(voiceId, value);
modalState.highlighted = voiceId;
renderMix(elements);
updateModalMeta(elements);
updateApplyState(elements);
});
wrapper.appendChild(header);
wrapper.appendChild(slider);
mixList.appendChild(wrapper);
});
};
const renderSamples = (elements) => {
if (!elements) return;
const { samplesContainer } = elements;
if (!samplesContainer) return;
samplesContainer.innerHTML = "";
if (!modalState.samples.length) {
const empty = document.createElement("p");
empty.className = "hint";
empty.textContent = "No sample paragraphs available yet.";
samplesContainer.appendChild(empty);
return;
}
const formula = getMixFormula();
modalState.samples.forEach((text, index) => {
const sample = document.createElement("article");
sample.className = "voice-browser__sample";
sample.dataset.sampleIndex = String(index);
if (index === 0) {
sample.dataset.active = "true";
}
const paragraph = document.createElement("p");
paragraph.textContent = text;
const actions = document.createElement("div");
actions.className = "voice-browser__sample-actions";
const previewButton = document.createElement("button");
previewButton.type = "button";
previewButton.className = "button button--ghost button--small";
previewButton.dataset.role = "speaker-preview";
previewButton.dataset.previewText = text;
previewButton.dataset.language = modalState.previewSettings.language;
previewButton.dataset.speed = modalState.previewSettings.speed;
previewButton.dataset.useGpu = modalState.previewSettings.useGpu;
previewButton.dataset.voice = formula || modalState.defaultVoice || "";
previewButton.textContent = "Preview sample";
actions.appendChild(previewButton);
sample.appendChild(paragraph);
sample.appendChild(actions);
samplesContainer.appendChild(sample);
});
};
const updateModalMeta = (elements) => {
if (!elements) return;
const { nameLabel, metaLabel } = elements;
if (!nameLabel || !metaLabel) return;
if (!modalState.mix.size) {
nameLabel.textContent = "Select voices to build a blend";
metaLabel.textContent = "";
return;
}
const highlight = modalState.highlighted && modalState.mix.has(modalState.highlighted)
? modalState.highlighted
: Array.from(modalState.mix.keys())[0];
modalState.highlighted = highlight;
const voice = voiceCatalogMap.get(highlight);
if (!voice) {
nameLabel.textContent = highlight;
metaLabel.textContent = "";
return;
}
nameLabel.textContent = voice.display_name;
metaLabel.textContent = `${voice.language_label} · ${voice.gender}`;
};
const updateApplyState = (elements) => {
const { applyButton } = elements || {};
if (!applyButton) return;
const formula = getMixFormula();
applyButton.disabled = !formula;
};
const refreshModal = (elements) => {
renderVoiceList(elements);
renderMix(elements);
renderSamples(elements);
updateModalMeta(elements);
updateApplyState(elements);
};
const openVoiceBrowser = (speakerItem, sampleIndex = 0) => {
if (!voiceModal) return;
modalState.speakerItem = speakerItem;
const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"][data-preview-source="pronunciation"]');
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || "";
modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map();
if (!modalState.mix.size && select && select.value) {
modalState.mix.set(select.value, 1);
}
modalState.mix = normaliseMix(modalState.mix);
modalState.previewSettings = {
language: previewTrigger?.dataset.language || "a",
speed: previewTrigger?.dataset.speed || "1",
useGpu: previewTrigger?.dataset.useGpu || "true",
};
const samples = readSpeakerSamples(speakerItem);
let excerpts = samples.map((sample) => sample.excerpt);
const storedIndex = sampleIndexState.get(speakerItem) || 0;
let effectiveIndex = Number.isFinite(sampleIndex) ? sampleIndex : 0;
if (!Number.isFinite(effectiveIndex) || effectiveIndex < 0 || effectiveIndex >= excerpts.length) {
effectiveIndex = storedIndex;
}
if (excerpts.length && effectiveIndex > 0 && effectiveIndex < excerpts.length) {
const [selected] = excerpts.splice(effectiveIndex, 1);
excerpts.unshift(selected);
}
if (!excerpts.length) {
const sampleButton = speakerItem.querySelector('[data-role="speaker-preview"][data-preview-source="sample"]');
const previewText = sampleButton?.dataset.previewText?.trim();
if (previewText) {
excerpts = [previewText];
}
}
modalState.samples = Array.from(new Set(excerpts));
modalState.recommended = new Set(
Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean)
);
activeGenderFilter = "";
const elements = {
list: voiceModal.querySelector('[data-role="voice-modal-list"]'),
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')),
mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'),
mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'),
samplesContainer: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
};
if (elements.searchInput) elements.searchInput.value = "";
if (elements.languageSelect) elements.languageSelect.value = "";
elements.genderButtons.forEach((button) => {
button.setAttribute("aria-pressed", button.dataset.value === "" ? "true" : "false");
});
refreshModal(elements);
voiceModal.hidden = false;
voiceModal.dataset.open = "true";
document.body.classList.add("modal-open");
if (elements.searchInput) {
setTimeout(() => elements.searchInput.focus({ preventScroll: true }), 0);
}
};
const closeVoiceBrowser = () => {
if (!voiceModal || voiceModal.hidden) return;
voiceModal.hidden = true;
voiceModal.removeAttribute("data-open");
document.body.classList.remove("modal-open");
resetModalState();
};
if (voiceModal) {
const elements = {
list: voiceModal.querySelector('[data-role="voice-modal-list"]'),
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')),
mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'),
mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'),
samplesContainer: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
randomButton: voiceModal.querySelector('[data-role="voice-modal-random"]'),
clearButton: voiceModal.querySelector('[data-role="voice-modal-clear"]'),
};
if (elements.searchInput) {
elements.searchInput.addEventListener("input", () => renderVoiceList(elements));
}
if (elements.languageSelect) {
elements.languageSelect.addEventListener("change", () => renderVoiceList(elements));
}
elements.genderButtons.forEach((button) => {
button.addEventListener("click", () => {
activeGenderFilter = button.dataset.value || "";
elements.genderButtons.forEach((btn) => btn.setAttribute("aria-pressed", btn === button ? "true" : "false"));
renderVoiceList(elements);
});
});
if (elements.list) {
elements.list.addEventListener("click", (event) => {
const target = event.target.closest('[data-role="voice-modal-item"]');
if (!target) return;
event.preventDefault();
const voiceId = target.dataset.voiceId;
if (!voiceId) return;
if (!modalState.mix.has(voiceId)) {
modalState.mix.set(voiceId, 0.5);
}
modalState.highlighted = voiceId;
renderMix(elements);
renderVoiceList(elements);
updateModalMeta(elements);
updateApplyState(elements);
});
}
if (elements.randomButton) {
elements.randomButton.addEventListener("click", () => {
const genderInput = modalState.speakerItem?.querySelector('[data-role="gender-input"]');
const gender = genderInput?.value || "unknown";
const mix = buildRandomMix(gender);
if (mix) {
modalState.mix = mix;
modalState.highlighted = Array.from(mix.keys())[0];
refreshModal(elements);
}
});
}
if (elements.clearButton) {
elements.clearButton.addEventListener("click", () => {
modalState.mix.clear();
modalState.highlighted = "";
refreshModal(elements);
});
}
if (elements.applyButton) {
elements.applyButton.addEventListener("click", (event) => {
event.preventDefault();
if (!modalState.speakerItem) return;
const formula = getMixFormula();
if (!formula) return;
applyFormulaToSpeaker(modalState.speakerItem, formula);
closeVoiceBrowser();
});
}
voiceModal.addEventListener("click", (event) => {
if (event.target.closest('[data-role="voice-modal-close"]')) {
event.preventDefault();
closeVoiceBrowser();
}
});
if (elements.samplesContainer) {
elements.samplesContainer.addEventListener("click", (event) => {
const sample = event.target.closest(".voice-browser__sample");
if (!sample) return;
elements.samplesContainer
.querySelectorAll(".voice-browser__sample")
.forEach((node) => node.removeAttribute("data-active"));
sample.dataset.active = "true";
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !voiceModal.hidden) {
closeVoiceBrowser();
}
});
renderVoiceList(elements);
}
const entitySummaryData = parseJSONScript("entity-summary-data") || {};
const entityCacheKeyData = parseJSONScript("entity-cache-key");
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
const heteronymOverridesSeed = parseJSONScript("heteronym-overrides-data") || [];
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
const entitiesUrl = form.dataset.entitiesUrl || "";
const manualUpsertUrl = form.dataset.manualUpsertUrl || "";
const manualDeleteUrlTemplate = form.dataset.manualDeleteUrlTemplate || "";
const manualSearchUrl = form.dataset.manualSearchUrl || "";
const baseVoice = form.dataset.baseVoice || form.dataset.voice || "";
const languageCode = form.dataset.language || "en";
const defaultSpeed = form.dataset.speed || "1.0";
const useGpuDefault = form.dataset.useGpu || "true";
let entitiesEnabled = form.dataset.entitiesEnabled !== "false";
const entityState = {
summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {},
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
heteronymOverrides: Array.isArray(heteronymOverridesSeed) ? [...heteronymOverridesSeed] : [],
filters: {
people: 0,
entities: 0,
entitiesKind: "all",
},
};
let highlightedOverrideId = "";
let highlightMode = "manual";
const dirtyOverrideIds = new Set();
let activeEntityPanel = "";
let overrideFlushPromise = null;
let markOverrideDirty = () => {};
let flushManualOverrides = () => null;
let hasTriggeredEntitiesRefresh = false;
if (entityTabs) {
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]'));
const tabPanels = new Map(
Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
);
const peopleSummaryContainer = entityTabs.querySelector('[data-role="people-summary"]');
const peopleStatsNode = peopleSummaryContainer?.querySelector('[data-role="people-stats"]');
const peopleListNode = peopleSummaryContainer?.querySelector('[data-role="entity-list-people"]');
const peopleFilterNode = peopleSummaryContainer?.querySelector('[data-role="entity-filter-people"]');
const entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]');
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]');
const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]');
const entitiesKindFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-kind"]');
const entityRowTemplate = entityTabs.querySelector('template[data-role="entity-row-template"]');
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
const entitySpinner = entitySummaryContainer?.querySelector('[data-role="entities-spinner"]');
const globalEntitySpinner = entityTabs.querySelector('[data-role="global-entity-spinner"]');
const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]');
const manualOverrideTemplate = manualOverridesRoot?.querySelector('template[data-role="manual-override-template"]');
const manualOverrideResultsList = manualOverridesRoot?.querySelector('[data-role="manual-override-results"]');
const manualOverrideQueryInput = manualOverridesRoot?.querySelector('[data-role="manual-override-query"]');
const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]');
const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]');
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]');
const heteronymOverridesRoot = manualOverridesRoot?.querySelector('[data-role="heteronym-overrides"]');
const heteronymOverrideList = heteronymOverridesRoot?.querySelector('[data-role="heteronym-override-list"]');
const heteronymOverrideTemplate = heteronymOverridesRoot?.querySelector('template[data-role="heteronym-override-template"]');
const heteronymOverridesEmpty = heteronymOverridesRoot?.querySelector('[data-role="heteronym-overrides-empty"]');
let manualOverrideStatusTimer = null;
let manualOverrideStatusNonce = 0;
const setManualOverrideStatus = (message, state = "") => {
if (!manualOverrideStatusNode) return;
manualOverrideStatusNonce += 1;
const nonce = manualOverrideStatusNonce;
if (manualOverrideStatusTimer) {
window.clearTimeout(manualOverrideStatusTimer);
manualOverrideStatusTimer = null;
}
manualOverrideStatusNode.textContent = message || "";
if (state) {
manualOverrideStatusNode.dataset.state = state;
} else {
manualOverrideStatusNode.removeAttribute("data-state");
}
if (state === "success" && message) {
manualOverrideStatusTimer = window.setTimeout(() => {
if (manualOverrideStatusNonce !== nonce) {
return;
}
manualOverrideStatusNode.textContent = "";
manualOverrideStatusNode.removeAttribute("data-state");
manualOverrideStatusTimer = null;
}, 4000);
}
};
if (entitiesRefreshButton) {
entitiesRefreshButton.disabled = !entitiesEnabled;
entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true");
}
const parseThreshold = (value) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
if (peopleFilterNode) {
peopleFilterNode.addEventListener("change", () => {
entityState.filters.people = parseThreshold(peopleFilterNode.value);
renderEntitySummary();
});
}
if (entitiesFilterNode) {
entitiesFilterNode.addEventListener("change", () => {
entityState.filters.entities = parseThreshold(entitiesFilterNode.value);
renderEntitySummary();
});
}
if (entitiesKindFilterNode) {
entitiesKindFilterNode.addEventListener("change", () => {
const value = (entitiesKindFilterNode.value || "all").trim();
entityState.filters.entitiesKind = value || "all";
renderEntitySummary();
});
}
const cloneTemplate = (template) => {
if (!template) return null;
if (template.content && template.content.firstElementChild) {
return template.content.firstElementChild.cloneNode(true);
}
return template.cloneNode(true);
};
const formatMentions = (value) => {
const count = Number(value || 0);
return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`;
};
const formatEntityKindLabel = (value) => {
if (!value) {
return "Unknown";
}
if (/^[A-Z0-9_]+$/.test(value) && value.length <= 4) {
return value.replace(/_/g, " ");
}
const lowerParts = value.toLowerCase().split("_");
return lowerParts
.map((part, index) => {
if (index > 0 && ["of", "and", "the", "in", "on", "at"].includes(part)) {
return part;
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(" ");
};
const buildOverrideLookup = () => {
const map = new Map();
const register = (entry, origin) => {
if (!entry || typeof entry !== "object") return;
const normalizedToken = entry.normalized || entry.token || "";
const canonical = canonicalizeEntityKey(normalizedToken);
const tokenLabel = entry.token || entry.normalized || "";
const pronunciation = entry.pronunciation || "";
if (!canonical || !tokenLabel) return;
map.set(canonical, {
...entry,
origin,
token: tokenLabel,
normalized: normalizedToken,
pronunciation,
});
};
const pronunciationOverrides = Array.isArray(entityState.pronunciationOverrides)
? entityState.pronunciationOverrides
: [];
pronunciationOverrides.forEach((entry) => register(entry, entry?.source || "history"));
const manualOverrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : [];
manualOverrides.forEach((entry) => register(entry, "manual"));
return map;
};
const buildPossessivePreviewSamples = (pronText, tokenLabel) => {
const samples = new Set();
const base = (pronText || "").trim();
const fallback = (tokenLabel || "").trim();
if (base) {
samples.add(base);
} else if (fallback) {
samples.add(fallback);
}
const reference = (fallback || base).trim();
if (!reference) {
return Array.from(samples);
}
const root = (base || reference).trim();
if (!root) {
return Array.from(samples);
}
const lowerReference = reference.toLowerCase();
const lowerRoot = root.toLowerCase();
const endsWithApostrophe = lowerRoot.endsWith("'") || lowerRoot.endsWith("");
const endsWithApostropheS = lowerRoot.endsWith("'s") || lowerRoot.endsWith("s");
if (!endsWithApostropheS) {
const possessive = `${root}'s`;
const altPossessive = `${root}s`;
samples.add(possessive);
samples.add(altPossessive);
}
if ((lowerReference.endsWith("s") || lowerRoot.endsWith("s")) && !endsWithApostrophe) {
samples.add(`${root}'`);
samples.add(`${root}`);
}
return Array.from(samples);
};
const joinPossessivePreviewSamples = (pronText, tokenLabel) => {
const variants = buildPossessivePreviewSamples(pronText, tokenLabel);
return variants.join("\n").trim();
};
const setEntitiesLoading = (isLoading) => {
if (globalEntitySpinner) {
globalEntitySpinner.hidden = !isLoading;
}
if (!entitySummaryContainer) {
return;
}
if (isLoading) {
entitySummaryContainer.dataset.loading = "true";
} else {
delete entitySummaryContainer.dataset.loading;
}
if (entitySpinner) {
entitySpinner.hidden = !isLoading;
entitySpinner.setAttribute("aria-hidden", isLoading ? "false" : "true");
}
if (entitiesRefreshButton) {
const disabled = isLoading || !entitiesEnabled;
entitiesRefreshButton.disabled = disabled;
entitiesRefreshButton.setAttribute("aria-disabled", disabled ? "true" : "false");
}
if (entityStatsNode && isLoading) {
entityStatsNode.textContent = "Updating entity analysis…";
}
};
function triggerEntitiesRefresh(force = false) {
if (!entitiesEnabled) {
return;
}
if (force) {
hasTriggeredEntitiesRefresh = true;
performEntitiesRefresh(true);
return;
}
if (!hasTriggeredEntitiesRefresh) {
hasTriggeredEntitiesRefresh = true;
performEntitiesRefresh(true);
}
}
function activateEntityTab(panelKey) {
if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) {
void flushManualOverrides();
}
tabButtons.forEach((button) => {
const isActive = button.dataset.panel === panelKey;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
});
tabPanels.forEach((panel, key) => {
if (!panel) return;
const isActive = key === panelKey;
panel.classList.toggle("is-active", isActive);
panel.hidden = !isActive;
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
});
activeEntityPanel = panelKey;
if (panelKey === "entities") {
triggerEntitiesRefresh();
}
}
function populateVoiceOptions(select, selectedVoice) {
if (!select) return;
const narratorLabel = baseVoice ? `Use narrator voice (${baseVoice})` : "Use narrator voice";
select.innerHTML = "";
const narratorOption = document.createElement("option");
narratorOption.value = "";
narratorOption.textContent = narratorLabel;
if (!selectedVoice) {
narratorOption.selected = true;
}
select.appendChild(narratorOption);
voiceCatalog.forEach((voice) => {
const option = document.createElement("option");
option.value = voice.id;
option.textContent = `${voice.display_name} · ${voice.language_label} · ${voice.gender}`;
if (selectedVoice === voice.id) {
option.selected = true;
}
select.appendChild(option);
});
if (selectedVoice) {
const hasMatch = Array.from(select.options).some((option) => option.value === selectedVoice);
if (!hasMatch) {
const fallback = document.createElement("option");
fallback.value = selectedVoice;
fallback.textContent = selectedVoice;
fallback.selected = true;
select.appendChild(fallback);
}
}
}
function renderEntitySummary() {
const summary = entityState.summary || {};
const stats = summary.stats || {};
const errors = Array.isArray(summary.errors) ? summary.errors : [];
const peopleEntries = Array.isArray(summary.people) ? summary.people : [];
const entityEntries = Array.isArray(summary.entities) ? summary.entities : [];
const overrideLookup = buildOverrideLookup();
const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0;
const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0;
const entityKindSelection = (entityState.filters?.entitiesKind || "all").toLowerCase();
const ensureSelectValue = (node, value) => {
if (!node) return;
const target = String(value);
if (node.value !== target) {
node.value = target;
}
};
ensureSelectValue(peopleFilterNode, peopleThreshold);
ensureSelectValue(entitiesFilterNode, entityThreshold);
ensureSelectValue(entitiesKindFilterNode, entityKindSelection || "all");
if (entitiesKindFilterNode) {
const seenKinds = new Set();
const existingValue = entitiesKindFilterNode.value || "all";
const options = Array.from(entityEntries || [])
.map((entry) => String(entry?.kind || ""))
.filter((kind) => kind && kind !== "PERSON");
entitiesKindFilterNode.innerHTML = "";
const addOption = (value, label) => {
if (seenKinds.has(value)) return;
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
entitiesKindFilterNode.appendChild(opt);
seenKinds.add(value);
};
addOption("all", "All");
addOption("proper_noun", "Proper nouns");
options.forEach((kind) => {
const normalized = kind.toLowerCase();
addOption(normalized, formatEntityKindLabel(kind));
});
if (!seenKinds.has(existingValue)) {
entityState.filters.entitiesKind = "all";
}
ensureSelectValue(entitiesKindFilterNode, entityState.filters.entitiesKind || "all");
}
const renderDisabled = (listNode, message) => {
if (!listNode) return;
listNode.innerHTML = "";
const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = message;
listNode.appendChild(emptyItem);
};
if (!entitiesEnabled) {
const disabledMessage = "Entity recognition is turned off in Settings.";
if (entityStatsNode) {
entityStatsNode.textContent = disabledMessage;
}
if (peopleStatsNode) {
peopleStatsNode.textContent = disabledMessage;
}
if (peopleFilterNode) {
peopleFilterNode.disabled = true;
}
if (entitiesFilterNode) {
entitiesFilterNode.disabled = true;
}
if (entitiesKindFilterNode) {
entitiesKindFilterNode.disabled = true;
entitiesKindFilterNode.value = "all";
}
renderDisabled(peopleListNode, disabledMessage);
renderDisabled(entityListNode, "Enable entity recognition to populate detected entities.");
return;
}
if (peopleFilterNode) {
peopleFilterNode.disabled = false;
}
if (entitiesFilterNode) {
entitiesFilterNode.disabled = false;
}
if (entitiesKindFilterNode) {
entitiesKindFilterNode.disabled = false;
}
if (entityStatsNode) {
if (errors.length) {
entityStatsNode.textContent = errors.join(" · ");
} else if (stats.processed) {
const parts = [];
if (typeof stats.chapters === "number") {
parts.push(`${stats.chapters} chapter${stats.chapters === 1 ? "" : "s"}`);
}
if (typeof stats.tokens === "number") {
parts.push(`${stats.tokens.toLocaleString()} tokens processed`);
}
if (typeof stats.people === "number") {
parts.push(`${stats.people} character${stats.people === 1 ? "" : "s"}`);
}
if (typeof stats.entities === "number") {
parts.push(`${stats.entities} ${stats.entities === 1 ? "entity" : "entities"}`);
}
entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date.";
} else {
entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters.";
}
}
const renderGroup = (listNode, entries, threshold, options) => {
if (!listNode) {
return { visible: 0, total: entries.length };
}
listNode.innerHTML = "";
let filtered = entries.filter((entry) => Number(entry?.count || 0) >= threshold);
if (options.kindFilter) {
filtered = filtered.filter((entry) => options.kindFilter(entry));
}
if (!filtered.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = entries.length ? options.filteredEmptyText : options.emptyText;
listNode.appendChild(emptyItem);
return { visible: 0, total: entries.length };
}
filtered.forEach((entity) => {
const item = cloneTemplate(entityRowTemplate);
if (!item) return;
const normalized = entity.normalized || entity.label || entity.token || "";
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
item.dataset.entityId = entity.id || normalized || tokenLabel;
item.dataset.entityCategory = options.groupKey;
item.dataset.normalized = normalized.toLowerCase();
if (entity.kind) {
item.dataset.entityKind = entity.kind;
}
const labelEl = item.querySelector('[data-role="entity-label"]');
if (labelEl) {
labelEl.textContent = tokenLabel;
}
const kindEl = item.querySelector('[data-role="entity-kind"]');
if (kindEl) {
const kind = entity.kind || entity.category || "";
if (options.hideKind || !kind) {
kindEl.textContent = "";
kindEl.hidden = true;
} else {
kindEl.hidden = false;
kindEl.textContent = formatEntityKindLabel(kind);
}
}
const countEl = item.querySelector('[data-role="entity-count"]');
if (countEl) {
countEl.textContent = formatMentions(entity.count);
}
const samplesContainer = item.querySelector('[data-role="entity-samples"]');
if (samplesContainer) {
samplesContainer.innerHTML = "";
const samples = Array.isArray(entity.samples) ? entity.samples : [];
if (!samples.length) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "No sample sentences captured yet.";
samplesContainer.appendChild(hint);
} else {
const list = document.createElement("ul");
list.className = "entity-summary__samples-list";
samples.slice(0, 3).forEach((sample) => {
const text = typeof sample === "string" ? sample : sample?.excerpt;
if (!text) return;
const entry = document.createElement("li");
entry.textContent = text;
list.appendChild(entry);
});
samplesContainer.appendChild(list);
}
}
const normalizedKey = canonicalizeEntityKey(normalized || tokenLabel);
const overrideMeta = normalizedKey ? overrideLookup.get(normalizedKey) : null;
const overrideButton = item.querySelector('[data-role="entity-add-override"]');
if (overrideButton) {
overrideButton.dataset.entityToken = entity.label || entity.token || "";
overrideButton.dataset.entityNormalized = normalized;
overrideButton.dataset.entityCategory = options.groupKey;
overrideButton.dataset.entityCount = String(entity.count || 0);
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string"
? entity.samples[0]
: entity.samples[0]?.excerpt || ""
: "";
if (sampleContext) {
overrideButton.dataset.entityContext = sampleContext;
}
if (entity.kind) {
overrideButton.dataset.entityKind = entity.kind;
}
overrideButton.textContent = overrideMeta
? overrideMeta.origin === "manual"
? "Edit manual override"
: "Edit pronunciation override"
: "Add manual override";
if (overrideMeta) {
item.dataset.hasOverride = "true";
} else {
delete item.dataset.hasOverride;
}
}
const inlineOverride = item.querySelector('[data-role="inline-override"]');
if (inlineOverride) {
const override = overrideMeta;
if (override) {
inlineOverride.hidden = false;
item.dataset.hasOverride = "true";
item.dataset.overrideId = override.id || override.normalized || override.token || "";
if (override.origin) {
item.dataset.overrideSource = override.origin;
} else {
delete item.dataset.overrideSource;
}
inlineOverride.dataset.pendingToken = override.token || tokenLabel;
inlineOverride.dataset.pendingNormalized = override.normalized || normalized;
inlineOverride.dataset.pendingContext = override.context || inlineOverride.dataset.pendingContext || "";
inlineOverride.dataset.pendingCategory = options.groupKey;
inlineOverride.dataset.pendingKind = entity.kind || "";
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
if (pronInput) {
pronInput.value = override.pronunciation || "";
pronInput.placeholder = override.token || tokenLabel;
}
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
if (voiceSelect) {
populateVoiceOptions(voiceSelect, override.voice || "");
}
const previewButton = inlineOverride.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
const previewValue = joinPossessivePreviewSamples(override.pronunciation, override.token || tokenLabel);
previewButton.dataset.previewText = previewValue || override.token || tokenLabel;
previewButton.dataset.voice = override.voice || override.voice_profile || baseVoice || "";
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
}
if (
highlightMode === "inline" &&
highlightedOverrideId &&
(override.id === highlightedOverrideId || override.normalized === highlightedOverrideId || override.token === highlightedOverrideId)
) {
item.classList.add("is-highlighted");
inlineOverride.hidden = false;
setTimeout(() => item.classList.remove("is-highlighted"), 2200);
highlightedOverrideId = "";
}
} else {
inlineOverride.hidden = true;
delete item.dataset.overrideId;
delete item.dataset.overrideSource;
}
}
const previewButton = item.querySelector('[data-entity-preview="true"]');
if (previewButton) {
const previewVoice = overrideMeta?.voice || baseVoice || "";
const previewText = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string"
? entity.samples[0]
: entity.samples[0]?.excerpt || tokenLabel
: tokenLabel;
previewButton.hidden = !previewText;
if (previewText) {
previewButton.dataset.previewText = previewText;
previewButton.dataset.voice = previewVoice;
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
previewButton.dataset.previewSource = "entity";
}
}
listNode.appendChild(item);
});
return { visible: filtered.length, total: entries.length };
};
const peopleRender = renderGroup(peopleListNode, peopleEntries, peopleThreshold, {
groupKey: "people",
hideKind: true,
emptyText: "No characters detected yet.",
filteredEmptyText: "No characters match the selected mention filter.",
});
if (peopleFilterNode && !peopleEntries.length) {
peopleFilterNode.value = "0";
}
if (peopleStatsNode) {
if (errors.length) {
peopleStatsNode.textContent = errors.join(" · ");
} else if (!peopleEntries.length) {
peopleStatsNode.textContent = "No characters detected yet.";
} else if (!peopleRender.visible) {
peopleStatsNode.textContent = "Adjust the mention filter to see additional characters.";
} else {
let label = "all mentions";
if (peopleThreshold > 1) {
label = `${peopleThreshold}+ mentions`;
} else if (peopleThreshold === 1) {
label = "1+ mention";
}
peopleStatsNode.textContent = `Showing ${peopleRender.visible} of ${peopleRender.total} characters (${label}).`;
}
}
const entitiesRender = renderGroup(entityListNode, entityEntries, entityThreshold, {
groupKey: "entities",
hideKind: false,
emptyText: "No entities detected yet.",
filteredEmptyText: "No entities match the selected mention filter.",
kindFilter: (entry) => {
if (!entityKindSelection || entityKindSelection === "all") {
return true;
}
const kind = (entry.kind || "").toLowerCase();
if (entityKindSelection === "proper_noun") {
return !kind || kind === "propn" || kind === "noun" || kind === "proper_noun";
}
return kind === entityKindSelection;
},
});
if (entitiesFilterNode && !entityEntries.length) {
entitiesFilterNode.value = "0";
}
if (
entityStatsNode &&
!errors.length &&
stats.processed &&
typeof stats.entities === "number" &&
entityThreshold > 0 &&
stats.entities > entitiesRender.visible
) {
const filterLabel = entityThreshold > 1 ? `${entityThreshold}+ mentions` : "1+ mention";
entityStatsNode.textContent += ` · Filter hiding ${stats.entities - entitiesRender.visible} entries (${filterLabel})`;
}
}
function renderManualOverrides() {
if (!manualOverrideList) return;
manualOverrideList.innerHTML = "";
const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : [];
if (!overrides.length) {
if (manualOverridesEmpty) {
manualOverridesEmpty.hidden = false;
}
return;
}
if (manualOverridesEmpty) {
manualOverridesEmpty.hidden = true;
}
overrides.forEach((override) => {
const node = cloneTemplate(manualOverrideTemplate);
if (!node) return;
const overrideId = override.id || override.normalized || override.token || "";
node.dataset.overrideId = overrideId;
node.dataset.token = override.token || "";
node.dataset.normalized = override.normalized || "";
node.dataset.context = override.context || "";
const labelEl = node.querySelector('[data-role="override-label"]');
if (labelEl) {
labelEl.textContent = override.token || override.normalized || "Manual override";
}
const notesEl = node.querySelector('[data-role="override-notes"]');
if (notesEl) {
const notes = override.notes || override.context || "";
if (notes) {
notesEl.textContent = notes;
} else {
notesEl.hidden = true;
}
}
const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]');
if (pronInput) {
pronInput.value = override.pronunciation || "";
pronInput.placeholder = override.token || override.normalized || "";
pronInput.dataset.overrideId = overrideId;
}
const voiceSelect = node.querySelector('[data-role="manual-override-voice"]');
if (voiceSelect) {
populateVoiceOptions(voiceSelect, override.voice || "");
voiceSelect.dataset.overrideId = overrideId;
}
const previewButton = node.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
const previewVoice = override.voice || voiceSelect?.value || baseVoice || "";
const previewText = joinPossessivePreviewSamples(override.pronunciation, override.token || override.normalized || "");
previewButton.dataset.overrideId = overrideId;
previewButton.dataset.previewText = previewText || override.pronunciation || override.token || "";
previewButton.dataset.voice = previewVoice;
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
}
const deleteButton = node.querySelector('[data-role="manual-override-delete"]');
if (deleteButton) {
deleteButton.dataset.overrideId = overrideId;
}
const metaEl = node.querySelector('[data-role="manual-override-meta"]');
if (metaEl) {
const parts = [];
if (override.source) {
parts.push(`Source: ${override.source}`);
}
if (override.updated_at) {
const timestamp = Number(override.updated_at) * 1000;
if (!Number.isNaN(timestamp)) {
parts.push(`Updated ${new Date(timestamp).toLocaleString()}`);
}
}
metaEl.textContent = parts.join(" · ");
}
manualOverrideList.appendChild(node);
if (highlightedOverrideId && highlightedOverrideId === overrideId) {
node.classList.add("is-highlighted");
setTimeout(() => node.classList.remove("is-highlighted"), 2400);
node.scrollIntoView({ behavior: "smooth", block: "center" });
highlightedOverrideId = "";
}
});
}
const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const resolveHeteronymTooltip = (entry) => {
if (!entry || typeof entry !== "object") return "";
const options = Array.isArray(entry.options) ? entry.options : [];
if (options.length < 2) return "";
const parts = [];
options.slice(0, 2).forEach((opt) => {
const label = String(opt?.label || "").trim();
const example = String(opt?.example_sentence || "").trim();
if (label && example) {
parts.push(`${label}: ${example}`);
} else if (example) {
parts.push(example);
}
});
return parts.join("\n");
};
const fillHighlightedSentence = (container, sentence, token, tooltip) => {
if (!container) return;
container.textContent = "";
const rawSentence = String(sentence || "");
const rawToken = String(token || "");
if (!rawSentence) {
return;
}
if (!rawToken) {
container.textContent = rawSentence;
return;
}
const pattern = new RegExp(`\\b${escapeRegExp(rawToken)}\\b`, "i");
const match = pattern.exec(rawSentence);
if (!match) {
container.textContent = rawSentence;
return;
}
const before = rawSentence.slice(0, match.index);
const hit = rawSentence.slice(match.index, match.index + match[0].length);
const after = rawSentence.slice(match.index + match[0].length);
if (before) {
container.appendChild(document.createTextNode(before));
}
const chip = document.createElement("span");
chip.className = "chip";
chip.textContent = hit;
if (tooltip) {
chip.title = tooltip;
}
container.appendChild(chip);
if (after) {
container.appendChild(document.createTextNode(after));
}
};
function renderHeteronymOverrides() {
if (!heteronymOverrideList) return;
heteronymOverrideList.innerHTML = "";
const entries = Array.isArray(entityState.heteronymOverrides) ? entityState.heteronymOverrides : [];
if (!entries.length) {
if (heteronymOverridesEmpty) {
heteronymOverridesEmpty.hidden = false;
}
return;
}
if (heteronymOverridesEmpty) {
heteronymOverridesEmpty.hidden = true;
}
entries.forEach((entry) => {
if (!entry || typeof entry !== "object") return;
const entryId = String(entry.entry_id || entry.id || "").trim();
if (!entryId) return;
const sentence = String(entry.sentence || "").trim();
if (!sentence) return;
const token = String(entry.token || "").trim();
const node = cloneTemplate(heteronymOverrideTemplate);
if (!node) return;
node.dataset.entryId = entryId;
const sentenceEl = node.querySelector('[data-role="heteronym-sentence"]');
if (sentenceEl) {
const tooltip = resolveHeteronymTooltip(entry);
fillHighlightedSentence(sentenceEl, sentence, token, tooltip);
}
const notesEl = node.querySelector('[data-role="heteronym-notes"]');
if (notesEl) {
const suggested = String(entry.default_choice || "").trim();
if (suggested) {
const options = Array.isArray(entry.options) ? entry.options : [];
const match = options.find((opt) => String(opt?.key || "").trim() === suggested);
const label = String(match?.label || "").trim();
notesEl.textContent = label ? `Suggested: ${label}` : "";
notesEl.hidden = !notesEl.textContent;
} else {
notesEl.hidden = true;
}
}
const optionsContainer = node.querySelector('[data-role="heteronym-options"]');
if (optionsContainer) {
optionsContainer.innerHTML = "";
const options = Array.isArray(entry.options) ? entry.options : [];
const selectedKey = String(entry.choice || entry.default_choice || "").trim();
options.slice(0, 2).forEach((opt, index) => {
if (!opt || typeof opt !== "object") return;
const key = String(opt.key || "").trim();
const label = String(opt.label || "Option").trim();
const previewSentence = String(opt.replacement_sentence || sentence).trim();
if (!key) return;
const wrapper = document.createElement("div");
wrapper.className = "entity-inline-override__actions";
const choiceLabel = document.createElement("label");
choiceLabel.className = "toggle-pill";
const input = document.createElement("input");
input.type = "radio";
input.name = `heteronym-${entryId}-choice`;
input.value = key;
if (selectedKey) {
input.checked = selectedKey === key;
} else {
input.checked = index === 0;
}
const span = document.createElement("span");
span.textContent = label;
choiceLabel.appendChild(input);
choiceLabel.appendChild(span);
const previewButton = document.createElement("button");
previewButton.type = "button";
previewButton.className = "button button--ghost button--small";
previewButton.dataset.role = "speaker-preview";
previewButton.dataset.previewText = previewSentence;
previewButton.dataset.voice = baseVoice || "";
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
previewButton.textContent = "Preview";
previewButton.disabled = !previewButton.dataset.voice;
previewButton.setAttribute("aria-disabled", previewButton.disabled ? "true" : "false");
wrapper.appendChild(choiceLabel);
wrapper.appendChild(previewButton);
optionsContainer.appendChild(wrapper);
});
}
heteronymOverrideList.appendChild(node);
});
}
function applyEntityPayload(payload, options = {}) {
if (payload && typeof payload === "object") {
if (payload.summary) {
entityState.summary = payload.summary;
}
if (Array.isArray(payload.manual_overrides)) {
entityState.manualOverrides = payload.manual_overrides;
}
if (Array.isArray(payload.pronunciation_overrides)) {
entityState.pronunciationOverrides = payload.pronunciation_overrides;
}
if (Array.isArray(payload.heteronym_overrides)) {
entityState.heteronymOverrides = payload.heteronym_overrides;
}
if (typeof payload.cache_key === "string") {
entityState.cacheKey = payload.cache_key;
}
if (Object.prototype.hasOwnProperty.call(payload, "recognition_enabled")) {
entitiesEnabled = payload.recognition_enabled !== false;
form.dataset.entitiesEnabled = entitiesEnabled ? "true" : "false";
if (entitiesRefreshButton) {
entitiesRefreshButton.disabled = !entitiesEnabled;
entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true");
}
}
}
if (options.highlightId) {
highlightedOverrideId = options.highlightId;
}
renderEntitySummary();
renderManualOverrides();
renderHeteronymOverrides();
}
const filterVoicesByGender = (voices, genderHint) => {
const normalized = (genderHint || "unknown").toLowerCase();
if (normalized === "female") {
return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "f");
}
if (normalized === "male") {
return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "m");
}
if (normalized === "either") {
return voices.filter((voice) => {
const code = (voice.gender_code || "").toLowerCase();
return code === "f" || code === "m";
});
}
return voices.slice();
};
const pickRandomVoiceForOverride = (genderHint) => {
if (!Array.isArray(voiceCatalog) || !voiceCatalog.length) {
return baseVoice || "";
}
const normalizedLanguage = (languageCode || "").trim().toLowerCase();
const languageCandidates = () => {
const keys = [];
if (normalizedLanguage) keys.push(normalizedLanguage);
if (languageCode && languageCode !== normalizedLanguage) {
keys.push(languageCode);
}
for (const key of keys) {
const mapped = languageMap?.[key];
if (Array.isArray(mapped) && mapped.length) {
const lookup = new Set(mapped);
const matches = voiceCatalog.filter((voice) => lookup.has(voice.id));
if (matches.length) {
return matches;
}
}
}
const direct = voiceCatalog.filter((voice) => (voice.language || "").toLowerCase() === normalizedLanguage);
return direct;
};
const preferred = languageCandidates();
let pool = filterVoicesByGender(preferred.length ? preferred : voiceCatalog, genderHint);
if (!pool.length) {
pool = voiceCatalog.slice();
}
if (!pool.length) {
return baseVoice || "";
}
const selected = pool[Math.floor(Math.random() * pool.length)];
return selected?.id || baseVoice || "";
};
const resolveOverrideVoice = (data) => {
const hasVoiceProp = Boolean(data && Object.prototype.hasOwnProperty.call(data, "voice"));
const voiceValueRaw = typeof data?.voice === "string" ? data.voice : "";
const voiceValue = voiceValueRaw.trim();
if (voiceValue) {
return voiceValue;
}
if (hasVoiceProp) {
return "";
}
const normalizedKey = canonicalizeEntityKey(data.normalized || data.token || "");
let genderHint = (data.gender || "").toLowerCase();
if (normalizedKey) {
const speakerHint = speakerHints.get(normalizedKey);
if (speakerHint) {
if (speakerHint.voice) {
return speakerHint.voice;
}
if (!genderHint || genderHint === "unknown") {
genderHint = speakerHint.gender || genderHint;
}
}
}
if (!genderHint || genderHint === "unknown") {
if (data.category === "people" || data.kind === "PERSON") {
genderHint = "either";
} else {
genderHint = "";
}
}
if (baseVoice) {
return "";
}
return pickRandomVoiceForOverride(genderHint);
};
function collectOverridePayload(overrideId) {
if (!overrideId || !manualOverrideList) return null;
const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&");
const node = manualOverrideList.querySelector(`[data-override-id="${selectorId}"]`);
if (!node) return null;
const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]');
const voiceSelect = node.querySelector('[data-role="manual-override-voice"]');
return {
id: overrideId,
token: node.dataset.token || "",
normalized: node.dataset.normalized || "",
pronunciation: pronInput?.value?.trim() || "",
voice: voiceSelect?.value || "",
notes: "",
context: node.dataset.context || "",
};
}
async function saveManualOverride(overrideId) {
const payload = collectOverridePayload(overrideId);
if (!payload || !manualUpsertUrl) return false;
try {
const response = await fetch(manualUpsertUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Override save failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data, { highlightId: data.override?.id || payload.id });
return true;
} catch (error) {
console.error("Failed to save manual override", error);
return false;
}
}
markOverrideDirty = (overrideId) => {
if (!overrideId) return;
dirtyOverrideIds.add(overrideId);
setManualOverrideStatus("Unsaved changes", "pending");
};
flushManualOverrides = (options = {}) => {
const { overrideId } = options || {};
const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds);
const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id));
if (!targetIds.length) return null;
setManualOverrideStatus("Saving overrides…", "pending");
targetIds.forEach((id) => dirtyOverrideIds.delete(id));
const runFlush = async () => {
if (overrideFlushPromise) {
try {
await overrideFlushPromise;
} catch (error) {
console.warn("Previous override flush failed", error);
}
}
const results = await Promise.all(targetIds.map((id) => saveManualOverride(id)));
let hasFailure = false;
results.forEach((ok, index) => {
if (!ok) {
hasFailure = true;
dirtyOverrideIds.add(targetIds[index]);
}
});
if (hasFailure) {
setManualOverrideStatus("Some overrides failed to save.", "error");
} else {
setManualOverrideStatus("Overrides saved.", "success");
}
};
overrideFlushPromise = runFlush()
.catch((error) => {
console.error("Failed flushing manual overrides", error);
setManualOverrideStatus("Failed to save overrides.", "error");
})
.finally(() => {
overrideFlushPromise = null;
});
return overrideFlushPromise;
};
async function deleteManualOverride(overrideId) {
if (!overrideId || !manualDeleteUrlTemplate) return;
dirtyOverrideIds.delete(overrideId);
const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId));
try {
const response = await fetch(targetUrl, { method: "DELETE" });
if (!response.ok) {
throw new Error(`Override delete failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data);
} catch (error) {
console.error("Failed to delete manual override", error);
}
}
async function performEntitiesRefresh(force = false) {
if (!entitiesUrl) return;
setEntitiesLoading(true);
try {
const url = new URL(entitiesUrl, window.location.origin);
if (force) {
url.searchParams.set("refresh", "1");
}
if (entityState.cacheKey) {
url.searchParams.set("cache_key", entityState.cacheKey);
}
const response = await fetch(url.toString(), { method: "GET" });
if (!response.ok) {
throw new Error(`Entity refresh failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data);
} catch (error) {
console.error("Failed to refresh entity summary", error);
} finally {
setEntitiesLoading(false);
}
}
async function performManualOverrideSearch(query) {
if (!manualSearchUrl || !manualOverrideResultsList) return;
manualOverrideResultsList.innerHTML = "";
try {
const url = new URL(manualSearchUrl, window.location.origin);
if (query) {
url.searchParams.set("q", query);
}
const response = await fetch(url.toString(), { method: "GET" });
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
const data = await response.json();
const results = Array.isArray(data.results) ? data.results : [];
if (!results.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "manual-overrides__results-empty";
emptyItem.textContent = query ? "No matches found." : "Start typing to search tokens.";
manualOverrideResultsList.appendChild(emptyItem);
return;
}
results.forEach((entry) => {
const item = document.createElement("li");
item.className = "manual-overrides__result";
const button = document.createElement("button");
button.type = "button";
button.className = "button button--ghost button--small";
button.dataset.role = "manual-override-result";
button.dataset.token = entry.token || entry.normalized || "";
if (entry.normalized) {
button.dataset.normalized = entry.normalized;
}
if (entry.context) {
button.dataset.context = entry.context;
} else if (Array.isArray(entry.samples) && entry.samples.length) {
const sample = entry.samples[0];
button.dataset.context = typeof sample === "string" ? sample : sample?.excerpt || "";
}
if (entry.pronunciation) {
button.dataset.pronunciation = entry.pronunciation;
}
if (entry.voice) {
button.dataset.voice = entry.voice;
}
button.dataset.source = entry.source || "search";
button.textContent = entry.token || entry.normalized || "Unnamed token";
item.appendChild(button);
manualOverrideResultsList.appendChild(item);
});
} catch (error) {
console.error("Failed to search manual override candidates", error);
}
}
async function createOverrideFromData(data) {
if (!data || !manualUpsertUrl) return false;
const token = (data.token || "").trim();
if (!token) return false;
const normalized = (data.normalized || "").trim();
const voiceChoice = resolveOverrideVoice({ ...data, token, normalized });
const payload = {
token,
normalized,
context: data.context || "",
pronunciation: data.pronunciation || "",
voice: voiceChoice || "",
source: data.source || "manual",
};
try {
const response = await fetch(manualUpsertUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Override creation failed with status ${response.status}`);
}
const body = await response.json();
const highlighted = body.override?.id || payload.normalized || payload.token;
applyEntityPayload(body, { highlightId: highlighted });
if (highlightMode === "inline") {
highlightedOverrideId = highlighted;
const targetPanel = activeEntityPanel || "entities";
activateEntityTab(targetPanel);
} else {
activateEntityTab("manual");
if (manualOverrideResultsList) {
manualOverrideResultsList.innerHTML = "";
}
}
return true;
} catch (error) {
console.error("Failed to create manual override", error);
return false;
}
}
tabButtons.forEach((button) => {
button.addEventListener("click", () => {
highlightMode = button.dataset.panel === "manual" ? "manual" : "entities";
activateEntityTab(button.dataset.panel || "people");
});
});
if (entitiesRefreshButton) {
entitiesRefreshButton.addEventListener("click", () => {
void flushManualOverrides();
triggerEntitiesRefresh(true);
});
}
const extractInlineOverrideState = (inlineOverride, row) => {
if (!inlineOverride || inlineOverride.hidden) {
return null;
}
const container = row || inlineOverride.closest('[data-role="entity-row"]');
if (!container) {
return null;
}
const tokenLabel = inlineOverride.dataset.pendingToken || container.querySelector('[data-role="entity-label"]')?.textContent || "";
if (!tokenLabel) {
return null;
}
const normalized = (inlineOverride.dataset.pendingNormalized || container.dataset.normalized || tokenLabel || "").trim();
const context = (inlineOverride.dataset.pendingContext || "").trim();
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
const pronunciationRaw = pronInput?.value ?? "";
const pronunciation = pronunciationRaw.trim();
const voiceRaw = voiceSelect?.value ?? "";
const voice = voiceRaw;
const previewVoice = voice || baseVoice || "";
const previewText = pronunciation || tokenLabel;
return {
token: tokenLabel,
normalized,
context,
pronunciation,
voice,
previewVoice,
previewText,
category: inlineOverride.dataset.pendingCategory || container.dataset.entityCategory || "",
kind: inlineOverride.dataset.pendingKind || container.dataset.entityKind || "",
};
};
const syncInlinePreviewUI = (row, inlineOverride, state, targetButton) => {
if (!state) {
return;
}
const previewText = state.previewText || state.token;
const previewVoice = state.previewVoice || baseVoice || "";
const applyDatasets = (button) => {
if (!button) return;
const previewValue = joinPossessivePreviewSamples(previewText, state.token);
button.dataset.previewText = previewValue || previewText || state.token || "";
button.dataset.voice = previewVoice;
button.dataset.language = languageCode;
button.dataset.speed = defaultSpeed;
button.dataset.useGpu = useGpuDefault;
if (!button.dataset.previewSource || button.dataset.previewSource === "entity") {
button.dataset.previewSource = "manual";
}
};
if (targetButton) {
applyDatasets(targetButton);
return;
}
if (inlineOverride) {
applyDatasets(inlineOverride.querySelector('[data-role="speaker-preview"]'));
}
if (row) {
applyDatasets(row.querySelector('[data-entity-preview="true"]'));
}
};
const persistInlineOverrideState = (state) => {
if (!state) {
return null;
}
highlightMode = "inline";
setManualOverrideStatus("Saving overrides…", "pending");
return createOverrideFromData({
token: state.token,
normalized: state.normalized,
context: state.context,
pronunciation: state.pronunciation,
voice: state.voice,
category: state.category,
kind: state.kind,
source: "entity-inline",
})
.then((ok) => {
if (ok) {
setManualOverrideStatus("Overrides saved.", "success");
} else {
setManualOverrideStatus("Failed to save overrides.", "error");
}
return ok;
})
.catch((error) => {
setManualOverrideStatus("Failed to save overrides.", "error");
throw error;
});
};
const handleEntityListClick = (event) => {
const addTrigger = event.target.closest('[data-role="entity-add-override"]');
if (addTrigger) {
event.preventDefault();
const row = addTrigger.closest('[data-role="entity-row"]');
if (!row) {
return;
}
const inlineOverride = row.querySelector('[data-role="inline-override"]');
if (!inlineOverride) {
highlightMode = "manual";
createOverrideFromData({
token: addTrigger.dataset.entityToken || "",
normalized: addTrigger.dataset.entityNormalized || "",
context: addTrigger.dataset.entityContext || "",
category: addTrigger.dataset.entityCategory || "",
kind: addTrigger.dataset.entityKind || "",
source: "entity",
});
return;
}
inlineOverride.hidden = false;
inlineOverride.setAttribute("aria-hidden", "false");
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
const previewButton = inlineOverride.querySelector('[data-role="speaker-preview"]');
const token = addTrigger.dataset.entityToken || row.querySelector('[data-role="entity-label"]')?.textContent || "";
const normalized = addTrigger.dataset.entityNormalized || row.dataset.normalized || token;
const context = addTrigger.dataset.entityContext || "";
if (!row.dataset.hasOverride) {
if (pronInput) {
pronInput.value = "";
}
if (voiceSelect) {
populateVoiceOptions(voiceSelect, "");
}
}
inlineOverride.dataset.pendingToken = token;
inlineOverride.dataset.pendingNormalized = normalized;
inlineOverride.dataset.pendingContext = context;
inlineOverride.dataset.pendingCategory = addTrigger.dataset.entityCategory || row.dataset.entityCategory || "";
inlineOverride.dataset.pendingKind = addTrigger.dataset.entityKind || row.dataset.entityKind || "";
const inlineState = extractInlineOverrideState(inlineOverride, row);
syncInlinePreviewUI(row, inlineOverride, inlineState, previewButton);
highlightMode = "inline";
highlightedOverrideId = row.dataset.overrideId || "";
pronInput?.focus({ preventScroll: true });
return;
}
const saveTrigger = event.target.closest('[data-role="inline-override-save"]');
if (saveTrigger) {
event.preventDefault();
const inlineOverride = saveTrigger.closest('[data-role="inline-override"]');
const row = saveTrigger.closest('[data-role="entity-row"]');
if (!inlineOverride || !row) {
return;
}
const inlineState = extractInlineOverrideState(inlineOverride, row);
syncInlinePreviewUI(row, inlineOverride, inlineState);
void persistInlineOverrideState(inlineState);
return;
}
const removeTrigger = event.target.closest('[data-role="inline-override-remove"]');
if (removeTrigger) {
event.preventDefault();
const row = removeTrigger.closest('[data-role="entity-row"]');
if (!row) {
return;
}
const overrideId = row.dataset.overrideId;
if (overrideId) {
deleteManualOverride(overrideId);
} else {
const inlineOverride = removeTrigger.closest('[data-role="inline-override"]');
if (inlineOverride) {
inlineOverride.hidden = true;
inlineOverride.setAttribute("aria-hidden", "true");
}
}
}
};
[peopleListNode, entityListNode].forEach((list) => {
if (!list) return;
list.addEventListener("click", handleEntityListClick);
});
if (manualOverrideSearchButton) {
manualOverrideSearchButton.addEventListener("click", () => {
const query = manualOverrideQueryInput?.value?.trim() || "";
performManualOverrideSearch(query);
});
}
if (manualOverrideQueryInput) {
manualOverrideQueryInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
performManualOverrideSearch(manualOverrideQueryInput.value.trim());
}
});
}
if (manualOverrideAddCustomButton) {
manualOverrideAddCustomButton.addEventListener("click", () => {
const token = window.prompt("Enter the token to override:");
if (!token) return;
highlightMode = "manual";
createOverrideFromData({ token: token.trim(), source: "manual" });
});
}
if (manualOverrideResultsList) {
manualOverrideResultsList.addEventListener("click", (event) => {
const resultButton = event.target.closest('[data-role="manual-override-result"]');
if (!resultButton) return;
event.preventDefault();
highlightMode = "manual";
createOverrideFromData({
token: resultButton.dataset.token || "",
normalized: resultButton.dataset.normalized || "",
context: resultButton.dataset.context || "",
pronunciation: resultButton.dataset.pronunciation || "",
voice: resultButton.dataset.voice || "",
source: resultButton.dataset.source || "search",
});
});
}
if (manualOverrideList) {
manualOverrideList.addEventListener("input", (event) => {
const input = event.target.closest('[data-role="manual-override-pronunciation"]');
if (!input) return;
const overrideId = input.dataset.overrideId || input.closest('[data-override-id]')?.dataset.overrideId;
const container = input.closest(".manual-override");
const previewButton = container?.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
const fallback = container?.dataset.token || input.placeholder || "";
const previewValue = joinPossessivePreviewSamples(input.value.trim(), fallback);
previewButton.dataset.previewText = previewValue || input.value.trim() || fallback;
}
if (overrideId) {
markOverrideDirty(overrideId);
}
});
manualOverrideList.addEventListener("change", (event) => {
const select = event.target.closest('[data-role="manual-override-voice"]');
if (!select) return;
const overrideId = select.dataset.overrideId || select.closest('[data-override-id]')?.dataset.overrideId;
const container = select.closest(".manual-override");
const previewButton = container?.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || "";
}
if (overrideId) {
markOverrideDirty(overrideId);
}
});
manualOverrideList.addEventListener("click", (event) => {
const previewButton = event.target.closest('[data-role="speaker-preview"]');
if (previewButton?.dataset.overrideId) {
void flushManualOverrides({ overrideId: previewButton.dataset.overrideId });
}
const deleteButton = event.target.closest('[data-role="manual-override-delete"]');
if (!deleteButton) return;
event.preventDefault();
const overrideId = deleteButton.dataset.overrideId;
if (!overrideId) return;
deleteManualOverride(overrideId);
});
}
const initialTab = tabButtons.find((button) => button.classList.contains("is-active"));
activateEntityTab(initialTab?.dataset.panel || "people");
renderEntitySummary();
renderManualOverrides();
renderHeteronymOverrides();
triggerEntitiesRefresh();
}
const handleDeferredSubmit = (event) => {
const hasDirty = dirtyOverrideIds.size > 0;
const hasPendingFlush = Boolean(overrideFlushPromise);
if (!hasDirty && !hasPendingFlush) return;
if (form.dataset.skipDirtyFlush === "true") {
delete form.dataset.skipDirtyFlush;
return;
}
event.preventDefault();
const submitter = event.submitter || null;
const resumeSubmission = () => {
form.dataset.skipDirtyFlush = "true";
if (typeof form.requestSubmit === "function") {
form.requestSubmit(submitter || undefined);
} else if (submitter && typeof submitter.click === "function") {
submitter.click();
} else if (typeof form.submit === "function") {
form.submit();
}
};
const flushPromise = overrideFlushPromise || flushManualOverrides();
if (!flushPromise) {
resumeSubmission();
return;
}
flushPromise.finally(resumeSubmission);
};
form.addEventListener("submit", handleDeferredSubmit);
form.addEventListener("click", (event) => {
const previewFromOverride = event.target.closest('[data-role="speaker-preview"]');
if (previewFromOverride) {
const entityRow = previewFromOverride.closest('[data-role="entity-row"]');
const inlineOverride = entityRow?.querySelector('[data-role="inline-override"]');
if (inlineOverride && !inlineOverride.hidden) {
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
syncInlinePreviewUI(entityRow, inlineOverride, inlineState, previewFromOverride);
void persistInlineOverrideState(inlineState);
}
}
if (previewFromOverride?.dataset.overrideId) {
void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId });
}
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 nextSampleButton = event.target.closest('[data-role="speaker-next-sample"]');
if (nextSampleButton) {
event.preventDefault();
const container = nextSampleButton.closest(".speaker-list__item");
if (!container) return;
const currentIndex = sampleIndexState.get(container) || 0;
setSpeakerSample(container, currentIndex + 1);
return;
}
const clearMixButton = event.target.closest('[data-role="clear-mix"]');
if (clearMixButton) {
event.preventDefault();
const container = clearMixButton.closest(".speaker-list__item");
applyFormulaToSpeaker(container, "");
return;
}
const generateButton = event.target.closest('[data-role="generate-voice"]');
if (generateButton) {
event.preventDefault();
const container = generateButton.closest(".speaker-list__item");
if (!container) return;
const genderInput = container.querySelector('[data-role="gender-input"]');
const genderValue = genderInput?.value || "unknown";
const mix = buildRandomMix(genderValue);
if (!mix) {
console.warn("No voices available to generate a mix for", genderValue);
return;
}
const formula = formatMix(normaliseMix(mix));
applyFormulaToSpeaker(container, formula);
return;
}
const modalTrigger = event.target.closest('[data-role="open-voice-browser"]');
if (modalTrigger) {
event.preventDefault();
const container = modalTrigger.closest(".speaker-list__item");
if (!container) return;
const sampleIndex = Number.parseInt(modalTrigger.dataset.sampleIndex || "0", 10);
openVoiceBrowser(container, Number.isNaN(sampleIndex) ? 0 : sampleIndex);
return;
}
const chip = event.target.closest('[data-role="recommended-voice"]');
if (!chip) return;
event.preventDefault();
const container = chip.closest(".speaker-list__item");
if (!container) return;
const select = container.querySelector('[data-role="speaker-voice"]');
if (!select) return;
select.value = chip.dataset.voice || "";
select.dispatchEvent(new Event("change", { bubbles: true }));
select.dataset.prevManual = select.value || "";
});
form.addEventListener("input", (event) => {
const inlineOverride = event.target.closest('[data-role="inline-override"]');
if (!inlineOverride || inlineOverride.hidden) {
return;
}
const entityRow = inlineOverride.closest('[data-role="entity-row"]');
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
syncInlinePreviewUI(entityRow, inlineOverride, inlineState);
if (inlineState) {
setManualOverrideStatus("Unsaved changes", "pending");
}
});
form.addEventListener("change", (event) => {
const inlineOverride = event.target.closest('[data-role="inline-override"]');
if (!inlineOverride || inlineOverride.hidden) {
return;
}
const entityRow = inlineOverride.closest('[data-role="entity-row"]');
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
syncInlinePreviewUI(entityRow, inlineOverride, inlineState);
if (inlineState) {
setManualOverrideStatus("Unsaved changes", "pending");
}
});
document.addEventListener("click", (event) => {
if (!form.contains(event.target)) {
hideGenderMenus();
}
});
};
window.AbogenPrepare = window.AbogenPrepare || {};
window.AbogenPrepare.init = initPrepare;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initPrepare());
} else {
initPrepare();
}