// ── 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], .vl-table-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();
  } else if (e.target.closest('#voice-table-view-btn')) {
    window._voiceTableView = !window._voiceTableView;
    if (!window._voiceTableView) window._voiceTableEditMode = false;
    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);
    
    const editBtn = document.getElementById('voice-table-edit-btn');
    if (editBtn) {
      editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
      editBtn.classList.toggle('active', !!window._voiceTableEditMode);
    }
    
    document.querySelector('.voices-workbench')?.classList.toggle('table-view', window._voiceTableView);
    document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', !!window._voiceTableEditMode);
    
    // 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 = '<div class="inspector-placeholder"><span><span class="mdi mdi-table"></span></span><p>Table View Mode<br>Click a row to exit table view and edit.</p></div>';
    }
    renderVoiceList();
  } else if (e.target.closest('#voice-table-edit-btn')) {
    window._voiceTableEditMode = !window._voiceTableEditMode;
    const editBtn = document.getElementById('voice-table-edit-btn');
    editBtn?.classList.toggle('active', window._voiceTableEditMode);
    document.querySelector('.voices-workbench')?.classList.toggle('table-edit-mode', window._voiceTableEditMode);
    renderVoiceList();
  }
});
// 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;
      setSort(field);
    }
  }
});

document.addEventListener('change', e => {
  if (e.target.id === 'voice-sort-field') setSort(e.target.value);
});

document.addEventListener('change', async e => {
  if (e.target.classList.contains('vl-inline-edit')) {
    const row = e.target.closest('.vl-row');
    if (!row) return;
    const voiceId = row.dataset.id;
    const field = e.target.dataset.field;
    let value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
    
    if (field === 'rating') value = parseInt(value) || 0;

    const payload = {};
    payload[field] = value;
    
    try {
      await saveMeta(voiceId, payload);
      const v = _voices.find(vv => vv.id === voiceId);
      if (v) v[field] = value;
      if (typeof toast === 'function') toast('Saved ' + field, 'success');
    } catch (err) {
      if (typeof toast === 'function') toast('Failed to save ' + field, 'error');
    }
  }
});

const FLAG_LANGUAGE_CANDIDATES = {
  GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'],
  CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'],
  DE:['DE'], AT:['DE'], CH:['DE','FR','IT'],
  FR:['FR'], BE:['FR','NL'], LU:['FR','DE'],
  ES:['ES'], MX:['ES'], AR:['ES'], CO:['ES'], CL:['ES'], PE:['ES'], VE:['ES'], UY:['ES'], EC:['ES'], BO:['ES'], CR:['ES'], CU:['ES'], DO:['ES'],
  PT:['PT'], BR:['PT'],
  IT:['IT'], NL:['NL'], PL:['PL'], SE:['SV'], DK:['DA'], NO:['NO'], FI:['FI'], IS:['IS'], GR:['EL'], CY:['EL','TR'],
  CZ:['CS'], SK:['SK'], HU:['HU'], RO:['RO'], BG:['BG'], HR:['HR'], SI:['SL'], RS:['SR'], BA:['BS'], ME:['SR'], MK:['MK'], AL:['SQ'],
  EE:['ET'], LV:['LV'], LT:['LT'], UA:['UK'], RU:['RU'], BY:['RU'], MD:['RO'], TR:['TR'],
  CN:['ZH'], TW:['ZH'], HK:['ZH'], MO:['ZH'], JP:['JA'], KR:['KO'], VN:['VI'], TH:['TH'], ID:['ID'], MY:['MS'], PK:['UR'], BD:['BN'], LK:['SI'], NP:['NE'],
  SA:['AR'], EG:['AR'], AE:['AR'], MA:['AR'], QA:['AR'], KW:['AR'], OM:['AR'], JO:['AR'], LB:['AR'], IQ:['AR'], IR:['FA'], IL:['HE'],
};
const FLAG_LANGUAGE = Object.fromEntries(Object.entries(FLAG_LANGUAGE_CANDIDATES).map(([cc, langs]) => [cc, langs[0]]));

const LANGUAGE_LABELS = {
  EN:'English', DE:'German', FR:'French', ES:'Spanish', PT:'Portuguese', IT:'Italian', NL:'Dutch', PL:'Polish', SV:'Swedish',
  DA:'Danish', NO:'Norwegian', FI:'Finnish', IS:'Icelandic', EL:'Greek', MT:'Maltese', CS:'Czech', SK:'Slovak', HU:'Hungarian', RO:'Romanian', BG:'Bulgarian',
  HR:'Croatian', SL:'Slovenian', SR:'Serbian', BS:'Bosnian', MK:'Macedonian', SQ:'Albanian', ET:'Estonian', LV:'Latvian', LT:'Lithuanian', UK:'Ukrainian',
  RU:'Russian', ZH:'Chinese', JA:'Japanese', KO:'Korean', VI:'Vietnamese', TH:'Thai', ID:'Indonesian', MS:'Malay', FIL:'Filipino', HI:'Hindi', UR:'Urdu', BN:'Bengali', SI:'Sinhala', NE:'Nepali',
  AR:'Arabic', FA:'Persian', HE:'Hebrew', TR:'Turkish',
};
const SEX_FILTER_LABELS = {
  F:'♀ Female',
  M:'♂ Male',
  N:'⚥ Diverse / neutral',
};

function voiceLangFromName(v) {
  return (v.lang || String(v.id || '').split('_')[0] || '').toUpperCase();
}

function libraryVoiceLang(v) {
  const fromName = voiceLangFromName(v);
  const candidates = FLAG_LANGUAGE_CANDIDATES[String(v.flag || '').toUpperCase()];
  if (candidates?.length) return candidates.includes(fromName) ? fromName : candidates[0];
  return fromName;
}

function libraryLanguageLabel(code) {
  return LANGUAGE_LABELS[code] || code;
}

function populateLibraryFilters() {
  const langSel = $('library-filter-lang');
  const sexSel = $('library-filter-sex');
  const typeSel = $('library-filter-type');
  if (!langSel || !sexSel || !typeSel) return;
  const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
  const langs = [...new Set((_voices || []).map(libraryVoiceLang).filter(Boolean))].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
  const sexOrder = ['F','M','N'];
  const sexes = [...new Set((_voices || []).map(v => v.gender || '').filter(Boolean))]
    .sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b)));
  const types = [...new Set((_voices || []).map(voiceFileType).filter(Boolean))].sort();
  langSel.innerHTML = '<option value="">All languages</option>' + langs.map(x => `<option value="${escHtml(x)}">${escHtml(libraryLanguageLabel(x))} (${escHtml(x)})</option>`).join('');
  sexSel.innerHTML = '<option value="">All</option>' + sexes.map(x => `<option value="${escHtml(x)}">${escHtml(SEX_FILTER_LABELS[x] || x)}</option>`).join('');
  typeSel.innerHTML = '<option value="">All</option>' + types.map(x => `<option value="${escHtml(x)}">${escHtml(x.toUpperCase())}</option>`).join('');
  langSel.value = langs.includes(keep.lang) ? keep.lang : '';
  sexSel.value = sexes.includes(keep.sex) ? keep.sex : '';
  typeSel.value = types.includes(keep.type) ? keep.type : '';
}

function readLibraryFilters() {
  _libraryFilters.text = ($('library-filter-text')?.value || '').trim().toLowerCase();
  _libraryFilters.lang = $('library-filter-lang')?.value || '';
  _libraryFilters.sex = $('library-filter-sex')?.value || '';
  _libraryFilters.type = $('library-filter-type')?.value || '';
  _libraryFilters.rating = $('library-filter-rating')?.value || '';
}

function libraryFilterMatch(v) {
  const f = _libraryFilters;
  if (f.lang && libraryVoiceLang(v) !== f.lang) return false;
  if (f.sex && (v.gender || '') !== f.sex) return false;
  if (f.type && voiceFileType(v) !== f.type) return false;
  if (f.rating) {
    const r = Number(v.rating || 0);
    const wanted = Number(f.rating);
    if (wanted === 0 && r !== 0) return false;
    if (wanted === 1 && r < 1) return false;
    if (wanted > 1 && r < wanted) return false;
  }
  if (f.text) {
    const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin].map(x => String(x || '').toLowerCase()).join(' ');
    if (!hay.includes(f.text)) return false;
  }
  return true;
}

function clearLibraryFilters() {
  ['library-filter-text','library-filter-lang','library-filter-sex','library-filter-type','library-filter-rating'].forEach(id => { const el = $(id); if (el) el.value = ''; });
  readLibraryFilters();
  renderVoiceList();
}

function libraryTtsBackend() {
  return $('library-tts-backend-select')?.value || 'voice_clone';
}

function needsDuration(v) {
  return v.duration == null || Number.isNaN(Number(v.duration));
}

function voiceFileType(v) {
  if (v.file_type) return String(v.file_type).replace(/^\./, '').toLowerCase();
  const source = String(v.path || v.filename || '');
  const match = source.match(/\.([A-Za-z0-9]+)(?:$|[?#])/);
  return match ? match[1].toLowerCase() : 'wav';
}

function voiceDbfs(v) {
  const value = v.loudness && (v.loudness.dbfs ?? v.loudness.after_dbfs);
  return value == null || Number.isNaN(Number(value)) ? null : Number(value);
}

function fmtDbfs(v) {
  const db = voiceDbfs(v);
  return db == null ? '-' : db.toFixed(1);
}

function voiceBenchmark(v) {
  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]) => `<div class="insight"><strong>${value}</strong><span>${label}</span></div>`).join('');
    return;
  }
  if (state === 'error') {
    el.innerHTML = '<div class="insight"><strong>Failed</strong><span>Library load</span></div>';
    return;
  }
  const total = _voices.length;
  const active = _voices.filter(v => v.enabled !== false).length;
  const hidden = total - active;
  const bench = _voices.map(voiceBenchmark).filter(Boolean);
  const slow = bench.filter(b => b && b.ok && b.realtime_ok === false).length;
  const dbValues = _voices.map(voiceDbfs).filter(v => v != null);
  const avgDb = dbValues.length ? (dbValues.reduce((a,b) => a + b, 0) / dbValues.length).toFixed(1) : '-';
  const missingRef = _voices.filter(v => !v.transcript).length;
  const restart = _voices.filter(v => v.needs_tts_restart).length;
  const visible = _voices.filter(v => $('show-disabled-cb').checked || v.enabled !== false).length;
  const tiles = [
    {value:`${visible}/${total}`, label:'Visible'},
    {value:`${active} on`, label:hidden ? `${hidden} hidden` : 'Active'},
    {value:dominantLanguages(), label:'Languages'},
    {value:bench.length ? `${bench.length} done` : '-', label:slow ? `${slow} slow` : 'Benchmarks', filter: slow ? 'slow' : '', title: slow ? describeIssueVoices('slow') : 'No slow voices'},
    {value:avgDb === '-' ? '-' : `${avgDb} dB`, label:missingRef ? `${missingRef} no text` : 'Avg loudness', filter: missingRef ? 'no_text' : '', title: missingRef ? describeIssueVoices('no_text') : 'All visible voices have reference text'},
    {value:restart || '-', label:restart ? 'Need restart' : 'Restart flags', filter: restart ? 'restart' : '', title: restart ? describeIssueVoices('restart') : 'No voices need restart'},
  ];
  el.innerHTML = tiles.map(item => {
    const filter = item.filter ? ` data-filter="${escHtml(item.filter)}" role="button" tabindex="0"` : '';
    const activeCls = item.filter && item.filter === _libraryIssueFilter ? ' active' : '';
    const title = item.title ? ` title="${escHtml(item.title)}"` : '';
    return `<div class="insight${activeCls}"${filter}${title}><strong>${escHtml(item.value)}</strong><span>${escHtml(item.label)}</span></div>`;
  }).join('');
  el.querySelectorAll('[data-filter]').forEach(tile => {
    const activate = () => setLibraryIssueFilter(tile.dataset.filter || '');
    tile.addEventListener('click', activate);
    tile.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } });
  });
}

