// ── 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

'; if (typeof window.onMobileInspectorClose === 'function') window.onMobileInspectorClose(); return; } _selectedVoiceWrap = wrap; wrap.classList.add('vr-selected'); if (typeof window.onMobileInspectorOpen === 'function') window.onMobileInspectorOpen(); 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 inspBenchText = typeof fmtBenchmark === 'function' ? fmtBenchmark(v) : '-'; const inspBenchTitle = typeof benchmarkTitle === 'function' ? benchmarkTitle(v) : ''; const inspBenchCls = typeof benchmarkClass === 'function' ? benchmarkClass(v) : ''; 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'} ${inspBenchText !== '-' ? ` ${escHtml(inspBenchText)}` : ''} ${makeStars(rating)} ${rating}/5
`; // Inject mobile back button after HTML is set (inspector DOM recreated above) if (window.innerWidth <= 767 && typeof window.onMobileInspectorOpen === 'function') { window.onMobileInspectorOpen(); } 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); }