From 4c686165b32ff46b5c8c77ca790c00a618fe6760 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 12 Aug 2026 08:40:38 +0200 Subject: [PATCH] Add targeted dialogue-repair pass and auto-repair chain, fix rollback on stopped runs (v1.20.16) Spoken lines that PDF extraction typed as narration were invisible to "Identify unknown characters" (it only scans type==='dialogue'), so no number of re-runs could fix them, while the pass that could re-sent all ~1000 narration segments to the LLM. Adds a detector that scores narration for direct-speech signals and re-checks only those: 36 candidates instead of 1036 on a real novel. Adds "Auto-repair cast" to chain the passes in the right order automatically after casting. Also fixes a rollback gap where stopping a run skipped the safety net that undoes a pass which increased Unknown speakers (confirmed live: 80 -> 159 with no warning), corrects an attribution rule that taught the model the listener is the speaker, and stops the threshold loop from re-verifying every narration segment each round. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++ VERSION | 2 +- static/dist/main.min.js | 36 ++++++--- static/index.html | 6 +- static/js/audiobook.js | 171 +++++++++++++++++++++++++++++++++++----- 5 files changed, 193 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e60377..70ce0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ 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). - **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.16] — 2026-08-12 + +### Added +- **"Repair mislabeled dialogue"** — a new casting pass for spoken lines that PDF extraction typed as narration (usually because the `» «` quote marks were lost). These were invisible to "Identify unknown characters", which only scans `type === 'dialogue'`, so re-running it could never fix them; the only pass that could ("Verify all characters") re-sent every narration segment to the LLM. The new pass scores narration for direct-speech signals (a bare inquit in the next segment, stray quote marks, imperative opening, 1st/2nd person, ending in `!`/`?`) and re-checks only the suspicious ones — measured on a real novel: **36 candidates instead of 1036, 29x fewer LLM calls**. +- **"Auto-repair cast"** — chains the repair sequence automatically (repair mislabeled → resolve unknown, repeated while it still helps, then a full verification only if still above target). Runs on its own after a completed cast when more than 10 Unknown speakers remain; never after a stopped run. Disable via `localStorage['ab-auto-repair-enabled'] = '0'`. +- **German speaker-attribution rules 13–19** in both the casting and verification prompts: listener/vocative exclusion (a name beside a quote is often the listener, not the speaker), split quotes with pronoun inquits, ranked disambiguation among multiple candidates, continuation over ping-pong, unnamed actors as speakers, epithet resolution ("die Elfe" → Nyrilla), and splitting segments that fuse narration with dialogue or two speakers' turns. + +### Fixed +- **A stopped casting run could silently leave the cast worse than before.** The automatic rollback that undoes a pass which increases Unknown speakers was gated behind `!_audiobook.cancel`, so stopping a run skipped it entirely — confirmed live: an interrupted pass took a book from 80 Unknown lines to 159 with no warning and no way back. Rollback now applies to interrupted runs too. +- **Corrected a misleading attribution rule.** Rule 4 told the model that whoever acts before *or after* a quote is usually the speaker, but in German prose the neighbouring name is frequently the listener ("Marcian spürte ihre Tränen", "Marcian hörte darüber hinweg") or the person being addressed. It now applies only to active verbs, with perception/reaction verbs marking the listener. +- **"Run until < N unknown" re-sent every narration segment to the LLM on every iteration** (`includeNarrator`), making each round enormously expensive and re-litigating lines that were already correct. It now uses the targeted repair pass. +- Identify Characters menu reordered to the order the passes should actually be run in: identify all → repair mislabeled → identify unknown → run until threshold → verify all → check voice consistency. + ## [1.20.5] — 2026-08-11 ### Added diff --git a/VERSION b/VERSION index 95393fc..a46bb8e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.20.8 +1.20.16 diff --git a/static/dist/main.min.js b/static/dist/main.min.js index e0eddd4..02204f7 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -1175,7 +1175,7 @@ ${lines.trim()} `,confirmOverlay.addEventListener("click",ce=>ce.stopPropagation()),confirmOverlay.querySelector("#btn-cancel-del").addEventListener("click",ce=>{ce.stopPropagation(),confirmOverlay.remove()}),confirmOverlay.querySelector("#btn-confirm-del").addEventListener("click",async ce=>{ce.stopPropagation(),confirmOverlay.innerHTML='';try{if(!(await fetch(`${READER_API}/${el.dataset.id}`,{method:"DELETE"})).ok)throw new Error("Failed to delete book");toast("Book deleted","success"),readerRenderLibrary()}catch(err){toast(err.message,"error"),confirmOverlay.remove()}}),el.appendChild(confirmOverlay)}),el.addEventListener("dragover",e=>{e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.style.borderColor="var(--accent)")}),el.addEventListener("dragleave",()=>{el.style.borderColor=""}),el.addEventListener("drop",async e=>{var _a2;if(!(e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")))return;e.preventDefault(),e.stopPropagation(),el.style.borderColor="";const f=(_a2=e.dataTransfer.files)==null?void 0:_a2[0];if(!f||!f.type.startsWith("image/")){toast("Please drop an image file","error");return}try{const id=el.dataset.id,r=await fetch(`${READER_API}/${id}/cover`,{method:"PUT",body:f});if(!r.ok)throw new Error(r.statusText);await fetch(`${READER_API}/${id}/progress`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({updated:new Date().toISOString()})}),toast("Cover image updated","success"),readerRenderLibrary()}catch(err){toast("Failed to upload cover: "+(err.message||err),"error")}})}),list.querySelectorAll(".reader-book-del").forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation(),confirm("Delete this saved document and its audio?")&&readerDeleteLibraryDoc(btn.dataset.del)}))}(_Kb=$("reader-file-input"))==null||_Kb.addEventListener("change",function(){var _a2;const f=(_a2=this.files)==null?void 0:_a2[0];this.value="",readerImportFile(f)}),(_Lb=$("reader-paste-load"))==null||_Lb.addEventListener("click",readerImportPastedText),(_Mb=$("reader-paste-text"))==null||_Mb.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="Enter"&&(e.preventDefault(),readerImportPastedText())}),(_Nb=$("reader-fetch-voices-btn"))==null||_Nb.addEventListener("click",readerFetchVoices),(_Ob=$("reader-backend-select"))==null||_Ob.addEventListener("change",()=>{const sel=$("reader-voice-select");sel&&(sel.innerHTML=''),window.VoicePicker&&(VoicePicker.upgrade("reader-voice-select"),VoicePicker.populate("reader-voice-select",[])),readerUpdateBackendHint()}),(_Pb=$("reader-chunk-mode"))==null||_Pb.addEventListener("change",function(){readerRechunk(this.value)}),(_Qb=$("reader-normalize"))==null||_Qb.addEventListener("change",function(){readerState.normalize=this.checked}),(_Rb=$("reader-play"))==null||_Rb.addEventListener("click",()=>{readerState.playing?readerPause():readerPlay()}),(_Sb=$("reader-stop"))==null||_Sb.addEventListener("click",()=>{readerStopPlayback(),readerState.idx=0,readerSaveResume(),readerUpdateProgress()}),(_Tb=$("reader-prev"))==null||_Tb.addEventListener("click",()=>readerJumpTo(readerState.idx-1)),(_Ub=$("reader-next"))==null||_Ub.addEventListener("click",()=>readerJumpTo(readerState.idx+1)),(_Vb=$("reader-speed"))==null||_Vb.addEventListener("input",function(){readerState.speed=parseFloat(this.value)||1;const lbl=$("reader-speed-label");lbl&&(lbl.textContent=readerState.speed.toFixed(2).replace(/0$/,"")+"\xD7"),readerState.currentSource&&(readerState.currentSource.playbackRate.value=readerState.speed)}),(_Wb=$("reader-zoom-fitw"))==null||_Wb.addEventListener("click",()=>readerApplyZoom("fit-width")),(_Xb=$("reader-zoom-fith"))==null||_Xb.addEventListener("click",()=>readerApplyZoom("fit-height")),(_Yb=$("reader-zoom-two"))==null||_Yb.addEventListener("click",()=>readerApplyZoom("two")),(_Zb=$("reader-zoom-in"))==null||_Zb.addEventListener("click",()=>{readerState.scale=Math.min(4,readerState.scale*1.2),readerApplyZoom("custom")}),(__b=$("reader-zoom-out"))==null||__b.addEventListener("click",()=>{readerState.scale=Math.max(.2,readerState.scale/1.2),readerApplyZoom("custom")});let _readerSearchTerm="",_readerSearchHits=[],_readerSearchCurrent=-1,_readerSearchTimer=null;const READER_SEARCH_MAX_HITS=2500;function readerSearchTextMap(words){var _a2;let text="";const spans=[];for(let i=0;isp.end>pos&&sp.startsp.wordIndex);idxs.length&&groups.push(idxs),pos=Math.max(pos+1,end)}return groups}function readerRemoveSearchHighlights(){document.querySelectorAll(".reader-search-hit").forEach(el=>el.remove()),document.querySelectorAll(".reader-word.search-hit, .reader-word.search-current").forEach(el=>{el.classList.remove("search-hit","search-current")})}function readerSetSearchStatus(text){const st=$("reader-search-status");st&&(st.textContent=text||"")}function readerClearSearch({clearInput=!1}={}){if(_readerSearchTerm="",_readerSearchHits=[],_readerSearchCurrent=-1,readerRemoveSearchHighlights(),readerSetSearchStatus(""),clearInput){const inp=$("reader-search");inp&&(inp.value="")}}function readerCollectSearchHits(term){var _a2,_b2,_c2;const hits=[],sents=readerState.sentences||[];for(let si=0;si=READER_SEARCH_MAX_HITS)return hits}}return hits}function readerPaintSearchHighlights(){if(readerRemoveSearchHighlights(),!_readerSearchTerm||!_readerSearchHits.length)return;const scale=readerState.scale||1;_readerSearchHits.forEach((hit,hi)=>{var _a2;const s=readerState.sentences[hit.sentenceIndex];if(s){hit.nodes=[];for(const wi of hit.wordIndexes){const w=(_a2=s.words)==null?void 0:_a2[wi];if(w)if(readerState.mode==="pdf"){const pg=readerState.pages[w.page];if(!(pg!=null&&pg.overlay))continue;const el=document.createElement("div");el.className="reader-search-hit"+(hi===_readerSearchCurrent?" search-current":""),Object.assign(el.style,{left:w.x*scale+"px",top:w.top*scale+"px",width:Math.max(w.w,2)*scale+"px",height:Math.max(w.h,2)*scale+"px"}),pg.overlay.appendChild(el),hit.nodes.push(el)}else w.el&&(w.el.classList.add("search-hit"),hi===_readerSearchCurrent&&w.el.classList.add("search-current"),hit.nodes.push(w.el))}}})}function readerUpdateSearchStatus(){var _a2,_b2;if(!_readerSearchTerm){readerSetSearchStatus("");return}if(!((_a2=readerState.sentences)!=null&&_a2.length)){readerSetSearchStatus("No text loaded");return}if(!_readerSearchHits.length){readerSetSearchStatus("0/0");return}const current=_readerSearchCurrent>=0?_readerSearchCurrent+1:1,hit=_readerSearchHits[Math.max(0,_readerSearchCurrent)],capped=_readerSearchHits.length>=READER_SEARCH_MAX_HITS?"+":"",page=readerState.mode==="pdf"?" \xB7 p."+(((_b2=hit==null?void 0:hit.page)!=null?_b2:0)+1):"";readerSetSearchStatus(current+"/"+_readerSearchHits.length+capped+page)}function readerJumpToSearchHit(idx){var _a2;if(!_readerSearchHits.length)return;_readerSearchCurrent=(idx+_readerSearchHits.length)%_readerSearchHits.length,readerPaintSearchHighlights(),readerUpdateSearchStatus();const hit=_readerSearchHits[_readerSearchCurrent],node=(_a2=hit==null?void 0:hit.nodes)==null?void 0:_a2[0];if(node)readerEnsureVisible(node);else if(readerState.mode==="pdf"){const pg=readerState.pages[hit.page];pg!=null&&pg.pageDiv&&(readerRenderPage(hit.page),pg.pageDiv.scrollIntoView({behavior:"smooth",block:"center"}))}}function readerSearchApply(term,{jump=!0}={}){if(term=String(term||"").trim(),!term){readerClearSearch();return}_readerSearchTerm=term,_readerSearchHits=readerCollectSearchHits(term),_readerSearchCurrent=_readerSearchHits.length?0:-1,readerPaintSearchHighlights(),readerUpdateSearchStatus(),jump&&_readerSearchHits.length&&readerJumpToSearchHit(0)}function readerSearchStep(direction){var _a2;const term=((_a2=$("reader-search"))==null?void 0:_a2.value.trim())||_readerSearchTerm;if(!_readerSearchHits.length||term!==_readerSearchTerm){readerSearchApply(term,{jump:!0});return}readerJumpToSearchHit(_readerSearchCurrent+(direction===-1?-1:1))}(_$b=$("reader-search"))==null||_$b.addEventListener("keydown",e=>{e.key==="Enter"&&(e.preventDefault(),readerSearchStep(e.shiftKey?-1:1)),e.key==="Escape"&&readerClearSearch({clearInput:!0})}),(_ac=$("reader-search"))==null||_ac.addEventListener("input",e=>{clearTimeout(_readerSearchTimer);const term=e.target.value.trim();if(!term){readerClearSearch();return}_readerSearchTimer=setTimeout(()=>readerSearchApply(term,{jump:!0}),160)}),(_bc=$("reader-search-prev"))==null||_bc.addEventListener("click",()=>readerSearchStep(-1)),(_cc=$("reader-search-next"))==null||_cc.addEventListener("click",()=>readerSearchStep(1)),(_dc=$("reader-search-clear"))==null||_dc.addEventListener("click",()=>readerClearSearch({clearInput:!0})),(_ec=$("reader-save-lib"))==null||_ec.addEventListener("click",readerSaveLibrary),(_fc=$("reader-export-btn"))==null||_fc.addEventListener("click",()=>{var _a2;return readerExport(((_a2=$("reader-export-mode"))==null?void 0:_a2.value)||"page")}),(_gc=$("reader-synth-all"))==null||_gc.addEventListener("click",readerSynthAll),(_hc=$("reader-synth-cancel"))==null||_hc.addEventListener("click",()=>{readerState.synthCancel=!0}),(_ic=$("reader-select-toggle"))==null||_ic.addEventListener("click",()=>{readerState.selecting?readerSetSelecting(!1):(readerClearSelection(),readerSetSelecting(!0))}),(_jc=$("reader-sel-clear"))==null||_jc.addEventListener("click",readerClearSelection),document.addEventListener("keydown",e=>{e.key==="Escape"&&readerState.selecting&&readerSetSelecting(!1)}),(_kc=$("reader-page-from"))==null||_kc.addEventListener("change",readerUpdateScopeLabel),(_lc=$("reader-page-to"))==null||_lc.addEventListener("change",readerUpdateScopeLabel),(_mc=$("reader-doc"))==null||_mc.addEventListener("click",e=>{if(!readerState.selecting||readerState.mode!=="pdf")return;const stat=e.target.closest(".reader-stat");if(!stat)return;const si=parseInt(stat.dataset.si);isNaN(si)||readerPickSentence(si)}),(_nc=$("reader-doc"))==null||_nc.addEventListener("scroll",function(){let t=null;return()=>{t||(t=setTimeout(()=>{t=null,readerRenderVisible()},120))}}()),window.addEventListener("resize",function(){let t=null;return()=>{readerState.mode!=="pdf"||readerState.zoomMode==="custom"||(clearTimeout(t),t=setTimeout(()=>readerApplyZoom(readerState.zoomMode),200))}}()),function(){["reader-dropzone","reader-doc"].map(id=>$(id)).filter(Boolean).forEach(el=>{el.addEventListener("dragover",e=>{e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.classList.add("dragover"))}),el.addEventListener("dragleave",()=>el.classList.remove("dragover")),el.addEventListener("drop",e=>{var _a2;e.dataTransfer&&[...e.dataTransfer.types||[]].includes("Files")&&(e.preventDefault(),el.classList.remove("dragover"),readerImportFile((_a2=e.dataTransfer.files)==null?void 0:_a2[0]))})})}(),window.showReaderView=function(view){const main=$("reader-main-view"),cast=$("reader-audiobook-panel"),chars=$("reader-charsheets-panel"),lib=$("reader-library-card");main&&(main.hidden=view!=="main"),cast&&(cast.hidden=view!=="cast",view!=="cast"&&(cast.style.display="",cast.style.flexDirection="",cast.style.minHeight=""),view==="cast"&&!cast.innerHTML.trim()&&(cast.innerHTML=`

No active casting session

Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.


`,cast.className="ab-castpanel-inline card")),chars&&(chars.hidden=view!=="chars",view==="chars"&&!chars.innerHTML.trim()&&(chars.innerHTML=`
Character sheets

This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.