async function loadVoiceLibrary() {
  if (_libraryLoadPromise) return _libraryLoadPromise;
  _libraryLoadPromise = (async () => {
    setBusyButton('refresh-voices-btn', true);
    const list = $('voice-list');
    if (list) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
    $('voice-count').textContent = 'Loading voices…';
    updateLibraryInsights('loading');
    status('Loading voice library…');
    try {
      const r = await fetch('/api/voices');
      if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
      _voices = await r.json();
      window._voices = _voices; // expose for cross-module access (Script Rehearser etc.)
      if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
      renderVoiceList();
      updatePreviewVoiceMatchPanel();
      if (typeof renderPerfHistory === 'function') renderPerfHistory();
      status(`Loaded ${_voices.length} voices`);
    } catch(e) {
      if (list) list.innerHTML = '<div style="color:var(--red);padding:8px">Failed to load voices</div>';
      $('voice-count').textContent = 'Load failed';
      updateLibraryInsights('error');
      status('Voice library load failed');
      throw e;
    } finally {
      setBusyButton('refresh-voices-btn', false);
      _libraryLoadPromise = null;
    }
  })();
  return _libraryLoadPromise;
}

$('refresh-voices-btn').addEventListener('click', loadVoiceLibrary);
$('sync-voice-folders-btn').addEventListener('click', async () => {
  $('sync-voice-folders-btn').disabled = true;
  status('Syncing active_voices and hidden_voices…');
  try {
    const r = await fetch('/api/voices/sync-folders', { method:'POST' });
    if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); }
    const d = await r.json();
    await loadVoiceLibrary();
    const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : '';
    toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success');
    status(`Synced folders. Restart Qwen3-TTS after changing active voices.`);
  } catch(e) {
    toast('Sync failed: ' + e.message, 'error');
    status('Folder sync failed');
  } finally { $('sync-voice-folders-btn').disabled = false; }
});

function visibleLibraryVoices() {
  const showDisabled = $('show-disabled-cb').checked;
  return _voices.filter(v => showDisabled || v.enabled !== false);
}
function libraryIssueMatch(v, filter = _libraryIssueFilter) {
  const b = voiceBenchmark(v);
  if (filter === 'slow') return Boolean(b && b.ok && b.realtime_ok === false);
  if (filter === 'no_text') return !String(v.transcript || '').trim();
  if (filter === 'restart') return Boolean(v.needs_tts_restart);
  return true;
}
function libraryIssueLabel(filter = _libraryIssueFilter) {
  return {slow:'slow benchmark voices', no_text:'voices without reference text', restart:'voices needing TTS restart'}[filter] || 'all voices';
}
function libraryIssueVoices(filter = _libraryIssueFilter) {
  return visibleLibraryVoices().filter(v => libraryIssueMatch(v, filter));
}
function describeIssueVoices(filter = _libraryIssueFilter, limit = 12) {
  const voices = libraryIssueVoices(filter).map(v => v.id);
  if (!voices.length) return 'No matching voices';
  const extra = voices.length > limit ? `, +${voices.length - limit} more` : '';
  return voices.slice(0, limit).join(', ') + extra;
}
function setLibraryIssueFilter(filter = '') {
  _libraryIssueFilter = _libraryIssueFilter === filter ? '' : filter;
  renderVoiceList();
  if (_libraryIssueFilter) status(`${libraryIssueLabel()}: ${describeIssueVoices()}`);
  else status('Showing all visible voices');
}

function libraryTargetDb() {
  const input = $('library-target-db');
  const raw = Number(input?.value ?? -20);
  const value = Number.isFinite(raw) ? Math.min(-1, Math.max(-60, raw)) : -20;
  if (input) input.value = String(value);
  return value;
}

$('calculate-db-btn').addEventListener('click', async () => {
  $('calculate-db-btn').disabled = true;
  status('Calculating voice loudness…');
  try {
    const d = await clientCalculateVoiceDb();
    renderVoiceList();
    const extra = d.errors && d.errors.length ? `, ${d.errors.length} errors` : '';
    toast(`Calculated dB for ${d.calculated} voices${extra}`, d.errors && d.errors.length ? 'error' : 'success');
    status(`Calculated voice loudness. Use Normalize volume for visible WAV voices.`);
  } catch(e) {
    toast('Calculate dB failed: ' + e.message, 'error');
    status('dB calculation failed');
  } finally { $('calculate-db-btn').disabled = false; }
});

$('normalize-volume-btn').addEventListener('click', async () => {
  const target = libraryTargetDb();
  const visible = visibleLibraryVoices();
  const voices = visible.filter(v => voiceFileType(v) === 'wav');
  const skipped = visible.length - voices.length;
  if (!voices.length) {
    toast('No visible WAV voices to normalize', 'error');
    return;
  }
  if (!confirm(`Normalize ${voices.length} visible WAV voices to ${target} dBFS?${skipped ? ` ${skipped} non-WAV voices will be skipped.` : ''}`)) return;

  $('normalize-volume-btn').disabled = true;
  $('calculate-db-btn').disabled = true;
  const stats = {startedAt: Date.now(), ok: 0, slow: skipped, errors: 0, middleLabel: 'Skipped'};
  const errors = [];
  let normalized = 0;
  setBenchmarkProgress(0, voices.length, `Normalizing to ${target} dBFS...`, stats);
  status(`Normalizing ${voices.length} voices to ${target} dBFS...`);
  try {
    for (const v of voices) {
      setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
      try {
        const r = await fetch('/api/voice/normalize', {method:'POST', headers:{'Content-Type':'application/json'},
          body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:target})});
        if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
        const d = await r.json();
        v.loudness = d.loudness || v.loudness;
        v.duration = d.duration ?? v.duration;
        v.file_type = d.file_type || v.file_type;
        v.path = d.path || v.path;
        v.needs_tts_restart = true;
        markVoiceAudioChanged(v);
        normalized++;
        stats.ok++;
        stats.last = `${v.id}: ${fmtDbfs(v)} dBFS`;
      } catch(e) {
        errors.push({voice_id:v.id, detail:e.message});
        stats.errors++;
        stats.last = `${v.id}: ${e.message}`;
      }
      setBenchmarkProgress(normalized + errors.length, voices.length, `Normalizing: ${v.id}`, stats);
      status(`Normalized ${normalized} / ${voices.length}`);
      await new Promise(resolve => setTimeout(resolve, 0));
    }
    setBenchmarkProgress(voices.length, voices.length, 'Volume normalization complete', stats);
    renderVoiceList();
    updateLibraryInsights();
    const extra = `${skipped ? `, ${skipped} skipped` : ''}${errors.length ? `, ${errors.length} errors` : ''}`;
    toast(`Normalized ${normalized} voices${extra}`, errors.length ? 'error' : 'success');
    status('Volume normalized. Restart TTS before rebenchmarking these voices.');
  } catch(e) {
    toast('Normalize volume failed: ' + e.message, 'error');
    status('Normalize volume failed');
  } finally {
    $('normalize-volume-btn').disabled = false;
    $('calculate-db-btn').disabled = false;
  }
});

function fmtClock(ms) {
  if (!Number.isFinite(ms) || ms < 0) return '-';
  const total = Math.round(ms / 1000);
  const m = Math.floor(total / 60), s = total % 60;
  return `${m}:${String(s).padStart(2,'0')}`;
}

function setBenchmarkProgress(done, total, label = '', stats = {}) {
  const panel = $('benchmark-progress');
  const track = panel.querySelector('.benchmark-progress-track');
  const pct = total ? Math.round(done / total * 100) : 0;
  panel.hidden = false;
  $('benchmark-progress-label').textContent = label || (done >= total ? 'Benchmark complete' : 'Benchmarking voices...');
  $('benchmark-progress-count').textContent = `${done} / ${total}`;
  $('benchmark-progress-bar').style.width = pct + '%';
  track.setAttribute('aria-valuenow', String(pct));
  const live = $('benchmark-live-stats');
  if (live) {
    const elapsed = stats.startedAt ? Date.now() - stats.startedAt : 0;
    const avg = done > 0 ? elapsed / done : 0;
    const eta = done > 0 && total > done ? avg * (total - done) : 0;
    live.innerHTML = [
      `Elapsed ${fmtClock(elapsed)}`,
      `Avg ${done ? (avg / 1000).toFixed(1) + 's' : '-'}`,
      `ETA ${done && total > done ? fmtClock(eta) : '-'}`,
      `OK ${stats.ok || 0}`,
      `${stats.middleLabel || 'Slow'} ${stats.slow || 0}`,
      `${stats.errorLabel || 'Errors'} ${stats.errors || 0}`,
    ].map(x => `<span>${escHtml(x)}</span>`).join('');
  }
  const last = $('benchmark-live-last');
  if (last && stats.last) last.textContent = stats.last;
}

function hideBenchmarkProgress() {
  $('benchmark-progress').hidden = true;
  $('benchmark-progress-bar').style.width = '0%';
  if ($('benchmark-live-last')) $('benchmark-live-last').textContent = '';
}

async function clearTtsRestartFlags() {
  const r = await fetch('/api/tts/restart-flags/clear', { method:'POST' });
  if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
  const d = await r.json();
  _voices.forEach(voice => { voice.needs_tts_restart = false; });
  document.querySelectorAll('.vl-row.edit-open').forEach(row => row.classList.remove('opt-restart-needed'));
  updateLibraryInsights();
  return d;
}

async function runVoiceBenchmark(voiceId = '', opts = {}) {
  const text = opts.text ?? benchmarkSampleText();
  if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
  const payload = {active_only:true, text};
  if (voiceId) payload.voice_id = voiceId;
  const r = await fetch('/api/voices/benchmark', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body:JSON.stringify(payload),
  });
  if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
  return r.json();
}

