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.
Restart TTS before benchmarking.
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.