Show quotation marks in the casting script, with a toggle (v1.20.29)

German prose never quotes narration, so displaying the guillemets makes a
mislabeled line obvious at a glance. Extraction strips them from ~80% of
segments, so they are rendered rather than stored: dialogue rows display
wrapped in » «, lines that kept their own marks are left as-is, and the saved
text is untouched so synthesis and exports are unaffected. A toolbar button
toggles visibility and the choice persists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-12 10:51:46 +02:00
parent ca654608ac
commit 55f634e626
6 changed files with 57 additions and 21 deletions

View File

@ -22,6 +22,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Emotion instructions are now always written in English**, even for non-English voices (the spoken text and the native-accent clause stay in the book's own language). Confirmed by controlled A/B testing — same line, same voice, only the instruct language varying — that Qwen3-TTS follows English emotion instructions far more reliably: German instructs produced barely-differentiated output, while English instructs yield a clean, correctly-ordered prosodic gradient (whisper 128 Hz → sad 142 → neutral 179 → scared 203 → happy 225 → angry 269 Hz), with sensible duration changes too (sad slowest, scared fastest). - **Emotion instructions are now always written in English**, even for non-English voices (the spoken text and the native-accent clause stay in the book's own language). Confirmed by controlled A/B testing — same line, same voice, only the instruct language varying — that Qwen3-TTS follows English emotion instructions far more reliably: German instructs produced barely-differentiated output, while English instructs yield a clean, correctly-ordered prosodic gradient (whisper 128 Hz → sad 142 → neutral 179 → scared 203 → happy 225 → angry 269 Hz), with sensible duration changes too (sad slowest, scared fastest).
- **Fish-Speech generation parameters (`temperature` / `top_p` / `repetition_penalty`) were never forwarded.** Every Fish-Speech line synthesized at the server's fixed defaults, ignoring the app's per-backend stability settings — the only backend not routed through the shared `_apply_tts_extra_params` helper. - **Fish-Speech generation parameters (`temperature` / `top_p` / `repetition_penalty`) were never forwarded.** Every Fish-Speech line synthesized at the server's fixed defaults, ignoring the app's per-backend stability settings — the only backend not routed through the shared `_apply_tts_extra_params` helper.
## [1.20.29] — 2026-08-12
### Added
- **Quotation marks in the casting script, with a toggle.** German prose never quotes narration — only characters speak in `» «` — so showing the marks makes a mislabeled line obvious at a glance. PDF extraction strips them from roughly 80% of segments, so they are now *rendered* rather than stored: dialogue rows are displayed wrapped in `» «` (lines that kept their own marks are left untouched), while the saved text stays exactly as cast so synthesis and exports are unaffected. The quote button in the casting view toggles them on or off, and the choice is remembered.
## [1.20.27] — 2026-08-12 ## [1.20.27] — 2026-08-12
### Fixed ### Fixed

View File

@ -1 +1 @@
1.20.27 1.20.29

File diff suppressed because one or more lines are too long

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

View File