async function runVoiceBenchmarkBatch() {
  const voices = benchmarkTargetVoices();
  const text = benchmarkSampleText();
  if (!text) { toast('Enter a benchmark sample sentence', 'error'); return null; }
  if (!voices.length) { toast('No voices to benchmark', 'error'); return null; }
  const total = voices.length;
  const aggregate = {benchmarked:0, errors:[], voices:[], text, active_only:true};
  const stats = {startedAt: Date.now(), ok: 0, slow: 0, errors: 0, last: ''};
  
  // Mark targeted rows as pending
  voices.forEach(v => {
    const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
    if (row) {
      row.classList.remove('benchmarking-active', 'benchmarking-done');
      row.classList.add('benchmarking-pending');
    }
  });

  setBenchmarkProgress(0, total, 'Starting benchmark...', stats);
  for (let i = 0; i < voices.length; i++) {
    const voice = voices[i];
    const row = document.querySelector(`.vl-row[data-id="${CSS.escape(voice.id)}"]`);
    if (row) {
      row.classList.remove('benchmarking-pending');
      row.classList.add('benchmarking-active');
      row.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }

    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}`;
    }
    
    if (row) {
      row.classList.remove('benchmarking-active');
      row.classList.add('benchmarking-done');
    }
    setBenchmarkProgress(i + 1, total, `Finished ${voice.id}`, stats);
  }
  
  // Cleanup any remaining pending rows just in case
  voices.forEach(v => {
    const row = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
    if (row) row.classList.remove('benchmarking-pending');
  });

  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 = `<div class="audiobook-box"><div class="audiobook-title"><span class="mdi mdi-flash-outline"></span> Precomputing embeddings</div>
    <div class="audiobook-msg" id="pc-msg">0 / ${ids.length}</div>
    <div class="reader-synth-track"><div class="reader-synth-fill" id="pc-fill"></div></div>
    <div class="audiobook-actions"><button class="btn-secondary btn-sm" id="pc-cancel">Cancel</button></div></div>`;
  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);
    const editBtn = document.getElementById('voice-table-edit-btn');
    if (editBtn) {
      editBtn.style.display = window._voiceTableView ? 'inline-flex' : 'none';
      editBtn.classList.toggle('active', !!window._voiceTableEditMode);
    }
  }

  // Apply sidebar category filter
  const cat = window._voiceSidebarCat || 'all';
  const enabledOk = v => showDisabled || v.enabled !== false;
  let filtered = _voices.filter(v => {
    // 'designed' = prompt-built voices (explicit origin marker, OR no reference WAV).
    // 'cloned'   = real WAV-identity voices that were NOT prompt-designed.
    if (cat === 'cloned')    return enabledOk(v) && v.has_ref && v.origin !== 'designed';
    if (cat === 'designed')  return enabledOk(v) && (v.origin === 'designed' || !v.has_ref);
    if (cat === 'favorites') return enabledOk(v) && (v.rating || 0) >= 4;
    if (cat === 'hidden')    return v.enabled === false;  // always show hidden voices in hidden tab
    // 'tools' shows all voices (same as 'all') so bulk-edit works
    return enabledOk(v);
  });
  // Apply active group filter (from the groups bar)
  if (window._voiceGroupFilter) {
    filtered = filtered.filter(v => (v.group || '').trim() === window._voiceGroupFilter);
  }
  // Apply active tag "subfolder" filter (from the My Voices nav tree)
  if (window._voiceTagFilter) {
    filtered = filtered.filter(v => String(v.tag || '').split(',').map(t => t.trim()).includes(window._voiceTagFilter));
  }
  const visibleCount = filtered.length;
  filtered = filtered.filter(libraryFilterMatch);
  const filterCount = filtered.length;
  if (_libraryIssueFilter) filtered = filtered.filter(v => libraryIssueMatch(v));
  $('voice-count').textContent = filtered.length + ' / ' + _voices.length + ' voices';

  filtered = filtered.slice().sort((a, b) => {
    const av = getSortValue(a, _sortField), bv = getSortValue(b, _sortField);
    if (av < bv) return -_sortDir;
    if (av > bv) return  _sortDir;
    return 0;
  });

  updateLibraryInsights();
  if (_libraryIssueFilter) {
    const note = document.createElement('div');
    note.className = 'library-filter-note';
    note.innerHTML = `<span><strong>${escHtml(filtered.length)} / ${escHtml(filterCount)}</strong> ${escHtml(libraryIssueLabel())}: ${escHtml(describeIssueVoices())}</span><button class="btn-secondary" type="button">Clear filter</button>`;
    note.querySelector('button').addEventListener('click', () => setLibraryIssueFilter(''));
    list.appendChild(note);
  }
  if (!filtered.length) {
    if (_voices.length === 0) {
      // True empty — render a two-case helper panel
      const emptyEl = document.createElement('div');
      emptyEl.className = 'voices-empty-state';
      emptyEl.innerHTML = `
        <div class="voices-empty-cases">
          <div class="voices-empty-case">
            <div class="voices-empty-case-icon"><span class="mdi mdi-folder-outline"></span></div>
            <h4>Wrong folder path?</h4>
            <p>Set the path where your <code>.wav</code> voice files live <em>inside the container</em>.</p>
            <div class="voices-folder-row">
              <input id="voices-empty-scan-dir" type="text" class="voices-folder-input" placeholder="/voices">
              <button class="btn-secondary" id="voices-empty-browse-btn" title="Browse container filesystem"><span class="mdi mdi-folder-outline"></span> Browse</button>
              <button class="btn-primary" id="voices-empty-save-btn">Set &amp; reload</button>
            </div>
            <div class="vef-dir-browser" id="vef-dir-browser" hidden>
              <div class="vef-breadcrumb" id="vef-breadcrumb"></div>
              <div class="vef-dir-list" id="vef-dir-list"><span class="vef-loading">Loading…</span></div>
              <div class="vef-browser-actions">
                <span class="vef-selected-path note" id="vef-selected-path"></span>
                <button class="btn-primary" id="vef-select-btn">Use this folder</button>
              </div>
            </div>
            <p class="voices-empty-hint">Map a host folder via docker-compose:<br>
              <code>- /your/host/path:/voices:rw</code><br>
              or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
          </div>
          <div class="voices-empty-divider">or</div>
          <div class="voices-empty-case">
            <div class="voices-empty-case-icon"><span class="mdi mdi-microphone-variant"></span></div>
            <h4>Folder is empty?</h4>
            <p>Create your first voice from a recording or download ready-made voices.</p>
            <div class="voices-empty-actions">
              <button class="btn-primary voices-empty-action-btn" id="voices-goto-clone"><span class="mdi mdi-lightning-bolt"></span> Clone a Voice</button>
              <button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio"><span class="mdi mdi-earth"></span> Get Voices Online</button>
            </div>
          </div>
        </div>
      `;
      list.appendChild(emptyEl);

      fetch('/api/settings').then(r => r.json()).then(s => {
        const inp = document.getElementById('voices-empty-scan-dir');
        if (inp) inp.value = s.voices_scan_dir || '/voices';
      }).catch(() => {});

      // ── Directory browser ──────────────────────────────────────────────────
      let _vefCurrentPath = '/';
      const vefBrowser  = document.getElementById('vef-dir-browser');
      const vefDirList  = document.getElementById('vef-dir-list');
      const vefCrumb    = document.getElementById('vef-breadcrumb');
      const vefSelPath  = document.getElementById('vef-selected-path');

      async function vefNavigate(path) {
        _vefCurrentPath = path;
        vefDirList.innerHTML = '<span class="vef-loading">Loading…</span>';
        if (vefSelPath) vefSelPath.textContent = path;
        try {
          const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
          // Breadcrumb
          const parts = data.path.split('/').filter(Boolean);
          const crumbs = [{ label: '/', path: '/' }];
          parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
          vefCrumb.innerHTML = crumbs.map((c, i) =>
            i < crumbs.length - 1
              ? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}">${escHtml(c.label)}</button><span class="vef-sep">/</span>`
              : `<span class="vef-crumb-cur">${escHtml(c.label)}</span>`
          ).join('');
          vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
          // Directory list
          if (!data.dirs.length) {
            vefDirList.innerHTML = '<span class="vef-empty">No subdirectories here.</span>';
          } else {
            vefDirList.innerHTML = data.dirs.map(d =>
              `<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}"><span class="mdi mdi-folder-outline"></span> ${escHtml(d)}</button>`
            ).join('');
            vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path)));
          }
          if (vefSelPath) vefSelPath.textContent = data.path;
          _vefCurrentPath = data.path;
        } catch(e) {
          vefDirList.innerHTML = `<span class="vef-error">Error: ${escHtml(e.message)}</span>`;
        }
      }

      document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => {
        const open = vefBrowser.hidden;
        vefBrowser.hidden = !open;
        if (open) {
          const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices';
          vefNavigate(cur);
        }
      });

      document.getElementById('vef-select-btn')?.addEventListener('click', () => {
        const inp = document.getElementById('voices-empty-scan-dir');
        if (inp) inp.value = _vefCurrentPath;
        if (vefBrowser) vefBrowser.hidden = true;
      });
      // ──────────────────────────────────────────────────────────────────────

      document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => {
        const inp = document.getElementById('voices-empty-scan-dir');
        const dir = inp?.value?.trim();
        if (!dir) return;
        const btn = document.getElementById('voices-empty-save-btn');
        btn.disabled = true; btn.textContent = 'Saving…';
        try {
          const s = await fetch('/api/settings').then(r => r.json());
          s.voices_scan_dir = dir;
          const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) });
          if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
          toast('Folder saved — reloading voices…', 'success');
          _libraryLoadPromise = null;
          await loadVoiceLibrary();
        } catch(e) {
          toast('Failed: ' + e.message, 'error');
          btn.disabled = false; btn.textContent = 'Set & reload';
        }
      });
      document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone'));
      document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio'));
    } else {
      list.innerHTML += '<div style="padding:14px;color:var(--subtext);font-size:14px">No voices match the current filters.</div>';
    }
    return;
  }
  if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
  else filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
  if (_pendingSelectId) {
    const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);
    if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
  }
  syncSortHeaders();
}

// Group the list into collapsible virtual "folders" by tag (a voice with several
// comma-separated tags appears under each; untagged voices fall into one bucket).
function renderVoicesByTag(list, filtered) {
  const UNTAG = '— Untagged —';
  const groups = new Map();
  filtered.forEach(v => {
    const tags = String(v.tag || '').split(',').map(t => t.trim()).filter(Boolean);
    (tags.length ? tags : [UNTAG]).forEach(t => {
      if (!groups.has(t)) groups.set(t, []);
      groups.get(t).push(v);
    });
  });
  let collapsed = {};
  try { collapsed = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {}
  const names = [...groups.keys()].sort((a, b) =>
    a === UNTAG ? 1 : b === UNTAG ? -1 : a.toLowerCase().localeCompare(b.toLowerCase()));

  names.forEach(tag => {
    const voices = groups.get(tag);
    const isCol = !!collapsed[tag];
    const sec = document.createElement('div');
    sec.className = 'vl-tag-group';
    const head = document.createElement('button');
    head.className = 'vl-tag-head' + (isCol ? ' collapsed' : '');
    head.type = 'button';
    head.innerHTML = `<span class="mdi mdi-${isCol ? 'chevron-right' : 'chevron-down'} vl-tag-caret"></span>`
      + `<span class="mdi mdi-folder${isCol ? '' : '-open'}-outline"></span>`
      + `<span class="vl-tag-name">${escHtml(tag)}</span>`
      + `<span class="vl-tag-count">${voices.length}</span>`;
    const body = document.createElement('div');
    body.className = 'vl-tag-body';
    if (isCol) body.style.display = 'none';
    voices.forEach(v => body.appendChild(makeVoiceRow(v)));
    head.addEventListener('click', () => {
      const nowCol = body.style.display !== 'none';
      body.style.display = nowCol ? 'none' : '';
      head.classList.toggle('collapsed', nowCol);
      head.querySelector('.vl-tag-caret').className = 'mdi mdi-' + (nowCol ? 'chevron-right' : 'chevron-down') + ' vl-tag-caret';
      head.querySelector('.mdi:nth-child(2)').className = 'mdi mdi-folder' + (nowCol ? '' : '-open') + '-outline';
      let c = {}; try { c = JSON.parse(localStorage.getItem('vl-tag-collapsed') || '{}'); } catch (_) {}
      c[tag] = nowCol; localStorage.setItem('vl-tag-collapsed', JSON.stringify(c));
    });
    sec.appendChild(head);
    sec.appendChild(body);
    list.appendChild(sec);
  });
}

async function decodeVoiceAudio(v) {
  const resp = await fetch(voiceFileUrl(v), {cache:'no-store'});
  if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
  const data = await resp.arrayBuffer();
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
  return ctx.decodeAudioData(data.slice(0));
}

function drawOptimizerWave(canvas, buffer, start = 0, end = buffer.duration) {
  const dpr = window.devicePixelRatio || 1;
  const width = Math.max(1, canvas.clientWidth);
  const height = Math.max(1, canvas.clientHeight);
  canvas.width = Math.round(width * dpr);
  canvas.height = Math.round(height * dpr);
  const ctx = canvas.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, width, height);
  ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg') || '#111';
  ctx.fillRect(0, 0, width, height);

  const data = buffer.getChannelData(0);
  const step = Math.max(1, Math.floor(data.length / width));
  const mid = height / 2;
  ctx.strokeStyle = '#89b4fa';
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let x = 0; x < width; x++) {
    let min = 1, max = -1;
    const base = x * step;
    for (let i = 0; i < step && base + i < data.length; i++) {
      const v = data[base + i];
      if (v < min) min = v;
      if (v > max) max = v;
    }
    ctx.moveTo(x, mid + min * mid * .92);
    ctx.lineTo(x, mid + max * mid * .92);
  }
  ctx.stroke();

  const sx = Math.max(0, Math.min(width, start / buffer.duration * width));
  const ex = Math.max(sx, Math.min(width, end / buffer.duration * width));
  ctx.fillStyle = 'rgba(166,227,161,.18)';
  ctx.fillRect(sx, 0, ex - sx, height);
  ctx.strokeStyle = '#a6e3a1';
  ctx.lineWidth = 2;
  ctx.strokeRect(sx + .5, .5, Math.max(1, ex - sx - 1), height - 1);

  const selectedSec = Math.max(0, end - start);
  if (ex - sx > 54 && selectedSec > 0) {
    const label = `${selectedSec.toFixed(1)}s`;
    ctx.font = '12px ui-monospace, Menlo, Consolas, monospace';
    const textW = ctx.measureText(label).width + 14;
    const tx = Math.max(sx + 6, Math.min(ex - textW - 6, sx + (ex - sx - textW) / 2));
    ctx.fillStyle = 'rgba(30,30,46,.78)';
    ctx.fillRect(tx, 6, textW, 22);
    ctx.fillStyle = '#ffffff';
    ctx.fillText(label, tx + 7, 21);
  }

  const handleW = 12;
  ctx.fillStyle = '#3d5ce8';
  ctx.strokeStyle = '#ffffff';
  [sx, ex].forEach((x, idx) => {
    const hx = Math.max(0, Math.min(width - handleW, x - handleW / 2));
    ctx.fillRect(hx, 0, handleW, height);
    ctx.strokeRect(hx + .5, .5, Math.max(1, handleW - 1), height - 1);
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(hx + 3, Math.max(12, height / 2 - 11), 2, 22);
    ctx.fillRect(hx + 7, Math.max(12, height / 2 - 11), 2, 22);
    ctx.fillStyle = '#3d5ce8';
  });
}

