tts-voice-creator-clone-and.../static/app.js
mARTin-B78 9c47b1998d Add container filesystem folder browser to empty voice state
- /api/browse-dirs endpoint lists subdirectories at any container path
- '📁 Browse' button next to the path input toggles an inline dir browser
- Breadcrumb navigation lets users click up/down through the filesystem
- 'Use this folder' confirms the selection back into the path input
- Existing 'Set & reload' flow unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:21:21 +02:00

6166 lines
290 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

// ── Utility ───────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
// ── Voice avatar colors ───────────────────────────────────────────────────
const _AVATAR_COLORS = ['#E57373','#F06292','#BA68C8','#9575CD','#7986CB',
'#64B5F6','#4DD0E1','#4DB6AC','#81C784','#FFB74D','#FF8A65','#A1887F'];
function avatarColor(id) {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xFFFFFF;
return _AVATAR_COLORS[Math.abs(h) % _AVATAR_COLORS.length];
}
// ── Voice inspector (3-pane workbench) ───────────────────────────────────
let _selectedVoiceWrap = null;
function selectVoice(wrap) {
const inspector = document.getElementById('voices-inspector');
if (!inspector) return;
function restoreToRow(targetWrap) {
// Restore all extracted elements back to their original DOM parents
(targetWrap._extracted || []).forEach(({el, target}) => {
if (el && target) target.appendChild(el);
});
targetWrap._extracted = [];
// Move main-row and optimizer back from inspector body to wrap
inspector.querySelectorAll('.vr-main-row,.vr-optimizer').forEach(el => targetWrap.appendChild(el));
}
// Deselect previous voice
if (_selectedVoiceWrap && _selectedVoiceWrap !== wrap) {
_selectedVoiceWrap.classList.remove('vr-selected');
restoreToRow(_selectedVoiceWrap);
}
if (_selectedVoiceWrap === wrap) {
restoreToRow(wrap);
wrap.classList.remove('vr-selected');
_selectedVoiceWrap = null;
inspector.innerHTML = '<div class="inspector-placeholder"><span>&#128266;</span><p>Pick a voice on the left<br>to edit it here</p></div>';
return;
}
_selectedVoiceWrap = wrap;
wrap.classList.add('vr-selected');
const voiceId = wrap.dataset.id || '';
const color = wrap.dataset.color || '#9575CD';
const isClone = wrap.dataset.hasRef === 'true';
const dbfs = wrap.dataset.dbfs || '-';
const hasPicture = wrap.dataset.hasPicture === 'true';
// Live voice object — authoritative for mutable fields
const v = (_voices || []).find(vv => vv.id === voiceId) || {};
const langCode = v.lang || wrap.dataset.lang || '';
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
const rating = v.rating || 0;
const nameParts = voiceId.split('_');
const dispName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId;
const initial = (dispName[0] || voiceId[0] || '?').toUpperCase();
const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
const inspFlagCc = flagCc || langCode;
const inspFlagIcon = inspFlagCc
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
const avatarHtml = picSrcInsp
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
: inspFlagIcon || initial;
const avatarBgStyle = (picSrcInsp || inspFlagIcon) ? '' : `style="background:${color}"`;
const flagIconHtml = inspFlagIcon || '';
const makeStars = n => [1,2,3,4,5].map(i =>
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}">&#9733;</span>`
).join('');
const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK'];
// Gender maps — used in template AND in event handlers
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
const curGender = v.gender || 'N';
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
inspector.innerHTML = `
<div class="inspector-header">
<div class="insp-hd-top">
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
<div class="insp-title-stack">
<div class="insp-hd-row1">
<h3 class="insp-disp-name" title="Double-click to rename">${escHtml(dispName)}</h3>
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
<div class="insp-actions-save"></div>
</div>
<div class="insp-hd-row2">
<div class="insp-id-block">
<span class="insp-full-id" title="Double-click to rename">${escHtml(voiceId)}</span>
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
</div>
<div class="insp-actions-active"></div>
</div>
<div class="insp-hd-divider"></div>
<div class="insp-subtitle">
<span class="insp-flag" title="Double-click to change accent flag">
<span class="insp-flag-icon">${flagIconHtml}</span>
</span>
<span class="insp-lang-wrap" title="Double-click to change language">
<span class="insp-lang-code">${escHtml(langCode)}</span>
</span>
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
<span class="insp-stars-wrap">
<span class="insp-stars">${makeStars(rating)}</span>
<span class="insp-rating-label">${rating}/5</span>
</span>
<input class="insp-tag-input" type="text" placeholder="tag1, tag2…" value="${escHtml(v.tag||'')}" autocomplete="off">
</div>
<div class="insp-note-delete-row">
<div class="insp-note-slot"></div>
<div class="insp-actions-delete"></div>
</div>
</div>
</div>
</div>
<div class="inspector-body"></div>
`;
const saveSlot = inspector.querySelector('.insp-actions-save');
const activeSlot = inspector.querySelector('.insp-actions-active');
const deleteSlot = inspector.querySelector('.insp-actions-delete');
const body = inspector.querySelector('.inspector-body');
// ── Save changes → row 1 right ───────────────────────────────────────────
const saveBtn = document.createElement('button');
saveBtn.className = 'btn-primary insp-save-btn';
saveBtn.textContent = 'Save changes';
saveBtn.addEventListener('click', () => body.querySelector('.opt-save-text')?.click());
saveSlot.appendChild(saveBtn);
// ── Active toggle → row 2 right ──────────────────────────────────────────
const detailRow = wrap.querySelector('.vr-detail-row');
const activeEl = detailRow?.querySelector('.vr-detail-active');
const deleteEl = detailRow?.querySelector('.vr-detail-delete');
if (activeEl) activeSlot.appendChild(activeEl);
// ── Delete → note row right ───────────────────────────────────────────────
if (deleteEl) {
deleteSlot.appendChild(deleteEl);
const _dBtn = deleteEl.querySelector('.delete-btn');
const _dCnl = deleteEl.querySelector('.delete-confirm-cancel');
_dBtn?.addEventListener('click', () => deleteEl.classList.add('delete-pending'));
_dCnl?.addEventListener('click', () => deleteEl.classList.remove('delete-pending'));
}
// ── Avatar click → photo upload ───────────────────────────────────────────
inspector.querySelector('.inspector-avatar').addEventListener('click', () => {
body.querySelector('.photo-input')?.click();
});
// ── Copy ID button ────────────────────────────────────────────────────────
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
});
// ── Double-click name/ID → inline rename ──────────────────────────────────
const dispNameEl = inspector.querySelector('.insp-disp-name');
const nameEditEl = inspector.querySelector('.insp-name-edit');
const fullIdEl = inspector.querySelector('.insp-full-id');
const startInspRename = () => {
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
nameEditEl.style.display = 'block';
nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select();
};
const commitInspRename = () => {
dispNameEl.style.display = ''; fullIdEl.style.display = '';
nameEditEl.style.display = 'none';
const newId = nameEditEl.value.trim();
if (!newId || newId === voiceId) return;
const rowInput = wrap.querySelector('.vr-name-input');
const rowOk = wrap.querySelector('.rename-ok');
if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); }
};
dispNameEl.addEventListener('dblclick', startInspRename);
fullIdEl.addEventListener('dblclick', startInspRename);
nameEditEl.addEventListener('blur', commitInspRename);
nameEditEl.addEventListener('keydown', e => {
if (e.key === 'Enter') nameEditEl.blur();
if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); }
});
// ── Flag (accent/country) — decoupled from language ──────────────────────
const flagSpan = inspector.querySelector('.insp-flag');
const flagIconEl = inspector.querySelector('.insp-flag-icon');
const applyFlag = async (cc) => {
v.flag = cc; wrap.dataset.flagCc = cc;
const fi = `<span class="fi fi-${cc.toLowerCase()}" role="img" aria-label="${cc}"></span>`;
if (flagIconEl) flagIconEl.innerHTML = fi;
await saveMeta(voiceId, { flag: cc });
};
flagSpan.addEventListener('dblclick', () => {
const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]);
createSearchablePicker(flagSpan, items, applyFlag, {
placeholder: 'Country or accent…',
renderItem: (cc, name) =>
`<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span><span>${escHtml(name)}</span>`,
});
});
// ── Language — double-click lang code to change ───────────────────────────
const langCodeEl = inspector.querySelector('.insp-lang-code');
const langWrap = inspector.querySelector('.insp-lang-wrap');
const applyLang = async (newLang) => {
v.lang = newLang; wrap.dataset.lang = newLang;
if (langCodeEl) langCodeEl.textContent = newLang;
await saveMeta(voiceId, { lang: newLang });
};
langWrap?.addEventListener('dblclick', () => {
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
createSearchablePicker(langWrap, items, applyLang, {
placeholder: 'Language…',
renderItem: (l, label) =>
`<span class="ipi-code">${escHtml(l)}</span><span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`,
});
});
// ── Gender label (subtitle) — click to cycle ──────────────────────────────
const genderLabelEl = inspector.querySelector('.insp-gender-label');
const genderSel = inspector.querySelector('.insp-gender-sel');
const applyGender = async (ng) => {
v.gender = ng;
if (genderLabelEl) genderLabelEl.textContent = `${_gM[ng]||'?'} ${_gL[ng]||'—'}`;
if (genderSel) genderSel.value = ng;
const gBadge = wrap.querySelector('.gender-badge');
if (gBadge) {
gBadge.innerHTML = `<span class="gender-sym">${_gM[ng]||'?'}</span><span class="gender-txt">${_gL[ng]||'—'}</span>`;
gBadge.className = 'gender-badge ' + (_gC[ng]||'g-n');
}
await saveMeta(voiceId, { gender: ng });
};
genderLabelEl?.addEventListener('click', () => {
const cycle = ['F','M','N'];
applyGender(cycle[(cycle.indexOf(v.gender||'N')+1)%3]);
});
genderSel?.addEventListener('change', () => applyGender(genderSel.value));
// ── Tag input — comma-separated, autocomplete from DB + localStorage ────────
const tagInput = inspector.querySelector('.insp-tag-input');
const getAllKnownTags = () => {
const fromDb = (_voices || []).flatMap(vv =>
(vv.tag || '').split(',').map(t => t.trim()).filter(Boolean));
const fromStorage = getStoredTags();
return [...new Set([...fromStorage, ...fromDb])].sort((a, b) => a.localeCompare(b));
};
const tagLastToken = val => val.split(',').pop().trimStart();
const tagReplaceLastToken = (val, rep) => {
const parts = val.split(',');
parts[parts.length - 1] = parts.length > 1 ? ' ' + rep : rep;
return parts.join(',');
};
let _tagDrop = null;
const hideTagDrop = () => { _tagDrop?.remove(); _tagDrop = null; };
const showTagDrop = () => {
hideTagDrop();
const token = tagLastToken(tagInput.value);
const all = getAllKnownTags();
const matches = all.filter(t =>
t.toLowerCase().startsWith(token.toLowerCase()) && t.toLowerCase() !== token.toLowerCase()
);
if (!matches.length) return;
_tagDrop = document.createElement('div');
_tagDrop.className = 'tag-suggest';
matches.slice(0, 10).forEach(tag => {
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'tag-suggest-item';
btn.textContent = tag;
btn.addEventListener('mousedown', e => {
e.preventDefault();
tagInput.value = tagReplaceLastToken(tagInput.value, tag);
tagInput.dispatchEvent(new Event('input'));
hideTagDrop();
tagInput.focus();
});
_tagDrop.appendChild(btn);
});
document.body.appendChild(_tagDrop);
const r = tagInput.getBoundingClientRect();
_tagDrop.style.top = (r.bottom + 3) + 'px';
_tagDrop.style.left = r.left + 'px';
_tagDrop.style.minWidth = Math.max(r.width, 140) + 'px';
};
tagInput.addEventListener('input', () => showTagDrop());
tagInput.addEventListener('focus', () => showTagDrop());
tagInput.addEventListener('blur', () => setTimeout(hideTagDrop, 150));
tagInput.addEventListener('keydown', e => {
if (e.key === 'Escape') hideTagDrop();
if (e.key === ',' && _tagDrop) setTimeout(showTagDrop, 10);
});
const saveTagValue = debounce(async () => {
const tags = tagInput.value.split(',').map(t => t.trim()).filter(Boolean);
tags.forEach(addStoredTag);
v.tag = tagInput.value;
await saveMeta(voiceId, { tag: tagInput.value });
}, 700);
tagInput.addEventListener('input', saveTagValue);
tagInput.addEventListener('change', saveTagValue);
// ── Interactive rating (subtitle + meta kept in sync) ─────────────────────
const updateRating = async (newRating) => {
v.rating = newRating; wrap.dataset.rating = newRating;
inspector.querySelectorAll('.insp-star').forEach(s =>
s.classList.toggle('on', parseInt(s.dataset.val) <= newRating));
inspector.querySelectorAll('.insp-rating-label, .insp-meta-rating-label').forEach(el =>
el.textContent = `${newRating}/5`);
wrap.querySelectorAll('.vr-rating .star').forEach((s, i) =>
s.classList.toggle('on', i < newRating));
await saveMeta(voiceId, { rating: newRating });
};
['.insp-stars', '.insp-meta-stars'].forEach(sel => {
const stars = [...inspector.querySelectorAll(`${sel} .insp-star`)];
stars.forEach(s => {
s.addEventListener('click', () => {
const val = parseInt(s.dataset.val);
updateRating(val === (v.rating||0) ? 0 : val);
});
s.addEventListener('mouseenter', () => {
const val = parseInt(s.dataset.val);
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= val));
});
s.addEventListener('mouseleave', () =>
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= (v.rating||0))));
});
});
// ── Note row ──────────────────────────────────────────────────────────────
const noteEl = detailRow?.querySelector('.vr-note');
const noteRowEl = inspector.querySelector('.insp-note-slot');
if (noteEl && noteRowEl) noteRowEl.appendChild(noteEl);
// ── Move main-row and optimizer into inspector body ───────────────────────
const mainRow = wrap.querySelector('.vr-main-row');
const optimizer = wrap.querySelector('.vr-optimizer');
if (mainRow) body.appendChild(mainRow);
if (optimizer) body.appendChild(optimizer);
if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e));
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
if (maintTitle) {
maintTitle.innerHTML = `Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span> <span class="opt-chevron">&#8964;</span>`;
}
// ── Collapsible opt-groups ────────────────────────────────────────────────
body.querySelectorAll('.opt-group').forEach(group => {
const title = group.querySelector(':scope > .opt-group-title');
if (!title) return;
title.addEventListener('click', () => group.classList.toggle('open'));
});
// Track extracted elements for restoreToRow
wrap._extracted = [
activeEl ? {el: activeEl, target: detailRow} : null,
deleteEl ? {el: deleteEl, target: detailRow} : null,
noteEl ? {el: noteEl, target: detailRow} : null,
].filter(Boolean);
}
let _toastTimer;
function toast(msg, type = '') {
const el = $('toast'); el.textContent = msg; el.className = 'show ' + type;
clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.className = '', 3500);
}
function status(msg) { $('status-bar').textContent = msg; }
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
} catch(e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
function fmtDuration(sec) {
if (sec == null || Number.isNaN(Number(sec))) return '-';
const total = Math.max(0, Math.round(Number(sec)));
const m = Math.floor(total / 60), s = total % 60;
return `${m}:${String(s).padStart(2,'0')}`;
}
function loadingMarkup(title, detail = '', rows = 4) {
const skeleton = Array.from({length: rows}, () => '<div class="skeleton-row"></div>').join('');
return `
<div class="loading-panel" role="status" aria-live="polite">
<div class="loading-box">
<div class="loading-head"><span class="spinner" aria-hidden="true"></span><span>${escHtml(title)}</span></div>
${detail ? `<div class="loading-sub">${escHtml(detail)}</div>` : ''}
<div class="skeleton-stack">${skeleton}</div>
</div>
</div>`;
}
function setBusyButton(id, busy) {
const el = $(id);
if (el) el.disabled = !!busy;
}
async function clientAutoTrimBounds(fid) {
const resp = await fetch('/api/audio/' + encodeURIComponent(fid));
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));
const samples = buffer.getChannelData(0);
const sr = buffer.sampleRate;
const dur = buffer.duration;
if (dur <= 20) return {start:0, end:dur, duration:dur, reason:'Audio is already short enough.'};
const chunkSec = 0.25, chunkSize = Math.max(1, Math.floor(sr * chunkSec));
const chunks = [];
for (let i = 0; i < samples.length; i += chunkSize) {
let sum = 0, peak = 0, n = Math.min(chunkSize, samples.length - i);
for (let j = 0; j < n; j++) {
const v = samples[i + j];
sum += v * v;
peak = Math.max(peak, Math.abs(v));
}
const rms = Math.sqrt(sum / Math.max(1, n));
const db = rms > 0 ? 20 * Math.log10(rms) : -80;
chunks.push({db, speech:false, clipped:peak > 0.96});
}
const avgDb = chunks.reduce((a,c)=>a+c.db,0) / chunks.length;
const floor = Math.max(avgDb - 18, -45);
chunks.forEach(c => c.speech = c.db >= floor);
function scoreWindow(startSec, lengthSec) {
const first = Math.floor(startSec / chunkSec);
const last = Math.min(chunks.length, Math.ceil((startSec + lengthSec) / chunkSec));
const win = chunks.slice(first, last);
if (!win.length) return {score:-9999};
const speech = win.filter(c => c.speech);
const speechRatio = speech.length / win.length;
const silenceRatio = 1 - speechRatio;
const clipRatio = win.filter(c => c.clipped).length / win.length;
const speechAvg = speech.length ? speech.reduce((a,c)=>a+c.db,0) / speech.length : -80;
const variance = speech.length ? speech.reduce((a,c)=>a+Math.pow(c.db-speechAvg,2),0) / speech.length : 100;
const score = speechRatio * 100 - silenceRatio * 55 - clipRatio * 85 - Math.abs(speechAvg + 20) * 1.7 - Math.min(18, Math.sqrt(variance) * 1.4) - Math.abs(lengthSec - 12) * 0.9;
return {score, speechRatio, silenceRatio, speechAvg};
}
let best = null;
[8,10,12,15,18].forEach(length => {
if (length > dur) return;
for (let start = 0; start <= dur - length; start += 0.5) {
const s = scoreWindow(start, length);
if (!best || s.score > best.score) best = {start, end:start + length, duration:length, ...s};
}
});
if (!best) return {start:0, end:Math.min(12,dur), duration:Math.min(12,dur), reason:'Using the beginning because no stable speech window was found.'};
return {
start:Number(best.start.toFixed(2)),
end:Number(best.end.toFixed(2)),
duration:Number(best.duration.toFixed(2)),
reason:`Selected ${best.duration.toFixed(1)}s with ${Math.round(best.speechRatio*100)}% speech, ${Math.round(best.silenceRatio*100)}% silence, avg ${best.speechAvg.toFixed(1)} dBFS.`
};
}
// ── Light / dark theme ────────────────────────────────────────────────────
function applyTheme(t) {
document.documentElement.dataset.theme = t;
$('theme-btn').textContent = t === 'dark' ? '☀️' : '🌙';
$('theme-btn').title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
localStorage.setItem('vcf-theme', t);
}
$('theme-btn').addEventListener('click', () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
);
applyTheme(localStorage.getItem('vcf-theme') || 'dark');
// ── Language helpers ──────────────────────────────────────────────────────
function cc2flag(cc) {
if (!cc || cc.length !== 2) return '🌐';
return cc.toUpperCase().replace(/./g, c => String.fromCodePoint(c.charCodeAt(0) + 127397));
}
// Display label for a country code (3-letter where conventional)
const CC_DISPLAY = {
AU:'AUS', NZ:'NZL', GB:'GB', US:'US', CA:'CA', IE:'IRE', ZA:'ZAF', IN:'IND', SG:'SGP', PH:'PHL', NG:'NGA', KE:'KEN', GH:'GHA', JM:'JAM', TT:'TTO', MT:'MLT',
CH:'CH', AT:'AT', BE:'BE', CN:'CN', TW:'TWN', HK:'HKG', MO:'MAC', JP:'JP', KR:'KR', VN:'VNM', TH:'THA', ID:'IDN', MY:'MYS', PK:'PAK', BD:'BGD', LK:'LKA', NP:'NPL', IR:'IRN', IL:'ISR',
MX:'MEX', AR:'ARG', CO:'COL', CL:'CHL', PE:'PER', VE:'VEN', UY:'URY', EC:'ECU', BO:'BOL', CR:'CRI', CU:'CUB', DO:'DOM',
BR:'BRA', SA:'SAU', EG:'EGY', AE:'UAE', MA:'MAR', QA:'QAT', KW:'KWT', OM:'OMN', JO:'JOR', LB:'LBN', IQ:'IRQ',
DK:'DNK', NO:'NOR', SE:'SE', FI:'FIN', IS:'ISL', NL:'NL', LU:'LUX', LI:'LIE', FR:'FR', DE:'DE', ES:'ES', PT:'PT', IT:'IT', GR:'GRC', CY:'CYP', TR:'TR', PL:'PL', CZ:'CZE', SK:'SVK', HU:'HUN', RO:'ROU', BG:'BGR', HR:'HRV', SI:'SVN', RS:'SRB', BA:'BIH', ME:'MNE', MK:'MKD', AL:'ALB', EE:'EST', LV:'LVA', LT:'LTU', UA:'UKR', RU:'RU', BY:'BLR', MD:'MDA'
};
function ccDisplay(cc) { return CC_DISPLAY[cc] || cc; }
const EUROPE_FLAGS = [
['GB','GB - British'], ['IE','IRE - Irish'], ['DE','DE - German'], ['AT','AT - Austrian'], ['CH','CH - Swiss'], ['FR','FR - French'], ['BE','BE - Belgian'], ['NL','NL - Dutch'], ['LU','LUX - Luxembourgish'], ['LI','LIE - Liechtenstein'],
['ES','ES - Spanish'], ['PT','PT - Portuguese'], ['IT','IT - Italian'], ['MT','MLT - Maltese'], ['GR','GRC - Greek'], ['CY','CYP - Cypriot'],
['DK','DNK - Danish'], ['NO','NOR - Norwegian'], ['SE','SE - Swedish'], ['FI','FIN - Finnish'], ['IS','ISL - Icelandic'],
['PL','PL - Polish'], ['CZ','CZE - Czech'], ['SK','SVK - Slovak'], ['HU','HUN - Hungarian'], ['RO','ROU - Romanian'], ['BG','BGR - Bulgarian'], ['HR','HRV - Croatian'], ['SI','SVN - Slovenian'], ['RS','SRB - Serbian'], ['BA','BIH - Bosnian'], ['ME','MNE - Montenegrin'], ['MK','MKD - Macedonian'], ['AL','ALB - Albanian'],
['EE','EST - Estonian'], ['LV','LVA - Latvian'], ['LT','LTU - Lithuanian'], ['UA','UKR - Ukrainian'], ['RU','RU - Russian'], ['BY','BLR - Belarusian'], ['MD','MDA - Moldovan'], ['TR','TR - Turkish'],
];
const ASIA_FLAGS = [
['CN','CN - Mainland Chinese'], ['TW','TWN - Taiwanese'], ['HK','HKG - Hong Kong'], ['MO','MAC - Macau'], ['SG','SGP - Singapore'], ['JP','JP - Japanese'], ['KR','KR - Korean'],
['VN','VNM - Vietnamese'], ['TH','THA - Thai'], ['ID','IDN - Indonesian'], ['MY','MYS - Malaysian'], ['PH','PHL - Filipino'], ['IN','IND - Indian'], ['PK','PAK - Pakistani'], ['BD','BGD - Bangladeshi'], ['LK','LKA - Sri Lankan'], ['NP','NPL - Nepali'],
['SA','SAU - Saudi'], ['AE','UAE - Emirati'], ['QA','QAT - Qatari'], ['KW','KWT - Kuwaiti'], ['OM','OMN - Omani'], ['JO','JOR - Jordanian'], ['LB','LBN - Lebanese'], ['IQ','IRQ - Iraqi'], ['IR','IRN - Iranian'], ['IL','ISR - Israeli'],
];
const ENGLISH_FLAGS = [
['GB','GB - British'], ['US','US - American'], ['CA','CA - Canadian'], ['AU','AUS - Australian'], ['NZ','NZL - New Zealand'], ['IE','IRE - Irish'], ['ZA','ZAF - South African'], ['IN','IND - Indian English'], ['SG','SGP - Singapore English'], ['PH','PHL - Filipino English'], ['NG','NGA - Nigerian English'], ['KE','KEN - Kenyan English'], ['GH','GHA - Ghanaian English'], ['JM','JAM - Jamaican English'], ['TT','TTO - Trinidad and Tobago English'], ['MT','MLT - Maltese English'],
];
const LATAM_FLAGS = [
['MX','MEX - Mexican'], ['AR','ARG - Argentine'], ['CO','COL - Colombian'], ['CL','CHL - Chilean'], ['PE','PER - Peruvian'], ['VE','VEN - Venezuelan'], ['UY','URY - Uruguayan'], ['EC','ECU - Ecuadorian'], ['BO','BOL - Bolivian'], ['CR','CRI - Costa Rican'], ['CU','CUB - Cuban'], ['DO','DOM - Dominican'], ['BR','BRA - Brazilian'],
];
function uniqueFlagOptions(groups) {
const seen = new Set(), out = [];
groups.flat().forEach(item => { if (item && !seen.has(item[0])) { seen.add(item[0]); out.push(item); } });
return out;
}
const FLAG_OPTIONS = {
EN: ENGLISH_FLAGS,
DE: uniqueFlagOptions([[['DE','DE - German'],['AT','AT - Austrian'],['CH','CH - Swiss (DE)']], EUROPE_FLAGS]),
FR: uniqueFlagOptions([[['FR','FR - French'],['BE','BE - Belgian'],['CH','CH - Swiss (FR)'],['CA','CA - Canadian']], EUROPE_FLAGS]),
ZH: uniqueFlagOptions([[['CN','CN - Mainland'],['TW','TWN - Taiwanese'],['HK','HKG - Hong Kong'],['SG','SGP - Singaporean']], ASIA_FLAGS]),
ES: uniqueFlagOptions([[['ES','ES - Spain']], LATAM_FLAGS, EUROPE_FLAGS]),
PT: uniqueFlagOptions([[['PT','PT - Portuguese'],['BR','BRA - Brazilian']], EUROPE_FLAGS, LATAM_FLAGS]),
AR: uniqueFlagOptions([[['SA','SAU - Saudi'],['EG','EGY - Egyptian'],['AE','UAE - Emirati'],['MA','MAR - Moroccan']], ASIA_FLAGS]),
NL: uniqueFlagOptions([[['NL','NL - Dutch'],['BE','BE - Belgian']], EUROPE_FLAGS]),
JA: uniqueFlagOptions([[['JP','JP - Japanese']], ASIA_FLAGS]),
KO: uniqueFlagOptions([[['KR','KR - Korean']], ASIA_FLAGS]),
IT: uniqueFlagOptions([[['IT','IT - Italian']], EUROPE_FLAGS]),
RU: uniqueFlagOptions([[['RU','RU - Russian']], EUROPE_FLAGS, ASIA_FLAGS]),
PL: uniqueFlagOptions([[['PL','PL - Polish']], EUROPE_FLAGS]),
SV: uniqueFlagOptions([[['SE','SE - Swedish']], EUROPE_FLAGS]),
TR: uniqueFlagOptions([[['TR','TR - Turkish']], EUROPE_FLAGS, ASIA_FLAGS]),
HI: uniqueFlagOptions([[['IN','IND - Indian']], ASIA_FLAGS]),
};
const LANG_FLAG_DEFAULT = {
EN:'GB', DE:'DE', ZH:'CN', FR:'FR', ES:'ES', JA:'JP', KO:'KR',
IT:'IT', PT:'BR', RU:'RU', AR:'SA', PL:'PL', NL:'NL', SV:'SE', TR:'TR', HI:'IN',
};
const ALL_FLAGS = uniqueFlagOptions([ENGLISH_FLAGS, EUROPE_FLAGS, ASIA_FLAGS, LATAM_FLAGS, [
['EG','EGY - Egyptian'], ['MA','MAR - Moroccan'], ['ZA','ZAF - South African'], ['NG','NGA - Nigerian'], ['KE','KEN - Kenyan'], ['GH','GHA - Ghanaian'],
]]);
// ── Preview sample texts per language ────────────────────────────────────
const VL_SAMPLE_TEXTS = {
EN: 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.',
DE: 'Hallo, wie geht es Ihnen heute? Bitte lesen Sie diesen Text deutlich vor — für einen fairen Stimmvergleich.',
FR: 'Bonjour, comment allez-vous aujourd\'hui ? Veuillez lire ce texte clairement pour un test vocal équitable.',
ES: '¡Hola! ¿Cómo estás hoy? Por favor, lee este texto con claridad para una evaluación justa de la voz.',
PT: 'Olá, como vai você hoje? Por favor, leia este texto claramente para uma avaliação justa da voz.',
IT: 'Ciao, come stai oggi? Per favore leggi questo testo in modo chiaro per una valutazione equa della voce.',
NL: 'Hallo, hoe gaat het vandaag? Lees dit voorbeeld alstublieft duidelijk voor een eerlijke stembeoordeling.',
PL: 'Cześć, jak się dziś masz? Przeczytaj proszę ten tekst wyraźnie, aby dokonać rzetelnej oceny głosu.',
SV: 'Hej, hur mår du idag? Vänligen läs detta exempel tydligt för en rättvis röstbedömning.',
DA: 'Hej, hvordan har du det i dag? Læs venligst dette eksempel tydeligt for en retfærdig stemmevurdering.',
NB: 'Hei, hvordan har du det i dag? Vennligst les dette eksempelet tydelig for en rettferdig stemmevurdering.',
FI: 'Hei, kuinka voit tänään? Lue tämä esimerkki selkeästi reilua ääniarviointia varten.',
HU: 'Szia, hogy vagy ma? Kérlek, olvasd fel ezt a szöveget érthetően az igazságos hangértékelés érdekében.',
CS: 'Dobrý den, jak se máte? Přečtěte prosím tento text zřetelně pro spravedlivé hodnocení hlasu.',
RO: 'Bună ziua, cum vă simțiți astăzi? Vă rugăm citiți acest text clar pentru o evaluare corectă a vocii.',
UK: 'Привіт, як ви сьогодні? Будь ласка, прочитайте цей текст чітко для справедливого тестування голосу.',
RU: 'Здравствуйте, как вы сегодня? Пожалуйста, прочитайте этот текст чётко для справедливого тестирования голоса.',
TR: 'Merhaba, bugün nasılsınız? Adil bir ses değerlendirmesi için lütfen bu örneği açıkça okuyun.',
AR: 'مرحباً، كيف حالك اليوم؟ يرجى قراءة هذا النص بوضوح لإجراء اختبار صوت عادل.',
HI: 'नमस्ते, आप आज कैसे हैं? कृपया इस नमूने को स्पष्ट रूप से पढ़ें ताकि एक उचित आवाज़ मूल्यांकन हो सके।',
ZH: '你好,你今天怎么样?请清晰地朗读这段示例,以便进行公正的语音评测。',
JA: 'こんにちは、今日はいかがですか?公平な音声評価のために、このサンプルをはっきりと読んでください。',
KO: '안녕하세요, 오늘 어떠세요? 공정한 음성 벤치마크를 위해 이 샘플을 명확하게 읽어주세요.',
};
const _VL_LANG_FLAG = Object.assign({ DA:'DK', NB:'NO', FI:'FI', HU:'HU', CS:'CZ', RO:'RO', UK:'UA' }, LANG_FLAG_DEFAULT);
function setPreviewLang(lang) {
const ta = $('vl-preview-sample');
const hidden = $('benchmark-sample-text');
if (!ta) return;
const text = VL_SAMPLE_TEXTS[lang] || VL_SAMPLE_TEXTS.EN;
ta.value = text;
ta.dispatchEvent(new Event('input'));
if (hidden) { hidden.value = text; hidden.dispatchEvent(new Event('input')); }
try { localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); } catch {}
const btn = $('vl-preview-lang-btn');
if (btn) {
const cc = (_VL_LANG_FLAG[lang] || 'gb').toLowerCase();
btn.innerHTML = `<span class="fi fi-${cc}"></span>`;
btn.title = (LANGUAGE_LABELS[lang] || lang) + ' — click to change';
btn.dataset.lang = lang;
}
}
function openPreviewLangPicker(anchor) {
const items = Object.keys(VL_SAMPLE_TEXTS).map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
createSearchablePicker(anchor, items, setPreviewLang, {
placeholder: 'Sample language…',
renderItem: (l) => {
const cc = (_VL_LANG_FLAG[l] || 'gb').toLowerCase();
return `<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span>`
+ `<span class="ipi-code">${escHtml(l)}</span>`
+ `<span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`;
},
});
}
// ── Searchable picker dropdown ────────────────────────────────────────────
function createSearchablePicker(anchor, items, onSelect, { placeholder = 'Search…', renderItem } = {}) {
document.querySelector('.insp-picker')?.remove();
const panel = document.createElement('div');
panel.className = 'insp-picker';
const search = document.createElement('input');
search.type = 'search'; search.className = 'insp-picker-search';
search.placeholder = placeholder; search.autocomplete = 'off';
const list = document.createElement('div');
list.className = 'insp-picker-list';
const renderList = (q) => {
const f = q.trim().toLowerCase();
const filtered = f
? items.filter(([code, name]) => name.toLowerCase().includes(f) || code.toLowerCase().includes(f))
: items;
list.innerHTML = '';
if (!filtered.length) { list.innerHTML = '<div class="insp-picker-empty">No matches</div>'; return; }
filtered.forEach(([code, name]) => {
const btn = document.createElement('button');
btn.className = 'insp-picker-item'; btn.type = 'button';
btn.innerHTML = renderItem ? renderItem(code, name) : `<span class="ipi-code">${escHtml(code)}</span><span>${escHtml(name)}</span>`;
btn.addEventListener('mousedown', (e) => { e.preventDefault(); onSelect(code); panel.remove(); cleanup(); });
list.appendChild(btn);
});
};
search.addEventListener('input', () => renderList(search.value));
renderList('');
panel.appendChild(search); panel.appendChild(list);
document.body.appendChild(panel);
const rect = anchor.getBoundingClientRect();
const pw = 244;
let left = rect.left;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
panel.style.top = (rect.bottom + 4) + 'px';
panel.style.left = Math.max(8, left) + 'px';
const cleanup = () => { document.removeEventListener('mousedown', onOut); document.removeEventListener('keydown', onKey); };
const onOut = (e) => { if (!panel.contains(e.target)) { panel.remove(); cleanup(); } };
const onKey = (e) => { if (e.key === 'Escape') { panel.remove(); cleanup(); } };
setTimeout(() => { document.addEventListener('mousedown', onOut); document.addEventListener('keydown', onKey); }, 0);
search.focus();
}
// ── Tag reuse (localStorage) ──────────────────────────────────────────────
const _TAG_KEY = 'ttsvc-voice-tags';
function getStoredTags() {
try { return JSON.parse(localStorage.getItem(_TAG_KEY) || '[]'); } catch { return []; }
}
function addStoredTag(tag) {
const t = tag.trim(); if (!t) return;
const tags = getStoredTags().filter(x => x !== t);
tags.unshift(t);
try { localStorage.setItem(_TAG_KEY, JSON.stringify(tags.slice(0, 60))); } catch {}
}
// ── Tabs ──────────────────────────────────────────────────────────────────
function disabledBackendTabMessage(tab) {
const required = tab?.dataset.backendRequired || '';
if (required === 'any_tts') return 'No Qwen3-TTS backend is running or configured. Check Settings.';
const backend = (_ttsBackends || []).find(b => b.id === required);
const label = backend?.label || required.replace(/_/g, ' ');
const url = backend?.url ? ` (${backend.url})` : '';
return `${label}${url} is not running or not configured in Settings.`;
}
function switchTab(name) {
const targetTab = document.querySelector(`.tab[data-tab="${name}"]`);
if (targetTab?.classList.contains('backend-unavailable')) {
toast(disabledBackendTabMessage(targetTab), 'error');
return false;
}
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
if (name === 'library') loadVoiceLibrary();
if (name === 'integrations') {
if (!_voices.length) loadVoiceLibrary();
renderIntegrationSnippets();
}
if (name === 'routing') loadRoutingTab();
if (name === 'getvoices') loadGetVoices();
return true;
}
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab)));
document.addEventListener('click', e => {
const btn = e.target.closest('.backend-jump');
if (!btn) return;
switchTab('generation');
const backend = btn.dataset.backend;
const sel = $('tts-backend-select');
if (sel && [...sel.options].some(o => o.value === backend)) {
sel.value = backend;
sel.dispatchEvent(new Event('change'));
}
});
// -- Get voices ---------------------------------------------------------------
const DEFAULT_VOICE_SOURCE_URLS = [
'https://aiartes.com/voiceai',
'https://sample-files.com/downloads/audio/wav/voice-sample.wav',
'https://freesound.org/people/Scott%20Simpson/',
'https://lanceblairvo.com/raw-voiceover-samples/',
'https://github.com/yaph/tts-samples/tree/main/mp3',
'https://github.com/jim-schwoebel/voice_datasets',
];
const VOICE_SOURCE_STORAGE_KEY = 'ttsvc-getvoices-sources';
let _voiceSourcePayload = null;
let _voiceSourceItems = [];
function sourceTextareaValueFromDefaults() {
return DEFAULT_VOICE_SOURCE_URLS.join('\n');
}
function initGetVoiceSourcesEditor() {
const box = $('getvoices-sources');
if (!box || box.dataset.ready) return;
box.value = localStorage.getItem(VOICE_SOURCE_STORAGE_KEY) || sourceTextareaValueFromDefaults();
box.dataset.ready = '1';
box.addEventListener('input', () => {
localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value);
_voiceSourcePayload = null;
$('getvoices-status').textContent = 'Source list changed.';
});
}
function getEditableVoiceSourceUrls() {
initGetVoiceSourcesEditor();
return ($('getvoices-sources')?.value || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
}
function getVoiceSourceItems() {
const sources = (_voiceSourcePayload && _voiceSourcePayload.sources) || [];
return sources.flatMap(src => (src.items || []).map(item => ({...item, _sourceName: src.name, _sourceHomepage: src.homepage})));
}
function voiceSourceSearchText(item) {
return [item.name, item.kind, item.category, item.language, item.gender, item.description, item.source, item._sourceName].join(' ').toLowerCase();
}
function setOptions(selectId, values, allLabel) {
const sel = $(selectId);
if (!sel) return;
const current = sel.value || 'all';
sel.innerHTML = `<option value="all">${escHtml(allLabel)}</option>` + values.map(value => `<option value="${escHtml(value)}">${escHtml(value)}</option>`).join('');
sel.value = values.includes(current) ? current : 'all';
}
function renderGetVoices() {
initGetVoiceSourcesEditor();
const list = $('getvoices-list');
const summary = $('getvoices-summary');
if (!list || !summary) return;
const payload = _voiceSourcePayload || {sources:[], total:0, direct_audio:0, errors:[]};
const sources = payload.sources || [];
const sourceFilter = $('getvoices-source-filter')?.value || 'all';
const languageFilter = $('getvoices-language-filter')?.value || 'all';
const genderFilter = $('getvoices-gender-filter')?.value || 'all';
const filetypeFilter = $('getvoices-filetype-filter')?.value || 'all';
const q = ($('getvoices-search')?.value || '').trim().toLowerCase();
const directOnly = !!$('getvoices-direct-only')?.checked;
_voiceSourceItems = getVoiceSourceItems();
summary.innerHTML = [
[`${payload.total || 0}`, 'Items found'],
[`${payload.direct_audio || 0}`, 'Direct audio'],
[`${sources.length}`, 'Sources OK'],
[`${(payload.errors || []).length}`, 'Errors'],
].map(([value, label]) => `<div class="insight"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
setOptions('getvoices-source-filter', sources.map(src => src.id).filter(Boolean), 'Source: all');
const sourceSelect = $('getvoices-source-filter');
if (sourceSelect) {
[...sourceSelect.options].forEach(option => {
if (option.value === 'all') return;
const src = sources.find(s => s.id === option.value);
if (src) option.textContent = src.name || src.id;
});
}
const languages = [...new Set(_voiceSourceItems.map(item => item.language || 'Unknown'))].sort((a, b) => a.localeCompare(b));
const genders = [...new Set(_voiceSourceItems.map(item => item.gender || 'Unknown'))].sort((a, b) => a.localeCompare(b));
const filetypes = [...new Set(_voiceSourceItems.map(item => (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase()))].sort((a, b) => a.localeCompare(b));
setOptions('getvoices-language-filter', languages, 'Language: all');
setOptions('getvoices-gender-filter', genders, 'Sex: all');
setOptions('getvoices-filetype-filter', filetypes, 'Filetype: all');
let items = _voiceSourceItems.filter(item => {
if (sourceFilter !== 'all' && item.source_id !== sourceFilter) return false;
if (languageFilter !== 'all' && (item.language || 'Unknown') !== languageFilter) return false;
if (genderFilter !== 'all' && (item.gender || 'Unknown') !== genderFilter) return false;
const itemFiletype = (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase();
if (filetypeFilter !== 'all' && itemFiletype !== filetypeFilter) return false;
if (directOnly && !item.direct_audio) return false;
if (q && !voiceSourceSearchText(item).includes(q)) return false;
return true;
});
const shown = items.slice(0, 240);
const more = items.length - shown.length;
if (!shown.length) {
const errorText = (payload.errors || []).map(e => `${e.source}: ${e.detail}`).join(' | ');
list.innerHTML = `<div class="card"><p class="note">No matching sources found.${errorText ? ' Source errors: ' + escHtml(errorText) : ''}</p></div>`;
return;
}
list.innerHTML = shown.map(item => {
const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="">` : `<div class="voice-source-thumb"></div>`;
const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : '';
const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : '';
const canGetVoice = !!item.audio_url;
const getVoice = canGetVoice ? `<button class="btn-primary get-source-voice" data-url="${escHtml(item.audio_url)}" data-name="${escHtml(item.name || '')}" data-image="${escHtml(item.image_url || '')}" data-language="${escHtml(item.language || '')}" data-gender="${escHtml(item.gender || '')}" data-kind="${escHtml(item.kind || '')}" data-description="${escHtml(item.description || '')}" data-page="${escHtml(item.page_url || item._sourceHomepage || '')}">Import this voice</button>` : '';
const type = item.file_type ? `<span class="voice-source-pill">${escHtml(String(item.file_type).toUpperCase())}</span>` : '';
const language = item.language ? `<span class="voice-source-pill">${escHtml(item.language)}</span>` : '';
const gender = item.gender ? `<span class="voice-source-pill">${escHtml(item.gender)}</span>` : '';
return `<div class="voice-source-card">
<div class="voice-source-head">
${thumb}
<div class="voice-source-title">
<strong title="${escHtml(item.name || '')}">${escHtml(item.name || 'Untitled')}</strong>
<span>${escHtml(item._sourceName || item.source || '')}${item.category ? ' · ' + escHtml(item.category) : ''}</span>
</div>
</div>
<div class="voice-source-desc">${escHtml(item.kind || '')}${item.description ? ' - ' + escHtml(item.description) : ''}</div>
${audio}
<div class="voice-source-actions">
${type}${language}${gender}
${audioLink}
<a class="btn-secondary" href="${escHtml(item.page_url || item._sourceHomepage || '#')}" target="_blank" rel="noopener">Source page</a>
<button class="btn-secondary copy-source-url" data-url="${escHtml(item.audio_url || item.page_url || '')}">Copy URL</button>
</div>
${getVoice ? `<div class="vs-import-footer">${getVoice}</div>` : ''}
</div>`;
}).join('') + (more > 0 ? `<div class="card"><p class="note">${more} more matches. Narrow the search or filters to see them.</p></div>` : '');
}
async function loadGetVoices(force = false) {
initGetVoiceSourcesEditor();
if (_voiceSourcePayload && !force) { renderGetVoices(); return; }
const urls = getEditableVoiceSourceUrls();
const list = $('getvoices-list');
if (!urls.length) {
if (list) list.innerHTML = '<div class="card"><p class="note">Add at least one source URL, then scrape again.</p></div>';
$('getvoices-status').textContent = 'No sources.';
return;
}
if (list) list.innerHTML = loadingMarkup('Scraping voice sources', `Fetching ${urls.length} source${urls.length === 1 ? '' : 's'} from the editable list.`, 6);
$('getvoices-status').textContent = 'Scraping...';
$('getvoices-refresh-btn').disabled = true;
try {
const r = await fetch('/api/voice-sources', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({urls}),
});
if (!r.ok) {
const text = await r.text().catch(() => '');
let message = r.statusText || `HTTP ${r.status}`;
try { message = JSON.parse(text).detail || message; } catch (_) { if (text) message = text.slice(0, 160); }
throw new Error(message);
}
_voiceSourcePayload = await r.json();
renderGetVoices();
const errors = (_voiceSourcePayload.errors || []).length;
$('getvoices-status').textContent = `${_voiceSourcePayload.total || 0} found${errors ? `, ${errors} source errors` : ''}`;
} catch(e) {
if (list) list.innerHTML = `<div class="card"><p style="color:var(--red)">Scrape failed: ${escHtml(e.message)}</p><p class="note">Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.</p></div>`;
$('getvoices-status').textContent = 'Scrape failed';
toast('Voice source scrape failed: ' + e.message, 'error');
} finally {
$('getvoices-refresh-btn').disabled = false;
}
}
['getvoices-search', 'getvoices-source-filter', 'getvoices-language-filter', 'getvoices-gender-filter', 'getvoices-filetype-filter', 'getvoices-direct-only'].forEach(id => {
const el = $(id);
if (el) el.addEventListener(id === 'getvoices-search' ? 'input' : 'change', renderGetVoices);
});
$('getvoices-refresh-btn')?.addEventListener('click', () => loadGetVoices(true));
$('getvoices-reset-sources-btn')?.addEventListener('click', () => {
const box = $('getvoices-sources');
if (!box) return;
box.value = sourceTextareaValueFromDefaults();
localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value);
_voiceSourcePayload = null;
renderGetVoices();
$('getvoices-status').textContent = 'Source list reset.';
});
const SOURCE_LANGUAGE_CODES = {
english:'EN', german:'DE', deutsch:'DE', french:'FR', spanish:'ES', japanese:'JA', korean:'KO',
italian:'IT', portuguese:'PT', russian:'RU', arabic:'AR', polish:'PL', dutch:'NL', swedish:'SV',
turkish:'TR', hindi:'HI', chinese:'ZH'
};
function sourceLanguageCode(language) {
const raw = String(language || '').trim();
if (/^[A-Z]{2}$/.test(raw)) return raw;
return SOURCE_LANGUAGE_CODES[raw.toLowerCase()] || 'EN';
}
function sourceGenderCode(gender, name = '') {
const text = `${gender || ''} ${name || ''}`.toLowerCase();
if (/female|woman|girl|\bf\b/.test(text)) return 'F';
if (/male|man|boy|\bm\b/.test(text)) return 'M';
return 'N';
}
function suggestedVoiceIdFromSourceName(name, language = 'EN', gender = 'N') {
const base = String(name || 'SourceVoice')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48) || 'SourceVoice';
return `${language || 'EN'}_${gender || 'N'}_${base}`;
}
function setLibAddSourcePreview(meta = {}) {
const sourceBox = document.querySelector('.lib-add-source-box');
if (!sourceBox) return;
let preview = $('lib-add-source-preview');
if (!preview) {
preview = document.createElement('div');
preview.id = 'lib-add-source-preview';
preview.className = 'lib-add-source-preview';
sourceBox.appendChild(preview);
}
if (!meta.name && !meta.imageUrl) {
preview.classList.remove('open');
preview.innerHTML = '';
return;
}
const image = meta.imageUrl ? `<img src="${escHtml(meta.imageUrl)}" alt="">` : '<div class="voice-source-thumb"></div>';
preview.innerHTML = `${image}<div style="min-width:0"><strong>${escHtml(meta.name || 'Source voice')}</strong><span>${escHtml([meta.language, meta.gender, meta.kind].filter(Boolean).join(' · ') || 'Source metadata will be saved with the voice')}</span></div>`;
preview.classList.add('open');
}
async function getSourceVoiceInLibrary(meta) {
if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; }
switchTab('library');
const panel = $('lib-add-panel');
if (panel && !panel.classList.contains('open')) panel.classList.add('open');
const lang = sourceLanguageCode(meta.language);
const gender = sourceGenderCode(meta.gender, meta.name);
if ($('lib-add-url')) $('lib-add-url').value = meta.url;
if ($('lib-add-lang')) $('lib-add-lang').value = lang;
if ($('lib-add-gender')) $('lib-add-gender').value = gender;
if ($('lib-add-voice-id')) $('lib-add-voice-id').value = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
if ($('lib-add-transcript')) $('lib-add-transcript').value = '';
if (window.libAddState) window.libAddState.pendingSource = {...meta, language: lang, gender};
setLibAddSourcePreview({...meta, language: lang, gender});
setLibAddStatus(`Importing source audio: ${meta.name || 'external voice'}...`);
try {
const r = await fetch('/api/import-source-audio', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({audio_url: meta.url, name: meta.name || 'Source voice'})
});
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, meta.name || 'Source voice');
setLibAddStatus(`Source voice loaded: ${meta.name || 'external voice'}${meta.imageUrl ? ' (image will attach on save)' : ''}`);
toast('Voice imported into Voice Clone', 'success');
} catch(e) {
setLibAddStatus('Source import failed');
toast('Source import failed: ' + e.message, 'error');
}
}
$('getvoices-list')?.addEventListener('click', async e => {
const getBtn = e.target.closest('.get-source-voice');
if (getBtn) {
await getSourceVoiceInLibrary({
url: getBtn.dataset.url || '', name: getBtn.dataset.name || '', imageUrl: getBtn.dataset.image || '',
language: getBtn.dataset.language || '', gender: getBtn.dataset.gender || '', kind: getBtn.dataset.kind || '',
description: getBtn.dataset.description || '', pageUrl: getBtn.dataset.page || ''
});
return;
}
const btn = e.target.closest('.copy-source-url');
if (!btn) return;
await copyText(btn.dataset.url || '');
toast('Source URL copied', 'success');
});
// ── Integration samples ──────────────────────────────────────────────────
function cleanBaseUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
function getTtsBaseUrl() {
return cleanBaseUrl($('s-tts-url')?.value) || 'http://localhost:8020';
}
function getTtsV1Url() {
const base = getTtsBaseUrl();
return base.endsWith('/v1') ? base : base + '/v1';
}
function getTtsStreamBaseUrl() {
return cleanBaseUrl($('s-tts-stream-url')?.value || _appSettings.tts_stream_url) || 'http://localhost:8023';
}
function getTtsStreamV1Url() {
const base = getTtsStreamBaseUrl();
return base.endsWith('/v1') ? base : base + '/v1';
}
function getCreatorV1Url() {
const loc = window.location;
const protocol = loc.protocol || 'http:';
const port = loc.port ? ':' + loc.port : '';
const host = loc.hostname === '0.0.0.0' ? 'localhost' : loc.hostname;
return `${protocol}//${host}${port}/v1`;
}
function updateCreatorUrlHints() {
const warning = $('routing-url-warning');
const badBindHost = window.location.hostname === '0.0.0.0';
if (warning) warning.classList.toggle('show', badBindHost);
}
function activeVoiceIds() {
return (_voices || [])
.filter(v => v.enabled !== false)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.map(v => v.id);
}
function integrationVoiceExample() {
return activeVoiceIds()[0] || 'EN_F_ExampleVoice';
}
function integrationVoiceList() {
const ids = activeVoiceIds();
return ids.length ? ids.join(', ') : 'EN_F_ExampleVoice, DE_M_ExampleVoice';
}
function virtualDesignVoiceIds() {
return Object.keys(loadDesignPresets ? loadDesignPresets() : {})
.sort((a,b)=>a.localeCompare(b))
.map(name => 'vd_' + name.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, ''));
}
function renderIntegrationSnippets() {
if (!$('snippet-sillytavern')) return;
const base = getTtsBaseUrl();
const v1 = getTtsV1Url();
const streamV1 = getTtsStreamV1Url();
const proxyV1 = getCreatorV1Url();
updateCreatorUrlHints();
const voice = integrationVoiceExample();
const voices = integrationVoiceList();
const vdVoices = virtualDesignVoiceIds();
const vdVoice = vdVoices[0] || 'vd_EN_F_Warm_Narrator';
$('integration-url-label').textContent = 'TTS backend: ' + base;
$('snippet-sillytavern').textContent =
`Provider: OpenAI compatible TTS
API base URL: ${v1}
API key: dummy
Model: qwen3-tts
Voice: ${voice}
Custom voices: ${voices}
Streaming backend, if your SillyTavern TTS extension supports progressive playback:
API base URL: ${streamV1}
Endpoint: /audio/speech
Format: wav`;
$('snippet-streaming-howto').textContent =
`Direct streaming backend, no creator routing:
API base URL: ${streamV1}
Endpoint: /audio/speech
Model: tts-1
Voice: ${voice}
Response format: wav
Requirement: the app must start playback while the HTTP response is still arriving.
Current 8023 streaming service ignores per-request instruct/style text.
Streaming through TTS Voice Creator routing:
API base URL: ${proxyV1}
Voice: default or another incoming route voice
Routing tab: set Backend = Streaming for the matching rule
Response format: wav
Avoid before/after sounds for true streaming; route sounds and MP3 require buffering.
SillyTavern note:
Use OpenAI-compatible TTS if your extension supports progressive audio responses. If it waits for the whole file before playing, streaming works technically but will feel like buffered TTS.`;
$('snippet-open-webui').textContent =
`Admin settings -> Audio -> Text-to-Speech
Engine/provider: OpenAI compatible
API base URL: ${proxyV1}
API key: dummy
Model: tts-1
Voice: default
Routing tab example:
default + EN -> ${voice}
default + DE -> DE_M_YourGermanVoice`;
$('snippet-home-assistant').textContent =
`Home Assistant OpenAI TTS agent:
Base URL: ${proxyV1}
API key: dummy
Model: tts-1
Voice: default
Extra JSON payload: {"app":"Home Assistant"}
Audio format: mp3 or wav
REST command example using routing:
rest_command:
routed_tts:
url: "${proxyV1}/audio/speech"
method: POST
content_type: "application/json"
headers:
Authorization: "Bearer dummy"
payload: >
{"model":"tts-1","voice":"default","input":"{{ text }}","response_format":"mp3","app":"Home Assistant"}
Important: use this Creator proxy URL, not the direct Qwen3 backend URL ${v1}. Routing only runs through the Creator proxy.`;
$('snippet-curl').textContent =
`curl -s "${v1}/models"
curl -s "${v1}/audio/speech" \\
-H "Authorization: Bearer dummy" \\
-H "Content-Type: application/json" \\
-d '{"model":"qwen3-tts","voice":"${voice}","input":"Hello from Qwen3 TTS","response_format":"wav"}' \\
--output qwen3-tts-test.wav`;
$('snippet-voice-design-proxy').textContent =
`Virtual VoiceDesign mode - no WAV export needed
Use this app as the OpenAI-compatible TTS endpoint:
API base URL: ${proxyV1}
API key: dummy
Model: tts-1
Voice: ${vdVoice}
Available virtual voices:
${vdVoices.length ? vdVoices.join(', ') : 'Save a Voice Design prompt preset first.'}
curl -s "${proxyV1}/models"
curl -s "${proxyV1}/audio/speech" \\
-H "Authorization: Bearer dummy" \\
-H "Content-Type: application/json" \\
-d '{"model":"tts-1","voice":"${vdVoice}","input":"This line is generated through the VoiceDesign container.","response_format":"wav"}' \\
--output voicedesign-virtual.wav`;
}
document.querySelectorAll('.copy-snippet').forEach(btn => btn.addEventListener('click', async () => {
const el = $(btn.dataset.snippet);
if (!el) return;
await copyText(el.textContent);
toast('Snippet copied', 'success');
}));
$('show-api-btn')?.addEventListener('click', () => {
window.open('/docs', '_blank', 'noopener');
});
$('integration-refresh-btn')?.addEventListener('click', () => {
renderIntegrationSnippets();
toast('Integration examples refreshed', 'success');
});
$('copy-active-voices-btn-integrations')?.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');
});
// ── TTS routing ──────────────────────────────────────────────────────────
let _ttsRoutes = [];
const ROUTE_LANGS = [
['*', 'Any'],
['AUTO', 'Auto'],
['EN', 'English'],
['DE', 'German'],
['FR', 'French'],
['ES', 'Spanish'],
['IT', 'Italian'],
['PT', 'Portuguese'],
['NL', 'Dutch'],
['PL', 'Polish'],
];
function routeSelectOptions(value) {
return ROUTE_LANGS.map(([code, label]) =>
`<option value="${code}" ${code === value ? 'selected' : ''}>${escHtml(label)}</option>`
).join('');
}
const ROUTE_BACKENDS = [
['voice_clone', 'Voice Clone'],
['streaming', 'Streaming'],
['voice_design', 'Voice Design'],
['nvidia_magpie', 'NVIDIA Magpie'],
['nvidia_zeroshot', 'NVIDIA Zeroshot'],
['nvidia_flow', 'NVIDIA Flow'],
];
let _routeSounds = [];
function routeSoundOptions(value = '') {
const current = String(value || '');
const listed = new Set(_routeSounds.map(s => String(s.path || '')));
const options = ['<option value="">Browse sounds</option>'];
if (current && !listed.has(current)) options.push(`<option value="${escHtml(current)}" selected>${escHtml(current)}</option>`);
_routeSounds.forEach(sound => {
const path = String(sound.path || '');
if (!path) return;
const duration = sound.duration != null ? ` · ${Number(sound.duration).toFixed(1)}s` : '';
options.push(`<option value="${escHtml(path)}" ${path === current ? 'selected' : ''}>${escHtml(path + duration)}</option>`);
});
return options.join('');
}
async function loadRouteSounds() {
try {
const r = await fetch('/api/route-sounds');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_routeSounds = Array.isArray(d.sounds) ? d.sounds : [];
} catch (_) {
_routeSounds = [];
}
}
let _routeSoundPickerTarget = null;
let _routeSoundPlayingButton = null;
function routeSoundUrl(path) {
return '/api/route-sounds/file/' + String(path || '').split('/').map(encodeURIComponent).join('/');
}
function setRouteSoundField(row, target, path) {
const field = row?.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound');
if (!field) return;
field.value = path || '';
readRoutingForm();
}
function renderRouteSoundBrowser() {
const list = $('routing-sound-list');
if (!list) return;
const query = String($('routing-sound-search')?.value || '').trim().toLowerCase();
const sounds = query
? _routeSounds.filter(sound => String(sound.path || '').toLowerCase().includes(query) || String(sound.name || '').toLowerCase().includes(query))
: _routeSounds;
if (!_routeSounds.length) {
list.innerHTML = '<div class="routing-log-empty">No sounds found yet. Upload one in a route row; only the selected file is imported.</div>';
return;
}
if (!sounds.length) {
list.innerHTML = '<div class="routing-log-empty">No sounds match this search.</div>';
return;
}
list.innerHTML = sounds.map(sound => {
const path = String(sound.path || '');
const size = sound.size != null ? Math.max(1, Math.round(Number(sound.size) / 1024)) + ' KB' : '';
const duration = sound.duration != null ? Number(sound.duration).toFixed(1) + 's' : size;
const type = sound.type ? String(sound.type).toUpperCase() : '';
const targetLabel = _routeSoundPickerTarget?.target === 'after' ? 'Use after' : 'Use before';
return `
<div class="routing-sound-item" data-path="${escHtml(path)}">
<button class="btn-secondary sound-play" type="button" title="Preview sound">▶</button>
<div class="sound-path" title="${escHtml(path)}">${escHtml(path)}</div>
<div class="sound-meta">${escHtml(duration || '-')} ${type ? '· ' + escHtml(type) : ''}</div>
<button class="btn-secondary sound-use-current" type="button">${escHtml(targetLabel)}</button>
</div>`;
}).join('');
}
async function openRouteSoundBrowser(row, target) {
_routeSoundPickerTarget = {row, target};
await loadRouteSounds();
renderRouteSoundBrowser();
const panel = $('routing-sound-browser');
if (panel) {
panel.hidden = false;
panel.scrollIntoView({block:'nearest', behavior:'smooth'});
}
const label = target === 'before' ? 'before sound' : 'after sound';
const note = $('routing-sound-browser-note');
if (note) note.textContent = `Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`;
}
function closeRouteSoundBrowser() {
const panel = $('routing-sound-browser');
if (panel) panel.hidden = true;
const audio = $('routing-sound-preview');
if (audio) { audio.pause(); audio.hidden = true; audio.removeAttribute('src'); }
if (_routeSoundPlayingButton) _routeSoundPlayingButton.textContent = '▶';
_routeSoundPlayingButton = null;
_routeSoundPickerTarget = null;
}
function playRouteSound(path, btn) {
const audio = $('routing-sound-preview');
if (!audio || !path) return;
if (_routeSoundPlayingButton && _routeSoundPlayingButton !== btn) _routeSoundPlayingButton.textContent = '▶';
_routeSoundPlayingButton = btn;
btn.textContent = '❚❚';
audio.hidden = false;
audio.src = routeSoundUrl(path);
audio.onended = () => { btn.textContent = '▶'; };
audio.onpause = () => { if (_routeSoundPlayingButton === btn) btn.textContent = '▶'; };
audio.onplay = () => { btn.textContent = '❚❚'; };
audio.play().catch(e => {
btn.textContent = '▶';
toast('Sound preview failed: ' + e.message, 'error');
});
}
function useRouteSound(path, target) {
const selected = _routeSoundPickerTarget || {};
const row = selected.row || document.querySelector('.routing-row');
const useTarget = target || selected.target || 'before';
setRouteSoundField(row, useTarget, path);
toast(`${useTarget === 'before' ? 'Before' : 'After'} sound selected`, 'success');
}
function routeBackendOptions(value) {
const current = value || 'voice_clone';
return ROUTE_BACKENDS.map(([code, label]) =>
`<option value="${code}" ${code === current ? 'selected' : ''}>${escHtml(label)}</option>`
).join('');
}
function refreshRoutingVoiceOptions() {
const dl = $('routing-voice-options');
if (dl) {
const ids = [...activeVoiceIds(), ...virtualDesignVoiceIds()];
dl.innerHTML = [...new Set(ids)].map(id => `<option value="${escHtml(id)}"></option>`).join('');
}
const soundsDl = $('routing-sound-options');
if (soundsDl) soundsDl.innerHTML = _routeSounds.map(sound => `<option value="${escHtml(sound.path || '')}"></option>`).join('');
}
function newRoute(app = 'Open WebUI', inputVoice = 'default', language = '*', outputVoice = '') {
return {
id: 'route_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6),
enabled: true,
app,
input_voice: inputVoice,
language,
backend: 'voice_clone',
output_voice: outputVoice,
before_sound: '',
after_sound: '',
};
}
function renderRoutingList() {
if (!$('routing-list')) return;
refreshRoutingVoiceOptions();
$('routing-proxy-url').textContent = getCreatorV1Url();
updateCreatorUrlHints();
$('routing-status').textContent = _ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.';
if (!_ttsRoutes.length) {
$('routing-list').innerHTML = '<div class="note" style="padding:10px">No routing rules yet. Add a route or add the Open WebUI default examples.</div>';
return;
}
$('routing-list').innerHTML = _ttsRoutes.map((r, i) => `
<div class="routing-grid routing-row" data-index="${i}">
<label class="toggle" title="Enable route">
<input type="checkbox" class="route-enabled" ${r.enabled !== false ? 'checked' : ''}>
<span class="t-slider"></span>
</label>
<input class="route-app" value="${escHtml(r.app || 'Open WebUI')}" placeholder="Open WebUI, SillyTavern, Home Assistant, or *" title="Matches app/client JSON, X-TTS-App header, or detected client name. Use * for any app.">
<input class="route-input" value="${escHtml(r.input_voice || 'default')}" placeholder="default">
<select class="route-lang">${routeSelectOptions(String(r.language || '*').toUpperCase())}</select>
<select class="route-backend" title="Voice Clone uses the normal TTS URL, Streaming uses the streaming URL, Voice Design uses vd_ presets, NVIDIA Magpie uses fixed NVIDIA voices, NVIDIA Zeroshot/Flow use saved library WAVs as audio prompts.">${routeBackendOptions(r.backend || 'voice_clone')}</select>
<input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset">
<div class="route-sound-cell">
<input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options">
<button class="btn-secondary route-sound-pick" data-target="before" title="Browse and preview uploaded sounds">Pick</button>
<button class="btn-secondary route-sound-upload" data-target="before" title="Upload one before sound">Upload</button>
</div>
<div class="route-sound-cell">
<input class="route-after-sound" value="${escHtml(r.after_sound || '')}" placeholder="sounds/end.wav" list="routing-sound-options">
<button class="btn-secondary route-sound-pick" data-target="after" title="Browse and preview uploaded sounds">Pick</button>
<button class="btn-secondary route-sound-upload" data-target="after" title="Upload one after sound">Upload</button>
</div>
<button class="btn-secondary routing-delete" title="Delete route">×</button>
</div>
`).join('');
}
function readRoutingForm() {
_ttsRoutes = [...document.querySelectorAll('.routing-row')].map((row, i) => {
const existing = _ttsRoutes[Number(row.dataset.index)] || {};
return {
id: existing.id || `route_${i+1}`,
enabled: row.querySelector('.route-enabled').checked,
app: row.querySelector('.route-app').value.trim() || '*',
input_voice: row.querySelector('.route-input').value.trim() || 'default',
language: row.querySelector('.route-lang').value || '*',
backend: row.querySelector('.route-backend').value || 'voice_clone',
output_voice: row.querySelector('.route-output').value.trim(),
before_sound: row.querySelector('.route-before-sound').value.trim(),
after_sound: row.querySelector('.route-after-sound').value.trim(),
};
});
}
async function loadRoutingTab() {
if (!$('routing-list')) return;
$('routing-proxy-url').textContent = getCreatorV1Url();
updateCreatorUrlHints();
$('routing-status').textContent = 'Loading routing…';
$('routing-list').innerHTML = loadingMarkup('Loading routing', 'Loading active voices and routing rules for the proxy.', 5);
setBusyButton('routing-refresh-btn', true);
try {
if (!_voices.length) await loadVoiceLibrary();
await loadRouteSounds();
const r = await fetch('/api/tts-routes');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_ttsRoutes = Array.isArray(d.routes) ? d.routes : [];
renderRoutingList();
status('Routing loaded');
loadRoutingLog();
} catch(e) {
$('routing-status').textContent = 'Load failed';
$('routing-list').innerHTML = '<div style="color:var(--red);padding:10px">Failed to load routes</div>';
status('Routing load failed');
} finally {
setBusyButton('routing-refresh-btn', false);
}
}
async function saveRoutingTab() {
readRoutingForm();
$('routing-save-btn').disabled = true;
try {
const r = await fetch('/api/tts-routes', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({routes:_ttsRoutes}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_ttsRoutes = d.routes || _ttsRoutes;
renderRoutingList();
toast('Routing saved', 'success');
status('TTS routing saved');
} catch(e) {
toast('Save routes failed: ' + e.message, 'error');
} finally {
$('routing-save-btn').disabled = false;
}
}
function renderRouteTestResult(d) {
const el = $('routing-test-result');
if (!el) return;
const health = d.voice_health || {};
const warnings = Array.isArray(health.warnings) ? health.warnings : [];
el.className = 'routing-test-result ' + (warnings.length ? 'warn' : 'ok');
const parts = [
`${escHtml(d.requested_voice || '')}${escHtml(d.routed_voice || '')}`,
`app ${escHtml(d.app || '-')}`,
`backend ${escHtml(d.backend || 'voice_clone')}`,
`language ${escHtml(d.detected_language || '-')}`,
d.matched ? 'matched route' : 'no route matched',
];
if (health.duration) parts.push(`reference ${health.duration}s`);
if (health.word_count != null) parts.push(`${health.word_count} words`);
if (health.words_per_sec) parts.push(`${health.words_per_sec} words/s`);
if (warnings.length) {
parts.push('Warning: ' + warnings.map(escHtml).join('; '));
}
const sounds = d.sounds || {};
for (const [key, sound] of Object.entries(sounds)) {
const label = key === 'before_sound' ? 'before sound' : 'after sound';
parts.push(sound.ok ? `${label} OK` : `${label}: ${escHtml(sound.error || 'not found')}`);
if (!sound.ok) el.className = 'routing-test-result warn';
}
el.innerHTML = parts.join(' · ');
}
function routingLogTime(ts) {
if (!ts) return '--:--:--';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return String(ts).slice(11, 19) || '--:--:--';
return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
}
function routingLogBadge(item) {
const status = String(item.status || item.kind || 'log');
if (item.kind === 'test' && status === 'matched') return 'test ok';
if (item.kind === 'test' && status === 'no_match') return 'test miss';
return status.replace(/_/g, ' ');
}
function routingLogMeta(item) {
const parts = [];
if (item.backend) parts.push(item.backend);
if (item.language) parts.push('lang ' + item.language);
if (item.response_format) parts.push(item.response_format);
if (item.duration != null) parts.push(Number(item.duration).toFixed(2) + 's');
if (item.bytes != null) parts.push(Math.round(Number(item.bytes) / 1024) + ' KB');
if (item.sounds && Array.isArray(item.sounds) && item.sounds.length) parts.push('sounds ' + item.sounds.join(','));
if (item.route_id) parts.push('route ' + item.route_id);
if (item.client) parts.push(item.client);
return parts.join(' · ');
}
let _routingLogItems = [];
let _routingLogFilter = 'all';
function routingLogPassesFilter(item) {
const status = String(item?.status || '').toLowerCase();
const isError = status === 'error' || Boolean(item?.error);
const isNoMatch = status === 'no_match' || item?.matched === false;
if (_routingLogFilter === 'error') return isError;
if (_routingLogFilter === 'no_match') return isNoMatch;
if (_routingLogFilter === 'attention') return isError || isNoMatch;
return true;
}
function renderCurrentRoutingLog() {
renderRoutingLog(_routingLogItems.filter(routingLogPassesFilter));
}
function renderRoutingLog(items = []) {
const el = $('routing-log-list');
if (!el) return;
if (!items.length) {
const filtered = _routingLogItems.length && _routingLogFilter !== 'all';
el.innerHTML = `<div class="routing-log-empty">${filtered ? 'No routing log entries match this filter.' : 'No routing log entries yet. Test a route or send a TTS request through the Creator proxy.'}</div>`;
return;
}
el.innerHTML = items.map(item => {
const status = String(item.status || 'log').replace(/[^a-z0-9_-]/gi, '_');
const requested = item.requested_voice || '-';
const routed = item.routed_voice || '-';
const voice = requested === routed ? requested : `${requested}${routed}`;
const text = item.error ? `Error: ${item.error}` : (item.text_preview || '');
return `
<div class="routing-log-entry status-${escHtml(status)}">
<div class="log-time">${escHtml(routingLogTime(item.ts))}</div>
<div class="routing-log-badge">${escHtml(routingLogBadge(item))}</div>
<div class="log-app" title="${escHtml(item.app || '-')}">${escHtml(item.app || '-')}</div>
<div class="log-voice" title="${escHtml(voice)}">${escHtml(voice)}</div>
<div class="log-meta" title="${escHtml(routingLogMeta(item))}">${escHtml(routingLogMeta(item) || '-')}</div>
<div class="log-text" title="${escHtml(text)}">${escHtml(text || '-')}</div>
</div>`;
}).join('');
}
async function loadRoutingLog() {
const el = $('routing-log-list');
if (!el) return;
try {
const r = await fetch('/api/tts-routing-log?limit=80');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_routingLogItems = Array.isArray(d.items) ? d.items : [];
renderCurrentRoutingLog();
} catch(e) {
el.innerHTML = `<div class="routing-log-empty" style="color:var(--red)">Routing log unavailable: ${escHtml(e.message)}</div>`;
}
}
async function clearRoutingLog() {
const btn = $('routing-log-clear-btn');
if (btn) btn.disabled = true;
try {
const r = await fetch('/api/tts-routing-log', {method:'DELETE'});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
_routingLogItems = [];
renderRoutingLog([]);
toast('Routing log cleared', 'success');
} catch(e) {
toast('Clear log failed: ' + e.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function testRouting() {
readRoutingForm();
const btn = $('routing-test-btn');
const el = $('routing-test-result');
btn.disabled = true;
el.className = 'routing-test-result';
el.textContent = 'Testing route...';
try {
const r = await fetch('/api/tts-route-test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
app: $('routing-test-app').value.trim() || 'Open WebUI',
voice: $('routing-test-voice').value.trim() || 'default',
input: $('routing-test-text').value.trim(),
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
renderRouteTestResult(await r.json());
loadRoutingLog();
} catch(e) {
el.className = 'routing-test-result warn';
el.textContent = 'Route test failed: ' + e.message;
} finally {
btn.disabled = false;
}
}
async function uploadRouteSoundForRow(row, target) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.multiple = false;
input.onchange = async () => {
if (!input.files || !input.files.length) return;
const btn = row.querySelector(`.route-sound-upload[data-target="${target}"]`);
const field = row.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound');
if (btn) btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', input.files[0]);
const r = await fetch('/api/route-sounds/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();
field.value = d.path || '';
await loadRouteSounds();
readRoutingForm();
renderRoutingList();
toast(`${target === 'before' ? 'Before' : 'After'} sound uploaded`, 'success');
status(`Uploaded route sound: ${d.path}`);
} catch(e) {
toast('Sound upload failed: ' + e.message, 'error');
status('Sound upload failed');
} finally {
if (btn) btn.disabled = false;
}
};
input.click();
}
$('routing-refresh-btn')?.addEventListener('click', loadRoutingTab);
$('routing-add-btn')?.addEventListener('click', () => {
readRoutingForm();
_ttsRoutes.push(newRoute());
renderRoutingList();
});
$('routing-add-openwebui-btn')?.addEventListener('click', () => {
readRoutingForm();
const voices = activeVoiceIds();
const firstByLang = lang => voices.find(v => v.toUpperCase().startsWith(lang + '_')) || '';
_ttsRoutes.push(newRoute('Open WebUI', 'default', 'EN', firstByLang('EN')));
_ttsRoutes.push(newRoute('Open WebUI', 'default', 'DE', firstByLang('DE')));
renderRoutingList();
});
$('routing-save-btn')?.addEventListener('click', saveRoutingTab);
$('routing-test-btn')?.addEventListener('click', testRouting);
$('routing-log-refresh-btn')?.addEventListener('click', loadRoutingLog);
$('routing-log-clear-btn')?.addEventListener('click', clearRoutingLog);
$('routing-log-filter')?.addEventListener('change', (e) => {
_routingLogFilter = e.target.value || 'all';
renderCurrentRoutingLog();
});
$('routing-list')?.addEventListener('change', e => {
const picker = e.target.closest('.route-sound-picker');
if (!picker) return;
const row = picker.closest('.routing-row');
const field = row.querySelector(picker.dataset.target === 'before' ? '.route-before-sound' : '.route-after-sound');
if (field) field.value = picker.value || '';
readRoutingForm();
});
$('routing-list')?.addEventListener('click', e => {
const pickBtn = e.target.closest('.route-sound-pick');
if (pickBtn) {
const row = pickBtn.closest('.routing-row');
openRouteSoundBrowser(row, pickBtn.dataset.target);
return;
}
const uploadBtn = e.target.closest('.route-sound-upload');
if (uploadBtn) {
const row = uploadBtn.closest('.routing-row');
uploadRouteSoundForRow(row, uploadBtn.dataset.target);
return;
}
const btn = e.target.closest('.routing-delete');
if (!btn) return;
readRoutingForm();
const row = btn.closest('.routing-row');
_ttsRoutes.splice(Number(row.dataset.index), 1);
renderRoutingList();
});
$('routing-sound-search')?.addEventListener('input', debounce(renderRouteSoundBrowser, 120));
$('routing-sound-refresh-btn')?.addEventListener('click', async () => {
await loadRouteSounds();
renderRouteSoundBrowser();
});
$('routing-sound-close-btn')?.addEventListener('click', closeRouteSoundBrowser);
$('routing-sound-list')?.addEventListener('click', e => {
const item = e.target.closest('.routing-sound-item');
if (!item) return;
const path = item.dataset.path || '';
const playBtn = e.target.closest('.sound-play');
if (playBtn) {
playRouteSound(path, playBtn);
return;
}
if (e.target.closest('.sound-use-current')) {
useRouteSound(path, _routeSoundPickerTarget?.target || 'before');
}
});
// ── Settings ──────────────────────────────────────────────────────────────
const SETTINGS_SEEN_KEY = 'vcf-settings-seen';
let _appSettings = {};
let _ttsBackends = [];
function availableTtsBackends() {
return (_ttsBackends || []).filter(b => b.available);
}
function ttsBackendOptions(selected = '') {
const backends = availableTtsBackends();
if (!backends.length) return '<option value="">No TTS backend available</option>';
const current = selected || backends[0].id;
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === current ? 'selected' : ''}>${escHtml(b.label)}</option>`).join('');
}
function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) {
const backends = availableTtsBackends();
if (!backends.length) return '<option value="">No backend available</option>';
const preferred = backends.some(b => b.id === selected) ? selected
: preferStyleAware
? (backends.find(b => b.style_aware)?.id || backends[0].id)
: backends[0].id;
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}>${escHtml(b.label)}</option>`).join('');
}
function backendById(id) {
return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null;
}
function backendHelpHtml(b, compact = false) {
if (!b) return '<strong>No TTS backend is reachable.</strong><div>Start at least one TTS service or check Settings URLs.</div>';
const tags = [
b.uses_wav ? ['good', 'uses WAV identity'] : ['warn', 'prompt/model voice'],
b.style_aware ? ['good', 'style-aware'] : ['warn', 'weak style'],
b.true_streaming ? ['good', 'true streaming'] : ['', 'buffered/normal'],
].map(([cls, text]) => `<span class="backend-tag ${cls}">${escHtml(text)}</span>`).join('');
const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}<br><strong>Identity:</strong> ${escHtml(b.identity || '')}<br><strong>Style:</strong> ${escHtml(b.style || '')}<br><strong>Best for:</strong> ${escHtml(b.best_for || '')}`;
return `<strong>${escHtml(b.label)}</strong><div class="backend-help-tags">${tags}</div><div>${detail}</div>`;
}
function updateBackendHelp() {
const b = backendById($('tts-backend-select')?.value || '');
const help = $('tts-backend-help');
if (help) help.innerHTML = backendHelpHtml(b);
const sttB = backendById($('stt-tts-backend-select')?.value || '');
const sttHelp = $('stt-tts-backend-help');
if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB);
}
function updateStyleBackendHelp(scope = document) {
scope.querySelectorAll('.opt-style-backend').forEach(sel => {
const box = sel.closest('.opt-style-panel')?.querySelector('.opt-style-backend-help');
if (!box) return;
const b = backendById(sel.value);
box.innerHTML = backendHelpHtml(b, true);
if (b && !b.style_aware) {
const styleAwareBacks = availableTtsBackends().filter(x => x.style_aware);
const suggestion = styleAwareBacks.length
? ` Try <strong>${escHtml(styleAwareBacks[0].label)}</strong> instead.`
: ' No style-aware backend is currently reachable.';
box.innerHTML += `<div class="style-backend-warn">&#9888; This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}</div>`;
}
});
}
function updateBackendDependentTabs() {
const availableBackends = availableTtsBackends();
const available = new Set(availableBackends.map(b => b.id));
document.querySelectorAll('.tab[data-backend-required]').forEach(tab => {
const originalSubtitle = tab.dataset.originalSubtitle || tab.querySelector('.tab-subtitle')?.textContent || '';
const originalTooltip = tab.dataset.originalTooltip || tab.querySelector('.tab-tooltip')?.textContent || '';
tab.dataset.originalSubtitle = originalSubtitle;
tab.dataset.originalTooltip = originalTooltip;
const required = tab.dataset.backendRequired;
const ok = required === 'any_tts' ? availableBackends.length > 0 : available.has(required);
tab.hidden = false;
tab.classList.toggle('backend-unavailable', !ok);
tab.setAttribute('aria-disabled', ok ? 'false' : 'true');
tab.tabIndex = ok ? 0 : -1;
const subtitle = tab.querySelector('.tab-subtitle');
const tooltip = tab.querySelector('.tab-tooltip');
if (subtitle) subtitle.textContent = ok ? originalSubtitle : 'not running/configured';
if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`;
});
const active = document.querySelector('.tab.active');
if (!active || active.classList.contains('backend-unavailable')) {
const first = document.querySelector('.tab:not(.backend-unavailable)');
if (first) switchTab(first.dataset.tab);
}
}
async function refreshTtsBackendAvailability(selected = '') {
try {
const d = await fetch('/api/tts-backends').then(r => r.json());
_ttsBackends = (d.backends || []).filter(b => b && b.id);
} catch (_) {
_ttsBackends = [];
}
const preview = $('tts-backend-select');
if (preview) {
const prev = selected || preview.value;
preview.innerHTML = ttsBackendOptions(prev);
preview.disabled = !availableTtsBackends().length;
}
const sttTtsBackend = $('stt-tts-backend-select');
if (sttTtsBackend) {
const prev = selected || sttTtsBackend.value;
sttTtsBackend.innerHTML = ttsBackendOptions(prev);
sttTtsBackend.disabled = !availableTtsBackends().length;
}
const libraryTts = $('library-tts-backend-select');
if (libraryTts) {
const prev = libraryTts.value || 'voice_clone';
libraryTts.innerHTML = ttsBackendOptions(prev);
libraryTts.disabled = !availableTtsBackends().length;
}
document.querySelectorAll('.opt-style-backend').forEach(sel => {
const prev = sel.value;
sel.innerHTML = styleBackendOptions(prev);
sel.disabled = !availableTtsBackends().length;
});
document.querySelectorAll('.opt-compare-backend').forEach(sel => {
const prev = sel.value && sel.value !== '' ? sel.value : 'voice_clone';
sel.innerHTML = styleBackendOptions(prev);
sel.disabled = !availableTtsBackends().length;
});
updateBackendHelp();
updateStyleBackendHelp();
updateBackendDependentTabs();
return _ttsBackends;
}
async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json());
$('s-whisper-url').value = s.whisper_url || '';
$('s-whisper-key').value = s.whisper_api_key || '';
$('s-tts-url').value = s.tts_url || '';
_appSettings = s;
$('s-tts-stream-url').value = s.tts_stream_url || '';
$('s-customvoice-url').value = s.customvoice_url || 'http://host.docker.internal:8022';
$('s-nvidia-router-url').value = s.nvidia_router_url || 'http://host.docker.internal:8090';
$('s-nvidia-tts-url').value = s.nvidia_tts_url || 'http://host.docker.internal:8091';
$('s-nvidia-asr-url').value = s.nvidia_asr_url || 'http://host.docker.internal:8092';
$('s-nvidia-zeroshot-url').value = s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093';
$('s-nvidia-flow-url').value = s.nvidia_flow_url || 'http://host.docker.internal:8094';
$('s-tts-stream-mode').value = s.tts_stream_mode || 'auto';
$('s-tts-key').value = s.tts_api_key || '';
$('s-tts-backend').value = s.tts_backend || 'openai';
const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0};
const byBackend = s.tts_extra_params_by_backend || {};
$('s-tts-extra-voice-clone').value = JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-streaming').value = JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-customvoice').value = JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-voice-design').value = JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-nvidia-magpie').value = JSON.stringify(byBackend.nvidia_magpie || {}, null, 2);
$('s-tts-extra-nvidia-zeroshot').value = JSON.stringify(byBackend.nvidia_zeroshot || {}, null, 2);
$('s-tts-extra-nvidia-flow').value = JSON.stringify(byBackend.nvidia_flow || {}, null, 2);
$('s-voice-design-url').value = s.voice_design_url || 'http://host.docker.internal:8021';
$('s-vd-key').value = s.voice_design_api_key || '';
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
$('s-output-dir').value = s.output_dir || '';
await refreshTtsBackendAvailability();
}
function markSettingsSeen() {
localStorage.setItem(SETTINGS_SEEN_KEY, '1');
}
function openSettings(firstRun = false) {
$('settings-first-run-note').style.display = firstRun ? '' : 'none';
if (firstRun) markSettingsSeen();
switchTab('settings');
}
function closeSettings(markSeen = true) {
if (markSeen) markSettingsSeen();
}
document.querySelectorAll('.s-eye-btn').forEach(btn => {
btn.addEventListener('click', () => {
const inp = $(btn.dataset.target);
inp.type = inp.type === 'password' ? 'text' : 'password';
});
});
$('settings-btn').addEventListener('click', async () => { await loadSettings(); openSettings(false); });
$('s-close-btn').addEventListener('click', async () => { await loadSettings(); toast('Settings reloaded', 'success'); });
$('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; });
$('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; });
$('s-save-btn').addEventListener('click', async () => {
let ttsExtraParamsByBackend = {};
const paramFields = [
['voice_clone', 's-tts-extra-voice-clone', 'Voice Clone/Base'],
['streaming', 's-tts-extra-streaming', 'Streaming'],
['customvoice', 's-tts-extra-customvoice', 'CustomVoice'],
['voice_design', 's-tts-extra-voice-design', 'Voice Design'],
['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'],
['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'],
['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'],
];
try {
for (const [key, id, label] of paramFields) {
ttsExtraParamsByBackend[key] = JSON.parse($(id).value || '{}');
}
} catch (e) {
toast('TTS params JSON is invalid: ' + e.message, 'error');
return;
}
await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
whisper_url: $('s-whisper-url').value,
whisper_api_key: $('s-whisper-key').value,
tts_url: $('s-tts-url').value,
tts_stream_url: $('s-tts-stream-url').value,
customvoice_url: $('s-customvoice-url').value,
nvidia_router_url: $('s-nvidia-router-url').value,
nvidia_tts_url: $('s-nvidia-tts-url').value,
nvidia_asr_url: $('s-nvidia-asr-url').value,
nvidia_clone_url: $('s-nvidia-zeroshot-url').value,
nvidia_zeroshot_url: $('s-nvidia-zeroshot-url').value,
nvidia_flow_url: $('s-nvidia-flow-url').value,
tts_stream_mode: $('s-tts-stream-mode').value,
tts_api_key: $('s-tts-key').value,
tts_backend: $('s-tts-backend').value,
tts_extra_params_by_backend: ttsExtraParamsByBackend,
voice_design_url: $('s-voice-design-url').value,
voice_design_api_key: $('s-vd-key').value,
voices_scan_dir: $('s-voices-scan-dir').value,
output_dir: $('s-output-dir').value,
}) });
_appSettings.tts_stream_url = $('s-tts-stream-url').value;
_appSettings.customvoice_url = $('s-customvoice-url').value;
_appSettings.nvidia_router_url = $('s-nvidia-router-url').value;
_appSettings.nvidia_tts_url = $('s-nvidia-tts-url').value;
_appSettings.nvidia_asr_url = $('s-nvidia-asr-url').value;
_appSettings.nvidia_clone_url = $('s-nvidia-zeroshot-url').value;
_appSettings.nvidia_zeroshot_url = $('s-nvidia-zeroshot-url').value;
_appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value;
_appSettings.voice_design_url = $('s-voice-design-url').value;
_appSettings.tts_stream_mode = $('s-tts-stream-mode').value;
_ttsStreamHealth = null;
await refreshTtsBackendAvailability($('tts-backend-select')?.value || '');
markSettingsSeen();
renderIntegrationSnippets();
toast('Settings saved', 'success');
toast('Settings saved', 'success');
});
// ── Voice ID field (tab 3) ────────────────────────────────────────────────
function validateVoiceId(v) { return /^[A-Za-z0-9_\-\.]+$/.test(v); }
$('voice-id-input').addEventListener('input', () => {
const val = $('voice-id-input').value;
const ok = val && validateVoiceId(val);
$('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : '';
$('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : '';
});
$('helper-apply-btn').addEventListener('click', () => {
const name = $('name-input').value.trim();
if (!name) { toast('Enter a name first', 'error'); return; }
$('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`;
$('voice-id-input').dispatchEvent(new Event('input'));
});
// ── WaveSurfer ────────────────────────────────────────────────────────────
let ws = null, wsRegions = null, currentFileId = null, trimmedFileId = null, designedFileId = null, editingVoiceId = null, editingVoicePath = null;
function initWaveSurfer() {
if (ws) { ws.destroy(); ws = null; wsRegions = null; }
wsRegions = WaveSurfer.Regions.create();
ws = WaveSurfer.create({ container:'#waveform', waveColor:'#45475a', progressColor:'#89b4fa',
cursorColor:'#cba6f7', height:90, normalize:true, plugins:[wsRegions] });
ws.on('ready', () => {
const dur = ws.getDuration();
$('trim-end').value = dur.toFixed(2); $('trim-end').max = dur.toFixed(2); $('trim-start').max = dur.toFixed(2);
updateRegion();
});
wsRegions.on('region-updated', r => {
$('trim-start').value = r.start.toFixed(2); $('trim-end').value = r.end.toFixed(2); updateDurationLabel();
});
}
function updateRegion() {
wsRegions.clearRegions();
const s = parseFloat($('trim-start').value)||0, e = parseFloat($('trim-end').value)||(ws?ws.getDuration():0);
wsRegions.addRegion({ start:s, end:e, color:'rgba(137,180,250,0.25)', drag:true, resize:true });
updateDurationLabel();
}
function updateDurationLabel() {
const d = Math.max(0, (parseFloat($('trim-end').value)||0) - (parseFloat($('trim-start').value)||0));
const el = $('trim-duration'); el.textContent = d.toFixed(1)+' s';
el.className = d>=5&&d<=20 ? 'dur-ok' : d>20 ? 'dur-warn' : 'dur-bad';
}
['trim-start','trim-end'].forEach(id => $(id).addEventListener('input', () => { if(ws) updateRegion(); }));
$('play-btn').addEventListener('click', () => { if(ws) ws.playPause(); });
$('play-selection-btn').addEventListener('click', () => {
if (!ws) return;
ws.play(parseFloat($('trim-start').value)||0, parseFloat($('trim-end').value)||ws.getDuration());
});
$('auto-trim-btn').addEventListener('click', async () => {
if (!currentFileId) { toast('No audio loaded','error'); return; }
$('auto-trim-btn').disabled = true;
status('Finding best TTS reference segment…');
try {
const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:currentFileId})});
let d;
if (r.ok) {
d = await r.json();
} else if (r.status === 404 || r.status === 405) {
status('Backend auto trim unavailable; analysing audio in browser…');
d = await clientAutoTrimBounds(currentFileId);
} else {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText || 'Auto trim failed');
}
$('trim-start').value = Number(d.start).toFixed(2);
$('trim-end').value = Number(d.end).toFixed(2);
if (ws) updateRegion();
toast('Auto trim set: '+Number(d.duration).toFixed(1)+' s','success');
status(d.reason || 'Auto trim ready');
} catch(e) {
toast('Auto trim failed: '+e.message,'error');
status('Auto trim failed');
} finally { $('auto-trim-btn').disabled = false; }
});
function loadAudioId(id, dur, opts = {}) {
currentFileId = id; trimmedFileId = null; designedFileId = null;
editingVoiceId = opts.editingVoiceId || null;
editingVoicePath = opts.editingVoicePath || null;
$('trim-start').value='0'; $('trim-end').value=dur.toFixed(2);
$('waveform-card').style.display=''; initWaveSurfer(); ws.load('/api/audio/'+id);
$('save-result').style.display='none'; $('trim-audio').style.display='none'; $('no-audio-hint').style.display='';
if (editingVoiceId) {
$('voice-id-input').value = editingVoiceId;
$('voice-id-input').dispatchEvent(new Event('input'));
$('transcript-area').value = opts.transcript || '';
status('Editing existing voice: ' + editingVoiceId);
}
}
// ── Drop zone ─────────────────────────────────────────────────────────────
const dropZone = $('drop-zone'), fileInput = $('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if(fileInput.files.length) uploadFile(fileInput.files[0]); });
async function uploadFile(file) {
status('Uploading '+file.name+'…');
const fd = new FormData(); fd.append('file', file);
try {
const r = await fetch('/api/upload', { method:'POST', body:fd });
if (!r.ok) { const e = await r.json(); throw new Error(e.detail||r.statusText); }
const d = await r.json();
loadAudioId(d.id, d.duration); status('Loaded: '+file.name+' ('+d.duration.toFixed(1)+' s)');
toast('File loaded', 'success');
} catch(e) { toast('Upload failed: '+e.message, 'error'); status('Upload failed'); }
}
async function loadLibraryVoiceAudio(v) {
const audioResp = await fetch(voiceFileUrl(v), {cache:'no-store'});
if (!audioResp.ok) {
const e = await audioResp.json().catch(() => ({}));
throw new Error(e.detail || audioResp.statusText);
}
const blob = await audioResp.blob();
const ext = (v.file_type || 'wav').toLowerCase();
const fd = new FormData();
fd.append('file', new File([blob], `${v.id}.${ext}`, {type:blob.type || 'audio/wav'}));
const upload = await fetch('/api/upload', { method:'POST', body:fd });
if (!upload.ok) {
const e = await upload.json().catch(() => ({}));
throw new Error(e.detail || upload.statusText);
}
const d = await upload.json();
return { id:d.id, voice_id:v.id, duration:d.duration, transcript:v.transcript || '', file_type:ext, path:v.path };
}
// ── YouTube ───────────────────────────────────────────────────────────────
$('yt-btn').addEventListener('click', () => {
const url = $('yt-url').value.trim(); if(!url) return;
$('yt-btn').disabled=true; $('yt-progress').textContent='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'); $('yt-progress').textContent=d.error; $('yt-btn').disabled=false; es.close(); }
else if (d.done) { es.close(); $('yt-btn').disabled=false; $('yt-progress').textContent='Done!'; loadAudioId(d.id,d.duration); toast('YouTube audio loaded','success'); }
else { $('yt-progress').textContent=d.msg||''; if(d.pct) status('Downloading… '+d.pct+'%'); }
};
es.onerror = () => { es.close(); $('yt-btn').disabled=false; };
});
// ── Microphone ────────────────────────────────────────────────────────────
const RAW_MIC_CONSTRAINTS = {
echoCancellation:false,
noiseSuppression:false,
autoGainControl:false
};
async function visibleMicrophoneCount() {
if (!navigator.mediaDevices?.enumerateDevices) return null;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === 'audioinput').length;
} catch(e) {
return null;
}
}
async function microphoneErrorMessage(error) {
const name = error?.name || '';
const message = error?.message || '';
const lowerMessage = message.toLowerCase();
const micCount = await visibleMicrophoneCount();
if (name === 'NotFoundError' || lowerMessage.includes('requested device not found')) {
return micCount === 0
? 'No microphone is visible to this browser. Connect or enable an input device in your OS/browser settings, then reload.'
: 'The browser can see a microphone, but cannot open the selected/default input. Check the site permission and OS input selection, then reload.';
}
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
return 'Microphone permission is blocked for this site. Allow microphone access in the address bar, then reload.';
}
if (name === 'NotReadableError') {
return 'The microphone is busy or unavailable. Close other apps using it, then try again.';
}
if (name === 'SecurityError') {
return 'Microphone access requires localhost or HTTPS.';
}
return message || 'Microphone failed.';
}
async function requestMicrophoneStream(options = {}) {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Microphone requires HTTPS. Open the app via https://... or access it on localhost.');
}
if (!options.raw) return navigator.mediaDevices.getUserMedia({audio:true});
try {
return await navigator.mediaDevices.getUserMedia({audio:RAW_MIC_CONSTRAINTS});
} catch(e) {
if (e?.name === 'OverconstrainedError' || e?.name === 'NotFoundError') {
return navigator.mediaDevices.getUserMedia({audio:true});
}
throw e;
}
}
let mediaRec=null, recChunks=[], recTimer=null, recSecs=0;
$('rec-start-btn').addEventListener('click', async () => {
try {
const stream = await requestMicrophoneStream();
recChunks=[]; recSecs=0; $('rec-time').textContent='0:00';
$('rec-indicator').classList.add('active'); $('rec-start-btn').disabled=true; $('rec-stop-btn').disabled=false;
recTimer = setInterval(() => { recSecs++; $('rec-time').textContent=Math.floor(recSecs/60)+':'+String(recSecs%60).padStart(2,'0'); }, 1000);
mediaRec = new MediaRecorder(stream);
mediaRec.ondataavailable = e => { if(e.data.size) recChunks.push(e.data); };
mediaRec.onstop = async () => {
clearInterval(recTimer); $('rec-indicator').classList.remove('active'); stream.getTracks().forEach(t=>t.stop());
const blob = new Blob(recChunks, {type:mediaRec.mimeType||'audio/webm'});
const ext = (mediaRec.mimeType||'').includes('ogg') ? '.ogg' : '.webm';
await uploadFile(new File([blob], 'recording'+ext, {type:blob.type}));
};
mediaRec.start(100); status('Recording…');
} catch(e) { toast(await microphoneErrorMessage(e), 'error'); }
});
$('rec-stop-btn').addEventListener('click', () => {
if(mediaRec&&mediaRec.state!=='inactive') mediaRec.stop();
$('rec-start-btn').disabled=false; $('rec-stop-btn').disabled=true;
});
// ── Trim ──────────────────────────────────────────────────────────────────
$('trim-btn').addEventListener('click', async () => {
if (!currentFileId) { toast('No audio loaded','error'); return; }
try {
const r = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({id:currentFileId, start:parseFloat($('trim-start').value)||0, end:parseFloat($('trim-end').value)||0}) });
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json(); trimmedFileId=d.id; designedFileId=null;
$('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none';
switchTab('save'); toast('Trim done','success');
} catch(e) { toast('Trim failed: '+e.message,'error'); }
});
// ── Voice design naming helpers ────────────────────────────────────────────
const DESIGN_LANG_CODE = {
Auto:'EN', English:'EN', Chinese:'ZH', Japanese:'JA', Korean:'KO',
German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Portuguese:'PT', Russian:'RU',
};
const DESIGN_GENDER_WORD = { F:'female', M:'male', N:'neutral' };
const DESIGN_PRESET_KEY = 'vcf-design-presets';
const DESIGN_PRESET_SEEDED_KEY = 'vcf-design-presets-seeded-v2';
const DEFAULT_DESIGN_PRESETS = {
'EN_M_Young_Energetic': {
description: 'Young adult male voice, clear English, bright and energetic, moderately high pitch, quick but controlled speaking rate, confident and friendly, suitable for tutorials or streaming.',
sample_text: 'Hey everyone, welcome back. Today we are going to move quickly, keep it clear, and make this setup feel easy.',
language: 'English',
gender: 'M',
},
'EN_F_Warm_Narrator': {
description: 'Adult female English narrator, warm and smooth, medium pitch, calm pace, gentle emotion, clear articulation, suited for audiobooks and voice assistant responses.',
sample_text: 'The room grew quiet as the morning light touched the window, and for a moment everything felt simple and kind.',
language: 'English',
gender: 'F',
},
'DE_M_Elderly_Documentary': {
description: 'Aeltere maennliche deutsche Stimme, tief und resonant, langsam und gelassen, klar artikuliert, ruhig und dokumentarisch, mit serioeser und vertrauensvoller Praesenz.',
sample_text: 'Seit vielen Jahren beobachten wir diesen Ort, seine Geschichte und die Menschen, die ihn mit Leben fuellen.',
language: 'German',
gender: 'M',
},
'DE_F_Young_Friendly': {
description: 'Junge weibliche deutsche Stimme, hell und freundlich, natuerliche Sprechgeschwindigkeit, klare Aussprache, leicht optimistisch und nahbar, passend fuer Assistenten und kurze Erklaerungen.',
sample_text: 'Hallo, schoen dass du da bist. Ich zeige dir kurz, wie alles funktioniert, Schritt fuer Schritt.',
language: 'German',
gender: 'F',
},
'EN_N_Old_Wise_Assistant': {
description: 'Older neutral English voice, gentle and wise, slightly low pitch, slow measured pace, soothing tone, very clear pronunciation, calm personality for guidance and reflective narration.',
sample_text: 'Take a slow breath. We will look at the facts carefully, choose the next step, and keep moving.',
language: 'English',
gender: 'N',
},
};
const QWEN_DESIGN_SAMPLES = {
'qwen-timbre-reuse': {
title: 'Qwen Timbre Reuse',
summary: 'Reference clip for designing a reusable teen character timbre.',
description: 'Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous',
text: "H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?",
language: 'English',
gender: 'M',
},
'acoustic-sausage-announcer': {
title: 'Acoustic Attribute Control - British announcer',
summary: 'Fast, loud, articulate British male delivery with excitement and performative authority.',
description: `gender: Male.
pitch: Low male pitch with significant upward inflections for emphasis and excitement.
speed: Fast-paced delivery with deliberate pauses for dramatic effect.
volume: Loud and projecting, increasing notably during moments of praise and announcements.
age: Young adult to middle-aged adult.
clarity: Highly articulate and distinct pronunciation.
fluency: Very fluent speech with no hesitations.
accent: British English.
texture: Bright and clear vocal texture.
emotion: Enthusiastic and excited, especially when complimenting.
tone: Upbeat, authoritative, and performative.
personality: Confident, extroverted, and engaging.`,
text: 'Nine different, exciting ways of cooking sausage. Incredible. There were three outstanding deliveries in terms of the sausage being the hero. The first dish that we want to dissect, this individual smartly combined different proteins in their sausage. Great seasoning. The blend was absolutely spot on. Congratulations. Please step forward. Natasha.',
language: 'English',
gender: 'M',
},
'acoustic-character-laugh': {
title: 'Acoustic Attribute Control - theatrical character',
summary: 'Artificially high male character voice shifting from loud forced amusement to deliberate resignation.',
description: `gender: Male.
pitch: Artificially high-pitched, slightly lowering after the initial laugh.
speed: Rapid during the laugh, then slowing to a deliberate pace.
volume: Loud laugh transitioning to a standard conversational level.
age: Young adult to middle-aged, performing a character voice.
clarity: Clear and distinct articulation.
fluency: Fluent delivery without hesitation.
accent: American English.
texture: Slightly strained and somewhat nasal quality.
emotion: Forced amusement shifting to feigned resignation.
tone: Initially playful, then shifts to a slightly put-upon tone.
personality: Theatrical and expressive.`,
text: "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.",
language: 'English',
gender: 'M',
},
'age-control-surly-elvis': {
title: 'Age Control - middle-aged gravel',
summary: 'Low, resonant, slightly gravelly American male voice with a commanding opening.',
description: `gender: Male.
pitch: Low male pitch, generally stable.
speed: Deliberate pace, slowing slightly after the initial exclamation.
volume: Starts loud, then transitions to a projected conversational volume.
age: Middle-aged adult.
clarity: High clarity with distinct pronunciation.
fluency: Highly fluent.
accent: American English.
texture: Resonant and slightly gravelly.
emotion: Initially commanding, shifting to narrative amusement.
tone: Authoritative start, moving to an engaging, descriptive tone.
personality: Confident and performative.`,
text: 'Older gentleman, 110, maybe 111 years old, sort of a surly Elvis thing happening with him. He smiles like this. Seen him around?',
language: 'English',
gender: 'M',
},
'gradual-control-anger': {
title: 'Gradual Control - emotional escalation',
summary: 'Female voice that begins neutral and quickly escalates into sharp anger and accusation.',
description: `gender: Female.
pitch: Mid-range female pitch, rising sharply with frustration.
speed: Starts measured, then accelerates rapidly during emotional outburst.
volume: Begins conversational, escalates quickly to loud and forceful.
age: Young adult to middle-aged.
clarity: High clarity and distinct articulation throughout.
fluency: Highly fluent with no significant pauses or fillers.
accent: General American English.
texture: Bright and clear vocal quality.
emotion: Shifts abruptly from neutral acceptance to intense resentment and anger.
tone: Initially accepting, becomes sharply accusatory and confrontational.
personality: Assertive and emotionally expressive when provoked.`,
text: 'Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you-',
language: 'English',
gender: 'F',
},
'human-likeness-digital-nomad': {
title: 'Human-likeness - casual self-aware monologue',
summary: 'Warm male conversational voice with natural laughter, hesitations, and self-deprecating humor.',
description: 'A relaxed, naturally expressive male voice in his late twenties to early thirties, with a moderately low pitch, casual speaking rate, and conversational volume; deliver lines with a light, self-deprecating tone, breaking into genuine, easygoing laughter at moments of embarrassment, while maintaining clear articulation and an overall warm, approachable clarity.',
text: `Yeah, so--uh--I'm a digital nomad, right? So... pretty much all my communication is just, like, texts and messages. And now, you know, there's these AI agents that can, uh... reply for you? Which is--heh--convenient, sure, I guess? But also... kinda delicate, you know?
Like, you'll type something super short--like, "Yep, sounds good"--and it'll turn that into this whole... warm, polished paragraph. Like, way nicer than I'd ever write myself. huh... ha Seriously, I sound like a Hallmark card all of a sudden.
But then... once you outsource that... what's the other person actually hearing? Are they hearing me... or just some... generic, friendly-bot voice? Man, that's weird to even say out loud.`,
language: 'English',
gender: 'M',
},
'background-marcus-cole': {
title: 'Background Information - Marcus Cole',
summary: 'Broadcast booth announcer profile with bright, agile, urgent delivery.',
description: `Character Name: Marcus Cole
Voice Profile: A bright, agile male voice with a natural upward lift, delivering lines at a brisk, energetic pace. Pitch leans high with spark, volume projects clearly--near-shouting at peaks--to convey urgency and excitement. Speech flows seamlessly, fluently, each word sharply defined, riding a current of dynamic rhythm.
Background: Longtime broadcast booth announcer for national television, specializing in live interstitials and public engagement spots. His voice bridges segments, rallies action, and keeps momentum alive--from voter drives to entertainment news.
Presence: Late 50s, neatly groomed, dressed in a crisp shirt under studio lights. Moves with practiced ease, eyes locked on the script, energy coiled and ready.
Personality: Energetic, precise, inherently engaging. He doesn't just read--he propels. Behind the speed is intent: to inform fast, to move people to act. Whether it's "text VOTE to 5703" or a star-studded tease, he makes it feel immediate, vital.`,
text: "Lot being you watching. 1-866-IDLE-03 for JPL. That's 1-866-436-5703. Or text the word VOTE to 5703. Diana DeGarmo's next with more from the movies right after this brief intermission on American Idol.",
language: 'English',
gender: 'M',
},
'timbre-reuse-lucas-mia': {
title: 'Timbre Reuse - Lucas and Mia',
summary: 'Two-character teen dialogue using native VoiceDesign speaker-profile switching.',
description: `"Lucas": "Male, 17 years old, tenor range, gaining confidence - deeper breath support now, though vowels still tighten when nervous"
"Mia": "Female, 16 years old, mezzo-soprano range, softening - lowering register to intimate speaking voice, consonants softening"`,
text: `Lucas:H-hey! You dropped your... uh... calculus notebook? I mean, I think it's yours? Maybe?
Mia:Oh wow, my mortal enemy - Mr. Thompson's problem sets. Thanks for rescuing me from that F.
Lucas:No problem! I actually... kinda finished those already? If you want to compare answers or something...
Mia:Is this your sneaky way of saying you want to study together, Lucas? Because I saw you staring during lab partners sign-up.
Lucas:What? No! I mean yes but not like... I just think you're... your titration technique is really precise!
Mia:That's the nerdiest compliment I've ever gotten. Tell you what - help me survive pre-calc and I'll teach you how to actually flirt.
Lucas:Wow, harsh. And here I thought my titration line was smooth.
Mia:It was adorable. Like when you tripped over your shoelaces in the hall yesterday. Or that time you-
Lucas:Okay okay! I get it, I'm a disaster. So... library after school? I'll bring the graphing calculators?
Mia:Only if you promise not to spill coffee on my notes again... though I guess watching you panic-clean was pretty cute.`,
language: 'English',
gender: 'N',
dialogue: true,
},
};
let currentDesignSource = null;
function loadDesignPresets() {
try { return JSON.parse(localStorage.getItem(DESIGN_PRESET_KEY) || '{}'); }
catch { return {}; }
}
function saveDesignPresets(presets) {
localStorage.setItem(DESIGN_PRESET_KEY, JSON.stringify(presets));
}
async function syncDesignPresetsToServer() {
try {
await fetch('/api/voice-design-presets', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(loadDesignPresets()),
});
renderIntegrationSnippets();
} catch(e) {
status('Voice Design preset sync failed: ' + e.message);
}
}
function seedDesignPresets() {
const presets = loadDesignPresets();
let changed = false;
Object.entries(DEFAULT_DESIGN_PRESETS).forEach(([name, preset]) => {
if (!presets[name]) {
presets[name] = preset;
changed = true;
return;
}
['description', 'sample_text', 'language', 'gender'].forEach(key => {
if (!presets[name][key] && preset[key]) {
presets[name][key] = preset[key];
changed = true;
}
});
});
if (changed || !localStorage.getItem(DESIGN_PRESET_SEEDED_KEY)) saveDesignPresets(presets);
localStorage.setItem(DESIGN_PRESET_SEEDED_KEY, '1');
if (changed) syncDesignPresetsToServer();
}
function refreshDesignPresetSelect() {
const presets = loadDesignPresets();
const sel = $('design-preset-select');
const prev = sel.value;
sel.innerHTML = '<option value="">— preset —</option>';
Object.keys(presets).sort((a,b)=>a.localeCompare(b)).forEach(name => {
const opt = document.createElement('option');
opt.value = opt.textContent = name;
sel.appendChild(opt);
});
if (presets[prev]) sel.value = prev;
renderDesignPresetLibrary();
}
function applyDesignPreset(name) {
const preset = loadDesignPresets()[name];
if (!preset) { toast('Preset not found', 'error'); return; }
$('design-instruct').value = preset.description || '';
$('design-sample-text').value = preset.sample_text || preset.text || $('design-sample-text').value || '';
$('design-language').value = preset.language || 'Auto';
$('design-gender').value = preset.gender || 'N';
$('design-preset-name').value = name;
$('design-preset-select').value = name;
currentDesignSource = { name, gender:preset.gender || 'N', language:preset.language || 'Auto', text:$('design-sample-text').value || '', description:preset.description || '' };
toast('Preset loaded: ' + name, 'success');
}
function renderDesignPresetLibrary() {
const lib = $('design-preset-library');
if (!lib) return;
const presets = loadDesignPresets();
const names = Object.keys(presets).sort((a,b)=>a.localeCompare(b));
if (!names.length) {
lib.innerHTML = '<div class="note">No saved prompt presets yet.</div>';
return;
}
lib.innerHTML = '';
names.forEach(name => {
const p = presets[name];
const row = document.createElement('div');
row.className = 'design-preset-row';
row.innerHTML = `
<strong>${escHtml(name)}</strong>
<span>${escHtml(p.gender || 'N')}</span>
<span>${escHtml(p.language || 'Auto')}</span>
<span class="preset-desc" title="${escHtml(p.description || '')}">${escHtml(p.description || '')}</span>
<span class="preset-transcript" title="${escHtml(p.sample_text || p.text || '')}">${escHtml(p.sample_text || p.text || '')}</span>
<span class="preset-actions">
<button class="btn-secondary preset-use">Use</button>
<button class="btn-primary preset-preview">Preview</button>
<button class="btn-secondary preset-delete">Delete</button>
</span>
`;
row.querySelector('.preset-use').addEventListener('click', () => applyDesignPreset(name));
row.querySelector('.preset-delete').addEventListener('click', () => {
const all = loadDesignPresets();
delete all[name];
saveDesignPresets(all);
syncDesignPresetsToServer();
refreshDesignPresetSelect();
toast('Preset deleted: ' + name, 'success');
});
row.querySelector('.preset-preview').addEventListener('click', async e => {
e.currentTarget.disabled = true;
try {
const sampleText = p.sample_text || p.text || $('design-sample-text').value;
const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(voiceDesignPayload(p.description || '', sampleText, p.language || 'Auto', p))});
if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); }
const d = await r.json();
$('design-audio').src = '/api/audio/' + d.id;
$('design-result').style.display = 'flex';
$('design-audio').play().catch(()=>{});
} catch(err) { toast('Preset preview failed: ' + err.message, 'error'); }
finally { e.currentTarget.disabled = false; }
});
lib.appendChild(row);
});
}
function renderQwenSampleCards() {
const list = $('qwen-sample-list');
if (!list) return;
list.innerHTML = '';
Object.entries(QWEN_DESIGN_SAMPLES).forEach(([key, sample]) => {
const card = document.createElement('div');
card.className = 'qwen-sample design-sample-grid';
card.dataset.qwenSample = key;
card.innerHTML = `
<strong title="${escHtml(sample.summary || sample.title || key)}">${escHtml(sample.title || key)}</strong>
<span class="sample-sex">${escHtml(sample.gender || 'N')}</span>
<span class="sample-language">${escHtml(sample.language || 'Auto')}</span>
<span class="sample-desc" title="${escHtml(sample.description || '')}">${escHtml(sample.description || '')}</span>
<span class="sample-text" title="${escHtml(sample.text || '')}">${escHtml(sample.text || '')}</span>
<div class="qwen-sample-actions">
<button class="btn-primary qwen-preview">Preview</button>
<button class="btn-secondary qwen-use">Use</button>
<span class="note qwen-state"></span>
</div>
<audio controls></audio>
`;
list.appendChild(card);
});
}
function applyQwenSample(sample) {
$('design-instruct').value = sample.description;
$('design-sample-text').value = sample.text;
$('design-language').value = sample.language;
$('design-gender').value = sample.gender;
currentDesignSource = sample;
$('design-result').style.display = 'none';
$('design-save-result').style.display = 'none';
$('design-instruct').scrollIntoView({behavior:'smooth', block:'nearest'});
}
function isDialogueDesign(instruct, text, source = null) {
if (source && source.dialogue) return true;
const speakers = new Set();
String(instruct || '').split(/\n+/).forEach(line => {
const match = line.trim().match(/^"?([^":]+)"?\s*:\s*"?(.+?)"?$/);
if (match) speakers.add(match[1].trim());
});
if (speakers.size < 2) return false;
const turnSpeakers = new Set();
String(text || '').split(/\n+/).forEach(line => {
const match = line.trim().match(/^([^:]{1,40}):\s*(.+)$/);
if (match && speakers.has(match[1].trim())) turnSpeakers.add(match[1].trim());
});
return turnSpeakers.size >= 2;
}
function voiceDesignPayload(instruct, sampleText, language, source = null, gender = null) {
return {
instruct,
sample_text: sampleText,
language,
gender: gender || source?.gender || $('design-gender')?.value || '',
dialogue: isDialogueDesign(instruct, sampleText, source),
};
}
let _dVoiceIdManual = false;
function designSafeName(name) {
return String(name || 'VoiceDesign')
.replace(/^[A-Z]{2}_[FMN]_/, '')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 42) || 'VoiceDesign';
}
function voiceIdSafePart(value, fallback = 'style') {
return String(value || fallback)
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 32) || fallback;
}
function suggestedStyleVoiceId(baseId, style) {
const suffix = voiceIdSafePart(style || 'style');
return `${baseId}_${suffix}`.slice(0, 96);
}
function _updateDVoiceId() {
if (_dVoiceIdManual) return;
const lang = $('d-lang').value, gender = $('d-gender').value, name = $('d-name').value.trim();
$('d-voice-id').value = name ? `${lang}_${gender}_${name}` : '';
}
['d-lang','d-gender'].forEach(id => $(id).addEventListener('change', _updateDVoiceId));
$('d-name').addEventListener('input', () => { _dVoiceIdManual = false; _updateDVoiceId(); });
$('d-voice-id').addEventListener('input', () => { _dVoiceIdManual = true; });
seedDesignPresets();
refreshDesignPresetSelect();
renderQwenSampleCards();
syncDesignPresetsToServer();
$('design-preset-select').addEventListener('change', () => {
if ($('design-preset-select').value) applyDesignPreset($('design-preset-select').value);
});
$('design-preset-load').addEventListener('click', () => {
const name = $('design-preset-select').value || $('design-preset-name').value.trim();
if (!name) { toast('Select a preset first', 'error'); return; }
applyDesignPreset(name);
});
$('design-preset-save').addEventListener('click', () => {
const name = $('design-preset-name').value.trim() || $('design-preset-select').value;
if (!name) { toast('Enter a preset name', 'error'); $('design-preset-name').focus(); return; }
const presets = loadDesignPresets();
presets[name] = {
description: $('design-instruct').value,
sample_text: $('design-sample-text').value,
language: $('design-language').value,
gender: $('design-gender').value,
dialogue: isDialogueDesign($('design-instruct').value, $('design-sample-text').value, currentDesignSource),
};
saveDesignPresets(presets);
syncDesignPresetsToServer();
refreshDesignPresetSelect();
$('design-preset-select').value = name;
toast('Preset saved: ' + name, 'success');
});
$('design-preset-delete').addEventListener('click', () => {
const name = $('design-preset-select').value || $('design-preset-name').value.trim();
if (!name) { toast('Select a preset first', 'error'); return; }
const presets = loadDesignPresets();
if (!presets[name]) { toast('Preset not found', 'error'); return; }
delete presets[name];
saveDesignPresets(presets);
syncDesignPresetsToServer();
refreshDesignPresetSelect();
$('design-preset-name').value = '';
toast('Preset deleted: ' + name, 'success');
});
['design-instruct','design-sample-text'].forEach(id => $(id).addEventListener('input', () => {
currentDesignSource = null;
if (id === 'design-sample-text') $('d-transcript').value = $('design-sample-text').value;
}));
document.querySelectorAll('.qwen-sample').forEach(card => {
const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample];
const state = card.querySelector('.qwen-state');
const audio = card.querySelector('audio');
card.querySelector('.qwen-use').addEventListener('click', () => {
applyQwenSample(sample);
toast('Voice Design sample loaded', 'success');
});
card.querySelector('.qwen-preview').addEventListener('click', async e => {
const btn = e.currentTarget;
btn.disabled = true;
state.textContent = 'Generating preview…';
try {
const r = await fetch('/api/voice-design', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(voiceDesignPayload(sample.description, sample.text, sample.language, sample))});
if (!r.ok) { const err = await r.json().catch(()=>({})); throw new Error(err.detail || r.statusText); }
const d = await r.json();
audio.src = '/api/audio/' + d.id;
audio.style.display = '';
audio.play().catch(()=>{});
state.textContent = 'Preview ready';
} catch(err) {
state.textContent = 'Preview failed';
toast('Sample preview failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
});
});
// ── Voice design ──────────────────────────────────────────────────────────
async function runVoiceDesign() {
const baseInstruct=$('design-instruct').value.trim(), sample=$('design-sample-text').value.trim();
const dialogue = isDialogueDesign(baseInstruct, sample, currentDesignSource);
const instruct = baseInstruct;
if (!instruct) { toast('Enter a voice description first','error'); return; }
$('design-generate-btn').disabled=true; $('design-status').textContent='Generating…';
$('design-result').style.display='none'; $('design-save-result').style.display='none';
status('Generating voice design…');
try {
const r = await fetch('/api/voice-design', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(voiceDesignPayload(instruct, sample, $('design-language').value, currentDesignSource, $('design-gender').value)) });
if (!r.ok) { const e=await r.json(); throw new Error(e.detail||r.statusText); }
const d = await r.json(); designedFileId=d.id; trimmedFileId=null; editingVoiceId=null;
$('design-audio').src='/api/audio/'+d.id;
$('design-result').style.display='flex';
$('design-status').textContent='Done ('+d.duration.toFixed(1)+' s)';
const langCode = DESIGN_LANG_CODE[$('design-language').value] || 'EN';
$('d-lang').value = langCode;
$('d-gender').value = $('design-gender').value;
$('d-name').value = designSafeName(currentDesignSource?.title || currentDesignSource?.name || $('design-preset-name').value || 'VoiceDesign');
_dVoiceIdManual = false; _updateDVoiceId();
$('d-transcript').value = sample;
$('trim-audio').src='/api/audio/'+d.id; $('trim-audio').style.display=''; $('no-audio-hint').style.display='none';
if (!$('transcript-area').value) $('transcript-area').value=sample;
$('design-audio').play().catch(()=>{});
$('design-result').scrollIntoView({behavior:'smooth',block:'nearest'});
toast('Voice generated and export fields filled.','success');
status('Voice design ready');
} catch(e) {
$('design-status').textContent='Failed: '+e.message;
toast('Voice design failed: '+e.message,'error');
status('Voice design failed');
} finally { $('design-generate-btn').disabled=false; }
}
$('design-generate-btn').addEventListener('click', runVoiceDesign);
$('design-retry-btn').addEventListener('click', runVoiceDesign);
$('design-save-btn').addEventListener('click', async () => {
if (!designedFileId) { toast('No voice generated yet','error'); return; }
const voiceId = $('d-voice-id').value.trim();
if (!voiceId) { toast('Enter a Voice ID first','error'); $('d-name').focus(); return; }
if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; }
$('design-save-btn').disabled=true;
try {
const r = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:designedFileId, voice_id:voiceId, transcript:$('d-transcript').value})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const saved = await r.json();
await saveMeta(saved.voice_id, {
gender: $('d-gender').value,
flag: LANG_FLAG_DEFAULT[$('d-lang').value] || undefined,
transcript: $('d-transcript').value,
note: 'Voice Design: ' + $('design-instruct').value.slice(0, 240),
}).catch(()=>{});
await loadVoiceLibrary().catch(()=>{});
$('design-save-result').style.display='flex';
$('design-save-result').scrollIntoView({behavior:'smooth',block:'nearest'});
toast('Exported to Voice Clone Library: '+saved.voice_id,'success');
status('Exported to Voice Clone Library: '+saved.voice_id);
} catch(e) { toast('Save failed: '+e.message,'error'); }
finally { $('design-save-btn').disabled=false; }
});
$('design-download-btn').addEventListener('click', () => {
if (!designedFileId) return;
const a=document.createElement('a'); a.href='/api/audio/'+designedFileId; a.download=($('d-voice-id').value.trim() || 'voice_design') + '.wav'; a.click();
});
// ── Transcribe ────────────────────────────────────────────────────────────
$('transcribe-btn').addEventListener('click', async () => {
const id = trimmedFileId||designedFileId||currentFileId;
if (!id) { toast('No audio to transcribe','error'); return; }
$('transcribe-btn').disabled=true; $('transcribe-status').textContent='Transcribing…';
try {
const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
const d = await r.json(); $('transcript-area').value=d.text; $('transcribe-status').textContent='Done';
toast('Transcription complete','success');
} catch(e) { $('transcribe-status').textContent='Failed: '+e.message; toast('Transcription failed: '+e.message,'error'); }
finally { $('transcribe-btn').disabled=false; }
});
// ── 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 _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 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 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].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();
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 = _voices.filter(v => v.enabled !== false);
const text = benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
if (!voices.length) { toast('No active 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);
}
function showBenchmarkConfirm() {
const voices = activeBenchmarkVoices();
const text = benchmarkSampleText();
if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; }
if (!voices.length) { toast('No active voices to benchmark', 'error'); return; }
const staleCount = voices.filter(v => v.needs_tts_restart).length;
$('benchmark-confirm-title').textContent = `Benchmark ${voices.length} active voices?`;
$('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 = '';
populateLibraryFilters();
readLibraryFilters();
// Apply sidebar category filter
const cat = window._voiceSidebarCat || 'all';
let filtered = _voices.filter(v => {
if (cat === 'cloned') return v.has_ref;
if (cat === 'designed') return !v.has_ref;
if (cat === 'favorites') return (v.rating || 0) >= 4;
if (cat === 'hidden') return v.enabled === false;
if (cat === 'tools') return false; // no rows for tools view
// 'all' — respect show-disabled toggle
return showDisabled || v.enabled !== false;
});
// For hidden/favorites cats always show all regardless of show-disabled toggle
if (cat !== 'all' && cat !== 'tools') {
// already filtered above — no extra enabled filter needed
} else if (cat === 'all') {
// already applied enabled filter in the lambda above
}
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">&#128193;</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">&#128193; Browse</button>
<button class="btn-primary" id="voices-empty-save-btn">Set &amp; reload</button>
</div>
<div class="vef-dir-browser" id="vef-dir-browser" hidden>
<div class="vef-breadcrumb" id="vef-breadcrumb"></div>
<div class="vef-dir-list" id="vef-dir-list"><span class="vef-loading">Loading…</span></div>
<div class="vef-browser-actions">
<span class="vef-selected-path note" id="vef-selected-path"></span>
<button class="btn-primary" id="vef-select-btn">Use this folder</button>
</div>
</div>
<p class="voices-empty-hint">Map a host folder via docker-compose:<br>
<code>- /your/host/path:/voices:rw</code><br>
or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
</div>
<div class="voices-empty-divider">or</div>
<div class="voices-empty-case">
<div class="voices-empty-case-icon">&#127908;</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">&#9889; Clone a Voice</button>
<button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio">&#127760; 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)}">&#128193; ${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;
}
filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
syncSortHeaders();
}
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();
const langOpts = FLAG_OPTIONS[langCode] || [];
// Use language-specific variants if there are multiple; fall back to world picker otherwise
const flagOpts = langOpts.length > 1 ? langOpts : ALL_FLAGS;
const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || '';
const flagEmoji = currentFlag ? cc2flag(currentFlag) : '🌐';
const flagCode = currentFlag ? ccDisplay(currentFlag) : '?';
wrap.dataset.flagEmoji = flagEmoji;
wrap.dataset.flagCc = currentFlag || '';
wrap.dataset.rating = v.rating || 0;
wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false';
const fileType = voiceFileType(v);
const dbfs = fmtDbfs(v);
const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
const benchText = fmtBenchmark(v);
const benchTitle = benchmarkTitle(v);
const benchCls = benchmarkClass(v);
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
const gender = v.gender || '';
const starsHtml = [1,2,3,4,5].map(i =>
`<span class="star${i<=(v.rating||0)?' on':''}" data-val="${i}">&#9733;</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;
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 > flag icon > initial letter (with color)
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo' : flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
const avatarStyle = picSrc || flagIconHtml ? '' : `style="background:${color}"`;
const avatarContent = picSrc
? `<img src="${picSrc}" alt="" class="vl-avatar-img">`
: 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">${escHtml(v.id)}</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>` : ''}
</div>
</div>
<div class="vr-play-group">
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording">&#9654;</button></div>
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview">&#9654;</button></div>
</div>
</div>
<div class="vr-main-row">
<div class="vr-photo" title="Click to upload photo">
${picSrc ? `<img src="${picSrc}" alt="">` : '<div class="ph-icon">&#128100;</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">&#10003;</button>
<button class="icon-btn rename-cancel" title="Cancel">&#10005;</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">&#9998;</button>
</div>
<div class="vr-play-group">
<div class="vr-play vr-play-original">
<button title="Play / pause original recording">&#9654;</button>
</div>
<div class="vr-play vr-play-synth">
<button title="Generate and play synthesized sample">&#9654;</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' : ''}">&#128172;</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">Reference audio &middot; crop <span class="opt-chevron">&#8964;</span></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">Reference transcript <span class="opt-chevron">&#8964;</span></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">Voice match <span class="opt-chevron">&#8964;</span></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>
<div class="opt-group opt-style-panel">
<div class="opt-group-title">Style variation <span class="opt-chevron">&#8964;</span></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>; 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">Loudness <span class="opt-chevron">&#8964;</span></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">Restart TTS</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>
`;
hydrateVoiceDuration(v, wrap.querySelector('.vr-length'));
// 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' : ''}` : '';
wrap.querySelector('.vr-length').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 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();
wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
wrap.querySelector('.vr-length').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) drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
};
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;
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;
}
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' : '';
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
wrap.querySelector('.vr-type').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'); }
});
const styleVariationInput = () => {
const style = optStyleInstruct.value.trim();
const text = optTranscript.value.trim() || getBenchmarkSampleText();
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);
wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
wrap.querySelector('.vr-type').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 TTS so edited voices are rescanned...');
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();
setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`);
toast('TTS restarted. Voices rescanned.', 'success');
} catch(e) {
toast('Restart TTS failed: ' + e.message, 'error');
setOptStatus('Restart TTS failed');
} 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 = '&#9654;', pauseIcon = '&#10073;&#10073;', generatingIcon = '&#8987;';
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;
}
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 });
// ── TTS preview ───────────────────────────────────────────────────────────
function backendVoiceId(value) {
return typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || JSON.stringify(value));
}
function shouldFilterBackendVoices(backend) {
return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || '');
}
async function activeLibraryVoiceIds() {
if (!_voices.length) await loadVoiceLibrary();
return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id));
}
function cleanReferenceText(text) {
return String(text || '').trim();
}
function selectedPreviewLibraryVoice() {
const id = $('tts-voice-select')?.value || '';
return id ? (_voices || []).find(v => v.id === id) : null;
}
function previewVoiceWarnings(v) {
const warnings = [];
const backend = backendById($('tts-backend-select')?.value || '');
if (backend && backend.id && !['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend.id)) {
warnings.push(backend.id === 'nvidia_magpie' ? 'NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.' : 'This backend may follow style/model voice more than the saved WAV identity.');
}
if (backend && backend.id === 'nvidia_zeroshot' && v.duration && (Number(v.duration) < 3 || Number(v.duration) > 10)) {
warnings.push('NVIDIA Zeroshot works best with a clear 3-10 second prompt.');
}
if (backend && backend.id === 'nvidia_flow' && !v.transcript) {
warnings.push('NVIDIA Flow requires the exact saved reference transcript for this voice.');
}
if (!v.transcript) warnings.push('No reference transcript is saved; cloned identity is harder to judge.');
if (v.duration && (Number(v.duration) < 3 || Number(v.duration) > 20)) warnings.push('Reference clip length is outside the 3-20 second sweet spot.');
if (v.needs_tts_restart) warnings.push('This voice changed since the last backend refresh; restart or clear restart flags before judging it.');
const healthWarnings = v.health && Array.isArray(v.health.warnings) ? v.health.warnings : [];
warnings.push(...healthWarnings.slice(0, 3));
return warnings;
}
function updatePreviewVoiceMatchPanel() {
const panel = $('preview-match-panel');
if (!panel) return;
const v = selectedPreviewLibraryVoice();
if (!v) { panel.hidden = true; return; }
panel.hidden = false;
const lang = v.language || v.lang || (v.id || '').split('_')[0] || '-';
const gender = v.gender || (v.id || '').split('_')[1] || '-';
const db = fmtDbfs(v);
const dur = v.duration ? fmtDuration(v.duration) : '-';
$('preview-match-title').textContent = v.id;
$('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`;
const warnings = previewVoiceWarnings(v);
$('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.';
const transcript = cleanReferenceText(v.transcript || '');
$('preview-match-transcript').textContent = transcript || 'No reference text saved for this voice.';
$('preview-ref-use-text').disabled = !transcript;
$('preview-ref-synth').disabled = !transcript;
const audio = $('preview-ref-audio');
const expected = voiceFileUrl(v);
if (audio.dataset.src !== expected) {
audio.pause();
audio.src = expected;
audio.dataset.src = expected;
}
}
async function synthesizeSelectedReferenceText() {
const v = selectedPreviewLibraryVoice();
if (!v) { toast('Select a library voice first', 'error'); return; }
let text = cleanReferenceText(v.transcript || '');
if (!text) { toast('This voice has no reference text', 'error'); return; }
if (v.needs_tts_restart) {
const ok = confirm('This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?');
if (!ok) return;
await clearTtsRestartFlags();
v.needs_tts_restart = false;
updatePreviewVoiceMatchPanel();
}
const backend = $('tts-backend-select').value;
if (!backend) { toast('No available TTS backend', 'error'); return; }
const btn = $('preview-ref-synth');
btn.disabled = true;
try {
$('preview-text-area').value = text;
const source = await createTtsAudioSource(v.id, text, backend, $('preview-playback-mode').value, $('preview-style-instruction').value.trim());
previewBlob = source.blob;
const audio = $('preview-audio');
audio.src = source.url;
audio.style.display = '';
await audio.play();
$('save-preview-mp3-btn').disabled = false;
$('save-preview-btn').disabled = source.streaming;
toast(source.streaming ? 'Reference text streaming' : 'Reference text synthesized', 'success');
} catch(e) { toast('Reference synthesis failed: ' + e.message, 'error'); }
finally { btn.disabled = false; }
}
$('fetch-tts-voices-btn').addEventListener('click', async () => {
$('fetch-tts-voices-btn').disabled = true;
try {
const backend = $('tts-backend-select')?.value;
if (!backend) throw new Error('No available TTS backend');
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
let voices = Array.isArray(rawVoices) ? rawVoices : [];
if (shouldFilterBackendVoices(backend)) {
const activeIds = await activeLibraryVoiceIds();
voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
}
const sel = $('tts-voice-select'), prev = sel.value;
sel.innerHTML = '<option value="">— select after fetch —</option>';
voices.forEach(v => {
const id = backendVoiceId(v);
const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt);
});
if(prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev;
updatePreviewVoiceMatchPanel();
const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices';
toast('Fetched '+voices.length+suffix,'success');
} catch(e) { toast('Fetch failed: '+e.message,'error'); }
finally { $('fetch-tts-voices-btn').disabled = false; }
});
$('tts-backend-select').addEventListener('change', () => {
const sel = $('tts-voice-select');
sel.innerHTML = '<option value="">— select after fetch —</option>';
updateBackendHelp();
updatePreviewVoiceMatchPanel();
previewBlob = null;
$('save-preview-mp3-btn').disabled = true;
$('save-preview-btn').disabled = true;
});
$('tts-voice-select').addEventListener('change', updatePreviewVoiceMatchPanel);
$('preview-ref-play').addEventListener('click', async () => {
updatePreviewVoiceMatchPanel();
const audio = $('preview-ref-audio');
try { await audio.play(); }
catch(e) { toast('Reference playback failed: ' + e.message, 'error'); }
});
$('preview-ref-use-text').addEventListener('click', () => {
const v = selectedPreviewLibraryVoice();
const text = cleanReferenceText(v?.transcript || '');
if (!text) { toast('This voice has no reference text', 'error'); return; }
$('preview-text-area').value = text;
toast('Reference text copied to target text', 'success');
});
$('preview-ref-synth').addEventListener('click', synthesizeSelectedReferenceText);
let _ttsStreamHealth = null;
function effectiveTtsPlaybackMode(override = 'settings') {
if (override && override !== 'settings') return override;
return _appSettings.tts_stream_mode || 'auto';
}
async function isTtsStreamAvailable(force = false) {
if (_ttsStreamHealth && !force) return _ttsStreamHealth.ok;
try {
_ttsStreamHealth = await fetch('/api/tts-stream-health').then(r => r.json());
return !!_ttsStreamHealth.ok;
} catch (_) {
_ttsStreamHealth = {ok:false};
return false;
}
}
async function createTtsStreamUrl(voice, text, instruct = '') {
if (!await isTtsStreamAvailable()) throw new Error('streaming backend unavailable');
const r = await fetch('/api/tts-stream-session', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({text,voice,instruct})});
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const data = await r.json();
return data.url;
}
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone') {
const r = await fetch('/api/tts-preview', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({text,voice,response_format:responseFormat,instruct,backend})});
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
return await r.blob();
}
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '') {
const mode = effectiveTtsPlaybackMode(modeOverride);
if (backend !== 'streaming' || mode === 'buffered') {
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend);
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
}
try {
return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
} catch (e) {
if (mode === 'streaming') throw e;
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend);
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
}
}
let previewBlob = null;
const PREVIEW_SAMPLE_TEXT = 'Hello! This is a voice preview from TTS Voice Creator - Clone and Design.';
$('preview-text-area').addEventListener('focus', () => {
if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = '';
}, { once:true });
$('preview-btn').addEventListener('click', async () => {
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
if(!backend) { toast('No available TTS backend','error'); return; }
if(!voice) { toast('Select a TTS voice','error'); return; }
if(!text) { toast('Enter preview text','error'); return; }
$('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true;
try {
const audio = $('preview-audio');
const source = await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct);
previewBlob = source.blob;
audio.src = source.url;
audio.style.display='';
await audio.play();
$('save-preview-mp3-btn').disabled = false;
$('save-preview-btn').disabled = source.streaming;
toast(source.streaming ? 'Streaming preview playing' : 'Preview playing', 'success');
} catch(e) { toast('TTS failed: '+e.message,'error'); }
finally { $('preview-btn').disabled=false; }
});
$('save-preview-mp3-btn').addEventListener('click', async () => {
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
if(!backend) { toast('No available TTS backend','error'); return; }
if(!voice || !text) return;
const btn = $('save-preview-mp3-btn');
btn.disabled = true;
try {
const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (voice||'preview')+'_preview.mp3'; a.click();
toast('MP3 saved', 'success');
} catch(e) { toast('MP3 save failed: '+e.message,'error'); }
finally { btn.disabled = false; }
});
$('save-preview-btn').addEventListener('click', () => {
if(!previewBlob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(previewBlob);
a.download = ($('tts-voice-select').value||'preview')+'_preview.wav'; a.click();
});
// ── STT -> TTS ───────────────────────────────────────────────────────────
let sttTtsSourceId = null;
let sttTtsOutputBlob = null;
let _sttBackends = [];
let sttTtsRecorder = null;
let sttTtsRecordStream = null;
let sttTtsRecordChunks = [];
let sttTtsRecordTimer = null;
let sttTtsRecordSecs = 0;
function sttTtsSelectedSttBackend() {
return $('stt-tts-stt-backend')?.value || 'configured';
}
function sttBackendOptionHtml(selected = 'configured') {
if (!_sttBackends.length) return '<option value="configured">Configured Whisper/STT</option>';
const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected);
return _sttBackends.map(b => {
const suffix = b.available ? '' : ' (unavailable)';
const disabled = b.available ? '' : ' disabled';
return `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}${disabled}>${escHtml(b.label + suffix)}</option>`;
}).join('');
}
function updateSttBackendHelp() {
const selected = sttTtsSelectedSttBackend();
const b = _sttBackends.find(item => item.id === selected) || _sttBackends.find(item => item.available) || null;
const help = $('stt-tts-stt-help');
if (!help) return;
if (!b) { help.textContent = 'No STT engine status loaded yet.'; return; }
const models = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : '';
help.textContent = `${b.available ? 'Ready' : 'Unavailable'} at ${b.url}.${models}`;
}
async function refreshSttBackends(selected = '') {
try {
const d = await fetch('/api/stt-backends').then(r => r.json());
_sttBackends = (d.backends || []).filter(b => b && b.id);
} catch (_) {
_sttBackends = [];
}
const sel = $('stt-tts-stt-backend');
if (sel) {
const prev = selected || sel.value || 'configured';
sel.innerHTML = sttBackendOptionHtml(prev);
sel.disabled = !_sttBackends.some(b => b.available);
}
updateSttBackendHelp();
}
function sttTtsSelectedBackend() {
return $('stt-tts-backend-select')?.value || '';
}
function sttTtsDownload(blob, name) {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
}
async function sttTtsUploadFile(file) {
if (!file) return;
$('stt-tts-source-status').textContent = 'Uploading ' + file.name + '...';
sttTtsSourceId = null;
sttTtsOutputBlob = null;
$('stt-tts-transcribe-btn').disabled = true;
$('stt-tts-copy-preview-btn').disabled = true;
$('stt-tts-save-mp3-btn').disabled = true;
$('stt-tts-save-wav-btn').disabled = true;
const fd = new FormData();
fd.append('file', file);
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();
sttTtsSourceId = d.id;
const audio = $('stt-tts-source-audio');
audio.src = '/api/audio/' + encodeURIComponent(d.id);
audio.style.display = '';
$('stt-tts-source-status').textContent = `${d.filename || file.name} loaded (${Number(d.duration || 0).toFixed(1)} s).`;
$('stt-tts-transcribe-btn').disabled = false;
toast('Speech audio loaded', 'success');
} catch (e) {
$('stt-tts-source-status').textContent = 'Upload failed.';
toast('STT source upload failed: ' + e.message, 'error');
}
}
async function sttTtsFetchVoices() {
const backend = sttTtsSelectedBackend();
if (!backend) throw new Error('No available TTS backend');
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
let voices = Array.isArray(rawVoices) ? rawVoices : [];
if (shouldFilterBackendVoices(backend)) {
const activeIds = await activeLibraryVoiceIds();
voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
}
const sel = $('stt-tts-voice-select'), prev = sel.value;
sel.innerHTML = '<option value="">-- select after fetch --</option>';
voices.forEach(v => {
const id = backendVoiceId(v);
const opt = document.createElement('option');
opt.value = opt.textContent = id;
sel.appendChild(opt);
});
if (prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev;
return voices.length;
}
$('stt-tts-file')?.addEventListener('change', async () => {
const input = $('stt-tts-file');
if (input.files && input.files.length) await sttTtsUploadFile(input.files[0]);
input.value = '';
});
$('stt-tts-refresh-stt-btn')?.addEventListener('click', async () => {
const btn = $('stt-tts-refresh-stt-btn');
btn.disabled = true;
try {
await refreshSttBackends(sttTtsSelectedSttBackend());
toast('STT engines refreshed', 'success');
} finally {
btn.disabled = false;
}
});
$('stt-tts-stt-backend')?.addEventListener('change', updateSttBackendHelp);
function sttTtsSetRecording(on) {
$('stt-tts-rec-start').disabled = on;
$('stt-tts-rec-stop').disabled = !on;
}
function sttTtsStopTracks() {
if (sttTtsRecordStream) sttTtsRecordStream.getTracks().forEach(t => t.stop());
sttTtsRecordStream = null;
}
$('stt-tts-rec-start')?.addEventListener('click', async () => {
try {
sttTtsRecordStream = await requestMicrophoneStream();
sttTtsRecordChunks = [];
sttTtsRecordSecs = 0;
$('stt-tts-rec-time').textContent = '0:00';
$('stt-tts-source-status').textContent = 'Recording...';
sttTtsSetRecording(true);
sttTtsRecordTimer = setInterval(() => {
sttTtsRecordSecs++;
$('stt-tts-rec-time').textContent = Math.floor(sttTtsRecordSecs / 60) + ':' + String(sttTtsRecordSecs % 60).padStart(2, '0');
}, 1000);
sttTtsRecorder = new MediaRecorder(sttTtsRecordStream);
sttTtsRecorder.ondataavailable = e => { if (e.data.size) sttTtsRecordChunks.push(e.data); };
sttTtsRecorder.onstop = async () => {
clearInterval(sttTtsRecordTimer);
sttTtsRecordTimer = null;
sttTtsSetRecording(false);
sttTtsStopTracks();
const mime = sttTtsRecorder.mimeType || 'audio/webm';
const blob = new Blob(sttTtsRecordChunks, {type:mime});
const ext = mime.includes('ogg') ? '.ogg' : '.webm';
if (!blob.size) {
$('stt-tts-source-status').textContent = 'Recording was empty.';
toast('Recording was empty', 'error');
return;
}
await sttTtsUploadFile(new File([blob], 'stt-recording' + ext, {type:mime}));
};
sttTtsRecorder.start(100);
toast('Recording started', 'success');
} catch (e) {
sttTtsSetRecording(false);
sttTtsStopTracks();
const message = await microphoneErrorMessage(e);
$('stt-tts-source-status').textContent = message;
toast(message, 'error');
}
});
$('stt-tts-rec-stop')?.addEventListener('click', () => {
if (sttTtsRecorder && sttTtsRecorder.state !== 'inactive') sttTtsRecorder.stop();
});
$('stt-tts-transcribe-btn')?.addEventListener('click', async () => {
if (!sttTtsSourceId) { toast('Load speech audio first', 'error'); return; }
const btn = $('stt-tts-transcribe-btn');
btn.disabled = true;
$('stt-tts-source-status').textContent = 'Transcribing...';
try {
const r = await fetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:sttTtsSourceId, backend:sttTtsSelectedSttBackend()})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
$('stt-tts-text').value = d.text || '';
$('stt-tts-copy-preview-btn').disabled = !(d.text || '').trim();
const used = d.backend ? ' via ' + d.backend : '';
$('stt-tts-source-status').textContent = 'Transcription ready' + used + '.';
toast('Transcription ready', 'success');
} catch (e) {
$('stt-tts-source-status').textContent = 'Transcription failed.';
toast('STT failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-copy-preview-btn')?.addEventListener('click', () => {
const text = $('stt-tts-text').value.trim();
if (!text) return;
$('preview-text-area').value = text;
switchTab('generation');
toast('Copied transcription to TTS Generation', 'success');
});
$('stt-tts-backend-select')?.addEventListener('change', () => {
$('stt-tts-voice-select').innerHTML = '<option value="">-- select after fetch --</option>';
sttTtsOutputBlob = null;
$('stt-tts-save-mp3-btn').disabled = true;
$('stt-tts-save-wav-btn').disabled = true;
updateBackendHelp();
});
$('stt-tts-fetch-voices-btn')?.addEventListener('click', async () => {
const btn = $('stt-tts-fetch-voices-btn');
btn.disabled = true;
try {
const count = await sttTtsFetchVoices();
toast('Fetched ' + count + ' voices', 'success');
} catch (e) {
toast('Fetch failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-generate-btn')?.addEventListener('click', async () => {
const backend = sttTtsSelectedBackend();
const voice = $('stt-tts-voice-select').value;
const text = $('stt-tts-text').value.trim();
const instruct = $('stt-tts-style-instruction').value.trim();
if (!backend) { toast('No available TTS backend', 'error'); return; }
if (!voice) { toast('Select a TTS voice', 'error'); return; }
if (!text) { toast('Transcribe or enter text first', 'error'); return; }
const btn = $('stt-tts-generate-btn');
btn.disabled = true;
$('stt-tts-save-mp3-btn').disabled = true;
$('stt-tts-save-wav-btn').disabled = true;
try {
const source = await createTtsAudioSource(voice, text, backend, $('stt-tts-playback-mode').value, instruct);
sttTtsOutputBlob = source.blob;
const audio = $('stt-tts-output-audio');
audio.src = source.url;
audio.style.display = '';
await audio.play();
$('stt-tts-save-mp3-btn').disabled = false;
$('stt-tts-save-wav-btn').disabled = source.streaming;
toast(source.streaming ? 'Streaming synthesized speech' : 'Synthesized speech ready', 'success');
} catch (e) {
toast('TTS failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-save-mp3-btn')?.addEventListener('click', async () => {
const backend = sttTtsSelectedBackend();
const voice = $('stt-tts-voice-select').value;
const text = $('stt-tts-text').value.trim();
const instruct = $('stt-tts-style-instruction').value.trim();
if (!backend || !voice || !text) return;
const btn = $('stt-tts-save-mp3-btn');
btn.disabled = true;
try {
const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend);
sttTtsDownload(blob, (voice || 'stt_tts') + '_stt_tts.mp3');
toast('MP3 saved', 'success');
} catch (e) {
toast('MP3 save failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-save-wav-btn')?.addEventListener('click', () => {
if (!sttTtsOutputBlob) return;
sttTtsDownload(sttTtsOutputBlob, ($('stt-tts-voice-select').value || 'stt_tts') + '_stt_tts.wav');
});
// ── Init ──────────────────────────────────────────────────────────────────
initBenchmarkSampleControls();
// Sync visible preview-text input with hidden benchmark-sample-text
(function syncPreviewInput() {
const visible = $('vl-preview-sample');
const hidden = $('benchmark-sample-text');
if (!visible) return;
const stored = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY);
visible.value = stored || DEFAULT_BENCHMARK_SAMPLE_TEXT;
if (hidden) hidden.value = visible.value;
visible.addEventListener('input', () => {
if (hidden) hidden.value = visible.value;
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, visible.value.trim());
});
})();
// Sync flag button to current preview text language (best-match)
(function syncPreviewLangBtn() {
const ta = $('vl-preview-sample');
const btn = $('vl-preview-lang-btn');
if (!ta || !btn) return;
const cur = ta.value.trim();
const matched = Object.keys(VL_SAMPLE_TEXTS).find(l => VL_SAMPLE_TEXTS[l] === cur) || 'EN';
const cc = (_VL_LANG_FLAG[matched] || 'gb').toLowerCase();
btn.innerHTML = `<span class="fi fi-${cc}"></span>`;
btn.title = (LANGUAGE_LABELS[matched] || matched) + ' — click to change sample language';
btn.dataset.lang = matched;
})();
// Synth mode segmented control (preview text vs. reference transcript)
(function initSynthMode() {
const seg = $('vl-synth-mode-seg');
const wrap = $('vl-preview-text-wrap');
if (!seg) return;
seg.querySelectorAll('.vl-synth-seg-btn').forEach(btn => {
btn.addEventListener('click', () => {
seg.querySelectorAll('.vl-synth-seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (wrap) wrap.classList.toggle('hidden', btn.dataset.mode === 'transcript');
});
});
})();
loadSettings().then(() => {
refreshSttBackends();
renderIntegrationSnippets();
if (!localStorage.getItem(SETTINGS_SEEN_KEY)) openSettings(true);
}).catch(e => status('Settings load failed: ' + e.message));
// ── ElevenLabs Voice Library Browser ──────────────────────────────────────
(function initElevenLabsBrowser() {
if (!$('el-browser-card')) return;
let _elPage = 0, _elHasMore = false, _elTotal = 0;
let _elAudio = null, _elAudioBtn = null;
let _elFilters = {};
let _elSearchTimer;
const _elHue = str => {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff;
return Math.abs(h) % 360;
};
const _elEsc = s => String(s || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Load key state from settings
function elLoadKey() {
fetch('/api/settings').then(r => r.json()).then(s => {
const key = (s.elevenlabs_api_key || '').trim();
const inp = $('el-api-key'), st = $('el-key-status');
if (inp) inp.placeholder = key ? '••••••••••••••• (key saved)' : 'ElevenLabs API key (free — unlocks full library)…';
if (st) { st.textContent = key ? '✓ Key active' : 'No key — max 3 results'; st.className = 'el-key-status ' + (key ? 'el-key-ok' : 'el-key-none'); }
elFetch();
}).catch(() => elFetch());
}
$('el-key-save').addEventListener('click', () => {
const inp = $('el-api-key');
const val = (inp?.value || '').trim();
if (!val || val.startsWith('•')) { _elPage = 0; elFetch(); return; }
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) })
.then(() => {
const st = $('el-key-status');
if (st) { st.textContent = '✓ Key saved'; st.className = 'el-key-status el-key-ok'; }
if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; }
_elPage = 0; elFetch();
});
});
$('el-api-key')?.addEventListener('keydown', e => { if (e.key === 'Enter') $('el-key-save')?.click(); });
$('el-cats')?.querySelectorAll('.el-cat').forEach(pill => {
pill.addEventListener('click', () => {
$('el-cats').querySelectorAll('.el-cat').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
_elFilters = {};
for (const attr of pill.getAttributeNames()) {
if (attr.startsWith('data-el-') && pill.getAttribute(attr))
_elFilters[attr.slice('data-el-'.length)] = pill.getAttribute(attr);
}
_elPage = 0; elFetch();
});
});
['el-lang', 'el-gender', 'el-age'].forEach(id => {
$(id)?.addEventListener('change', () => { _elPage = 0; elFetch(); });
});
$('el-search')?.addEventListener('input', () => {
clearTimeout(_elSearchTimer);
_elSearchTimer = setTimeout(() => { _elPage = 0; elFetch(); }, 420);
});
$('el-fetch')?.addEventListener('click', () => { _elPage = 0; elFetch(); });
$('el-prev')?.addEventListener('click', () => { if (_elPage > 0) { _elPage--; elFetch(); } });
$('el-next')?.addEventListener('click', () => { if (_elHasMore) { _elPage++; elFetch(); } });
function elParams() {
const p = new URLSearchParams({ page_size: '24', page: String(_elPage) });
const lang = $('el-lang')?.value, gender = $('el-gender')?.value, age = $('el-age')?.value;
const search = ($('el-search')?.value || '').trim();
if (lang) p.set('language', lang);
if (gender) p.set('gender', gender);
if (age) p.set('age', age);
if (search) p.set('search', search);
Object.entries(_elFilters).forEach(([k, v]) => { if (v) p.set(k, v); });
return p.toString();
}
async function elFetch() {
const grid = $('el-grid'), sb = $('el-status-bar'), st = $('el-status-text');
if (grid) grid.innerHTML = '<div class="el-loading"><span class="el-spinner"></span> Loading voices…</div>';
try {
const res = await fetch('/api/elevenlabs/voices?' + elParams());
const data = await res.json();
if (data.detail) {
if (grid) grid.innerHTML = `<div class="el-error">⚠ ${_elEsc(data.detail?.message || JSON.stringify(data.detail))}</div>`;
if (sb) sb.hidden = true;
return;
}
_elHasMore = !!data.has_more;
_elTotal = data.total_count || 0;
const voices = data.voices || [];
if (st) {
const cap = voices.length < 24 && _elTotal > 3 ? ' · \u{1F511} Add your free API key for full access' : '';
st.innerHTML = `<strong>${_elTotal.toLocaleString()}</strong> voices &middot; showing ${voices.length} &middot; page ${_elPage + 1}${cap}`;
}
if (sb) sb.hidden = false;
if (!voices.length) {
if (grid) grid.innerHTML = '<div class="el-empty">No voices found — try different filters.</div>';
} else {
if (grid) { grid.innerHTML = voices.map(elCard).join(''); }
grid?.querySelectorAll('.el-play').forEach(b => b.addEventListener('click', () => elPlay(b)));
grid?.querySelectorAll('.el-clone').forEach(b => b.addEventListener('click', () => elImport(b)));
}
const pager = $('el-pager');
if (pager) {
pager.hidden = _elPage === 0 && !_elHasMore;
const pb = $('el-prev'), nb = $('el-next'), pi = $('el-pager-info');
if (pb) pb.disabled = _elPage === 0;
if (nb) nb.disabled = !_elHasMore;
if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`;
}
} catch (e) {
if (grid) grid.innerHTML = `<div class="el-error">⚠ ${_elEsc(e.message)}</div>`;
}
}
function elCard(v) {
const hue = _elHue(v.voice_id || v.name || '');
const letter = _elEsc((v.name || '?')[0].toUpperCase());
const lang = (v.language || '').toUpperCase();
const g = v.gender === 'female' ? 'F' : v.gender === 'male' ? 'M' : (v.gender || '');
const age = (v.age || '').replace(/_/g, ' ');
const uc = (v.use_case || '').replace(/_/g, ' ');
const clones = v.cloned_by_count || 0;
const cl = clones >= 1000 ? (clones / 1000).toFixed(1) + 'k' : clones > 0 ? String(clones) : '';
const desc = (v.description || '').slice(0, 88) + ((v.description || '').length > 88 ? '…' : '');
const prev = _elEsc(v.preview_url || '');
const nm = _elEsc(v.name || '');
const tags = [
lang ? `<span class="el-tag el-lang">${lang}</span>` : '',
g ? `<span class="el-tag el-tag-g">${g}</span>` : '',
age ? `<span class="el-tag">${_elEsc(age)}</span>` : '',
uc ? `<span class="el-tag el-uc">${_elEsc(uc)}</span>` : '',
(v.accent && v.accent !== 'standard') ? `<span class="el-tag">${_elEsc(v.accent)}</span>` : '',
v.free_users_allowed ? '<span class="el-tag el-free">Free</span>' : '',
v.featured ? '<span class="el-tag el-feat">★</span>' : '',
cl ? `<span class="el-tag el-clones">↓ ${cl}</span>` : '',
].join('');
return `<div class="el-card-voice">
<div class="el-vc-av" style="--elh:${hue}">${letter}</div>
<div class="el-vc-body">
<div class="el-vc-name">${nm}</div>
<div class="el-vc-tags">${tags}</div>
${desc ? `<div class="el-vc-desc">${_elEsc(desc)}</div>` : ''}
<div class="el-vc-meta">
<button class="el-play" title="Play preview" data-preview="${prev}">▶ Play</button>
</div>
</div>
${prev ? `<div class="el-vc-footer"><button class="el-clone" title="Import to Clone a Voice" data-preview="${prev}" data-name="${nm}">&#8595; Clone</button></div>` : ''}
</div>`;
}
function elPlay(btn) {
const url = btn.dataset.preview;
if (!url) return;
if (_elAudio && _elAudioBtn === btn) {
_elAudio.paused ? _elAudio.play() : _elAudio.pause();
btn.textContent = _elAudio.paused ? '▶' : '⏸';
return;
}
if (_elAudio) { _elAudio.pause(); if (_elAudioBtn) _elAudioBtn.textContent = '▶'; }
_elAudio = new Audio(url);
_elAudioBtn = btn;
btn.textContent = '⏸';
_elAudio.play().catch(() => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
}
function elImport(btn) {
const url = btn.dataset.preview;
if (!url) return;
if (typeof navTo === 'function') navTo('s-clone');
setTimeout(() => {
const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn');
if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); }
if (btn2) btn2.click();
}, 120);
}
elLoadKey();
})();
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));