@ -1312,6 +1312,30 @@ function _abRecordColor(rec, name) {
// so the review/preview overlay (audiobookShowPreview) can use it too — the cast // so the review/preview overlay (audiobookShowPreview) can use it too — the cast
// view (audiobookCastView) defines its own roster-coloured version that shadows // view (audiobookCastView) defines its own roster-coloured version that shadows
// this inside its closure. Names default to the current run's roster. // this inside its closure. Names default to the current run's roster.
// Guillemets are the clearest visual cue for "this is spoken" — and in German
// prose narration is never quoted, so showing them makes a mislabeled line
// obvious at a glance. Extraction strips them from most segments, so they are
// rendered rather than stored: the saved text is left exactly as cast, and the
// marks are added for DISPLAY only on dialogue rows that lack them.
let _abShowQuotes = (() => { try { return localStorage.getItem('ab-show-quotes') !== '0'; } catch (_) { return true; } })();
function _abDisplayText(s) {
const t = String(s?.text || '');
if (!_abShowQuotes || s?.type !== 'dialogue' || !t.trim()) return t;
if (/^\s*[»„"']/.test(t)) return t; // already quoted
return '»' + t.trim() + '«';
}
function audiobookToggleQuotes(on) {
_abShowQuotes = (on === undefined) ? !_abShowQuotes : !!on;
try { localStorage.setItem('ab-show-quotes', _abShowQuotes ? '1' : '0'); } catch (_) {}
document.querySelectorAll('.ab-cv-quote-toggle').forEach(b => {
b.classList.toggle('is-on', _abShowQuotes);
b.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks';
});
if (Array.isArray(_audiobook.segments) && _audiobook.segments.length) _abRedrawSegmentsChunked(_audiobook.segments);
return _abShowQuotes;
}
window.audiobookToggleQuotes = audiobookToggleQuotes;
function highlightText(text, names) { function highlightText(text, names) {
if (!text) return ''; if (!text) return '';
let html = escHtml(text); let html = escHtml(text);
@ -1603,6 +1627,7 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:64%"></div></div> <div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:64%"></div></div>
</div> </div>
</div> </div>
<button class="btn-secondary btn-sm ab-cv-quote-toggle" id="ab-cv-quotes" type="button" title="Show quotation marks" style="position:absolute; top:8px; right:12px; z-index:3"><span class="mdi mdi-format-quote-close"></span></button>
<button class="ab-cv-jump-btn" id="ab-cv-jump-btn" hidden title="Jump to latest"><span class="mdi mdi-chevron-double-down"></span> Live</button> <button class="ab-cv-jump-btn" id="ab-cv-jump-btn" hidden title="Jump to latest"><span class="mdi mdi-chevron-double-down"></span> Live</button>
</div> </div>
<div class="ab-cv-side" id="ab-cv-side"> <div class="ab-cv-side" id="ab-cv-side">
@ -2118,7 +2143,9 @@ STRIKTE FORMAT- UND TEXTREGELN:
// the full character sheet (hiding the feed temporarily). // the full character sheet (hiding the feed temporarily).
const feedWrap = panel.querySelector('.ab-cv-feed-wrap'); const feedWrap = panel.querySelector('.ab-cv-feed-wrap');
const jumpBtn = panel.querySelector('#ab-cv-jump-btn'); const jumpBtn = panel.querySelector('#ab-cv-quotes')?.addEventListener('click', () => audiobookToggleQuotes());
{ const _qb = panel.querySelector('#ab-cv-quotes'); if (_qb) { _qb.classList.toggle('is-on', _abShowQuotes); _qb.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; } }
panel.querySelector('#ab-cv-jump-btn');
// Compact toolbar above the feed: selected character, edit history, page nav. // Compact toolbar above the feed: selected character, edit history, page nav.
const _abTopbar = document.createElement('div'); const _abTopbar = document.createElement('div');
@ -2226,7 +2253,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const spk = row.querySelector('.ab-cv-spk'); const spk = row.querySelector('.ab-cv-spk');
const txt = row.querySelector('.ab-cv-txt'); const txt = row.querySelector('.ab-cv-txt');
if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c; if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c;
if (txt) txt.innerHTML = highlightText(seg.text || ''); if (txt) txt.innerHTML = highlightText(_abDisplayText(seg));
}); });
renderRoster(); renderRoster();
const selected = _abBar.hidden ? null : _abBar.dataset.charName; const selected = _abBar.hidden ? null : _abBar.dataset.charName;
@ -2924,7 +2951,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
<button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button> <button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
</span> </span>
<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span> <span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span>
<span class="ab-cv-txt">${highlightText(s.text || '')}</span>`; <span class="ab-cv-txt">${highlightText(_abDisplayText(s))}</span>`;
// No per-row listeners here on purpose — with 1000+ segments in a big book, // No per-row listeners here on purpose — with 1000+ segments in a big book,
// attaching 2 extra listeners per row on every redraw (merge/split rebuilds // attaching 2 extra listeners per row on every redraw (merge/split rebuilds
// the whole feed) was measurably slower. Editing is wired once via event // the whole feed) was measurably slower. Editing is wired once via event
@ -3006,7 +3033,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
} }
} }
ta.replaceWith(txtEl); ta.replaceWith(txtEl);
txtEl.innerHTML = highlightText(s.text || ''); txtEl.innerHTML = highlightText(_abDisplayText(s));
}; };
ta.addEventListener('keydown', e => { ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); } if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); }
@ -3836,7 +3863,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : ''); row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
row.__seg = s; row.__seg = s;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`; row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(_abDisplayText(s))}</span>`;
frag.appendChild(row); frag.appendChild(row);
} }
if (rest.length) { if (rest.length) {
@ -3855,7 +3882,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : ''); row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
row.__seg = s; row.__seg = s;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`; row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(_abDisplayText(s))}</span>`;
f2.appendChild(row); f2.appendChild(row);
} }
next.replaceWith(f2); next.replaceWith(f2);
@ -5364,7 +5391,7 @@ function audiobookShowPreview() {
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}"> ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker"> <input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}> <input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
<div class="audiobook-seg-text">${highlightText(s.text || '')}</div> <div class="audiobook-seg-text">${highlightText(_abDisplayText(s))}</div>
</div>`).join(''); </div>`).join('');
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove()); ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); }); ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });

View File

@ -7319,3 +7319,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
} }
.fish-tag:hover { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .fish-tag:hover { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.fish-tag:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .fish-tag:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Quotation-mark visibility toggle in the casting view */
.ab-cv-quote-toggle.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }