From 262e37fffdbb207344971182827b5b237239938b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:47:51 +0200 Subject: [PATCH] refactor: extract rehearser voice matching --- CHANGELOG.md | 2 + TODO.md | 3 +- VERSION | 2 +- docs/ARCHITECTURE.md | 2 +- static/index.html | 16 +-- static/js/rehearser-voice-picker.js | 211 +++++++++++++++++++++++++++- static/js/rehearser.js | 197 -------------------------- tests/rehearser.spec.js | 1 + 8 files changed, 223 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 059e159..c190889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### 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, 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 diff --git a/TODO.md b/TODO.md index 5c8fe16..2f067b1 100644 --- a/TODO.md +++ b/TODO.md @@ -129,7 +129,8 @@ architecture change: the larger Audiobook/Rehearser/style split. larger responsibility splits remain. - [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. -- [ ] 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 cast view, sorting, filtering, and toolbar wiring into `rehearser-cast.js`. - [x] Extract Rehearser training transcript comparison helpers into `rehearser-train.js`. diff --git a/VERSION b/VERSION index eb6fac4..ffb8153 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.46 +1.24.47 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cb4962b..221e5ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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-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-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-voice-design.js` — character development, backend selection, and backend-refresh synchronization. - ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. diff --git a/static/index.html b/static/index.html index 92c5e67..9360cf7 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-voice-picker.js b/static/js/rehearser-voice-picker.js index b1fc453..b543a7d 100644 --- a/static/js/rehearser-voice-picker.js +++ b/static/js/rehearser-voice-picker.js @@ -1,6 +1,6 @@ // ── Rehearser voice-picker presentation ───────────────────────────────────── -// Matching/import actions remain in rehearser.js for now; this module owns the -// reusable preview state and HTML for the online/local voice choices. +// 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() { @@ -72,6 +72,211 @@ function castOnlinePanelHtml(sp, c) { `; } +// 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 }; +window.TTVC.rehearser.voicePicker = { + castOnlinePanelHtml, + preview: _rehAudition, + stopPreview: _rehStopAudition, + searchOnline: _rehSearchOnline, + assignLocal: _rehAssignLocal, + useCandidate: _rehUseCandidate, +}; diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 1e6044e..351e8a8 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -1002,203 +1002,6 @@ function castAvatarHtml(sp) { return `${initial}`; } -// 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'); - } -} const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names diff --git a/tests/rehearser.spec.js b/tests/rehearser.spec.js index fa090cf..fb7392a 100644 --- a/tests/rehearser.spec.js +++ b/tests/rehearser.spec.js @@ -35,6 +35,7 @@ test('parses a screenplay and builds the cast', async ({ page }) => { window.TTVC?.rehearser?.parse?.parseScript && window.TTVC?.rehearser?.stage?.renderCastStrip && window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml && + window.TTVC?.rehearser?.voicePicker?.searchOnline && window.TTVC?.rehearser?.cast?.renderCastList ))).toBe(true); await page.evaluate(() => window.navRehearserPhase(1));