tts-voice-creator-clone-and.../static/js/utils.js

461 lines
26 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) { $('status-bar').textContent = msg; }
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', 'true');
}
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 {}
}
// ── 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') loadVoiceLibrary();
if (name === 'integrations') {
if (!_voices.length) loadVoiceLibrary();
renderIntegrationSnippets();
}
if (name === 'routing') loadRoutingTab();
if (name === 'getvoices') loadGetVoices();
return true;
}
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'));
}
});