Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3040 lines
142 KiB
JavaScript
3040 lines
142 KiB
JavaScript
// ── Save voice ────────────────────────────────────────────────────────────
|
|
|
|
$('save-btn').addEventListener('click', async () => {
|
|
const id = trimmedFileId||designedFileId||currentFileId;
|
|
if (!id) { toast('No audio ready','error'); return; }
|
|
const voiceId=$('voice-id-input').value.trim();
|
|
if (!voiceId) { toast('Enter a Voice ID','error'); return; }
|
|
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; }
|
|
$('save-btn').disabled=true;
|
|
try {
|
|
const payload = {id, voice_id:voiceId, path:editingVoicePath, transcript:$('transcript-area').value};
|
|
const sendSave = endpoint => fetch(endpoint, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
let fallbackSave = false;
|
|
let r = await sendSave(editingVoiceId ? '/api/voice-replace' : '/api/save');
|
|
if (editingVoiceId && (r.status === 404 || r.status === 405)) {
|
|
fallbackSave = true;
|
|
status('Update endpoint unavailable; saving as a regular voice…');
|
|
r = await sendSave('/api/save');
|
|
}
|
|
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
|
|
const d = await r.json(); $('save-result').style.display=''; toast((editingVoiceId ? 'Voice updated: ' : 'Voice saved: ')+d.voice_id,'success');
|
|
if (fallbackSave && editingVoiceId && voiceId !== editingVoiceId) {
|
|
const del = await fetch('/api/voice/' + encodeURIComponent(editingVoiceId), {method:'DELETE'});
|
|
if (!del.ok) status('Saved renamed voice; old library entry may need manual deletion.');
|
|
}
|
|
editingVoiceId = null; editingVoicePath = null;
|
|
} catch(e) { toast('Save failed: '+e.message,'error'); }
|
|
finally { $('save-btn').disabled=false; }
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// LIBRARY — sort + render
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
let _voices = [];
|
|
let _pendingSelectId = null;
|
|
let _sortField = 'id';
|
|
let _sortDir = 1; // 1 = asc, -1 = desc
|
|
let _libraryIssueFilter = '';
|
|
let _activePlayButton = null;
|
|
let _activePlayVoiceId = null;
|
|
let _activePlayUrl = null;
|
|
let _libraryLoadPromise = null;
|
|
const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text';
|
|
const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''};
|
|
const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.';
|
|
|
|
function benchmarkSampleText() {
|
|
const el = $('benchmark-sample-text');
|
|
return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
|
|
}
|
|
|
|
function initBenchmarkSampleControls() {
|
|
const sample = $('benchmark-sample-text');
|
|
if (!sample) return;
|
|
sample.value = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
|
|
sample.addEventListener('input', debounce(() => {
|
|
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value.trim());
|
|
status('Benchmark sample sentence saved');
|
|
}, 500));
|
|
$('benchmark-reset-sample-btn')?.addEventListener('click', () => {
|
|
sample.value = DEFAULT_BENCHMARK_SAMPLE_TEXT;
|
|
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
|
|
status('Benchmark sample sentence reset');
|
|
});
|
|
$('benchmark-use-preview-btn')?.addEventListener('click', () => {
|
|
const text = $('preview-text-area')?.value.trim();
|
|
if (!text) { toast('Preview text is empty', 'error'); return; }
|
|
sample.value = text;
|
|
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text);
|
|
status('Benchmark sample sentence copied from TTS preview');
|
|
});
|
|
}
|
|
|
|
function getSortValue(v, field) {
|
|
switch(field) {
|
|
case 'has_picture': return v.has_picture ? 1 : 0;
|
|
case 'flag': return (v.flag || '').toLowerCase();
|
|
case 'gender': return {'F':0,'M':1,'N':2}[v.gender] ?? 3;
|
|
case 'id': return v.id.toLowerCase();
|
|
case 'file_type': return voiceFileType(v);
|
|
case 'duration': return v.duration || 0;
|
|
case 'dbfs': return voiceDbfs(v) ?? -999;
|
|
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
|
|
case 'transcript': return (v.transcript || '').toLowerCase();
|
|
case 'note': return (v.note || '').toLowerCase();
|
|
case 'rating': return v.rating || 0;
|
|
case 'enabled': return v.enabled === false ? 0 : 1;
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
function setSort(field) {
|
|
_sortDir = (_sortField === field) ? _sortDir * -1 : 1;
|
|
_sortField = field;
|
|
syncSortHeaders();
|
|
renderVoiceList();
|
|
}
|
|
|
|
function toggleSortDir() {
|
|
_sortDir *= -1;
|
|
syncSortHeaders();
|
|
renderVoiceList();
|
|
}
|
|
|
|
function syncSortHeaders() {
|
|
document.querySelectorAll('.vl-header [data-sort]').forEach(el => {
|
|
el.classList.remove('sort-asc', 'sort-desc');
|
|
if (el.dataset.sort === _sortField)
|
|
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
|
|
});
|
|
const sel = document.getElementById('voice-sort-field');
|
|
if (sel && sel.value !== _sortField) sel.value = _sortField;
|
|
const dirBtn = document.getElementById('voice-sort-dir');
|
|
if (dirBtn) {
|
|
const icon = dirBtn.querySelector('.mdi');
|
|
if (icon) icon.className = _sortDir === 1 ? 'mdi mdi-arrow-up' : 'mdi mdi-arrow-down';
|
|
dirBtn.title = _sortDir === 1 ? 'Ascending — click to reverse' : 'Descending — click to reverse';
|
|
}
|
|
}
|
|
|
|
// Wire sort direction button via addEventListener (more reliable than inline onclick
|
|
// since the button is injected into the DOM after script execution).
|
|
document.addEventListener('click', e => {
|
|
if (e.target.closest('#voice-sort-dir')) toggleSortDir();
|
|
if (e.target.closest('#voice-group-tag-btn')) {
|
|
window._voiceGroupByTag = !window._voiceGroupByTag;
|
|
try { localStorage.setItem('vl-group-by-tag', window._voiceGroupByTag ? '1' : '0'); } catch (_) {}
|
|
const btn = document.getElementById('voice-group-tag-btn');
|
|
btn?.classList.toggle('active', window._voiceGroupByTag);
|
|
const icon = btn?.querySelector('.mdi');
|
|
if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
|
renderVoiceList();
|
|
}
|
|
});
|
|
// Restore the grouped-by-tag preference on load
|
|
try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {}
|
|
document.addEventListener('change', e => {
|
|
if (e.target.id === 'voice-sort-field') setSort(e.target.value);
|
|
});
|
|
|
|
const FLAG_LANGUAGE_CANDIDATES = {
|
|
GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
|
|
CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'],
|
|
DE:['DE'], AT:['DE'], CH:['DE','FR','IT'],
|
|
FR:['FR'], BE:['FR','NL'], LU:['FR','DE'],
|
|
ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'],
|
|
PT:['PT'], BR:['PT'],
|
|
IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'],
|
|
CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'],
|
|
EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'],
|
|
CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'],
|
|
SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'],
|
|
};
|
|
const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]]));
|
|
|
|
const LANGUAGE_LABELS = {
|
|
EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish',
|
|
DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian',
|
|
HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian',
|
|
RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali',
|
|
AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish',
|
|
};
|
|
const SEX_FILTER_LABELS = {
|
|
F:'♀ Female',
|
|
M:'♂ Male',
|
|
N:'⚥ Diverse / neutral',
|
|
};
|
|
|
|
function voiceLangFromName(v) {
|
|
return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase();
|
|
}
|
|
|
|
function libraryVoiceLang(v) {
|
|
const fromName = voiceLangFromName(v);
|
|
const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()];
|
|
if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0];
|
|
return fromName;
|
|
}
|
|
|
|
function libraryLanguageLabel(code) {
|
|
return LANGUAGE_LABELS[code] || code;
|
|
}
|
|
|
|
function populateLibraryFilters() {
|
|
const langSel = $('library-filter-lang');
|
|
const sexSel = $('library-filter-sex');
|
|
const typeSel = $('library-filter-type');
|
|
if (!langSel || !sexSel || !typeSel) return;
|
|
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
|
|
const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
|
|
const sexOrder = ['F','M','N'];
|
|
const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))]
|
|
.sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b)));
|
|
const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort();
|
|
langSel.innerHTML = '<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) {
|
|
return v.benchmark && typeof v.benchmark === 'object' ? v.benchmark : null;
|
|
}
|
|
|
|
function voiceBenchmarkElapsed(v) {
|
|
const b = voiceBenchmark(v);
|
|
const value = b && b.elapsed_sec;
|
|
return value == null || Number.isNaN(Number(value)) ? null : Number(value);
|
|
}
|
|
|
|
function fmtBenchmark(v) {
|
|
const b = voiceBenchmark(v);
|
|
if (!b) return '-';
|
|
if (!b.ok) return 'ERR';
|
|
const elapsed = voiceBenchmarkElapsed(v);
|
|
if (elapsed == null) return '-';
|
|
const speed = b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : '';
|
|
return elapsed.toFixed(1) + 's' + speed;
|
|
}
|
|
|
|
function benchmarkClass(v) {
|
|
const b = voiceBenchmark(v);
|
|
if (!b) return '';
|
|
if (!b.ok || b.clipped || b.realtime_ok === false) return 'bench-bad';
|
|
const elapsed = voiceBenchmarkElapsed(v);
|
|
return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn';
|
|
}
|
|
|
|
function voiceFileUrl(v) {
|
|
const version = v._audioVersion || v.updated_at || v.benchmarked_at || '';
|
|
const bust = version || Date.now();
|
|
return `/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`;
|
|
}
|
|
|
|
function markVoiceAudioChanged(v) {
|
|
v._audioVersion = Date.now();
|
|
}
|
|
|
|
function benchmarkTitle(v) {
|
|
const b = voiceBenchmark(v);
|
|
if (!b) return 'Not benchmarked yet';
|
|
const parts = [];
|
|
if (b.ok) {
|
|
parts.push(`total ${Number(b.elapsed_sec || 0).toFixed(2)}s`);
|
|
if (b.ttfa_ms != null) parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`);
|
|
if (b.audio_sec != null) parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`);
|
|
if (b.rtf != null) parts.push(`RTF ${Number(b.rtf).toFixed(2)}`);
|
|
if (b.speed != null) parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`);
|
|
if (b.clipped) parts.push('output clipped');
|
|
} else {
|
|
parts.push('benchmark failed');
|
|
if (b.error) parts.push(b.error);
|
|
}
|
|
if (Array.isArray(b.advice) && b.advice.length) parts.push(b.advice.join(' | '));
|
|
if (b.benchmarked_at) parts.push(`saved ${b.benchmarked_at}`);
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
async function clientVoiceLoudness(v) {
|
|
if (!v.path) throw new Error('No audio path');
|
|
const resp = await fetch(voiceFileUrl(v), {cache:'no-store'});
|
|
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
|
|
const audioData = await resp.arrayBuffer();
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const buffer = await ctx.decodeAudioData(audioData.slice(0));
|
|
let sum = 0, peak = 0, count = 0;
|
|
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
|
|
const data = buffer.getChannelData(ch);
|
|
count += data.length;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const sample = data[i];
|
|
sum += sample * sample;
|
|
peak = Math.max(peak, Math.abs(sample));
|
|
}
|
|
}
|
|
const rms = Math.sqrt(sum / Math.max(1, count));
|
|
const dbfs = rms > 0 ? 20 * Math.log10(rms) : null;
|
|
const peakDbfs = peak > 0 ? 20 * Math.log10(peak) : null;
|
|
return {
|
|
dbfs: dbfs == null ? null : Number(dbfs.toFixed(2)),
|
|
peak_dbfs: peakDbfs == null ? null : Number(peakDbfs.toFixed(2)),
|
|
};
|
|
}
|
|
|
|
async function clientCalculateVoiceDb() {
|
|
const voices = visibleLibraryVoices();
|
|
const errors = [];
|
|
let calculated = 0;
|
|
const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'};
|
|
setBenchmarkProgress(0, voices.length, 'Preparing dB scan...', stats);
|
|
for (const v of voices) {
|
|
setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats);
|
|
try {
|
|
v.loudness = await clientVoiceLoudness(v);
|
|
await saveMeta(v.id, { loudness: v.loudness }).catch(()=>{});
|
|
calculated++;
|
|
stats.ok++;
|
|
stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
|
|
status(`Calculated dB: ${calculated} / ${voices.length}`);
|
|
} catch(e) {
|
|
errors.push({voice_id:v.id, detail:e.message});
|
|
stats.errors++;
|
|
stats.last = `${v.id}: ${e.message}`;
|
|
}
|
|
setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
setBenchmarkProgress(voices.length, voices.length, 'dB scan complete', stats);
|
|
return { calculated, errors, voices: voices.map(v => ({voice_id:v.id, loudness:v.loudness})) };
|
|
}
|
|
|
|
async function hydrateVoiceDuration(v, el) {
|
|
if (!v.path || !needsDuration(v) || v._durationLoading) return;
|
|
v._durationLoading = true;
|
|
try {
|
|
const audio = new Audio();
|
|
audio.preload = 'metadata';
|
|
audio.src = voiceFileUrl(v);
|
|
await new Promise((resolve, reject) => {
|
|
audio.onloadedmetadata = resolve;
|
|
audio.onerror = () => reject(new Error('Could not read duration'));
|
|
});
|
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
|
v.duration = audio.duration;
|
|
if (el && document.body.contains(el)) {
|
|
el.textContent = fmtDuration(v.duration);
|
|
el.title = String(v.duration.toFixed(2));
|
|
}
|
|
}
|
|
audio.removeAttribute('src');
|
|
audio.load();
|
|
} catch(e) {
|
|
if (el && document.body.contains(el)) el.title = e.message;
|
|
} finally {
|
|
v._durationLoading = false;
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('.vl-header [data-sort]').forEach(el =>
|
|
el.addEventListener('click', () => setSort(el.dataset.sort))
|
|
);
|
|
|
|
function dominantLanguages(limit = 3) {
|
|
const counts = new Map();
|
|
(_voices || []).forEach(v => {
|
|
const lang = (v.lang || String(v.id || '').split('_')[0] || '?').toUpperCase();
|
|
counts.set(lang, (counts.get(lang) || 0) + 1);
|
|
});
|
|
return [...counts.entries()]
|
|
.sort((a,b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.slice(0, limit)
|
|
.map(([lang, count]) => `${lang} ${count}`)
|
|
.join(' · ') || '-';
|
|
}
|
|
|
|
function updateLibraryInsights(state = 'ready') {
|
|
const el = $('library-insights');
|
|
if (!el) return;
|
|
if (state === 'loading') {
|
|
el.innerHTML = [
|
|
['…', 'Loading'], ['…', 'Active'], ['…', 'Languages'], ['…', 'Benchmarks'], ['…', 'Quality'], ['…', 'Actions']
|
|
].map(([value, label]) => `<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();
|
|
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;
|
|
status('Calculating voice loudness…');
|
|
try {
|
|
const d = await clientCalculateVoiceDb();
|
|
renderVoiceList();
|
|
const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : '';
|
|
toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success');
|
|
status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`);
|
|
} catch(e) {
|
|
toast('Calculate dB failed: ' + e.message, 'error');
|
|
status('dB calculation failed');
|
|
} finally { $('calculate-db-btn').disabled = false; }
|
|
});
|
|
|
|
$('normalize-volume-btn').addEventListener('click', async () => {
|
|
const target = libraryTargetDb();
|
|
const visible = visibleLibraryVoices();
|
|
const voices = visible.filter(v => voiceFileType(v) === 'wav');
|
|
const skipped = visible.length - voices.length;
|
|
if (!voices.length) {
|
|
toast('No visible WAV voices to normalize', 'error');
|
|
return;
|
|
}
|
|
if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return;
|
|
|
|
$('normalize-volume-btn').disabled = true;
|
|
$('calculate-db-btn').disabled = true;
|
|
const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'};
|
|
const errors = [];
|
|
let normalized = 0;
|
|
setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats);
|
|
status(`Normalizing ${voices.length} voices to ${target} dBFS...`);
|
|
try {
|
|
for (const v of voices) {
|
|
setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
|
|
try {
|
|
const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
|
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
v.loudness = d.loudness || v.loudness;
|
|
v.duration = d.duration ?? v.duration;
|
|
v.file_type = d.file_type || v.file_type;
|
|
v.path = d.path || v.path;
|
|
v.needs_tts_restart = true;
|
|
markVoiceAudioChanged(v);
|
|
normalized++;
|
|
stats.ok++;
|
|
stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
|
|
} catch(e) {
|
|
errors.push({voice_id:v.id, detail:e.message});
|
|
stats.errors++;
|
|
stats.last = `${v.id}: ${e.message}`;
|
|
}
|
|
setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
|
|
status(`Normalized ${normalized} / ${voices.length}`);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats);
|
|
renderVoiceList();
|
|
updateLibraryInsights();
|
|
const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`;
|
|
toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success');
|
|
status('Volume normalized. Restart TTS before rebenchmarking these voices.');
|
|
} catch(e) {
|
|
toast('Normalize volume failed: ' + e.message, 'error');
|
|
status('Normalize volume failed');
|
|
} finally {
|
|
$('normalize-volume-btn').disabled = false;
|
|
$('calculate-db-btn').disabled = false;
|
|
}
|
|
});
|
|
|
|
function fmtClock(ms) {
|
|
if (!Number.isFinite(ms) || ms < 0) return '-';
|
|
const total = Math.round(ms / 1000);
|
|
const m = Math.floor(total / 60), s = total % 60;
|
|
return `${m}:${String(s).padStart(2,'0')}`;
|
|
}
|
|
|
|
function setBenchmarkProgress(done, total, label = '', stats = {}) {
|
|
const panel = $('benchmark-progress');
|
|
const track = panel.querySelector('.benchmark-progress-track');
|
|
const pct = total ? Math.round(done / total * 100) : 0;
|
|
panel.hidden = false;
|
|
$('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...');
|
|
$('benchmark-progress-count').textContent = `${done} / ${total}`;
|
|
$('benchmark-progress-bar').style.width = pct + '%';
|
|
track.setAttribute('aria-valuenow', String(pct));
|
|
const live = $('benchmark-live-stats');
|
|
if (live) {
|
|
const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0;
|
|
const avg = done > 0 ? elapsed / done : 0;
|
|
const eta = done > 0 && total > done ? avg * (total - done) : 0;
|
|
live.innerHTML = [
|
|
`Elapsed ${fmtClock(elapsed)}`,
|
|
`Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`,
|
|
`ETA ${done && total > done ? fmtClock(eta) : '-'}`,
|
|
`OK ${stats.ok || 0}`,
|
|
`${stats.middleLabel || 'Slow'} ${stats.slow || 0}`,
|
|
`${stats.errorLabel || 'Errors'} ${stats.errors || 0}`,
|
|
].map(x => `<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: ''};
|
|
setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
|
|
for (let i = 0; i < voices.length; i++) {
|
|
const voice = voices[i];
|
|
setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats);
|
|
status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`);
|
|
try {
|
|
const d = await runVoiceBenchmark(voice.id, {text});
|
|
if (d) {
|
|
aggregate.benchmarked += Number(d.benchmarked || 0);
|
|
aggregate.errors.push(...(d.errors || []));
|
|
aggregate.voices.push(...(d.voices || []));
|
|
mergeBenchmarkResults(d);
|
|
const hit = (d.voices || []).find(x => x.voice_id === voice.id);
|
|
const b = hit && hit.benchmark;
|
|
if (b && b.ok) {
|
|
stats.ok++;
|
|
if (b.realtime_ok === false) stats.slow++;
|
|
stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`;
|
|
} else {
|
|
stats.errors++;
|
|
stats.last = `${voice.id}: failed${b && b.error ? ' · ' + b.error : ''}`;
|
|
}
|
|
}
|
|
} catch(e) {
|
|
aggregate.errors.push({voice_id: voice.id, detail: e.message});
|
|
stats.errors++;
|
|
stats.last = `${voice.id}: failed · ${e.message}`;
|
|
}
|
|
setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats);
|
|
}
|
|
setBenchmarkProgress(total, total, 'Benchmark complete', stats);
|
|
return aggregate;
|
|
}
|
|
|
|
function mergeBenchmarkResults(d) {
|
|
const byId = new Map((d.voices || []).map(x => [x.voice_id, x]));
|
|
_voices.forEach(v => {
|
|
const hit = byId.get(v.id);
|
|
if (hit && hit.benchmark) v.benchmark = hit.benchmark;
|
|
});
|
|
}
|
|
|
|
function activeBenchmarkVoices() {
|
|
return (_voices || []).filter(v => v.enabled !== false);
|
|
}
|
|
|
|
// Benchmark target: the checked/selected voices when any are selected, else all active.
|
|
function benchmarkTargetVoices() {
|
|
if (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0) {
|
|
return (_voices || []).filter(v => _bulkSelected.has(v.id));
|
|
}
|
|
return activeBenchmarkVoices();
|
|
}
|
|
|
|
function showBenchmarkConfirm() {
|
|
const voices = benchmarkTargetVoices();
|
|
const onlySelected = (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0);
|
|
const text = benchmarkSampleText();
|
|
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; }
|
|
if (!voices.length) { toast('No voices to benchmark', 'error'); return; }
|
|
const staleCount = voices.filter(v => v.needs_tts_restart).length;
|
|
$('benchmark-confirm-title').textContent = `Benchmark ${voices.length} ${onlySelected ? 'selected' : 'active'} voice${voices.length === 1 ? '' : 's'}?`;
|
|
$('benchmark-confirm-text').textContent = 'This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice.' +
|
|
(staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : '');
|
|
$('benchmark-confirm').hidden = false;
|
|
$('benchmark-confirm-start').focus();
|
|
}
|
|
|
|
function hideBenchmarkConfirm() {
|
|
const panel = $('benchmark-confirm');
|
|
if (panel) panel.hidden = true;
|
|
}
|
|
|
|
$('benchmark-voices-btn').addEventListener('click', showBenchmarkConfirm);
|
|
$('benchmark-confirm-cancel')?.addEventListener('click', hideBenchmarkConfirm);
|
|
$('benchmark-confirm-start')?.addEventListener('click', async () => {
|
|
hideBenchmarkConfirm();
|
|
$('benchmark-voices-btn').disabled = true;
|
|
$('benchmark-confirm-start').disabled = true;
|
|
status('Benchmarking active voices...');
|
|
try {
|
|
const d = await runVoiceBenchmarkBatch();
|
|
if (!d) return;
|
|
mergeBenchmarkResults(d);
|
|
await loadVoiceLibrary();
|
|
const slow = (d.voices || []).filter(x => x.benchmark && x.benchmark.realtime_ok === false).length;
|
|
const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : '';
|
|
toast(`Benchmarked ${d.benchmarked} voices${slow ? `, ${slow} slow` : ''}${extra}`, d.errors && d.errors.length ? 'error' : 'success');
|
|
status('Benchmark saved with TTFA, total time, RTF, and speed.');
|
|
} catch(e) {
|
|
toast('Benchmark failed: ' + e.message, 'error');
|
|
status('Benchmark failed');
|
|
} finally {
|
|
$('benchmark-voices-btn').disabled = false;
|
|
$('benchmark-confirm-start').disabled = false;
|
|
}
|
|
});
|
|
|
|
$('copy-active-voices-btn').addEventListener('click', async () => {
|
|
const active = activeVoiceIds();
|
|
if (!active.length) { toast('No active voices to copy', 'error'); return; }
|
|
await copyText(active.join(', '));
|
|
toast('Copied ' + active.length + ' active voices', 'success');
|
|
status('Copied active voices to clipboard');
|
|
});
|
|
|
|
const LIB_ADD_SAMPLE_TEXTS = {
|
|
EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.',
|
|
DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.',
|
|
IT: 'La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.',
|
|
ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.',
|
|
FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.',
|
|
PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.',
|
|
NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.',
|
|
PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.'
|
|
};
|
|
const LIB_ADD_SAMPLE_STORAGE_KEY = 'vcf-lib-add-sample-texts';
|
|
|
|
function libAddSampleOverrides() {
|
|
try { return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY) || '{}') || {}; }
|
|
catch(e) { return {}; }
|
|
}
|
|
|
|
function getLibAddSampleText(code) {
|
|
return libAddSampleOverrides()[code] || LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN;
|
|
}
|
|
|
|
function saveLibAddSampleText() {
|
|
const code = $('lib-add-sample-lang').value;
|
|
const text = $('lib-add-sample-text').value.trim();
|
|
const overrides = libAddSampleOverrides();
|
|
if (text && text !== LIB_ADD_SAMPLE_TEXTS[code]) overrides[code] = text;
|
|
else delete overrides[code];
|
|
localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides));
|
|
setLibAddStatus('Sample sentence saved');
|
|
}
|
|
|
|
function resetLibAddSampleText() {
|
|
const code = $('lib-add-sample-lang').value;
|
|
const overrides = libAddSampleOverrides();
|
|
delete overrides[code];
|
|
localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides));
|
|
$('lib-add-sample-text').value = LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN;
|
|
setLibAddStatus('Sample sentence reset');
|
|
}
|
|
|
|
function updateLibAddSampleLanguage(lang) {
|
|
const code = LIB_ADD_SAMPLE_TEXTS[lang] ? lang : 'EN';
|
|
$('lib-add-sample-lang').value = code;
|
|
$('lib-add-lang').value = code;
|
|
$('lib-add-sample-text').value = getLibAddSampleText(code);
|
|
const voiceId = $('lib-add-voice-id').value.trim();
|
|
if (voiceId && /^[A-Z]{2}_/.test(voiceId)) {
|
|
$('lib-add-voice-id').value = voiceId.replace(/^[A-Z]{2}_/, code + '_');
|
|
}
|
|
}
|
|
|
|
function renderLibAddMeter(level = 0, db = -Infinity, clipped = false) {
|
|
const meter = $('lib-add-mic-meter');
|
|
if (!meter.children.length) {
|
|
for (let i = 0; i < 18; i++) {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'bar';
|
|
meter.appendChild(bar);
|
|
}
|
|
}
|
|
const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length);
|
|
[...meter.children].forEach((bar, i) => {
|
|
bar.className = 'bar';
|
|
bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px';
|
|
if (i < active) {
|
|
bar.classList.add('on');
|
|
if (db > -12 && i > 11) bar.classList.add('hot');
|
|
if (clipped && i > 14) bar.classList.add('clip');
|
|
}
|
|
});
|
|
$('lib-add-db-readout').textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
|
|
}
|
|
|
|
function syncLibAddMicGain() {
|
|
const gain = parseFloat($('lib-add-mic-gain').value) || 0;
|
|
$('lib-add-mic-gain-value').textContent = gain.toFixed(2) + 'x';
|
|
if (libAddState.gainNode) libAddState.gainNode.gain.value = gain;
|
|
}
|
|
|
|
function startLibAddMeter() {
|
|
if (!libAddState.analyser) return;
|
|
if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf);
|
|
const data = new Float32Array(libAddState.analyser.fftSize);
|
|
const tick = () => {
|
|
libAddState.analyser.getFloatTimeDomainData(data);
|
|
let sum = 0, peak = 0;
|
|
for (const sample of data) {
|
|
sum += sample * sample;
|
|
peak = Math.max(peak, Math.abs(sample));
|
|
}
|
|
const rms = Math.sqrt(sum / data.length);
|
|
const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
|
|
const level = Number.isFinite(db) ? (db + 60) / 60 : 0;
|
|
renderLibAddMeter(level, db, peak > 0.98);
|
|
libAddState.meterRaf = requestAnimationFrame(tick);
|
|
};
|
|
tick();
|
|
}
|
|
|
|
async function ensureLibAddMicMonitor() {
|
|
if (libAddState.recordStream) return;
|
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
libAddState.stream = await requestMicrophoneStream({raw:true});
|
|
if (AudioCtx) {
|
|
libAddState.audioCtx = new AudioCtx();
|
|
libAddState.sourceNode = libAddState.audioCtx.createMediaStreamSource(libAddState.stream);
|
|
libAddState.gainNode = libAddState.audioCtx.createGain();
|
|
libAddState.analyser = libAddState.audioCtx.createAnalyser();
|
|
libAddState.analyser.fftSize = 1024;
|
|
const dest = libAddState.audioCtx.createMediaStreamDestination();
|
|
syncLibAddMicGain();
|
|
libAddState.sourceNode.connect(libAddState.gainNode);
|
|
libAddState.gainNode.connect(libAddState.analyser);
|
|
libAddState.gainNode.connect(dest);
|
|
libAddState.recordStream = dest.stream;
|
|
startLibAddMeter();
|
|
} else {
|
|
libAddState.recordStream = libAddState.stream;
|
|
}
|
|
libAddState.monitoring = true;
|
|
$('lib-add-monitor-btn').disabled = true;
|
|
$('lib-add-monitor-stop').disabled = false;
|
|
}
|
|
|
|
function stopLibAddMic() {
|
|
if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf);
|
|
libAddState.meterRaf = null;
|
|
[libAddState.sourceNode, libAddState.gainNode, libAddState.analyser].forEach(node => {
|
|
try { if (node) node.disconnect(); } catch(e) {}
|
|
});
|
|
if (libAddState.stream) libAddState.stream.getTracks().forEach(t => t.stop());
|
|
if (libAddState.recordStream) libAddState.recordStream.getTracks().forEach(t => t.stop());
|
|
if (libAddState.audioCtx) libAddState.audioCtx.close().catch(()=>{});
|
|
libAddState.stream = null;
|
|
libAddState.recordStream = null;
|
|
libAddState.sourceNode = null;
|
|
libAddState.gainNode = null;
|
|
libAddState.analyser = null;
|
|
libAddState.audioCtx = null;
|
|
libAddState.monitoring = false;
|
|
$('lib-add-monitor-btn').disabled = false;
|
|
$('lib-add-monitor-stop').disabled = true;
|
|
renderLibAddMeter(0, -Infinity, false);
|
|
}
|
|
|
|
let libAddState = {
|
|
id:null, duration:0, audio:null, buffer:null, recorder:null, chunks:[], pendingSource:null,
|
|
stream:null, recordStream:null, timer:null, secs:0, audioCtx:null,
|
|
sourceNode:null, gainNode:null, analyser:null, meterRaf:null, monitoring:false
|
|
};
|
|
window.libAddState = libAddState;
|
|
$('add-new-voice-btn').addEventListener('click', () => {
|
|
$('lib-add-panel').classList.toggle('open');
|
|
});
|
|
$('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value));
|
|
$('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value));
|
|
$('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500));
|
|
$('lib-add-use-sample').addEventListener('click', () => {
|
|
$('lib-add-transcript').value = $('lib-add-sample-text').value.trim();
|
|
setLibAddStatus('Sample sentence copied to transcript');
|
|
});
|
|
$('lib-add-reset-sample').addEventListener('click', resetLibAddSampleText);
|
|
$('lib-add-mic-help-btn').addEventListener('click', () => {
|
|
$('lib-add-mic-help').classList.toggle('open');
|
|
});
|
|
$('lib-add-monitor-btn').addEventListener('click', async () => {
|
|
try {
|
|
await ensureLibAddMicMonitor();
|
|
setLibAddStatus('Mic level monitor active');
|
|
} catch(e) {
|
|
stopLibAddMic();
|
|
$('lib-add-mic-help').classList.add('open');
|
|
const message = await microphoneErrorMessage(e);
|
|
toast(message, 'error');
|
|
setLibAddStatus(message);
|
|
}
|
|
});
|
|
$('lib-add-monitor-stop').addEventListener('click', () => {
|
|
stopLibAddMic();
|
|
setLibAddStatus('Mic level monitor stopped');
|
|
});
|
|
$('lib-add-mic-gain').addEventListener('input', syncLibAddMicGain);
|
|
renderLibAddMeter();
|
|
syncLibAddMicGain();
|
|
updateLibAddSampleLanguage('EN');
|
|
|
|
function setLibAddStatus(msg) {
|
|
$('lib-add-status').textContent = msg;
|
|
status(msg);
|
|
}
|
|
function suggestLibVoiceId(filename) {
|
|
if ($('lib-add-voice-id').value.trim()) return;
|
|
const base = String(filename || 'NewVoice')
|
|
.replace(/\.[^.]+$/, '')
|
|
.replace(/[^A-Za-z0-9_-]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 60) || 'NewVoice';
|
|
$('lib-add-voice-id').value = `${$('lib-add-lang').value || 'EN'}_${$('lib-add-gender').value || 'N'}_${base}`;
|
|
}
|
|
function loadLibAddAudio(id, duration, label = 'Audio') {
|
|
libAddState.id = id;
|
|
libAddState.duration = Number(duration) || 0;
|
|
libAddState.buffer = null;
|
|
$('lib-add-start').value = '0.00';
|
|
$('lib-add-end').value = libAddState.duration ? Math.min(libAddState.duration, 20).toFixed(2) : '0.00';
|
|
$('lib-add-audio').src = '/api/audio/' + id;
|
|
$('lib-add-audio').style.display = '';
|
|
$('lib-add-wave').style.display = '';
|
|
attachLibAddWaveSelection();
|
|
decodeTempAudio(id).then(buffer => {
|
|
if (libAddState.id !== id) return;
|
|
libAddState.buffer = buffer;
|
|
drawLibAddWave();
|
|
}).catch(()=>{});
|
|
setLibAddStatus(`${label} loaded${libAddState.duration ? ' (' + libAddState.duration.toFixed(1) + ' s)' : ''}`);
|
|
}
|
|
async function decodeTempAudio(id) {
|
|
const resp = await fetch('/api/audio/' + encodeURIComponent(id));
|
|
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
|
|
const data = await resp.arrayBuffer();
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
return ctx.decodeAudioData(data.slice(0));
|
|
}
|
|
function clampLibAddTime(value) {
|
|
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
|
|
return Math.max(0, Math.min(duration, Number(value) || 0));
|
|
}
|
|
function setLibAddCropRange(start, end) {
|
|
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
|
|
let a = clampLibAddTime(start), b = clampLibAddTime(end);
|
|
if (Math.abs(b - a) < 0.05) b = Math.min(duration, a + Math.min(1, duration || 1));
|
|
if (b < a) [a, b] = [b, a];
|
|
$('lib-add-start').value = a.toFixed(2);
|
|
$('lib-add-end').value = b.toFixed(2);
|
|
drawLibAddWave();
|
|
}
|
|
function libAddWaveTimeFromEvent(e) {
|
|
const canvas = $('lib-add-wave');
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
|
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
|
|
return rect.width ? x / rect.width * duration : 0;
|
|
}
|
|
function updateLibAddCropHint() {
|
|
const hint = $('lib-add-crop-hint');
|
|
if (!hint) return;
|
|
const start = parseFloat($('lib-add-start').value) || 0;
|
|
const end = parseFloat($('lib-add-end').value) || 0;
|
|
const dur = Math.max(0, end - start);
|
|
hint.textContent = dur ? `Selected ${dur.toFixed(1)}s. Aim for 3-20 seconds.` : 'Select 3-20 seconds for best cloning.';
|
|
hint.className = 'crop-duration-hint ' + (dur >= 3 && dur <= 20 ? 'ok' : dur ? 'warn' : '');
|
|
}
|
|
function drawLibAddWave() {
|
|
if (!libAddState.buffer) return;
|
|
drawOptimizerWave(
|
|
$('lib-add-wave'),
|
|
libAddState.buffer,
|
|
parseFloat($('lib-add-start').value) || 0,
|
|
parseFloat($('lib-add-end').value) || libAddState.duration || libAddState.buffer.duration
|
|
);
|
|
updateLibAddCropHint();
|
|
}
|
|
function libAddWaveSelectionPixels(e) {
|
|
const canvas = $('lib-add-wave');
|
|
const rect = canvas.getBoundingClientRect();
|
|
const duration = libAddState.duration || libAddState.buffer?.duration || 0;
|
|
const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0);
|
|
const end = clampLibAddTime(parseFloat($('lib-add-end').value) || duration);
|
|
const sx = duration && rect.width ? start / duration * rect.width : 0;
|
|
const ex = duration && rect.width ? end / duration * rect.width : rect.width;
|
|
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
|
return {x, sx, ex, start, end, duration};
|
|
}
|
|
function libAddWaveDragMode(e) {
|
|
const {x, sx, ex} = libAddWaveSelectionPixels(e);
|
|
const hit = 16;
|
|
if (Math.abs(x - sx) <= hit) return 'start';
|
|
if (Math.abs(x - ex) <= hit) return 'end';
|
|
return 'new';
|
|
}
|
|
function attachLibAddWaveSelection() {
|
|
const canvas = $('lib-add-wave');
|
|
if (!canvas || canvas.dataset.cropReady) return;
|
|
canvas.dataset.cropReady = '1';
|
|
let drag = null;
|
|
canvas.addEventListener('pointerdown', e => {
|
|
if (!libAddState.buffer) return;
|
|
e.preventDefault();
|
|
const mode = libAddWaveDragMode(e);
|
|
const t = libAddWaveTimeFromEvent(e);
|
|
const currentStart = parseFloat($('lib-add-start').value) || 0;
|
|
const currentEnd = parseFloat($('lib-add-end').value) || libAddState.duration || 0;
|
|
drag = {mode, anchor: t, start: currentStart, end: currentEnd};
|
|
canvas.setPointerCapture?.(e.pointerId);
|
|
if (mode === 'start') setLibAddCropRange(t, currentEnd);
|
|
else if (mode === 'end') setLibAddCropRange(currentStart, t);
|
|
else setLibAddCropRange(t, t);
|
|
setLibAddStatus(mode === 'start' ? 'Dragging crop start handle' : mode === 'end' ? 'Dragging crop end handle' : 'Drag to choose a new crop range');
|
|
});
|
|
canvas.addEventListener('pointermove', e => {
|
|
if (!libAddState.buffer) return;
|
|
if (!drag) {
|
|
const mode = libAddWaveDragMode(e);
|
|
canvas.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : 'crosshair';
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
const t = libAddWaveTimeFromEvent(e);
|
|
if (drag.mode === 'start') setLibAddCropRange(t, drag.end);
|
|
else if (drag.mode === 'end') setLibAddCropRange(drag.start, t);
|
|
else setLibAddCropRange(drag.anchor, t);
|
|
});
|
|
const finish = e => {
|
|
if (!drag) return;
|
|
e.preventDefault();
|
|
const t = libAddWaveTimeFromEvent(e);
|
|
if (drag.mode === 'start') setLibAddCropRange(t, drag.end);
|
|
else if (drag.mode === 'end') setLibAddCropRange(drag.start, t);
|
|
else setLibAddCropRange(drag.anchor, t);
|
|
drag = null;
|
|
const start = parseFloat($('lib-add-start').value) || 0;
|
|
const end = parseFloat($('lib-add-end').value) || 0;
|
|
setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0, end - start).toFixed(1)}s) selected`);
|
|
};
|
|
canvas.addEventListener('pointerup', finish);
|
|
canvas.addEventListener('pointerleave', () => { if (!drag) canvas.style.cursor = 'crosshair'; });
|
|
canvas.addEventListener('pointercancel', () => { drag = null; canvas.style.cursor = 'crosshair'; });
|
|
}
|
|
async function uploadLibAddFile(file) {
|
|
if (!file) return;
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
setLibAddStatus('Uploading audio…');
|
|
try {
|
|
const r = await fetch('/api/upload', {method:'POST', body:fd});
|
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
suggestLibVoiceId(file.name);
|
|
loadLibAddAudio(d.id, d.duration, file.name || 'Audio');
|
|
toast('Audio loaded', 'success');
|
|
} catch(e) { toast('Load failed: ' + e.message, 'error'); setLibAddStatus('Load failed'); }
|
|
}
|
|
|
|
const libAddDrop = $('lib-add-drop');
|
|
libAddDrop.addEventListener('click', () => $('lib-add-file').click());
|
|
libAddDrop.addEventListener('dragover', e => { e.preventDefault(); libAddDrop.classList.add('drag-over'); });
|
|
libAddDrop.addEventListener('dragleave', () => libAddDrop.classList.remove('drag-over'));
|
|
libAddDrop.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
libAddDrop.classList.remove('drag-over');
|
|
if (e.dataTransfer.files.length) uploadLibAddFile(e.dataTransfer.files[0]);
|
|
});
|
|
$('lib-add-file').addEventListener('change', async () => {
|
|
if ($('lib-add-file').files.length) await uploadLibAddFile($('lib-add-file').files[0]);
|
|
$('lib-add-file').value = '';
|
|
});
|
|
|
|
$('lib-add-url-btn').addEventListener('click', () => {
|
|
const url = $('lib-add-url').value.trim();
|
|
if (!url) { toast('Enter a YouTube or audio URL', 'error'); return; }
|
|
$('lib-add-url-btn').disabled = true;
|
|
setLibAddStatus('Starting download…');
|
|
const es = new EventSource('/api/download-yt?url=' + encodeURIComponent(url));
|
|
es.onmessage = e => {
|
|
const d = JSON.parse(e.data);
|
|
if (d.error) {
|
|
toast('Download failed: ' + d.error, 'error');
|
|
setLibAddStatus(d.error);
|
|
$('lib-add-url-btn').disabled = false;
|
|
es.close();
|
|
} else if (d.done) {
|
|
es.close();
|
|
$('lib-add-url-btn').disabled = false;
|
|
suggestLibVoiceId(url.split('/').pop() || 'DownloadedVoice');
|
|
loadLibAddAudio(d.id, d.duration, 'Downloaded audio');
|
|
toast('URL audio loaded', 'success');
|
|
} else {
|
|
setLibAddStatus(d.msg || 'Downloading…');
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
es.close();
|
|
$('lib-add-url-btn').disabled = false;
|
|
setLibAddStatus('Download connection closed');
|
|
};
|
|
});
|
|
|
|
$('lib-add-rec-start').addEventListener('click', async () => {
|
|
try {
|
|
await ensureLibAddMicMonitor();
|
|
libAddState.chunks = [];
|
|
libAddState.secs = 0;
|
|
$('lib-add-rec-time').textContent = '0:00';
|
|
$('lib-add-rec-start').disabled = true;
|
|
$('lib-add-rec-stop').disabled = false;
|
|
$('lib-add-monitor-stop').disabled = true;
|
|
libAddState.timer = setInterval(() => {
|
|
libAddState.secs++;
|
|
$('lib-add-rec-time').textContent = Math.floor(libAddState.secs / 60) + ':' + String(libAddState.secs % 60).padStart(2, '0');
|
|
}, 1000);
|
|
libAddState.recorder = new MediaRecorder(libAddState.recordStream);
|
|
libAddState.recorder.ondataavailable = e => { if (e.data.size) libAddState.chunks.push(e.data); };
|
|
libAddState.recorder.onstop = async () => {
|
|
clearInterval(libAddState.timer);
|
|
$('lib-add-rec-start').disabled = false;
|
|
$('lib-add-rec-stop').disabled = true;
|
|
const blob = new Blob(libAddState.chunks, {type:libAddState.recorder.mimeType || 'audio/webm'});
|
|
const ext = (libAddState.recorder.mimeType || '').includes('ogg') ? '.ogg' : '.webm';
|
|
stopLibAddMic();
|
|
suggestLibVoiceId('recording');
|
|
await uploadLibAddFile(new File([blob], 'recording' + ext, {type:blob.type}));
|
|
};
|
|
libAddState.recorder.start(100);
|
|
setLibAddStatus('Recording…');
|
|
} catch(e) {
|
|
stopLibAddMic();
|
|
$('lib-add-mic-help').classList.add('open');
|
|
const message = await microphoneErrorMessage(e);
|
|
toast(message, 'error');
|
|
setLibAddStatus(message);
|
|
$('lib-add-rec-start').disabled = false;
|
|
$('lib-add-rec-stop').disabled = true;
|
|
}
|
|
});
|
|
$('lib-add-rec-stop').addEventListener('click', () => {
|
|
if (libAddState.recorder && libAddState.recorder.state !== 'inactive') libAddState.recorder.stop();
|
|
});
|
|
$('lib-add-auto-trim').addEventListener('click', async () => {
|
|
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
|
|
$('lib-add-auto-trim').disabled = true;
|
|
try {
|
|
const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id})});
|
|
let d;
|
|
if (r.ok) d = await r.json();
|
|
else if (r.status === 404 || r.status === 405) d = await clientAutoTrimBounds(libAddState.id);
|
|
else { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
$('lib-add-start').value = Number(d.start).toFixed(2);
|
|
$('lib-add-end').value = Number(d.end).toFixed(2);
|
|
drawLibAddWave();
|
|
setLibAddStatus(d.reason || 'Auto trim ready');
|
|
} catch(e) { toast('Auto trim failed: ' + e.message, 'error'); setLibAddStatus('Auto trim failed'); }
|
|
finally { $('lib-add-auto-trim').disabled = false; }
|
|
});
|
|
async function transcribeLibAddCurrent(successMessage = 'Text recognised', audioId = libAddState.id) {
|
|
if (!audioId) throw new Error('Load audio first');
|
|
setLibAddStatus('Recognising text...');
|
|
const r = await fetch('/api/transcribe', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({id:audioId})
|
|
});
|
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
const text = d.text || '';
|
|
$('lib-add-transcript').value = text;
|
|
setLibAddStatus(successMessage);
|
|
return text;
|
|
}
|
|
function openSavedLibraryVoice(voiceId) {
|
|
const openRow = () => {
|
|
const row = Array.from(document.querySelectorAll('.vl-row')).find(r => r.dataset.id === voiceId);
|
|
if (!row) return false;
|
|
row.scrollIntoView({behavior:'smooth', block:'center'});
|
|
if (!row.classList.contains('edit-open')) row.querySelector('.edit-audio-btn')?.click();
|
|
return true;
|
|
};
|
|
if (!openRow()) setTimeout(openRow, 150);
|
|
}
|
|
async function applyLibAddCrop() {
|
|
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
|
|
const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0);
|
|
const end = clampLibAddTime(parseFloat($('lib-add-end').value) || libAddState.duration);
|
|
const duration = end - start;
|
|
if (end <= start + 0.1) { toast('Crop range is too short', 'error'); setLibAddStatus('Crop range is too short'); return; }
|
|
if (duration < 3 || duration > 20) toast('Best clone references are 3-20 seconds; cropping anyway.', 'error');
|
|
['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = true; });
|
|
setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`);
|
|
try {
|
|
const r = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id, start, end})});
|
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
loadLibAddAudio(d.id, d.duration, 'Cropped audio');
|
|
toast('Crop applied', 'success');
|
|
try {
|
|
await transcribeLibAddCurrent('Cropped audio loaded and text recognised', d.id);
|
|
} catch (e) {
|
|
toast('Crop applied, but recognition failed: ' + e.message, 'error');
|
|
setLibAddStatus('Cropped audio loaded; recognition failed');
|
|
}
|
|
} catch(e) { toast('Crop failed: ' + e.message, 'error'); setLibAddStatus('Crop failed'); }
|
|
finally { ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = false; }); }
|
|
}
|
|
$('lib-add-save-crop').addEventListener('click', applyLibAddCrop);
|
|
$('lib-add-save-crop-bottom').addEventListener('click', applyLibAddCrop);
|
|
['lib-add-start','lib-add-end'].forEach(id => $(id).addEventListener('input', drawLibAddWave));
|
|
$('lib-add-play').addEventListener('click', () => {
|
|
if (!libAddState.id) return;
|
|
if (libAddState.audio) libAddState.audio.pause();
|
|
libAddState.audio = new Audio('/api/audio/' + libAddState.id);
|
|
const start = parseFloat($('lib-add-start').value) || 0;
|
|
const end = parseFloat($('lib-add-end').value) || libAddState.duration;
|
|
libAddState.audio.currentTime = start;
|
|
libAddState.audio.ontimeupdate = () => { if (libAddState.audio.currentTime >= end) libAddState.audio.pause(); };
|
|
libAddState.audio.play();
|
|
});
|
|
$('lib-add-recognize').addEventListener('click', async () => {
|
|
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
|
|
try {
|
|
await transcribeLibAddCurrent('Text recognised');
|
|
} catch(e) { toast('Recognition failed: ' + e.message, 'error'); setLibAddStatus('Recognition failed'); }
|
|
});
|
|
$('lib-add-save').addEventListener('click', async () => {
|
|
if (!libAddState.id) { toast('Load audio first', 'error'); return; }
|
|
const voiceId = $('lib-add-voice-id').value.trim() || `${$('lib-add-lang').value}_${$('lib-add-gender').value}_NewVoice`;
|
|
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters', 'error'); return; }
|
|
setLibAddStatus('Saving voice...');
|
|
$('lib-add-save').disabled = true;
|
|
try {
|
|
const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({id:libAddState.id, start:parseFloat($('lib-add-start').value)||0, end:parseFloat($('lib-add-end').value)||libAddState.duration})});
|
|
if (!pr.ok) { const e = await pr.json().catch(()=>({})); throw new Error(e.detail || pr.statusText); }
|
|
const p = await pr.json();
|
|
let transcript = $('lib-add-transcript').value.trim();
|
|
if (!transcript) {
|
|
transcript = await transcribeLibAddCurrent('Final clip recognised; saving voice...', p.id);
|
|
if (!transcript.trim()) throw new Error('Recognition returned no transcript; add text or try recognising again.');
|
|
}
|
|
const sr = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({id:p.id, voice_id:voiceId, transcript})});
|
|
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
|
|
if (libAddState.pendingSource?.imageUrl) {
|
|
try {
|
|
await fetch('/api/voice/picture-url', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl})
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
libAddState.pendingSource = null;
|
|
setLibAddSourcePreview({});
|
|
$('lib-add-panel')?.classList.remove('open');
|
|
toast('Voice saved: ' + voiceId, 'success');
|
|
setLibAddStatus('Voice saved');
|
|
await loadVoiceLibrary();
|
|
openSavedLibraryVoice(voiceId);
|
|
} catch(e) { toast('Save failed: ' + e.message, 'error'); setLibAddStatus('Save failed'); }
|
|
finally { $('lib-add-save').disabled = false; }
|
|
});
|
|
|
|
$('show-disabled-cb').addEventListener('change', () => {
|
|
$('disabled-info').style.display = $('show-disabled-cb').checked ? '' : 'none';
|
|
renderVoiceList();
|
|
});
|
|
['library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => {
|
|
$(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); });
|
|
});
|
|
$('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180));
|
|
$('library-clear-filters')?.addEventListener('click', clearLibraryFilters);
|
|
$('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); });
|
|
|
|
function renderVoiceList() {
|
|
const showDisabled = $('show-disabled-cb').checked;
|
|
const list = $('voice-list');
|
|
list.innerHTML = '';
|
|
|
|
renderVoiceGroupsBar();
|
|
populateLibraryFilters();
|
|
readLibraryFilters();
|
|
|
|
const _gtBtn = $('voice-group-tag-btn');
|
|
if (_gtBtn) {
|
|
_gtBtn.classList.toggle('active', !!window._voiceGroupByTag);
|
|
const ic = _gtBtn.querySelector('.mdi');
|
|
if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline';
|
|
}
|
|
|
|
// Apply sidebar category filter
|
|
const cat = window._voiceSidebarCat || 'all';
|
|
const enabledOk = v => showDisabled || v.enabled !== false;
|
|
let filtered = _voices.filter(v => {
|
|
// 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV).
|
|
// 'cloned' = real WAV-identity voices that were NOT prompt-designed.
|
|
if (cat === 'cloned') return enabledOk(v) && v.has_ref && v.origin !== 'designed';
|
|
if (cat === 'designed') return enabledOk(v) && (v.origin === 'designed' || !v.has_ref);
|
|
if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4;
|
|
if (cat === 'hidden') return v.enabled === false; // always show hidden voices in hidden tab
|
|
// 'tools' shows all voices (same as 'all') so bulk-edit works
|
|
return enabledOk(v);
|
|
});
|
|
// Apply active group filter (from the groups bar)
|
|
if (window._voiceGroupFilter) {
|
|
filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter);
|
|
}
|
|
// Apply active tag "subfolder" filter (from the My Voices nav tree)
|
|
if (window._voiceTagFilter) {
|
|
filtered = filtered.filter(v => String(v.tag || '').split(',').map(t => t.trim()).includes(window._voiceTagFilter));
|
|
}
|
|
const visibleCount = filtered.length;
|
|
filtered = filtered.filter(libraryFilterMatch);
|
|
const filterCount = filtered.length;
|
|
if (_libraryIssueFilter) filtered = filtered.filter(v => libraryIssueMatch(v));
|
|
$('voice-count').textContent = filtered.length + ' / ' + _voices.length + ' voices';
|
|
|
|
filtered = filtered.slice().sort((a, b) => {
|
|
const av = getSortValue(a, _sortField), bv = getSortValue(b, _sortField);
|
|
if (av < bv) return -_sortDir;
|
|
if (av > bv) return _sortDir;
|
|
return 0;
|
|
});
|
|
|
|
updateLibraryInsights();
|
|
if (_libraryIssueFilter) {
|
|
const note = document.createElement('div');
|
|
note.className = 'library-filter-note';
|
|
note.innerHTML = `<span><strong>${escHtml(filtered.length)} / ${escHtml(filterCount)}</strong> ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}</span><button class="btn-secondary" type="button">Clear filter</button>`;
|
|
note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter(''));
|
|
list.appendChild(note);
|
|
}
|
|
if (!filtered.length) {
|
|
if (_voices.length === 0) {
|
|
// True empty — render a two-case helper panel
|
|
const emptyEl = document.createElement('div');
|
|
emptyEl.className = 'voices-empty-state';
|
|
emptyEl.innerHTML = `
|
|
<div class="voices-empty-cases">
|
|
<div class="voices-empty-case">
|
|
<div class="voices-empty-case-icon"><span class="mdi mdi-folder-outline"></span></div>
|
|
<h4>Wrong folder path?</h4>
|
|
<p>Set the path where your <code>.wav</code> voice files live <em>inside the container</em>.</p>
|
|
<div class="voices-folder-row">
|
|
<input id="voices-empty-scan-dir" type="text" class="voices-folder-input" placeholder="/voices">
|
|
<button class="btn-secondary" id="voices-empty-browse-btn" title="Browse container filesystem"><span class="mdi mdi-folder-outline"></span> Browse</button>
|
|
<button class="btn-primary" id="voices-empty-save-btn">Set & reload</button>
|
|
</div>
|
|
<div class="vef-dir-browser" id="vef-dir-browser" hidden>
|
|
<div class="vef-breadcrumb" id="vef-breadcrumb"></div>
|
|
<div class="vef-dir-list" id="vef-dir-list"><span class="vef-loading">Loading…</span></div>
|
|
<div class="vef-browser-actions">
|
|
<span class="vef-selected-path note" id="vef-selected-path"></span>
|
|
<button class="btn-primary" id="vef-select-btn">Use this folder</button>
|
|
</div>
|
|
</div>
|
|
<p class="voices-empty-hint">Map a host folder via docker-compose:<br>
|
|
<code>- /your/host/path:/voices:rw</code><br>
|
|
or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
|
|
</div>
|
|
<div class="voices-empty-divider">or</div>
|
|
<div class="voices-empty-case">
|
|
<div class="voices-empty-case-icon"><span class="mdi mdi-microphone-variant"></span></div>
|
|
<h4>Folder is empty?</h4>
|
|
<p>Create your first voice from a recording or download ready-made voices.</p>
|
|
<div class="voices-empty-actions">
|
|
<button class="btn-primary voices-empty-action-btn" id="voices-goto-clone"><span class="mdi mdi-lightning-bolt"></span> Clone a Voice</button>
|
|
<button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio"><span class="mdi mdi-earth"></span> Get Voices Online</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
list.appendChild(emptyEl);
|
|
|
|
fetch('/api/settings').then(r => r.json()).then(s => {
|
|
const inp = document.getElementById('voices-empty-scan-dir');
|
|
if (inp) inp.value = s.voices_scan_dir || '/voices';
|
|
}).catch(() => {});
|
|
|
|
// ── Directory browser ──────────────────────────────────────────────────
|
|
let _vefCurrentPath = '/';
|
|
const vefBrowser = document.getElementById('vef-dir-browser');
|
|
const vefDirList = document.getElementById('vef-dir-list');
|
|
const vefCrumb = document.getElementById('vef-breadcrumb');
|
|
const vefSelPath = document.getElementById('vef-selected-path');
|
|
|
|
async function vefNavigate(path) {
|
|
_vefCurrentPath = path;
|
|
vefDirList.innerHTML = '<span class="vef-loading">Loading…</span>';
|
|
if (vefSelPath) vefSelPath.textContent = path;
|
|
try {
|
|
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
|
|
// Breadcrumb
|
|
const parts = data.path.split('/').filter(Boolean);
|
|
const crumbs = [{ label: '/', path: '/' }];
|
|
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
|
|
vefCrumb.innerHTML = crumbs.map((c, i) =>
|
|
i < crumbs.length - 1
|
|
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}">${escHtml(c.label)}</button><span class="vef-sep">/</span>`
|
|
: `<span class="vef-crumb-cur">${escHtml(c.label)}</span>`
|
|
).join('');
|
|
vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
|
|
// Directory list
|
|
if (!data.dirs.length) {
|
|
vefDirList.innerHTML = '<span class="vef-empty">No subdirectories here.</span>';
|
|
} else {
|
|
vefDirList.innerHTML = data.dirs.map(d =>
|
|
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}"><span class="mdi mdi-folder-outline"></span> ${escHtml(d)}</button>`
|
|
).join('');
|
|
vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
|
|
}
|
|
if (vefSelPath) vefSelPath.textContent = data.path;
|
|
_vefCurrentPath = data.path;
|
|
} catch(e) {
|
|
vefDirList.innerHTML = `<span class="vef-error">Error: ${escHtml(e.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => {
|
|
const open = vefBrowser.hidden;
|
|
vefBrowser.hidden = !open;
|
|
if (open) {
|
|
const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices';
|
|
vefNavigate(cur);
|
|
}
|
|
});
|
|
|
|
document.getElementById('vef-select-btn')?.addEventListener('click', () => {
|
|
const inp = document.getElementById('voices-empty-scan-dir');
|
|
if (inp) inp.value = _vefCurrentPath;
|
|
if (vefBrowser) vefBrowser.hidden = true;
|
|
});
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => {
|
|
const inp = document.getElementById('voices-empty-scan-dir');
|
|
const dir = inp?.value?.trim();
|
|
if (!dir) return;
|
|
const btn = document.getElementById('voices-empty-save-btn');
|
|
btn.disabled = true; btn.textContent = 'Saving…';
|
|
try {
|
|
const s = await fetch('/api/settings').then(r => r.json());
|
|
s.voices_scan_dir = dir;
|
|
const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) });
|
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
|
toast('Folder saved — reloading voices…', 'success');
|
|
_libraryLoadPromise = null;
|
|
await loadVoiceLibrary();
|
|
} catch(e) {
|
|
toast('Failed: ' + e.message, 'error');
|
|
btn.disabled = false; btn.textContent = 'Set & reload';
|
|
}
|
|
});
|
|
document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone'));
|
|
document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio'));
|
|
} else {
|
|
list.innerHTML += '<div style="padding:14px;color:var(--subtext);font-size:14px">No voices match the current filters.</div>';
|
|
}
|
|
return;
|
|
}
|
|
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
|
|
else 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 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 > flag icon > initial letter (with color)
|
|
const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
|
|
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo'
|
|
: iconHtml ? 'vl-avatar vl-avatar-icon'
|
|
: flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
|
|
const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`;
|
|
const avatarContent = picSrc
|
|
? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
|
|
: iconHtml || flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
|
|
|
|
wrap.innerHTML = `
|
|
<div class="vl-compact">
|
|
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
|
|
<div class="vl-info">
|
|
<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>
|
|
<div class="vl-meta">
|
|
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
|
${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''}
|
|
${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</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>
|
|
|
|
<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-detail-active">
|
|
<label>Active</label>
|
|
<div class="vr-active-tools">
|
|
<div class="vr-toggle">
|
|
<label class="toggle" title="${v.enabled===false ? 'Disabled - click to enable' : 'Enabled - click to disable'}">
|
|
<input type="checkbox" ${v.enabled!==false ? 'checked' : ''}>
|
|
<span class="t-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="vr-detail-delete">
|
|
<button class="delete-btn" title="Delete voice permanently">Delete voice</button>
|
|
<div class="delete-confirm" role="group" aria-label="Confirm delete voice">
|
|
<strong>Delete?</strong>
|
|
<span title="${escHtml(v.id)}">${escHtml(v.id)}</span>
|
|
<button class="btn-secondary delete-confirm-cancel" type="button">Cancel</button>
|
|
<button class="delete-confirm-go" type="button">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="vr-optimizer">
|
|
<div class="optimizer-grid">
|
|
<div class="opt-group opt-trim-panel">
|
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference audio · crop</span></div>
|
|
<div class="opt-group-subtitle">Crop the saved WAV to 3–20 s of clean speech.</div>
|
|
<div class="opt-group-body">
|
|
<canvas class="opt-wave"></canvas>
|
|
<div class="opt-controls">
|
|
<div class="opt-field"><label>Start</label><input class="opt-start" type="number" step="0.01" value="0"></div>
|
|
<div class="opt-field"><label>End</label><input class="opt-end" type="number" step="0.01" value="0"></div>
|
|
<button class="btn-secondary opt-auto-trim">Auto trim</button>
|
|
<button class="btn-secondary opt-play">Play crop</button>
|
|
<button class="btn-secondary opt-undo">Undo crop</button>
|
|
</div>
|
|
<div class="opt-group-footer">
|
|
<button class="btn-primary opt-save-crop">Save crop</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="opt-group opt-text-panel open">
|
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference transcript</span></div>
|
|
<div class="opt-group-subtitle">The spoken text that matches this voice recording.</div>
|
|
<div class="opt-group-body">
|
|
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
|
|
<div class="opt-controls">
|
|
<button class="btn-secondary opt-recognize">Re-recognise text</button>
|
|
</div>
|
|
<div class="opt-group-footer">
|
|
<button class="btn-primary opt-save-text">Save text</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="opt-group opt-compare-panel">
|
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Voice match</span></div>
|
|
<div class="opt-group-subtitle">Compare the saved WAV with a fresh TTS synthesis.</div>
|
|
<div class="opt-group-body">
|
|
<div class="opt-controls">
|
|
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
|
|
<button class="btn-secondary opt-play-reference">Play WAV file</button>
|
|
<button class="btn-primary opt-synth-reference">Synthesize reference text</button>
|
|
</div>
|
|
<p class="opt-group-note">Compare the saved reference WAV with a fresh synthesis of the same reference text. Restart TTS first after editing a voice, otherwise the backend may still use a cached version.</p>
|
|
<div class="opt-compare-grid">
|
|
<div class="opt-compare-card">
|
|
<strong>WAV file</strong>
|
|
<audio class="opt-compare-ref-audio" controls></audio>
|
|
</div>
|
|
<div class="opt-compare-card">
|
|
<strong>Synthesized reference text</strong>
|
|
<audio class="opt-compare-synth-audio" controls></audio>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${!isClone ? `<div class="opt-group opt-style-panel">
|
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Style variation</span></div>
|
|
<div class="opt-group-subtitle">Create a styled variant and save it as a new voice (CustomVoice only).</div>
|
|
<div class="opt-group-body">
|
|
<div class="opt-controls">
|
|
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
|
|
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
|
|
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
|
|
</div>
|
|
<p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.</p>
|
|
<div class="backend-help opt-style-backend-help"></div>
|
|
<div class="opt-style-preview-box">
|
|
<div class="opt-controls">
|
|
<button class="btn-secondary opt-preview-style">Preview style</button>
|
|
</div>
|
|
<audio class="opt-style-audio" controls></audio>
|
|
</div>
|
|
<div class="opt-group-footer">
|
|
<button class="btn-primary opt-save-style">Save style variation</button>
|
|
</div>
|
|
</div>
|
|
</div>` : ''}
|
|
<div class="opt-group opt-maintenance open">
|
|
<div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Loudness</span></div>
|
|
<div class="opt-group-subtitle">Normalize the volume of the reference audio file.</div>
|
|
<div class="opt-group-body">
|
|
<div class="opt-controls">
|
|
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
|
|
<button class="btn-secondary opt-db-minus">-</button>
|
|
<button class="btn-secondary opt-db-plus">+</button>
|
|
<button class="btn-secondary opt-db-auto">Auto</button>
|
|
<button class="btn-secondary opt-restart-tts" title="Restarts the WAV-loading TTS backends (Voice Clone + Streaming) so they rescan the active_voices folder">Restart WAV engines</button>
|
|
<button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
|
|
<span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
|
|
</div>
|
|
<div class="opt-group-footer">
|
|
<button class="btn-primary opt-save-volume">Save volume</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="opt-status optimizer-workflow">Click the pencil to load waveform and tools.</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const vrLengthEl = wrap.querySelector('.vr-length');
|
|
hydrateVoiceDuration(v, vrLengthEl);
|
|
|
|
// Photo upload
|
|
const photoCell = wrap.querySelector('.vr-photo');
|
|
const photoInput = wrap.querySelector('.photo-input');
|
|
photoCell.addEventListener('click', () => photoInput.click());
|
|
photoInput.addEventListener('change', async () => {
|
|
if (!photoInput.files.length) return;
|
|
const fd = new FormData();
|
|
fd.append('voice_id', v.id);
|
|
fd.append('file', photoInput.files[0]);
|
|
try {
|
|
const r = await fetch('/api/voice/picture', { method:'POST', body:fd });
|
|
if (!r.ok) throw new Error((await r.json()).detail);
|
|
const img = document.createElement('img');
|
|
img.src = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${Date.now()}`;
|
|
img.alt = '';
|
|
photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput);
|
|
v.has_picture = true; toast('Photo uploaded','success');
|
|
} catch(e) { toast('Photo upload failed: '+e.message,'error'); }
|
|
});
|
|
|
|
// Per-voice loudness normalization
|
|
const normalizeBtn = wrap.querySelector('.normalize-voice-btn');
|
|
const dbValue = wrap.querySelector('.vr-db-value');
|
|
const dbCell = wrap.querySelector('.vr-db');
|
|
normalizeBtn.addEventListener('click', async () => {
|
|
const target = libraryTargetDb();
|
|
if (!confirm(`Normalize "${v.id}" to ${target} dBFS?`)) return;
|
|
normalizeBtn.disabled = true;
|
|
status('Normalizing ' + v.id + '…');
|
|
try {
|
|
const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
|
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
v.loudness = d.loudness || v.loudness;
|
|
v.duration = d.duration ?? v.duration;
|
|
v.file_type = d.file_type || v.file_type;
|
|
v.path = d.path || v.path;
|
|
v.needs_tts_restart = true;
|
|
markVoiceAudioChanged(v);
|
|
dbValue.textContent = fmtDbfs(v);
|
|
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
|
|
if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
|
|
toast('Normalized: ' + v.id, 'success');
|
|
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
|
|
} catch(e) {
|
|
toast('Normalize failed: ' + e.message, 'error');
|
|
status('Normalize failed');
|
|
} finally {
|
|
normalizeBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// Language picker
|
|
const flagEmojiEl = wrap.querySelector('.flag-emoji');
|
|
const flagCodeEl = wrap.querySelector('.flag-code');
|
|
const flagPicker = wrap.querySelector('.flag-picker');
|
|
const flagCell = wrap.querySelector('.vr-flag');
|
|
|
|
flagCell.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
document.querySelectorAll('.flag-picker.open').forEach(fp => { if(fp!==flagPicker) fp.classList.remove('open'); });
|
|
flagPicker.classList.toggle('open');
|
|
});
|
|
flagPicker.querySelectorAll('.flag-opt').forEach(opt => {
|
|
opt.addEventListener('click', async e => {
|
|
e.stopPropagation();
|
|
const cc = opt.dataset.cc;
|
|
flagPicker.classList.remove('open');
|
|
flagEmojiEl.textContent = cc2flag(cc);
|
|
flagCodeEl.textContent = ccDisplay(cc);
|
|
flagPicker.querySelectorAll('.flag-opt').forEach(o => o.classList.toggle('active', o.dataset.cc===cc));
|
|
v.flag = cc;
|
|
await saveMeta(v.id, { flag: cc });
|
|
});
|
|
});
|
|
|
|
// Gender cycle F → M → N → F
|
|
const gBadge = wrap.querySelector('.gender-badge');
|
|
gBadge.addEventListener('click', async () => {
|
|
const cycle = ['F','M','N'];
|
|
v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3];
|
|
gBadge.innerHTML = `<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));
|
|
|
|
// 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);
|
|
setBenchmarkProgress(1, 1, `Finished ${v.id}`);
|
|
toast('Benchmarked ' + v.id, 'success');
|
|
setVoiceRestartState(false, 'Benchmark saved for ' + v.id);
|
|
} catch(e) {
|
|
toast('Benchmark failed: ' + e.message, 'error');
|
|
setOptStatus('Benchmark failed');
|
|
} finally { triggerBtn.disabled = false; }
|
|
};
|
|
benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn));
|
|
optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn));
|
|
|
|
// Play original recording or synthesized sample
|
|
const originalPlayBtn = wrap.querySelector('.vr-play-original button');
|
|
const synthPlayBtn = wrap.querySelector('.vr-play-synth button');
|
|
const playIcon = '<span class="mdi mdi-play"></span>', pauseIcon = '<span class="mdi mdi-pause-circle-outline"></span>', generatingIcon = '<span class="mdi mdi-timer-sand"></span>';
|
|
function setLibraryPlayButtonState(btn, state) {
|
|
btn.classList.toggle('is-generating', state === 'generating');
|
|
btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon);
|
|
btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording'));
|
|
}
|
|
async function playLibraryVoice(kind, playBtn) {
|
|
const bar = $('lib-audio-bar'), audio = $('lib-audio');
|
|
const playKey = v.id + ':' + kind;
|
|
playBtn.dataset.playKind = kind;
|
|
|
|
if (_activePlayVoiceId === playKey && !audio.paused) {
|
|
audio.pause();
|
|
setLibraryPlayButtonState(playBtn, 'idle');
|
|
return;
|
|
}
|
|
if (_activePlayVoiceId === playKey && audio.paused && audio.src) {
|
|
_activePlayButton = playBtn;
|
|
try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); }
|
|
return;
|
|
}
|
|
|
|
if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle');
|
|
_activePlayButton = playBtn;
|
|
_activePlayVoiceId = playKey;
|
|
if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; }
|
|
if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating');
|
|
playBtn.disabled = true;
|
|
try {
|
|
if (kind === 'synth') {
|
|
if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error');
|
|
const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview';
|
|
const text = synthMode === 'transcript'
|
|
? (v.transcript?.trim() || benchmarkSampleText())
|
|
: benchmarkSampleText();
|
|
const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text';
|
|
const backend = libraryTtsBackend();
|
|
const source = await createTtsAudioSource(v.id, text, backend, 'settings', '');
|
|
audio.src = source.url;
|
|
if (!source.streaming) _activePlayUrl = source.url;
|
|
$('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend);
|
|
} else {
|
|
audio.src = voiceFileUrl(v);
|
|
$('lib-audio-label').textContent = v.id + ' · original recording';
|
|
}
|
|
bar.style.display = '';
|
|
audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; };
|
|
audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); };
|
|
audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); };
|
|
await audio.play();
|
|
} catch(e) {
|
|
setLibraryPlayButtonState(playBtn, 'idle');
|
|
toast('Play failed: '+e.message,'error');
|
|
} finally {
|
|
playBtn.disabled = false;
|
|
}
|
|
}
|
|
originalPlayBtn.dataset.playKind = 'original';
|
|
synthPlayBtn.dataset.playKind = 'synth';
|
|
setLibraryPlayButtonState(originalPlayBtn, 'idle');
|
|
setLibraryPlayButtonState(synthPlayBtn, 'idle');
|
|
originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn));
|
|
synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn));
|
|
|
|
// Enable toggle
|
|
const toggleCb = wrap.querySelector('.toggle input');
|
|
toggleCb.addEventListener('change', async () => {
|
|
const nextEnabled = toggleCb.checked;
|
|
const previousEnabled = v.enabled !== false;
|
|
toggleCb.disabled = true;
|
|
try {
|
|
const saved = await saveMeta(v.id, { enabled: nextEnabled });
|
|
v.enabled = nextEnabled;
|
|
if (saved && saved.path) v.path = saved.path;
|
|
wrap.classList.toggle('vr-disabled', !v.enabled);
|
|
toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success');
|
|
if (!v.enabled && !$('show-disabled-cb').checked) {
|
|
wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0';
|
|
setTimeout(() => wrap.remove(), 400);
|
|
}
|
|
} catch(e) {
|
|
toggleCb.checked = previousEnabled;
|
|
v.enabled = previousEnabled;
|
|
wrap.classList.toggle('vr-disabled', !v.enabled);
|
|
toast('Move failed: ' + e.message, 'error');
|
|
} finally {
|
|
toggleCb.disabled = false;
|
|
}
|
|
});
|
|
|
|
// Delete voice
|
|
const deleteBtn = wrap.querySelector('.delete-btn');
|
|
const deleteConfirm = wrap.querySelector('.delete-confirm');
|
|
const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel');
|
|
const deleteGoBtn = wrap.querySelector('.delete-confirm-go');
|
|
const closeDeleteConfirm = () => wrap.classList.remove('delete-pending');
|
|
deleteBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); });
|
|
wrap.classList.add('delete-pending');
|
|
deleteGoBtn.focus();
|
|
});
|
|
deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); });
|
|
deleteConfirm.addEventListener('click', e => e.stopPropagation());
|
|
deleteGoBtn.addEventListener('click', async e => {
|
|
e.stopPropagation();
|
|
deleteGoBtn.disabled = true;
|
|
deleteCancelBtn.disabled = true;
|
|
try {
|
|
const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' });
|
|
if (!r.ok) { const e = await r.json(); throw new Error(e.detail); }
|
|
_voices = _voices.filter(x => x.id !== v.id);
|
|
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
|
|
setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
|
|
toast(`Deleted: ${v.id}`, 'success');
|
|
} catch(e) {
|
|
toast('Delete failed: ' + e.message, 'error');
|
|
deleteGoBtn.disabled = false;
|
|
deleteCancelBtn.disabled = false;
|
|
closeDeleteConfirm();
|
|
}
|
|
});
|
|
|
|
return wrap;
|
|
}
|
|
|
|
// ── Voice groups bar (bulk edit/remove a tagged set, e.g. a rehearser play) ──
|
|
function renderVoiceGroupsBar() {
|
|
const bar = $('voice-groups-bar');
|
|
if (!bar) return;
|
|
// Tally groups across all voices
|
|
const groups = {};
|
|
(_voices || []).forEach(v => {
|
|
const g = (v.group || '').trim();
|
|
if (g) groups[g] = (groups[g] || 0) + 1;
|
|
});
|
|
const names = Object.keys(groups).sort();
|
|
if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
|
|
bar.hidden = false;
|
|
const active = window._voiceGroupFilter || '';
|
|
bar.innerHTML =
|
|
`<span class="vg-label"><span class="mdi mdi-folder-multiple-outline"></span> Groups</span>` +
|
|
names.map(g =>
|
|
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
|
|
${escHtml(g)} <span class="vg-count">${groups[g]}</span>
|
|
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">×</button>
|
|
</span>`
|
|
).join('') +
|
|
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
|
|
|
|
bar.querySelectorAll('.vg-chip').forEach(chip => {
|
|
chip.addEventListener('click', e => {
|
|
if (e.target.closest('.vg-del')) return;
|
|
const g = chip.dataset.group;
|
|
window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g;
|
|
renderVoiceList();
|
|
});
|
|
});
|
|
bar.querySelectorAll('.vg-del').forEach(btn => {
|
|
btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
|
|
});
|
|
bar.querySelector('.vg-clear')?.addEventListener('click', () => {
|
|
window._voiceGroupFilter = ''; renderVoiceList();
|
|
});
|
|
}
|
|
|
|
async function deleteVoiceGroup(group) {
|
|
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
|
|
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
|
|
try {
|
|
const r = await fetch('/api/voices/delete-group', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ group }),
|
|
});
|
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
|
|
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
|
|
await loadVoiceLibrary();
|
|
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
|
|
}
|
|
|
|
async function saveMeta(voiceId, patch) {
|
|
const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ voice_id:voiceId, ...patch }) });
|
|
if (!r.ok) {
|
|
const e = await r.json().catch(() => ({}));
|
|
throw new Error(e.detail || r.statusText);
|
|
}
|
|
return r.json();
|
|
}
|
|
|
|
// Close language pickers when clicking elsewhere
|
|
document.addEventListener('click', () => {
|
|
document.querySelectorAll('.flag-picker.open').forEach(fp => fp.classList.remove('open'));
|
|
document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending'));
|
|
}, { passive:true });
|
|
|
|
// ── Bulk-edit ────────────────────────────────────────────────────────────────
|
|
|
|
const _bulkSelected = new Set(); // voice IDs currently checked
|
|
|
|
function _bulkUpdate() {
|
|
const bar = $('vl-bulk-bar');
|
|
const count = $('vl-bulk-count');
|
|
const n = _bulkSelected.size;
|
|
if (bar) bar.hidden = n === 0;
|
|
if (count) count.textContent = `${n} selected`;
|
|
// Sync checkboxes in the DOM to match the set
|
|
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
|
|
cb.checked = _bulkSelected.has(cb.dataset.id);
|
|
});
|
|
}
|
|
|
|
function _bulkToggle(id, checked) {
|
|
if (checked) _bulkSelected.add(id); else _bulkSelected.delete(id);
|
|
_bulkUpdate();
|
|
}
|
|
|
|
// Inject checkbox into each voice row after renderVoiceList builds the DOM
|
|
const _origRenderVoiceList = renderVoiceList;
|
|
renderVoiceList = function() {
|
|
_origRenderVoiceList.apply(this, arguments);
|
|
_bulkInjectCheckboxes();
|
|
};
|
|
|
|
function _bulkInjectCheckboxes() {
|
|
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
|
|
if (row.querySelector('.vl-bulk-cb')) return; // already injected
|
|
const id = row.dataset.id;
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.className = 'vl-bulk-cb';
|
|
cb.dataset.id = id;
|
|
cb.checked = _bulkSelected.has(id);
|
|
cb.title = 'Select for bulk edit';
|
|
cb.addEventListener('change', e => { e.stopPropagation(); _bulkToggle(id, cb.checked); });
|
|
cb.addEventListener('click', e => e.stopPropagation());
|
|
// Prepend into vl-compact
|
|
const compact = row.querySelector('.vl-compact');
|
|
if (compact) compact.prepend(cb);
|
|
});
|
|
}
|
|
|
|
// ── Toolbar button wiring ───────────────────────────────────────────────────
|
|
|
|
$('vl-bulk-select-all')?.addEventListener('click', () => {
|
|
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
|
|
if (row.dataset.id) _bulkSelected.add(row.dataset.id);
|
|
});
|
|
_bulkUpdate();
|
|
});
|
|
|
|
// Always-visible Select-all toggle in the sort bar (the bulk bar only appears once
|
|
// something is selected, so this is the entry point). Selects every visible row, or
|
|
// clears the selection if they're all already selected.
|
|
$('vl-select-all-visible')?.addEventListener('click', () => {
|
|
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
|
|
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
|
|
if (allSelected) {
|
|
rows.forEach(r => _bulkSelected.delete(r.dataset.id));
|
|
} else {
|
|
rows.forEach(r => _bulkSelected.add(r.dataset.id));
|
|
}
|
|
_bulkUpdate();
|
|
});
|
|
|
|
$('vl-bulk-deselect')?.addEventListener('click', () => {
|
|
_bulkSelected.clear();
|
|
_bulkUpdate();
|
|
});
|
|
|
|
$('vl-bulk-hide')?.addEventListener('click', async () => {
|
|
if (!_bulkSelected.size) return;
|
|
const ids = [..._bulkSelected];
|
|
const r = await _bulkSetEnabled(ids, false);
|
|
toast(`Hidden ${r} voice${r!==1?'s':''}`, 'success');
|
|
_bulkSelected.clear();
|
|
await loadVoiceLibrary();
|
|
});
|
|
|
|
$('vl-bulk-unhide')?.addEventListener('click', async () => {
|
|
if (!_bulkSelected.size) return;
|
|
const ids = [..._bulkSelected];
|
|
const r = await _bulkSetEnabled(ids, true);
|
|
toast(`Unhidden ${r} voice${r!==1?'s':''}`, 'success');
|
|
_bulkSelected.clear();
|
|
await loadVoiceLibrary();
|
|
});
|
|
|
|
$('vl-bulk-tag')?.addEventListener('click', () => {
|
|
if (!_bulkSelected.size) return;
|
|
const ids = [..._bulkSelected];
|
|
// Show a small inline prompt inside the toolbar
|
|
const bar = $('vl-bulk-bar');
|
|
let inp = bar.querySelector('.vl-bulk-tag-inp');
|
|
if (!inp) {
|
|
inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.className = 'vl-bulk-tag-inp';
|
|
inp.placeholder = 'tag1, tag2…';
|
|
inp.autocomplete = 'off';
|
|
const applyBtn = document.createElement('button');
|
|
applyBtn.className = 'vl-bulk-btn';
|
|
applyBtn.innerHTML = '<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-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;
|
|
}
|
|
|