refactor: extract rehearser voice picker

This commit is contained in:
mARTin-B78 2026-09-04 10:54:21 +02:00
parent e98c5ffe33
commit 0bfb6d694b
8 changed files with 85 additions and 86 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-picker extraction.** Voice preview state and matched voice-choice presentation now live in
`rehearser-voice-picker.js`; matching, import, and assignment actions remain compatible with the existing cast API.
- **Rehearser stage extraction.** Stage font scaling, cast-sidebar collapse state, cast filtering/sorting, and cast-strip - **Rehearser stage extraction.** Stage font scaling, cast-sidebar collapse state, cast filtering/sorting, and cast-strip
rendering now live in `rehearser-stage.js`, reducing the main Rehearser module while preserving its legacy globals. rendering now live in `rehearser-stage.js`, reducing the main Rehearser module while preserving its legacy globals.
- **Release cache-busting.** Bumped the app version and HTML stylesheet/loader URLs to `1.24.25` so the extracted module - **Release cache-busting.** Bumped the app version and HTML stylesheet/loader URLs to `1.24.25` so the extracted module

View File

@ -124,6 +124,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [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. - [ ] Extract Rehearser voice matching and voice design.
- [x] Extract Rehearser voice-picker presentation and preview state into `rehearser-voice-picker.js`.
- [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`. - [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`.
- [ ] Extract Audiobook parsing and casting. - [ ] Extract Audiobook parsing and casting.
- [ ] Split `style.css` by feature. - [ ] Split `style.css` by feature.

View File

@ -1 +1 @@
1.24.25 1.24.26

View File

@ -32,6 +32,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, character research. - ⬜ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, 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-train.js` — mic record / STT compare. - ⬜ `rehearser-train.js` — mic record / STT compare.
- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain - ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain
in `rehearser.js` until their UI/event-handler dependencies are isolated. in `rehearser.js` until their UI/event-handler dependencies are isolated.

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.25"> <meta name="app-version" content="1.24.26">
<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,7 +27,7 @@
<!-- ── 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.25"> <link rel="stylesheet" href="/static/style.css?v=1.24.26">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -392,7 +392,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.25"></script> <script src="/static/loader.js?v=1.24.26"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,73 @@
// ── 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.
let _rehAudEl = null, _rehAudBtn = null, _rehAudObjectUrl = null;
function _rehStopAudition() {
if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
if (_rehAudObjectUrl) { URL.revokeObjectURL(_rehAudObjectUrl); _rehAudObjectUrl = null; }
if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
_rehAudBtn = null;
}
function _rehAudition(url, btn) {
if (!url) { toast('No preview audio for this voice', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = url;
_rehAudEl.play().then(() => {
_rehAudBtn = btn; btn.classList.add('playing');
const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
}).catch(() => toast('Could not play preview', 'error'));
}
const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
const play = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
const right = isCurrent
? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
: `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
<div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
${right}</div>`;
}
function castOnlinePanelHtml(sp, c) {
const o = c.online;
if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';
const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
let curRow;
if (curCand) {
curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
} else if (c.voice) {
const vd = getVoiceData(c.voice);
curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
} else {
curRow = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
}
const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
})).join('');
return `<div class="reh-cc-online">
<div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try &amp; change
${alts.length ? `<button type="button" class="reh-vo-toggle">${alts.length} match${alts.length !== 1 ? 'es' : ''} ▾</button>` : ''}</div>
<div class="reh-vo-list">${curRow}</div>
${alts.length ? `<div class="reh-vo-list reh-vo-alts" hidden>${altRows}</div>` : ''}
<div class="reh-vo-tools">
<button type="button" class="reh-vo-tool" data-tool="local"><span class="mdi mdi-bookshelf"></span> From library</button>
<button type="button" class="reh-vo-tool" data-tool="search"><span class="mdi mdi-magnify"></span> Search online</button>
</div>
<div class="reh-vo-tool-panel reh-vo-local-panel" hidden>
<input type="search" class="reh-vo-local-input" placeholder="Filter your library voices…" autocomplete="off">
<div class="reh-vo-results reh-vo-local-results"></div>
</div>
<div class="reh-vo-tool-panel reh-vo-search-panel" hidden>
<div class="reh-vo-search-row"><input type="search" class="reh-vo-search-input" placeholder="Search fish.audio (name, style, language)…" autocomplete="off"><button type="button" class="btn-secondary btn-sm reh-vo-search-go">Search</button></div>
<div class="reh-vo-results reh-vo-search-results"><div class="reh-vo-hint">Type a query and hit Search to browse fish.audio voices.</div></div>
</div>
</div>`;
}

View File

@ -1002,84 +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>`;
} }
// ── Online voice picker: audition a sample, browse alternatives, agree/switch ──
let _rehAudEl = null, _rehAudBtn = null, _rehAudObjectUrl = null;
function _rehStopAudition() {
if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
if (_rehAudObjectUrl) { URL.revokeObjectURL(_rehAudObjectUrl); _rehAudObjectUrl = null; }
if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
_rehAudBtn = null;
}
function _rehAudition(url, btn) {
if (!url) { toast('No preview audio for this voice', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = url;
_rehAudEl.play().then(() => {
_rehAudBtn = btn; btn.classList.add('playing');
const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
}).catch(() => toast('Could not play preview', 'error'));
}
const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)
// One row of the voice picker. Plays via sample URL (online) or TTS (local library voice).
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
const play = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
const right = isCurrent
? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
: `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
<div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
${right}</div>`;
}
// Render the "matched from fish.audio" picker: current voice + alternatives + change tools
function castOnlinePanelHtml(sp, c) {
const o = c.online;
if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';
// Current assigned voice — an online candidate (has a sample) or a plain library voice
const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
let curRow;
if (curCand) {
curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
} else if (c.voice) {
const vd = getVoiceData(c.voice);
curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
} else {
curRow = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
}
// Alternatives = the other matched candidates (not the current one)
const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
})).join('');
return `<div class="reh-cc-online">
<div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try &amp; change
${alts.length ? `<button type="button" class="reh-vo-toggle">${alts.length} match${alts.length !== 1 ? 'es' : ''} ▾</button>` : ''}</div>
<div class="reh-vo-list">${curRow}</div>
${alts.length ? `<div class="reh-vo-list reh-vo-alts" hidden>${altRows}</div>` : ''}
<div class="reh-vo-tools">
<button type="button" class="reh-vo-tool" data-tool="local"><span class="mdi mdi-bookshelf"></span> From library</button>
<button type="button" class="reh-vo-tool" data-tool="search"><span class="mdi mdi-magnify"></span> Search online</button>
</div>
<div class="reh-vo-tool-panel reh-vo-local-panel" hidden>
<input type="search" class="reh-vo-local-input" placeholder="Filter your library voices…" autocomplete="off">
<div class="reh-vo-results reh-vo-local-results"></div>
</div>
<div class="reh-vo-tool-panel reh-vo-search-panel" hidden>
<div class="reh-vo-search-row"><input type="search" class="reh-vo-search-input" placeholder="Search fish.audio (name, style, language)…" autocomplete="off"><button type="button" class="btn-secondary btn-sm reh-vo-search-go">Search</button></div>
<div class="reh-vo-results reh-vo-search-results"><div class="reh-vo-hint">Type a query and hit Search to browse fish.audio voices.</div></div>
</div>
</div>`;
}
// Preview a local library voice by synthesizing a short sample with the chosen backend // Preview a local library voice by synthesizing a short sample with the chosen backend
async function _rehPreviewLocal(voiceId, btn) { async function _rehPreviewLocal(voiceId, btn) {
const backend = $('reh-backend-select')?.value; const backend = $('reh-backend-select')?.value;

View File

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