refactor: extract rehearser voice matching
This commit is contained in:
parent
4c8022e8aa
commit
262e37fffd
@ -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
|
||||||
|
|||||||
3
TODO.md
3
TODO.md
@ -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`.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user