// ── 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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}, () => '
').join(''); return `
${escHtml(title)}
${detail ? `
${escHtml(detail)}
` : ''}
${skeleton}
`; } 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.` }; } // ── Light / dark theme ──────────────────────────────────────────────────── function applyTheme(t) { document.documentElement.dataset.theme = t; const btn = $('theme-btn'); if (btn) { btn.innerHTML = t === 'dark' ? '' : ''; 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 ''; 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 = ``; 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 `` + `${escHtml(l)}` + `${escHtml(LANGUAGE_LABELS[l] || l)}`; }, }); } // ── 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 = '
No matches
'; return; } filtered.forEach(([code, name]) => { const btn = document.createElement('button'); btn.className = 'insp-picker-item'; btn.type = 'button'; btn.innerHTML = renderItem ? renderItem(code, name) : `${escHtml(code)}${escHtml(name)}`; 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')); } });