tts-voice-creator-clone-and.../static/js/utils.js
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

1155 lines
61 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.

// ── Utility ───────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
// ── Voice avatar colors ───────────────────────────────────────────────────
const _AVATAR_COLORS = ['#E57373','#F06292','#BA68C8','#9575CD','#7986CB',
'#64B5F6','#4DD0E1','#4DB6AC','#81C784','#FFB74D','#FF8A65','#A1887F'];
function avatarColor(id) {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xFFFFFF;
return _AVATAR_COLORS[Math.abs(h) % _AVATAR_COLORS.length];
}
let _toastTimer;
function toast(msg, type = '', ms = 6500) {
const el = $('toast');
if (!el) return;
el.textContent = msg;
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
el.className = 'show ' + type;
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.className = '', ms);
}
// In-app replacement for window.confirm() — native browser confirm dialogs
// show the page's raw URL/IP ("192.168.178.8:7890 says…") and can't be
// styled, which reads as broken/untrustworthy next to the rest of the UI.
// Returns a Promise<boolean> so call sites just `await confirmDialog(...)`
// instead of the synchronous window.confirm() return value.
function confirmDialog(message, opts = {}) {
return new Promise(resolve => {
const ov = document.createElement('div');
ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-alert-circle-outline"></span> ${escHtml(opts.title || 'Are you sure?')}</div>
<div class="audiobook-msg">${escHtml(message || '')}</div>
<div class="audiobook-actions">
<button type="button" class="btn-secondary btn-sm" id="cd-cancel">${escHtml(opts.cancelLabel || 'Cancel')}</button>
<button type="button" class="btn-primary btn-sm${opts.danger ? ' btn-danger' : ''}" id="cd-ok">${escHtml(opts.okLabel || 'OK')}</button>
</div>
</div>`;
document.body.appendChild(ov);
const cleanup = (result) => {
ov.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
};
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); cleanup(false); }
else if (e.key === 'Enter') { e.preventDefault(); cleanup(true); }
};
document.addEventListener('keydown', onKey);
ov.querySelector('#cd-cancel').addEventListener('click', () => cleanup(false));
ov.querySelector('#cd-ok').addEventListener('click', () => cleanup(true));
ov.addEventListener('click', e => { if (e.target === ov) cleanup(false); });
ov.querySelector('#cd-ok').focus();
});
}
window.confirmDialog = confirmDialog;
function status(msg) {
ensureStatusBar();
const el = $('status-message') || $('status-bar');
if (el) el.textContent = msg;
}
function ensureStatusBar() {
const bar = $('status-bar');
if (!bar) return null;
if (!$('status-message')) {
const previous = bar.textContent || 'Ready';
bar.textContent = '';
const msg = document.createElement('span');
msg.id = 'status-message';
msg.textContent = previous;
bar.appendChild(msg);
}
if (!$('status-engines')) {
const engines = document.createElement('span');
engines.id = 'status-engines';
engines.setAttribute('aria-label', 'Active engine status');
bar.appendChild(engines);
}
return bar;
}
const STATUS_LLM_CACHE = { url: '', model: '', ok: false, checked: 0, pending: false };
function statusCleanUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
function statusShortUrl(url) {
try {
const u = new URL(url);
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
} catch (_) {
return statusCleanUrl(url) || 'not configured';
}
}
function statusLabelList(items, fallback) {
const clean = items.map(x => String(x || '').replace(/^\d+\s+/, '').trim()).filter(Boolean);
if (!clean.length) return fallback;
const shown = clean.slice(0, 4).join(', ');
return clean.length > 4 ? shown + ` +${clean.length - 4}` : shown;
}
function statusActiveTts() {
let all = [];
try {
all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
} catch (_) {}
const labels = all.map(b => b.label || b.id);
return {
kind: 'tts',
label: 'TTS',
ok: all.length > 0,
value: statusLabelList(labels, 'No TTS backend'),
title: all.length ? `${all.length} reachable TTS backend${all.length === 1 ? '' : 's'}` : 'No reachable TTS backend',
};
}
function statusActiveStt() {
let all = [];
try {
all = Array.isArray(_sttBackends) ? _sttBackends : [];
} catch (_) {}
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
const available = all.filter(b => b.available);
const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
return {
kind: 'stt',
label: 'STT',
ok: !!(chosen && chosen.available),
value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend',
title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
};
}
// Resolve the effective LLM endpoint+model (app settings, falling back to the
// rehearser's or conversation's pickers) — shared by the status chip and the
// engine health check so the resolution order can't drift between them.
function statusLlmTarget() {
let settings = {};
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
return {
url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''),
model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '',
apiKey: settings.llm_api_key || '',
};
}
function statusActiveLlm() {
const { url, model } = statusLlmTarget();
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
const ok = !!url && same && STATUS_LLM_CACHE.ok;
return {
kind: 'llm',
label: 'LLM',
ok,
value: model || statusShortUrl(url) || 'No LLM configured',
title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured',
};
}
function statusEngineChip(info) {
const cls = info.ok ? 'ok' : 'bad';
return `<button type="button" class="status-engine ${cls}" data-status-kind="${info.kind}" title="${escHtml(info.title || '')} — click to switch">
<span class="status-dot" aria-hidden="true"></span>
<span class="status-engine-label">${escHtml(info.label)}</span>
<span class="status-engine-value">${escHtml(info.value)}</span>
<span class="mdi mdi-chevron-up status-engine-caret"></span>
</button>`;
}
function updateStatusBar() {
ensureStatusBar();
const engines = $('status-engines');
if (!engines) return;
const items = [statusActiveLlm(), statusActiveStt(), statusActiveTts()];
engines.innerHTML = items.map(statusEngineChip).join('');
}
// ── Fly-up menus on the footer status chips — quickly switch the active
// LLM model / STT backend / TTS backend without hunting through Settings. ──
let _statusFlyup = null;
function _statusCloseFlyup() {
if (_statusFlyup) { _statusFlyup.remove(); _statusFlyup = null; }
}
function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) {
_statusCloseFlyup();
const el = document.createElement('div');
el.className = 'status-flyup';
if (kind) el.dataset.forKind = kind;
// A long model list (OpenRouter alone lists 50+) is unusable to scan by
// eye — add a live-filter search box once there's enough items that
// scrolling to find one is slower than typing its name.
const showSearch = items.length > 8;
const searchHtml = showSearch
? `<div class="status-flyup-search"><span class="mdi mdi-magnify"></span><input type="text" placeholder="Search…" spellcheck="false"></div>`
: '';
const listHtml = items.length
? items.map(it => `<button type="button" class="status-flyup-item${it.active ? ' is-active' : ''}" data-val="${escHtml(it.id)}" data-search="${escHtml(it.label.toLowerCase())}">${it.active ? '<span class="mdi mdi-check"></span>' : ''}<span>${escHtml(it.label)}</span></button>`).join('')
: `<div class="status-flyup-empty">${escHtml(emptyLabel || 'Nothing available')}</div>`;
el.innerHTML = searchHtml + `<div class="status-flyup-list">${listHtml}</div>`;
document.body.appendChild(el);
const rect = anchor.getBoundingClientRect();
el.style.left = Math.min(rect.left, window.innerWidth - el.offsetWidth - 12) + 'px';
el.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
el.querySelectorAll('.status-flyup-item').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); onPick(btn.dataset.val); _statusCloseFlyup(); });
});
const searchInp = el.querySelector('.status-flyup-search input');
if (searchInp) {
searchInp.addEventListener('click', e => e.stopPropagation());
searchInp.addEventListener('input', () => {
const q = searchInp.value.trim().toLowerCase();
el.querySelectorAll('.status-flyup-item').forEach(btn => {
btn.hidden = q.length > 0 && !btn.dataset.search.includes(q);
});
});
setTimeout(() => searchInp.focus(), 0);
}
_statusFlyup = el;
}
// Apply a chosen backend id to every matching <select> in the DOM (each
// screen keeps its own backend picker; this is the "quick switch everywhere"
// shortcut instead of hunting down each one individually).
function _statusApplyToSelects(ids, value) {
for (const id of ids) {
const el = $(id);
if (el && [...el.options || []].some(o => o.value === value)) {
el.value = value;
el.dispatchEvent(new Event('change'));
}
}
}
async function _statusOpenLlmFlyup(anchor) {
const { url, model, apiKey } = statusLlmTarget();
if (!url) { _statusFlyupList(anchor, [], null, 'No LLM endpoint configured', 'llm'); return; }
_statusFlyupList(anchor, [], null, 'Loading models…', 'llm');
let models = [];
try {
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
const data = await fetch(fetchUrl).then(r => r.json());
models = Array.isArray(data.models) ? data.models : [];
} catch (_) {}
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
_statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => {
if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_model: picked }) }).catch(() => {});
_statusApplyToSelects(['reh-llm-model', 'conv-llm-model-select'], picked);
refreshStatusBarEngines({ force: true });
toast('LLM model set to ' + picked, 'success');
}, 'No models found at this endpoint', 'llm');
}
function _statusOpenSttFlyup(anchor) {
const all = Array.isArray(_sttBackends) ? _sttBackends : [];
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
_statusFlyupList(anchor, all.filter(b => b.available).map(b => ({
id: b.id, label: b.label || b.id, active: b.id === preferred,
})), (picked) => {
_statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked);
updateStatusBar();
toast('STT backend set to ' + picked, 'success');
}, 'No reachable STT backend', 'stt');
}
function _statusOpenTtsFlyup(anchor) {
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
const preferred = $('tts-backend-select')?.value || $('reader-backend-select')?.value || $('reh-backend-select')?.value || '';
_statusFlyupList(anchor, all.map(b => ({
id: b.id, label: b.label || b.id, active: b.id === preferred,
})), (picked) => {
_statusApplyToSelects(['tts-backend-select', 'reader-backend-select', 'reh-backend-select'], picked);
updateStatusBar();
toast('TTS backend set to ' + picked, 'success');
}, 'No reachable TTS backend', 'tts');
}
document.addEventListener('click', e => {
const chip = e.target.closest('.status-engine');
if (chip) {
e.stopPropagation();
const kind = chip.dataset.statusKind;
if (_statusFlyup && _statusFlyup.dataset.forKind === kind) { _statusCloseFlyup(); return; }
if (kind === 'llm') _statusOpenLlmFlyup(chip);
else if (kind === 'stt') _statusOpenSttFlyup(chip);
else if (kind === 'tts') _statusOpenTtsFlyup(chip);
return;
}
if (!e.target.closest('.status-flyup')) _statusCloseFlyup();
});
async function refreshStatusBarEngines({ force = false } = {}) {
updateStatusBar();
const { url, model, apiKey } = statusLlmTarget();
if (!url || STATUS_LLM_CACHE.pending) return;
const fresh = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model && (Date.now() - STATUS_LLM_CACHE.checked) < 30000;
if (fresh && !force) return;
STATUS_LLM_CACHE.pending = true;
try {
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
const data = await fetch(fetchUrl).then(r => r.json());
const models = Array.isArray(data.models) ? data.models : [];
STATUS_LLM_CACHE.url = url;
STATUS_LLM_CACHE.model = model;
STATUS_LLM_CACHE.ok = !data.error && (!!models.length || !model) && (!model || !models.length || models.includes(model));
STATUS_LLM_CACHE.checked = Date.now();
} catch (_) {
STATUS_LLM_CACHE.url = url;
STATUS_LLM_CACHE.model = model;
STATUS_LLM_CACHE.ok = false;
STATUS_LLM_CACHE.checked = Date.now();
} finally {
STATUS_LLM_CACHE.pending = false;
updateStatusBar();
}
}
window.updateStatusBar = updateStatusBar;
window.refreshStatusBarEngines = refreshStatusBarEngines;
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
// Log a non-fatal error instead of silently swallowing it (was `.catch(()=>{})`).
// Use in best-effort paths so failures are visible in the console for debugging.
function logErr(context, err) { try { console.warn(`[${context}]`, err && (err.message || err)); } catch (_) {} }
// Run an async worker over items with bounded concurrency (default 4). `onProgress(done,total)`
// fires after each item. Replaces slow serial `for…await` loops over network calls.
async function runPool(items, worker, concurrency = 4, onProgress) {
const arr = [...items]; let i = 0, done = 0; const total = arr.length;
async function run() {
while (i < arr.length) {
const idx = i++;
try { await worker(arr[idx], idx); } catch (e) { logErr('runPool', e); }
done++; if (onProgress) { try { onProgress(done, total); } catch (_) {} }
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, arr.length) }, run));
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
} catch(e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
function fmtDuration(sec) {
if (sec == null || Number.isNaN(Number(sec))) return '-';
const total = Math.max(0, Math.round(Number(sec)));
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2,'0')}`;
}
function loadingMarkup(title, detail = '', rows = 4) {
const skeleton = Array.from({length: rows}, () => '<div class="skeleton-row"></div>').join('');
return `
<div class="loading-panel" role="status" aria-live="polite">
<div class="loading-box">
<div class="loading-head"><span class="spinner" aria-hidden="true"></span><span>${escHtml(title)}</span></div>
${detail ? `<div class="loading-sub">${escHtml(detail)}</div>` : ''}
<div class="skeleton-stack">${skeleton}</div>
</div>
</div>`;
}
function setBusyButton(id, busy) {
const el = $(id);
if (el) el.disabled = !!busy;
}
async function clientAutoTrimBounds(fid) {
const resp = await fetch('/api/audio/' + encodeURIComponent(fid));
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
const audioData = await resp.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const buffer = await ctx.decodeAudioData(audioData.slice(0));
try { ctx.close(); } catch (_) {} // iOS Safari hard-limits live AudioContexts — free it now
const samples = buffer.getChannelData(0);
const sr = buffer.sampleRate;
const dur = buffer.duration;
if (dur <= 20) return {start:0, end:dur, duration:dur, reason:'Audio is already short enough.'};
const chunkSec = 0.25, chunkSize = Math.max(1, Math.floor(sr * chunkSec));
const chunks = [];
for (let i = 0; i < samples.length; i += chunkSize) {
let sum = 0, peak = 0, n = Math.min(chunkSize, samples.length - i);
for (let j = 0; j < n; j++) {
const v = samples[i + j];
sum += v * v;
peak = Math.max(peak, Math.abs(v));
}
const rms = Math.sqrt(sum / Math.max(1, n));
const db = rms > 0 ? 20 * Math.log10(rms) : -80;
chunks.push({db, speech:false, clipped:peak > 0.96});
}
const avgDb = chunks.reduce((a,c)=>a+c.db,0) / chunks.length;
const floor = Math.max(avgDb - 18, -45);
chunks.forEach(c => c.speech = c.db >= floor);
function scoreWindow(startSec, lengthSec) {
const first = Math.floor(startSec / chunkSec);
const last = Math.min(chunks.length, Math.ceil((startSec + lengthSec) / chunkSec));
const win = chunks.slice(first, last);
if (!win.length) return {score:-9999};
const speech = win.filter(c => c.speech);
const speechRatio = speech.length / win.length;
const silenceRatio = 1 - speechRatio;
const clipRatio = win.filter(c => c.clipped).length / win.length;
const speechAvg = speech.length ? speech.reduce((a,c)=>a+c.db,0) / speech.length : -80;
const variance = speech.length ? speech.reduce((a,c)=>a+Math.pow(c.db-speechAvg,2),0) / speech.length : 100;
const score = speechRatio * 100 - silenceRatio * 55 - clipRatio * 85 - Math.abs(speechAvg + 20) * 1.7 - Math.min(18, Math.sqrt(variance) * 1.4) - Math.abs(lengthSec - 12) * 0.9;
return {score, speechRatio, silenceRatio, speechAvg};
}
let best = null;
[8,10,12,15,18].forEach(length => {
if (length > dur) return;
for (let start = 0; start <= dur - length; start += 0.5) {
const s = scoreWindow(start, length);
if (!best || s.score > best.score) best = {start, end:start + length, duration:length, ...s};
}
});
if (!best) return {start:0, end:Math.min(12,dur), duration:Math.min(12,dur), reason:'Using the beginning because no stable speech window was found.'};
return {
start:Number(best.start.toFixed(2)),
end:Number(best.end.toFixed(2)),
duration:Number(best.duration.toFixed(2)),
reason:`Selected ${best.duration.toFixed(1)}s with ${Math.round(best.speechRatio*100)}% speech, ${Math.round(best.silenceRatio*100)}% silence, avg ${best.speechAvg.toFixed(1)} dBFS.`
};
}
// ── Accessibility helpers ────────────────────────────────────────────────
function _a11yText(el) {
return String(el?.textContent || '').replace(/\s+/g, ' ').trim();
}
function _a11yLabel(el) {
return el?.getAttribute('aria-label') || el?.getAttribute('title') || _a11yText(el) || '';
}
function enhanceAccessibility(root = document) {
const scope = root && root.querySelectorAll ? root : document;
const statusBar = $('status-bar');
if (statusBar) {
statusBar.setAttribute('role', 'status');
statusBar.setAttribute('aria-live', 'polite');
statusBar.setAttribute('aria-atomic', 'false');
}
const toastEl = $('toast');
if (toastEl) {
toastEl.setAttribute('aria-live', 'polite');
toastEl.setAttribute('aria-atomic', 'true');
}
scope.querySelectorAll('.nav-item, .nav-tree-item, .tab, [onclick]').forEach(el => {
if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/i.test(el.tagName)) return;
if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
const label = _a11yLabel(el);
if (label && !el.getAttribute('aria-label')) el.setAttribute('aria-label', label);
if (label && !el.getAttribute('title')) el.setAttribute('title', label);
});
scope.querySelectorAll('button, [role=button], a, input, select, textarea').forEach(el => {
const label = _a11yLabel(el);
if (label && !el.getAttribute('title') && el.matches('button, [role=button]')) el.setAttribute('title', label);
if (label && !el.getAttribute('aria-label') && !_a11yText(el) && el.matches('button, [role=button]')) el.setAttribute('aria-label', label);
});
scope.querySelectorAll('button .mdi, [role=button] .mdi, .nav-icon .mdi').forEach(icon => {
icon.setAttribute('aria-hidden', 'true');
});
_syncNavActiveState();
}
// Split out of enhanceAccessibility() so the MutationObserver's attribute
// handler (fires on every class-attribute change anywhere in the app — very
// frequent once a session has any dynamic UI toggling active/hover classes)
// doesn't have to re-run the full enhancement pass (several document-wide
// querySelectorAll calls) just to keep nav aria-current in sync. That
// mismatch is what caused multi-second rAF-adjacent blocking / an
// effectively frozen page once this session's larger grids made class
// mutations much more frequent than when this observer was first written.
function _syncNavActiveState() {
document.querySelectorAll('.nav-item, .nav-tree-item').forEach(el => {
const active = el.classList.contains('active') || el.classList.contains('is-active');
if (active && el.getAttribute('aria-current') !== 'page') el.setAttribute('aria-current', 'page');
else if (!active && el.hasAttribute('aria-current')) el.removeAttribute('aria-current');
});
}
// Focus trap + restore for the ~11 places across the app that build a modal
// as `<div class="audiobook-overlay">…</div>` appended straight to
// document.body (confirmDialog, the avatar lightbox, csShow, voice pickers,
// etc.) — none of them individually managed focus, so a keyboard/screen-
// reader user could Tab straight out of an open dialog into the page behind
// it, and focus never returned to whatever triggered the dialog on close.
// Hooking this into the childList observer that's already watching
// document.body for every dynamically-inserted node avoids touching all 11
// call sites individually.
const _a11yModalStack = [];
function _a11yFocusables(root) {
return [...root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
.filter(el => !el.disabled && el.offsetParent !== null);
}
function _a11yOnModalOpened(ov) {
_a11yModalStack.push({ ov, trigger: document.activeElement });
const focusables = _a11yFocusables(ov);
(focusables[0] || ov).focus?.({ preventScroll: true });
if (!ov.hasAttribute('tabindex') && !focusables.length) ov.setAttribute('tabindex', '-1');
}
function _a11yOnModalClosed(ov) {
const idx = _a11yModalStack.findIndex(entry => entry.ov === ov);
if (idx < 0) return;
const [entry] = _a11yModalStack.splice(idx, 1);
if (document.body.contains(entry.trigger)) entry.trigger.focus?.({ preventScroll: true });
}
function initAccessibilityEnhancements() {
if (window.__ttsvcA11yReady) { enhanceAccessibility(document); return; }
window.__ttsvcA11yReady = true;
enhanceAccessibility(document);
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const el = e.target?.closest?.('[role=button]');
if (!el || /^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/i.test(el.tagName)) return;
e.preventDefault();
el.click();
});
// Tab trap: while any modal is open, Tab/Shift+Tab cycles only within the
// TOPMOST one's focusable elements instead of escaping into the page
// behind it.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || !_a11yModalStack.length) return;
const { ov } = _a11yModalStack[_a11yModalStack.length - 1];
const focusables = _a11yFocusables(ov);
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
enhanceAccessibility(n);
if (n.classList?.contains('audiobook-overlay')) _a11yOnModalOpened(n);
});
m.removedNodes.forEach(n => {
if (n.nodeType !== 1) return;
if (n.classList?.contains('audiobook-overlay')) _a11yOnModalClosed(n);
});
} else if (m.type === 'attributes') {
// Cheap, targeted sync only — NOT a full-document re-scan.
_syncNavActiveState();
}
}
});
obs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
}
// ── Light / dark theme ────────────────────────────────────────────────────
function applyTheme(t) {
document.documentElement.dataset.theme = t;
const btn = $('theme-btn');
if (btn) { btn.innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>'; btn.title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; }
const sel = $('s-theme-select');
if (sel) sel.value = t;
localStorage.setItem('vcf-theme', t);
}
$('theme-btn')?.addEventListener('click', () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
);
applyTheme(localStorage.getItem('vcf-theme') || 'dark');
// ── Language helpers ──────────────────────────────────────────────────────
function cc2flag(cc) {
if (!cc || cc.length !== 2) return '<span class="mdi mdi-earth"></span>';
return cc.toUpperCase().replace(/./g, c => String.fromCodePoint(c.charCodeAt(0) + 127397));
}
// Display label for a country code (3-letter where conventional)
const CC_DISPLAY = {
AU:'AUS', NZ:'NZL', GB:'GB', US:'US', CA:'CA', IE:'IRE', ZA:'ZAF', IN:'IND', SG:'SGP', PH:'PHL', NG:'NGA', KE:'KEN', GH:'GHA', JM:'JAM', TT:'TTO', MT:'MLT',
CH:'CH', AT:'AT', BE:'BE', CN:'CN', TW:'TWN', HK:'HKG', MO:'MAC', JP:'JP', KR:'KR', VN:'VNM', TH:'THA', ID:'IDN', MY:'MYS', PK:'PAK', BD:'BGD', LK:'LKA', NP:'NPL', IR:'IRN', IL:'ISR',
MX:'MEX', AR:'ARG', CO:'COL', CL:'CHL', PE:'PER', VE:'VEN', UY:'URY', EC:'ECU', BO:'BOL', CR:'CRI', CU:'CUB', DO:'DOM',
BR:'BRA', SA:'SAU', EG:'EGY', AE:'UAE', MA:'MAR', QA:'QAT', KW:'KWT', OM:'OMN', JO:'JOR', LB:'LBN', IQ:'IRQ',
DK:'DNK', NO:'NOR', SE:'SE', FI:'FIN', IS:'ISL', NL:'NL', LU:'LUX', LI:'LIE', FR:'FR', DE:'DE', ES:'ES', PT:'PT', IT:'IT', GR:'GRC', CY:'CYP', TR:'TR', PL:'PL', CZ:'CZE', SK:'SVK', HU:'HUN', RO:'ROU', BG:'BGR', HR:'HRV', SI:'SVN', RS:'SRB', BA:'BIH', ME:'MNE', MK:'MKD', AL:'ALB', EE:'EST', LV:'LVA', LT:'LTU', UA:'UKR', RU:'RU', BY:'BLR', MD:'MDA'
};
function ccDisplay(cc) { return CC_DISPLAY[cc] || cc; }
const EUROPE_FLAGS = [
['GB','GB - British'], ['IE','IRE - Irish'], ['DE','DE - German'], ['AT','AT - Austrian'], ['CH','CH - Swiss'], ['FR','FR - French'], ['BE','BE - Belgian'], ['NL','NL - Dutch'], ['LU','LUX - Luxembourgish'], ['LI','LIE - Liechtenstein'],
['ES','ES - Spanish'], ['PT','PT - Portuguese'], ['IT','IT - Italian'], ['MT','MLT - Maltese'], ['GR','GRC - Greek'], ['CY','CYP - Cypriot'],
['DK','DNK - Danish'], ['NO','NOR - Norwegian'], ['SE','SE - Swedish'], ['FI','FIN - Finnish'], ['IS','ISL - Icelandic'],
['PL','PL - Polish'], ['CZ','CZE - Czech'], ['SK','SVK - Slovak'], ['HU','HUN - Hungarian'], ['RO','ROU - Romanian'], ['BG','BGR - Bulgarian'], ['HR','HRV - Croatian'], ['SI','SVN - Slovenian'], ['RS','SRB - Serbian'], ['BA','BIH - Bosnian'], ['ME','MNE - Montenegrin'], ['MK','MKD - Macedonian'], ['AL','ALB - Albanian'],
['EE','EST - Estonian'], ['LV','LVA - Latvian'], ['LT','LTU - Lithuanian'], ['UA','UKR - Ukrainian'], ['RU','RU - Russian'], ['BY','BLR - Belarusian'], ['MD','MDA - Moldovan'], ['TR','TR - Turkish'],
];
const ASIA_FLAGS = [
['CN','CN - Mainland Chinese'], ['TW','TWN - Taiwanese'], ['HK','HKG - Hong Kong'], ['MO','MAC - Macau'], ['SG','SGP - Singapore'], ['JP','JP - Japanese'], ['KR','KR - Korean'],
['VN','VNM - Vietnamese'], ['TH','THA - Thai'], ['ID','IDN - Indonesian'], ['MY','MYS - Malaysian'], ['PH','PHL - Filipino'], ['IN','IND - Indian'], ['PK','PAK - Pakistani'], ['BD','BGD - Bangladeshi'], ['LK','LKA - Sri Lankan'], ['NP','NPL - Nepali'],
['SA','SAU - Saudi'], ['AE','UAE - Emirati'], ['QA','QAT - Qatari'], ['KW','KWT - Kuwaiti'], ['OM','OMN - Omani'], ['JO','JOR - Jordanian'], ['LB','LBN - Lebanese'], ['IQ','IRQ - Iraqi'], ['IR','IRN - Iranian'], ['IL','ISR - Israeli'],
];
const ENGLISH_FLAGS = [
['GB','GB - British'], ['US','US - American'], ['CA','CA - Canadian'], ['AU','AUS - Australian'], ['NZ','NZL - New Zealand'], ['IE','IRE - Irish'], ['ZA','ZAF - South African'], ['IN','IND - Indian English'], ['SG','SGP - Singapore English'], ['PH','PHL - Filipino English'], ['NG','NGA - Nigerian English'], ['KE','KEN - Kenyan English'], ['GH','GHA - Ghanaian English'], ['JM','JAM - Jamaican English'], ['TT','TTO - Trinidad and Tobago English'], ['MT','MLT - Maltese English'],
];
const LATAM_FLAGS = [
['MX','MEX - Mexican'], ['AR','ARG - Argentine'], ['CO','COL - Colombian'], ['CL','CHL - Chilean'], ['PE','PER - Peruvian'], ['VE','VEN - Venezuelan'], ['UY','URY - Uruguayan'], ['EC','ECU - Ecuadorian'], ['BO','BOL - Bolivian'], ['CR','CRI - Costa Rican'], ['CU','CUB - Cuban'], ['DO','DOM - Dominican'], ['BR','BRA - Brazilian'],
];
function uniqueFlagOptions(groups) {
const seen = new Set(), out = [];
groups.flat().forEach(item => { if (item && !seen.has(item[0])) { seen.add(item[0]); out.push(item); } });
return out;
}
const FLAG_OPTIONS = {
EN: ENGLISH_FLAGS,
DE: uniqueFlagOptions([[['DE','DE - German'],['AT','AT - Austrian'],['CH','CH - Swiss (DE)']], EUROPE_FLAGS]),
FR: uniqueFlagOptions([[['FR','FR - French'],['BE','BE - Belgian'],['CH','CH - Swiss (FR)'],['CA','CA - Canadian']], EUROPE_FLAGS]),
ZH: uniqueFlagOptions([[['CN','CN - Mainland'],['TW','TWN - Taiwanese'],['HK','HKG - Hong Kong'],['SG','SGP - Singaporean']], ASIA_FLAGS]),
ES: uniqueFlagOptions([[['ES','ES - Spain']], LATAM_FLAGS, EUROPE_FLAGS]),
PT: uniqueFlagOptions([[['PT','PT - Portuguese'],['BR','BRA - Brazilian']], EUROPE_FLAGS, LATAM_FLAGS]),
AR: uniqueFlagOptions([[['SA','SAU - Saudi'],['EG','EGY - Egyptian'],['AE','UAE - Emirati'],['MA','MAR - Moroccan']], ASIA_FLAGS]),
NL: uniqueFlagOptions([[['NL','NL - Dutch'],['BE','BE - Belgian']], EUROPE_FLAGS]),
JA: uniqueFlagOptions([[['JP','JP - Japanese']], ASIA_FLAGS]),
KO: uniqueFlagOptions([[['KR','KR - Korean']], ASIA_FLAGS]),
IT: uniqueFlagOptions([[['IT','IT - Italian']], EUROPE_FLAGS]),
RU: uniqueFlagOptions([[['RU','RU - Russian']], EUROPE_FLAGS, ASIA_FLAGS]),
PL: uniqueFlagOptions([[['PL','PL - Polish']], EUROPE_FLAGS]),
SV: uniqueFlagOptions([[['SE','SE - Swedish']], EUROPE_FLAGS]),
TR: uniqueFlagOptions([[['TR','TR - Turkish']], EUROPE_FLAGS, ASIA_FLAGS]),
HI: uniqueFlagOptions([[['IN','IND - Indian']], ASIA_FLAGS]),
};
const LANG_FLAG_DEFAULT = {
EN:'GB', DE:'DE', ZH:'CN', FR:'FR', ES:'ES', JA:'JP', KO:'KR',
IT:'IT', PT:'BR', RU:'RU', AR:'SA', PL:'PL', NL:'NL', SV:'SE', TR:'TR', HI:'IN',
};
const ALL_FLAGS = uniqueFlagOptions([ENGLISH_FLAGS, EUROPE_FLAGS, ASIA_FLAGS, LATAM_FLAGS, [
['EG','EGY - Egyptian'], ['MA','MAR - Moroccan'], ['ZA','ZAF - South African'], ['NG','NGA - Nigerian'], ['KE','KEN - Kenyan'], ['GH','GHA - Ghanaian'],
]]);
// ── Preview sample texts per language ────────────────────────────────────
const VL_SAMPLE_TEXTS = {
EN: 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.',
DE: 'Hallo, wie geht es Ihnen heute? Bitte lesen Sie diesen Text deutlich vor — für einen fairen Stimmvergleich.',
FR: 'Bonjour, comment allez-vous aujourd\'hui ? Veuillez lire ce texte clairement pour un test vocal équitable.',
ES: '¡Hola! ¿Cómo estás hoy? Por favor, lee este texto con claridad para una evaluación justa de la voz.',
PT: 'Olá, como vai você hoje? Por favor, leia este texto claramente para uma avaliação justa da voz.',
IT: 'Ciao, come stai oggi? Per favore leggi questo testo in modo chiaro per una valutazione equa della voce.',
NL: 'Hallo, hoe gaat het vandaag? Lees dit voorbeeld alstublieft duidelijk voor een eerlijke stembeoordeling.',
PL: 'Cześć, jak się dziś masz? Przeczytaj proszę ten tekst wyraźnie, aby dokonać rzetelnej oceny głosu.',
SV: 'Hej, hur mår du idag? Vänligen läs detta exempel tydligt för en rättvis röstbedömning.',
DA: 'Hej, hvordan har du det i dag? Læs venligst dette eksempel tydeligt for en retfærdig stemmevurdering.',
NB: 'Hei, hvordan har du det i dag? Vennligst les dette eksempelet tydelig for en rettferdig stemmevurdering.',
FI: 'Hei, kuinka voit tänään? Lue tämä esimerkki selkeästi reilua ääniarviointia varten.',
HU: 'Szia, hogy vagy ma? Kérlek, olvasd fel ezt a szöveget érthetően az igazságos hangértékelés érdekében.',
CS: 'Dobrý den, jak se máte? Přečtěte prosím tento text zřetelně pro spravedlivé hodnocení hlasu.',
RO: 'Bună ziua, cum vă simțiți astăzi? Vă rugăm citiți acest text clar pentru o evaluare corectă a vocii.',
UK: 'Привіт, як ви сьогодні? Будь ласка, прочитайте цей текст чітко для справедливого тестування голосу.',
RU: 'Здравствуйте, как вы сегодня? Пожалуйста, прочитайте этот текст чётко для справедливого тестирования голоса.',
TR: 'Merhaba, bugün nasılsınız? Adil bir ses değerlendirmesi için lütfen bu örneği açıkça okuyun.',
AR: 'مرحباً، كيف حالك اليوم؟ يرجى قراءة هذا النص بوضوح لإجراء اختبار صوت عادل.',
HI: 'नमस्ते, आप आज कैसे हैं? कृपया इस नमूने को स्पष्ट रूप से पढ़ें ताकि एक उचित आवाज़ मूल्यांकन हो सके।',
ZH: '你好,你今天怎么样?请清晰地朗读这段示例,以便进行公正的语音评测。',
JA: 'こんにちは、今日はいかがですか?公平な音声評価のために、このサンプルをはっきりと読んでください。',
KO: '안녕하세요, 오늘 어떠세요? 공정한 음성 벤치마크를 위해 이 샘플을 명확하게 읽어주세요.',
};
const _VL_LANG_FLAG = Object.assign({ DA:'DK', NB:'NO', FI:'FI', HU:'HU', CS:'CZ', RO:'RO', UK:'UA' }, LANG_FLAG_DEFAULT);
function setPreviewLang(lang) {
const ta = $('vl-preview-sample');
const hidden = $('benchmark-sample-text');
if (!ta) return;
const text = VL_SAMPLE_TEXTS[lang] || VL_SAMPLE_TEXTS.EN;
ta.value = text;
ta.dispatchEvent(new Event('input'));
if (hidden) { hidden.value = text; hidden.dispatchEvent(new Event('input')); }
try { localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); } catch {}
const btn = $('vl-preview-lang-btn');
if (btn) {
const cc = (_VL_LANG_FLAG[lang] || 'gb').toLowerCase();
btn.innerHTML = `<span class="fi fi-${cc}"></span>`;
btn.title = (LANGUAGE_LABELS[lang] || lang) + ' — click to change';
btn.dataset.lang = lang;
}
}
function openPreviewLangPicker(anchor) {
const items = Object.keys(VL_SAMPLE_TEXTS).map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
createSearchablePicker(anchor, items, setPreviewLang, {
placeholder: 'Sample language…',
renderItem: (l) => {
const cc = (_VL_LANG_FLAG[l] || 'gb').toLowerCase();
return `<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span>`
+ `<span class="ipi-code">${escHtml(l)}</span>`
+ `<span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`;
},
});
}
// ── Searchable picker dropdown ────────────────────────────────────────────
function createSearchablePicker(anchor, items, onSelect, { placeholder = 'Search…', renderItem } = {}) {
document.querySelector('.insp-picker')?.remove();
const panel = document.createElement('div');
panel.className = 'insp-picker';
const search = document.createElement('input');
search.type = 'search'; search.className = 'insp-picker-search';
search.placeholder = placeholder; search.autocomplete = 'off';
const list = document.createElement('div');
list.className = 'insp-picker-list';
const renderList = (q) => {
const f = q.trim().toLowerCase();
const filtered = f
? items.filter(([code, name]) => name.toLowerCase().includes(f) || code.toLowerCase().includes(f))
: items;
list.innerHTML = '';
if (!filtered.length) { list.innerHTML = '<div class="insp-picker-empty">No matches</div>'; return; }
filtered.forEach(([code, name]) => {
const btn = document.createElement('button');
btn.className = 'insp-picker-item'; btn.type = 'button';
btn.innerHTML = renderItem ? renderItem(code, name) : `<span class="ipi-code">${escHtml(code)}</span><span>${escHtml(name)}</span>`;
btn.addEventListener('mousedown', (e) => { e.preventDefault(); onSelect(code); panel.remove(); cleanup(); });
list.appendChild(btn);
});
};
search.addEventListener('input', () => renderList(search.value));
renderList('');
panel.appendChild(search); panel.appendChild(list);
document.body.appendChild(panel);
const rect = anchor.getBoundingClientRect();
const pw = 244;
let left = rect.left;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
panel.style.top = (rect.bottom + 4) + 'px';
panel.style.left = Math.max(8, left) + 'px';
const cleanup = () => { document.removeEventListener('mousedown', onOut); document.removeEventListener('keydown', onKey); };
const onOut = (e) => { if (!panel.contains(e.target)) { panel.remove(); cleanup(); } };
const onKey = (e) => { if (e.key === 'Escape') { panel.remove(); cleanup(); } };
setTimeout(() => { document.addEventListener('mousedown', onOut); document.addEventListener('keydown', onKey); }, 0);
search.focus();
}
// ── Tag reuse (localStorage) ──────────────────────────────────────────────
const _TAG_KEY = 'ttsvc-voice-tags';
function getStoredTags() {
try { return JSON.parse(localStorage.getItem(_TAG_KEY) || '[]'); } catch { return []; }
}
function addStoredTag(tag) {
const t = tag.trim(); if (!t) return;
const tags = getStoredTags().filter(x => x !== t);
tags.unshift(t);
try { localStorage.setItem(_TAG_KEY, JSON.stringify(tags.slice(0, 60))); } catch {}
}
// Voice-library rendering can be expensive with large local collections because it
// creates many rich rows and image requests. Schedule automatic refreshes after the
// current startup/navigation task so core routing is interactive first.
const _voiceLibraryLoadCallbacks = [];
const _VOICE_LIBRARY_LOAD_NAV_WAIT_MS = 3500;
function scheduleVoiceLibraryLoad(onDone) {
if (typeof onDone === 'function') _voiceLibraryLoadCallbacks.push(onDone);
if (window.__voiceLibraryLoadScheduled) return;
window.__voiceLibraryLoadScheduled = true;
const scheduledAt = (performance && performance.now) ? performance.now() : Date.now();
const run = () => {
const now = (performance && performance.now) ? performance.now() : Date.now();
if (typeof window.navTo !== 'function' && now - scheduledAt < _VOICE_LIBRARY_LOAD_NAV_WAIT_MS) {
setTimeout(run, 80);
return;
}
window.__voiceLibraryLoadScheduled = false;
if (typeof loadVoiceLibrary !== 'function') {
_voiceLibraryLoadCallbacks.splice(0);
return;
}
loadVoiceLibrary()
.then(() => {
const callbacks = _voiceLibraryLoadCallbacks.splice(0);
callbacks.forEach(fn => { try { fn(); } catch (e) { logErr('scheduleVoiceLibraryLoad callback', e); } });
})
.catch(e => {
_voiceLibraryLoadCallbacks.splice(0);
status('Voice library load failed: ' + (e.message || e));
});
};
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 1200 });
else setTimeout(run, 0);
}
window.scheduleVoiceLibraryLoad = scheduleVoiceLibraryLoad;
// ── Tabs ──────────────────────────────────────────────────────────────────
function disabledBackendTabMessage(tab) {
const required = tab?.dataset.backendRequired || '';
if (required === 'any_tts') return 'No Qwen3-TTS backend is running or configured. Check Settings.';
const backend = (_ttsBackends || []).find(b => b.id === required);
const label = backend?.label || required.replace(/_/g, ' ');
const url = backend?.url ? ` (${backend.url})` : '';
return `${label}${url} is not running or not configured in Settings.`;
}
function switchTab(name) {
const targetTab = document.querySelector(`.tab[data-tab="${name}"]`);
if (targetTab?.classList.contains('backend-unavailable')) {
toast(disabledBackendTabMessage(targetTab), 'error');
return false;
}
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
if (name === 'library') scheduleVoiceLibraryLoad();
if (name === 'integrations') {
if (!_voices.length) scheduleVoiceLibraryLoad(renderIntegrationSnippets);
renderIntegrationSnippets();
}
if (name === 'performance' && typeof window.populateBatchBenchmarkFromLibrary === 'function') {
window.populateBatchBenchmarkFromLibrary();
}
if (name === 'performance' && typeof window.loadBenchmarkSectionData === 'function') {
window.loadBenchmarkSectionData();
}
if (name === 'routing') loadRoutingTab();
if (name === 'getvoices') {
if (typeof window.initElevenLabsBrowser === 'function') window.initElevenLabsBrowser();
loadGetVoices();
}
return true;
}
// ── Language detection ────────────────────────────────────────────────────────
// Lightweight, dependency-free detector. First by script (CJK / Cyrillic /
// Hangul), then by stop-word frequency for Latin-script languages. Returns a
// full English language name ("German", "English", …) matching the design-language
// <select>, or '' when there's too little signal.
function detectLang(text) {
const t = String(text || '').slice(0, 4000);
if (!t.trim()) return '';
if (/[가-힣]/.test(t)) return 'Korean';
if (/[぀-ヿ]/.test(t)) return 'Japanese';
if (/[一-鿿]/.test(t)) return 'Chinese';
if (/[Ѐ-ӿ]/.test(t)) return 'Russian';
const STOP = {
English: ['the','and','of','to','a','in','that','is','was','he','for','it','with','as','his','on','be','at','by','she','you','not'],
German: ['der','die','und','ich','das','nicht','ein','ist','sie','mit','dem','den','war','auf','für','des','eine','auch','als','er','wir','aber','noch'],
French: ['le','la','les','et','de','un','une','des','est','que','pour','dans','qui','pas','plus','vous','avec','sur','son','ne','je','il'],
Spanish: ['el','la','los','las','de','que','y','un','una','en','es','por','con','no','se','su','para','lo','como','más','pero','sus'],
Italian: ['il','la','di','e','che','un','una','è','per','non','con','sono','del','le','si','lo','ma','come','più','anche','della','gli'],
Portuguese: ['de','que','e','o','a','do','da','em','um','uma','não','os','para','com','por','como','mas','se','dos','das','ao','seu'],
Dutch: ['de','het','een','en','van','ik','te','dat','die','in','is','niet','met','zijn','op','aan','voor','er','maar','om','ook','als'],
};
const words = (t.toLowerCase().match(/[a-zà-ÿ]+/g) || []);
if (words.length < 8) return '';
const counts = {}, sets = {};
Object.keys(STOP).forEach(l => { counts[l] = 0; sets[l] = new Set(STOP[l]); });
for (const w of words) for (const l in sets) if (sets[l].has(w)) counts[l]++;
let best = '', bestN = 0;
for (const l in counts) if (counts[l] > bestN) { bestN = counts[l]; best = l; }
// '' on weak signal, matching this function's own documented contract
// above — NOT 'English'. Every caller already does `detectLang(x) || ''`
// expecting a falsy result here to mean "couldn't tell", so silently
// returning 'English' instead defeated that: _resolveBookLang
// (library-characters.js) treats any truthy result as a confident,
// final answer and never falls through to its own book-majority-vote
// fallback — confirmed live as the actual cause of sparse/minor
// characters in an all-German book (e.g. "Junker", "Kroah") getting
// designed as English voices, even though the book-majority fallback
// that exists specifically to prevent this was right there unused.
return bestN >= 3 ? best : '';
}
window.detectLang = detectLang;
// German umlauts/ß transliterated to their standard ASCII spelling (ä→ae,
// ö→oe, ü→ue, ß→ss) before any [^A-Za-z0-9]-style sanitizer strips them —
// every one of those sanitizers (voice IDs, filenames, tags) treats a
// non-ASCII letter as junk to collapse into an underscore rather than a
// real letter to keep, so "Torwächter" silently became "Torw_chter" and
// "Mädchen" became "M_dchen" (both later displayed as just the tail after
// the LAST underscore, e.g. "chter"/"dchen", since a display-name fallback
// takes the last `_`-separated segment of the id). This is also the exact
// transliteration the actual TTS engine's own directory-scan voice-naming
// already uses (confirmed live: it registers a voice literally as
// "...Hoerbuch..." for a reference file named with "ö"), so this keeps
// generated IDs consistent with what the engine itself expects.
function _umlautSafe(str) {
return String(str || '').replace(/[äöüÄÖÜß]/g, ch => ({
'ä':'ae', 'ö':'oe', 'ü':'ue', 'Ä':'Ae', 'Ö':'Oe', 'Ü':'Ue', 'ß':'ss',
}[ch]));
}
window._umlautSafe = _umlautSafe;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab)));
document.addEventListener('click', e => {
const btn = e.target.closest('.backend-jump');
if (!btn) return;
switchTab('generation');
const backend = btn.dataset.backend;
const sel = $('tts-backend-select');
if (sel && [...sel.options].some(o => o.value === backend)) {
sel.value = backend;
sel.dispatchEvent(new Event('change'));
}
});
// ── Cross-workflow pipeline stepper ─────────────────────────────────────────
// PDF → Text → Cast Audiobook → Cast Characters → Cast → Script Rehearser
// → Generate MP3s → Audiobook. A persistent strip (rendered into any element
// with class .wf-stepper — currently one in s-reader.html, one in
// s-rehearser.html) that lets you jump directly to any reachable stage
// without losing state: each stage's data lives in its own owner
// (readerState / _audiobook / rehState) regardless of which stage is on
// screen, so this only ever toggles visibility/phase — it never rebuilds
// anything from scratch.
let _wfActive = 'source';
// PDF import and text extraction used to be two stepper stops, but they both
// just land you on the same Reader screen — merged into one "Source" stop
// (matches the "Source" label already used on the Reader's import card).
const WF_STEPS = [
{ key: 'source', label: 'Source', enabled: () => true,
hint: 'Import a PDF or text file and skim the extracted pages. Once the text looks right, move on.' },
{ key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length,
hint: 'Split the text into narration and dialogue and attribute each line to a speaker. Check the result and fix any misattributed lines before continuing.' },
{ key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true,
hint: 'Optional — let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip if you just want to cast voices quickly.' },
{ key: 'castlib', label: 'Cast', enabled: () => !!window.readerState?.sentences?.length,
hint: 'Review the cast list. Merge any duplicate characters and fix names before assigning voices.' },
{ key: 'voices', label: 'Assign Voices', enabled: () => !!window.readerState?.sentences?.length, optional: true,
hint: 'Every character needs a voice before audio can be generated — assign one manually or use "Auto assign". Come back here if a voice sounds wrong.' },
{ key: 'rehearser', label: 'Script Rehearser',
// Gating this on rehState.lines.length alone was circular: that's only
// ever populated by loading a script INTO the Rehearser, which is
// exactly what clicking this step does (workflowCrumbGo builds it from
// the live Audiobook segments) — so the button could never become
// enabled from a fresh session, permanently blocking the only path in.
enabled: () => !!(window.rehState?.lines?.length || window._audiobook?.segments?.length),
hint: 'Fine-tune the script line by line — attribution, emotion, and pacing. Fix problems here rather than after audio is generated.' },
{ key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length,
hint: 'Generate audio for every line. Wait for synthesis to finish and re-run any failed lines before merging into the final audiobook.' },
{ key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length,
hint: 'Merge the generated clips into the final audiobook file and download it. Go back to Generate MP3s if anything sounds off.' },
];
function workflowCrumbGo(key) {
if (key === 'source') {
// navTo's cast-session restore is synchronous, so the explicit view choice
// below simply wins by running after it.
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('main');
} else if (key === 'cast') {
if (typeof navTo === 'function') navTo('s-reader');
if (typeof audiobookOpenCastView === 'function') audiobookOpenCastView();
} else if (key === 'chars') {
// Navigate to the character-sheet stage in Read Aloud.
if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('chars');
_wfActive = 'chars';
refreshWorkflowCrumbs();
} else if (key === 'castlib') {
// Navigate to the cast roster/library view.
if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters');
_wfActive = 'castlib';
refreshWorkflowCrumbs();
} else if (key === 'voices') {
// Same Character Library grid as "Cast" — voice/image assignment already
// lives there (per-character voice pick/auto, avatar upload, AI-generate
// image, and "Cast selected character roles" for a missing sheet). This
// step just scrolls straight to the CURRENT book's block instead of
// landing on the whole cross-book library unfocused.
window._libCharsScrollToBook = window.readerState?.title || window.rehState?.title || null;
if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters');
_wfActive = 'voices';
refreshWorkflowCrumbs();
} else if (key === 'rehearser') {
// A bare navTo left the Stage empty (0/0 lines) whenever this step was
// reached by clicking the stepper/Next directly, since nothing actually
// parsed the current cast into rehState — only opening the Rehearser via
// its own "Rehearse" entry points did that. Build it from the live
// Audiobook segments (same cast/voices already assigned in Assign
// Voices) unless a script is already loaded, in which case leave it alone
// rather than re-parsing over in-progress Stage edits.
if (window.rehState?.lines?.length) {
if (typeof navTo === 'function') navTo('s-rehearser');
} else if (typeof audiobookOpenCurrentInRehearser === 'function' && (window._audiobook?.segments || []).length) {
audiobookOpenCurrentInRehearser();
} else {
if (typeof navTo === 'function') navTo('s-rehearser');
}
} else if (key === 'mp3') {
if (typeof navTo === 'function') navTo('s-rehearser');
if (typeof showPhase === 'function' && window.rehState?.lines?.length) showPhase(3);
} else if (key === 'audiobook') {
if (typeof navTo === 'function') navTo('s-rehearser');
if (typeof showPhase === 'function' && window.rehState?.clips?.length) showPhase(4);
}
}
window.workflowCrumbGo = workflowCrumbGo;
// Nearest reachable step from curIdx in the given direction (+1/-1), or null.
function _wfNeighbor(curIdx, dir) {
for (let i = curIdx + dir; i >= 0 && i < WF_STEPS.length; i += dir) {
if (WF_STEPS[i].enabled()) return WF_STEPS[i];
}
return null;
}
function refreshWorkflowCrumbs(active) {
if (active) _wfActive = active;
const title = window.readerState?.title || window.rehState?.title || '';
const containers = document.querySelectorAll('.wf-stepper');
// Hide the strip until some stage beyond the always-available "Source" is
// reachable — otherwise a fresh session shows a stepper with one live stop.
const anyState = WF_STEPS.some(st => st.key !== 'source' && st.enabled());
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
if (!containers.length) { _wfUpdateHeaderNav(anyState, curIdx); return; }
containers.forEach(el => {
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
el.hidden = false;
const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null;
const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null;
// Two rows instead of one long strip: the book title on top, the
// numbered step sequence (now leading with the back-to-previous-step
// nudge, moved down from the title row to sit with the rest of the step
// navigation) below — the single-row version forced the steps into a
// horizontally-scrolling sliver that hid most of them behind a scrollbar.
const row1 = [];
if (title) row1.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
const row2 = [];
row2.push(`<button type="button" class="wf-nav-btn wf-nav-prev" data-wf-nav="prev" title="${prevStep ? 'Back to ' + escHtml(prevStep.label) : 'No previous step'}"${prevStep ? '' : ' disabled'}><span class="mdi mdi-chevron-left"></span></button>`);
WF_STEPS.forEach((st, i) => {
const enabled = st.enabled();
const cur = _wfActive === st.key;
const done = curIdx >= 0 && i < curIdx && enabled;
if (i > 0) row2.push(`<span class="wf-step-arrow${done ? ' is-done' : ''}"></span>`);
row2.push(
`<button type="button" class="wf-step${cur ? ' is-current' : ''}${done ? ' is-done' : ''}${st.optional ? ' is-optional' : ''}" ` +
`data-wf="${st.key}"${(!enabled || cur) ? ' disabled' : ''} title="${st.optional ? 'Optional — ' : ''}${escHtml(st.label)}">` +
`<span class="wf-step-num">${done ? '<span class="mdi mdi-check"></span>' : i + 1}</span><span>${escHtml(st.label)}${st.optional ? '<small>optional</small>' : ''}</span></button>`
);
});
row2.push(`<button type="button" class="wf-nav-btn wf-nav-next" data-wf-nav="next" title="${nextStep ? 'On to ' + escHtml(nextStep.label) : 'No next step'}"${nextStep ? '' : ' disabled'}><span class="mdi mdi-chevron-right"></span></button>`);
el.innerHTML = `<div class="wf-stepper-row1">${row1.join('')}</div><div class="wf-stepper-row2">${row2.join('')}</div>`;
el.querySelectorAll('.wf-step[data-wf]').forEach(btn => {
btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf));
});
const prevBtn = el.querySelector('.wf-nav-prev');
if (prevBtn && !prevBtn.disabled) prevBtn.addEventListener('click', () => workflowCrumbGo(prevStep.key));
const nextBtn = el.querySelector('.wf-nav-next');
if (nextBtn && !nextBtn.disabled) nextBtn.addEventListener('click', () => workflowCrumbGo(nextStep.key));
});
_wfUpdateHeaderNav(anyState, curIdx);
}
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
// Previous/Next workflow buttons on every page's own section header (not
// just the stepper widget further down) — lets you step through the
// pipeline without scrolling to find the stepper first. Injected once per
// .section-head (every section has one) and just updated in place after
// that, since section HTML is loaded once and never rebuilt.
function _wfUpdateHeaderNav(anyState, curIdx) {
const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null;
const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null;
const curStep = curIdx >= 0 ? WF_STEPS[curIdx] : null;
document.querySelectorAll('.section-head').forEach(head => {
// Studio has its own self-contained 4-phase nav (studio.js) — this
// stepper's Previous/Next buttons are hardcoded to the old s-reader/
// s-library/s-rehearser sections and don't know about Studio's phases,
// so injecting them here just breaks navigation when clicked.
if (head.closest('#s-caststudio')) {
const staleNav = head.querySelector('.wf-header-nav');
if (staleNav) staleNav.hidden = true;
const staleHint = head.nextElementSibling?.classList?.contains('wf-step-hint') ? head.nextElementSibling : null;
if (staleHint) staleHint.hidden = true;
return;
}
let nav = head.querySelector('.wf-header-nav');
// A slim guidance bar right under the header — what to actually do on
// THIS step and when it's safe to move on/back — since the generic
// "Previous"/"Next" buttons alone gave no clue what either direction
// actually did or when a step was "done".
let hintBar = head.nextElementSibling?.classList?.contains('wf-step-hint') ? head.nextElementSibling : null;
if (!anyState) { if (nav) nav.hidden = true; if (hintBar) hintBar.hidden = true; return; }
if (!nav) {
nav = document.createElement('div');
nav.className = 'wf-header-nav';
nav.innerHTML =
`<button type="button" class="wf-header-nav-btn wf-header-nav-prev"><span class="mdi mdi-chevron-left"></span><span class="wf-header-nav-label">Previous</span></button>` +
`<button type="button" class="wf-header-nav-btn wf-header-nav-next"><span class="wf-header-nav-label">Next</span><span class="mdi mdi-chevron-right"></span></button>`;
head.appendChild(nav);
nav.querySelector('.wf-header-nav-prev').addEventListener('click', () => { if (nav.dataset.prevKey) workflowCrumbGo(nav.dataset.prevKey); });
nav.querySelector('.wf-header-nav-next').addEventListener('click', () => { if (nav.dataset.nextKey) workflowCrumbGo(nav.dataset.nextKey); });
}
if (!hintBar) {
hintBar = document.createElement('div');
hintBar.className = 'wf-step-hint';
head.insertAdjacentElement('afterend', hintBar);
}
nav.hidden = false;
const prevBtn = nav.querySelector('.wf-header-nav-prev');
const nextBtn = nav.querySelector('.wf-header-nav-next');
nav.dataset.prevKey = prevStep ? prevStep.key : '';
nav.dataset.nextKey = nextStep ? nextStep.key : '';
prevBtn.disabled = !prevStep;
prevBtn.title = prevStep ? 'Go back to fix something on ' + prevStep.label : 'No previous step';
prevBtn.querySelector('.wf-header-nav-label').textContent = prevStep ? prevStep.label : 'Previous';
nextBtn.disabled = !nextStep;
nextBtn.title = nextStep ? 'Continue to ' + nextStep.label : 'No next step';
nextBtn.querySelector('.wf-header-nav-label').textContent = nextStep ? nextStep.label : 'Next';
if (curStep?.hint) {
hintBar.hidden = false;
hintBar.innerHTML =
`<span class="wf-step-hint-badge">Step ${curIdx + 1}/${WF_STEPS.length}${curStep.optional ? ' · optional' : ''}</span>` +
`<span class="wf-step-hint-text">${escHtml(curStep.hint)}</span>`;
} else {
hintBar.hidden = true;
}
});
}
// Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last
// four stepper stops — keep the stepper's "current" highlight following them.
window.onRehearserPhaseChange = function (n) {
refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser');
};