283 lines
16 KiB
JavaScript
283 lines
16 KiB
JavaScript
// ── 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 = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
|
||
const right = isCurrent
|
||
? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
|
||
: `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
|
||
return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
|
||
<div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
|
||
${right}</div>`;
|
||
}
|
||
|
||
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 = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
|
||
}
|
||
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 `<div class="reh-cc-online">
|
||
<div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try & change
|
||
${alts.length ? `<button type="button" class="reh-vo-toggle">${alts.length} match${alts.length !== 1 ? 'es' : ''} ▾</button>` : ''}</div>
|
||
<div class="reh-vo-list">${curRow}</div>
|
||
${alts.length ? `<div class="reh-vo-list reh-vo-alts" hidden>${altRows}</div>` : ''}
|
||
<div class="reh-vo-tools">
|
||
<button type="button" class="reh-vo-tool" data-tool="local"><span class="mdi mdi-bookshelf"></span> From library</button>
|
||
<button type="button" class="reh-vo-tool" data-tool="search"><span class="mdi mdi-magnify"></span> Search online</button>
|
||
</div>
|
||
<div class="reh-vo-tool-panel reh-vo-local-panel" hidden>
|
||
<input type="search" class="reh-vo-local-input" placeholder="Filter your library voices…" autocomplete="off">
|
||
<div class="reh-vo-results reh-vo-local-results"></div>
|
||
</div>
|
||
<div class="reh-vo-tool-panel reh-vo-search-panel" hidden>
|
||
<div class="reh-vo-search-row"><input type="search" class="reh-vo-search-input" placeholder="Search fish.audio (name, style, language)…" autocomplete="off"><button type="button" class="btn-secondary btn-sm reh-vo-search-go">Search</button></div>
|
||
<div class="reh-vo-results reh-vo-search-results"><div class="reh-vo-hint">Type a query and hit Search to browse fish.audio voices.</div></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 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 = `<div class="reh-vo-hint">No library voices match.</div>`; 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 = `<div class="reh-vo-hint">Enter a search term first.</div>`; return; }
|
||
box.innerHTML = `<div class="reh-vo-hint"><span class="reh-imsdb-spinner"></span> Searching fish.audio…</div>`;
|
||
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 = `<div class="reh-vo-hint">No playable voices found for “${escHtml(q)}”.</div>`; 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 = '<span class="reh-imsdb-spinner"></span>';
|
||
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 = '<span class="reh-imsdb-spinner"></span>';
|
||
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,
|
||
};
|