3529 lines
164 KiB
JavaScript
3529 lines
164 KiB
JavaScript
// ── 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 _VL_CACHE_KEY = 'ttsvc_vc';
|
||
|
||
function _vlCacheRead() {
|
||
try { return JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY) || 'null'); } catch (_) { return null; }
|
||
}
|
||
function _vlCacheWrite(voices) {
|
||
try { sessionStorage.setItem(_VL_CACHE_KEY, JSON.stringify(voices)); } catch (_) {}
|
||
}
|
||
function _vlCacheClear() {
|
||
try { sessionStorage.removeItem(_VL_CACHE_KEY); } catch (_) {}
|
||
}
|
||
window._vlCacheClear = _vlCacheClear;
|
||
const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''};
|
||
let _libraryFilterOptionsSig = '';
|
||
const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.';
|
||
|
||
const BENCHMARK_PRESETS = {
|
||
de: 'Die Welt ist voller Geschichten, die darauf warten, erzählt zu werden — von mutigen Helden und stillen Träumern.',
|
||
en: 'The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.',
|
||
de2: 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.',
|
||
en2: 'She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.',
|
||
reset: DEFAULT_BENCHMARK_SAMPLE_TEXT,
|
||
};
|
||
|
||
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');
|
||
});
|
||
const presetSel = $('benchmark-preset-select');
|
||
if (presetSel) {
|
||
presetSel.addEventListener('change', () => {
|
||
const key = presetSel.value;
|
||
if (!key || !BENCHMARK_PRESETS[key]) { presetSel.value = ''; return; }
|
||
sample.value = BENCHMARK_PRESETS[key];
|
||
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
|
||
presetSel.value = '';
|
||
status('Benchmark sample sentence loaded');
|
||
});
|
||
}
|
||
$('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');
|
||
});
|
||
}
|
||
|
||
// Returns the display-level source label for a voice.
|
||
// Explicit origin field wins; fallback to any recognised tag value.
|
||
function _displaySource(v) {
|
||
if (v.origin) return v.origin;
|
||
if (v.note) {
|
||
// Notes from Rehearser clones follow: "Rehearser · <script title> · <character> — …"
|
||
const m = v.note.match(/^Rehearser\s*·\s*(.+?)\s*·/);
|
||
if (m) return m[1].trim();
|
||
}
|
||
if (v.tag) {
|
||
const tags = v.tag.split(',').map(t => t.trim().toLowerCase());
|
||
if (tags.includes('fish-audio') || tags.includes('fishaudio')) return 'fish-audio';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
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':
|
||
case 'factor': return -(voiceFactor(v) ?? -999);
|
||
case 'elapsed': return voiceBenchmarkElapsed(v) ?? 999;
|
||
case 'bench_audio': return voiceBenchmarkAudioSec(v) ?? 999;
|
||
case 'wpm': return voiceWpm(v) ?? -1;
|
||
case 'transcript': return (v.transcript || '').toLowerCase();
|
||
case 'note': return (v.note || '').toLowerCase();
|
||
case 'source': return (_displaySource(v) || '').toLowerCase();
|
||
case 'seed': return v.seed != null ? v.seed : 9999999;
|
||
case 'tag': return (v.tag || '').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 toggleSortDir() {
|
||
_sortDir *= -1;
|
||
syncSortHeaders();
|
||
renderVoiceList();
|
||
}
|
||
|
||
function syncSortHeaders() {
|
||
document.querySelectorAll('.vl-header [data-sort], .vl-table-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 sel = document.getElementById('voice-sort-field');
|
||
if (sel && sel.value !== _sortField) sel.value = _sortField;
|
||
const dirBtn = document.getElementById('voice-sort-dir');
|
||
if (dirBtn) {
|
||
const icon = dirBtn.querySelector('.mdi');
|
||
if (icon) icon.className = _sortDir === 1 ? 'mdi mdi-arrow-up' : 'mdi mdi-arrow-down';
|
||
dirBtn.title = _sortDir === 1 ? 'Ascending — click to reverse' : 'Descending — click to reverse';
|
||
}
|
||
}
|
||
|
||
// Wire sort direction button via addEventListener (more reliable than inline onclick
|
||
// since the button is injected into the DOM after script execution).
|
||
document.addEventListener('click', e => {
|
||
if (e.target.closest('#voice-sort-dir')) toggleSortDir();
|
||
if (e.target.closest('#voice-group-tag-btn')) {
|
||
window._voiceGroupByTag = !window._voiceGroupByTag;
|
||
try { localStorage.setItem('vl-group-by-tag', window._voiceGroupByTag ? '1' : '0'); } catch (_) {}
|
||
const btn = document.getElementById('voice-group-tag-btn');
|
||
btn?.classList.toggle('active', window._voiceGroupByTag);
|
||
const icon = btn?.querySelector('.mdi');
|
||
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
||
renderVoiceList();
|
||
} else if (e.target.closest('#voice-table-view-btn')) {
|
||
window._voiceTableView = !window._voiceTableView;
|
||
if (!window._voiceTableView) window._voiceTableEditMode = false;
|
||
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
|
||
const btn = document.getElementById('voice-table-view-btn');
|
||
btn?.classList.toggle('active', window._voiceTableView);
|
||
|
||
const editBtn = document.getElementById('voice-table-edit-btn');
|
||
if (editBtn) {
|
||
editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
|
||
editBtn.classList.toggle('active', !!window._voiceTableEditMode);
|
||
}
|
||
|
||
document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
|
||
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', !!window._voiceTableEditMode);
|
||
|
||
// Auto-close any open inspector row when toggling table view
|
||
if (window._voiceTableView) {
|
||
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
|
||
const inspector = document.getElementById('voices-inspector');
|
||
if (inspector) inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-table"></span></span><p>Table View Mode<br>Click a row to exit table view and edit.</p></div>';
|
||
}
|
||
renderVoiceList();
|
||
} else if (e.target.closest('#voice-table-edit-btn')) {
|
||
window._voiceTableEditMode = !window._voiceTableEditMode;
|
||
const editBtn = document.getElementById('voice-table-edit-btn');
|
||
editBtn?.classList.toggle('active', window._voiceTableEditMode);
|
||
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', window._voiceTableEditMode);
|
||
renderVoiceList();
|
||
}
|
||
});
|
||
// Restore the preferences on load
|
||
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
|
||
try { window._voiceTableView = localStorage.getItem('vl-table-view') === '1'; } catch (_) {}
|
||
|
||
document.addEventListener('click', e => {
|
||
const th = e.target.closest('.vl-th-sortable');
|
||
if (th) {
|
||
const field = th.dataset.sort;
|
||
if (field) {
|
||
const sel = document.getElementById('voice-sort-field');
|
||
if (sel) sel.value = field;
|
||
setSort(field);
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener('change', e => {
|
||
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
||
});
|
||
|
||
document.addEventListener('change', async e => {
|
||
if (e.target.classList.contains('vl-inline-edit')) {
|
||
const row = e.target.closest('.vl-row');
|
||
if (!row) return;
|
||
const voiceId = row.dataset.id;
|
||
const field = e.target.dataset.field;
|
||
let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||
|
||
if (field === 'rating') value = parseInt(value) || 0;
|
||
|
||
const payload = {};
|
||
payload[field] = value;
|
||
|
||
try {
|
||
await saveMeta(voiceId, payload);
|
||
const v = _voices.find(vv => vv.id === voiceId);
|
||
if (v) v[field] = value;
|
||
if (typeof toast === 'function') toast('Saved ' + field, 'success');
|
||
} catch (err) {
|
||
if (typeof toast === 'function') toast('Failed to save ' + field, 'error');
|
||
}
|
||
}
|
||
});
|
||
|
||
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 langSet = new Set();
|
||
const sexSet = new Set();
|
||
const typeSet = new Set();
|
||
(_voices || []).forEach(v => {
|
||
const lang = libraryVoiceLang(v);
|
||
if (lang) langSet.add(lang);
|
||
if (v.gender) sexSet.add(v.gender);
|
||
const type = voiceFileType(v);
|
||
if (type) typeSet.add(type);
|
||
});
|
||
const langs = [...langSet].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
|
||
const sexOrder = ['F','M','N'];
|
||
const sexes = [...sexSet]
|
||
.sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b)));
|
||
const types = [...typeSet].sort();
|
||
const sig = JSON.stringify([langs, sexes, types]);
|
||
if (sig === _libraryFilterOptionsSig) return;
|
||
_libraryFilterOptionsSig = sig;
|
||
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
|
||
langSel.innerHTML = '<option value="">All languages</option>' + langs.map(x => `<option value="${escHtml(x)}">${escHtml(libraryLanguageLabel(x))} (${escHtml(x)})</option>`).join('');
|
||
sexSel.innerHTML = '<option value="">All</option>' + sexes.map(x => `<option value="${escHtml(x)}">${escHtml(SEX_FILTER_LABELS[x] || x)}</option>`).join('');
|
||
typeSel.innerHTML = '<option value="">All</option>' + types.map(x => `<option value="${escHtml(x)}">${escHtml(x.toUpperCase())}</option>`).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, v.group, v.origin].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) {
|
||
if (v.benchmark && typeof v.benchmark === 'object' && Object.keys(v.benchmark).length > 0) {
|
||
return v.benchmark;
|
||
}
|
||
return 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 voiceFactor(v) {
|
||
const b = voiceBenchmark(v);
|
||
return b && b.ok && b.speed != null ? Number(b.speed) : null;
|
||
}
|
||
|
||
function fmtFactor(v) {
|
||
const f = voiceFactor(v);
|
||
return f != null ? f.toFixed(2) + 'x' : '-';
|
||
}
|
||
|
||
function fmtElapsed(v) {
|
||
const e = voiceBenchmarkElapsed(v);
|
||
const b = voiceBenchmark(v);
|
||
if (!b || !b.ok) return b && !b.ok ? 'ERR' : '-';
|
||
return e != null ? e.toFixed(1) + 's' : '-';
|
||
}
|
||
|
||
function fmtBenchmark(v) {
|
||
const elapsed = fmtElapsed(v);
|
||
const factor = fmtFactor(v);
|
||
if (elapsed === '-' && factor === '-') return '-';
|
||
return [elapsed, factor].filter(x => x !== '-').join(' · ');
|
||
}
|
||
|
||
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 voiceBenchmarkAudioSec(v) {
|
||
const b = voiceBenchmark(v);
|
||
return b && b.ok && b.audio_sec != null ? Number(b.audio_sec) : null;
|
||
}
|
||
|
||
function fmtBenchmarkAudio(v) {
|
||
const sec = voiceBenchmarkAudioSec(v);
|
||
return sec != null ? sec.toFixed(1) + 's' : '-';
|
||
}
|
||
|
||
function voiceWpm(v) {
|
||
const b = voiceBenchmark(v);
|
||
if (!b || !b.ok || !b.audio_sec || !b.text) return null;
|
||
const words = b.text.trim().split(/\s+/).length;
|
||
return Math.round(words / (b.audio_sec / 60));
|
||
}
|
||
|
||
function fmtWpm(v) {
|
||
const wpm = voiceWpm(v);
|
||
return wpm != null ? wpm + ' wpm' : '-';
|
||
}
|
||
|
||
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 = (_bulkSelected && _bulkSelected.size > 0)
|
||
? (_voices || []).filter(v => _bulkSelected.has(v.id))
|
||
: 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]) => `<div class="insight"><strong>${value}</strong><span>${label}</span></div>`).join('');
|
||
return;
|
||
}
|
||
if (state === 'error') {
|
||
el.innerHTML = '<div class="insight"><strong>Failed</strong><span>Library load</span></div>';
|
||
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 `<div class="insight${activeCls}"${filter}${title}><strong>${escHtml(item.value)}</strong><span>${escHtml(item.label)}</span></div>`;
|
||
}).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(); } });
|
||
});
|
||
}
|
||
|
||
function shouldRenderVoiceLibrary() {
|
||
const section = $('s-voices');
|
||
return !section || section.classList.contains('is-active');
|
||
}
|
||
|
||
async function loadVoiceLibrary(options = {}) {
|
||
const forceRefresh = !!(options && options.refresh);
|
||
if (_libraryLoadPromise) {
|
||
return _libraryLoadPromise.then(() => {
|
||
if (shouldRenderVoiceLibrary() && _voices.length) renderVoiceList();
|
||
});
|
||
}
|
||
_libraryLoadPromise = (async () => {
|
||
setBusyButton('refresh-voices-btn', true);
|
||
const list = $('voice-list');
|
||
const renderVisibleList = shouldRenderVoiceLibrary();
|
||
|
||
// Serve from sessionStorage cache immediately so the list is never blank on reload
|
||
const cached = _voices.length === 0 ? _vlCacheRead() : null;
|
||
if (cached && Array.isArray(cached) && cached.length) {
|
||
_voices = cached;
|
||
window._voices = _voices;
|
||
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
|
||
if (renderVisibleList) renderVoiceList();
|
||
updatePreviewVoiceMatchPanel();
|
||
status(`Loaded ${_voices.length} voices`);
|
||
}
|
||
|
||
// Always background-fetch fresh data; skip skeleton if something is already shown
|
||
const silent = _voices.length > 0;
|
||
if (list && !silent && renderVisibleList) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
|
||
if (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
|
||
try {
|
||
const r = await fetch('/api/voices' + (forceRefresh ? '?refresh=1' : ''));
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||
const fresh = await r.json();
|
||
_vlCacheWrite(fresh);
|
||
_voices = fresh;
|
||
window._voices = _voices;
|
||
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
|
||
if (shouldRenderVoiceLibrary()) renderVoiceList();
|
||
updatePreviewVoiceMatchPanel();
|
||
if (typeof renderPerfHistory === 'function') renderPerfHistory();
|
||
status(`Loaded ${_voices.length} voices`);
|
||
} catch(e) {
|
||
if (!silent && renderVisibleList) {
|
||
if (list) list.innerHTML = '<div style="color:var(--red);padding:8px;font-size:13px">Failed to load voices: ' + (e.message||String(e)) + '</div>';
|
||
$('voice-count').textContent = 'Load failed';
|
||
updateLibraryInsights('error');
|
||
}
|
||
status('Voice library load failed: ' + e.message);
|
||
if (!silent) throw e;
|
||
} finally {
|
||
setBusyButton('refresh-voices-btn', false);
|
||
_libraryLoadPromise = null;
|
||
}
|
||
})();
|
||
return _libraryLoadPromise;
|
||
}
|
||
|
||
$('refresh-voices-btn').addEventListener('click', () => { _vlCacheClear(); loadVoiceLibrary({ refresh: true }); });
|
||
$('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;
|
||
const _calcTarget = (_bulkSelected && _bulkSelected.size > 0) ? `${_bulkSelected.size} selected` : 'visible';
|
||
status(`Calculating voice loudness (${_calcTarget})…`);
|
||
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 => `<span>${escHtml(x)}</span>`).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 = benchmarkTargetVoices();
|
||
const text = benchmarkSampleText();
|
||
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
|
||
if (!voices.length) { toast('No 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: ''};
|
||
|
||
// Mark targeted rows as pending
|
||
voices.forEach(v => {
|
||
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
|
||
if (row) {
|
||
row.classList.remove('benchmarking-active', 'benchmarking-done');
|
||
row.classList.add('benchmarking-pending');
|
||
}
|
||
});
|
||
|
||
setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
|
||
for (let i = 0; i < voices.length; i++) {
|
||
const voice = voices[i];
|
||
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(voice.id)}"]`);
|
||
if (row) {
|
||
row.classList.remove('benchmarking-pending');
|
||
row.classList.add('benchmarking-active');
|
||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
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(2)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`;
|
||
// Live-update the row cells so results appear immediately
|
||
if (row) {
|
||
const bc = benchmarkClass(voice), btitle = benchmarkTitle(voice);
|
||
const durCell = row.querySelector('.vl-tbl-dur');
|
||
const factorCell = row.querySelector('.vl-tbl-factor');
|
||
const timeCell = row.querySelector('.vl-tbl-time');
|
||
const wpmCell = row.querySelector('.vl-tbl-wpm');
|
||
if (durCell) { durCell.textContent = fmtBenchmarkAudio(voice); durCell.title = `${fmtBenchmarkAudio(voice)} — length of synthesised benchmark audio`; }
|
||
if (factorCell) { factorCell.textContent = fmtFactor(voice); factorCell.className = `vl-tbl-factor ${bc}`; factorCell.title = btitle; }
|
||
if (timeCell) { timeCell.textContent = fmtElapsed(voice); timeCell.className = `vl-tbl-time ${bc}`; timeCell.title = btitle; }
|
||
if (wpmCell) { const wpm = voiceWpm(voice); wpmCell.textContent = fmtWpm(voice); wpmCell.title = wpm != null ? `${wpm} wpm — 130–180 wpm is natural for long listening` : ''; }
|
||
}
|
||
} 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}`;
|
||
}
|
||
|
||
if (row) {
|
||
row.classList.remove('benchmarking-active');
|
||
row.classList.add('benchmarking-done');
|
||
}
|
||
setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats);
|
||
}
|
||
|
||
// Cleanup any remaining pending rows just in case
|
||
voices.forEach(v => {
|
||
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
|
||
if (row) row.classList.remove('benchmarking-pending');
|
||
});
|
||
|
||
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;
|
||
});
|
||
}
|
||
window.loadVoiceLibrary = loadVoiceLibrary;
|
||
window.mergeBenchmarkResults = mergeBenchmarkResults;
|
||
|
||
function activeBenchmarkVoices() {
|
||
return (_voices || []).filter(v => v.enabled !== false);
|
||
}
|
||
|
||
// Benchmark target: the checked/selected voices when any are selected, else all active.
|
||
function benchmarkTargetVoices() {
|
||
if (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0) {
|
||
return (_voices || []).filter(v => _bulkSelected.has(v.id));
|
||
}
|
||
return activeBenchmarkVoices();
|
||
}
|
||
|
||
function showBenchmarkConfirm() {
|
||
const voices = benchmarkTargetVoices();
|
||
const onlySelected = (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0);
|
||
const text = benchmarkSampleText();
|
||
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; }
|
||
if (!voices.length) { toast('No voices to benchmark', 'error'); return; }
|
||
const staleCount = voices.filter(v => v.needs_tts_restart).length;
|
||
$('benchmark-confirm-title').textContent = `Benchmark ${voices.length} ${onlySelected ? 'selected' : 'active'} voice${voices.length === 1 ? '' : 's'}?`;
|
||
$('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 useSelected = _bulkSelected && _bulkSelected.size > 0;
|
||
const ids = useSelected ? [..._bulkSelected] : activeVoiceIds();
|
||
const label = useSelected ? `${ids.length} selected` : `${ids.length} active`;
|
||
if (!ids.length) { toast('No voices to copy', 'error'); return; }
|
||
await copyText(ids.join(', '));
|
||
toast('Copied ' + label + ' voices', 'success');
|
||
status('Copied ' + label + ' voices to clipboard');
|
||
});
|
||
|
||
// Precompute speaker embeddings: fire a tiny synth per active voice so the TTS
|
||
// engine computes + caches each voice's .pt (speaker fingerprint) ahead of time,
|
||
// making first real playback instant. The engine prefers the cached .pt and only
|
||
// (re)builds it from the wav when missing — so this is purely a warm-up.
|
||
$('precompute-embeddings-btn')?.addEventListener('click', async () => {
|
||
const backend = libraryTtsBackend();
|
||
const _useSelected = _bulkSelected && _bulkSelected.size > 0;
|
||
const ids = _useSelected ? [..._bulkSelected] : activeVoiceIds();
|
||
const _scopeLabel = _useSelected ? `${ids.length} selected` : `${ids.length} active`;
|
||
if (!ids.length) { toast('No voices to precompute', 'error'); return; }
|
||
if (!confirm(`Precompute speaker embeddings for ${_scopeLabel} voice(s) via “${backend}”?\n\nThis warms each voice so the engine caches its .pt and first playback is instant.`)) return;
|
||
|
||
const btn = $('precompute-embeddings-btn'); if (btn) btn.disabled = true;
|
||
const ov = document.createElement('div');
|
||
ov.className = 'audiobook-overlay'; ov.id = 'precompute-overlay';
|
||
ov.innerHTML = `<div class="audiobook-box"><div class="audiobook-title"><span class="mdi mdi-flash-outline"></span> Precomputing embeddings</div>
|
||
<div class="audiobook-msg" id="pc-msg">0 / ${ids.length}</div>
|
||
<div class="reader-synth-track"><div class="reader-synth-fill" id="pc-fill"></div></div>
|
||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="pc-cancel">Cancel</button></div></div>`;
|
||
document.body.appendChild(ov);
|
||
let cancel = false;
|
||
ov.querySelector('#pc-cancel').addEventListener('click', () => { cancel = true; });
|
||
const fill = ov.querySelector('#pc-fill'), msg = ov.querySelector('#pc-msg');
|
||
let done = 0, ok = 0, failed = 0;
|
||
const queue = ids.slice();
|
||
const worker = async () => {
|
||
while (queue.length && !cancel) {
|
||
const id = queue.shift();
|
||
if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`;
|
||
try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', backend); ok++; }
|
||
catch (_) { failed++; }
|
||
done++; if (fill) fill.style.width = (done / ids.length * 100) + '%';
|
||
}
|
||
};
|
||
try { await Promise.all(Array.from({ length: Math.min(2, ids.length) }, worker)); }
|
||
finally { ov.remove(); if (btn) btn.disabled = false; }
|
||
toast(cancel ? `Cancelled — ${ok} warmed` : `Precomputed ${ok} embedding(s)${failed ? `, ${failed} skipped/failed` : ''}`,
|
||
(!ok && failed) ? 'error' : 'success');
|
||
});
|
||
|
||
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 = '';
|
||
|
||
renderVoiceGroupsBar();
|
||
populateLibraryFilters();
|
||
readLibraryFilters();
|
||
|
||
const _gtBtn = $('voice-group-tag-btn');
|
||
if (_gtBtn) {
|
||
_gtBtn.classList.toggle('active', !!window._voiceGroupByTag);
|
||
const ic = _gtBtn.querySelector('.mdi');
|
||
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
||
}
|
||
|
||
const _tvBtn = $('voice-table-view-btn');
|
||
if (_tvBtn) {
|
||
_tvBtn.classList.toggle('active', !!window._voiceTableView);
|
||
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView);
|
||
const editBtn = document.getElementById('voice-table-edit-btn');
|
||
if (editBtn) {
|
||
editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
|
||
editBtn.classList.toggle('active', !!window._voiceTableEditMode);
|
||
}
|
||
}
|
||
|
||
// Apply sidebar category filter
|
||
const cat = window._voiceSidebarCat || 'all';
|
||
const enabledOk = v => showDisabled || v.enabled !== false;
|
||
let filtered = _voices.filter(v => {
|
||
// 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV).
|
||
// 'cloned' = real WAV-identity voices that were NOT prompt-designed.
|
||
if (cat === 'cloned') return enabledOk(v) && v.has_ref && v.origin !== 'designed';
|
||
if (cat === 'designed') return enabledOk(v) && (v.origin === 'designed' || !v.has_ref);
|
||
if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4;
|
||
if (cat === 'hidden') return v.enabled === false; // always show hidden voices in hidden tab
|
||
// 'tools' shows all voices (same as 'all') so bulk-edit works
|
||
return enabledOk(v);
|
||
});
|
||
// Apply active group filter (from the groups bar)
|
||
if (window._voiceGroupFilter) {
|
||
filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter);
|
||
}
|
||
// Apply active tag "subfolder" filter (from the My Voices nav tree)
|
||
if (window._voiceTagFilter) {
|
||
filtered = filtered.filter(v => String(v.tag || '').split(',').map(t => t.trim()).includes(window._voiceTagFilter));
|
||
}
|
||
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 = `<span><strong>${escHtml(filtered.length)} / ${escHtml(filterCount)}</strong> ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}</span><button class="btn-secondary" type="button">Clear filter</button>`;
|
||
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 = `
|
||
<div class="voices-empty-cases">
|
||
<div class="voices-empty-case">
|
||
<div class="voices-empty-case-icon"><span class="mdi mdi-folder-outline"></span></div>
|
||
<h4>Wrong folder path?</h4>
|
||
<p>Set the path where your <code>.wav</code> voice files live <em>inside the container</em>.</p>
|
||
<div class="voices-folder-row">
|
||
<input id="voices-empty-scan-dir" type="text" class="voices-folder-input" placeholder="/voices">
|
||
<button class="btn-secondary" id="voices-empty-browse-btn" title="Browse container filesystem"><span class="mdi mdi-folder-outline"></span> Browse</button>
|
||
<button class="btn-primary" id="voices-empty-save-btn">Set & reload</button>
|
||
</div>
|
||
<div class="vef-dir-browser" id="vef-dir-browser" hidden>
|
||
<div class="vef-breadcrumb" id="vef-breadcrumb"></div>
|
||
<div class="vef-dir-list" id="vef-dir-list"><span class="vef-loading">Loading…</span></div>
|
||
<div class="vef-browser-actions">
|
||
<span class="vef-selected-path note" id="vef-selected-path"></span>
|
||
<button class="btn-primary" id="vef-select-btn">Use this folder</button>
|
||
</div>
|
||
</div>
|
||
<p class="voices-empty-hint">Map a host folder via docker-compose:<br>
|
||
<code>- /your/host/path:/voices:rw</code><br>
|
||
or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
|
||
</div>
|
||
<div class="voices-empty-divider">or</div>
|
||
<div class="voices-empty-case">
|
||
<div class="voices-empty-case-icon"><span class="mdi mdi-microphone-variant"></span></div>
|
||
<h4>Folder is empty?</h4>
|
||
<p>Create your first voice from a recording or download ready-made voices.</p>
|
||
<div class="voices-empty-actions">
|
||
<button class="btn-primary voices-empty-action-btn" id="voices-goto-clone"><span class="mdi mdi-lightning-bolt"></span> Clone a Voice</button>
|
||
<button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio"><span class="mdi mdi-earth"></span> Get Voices Online</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = '<span class="vef-loading">Loading…</span>';
|
||
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
|
||
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}">${escHtml(c.label)}</button><span class="vef-sep">/</span>`
|
||
: `<span class="vef-crumb-cur">${escHtml(c.label)}</span>`
|
||
).join('');
|
||
vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
|
||
// Directory list
|
||
if (!data.dirs.length) {
|
||
vefDirList.innerHTML = '<span class="vef-empty">No subdirectories here.</span>';
|
||
} else {
|
||
vefDirList.innerHTML = data.dirs.map(d =>
|
||
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}"><span class="mdi mdi-folder-outline"></span> ${escHtml(d)}</button>`
|
||
).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 = `<span class="vef-error">Error: ${escHtml(e.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
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 += '<div style="padding:14px;color:var(--subtext);font-size:14px">No voices match the current filters.</div>';
|
||
}
|
||
return;
|
||
}
|
||
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
|
||
else {
|
||
const frag = document.createDocumentFragment();
|
||
filtered.forEach(v => frag.appendChild(makeVoiceRow(v)));
|
||
list.appendChild(frag);
|
||
}
|
||
if (_pendingSelectId) {
|
||
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
|
||
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
|
||
}
|
||
syncSortHeaders();
|
||
}
|
||
|
||
// Group the list into collapsible virtual "folders" by tag (a voice with several
|
||
// comma-separated tags appears under each; untagged voices fall into one bucket).
|
||
function renderVoicesByTag(list, filtered) {
|
||
const UNTAG = '— Untagged —';
|
||
const groups = new Map();
|
||
filtered.forEach(v => {
|
||
const tags = String(v.tag || '').split(',').map(t => t.trim()).filter(Boolean);
|
||
(tags.length ? tags : [UNTAG]).forEach(t => {
|
||
if (!groups.has(t)) groups.set(t, []);
|
||
groups.get(t).push(v);
|
||
});
|
||
});
|
||
let collapsed = {};
|
||
try { collapsed = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {}
|
||
const names = [...groups.keys()].sort((a, b) =>
|
||
a === UNTAG ? 1 : b === UNTAG ? -1 : a.toLowerCase().localeCompare(b.toLowerCase()));
|
||
|
||
const outer = document.createDocumentFragment();
|
||
names.forEach(tag => {
|
||
const voices = groups.get(tag);
|
||
const isCol = !!collapsed[tag];
|
||
const sec = document.createElement('div');
|
||
sec.className = 'vl-tag-group';
|
||
const head = document.createElement('button');
|
||
head.className = 'vl-tag-head' + (isCol ? ' collapsed' : '');
|
||
head.type = 'button';
|
||
head.innerHTML = `<span class="mdi mdi-${isCol ? 'chevron-right' : 'chevron-down'} vl-tag-caret"></span>`
|
||
+ `<span class="mdi mdi-folder${isCol ? '' : '-open'}-outline"></span>`
|
||
+ `<span class="vl-tag-name">${escHtml(tag)}</span>`
|
||
+ `<span class="vl-tag-count">${voices.length}</span>`;
|
||
const body = document.createElement('div');
|
||
body.className = 'vl-tag-body';
|
||
if (isCol) body.style.display = 'none';
|
||
const rows = document.createDocumentFragment();
|
||
voices.forEach(v => rows.appendChild(makeVoiceRow(v)));
|
||
body.appendChild(rows);
|
||
head.addEventListener('click', () => {
|
||
const nowCol = body.style.display !== 'none';
|
||
body.style.display = nowCol ? 'none' : '';
|
||
head.classList.toggle('collapsed', nowCol);
|
||
head.querySelector('.vl-tag-caret').className = 'mdi mdi-' + (nowCol ? 'chevron-right' : 'chevron-down') + ' vl-tag-caret';
|
||
head.querySelector('.mdi:nth-child(2)').className = 'mdi mdi-folder' + (nowCol ? '' : '-open') + '-outline';
|
||
let c = {}; try { c = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {}
|
||
c[tag] = nowCol; localStorage.setItem('vl-tag-collapsed', JSON.stringify(c));
|
||
});
|
||
sec.appendChild(head);
|
||
sec.appendChild(body);
|
||
outer.appendChild(sec);
|
||
});
|
||
list.appendChild(outer);
|
||
}
|
||
|
||
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();
|
||
// Display name: explicit meta.name (e.g. Rehearser character) wins, else last ID segment.
|
||
const _idParts = String(v.id).split('_');
|
||
const dispName = (v.name && String(v.name).trim())
|
||
? String(v.name).trim()
|
||
: (_idParts.length > 1 ? _idParts[_idParts.length - 1] : v.id);
|
||
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) : '<span class="mdi mdi-earth"></span>';
|
||
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 genderMapLetter = {F:'F', M:'M', N:'D', '':'?'};
|
||
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 =>
|
||
`<span class="star${i<=(v.rating||0)?' on':''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
|
||
).join('');
|
||
|
||
const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null;
|
||
|
||
const pickerHtml = flagOpts.length > 1
|
||
? flagOpts.map(([cc, label]) =>
|
||
`<span class="flag-opt${cc===currentFlag?' active':''}" data-cc="${cc}" title="${label}">${cc2flag(cc)}<span class="fo-code">${ccDisplay(cc)}</span></span>`
|
||
).join('')
|
||
: `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`;
|
||
|
||
// Compact list item elements (visible in list pane, hidden in inspector)
|
||
const isClone = v.has_ref && v.origin !== 'designed';
|
||
const initial = v.id[0] ? v.id[0].toUpperCase() : '?';
|
||
const flagIconHtml = currentFlag
|
||
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
|
||
: null;
|
||
|
||
// Avatar: photo > gender/type icon > silhouette (with color)
|
||
const defaultSilClass = gender === 'M' ? 'mdi-face-man' : (gender === 'F' ? 'mdi-face-woman' : 'mdi-account');
|
||
const silHtml = `<span class="mdi ${defaultSilClass}" style="color:#fff;font-size:20px;display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:${color}"></span>`;
|
||
|
||
const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
|
||
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
|
||
: iconHtml ? 'vl-avatar vl-avatar-icon' : 'vl-avatar vl-avatar-sil';
|
||
const avatarStyle = (picSrc || iconHtml) ? '' : `style="background:${color}"`;
|
||
const avatarContent = picSrc
|
||
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
|
||
: iconHtml || silHtml;
|
||
|
||
wrap.innerHTML = `
|
||
<div class="vl-compact">
|
||
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
|
||
<div class="vl-info">
|
||
${window._voiceTableEditMode
|
||
? `<input type="text" class="vl-inline-edit vl-name-input" data-field="name" value="${escHtml(dispName)}" title="Display Name" placeholder="Name">`
|
||
: `<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>`}
|
||
<div class="vl-meta">
|
||
<span class="vl-lang-label" title="${escHtml(currentFlag || langCode)}">${flagEmoji}</span>
|
||
${gender ? `<span class="vl-gender-letter ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMapLetter[gender]||'?'}</span>` : ''}
|
||
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText.split(' ')[0])}</span>` : ''}
|
||
<div class="vr-rating">${starsHtml}</div>
|
||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="vr-play-group">
|
||
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
|
||
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
|
||
</div>
|
||
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">
|
||
${window._voiceTableEditMode
|
||
? `<input type="text" class="vl-inline-edit" data-field="lang" value="${escHtml(langCode)}" style="width:100%">`
|
||
: `${flagEmoji} ${escHtml(langCode)}`}
|
||
</div>
|
||
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">
|
||
${window._voiceTableEditMode
|
||
? `<select class="vl-inline-edit" data-field="gender" style="width:100%"><option value="N" ${gender==='N'?'selected':''}>N</option><option value="M" ${gender==='M'?'selected':''}>M</option><option value="F" ${gender==='F'?'selected':''}>F</option></select>`
|
||
: `${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}`}
|
||
</div>
|
||
<div class="vl-tbl-factor ${benchCls}" title="${escHtml(benchTitle)}">${escHtml(fmtFactor(v))}</div>
|
||
<div class="vl-tbl-wpm" title="${escHtml(voiceWpm(v) != null ? `${voiceWpm(v)} words per minute — how fast this voice speaks. 130–180 wpm is natural for long listening.` : 'Benchmark this voice to see speaking rate')}">${escHtml(fmtWpm(v))}</div>
|
||
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
|
||
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
|
||
<div class="vl-tbl-rating">
|
||
${window._voiceTableEditMode
|
||
? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">`
|
||
: starsHtml}
|
||
</div>
|
||
<div class="vl-tbl-source" title="Click to edit source">${escHtml(_displaySource(v) || '-')}</div>
|
||
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
|
||
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">
|
||
${window._voiceTableEditMode
|
||
? `<input type="text" class="vl-inline-edit" data-field="note" value="${escHtml(v.note||'')}" style="width:100%">`
|
||
: escHtml(v.note || '')}
|
||
</div>
|
||
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">
|
||
${window._voiceTableEditMode
|
||
? `<input type="text" class="vl-inline-edit" data-field="tag" value="${escHtml(v.tag||'')}" style="width:100%">`
|
||
: escHtml(v.tag || '')}
|
||
</div>
|
||
<div class="vl-tbl-active">
|
||
${window._voiceTableEditMode
|
||
? `<input type="checkbox" class="vl-inline-edit" data-field="enabled" ${v.enabled!==false?'checked':''} style="width:16px;height:16px">`
|
||
: `<span class="mdi ${v.enabled !== false ? 'mdi-check-circle' : 'mdi-close-circle'}" style="color:${v.enabled !== false ? 'var(--green)' : 'var(--subtext)'}"></span>`}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-main-row">
|
||
<div class="vr-photo" title="Click to upload photo">
|
||
${picSrc ? `<img src="${picSrc}" alt="" loading="lazy" decoding="async">` : '<div class="ph-icon"><span class="mdi mdi-account-outline"></span></div>'}
|
||
<input type="file" class="photo-input" accept="image/*">
|
||
</div>
|
||
|
||
<div class="vr-identity">
|
||
<div class="vr-flag">
|
||
<span class="flag-emoji">${flagEmoji}</span>
|
||
<span class="flag-code">${flagCode}</span>
|
||
<div class="flag-picker">${pickerHtml}</div>
|
||
</div>
|
||
|
||
<div class="vr-gender">
|
||
<span class="gender-badge ${genderClass[gender]||'g-n'}" title="Click to cycle">
|
||
<span class="gender-sym">${genderMap[gender]||'?'}</span>
|
||
<span class="gender-txt">${genderLabel[gender]||'—'}</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="vr-name">
|
||
<span class="vr-name-text" title="${escHtml(v.id)}">${escHtml(v.id)}</span>
|
||
<span class="rename-confirm">
|
||
<input class="vr-name-input" value="${escHtml(v.id)}" spellcheck="false">
|
||
<button class="icon-btn rename-ok" title="Save"><span class="mdi mdi-check"></span></button>
|
||
<button class="icon-btn rename-cancel" title="Cancel"><span class="mdi mdi-close"></span></button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-type" title="${escHtml(fileType)}">${escHtml(fileType.toUpperCase())}</div>
|
||
<div class="vr-length" title="${escHtml(String(v.duration ?? ''))}">${fmtDuration(v.duration)}</div>
|
||
|
||
<div class="vr-db" title="${escHtml(dbTitle)}">
|
||
<span class="vr-db-value">${escHtml(dbfs)}</span>
|
||
<button class="normalize-voice-btn" title="Normalize this voice">±</button>
|
||
</div>
|
||
|
||
<div class="vr-bench ${benchCls}" title="${escHtml(benchTitle)}">
|
||
<span class="vr-bench-value">${escHtml(benchText)}</span>
|
||
<button class="btn-secondary benchmark-one-btn" title="Benchmark this voice">⏱</button>
|
||
</div>
|
||
|
||
<div class="vr-rating">${starsHtml}</div>
|
||
|
||
<div class="vr-edit">
|
||
<button class="edit-audio-btn" title="Edit audio"><span class="mdi mdi-pencil-outline"></span></button>
|
||
</div>
|
||
|
||
<div class="vr-play-group">
|
||
<div class="vr-play vr-play-original">
|
||
<button title="Play / pause original recording"><span class="mdi mdi-play"></span></button>
|
||
</div>
|
||
<div class="vr-play vr-play-synth">
|
||
<button title="Generate and play synthesized sample"><span class="mdi mdi-play"></span></button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="vr-detail-row">
|
||
<div class="vr-ref">
|
||
<label>Reference</label>
|
||
<div class="vr-inline">
|
||
<input type="text" value="${escHtml(v.transcript||'')}" placeholder="No reference text" title="${escHtml(v.transcript||'')}">
|
||
<button class="ref-transcribe-btn" title="Recognise reference text" style="${v.transcript ? 'display:none' : ''}"><span class="mdi mdi-message-text-outline"></span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-note">
|
||
<label>Note</label>
|
||
<div class="vr-inline">
|
||
<input type="text" placeholder="Add note..." value="${escHtml(v.note||'')}">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-source">
|
||
<label>Source</label>
|
||
<div class="vr-inline">
|
||
<input type="text" placeholder="e.g. fish-audio, cloned, designed…" value="${escHtml(v.origin||'')}">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-detail-active">
|
||
<label>Active</label>
|
||
<div class="vr-active-tools">
|
||
<div class="vr-toggle">
|
||
<label class="toggle" title="${v.enabled===false ? 'Disabled - click to enable' : 'Enabled - click to disable'}">
|
||
<input type="checkbox" ${v.enabled!==false ? 'checked' : ''}>
|
||
<span class="t-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-detail-delete">
|
||
<button class="delete-btn" title="Delete voice permanently">Delete voice</button>
|
||
<div class="delete-confirm" role="group" aria-label="Confirm delete voice">
|
||
<strong>Delete?</strong>
|
||
<span title="${escHtml(v.id)}">${escHtml(v.id)}</span>
|
||
<button class="btn-secondary delete-confirm-cancel" type="button">Cancel</button>
|
||
<button class="delete-confirm-go" type="button">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vr-optimizer">
|
||
<div class="optimizer-grid">
|
||
<div class="opt-group opt-trim-panel">
|
||
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference audio · crop</span></div>
|
||
<div class="opt-group-subtitle">Crop the saved WAV to 3–20 s of clean speech.</div>
|
||
<div class="opt-group-body">
|
||
<canvas class="opt-wave"></canvas>
|
||
<div class="opt-controls">
|
||
<div class="opt-field"><label>Start</label><input class="opt-start" type="number" step="0.01" value="0"></div>
|
||
<div class="opt-field"><label>End</label><input class="opt-end" type="number" step="0.01" value="0"></div>
|
||
<button class="btn-secondary opt-auto-trim">Auto trim</button>
|
||
<button class="btn-secondary opt-play">Play crop</button>
|
||
<button class="btn-secondary opt-undo">Undo crop</button>
|
||
</div>
|
||
<div class="opt-group-footer">
|
||
<button class="btn-primary opt-save-crop">Save crop</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="opt-group opt-text-panel open">
|
||
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference transcript</span></div>
|
||
<div class="opt-group-subtitle">The spoken text that matches this voice recording.</div>
|
||
<div class="opt-group-body">
|
||
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
|
||
<div class="opt-controls">
|
||
<button class="btn-secondary opt-recognize">Re-recognise text</button>
|
||
</div>
|
||
<div class="opt-group-footer">
|
||
<button class="btn-primary opt-save-text">Save text</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="opt-group opt-compare-panel">
|
||
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Voice match</span></div>
|
||
<div class="opt-group-subtitle">Compare the saved WAV with a fresh TTS synthesis.</div>
|
||
<div class="opt-group-body">
|
||
<div class="opt-controls">
|
||
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
|
||
<button class="btn-secondary opt-play-reference">Play WAV file</button>
|
||
<button class="btn-primary opt-synth-reference">Synthesize reference text</button>
|
||
</div>
|
||
<p class="opt-group-note">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.</p>
|
||
<div class="opt-compare-grid">
|
||
<div class="opt-compare-card">
|
||
<strong>WAV file</strong>
|
||
<audio class="opt-compare-ref-audio" controls></audio>
|
||
</div>
|
||
<div class="opt-compare-card">
|
||
<strong>Synthesized reference text</strong>
|
||
<audio class="opt-compare-synth-audio" controls></audio>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${!isClone ? `<div class="opt-group opt-style-panel">
|
||
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Style variation</span></div>
|
||
<div class="opt-group-subtitle">Create a styled variant and save it as a new voice (CustomVoice only).</div>
|
||
<div class="opt-group-body">
|
||
<div class="opt-controls">
|
||
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
|
||
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
|
||
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
|
||
</div>
|
||
<p class="opt-group-note">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 <code>instruct</code>; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.</p>
|
||
<div class="backend-help opt-style-backend-help"></div>
|
||
<div class="opt-style-preview-box">
|
||
<div class="opt-controls">
|
||
<button class="btn-secondary opt-preview-style">Preview style</button>
|
||
</div>
|
||
<audio class="opt-style-audio" controls></audio>
|
||
</div>
|
||
<div class="opt-group-footer">
|
||
<button class="btn-primary opt-save-style">Save style variation</button>
|
||
</div>
|
||
</div>
|
||
</div>` : ''}
|
||
<div class="opt-group opt-maintenance open">
|
||
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Loudness</span></div>
|
||
<div class="opt-group-subtitle">Normalize the volume of the reference audio file.</div>
|
||
<div class="opt-group-body">
|
||
<div class="opt-controls">
|
||
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
|
||
<button class="btn-secondary opt-db-minus">-</button>
|
||
<button class="btn-secondary opt-db-plus">+</button>
|
||
<button class="btn-secondary opt-db-auto">Auto</button>
|
||
<button class="btn-secondary opt-restart-tts" title="Restarts the WAV-loading TTS backends (Voice Clone + Streaming) so they rescan the active_voices folder">Restart WAV engines</button>
|
||
<button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
|
||
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
|
||
</div>
|
||
<div class="opt-group-footer">
|
||
<button class="btn-primary opt-save-volume">Save volume</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="opt-status optimizer-workflow">Click the pencil to load waveform and tools.</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const vrLengthEl = wrap.querySelector('.vr-length');
|
||
hydrateVoiceDuration(v, vrLengthEl);
|
||
|
||
// Photo upload
|
||
const photoCell = wrap.querySelector('.vr-photo');
|
||
const photoInput = wrap.querySelector('.photo-input');
|
||
|
||
const updatePhotoImg = () => {
|
||
const ts = Date.now();
|
||
const imgSrc = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`;
|
||
// Update main photo cell
|
||
const img = document.createElement('img');
|
||
img.src = imgSrc;
|
||
img.alt = '';
|
||
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
|
||
|
||
// Update compact row avatar
|
||
const compactAvatar = wrap.querySelector('.vl-avatar');
|
||
if (compactAvatar) {
|
||
compactAvatar.className = 'vl-avatar vl-avatar-photo';
|
||
compactAvatar.style.background = '';
|
||
compactAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="vl-avatar-img">`;
|
||
}
|
||
|
||
// Update inspector avatar if this voice is currently open
|
||
const inspectorAvatar = document.querySelector('.inspector-avatar');
|
||
if (inspectorAvatar && wrap.classList.contains('vr-selected')) {
|
||
inspectorAvatar.classList.add('insp-avatar-photo');
|
||
inspectorAvatar.style.background = '';
|
||
inspectorAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="insp-avatar-img">`;
|
||
}
|
||
|
||
v.has_picture = true;
|
||
};
|
||
|
||
photoCell.addEventListener('update-photo', updatePhotoImg);
|
||
|
||
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);
|
||
updatePhotoImg();
|
||
toast('Photo uploaded','success');
|
||
} catch(e) { toast('Photo upload failed: '+e.message,'error'); }
|
||
});
|
||
|
||
photoCell.addEventListener('dragenter', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
|
||
photoCell.addEventListener('dragover', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
|
||
photoCell.addEventListener('dragleave', () => photoCell.classList.remove('drag-over'));
|
||
photoCell.addEventListener('drop', async e => {
|
||
e.preventDefault();
|
||
photoCell.classList.remove('drag-over');
|
||
|
||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||
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)) {
|
||
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: v.id, image_url: url})
|
||
});
|
||
if (!r.ok) throw new Error((await r.json()).detail);
|
||
updatePhotoImg();
|
||
toast('Photo saved from URL', 'success');
|
||
status('Photo saved successfully');
|
||
} catch(err) {
|
||
toast('Photo URL download failed: ' + err.message, 'error');
|
||
status('Photo URL download failed');
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
// 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 = `<span class="gender-sym">${genderMap[v.gender]||'?'}</span><span class="gender-txt">${genderLabel[v.gender]||'—'}</span>`;
|
||
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));
|
||
|
||
// Source / origin (debounced save — also updates the table cell live)
|
||
const sourceInput = wrap.querySelector('.vr-source input');
|
||
sourceInput.addEventListener('input', debounce(async () => {
|
||
v.origin = sourceInput.value.trim();
|
||
await saveMeta(v.id, { origin: v.origin });
|
||
// Reflect in table row immediately
|
||
const tblCell = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);
|
||
if (tblCell) tblCell.textContent = _displaySource(v) || '-';
|
||
}, 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<newRating));
|
||
await saveMeta(v.id, { rating: newRating });
|
||
});
|
||
s.addEventListener('mouseenter', () => {
|
||
const val = parseInt(s.dataset.val);
|
||
starSpans.forEach((ss,i) => ss.classList.toggle('on', i<val));
|
||
});
|
||
s.addEventListener('mouseleave', () => {
|
||
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);
|
||
// Also update the table row cells
|
||
const tblRow = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
|
||
if (tblRow) {
|
||
const bc = benchmarkClass(v), btitle = benchmarkTitle(v);
|
||
const tblDur = tblRow.querySelector('.vl-tbl-dur');
|
||
const tblFactor = tblRow.querySelector('.vl-tbl-factor');
|
||
const tblTime = tblRow.querySelector('.vl-tbl-time');
|
||
const tblWpm = tblRow.querySelector('.vl-tbl-wpm');
|
||
if (tblDur) { tblDur.textContent = fmtBenchmarkAudio(v); tblDur.title = `${fmtBenchmarkAudio(v)} — length of synthesised benchmark audio`; }
|
||
if (tblFactor) { tblFactor.textContent = fmtFactor(v); tblFactor.className = `vl-tbl-factor ${bc}`; tblFactor.title = btitle; }
|
||
if (tblTime) { tblTime.textContent = fmtElapsed(v); tblTime.className = `vl-tbl-time ${bc}`; tblTime.title = btitle; }
|
||
if (tblWpm) { const wpm = voiceWpm(v); tblWpm.textContent = fmtWpm(v); tblWpm.title = wpm != null ? `${wpm} wpm — 130–180 wpm is natural for long listening` : ''; }
|
||
}
|
||
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 = '<span class="mdi mdi-play"></span>', pauseIcon = '<span class="mdi mdi-pause-circle-outline"></span>', generatingIcon = '<span class="mdi mdi-timer-sand"></span>';
|
||
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;
|
||
}
|
||
|
||
// ── Voice groups bar (bulk edit/remove a tagged set, e.g. a rehearser play) ──
|
||
function renderVoiceGroupsBar() {
|
||
const bar = $('voice-groups-bar');
|
||
if (!bar) return;
|
||
// Tally groups across all voices
|
||
const groups = {};
|
||
(_voices || []).forEach(v => {
|
||
const g = (v.group || '').trim();
|
||
if (g) groups[g] = (groups[g] || 0) + 1;
|
||
});
|
||
const names = Object.keys(groups).sort();
|
||
if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
|
||
bar.hidden = false;
|
||
const active = window._voiceGroupFilter || '';
|
||
bar.innerHTML =
|
||
`<span class="vg-label"><span class="mdi mdi-folder-multiple-outline"></span> Groups</span>` +
|
||
names.map(g =>
|
||
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
|
||
${escHtml(g)} <span class="vg-count">${groups[g]}</span>
|
||
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">×</button>
|
||
</span>`
|
||
).join('') +
|
||
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
|
||
|
||
bar.querySelectorAll('.vg-chip').forEach(chip => {
|
||
chip.addEventListener('click', e => {
|
||
if (e.target.closest('.vg-del')) return;
|
||
const g = chip.dataset.group;
|
||
window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g;
|
||
renderVoiceList();
|
||
});
|
||
});
|
||
bar.querySelectorAll('.vg-del').forEach(btn => {
|
||
btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
|
||
});
|
||
bar.querySelector('.vg-clear')?.addEventListener('click', () => {
|
||
window._voiceGroupFilter = ''; renderVoiceList();
|
||
});
|
||
}
|
||
|
||
async function deleteVoiceGroup(group) {
|
||
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
|
||
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
|
||
try {
|
||
const r = await fetch('/api/voices/delete-group', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ group }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
|
||
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
|
||
await loadVoiceLibrary();
|
||
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
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 });
|
||
|
||
// ── Bulk-edit ────────────────────────────────────────────────────────────────
|
||
|
||
const _bulkSelected = new Set(); // voice IDs currently checked
|
||
|
||
function _bulkUpdate() {
|
||
const bar = $('vl-bulk-bar');
|
||
const count = $('vl-bulk-count');
|
||
const n = _bulkSelected.size;
|
||
if (bar) bar.hidden = n === 0;
|
||
if (count) count.textContent = `${n} selected`;
|
||
// Sync checkboxes in the DOM to match the set
|
||
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
|
||
cb.checked = _bulkSelected.has(cb.dataset.id);
|
||
});
|
||
|
||
const headerCb = document.getElementById('vl-select-all-header');
|
||
if (headerCb) {
|
||
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
|
||
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
|
||
headerCb.checked = allSelected;
|
||
}
|
||
}
|
||
|
||
function _bulkToggle(id, checked) {
|
||
if (checked) _bulkSelected.add(id); else _bulkSelected.delete(id);
|
||
_bulkUpdate();
|
||
}
|
||
|
||
// Inject checkbox into each voice row after renderVoiceList builds the DOM
|
||
const _origRenderVoiceList = renderVoiceList;
|
||
renderVoiceList = function() {
|
||
_origRenderVoiceList.apply(this, arguments);
|
||
_bulkInjectCheckboxes();
|
||
};
|
||
|
||
function _bulkInjectCheckboxes() {
|
||
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
|
||
if (row.querySelector('.vl-bulk-cb')) return; // already injected
|
||
const id = row.dataset.id;
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.className = 'vl-bulk-cb';
|
||
cb.dataset.id = id;
|
||
cb.checked = _bulkSelected.has(id);
|
||
cb.title = 'Select for bulk edit';
|
||
cb.addEventListener('change', e => { e.stopPropagation(); _bulkToggle(id, cb.checked); });
|
||
cb.addEventListener('click', e => e.stopPropagation());
|
||
// Prepend into vl-compact
|
||
const compact = row.querySelector('.vl-compact');
|
||
if (compact) compact.prepend(cb);
|
||
});
|
||
}
|
||
|
||
// ── Toolbar button wiring ───────────────────────────────────────────────────
|
||
|
||
$('vl-bulk-select-all')?.addEventListener('click', () => {
|
||
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
|
||
if (row.dataset.id) _bulkSelected.add(row.dataset.id);
|
||
});
|
||
_bulkUpdate();
|
||
});
|
||
|
||
// Always-visible Select-all toggle in the table header (the bulk bar only appears once
|
||
// something is selected, so this is the entry point). Selects every visible row, or
|
||
// clears the selection if they're all already selected.
|
||
$('vl-select-all-header')?.addEventListener('change', (e) => {
|
||
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
|
||
if (e.target.checked) {
|
||
rows.forEach(r => _bulkSelected.add(r.dataset.id));
|
||
} else {
|
||
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
|
||
}
|
||
_bulkUpdate();
|
||
});
|
||
|
||
$('vl-bulk-deselect')?.addEventListener('click', () => {
|
||
_bulkSelected.clear();
|
||
_bulkUpdate();
|
||
});
|
||
|
||
$('vl-bulk-hide')?.addEventListener('click', async () => {
|
||
if (!_bulkSelected.size) return;
|
||
const ids = [..._bulkSelected];
|
||
const r = await _bulkSetEnabled(ids, false);
|
||
toast(`Hidden ${r} voice${r!==1?'s':''}`, 'success');
|
||
_bulkSelected.clear();
|
||
await loadVoiceLibrary();
|
||
});
|
||
|
||
$('vl-bulk-unhide')?.addEventListener('click', async () => {
|
||
if (!_bulkSelected.size) return;
|
||
const ids = [..._bulkSelected];
|
||
const r = await _bulkSetEnabled(ids, true);
|
||
toast(`Unhidden ${r} voice${r!==1?'s':''}`, 'success');
|
||
_bulkSelected.clear();
|
||
await loadVoiceLibrary();
|
||
});
|
||
|
||
$('vl-bulk-tag')?.addEventListener('click', () => {
|
||
if (!_bulkSelected.size) return;
|
||
const ids = [..._bulkSelected];
|
||
// Show a small inline prompt inside the toolbar
|
||
const bar = $('vl-bulk-bar');
|
||
let inp = bar.querySelector('.vl-bulk-tag-inp');
|
||
if (!inp) {
|
||
inp = document.createElement('input');
|
||
inp.type = 'text';
|
||
inp.className = 'vl-bulk-tag-inp';
|
||
inp.placeholder = 'tag1, tag2…';
|
||
inp.autocomplete = 'off';
|
||
const applyBtn = document.createElement('button');
|
||
applyBtn.className = 'vl-bulk-btn';
|
||
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
|
||
applyBtn.addEventListener('click', async () => {
|
||
const tagVal = inp.value.trim();
|
||
let done = 0;
|
||
for (const id of ids) {
|
||
await saveMeta(id, { tag: tagVal }).catch(() => {});
|
||
done++;
|
||
}
|
||
toast(`Tag set on ${done} voice${done!==1?'s':''}`, 'success');
|
||
inp.remove(); applyBtn.remove();
|
||
_bulkSelected.clear();
|
||
await loadVoiceLibrary();
|
||
});
|
||
bar.appendChild(inp);
|
||
bar.appendChild(applyBtn);
|
||
}
|
||
inp.focus();
|
||
});
|
||
|
||
$('vl-bulk-source')?.addEventListener('click', () => {
|
||
if (!_bulkSelected.size) return;
|
||
const ids = [..._bulkSelected];
|
||
const bar = $('vl-bulk-bar');
|
||
let inp = bar.querySelector('.vl-bulk-source-inp');
|
||
if (!inp) {
|
||
inp = document.createElement('input');
|
||
inp.type = 'text';
|
||
inp.className = 'vl-bulk-source-inp';
|
||
inp.placeholder = 'e.g. fish-audio, cloned…';
|
||
inp.autocomplete = 'off';
|
||
const applyBtn = document.createElement('button');
|
||
applyBtn.className = 'vl-bulk-btn';
|
||
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
|
||
applyBtn.addEventListener('click', async () => {
|
||
const val = inp.value.trim();
|
||
let done = 0;
|
||
for (const id of ids) {
|
||
await saveMeta(id, { origin: val }).catch(() => {});
|
||
done++;
|
||
}
|
||
toast(`Source set on ${done} voice${done !== 1 ? 's' : ''}`, 'success');
|
||
inp.remove(); applyBtn.remove();
|
||
_bulkSelected.clear();
|
||
await loadVoiceLibrary();
|
||
});
|
||
bar.appendChild(inp);
|
||
bar.appendChild(applyBtn);
|
||
}
|
||
inp.focus();
|
||
});
|
||
|
||
$('vl-bulk-rating')?.addEventListener('click', () => {
|
||
if (!_bulkSelected.size) return;
|
||
const ids = [..._bulkSelected];
|
||
const bar = $('vl-bulk-bar');
|
||
let sel = bar.querySelector('.vl-bulk-rating-sel');
|
||
if (!sel) {
|
||
sel = document.createElement('select');
|
||
sel.className = 'vl-bulk-rating-sel';
|
||
sel.innerHTML = '<option value="">⭐ Pick rating…</option>' +
|
||
[1,2,3,4,5].map(n => `<option value="${n}">${'★'.repeat(n)}</option>`).join('');
|
||
sel.addEventListener('change', async () => {
|
||
const rating = Number(sel.value);
|
||
if (!rating) return;
|
||
let done = 0;
|
||
for (const id of ids) {
|
||
await saveMeta(id, { rating }).catch(() => {});
|
||
done++;
|
||
}
|
||
toast(`Rated ${done} voice${done!==1?'s':''}`, 'success');
|
||
sel.remove();
|
||
_bulkSelected.clear();
|
||
await loadVoiceLibrary();
|
||
});
|
||
bar.appendChild(sel);
|
||
sel.focus();
|
||
}
|
||
});
|
||
|
||
$('vl-bulk-delete')?.addEventListener('click', () => {
|
||
if (!_bulkSelected.size) return;
|
||
_showBulkDeleteConfirm([..._bulkSelected]);
|
||
});
|
||
|
||
function _showBulkDeleteConfirm(ids) {
|
||
document.querySelector('.vl-bdc-overlay')?.remove();
|
||
const plural = ids.length !== 1 ? 's' : '';
|
||
const names = ids.slice(0, 12).map(id => `<span class="vl-bdc-id" title="${escHtml(id)}">${escHtml(id)}</span>`).join('');
|
||
const more = ids.length > 12 ? `<span class="vl-bdc-more">+${ids.length - 12} more</span>` : '';
|
||
|
||
const ov = document.createElement('div');
|
||
ov.className = 'vl-bdc-overlay';
|
||
ov.innerHTML = `
|
||
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices">
|
||
<div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div>
|
||
<p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p>
|
||
<div class="vl-bdc-list">${names}${more}</div>
|
||
<div class="vl-bdc-actions">
|
||
<button type="button" class="vl-bdc-cancel">Cancel</button>
|
||
<button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); };
|
||
function onKey(e) { if (e.key === 'Escape') close(); }
|
||
ov.addEventListener('click', e => { if (e.target === ov) close(); });
|
||
ov.querySelector('.vl-bdc-cancel').addEventListener('click', close);
|
||
document.addEventListener('keydown', onKey);
|
||
|
||
ov.querySelector('.vl-bdc-go').addEventListener('click', async () => {
|
||
const goBtn = ov.querySelector('.vl-bdc-go');
|
||
const cancelBtn = ov.querySelector('.vl-bdc-cancel');
|
||
goBtn.disabled = cancelBtn.disabled = true;
|
||
let done = 0, errors = 0;
|
||
await runPool(ids, async (id) => {
|
||
try {
|
||
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
if (r.ok) done++; else errors++;
|
||
} catch (_) { errors++; }
|
||
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}…`; });
|
||
close();
|
||
toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
|
||
_bulkSelected.clear();
|
||
await loadVoiceLibrary();
|
||
});
|
||
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('.vl-bdc-cancel').focus();
|
||
}
|
||
|
||
// ── Bulk API helpers ────────────────────────────────────────────────────────
|
||
|
||
async function _bulkSetEnabled(ids, enabled) {
|
||
let done = 0;
|
||
for (const id of ids) {
|
||
await saveMeta(id, { enabled }).catch(() => {});
|
||
done++;
|
||
}
|
||
return done;
|
||
}
|