refactor: extract rehearser voice design

This commit is contained in:
mARTin-B78 2026-09-04 11:19:01 +02:00
parent d43959728d
commit 2fde0a176d
8 changed files with 84 additions and 85 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
- **Rehearser voice-design extraction.** Character development, backend selection, and backend-refresh synchronization
now live in `rehearser-voice-design.js` while preserving the existing cast controls and global API.
- **Voice Design stylesheet split.** Voice-design form, sample-grid, and preview-match rules now live in
`static/css/voice-design.css`.
- **Background-job stylesheet split.** Job history and retry-panel rules now live in `static/css/background-jobs.css`.

View File

@ -132,6 +132,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [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`.
- [x] Extract Rehearser character development and backend-selection controls into `rehearser-voice-design.js`.
- [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`.
- [ ] Extract Audiobook parsing and casting.
- [x] Extract Audiobook parsing primitives and speaker-heuristic constants into `audiobook-parse.js`.

View File

@ -1 +1 @@
1.24.36
1.24.37

View File

@ -34,6 +34,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed)
- ✅ `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-cast.js` — cast view, sort/filter state handlers, and cast toolbar wiring; card rendering remains in `rehearser.js`.
- ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization.
- ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`.
- ✅ `audiobook-cast.js` — cast Markdown export and legacy page-stamping migration helpers; live casting remains in `audiobook.js`.
- ✅ `static/css/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles.

View File

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

View File

@ -0,0 +1,69 @@
// ── Rehearser voice design controls ─────────────────────────────────────────
// Character development and backend selection resolve the shared Rehearser
// state at interaction time, so they can be loaded as a leaf feature module.
async function _castDevelop(sp, btn) {
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
if (!script) { toast('Load a script first', 'error'); return; }
const c = rehState.cast[sp];
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Developing…';
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script, names: [sp],
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: c.lang || $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const info = (await r.json()).characters?.[0] || {};
if (info.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
if (info.description) { c.soul = info.description; c.instruct = c.instruct || info.description; }
renderCastList();
toast(`Developed ${sp}`, 'success');
} catch (e) {
btn.disabled = false; btn.innerHTML = orig;
toast('Develop failed: ' + e.message, 'error');
}
}
async function refreshRehBackends() {
rehInitLlmField();
const sel = $('reh-backend-select'); if (!sel) return;
let backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
if (!backends.length && typeof refreshTtsBackendAvailability === 'function') {
await refreshTtsBackendAvailability().catch(() => {});
backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
}
if (!backends.length && typeof _ttsBackends !== 'undefined' && Array.isArray(_ttsBackends)) backends = _ttsBackends;
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.length
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="">No backend available</option>';
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
else {
const preferred = ['fishspeech', 'voice_clone', 'customvoice', 'voice_design'];
const pick = preferred.find(id => [...sel.options].some(o => o.value === id));
if (pick) sel.value = pick;
}
rehState.backend = sel.value || '';
_checkToneStyleSupport();
}
(window._ttsRefreshHooks = window._ttsRefreshHooks || []).push(() => {
const sel = $('reh-backend-select'); if (!sel) return;
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
if (!backends.length) return;
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('');
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
else if (sel.options.length) sel.value = sel.options[0].value;
rehState.backend = sel.value || '';
});
window.TTVC = window.TTVC || {};
window.TTVC.rehearser = window.TTVC.rehearser || {};
window.TTVC.rehearser.voiceDesign = { develop: _castDevelop, refreshBackends: refreshRehBackends };

View File

@ -1531,80 +1531,6 @@ function _castDeleteCharacter(sp) {
toast(`Removed ${sp}`, 'success');
}
// Ask the LLM to flesh out one character → fills the soul brief + design prompt
async function _castDevelop(sp, btn) {
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
if (!script) { toast('Load a script first', 'error'); return; }
const c = rehState.cast[sp];
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Developing…';
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script, names: [sp],
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: c.lang || $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const info = (await r.json()).characters?.[0] || {};
if (info.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
if (info.description) { c.soul = info.description; c.instruct = c.instruct || info.description; }
renderCastList();
toast(`Developed ${sp}`, 'success');
} catch (e) {
btn.disabled = false; btn.innerHTML = orig;
toast('Develop failed: ' + e.message, 'error');
}
}
async function refreshRehBackends() {
rehInitLlmField();
const sel = $('reh-backend-select'); if (!sel) return;
let backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
// If the global list hasn't been probed yet, trigger the probe now
if (!backends.length && typeof refreshTtsBackendAvailability === 'function') {
await refreshTtsBackendAvailability().catch(() => {});
backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
}
// Last resort: show all known backends (even unavailable ones) so the user can try
if (!backends.length && typeof _ttsBackends !== 'undefined' && Array.isArray(_ttsBackends)) {
backends = _ttsBackends;
}
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.length
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="">No backend available</option>';
if (prev && [...sel.options].some(o => o.value === prev)) {
// Restore a previously chosen backend
sel.value = prev;
} else {
// Default backend preference. fishspeech is best — it clones each voice's saved WAV
// (consistent identity) AND honours per-line emotion markers (tone), so a character
// sounds the same across the rehearsal while still reacting to tone. voice_clone is the
// fallback for consistency (weak tone); voice_design gives tone but drifts each line.
const preferred = ['fishspeech', 'voice_clone', 'customvoice', 'voice_design'];
const pick = preferred.find(id => [...sel.options].some(o => o.value === id));
if (pick) sel.value = pick;
}
rehState.backend = sel.value || ''; // keep state in sync with the (possibly auto-picked) value
_checkToneStyleSupport();
}
// Keep the rehearser select in sync whenever the global backend list refreshes
(window._ttsRefreshHooks = window._ttsRefreshHooks || []).push(() => {
const sel = $('reh-backend-select'); if (!sel) return;
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
if (!backends.length) return;
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('');
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
else if (sel.options.length) sel.value = sel.options[0].value;
rehState.backend = sel.value || '';
});
// All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
// in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
function _rehAllVoiceIds(include) {

View File

@ -4,9 +4,9 @@
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
],
"lazy": {
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"],
"s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets"],
"s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
"s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"],
"s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets"],
"s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
"s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
}
}