// ── 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 = `
`;
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.
Save persona
`;
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);
}