tts-voice-creator-clone-and.../static/js/voice-library.js
mARTin-B78 0606db101a feat: reorder columns, add Duration column, benchmark sentence presets (v1.12.12)
Column order now: Img·Play·Name·Lang·Gender·Length·Duration·WPM·Speed·
Seed·dBFS·Type·Source·Rating·Tags·Note·Active.
Duration shows synthesised audio length (b.audio_sec), sortable.
Preset dropdown offers 4 benchmark sentences (DE/EN narrative + DE/EN
tongue-twister) and a reset option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 22:54:54 +02:00

3467 lines
161 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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.';
const BENCHMARK_PRESETS = {
de: 'Die Welt ist voller Geschichten, die darauf warten, erzählt zu werden — von mutigen Helden und stillen Träumern.',
en: 'The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.',
de2: 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.',
en2: 'She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.',
reset: DEFAULT_BENCHMARK_SAMPLE_TEXT,
};
function benchmarkSampleText() {
const el = $('benchmark-sample-text');
return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
}
function initBenchmarkSampleControls() {
const sample = $('benchmark-sample-text');
if (!sample) return;
sample.value = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
sample.addEventListener('input', debounce(() => {
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value.trim());
status('Benchmark sample sentence saved');
}, 500));
$('benchmark-reset-sample-btn')?.addEventListener('click', () => {
sample.value = DEFAULT_BENCHMARK_SAMPLE_TEXT;
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
status('Benchmark sample sentence reset');
});
const presetSel = $('benchmark-preset-select');
if (presetSel) {
presetSel.addEventListener('change', () => {
const key = presetSel.value;
if (!key || !BENCHMARK_PRESETS[key]) { presetSel.value = ''; return; }
sample.value = BENCHMARK_PRESETS[key];
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
presetSel.value = '';
status('Benchmark sample sentence loaded');
});
}
$('benchmark-use-preview-btn')?.addEventListener('click', () => {
const text = $('preview-text-area')?.value.trim();
if (!text) { toast('Preview text is empty', 'error'); return; }
sample.value = text;
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text);
status('Benchmark sample sentence copied from TTS preview');
});
}
// Returns the display-level source label for a voice.
// Explicit origin field wins; fallback to any recognised tag value.
function _displaySource(v) {
if (v.origin) return v.origin;
if (v.note) {
// Notes from Rehearser clones follow: "Rehearser · <script title> · <character> — …"
const m = v.note.match(/^Rehearser\s*·\s*(.+?)\s*·/);
if (m) return m[1].trim();
}
if (v.tag) {
const tags = v.tag.split(',').map(t => t.trim().toLowerCase());
if (tags.includes('fish-audio') || tags.includes('fishaudio')) return 'fish-audio';
}
return '';
}
function getSortValue(v, field) {
switch(field) {
case 'has_picture': return v.has_picture ? 1 : 0;
case 'flag': return (v.flag || '').toLowerCase();
case 'gender': return {'F':0,'M':1,'N':2}[v.gender] ?? 3;
case 'id': return v.id.toLowerCase();
case 'file_type': return voiceFileType(v);
case 'duration': return v.duration || 0;
case 'dbfs': return voiceDbfs(v) ?? -999;
case 'benchmark': { const b = voiceBenchmark(v); return b && b.speed != null ? -Number(b.speed) : 999; }
case 'bench_audio': return voiceBenchmarkAudioSec(v) ?? 999;
case 'wpm': return voiceWpm(v) ?? -1;
case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase();
case 'source': return (_displaySource(v) || '').toLowerCase();
case 'seed': return v.seed != null ? v.seed : 9999999;
case 'tag': return (v.tag || '').toLowerCase();
case 'rating': return v.rating || 0;
case 'enabled': return v.enabled === false ? 0 : 1;
default: return '';
}
}
function setSort(field) {
_sortDir = (_sortField === field) ? _sortDir * -1 : 1;
_sortField = field;
syncSortHeaders();
renderVoiceList();
}
function toggleSortDir() {
_sortDir *= -1;
syncSortHeaders();
renderVoiceList();
}
function syncSortHeaders() {
document.querySelectorAll('.vl-header [data-sort], .vl-table-header [data-sort]').forEach(el => {
el.classList.remove('sort-asc', 'sort-desc');
if (el.dataset.sort === _sortField)
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
});
const sel = document.getElementById('voice-sort-field');
if (sel && sel.value !== _sortField) sel.value = _sortField;
const dirBtn = document.getElementById('voice-sort-dir');
if (dirBtn) {
const icon = dirBtn.querySelector('.mdi');
if (icon) icon.className = _sortDir === 1 ? 'mdi mdi-arrow-up' : 'mdi mdi-arrow-down';
dirBtn.title = _sortDir === 1 ? 'Ascending — click to reverse' : 'Descending — click to reverse';
}
}
// Wire sort direction button via addEventListener (more reliable than inline onclick
// since the button is injected into the DOM after script execution).
document.addEventListener('click', e => {
if (e.target.closest('#voice-sort-dir')) toggleSortDir();
if (e.target.closest('#voice-group-tag-btn')) {
window._voiceGroupByTag = !window._voiceGroupByTag;
try { localStorage.setItem('vl-group-by-tag', window._voiceGroupByTag ? '1' : '0'); } catch (_) {}
const btn = document.getElementById('voice-group-tag-btn');
btn?.classList.toggle('active', window._voiceGroupByTag);
const icon = btn?.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
renderVoiceList();
} else if (e.target.closest('#voice-table-view-btn')) {
window._voiceTableView = !window._voiceTableView;
if (!window._voiceTableView) window._voiceTableEditMode = false;
try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {}
const btn = document.getElementById('voice-table-view-btn');
btn?.classList.toggle('active', window._voiceTableView);
const editBtn = document.getElementById('voice-table-edit-btn');
if (editBtn) {
editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
editBtn.classList.toggle('active', !!window._voiceTableEditMode);
}
document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', !!window._voiceTableEditMode);
// Auto-close any open inspector row when toggling table view
if (window._voiceTableView) {
document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open'));
const inspector = document.getElementById('voices-inspector');
if (inspector) inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-table"></span></span><p>Table View Mode<br>Click a row to exit table view and edit.</p></div>';
}
renderVoiceList();
} else if (e.target.closest('#voice-table-edit-btn')) {
window._voiceTableEditMode = !window._voiceTableEditMode;
const editBtn = document.getElementById('voice-table-edit-btn');
editBtn?.classList.toggle('active', window._voiceTableEditMode);
document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', window._voiceTableEditMode);
renderVoiceList();
}
});
// Restore the preferences on load
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
try { window._voiceTableView = localStorage.getItem('vl-table-view') === '1'; } catch (_) {}
document.addEventListener('click', e => {
const th = e.target.closest('.vl-th-sortable');
if (th) {
const field = th.dataset.sort;
if (field) {
const sel = document.getElementById('voice-sort-field');
if (sel) sel.value = field;
setSort(field);
}
}
});
document.addEventListener('change', e => {
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
});
document.addEventListener('change', async e => {
if (e.target.classList.contains('vl-inline-edit')) {
const row = e.target.closest('.vl-row');
if (!row) return;
const voiceId = row.dataset.id;
const field = e.target.dataset.field;
let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
if (field === 'rating') value = parseInt(value) || 0;
const payload = {};
payload[field] = value;
try {
await saveMeta(voiceId, payload);
const v = _voices.find(vv => vv.id === voiceId);
if (v) v[field] = value;
if (typeof toast === 'function') toast('Saved ' + field, 'success');
} catch (err) {
if (typeof toast === 'function') toast('Failed to save ' + field, 'error');
}
}
});
const FLAG_LANGUAGE_CANDIDATES = {
GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'],
DE:['DE'], AT:['DE'], CH:['DE','FR','IT'],
FR:['FR'], BE:['FR','NL'], LU:['FR','DE'],
ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'],
PT:['PT'], BR:['PT'],
IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'],
CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'],
EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'],
CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'],
SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'],
};
const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]]));
const LANGUAGE_LABELS = {
EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish',
DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian',
HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian',
RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali',
AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish',
};
const SEX_FILTER_LABELS = {
F:'♀ Female',
M:'♂ Male',
N:'⚥ Diverse / neutral',
};
function voiceLangFromName(v) {
return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase();
}
function libraryVoiceLang(v) {
const fromName = voiceLangFromName(v);
const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()];
if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0];
return fromName;
}
function libraryLanguageLabel(code) {
return LANGUAGE_LABELS[code] || code;
}
function populateLibraryFilters() {
const langSel = $('library-filter-lang');
const sexSel = $('library-filter-sex');
const typeSel = $('library-filter-type');
if (!langSel || !sexSel || !typeSel) return;
const 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 = '<option value="">All languages</option>' + langs.map(x => `<option value="${escHtml(x)}">${escHtml(libraryLanguageLabel(x))} (${escHtml(x)})</option>`).join('');
sexSel.innerHTML = '<option value="">All</option>' + sexes.map(x => `<option value="${escHtml(x)}">${escHtml(SEX_FILTER_LABELS[x] || x)}</option>`).join('');
typeSel.innerHTML = '<option value="">All</option>' + types.map(x => `<option value="${escHtml(x)}">${escHtml(x.toUpperCase())}</option>`).join('');
langSel.value = langs.includes(keep.lang) ? keep.lang : '';
sexSel.value = sexes.includes(keep.sex) ? keep.sex : '';
typeSel.value = types.includes(keep.type) ? keep.type : '';
}
function readLibraryFilters() {
_libraryFilters.text = ($('library-filter-text')?.value || '').trim().toLowerCase();
_libraryFilters.lang = $('library-filter-lang')?.value || '';
_libraryFilters.sex = $('library-filter-sex')?.value || '';
_libraryFilters.type = $('library-filter-type')?.value || '';
_libraryFilters.rating = $('library-filter-rating')?.value || '';
}
function libraryFilterMatch(v) {
const f = _libraryFilters;
if (f.lang && libraryVoiceLang(v) !== f.lang) return false;
if (f.sex && (v.gender || '') !== f.sex) return false;
if (f.type && voiceFileType(v) !== f.type) return false;
if (f.rating) {
const r = Number(v.rating || 0);
const wanted = Number(f.rating);
if (wanted === 0 && r !== 0) return false;
if (wanted === 1 && r < 1) return false;
if (wanted > 1 && r < wanted) return false;
}
if (f.text) {
const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin].map(x => String(x || '').toLowerCase()).join(' ');
if (!hay.includes(f.text)) return false;
}
return true;
}
function clearLibraryFilters() {
['library-filter-text','library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { const el = $(id); if (el) el.value = ''; });
readLibraryFilters();
renderVoiceList();
}
function libraryTtsBackend() {
return $('library-tts-backend-select')?.value || 'voice_clone';
}
function needsDuration(v) {
return v.duration == null || Number.isNaN(Number(v.duration));
}
function voiceFileType(v) {
if (v.file_type) return String(v.file_type).replace(/^\./, '').toLowerCase();
const source = String(v.path || v.filename || '');
const match = source.match(/\.([A-Za-z0-9]+)(?:$|[?#])/);
return match ? match[1].toLowerCase() : 'wav';
}
function voiceDbfs(v) {
const value = v.loudness && (v.loudness.dbfs ?? v.loudness.after_dbfs);
return value == null || Number.isNaN(Number(value)) ? null : Number(value);
}
function fmtDbfs(v) {
const db = voiceDbfs(v);
return db == null ? '-' : db.toFixed(1);
}
function voiceBenchmark(v) {
if (v.benchmark && typeof v.benchmark === 'object' && Object.keys(v.benchmark).length > 0) {
return v.benchmark;
}
return null;
}
function voiceBenchmarkElapsed(v) {
const b = voiceBenchmark(v);
const value = b && b.elapsed_sec;
return value == null || Number.isNaN(Number(value)) ? null : Number(value);
}
function 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(2)}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 voiceBenchmarkAudioSec(v) {
const b = voiceBenchmark(v);
return b && b.ok && b.audio_sec != null ? Number(b.audio_sec) : null;
}
function fmtBenchmarkAudio(v) {
const sec = voiceBenchmarkAudioSec(v);
return sec != null ? sec.toFixed(1) + 's' : '-';
}
function voiceWpm(v) {
const b = voiceBenchmark(v);
if (!b || !b.ok || !b.audio_sec || !b.text) return null;
const words = b.text.trim().split(/\s+/).length;
return Math.round(words / (b.audio_sec / 60));
}
function fmtWpm(v) {
const wpm = voiceWpm(v);
return wpm != null ? wpm + ' wpm' : '-';
}
function voiceFileUrl(v) {
const version = v._audioVersion || v.updated_at || v.benchmarked_at || '';
const bust = version || Date.now();
return `/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`;
}
function markVoiceAudioChanged(v) {
v._audioVersion = Date.now();
}
function benchmarkTitle(v) {
const b = voiceBenchmark(v);
if (!b) return 'Not benchmarked yet';
const parts = [];
if (b.ok) {
parts.push(`total ${Number(b.elapsed_sec || 0).toFixed(2)}s`);
if (b.ttfa_ms != null) parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`);
if (b.audio_sec != null) parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`);
if (b.rtf != null) parts.push(`RTF ${Number(b.rtf).toFixed(2)}`);
if (b.speed != null) parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`);
if (b.clipped) parts.push('output clipped');
} else {
parts.push('benchmark failed');
if (b.error) parts.push(b.error);
}
if (Array.isArray(b.advice) && b.advice.length) parts.push(b.advice.join(' | '));
if (b.benchmarked_at) parts.push(`saved ${b.benchmarked_at}`);
return parts.join(' · ');
}
async function clientVoiceLoudness(v) {
if (!v.path) throw new Error('No audio path');
const resp = await fetch(voiceFileUrl(v), {cache:'no-store'});
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
const audioData = await resp.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const buffer = await ctx.decodeAudioData(audioData.slice(0));
let sum = 0, peak = 0, count = 0;
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
const data = buffer.getChannelData(ch);
count += data.length;
for (let i = 0; i < data.length; i++) {
const sample = data[i];
sum += sample * sample;
peak = Math.max(peak, Math.abs(sample));
}
}
const rms = Math.sqrt(sum / Math.max(1, count));
const dbfs = rms > 0 ? 20 * Math.log10(rms) : null;
const peakDbfs = peak > 0 ? 20 * Math.log10(peak) : null;
return {
dbfs: dbfs == null ? null : Number(dbfs.toFixed(2)),
peak_dbfs: peakDbfs == null ? null : Number(peakDbfs.toFixed(2)),
};
}
async function clientCalculateVoiceDb() {
const voices = (_bulkSelected && _bulkSelected.size > 0)
? (_voices || []).filter(v => _bulkSelected.has(v.id))
: visibleLibraryVoices();
const errors = [];
let calculated = 0;
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'};
setBenchmarkProgress(0, voices.length, 'Preparing dB scan...', stats);
for (const v of voices) {
setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats);
try {
v.loudness = await clientVoiceLoudness(v);
await saveMeta(v.id, { loudness: v.loudness }).catch(()=>{});
calculated++;
stats.ok++;
stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
status(`Calculated dB: ${calculated} / ${voices.length}`);
} catch(e) {
errors.push({voice_id:v.id, detail:e.message});
stats.errors++;
stats.last = `${v.id}: ${e.message}`;
}
setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats);
await new Promise(resolve => setTimeout(resolve, 0));
}
setBenchmarkProgress(voices.length, voices.length, 'dB scan complete', stats);
return { calculated, errors, voices: voices.map(v => ({voice_id:v.id, loudness:v.loudness})) };
}
async function hydrateVoiceDuration(v, el) {
if (!v.path || !needsDuration(v) || v._durationLoading) return;
v._durationLoading = true;
try {
const audio = new Audio();
audio.preload = 'metadata';
audio.src = voiceFileUrl(v);
await new Promise((resolve, reject) => {
audio.onloadedmetadata = resolve;
audio.onerror = () => reject(new Error('Could not read duration'));
});
if (Number.isFinite(audio.duration) && audio.duration > 0) {
v.duration = audio.duration;
if (el && document.body.contains(el)) {
el.textContent = fmtDuration(v.duration);
el.title = String(v.duration.toFixed(2));
}
}
audio.removeAttribute('src');
audio.load();
} catch(e) {
if (el && document.body.contains(el)) el.title = e.message;
} finally {
v._durationLoading = false;
}
}
document.querySelectorAll('.vl-header [data-sort]').forEach(el =>
el.addEventListener('click', () => setSort(el.dataset.sort))
);
function dominantLanguages(limit = 3) {
const counts = new Map();
(_voices || []).forEach(v => {
const lang = (v.lang || String(v.id || '').split('_')[0] || '?').toUpperCase();
counts.set(lang, (counts.get(lang) || 0) + 1);
});
return [...counts.entries()]
.sort((a,b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([lang, count]) => `${lang} ${count}`)
.join(' · ') || '-';
}
function updateLibraryInsights(state = 'ready') {
const el = $('library-insights');
if (!el) return;
if (state === 'loading') {
el.innerHTML = [
['…', 'Loading'], ['…', 'Active'], ['…', 'Languages'], ['…', 'Benchmarks'], ['…', 'Quality'], ['…', 'Actions']
].map(([value, label]) => `<div class="insight"><strong>${value}</strong><span>${label}</span></div>`).join('');
return;
}
if (state === 'error') {
el.innerHTML = '<div class="insight"><strong>Failed</strong><span>Library load</span></div>';
return;
}
const total = _voices.length;
const active = _voices.filter(v => v.enabled !== false).length;
const hidden = total - active;
const bench = _voices.map(voiceBenchmark).filter(Boolean);
const slow = bench.filter(b => b && b.ok && b.realtime_ok === false).length;
const dbValues = _voices.map(voiceDbfs).filter(v => v != null);
const avgDb = dbValues.length ? (dbValues.reduce((a,b) => a + b, 0) / dbValues.length).toFixed(1) : '-';
const missingRef = _voices.filter(v => !v.transcript).length;
const restart = _voices.filter(v => v.needs_tts_restart).length;
const visible = _voices.filter(v => $('show-disabled-cb').checked || v.enabled !== false).length;
const tiles = [
{value:`${visible}/${total}`, label:'Visible'},
{value:`${active} on`, label:hidden ? `${hidden} hidden` : 'Active'},
{value:dominantLanguages(), label:'Languages'},
{value:bench.length ? `${bench.length} done` : '-', label:slow ? `${slow} slow` : 'Benchmarks', filter: slow ? 'slow' : '', title: slow ? describeIssueVoices('slow') : 'No slow voices'},
{value:avgDb === '-' ? '-' : `${avgDb} dB`, label:missingRef ? `${missingRef} no text` : 'Avg loudness', filter: missingRef ? 'no_text' : '', title: missingRef ? describeIssueVoices('no_text') : 'All visible voices have reference text'},
{value:restart || '-', label:restart ? 'Need restart' : 'Restart flags', filter: restart ? 'restart' : '', title: restart ? describeIssueVoices('restart') : 'No voices need restart'},
];
el.innerHTML = tiles.map(item => {
const filter = item.filter ? ` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"` : '';
const activeCls = item.filter && item.filter === _libraryIssueFilter ? ' active' : '';
const title = item.title ? ` title="${escHtml(item.title)}"` : '';
return `<div class="insight${activeCls}"${filter}${title}><strong>${escHtml(item.value)}</strong><span>${escHtml(item.label)}</span></div>`;
}).join('');
el.querySelectorAll('[data-filter]').forEach(tile => {
const activate = () => setLibraryIssueFilter(tile.dataset.filter || '');
tile.addEventListener('click', activate);
tile.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } });
});
}
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 = '<div style="color:var(--red);padding:8px">Failed to load voices</div>';
$('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;
const _calcTarget = (_bulkSelected && _bulkSelected.size > 0) ? `${_bulkSelected.size} selected` : 'visible';
status(`Calculating voice loudness (${_calcTarget})…`);
try {
const d = await clientCalculateVoiceDb();
renderVoiceList();
const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : '';
toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success');
status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`);
} catch(e) {
toast('Calculate dB failed: ' + e.message, 'error');
status('dB calculation failed');
} finally { $('calculate-db-btn').disabled = false; }
});
$('normalize-volume-btn').addEventListener('click', async () => {
const target = libraryTargetDb();
const visible = visibleLibraryVoices();
const voices = visible.filter(v => voiceFileType(v) === 'wav');
const skipped = visible.length - voices.length;
if (!voices.length) {
toast('No visible WAV voices to normalize', 'error');
return;
}
if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return;
$('normalize-volume-btn').disabled = true;
$('calculate-db-btn').disabled = true;
const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'};
const errors = [];
let normalized = 0;
setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats);
status(`Normalizing ${voices.length} voices to ${target} dBFS...`);
try {
for (const v of voices) {
setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
try {
const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.loudness = d.loudness || v.loudness;
v.duration = d.duration ?? v.duration;
v.file_type = d.file_type || v.file_type;
v.path = d.path || v.path;
v.needs_tts_restart = true;
markVoiceAudioChanged(v);
normalized++;
stats.ok++;
stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
} catch(e) {
errors.push({voice_id:v.id, detail:e.message});
stats.errors++;
stats.last = `${v.id}: ${e.message}`;
}
setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
status(`Normalized ${normalized} / ${voices.length}`);
await new Promise(resolve => setTimeout(resolve, 0));
}
setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats);
renderVoiceList();
updateLibraryInsights();
const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`;
toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success');
status('Volume normalized. Restart TTS before rebenchmarking these voices.');
} catch(e) {
toast('Normalize volume failed: ' + e.message, 'error');
status('Normalize volume failed');
} finally {
$('normalize-volume-btn').disabled = false;
$('calculate-db-btn').disabled = false;
}
});
function fmtClock(ms) {
if (!Number.isFinite(ms) || ms < 0) return '-';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2,'0')}`;
}
function setBenchmarkProgress(done, total, label = '', stats = {}) {
const panel = $('benchmark-progress');
const track = panel.querySelector('.benchmark-progress-track');
const pct = total ? Math.round(done / total * 100) : 0;
panel.hidden = false;
$('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...');
$('benchmark-progress-count').textContent = `${done} / ${total}`;
$('benchmark-progress-bar').style.width = pct + '%';
track.setAttribute('aria-valuenow', String(pct));
const live = $('benchmark-live-stats');
if (live) {
const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0;
const avg = done > 0 ? elapsed / done : 0;
const eta = done > 0 && total > done ? avg * (total - done) : 0;
live.innerHTML = [
`Elapsed ${fmtClock(elapsed)}`,
`Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`,
`ETA ${done && total > done ? fmtClock(eta) : '-'}`,
`OK ${stats.ok || 0}`,
`${stats.middleLabel || 'Slow'} ${stats.slow || 0}`,
`${stats.errorLabel || 'Errors'} ${stats.errors || 0}`,
].map(x => `<span>${escHtml(x)}</span>`).join('');
}
const last = $('benchmark-live-last');
if (last && stats.last) last.textContent = stats.last;
}
function hideBenchmarkProgress() {
$('benchmark-progress').hidden = true;
$('benchmark-progress-bar').style.width = '0%';
if ($('benchmark-live-last')) $('benchmark-live-last').textContent = '';
}
async function clearTtsRestartFlags() {
const r = await fetch('/api/tts/restart-flags/clear', { method:'POST' });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_voices.forEach(voice => { voice.needs_tts_restart = false; });
document.querySelectorAll('.vl-row.edit-open').forEach(row => row.classList.remove('opt-restart-needed'));
updateLibraryInsights();
return d;
}
async function runVoiceBenchmark(voiceId = '', opts = {}) {
const text = opts.text ?? benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
const payload = {active_only:true, text};
if (voiceId) payload.voice_id = voiceId;
const r = await fetch('/api/voices/benchmark', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
return r.json();
}
async function runVoiceBenchmarkBatch() {
const voices = benchmarkTargetVoices();
const text = benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
if (!voices.length) { toast('No voices to benchmark', 'error'); return null; }
const total = voices.length;
const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''};
// Mark targeted rows as pending
voices.forEach(v => {
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (row) {
row.classList.remove('benchmarking-active', 'benchmarking-done');
row.classList.add('benchmarking-pending');
}
});
setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
for (let i = 0; i < voices.length; i++) {
const voice = voices[i];
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(voice.id)}"]`);
if (row) {
row.classList.remove('benchmarking-pending');
row.classList.add('benchmarking-active');
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats);
status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`);
try {
const d = await runVoiceBenchmark(voice.id, {text});
if (d) {
aggregate.benchmarked += Number(d.benchmarked || 0);
aggregate.errors.push(...(d.errors || []));
aggregate.voices.push(...(d.voices || []));
mergeBenchmarkResults(d);
const hit = (d.voices || []).find(x => x.voice_id === voice.id);
const b = hit && hit.benchmark;
if (b && b.ok) {
stats.ok++;
if (b.realtime_ok === false) stats.slow++;
stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(2)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`;
// Live-update the row cells so results appear immediately
if (row) {
const benchCell = row.querySelector('.vl-tbl-bench');
const durCell = row.querySelector('.vl-tbl-dur');
const wpmCell = row.querySelector('.vl-tbl-wpm');
if (benchCell) {
const bt = fmtBenchmark(voice), bc = benchmarkClass(voice), btitle = benchmarkTitle(voice);
benchCell.innerHTML = bt !== '-' ? `<span class="vl-bench-chip ${bc}" title="${escHtml(btitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(bt)}</span>` : '-';
}
if (durCell) {
const sec = voiceBenchmarkAudioSec(voice);
durCell.textContent = fmtBenchmarkAudio(voice);
durCell.title = sec != null ? `${fmtBenchmarkAudio(voice)} of synthesised audio for the benchmark sentence` : '';
}
if (wpmCell) {
const wpm = voiceWpm(voice);
wpmCell.textContent = fmtWpm(voice);
wpmCell.title = wpm != null ? `${wpm} wpm — 130180 wpm is natural for long listening` : '';
}
}
} else {
stats.errors++;
stats.last = `${voice.id}: failed${b && b.error ? ' · ' + b.error : ''}`;
}
}
} catch(e) {
aggregate.errors.push({voice_id: voice.id, detail: e.message});
stats.errors++;
stats.last = `${voice.id}: failed · ${e.message}`;
}
if (row) {
row.classList.remove('benchmarking-active');
row.classList.add('benchmarking-done');
}
setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats);
}
// Cleanup any remaining pending rows just in case
voices.forEach(v => {
const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (row) row.classList.remove('benchmarking-pending');
});
setBenchmarkProgress(total, total, 'Benchmark complete', stats);
return aggregate;
}
function mergeBenchmarkResults(d) {
const byId = new Map((d.voices || []).map(x => [x.voice_id, x]));
_voices.forEach(v => {
const hit = byId.get(v.id);
if (hit && hit.benchmark) v.benchmark = hit.benchmark;
});
}
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');
});
// Precompute speaker embeddings: fire a tiny synth per active voice so the TTS
// engine computes + caches each voice's .pt (speaker fingerprint) ahead of time,
// making first real playback instant. The engine prefers the cached .pt and only
// (re)builds it from the wav when missing — so this is purely a warm-up.
$('precompute-embeddings-btn')?.addEventListener('click', async () => {
const backend = libraryTtsBackend();
const _useSelected = _bulkSelected && _bulkSelected.size > 0;
const ids = _useSelected ? [..._bulkSelected] : activeVoiceIds();
const _scopeLabel = _useSelected ? `${ids.length} selected` : `${ids.length} active`;
if (!ids.length) { toast('No voices to precompute', 'error'); return; }
if (!confirm(`Precompute speaker embeddings for ${_scopeLabel} voice(s) via “${backend}”?\n\nThis warms each voice so the engine caches its .pt and first playback is instant.`)) return;
const btn = $('precompute-embeddings-btn'); if (btn) btn.disabled = true;
const ov = document.createElement('div');
ov.className = 'audiobook-overlay'; ov.id = 'precompute-overlay';
ov.innerHTML = `<div class="audiobook-box"><div class="audiobook-title"><span class="mdi mdi-flash-outline"></span> Precomputing embeddings</div>
<div class="audiobook-msg" id="pc-msg">0 / ${ids.length}</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="pc-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="pc-cancel">Cancel</button></div></div>`;
document.body.appendChild(ov);
let cancel = false;
ov.querySelector('#pc-cancel').addEventListener('click', () => { cancel = true; });
const fill = ov.querySelector('#pc-fill'), msg = ov.querySelector('#pc-msg');
let done = 0, ok = 0, failed = 0;
const queue = ids.slice();
const worker = async () => {
while (queue.length && !cancel) {
const id = queue.shift();
if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`;
try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', backend); ok++; }
catch (_) { failed++; }
done++; if (fill) fill.style.width = (done / ids.length * 100) + '%';
}
};
try { await Promise.all(Array.from({ length: Math.min(2, ids.length) }, worker)); }
finally { ov.remove(); if (btn) btn.disabled = false; }
toast(cancel ? `Cancelled — ${ok} warmed` : `Precomputed ${ok} embedding(s)${failed ? `, ${failed} skipped/failed` : ''}`,
(!ok && failed) ? 'error' : 'success');
});
const LIB_ADD_SAMPLE_TEXTS = {
EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.',
DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.',
IT: 'La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.',
ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.',
FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.',
PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.',
NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.',
PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.'
};
const LIB_ADD_SAMPLE_STORAGE_KEY = 'vcf-lib-add-sample-texts';
function libAddSampleOverrides() {
try { return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY) || '{}') || {}; }
catch(e) { return {}; }
}
function getLibAddSampleText(code) {
return libAddSampleOverrides()[code] || LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN;
}
function saveLibAddSampleText() {
const code = $('lib-add-sample-lang').value;
const text = $('lib-add-sample-text').value.trim();
const overrides = libAddSampleOverrides();
if (text && text !== LIB_ADD_SAMPLE_TEXTS[code]) overrides[code] = text;
else delete overrides[code];
localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides));
setLibAddStatus('Sample sentence saved');
}
function resetLibAddSampleText() {
const code = $('lib-add-sample-lang').value;
const overrides = libAddSampleOverrides();
delete overrides[code];
localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides));
$('lib-add-sample-text').value = LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN;
setLibAddStatus('Sample sentence reset');
}
function updateLibAddSampleLanguage(lang) {
const code = LIB_ADD_SAMPLE_TEXTS[lang] ? lang : 'EN';
$('lib-add-sample-lang').value = code;
$('lib-add-lang').value = code;
$('lib-add-sample-text').value = getLibAddSampleText(code);
const voiceId = $('lib-add-voice-id').value.trim();
if (voiceId && /^[A-Z]{2}_/.test(voiceId)) {
$('lib-add-voice-id').value = voiceId.replace(/^[A-Z]{2}_/, code + '_');
}
}
function renderLibAddMeter(level = 0, db = -Infinity, clipped = false) {
const meter = $('lib-add-mic-meter');
if (!meter.children.length) {
for (let i = 0; i < 18; i++) {
const bar = document.createElement('div');
bar.className = 'bar';
meter.appendChild(bar);
}
}
const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length);
[...meter.children].forEach((bar, i) => {
bar.className = 'bar';
bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px';
if (i < active) {
bar.classList.add('on');
if (db > -12 && i > 11) bar.classList.add('hot');
if (clipped && i > 14) bar.classList.add('clip');
}
});
$('lib-add-db-readout').textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
}
function syncLibAddMicGain() {
const gain = parseFloat($('lib-add-mic-gain').value) || 0;
$('lib-add-mic-gain-value').textContent = gain.toFixed(2) + 'x';
if (libAddState.gainNode) libAddState.gainNode.gain.value = gain;
}
function startLibAddMeter() {
if (!libAddState.analyser) return;
if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf);
const data = new Float32Array(libAddState.analyser.fftSize);
const tick = () => {
libAddState.analyser.getFloatTimeDomainData(data);
let sum = 0, peak = 0;
for (const sample of data) {
sum += sample * sample;
peak = Math.max(peak, Math.abs(sample));
}
const rms = Math.sqrt(sum / data.length);
const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
const level = Number.isFinite(db) ? (db + 60) / 60 : 0;
renderLibAddMeter(level, db, peak > 0.98);
libAddState.meterRaf = requestAnimationFrame(tick);
};
tick();
}
async function ensureLibAddMicMonitor() {
if (libAddState.recordStream) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
libAddState.stream = await requestMicrophoneStream({raw:true});
if (AudioCtx) {
libAddState.audioCtx = new AudioCtx();
libAddState.sourceNode = libAddState.audioCtx.createMediaStreamSource(libAddState.stream);
libAddState.gainNode = libAddState.audioCtx.createGain();
libAddState.analyser = libAddState.audioCtx.createAnalyser();
libAddState.analyser.fftSize = 1024;
const dest = libAddState.audioCtx.createMediaStreamDestination();
syncLibAddMicGain();
libAddState.sourceNode.connect(libAddState.gainNode);
libAddState.gainNode.connect(libAddState.analyser);
libAddState.gainNode.connect(dest);
libAddState.recordStream = dest.stream;
startLibAddMeter();
} else {
libAddState.recordStream = libAddState.stream;
}
libAddState.monitoring = true;
$('lib-add-monitor-btn').disabled = true;
$('lib-add-monitor-stop').disabled = false;
}
function stopLibAddMic() {
if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf);
libAddState.meterRaf = null;
[libAddState.sourceNode, libAddState.gainNode, libAddState.analyser].forEach(node => {
try { if (node) node.disconnect(); } catch(e) {}
});
if (libAddState.stream) libAddState.stream.getTracks().forEach(t => t.stop());
if (libAddState.recordStream) libAddState.recordStream.getTracks().forEach(t => t.stop());
if (libAddState.audioCtx) libAddState.audioCtx.close().catch(()=>{});
libAddState.stream = null;
libAddState.recordStream = null;
libAddState.sourceNode = null;
libAddState.gainNode = null;
libAddState.analyser = null;
libAddState.audioCtx = null;
libAddState.monitoring = false;
$('lib-add-monitor-btn').disabled = false;
$('lib-add-monitor-stop').disabled = true;
renderLibAddMeter(0, -Infinity, false);
}
let libAddState = {
id:null, duration:0, audio:null, buffer:null, recorder:null, chunks:[], pendingSource:null,
stream:null, recordStream:null, timer:null, secs:0, audioCtx:null,
sourceNode:null, gainNode:null, analyser:null, meterRaf:null, monitoring:false
};
window.libAddState = libAddState;
$('add-new-voice-btn').addEventListener('click', () => {
$('lib-add-panel').classList.toggle('open');
});
$('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value));
$('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value));
$('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500));
$('lib-add-use-sample').addEventListener('click', () => {
$('lib-add-transcript').value = $('lib-add-sample-text').value.trim();
setLibAddStatus('Sample sentence copied to transcript');
});
$('lib-add-reset-sample').addEventListener('click', resetLibAddSampleText);
$('lib-add-mic-help-btn').addEventListener('click', () => {
$('lib-add-mic-help').classList.toggle('open');
});
$('lib-add-monitor-btn').addEventListener('click', async () => {
try {
await ensureLibAddMicMonitor();
setLibAddStatus('Mic level monitor active');
} catch(e) {
stopLibAddMic();
$('lib-add-mic-help').classList.add('open');
const message = await microphoneErrorMessage(e);
toast(message, 'error');
setLibAddStatus(message);
}
});
$('lib-add-monitor-stop').addEventListener('click', () => {
stopLibAddMic();
setLibAddStatus('Mic level monitor stopped');
});
$('lib-add-mic-gain').addEventListener('input', syncLibAddMicGain);
renderLibAddMeter();
syncLibAddMicGain();
updateLibAddSampleLanguage('EN');
function setLibAddStatus(msg) {
$('lib-add-status').textContent = msg;
status(msg);
}
function suggestLibVoiceId(filename) {
if ($('lib-add-voice-id').value.trim()) return;
const base = String(filename || 'NewVoice')
.replace(/\.[^.]+$/, '')
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 60) || 'NewVoice';
$('lib-add-voice-id').value = `${$('lib-add-lang').value || 'EN'}_${$('lib-add-gender').value || 'N'}_${base}`;
}
function loadLibAddAudio(id, duration, label = 'Audio') {
libAddState.id = id;
libAddState.duration = Number(duration) || 0;
libAddState.buffer = null;
$('lib-add-start').value = '0.00';
$('lib-add-end').value = libAddState.duration ? Math.min(libAddState.duration, 20).toFixed(2) : '0.00';
$('lib-add-audio').src = '/api/audio/' + id;
$('lib-add-audio').style.display = '';
$('lib-add-wave').style.display = '';
attachLibAddWaveSelection();
decodeTempAudio(id).then(buffer => {
if (libAddState.id !== id) return;
libAddState.buffer = buffer;
drawLibAddWave();
}).catch(()=>{});
setLibAddStatus(`${label} loaded${libAddState.duration ? ' (' + libAddState.duration.toFixed(1) + ' s)' : ''}`);
}
async function decodeTempAudio(id) {
const resp = await fetch('/api/audio/' + encodeURIComponent(id));
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
const data = await resp.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
return ctx.decodeAudioData(data.slice(0));
}
function clampLibAddTime(value) {
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
return Math.max(0, Math.min(duration, Number(value) || 0));
}
function setLibAddCropRange(start, end) {
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
let a = clampLibAddTime(start), b = clampLibAddTime(end);
if (Math.abs(b - a) < 0.05) b = Math.min(duration, a + Math.min(1, duration || 1));
if (b < a) [a, b] = [b, a];
$('lib-add-start').value = a.toFixed(2);
$('lib-add-end').value = b.toFixed(2);
drawLibAddWave();
}
function libAddWaveTimeFromEvent(e) {
const canvas = $('lib-add-wave');
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
return rect.width ? x / rect.width * duration : 0;
}
function updateLibAddCropHint() {
const hint = $('lib-add-crop-hint');
if (!hint) return;
const start = parseFloat($('lib-add-start').value) || 0;
const end = parseFloat($('lib-add-end').value) || 0;
const dur = Math.max(0, end - start);
hint.textContent = dur ? `Selected ${dur.toFixed(1)}s. Aim for 3-20 seconds.` : 'Select 3-20 seconds for best cloning.';
hint.className = 'crop-duration-hint ' + (dur >= 3 && dur <= 20 ? 'ok' : dur ? 'warn' : '');
}
function drawLibAddWave() {
if (!libAddState.buffer) return;
drawOptimizerWave(
$('lib-add-wave'),
libAddState.buffer,
parseFloat($('lib-add-start').value) || 0,
parseFloat($('lib-add-end').value) || libAddState.duration || libAddState.buffer.duration
);
updateLibAddCropHint();
}
function libAddWaveSelectionPixels(e) {
const canvas = $('lib-add-wave');
const rect = canvas.getBoundingClientRect();
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0);
const end = clampLibAddTime(parseFloat($('lib-add-end').value) || duration);
const sx = duration && rect.width ? start / duration * rect.width : 0;
const ex = duration && rect.width ? end / duration * rect.width : rect.width;
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
return {x, sx, ex, start, end, duration};
}
function libAddWaveDragMode(e) {
const {x, sx, ex} = libAddWaveSelectionPixels(e);
const hit = 16;
if (Math.abs(x - sx) <= hit) return 'start';
if (Math.abs(x - ex) <= hit) return 'end';
return 'new';
}
function attachLibAddWaveSelection() {
const canvas = $('lib-add-wave');
if (!canvas || canvas.dataset.cropReady) return;
canvas.dataset.cropReady = '1';
let drag = null;
canvas.addEventListener('pointerdown', e => {
if (!libAddState.buffer) return;
e.preventDefault();
const mode = libAddWaveDragMode(e);
const t = libAddWaveTimeFromEvent(e);
const currentStart = parseFloat($('lib-add-start').value) || 0;
const currentEnd = parseFloat($('lib-add-end').value) || libAddState.duration || 0;
drag = {mode, anchor: t, start: currentStart, end: currentEnd};
canvas.setPointerCapture?.(e.pointerId);
if (mode === 'start') setLibAddCropRange(t, currentEnd);
else if (mode === 'end') setLibAddCropRange(currentStart, t);
else setLibAddCropRange(t, t);
setLibAddStatus(mode === 'start' ? 'Dragging crop start handle' : mode === 'end' ? 'Dragging crop end handle' : 'Drag to choose a new crop range');
});
canvas.addEventListener('pointermove', e => {
if (!libAddState.buffer) return;
if (!drag) {
const mode = libAddWaveDragMode(e);
canvas.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : 'crosshair';
return;
}
e.preventDefault();
const t = libAddWaveTimeFromEvent(e);
if (drag.mode === 'start') setLibAddCropRange(t, drag.end);
else if (drag.mode === 'end') setLibAddCropRange(drag.start, t);
else setLibAddCropRange(drag.anchor, t);
});
const finish = e => {
if (!drag) return;
e.preventDefault();
const t = libAddWaveTimeFromEvent(e);
if (drag.mode === 'start') setLibAddCropRange(t, drag.end);
else if (drag.mode === 'end') setLibAddCropRange(drag.start, t);
else setLibAddCropRange(drag.anchor, t);
drag = null;
const start = parseFloat($('lib-add-start').value) || 0;
const end = parseFloat($('lib-add-end').value) || 0;
setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0, end - start).toFixed(1)}s) selected`);
};
canvas.addEventListener('pointerup', finish);
canvas.addEventListener('pointerleave', () => { if (!drag) canvas.style.cursor = 'crosshair'; });
canvas.addEventListener('pointercancel', () => { drag = null; canvas.style.cursor = 'crosshair'; });
}
async function uploadLibAddFile(file) {
if (!file) return;
const fd = new FormData();
fd.append('file', file);
setLibAddStatus('Uploading audio…');
try {
const r = await fetch('/api/upload', {method:'POST', body:fd});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
suggestLibVoiceId(file.name);
loadLibAddAudio(d.id, d.duration, file.name || 'Audio');
toast('Audio loaded', 'success');
} catch(e) { toast('Load failed: ' + e.message, 'error'); setLibAddStatus('Load failed'); }
}
const libAddDrop = $('lib-add-drop');
libAddDrop.addEventListener('click', () => $('lib-add-file').click());
libAddDrop.addEventListener('dragover', e => { e.preventDefault(); libAddDrop.classList.add('drag-over'); });
libAddDrop.addEventListener('dragleave', () => libAddDrop.classList.remove('drag-over'));
libAddDrop.addEventListener('drop', e => {
e.preventDefault();
libAddDrop.classList.remove('drag-over');
if (e.dataTransfer.files.length) uploadLibAddFile(e.dataTransfer.files[0]);
});
$('lib-add-file').addEventListener('change', async () => {
if ($('lib-add-file').files.length) await uploadLibAddFile($('lib-add-file').files[0]);
$('lib-add-file').value = '';
});
$('lib-add-url-btn').addEventListener('click', () => {
const url = $('lib-add-url').value.trim();
if (!url) { toast('Enter a YouTube or audio URL', 'error'); return; }
$('lib-add-url-btn').disabled = true;
setLibAddStatus('Starting download…');
const es = new EventSource('/api/download-yt?url=' + encodeURIComponent(url));
es.onmessage = e => {
const d = JSON.parse(e.data);
if (d.error) {
toast('Download failed: ' + d.error, 'error');
setLibAddStatus(d.error);
$('lib-add-url-btn').disabled = false;
es.close();
} else if (d.done) {
es.close();
$('lib-add-url-btn').disabled = false;
suggestLibVoiceId(url.split('/').pop() || 'DownloadedVoice');
loadLibAddAudio(d.id, d.duration, 'Downloaded audio');
toast('URL audio loaded', 'success');
} else {
setLibAddStatus(d.msg || 'Downloading…');
}
};
es.onerror = () => {
es.close();
$('lib-add-url-btn').disabled = false;
setLibAddStatus('Download connection closed');
};
});
$('lib-add-rec-start').addEventListener('click', async () => {
try {
await ensureLibAddMicMonitor();
libAddState.chunks = [];
libAddState.secs = 0;
$('lib-add-rec-time').textContent = '0:00';
$('lib-add-rec-start').disabled = true;
$('lib-add-rec-stop').disabled = false;
$('lib-add-monitor-stop').disabled = true;
libAddState.timer = setInterval(() => {
libAddState.secs++;
$('lib-add-rec-time').textContent = Math.floor(libAddState.secs / 60) + ':' + String(libAddState.secs % 60).padStart(2, '0');
}, 1000);
libAddState.recorder = new MediaRecorder(libAddState.recordStream);
libAddState.recorder.ondataavailable = e => { if (e.data.size) libAddState.chunks.push(e.data); };
libAddState.recorder.onstop = async () => {
clearInterval(libAddState.timer);
$('lib-add-rec-start').disabled = false;
$('lib-add-rec-stop').disabled = true;
const blob = new Blob(libAddState.chunks, {type:libAddState.recorder.mimeType || 'audio/webm'});
const ext = (libAddState.recorder.mimeType || '').includes('ogg') ? '.ogg' : '.webm';
stopLibAddMic();
suggestLibVoiceId('recording');
await uploadLibAddFile(new File([blob], 'recording' + ext, {type:blob.type}));
};
libAddState.recorder.start(100);
setLibAddStatus('Recording…');
} catch(e) {
stopLibAddMic();
$('lib-add-mic-help').classList.add('open');
const message = await microphoneErrorMessage(e);
toast(message, 'error');
setLibAddStatus(message);
$('lib-add-rec-start').disabled = false;
$('lib-add-rec-stop').disabled = true;
}
});
$('lib-add-rec-stop').addEventListener('click', () => {
if (libAddState.recorder && libAddState.recorder.state !== 'inactive') libAddState.recorder.stop();
});
$('lib-add-auto-trim').addEventListener('click', async () => {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
$('lib-add-auto-trim').disabled = true;
try {
const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id})});
let d;
if (r.ok) d = await r.json();
else if (r.status === 404 || r.status === 405) d = await clientAutoTrimBounds(libAddState.id);
else { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
$('lib-add-start').value = Number(d.start).toFixed(2);
$('lib-add-end').value = Number(d.end).toFixed(2);
drawLibAddWave();
setLibAddStatus(d.reason || 'Auto trim ready');
} catch(e) { toast('Auto trim failed: ' + e.message, 'error'); setLibAddStatus('Auto trim failed'); }
finally { $('lib-add-auto-trim').disabled = false; }
});
async function transcribeLibAddCurrent(successMessage = 'Text recognised', audioId = libAddState.id) {
if (!audioId) throw new Error('Load audio first');
setLibAddStatus('Recognising text...');
const r = await fetch('/api/transcribe', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:audioId})
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
const text = d.text || '';
$('lib-add-transcript').value = text;
setLibAddStatus(successMessage);
return text;
}
function openSavedLibraryVoice(voiceId) {
const openRow = () => {
const row = Array.from(document.querySelectorAll('.vl-row')).find(r => r.dataset.id === voiceId);
if (!row) return false;
row.scrollIntoView({behavior:'smooth', block:'center'});
if (!row.classList.contains('edit-open')) row.querySelector('.edit-audio-btn')?.click();
return true;
};
if (!openRow()) setTimeout(openRow, 150);
}
async function applyLibAddCrop() {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0);
const end = clampLibAddTime(parseFloat($('lib-add-end').value) || libAddState.duration);
const duration = end - start;
if (end <= start + 0.1) { toast('Crop range is too short', 'error'); setLibAddStatus('Crop range is too short'); return; }
if (duration < 3 || duration > 20) toast('Best clone references are 3-20 seconds; cropping anyway.', 'error');
['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = true; });
setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);
try {
const r = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id, start, end})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
loadLibAddAudio(d.id, d.duration, 'Cropped audio');
toast('Crop applied', 'success');
try {
await transcribeLibAddCurrent('Cropped audio loaded and text recognised', d.id);
} catch (e) {
toast('Crop applied, but recognition failed: ' + e.message, 'error');
setLibAddStatus('Cropped audio loaded; recognition failed');
}
} catch(e) { toast('Crop failed: ' + e.message, 'error'); setLibAddStatus('Crop failed'); }
finally { ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = false; }); }
}
$('lib-add-save-crop').addEventListener('click', applyLibAddCrop);
$('lib-add-save-crop-bottom').addEventListener('click', applyLibAddCrop);
['lib-add-start','lib-add-end'].forEach(id => $(id).addEventListener('input', drawLibAddWave));
$('lib-add-play').addEventListener('click', () => {
if (!libAddState.id) return;
if (libAddState.audio) libAddState.audio.pause();
libAddState.audio = new Audio('/api/audio/' + libAddState.id);
const start = parseFloat($('lib-add-start').value) || 0;
const end = parseFloat($('lib-add-end').value) || libAddState.duration;
libAddState.audio.currentTime = start;
libAddState.audio.ontimeupdate = () => { if (libAddState.audio.currentTime >= end) libAddState.audio.pause(); };
libAddState.audio.play();
});
$('lib-add-recognize').addEventListener('click', async () => {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
try {
await transcribeLibAddCurrent('Text recognised');
} catch(e) { toast('Recognition failed: ' + e.message, 'error'); setLibAddStatus('Recognition failed'); }
});
$('lib-add-save').addEventListener('click', async () => {
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
const voiceId = $('lib-add-voice-id').value.trim() || `${$('lib-add-lang').value}_${$('lib-add-gender').value}_NewVoice`;
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters', 'error'); return; }
setLibAddStatus('Saving voice...');
$('lib-add-save').disabled = true;
try {
const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:libAddState.id, start:parseFloat($('lib-add-start').value)||0, end:parseFloat($('lib-add-end').value)||libAddState.duration})});
if (!pr.ok) { const e = await pr.json().catch(()=>({})); throw new Error(e.detail || pr.statusText); }
const p = await pr.json();
let transcript = $('lib-add-transcript').value.trim();
if (!transcript) {
transcript = await transcribeLibAddCurrent('Final clip recognised; saving voice...', p.id);
if (!transcript.trim()) throw new Error('Recognition returned no transcript; add text or try recognising again.');
}
const sr = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:p.id, voice_id:voiceId, transcript})});
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
if (libAddState.pendingSource?.imageUrl) {
try {
await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl})
});
} catch (_) {}
}
libAddState.pendingSource = null;
setLibAddSourcePreview({});
$('lib-add-panel')?.classList.remove('open');
toast('Voice saved: ' + voiceId, 'success');
setLibAddStatus('Voice saved');
await loadVoiceLibrary();
openSavedLibraryVoice(voiceId);
} catch(e) { toast('Save failed: ' + e.message, 'error'); setLibAddStatus('Save failed'); }
finally { $('lib-add-save').disabled = false; }
});
$('show-disabled-cb').addEventListener('change', () => {
$('disabled-info').style.display = $('show-disabled-cb').checked ? '' : 'none';
renderVoiceList();
});
['library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => {
$(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); });
});
$('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180));
$('library-clear-filters')?.addEventListener('click', clearLibraryFilters);
$('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); });
function renderVoiceList() {
const showDisabled = $('show-disabled-cb').checked;
const list = $('voice-list');
list.innerHTML = '';
renderVoiceGroupsBar();
populateLibraryFilters();
readLibraryFilters();
const _gtBtn = $('voice-group-tag-btn');
if (_gtBtn) {
_gtBtn.classList.toggle('active', !!window._voiceGroupByTag);
const ic = _gtBtn.querySelector('.mdi');
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
}
const _tvBtn = $('voice-table-view-btn');
if (_tvBtn) {
_tvBtn.classList.toggle('active', !!window._voiceTableView);
document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView);
const editBtn = document.getElementById('voice-table-edit-btn');
if (editBtn) {
editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
editBtn.classList.toggle('active', !!window._voiceTableEditMode);
}
}
// Apply sidebar category filter
const cat = window._voiceSidebarCat || 'all';
const enabledOk = v => showDisabled || v.enabled !== false;
let filtered = _voices.filter(v => {
// 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV).
// 'cloned' = real WAV-identity voices that were NOT prompt-designed.
if (cat === 'cloned') return enabledOk(v) && v.has_ref && v.origin !== 'designed';
if (cat === 'designed') return enabledOk(v) && (v.origin === 'designed' || !v.has_ref);
if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4;
if (cat === 'hidden') return v.enabled === false; // always show hidden voices in hidden tab
// 'tools' shows all voices (same as 'all') so bulk-edit works
return enabledOk(v);
});
// Apply active group filter (from the groups bar)
if (window._voiceGroupFilter) {
filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter);
}
// Apply active tag "subfolder" filter (from the My Voices nav tree)
if (window._voiceTagFilter) {
filtered = filtered.filter(v => String(v.tag || '').split(',').map(t => t.trim()).includes(window._voiceTagFilter));
}
const visibleCount = filtered.length;
filtered = filtered.filter(libraryFilterMatch);
const filterCount = filtered.length;
if (_libraryIssueFilter) filtered = filtered.filter(v => libraryIssueMatch(v));
$('voice-count').textContent = filtered.length + ' / ' + _voices.length + ' voices';
filtered = filtered.slice().sort((a, b) => {
const av = getSortValue(a, _sortField), bv = getSortValue(b, _sortField);
if (av < bv) return -_sortDir;
if (av > bv) return _sortDir;
return 0;
});
updateLibraryInsights();
if (_libraryIssueFilter) {
const note = document.createElement('div');
note.className = 'library-filter-note';
note.innerHTML = `<span><strong>${escHtml(filtered.length)} / ${escHtml(filterCount)}</strong> ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}</span><button class="btn-secondary" type="button">Clear filter</button>`;
note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter(''));
list.appendChild(note);
}
if (!filtered.length) {
if (_voices.length === 0) {
// True empty — render a two-case helper panel
const emptyEl = document.createElement('div');
emptyEl.className = 'voices-empty-state';
emptyEl.innerHTML = `
<div class="voices-empty-cases">
<div class="voices-empty-case">
<div class="voices-empty-case-icon"><span class="mdi mdi-folder-outline"></span></div>
<h4>Wrong folder path?</h4>
<p>Set the path where your <code>.wav</code> voice files live <em>inside the container</em>.</p>
<div class="voices-folder-row">
<input id="voices-empty-scan-dir" type="text" class="voices-folder-input" placeholder="/voices">
<button class="btn-secondary" id="voices-empty-browse-btn" title="Browse container filesystem"><span class="mdi mdi-folder-outline"></span> Browse</button>
<button class="btn-primary" id="voices-empty-save-btn">Set &amp; reload</button>
</div>
<div class="vef-dir-browser" id="vef-dir-browser" hidden>
<div class="vef-breadcrumb" id="vef-breadcrumb"></div>
<div class="vef-dir-list" id="vef-dir-list"><span class="vef-loading">Loading…</span></div>
<div class="vef-browser-actions">
<span class="vef-selected-path note" id="vef-selected-path"></span>
<button class="btn-primary" id="vef-select-btn">Use this folder</button>
</div>
</div>
<p class="voices-empty-hint">Map a host folder via docker-compose:<br>
<code>- /your/host/path:/voices:rw</code><br>
or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
</div>
<div class="voices-empty-divider">or</div>
<div class="voices-empty-case">
<div class="voices-empty-case-icon"><span class="mdi mdi-microphone-variant"></span></div>
<h4>Folder is empty?</h4>
<p>Create your first voice from a recording or download ready-made voices.</p>
<div class="voices-empty-actions">
<button class="btn-primary voices-empty-action-btn" id="voices-goto-clone"><span class="mdi mdi-lightning-bolt"></span> Clone a Voice</button>
<button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio"><span class="mdi mdi-earth"></span> Get Voices Online</button>
</div>
</div>
</div>
`;
list.appendChild(emptyEl);
fetch('/api/settings').then(r => r.json()).then(s => {
const inp = document.getElementById('voices-empty-scan-dir');
if (inp) inp.value = s.voices_scan_dir || '/voices';
}).catch(() => {});
// ── Directory browser ──────────────────────────────────────────────────
let _vefCurrentPath = '/';
const vefBrowser = document.getElementById('vef-dir-browser');
const vefDirList = document.getElementById('vef-dir-list');
const vefCrumb = document.getElementById('vef-breadcrumb');
const vefSelPath = document.getElementById('vef-selected-path');
async function vefNavigate(path) {
_vefCurrentPath = path;
vefDirList.innerHTML = '<span class="vef-loading">Loading…</span>';
if (vefSelPath) vefSelPath.textContent = path;
try {
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
// Breadcrumb
const parts = data.path.split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
vefCrumb.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}">${escHtml(c.label)}</button><span class="vef-sep">/</span>`
: `<span class="vef-crumb-cur">${escHtml(c.label)}</span>`
).join('');
vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
// Directory list
if (!data.dirs.length) {
vefDirList.innerHTML = '<span class="vef-empty">No subdirectories here.</span>';
} else {
vefDirList.innerHTML = data.dirs.map(d =>
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}"><span class="mdi mdi-folder-outline"></span> ${escHtml(d)}</button>`
).join('');
vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
}
if (vefSelPath) vefSelPath.textContent = data.path;
_vefCurrentPath = data.path;
} catch(e) {
vefDirList.innerHTML = `<span class="vef-error">Error: ${escHtml(e.message)}</span>`;
}
}
document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => {
const open = vefBrowser.hidden;
vefBrowser.hidden = !open;
if (open) {
const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices';
vefNavigate(cur);
}
});
document.getElementById('vef-select-btn')?.addEventListener('click', () => {
const inp = document.getElementById('voices-empty-scan-dir');
if (inp) inp.value = _vefCurrentPath;
if (vefBrowser) vefBrowser.hidden = true;
});
// ──────────────────────────────────────────────────────────────────────
document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => {
const inp = document.getElementById('voices-empty-scan-dir');
const dir = inp?.value?.trim();
if (!dir) return;
const btn = document.getElementById('voices-empty-save-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const s = await fetch('/api/settings').then(r => r.json());
s.voices_scan_dir = dir;
const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
toast('Folder saved — reloading voices…', 'success');
_libraryLoadPromise = null;
await loadVoiceLibrary();
} catch(e) {
toast('Failed: ' + e.message, 'error');
btn.disabled = false; btn.textContent = 'Set & reload';
}
});
document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone'));
document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio'));
} else {
list.innerHTML += '<div style="padding:14px;color:var(--subtext);font-size:14px">No voices match the current filters.</div>';
}
return;
}
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
else 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 = `<span class="mdi mdi-${isCol ? 'chevron-right' : 'chevron-down'} vl-tag-caret"></span>`
+ `<span class="mdi mdi-folder${isCol ? '' : '-open'}-outline"></span>`
+ `<span class="vl-tag-name">${escHtml(tag)}</span>`
+ `<span class="vl-tag-count">${voices.length}</span>`;
const body = document.createElement('div');
body.className = 'vl-tag-body';
if (isCol) body.style.display = 'none';
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) : '<span class="mdi mdi-earth"></span>';
const flagCode = currentFlag ? ccDisplay(currentFlag) : '?';
wrap.dataset.flagEmoji = flagEmoji;
wrap.dataset.flagCc = currentFlag || '';
wrap.dataset.rating = v.rating || 0;
wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false';
const fileType = voiceFileType(v);
const dbfs = fmtDbfs(v);
const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
const benchText = fmtBenchmark(v);
const benchTitle = benchmarkTitle(v);
const benchCls = benchmarkClass(v);
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
const genderMapLetter = {F:'F', M:'M', N:'D', '':'?'};
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
const gender = v.gender || '';
const starsHtml = [1,2,3,4,5].map(i =>
`<span class="star${i<=(v.rating||0)?' on':''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
).join('');
const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null;
const pickerHtml = flagOpts.length > 1
? flagOpts.map(([cc, label]) =>
`<span class="flag-opt${cc===currentFlag?' active':''}" data-cc="${cc}" title="${label}">${cc2flag(cc)}<span class="fo-code">${ccDisplay(cc)}</span></span>`
).join('')
: `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`;
// Compact list item elements (visible in list pane, hidden in inspector)
const isClone = v.has_ref && v.origin !== 'designed';
const initial = v.id[0] ? v.id[0].toUpperCase() : '?';
const flagIconHtml = currentFlag
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
: null;
// Avatar: photo > gender/type icon > silhouette (with color)
const defaultSilClass = gender === 'M' ? 'mdi-face-man' : (gender === 'F' ? 'mdi-face-woman' : 'mdi-account');
const silHtml = `<span class="mdi ${defaultSilClass}" style="color:#fff;font-size:20px;display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:${color}"></span>`;
const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
: iconHtml ? 'vl-avatar vl-avatar-icon' : 'vl-avatar vl-avatar-sil';
const avatarStyle = (picSrc || iconHtml) ? '' : `style="background:${color}"`;
const avatarContent = picSrc
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
: iconHtml || silHtml;
wrap.innerHTML = `
<div class="vl-compact">
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
<div class="vl-info">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit vl-name-input" data-field="name" value="${escHtml(dispName)}" title="Display Name" placeholder="Name">`
: `<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>`}
<div class="vl-meta">
<span class="vl-lang-label" title="${escHtml(currentFlag || langCode)}">${flagEmoji}</span>
${gender ? `<span class="vl-gender-letter ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMapLetter[gender]||'?'}</span>` : ''}
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText.split(' ')[0])}</span>` : ''}
<div class="vr-rating">${starsHtml}</div>
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
</div>
</div>
<div class="vr-play-group">
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
</div>
<div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit" data-field="lang" value="${escHtml(langCode)}" style="width:100%">`
: `${flagEmoji} ${escHtml(langCode)}`}
</div>
<div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">
${window._voiceTableEditMode
? `<select class="vl-inline-edit" data-field="gender" style="width:100%"><option value="N" ${gender==='N'?'selected':''}>N</option><option value="M" ${gender==='M'?'selected':''}>M</option><option value="F" ${gender==='F'?'selected':''}>F</option></select>`
: `${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}`}
</div>
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
<div class="vl-tbl-bench">${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : '-'}</div>
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
<div class="vl-tbl-dur" title="${escHtml(voiceBenchmarkAudioSec(v) != null ? `${fmtBenchmarkAudio(v)} of synthesised audio for the benchmark sentence` : 'Benchmark this voice to see output duration')}">${escHtml(fmtBenchmarkAudio(v))}</div>
<div class="vl-tbl-wpm" title="${escHtml(voiceWpm(v) != null ? `${voiceWpm(v)} words per minute — how fast this voice speaks. 130180 wpm is natural for long listening.` : 'Benchmark this voice to see speaking rate')}">${escHtml(fmtWpm(v))}</div>
<div class="vl-tbl-rating">
${window._voiceTableEditMode
? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">`
: starsHtml}
</div>
<div class="vl-tbl-source" title="Click to edit source">${escHtml(_displaySource(v) || '-')}</div>
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
<div class="vl-tbl-note" title="${escHtml(v.note||'')}">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit" data-field="note" value="${escHtml(v.note||'')}" style="width:100%">`
: escHtml(v.note || '')}
</div>
<div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">
${window._voiceTableEditMode
? `<input type="text" class="vl-inline-edit" data-field="tag" value="${escHtml(v.tag||'')}" style="width:100%">`
: escHtml(v.tag || '')}
</div>
<div class="vl-tbl-active">
${window._voiceTableEditMode
? `<input type="checkbox" class="vl-inline-edit" data-field="enabled" ${v.enabled!==false?'checked':''} style="width:16px;height:16px">`
: `<span class="mdi ${v.enabled !== false ? 'mdi-check-circle' : 'mdi-close-circle'}" style="color:${v.enabled !== false ? 'var(--green)' : 'var(--subtext)'}"></span>`}
</div>
</div>
<div class="vr-main-row">
<div class="vr-photo" title="Click to upload photo">
${picSrc ? `<img src="${picSrc}" alt="" loading="lazy" decoding="async">` : '<div class="ph-icon"><span class="mdi mdi-account-outline"></span></div>'}
<input type="file" class="photo-input" accept="image/*">
</div>
<div class="vr-identity">
<div class="vr-flag">
<span class="flag-emoji">${flagEmoji}</span>
<span class="flag-code">${flagCode}</span>
<div class="flag-picker">${pickerHtml}</div>
</div>
<div class="vr-gender">
<span class="gender-badge ${genderClass[gender]||'g-n'}" title="Click to cycle">
<span class="gender-sym">${genderMap[gender]||'?'}</span>
<span class="gender-txt">${genderLabel[gender]||'—'}</span>
</span>
</div>
<div class="vr-name">
<span class="vr-name-text" title="${escHtml(v.id)}">${escHtml(v.id)}</span>
<span class="rename-confirm">
<input class="vr-name-input" value="${escHtml(v.id)}" spellcheck="false">
<button class="icon-btn rename-ok" title="Save"><span class="mdi mdi-check"></span></button>
<button class="icon-btn rename-cancel" title="Cancel"><span class="mdi mdi-close"></span></button>
</span>
</div>
</div>
<div class="vr-type" title="${escHtml(fileType)}">${escHtml(fileType.toUpperCase())}</div>
<div class="vr-length" title="${escHtml(String(v.duration ?? ''))}">${fmtDuration(v.duration)}</div>
<div class="vr-db" title="${escHtml(dbTitle)}">
<span class="vr-db-value">${escHtml(dbfs)}</span>
<button class="normalize-voice-btn" title="Normalize this voice">±</button>
</div>
<div class="vr-bench ${benchCls}" title="${escHtml(benchTitle)}">
<span class="vr-bench-value">${escHtml(benchText)}</span>
<button class="btn-secondary benchmark-one-btn" title="Benchmark this voice">⏱</button>
</div>
<div class="vr-rating">${starsHtml}</div>
<div class="vr-edit">
<button class="edit-audio-btn" title="Edit audio"><span class="mdi mdi-pencil-outline"></span></button>
</div>
<div class="vr-play-group">
<div class="vr-play vr-play-original">
<button title="Play / pause original recording"><span class="mdi mdi-play"></span></button>
</div>
<div class="vr-play vr-play-synth">
<button title="Generate and play synthesized sample"><span class="mdi mdi-play"></span></button>
</div>
</div>
</div>
<div class="vr-detail-row">
<div class="vr-ref">
<label>Reference</label>
<div class="vr-inline">
<input type="text" value="${escHtml(v.transcript||'')}" placeholder="No reference text" title="${escHtml(v.transcript||'')}">
<button class="ref-transcribe-btn" title="Recognise reference text" style="${v.transcript ? 'display:none' : ''}"><span class="mdi mdi-message-text-outline"></span></button>
</div>
</div>
<div class="vr-note">
<label>Note</label>
<div class="vr-inline">
<input type="text" placeholder="Add note..." value="${escHtml(v.note||'')}">
</div>
</div>
<div class="vr-source">
<label>Source</label>
<div class="vr-inline">
<input type="text" placeholder="e.g. fish-audio, cloned, designed…" value="${escHtml(v.origin||'')}">
</div>
</div>
<div class="vr-detail-active">
<label>Active</label>
<div class="vr-active-tools">
<div class="vr-toggle">
<label class="toggle" title="${v.enabled===false ? 'Disabled - click to enable' : 'Enabled - click to disable'}">
<input type="checkbox" ${v.enabled!==false ? 'checked' : ''}>
<span class="t-slider"></span>
</label>
</div>
</div>
</div>
<div class="vr-detail-delete">
<button class="delete-btn" title="Delete voice permanently">Delete voice</button>
<div class="delete-confirm" role="group" aria-label="Confirm delete voice">
<strong>Delete?</strong>
<span title="${escHtml(v.id)}">${escHtml(v.id)}</span>
<button class="btn-secondary delete-confirm-cancel" type="button">Cancel</button>
<button class="delete-confirm-go" type="button">Delete</button>
</div>
</div>
</div>
<div class="vr-optimizer">
<div class="optimizer-grid">
<div class="opt-group opt-trim-panel">
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference audio &middot; crop</span></div>
<div class="opt-group-subtitle">Crop the saved WAV to 3&ndash;20&nbsp;s of clean speech.</div>
<div class="opt-group-body">
<canvas class="opt-wave"></canvas>
<div class="opt-controls">
<div class="opt-field"><label>Start</label><input class="opt-start" type="number" step="0.01" value="0"></div>
<div class="opt-field"><label>End</label><input class="opt-end" type="number" step="0.01" value="0"></div>
<button class="btn-secondary opt-auto-trim">Auto trim</button>
<button class="btn-secondary opt-play">Play crop</button>
<button class="btn-secondary opt-undo">Undo crop</button>
</div>
<div class="opt-group-footer">
<button class="btn-primary opt-save-crop">Save crop</button>
</div>
</div>
</div>
<div class="opt-group opt-text-panel open">
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference transcript</span></div>
<div class="opt-group-subtitle">The spoken text that matches this voice recording.</div>
<div class="opt-group-body">
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
<div class="opt-controls">
<button class="btn-secondary opt-recognize">Re-recognise text</button>
</div>
<div class="opt-group-footer">
<button class="btn-primary opt-save-text">Save text</button>
</div>
</div>
</div>
<div class="opt-group opt-compare-panel">
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Voice match</span></div>
<div class="opt-group-subtitle">Compare the saved WAV with a fresh TTS synthesis.</div>
<div class="opt-group-body">
<div class="opt-controls">
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
<button class="btn-secondary opt-play-reference">Play WAV file</button>
<button class="btn-primary opt-synth-reference">Synthesize reference text</button>
</div>
<p class="opt-group-note">Compare the saved reference WAV with a fresh synthesis of the same reference text. Restart TTS first after editing a voice, otherwise the backend may still use a cached version.</p>
<div class="opt-compare-grid">
<div class="opt-compare-card">
<strong>WAV file</strong>
<audio class="opt-compare-ref-audio" controls></audio>
</div>
<div class="opt-compare-card">
<strong>Synthesized reference text</strong>
<audio class="opt-compare-synth-audio" controls></audio>
</div>
</div>
</div>
</div>
${!isClone ? `<div class="opt-group opt-style-panel">
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Style variation</span></div>
<div class="opt-group-subtitle">Create a styled variant and save it as a new voice (CustomVoice only).</div>
<div class="opt-group-body">
<div class="opt-controls">
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
</div>
<p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.</p>
<div class="backend-help opt-style-backend-help"></div>
<div class="opt-style-preview-box">
<div class="opt-controls">
<button class="btn-secondary opt-preview-style">Preview style</button>
</div>
<audio class="opt-style-audio" controls></audio>
</div>
<div class="opt-group-footer">
<button class="btn-primary opt-save-style">Save style variation</button>
</div>
</div>
</div>` : ''}
<div class="opt-group opt-maintenance open">
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Loudness</span></div>
<div class="opt-group-subtitle">Normalize the volume of the reference audio file.</div>
<div class="opt-group-body">
<div class="opt-controls">
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
<button class="btn-secondary opt-db-minus">-</button>
<button class="btn-secondary opt-db-plus">+</button>
<button class="btn-secondary opt-db-auto">Auto</button>
<button class="btn-secondary opt-restart-tts" title="Restarts the WAV-loading TTS backends (Voice Clone + Streaming) so they rescan the active_voices folder">Restart WAV engines</button>
<button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
</div>
<div class="opt-group-footer">
<button class="btn-primary opt-save-volume">Save volume</button>
</div>
</div>
</div>
<div class="opt-status optimizer-workflow">Click the pencil to load waveform and tools.</div>
</div>
</div>
`;
const vrLengthEl = wrap.querySelector('.vr-length');
hydrateVoiceDuration(v, vrLengthEl);
// Photo upload
const photoCell = wrap.querySelector('.vr-photo');
const photoInput = wrap.querySelector('.photo-input');
const updatePhotoImg = () => {
const ts = Date.now();
const imgSrc = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`;
// Update main photo cell
const img = document.createElement('img');
img.src = imgSrc;
img.alt = '';
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
// Update compact row avatar
const compactAvatar = wrap.querySelector('.vl-avatar');
if (compactAvatar) {
compactAvatar.className = 'vl-avatar vl-avatar-photo';
compactAvatar.style.background = '';
compactAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="vl-avatar-img">`;
}
// Update inspector avatar if this voice is currently open
const inspectorAvatar = document.querySelector('.inspector-avatar');
if (inspectorAvatar && wrap.classList.contains('vr-selected')) {
inspectorAvatar.classList.add('insp-avatar-photo');
inspectorAvatar.style.background = '';
inspectorAvatar.innerHTML = `<img src="${imgSrc}" alt="" class="insp-avatar-img">`;
}
v.has_picture = true;
};
photoCell.addEventListener('update-photo', updatePhotoImg);
photoCell.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', async () => {
if (!photoInput.files.length) return;
const fd = new FormData();
fd.append('voice_id', v.id);
fd.append('file', photoInput.files[0]);
try {
const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
if (!r.ok) throw new Error((await r.json()).detail);
updatePhotoImg();
toast('Photo uploaded','success');
} catch(e) { toast('Photo upload failed: '+e.message,'error'); }
});
photoCell.addEventListener('dragenter', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
photoCell.addEventListener('dragover', e => { e.preventDefault(); photoCell.classList.add('drag-over'); });
photoCell.addEventListener('dragleave', () => photoCell.classList.remove('drag-over'));
photoCell.addEventListener('drop', async e => {
e.preventDefault();
photoCell.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
photoInput.files = e.dataTransfer.files;
photoInput.dispatchEvent(new Event('change'));
return;
}
let url = e.dataTransfer.getData('text/uri-list');
if (!url) {
const html = e.dataTransfer.getData('text/html');
if (html) {
const match = html.match(/src=["'](.*?)["']/);
if (match) url = match[1];
}
}
if (!url) url = e.dataTransfer.getData('text/plain');
if (url && /^https?:\/\//i.test(url)) {
status('Downloading picture from URL...');
try {
const r = await fetch('/api/voice/picture-url', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({voice_id: v.id, image_url: url})
});
if (!r.ok) throw new Error((await r.json()).detail);
updatePhotoImg();
toast('Photo saved from URL', 'success');
status('Photo saved successfully');
} catch(err) {
toast('Photo URL download failed: ' + err.message, 'error');
status('Photo URL download failed');
}
}
});
// Per-voice loudness normalization
const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
const dbValue = wrap.querySelector('.vr-db-value');
const dbCell = wrap.querySelector('.vr-db');
normalizeBtn.addEventListener('click', async () => {
const target = libraryTargetDb();
if (!confirm(`Normalize "${v.id}" to ${target} dBFS?`)) return;
normalizeBtn.disabled = true;
status('Normalizing ' + v.id + '…');
try {
const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.loudness = d.loudness || v.loudness;
v.duration = d.duration ?? v.duration;
v.file_type = d.file_type || v.file_type;
v.path = d.path || v.path;
v.needs_tts_restart = true;
markVoiceAudioChanged(v);
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
toast('Normalized: ' + v.id, 'success');
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
} catch(e) {
toast('Normalize failed: ' + e.message, 'error');
status('Normalize failed');
} finally {
normalizeBtn.disabled = false;
}
});
// Language picker
const flagEmojiEl = wrap.querySelector('.flag-emoji');
const flagCodeEl = wrap.querySelector('.flag-code');
const flagPicker = wrap.querySelector('.flag-picker');
const flagCell = wrap.querySelector('.vr-flag');
flagCell.addEventListener('click', e => {
e.stopPropagation();
document.querySelectorAll('.flag-picker.open').forEach(fp => { if(fp!==flagPicker) fp.classList.remove('open'); });
flagPicker.classList.toggle('open');
});
flagPicker.querySelectorAll('.flag-opt').forEach(opt => {
opt.addEventListener('click', async e => {
e.stopPropagation();
const cc = opt.dataset.cc;
flagPicker.classList.remove('open');
flagEmojiEl.textContent = cc2flag(cc);
flagCodeEl.textContent = ccDisplay(cc);
flagPicker.querySelectorAll('.flag-opt').forEach(o => o.classList.toggle('active', o.dataset.cc===cc));
v.flag = cc;
await saveMeta(v.id, { flag: cc });
});
});
// Gender cycle F → M → N → F
const gBadge = wrap.querySelector('.gender-badge');
gBadge.addEventListener('click', async () => {
const cycle = ['F','M','N'];
v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3];
gBadge.innerHTML = `<span class="gender-sym">${genderMap[v.gender]||'?'}</span><span class="gender-txt">${genderLabel[v.gender]||'—'}</span>`;
gBadge.className = 'gender-badge ' + genderClass[v.gender];
await saveMeta(v.id, { gender: v.gender });
});
// Rename
const nameText = wrap.querySelector('.vr-name-text');
const renameConf = wrap.querySelector('.rename-confirm');
const nameInput = wrap.querySelector('.vr-name-input');
const renameOk = wrap.querySelector('.rename-ok');
const renameCancel= wrap.querySelector('.rename-cancel');
const startRename = () => {
nameText.style.display='none';
renameConf.classList.add('show'); nameInput.focus(); nameInput.select();
};
nameText.addEventListener('dblclick', startRename);
const cancelRename = () => {
nameText.style.display=''; renameConf.classList.remove('show');
nameInput.value = v.id;
};
renameCancel.addEventListener('click', cancelRename);
const doRename = async () => {
const newId = nameInput.value.trim();
if (!newId || newId===v.id) { cancelRename(); return; }
if (!/^[A-Za-z0-9_\-\.]+$/.test(newId)) { toast('Invalid characters in name','error'); return; }
try {
const r = await fetch('/api/voice/rename', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({old_id:v.id, new_id:newId})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json();
v.id = newId;
if (d.path) v.path = d.path;
if (d.file_type) v.file_type = d.file_type;
nameText.textContent = newId; nameText.title = newId;
nameText.style.display=''; renameConf.classList.remove('show');
wrap.dataset.id = newId; nameInput.value = newId;
toast('Renamed to '+newId,'success');
} catch(e) { toast('Rename failed: '+e.message,'error'); }
};
renameOk.addEventListener('click', doRename);
nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); });
// Select row → open inspector (click on compact area, but not on play/buttons)
wrap.querySelector('.vl-compact').addEventListener('click', function (e) {
if (e.target.closest('button')) return;
selectVoice(wrap);
});
// Inline optimizer in Library
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
const optPanel = wrap.querySelector('.vr-optimizer');
const vrTypeEl = wrap.querySelector('.vr-type');
const optCanvas = wrap.querySelector('.opt-wave');
const optStart = wrap.querySelector('.opt-start');
const optEnd = wrap.querySelector('.opt-end');
const optTranscript = wrap.querySelector('.opt-transcript');
const optTargetDb = wrap.querySelector('.opt-target-db');
const optStyleInstruct = wrap.querySelector('.opt-style-instruct');
const optStyleBackend = wrap.querySelector('.opt-style-backend');
const optStyleVoiceId = wrap.querySelector('.opt-style-voice-id');
const optCompareBackend = wrap.querySelector('.opt-compare-backend');
const optPlayReferenceBtn = wrap.querySelector('.opt-play-reference');
const optSynthReferenceBtn = wrap.querySelector('.opt-synth-reference');
const optCompareRefAudio = wrap.querySelector('.opt-compare-ref-audio');
const optCompareSynthAudio = wrap.querySelector('.opt-compare-synth-audio');
const optPreviewStyleBtn = wrap.querySelector('.opt-preview-style');
const optSaveStyleBtn = wrap.querySelector('.opt-save-style');
const optStyleAudio = wrap.querySelector('.opt-style-audio');
const optStatus = wrap.querySelector('.opt-status');
const optSaveTextBtn = wrap.querySelector('.opt-save-text');
const optRestartTtsBtn = wrap.querySelector('.opt-restart-tts');
const optRebenchmarkBtn = wrap.querySelector('.opt-rebenchmark');
const optRestartNote = wrap.querySelector('.opt-restart-note');
let optState = { loaded:false, id:null, duration:0, buffer:null, audio:null, compareSynthUrl:null };
const setOptStatus = msg => { optStatus.textContent = msg; status(msg); };
const setVoiceRestartState = (required, msg = '') => {
v.needs_tts_restart = required;
optPanel.classList.toggle('opt-restart-needed', required);
optRestartNote.hidden = !required;
optRestartNote.textContent = required ? 'Restart TTS before benchmarking; the backend may still have the old voice cached.' : '';
optRebenchmarkBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice';
benchmarkOneBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice';
if (msg) setOptStatus(msg);
};
const markTtsRestartRequired = msg => setVoiceRestartState(true, msg);
const refreshOptimizerFromVoice = async () => {
markVoiceAudioChanged(v);
optState.loaded = false;
optState.buffer = null;
optState.id = null;
await loadOptimizer();
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
};
const saveOptimizerText = async () => {
const transcript = optTranscript.value.trim();
setOptStatus('Saving reference text...');
v.transcript = transcript;
refInput.value = transcript;
refInput.title = transcript;
refTranscribeBtn.style.display = transcript ? 'none' : '';
await saveMeta(v.id, { transcript });
markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.');
toast('Reference text saved: ' + v.id, 'success');
};
const redrawOpt = () => {
if (!optState.buffer || optCanvas.clientWidth < 4) return;
drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
};
// Redraw whenever the canvas is resized (handles display:none → visible transition)
const _waveRO = new ResizeObserver(() => redrawOpt());
_waveRO.observe(optCanvas);
const syncCompareReferenceAudio = () => {
if (!optState.id || !optCompareRefAudio) return;
const src = '/api/audio/' + optState.id;
if (!optCompareRefAudio.src.endsWith(src)) {
optCompareRefAudio.src = src;
optCompareRefAudio.load();
}
const stopAtEnd = () => {
const end = parseFloat(optEnd.value) || optState.duration;
if (optCompareRefAudio.currentTime >= end) optCompareRefAudio.pause();
};
optCompareRefAudio.ontimeupdate = stopAtEnd;
};
const optWaveTimeFromEvent = e => {
const rect = optCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
return optState.duration ? x / Math.max(1, rect.width) * optState.duration : 0;
};
const setOptCropRange = (start, end) => {
start = Math.max(0, Math.min(optState.duration || 0, Number(start) || 0));
end = Math.max(0, Math.min(optState.duration || 0, Number(end) || 0));
if (end < start) [start, end] = [end, start];
optStart.value = start.toFixed(2);
optEnd.value = end.toFixed(2);
redrawOpt();
};
const optWaveSelectionPixels = e => {
const rect = optCanvas.getBoundingClientRect();
const duration = Math.max(0.01, optState.duration || 0.01);
const sx = (parseFloat(optStart.value) || 0) / duration * rect.width;
const ex = (parseFloat(optEnd.value) || optState.duration || 0) / duration * rect.width;
const x = e.clientX - rect.left;
return {x, sx, ex};
};
const optWaveDragMode = e => {
const {x, sx, ex} = optWaveSelectionPixels(e);
const hit = 18;
const nearStart = Math.abs(x - sx) <= hit;
const nearEnd = Math.abs(x - ex) <= hit;
if (nearStart && nearEnd) return Math.abs(x - sx) <= Math.abs(x - ex) ? 'start' : 'end';
if (nearStart) return 'start';
if (nearEnd) return 'end';
if (x > sx && x < ex) return 'move';
return 'new';
};
const attachOptWaveSelection = () => {
let drag = null;
optCanvas.addEventListener('pointerdown', e => {
if (!optState.buffer || !optState.duration) return;
e.preventDefault();
optCanvas.setPointerCapture?.(e.pointerId);
const mode = optWaveDragMode(e);
const currentStart = parseFloat(optStart.value) || 0;
const currentEnd = parseFloat(optEnd.value) || optState.duration;
drag = {mode, anchor: optWaveTimeFromEvent(e), start: currentStart, end: currentEnd, length: Math.max(0.05, currentEnd - currentStart)};
optCanvas.style.cursor = mode === 'move' ? 'grabbing' : 'ew-resize';
if (mode === 'new') setOptCropRange(drag.anchor, drag.anchor);
});
optCanvas.addEventListener('pointermove', e => {
if (!optState.buffer || !optState.duration) return;
if (!drag) {
const mode = optWaveDragMode(e);
optCanvas.style.cursor = mode === 'move' ? 'grab' : (mode === 'start' || mode === 'end') ? 'ew-resize' : 'crosshair';
return;
}
e.preventDefault();
const t = optWaveTimeFromEvent(e);
if (drag.mode === 'start') setOptCropRange(Math.min(t, drag.end - 0.05), drag.end);
else if (drag.mode === 'end') setOptCropRange(drag.start, Math.max(t, drag.start + 0.05));
else if (drag.mode === 'move') {
let start = t - (drag.anchor - drag.start);
start = Math.max(0, Math.min((optState.duration || 0) - drag.length, start));
setOptCropRange(start, start + drag.length);
} else setOptCropRange(drag.anchor, t);
});
const finish = e => {
if (!drag) return;
optCanvas.releasePointerCapture?.(e.pointerId);
drag = null;
optCanvas.style.cursor = 'crosshair';
};
optCanvas.addEventListener('pointerup', finish);
optCanvas.addEventListener('pointercancel', finish);
optCanvas.addEventListener('pointerleave', () => { if (!drag) optCanvas.style.cursor = 'crosshair'; });
};
attachOptWaveSelection();
const loadOptimizer = async () => {
if (optState.loaded) return;
setOptStatus('Loading voice optimizer…');
const d = await loadLibraryVoiceAudio(v);
optState.id = d.id;
optState.duration = d.duration;
v.duration = d.duration;
optState.buffer = await decodeVoiceAudio(v);
optState.loaded = true;
optStart.value = '0.00';
optEnd.value = d.duration.toFixed(2);
optEnd.max = d.duration.toFixed(2);
optTranscript.value = d.transcript || v.transcript || '';
redrawOpt();
syncCompareReferenceAudio();
setVoiceRestartState(Boolean(v.needs_tts_restart));
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
};
wrap._loadOptimizer = loadOptimizer;
wrap._redrawOpt = redrawOpt;
editAudioBtn.addEventListener('click', async () => {
editAudioBtn.disabled = true;
try {
const opening = !wrap.classList.contains('edit-open');
document.querySelectorAll('.vl-row.edit-open').forEach(r => { if (r !== wrap) r.classList.remove('edit-open'); });
wrap.classList.toggle('edit-open', opening);
if (opening) {
await loadOptimizer();
wrap.scrollIntoView({behavior:'smooth', block:'nearest'});
}
} catch(e) {
toast('Edit load failed: '+e.message,'error');
status('Edit load failed');
} finally {
editAudioBtn.disabled = false;
}
});
[optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
optStyleInstruct?.addEventListener('input', () => {
if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
});
optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
if (optCompareBackend.value === '') {
optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
optCompareBackend.disabled = !availableTtsBackends().length;
}
if (optStyleBackend) updateStyleBackendHelp(wrap);
wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
wrap.querySelector('.opt-play').addEventListener('click', async () => {
try {
await loadOptimizer();
if (optState.audio) optState.audio.pause();
optState.audio = new Audio('/api/audio/' + optState.id);
optState.audio.currentTime = parseFloat(optStart.value) || 0;
const end = parseFloat(optEnd.value) || optState.duration;
optState.audio.ontimeupdate = () => { if (optState.audio.currentTime >= end) optState.audio.pause(); };
optState.audio.play();
} catch(e) { toast('Preview failed: ' + e.message, 'error'); }
});
optPlayReferenceBtn.addEventListener('click', async () => {
try {
await loadOptimizer();
syncCompareReferenceAudio();
optCompareRefAudio.currentTime = parseFloat(optStart.value) || 0;
await optCompareRefAudio.play().catch(()=>{});
setOptStatus('Playing reference WAV selection for comparison.');
} catch(e) { toast('Reference playback failed: ' + e.message, 'error'); }
});
optSynthReferenceBtn.addEventListener('click', async () => {
const text = optTranscript.value.trim();
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return; }
if (v.needs_tts_restart) {
const ok = confirm('This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?');
if (!ok) {
setOptStatus('Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.');
return;
}
try {
const d = await clearTtsRestartFlags();
setVoiceRestartState(false, `Restart flags cleared (${d.cleared_restart_flags || 0}). Synthesizing comparison...`);
toast('Restart flags cleared', 'success');
} catch(e) {
toast('Could not clear restart flags: ' + e.message, 'error');
setOptStatus('Could not clear restart flags');
return;
}
}
optSynthReferenceBtn.disabled = true;
try {
await loadOptimizer();
setOptStatus('Synthesizing reference text for comparison...');
const source = await createTtsAudioSource(v.id, text, optCompareBackend.value, 'settings', '');
if (optState.compareSynthUrl) URL.revokeObjectURL(optState.compareSynthUrl);
optCompareSynthAudio.src = source.url;
optState.compareSynthUrl = source.streaming ? null : source.url;
await optCompareSynthAudio.play().catch(()=>{});
setOptStatus(source.streaming ? 'Streaming synthesized comparison.' : 'Synthesized comparison ready.');
} catch(e) {
toast('Synthesis comparison failed: ' + e.message, 'error');
setOptStatus('Synthesis comparison failed');
} finally {
optSynthReferenceBtn.disabled = false;
}
});
wrap.querySelector('.opt-auto-trim').addEventListener('click', async () => {
try {
await loadOptimizer();
const d = await clientAutoTrimBounds(optState.id);
optStart.value = Number(d.start).toFixed(2);
optEnd.value = Number(d.end).toFixed(2);
redrawOpt();
setOptStatus(d.reason || 'Auto trim ready');
} catch(e) { toast('Auto trim failed: ' + e.message, 'error'); }
});
wrap.querySelector('.opt-recognize').addEventListener('click', async () => {
try {
await loadOptimizer();
const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:optState.id})});
if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); }
const d = await r.json();
optTranscript.value = d.text || '';
setOptStatus('Reference text recognised. Review it, then Save text.');
} catch(e) { toast('Recognition failed: ' + e.message, 'error'); }
});
optSaveTextBtn.addEventListener('click', async () => {
optSaveTextBtn.disabled = true;
try {
await saveOptimizerText();
} catch(e) {
toast('Save text failed: ' + e.message, 'error');
setOptStatus('Save text failed');
} finally {
optSaveTextBtn.disabled = false;
}
});
wrap.querySelector('.opt-save-crop').addEventListener('click', async () => {
try {
await loadOptimizer();
const cropStart = Math.max(0, parseFloat(optStart.value) || 0);
const cropEnd = Math.min(optState.duration, parseFloat(optEnd.value) || optState.duration);
if (cropStart <= 0.01 && cropEnd >= optState.duration - 0.05) {
setOptStatus('No crop range selected. Adjust Start or End first, then Save crop.');
toast('No crop range selected', 'error');
return;
}
if (cropEnd <= cropStart + 0.1) {
setOptStatus('Crop range is too short.');
toast('Crop range is too short', 'error');
return;
}
setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);
const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:optState.id, start:cropStart, end:cropEnd})});
if (!pr.ok) { const e = await pr.json(); throw new Error(e.detail || pr.statusText); }
const p = await pr.json();
const rr = await fetch('/api/voice-replace', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:p.id, voice_id:v.id, path:v.path, transcript:optTranscript.value})});
if (!rr.ok) { const e = await rr.json().catch(()=>({})); throw new Error(e.detail || rr.statusText); }
const saved = await rr.json();
v.transcript = optTranscript.value;
v.duration = saved.duration ?? p.duration;
if (saved.loudness) v.loudness = saved.loudness;
if (saved.path) v.path = saved.path;
if (saved.file_type) v.file_type = saved.file_type;
markVoiceAudioChanged(v);
refInput.value = v.transcript; refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
await refreshOptimizerFromVoice();
toast('Voice crop saved: ' + v.id, 'success');
markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
});
if (optStyleInstruct) {
const styleVariationInput = () => {
const style = optStyleInstruct.value.trim();
const text = optTranscript.value.trim() || benchmarkSampleText();
const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
return {style, text, newId, backend: optStyleBackend.value};
};
optPreviewStyleBtn.addEventListener('click', async () => {
const input = styleVariationInput();
if (!input) return;
optPreviewStyleBtn.disabled = true;
try {
setOptStatus('Synthesizing style preview...');
const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
optStyleAudio.src = URL.createObjectURL(blob);
optStyleAudio.style.display = '';
await optStyleAudio.play().catch(()=>{});
setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
} catch(e) {
toast('Style preview failed: ' + e.message, 'error');
setOptStatus('Style preview failed');
} finally {
optPreviewStyleBtn.disabled = false;
}
});
optSaveStyleBtn.addEventListener('click', async () => {
const input = styleVariationInput();
if (!input) return;
optSaveStyleBtn.disabled = true;
try {
setOptStatus(`Synthesizing style variation ${input.newId}...`);
const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
toast('Style variation saved: ' + d.voice_id, 'success');
setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
await loadVoiceLibrary();
renderIntegrationSnippets();
} catch(e) {
toast('Style variation failed: ' + e.message, 'error');
setOptStatus('Style variation failed');
} finally {
optSaveStyleBtn.disabled = false;
}
});
}
wrap.querySelector('.opt-undo').addEventListener('click', async () => {
if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
try {
setOptStatus('Restoring original…');
const r = await fetch('/api/voice/undo', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.duration = d.duration ?? v.duration;
v.loudness = d.loudness || v.loudness;
v.path = d.path || v.path;
v.file_type = d.file_type || v.file_type;
markVoiceAudioChanged(v);
if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
await refreshOptimizerFromVoice();
toast('Original restored: ' + v.id, 'success');
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
} catch(e) { toast('Undo failed: ' + e.message, 'error'); setOptStatus('Undo failed'); }
});
wrap.querySelector('.opt-save-volume').addEventListener('click', async () => {
try {
setOptStatus('Saving volume…');
const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:Number(optTargetDb.value || -20)})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
v.loudness = d.loudness || v.loudness;
v.duration = d.duration ?? v.duration;
if (d.path) v.path = d.path;
if (d.file_type) v.file_type = d.file_type;
markVoiceAudioChanged(v);
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
await refreshOptimizerFromVoice();
toast('Volume saved: ' + v.id, 'success');
markTtsRestartRequired('Volume saved. Restart TTS before rebenchmarking this voice.');
} catch(e) { toast('Volume save failed: ' + e.message, 'error'); setOptStatus('Volume save failed'); }
});
optRestartTtsBtn.addEventListener('click', async () => {
optRestartTtsBtn.disabled = true;
try {
setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…');
const r = await fetch('/api/tts/restart', { method:'POST' });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_voices.forEach(voice => { voice.needs_tts_restart = false; });
updateLibraryInsights();
const names = (d.restarted || []).join(', ') || 'containers';
const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : '';
setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`);
toast(`TTS restarted: ${names}`, 'success');
} catch(e) {
toast('Restart TTS failed: ' + e.message, 'error');
setOptStatus('Restart TTS failed: ' + e.message);
} finally {
optRestartTtsBtn.disabled = false;
}
});
// Reference text input and recognition
const refInput = wrap.querySelector('.vr-ref input');
const refTranscribeBtn = wrap.querySelector('.ref-transcribe-btn');
refInput.addEventListener('input', debounce(async () => {
v.transcript = refInput.value;
refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
await saveMeta(v.id, { transcript: v.transcript });
if (wrap.classList.contains('edit-open')) markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.');
else v.needs_tts_restart = true;
}, 800));
refTranscribeBtn.addEventListener('click', async () => {
refTranscribeBtn.disabled = true;
try {
const d = await loadLibraryVoiceAudio(v);
const tr = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})});
if (!tr.ok) { const e=await tr.json(); throw new Error(e.detail); }
const text = await tr.json();
v.transcript = text.text || '';
refInput.value = v.transcript;
refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
await saveMeta(v.id, { transcript: v.transcript });
v.needs_tts_restart = true;
toast('Reference text recognised; restart TTS before benchmarking','success');
} catch(e) {
toast('Recognition failed: '+e.message,'error');
} finally { refTranscribeBtn.disabled = false; }
});
// Note (debounced save)
const noteInput = wrap.querySelector('.vr-note input');
noteInput.addEventListener('input', debounce(async () => {
v.note = noteInput.value;
await saveMeta(v.id, { note: v.note });
}, 800));
// Source / origin (debounced save — also updates the table cell live)
const sourceInput = wrap.querySelector('.vr-source input');
sourceInput.addEventListener('input', debounce(async () => {
v.origin = sourceInput.value.trim();
await saveMeta(v.id, { origin: v.origin });
// Reflect in table row immediately
const tblCell = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);
if (tblCell) tblCell.textContent = _displaySource(v) || '-';
}, 800));
// Stars
const starSpans = wrap.querySelectorAll('.star');
starSpans.forEach(s => {
s.addEventListener('click', async () => {
const val = parseInt(s.dataset.val);
const newRating = val===v.rating ? 0 : val;
v.rating = newRating;
starSpans.forEach((ss,i) => ss.classList.toggle('on', i<newRating));
await saveMeta(v.id, { rating: newRating });
});
s.addEventListener('mouseenter', () => {
const val = parseInt(s.dataset.val);
starSpans.forEach((ss,i) => ss.classList.toggle('on', i<val));
});
s.addEventListener('mouseleave', () => {
starSpans.forEach((ss,i) => ss.classList.toggle('on', i<(v.rating||0)));
});
});
// Per-voice benchmark
const benchmarkOneBtn = wrap.querySelector('.benchmark-one-btn');
const benchmarkThisVoice = async triggerBtn => {
if (v.needs_tts_restart) {
const ok = confirm('This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?');
if (!ok) {
setOptStatus('Restart TTS first, then rebenchmark this voice.');
return;
}
}
triggerBtn.disabled = true;
status('Benchmarking ' + v.id + '...');
try {
setBenchmarkProgress(0, 1, `Benchmarking ${v.id}`);
const d = await runVoiceBenchmark(v.id);
mergeBenchmarkResults(d);
const hit = (d.voices || []).find(x => x.voice_id === v.id);
if (hit && hit.benchmark) v.benchmark = hit.benchmark;
const benchCell = wrap.querySelector('.vr-bench');
benchCell.className = 'vr-bench ' + benchmarkClass(v);
benchCell.title = benchmarkTitle(v);
benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v);
// Also update the table row cells
const tblRow = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (tblRow) {
const tblBench = tblRow.querySelector('.vl-tbl-bench');
const tblDur = tblRow.querySelector('.vl-tbl-dur');
const tblWpm = tblRow.querySelector('.vl-tbl-wpm');
if (tblBench) {
const bt = fmtBenchmark(v), bc = benchmarkClass(v), btitle = benchmarkTitle(v);
tblBench.innerHTML = bt !== '-' ? `<span class="vl-bench-chip ${bc}" title="${escHtml(btitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(bt)}</span>` : '-';
}
if (tblDur) {
const sec = voiceBenchmarkAudioSec(v);
tblDur.textContent = fmtBenchmarkAudio(v);
tblDur.title = sec != null ? `${fmtBenchmarkAudio(v)} of synthesised audio for the benchmark sentence` : '';
}
if (tblWpm) {
const wpm = voiceWpm(v);
tblWpm.textContent = fmtWpm(v);
tblWpm.title = wpm != null ? `${wpm} wpm — 130180 wpm is natural for long listening` : '';
}
}
setBenchmarkProgress(1, 1, `Finished ${v.id}`);
toast('Benchmarked ' + v.id, 'success');
setVoiceRestartState(false, 'Benchmark saved for ' + v.id);
} catch(e) {
toast('Benchmark failed: ' + e.message, 'error');
setOptStatus('Benchmark failed');
} finally { triggerBtn.disabled = false; }
};
benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn));
optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn));
// Play original recording or synthesized sample
const originalPlayBtn = wrap.querySelector('.vr-play-original button');
const synthPlayBtn = wrap.querySelector('.vr-play-synth button');
const playIcon = '<span class="mdi mdi-play"></span>', pauseIcon = '<span class="mdi mdi-pause-circle-outline"></span>', generatingIcon = '<span class="mdi mdi-timer-sand"></span>';
function setLibraryPlayButtonState(btn, state) {
btn.classList.toggle('is-generating', state === 'generating');
btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon);
btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording'));
}
async function playLibraryVoice(kind, playBtn) {
const bar = $('lib-audio-bar'), audio = $('lib-audio');
const playKey = v.id + ':' + kind;
playBtn.dataset.playKind = kind;
if (_activePlayVoiceId === playKey && !audio.paused) {
audio.pause();
setLibraryPlayButtonState(playBtn, 'idle');
return;
}
if (_activePlayVoiceId === playKey && audio.paused && audio.src) {
_activePlayButton = playBtn;
try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); }
return;
}
if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle');
_activePlayButton = playBtn;
_activePlayVoiceId = playKey;
if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; }
if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating');
playBtn.disabled = true;
try {
if (kind === 'synth') {
if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error');
const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview';
const text = synthMode === 'transcript'
? (v.transcript?.trim() || benchmarkSampleText())
: benchmarkSampleText();
const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text';
const backend = libraryTtsBackend();
const source = await createTtsAudioSource(v.id, text, backend, 'settings', '');
audio.src = source.url;
if (!source.streaming) _activePlayUrl = source.url;
$('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend);
} else {
audio.src = voiceFileUrl(v);
$('lib-audio-label').textContent = v.id + ' · original recording';
}
bar.style.display = '';
audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; };
audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); };
audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); };
await audio.play();
} catch(e) {
setLibraryPlayButtonState(playBtn, 'idle');
toast('Play failed: '+e.message,'error');
} finally {
playBtn.disabled = false;
}
}
originalPlayBtn.dataset.playKind = 'original';
synthPlayBtn.dataset.playKind = 'synth';
setLibraryPlayButtonState(originalPlayBtn, 'idle');
setLibraryPlayButtonState(synthPlayBtn, 'idle');
originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn));
synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn));
// Enable toggle
const toggleCb = wrap.querySelector('.toggle input');
toggleCb.addEventListener('change', async () => {
const nextEnabled = toggleCb.checked;
const previousEnabled = v.enabled !== false;
toggleCb.disabled = true;
try {
const saved = await saveMeta(v.id, { enabled: nextEnabled });
v.enabled = nextEnabled;
if (saved && saved.path) v.path = saved.path;
wrap.classList.toggle('vr-disabled', !v.enabled);
toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success');
if (!v.enabled && !$('show-disabled-cb').checked) {
wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0';
setTimeout(() => wrap.remove(), 400);
}
} catch(e) {
toggleCb.checked = previousEnabled;
v.enabled = previousEnabled;
wrap.classList.toggle('vr-disabled', !v.enabled);
toast('Move failed: ' + e.message, 'error');
} finally {
toggleCb.disabled = false;
}
});
// Delete voice
const deleteBtn = wrap.querySelector('.delete-btn');
const deleteConfirm = wrap.querySelector('.delete-confirm');
const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel');
const deleteGoBtn = wrap.querySelector('.delete-confirm-go');
const closeDeleteConfirm = () => wrap.classList.remove('delete-pending');
deleteBtn.addEventListener('click', e => {
e.stopPropagation();
document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); });
wrap.classList.add('delete-pending');
deleteGoBtn.focus();
});
deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); });
deleteConfirm.addEventListener('click', e => e.stopPropagation());
deleteGoBtn.addEventListener('click', async e => {
e.stopPropagation();
deleteGoBtn.disabled = true;
deleteCancelBtn.disabled = true;
try {
const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' });
if (!r.ok) { const e = await r.json(); throw new Error(e.detail); }
_voices = _voices.filter(x => x.id !== v.id);
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
toast(`Deleted: ${v.id}`, 'success');
} catch(e) {
toast('Delete failed: ' + e.message, 'error');
deleteGoBtn.disabled = false;
deleteCancelBtn.disabled = false;
closeDeleteConfirm();
}
});
return wrap;
}
// ── Voice groups bar (bulk edit/remove a tagged set, e.g. a rehearser play) ──
function renderVoiceGroupsBar() {
const bar = $('voice-groups-bar');
if (!bar) return;
// Tally groups across all voices
const groups = {};
(_voices || []).forEach(v => {
const g = (v.group || '').trim();
if (g) groups[g] = (groups[g] || 0) + 1;
});
const names = Object.keys(groups).sort();
if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false;
const active = window._voiceGroupFilter || '';
bar.innerHTML =
`<span class="vg-label"><span class="mdi mdi-folder-multiple-outline"></span> Groups</span>` +
names.map(g =>
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
${escHtml(g)} <span class="vg-count">${groups[g]}</span>
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">&times;</button>
</span>`
).join('') +
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
bar.querySelectorAll('.vg-chip').forEach(chip => {
chip.addEventListener('click', e => {
if (e.target.closest('.vg-del')) return;
const g = chip.dataset.group;
window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g;
renderVoiceList();
});
});
bar.querySelectorAll('.vg-del').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
});
bar.querySelector('.vg-clear')?.addEventListener('click', () => {
window._voiceGroupFilter = ''; renderVoiceList();
});
}
async function deleteVoiceGroup(group) {
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
try {
const r = await fetch('/api/voices/delete-group', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
}
async function saveMeta(voiceId, patch) {
const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ voice_id:voiceId, ...patch }) });
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
return r.json();
}
// Close language pickers when clicking elsewhere
document.addEventListener('click', () => {
document.querySelectorAll('.flag-picker.open').forEach(fp => fp.classList.remove('open'));
document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending'));
}, { passive:true });
// ── Bulk-edit ────────────────────────────────────────────────────────────────
const _bulkSelected = new Set(); // voice IDs currently checked
function _bulkUpdate() {
const bar = $('vl-bulk-bar');
const count = $('vl-bulk-count');
const n = _bulkSelected.size;
if (bar) bar.hidden = n === 0;
if (count) count.textContent = `${n} selected`;
// Sync checkboxes in the DOM to match the set
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
cb.checked = _bulkSelected.has(cb.dataset.id);
});
const headerCb = document.getElementById('vl-select-all-header');
if (headerCb) {
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
headerCb.checked = allSelected;
}
}
function _bulkToggle(id, checked) {
if (checked) _bulkSelected.add(id); else _bulkSelected.delete(id);
_bulkUpdate();
}
// Inject checkbox into each voice row after renderVoiceList builds the DOM
const _origRenderVoiceList = renderVoiceList;
renderVoiceList = function() {
_origRenderVoiceList.apply(this, arguments);
_bulkInjectCheckboxes();
};
function _bulkInjectCheckboxes() {
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
if (row.querySelector('.vl-bulk-cb')) return; // already injected
const id = row.dataset.id;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'vl-bulk-cb';
cb.dataset.id = id;
cb.checked = _bulkSelected.has(id);
cb.title = 'Select for bulk edit';
cb.addEventListener('change', e => { e.stopPropagation(); _bulkToggle(id, cb.checked); });
cb.addEventListener('click', e => e.stopPropagation());
// Prepend into vl-compact
const compact = row.querySelector('.vl-compact');
if (compact) compact.prepend(cb);
});
}
// ── Toolbar button wiring ───────────────────────────────────────────────────
$('vl-bulk-select-all')?.addEventListener('click', () => {
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
if (row.dataset.id) _bulkSelected.add(row.dataset.id);
});
_bulkUpdate();
});
// Always-visible Select-all toggle in the table header (the bulk bar only appears once
// something is selected, so this is the entry point). Selects every visible row, or
// clears the selection if they're all already selected.
$('vl-select-all-header')?.addEventListener('change', (e) => {
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
if (e.target.checked) {
rows.forEach(r => _bulkSelected.add(r.dataset.id));
} else {
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
}
_bulkUpdate();
});
$('vl-bulk-deselect')?.addEventListener('click', () => {
_bulkSelected.clear();
_bulkUpdate();
});
$('vl-bulk-hide')?.addEventListener('click', async () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const r = await _bulkSetEnabled(ids, false);
toast(`Hidden ${r} voice${r!==1?'s':''}`, 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});
$('vl-bulk-unhide')?.addEventListener('click', async () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const r = await _bulkSetEnabled(ids, true);
toast(`Unhidden ${r} voice${r!==1?'s':''}`, 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});
$('vl-bulk-tag')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
// Show a small inline prompt inside the toolbar
const bar = $('vl-bulk-bar');
let inp = bar.querySelector('.vl-bulk-tag-inp');
if (!inp) {
inp = document.createElement('input');
inp.type = 'text';
inp.className = 'vl-bulk-tag-inp';
inp.placeholder = 'tag1, tag2…';
inp.autocomplete = 'off';
const applyBtn = document.createElement('button');
applyBtn.className = 'vl-bulk-btn';
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
applyBtn.addEventListener('click', async () => {
const tagVal = inp.value.trim();
let done = 0;
for (const id of ids) {
await saveMeta(id, { tag: tagVal }).catch(() => {});
done++;
}
toast(`Tag set on ${done} voice${done!==1?'s':''}`, 'success');
inp.remove(); applyBtn.remove();
_bulkSelected.clear();
await loadVoiceLibrary();
});
bar.appendChild(inp);
bar.appendChild(applyBtn);
}
inp.focus();
});
$('vl-bulk-source')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const bar = $('vl-bulk-bar');
let inp = bar.querySelector('.vl-bulk-source-inp');
if (!inp) {
inp = document.createElement('input');
inp.type = 'text';
inp.className = 'vl-bulk-source-inp';
inp.placeholder = 'e.g. fish-audio, cloned…';
inp.autocomplete = 'off';
const applyBtn = document.createElement('button');
applyBtn.className = 'vl-bulk-btn';
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
applyBtn.addEventListener('click', async () => {
const val = inp.value.trim();
let done = 0;
for (const id of ids) {
await saveMeta(id, { origin: val }).catch(() => {});
done++;
}
toast(`Source set on ${done} voice${done !== 1 ? 's' : ''}`, 'success');
inp.remove(); applyBtn.remove();
_bulkSelected.clear();
await loadVoiceLibrary();
});
bar.appendChild(inp);
bar.appendChild(applyBtn);
}
inp.focus();
});
$('vl-bulk-rating')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const bar = $('vl-bulk-bar');
let sel = bar.querySelector('.vl-bulk-rating-sel');
if (!sel) {
sel = document.createElement('select');
sel.className = 'vl-bulk-rating-sel';
sel.innerHTML = '<option value="">⭐ Pick rating…</option>' +
[1,2,3,4,5].map(n => `<option value="${n}">${'★'.repeat(n)}</option>`).join('');
sel.addEventListener('change', async () => {
const rating = Number(sel.value);
if (!rating) return;
let done = 0;
for (const id of ids) {
await saveMeta(id, { rating }).catch(() => {});
done++;
}
toast(`Rated ${done} voice${done!==1?'s':''}`, 'success');
sel.remove();
_bulkSelected.clear();
await loadVoiceLibrary();
});
bar.appendChild(sel);
sel.focus();
}
});
$('vl-bulk-delete')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
_showBulkDeleteConfirm([..._bulkSelected]);
});
function _showBulkDeleteConfirm(ids) {
document.querySelector('.vl-bdc-overlay')?.remove();
const plural = ids.length !== 1 ? 's' : '';
const names = ids.slice(0, 12).map(id => `<span class="vl-bdc-id" title="${escHtml(id)}">${escHtml(id)}</span>`).join('');
const more = ids.length > 12 ? `<span class="vl-bdc-more">+${ids.length - 12} more</span>` : '';
const ov = document.createElement('div');
ov.className = 'vl-bdc-overlay';
ov.innerHTML = `
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices">
<div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div>
<p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p>
<div class="vl-bdc-list">${names}${more}</div>
<div class="vl-bdc-actions">
<button type="button" class="vl-bdc-cancel">Cancel</button>
<button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button>
</div>
</div>`;
const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); };
function onKey(e) { if (e.key === 'Escape') close(); }
ov.addEventListener('click', e => { if (e.target === ov) close(); });
ov.querySelector('.vl-bdc-cancel').addEventListener('click', close);
document.addEventListener('keydown', onKey);
ov.querySelector('.vl-bdc-go').addEventListener('click', async () => {
const goBtn = ov.querySelector('.vl-bdc-go');
const cancelBtn = ov.querySelector('.vl-bdc-cancel');
goBtn.disabled = cancelBtn.disabled = true;
let done = 0, errors = 0;
await runPool(ids, async (id) => {
try {
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (r.ok) done++; else errors++;
} catch (_) { errors++; }
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}`; });
close();
toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});
document.body.appendChild(ov);
ov.querySelector('.vl-bdc-cancel').focus();
}
// ── Bulk API helpers ────────────────────────────────────────────────────────
async function _bulkSetEnabled(ids, enabled) {
let done = 0;
for (const id of ids) {
await saveMeta(id, { enabled }).catch(() => {});
done++;
}
return done;
}