// ── 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]; } // ── Voice inspector (3-pane workbench) ─────────────────────────────────── let _selectedVoiceWrap = null; function selectVoice(wrap) { const inspector = document.getElementById('voices-inspector'); if (!inspector) return; function restoreToRow(targetWrap) { // Restore all extracted elements back to their original DOM parents (targetWrap._extracted || []).forEach(({el, target}) => { if (el && target) target.appendChild(el); }); targetWrap._extracted = []; // Move main-row and optimizer back from inspector body to wrap inspector.querySelectorAll('.vr-main-row,.vr-optimizer').forEach(el => targetWrap.appendChild(el)); } // Deselect previous voice if (_selectedVoiceWrap && _selectedVoiceWrap !== wrap) { _selectedVoiceWrap.classList.remove('vr-selected'); restoreToRow(_selectedVoiceWrap); } if (_selectedVoiceWrap === wrap) { restoreToRow(wrap); wrap.classList.remove('vr-selected'); _selectedVoiceWrap = null; inspector.innerHTML = '

Pick a voice on the left
to edit it here

'; return; } _selectedVoiceWrap = wrap; wrap.classList.add('vr-selected'); const voiceId = wrap.dataset.id || ''; const color = wrap.dataset.color || '#9575CD'; const isClone = wrap.dataset.hasRef === 'true'; const dbfs = wrap.dataset.dbfs || '-'; const hasPicture = wrap.dataset.hasPicture === 'true'; // Live voice object — authoritative for mutable fields const v = (_voices || []).find(vv => vv.id === voiceId) || {}; const langCode = v.lang || wrap.dataset.lang || ''; const flagCc = v.flag || wrap.dataset.flagCc || langCode; const rating = v.rating || 0; const nameParts = voiceId.split('_'); const dispName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId; const initial = (dispName[0] || voiceId[0] || '?').toUpperCase(); const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null; const inspFlagCc = flagCc || langCode; const inspFlagIcon = inspFlagCc ? `` : null; const avatarHtml = picSrcInsp ? `` : inspFlagIcon || initial; const avatarBgStyle = (picSrcInsp || inspFlagIcon) ? '' : `style="background:${color}"`; const flagIconHtml = inspFlagIcon || ''; const makeStars = n => [1,2,3,4,5].map(i => `` ).join(''); const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK']; // Gender maps — used in template AND in event handlers const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'}; const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'}; const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'}; const curGender = v.gender || 'N'; const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`; inspector.innerHTML = `
${avatarHtml}

${escHtml(dispName)}

${escHtml(voiceId)}
${flagIconHtml} ${escHtml(langCode)} ${escHtml(genderLabelHtml)} ${isClone ? 'Clone' : 'Design'} ${makeStars(rating)} ${rating}/5
`; const saveSlot = inspector.querySelector('.insp-actions-save'); const activeSlot = inspector.querySelector('.insp-actions-active'); const deleteSlot = inspector.querySelector('.insp-actions-delete'); const body = inspector.querySelector('.inspector-body'); // ── Save changes → row 1 right ─────────────────────────────────────────── const saveBtn = document.createElement('button'); saveBtn.className = 'btn-primary insp-save-btn'; saveBtn.textContent = 'Save changes'; saveBtn.addEventListener('click', () => body.querySelector('.opt-save-text')?.click()); saveSlot.appendChild(saveBtn); // ── Active toggle → row 2 right ────────────────────────────────────────── const detailRow = wrap.querySelector('.vr-detail-row'); const activeEl = detailRow?.querySelector('.vr-detail-active'); const deleteEl = detailRow?.querySelector('.vr-detail-delete'); if (activeEl) activeSlot.appendChild(activeEl); // ── Delete → note row right ─────────────────────────────────────────────── if (deleteEl) { deleteSlot.appendChild(deleteEl); const _dBtn = deleteEl.querySelector('.delete-btn'); const _dCnl = deleteEl.querySelector('.delete-confirm-cancel'); _dBtn?.addEventListener('click', () => deleteEl.classList.add('delete-pending')); _dCnl?.addEventListener('click', () => deleteEl.classList.remove('delete-pending')); } // ── Avatar click → photo upload ─────────────────────────────────────────── inspector.querySelector('.inspector-avatar').addEventListener('click', () => { body.querySelector('.photo-input')?.click(); }); // ── Copy ID button ──────────────────────────────────────────────────────── inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => { copyText(voiceId).then(() => toast('Copied: ' + voiceId)); }); // ── Double-click name/ID → inline rename ────────────────────────────────── const dispNameEl = inspector.querySelector('.insp-disp-name'); const nameEditEl = inspector.querySelector('.insp-name-edit'); const fullIdEl = inspector.querySelector('.insp-full-id'); const startInspRename = () => { dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none'; nameEditEl.style.display = 'block'; nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select(); }; const commitInspRename = () => { dispNameEl.style.display = ''; fullIdEl.style.display = ''; nameEditEl.style.display = 'none'; const newId = nameEditEl.value.trim(); if (!newId || newId === voiceId) return; const rowInput = wrap.querySelector('.vr-name-input'); const rowOk = wrap.querySelector('.rename-ok'); if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); } }; dispNameEl.addEventListener('dblclick', startInspRename); fullIdEl.addEventListener('dblclick', startInspRename); nameEditEl.addEventListener('blur', commitInspRename); nameEditEl.addEventListener('keydown', e => { if (e.key === 'Enter') nameEditEl.blur(); if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); } }); // ── Flag (accent/country) — decoupled from language ────────────────────── const flagSpan = inspector.querySelector('.insp-flag'); const flagIconEl = inspector.querySelector('.insp-flag-icon'); const applyFlag = async (cc) => { v.flag = cc; wrap.dataset.flagCc = cc; const fi = ``; if (flagIconEl) flagIconEl.innerHTML = fi; await saveMeta(voiceId, { flag: cc }); }; flagSpan.addEventListener('click', () => { const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]); createSearchablePicker(flagSpan, items, applyFlag, { placeholder: 'Country or accent…', renderItem: (cc, name) => `${escHtml(name)}`, }); }); // ── Language — click lang code to change ───────────────────────────────── const langCodeEl = inspector.querySelector('.insp-lang-code'); const langWrap = inspector.querySelector('.insp-lang-wrap'); const applyLang = async (newLang) => { v.lang = newLang; wrap.dataset.lang = newLang; if (langCodeEl) langCodeEl.textContent = newLang; await saveMeta(voiceId, { lang: newLang }); }; langWrap?.addEventListener('click', () => { const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]); createSearchablePicker(langWrap, items, applyLang, { placeholder: 'Language…', renderItem: (l, label) => `${escHtml(l)}${escHtml(LANGUAGE_LABELS[l] || l)}`, }); }); // ── Gender label (subtitle) — click to cycle ────────────────────────────── const genderLabelEl = inspector.querySelector('.insp-gender-label'); const genderSel = inspector.querySelector('.insp-gender-sel'); const applyGender = async (ng) => { v.gender = ng; if (genderLabelEl) genderLabelEl.textContent = `${_gM[ng]||'?'} ${_gL[ng]||'—'}`; if (genderSel) genderSel.value = ng; const gBadge = wrap.querySelector('.gender-badge'); if (gBadge) { gBadge.innerHTML = `${_gM[ng]||'?'}${_gL[ng]||'—'}`; gBadge.className = 'gender-badge ' + (_gC[ng]||'g-n'); } await saveMeta(voiceId, { gender: ng }); }; genderLabelEl?.addEventListener('click', () => { const cycle = ['F','M','N']; applyGender(cycle[(cycle.indexOf(v.gender||'N')+1)%3]); }); genderSel?.addEventListener('change', () => applyGender(genderSel.value)); // ── Tag input — comma-separated, autocomplete from DB + localStorage ──────── const tagInput = inspector.querySelector('.insp-tag-input'); const getAllKnownTags = () => { const fromDb = (_voices || []).flatMap(vv => (vv.tag || '').split(',').map(t => t.trim()).filter(Boolean)); const fromStorage = getStoredTags(); return [...new Set([...fromStorage, ...fromDb])].sort((a, b) => a.localeCompare(b)); }; const tagLastToken = val => val.split(',').pop().trimStart(); const tagReplaceLastToken = (val, rep) => { const parts = val.split(','); parts[parts.length - 1] = parts.length > 1 ? ' ' + rep : rep; return parts.join(','); }; let _tagDrop = null; const hideTagDrop = () => { _tagDrop?.remove(); _tagDrop = null; }; const showTagDrop = () => { hideTagDrop(); const token = tagLastToken(tagInput.value); const all = getAllKnownTags(); const matches = all.filter(t => t.toLowerCase().startsWith(token.toLowerCase()) && t.toLowerCase() !== token.toLowerCase() ); if (!matches.length) return; _tagDrop = document.createElement('div'); _tagDrop.className = 'tag-suggest'; matches.slice(0, 10).forEach(tag => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tag-suggest-item'; btn.textContent = tag; btn.addEventListener('mousedown', e => { e.preventDefault(); tagInput.value = tagReplaceLastToken(tagInput.value, tag); tagInput.dispatchEvent(new Event('input')); hideTagDrop(); tagInput.focus(); }); _tagDrop.appendChild(btn); }); document.body.appendChild(_tagDrop); const r = tagInput.getBoundingClientRect(); _tagDrop.style.top = (r.bottom + 3) + 'px'; _tagDrop.style.left = r.left + 'px'; _tagDrop.style.minWidth = Math.max(r.width, 140) + 'px'; }; tagInput.addEventListener('input', () => showTagDrop()); tagInput.addEventListener('focus', () => showTagDrop()); tagInput.addEventListener('blur', () => setTimeout(hideTagDrop, 150)); tagInput.addEventListener('keydown', e => { if (e.key === 'Escape') hideTagDrop(); if (e.key === ',' && _tagDrop) setTimeout(showTagDrop, 10); }); const saveTagValue = debounce(async () => { const tags = tagInput.value.split(',').map(t => t.trim()).filter(Boolean); tags.forEach(addStoredTag); v.tag = tagInput.value; await saveMeta(voiceId, { tag: tagInput.value }); }, 700); tagInput.addEventListener('input', saveTagValue); tagInput.addEventListener('change', saveTagValue); // ── Interactive rating (subtitle + meta kept in sync) ───────────────────── const updateRating = async (newRating) => { v.rating = newRating; wrap.dataset.rating = newRating; inspector.querySelectorAll('.insp-star').forEach(s => s.classList.toggle('on', parseInt(s.dataset.val) <= newRating)); inspector.querySelectorAll('.insp-rating-label, .insp-meta-rating-label').forEach(el => el.textContent = `${newRating}/5`); wrap.querySelectorAll('.vr-rating .star').forEach((s, i) => s.classList.toggle('on', i < newRating)); await saveMeta(voiceId, { rating: newRating }); }; ['.insp-stars', '.insp-meta-stars'].forEach(sel => { const stars = [...inspector.querySelectorAll(`${sel} .insp-star`)]; stars.forEach(s => { s.addEventListener('click', () => { const val = parseInt(s.dataset.val); updateRating(val === (v.rating||0) ? 0 : val); }); s.addEventListener('mouseenter', () => { const val = parseInt(s.dataset.val); stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= val)); }); s.addEventListener('mouseleave', () => stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= (v.rating||0)))); }); }); // ── Note row ────────────────────────────────────────────────────────────── const noteEl = detailRow?.querySelector('.vr-note'); const noteRowEl = inspector.querySelector('.insp-note-slot'); if (noteEl && noteRowEl) noteRowEl.appendChild(noteEl); // ── Move main-row and optimizer into inspector body ─────────────────────── const mainRow = wrap.querySelector('.vr-main-row'); const optimizer = wrap.querySelector('.vr-optimizer'); if (mainRow) body.appendChild(mainRow); if (optimizer) body.appendChild(optimizer); if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e)); // ── Persona panel ───────────────────────────────────────────────────────── const personaVal = v.persona || ''; const personaPanel = document.createElement('div'); personaPanel.className = 'insp-persona-panel'; personaPanel.innerHTML = `
Character persona Used by LLM to rewrite text in this voice's style. Leave empty to skip.
`; body.appendChild(personaPanel); const personaTextarea = personaPanel.querySelector('.insp-persona-input'); const personaStatus = personaPanel.querySelector('.insp-persona-status'); personaPanel.querySelector('.insp-persona-save-btn').addEventListener('click', async () => { const pText = personaTextarea.value.trim(); try { await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ voice_id: voiceId, persona: pText }) }); v.persona = pText; personaStatus.textContent = 'Saved.'; setTimeout(() => { personaStatus.textContent = ''; }, 2000); toast('Persona saved', 'success'); } catch(e) { toast('Save failed: ' + e.message, 'error'); } }); const maintTitle = body.querySelector('.opt-maintenance .opt-group-title'); if (maintTitle) { maintTitle.innerHTML = `Loudness Current ${escHtml(dbfs)} dBFS`; } // ── Collapsible opt-groups ──────────────────────────────────────────────── body.querySelectorAll('.opt-group').forEach(group => { const title = group.querySelector(':scope > .opt-group-title'); if (!title) return; title.addEventListener('click', () => { group.classList.toggle('open'); }); }); // Track extracted elements for restoreToRow wrap._extracted = [ activeEl ? {el: activeEl, target: detailRow} : null, deleteEl ? {el: deleteEl, target: detailRow} : null, noteEl ? {el: noteEl, target: detailRow} : null, ].filter(Boolean); } let _toastTimer; function toast(msg, type = '', ms = 3500) { const el = $('toast'); el.textContent = msg; 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); }; } 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)); 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')); } }); // -- Get voices --------------------------------------------------------------- const DEFAULT_VOICE_SOURCE_URLS = [ 'https://aiartes.com/voiceai', 'https://sample-files.com/downloads/audio/wav/voice-sample.wav', 'https://freesound.org/people/Scott%20Simpson/', 'https://lanceblairvo.com/raw-voiceover-samples/', 'https://github.com/yaph/tts-samples/tree/main/mp3', 'https://github.com/jim-schwoebel/voice_datasets', ]; const VOICE_SOURCE_STORAGE_KEY = 'ttsvc-getvoices-sources'; let _voiceSourcePayload = null; let _voiceSourceItems = []; function sourceTextareaValueFromDefaults() { return DEFAULT_VOICE_SOURCE_URLS.join('\n'); } function initGetVoiceSourcesEditor() { const box = $('getvoices-sources'); if (!box || box.dataset.ready) return; box.value = localStorage.getItem(VOICE_SOURCE_STORAGE_KEY) || sourceTextareaValueFromDefaults(); box.dataset.ready = '1'; box.addEventListener('input', () => { localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value); _voiceSourcePayload = null; $('getvoices-status').textContent = 'Source list changed.'; }); } function getEditableVoiceSourceUrls() { initGetVoiceSourcesEditor(); return ($('getvoices-sources')?.value || '') .split(/\r?\n/) .map(line => line.trim()) .filter(line => line && !line.startsWith('#')); } function getVoiceSourceItems() { const sources = (_voiceSourcePayload && _voiceSourcePayload.sources) || []; return sources.flatMap(src => (src.items || []).map(item => ({...item, _sourceName: src.name, _sourceHomepage: src.homepage}))); } function voiceSourceSearchText(item) { return [item.name, item.kind, item.category, item.language, item.gender, item.description, item.source, item._sourceName].join(' ').toLowerCase(); } function setOptions(selectId, values, allLabel) { const sel = $(selectId); if (!sel) return; const current = sel.value || 'all'; sel.innerHTML = `` + values.map(value => ``).join(''); sel.value = values.includes(current) ? current : 'all'; } function renderGetVoices() { initGetVoiceSourcesEditor(); const list = $('getvoices-list'); const summary = $('getvoices-summary'); if (!list || !summary) return; const payload = _voiceSourcePayload || {sources:[], total:0, direct_audio:0, errors:[]}; const sources = payload.sources || []; const sourceFilter = $('getvoices-source-filter')?.value || 'all'; const languageFilter = $('getvoices-language-filter')?.value || 'all'; const genderFilter = $('getvoices-gender-filter')?.value || 'all'; const filetypeFilter = $('getvoices-filetype-filter')?.value || 'all'; const q = ($('getvoices-search')?.value || '').trim().toLowerCase(); const directOnly = !!$('getvoices-direct-only')?.checked; _voiceSourceItems = getVoiceSourceItems(); summary.innerHTML = [ [`${payload.total || 0}`, 'Items found'], [`${payload.direct_audio || 0}`, 'Direct audio'], [`${sources.length}`, 'Sources OK'], [`${(payload.errors || []).length}`, 'Errors'], ].map(([value, label]) => `
${escHtml(value)}${escHtml(label)}
`).join(''); setOptions('getvoices-source-filter', sources.map(src => src.id).filter(Boolean), 'Source: all'); const sourceSelect = $('getvoices-source-filter'); if (sourceSelect) { [...sourceSelect.options].forEach(option => { if (option.value === 'all') return; const src = sources.find(s => s.id === option.value); if (src) option.textContent = src.name || src.id; }); } const languages = [...new Set(_voiceSourceItems.map(item => item.language || 'Unknown'))].sort((a, b) => a.localeCompare(b)); const genders = [...new Set(_voiceSourceItems.map(item => item.gender || 'Unknown'))].sort((a, b) => a.localeCompare(b)); const filetypes = [...new Set(_voiceSourceItems.map(item => (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase()))].sort((a, b) => a.localeCompare(b)); setOptions('getvoices-language-filter', languages, 'Language: all'); setOptions('getvoices-gender-filter', genders, 'Sex: all'); setOptions('getvoices-filetype-filter', filetypes, 'Filetype: all'); let items = _voiceSourceItems.filter(item => { if (sourceFilter !== 'all' && item.source_id !== sourceFilter) return false; if (languageFilter !== 'all' && (item.language || 'Unknown') !== languageFilter) return false; if (genderFilter !== 'all' && (item.gender || 'Unknown') !== genderFilter) return false; const itemFiletype = (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase(); if (filetypeFilter !== 'all' && itemFiletype !== filetypeFilter) return false; if (directOnly && !item.direct_audio) return false; if (q && !voiceSourceSearchText(item).includes(q)) return false; return true; }); const shown = items.slice(0, 240); const more = items.length - shown.length; if (!shown.length) { const errorText = (payload.errors || []).map(e => `${e.source}: ${e.detail}`).join(' | '); list.innerHTML = `

No matching sources found.${errorText ? ' Source errors: ' + escHtml(errorText) : ''}

`; return; } list.innerHTML = shown.map(item => { const thumb = item.image_url ? `` : `
`; const audio = item.audio_url ? `` : ''; const audioLink = item.audio_url ? `Open audio` : ''; const canGetVoice = !!(item.import_url || item.audio_url); const importUrl = item.import_url || item.audio_url; const getVoice = canGetVoice ? `` : ''; const type = item.file_type ? `${escHtml(String(item.file_type).toUpperCase())}` : ''; const language = item.language ? `${escHtml(item.language)}` : ''; const gender = item.gender ? `${escHtml(item.gender)}` : ''; return `
${thumb}
${escHtml(item.name || 'Untitled')} ${escHtml(item._sourceName || item.source || '')}${item.category ? ' · ' + escHtml(item.category) : ''}
${escHtml(item.kind || '')}${item.description ? ' - ' + escHtml(item.description) : ''}
${audio}
${type}${language}${gender} ${audioLink} Source page
${getVoice ? `` : ''}
`; }).join('') + (more > 0 ? `

${more} more matches. Narrow the search or filters to see them.

` : ''); } async function loadGetVoices(force = false) { initGetVoiceSourcesEditor(); if (_voiceSourcePayload && !force) { renderGetVoices(); return; } const urls = getEditableVoiceSourceUrls(); const list = $('getvoices-list'); if (!urls.length) { if (list) list.innerHTML = '

Add at least one source URL, then scrape again.

'; $('getvoices-status').textContent = 'No sources.'; return; } if (list) list.innerHTML = loadingMarkup('Scraping voice sources', `Fetching ${urls.length} source${urls.length === 1 ? '' : 's'} from the editable list.`, 6); $('getvoices-status').textContent = 'Scraping...'; $('getvoices-refresh-btn').disabled = true; try { const r = await fetch('/api/voice-sources', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({urls}), }); if (!r.ok) { const text = await r.text().catch(() => ''); let message = r.statusText || `HTTP ${r.status}`; try { message = JSON.parse(text).detail || message; } catch (_) { if (text) message = text.slice(0, 160); } throw new Error(message); } _voiceSourcePayload = await r.json(); renderGetVoices(); const errors = (_voiceSourcePayload.errors || []).length; $('getvoices-status').textContent = `${_voiceSourcePayload.total || 0} found${errors ? `, ${errors} source errors` : ''}`; } catch(e) { if (list) list.innerHTML = `

Scrape failed: ${escHtml(e.message)}

Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.