function makeVoiceRow(v) {
  const wrap = document.createElement('div');
  wrap.className = 'vl-row' + (v.enabled===false ? ' vr-disabled' : '');
  wrap.dataset.id = v.id;

  // Data attrs used by inspector header
  const color = avatarColor(v.id);
  wrap.dataset.color  = color;
  wrap.dataset.hasRef = v.has_ref ? 'true' : 'false';
  wrap.dataset.dbfs   = fmtDbfs(v);
  wrap.dataset.lang   = v.lang || v.id.split('_')[0].toUpperCase();

  const langCode    = v.lang || v.id.split('_')[0].toUpperCase();
  // Display name: explicit meta.name (e.g. Rehearser character) wins, else last ID segment.
  const _idParts    = String(v.id).split('_');
  const dispName    = (v.name && String(v.name).trim())
    ? String(v.name).trim()
    : (_idParts.length > 1 ? _idParts[_idParts.length - 1] : v.id);
  const langOpts    = FLAG_OPTIONS[langCode] || [];
  // Use language-specific variants if there are multiple; fall back to world picker otherwise
  const flagOpts    = langOpts.length > 1 ? langOpts : ALL_FLAGS;
  const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || '';
  const flagEmoji   = currentFlag ? cc2flag(currentFlag) : '<span class="mdi mdi-earth"></span>';
  const flagCode    = currentFlag ? ccDisplay(currentFlag) : '?';
  wrap.dataset.flagEmoji  = flagEmoji;
  wrap.dataset.flagCc     = currentFlag || '';
  wrap.dataset.rating     = v.rating || 0;
  wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false';
  const fileType    = voiceFileType(v);
  const dbfs        = fmtDbfs(v);
  const dbTitle     = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
  const benchText   = fmtBenchmark(v);
  const benchTitle  = benchmarkTitle(v);
  const benchCls    = benchmarkClass(v);

  const genderMap   = {F:'♀', M:'♂', N:'⚥', '':'?'};
  const genderMapLetter = {F:'F', M:'M', N:'D', '':'?'};
  const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
  const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
  const gender      = v.gender || '';

  const starsHtml = [1,2,3,4,5].map(i =>
    `<span class="star${i<=(v.rating||0)?' on':''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
  ).join('');

  const picSrc = v.has_picture ? `/api/voice/picture/${encodeURIComponent(v.id)}` : null;

  const pickerHtml = flagOpts.length > 1
    ? flagOpts.map(([cc, label]) =>
        `<span class="flag-opt${cc===currentFlag?' active':''}" data-cc="${cc}" title="${label}">${cc2flag(cc)}<span class="fo-code">${ccDisplay(cc)}</span></span>`
      ).join('')
    : `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`;

  // Compact list item elements (visible in list pane, hidden in inspector)
  const isClone     = v.has_ref && v.origin !== 'designed';
  const initial     = v.id[0] ? v.id[0].toUpperCase() : '?';
  const flagIconHtml = currentFlag
    ? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
    : null;

  // Avatar: photo > gender/type icon > silhouette (with color)
  const defaultSilClass = gender === 'M' ? 'mdi-face-man' : (gender === 'F' ? 'mdi-face-woman' : 'mdi-account');
  const silHtml = `<span class="mdi ${defaultSilClass}" style="color:#fff;font-size:20px;display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:${color}"></span>`;

  const iconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 34) : null;
  const avatarClass   = picSrc ? 'vl-avatar vl-avatar-photo'
    : iconHtml ? 'vl-avatar vl-avatar-icon' : 'vl-avatar vl-avatar-sil';
  const avatarStyle   = (picSrc || iconHtml) ? '' : `style="background:${color}"`;
  const avatarContent = picSrc
    ? `<img src="${picSrc}" alt="" class="vl-avatar-img" loading="lazy" decoding="async">`
    : iconHtml || silHtml;

  wrap.innerHTML = `
    <div class="vl-compact">
      <div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
      <div class="vl-info">
        ${window._voiceTableEditMode 
          ? `<input type="text" class="vl-inline-edit vl-name-input" data-field="name" value="${escHtml(dispName)}" title="Display Name" placeholder="Name">` 
          : `<span class="vl-name" title="${escHtml(v.id)}">${escHtml(dispName)}</span>`}
        <div class="vl-meta">
          <span class="vl-lang-label" title="${escHtml(currentFlag || langCode)}">${flagEmoji}</span>
          ${gender ? `<span class="vl-gender-letter ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMapLetter[gender]||'?'}</span>` : ''}
          ${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText.split(' ')[0])}</span>` : ''}
          <div class="vr-rating">${starsHtml}</div>
          <span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
        </div>
      </div>
      <div class="vr-play-group">
        <div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording" aria-label="Play original recording"><span class="mdi mdi-play"></span></button></div>
        <div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview" aria-label="Generate and play TTS preview"><span class="mdi mdi-play"></span></button></div>
      </div>
      <div class="vl-tbl-lang" title="${escHtml(currentFlag || langCode)}">
        ${window._voiceTableEditMode
          ? `<input type="text" class="vl-inline-edit" data-field="lang" value="${escHtml(langCode)}" style="width:100%">`
          : `${flagEmoji} ${escHtml(langCode)}`}
      </div>
      <div class="vl-tbl-gender" title="${escHtml(genderLabel[gender]||'')}">
        ${window._voiceTableEditMode
          ? `<select class="vl-inline-edit" data-field="gender" style="width:100%"><option value="N" ${gender==='N'?'selected':''}>N</option><option value="M" ${gender==='M'?'selected':''}>M</option><option value="F" ${gender==='F'?'selected':''}>F</option></select>`
          : `${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}`}
      </div>
      <div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
      <div class="vl-tbl-bench">${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : '-'}</div>
      <div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
      <div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
      <div class="vl-tbl-rating">
        ${window._voiceTableEditMode
          ? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">`
          : starsHtml}
      </div>
      <div class="vl-tbl-source">${escHtml(v.origin || '-')}</div>
      <div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
      <div class="vl-tbl-note" title="${escHtml(v.note||'')}">
        ${window._voiceTableEditMode
          ? `<input type="text" class="vl-inline-edit" data-field="note" value="${escHtml(v.note||'')}" style="width:100%">`
          : escHtml(v.note || '')}
      </div>
      <div class="vl-tbl-tag" title="${escHtml(v.tag||'')}">
        ${window._voiceTableEditMode
          ? `<input type="text" class="vl-inline-edit" data-field="tag" value="${escHtml(v.tag||'')}" style="width:100%">`
          : escHtml(v.tag || '')}
      </div>
      <div class="vl-tbl-active">
        ${window._voiceTableEditMode
          ? `<input type="checkbox" class="vl-inline-edit" data-field="enabled" ${v.enabled!==false?'checked':''} style="width:16px;height:16px">`
          : `<span class="mdi ${v.enabled !== false ? 'mdi-check-circle' : 'mdi-close-circle'}" style="color:${v.enabled !== false ? 'var(--green)' : 'var(--subtext)'}"></span>`}
      </div>
    </div>

    <div class="vr-main-row">
      <div class="vr-photo" title="Click to upload photo">
        ${picSrc ? `<img src="${picSrc}" alt="" loading="lazy" decoding="async">` : '<div class="ph-icon"><span class="mdi mdi-account-outline"></span></div>'}
        <input type="file" class="photo-input" accept="image/*">
      </div>

      <div class="vr-identity">
        <div class="vr-flag">
          <span class="flag-emoji">${flagEmoji}</span>
          <span class="flag-code">${flagCode}</span>
          <div class="flag-picker">${pickerHtml}</div>
        </div>

        <div class="vr-gender">
          <span class="gender-badge ${genderClass[gender]||'g-n'}" title="Click to cycle">
            <span class="gender-sym">${genderMap[gender]||'?'}</span>
            <span class="gender-txt">${genderLabel[gender]||'—'}</span>
          </span>
        </div>

        <div class="vr-name">
          <span class="vr-name-text" title="${escHtml(v.id)}">${escHtml(v.id)}</span>
          <span class="rename-confirm">
            <input class="vr-name-input" value="${escHtml(v.id)}" spellcheck="false">
            <button class="icon-btn rename-ok" title="Save"><span class="mdi mdi-check"></span></button>
            <button class="icon-btn rename-cancel" title="Cancel"><span class="mdi mdi-close"></span></button>
          </span>
        </div>
      </div>

      <div class="vr-type" title="${escHtml(fileType)}">${escHtml(fileType.toUpperCase())}</div>
      <div class="vr-length" title="${escHtml(String(v.duration ?? ''))}">${fmtDuration(v.duration)}</div>

      <div class="vr-db" title="${escHtml(dbTitle)}">
        <span class="vr-db-value">${escHtml(dbfs)}</span>
        <button class="normalize-voice-btn" title="Normalize this voice">±</button>
      </div>

      <div class="vr-bench ${benchCls}" title="${escHtml(benchTitle)}">
        <span class="vr-bench-value">${escHtml(benchText)}</span>
        <button class="btn-secondary benchmark-one-btn" title="Benchmark this voice">⏱</button>
      </div>

      <div class="vr-rating">${starsHtml}</div>

      <div class="vr-edit">
        <button class="edit-audio-btn" title="Edit audio"><span class="mdi mdi-pencil-outline"></span></button>
      </div>

      <div class="vr-play-group">
        <div class="vr-play vr-play-original">
          <button title="Play / pause original recording"><span class="mdi mdi-play"></span></button>
        </div>
        <div class="vr-play vr-play-synth">
          <button title="Generate and play synthesized sample"><span class="mdi mdi-play"></span></button>
        </div>
      </div>

    </div>

    <div class="vr-detail-row">
      <div class="vr-ref">
        <label>Reference</label>
        <div class="vr-inline">
          <input type="text" value="${escHtml(v.transcript||'')}" placeholder="No reference text" title="${escHtml(v.transcript||'')}">
          <button class="ref-transcribe-btn" title="Recognise reference text" style="${v.transcript ? 'display:none' : ''}"><span class="mdi mdi-message-text-outline"></span></button>
        </div>
      </div>

      <div class="vr-note">
        <label>Note</label>
        <div class="vr-inline">
          <input type="text" placeholder="Add note..." value="${escHtml(v.note||'')}">
        </div>
      </div>

      <div class="vr-detail-active">
        <label>Active</label>
        <div class="vr-active-tools">
          <div class="vr-toggle">
            <label class="toggle" title="${v.enabled===false ? 'Disabled - click to enable' : 'Enabled - click to disable'}">
              <input type="checkbox" ${v.enabled!==false ? 'checked' : ''}>
              <span class="t-slider"></span>
            </label>
          </div>
        </div>
      </div>

      <div class="vr-detail-delete">
        <button class="delete-btn" title="Delete voice permanently">Delete voice</button>
        <div class="delete-confirm" role="group" aria-label="Confirm delete voice">
          <strong>Delete?</strong>
          <span title="${escHtml(v.id)}">${escHtml(v.id)}</span>
          <button class="btn-secondary delete-confirm-cancel" type="button">Cancel</button>
          <button class="delete-confirm-go" type="button">Delete</button>
        </div>
      </div>
    </div>

    <div class="vr-optimizer">
      <div class="optimizer-grid">
        <div class="opt-group opt-trim-panel">
          <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference audio &middot; crop</span></div>
          <div class="opt-group-subtitle">Crop the saved WAV to 3&ndash;20&nbsp;s of clean speech.</div>
          <div class="opt-group-body">
            <canvas class="opt-wave"></canvas>
            <div class="opt-controls">
              <div class="opt-field"><label>Start</label><input class="opt-start" type="number" step="0.01" value="0"></div>
              <div class="opt-field"><label>End</label><input class="opt-end" type="number" step="0.01" value="0"></div>
              <button class="btn-secondary opt-auto-trim">Auto trim</button>
              <button class="btn-secondary opt-play">Play crop</button>
              <button class="btn-secondary opt-undo">Undo crop</button>
            </div>
            <div class="opt-group-footer">
              <button class="btn-primary opt-save-crop">Save crop</button>
            </div>
          </div>
        </div>
        <div class="opt-group opt-text-panel open">
          <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Reference transcript</span></div>
          <div class="opt-group-subtitle">The spoken text that matches this voice recording.</div>
          <div class="opt-group-body">
            <textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
            <div class="opt-controls">
              <button class="btn-secondary opt-recognize">Re-recognise text</button>
            </div>
            <div class="opt-group-footer">
              <button class="btn-primary opt-save-text">Save text</button>
            </div>
          </div>
        </div>
        <div class="opt-group opt-compare-panel">
          <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Voice match</span></div>
          <div class="opt-group-subtitle">Compare the saved WAV with a fresh TTS synthesis.</div>
          <div class="opt-group-body">
            <div class="opt-controls">
              <div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
              <button class="btn-secondary opt-play-reference">Play WAV file</button>
              <button class="btn-primary opt-synth-reference">Synthesize reference text</button>
            </div>
            <p class="opt-group-note">Compare the saved reference WAV with a fresh synthesis of the same reference text. Restart TTS first after editing a voice, otherwise the backend may still use a cached version.</p>
            <div class="opt-compare-grid">
              <div class="opt-compare-card">
                <strong>WAV file</strong>
                <audio class="opt-compare-ref-audio" controls></audio>
              </div>
              <div class="opt-compare-card">
                <strong>Synthesized reference text</strong>
                <audio class="opt-compare-synth-audio" controls></audio>
              </div>
            </div>
          </div>
        </div>
        ${!isClone ? `<div class="opt-group opt-style-panel">
          <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Style variation</span></div>
          <div class="opt-group-subtitle">Create a styled variant and save it as a new voice (CustomVoice only).</div>
          <div class="opt-group-body">
            <div class="opt-controls">
              <div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
              <div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
              <div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
            </div>
            <p class="opt-group-note">Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors <code>instruct</code>; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.</p>
            <div class="backend-help opt-style-backend-help"></div>
            <div class="opt-style-preview-box">
              <div class="opt-controls">
                <button class="btn-secondary opt-preview-style">Preview style</button>
              </div>
              <audio class="opt-style-audio" controls></audio>
            </div>
            <div class="opt-group-footer">
              <button class="btn-primary opt-save-style">Save style variation</button>
            </div>
          </div>
        </div>` : ''}
        <div class="opt-group opt-maintenance open">
          <div class="opt-group-title"><span class="opt-chevron"></span><span class="opt-title-text">Loudness</span></div>
          <div class="opt-group-subtitle">Normalize the volume of the reference audio file.</div>
          <div class="opt-group-body">
            <div class="opt-controls">
              <div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
              <button class="btn-secondary opt-db-minus">-</button>
              <button class="btn-secondary opt-db-plus">+</button>
              <button class="btn-secondary opt-db-auto">Auto</button>
              <button class="btn-secondary opt-restart-tts" title="Restarts the WAV-loading TTS backends (Voice Clone + Streaming) so they rescan the active_voices folder">Restart WAV engines</button>
              <button class="btn-secondary opt-rebenchmark">Rebenchmark this one</button>
              <span class="opt-restart-note" hidden>Restart TTS before benchmarking.</span>
            </div>
            <div class="opt-group-footer">
              <button class="btn-primary opt-save-volume">Save volume</button>
            </div>
          </div>
        </div>
        <div class="opt-status optimizer-workflow">Click the pencil to load waveform and tools.</div>
      </div>
    </div>
  `;
  const vrLengthEl = wrap.querySelector('.vr-length');
  hydrateVoiceDuration(v, vrLengthEl);

  // Photo upload
  const photoCell  = wrap.querySelector('.vr-photo');
  const photoInput = wrap.querySelector('.photo-input');
  
  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 = `<img src="${imgSrc}" alt="" class="vl-avatar-img">`;
    }

    // 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 = `<img src="${imgSrc}" alt="" class="insp-avatar-img">`;
    }

    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 = `<span class="gender-sym">${genderMap[v.gender]||'?'}</span><span class="gender-txt">${genderLabel[v.gender]||'—'}</span>`;
    gBadge.className = 'gender-badge ' + genderClass[v.gender];
    await saveMeta(v.id, { gender: v.gender });
  });

  // Rename
  const nameText    = wrap.querySelector('.vr-name-text');
  const renameConf  = wrap.querySelector('.rename-confirm');
  const nameInput   = wrap.querySelector('.vr-name-input');
  const renameOk    = wrap.querySelector('.rename-ok');
  const renameCancel= wrap.querySelector('.rename-cancel');

  const startRename = () => {
    nameText.style.display='none';
    renameConf.classList.add('show'); nameInput.focus(); nameInput.select();
  };
  nameText.addEventListener('dblclick', startRename);
  const cancelRename = () => {
    nameText.style.display=''; renameConf.classList.remove('show');
    nameInput.value = v.id;
  };
  renameCancel.addEventListener('click', cancelRename);
  const doRename = async () => {
    const newId = nameInput.value.trim();
    if (!newId || newId===v.id) { cancelRename(); return; }
    if (!/^[A-Za-z0-9_\-\.]+$/.test(newId)) { toast('Invalid characters in name','error'); return; }
    try {
      const r = await fetch('/api/voice/rename', {method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({old_id:v.id, new_id:newId})});
      if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
      const d = await r.json();
      v.id = newId;
      if (d.path) v.path = d.path;
      if (d.file_type) v.file_type = d.file_type;
      nameText.textContent = newId; nameText.title = newId;
      nameText.style.display=''; renameConf.classList.remove('show');
      wrap.dataset.id = newId; nameInput.value = newId;
      toast('Renamed to '+newId,'success');
    } catch(e) { toast('Rename failed: '+e.message,'error'); }
  };
  renameOk.addEventListener('click', doRename);
  nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); });

  // Select row → open inspector (click on compact area, but not on play/buttons)
  wrap.querySelector('.vl-compact').addEventListener('click', function (e) {
    if (e.target.closest('button')) return;
    selectVoice(wrap);
  });

  // Inline optimizer in Library
  const editAudioBtn = wrap.querySelector('.edit-audio-btn');
  const optPanel = wrap.querySelector('.vr-optimizer');
  const vrTypeEl = wrap.querySelector('.vr-type');
  const optCanvas = wrap.querySelector('.opt-wave');
  const optStart = wrap.querySelector('.opt-start');
  const optEnd = wrap.querySelector('.opt-end');
  const optTranscript = wrap.querySelector('.opt-transcript');
  const optTargetDb = wrap.querySelector('.opt-target-db');
  const optStyleInstruct = wrap.querySelector('.opt-style-instruct');
  const optStyleBackend = wrap.querySelector('.opt-style-backend');
  const optStyleVoiceId = wrap.querySelector('.opt-style-voice-id');
  const optCompareBackend = wrap.querySelector('.opt-compare-backend');
  const optPlayReferenceBtn = wrap.querySelector('.opt-play-reference');
  const optSynthReferenceBtn = wrap.querySelector('.opt-synth-reference');
  const optCompareRefAudio = wrap.querySelector('.opt-compare-ref-audio');
  const optCompareSynthAudio = wrap.querySelector('.opt-compare-synth-audio');
  const optPreviewStyleBtn = wrap.querySelector('.opt-preview-style');
  const optSaveStyleBtn = wrap.querySelector('.opt-save-style');
  const optStyleAudio = wrap.querySelector('.opt-style-audio');
  const optStatus = wrap.querySelector('.opt-status');
  const optSaveTextBtn = wrap.querySelector('.opt-save-text');
  const optRestartTtsBtn = wrap.querySelector('.opt-restart-tts');
  const optRebenchmarkBtn = wrap.querySelector('.opt-rebenchmark');
  const optRestartNote = wrap.querySelector('.opt-restart-note');
  let optState = { loaded:false, id:null, duration:0, buffer:null, audio:null, compareSynthUrl:null };
  const setOptStatus = msg => { optStatus.textContent = msg; status(msg); };
  const setVoiceRestartState = (required, msg = '') => {
    v.needs_tts_restart = required;
    optPanel.classList.toggle('opt-restart-needed', required);
    optRestartNote.hidden = !required;
    optRestartNote.textContent = required ? 'Restart TTS before benchmarking; the backend may still have the old voice cached.' : '';
    optRebenchmarkBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice';
    benchmarkOneBtn.title = required ? 'Restart TTS first, otherwise the benchmark may use a cached voice' : 'Benchmark this voice';
    if (msg) setOptStatus(msg);
  };
  const markTtsRestartRequired = msg => setVoiceRestartState(true, msg);
  const refreshOptimizerFromVoice = async () => {
    markVoiceAudioChanged(v);
    optState.loaded = false;
    optState.buffer = null;
    optState.id = null;
    await loadOptimizer();
    if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
    if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
    dbValue.textContent = fmtDbfs(v);
    dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
  };

  const saveOptimizerText = async () => {
    const transcript = optTranscript.value.trim();
    setOptStatus('Saving reference text...');
    v.transcript = transcript;
    refInput.value = transcript;
    refInput.title = transcript;
    refTranscribeBtn.style.display = transcript ? 'none' : '';
    await saveMeta(v.id, { transcript });
    markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.');
    toast('Reference text saved: ' + v.id, 'success');
  };

  const redrawOpt = () => {
    if (!optState.buffer || optCanvas.clientWidth < 4) return;
    drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
  };
  // Redraw whenever the canvas is resized (handles display:none → visible transition)
  const _waveRO = new ResizeObserver(() => redrawOpt());
  _waveRO.observe(optCanvas);
  const syncCompareReferenceAudio = () => {
    if (!optState.id || !optCompareRefAudio) return;
    const src = '/api/audio/' + optState.id;
    if (!optCompareRefAudio.src.endsWith(src)) {
      optCompareRefAudio.src = src;
      optCompareRefAudio.load();
    }
    const stopAtEnd = () => {
      const end = parseFloat(optEnd.value) || optState.duration;
      if (optCompareRefAudio.currentTime >= end) optCompareRefAudio.pause();
    };
    optCompareRefAudio.ontimeupdate = stopAtEnd;
  };
  const optWaveTimeFromEvent = e => {
    const rect = optCanvas.getBoundingClientRect();
    const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
    return optState.duration ? x / Math.max(1, rect.width) * optState.duration : 0;
  };
  const setOptCropRange = (start, end) => {
    start = Math.max(0, Math.min(optState.duration || 0, Number(start) || 0));
    end = Math.max(0, Math.min(optState.duration || 0, Number(end) || 0));
    if (end < start) [start, end] = [end, start];
    optStart.value = start.toFixed(2);
    optEnd.value = end.toFixed(2);
    redrawOpt();
  };
  const optWaveSelectionPixels = e => {
    const rect = optCanvas.getBoundingClientRect();
    const duration = Math.max(0.01, optState.duration || 0.01);
    const sx = (parseFloat(optStart.value) || 0) / duration * rect.width;
    const ex = (parseFloat(optEnd.value) || optState.duration || 0) / duration * rect.width;
    const x = e.clientX - rect.left;
    return {x, sx, ex};
  };
  const optWaveDragMode = e => {
    const {x, sx, ex} = optWaveSelectionPixels(e);
    const hit = 18;
    const nearStart = Math.abs(x - sx) <= hit;
    const nearEnd = Math.abs(x - ex) <= hit;
    if (nearStart && nearEnd) return Math.abs(x - sx) <= Math.abs(x - ex) ? 'start' : 'end';
    if (nearStart) return 'start';
    if (nearEnd) return 'end';
    if (x > sx && x < ex) return 'move';
    return 'new';
  };
  const attachOptWaveSelection = () => {
    let drag = null;
    optCanvas.addEventListener('pointerdown', e => {
      if (!optState.buffer || !optState.duration) return;
      e.preventDefault();
      optCanvas.setPointerCapture?.(e.pointerId);
      const mode = optWaveDragMode(e);
      const currentStart = parseFloat(optStart.value) || 0;
      const currentEnd = parseFloat(optEnd.value) || optState.duration;
      drag = {mode, anchor: optWaveTimeFromEvent(e), start: currentStart, end: currentEnd, length: Math.max(0.05, currentEnd - currentStart)};
      optCanvas.style.cursor = mode === 'move' ? 'grabbing' : 'ew-resize';
      if (mode === 'new') setOptCropRange(drag.anchor, drag.anchor);
    });
    optCanvas.addEventListener('pointermove', e => {
      if (!optState.buffer || !optState.duration) return;
      if (!drag) {
        const mode = optWaveDragMode(e);
        optCanvas.style.cursor = mode === 'move' ? 'grab' : (mode === 'start' || mode === 'end') ? 'ew-resize' : 'crosshair';
        return;
      }
      e.preventDefault();
      const t = optWaveTimeFromEvent(e);
      if (drag.mode === 'start') setOptCropRange(Math.min(t, drag.end - 0.05), drag.end);
      else if (drag.mode === 'end') setOptCropRange(drag.start, Math.max(t, drag.start + 0.05));
      else if (drag.mode === 'move') {
        let start = t - (drag.anchor - drag.start);
        start = Math.max(0, Math.min((optState.duration || 0) - drag.length, start));
        setOptCropRange(start, start + drag.length);
      } else setOptCropRange(drag.anchor, t);
    });
    const finish = e => {
      if (!drag) return;
      optCanvas.releasePointerCapture?.(e.pointerId);
      drag = null;
      optCanvas.style.cursor = 'crosshair';
    };
    optCanvas.addEventListener('pointerup', finish);
    optCanvas.addEventListener('pointercancel', finish);
    optCanvas.addEventListener('pointerleave', () => { if (!drag) optCanvas.style.cursor = 'crosshair'; });
  };
  attachOptWaveSelection();
  const loadOptimizer = async () => {
    if (optState.loaded) return;
    setOptStatus('Loading voice optimizer…');
    const d = await loadLibraryVoiceAudio(v);
      optState.id = d.id;
      optState.duration = d.duration;
      v.duration = d.duration;
      optState.buffer = await decodeVoiceAudio(v);
    optState.loaded = true;
    optStart.value = '0.00';
    optEnd.value = d.duration.toFixed(2);
    optEnd.max = d.duration.toFixed(2);
    optTranscript.value = d.transcript || v.transcript || '';
    redrawOpt();
    syncCompareReferenceAudio();
    setVoiceRestartState(Boolean(v.needs_tts_restart));
    setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
  };
  wrap._loadOptimizer = loadOptimizer;
  wrap._redrawOpt = redrawOpt;

  editAudioBtn.addEventListener('click', async () => {
    editAudioBtn.disabled = true;
    try {
      const opening = !wrap.classList.contains('edit-open');
      document.querySelectorAll('.vl-row.edit-open').forEach(r => { if (r !== wrap) r.classList.remove('edit-open'); });
      wrap.classList.toggle('edit-open', opening);
      if (opening) {
        await loadOptimizer();
        wrap.scrollIntoView({behavior:'smooth', block:'nearest'});
      }
    } catch(e) {
      toast('Edit load failed: '+e.message,'error');
      status('Edit load failed');
    } finally {
      editAudioBtn.disabled = false;
    }
  });
  [optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
  optStyleInstruct?.addEventListener('input', () => {
    if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
  });
  optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
  optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
  if (optCompareBackend.value === '') {
    optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
    optCompareBackend.disabled = !availableTtsBackends().length;
  }
  if (optStyleBackend) updateStyleBackendHelp(wrap);
  wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
  wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
  wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
  wrap.querySelector('.opt-play').addEventListener('click', async () => {
    try {
      await loadOptimizer();
      if (optState.audio) optState.audio.pause();
      optState.audio = new Audio('/api/audio/' + optState.id);
      optState.audio.currentTime = parseFloat(optStart.value) || 0;
      const end = parseFloat(optEnd.value) || optState.duration;
      optState.audio.ontimeupdate = () => { if (optState.audio.currentTime >= end) optState.audio.pause(); };
      optState.audio.play();
    } catch(e) { toast('Preview failed: ' + e.message, 'error'); }
  });
  optPlayReferenceBtn.addEventListener('click', async () => {
    try {
      await loadOptimizer();
      syncCompareReferenceAudio();
      optCompareRefAudio.currentTime = parseFloat(optStart.value) || 0;
      await optCompareRefAudio.play().catch(()=>{});
      setOptStatus('Playing reference WAV selection for comparison.');
    } catch(e) { toast('Reference playback failed: ' + e.message, 'error'); }
  });
  optSynthReferenceBtn.addEventListener('click', async () => {
    const text = optTranscript.value.trim();
    if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return; }
    if (v.needs_tts_restart) {
      const ok = confirm('This voice is still marked as needing a TTS restart. If you already restarted TTS manually, clear the restart flags and synthesize now?');
      if (!ok) {
        setOptStatus('Restart TTS before synthesizing this comparison, or clear the flag after a manual restart.');
        return;
      }
      try {
        const d = await clearTtsRestartFlags();
        setVoiceRestartState(false, `Restart flags cleared (${d.cleared_restart_flags || 0}). Synthesizing comparison...`);
        toast('Restart flags cleared', 'success');
      } catch(e) {
        toast('Could not clear restart flags: ' + e.message, 'error');
        setOptStatus('Could not clear restart flags');
        return;
      }
    }
    optSynthReferenceBtn.disabled = true;
    try {
      await loadOptimizer();
      setOptStatus('Synthesizing reference text for comparison...');
      const source = await createTtsAudioSource(v.id, text, optCompareBackend.value, 'settings', '');
      if (optState.compareSynthUrl) URL.revokeObjectURL(optState.compareSynthUrl);
      optCompareSynthAudio.src = source.url;
      optState.compareSynthUrl = source.streaming ? null : source.url;
      await optCompareSynthAudio.play().catch(()=>{});
      setOptStatus(source.streaming ? 'Streaming synthesized comparison.' : 'Synthesized comparison ready.');
    } catch(e) {
      toast('Synthesis comparison failed: ' + e.message, 'error');
      setOptStatus('Synthesis comparison failed');
    } finally {
      optSynthReferenceBtn.disabled = false;
    }
  });
  wrap.querySelector('.opt-auto-trim').addEventListener('click', async () => {
    try {
      await loadOptimizer();
      const d = await clientAutoTrimBounds(optState.id);
      optStart.value = Number(d.start).toFixed(2);
      optEnd.value = Number(d.end).toFixed(2);
      redrawOpt();
      setOptStatus(d.reason || 'Auto trim ready');
    } catch(e) { toast('Auto trim failed: ' + e.message, 'error'); }
  });
  wrap.querySelector('.opt-recognize').addEventListener('click', async () => {
    try {
      await loadOptimizer();
      const r = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:optState.id})});
      if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); }
      const d = await r.json();
      optTranscript.value = d.text || '';
      setOptStatus('Reference text recognised. Review it, then Save text.');
    } catch(e) { toast('Recognition failed: ' + e.message, 'error'); }
  });
  optSaveTextBtn.addEventListener('click', async () => {
    optSaveTextBtn.disabled = true;
    try {
      await saveOptimizerText();
    } catch(e) {
      toast('Save text failed: ' + e.message, 'error');
      setOptStatus('Save text failed');
    } finally {
      optSaveTextBtn.disabled = false;
    }
  });
  wrap.querySelector('.opt-save-crop').addEventListener('click', async () => {
    try {
      await loadOptimizer();
      const cropStart = Math.max(0, parseFloat(optStart.value) || 0);
      const cropEnd = Math.min(optState.duration, parseFloat(optEnd.value) || optState.duration);
      if (cropStart <= 0.01 && cropEnd >= optState.duration - 0.05) {
        setOptStatus('No crop range selected. Adjust Start or End first, then Save crop.');
        toast('No crop range selected', 'error');
        return;
      }
      if (cropEnd <= cropStart + 0.1) {
        setOptStatus('Crop range is too short.');
        toast('Crop range is too short', 'error');
        return;
      }
      setOptStatus(`Saving crop ${cropStart.toFixed(2)}s -> ${cropEnd.toFixed(2)}s...`);
      const pr = await fetch('/api/process', {method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({id:optState.id, start:cropStart, end:cropEnd})});
      if (!pr.ok) { const e = await pr.json(); throw new Error(e.detail || pr.statusText); }
      const p = await pr.json();
      const rr = await fetch('/api/voice-replace', {method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({id:p.id, voice_id:v.id, path:v.path, transcript:optTranscript.value})});
      if (!rr.ok) { const e = await rr.json().catch(()=>({})); throw new Error(e.detail || rr.statusText); }
      const saved = await rr.json();
      v.transcript = optTranscript.value;
      v.duration = saved.duration ?? p.duration;
      if (saved.loudness) v.loudness = saved.loudness;
      if (saved.path) v.path = saved.path;
      if (saved.file_type) v.file_type = saved.file_type;
      markVoiceAudioChanged(v);
      refInput.value = v.transcript; refInput.title = v.transcript;
      refTranscribeBtn.style.display = v.transcript ? 'none' : '';
      if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
      await refreshOptimizerFromVoice();
      toast('Voice crop saved: ' + v.id, 'success');
      markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
    } catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
  });
  if (optStyleInstruct) {
    const styleVariationInput = () => {
      const style = optStyleInstruct.value.trim();
      const text = optTranscript.value.trim() || benchmarkSampleText();
      const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
      if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
      if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
      if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
      return {style, text, newId, backend: optStyleBackend.value};
    };
    optPreviewStyleBtn.addEventListener('click', async () => {
      const input = styleVariationInput();
      if (!input) return;
      optPreviewStyleBtn.disabled = true;
      try {
        setOptStatus('Synthesizing style preview...');
        const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
        if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
        optStyleAudio.src = URL.createObjectURL(blob);
        optStyleAudio.style.display = '';
        await optStyleAudio.play().catch(()=>{});
        setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
      } catch(e) {
        toast('Style preview failed: ' + e.message, 'error');
        setOptStatus('Style preview failed');
      } finally {
        optPreviewStyleBtn.disabled = false;
      }
    });
    optSaveStyleBtn.addEventListener('click', async () => {
      const input = styleVariationInput();
      if (!input) return;
      optSaveStyleBtn.disabled = true;
      try {
        setOptStatus(`Synthesizing style variation ${input.newId}...`);
        const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
          body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
        if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
        const d = await r.json();
        toast('Style variation saved: ' + d.voice_id, 'success');
        setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
        await loadVoiceLibrary();
        renderIntegrationSnippets();
      } catch(e) {
        toast('Style variation failed: ' + e.message, 'error');
        setOptStatus('Style variation failed');
      } finally {
        optSaveStyleBtn.disabled = false;
      }
    });
  }

  wrap.querySelector('.opt-undo').addEventListener('click', async () => {
    if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
    try {
      setOptStatus('Restoring original…');
      const r = await fetch('/api/voice/undo', {method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({voice_id:v.id, path:v.path})});
      if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
      const d = await r.json();
      v.duration = d.duration ?? v.duration;
      v.loudness = d.loudness || v.loudness;
      v.path = d.path || v.path;
      v.file_type = d.file_type || v.file_type;
      markVoiceAudioChanged(v);
      if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
      await refreshOptimizerFromVoice();
      toast('Original restored: ' + v.id, 'success');
      markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
    } catch(e) { toast('Undo failed: ' + e.message, 'error'); setOptStatus('Undo failed'); }
  });
  wrap.querySelector('.opt-save-volume').addEventListener('click', async () => {
    try {
      setOptStatus('Saving volume…');
      const r = await fetch('/api/voice/normalize', {method:'POST',headers:{'Content-Type':'application/json'},
        body:JSON.stringify({voice_id:v.id, path:v.path, target_dbfs:Number(optTargetDb.value || -20)})});
      if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
      const d = await r.json();
      v.loudness = d.loudness || v.loudness;
      v.duration = d.duration ?? v.duration;
      if (d.path) v.path = d.path;
      if (d.file_type) v.file_type = d.file_type;
      markVoiceAudioChanged(v);
      dbValue.textContent = fmtDbfs(v);
      dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
      await refreshOptimizerFromVoice();
      toast('Volume saved: ' + v.id, 'success');
      markTtsRestartRequired('Volume saved. Restart TTS before rebenchmarking this voice.');
    } catch(e) { toast('Volume save failed: ' + e.message, 'error'); setOptStatus('Volume save failed'); }
  });
  optRestartTtsBtn.addEventListener('click', async () => {
    optRestartTtsBtn.disabled = true;
    try {
      setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…');
      const r = await fetch('/api/tts/restart', { method:'POST' });
      if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
      const d = await r.json();
      _voices.forEach(voice => { voice.needs_tts_restart = false; });
      updateLibraryInsights();
      const names = (d.restarted || []).join(', ') || 'containers';
      const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : '';
      setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`);
      toast(`TTS restarted: ${names}`, 'success');
    } catch(e) {
      toast('Restart TTS failed: ' + e.message, 'error');
      setOptStatus('Restart TTS failed: ' + e.message);
    } finally {
      optRestartTtsBtn.disabled = false;
    }
  });

  // Reference text input and recognition
  const refInput = wrap.querySelector('.vr-ref input');
  const refTranscribeBtn = wrap.querySelector('.ref-transcribe-btn');
  refInput.addEventListener('input', debounce(async () => {
    v.transcript = refInput.value;
    refInput.title = v.transcript;
    refTranscribeBtn.style.display = v.transcript ? 'none' : '';
    await saveMeta(v.id, { transcript: v.transcript });
    if (wrap.classList.contains('edit-open')) markTtsRestartRequired('Reference text saved. Restart TTS before rebenchmarking.');
    else v.needs_tts_restart = true;
  }, 800));
  refTranscribeBtn.addEventListener('click', async () => {
    refTranscribeBtn.disabled = true;
    try {
      const d = await loadLibraryVoiceAudio(v);
      const tr = await fetch('/api/transcribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:d.id})});
      if (!tr.ok) { const e=await tr.json(); throw new Error(e.detail); }
      const text = await tr.json();
      v.transcript = text.text || '';
      refInput.value = v.transcript;
      refInput.title = v.transcript;
      refTranscribeBtn.style.display = v.transcript ? 'none' : '';
      await saveMeta(v.id, { transcript: v.transcript });
      v.needs_tts_restart = true;
      toast('Reference text recognised; restart TTS before benchmarking','success');
    } catch(e) {
      toast('Recognition failed: '+e.message,'error');
    } finally { refTranscribeBtn.disabled = false; }
  });

  // Note (debounced save)
  const noteInput = wrap.querySelector('.vr-note input');
  noteInput.addEventListener('input', debounce(async () => {
    v.note = noteInput.value;
    await saveMeta(v.id, { note: v.note });
  }, 800));

  // Stars
  const starSpans = wrap.querySelectorAll('.star');
  starSpans.forEach(s => {
    s.addEventListener('click', async () => {
      const val = parseInt(s.dataset.val);
      const newRating = val===v.rating ? 0 : val;
      v.rating = newRating;
      starSpans.forEach((ss,i) => ss.classList.toggle('on', i<newRating));
      await saveMeta(v.id, { rating: newRating });
    });
    s.addEventListener('mouseenter', () => {
      const val = parseInt(s.dataset.val);
      starSpans.forEach((ss,i) => ss.classList.toggle('on', i<val));
    });
    s.addEventListener('mouseleave', () => {
      starSpans.forEach((ss,i) => ss.classList.toggle('on', i<(v.rating||0)));
    });
  });

  // Per-voice benchmark
  const benchmarkOneBtn = wrap.querySelector('.benchmark-one-btn');
  const benchmarkThisVoice = async triggerBtn => {
    if (v.needs_tts_restart) {
      const ok = confirm('This voice changed since the last TTS restart. Benchmarking now may use the cached old voice. Continue anyway?');
      if (!ok) {
        setOptStatus('Restart TTS first, then rebenchmark this voice.');
        return;
      }
    }
    triggerBtn.disabled = true;
    status('Benchmarking ' + v.id + '...');
    try {
      setBenchmarkProgress(0, 1, `Benchmarking ${v.id}`);
      const d = await runVoiceBenchmark(v.id);
      mergeBenchmarkResults(d);
      const hit = (d.voices || []).find(x => x.voice_id === v.id);
      if (hit && hit.benchmark) v.benchmark = hit.benchmark;
      const benchCell = wrap.querySelector('.vr-bench');
      benchCell.className = 'vr-bench ' + benchmarkClass(v);
      benchCell.title = benchmarkTitle(v);
      benchCell.querySelector('.vr-bench-value').textContent = fmtBenchmark(v);
      setBenchmarkProgress(1, 1, `Finished ${v.id}`);
      toast('Benchmarked ' + v.id, 'success');
      setVoiceRestartState(false, 'Benchmark saved for ' + v.id);
    } catch(e) {
      toast('Benchmark failed: ' + e.message, 'error');
      setOptStatus('Benchmark failed');
    } finally { triggerBtn.disabled = false; }
  };
  benchmarkOneBtn.addEventListener('click', () => benchmarkThisVoice(benchmarkOneBtn));
  optRebenchmarkBtn.addEventListener('click', () => benchmarkThisVoice(optRebenchmarkBtn));

  // Play original recording or synthesized sample
  const originalPlayBtn = wrap.querySelector('.vr-play-original button');
  const synthPlayBtn = wrap.querySelector('.vr-play-synth button');
  const playIcon = '<span class="mdi mdi-play"></span>', pauseIcon = '<span class="mdi mdi-pause-circle-outline"></span>', generatingIcon = '<span class="mdi mdi-timer-sand"></span>';
  function setLibraryPlayButtonState(btn, state) {
    btn.classList.toggle('is-generating', state === 'generating');
    btn.innerHTML = state === 'playing' ? pauseIcon : (state === 'generating' ? generatingIcon : playIcon);
    btn.title = state === 'generating' ? 'Generating synthesized sample...' : (state === 'playing' ? 'Pause playback' : (btn.dataset.playKind === 'synth' ? 'Generate and play synthesized sample' : 'Play original recording'));
  }
  async function playLibraryVoice(kind, playBtn) {
    const bar = $('lib-audio-bar'), audio = $('lib-audio');
    const playKey = v.id + ':' + kind;
    playBtn.dataset.playKind = kind;

    if (_activePlayVoiceId === playKey && !audio.paused) {
      audio.pause();
      setLibraryPlayButtonState(playBtn, 'idle');
      return;
    }
    if (_activePlayVoiceId === playKey && audio.paused && audio.src) {
      _activePlayButton = playBtn;
      try { await audio.play(); } catch(e) { toast('Play failed: '+e.message,'error'); }
      return;
    }

    if (_activePlayButton && _activePlayButton !== playBtn) setLibraryPlayButtonState(_activePlayButton, 'idle');
    _activePlayButton = playBtn;
    _activePlayVoiceId = playKey;
    if (_activePlayUrl) { URL.revokeObjectURL(_activePlayUrl); _activePlayUrl = null; }
    if (kind === 'synth') setLibraryPlayButtonState(playBtn, 'generating');
    playBtn.disabled = true;
    try {
      if (kind === 'synth') {
        if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error');
        const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview';
        const text = synthMode === 'transcript'
          ? (v.transcript?.trim() || benchmarkSampleText())
          : benchmarkSampleText();
        const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text';
        const backend = libraryTtsBackend();
        const source = await createTtsAudioSource(v.id, text, backend, 'settings', '');
        audio.src = source.url;
        if (!source.streaming) _activePlayUrl = source.url;
        $('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend);
      } else {
        audio.src = voiceFileUrl(v);
        $('lib-audio-label').textContent = v.id + ' · original recording';
      }
      bar.style.display = '';
      audio.onended = () => { setLibraryPlayButtonState(playBtn, 'idle'); _activePlayVoiceId = null; };
      audio.onpause = () => { if (_activePlayButton === playBtn) setLibraryPlayButtonState(playBtn, 'idle'); };
      audio.onplay = () => { setLibraryPlayButtonState(playBtn, 'playing'); };
      await audio.play();
    } catch(e) {
      setLibraryPlayButtonState(playBtn, 'idle');
      toast('Play failed: '+e.message,'error');
    } finally {
      playBtn.disabled = false;
    }
  }
  originalPlayBtn.dataset.playKind = 'original';
  synthPlayBtn.dataset.playKind = 'synth';
  setLibraryPlayButtonState(originalPlayBtn, 'idle');
  setLibraryPlayButtonState(synthPlayBtn, 'idle');
  originalPlayBtn.addEventListener('click', () => playLibraryVoice('original', originalPlayBtn));
  synthPlayBtn.addEventListener('click', () => playLibraryVoice('synth', synthPlayBtn));

  // Enable toggle
  const toggleCb = wrap.querySelector('.toggle input');
  toggleCb.addEventListener('change', async () => {
    const nextEnabled = toggleCb.checked;
    const previousEnabled = v.enabled !== false;
    toggleCb.disabled = true;
    try {
      const saved = await saveMeta(v.id, { enabled: nextEnabled });
      v.enabled = nextEnabled;
      if (saved && saved.path) v.path = saved.path;
      wrap.classList.toggle('vr-disabled', !v.enabled);
      toast(nextEnabled ? 'Moved to active_voices' : 'Moved to hidden_voices', 'success');
      if (!v.enabled && !$('show-disabled-cb').checked) {
        wrap.style.transition = 'opacity .4s'; wrap.style.opacity = '0';
        setTimeout(() => wrap.remove(), 400);
      }
    } catch(e) {
      toggleCb.checked = previousEnabled;
      v.enabled = previousEnabled;
      wrap.classList.toggle('vr-disabled', !v.enabled);
      toast('Move failed: ' + e.message, 'error');
    } finally {
      toggleCb.disabled = false;
    }
  });

  // Delete voice
  const deleteBtn = wrap.querySelector('.delete-btn');
  const deleteConfirm = wrap.querySelector('.delete-confirm');
  const deleteCancelBtn = wrap.querySelector('.delete-confirm-cancel');
  const deleteGoBtn = wrap.querySelector('.delete-confirm-go');
  const closeDeleteConfirm = () => wrap.classList.remove('delete-pending');
  deleteBtn.addEventListener('click', e => {
    e.stopPropagation();
    document.querySelectorAll('.vl-row.delete-pending').forEach(row => { if (row !== wrap) row.classList.remove('delete-pending'); });
    wrap.classList.add('delete-pending');
    deleteGoBtn.focus();
  });
  deleteCancelBtn.addEventListener('click', e => { e.stopPropagation(); closeDeleteConfirm(); });
  deleteConfirm.addEventListener('click', e => e.stopPropagation());
  deleteGoBtn.addEventListener('click', async e => {
    e.stopPropagation();
    deleteGoBtn.disabled = true;
    deleteCancelBtn.disabled = true;
    try {
      const r = await fetch(`/api/voice/${encodeURIComponent(v.id)}`, { method: 'DELETE' });
      if (!r.ok) { const e = await r.json(); throw new Error(e.detail); }
      _voices = _voices.filter(x => x.id !== v.id);
      wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
      setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
      toast(`Deleted: ${v.id}`, 'success');
    } catch(e) {
      toast('Delete failed: ' + e.message, 'error');
      deleteGoBtn.disabled = false;
      deleteCancelBtn.disabled = false;
      closeDeleteConfirm();
    }
  });

  return wrap;
}

// ── Voice groups bar (bulk edit/remove a tagged set, e.g. a rehearser play) ──
function renderVoiceGroupsBar() {
  const bar = $('voice-groups-bar');
  if (!bar) return;
  // Tally groups across all voices
  const groups = {};
  (_voices || []).forEach(v => {
    const g = (v.group || '').trim();
    if (g) groups[g] = (groups[g] || 0) + 1;
  });
  const names = Object.keys(groups).sort();
  if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
  bar.hidden = false;
  const active = window._voiceGroupFilter || '';
  bar.innerHTML =
    `<span class="vg-label"><span class="mdi mdi-folder-multiple-outline"></span> Groups</span>` +
    names.map(g =>
      `<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
         ${escHtml(g)} <span class="vg-count">${groups[g]}</span>
         <button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">&times;</button>
       </span>`
    ).join('') +
    (active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');

  bar.querySelectorAll('.vg-chip').forEach(chip => {
    chip.addEventListener('click', e => {
      if (e.target.closest('.vg-del')) return;
      const g = chip.dataset.group;
      window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g;
      renderVoiceList();
    });
  });
  bar.querySelectorAll('.vg-del').forEach(btn => {
    btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
  });
  bar.querySelector('.vg-clear')?.addEventListener('click', () => {
    window._voiceGroupFilter = ''; renderVoiceList();
  });
}

async function deleteVoiceGroup(group) {
  const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
  if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
  try {
    const r = await fetch('/api/voices/delete-group', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ group }),
    });
    if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
    const d = await r.json();
    if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
    toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
    await loadVoiceLibrary();
  } catch(e) { toast('Delete failed: ' + e.message, 'error'); }
}

