refactor: extract rehearser voice picker
This commit is contained in:
parent
e98c5ffe33
commit
0bfb6d694b
@ -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
|
||||||
|
|||||||
1
TODO.md
1
TODO.md
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
73
static/js/rehearser-voice-picker.js
Normal file
73
static/js/rehearser-voice-picker.js
Normal 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 & 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>`;
|
||||||
|
}
|
||||||
@ -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 & 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;
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user