// ── Rehearser voice-picker presentation ─────────────────────────────────────
// Preview, local-library matching, and online search/import actions share this
// module; DOM event registration remains in the cast renderer.
let _rehAudEl = null, _rehAudBtn = null, _rehAudObjectUrl = null;
function _rehStopAudition() {
if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
if (_rehAudObjectUrl) { URL.revokeObjectURL(_rehAudObjectUrl); _rehAudObjectUrl = null; }
if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
_rehAudBtn = null;
}
function _rehAudition(url, btn) {
if (!url) { toast('No preview audio for this voice', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = url;
_rehAudEl.play().then(() => {
_rehAudBtn = btn; btn.classList.add('playing');
const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
}).catch(() => toast('Could not play preview', 'error'));
}
const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
const play = ``;
const right = isCurrent
? ` Selected`
: ``;
return `
${play}
${escHtml(name)}${escHtml(meta)}
${right}
`;
}
function castOnlinePanelHtml(sp, c) {
const o = c.online;
if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';
const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
let curRow;
if (curCand) {
curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
} else if (c.voice) {
const vd = getVoiceData(c.voice);
curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
} else {
curRow = `No voice assignedpick one below
`;
}
const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
})).join('');
return `
Voice — try & change
${alts.length ? `` : ''}
${curRow}
${alts.length ? `
${altRows}
` : ''}
`;
}
// Preview a local library voice by synthesizing a short sample with the chosen backend
async function _rehPreviewLocal(voiceId, btn) {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try {
const blob = await fetchTtsPreviewBlob(voiceId, _REH_PREVIEW_TEXT, 'wav', '', backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudObjectUrl = URL.createObjectURL(blob);
_rehAudEl.src = _rehAudObjectUrl;
await _rehAudEl.play();
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
if (icon) icon.className = 'mdi mdi-stop';
} catch (e) {
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
toast('Preview failed: ' + (e.message || e), 'error');
}
}
// Pick a representative short line from a character's own dialogue (narrator: from
// the action/scene lines it narrates). Prefers a self-contained ~20–110 char sentence.
function _rehPickOneLiner(sp) {
const isNarr = sp === REH_NARRATOR_KEY;
const pool = rehState.lines
.filter(l => isNarr ? (l.type !== 'dialog' && l.type !== 'pagebreak' && !l.ignored) : (l.type === 'dialog' && l.speaker === sp))
.map(l => ({ text: stripMarkdown(l.text || '').trim(), emotion: l.emotion || '' }))
.filter(l => l.text.length >= 4);
if (!pool.length) return null;
const scored = pool.map(l => {
const len = l.text.length;
let score = (len >= 20 && len <= 110) ? 3 : (len < 20 ? 1 : 0);
if (/[.!?]["']?$/.test(l.text)) score += 1; // complete sentence
score -= Math.abs(len - 60) / 120; // prefer ~60 chars
return { l, score };
}).sort((a, b) => b.score - a.score);
const best = scored[0].l;
return { text: best.text.slice(0, 220), emotion: best.emotion };
}
// Synthesize and play a one-liner from this character's lines, in their assigned voice.
async function _rehPreviewCastLine(sp, btn) {
const c = rehState.cast[sp];
if (!c || !c.voice || c.voice === 'me') { toast('Assign a voice first', 'error'); return; }
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
const pick = _rehPickOneLiner(sp);
if (!pick) { toast('No lines to preview yet', 'error'); return; }
const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion, c.voice), backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = URL.createObjectURL(blob);
await _rehAudEl.play();
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
if (icon) icon.className = 'mdi mdi-stop';
} catch (e) {
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
toast('Preview failed: ' + (e.message || e), 'error');
}
}
// Assign an existing library voice to a character (keeps the picker open)
function _rehAssignLocal(sp, voiceId) {
const c = rehState.cast[sp]; if (!c) return;
_rehStopAudition();
c.voice = voiceId; c.voiceData = getVoiceData(voiceId);
if (c.online) c.online.picked = c.online.candidates.findIndex(x => x.voice_id === voiceId);
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
}
// Render filtered library voices inside a card's "From library" panel
function _rehRenderLocalResults(card, sp, q) {
const box = card.querySelector('.reh-vo-local-results'); if (!box) return;
const cur = rehState.cast[sp]?.voice;
const ql = (q || '').trim().toLowerCase();
const matches = rehState.voices.filter(v => {
if (!_rehVoiceVisibleId(v, cur)) return false;
if (v === cur) return false;
if (!ql) return true;
const vd = getVoiceData(v);
return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql);
}).slice(0, 40);
if (!matches.length) { box.innerHTML = `No library voices match.
`; return; }
box.innerHTML = matches.map(v => {
const vd = getVoiceData(v);
return _rehVoiceRow({ name: vd?.name || v, meta: vd?.group || vd?.tag || 'library voice', playVoice: v, useAttrs: ` data-act="local" data-voice="${escHtml(v)}"` });
}).join('');
}
// Search fish.audio and render results inside a card's "Search online" panel
async function _rehSearchOnline(card, sp, q) {
const box = card.querySelector('.reh-vo-search-results'); if (!box) return;
q = (q || '').trim();
if (!q) { box.innerHTML = `Enter a search term first.
`; return; }
box.innerHTML = ` Searching fish.audio…
`;
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
let items = [];
try {
const qs = [`search=${encodeURIComponent(q)}`, `language=${lang}`, 'page_size=10', 'sort_by=score'];
items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || [];
} catch (_) {}
const cands = items.filter(v => v.sample_audio).slice(0, 10).map(v => ({
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
}));
_rehSearchCache[sp] = cands;
if (!cands.length) { box.innerHTML = `No playable voices found for “${escHtml(q)}”.
`; return; }
box.innerHTML = cands.map((cand, i) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`,
})).join('');
}
// Import a freshly searched fish.audio result, assign it, and fold it into candidates
async function _rehUseSearchResult(sp, idx, btn) {
const c = rehState.cast[sp]; if (!c) return;
const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return;
_rehStopAudition();
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid);
if (!c.online) c.online = { candidates: [], picked: 0 };
c.online.candidates.push(cand);
c.online.picked = c.online.candidates.length - 1;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
// Remember fish imports this session so the same voice isn't downloaded twice
const _rehFishImported = {};
// Import one fish.audio candidate into the library and return its voice_id.
// Reuses an already-imported voice (this session OR already in the library) so the
// same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, …
async function _rehImportFishCandidate(sp, cand) {
const key = (cand.sample_audio || cand.title || '').toLowerCase();
if (key && _rehFishImported[key]) {
const id = _rehFishImported[key];
if (!rehState.voices.includes(id)) rehState.voices.push(id);
return id;
}
const title = (cand.title || '').toLowerCase().trim();
const existing = title && (window._voices || []).find(v =>
(v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title);
if (existing) {
if (key) _rehFishImported[key] = existing.id;
if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id);
return existing.id;
}
const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
const vid = `${lang2}_${(typeof _umlautSafe === 'function' ? _umlautSafe(cand.title || sp) : (cand.title || sp)).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
const d = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
}).then(r => r.json());
if (!d.voice_id) throw new Error('import returned no id');
if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e));
if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e));
if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id);
if (key) _rehFishImported[key] = d.voice_id;
return d.voice_id;
}
// "Disagree" → switch this character to one of the presented alternatives
async function _rehUseCandidate(sp, idx, btn) {
const c = rehState.cast[sp]; const o = c && c.online; if (!o) return;
const cand = o.candidates[idx]; if (!cand) return;
_rehStopAudition();
if (cand.voice_id) { // already imported earlier — just reassign
c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx;
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
return;
}
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
window.TTVC = window.TTVC || {};
window.TTVC.rehearser = window.TTVC.rehearser || {};
window.TTVC.rehearser.voicePicker = {
castOnlinePanelHtml,
preview: _rehAudition,
stopPreview: _rehStopAudition,
searchOnline: _rehSearchOnline,
assignLocal: _rehAssignLocal,
useCandidate: _rehUseCandidate,
};