diff --git a/static/js/benchmark-voice-picker.js b/static/js/benchmark-voice-picker.js new file mode 100644 index 0000000..7f5fb5c --- /dev/null +++ b/static/js/benchmark-voice-picker.js @@ -0,0 +1,269 @@ +// ── Benchmark Voice Picker ─────────────────────────────────────────────── +// Searchable benchmark voice selector with avatars plus duration/speed stats. +(function () { + 'use strict'; + + const pickers = {}; + + function esc(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function norm(value) { + if (typeof value === 'string') return { id: value, label: value, meta: null, raw: value }; + const id = value?.id || value?.voice || value?.name || value?.voice_id || ''; + const hasExplicitMeta = Object.prototype.hasOwnProperty.call(value || {}, 'meta'); + const looksLikeVoice = !!(value?.display_name || value?.duration || value?.benchmark || value?.has_picture || value?.transcript); + return { + id, + label: value?.label || value?.display_name || value?.name || value?.voice || id, + meta: hasExplicitMeta ? value.meta : (looksLikeVoice ? value : null), + raw: value, + }; + } + + function libraryVoice(id) { + return (window._voices || []).find(v => v && v.id === id) || null; + } + + function metaFor(item) { + return item?.meta || libraryVoice(item?.id) || item?.raw || null; + } + + function langColor(lang, id) { + const str = String(lang || id || '').toLowerCase(); + if (str.startsWith('de')) return '#3b82f6'; + if (str.startsWith('en')) return '#10b981'; + if (str.startsWith('fr')) return '#8b5cf6'; + if (str.startsWith('es')) return '#f59e0b'; + if (str.startsWith('it')) return '#ef4444'; + if (str.startsWith('zh')) return '#ec4899'; + if (str.startsWith('ja')) return '#f97316'; + const palette = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16']; + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0; + return palette[h % palette.length]; + } + + function avatarHtml(item, size) { + const id = item?.id || ''; + const meta = metaFor(item); + const s = `${size}px`; + const r = `${Math.round(size / 2)}px`; + if (meta?.has_picture) { + return ``; + } + const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta?.avatar, size) : null; + if (icon) return icon.replace(/vp-avatar/g, 'bvp-avatar'); + const color = langColor(meta?.lang || meta?.language, id); + const init = (item?.label || id || '?').trim()[0]?.toUpperCase() || '?'; + return `${esc(init)}`; + } + + function numeric(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + + function statsFor(item) { + const meta = metaFor(item) || {}; + const b = meta.benchmark || item?.benchmark || {}; + const parts = []; + const duration = numeric(meta.duration ?? item?.duration); + const speed = numeric(b.speed ?? b.avg_speed ?? meta.speed); + const rtf = numeric(b.rtf ?? b.avg_rtf ?? meta.rtf); + const elapsed = numeric(b.elapsed_sec ?? b.avg_elapsed_sec); + if (duration != null && duration > 0) parts.push(`${duration.toFixed(1)}s`); + if (speed != null && speed > 0) parts.push(`${speed.toFixed(2)}x`); + else if (rtf != null && rtf > 0) parts.push(`RTF ${rtf.toFixed(2)}`); + if (!parts.length && elapsed != null && elapsed > 0) parts.push(`${elapsed.toFixed(1)}s TTS`); + if (!parts.length && b.ok === false) parts.push('benchmark failed'); + return parts.join(' · '); + } + + function searchText(item) { + const meta = metaFor(item) || {}; + return [ + item?.id, + item?.label, + meta.display_name, + meta.name, + meta.lang, + meta.language, + Array.isArray(meta.tags) ? meta.tags.join(' ') : meta.tags, + statsFor(item), + ].filter(Boolean).join(' ').toLowerCase(); + } + + function optionLabel(item) { + const stats = statsFor(item); + return `${item.label || item.id}${stats ? ` (${stats})` : ''}`; + } + + function renderTrigger(picker) { + const value = picker.select.value; + const item = picker.items.find(v => v.id === value); + const content = picker.trigger.querySelector('.bvp-trigger-content'); + if (!item) { + content.innerHTML = `${esc(picker.placeholder)}`; + return; + } + const stats = statsFor(item); + content.innerHTML = `${avatarHtml(item, 24)}${esc(item.label || item.id)}${stats ? `${esc(stats)}` : ''}`; + } + + function renderList(picker, filter) { + const q = String(filter || '').trim().toLowerCase(); + const items = q ? picker.items.filter(item => searchText(item).includes(q)) : picker.items; + if (!items.length) { + picker.list.innerHTML = `
${esc(q ? 'No voices found' : picker.empty)}
`; + return; + } + picker.list.innerHTML = items.map(item => { + const meta = metaFor(item) || {}; + const stats = statsFor(item); + const sub = [meta.lang || meta.language, meta.file_type].filter(Boolean).join(' · '); + return ``; + }).join(''); + } + + function close(picker) { + if (!picker) return; + picker.drop.hidden = true; + picker.drop.style.display = 'none'; + picker.trigger.classList.remove('open'); + } + + function closeAll(except) { + Object.values(pickers).forEach(p => { if (p !== except) close(p); }); + } + + function open(picker) { + closeAll(picker); + picker.drop.hidden = false; + picker.drop.style.display = ''; + picker.trigger.classList.add('open'); + picker.search.value = ''; + renderList(picker, ''); + picker.search.focus(); + requestAnimationFrame(() => picker.list.querySelector('.bvp-item.selected')?.scrollIntoView({ block: 'nearest' })); + } + + function setValue(picker, value, dispatch) { + picker.select.value = value || ''; + renderTrigger(picker); + renderList(picker, picker.search.value); + close(picker); + if (dispatch !== false) picker.select.dispatchEvent(new Event('change', { bubbles: true })); + picker.trigger.focus(); + } + + function upgrade(selectId, options) { + const select = document.getElementById(selectId); + if (!select) return null; + if (pickers[selectId]) return pickers[selectId]; + const picker = { + select, + items: [], + placeholder: options?.placeholder || Array.from(select.options).find(o => !o.value)?.textContent || '— select after fetch —', + empty: options?.empty || 'No voices', + }; + select.dataset.bvpUpgraded = '1'; + select.style.display = 'none'; + + const root = document.createElement('div'); + root.className = 'bvp-root'; + root.dataset.bvpFor = selectId; + select.parentNode.insertBefore(root, select); + + root.innerHTML = ` + `; + + Object.assign(picker, { + root, + trigger: root.querySelector('.bvp-trigger'), + drop: root.querySelector('.bvp-drop'), + search: root.querySelector('.bvp-search'), + list: root.querySelector('.bvp-list'), + }); + + picker.trigger.addEventListener('click', e => { + e.preventDefault(); + picker.drop.hidden ? open(picker) : close(picker); + }); + picker.trigger.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(picker); } + if (e.key === 'ArrowDown') { e.preventDefault(); open(picker); picker.list.querySelector('.bvp-item')?.focus(); } + }); + picker.search.addEventListener('input', () => renderList(picker, picker.search.value)); + picker.search.addEventListener('keydown', e => { + if (e.key === 'Escape') { e.preventDefault(); close(picker); picker.trigger.focus(); } + if (e.key === 'ArrowDown') { e.preventDefault(); picker.list.querySelector('.bvp-item')?.focus(); } + }); + picker.list.addEventListener('click', e => { + const item = e.target.closest('.bvp-item'); + if (item) setValue(picker, item.dataset.value, true); + }); + picker.list.addEventListener('keydown', e => { + const item = e.target.closest('.bvp-item'); + if (!item) return; + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setValue(picker, item.dataset.value, true); } + if (e.key === 'ArrowDown') { e.preventDefault(); (item.nextElementSibling || item).focus(); } + if (e.key === 'ArrowUp') { e.preventDefault(); (item.previousElementSibling || picker.search).focus(); } + if (e.key === 'Escape') { e.preventDefault(); close(picker); picker.trigger.focus(); } + }); + select.addEventListener('change', () => { renderTrigger(picker); close(picker); }); + document.addEventListener('click', e => { if (!root.contains(e.target)) close(picker); }, true); + + pickers[selectId] = picker; + syncFromSelect(selectId); + return picker; + } + + function populate(selectId, values, options) { + const picker = upgrade(selectId, options || {}); + if (!picker) return; + picker.placeholder = options?.placeholder || picker.placeholder; + picker.empty = options?.empty || picker.empty; + const previous = picker.select.value; + picker.items = (values || []).map(norm).filter(item => item.id); + picker.select.innerHTML = `` + picker.items.map(item => ``).join(''); + if (previous && picker.items.some(item => item.id === previous)) picker.select.value = previous; + renderTrigger(picker); + renderList(picker, picker.search?.value || ''); + } + + function syncFromSelect(selectId) { + const picker = pickers[selectId]; + if (!picker) return; + picker.items = Array.from(picker.select.options).filter(o => o.value).map(o => ({ id: o.value, label: o.textContent || o.value, meta: libraryVoice(o.value) || {} })); + renderTrigger(picker); + renderList(picker, ''); + } + + function getValue(selectId) { + return document.getElementById(selectId)?.value || ''; + } + + function set(selectId, value) { + const picker = pickers[selectId] || upgrade(selectId); + if (!picker) return; + setValue(picker, value, true); + } + + window.BenchmarkVoicePicker = { upgrade, populate, syncFromSelect, getValue, set }; +})(); diff --git a/static/js/benchmark.js b/static/js/benchmark.js index 3d410b8..1d0d9f8 100644 --- a/static/js/benchmark.js +++ b/static/js/benchmark.js @@ -434,18 +434,30 @@ async function loadBenchmarkVoiceLibrary() { const sel = q('bench-stt-library-voice'); if (!sel) return; - sel.innerHTML = ''; + const picker = window.BenchmarkVoicePicker; + if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Loading voices...', empty: 'Loading voices...' }); + else sel.innerHTML = ''; try { const voices = await fetchJson('/api/voices'); - const usable = (voices || []).filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim()); - sel.innerHTML = '' + usable - .sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id))) - .map(v => ``) - .join(''); + const usable = (voices || []) + .filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim()) + .sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id))); + const items = usable.map(v => ({ id: v.id, label: v.display_name || v.name || v.id, meta: v })); + if (picker) { + picker.populate('bench-stt-library-voice', items, { + placeholder: '-- choose from voice library --', + empty: 'No library voices with reference transcripts found', + }); + } else { + sel.innerHTML = '' + usable + .map(v => ``) + .join(''); + } const note = q('bench-stt-source-note'); if (note) note.textContent = usable.length ? `${usable.length} voices with reference text available.` : 'No library voices with reference transcripts found.'; } catch (e) { - sel.innerHTML = ''; + if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Voice library unavailable', empty: 'Voice library unavailable' }); + else sel.innerHTML = ''; const note = q('bench-stt-source-note'); if (note) note.textContent = 'Could not load voice library: ' + e.message; } @@ -649,13 +661,19 @@ const btn = q('bench-turn-fetch-voices'); const sel = q('bench-turn-voice'); const backend = q('bench-turn-tts-backend')?.value || 'voice_clone'; + const picker = window.BenchmarkVoicePicker; if (btn) btn.disabled = true; try { const raw = await fetchJson('/api/tts-voices?backend=' + encodeURIComponent(backend)); - const ids = (Array.isArray(raw) ? raw : []).map(v => typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name))).filter(Boolean); - if (sel) sel.innerHTML = ids.length ? ids.map(id => ``).join('') : ''; + const items = (Array.isArray(raw) ? raw : []).map(v => { + const id = typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name)); + return id ? { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) } : null; + }).filter(Boolean); + if (picker) picker.populate('bench-turn-voice', items, { placeholder: 'Fetch voices', empty: 'No voices' }); + else if (sel) sel.innerHTML = items.length ? items.map(v => ``).join('') : ''; } catch (e) { - if (sel) sel.innerHTML = ''; + if (picker) picker.populate('bench-turn-voice', [], { placeholder: 'Fetch failed', empty: 'Fetch failed' }); + else if (sel) sel.innerHTML = ''; say('Voice fetch failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } @@ -781,6 +799,11 @@ if (initialized || !q('bench-stt-run')) return; initialized = true; document.querySelectorAll('.bench-tab').forEach(btn => btn.addEventListener('click', () => setBenchTab(btn.dataset.benchTab))); + if (window.BenchmarkVoicePicker) { + BenchmarkVoicePicker.upgrade('bench-stt-library-voice', { placeholder: '-- choose from voice library --', empty: 'No library voices with reference transcripts found' }); + BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' }); + BenchmarkVoicePicker.upgrade('bench-turn-voice', { placeholder: 'Fetch voices', empty: 'No voices' }); + } document.addEventListener('click', () => closeBenchmarkModelPickers()); q('bench-stt-refresh')?.addEventListener('click', loadBenchmarkSttEngines); q('bench-stt-load-voice')?.addEventListener('click', useBenchmarkLibraryVoice); @@ -790,7 +813,10 @@ q('bench-turn-fetch-llm')?.addEventListener('click', fetchTurnModels); q('bench-turn-fetch-voices')?.addEventListener('click', fetchTurnVoices); q('bench-turn-run')?.addEventListener('click', runTurnBenchmark); - q('bench-turn-tts-backend')?.addEventListener('change', () => { if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = ''; }); + q('bench-turn-tts-backend')?.addEventListener('change', () => { + if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.populate('bench-turn-voice', [], { placeholder: 'Fetch voices', empty: 'No voices' }); + else if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = ''; + }); loadBenchmarkSttEngines(); loadBenchmarkVoiceLibrary(); initTurnControls(); diff --git a/static/js/tts-preview.js b/static/js/tts-preview.js index 345dd03..b1633a6 100644 --- a/static/js/tts-preview.js +++ b/static/js/tts-preview.js @@ -390,6 +390,7 @@ function renderPerfHistory() { ).join('') || ''; } populatePerfBackends(); + if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' }); perfFetchBtn.addEventListener('click', async () => { const backend = perfBackendSel.value; @@ -398,10 +399,16 @@ function renderPerfHistory() { try { const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); const cur = perfVoiceSel.value; - perfVoiceSel.innerHTML = rawVoices.map(v => { + const items = rawVoices.map(v => { const id = backendVoiceId(v); - return ``; - }).join('') || ''; + return { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) }; + }).filter(v => v.id); + if (window.BenchmarkVoicePicker) { + BenchmarkVoicePicker.populate('perf-voice-select', items, { placeholder: '-- select after fetch --', empty: 'No voices' }); + if (cur && items.some(v => v.id === cur)) BenchmarkVoicePicker.set('perf-voice-select', cur); + } else { + perfVoiceSel.innerHTML = items.map(v => ``).join('') || ''; + } } catch(e) { toast('Fetch voices failed: '+e.message, 'error'); } finally { perfFetchBtn.disabled = false; } }); diff --git a/static/loader.js b/static/loader.js index 9ca86b8..e74425a 100644 --- a/static/loader.js +++ b/static/loader.js @@ -108,6 +108,7 @@ // default loads them individually so editing static/js/* is live without a build. var _featureFiles = [ '/static/js/voice-picker.js', + '/static/js/benchmark-voice-picker.js', '/static/js/voice-inspector.js', '/static/js/voice-sources.js', '/static/js/fishaudio-browser.js', diff --git a/static/style.css b/static/style.css index c3bf92d..902feb8 100644 --- a/static/style.css +++ b/static/style.css @@ -2389,7 +2389,44 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .bench-source-row { display: flex; gap: 8px; align-items: center; } .bench-source-row select { flex: 1; min-width: 220px; } .bench-source-note { margin-top: 5px; color: var(--subtext); font-size: 12px; } -@media (max-width: 700px) { .bench-source-row { flex-direction: column; align-items: stretch; } } +.bench-source-row .bvp-root { flex: 1 1 360px; min-width: min(360px, 100%); } +.bvp-root { position: relative; display: inline-flex; flex-direction: column; min-width: 260px; flex: 1 1 300px; } +.bvp-trigger { + width: 100%; min-height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 0 10px; cursor: pointer; background: var(--input-bg, var(--panel)); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius, 8px); text-align: left; user-select: none; +} +.bvp-trigger:hover, .bvp-trigger.open { border-color: var(--accent); filter: none; } +.bvp-trigger-content { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; overflow: hidden; } +.bvp-trigger-main { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; } +.bvp-trigger-name { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--text); } +.bvp-trigger-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--subtext); font-variant-numeric: tabular-nums; } +.bvp-placeholder { font-size: 13px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bvp-chevron { color: var(--subtext); flex-shrink: 0; transition: transform .15s; } +.bvp-trigger.open .bvp-chevron { transform: rotate(180deg); } +.bvp-avatar { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; } +.bvp-drop[hidden] { display: none !important; } +.bvp-drop { + position: absolute; z-index: 245; top: calc(100% + 4px); left: 0; right: 0; min-width: min(520px, 92vw); + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius, 8px); + box-shadow: 0 10px 28px rgba(0,0,0,.18); overflow: hidden; +} +.bvp-search-wrap { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--subtext); background: var(--panel); } +.bvp-search { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--text); font-size: 13px; } +.bvp-list { max-height: 340px; overflow-y: auto; padding: 4px; } +.bvp-item { + width: 100%; display: flex; align-items: center; gap: 9px; padding: 7px 8px; + border: 0; border-radius: 6px; background: transparent; color: var(--text); text-align: left; cursor: pointer; +} +.bvp-item:hover, .bvp-item:focus { background: var(--hover); outline: none; filter: none; } +.bvp-item.selected { background: rgba(37,99,235,.12); color: var(--accent); } +.bvp-item-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 1px; } +.bvp-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; } +.bvp-item-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--subtext); } +.bvp-item-stats { flex-shrink: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--subtext); font-variant-numeric: tabular-nums; } +.bvp-empty { padding: 14px 12px; text-align: center; color: var(--subtext); font-size: 12px; } +.bench-inline-controls .bvp-root { min-width: min(340px, 100%); flex: 1 1 340px; } +@media (max-width: 700px) { .bench-source-row { flex-direction: column; align-items: stretch; } .bvp-drop { min-width: 100%; } .bvp-item-stats { max-width: 110px; } } .bench-engine-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); overflow: hidden; } .bench-engine-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; } .bench-engine-head span { color: var(--subtext); } @@ -2446,7 +2483,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .bench-engine-item { grid-template-columns: auto minmax(0, 1fr); } .bench-engine-state, .bench-engine-model { grid-column: 2; } .bench-inline-controls { flex-wrap: wrap; min-width: 0; } - .bench-inline-controls input, .bench-inline-controls select { flex: 1 1 180px; } + .bench-inline-controls input, .bench-inline-controls select, .bench-inline-controls .bvp-root { flex: 1 1 180px; } } @media (max-width: 560px) { .bench-tab span:not(.mdi) { display: none; }