async function saveMeta(voiceId, patch) {
  const r = await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
    body: JSON.stringify({ voice_id:voiceId, ...patch }) });
  if (!r.ok) {
    const e = await r.json().catch(() => ({}));
    throw new Error(e.detail || r.statusText);
  }
  return r.json();
}

// Close language pickers when clicking elsewhere
document.addEventListener('click', () => {
  document.querySelectorAll('.flag-picker.open').forEach(fp => fp.classList.remove('open'));
  document.querySelectorAll('.vl-row.delete-pending').forEach(row => row.classList.remove('delete-pending'));
}, { passive:true });

// ── Bulk-edit ────────────────────────────────────────────────────────────────

const _bulkSelected = new Set();   // voice IDs currently checked

function _bulkUpdate() {
  const bar   = $('vl-bulk-bar');
  const count = $('vl-bulk-count');
  const n = _bulkSelected.size;
  if (bar)   bar.hidden   = n === 0;
  if (count) count.textContent = `${n} selected`;
  // Sync checkboxes in the DOM to match the set
  document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
    cb.checked = _bulkSelected.has(cb.dataset.id);
  });
  
  const headerCb = document.getElementById('vl-select-all-header');
  if (headerCb) {
    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));
    headerCb.checked = allSelected;
  }
}

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 table header (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-header')?.addEventListener('change', (e) => {
  const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
  if (e.target.checked) {
    rows.forEach(r => _bulkSelected.add(r.dataset.id));
  } else {
    rows.forEach(r => _bulkSelected.delete(r.dataset.id));
  }
  _bulkUpdate();
});

