Removes 43 small Sonar findings (unused locals, dead helpers, a misread comment); behaviour unchanged. CHANGELOG entry was already swept into 9f78a15.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
618 lines
32 KiB
JavaScript
618 lines
32 KiB
JavaScript
// ── Voice inspector (3-pane workbench) ───────────────────────────────────
|
|
(function (root) {
|
|
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 = '<div class="inspector-placeholder"><span><span class="mdi mdi-account-voice"></span></span><p>Pick a voice on the left<br>to edit it here</p></div>';
|
|
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 curGender = v.gender || 'N';
|
|
|
|
const nameParts = voiceId.split('_');
|
|
// Prefer an explicit display name (e.g. the character name for Rehearser voices);
|
|
// otherwise fall back to the last segment of the ID (EN_F_Anna → "Anna").
|
|
const dispName = (v.name && String(v.name).trim())
|
|
? String(v.name).trim()
|
|
: (nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId);
|
|
const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
|
|
const inspFlagCc = flagCc || langCode;
|
|
const inspFlagIcon = inspFlagCc
|
|
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
|
|
|
|
const defaultSilClass = curGender === 'M' ? 'mdi-face-man' : (curGender === 'F' ? 'mdi-face-woman' : 'mdi-account');
|
|
const silHtml = `<span class="mdi ${defaultSilClass}" style="color:#fff;font-size:32px;display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:${color}"></span>`;
|
|
|
|
const inspIconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 48) : null;
|
|
const avatarHtml = picSrcInsp
|
|
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
|
: inspIconHtml || silHtml;
|
|
const avatarBgStyle = (picSrcInsp || inspIconHtml) ? '' : `style="background:${color}"`;
|
|
|
|
const flagIconHtml = inspFlagIcon || '';
|
|
|
|
const makeStars = n => [1,2,3,4,5].map(i =>
|
|
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
|
|
).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 genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
|
|
|
|
inspector.innerHTML = `
|
|
<div class="inspector-header">
|
|
<div class="insp-hd-top">
|
|
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
|
|
<div class="insp-title-stack">
|
|
<div class="insp-hd-row1">
|
|
<h3 class="insp-disp-name" title="Double-click to rename the display name">${escHtml(dispName)}</h3>
|
|
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
|
|
<div class="insp-actions-save"></div>
|
|
</div>
|
|
<div class="insp-hd-row2">
|
|
<div class="insp-id-block">
|
|
<span class="insp-full-id" title="This is also the voice's filename on disk — click to rename it (updates the .wav, .json and .txt files too)">${escHtml(voiceId)}</span>
|
|
<button class="insp-edit-id-btn" type="button" title="Rename this voice's files (.wav, .json, .txt) — the voice ID is its filename"><span class="mdi mdi-pencil-outline"></span> rename filename</button>
|
|
<button class="insp-copy-id-btn" type="button" title="Copy filename">copy filename</button>
|
|
</div>
|
|
<div class="insp-hd-row2-right" style="display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
|
|
<div class="insp-actions-active"></div>
|
|
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; display:none;"></div>`}
|
|
</div>
|
|
</div>
|
|
<div class="insp-hd-divider"></div>
|
|
<div class="insp-subtitle">
|
|
<span class="insp-flag" title="Click to change accent flag">
|
|
<span class="insp-flag-icon">${flagIconHtml}</span>
|
|
</span>
|
|
<span class="insp-lang-wrap" title="Click to change language">
|
|
<span class="insp-lang-code">${escHtml(langCode)}</span>
|
|
</span>
|
|
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
|
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
|
${window.provenanceBadge ? window.provenanceBadge(v) : ''}
|
|
${inspBenchText !== '-' ? `<span class="insp-bench-chip ${escHtml(inspBenchCls)}" title="${escHtml(inspBenchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(inspBenchText)}</span>` : ''}
|
|
<span class="insp-stars-wrap">
|
|
<span class="insp-stars">${makeStars(rating)}</span>
|
|
<span class="insp-rating-label">${rating}/5</span>
|
|
</span>
|
|
<input class="insp-tag-input" type="text" placeholder="tag1, tag2…" value="${escHtml(v.tag||'')}" autocomplete="off">
|
|
</div>
|
|
<div class="insp-note-delete-row">
|
|
<div class="insp-note-slot"></div>
|
|
<div class="insp-actions-delete"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="inspector-body"></div>
|
|
`;
|
|
|
|
// 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 ───────────────────────────────────────────
|
|
const inspAvatar = inspector.querySelector('.inspector-avatar');
|
|
inspAvatar.addEventListener('click', () => {
|
|
body.querySelector('.photo-input')?.click();
|
|
});
|
|
|
|
inspAvatar.addEventListener('dragenter', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
|
|
inspAvatar.addEventListener('dragover', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
|
|
inspAvatar.addEventListener('dragleave', () => inspAvatar.classList.remove('drag-over'));
|
|
inspAvatar.addEventListener('drop', async e => {
|
|
e.preventDefault();
|
|
inspAvatar.classList.remove('drag-over');
|
|
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
const photoInput = body.querySelector('.photo-input');
|
|
if (photoInput) {
|
|
photoInput.files = e.dataTransfer.files;
|
|
photoInput.dispatchEvent(new Event('change'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
let url = e.dataTransfer.getData('text/uri-list');
|
|
if (!url) {
|
|
const html = e.dataTransfer.getData('text/html');
|
|
if (html) {
|
|
const match = html.match(/src=["'](.*?)["']/);
|
|
if (match) url = match[1];
|
|
}
|
|
}
|
|
if (!url) url = e.dataTransfer.getData('text/plain');
|
|
|
|
if (url && /^https?:\/\//i.test(url)) {
|
|
if (typeof status === 'function') status('Downloading picture from URL...');
|
|
try {
|
|
const r = await fetch('/api/voice/picture-url', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({voice_id: voiceId, image_url: url})
|
|
});
|
|
if (!r.ok) throw new Error((await r.json()).detail);
|
|
|
|
// Trigger the photo update in the row
|
|
const photoCell = body.querySelector('.vr-photo');
|
|
if (photoCell) photoCell.dispatchEvent(new Event('update-photo'));
|
|
|
|
if (typeof toast === 'function') toast('Photo saved from URL', 'success');
|
|
if (typeof status === 'function') status('Photo saved successfully');
|
|
} catch(err) {
|
|
if (typeof toast === 'function') toast('Photo URL download failed: ' + err.message, 'error');
|
|
if (typeof status === 'function') status('Photo URL download failed');
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Copy ID button ────────────────────────────────────────────────────────
|
|
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
|
|
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
|
});
|
|
|
|
// ── Editing: the big header is the DISPLAY NAME (v.name); "edit ID" / the
|
|
// full id renames the underlying voice ID. ───────────────────────────────
|
|
const dispNameEl = inspector.querySelector('.insp-disp-name');
|
|
const nameEditEl = inspector.querySelector('.insp-name-edit');
|
|
const fullIdEl = inspector.querySelector('.insp-full-id');
|
|
let _editMode = null; // 'name' | 'id'
|
|
let _editCancelled = false;
|
|
|
|
const showEditor = (mode, val) => {
|
|
_editMode = mode; _editCancelled = false;
|
|
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
|
|
nameEditEl.style.display = 'block';
|
|
nameEditEl.placeholder = mode === 'name' ? 'Display name' : 'Voice ID';
|
|
nameEditEl.value = val; nameEditEl.focus(); nameEditEl.select();
|
|
};
|
|
const startNameEdit = () => showEditor('name', dispName); // edit display name
|
|
const startInspRename = () => showEditor('id', voiceId); // rename voice ID
|
|
|
|
const commitEdit = async () => {
|
|
const mode = _editMode; _editMode = null;
|
|
dispNameEl.style.display = ''; fullIdEl.style.display = '';
|
|
nameEditEl.style.display = 'none';
|
|
if (_editCancelled || !mode) return;
|
|
const val = nameEditEl.value.trim();
|
|
if (mode === 'id') {
|
|
if (!val || val === voiceId) return;
|
|
// selectVoice() physically moves .vr-main-row (which contains these
|
|
// rename controls) out of `wrap` and into #voices-inspector once a
|
|
// voice is open in the single-voice detail view — the same relocation
|
|
// already documented as the cause of a similar stale-`wrap` bug for
|
|
// the benchmark cell (see runBenchmark's fallback in voice-library.js).
|
|
// By the time "rename filename" is clicked here, `wrap` is the now-
|
|
// emptied original list container, so wrap.querySelector always
|
|
// returned null and the rename silently did nothing (confirmed live).
|
|
const rowInput = wrap.querySelector('.vr-name-input') || document.querySelector('#voices-inspector .vr-name-input');
|
|
const rowOk = wrap.querySelector('.rename-ok') || document.querySelector('#voices-inspector .rename-ok');
|
|
if (rowInput && rowOk) { rowInput.value = val; rowOk.click(); }
|
|
else if (typeof toast === 'function') toast('Rename control not found — try renaming from the voice list instead', 'error');
|
|
return;
|
|
}
|
|
// mode === 'name' → save a display name to the voice's metadata
|
|
if (val === dispName) return;
|
|
try {
|
|
const r = await fetch('/api/voice/meta', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice_id: voiceId, name: val }),
|
|
});
|
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
|
v.name = val;
|
|
dispNameEl.textContent = val || (voiceId.split('_').pop());
|
|
if (typeof toast === 'function') toast('Display name updated', 'success');
|
|
if (typeof renderVoiceList === 'function') renderVoiceList();
|
|
} catch (e) {
|
|
if (typeof toast === 'function') toast('Rename failed: ' + (e.message || e), 'error');
|
|
}
|
|
};
|
|
|
|
dispNameEl.addEventListener('dblclick', startNameEdit);
|
|
fullIdEl.addEventListener('dblclick', startInspRename);
|
|
fullIdEl.addEventListener('click', startInspRename);
|
|
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
|
|
nameEditEl.addEventListener('blur', commitEdit);
|
|
nameEditEl.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') nameEditEl.blur();
|
|
if (e.key === 'Escape') { _editCancelled = true; 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 = `<span class="fi fi-${cc.toLowerCase()}" role="img" aria-label="${cc}"></span>`;
|
|
if (flagIconEl) flagIconEl.innerHTML = fi;
|
|
await saveMeta(voiceId, { flag: cc });
|
|
};
|
|
flagSpan.addEventListener('click', () => {
|
|
// Show this language's accents first, then the complete world list — accent is
|
|
// decoupled from language, so e.g. an English voice can still be set to German.
|
|
const langList = FLAG_OPTIONS[langCode.toUpperCase()] || [];
|
|
const items = uniqueFlagOptions([langList, ALL_FLAGS]).map(([cc, name]) => [cc.toLowerCase(), name]);
|
|
createSearchablePicker(flagSpan, items, applyFlag, {
|
|
placeholder: 'Country or accent…',
|
|
renderItem: (cc, name) =>
|
|
`<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span><span>${escHtml(name)}</span>`,
|
|
});
|
|
});
|
|
|
|
// ── 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) =>
|
|
`<span class="ipi-code">${escHtml(l)}</span><span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`,
|
|
});
|
|
});
|
|
|
|
// ── 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 = `<span class="gender-sym">${_gM[ng]||'?'}</span><span class="gender-txt">${_gL[ng]||'—'}</span>`;
|
|
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 = `
|
|
<div class="insp-persona-head">
|
|
<span class="insp-persona-title">Character persona</span>
|
|
<span class="insp-persona-hint">Used by LLM to rewrite text in this voice's style. Leave empty to skip.</span>
|
|
</div>
|
|
<textarea class="insp-persona-input" rows="3" placeholder="e.g. calm British narrator with a warm tone, measured pace, formal vocabulary…">${escHtml(personaVal)}</textarea>
|
|
<div class="insp-persona-actions">
|
|
<button class="btn-primary btn-sm insp-persona-save-btn">Save persona</button>
|
|
<span class="insp-persona-status"></span>
|
|
</div>
|
|
`;
|
|
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'); }
|
|
});
|
|
|
|
// ── Source & consent (provenance) ─────────────────────────────────────────
|
|
attachProvenancePanel(v, voiceId, body);
|
|
|
|
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
|
if (maintTitle) {
|
|
maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
|
|
}
|
|
|
|
// ── 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');
|
|
});
|
|
});
|
|
|
|
// ── Seed Finder panel ────────────────────────────────────────────────────
|
|
if (typeof attachSeedFinder === 'function') {
|
|
attachSeedFinder(voiceId, body);
|
|
}
|
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
const PROV_BASES = [['unknown', 'Unknown'], ['own_voice', 'My own voice'], ['consented', 'Speaker consented'],
|
|
['licensed', 'Licensed'], ['public_domain', 'Public domain']];
|
|
|
|
// Warning chip for voices whose consent/source is not recorded (list rows + inspector).
|
|
root.provenanceBadge = v => ((v && v.provenance && v.provenance.basis) || 'unknown') === 'unknown'
|
|
? '<span class="vl-prov-badge" title="Source and consent not recorded - open the voice to set them"><span class="mdi mdi-alert-outline" aria-hidden="true"></span> Source unknown</span>'
|
|
: '';
|
|
|
|
function attachProvenancePanel(v, voiceId, body) {
|
|
const p = v.provenance || {};
|
|
const id = 'prov-' + voiceId.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
const panel = document.createElement('section');
|
|
panel.className = 'insp-persona-panel insp-prov-panel';
|
|
panel.setAttribute('aria-labelledby', id + '-h');
|
|
panel.innerHTML = `
|
|
<div class="insp-persona-head">
|
|
<h4 class="insp-persona-title" id="${id}-h">Source & consent</h4>
|
|
<span class="insp-persona-hint">Where this voice comes from and why you may use it. Stored locally in this voice's metadata only; nothing is sent anywhere. Generation is not blocked by this.</span>
|
|
</div>
|
|
<label for="${id}-basis">Basis</label>
|
|
<select id="${id}-basis" class="insp-prov-input">${PROV_BASES.map(([k, l]) =>
|
|
`<option value="${k}"${(p.basis || 'unknown') === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
|
<label for="${id}-source">Source (URL or description, no passwords)</label>
|
|
<input id="${id}-source" class="insp-prov-input" type="text" maxlength="500" autocomplete="off" value="${escHtml(p.source || '')}">
|
|
<label for="${id}-licence">Licence</label>
|
|
<input id="${id}-licence" class="insp-prov-input" type="text" maxlength="300" autocomplete="off" value="${escHtml(p.licence || '')}">
|
|
<label for="${id}-note">Consent statement or note (typed alternative to a recording)</label>
|
|
<textarea id="${id}-note" class="insp-persona-input" rows="2" maxlength="1000">${escHtml(p.note || '')}</textarea>
|
|
<div class="insp-prov-rec">
|
|
<button type="button" class="btn-secondary insp-prov-rec-btn" aria-pressed="false">Record spoken consent</button>
|
|
<audio class="insp-prov-audio" controls ${p.recorded_consent ? `src="/api/voices/${encodeURIComponent(voiceId)}/consent-recording"` : 'hidden'} aria-label="Recorded consent statement"></audio>
|
|
</div>
|
|
<div class="insp-persona-actions">
|
|
<button type="button" class="btn-primary btn-sm insp-prov-save">Save source & consent</button>
|
|
<span class="insp-persona-status" role="status" aria-live="polite"></span>
|
|
</div>`;
|
|
body.appendChild(panel);
|
|
const q = sel => panel.querySelector(sel);
|
|
const status = q('.insp-persona-status');
|
|
const say = (msg, err) => { status.textContent = msg; status.style.color = err ? 'var(--danger, #dc2626)' : ''; };
|
|
q('.insp-prov-save').addEventListener('click', async () => {
|
|
try {
|
|
const r = await fetch(`/api/voices/${encodeURIComponent(voiceId)}/provenance`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ basis: q(`#${id}-basis`).value, source: q(`#${id}-source`).value,
|
|
licence: q(`#${id}-licence`).value, note: q(`#${id}-note`).value }) });
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw new Error(d.detail || r.statusText);
|
|
v.provenance = d.provenance;
|
|
if (typeof window.invalidateVoiceLibraryCache === 'function') window.invalidateVoiceLibraryCache();
|
|
say('Saved.');
|
|
} catch (e) { say('Save failed: ' + e.message, true); }
|
|
});
|
|
const btn = q('.insp-prov-rec-btn');
|
|
let rec = null;
|
|
btn.addEventListener('click', async () => {
|
|
if (rec) { rec.stop(); return; }
|
|
if (!navigator.mediaDevices || !window.MediaRecorder) {
|
|
say('Recording is not supported in this browser. Type the consent statement in the note field instead.', true); return;
|
|
}
|
|
let stream;
|
|
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
|
catch (e) {
|
|
say(e && e.name === 'NotAllowedError'
|
|
? 'Microphone permission was denied. Allow it in the browser, or type the consent statement in the note field.'
|
|
: 'No usable microphone found. Type the consent statement in the note field instead.', true); return;
|
|
}
|
|
const chunks = [];
|
|
rec = new MediaRecorder(stream);
|
|
rec.ondataavailable = ev => { if (ev.data.size) chunks.push(ev.data); };
|
|
rec.onstop = async () => {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
rec = null; btn.textContent = 'Record spoken consent'; btn.setAttribute('aria-pressed', 'false');
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', new Blob(chunks, { type: 'audio/webm' }), 'consent.webm');
|
|
const r = await fetch(`/api/voices/${encodeURIComponent(voiceId)}/consent-recording`, { method: 'POST', body: fd });
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw new Error(d.detail || r.statusText);
|
|
v.provenance = d.provenance;
|
|
const a = q('.insp-prov-audio'); a.src = `/api/voices/${encodeURIComponent(voiceId)}/consent-recording?t=${Date.now()}`; a.hidden = false;
|
|
say('Consent recording saved.');
|
|
} catch (e) { say('Upload failed: ' + e.message, true); }
|
|
};
|
|
rec.start(); btn.textContent = 'Stop recording'; btn.setAttribute('aria-pressed', 'true');
|
|
say('Recording... say who you are, that you consent to this voice being cloned, and for what use.');
|
|
});
|
|
}
|
|
|
|
root.selectVoice = selectVoice;
|
|
root.TTVC = root.TTVC || {};
|
|
root.TTVC.voiceInspector = root.TTVC.voiceInspector || {};
|
|
Object.assign(root.TTVC.voiceInspector, { select: selectVoice });
|
|
})(window);
|