`,chars.className="")),lib&&(lib.hidden=view!=="library",view==="library"&&(lib.classList.remove("card"),lib.style.boxShadow="none",lib.style.border="none",lib.style.background="transparent",readerRenderLibrary())),typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs(view==="cast"?"cast":view==="chars"?"chars":"source")},window.navReaderView=function(view){window.showReaderView(view)},window.readerJumpToPage=function(pageNum){const pg=parseInt(pageNum,10);if(!pg)return Promise.resolve(!1);typeof navTo=="function"&&navTo("s-reader"),window.showReaderView("main");let tries=0;const waitFrame=()=>new Promise(resolve=>requestAnimationFrame(resolve)),jump=async()=>{var _a2;await waitFrame();const pageState=(_a2=readerState.pages)==null?void 0:_a2[pg-1],pageDiv=pageState==null?void 0:pageState.pageDiv;return pageDiv&&!pageDiv.hidden?(await readerRenderPage(pg-1),pageDiv.scrollIntoView({behavior:"smooth",block:"start",inline:"nearest"}),readerRenderVisible(),!0):tries++<20?(await new Promise(resolve=>setTimeout(resolve,100)),jump()):(typeof toast=="function"&&toast("Source page is not loaded in Reader yet","info"),!1)};return jump()},window.readerOnShow=async function(){const sel=$("reader-backend-select");if(sel&&(!sel.value||sel.options.length<=1)){if(typeof availableTtsBackends=="function"&&!availableTtsBackends().length&&typeof refreshTtsBackendAvailability=="function")try{await refreshTtsBackendAvailability()}catch{}typeof availableTtsBackends=="function"&&availableTtsBackends().length&&(sel.innerHTML=ttsBackendOptions(sel.value),sel.disabled=!1)}readerUpdateBackendHint(),window.VoicePicker&&VoicePicker.upgrade("reader-voice-select"),readerRenderLibrary(),window._readerStartView&&(window.showReaderView(window._readerStartView),window._readerStartView=null),_readerEnsureImportVisible()};function _readerEnsureImportVisible(){if(readerState.sentences.length)return;const card=document.getElementById("reader-config-card"),body=card?card.querySelector(".card-col-body"):null;body&&body.hidden&&(body.hidden=!1,card.classList.remove("card-col-closed"))}const AUDIOBOOK_CHUNK_CHARS=3e3,AUDIOBOOK_WARMUP_TIMEOUT_MS=24e4,AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS=6e5,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS=3e5,AUDIOBOOK_RECAST_TIMEOUT_MS=24e4,AUDIOBOOK_RECAST_CONTEXT_CHARS=4200,AUDIOBOOK_RECAST_TARGETS_PER_CALL=6,AUDIOBOOK_DRAFT_AUTOSAVE_MS=5*60*1e3,_audiobook={running:!1,cancel:!1};window._audiobook=_audiobook;let _abDraftAutosaveTimer=null,_abLastServerDraftErrorAt=0;async function audiobookFetchWithTimeout(url,options={},timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const parentSignal=options.signal,ac=new AbortController;let timedOut=!1;const timer=setTimeout(()=>{timedOut=!0,ac.abort()},timeoutMs),onParentAbort=()=>ac.abort(parentSignal==null?void 0:parentSignal.reason);parentSignal&&(parentSignal.aborted?onParentAbort():parentSignal.addEventListener("abort",onParentAbort,{once:!0}));try{return await fetch(url,{...options,signal:ac.signal})}catch(err){if(timedOut){const timeoutErr=new Error(`Timed out after ${Math.ceil(timeoutMs/1e3)}s`);throw timeoutErr.name="TimeoutError",timeoutErr}throw err}finally{clearTimeout(timer),parentSignal&&parentSignal.removeEventListener("abort",onParentAbort)}}function audiobookTimeoutSeconds(timeoutMs){return Math.max(5,Math.round(timeoutMs/1e3))}async function audiobookAttributeStream(body,view,outerSignal,idleTimeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/attribute-dialogue/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify({...body,want_reasoning:!0})});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(` -`))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&(view!=null&&view.thinking)&&view.thinking(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}catch(err){throw(err==null?void 0:err.name)==="AbortError"&&!(outerSignal&&outerSignal.aborted)?new Error("stream idle timeout"):err}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}function audiobookDialogueKey(text){return String(text||"").normalize("NFKC").replace(/[»«„“”"‘’'`]+/g,"").replace(/\s+/g," ").trim().toLowerCase()}function audiobookSegmentText(seg){return seg?seg.type==="narration"||!seg.speaker||/^Unknown|Unbekannt/i.test(seg.speaker)?seg.text||"":`"${seg.text||""}"`:""}function _abWordRangeAtPoint(x,y){let node=null,offset=0;if(document.caretRangeFromPoint){const r=document.caretRangeFromPoint(x,y);if(!r)return null;node=r.startContainer,offset=r.startOffset}else if(document.caretPositionFromPoint){const p=document.caretPositionFromPoint(x,y);if(!p)return null;node=p.offsetNode,offset=p.offset}else return null;if(!node||node.nodeType!==3)return null;const text=node.nodeValue||"",isW=ch=>ch!=null&&/[\p{L}\p{N}'’-]/u.test(ch);if(offset>=text.length&&(offset=text.length-1),!isW(text[offset]))if(offset>0&&isW(text[offset-1]))offset--;else return null;let a=offset,b=offset;for(;a>0&&isW(text[a-1]);)a--;for(;b+1!s||s.type==="narration"||/^Unknown|Unbekannt/i.test(s.speaker||""));!cur.length||shortGap&&cur.lengthsegs.slice(start,end).map(audiobookSegmentText).join(" ").trim();let text=render();for(;text.length>AUDIOBOOK_RECAST_CONTEXT_CHARS&&(starttargetEnd)&&(end>targetEnd&&end--,text=render(),!(text.length<=AUDIOBOOK_RECAST_CONTEXT_CHARS));)start=end?"after":"near"}: ${s.speaker}: ${(s.text||"").slice(0,100)}`)}return lines.slice(-12).join(` +`))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&(view!=null&&view.thinking)&&view.thinking(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}catch(err){throw(err==null?void 0:err.name)==="AbortError"&&!(outerSignal&&outerSignal.aborted)?new Error("stream idle timeout"):err}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}function audiobookDialogueKey(text){return String(text||"").normalize("NFKC").replace(/[»«„“”"‘’'`]+/g,"").replace(/\s+/g," ").trim().toLowerCase()}function audiobookSegmentText(seg){return seg?seg.type==="narration"||!seg.speaker||/^Unknown|Unbekannt/i.test(seg.speaker)?seg.text||"":`"${seg.text||""}"`:""}function _abWordRangeAtPoint(x,y){let node=null,offset=0;if(document.caretRangeFromPoint){const r=document.caretRangeFromPoint(x,y);if(!r)return null;node=r.startContainer,offset=r.startOffset}else if(document.caretPositionFromPoint){const p=document.caretPositionFromPoint(x,y);if(!p)return null;node=p.offsetNode,offset=p.offset}else return null;if(!node||node.nodeType!==3)return null;const text=node.nodeValue||"",isW=ch=>ch!=null&&/[\p{L}\p{N}'’-]/u.test(ch);if(offset>=text.length&&(offset=text.length-1),!isW(text[offset]))if(offset>0&&isW(text[offset-1]))offset--;else return null;let a=offset,b=offset;for(;a>0&&isW(text[a-1]);)a--;for(;b+1{var _a3;return String(((_a3=segs[i])==null?void 0:_a3.text)||"").replace(/\s+/g," ").trim()},out=new Set;for(let i=0;i=minScore&&out.add(i)}return out}window._abSuspectNarration=_abSuspectNarration;function audiobookRecastGroups(unknownIdxs,segs){const groups=[];let cur=[];for(const idx of unknownIdxs){const prev=cur.length?cur[cur.length-1]:-1/0,shortGap=idx<=prev+2&&segs.slice(prev+1,idx).every(s=>!s||s.type==="narration"||/^Unknown|Unbekannt/i.test(s.speaker||""));!cur.length||shortGap&&cur.lengthsegs.slice(start,end).map(audiobookSegmentText).join(" ").trim();let text=render();for(;text.length>AUDIOBOOK_RECAST_CONTEXT_CHARS&&(starttargetEnd)&&(end>targetEnd&&end--,text=render(),!(text.length<=AUDIOBOOK_RECAST_CONTEXT_CHARS));)start=end?"after":"near"}: ${s.speaker}: ${(s.text||"").slice(0,100)}`)}return lines.slice(-12).join(` `)}function audiobookFindReturnedSegment(targetSeg,returned,used){const targetKey=audiobookDialogueKey(targetSeg==null?void 0:targetSeg.text);if(!targetKey)return null;let loose=null;for(let i=0;i=18&&(candKey.includes(targetKey)||targetKey.includes(candKey))&&(loose=loose||{idx:i,seg:cand})}return loose}function audiobookFindReturnedSegmentSequence(targetSeg,returned,used){const targetKey=audiobookDialogueKey(targetSeg==null?void 0:targetSeg.text);if(!targetKey)return null;for(let i=0;ireturned[k])};if(key.length>targetKey.length*1.35+40)break}}return null}function _abStr(v){return v==null?"":typeof v=="string"?v:Array.isArray(v)?v.filter(Boolean).join(", "):String(v)}const _AB_DRAFT_KEY="ttsvc_ab_draft";function _abBookId(){return window.readerState&&readerState.savedId||_audiobook.bookId||null}function _abDraftKey(bookId){return bookId?_AB_DRAFT_KEY+"_"+bookId:_AB_DRAFT_KEY}function _abTextId(text){const s=(text.slice(0,300)+text.slice(-300)).replace(/\s+/g,"");let h=5381;for(let i=0;i>>0;return h.toString(36)+"_"+text.length}function _abSafeFilename(name,fallback="cast"){return(String(name||fallback||"cast").replace(/[\\/:*?"<>|]+/g,"_").replace(/\s+/g,"_").replace(/^_+|_+$/g,"")||fallback||"cast").slice(0,120)}function _abCastMarkdown(data){const payload=data||{},segments=Array.isArray(payload.segments)?payload.segments:[],speakers=[...new Set(segments.filter(s=>(s==null?void 0:s.type)==="dialogue"&&s.speaker).map(s=>String(s.speaker)))].sort(),lines=[`# ${payload.title||"Cast Script"}`,""],metaBits=[];payload.savedAt&&metaBits.push("exported "+new Date(payload.savedAt).toISOString().slice(0,16).replace("T"," ")),metaBits.push(`${speakers.length} character${speakers.length!==1?"s":""}`),metaBits.push(`${segments.length} segment${segments.length!==1?"s":""}`),lines.push(`*${metaBits.join(" \xB7 ")}*`,""),speakers.length&&lines.push(`**Characters:** ${speakers.join(", ")}`,"");let lastPage=null;for(const seg of segments){const text=String((seg==null?void 0:seg.text)||"").trim();if(text){if(seg.page!=null&&seg.page!==lastPage&&(lines.push("---","",`## Page ${seg.page}`,""),lastPage=seg.page),seg.type==="dialogue"){const speaker=String(seg.speaker||"Narrator"),tag=seg.emotion?` *(${seg.emotion})*`:"";lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`)}else lines.push(text);lines.push("")}}return lines.join(` `)}function _abStampSegmentPages(segs,pageMarks,text){if(!Array.isArray(segs)||!segs.length||segs.some(s=>s&&s.page!=null))return;const marks=(pageMarks||[]).slice().sort((a,b)=>(a.offset||0)-(b.offset||0)),src=text||"";if(!marks.length||!src)return;let markIdx=0,searchPos=0,cur=null;marks[0].offset<=2&&(cur=marks[0].page+1,markIdx=1);for(const s of segs){const probe=String(s.text||"").trim().slice(0,24),at=probe?src.indexOf(probe,searchPos):-1,pos=at>=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)cur=marks[markIdx].page+1,markIdx++;cur!=null&&(s.page=cur)}}let _abLastDraftSaveAt=0;const AB_DRAFT_SAVE_THROTTLE_MS=4e3;function _abSaveDraft(segs,roster,text,done,total){var _a2;const isFinal=total>0&&done>=total,now=Date.now();if(!isFinal&&now-_abLastDraftSaveAt{if(!r.ok)throw new Error(r.statusText||`HTTP ${r.status}`)}).catch(err=>{console.warn("[audiobook] server draft autosave failed:",err);const now2=Date.now();now2-_abLastServerDraftErrorAt>6e4&&(_abLastServerDraftErrorAt=now2,typeof toast=="function"&&toast("Server autosave failed. Browser draft is still saved.","error"))})}let _abExportBusy=!1;async function audiobookExportCastMd(){var _a2;if(_abExportBusy)return;_abExportBusy=!0,setTimeout(()=>{_abExportBusy=!1},2e3);const segs=_audiobook.segments||[];if(!segs.length){toast("No cast to export","error");return}const bookId=_abBookId();_audiobook.lastText&&_abSaveDraft(segs,_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0);const title=((_a2=window.readerState)==null?void 0:_a2.title)||_audiobook.title||"audiobook";if(bookId)try{const r=await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast/export`);if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const blob2=await r.blob(),match=(r.headers.get("Content-Disposition")||"").match(/filename="([^"]+)"/i),name=(match==null?void 0:match[1])||`${_abSafeFilename(title)}_cast.zip`;if(typeof readerDownload=="function")readerDownload(blob2,name);else{const a=document.createElement("a");a.href=URL.createObjectURL(blob2),a.download=name,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),500)}toast("Exported cast + character sheets (.zip)","success");return}catch{toast("Server export failed, downloading local cast copy","error")}const payload={bookId,title,textId:_audiobook.lastText?_abTextId(_audiobook.lastText):"",segments:segs,roster:_audiobook.roster||[],pageMarks:_audiobook.pageMarks||[],rehId:_audiobook.rehId||null,done:_audiobook.completedChunks||0,total:_audiobook.completedTotal||0,savedAt:Date.now()},blob=new Blob([_abCastMarkdown(payload)],{type:"text/markdown;charset=utf-8"});if(typeof readerDownload=="function")readerDownload(blob,`${_abSafeFilename(title)}_cast.md`);else{const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=`${_abSafeFilename(title)}_cast.md`,a.click(),setTimeout(()=>URL.revokeObjectURL(a.href),500)}}function _abStartDraftAutosave(saveNow){if(_abStopDraftAutosave(),typeof saveNow!="function")return;_abDraftAutosaveTimer=setInterval(saveNow,AUDIOBOOK_DRAFT_AUTOSAVE_MS);const saveOnLeave=()=>saveNow();_audiobook._draftSaveOnLeave=saveOnLeave,window.addEventListener("pagehide",saveOnLeave),window.addEventListener("beforeunload",saveOnLeave)}function _abStopDraftAutosave(){_abDraftAutosaveTimer&&clearInterval(_abDraftAutosaveTimer),_abDraftAutosaveTimer=null,_audiobook._draftSaveOnLeave&&(window.removeEventListener("pagehide",_audiobook._draftSaveOnLeave),window.removeEventListener("beforeunload",_audiobook._draftSaveOnLeave),_audiobook._draftSaveOnLeave=null)}async function _abEnsureLibraryBook(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2;const rs=window.readerState;if(!rs||rs.savedId||!((_a2=rs.sentences)!=null&&_a2.length))return!!(rs!=null&&rs.savedId);if(rs.mode==="pdf"&&!rs.fileBlob)return!1;const meta={title:rs.title||"Untitled",kind:rs.mode,idx:rs.idx,voice:((_b2=$("reader-voice-select"))==null?void 0:_b2.value)||"",backend:((_c2=$("reader-backend-select"))==null?void 0:_c2.value)||"",speed:rs.speed,instruct:((_d2=$("reader-instruct"))==null?void 0:_d2.value.trim())||"",chunkMode:rs.chunkMode,seed:((_e2=$("reader-seed"))==null?void 0:_e2.value.trim())||"",temperature:((_f2=$("reader-temp"))==null?void 0:_f2.value.trim())||"",tts_speed:parseFloat((_g2=$("reader-tts-speed"))==null?void 0:_g2.value)||1,normalize:rs.normalize,sentenceCount:rs.sentences.length,pageCount:((_h2=rs.pages)==null?void 0:_h2.length)||0,synthCount:0,updated:new Date().toISOString()};try{const r=await fetch("/api/reader/docs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(meta)});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);rs.savedId=(await r.json()).id,_audiobook.bookId=rs.savedId;const ext=rs.mode==="pdf"?"pdf":"txt",body=rs.mode==="pdf"?rs.fileBlob:new Blob([rs.docText||audiobookScopeText()||""],{type:"text/plain"});return(await fetch(`/api/reader/docs/${encodeURIComponent(rs.savedId)}/source?ext=${ext}`,{method:"PUT",body})).ok&&(rs.sourceUploaded=!0),typeof readerRenderLibrary=="function"&&readerRenderLibrary(),!0}catch(err){return console.warn("[audiobook] could not create reader-library autosave target:",err),!1}}async function _abLoadDraftServer(bookId){if(!bookId)return null;for(let attempt=0;attempt<2;attempt++)try{const r=await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`);if(!r.ok){if(attempt===0&&(r.status===404||r.status>=500)){await new Promise(resolve=>setTimeout(resolve,650));continue}return null}const d=await r.json();return d&&Array.isArray(d.segments)&&d.segments.length?d:null}catch{attempt===0&&await new Promise(resolve=>setTimeout(resolve,650))}return null}function _abLoadDraft(text){var _a2;const bookId=_abBookId(),textId=_abTextId(text),title=String(((_a2=window.readerState)==null?void 0:_a2.title)||"").trim().toLowerCase();try{if(bookId){const raw2=localStorage.getItem(_abDraftKey(bookId));if(raw2){const d=JSON.parse(raw2);if(d&&Array.isArray(d.segments)&&d.segments.length)return d}}const raw=localStorage.getItem(_AB_DRAFT_KEY);if(raw){const d=JSON.parse(raw);if(d&&Array.isArray(d.segments)&&d.segments.length&&d.textId===textId)return d}let best=null;for(let i=0;ibest.score)&&(best={score,draft:cand})}catch{}}if(best!=null&&best.draft)return best.draft}catch{return null}return null}function _abClearDraft(){const bookId=_abBookId();try{localStorage.removeItem(_abDraftKey(bookId))}catch{}bookId&&fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`,{method:"DELETE"}).catch(()=>{})}const AB_DIALOGUE_RE=/[«»„“”"‟‚‘’『「<]|(?:^|\n)\s*[—–]\s/;function audiobookHasDialogue(t){return AB_DIALOGUE_RE.test(t||"")}function audiobookDehyphenate(t){return(t||"").replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g,"$1")}const AB_SPEECH_VERBS="(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|fl\xFCsterte|wisperte|raunte|murmelte|brummte|knurrte|br\xFCllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)",AB_NOTNAME=new Set(["Der","Die","Das","Den","Dem","Ein","Eine","Einen","Er","Sie","Es","Ich","Du","Wir","Ihr","Man","Und","Aber","Da","Dann","Doch","So","Nun","Jetzt","The","He","She","It","They","A","An","And","But","Then","Now","Sofort","Pl\xF6tzlich","Endlich","Schlie\xDFlich","Stille","Schweigen","Stimme","Stimmen","Frage","Antwort","Gel\xE4chter","Wieder","Gleich","Sogleich","Langsam","Leise","Laut","Kaum","Vielleicht","Nat\xFCrlich","Wirklich","Ja","Nein","Komm","Warte","Halt","Geh","Hier","Dort","Oben","Unten","Schon","Noch","Auch","Nur","Immer","Nie"]),_AB_NAME="([A-Z\xC4\xD6\xDC][A-Za-z\xE4\xF6\xFC\xDF'\\-]+)",AB_PERSON_NOUNS=new Set(["Mann","Frau","Junge","M\xE4dchen","Alte","Alter","Fremde","Fremder","Krieger","Kriegerin","W\xE4chter","Wache","Soldat","Hauptmann","Ork","Ritter","Magier","Magierin","Zwerg","Elf","Elfe","H\xE4ndler","Wirt","Wirtin","Bauer","Priester","Priesterin","Nachbar","Nachbarin","Sklave","Sklavin","Verweser","Inquisitor","General","K\xF6nig","K\xF6nigin","Prinz","Prinzessin","F\xFCrst","F\xFCrstin","Baron","Baronin","Bote","Diener","Dienerin","Knabe","Kind","Reiter","Reiterin","Bogensch\xFCtze","Schmied","Schmiedin","Heiler","Heilerin","Gelehrte","Gelehrter","Kapit\xE4n","Anf\xFChrer","Anf\xFChrerin"]);function audiobookResolveUnknowns(segs,prevTail,roster){var _a2,_b2;const isUnknown=s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)),isNamed=s=>(s==null?void 0:s.type)==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt|Narrator$/i.test(s.speaker),names=(roster||[]).filter(n=>n&&!/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a,b)=>b.length-a.length),lastNameIn=text=>{let best=null,bestAt=-1;for(const n of names){const at=text.lastIndexOf(n);at>bestAt&&(bestAt=at,best=n)}if(best)return best;const clause=text.match(new RegExp(_AB_NAME+"[^.!?:]{0,80}\\b(?:und\\s+)?(?:"+AB_SPEECH_VERBS+")[^:]{0,60}:\\s*$"));if(clause&&!AB_NOTNAME.has(clause[1])&&!AB_PERSON_NOUNS.has(clause[1]))return clause[1];const m=[...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)].map(x=>x[1]).filter(n=>AB_PERSON_NOUNS.has(n));return m.length?m[m.length-1]:null},all=[...prevTail||[],...segs],offset=(prevTail||[]).length,resolved=[];for(let i=offset;i=0&&j>=i-6&&prevDialogues.length<2&&!(((_a2=all[j])==null?void 0:_a2.page)!=null&&s.page!=null&&all[j].page!==s.page);j--)if(((_b2=all[j])==null?void 0:_b2.type)==="dialogue"){if(!isNamed(all[j])){prevDialogues.length=0;break}prevDialogues.push(all[j].speaker)}prevDialogues.length===2&&prevDialogues[0]!==prevDialogues[1]&&(who=prevDialogues[1])}who&&!/^(Narrator|Unknown|Unbekannt)$/i.test(who)&&(s.speaker=who,resolved.push(who))}return resolved}const _AB_TAG_NAME="[A-Z\xC4\xD6\xDC][A-Za-z\xE4\xF6\xFC\xDF'\\-]+",AB_SPEECH_TAG_ONLY_RE=new RegExp("^\\s*[,.;:!?\u2013-]*\\s*(?:(?:"+AB_SPEECH_VERBS+")\\b\\s+(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|"+_AB_TAG_NAME+")|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|"+_AB_TAG_NAME+")\\s+(?:"+AB_SPEECH_VERBS+")\\b)(?:\\s+(?:mit|in|leise|laut|kalt|heiser|erstickt|ruhig|zornig|w\xFCtend|\xE4ngstlich|sp\xF6ttisch|ver\xE4chtlich|fragend|fl\xFCsternd|schrill|dumpf|slowly|coldly|quietly|softly|angrily|hoarsely)\\b[\\s\\S]*)?[.!?\u2026]*\\s*$","i");function audiobookIsSpeechTagOnly(text){const t=String(text||"").trim();return!t||t.length>180||/[»«„“”"‟‚‘’『「]/.test(t)?!1:AB_SPEECH_TAG_ONLY_RE.test(t)}function audiobookGuessSpeaker(after,before){let m;const ok=n=>n&&!AB_NOTNAME.has(n)?n:null;if(m=new RegExp("^[\\s,;\u2013-]*"+AB_SPEECH_VERBS+"\\s+(?:der|die|das|ein|eine)?\\s*"+_AB_NAME).exec(after||"")){const r=ok(m[1]);if(r)return r}if(m=new RegExp("^[\\s,;\u2013-]*"+_AB_NAME+"\\s+"+AB_SPEECH_VERBS).exec(after||"")){const r=ok(m[1]);if(r)return r}if(m=new RegExp(_AB_NAME+"\\s+"+AB_SPEECH_VERBS+"[\\s:,\u2013-]*$").exec(before||"")){const r=ok(m[1]);if(r)return r}return null}const AB_QUOTE_PAIRS={"\xBB":"\xAB","\xAB":"\xBB","\u201E":"\u201C","\u201C":"\u201D",'"':'"',"\u300C":"\u300D","\u300E":"\u300F","\u2018":"\u2019","\u201A":"\u2018"},_AB_UNCLOSED_TAG_RE=new RegExp("^\\s*(?:[,;:\u2013-]\\s*)?(?:"+AB_SPEECH_VERBS+"|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+"+AB_SPEECH_VERBS+")\\b","i");function _abUnclosedQuoteEnd(raw,closeIdx){const limit=closeIdx>=0?closeIdx:raw.length;for(let i=0;i40)return m[1].length}return closeIdx>=0?closeIdx:raw.length}function _abOrphanClosingQuoteSpan(text,closeIdx,minStart){var _a2;if(!"\xAB\u201D\u2019\u2039\u300D\u300F".includes(text[closeIdx]))return null;const before=text.slice(minStart,closeIdx),prev=((_a2=before.match(/\S(?=\s*$)/))==null?void 0:_a2[0])||"";if(!/[.!?]/.test(prev))return null;const trimmedLen=before.trimEnd().length;let localStart=0;const boundaryRe=/[\n\r]|[.!?:]\s+/g;let m;for(;m=boundaryRe.exec(before);){const next=m.index+m[0].length;nextch&&(AB_QUOTE_OPENERS+AB_QUOTE_CLOSERS).includes(ch);for(let i=0;is.text.trim())}function _audiobookMergeAdjacentSameSpeaker(segments){const out=[];for(const s of segments){const last=out[out.length-1];if(last&&last.type===s.type&&last.page===s.page){const lastSpeaker=(last.speaker||"Narrator").trim().toLowerCase(),curSpeaker=(s.speaker||"Narrator").trim().toLowerCase(),sameEmotion=s.type!=="dialogue"||(last.emotion||"").toLowerCase()===(s.emotion||"").toLowerCase();if(lastSpeaker===curSpeaker&&sameEmotion){const lastText=last.text.trimEnd(),curText=s.text.trimStart();last.text=/[.!?…»«”"’']$/.test(lastText)?lastText+` @@ -1292,7 +1292,7 @@ ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv \u2014 arbeite w 1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". L\xF6se Pronomen (er/sie) IMMER zum tats\xE4chlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts. 2. Doppelpunkt-Regel: Endet ein Erz\xE4hlersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" \u2192 der zuvor genannte Charakter spricht das n\xE4chste Zitat; "Ein anderer fragte verschlafen:" \u2192 dieser andere spricht). 3. Nachgestellte Zuordnung: Der Erz\xE4hlersatz NACH einem Zitat verr\xE4t oft den Sprecher \u2014 auch bei unpers\xF6nlicher Formel: "\xBBWas machst du denn da?\xAB ert\xF6nte es \xFCber ihm. Karyla hatte ihren Hammer weggelegt und war her\xFCbergekommen." \u2192 Karyla sprach das Zitat. -4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat handelt, spricht meist (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"; "Ver\xE4chtlich sah sie ihn an. \xBBDas wirst du schon noch merken.\xAB" \u2192 sie spricht, und "sie" ist die zuletzt genannte Frau). +4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist \u2014 ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (sp\xFCrte, h\xF6rte, f\xFChlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUH\xD6RER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"; "Ver\xE4chtlich sah sie ihn an. \xBBDas wirst du schon noch merken.\xAB" \u2192 sie spricht, und "sie" ist die zuletzt genannte Frau). 5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" \u2192 X spricht das n\xE4chste Zitat, und Y ist der wahrscheinlichste Antwortende. 6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab \u2014 auch \xFCber viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette l\xFCckenlos zur\xFCck zur letzten eindeutigen Nennung und f\xFChre sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch. 7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters? @@ -1301,6 +1301,13 @@ ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv \u2014 arbeite w 10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln absolut unm\xF6glich ist. Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung gegen\xFCber 'Unknown'. 11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende \u2014 die Rede geht danach unver\xE4ndert weiter, bis das tats\xE4chliche schlie\xDFende Anf\xFChrungszeichen erscheint. 12. Stimm-Ank\xFCndigung: Erw\xE4hnt ein Erz\xE4hlersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), geh\xF6rt diese Zeile dieser Person \u2014 nicht 'Unknown'. +13. ZUH\xD6RER-AUSSCHLUSS (negative Evidenz \u2014 sehr wichtig): Ein Name im Erz\xE4hlsatz neben einem Zitat ist H\xC4UFIG der Zuh\xF6rer, nicht der Sprecher. Schlie\xDFe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian sp\xFCrte ihre Tr\xE4nen auf seiner Brust; Marcian h\xF6rte dar\xFCber hinweg \u2192 Marcian H\xD6RT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: M\xF6gen die G\xF6tter dich sch\xFCtzen, Marcian. \u2192 Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr \xFCbrig bleibt, suche WEITER ZUR\xDCCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) \u2014 der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt f\xFCr sich allein NIEMALS 'Unknown'. +16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER \u2014 das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." \u2192 das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13). +17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") \u2014 niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt. +18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe \u2192 "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt. +19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. \xBBIch werde jetzt ...\xAB"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf \u2014 siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergn\xFCgen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen. +14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er \u2192 letzter Mann, sie \u2192 letzte Frau). Solche Zeilen d\xFCrfen niemals 'Unknown' bleiben. +15. MEHRERE KANDIDATEN in der N\xE4he: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuh\xF6rer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegen\xFCber jemandem in Objekt- oder Pr\xE4positionalstellung; (c) pr\xFCfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zul\xE4ssig. F\xDCR JEDES SEGMENT GIBST DU FOLGENDES AUS: - speaker: 'Narrator' f\xFCr Narration/Erz\xE4hlertext, oder den EXAKTEN Namen des Charakters f\xFCr gesprochene Dialoge. @@ -1327,7 +1334,7 @@ STRIKTE FORMAT- UND TEXTREGELN: 1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". L\xF6se Pronomen (er/sie) IMMER zum tats\xE4chlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts. 2. Doppelpunkt-Regel: Endet ein Erz\xE4hlersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" \u2192 der zuvor genannte Charakter spricht das n\xE4chste Zitat; "Ein anderer fragte verschlafen:" \u2192 dieser andere spricht). 3. Nachgestellte Zuordnung: Der Erz\xE4hlersatz NACH einem Zitat verr\xE4t oft den Sprecher \u2014 auch bei unpers\xF6nlicher Formel: "\xBBWas machst du denn da?\xAB ert\xF6nte es \xFCber ihm. Karyla hatte ihren Hammer weggelegt und war her\xFCbergekommen." \u2192 Karyla sprach das Zitat. -4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat handelt, spricht meist (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"; "Ver\xE4chtlich sah sie ihn an. \xBBDas wirst du schon noch merken.\xAB" \u2192 sie spricht, und "sie" ist die zuletzt genannte Frau). +4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist \u2014 ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (sp\xFCrte, h\xF6rte, f\xFChlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUH\xD6RER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"; "Ver\xE4chtlich sah sie ihn an. \xBBDas wirst du schon noch merken.\xAB" \u2192 sie spricht, und "sie" ist die zuletzt genannte Frau). 5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" \u2192 X spricht das n\xE4chste Zitat, und Y ist der wahrscheinlichste Antwortende. 6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab \u2014 auch \xFCber viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette l\xFCckenlos zur\xFCck zur letzten eindeutigen Nennung und f\xFChre sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch. 7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters? @@ -1342,8 +1349,15 @@ GRAMMATIK-REGELN F\xDCR NARRATION: - Satzfragmente mit f\xFChrendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenst\xE4ndige Dialoge; sie geh\xF6ren als narration zum Erz\xE4hlertext.`)),/ABSATZ- UND KAPITELSTRUKTUR/.test(p)||(p=p.replace('Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog).',`Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog). ABSATZ- UND KAPITELSTRUKTUR: -Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitel\xFCberschrift \u2014 immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenst\xE4ndige narration-Segmente oder als Teil des umgebenden Erz\xE4hler-Segments bei; erfinde daraus keinen Dialog und l\xF6sche sie nicht aus dem rekonstruierten Text.`)),!/Gedankenstrich-Pause-Regel/.test(p)){const newRules=`11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende \u2014 die Rede geht danach unver\xE4ndert weiter, bis das tats\xE4chliche schlie\xDFende Anf\xFChrungszeichen erscheint. -12. Stimm-Ank\xFCndigung: Erw\xE4hnt ein Erz\xE4hlersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), geh\xF6rt diese Zeile dieser Person \u2014 nicht 'Unknown'.`,unknownRuleRe=/(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/;unknownRuleRe.test(p)?p=p.replace(unknownRuleRe,`$1 +Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitel\xFCberschrift \u2014 immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenst\xE4ndige narration-Segmente oder als Teil des umgebenden Erz\xE4hler-Segments bei; erfinde daraus keinen Dialog und l\xF6sche sie nicht aus dem rekonstruierten Text.`)),!/Gedankenstrich-Pause-Regel/.test(p)||!/ZUHÖRER-AUSSCHLUSS/.test(p)||!/FORTSETZUNGS-REGEL/.test(p)||!/EPITHETA/.test(p)){const newRules=`11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende \u2014 die Rede geht danach unver\xE4ndert weiter, bis das tats\xE4chliche schlie\xDFende Anf\xFChrungszeichen erscheint. +12. Stimm-Ank\xFCndigung: Erw\xE4hnt ein Erz\xE4hlersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), geh\xF6rt diese Zeile dieser Person \u2014 nicht 'Unknown'. +13. ZUH\xD6RER-AUSSCHLUSS (negative Evidenz \u2014 sehr wichtig): Ein Name im Erz\xE4hlsatz neben einem Zitat ist H\xC4UFIG der Zuh\xF6rer, nicht der Sprecher. Schlie\xDFe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian sp\xFCrte ihre Tr\xE4nen auf seiner Brust; Marcian h\xF6rte dar\xFCber hinweg \u2192 Marcian H\xD6RT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: M\xF6gen die G\xF6tter dich sch\xFCtzen, Marcian. \u2192 Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr \xFCbrig bleibt, suche WEITER ZUR\xDCCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) \u2014 der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt f\xFCr sich allein NIEMALS 'Unknown'. +16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER \u2014 das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." \u2192 das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13). +17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") \u2014 niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt. +18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe \u2192 "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt. +19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. \xBBIch werde jetzt ...\xAB"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf \u2014 siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergn\xFCgen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen. +14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er \u2192 letzter Mann, sie \u2192 letzte Frau). Solche Zeilen d\xFCrfen niemals 'Unknown' bleiben. +15. MEHRERE KANDIDATEN in der N\xE4he: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuh\xF6rer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegen\xFCber jemandem in Objekt- oder Pr\xE4positionalstellung; (c) pr\xFCfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zul\xE4ssig.`,unknownRuleRe=/(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/;unknownRuleRe.test(p)?p=p.replace(unknownRuleRe,`$1 ${newRules}`):p+=` ${newRules}`}return/gehören IMMER zum Dialog-Segment/.test(p)||(p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!",`- Anf\xFChrungszeichen geh\xF6ren IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schlie\xDFende \xAB am Ende einer Figurenrede geh\xF6rt ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das \xF6ffnende \xBB am Anfang einer Figurenrede geh\xF6rt an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen \xBB, \u201E, \u201A oder \u203A beginnen oder enden, und NIE mit einem einzelnen \xAB, ", ' oder \u2039 enden oder beginnen \u2014 verschiebe das Zeichen ins richtige Nachbar-Segment. - Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!`)),p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.audiobook_prompt?_appSettings.audiobook_prompt:AB_DEFAULT_PROMPT,globalPrompt=normalizeCastingPrompt(rawPrompt);globalPrompt!==rawPrompt&&typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=globalPrompt,fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:globalPrompt})}).catch(()=>{})),panel.querySelector("#ab-cv-prompt-text").value=globalPrompt;const closePanel=()=>{if(panel.hidden=!0,panel.innerHTML="",typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1),typeof window.navReaderView=="function")window.navReaderView("main");else if(typeof window.showReaderView=="function")window.showReaderView("main");else{const mv=document.getElementById("reader-main-view");mv&&(mv.hidden=!1)}},setStoppingState=()=>{const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.disabled=!0,cancelBtn.innerHTML=' Stopping...',cancelBtn.title="Stopping the current casting request");const status2=panel.querySelector("#ab-cv-status-msg");status2&&(status2.style.display="inline-block",status2.textContent="Stopping... completed passages will stay saved.")};panel.querySelector("#ab-cv-cancel").addEventListener("click",()=>{if(_audiobook.running){_audiobook.cancel=!0,setStoppingState(),typeof _audiobook.abort=="function"&&_audiobook.abort();return}closePanel()}),panel.querySelector("#ab-cv-prompt-btn").addEventListener("click",()=>{const p=panel.querySelector("#ab-cv-prompt-panel");p.hidden=!p.hidden;const chevron=panel.querySelector("#ab-cv-prompt-chevron");chevron&&(chevron.className=p.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")}),function(){const tBtn=panel.querySelector("#ab-cv-settings-toggle"),sBox=panel.querySelector("#ab-cv-settings"),chev=panel.querySelector("#ab-cv-settings-chevron");function apply(collapsed2){if(sBox&&(sBox.style.display=collapsed2?"none":"flex"),collapsed2){const p=panel.querySelector("#ab-cv-prompt-panel");p&&(p.hidden=!0)}chev&&(chev.className="mdi "+(collapsed2?"mdi-chevron-down":"mdi-chevron-up"))}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_settings_collapsed")==="1"}catch{}apply(collapsed),tBtn&&tBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_settings_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}(),function(){const side=panel.querySelector("#ab-cv-side"),body=panel.querySelector(".ab-cv-body"),cBtn=panel.querySelector("#ab-cv-side-collapse");function apply(collapsed2){side&&side.classList.toggle("is-collapsed",collapsed2),body&&body.classList.toggle("side-collapsed",collapsed2),cBtn&&(cBtn.querySelector(".mdi").className="mdi "+(collapsed2?"mdi-chevron-left":"mdi-chevron-right")),cBtn&&(cBtn.title=collapsed2?"Expand character list":"Collapse to avatars")}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_side_collapsed")==="1"}catch{}apply(collapsed),cBtn&&cBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_side_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}();let savedPrompts=[];try{savedPrompts=JSON.parse(localStorage.getItem("ttsvc_ab_prompts")||"[]")}catch{savedPrompts=[]}const libSelect=panel.querySelector("#ab-cv-prompt-lib"),delBtn=panel.querySelector("#ab-cv-prompt-del"),promptText=panel.querySelector("#ab-cv-prompt-text"),renderPromptLib=(selectedIdx=-1)=>{libSelect.innerHTML=''+savedPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="block"):(libSelect.value="",delBtn.style.display="none")};renderPromptLib(),libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value),nameInput=panel.querySelector("#ab-cv-prompt-name");!isNaN(idx)&&savedPrompts[idx]?(promptText.value=savedPrompts[idx].prompt,nameInput&&(nameInput.value=savedPrompts[idx].name),delBtn.style.display="block"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedPrompts.splice(idx,1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(),toast("Prompt deleted","success"))}),panel.querySelector("#ab-cv-prompt-save").addEventListener("click",async()=>{const val=promptText.value.trim();if(!val){toast("Prompt is empty","error");return}const name=panel.querySelector("#ab-cv-prompt-name").value.trim()||"Custom Prompt "+(savedPrompts.length+1);let targetIdx=parseInt(libSelect.value);!isNaN(targetIdx)&&savedPrompts[targetIdx]&&savedPrompts[targetIdx].name===name?savedPrompts[targetIdx].prompt=val:(savedPrompts.push({name,prompt:val}),targetIdx=savedPrompts.length-1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(targetIdx),toast("Prompt preset saved","success"),typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=val);try{await fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:val})})}catch{}}),(()=>{const localSel=panel.querySelector("#ab-cv-llm-url"),globalSel=document.getElementById("llm-active-url");if(globalSel&&localSel){const currentVal=localSel.value;localSel.innerHTML=globalSel.innerHTML,localSel.value=currentVal||globalSel.value||""}})();const loadModels=()=>{const urlInput2=panel.querySelector("#ab-cv-llm-url"),sel=panel.querySelector("#ab-cv-llm-select");if(!sel||!urlInput2)return;const currentUrl=urlInput2.value.trim(),oldVal=sel.value;sel.innerHTML='',fetch("/api/conversation/llm-models"+(currentUrl?"?url="+encodeURIComponent(currentUrl):"")).then(r=>r.json()).then(d=>{if(d.models&&d.models.length){const preferred=audiobookSafeLlmModel(oldVal||defaultModel);sel.innerHTML=d.models.map(m=>``).join(""),preferred&&d.models.includes(preferred)?sel.value=preferred:oldVal&&d.models.includes(oldVal)&&!audiobookIsRouterModel(oldVal)?sel.value=oldVal:d.models.includes(defaultModel)&&(sel.value=defaultModel)}else sel.innerHTML=``}).catch(()=>{sel.innerHTML=``})};loadModels(),panel.querySelector("#ab-cv-llm-refresh").addEventListener("click",loadModels);const urlInput=panel.querySelector("#ab-cv-llm-url");urlInput&&urlInput.addEventListener("change",loadModels);const applyPromptAndRun=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{callback&&callback(savedChoice.url,savedChoice.model)})};if(isIdle){const hasSegs=_audiobook.segments&&_audiobook.segments.length>0;if(panel.querySelector("#ab-cv-status-msg").style.display="inline-block",hasSegs){const recastUnkBtn=panel.querySelector("#ab-cv-start-recast-unk"),recastBtn=panel.querySelector("#ab-cv-start-recast");recastUnkBtn.style.display="inline-block",recastBtn.style.display="inline-block",recastUnkBtn.addEventListener("click",()=>applyPromptAndRun(audiobookRecastUnknown)),recastBtn.addEventListener("click",async()=>{await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})&&applyPromptAndRun(audiobookCast)}),panel.querySelector("#ab-cv-status-msg").textContent="Ready to identify characters."}else{const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>applyPromptAndRun(audiobookCast))}}else panel.querySelector("#ab-cv-cancel").innerHTML=' Stop Casting';const fill=panel.querySelector("#ab-cv-fill"),count=panel.querySelector("#ab-cv-count"),feed=panel.querySelector("#ab-cv-feed"),chars=panel.querySelector("#ab-cv-chars"),roster=new Map,characterRecords=new Map,identityNames=recOrSheet=>{const s=(recOrSheet==null?void 0:recOrSheet.sheet)||recOrSheet||{},out=new Set,add=(v,opts={})=>clSplitIdentityTokens(v,opts).forEach(x=>out.add(x));return add((recOrSheet==null?void 0:recOrSheet.name)||s.name),["aliases","first_name","last_name","full_name","title"].forEach(k=>add(s[k],{aliases:k==="aliases"})),[...out]};let _hlVer=0,_hlCache={ver:-1,rosterSize:-1,regex:null,byLower:new Map};const registerCharacterRecord=rec=>{if(rec!=null&&rec.name){_hlVer++;for(const n of identityNames(rec))characterRecords.set(n.toLowerCase(),rec)}},recordForName=name=>characterRecords.get(String(name||"").toLowerCase())||null,colorFor=(name,rec)=>{const key=String(name||"").toLowerCase(),stored=rec||characterRecords.get(key),color=stored?_abRecordColor(stored,name):_abDefaultCharacterColor(name);return roster.has(name)?roster.get(name).color=color:roster.set(name,{count:0,color}),roster.get(name).color},_abAvatarHtml=(name,color)=>{var _a3;const img=(_a3=recordForName(name))==null?void 0:_a3.image,c=color||colorFor(name);return img?``:`${escHtml((name||"?")[0].toUpperCase())}`},setCharacterColor=(name,color)=>{const safe=_abNormalizeColor(color,name),info=roster.get(name);info&&(info.color=safe);const key=String(name||"").toLowerCase(),rec=characterRecords.get(key);return rec&&(rec.color=safe,rec.sheet&&(rec.sheet.color=safe)),safe},highlightText2=text=>{if(!text)return"";let html=escHtml(text);if(_hlCache.ver!==_hlVer||_hlCache.rosterSize!==roster.size){const extraNames=[];new Set([...characterRecords.values()]).forEach(rec=>extraNames.push(...identityNames(rec)));const seen=new Set,names=[];for(const n of[...roster.keys(),...extraNames]){const k=n.toLowerCase();n.length<2||k==="narrator"||/^unknown|unbekannt/i.test(k)||seen.has(k)||(seen.add(k),names.push(n))}names.sort((a,b)=>b.length-a.length);const pattern=names.map(n=>n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");_hlCache={ver:_hlVer,rosterSize:roster.size,regex:pattern?new RegExp(`\\b(${pattern})\\b`,"gi"):null,byLower:new Map(names.map(n=>[n.toLowerCase(),n]))}}return _hlCache.regex&&(html=html.replace(_hlCache.regex,match=>{const canon=_hlCache.byLower.get(match.toLowerCase())||match;return`${match}`})),html},feedWrap=panel.querySelector(".ab-cv-feed-wrap"),jumpBtn=panel.querySelector("#ab-cv-jump-btn"),_abTopbar=document.createElement("div");_abTopbar.className="ab-cv-topbar",feedWrap.insertBefore(_abTopbar,feedWrap.firstChild);const _abBar=document.createElement("div");_abBar.className="ab-char-bar",_abBar.hidden=!0,_abTopbar.appendChild(_abBar);const _abEditToolbar=document.createElement("div");_abEditToolbar.className="ab-edit-toolbar",_abEditToolbar.innerHTML=` @@ -1442,7 +1456,7 @@ ${newRules}`}return/gehören IMMER zum Dialog-Segment/.test(p)||(p=p.replace("-

           
         
-      `,this._procRow.querySelector(".ab-cv-expand-btn").addEventListener("click",function(){const split=this.closest(".ab-cv-llm-row").querySelector(".ab-cv-llm-split"),open=this.getAttribute("aria-expanded")==="true";this.setAttribute("aria-expanded",String(!open)),split.hidden=open;const icon=this.querySelector("span");icon&&(icon.className=open?"mdi mdi-chevron-right":"mdi mdi-chevron-down")}),_abPage().appendChild(this._procRow),trim()},thinking(delta){if(!this._procRow||!delta||this._thinkDone)return;this._thinkRaw=(this._thinkRaw||"")+delta;const think=this._procRow.querySelector(".ab-cv-think"),label=this._procRow.querySelector(".ab-cv-think-label");if(!think)return;const openAt=this._thinkRaw.indexOf("");let shown,isReal=!1,closeAt=-1;if(openAt>=0){const afterOpen=this._thinkRaw.slice(openAt+7);closeAt=afterOpen.indexOf(""),shown=closeAt>=0?afterOpen.slice(0,closeAt):afterOpen,isReal=!0}else shown=this._thinkRaw;label&&(label.innerHTML=isReal?' LLM Thinking\u2026':' Live Output (raw \u2014 no reasoning exposed)'),think.textContent=shown.trim().slice(-2e4),think.scrollTop=think.scrollHeight,closeAt>=0&&(this._thinkDone=!0)},clearProcessing(){this._procRow&&(this._procRow.remove(),this._procRow=null)},addSegments(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const makeRow=s=>{const{speakerName}=_abRowSpeaker(s);return colorFor(speakerName),roster.get(speakerName).count++,_abRowFromSegment(s)},CHUNK=80,first=segs.slice(0,CHUNK),frag0=document.createDocumentFragment();if(first.forEach(s=>frag0.appendChild(makeRow(s))),_abPage().appendChild(frag0),renderRoster(),segs.length>CHUNK){let i=CHUNK;const next=()=>{if(i>=segs.length){trim(),renderRoster();return}const batch=document.createDocumentFragment(),end=Math.min(i+CHUNK,segs.length);for(;i=0?prevIdx+1:0),segsAll=_audiobook.segments||[],gapTo=Math.min(segsAll.length-1,nextIdx>=0?nextIdx-1:segsAll.length-1),gapCount=Math.max(0,gapTo-gapFrom+1);r.innerHTML=`\u22EF ${gapCount>0?gapCount+" line"+(gapCount!==1?"s":"")+" hidden \u2014 ":""}click to expand`,r.title="Click to reveal the text between these passages",r.addEventListener("click",()=>{const gap=segsAll.slice(gapFrom,gapTo+1);if(!gap.length){r.remove();return}const PEEK=20,show=gap.slice(0,PEEK),rest=gap.slice(PEEK),frag=document.createDocumentFragment();for(const s of show){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,frag.appendChild(row)}if(rest.length){const next=document.createElement("div");next.className="ab-cv-divider ab-cv-divider-expand",next.innerHTML=`\u22EF ${rest.length} line${rest.length!==1?"s":""} hidden \u2014 click to expand`,next.title="Click to reveal more";const restSegs=rest;next.addEventListener("click",function(){const f2=document.createDocumentFragment();for(const s of restSegs){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,f2.appendChild(row)}next.replaceWith(f2)}),frag.appendChild(next)}r.replaceWith(frag)}),_abPage().appendChild(r),trim()},pagemark(pageNum){this.clearProcessing(),_abNewPage("Page "+pageNum,pageNum),trim()},complete(summary,onOpen,onRecast,onRecastUnknown){var _a3,_b3;count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0);const completedSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length?_audiobook.segments:typeof allSegments!="undefined"&&allSegments.length?allSegments:null;completedSegments&&_abRedrawSegmentsChunked(completedSegments);const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.hidden=!0);const foot=panel.querySelector("#ab-cv-foot");foot.hidden=!1;const sideEl=panel.querySelector(".ab-cv-side");if(sideEl){let doneEl=sideEl.querySelector(".ab-cv-side-done");doneEl||(doneEl=document.createElement("div"),doneEl.className="ab-cv-side-done",sideEl.appendChild(doneEl)),doneEl.innerHTML=` ${escHtml(summary)}`}const continueBtnHtml=_audiobook.completedChunks>0&&_audiobook.completedTotal>0&&_audiobook.completedChunks<_audiobook.completedTotal?``:"";foot.innerHTML=`${continueBtnHtml}`;const runVerificationPass=()=>{const verificationPrompt=`Du bist ein Qualit\xE4tspr\xFCfer f\xFCr die Sprecherzuordnung eines bereits analysierten deutschen H\xF6rbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT \u2014 du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters geh\xF6rt, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer k\xF6nnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erz\xE4hltext hineingezogen?".
+      `,this._procRow.querySelector(".ab-cv-expand-btn").addEventListener("click",function(){const split=this.closest(".ab-cv-llm-row").querySelector(".ab-cv-llm-split"),open=this.getAttribute("aria-expanded")==="true";this.setAttribute("aria-expanded",String(!open)),split.hidden=open;const icon=this.querySelector("span");icon&&(icon.className=open?"mdi mdi-chevron-right":"mdi mdi-chevron-down")}),_abPage().appendChild(this._procRow),trim()},thinking(delta){if(!this._procRow||!delta||this._thinkDone)return;this._thinkRaw=(this._thinkRaw||"")+delta;const think=this._procRow.querySelector(".ab-cv-think"),label=this._procRow.querySelector(".ab-cv-think-label");if(!think)return;const openAt=this._thinkRaw.indexOf("");let shown,isReal=!1,closeAt=-1;if(openAt>=0){const afterOpen=this._thinkRaw.slice(openAt+7);closeAt=afterOpen.indexOf(""),shown=closeAt>=0?afterOpen.slice(0,closeAt):afterOpen,isReal=!0}else shown=this._thinkRaw;label&&(label.innerHTML=isReal?' LLM Thinking\u2026':' Live Output (raw \u2014 no reasoning exposed)'),think.textContent=shown.trim().slice(-2e4),think.scrollTop=think.scrollHeight,closeAt>=0&&(this._thinkDone=!0)},clearProcessing(){this._procRow&&(this._procRow.remove(),this._procRow=null)},addSegments(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const makeRow=s=>{const{speakerName}=_abRowSpeaker(s);return colorFor(speakerName),roster.get(speakerName).count++,_abRowFromSegment(s)},CHUNK=80,first=segs.slice(0,CHUNK),frag0=document.createDocumentFragment();if(first.forEach(s=>frag0.appendChild(makeRow(s))),_abPage().appendChild(frag0),renderRoster(),segs.length>CHUNK){let i=CHUNK;const next=()=>{if(i>=segs.length){trim(),renderRoster();return}const batch=document.createDocumentFragment(),end=Math.min(i+CHUNK,segs.length);for(;i=0?prevIdx+1:0),segsAll=_audiobook.segments||[],gapTo=Math.min(segsAll.length-1,nextIdx>=0?nextIdx-1:segsAll.length-1),gapCount=Math.max(0,gapTo-gapFrom+1);r.innerHTML=`\u22EF ${gapCount>0?gapCount+" line"+(gapCount!==1?"s":"")+" hidden \u2014 ":""}click to expand`,r.title="Click to reveal the text between these passages",r.addEventListener("click",()=>{const gap=segsAll.slice(gapFrom,gapTo+1);if(!gap.length){r.remove();return}const PEEK=20,show=gap.slice(0,PEEK),rest=gap.slice(PEEK),frag=document.createDocumentFragment();for(const s of show){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,frag.appendChild(row)}if(rest.length){const next=document.createElement("div");next.className="ab-cv-divider ab-cv-divider-expand",next.innerHTML=`\u22EF ${rest.length} line${rest.length!==1?"s":""} hidden \u2014 click to expand`,next.title="Click to reveal more";const restSegs=rest;next.addEventListener("click",function(){const f2=document.createDocumentFragment();for(const s of restSegs){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${highlightText2(s.text||"")}`,f2.appendChild(row)}next.replaceWith(f2)}),frag.appendChild(next)}r.replaceWith(frag)}),_abPage().appendChild(r),trim()},pagemark(pageNum){this.clearProcessing(),_abNewPage("Page "+pageNum,pageNum),trim()},complete(summary,onOpen,onRecast,onRecastUnknown){var _a3,_b3;count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0);const completedSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length?_audiobook.segments:typeof allSegments!="undefined"&&allSegments.length?allSegments:null;completedSegments&&_abRedrawSegmentsChunked(completedSegments);const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.hidden=!0);const foot=panel.querySelector("#ab-cv-foot");foot.hidden=!1;const sideEl=panel.querySelector(".ab-cv-side");if(sideEl){let doneEl=sideEl.querySelector(".ab-cv-side-done");doneEl||(doneEl=document.createElement("div"),doneEl.className="ab-cv-side-done",sideEl.appendChild(doneEl)),doneEl.innerHTML=` ${escHtml(summary)}`}const continueBtnHtml=_audiobook.completedChunks>0&&_audiobook.completedTotal>0&&_audiobook.completedChunks<_audiobook.completedTotal?``:"";foot.innerHTML=`${continueBtnHtml}`;const verificationPrompt=`Du bist ein Qualit\xE4tspr\xFCfer f\xFCr die Sprecherzuordnung eines bereits analysierten deutschen H\xF6rbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT \u2014 du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters geh\xF6rt, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer k\xF6nnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erz\xE4hltext hineingezogen?".
 
 WORAUF DU PR\xDCFST \u2014 IN DIESER REIHENFOLGE (h\xF6chste Priorit\xE4t zuerst):
 1. ZUERST alle 'Unknown'-Zeilen: L\xF6se den Sprecher \xFCber Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegespr\xE4chen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung m\xF6glich ist. Das ist die dringendste Kategorie \u2014 eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete.
@@ -1453,7 +1467,9 @@ WORAUF DU PR\xDCFST \u2014 IN DIESER REIHENFOLGE (h\xF6chste Priorit\xE4t zuerst
 DEDUKTIONS-WERKZEUGE f\xFCr die Sprecherzuordnung:
 - Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende.
 - Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person \u2014 auch ohne Anf\xFChrungszeichen. Das ist der h\xE4ufigste Fehler der ersten Zuordnung: gesprochene Rede wird f\xE4lschlich als Narration markiert, weil die Anf\xFChrungszeichen bei der Extraktion verloren gingen.
-- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile handelt (aufblickt, sich umdreht, den Kopf sch\xFCttelt), ist der wahrscheinlichste Sprecher dieser Zeile.
+- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile AKTIV HANDELT (aufblickt, sich umdreht, den Kopf sch\xFCttelt), ist der wahrscheinlichste Sprecher dieser Zeile.
+- ZUH\xD6RER-AUSSCHLUSS (negative Evidenz): Wahrnehmungs- und Reaktionsverben kennzeichnen den ZUH\xD6RER, nicht den Sprecher ("Marcian sp\xFCrte ihre Tr\xE4nen", "Marcian h\xF6rte dar\xFCber hinweg" \u2192 Marcian h\xF6rt zu, jemand anderes spricht). Ebenso ist eine Person, die im Zitat per Vokativ ANGEREDET wird ("..., Marcian."), nicht der Sprecher. Pr\xFCfe diese Ausschl\xFCsse ZUERST, bevor du einen Namen aus dem Nachbarsatz \xFCbernimmst \u2014 das ist die h\xE4ufigste Fehlerquelle bei nicht zugeordneten Zeilen.
+- Pronomen-Inquit bei geteilten Zitaten: Eine Zeile wie ", antwortete er knapp." geh\xF6rt zum vorherigen Zitat; l\xF6se das Pronomen auf die zuletzt genannte Person passenden Geschlechts VOR dem Zitat auf (er \u2192 letzter Mann, sie \u2192 letzte Frau).
 - Ping-Pong-Prinzip: Zwei Personen im Gespr\xE4ch wechseln sich strikt ab, auch ohne Tags \u2014 verfolge die Kette zur letzten eindeutigen Nennung zur\xFCck.
 - Pronomen-Aufl\xF6sung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts.
 - Rollenbezeichnungen sind g\xFCltige Sprecher (z.B. 'Ork', 'Der Fremde', 'W\xE4chter') statt 'Unknown'.
@@ -1468,12 +1484,12 @@ ABSOLUTE REGELN:
 - Alle Segmente zusammen M\xDCSSEN den Originaltext exakt, l\xFCckenlos und wortgetreu rekonstruieren.
 - Erfinde NIEMALS Text. Lasse NIEMALS W\xF6rter weg. F\xFCge NIEMALS etwas hinzu.
 - Mische NIEMALS Narration und Dialog in einem Segment.
-- Sei konservativ: \xE4ndere eine Zeile nur, wenn du nach dieser Pr\xFCfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend w\xE4re \u2014 nicht jede Zeile muss sich \xE4ndern.`,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);closePanel(),audiobookRecastUnknown(savedChoice.url,savedChoice.model,{prompt:verificationPrompt,includeNarrator:!0})},runConsistencyPass=async()=>{const active=_abActiveSegments(),segs=active.arr;if(!segs.length){toast("Nothing cast yet to check","error");return}const byName=new Map;segs.forEach((s,idx)=>{s.type!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker)||(byName.has(s.speaker)||byName.set(s.speaker,[]),byName.get(s.speaker).push(idx))});const MIN_LINES=4,MAX_LINES_PER_CALL=60,candidates=[...byName.entries()].filter(([,idxs])=>idxs.length>=MIN_LINES);if(!candidates.length){toast("No characters with enough lines yet to check for consistency","error");return}const choice2=audiobookCurrentCastLlm(panel),savedChoice2=audiobookSaveLlmChoice(choice2.url,choice2.model);closePanel(),_abPushEditState(active.key,segs);const busy=_abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length!==1?"s":""}\u2026`,!0);let checked=0,flaggedTotal=0,failedNames=[];const touchedSegs=[],knownNames=[...roster.keys()];try{for(const[name,idxs]of candidates){busy.setProgress(checked,candidates.length);let sampleIdxs=idxs;if(idxs.length>MAX_LINES_PER_CALL){const step=idxs.length/MAX_LINES_PER_CALL;sampleIdxs=Array.from({length:MAX_LINES_PER_CALL},(_,i)=>idxs[Math.floor(i*step)])}const lines=sampleIdxs.map(i=>({index:i,text:segs[i].text}));try{const r=await fetch("/api/audiobook-consistency-check",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character:name,lines,known_characters:knownNames.filter(n=>n.toLowerCase()!==name.toLowerCase()),llm_url:savedChoice2.url,model:savedChoice2.model})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const data=await r.json(),outliers=Array.isArray(data==null?void 0:data.outliers)?data.outliers:[];for(const o of outliers){let idx=o.index;const quote=String(o.quote||"").trim().toLowerCase(),matchesQuote=i=>{var _a4;return!quote||(((_a4=segs[i])==null?void 0:_a4.text)||"").toLowerCase().includes(quote.slice(0,40))};if((typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name||!matchesQuote(idx))&&(idx=quote?sampleIdxs.find(i=>segs[i].speaker===name&&matchesQuote(i)):void 0),typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name)continue;const suggested=String(o.suggested_speaker||"").trim();let newSpeaker=null;!suggested||/^unknown|unbekannt$/i.test(suggested)?newSpeaker="Unknown":newSpeaker=knownNames.find(n=>n.toLowerCase()===suggested.toLowerCase())||null,!(!newSpeaker||newSpeaker===name)&&(segs[idx].speaker=newSpeaker,newSpeaker==="Unknown"&&(segs[idx].type="dialogue"),touchedSegs.push(segs[idx]),flaggedTotal++)}}catch(err){failedNames.push(name),console.error("[consistency check]",name,err)}checked++,busy.setProgress(checked,candidates.length)}}finally{busy.remove()}flaggedTotal&&(await _abPatchScatteredRows(touchedSegs,()=>{})||await _abRedrawSegmentsChunked(active.arr),_abPersistManualEdit());const failSuffix=failedNames.length?` (${failedNames.length} character${failedNames.length!==1?"s":""} failed to check: ${failedNames.slice(0,5).join(", ")})`:"";toast((flaggedTotal?`Consistency check: ${flaggedTotal} line${flaggedTotal!==1?"s":""} reassigned across ${checked} character${checked!==1?"s":""}`:`Consistency check: no mismatches found across ${checked} character${checked!==1?"s":""}`)+failSuffix,failedNames.length&&!flaggedTotal?"error":"success")};foot.querySelector("#ab-cv-open-reh").addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()});const applyPromptAndRun2=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{closePanel(),callback&&callback(savedChoice.url,savedChoice.model)})},runIdentifyAll=async()=>{await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})&&applyPromptAndRun2(audiobookCast)},runIdentifyUnknown=()=>applyPromptAndRun2(audiobookRecastUnknown),runCastAll=()=>{typeof window.csForReader=="function"?window.csForReader():typeof csForReader=="function"?csForReader():toast("Character sheets not loaded yet","error")},runCastFresh=async()=>{await confirmDialog("Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.",{title:"Discard all character sheets?",okLabel:"Discard & rebuild",danger:!0})&&(typeof window.csForReader=="function"?window.csForReader({fresh:!0}):typeof csForReader=="function"?csForReader({fresh:!0}):toast("Character sheets not loaded yet","error"))},runCastSelected=()=>{if(!existing.length){toast("No character sheets found yet for this book","error");return}_abOpenRecastSelectPopup(existing)},runCastContinueUncasted=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof csForReaderSelective!="function"||typeof CS_DETAIL_FIELDS=="undefined"){toast("Character sheets not loaded yet","error");return}let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const recByName=new Map;recs.forEach(r=>{var _a4;const name=String(r.name||"").trim().toLowerCase();if(name&&recByName.set(name,r),typeof clSplitIdentityTokens=="function")for(const a of clSplitIdentityTokens((_a4=r==null?void 0:r.sheet)==null?void 0:_a4.aliases,{aliases:!0})){const key=a.toLowerCase();recByName.has(key)||recByName.set(key,r)}});const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim())),MIN_LINES_TO_TRY=10,lineCounts=new Map;(_audiobook.segments||[]).forEach(s=>{if((s==null?void 0:s.type)!=="dialogue"||!s.speaker)return;const k=String(s.speaker).trim().toLowerCase();lineCounts.set(k,(lineCounts.get(k)||0)+1)});const tooSparse=[],incomplete=roster2.filter(n=>{const rec=recByName.get(String(n).trim().toLowerCase());return rec&&CS_DETAIL_FIELDS.some(f=>String((rec.sheet||{})[f]||"").trim())?!1:(lineCounts.get(String(n).trim().toLowerCase())||0){var _a4;if((_a4=document.getElementById("s-caststudio"))!=null&&_a4.classList.contains("is-active")&&typeof window.showStudioPhase=="function"){window.showStudioPhase(3);return}try{sessionStorage.setItem("ttsvc_cast_return","reader")}catch{}typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"&&navLibraryView("characters")},runLibraryCleanup=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof clDelete!="function"){toast("Character library is not available right now","error");return}const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim()));if(!roster2.length){toast("No current roster to clean up against","error");return}const rosterSet=new Set(roster2.map(n=>n.trim().toLowerCase()));let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const toDelete=recs.filter(r=>!rosterSet.has(String(r.name||"").trim().toLowerCase()));if(!toDelete.length){toast(`Library already matches the current ${roster2.length}-name roster \u2014 nothing to remove`,"success");return}const preview=toDelete.slice(0,12).map(r=>r.name).join(", ")+(toDelete.length>12?`, +${toDelete.length-12} more`:"");if(!await confirmDialog(`Remove ${toDelete.length} character record${toDelete.length!==1?"s":""} that aren't in the current ${roster2.length}-name roster? This cannot be undone.
+- Sei konservativ: \xE4ndere eine Zeile nur, wenn du nach dieser Pr\xFCfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend w\xE4re \u2014 nicht jede Zeile muss sich \xE4ndern.`,runVerificationPass=()=>{const choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);closePanel(),audiobookRecastUnknown(savedChoice.url,savedChoice.model,{prompt:verificationPrompt,includeNarrator:!0})},runConsistencyPass=async()=>{const active=_abActiveSegments(),segs=active.arr;if(!segs.length){toast("Nothing cast yet to check","error");return}const byName=new Map;segs.forEach((s,idx)=>{s.type!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker)||(byName.has(s.speaker)||byName.set(s.speaker,[]),byName.get(s.speaker).push(idx))});const MIN_LINES=4,MAX_LINES_PER_CALL=60,candidates=[...byName.entries()].filter(([,idxs])=>idxs.length>=MIN_LINES);if(!candidates.length){toast("No characters with enough lines yet to check for consistency","error");return}const choice2=audiobookCurrentCastLlm(panel),savedChoice2=audiobookSaveLlmChoice(choice2.url,choice2.model);closePanel(),_abPushEditState(active.key,segs);const busy=_abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length!==1?"s":""}\u2026`,!0);let checked=0,flaggedTotal=0,failedNames=[];const touchedSegs=[],knownNames=[...roster.keys()];try{for(const[name,idxs]of candidates){busy.setProgress(checked,candidates.length);let sampleIdxs=idxs;if(idxs.length>MAX_LINES_PER_CALL){const step=idxs.length/MAX_LINES_PER_CALL;sampleIdxs=Array.from({length:MAX_LINES_PER_CALL},(_,i)=>idxs[Math.floor(i*step)])}const lines=sampleIdxs.map(i=>({index:i,text:segs[i].text}));try{const r=await fetch("/api/audiobook-consistency-check",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character:name,lines,known_characters:knownNames.filter(n=>n.toLowerCase()!==name.toLowerCase()),llm_url:savedChoice2.url,model:savedChoice2.model})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const data=await r.json(),outliers=Array.isArray(data==null?void 0:data.outliers)?data.outliers:[];for(const o of outliers){let idx=o.index;const quote=String(o.quote||"").trim().toLowerCase(),matchesQuote=i=>{var _a4;return!quote||(((_a4=segs[i])==null?void 0:_a4.text)||"").toLowerCase().includes(quote.slice(0,40))};if((typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name||!matchesQuote(idx))&&(idx=quote?sampleIdxs.find(i=>segs[i].speaker===name&&matchesQuote(i)):void 0),typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name)continue;const suggested=String(o.suggested_speaker||"").trim();let newSpeaker=null;!suggested||/^unknown|unbekannt$/i.test(suggested)?newSpeaker="Unknown":newSpeaker=knownNames.find(n=>n.toLowerCase()===suggested.toLowerCase())||null,!(!newSpeaker||newSpeaker===name)&&(segs[idx].speaker=newSpeaker,newSpeaker==="Unknown"&&(segs[idx].type="dialogue"),touchedSegs.push(segs[idx]),flaggedTotal++)}}catch(err){failedNames.push(name),console.error("[consistency check]",name,err)}checked++,busy.setProgress(checked,candidates.length)}}finally{busy.remove()}flaggedTotal&&(await _abPatchScatteredRows(touchedSegs,()=>{})||await _abRedrawSegmentsChunked(active.arr),_abPersistManualEdit());const failSuffix=failedNames.length?` (${failedNames.length} character${failedNames.length!==1?"s":""} failed to check: ${failedNames.slice(0,5).join(", ")})`:"";toast((flaggedTotal?`Consistency check: ${flaggedTotal} line${flaggedTotal!==1?"s":""} reassigned across ${checked} character${checked!==1?"s":""}`:`Consistency check: no mismatches found across ${checked} character${checked!==1?"s":""}`)+failSuffix,failedNames.length&&!flaggedTotal?"error":"success")};foot.querySelector("#ab-cv-open-reh").addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()});const applyPromptAndRun2=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{closePanel(),callback&&callback(savedChoice.url,savedChoice.model)})},runIdentifyAll=async()=>{await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})&&applyPromptAndRun2(audiobookCast)},runIdentifyUnknown=()=>applyPromptAndRun2(audiobookRecastUnknown),runRepairNarration=()=>{const segs=_audiobook.segments||[],n=typeof _abSuspectNarration=="function"?_abSuspectNarration(segs).size:0;if(!n){toast("No narration lines look like mislabeled dialogue","success");return}toast(`Re-checking ${n} narration line${n!==1?"s":""} that look like spoken dialogue\u2026`,"info"),audiobookRecastUnknown(null,null,{repairNarration:!0,prompt:verificationPrompt})},runCastAll=()=>{typeof window.csForReader=="function"?window.csForReader():typeof csForReader=="function"?csForReader():toast("Character sheets not loaded yet","error")},runCastFresh=async()=>{await confirmDialog("Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.",{title:"Discard all character sheets?",okLabel:"Discard & rebuild",danger:!0})&&(typeof window.csForReader=="function"?window.csForReader({fresh:!0}):typeof csForReader=="function"?csForReader({fresh:!0}):toast("Character sheets not loaded yet","error"))},runCastSelected=()=>{if(!existing.length){toast("No character sheets found yet for this book","error");return}_abOpenRecastSelectPopup(existing)},runCastContinueUncasted=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof csForReaderSelective!="function"||typeof CS_DETAIL_FIELDS=="undefined"){toast("Character sheets not loaded yet","error");return}let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const recByName=new Map;recs.forEach(r=>{var _a4;const name=String(r.name||"").trim().toLowerCase();if(name&&recByName.set(name,r),typeof clSplitIdentityTokens=="function")for(const a of clSplitIdentityTokens((_a4=r==null?void 0:r.sheet)==null?void 0:_a4.aliases,{aliases:!0})){const key=a.toLowerCase();recByName.has(key)||recByName.set(key,r)}});const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim())),MIN_LINES_TO_TRY=10,lineCounts=new Map;(_audiobook.segments||[]).forEach(s=>{if((s==null?void 0:s.type)!=="dialogue"||!s.speaker)return;const k=String(s.speaker).trim().toLowerCase();lineCounts.set(k,(lineCounts.get(k)||0)+1)});const tooSparse=[],incomplete=roster2.filter(n=>{const rec=recByName.get(String(n).trim().toLowerCase());return rec&&CS_DETAIL_FIELDS.some(f=>String((rec.sheet||{})[f]||"").trim())?!1:(lineCounts.get(String(n).trim().toLowerCase())||0){var _a4;if((_a4=document.getElementById("s-caststudio"))!=null&&_a4.classList.contains("is-active")&&typeof window.showStudioPhase=="function"){window.showStudioPhase(3);return}try{sessionStorage.setItem("ttsvc_cast_return","reader")}catch{}typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"&&navLibraryView("characters")},runLibraryCleanup=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof clDelete!="function"){toast("Character library is not available right now","error");return}const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim()));if(!roster2.length){toast("No current roster to clean up against","error");return}const rosterSet=new Set(roster2.map(n=>n.trim().toLowerCase()));let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const toDelete=recs.filter(r=>!rosterSet.has(String(r.name||"").trim().toLowerCase()));if(!toDelete.length){toast(`Library already matches the current ${roster2.length}-name roster \u2014 nothing to remove`,"success");return}const preview=toDelete.slice(0,12).map(r=>r.name).join(", ")+(toDelete.length>12?`, +${toDelete.length-12} more`:"");if(!await confirmDialog(`Remove ${toDelete.length} character record${toDelete.length!==1?"s":""} that aren't in the current ${roster2.length}-name roster? This cannot be undone.
 
-${preview}`,{title:"Clean up character library?",okLabel:`Remove ${toDelete.length}`,danger:!0}))return;let removed=0;for(const r of toDelete)try{await clDelete(r.id),removed++}catch{}toast(`Removed ${removed} character record${removed!==1?"s":""} not in the current roster`,"success")},bookTitle=((_a3=window.readerState)==null?void 0:_a3.title)||"";let existing=[];(async()=>{if(!(!bookTitle||typeof clGetAllByTagOrBook!="function"))try{existing=await clGetAllByTagOrBook(bookTitle)}catch{}})();const identifyMenu=foot.querySelector("#ab-cv-menu-identify"),castMenu=foot.querySelector("#ab-cv-menu-cast"),viewCastMenu=foot.querySelector("#ab-cv-menu-viewcast");identifyMenu==null||identifyMenu.addEventListener("click",()=>_abToggleFootMenu(identifyMenu,[{icon:"mdi-refresh",label:"Identify all characters",title:"Scan the text and build the cast list from scratch",onClick:runIdentifyAll,danger:!0},{icon:"mdi-account-question-outline",label:"Identify unknown characters",title:"Re-scan only the unknown segments with the current prompt",onClick:runIdentifyUnknown},{icon:"mdi-shield-check-outline",label:"Verify all characters",title:"Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches",onClick:runVerificationPass},{icon:"mdi-account-search-outline",label:"Check voice consistency",title:"Third-pass check: gathers every line already credited to each character across the whole book and flags any that don\u2019t match their established voice",onClick:runConsistencyPass},{icon:"mdi-repeat",label:"Run until < N unknown\u2026",title:"Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls",onClick:()=>{const input=window.prompt("Stop once fewer than this many Unknown speakers remain:","10");if(input==null)return;const threshold=parseInt(input,10);if(!Number.isFinite(threshold)||threshold<0){toast("Enter a whole number of 0 or more","error");return}audiobookRecastUntilThreshold(threshold)}}])),castMenu==null||castMenu.addEventListener("click",()=>_abToggleFootMenu(castMenu,[{icon:"mdi-account-multiple-plus-outline",label:"Cast all character roles",title:"Generate / refresh the character sheets for every cast character",onClick:runCastAll},{icon:"mdi-account-arrow-right-outline",label:"Continue uncasted characters",title:"Only generate profiles for characters with no detail yet \u2014 skips anyone already fully cast",onClick:runCastContinueUncasted},{icon:"mdi-account-check-outline",label:"Cast selected character roles",title:existing.length?"Generate / refresh the character sheets for selected cast characters":"No character sheets found yet for this book",disabled:!existing.length,onClick:runCastSelected},{divider:!0},{icon:"mdi-refresh",label:"New recast (discard & rebuild all)",title:"Discard every generated character sheet and rebuild every profile from scratch",onClick:runCastFresh,danger:!0}])),viewCastMenu==null||viewCastMenu.addEventListener("click",()=>_abToggleFootMenu(viewCastMenu,[{icon:"mdi-eye-outline",label:"View cast",title:"View the cast overview in the library",onClick:runViewCast},{icon:"mdi-folder-zip-outline",label:"Export cast archive (.zip)",title:"Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character",onClick:audiobookExportCastMd},{divider:!0},{icon:"mdi-broom",label:"Clean up library to current roster",title:"Remove saved character records that aren't in the current roster \u2014 old spelling-variant duplicates and superseded names from past casting runs",onClick:runLibraryCleanup,danger:!0}])),(_b3=foot.querySelector("#ab-cv-continue"))==null||_b3.addEventListener("click",()=>applyPromptAndRun2((u,m)=>audiobookCast(u,m,{startIndex:_audiobook.completedChunks,segments:_audiobook.segments,roster:_audiobook.roster,narrationOnly:_audiobook.narratedPassages,degraded:_audiobook.degraded}))),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},done(options={}){var _a3,_b3,_c3,_d3,_e3;if(!(options.stopped||_audiobook.cancel)){closePanel();return}this.clearProcessing(),count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const message=options.message||"Casting stopped before any passage completed.";if(feed.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page")){if(options.message){const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}}else{feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No completed characters yet.');const foot=panel.querySelector("#ab-cv-foot"),hasSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length>0;foot.hidden=!1,foot.innerHTML=` ${escHtml(message)}${hasSegments?'':""}`,(_c3=foot.querySelector("#ab-cv-stopped-back"))==null||_c3.addEventListener("click",closePanel),(_d3=foot.querySelector("#ab-cv-stopped-review"))==null||_d3.addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()}),(_e3=foot.querySelector("#ab-cv-stopped-recast"))==null||_e3.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt||""})}).finally(()=>{closePanel(),audiobookCast(choice.url,choice.model)})}),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},loadingRestore(){var _a3,_b3;(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent="Loading saved cast\u2026",feed.appendChild(r),chars.innerHTML='restoring\u2026';const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent="Checking saved cast before starting a new one.");const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="none")},setFreshState(message="Ready to identify characters."){var _a3,_b3;if((_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),!feed.querySelector(".ab-cv-row, .ab-cv-divider, .ab-cv-page")){feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No saved cast found.');const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent=message);const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),audiobookCast(choice.url,choice.model)}))}}}async function audiobookRecastUnknown(overrideUrl,overrideModel,options={}){var _a2,_b2,_c2,_d2,_e2,_f2;if(_audiobook.running)return;const segs=_audiobook.segments;if(!segs||!segs.length)return;const originalSegments=segs.map(s=>({...s})),countUnknownDialogue=arr=>(arr||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length,beforeUnknownCount=countUnknownDialogue(segs),preResolved=audiobookResolveUnknowns(segs,[],_audiobook.roster||[]);preResolved.length&&toast(`${preResolved.length} Unknown line${preResolved.length!==1?"s":""} resolved by grammar rules`,"success");const unknownIdxs=[];for(let i=0;iac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang();let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const promptOverride=typeof options.prompt=="string"?options.prompt:null,groups=audiobookRecastGroups(unknownIdxs,segs),view=audiobookCastView(unknownIdxs.length,llm_url,model);view.recountRoster(segs);const _rcAliasMap=new Map;try{const bookTitle=((_a2=window.readerState)==null?void 0:_a2.title)||"",records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(bookTitle):[];for(const rec of records||[])if(rec!=null&&rec.name&&(_rcAliasMap.set(rec.name.toLowerCase(),rec.name),typeof clSplitIdentityTokens=="function"))for(const a of clSplitIdentityTokens(rec.aliases,{aliases:!0}))_rcAliasMap.set(a.toLowerCase(),rec.name)}catch{}const canonicalizeSpeaker=name=>name&&_rcAliasMap.get(name.toLowerCase())||name;view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_b2=document.getElementById("ab-cv-llm-url"))==null?void 0:_b2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_c2=document.getElementById("ab-cv-llm-select"))==null?void 0:_c2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Character definition stopped before any lines were updated."}),toast("Character definition stopped. Existing cast preserved.","info");return}}let done=0,prevIdx=-2,groupsSinceSave=0;const pendingReplacements=new Map,normalizeRecastSegment=(seg,fallback)=>{const type=(seg==null?void 0:seg.type)==="narration"?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":canonicalizeSpeaker((seg==null?void 0:seg.speaker)||(fallback==null?void 0:fallback.speaker)||"Unknown"),type,emotion:type==="dialogue"&&((seg==null?void 0:seg.emotion)||(fallback==null?void 0:fallback.emotion))||"",text:(seg==null?void 0:seg.text)||(fallback==null?void 0:fallback.text)||""}};try{for(let group of groups){if(_audiobook.cancel)break;const unresolvedGroup=[];for(const idx of group)audiobookIsSpeechTagOnly((_d2=segs[idx])==null?void 0:_d2.text)?(segs[idx].type="narration",segs[idx].speaker="Narrator",segs[idx].emotion=""):unresolvedGroup.push(idx);if(!unresolvedGroup.length){group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1],view.update(done,`Correcting narration tags ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}group=unresolvedGroup,group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1];const recastCtx=audiobookRecastContext(segs,group),passageText=recastCtx.text,lineLabel=group.length>1?`Attributing lines ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`:`Attributing line ${done+1} / ${unknownIdxs.length}\u2026`;view.update(done,lineLabel),view.processing(passageText.trim());let data=null;const recastBody=audiobookAttributeBody({text:passageText.trim(),known_characters:_audiobook.roster.slice(-40),recent:recastCtx.recent||"",language,llm_url:((_e2=document.getElementById("ab-cv-llm-url"))==null?void 0:_e2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_f2=document.getElementById("ab-cv-llm-select"))==null?void 0:_f2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)},promptOverride);try{try{data=await audiobookAttributeStream(recastBody,view,ac.signal,AUDIOBOOK_RECAST_TIMEOUT_MS)}catch(streamErr){if(streamErr.name==="AbortError")throw streamErr;const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(recastBody)},AUDIOBOOK_RECAST_TIMEOUT_MS+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}data=await r.json()}}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0;break}view.note(`API Error while checking ${group.length>1?"a group of Unknown lines":"Unknown line"}: ${err.message||err}`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}if(data&&data.segments){const used=new Set;for(const idx of group){const targetSeg=segs[idx],seqMatch=audiobookFindReturnedSegmentSequence(targetSeg,data.segments,used);if(seqMatch&&seqMatch.segs.length>1){const repl=seqMatch.segs.map(s=>normalizeRecastSegment(s,targetSeg)).filter(s=>(s.text||"").trim()),hasResolved=repl.some(s=>s.type==="narration"||s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)),hasUnresolvedDialogue=repl.some(s=>s.type==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)));if(repl.length&&hasResolved&&!hasUnresolvedDialogue){seqMatch.idxs.forEach(i=>used.add(i)),pendingReplacements.set(idx,repl),repl.forEach(s=>{s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)&&!_audiobook.roster.includes(s.speaker)&&_audiobook.roster.push(s.speaker)});continue}}const match=audiobookFindReturnedSegment(targetSeg,data.segments,used);if(match&&match.seg.speaker){const speaker=match.seg.type==="narration"?"Narrator":canonicalizeSpeaker(match.seg.speaker);if(!speaker||/^Unknown|Unbekannt/i.test(speaker))continue;used.add(match.idx),targetSeg.type=match.seg.type==="narration"?"narration":"dialogue",targetSeg.speaker=speaker,targetSeg.emotion=targetSeg.type==="dialogue"&&(match.seg.emotion||targetSeg.emotion)||"",targetSeg.type==="dialogue"&&!_audiobook.roster.includes(speaker)&&_audiobook.roster.push(speaker)}}}for(const idx of group)view.addSegments(pendingReplacements.get(idx)||[segs[idx]]),done++;groupsSinceSave++,groupsSinceSave>=10&&_audiobook.lastText&&(groupsSinceSave=0,_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),view.recountRoster(segs))}view.update(_audiobook.cancel?done:unknownIdxs.length)}catch(e){e.name!=="AbortError"&&view.note("Error defining unknowns: "+e.message)}finally{_audiobook.running=!1,_audiobook.abort=null}try{pendingReplacements.size&&[...pendingReplacements.entries()].sort((a,b)=>b[0]-a[0]).forEach(([idx,repl])=>segs.splice(idx,1,...repl));const{segments:deduped,removed:dupRemoved}=_audiobookDedupNearbyDuplicates(segs);dupRemoved&&(segs.splice(0,segs.length,...deduped),view.note(`Removed ${dupRemoved} duplicated line${dupRemoved!==1?"s":""} introduced by this verification pass.`));const afterUnknownCount=countUnknownDialogue(segs);!_audiobook.cancel&&afterUnknownCount>beforeUnknownCount&&(segs.splice(0,segs.length,...originalSegments),view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`),toast("Quality run rolled back because it increased Unknown speakers.","error"));const _rcDone=_audiobook.completedChunks||segs.length,_rcTotal=_audiobook.completedTotal||_rcDone;if(_audiobook.cancel){_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal),view.done({stopped:!0,message:"Character definition stopped. Existing cast preserved."}),toast("Character definition stopped. Existing cast preserved.","info");return}_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal);const speakers=new Set(segs.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${segs.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)}catch(e){console.error("[audiobookRecastUnknown] post-processing failed",e),view.done({stopped:!0,message:"Finished checking speakers, but saving/cleanup failed: "+e.message+" \u2014 your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted."}),toast("Casting finished but cleanup failed: "+e.message,"error")}}async function audiobookRecastUntilThreshold(threshold=10,maxPasses=8){if(_audiobook.running){toast("Casting is already running","error");return}const segs=_audiobook.segments;if(!segs||!segs.length){toast("No cast to check","error");return}const countUnknown=()=>(segs||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length;let prev=countUnknown();if(prev<=threshold){toast(`Already at ${prev} unknown speakers (target: <${threshold})`,"success");return}toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})\u2026`,"info");for(let pass=1;pass<=maxPasses;pass++){if(await audiobookRecastUnknown(null,null,{includeNarrator:!0}),_audiobook.cancel){toast("Stopped \u2014 cancelled mid-pass.","info");return}const now=countUnknown();if(now<=threshold){toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass!==1?"es":""}).`,"success");return}if(now>=prev){toast(`Stopped after ${pass} pass${pass!==1?"es":""}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`,"error");return}prev=now}toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`,"error")}window.audiobookRecastUntilThreshold=audiobookRecastUntilThreshold;async function audiobookOpenCastView(){var _a2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}const _curBook=window.readerState&&readerState.savedId||null;_curBook&&_audiobook.bookId&&_audiobook.bookId!==_curBook&&(_audiobook.segments=null,_audiobook.lastText=null,_audiobook.roster=null),_audiobook.bookId=_curBook;const llm_url=audiobookLlmUrl(),model=audiobookLlmModel(),chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text];if(_audiobook.segments&&_audiobook.segments.length>0&&_audiobook.lastText===text){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.rebuild(_audiobook.segments);const speakers=new Set(_audiobook.segments.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${_audiobook.segments.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown);return}const _applyDraft=(draft,view,source)=>{const draftDone=Number.isFinite(Number(draft.done))?Number(draft.done):0,draftTotal=Number.isFinite(Number(draft.total))?Number(draft.total):0;_abStampSegmentPages(draft.segments,draft.pageMarks,text),_audiobook.segments=draft.segments,_audiobook.roster=draft.roster||[],_audiobook.lastText=text,_audiobook.pageMarks=draft.pageMarks||[],_audiobook.rehId=draft.rehId||null,_audiobook.completedChunks=draftDone>0?draftDone:0,_audiobook.completedTotal=draftTotal>0?draftTotal:0,view.rebuild(draft.segments);const ageMs=Date.now()-(draft.savedAt||0),ageMins=Math.round(ageMs/6e4),ageStr=ageMins<1?"gerade eben":ageMins<60?`vor ${ageMins} Min.`:`vor ${Math.round(ageMins/60)} Std.`,pct=draftTotal>0?Math.max(0,Math.min(100,Math.round(draftDone/draftTotal*100))):100,wasDone=draftTotal>0&&draftDone>=draftTotal,canContinue=draftDone>0&&draftTotal>0&&draftDones.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} Charakter${speakers.size!==1?"e":""} \xB7 ${draft.segments.length} Segmente`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)},_localDraft=_abLoadDraft(text);if(_localDraft&&_localDraft.segments&&_localDraft.segments.length>0){const view=audiobookCastView(chunks.length,llm_url,model,!1);if(_applyDraft(_localDraft,view,"local"),_abBookId()){const serverCopy={..._localDraft,bookId:_abBookId(),title:((_a2=window.readerState)==null?void 0:_a2.title)||_localDraft.title||""};fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(serverCopy)}).catch(()=>{})}return}const bookId=_abBookId();if(bookId){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.loadingRestore();try{const serverDraft=await _abLoadDraftServer(bookId);if(serverDraft&&serverDraft.segments&&serverDraft.segments.length>0){try{localStorage.setItem(_abDraftKey(bookId),JSON.stringify(serverDraft))}catch{}_applyDraft(serverDraft,view,"server")}else view.setFreshState("No saved cast was found for this saved book. Starting a new cast will create a new draft.")}catch{view.setFreshState("Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.")}return}audiobookCastView(chunks.length,llm_url,model,!0)}async function audiobookCast(overrideUrl,overrideModel,resume){var _a2,_b2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}if(typeof parseScript!="function"){toast("Rehearser not loaded yet \u2014 try again in a moment","error");return}_audiobook.running=!0,!!_abBookId()||!await _abEnsureLibraryBook()&&typeof toast=="function"&&toast("Autosave will use this browser only until the book is saved to the library.","info");const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text],startIndex=resume&&resume.startIndex>0&&resume.startIndex0;_audiobook.cancel=!1,isResume||_abClearDraft(),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!0);const ac=new AbortController;_audiobook.abort=()=>ac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang(text);let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const view=audiobookCastView(chunks.length,llm_url,model,!1);isResume&&resume.segments&&resume.segments.length&&view.rebuild(resume.segments),view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_a2=document.getElementById("ab-cv-llm-url"))==null?void 0:_a2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b2=document.getElementById("ab-cv-llm-select"))==null?void 0:_b2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}}const allSegments2=isResume?resume.segments.slice():[];_audiobook.liveSegments=allSegments2;const roster=isResume?(resume.roster||[]).slice():[];let narrationOnly=isResume&&resume.narrationOnly||0,degraded=isResume&&resume.degraded||0,completedChunks=startIndex;_abStartDraftAutosave(()=>{allSegments2.length&&_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)});const _pgMarks=(_audiobook.pageMarks||[]).slice();_pgMarks.length&&_pgMarks[0].offset<=2&&_pgMarks.shift();let _pgMarkIdx=0,_pgCharPos=0,_curPageNum=1;if(isResume){for(let k=0;k0&&(_curPageNum=_pgMarks[_pgMarkIdx-1].page+1)}try{for(let i=startIndex;i=_pgMarks[_pgMarkIdx].offset;)_curPageNum=_pgMarks[_pgMarkIdx].page+1,view.pagemark(_curPageNum),_pgMarkIdx++;if(_pgCharPos+=chunks[i].length+1,!audiobookHasDialogue(chunks[i])){const seg={speaker:"Narrator",type:"narration",text:chunks[i],emotion:"",page:_curPageNum};allSegments2.push(seg),narrationOnly++,view.addSegments([seg]),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length);continue}const recent=allSegments2.filter(s=>s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)).slice(-6).map(s=>`${s.speaker}: ${(s.text||"").slice(0,80)}`).join(`
+${preview}`,{title:"Clean up character library?",okLabel:`Remove ${toDelete.length}`,danger:!0}))return;let removed=0;for(const r of toDelete)try{await clDelete(r.id),removed++}catch{}toast(`Removed ${removed} character record${removed!==1?"s":""} not in the current roster`,"success")},bookTitle=((_a3=window.readerState)==null?void 0:_a3.title)||"";let existing=[];(async()=>{if(!(!bookTitle||typeof clGetAllByTagOrBook!="function"))try{existing=await clGetAllByTagOrBook(bookTitle)}catch{}})();const identifyMenu=foot.querySelector("#ab-cv-menu-identify"),castMenu=foot.querySelector("#ab-cv-menu-cast"),viewCastMenu=foot.querySelector("#ab-cv-menu-viewcast");identifyMenu==null||identifyMenu.addEventListener("click",()=>_abToggleFootMenu(identifyMenu,[{icon:"mdi-refresh",label:"Identify all characters",title:"Scan the text and build the cast list from scratch",onClick:runIdentifyAll,danger:!0},{icon:"mdi-comment-alert-outline",label:"Repair mislabeled dialogue",title:"Finds spoken lines that were typed as narration (usually because the \xBB \xAB quote marks were lost in extraction) and re-checks only those \u2014 far cheaper than verifying every narration line",onClick:runRepairNarration},{icon:"mdi-account-question-outline",label:"Identify unknown characters",title:"Re-scan only the unknown segments with the current prompt",onClick:runIdentifyUnknown},{icon:"mdi-auto-fix",label:"Auto-repair cast",title:"Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed",onClick:()=>audiobookAutoRepairCast()},{icon:"mdi-repeat",label:"Run until < N unknown\u2026",title:"Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls",onClick:()=>{const input=window.prompt("Stop once fewer than this many Unknown speakers remain:","10");if(input==null)return;const threshold=parseInt(input,10);if(!Number.isFinite(threshold)||threshold<0){toast("Enter a whole number of 0 or more","error");return}audiobookRecastUntilThreshold(threshold)}},{icon:"mdi-shield-check-outline",label:"Verify all characters",title:"Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches",onClick:runVerificationPass},{icon:"mdi-account-search-outline",label:"Check voice consistency",title:"Third-pass check: gathers every line already credited to each character across the whole book and flags any that don\u2019t match their established voice",onClick:runConsistencyPass}])),castMenu==null||castMenu.addEventListener("click",()=>_abToggleFootMenu(castMenu,[{icon:"mdi-account-multiple-plus-outline",label:"Cast all character roles",title:"Generate / refresh the character sheets for every cast character",onClick:runCastAll},{icon:"mdi-account-arrow-right-outline",label:"Continue uncasted characters",title:"Only generate profiles for characters with no detail yet \u2014 skips anyone already fully cast",onClick:runCastContinueUncasted},{icon:"mdi-account-check-outline",label:"Cast selected character roles",title:existing.length?"Generate / refresh the character sheets for selected cast characters":"No character sheets found yet for this book",disabled:!existing.length,onClick:runCastSelected},{divider:!0},{icon:"mdi-refresh",label:"New recast (discard & rebuild all)",title:"Discard every generated character sheet and rebuild every profile from scratch",onClick:runCastFresh,danger:!0}])),viewCastMenu==null||viewCastMenu.addEventListener("click",()=>_abToggleFootMenu(viewCastMenu,[{icon:"mdi-eye-outline",label:"View cast",title:"View the cast overview in the library",onClick:runViewCast},{icon:"mdi-folder-zip-outline",label:"Export cast archive (.zip)",title:"Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character",onClick:audiobookExportCastMd},{divider:!0},{icon:"mdi-broom",label:"Clean up library to current roster",title:"Remove saved character records that aren't in the current roster \u2014 old spelling-variant duplicates and superseded names from past casting runs",onClick:runLibraryCleanup,danger:!0}])),(_b3=foot.querySelector("#ab-cv-continue"))==null||_b3.addEventListener("click",()=>applyPromptAndRun2((u,m)=>audiobookCast(u,m,{startIndex:_audiobook.completedChunks,segments:_audiobook.segments,roster:_audiobook.roster,narrationOnly:_audiobook.narratedPassages,degraded:_audiobook.degraded}))),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},done(options={}){var _a3,_b3,_c3,_d3,_e3;if(!(options.stopped||_audiobook.cancel)){closePanel();return}this.clearProcessing(),count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const message=options.message||"Casting stopped before any passage completed.";if(feed.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page")){if(options.message){const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}}else{feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No completed characters yet.');const foot=panel.querySelector("#ab-cv-foot"),hasSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length>0;foot.hidden=!1,foot.innerHTML=` ${escHtml(message)}${hasSegments?'':""}`,(_c3=foot.querySelector("#ab-cv-stopped-back"))==null||_c3.addEventListener("click",closePanel),(_d3=foot.querySelector("#ab-cv-stopped-review"))==null||_d3.addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()}),(_e3=foot.querySelector("#ab-cv-stopped-recast"))==null||_e3.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt||""})}).finally(()=>{closePanel(),audiobookCast(choice.url,choice.model)})}),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},loadingRestore(){var _a3,_b3;(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent="Loading saved cast\u2026",feed.appendChild(r),chars.innerHTML='restoring\u2026';const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent="Checking saved cast before starting a new one.");const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="none")},setFreshState(message="Ready to identify characters."){var _a3,_b3;if((_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),!feed.querySelector(".ab-cv-row, .ab-cv-divider, .ab-cv-page")){feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No saved cast found.');const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent=message);const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),audiobookCast(choice.url,choice.model)}))}}}async function audiobookRecastUnknown(overrideUrl,overrideModel,options={}){var _a2,_b2,_c2,_d2,_e2,_f2;if(_audiobook.running)return;const segs=_audiobook.segments;if(!segs||!segs.length)return;const originalSegments=segs.map(s=>({...s})),countUnknownDialogue=arr=>(arr||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length,beforeUnknownCount=countUnknownDialogue(segs),preResolved=audiobookResolveUnknowns(segs,[],_audiobook.roster||[]);preResolved.length&&toast(`${preResolved.length} Unknown line${preResolved.length!==1?"s":""} resolved by grammar rules`,"success");const unknownIdxs=[],_abRepairSuspects=options.repairNarration?_abSuspectNarration(segs):null;for(let i=0;iac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang();let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const promptOverride=typeof options.prompt=="string"?options.prompt:null,groups=audiobookRecastGroups(unknownIdxs,segs),view=audiobookCastView(unknownIdxs.length,llm_url,model);view.recountRoster(segs);const _rcAliasMap=new Map;try{const bookTitle=((_a2=window.readerState)==null?void 0:_a2.title)||"",records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(bookTitle):[];for(const rec of records||[])if(rec!=null&&rec.name&&(_rcAliasMap.set(rec.name.toLowerCase(),rec.name),typeof clSplitIdentityTokens=="function"))for(const a of clSplitIdentityTokens(rec.aliases,{aliases:!0}))_rcAliasMap.set(a.toLowerCase(),rec.name)}catch{}const canonicalizeSpeaker=name=>name&&_rcAliasMap.get(name.toLowerCase())||name;view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_b2=document.getElementById("ab-cv-llm-url"))==null?void 0:_b2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_c2=document.getElementById("ab-cv-llm-select"))==null?void 0:_c2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Character definition stopped before any lines were updated."}),toast("Character definition stopped. Existing cast preserved.","info");return}}let done=0,prevIdx=-2,groupsSinceSave=0;const pendingReplacements=new Map,normalizeRecastSegment=(seg,fallback)=>{const type=(seg==null?void 0:seg.type)==="narration"?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":canonicalizeSpeaker((seg==null?void 0:seg.speaker)||(fallback==null?void 0:fallback.speaker)||"Unknown"),type,emotion:type==="dialogue"&&((seg==null?void 0:seg.emotion)||(fallback==null?void 0:fallback.emotion))||"",text:(seg==null?void 0:seg.text)||(fallback==null?void 0:fallback.text)||""}};try{for(let group of groups){if(_audiobook.cancel)break;const unresolvedGroup=[];for(const idx of group)audiobookIsSpeechTagOnly((_d2=segs[idx])==null?void 0:_d2.text)?(segs[idx].type="narration",segs[idx].speaker="Narrator",segs[idx].emotion=""):unresolvedGroup.push(idx);if(!unresolvedGroup.length){group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1],view.update(done,`Correcting narration tags ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}group=unresolvedGroup,group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1];const recastCtx=audiobookRecastContext(segs,group),passageText=recastCtx.text,lineLabel=group.length>1?`Attributing lines ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`:`Attributing line ${done+1} / ${unknownIdxs.length}\u2026`;view.update(done,lineLabel),view.processing(passageText.trim());let data=null;const recastBody=audiobookAttributeBody({text:passageText.trim(),known_characters:_audiobook.roster.slice(-40),recent:recastCtx.recent||"",language,llm_url:((_e2=document.getElementById("ab-cv-llm-url"))==null?void 0:_e2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_f2=document.getElementById("ab-cv-llm-select"))==null?void 0:_f2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)},promptOverride);try{try{data=await audiobookAttributeStream(recastBody,view,ac.signal,AUDIOBOOK_RECAST_TIMEOUT_MS)}catch(streamErr){if(streamErr.name==="AbortError")throw streamErr;const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(recastBody)},AUDIOBOOK_RECAST_TIMEOUT_MS+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}data=await r.json()}}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0;break}view.note(`API Error while checking ${group.length>1?"a group of Unknown lines":"Unknown line"}: ${err.message||err}`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}if(data&&data.segments){const used=new Set;for(const idx of group){const targetSeg=segs[idx],seqMatch=audiobookFindReturnedSegmentSequence(targetSeg,data.segments,used);if(seqMatch&&seqMatch.segs.length>1){const repl=seqMatch.segs.map(s=>normalizeRecastSegment(s,targetSeg)).filter(s=>(s.text||"").trim()),hasResolved=repl.some(s=>s.type==="narration"||s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)),hasUnresolvedDialogue=repl.some(s=>s.type==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker)));if(repl.length&&hasResolved&&!hasUnresolvedDialogue){seqMatch.idxs.forEach(i=>used.add(i)),pendingReplacements.set(idx,repl),repl.forEach(s=>{s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)&&!_audiobook.roster.includes(s.speaker)&&_audiobook.roster.push(s.speaker)});continue}}const match=audiobookFindReturnedSegment(targetSeg,data.segments,used);if(match&&match.seg.speaker){const speaker=match.seg.type==="narration"?"Narrator":canonicalizeSpeaker(match.seg.speaker);if(!speaker||/^Unknown|Unbekannt/i.test(speaker))continue;used.add(match.idx),targetSeg.type=match.seg.type==="narration"?"narration":"dialogue",targetSeg.speaker=speaker,targetSeg.emotion=targetSeg.type==="dialogue"&&(match.seg.emotion||targetSeg.emotion)||"",targetSeg.type==="dialogue"&&!_audiobook.roster.includes(speaker)&&_audiobook.roster.push(speaker)}}}for(const idx of group)view.addSegments(pendingReplacements.get(idx)||[segs[idx]]),done++;groupsSinceSave++,groupsSinceSave>=10&&_audiobook.lastText&&(groupsSinceSave=0,_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),view.recountRoster(segs))}view.update(_audiobook.cancel?done:unknownIdxs.length)}catch(e){e.name!=="AbortError"&&view.note("Error defining unknowns: "+e.message)}finally{_audiobook.running=!1,_audiobook.abort=null}try{pendingReplacements.size&&[...pendingReplacements.entries()].sort((a,b)=>b[0]-a[0]).forEach(([idx,repl])=>segs.splice(idx,1,...repl));const{segments:deduped,removed:dupRemoved}=_audiobookDedupNearbyDuplicates(segs);dupRemoved&&(segs.splice(0,segs.length,...deduped),view.note(`Removed ${dupRemoved} duplicated line${dupRemoved!==1?"s":""} introduced by this verification pass.`));const afterUnknownCount=countUnknownDialogue(segs);if(afterUnknownCount>beforeUnknownCount){segs.splice(0,segs.length,...originalSegments);const how=_audiobook.cancel?"Stopped run":"Quality run";view.note(`${how} rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`),toast(`${how} rolled back because it increased Unknown speakers.`,"error")}const _rcDone=_audiobook.completedChunks||segs.length,_rcTotal=_audiobook.completedTotal||_rcDone;if(_audiobook.cancel){_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal),view.done({stopped:!0,message:"Character definition stopped. Existing cast preserved."}),toast("Character definition stopped. Existing cast preserved.","info");return}_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal);const speakers=new Set(segs.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${segs.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)}catch(e){console.error("[audiobookRecastUnknown] post-processing failed",e),view.done({stopped:!0,message:"Finished checking speakers, but saving/cleanup failed: "+e.message+" \u2014 your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted."}),toast("Casting finished but cleanup failed: "+e.message,"error")}}const AB_AUTO_REPAIR_KEY="ab-auto-repair-enabled";function audiobookAutoRepairEnabled(){try{return localStorage.getItem(AB_AUTO_REPAIR_KEY)!=="0"}catch{return!0}}async function audiobookAutoRepairCast(threshold=10,maxRounds=4){if(!(_audiobook.segments||[]).length){toast("Nothing cast yet to repair","error");return}const countUnknown=()=>(_audiobook.segments||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length;let prev=countUnknown();if(prev<=threshold){toast(`Cast looks clean \u2014 ${prev} unknown speaker${prev!==1?"s":""}`,"success");return}toast(`Auto-repair: ${prev} unknown speakers \u2014 running repair passes\u2026`,"info");for(let round=1;round<=maxRounds;round++){if(await audiobookRecastUnknown(null,null,{repairNarration:!0}),_audiobook.cancel){toast("Auto-repair stopped.","info");return}const now=countUnknown();if(now<=threshold){toast(`Auto-repair done: ${now} unknown left after ${round} round${round!==1?"s":""}.`,"success");return}if(now>=prev)break;prev=now}toast(`Still ${prev} unknown \u2014 running full verification pass\u2026`,"info"),await audiobookRecastUnknown(null,null,{includeNarrator:!0});const final=countUnknown();toast(final<=threshold?`Auto-repair done: ${final} unknown left.`:`Auto-repair finished: ${final} unknown remain (target was under ${threshold}).`,final<=threshold?"success":"error")}window.audiobookAutoRepairCast=audiobookAutoRepairCast;async function audiobookRecastUntilThreshold(threshold=10,maxPasses=8){if(_audiobook.running){toast("Casting is already running","error");return}const segs=_audiobook.segments;if(!segs||!segs.length){toast("No cast to check","error");return}const countUnknown=()=>(segs||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length;let prev=countUnknown();if(prev<=threshold){toast(`Already at ${prev} unknown speakers (target: <${threshold})`,"success");return}toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})\u2026`,"info");for(let pass=1;pass<=maxPasses;pass++){if(await audiobookRecastUnknown(null,null,{repairNarration:!0}),_audiobook.cancel){toast("Stopped \u2014 cancelled mid-pass.","info");return}const now=countUnknown();if(now<=threshold){toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass!==1?"es":""}).`,"success");return}if(now>=prev){toast(`Stopped after ${pass} pass${pass!==1?"es":""}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`,"error");return}prev=now}toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`,"error")}window.audiobookRecastUntilThreshold=audiobookRecastUntilThreshold;async function audiobookOpenCastView(){var _a2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}const _curBook=window.readerState&&readerState.savedId||null;_curBook&&_audiobook.bookId&&_audiobook.bookId!==_curBook&&(_audiobook.segments=null,_audiobook.lastText=null,_audiobook.roster=null),_audiobook.bookId=_curBook;const llm_url=audiobookLlmUrl(),model=audiobookLlmModel(),chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text];if(_audiobook.segments&&_audiobook.segments.length>0&&_audiobook.lastText===text){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.rebuild(_audiobook.segments);const speakers=new Set(_audiobook.segments.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${_audiobook.segments.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown);return}const _applyDraft=(draft,view,source)=>{const draftDone=Number.isFinite(Number(draft.done))?Number(draft.done):0,draftTotal=Number.isFinite(Number(draft.total))?Number(draft.total):0;_abStampSegmentPages(draft.segments,draft.pageMarks,text),_audiobook.segments=draft.segments,_audiobook.roster=draft.roster||[],_audiobook.lastText=text,_audiobook.pageMarks=draft.pageMarks||[],_audiobook.rehId=draft.rehId||null,_audiobook.completedChunks=draftDone>0?draftDone:0,_audiobook.completedTotal=draftTotal>0?draftTotal:0,view.rebuild(draft.segments);const ageMs=Date.now()-(draft.savedAt||0),ageMins=Math.round(ageMs/6e4),ageStr=ageMins<1?"gerade eben":ageMins<60?`vor ${ageMins} Min.`:`vor ${Math.round(ageMins/60)} Std.`,pct=draftTotal>0?Math.max(0,Math.min(100,Math.round(draftDone/draftTotal*100))):100,wasDone=draftTotal>0&&draftDone>=draftTotal,canContinue=draftDone>0&&draftTotal>0&&draftDones.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} Charakter${speakers.size!==1?"e":""} \xB7 ${draft.segments.length} Segmente`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)},_localDraft=_abLoadDraft(text);if(_localDraft&&_localDraft.segments&&_localDraft.segments.length>0){const view=audiobookCastView(chunks.length,llm_url,model,!1);if(_applyDraft(_localDraft,view,"local"),_abBookId()){const serverCopy={..._localDraft,bookId:_abBookId(),title:((_a2=window.readerState)==null?void 0:_a2.title)||_localDraft.title||""};fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(serverCopy)}).catch(()=>{})}return}const bookId=_abBookId();if(bookId){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.loadingRestore();try{const serverDraft=await _abLoadDraftServer(bookId);if(serverDraft&&serverDraft.segments&&serverDraft.segments.length>0){try{localStorage.setItem(_abDraftKey(bookId),JSON.stringify(serverDraft))}catch{}_applyDraft(serverDraft,view,"server")}else view.setFreshState("No saved cast was found for this saved book. Starting a new cast will create a new draft.")}catch{view.setFreshState("Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.")}return}audiobookCastView(chunks.length,llm_url,model,!0)}async function audiobookCast(overrideUrl,overrideModel,resume){var _a2,_b2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}if(typeof parseScript!="function"){toast("Rehearser not loaded yet \u2014 try again in a moment","error");return}_audiobook.running=!0,!!_abBookId()||!await _abEnsureLibraryBook()&&typeof toast=="function"&&toast("Autosave will use this browser only until the book is saved to the library.","info");const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text],startIndex=resume&&resume.startIndex>0&&resume.startIndex0;_audiobook.cancel=!1,isResume||_abClearDraft(),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!0);const ac=new AbortController;_audiobook.abort=()=>ac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang(text);let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const view=audiobookCastView(chunks.length,llm_url,model,!1);isResume&&resume.segments&&resume.segments.length&&view.rebuild(resume.segments),view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_a2=document.getElementById("ab-cv-llm-url"))==null?void 0:_a2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b2=document.getElementById("ab-cv-llm-select"))==null?void 0:_b2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}}const allSegments2=isResume?resume.segments.slice():[];_audiobook.liveSegments=allSegments2;const roster=isResume?(resume.roster||[]).slice():[];let narrationOnly=isResume&&resume.narrationOnly||0,degraded=isResume&&resume.degraded||0,completedChunks=startIndex;_abStartDraftAutosave(()=>{allSegments2.length&&_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)});const _pgMarks=(_audiobook.pageMarks||[]).slice();_pgMarks.length&&_pgMarks[0].offset<=2&&_pgMarks.shift();let _pgMarkIdx=0,_pgCharPos=0,_curPageNum=1;if(isResume){for(let k=0;k0&&(_curPageNum=_pgMarks[_pgMarkIdx-1].page+1)}try{for(let i=startIndex;i=_pgMarks[_pgMarkIdx].offset;)_curPageNum=_pgMarks[_pgMarkIdx].page+1,view.pagemark(_curPageNum),_pgMarkIdx++;if(_pgCharPos+=chunks[i].length+1,!audiobookHasDialogue(chunks[i])){const seg={speaker:"Narrator",type:"narration",text:chunks[i],emotion:"",page:_curPageNum};allSegments2.push(seg),narrationOnly++,view.addSegments([seg]),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length);continue}const recent=allSegments2.filter(s=>s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)).slice(-6).map(s=>`${s.speaker}: ${(s.text||"").slice(0,80)}`).join(`
 `);view.processing(chunks[i]);const attributeChunk=async(chunkText,recentCtx,timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS)=>{var _a3,_b3;const body={text:chunkText,known_characters:roster.slice(-40),recent:recentCtx,language,llm_url:((_a3=document.getElementById("ab-cv-llm-url"))==null?void 0:_a3.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b3=document.getElementById("ab-cv-llm-select"))==null?void 0:_b3.value)||model),timeout_seconds:audiobookTimeoutSeconds(timeoutMs)};try{const d=await audiobookAttributeStream(body,view,ac.signal,timeoutMs);return Array.isArray(d.segments)?d.segments:null}catch(err){if(err.name==="AbortError")throw err}try{const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(body)},timeoutMs+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}const d=await r.json();return Array.isArray(d.segments)?d.segments:null}catch(err){if(err.name==="AbortError")throw err;return{error:err.message}}};let segs;try{let result=await attributeChunk(chunks[i],recent),data=null;if(result&&!result.error)data=result;else if(result&&result.error){view.note(`\u26A0\uFE0F Passage ${i+1} timed out \u2014 retrying in two halves\u2026`);const half=Math.floor(chunks[i].length/2),splitAt=chunks[i].lastIndexOf(" ",half)||half,chunkA=chunks[i].slice(0,splitAt).trim(),chunkB=chunks[i].slice(splitAt).trim(),resA=await attributeChunk(chunkA,recent,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS),resB=await attributeChunk(chunkB,recent,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS),segsA=Array.isArray(resA)?resA:null,segsB=Array.isArray(resB)?resB:null;if(segsA||segsB){const arrA=segsA||audiobookSplitByQuotes(chunkA),arrB=segsB||audiobookSplitByQuotes(chunkB);data=[...arrA,...arrB],segsA||(degraded++,view.note(`\u26A0\uFE0F Passage ${i+1} (first half) \u2014 auto-detected (retry also failed)`)),segsB||(degraded++,view.note(`\u26A0\uFE0F Passage ${i+1} (second half) \u2014 auto-detected (retry also failed)`))}else view.note(`\u274C Passage ${i+1} \u2014 LLM error: ${result.error}`),data=null}segs=Array.isArray(data)?data:data&&Array.isArray(data.segments)?data.segments:[];const hasDialogue=segs.some(s=>s.type==="dialogue");if(!segs.length||!hasDialogue&&audiobookHasDialogue(chunks[i])){segs=audiobookSplitByQuotes(chunks[i]),degraded++;const named=segs.filter(s=>s.type==="dialogue"&&!/^Unknown|Unbekannt/i.test(s.speaker)).length;view.note(`Passage ${i+1} \u2014 auto-detected dialogue${named?` (${named} speaker${named!==1?"s":""} from tags)`:" (set speakers in review)"}`)}else{let cleanedSegs=[];for(let s of segs){if(s.text=s.text||"",s.type==="dialogue"&&audiobookIsSpeechTagOnly(s.text)&&(s.type="narration",s.speaker="Narrator",s.emotion=""),s.type==="dialogue"&&s.text.trim().length>10){const tText=s.text.trim(),idx=chunks[i].indexOf(tText);if(idx!==-1){const surround=chunks[i].slice(Math.max(0,idx-8),idx)+chunks[i].slice(idx+tText.length,idx+tText.length+8);/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)||(s.type="narration",s.speaker="Narrator",s.emotion="")}}if(cleanedSegs.length>0){let last=cleanedSegs[cleanedSegs.length-1];if(s.text.trim()===last.text.trim())if(s.type==="dialogue"&&last.type!=="dialogue"){cleanedSegs[cleanedSegs.length-1]=s;continue}else{if(s.type!=="dialogue"&&last.type==="dialogue")continue;continue}if(s.type==="dialogue"&&last.type==="narration"){const tS=s.text.trim(),tL=last.text.trim();tL.endsWith(tS)&&(last.text=tL.slice(0,-tS.length).trim(),last.text||cleanedSegs.pop())}}s.text.trim()&&cleanedSegs.push(s)}let mergedSegs=[];for(let s of cleanedSegs){if(mergedSegs.length>0){let last=mergedSegs[mergedSegs.length-1];if(s.type==="narration"&&last.type==="narration"&&(s.speaker||"Narrator").toLowerCase()==="narrator"&&(last.speaker||"Narrator").toLowerCase()==="narrator"){/[.!?]$/.test(last.text.trim())?last.text=last.text.trimEnd()+`
 
-`+s.text.trimStart():last.text=last.text.trimEnd()+" "+s.text.trimStart();continue}}mergedSegs.push(s)}let respiltSegs=[];for(const s of mergedSegs)s.type==="narration"&&audiobookHasDialogue(s.text)?respiltSegs.push(...audiobookSplitByQuotes(s.text)):respiltSegs.push(s);segs=respiltSegs}}catch(chunkErr){if(chunkErr.name==="AbortError")throw chunkErr;degraded++,segs=audiobookSplitByQuotes(chunks[i]),view.note(`\u274C Passage ${i+1} \u2014 unexpected error (${chunkErr.message}) \u2014 auto-detected dialogue instead`)}audiobookResolveUnknowns(segs,allSegments2.slice(-2),roster),segs.forEach(s=>{const speakerName=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator"?"Narrator":s.speaker;!/^Unknown|Unbekannt/i.test(speakerName)&&!roster.includes(speakerName)&&roster.push(speakerName)}),segs.forEach(s=>{s.page=_curPageNum,allSegments2.push(s)}),view.addSegments(segs),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)}view.update(_audiobook.cancel?completedChunks:chunks.length)}catch(err){err.name==="AbortError"?(_audiobook.cancel=!0,view.note("Casting stopped. Completed passages were preserved.")):(_audiobook.cancel=!0,view.note(`\u274C Casting stopped \u2014 unexpected error: ${err.message}`))}finally{_abStopDraftAutosave(),_audiobook.running=!1,_audiobook.abort=null}if(!allSegments2.length){if(_audiobook.cancel){view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}view.done(),toast("No segments produced","error");return}try{const _quoteFixedSegs=_audiobookFixOrphanedQuoteMarks(allSegments2),_mergedSegs=_audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs);allSegments2.length=0,allSegments2.push(..._mergedSegs),_audiobook.segments=allSegments2,_audiobook.lastText=text,_audiobook.roster=roster,_audiobook.narratedPassages=narrationOnly,_audiobook.degraded=degraded,_audiobook.completedChunks=_audiobook.cancel?completedChunks:chunks.length,_audiobook.completedTotal=chunks.length,_abSaveDraft(allSegments2,roster,text,_audiobook.completedChunks,chunks.length),audiobookSaveAsRehearsal({silent:!0}),_audiobook.cancel&&toast("Casting stopped early. Progress preserved.","info");const speakers=new Set(allSegments2.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${_audiobook.cancel&&completedChunks{}),!0}catch(err){console.warn("[audiobook] failed to build rehearser record directly, falling back:",err)}await audiobookSaveAsRehearsal({silent:!0});const{script,emotions}=audiobookBuildScript(segs);return await audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions),rehState.lines.length&&typeof buildScriptPage=="function"?(buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine()):typeof showPhase=="function"&&showPhase(3),!0}function audiobookShowPreview(){var _a2;audiobookOpenCurrentInRehearser()}function audiobookApplyPreviewAndOpen(){const segs=_audiobook.segments;document.querySelectorAll("#audiobook-seglist .audiobook-seg-sp").forEach(inp=>{const i=+inp.dataset.i,v=inp.value.trim()||"Narrator";segs[i].speaker=v,segs[i].type=v.toLowerCase()==="narrator"?"narration":"dialogue"}),document.querySelectorAll("#audiobook-seglist .audiobook-seg-emo").forEach(inp=>{const i=+inp.dataset.i;segs[i].emotion=inp.value.trim()});const{script,emotions}=audiobookBuildScript(segs);audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions)}function audiobookBuildScript(segments){let script="";const emotions=[],marks=_audiobook.pageMarks||[],src=_audiobook.lastText||"";let markIdx=0,searchPos=0;marks.length&&marks[0].offset<=2&&(markIdx=1);for(const s of segments){const t=(s.text||"").trim();if(!t)continue;if(src&&markIdx=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)script.trim()&&(script+=`
+`+s.text.trimStart():last.text=last.text.trimEnd()+" "+s.text.trimStart();continue}}mergedSegs.push(s)}let respiltSegs=[];for(const s of mergedSegs)s.type==="narration"&&audiobookHasDialogue(s.text)?respiltSegs.push(...audiobookSplitByQuotes(s.text)):respiltSegs.push(s);segs=respiltSegs}}catch(chunkErr){if(chunkErr.name==="AbortError")throw chunkErr;degraded++,segs=audiobookSplitByQuotes(chunks[i]),view.note(`\u274C Passage ${i+1} \u2014 unexpected error (${chunkErr.message}) \u2014 auto-detected dialogue instead`)}audiobookResolveUnknowns(segs,allSegments2.slice(-2),roster),segs.forEach(s=>{const speakerName=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator"?"Narrator":s.speaker;!/^Unknown|Unbekannt/i.test(speakerName)&&!roster.includes(speakerName)&&roster.push(speakerName)}),segs.forEach(s=>{s.page=_curPageNum,allSegments2.push(s)}),view.addSegments(segs),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)}view.update(_audiobook.cancel?completedChunks:chunks.length)}catch(err){err.name==="AbortError"?(_audiobook.cancel=!0,view.note("Casting stopped. Completed passages were preserved.")):(_audiobook.cancel=!0,view.note(`\u274C Casting stopped \u2014 unexpected error: ${err.message}`))}finally{_abStopDraftAutosave(),_audiobook.running=!1,_audiobook.abort=null}if(!allSegments2.length){if(_audiobook.cancel){view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}view.done(),toast("No segments produced","error");return}try{const _quoteFixedSegs=_audiobookFixOrphanedQuoteMarks(allSegments2),_mergedSegs=_audiobookMergeAdjacentSameSpeaker(_quoteFixedSegs);allSegments2.length=0,allSegments2.push(..._mergedSegs),_audiobook.segments=allSegments2,_audiobook.lastText=text,_audiobook.roster=roster,_audiobook.narratedPassages=narrationOnly,_audiobook.degraded=degraded,_audiobook.completedChunks=_audiobook.cancel?completedChunks:chunks.length,_audiobook.completedTotal=chunks.length,_abSaveDraft(allSegments2,roster,text,_audiobook.completedChunks,chunks.length),audiobookSaveAsRehearsal({silent:!0}),_audiobook.cancel&&toast("Casting stopped early. Progress preserved.","info");const speakers=new Set(allSegments2.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),stoppedEarly=_audiobook.cancel&&completedChunks(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length>10&&setTimeout(()=>{audiobookAutoRepairCast().catch(e=>console.warn("[auto-repair]",e))},800)}catch(e){console.error("[audiobookCast] post-processing failed",e),view.done({stopped:!0,message:"Finished casting, but saving/cleanup failed: "+e.message+" \u2014 your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted."}),toast("Casting finished but cleanup failed: "+e.message,"error")}}async function audiobookOpenCurrentInRehearser(){var _a2;const segs=_audiobook.segments||[];if(!segs.length)return toast("No cast to open","error"),!1;if(typeof window.rehLoadRecord=="function"&&typeof _audiobookBuildRehRecord=="function")try{const rec=await _audiobookBuildRehRecord(segs);return _audiobook.rehId&&(rec.id=_audiobook.rehId),(_a2=document.getElementById("audiobook-preview"))==null||_a2.remove(),typeof navTo=="function"&&navTo("s-rehearser"),window.rehLoadRecord(rec),rehState.lines.length&&typeof buildScriptPage=="function"?(buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine()):typeof showPhase=="function"&&showPhase(3),toast("Opened cast in Script Rehearser","success"),audiobookSaveAsRehearsal({silent:!0}).catch(()=>{}),!0}catch(err){console.warn("[audiobook] failed to build rehearser record directly, falling back:",err)}await audiobookSaveAsRehearsal({silent:!0});const{script,emotions}=audiobookBuildScript(segs);return await audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions),rehState.lines.length&&typeof buildScriptPage=="function"?(buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine()):typeof showPhase=="function"&&showPhase(3),!0}function audiobookShowPreview(){var _a2;audiobookOpenCurrentInRehearser()}function audiobookApplyPreviewAndOpen(){const segs=_audiobook.segments;document.querySelectorAll("#audiobook-seglist .audiobook-seg-sp").forEach(inp=>{const i=+inp.dataset.i,v=inp.value.trim()||"Narrator";segs[i].speaker=v,segs[i].type=v.toLowerCase()==="narrator"?"narration":"dialogue"}),document.querySelectorAll("#audiobook-seglist .audiobook-seg-emo").forEach(inp=>{const i=+inp.dataset.i;segs[i].emotion=inp.value.trim()});const{script,emotions}=audiobookBuildScript(segs);audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions)}function audiobookBuildScript(segments){let script="";const emotions=[],marks=_audiobook.pageMarks||[],src=_audiobook.lastText||"";let markIdx=0,searchPos=0;marks.length&&marks[0].offset<=2&&(markIdx=1);for(const s of segments){const t=(s.text||"").trim();if(!t)continue;if(src&&markIdx=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)script.trim()&&(script+=`
 \f${marks[markIdx].page+1}
 `),markIdx++}s.type==="dialogue"&&s.speaker&&s.speaker.toLowerCase()!=="narrator"?(script+=`
 `+s.speaker.toUpperCase()+`
diff --git a/static/index.html b/static/index.html
index 6c93981..cc413ae 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
   
   
   
-  
+  
   
   
   
@@ -27,7 +27,7 @@
 
   
   
-  
+  
 
 
   
@@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
 
 
 
-
+
 
 
 
diff --git a/static/js/audiobook.js b/static/js/audiobook.js
index 917d3dd..ecf1844 100644
--- a/static/js/audiobook.js
+++ b/static/js/audiobook.js
@@ -184,6 +184,44 @@ function audiobookJoinSegmentText(a, b) {
   return left + ' ' + right;
 }
 
+// ── Mislabeled-dialogue detector ────────────────────────────────────────────
+// Spoken lines routinely come out of PDF extraction typed as `narration` —
+// usually because the » « quote marks were lost. Those segments are invisible
+// to "Identify unknown characters" (it only scans type === 'dialogue'), so no
+// number of re-runs can ever fix them, and the only pass that could ("Verify
+// all characters") re-sends every narration segment to the LLM: 1036 of them
+// on a single novel, which is slow, GPU-hungry and risks disturbing lines that
+// were already right. This scores narration segments for direct-speech
+// signals instead, so only the genuinely suspicious ones get spent on an LLM
+// call — measured on a real book: 36 candidates instead of 1036 (29x fewer),
+// with the true positives ranked at the top.
+const _AB_INQUIT_VERBS = '(?:sagte|rief|fragte|antwortete|erwiderte|murmelte|fl[üu]sterte|br[üu]llte|entgegnete|meinte|knurrte|zischte|stammelte|schrie|dr[äa]ngte|herrschte|seufzte|wisperte|grunzte|befahl|erkl[äa]rte|fuhr fort|setzte hinzu|f[üu]gte hinzu)';
+function _abSuspectNarration(segs, minScore) {
+  minScore = minScore || 3;
+  const norm = i => String(segs[i]?.text || '').replace(/\s+/g, ' ').trim();
+  const out = new Set();
+  for (let i = 0; i < segs.length; i++) {
+    if (segs[i]?.type !== 'narration') continue;
+    const t = norm(i), nxt = i + 1 < segs.length ? norm(i + 1) : '';
+    let score = 0;
+    // Strongest tell: the NEXT segment is a bare inquit ", drängte Marcian
+    // ungeduldig." — which means THIS segment was the quote it belongs to.
+    if (new RegExp('^[\\s,»«"\'\\-–]{0,4}(?:\\w+\\s+){0,2}' + _AB_INQUIT_VERBS + '\\b', 'i').test(nxt)) score += 5;
+    if (t.includes('»') || t.includes('«')) score += 4;                       // typed narration yet carries quote marks
+    const p12   = /\b(ich|du|wir|ihr|mein|dein|dich|euch)\b/i.test(t);        // narration is 3rd-person past
+    const excl  = /[!?]\s*$/.test(t);
+    const imper = /^(Bringt|Los|Mach|Kommt|Geht|Nehmt|Haltet|Schafft|T[öo]tet|Sieh|Hört|Wartet)\b/.test(t);
+    if (p12) score += 1;
+    if (excl) score += 1;
+    if (imper) score += 3;
+    if (p12 && excl) score += 1;
+    if (t.length < 160 && (p12 || excl || imper)) score += 1;
+    if (score >= minScore) out.add(i);
+  }
+  return out;
+}
+window._abSuspectNarration = _abSuspectNarration;
+
 function audiobookRecastGroups(unknownIdxs, segs) {
   const groups = [];
   let cur = [];
@@ -1523,7 +1561,7 @@ ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ei
 1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) IMMER zum tatsächlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts.
 2. Doppelpunkt-Regel: Endet ein Erzählersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter spricht das nächste Zitat; "Ein anderer fragte verschlafen:" → dieser andere spricht).
 3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei unpersönlicher Formel: "»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach das Zitat.
-4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat handelt, spricht meist (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau).
+4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist — ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (spürte, hörte, fühlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUHÖRER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau).
 5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" → X spricht das nächste Zitat, und Y ist der wahrscheinlichste Antwortende.
 6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab — auch über viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette lückenlos zurück zur letzten eindeutigen Nennung und führe sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
 7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters?
@@ -1532,6 +1570,13 @@ ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ei
 10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln absolut unmöglich ist. Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung gegenüber 'Unknown'.
 11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint.
 12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht 'Unknown'.
+13. ZUHÖRER-AUSSCHLUSS (negative Evidenz — sehr wichtig): Ein Name im Erzählsatz neben einem Zitat ist HÄUFIG der Zuhörer, nicht der Sprecher. Schließe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian spürte ihre Tränen auf seiner Brust; Marcian hörte darüber hinweg → Marcian HÖRT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: Mögen die Götter dich schützen, Marcian. → Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr übrig bleibt, suche WEITER ZURÜCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) — der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt für sich allein NIEMALS 'Unknown'.
+16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER — das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." → das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).
+17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") — niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.
+18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe → "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt.
+19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. »Ich werde jetzt ...«"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf — siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergnügen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen.
+14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er → letzter Mann, sie → letzte Frau). Solche Zeilen dürfen niemals 'Unknown' bleiben.
+15. MEHRERE KANDIDATEN in der Nähe: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuhörer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegenüber jemandem in Objekt- oder Präpositionalstellung; (c) prüfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zulässig.
 
 FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS:
 - speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge.