$('vl-bulk-deselect')?.addEventListener('click', () => {
  _bulkSelected.clear();
  _bulkUpdate();
});

$('vl-bulk-hide')?.addEventListener('click', async () => {
  if (!_bulkSelected.size) return;
  const ids = [..._bulkSelected];
  const r = await _bulkSetEnabled(ids, false);
  toast(`Hidden ${r} voice${r!==1?'s':''}`, 'success');
  _bulkSelected.clear();
  await loadVoiceLibrary();
});

$('vl-bulk-unhide')?.addEventListener('click', async () => {
  if (!_bulkSelected.size) return;
  const ids = [..._bulkSelected];
  const r = await _bulkSetEnabled(ids, true);
  toast(`Unhidden ${r} voice${r!==1?'s':''}`, 'success');
  _bulkSelected.clear();
  await loadVoiceLibrary();
});

$('vl-bulk-tag')?.addEventListener('click', () => {
  if (!_bulkSelected.size) return;
  const ids = [..._bulkSelected];
  // Show a small inline prompt inside the toolbar
  const bar = $('vl-bulk-bar');
  let inp = bar.querySelector('.vl-bulk-tag-inp');
  if (!inp) {
    inp = document.createElement('input');
    inp.type = 'text';
    inp.className = 'vl-bulk-tag-inp';
    inp.placeholder = 'tag1, tag2…';
    inp.autocomplete = 'off';
    const applyBtn = document.createElement('button');
    applyBtn.className = 'vl-bulk-btn';
    applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
    applyBtn.addEventListener('click', async () => {
      const tagVal = inp.value.trim();
      let done = 0;
      for (const id of ids) {
        await saveMeta(id, { tag: tagVal }).catch(() => {});
        done++;
      }
      toast(`Tag set on ${done} voice${done!==1?'s':''}`, 'success');
      inp.remove(); applyBtn.remove();
      _bulkSelected.clear();
      await loadVoiceLibrary();
    });
    bar.appendChild(inp);
    bar.appendChild(applyBtn);
  }
  inp.focus();
});