`; $('getvoices-status').textContent = 'Scrape failed'; toast('Voice source scrape failed: ' + e.message, 'error'); } finally { $('getvoices-refresh-btn').disabled = false; } } ['getvoices-search', 'getvoices-source-filter', 'getvoices-language-filter', 'getvoices-gender-filter', 'getvoices-filetype-filter', 'getvoices-direct-only'].forEach(id => { const el = $(id); if (el) el.addEventListener(id === 'getvoices-search' ? 'input' : 'change', renderGetVoices); }); $('getvoices-refresh-btn')?.addEventListener('click', () => loadGetVoices(true)); $('getvoices-reset-sources-btn')?.addEventListener('click', () => { const box = $('getvoices-sources'); if (!box) return; box.value = sourceTextareaValueFromDefaults(); localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value); _voiceSourcePayload = null; renderGetVoices(); $('getvoices-status').textContent = 'Source list reset.'; }); const SOURCE_LANGUAGE_CODES = { english:'EN', german:'DE', deutsch:'DE', french:'FR', spanish:'ES', japanese:'JA', korean:'KO', italian:'IT', portuguese:'PT', russian:'RU', arabic:'AR', polish:'PL', dutch:'NL', swedish:'SV', turkish:'TR', hindi:'HI', chinese:'ZH' }; function sourceLanguageCode(language) { const raw = String(language || '').trim(); if (/^[A-Z]{2}$/.test(raw)) return raw; return SOURCE_LANGUAGE_CODES[raw.toLowerCase()] || 'EN'; } function sourceGenderCode(gender, name = '') { const text = `${gender || ''} ${name || ''}`.toLowerCase(); if (/female|woman|girl|\bf\b/.test(text)) return 'F'; if (/male|man|boy|\bm\b/.test(text)) return 'M'; return 'N'; } function suggestedVoiceIdFromSourceName(name, language = 'EN', gender = 'N') { const base = String(name || 'SourceVoice') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^A-Za-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 48) || 'SourceVoice'; return `${language || 'EN'}_${gender || 'N'}_${base}`; } function setLibAddSourcePreview(meta = {}) { const sourceBox = document.querySelector('.lib-add-source-box'); if (!sourceBox) return; let preview = $('lib-add-source-preview'); if (!preview) { preview = document.createElement('div'); preview.id = 'lib-add-source-preview'; preview.className = 'lib-add-source-preview'; sourceBox.appendChild(preview); } if (!meta.name && !meta.imageUrl) { preview.classList.remove('open'); preview.innerHTML = ''; return; } const image = meta.imageUrl ? `` : '
'; preview.innerHTML = `${image}
${escHtml(meta.name || 'Source voice')}${escHtml([meta.language, meta.gender, meta.kind].filter(Boolean).join(' · ') || 'Source metadata will be saved with the voice')}
`; preview.classList.add('open'); } async function getSourceVoiceInLibrary(meta) { if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; } const lang = sourceLanguageCode(meta.language); const gender = sourceGenderCode(meta.gender, meta.name); const voiceId = suggestedVoiceIdFromSourceName(meta.name, lang, gender); toast(`Importing ${meta.name || 'voice'}…`, 'info'); try { const r = await fetch('/api/quick-import-voice', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({audio_url: meta.url, voice_id: voiceId, transcript: ''}) }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _pendingSelectId = d.voice_id; navTo('s-voices'); _libraryLoadPromise = null; await loadVoiceLibrary(); toast(`Saved as ${d.voice_id}`, 'success'); } catch(e) { toast('Import failed: ' + e.message, 'error'); } } $('getvoices-list')?.addEventListener('click', async e => { const getBtn = e.target.closest('.get-source-voice'); if (getBtn) { await getSourceVoiceInLibrary({ url: getBtn.dataset.url || '', name: getBtn.dataset.name || '', imageUrl: getBtn.dataset.image || '', language: getBtn.dataset.language || '', gender: getBtn.dataset.gender || '', kind: getBtn.dataset.kind || '', description: getBtn.dataset.description || '', pageUrl: getBtn.dataset.page || '' }); return; } const btn = e.target.closest('.copy-source-url'); if (!btn) return; await copyText(btn.dataset.url || ''); toast('Source URL copied', 'success'); }); // ── Integration samples ────────────────────────────────────────────────── function cleanBaseUrl(url) { return String(url || '').trim().replace(/\/+$/, ''); } function getTtsBaseUrl() { return cleanBaseUrl($('s-tts-url')?.value) || 'http://localhost:8020'; } function getTtsV1Url() { const base = getTtsBaseUrl(); return base.endsWith('/v1') ? base : base + '/v1'; } function getTtsStreamBaseUrl() { return cleanBaseUrl($('s-tts-stream-url')?.value || _appSettings.tts_stream_url) || 'http://localhost:8023'; } function getTtsStreamV1Url() { const base = getTtsStreamBaseUrl(); return base.endsWith('/v1') ? base : base + '/v1'; } function getCreatorV1Url() { const loc = window.location; const protocol = loc.protocol || 'http:'; const port = loc.port ? ':' + loc.port : ''; const host = loc.hostname === '0.0.0.0' ? 'localhost' : loc.hostname; return `${protocol}//${host}${port}/v1`; } function updateCreatorUrlHints() { const warning = $('routing-url-warning'); const badBindHost = window.location.hostname === '0.0.0.0'; if (warning) warning.classList.toggle('show', badBindHost); } function activeVoiceIds() { return (_voices || []) .filter(v => v.enabled !== false) .slice() .sort((a, b) => a.id.localeCompare(b.id)) .map(v => v.id); } function integrationVoiceExample() { return activeVoiceIds()[0] || 'EN_F_ExampleVoice'; } function integrationVoiceList() { const ids = activeVoiceIds(); return ids.length ? ids.join(', ') : 'EN_F_ExampleVoice, DE_M_ExampleVoice'; } function virtualDesignVoiceIds() { return Object.keys(loadDesignPresets ? loadDesignPresets() : {}) .sort((a,b)=>a.localeCompare(b)) .map(name => 'vd_' + name.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '')); } function renderIntegrationSnippets() { if (!$('snippet-sillytavern')) return; const base = getTtsBaseUrl(); const v1 = getTtsV1Url(); const streamV1 = getTtsStreamV1Url(); const proxyV1 = getCreatorV1Url(); const proxyBase = proxyV1.replace(/\/v1$/, ''); updateCreatorUrlHints(); const voice = integrationVoiceExample(); const voices = integrationVoiceList(); const vdVoices = virtualDesignVoiceIds(); const vdVoice = vdVoices[0] || 'vd_EN_F_Warm_Narrator'; $('integration-url-label').textContent = 'TTS backend: ' + base; $('snippet-sillytavern').textContent = `Provider: OpenAI compatible TTS API base URL: ${v1} API key: dummy Model: qwen3-tts Voice: ${voice} Custom voices: ${voices} Streaming backend, if your SillyTavern TTS extension supports progressive playback: API base URL: ${streamV1} Endpoint: /audio/speech Format: wav`; $('snippet-streaming-howto').textContent = `Direct streaming backend, no creator routing: API base URL: ${streamV1} Endpoint: /audio/speech Model: tts-1 Voice: ${voice} Response format: wav Requirement: the app must start playback while the HTTP response is still arriving. Current 8023 streaming service ignores per-request instruct/style text. Streaming through TTS Voice Creator routing: API base URL: ${proxyV1} Voice: default or another incoming route voice Routing tab: set Backend = Streaming for the matching rule Response format: wav Avoid before/after sounds for true streaming; route sounds and MP3 require buffering. SillyTavern note: Use OpenAI-compatible TTS if your extension supports progressive audio responses. If it waits for the whole file before playing, streaming works technically but will feel like buffered TTS.`; $('snippet-open-webui').textContent = `Admin settings -> Audio -> Text-to-Speech Engine/provider: OpenAI compatible API base URL: ${proxyV1} API key: dummy Model: tts-1 Voice: default Routing tab example: default + EN -> ${voice} default + DE -> DE_M_YourGermanVoice`; $('snippet-home-assistant').textContent = `Home Assistant OpenAI TTS agent: Base URL: ${proxyV1} API key: dummy Model: tts-1 Voice: default Extra JSON payload: {"app":"Home Assistant"} Audio format: mp3 or wav REST command example using routing: rest_command: routed_tts: url: "${proxyV1}/audio/speech" method: POST content_type: "application/json" headers: Authorization: "Bearer dummy" payload: > {"model":"tts-1","voice":"default","input":"{{ text }}","response_format":"mp3","app":"Home Assistant"} Important: use this Creator proxy URL, not the direct Qwen3 backend URL ${v1}. Routing only runs through the Creator proxy.`; $('snippet-curl').textContent = `curl -s "${v1}/models" curl -s "${v1}/audio/speech" \\ -H "Authorization: Bearer dummy" \\ -H "Content-Type: application/json" \\ -d '{"model":"qwen3-tts","voice":"${voice}","input":"Hello from Qwen3 TTS","response_format":"wav"}' \\ --output qwen3-tts-test.wav`; $('snippet-voice-design-proxy').textContent = `Virtual VoiceDesign mode - no WAV export needed Use this app as the OpenAI-compatible TTS endpoint: API base URL: ${proxyV1} API key: dummy Model: tts-1 Voice: ${vdVoice} Available virtual voices: ${vdVoices.length ? vdVoices.join(', ') : 'Save a Voice Design prompt preset first.'} curl -s "${proxyV1}/models" curl -s "${proxyV1}/audio/speech" \\ -H "Authorization: Bearer dummy" \\ -H "Content-Type: application/json" \\ -d '{"model":"tts-1","voice":"${vdVoice}","input":"This line is generated through the VoiceDesign container.","response_format":"wav"}' \\ --output voicedesign-virtual.wav`; if ($('snippet-mcp-claude-cmd')) $('snippet-mcp-claude-cmd').textContent = `claude mcp add voice-creator \\ --transport http \\ --url ${proxyBase}/mcp \\ --header "X-Voice-Creator-Client-Id: claude-code"`; if ($('snippet-mcp-claude-config')) $('snippet-mcp-claude-config').textContent = `{ "mcpServers": { "voice-creator": { "url": "${proxyBase}/mcp", "headers": { "X-Voice-Creator-Client-Id": "my-agent" } } } }`; if ($('snippet-speak')) $('snippet-speak').textContent = `# Bind your client ID to a voice once (persisted in Settings) curl -X PUT ${proxyBase}/speak/bindings/my-script \\ -H "Content-Type: application/json" \\ -d '{"voice":"${voice}"}' # Generate speech — voice from binding, persona rewrite optional curl -X POST ${proxyBase}/speak \\ -H "Content-Type: application/json" \\ -H "X-Voice-Creator-Client-Id: my-script" \\ -d '{"text":"Hello world","apply_persona":true}' \\ --output speech.wav # Or specify voice directly curl -X POST ${proxyBase}/speak \\ -H "Content-Type: application/json" \\ -d '{"text":"Hello world","voice":"${voice}"}' \\ --output speech.wav`; } document.querySelectorAll('.copy-snippet').forEach(btn => btn.addEventListener('click', async () => { const el = $(btn.dataset.snippet); if (!el) return; await copyText(el.textContent); toast('Snippet copied', 'success'); })); $('show-api-btn')?.addEventListener('click', () => { window.open('/docs', '_blank', 'noopener'); }); $('integration-refresh-btn')?.addEventListener('click', () => { renderIntegrationSnippets(); toast('Integration examples refreshed', 'success'); }); $('copy-active-voices-btn-integrations')?.addEventListener('click', async () => { const active = activeVoiceIds(); if (!active.length) { toast('No active voices to copy', 'error'); return; } await copyText(active.join(', ')); toast('Copied ' + active.length + ' active voices', 'success'); status('Copied active voices to clipboard'); }); // ── TTS routing ────────────────────────────────────────────────────────── let _ttsRoutes = []; const ROUTE_LANGS = [ ['*', 'Any'], ['AUTO', 'Auto'], ['EN', 'English'], ['DE', 'German'], ['FR', 'French'], ['ES', 'Spanish'], ['IT', 'Italian'], ['PT', 'Portuguese'], ['NL', 'Dutch'], ['PL', 'Polish'], ]; function routeSelectOptions(value) { return ROUTE_LANGS.map(([code, label]) => `` ).join(''); } const ROUTE_BACKENDS = [ ['voice_clone', 'Voice Clone'], ['streaming', 'Streaming'], ['voice_design', 'Voice Design'], ['nvidia_magpie', 'NVIDIA Magpie'], ['nvidia_zeroshot', 'NVIDIA Zeroshot'], ['nvidia_flow', 'NVIDIA Flow'], ]; let _routeSounds = []; function routeSoundOptions(value = '') { const current = String(value || ''); const listed = new Set(_routeSounds.map(s => String(s.path || ''))); const options = ['']; if (current && !listed.has(current)) options.push(``); _routeSounds.forEach(sound => { const path = String(sound.path || ''); if (!path) return; const duration = sound.duration != null ? ` · ${Number(sound.duration).toFixed(1)}s` : ''; options.push(``); }); return options.join(''); } async function loadRouteSounds() { try { const r = await fetch('/api/route-sounds'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _routeSounds = Array.isArray(d.sounds) ? d.sounds : []; } catch (_) { _routeSounds = []; } } let _routeSoundPickerTarget = null; let _routeSoundPlayingButton = null; function routeSoundUrl(path) { return '/api/route-sounds/file/' + String(path || '').split('/').map(encodeURIComponent).join('/'); } function setRouteSoundField(row, target, path) { const field = row?.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound'); if (!field) return; field.value = path || ''; readRoutingForm(); } function renderRouteSoundBrowser() { const list = $('routing-sound-list'); if (!list) return; const query = String($('routing-sound-search')?.value || '').trim().toLowerCase(); const sounds = query ? _routeSounds.filter(sound => String(sound.path || '').toLowerCase().includes(query) || String(sound.name || '').toLowerCase().includes(query)) : _routeSounds; if (!_routeSounds.length) { list.innerHTML = '
No sounds found yet. Upload one in a route row; only the selected file is imported.
'; return; } if (!sounds.length) { list.innerHTML = '
No sounds match this search.
'; return; } list.innerHTML = sounds.map(sound => { const path = String(sound.path || ''); const size = sound.size != null ? Math.max(1, Math.round(Number(sound.size) / 1024)) + ' KB' : ''; const duration = sound.duration != null ? Number(sound.duration).toFixed(1) + 's' : size; const type = sound.type ? String(sound.type).toUpperCase() : ''; const targetLabel = _routeSoundPickerTarget?.target === 'after' ? 'Use after' : 'Use before'; return `
${escHtml(path)}
${escHtml(duration || '-')} ${type ? '· ' + escHtml(type) : ''}
`; }).join(''); } async function openRouteSoundBrowser(row, target) { _routeSoundPickerTarget = {row, target}; await loadRouteSounds(); renderRouteSoundBrowser(); const panel = $('routing-sound-browser'); if (panel) { panel.hidden = false; panel.scrollIntoView({block:'nearest', behavior:'smooth'}); } const label = target === 'before' ? 'before sound' : 'after sound'; const note = $('routing-sound-browser-note'); if (note) note.textContent = `Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`; } function closeRouteSoundBrowser() { const panel = $('routing-sound-browser'); if (panel) panel.hidden = true; const audio = $('routing-sound-preview'); if (audio) { audio.pause(); audio.hidden = true; audio.removeAttribute('src'); } if (_routeSoundPlayingButton) _routeSoundPlayingButton.textContent = '▶'; _routeSoundPlayingButton = null; _routeSoundPickerTarget = null; } function playRouteSound(path, btn) { const audio = $('routing-sound-preview'); if (!audio || !path) return; if (_routeSoundPlayingButton && _routeSoundPlayingButton !== btn) _routeSoundPlayingButton.textContent = '▶'; _routeSoundPlayingButton = btn; btn.textContent = '❚❚'; audio.hidden = false; audio.src = routeSoundUrl(path); audio.onended = () => { btn.textContent = '▶'; }; audio.onpause = () => { if (_routeSoundPlayingButton === btn) btn.textContent = '▶'; }; audio.onplay = () => { btn.textContent = '❚❚'; }; audio.play().catch(e => { btn.textContent = '▶'; toast('Sound preview failed: ' + e.message, 'error'); }); } function useRouteSound(path, target) { const selected = _routeSoundPickerTarget || {}; const row = selected.row || document.querySelector('.routing-row'); const useTarget = target || selected.target || 'before'; setRouteSoundField(row, useTarget, path); toast(`${useTarget === 'before' ? 'Before' : 'After'} sound selected`, 'success'); } function routeBackendOptions(value) { const current = value || 'voice_clone'; return ROUTE_BACKENDS.map(([code, label]) => `` ).join(''); } function refreshRoutingVoiceOptions() { const dl = $('routing-voice-options'); if (dl) { const ids = [...activeVoiceIds(), ...virtualDesignVoiceIds()]; dl.innerHTML = [...new Set(ids)].map(id => ``).join(''); } const soundsDl = $('routing-sound-options'); if (soundsDl) soundsDl.innerHTML = _routeSounds.map(sound => ``).join(''); } function newRoute(app = 'Open WebUI', inputVoice = 'default', language = '*', outputVoice = '') { return { id: 'route_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6), enabled: true, app, input_voice: inputVoice, language, backend: 'voice_clone', output_voice: outputVoice, before_sound: '', after_sound: '', }; } function renderRoutingList() { if (!$('routing-list')) return; refreshRoutingVoiceOptions(); $('routing-proxy-url').textContent = getCreatorV1Url(); updateCreatorUrlHints(); $('routing-status').textContent = _ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.'; if (!_ttsRoutes.length) { $('routing-list').innerHTML = '
No routing rules yet. Add a route or add the Open WebUI default examples.
'; return; } $('routing-list').innerHTML = _ttsRoutes.map((r, i) => `
`).join(''); } function readRoutingForm() { _ttsRoutes = [...document.querySelectorAll('.routing-row')].map((row, i) => { const existing = _ttsRoutes[Number(row.dataset.index)] || {}; return { id: existing.id || `route_${i+1}`, enabled: row.querySelector('.route-enabled').checked, app: row.querySelector('.route-app').value.trim() || '*', input_voice: row.querySelector('.route-input').value.trim() || 'default', language: row.querySelector('.route-lang').value || '*', backend: row.querySelector('.route-backend').value || 'voice_clone', output_voice: row.querySelector('.route-output').value.trim(), before_sound: row.querySelector('.route-before-sound').value.trim(), after_sound: row.querySelector('.route-after-sound').value.trim(), }; }); } async function loadRoutingTab() { if (!$('routing-list')) return; $('routing-proxy-url').textContent = getCreatorV1Url(); updateCreatorUrlHints(); $('routing-status').textContent = 'Loading routing…'; $('routing-list').innerHTML = loadingMarkup('Loading routing', 'Loading active voices and routing rules for the proxy.', 5); setBusyButton('routing-refresh-btn', true); try { if (!_voices.length) await loadVoiceLibrary(); await loadRouteSounds(); const r = await fetch('/api/tts-routes'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _ttsRoutes = Array.isArray(d.routes) ? d.routes : []; renderRoutingList(); status('Routing loaded'); loadRoutingLog(); } catch(e) { $('routing-status').textContent = 'Load failed'; $('routing-list').innerHTML = '
Failed to load routes
'; status('Routing load failed'); } finally { setBusyButton('routing-refresh-btn', false); } } async function saveRoutingTab() { readRoutingForm(); $('routing-save-btn').disabled = true; try { const r = await fetch('/api/tts-routes', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({routes:_ttsRoutes}), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _ttsRoutes = d.routes || _ttsRoutes; renderRoutingList(); toast('Routing saved', 'success'); status('TTS routing saved'); } catch(e) { toast('Save routes failed: ' + e.message, 'error'); } finally { $('routing-save-btn').disabled = false; } } function renderRouteTestResult(d) { const el = $('routing-test-result'); if (!el) return; const health = d.voice_health || {}; const warnings = Array.isArray(health.warnings) ? health.warnings : []; el.className = 'routing-test-result ' + (warnings.length ? 'warn' : 'ok'); const parts = [ `${escHtml(d.requested_voice || '')} → ${escHtml(d.routed_voice || '')}`, `app ${escHtml(d.app || '-')}`, `backend ${escHtml(d.backend || 'voice_clone')}`, `language ${escHtml(d.detected_language || '-')}`, d.matched ? 'matched route' : 'no route matched', ]; if (health.duration) parts.push(`reference ${health.duration}s`); if (health.word_count != null) parts.push(`${health.word_count} words`); if (health.words_per_sec) parts.push(`${health.words_per_sec} words/s`); if (warnings.length) { parts.push('Warning: ' + warnings.map(escHtml).join('; ')); } const sounds = d.sounds || {}; for (const [key, sound] of Object.entries(sounds)) { const label = key === 'before_sound' ? 'before sound' : 'after sound'; parts.push(sound.ok ? `${label} OK` : `${label}: ${escHtml(sound.error || 'not found')}`); if (!sound.ok) el.className = 'routing-test-result warn'; } el.innerHTML = parts.join(' · '); } function routingLogTime(ts) { if (!ts) return '--:--:--'; const d = new Date(ts); if (Number.isNaN(d.getTime())) return String(ts).slice(11, 19) || '--:--:--'; return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); } function routingLogBadge(item) { const status = String(item.status || item.kind || 'log'); if (item.kind === 'test' && status === 'matched') return 'test ok'; if (item.kind === 'test' && status === 'no_match') return 'test miss'; return status.replace(/_/g, ' '); } function routingLogMeta(item) { const parts = []; if (item.backend) parts.push(item.backend); if (item.language) parts.push('lang ' + item.language); if (item.response_format) parts.push(item.response_format); if (item.duration != null) parts.push(Number(item.duration).toFixed(2) + 's'); if (item.bytes != null) parts.push(Math.round(Number(item.bytes) / 1024) + ' KB'); if (item.sounds && Array.isArray(item.sounds) && item.sounds.length) parts.push('sounds ' + item.sounds.join(',')); if (item.route_id) parts.push('route ' + item.route_id); if (item.client) parts.push(item.client); return parts.join(' · '); } let _routingLogItems = []; let _routingLogFilter = 'all'; function routingLogPassesFilter(item) { const status = String(item?.status || '').toLowerCase(); const isError = status === 'error' || Boolean(item?.error); const isNoMatch = status === 'no_match' || item?.matched === false; if (_routingLogFilter === 'error') return isError; if (_routingLogFilter === 'no_match') return isNoMatch; if (_routingLogFilter === 'attention') return isError || isNoMatch; return true; } function renderCurrentRoutingLog() { renderRoutingLog(_routingLogItems.filter(routingLogPassesFilter)); } function renderRoutingLog(items = []) { const el = $('routing-log-list'); if (!el) return; if (!items.length) { const filtered = _routingLogItems.length && _routingLogFilter !== 'all'; el.innerHTML = `
${filtered ? 'No routing log entries match this filter.' : 'No routing log entries yet. Test a route or send a TTS request through the Creator proxy.'}
`; return; } el.innerHTML = items.map(item => { const status = String(item.status || 'log').replace(/[^a-z0-9_-]/gi, '_'); const requested = item.requested_voice || '-'; const routed = item.routed_voice || '-'; const voice = requested === routed ? requested : `${requested} → ${routed}`; const text = item.error ? `Error: ${item.error}` : (item.text_preview || ''); return `
${escHtml(routingLogTime(item.ts))}
${escHtml(routingLogBadge(item))}
${escHtml(item.app || '-')}
${escHtml(voice)}
${escHtml(routingLogMeta(item) || '-')}
${escHtml(text || '-')}
`; }).join(''); } async function loadRoutingLog() { const el = $('routing-log-list'); if (!el) return; try { const r = await fetch('/api/tts-routing-log?limit=80'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _routingLogItems = Array.isArray(d.items) ? d.items : []; renderCurrentRoutingLog(); } catch(e) { el.innerHTML = `
Routing log unavailable: ${escHtml(e.message)}
`; } } async function clearRoutingLog() { const btn = $('routing-log-clear-btn'); if (btn) btn.disabled = true; try { const r = await fetch('/api/tts-routing-log', {method:'DELETE'}); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } _routingLogItems = []; renderRoutingLog([]); toast('Routing log cleared', 'success'); } catch(e) { toast('Clear log failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } async function testRouting() { readRoutingForm(); const btn = $('routing-test-btn'); const el = $('routing-test-result'); btn.disabled = true; el.className = 'routing-test-result'; el.textContent = 'Testing route...'; try { const r = await fetch('/api/tts-route-test', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ app: $('routing-test-app').value.trim() || 'Open WebUI', voice: $('routing-test-voice').value.trim() || 'default', input: $('routing-test-text').value.trim(), }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } renderRouteTestResult(await r.json()); loadRoutingLog(); } catch(e) { el.className = 'routing-test-result warn'; el.textContent = 'Route test failed: ' + e.message; } finally { btn.disabled = false; } } async function uploadRouteSoundForRow(row, target) { const input = document.createElement('input'); input.type = 'file'; input.accept = 'audio/*'; input.multiple = false; input.onchange = async () => { if (!input.files || !input.files.length) return; const btn = row.querySelector(`.route-sound-upload[data-target="${target}"]`); const field = row.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound'); if (btn) btn.disabled = true; try { const fd = new FormData(); fd.append('file', input.files[0]); const r = await fetch('/api/route-sounds/upload', { method:'POST', body:fd }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); field.value = d.path || ''; await loadRouteSounds(); readRoutingForm(); renderRoutingList(); toast(`${target === 'before' ? 'Before' : 'After'} sound uploaded`, 'success'); status(`Uploaded route sound: ${d.path}`); } catch(e) { toast('Sound upload failed: ' + e.message, 'error'); status('Sound upload failed'); } finally { if (btn) btn.disabled = false; } }; input.click(); } $('routing-refresh-btn')?.addEventListener('click', loadRoutingTab); $('routing-add-btn')?.addEventListener('click', () => { readRoutingForm(); _ttsRoutes.push(newRoute()); renderRoutingList(); }); $('routing-add-openwebui-btn')?.addEventListener('click', () => { readRoutingForm(); const voices = activeVoiceIds(); const firstByLang = lang => voices.find(v => v.toUpperCase().startsWith(lang + '_')) || ''; _ttsRoutes.push(newRoute('Open WebUI', 'default', 'EN', firstByLang('EN'))); _ttsRoutes.push(newRoute('Open WebUI', 'default', 'DE', firstByLang('DE'))); renderRoutingList(); }); $('routing-save-btn')?.addEventListener('click', saveRoutingTab); $('routing-test-btn')?.addEventListener('click', testRouting); $('routing-log-refresh-btn')?.addEventListener('click', loadRoutingLog); $('routing-log-clear-btn')?.addEventListener('click', clearRoutingLog); $('routing-log-filter')?.addEventListener('change', (e) => { _routingLogFilter = e.target.value || 'all'; renderCurrentRoutingLog(); }); $('routing-list')?.addEventListener('change', e => { const picker = e.target.closest('.route-sound-picker'); if (!picker) return; const row = picker.closest('.routing-row'); const field = row.querySelector(picker.dataset.target === 'before' ? '.route-before-sound' : '.route-after-sound'); if (field) field.value = picker.value || ''; readRoutingForm(); }); $('routing-list')?.addEventListener('click', e => { const pickBtn = e.target.closest('.route-sound-pick'); if (pickBtn) { const row = pickBtn.closest('.routing-row'); openRouteSoundBrowser(row, pickBtn.dataset.target); return; } const uploadBtn = e.target.closest('.route-sound-upload'); if (uploadBtn) { const row = uploadBtn.closest('.routing-row'); uploadRouteSoundForRow(row, uploadBtn.dataset.target); return; } const btn = e.target.closest('.routing-delete'); if (!btn) return; readRoutingForm(); const row = btn.closest('.routing-row'); _ttsRoutes.splice(Number(row.dataset.index), 1); renderRoutingList(); }); $('routing-sound-search')?.addEventListener('input', debounce(renderRouteSoundBrowser, 120)); $('routing-sound-refresh-btn')?.addEventListener('click', async () => { await loadRouteSounds(); renderRouteSoundBrowser(); }); $('routing-sound-close-btn')?.addEventListener('click', closeRouteSoundBrowser); $('routing-sound-list')?.addEventListener('click', e => { const item = e.target.closest('.routing-sound-item'); if (!item) return; const path = item.dataset.path || ''; const playBtn = e.target.closest('.sound-play'); if (playBtn) { playRouteSound(path, playBtn); return; } if (e.target.closest('.sound-use-current')) { useRouteSound(path, _routeSoundPickerTarget?.target || 'before'); } }); // ── Settings ────────────────────────────────────────────────────────────── const SETTINGS_SEEN_KEY = 'vcf-settings-seen'; let _appSettings = {}; let _ttsBackends = []; function availableTtsBackends() { return (_ttsBackends || []).filter(b => b.available); } function ttsBackendOptions(selected = '') { const backends = availableTtsBackends(); if (!backends.length) return ''; const current = selected || backends[0].id; return backends.map(b => ``).join(''); } function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) { const backends = availableTtsBackends(); if (!backends.length) return ''; const preferred = backends.some(b => b.id === selected) ? selected : preferStyleAware ? (backends.find(b => b.style_aware)?.id || backends[0].id) : backends[0].id; return backends.map(b => ``).join(''); } function backendById(id) { return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null; } function backendHelpHtml(b, compact = false) { if (!b) return 'No TTS backend is reachable.
Start at least one TTS service or check Settings URLs.
'; const tags = [ b.uses_wav ? ['good', 'uses WAV identity'] : ['warn', 'prompt/model voice'], b.style_aware ? ['good', 'style-aware'] : ['warn', 'weak style'], b.true_streaming ? ['good', 'true streaming'] : ['', 'buffered/normal'], ].map(([cls, text]) => `${escHtml(text)}`).join(''); const metricParts = []; if (b.speed) metricParts.push(` ${escHtml(b.speed)}`); if (b.latency) metricParts.push(` ${escHtml(b.latency)}`); if (b.quality) metricParts.push(` ${escHtml(b.quality)}`); if (b.ram) metricParts.push(` ${escHtml(b.ram)}`); const metrics = metricParts.length ? `
${metricParts.join('')}
` : ''; const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}
Identity: ${escHtml(b.identity || '')}
Style: ${escHtml(b.style || '')}
Best for: ${escHtml(b.best_for || '')}`; return `${escHtml(b.label)}
${tags}
${metrics}
${detail}
`; } function sttBackendHelpHtml(b) { if (!b) return 'No STT engine selected.'; const m = b.metrics || {}; const metricParts = []; if (m.speed) metricParts.push(` ${escHtml(m.speed)}`); if (m.latency) metricParts.push(` ${escHtml(m.latency)}`); if (m.quality) metricParts.push(` ${escHtml(m.quality)}`); if (m.ram) metricParts.push(` ${escHtml(m.ram)}`); const metrics = metricParts.length ? `
${metricParts.join('')}
` : ''; const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : ''; const avail = b.available ? `ready` : `unavailable`; return `${escHtml(b.label)} ${avail}${metrics}
${escHtml(b.url)}${escHtml(modelList)}
`; } function updateBackendHelp() { const b = backendById($('tts-backend-select')?.value || ''); const help = $('tts-backend-help'); if (help) help.innerHTML = backendHelpHtml(b); const sttB = backendById($('stt-tts-backend-select')?.value || ''); const sttHelp = $('stt-tts-backend-help'); if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB); } function updateStyleBackendHelp(scope = document) { scope.querySelectorAll('.opt-style-backend').forEach(sel => { const box = sel.closest('.opt-style-panel')?.querySelector('.opt-style-backend-help'); if (!box) return; const b = backendById(sel.value); box.innerHTML = backendHelpHtml(b, true); if (b && !b.style_aware) { const styleAwareBacks = availableTtsBackends().filter(x => x.style_aware); const suggestion = styleAwareBacks.length ? ` Try ${escHtml(styleAwareBacks[0].label)} instead.` : ' No style-aware backend is currently reachable.'; box.innerHTML += `
This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}
`; } }); } function updateBackendDependentTabs() { const availableBackends = availableTtsBackends(); const available = new Set(availableBackends.map(b => b.id)); document.querySelectorAll('.tab[data-backend-required]').forEach(tab => { const originalSubtitle = tab.dataset.originalSubtitle || tab.querySelector('.tab-subtitle')?.textContent || ''; const originalTooltip = tab.dataset.originalTooltip || tab.querySelector('.tab-tooltip')?.textContent || ''; tab.dataset.originalSubtitle = originalSubtitle; tab.dataset.originalTooltip = originalTooltip; const required = tab.dataset.backendRequired; const ok = required === 'any_tts' ? availableBackends.length > 0 : available.has(required); tab.hidden = false; tab.classList.toggle('backend-unavailable', !ok); tab.setAttribute('aria-disabled', ok ? 'false' : 'true'); tab.tabIndex = ok ? 0 : -1; const subtitle = tab.querySelector('.tab-subtitle'); const tooltip = tab.querySelector('.tab-tooltip'); if (subtitle) subtitle.textContent = ok ? originalSubtitle : 'not running/configured'; if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`; }); const active = document.querySelector('.tab.active'); if (!active || active.classList.contains('backend-unavailable')) { const first = document.querySelector('.tab:not(.backend-unavailable)'); if (first) switchTab(first.dataset.tab); } } async function refreshTtsBackendAvailability(selected = '') { try { const d = await fetch('/api/tts-backends').then(r => r.json()); _ttsBackends = (d.backends || []).filter(b => b && b.id); } catch (_) { _ttsBackends = []; } const preview = $('tts-backend-select'); if (preview) { const prev = selected || preview.value; preview.innerHTML = ttsBackendOptions(prev); preview.disabled = !availableTtsBackends().length; } const sttTtsBackend = $('stt-tts-backend-select'); if (sttTtsBackend) { const prev = selected || sttTtsBackend.value; sttTtsBackend.innerHTML = ttsBackendOptions(prev); sttTtsBackend.disabled = !availableTtsBackends().length; } const libraryTts = $('library-tts-backend-select'); if (libraryTts) { const prev = libraryTts.value || 'voice_clone'; libraryTts.innerHTML = ttsBackendOptions(prev); libraryTts.disabled = !availableTtsBackends().length; } document.querySelectorAll('.opt-style-backend').forEach(sel => { const prev = sel.value; sel.innerHTML = styleBackendOptions(prev); sel.disabled = !availableTtsBackends().length; }); document.querySelectorAll('.opt-compare-backend').forEach(sel => { const prev = sel.value && sel.value !== '' ? sel.value : 'voice_clone'; sel.innerHTML = styleBackendOptions(prev); sel.disabled = !availableTtsBackends().length; }); const perfSel = $('perf-backend-select'); if (perfSel) { const prev = perfSel.value; perfSel.innerHTML = ttsBackendOptions(prev); perfSel.disabled = !availableTtsBackends().length; } const batchSel = $('batch-backend-select'); if (batchSel) { const prev = batchSel.value; batchSel.innerHTML = ttsBackendOptions(prev); batchSel.disabled = !availableTtsBackends().length; } updateBackendHelp(); updateStyleBackendHelp(); updateBackendDependentTabs(); // Call any post-refresh hooks registered by sub-sections (e.g. conversation panel) (window._ttsRefreshHooks || []).forEach(fn => { try { fn(); } catch(_) {} }); return _ttsBackends; } async function _patchSettings(patch) { try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); } catch (e) { console.warn('[settings] patch failed', e); } } let _engineUrlSaveTimer = null; function _saveEngineLocalUrls() { clearTimeout(_engineUrlSaveTimer); _engineUrlSaveTimer = setTimeout(() => { const urls = {}; document.querySelectorAll('[data-llm-local-key]').forEach(i => { if (i.value) urls[i.dataset.llmLocalKey] = i.value; }); _patchSettings({ engine_local_urls: urls }); if (_appSettings) _appSettings.engine_local_urls = urls; }, 800); } async function loadSettings() { const s = await fetch('/api/settings').then(r => r.json()); $('s-whisper-url').value = s.whisper_url || ''; $('s-whisper-key').value = s.whisper_api_key || ''; $('s-tts-url').value = s.tts_url || ''; $('s-faster-whisper-url').value = s.faster_whisper_url || ''; $('s-whisper-cpp-url').value = s.whisper_cpp_url || ''; $('s-groq-api-key').value = s.groq_api_key || ''; $('s-kokoro-url').value = s.kokoro_url || ''; $('s-vibevoice-url').value = s.vibevoice_url || ''; $('s-xtts-url').value = s.xtts_url || ''; const llmUrlEl = $('s-llm-url'); if (llmUrlEl) llmUrlEl.value = s.llm_url || ''; _appSettings = s; // Restore engine URL inputs from server (overrides localStorage fallback) const savedEngineUrls = s.engine_local_urls || {}; document.querySelectorAll('[data-llm-local-key]').forEach(inp => { const v = savedEngineUrls[inp.dataset.llmLocalKey]; if (v) inp.value = v; }); // Restore LLM URLs for refinement and conversation panels const refineInp = $('refine-llm-url'); if (refineInp && s.refine_llm_url) refineInp.value = s.refine_llm_url; const convInp = $('conv-llm-url'); if (convInp && s.conv_llm_url) convInp.value = s.conv_llm_url; $('s-tts-stream-url').value = s.tts_stream_url || ''; $('s-customvoice-url').value = s.customvoice_url || 'http://host.docker.internal:8022'; $('s-nvidia-router-url').value = s.nvidia_router_url || 'http://host.docker.internal:8090'; $('s-nvidia-tts-url').value = s.nvidia_tts_url || 'http://host.docker.internal:8091'; $('s-nvidia-asr-url').value = s.nvidia_asr_url || 'http://host.docker.internal:8092'; $('s-nvidia-zeroshot-url').value = s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093'; $('s-nvidia-flow-url').value = s.nvidia_flow_url || 'http://host.docker.internal:8094'; $('s-tts-stream-mode').value = s.tts_stream_mode || 'auto'; $('s-tts-key').value = s.tts_api_key || ''; $('s-tts-backend').value = s.tts_backend || 'openai'; const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0}; const byBackend = s.tts_extra_params_by_backend || {}; $('s-tts-extra-voice-clone').value = JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2); $('s-tts-extra-streaming').value = JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2); $('s-tts-extra-customvoice').value = JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2); $('s-tts-extra-voice-design').value = JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2); $('s-tts-extra-nvidia-magpie').value = JSON.stringify(byBackend.nvidia_magpie || {}, null, 2); $('s-tts-extra-nvidia-zeroshot').value = JSON.stringify(byBackend.nvidia_zeroshot || {}, null, 2); $('s-tts-extra-nvidia-flow').value = JSON.stringify(byBackend.nvidia_flow || {}, null, 2); $('s-tts-extra-kokoro').value = JSON.stringify(byBackend.kokoro || {}, null, 2); const vibevoiceExtraEl = $('s-tts-extra-vibevoice'); if (vibevoiceExtraEl) vibevoiceExtraEl.value = JSON.stringify(byBackend.vibevoice || {}, null, 2); $('s-voice-design-url').value = s.voice_design_url || 'http://host.docker.internal:8021'; $('s-vd-key').value = s.voice_design_api_key || ''; $('s-voices-scan-dir').value = s.voices_scan_dir || ''; $('s-output-dir').value = s.output_dir || ''; const themeEl = $('s-theme-select'); if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark'; // Captures settings const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || ''; const sttPref = $('s-stt-preferred-backend'); if (sttPref) sttPref.value = s.stt_preferred_backend || ''; const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off'; const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || ''; const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false; const rfRep = $('s-refine-repetitions'); if (rfRep) rfRep.checked = s.refine_repetitions !== false; const rfCorr = $('s-refine-corrections'); if (rfCorr) rfCorr.checked = s.refine_corrections !== false; const rfPunc = $('s-refine-punctuation'); if (rfPunc) rfPunc.checked = s.refine_punctuation !== false; // Default capture voice dropdown const cvSel = $('s-captures-default-voice'); if (cvSel && window._voices) { const cur = s.captures_default_voice || ''; cvSel.innerHTML = '' + (window._voices || []).map(v => ``).join(''); } await refreshTtsBackendAvailability(); renderSettingsAbout(); } function markSettingsSeen() { localStorage.setItem(SETTINGS_SEEN_KEY, '1'); } function openSettings(firstRun = false) { if (firstRun) markSettingsSeen(); switchTab('settings'); } function closeSettings(markSeen = true) { if (markSeen) markSettingsSeen(); } document.querySelectorAll('.s-eye-btn').forEach(btn => { btn.addEventListener('click', () => { const inp = $(btn.dataset.target); inp.type = inp.type === 'password' ? 'text' : 'password'; }); }); $('settings-btn')?.addEventListener('click', async () => { await loadSettings(); openSettings(false); }); document.addEventListener('click', async e => { if (e.target.closest('.s-reload-btn')) { await loadSettings(); toast('Settings reloaded', 'success'); } }); $('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; }); $('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; }); $('s-use-faster-whisper')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-faster-whisper-url').value || 'http://host.docker.internal:8000'; }); $('s-use-whisper-cpp')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-whisper-cpp-url').value || 'http://host.docker.internal:8080'; }); document.addEventListener('click', async e => { if (!e.target.closest('.s-save-btn')) return; { let ttsExtraParamsByBackend = {}; const paramFields = [ ['voice_clone', 's-tts-extra-voice-clone', 'Voice Clone/Base'], ['streaming', 's-tts-extra-streaming', 'Streaming'], ['customvoice', 's-tts-extra-customvoice', 'CustomVoice'], ['voice_design', 's-tts-extra-voice-design', 'Voice Design'], ['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'], ['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'], ['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'], ['kokoro', 's-tts-extra-kokoro', 'Kokoro'], ['vibevoice', 's-tts-extra-vibevoice', 'VibeVoice'], ]; try { for (const [key, id, label] of paramFields) { ttsExtraParamsByBackend[key] = JSON.parse($(id).value || '{}'); } } catch (e) { toast('TTS params JSON is invalid: ' + e.message, 'error'); return; } await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ whisper_url: $('s-whisper-url').value, whisper_api_key: $('s-whisper-key').value, faster_whisper_url: $('s-faster-whisper-url').value, whisper_cpp_url: $('s-whisper-cpp-url').value, groq_api_key: $('s-groq-api-key').value, tts_url: $('s-tts-url').value, kokoro_url: $('s-kokoro-url').value, vibevoice_url: $('s-vibevoice-url').value, xtts_url: $('s-xtts-url')?.value || '', llm_url: $('s-llm-url')?.value || '', tts_stream_url: $('s-tts-stream-url').value, customvoice_url: $('s-customvoice-url').value, nvidia_router_url: $('s-nvidia-router-url').value, nvidia_tts_url: $('s-nvidia-tts-url').value, nvidia_asr_url: $('s-nvidia-asr-url').value, nvidia_clone_url: $('s-nvidia-zeroshot-url').value, nvidia_zeroshot_url: $('s-nvidia-zeroshot-url').value, nvidia_flow_url: $('s-nvidia-flow-url').value, tts_stream_mode: $('s-tts-stream-mode').value, tts_api_key: $('s-tts-key').value, tts_backend: $('s-tts-backend').value, tts_extra_params_by_backend: ttsExtraParamsByBackend, voice_design_url: $('s-voice-design-url').value, voice_design_api_key: $('s-vd-key').value, voices_scan_dir: $('s-voices-scan-dir').value, output_dir: $('s-output-dir').value, stt_language: $('s-stt-language')?.value || '', stt_preferred_backend: $('s-stt-preferred-backend')?.value || '', auto_refine: $('s-auto-refine')?.value || 'off', refine_model: $('s-refine-model')?.value || '', refine_fillers: $('s-refine-fillers')?.checked ?? true, refine_repetitions: $('s-refine-repetitions')?.checked ?? true, refine_corrections: $('s-refine-corrections')?.checked ?? true, refine_punctuation: $('s-refine-punctuation')?.checked ?? true, captures_default_voice: $('s-captures-default-voice')?.value || '', }) }); _appSettings.tts_stream_url = $('s-tts-stream-url').value; _appSettings.customvoice_url = $('s-customvoice-url').value; _appSettings.nvidia_router_url = $('s-nvidia-router-url').value; _appSettings.nvidia_tts_url = $('s-nvidia-tts-url').value; _appSettings.nvidia_asr_url = $('s-nvidia-asr-url').value; _appSettings.nvidia_clone_url = $('s-nvidia-zeroshot-url').value; _appSettings.nvidia_zeroshot_url = $('s-nvidia-zeroshot-url').value; _appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value; _appSettings.voice_design_url = $('s-voice-design-url').value; _appSettings.vibevoice_url = $('s-vibevoice-url').value; _appSettings.xtts_url = $('s-xtts-url')?.value || ''; _appSettings.tts_stream_mode = $('s-tts-stream-mode').value; _ttsStreamHealth = null; await refreshTtsBackendAvailability($('tts-backend-select')?.value || ''); markSettingsSeen(); renderIntegrationSnippets(); toast('Settings saved', 'success'); }}); // Theme select in General settings document.addEventListener('change', e => { if (e.target.id === 's-theme-select') applyTheme(e.target.value); }); // ── Voice ID field (tab 3) ──────────────────────────────────────────────── function validateVoiceId(v) { return /^[A-Za-z0-9_\-\.]+$/.test(v); } $('voice-id-input').addEventListener('input', () => { const val = $('voice-id-input').value; const ok = val && validateVoiceId(val); $('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : ''; $('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : ''; }); $('helper-apply-btn').addEventListener('click', () => { const name = $('name-input').value.trim(); if (!name) { toast('Enter a name first', 'error'); return; } $('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`; $('voice-id-input').dispatchEvent(new Event('input')); }); // ── WaveSurfer ──────────────────────────────────────────────────────────── let ws = null, wsRegions = null, currentFileId = null, trimmedFileId = null, designedFileId = null, editingVoiceId = null, editingVoicePath = null; function initWaveSurfer() { if (ws) { ws.destroy(); ws = null; wsRegions = null; } wsRegions = WaveSurfer.Regions.create(); ws = WaveSurfer.create({ container:'#waveform', waveColor:'#45475a', progressColor:'#89b4fa', cursorColor:'#cba6f7', height:90, normalize:true, plugins:[wsRegions] }); ws.on('ready', () => { const dur = ws.getDuration(); $('trim-end').value = dur.toFixed(2); $('trim-end').max = dur.toFixed(2); $('trim-start').max = dur.toFixed(2); updateRegion(); }); wsRegions.on('region-updated', r => { $('trim-start').value = r.start.toFixed(2); $('trim-end').value = r.end.toFixed(2); updateDurationLabel(); }); } function updateRegion() { wsRegions.clearRegions(); const s = parseFloat($('trim-start').value)||0, e = parseFloat($('trim-end').value)||(ws?ws.getDuration():0); wsRegions.addRegion({ start:s, end:e, color:'rgba(137,180,250,0.25)', drag:true, resize:true }); updateDurationLabel(); } function updateDurationLabel() { const d = Math.max(0, (parseFloat($('trim-end').value)||0) - (parseFloat($('trim-start').value)||0)); const el = $('trim-duration'); el.textContent = d.toFixed(1)+' s'; el.className = d>=5&&d<=20 ? 'dur-ok' : d>20 ? 'dur-warn' : 'dur-bad'; } ['trim-start','trim-end'].forEach(id => $(id).addEventListener('input', () => { if(ws) updateRegion(); })); $('play-btn').addEventListener('click', () => { if(ws) ws.playPause(); }); $('play-selection-btn').addEventListener('click', () => { if (!ws) return; ws.play(parseFloat($('trim-start').value)||0, parseFloat($('trim-end').value)||ws.getDuration()); }); $('auto-trim-btn').addEventListener('click', async () => { if (!currentFileId) { toast('No audio loaded','error'); return; } $('auto-trim-btn').disabled = true; status('Finding best TTS reference segment…'); try { const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:currentFileId})}); let d; if (r.ok) { d = await r.json(); } else if (r.status === 404 || r.status === 405) { status('Backend auto trim unavailable; analysing audio in browser…'); d = await clientAutoTrimBounds(currentFileId); } else { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText || 'Auto trim failed'); } $('trim-start').value = Number(d.start).toFixed(2); $('trim-end').value = Number(d.end).toFixed(2); if (ws) updateRegion(); toast('Auto trim set: '+Number(d.duration).toFixed(1)+' s','success'); status(d.reason || 'Auto trim ready'); } catch(e) { toast('Auto trim failed: '+e.message,'error'); status('Auto trim failed'); } finally { $('auto-trim-btn').disabled = false; } }); function loadAudioId(id, dur, opts = {}) { currentFileId = id; trimmedFileId = null; designedFileId = null; editingVoiceId = opts.editingVoiceId || null; editingVoicePath = opts.editingVoicePath || null; $('trim-start').value='0'; $('trim-end').value=dur.toFixed(2); $('waveform-card').style.display=''; initWaveSurfer(); ws.load('/api/audio/'+id); $('save-result').style.display='none'; $('trim-audio').style.display='none'; $('no-audio-hint').style.display=''; if (editingVoiceId) { $('voice-id-input').value = editingVoiceId; $('voice-id-input').dispatchEvent(new Event('input')); $('transcript-area').value = opts.transcript || ''; status('Editing existing voice: ' + editingVoiceId); } } // ── Drop zone ───────────────────────────────────────────────────────────── const dropZone = $('drop-zone'), fileInput = $('file-input'); dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', () => { if(fileInput.files.length) uploadFile(fileInput.files[0]); }); async function uploadFile(file) { status('Uploading '+file.name+'…'); const fd = new FormData(); fd.append('file', file); try { const r = await fetch('/api/upload', { method:'POST', body:fd }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail||r.statusText); } const d = await r.json(); loadAudioId(d.id, d.duration); status('Loaded: '+file.name+' ('+d.duration.toFixed(1)+' s)'); toast('File loaded', 'success'); } catch(e) { toast('Upload failed: '+e.message, 'error'); status('Upload failed'); } } async function loadLibraryVoiceAudio(v) { const audioResp = await fetch(voiceFileUrl(v), {cache:'no-store'}); if (!audioResp.ok) { const e = await audioResp.json().catch(() => ({})); throw new Error(e.detail || audioResp.statusText); } const blob = await audioResp.blob(); const ext = (v.file_type || 'wav').toLowerCase(); const fd = new FormData(); fd.append('file', new File([blob], `${v.id}.${ext}`, {type:blob.type || 'audio/wav'})); const upload = await fetch('/api/upload', { method:'POST', body:fd }); if (!upload.ok) { const e = await upload.json().catch(() => ({})); throw new Error(e.detail || upload.statusText); } const d = await upload.json(); return { id:d.id, voice_id:v.id, duration:d.duration, transcript:v.transcript || '', file_type:ext, path:v.path }; } // ── YouTube ─────────────────────────────────────────────────────────────── $('yt-btn').addEventListener('click', () => { const url = $('yt-url').value.trim(); if(!url) return; $('yt-btn').disabled=true; $('yt-progress').textContent='Starting download…'; const es = new EventSource('/api/download-yt?url='+encodeURIComponent(url)); es.onmessage = e => { const d = JSON.parse(e.data); if (d.error) { toast('Download failed: '+d.error,'error'); $('yt-progress').textContent=d.error; $('yt-btn').disabled=false; es.close(); } else if (d.done) { es.close(); $('yt-btn').disabled=false; $('yt-progress').textContent='Done!'; loadAudioId(d.id,d.duration); toast('YouTube audio loaded','success'); } else { $('yt-progress').textContent=d.msg||''; if(d.pct) status('Downloading… '+d.pct+'%'); } }; es.onerror = () => { es.close(); $('yt-btn').disabled=false; }; }); // ── Microphone ──────────────────────────────────────────────────────────── const RAW_MIC_CONSTRAINTS = { echoCancellation:false, noiseSuppression:false, autoGainControl:false }; async function visibleMicrophoneCount() { if (!navigator.mediaDevices?.enumerateDevices) return null; try { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(device => device.kind === 'audioinput').length; } catch(e) { return null; } } async function microphoneErrorMessage(error) { const name = error?.name || ''; const message = error?.message || ''; const lowerMessage = message.toLowerCase(); const micCount = await visibleMicrophoneCount(); if (name === 'NotFoundError' || lowerMessage.includes('requested device not found')) { return micCount === 0 ? 'No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.' : 'The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.'; } if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { return 'Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.'; } if (name === 'NotReadableError') { return 'The microphone is busy or unavailable. Close other apps using it, then try again.'; } if (name === 'SecurityError') { return 'Microphone access requires localhost or HTTPS.'; } return message || 'Microphone failed.'; } async function requestMicrophoneStream(options = {}) { if (!navigator.mediaDevices?.getUserMedia) { throw new Error('Microphone requires HTTPS. Open the app via https://... or access it on localhost.'); } if (!options.raw) return navigator.mediaDevices.getUserMedia({audio:true}); try { return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS}); } catch(e) { if (e?.name === 'OverconstrainedError' || e?.name === 'NotFoundError') { return navigator.mediaDevices.getUserMedia({audio:true}); } throw e; } } let mediaRec=null, recChunks=[], recTimer=null, recSecs=0; $('rec-start-btn').addEventListener('click', async () => { try { const stream = await requestMicrophoneStream(); recChunks=[]; recSecs=0; $('rec-time').textContent='0:00'; $('rec-indicator').classList.add('active'); $('rec-start-btn').disabled=true; $('rec-stop-btn').disabled=false; recTimer = setInterval(() => { recSecs++; $('rec-time').textContent=Math.floor(recSecs/60)+':'+String(recSecs%60).padStart(2,'0'); }, 1000); mediaRec = new MediaRecorder(stream); mediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data); }; mediaRec.onstop = async () => { clearInterval(recTimer); $('rec-indicator').classList.remove('active'); stream.getTracks().forEach(t=>t.stop()); const blob = new Blob(recChunks, {type:mediaRec.mimeType||'audio/webm'}); const ext = (mediaRec.mimeType||'').includes('ogg') ? '.ogg' : '.webm'; await uploadFile(new File([blob], 'recording'+ext, {type:blob.type})); }; mediaRec.start(100); status('Recording…'); } catch(e) { toast(await microphoneErrorMessage(e), 'error'); } }); $('rec-stop-btn').addEventListener('click', () => { if(mediaRec&&mediaRec.state!=='inactive') mediaRec.stop(); $('rec-start-btn').disabled=false; $('rec-stop-btn').disabled=true; }); // ── Trim ────────────────────────────────────────────────────────────────── $('trim-btn').addEventListener('click', async () => { if (!currentFileId) { toast('No audio loaded','error'); return; } try { const r = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:currentFileId, start:parseFloat($('trim-start').value)||0, end:parseFloat($('trim-end').value)||0}) }); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); trimmedFileId=d.id; designedFileId=null; $('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none'; switchTab('save'); toast('Trim done','success'); } catch(e) { toast('Trim failed: '+e.message,'error'); } }); // ── Voice design naming helpers ──────────────────────────────────────────── const DESIGN_LANG_CODE = { Auto:'EN', English:'EN', Chinese:'ZH', Japanese:'JA', Korean:'KO', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Portuguese:'PT', Russian:'RU', }; const DESIGN_GENDER_WORD = { F:'female', M:'male', N:'neutral' }; const DESIGN_PRESET_KEY = 'vcf-design-presets'; const DESIGN_PRESET_SEEDED_KEY = 'vcf-design-presets-seeded-v2'; const DEFAULT_DESIGN_PRESETS = { 'EN_M_Young_Energetic': { description: 'Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.', sample_text: 'Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.', language: 'English', gender: 'M', }, 'EN_F_Warm_Narrator': { description: 'Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.', sample_text: 'The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.', language: 'English', gender: 'F', }, 'DE_M_Elderly_Documentary': { description: 'Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.', sample_text: 'Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.', language: 'German', gender: 'M', }, 'DE_F_Young_Friendly': { description: 'Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.', sample_text: 'Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.', language: 'German', gender: 'F', }, 'EN_N_Old_Wise_Assistant': { description: 'Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.', sample_text: 'Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.', language: 'English', gender: 'N', }, }; const QWEN_DESIGN_SAMPLES = { 'qwen-timbre-reuse': { title: 'Qwen Timbre Reuse', summary: 'Reference clip for designing a reusable teen character timbre.', description: 'Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous', text: "H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?", language: 'English', gender: 'M', }, 'acoustic-sausage-announcer': { title: 'Acoustic Attribute Control - British announcer', summary: 'Fast, loud, articulate British male delivery with excitement and performative authority.', description: `gender: Male. pitch: Low male pitch with significant upward inflections for emphasis and excitement. speed: Fast-paced delivery with deliberate pauses for dramatic effect. volume: Loud and projecting, increasing notably during moments of praise and announcements. age: Young adult to middle-aged adult. clarity: Highly articulate and distinct pronunciation. fluency: Very fluent speech with no hesitations. accent: British English. texture: Bright and clear vocal texture. emotion: Enthusiastic and excited, especially when complimenting. tone: Upbeat, authoritative, and performative. personality: Confident, extroverted, and engaging.`, text: 'Nine different, exciting ways of cooking sausage. Incredible. There were three outstanding deliveries in terms of the sausage being the hero. The first dish that we want to dissect, this individual smartly combined different proteins in their sausage. Great seasoning. The blend was absolutely spot on. Congratulations. Please step forward. Natasha.', language: 'English', gender: 'M', }, 'acoustic-character-laugh': { title: 'Acoustic Attribute Control - theatrical character', summary: 'Artificially high male character voice shifting from loud forced amusement to deliberate resignation.', description: `gender: Male. pitch: Artificially high-pitched, slightly lowering after the initial laugh. speed: Rapid during the laugh, then slowing to a deliberate pace. volume: Loud laugh transitioning to a standard conversational level. age: Young adult to middle-aged, performing a character voice. clarity: Clear and distinct articulation. fluency: Fluent delivery without hesitation. accent: American English. texture: Slightly strained and somewhat nasal quality. emotion: Forced amusement shifting to feigned resignation. tone: Initially playful, then shifts to a slightly put-upon tone. personality: Theatrical and expressive.`, text: "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.", language: 'English', gender: 'M', }, 'age-control-surly-elvis': { title: 'Age Control - middle-aged gravel', summary: 'Low, resonant, slightly gravelly American male voice with a commanding opening.', description: `gender: Male. pitch: Low male pitch, generally stable. speed: Deliberate pace, slowing slightly after the initial exclamation. volume: Starts loud, then transitions to a projected conversational volume. age: Middle-aged adult. clarity: High clarity with distinct pronunciation. fluency: Highly fluent. accent: American English. texture: Resonant and slightly gravelly. emotion: Initially commanding, shifting to narrative amusement. tone: Authoritative start, moving to an engaging, descriptive tone. personality: Confident and performative.`, text: 'Older gentleman, 110, maybe 111 years old, sort of a surly Elvis thing happening with him. He smiles like this. Seen him around?', language: 'English', gender: 'M', }, 'gradual-control-anger': { title: 'Gradual Control - emotional escalation', summary: 'Female voice that begins neutral and quickly escalates into sharp anger and accusation.', description: `gender: Female. pitch: Mid-range female pitch, rising sharply with frustration. speed: Starts measured, then accelerates rapidly during emotional outburst. volume: Begins conversational, escalates quickly to loud and forceful. age: Young adult to middle-aged. clarity: High clarity and distinct articulation throughout. fluency: Highly fluent with no significant pauses or fillers. accent: General American English. texture: Bright and clear vocal quality. emotion: Shifts abruptly from neutral acceptance to intense resentment and anger. tone: Initially accepting, becomes sharply accusatory and confrontational. personality: Assertive and emotionally expressive when provoked.`, text: 'Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you-', language: 'English', gender: 'F', }, 'human-likeness-digital-nomad': { title: 'Human-likeness - casual self-aware monologue', summary: 'Warm male conversational voice with natural laughter, hesitations, and self-deprecating humor.', description: 'A relaxed, naturally expressive male voice in his late twenties to early thirties, with a moderately low pitch, casual speaking rate, and conversational volume; deliver lines with a light, self-deprecating tone, breaking into genuine, easygoing laughter at moments of embarrassment, while maintaining clear articulation and an overall warm, approachable clarity.', text: `Yeah, so--uh--I'm a digital nomad, right? So... pretty much all my communication is just, like, texts and messages. And now, you know, there's these AI agents that can, uh... reply for you? Which is--heh--convenient, sure, I guess? But also... kinda delicate, you know? Like, you'll type something super short--like, "Yep, sounds good"--and it'll turn that into this whole... warm, polished paragraph. Like, way nicer than I'd ever write myself. huh... ha Seriously, I sound like a Hallmark card all of a sudden. But then... once you outsource that... what's the other person actually hearing? Are they hearing me... or just some... generic, friendly-bot voice? Man, that's weird to even say out loud.`, language: 'English', gender: 'M', }, 'background-marcus-cole': { title: 'Background Information - Marcus Cole', summary: 'Broadcast booth announcer profile with bright, agile, urgent delivery.', description: `Character Name: Marcus Cole Voice Profile: A bright, agile male voice with a natural upward lift, delivering lines at a brisk, energetic pace. Pitch leans high with spark, volume projects clearly--near-shouting at peaks--to convey urgency and excitement. Speech flows seamlessly, fluently, each word sharply defined, riding a current of dynamic rhythm. Background: Longtime broadcast booth announcer for national television, specializing in live interstitials and public engagement spots. His voice bridges segments, rallies action, and keeps momentum alive--from voter drives to entertainment news. Presence: Late 50s, neatly groomed, dressed in a crisp shirt under studio lights. Moves with practiced ease, eyes locked on the script, energy coiled and ready. Personality: Energetic, precise, inherently engaging. He doesn't just read--he propels. Behind the speed is intent: to inform fast, to move people to act. Whether it's "text VOTE to 5703" or a star-studded tease, he makes it feel immediate, vital.`, text: "Lot being you watching. 1-866-IDLE-03 for JPL. That's 1-866-436-5703. Or text the word VOTE to 5703. Diana DeGarmo's next with more from the movies right after this brief intermission on American Idol.", language: 'English', gender: 'M', }, 'timbre-reuse-lucas-mia': { title: 'Timbre Reuse - Lucas and Mia', summary: 'Two-character teen dialogue using native VoiceDesign speaker-profile switching.', description: `"Lucas": "Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous" "Mia": "Female, 16 years old, mezzo-soprano range, softening - lowering register to intimate speaking voice, consonants softening"`, text: `Lucas:H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe? Mia:Oh wow, my mortal enemy - Mr. Thompson's problem sets. Thanks for rescuing me from that F. Lucas:No problem! I actually... kinda finished those already? If you want to compare answers or something... Mia:Is this your sneaky way of saying you want to study together, Lucas? Because I saw you staring during lab partners sign-up. Lucas:What? No! I mean yes but not like... I just think you're... your titration technique is really precise! Mia:That's the nerdiest compliment I've ever gotten. Tell you what - help me survive pre-calc and I'll teach you how to actually flirt. Lucas:Wow, harsh. And here I thought my titration line was smooth. Mia:It was adorable. Like when you tripped over your shoelaces in the hall yesterday. Or that time you- Lucas:Okay okay! I get it, I'm a disaster. So... library after school? I'll bring the graphing calculators? Mia:Only if you promise not to spill coffee on my notes again... though I guess watching you panic-clean was pretty cute.`, language: 'English', gender: 'N', dialogue: true, }, }; let currentDesignSource = null; function loadDesignPresets() { try { return JSON.parse(localStorage.getItem(DESIGN_PRESET_KEY) || '{}'); } catch { return {}; } } function saveDesignPresets(presets) { localStorage.setItem(DESIGN_PRESET_KEY, JSON.stringify(presets)); } async function syncDesignPresetsToServer() { try { await fetch('/api/voice-design-presets', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(loadDesignPresets()), }); renderIntegrationSnippets(); } catch(e) { status('Voice Design preset sync failed: ' + e.message); } } function seedDesignPresets() { const presets = loadDesignPresets(); let changed = false; Object.entries(DEFAULT_DESIGN_PRESETS).forEach(([name, preset]) => { if (!presets[name]) { presets[name] = preset; changed = true; return; } ['description', 'sample_text', 'language', 'gender'].forEach(key => { if (!presets[name][key] && preset[key]) { presets[name][key] = preset[key]; changed = true; } }); }); if (changed || !localStorage.getItem(DESIGN_PRESET_SEEDED_KEY)) saveDesignPresets(presets); localStorage.setItem(DESIGN_PRESET_SEEDED_KEY, '1'); if (changed) syncDesignPresetsToServer(); } function refreshDesignPresetSelect() { const presets = loadDesignPresets(); const sel = $('design-preset-select'); const prev = sel.value; sel.innerHTML = ''; Object.keys(presets).sort((a,b)=>a.localeCompare(b)).forEach(name => { const opt = document.createElement('option'); opt.value = opt.textContent = name; sel.appendChild(opt); }); if (presets[prev]) sel.value = prev; renderDesignPresetLibrary(); } function applyDesignPreset(name) { const preset = loadDesignPresets()[name]; if (!preset) { toast('Preset not found', 'error'); return; } $('design-instruct').value = preset.description || ''; $('design-sample-text').value = preset.sample_text || preset.text || $('design-sample-text').value || ''; $('design-language').value = preset.language || 'Auto'; $('design-gender').value = preset.gender || 'N'; $('design-preset-name').value = name; $('design-preset-select').value = name; currentDesignSource = { name, gender:preset.gender || 'N', language:preset.language || 'Auto', text:$('design-sample-text').value || '', description:preset.description || '' }; toast('Preset loaded: ' + name, 'success'); } function renderDesignPresetLibrary() { const lib = $('design-preset-library'); if (!lib) return; const presets = loadDesignPresets(); const names = Object.keys(presets).sort((a,b)=>a.localeCompare(b)); if (!names.length) { lib.innerHTML = '
No saved prompt presets yet.
'; return; } lib.innerHTML = ''; names.forEach(name => { const p = presets[name]; const row = document.createElement('div'); row.className = 'design-preset-row'; row.innerHTML = ` ${escHtml(name)} ${escHtml(p.gender || 'N')} ${escHtml(p.language || 'Auto')} ${escHtml(p.description || '')} ${escHtml(p.sample_text || p.text || '')} `; row.querySelector('.preset-use').addEventListener('click', () => applyDesignPreset(name)); row.querySelector('.preset-delete').addEventListener('click', () => { const all = loadDesignPresets(); delete all[name]; saveDesignPresets(all); syncDesignPresetsToServer(); refreshDesignPresetSelect(); toast('Preset deleted: ' + name, 'success'); }); row.querySelector('.preset-preview').addEventListener('click', async e => { e.currentTarget.disabled = true; try { const sampleText = p.sample_text || p.text || $('design-sample-text').value; const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify(voiceDesignPayload(p.description || '', sampleText, p.language || 'Auto', p))}); if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); } const d = await r.json(); $('design-audio').src = '/api/audio/' + d.id; $('design-result').style.display = 'flex'; $('design-audio').play().catch(()=>{}); } catch(err) { toast('Preset preview failed: ' + err.message, 'error'); } finally { e.currentTarget.disabled = false; } }); lib.appendChild(row); }); } function renderQwenSampleCards() { const list = $('qwen-sample-list'); if (!list) return; list.innerHTML = ''; Object.entries(QWEN_DESIGN_SAMPLES).forEach(([key, sample]) => { const card = document.createElement('div'); card.className = 'qwen-sample design-sample-grid'; card.dataset.qwenSample = key; card.innerHTML = ` ${escHtml(sample.title || key)} ${escHtml(sample.gender || 'N')} ${escHtml(sample.language || 'Auto')} ${escHtml(sample.description || '')} ${escHtml(sample.text || '')}
`; list.appendChild(card); }); } function applyQwenSample(sample) { $('design-instruct').value = sample.description; $('design-sample-text').value = sample.text; $('design-language').value = sample.language; $('design-gender').value = sample.gender; currentDesignSource = sample; $('design-result').style.display = 'none'; $('design-save-result').style.display = 'none'; $('design-instruct').scrollIntoView({behavior:'smooth', block:'nearest'}); } function isDialogueDesign(instruct, text, source = null) { if (source && source.dialogue) return true; const speakers = new Set(); String(instruct || '').split(/\n+/).forEach(line => { const match = line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/); if (match) speakers.add(match[1].trim()); }); if (speakers.size < 2) return false; const turnSpeakers = new Set(); String(text || '').split(/\n+/).forEach(line => { const match = line.trim().match(/^([^:]{1,40}):\s*(.+)$/); if (match && speakers.has(match[1].trim())) turnSpeakers.add(match[1].trim()); }); return turnSpeakers.size >= 2; } function voiceDesignPayload(instruct, sampleText, language, source = null, gender = null) { return { instruct, sample_text: sampleText, language, gender: gender || source?.gender || $('design-gender')?.value || '', dialogue: isDialogueDesign(instruct, sampleText, source), }; } let _dVoiceIdManual = false; function designSafeName(name) { return String(name || 'VoiceDesign') .replace(/^[A-Z]{2}_[FMN]_/, '') .replace(/[^A-Za-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 42) || 'VoiceDesign'; } function voiceIdSafePart(value, fallback = 'style') { return String(value || fallback) .replace(/[^A-Za-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 32) || fallback; } function suggestedStyleVoiceId(baseId, style) { const suffix = voiceIdSafePart(style || 'style'); return `${baseId}_${suffix}`.slice(0, 96); } function _updateDVoiceId() { if (_dVoiceIdManual) return; const lang = $('d-lang').value, gender = $('d-gender').value, name = $('d-name').value.trim(); $('d-voice-id').value = name ? `${lang}_${gender}_${name}` : ''; } ['d-lang','d-gender'].forEach(id => $(id).addEventListener('change', _updateDVoiceId)); $('d-name').addEventListener('input', () => { _dVoiceIdManual = false; _updateDVoiceId(); }); $('d-voice-id').addEventListener('input', () => { _dVoiceIdManual = true; }); seedDesignPresets(); refreshDesignPresetSelect(); renderQwenSampleCards(); syncDesignPresetsToServer(); $('design-preset-select').addEventListener('change', () => { if ($('design-preset-select').value) applyDesignPreset($('design-preset-select').value); }); $('design-preset-load').addEventListener('click', () => { const name = $('design-preset-select').value || $('design-preset-name').value.trim(); if (!name) { toast('Select a preset first', 'error'); return; } applyDesignPreset(name); }); $('design-preset-save').addEventListener('click', () => { const name = $('design-preset-name').value.trim() || $('design-preset-select').value; if (!name) { toast('Enter a preset name', 'error'); $('design-preset-name').focus(); return; } const presets = loadDesignPresets(); presets[name] = { description: $('design-instruct').value, sample_text: $('design-sample-text').value, language: $('design-language').value, gender: $('design-gender').value, dialogue: isDialogueDesign($('design-instruct').value, $('design-sample-text').value, currentDesignSource), }; saveDesignPresets(presets); syncDesignPresetsToServer(); refreshDesignPresetSelect(); $('design-preset-select').value = name; toast('Preset saved: ' + name, 'success'); }); $('design-preset-delete').addEventListener('click', () => { const name = $('design-preset-select').value || $('design-preset-name').value.trim(); if (!name) { toast('Select a preset first', 'error'); return; } const presets = loadDesignPresets(); if (!presets[name]) { toast('Preset not found', 'error'); return; } delete presets[name]; saveDesignPresets(presets); syncDesignPresetsToServer(); refreshDesignPresetSelect(); $('design-preset-name').value = ''; toast('Preset deleted: ' + name, 'success'); }); ['design-instruct','design-sample-text'].forEach(id => $(id).addEventListener('input', () => { currentDesignSource = null; if (id === 'design-sample-text') $('d-transcript').value = $('design-sample-text').value; })); document.querySelectorAll('.qwen-sample').forEach(card => { const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample]; const state = card.querySelector('.qwen-state'); const audio = card.querySelector('audio'); card.querySelector('.qwen-use').addEventListener('click', () => { applyQwenSample(sample); toast('Voice Design sample loaded', 'success'); }); card.querySelector('.qwen-preview').addEventListener('click', async e => { const btn = e.currentTarget; btn.disabled = true; state.textContent = 'Generating preview…'; try { const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify(voiceDesignPayload(sample.description, sample.text, sample.language, sample))}); if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); } const d = await r.json(); audio.src = '/api/audio/' + d.id; audio.style.display = ''; audio.play().catch(()=>{}); state.textContent = 'Preview ready'; } catch(err) { state.textContent = 'Preview failed'; toast('Sample preview failed: ' + err.message, 'error'); } finally { btn.disabled = false; } }); }); // ── Voice design ────────────────────────────────────────────────────────── async function runVoiceDesign() { const baseInstruct=$('design-instruct').value.trim(), sample=$('design-sample-text').value.trim(); const dialogue = isDialogueDesign(baseInstruct, sample, currentDesignSource); const instruct = baseInstruct; if (!instruct) { toast('Enter a voice description first','error'); return; } $('design-generate-btn').disabled=true; $('design-status').textContent='Generating…'; $('design-result').style.display='none'; $('design-save-result').style.display='none'; status('Generating voice design…'); try { const r = await fetch('/api/voice-design', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(voiceDesignPayload(instruct, sample, $('design-language').value, currentDesignSource, $('design-gender').value)) }); if (!r.ok) { const e=await r.json(); throw new Error(e.detail||r.statusText); } const d = await r.json(); designedFileId=d.id; trimmedFileId=null; editingVoiceId=null; $('design-audio').src='/api/audio/'+d.id; $('design-result').style.display='flex'; $('design-status').textContent='Done ('+d.duration.toFixed(1)+' s)'; const langCode = DESIGN_LANG_CODE[$('design-language').value] || 'EN'; $('d-lang').value = langCode; $('d-gender').value = $('design-gender').value; $('d-name').value = designSafeName(currentDesignSource?.title || currentDesignSource?.name || $('design-preset-name').value || 'VoiceDesign'); _dVoiceIdManual = false; _updateDVoiceId(); $('d-transcript').value = sample; $('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none'; if (!$('transcript-area').value) $('transcript-area').value=sample; $('design-audio').play().catch(()=>{}); $('design-result').scrollIntoView({behavior:'smooth',block:'nearest'}); toast('Voice generated and export fields filled.','success'); status('Voice design ready'); } catch(e) { $('design-status').textContent='Failed: '+e.message; toast('Voice design failed: '+e.message,'error'); status('Voice design failed'); } finally { $('design-generate-btn').disabled=false; } } $('design-generate-btn').addEventListener('click', runVoiceDesign); $('design-retry-btn').addEventListener('click', runVoiceDesign); $('design-save-btn').addEventListener('click', async () => { if (!designedFileId) { toast('No voice generated yet','error'); return; } const voiceId = $('d-voice-id').value.trim(); if (!voiceId) { toast('Enter a Voice ID first','error'); $('d-name').focus(); return; } if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; } $('design-save-btn').disabled=true; try { const r = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:designedFileId, voice_id:voiceId, transcript:$('d-transcript').value})}); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const saved = await r.json(); await saveMeta(saved.voice_id, { gender: $('d-gender').value, flag: LANG_FLAG_DEFAULT[$('d-lang').value] || undefined, transcript: $('d-transcript').value, note: 'Voice Design: ' + $('design-instruct').value.slice(0, 240), }).catch(()=>{}); await loadVoiceLibrary().catch(()=>{}); $('design-save-result').style.display='flex'; $('design-save-result').scrollIntoView({behavior:'smooth',block:'nearest'}); toast('Exported to Voice Clone Library: '+saved.voice_id,'success'); status('Exported to Voice Clone Library: '+saved.voice_id); } catch(e) { toast('Save failed: '+e.message,'error'); } finally { $('design-save-btn').disabled=false; } }); $('design-download-btn').addEventListener('click', () => { if (!designedFileId) return; const a=document.createElement('a'); a.href='/api/audio/'+designedFileId; a.download=($('d-voice-id').value.trim() || 'voice_design') + '.wav'; a.click(); }); // ── Transcribe ──────────────────────────────────────────────────────────── $('transcribe-btn').addEventListener('click', async () => { const id = trimmedFileId||designedFileId||currentFileId; if (!id) { toast('No audio to transcribe','error'); return; } $('transcribe-btn').disabled=true; $('transcribe-status').textContent='Transcribing…'; try { const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); $('transcript-area').value=d.text; $('transcribe-status').textContent='Done'; toast('Transcription complete','success'); } catch(e) { $('transcribe-status').textContent='Failed: '+e.message; toast('Transcription failed: '+e.message,'error'); } finally { $('transcribe-btn').disabled=false; } }); // ── Save voice ──────────────────────────────────────────────────────────── $('save-btn').addEventListener('click', async () => { const id = trimmedFileId||designedFileId||currentFileId; if (!id) { toast('No audio ready','error'); return; } const voiceId=$('voice-id-input').value.trim(); if (!voiceId) { toast('Enter a Voice ID','error'); return; } if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; } $('save-btn').disabled=true; try { const payload = {id, voice_id:voiceId, path:editingVoicePath, transcript:$('transcript-area').value}; const sendSave = endpoint => fetch(endpoint, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); let fallbackSave = false; let r = await sendSave(editingVoiceId ? '/api/voice-replace' : '/api/save'); if (editingVoiceId && (r.status === 404 || r.status === 405)) { fallbackSave = true; status('Update endpoint unavailable; saving as a regular voice…'); r = await sendSave('/api/save'); } if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); $('save-result').style.display=''; toast((editingVoiceId ? 'Voice updated: ' : 'Voice saved: ')+d.voice_id,'success'); if (fallbackSave && editingVoiceId && voiceId !== editingVoiceId) { const del = await fetch('/api/voice/' + encodeURIComponent(editingVoiceId), {method:'DELETE'}); if (!del.ok) status('Saved renamed voice; old library entry may need manual deletion.'); } editingVoiceId = null; editingVoicePath = null; } catch(e) { toast('Save failed: '+e.message,'error'); } finally { $('save-btn').disabled=false; } }); // ══════════════════════════════════════════════════════════════════════════ // LIBRARY — sort + render // ══════════════════════════════════════════════════════════════════════════ let _voices = []; let _pendingSelectId = null; let _sortField = 'id'; let _sortDir = 1; // 1 = asc, -1 = desc let _libraryIssueFilter = ''; let _activePlayButton = null; let _activePlayVoiceId = null; let _activePlayUrl = null; let _libraryLoadPromise = null; const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text'; const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''}; const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.'; function benchmarkSampleText() { const el = $('benchmark-sample-text'); return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT; } function initBenchmarkSampleControls() { const sample = $('benchmark-sample-text'); if (!sample) return; sample.value = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY) || DEFAULT_BENCHMARK_SAMPLE_TEXT; sample.addEventListener('input', debounce(() => { localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value.trim()); status('Benchmark sample sentence saved'); }, 500)); $('benchmark-reset-sample-btn')?.addEventListener('click', () => { sample.value = DEFAULT_BENCHMARK_SAMPLE_TEXT; localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value); status('Benchmark sample sentence reset'); }); $('benchmark-use-preview-btn')?.addEventListener('click', () => { const text = $('preview-text-area')?.value.trim(); if (!text) { toast('Preview text is empty', 'error'); return; } sample.value = text; localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); status('Benchmark sample sentence copied from TTS preview'); }); } function getSortValue(v, field) { switch(field) { case 'has_picture': return v.has_picture ? 1 : 0; case 'flag': return (v.flag || '').toLowerCase(); case 'gender': return {'F':0,'M':1,'N':2}[v.gender] ?? 3; case 'id': return v.id.toLowerCase(); case 'file_type': return voiceFileType(v); case 'duration': return v.duration || 0; case 'dbfs': return voiceDbfs(v) ?? -999; case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; case 'transcript': return (v.transcript || '').toLowerCase(); case 'note': return (v.note || '').toLowerCase(); case 'rating': return v.rating || 0; case 'enabled': return v.enabled === false ? 0 : 1; default: return ''; } } function setSort(field) { _sortDir = (_sortField === field) ? _sortDir * -1 : 1; _sortField = field; syncSortHeaders(); renderVoiceList(); } function syncSortHeaders() { document.querySelectorAll('.vl-header [data-sort]').forEach(el => { el.classList.remove('sort-asc', 'sort-desc'); if (el.dataset.sort === _sortField) el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc'); }); } const FLAG_LANGUAGE_CANDIDATES = { GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'], CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'], DE:['DE'], AT:['DE'], CH:['DE','FR','IT'], FR:['FR'], BE:['FR','NL'], LU:['FR','DE'], ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'], PT:['PT'], BR:['PT'], IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'], CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'], EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'], CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'], SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'], }; const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]])); const LANGUAGE_LABELS = { EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish', DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian', HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian', RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali', AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish', }; const SEX_FILTER_LABELS = { F:'♀ Female', M:'♂ Male', N:'⚥ Diverse / neutral', }; function voiceLangFromName(v) { return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase(); } function libraryVoiceLang(v) { const fromName = voiceLangFromName(v); const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()]; if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0]; return fromName; } function libraryLanguageLabel(code) { return LANGUAGE_LABELS[code] || code; } function populateLibraryFilters() { const langSel = $('library-filter-lang'); const sexSel = $('library-filter-sex'); const typeSel = $('library-filter-type'); if (!langSel || !sexSel || !typeSel) return; const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value}; const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))); const sexOrder = ['F','M','N']; const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))] .sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b))); const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort(); langSel.innerHTML = '' + langs.map(x => ``).join(''); sexSel.innerHTML = '' + sexes.map(x => ``).join(''); typeSel.innerHTML = '' + types.map(x => ``).join(''); langSel.value = langs.includes(keep.lang) ? keep.lang : ''; sexSel.value = sexes.includes(keep.sex) ? keep.sex : ''; typeSel.value = types.includes(keep.type) ? keep.type : ''; } function readLibraryFilters() { _libraryFilters.text = ($('library-filter-text')?.value || '').trim().toLowerCase(); _libraryFilters.lang = $('library-filter-lang')?.value || ''; _libraryFilters.sex = $('library-filter-sex')?.value || ''; _libraryFilters.type = $('library-filter-type')?.value || ''; _libraryFilters.rating = $('library-filter-rating')?.value || ''; } function libraryFilterMatch(v) { const f = _libraryFilters; if (f.lang && libraryVoiceLang(v) !== f.lang) return false; if (f.sex && (v.gender || '') !== f.sex) return false; if (f.type && voiceFileType(v) !== f.type) return false; if (f.rating) { const r = Number(v.rating || 0); const wanted = Number(f.rating); if (wanted === 0 && r !== 0) return false; if (wanted === 1 && r < 1) return false; if (wanted > 1 && r < wanted) return false; } if (f.text) { const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender].map(x => String(x || '').toLowerCase()).join(' '); if (!hay.includes(f.text)) return false; } return true; } function clearLibraryFilters() { ['library-filter-text','library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { const el = $(id); if (el) el.value = ''; }); readLibraryFilters(); renderVoiceList(); } function libraryTtsBackend() { return $('library-tts-backend-select')?.value || 'voice_clone'; } function needsDuration(v) { return v.duration == null || Number.isNaN(Number(v.duration)); } function voiceFileType(v) { if (v.file_type) return String(v.file_type).replace(/^\./, '').toLowerCase(); const source = String(v.path || v.filename || ''); const match = source.match(/\.([A-Za-z0-9]+)(?:$|[?#])/); return match ? match[1].toLowerCase() : 'wav'; } function voiceDbfs(v) { const value = v.loudness && (v.loudness.dbfs ?? v.loudness.after_dbfs); return value == null || Number.isNaN(Number(value)) ? null : Number(value); } function fmtDbfs(v) { const db = voiceDbfs(v); return db == null ? '-' : db.toFixed(1); } function voiceBenchmark(v) { return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null; } function voiceBenchmarkElapsed(v) { const b = voiceBenchmark(v); const value = b && b.elapsed_sec; return value == null || Number.isNaN(Number(value)) ? null : Number(value); } function fmtBenchmark(v) { const b = voiceBenchmark(v); if (!b) return '-'; if (!b.ok) return 'ERR'; const elapsed = voiceBenchmarkElapsed(v); if (elapsed == null) return '-'; const speed = b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''; return elapsed.toFixed(1) + 's' + speed; } function benchmarkClass(v) { const b = voiceBenchmark(v); if (!b) return ''; if (!b.ok || b.clipped || b.realtime_ok === false) return 'bench-bad'; const elapsed = voiceBenchmarkElapsed(v); return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn'; } function voiceFileUrl(v) { const version = v._audioVersion || v.updated_at || v.benchmarked_at || ''; const bust = version || Date.now(); return `/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`; } function markVoiceAudioChanged(v) { v._audioVersion = Date.now(); } function benchmarkTitle(v) { const b = voiceBenchmark(v); if (!b) return 'Not benchmarked yet'; const parts = []; if (b.ok) { parts.push(`total ${Number(b.elapsed_sec || 0).toFixed(2)}s`); if (b.ttfa_ms != null) parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`); if (b.audio_sec != null) parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`); if (b.rtf != null) parts.push(`RTF ${Number(b.rtf).toFixed(2)}`); if (b.speed != null) parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`); if (b.clipped) parts.push('output clipped'); } else { parts.push('benchmark failed'); if (b.error) parts.push(b.error); } if (Array.isArray(b.advice) && b.advice.length) parts.push(b.advice.join(' | ')); if (b.benchmarked_at) parts.push(`saved ${b.benchmarked_at}`); return parts.join(' · '); } async function clientVoiceLoudness(v) { if (!v.path) throw new Error('No audio path'); const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); 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)); let sum = 0, peak = 0, count = 0; for (let ch = 0; ch < buffer.numberOfChannels; ch++) { const data = buffer.getChannelData(ch); count += data.length; for (let i = 0; i < data.length; i++) { const sample = data[i]; sum += sample * sample; peak = Math.max(peak, Math.abs(sample)); } } const rms = Math.sqrt(sum / Math.max(1, count)); const dbfs = rms > 0 ? 20 * Math.log10(rms) : null; const peakDbfs = peak > 0 ? 20 * Math.log10(peak) : null; return { dbfs: dbfs == null ? null : Number(dbfs.toFixed(2)), peak_dbfs: peakDbfs == null ? null : Number(peakDbfs.toFixed(2)), }; } async function clientCalculateVoiceDb() { const voices = visibleLibraryVoices(); const errors = []; let calculated = 0; const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'}; setBenchmarkProgress(0, voices.length, 'Preparing dB scan...', stats); for (const v of voices) { setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats); try { v.loudness = await clientVoiceLoudness(v); await saveMeta(v.id, { loudness: v.loudness }).catch(()=>{}); calculated++; stats.ok++; stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`; status(`Calculated dB: ${calculated} / ${voices.length}`); } catch(e) { errors.push({voice_id:v.id, detail:e.message}); stats.errors++; stats.last = `${v.id}: ${e.message}`; } setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats); await new Promise(resolve => setTimeout(resolve, 0)); } setBenchmarkProgress(voices.length, voices.length, 'dB scan complete', stats); return { calculated, errors, voices: voices.map(v => ({voice_id:v.id, loudness:v.loudness})) }; } async function hydrateVoiceDuration(v, el) { if (!v.path || !needsDuration(v) || v._durationLoading) return; v._durationLoading = true; try { const audio = new Audio(); audio.preload = 'metadata'; audio.src = voiceFileUrl(v); await new Promise((resolve, reject) => { audio.onloadedmetadata = resolve; audio.onerror = () => reject(new Error('Could not read duration')); }); if (Number.isFinite(audio.duration) && audio.duration > 0) { v.duration = audio.duration; if (el && document.body.contains(el)) { el.textContent = fmtDuration(v.duration); el.title = String(v.duration.toFixed(2)); } } audio.removeAttribute('src'); audio.load(); } catch(e) { if (el && document.body.contains(el)) el.title = e.message; } finally { v._durationLoading = false; } } document.querySelectorAll('.vl-header [data-sort]').forEach(el => el.addEventListener('click', () => setSort(el.dataset.sort)) ); function dominantLanguages(limit = 3) { const counts = new Map(); (_voices || []).forEach(v => { const lang = (v.lang || String(v.id || '').split('_')[0] || '?').toUpperCase(); counts.set(lang, (counts.get(lang) || 0) + 1); }); return [...counts.entries()] .sort((a,b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, limit) .map(([lang, count]) => `${lang} ${count}`) .join(' · ') || '-'; } function updateLibraryInsights(state = 'ready') { const el = $('library-insights'); if (!el) return; if (state === 'loading') { el.innerHTML = [ ['…', 'Loading'], ['…', 'Active'], ['…', 'Languages'], ['…', 'Benchmarks'], ['…', 'Quality'], ['…', 'Actions'] ].map(([value, label]) => `
${value}${label}
`).join(''); return; } if (state === 'error') { el.innerHTML = '
FailedLibrary load
'; return; } const total = _voices.length; const active = _voices.filter(v => v.enabled !== false).length; const hidden = total - active; const bench = _voices.map(voiceBenchmark).filter(Boolean); const slow = bench.filter(b => b && b.ok && b.realtime_ok === false).length; const dbValues = _voices.map(voiceDbfs).filter(v => v != null); const avgDb = dbValues.length ? (dbValues.reduce((a,b) => a + b, 0) / dbValues.length).toFixed(1) : '-'; const missingRef = _voices.filter(v => !v.transcript).length; const restart = _voices.filter(v => v.needs_tts_restart).length; const visible = _voices.filter(v => $('show-disabled-cb').checked || v.enabled !== false).length; const tiles = [ {value:`${visible}/${total}`, label:'Visible'}, {value:`${active} on`, label:hidden ? `${hidden} hidden` : 'Active'}, {value:dominantLanguages(), label:'Languages'}, {value:bench.length ? `${bench.length} done` : '-', label:slow ? `${slow} slow` : 'Benchmarks', filter: slow ? 'slow' : '', title: slow ? describeIssueVoices('slow') : 'No slow voices'}, {value:avgDb === '-' ? '-' : `${avgDb} dB`, label:missingRef ? `${missingRef} no text` : 'Avg loudness', filter: missingRef ? 'no_text' : '', title: missingRef ? describeIssueVoices('no_text') : 'All visible voices have reference text'}, {value:restart || '-', label:restart ? 'Need restart' : 'Restart flags', filter: restart ? 'restart' : '', title: restart ? describeIssueVoices('restart') : 'No voices need restart'}, ]; el.innerHTML = tiles.map(item => { const filter = item.filter ? ` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"` : ''; const activeCls = item.filter && item.filter === _libraryIssueFilter ? ' active' : ''; const title = item.title ? ` title="${escHtml(item.title)}"` : ''; return `
${escHtml(item.value)}${escHtml(item.label)}
`; }).join(''); el.querySelectorAll('[data-filter]').forEach(tile => { const activate = () => setLibraryIssueFilter(tile.dataset.filter || ''); tile.addEventListener('click', activate); tile.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }); }); } async function loadVoiceLibrary() { if (_libraryLoadPromise) return _libraryLoadPromise; _libraryLoadPromise = (async () => { setBusyButton('refresh-voices-btn', true); const list = $('voice-list'); if (list) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8); $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); try { const r = await fetch('/api/voices'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } _voices = await r.json(); if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices); renderVoiceList(); updatePreviewVoiceMatchPanel(); status(`Loaded ${_voices.length} voices`); } catch(e) { if (list) list.innerHTML = '
Failed to load voices
'; $('voice-count').textContent = 'Load failed'; updateLibraryInsights('error'); status('Voice library load failed'); throw e; } finally { setBusyButton('refresh-voices-btn', false); _libraryLoadPromise = null; } })(); return _libraryLoadPromise; } $('refresh-voices-btn').addEventListener('click', loadVoiceLibrary); $('sync-voice-folders-btn').addEventListener('click', async () => { $('sync-voice-folders-btn').disabled = true; status('Syncing active_voices and hidden_voices…'); try { const r = await fetch('/api/voices/sync-folders', { method:'POST' }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } const d = await r.json(); await loadVoiceLibrary(); const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : ''; toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success'); status(`Synced folders. Restart Qwen3-TTS after changing active voices.`); } catch(e) { toast('Sync failed: ' + e.message, 'error'); status('Folder sync failed'); } finally { $('sync-voice-folders-btn').disabled = false; } }); function visibleLibraryVoices() { const showDisabled = $('show-disabled-cb').checked; return _voices.filter(v => showDisabled || v.enabled !== false); } function libraryIssueMatch(v, filter = _libraryIssueFilter) { const b = voiceBenchmark(v); if (filter === 'slow') return Boolean(b && b.ok && b.realtime_ok === false); if (filter === 'no_text') return !String(v.transcript || '').trim(); if (filter === 'restart') return Boolean(v.needs_tts_restart); return true; } function libraryIssueLabel(filter = _libraryIssueFilter) { return {slow:'slow benchmark voices', no_text:'voices without reference text', restart:'voices needing TTS restart'}[filter] || 'all voices'; } function libraryIssueVoices(filter = _libraryIssueFilter) { return visibleLibraryVoices().filter(v => libraryIssueMatch(v, filter)); } function describeIssueVoices(filter = _libraryIssueFilter, limit = 12) { const voices = libraryIssueVoices(filter).map(v => v.id); if (!voices.length) return 'No matching voices'; const extra = voices.length > limit ? `, +${voices.length - limit} more` : ''; return voices.slice(0, limit).join(', ') + extra; } function setLibraryIssueFilter(filter = '') { _libraryIssueFilter = _libraryIssueFilter === filter ? '' : filter; renderVoiceList(); if (_libraryIssueFilter) status(`${libraryIssueLabel()}: ${describeIssueVoices()}`); else status('Showing all visible voices'); } function libraryTargetDb() { const input = $('library-target-db'); const raw = Number(input?.value ?? -20); const value = Number.isFinite(raw) ? Math.min(-1, Math.max(-60, raw)) : -20; if (input) input.value = String(value); return value; } $('calculate-db-btn').addEventListener('click', async () => { $('calculate-db-btn').disabled = true; status('Calculating voice loudness…'); try { const d = await clientCalculateVoiceDb(); renderVoiceList(); const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : ''; toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success'); status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`); } catch(e) { toast('Calculate dB failed: ' + e.message, 'error'); status('dB calculation failed'); } finally { $('calculate-db-btn').disabled = false; } }); $('normalize-volume-btn').addEventListener('click', async () => { const target = libraryTargetDb(); const visible = visibleLibraryVoices(); const voices = visible.filter(v => voiceFileType(v) === 'wav'); const skipped = visible.length - voices.length; if (!voices.length) { toast('No visible WAV voices to normalize', 'error'); return; } if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return; $('normalize-volume-btn').disabled = true; $('calculate-db-btn').disabled = true; const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'}; const errors = []; let normalized = 0; setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats); status(`Normalizing ${voices.length} voices to ${target} dBFS...`); try { for (const v of voices) { setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats); try { const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})}); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.loudness = d.loudness || v.loudness; v.duration = d.duration ?? v.duration; v.file_type = d.file_type || v.file_type; v.path = d.path || v.path; v.needs_tts_restart = true; markVoiceAudioChanged(v); normalized++; stats.ok++; stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`; } catch(e) { errors.push({voice_id:v.id, detail:e.message}); stats.errors++; stats.last = `${v.id}: ${e.message}`; } setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats); status(`Normalized ${normalized} / ${voices.length}`); await new Promise(resolve => setTimeout(resolve, 0)); } setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats); renderVoiceList(); updateLibraryInsights(); const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`; toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success'); status('Volume normalized. Restart TTS before rebenchmarking these voices.'); } catch(e) { toast('Normalize volume failed: ' + e.message, 'error'); status('Normalize volume failed'); } finally { $('normalize-volume-btn').disabled = false; $('calculate-db-btn').disabled = false; } }); function fmtClock(ms) { if (!Number.isFinite(ms) || ms < 0) return '-'; const total = Math.round(ms / 1000); const m = Math.floor(total / 60), s = total % 60; return `${m}:${String(s).padStart(2,'0')}`; } function setBenchmarkProgress(done, total, label = '', stats = {}) { const panel = $('benchmark-progress'); const track = panel.querySelector('.benchmark-progress-track'); const pct = total ? Math.round(done / total * 100) : 0; panel.hidden = false; $('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...'); $('benchmark-progress-count').textContent = `${done} / ${total}`; $('benchmark-progress-bar').style.width = pct + '%'; track.setAttribute('aria-valuenow', String(pct)); const live = $('benchmark-live-stats'); if (live) { const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0; const avg = done > 0 ? elapsed / done : 0; const eta = done > 0 && total > done ? avg * (total - done) : 0; live.innerHTML = [ `Elapsed ${fmtClock(elapsed)}`, `Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`, `ETA ${done && total > done ? fmtClock(eta) : '-'}`, `OK ${stats.ok || 0}`, `${stats.middleLabel || 'Slow'} ${stats.slow || 0}`, `${stats.errorLabel || 'Errors'} ${stats.errors || 0}`, ].map(x => `${escHtml(x)}`).join(''); } const last = $('benchmark-live-last'); if (last && stats.last) last.textContent = stats.last; } function hideBenchmarkProgress() { $('benchmark-progress').hidden = true; $('benchmark-progress-bar').style.width = '0%'; if ($('benchmark-live-last')) $('benchmark-live-last').textContent = ''; } async function clearTtsRestartFlags() { const r = await fetch('/api/tts/restart-flags/clear', { method:'POST' }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _voices.forEach(voice => { voice.needs_tts_restart = false; }); document.querySelectorAll('.vl-row.edit-open').forEach(row => row.classList.remove('opt-restart-needed')); updateLibraryInsights(); return d; } async function runVoiceBenchmark(voiceId = '', opts = {}) { const text = opts.text ?? benchmarkSampleText(); if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } const payload = {active_only:true, text}; if (voiceId) payload.voice_id = voiceId; const r = await fetch('/api/voices/benchmark', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } return r.json(); } async function runVoiceBenchmarkBatch() { const voices = _voices.filter(v => v.enabled !== false); const text = benchmarkSampleText(); if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } if (!voices.length) { toast('No active voices to benchmark', 'error'); return null; } const total = voices.length; const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true}; const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''}; setBenchmarkProgress(0, total, 'Starting benchmark...', stats); for (let i = 0; i < voices.length; i++) { const voice = voices[i]; setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats); status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`); try { const d = await runVoiceBenchmark(voice.id, {text}); if (d) { aggregate.benchmarked += Number(d.benchmarked || 0); aggregate.errors.push(...(d.errors || [])); aggregate.voices.push(...(d.voices || [])); mergeBenchmarkResults(d); const hit = (d.voices || []).find(x => x.voice_id === voice.id); const b = hit && hit.benchmark; if (b && b.ok) { stats.ok++; if (b.realtime_ok === false) stats.slow++; stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`; } else { stats.errors++; stats.last = `${voice.id}: failed${b && b.error ? ' · ' + b.error : ''}`; } } } catch(e) { aggregate.errors.push({voice_id: voice.id, detail: e.message}); stats.errors++; stats.last = `${voice.id}: failed · ${e.message}`; } setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats); } setBenchmarkProgress(total, total, 'Benchmark complete', stats); return aggregate; } function mergeBenchmarkResults(d) { const byId = new Map((d.voices || []).map(x => [x.voice_id, x])); _voices.forEach(v => { const hit = byId.get(v.id); if (hit && hit.benchmark) v.benchmark = hit.benchmark; }); } function activeBenchmarkVoices() { return (_voices || []).filter(v => v.enabled !== false); } function showBenchmarkConfirm() { const voices = activeBenchmarkVoices(); const text = benchmarkSampleText(); if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; } if (!voices.length) { toast('No active voices to benchmark', 'error'); return; } const staleCount = voices.filter(v => v.needs_tts_restart).length; $('benchmark-confirm-title').textContent = `Benchmark ${voices.length} active voices?`; $('benchmark-confirm-text').textContent = 'This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice.' + (staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : ''); $('benchmark-confirm').hidden = false; $('benchmark-confirm-start').focus(); } function hideBenchmarkConfirm() { const panel = $('benchmark-confirm'); if (panel) panel.hidden = true; } $('benchmark-voices-btn').addEventListener('click', showBenchmarkConfirm); $('benchmark-confirm-cancel')?.addEventListener('click', hideBenchmarkConfirm); $('benchmark-confirm-start')?.addEventListener('click', async () => { hideBenchmarkConfirm(); $('benchmark-voices-btn').disabled = true; $('benchmark-confirm-start').disabled = true; status('Benchmarking active voices...'); try { const d = await runVoiceBenchmarkBatch(); if (!d) return; mergeBenchmarkResults(d); await loadVoiceLibrary(); const slow = (d.voices || []).filter(x => x.benchmark && x.benchmark.realtime_ok === false).length; const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : ''; toast(`Benchmarked ${d.benchmarked} voices${slow ? `, ${slow} slow` : ''}${extra}`, d.errors && d.errors.length ? 'error' : 'success'); status('Benchmark saved with TTFA, total time, RTF, and speed.'); } catch(e) { toast('Benchmark failed: ' + e.message, 'error'); status('Benchmark failed'); } finally { $('benchmark-voices-btn').disabled = false; $('benchmark-confirm-start').disabled = false; } }); $('copy-active-voices-btn').addEventListener('click', async () => { const active = activeVoiceIds(); if (!active.length) { toast('No active voices to copy', 'error'); return; } await copyText(active.join(', ')); toast('Copied ' + active.length + ' active voices', 'success'); status('Copied active voices to clipboard'); }); const LIB_ADD_SAMPLE_TEXTS = { EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.', DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.', IT: 'La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.', ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.', FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.', PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.', NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.', PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.' }; const LIB_ADD_SAMPLE_STORAGE_KEY = 'vcf-lib-add-sample-texts'; function libAddSampleOverrides() { try { return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY) || '{}') || {}; } catch(e) { return {}; } } function getLibAddSampleText(code) { return libAddSampleOverrides()[code] || LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN; } function saveLibAddSampleText() { const code = $('lib-add-sample-lang').value; const text = $('lib-add-sample-text').value.trim(); const overrides = libAddSampleOverrides(); if (text && text !== LIB_ADD_SAMPLE_TEXTS[code]) overrides[code] = text; else delete overrides[code]; localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides)); setLibAddStatus('Sample sentence saved'); } function resetLibAddSampleText() { const code = $('lib-add-sample-lang').value; const overrides = libAddSampleOverrides(); delete overrides[code]; localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides)); $('lib-add-sample-text').value = LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN; setLibAddStatus('Sample sentence reset'); } function updateLibAddSampleLanguage(lang) { const code = LIB_ADD_SAMPLE_TEXTS[lang] ? lang : 'EN'; $('lib-add-sample-lang').value = code; $('lib-add-lang').value = code; $('lib-add-sample-text').value = getLibAddSampleText(code); const voiceId = $('lib-add-voice-id').value.trim(); if (voiceId && /^[A-Z]{2}_/.test(voiceId)) { $('lib-add-voice-id').value = voiceId.replace(/^[A-Z]{2}_/, code + '_'); } } function renderLibAddMeter(level = 0, db = -Infinity, clipped = false) { const meter = $('lib-add-mic-meter'); if (!meter.children.length) { for (let i = 0; i < 18; i++) { const bar = document.createElement('div'); bar.className = 'bar'; meter.appendChild(bar); } } const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length); [...meter.children].forEach((bar, i) => { bar.className = 'bar'; bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px'; if (i < active) { bar.classList.add('on'); if (db > -12 && i > 11) bar.classList.add('hot'); if (clipped && i > 14) bar.classList.add('clip'); } }); $('lib-add-db-readout').textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; } function syncLibAddMicGain() { const gain = parseFloat($('lib-add-mic-gain').value) || 0; $('lib-add-mic-gain-value').textContent = gain.toFixed(2) + 'x'; if (libAddState.gainNode) libAddState.gainNode.gain.value = gain; } function startLibAddMeter() { if (!libAddState.analyser) return; if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf); const data = new Float32Array(libAddState.analyser.fftSize); const tick = () => { libAddState.analyser.getFloatTimeDomainData(data); let sum = 0, peak = 0; for (const sample of data) { sum += sample * sample; peak = Math.max(peak, Math.abs(sample)); } const rms = Math.sqrt(sum / data.length); const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity; const level = Number.isFinite(db) ? (db + 60) / 60 : 0; renderLibAddMeter(level, db, peak > 0.98); libAddState.meterRaf = requestAnimationFrame(tick); }; tick(); } async function ensureLibAddMicMonitor() { if (libAddState.recordStream) return; const AudioCtx = window.AudioContext || window.webkitAudioContext; libAddState.stream = await requestMicrophoneStream({raw:true}); if (AudioCtx) { libAddState.audioCtx = new AudioCtx(); libAddState.sourceNode = libAddState.audioCtx.createMediaStreamSource(libAddState.stream); libAddState.gainNode = libAddState.audioCtx.createGain(); libAddState.analyser = libAddState.audioCtx.createAnalyser(); libAddState.analyser.fftSize = 1024; const dest = libAddState.audioCtx.createMediaStreamDestination(); syncLibAddMicGain(); libAddState.sourceNode.connect(libAddState.gainNode); libAddState.gainNode.connect(libAddState.analyser); libAddState.gainNode.connect(dest); libAddState.recordStream = dest.stream; startLibAddMeter(); } else { libAddState.recordStream = libAddState.stream; } libAddState.monitoring = true; $('lib-add-monitor-btn').disabled = true; $('lib-add-monitor-stop').disabled = false; } function stopLibAddMic() { if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf); libAddState.meterRaf = null; [libAddState.sourceNode, libAddState.gainNode, libAddState.analyser].forEach(node => { try { if (node) node.disconnect(); } catch(e) {} }); if (libAddState.stream) libAddState.stream.getTracks().forEach(t => t.stop()); if (libAddState.recordStream) libAddState.recordStream.getTracks().forEach(t => t.stop()); if (libAddState.audioCtx) libAddState.audioCtx.close().catch(()=>{}); libAddState.stream = null; libAddState.recordStream = null; libAddState.sourceNode = null; libAddState.gainNode = null; libAddState.analyser = null; libAddState.audioCtx = null; libAddState.monitoring = false; $('lib-add-monitor-btn').disabled = false; $('lib-add-monitor-stop').disabled = true; renderLibAddMeter(0, -Infinity, false); } let libAddState = { id:null, duration:0, audio:null, buffer:null, recorder:null, chunks:[], pendingSource:null, stream:null, recordStream:null, timer:null, secs:0, audioCtx:null, sourceNode:null, gainNode:null, analyser:null, meterRaf:null, monitoring:false }; window.libAddState = libAddState; $('add-new-voice-btn').addEventListener('click', () => { $('lib-add-panel').classList.toggle('open'); }); $('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value)); $('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value)); $('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500)); $('lib-add-use-sample').addEventListener('click', () => { $('lib-add-transcript').value = $('lib-add-sample-text').value.trim(); setLibAddStatus('Sample sentence copied to transcript'); }); $('lib-add-reset-sample').addEventListener('click', resetLibAddSampleText); $('lib-add-mic-help-btn').addEventListener('click', () => { $('lib-add-mic-help').classList.toggle('open'); }); $('lib-add-monitor-btn').addEventListener('click', async () => { try { await ensureLibAddMicMonitor(); setLibAddStatus('Mic level monitor active'); } catch(e) { stopLibAddMic(); $('lib-add-mic-help').classList.add('open'); const message = await microphoneErrorMessage(e); toast(message, 'error'); setLibAddStatus(message); } }); $('lib-add-monitor-stop').addEventListener('click', () => { stopLibAddMic(); setLibAddStatus('Mic level monitor stopped'); }); $('lib-add-mic-gain').addEventListener('input', syncLibAddMicGain); renderLibAddMeter(); syncLibAddMicGain(); updateLibAddSampleLanguage('EN'); function setLibAddStatus(msg) { $('lib-add-status').textContent = msg; status(msg); } function suggestLibVoiceId(filename) { if ($('lib-add-voice-id').value.trim()) return; const base = String(filename || 'NewVoice') .replace(/\.[^.]+$/, '') .replace(/[^A-Za-z0-9_-]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 60) || 'NewVoice'; $('lib-add-voice-id').value = `${$('lib-add-lang').value || 'EN'}_${$('lib-add-gender').value || 'N'}_${base}`; } function loadLibAddAudio(id, duration, label = 'Audio') { libAddState.id = id; libAddState.duration = Number(duration) || 0; libAddState.buffer = null; $('lib-add-start').value = '0.00'; $('lib-add-end').value = libAddState.duration ? Math.min(libAddState.duration, 20).toFixed(2) : '0.00'; $('lib-add-audio').src = '/api/audio/' + id; $('lib-add-audio').style.display = ''; $('lib-add-wave').style.display = ''; attachLibAddWaveSelection(); decodeTempAudio(id).then(buffer => { if (libAddState.id !== id) return; libAddState.buffer = buffer; drawLibAddWave(); }).catch(()=>{}); setLibAddStatus(`${label} loaded${libAddState.duration ? ' (' + libAddState.duration.toFixed(1) + ' s)' : ''}`); } async function decodeTempAudio(id) { const resp = await fetch('/api/audio/' + encodeURIComponent(id)); if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); const data = await resp.arrayBuffer(); const ctx = new (window.AudioContext || window.webkitAudioContext)(); return ctx.decodeAudioData(data.slice(0)); } function clampLibAddTime(value) { const duration = libAddState.duration || libAddState.buffer?.duration || 0; return Math.max(0, Math.min(duration, Number(value) || 0)); } function setLibAddCropRange(start, end) { const duration = libAddState.duration || libAddState.buffer?.duration || 0; let a = clampLibAddTime(start), b = clampLibAddTime(end); if (Math.abs(b - a) < 0.05) b = Math.min(duration, a + Math.min(1, duration || 1)); if (b < a) [a, b] = [b, a]; $('lib-add-start').value = a.toFixed(2); $('lib-add-end').value = b.toFixed(2); drawLibAddWave(); } function libAddWaveTimeFromEvent(e) { const canvas = $('lib-add-wave'); const rect = canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); const duration = libAddState.duration || libAddState.buffer?.duration || 0; return rect.width ? x / rect.width * duration : 0; } function updateLibAddCropHint() { const hint = $('lib-add-crop-hint'); if (!hint) return; const start = parseFloat($('lib-add-start').value) || 0; const end = parseFloat($('lib-add-end').value) || 0; const dur = Math.max(0, end - start); hint.textContent = dur ? `Selected ${dur.toFixed(1)}s. Aim for 3-20 seconds.` : 'Select 3-20 seconds for best cloning.'; hint.className = 'crop-duration-hint ' + (dur >= 3 && dur <= 20 ? 'ok' : dur ? 'warn' : ''); } function drawLibAddWave() { if (!libAddState.buffer) return; drawOptimizerWave( $('lib-add-wave'), libAddState.buffer, parseFloat($('lib-add-start').value) || 0, parseFloat($('lib-add-end').value) || libAddState.duration || libAddState.buffer.duration ); updateLibAddCropHint(); } function libAddWaveSelectionPixels(e) { const canvas = $('lib-add-wave'); const rect = canvas.getBoundingClientRect(); const duration = libAddState.duration || libAddState.buffer?.duration || 0; const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0); const end = clampLibAddTime(parseFloat($('lib-add-end').value) || duration); const sx = duration && rect.width ? start / duration * rect.width : 0; const ex = duration && rect.width ? end / duration * rect.width : rect.width; const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); return {x, sx, ex, start, end, duration}; } function libAddWaveDragMode(e) { const {x, sx, ex} = libAddWaveSelectionPixels(e); const hit = 16; if (Math.abs(x - sx) <= hit) return 'start'; if (Math.abs(x - ex) <= hit) return 'end'; return 'new'; } function attachLibAddWaveSelection() { const canvas = $('lib-add-wave'); if (!canvas || canvas.dataset.cropReady) return; canvas.dataset.cropReady = '1'; let drag = null; canvas.addEventListener('pointerdown', e => { if (!libAddState.buffer) return; e.preventDefault(); const mode = libAddWaveDragMode(e); const t = libAddWaveTimeFromEvent(e); const currentStart = parseFloat($('lib-add-start').value) || 0; const currentEnd = parseFloat($('lib-add-end').value) || libAddState.duration || 0; drag = {mode, anchor: t, start: currentStart, end: currentEnd}; canvas.setPointerCapture?.(e.pointerId); if (mode === 'start') setLibAddCropRange(t, currentEnd); else if (mode === 'end') setLibAddCropRange(currentStart, t); else setLibAddCropRange(t, t); setLibAddStatus(mode === 'start' ? 'Dragging crop start handle' : mode === 'end' ? 'Dragging crop end handle' : 'Drag to choose a new crop range'); }); canvas.addEventListener('pointermove', e => { if (!libAddState.buffer) return; if (!drag) { const mode = libAddWaveDragMode(e); canvas.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : 'crosshair'; return; } e.preventDefault(); const t = libAddWaveTimeFromEvent(e); if (drag.mode === 'start') setLibAddCropRange(t, drag.end); else if (drag.mode === 'end') setLibAddCropRange(drag.start, t); else setLibAddCropRange(drag.anchor, t); }); const finish = e => { if (!drag) return; e.preventDefault(); const t = libAddWaveTimeFromEvent(e); if (drag.mode === 'start') setLibAddCropRange(t, drag.end); else if (drag.mode === 'end') setLibAddCropRange(drag.start, t); else setLibAddCropRange(drag.anchor, t); drag = null; const start = parseFloat($('lib-add-start').value) || 0; const end = parseFloat($('lib-add-end').value) || 0; setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0, end - start).toFixed(1)}s) selected`); }; canvas.addEventListener('pointerup', finish); canvas.addEventListener('pointerleave', () => { if (!drag) canvas.style.cursor = 'crosshair'; }); canvas.addEventListener('pointercancel', () => { drag = null; canvas.style.cursor = 'crosshair'; }); } async function uploadLibAddFile(file) { if (!file) return; const fd = new FormData(); fd.append('file', file); setLibAddStatus('Uploading audio…'); try { const r = await fetch('/api/upload', {method:'POST', body:fd}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); suggestLibVoiceId(file.name); loadLibAddAudio(d.id, d.duration, file.name || 'Audio'); toast('Audio loaded', 'success'); } catch(e) { toast('Load failed: ' + e.message, 'error'); setLibAddStatus('Load failed'); } } const libAddDrop = $('lib-add-drop'); libAddDrop.addEventListener('click', () => $('lib-add-file').click()); libAddDrop.addEventListener('dragover', e => { e.preventDefault(); libAddDrop.classList.add('drag-over'); }); libAddDrop.addEventListener('dragleave', () => libAddDrop.classList.remove('drag-over')); libAddDrop.addEventListener('drop', e => { e.preventDefault(); libAddDrop.classList.remove('drag-over'); if (e.dataTransfer.files.length) uploadLibAddFile(e.dataTransfer.files[0]); }); $('lib-add-file').addEventListener('change', async () => { if ($('lib-add-file').files.length) await uploadLibAddFile($('lib-add-file').files[0]); $('lib-add-file').value = ''; }); $('lib-add-url-btn').addEventListener('click', () => { const url = $('lib-add-url').value.trim(); if (!url) { toast('Enter a YouTube or audio URL', 'error'); return; } $('lib-add-url-btn').disabled = true; setLibAddStatus('Starting download…'); const es = new EventSource('/api/download-yt?url=' + encodeURIComponent(url)); es.onmessage = e => { const d = JSON.parse(e.data); if (d.error) { toast('Download failed: ' + d.error, 'error'); setLibAddStatus(d.error); $('lib-add-url-btn').disabled = false; es.close(); } else if (d.done) { es.close(); $('lib-add-url-btn').disabled = false; suggestLibVoiceId(url.split('/').pop() || 'DownloadedVoice'); loadLibAddAudio(d.id, d.duration, 'Downloaded audio'); toast('URL audio loaded', 'success'); } else { setLibAddStatus(d.msg || 'Downloading…'); } }; es.onerror = () => { es.close(); $('lib-add-url-btn').disabled = false; setLibAddStatus('Download connection closed'); }; }); $('lib-add-rec-start').addEventListener('click', async () => { try { await ensureLibAddMicMonitor(); libAddState.chunks = []; libAddState.secs = 0; $('lib-add-rec-time').textContent = '0:00'; $('lib-add-rec-start').disabled = true; $('lib-add-rec-stop').disabled = false; $('lib-add-monitor-stop').disabled = true; libAddState.timer = setInterval(() => { libAddState.secs++; $('lib-add-rec-time').textContent = Math.floor(libAddState.secs / 60) + ':' + String(libAddState.secs % 60).padStart(2, '0'); }, 1000); libAddState.recorder = new MediaRecorder(libAddState.recordStream); libAddState.recorder.ondataavailable = e => { if (e.data.size) libAddState.chunks.push(e.data); }; libAddState.recorder.onstop = async () => { clearInterval(libAddState.timer); $('lib-add-rec-start').disabled = false; $('lib-add-rec-stop').disabled = true; const blob = new Blob(libAddState.chunks, {type:libAddState.recorder.mimeType || 'audio/webm'}); const ext = (libAddState.recorder.mimeType || '').includes('ogg') ? '.ogg' : '.webm'; stopLibAddMic(); suggestLibVoiceId('recording'); await uploadLibAddFile(new File([blob], 'recording' + ext, {type:blob.type})); }; libAddState.recorder.start(100); setLibAddStatus('Recording…'); } catch(e) { stopLibAddMic(); $('lib-add-mic-help').classList.add('open'); const message = await microphoneErrorMessage(e); toast(message, 'error'); setLibAddStatus(message); $('lib-add-rec-start').disabled = false; $('lib-add-rec-stop').disabled = true; } }); $('lib-add-rec-stop').addEventListener('click', () => { if (libAddState.recorder && libAddState.recorder.state !== 'inactive') libAddState.recorder.stop(); }); $('lib-add-auto-trim').addEventListener('click', async () => { if (!libAddState.id) { toast('Load audio first', 'error'); return; } $('lib-add-auto-trim').disabled = true; try { const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id})}); let d; if (r.ok) d = await r.json(); else if (r.status === 404 || r.status === 405) d = await clientAutoTrimBounds(libAddState.id); else { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } $('lib-add-start').value = Number(d.start).toFixed(2); $('lib-add-end').value = Number(d.end).toFixed(2); drawLibAddWave(); setLibAddStatus(d.reason || 'Auto trim ready'); } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); setLibAddStatus('Auto trim failed'); } finally { $('lib-add-auto-trim').disabled = false; } }); async function transcribeLibAddCurrent(successMessage = 'Text recognised', audioId = libAddState.id) { if (!audioId) throw new Error('Load audio first'); setLibAddStatus('Recognising text...'); const r = await fetch('/api/transcribe', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:audioId}) }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); const text = d.text || ''; $('lib-add-transcript').value = text; setLibAddStatus(successMessage); return text; } function openSavedLibraryVoice(voiceId) { const openRow = () => { const row = Array.from(document.querySelectorAll('.vl-row')).find(r => r.dataset.id === voiceId); if (!row) return false; row.scrollIntoView({behavior:'smooth', block:'center'}); if (!row.classList.contains('edit-open')) row.querySelector('.edit-audio-btn')?.click(); return true; }; if (!openRow()) setTimeout(openRow, 150); } async function applyLibAddCrop() { if (!libAddState.id) { toast('Load audio first', 'error'); return; } const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0); const end = clampLibAddTime(parseFloat($('lib-add-end').value) || libAddState.duration); const duration = end - start; if (end <= start + 0.1) { toast('Crop range is too short', 'error'); setLibAddStatus('Crop range is too short'); return; } if (duration < 3 || duration > 20) toast('Best clone references are 3-20 seconds; cropping anyway.', 'error'); ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = true; }); setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`); try { const r = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id, start, end})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); loadLibAddAudio(d.id, d.duration, 'Cropped audio'); toast('Crop applied', 'success'); try { await transcribeLibAddCurrent('Cropped audio loaded and text recognised', d.id); } catch (e) { toast('Crop applied, but recognition failed: ' + e.message, 'error'); setLibAddStatus('Cropped audio loaded; recognition failed'); } } catch(e) { toast('Crop failed: ' + e.message, 'error'); setLibAddStatus('Crop failed'); } finally { ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = false; }); } } $('lib-add-save-crop').addEventListener('click', applyLibAddCrop); $('lib-add-save-crop-bottom').addEventListener('click', applyLibAddCrop); ['lib-add-start','lib-add-end'].forEach(id => $(id).addEventListener('input', drawLibAddWave)); $('lib-add-play').addEventListener('click', () => { if (!libAddState.id) return; if (libAddState.audio) libAddState.audio.pause(); libAddState.audio = new Audio('/api/audio/' + libAddState.id); const start = parseFloat($('lib-add-start').value) || 0; const end = parseFloat($('lib-add-end').value) || libAddState.duration; libAddState.audio.currentTime = start; libAddState.audio.ontimeupdate = () => { if (libAddState.audio.currentTime >= end) libAddState.audio.pause(); }; libAddState.audio.play(); }); $('lib-add-recognize').addEventListener('click', async () => { if (!libAddState.id) { toast('Load audio first', 'error'); return; } try { await transcribeLibAddCurrent('Text recognised'); } catch(e) { toast('Recognition failed: ' + e.message, 'error'); setLibAddStatus('Recognition failed'); } }); $('lib-add-save').addEventListener('click', async () => { if (!libAddState.id) { toast('Load audio first', 'error'); return; } const voiceId = $('lib-add-voice-id').value.trim() || `${$('lib-add-lang').value}_${$('lib-add-gender').value}_NewVoice`; if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters', 'error'); return; } setLibAddStatus('Saving voice...'); $('lib-add-save').disabled = true; try { const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:libAddState.id, start:parseFloat($('lib-add-start').value)||0, end:parseFloat($('lib-add-end').value)||libAddState.duration})}); if (!pr.ok) { const e = await pr.json().catch(()=>({})); throw new Error(e.detail || pr.statusText); } const p = await pr.json(); let transcript = $('lib-add-transcript').value.trim(); if (!transcript) { transcript = await transcribeLibAddCurrent('Final clip recognised; saving voice...', p.id); if (!transcript.trim()) throw new Error('Recognition returned no transcript; add text or try recognising again.'); } const sr = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:p.id, voice_id:voiceId, transcript})}); if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); } if (libAddState.pendingSource?.imageUrl) { try { await fetch('/api/voice/picture-url', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl}) }); } catch (_) {} } libAddState.pendingSource = null; setLibAddSourcePreview({}); $('lib-add-panel')?.classList.remove('open'); toast('Voice saved: ' + voiceId, 'success'); setLibAddStatus('Voice saved'); await loadVoiceLibrary(); openSavedLibraryVoice(voiceId); } catch(e) { toast('Save failed: ' + e.message, 'error'); setLibAddStatus('Save failed'); } finally { $('lib-add-save').disabled = false; } }); $('show-disabled-cb').addEventListener('change', () => { $('disabled-info').style.display = $('show-disabled-cb').checked ? '' : 'none'; renderVoiceList(); }); ['library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { $(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); }); }); $('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180)); $('library-clear-filters')?.addEventListener('click', clearLibraryFilters); $('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); }); function renderVoiceList() { const showDisabled = $('show-disabled-cb').checked; const list = $('voice-list'); list.innerHTML = ''; populateLibraryFilters(); readLibraryFilters(); // Apply sidebar category filter const cat = window._voiceSidebarCat || 'all'; let filtered = _voices.filter(v => { if (cat === 'cloned') return v.has_ref; if (cat === 'designed') return !v.has_ref; if (cat === 'favorites') return (v.rating || 0) >= 4; if (cat === 'hidden') return v.enabled === false; if (cat === 'tools') return false; // no rows for tools view // 'all' — respect show-disabled toggle return showDisabled || v.enabled !== false; }); // For hidden/favorites cats always show all regardless of show-disabled toggle if (cat !== 'all' && cat !== 'tools') { // already filtered above — no extra enabled filter needed } else if (cat === 'all') { // already applied enabled filter in the lambda above } const visibleCount = filtered.length; filtered = filtered.filter(libraryFilterMatch); const filterCount = filtered.length; if (_libraryIssueFilter) filtered = filtered.filter(v => libraryIssueMatch(v)); $('voice-count').textContent = filtered.length + ' / ' + _voices.length + ' voices'; filtered = filtered.slice().sort((a, b) => { const av = getSortValue(a, _sortField), bv = getSortValue(b, _sortField); if (av < bv) return -_sortDir; if (av > bv) return _sortDir; return 0; }); updateLibraryInsights(); if (_libraryIssueFilter) { const note = document.createElement('div'); note.className = 'library-filter-note'; note.innerHTML = `${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`; note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter('')); list.appendChild(note); } if (!filtered.length) { if (_voices.length === 0) { // True empty — render a two-case helper panel const emptyEl = document.createElement('div'); emptyEl.className = 'voices-empty-state'; emptyEl.innerHTML = `

Wrong folder path?

Set the path where your .wav voice files live inside the container.

Map a host folder via docker-compose:
- /your/host/path:/voices:rw
or set VOICE_HOST_DIR=/your/host/path in the stack env.

or

Folder is empty?

Create your first voice from a recording or download ready-made voices.

`; list.appendChild(emptyEl); fetch('/api/settings').then(r => r.json()).then(s => { const inp = document.getElementById('voices-empty-scan-dir'); if (inp) inp.value = s.voices_scan_dir || '/voices'; }).catch(() => {}); // ── Directory browser ────────────────────────────────────────────────── let _vefCurrentPath = '/'; const vefBrowser = document.getElementById('vef-dir-browser'); const vefDirList = document.getElementById('vef-dir-list'); const vefCrumb = document.getElementById('vef-breadcrumb'); const vefSelPath = document.getElementById('vef-selected-path'); async function vefNavigate(path) { _vefCurrentPath = path; vefDirList.innerHTML = 'Loading…'; if (vefSelPath) vefSelPath.textContent = path; try { const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json()); // Breadcrumb const parts = data.path.split('/').filter(Boolean); const crumbs = [{ label: '/', path: '/' }]; parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') })); vefCrumb.innerHTML = crumbs.map((c, i) => i < crumbs.length - 1 ? `/` : `${escHtml(c.label)}` ).join(''); vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path))); // Directory list if (!data.dirs.length) { vefDirList.innerHTML = 'No subdirectories here.'; } else { vefDirList.innerHTML = data.dirs.map(d => `` ).join(''); vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path))); } if (vefSelPath) vefSelPath.textContent = data.path; _vefCurrentPath = data.path; } catch(e) { vefDirList.innerHTML = `Error: ${escHtml(e.message)}`; } } document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => { const open = vefBrowser.hidden; vefBrowser.hidden = !open; if (open) { const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices'; vefNavigate(cur); } }); document.getElementById('vef-select-btn')?.addEventListener('click', () => { const inp = document.getElementById('voices-empty-scan-dir'); if (inp) inp.value = _vefCurrentPath; if (vefBrowser) vefBrowser.hidden = true; }); // ────────────────────────────────────────────────────────────────────── document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => { const inp = document.getElementById('voices-empty-scan-dir'); const dir = inp?.value?.trim(); if (!dir) return; const btn = document.getElementById('voices-empty-save-btn'); btn.disabled = true; btn.textContent = 'Saving…'; try { const s = await fetch('/api/settings').then(r => r.json()); s.voices_scan_dir = dir; const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); toast('Folder saved — reloading voices…', 'success'); _libraryLoadPromise = null; await loadVoiceLibrary(); } catch(e) { toast('Failed: ' + e.message, 'error'); btn.disabled = false; btn.textContent = 'Set & reload'; } }); document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone')); document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio')); } else { list.innerHTML += '
No voices match the current filters.
'; } return; } filtered.forEach(v => list.appendChild(makeVoiceRow(v))); if (_pendingSelectId) { const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`); if (wrap) { _pendingSelectId = null; selectVoice(wrap); } } syncSortHeaders(); } async function decodeVoiceAudio(v) { const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); const data = await resp.arrayBuffer(); const ctx = new (window.AudioContext || window.webkitAudioContext)(); return ctx.decodeAudioData(data.slice(0)); } function drawOptimizerWave(canvas, buffer, start = 0, end = buffer.duration) { const dpr = window.devicePixelRatio || 1; const width = Math.max(1, canvas.clientWidth); const height = Math.max(1, canvas.clientHeight); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg') || '#111'; ctx.fillRect(0, 0, width, height); const data = buffer.getChannelData(0); const step = Math.max(1, Math.floor(data.length / width)); const mid = height / 2; ctx.strokeStyle = '#89b4fa'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x < width; x++) { let min = 1, max = -1; const base = x * step; for (let i = 0; i < step && base + i < data.length; i++) { const v = data[base + i]; if (v < min) min = v; if (v > max) max = v; } ctx.moveTo(x, mid + min * mid * .92); ctx.lineTo(x, mid + max * mid * .92); } ctx.stroke(); const sx = Math.max(0, Math.min(width, start / buffer.duration * width)); const ex = Math.max(sx, Math.min(width, end / buffer.duration * width)); ctx.fillStyle = 'rgba(166,227,161,.18)'; ctx.fillRect(sx, 0, ex - sx, height); ctx.strokeStyle = '#a6e3a1'; ctx.lineWidth = 2; ctx.strokeRect(sx + .5, .5, Math.max(1, ex - sx - 1), height - 1); const selectedSec = Math.max(0, end - start); if (ex - sx > 54 && selectedSec > 0) { const label = `${selectedSec.toFixed(1)}s`; ctx.font = '12px ui-monospace, Menlo, Consolas, monospace'; const textW = ctx.measureText(label).width + 14; const tx = Math.max(sx + 6, Math.min(ex - textW - 6, sx + (ex - sx - textW) / 2)); ctx.fillStyle = 'rgba(30,30,46,.78)'; ctx.fillRect(tx, 6, textW, 22); ctx.fillStyle = '#ffffff'; ctx.fillText(label, tx + 7, 21); } const handleW = 12; ctx.fillStyle = '#3d5ce8'; ctx.strokeStyle = '#ffffff'; [sx, ex].forEach((x, idx) => { const hx = Math.max(0, Math.min(width - handleW, x - handleW / 2)); ctx.fillRect(hx, 0, handleW, height); ctx.strokeRect(hx + .5, .5, Math.max(1, handleW - 1), height - 1); ctx.fillStyle = '#ffffff'; ctx.fillRect(hx + 3, Math.max(12, height / 2 - 11), 2, 22); ctx.fillRect(hx + 7, Math.max(12, height / 2 - 11), 2, 22); ctx.fillStyle = '#3d5ce8'; }); } function makeVoiceRow(v) { const wrap = document.createElement('div'); wrap.className = 'vl-row' + (v.enabled===false ? ' vr-disabled' : ''); wrap.dataset.id = v.id; // Data attrs used by inspector header const color = avatarColor(v.id); wrap.dataset.color = color; wrap.dataset.hasRef = v.has_ref ? 'true' : 'false'; wrap.dataset.dbfs = fmtDbfs(v); wrap.dataset.lang = v.lang || v.id.split('_')[0].toUpperCase(); const langCode = v.lang || v.id.split('_')[0].toUpperCase(); const langOpts = FLAG_OPTIONS[langCode] || []; // Use language-specific variants if there are multiple; fall back to world picker otherwise const flagOpts = langOpts.length > 1 ? langOpts : ALL_FLAGS; const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || ''; const flagEmoji = currentFlag ? cc2flag(currentFlag) : ''; const flagCode = currentFlag ? ccDisplay(currentFlag) : '?'; wrap.dataset.flagEmoji = flagEmoji; wrap.dataset.flagCc = currentFlag || ''; wrap.dataset.rating = v.rating || 0; wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false'; const fileType = voiceFileType(v); const dbfs = fmtDbfs(v); const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; const benchText = fmtBenchmark(v); const benchTitle = benchmarkTitle(v); const benchCls = benchmarkClass(v); const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'}; const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'}; const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'}; const gender = v.gender || ''; const starsHtml = [1,2,3,4,5].map(i => `` ).join(''); const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null; const pickerHtml = flagOpts.length > 1 ? flagOpts.map(([cc, label]) => `${cc2flag(cc)}${ccDisplay(cc)}` ).join('') : `No regional variants`; // Compact list item elements (visible in list pane, hidden in inspector) const isClone = v.has_ref; const initial = v.id[0] ? v.id[0].toUpperCase() : '?'; const flagIconHtml = currentFlag ? `` : null; // Avatar: photo > flag icon > initial letter (with color) const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo' : flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial'; const avatarStyle = picSrc || flagIconHtml ? '' : `style="background:${color}"`; const avatarContent = picSrc ? `` : flagIconHtml || `${initial}`; wrap.innerHTML = `
${avatarContent}
${escHtml(v.id)}
${isClone ? 'Clone' : 'Design'} ${gender ? `${genderMap[gender]||'?'} ${genderLabel[gender]||''}` : ''}
${picSrc ? `` : '
'}
${flagEmoji} ${flagCode}
${pickerHtml}
${genderMap[gender]||'?'} ${genderLabel[gender]||'—'}
${escHtml(v.id)}
${escHtml(fileType.toUpperCase())}
${fmtDuration(v.duration)}
${escHtml(dbfs)}
${escHtml(benchText)}
${starsHtml}
Delete? ${escHtml(v.id)}
Reference audio · crop
Crop the saved WAV to 3–20 s of clean speech.
Reference transcript
The spoken text that matches this voice recording.
Voice match
Compare the saved WAV with a fresh TTS synthesis.

Compare the saved reference WAV with a fresh synthesis of the same reference text. Restart TTS first after editing a voice, otherwise the backend may still use a cached version.

WAV file
Synthesized reference text
${!isClone ? `
Style variation
Create a styled variant and save it as a new voice (CustomVoice only).

Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.

` : ''}
Loudness
Normalize the volume of the reference audio file.
Click the pencil to load waveform and tools.
`; const vrLengthEl = wrap.querySelector('.vr-length'); hydrateVoiceDuration(v, vrLengthEl); // Photo upload const photoCell = wrap.querySelector('.vr-photo'); const photoInput = wrap.querySelector('.photo-input'); photoCell.addEventListener('click', () => photoInput.click()); photoInput.addEventListener('change', async () => { if (!photoInput.files.length) return; const fd = new FormData(); fd.append('voice_id', v.id); fd.append('file', photoInput.files[0]); try { const r = await fetch('/api/voice/picture', { method:'POST', body:fd }); if (!r.ok) throw new Error((await r.json()).detail); const img = document.createElement('img'); img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`; img.alt = ''; photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput); v.has_picture = true; toast('Photo uploaded','success'); } catch(e) { toast('Photo upload failed: '+e.message,'error'); } }); // Per-voice loudness normalization const normalizeBtn = wrap.querySelector('.normalize-voice-btn'); const dbValue = wrap.querySelector('.vr-db-value'); const dbCell = wrap.querySelector('.vr-db'); normalizeBtn.addEventListener('click', async () => { const target = libraryTargetDb(); if (!confirm(`Normalize "${v.id}" to ${target} dBFS?`)) return; normalizeBtn.disabled = true; status('Normalizing ' + v.id + '…'); try { const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})}); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.loudness = d.loudness || v.loudness; v.duration = d.duration ?? v.duration; v.file_type = d.file_type || v.file_type; v.path = d.path || v.path; v.needs_tts_restart = true; markVoiceAudioChanged(v); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration); toast('Normalized: ' + v.id, 'success'); status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`); } catch(e) { toast('Normalize failed: ' + e.message, 'error'); status('Normalize failed'); } finally { normalizeBtn.disabled = false; } }); // Language picker const flagEmojiEl = wrap.querySelector('.flag-emoji'); const flagCodeEl = wrap.querySelector('.flag-code'); const flagPicker = wrap.querySelector('.flag-picker'); const flagCell = wrap.querySelector('.vr-flag'); flagCell.addEventListener('click', e => { e.stopPropagation(); document.querySelectorAll('.flag-picker.open').forEach(fp => { if(fp!==flagPicker) fp.classList.remove('open'); }); flagPicker.classList.toggle('open'); }); flagPicker.querySelectorAll('.flag-opt').forEach(opt => { opt.addEventListener('click', async e => { e.stopPropagation(); const cc = opt.dataset.cc; flagPicker.classList.remove('open'); flagEmojiEl.textContent = cc2flag(cc); flagCodeEl.textContent = ccDisplay(cc); flagPicker.querySelectorAll('.flag-opt').forEach(o => o.classList.toggle('active', o.dataset.cc===cc)); v.flag = cc; await saveMeta(v.id, { flag: cc }); }); }); // Gender cycle F → M → N → F const gBadge = wrap.querySelector('.gender-badge'); gBadge.addEventListener('click', async () => { const cycle = ['F','M','N']; v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3]; gBadge.innerHTML = `${genderMap[v.gender]||'?'}${genderLabel[v.gender]||'—'}`; gBadge.className = 'gender-badge ' + genderClass[v.gender]; await saveMeta(v.id, { gender: v.gender }); }); // Rename const nameText = wrap.querySelector('.vr-name-text'); const renameConf = wrap.querySelector('.rename-confirm'); const nameInput = wrap.querySelector('.vr-name-input'); const renameOk = wrap.querySelector('.rename-ok'); const renameCancel= wrap.querySelector('.rename-cancel'); const startRename = () => { nameText.style.display='none'; renameConf.classList.add('show'); nameInput.focus(); nameInput.select(); }; nameText.addEventListener('dblclick', startRename); const cancelRename = () => { nameText.style.display=''; renameConf.classList.remove('show'); nameInput.value = v.id; }; renameCancel.addEventListener('click', cancelRename); const doRename = async () => { const newId = nameInput.value.trim(); if (!newId || newId===v.id) { cancelRename(); return; } if (!/^[A-Za-z0-9_\-\.]+$/.test(newId)) { toast('Invalid characters in name','error'); return; } try { const r = await fetch('/api/voice/rename', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({old_id:v.id, new_id:newId})}); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); v.id = newId; if (d.path) v.path = d.path; if (d.file_type) v.file_type = d.file_type; nameText.textContent = newId; nameText.title = newId; nameText.style.display=''; renameConf.classList.remove('show'); wrap.dataset.id = newId; nameInput.value = newId; toast('Renamed to '+newId,'success'); } catch(e) { toast('Rename failed: '+e.message,'error'); } }; renameOk.addEventListener('click', doRename); nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); }); // Select row → open inspector (click on compact area, but not on play/buttons) wrap.querySelector('.vl-compact').addEventListener('click', function (e) { if (e.target.closest('button')) return; selectVoice(wrap); }); // Inline optimizer in Library const editAudioBtn = wrap.querySelector('.edit-audio-btn'); const optPanel = wrap.querySelector('.vr-optimizer'); const vrTypeEl = wrap.querySelector('.vr-type'); const optCanvas = wrap.querySelector('.opt-wave'); const optStart = wrap.querySelector('.opt-start'); const optEnd = wrap.querySelector('.opt-end'); const optTranscript = wrap.querySelector('.opt-transcript'); const optTargetDb = wrap.querySelector('.opt-target-db'); const optStyleInstruct = wrap.querySelector('.opt-style-instruct'); const optStyleBackend = wrap.querySelector('.opt-style-backend'); const optStyleVoiceId = wrap.querySelector('.opt-style-voice-id'); const optCompareBackend = wrap.querySelector('.opt-compare-backend'); const optPlayReferenceBtn = wrap.querySelector('.opt-play-reference'); const optSynthReferenceBtn = wrap.querySelector('.opt-synth-reference'); const optCompareRefAudio = wrap.querySelector('.opt-compare-ref-audio'); const optCompareSynthAudio = wrap.querySelector('.opt-compare-synth-audio'); const optPreviewStyleBtn = wrap.querySelector('.opt-preview-style'); const optSaveStyleBtn = wrap.querySelector('.opt-save-style'); const optStyleAudio = wrap.querySelector('.opt-style-audio'); const optStatus = wrap.querySelector('.opt-status'); const optSaveTextBtn = wrap.querySelector('.opt-save-text'); const optRestartTtsBtn = wrap.querySelector('.opt-restart-tts'); const optRebenchmarkBtn = wrap.querySelector('.opt-rebenchmark'); const optRestartNote = wrap.querySelector('.opt-restart-note'); let optState = { loaded:false, id:null, duration:0, buffer:null, audio:null, compareSynthUrl:null }; const setOptStatus = msg => { optStatus.textContent = msg; status(msg); }; const setVoiceRestartState = (required, msg = '') => { v.needs_tts_restart = required; optPanel.classList.toggle('opt-restart-needed', required); optRestartNote.hidden = !required; optRestartNote.textContent = required ? 'Restart TTS before benchmarking; the backend may still have the old voice cached.' : ''; optRebenchmarkBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice'; benchmarkOneBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice'; if (msg) setOptStatus(msg); }; const markTtsRestartRequired = msg => setVoiceRestartState(true, msg); const refreshOptimizerFromVoice = async () => { markVoiceAudioChanged(v); optState.loaded = false; optState.buffer = null; optState.id = null; await loadOptimizer(); if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration); if (vrLengthEl) vrLengthEl.title = String(v.duration ?? ''); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; }; const saveOptimizerText = async () => { const transcript = optTranscript.value.trim(); setOptStatus('Saving reference text...'); v.transcript = transcript; refInput.value = transcript; refInput.title = transcript; refTranscribeBtn.style.display = transcript ? 'none' : ''; await saveMeta(v.id, { transcript }); markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.'); toast('Reference text saved: ' + v.id, 'success'); }; const redrawOpt = () => { if (!optState.buffer || optCanvas.clientWidth < 4) return; drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration); }; // Redraw whenever the canvas is resized (handles display:none → visible transition) const _waveRO = new ResizeObserver(() => redrawOpt()); _waveRO.observe(optCanvas); const syncCompareReferenceAudio = () => { if (!optState.id || !optCompareRefAudio) return; const src = '/api/audio/' + optState.id; if (!optCompareRefAudio.src.endsWith(src)) { optCompareRefAudio.src = src; optCompareRefAudio.load(); } const stopAtEnd = () => { const end = parseFloat(optEnd.value) || optState.duration; if (optCompareRefAudio.currentTime >= end) optCompareRefAudio.pause(); }; optCompareRefAudio.ontimeupdate = stopAtEnd; }; const optWaveTimeFromEvent = e => { const rect = optCanvas.getBoundingClientRect(); const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); return optState.duration ? x / Math.max(1, rect.width) * optState.duration : 0; }; const setOptCropRange = (start, end) => { start = Math.max(0, Math.min(optState.duration || 0, Number(start) || 0)); end = Math.max(0, Math.min(optState.duration || 0, Number(end) || 0)); if (end < start) [start, end] = [end, start]; optStart.value = start.toFixed(2); optEnd.value = end.toFixed(2); redrawOpt(); }; const optWaveSelectionPixels = e => { const rect = optCanvas.getBoundingClientRect(); const duration = Math.max(0.01, optState.duration || 0.01); const sx = (parseFloat(optStart.value) || 0) / duration * rect.width; const ex = (parseFloat(optEnd.value) || optState.duration || 0) / duration * rect.width; const x = e.clientX - rect.left; return {x, sx, ex}; }; const optWaveDragMode = e => { const {x, sx, ex} = optWaveSelectionPixels(e); const hit = 18; const nearStart = Math.abs(x - sx) <= hit; const nearEnd = Math.abs(x - ex) <= hit; if (nearStart && nearEnd) return Math.abs(x - sx) <= Math.abs(x - ex) ? 'start' : 'end'; if (nearStart) return 'start'; if (nearEnd) return 'end'; if (x > sx && x < ex) return 'move'; return 'new'; }; const attachOptWaveSelection = () => { let drag = null; optCanvas.addEventListener('pointerdown', e => { if (!optState.buffer || !optState.duration) return; e.preventDefault(); optCanvas.setPointerCapture?.(e.pointerId); const mode = optWaveDragMode(e); const currentStart = parseFloat(optStart.value) || 0; const currentEnd = parseFloat(optEnd.value) || optState.duration; drag = {mode, anchor: optWaveTimeFromEvent(e), start: currentStart, end: currentEnd, length: Math.max(0.05, currentEnd - currentStart)}; optCanvas.style.cursor = mode === 'move' ? 'grabbing' : 'ew-resize'; if (mode === 'new') setOptCropRange(drag.anchor, drag.anchor); }); optCanvas.addEventListener('pointermove', e => { if (!optState.buffer || !optState.duration) return; if (!drag) { const mode = optWaveDragMode(e); optCanvas.style.cursor = mode === 'move' ? 'grab' : (mode === 'start' || mode === 'end') ? 'ew-resize' : 'crosshair'; return; } e.preventDefault(); const t = optWaveTimeFromEvent(e); if (drag.mode === 'start') setOptCropRange(Math.min(t, drag.end - 0.05), drag.end); else if (drag.mode === 'end') setOptCropRange(drag.start, Math.max(t, drag.start + 0.05)); else if (drag.mode === 'move') { let start = t - (drag.anchor - drag.start); start = Math.max(0, Math.min((optState.duration || 0) - drag.length, start)); setOptCropRange(start, start + drag.length); } else setOptCropRange(drag.anchor, t); }); const finish = e => { if (!drag) return; optCanvas.releasePointerCapture?.(e.pointerId); drag = null; optCanvas.style.cursor = 'crosshair'; }; optCanvas.addEventListener('pointerup', finish); optCanvas.addEventListener('pointercancel', finish); optCanvas.addEventListener('pointerleave', () => { if (!drag) optCanvas.style.cursor = 'crosshair'; }); }; attachOptWaveSelection(); const loadOptimizer = async () => { if (optState.loaded) return; setOptStatus('Loading voice optimizer…'); const d = await loadLibraryVoiceAudio(v); optState.id = d.id; optState.duration = d.duration; v.duration = d.duration; optState.buffer = await decodeVoiceAudio(v); optState.loaded = true; optStart.value = '0.00'; optEnd.value = d.duration.toFixed(2); optEnd.max = d.duration.toFixed(2); optTranscript.value = d.transcript || v.transcript || ''; redrawOpt(); syncCompareReferenceAudio(); setVoiceRestartState(Boolean(v.needs_tts_restart)); setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready'); }; wrap._loadOptimizer = loadOptimizer; wrap._redrawOpt = redrawOpt; editAudioBtn.addEventListener('click', async () => { editAudioBtn.disabled = true; try { const opening = !wrap.classList.contains('edit-open'); document.querySelectorAll('.vl-row.edit-open').forEach(r => { if (r !== wrap) r.classList.remove('edit-open'); }); wrap.classList.toggle('edit-open', opening); if (opening) { await loadOptimizer(); wrap.scrollIntoView({behavior:'smooth', block:'nearest'}); } } catch(e) { toast('Edit load failed: '+e.message,'error'); status('Edit load failed'); } finally { editAudioBtn.disabled = false; } }); [optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); })); optStyleInstruct?.addEventListener('input', () => { if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value); }); optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap)); optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`)); if (optCompareBackend.value === '') { optCompareBackend.innerHTML = styleBackendOptions('voice_clone'); optCompareBackend.disabled = !availableTtsBackends().length; } if (optStyleBackend) updateStyleBackendHelp(wrap); wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); }); wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); }); wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; }); wrap.querySelector('.opt-play').addEventListener('click', async () => { try { await loadOptimizer(); if (optState.audio) optState.audio.pause(); optState.audio = new Audio('/api/audio/' + optState.id); optState.audio.currentTime = parseFloat(optStart.value) || 0; const end = parseFloat(optEnd.value) || optState.duration; optState.audio.ontimeupdate = () => { if (optState.audio.currentTime >= end) optState.audio.pause(); }; optState.audio.play(); } catch(e) { toast('Preview failed: ' + e.message, 'error'); } }); optPlayReferenceBtn.addEventListener('click', async () => { try { await loadOptimizer(); syncCompareReferenceAudio(); optCompareRefAudio.currentTime = parseFloat(optStart.value) || 0; await optCompareRefAudio.play().catch(()=>{}); setOptStatus('Playing reference WAV selection for comparison.'); } catch(e) { toast('Reference playback failed: ' + e.message, 'error'); } }); optSynthReferenceBtn.addEventListener('click', async () => { const text = optTranscript.value.trim(); if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return; } if (v.needs_tts_restart) { const ok = confirm('This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?'); if (!ok) { setOptStatus('Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.'); return; } try { const d = await clearTtsRestartFlags(); setVoiceRestartState(false, `Restart flags cleared (${d.cleared_restart_flags || 0}). Synthesizing comparison...`); toast('Restart flags cleared', 'success'); } catch(e) { toast('Could not clear restart flags: ' + e.message, 'error'); setOptStatus('Could not clear restart flags'); return; } } optSynthReferenceBtn.disabled = true; try { await loadOptimizer(); setOptStatus('Synthesizing reference text for comparison...'); const source = await createTtsAudioSource(v.id, text, optCompareBackend.value, 'settings', ''); if (optState.compareSynthUrl) URL.revokeObjectURL(optState.compareSynthUrl); optCompareSynthAudio.src = source.url; optState.compareSynthUrl = source.streaming ? null : source.url; await optCompareSynthAudio.play().catch(()=>{}); setOptStatus(source.streaming ? 'Streaming synthesized comparison.' : 'Synthesized comparison ready.'); } catch(e) { toast('Synthesis comparison failed: ' + e.message, 'error'); setOptStatus('Synthesis comparison failed'); } finally { optSynthReferenceBtn.disabled = false; } }); wrap.querySelector('.opt-auto-trim').addEventListener('click', async () => { try { await loadOptimizer(); const d = await clientAutoTrimBounds(optState.id); optStart.value = Number(d.start).toFixed(2); optEnd.value = Number(d.end).toFixed(2); redrawOpt(); setOptStatus(d.reason || 'Auto trim ready'); } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); } }); wrap.querySelector('.opt-recognize').addEventListener('click', async () => { try { await loadOptimizer(); const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:optState.id})}); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } const d = await r.json(); optTranscript.value = d.text || ''; setOptStatus('Reference text recognised. Review it, then Save text.'); } catch(e) { toast('Recognition failed: ' + e.message, 'error'); } }); optSaveTextBtn.addEventListener('click', async () => { optSaveTextBtn.disabled = true; try { await saveOptimizerText(); } catch(e) { toast('Save text failed: ' + e.message, 'error'); setOptStatus('Save text failed'); } finally { optSaveTextBtn.disabled = false; } }); wrap.querySelector('.opt-save-crop').addEventListener('click', async () => { try { await loadOptimizer(); const cropStart = Math.max(0, parseFloat(optStart.value) || 0); const cropEnd = Math.min(optState.duration, parseFloat(optEnd.value) || optState.duration); if (cropStart <= 0.01 && cropEnd >= optState.duration - 0.05) { setOptStatus('No crop range selected. Adjust Start or End first, then Save crop.'); toast('No crop range selected', 'error'); return; } if (cropEnd <= cropStart + 0.1) { setOptStatus('Crop range is too short.'); toast('Crop range is too short', 'error'); return; } setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`); const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:optState.id, start:cropStart, end:cropEnd})}); if (!pr.ok) { const e = await pr.json(); throw new Error(e.detail || pr.statusText); } const p = await pr.json(); const rr = await fetch('/api/voice-replace', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:p.id, voice_id:v.id, path:v.path, transcript:optTranscript.value})}); if (!rr.ok) { const e = await rr.json().catch(()=>({})); throw new Error(e.detail || rr.statusText); } const saved = await rr.json(); v.transcript = optTranscript.value; v.duration = saved.duration ?? p.duration; if (saved.loudness) v.loudness = saved.loudness; if (saved.path) v.path = saved.path; if (saved.file_type) v.file_type = saved.file_type; markVoiceAudioChanged(v); refInput.value = v.transcript; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); } await refreshOptimizerFromVoice(); toast('Voice crop saved: ' + v.id, 'success'); markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.'); } catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); } }); if (optStyleInstruct) { const styleVariationInput = () => { const style = optStyleInstruct.value.trim(); const text = optTranscript.value.trim() || benchmarkSampleText(); const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style); if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; } if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; } if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; } return {style, text, newId, backend: optStyleBackend.value}; }; optPreviewStyleBtn.addEventListener('click', async () => { const input = styleVariationInput(); if (!input) return; optPreviewStyleBtn.disabled = true; try { setOptStatus('Synthesizing style preview...'); const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend); if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src); optStyleAudio.src = URL.createObjectURL(blob); optStyleAudio.style.display = ''; await optStyleAudio.play().catch(()=>{}); setOptStatus('Style preview ready. If it sounds right, save it as a new voice.'); } catch(e) { toast('Style preview failed: ' + e.message, 'error'); setOptStatus('Style preview failed'); } finally { optPreviewStyleBtn.disabled = false; } }); optSaveStyleBtn.addEventListener('click', async () => { const input = styleVariationInput(); if (!input) return; optSaveStyleBtn.disabled = true; try { setOptStatus(`Synthesizing style variation ${input.newId}...`); const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); toast('Style variation saved: ' + d.voice_id, 'success'); setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.'); await loadVoiceLibrary(); renderIntegrationSnippets(); } catch(e) { toast('Style variation failed: ' + e.message, 'error'); setOptStatus('Style variation failed'); } finally { optSaveStyleBtn.disabled = false; } }); } wrap.querySelector('.opt-undo').addEventListener('click', async () => { if (!confirm(`Restore the original backup for "${v.id}"?`)) return; try { setOptStatus('Restoring original…'); const r = await fetch('/api/voice/undo', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.duration = d.duration ?? v.duration; v.loudness = d.loudness || v.loudness; v.path = d.path || v.path; v.file_type = d.file_type || v.file_type; markVoiceAudioChanged(v); if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); } await refreshOptimizerFromVoice(); toast('Original restored: ' + v.id, 'success'); markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.'); } catch(e) { toast('Undo failed: ' + e.message, 'error'); setOptStatus('Undo failed'); } }); wrap.querySelector('.opt-save-volume').addEventListener('click', async () => { try { setOptStatus('Saving volume…'); const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:Number(optTargetDb.value || -20)})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.loudness = d.loudness || v.loudness; v.duration = d.duration ?? v.duration; if (d.path) v.path = d.path; if (d.file_type) v.file_type = d.file_type; markVoiceAudioChanged(v); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; await refreshOptimizerFromVoice(); toast('Volume saved: ' + v.id, 'success'); markTtsRestartRequired('Volume saved. Restart TTS before rebenchmarking this voice.'); } catch(e) { toast('Volume save failed: ' + e.message, 'error'); setOptStatus('Volume save failed'); } }); optRestartTtsBtn.addEventListener('click', async () => { optRestartTtsBtn.disabled = true; try { setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…'); const r = await fetch('/api/tts/restart', { method:'POST' }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _voices.forEach(voice => { voice.needs_tts_restart = false; }); updateLibraryInsights(); const names = (d.restarted || []).join(', ') || 'containers'; const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : ''; setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`); toast(`TTS restarted: ${names}`, 'success'); } catch(e) { toast('Restart TTS failed: ' + e.message, 'error'); setOptStatus('Restart TTS failed: ' + e.message); } finally { optRestartTtsBtn.disabled = false; } }); // Reference text input and recognition const refInput = wrap.querySelector('.vr-ref input'); const refTranscribeBtn = wrap.querySelector('.ref-transcribe-btn'); refInput.addEventListener('input', debounce(async () => { v.transcript = refInput.value; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; await saveMeta(v.id, { transcript: v.transcript }); if (wrap.classList.contains('edit-open')) markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.'); else v.needs_tts_restart = true; }, 800)); refTranscribeBtn.addEventListener('click', async () => { refTranscribeBtn.disabled = true; try { const d = await loadLibraryVoiceAudio(v); const tr = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})}); if (!tr.ok) { const e=await tr.json(); throw new Error(e.detail); } const text = await tr.json(); v.transcript = text.text || ''; refInput.value = v.transcript; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; await saveMeta(v.id, { transcript: v.transcript }); v.needs_tts_restart = true; toast('Reference text recognised; restart TTS before benchmarking','success'); } catch(e) { toast('Recognition failed: '+e.message,'error'); } finally { refTranscribeBtn.disabled = false; } }); // Note (debounced save) const noteInput = wrap.querySelector('.vr-note input'); noteInput.addEventListener('input', debounce(async () => { v.note = noteInput.value; await saveMeta(v.id, { note: v.note }); }, 800)); // Stars const starSpans = wrap.querySelectorAll('.star'); starSpans.forEach(s => { s.addEventListener('click', async () => { const val = parseInt(s.dataset.val); const newRating = val===v.rating ? 0 : val; v.rating = newRating; starSpans.forEach((ss,i) => ss.classList.toggle('on', i { const val = parseInt(s.dataset.val); starSpans.forEach((ss,i) => ss.classList.toggle('on', i { starSpans.forEach((ss,i) => ss.classList.toggle('on', i<(v.rating||0))); }); }); // Per-voice benchmark const benchmarkOneBtn = wrap.querySelector('.benchmark-one-btn'); const benchmarkThisVoice = async triggerBtn => { if (v.needs_tts_restart) { const ok = confirm('This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?'); if (!ok) { setOptStatus('Restart TTS first, then rebenchmark this voice.'); return; } } triggerBtn.disabled = true; status('Benchmarking ' + v.id + '...'); try { setBenchmarkProgress(0, 1, `Benchmarking ${v.id}`); const d = await runVoiceBenchmark(v.id); mergeBenchmarkResults(d); const hit = (d.voices || []).find(x => x.voice_id === v.id); if (hit && hit.benchmark) v.benchmark = hit.benchmark; const benchCell = wrap.querySelector('.vr-bench'); benchCell.className = 'vr-bench ' + benchmarkClass(v); benchCell.title = benchmarkTitle(v); benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v); setBenchmarkProgress(1, 1, `Finished ${v.id}`); toast('Benchmarked ' + v.id, 'success'); setVoiceRestartState(false, 'Benchmark saved for ' + v.id); } catch(e) { toast('Benchmark failed: ' + e.message, 'error'); setOptStatus('Benchmark failed'); } finally { triggerBtn.disabled = false; } }; benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn)); optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn)); // Play original recording or synthesized sample const originalPlayBtn = wrap.querySelector('.vr-play-original button'); const synthPlayBtn = wrap.querySelector('.vr-play-synth button'); const playIcon = '', pauseIcon = '', generatingIcon = ''; function setLibraryPlayButtonState(btn, state) { btn.classList.toggle('is-generating', state === 'generating'); btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon); btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording')); } async function playLibraryVoice(kind, playBtn) { const bar = $('lib-audio-bar'), audio = $('lib-audio'); const playKey = v.id + ':' + kind; playBtn.dataset.playKind = kind; if (_activePlayVoiceId === playKey && !audio.paused) { audio.pause(); setLibraryPlayButtonState(playBtn, 'idle'); return; } if (_activePlayVoiceId === playKey && audio.paused && audio.src) { _activePlayButton = playBtn; try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); } return; } if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle'); _activePlayButton = playBtn; _activePlayVoiceId = playKey; if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; } if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating'); playBtn.disabled = true; try { if (kind === 'synth') { if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error'); const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview'; const text = synthMode === 'transcript' ? (v.transcript?.trim() || benchmarkSampleText()) : benchmarkSampleText(); const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text'; const backend = libraryTtsBackend(); const source = await createTtsAudioSource(v.id, text, backend, 'settings', ''); audio.src = source.url; if (!source.streaming) _activePlayUrl = source.url; $('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend); } else { audio.src = voiceFileUrl(v); $('lib-audio-label').textContent = v.id + ' · original recording'; } bar.style.display = ''; audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; }; audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); }; audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); }; await audio.play(); } catch(e) { setLibraryPlayButtonState(playBtn, 'idle'); toast('Play failed: '+e.message,'error'); } finally { playBtn.disabled = false; } } originalPlayBtn.dataset.playKind = 'original'; synthPlayBtn.dataset.playKind = 'synth'; setLibraryPlayButtonState(originalPlayBtn, 'idle'); setLibraryPlayButtonState(synthPlayBtn, 'idle'); originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn)); synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn)); // Enable toggle const toggleCb = wrap.querySelector('.toggle input'); toggleCb.addEventListener('change', async () => { const nextEnabled = toggleCb.checked; const previousEnabled = v.enabled !== false; toggleCb.disabled = true; try { const saved = await saveMeta(v.id, { enabled: nextEnabled }); v.enabled = nextEnabled; if (saved && saved.path) v.path = saved.path; wrap.classList.toggle('vr-disabled', !v.enabled); toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success'); if (!v.enabled && !$('show-disabled-cb').checked) { wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0'; setTimeout(() => wrap.remove(), 400); } } catch(e) { toggleCb.checked = previousEnabled; v.enabled = previousEnabled; wrap.classList.toggle('vr-disabled', !v.enabled); toast('Move failed: ' + e.message, 'error'); } finally { toggleCb.disabled = false; } }); // Delete voice const deleteBtn = wrap.querySelector('.delete-btn'); const deleteConfirm = wrap.querySelector('.delete-confirm'); const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel'); const deleteGoBtn = wrap.querySelector('.delete-confirm-go'); const closeDeleteConfirm = () => wrap.classList.remove('delete-pending'); deleteBtn.addEventListener('click', e => { e.stopPropagation(); document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); }); wrap.classList.add('delete-pending'); deleteGoBtn.focus(); }); deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); }); deleteConfirm.addEventListener('click', e => e.stopPropagation()); deleteGoBtn.addEventListener('click', async e => { e.stopPropagation(); deleteGoBtn.disabled = true; deleteCancelBtn.disabled = true; try { const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail); } _voices = _voices.filter(x => x.id !== v.id); wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0'; setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300); toast(`Deleted: ${v.id}`, 'success'); } catch(e) { toast('Delete failed: ' + e.message, 'error'); deleteGoBtn.disabled = false; deleteCancelBtn.disabled = false; closeDeleteConfirm(); } }); return wrap; } async function saveMeta(voiceId, patch) { const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ voice_id:voiceId, ...patch }) }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } return r.json(); } // Close language pickers when clicking elsewhere document.addEventListener('click', () => { document.querySelectorAll('.flag-picker.open').forEach(fp => fp.classList.remove('open')); document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending')); }, { passive:true }); // ── TTS preview ─────────────────────────────────────────────────────────── function backendVoiceId(value) { return typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || JSON.stringify(value)); } function shouldFilterBackendVoices(backend) { return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || ''); } async function activeLibraryVoiceIds() { if (!_voices.length) await loadVoiceLibrary(); return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id)); } function cleanReferenceText(text) { return String(text || '').trim(); } function selectedPreviewLibraryVoice() { const id = $('tts-voice-select')?.value || ''; return id ? (_voices || []).find(v => v.id === id) : null; } function previewVoiceWarnings(v) { const warnings = []; const backend = backendById($('tts-backend-select')?.value || ''); if (backend && backend.id && !['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend.id)) { warnings.push(backend.id === 'nvidia_magpie' ? 'NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.' : 'This backend may follow style/model voice more than the saved WAV identity.'); } if (backend && backend.id === 'nvidia_zeroshot' && v.duration && (Number(v.duration) < 3 || Number(v.duration) > 10)) { warnings.push('NVIDIA Zeroshot works best with a clear 3-10 second prompt.'); } if (backend && backend.id === 'nvidia_flow' && !v.transcript) { warnings.push('NVIDIA Flow requires the exact saved reference transcript for this voice.'); } if (!v.transcript) warnings.push('No reference transcript is saved; cloned identity is harder to judge.'); if (v.duration && (Number(v.duration) < 3 || Number(v.duration) > 20)) warnings.push('Reference clip length is outside the 3-20 second sweet spot.'); if (v.needs_tts_restart) warnings.push('This voice changed since the last backend refresh; restart or clear restart flags before judging it.'); const healthWarnings = v.health && Array.isArray(v.health.warnings) ? v.health.warnings : []; warnings.push(...healthWarnings.slice(0, 3)); return warnings; } function updatePreviewVoiceMatchPanel() { const panel = $('preview-match-panel'); if (!panel) return; const v = selectedPreviewLibraryVoice(); if (!v) { panel.hidden = true; return; } panel.hidden = false; const lang = v.language || v.lang || (v.id || '').split('_')[0] || '-'; const gender = v.gender || (v.id || '').split('_')[1] || '-'; const db = fmtDbfs(v); const dur = v.duration ? fmtDuration(v.duration) : '-'; $('preview-match-title').textContent = v.id; $('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`; const warnings = previewVoiceWarnings(v); $('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.'; const transcript = cleanReferenceText(v.transcript || ''); $('preview-match-transcript').textContent = transcript || 'No reference text saved for this voice.'; $('preview-ref-use-text').disabled = !transcript; $('preview-ref-synth').disabled = !transcript; const audio = $('preview-ref-audio'); const expected = voiceFileUrl(v); if (audio.dataset.src !== expected) { audio.pause(); audio.src = expected; audio.dataset.src = expected; } // Persona rewrite button — shown only when voice has a persona const actionsEl = panel.querySelector('.preview-match-actions'); let personaBtn = panel.querySelector('.preview-persona-btn'); if (v.persona) { if (!personaBtn) { personaBtn = document.createElement('button'); personaBtn.className = 'btn-secondary preview-persona-btn'; personaBtn.type = 'button'; personaBtn.textContent = 'Rewrite with persona'; actionsEl?.appendChild(personaBtn); personaBtn.addEventListener('click', async () => { const text = $('preview-text-area').value.trim(); if (!text) { toast('Enter text to rewrite', 'error'); return; } const lv = selectedPreviewLibraryVoice(); if (!lv?.persona) { toast('This voice has no persona', 'error'); return; } personaBtn.disabled = true; personaBtn.textContent = 'Rewriting…'; try { const llmUrl = localStorage.getItem('refine-llm-url') || 'http://localhost:11434/v1'; const r = await fetch('/api/rewrite-with-persona', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text, persona: lv.persona, llm_url: llmUrl, mode:'rewrite' }) }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); $('preview-text-area').value = d.text; toast('Text rewritten in persona style', 'success'); } catch(e) { toast('Persona rewrite failed: ' + e.message, 'error'); } finally { personaBtn.disabled = false; personaBtn.textContent = 'Rewrite with persona'; } }); } } else { personaBtn?.remove(); } } async function synthesizeSelectedReferenceText() { const v = selectedPreviewLibraryVoice(); if (!v) { toast('Select a library voice first', 'error'); return; } let text = cleanReferenceText(v.transcript || ''); if (!text) { toast('This voice has no reference text', 'error'); return; } if (v.needs_tts_restart) { const ok = confirm('This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?'); if (!ok) return; await clearTtsRestartFlags(); v.needs_tts_restart = false; updatePreviewVoiceMatchPanel(); } const backend = $('tts-backend-select').value; if (!backend) { toast('No available TTS backend', 'error'); return; } const btn = $('preview-ref-synth'); btn.disabled = true; try { $('preview-text-area').value = text; const source = await createTtsAudioSource(v.id, text, backend, $('preview-playback-mode').value, $('preview-style-instruction').value.trim()); previewBlob = source.blob; const audio = $('preview-audio'); audio.src = source.url; audio.style.display = ''; await audio.play(); $('save-preview-mp3-btn').disabled = false; $('save-preview-btn').disabled = source.streaming; toast(source.streaming ? 'Reference text streaming' : 'Reference text synthesized', 'success'); } catch(e) { toast('Reference synthesis failed: ' + e.message, 'error'); } finally { btn.disabled = false; } } $('fetch-tts-voices-btn').addEventListener('click', async () => { $('fetch-tts-voices-btn').disabled = true; try { const backend = $('tts-backend-select')?.value; if (!backend) throw new Error('No available TTS backend'); const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); let voices = Array.isArray(rawVoices) ? rawVoices : []; if (shouldFilterBackendVoices(backend)) { const activeIds = await activeLibraryVoiceIds(); voices = voices.filter(v => activeIds.has(backendVoiceId(v))); } const sel = $('tts-voice-select'), prev = sel.value; sel.innerHTML = ''; voices.forEach(v => { const id = backendVoiceId(v); const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt); }); if(prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev; updatePreviewVoiceMatchPanel(); const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices'; toast('Fetched '+voices.length+suffix,'success'); } catch(e) { toast('Fetch failed: '+e.message,'error'); } finally { $('fetch-tts-voices-btn').disabled = false; } }); $('tts-backend-select').addEventListener('change', () => { const sel = $('tts-voice-select'); sel.innerHTML = ''; updateBackendHelp(); updatePreviewVoiceMatchPanel(); previewBlob = null; $('save-preview-mp3-btn').disabled = true; $('save-preview-btn').disabled = true; }); $('tts-voice-select').addEventListener('change', updatePreviewVoiceMatchPanel); $('preview-ref-play').addEventListener('click', async () => { updatePreviewVoiceMatchPanel(); const audio = $('preview-ref-audio'); try { await audio.play(); } catch(e) { toast('Reference playback failed: ' + e.message, 'error'); } }); $('preview-ref-use-text').addEventListener('click', () => { const v = selectedPreviewLibraryVoice(); const text = cleanReferenceText(v?.transcript || ''); if (!text) { toast('This voice has no reference text', 'error'); return; } $('preview-text-area').value = text; toast('Reference text copied to target text', 'success'); }); $('preview-ref-synth').addEventListener('click', synthesizeSelectedReferenceText); let _ttsStreamHealth = null; function effectiveTtsPlaybackMode(override = 'settings') { if (override && override !== 'settings') return override; return _appSettings.tts_stream_mode || 'auto'; } async function isTtsStreamAvailable(force = false) { if (_ttsStreamHealth && !force) return _ttsStreamHealth.ok; try { _ttsStreamHealth = await fetch('/api/tts-stream-health').then(r => r.json()); return !!_ttsStreamHealth.ok; } catch (_) { _ttsStreamHealth = {ok:false}; return false; } } async function createTtsStreamUrl(voice, text, instruct = '') { if (!await isTtsStreamAvailable()) throw new Error('streaming backend unavailable'); const r = await fetch('/api/tts-stream-session', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({text,voice,instruct})}); if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const data = await r.json(); return data.url; } async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false) { const body = {text, voice, response_format: responseFormat, instruct, backend}; if (applyPersona) body.apply_persona = true; const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } return await r.blob(); } async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false) { const mode = effectiveTtsPlaybackMode(modeOverride); if (backend !== 'streaming' || mode === 'buffered') { const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona); return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'}; } try { return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'}; } catch (e) { if (mode === 'streaming') throw e; const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona); return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'}; } } let previewBlob = null; const PREVIEW_SAMPLE_TEXT = 'Hello! This is a voice preview from TTS Voice Creator - Clone and Design.'; $('preview-text-area').addEventListener('focus', () => { if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = ''; }, { once:true }); function _onPreviewGenerated(source, voice, text, backend) { if (typeof effectsSourceBlob !== 'undefined') window._effectsSourceBlob = null; const ea = $('effects-apply-btn'); if (ea && source.blob) ea.disabled = false; const ap = $('add-to-playlist-btn'); if (ap && source.blob) ap.disabled = false; if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url); } $('preview-btn').addEventListener('click', async () => { const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim(); const applyPersona = $('preview-persona-toggle')?.checked || false; if(!backend) { toast('No available TTS backend','error'); return; } if(!voice) { toast('Select a TTS voice','error'); return; } if(!text) { toast('Enter preview text','error'); return; } $('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true; if($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true; if($('effects-apply-btn')) $('effects-apply-btn').disabled=true; try { const audio = $('preview-audio'); const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function'; const source = useChunked ? await generateChunkedTts(voice, text, backend, instruct) : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona); previewBlob = source.blob; window._previewVoice = voice; window._previewBackend = backend; window._previewText = text; audio.src = source.url; audio.style.display=''; await audio.play(); $('save-preview-mp3-btn').disabled = false; $('save-preview-btn').disabled = source.streaming; _onPreviewGenerated(source, voice, text, backend); toast(source.streaming ? 'Streaming preview playing' : source.label === 'chunked' ? `Chunked (${text.length} chars) playing` : 'Preview playing', 'success'); } catch(e) { toast('TTS failed: '+e.message,'error'); } finally { $('preview-btn').disabled=false; } }); $('save-preview-mp3-btn').addEventListener('click', async () => { const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim(); if(!backend) { toast('No available TTS backend','error'); return; } if(!voice || !text) return; const btn = $('save-preview-mp3-btn'); btn.disabled = true; try { const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (voice||'preview')+'_preview.mp3'; a.click(); toast('MP3 saved', 'success'); } catch(e) { toast('MP3 save failed: '+e.message,'error'); } finally { btn.disabled = false; } }); $('save-preview-btn').addEventListener('click', () => { if(!previewBlob) return; const a = document.createElement('a'); a.href = URL.createObjectURL(previewBlob); a.download = ($('tts-voice-select').value||'preview')+'_preview.wav'; a.click(); }); // ── Performance benchmark ───────────────────────────────────────────────── const PERF_HISTORY_KEY = 'vcf-perf-history'; const PERF_HISTORY_MAX = 50; function perfHistoryLoad() { try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; } } function perfHistorySave(entries) { try { localStorage.setItem(PERF_HISTORY_KEY, JSON.stringify(entries.slice(-PERF_HISTORY_MAX))); } catch(_) {} } function perfHistoryAdd(entry) { const h = perfHistoryLoad(); h.push(entry); perfHistorySave(h); } function perfSparklineSvg(rtfValues) { if (!rtfValues.length) return ''; const W = 120, H = 32, PAD = 2, barW = Math.max(4, Math.floor((W - PAD * 2) / rtfValues.length) - 1); const maxV = Math.max(...rtfValues, 1); const bars = rtfValues.map((v, i) => { const bh = Math.max(3, Math.round((v / maxV) * (H - PAD * 2))); const x = PAD + i * (barW + 1); const y = H - PAD - bh; const col = v < 1 ? 'var(--green)' : 'var(--yellow)'; return ``; }).join(''); return ``; } function renderPerfHistory() { const histList = $('perf-history-list'); if (!histList) return; const filterEl = $('perf-history-filter-current'); const filterOn = filterEl?.checked; const curBack = $('perf-backend-select')?.value; const curVoice = $('perf-voice-select')?.value; let entries = perfHistoryLoad().slice().reverse(); if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice); if (!entries.length) { histList.innerHTML = '
' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '
'; return; } const head = `
Date / TimeBackendVoice Avg latencyMinAvg RTF
`; const rows = entries.map(e => { const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow'; return `
${escHtml(dt)} ${escHtml(e.backend)} ${escHtml(e.voice)} ${Math.round(e.avgLatencyMs)} ms ${Math.round(e.minLatencyMs)} ms ${e.avgRtf.toFixed(2)}
`; }).join(''); histList.innerHTML = head + rows; histList.querySelectorAll('.perf-history-del').forEach(btn => { btn.addEventListener('click', () => { const ts = Number(btn.dataset.ts); perfHistorySave(perfHistoryLoad().filter(e => e.ts !== ts)); renderPerfHistory(); }); }); } (function initPerfBenchmark() { const perfBackendSel = $('perf-backend-select'); const perfVoiceSel = $('perf-voice-select'); const perfFetchBtn = $('perf-fetch-voices-btn'); const perfRunBtn = $('perf-run-btn'); const perfClearBtn = $('perf-clear-btn'); const perfProgress = $('perf-progress'); const perfResultsCard = $('perf-results-card'); const perfSummary = $('perf-summary'); const perfTbody = $('perf-tbody'); const perfText = $('perf-text'); const perfRunsSel = $('perf-runs'); if (!perfRunBtn) return; let perfRows = []; function populatePerfBackends() { if (!perfBackendSel) return; const cur = perfBackendSel.value; perfBackendSel.innerHTML = availableTtsBackends().map(b => `` ).join('') || ''; } populatePerfBackends(); perfFetchBtn.addEventListener('click', async () => { const backend = perfBackendSel.value; if (!backend) { toast('Select a backend first', 'error'); return; } perfFetchBtn.disabled = true; try { const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); const cur = perfVoiceSel.value; perfVoiceSel.innerHTML = rawVoices.map(v => { const id = backendVoiceId(v); return ``; }).join('') || ''; } catch(e) { toast('Fetch voices failed: '+e.message, 'error'); } finally { perfFetchBtn.disabled = false; } }); function updateTrendDisplay(backend, voice, currentAvgRtf) { const trendRow = $('perf-trend-row'); const trendBadge = $('perf-trend-badge'); const sparkWrap = $('perf-sparkline-wrap') || trendRow?.querySelector('.perf-sparkline-wrap'); if (!trendRow) return; const history = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number'); if (history.length === 0) { trendRow.style.display = 'none'; return; } const prevRtf = history[history.length - 1].avgRtf; const delta = currentAvgRtf - prevRtf; const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100; let cls, label; if (pct < 5) { cls = 'perf-trend-stable'; label = ' Stable'; } else if (delta < 0) { cls = 'perf-trend-better'; label = ` ${pct.toFixed(0)}% faster`; } else { cls = 'perf-trend-worse'; label = ` ${pct.toFixed(0)}% slower`; } trendBadge.className = 'perf-trend-badge ' + cls; trendBadge.innerHTML = label; const rtfValues = [...history.slice(-9).map(e => e.avgRtf), currentAvgRtf]; if (sparkWrap) sparkWrap.innerHTML = perfSparklineSvg(rtfValues); trendRow.style.display = ''; } function renderPerfTable(sessionDone = false) { if (!perfRows.length) { perfResultsCard.style.display='none'; return; } perfResultsCard.style.display = ''; perfTbody.innerHTML = perfRows.map((r, i) => { const rtf = r.audioDuration > 0 ? (r.latencyMs / 1000 / r.audioDuration).toFixed(2) : '—'; const ok = r.ok; return ` ${i+1} ${escHtml(r.backend)} ${escHtml(r.voice)} ${ok ? r.latencyMs : '—'} ${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'} ${ok ? rtf : '—'} ${ok ? 'OK' : `${escHtml(r.error||'Error')}`} `; }).join(''); const ok = perfRows.filter(r => r.ok); if (ok.length) { const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length; const minL = Math.min(...ok.map(r => r.latencyMs)); const maxL = Math.max(...ok.map(r => r.latencyMs)); const rtfArr = ok.filter(r=>r.audioDuration>0).map(r=>r.latencyMs/1000/r.audioDuration); const avgRtf = rtfArr.length ? rtfArr.reduce((s,v)=>s+v,0)/rtfArr.length : 0; const labelEl = $('perf-results-label'); if (labelEl) labelEl.textContent = `${ok.length} run${ok.length>1?'s':''} — ${perfBackendSel.value} / ${perfVoiceSel.value}`; perfSummary.innerHTML = ` ${Math.round(avg)} ms avg latency ${minL} ms best ${maxL} ms worst ${avgRtf.toFixed(2)} avg RTF ${avgRtf < 1 ? ' Real-time capable' : ' Slower than real-time'} `; if (sessionDone && rtfArr.length) { updateTrendDisplay(perfBackendSel.value, perfVoiceSel.value, avgRtf); perfHistoryAdd({ ts: Date.now(), backend: perfBackendSel.value, voice: perfVoiceSel.value, textLen: perfText.value.trim().length, avgLatencyMs: avg, minLatencyMs: minL, maxLatencyMs: maxL, avgRtf, runCount: ok.length, allOk: ok.length === perfRows.length, }); renderPerfHistory(); } } else { perfSummary.innerHTML = 'All runs failed'; } } perfClearBtn.addEventListener('click', () => { perfRows = []; renderPerfTable(); if ($('perf-trend-row')) $('perf-trend-row').style.display = 'none'; perfProgress.style.display = 'none'; }); perfRunBtn.addEventListener('click', async () => { const backend = perfBackendSel.value; const voice = perfVoiceSel.value; const text = perfText.value.trim(); const runs = parseInt(perfRunsSel.value) || 3; if (!backend) { toast('Select a backend first', 'error'); return; } if (!voice) { toast('Fetch and select a voice first', 'error'); return; } if (!text) { toast('Enter sample text', 'error'); return; } perfRows = []; perfRunBtn.disabled = true; perfProgress.style.display = ''; for (let i = 0; i < runs; i++) { perfProgress.textContent = `Run ${i+1} / ${runs}…`; const row = { backend, voice, ok: false, latencyMs: 0, audioDuration: 0, error: '' }; try { const t0 = performance.now(); const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend); row.latencyMs = Math.round(performance.now() - t0); row.ok = true; try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const buf = await audioCtx.decodeAudioData(await blob.arrayBuffer()); row.audioDuration = buf.duration; audioCtx.close(); } catch(_) {} } catch(e) { row.error = e.message; } perfRows.push(row); renderPerfTable(false); } perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`; renderPerfTable(true); perfRunBtn.disabled = false; }); // History filter toggle $('perf-history-filter-current')?.addEventListener('change', renderPerfHistory); $('perf-history-clear-btn')?.addEventListener('click', () => { perfHistorySave([]); renderPerfHistory(); toast('Benchmark history cleared', 'success'); }); renderPerfHistory(); })(); // ── Batch benchmark ─────────────────────────────────────────────────────── (function initBatchBenchmark() { const batchBackendSel = $('batch-backend-select'); const batchRunsSel = $('batch-runs'); const batchLoadBtn = $('batch-load-voices-btn'); const batchSelectAllBtn = $('batch-select-all-btn'); const batchSelectNoneBtn= $('batch-select-none-btn'); const batchVoiceList = $('batch-voice-list'); const batchSelCount = $('batch-selected-count'); const batchRunBtn = $('batch-run-btn'); const batchStopBtn = $('batch-stop-btn'); const batchProgress = $('batch-progress'); const batchProgLabel = $('batch-progress-label'); const batchProgCount = $('batch-progress-count'); const batchProgBar = $('batch-progress-bar'); const batchResultsCard = $('batch-results-card'); const batchResultsLabel = $('batch-results-label'); const batchTbody = $('batch-tbody'); if (!batchRunBtn) return; let batchStopped = false; let batchResults = []; function populateBatchBackends() { if (!batchBackendSel) return; const cur = batchBackendSel.value; batchBackendSel.innerHTML = availableTtsBackends().map(b => `` ).join('') || ''; } populateBatchBackends(); function updateSelCount() { if (!batchVoiceList || !batchSelCount) return; const total = batchVoiceList.querySelectorAll('.batch-voice-cb').length; const checked = batchVoiceList.querySelectorAll('.batch-voice-cb:checked').length; batchSelCount.textContent = total ? `${checked} of ${total} selected` : ''; batchRunBtn.disabled = checked === 0; } function buildVoiceList(voices) { const activeIds = new Set(activeVoiceIds()); if (!voices.length) { batchVoiceList.innerHTML = '
No voices found.
'; updateSelCount(); return; } batchVoiceList.innerHTML = voices.map(v => { const isActive = activeIds.has(v.id); return ``; }).join(''); batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', updateSelCount)); updateSelCount(); } // Pre-populate from My Voices library on init function populateFromLibrary() { const voices = (_voices || []) .filter(v => v.enabled !== false) .map(v => ({ id: v.id, label: v.display_name || v.name || v.id })) .sort((a, b) => a.label.localeCompare(b.label)); buildVoiceList(voices); } populateFromLibrary(); batchLoadBtn.addEventListener('click', async () => { const backend = batchBackendSel.value; if (!backend) { toast('Select a backend first', 'error'); return; } batchLoadBtn.disabled = true; batchVoiceList.innerHTML = '
Loading from backend…
'; try { const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); const voices = raw.map(v => ({ id: backendVoiceId(v), label: backendVoiceId(v) })) .sort((a, b) => a.label.localeCompare(b.label)); buildVoiceList(voices); } catch(e) { batchVoiceList.innerHTML = `
Failed: ${escHtml(e.message)}
`; } finally { batchLoadBtn.disabled = false; } }); batchSelectAllBtn.addEventListener('click', () => { batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = true); updateSelCount(); }); batchSelectNoneBtn.addEventListener('click', () => { batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = false); updateSelCount(); }); function renderBatchResults() { if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; } batchResultsCard.style.display = ''; const sorted = batchResults.slice().sort((a, b) => { if (a.ok && !b.ok) return -1; if (!a.ok && b.ok) return 1; return (a.avgRtf || 999) - (b.avgRtf || 999); }); const okCount = batchResults.filter(r => r.ok).length; if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`; batchTbody.innerHTML = sorted.map(r => { const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : ''; const trend = r.trend ? `${r.trend.label}` : ''; return ` ${escHtml(r.voice)} ${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'} ${r.ok ? r.minLatency + ' ms' : '—'} ${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'} ${r.ok && r.avgRtf ? r.avgRtf.toFixed(2) : '—'}${trend} ${r.ok ? 'OK' : `${escHtml(r.error || 'Failed')}`} `; }).join(''); } batchRunBtn.addEventListener('click', async () => { const backend = batchBackendSel.value; const text = $('perf-text')?.value.trim(); const runs = parseInt(batchRunsSel.value) || 1; const selected = [...(batchVoiceList?.querySelectorAll('.batch-voice-cb:checked') || [])].map(cb => cb.value); if (!backend) { toast('Select a backend first', 'error'); return; } if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; } if (!selected.length) { toast('Select at least one voice', 'error'); return; } batchStopped = false; batchResults = []; batchRunBtn.disabled = true; batchStopBtn.disabled = false; batchProgress.style.display = ''; batchResultsCard.style.display = 'none'; for (let vi = 0; vi < selected.length; vi++) { if (batchStopped) break; const voice = selected[vi]; batchProgLabel.textContent = `${voice} (${vi + 1} / ${selected.length})`; batchProgCount.textContent = `${vi + 1} / ${selected.length}`; batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`; const entry = { voice, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' }; const rowRunResults = []; for (let ri = 0; ri < runs; ri++) { if (batchStopped) break; try { const t0 = performance.now(); const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend); const lat = Math.round(performance.now() - t0); let dur = 0; try { const ac = new (window.AudioContext || window.webkitAudioContext)(); const buf = await ac.decodeAudioData(await blob.arrayBuffer()); dur = buf.duration; ac.close(); } catch(_) {} rowRunResults.push({ lat, dur }); } catch(e) { entry.error = e.message; break; } } if (rowRunResults.length) { entry.ok = true; entry.avgLatency = rowRunResults.reduce((s, r) => s + r.lat, 0) / rowRunResults.length; entry.minLatency = Math.min(...rowRunResults.map(r => r.lat)); const durRows = rowRunResults.filter(r => r.dur > 0); entry.avgAudio = durRows.length ? durRows.reduce((s, r) => s + r.dur, 0) / durRows.length : 0; const rtfArr = durRows.map(r => r.lat / 1000 / r.dur); entry.avgRtf = rtfArr.length ? rtfArr.reduce((s, v) => s + v, 0) / rtfArr.length : 0; // compute trend vs previous session for this voice if (entry.avgRtf > 0) { const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number'); if (prev.length) { const prevRtf = prev[prev.length - 1].avgRtf; const delta = entry.avgRtf - prevRtf; const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100; if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: '→ stable' }; else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `↓ ${pct.toFixed(0)}% faster` }; else entry.trend = { cls: 'perf-trend-worse', label: `↑ ${pct.toFixed(0)}% slower` }; } perfHistoryAdd({ ts: Date.now(), backend, voice, textLen: text.length, avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency, maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)), avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true, }); } } batchResults.push(entry); renderBatchResults(); } batchProgBar.style.width = '100%'; batchProgLabel.textContent = batchStopped ? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.` : `Done — ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`; batchStopBtn.disabled = true; batchRunBtn.disabled = false; renderPerfHistory(); const ok = batchResults.filter(r => r.ok); const best = ok.slice().sort((a, b) => a.avgRtf - b.avgRtf)[0]; toast( `Batch done: ${ok.length}/${batchResults.length} OK` + (best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''), ok.length < batchResults.length ? 'error' : 'success' ); }); batchStopBtn.addEventListener('click', () => { batchStopped = true; batchStopBtn.disabled = true; batchProgLabel.textContent = 'Stopping after current voice…'; }); })(); // ── STT -> TTS ─────────────────────────────────────────────────────────── let sttTtsSourceId = null; let sttTtsOutputBlob = null; let _sttBackends = []; let sttTtsRecorder = null; let sttTtsRecordStream = null; let sttTtsRecordChunks = []; let sttTtsRecordTimer = null; let sttTtsRecordSecs = 0; function sttTtsSelectedSttBackend() { return $('stt-tts-stt-backend')?.value || 'configured'; } function sttBackendOptionHtml(selected = 'configured') { if (!_sttBackends.length) return ''; const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected); return _sttBackends.map(b => { const suffix = b.available ? '' : ' (unavailable)'; const disabled = b.available ? '' : ' disabled'; return ``; }).join(''); } function updateSttBackendHelp() { const selected = sttTtsSelectedSttBackend(); const b = _sttBackends.find(item => item.id === selected) || _sttBackends.find(item => item.available) || null; const help = $('stt-tts-stt-help'); if (!help) return; if (!b) { help.textContent = 'No STT engine status loaded yet.'; return; } help.innerHTML = sttBackendHelpHtml(b); } async function refreshSttBackends(selected = '') { try { const d = await fetch('/api/stt-backends').then(r => r.json()); _sttBackends = (d.backends || []).filter(b => b && b.id); } catch (_) { _sttBackends = []; } const sel = $('stt-tts-stt-backend'); if (sel) { const prev = selected || sel.value || 'configured'; sel.innerHTML = sttBackendOptionHtml(prev); sel.disabled = !_sttBackends.some(b => b.available); } updateSttBackendHelp(); } function sttTtsSelectedBackend() { return $('stt-tts-backend-select')?.value || ''; } function sttTtsDownload(blob, name) { if (!blob) return; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); } async function sttTtsUploadFile(file) { if (!file) return; $('stt-tts-source-status').textContent = 'Uploading ' + file.name + '...'; sttTtsSourceId = null; sttTtsOutputBlob = null; $('stt-tts-transcribe-btn').disabled = true; $('stt-tts-copy-preview-btn').disabled = true; $('stt-tts-save-mp3-btn').disabled = true; $('stt-tts-save-wav-btn').disabled = true; const fd = new FormData(); fd.append('file', file); try { const r = await fetch('/api/upload', {method:'POST', body:fd}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); sttTtsSourceId = d.id; const audio = $('stt-tts-source-audio'); audio.src = '/api/audio/' + encodeURIComponent(d.id); audio.style.display = ''; $('stt-tts-source-status').textContent = `${d.filename || file.name} loaded (${Number(d.duration || 0).toFixed(1)} s).`; $('stt-tts-transcribe-btn').disabled = false; toast('Speech audio loaded', 'success'); } catch (e) { $('stt-tts-source-status').textContent = 'Upload failed.'; toast('STT source upload failed: ' + e.message, 'error'); } } async function sttTtsFetchVoices() { const backend = sttTtsSelectedBackend(); if (!backend) throw new Error('No available TTS backend'); const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); let voices = Array.isArray(rawVoices) ? rawVoices : []; if (shouldFilterBackendVoices(backend)) { const activeIds = await activeLibraryVoiceIds(); voices = voices.filter(v => activeIds.has(backendVoiceId(v))); } const sel = $('stt-tts-voice-select'), prev = sel.value; sel.innerHTML = ''; voices.forEach(v => { const id = backendVoiceId(v); const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt); }); if (prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev; return voices.length; } $('stt-tts-file')?.addEventListener('change', async () => { const input = $('stt-tts-file'); if (input.files && input.files.length) await sttTtsUploadFile(input.files[0]); input.value = ''; }); $('stt-tts-refresh-stt-btn')?.addEventListener('click', async () => { const btn = $('stt-tts-refresh-stt-btn'); btn.disabled = true; try { await refreshSttBackends(sttTtsSelectedSttBackend()); toast('STT engines refreshed', 'success'); } finally { btn.disabled = false; } }); $('stt-tts-stt-backend')?.addEventListener('change', updateSttBackendHelp); function sttTtsSetRecording(on) { $('stt-tts-rec-start').disabled = on; $('stt-tts-rec-stop').disabled = !on; } function sttTtsStopTracks() { if (sttTtsRecordStream) sttTtsRecordStream.getTracks().forEach(t => t.stop()); sttTtsRecordStream = null; } $('stt-tts-rec-start')?.addEventListener('click', async () => { try { sttTtsRecordStream = await requestMicrophoneStream(); sttTtsRecordChunks = []; sttTtsRecordSecs = 0; $('stt-tts-rec-time').textContent = '0:00'; $('stt-tts-source-status').textContent = 'Recording...'; sttTtsSetRecording(true); sttTtsRecordTimer = setInterval(() => { sttTtsRecordSecs++; $('stt-tts-rec-time').textContent = Math.floor(sttTtsRecordSecs / 60) + ':' + String(sttTtsRecordSecs % 60).padStart(2, '0'); }, 1000); sttTtsRecorder = new MediaRecorder(sttTtsRecordStream); sttTtsRecorder.ondataavailable = e => { if (e.data.size) sttTtsRecordChunks.push(e.data); }; sttTtsRecorder.onstop = async () => { clearInterval(sttTtsRecordTimer); sttTtsRecordTimer = null; sttTtsSetRecording(false); sttTtsStopTracks(); const mime = sttTtsRecorder.mimeType || 'audio/webm'; const blob = new Blob(sttTtsRecordChunks, {type:mime}); const ext = mime.includes('ogg') ? '.ogg' : '.webm'; if (!blob.size) { $('stt-tts-source-status').textContent = 'Recording was empty.'; toast('Recording was empty', 'error'); return; } await sttTtsUploadFile(new File([blob], 'stt-recording' + ext, {type:mime})); }; sttTtsRecorder.start(100); toast('Recording started', 'success'); } catch (e) { sttTtsSetRecording(false); sttTtsStopTracks(); const message = await microphoneErrorMessage(e); $('stt-tts-source-status').textContent = message; toast(message, 'error'); } }); $('stt-tts-rec-stop')?.addEventListener('click', () => { if (sttTtsRecorder && sttTtsRecorder.state !== 'inactive') sttTtsRecorder.stop(); }); $('stt-tts-transcribe-btn')?.addEventListener('click', async () => { if (!sttTtsSourceId) { toast('Load speech audio first', 'error'); return; } const btn = $('stt-tts-transcribe-btn'); btn.disabled = true; $('stt-tts-source-status').textContent = 'Transcribing...'; try { const r = await fetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:sttTtsSourceId, backend:sttTtsSelectedSttBackend()})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); $('stt-tts-text').value = d.text || ''; $('stt-tts-copy-preview-btn').disabled = !(d.text || '').trim(); const used = d.backend ? ' via ' + d.backend : ''; $('stt-tts-source-status').textContent = 'Transcription ready' + used + '.'; if (typeof updateRefineButtonState === 'function') updateRefineButtonState(); toast('Transcription ready', 'success'); } catch (e) { $('stt-tts-source-status').textContent = 'Transcription failed.'; toast('STT failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); $('stt-tts-copy-preview-btn')?.addEventListener('click', () => { const text = $('stt-tts-text').value.trim(); if (!text) return; $('preview-text-area').value = text; switchTab('generation'); toast('Copied transcription to TTS Generation', 'success'); }); $('stt-tts-backend-select')?.addEventListener('change', () => { $('stt-tts-voice-select').innerHTML = ''; sttTtsOutputBlob = null; $('stt-tts-save-mp3-btn').disabled = true; $('stt-tts-save-wav-btn').disabled = true; updateBackendHelp(); }); $('stt-tts-fetch-voices-btn')?.addEventListener('click', async () => { const btn = $('stt-tts-fetch-voices-btn'); btn.disabled = true; try { const count = await sttTtsFetchVoices(); toast('Fetched ' + count + ' voices', 'success'); } catch (e) { toast('Fetch failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); $('stt-tts-generate-btn')?.addEventListener('click', async () => { const backend = sttTtsSelectedBackend(); const voice = $('stt-tts-voice-select').value; const text = $('stt-tts-text').value.trim(); const instruct = $('stt-tts-style-instruction').value.trim(); if (!backend) { toast('No available TTS backend', 'error'); return; } if (!voice) { toast('Select a TTS voice', 'error'); return; } if (!text) { toast('Transcribe or enter text first', 'error'); return; } const btn = $('stt-tts-generate-btn'); btn.disabled = true; $('stt-tts-save-mp3-btn').disabled = true; $('stt-tts-save-wav-btn').disabled = true; try { const source = await createTtsAudioSource(voice, text, backend, $('stt-tts-playback-mode').value, instruct); sttTtsOutputBlob = source.blob; const audio = $('stt-tts-output-audio'); audio.src = source.url; audio.style.display = ''; await audio.play(); $('stt-tts-save-mp3-btn').disabled = false; $('stt-tts-save-wav-btn').disabled = source.streaming; toast(source.streaming ? 'Streaming synthesized speech' : 'Synthesized speech ready', 'success'); } catch (e) { toast('TTS failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); $('stt-tts-save-mp3-btn')?.addEventListener('click', async () => { const backend = sttTtsSelectedBackend(); const voice = $('stt-tts-voice-select').value; const text = $('stt-tts-text').value.trim(); const instruct = $('stt-tts-style-instruction').value.trim(); if (!backend || !voice || !text) return; const btn = $('stt-tts-save-mp3-btn'); btn.disabled = true; try { const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend); sttTtsDownload(blob, (voice || 'stt_tts') + '_stt_tts.mp3'); toast('MP3 saved', 'success'); } catch (e) { toast('MP3 save failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); $('stt-tts-save-wav-btn')?.addEventListener('click', () => { if (!sttTtsOutputBlob) return; sttTtsDownload(sttTtsOutputBlob, ($('stt-tts-voice-select').value || 'stt_tts') + '_stt_tts.wav'); }); // ── Init ────────────────────────────────────────────────────────────────── initBenchmarkSampleControls(); // Sync visible preview-text input with hidden benchmark-sample-text (function syncPreviewInput() { const visible = $('vl-preview-sample'); const hidden = $('benchmark-sample-text'); if (!visible) return; const stored = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY); visible.value = stored || DEFAULT_BENCHMARK_SAMPLE_TEXT; if (hidden) hidden.value = visible.value; visible.addEventListener('input', () => { if (hidden) hidden.value = visible.value; localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, visible.value.trim()); }); })(); // Sync flag button to current preview text language (best-match) (function syncPreviewLangBtn() { const ta = $('vl-preview-sample'); const btn = $('vl-preview-lang-btn'); if (!ta || !btn) return; const cur = ta.value.trim(); const matched = Object.keys(VL_SAMPLE_TEXTS).find(l => VL_SAMPLE_TEXTS[l] === cur) || 'EN'; const cc = (_VL_LANG_FLAG[matched] || 'gb').toLowerCase(); btn.innerHTML = ``; btn.title = (LANGUAGE_LABELS[matched] || matched) + ' — click to change sample language'; btn.dataset.lang = matched; })(); // Synth mode segmented control (preview text vs. reference transcript) (function initSynthMode() { const seg = $('vl-synth-mode-seg'); const wrap = $('vl-preview-text-wrap'); if (!seg) return; seg.querySelectorAll('.vl-synth-seg-btn').forEach(btn => { btn.addEventListener('click', () => { seg.querySelectorAll('.vl-synth-seg-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); if (wrap) wrap.classList.toggle('hidden', btn.dataset.mode === 'transcript'); }); }); })(); loadSettings().then(() => { refreshSttBackends(); renderIntegrationSnippets(); if (!localStorage.getItem(SETTINGS_SEEN_KEY)) openSettings(true); }).catch(e => status('Settings load failed: ' + e.message)); // ── ElevenLabs Voice Library Browser ────────────────────────────────────── (function initElevenLabsBrowser() { if (!$('el-browser-card')) return; let _elPage = 0, _elHasMore = false, _elTotal = 0; let _elAudio = null, _elAudioBtn = null; let _elFilters = {}; let _elSearchTimer; const _elHue = str => { let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff; return Math.abs(h) % 360; }; const _elEsc = s => String(s || '') .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); // Load key state from settings function elLoadKey() { fetch('/api/settings').then(r => r.json()).then(s => { const key = (s.elevenlabs_api_key || '').trim(); const inp = $('el-api-key'), st = $('el-key-status'); if (inp) inp.placeholder = key ? '••••••••••••••• (key saved)' : 'ElevenLabs API key (free — unlocks full library)…'; if (st) { st.innerHTML = key ? ' Key active' : 'No key — max 3 results'; st.className = 'el-key-status ' + (key ? 'el-key-ok' : 'el-key-none'); } elFetch(); }).catch(() => elFetch()); } $('el-key-save').addEventListener('click', () => { const inp = $('el-api-key'); const val = (inp?.value || '').trim(); if (!val || val.startsWith('•')) { _elPage = 0; elFetch(); return; } fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) }) .then(() => { const st = $('el-key-status'); if (st) { st.innerHTML = ' Key saved'; st.className = 'el-key-status el-key-ok'; } if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; } _elPage = 0; elFetch(); }); }); $('el-api-key')?.addEventListener('keydown', e => { if (e.key === 'Enter') $('el-key-save')?.click(); }); $('el-cats')?.querySelectorAll('.el-cat').forEach(pill => { pill.addEventListener('click', () => { $('el-cats').querySelectorAll('.el-cat').forEach(p => p.classList.remove('active')); pill.classList.add('active'); _elFilters = {}; for (const attr of pill.getAttributeNames()) { if (attr.startsWith('data-el-') && pill.getAttribute(attr)) _elFilters[attr.slice('data-el-'.length)] = pill.getAttribute(attr); } _elPage = 0; elFetch(); }); }); ['el-lang', 'el-gender', 'el-age'].forEach(id => { $(id)?.addEventListener('change', () => { _elPage = 0; elFetch(); }); }); $('el-search')?.addEventListener('input', () => { clearTimeout(_elSearchTimer); _elSearchTimer = setTimeout(() => { _elPage = 0; elFetch(); }, 420); }); $('el-fetch')?.addEventListener('click', () => { _elPage = 0; elFetch(); }); $('el-prev')?.addEventListener('click', () => { if (_elPage > 0) { _elPage--; elFetch(); } }); $('el-next')?.addEventListener('click', () => { if (_elHasMore) { _elPage++; elFetch(); } }); function elParams() { const p = new URLSearchParams({ page_size: '24', page: String(_elPage) }); const lang = $('el-lang')?.value, gender = $('el-gender')?.value, age = $('el-age')?.value; const search = ($('el-search')?.value || '').trim(); if (lang) p.set('language', lang); if (gender) p.set('gender', gender); if (age) p.set('age', age); if (search) p.set('search', search); Object.entries(_elFilters).forEach(([k, v]) => { if (v) p.set(k, v); }); return p.toString(); } async function elFetch() { const grid = $('el-grid'), sb = $('el-status-bar'), st = $('el-status-text'); if (grid) grid.innerHTML = '
Loading voices…
'; try { const res = await fetch('/api/elevenlabs/voices?' + elParams()); const data = await res.json(); if (data.detail) { if (grid) grid.innerHTML = `
${_elEsc(data.detail?.message || JSON.stringify(data.detail))}
`; if (sb) sb.hidden = true; return; } _elHasMore = !!data.has_more; _elTotal = data.total_count || 0; const voices = data.voices || []; if (st) { const cap = voices.length < 24 && _elTotal > 3 ? ' · \u{1F511} Add your free API key for full access' : ''; st.innerHTML = `${_elTotal.toLocaleString()} voices · showing ${voices.length} · page ${_elPage + 1}${cap}`; } if (sb) sb.hidden = false; if (!voices.length) { if (grid) grid.innerHTML = '
No voices found — try different filters.
'; } else { if (grid) { grid.innerHTML = voices.map(elCard).join(''); } grid?.querySelectorAll('.el-play').forEach(b => b.addEventListener('click', () => elPlay(b))); grid?.querySelectorAll('.el-clone').forEach(b => b.addEventListener('click', () => elImport(b))); } const pager = $('el-pager'); if (pager) { pager.hidden = _elPage === 0 && !_elHasMore; const pb = $('el-prev'), nb = $('el-next'), pi = $('el-pager-info'); if (pb) pb.disabled = _elPage === 0; if (nb) nb.disabled = !_elHasMore; if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`; } } catch (e) { if (grid) grid.innerHTML = `
${_elEsc(e.message)}
`; } } function elCard(v) { const hue = _elHue(v.voice_id || v.name || ''); const letter = _elEsc((v.name || '?')[0].toUpperCase()); const lang = (v.language || '').toUpperCase(); const g = v.gender === 'female' ? 'F' : v.gender === 'male' ? 'M' : (v.gender || ''); const age = (v.age || '').replace(/_/g, ' '); const uc = (v.use_case || '').replace(/_/g, ' '); const clones = v.cloned_by_count || 0; const cl = clones >= 1000 ? (clones / 1000).toFixed(1) + 'k' : clones > 0 ? String(clones) : ''; const desc = (v.description || '').slice(0, 88) + ((v.description || '').length > 88 ? '…' : ''); const prev = _elEsc(v.preview_url || ''); const nm = _elEsc(v.name || ''); const tags = [ lang ? `${lang}` : '', g ? `${g}` : '', age ? `${_elEsc(age)}` : '', uc ? `${_elEsc(uc)}` : '', (v.accent && v.accent !== 'standard') ? `${_elEsc(v.accent)}` : '', v.free_users_allowed ? 'Free' : '', v.featured ? '' : '', cl ? ` ${cl}` : '', ].join(''); return `
${letter}
${nm}
${tags}
${desc ? `
${_elEsc(desc)}
` : ''}
${prev ? `` : ''}
`; } function elPlay(btn) { const url = btn.dataset.preview; if (!url) return; if (_elAudio && _elAudioBtn === btn) { _elAudio.paused ? _elAudio.play() : _elAudio.pause(); btn.textContent = _elAudio.paused ? '▶' : '⏸'; return; } if (_elAudio) { _elAudio.pause(); if (_elAudioBtn) _elAudioBtn.textContent = '▶'; } _elAudio = new Audio(url); _elAudioBtn = btn; btn.textContent = '⏸'; _elAudio.play().catch(() => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; }); _elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; }); } function elImport(btn) { const url = btn.dataset.preview; if (!url) return; if (typeof navTo === 'function') navTo('s-clone'); setTimeout(() => { const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn'); if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); } if (btn2) btn2.click(); }, 120); } elLoadKey(); })(); loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message)); // ── Engines section: load containers on startup ──────────────────────────── loadLocalContainers(); // ── Custom engine card storage helpers ──────────────────────────────────── function loadCustomEngineCards() { if (_appSettings && Array.isArray(_appSettings.custom_engine_cards) && _appSettings.custom_engine_cards.length) return _appSettings.custom_engine_cards; try { return JSON.parse(localStorage.getItem('engines-custom-cards') || '[]'); } catch (e) { return []; } } function saveCustomEngineCards(cards) { localStorage.setItem('engines-custom-cards', JSON.stringify(cards)); if (_appSettings) _appSettings.custom_engine_cards = cards; _patchSettings({ custom_engine_cards: cards }); } // Settings key per docker container name; role fallback for custom cards const DC_USE_MAP = { 'faster-qwen3-tts-voiceclone': { settingKey: 'tts_url', label: 'Use as TTS' }, 'faster-qwen3-tts-voicedesign': { settingKey: 'tts_url', label: 'Use as TTS' }, 'faster-qwen3-tts-customvoice': { settingKey: 'tts_url', label: 'Use as TTS' }, 'faster-qwen3-tts-streaming': { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' }, 'parakeet-asr': { settingKey: 'nvidia_asr_url', label: 'Use as STT' }, 'magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' }, 'parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' }, }; const DC_ROLE_USE = { tts: { settingKey: 'tts_url', label: 'Use as TTS' }, stt: { settingKey: 'faster_whisper_url', label: 'Use as STT' }, llm: { settingKey: 'llm_url', label: 'Use as LLM' }, }; window.openAddEngineDialog = function(role) { const dlg = document.getElementById('add-engine-dlg'); if (!dlg) return; delete dlg.dataset.editId; const titleEl = dlg.querySelector('.add-engine-title'); if (titleEl) titleEl.textContent = ' Add Custom Engine'; const saveBtn = document.getElementById('aed-save'); if (saveBtn) saveBtn.textContent = 'Add Card'; const roleEl = document.getElementById('aed-role'); if (roleEl && role) roleEl.value = role; ['aed-name', 'aed-url', 'aed-container', 'aed-desc'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); dlg.showModal(); }; function editCustomEngineCard(card) { const dlg = document.getElementById('add-engine-dlg'); if (!dlg) return; dlg.dataset.editId = String(card.id || card.name); const titleEl = dlg.querySelector('.add-engine-title'); if (titleEl) titleEl.textContent = ' Edit Engine'; const saveBtn = document.getElementById('aed-save'); if (saveBtn) saveBtn.textContent = 'Save Changes'; const roleEl = document.getElementById('aed-role'); if (roleEl) roleEl.value = card.role || 'tts'; const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; }; setVal('aed-name', card.label || card.name); setVal('aed-url', card.url); setVal('aed-container', card.containerName); setVal('aed-desc', card.description); dlg.showModal(); } (function initCustomEngineDialog() { const dlg = document.getElementById('add-engine-dlg'); if (!dlg) return; document.getElementById('aed-cancel')?.addEventListener('click', () => { delete dlg.dataset.editId; dlg.close(); }); dlg.addEventListener('click', e => { if (e.target === dlg) { delete dlg.dataset.editId; dlg.close(); } }); document.getElementById('aed-save')?.addEventListener('click', () => { const name = document.getElementById('aed-name')?.value.trim(); const role = document.getElementById('aed-role')?.value; const url = document.getElementById('aed-url')?.value.trim(); const containerName = document.getElementById('aed-container')?.value.trim() || ''; const desc = document.getElementById('aed-desc')?.value.trim() || ''; if (!name) { toast('Name is required', 'error'); return; } if (!url) { toast('URL is required', 'error'); return; } const editId = dlg.dataset.editId; if (editId) { const cards = loadCustomEngineCards().map(c => String(c.id || c.name) === editId ? { ...c, name, label: name, role, url, containerName, description: desc } : c ); saveCustomEngineCards(cards); delete dlg.dataset.editId; dlg.close(); loadLocalContainers(); toast(`"${name}" updated`, 'success'); } else { const card = { id: 'custom-' + Date.now(), name, label: name, role, url, containerName, description: desc, isCustom: true }; const cards = loadCustomEngineCards(); cards.push(card); saveCustomEngineCards(cards); dlg.close(); loadLocalContainers(); toast(`"${name}" added`, 'success'); } }); })(); // ── Local Docker container management ───────────────────────────────────── function cardType(card) { const page = card?.closest('[data-page]'); return page ? page.dataset.page : ''; } async function loadLocalContainers() { const gridTts = $('dc-grid-tts'); const gridStt = $('dc-grid-stt'); const gridLlm = $('dc-grid-llm'); if (!gridTts && !gridStt && !gridLlm) return; const loading = '
Checking container status…
'; if (gridTts) gridTts.innerHTML = loading; if (gridStt) gridStt.innerHTML = loading; if (gridLlm) gridLlm.innerHTML = ''; const custom = loadCustomEngineCards(); try { const r = await fetch('/api/local-containers'); const d = await r.json(); renderLocalContainers([...(d.containers || []), ...custom]); } catch (e) { const err = `
Could not reach server: ${escHtml(e.message)}
`; if (gridTts) gridTts.innerHTML = err; if (gridStt) gridStt.innerHTML = err; if (custom.length) renderLocalContainers(custom); } } function renderLocalContainers(containers) { const gridTts = $('dc-grid-tts'); const gridStt = $('dc-grid-stt'); const gridLlm = $('dc-grid-llm'); if (!gridTts && !gridStt && !gridLlm) return; const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' }; const DC_ICONS = { 'faster-qwen3-tts-voiceclone': '', 'faster-qwen3-tts-voicedesign': '', 'faster-qwen3-tts-customvoice': '🎭', 'faster-qwen3-tts-streaming': '', 'parakeet-asr': '🦜', 'magpie-tts': '🐦', 'parakeet-rnnt-nim': '🦜', }; const _t = (v) => ``; const _l = (v) => ``; const _q = (v) => ``; const _m = (v) => ``; const DC_METRICS = { 'faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium clone'],[_m(),'6–8 GB VRAM']], 'faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']], 'faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']], 'faster-qwen3-tts-streaming': [[_t(),'~0.1× GPU'],[_l(),'0.5–1 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']], 'magpie-tts': [[_t(),'~0.05× GPU'],[_l(),'0.3–0.8 s'],[_q(),'High'], [_m(),'4–6 GB VRAM']], 'parakeet-asr': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-TDT'],[_m(),'2 GB VRAM']], 'parakeet-rnnt-nim': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']], }; const roleIcon = { tts: '', stt: '', 'stt+tts': '', llm: '' }; function buildCardHtml(c) { const isCustom = !!c.isCustom; const st = isCustom ? 'custom' : (c.status || 'not_found'); const running = st === 'running'; const stopped = st === 'exited' || st === 'stopped'; const absent = st === 'not_found'; const stLabel = isCustom ? 'Custom' : running ? 'Running' : stopped ? 'Stopped' : 'Not installed'; const stCls = isCustom ? '' : running ? 'llm-compat-running' : stopped ? 'llm-compat-stopped' : ''; const cardCls = running ? ' llm-local-card-running' : stopped ? ' llm-local-card-offline' : ''; const roleBadge = ROLE_LABEL[c.role] || c.role || ''; const portStr = c.port ? ` :${c.port}` : ''; const installed = !absent; const icon = DC_ICONS[c.name] || roleIcon[c.role] || '📦'; const metricChips = (DC_METRICS[c.name] || []) .map(([em, txt]) => `${em} ${escHtml(txt)}`).join(''); const metricsHtml = metricChips ? `
${metricChips}
` : ''; const n = escHtml(c.name); // URL row (docker containers use port-based default; custom cards use their stored url) const defaultUrl = isCustom ? (c.url || '') : (c.port ? `http://host.docker.internal:${c.port}` : ''); const urlRowHtml = `
URL
`; // Use-as button const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role]; const useBtn = useEntry ? `` : ''; // Docker stop/start/restart buttons const dockerContainerName = isCustom ? escHtml(c.containerName || '') : n; const dockerBtns = isCustom ? (c.containerName ? ` ` : '') : (installed ? (running ? ` ` : ` `) : (c.repo ? ` View on GitHub` : '')); // Edit / Delete buttons for custom cards const customId = escHtml(String(c.id || c.name)); const editBtn = isCustom ? `` : ''; const deleteBtn = isCustom ? `` : ''; const portNum = c.port || ''; const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`; const snippetCode = c.role === 'tts' ? `curl -s "${snippetBaseUrl}/v1/audio/speech" \\\n -H "Authorization: Bearer dummy" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"tts-1","voice":"default","input":"Hello world","response_format":"wav"}' \\\n --output test.wav` : `curl -s "${snippetBaseUrl}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`; const snippetCopy = escHtml(snippetCode); const snippetHtml = (portNum || isCustom) ? `
API test
${escHtml(snippetCode)}
` : ''; return `
${icon} ${escHtml(c.label || c.name)} ${roleBadge ? `${escHtml(roleBadge)}${escHtml(portStr)}` : ''} ${escHtml(stLabel)}
${metricsHtml} ${c.description ? `

