tts-voice-creator-clone-and.../static/js/utils.js
mARTin-B78 cfe15a72f5 Optimize and clean up the pipeline merge (v1.12.85)
Speed: OCR renders only the heading band (was full page at 2.5x),
Tesseract worker freed after extraction, name-underline index cached
instead of rebuilt per segment, constant regexes hoisted, old drafts
migrate segment page numbers once at load (single-path feed renderer).

Quality: global [hidden]{display:none!important} ends the empty-box bug
class; racy deferred cast-restore + _readerSuppressCastRestore flag
replaced by a synchronous, caller-wins restore; card collapse defaults
move to data-collapse-default markup; duplicated join/colour/alias/LLM-
target helpers now delegate to their canonical implementations; dead
reader state removed; stepper hide-guard fixed for the merged Source key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:52:33 +02:00

774 lines
40 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);
}
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 `<span class="status-engine ${cls}" title="${escHtml(info.title || '')}">
<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>`;
}
function updateStatusBar() {
ensureStatusBar();
const engines = $('status-engines');
if (!engines) return;
const items = [statusActiveLlm(), statusActiveStt(), statusActiveTts()];
engines.innerHTML = items.map(statusEngineChip).join('');
}
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');
});
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');
});
}
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();
});
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach(n => { if (n.nodeType === 1) enhanceAccessibility(n); });
} else if (m.type === 'attributes') {
enhanceAccessibility(document);
}
}
});
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; }
return bestN >= 3 ? best : 'English';
}
window.detectLang = detectLang;
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 (optional) → 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 },
{ key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length },
{ key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true },
{ key: 'rehearser', label: 'Script Rehearser',enabled: () => !!window.rehState?.lines?.length },
{ key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length },
{ key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length },
];
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') {
if (typeof csForReader === 'function') csForReader();
_wfActive = 'chars';
refreshWorkflowCrumbs();
} else if (key === 'rehearser') {
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;
function refreshWorkflowCrumbs(active) {
if (active) _wfActive = active;
const title = window.readerState?.title || window.rehState?.title || '';
const containers = document.querySelectorAll('.wf-stepper');
if (!containers.length) return;
// 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());
containers.forEach(el => {
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
el.hidden = false;
const parts = [];
if (title) parts.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
WF_STEPS.forEach((st, i) => {
const enabled = st.enabled();
const cur = _wfActive === st.key;
const done = curIdx >= 0 && i < curIdx && enabled;
if (i > 0) parts.push(`<span class="wf-step-arrow${done ? ' is-done' : ''}"></span>`);
parts.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>`
);
});
el.innerHTML = parts.join('');
el.querySelectorAll('.wf-step[data-wf]').forEach(btn => {
btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf));
});
});
}
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
// Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last
// three stepper stops — keep the stepper's "current" highlight following them.
window.onRehearserPhaseChange = function (n) {
refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser');
};