// ── Save voice ──────────────────────────────────────────────────────────── $('save-btn').addEventListener('click', async () => { const id = trimmedFileId||designedFileId||currentFileId; if (!id) { toast('No audio ready','error'); return; } const voiceId=$('voice-id-input').value.trim(); if (!voiceId) { toast('Enter a Voice ID','error'); return; } if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters','error'); return; } $('save-btn').disabled=true; try { const payload = {id, voice_id:voiceId, path:editingVoicePath, transcript:$('transcript-area').value}; const sendSave = endpoint => fetch(endpoint, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); let fallbackSave = false; let r = await sendSave(editingVoiceId ? '/api/voice-replace' : '/api/save'); if (editingVoiceId && (r.status === 404 || r.status === 405)) { fallbackSave = true; status('Update endpoint unavailable; saving as a regular voice…'); r = await sendSave('/api/save'); } if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); $('save-result').style.display=''; toast((editingVoiceId ? 'Voice updated: ' : 'Voice saved: ')+d.voice_id,'success'); if (fallbackSave && editingVoiceId && voiceId !== editingVoiceId) { const del = await fetch('/api/voice/' + encodeURIComponent(editingVoiceId), {method:'DELETE'}); if (!del.ok) status('Saved renamed voice; old library entry may need manual deletion.'); } editingVoiceId = null; editingVoicePath = null; } catch(e) { toast('Save failed: '+e.message,'error'); } finally { $('save-btn').disabled=false; } }); // ══════════════════════════════════════════════════════════════════════════ // LIBRARY — sort + render // ══════════════════════════════════════════════════════════════════════════ let _voices = []; let _pendingSelectId = null; let _sortField = 'id'; let _sortDir = 1; // 1 = asc, -1 = desc let _libraryIssueFilter = ''; let _activePlayButton = null; let _activePlayVoiceId = null; let _activePlayUrl = null; let _libraryLoadPromise = null; const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text'; const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''}; const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.'; function benchmarkSampleText() { const el = $('benchmark-sample-text'); return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT; } function initBenchmarkSampleControls() { const sample = $('benchmark-sample-text'); if (!sample) return; sample.value = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY) || DEFAULT_BENCHMARK_SAMPLE_TEXT; sample.addEventListener('input', debounce(() => { localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value.trim()); status('Benchmark sample sentence saved'); }, 500)); $('benchmark-reset-sample-btn')?.addEventListener('click', () => { sample.value = DEFAULT_BENCHMARK_SAMPLE_TEXT; localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value); status('Benchmark sample sentence reset'); }); $('benchmark-use-preview-btn')?.addEventListener('click', () => { const text = $('preview-text-area')?.value.trim(); if (!text) { toast('Preview text is empty', 'error'); return; } sample.value = text; localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); status('Benchmark sample sentence copied from TTS preview'); }); } function getSortValue(v, field) { switch(field) { case 'has_picture': return v.has_picture ? 1 : 0; case 'flag': return (v.flag || '').toLowerCase(); case 'gender': return {'F':0,'M':1,'N':2}[v.gender] ?? 3; case 'id': return v.id.toLowerCase(); case 'file_type': return voiceFileType(v); case 'duration': return v.duration || 0; case 'dbfs': return voiceDbfs(v) ?? -999; case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; case 'transcript': return (v.transcript || '').toLowerCase(); case 'note': return (v.note || '').toLowerCase(); case 'source': return (v.origin || '').toLowerCase(); case 'seed': return v.seed != null ? v.seed : 9999999; case 'tag': return (v.tag || '').toLowerCase(); case 'rating': return v.rating || 0; case 'enabled': return v.enabled === false ? 0 : 1; default: return ''; } } function setSort(field) { _sortDir = (_sortField === field) ? _sortDir * -1 : 1; _sortField = field; syncSortHeaders(); renderVoiceList(); } function toggleSortDir() { _sortDir *= -1; syncSortHeaders(); renderVoiceList(); } function syncSortHeaders() { document.querySelectorAll('.vl-header [data-sort]').forEach(el => { el.classList.remove('sort-asc', 'sort-desc'); if (el.dataset.sort === _sortField) el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc'); }); const sel = document.getElementById('voice-sort-field'); if (sel && sel.value !== _sortField) sel.value = _sortField; const dirBtn = document.getElementById('voice-sort-dir'); if (dirBtn) { const icon = dirBtn.querySelector('.mdi'); if (icon) icon.className = _sortDir === 1 ? 'mdi mdi-arrow-up' : 'mdi mdi-arrow-down'; dirBtn.title = _sortDir === 1 ? 'Ascending — click to reverse' : 'Descending — click to reverse'; } } // Wire sort direction button via addEventListener (more reliable than inline onclick // since the button is injected into the DOM after script execution). document.addEventListener('click', e => { if (e.target.closest('#voice-sort-dir')) toggleSortDir(); if (e.target.closest('#voice-group-tag-btn')) { window._voiceGroupByTag = !window._voiceGroupByTag; try { localStorage.setItem('vl-group-by-tag', window._voiceGroupByTag ? '1' : '0'); } catch (_) {} const btn = document.getElementById('voice-group-tag-btn'); btn?.classList.toggle('active', window._voiceGroupByTag); const icon = btn?.querySelector('.mdi'); if (icon) icon.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; renderVoiceList(); } if (e.target.closest('#voice-table-view-btn')) { window._voiceTableView = !window._voiceTableView; try { localStorage.setItem('vl-table-view', window._voiceTableView ? '1' : '0'); } catch (_) {} const btn = document.getElementById('voice-table-view-btn'); btn?.classList.toggle('active', window._voiceTableView); document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView); // Auto-close any open inspector row when toggling table view if (window._voiceTableView) { document.querySelectorAll('.edit-open').forEach(r => r.classList.remove('edit-open')); const inspector = document.getElementById('voices-inspector'); if (inspector) inspector.innerHTML = '

Table View Mode
Click a row to exit table view and edit.

