// ── 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 ? `` : ''}
`; } // 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, };