diff --git a/CHANGELOG.md b/CHANGELOG.md index 7449c49..f32efc5 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-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 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 diff --git a/TODO.md b/TODO.md index cf967fd..5b1a171 100644 --- a/TODO.md +++ b/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`); a full style/lint policy remains a follow-up. - [ ] 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`. - [ ] Extract Audiobook parsing and casting. - [ ] Split `style.css` by feature. diff --git a/VERSION b/VERSION index 80dd64f..e1b911a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.25 +1.24.26 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f6e22b..28f0900 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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-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-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-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. diff --git a/static/index.html b/static/index.html index 09fedb4..1658b43 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-voice-picker.js b/static/js/rehearser-voice-picker.js new file mode 100644 index 0000000..4ae9d8d --- /dev/null +++ b/static/js/rehearser-voice-picker.js @@ -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 = ``; + const right = isCurrent + ? ` Selected` + : ``; + return `
${play} +
${escHtml(name)}${escHtml(meta)}
+ ${right}
`; +} + +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 = `
No voice assignedpick one below
`; + } + 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 `
+
Voice — try & change + ${alts.length ? `` : ''}
+
${curRow}
+ ${alts.length ? `` : ''} +
+ + +
+ + +
`; +} diff --git a/static/js/rehearser.js b/static/js/rehearser.js index a2cc8db..9c3af70 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -1002,84 +1002,6 @@ function castAvatarHtml(sp) { return `${initial}`; } -// ── 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 = ``; - const right = isCurrent - ? ` Selected` - : ``; - return `
${play} -
${escHtml(name)}${escHtml(meta)}
- ${right}
`; -} - -// 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 = `
No voice assignedpick one below
`; - } - - // 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 `
-
Voice — try & change - ${alts.length ? `` : ''}
-
${curRow}
- ${alts.length ? `` : ''} -
- - -
- - -
`; -} - // 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; diff --git a/static/module-manifest.json b/static/module-manifest.json index 14fc179..65c1e86 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -4,9 +4,9 @@ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage"], - "s-reader": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "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-caststudio": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], + "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", "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", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } }