diff --git a/routes/tts.py b/routes/tts.py
index 3cfee3d..2e351be 100644
--- a/routes/tts.py
+++ b/routes/tts.py
@@ -458,6 +458,27 @@ async def tts_preview(request: Request):
backend = _clean_preview_backend(data.get("backend", "voice_clone"))
settings = _load_settings()
+ # Per-request generation overrides (seed / temperature / top_p). When provided,
+ # force them onto the stability params for this backend so _apply_tts_extra_params
+ # forwards them to the engine (backends that reject them fall back gracefully).
+ _overrides = {}
+ for _k in ("seed", "temperature", "top_p"):
+ _v = data.get(_k)
+ if _v is not None and _v != "":
+ try:
+ _overrides[_k] = int(_v) if _k == "seed" else float(_v)
+ except (TypeError, ValueError):
+ pass
+ if _overrides:
+ from core.config import _tts_extra_params
+ settings = dict(settings)
+ eff = dict(_tts_extra_params(settings, backend) or {})
+ eff.update(_overrides)
+ settings["tts_stability_enabled"] = True
+ by_backend = dict(settings.get("tts_extra_params_by_backend") or {})
+ by_backend[backend] = eff
+ settings["tts_extra_params_by_backend"] = by_backend
+
if data.get("apply_persona"):
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
wav = _find_voice_audio(voice, scan_dir)
@@ -854,3 +875,32 @@ async def openai_speech_proxy(request: Request):
@router.post("/v1")
async def openai_speech_proxy_v1_shortcut(request: Request):
return await openai_speech_proxy(request)
+
+
+@router.post("/api/tts-voice-seed")
+async def tts_voice_seed(request: Request):
+ """Proxy to the faster-qwen3-tts /voice-seed endpoint.
+
+ Writes or clears the seed for a voice in the TTS server's voices.json.
+ Body: {"voice": "EN_F_NatashaNeural", "seed": 7} (seed: null to remove)
+ """
+ data = await request.json()
+ settings = _load_settings()
+ tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
+ try:
+ resp = requests.post(
+ f"{tts_base}/voice-seed",
+ json=data,
+ timeout=10,
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except requests.exceptions.ConnectionError:
+ raise HTTPException(502, "Could not reach TTS server")
+ except requests.exceptions.HTTPError as e:
+ detail = ""
+ try:
+ detail = e.response.json().get("detail", "")
+ except Exception:
+ pass
+ raise HTTPException(e.response.status_code, detail or str(e))
diff --git a/static/js/seed-finder.js b/static/js/seed-finder.js
new file mode 100644
index 0000000..c27f065
--- /dev/null
+++ b/static/js/seed-finder.js
@@ -0,0 +1,213 @@
+// ── Seed Finder — find the best RNG seed for a voice ─────────────────────────
+//
+// Appended to the voice inspector body by voice-inspector.js.
+// Generates audio samples for a voice across a range of seeds so the user
+// can listen and pick the most natural-sounding one, then save it to the
+// TTS server's voices.json with one click.
+
+const SEED_FINDER_TEXT_DE = (
+ 'Die 3.500 neuen High-End Geräte für das Server-Update benötigen eine ' +
+ 'außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance ' +
+ 'bei großer Last zu gewährleisten.'
+);
+const SEED_FINDER_TEXT_EN = (
+ 'The system administrator successfully configured the customized Docker stacks ' +
+ 'and benchmarked the inference engines at exactly 8:45 AM.'
+);
+const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE + ' - ' + SEED_FINDER_TEXT_EN;
+
+function _seedFinderDefaultText(voiceId) {
+ const lc = (voiceId || '').toLowerCase();
+ if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED;
+ if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN;
+ return SEED_FINDER_TEXT_MIXED;
+}
+
+// Called from voice-inspector.js after the persona panel is appended.
+function attachSeedFinder(voiceId, body) {
+ const panel = document.createElement('div');
+ panel.className = 'opt-group seed-finder-group';
+ panel.innerHTML = `
+
+
+ 🎲 Seed Finder
+ Find the most natural-sounding voice
+
+
+ Generates audio for each seed so you can listen and pick the best one.
+ Click "Use this seed" to save it to the TTS server.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Generating…
+ 0 / 0
+
+
+
+
+
+
+ `;
+
+ body.appendChild(panel);
+
+ // Toggle open/closed
+ panel.querySelector('.opt-group-title').addEventListener('click', () => {
+ panel.classList.toggle('open');
+ });
+
+ const textEl = panel.querySelector('.seed-finder-text');
+ const fromEl = panel.querySelector('.seed-finder-from');
+ const toEl = panel.querySelector('.seed-finder-to');
+ const backendEl = panel.querySelector('.seed-finder-backend');
+ const runBtn = panel.querySelector('.seed-finder-run-btn');
+ const cancelBtn = panel.querySelector('.seed-finder-cancel-btn');
+ const statusEl = panel.querySelector('.seed-finder-status');
+ const progressEl = panel.querySelector('.seed-finder-progress');
+ const progLabel = panel.querySelector('.seed-finder-progress-label');
+ const progCount = panel.querySelector('.seed-finder-progress-count');
+ const progBar = panel.querySelector('.seed-finder-bar');
+ const resultsEl = panel.querySelector('.seed-finder-results');
+
+ textEl.value = _seedFinderDefaultText(voiceId);
+
+ let _cancelled = false;
+ let _currentAudio = null;
+
+ function _playBlob(blob) {
+ if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
+ const url = URL.createObjectURL(blob);
+ const audio = new Audio(url);
+ _currentAudio = audio;
+ audio.play().catch(() => {});
+ audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; });
+ }
+
+ function _addResultRow(seed, blob, durationSec, err, activeSeedSet) {
+ const row = document.createElement('div');
+ row.className = 'seed-finder-result-row' + (err ? ' seed-finder-result-error' : '');
+ row.dataset.seed = seed;
+
+ if (err) {
+ row.innerHTML = `
+ Seed ${seed}
+ Failed: ${escHtml(String(err))}
+ `;
+ } else {
+ const durStr = durationSec ? `${durationSec.toFixed(1)}s` : '';
+ row.innerHTML = `
+ Seed ${seed}
+ ${durStr}
+
+
+
+ `;
+ row.querySelector('.sfr-play-btn').addEventListener('click', () => _playBlob(blob));
+ row.querySelector('.sfr-use-btn').addEventListener('click', async () => {
+ // Stop any playback
+ if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
+ try {
+ const resp = await fetch('/api/tts-voice-seed', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ voice: voiceId, seed }),
+ });
+ if (!resp.ok) {
+ const e = await resp.json().catch(() => ({}));
+ throw new Error(e.detail || resp.statusText);
+ }
+ // Clear all other "saved" markers
+ resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = '');
+ resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active'));
+ row.classList.add('sfr-active');
+ row.querySelector('.sfr-saved').textContent = '✓ Saved';
+ if (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success');
+ } catch (e) {
+ if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error');
+ }
+ });
+ }
+
+ resultsEl.appendChild(row);
+ }
+
+ async function _runFinder() {
+ const text = textEl.value.trim();
+ if (!text) { statusEl.textContent = 'Enter a test sentence.'; return; }
+
+ const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1);
+ const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 15);
+ const backend = backendEl.value;
+ const seeds = [];
+ for (let s = seedFrom; s <= seedTo; s++) seeds.push(s);
+ const total = seeds.length;
+
+ _cancelled = false;
+ resultsEl.innerHTML = '';
+ progressEl.style.display = '';
+ runBtn.style.display = 'none';
+ cancelBtn.style.display = '';
+ statusEl.textContent = '';
+ progBar.style.width = '0%';
+ progCount.textContent = `0 / ${total}`;
+ progLabel.textContent = 'Generating…';
+
+ for (let i = 0; i < seeds.length; i++) {
+ if (_cancelled) break;
+ const seed = seeds[i];
+ progLabel.textContent = `Seed ${seed}…`;
+ progCount.textContent = `${i} / ${total}`;
+ progBar.style.width = `${Math.round(i / total * 100)}%`;
+
+ try {
+ const blob = await fetchTtsPreviewBlob(
+ voiceId, text, 'wav', '', backend, false, { seed }
+ );
+ // Estimate duration from WAV byte length: (bytes - 44 header) / (24000 * 2)
+ const durSec = Math.max(0, (blob.size - 44) / (24000 * 2));
+ _addResultRow(seed, blob, durSec, null, false);
+ } catch (e) {
+ _addResultRow(seed, null, 0, e.message || String(e), false);
+ }
+ }
+
+ progBar.style.width = '100%';
+ progCount.textContent = `${seeds.length} / ${total}`;
+ progLabel.textContent = _cancelled ? 'Cancelled.' : 'Done — listen and pick your seed.';
+ runBtn.style.display = '';
+ cancelBtn.style.display = 'none';
+ }
+
+ runBtn.addEventListener('click', _runFinder);
+ cancelBtn.addEventListener('click', () => { _cancelled = true; });
+}
diff --git a/static/js/voice-inspector.js b/static/js/voice-inspector.js
index a352e55..6e592d1 100644
--- a/static/js/voice-inspector.js
+++ b/static/js/voice-inspector.js
@@ -410,6 +410,11 @@ function selectVoice(wrap) {
});
});
+ // ── Seed Finder panel ────────────────────────────────────────────────────
+ if (typeof attachSeedFinder === 'function') {
+ attachSeedFinder(voiceId, body);
+ }
+
// Track extracted elements for restoreToRow
wrap._extracted = [
activeEl ? {el: activeEl, target: detailRow} : null,
diff --git a/static/loader.js b/static/loader.js
index 1120e22..48f7cc8 100644
--- a/static/loader.js
+++ b/static/loader.js
@@ -3,7 +3,7 @@
const SECTIONS = [
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
- 's-performance', 's-routing', 's-connect', 's-settings',
+ 's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
's-llms', 's-conversation',
];
@@ -110,6 +110,7 @@
'/static/js/voice-picker.js',
'/static/js/benchmark-voice-picker.js',
'/static/js/voice-inspector.js',
+ '/static/js/seed-finder.js',
'/static/js/voice-sources.js',
'/static/js/fishaudio-browser.js',
'/static/js/integrations.js',
@@ -122,6 +123,9 @@
'/static/js/stt.js',
'/static/js/rehearser-parse.js',
'/static/js/rehearser.js',
+ '/static/js/reader.js',
+ '/static/js/audiobook.js',
+ '/static/js/character-sheets.js',
];
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
var _bundleOk = false;
diff --git a/static/style.css b/static/style.css
index 75d762a..92c13c3 100644
--- a/static/style.css
+++ b/static/style.css
@@ -60,8 +60,15 @@ body {
}
/* Language picker in the sidebar brand */
-.app-lang-picker { margin-top: 8px; font-size: 12px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); font-family: var(--font); cursor: pointer; width: 100%; }
-.app-lang-picker:focus { outline: none; border-color: var(--accent); }
+.app-lang-toggle {
+ margin-left: auto; padding: 2px; border: 1px solid var(--border); border-radius: 5px;
+ background: var(--panel); cursor: pointer; line-height: 0; display: inline-flex;
+ align-items: center; transition: border-color .15s, transform .1s;
+}
+.app-lang-toggle:hover { border-color: var(--accent); }
+.app-lang-toggle:active { transform: scale(.94); }
+.app-lang-toggle:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.18); }
+.app-lang-toggle .fi { width: 20px; height: 14px; border-radius: 2px; }
/* ── App shell ───────────────────────────────────────────────────────────── */
#app-shell {
@@ -82,6 +89,7 @@ body {
.sidebar-brand h1 {
font-size: 15px; font-weight: 800; color: var(--accent);
letter-spacing: -0.01em; line-height: 1.2;
+ display: flex; align-items: center; gap: 6px;
}
.sidebar-brand p {
font-size: 14px; color: var(--subtext); margin-top: 3px;
@@ -2707,6 +2715,55 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.preview-persona-btn:hover { background: rgba(13,148,136,.12); }
.preview-persona-btn:disabled { opacity: .5; cursor: default; }
+/* ── Seed Finder ─────────────────────────────────────────────────────────── */
+.seed-finder-group { margin-top: 12px; }
+.seed-finder-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
+.seed-finder-config { display: flex; flex-direction: column; gap: 10px; }
+.seed-finder-row { display: flex; flex-direction: column; gap: 5px; }
+.seed-finder-label { font-size: 12px; font-weight: 600; color: var(--subtext); }
+.seed-finder-text {
+ width: 100%; font-size: 13px; padding: 8px 10px;
+ border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--surface); color: var(--text);
+ font-family: var(--font); resize: vertical; min-height: 68px;
+ transition: border-color .15s;
+}
+.seed-finder-text:focus { border-color: var(--accent); outline: none; }
+.seed-finder-params { flex-direction: row; flex-wrap: wrap; align-items: flex-end; gap: 14px; }
+.seed-finder-field { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text); }
+.seed-finder-field input[type="number"] {
+ width: 64px; padding: 5px 7px; font-size: 13px;
+ border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--surface); color: var(--text); text-align: center;
+}
+.seed-finder-field select {
+ padding: 5px 8px; font-size: 13px;
+ border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--surface); color: var(--text);
+}
+.seed-finder-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.seed-finder-status { font-size: 12px; color: var(--subtext); }
+
+.seed-finder-progress { display: flex; flex-direction: column; gap: 6px; }
+.seed-finder-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
+.seed-finder-bar { height: 100%; background: var(--accent); transition: width .2s ease; border-radius: 999px; }
+
+.seed-finder-results { display: flex; flex-direction: column; gap: 6px; }
+.seed-finder-result-row {
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+ padding: 7px 10px; border-radius: var(--radius);
+ border: 1px solid var(--border); background: var(--surface);
+ font-size: 13px;
+}
+.seed-finder-result-row.sfr-active { border-color: var(--accent); background: rgba(var(--accent-rgb, 124,77,255),.07); }
+.seed-finder-result-row.seed-finder-result-error { opacity: .6; }
+.sfr-seed { font-weight: 700; min-width: 58px; color: var(--text); }
+.sfr-dur { color: var(--subtext); font-size: 12px; min-width: 36px; }
+.sfr-play-btn { font-size: 12px; padding: 4px 10px; }
+.sfr-use-btn { font-size: 12px; padding: 4px 10px; }
+.sfr-saved { font-size: 12px; color: var(--green); font-weight: 600; }
+.sfr-error { font-size: 12px; color: var(--red); }
+
/* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
.conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
@@ -3771,3 +3828,246 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
min-height: 46px;
flex: 0 0 auto;
}
+
+/* ── Read Aloud reader ─────────────────────────────────────────────────── */
+.reader-controls {
+ display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end;
+}
+.reader-ctl { margin: 0; min-width: 150px; }
+.reader-ctl-grow { flex: 1 1 200px; }
+.reader-ctl-sm { min-width: 96px; max-width: 130px; }
+.reader-ctl-sm input { width: 100%; }
+.reader-norm-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--subtext); white-space: nowrap; align-self: center; cursor: pointer; }
+.reader-backend-hint { margin-top: 10px; font-size: 12px; color: var(--subtext); display: flex; align-items: flex-start; gap: 6px; }
+.reader-backend-hint .mdi { color: var(--accent); flex-shrink: 0; }
+.reader-backend-hint b { color: var(--text); }
+.reader-backend-hint.reader-hint-warn { color: var(--text); }
+.reader-backend-hint:empty { display: none; }
+.reader-voice-pick { display: flex; gap: 6px; }
+.reader-voice-pick select { flex: 1; min-width: 0; }
+.reader-speed-label { color: var(--accent); font-weight: 700; }
+.reader-ctl input[type=range] { width: 100%; accent-color: var(--accent); }
+.reader-import-btn { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
+
+.reader-dropzone {
+ margin-top: 14px; padding: 30px 16px; border: 2px dashed var(--border);
+ border-radius: 12px; text-align: center; color: var(--subtext);
+ display: flex; flex-direction: column; align-items: center; gap: 5px;
+ transition: border-color .15s, background .15s;
+}
+.reader-dropzone-icon { font-size: 34px; opacity: .5; }
+.reader-dropzone.dragover { border-color: var(--accent); background: rgba(37,99,235,.06); color: var(--accent); }
+.reader-dropzone.dragover .reader-dropzone-icon { opacity: 1; }
+
+.reader-transport {
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
+ margin-top: 14px; padding: 10px 14px; background: var(--surface);
+ border: 1px solid var(--border); border-radius: var(--radius);
+}
+.reader-tb-group { display: flex; gap: 4px; }
+.reader-progress-wrap { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 160px; }
+.reader-progress-track { flex: 1; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; }
+.reader-progress-fill { height: 100%; width: 0; background: var(--accent); transition: width .25s; }
+.reader-progress-label { font-size: 12px; color: var(--subtext); white-space: nowrap; min-width: 56px; text-align: right; }
+.reader-doc-title { font-size: 13px; font-weight: 700; color: var(--text); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* Saved-books library */
+.reader-lib-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
+.reader-lib-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
+.reader-book {
+ position: relative; padding: 14px; border: 1px solid var(--border); border-radius: 10px;
+ background: var(--panel); cursor: pointer; transition: border-color .15s, transform .1s, box-shadow .15s;
+}
+.reader-book:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(0,0,0,.08); transform: translateY(-1px); }
+.reader-book-icon { font-size: 26px; color: var(--accent); line-height: 1; }
+.reader-book-title { font-weight: 700; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.reader-book-meta { font-size: 11px; color: var(--subtext); margin-top: 2px; }
+.reader-book-bar { position: relative; height: 6px; border-radius: 4px; background: var(--border); margin-top: 10px; overflow: hidden; }
+.reader-book-bar-synth { position: absolute; inset: 0 auto 0 0; background: rgba(34,197,94,.45); }
+.reader-book-bar-read { position: absolute; inset: 0 auto 0 0; background: var(--accent); }
+.reader-book-stats { display: flex; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--subtext); margin-top: 5px; }
+.reader-book-del {
+ position: absolute; top: 6px; right: 6px; border: none; background: rgba(0,0,0,.04);
+ color: var(--subtext); border-radius: 6px; width: 24px; height: 24px; cursor: pointer; opacity: 0; transition: opacity .15s;
+}
+.reader-book:hover .reader-book-del { opacity: 1; }
+.reader-book-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.1); }
+
+/* Synthesise-ahead bar + range selection */
+.reader-synthbar {
+ display: flex; align-items: center; flex-wrap: wrap; gap: 10px;
+ margin-top: 10px; padding: 8px 12px; background: var(--surface);
+ border: 1px solid var(--border); border-radius: var(--radius);
+}
+.reader-page-range { display: inline-flex; align-items: center; gap: 6px; }
+.reader-page-inp {
+ width: 56px; padding: 4px 6px; border: 1px solid var(--border);
+ border-radius: 6px; background: var(--panel); color: var(--text); font-family: var(--font);
+}
+#reader-select-toggle.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+.reader-sel-info { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--accent); font-weight: 600; }
+.reader-sel-clear { border: none; background: none; color: var(--subtext); cursor: pointer; font-size: 13px; padding: 0 2px; }
+.reader-sel-clear:hover { color: var(--red, #ef4444); }
+.reader-sel-hint { font-size: 12px; color: var(--subtext); }
+.reader-sel-hint b { color: var(--accent); }
+.reader-export { display: inline-flex; align-items: center; gap: 6px; }
+#reader-audiobook-btn .mdi { color: var(--accent); }
+
+/* Audiobook live casting — floating, non-blocking, minimisable panel */
+.ab-castpanel {
+ position: fixed; right: 18px; bottom: 18px; z-index: 1000;
+ width: min(880px, 96vw); max-height: 82vh; display: flex; flex-direction: column;
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
+ box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden;
+}
+.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); cursor: default; }
+.ab-castpanel-head > .mdi { color: var(--accent); font-size: 18px; }
+.ab-castpanel-title { font-weight: 800; font-size: 14px; }
+.ab-castpanel-count { font-size: 12px; color: var(--subtext); }
+.ab-castpanel-btn { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px 5px; border-radius: 6px; line-height: 1; }
+.ab-castpanel-btn:hover { background: var(--panel); color: var(--text); }
+.ab-castpanel-bar { margin: 0; border-radius: 0; }
+.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); }
+.ab-cv-done { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
+.ab-cv-done .mdi { color: #10b981; }
+/* minimised → just the header chip in the corner */
+.ab-castpanel.minimized { width: auto; max-width: 320px; }
+.ab-castpanel.minimized .ab-castpanel-bar,
+.ab-castpanel.minimized .ab-cv-body,
+.ab-castpanel.minimized .ab-castpanel-foot { display: none; }
+.ab-castpanel.minimized .ab-castpanel-head { cursor: pointer; border-bottom: none; }
+
+.ab-cv-body { display: grid; grid-template-columns: 1fr 220px; gap: 12px; padding: 12px; }
+.ab-cv-feed { height: 44vh; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; background: var(--panel); display: flex; flex-direction: column; gap: 3px; }
+.ab-cv-row { display: grid; grid-template-columns: 130px 1fr; gap: 8px; font-size: 12.5px; padding: 2px 0; align-items: baseline; }
+.ab-cv-row.is-narr { opacity: .6; }
+.ab-cv-spk { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ab-cv-row.is-narr .ab-cv-spk { color: var(--subtext); font-weight: 600; }
+.ab-cv-txt { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); }
+.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; height: 44vh; }
+.ab-cv-side-head { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-bottom: 6px; }
+.ab-cv-chars { display: flex; flex-direction: column; gap: 5px; }
+.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
+.ab-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 20px; }
+.ab-chip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); flex-shrink: 0; }
+.ab-chip b { margin-left: auto; color: var(--subtext); font-weight: 700; }
+.audiobook-narr-note { color: var(--subtext); }
+@media (max-width: 560px) { .ab-cv-body { grid-template-columns: 1fr; } .ab-cv-side { height: auto; max-height: 22vh; } }
+
+/* Audiobook casting overlay */
+.audiobook-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; }
+.audiobook-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px 24px; width: min(420px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,.25); }
+.audiobook-title { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 8px; }
+.audiobook-title .mdi { color: var(--accent); font-size: 20px; }
+.audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; }
+.audiobook-box .reader-synth-track { width: 100%; }
+.audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; }
+.audiobook-count { font-weight: 500; font-size: 12px; color: var(--subtext); margin-left: auto; }
+.audiobook-preview-box { width: min(760px, 94vw); }
+.audiobook-preview-box .audiobook-title { flex-wrap: wrap; }
+.audiobook-seglist { max-height: 56vh; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px; margin-top: 6px; }
+.audiobook-seg { display: grid; grid-template-columns: 130px 96px 1fr; gap: 8px; align-items: center; padding: 4px 6px; border-radius: 6px; }
+.audiobook-seg:nth-child(odd) { background: var(--panel); }
+.audiobook-seg.is-dialog { box-shadow: inset 3px 0 0 var(--accent); }
+.audiobook-seg input { padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); font-size: 12px; font-family: var(--font); min-width: 0; }
+.audiobook-seg input:disabled { opacity: .4; }
+.audiobook-seg-text { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+#reh-tb-audiobook .mdi { color: var(--accent); }
+#reader-charsheets-btn .mdi, #reh-charsheets-btn .mdi { color: var(--accent); }
+
+/* Character sheets overlay */
+.cs-box { width: min(900px, 95vw); max-height: 90vh; display: flex; flex-direction: column; }
+.cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
+.cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; align-content: start; }
+.cs-group-label { grid-column: 1 / -1; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-top: 4px; }
+.cs-card { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--panel); }
+.cs-card.cs-main { box-shadow: inset 4px 0 0 var(--accent); }
+.cs-card.cs-supp { box-shadow: inset 4px 0 0 var(--border); }
+.cs-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
+.cs-name { font-weight: 800; font-size: 15px; }
+.cs-archetype { font-size: 12px; font-weight: 700; color: var(--accent); }
+.cs-tier { margin-left: auto; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; }
+.cs-aliases { font-size: 12px; color: var(--subtext); font-style: italic; margin: 2px 0 6px; }
+.cs-fields { margin: 0; display: flex; flex-direction: column; gap: 5px; }
+.cs-row { display: grid; grid-template-columns: 120px 1fr; gap: 8px; font-size: 12.5px; }
+.cs-row dt { font-weight: 700; color: var(--subtext); }
+.cs-row dd { margin: 0; color: var(--text); }
+.cs-row ul { margin: 0; padding-left: 16px; }
+.cs-sources { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); }
+.cs-sources b { color: var(--text); }
+.cs-sources .mdi { color: var(--accent); }
+.reader-synth-prog { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
+.reader-synth-track { width: 130px; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; }
+.reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; }
+.reader-synth-label { font-size: 12px; color: var(--subtext); min-width: 52px; }
+
+/* Select mode: clickable sentences (pointer, not crosshair — it's click-to-pick) */
+.reader-doc.reader-selecting { box-shadow: inset 0 0 0 2px var(--accent); }
+.reader-doc.reader-selecting .reader-stat { pointer-events: auto; cursor: pointer; }
+.reader-doc.reader-selecting .reader-stat:hover { background: rgba(37,99,235,.16); outline: 1px dashed rgba(37,99,235,.5); }
+.reader-doc.reader-selecting .reader-word { cursor: pointer; }
+.reader-doc.reader-selecting .reader-word:hover { background: rgba(37,99,235,.18); }
+/* committed range */
+.reader-stat.sel { outline: 2px solid var(--accent); outline-offset: 0; background: rgba(37,99,235,.10); }
+.reader-word.in-sel { box-shadow: inset 0 -2px 0 var(--accent); }
+/* the start "anchor" pulses while waiting for the end click */
+.reader-stat.sel-anchor { outline: 2px solid var(--accent); background: rgba(37,99,235,.20); animation: readerAnchorPulse 1s ease-in-out infinite; }
+.reader-word.sel-anchor { box-shadow: inset 0 -2px 0 var(--accent); background: rgba(37,99,235,.22); animation: readerAnchorPulse 1s ease-in-out infinite; }
+@keyframes readerAnchorPulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
+
+/* Zoom toolbar + status legend */
+.reader-toolbar {
+ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;
+ margin-top: 10px; padding: 8px 12px; background: var(--surface);
+ border: 1px solid var(--border); border-radius: var(--radius);
+}
+.reader-zoom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.reader-zoom-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
+.reader-zoom-pct { font-size: 12px; font-weight: 700; min-width: 44px; text-align: center; color: var(--subtext); }
+.reader-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 11px; color: var(--subtext); }
+.reader-leg { display: inline-flex; align-items: center; gap: 5px; }
+.reader-leg::before { content: ''; width: 11px; height: 11px; border-radius: 3px; }
+.reader-leg-pending::before { background: rgba(239,68,68,.45); }
+.reader-leg-synth::before { background: rgba(234,179,8,.65); }
+.reader-leg-ready::before { background: rgba(34,197,94,.55); }
+.reader-leg-reading::before { background: rgba(37,99,235,.6); }
+
+.reader-doc {
+ margin-top: 14px; max-height: 72vh; overflow-y: auto;
+ background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 16px;
+}
+.reader-doc:empty { display: none; }
+.reader-parsing { display: flex; align-items: center; gap: 10px; padding: 24px; color: var(--subtext); font-size: 13px; }
+.reader-doc.two-page { flex-direction: row; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
+.reader-page {
+ position: relative; flex: 0 0 auto;
+ box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff;
+}
+.reader-canvas { display: block; }
+.reader-overlay { position: absolute; inset: 0; pointer-events: none; }
+.reader-hl-word {
+ position: absolute; border-radius: 3px; background: rgba(37,99,235,.30); z-index: 3;
+ box-shadow: 0 0 0 1.5px rgba(37,99,235,.75); transition: left .12s, top .12s, width .12s, height .12s;
+}
+/* Per-sentence synthesis-state overlay (PDF) */
+.reader-stat { position: absolute; border-radius: 3px; z-index: 1; transition: background .2s; }
+.reader-stat-pending { background: rgba(239,68,68,.12); }
+.reader-stat-synth { background: rgba(234,179,8,.30); }
+.reader-stat-ready { background: rgba(34,197,94,.18); }
+.reader-stat-reading { background: rgba(37,99,235,.20); z-index: 2; }
+
+.reader-text {
+ max-width: 760px; background: var(--surface); color: var(--text);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 28px 34px; line-height: 1.85; font-size: 17px;
+}
+.reader-text-para { margin: 0 0 1em; }
+.reader-word { border-radius: 3px; padding: 0 1px; cursor: pointer; transition: background .12s; }
+/* Per-sentence synthesis-state tint (text) */
+.reader-word.stat-pending { background: rgba(239,68,68,.09); }
+.reader-word.stat-synth { background: rgba(234,179,8,.22); }
+.reader-word.stat-ready { background: rgba(34,197,94,.14); }
+.reader-word.stat-reading { background: rgba(37,99,235,.15); }
+.reader-word.is-word { background: rgba(37,99,235,.42) !important; box-shadow: 0 0 0 1px rgba(37,99,235,.6); }