refactor: extract rehearser voice matching

This commit is contained in:
mARTin-B78 2026-09-04 11:47:51 +02:00
parent 4c8022e8aa
commit 262e37fffd
8 changed files with 223 additions and 211 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- **Rehearser voice-matching extraction.** Local voice matching, preview-line selection, fish.audio search/import,
and candidate assignment now live in `rehearser-voice-picker.js`; DOM event registration remains timing-safe.
- **Audiobook/Reader stylesheet extraction.** Reader controls, Audiobook casting, page layout, character overlays, - **Audiobook/Reader stylesheet extraction.** Reader controls, Audiobook casting, page layout, character overlays,
and confidence/recast styles now live in `static/css/audiobook.css` with release cache busting. and confidence/recast styles now live in `static/css/audiobook.css` with release cache busting.
- **Rehearser stylesheet extraction.** The main Rehearser layout, cast, stage, training, library, and script-view - **Rehearser stylesheet extraction.** The main Rehearser layout, cast, stage, training, library, and script-view

View File

@ -129,7 +129,8 @@ architecture change: the larger Audiobook/Rehearser/style split.
larger responsibility splits remain. larger responsibility splits remain.
- [x] Add a dependency-free frontend syntax and top-level declaration collision gate (`npm run check:frontend`); - [x] Add a dependency-free frontend syntax and top-level declaration collision gate (`npm run check:frontend`);
a full style/lint policy remains a follow-up. a full style/lint policy remains a follow-up.
- [ ] Extract Rehearser voice matching and voice design. - [x] Extract Rehearser voice matching and voice design; matching/import helpers now live with the voice-picker module,
while timing-sensitive DOM wiring remains in the cast renderer.
- [x] Extract Rehearser voice-picker presentation and preview state into `rehearser-voice-picker.js`. - [x] Extract Rehearser voice-picker presentation and preview state into `rehearser-voice-picker.js`.
- [x] Extract Rehearser cast view, sorting, filtering, and toolbar wiring into `rehearser-cast.js`. - [x] Extract Rehearser cast view, sorting, filtering, and toolbar wiring into `rehearser-cast.js`.
- [x] Extract Rehearser training transcript comparison helpers into `rehearser-train.js`. - [x] Extract Rehearser training transcript comparison helpers into `rehearser-train.js`.

View File

@ -1 +1 @@
1.24.46 1.24.47

View File

@ -33,7 +33,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed)
- ✅ `rehearser-parse.js` — `parseScript`, `detectCharacters` (pure; only call-time `SPEAKER_COLORS`). - ✅ `rehearser-parse.js` — `parseScript`, `detectCharacters` (pure; only call-time `SPEAKER_COLORS`).
- ✅ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research. - ✅ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research.
- ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering; playback and transport remain in `rehearser.js`. - ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering; playback and transport remain in `rehearser.js`.
- ✅ `rehearser-voice-picker.js` — voice preview state and matched-choice presentation; import and assignment actions remain in `rehearser.js`. - ✅ `rehearser-voice-picker.js` — voice preview state, local matching, fish.audio search/import, and candidate assignment; DOM event registration remains in `rehearser-cast.js`.
- ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring. - ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring.
- ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization.
- ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. - ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation.

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.24.46"> <meta name="app-version" content="1.24.47">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.24.46"> <link rel="stylesheet" href="/static/style.css?v=1.24.47">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.46"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.47">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.46"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.47">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.46"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.47">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.46"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.47">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.46"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.47">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.24.46"></script> <script src="/static/loader.js?v=1.24.47"></script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
// ── Rehearser voice-picker presentation ───────────────────────────────────── // ── Rehearser voice-picker presentation ─────────────────────────────────────
// Matching/import actions remain in rehearser.js for now; this module owns the // Preview, local-library matching, and online search/import actions share this
// reusable preview state and HTML for the online/local voice choices. // module; DOM event registration remains in the cast renderer.
let _rehAudEl = null, _rehAudBtn = null, _rehAudObjectUrl = null; let _rehAudEl = null, _rehAudBtn = null, _rehAudObjectUrl = null;
function _rehStopAudition() { function _rehStopAudition() {
@ -72,6 +72,211 @@ function castOnlinePanelHtml(sp, c) {
</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 = window.TTVC || {};
window.TTVC.rehearser = window.TTVC.rehearser || {}; window.TTVC.rehearser = window.TTVC.rehearser || {};
window.TTVC.rehearser.voicePicker = { castOnlinePanelHtml, preview: _rehAudition, stopPreview: _rehStopAudition }; window.TTVC.rehearser.voicePicker = {
castOnlinePanelHtml,
preview: _rehAudition,
stopPreview: _rehStopAudition,
searchOnline: _rehSearchOnline,
assignLocal: _rehAssignLocal,
useCandidate: _rehUseCandidate,
};

View File

@ -1002,203 +1002,6 @@ function castAvatarHtml(sp) {
return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`; return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
} }
// 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');
}
}
const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names

View File

@ -35,6 +35,7 @@ test('parses a screenplay and builds the cast', async ({ page }) => {
window.TTVC?.rehearser?.parse?.parseScript && window.TTVC?.rehearser?.parse?.parseScript &&
window.TTVC?.rehearser?.stage?.renderCastStrip && window.TTVC?.rehearser?.stage?.renderCastStrip &&
window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml && window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml &&
window.TTVC?.rehearser?.voicePicker?.searchOnline &&
window.TTVC?.rehearser?.cast?.renderCastList window.TTVC?.rehearser?.cast?.renderCastList
))).toBe(true); ))).toBe(true);
await page.evaluate(() => window.navRehearserPhase(1)); await page.evaluate(() => window.navRehearserPhase(1));