$('vl-bulk-rating')?.addEventListener('click', () => {
  if (!_bulkSelected.size) return;
  const ids = [..._bulkSelected];
  const bar = $('vl-bulk-bar');
  let sel = bar.querySelector('.vl-bulk-rating-sel');
  if (!sel) {
    sel = document.createElement('select');
    sel.className = 'vl-bulk-rating-sel';
    sel.innerHTML = '<option value="">⭐ Pick rating…</option>' +
      [1,2,3,4,5].map(n => `<option value="${n}">${'★'.repeat(n)}</option>`).join('');
    sel.addEventListener('change', async () => {
      const rating = Number(sel.value);
      if (!rating) return;
      let done = 0;
      for (const id of ids) {
        await saveMeta(id, { rating }).catch(() => {});
        done++;
      }
      toast(`Rated ${done} voice${done!==1?'s':''}`, 'success');
      sel.remove();
      _bulkSelected.clear();
      await loadVoiceLibrary();
    });
    bar.appendChild(sel);
    sel.focus();
  }
});

$('vl-bulk-delete')?.addEventListener('click', () => {
  if (!_bulkSelected.size) return;
  _showBulkDeleteConfirm([..._bulkSelected]);
});

function _showBulkDeleteConfirm(ids) {
  document.querySelector('.vl-bdc-overlay')?.remove();
  const plural = ids.length !== 1 ? 's' : '';
  const names = ids.slice(0, 12).map(id => `<span class="vl-bdc-id" title="${escHtml(id)}">${escHtml(id)}</span>`).join('');
  const more  = ids.length > 12 ? `<span class="vl-bdc-more">+${ids.length - 12} more</span>` : '';

  const ov = document.createElement('div');
  ov.className = 'vl-bdc-overlay';
  ov.innerHTML = `
    <div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices">
      <div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div>
      <p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p>
      <div class="vl-bdc-list">${names}${more}</div>
      <div class="vl-bdc-actions">
        <button type="button" class="vl-bdc-cancel">Cancel</button>
        <button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button>
      </div>
    </div>`;

  const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); };
  function onKey(e) { if (e.key === 'Escape') close(); }
  ov.addEventListener('click', e => { if (e.target === ov) close(); });
  ov.querySelector('.vl-bdc-cancel').addEventListener('click', close);
  document.addEventListener('keydown', onKey);

  ov.querySelector('.vl-bdc-go').addEventListener('click', async () => {
    const goBtn = ov.querySelector('.vl-bdc-go');
    const cancelBtn = ov.querySelector('.vl-bdc-cancel');
    goBtn.disabled = cancelBtn.disabled = true;
    let done = 0, errors = 0;
    await runPool(ids, async (id) => {
      try {
        const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
        if (r.ok) done++; else errors++;
      } catch (_) { errors++; }
    }, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}…`; });
    close();
    toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
    _bulkSelected.clear();
    await loadVoiceLibrary();
  });

  document.body.appendChild(ov);
  ov.querySelector('.vl-bdc-cancel').focus();
}

// ── Bulk API helpers ────────────────────────────────────────────────────────

async function _bulkSetEnabled(ids, enabled) {
  let done = 0;
  for (const id of ids) {
    await saveMeta(id, { enabled }).catch(() => {});
    done++;
  }
  return done;
}

