diff --git a/CHANGELOG.md b/CHANGELOG.md index 1831cba..fb3779e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **Emotion instructions are now always written in English**, even for non-English voices (the spoken text and the native-accent clause stay in the book's own language). Confirmed by controlled A/B testing — same line, same voice, only the instruct language varying — that Qwen3-TTS follows English emotion instructions far more reliably: German instructs produced barely-differentiated output, while English instructs yield a clean, correctly-ordered prosodic gradient (whisper 128 Hz → sad 142 → neutral 179 → scared 203 → happy 225 → angry 269 Hz), with sensible duration changes too (sad slowest, scared fastest). - **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.26] — 2026-08-12 + +### Fixed +- **Segments fusing two speakers' turns are no longer auto-attributed to one of them.** A trailing inquit only names the *last* speaker, so applying it to a block containing several turns ("…ausliefern und …" + "Er hat es nicht so gemeint" + ", unterbrach die blonde Perdia Lysandra.") silently mis-credited the earlier turn — worse than leaving it Unknown, because a wrong attribution is invisible while an Unknown is not. Blocks holding multiple turns are now skipped by the grammar rules and left for the LLM pass or manual splitting. + ## [1.20.25] — 2026-08-12 ### Fixed diff --git a/VERSION b/VERSION index 5b60692..e399e5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.20.25 +1.20.26 diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 4ee5a29..3a5840a 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -1177,7 +1177,7 @@ ${lines.trim()} `))>=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|grunzte|versetzte|unterbrach|dr\xE4ngte|herrschte|keuchte|hauchte|\xE4chzte|st\xF6hnte|jammerte|spottete|h\xF6hnte|warf\\s+ein|setzte\\s+hinzu|erkl\xE4rte|verk\xFCndete|widersprach|beharrte|gestand|best\xE4tigte|erinnerte|dachte|\xFCberlegte|sinnierte|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&&_nextIsInquitText&&next&&next.type!=="narration"&&(next.type="narration",next.speaker="Narrator",next.emotion=""),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+` +`)}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|grunzte|versetzte|unterbrach|dr\xE4ngte|herrschte|keuchte|hauchte|\xE4chzte|st\xF6hnte|jammerte|spottete|h\xF6hnte|warf\\s+ein|setzte\\s+hinzu|erkl\xE4rte|verk\xFCndete|widersprach|beharrte|gestand|best\xE4tigte|erinnerte|dachte|\xFCberlegte|sinnierte|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&&_nextIsInquitText&&next&&next.type!=="narration"&&(next.type="narration",next.speaker="Narrator",next.emotion=""),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+` `+curText:lastText+" "+curText;continue}}out.push(Object.assign({},s))}return out}function _audiobookMergeSplitSentences(segments){const out=[];let merged=0;for(const s of segments){const prev=out[out.length-1],cur=String((s==null?void 0:s.text)||"").trim();if(prev&&prev.type==="narration"&&(s==null?void 0:s.type)==="narration"&&cur){const prevText=String(prev.text||"").trimEnd(),endsOpen=prevText&&!/[.!?:;…»"'\)\]]$/.test(prevText),startsLower=/^[a-zäöüß]/.test(cur),prevIsInquit=new RegExp(_AB_INQUIT_VERBS+"[^.!?]{0,40},\\s*$","i").test(prevText);if(endsOpen&&startsLower&&!prevIsInquit){prev.text=prevText+" "+cur,merged++;continue}}out.push(s)}return{segments:out,merged}}function _audiobookDedupNearbyDuplicates(segments){const QUOTE_CHARS='\xBB\xAB\u201E"\u2018\u2019\u203A\u2039',stripRe=new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`,"g"),norm=t=>String(t||"").replace(stripRe,"").toLowerCase().replace(/\s+/g," ").trim(),WINDOW=30,MIN_LEN=20,out=[];let removed=0;for(const s of segments){const nt=norm(s==null?void 0:s.text);if(nt.length>=MIN_LEN){let isDup=!1;for(let k=out.length-1;k>=0&&out.length-k<=WINDOW;k--)if(out[k].type===s.type&&norm(out[k].text)===nt){isDup=!0;break}if(isDup){removed++;continue}}out.push(s)}return{segments:out,removed}}function audiobookSplitByQuotes(text){const spans=[];for(let i=0;i0&&!/[\s([{—–-]/.test(text[i-1]))continue;const raw=text.slice(i+1),closeIdx=raw.indexOf(close),endInRaw=_abUnclosedQuoteEnd(raw,closeIdx),quote=raw.slice(0,endInRaw).trim();if(!quote)continue;const consumedClose=closeIdx>=0&&endInRaw===closeIdx;spans.push({start:i,end:i+1+endInRaw+(consumedClose?1:0),quote}),i=spans[spans.length-1].end-1}if(!spans.length)return[{speaker:"Narrator",type:"narration",text,emotion:""}];const out=[];let last=0;for(let k=0;k{});const rehModel=$("reh-llm-model");return rehModel&&cleanModel&&[...rehModel.options].some(o=>o.value===cleanModel)&&(rehModel.value=cleanModel),{url:cleanUrl,model:cleanModel}}function audiobookScopeText(){var _a2,_b2,_c2;if(_audiobook.pageMarks=[],typeof readerScopeIndices!="function"||!((_a2=readerState==null?void 0:readerState.sentences)!=null&&_a2.length))return"";const idxs=readerScopeIndices(),anchors=[];let lastPage=null;for(const i of idxs){const u=readerState.sentences[i],pg=(_c2=(_b2=u==null?void 0:u.words)==null?void 0:_b2[0])==null?void 0:_c2.page;pg!=null&&pg!==lastPage&&(anchors.push({page:pg,anchor:(u.text||"").trim().slice(0,40)}),lastPage=pg)}let raw="";for(const i of idxs){const u=readerState.sentences[i];u!=null&&u.text&&(raw+=raw?(u.paraStart?` diff --git a/static/index.html b/static/index.html index 496e407..d24c7d4 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 96537e7..7a9f7af 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -804,8 +804,15 @@ function audiobookResolveUnknowns(segs, prevTail, roster) { // mistyped it as dialogue — confirmed live: the quote before such a segment // stayed Unknown purely because the inquit carried the wrong type. Accept // either type here and repair the type while we are at it. - const _nextIsInquitText = next && new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\b').test(String(next.text || '').trim()); - if (!who && (next?.type === 'narration' || _nextIsInquitText)) { + // A segment holding several turns separated by a blank line is TWO speakers + // fused into one ("...ausliefern und ...\n\nEr hat es nicht so gemeint" — + // Lysandra, then Perdia interrupting). A trailing inquit only names the LAST + // speaker, so applying it to the whole block silently mis-credits the earlier + // turn. Leaving it Unknown is the honest outcome: visibly unresolved beats + // confidently wrong, and the split can then be made by hand or by the LLM pass. + const _isFusedTurns = /\n\s*\n/.test(String(s.text || '').trim()); + const _nextIsInquitText = !_isFusedTurns && next && new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\b').test(String(next.text || '').trim()); + if (!who && !_isFusedTurns && (next?.type === 'narration' || _nextIsInquitText)) { const nt = String(next.text || '').trim(); // Split quote whose inquit uses a PRONOUN rather than a name: // "»Ja, ungewöhnlich«" + ", antwortete er knapp." The rules below all