// ── Save voice ────────────────────────────────────────────────────────────
$('save-btn').addEventListener('click', async () => {
const id = trimmedFileId||designedFileId||currentFileId;
if (!id) { toast('No audio ready','error'); return; }
const voiceId=$('voice-id-input').value.trim();
if (!voiceId) { toast('Enter a Voice ID','error'); return; }
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; }
$('save-btn').disabled=true;
try {
const payload = {id, voice_id:voiceId, path:editingVoicePath, transcript:$('transcript-area').value};
const sendSave = endpoint => fetch(endpoint, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
let fallbackSave = false;
let r = await sendSave(editingVoiceId ? '/api/voice-replace' : '/api/save');
if (editingVoiceId && (r.status === 404 || r.status === 405)) {
fallbackSave = true;
status('Update endpoint unavailable; saving as a regular voice…');
r = await sendSave('/api/save');
}
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json(); $('save-result').style.display=''; toast((editingVoiceId ? 'Voice updated: ' : 'Voice saved: ')+d.voice_id,'success');
if (fallbackSave && editingVoiceId && voiceId !== editingVoiceId) {
const del = await fetch('/api/voice/' + encodeURIComponent(editingVoiceId), {method:'DELETE'});
if (!del.ok) status('Saved renamed voice; old library entry may need manual deletion.');
}
editingVoiceId = null; editingVoicePath = null;
} catch(e) { toast('Save failed: '+e.message,'error'); }
finally { $('save-btn').disabled=false; }
});
// ══════════════════════════════════════════════════════════════════════════
// LIBRARY — sort + render
// ══════════════════════════════════════════════════════════════════════════
let _voices = [];
let _pendingSelectId = null;
let _sortField = 'id';
let _sortDir = 1; // 1 = asc, -1 = desc
let _libraryIssueFilter = '';
let _activePlayButton = null;
let _activePlayVoiceId = null;
let _activePlayUrl = null;
let _libraryLoadPromise = null;
const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text';
const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''};
const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.';
function benchmarkSampleText() {
const el = $('benchmark-sample-text');
return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
}
function initBenchmarkSampleControls() {
const sample = $('benchmark-sample-text');
if (!sample) return;
sample.value = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
sample.addEventListener('input', debounce(() => {
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value.trim());
status('Benchmark sample sentence saved');
}, 500));
$('benchmark-reset-sample-btn')?.addEventListener('click', () => {
sample.value = DEFAULT_BENCHMARK_SAMPLE_TEXT;
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
status('Benchmark sample sentence reset');
});
$('benchmark-use-preview-btn')?.addEventListener('click', () => {
const text = $('preview-text-area')?.value.trim();
if (!text) { toast('Preview text is empty', 'error'); return; }
sample.value = text;
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text);
status('Benchmark sample sentence copied from TTS preview');
});
}
function getSortValue(v, field) {
switch(field) {
case 'has_picture': return v.has_picture ? 1 : 0;
case 'flag': return (v.flag || '').toLowerCase();
case 'gender': return {'F':0,'M':1,'N':2}[v.gender] ?? 3;
case 'id': return v.id.toLowerCase();
case 'file_type': return voiceFileType(v);
case 'duration': return v.duration || 0;
case 'dbfs': return voiceDbfs(v) ?? -999;
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase();
case 'rating': return v.rating || 0;
case 'enabled': return v.enabled === false ? 0 : 1;
default: return '';
}
}
function setSort(field) {
_sortDir = (_sortField === field) ? _sortDir * -1 : 1;
_sortField = field;
syncSortHeaders();
renderVoiceList();
}
function toggleSortDir() {
_sortDir *= -1;
syncSortHeaders();
renderVoiceList();
}
function syncSortHeaders() {
document.querySelectorAll('.vl-header [data-sort]').forEach(el => {
el.classList.remove('sort-asc', 'sort-desc');
if (el.dataset.sort === _sortField)
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
});
const 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();
}
});
// Restore the grouped-by-tag preference on load
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
document.addEventListener('change', e => {
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
});
const FLAG_LANGUAGE_CANDIDATES = {
GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'],
DE:['DE'], AT:['DE'], CH:['DE','FR','IT'],
FR:['FR'], BE:['FR','NL'], LU:['FR','DE'],
ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'],
PT:['PT'], BR:['PT'],
IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'],
CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'],
EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'],
CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'],
SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'],
};
const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]]));
const LANGUAGE_LABELS = {
EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish',
DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian',
HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian',
RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali',
AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish',
};
const SEX_FILTER_LABELS = {
F:'♀ Female',
M:'♂ Male',
N:'⚥ Diverse / neutral',
};
function voiceLangFromName(v) {
return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase();
}
function libraryVoiceLang(v) {
const fromName = voiceLangFromName(v);
const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()];
if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0];
return fromName;
}
function libraryLanguageLabel(code) {
return LANGUAGE_LABELS[code] || code;
}
function populateLibraryFilters() {
const langSel = $('library-filter-lang');
const sexSel = $('library-filter-sex');
const typeSel = $('library-filter-type');
if (!langSel || !sexSel || !typeSel) return;
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
const sexOrder = ['F','M','N'];
const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))]
.sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b)));
const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort();
langSel.innerHTML = 'All languages ' + langs.map(x => `${escHtml(libraryLanguageLabel(x))} (${escHtml(x)}) `).join('');
sexSel.innerHTML = 'All ' + sexes.map(x => `${escHtml(SEX_FILTER_LABELS[x] || x)} `).join('');
typeSel.innerHTML = 'All ' + types.map(x => `${escHtml(x.toUpperCase())} `).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) {
return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null;
}
function voiceBenchmarkElapsed(v) {
const b = voiceBenchmark(v);
const value = b && b.elapsed_sec;
return value == null || Number.isNaN(Number(value)) ? null : Number(value);
}
function fmtBenchmark(v) {
const b = voiceBenchmark(v);
if (!b) return '-';
if (!b.ok) return 'ERR';
const elapsed = voiceBenchmarkElapsed(v);
if (elapsed == null) return '-';
const speed = b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : '';
return elapsed.toFixed(1) + 's' + speed;
}
function benchmarkClass(v) {
const b = voiceBenchmark(v);
if (!b) return '';
if (!b.ok || b.clipped || b.realtime_ok === false) return 'bench-bad';
const elapsed = voiceBenchmarkElapsed(v);
return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn';
}
function voiceFileUrl(v) {
const version = v._audioVersion || v.updated_at || v.benchmarked_at || '';
const bust = version || Date.now();
return `/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`;
}
function markVoiceAudioChanged(v) {
v._audioVersion = Date.now();
}
function benchmarkTitle(v) {
const b = voiceBenchmark(v);
if (!b) return 'Not benchmarked yet';
const parts = [];
if (b.ok) {
parts.push(`total ${Number(b.elapsed_sec || 0).toFixed(2)}s`);
if (b.ttfa_ms != null) parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`);
if (b.audio_sec != null) parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`);
if (b.rtf != null) parts.push(`RTF ${Number(b.rtf).toFixed(2)}`);
if (b.speed != null) parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`);
if (b.clipped) parts.push('output clipped');
} else {
parts.push('benchmark failed');
if (b.error) parts.push(b.error);
}
if (Array.isArray(b.advice) && b.advice.length) parts.push(b.advice.join(' | '));
if (b.benchmarked_at) parts.push(`saved ${b.benchmarked_at}`);
return parts.join(' · ');
}
async function clientVoiceLoudness(v) {
if (!v.path) throw new Error('No audio path');
const resp = await fetch(voiceFileUrl(v), {cache:'no-store'});
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
const audioData = await resp.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const buffer = await ctx.decodeAudioData(audioData.slice(0));
let sum = 0, peak = 0, count = 0;
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
const data = buffer.getChannelData(ch);
count += data.length;
for (let i = 0; i < data.length; i++) {
const sample = data[i];
sum += sample * sample;
peak = Math.max(peak, Math.abs(sample));
}
}
const rms = Math.sqrt(sum / Math.max(1, count));
const dbfs = rms > 0 ? 20 * Math.log10(rms) : null;
const peakDbfs = peak > 0 ? 20 * Math.log10(peak) : null;
return {
dbfs: dbfs == null ? null : Number(dbfs.toFixed(2)),
peak_dbfs: peakDbfs == null ? null : Number(peakDbfs.toFixed(2)),
};
}
async function clientCalculateVoiceDb() {
const voices = visibleLibraryVoices();
const errors = [];
let calculated = 0;
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'};
setBenchmarkProgress(0, voices.length, 'Preparing dB scan...', stats);
for (const v of voices) {
setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats);
try {
v.loudness = await clientVoiceLoudness(v);
await saveMeta(v.id, { loudness: v.loudness }).catch(()=>{});
calculated++;
stats.ok++;
stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
status(`Calculated dB: ${calculated} / ${voices.length}`);
} catch(e) {
errors.push({voice_id:v.id, detail:e.message});
stats.errors++;
stats.last = `${v.id}: ${e.message}`;
}
setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats);
await new Promise(resolve => setTimeout(resolve, 0));
}
setBenchmarkProgress(voices.length, voices.length, 'dB scan complete', stats);
return { calculated, errors, voices: voices.map(v => ({voice_id:v.id, loudness:v.loudness})) };
}
async function hydrateVoiceDuration(v, el) {
if (!v.path || !needsDuration(v) || v._durationLoading) return;
v._durationLoading = true;
try {
const audio = new Audio();
audio.preload = 'metadata';
audio.src = voiceFileUrl(v);
await new Promise((resolve, reject) => {
audio.onloadedmetadata = resolve;
audio.onerror = () => reject(new Error('Could not read duration'));
});
if (Number.isFinite(audio.duration) && audio.duration > 0) {
v.duration = audio.duration;
if (el && document.body.contains(el)) {
el.textContent = fmtDuration(v.duration);
el.title = String(v.duration.toFixed(2));
}
}
audio.removeAttribute('src');
audio.load();
} catch(e) {
if (el && document.body.contains(el)) el.title = e.message;
} finally {
v._durationLoading = false;
}
}
document.querySelectorAll('.vl-header [data-sort]').forEach(el =>
el.addEventListener('click', () => setSort(el.dataset.sort))
);
function dominantLanguages(limit = 3) {
const counts = new Map();
(_voices || []).forEach(v => {
const lang = (v.lang || String(v.id || '').split('_')[0] || '?').toUpperCase();
counts.set(lang, (counts.get(lang) || 0) + 1);
});
return [...counts.entries()]
.sort((a,b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([lang, count]) => `${lang} ${count}`)
.join(' · ') || '-';
}
function updateLibraryInsights(state = 'ready') {
const el = $('library-insights');
if (!el) return;
if (state === 'loading') {
el.innerHTML = [
['…', 'Loading'], ['…', 'Active'], ['…', 'Languages'], ['…', 'Benchmarks'], ['…', 'Quality'], ['…', 'Actions']
].map(([value, label]) => `
${value} ${label}
`).join('');
return;
}
if (state === 'error') {
el.innerHTML = 'Failed Library load
';
return;
}
const total = _voices.length;
const active = _voices.filter(v => v.enabled !== false).length;
const hidden = total - active;
const bench = _voices.map(voiceBenchmark).filter(Boolean);
const slow = bench.filter(b => b && b.ok && b.realtime_ok === false).length;
const dbValues = _voices.map(voiceDbfs).filter(v => v != null);
const avgDb = dbValues.length ? (dbValues.reduce((a,b) => a + b, 0) / dbValues.length).toFixed(1) : '-';
const missingRef = _voices.filter(v => !v.transcript).length;
const restart = _voices.filter(v => v.needs_tts_restart).length;
const visible = _voices.filter(v => $('show-disabled-cb').checked || v.enabled !== false).length;
const tiles = [
{value:`${visible}/${total}`, label:'Visible'},
{value:`${active} on`, label:hidden ? `${hidden} hidden` : 'Active'},
{value:dominantLanguages(), label:'Languages'},
{value:bench.length ? `${bench.length} done` : '-', label:slow ? `${slow} slow` : 'Benchmarks', filter: slow ? 'slow' : '', title: slow ? describeIssueVoices('slow') : 'No slow voices'},
{value:avgDb === '-' ? '-' : `${avgDb} dB`, label:missingRef ? `${missingRef} no text` : 'Avg loudness', filter: missingRef ? 'no_text' : '', title: missingRef ? describeIssueVoices('no_text') : 'All visible voices have reference text'},
{value:restart || '-', label:restart ? 'Need restart' : 'Restart flags', filter: restart ? 'restart' : '', title: restart ? describeIssueVoices('restart') : 'No voices need restart'},
];
el.innerHTML = tiles.map(item => {
const filter = item.filter ? ` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"` : '';
const activeCls = item.filter && item.filter === _libraryIssueFilter ? ' active' : '';
const title = item.title ? ` title="${escHtml(item.title)}"` : '';
return `${escHtml(item.value)} ${escHtml(item.label)}
`;
}).join('');
el.querySelectorAll('[data-filter]').forEach(tile => {
const activate = () => setLibraryIssueFilter(tile.dataset.filter || '');
tile.addEventListener('click', activate);
tile.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } });
});
}
async function loadVoiceLibrary() {
if (_libraryLoadPromise) return _libraryLoadPromise;
_libraryLoadPromise = (async () => {
setBusyButton('refresh-voices-btn', true);
const list = $('voice-list');
if (list) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
$('voice-count').textContent = 'Loading voices…';
updateLibraryInsights('loading');
status('Loading voice library…');
try {
const r = await fetch('/api/voices');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
_voices = await r.json();
window._voices = _voices; // expose for cross-module access (Script Rehearser etc.)
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
updatePreviewVoiceMatchPanel();
if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`);
} catch(e) {
if (list) list.innerHTML = 'Failed to load voices
';
$('voice-count').textContent = 'Load failed';
updateLibraryInsights('error');
status('Voice library load failed');
throw e;
} finally {
setBusyButton('refresh-voices-btn', false);
_libraryLoadPromise = null;
}
})();
return _libraryLoadPromise;
}
$('refresh-voices-btn').addEventListener('click', loadVoiceLibrary);
$('sync-voice-folders-btn').addEventListener('click', async () => {
$('sync-voice-folders-btn').disabled = true;
status('Syncing active_voices and hidden_voices…');
try {
const r = await fetch('/api/voices/sync-folders', { method:'POST' });
if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); }
const d = await r.json();
await loadVoiceLibrary();
const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : '';
toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success');
status(`Synced folders. Restart Qwen3-TTS after changing active voices.`);
} catch(e) {
toast('Sync failed: ' + e.message, 'error');
status('Folder sync failed');
} finally { $('sync-voice-folders-btn').disabled = false; }
});
function visibleLibraryVoices() {
const showDisabled = $('show-disabled-cb').checked;
return _voices.filter(v => showDisabled || v.enabled !== false);
}
function libraryIssueMatch(v, filter = _libraryIssueFilter) {
const b = voiceBenchmark(v);
if (filter === 'slow') return Boolean(b && b.ok && b.realtime_ok === false);
if (filter === 'no_text') return !String(v.transcript || '').trim();
if (filter === 'restart') return Boolean(v.needs_tts_restart);
return true;
}
function libraryIssueLabel(filter = _libraryIssueFilter) {
return {slow:'slow benchmark voices', no_text:'voices without reference text', restart:'voices needing TTS restart'}[filter] || 'all voices';
}
function libraryIssueVoices(filter = _libraryIssueFilter) {
return visibleLibraryVoices().filter(v => libraryIssueMatch(v, filter));
}
function describeIssueVoices(filter = _libraryIssueFilter, limit = 12) {
const voices = libraryIssueVoices(filter).map(v => v.id);
if (!voices.length) return 'No matching voices';
const extra = voices.length > limit ? `, +${voices.length - limit} more` : '';
return voices.slice(0, limit).join(', ') + extra;
}
function setLibraryIssueFilter(filter = '') {
_libraryIssueFilter = _libraryIssueFilter === filter ? '' : filter;
renderVoiceList();
if (_libraryIssueFilter) status(`${libraryIssueLabel()}: ${describeIssueVoices()}`);
else status('Showing all visible voices');
}
function libraryTargetDb() {
const input = $('library-target-db');
const raw = Number(input?.value ?? -20);
const value = Number.isFinite(raw) ? Math.min(-1, Math.max(-60, raw)) : -20;
if (input) input.value = String(value);
return value;
}
$('calculate-db-btn').addEventListener('click', async () => {
$('calculate-db-btn').disabled = true;
status('Calculating voice loudness…');
try {
const d = await clientCalculateVoiceDb();
renderVoiceList();
const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : '';
toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success');
status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`);
} catch(e) {
toast('Calculate dB failed: ' + e.message, 'error');
status('dB calculation failed');
} finally { $('calculate-db-btn').disabled = false; }
});
$('normalize-volume-btn').addEventListener('click', async () => {
const target = libraryTargetDb();
const visible = visibleLibraryVoices();
const voices = visible.filter(v => voiceFileType(v) === 'wav');
const skipped = visible.length - voices.length;
if (!voices.length) {
toast('No visible WAV voices to normalize', 'error');
return;
}
if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return;
$('normalize-volume-btn').disabled = true;
$('calculate-db-btn').disabled = true;
const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'};
const errors = [];
let normalized = 0;
setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats);
status(`Normalizing ${voices.length} voices to ${target} dBFS...`);
try {
for (const v of voices) {
setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
try {
const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.loudness = d.loudness || v.loudness;
v.duration = d.duration ?? v.duration;
v.file_type = d.file_type || v.file_type;
v.path = d.path || v.path;
v.needs_tts_restart = true;
markVoiceAudioChanged(v);
normalized++;
stats.ok++;
stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
} catch(e) {
errors.push({voice_id:v.id, detail:e.message});
stats.errors++;
stats.last = `${v.id}: ${e.message}`;
}
setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
status(`Normalized ${normalized} / ${voices.length}`);
await new Promise(resolve => setTimeout(resolve, 0));
}
setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats);
renderVoiceList();
updateLibraryInsights();
const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`;
toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success');
status('Volume normalized. Restart TTS before rebenchmarking these voices.');
} catch(e) {
toast('Normalize volume failed: ' + e.message, 'error');
status('Normalize volume failed');
} finally {
$('normalize-volume-btn').disabled = false;
$('calculate-db-btn').disabled = false;
}
});
function fmtClock(ms) {
if (!Number.isFinite(ms) || ms < 0) return '-';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2,'0')}`;
}
function setBenchmarkProgress(done, total, label = '', stats = {}) {
const panel = $('benchmark-progress');
const track = panel.querySelector('.benchmark-progress-track');
const pct = total ? Math.round(done / total * 100) : 0;
panel.hidden = false;
$('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...');
$('benchmark-progress-count').textContent = `${done} / ${total}`;
$('benchmark-progress-bar').style.width = pct + '%';
track.setAttribute('aria-valuenow', String(pct));
const live = $('benchmark-live-stats');
if (live) {
const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0;
const avg = done > 0 ? elapsed / done : 0;
const eta = done > 0 && total > done ? avg * (total - done) : 0;
live.innerHTML = [
`Elapsed ${fmtClock(elapsed)}`,
`Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`,
`ETA ${done && total > done ? fmtClock(eta) : '-'}`,
`OK ${stats.ok || 0}`,
`${stats.middleLabel || 'Slow'} ${stats.slow || 0}`,
`${stats.errorLabel || 'Errors'} ${stats.errors || 0}`,
].map(x => `${escHtml(x)} `).join('');
}
const last = $('benchmark-live-last');
if (last && stats.last) last.textContent = stats.last;
}
function hideBenchmarkProgress() {
$('benchmark-progress').hidden = true;
$('benchmark-progress-bar').style.width = '0%';
if ($('benchmark-live-last')) $('benchmark-live-last').textContent = '';
}
async function clearTtsRestartFlags() {
const r = await fetch('/api/tts/restart-flags/clear', { method:'POST' });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_voices.forEach(voice => { voice.needs_tts_restart = false; });
document.querySelectorAll('.vl-row.edit-open').forEach(row => row.classList.remove('opt-restart-needed'));
updateLibraryInsights();
return d;
}
async function runVoiceBenchmark(voiceId = '', opts = {}) {
const text = opts.text ?? benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
const payload = {active_only:true, text};
if (voiceId) payload.voice_id = voiceId;
const r = await fetch('/api/voices/benchmark', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
return r.json();
}
async function runVoiceBenchmarkBatch() {
const voices = 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: ''};
setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
for (let i = 0; i < voices.length; i++) {
const voice = voices[i];
setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats);
status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`);
try {
const d = await runVoiceBenchmark(voice.id, {text});
if (d) {
aggregate.benchmarked += Number(d.benchmarked || 0);
aggregate.errors.push(...(d.errors || []));
aggregate.voices.push(...(d.voices || []));
mergeBenchmarkResults(d);
const hit = (d.voices || []).find(x => x.voice_id === voice.id);
const b = hit && hit.benchmark;
if (b && b.ok) {
stats.ok++;
if (b.realtime_ok === false) stats.slow++;
stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`;
} else {
stats.errors++;
stats.last = `${voice.id}: failed${b && b.error ? ' · ' + b.error : ''}`;
}
}
} catch(e) {
aggregate.errors.push({voice_id: voice.id, detail: e.message});
stats.errors++;
stats.last = `${voice.id}: failed · ${e.message}`;
}
setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats);
}
setBenchmarkProgress(total, total, 'Benchmark complete', stats);
return aggregate;
}
function mergeBenchmarkResults(d) {
const byId = new Map((d.voices || []).map(x => [x.voice_id, x]));
_voices.forEach(v => {
const hit = byId.get(v.id);
if (hit && hit.benchmark) v.benchmark = hit.benchmark;
});
}
function activeBenchmarkVoices() {
return (_voices || []).filter(v => v.enabled !== false);
}
// 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 active = activeVoiceIds();
if (!active.length) { toast('No active voices to copy', 'error'); return; }
await copyText(active.join(', '));
toast('Copied ' + active.length + ' active voices', 'success');
status('Copied active voices to clipboard');
});
const LIB_ADD_SAMPLE_TEXTS = {
EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.',
DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.',
IT: 'La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.',
ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.',
FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.',
PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.',
NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.',
PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.'
};
const LIB_ADD_SAMPLE_STORAGE_KEY = 'vcf-lib-add-sample-texts';
function libAddSampleOverrides() {
try { return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY) || '{}') || {}; }
catch(e) { return {}; }
}
function getLibAddSampleText(code) {
return libAddSampleOverrides()[code] || LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN;
}
function saveLibAddSampleText() {
const code = $('lib-add-sample-lang').value;
const text = $('lib-add-sample-text').value.trim();
const overrides = libAddSampleOverrides();
if (text && text !== LIB_ADD_SAMPLE_TEXTS[code]) overrides[code] = text;
else delete overrides[code];
localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides));
setLibAddStatus('Sample sentence saved');
}
function resetLibAddSampleText() {
const code = $('lib-add-sample-lang').value;
const overrides = libAddSampleOverrides();
delete overrides[code];
localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides));
$('lib-add-sample-text').value = LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN;
setLibAddStatus('Sample sentence reset');
}
function updateLibAddSampleLanguage(lang) {
const code = LIB_ADD_SAMPLE_TEXTS[lang] ? lang : 'EN';
$('lib-add-sample-lang').value = code;
$('lib-add-lang').value = code;
$('lib-add-sample-text').value = getLibAddSampleText(code);
const voiceId = $('lib-add-voice-id').value.trim();
if (voiceId && /^[A-Z]{2}_/.test(voiceId)) {
$('lib-add-voice-id').value = voiceId.replace(/^[A-Z]{2}_/, code + '_');
}
}
function renderLibAddMeter(level = 0, db = -Infinity, clipped = false) {
const meter = $('lib-add-mic-meter');
if (!meter.children.length) {
for (let i = 0; i < 18; i++) {
const bar = document.createElement('div');
bar.className = 'bar';
meter.appendChild(bar);
}
}
const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length);
[...meter.children].forEach((bar, i) => {
bar.className = 'bar';
bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px';
if (i < active) {
bar.classList.add('on');
if (db > -12 && i > 11) bar.classList.add('hot');
if (clipped && i > 14) bar.classList.add('clip');
}
});
$('lib-add-db-readout').textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
}
function syncLibAddMicGain() {
const gain = parseFloat($('lib-add-mic-gain').value) || 0;
$('lib-add-mic-gain-value').textContent = gain.toFixed(2) + 'x';
if (libAddState.gainNode) libAddState.gainNode.gain.value = gain;
}
function startLibAddMeter() {
if (!libAddState.analyser) return;
if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf);
const data = new Float32Array(libAddState.analyser.fftSize);
const tick = () => {
libAddState.analyser.getFloatTimeDomainData(data);
let sum = 0, peak = 0;
for (const sample of data) {
sum += sample * sample;
peak = Math.max(peak, Math.abs(sample));
}
const rms = Math.sqrt(sum / data.length);
const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
const level = Number.isFinite(db) ? (db + 60) / 60 : 0;
renderLibAddMeter(level, db, peak > 0.98);
libAddState.meterRaf = requestAnimationFrame(tick);
};
tick();
}
async function ensureLibAddMicMonitor() {
if (libAddState.recordStream) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
libAddState.stream = await requestMicrophoneStream({raw:true});
if (AudioCtx) {
libAddState.audioCtx = new AudioCtx();
libAddState.sourceNode = libAddState.audioCtx.createMediaStreamSource(libAddState.stream);
libAddState.gainNode = libAddState.audioCtx.createGain();
libAddState.analyser = libAddState.audioCtx.createAnalyser();
libAddState.analyser.fftSize = 1024;
const dest = libAddState.audioCtx.createMediaStreamDestination();
syncLibAddMicGain();
libAddState.sourceNode.connect(libAddState.gainNode);
libAddState.gainNode.connect(libAddState.analyser);
libAddState.gainNode.connect(dest);
libAddState.recordStream = dest.stream;
startLibAddMeter();
} else {
libAddState.recordStream = libAddState.stream;
}
libAddState.monitoring = true;
$('lib-add-monitor-btn').disabled = true;
$('lib-add-monitor-stop').disabled = false;
}
function stopLibAddMic() {
if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf);
libAddState.meterRaf = null;
[libAddState.sourceNode, libAddState.gainNode, libAddState.analyser].forEach(node => {
try { if (node) node.disconnect(); } catch(e) {}
});
if (libAddState.stream) libAddState.stream.getTracks().forEach(t => t.stop());
if (libAddState.recordStream) libAddState.recordStream.getTracks().forEach(t => t.stop());
if (libAddState.audioCtx) libAddState.audioCtx.close().catch(()=>{});
libAddState.stream = null;
libAddState.recordStream = null;
libAddState.sourceNode = null;
libAddState.gainNode = null;
libAddState.analyser = null;
libAddState.audioCtx = null;
libAddState.monitoring = false;
$('lib-add-monitor-btn').disabled = false;
$('lib-add-monitor-stop').disabled = true;
renderLibAddMeter(0, -Infinity, false);
}
let libAddState = {
id:null, duration:0, audio:null, buffer:null, recorder:null, chunks:[], pendingSource:null,
stream:null, recordStream:null, timer:null, secs:0, audioCtx:null,
sourceNode:null, gainNode:null, analyser:null, meterRaf:null, monitoring:false
};
window.libAddState = libAddState;
$('add-new-voice-btn').addEventListener('click', () => {
$('lib-add-panel').classList.toggle('open');
});
$('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value));
$('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value));
$('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500));
$('lib-add-use-sample').addEventListener('click', () => {
$('lib-add-transcript').value = $('lib-add-sample-text').value.trim();
setLibAddStatus('Sample sentence copied to transcript');
});
$('lib-add-reset-sample').addEventListener('click', resetLibAddSampleText);
$('lib-add-mic-help-btn').addEventListener('click', () => {
$('lib-add-mic-help').classList.toggle('open');
});
$('lib-add-monitor-btn').addEventListener('click', async () => {
try {
await ensureLibAddMicMonitor();
setLibAddStatus('Mic level monitor active');
} catch(e) {
stopLibAddMic();
$('lib-add-mic-help').classList.add('open');
const message = await microphoneErrorMessage(e);
toast(message, 'error');
setLibAddStatus(message);
}
});
$('lib-add-monitor-stop').addEventListener('click', () => {
stopLibAddMic();
setLibAddStatus('Mic level monitor stopped');
});
$('lib-add-mic-gain').addEventListener('input', syncLibAddMicGain);
renderLibAddMeter();
syncLibAddMicGain();
updateLibAddSampleLanguage('EN');
function setLibAddStatus(msg) {
$('lib-add-status').textContent = msg;
status(msg);
}
function suggestLibVoiceId(filename) {
if ($('lib-add-voice-id').value.trim()) return;
const base = String(filename || 'NewVoice')
.replace(/\.[^.]+$/, '')
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 60) || 'NewVoice';
$('lib-add-voice-id').value = `${$('lib-add-lang').value || 'EN'}_${$('lib-add-gender').value || 'N'}_${base}`;
}
function loadLibAddAudio(id, duration, label = 'Audio') {
libAddState.id = id;
libAddState.duration = Number(duration) || 0;
libAddState.buffer = null;
$('lib-add-start').value = '0.00';
$('lib-add-end').value = libAddState.duration ? Math.min(libAddState.duration, 20).toFixed(2) : '0.00';
$('lib-add-audio').src = '/api/audio/' + id;
$('lib-add-audio').style.display = '';
$('lib-add-wave').style.display = '';
attachLibAddWaveSelection();
decodeTempAudio(id).then(buffer => {
if (libAddState.id !== id) return;
libAddState.buffer = buffer;
drawLibAddWave();
}).catch(()=>{});
setLibAddStatus(`${label} loaded${libAddState.duration ? ' (' + libAddState.duration.toFixed(1) + ' s)' : ''}`);
}
async function decodeTempAudio(id) {
const resp = await fetch('/api/audio/' + encodeURIComponent(id));
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
const data = await resp.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
return ctx.decodeAudioData(data.slice(0));
}
function clampLibAddTime(value) {
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
return Math.max(0, Math.min(duration, Number(value) || 0));
}
function setLibAddCropRange(start, end) {
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
let a = clampLibAddTime(start), b = clampLibAddTime(end);
if (Math.abs(b - a) < 0.05) b = Math.min(duration, a + Math.min(1, duration || 1));
if (b < a) [a, b] = [b, a];
$('lib-add-start').value = a.toFixed(2);
$('lib-add-end').value = b.toFixed(2);
drawLibAddWave();
}
function libAddWaveTimeFromEvent(e) {
const canvas = $('lib-add-wave');
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
return rect.width ? x / rect.width * duration : 0;
}
function updateLibAddCropHint() {
const hint = $('lib-add-crop-hint');
if (!hint) return;
const start = parseFloat($('lib-add-start').value) || 0;
const end = parseFloat($('lib-add-end').value) || 0;
const dur = Math.max(0, end - start);
hint.textContent = dur ? `Selected ${dur.toFixed(1)}s. Aim for 3-20 seconds.` : 'Select 3-20 seconds for best cloning.';
hint.className = 'crop-duration-hint ' + (dur >= 3 && dur <= 20 ? 'ok' : dur ? 'warn' : '');
}
function drawLibAddWave() {
if (!libAddState.buffer) return;
drawOptimizerWave(
$('lib-add-wave'),
libAddState.buffer,
parseFloat($('lib-add-start').value) || 0,
parseFloat($('lib-add-end').value) || libAddState.duration || libAddState.buffer.duration
);
updateLibAddCropHint();
}
function libAddWaveSelectionPixels(e) {
const canvas = $('lib-add-wave');
const rect = canvas.getBoundingClientRect();
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0);
const end = clampLibAddTime(parseFloat($('lib-add-end').value) || duration);
const sx = duration && rect.width ? start / duration * rect.width : 0;
const ex = duration && rect.width ? end / duration * rect.width : rect.width;
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
return {x, sx, ex, start, end, duration};
}
function libAddWaveDragMode(e) {
const {x, sx, ex} = libAddWaveSelectionPixels(e);
const hit = 16;
if (Math.abs(x - sx) <= hit) return 'start';
if (Math.abs(x - ex) <= hit) return 'end';
return 'new';
}
function attachLibAddWaveSelection() {
const canvas = $('lib-add-wave');
if (!canvas || canvas.dataset.cropReady) return;
canvas.dataset.cropReady = '1';
let drag = null;
canvas.addEventListener('pointerdown', e => {
if (!libAddState.buffer) return;
e.preventDefault();
const mode = libAddWaveDragMode(e);
const t = libAddWaveTimeFromEvent(e);
const currentStart = parseFloat($('lib-add-start').value) || 0;
const currentEnd = parseFloat($('lib-add-end').value) || libAddState.duration || 0;
drag = {mode, anchor: t, start: currentStart, end: currentEnd};
canvas.setPointerCapture?.(e.pointerId);
if (mode === 'start') setLibAddCropRange(t, currentEnd);
else if (mode === 'end') setLibAddCropRange(currentStart, t);
else setLibAddCropRange(t, t);
setLibAddStatus(mode === 'start' ? 'Dragging crop start handle' : mode === 'end' ? 'Dragging crop end handle' : 'Drag to choose a new crop range');
});
canvas.addEventListener('pointermove', e => {
if (!libAddState.buffer) return;
if (!drag) {
const mode = libAddWaveDragMode(e);
canvas.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : 'crosshair';
return;
}
e.preventDefault();
const t = libAddWaveTimeFromEvent(e);
if (drag.mode === 'start') setLibAddCropRange(t, drag.end);
else if (drag.mode === 'end') setLibAddCropRange(drag.start, t);
else setLibAddCropRange(drag.anchor, t);
});
const finish = e => {
if (!drag) return;
e.preventDefault();
const t = libAddWaveTimeFromEvent(e);
if (drag.mode === 'start') setLibAddCropRange(t, drag.end);
else if (drag.mode === 'end') setLibAddCropRange(drag.start, t);
else setLibAddCropRange(drag.anchor, t);
drag = null;
const start = parseFloat($('lib-add-start').value) || 0;
const end = parseFloat($('lib-add-end').value) || 0;
setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0, end - start).toFixed(1)}s) selected`);
};
canvas.addEventListener('pointerup', finish);
canvas.addEventListener('pointerleave', () => { if (!drag) canvas.style.cursor = 'crosshair'; });
canvas.addEventListener('pointercancel', () => { drag = null; canvas.style.cursor = 'crosshair'; });
}
async function uploadLibAddFile(file) {
if (!file) return;
const fd = new FormData();
fd.append('file', file);
setLibAddStatus('Uploading audio…');
try {
const r = await fetch('/api/upload', {method:'POST', body:fd});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
suggestLibVoiceId(file.name);
loadLibAddAudio(d.id, d.duration, file.name || 'Audio');
toast('Audio loaded', 'success');
} catch(e) { toast('Load failed: ' + e.message, 'error'); setLibAddStatus('Load failed'); }
}
const libAddDrop = $('lib-add-drop');
libAddDrop.addEventListener('click', () => $('lib-add-file').click());
libAddDrop.addEventListener('dragover', e => { e.preventDefault(); libAddDrop.classList.add('drag-over'); });
libAddDrop.addEventListener('dragleave', () => libAddDrop.classList.remove('drag-over'));
libAddDrop.addEventListener('drop', e => {
e.preventDefault();
libAddDrop.classList.remove('drag-over');
if (e.dataTransfer.files.length) uploadLibAddFile(e.dataTransfer.files[0]);
});
$('lib-add-file').addEventListener('change', async () => {
if ($('lib-add-file').files.length) await uploadLibAddFile($('lib-add-file').files[0]);
$('lib-add-file').value = '';
});
$('lib-add-url-btn').addEventListener('click', () => {
const url = $('lib-add-url').value.trim();
if (!url) { toast('Enter a YouTube or audio URL', 'error'); return; }
$('lib-add-url-btn').disabled = true;
setLibAddStatus('Starting download…');
const es = new EventSource('/api/download-yt?url=' + encodeURIComponent(url));
es.onmessage = e => {
const d = JSON.parse(e.data);
if (d.error) {
toast('Download failed: ' + d.error, 'error');
setLibAddStatus(d.error);
$('lib-add-url-btn').disabled = false;
es.close();
} else if (d.done) {
es.close();
$('lib-add-url-btn').disabled = false;
suggestLibVoiceId(url.split('/').pop() || 'DownloadedVoice');
loadLibAddAudio(d.id, d.duration, 'Downloaded audio');
toast('URL audio loaded', 'success');
} else {
setLibAddStatus(d.msg || 'Downloading…');
}
};
es.onerror = () => {
es.close();
$('lib-add-url-btn').disabled = false;
setLibAddStatus('Download connection closed');
};
});
$('lib-add-rec-start').addEventListener('click', async () => {
try {
await ensureLibAddMicMonitor();
libAddState.chunks = [];
libAddState.secs = 0;
$('lib-add-rec-time').textContent = '0:00';
$('lib-add-rec-start').disabled = true;
$('lib-add-rec-stop').disabled = false;
$('lib-add-monitor-stop').disabled = true;
libAddState.timer = setInterval(() => {
libAddState.secs++;
$('lib-add-rec-time').textContent = Math.floor(libAddState.secs / 60) + ':' + String(libAddState.secs % 60).padStart(2, '0');
}, 1000);
libAddState.recorder = new MediaRecorder(libAddState.recordStream);
libAddState.recorder.ondataavailable = e => { if (e.data.size) libAddState.chunks.push(e.data); };
libAddState.recorder.onstop = async () => {
clearInterval(libAddState.timer);
$('lib-add-rec-start').disabled = false;
$('lib-add-rec-stop').disabled = true;
const blob = new Blob(libAddState.chunks, {type:libAddState.recorder.mimeType || 'audio/webm'});
const ext = (libAddState.recorder.mimeType || '').includes('ogg') ? '.ogg' : '.webm';
stopLibAddMic();
suggestLibVoiceId('recording');
await uploadLibAddFile(new File([blob], 'recording' + ext, {type:blob.type}));
};
libAddState.recorder.start(100);
setLibAddStatus('Recording…');
} catch(e) {
stopLibAddMic();
$('lib-add-mic-help').classList.add('open');
const message = await microphoneErrorMessage(e);
toast(message, 'error');
setLibAddStatus(message);
$('lib-add-rec-start').disabled = false;
$('lib-add-rec-stop').disabled = true;
}
});
$('lib-add-rec-stop').addEventListener('click', () => {
if (libAddState.recorder && libAddState.recorder.state !== 'inactive') libAddState.recorder.stop();
});
$('lib-add-auto-trim').addEventListener('click', async () => {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
$('lib-add-auto-trim').disabled = true;
try {
const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id})});
let d;
if (r.ok) d = await r.json();
else if (r.status === 404 || r.status === 405) d = await clientAutoTrimBounds(libAddState.id);
else { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
$('lib-add-start').value = Number(d.start).toFixed(2);
$('lib-add-end').value = Number(d.end).toFixed(2);
drawLibAddWave();
setLibAddStatus(d.reason || 'Auto trim ready');
} catch(e) { toast('Auto trim failed: ' + e.message, 'error'); setLibAddStatus('Auto trim failed'); }
finally { $('lib-add-auto-trim').disabled = false; }
});
async function transcribeLibAddCurrent(successMessage = 'Text recognised', audioId = libAddState.id) {
if (!audioId) throw new Error('Load audio first');
setLibAddStatus('Recognising text...');
const r = await fetch('/api/transcribe', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:audioId})
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
const text = d.text || '';
$('lib-add-transcript').value = text;
setLibAddStatus(successMessage);
return text;
}
function openSavedLibraryVoice(voiceId) {
const openRow = () => {
const row = Array.from(document.querySelectorAll('.vl-row')).find(r => r.dataset.id === voiceId);
if (!row) return false;
row.scrollIntoView({behavior:'smooth', block:'center'});
if (!row.classList.contains('edit-open')) row.querySelector('.edit-audio-btn')?.click();
return true;
};
if (!openRow()) setTimeout(openRow, 150);
}
async function applyLibAddCrop() {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0);
const end = clampLibAddTime(parseFloat($('lib-add-end').value) || libAddState.duration);
const duration = end - start;
if (end <= start + 0.1) { toast('Crop range is too short', 'error'); setLibAddStatus('Crop range is too short'); return; }
if (duration < 3 || duration > 20) toast('Best clone references are 3-20 seconds; cropping anyway.', 'error');
['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = true; });
setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);
try {
const r = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id, start, end})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
loadLibAddAudio(d.id, d.duration, 'Cropped audio');
toast('Crop applied', 'success');
try {
await transcribeLibAddCurrent('Cropped audio loaded and text recognised', d.id);
} catch (e) {
toast('Crop applied, but recognition failed: ' + e.message, 'error');
setLibAddStatus('Cropped audio loaded; recognition failed');
}
} catch(e) { toast('Crop failed: ' + e.message, 'error'); setLibAddStatus('Crop failed'); }
finally { ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = false; }); }
}
$('lib-add-save-crop').addEventListener('click', applyLibAddCrop);
$('lib-add-save-crop-bottom').addEventListener('click', applyLibAddCrop);
['lib-add-start','lib-add-end'].forEach(id => $(id).addEventListener('input', drawLibAddWave));
$('lib-add-play').addEventListener('click', () => {
if (!libAddState.id) return;
if (libAddState.audio) libAddState.audio.pause();
libAddState.audio = new Audio('/api/audio/' + libAddState.id);
const start = parseFloat($('lib-add-start').value) || 0;
const end = parseFloat($('lib-add-end').value) || libAddState.duration;
libAddState.audio.currentTime = start;
libAddState.audio.ontimeupdate = () => { if (libAddState.audio.currentTime >= end) libAddState.audio.pause(); };
libAddState.audio.play();
});
$('lib-add-recognize').addEventListener('click', async () => {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
try {
await transcribeLibAddCurrent('Text recognised');
} catch(e) { toast('Recognition failed: ' + e.message, 'error'); setLibAddStatus('Recognition failed'); }
});
$('lib-add-save').addEventListener('click', async () => {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
const voiceId = $('lib-add-voice-id').value.trim() || `${$('lib-add-lang').value}_${$('lib-add-gender').value}_NewVoice`;
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters', 'error'); return; }
setLibAddStatus('Saving voice...');
$('lib-add-save').disabled = true;
try {
const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:libAddState.id, start:parseFloat($('lib-add-start').value)||0, end:parseFloat($('lib-add-end').value)||libAddState.duration})});
if (!pr.ok) { const e = await pr.json().catch(()=>({})); throw new Error(e.detail || pr.statusText); }
const p = await pr.json();
let transcript = $('lib-add-transcript').value.trim();
if (!transcript) {
transcript = await transcribeLibAddCurrent('Final clip recognised; saving voice...', p.id);
if (!transcript.trim()) throw new Error('Recognition returned no transcript; add text or try recognising again.');
}
const sr = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:p.id, voice_id:voiceId, transcript})});
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
if (libAddState.pendingSource?.imageUrl) {
try {
await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl})
});
} catch (_) {}
}
libAddState.pendingSource = null;
setLibAddSourcePreview({});
$('lib-add-panel')?.classList.remove('open');
toast('Voice saved: ' + voiceId, 'success');
setLibAddStatus('Voice saved');
await loadVoiceLibrary();
openSavedLibraryVoice(voiceId);
} catch(e) { toast('Save failed: ' + e.message, 'error'); setLibAddStatus('Save failed'); }
finally { $('lib-add-save').disabled = false; }
});
$('show-disabled-cb').addEventListener('change', () => {
$('disabled-info').style.display = $('show-disabled-cb').checked ? '' : 'none';
renderVoiceList();
});
['library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => {
$(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); });
});
$('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180));
$('library-clear-filters')?.addEventListener('click', clearLibraryFilters);
$('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); });
function renderVoiceList() {
const showDisabled = $('show-disabled-cb').checked;
const list = $('voice-list');
list.innerHTML = '';
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';
}
// 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 = `${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}Clear filter `;
note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter(''));
list.appendChild(note);
}
if (!filtered.length) {
if (_voices.length === 0) {
// True empty — render a two-case helper panel
const emptyEl = document.createElement('div');
emptyEl.className = 'voices-empty-state';
emptyEl.innerHTML = `
Wrong folder path?
Set the path where your .wav voice files live inside the container .
Browse
Set & reload
Map a host folder via docker-compose:
- /your/host/path:/voices:rw
or set VOICE_HOST_DIR=/your/host/path in the stack env.
or
Folder is empty?
Create your first voice from a recording or download ready-made voices.
Clone a Voice
Get Voices Online
`;
list.appendChild(emptyEl);
fetch('/api/settings').then(r => r.json()).then(s => {
const inp = document.getElementById('voices-empty-scan-dir');
if (inp) inp.value = s.voices_scan_dir || '/voices';
}).catch(() => {});
// ── Directory browser ──────────────────────────────────────────────────
let _vefCurrentPath = '/';
const vefBrowser = document.getElementById('vef-dir-browser');
const vefDirList = document.getElementById('vef-dir-list');
const vefCrumb = document.getElementById('vef-breadcrumb');
const vefSelPath = document.getElementById('vef-selected-path');
async function vefNavigate(path) {
_vefCurrentPath = path;
vefDirList.innerHTML = 'Loading… ';
if (vefSelPath) vefSelPath.textContent = path;
try {
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
// Breadcrumb
const parts = data.path.split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
vefCrumb.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `${escHtml(c.label)} / `
: `${escHtml(c.label)} `
).join('');
vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
// Directory list
if (!data.dirs.length) {
vefDirList.innerHTML = 'No subdirectories here. ';
} else {
vefDirList.innerHTML = data.dirs.map(d =>
` ${escHtml(d)} `
).join('');
vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
}
if (vefSelPath) vefSelPath.textContent = data.path;
_vefCurrentPath = data.path;
} catch(e) {
vefDirList.innerHTML = `Error: ${escHtml(e.message)} `;
}
}
document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => {
const open = vefBrowser.hidden;
vefBrowser.hidden = !open;
if (open) {
const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices';
vefNavigate(cur);
}
});
document.getElementById('vef-select-btn')?.addEventListener('click', () => {
const inp = document.getElementById('voices-empty-scan-dir');
if (inp) inp.value = _vefCurrentPath;
if (vefBrowser) vefBrowser.hidden = true;
});
// ──────────────────────────────────────────────────────────────────────
document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => {
const inp = document.getElementById('voices-empty-scan-dir');
const dir = inp?.value?.trim();
if (!dir) return;
const btn = document.getElementById('voices-empty-save-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const s = await fetch('/api/settings').then(r => r.json());
s.voices_scan_dir = dir;
const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
toast('Folder saved — reloading voices…', 'success');
_libraryLoadPromise = null;
await loadVoiceLibrary();
} catch(e) {
toast('Failed: ' + e.message, 'error');
btn.disabled = false; btn.textContent = 'Set & reload';
}
});
document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone'));
document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio'));
} else {
list.innerHTML += 'No voices match the current filters.
';
}
return;
}
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
else filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
if (_pendingSelectId) {
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
}
syncSortHeaders();
}
// 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()));
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 = ` `
+ ` `
+ `${escHtml(tag)} `
+ `${voices.length} `;
const body = document.createElement('div');
body.className = 'vl-tag-body';
if (isCol) body.style.display = 'none';
voices.forEach(v => body.appendChild(makeVoiceRow(v)));
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);
list.appendChild(sec);
});
}
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) : ' ';
const flagCode = currentFlag ? ccDisplay(currentFlag) : '?';
wrap.dataset.flagEmoji = flagEmoji;
wrap.dataset.flagCc = currentFlag || '';
wrap.dataset.rating = v.rating || 0;
wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false';
const fileType = voiceFileType(v);
const dbfs = fmtDbfs(v);
const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
const benchText = fmtBenchmark(v);
const benchTitle = benchmarkTitle(v);
const benchCls = benchmarkClass(v);
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
const gender = v.gender || '';
const starsHtml = [1,2,3,4,5].map(i =>
` `
).join('');
const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null;
const pickerHtml = flagOpts.length > 1
? flagOpts.map(([cc, label]) =>
`${cc2flag(cc)}${ccDisplay(cc)} `
).join('')
: `No regional variants `;
// Compact list item elements (visible in list pane, hidden in inspector)
const isClone = v.has_ref && v.origin !== 'designed';
const initial = v.id[0] ? v.id[0].toUpperCase() : '?';
const flagIconHtml = currentFlag
? ` `
: null;
// Avatar: photo > gender/type icon > flag icon > initial letter (with color)
const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
: iconHtml ? 'vl-avatar vl-avatar-icon'
: flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`;
const avatarContent = picSrc
? ` `
: iconHtml || flagIconHtml || `${initial} `;
wrap.innerHTML = `
${avatarContent}
${escHtml(dispName)}
${isClone ? 'Clone' : 'Design'}
${gender ? `${genderMap[gender]||'?'} ${genderLabel[gender]||''} ` : ''}
${benchText !== '-' ? ` ${escHtml(benchText)} ` : ''}
${picSrc ? `
` : '
'}
${flagEmoji}
${flagCode}
${pickerHtml}
${genderMap[gender]||'?'}
${genderLabel[gender]||'—'}
${escHtml(v.id)}
${escHtml(fileType.toUpperCase())}
${fmtDuration(v.duration)}
${escHtml(dbfs)}
±
${escHtml(benchText)}
⏱
${starsHtml}
Delete voice
Delete?
${escHtml(v.id)}
Cancel
Delete
Reference audio · crop
Crop the saved WAV to 3–20 s of clean speech.
Reference transcript
The spoken text that matches this voice recording.
Voice match
Compare the saved WAV with a fresh TTS synthesis.
Comparison backend ${styleBackendOptions('voice_clone')}
Play WAV file
Synthesize reference text
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.
Synthesized reference text
${!isClone ? `
Style variation
Create a styled variant and save it as a new voice (CustomVoice only).
Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.
` : ''}
Loudness
Normalize the volume of the reference audio file.
Click the pencil to load waveform and tools.
`;
const vrLengthEl = wrap.querySelector('.vr-length');
hydrateVoiceDuration(v, vrLengthEl);
// Photo upload
const photoCell = wrap.querySelector('.vr-photo');
const photoInput = wrap.querySelector('.photo-input');
photoCell.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', async () => {
if (!photoInput.files.length) return;
const fd = new FormData();
fd.append('voice_id', v.id);
fd.append('file', photoInput.files[0]);
try {
const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
if (!r.ok) throw new Error((await r.json()).detail);
const img = document.createElement('img');
img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`;
img.alt = '';
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
v.has_picture = true; toast('Photo uploaded','success');
} catch(e) { toast('Photo upload failed: '+e.message,'error'); }
});
// Per-voice loudness normalization
const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
const dbValue = wrap.querySelector('.vr-db-value');
const dbCell = wrap.querySelector('.vr-db');
normalizeBtn.addEventListener('click', async () => {
const target = libraryTargetDb();
if (!confirm(`Normalize "${v.id}" to ${target} dBFS?`)) return;
normalizeBtn.disabled = true;
status('Normalizing ' + v.id + '…');
try {
const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.loudness = d.loudness || v.loudness;
v.duration = d.duration ?? v.duration;
v.file_type = d.file_type || v.file_type;
v.path = d.path || v.path;
v.needs_tts_restart = true;
markVoiceAudioChanged(v);
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
toast('Normalized: ' + v.id, 'success');
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
} catch(e) {
toast('Normalize failed: ' + e.message, 'error');
status('Normalize failed');
} finally {
normalizeBtn.disabled = false;
}
});
// Language picker
const flagEmojiEl = wrap.querySelector('.flag-emoji');
const flagCodeEl = wrap.querySelector('.flag-code');
const flagPicker = wrap.querySelector('.flag-picker');
const flagCell = wrap.querySelector('.vr-flag');
flagCell.addEventListener('click', e => {
e.stopPropagation();
document.querySelectorAll('.flag-picker.open').forEach(fp => { if(fp!==flagPicker) fp.classList.remove('open'); });
flagPicker.classList.toggle('open');
});
flagPicker.querySelectorAll('.flag-opt').forEach(opt => {
opt.addEventListener('click', async e => {
e.stopPropagation();
const cc = opt.dataset.cc;
flagPicker.classList.remove('open');
flagEmojiEl.textContent = cc2flag(cc);
flagCodeEl.textContent = ccDisplay(cc);
flagPicker.querySelectorAll('.flag-opt').forEach(o => o.classList.toggle('active', o.dataset.cc===cc));
v.flag = cc;
await saveMeta(v.id, { flag: cc });
});
});
// Gender cycle F → M → N → F
const gBadge = wrap.querySelector('.gender-badge');
gBadge.addEventListener('click', async () => {
const cycle = ['F','M','N'];
v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3];
gBadge.innerHTML = `${genderMap[v.gender]||'?'} ${genderLabel[v.gender]||'—'} `;
gBadge.className = 'gender-badge ' + genderClass[v.gender];
await saveMeta(v.id, { gender: v.gender });
});
// Rename
const nameText = wrap.querySelector('.vr-name-text');
const renameConf = wrap.querySelector('.rename-confirm');
const nameInput = wrap.querySelector('.vr-name-input');
const renameOk = wrap.querySelector('.rename-ok');
const renameCancel= wrap.querySelector('.rename-cancel');
const startRename = () => {
nameText.style.display='none';
renameConf.classList.add('show'); nameInput.focus(); nameInput.select();
};
nameText.addEventListener('dblclick', startRename);
const cancelRename = () => {
nameText.style.display=''; renameConf.classList.remove('show');
nameInput.value = v.id;
};
renameCancel.addEventListener('click', cancelRename);
const doRename = async () => {
const newId = nameInput.value.trim();
if (!newId || newId===v.id) { cancelRename(); return; }
if (!/^[A-Za-z0-9_\-\.]+$/.test(newId)) { toast('Invalid characters in name','error'); return; }
try {
const r = await fetch('/api/voice/rename', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({old_id:v.id, new_id:newId})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json();
v.id = newId;
if (d.path) v.path = d.path;
if (d.file_type) v.file_type = d.file_type;
nameText.textContent = newId; nameText.title = newId;
nameText.style.display=''; renameConf.classList.remove('show');
wrap.dataset.id = newId; nameInput.value = newId;
toast('Renamed to '+newId,'success');
} catch(e) { toast('Rename failed: '+e.message,'error'); }
};
renameOk.addEventListener('click', doRename);
nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); });
// Select row → open inspector (click on compact area, but not on play/buttons)
wrap.querySelector('.vl-compact').addEventListener('click', function (e) {
if (e.target.closest('button')) return;
selectVoice(wrap);
});
// Inline optimizer in Library
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
const optPanel = wrap.querySelector('.vr-optimizer');
const vrTypeEl = wrap.querySelector('.vr-type');
const optCanvas = wrap.querySelector('.opt-wave');
const optStart = wrap.querySelector('.opt-start');
const optEnd = wrap.querySelector('.opt-end');
const optTranscript = wrap.querySelector('.opt-transcript');
const optTargetDb = wrap.querySelector('.opt-target-db');
const optStyleInstruct = wrap.querySelector('.opt-style-instruct');
const optStyleBackend = wrap.querySelector('.opt-style-backend');
const optStyleVoiceId = wrap.querySelector('.opt-style-voice-id');
const optCompareBackend = wrap.querySelector('.opt-compare-backend');
const optPlayReferenceBtn = wrap.querySelector('.opt-play-reference');
const optSynthReferenceBtn = wrap.querySelector('.opt-synth-reference');
const optCompareRefAudio = wrap.querySelector('.opt-compare-ref-audio');
const optCompareSynthAudio = wrap.querySelector('.opt-compare-synth-audio');
const optPreviewStyleBtn = wrap.querySelector('.opt-preview-style');
const optSaveStyleBtn = wrap.querySelector('.opt-save-style');
const optStyleAudio = wrap.querySelector('.opt-style-audio');
const optStatus = wrap.querySelector('.opt-status');
const optSaveTextBtn = wrap.querySelector('.opt-save-text');
const optRestartTtsBtn = wrap.querySelector('.opt-restart-tts');
const optRebenchmarkBtn = wrap.querySelector('.opt-rebenchmark');
const optRestartNote = wrap.querySelector('.opt-restart-note');
let optState = { loaded:false, id:null, duration:0, buffer:null, audio:null, compareSynthUrl:null };
const setOptStatus = msg => { optStatus.textContent = msg; status(msg); };
const setVoiceRestartState = (required, msg = '') => {
v.needs_tts_restart = required;
optPanel.classList.toggle('opt-restart-needed', required);
optRestartNote.hidden = !required;
optRestartNote.textContent = required ? 'Restart TTS before benchmarking; the backend may still have the old voice cached.' : '';
optRebenchmarkBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice';
benchmarkOneBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice';
if (msg) setOptStatus(msg);
};
const markTtsRestartRequired = msg => setVoiceRestartState(true, msg);
const refreshOptimizerFromVoice = async () => {
markVoiceAudioChanged(v);
optState.loaded = false;
optState.buffer = null;
optState.id = null;
await loadOptimizer();
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
};
const saveOptimizerText = async () => {
const transcript = optTranscript.value.trim();
setOptStatus('Saving reference text...');
v.transcript = transcript;
refInput.value = transcript;
refInput.title = transcript;
refTranscribeBtn.style.display = transcript ? 'none' : '';
await saveMeta(v.id, { transcript });
markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.');
toast('Reference text saved: ' + v.id, 'success');
};
const redrawOpt = () => {
if (!optState.buffer || optCanvas.clientWidth < 4) return;
drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
};
// Redraw whenever the canvas is resized (handles display:none → visible transition)
const _waveRO = new ResizeObserver(() => redrawOpt());
_waveRO.observe(optCanvas);
const syncCompareReferenceAudio = () => {
if (!optState.id || !optCompareRefAudio) return;
const src = '/api/audio/' + optState.id;
if (!optCompareRefAudio.src.endsWith(src)) {
optCompareRefAudio.src = src;
optCompareRefAudio.load();
}
const stopAtEnd = () => {
const end = parseFloat(optEnd.value) || optState.duration;
if (optCompareRefAudio.currentTime >= end) optCompareRefAudio.pause();
};
optCompareRefAudio.ontimeupdate = stopAtEnd;
};
const optWaveTimeFromEvent = e => {
const rect = optCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
return optState.duration ? x / Math.max(1, rect.width) * optState.duration : 0;
};
const setOptCropRange = (start, end) => {
start = Math.max(0, Math.min(optState.duration || 0, Number(start) || 0));
end = Math.max(0, Math.min(optState.duration || 0, Number(end) || 0));
if (end < start) [start, end] = [end, start];
optStart.value = start.toFixed(2);
optEnd.value = end.toFixed(2);
redrawOpt();
};
const optWaveSelectionPixels = e => {
const rect = optCanvas.getBoundingClientRect();
const duration = Math.max(0.01, optState.duration || 0.01);
const sx = (parseFloat(optStart.value) || 0) / duration * rect.width;
const ex = (parseFloat(optEnd.value) || optState.duration || 0) / duration * rect.width;
const x = e.clientX - rect.left;
return {x, sx, ex};
};
const optWaveDragMode = e => {
const {x, sx, ex} = optWaveSelectionPixels(e);
const hit = 18;
const nearStart = Math.abs(x - sx) <= hit;
const nearEnd = Math.abs(x - ex) <= hit;
if (nearStart && nearEnd) return Math.abs(x - sx) <= Math.abs(x - ex) ? 'start' : 'end';
if (nearStart) return 'start';
if (nearEnd) return 'end';
if (x > sx && x < ex) return 'move';
return 'new';
};
const attachOptWaveSelection = () => {
let drag = null;
optCanvas.addEventListener('pointerdown', e => {
if (!optState.buffer || !optState.duration) return;
e.preventDefault();
optCanvas.setPointerCapture?.(e.pointerId);
const mode = optWaveDragMode(e);
const currentStart = parseFloat(optStart.value) || 0;
const currentEnd = parseFloat(optEnd.value) || optState.duration;
drag = {mode, anchor: optWaveTimeFromEvent(e), start: currentStart, end: currentEnd, length: Math.max(0.05, currentEnd - currentStart)};
optCanvas.style.cursor = mode === 'move' ? 'grabbing' : 'ew-resize';
if (mode === 'new') setOptCropRange(drag.anchor, drag.anchor);
});
optCanvas.addEventListener('pointermove', e => {
if (!optState.buffer || !optState.duration) return;
if (!drag) {
const mode = optWaveDragMode(e);
optCanvas.style.cursor = mode === 'move' ? 'grab' : (mode === 'start' || mode === 'end') ? 'ew-resize' : 'crosshair';
return;
}
e.preventDefault();
const t = optWaveTimeFromEvent(e);
if (drag.mode === 'start') setOptCropRange(Math.min(t, drag.end - 0.05), drag.end);
else if (drag.mode === 'end') setOptCropRange(drag.start, Math.max(t, drag.start + 0.05));
else if (drag.mode === 'move') {
let start = t - (drag.anchor - drag.start);
start = Math.max(0, Math.min((optState.duration || 0) - drag.length, start));
setOptCropRange(start, start + drag.length);
} else setOptCropRange(drag.anchor, t);
});
const finish = e => {
if (!drag) return;
optCanvas.releasePointerCapture?.(e.pointerId);
drag = null;
optCanvas.style.cursor = 'crosshair';
};
optCanvas.addEventListener('pointerup', finish);
optCanvas.addEventListener('pointercancel', finish);
optCanvas.addEventListener('pointerleave', () => { if (!drag) optCanvas.style.cursor = 'crosshair'; });
};
attachOptWaveSelection();
const loadOptimizer = async () => {
if (optState.loaded) return;
setOptStatus('Loading voice optimizer…');
const d = await loadLibraryVoiceAudio(v);
optState.id = d.id;
optState.duration = d.duration;
v.duration = d.duration;
optState.buffer = await decodeVoiceAudio(v);
optState.loaded = true;
optStart.value = '0.00';
optEnd.value = d.duration.toFixed(2);
optEnd.max = d.duration.toFixed(2);
optTranscript.value = d.transcript || v.transcript || '';
redrawOpt();
syncCompareReferenceAudio();
setVoiceRestartState(Boolean(v.needs_tts_restart));
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
};
wrap._loadOptimizer = loadOptimizer;
wrap._redrawOpt = redrawOpt;
editAudioBtn.addEventListener('click', async () => {
editAudioBtn.disabled = true;
try {
const opening = !wrap.classList.contains('edit-open');
document.querySelectorAll('.vl-row.edit-open').forEach(r => { if (r !== wrap) r.classList.remove('edit-open'); });
wrap.classList.toggle('edit-open', opening);
if (opening) {
await loadOptimizer();
wrap.scrollIntoView({behavior:'smooth', block:'nearest'});
}
} catch(e) {
toast('Edit load failed: '+e.message,'error');
status('Edit load failed');
} finally {
editAudioBtn.disabled = false;
}
});
[optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
optStyleInstruct?.addEventListener('input', () => {
if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
});
optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
if (optCompareBackend.value === '') {
optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
optCompareBackend.disabled = !availableTtsBackends().length;
}
if (optStyleBackend) updateStyleBackendHelp(wrap);
wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
wrap.querySelector('.opt-play').addEventListener('click', async () => {
try {
await loadOptimizer();
if (optState.audio) optState.audio.pause();
optState.audio = new Audio('/api/audio/' + optState.id);
optState.audio.currentTime = parseFloat(optStart.value) || 0;
const end = parseFloat(optEnd.value) || optState.duration;
optState.audio.ontimeupdate = () => { if (optState.audio.currentTime >= end) optState.audio.pause(); };
optState.audio.play();
} catch(e) { toast('Preview failed: ' + e.message, 'error'); }
});
optPlayReferenceBtn.addEventListener('click', async () => {
try {
await loadOptimizer();
syncCompareReferenceAudio();
optCompareRefAudio.currentTime = parseFloat(optStart.value) || 0;
await optCompareRefAudio.play().catch(()=>{});
setOptStatus('Playing reference WAV selection for comparison.');
} catch(e) { toast('Reference playback failed: ' + e.message, 'error'); }
});
optSynthReferenceBtn.addEventListener('click', async () => {
const text = optTranscript.value.trim();
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return; }
if (v.needs_tts_restart) {
const ok = confirm('This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?');
if (!ok) {
setOptStatus('Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.');
return;
}
try {
const d = await clearTtsRestartFlags();
setVoiceRestartState(false, `Restart flags cleared (${d.cleared_restart_flags || 0}). Synthesizing comparison...`);
toast('Restart flags cleared', 'success');
} catch(e) {
toast('Could not clear restart flags: ' + e.message, 'error');
setOptStatus('Could not clear restart flags');
return;
}
}
optSynthReferenceBtn.disabled = true;
try {
await loadOptimizer();
setOptStatus('Synthesizing reference text for comparison...');
const source = await createTtsAudioSource(v.id, text, optCompareBackend.value, 'settings', '');
if (optState.compareSynthUrl) URL.revokeObjectURL(optState.compareSynthUrl);
optCompareSynthAudio.src = source.url;
optState.compareSynthUrl = source.streaming ? null : source.url;
await optCompareSynthAudio.play().catch(()=>{});
setOptStatus(source.streaming ? 'Streaming synthesized comparison.' : 'Synthesized comparison ready.');
} catch(e) {
toast('Synthesis comparison failed: ' + e.message, 'error');
setOptStatus('Synthesis comparison failed');
} finally {
optSynthReferenceBtn.disabled = false;
}
});
wrap.querySelector('.opt-auto-trim').addEventListener('click', async () => {
try {
await loadOptimizer();
const d = await clientAutoTrimBounds(optState.id);
optStart.value = Number(d.start).toFixed(2);
optEnd.value = Number(d.end).toFixed(2);
redrawOpt();
setOptStatus(d.reason || 'Auto trim ready');
} catch(e) { toast('Auto trim failed: ' + e.message, 'error'); }
});
wrap.querySelector('.opt-recognize').addEventListener('click', async () => {
try {
await loadOptimizer();
const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:optState.id})});
if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); }
const d = await r.json();
optTranscript.value = d.text || '';
setOptStatus('Reference text recognised. Review it, then Save text.');
} catch(e) { toast('Recognition failed: ' + e.message, 'error'); }
});
optSaveTextBtn.addEventListener('click', async () => {
optSaveTextBtn.disabled = true;
try {
await saveOptimizerText();
} catch(e) {
toast('Save text failed: ' + e.message, 'error');
setOptStatus('Save text failed');
} finally {
optSaveTextBtn.disabled = false;
}
});
wrap.querySelector('.opt-save-crop').addEventListener('click', async () => {
try {
await loadOptimizer();
const cropStart = Math.max(0, parseFloat(optStart.value) || 0);
const cropEnd = Math.min(optState.duration, parseFloat(optEnd.value) || optState.duration);
if (cropStart <= 0.01 && cropEnd >= optState.duration - 0.05) {
setOptStatus('No crop range selected. Adjust Start or End first, then Save crop.');
toast('No crop range selected', 'error');
return;
}
if (cropEnd <= cropStart + 0.1) {
setOptStatus('Crop range is too short.');
toast('Crop range is too short', 'error');
return;
}
setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);
const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:optState.id, start:cropStart, end:cropEnd})});
if (!pr.ok) { const e = await pr.json(); throw new Error(e.detail || pr.statusText); }
const p = await pr.json();
const rr = await fetch('/api/voice-replace', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:p.id, voice_id:v.id, path:v.path, transcript:optTranscript.value})});
if (!rr.ok) { const e = await rr.json().catch(()=>({})); throw new Error(e.detail || rr.statusText); }
const saved = await rr.json();
v.transcript = optTranscript.value;
v.duration = saved.duration ?? p.duration;
if (saved.loudness) v.loudness = saved.loudness;
if (saved.path) v.path = saved.path;
if (saved.file_type) v.file_type = saved.file_type;
markVoiceAudioChanged(v);
refInput.value = v.transcript; refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
await refreshOptimizerFromVoice();
toast('Voice crop saved: ' + v.id, 'success');
markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
});
if (optStyleInstruct) {
const styleVariationInput = () => {
const style = optStyleInstruct.value.trim();
const text = optTranscript.value.trim() || benchmarkSampleText();
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
return {style, text, newId, backend: optStyleBackend.value};
};
optPreviewStyleBtn.addEventListener('click', async () => {
const input = styleVariationInput();
if (!input) return;
optPreviewStyleBtn.disabled = true;
try {
setOptStatus('Synthesizing style preview...');
const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
optStyleAudio.src = URL.createObjectURL(blob);
optStyleAudio.style.display = '';
await optStyleAudio.play().catch(()=>{});
setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
} catch(e) {
toast('Style preview failed: ' + e.message, 'error');
setOptStatus('Style preview failed');
} finally {
optPreviewStyleBtn.disabled = false;
}
});
optSaveStyleBtn.addEventListener('click', async () => {
const input = styleVariationInput();
if (!input) return;
optSaveStyleBtn.disabled = true;
try {
setOptStatus(`Synthesizing style variation ${input.newId}...`);
const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
toast('Style variation saved: ' + d.voice_id, 'success');
setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
await loadVoiceLibrary();
renderIntegrationSnippets();
} catch(e) {
toast('Style variation failed: ' + e.message, 'error');
setOptStatus('Style variation failed');
} finally {
optSaveStyleBtn.disabled = false;
}
});
}
wrap.querySelector('.opt-undo').addEventListener('click', async () => {
if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
try {
setOptStatus('Restoring original…');
const r = await fetch('/api/voice/undo', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.duration = d.duration ?? v.duration;
v.loudness = d.loudness || v.loudness;
v.path = d.path || v.path;
v.file_type = d.file_type || v.file_type;
markVoiceAudioChanged(v);
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
await refreshOptimizerFromVoice();
toast('Original restored: ' + v.id, 'success');
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
} catch(e) { toast('Undo failed: ' + e.message, 'error'); setOptStatus('Undo failed'); }
});
wrap.querySelector('.opt-save-volume').addEventListener('click', async () => {
try {
setOptStatus('Saving volume…');
const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:Number(optTargetDb.value || -20)})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.loudness = d.loudness || v.loudness;
v.duration = d.duration ?? v.duration;
if (d.path) v.path = d.path;
if (d.file_type) v.file_type = d.file_type;
markVoiceAudioChanged(v);
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
await refreshOptimizerFromVoice();
toast('Volume saved: ' + v.id, 'success');
markTtsRestartRequired('Volume saved. Restart TTS before rebenchmarking this voice.');
} catch(e) { toast('Volume save failed: ' + e.message, 'error'); setOptStatus('Volume save failed'); }
});
optRestartTtsBtn.addEventListener('click', async () => {
optRestartTtsBtn.disabled = true;
try {
setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…');
const r = await fetch('/api/tts/restart', { method:'POST' });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_voices.forEach(voice => { voice.needs_tts_restart = false; });
updateLibraryInsights();
const names = (d.restarted || []).join(', ') || 'containers';
const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : '';
setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`);
toast(`TTS restarted: ${names}`, 'success');
} catch(e) {
toast('Restart TTS failed: ' + e.message, 'error');
setOptStatus('Restart TTS failed: ' + e.message);
} finally {
optRestartTtsBtn.disabled = false;
}
});
// Reference text input and recognition
const refInput = wrap.querySelector('.vr-ref input');
const refTranscribeBtn = wrap.querySelector('.ref-transcribe-btn');
refInput.addEventListener('input', debounce(async () => {
v.transcript = refInput.value;
refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
await saveMeta(v.id, { transcript: v.transcript });
if (wrap.classList.contains('edit-open')) markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.');
else v.needs_tts_restart = true;
}, 800));
refTranscribeBtn.addEventListener('click', async () => {
refTranscribeBtn.disabled = true;
try {
const d = await loadLibraryVoiceAudio(v);
const tr = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})});
if (!tr.ok) { const e=await tr.json(); throw new Error(e.detail); }
const text = await tr.json();
v.transcript = text.text || '';
refInput.value = v.transcript;
refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
await saveMeta(v.id, { transcript: v.transcript });
v.needs_tts_restart = true;
toast('Reference text recognised; restart TTS before benchmarking','success');
} catch(e) {
toast('Recognition failed: '+e.message,'error');
} finally { refTranscribeBtn.disabled = false; }
});
// Note (debounced save)
const noteInput = wrap.querySelector('.vr-note input');
noteInput.addEventListener('input', debounce(async () => {
v.note = noteInput.value;
await saveMeta(v.id, { note: v.note });
}, 800));
// Stars
const starSpans = wrap.querySelectorAll('.star');
starSpans.forEach(s => {
s.addEventListener('click', async () => {
const val = parseInt(s.dataset.val);
const newRating = val===v.rating ? 0 : val;
v.rating = newRating;
starSpans.forEach((ss,i) => ss.classList.toggle('on', i {
const val = parseInt(s.dataset.val);
starSpans.forEach((ss,i) => ss.classList.toggle('on', i {
starSpans.forEach((ss,i) => ss.classList.toggle('on', i<(v.rating||0)));
});
});
// Per-voice benchmark
const benchmarkOneBtn = wrap.querySelector('.benchmark-one-btn');
const benchmarkThisVoice = async triggerBtn => {
if (v.needs_tts_restart) {
const ok = confirm('This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?');
if (!ok) {
setOptStatus('Restart TTS first, then rebenchmark this voice.');
return;
}
}
triggerBtn.disabled = true;
status('Benchmarking ' + v.id + '...');
try {
setBenchmarkProgress(0, 1, `Benchmarking ${v.id}`);
const d = await runVoiceBenchmark(v.id);
mergeBenchmarkResults(d);
const hit = (d.voices || []).find(x => x.voice_id === v.id);
if (hit && hit.benchmark) v.benchmark = hit.benchmark;
const benchCell = wrap.querySelector('.vr-bench');
benchCell.className = 'vr-bench ' + benchmarkClass(v);
benchCell.title = benchmarkTitle(v);
benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v);
setBenchmarkProgress(1, 1, `Finished ${v.id}`);
toast('Benchmarked ' + v.id, 'success');
setVoiceRestartState(false, 'Benchmark saved for ' + v.id);
} catch(e) {
toast('Benchmark failed: ' + e.message, 'error');
setOptStatus('Benchmark failed');
} finally { triggerBtn.disabled = false; }
};
benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn));
optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn));
// Play original recording or synthesized sample
const originalPlayBtn = wrap.querySelector('.vr-play-original button');
const synthPlayBtn = wrap.querySelector('.vr-play-synth button');
const playIcon = ' ', pauseIcon = ' ', generatingIcon = ' ';
function setLibraryPlayButtonState(btn, state) {
btn.classList.toggle('is-generating', state === 'generating');
btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon);
btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording'));
}
async function playLibraryVoice(kind, playBtn) {
const bar = $('lib-audio-bar'), audio = $('lib-audio');
const playKey = v.id + ':' + kind;
playBtn.dataset.playKind = kind;
if (_activePlayVoiceId === playKey && !audio.paused) {
audio.pause();
setLibraryPlayButtonState(playBtn, 'idle');
return;
}
if (_activePlayVoiceId === playKey && audio.paused && audio.src) {
_activePlayButton = playBtn;
try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); }
return;
}
if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle');
_activePlayButton = playBtn;
_activePlayVoiceId = playKey;
if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; }
if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating');
playBtn.disabled = true;
try {
if (kind === 'synth') {
if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error');
const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview';
const text = synthMode === 'transcript'
? (v.transcript?.trim() || benchmarkSampleText())
: benchmarkSampleText();
const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text';
const backend = libraryTtsBackend();
const source = await createTtsAudioSource(v.id, text, backend, 'settings', '');
audio.src = source.url;
if (!source.streaming) _activePlayUrl = source.url;
$('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend);
} else {
audio.src = voiceFileUrl(v);
$('lib-audio-label').textContent = v.id + ' · original recording';
}
bar.style.display = '';
audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; };
audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); };
audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); };
await audio.play();
} catch(e) {
setLibraryPlayButtonState(playBtn, 'idle');
toast('Play failed: '+e.message,'error');
} finally {
playBtn.disabled = false;
}
}
originalPlayBtn.dataset.playKind = 'original';
synthPlayBtn.dataset.playKind = 'synth';
setLibraryPlayButtonState(originalPlayBtn, 'idle');
setLibraryPlayButtonState(synthPlayBtn, 'idle');
originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn));
synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn));
// Enable toggle
const toggleCb = wrap.querySelector('.toggle input');
toggleCb.addEventListener('change', async () => {
const nextEnabled = toggleCb.checked;
const previousEnabled = v.enabled !== false;
toggleCb.disabled = true;
try {
const saved = await saveMeta(v.id, { enabled: nextEnabled });
v.enabled = nextEnabled;
if (saved && saved.path) v.path = saved.path;
wrap.classList.toggle('vr-disabled', !v.enabled);
toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success');
if (!v.enabled && !$('show-disabled-cb').checked) {
wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0';
setTimeout(() => wrap.remove(), 400);
}
} catch(e) {
toggleCb.checked = previousEnabled;
v.enabled = previousEnabled;
wrap.classList.toggle('vr-disabled', !v.enabled);
toast('Move failed: ' + e.message, 'error');
} finally {
toggleCb.disabled = false;
}
});
// Delete voice
const deleteBtn = wrap.querySelector('.delete-btn');
const deleteConfirm = wrap.querySelector('.delete-confirm');
const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel');
const deleteGoBtn = wrap.querySelector('.delete-confirm-go');
const closeDeleteConfirm = () => wrap.classList.remove('delete-pending');
deleteBtn.addEventListener('click', e => {
e.stopPropagation();
document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); });
wrap.classList.add('delete-pending');
deleteGoBtn.focus();
});
deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); });
deleteConfirm.addEventListener('click', e => e.stopPropagation());
deleteGoBtn.addEventListener('click', async e => {
e.stopPropagation();
deleteGoBtn.disabled = true;
deleteCancelBtn.disabled = true;
try {
const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' });
if (!r.ok) { const e = await r.json(); throw new Error(e.detail); }
_voices = _voices.filter(x => x.id !== v.id);
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
toast(`Deleted: ${v.id}`, 'success');
} catch(e) {
toast('Delete failed: ' + e.message, 'error');
deleteGoBtn.disabled = false;
deleteCancelBtn.disabled = false;
closeDeleteConfirm();
}
});
return wrap;
}
// ── 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 =
` Groups ` +
names.map(g =>
`
${escHtml(g)} ${groups[g]}
×
`
).join('') +
(active ? `Show all ` : '');
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);
});
}
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 sort bar (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-visible')?.addEventListener('click', () => {
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));
if (allSelected) {
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
} else {
rows.forEach(r => _bulkSelected.add(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 = ' 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-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 = '⭐ Pick rating… ' +
[1,2,3,4,5].map(n => `${'★'.repeat(n)} `).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 => `${escHtml(id)} `).join('');
const more = ids.length > 12 ? `+${ids.length - 12} more ` : '';
const ov = document.createElement('div');
ov.className = 'vl-bdc-overlay';
ov.innerHTML = `
Delete ${ids.length} voice${plural}?
This permanently removes the selected voice${plural} from your library. This cannot be undone.
${names}${more}
Cancel
Delete ${ids.length} voice${plural}
`;
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 = ` 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;
}