${escHtml(c.description)}

` : ''}${urlRowHtml}
${dockerBtns}${useBtn}${editBtn}${deleteBtn}
${snippetHtml}
`; } const isTts = c => c.role === 'tts'; const isStt = c => c.role === 'stt' || c.role === 'stt+tts'; const isLlm = c => c.role === 'llm'; if (gridTts) gridTts.innerHTML = containers.filter(isTts).map(buildCardHtml).join(''); if (gridStt) gridStt.innerHTML = containers.filter(isStt).map(buildCardHtml).join(''); if (gridLlm) gridLlm.innerHTML = containers.filter(isLlm).map(buildCardHtml).join(''); [gridTts, gridStt, gridLlm].forEach(grid => { if (!grid) return; // Stop / Start / Restart docker containers grid.querySelectorAll('.dc-btn[data-dc-action]').forEach(btn => { btn.addEventListener('click', async () => { const action = btn.dataset.dcAction; const name = btn.dataset.dcName; btn.disabled = true; btn.textContent = action === 'start' ? 'Starting…' : 'Stopping…'; try { const r = await fetch(`/api/local-containers/${encodeURIComponent(name)}/${action}`, { method: 'POST' }); const d = await r.json(); if (!d.ok) toast(d.error || `${action} failed`, 'error'); } catch (e) { toast(`${action} failed: ${e.message}`, 'error'); } await loadLocalContainers(); }); }); // Copy snippet buttons grid.querySelectorAll('.dc-copy-btn').forEach(btn => { btn.addEventListener('click', async () => { const text = (btn.dataset.copy || '').replace(/ /g, '\n').replace(/"/g, '"').replace(/&/g, '&'); await copyText(text); toast('Copied', 'success'); }); }); // URL input — load from localStorage, auto-save, restore connected state grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => { const key = inp.dataset.dcUrlKey; const saved = localStorage.getItem('dc-url-' + key); if (saved) inp.value = saved; inp.addEventListener('input', () => localStorage.setItem('dc-url-' + key, inp.value)); if (localStorage.getItem('dc-con-' + key) === '1') { const card = inp.closest('.llm-local-card'); const btn = grid.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`); if (card) card.classList.add('llm-local-card-online'); if (btn) { btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok'; } } }); // Connect / Disconnect button in docker card URL row grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => { btn.addEventListener('click', async () => { const key = btn.dataset.dcUrlKey; const inp = grid.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`); const rawUrl = inp?.value.trim() || inp?.placeholder; if (!rawUrl) return; const card = btn.closest('.llm-local-card'); btn.disabled = true; btn.textContent = 'Connecting…'; try { if (window.probeUrl) { const type = card ? cardType(card) : ''; const d = await window.probeUrl(rawUrl, type); if (d.ok) { btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok'; if (card) card.classList.add('llm-local-card-online'); localStorage.setItem('dc-con-' + key, '1'); } else { btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn'; if (card) card.classList.remove('llm-local-card-online'); localStorage.removeItem('dc-con-' + key); toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error'); } } else { btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn'; } } catch (e) { btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn'; if (card) card.classList.remove('llm-local-card-online'); localStorage.removeItem('dc-con-' + key); } finally { btn.disabled = false; } }); }); // Use as TTS / STT / LLM grid.querySelectorAll('.dc-use-btn[data-dc-use-key]').forEach(btn => { btn.addEventListener('click', async () => { const settingKey = btn.dataset.dcUseKey; const urlKey = btn.dataset.dcName; const inp = grid.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`); const url = inp?.value.trim() || inp?.placeholder || ''; if (!url) { toast('Enter a URL first', 'error'); return; } if (window.applyAndSaveSettings) { await window.applyAndSaveSettings({ [settingKey]: url }); toast(`${settingKey.replace(/_/g, ' ')} → ${url}`, 'success'); } }); }); // Edit custom card grid.querySelectorAll('.dc-edit-btn[data-dc-custom-id]').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.dcCustomId; const card = loadCustomEngineCards().find(c => String(c.id || c.name) === id); if (card) editCustomEngineCard(card); }); }); // Delete custom card grid.querySelectorAll('.dc-delete-btn[data-dc-custom-id]').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.dcCustomId; const cards = loadCustomEngineCards().filter(c => String(c.id || c.name) !== id); saveCustomEngineCards(cards); loadLocalContainers(); }); }); initLlmSnippets(grid); }); } document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('click', loadLocalContainers)); // ── AI Backends section: copy, API keys, local service connect ───────────── (function initLlmsSection() { // Copy buttons document.querySelectorAll('.llm-copy-btn').forEach(btn => { btn.addEventListener('click', () => { const text = btn.dataset.copy || ''; const orig = btn.textContent; const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); }; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(done).catch(done); } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); } }); }); // API key inputs — persist to localStorage, eye toggle, saved badge document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => { const key = inp.dataset.llmKey; const saved = localStorage.getItem('llm-key-' + key); if (saved) inp.value = saved; const eye = document.createElement('button'); eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide'; eye.textContent = '👁'; inp.insertAdjacentElement('afterend', eye); const badge = document.createElement('span'); badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true; eye.insertAdjacentElement('afterend', badge); eye.addEventListener('click', () => { inp.type = inp.type === 'password' ? 'text' : 'password'; eye.classList.toggle('active', inp.type === 'text'); }); let t; inp.addEventListener('input', () => { clearTimeout(t); t = setTimeout(() => { if (inp.value) localStorage.setItem('llm-key-' + key, inp.value); else localStorage.removeItem('llm-key-' + key); badge.hidden = false; setTimeout(() => { badge.hidden = true; }, 1800); }, 600); }); }); // Local service URL inputs + Connect / Disconnect function normalizeProbeUrl(raw) { // 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2'); } async function probeUrl(rawUrl, type = '') { const url = normalizeProbeUrl(rawUrl); const params = { url }; if (type) params.type = type; const r = await fetch('/api/probe-url?' + new URLSearchParams(params)); return r.json(); } function applyCardState(card, key, connected, failed) { card.classList.toggle('llm-local-card-online', connected); card.classList.toggle('llm-local-card-offline', !connected && !!failed); localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0'); const btn = card.querySelector('.llm-local-ping'); if (!btn) return; if (connected) { btn.innerHTML = ' Disconnect'; btn.dataset.action = 'disconnect'; btn.className = 'llm-local-ping ok'; } else { btn.textContent = 'Connect'; btn.dataset.action = 'connect'; btn.className = 'llm-local-ping'; } } document.querySelectorAll('[data-llm-local-key]').forEach(inp => { const key = inp.dataset.llmLocalKey; const card = inp.closest('.llm-local-card'); if (!card) return; const btn = card.querySelector('.llm-local-ping'); const savedUrl = (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls[key]) || localStorage.getItem('llm-local-url-' + key); if (savedUrl) inp.value = savedUrl; inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); _saveEngineLocalUrls(); }); if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false); if (!btn) return; btn.addEventListener('click', async () => { const action = btn.dataset.action || 'connect'; if (action === 'disconnect') { applyCardState(card, key, false, false); return; } const rawUrl = inp.value.trim() || inp.placeholder; if (!rawUrl) return; btn.disabled = true; btn.textContent = 'Connecting…'; try { const type = cardType(card); const d = await probeUrl(rawUrl, type); applyCardState(card, key, d.ok, !d.ok); if (d.ok) { toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success'); } else { toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error'); } } catch (e) { applyCardState(card, key, false, true); toast('Probe failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); }); // ── "Use as STT / TTS" quick-apply buttons in AI Backends section ────────── async function applyAndSaveSettings(patch) { try { const resp = await fetch('/api/settings').then(r => r.json()); const updated = { ...resp, ...patch }; await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); Object.assign(_appSettings || {}, patch); // refresh visible inputs in Settings if open for (const [id, val] of Object.entries(patch)) { const inp = $('s-' + id.replace(/_/g, '-')); if (inp && inp.value !== undefined) inp.value = val; } await refreshTtsBackendAvailability(); await refreshSttBackends(); } catch (e) { toast('Apply failed: ' + e.message, 'error'); } } window.applyAndSaveSettings = applyAndSaveSettings; window.probeUrl = probeUrl; $('llm-use-faster-whisper-stt')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim() || 'http://host.docker.internal:8000'; applyAndSaveSettings({ faster_whisper_url: url }); toast('faster-whisper-server URL saved → Settings. Use the "faster-whisper" engine in the STT dropdown.', 'success'); }); $('llm-use-whisper-cpp-stt')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="whisper-cpp"]')?.value.trim() || 'http://host.docker.internal:8080'; applyAndSaveSettings({ whisper_cpp_url: url }); toast('whisper.cpp URL saved → Settings. Use the "whisper.cpp" engine in the STT dropdown.', 'success'); }); $('llm-use-kokoro-tts')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="kokoro"]')?.value.trim() || 'http://host.docker.internal:8880/v1'; applyAndSaveSettings({ kokoro_url: url }); toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success'); }); $('llm-use-vibevoice-tts')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim() || 'http://192.168.178.8:8027'; applyAndSaveSettings({ vibevoice_url: url }); toast('VibeVoice URL saved → Settings. Pick "VibeVoice" in Try It Out → Backend or a routing rule.', 'success'); }); $('llm-use-piper-tts')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="piper"]')?.value.trim() || 'localhost:10200'; applyAndSaveSettings({ tts_url: url }); toast('Piper URL saved → tts_url. Note: Piper uses Wyoming protocol — pair with an OpenAI-compatible wrapper for full support.', 'success'); }); $('llm-use-xtts-tts')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim() || 'http://localhost:8024'; applyAndSaveSettings({ xtts_url: url }); toast('XTTS v2 URL saved → xtts_url. It now appears as "XTTS v2" in the TTS backend dropdown.', 'success'); }); const LLM_USE_MAP = { 'llm-use-ollama-llm': { key: 'ollama', fallback: 'http://localhost:11434/v1' }, 'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/v1' }, 'llm-use-lmstudio-llm': { key: 'lmstudio', fallback: 'http://localhost:1234/v1' }, 'llm-use-llamacpp-llm': { key: 'llamacpp', fallback: 'http://localhost:8080/v1' }, }; Object.entries(LLM_USE_MAP).forEach(([id, { key, fallback }]) => { $(id)?.addEventListener('click', () => { const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback; applyAndSaveSettings({ llm_url: url }); toast(`LLM URL set to ${url} → Settings. Used for persona rewrite and transcription refinement.`, 'success'); }); }); // ── Inline STT quick-test panel ───────────────────────────────────────── (async function initSttTestPanel() { const panel = $('stt-test-panel'); const sel = $('stt-test-backend'); const micBtn = $('stt-test-mic-btn'); const statusEl = $('stt-test-status'); const resultEl = $('stt-test-result'); if (!panel || !micBtn) return; // Populate backend dropdown async function refreshSttTestBackends() { try { const d = await fetch('/api/stt-backends').then(r => r.json()); const prev = sel.value; sel.innerHTML = (d.backends || []).map(b => `` ).join(''); if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) sel.value = prev; } catch(_) {} } refreshSttTestBackends(); if (!navigator.mediaDevices?.getUserMedia) { micBtn.disabled = true; micBtn.title = 'Microphone unavailable (requires HTTPS or localhost)'; return; } let mediaRecorder = null; let chunks = []; micBtn.addEventListener('click', async () => { if (mediaRecorder && mediaRecorder.state === 'recording') { mediaRecorder.stop(); return; } if (micBtn.classList.contains('busy')) return; chunks = []; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); }; mediaRecorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()); micBtn.className = 'stt-test-mic busy'; micBtn.innerHTML = ''; statusEl.textContent = 'Transcribing…'; resultEl.style.display = 'none'; const blob = new Blob(chunks, { type: 'audio/webm' }); const form = new FormData(); form.append('file', blob, 'audio.webm'); form.append('backend', sel.value || 'configured'); try { const r = await fetch('/api/transcribe-bytes', { method: 'POST', body: form }); const d = await r.json(); if (d.text !== undefined) { resultEl.textContent = d.text || '(no speech detected)'; statusEl.textContent = 'Done'; } else { resultEl.textContent = '⚠ ' + (d.detail || JSON.stringify(d)); statusEl.textContent = 'Error'; } } catch(e) { resultEl.textContent = '⚠ ' + e.message; statusEl.textContent = 'Error'; } resultEl.style.display = ''; micBtn.className = 'stt-test-mic'; micBtn.innerHTML = ''; }; mediaRecorder.start(); micBtn.className = 'stt-test-mic recording'; micBtn.innerHTML = ''; statusEl.textContent = 'Recording…'; resultEl.style.display = 'none'; } catch(e) { statusEl.textContent = 'Mic error'; toast('Microphone error: ' + e.message, 'error'); } }); })(); })(); // ── LLM snippet collapse ─────────────────────────────────────────────────── function initLlmSnippets(root) { (root || document).querySelectorAll('.llm-local-snippet:not([data-snip-init])').forEach(snip => { snip.dataset.snipInit = '1'; const bar = snip.querySelector('.llm-snippet-bar'); const pre = snip.querySelector('pre'); const copyBtn = snip.querySelector('.llm-copy-btn'); if (!bar || !pre) return; const labelEl = bar.querySelector('span:not(.llm-snip-chev)'); const origText = labelEl ? labelEl.textContent.trim() : 'snippet'; const cardName = snip.closest('.llm-local-card')?.querySelector('.llm-local-name')?.textContent?.trim() || ''; const key = 'snip-' + (cardName + '-' + origText).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 52); const chev = document.createElement('span'); chev.className = 'llm-snip-chev'; if (labelEl) bar.insertBefore(chev, labelEl); else bar.prepend(chev); const apply = (open) => { pre.hidden = !open; if (copyBtn) copyBtn.hidden = !open; if (labelEl) labelEl.textContent = open ? origText : 'Show code snippet'; chev.textContent = open ? '▾' : '▸'; }; const saved = localStorage.getItem(key); apply(saved === '1'); // default: collapsed bar.addEventListener('click', e => { if (copyBtn && (e.target === copyBtn || copyBtn.contains(e.target))) return; const nowOpen = pre.hidden; apply(nowOpen); try { localStorage.setItem(key, nowOpen ? '1' : ''); } catch {} }); }); } initLlmSnippets(document); // ── Docker management for static llm-local-cards ────────────────────────── // Finds every static card that has a [data-llm-local-key] URL input and // appends an optional "container name" row + Stop/Start/Restart buttons. // No HTML changes needed — the key is read from the existing input attribute. (function initStaticDockerManagement() { document.querySelectorAll('.llm-local-card:not([data-docker-init])').forEach(card => { const urlInp = card.querySelector('[data-llm-local-key]'); if (!urlInp) return; card.dataset.dockerInit = '1'; const key = urlInp.dataset.llmLocalKey; const saved = localStorage.getItem('llm-docker-name-' + key) || ''; // Inject the container name row right after the URL row const urlRow = urlInp.closest('.llm-local-url'); const dockerRow = document.createElement('div'); dockerRow.className = 'llm-local-url'; dockerRow.innerHTML = `` + ``; const nameInp = dockerRow.querySelector('input'); nameInp.value = saved; if (urlRow) urlRow.after(dockerRow); else card.querySelector('.llm-local-actions')?.before(dockerRow); // Placeholder for the docker action buttons (appended into existing actions row) const actionsEl = card.querySelector('.llm-local-actions'); const dockerBtnsEl = document.createElement('span'); actionsEl?.appendChild(dockerBtnsEl); function renderBtns() { const name = nameInp.value.trim(); if (!name) { dockerBtnsEl.innerHTML = ''; return; } const esc = name.replace(/&/g,'&').replace(/"/g,'"'); dockerBtnsEl.innerHTML = `` + `` + ``; dockerBtnsEl.querySelectorAll('.dc-btn').forEach(btn => { btn.addEventListener('click', async () => { const action = btn.dataset.dcAction; const cname = btn.dataset.dcName; const orig = btn.innerHTML; btn.disabled = true; btn.textContent = action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Restarting…'; try { const r = await fetch(`/api/local-containers/${encodeURIComponent(cname)}/${action}`, { method: 'POST' }); const d = await r.json(); if (d.ok) toast(`${cname}: ${action} OK`, 'success'); else toast(d.error || `${action} failed`, 'error'); } catch (e) { toast(`${action} failed: ${e.message}`, 'error'); } btn.disabled = false; btn.innerHTML = orig; }); }); } nameInp.addEventListener('input', () => { localStorage.setItem('llm-docker-name-' + key, nameInp.value); renderBtns(); }); renderBtns(); }); })(); // ── Collapsible cards ────────────────────────────────────────────────────── (function initCollapsibleCards() { const LS_KEY = 'card-collapse-v1'; const load = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } }; const save = (k, v) => { const s = load(); s[k] = v; try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {} }; const slug = t => t.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48); // Sync existing
elements inside .card to localStorage document.querySelectorAll('.card > details').forEach(det => { const sum = det.querySelector('summary'); if (!sum) return; const key = 'det-' + slug(sum.textContent); const st = load(); if (st[key] !== undefined) det.open = st[key]; else st[key] = det.open; // persist current default det.addEventListener('toggle', () => save(key, det.open)); }); // Add collapse toggle to every .card that has a direct h2 child document.querySelectorAll('.card > h2').forEach(h2 => { const card = h2.parentElement; if (!card || card.dataset.colInit) return; card.dataset.colInit = '1'; const key = 'card-' + slug(h2.textContent); const open = load()[key] !== false; // default: open // Prepend a rotating chevron inside the h2 const chev = document.createElement('span'); chev.className = 'card-chev'; chev.setAttribute('aria-hidden', 'true'); h2.prepend(chev); h2.classList.add('card-collapse-h2'); // Wrap every element after h2 (skipping .card-subtitle which stays visible) in body const body = document.createElement('div'); body.className = 'card-col-body'; let sib = h2.nextElementSibling; while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling; while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; } card.appendChild(body); const apply = (isOpen) => { body.hidden = !isOpen; card.classList.toggle('card-col-closed', !isOpen); }; apply(open); h2.addEventListener('click', () => { const nowOpen = body.hidden; // hidden → about to open apply(nowOpen); save(key, nowOpen); }); }); })(); // ── Collapsible integration cards (collapsed by default) ─────────────────── (function initIntegrationCards() { const LS_KEY = 'icard-collapse-v1'; const load = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } }; const save = (k, v) => { const s = load(); s[k] = v; try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {} }; const slug = t => t.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48); document.querySelectorAll('.integration-card').forEach(card => { const h3 = card.querySelector('h3'); if (!h3 || card.dataset.icardInit) return; card.dataset.icardInit = '1'; const key = 'icard-' + slug(h3.textContent); const open = load()[key] === true; // default: closed // Prepend logo — favicon img or MDI icon (skip if h3 already has a leading .mdi span) const hasIcon = h3.firstElementChild && h3.firstElementChild.classList.contains('mdi'); const faviconUrl = card.dataset.favicon; const iconClass = card.dataset.icon; if (!hasIcon) { if (faviconUrl) { const img = document.createElement('img'); img.className = 'icard-logo'; img.src = faviconUrl; img.alt = ''; img.onerror = () => { img.style.display = 'none'; }; h3.prepend(img); } else if (iconClass) { const span = document.createElement('span'); span.className = 'icard-icon ' + iconClass; h3.prepend(span); } } else if (faviconUrl) { // Has existing .mdi span but also has a favicon — add favicon before the mdi span const img = document.createElement('img'); img.className = 'icard-logo'; img.src = faviconUrl; img.alt = ''; img.onerror = () => { img.style.display = 'none'; }; h3.prepend(img); } // Append chevron const chev = document.createElement('span'); chev.className = 'icard-chev'; chev.setAttribute('aria-hidden', 'true'); h3.appendChild(chev); // Wrap all siblings after h3 in a body div const body = document.createElement('div'); body.className = 'icard-body'; let sib = h3.nextElementSibling; while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; } card.appendChild(body); const apply = (isOpen) => { body.hidden = !isOpen; card.classList.toggle('icard-closed', !isOpen); }; apply(open); h3.addEventListener('click', () => { const nowOpen = body.hidden; apply(nowOpen); save(key, nowOpen); }); }); })(); // ── WAV merge utility (chunked TTS + playlist export) ────────────────────── async function mergeWavBlobs(blobs) { if (!blobs || blobs.length === 0) return null; if (blobs.length === 1) return blobs[0]; function parseWav(bytes) { const v = new DataView(bytes.buffer); let off = 12, fmt = null, dataOff = 0, dataSize = 0; while (off + 8 <= bytes.length) { const id = v.getUint32(off, false); const sz = v.getUint32(off + 4, true); if (id === 0x666d7420) { fmt = { channels: v.getUint16(off+10,true), sampleRate: v.getUint32(off+12,true), bitDepth: v.getUint16(off+22,true) }; } else if (id === 0x64617461) { dataOff = off + 8; dataSize = sz; } off += 8 + sz; } return { fmt, dataOff, dataSize }; } const parsed = []; for (const b of blobs) { const bytes = new Uint8Array(await b.arrayBuffer()); const p = parseWav(bytes); if (!p.fmt) throw new Error('Invalid WAV in chunk'); parsed.push({ bytes, ...p }); } const ref = parsed[0].fmt; const totalPcm = parsed.reduce((s, p) => s + p.dataSize, 0); const out = new Uint8Array(44 + totalPcm); const dv = new DataView(out.buffer); dv.setUint32(0, 0x52494646, false); dv.setUint32(4, 36 + totalPcm, true); dv.setUint32(8, 0x57415645, false); dv.setUint32(12, 0x666d7420, false); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, ref.channels, true); dv.setUint32(24, ref.sampleRate, true); dv.setUint32(28, ref.sampleRate * ref.channels * (ref.bitDepth >> 3), true); dv.setUint16(32, ref.channels * (ref.bitDepth >> 3), true); dv.setUint16(34, ref.bitDepth, true); dv.setUint32(36, 0x64617461, false); dv.setUint32(40, totalPcm, true); let pos = 44; for (const p of parsed) { out.set(p.bytes.slice(p.dataOff, p.dataOff + p.dataSize), pos); pos += p.dataSize; } return new Blob([out], { type: 'audio/wav' }); } // ── Chunked TTS ──────────────────────────────────────────────────────────── function splitTextIntoChunks(text, maxLen = 800) { const abbrev = /\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|e\.g|i\.e)\.\s/g; const safe = text.replace(abbrev, m => m.replace('.', '\x00')); const parts = safe.match(/[^.!?]+[.!?]+\s*/g) || []; const last = safe.replace(/[^.!?]+[.!?]+\s*/g, '').trim(); if (last) parts.push(last); const restore = s => s.replace(/\x00/g, '.'); if (!parts.length) return [text]; const chunks = []; let cur = ''; for (const p of parts) { if ((cur + p).length > maxLen && cur) { chunks.push(restore(cur.trim())); cur = p; } else cur += p; } if (cur.trim()) chunks.push(restore(cur.trim())); return chunks.length ? chunks : [text]; } async function generateChunkedTts(voice, text, backend, instruct) { const chunks = splitTextIntoChunks(text); const prog = $('preview-chunk-progress'); if (prog) { prog.hidden = false; prog.textContent = `Chunk 1 / ${chunks.length}…`; } const blobs = []; for (let i = 0; i < chunks.length; i++) { if (prog) prog.textContent = `Chunk ${i + 1} / ${chunks.length}…`; blobs.push(await fetchTtsPreviewBlob(voice, chunks[i], 'wav', instruct, backend)); } if (prog) prog.textContent = 'Merging…'; const merged = await mergeWavBlobs(blobs); if (prog) { prog.hidden = true; prog.textContent = ''; } return { url: URL.createObjectURL(merged), blob: merged, streaming: false, label: 'chunked' }; } // ── Generation history ───────────────────────────────────────────────────── const _genHistory = []; function historyPush(voice, text, backend, blob, url) { const id = Date.now() + '-' + Math.random().toString(36).slice(2, 6); _genHistory.unshift({ id, ts: Date.now(), voice, text: text.slice(0, 200), backend, blob, url }); if (_genHistory.length > 20) _genHistory.pop(); renderHistory(); } function renderHistory() { const list = $('history-list'); if (!list) return; if (!_genHistory.length) { list.innerHTML = '
No generations yet.
'; return; } list.innerHTML = _genHistory.map(item => { const t = new Date(item.ts); const ts = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0'); const preview = escHtml(item.text.length > 90 ? item.text.slice(0,90) + '…' : item.text); return `
${escHtml(item.voice)} ${escHtml(item.backend)} ${ts}
${preview}
`; }).join(''); list.querySelectorAll('.hist-play-btn').forEach(btn => { btn.addEventListener('click', () => { const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid); if (!item?.url) return; const audio = $('preview-audio'); audio.src = item.url; audio.style.display = ''; audio.play().catch(()=>{}); }); }); list.querySelectorAll('.hist-reuse-btn').forEach(btn => { btn.addEventListener('click', () => { const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid); if (!item) return; $('preview-text-area').value = item.text; const bSel = $('tts-backend-select'); if (bSel) [...bSel.options].forEach(o => { if (o.value === item.backend) bSel.value = item.backend; }); const vSel = $('tts-voice-select'); if (vSel) [...vSel.options].forEach(o => { if (o.value === item.voice) vSel.value = item.voice; }); toast('Settings restored from history', 'success'); }); }); list.querySelectorAll('.hist-playlist-btn').forEach(btn => { btn.addEventListener('click', () => { const item = _genHistory.find(h => h.id === btn.closest('[data-hid]')?.dataset.hid); if (item?.blob) playlistAdd(item.voice, item.text, item.blob, item.url); }); }); } $('history-clear-btn')?.addEventListener('click', () => { _genHistory.length = 0; renderHistory(); toast('History cleared', 'success'); }); // ── Playlist ─────────────────────────────────────────────────────────────── const _playlist = []; function playlistAdd(voice, text, blob, url) { const id = 'pl-' + Date.now() + '-' + Math.random().toString(36).slice(2,5); _playlist.push({ id, voice, text: text.slice(0, 120), blob, url }); renderPlaylist(); if ($('playlist-export-btn')) $('playlist-export-btn').disabled = false; toast('Added to playlist', 'success'); } function renderPlaylist() { const list = $('playlist-list'); if (!list) return; if (!_playlist.length) { list.innerHTML = '
No clips in playlist. Use + Playlist after generating.
'; if ($('playlist-export-btn')) $('playlist-export-btn').disabled = true; return; } list.innerHTML = _playlist.map((item, i) => `
${i + 1}
${escHtml(item.voice)} ${escHtml(item.text.length > 70 ? item.text.slice(0,70)+'…' : item.text)}
`).join(''); list.querySelectorAll('.pl-rm').forEach(btn => { btn.addEventListener('click', () => { const pid = btn.closest('[data-pid]')?.dataset.pid; const idx = _playlist.findIndex(p => p.id === pid); if (idx >= 0) { _playlist.splice(idx, 1); renderPlaylist(); } }); }); list.querySelectorAll('.pl-up').forEach(btn => { btn.addEventListener('click', () => { const pid = btn.closest('[data-pid]')?.dataset.pid; const idx = _playlist.findIndex(p => p.id === pid); if (idx > 0) { [_playlist[idx-1], _playlist[idx]] = [_playlist[idx], _playlist[idx-1]]; renderPlaylist(); } }); }); list.querySelectorAll('.pl-dn').forEach(btn => { btn.addEventListener('click', () => { const pid = btn.closest('[data-pid]')?.dataset.pid; const idx = _playlist.findIndex(p => p.id === pid); if (idx < _playlist.length - 1) { [_playlist[idx], _playlist[idx+1]] = [_playlist[idx+1], _playlist[idx]]; renderPlaylist(); } }); }); } $('add-to-playlist-btn')?.addEventListener('click', () => { if (!previewBlob) { toast('Generate audio first', 'error'); return; } playlistAdd( window._previewVoice || $('tts-voice-select')?.value || '', window._previewText || $('preview-text-area')?.value || '', previewBlob, $('preview-audio')?.src || '' ); }); $('playlist-export-btn')?.addEventListener('click', async () => { if (!_playlist.length) return; const btn = $('playlist-export-btn'), orig = btn.textContent; btn.disabled = true; btn.textContent = 'Merging…'; try { const merged = await mergeWavBlobs(_playlist.map(p => p.blob).filter(Boolean)); if (!merged) throw new Error('No audio to export'); const a = document.createElement('a'); a.href = URL.createObjectURL(merged); a.download = 'playlist_' + Date.now() + '.wav'; a.click(); toast('Playlist exported as WAV', 'success'); } catch(e) { toast('Export failed: ' + e.message, 'error'); } finally { btn.disabled = _playlist.length === 0; btn.textContent = orig; } }); $('playlist-clear-btn')?.addEventListener('click', () => { _playlist.length = 0; renderPlaylist(); toast('Playlist cleared', 'success'); }); // ── Audio effects panel ──────────────────────────────────────────────────── const _FX_PRESETS = { studio: { reverb: { on:true, room_size:0.6, wet:0.35 }, compressor: { on:true, threshold_db:-18, ratio:3 } }, broadcast: { compressor: { on:true, threshold_db:-12, ratio:6 } }, telephone: { compressor: { on:true, threshold_db:-10, ratio:8 } }, warm: { reverb: { on:true, room_size:0.2, wet:0.15 }, compressor: { on:true, threshold_db:-20, ratio:2 } }, radio: { compressor: { on:true, threshold_db:-14, ratio:5 } }, }; function fxSliderBind(sliderId, labelId, fmt) { const s = $(sliderId), l = $(labelId); if (!s || !l) return; const upd = () => { l.textContent = fmt(s.value); }; upd(); s.addEventListener('input', upd); } fxSliderBind('fx-reverb-room', 'fx-reverb-room-val', v => parseFloat(v).toFixed(2)); fxSliderBind('fx-reverb-wet', 'fx-reverb-wet-val', v => parseFloat(v).toFixed(2)); fxSliderBind('fx-comp-thresh', 'fx-comp-thresh-val', v => v + ' dB'); fxSliderBind('fx-comp-ratio', 'fx-comp-ratio-val', v => v + ':1'); fxSliderBind('fx-chorus-rate', 'fx-chorus-rate-val', v => parseFloat(v).toFixed(1) + ' Hz'); fxSliderBind('fx-chorus-mix', 'fx-chorus-mix-val', v => parseFloat(v).toFixed(2)); fxSliderBind('fx-pitch-semi', 'fx-pitch-semi-val', v => (parseFloat(v) >= 0 ? '+' : '') + v + ' st'); $('effects-preset')?.addEventListener('change', () => { const preset = _FX_PRESETS[$('effects-preset').value]; if (!preset) return; ['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on'].forEach(id => { const el=$(id); if(el) el.checked=false; }); if (preset.reverb) { $('fx-reverb-on').checked = !!preset.reverb.on; if (preset.reverb.room_size != null) $('fx-reverb-room').value = preset.reverb.room_size; if (preset.reverb.wet != null) $('fx-reverb-wet').value = preset.reverb.wet; } if (preset.compressor) { $('fx-compressor-on').checked = !!preset.compressor.on; if (preset.compressor.threshold_db != null) $('fx-comp-thresh').value = preset.compressor.threshold_db; if (preset.compressor.ratio != null) $('fx-comp-ratio').value = preset.compressor.ratio; } ['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi'] .forEach(id => $(id)?.dispatchEvent(new Event('input'))); }); $('effects-reset-btn')?.addEventListener('click', () => { $('effects-preset').value = ''; ['fx-reverb-on','fx-compressor-on','fx-chorus-on','fx-pitch-on'].forEach(id => { const el=$(id); if(el) el.checked=false; }); $('fx-reverb-room').value = '0.35'; $('fx-reverb-wet').value = '0.25'; $('fx-comp-thresh').value = '-20'; $('fx-comp-ratio').value = '4'; $('fx-chorus-rate').value = '1'; $('fx-chorus-mix').value = '0.5'; $('fx-pitch-semi').value = '0'; ['fx-reverb-room','fx-reverb-wet','fx-comp-thresh','fx-comp-ratio','fx-chorus-rate','fx-chorus-mix','fx-pitch-semi'] .forEach(id => $(id)?.dispatchEvent(new Event('input'))); }); let _effectsSourceBlob = null; $('effects-apply-btn')?.addEventListener('click', async () => { const blob = previewBlob || _effectsSourceBlob; if (!blob) { toast('Generate audio first', 'error'); return; } const chain = []; if ($('fx-reverb-on')?.checked) chain.push({ type:'reverb', params: { room_size: +$('fx-reverb-room').value, wet: +$('fx-reverb-wet').value, dry: 1 - +$('fx-reverb-wet').value } }); if ($('fx-compressor-on')?.checked) chain.push({ type:'compressor', params: { threshold_db: +$('fx-comp-thresh').value, ratio: +$('fx-comp-ratio').value } }); if ($('fx-chorus-on')?.checked) chain.push({ type:'chorus', params: { rate_hz: +$('fx-chorus-rate').value, mix: +$('fx-chorus-mix').value } }); if ($('fx-pitch-on')?.checked) chain.push({ type:'pitch_shift', params: { semitones: +$('fx-pitch-semi').value } }); if (!chain.length) { toast('Enable at least one effect', 'error'); return; } const btn = $('effects-apply-btn'), st = $('effects-status'); btn.disabled = true; if (st) st.textContent = 'Processing…'; try { const fd = new FormData(); fd.append('audio', blob, 'audio.wav'); fd.append('effects', JSON.stringify(chain)); const resp = await fetch('/api/audio/effects', { method:'POST', body: fd }); if (!resp.ok) { const e = await resp.json().catch(()=>({})); throw new Error(e.detail || resp.statusText); } const out = await resp.blob(); if (!_effectsSourceBlob) _effectsSourceBlob = previewBlob; previewBlob = out; const audio = $('preview-audio'); audio.src = URL.createObjectURL(out); audio.style.display = ''; audio.play().catch(()=>{}); if (st) st.textContent = 'Applied.'; toast('Effects applied', 'success'); } catch(e) { if (st) st.textContent = ''; toast('Effects failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); // ── LLM refinement ───────────────────────────────────────────────────────── let _refineOriginal = null; (function initLlmRefinement() { const inp = $('refine-llm-url'); if (!inp) return; // loadSettings() will overwrite with the server value; localStorage is the fast initial fallback const saved = (_appSettings && _appSettings.refine_llm_url) || localStorage.getItem('refine-llm-url'); if (saved) inp.value = saved; else inp.value = (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls['ollama']) || localStorage.getItem('llm-local-url-ollama') || 'http://localhost:11434/v1'; inp.addEventListener('input', () => { localStorage.setItem('refine-llm-url', inp.value); _patchSettings({ refine_llm_url: inp.value }); if (_appSettings) _appSettings.refine_llm_url = inp.value; }); })(); function updateRefineButtonState() { const btn = $('refine-btn'); if (btn) btn.disabled = !$('stt-tts-text')?.value?.trim(); } $('stt-tts-text')?.addEventListener('input', updateRefineButtonState); $('refine-btn')?.addEventListener('click', async () => { const text = $('stt-tts-text')?.value?.trim(); if (!text) return; const btn = $('refine-btn'), st = $('refine-status'); btn.disabled = true; if (st) st.textContent = 'Refining…'; try { const r = await fetch('/api/refine-text', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text, llm_url: $('refine-llm-url')?.value?.trim() || 'http://localhost:11434/v1', model: $('refine-model')?.value?.trim() || '', toggles: { fillers: $('refine-fillers')?.checked ?? true, repetitions: $('refine-repetitions')?.checked ?? true, corrections: $('refine-corrections')?.checked ?? true, punctuation: $('refine-punctuation')?.checked ?? true, }, }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _refineOriginal = text; $('stt-tts-text').value = d.text; if ($('refine-restore-btn')) $('refine-restore-btn').disabled = false; if (st) st.textContent = 'Done.'; toast('Transcription refined', 'success'); } catch(e) { if (st) st.textContent = ''; toast('Refinement failed: ' + e.message, 'error'); } finally { btn.disabled = !$('stt-tts-text')?.value?.trim(); } }); $('refine-restore-btn')?.addEventListener('click', () => { if (!_refineOriginal) return; $('stt-tts-text').value = _refineOriginal; _refineOriginal = null; if ($('refine-restore-btn')) $('refine-restore-btn').disabled = true; if ($('refine-status')) $('refine-status').textContent = ''; toast('Original transcription restored', 'success'); }); // ── Settings: Logs ──────────────────────────────────────────────────────── let _logsLiveTimer = null; let _logsActiveLevel = ''; function escLog(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); } window.loadSettingsLogs = async function() { const viewer = $('s-log-viewer'); if (!viewer) return; try { const data = await fetch('/api/logs?limit=300').then(r => r.json()); const items = (data.items || []).filter(item => !_logsActiveLevel || item.level === _logsActiveLevel ); const count = $('s-log-count'); if (count) count.textContent = items.length + ' entries'; if (!items.length) { viewer.innerHTML = '
No log entries
'; return; } viewer.innerHTML = items.map(item => { const ts = item.ts ? item.ts.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : ''; return `
${escLog(ts)} ${escLog(item.level)} ${escLog(item.msg)}
`; }).join(''); } catch(e) { viewer.innerHTML = `
Failed to load logs: ${escLog(e.message)}
`; } }; $('s-logs-refresh-btn')?.addEventListener('click', () => loadSettingsLogs()); $('s-logs-clear-btn')?.addEventListener('click', async () => { await fetch('/api/logs', { method: 'DELETE' }); const viewer = $('s-log-viewer'); if (viewer) viewer.innerHTML = '
Logs cleared
'; const count = $('s-log-count'); if (count) count.textContent = ''; }); $('s-logs-live-toggle')?.addEventListener('change', function() { clearInterval(_logsLiveTimer); if (this.checked) { loadSettingsLogs(); _logsLiveTimer = setInterval(loadSettingsLogs, 3000); } }); document.querySelectorAll('.s-log-filter').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.s-log-filter').forEach(b => b.classList.remove('is-active')); this.classList.add('is-active'); _logsActiveLevel = this.dataset.logLevel; loadSettingsLogs(); }); }); // ── Settings: About ─────────────────────────────────────────────────────── function renderSettingsAbout() { const el = $('s-about-backends'); if (!el) return; const available = new Set((_ttsBackends || []).filter(b => b.available).map(b => b.id)); const all = (_ttsBackends || []); if (!all.length) { el.innerHTML = ''; return; } el.innerHTML = all.map(b => { const online = available.has(b.id); return ` ${escHtml(b.label)} `; }).join(''); } // ── Voices import ────────────────────────────────────────────────────────── $('s-import-voices-file')?.addEventListener('change', async function () { const file = this.files?.[0]; if (!file) return; const st = $('s-import-status'); if (st) st.textContent = 'Uploading…'; try { const fd = new FormData(); fd.append('file', file); const r = await fetch('/api/voices/import', { method:'POST', body: fd }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); if (st) st.textContent = `Imported ${d.imported} files.`; toast(`Imported ${d.imported} voice files`, 'success'); this.value = ''; } catch(e) { if (st) st.textContent = 'Import failed.'; toast('Import failed: ' + e.message, 'error'); } }); // ── Conversation Playground ──────────────────────────────────────────────── (function initConversationPlayground() { const chatWindow = $('conv-chat-window'); const micBtn = $('conv-mic-btn'); const micIcon = $('conv-mic-icon'); const micStatus = $('conv-mic-status'); const micTimer = $('conv-mic-timer'); const clearBtn = $('conv-clear-btn'); const sttSel = $('conv-stt-select'); const llmUrlInp = $('conv-llm-url'); const llmFetchBtn = $('conv-llm-fetch-btn'); const llmModelSel = $('conv-llm-model-select'); const ttsBkSel = $('conv-tts-backend-select'); const ttsFetchBtn = $('conv-tts-fetch-btn'); const ttsVoiceSel = $('conv-tts-voice-select'); const systemPrompt = $('conv-system-prompt'); const turnHistory = $('conv-turn-history'); if (!chatWindow || !micBtn) return; // Warn if microphone API is unavailable (HTTP on non-localhost = insecure context) if (!navigator.mediaDevices?.getUserMedia) { if (micStatus) { micStatus.textContent = 'Mic unavailable — insecure context'; micStatus.style.color = 'var(--red, #e05)'; } if (micBtn) { micBtn.disabled = true; micBtn.title = 'Browser blocks microphone on HTTP. Use http://localhost:7890 or HTTPS.\n' + 'Chrome fix: chrome://flags/#unsafely-treat-insecure-origin-as-secure'; micBtn.style.opacity = '0.4'; } const warn = document.createElement('div'); warn.style.cssText = 'background:var(--red,#c00);color:#fff;padding:10px 16px;border-radius:8px;margin:12px 0;font-size:13px;line-height:1.5'; warn.innerHTML = '⚠ Microphone blocked by browser
' + 'Browsers only allow microphone access on secure contexts (HTTPS or localhost).
' + 'Quick fix: open the app at http://localhost:7890 instead of the IP address.
' + 'Remote access fix: add the URL in chrome://flags/#unsafely-treat-insecure-origin-as-secure'; chatWindow && chatWindow.parentElement && chatWindow.parentElement.insertBefore(warn, chatWindow); } // Restore conv LLM URL from server settings if (llmUrlInp && _appSettings && _appSettings.conv_llm_url) llmUrlInp.value = _appSettings.conv_llm_url; if (llmUrlInp) llmUrlInp.addEventListener('input', () => { _patchSettings({ conv_llm_url: llmUrlInp.value }); if (_appSettings) _appSettings.conv_llm_url = llmUrlInp.value; }); let mediaRecorder = null; let recChunks = []; let recTimerInterval = null; let recStart = 0; let conversationHistory = []; let turnCount = 0; let isProcessing = false; // ── Populate STT backends ──────────────────────────────────────────────── async function loadConvSttBackends() { if (!sttSel) return; const prev = sttSel.value; try { const d = await fetch('/api/stt-backends').then(r => r.json()); const all = d.backends || []; if (!all.length) { sttSel.innerHTML = ''; return; } sttSel.innerHTML = all.map(b => { const icon = b.available ? '✓' : '✗'; const label = `${icon} ${escHtml(b.label)}`; const disabled = !b.available; return ``; }).join(''); // Restore prev selection or pick first available const opt = sttSel.querySelector(`option[value="${CSS.escape(prev)}"]`); if (opt && !opt.disabled) { sttSel.value = prev; } else { const first = sttSel.querySelector('option:not([disabled])'); if (first) sttSel.value = first.value; } } catch(_) { sttSel.innerHTML = ''; } } // ── Populate TTS backends (reuse global _ttsBackends) ─────────────────── function populateConvTtsBackends() { if (!ttsBkSel) return; const prev = ttsBkSel.value; const all = _ttsBackends || []; if (!all.length) { ttsBkSel.innerHTML = ''; return; } ttsBkSel.innerHTML = all.map(b => { const icon = b.available ? '✓' : '✗'; return ``; }).join(''); const opt = ttsBkSel.querySelector(`option[value="${CSS.escape(prev)}"]`); if (opt && !opt.disabled) { ttsBkSel.value = prev; } else { const first = ttsBkSel.querySelector('option:not([disabled])'); if (first) ttsBkSel.value = first.value; } } // ── Fetch LLM models ───────────────────────────────────────────────────── async function fetchLlmModels() { if (!llmModelSel) return; const url = llmUrlInp?.value.trim() || ''; llmFetchBtn.disabled = true; try { const d = await fetch('/api/conversation/llm-models' + (url ? '?url=' + encodeURIComponent(url) : '')).then(r => r.json()); const models = d.models || []; llmModelSel.innerHTML = models.length ? models.map(m => ``).join('') : ''; } catch(e) { llmModelSel.innerHTML = ''; } finally { llmFetchBtn.disabled = false; } } // ── Fetch TTS voices ───────────────────────────────────────────────────── async function fetchConvTtsVoices() { if (!ttsVoiceSel || !ttsBkSel) return; const backend = ttsBkSel.value; if (!backend) return; ttsFetchBtn.disabled = true; try { const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); ttsVoiceSel.innerHTML = rawVoices.length ? rawVoices.map(v => { const id = backendVoiceId(v); return ``; }).join('') : ''; } catch(e) { ttsVoiceSel.innerHTML = ''; } finally { ttsFetchBtn.disabled = false; } } // ── Chat bubble helpers ────────────────────────────────────────────────── function timeStr() { const now = new Date(); return now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0'); } function removeWelcome() { const w = chatWindow.querySelector('.conv-chat-welcome'); if (w) w.remove(); } function addBubble(role, text) { removeWelcome(); const wrap = document.createElement('div'); wrap.className = `conv-bubble-wrap conv-bubble-wrap--${role}`; const bubble = document.createElement('div'); bubble.className = `conv-bubble conv-bubble--${role}`; bubble.textContent = text || ''; const meta = document.createElement('div'); meta.className = 'conv-bubble-meta'; meta.textContent = timeStr(); wrap.appendChild(bubble); wrap.appendChild(meta); chatWindow.appendChild(wrap); chatWindow.scrollTop = chatWindow.scrollHeight; return bubble; } function addTypingBubble() { removeWelcome(); const wrap = document.createElement('div'); wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant'; wrap.id = 'conv-typing-wrap'; const bubble = document.createElement('div'); bubble.className = 'conv-bubble conv-bubble--assistant'; bubble.innerHTML = ''; wrap.appendChild(bubble); chatWindow.appendChild(wrap); chatWindow.scrollTop = chatWindow.scrollHeight; return bubble; } function addErrorBubble(msg) { removeWelcome(); const wrap = document.createElement('div'); wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant'; const bubble = document.createElement('div'); bubble.className = 'conv-bubble conv-bubble--error'; bubble.innerHTML = ` ${escHtml(msg)}`; wrap.appendChild(bubble); chatWindow.appendChild(wrap); chatWindow.scrollTop = chatWindow.scrollHeight; } // ── Stats panel ────────────────────────────────────────────────────────── function fmtMs(ms) { return ms == null ? '—' : ms >= 1000 ? (ms/1000).toFixed(2)+'s' : ms+'ms'; } function updateStatBar(id, val, maxVal) { const fill = $(id); if (fill) fill.style.width = maxVal > 0 ? Math.min(100, (val / maxVal) * 100) + '%' : '0%'; } function updateStats(stats) { const { stt_ms, llm_ttft_ms, llm_total_ms, tts_ms, total_ms } = stats; const max = total_ms || 1; const set = (valId, fillId, ms) => { const el = $(valId); if (el) el.textContent = fmtMs(ms); updateStatBar(fillId, ms || 0, max); }; set('cpv-stt', 'cpf-stt', stt_ms); set('cpv-ttft', 'cpf-ttft', llm_ttft_ms); set('cpv-llm', 'cpf-llm', llm_total_ms); set('cpv-tts', 'cpf-tts', tts_ms); set('cpv-total', 'cpf-total', total_ms); } function addHistoryItem(n, totalMs, ok) { const empty = turnHistory?.querySelector('.conv-history-empty'); if (empty) empty.remove(); const item = document.createElement('div'); item.className = 'conv-hist-item'; const cls = ok ? 'conv-hist-ok' : 'conv-hist-err'; const icon = ok ? 'mdi-check-circle-outline' : 'mdi-alert-outline'; item.innerHTML = `#${n} ${fmtMs(totalMs)}`; turnHistory.insertBefore(item, turnHistory.firstChild); } // ── Recording ──────────────────────────────────────────────────────────── function startRecTimer() { recStart = Date.now(); recTimerInterval = setInterval(() => { const s = Math.floor((Date.now() - recStart) / 1000); if (micTimer) micTimer.textContent = s + 's'; }, 500); } function stopRecTimer() { clearInterval(recTimerInterval); if (micTimer) micTimer.textContent = ''; } async function startRecording() { if (isProcessing) return; if (!navigator.mediaDevices?.getUserMedia) { toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' + 'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000); return; } let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch(e) { toast('Microphone access denied: ' + e.message, 'error'); return; } recChunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); }; mediaRecorder.onstop = () => { stream.getTracks().forEach(t => t.stop()); const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' }); processBlob(blob); }; mediaRecorder.start(); micBtn.classList.add('recording'); micIcon.className = 'mdi mdi-stop'; if (micStatus) micStatus.textContent = 'Recording… click to stop'; startRecTimer(); } function stopRecording() { if (!mediaRecorder || mediaRecorder.state === 'inactive') return; mediaRecorder.stop(); stopRecTimer(); micBtn.classList.remove('recording'); micBtn.classList.add('processing'); micIcon.className = 'mdi mdi-dots-horizontal'; if (micStatus) micStatus.textContent = 'Processing…'; isProcessing = true; } // ── Send turn via SSE ───────────────────────────────────────────────────── async function processBlob(blob) { turnCount++; const turnN = turnCount; const t0 = Date.now(); // Show user bubble with placeholder const userBubble = addBubble('user', '…'); const assistantBubble = addTypingBubble(); let assistantText = ''; let lastStats = null; const form = new FormData(); form.append('audio', blob, 'audio.webm'); form.append('stt_backend', sttSel?.value || 'configured'); form.append('llm_url', llmUrlInp?.value.trim() || ''); form.append('llm_model', llmModelSel?.value || ''); form.append('tts_backend', ttsBkSel?.value || 'voice_clone'); form.append('tts_voice', ttsVoiceSel?.value || ''); form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.'); form.append('history', JSON.stringify(conversationHistory.slice(-20))); try { const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form }); if (!resp.ok) throw new Error('Server error ' + resp.status); const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (!line.startsWith('data:')) continue; let evt; try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; } if (evt.type === 'transcript') { userBubble.textContent = evt.text || '(empty)'; if (micStatus) micStatus.textContent = 'Generating reply…'; } else if (evt.type === 'token') { if (assistantBubble.querySelector('.conv-typing')) { assistantBubble.innerHTML = ''; } assistantText += evt.delta; assistantBubble.textContent = assistantText; chatWindow.scrollTop = chatWindow.scrollHeight; } else if (evt.type === 'llm_done') { assistantText = evt.text || assistantText; assistantBubble.textContent = assistantText; if (micStatus) micStatus.textContent = 'Synthesising speech…'; } else if (evt.type === 'audio') { const mime = evt.mime || 'audio/wav'; const binStr = atob(evt.b64); const arr = new Uint8Array(binStr.length); for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i); const audioBlob = new Blob([arr], { type: mime }); const url = URL.createObjectURL(audioBlob); const audio = new Audio(url); audio.onended = () => URL.revokeObjectURL(url); audio.play().catch(() => {}); if (micStatus) micStatus.textContent = 'Speaking…'; } else if (evt.type === 'stats') { lastStats = evt; updateStats(evt); } else if (evt.type === 'done') { conversationHistory.push({ role: 'user', content: userBubble.textContent }); conversationHistory.push({ role: 'assistant', content: assistantText }); addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true); if (micStatus) micStatus.textContent = 'Ready'; } else if (evt.type === 'error') { const wrap = assistantBubble.closest('.conv-bubble-wrap'); if (wrap) wrap.remove(); const stage = evt.stage?.toUpperCase() || 'ERR'; let msg = evt.message || 'Unknown error'; // Surface the actual server error detail, not the raw HTTP noise const detailMatch = msg.match(/HTTP \d+:\s*(.+)/s); if (detailMatch) msg = detailMatch[1].trim(); // Truncate very long stack traces if (msg.length > 300) msg = msg.slice(0, 300) + '…'; addErrorBubble(`[${stage}] ${msg}`); addHistoryItem(turnN, Date.now() - t0, false); if (micStatus) micStatus.textContent = 'Error — ready'; } } } } catch(e) { const wrap = assistantBubble.closest('.conv-bubble-wrap'); if (wrap) wrap.remove(); addErrorBubble(e.message); addHistoryItem(turnN, Date.now() - t0, false); if (micStatus) micStatus.textContent = 'Error — ready'; } finally { isProcessing = false; micBtn.classList.remove('processing'); micIcon.className = 'mdi mdi-microphone'; } } // ── Wire up events ─────────────────────────────────────────────────────── micBtn.addEventListener('click', () => { if (isProcessing) return; if (mediaRecorder && mediaRecorder.state === 'recording') { stopRecording(); } else { startRecording(); } }); clearBtn?.addEventListener('click', () => { conversationHistory = []; turnCount = 0; chatWindow.innerHTML = '

Press the microphone button below and start talking.

'; if (turnHistory) turnHistory.innerHTML = '
No turns yet.
'; ['cpv-stt','cpv-ttft','cpv-llm','cpv-tts','cpv-total'].forEach(id => { const el = $(id); if(el) el.textContent='—'; }); ['cpf-stt','cpf-ttft','cpf-llm','cpf-tts','cpf-total'].forEach(id => { const el = $(id); if(el) el.style.width='0%'; }); }); llmFetchBtn?.addEventListener('click', fetchLlmModels); ttsFetchBtn?.addEventListener('click', fetchConvTtsVoices); // Re-populate TTS when backend changes ttsBkSel?.addEventListener('change', () => { ttsVoiceSel.innerHTML = ''; }); // ── Init ───────────────────────────────────────────────────────────────── loadConvSttBackends(); populateConvTtsBackends(); // Keep TTS backend select in sync after global backend refresh window._ttsRefreshHooks = window._ttsRefreshHooks || []; window._ttsRefreshHooks.push(populateConvTtsBackends); })();