'; } } }); // Restore the preferences on load try { window._voiceGroupByTag = localStorage.getItem('vl-group-by-tag') === '1'; } catch (_) {} try { window._voiceTableView = localStorage.getItem('vl-table-view') === '1'; } catch (_) {} document.addEventListener('click', e => { const th = e.target.closest('.vl-th-sortable'); if (th) { const field = th.dataset.sort; if (field) { const sel = document.getElementById('voice-sort-field'); if (sel && sel.value === field) { window._voiceSortDir *= -1; // toggle dir } else if (sel) { sel.value = field; window._voiceSortDir = 1; // default to desc for new field (or asc if you want) } readLibrarySortConfig(); renderVoiceList(); } } }); document.addEventListener('change', e => { if (e.target.id === 'voice-sort-field') setSort(e.target.value); }); const FLAG_LANGUAGE_CANDIDATES = { GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'], CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'], DE:['DE'], AT:['DE'], CH:['DE','FR','IT'], FR:['FR'], BE:['FR','NL'], LU:['FR','DE'], ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'], PT:['PT'], BR:['PT'], IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'], CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'], EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'], CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'], SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'], }; const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]])); const LANGUAGE_LABELS = { EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish', DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian', HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian', RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali', AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish', }; const SEX_FILTER_LABELS = { F:'♀ Female', M:'♂ Male', N:'⚥ Diverse / neutral', }; function voiceLangFromName(v) { return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase(); } function libraryVoiceLang(v) { const fromName = voiceLangFromName(v); const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()]; if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0]; return fromName; } function libraryLanguageLabel(code) { return LANGUAGE_LABELS[code] || code; } function populateLibraryFilters() { const langSel = $('library-filter-lang'); const sexSel = $('library-filter-sex'); const typeSel = $('library-filter-type'); if (!langSel || !sexSel || !typeSel) return; const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value}; const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b))); const sexOrder = ['F','M','N']; const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))] .sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b))); const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort(); langSel.innerHTML = '' + langs.map(x => ``).join(''); sexSel.innerHTML = '' + sexes.map(x => ``).join(''); typeSel.innerHTML = '' + types.map(x => ``).join(''); langSel.value = langs.includes(keep.lang) ? keep.lang : ''; sexSel.value = sexes.includes(keep.sex) ? keep.sex : ''; typeSel.value = types.includes(keep.type) ? keep.type : ''; } function readLibraryFilters() { _libraryFilters.text = ($('library-filter-text')?.value || '').trim().toLowerCase(); _libraryFilters.lang = $('library-filter-lang')?.value || ''; _libraryFilters.sex = $('library-filter-sex')?.value || ''; _libraryFilters.type = $('library-filter-type')?.value || ''; _libraryFilters.rating = $('library-filter-rating')?.value || ''; } function libraryFilterMatch(v) { const f = _libraryFilters; if (f.lang && libraryVoiceLang(v) !== f.lang) return false; if (f.sex && (v.gender || '') !== f.sex) return false; if (f.type && voiceFileType(v) !== f.type) return false; if (f.rating) { const r = Number(v.rating || 0); const wanted = Number(f.rating); if (wanted === 0 && r !== 0) return false; if (wanted === 1 && r < 1) return false; if (wanted > 1 && r < wanted) return false; } if (f.text) { const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin].map(x => String(x || '').toLowerCase()).join(' '); if (!hay.includes(f.text)) return false; } return true; } function clearLibraryFilters() { ['library-filter-text','library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { const el = $(id); if (el) el.value = ''; }); readLibraryFilters(); renderVoiceList(); } function libraryTtsBackend() { return $('library-tts-backend-select')?.value || 'voice_clone'; } function needsDuration(v) { return v.duration == null || Number.isNaN(Number(v.duration)); } function voiceFileType(v) { if (v.file_type) return String(v.file_type).replace(/^\./, '').toLowerCase(); const source = String(v.path || v.filename || ''); const match = source.match(/\.([A-Za-z0-9]+)(?:$|[?#])/); return match ? match[1].toLowerCase() : 'wav'; } function voiceDbfs(v) { const value = v.loudness && (v.loudness.dbfs ?? v.loudness.after_dbfs); return value == null || Number.isNaN(Number(value)) ? null : Number(value); } function fmtDbfs(v) { const db = voiceDbfs(v); return db == null ? '-' : db.toFixed(1); } function voiceBenchmark(v) { if (v.benchmark && typeof v.benchmark === 'object' && Object.keys(v.benchmark).length > 0) { return v.benchmark; } return null; } function voiceBenchmarkElapsed(v) { const b = voiceBenchmark(v); const value = b && b.elapsed_sec; return value == null || Number.isNaN(Number(value)) ? null : Number(value); } function fmtBenchmark(v) { const b = voiceBenchmark(v); if (!b) return '-'; if (!b.ok) return 'ERR'; const elapsed = voiceBenchmarkElapsed(v); if (elapsed == null) return '-'; const speed = b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''; return elapsed.toFixed(1) + 's' + speed; } function benchmarkClass(v) { const b = voiceBenchmark(v); if (!b) return ''; if (!b.ok || b.clipped || b.realtime_ok === false) return 'bench-bad'; const elapsed = voiceBenchmarkElapsed(v); return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn'; } function voiceFileUrl(v) { const version = v._audioVersion || v.updated_at || v.benchmarked_at || ''; const bust = version || Date.now(); return `/api/voice-file?path=${encodeURIComponent(v.path)}&v=${encodeURIComponent(bust)}`; } function markVoiceAudioChanged(v) { v._audioVersion = Date.now(); } function benchmarkTitle(v) { const b = voiceBenchmark(v); if (!b) return 'Not benchmarked yet'; const parts = []; if (b.ok) { parts.push(`total ${Number(b.elapsed_sec || 0).toFixed(2)}s`); if (b.ttfa_ms != null) parts.push(`TTFA ${Number(b.ttfa_ms).toFixed(0)}ms`); if (b.audio_sec != null) parts.push(`audio ${Number(b.audio_sec).toFixed(2)}s`); if (b.rtf != null) parts.push(`RTF ${Number(b.rtf).toFixed(2)}`); if (b.speed != null) parts.push(`speed ${Number(b.speed).toFixed(2)}x real-time`); if (b.clipped) parts.push('output clipped'); } else { parts.push('benchmark failed'); if (b.error) parts.push(b.error); } if (Array.isArray(b.advice) && b.advice.length) parts.push(b.advice.join(' | ')); if (b.benchmarked_at) parts.push(`saved ${b.benchmarked_at}`); return parts.join(' · '); } async function clientVoiceLoudness(v) { if (!v.path) throw new Error('No audio path'); const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); const audioData = await resp.arrayBuffer(); const ctx = new (window.AudioContext || window.webkitAudioContext)(); const buffer = await ctx.decodeAudioData(audioData.slice(0)); let sum = 0, peak = 0, count = 0; for (let ch = 0; ch < buffer.numberOfChannels; ch++) { const data = buffer.getChannelData(ch); count += data.length; for (let i = 0; i < data.length; i++) { const sample = data[i]; sum += sample * sample; peak = Math.max(peak, Math.abs(sample)); } } const rms = Math.sqrt(sum / Math.max(1, count)); const dbfs = rms > 0 ? 20 * Math.log10(rms) : null; const peakDbfs = peak > 0 ? 20 * Math.log10(peak) : null; return { dbfs: dbfs == null ? null : Number(dbfs.toFixed(2)), peak_dbfs: peakDbfs == null ? null : Number(peakDbfs.toFixed(2)), }; } async function clientCalculateVoiceDb() { const voices = visibleLibraryVoices(); const errors = []; let calculated = 0; const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, middleLabel: 'Skipped'}; setBenchmarkProgress(0, voices.length, 'Preparing dB scan...', stats); for (const v of voices) { setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats); try { v.loudness = await clientVoiceLoudness(v); await saveMeta(v.id, { loudness: v.loudness }).catch(()=>{}); calculated++; stats.ok++; stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`; status(`Calculated dB: ${calculated} / ${voices.length}`); } catch(e) { errors.push({voice_id:v.id, detail:e.message}); stats.errors++; stats.last = `${v.id}: ${e.message}`; } setBenchmarkProgress(calculated + errors.length, voices.length, `Calculating dB: ${v.id}`, stats); await new Promise(resolve => setTimeout(resolve, 0)); } setBenchmarkProgress(voices.length, voices.length, 'dB scan complete', stats); return { calculated, errors, voices: voices.map(v => ({voice_id:v.id, loudness:v.loudness})) }; } async function hydrateVoiceDuration(v, el) { if (!v.path || !needsDuration(v) || v._durationLoading) return; v._durationLoading = true; try { const audio = new Audio(); audio.preload = 'metadata'; audio.src = voiceFileUrl(v); await new Promise((resolve, reject) => { audio.onloadedmetadata = resolve; audio.onerror = () => reject(new Error('Could not read duration')); }); if (Number.isFinite(audio.duration) && audio.duration > 0) { v.duration = audio.duration; if (el && document.body.contains(el)) { el.textContent = fmtDuration(v.duration); el.title = String(v.duration.toFixed(2)); } } audio.removeAttribute('src'); audio.load(); } catch(e) { if (el && document.body.contains(el)) el.title = e.message; } finally { v._durationLoading = false; } } document.querySelectorAll('.vl-header [data-sort]').forEach(el => el.addEventListener('click', () => setSort(el.dataset.sort)) ); function dominantLanguages(limit = 3) { const counts = new Map(); (_voices || []).forEach(v => { const lang = (v.lang || String(v.id || '').split('_')[0] || '?').toUpperCase(); counts.set(lang, (counts.get(lang) || 0) + 1); }); return [...counts.entries()] .sort((a,b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, limit) .map(([lang, count]) => `${lang} ${count}`) .join(' · ') || '-'; } function updateLibraryInsights(state = 'ready') { const el = $('library-insights'); if (!el) return; if (state === 'loading') { el.innerHTML = [ ['…', 'Loading'], ['…', 'Active'], ['…', 'Languages'], ['…', 'Benchmarks'], ['…', 'Quality'], ['…', 'Actions'] ].map(([value, label]) => `
${value}${label}
`).join(''); return; } if (state === 'error') { el.innerHTML = '
FailedLibrary load
'; return; } const total = _voices.length; const active = _voices.filter(v => v.enabled !== false).length; const hidden = total - active; const bench = _voices.map(voiceBenchmark).filter(Boolean); const slow = bench.filter(b => b && b.ok && b.realtime_ok === false).length; const dbValues = _voices.map(voiceDbfs).filter(v => v != null); const avgDb = dbValues.length ? (dbValues.reduce((a,b) => a + b, 0) / dbValues.length).toFixed(1) : '-'; const missingRef = _voices.filter(v => !v.transcript).length; const restart = _voices.filter(v => v.needs_tts_restart).length; const visible = _voices.filter(v => $('show-disabled-cb').checked || v.enabled !== false).length; const tiles = [ {value:`${visible}/${total}`, label:'Visible'}, {value:`${active} on`, label:hidden ? `${hidden} hidden` : 'Active'}, {value:dominantLanguages(), label:'Languages'}, {value:bench.length ? `${bench.length} done` : '-', label:slow ? `${slow} slow` : 'Benchmarks', filter: slow ? 'slow' : '', title: slow ? describeIssueVoices('slow') : 'No slow voices'}, {value:avgDb === '-' ? '-' : `${avgDb} dB`, label:missingRef ? `${missingRef} no text` : 'Avg loudness', filter: missingRef ? 'no_text' : '', title: missingRef ? describeIssueVoices('no_text') : 'All visible voices have reference text'}, {value:restart || '-', label:restart ? 'Need restart' : 'Restart flags', filter: restart ? 'restart' : '', title: restart ? describeIssueVoices('restart') : 'No voices need restart'}, ]; el.innerHTML = tiles.map(item => { const filter = item.filter ? ` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"` : ''; const activeCls = item.filter && item.filter === _libraryIssueFilter ? ' active' : ''; const title = item.title ? ` title="${escHtml(item.title)}"` : ''; return `
${escHtml(item.value)}${escHtml(item.label)}
`; }).join(''); el.querySelectorAll('[data-filter]').forEach(tile => { const activate = () => setLibraryIssueFilter(tile.dataset.filter || ''); tile.addEventListener('click', activate); tile.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }); }); } async function loadVoiceLibrary() { if (_libraryLoadPromise) return _libraryLoadPromise; _libraryLoadPromise = (async () => { setBusyButton('refresh-voices-btn', true); const list = $('voice-list'); if (list) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8); $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); try { const r = await fetch('/api/voices'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } _voices = await r.json(); window._voices = _voices; // expose for cross-module access (Script Rehearser etc.) if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices); renderVoiceList(); updatePreviewVoiceMatchPanel(); if (typeof renderPerfHistory === 'function') renderPerfHistory(); status(`Loaded ${_voices.length} voices`); } catch(e) { if (list) list.innerHTML = '
Failed to load voices
'; $('voice-count').textContent = 'Load failed'; updateLibraryInsights('error'); status('Voice library load failed'); throw e; } finally { setBusyButton('refresh-voices-btn', false); _libraryLoadPromise = null; } })(); return _libraryLoadPromise; } $('refresh-voices-btn').addEventListener('click', loadVoiceLibrary); $('sync-voice-folders-btn').addEventListener('click', async () => { $('sync-voice-folders-btn').disabled = true; status('Syncing active_voices and hidden_voices…'); try { const r = await fetch('/api/voices/sync-folders', { method:'POST' }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } const d = await r.json(); await loadVoiceLibrary(); const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : ''; toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success'); status(`Synced folders. Restart Qwen3-TTS after changing active voices.`); } catch(e) { toast('Sync failed: ' + e.message, 'error'); status('Folder sync failed'); } finally { $('sync-voice-folders-btn').disabled = false; } }); function visibleLibraryVoices() { const showDisabled = $('show-disabled-cb').checked; return _voices.filter(v => showDisabled || v.enabled !== false); } function libraryIssueMatch(v, filter = _libraryIssueFilter) { const b = voiceBenchmark(v); if (filter === 'slow') return Boolean(b && b.ok && b.realtime_ok === false); if (filter === 'no_text') return !String(v.transcript || '').trim(); if (filter === 'restart') return Boolean(v.needs_tts_restart); return true; } function libraryIssueLabel(filter = _libraryIssueFilter) { return {slow:'slow benchmark voices', no_text:'voices without reference text', restart:'voices needing TTS restart'}[filter] || 'all voices'; } function libraryIssueVoices(filter = _libraryIssueFilter) { return visibleLibraryVoices().filter(v => libraryIssueMatch(v, filter)); } function describeIssueVoices(filter = _libraryIssueFilter, limit = 12) { const voices = libraryIssueVoices(filter).map(v => v.id); if (!voices.length) return 'No matching voices'; const extra = voices.length > limit ? `, +${voices.length - limit} more` : ''; return voices.slice(0, limit).join(', ') + extra; } function setLibraryIssueFilter(filter = '') { _libraryIssueFilter = _libraryIssueFilter === filter ? '' : filter; renderVoiceList(); if (_libraryIssueFilter) status(`${libraryIssueLabel()}: ${describeIssueVoices()}`); else status('Showing all visible voices'); } function libraryTargetDb() { const input = $('library-target-db'); const raw = Number(input?.value ?? -20); const value = Number.isFinite(raw) ? Math.min(-1, Math.max(-60, raw)) : -20; if (input) input.value = String(value); return value; } $('calculate-db-btn').addEventListener('click', async () => { $('calculate-db-btn').disabled = true; status('Calculating voice loudness…'); try { const d = await clientCalculateVoiceDb(); renderVoiceList(); const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : ''; toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success'); status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`); } catch(e) { toast('Calculate dB failed: ' + e.message, 'error'); status('dB calculation failed'); } finally { $('calculate-db-btn').disabled = false; } }); $('normalize-volume-btn').addEventListener('click', async () => { const target = libraryTargetDb(); const visible = visibleLibraryVoices(); const voices = visible.filter(v => voiceFileType(v) === 'wav'); const skipped = visible.length - voices.length; if (!voices.length) { toast('No visible WAV voices to normalize', 'error'); return; } if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return; $('normalize-volume-btn').disabled = true; $('calculate-db-btn').disabled = true; const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'}; const errors = []; let normalized = 0; setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats); status(`Normalizing ${voices.length} voices to ${target} dBFS...`); try { for (const v of voices) { setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats); try { const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})}); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.loudness = d.loudness || v.loudness; v.duration = d.duration ?? v.duration; v.file_type = d.file_type || v.file_type; v.path = d.path || v.path; v.needs_tts_restart = true; markVoiceAudioChanged(v); normalized++; stats.ok++; stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`; } catch(e) { errors.push({voice_id:v.id, detail:e.message}); stats.errors++; stats.last = `${v.id}: ${e.message}`; } setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats); status(`Normalized ${normalized} / ${voices.length}`); await new Promise(resolve => setTimeout(resolve, 0)); } setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats); renderVoiceList(); updateLibraryInsights(); const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`; toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success'); status('Volume normalized. Restart TTS before rebenchmarking these voices.'); } catch(e) { toast('Normalize volume failed: ' + e.message, 'error'); status('Normalize volume failed'); } finally { $('normalize-volume-btn').disabled = false; $('calculate-db-btn').disabled = false; } }); function fmtClock(ms) { if (!Number.isFinite(ms) || ms < 0) return '-'; const total = Math.round(ms / 1000); const m = Math.floor(total / 60), s = total % 60; return `${m}:${String(s).padStart(2,'0')}`; } function setBenchmarkProgress(done, total, label = '', stats = {}) { const panel = $('benchmark-progress'); const track = panel.querySelector('.benchmark-progress-track'); const pct = total ? Math.round(done / total * 100) : 0; panel.hidden = false; $('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...'); $('benchmark-progress-count').textContent = `${done} / ${total}`; $('benchmark-progress-bar').style.width = pct + '%'; track.setAttribute('aria-valuenow', String(pct)); const live = $('benchmark-live-stats'); if (live) { const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0; const avg = done > 0 ? elapsed / done : 0; const eta = done > 0 && total > done ? avg * (total - done) : 0; live.innerHTML = [ `Elapsed ${fmtClock(elapsed)}`, `Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`, `ETA ${done && total > done ? fmtClock(eta) : '-'}`, `OK ${stats.ok || 0}`, `${stats.middleLabel || 'Slow'} ${stats.slow || 0}`, `${stats.errorLabel || 'Errors'} ${stats.errors || 0}`, ].map(x => `${escHtml(x)}`).join(''); } const last = $('benchmark-live-last'); if (last && stats.last) last.textContent = stats.last; } function hideBenchmarkProgress() { $('benchmark-progress').hidden = true; $('benchmark-progress-bar').style.width = '0%'; if ($('benchmark-live-last')) $('benchmark-live-last').textContent = ''; } async function clearTtsRestartFlags() { const r = await fetch('/api/tts/restart-flags/clear', { method:'POST' }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _voices.forEach(voice => { voice.needs_tts_restart = false; }); document.querySelectorAll('.vl-row.edit-open').forEach(row => row.classList.remove('opt-restart-needed')); updateLibraryInsights(); return d; } async function runVoiceBenchmark(voiceId = '', opts = {}) { const text = opts.text ?? benchmarkSampleText(); if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } const payload = {active_only:true, text}; if (voiceId) payload.voice_id = voiceId; const r = await fetch('/api/voices/benchmark', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } return r.json(); } async function runVoiceBenchmarkBatch() { const voices = benchmarkTargetVoices(); const text = benchmarkSampleText(); if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; } if (!voices.length) { toast('No voices to benchmark', 'error'); return null; } const total = voices.length; const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true}; const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''}; setBenchmarkProgress(0, total, 'Starting benchmark...', stats); for (let i = 0; i < voices.length; i++) { const voice = voices[i]; setBenchmarkProgress(i, total, `Benchmarking ${voice.id}`, stats); status(`Benchmarking ${voice.id} (${i + 1} / ${total})...`); try { const d = await runVoiceBenchmark(voice.id, {text}); if (d) { aggregate.benchmarked += Number(d.benchmarked || 0); aggregate.errors.push(...(d.errors || [])); aggregate.voices.push(...(d.voices || [])); mergeBenchmarkResults(d); const hit = (d.voices || []).find(x => x.voice_id === voice.id); const b = hit && hit.benchmark; if (b && b.ok) { stats.ok++; if (b.realtime_ok === false) stats.slow++; stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(1)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`; } else { stats.errors++; stats.last = `${voice.id}: failed${b && b.error ? ' · ' + b.error : ''}`; } } } catch(e) { aggregate.errors.push({voice_id: voice.id, detail: e.message}); stats.errors++; stats.last = `${voice.id}: failed · ${e.message}`; } setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats); } setBenchmarkProgress(total, total, 'Benchmark complete', stats); return aggregate; } function mergeBenchmarkResults(d) { const byId = new Map((d.voices || []).map(x => [x.voice_id, x])); _voices.forEach(v => { const hit = byId.get(v.id); if (hit && hit.benchmark) v.benchmark = hit.benchmark; }); } function activeBenchmarkVoices() { return (_voices || []).filter(v => v.enabled !== false); } // Benchmark target: the checked/selected voices when any are selected, else all active. function benchmarkTargetVoices() { if (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0) { return (_voices || []).filter(v => _bulkSelected.has(v.id)); } return activeBenchmarkVoices(); } function showBenchmarkConfirm() { const voices = benchmarkTargetVoices(); const onlySelected = (typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0); const text = benchmarkSampleText(); if (!text) { toast('Enter a benchmark sample sentence', 'error'); return; } if (!voices.length) { toast('No voices to benchmark', 'error'); return; } const staleCount = voices.filter(v => v.needs_tts_restart).length; $('benchmark-confirm-title').textContent = `Benchmark ${voices.length} ${onlySelected ? 'selected' : 'active'} voice${voices.length === 1 ? '' : 's'}?`; $('benchmark-confirm-text').textContent = 'This sends the sample sentence to each active voice and can keep the GPU busy for a while. Progress updates after every voice.' + (staleCount ? ` ${staleCount} edited voice${staleCount === 1 ? '' : 's'} should be restarted first, otherwise cached old voices may be benchmarked.` : ''); $('benchmark-confirm').hidden = false; $('benchmark-confirm-start').focus(); } function hideBenchmarkConfirm() { const panel = $('benchmark-confirm'); if (panel) panel.hidden = true; } $('benchmark-voices-btn').addEventListener('click', showBenchmarkConfirm); $('benchmark-confirm-cancel')?.addEventListener('click', hideBenchmarkConfirm); $('benchmark-confirm-start')?.addEventListener('click', async () => { hideBenchmarkConfirm(); $('benchmark-voices-btn').disabled = true; $('benchmark-confirm-start').disabled = true; status('Benchmarking active voices...'); try { const d = await runVoiceBenchmarkBatch(); if (!d) return; mergeBenchmarkResults(d); await loadVoiceLibrary(); const slow = (d.voices || []).filter(x => x.benchmark && x.benchmark.realtime_ok === false).length; const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : ''; toast(`Benchmarked ${d.benchmarked} voices${slow ? `, ${slow} slow` : ''}${extra}`, d.errors && d.errors.length ? 'error' : 'success'); status('Benchmark saved with TTFA, total time, RTF, and speed.'); } catch(e) { toast('Benchmark failed: ' + e.message, 'error'); status('Benchmark failed'); } finally { $('benchmark-voices-btn').disabled = false; $('benchmark-confirm-start').disabled = false; } }); $('copy-active-voices-btn').addEventListener('click', async () => { const active = activeVoiceIds(); if (!active.length) { toast('No active voices to copy', 'error'); return; } await copyText(active.join(', ')); toast('Copied ' + active.length + ' active voices', 'success'); status('Copied active voices to clipboard'); }); // Precompute speaker embeddings: fire a tiny synth per active voice so the TTS // engine computes + caches each voice's .pt (speaker fingerprint) ahead of time, // making first real playback instant. The engine prefers the cached .pt and only // (re)builds it from the wav when missing — so this is purely a warm-up. $('precompute-embeddings-btn')?.addEventListener('click', async () => { const backend = libraryTtsBackend(); const ids = activeVoiceIds(); if (!ids.length) { toast('No active voices to precompute', 'error'); return; } if (!confirm(`Precompute speaker embeddings for ${ids.length} active voice(s) via “${backend}”?\n\nThis warms each voice so the engine caches its .pt and first playback is instant.`)) return; const btn = $('precompute-embeddings-btn'); if (btn) btn.disabled = true; const ov = document.createElement('div'); ov.className = 'audiobook-overlay'; ov.id = 'precompute-overlay'; ov.innerHTML = `
Precomputing embeddings
0 / ${ids.length}
`; document.body.appendChild(ov); let cancel = false; ov.querySelector('#pc-cancel').addEventListener('click', () => { cancel = true; }); const fill = ov.querySelector('#pc-fill'), msg = ov.querySelector('#pc-msg'); let done = 0, ok = 0, failed = 0; const queue = ids.slice(); const worker = async () => { while (queue.length && !cancel) { const id = queue.shift(); if (msg) msg.textContent = `${done} / ${ids.length} · ${id}`; try { await fetchTtsPreviewBlob(id, 'Hallo.', 'wav', '', backend); ok++; } catch (_) { failed++; } done++; if (fill) fill.style.width = (done / ids.length * 100) + '%'; } }; try { await Promise.all(Array.from({ length: Math.min(2, ids.length) }, worker)); } finally { ov.remove(); if (btn) btn.disabled = false; } toast(cancel ? `Cancelled — ${ok} warmed` : `Precomputed ${ok} embedding(s)${failed ? `, ${failed} skipped/failed` : ''}`, (!ok && failed) ? 'error' : 'success'); }); const LIB_ADD_SAMPLE_TEXTS = { EN: 'The clear morning light warmed the quiet studio as I described a silver train, a bright red apple, and the gentle rhythm of rain on the window.', DE: 'Das klare Morgenlicht waermte das ruhige Studio, waehrend ich einen silbernen Zug, einen roten Apfel und den sanften Rhythmus des Regens am Fenster beschrieb.', IT: 'La luce chiara del mattino scaldava lo studio tranquillo mentre descrivevo un treno d argento, una mela rossa e il ritmo leggero della pioggia alla finestra.', ES: 'La clara luz de la manana calentaba el estudio tranquilo mientras describia un tren plateado, una manzana roja y el suave ritmo de la lluvia en la ventana.', FR: 'La lumiere claire du matin rechauffait le studio calme pendant que je decrivais un train argente, une pomme rouge et le doux rythme de la pluie sur la fenetre.', PT: 'A luz clara da manha aquecia o estudio tranquilo enquanto eu descrevia um comboio prateado, uma maca vermelha e o ritmo suave da chuva na janela.', NL: 'Het heldere ochtendlicht verwarmde de stille studio terwijl ik een zilveren trein, een rode appel en het zachte ritme van regen op het raam beschreef.', PL: 'Jasne poranne swiatlo ogrzewalo ciche studio, gdy opisywalem srebrny pociag, czerwone jablko i lagodny rytm deszczu na oknie.' }; const LIB_ADD_SAMPLE_STORAGE_KEY = 'vcf-lib-add-sample-texts'; function libAddSampleOverrides() { try { return JSON.parse(localStorage.getItem(LIB_ADD_SAMPLE_STORAGE_KEY) || '{}') || {}; } catch(e) { return {}; } } function getLibAddSampleText(code) { return libAddSampleOverrides()[code] || LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN; } function saveLibAddSampleText() { const code = $('lib-add-sample-lang').value; const text = $('lib-add-sample-text').value.trim(); const overrides = libAddSampleOverrides(); if (text && text !== LIB_ADD_SAMPLE_TEXTS[code]) overrides[code] = text; else delete overrides[code]; localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides)); setLibAddStatus('Sample sentence saved'); } function resetLibAddSampleText() { const code = $('lib-add-sample-lang').value; const overrides = libAddSampleOverrides(); delete overrides[code]; localStorage.setItem(LIB_ADD_SAMPLE_STORAGE_KEY, JSON.stringify(overrides)); $('lib-add-sample-text').value = LIB_ADD_SAMPLE_TEXTS[code] || LIB_ADD_SAMPLE_TEXTS.EN; setLibAddStatus('Sample sentence reset'); } function updateLibAddSampleLanguage(lang) { const code = LIB_ADD_SAMPLE_TEXTS[lang] ? lang : 'EN'; $('lib-add-sample-lang').value = code; $('lib-add-lang').value = code; $('lib-add-sample-text').value = getLibAddSampleText(code); const voiceId = $('lib-add-voice-id').value.trim(); if (voiceId && /^[A-Z]{2}_/.test(voiceId)) { $('lib-add-voice-id').value = voiceId.replace(/^[A-Z]{2}_/, code + '_'); } } function renderLibAddMeter(level = 0, db = -Infinity, clipped = false) { const meter = $('lib-add-mic-meter'); if (!meter.children.length) { for (let i = 0; i < 18; i++) { const bar = document.createElement('div'); bar.className = 'bar'; meter.appendChild(bar); } } const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length); [...meter.children].forEach((bar, i) => { bar.className = 'bar'; bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px'; if (i < active) { bar.classList.add('on'); if (db > -12 && i > 11) bar.classList.add('hot'); if (clipped && i > 14) bar.classList.add('clip'); } }); $('lib-add-db-readout').textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; } function syncLibAddMicGain() { const gain = parseFloat($('lib-add-mic-gain').value) || 0; $('lib-add-mic-gain-value').textContent = gain.toFixed(2) + 'x'; if (libAddState.gainNode) libAddState.gainNode.gain.value = gain; } function startLibAddMeter() { if (!libAddState.analyser) return; if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf); const data = new Float32Array(libAddState.analyser.fftSize); const tick = () => { libAddState.analyser.getFloatTimeDomainData(data); let sum = 0, peak = 0; for (const sample of data) { sum += sample * sample; peak = Math.max(peak, Math.abs(sample)); } const rms = Math.sqrt(sum / data.length); const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity; const level = Number.isFinite(db) ? (db + 60) / 60 : 0; renderLibAddMeter(level, db, peak > 0.98); libAddState.meterRaf = requestAnimationFrame(tick); }; tick(); } async function ensureLibAddMicMonitor() { if (libAddState.recordStream) return; const AudioCtx = window.AudioContext || window.webkitAudioContext; libAddState.stream = await requestMicrophoneStream({raw:true}); if (AudioCtx) { libAddState.audioCtx = new AudioCtx(); libAddState.sourceNode = libAddState.audioCtx.createMediaStreamSource(libAddState.stream); libAddState.gainNode = libAddState.audioCtx.createGain(); libAddState.analyser = libAddState.audioCtx.createAnalyser(); libAddState.analyser.fftSize = 1024; const dest = libAddState.audioCtx.createMediaStreamDestination(); syncLibAddMicGain(); libAddState.sourceNode.connect(libAddState.gainNode); libAddState.gainNode.connect(libAddState.analyser); libAddState.gainNode.connect(dest); libAddState.recordStream = dest.stream; startLibAddMeter(); } else { libAddState.recordStream = libAddState.stream; } libAddState.monitoring = true; $('lib-add-monitor-btn').disabled = true; $('lib-add-monitor-stop').disabled = false; } function stopLibAddMic() { if (libAddState.meterRaf) cancelAnimationFrame(libAddState.meterRaf); libAddState.meterRaf = null; [libAddState.sourceNode, libAddState.gainNode, libAddState.analyser].forEach(node => { try { if (node) node.disconnect(); } catch(e) {} }); if (libAddState.stream) libAddState.stream.getTracks().forEach(t => t.stop()); if (libAddState.recordStream) libAddState.recordStream.getTracks().forEach(t => t.stop()); if (libAddState.audioCtx) libAddState.audioCtx.close().catch(()=>{}); libAddState.stream = null; libAddState.recordStream = null; libAddState.sourceNode = null; libAddState.gainNode = null; libAddState.analyser = null; libAddState.audioCtx = null; libAddState.monitoring = false; $('lib-add-monitor-btn').disabled = false; $('lib-add-monitor-stop').disabled = true; renderLibAddMeter(0, -Infinity, false); } let libAddState = { id:null, duration:0, audio:null, buffer:null, recorder:null, chunks:[], pendingSource:null, stream:null, recordStream:null, timer:null, secs:0, audioCtx:null, sourceNode:null, gainNode:null, analyser:null, meterRaf:null, monitoring:false }; window.libAddState = libAddState; $('add-new-voice-btn').addEventListener('click', () => { $('lib-add-panel').classList.toggle('open'); }); $('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value)); $('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value)); $('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500)); $('lib-add-use-sample').addEventListener('click', () => { $('lib-add-transcript').value = $('lib-add-sample-text').value.trim(); setLibAddStatus('Sample sentence copied to transcript'); }); $('lib-add-reset-sample').addEventListener('click', resetLibAddSampleText); $('lib-add-mic-help-btn').addEventListener('click', () => { $('lib-add-mic-help').classList.toggle('open'); }); $('lib-add-monitor-btn').addEventListener('click', async () => { try { await ensureLibAddMicMonitor(); setLibAddStatus('Mic level monitor active'); } catch(e) { stopLibAddMic(); $('lib-add-mic-help').classList.add('open'); const message = await microphoneErrorMessage(e); toast(message, 'error'); setLibAddStatus(message); } }); $('lib-add-monitor-stop').addEventListener('click', () => { stopLibAddMic(); setLibAddStatus('Mic level monitor stopped'); }); $('lib-add-mic-gain').addEventListener('input', syncLibAddMicGain); renderLibAddMeter(); syncLibAddMicGain(); updateLibAddSampleLanguage('EN'); function setLibAddStatus(msg) { $('lib-add-status').textContent = msg; status(msg); } function suggestLibVoiceId(filename) { if ($('lib-add-voice-id').value.trim()) return; const base = String(filename || 'NewVoice') .replace(/\.[^.]+$/, '') .replace(/[^A-Za-z0-9_-]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 60) || 'NewVoice'; $('lib-add-voice-id').value = `${$('lib-add-lang').value || 'EN'}_${$('lib-add-gender').value || 'N'}_${base}`; } function loadLibAddAudio(id, duration, label = 'Audio') { libAddState.id = id; libAddState.duration = Number(duration) || 0; libAddState.buffer = null; $('lib-add-start').value = '0.00'; $('lib-add-end').value = libAddState.duration ? Math.min(libAddState.duration, 20).toFixed(2) : '0.00'; $('lib-add-audio').src = '/api/audio/' + id; $('lib-add-audio').style.display = ''; $('lib-add-wave').style.display = ''; attachLibAddWaveSelection(); decodeTempAudio(id).then(buffer => { if (libAddState.id !== id) return; libAddState.buffer = buffer; drawLibAddWave(); }).catch(()=>{}); setLibAddStatus(`${label} loaded${libAddState.duration ? ' (' + libAddState.duration.toFixed(1) + ' s)' : ''}`); } async function decodeTempAudio(id) { const resp = await fetch('/api/audio/' + encodeURIComponent(id)); if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); const data = await resp.arrayBuffer(); const ctx = new (window.AudioContext || window.webkitAudioContext)(); return ctx.decodeAudioData(data.slice(0)); } function clampLibAddTime(value) { const duration = libAddState.duration || libAddState.buffer?.duration || 0; return Math.max(0, Math.min(duration, Number(value) || 0)); } function setLibAddCropRange(start, end) { const duration = libAddState.duration || libAddState.buffer?.duration || 0; let a = clampLibAddTime(start), b = clampLibAddTime(end); if (Math.abs(b - a) < 0.05) b = Math.min(duration, a + Math.min(1, duration || 1)); if (b < a) [a, b] = [b, a]; $('lib-add-start').value = a.toFixed(2); $('lib-add-end').value = b.toFixed(2); drawLibAddWave(); } function libAddWaveTimeFromEvent(e) { const canvas = $('lib-add-wave'); const rect = canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); const duration = libAddState.duration || libAddState.buffer?.duration || 0; return rect.width ? x / rect.width * duration : 0; } function updateLibAddCropHint() { const hint = $('lib-add-crop-hint'); if (!hint) return; const start = parseFloat($('lib-add-start').value) || 0; const end = parseFloat($('lib-add-end').value) || 0; const dur = Math.max(0, end - start); hint.textContent = dur ? `Selected ${dur.toFixed(1)}s. Aim for 3-20 seconds.` : 'Select 3-20 seconds for best cloning.'; hint.className = 'crop-duration-hint ' + (dur >= 3 && dur <= 20 ? 'ok' : dur ? 'warn' : ''); } function drawLibAddWave() { if (!libAddState.buffer) return; drawOptimizerWave( $('lib-add-wave'), libAddState.buffer, parseFloat($('lib-add-start').value) || 0, parseFloat($('lib-add-end').value) || libAddState.duration || libAddState.buffer.duration ); updateLibAddCropHint(); } function libAddWaveSelectionPixels(e) { const canvas = $('lib-add-wave'); const rect = canvas.getBoundingClientRect(); const duration = libAddState.duration || libAddState.buffer?.duration || 0; const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0); const end = clampLibAddTime(parseFloat($('lib-add-end').value) || duration); const sx = duration && rect.width ? start / duration * rect.width : 0; const ex = duration && rect.width ? end / duration * rect.width : rect.width; const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); return {x, sx, ex, start, end, duration}; } function libAddWaveDragMode(e) { const {x, sx, ex} = libAddWaveSelectionPixels(e); const hit = 16; if (Math.abs(x - sx) <= hit) return 'start'; if (Math.abs(x - ex) <= hit) return 'end'; return 'new'; } function attachLibAddWaveSelection() { const canvas = $('lib-add-wave'); if (!canvas || canvas.dataset.cropReady) return; canvas.dataset.cropReady = '1'; let drag = null; canvas.addEventListener('pointerdown', e => { if (!libAddState.buffer) return; e.preventDefault(); const mode = libAddWaveDragMode(e); const t = libAddWaveTimeFromEvent(e); const currentStart = parseFloat($('lib-add-start').value) || 0; const currentEnd = parseFloat($('lib-add-end').value) || libAddState.duration || 0; drag = {mode, anchor: t, start: currentStart, end: currentEnd}; canvas.setPointerCapture?.(e.pointerId); if (mode === 'start') setLibAddCropRange(t, currentEnd); else if (mode === 'end') setLibAddCropRange(currentStart, t); else setLibAddCropRange(t, t); setLibAddStatus(mode === 'start' ? 'Dragging crop start handle' : mode === 'end' ? 'Dragging crop end handle' : 'Drag to choose a new crop range'); }); canvas.addEventListener('pointermove', e => { if (!libAddState.buffer) return; if (!drag) { const mode = libAddWaveDragMode(e); canvas.style.cursor = mode === 'start' || mode === 'end' ? 'ew-resize' : 'crosshair'; return; } e.preventDefault(); const t = libAddWaveTimeFromEvent(e); if (drag.mode === 'start') setLibAddCropRange(t, drag.end); else if (drag.mode === 'end') setLibAddCropRange(drag.start, t); else setLibAddCropRange(drag.anchor, t); }); const finish = e => { if (!drag) return; e.preventDefault(); const t = libAddWaveTimeFromEvent(e); if (drag.mode === 'start') setLibAddCropRange(t, drag.end); else if (drag.mode === 'end') setLibAddCropRange(drag.start, t); else setLibAddCropRange(drag.anchor, t); drag = null; const start = parseFloat($('lib-add-start').value) || 0; const end = parseFloat($('lib-add-end').value) || 0; setLibAddStatus(`Crop range ${start.toFixed(2)}s to ${end.toFixed(2)}s (${Math.max(0, end - start).toFixed(1)}s) selected`); }; canvas.addEventListener('pointerup', finish); canvas.addEventListener('pointerleave', () => { if (!drag) canvas.style.cursor = 'crosshair'; }); canvas.addEventListener('pointercancel', () => { drag = null; canvas.style.cursor = 'crosshair'; }); } async function uploadLibAddFile(file) { if (!file) return; const fd = new FormData(); fd.append('file', file); setLibAddStatus('Uploading audio…'); try { const r = await fetch('/api/upload', {method:'POST', body:fd}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); suggestLibVoiceId(file.name); loadLibAddAudio(d.id, d.duration, file.name || 'Audio'); toast('Audio loaded', 'success'); } catch(e) { toast('Load failed: ' + e.message, 'error'); setLibAddStatus('Load failed'); } } const libAddDrop = $('lib-add-drop'); libAddDrop.addEventListener('click', () => $('lib-add-file').click()); libAddDrop.addEventListener('dragover', e => { e.preventDefault(); libAddDrop.classList.add('drag-over'); }); libAddDrop.addEventListener('dragleave', () => libAddDrop.classList.remove('drag-over')); libAddDrop.addEventListener('drop', e => { e.preventDefault(); libAddDrop.classList.remove('drag-over'); if (e.dataTransfer.files.length) uploadLibAddFile(e.dataTransfer.files[0]); }); $('lib-add-file').addEventListener('change', async () => { if ($('lib-add-file').files.length) await uploadLibAddFile($('lib-add-file').files[0]); $('lib-add-file').value = ''; }); $('lib-add-url-btn').addEventListener('click', () => { const url = $('lib-add-url').value.trim(); if (!url) { toast('Enter a YouTube or audio URL', 'error'); return; } $('lib-add-url-btn').disabled = true; setLibAddStatus('Starting download…'); const es = new EventSource('/api/download-yt?url=' + encodeURIComponent(url)); es.onmessage = e => { const d = JSON.parse(e.data); if (d.error) { toast('Download failed: ' + d.error, 'error'); setLibAddStatus(d.error); $('lib-add-url-btn').disabled = false; es.close(); } else if (d.done) { es.close(); $('lib-add-url-btn').disabled = false; suggestLibVoiceId(url.split('/').pop() || 'DownloadedVoice'); loadLibAddAudio(d.id, d.duration, 'Downloaded audio'); toast('URL audio loaded', 'success'); } else { setLibAddStatus(d.msg || 'Downloading…'); } }; es.onerror = () => { es.close(); $('lib-add-url-btn').disabled = false; setLibAddStatus('Download connection closed'); }; }); $('lib-add-rec-start').addEventListener('click', async () => { try { await ensureLibAddMicMonitor(); libAddState.chunks = []; libAddState.secs = 0; $('lib-add-rec-time').textContent = '0:00'; $('lib-add-rec-start').disabled = true; $('lib-add-rec-stop').disabled = false; $('lib-add-monitor-stop').disabled = true; libAddState.timer = setInterval(() => { libAddState.secs++; $('lib-add-rec-time').textContent = Math.floor(libAddState.secs / 60) + ':' + String(libAddState.secs % 60).padStart(2, '0'); }, 1000); libAddState.recorder = new MediaRecorder(libAddState.recordStream); libAddState.recorder.ondataavailable = e => { if (e.data.size) libAddState.chunks.push(e.data); }; libAddState.recorder.onstop = async () => { clearInterval(libAddState.timer); $('lib-add-rec-start').disabled = false; $('lib-add-rec-stop').disabled = true; const blob = new Blob(libAddState.chunks, {type:libAddState.recorder.mimeType || 'audio/webm'}); const ext = (libAddState.recorder.mimeType || '').includes('ogg') ? '.ogg' : '.webm'; stopLibAddMic(); suggestLibVoiceId('recording'); await uploadLibAddFile(new File([blob], 'recording' + ext, {type:blob.type})); }; libAddState.recorder.start(100); setLibAddStatus('Recording…'); } catch(e) { stopLibAddMic(); $('lib-add-mic-help').classList.add('open'); const message = await microphoneErrorMessage(e); toast(message, 'error'); setLibAddStatus(message); $('lib-add-rec-start').disabled = false; $('lib-add-rec-stop').disabled = true; } }); $('lib-add-rec-stop').addEventListener('click', () => { if (libAddState.recorder && libAddState.recorder.state !== 'inactive') libAddState.recorder.stop(); }); $('lib-add-auto-trim').addEventListener('click', async () => { if (!libAddState.id) { toast('Load audio first', 'error'); return; } $('lib-add-auto-trim').disabled = true; try { const r = await fetch('/api/auto-trim', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id})}); let d; if (r.ok) d = await r.json(); else if (r.status === 404 || r.status === 405) d = await clientAutoTrimBounds(libAddState.id); else { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } $('lib-add-start').value = Number(d.start).toFixed(2); $('lib-add-end').value = Number(d.end).toFixed(2); drawLibAddWave(); setLibAddStatus(d.reason || 'Auto trim ready'); } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); setLibAddStatus('Auto trim failed'); } finally { $('lib-add-auto-trim').disabled = false; } }); async function transcribeLibAddCurrent(successMessage = 'Text recognised', audioId = libAddState.id) { if (!audioId) throw new Error('Load audio first'); setLibAddStatus('Recognising text...'); const r = await fetch('/api/transcribe', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:audioId}) }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); const text = d.text || ''; $('lib-add-transcript').value = text; setLibAddStatus(successMessage); return text; } function openSavedLibraryVoice(voiceId) { const openRow = () => { const row = Array.from(document.querySelectorAll('.vl-row')).find(r => r.dataset.id === voiceId); if (!row) return false; row.scrollIntoView({behavior:'smooth', block:'center'}); if (!row.classList.contains('edit-open')) row.querySelector('.edit-audio-btn')?.click(); return true; }; if (!openRow()) setTimeout(openRow, 150); } async function applyLibAddCrop() { if (!libAddState.id) { toast('Load audio first', 'error'); return; } const start = clampLibAddTime(parseFloat($('lib-add-start').value) || 0); const end = clampLibAddTime(parseFloat($('lib-add-end').value) || libAddState.duration); const duration = end - start; if (end <= start + 0.1) { toast('Crop range is too short', 'error'); setLibAddStatus('Crop range is too short'); return; } if (duration < 3 || duration > 20) toast('Best clone references are 3-20 seconds; cropping anyway.', 'error'); ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = true; }); setLibAddStatus(`Cropping ${start.toFixed(2)}s to ${end.toFixed(2)}s...`); try { const r = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:libAddState.id, start, end})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); loadLibAddAudio(d.id, d.duration, 'Cropped audio'); toast('Crop applied', 'success'); try { await transcribeLibAddCurrent('Cropped audio loaded and text recognised', d.id); } catch (e) { toast('Crop applied, but recognition failed: ' + e.message, 'error'); setLibAddStatus('Cropped audio loaded; recognition failed'); } } catch(e) { toast('Crop failed: ' + e.message, 'error'); setLibAddStatus('Crop failed'); } finally { ['lib-add-save-crop', 'lib-add-save-crop-bottom'].forEach(id => { if ($(id)) $(id).disabled = false; }); } } $('lib-add-save-crop').addEventListener('click', applyLibAddCrop); $('lib-add-save-crop-bottom').addEventListener('click', applyLibAddCrop); ['lib-add-start','lib-add-end'].forEach(id => $(id).addEventListener('input', drawLibAddWave)); $('lib-add-play').addEventListener('click', () => { if (!libAddState.id) return; if (libAddState.audio) libAddState.audio.pause(); libAddState.audio = new Audio('/api/audio/' + libAddState.id); const start = parseFloat($('lib-add-start').value) || 0; const end = parseFloat($('lib-add-end').value) || libAddState.duration; libAddState.audio.currentTime = start; libAddState.audio.ontimeupdate = () => { if (libAddState.audio.currentTime >= end) libAddState.audio.pause(); }; libAddState.audio.play(); }); $('lib-add-recognize').addEventListener('click', async () => { if (!libAddState.id) { toast('Load audio first', 'error'); return; } try { await transcribeLibAddCurrent('Text recognised'); } catch(e) { toast('Recognition failed: ' + e.message, 'error'); setLibAddStatus('Recognition failed'); } }); $('lib-add-save').addEventListener('click', async () => { if (!libAddState.id) { toast('Load audio first', 'error'); return; } const voiceId = $('lib-add-voice-id').value.trim() || `${$('lib-add-lang').value}_${$('lib-add-gender').value}_NewVoice`; if (!validateVoiceId(voiceId)) { toast('Voice ID contains invalid characters', 'error'); return; } setLibAddStatus('Saving voice...'); $('lib-add-save').disabled = true; try { const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:libAddState.id, start:parseFloat($('lib-add-start').value)||0, end:parseFloat($('lib-add-end').value)||libAddState.duration})}); if (!pr.ok) { const e = await pr.json().catch(()=>({})); throw new Error(e.detail || pr.statusText); } const p = await pr.json(); let transcript = $('lib-add-transcript').value.trim(); if (!transcript) { transcript = await transcribeLibAddCurrent('Final clip recognised; saving voice...', p.id); if (!transcript.trim()) throw new Error('Recognition returned no transcript; add text or try recognising again.'); } const sr = await fetch('/api/save', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:p.id, voice_id:voiceId, transcript})}); if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); } if (libAddState.pendingSource?.imageUrl) { try { await fetch('/api/voice/picture-url', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({voice_id: voiceId, image_url: libAddState.pendingSource.imageUrl}) }); } catch (_) {} } libAddState.pendingSource = null; setLibAddSourcePreview({}); $('lib-add-panel')?.classList.remove('open'); toast('Voice saved: ' + voiceId, 'success'); setLibAddStatus('Voice saved'); await loadVoiceLibrary(); openSavedLibraryVoice(voiceId); } catch(e) { toast('Save failed: ' + e.message, 'error'); setLibAddStatus('Save failed'); } finally { $('lib-add-save').disabled = false; } }); $('show-disabled-cb').addEventListener('change', () => { $('disabled-info').style.display = $('show-disabled-cb').checked ? '' : 'none'; renderVoiceList(); }); ['library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { $(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); }); }); $('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180)); $('library-clear-filters')?.addEventListener('click', clearLibraryFilters); $('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); }); function renderVoiceList() { const showDisabled = $('show-disabled-cb').checked; const list = $('voice-list'); list.innerHTML = ''; renderVoiceGroupsBar(); populateLibraryFilters(); readLibraryFilters(); const _gtBtn = $('voice-group-tag-btn'); if (_gtBtn) { _gtBtn.classList.toggle('active', !!window._voiceGroupByTag); const ic = _gtBtn.querySelector('.mdi'); if (ic) ic.className = 'mdi mdi-folder' + (window._voiceGroupByTag ? '-open' : '') + '-outline'; } const _tvBtn = $('voice-table-view-btn'); if (_tvBtn) { _tvBtn.classList.toggle('active', !!window._voiceTableView); document.querySelector('.voices-workbench')?.classList.toggle('table-view', !!window._voiceTableView); } // Apply sidebar category filter const cat = window._voiceSidebarCat || 'all'; const enabledOk = v => showDisabled || v.enabled !== false; let filtered = _voices.filter(v => { // 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV). // 'cloned' = real WAV-identity voices that were NOT prompt-designed. if (cat === 'cloned') return enabledOk(v) && v.has_ref && v.origin !== 'designed'; if (cat === 'designed') return enabledOk(v) && (v.origin === 'designed' || !v.has_ref); if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4; if (cat === 'hidden') return v.enabled === false; // always show hidden voices in hidden tab // 'tools' shows all voices (same as 'all') so bulk-edit works return enabledOk(v); }); // Apply active group filter (from the groups bar) if (window._voiceGroupFilter) { filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter); } // Apply active tag "subfolder" filter (from the My Voices nav tree) if (window._voiceTagFilter) { filtered = filtered.filter(v => String(v.tag || '').split(',').map(t => t.trim()).includes(window._voiceTagFilter)); } const visibleCount = filtered.length; filtered = filtered.filter(libraryFilterMatch); const filterCount = filtered.length; if (_libraryIssueFilter) filtered = filtered.filter(v => libraryIssueMatch(v)); $('voice-count').textContent = filtered.length + ' / ' + _voices.length + ' voices'; filtered = filtered.slice().sort((a, b) => { const av = getSortValue(a, _sortField), bv = getSortValue(b, _sortField); if (av < bv) return -_sortDir; if (av > bv) return _sortDir; return 0; }); updateLibraryInsights(); if (_libraryIssueFilter) { const note = document.createElement('div'); note.className = 'library-filter-note'; note.innerHTML = `${escHtml(filtered.length)} / ${escHtml(filterCount)} ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}`; note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter('')); list.appendChild(note); } if (!filtered.length) { if (_voices.length === 0) { // True empty — render a two-case helper panel const emptyEl = document.createElement('div'); emptyEl.className = 'voices-empty-state'; emptyEl.innerHTML = `

Wrong folder path?

Set the path where your .wav voice files live inside the container.

Map a host folder via docker-compose:
- /your/host/path:/voices:rw
or set VOICE_HOST_DIR=/your/host/path in the stack env.

or

Folder is empty?

Create your first voice from a recording or download ready-made voices.

`; list.appendChild(emptyEl); fetch('/api/settings').then(r => r.json()).then(s => { const inp = document.getElementById('voices-empty-scan-dir'); if (inp) inp.value = s.voices_scan_dir || '/voices'; }).catch(() => {}); // ── Directory browser ────────────────────────────────────────────────── let _vefCurrentPath = '/'; const vefBrowser = document.getElementById('vef-dir-browser'); const vefDirList = document.getElementById('vef-dir-list'); const vefCrumb = document.getElementById('vef-breadcrumb'); const vefSelPath = document.getElementById('vef-selected-path'); async function vefNavigate(path) { _vefCurrentPath = path; vefDirList.innerHTML = 'Loading…'; if (vefSelPath) vefSelPath.textContent = path; try { const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json()); // Breadcrumb const parts = data.path.split('/').filter(Boolean); const crumbs = [{ label: '/', path: '/' }]; parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') })); vefCrumb.innerHTML = crumbs.map((c, i) => i < crumbs.length - 1 ? `/` : `${escHtml(c.label)}` ).join(''); vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path))); // Directory list if (!data.dirs.length) { vefDirList.innerHTML = 'No subdirectories here.'; } else { vefDirList.innerHTML = data.dirs.map(d => `` ).join(''); vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path))); } if (vefSelPath) vefSelPath.textContent = data.path; _vefCurrentPath = data.path; } catch(e) { vefDirList.innerHTML = `Error: ${escHtml(e.message)}`; } } document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => { const open = vefBrowser.hidden; vefBrowser.hidden = !open; if (open) { const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices'; vefNavigate(cur); } }); document.getElementById('vef-select-btn')?.addEventListener('click', () => { const inp = document.getElementById('voices-empty-scan-dir'); if (inp) inp.value = _vefCurrentPath; if (vefBrowser) vefBrowser.hidden = true; }); // ────────────────────────────────────────────────────────────────────── document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => { const inp = document.getElementById('voices-empty-scan-dir'); const dir = inp?.value?.trim(); if (!dir) return; const btn = document.getElementById('voices-empty-save-btn'); btn.disabled = true; btn.textContent = 'Saving…'; try { const s = await fetch('/api/settings').then(r => r.json()); s.voices_scan_dir = dir; const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); toast('Folder saved — reloading voices…', 'success'); _libraryLoadPromise = null; await loadVoiceLibrary(); } catch(e) { toast('Failed: ' + e.message, 'error'); btn.disabled = false; btn.textContent = 'Set & reload'; } }); document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone')); document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio')); } else { list.innerHTML += '
No voices match the current filters.
'; } return; } if (window._voiceGroupByTag) renderVoicesByTag(list, filtered); else filtered.forEach(v => list.appendChild(makeVoiceRow(v))); if (_pendingSelectId) { const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`); if (wrap) { _pendingSelectId = null; selectVoice(wrap); } } syncSortHeaders(); } // Group the list into collapsible virtual "folders" by tag (a voice with several // comma-separated tags appears under each; untagged voices fall into one bucket). function renderVoicesByTag(list, filtered) { const UNTAG = '— Untagged —'; const groups = new Map(); filtered.forEach(v => { const tags = String(v.tag || '').split(',').map(t => t.trim()).filter(Boolean); (tags.length ? tags : [UNTAG]).forEach(t => { if (!groups.has(t)) groups.set(t, []); groups.get(t).push(v); }); }); let collapsed = {}; try { collapsed = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {} const names = [...groups.keys()].sort((a, b) => a === UNTAG ? 1 : b === UNTAG ? -1 : a.toLowerCase().localeCompare(b.toLowerCase())); names.forEach(tag => { const voices = groups.get(tag); const isCol = !!collapsed[tag]; const sec = document.createElement('div'); sec.className = 'vl-tag-group'; const head = document.createElement('button'); head.className = 'vl-tag-head' + (isCol ? ' collapsed' : ''); head.type = 'button'; head.innerHTML = `` + `` + `${escHtml(tag)}` + `${voices.length}`; const body = document.createElement('div'); body.className = 'vl-tag-body'; if (isCol) body.style.display = 'none'; voices.forEach(v => body.appendChild(makeVoiceRow(v))); head.addEventListener('click', () => { const nowCol = body.style.display !== 'none'; body.style.display = nowCol ? 'none' : ''; head.classList.toggle('collapsed', nowCol); head.querySelector('.vl-tag-caret').className = 'mdi mdi-' + (nowCol ? 'chevron-right' : 'chevron-down') + ' vl-tag-caret'; head.querySelector('.mdi:nth-child(2)').className = 'mdi mdi-folder' + (nowCol ? '' : '-open') + '-outline'; let c = {}; try { c = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {} c[tag] = nowCol; localStorage.setItem('vl-tag-collapsed', JSON.stringify(c)); }); sec.appendChild(head); sec.appendChild(body); list.appendChild(sec); }); } async function decodeVoiceAudio(v) { const resp = await fetch(voiceFileUrl(v), {cache:'no-store'}); if (!resp.ok) throw new Error(resp.statusText || 'Audio not found'); const data = await resp.arrayBuffer(); const ctx = new (window.AudioContext || window.webkitAudioContext)(); return ctx.decodeAudioData(data.slice(0)); } function drawOptimizerWave(canvas, buffer, start = 0, end = buffer.duration) { const dpr = window.devicePixelRatio || 1; const width = Math.max(1, canvas.clientWidth); const height = Math.max(1, canvas.clientHeight); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg') || '#111'; ctx.fillRect(0, 0, width, height); const data = buffer.getChannelData(0); const step = Math.max(1, Math.floor(data.length / width)); const mid = height / 2; ctx.strokeStyle = '#89b4fa'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x < width; x++) { let min = 1, max = -1; const base = x * step; for (let i = 0; i < step && base + i < data.length; i++) { const v = data[base + i]; if (v < min) min = v; if (v > max) max = v; } ctx.moveTo(x, mid + min * mid * .92); ctx.lineTo(x, mid + max * mid * .92); } ctx.stroke(); const sx = Math.max(0, Math.min(width, start / buffer.duration * width)); const ex = Math.max(sx, Math.min(width, end / buffer.duration * width)); ctx.fillStyle = 'rgba(166,227,161,.18)'; ctx.fillRect(sx, 0, ex - sx, height); ctx.strokeStyle = '#a6e3a1'; ctx.lineWidth = 2; ctx.strokeRect(sx + .5, .5, Math.max(1, ex - sx - 1), height - 1); const selectedSec = Math.max(0, end - start); if (ex - sx > 54 && selectedSec > 0) { const label = `${selectedSec.toFixed(1)}s`; ctx.font = '12px ui-monospace, Menlo, Consolas, monospace'; const textW = ctx.measureText(label).width + 14; const tx = Math.max(sx + 6, Math.min(ex - textW - 6, sx + (ex - sx - textW) / 2)); ctx.fillStyle = 'rgba(30,30,46,.78)'; ctx.fillRect(tx, 6, textW, 22); ctx.fillStyle = '#ffffff'; ctx.fillText(label, tx + 7, 21); } const handleW = 12; ctx.fillStyle = '#3d5ce8'; ctx.strokeStyle = '#ffffff'; [sx, ex].forEach((x, idx) => { const hx = Math.max(0, Math.min(width - handleW, x - handleW / 2)); ctx.fillRect(hx, 0, handleW, height); ctx.strokeRect(hx + .5, .5, Math.max(1, handleW - 1), height - 1); ctx.fillStyle = '#ffffff'; ctx.fillRect(hx + 3, Math.max(12, height / 2 - 11), 2, 22); ctx.fillRect(hx + 7, Math.max(12, height / 2 - 11), 2, 22); ctx.fillStyle = '#3d5ce8'; }); } function makeVoiceRow(v) { const wrap = document.createElement('div'); wrap.className = 'vl-row' + (v.enabled===false ? ' vr-disabled' : ''); wrap.dataset.id = v.id; // Data attrs used by inspector header const color = avatarColor(v.id); wrap.dataset.color = color; wrap.dataset.hasRef = v.has_ref ? 'true' : 'false'; wrap.dataset.dbfs = fmtDbfs(v); wrap.dataset.lang = v.lang || v.id.split('_')[0].toUpperCase(); const langCode = v.lang || v.id.split('_')[0].toUpperCase(); // Display name: explicit meta.name (e.g. Rehearser character) wins, else last ID segment. const _idParts = String(v.id).split('_'); const dispName = (v.name && String(v.name).trim()) ? String(v.name).trim() : (_idParts.length > 1 ? _idParts[_idParts.length - 1] : v.id); const langOpts = FLAG_OPTIONS[langCode] || []; // Use language-specific variants if there are multiple; fall back to world picker otherwise const flagOpts = langOpts.length > 1 ? langOpts : ALL_FLAGS; const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || ''; const flagEmoji = currentFlag ? cc2flag(currentFlag) : ''; const flagCode = currentFlag ? ccDisplay(currentFlag) : '?'; wrap.dataset.flagEmoji = flagEmoji; wrap.dataset.flagCc = currentFlag || ''; wrap.dataset.rating = v.rating || 0; wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false'; const fileType = voiceFileType(v); const dbfs = fmtDbfs(v); const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; const benchText = fmtBenchmark(v); const benchTitle = benchmarkTitle(v); const benchCls = benchmarkClass(v); const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'}; const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'}; const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'}; const gender = v.gender || ''; const starsHtml = [1,2,3,4,5].map(i => `` ).join(''); const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null; const pickerHtml = flagOpts.length > 1 ? flagOpts.map(([cc, label]) => `${cc2flag(cc)}${ccDisplay(cc)}` ).join('') : `No regional variants`; // Compact list item elements (visible in list pane, hidden in inspector) const isClone = v.has_ref && v.origin !== 'designed'; const initial = v.id[0] ? v.id[0].toUpperCase() : '?'; const flagIconHtml = currentFlag ? `` : null; // Avatar: photo > gender/type icon > flag icon > initial letter (with color) const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null; const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo' : iconHtml ? 'vl-avatar vl-avatar-icon' : flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial'; const avatarStyle = (picSrc || iconHtml || flagIconHtml) ? '' : `style="background:${color}"`; const avatarContent = picSrc ? `` : iconHtml || flagIconHtml || `${initial}`; wrap.innerHTML = `
${avatarContent}
${escHtml(dispName)}
${isClone ? 'Clone' : 'Design'} ${gender ? `${genderMap[gender]||'?'} ${genderLabel[gender]||''}` : ''} ${benchText !== '-' ? ` ${escHtml(benchText)}` : ''}
${flagEmoji} ${escHtml(langCode)}
${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}
${isClone ? 'Clone' : 'Design'}
${benchText !== '-' ? ` ${escHtml(benchText)}` : '-'}
${dbfs} dB
${fmtDuration(v.duration)}
${starsHtml}
${escHtml(v.origin || '-')}
${v.seed != null ? '#' + v.seed : 'Auto'}
${escHtml(v.note || '')}
${escHtml(v.tag || '')}
${picSrc ? `` : '
'}
${flagEmoji} ${flagCode}
${pickerHtml}
${genderMap[gender]||'?'} ${genderLabel[gender]||'—'}
${escHtml(v.id)}
${escHtml(fileType.toUpperCase())}
${fmtDuration(v.duration)}
${escHtml(dbfs)}
${escHtml(benchText)}
${starsHtml}
Delete? ${escHtml(v.id)}
Reference audio · crop
Crop the saved WAV to 3–20 s of clean speech.
Reference transcript
The spoken text that matches this voice recording.
Voice match
Compare the saved WAV with a fresh TTS synthesis.

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.

WAV file
Synthesized reference text
${!isClone ? `
Style variation
Create a styled variant and save it as a new voice (CustomVoice only).

Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.

` : ''}
Loudness
Normalize the volume of the reference audio file.
Click the pencil to load waveform and tools.
`; const vrLengthEl = wrap.querySelector('.vr-length'); hydrateVoiceDuration(v, vrLengthEl); // Photo upload const photoCell = wrap.querySelector('.vr-photo'); const photoInput = wrap.querySelector('.photo-input'); const updatePhotoImg = () => { const ts = Date.now(); const imgSrc = `/api/voice/picture/${encodeURIComponent(v.id)}?t=${ts}`; // Update main photo cell const img = document.createElement('img'); img.src = imgSrc; img.alt = ''; photoCell.innerHTML = ''; photoCell.appendChild(img); photoCell.appendChild(photoInput); // Update compact row avatar const compactAvatar = wrap.querySelector('.vl-avatar'); if (compactAvatar) { compactAvatar.className = 'vl-avatar vl-avatar-photo'; compactAvatar.style.background = ''; compactAvatar.innerHTML = ``; } // Update inspector avatar if this voice is currently open const inspectorAvatar = document.querySelector('.inspector-avatar'); if (inspectorAvatar && wrap.classList.contains('vr-selected')) { inspectorAvatar.classList.add('insp-avatar-photo'); inspectorAvatar.style.background = ''; inspectorAvatar.innerHTML = ``; } v.has_picture = true; }; photoCell.addEventListener('update-photo', updatePhotoImg); photoCell.addEventListener('click', () => photoInput.click()); photoInput.addEventListener('change', async () => { if (!photoInput.files.length) return; const fd = new FormData(); fd.append('voice_id', v.id); fd.append('file', photoInput.files[0]); try { const r = await fetch('/api/voice/picture', { method:'POST', body:fd }); if (!r.ok) throw new Error((await r.json()).detail); updatePhotoImg(); toast('Photo uploaded','success'); } catch(e) { toast('Photo upload failed: '+e.message,'error'); } }); photoCell.addEventListener('dragenter', e => { e.preventDefault(); photoCell.classList.add('drag-over'); }); photoCell.addEventListener('dragover', e => { e.preventDefault(); photoCell.classList.add('drag-over'); }); photoCell.addEventListener('dragleave', () => photoCell.classList.remove('drag-over')); photoCell.addEventListener('drop', async e => { e.preventDefault(); photoCell.classList.remove('drag-over'); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { photoInput.files = e.dataTransfer.files; photoInput.dispatchEvent(new Event('change')); return; } let url = e.dataTransfer.getData('text/uri-list'); if (!url) { const html = e.dataTransfer.getData('text/html'); if (html) { const match = html.match(/src=["'](.*?)["']/); if (match) url = match[1]; } } if (!url) url = e.dataTransfer.getData('text/plain'); if (url && /^https?:\/\//i.test(url)) { status('Downloading picture from URL...'); try { const r = await fetch('/api/voice/picture-url', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({voice_id: v.id, image_url: url}) }); if (!r.ok) throw new Error((await r.json()).detail); updatePhotoImg(); toast('Photo saved from URL', 'success'); status('Photo saved successfully'); } catch(err) { toast('Photo URL download failed: ' + err.message, 'error'); status('Photo URL download failed'); } } }); // Per-voice loudness normalization const normalizeBtn = wrap.querySelector('.normalize-voice-btn'); const dbValue = wrap.querySelector('.vr-db-value'); const dbCell = wrap.querySelector('.vr-db'); normalizeBtn.addEventListener('click', async () => { const target = libraryTargetDb(); if (!confirm(`Normalize "${v.id}" to ${target} dBFS?`)) return; normalizeBtn.disabled = true; status('Normalizing ' + v.id + '…'); try { const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})}); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.loudness = d.loudness || v.loudness; v.duration = d.duration ?? v.duration; v.file_type = d.file_type || v.file_type; v.path = d.path || v.path; v.needs_tts_restart = true; markVoiceAudioChanged(v); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration); toast('Normalized: ' + v.id, 'success'); status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`); } catch(e) { toast('Normalize failed: ' + e.message, 'error'); status('Normalize failed'); } finally { normalizeBtn.disabled = false; } }); // Language picker const flagEmojiEl = wrap.querySelector('.flag-emoji'); const flagCodeEl = wrap.querySelector('.flag-code'); const flagPicker = wrap.querySelector('.flag-picker'); const flagCell = wrap.querySelector('.vr-flag'); flagCell.addEventListener('click', e => { e.stopPropagation(); document.querySelectorAll('.flag-picker.open').forEach(fp => { if(fp!==flagPicker) fp.classList.remove('open'); }); flagPicker.classList.toggle('open'); }); flagPicker.querySelectorAll('.flag-opt').forEach(opt => { opt.addEventListener('click', async e => { e.stopPropagation(); const cc = opt.dataset.cc; flagPicker.classList.remove('open'); flagEmojiEl.textContent = cc2flag(cc); flagCodeEl.textContent = ccDisplay(cc); flagPicker.querySelectorAll('.flag-opt').forEach(o => o.classList.toggle('active', o.dataset.cc===cc)); v.flag = cc; await saveMeta(v.id, { flag: cc }); }); }); // Gender cycle F → M → N → F const gBadge = wrap.querySelector('.gender-badge'); gBadge.addEventListener('click', async () => { const cycle = ['F','M','N']; v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3]; gBadge.innerHTML = `${genderMap[v.gender]||'?'}${genderLabel[v.gender]||'—'}`; gBadge.className = 'gender-badge ' + genderClass[v.gender]; await saveMeta(v.id, { gender: v.gender }); }); // Rename const nameText = wrap.querySelector('.vr-name-text'); const renameConf = wrap.querySelector('.rename-confirm'); const nameInput = wrap.querySelector('.vr-name-input'); const renameOk = wrap.querySelector('.rename-ok'); const renameCancel= wrap.querySelector('.rename-cancel'); const startRename = () => { nameText.style.display='none'; renameConf.classList.add('show'); nameInput.focus(); nameInput.select(); }; nameText.addEventListener('dblclick', startRename); const cancelRename = () => { nameText.style.display=''; renameConf.classList.remove('show'); nameInput.value = v.id; }; renameCancel.addEventListener('click', cancelRename); const doRename = async () => { const newId = nameInput.value.trim(); if (!newId || newId===v.id) { cancelRename(); return; } if (!/^[A-Za-z0-9_\-\.]+$/.test(newId)) { toast('Invalid characters in name','error'); return; } try { const r = await fetch('/api/voice/rename', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({old_id:v.id, new_id:newId})}); if (!r.ok) { const e=await r.json(); throw new Error(e.detail); } const d = await r.json(); v.id = newId; if (d.path) v.path = d.path; if (d.file_type) v.file_type = d.file_type; nameText.textContent = newId; nameText.title = newId; nameText.style.display=''; renameConf.classList.remove('show'); wrap.dataset.id = newId; nameInput.value = newId; toast('Renamed to '+newId,'success'); } catch(e) { toast('Rename failed: '+e.message,'error'); } }; renameOk.addEventListener('click', doRename); nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); }); // Select row → open inspector (click on compact area, but not on play/buttons) wrap.querySelector('.vl-compact').addEventListener('click', function (e) { if (e.target.closest('button')) return; selectVoice(wrap); }); // Inline optimizer in Library const editAudioBtn = wrap.querySelector('.edit-audio-btn'); const optPanel = wrap.querySelector('.vr-optimizer'); const vrTypeEl = wrap.querySelector('.vr-type'); const optCanvas = wrap.querySelector('.opt-wave'); const optStart = wrap.querySelector('.opt-start'); const optEnd = wrap.querySelector('.opt-end'); const optTranscript = wrap.querySelector('.opt-transcript'); const optTargetDb = wrap.querySelector('.opt-target-db'); const optStyleInstruct = wrap.querySelector('.opt-style-instruct'); const optStyleBackend = wrap.querySelector('.opt-style-backend'); const optStyleVoiceId = wrap.querySelector('.opt-style-voice-id'); const optCompareBackend = wrap.querySelector('.opt-compare-backend'); const optPlayReferenceBtn = wrap.querySelector('.opt-play-reference'); const optSynthReferenceBtn = wrap.querySelector('.opt-synth-reference'); const optCompareRefAudio = wrap.querySelector('.opt-compare-ref-audio'); const optCompareSynthAudio = wrap.querySelector('.opt-compare-synth-audio'); const optPreviewStyleBtn = wrap.querySelector('.opt-preview-style'); const optSaveStyleBtn = wrap.querySelector('.opt-save-style'); const optStyleAudio = wrap.querySelector('.opt-style-audio'); const optStatus = wrap.querySelector('.opt-status'); const optSaveTextBtn = wrap.querySelector('.opt-save-text'); const optRestartTtsBtn = wrap.querySelector('.opt-restart-tts'); const optRebenchmarkBtn = wrap.querySelector('.opt-rebenchmark'); const optRestartNote = wrap.querySelector('.opt-restart-note'); let optState = { loaded:false, id:null, duration:0, buffer:null, audio:null, compareSynthUrl:null }; const setOptStatus = msg => { optStatus.textContent = msg; status(msg); }; const setVoiceRestartState = (required, msg = '') => { v.needs_tts_restart = required; optPanel.classList.toggle('opt-restart-needed', required); optRestartNote.hidden = !required; optRestartNote.textContent = required ? 'Restart TTS before benchmarking; the backend may still have the old voice cached.' : ''; optRebenchmarkBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice'; benchmarkOneBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice'; if (msg) setOptStatus(msg); }; const markTtsRestartRequired = msg => setVoiceRestartState(true, msg); const refreshOptimizerFromVoice = async () => { markVoiceAudioChanged(v); optState.loaded = false; optState.buffer = null; optState.id = null; await loadOptimizer(); if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration); if (vrLengthEl) vrLengthEl.title = String(v.duration ?? ''); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; }; const saveOptimizerText = async () => { const transcript = optTranscript.value.trim(); setOptStatus('Saving reference text...'); v.transcript = transcript; refInput.value = transcript; refInput.title = transcript; refTranscribeBtn.style.display = transcript ? 'none' : ''; await saveMeta(v.id, { transcript }); markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.'); toast('Reference text saved: ' + v.id, 'success'); }; const redrawOpt = () => { if (!optState.buffer || optCanvas.clientWidth < 4) return; drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration); }; // Redraw whenever the canvas is resized (handles display:none → visible transition) const _waveRO = new ResizeObserver(() => redrawOpt()); _waveRO.observe(optCanvas); const syncCompareReferenceAudio = () => { if (!optState.id || !optCompareRefAudio) return; const src = '/api/audio/' + optState.id; if (!optCompareRefAudio.src.endsWith(src)) { optCompareRefAudio.src = src; optCompareRefAudio.load(); } const stopAtEnd = () => { const end = parseFloat(optEnd.value) || optState.duration; if (optCompareRefAudio.currentTime >= end) optCompareRefAudio.pause(); }; optCompareRefAudio.ontimeupdate = stopAtEnd; }; const optWaveTimeFromEvent = e => { const rect = optCanvas.getBoundingClientRect(); const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); return optState.duration ? x / Math.max(1, rect.width) * optState.duration : 0; }; const setOptCropRange = (start, end) => { start = Math.max(0, Math.min(optState.duration || 0, Number(start) || 0)); end = Math.max(0, Math.min(optState.duration || 0, Number(end) || 0)); if (end < start) [start, end] = [end, start]; optStart.value = start.toFixed(2); optEnd.value = end.toFixed(2); redrawOpt(); }; const optWaveSelectionPixels = e => { const rect = optCanvas.getBoundingClientRect(); const duration = Math.max(0.01, optState.duration || 0.01); const sx = (parseFloat(optStart.value) || 0) / duration * rect.width; const ex = (parseFloat(optEnd.value) || optState.duration || 0) / duration * rect.width; const x = e.clientX - rect.left; return {x, sx, ex}; }; const optWaveDragMode = e => { const {x, sx, ex} = optWaveSelectionPixels(e); const hit = 18; const nearStart = Math.abs(x - sx) <= hit; const nearEnd = Math.abs(x - ex) <= hit; if (nearStart && nearEnd) return Math.abs(x - sx) <= Math.abs(x - ex) ? 'start' : 'end'; if (nearStart) return 'start'; if (nearEnd) return 'end'; if (x > sx && x < ex) return 'move'; return 'new'; }; const attachOptWaveSelection = () => { let drag = null; optCanvas.addEventListener('pointerdown', e => { if (!optState.buffer || !optState.duration) return; e.preventDefault(); optCanvas.setPointerCapture?.(e.pointerId); const mode = optWaveDragMode(e); const currentStart = parseFloat(optStart.value) || 0; const currentEnd = parseFloat(optEnd.value) || optState.duration; drag = {mode, anchor: optWaveTimeFromEvent(e), start: currentStart, end: currentEnd, length: Math.max(0.05, currentEnd - currentStart)}; optCanvas.style.cursor = mode === 'move' ? 'grabbing' : 'ew-resize'; if (mode === 'new') setOptCropRange(drag.anchor, drag.anchor); }); optCanvas.addEventListener('pointermove', e => { if (!optState.buffer || !optState.duration) return; if (!drag) { const mode = optWaveDragMode(e); optCanvas.style.cursor = mode === 'move' ? 'grab' : (mode === 'start' || mode === 'end') ? 'ew-resize' : 'crosshair'; return; } e.preventDefault(); const t = optWaveTimeFromEvent(e); if (drag.mode === 'start') setOptCropRange(Math.min(t, drag.end - 0.05), drag.end); else if (drag.mode === 'end') setOptCropRange(drag.start, Math.max(t, drag.start + 0.05)); else if (drag.mode === 'move') { let start = t - (drag.anchor - drag.start); start = Math.max(0, Math.min((optState.duration || 0) - drag.length, start)); setOptCropRange(start, start + drag.length); } else setOptCropRange(drag.anchor, t); }); const finish = e => { if (!drag) return; optCanvas.releasePointerCapture?.(e.pointerId); drag = null; optCanvas.style.cursor = 'crosshair'; }; optCanvas.addEventListener('pointerup', finish); optCanvas.addEventListener('pointercancel', finish); optCanvas.addEventListener('pointerleave', () => { if (!drag) optCanvas.style.cursor = 'crosshair'; }); }; attachOptWaveSelection(); const loadOptimizer = async () => { if (optState.loaded) return; setOptStatus('Loading voice optimizer…'); const d = await loadLibraryVoiceAudio(v); optState.id = d.id; optState.duration = d.duration; v.duration = d.duration; optState.buffer = await decodeVoiceAudio(v); optState.loaded = true; optStart.value = '0.00'; optEnd.value = d.duration.toFixed(2); optEnd.max = d.duration.toFixed(2); optTranscript.value = d.transcript || v.transcript || ''; redrawOpt(); syncCompareReferenceAudio(); setVoiceRestartState(Boolean(v.needs_tts_restart)); setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready'); }; wrap._loadOptimizer = loadOptimizer; wrap._redrawOpt = redrawOpt; editAudioBtn.addEventListener('click', async () => { editAudioBtn.disabled = true; try { const opening = !wrap.classList.contains('edit-open'); document.querySelectorAll('.vl-row.edit-open').forEach(r => { if (r !== wrap) r.classList.remove('edit-open'); }); wrap.classList.toggle('edit-open', opening); if (opening) { await loadOptimizer(); wrap.scrollIntoView({behavior:'smooth', block:'nearest'}); } } catch(e) { toast('Edit load failed: '+e.message,'error'); status('Edit load failed'); } finally { editAudioBtn.disabled = false; } }); [optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); })); optStyleInstruct?.addEventListener('input', () => { if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value); }); optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap)); optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`)); if (optCompareBackend.value === '') { optCompareBackend.innerHTML = styleBackendOptions('voice_clone'); optCompareBackend.disabled = !availableTtsBackends().length; } if (optStyleBackend) updateStyleBackendHelp(wrap); wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); }); wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); }); wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; }); wrap.querySelector('.opt-play').addEventListener('click', async () => { try { await loadOptimizer(); if (optState.audio) optState.audio.pause(); optState.audio = new Audio('/api/audio/' + optState.id); optState.audio.currentTime = parseFloat(optStart.value) || 0; const end = parseFloat(optEnd.value) || optState.duration; optState.audio.ontimeupdate = () => { if (optState.audio.currentTime >= end) optState.audio.pause(); }; optState.audio.play(); } catch(e) { toast('Preview failed: ' + e.message, 'error'); } }); optPlayReferenceBtn.addEventListener('click', async () => { try { await loadOptimizer(); syncCompareReferenceAudio(); optCompareRefAudio.currentTime = parseFloat(optStart.value) || 0; await optCompareRefAudio.play().catch(()=>{}); setOptStatus('Playing reference WAV selection for comparison.'); } catch(e) { toast('Reference playback failed: ' + e.message, 'error'); } }); optSynthReferenceBtn.addEventListener('click', async () => { const text = optTranscript.value.trim(); if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return; } if (v.needs_tts_restart) { const ok = confirm('This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?'); if (!ok) { setOptStatus('Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.'); return; } try { const d = await clearTtsRestartFlags(); setVoiceRestartState(false, `Restart flags cleared (${d.cleared_restart_flags || 0}). Synthesizing comparison...`); toast('Restart flags cleared', 'success'); } catch(e) { toast('Could not clear restart flags: ' + e.message, 'error'); setOptStatus('Could not clear restart flags'); return; } } optSynthReferenceBtn.disabled = true; try { await loadOptimizer(); setOptStatus('Synthesizing reference text for comparison...'); const source = await createTtsAudioSource(v.id, text, optCompareBackend.value, 'settings', ''); if (optState.compareSynthUrl) URL.revokeObjectURL(optState.compareSynthUrl); optCompareSynthAudio.src = source.url; optState.compareSynthUrl = source.streaming ? null : source.url; await optCompareSynthAudio.play().catch(()=>{}); setOptStatus(source.streaming ? 'Streaming synthesized comparison.' : 'Synthesized comparison ready.'); } catch(e) { toast('Synthesis comparison failed: ' + e.message, 'error'); setOptStatus('Synthesis comparison failed'); } finally { optSynthReferenceBtn.disabled = false; } }); wrap.querySelector('.opt-auto-trim').addEventListener('click', async () => { try { await loadOptimizer(); const d = await clientAutoTrimBounds(optState.id); optStart.value = Number(d.start).toFixed(2); optEnd.value = Number(d.end).toFixed(2); redrawOpt(); setOptStatus(d.reason || 'Auto trim ready'); } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); } }); wrap.querySelector('.opt-recognize').addEventListener('click', async () => { try { await loadOptimizer(); const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:optState.id})}); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } const d = await r.json(); optTranscript.value = d.text || ''; setOptStatus('Reference text recognised. Review it, then Save text.'); } catch(e) { toast('Recognition failed: ' + e.message, 'error'); } }); optSaveTextBtn.addEventListener('click', async () => { optSaveTextBtn.disabled = true; try { await saveOptimizerText(); } catch(e) { toast('Save text failed: ' + e.message, 'error'); setOptStatus('Save text failed'); } finally { optSaveTextBtn.disabled = false; } }); wrap.querySelector('.opt-save-crop').addEventListener('click', async () => { try { await loadOptimizer(); const cropStart = Math.max(0, parseFloat(optStart.value) || 0); const cropEnd = Math.min(optState.duration, parseFloat(optEnd.value) || optState.duration); if (cropStart <= 0.01 && cropEnd >= optState.duration - 0.05) { setOptStatus('No crop range selected. Adjust Start or End first, then Save crop.'); toast('No crop range selected', 'error'); return; } if (cropEnd <= cropStart + 0.1) { setOptStatus('Crop range is too short.'); toast('Crop range is too short', 'error'); return; } setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`); const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:optState.id, start:cropStart, end:cropEnd})}); if (!pr.ok) { const e = await pr.json(); throw new Error(e.detail || pr.statusText); } const p = await pr.json(); const rr = await fetch('/api/voice-replace', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:p.id, voice_id:v.id, path:v.path, transcript:optTranscript.value})}); if (!rr.ok) { const e = await rr.json().catch(()=>({})); throw new Error(e.detail || rr.statusText); } const saved = await rr.json(); v.transcript = optTranscript.value; v.duration = saved.duration ?? p.duration; if (saved.loudness) v.loudness = saved.loudness; if (saved.path) v.path = saved.path; if (saved.file_type) v.file_type = saved.file_type; markVoiceAudioChanged(v); refInput.value = v.transcript; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); } await refreshOptimizerFromVoice(); toast('Voice crop saved: ' + v.id, 'success'); markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.'); } catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); } }); if (optStyleInstruct) { const styleVariationInput = () => { const style = optStyleInstruct.value.trim(); const text = optTranscript.value.trim() || benchmarkSampleText(); const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style); if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; } if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; } if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; } return {style, text, newId, backend: optStyleBackend.value}; }; optPreviewStyleBtn.addEventListener('click', async () => { const input = styleVariationInput(); if (!input) return; optPreviewStyleBtn.disabled = true; try { setOptStatus('Synthesizing style preview...'); const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend); if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src); optStyleAudio.src = URL.createObjectURL(blob); optStyleAudio.style.display = ''; await optStyleAudio.play().catch(()=>{}); setOptStatus('Style preview ready. If it sounds right, save it as a new voice.'); } catch(e) { toast('Style preview failed: ' + e.message, 'error'); setOptStatus('Style preview failed'); } finally { optPreviewStyleBtn.disabled = false; } }); optSaveStyleBtn.addEventListener('click', async () => { const input = styleVariationInput(); if (!input) return; optSaveStyleBtn.disabled = true; try { setOptStatus(`Synthesizing style variation ${input.newId}...`); const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); toast('Style variation saved: ' + d.voice_id, 'success'); setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.'); await loadVoiceLibrary(); renderIntegrationSnippets(); } catch(e) { toast('Style variation failed: ' + e.message, 'error'); setOptStatus('Style variation failed'); } finally { optSaveStyleBtn.disabled = false; } }); } wrap.querySelector('.opt-undo').addEventListener('click', async () => { if (!confirm(`Restore the original backup for "${v.id}"?`)) return; try { setOptStatus('Restoring original…'); const r = await fetch('/api/voice/undo', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.duration = d.duration ?? v.duration; v.loudness = d.loudness || v.loudness; v.path = d.path || v.path; v.file_type = d.file_type || v.file_type; markVoiceAudioChanged(v); if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); } await refreshOptimizerFromVoice(); toast('Original restored: ' + v.id, 'success'); markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.'); } catch(e) { toast('Undo failed: ' + e.message, 'error'); setOptStatus('Undo failed'); } }); wrap.querySelector('.opt-save-volume').addEventListener('click', async () => { try { setOptStatus('Saving volume…'); const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:Number(optTargetDb.value || -20)})}); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); v.loudness = d.loudness || v.loudness; v.duration = d.duration ?? v.duration; if (d.path) v.path = d.path; if (d.file_type) v.file_type = d.file_type; markVoiceAudioChanged(v); dbValue.textContent = fmtDbfs(v); dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; await refreshOptimizerFromVoice(); toast('Volume saved: ' + v.id, 'success'); markTtsRestartRequired('Volume saved. Restart TTS before rebenchmarking this voice.'); } catch(e) { toast('Volume save failed: ' + e.message, 'error'); setOptStatus('Volume save failed'); } }); optRestartTtsBtn.addEventListener('click', async () => { optRestartTtsBtn.disabled = true; try { setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…'); const r = await fetch('/api/tts/restart', { method:'POST' }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); _voices.forEach(voice => { voice.needs_tts_restart = false; }); updateLibraryInsights(); const names = (d.restarted || []).join(', ') || 'containers'; const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : ''; setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`); toast(`TTS restarted: ${names}`, 'success'); } catch(e) { toast('Restart TTS failed: ' + e.message, 'error'); setOptStatus('Restart TTS failed: ' + e.message); } finally { optRestartTtsBtn.disabled = false; } }); // Reference text input and recognition const refInput = wrap.querySelector('.vr-ref input'); const refTranscribeBtn = wrap.querySelector('.ref-transcribe-btn'); refInput.addEventListener('input', debounce(async () => { v.transcript = refInput.value; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; await saveMeta(v.id, { transcript: v.transcript }); if (wrap.classList.contains('edit-open')) markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.'); else v.needs_tts_restart = true; }, 800)); refTranscribeBtn.addEventListener('click', async () => { refTranscribeBtn.disabled = true; try { const d = await loadLibraryVoiceAudio(v); const tr = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})}); if (!tr.ok) { const e=await tr.json(); throw new Error(e.detail); } const text = await tr.json(); v.transcript = text.text || ''; refInput.value = v.transcript; refInput.title = v.transcript; refTranscribeBtn.style.display = v.transcript ? 'none' : ''; await saveMeta(v.id, { transcript: v.transcript }); v.needs_tts_restart = true; toast('Reference text recognised; restart TTS before benchmarking','success'); } catch(e) { toast('Recognition failed: '+e.message,'error'); } finally { refTranscribeBtn.disabled = false; } }); // Note (debounced save) const noteInput = wrap.querySelector('.vr-note input'); noteInput.addEventListener('input', debounce(async () => { v.note = noteInput.value; await saveMeta(v.id, { note: v.note }); }, 800)); // Stars const starSpans = wrap.querySelectorAll('.star'); starSpans.forEach(s => { s.addEventListener('click', async () => { const val = parseInt(s.dataset.val); const newRating = val===v.rating ? 0 : val; v.rating = newRating; starSpans.forEach((ss,i) => ss.classList.toggle('on', i { const val = parseInt(s.dataset.val); starSpans.forEach((ss,i) => ss.classList.toggle('on', i { starSpans.forEach((ss,i) => ss.classList.toggle('on', i<(v.rating||0))); }); }); // Per-voice benchmark const benchmarkOneBtn = wrap.querySelector('.benchmark-one-btn'); const benchmarkThisVoice = async triggerBtn => { if (v.needs_tts_restart) { const ok = confirm('This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?'); if (!ok) { setOptStatus('Restart TTS first, then rebenchmark this voice.'); return; } } triggerBtn.disabled = true; status('Benchmarking ' + v.id + '...'); try { setBenchmarkProgress(0, 1, `Benchmarking ${v.id}`); const d = await runVoiceBenchmark(v.id); mergeBenchmarkResults(d); const hit = (d.voices || []).find(x => x.voice_id === v.id); if (hit && hit.benchmark) v.benchmark = hit.benchmark; const benchCell = wrap.querySelector('.vr-bench'); benchCell.className = 'vr-bench ' + benchmarkClass(v); benchCell.title = benchmarkTitle(v); benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v); setBenchmarkProgress(1, 1, `Finished ${v.id}`); toast('Benchmarked ' + v.id, 'success'); setVoiceRestartState(false, 'Benchmark saved for ' + v.id); } catch(e) { toast('Benchmark failed: ' + e.message, 'error'); setOptStatus('Benchmark failed'); } finally { triggerBtn.disabled = false; } }; benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn)); optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn)); // Play original recording or synthesized sample const originalPlayBtn = wrap.querySelector('.vr-play-original button'); const synthPlayBtn = wrap.querySelector('.vr-play-synth button'); const playIcon = '', pauseIcon = '', generatingIcon = ''; function setLibraryPlayButtonState(btn, state) { btn.classList.toggle('is-generating', state === 'generating'); btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon); btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording')); } async function playLibraryVoice(kind, playBtn) { const bar = $('lib-audio-bar'), audio = $('lib-audio'); const playKey = v.id + ':' + kind; playBtn.dataset.playKind = kind; if (_activePlayVoiceId === playKey && !audio.paused) { audio.pause(); setLibraryPlayButtonState(playBtn, 'idle'); return; } if (_activePlayVoiceId === playKey && audio.paused && audio.src) { _activePlayButton = playBtn; try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); } return; } if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle'); _activePlayButton = playBtn; _activePlayVoiceId = playKey; if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; } if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating'); playBtn.disabled = true; try { if (kind === 'synth') { if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error'); const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview'; const text = synthMode === 'transcript' ? (v.transcript?.trim() || benchmarkSampleText()) : benchmarkSampleText(); const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text'; const backend = libraryTtsBackend(); const source = await createTtsAudioSource(v.id, text, backend, 'settings', ''); audio.src = source.url; if (!source.streaming) _activePlayUrl = source.url; $('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend); } else { audio.src = voiceFileUrl(v); $('lib-audio-label').textContent = v.id + ' · original recording'; } bar.style.display = ''; audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; }; audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); }; audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); }; await audio.play(); } catch(e) { setLibraryPlayButtonState(playBtn, 'idle'); toast('Play failed: '+e.message,'error'); } finally { playBtn.disabled = false; } } originalPlayBtn.dataset.playKind = 'original'; synthPlayBtn.dataset.playKind = 'synth'; setLibraryPlayButtonState(originalPlayBtn, 'idle'); setLibraryPlayButtonState(synthPlayBtn, 'idle'); originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn)); synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn)); // Enable toggle const toggleCb = wrap.querySelector('.toggle input'); toggleCb.addEventListener('change', async () => { const nextEnabled = toggleCb.checked; const previousEnabled = v.enabled !== false; toggleCb.disabled = true; try { const saved = await saveMeta(v.id, { enabled: nextEnabled }); v.enabled = nextEnabled; if (saved && saved.path) v.path = saved.path; wrap.classList.toggle('vr-disabled', !v.enabled); toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success'); if (!v.enabled && !$('show-disabled-cb').checked) { wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0'; setTimeout(() => wrap.remove(), 400); } } catch(e) { toggleCb.checked = previousEnabled; v.enabled = previousEnabled; wrap.classList.toggle('vr-disabled', !v.enabled); toast('Move failed: ' + e.message, 'error'); } finally { toggleCb.disabled = false; } }); // Delete voice const deleteBtn = wrap.querySelector('.delete-btn'); const deleteConfirm = wrap.querySelector('.delete-confirm'); const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel'); const deleteGoBtn = wrap.querySelector('.delete-confirm-go'); const closeDeleteConfirm = () => wrap.classList.remove('delete-pending'); deleteBtn.addEventListener('click', e => { e.stopPropagation(); document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); }); wrap.classList.add('delete-pending'); deleteGoBtn.focus(); }); deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); }); deleteConfirm.addEventListener('click', e => e.stopPropagation()); deleteGoBtn.addEventListener('click', async e => { e.stopPropagation(); deleteGoBtn.disabled = true; deleteCancelBtn.disabled = true; try { const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail); } _voices = _voices.filter(x => x.id !== v.id); wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0'; setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300); toast(`Deleted: ${v.id}`, 'success'); } catch(e) { toast('Delete failed: ' + e.message, 'error'); deleteGoBtn.disabled = false; deleteCancelBtn.disabled = false; closeDeleteConfirm(); } }); return wrap; } // ── Voice groups bar (bulk edit/remove a tagged set, e.g. a rehearser play) ── function renderVoiceGroupsBar() { const bar = $('voice-groups-bar'); if (!bar) return; // Tally groups across all voices const groups = {}; (_voices || []).forEach(v => { const g = (v.group || '').trim(); if (g) groups[g] = (groups[g] || 0) + 1; }); const names = Object.keys(groups).sort(); if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; } bar.hidden = false; const active = window._voiceGroupFilter || ''; bar.innerHTML = ` Groups` + names.map(g => ` ${escHtml(g)} ${groups[g]} ` ).join('') + (active ? `` : ''); bar.querySelectorAll('.vg-chip').forEach(chip => { chip.addEventListener('click', e => { if (e.target.closest('.vg-del')) return; const g = chip.dataset.group; window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g; renderVoiceList(); }); }); bar.querySelectorAll('.vg-del').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); }); }); bar.querySelector('.vg-clear')?.addEventListener('click', () => { window._voiceGroupFilter = ''; renderVoiceList(); }); } async function deleteVoiceGroup(group) { const count = (_voices || []).filter(v => (v.group || '').trim() === group).length; if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return; try { const r = await fetch('/api/voices/delete-group', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); if (window._voiceGroupFilter === group) window._voiceGroupFilter = ''; toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success'); await loadVoiceLibrary(); } catch(e) { toast('Delete failed: ' + e.message, 'error'); } } async function saveMeta(voiceId, patch) { const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ voice_id:voiceId, ...patch }) }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } return r.json(); } // Close language pickers when clicking elsewhere document.addEventListener('click', () => { document.querySelectorAll('.flag-picker.open').forEach(fp => fp.classList.remove('open')); document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending')); }, { passive:true }); // ── Bulk-edit ──────────────────────────────────────────────────────────────── const _bulkSelected = new Set(); // voice IDs currently checked function _bulkUpdate() { const bar = $('vl-bulk-bar'); const count = $('vl-bulk-count'); const n = _bulkSelected.size; if (bar) bar.hidden = n === 0; if (count) count.textContent = `${n} selected`; // Sync checkboxes in the DOM to match the set document.querySelectorAll('.vl-bulk-cb').forEach(cb => { cb.checked = _bulkSelected.has(cb.dataset.id); }); } function _bulkToggle(id, checked) { if (checked) _bulkSelected.add(id); else _bulkSelected.delete(id); _bulkUpdate(); } // Inject checkbox into each voice row after renderVoiceList builds the DOM const _origRenderVoiceList = renderVoiceList; renderVoiceList = function() { _origRenderVoiceList.apply(this, arguments); _bulkInjectCheckboxes(); }; function _bulkInjectCheckboxes() { document.querySelectorAll('#voice-list .vl-row').forEach(row => { if (row.querySelector('.vl-bulk-cb')) return; // already injected const id = row.dataset.id; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'vl-bulk-cb'; cb.dataset.id = id; cb.checked = _bulkSelected.has(id); cb.title = 'Select for bulk edit'; cb.addEventListener('change', e => { e.stopPropagation(); _bulkToggle(id, cb.checked); }); cb.addEventListener('click', e => e.stopPropagation()); // Prepend into vl-compact const compact = row.querySelector('.vl-compact'); if (compact) compact.prepend(cb); }); } // ── Toolbar button wiring ─────────────────────────────────────────────────── $('vl-bulk-select-all')?.addEventListener('click', () => { document.querySelectorAll('#voice-list .vl-row').forEach(row => { if (row.dataset.id) _bulkSelected.add(row.dataset.id); }); _bulkUpdate(); }); // Always-visible Select-all toggle in the sort bar (the bulk bar only appears once // something is selected, so this is the entry point). Selects every visible row, or // clears the selection if they're all already selected. $('vl-select-all-visible')?.addEventListener('click', () => { const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id); const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id)); if (allSelected) { rows.forEach(r => _bulkSelected.delete(r.dataset.id)); } else { rows.forEach(r => _bulkSelected.add(r.dataset.id)); } _bulkUpdate(); }); $('vl-bulk-deselect')?.addEventListener('click', () => { _bulkSelected.clear(); _bulkUpdate(); }); $('vl-bulk-hide')?.addEventListener('click', async () => { if (!_bulkSelected.size) return; const ids = [..._bulkSelected]; const r = await _bulkSetEnabled(ids, false); toast(`Hidden ${r} voice${r!==1?'s':''}`, 'success'); _bulkSelected.clear(); await loadVoiceLibrary(); }); $('vl-bulk-unhide')?.addEventListener('click', async () => { if (!_bulkSelected.size) return; const ids = [..._bulkSelected]; const r = await _bulkSetEnabled(ids, true); toast(`Unhidden ${r} voice${r!==1?'s':''}`, 'success'); _bulkSelected.clear(); await loadVoiceLibrary(); }); $('vl-bulk-tag')?.addEventListener('click', () => { if (!_bulkSelected.size) return; const ids = [..._bulkSelected]; // Show a small inline prompt inside the toolbar const bar = $('vl-bulk-bar'); let inp = bar.querySelector('.vl-bulk-tag-inp'); if (!inp) { inp = document.createElement('input'); inp.type = 'text'; inp.className = 'vl-bulk-tag-inp'; inp.placeholder = 'tag1, tag2…'; inp.autocomplete = 'off'; const applyBtn = document.createElement('button'); applyBtn.className = 'vl-bulk-btn'; applyBtn.innerHTML = ' Apply'; applyBtn.addEventListener('click', async () => { const tagVal = inp.value.trim(); let done = 0; for (const id of ids) { await saveMeta(id, { tag: tagVal }).catch(() => {}); done++; } toast(`Tag set on ${done} voice${done!==1?'s':''}`, 'success'); inp.remove(); applyBtn.remove(); _bulkSelected.clear(); await loadVoiceLibrary(); }); bar.appendChild(inp); bar.appendChild(applyBtn); } inp.focus(); }); $('vl-bulk-rating')?.addEventListener('click', () => { if (!_bulkSelected.size) return; const ids = [..._bulkSelected]; const bar = $('vl-bulk-bar'); let sel = bar.querySelector('.vl-bulk-rating-sel'); if (!sel) { sel = document.createElement('select'); sel.className = 'vl-bulk-rating-sel'; sel.innerHTML = '' + [1,2,3,4,5].map(n => ``).join(''); sel.addEventListener('change', async () => { const rating = Number(sel.value); if (!rating) return; let done = 0; for (const id of ids) { await saveMeta(id, { rating }).catch(() => {}); done++; } toast(`Rated ${done} voice${done!==1?'s':''}`, 'success'); sel.remove(); _bulkSelected.clear(); await loadVoiceLibrary(); }); bar.appendChild(sel); sel.focus(); } }); $('vl-bulk-delete')?.addEventListener('click', () => { if (!_bulkSelected.size) return; _showBulkDeleteConfirm([..._bulkSelected]); }); function _showBulkDeleteConfirm(ids) { document.querySelector('.vl-bdc-overlay')?.remove(); const plural = ids.length !== 1 ? 's' : ''; const names = ids.slice(0, 12).map(id => `${escHtml(id)}`).join(''); const more = ids.length > 12 ? `+${ids.length - 12} more` : ''; const ov = document.createElement('div'); ov.className = 'vl-bdc-overlay'; ov.innerHTML = `
Delete ${ids.length} voice${plural}?

This permanently removes the selected voice${plural} from your library. This cannot be undone.

${names}${more}
`; const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); }; function onKey(e) { if (e.key === 'Escape') close(); } ov.addEventListener('click', e => { if (e.target === ov) close(); }); ov.querySelector('.vl-bdc-cancel').addEventListener('click', close); document.addEventListener('keydown', onKey); ov.querySelector('.vl-bdc-go').addEventListener('click', async () => { const goBtn = ov.querySelector('.vl-bdc-go'); const cancelBtn = ov.querySelector('.vl-bdc-cancel'); goBtn.disabled = cancelBtn.disabled = true; let done = 0, errors = 0; await runPool(ids, async (id) => { try { const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (r.ok) done++; else errors++; } catch (_) { errors++; } }, 5, (n) => { goBtn.innerHTML = ` Deleting ${n}/${ids.length}…`; }); close(); toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success'); _bulkSelected.clear(); await loadVoiceLibrary(); }); document.body.appendChild(ov); ov.querySelector('.vl-bdc-cancel').focus(); } // ── Bulk API helpers ──────────────────────────────────────────────────────── async function _bulkSetEnabled(ids, enabled) { let done = 0; for (const id of ids) { await saveMeta(id, { enabled }).catch(() => {}); done++; } return done; }