@@ -1595,7 +1640,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
 1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) IMMER zum tatsächlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts.
 2. Doppelpunkt-Regel: Endet ein Erzählersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter spricht das nächste Zitat; "Ein anderer fragte verschlafen:" → dieser andere spricht).
 3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei unpersönlicher Formel: "»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach das Zitat.
-4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat handelt, spricht meist (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau).
+4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist — ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (spürte, hörte, fühlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUHÖRER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau).
 5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" → X spricht das nächste Zitat, und Y ist der wahrscheinlichste Antwortende.
 6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab — auch über viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette lückenlos zurück zur letzten eindeutigen Nennung und führe sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
 7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters?
@@ -1623,8 +1668,8 @@ STRIKTE FORMAT- UND TEXTREGELN:
     // getting cut into a new (wrong) speaker at the dash, and narration like
     // "Marcians Stimme wirkte nicht mehr so fest" right before an unattributed
     // line wasn't being used to resolve it away from 'Unknown'.
-    if (!/Gedankenstrich-Pause-Regel/.test(p)) {
-      const newRules = '11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint.\n12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht \'Unknown\'.';
+    if (!/Gedankenstrich-Pause-Regel/.test(p) || !/ZUHÖRER-AUSSCHLUSS/.test(p) || !/FORTSETZUNGS-REGEL/.test(p) || !/EPITHETA/.test(p)) {
+      const newRules = '11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint.\n12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht \'Unknown\'.\n13. ZUHÖRER-AUSSCHLUSS (negative Evidenz — sehr wichtig): Ein Name im Erzählsatz neben einem Zitat ist HÄUFIG der Zuhörer, nicht der Sprecher. Schließe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian spürte ihre Tränen auf seiner Brust; Marcian hörte darüber hinweg → Marcian HÖRT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: Mögen die Götter dich schützen, Marcian. → Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr übrig bleibt, suche WEITER ZURÜCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) — der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt für sich allein NIEMALS \'Unknown\'.\n16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER — das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." → das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).\n17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") — niemals \'Unknown\'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.\n18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe → "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals \'Unknown\', damit die Figur durchgehend dieselbe Stimme behaelt.\n19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. »Ich werde jetzt ...«"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker \'Narrator\' und type \'narration\', das Zitat ein eigenes Segment mit type \'dialogue\' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf — siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes \'Unknown\' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergnügen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als \'Unknown\' zu lassen.\n14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er → letzter Mann, sie → letzte Frau). Solche Zeilen dürfen niemals \'Unknown\' bleiben.\n15. MEHRERE KANDIDATEN in der Nähe: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuhörer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegenüber jemandem in Objekt- oder Präpositionalstellung; (c) prüfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist \'Unknown\' zulässig.';
       const unknownRuleRe = /(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/;
       if (unknownRuleRe.test(p)) {
         p = p.replace(unknownRuleRe, `$1\n${newRules}`);
@@ -3780,15 +3825,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
       // most common next step, not variants of each other.
       foot.innerHTML = `${continueBtnHtml}`;
 
-      const runVerificationPass = () => {
-        // Deliberately NOT a copy of the first-pass attribution prompt. Pass 1
-        // classifies from scratch; this pass receives a segment that ALREADY
-        // has a label (a character's name, or Narrator) and has to judge
-        // whether that label actually holds up — a plausibility/confidence
-        // check rather than fresh attribution. It also runs on every Narrator
-        // segment (not just Unknown dialogue), specifically to catch spoken
-        // lines that got swallowed into narration on the first pass.
-        const verificationPrompt = `Du bist ein Qualitätsprüfer für die Sprecherzuordnung eines bereits analysierten deutschen Hörbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT — du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters gehört, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer könnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erzähltext hineingezogen?".
+      const verificationPrompt = `Du bist ein Qualitätsprüfer für die Sprecherzuordnung eines bereits analysierten deutschen Hörbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT — du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters gehört, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer könnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erzähltext hineingezogen?".
 
 WORAUF DU PRÜFST — IN DIESER REIHENFOLGE (höchste Priorität zuerst):
 1. ZUERST alle 'Unknown'-Zeilen: Löse den Sprecher über Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegesprächen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung möglich ist. Das ist die dringendste Kategorie — eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete.
@@ -3799,7 +3836,9 @@ WORAUF DU PRÜFST — IN DIESER REIHENFOLGE (höchste Priorität zuerst):
 DEDUKTIONS-WERKZEUGE für die Sprecherzuordnung:
 - Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende.
 - Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person — auch ohne Anführungszeichen. Das ist der häufigste Fehler der ersten Zuordnung: gesprochene Rede wird fälschlich als Narration markiert, weil die Anführungszeichen bei der Extraktion verloren gingen.
-- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile handelt (aufblickt, sich umdreht, den Kopf schüttelt), ist der wahrscheinlichste Sprecher dieser Zeile.
+- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile AKTIV HANDELT (aufblickt, sich umdreht, den Kopf schüttelt), ist der wahrscheinlichste Sprecher dieser Zeile.
+- ZUHÖRER-AUSSCHLUSS (negative Evidenz): Wahrnehmungs- und Reaktionsverben kennzeichnen den ZUHÖRER, nicht den Sprecher ("Marcian spürte ihre Tränen", "Marcian hörte darüber hinweg" → Marcian hört zu, jemand anderes spricht). Ebenso ist eine Person, die im Zitat per Vokativ ANGEREDET wird ("..., Marcian."), nicht der Sprecher. Prüfe diese Ausschlüsse ZUERST, bevor du einen Namen aus dem Nachbarsatz übernimmst — das ist die häufigste Fehlerquelle bei nicht zugeordneten Zeilen.
+- Pronomen-Inquit bei geteilten Zitaten: Eine Zeile wie ", antwortete er knapp." gehört zum vorherigen Zitat; löse das Pronomen auf die zuletzt genannte Person passenden Geschlechts VOR dem Zitat auf (er → letzter Mann, sie → letzte Frau).
 - Ping-Pong-Prinzip: Zwei Personen im Gespräch wechseln sich strikt ab, auch ohne Tags — verfolge die Kette zur letzten eindeutigen Nennung zurück.
 - Pronomen-Auflösung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts.
 - Rollenbezeichnungen sind gültige Sprecher (z.B. 'Ork', 'Der Fremde', 'Wächter') statt 'Unknown'.
@@ -3815,6 +3854,15 @@ ABSOLUTE REGELN:
 - Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
 - Mische NIEMALS Narration und Dialog in einem Segment.
 - Sei konservativ: ändere eine Zeile nur, wenn du nach dieser Prüfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend wäre — nicht jede Zeile muss sich ändern.`;
+      const runVerificationPass = () => {
+        // Deliberately NOT a copy of the first-pass attribution prompt. Pass 1
+        // classifies from scratch; this pass receives a segment that ALREADY
+        // has a label (a character's name, or Narrator) and has to judge
+        // whether that label actually holds up — a plausibility/confidence
+        // check rather than fresh attribution. It also runs on every Narrator
+        // segment (not just Unknown dialogue), specifically to catch spoken
+        // lines that got swallowed into narration on the first pass.
+        
         const choice = audiobookCurrentCastLlm(panel);
         const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model);
         closePanel();
@@ -3958,6 +4006,16 @@ ABSOLUTE REGELN:
         applyPromptAndRun(audiobookCast);
       };
       const runIdentifyUnknown = () => applyPromptAndRun(audiobookRecastUnknown);
+      // Spoken lines typed as narration are invisible to runIdentifyUnknown (it only
+      // scans type==='dialogue'), and "Verify all" would re-send all ~1000 narration
+      // segments. This re-checks only the ones scoring as direct speech.
+      const runRepairNarration = () => {
+        const segs = _audiobook.segments || [];
+        const n = (typeof _abSuspectNarration === 'function') ? _abSuspectNarration(segs).size : 0;
+        if (!n) { toast('No narration lines look like mislabeled dialogue', 'success'); return; }
+        toast(`Re-checking ${n} narration line${n !== 1 ? 's' : ''} that look like spoken dialogue…`, 'info');
+        audiobookRecastUnknown(null, null, { repairNarration: true, prompt: verificationPrompt });
+      };
       const runCastAll = () => {
         if (typeof window.csForReader === 'function') window.csForReader();
         else if (typeof csForReader === 'function') csForReader();
@@ -4109,9 +4167,9 @@ ABSOLUTE REGELN:
       const viewCastMenu = foot.querySelector('#ab-cv-menu-viewcast');
       identifyMenu?.addEventListener('click', () => _abToggleFootMenu(identifyMenu, [
         { icon: 'mdi-refresh', label: 'Identify all characters', title: 'Scan the text and build the cast list from scratch', onClick: runIdentifyAll, danger: true },
+        { icon: 'mdi-comment-alert-outline', label: 'Repair mislabeled dialogue', title: 'Finds spoken lines that were typed as narration (usually because the » « quote marks were lost in extraction) and re-checks only those — far cheaper than verifying every narration line', onClick: runRepairNarration },
         { icon: 'mdi-account-question-outline', label: 'Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown },
-        { icon: 'mdi-shield-check-outline', label: 'Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass },
-        { icon: 'mdi-account-search-outline', label: 'Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass },
+        { icon: 'mdi-auto-fix', label: 'Auto-repair cast', title: 'Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed', onClick: () => audiobookAutoRepairCast() },
         { icon: 'mdi-repeat', label: 'Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => {
           const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10');
           if (input == null) return;
@@ -4119,6 +4177,8 @@ ABSOLUTE REGELN:
           if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; }
           audiobookRecastUntilThreshold(threshold);
         } },
+        { icon: 'mdi-shield-check-outline', label: 'Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass },
+        { icon: 'mdi-account-search-outline', label: 'Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass },
       ]));
       castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
         { icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
@@ -4270,10 +4330,14 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
   // non-Unknown dialogue stays out of scope either way; re-litigating lines
   // that are already clearly attributed isn't what was asked for.
   const unknownIdxs = [];
+  const _abRepairSuspects = options.repairNarration ? _abSuspectNarration(segs) : null;
   for (let i = 0; i < segs.length; i++) {
     const s = segs[i];
     const isUnknownDialogue = s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker));
-    const isNarratorCandidate = options.includeNarrator && s.type === 'narration';
+    // includeNarrator re-checks EVERY narration line (thorough but expensive);
+    // repairNarration re-checks only the ones that actually look like speech.
+    const isNarratorCandidate = (options.includeNarrator && s.type === 'narration')
+      || (options.repairNarration && s.type === 'narration' && _abRepairSuspects && _abRepairSuspects.has(i));
     if (isUnknownDialogue || isNarratorCandidate) unknownIdxs.push(i);
   }
   if (!unknownIdxs.length) {
@@ -4497,10 +4561,16 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
     }
 
     const afterUnknownCount = countUnknownDialogue(segs);
-    if (!_audiobook.cancel && afterUnknownCount > beforeUnknownCount) {
+    // The `!cancel` guard used to sit in front of this check, so STOPPING a run
+    // skipped the rollback entirely and a half-finished pass that had made the
+    // cast worse was kept silently — confirmed live: a stopped recast took a book
+    // from 80 Unknown lines to 159, with no warning and no way back. A run that
+    // increases Unknown speakers is never worth keeping, finished or interrupted.
+    if (afterUnknownCount > beforeUnknownCount) {
       segs.splice(0, segs.length, ...originalSegments);
-      view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
-      toast('Quality run rolled back because it increased Unknown speakers.', 'error');
+      const how = _audiobook.cancel ? 'Stopped run' : 'Quality run';
+      view.note(`${how} rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
+      toast(`${how} rolled back because it increased Unknown speakers.`, 'error');
     }
 
     // Recast-unknown only fixes speakers on already-cast segments — it never adds
@@ -4535,6 +4605,47 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
 // genuinely irreducible residue of ambiguous lines — either way, more passes
 // won't help). User-requested follow-up: this used to only be doable by
 // calling audiobookRecastUnknown() directly from the browser console.
+// ── Auto-repair after a full cast ───────────────────────────────────────────
+// A fresh "Identify all characters" run routinely leaves a tail of Unknown
+// speakers plus spoken lines mis-typed as narration. Fixing those by hand meant
+// knowing which of the six menu passes to run, in which order — repair BEFORE
+// identify (repair converts narration into new, still-unattributed dialogue),
+// and the expensive full verify only as a last resort. This runs that sequence
+// automatically and stops as soon as it stops helping, so a book that casts
+// cleanly costs nothing extra.
+const AB_AUTO_REPAIR_KEY = 'ab-auto-repair-enabled';
+function audiobookAutoRepairEnabled() {
+  try { return localStorage.getItem(AB_AUTO_REPAIR_KEY) !== '0'; } catch (_) { return true; }
+}
+async function audiobookAutoRepairCast(threshold = 10, maxRounds = 4) {
+  const segs = _audiobook.segments || [];
+  if (!segs.length) { toast('Nothing cast yet to repair', 'error'); return; }
+  const countUnknown = () => (_audiobook.segments || []).filter(s =>
+    s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
+  let prev = countUnknown();
+  if (prev <= threshold) { toast(`Cast looks clean — ${prev} unknown speaker${prev !== 1 ? 's' : ''}`, 'success'); return; }
+  toast(`Auto-repair: ${prev} unknown speakers — running repair passes…`, 'info');
+  for (let round = 1; round <= maxRounds; round++) {
+    // repairNarration also re-checks Unknown dialogue in the same call, so one
+    // pass per round covers menu steps 2 and 3 together.
+    await audiobookRecastUnknown(null, null, { repairNarration: true });
+    if (_audiobook.cancel) { toast('Auto-repair stopped.', 'info'); return; }
+    const now = countUnknown();
+    if (now <= threshold) { toast(`Auto-repair done: ${now} unknown left after ${round} round${round !== 1 ? 's' : ''}.`, 'success'); return; }
+    if (now >= prev) break;          // no further progress — stop burning GPU time
+    prev = now;
+  }
+  // Still above target: fall back to the exhaustive sweep, once.
+  toast(`Still ${prev} unknown — running full verification pass…`, 'info');
+  await audiobookRecastUnknown(null, null, { includeNarrator: true });
+  const final = countUnknown();
+  toast(final <= threshold
+    ? `Auto-repair done: ${final} unknown left.`
+    : `Auto-repair finished: ${final} unknown remain (target was under ${threshold}).`,
+    final <= threshold ? 'success' : 'error');
+}
+window.audiobookAutoRepairCast = audiobookAutoRepairCast;
+
 async function audiobookRecastUntilThreshold(threshold = 10, maxPasses = 8) {
   if (_audiobook.running) { toast('Casting is already running', 'error'); return; }
   const segs = _audiobook.segments;
@@ -4544,7 +4655,11 @@ async function audiobookRecastUntilThreshold(threshold = 10, maxPasses = 8) {
   if (prev <= threshold) { toast(`Already at ${prev} unknown speakers (target: <${threshold})`, 'success'); return; }
   toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})…`, 'info');
   for (let pass = 1; pass <= maxPasses; pass++) {
-    await audiobookRecastUnknown(null, null, { includeNarrator: true });
+    // Each round re-checks Unknown dialogue AND narration lines that score as
+    // mislabeled speech. This used to pass includeNarrator, which re-sent every
+    // narration segment (~1000 on a novel) to the LLM on EVERY iteration —
+    // enormously expensive and it re-litigated lines that were already correct.
+    await audiobookRecastUnknown(null, null, { repairNarration: true });
     if (_audiobook.cancel) { toast('Stopped — cancelled mid-pass.', 'info'); return; }
     const now = countUnknown();
     if (now <= threshold) { toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass !== 1 ? 'es' : ''}).`, 'success'); return; }
@@ -5024,6 +5139,20 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
     const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
     const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
     view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
+
+    // A fresh cast usually leaves a tail of Unknown speakers and some spoken
+    // lines typed as narration. Rather than leaving the user to work out which
+    // of the six passes to run and in what order, chain the repair sequence
+    // automatically — but only when the cast actually finished and the count is
+    // genuinely high, and never after a stopped run (the partial state is not a
+    // fair basis for it). Switch it off via localStorage 'ab-auto-repair-enabled'.
+    if (!stoppedEarly && !_audiobook.cancel && audiobookAutoRepairEnabled()) {
+      const unknownNow = allSegments.filter(s =>
+        s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
+      if (unknownNow > 10) {
+        setTimeout(() => { audiobookAutoRepairCast().catch(e => console.warn('[auto-repair]', e)); }, 800);
+      }
+    }
   } catch (e) {
     console.error('[audiobookCast] post-processing failed', e);
     view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });