"}).join(""),list.querySelectorAll(".lib-book").forEach(function(el){const id=el.dataset.id,title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||(window._readerStartView="main",typeof navTo=="function"&&navTo("s-reader"),typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(id))});const reh=el.querySelector(".lib-act-rehearse");reh&&reh.addEventListener("click",function(e){e.stopPropagation(),productionOpenInRehearser(title)});const del=el.querySelector(".lib-act-del-book");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete book?","Audio files will be removed.",async function(){try{if(!(await fetch(LIB_READER_API+"/"+id,{method:"DELETE"})).ok)throw new Error("delete failed");toast("Book deleted","success"),libraryRenderBooks()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function libraryRenderPlays(){const list=document.getElementById("lib-plays-list");if(!list)return;list.innerHTML=_libSkeleton(3);let all=[];try{all=typeof rehDbGetAll=="function"?await rehDbGetAll():[]}catch{all=[]}if(!all.length){list.innerHTML='
No theater plays yet.
Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.
';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const speakers=Object.keys(rec.cast||{}),total=typeof parseScript=="function"?parseScript(rec.script||"").filter(function(l){return l.type==="dialog"}).length:0,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",cov=libBookCover(rec.title||"Untitled"),avatars=speakers.slice(0,5).map(function(sp){return''+(sp[0]||"?").toUpperCase()+""}).join("");return'
'+escHtml(rec.title||"Untitled")+'
'+avatars+"
"+total+" lines \xB7 "+speakers.length+' cast
'+pct+"% \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-play").forEach(function(el){const id=parseInt(el.dataset.id,10),title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||openPlayInRehearser(id)});const ra=el.querySelector(".lib-act-readaloud");ra&&ra.addEventListener("click",function(e){e.stopPropagation(),productionOpenInReader(title)});const del=el.querySelector(".lib-act-del-play");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete rehearsal?","This cannot be undone.",async function(){try{typeof rehDbDelete=="function"&&await rehDbDelete(id),toast("Rehearsal deleted","success"),libraryRenderPlays()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function openPlayInRehearser(id){try{const rec=typeof rehDbGetById=="function"?await rehDbGetById(id):null;rec&&typeof loadRecord=="function"?loadRecord(rec):toast("Rehearsal not found","error")}catch{toast("Could not open rehearsal","error")}}async function productionOpenInRehearser(title){const key=prodKey(title);try{const match=(typeof rehDbGetAll=="function"?await rehDbGetAll():[]).find(function(p){return prodKey(p.title)===key});if(match){openPlayInRehearser(match.id);return}}catch{}let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&book.kind!=="pdf")try{const sr=await fetch(LIB_READER_API+"/"+book.id+"/source"),text=sr.ok?await sr.text():"";if(text&&typeof audiobookOpenInRehearser=="function"){audiobookOpenInRehearser(text,title,[]);return}}catch{}if(book&&book.kind==="pdf"){typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(book.id),toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal',"info");return}toast("No source to rehearse for this title yet","error")}async function productionOpenInReader(title){const key=prodKey(title);let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&typeof readerOpenLibraryDoc=="function"){readerOpenLibraryDoc(book.id);return}typeof navTo=="function"&&navTo("s-reader"),toast("No audiobook for this title yet \u2014 import its source in Read Aloud","info")}async function castForProduction(title){const out={};if(typeof clGetAllByTagOrBook!="function")return out;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{recs=[]}return recs.forEach(function(r){const name=(r.name||"").trim();if(!name)return;const voice=r.voice&&r.voice.id?r.voice.id:typeof r.voice=="string"?r.voice:"";out[name.toLowerCase()]={name,voice:voice||"",gender:r.sheet&&r.sheet.gender||"",soul:r.sheet&&(r.sheet.voice_pattern||r.sheet.motivation)||"",tags:r.tags||""}}),out}window.castForProduction=castForProduction;async function castWriteBack(title,castMap){if(!title||!castMap||typeof clGetAllByTagOrBook!="function"||typeof clPut!="function")return;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{return}if(!recs.length)return;const byName={};recs.forEach(function(r){byName[(r.name||"").trim().toLowerCase()]=r});let n=0;for(const sp of Object.keys(castMap)){if(String(sp).includes("NARRATOR"))continue;const voice=(castMap[sp]||{}).voice;if(!voice||voice==="me")continue;const rec=byName[String(sp).trim().toLowerCase()];if(!(!rec||(rec.voice&&rec.voice.id?rec.voice.id:typeof rec.voice=="string"?rec.voice:"")===voice)){rec.voice={id:voice},rec.updated=new Date;try{await clPut(rec),n++}catch{}}}return n}window.castWriteBack=castWriteBack;function libBookCover(title){let h=0;const s=String(title||"Untitled");for(let i=0;i'+heading+'
Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets \u2014 or import an existing cast from SillyTavern.
'+th("alpha","Name")+th("gender","Geschlecht")+th("age","Alter","Estimated age")+th("lines","Zeilen","Anzahl Zeilen")+th("language","Sprache")+th("align","Gut/B\xF6se","Moralische Gesinnung")+th("profile","Profil (gef\xFCllt)","Sort by filled profile fields; the second number is always 32")+th("voice","Stimme")+th("rating","Rating","Rate the assigned voice")+'
":""}function _lcdPromptBox(label,value,sheetKey){const has=!!(value&&String(value).trim()),isVoicePrompt=sheetKey==="voice_design_prompt",saveAction='';return'"+escHtml(label)+(isVoicePrompt?' Edit the description to improve the voice':"")+(has||isVoicePrompt?"":' \u2014 not generated yet')+'
This will replace '+escHtml(rec.voice||fileName)+' with a new voice generated from the visible prompt.
':''}()+_lcdPromptBox("Character Image Prompt",sh.image_prompt,"image_prompt")+_lcdPromptBox("SillyTavern Character Prompt",sh.silly_tavern_prompt,"silly_tavern_prompt")+_lcdPromptBox("Concept Art Prompt",sh.concept_art_prompt,"concept_art_prompt")+"
':""),ul.querySelectorAll(".lib-vp-item").forEach(function(item){item.addEventListener("click",async function(){const vid=item.dataset.vid;await clPut(Object.assign({},rec,{voice:vid,updated:new Date})),rec.voice=vid,_syncVoicePictureFromChar(rec),popup.remove(),onDone()})})}renderList(""),popup.querySelector(".lib-vp-input").addEventListener("input",function(e){renderList(e.target.value)}),voices.length===0&&typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().then(function(){popup.isConnected&&(voices=window._voices||[],renderList(popup.querySelector(".lib-vp-input").value||""))}).catch(function(){}),document.body.appendChild(popup);const rect=cardEl.getBoundingClientRect(),popupWidth=260;popup.style.position="fixed",popup.style.left=Math.max(8,Math.min(rect.left,window.innerWidth-popupWidth-8))+"px",popup.style.width=popupWidth+"px";const spaceBelow=window.innerHeight-rect.bottom;spaceBelow>300||spaceBelow>rect.top?popup.style.top=rect.bottom+4+"px":popup.style.bottom=window.innerHeight-rect.top+4+"px",setTimeout(function(){function close(e){popup.contains(e.target)||(popup.remove(),document.removeEventListener("click",close))}document.addEventListener("click",close)},0),popup.querySelector(".lib-vp-input").focus()}async function _findVoiceFromSameCharacterElsewhere(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey)return null;let all=[];try{all=await clGetAll()}catch{return null}const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null,match=all.find(function(r){if(r.id===rec.id||!r.voice||String(r.name||"").trim().toLowerCase()!==nameKey)return!1;const vId=typeof r.voice=="object"?r.voice.id:r.voice;return!(!_voiceExists(vId)||bookCode&&_voiceLangCode(vId)!==bookCode)});return match?{voiceId:typeof match.voice=="object"?match.voice.id:match.voice,book:match.book}:null}function _voiceLangCode(voiceId){const m=/^([A-Za-z]{2,3})_/.exec(String(voiceId||""));return m?m[1].toUpperCase():null}async function _findVoiceByCharacterName(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey||nameKey.length<3)return null;const hits=(window._voices||[]).filter(function(v){return v.enabled!==!1}).filter(function(v){return String(v.id||v.name||"").toLowerCase().includes(nameKey)});if(!hits.length)return null;const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null;if(bookCode){const langHits=hits.filter(function(v){return _voiceLangCode(v.id)===bookCode});return langHits.length?langHits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]:null}return hits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]}async function _autoAssignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}const named=await _findVoiceByCharacterName(rec);if(named){await clPut(Object.assign({},rec,{voice:named.id,updated:new Date})),rec.voice=named.id,_syncVoicePictureFromChar(rec),toast(named.id+" \u2192 "+rec.name+" (matching voice already in the library)","success");return}if(typeof _charAutoDesignVoice=="function"){await _charAutoDesignVoice(rec);return}toast("No matching voice found","error")}function _charLang(rec){const sh=rec.sheet||{},text=[sh.backstory,sh.voice_pattern,sh.mannerisms,sh.relationships,sh.motivation,sh.archetype].filter(Boolean).join(" ");return typeof detectLang=="function"?detectLang(text):""}const _bookProfileCache=new Map;async function _getBookProfile(book){const key=String(book||"").trim();if(!key)return{};if(_bookProfileCache.has(key))return _bookProfileCache.get(key);let profile={};try{const r=await fetch("/api/book-profile?book="+encodeURIComponent(key));r.ok&&(profile=await r.json())}catch(e){console.warn("[book profile]",e)}return _bookProfileCache.set(key,profile),profile}function _getBookProfileSync(book){return _bookProfileCache.get(String(book||"").trim())||{}}async function _saveBookProfile(book,profile){const key=String(book||"").trim(),r=await fetch("/api/book-profile",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.assign({book:key,confirmed:!0},profile))});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return _bookProfileCache.set(key,d.profile||profile),d.profile}const _bookLangCache=new Map;async function _resolveBookLang(rec){const book=rec.book||"",profile=await _getBookProfile(book);if(profile&&profile.language)return profile.language;const direct=_charLang(rec);if(direct)return direct;if(_bookLangCache.has(book))return _bookLangCache.get(book);let lang="";try{const siblings=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(book):[],counts={};siblings.forEach(function(s){const l=_charLang(s);l&&(counts[l]=(counts[l]||0)+1)});let best="",bestN=0;Object.keys(counts).forEach(function(l){counts[l]>bestN&&(best=l,bestN=counts[l])}),lang=best}catch(e){console.warn("[book lang]",e)}return _bookLangCache.set(book,lang),lang}const _VOICE_TEXTURE_POOL=["a warm, breathy timbre","a bright, clear timbre","a low, husky timbre","a crisp, silvery timbre","a soft, velvety timbre","a slightly nasal, reedy timbre","a rich, resonant timbre","a light, airy timbre"],_VOICE_PACE_POOL=["an unhurried, deliberate pace","a quick, energetic pace","a measured, even pace","a pace that quickens when excited or nervous"];function _hashPick(str,pool){let h=0;for(let i=0;i>>0;return pool[h%pool.length]}function _voiceAudibleSpeciesCue(value){const v=String(value||"").toLowerCase();return/\b(ork|orc|orcs)\b/.test(v)?"Orc: chest-led, gravel-edged resonance; clipped consonants, forceful breath, fully intelligible.":/\b(vampir|vampire)\b/.test(v)?"Vampire: cool velvety timbre, precise diction, restrained predatory calm.":/\b(elf|elv|elfe)\b/.test(v)?"Elf: clear airy resonance, precise musical diction, adult and grounded.":/\b(d[aä]mon|demon|teufel|devil)\b/.test(v)?"Demon: dark resonant timbre with a subtle otherworldly undertone; fully intelligible.":/\b(zwerg|dwarf)\b/.test(v)?"Dwarf: compact earthy resonance, sturdy cadence, deliberate articulation.":/\b(fee|fae|geist|spirit|untot|undead)\b/.test(v)?"Otherworldly: unusual natural resonance, measured breath, emotionally believable.":""}function _voiceAudibleRoleCue(sheet){const v=[sheet.profession,sheet.title,sheet.archetype,sheet.social_class,sheet.capabilities].filter(Boolean).join(" ").toLowerCase();return/krieger|soldat|ritter|wache|kämpfer|warrior|soldier|knight|guard|general|anführer|commander/.test(v)?"Warrior/commander: grounded projection, disciplined breath, concise command cadence.":/schmied|blacksmith|handwerk|artisan|craft/.test(v)?"Craftsperson: practical work-worn texture, measured pace, plain-spoken certainty.":/magier|zauber|hexe|hexer|wizard|mage|sorcer|druid/.test(v)?"Magic-user: measured phrasing, precise diction, contained intensity.":/priester|inquis|mönch|monk|priest|cleric|prophet/.test(v)?"Religious authority: formal diction, calm conviction, weighted pauses.":/könig|königin|baron|graf|adel|noble|lord|lady|herrscher|herrscherin/.test(v)?"High-status authority: polished diction, composed pace, effortless command.":/dieb|spion|assassin|schurke|rogue|thief/.test(v)?"Covert operator: quiet confidence, economical phrasing, alert timing.":/gelehrt|scholar|lehrer|teacher|arzt|heiler|healer/.test(v)?"Learned professional: articulate, patient pace, observant and assured.":""}function _voiceIdentityPrompt(rec,sourcePrompt,langName){const sh=rec.sheet||{},rawGender=String(sh.gender||_genderFromGenericName(rec.name)||"").toLowerCase(),gender=rawGender.startsWith("f")?"female":rawGender.startsWith("m")?"male":rawGender.startsWith("non")?"androgynous/nonbinary":"",hardGender=gender?"Adult "+gender+" voice, clearly "+(gender==="female"?"feminine and never masculine-coded":gender==="male"?"masculine and never feminine-coded":"androgynous")+".":"",age=String(sh.age_estimate||"").trim(),ageCue=age?"Age: "+age.slice(0,36)+".":"",lang=String(langName||"").trim(),languageCue=lang?lang.toLowerCase()==="english"?"Neutral international English.":"Native "+lang+" delivery, never American-accented.":"",species=_voiceAudibleSpeciesCue([sh.race_species,sh.archetype,sh.profession,sh.aliases].filter(Boolean).join(" ")),role=_voiceAudibleRoleCue(sh),prefix=[hardGender,ageCue,languageCue,species,role,"Distinct natural audiobook voice; no imitation."].filter(Boolean).join(" "),base=String(sourcePrompt||"").replace(/\s+/g," ").trim(),room=Math.max(0,480-prefix.length-(prefix&&base?1:0));return(prefix+(base?" "+base.slice(0,room):"")).slice(0,480)}function _buildVoicePrompt(rec,profile,langName){const sh=rec.sheet||{},g=String(sh.gender||"").toLowerCase(),genderWord=g.startsWith("f")?"female":g.startsWith("m")?"male":"",bits=[],lang=String(langName||"").trim();lang&&lang.toLowerCase()!=="english"?bits.push("Speak with an authentic native "+lang+" accent \u2014 not American-accented, not an English speaker doing "+lang+"."):lang&&bits.push("English with a neutral British or international accent, explicitly not American/US-accented.");const settingBits=[profile&&profile.genre,profile&&profile.setting,profile&&profile.era].filter(Boolean);return settingBits.length&&bits.push("Setting: "+settingBits.join(", ")+"."),bits.push("A "+(genderWord?genderWord+" ":"")+"voice"+(sh.archetype?" for "+sh.archetype.toLowerCase():"")+","),bits.push("with "+_hashPick(rec.name||rec.id||"",_VOICE_TEXTURE_POOL)+" and "+_hashPick((rec.name||rec.id||"")+"_pace",_VOICE_PACE_POOL)+"."),sh.voice_pattern&&bits.push(sh.voice_pattern),sh.mannerisms&&bits.push("Mannerisms: "+sh.mannerisms),sh.physical&&bits.push(sh.physical),sh.alignment&&bits.push("Disposition: "+sh.alignment),bits.join(" ").slice(0,600)}function _selectLoose(sel,val){if(!sel||!val)return;const v=String(val).toLowerCase(),opt=[...sel.options].find(function(o){const ov=o.value.toLowerCase(),ot=o.textContent.toLowerCase();return ov===v||ot===v||ov.startsWith(v)||ot.startsWith(v)||v.startsWith(ov)});opt&&(sel.value=opt.value,sel.dispatchEvent(new Event("change")))}function _charSearchOnline(rec){typeof navTo=="function"&&navTo("s-studio");const lang=_charLang(rec);setTimeout(function(){const fishTab=document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');fishTab&&fishTab.click(),setTimeout(function(){const langSel=document.getElementById("fa-lang");langSel&&_selectLoose(langSel,lang);const search=document.getElementById("fa-search");search&&(search.value=rec.name,search.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:!0})))},120)},120),toast("Searching online voices for "+rec.name+(lang?" ("+lang+")":""),"info")}function _editBookProfile(book,afterSave){_getBookProfile(book).then(function(profile){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Book context \u2014 '+escHtml(book)+`
Used in every voice design (and image) prompt for this book, so a fantasy story doesn't end up with 1920s-general portraits or English voices in a German book just because one character's own sheet was too sparse to tell.
',document.body.appendChild(ov);const close=function(result){ov.remove(),typeof afterSave=="function"&&afterSave(result||null)};ov.querySelector("#bctx-cancel").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#bctx-save").addEventListener("click",async function(){const btn=this;btn.disabled=!0;try{const saved=await _saveBookProfile(book,{genre:ov.querySelector("#bctx-genre").value,setting:ov.querySelector("#bctx-setting").value,era:ov.querySelector("#bctx-era").value,language:ov.querySelector("#bctx-lang").value});toast("Book context saved for "+book,"success"),close(saved)}catch(e){toast("Failed to save: "+(e.message||e),"error"),btn.disabled=!1}})})}window.openBookContextEditor=function(book){return new Promise(function(resolve){_editBookProfile(book,resolve)})};function _confirmVoiceReuse(rec,reuse){return new Promise(function(resolve){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Existing voice found for '+escHtml(rec.name)+'
"'+escHtml(reuse.voiceId)+'" is already used for '+escHtml(rec.name)+' in "'+escHtml(reuse.book)+'". Reuse it for series consistency, or design a brand-new voice just for this book?
',document.body.appendChild(ov);let audioEl=null,audioObjectUrl=null;ov.querySelector("#cvr-play").addEventListener("click",async function(e){const btn=e.currentTarget,icon=btn.querySelector(".mdi");if(audioEl&&!audioEl.paused){audioEl.pause(),icon.className="mdi mdi-play";return}btn.disabled=!0,icon.className="mdi mdi-loading mdi-spin";try{const langHint=typeof _resolveBookLang=="function"?await _resolveBookLang(rec).catch(function(){return""}):"",text=typeof _charSampleTextFor=="function"&&_charSampleTextFor(rec,langHint)||"Hallo, ich bin "+rec.name+".",rv=(window._voices||[]).find(x=>x.id===reuse.voiceId),rBackend=rv&&!rv.has_ref?"voice_design":"voice_clone",blob=await fetchTtsPreviewBlob(reuse.voiceId,text,"wav","",rBackend);audioEl||(audioEl=new Audio,audioEl.addEventListener("ended",function(){icon.className="mdi mdi-play",audioObjectUrl&&(URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=null)})),audioObjectUrl&&URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=URL.createObjectURL(blob),audioEl.src=audioObjectUrl,await audioEl.play(),icon.className="mdi mdi-pause"}catch(err){toast("Could not play sample: "+(err.message||err),"error"),icon.className="mdi mdi-play"}finally{btn.disabled=!1}});const cleanup=function(result){audioEl&&audioEl.pause(),audioObjectUrl&&(URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=null),ov.remove(),resolve(result)};ov.querySelector("#cvr-cancel").addEventListener("click",function(){cleanup("cancel")}),ov.querySelector("#cvr-use").addEventListener("click",function(){cleanup("use")}),ov.querySelector("#cvr-new").addEventListener("click",function(){cleanup("new")}),ov.addEventListener("click",function(e){e.target===ov&&cleanup("cancel")})})}async function _charDesignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){const choice=await _confirmVoiceReuse(rec,reuse);if(choice==="cancel")return;if(choice==="use"){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}}typeof navTo=="function"&&navTo("s-design");const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(sh.voice_design_prompt).trim();setTimeout(function(){_selectLoose(document.getElementById("design-gender"),sh.gender),_selectLoose(document.getElementById("design-language"),lang);const instruct=document.getElementById("design-instruct");instruct&&(instruct.value=_voiceIdentityPrompt(rec,savedPrompt||_buildVoicePrompt(rec,null,lang),lang));const nm=document.getElementById("design-preset-name");nm&&(nm.value=rec.name)},140),toast("Voice design prepared for "+rec.name+(lang?" \xB7 "+lang:""),"info")}function _charDesignVoiceInline(rec,promptOverride){const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(promptOverride||sh.voice_design_prompt).trim(),instruct=savedPrompt||_voiceIdentityPrompt(rec,_buildVoicePrompt(rec,null,lang),lang),ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Voice design prompt \u2014 '+escHtml(rec.name)+'
Edit the description, then generate a new voice from it. This replaces '+(rec.voice?"the current voice":"this character\u2019s voice")+'.
',document.body.appendChild(ov);const close=function(){ov.remove()};ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#cdi-cancel").addEventListener("click",close);let promptSaveTimer=null;const saveInlinePrompt=async function(text){if(text=String(text||"").trim(),!!text&&(rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,rec.updated=new Date,typeof clPut=="function")){const saved=await clPut(rec);saved&&saved.sheet&&(rec.sheet=saved.sheet)}},promptInput=ov.querySelector("#cdi-instruct");promptInput.addEventListener("input",function(){clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(function(){saveInlinePrompt(promptInput.value).catch(function(err){console.warn("[character prompt] autosave failed",err)})},600)}),ov.querySelector("#cdi-save").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;clearTimeout(promptSaveTimer),btn.disabled=!0,btn.innerHTML=' Saving\u2026';try{await saveInlinePrompt(promptInput.value),toast("Voice design prompt saved","success")}catch(err){toast("Prompt save failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-rewrite").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Rewriting\u2026';try{const target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},sample=[sh.physical,sh.backstory,sh.motivation].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:rec.name,book:rec.book||"",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},sheet:sh,language,llm_url:target.url,model:target.model,fields:["voice_design_prompt"]})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();if(!d.voice_design_prompt)throw new Error("The rewrite returned an empty prompt");ov.querySelector("#cdi-instruct").value=d.voice_design_prompt,await saveInlinePrompt(d.voice_design_prompt),toast("Voice design prompt rewritten \u2014 review it, then generate the voice","success")}catch(err){toast("Prompt rewrite failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-generate").addEventListener("click",async function(e){const btn=e.currentTarget,text=ov.querySelector("#cdi-instruct").value.trim();if(!text){toast("Prompt is empty","error");return}clearTimeout(promptSaveTimer),btn.disabled=!0;const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-loading mdi-spin");try{await saveInlinePrompt(text),await _charAutoDesignVoice(rec,!0,text),_schedulePendingTtsRestart(),rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,close(),toast("New voice designed for "+rec.name,"success");const detailPage=document.querySelector(".lib-char-page"),detailOpts=window._libDetailOpts;if(detailPage&&detailOpts&&typeof _charDetailPage=="function"){const all=typeof clGetAll=="function"?await clGetAll().catch(function(){return[]}):[],updated=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(updated,all.filter(function(r){return r.book===updated.book}),detailOpts)}else typeof libraryRenderCharacters=="function"&&await libraryRenderCharacters();typeof _libRefreshDetailModal=="function"&&_libRefreshDetailModal(rec)}catch(err){toast("Voice design failed: "+(err.message||err),"error"),btn.disabled=!1,icon&&(icon.className="mdi mdi-creation")}})}function _charCloneVoice(rec){typeof navTo=="function"&&navTo("s-clone"),setTimeout(function(){const nm=document.getElementById("clone-your-name");nm&&(nm.value=rec.name)},140),toast("Clone a Voice prepared for "+rec.name+" \u2014 pick a mic take, file, or YouTube URL","info")}const _DESIGN_SAMPLE_FALLBACK={German:"Ich habe lange auf diesen Moment gewartet, und jetzt, da er da ist, wei\xDF ich genau, was zu tun ist.",English:"I have waited a long time for this moment, and now that it is here, I know exactly what to do."};function _charRealLine(rec){const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook,nameLower=String(rec.name||"").trim().toLowerCase(),line=(ab&&ab.segments||[]).find(function(s){return s&&s.type==="dialogue"&&String(s.speaker||"").trim().toLowerCase()===nameLower&&s.text&&s.text.trim().length>=20&&s.text.trim().length<=200});if(line)return line.text.trim();const quotes=(Array.isArray(rec.sheet&&rec.sheet.sources)?rec.sheet.sources:[]).map(function(s){return s&&s.quote?String(s.quote).trim():""}).filter(function(q){return q.length>=20&&q.length<=240}),spoken=quotes.find(function(q){return/[""„"]/.test(q)});return spoken||(quotes.length?quotes[0]:null)}function _charSampleTextFor(rec,langNameHint){const lang=_charLang(rec)||langNameHint||"English",greeting=lang==="German"?`Hallo, ich bin ${rec.name}.`:`Hello, I am ${rec.name}.`,line=_charRealLine(rec);return line?`${greeting} ${line}`:_DESIGN_SAMPLE_FALLBACK[lang]||_DESIGN_SAMPLE_FALLBACK.English}const _GENERIC_NAME_GENDER={frau:"female",dame:"female",junge_frau:"female",m\u00E4dchen:"female",maedchen:"female",mann:"male",herr:"male",junge:"male",knabe:"male"};function _genderFromGenericName(name){const key=String(name||"").trim().toLowerCase().replace(/\s+/g,"_");return _GENERIC_NAME_GENDER[key]||""}let _voiceRestartPending=!1;async function _flushPendingTtsRestart(){if(_voiceRestartPending){_voiceRestartPending=!1;try{const r=await fetch("/api/tts/restart",{method:"POST"});r.ok?toast("TTS backend restarted to pick up the newly designed voice(s)","success"):console.warn("[tts restart] failed:",r.status)}catch(e){console.warn("[tts restart]",e)}}}let _voiceRestartDebounceTimer=null;function _schedulePendingTtsRestart(){clearTimeout(_voiceRestartDebounceTimer),_voiceRestartDebounceTimer=setTimeout(_flushPendingTtsRestart,4e3)}async function _fetchRetryingNetworkErrors(url,opts,tries){tries=tries||3;for(let i=1;i<=tries;i++)try{return await fetch(url,opts)}catch(e){if(i===tries)throw e;await new Promise(function(r){setTimeout(r,2500*i)})}}function _designBenchmarkWpmBad(b){if(!b||!b.ok||!b.audio_sec||!b.text)return!1;const wpm=String(b.text).trim().split(/\s+/).length/(b.audio_sec/60);return wpm<80||wpm>400}async function _charAutoDesignVoice(rec,force,instructOverride){const reuse=force||instructOverride?null:await _findVoiceFromSameCharacterElsewhere(rec);if(reuse&&reuse.voiceId!==rec.voice){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec);return}const sh=rec.sheet||{},langName=await _resolveBookLang(rec)||"English",instruct=instructOverride?_libStr(instructOverride).trim():_voiceIdentityPrompt(rec,_libStr(sh.voice_design_prompt).trim()||_buildVoicePrompt(rec,await _getBookProfile(rec.book),langName),langName);if(!instruct.trim())throw new Error("No character description to design a voice from yet");instructOverride&&(sh.voice_design_prompt=instruct);const langCode=typeof DESIGN_LANG_CODE!="undefined"&&DESIGN_LANG_CODE[langName]||"EN",genderWord=String(sh.gender||"").toLowerCase()||_genderFromGenericName(rec.name),genderLetter=genderWord.startsWith("f")?"F":genderWord.startsWith("m")?"M":"N",sampleText=_charSampleTextFor(rec,langName),dialogue=typeof isDialogueDesign=="function"?isDialogueDesign(instruct,sampleText,null):!1,baseName=typeof designSafeName=="function"?designSafeName(rec.name):(typeof _umlautSafe=="function"?_umlautSafe(rec.name||"VoiceDesign"):String(rec.name||"VoiceDesign")).replace(/[^A-Za-z0-9]+/g,"_"),voiceId=(langCode+"_"+genderLetter+"_"+baseName).slice(0,96),maxAttempts=3;let saved=null;for(let attempt=1;attempt<=maxAttempts;attempt++){const r1=await _fetchRetryingNetworkErrors("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({instruct,sample_text:sampleText,language:langName,gender:genderLetter,dialogue})});if(!r1.ok){const e=await r1.json().catch(function(){return{}});throw new Error(e.detail||r1.statusText)}const designed=await r1.json(),tryId=(voiceId+"__try"+attempt).slice(0,96),r2=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:tryId,transcript:sampleText})});if(!r2.ok){const e=await r2.json().catch(function(){return{}});throw new Error(e.detail||r2.statusText)}await r2.json();let bad=!1;if(typeof runVoiceBenchmark=="function")try{const d=await runVoiceBenchmark(tryId,{text:sampleText}),hit=(d&&d.voices||[]).find(function(x){return x.voice_id===tryId}),b=hit&&hit.benchmark;!b||!b.ok&&/connection (refused|reset|aborted)|max retries exceeded|newconnectionerror|econnrefused|timed? ?out/i.test(String(b.error||""))?console.warn("[voice design] benchmark unreachable, accepting unverified:",b&&b.error):bad=!!(b.clipped||_designBenchmarkWpmBad(b))}catch(e){console.warn("[voice benchmark]",e)}if(!bad&&typeof _voiceRoundtripCheck=="function")try{const rt=await _voiceRoundtripCheck(tryId,sampleText,"voice_design");rt.score<.5&&(bad=!0,console.warn("[voice design] STT roundtrip mismatch (score "+rt.score.toFixed(2)+'): said "'+rt.transcript+'"'))}catch(e){console.warn("[voice design roundtrip]",e)}if(!bad){const r3=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:voiceId,transcript:sampleText})});if(!r3.ok){const e=await r3.json().catch(function(){return{}});throw new Error(e.detail||r3.statusText)}saved=await r3.json(),saved.needs_tts_restart&&(_voiceRestartPending=!0),await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){});break}if(await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){}),attempt===maxAttempts)throw new Error('Voice design for "'+voiceId+'" produced broken audio after '+maxAttempts+" attempts \u2014 left the previous voice in place, try again later")}return typeof saveMeta=="function"&&await saveMeta(saved.voice_id,{gender:genderLetter,flag:typeof LANG_FLAG_DEFAULT!="undefined"?LANG_FLAG_DEFAULT[langCode]:void 0,origin:"designed",group:rec.book||void 0,tag:rec.book||void 0,transcript:sampleText,voice_design_prompt:instruct}).catch(function(){}),rec.voice=saved.voice_id,await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name,voice:saved.voice_id}),rec.id),_syncVoicePictureFromChar(rec),saved.voice_id}async function _charAutoGenerateImage(rec,provider){const sh=rec.sheet||{},hasExplicitPrompt=!!_libStr(sh.image_prompt).trim();if(!hasExplicitPrompt&&[sh.archetype,sh.physical,sh.clothing].filter(Boolean).join(" ").trim().length<20)throw new Error("Not enough character detail to generate a meaningful portrait \u2014 skipped instead of using a generic placeholder");const bookProfile=typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},prompt=hasExplicitPrompt?_libStr(sh.image_prompt).trim():typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh,bookProfile):"";if(!prompt)throw new Error("No image prompt to work from yet");const body={prompt,image_kind:"portrait"};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return typeof clSetImage=="function"&&await clSetImage(rec.id,d.image),rec.image=d.image,document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''}),_syncVoicePictureFromChar(rec),d.image}const _CIJ_STEPS=[{key:"identity",label:"Identity portrait"},{key:"render",label:"Turnaround, scene & props"}];function _cijShowPanel(name){var _a2;(_a2=document.getElementById("cij-progress-panel"))==null||_a2.remove();const panel=document.createElement("div");panel.id="cij-progress-panel",panel.className="cij-progress-panel";const stepsHtml=_CIJ_STEPS.map(function(s){return'
',document.body.appendChild(panel),panel.querySelector(".cij-progress-close").addEventListener("click",function(){panel.remove()}),{update:function(stage,detail,elapsed,warn){const p=document.getElementById("cij-progress-panel");if(!p)return;p.querySelector(".cij-progress-detail").textContent=detail||"Working\u2026",p.querySelector(".cij-progress-elapsed").textContent="Elapsed "+_cijFmtElapsed(elapsed)+(warn?" \u2014 no update in a while, it may be stuck":""),p.classList.toggle("cij-progress-warn",!!warn);const activeIndex=_CIJ_STEPS.findIndex(function(s){return s.key===stage});p.querySelectorAll(".cij-step").forEach(function(el,i){const icon=el.querySelector(".cij-step-icon");el.classList.remove("is-done","is-active","is-pending"),activeIndex<0||i60;if(panel.update(status2.stage,status2.detail,status2.elapsed,warn),status2.done){if(status2.error)throw panel.done(!1),new Error(status2.error);return panel.done(!0),status2.result}}}async function _charAutoGenerateConsistentImages(rec,visualStyle){const sh=rec.sheet||{},profilePrompt=_libStr(sh.image_prompt).trim(),referencePrompt=_libStr(sh.concept_art_prompt).trim();if(!profilePrompt)throw new Error("No Character Image Prompt \u2014 generate it first");if(!referencePrompt)throw new Error("No Character Reference Prompt \u2014 generate it first");const r=await fetch("/api/character-generate-consistent-images",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({profile_prompt:profilePrompt,reference_prompt:referencePrompt,name:rec.name||"",book:rec.book||"",sheet:sh,visual_style:visualStyle||"cinematic",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},identity_image:"",reuse_identity:!1})});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const{job_id}=await r.json(),panel=_cijShowPanel(rec.name),d=await _cijPollJob(job_id,panel);if(!d.profile||!d.reference)throw new Error("ComfyUI returned an incomplete image pair");typeof clSetImage=="function"&&await clSetImage(rec.id,d.profile);const target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;return target.image=d.profile,target.sheet=Object.assign({},target.sheet||{},{concept_art_image:d.reference,consistent_prompt_version:d.prompt_version||"v3",consistent_identity_fingerprint:d.identity_fingerprint||"",identity_consistent:d.identity_consistent!==!1}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),rec.image=d.profile,rec.sheet=Object.assign({},sh,{concept_art_image:d.reference,consistent_prompt_version:d.prompt_version||"v3",consistent_identity_fingerprint:d.identity_fingerprint||"",identity_consistent:d.identity_consistent!==!1}),document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''}),d.profile}async function _charAutoGenerateConceptArt(rec,provider){const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim();if(!prompt)throw new Error("No concept art prompt to work from yet \u2014 generate the prompt first");const body={prompt,image_kind:"concept_art"};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json(),target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;return target.sheet=Object.assign({},target.sheet||{},{concept_art_image:d.image}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),sh.concept_art_image=d.image,rec.sheet=sh,d.image}function _openConceptArtLightbox(rec,onSaved){var _a2;(_a2=document.getElementById("conceptart-lightbox"))==null||_a2.remove();const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim(),ov=document.createElement("div");ov.id="conceptart-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
Optional \u2014 let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.
"}).join(""),list.querySelectorAll(".lib-book").forEach(function(el){const id=el.dataset.id,title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||(window._readerStartView="main",typeof navTo=="function"&&navTo("s-reader"),typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(id))});const reh=el.querySelector(".lib-act-rehearse");reh&&reh.addEventListener("click",function(e){e.stopPropagation(),productionOpenInRehearser(title)});const del=el.querySelector(".lib-act-del-book");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete book?","Audio files will be removed.",async function(){try{if(!(await fetch(LIB_READER_API+"/"+id,{method:"DELETE"})).ok)throw new Error("delete failed");toast("Book deleted","success"),libraryRenderBooks()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function libraryRenderPlays(){const list=document.getElementById("lib-plays-list");if(!list)return;list.innerHTML=_libSkeleton(3);let all=[];try{all=typeof rehDbGetAll=="function"?await rehDbGetAll():[]}catch{all=[]}if(!all.length){list.innerHTML='
No theater plays yet.
Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.
';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const speakers=Object.keys(rec.cast||{}),total=typeof parseScript=="function"?parseScript(rec.script||"").filter(function(l){return l.type==="dialog"}).length:0,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",cov=libBookCover(rec.title||"Untitled"),avatars=speakers.slice(0,5).map(function(sp){return''+(sp[0]||"?").toUpperCase()+""}).join("");return'
'+escHtml(rec.title||"Untitled")+'
'+avatars+"
"+total+" lines \xB7 "+speakers.length+' cast
'+pct+"% \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-play").forEach(function(el){const id=parseInt(el.dataset.id,10),title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||openPlayInRehearser(id)});const ra=el.querySelector(".lib-act-readaloud");ra&&ra.addEventListener("click",function(e){e.stopPropagation(),productionOpenInReader(title)});const del=el.querySelector(".lib-act-del-play");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete rehearsal?","This cannot be undone.",async function(){try{typeof rehDbDelete=="function"&&await rehDbDelete(id),toast("Rehearsal deleted","success"),libraryRenderPlays()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function openPlayInRehearser(id){try{const rec=typeof rehDbGetById=="function"?await rehDbGetById(id):null;rec&&typeof loadRecord=="function"?loadRecord(rec):toast("Rehearsal not found","error")}catch{toast("Could not open rehearsal","error")}}async function productionOpenInRehearser(title){const key=prodKey(title);try{const match=(typeof rehDbGetAll=="function"?await rehDbGetAll():[]).find(function(p){return prodKey(p.title)===key});if(match){openPlayInRehearser(match.id);return}}catch{}let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&book.kind!=="pdf")try{const sr=await fetch(LIB_READER_API+"/"+book.id+"/source"),text=sr.ok?await sr.text():"";if(text&&typeof audiobookOpenInRehearser=="function"){audiobookOpenInRehearser(text,title,[]);return}}catch{}if(book&&book.kind==="pdf"){typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(book.id),toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal',"info");return}toast("No source to rehearse for this title yet","error")}async function productionOpenInReader(title){const key=prodKey(title);let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&typeof readerOpenLibraryDoc=="function"){readerOpenLibraryDoc(book.id);return}typeof navTo=="function"&&navTo("s-reader"),toast("No audiobook for this title yet \u2014 import its source in Read Aloud","info")}async function castForProduction(title){const out={};if(typeof clGetAllByTagOrBook!="function")return out;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{recs=[]}return recs.forEach(function(r){const name=(r.name||"").trim();if(!name)return;const voice=r.voice&&r.voice.id?r.voice.id:typeof r.voice=="string"?r.voice:"";out[name.toLowerCase()]={name,voice:voice||"",gender:r.sheet&&r.sheet.gender||"",soul:r.sheet&&(r.sheet.voice_pattern||r.sheet.motivation)||"",tags:r.tags||""}}),out}window.castForProduction=castForProduction;async function castWriteBack(title,castMap){if(!title||!castMap||typeof clGetAllByTagOrBook!="function"||typeof clPut!="function")return;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{return}if(!recs.length)return;const byName={};recs.forEach(function(r){byName[(r.name||"").trim().toLowerCase()]=r});let n=0;for(const sp of Object.keys(castMap)){if(String(sp).includes("NARRATOR"))continue;const voice=(castMap[sp]||{}).voice;if(!voice||voice==="me")continue;const rec=byName[String(sp).trim().toLowerCase()];if(!(!rec||(rec.voice&&rec.voice.id?rec.voice.id:typeof rec.voice=="string"?rec.voice:"")===voice)){rec.voice={id:voice},rec.updated=new Date;try{await clPut(rec),n++}catch{}}}return n}window.castWriteBack=castWriteBack;function libBookCover(title){let h=0;const s=String(title||"Untitled");for(let i=0;i'+heading+'
Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets \u2014 or import an existing cast from SillyTavern.
'+th("alpha","Name")+th("gender","Geschlecht")+th("age","Alter","Estimated age")+th("lines","Zeilen","Anzahl Zeilen")+th("language","Sprache")+th("align","Gut/B\xF6se","Moralische Gesinnung")+th("profile","Profil (gef\xFCllt)","Sort by filled profile fields; the second number is always 32")+th("voice","Stimme")+th("rating","Rating","Rate the assigned voice")+'
":""}function _lcdPromptBox(label,value,sheetKey){const has=!!(value&&String(value).trim()),isVoicePrompt=sheetKey==="voice_design_prompt",saveAction='';return'"+escHtml(label)+(isVoicePrompt?' Edit the description to improve the voice':"")+(has||isVoicePrompt?"":' \u2014 not generated yet')+'
This will replace '+escHtml(rec.voice||fileName)+' with a new voice generated from the visible prompt.
':''}()+_lcdPromptBox("Character Image Prompt",sh.image_prompt,"image_prompt")+_lcdPromptBox("SillyTavern Character Prompt",sh.silly_tavern_prompt,"silly_tavern_prompt")+_lcdPromptBox("Concept Art Prompt",sh.concept_art_prompt,"concept_art_prompt")+"
':""),ul.querySelectorAll(".lib-vp-item").forEach(function(item){item.addEventListener("click",async function(){const vid=item.dataset.vid;await clPut(Object.assign({},rec,{voice:vid,updated:new Date})),rec.voice=vid,_syncVoicePictureFromChar(rec),popup.remove(),onDone()})})}renderList(""),popup.querySelector(".lib-vp-input").addEventListener("input",function(e){renderList(e.target.value)}),voices.length===0&&typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().then(function(){popup.isConnected&&(voices=window._voices||[],renderList(popup.querySelector(".lib-vp-input").value||""))}).catch(function(){}),document.body.appendChild(popup);const rect=cardEl.getBoundingClientRect(),popupWidth=260;popup.style.position="fixed",popup.style.left=Math.max(8,Math.min(rect.left,window.innerWidth-popupWidth-8))+"px",popup.style.width=popupWidth+"px";const spaceBelow=window.innerHeight-rect.bottom;spaceBelow>300||spaceBelow>rect.top?popup.style.top=rect.bottom+4+"px":popup.style.bottom=window.innerHeight-rect.top+4+"px",setTimeout(function(){function close(e){popup.contains(e.target)||(popup.remove(),document.removeEventListener("click",close))}document.addEventListener("click",close)},0),popup.querySelector(".lib-vp-input").focus()}async function _findVoiceFromSameCharacterElsewhere(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey)return null;let all=[];try{all=await clGetAll()}catch{return null}const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null,match=all.find(function(r){if(r.id===rec.id||!r.voice||String(r.name||"").trim().toLowerCase()!==nameKey)return!1;const vId=typeof r.voice=="object"?r.voice.id:r.voice;return!(!_voiceExists(vId)||bookCode&&_voiceLangCode(vId)!==bookCode)});return match?{voiceId:typeof match.voice=="object"?match.voice.id:match.voice,book:match.book}:null}function _voiceLangCode(voiceId){const m=/^([A-Za-z]{2,3})_/.exec(String(voiceId||""));return m?m[1].toUpperCase():null}async function _findVoiceByCharacterName(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey||nameKey.length<3)return null;const hits=(window._voices||[]).filter(function(v){return v.enabled!==!1}).filter(function(v){return String(v.id||v.name||"").toLowerCase().includes(nameKey)});if(!hits.length)return null;const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null;if(bookCode){const langHits=hits.filter(function(v){return _voiceLangCode(v.id)===bookCode});return langHits.length?langHits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]:null}return hits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]}async function _autoAssignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}const named=await _findVoiceByCharacterName(rec);if(named){await clPut(Object.assign({},rec,{voice:named.id,updated:new Date})),rec.voice=named.id,_syncVoicePictureFromChar(rec),toast(named.id+" \u2192 "+rec.name+" (matching voice already in the library)","success");return}if(typeof _charAutoDesignVoice=="function"){await _charAutoDesignVoice(rec);return}toast("No matching voice found","error")}function _charLang(rec){const sh=rec.sheet||{},text=[sh.backstory,sh.voice_pattern,sh.mannerisms,sh.relationships,sh.motivation,sh.archetype].filter(Boolean).join(" ");return typeof detectLang=="function"?detectLang(text):""}const _bookProfileCache=new Map;async function _getBookProfile(book){const key=String(book||"").trim();if(!key)return{};if(_bookProfileCache.has(key))return _bookProfileCache.get(key);let profile={};try{const r=await fetch("/api/book-profile?book="+encodeURIComponent(key));r.ok&&(profile=await r.json())}catch(e){console.warn("[book profile]",e)}return _bookProfileCache.set(key,profile),profile}function _getBookProfileSync(book){return _bookProfileCache.get(String(book||"").trim())||{}}async function _saveBookProfile(book,profile){const key=String(book||"").trim(),r=await fetch("/api/book-profile",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.assign({book:key,confirmed:!0},profile))});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return _bookProfileCache.set(key,d.profile||profile),d.profile}const _bookLangCache=new Map;async function _resolveBookLang(rec){const book=rec.book||"",profile=await _getBookProfile(book);if(profile&&profile.language)return profile.language;const direct=_charLang(rec);if(direct)return direct;if(_bookLangCache.has(book))return _bookLangCache.get(book);let lang="";try{const siblings=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(book):[],counts={};siblings.forEach(function(s){const l=_charLang(s);l&&(counts[l]=(counts[l]||0)+1)});let best="",bestN=0;Object.keys(counts).forEach(function(l){counts[l]>bestN&&(best=l,bestN=counts[l])}),lang=best}catch(e){console.warn("[book lang]",e)}return _bookLangCache.set(book,lang),lang}const _VOICE_TEXTURE_POOL=["a warm, breathy timbre","a bright, clear timbre","a low, husky timbre","a crisp, silvery timbre","a soft, velvety timbre","a slightly nasal, reedy timbre","a rich, resonant timbre","a light, airy timbre"],_VOICE_PACE_POOL=["an unhurried, deliberate pace","a quick, energetic pace","a measured, even pace","a pace that quickens when excited or nervous"];function _hashPick(str,pool){let h=0;for(let i=0;i>>0;return pool[h%pool.length]}function _voiceAudibleSpeciesCue(value){const v=String(value||"").toLowerCase();return/\b(ork|orc|orcs)\b/.test(v)?"Orc: chest-led, gravel-edged resonance; clipped consonants, forceful breath, fully intelligible.":/\b(vampir|vampire)\b/.test(v)?"Vampire: cool velvety timbre, precise diction, restrained predatory calm.":/\b(elf|elv|elfe)\b/.test(v)?"Elf: clear airy resonance, precise musical diction, adult and grounded.":/\b(d[aä]mon|demon|teufel|devil)\b/.test(v)?"Demon: dark resonant timbre with a subtle otherworldly undertone; fully intelligible.":/\b(zwerg|dwarf)\b/.test(v)?"Dwarf: compact earthy resonance, sturdy cadence, deliberate articulation.":/\b(fee|fae|geist|spirit|untot|undead)\b/.test(v)?"Otherworldly: unusual natural resonance, measured breath, emotionally believable.":""}function _voiceAudibleRoleCue(sheet){const v=[sheet.profession,sheet.title,sheet.archetype,sheet.social_class,sheet.capabilities].filter(Boolean).join(" ").toLowerCase();return/krieger|soldat|ritter|wache|kämpfer|warrior|soldier|knight|guard|general|anführer|commander/.test(v)?"Warrior/commander: grounded projection, disciplined breath, concise command cadence.":/schmied|blacksmith|handwerk|artisan|craft/.test(v)?"Craftsperson: practical work-worn texture, measured pace, plain-spoken certainty.":/magier|zauber|hexe|hexer|wizard|mage|sorcer|druid/.test(v)?"Magic-user: measured phrasing, precise diction, contained intensity.":/priester|inquis|mönch|monk|priest|cleric|prophet/.test(v)?"Religious authority: formal diction, calm conviction, weighted pauses.":/könig|königin|baron|graf|adel|noble|lord|lady|herrscher|herrscherin/.test(v)?"High-status authority: polished diction, composed pace, effortless command.":/dieb|spion|assassin|schurke|rogue|thief/.test(v)?"Covert operator: quiet confidence, economical phrasing, alert timing.":/gelehrt|scholar|lehrer|teacher|arzt|heiler|healer/.test(v)?"Learned professional: articulate, patient pace, observant and assured.":""}function _voiceIdentityPrompt(rec,sourcePrompt,langName){const sh=rec.sheet||{},rawGender=String(sh.gender||_genderFromGenericName(rec.name)||"").toLowerCase(),gender=rawGender.startsWith("f")?"female":rawGender.startsWith("m")?"male":rawGender.startsWith("non")?"androgynous/nonbinary":"",hardGender=gender?"Adult "+gender+" voice, clearly "+(gender==="female"?"feminine and never masculine-coded":gender==="male"?"masculine and never feminine-coded":"androgynous")+".":"",age=String(sh.age_estimate||"").trim(),ageCue=age?"Age: "+age.slice(0,36)+".":"",lang=String(langName||"").trim(),languageCue=lang?lang.toLowerCase()==="english"?"Neutral international English.":"Native "+lang+" delivery, never American-accented.":"",species=_voiceAudibleSpeciesCue([sh.race_species,sh.archetype,sh.profession,sh.aliases].filter(Boolean).join(" ")),role=_voiceAudibleRoleCue(sh),prefix=[hardGender,ageCue,languageCue,species,role,"Distinct natural audiobook voice; no imitation."].filter(Boolean).join(" "),base=String(sourcePrompt||"").replace(/\s+/g," ").trim(),room=Math.max(0,480-prefix.length-(prefix&&base?1:0));return(prefix+(base?" "+base.slice(0,room):"")).slice(0,480)}function _buildVoicePrompt(rec,profile,langName){const sh=rec.sheet||{},g=String(sh.gender||"").toLowerCase(),genderWord=g.startsWith("f")?"female":g.startsWith("m")?"male":"",bits=[],lang=String(langName||"").trim();lang&&lang.toLowerCase()!=="english"?bits.push("Speak with an authentic native "+lang+" accent \u2014 not American-accented, not an English speaker doing "+lang+"."):lang&&bits.push("English with a neutral British or international accent, explicitly not American/US-accented.");const settingBits=[profile&&profile.genre,profile&&profile.setting,profile&&profile.era].filter(Boolean);return settingBits.length&&bits.push("Setting: "+settingBits.join(", ")+"."),bits.push("A "+(genderWord?genderWord+" ":"")+"voice"+(sh.archetype?" for "+sh.archetype.toLowerCase():"")+","),bits.push("with "+_hashPick(rec.name||rec.id||"",_VOICE_TEXTURE_POOL)+" and "+_hashPick((rec.name||rec.id||"")+"_pace",_VOICE_PACE_POOL)+"."),sh.voice_pattern&&bits.push(sh.voice_pattern),sh.mannerisms&&bits.push("Mannerisms: "+sh.mannerisms),sh.physical&&bits.push(sh.physical),sh.alignment&&bits.push("Disposition: "+sh.alignment),bits.join(" ").slice(0,600)}function _selectLoose(sel,val){if(!sel||!val)return;const v=String(val).toLowerCase(),opt=[...sel.options].find(function(o){const ov=o.value.toLowerCase(),ot=o.textContent.toLowerCase();return ov===v||ot===v||ov.startsWith(v)||ot.startsWith(v)||v.startsWith(ov)});opt&&(sel.value=opt.value,sel.dispatchEvent(new Event("change")))}function _charSearchOnline(rec){typeof navTo=="function"&&navTo("s-studio");const lang=_charLang(rec);setTimeout(function(){const fishTab=document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');fishTab&&fishTab.click(),setTimeout(function(){const langSel=document.getElementById("fa-lang");langSel&&_selectLoose(langSel,lang);const search=document.getElementById("fa-search");search&&(search.value=rec.name,search.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:!0})))},120)},120),toast("Searching online voices for "+rec.name+(lang?" ("+lang+")":""),"info")}function _editBookProfile(book,afterSave){_getBookProfile(book).then(function(profile){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Book context \u2014 '+escHtml(book)+`
Used in every voice design (and image) prompt for this book, so a fantasy story doesn't end up with 1920s-general portraits or English voices in a German book just because one character's own sheet was too sparse to tell.
',document.body.appendChild(ov);const close=function(result){ov.remove(),typeof afterSave=="function"&&afterSave(result||null)};ov.querySelector("#bctx-cancel").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#bctx-save").addEventListener("click",async function(){const btn=this;btn.disabled=!0;try{const saved=await _saveBookProfile(book,{genre:ov.querySelector("#bctx-genre").value,setting:ov.querySelector("#bctx-setting").value,era:ov.querySelector("#bctx-era").value,language:ov.querySelector("#bctx-lang").value});toast("Book context saved for "+book,"success"),close(saved)}catch(e){toast("Failed to save: "+(e.message||e),"error"),btn.disabled=!1}})})}window.openBookContextEditor=function(book){return new Promise(function(resolve){_editBookProfile(book,resolve)})};function _confirmVoiceReuse(rec,reuse){return new Promise(function(resolve){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Existing voice found for '+escHtml(rec.name)+'
"'+escHtml(reuse.voiceId)+'" is already used for '+escHtml(rec.name)+' in "'+escHtml(reuse.book)+'". Reuse it for series consistency, or design a brand-new voice just for this book?
',document.body.appendChild(ov);let audioEl=null,audioObjectUrl=null;ov.querySelector("#cvr-play").addEventListener("click",async function(e){const btn=e.currentTarget,icon=btn.querySelector(".mdi");if(audioEl&&!audioEl.paused){audioEl.pause(),icon.className="mdi mdi-play";return}btn.disabled=!0,icon.className="mdi mdi-loading mdi-spin";try{const langHint=typeof _resolveBookLang=="function"?await _resolveBookLang(rec).catch(function(){return""}):"",text=typeof _charSampleTextFor=="function"&&_charSampleTextFor(rec,langHint)||"Hallo, ich bin "+rec.name+".",rv=(window._voices||[]).find(x=>x.id===reuse.voiceId),rBackend=rv&&!rv.has_ref?"voice_design":"voice_clone",blob=await fetchTtsPreviewBlob(reuse.voiceId,text,"wav","",rBackend);audioEl||(audioEl=new Audio,audioEl.addEventListener("ended",function(){icon.className="mdi mdi-play",audioObjectUrl&&(URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=null)})),audioObjectUrl&&URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=URL.createObjectURL(blob),audioEl.src=audioObjectUrl,await audioEl.play(),icon.className="mdi mdi-pause"}catch(err){toast("Could not play sample: "+(err.message||err),"error"),icon.className="mdi mdi-play"}finally{btn.disabled=!1}});const cleanup=function(result){audioEl&&audioEl.pause(),audioObjectUrl&&(URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=null),ov.remove(),resolve(result)};ov.querySelector("#cvr-cancel").addEventListener("click",function(){cleanup("cancel")}),ov.querySelector("#cvr-use").addEventListener("click",function(){cleanup("use")}),ov.querySelector("#cvr-new").addEventListener("click",function(){cleanup("new")}),ov.addEventListener("click",function(e){e.target===ov&&cleanup("cancel")})})}async function _charDesignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){const choice=await _confirmVoiceReuse(rec,reuse);if(choice==="cancel")return;if(choice==="use"){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}}typeof navTo=="function"&&navTo("s-design");const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(sh.voice_design_prompt).trim();setTimeout(function(){_selectLoose(document.getElementById("design-gender"),sh.gender),_selectLoose(document.getElementById("design-language"),lang);const instruct=document.getElementById("design-instruct");instruct&&(instruct.value=_voiceIdentityPrompt(rec,savedPrompt||_buildVoicePrompt(rec,null,lang),lang));const nm=document.getElementById("design-preset-name");nm&&(nm.value=rec.name)},140),toast("Voice design prepared for "+rec.name+(lang?" \xB7 "+lang:""),"info")}function _charDesignVoiceInline(rec,promptOverride){const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(promptOverride||sh.voice_design_prompt).trim(),instruct=savedPrompt||_voiceIdentityPrompt(rec,_buildVoicePrompt(rec,null,lang),lang),ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Voice design prompt \u2014 '+escHtml(rec.name)+'
Edit the description, then generate a new voice from it. This replaces '+(rec.voice?"the current voice":"this character\u2019s voice")+'.
',document.body.appendChild(ov);const close=function(){ov.remove()};ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#cdi-cancel").addEventListener("click",close);let promptSaveTimer=null;const saveInlinePrompt=async function(text){if(text=String(text||"").trim(),!!text&&(rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,rec.updated=new Date,typeof clPut=="function")){const saved=await clPut(rec);saved&&saved.sheet&&(rec.sheet=saved.sheet)}},promptInput=ov.querySelector("#cdi-instruct");promptInput.addEventListener("input",function(){clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(function(){saveInlinePrompt(promptInput.value).catch(function(err){console.warn("[character prompt] autosave failed",err)})},600)}),ov.querySelector("#cdi-save").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;clearTimeout(promptSaveTimer),btn.disabled=!0,btn.innerHTML=' Saving\u2026';try{await saveInlinePrompt(promptInput.value),toast("Voice design prompt saved","success")}catch(err){toast("Prompt save failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-rewrite").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Rewriting\u2026';try{const target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},sample=[sh.physical,sh.backstory,sh.motivation].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",r=await fetch("/api/character-generate-prompts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:rec.name,book:rec.book||"",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},sheet:sh,language,llm_url:target.url,model:target.model,fields:["voice_design_prompt"]})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();if(!d.voice_design_prompt)throw new Error("The rewrite returned an empty prompt");ov.querySelector("#cdi-instruct").value=d.voice_design_prompt,await saveInlinePrompt(d.voice_design_prompt),toast("Voice design prompt rewritten \u2014 review it, then generate the voice","success")}catch(err){toast("Prompt rewrite failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-generate").addEventListener("click",async function(e){const btn=e.currentTarget,text=ov.querySelector("#cdi-instruct").value.trim();if(!text){toast("Prompt is empty","error");return}clearTimeout(promptSaveTimer),btn.disabled=!0;const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-loading mdi-spin");try{await saveInlinePrompt(text),await _charAutoDesignVoice(rec,!0,text),_schedulePendingTtsRestart(),rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,close(),toast("New voice designed for "+rec.name,"success");const detailPage=document.querySelector(".lib-char-page"),detailOpts=window._libDetailOpts;if(detailPage&&detailOpts&&typeof _charDetailPage=="function"){const all=typeof clGetAll=="function"?await clGetAll().catch(function(){return[]}):[],updated=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(updated,all.filter(function(r){return r.book===updated.book}),detailOpts)}else typeof libraryRenderCharacters=="function"&&await libraryRenderCharacters();typeof _libRefreshDetailModal=="function"&&_libRefreshDetailModal(rec)}catch(err){toast("Voice design failed: "+(err.message||err),"error"),btn.disabled=!1,icon&&(icon.className="mdi mdi-creation")}})}function _charCloneVoice(rec){typeof navTo=="function"&&navTo("s-clone"),setTimeout(function(){const nm=document.getElementById("clone-your-name");nm&&(nm.value=rec.name)},140),toast("Clone a Voice prepared for "+rec.name+" \u2014 pick a mic take, file, or YouTube URL","info")}const _DESIGN_SAMPLE_FALLBACK={German:"Ich habe lange auf diesen Moment gewartet, und jetzt, da er da ist, wei\xDF ich genau, was zu tun ist.",English:"I have waited a long time for this moment, and now that it is here, I know exactly what to do."};function _charRealLine(rec){const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook,nameLower=String(rec.name||"").trim().toLowerCase(),line=(ab&&ab.segments||[]).find(function(s){return s&&s.type==="dialogue"&&String(s.speaker||"").trim().toLowerCase()===nameLower&&s.text&&s.text.trim().length>=20&&s.text.trim().length<=200});if(line)return line.text.trim();const quotes=(Array.isArray(rec.sheet&&rec.sheet.sources)?rec.sheet.sources:[]).map(function(s){return s&&s.quote?String(s.quote).trim():""}).filter(function(q){return q.length>=20&&q.length<=240}),spoken=quotes.find(function(q){return/[""„"]/.test(q)});return spoken||(quotes.length?quotes[0]:null)}function _charSampleTextFor(rec,langNameHint){const lang=_charLang(rec)||langNameHint||"English",greeting=lang==="German"?`Hallo, ich bin ${rec.name}.`:`Hello, I am ${rec.name}.`,line=_charRealLine(rec);return line?`${greeting} ${line}`:_DESIGN_SAMPLE_FALLBACK[lang]||_DESIGN_SAMPLE_FALLBACK.English}const _GENERIC_NAME_GENDER={frau:"female",dame:"female",junge_frau:"female",m\u00E4dchen:"female",maedchen:"female",mann:"male",herr:"male",junge:"male",knabe:"male"};function _genderFromGenericName(name){const key=String(name||"").trim().toLowerCase().replace(/\s+/g,"_");return _GENERIC_NAME_GENDER[key]||""}let _voiceRestartPending=!1;async function _flushPendingTtsRestart(){if(_voiceRestartPending){_voiceRestartPending=!1;try{const r=await fetch("/api/tts/restart",{method:"POST"});r.ok?toast("TTS backend restarted to pick up the newly designed voice(s)","success"):console.warn("[tts restart] failed:",r.status)}catch(e){console.warn("[tts restart]",e)}}}let _voiceRestartDebounceTimer=null;function _schedulePendingTtsRestart(){clearTimeout(_voiceRestartDebounceTimer),_voiceRestartDebounceTimer=setTimeout(_flushPendingTtsRestart,4e3)}async function _fetchRetryingNetworkErrors(url,opts,tries){tries=tries||3;for(let i=1;i<=tries;i++)try{return await fetch(url,opts)}catch(e){if(i===tries)throw e;await new Promise(function(r){setTimeout(r,2500*i)})}}function _designBenchmarkWpmBad(b){if(!b||!b.ok||!b.audio_sec||!b.text)return!1;const wpm=String(b.text).trim().split(/\s+/).length/(b.audio_sec/60);return wpm<80||wpm>400}async function _charAutoDesignVoice(rec,force,instructOverride){const reuse=force||instructOverride?null:await _findVoiceFromSameCharacterElsewhere(rec);if(reuse&&reuse.voiceId!==rec.voice){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec);return}const sh=rec.sheet||{},langName=await _resolveBookLang(rec)||"English",instruct=instructOverride?_libStr(instructOverride).trim():_voiceIdentityPrompt(rec,_libStr(sh.voice_design_prompt).trim()||_buildVoicePrompt(rec,await _getBookProfile(rec.book),langName),langName);if(!instruct.trim())throw new Error("No character description to design a voice from yet");instructOverride&&(sh.voice_design_prompt=instruct);const langCode=typeof DESIGN_LANG_CODE!="undefined"&&DESIGN_LANG_CODE[langName]||"EN",genderWord=String(sh.gender||"").toLowerCase()||_genderFromGenericName(rec.name),genderLetter=genderWord.startsWith("f")?"F":genderWord.startsWith("m")?"M":"N",sampleText=_charSampleTextFor(rec,langName),dialogue=typeof isDialogueDesign=="function"?isDialogueDesign(instruct,sampleText,null):!1,baseName=typeof designSafeName=="function"?designSafeName(rec.name):(typeof _umlautSafe=="function"?_umlautSafe(rec.name||"VoiceDesign"):String(rec.name||"VoiceDesign")).replace(/[^A-Za-z0-9]+/g,"_"),voiceId=(langCode+"_"+genderLetter+"_"+baseName).slice(0,96),maxAttempts=3;let saved=null;for(let attempt=1;attempt<=maxAttempts;attempt++){const r1=await _fetchRetryingNetworkErrors("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({instruct,sample_text:sampleText,language:langName,gender:genderLetter,dialogue})});if(!r1.ok){const e=await r1.json().catch(function(){return{}});throw new Error(e.detail||r1.statusText)}const designed=await r1.json(),tryId=(voiceId+"__try"+attempt).slice(0,96),r2=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:tryId,transcript:sampleText})});if(!r2.ok){const e=await r2.json().catch(function(){return{}});throw new Error(e.detail||r2.statusText)}await r2.json();let bad=!1;if(typeof runVoiceBenchmark=="function")try{const d=await runVoiceBenchmark(tryId,{text:sampleText}),hit=(d&&d.voices||[]).find(function(x){return x.voice_id===tryId}),b=hit&&hit.benchmark;!b||!b.ok&&/connection (refused|reset|aborted)|max retries exceeded|newconnectionerror|econnrefused|timed? ?out/i.test(String(b.error||""))?console.warn("[voice design] benchmark unreachable, accepting unverified:",b&&b.error):bad=!!(b.clipped||_designBenchmarkWpmBad(b))}catch(e){console.warn("[voice benchmark]",e)}if(!bad&&typeof _voiceRoundtripCheck=="function")try{const rt=await _voiceRoundtripCheck(tryId,sampleText,"voice_design");rt.score<.5&&(bad=!0,console.warn("[voice design] STT roundtrip mismatch (score "+rt.score.toFixed(2)+'): said "'+rt.transcript+'"'))}catch(e){console.warn("[voice design roundtrip]",e)}if(!bad){const r3=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:voiceId,transcript:sampleText})});if(!r3.ok){const e=await r3.json().catch(function(){return{}});throw new Error(e.detail||r3.statusText)}saved=await r3.json(),saved.needs_tts_restart&&(_voiceRestartPending=!0),await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){});break}if(await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){}),attempt===maxAttempts)throw new Error('Voice design for "'+voiceId+'" produced broken audio after '+maxAttempts+" attempts \u2014 left the previous voice in place, try again later")}return typeof saveMeta=="function"&&await saveMeta(saved.voice_id,{gender:genderLetter,flag:typeof LANG_FLAG_DEFAULT!="undefined"?LANG_FLAG_DEFAULT[langCode]:void 0,origin:"designed",group:rec.book||void 0,tag:rec.book||void 0,transcript:sampleText,voice_design_prompt:instruct}).catch(function(){}),rec.voice=saved.voice_id,await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name,voice:saved.voice_id}),rec.id),_syncVoicePictureFromChar(rec),saved.voice_id}async function _charAutoGenerateImage(rec,provider){const sh=rec.sheet||{},hasExplicitPrompt=!!_libStr(sh.image_prompt).trim();if(!hasExplicitPrompt&&[sh.archetype,sh.physical,sh.clothing].filter(Boolean).join(" ").trim().length<20)throw new Error("Not enough character detail to generate a meaningful portrait \u2014 skipped instead of using a generic placeholder");const bookProfile=typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},prompt=hasExplicitPrompt?_libStr(sh.image_prompt).trim():typeof csBuildImagePrompt=="function"?csBuildImagePrompt(sh,bookProfile):"";if(!prompt)throw new Error("No image prompt to work from yet");const body={prompt,image_kind:"portrait"};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return typeof clSetImage=="function"&&await clSetImage(rec.id,d.image),rec.image=d.image,document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''}),_syncVoicePictureFromChar(rec),d.image}const _CIJ_STEPS=[{key:"identity",label:"Identity portrait"},{key:"render",label:"Turnaround, scene & props"}];function _cijShowPanel(name){var _a2;(_a2=document.getElementById("cij-progress-panel"))==null||_a2.remove();const panel=document.createElement("div");panel.id="cij-progress-panel",panel.className="cij-progress-panel";const stepsHtml=_CIJ_STEPS.map(function(s){return'
',document.body.appendChild(panel),panel.querySelector(".cij-progress-close").addEventListener("click",function(){panel.remove()});const cancelButton=panel.querySelector(".cij-progress-cancel");return cancelButton.addEventListener("click",async function(){cancelButton.disabled=!0,cancelButton.textContent="Cancelling\u2026";try{if(!(await fetch("/api/character-generate-consistent-images/cancel/"+encodeURIComponent(panel.dataset.jobId),{method:"POST"})).ok)throw new Error("Cancellation request failed")}catch(error){cancelButton.disabled=!1,cancelButton.textContent="Cancel",toast(error.message||"Could not cancel generation","error")}}),{update:function(stage,detail,elapsed,warn){const p=document.getElementById("cij-progress-panel");if(!p)return;p.querySelector(".cij-progress-detail").textContent=detail||"Working\u2026",p.querySelector(".cij-progress-elapsed").textContent="Elapsed "+_cijFmtElapsed(elapsed)+(warn?" \u2014 no update in a while, it may be stuck":""),p.classList.toggle("cij-progress-warn",!!warn);const activeIndex=_CIJ_STEPS.findIndex(function(s){return s.key===stage});p.querySelectorAll(".cij-step").forEach(function(el,i){const icon=el.querySelector(".cij-step-icon");el.classList.remove("is-done","is-active","is-pending"),activeIndex<0||i60;if(panel.update(status2.stage,status2.detail,status2.elapsed,warn),status2.done){if(status2.error)throw panel.done(!1),new Error(status2.error);return panel.done(!0),status2.result}}}async function _charAutoGenerateConsistentImages(rec,visualStyle){const sh=rec.sheet||{},profilePrompt=_libStr(sh.image_prompt).trim(),referencePrompt=_libStr(sh.concept_art_prompt).trim();if(!profilePrompt)throw new Error("No Character Image Prompt \u2014 generate it first");if(!referencePrompt)throw new Error("No Character Reference Prompt \u2014 generate it first");const r=await fetch("/api/character-generate-consistent-images",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({profile_prompt:profilePrompt,reference_prompt:referencePrompt,name:rec.name||"",book:rec.book||"",sheet:sh,visual_style:visualStyle||"cinematic",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},identity_image:"",reuse_identity:!1})});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const{job_id}=await r.json(),panel=_cijShowPanel(rec.name);panel.setJobId(job_id);const d=await _cijPollJob(job_id,panel);if(!d.profile||!d.reference)throw new Error("ComfyUI returned an incomplete image pair");typeof clSetImage=="function"&&await clSetImage(rec.id,d.profile);const target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;return target.image=d.profile,target.sheet=Object.assign({},target.sheet||{},{concept_art_image:d.reference,consistent_prompt_version:d.prompt_version||"v3",consistent_identity_fingerprint:d.identity_fingerprint||"",identity_consistent:d.identity_consistent!==!1}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),rec.image=d.profile,rec.sheet=Object.assign({},sh,{concept_art_image:d.reference,consistent_prompt_version:d.prompt_version||"v3",consistent_identity_fingerprint:d.identity_fingerprint||"",identity_consistent:d.identity_consistent!==!1}),document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''}),d.profile}async function _charAutoGenerateConceptArt(rec,provider){const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim();if(!prompt)throw new Error("No concept art prompt to work from yet \u2014 generate the prompt first");const body={prompt,image_kind:"concept_art"};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json(),target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec;return target.sheet=Object.assign({},target.sheet||{},{concept_art_image:d.image}),target.updated=new Date,typeof clPut=="function"&&await clPut(target),sh.concept_art_image=d.image,rec.sheet=sh,d.image}function _openConceptArtLightbox(rec,onSaved){var _a2;(_a2=document.getElementById("conceptart-lightbox"))==null||_a2.remove();const sh=rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim(),ov=document.createElement("div");ov.id="conceptart-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
Optional \u2014 let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.