diff --git a/CHANGELOG.md b/CHANGELOG.md index 0143ee7..7300d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Bounded Rehearser Audio Cache.** Decoded/synthesized line audio now uses a 128-entry LRU cache so long scripts + cannot grow browser memory without limit. - **Shared Voice-Library Cache.** Library, Benchmark, and Rehearser now share a bounded session cache with in-flight request coalescing and explicit invalidation after voice mutations. - **Frontend Namespace Bridge.** A stable `window.TTVC` migration surface now exposes Audiobook, Rehearser, and job diff --git a/TODO.md b/TODO.md index 120cbad..d997ac2 100644 --- a/TODO.md +++ b/TODO.md @@ -127,8 +127,8 @@ the first block before investing in architecture or polish. invalidation after library metadata/folder mutations; the SQLite voice index remains authoritative. - [x] Lazy-load profile images and concept art. - [ ] Update only affected table rows instead of rebuilding entire tables. -- [ ] Add an LRU cache for audio and images and remove old entries automatically. Persisted line-audio is now bounded; - image-cache eviction remains. +- [x] Add an LRU cache for decoded Rehearser audio and remove old entries automatically. Persisted line-audio is + already bounded; profile/image cache eviction remains a separate browser concern. - [x] Revoke discarded object URLs with `URL.revokeObjectURL(objectUrl)`; completed synthesized voice previews and character/Rehearser local auditions now release their Blob URLs when playback ends or is replaced. - [x] Add low-memory mode using `navigator.deviceMemory` where available; shared worker pools cap at two tasks on devices reporting approximately 4 GB RAM or less. diff --git a/VERSION b/VERSION index 59b0544..16cb6f2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.13 +1.24.14 diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 6f5ba3b..1643905 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -1,4 +1,4 @@ -var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc,_pc,_qc,_rc,_sc,_tc,_uc,_vc,_wc,_xc,_yc,_zc,_Ac;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
+var __defProp=Object.defineProperty;var __defNormalProp=(obj,key,value)=>key in obj?__defProp(obj,key,{enumerable:!0,configurable:!0,writable:!0,value}):obj[key]=value;var __publicField=(obj,key,value)=>__defNormalProp(obj,typeof key!="symbol"?key+"":key,value);var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc,_pc,_qc,_rc,_sc,_tc,_uc,_vc,_wc,_xc,_yc,_zc,_Ac;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
${_avatarHtml(id,26)} ${_esc(name)} ${meta?`${_esc(meta)}`:_flagSpan(v)} @@ -876,7 +876,7 @@ This warms each voice so the engine caches its .pt and first playback is instant ${esc(status2)} `}).join(""):'No voices benchmarked.')}async function initTurnControls(){try{const settings=await fetchJson("/api/settings");q("bench-turn-llm-url")&&(q("bench-turn-llm-url").value=settings.conv_llm_url||settings.llm_url||"")}catch{}try{const d=await fetchJson("/api/stt-backends"),sel=q("bench-turn-stt");sel&&(sel.innerHTML=(d.backends||[]).map(b=>``).join("")||'')}catch{}try{typeof refreshTtsBackendAvailability=="function"&&await refreshTtsBackendAvailability();const sel=q("bench-turn-tts-backend"),backends=(window._ttsBackends||(typeof _ttsBackends!="undefined"?_ttsBackends:[])||[]).filter(Boolean);sel&&(sel.innerHTML=backends.map(b=>``).join("")||'')}catch{}}async function fetchTurnModels(){var _a2;const btn=q("bench-turn-fetch-llm"),sel=q("bench-turn-llm-model"),url=((_a2=q("bench-turn-llm-url"))==null?void 0:_a2.value.trim())||"";btn&&(btn.disabled=!0);try{const models=(await fetchJson("/api/conversation/llm-models"+(url?"?url="+encodeURIComponent(url):""))).models||[];sel&&(sel.innerHTML=models.length?models.map(m=>``).join(""):'')}catch(e){sel&&(sel.innerHTML=''),say("Model fetch failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}async function fetchTurnVoices(){var _a2;const btn=q("bench-turn-fetch-voices"),sel=q("bench-turn-voice"),backend=((_a2=q("bench-turn-tts-backend"))==null?void 0:_a2.value)||"voice_clone",picker=window.BenchmarkVoicePicker;btn&&(btn.disabled=!0);try{const raw=await fetchJson("/api/tts-voices?backend="+encodeURIComponent(backend)),items=(Array.isArray(raw)?raw:[]).map(v=>{const id=typeof backendVoiceId=="function"?backendVoiceId(v):typeof v=="string"?v:v.id||v.voice||v.name;return id?{id,label:id,meta:(window._voices||[]).find(x=>x&&x.id===id)||(typeof v=="object"?v:null)}:null}).filter(Boolean);picker?picker.populate("bench-turn-voice",items,{placeholder:"Fetch voices",empty:"No voices"}):sel&&(sel.innerHTML=items.length?items.map(v=>``).join(""):'')}catch(e){picker?picker.populate("bench-turn-voice",[],{placeholder:"Fetch failed",empty:"Fetch failed"}):sel&&(sel.innerHTML=''),say("Voice fetch failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function updateTurnStats(stats){const max=stats.total_ms||1;[["stt",stats.stt_ms],["ttft",stats.llm_ttft_ms],["llm",stats.llm_total_ms],["tts",stats.tts_ms],["total",stats.total_ms]].forEach(([key,ms])=>{const val=q("bench-turn-val-"+key),fill=q("bench-turn-fill-"+key);val&&(val.textContent=fmtMs(ms)),fill&&(fill.style.width=max>0?Math.min(100,(ms||0)/max*100)+"%":"0%")})}function addTurnLog(role,text){var _a2;const log=q("bench-turn-log");if(!log)return null;(_a2=log.querySelector(".conv-chat-welcome"))==null||_a2.remove();const wrap=document.createElement("div");wrap.className=`conv-bubble-wrap conv-bubble-wrap--${role}`;const bubble=document.createElement("div");return bubble.className=`conv-bubble conv-bubble--${role}`,bubble.textContent=text||"",wrap.appendChild(bubble),log.appendChild(wrap),log.scrollTop=log.scrollHeight,bubble}function addTurnHistory(total,ok){var _a2;const hist=q("bench-turn-history");if(!hist)return;(_a2=hist.querySelector(".conv-history-empty"))==null||_a2.remove(),turnHistoryCount++;const item=document.createElement("div");item.className="conv-hist-item",item.innerHTML=`#${turnHistoryCount}${fmtMs(total)}`,hist.prepend(item)}async function runTurnBenchmark(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2,_j2,_k2;const audio=(_b2=(_a2=q("bench-turn-audio"))==null?void 0:_a2.files)==null?void 0:_b2[0],text=((_c2=q("bench-turn-text"))==null?void 0:_c2.value.trim())||"";if(!audio&&!text){say("Choose turn audio or enter fallback text","error");return}const btn=q("bench-turn-run"),st=q("bench-turn-status");btn&&(btn.disabled=!0),st&&(st.textContent="Running conversation turn...");const t0=Date.now(),userBubble=addTurnLog("user",text||"Transcribing audio..."),assistantBubble=addTurnLog("assistant","...");let assistantText="",lastStats=null;try{const fd=new FormData;audio&&fd.append("audio",audio,audio.name),text&&fd.append("text",text),fd.append("stt_backend",((_d2=q("bench-turn-stt"))==null?void 0:_d2.value)||"configured"),fd.append("llm_url",((_e2=q("bench-turn-llm-url"))==null?void 0:_e2.value.trim())||""),fd.append("llm_model",((_f2=q("bench-turn-llm-model"))==null?void 0:_f2.value)||""),fd.append("tts_backend",((_g2=q("bench-turn-tts-backend"))==null?void 0:_g2.value)||"voice_clone"),fd.append("tts_voice",((_h2=q("bench-turn-voice"))==null?void 0:_h2.value)||""),fd.append("system_prompt",((_i2=q("bench-turn-system"))==null?void 0:_i2.value.trim())||"You are a helpful voice assistant."),fd.append("history","[]");const resp=await fetch("/api/conversation/turn",{method:"POST",body:fd});if(!resp.ok)throw new Error("Server error "+resp.status);const reader=resp.body.getReader(),dec=new TextDecoder;let buf="";for(;;){const{done,value}=await reader.read();if(done)break;buf+=dec.decode(value,{stream:!0});const lines=buf.split(` `);buf=lines.pop();for(const line of lines){if(!line.startsWith("data:"))continue;let evt;try{evt=JSON.parse(line.slice(5).trim())}catch{continue}if(evt.type==="transcript"&&userBubble&&(userBubble.textContent=evt.text||"(empty)"),evt.type==="token"&&(assistantText+=evt.delta||"",assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="llm_done"&&(assistantText=evt.text||assistantText,assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="audio"&&evt.b64){const bytes=Uint8Array.from(atob(evt.b64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:evt.mime||"audio/wav"})),audioEl=document.createElement("audio");audioEl.controls=!0,audioEl.src=url,audioEl.addEventListener("ended",()=>URL.revokeObjectURL(url),{once:!0}),(_j2=q("bench-turn-log"))==null||_j2.appendChild(audioEl)}if(evt.type==="stats"&&(lastStats=evt,updateTurnStats(evt)),evt.type==="error")throw new Error(`[${evt.stage||"turn"}] ${evt.message||"Unknown error"}`)}}const total=(_k2=lastStats==null?void 0:lastStats.total_ms)!=null?_k2:Date.now()-t0;addTurnHistory(total,!0),st&&(st.textContent=`Finished in ${fmtMs(total)}.`),say("Turn benchmark complete","success")}catch(e){assistantBubble&&(assistantBubble.textContent=e.message),addTurnHistory(Date.now()-t0,!1),st&&(st.textContent="Turn benchmark failed"),say("Turn benchmark failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function bindBenchmarkSection(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2;initialized||!q("bench-stt-run")||(initialized=!0,document.querySelectorAll(".bench-tab").forEach(btn=>btn.addEventListener("click",()=>setBenchTab(btn.dataset.benchTab))),window.BenchmarkVoicePicker&&(BenchmarkVoicePicker.upgrade("bench-stt-library-voice",{placeholder:"-- choose from voice library --",empty:"No library voices with reference transcripts found"}),BenchmarkVoicePicker.upgrade("perf-voice-select",{placeholder:"-- select after fetch --",empty:"No voices"}),BenchmarkVoicePicker.upgrade("bench-turn-voice",{placeholder:"Fetch voices",empty:"No voices"})),document.addEventListener("click",()=>closeBenchmarkModelPickers()),(_a2=q("bench-stt-refresh"))==null||_a2.addEventListener("click",loadBenchmarkSttEngines),(_b2=q("bench-stt-load-voice"))==null||_b2.addEventListener("click",useBenchmarkLibraryVoice),(_c2=q("bench-stt-audio"))==null||_c2.addEventListener("change",()=>{q("bench-stt-source-id")&&(q("bench-stt-source-id").value="")}),(_d2=q("bench-stt-run"))==null||_d2.addEventListener("click",runSttBenchmark),(_e2=q("bench-tts-run"))==null||_e2.addEventListener("click",runTtsBenchmark),(_f2=q("bench-turn-fetch-llm"))==null||_f2.addEventListener("click",fetchTurnModels),(_g2=q("bench-turn-fetch-voices"))==null||_g2.addEventListener("click",fetchTurnVoices),(_h2=q("bench-turn-run"))==null||_h2.addEventListener("click",runTurnBenchmark),(_i2=q("bench-turn-tts-backend"))==null||_i2.addEventListener("change",()=>{window.BenchmarkVoicePicker?BenchmarkVoicePicker.populate("bench-turn-voice",[],{placeholder:"Fetch voices",empty:"No voices"}):q("bench-turn-voice")&&(q("bench-turn-voice").innerHTML='')}))}function loadBenchmarkSectionData(){bindBenchmarkSection(),!(benchmarkDataLoaded||!q("bench-stt-run"))&&(benchmarkDataLoaded=!0,loadBenchmarkSttEngines(),loadBenchmarkVoiceLibrary(),initTurnControls())}window.loadBenchmarkSectionData=loadBenchmarkSectionData,bindBenchmarkSection(),!initialized&&document.body&&new MutationObserver(()=>bindBenchmarkSection()).observe(document.body,{childList:!0,subtree:!0})}();let sttTtsSourceId=null,sttTtsOutputBlob=null,_sttBackends=[],sttTtsRecorder=null,sttTtsRecordStream=null,sttTtsRecordChunks=[],sttTtsRecordTimer=null,sttTtsRecordSecs=0;function sttTtsSelectedSttBackend(){var _a2;return((_a2=$("stt-tts-stt-backend"))==null?void 0:_a2.value)||"configured"}function sttBackendOptionHtml(selected="configured"){var _a2;if(!_sttBackends.length)return'';const preferred=_sttBackends.some(b=>b.id===selected&&b.available)?selected:((_a2=_sttBackends.find(b=>b.available))==null?void 0:_a2.id)||selected;return _sttBackends.map(b=>{const suffix=b.available?"":" (unavailable)",disabled=b.available?"":" disabled";return``}).join("")}function updateSttBackendHelp(){const selected=sttTtsSelectedSttBackend(),b=_sttBackends.find(item=>item.id===selected)||_sttBackends.find(item=>item.available)||null,help=$("stt-tts-stt-help");if(help){if(!b){help.textContent="No STT engine status loaded yet.";return}help.innerHTML=sttBackendHelpHtml(b)}}async function refreshSttBackends(selected=""){try{_sttBackends=((await fetch("/api/stt-backends").then(r=>r.json())).backends||[]).filter(b=>b&&b.id)}catch{_sttBackends=[]}["stt-tts-stt-backend","clone-stt-backend"].forEach(id=>{const sel=$(id);if(!sel)return;const prev=selected||sel.value||"configured";sel.innerHTML=sttBackendOptionHtml(prev),sel.disabled=!_sttBackends.some(b=>b.available)}),updateSttBackendHelp(),typeof window.updateStatusBar=="function"&&window.updateStatusBar(),typeof window.refreshStatusBarEngines=="function"&&window.refreshStatusBarEngines()}function sttTtsSelectedBackend(){var _a2;return((_a2=$("stt-tts-backend-select"))==null?void 0:_a2.value)||""}function sttTtsDownload(blob,name){if(!blob)return;const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=name,a.click()}async function sttTtsUploadFile(file){if(!file)return;$("stt-tts-source-status").textContent="Uploading "+file.name+"...",sttTtsSourceId=null,sttTtsOutputBlob=null,$("stt-tts-transcribe-btn").disabled=!0,$("stt-tts-copy-preview-btn").disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();sttTtsSourceId=d.id;const audio=$("stt-tts-source-audio");audio.src="/api/audio/"+encodeURIComponent(d.id),audio.style.display="",$("stt-tts-source-status").textContent=`${d.filename||file.name} loaded (${Number(d.duration||0).toFixed(1)} s).`,$("stt-tts-transcribe-btn").disabled=!1,toast("Speech audio loaded","success")}catch(e){$("stt-tts-source-status").textContent="Upload failed.",toast("STT source upload failed: "+e.message,"error")}}async function sttTtsFetchVoices(){const backend=sttTtsSelectedBackend();if(!backend)throw new Error("No available TTS backend");const rawVoices=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());let voices=Array.isArray(rawVoices)?rawVoices:[];if(shouldFilterBackendVoices(backend)){const activeIds=await activeLibraryVoiceIds();voices=voices.filter(v=>activeIds.has(backendVoiceId(v)))}const sel=$("stt-tts-voice-select"),prev=sel.value;return sel.innerHTML='',voices.forEach(v=>{const id=backendVoiceId(v),opt=document.createElement("option");opt.value=opt.textContent=id,sel.appendChild(opt)}),prev&&voices.some(v=>backendVoiceId(v)===prev)&&(sel.value=prev),voices.length}(_da=$("stt-tts-file"))==null||_da.addEventListener("change",async()=>{const input=$("stt-tts-file");input.files&&input.files.length&&await sttTtsUploadFile(input.files[0]),input.value=""}),(_ea=$("stt-tts-refresh-stt-btn"))==null||_ea.addEventListener("click",async()=>{const btn=$("stt-tts-refresh-stt-btn");btn.disabled=!0;try{await refreshSttBackends(sttTtsSelectedSttBackend()),toast("STT engines refreshed","success")}finally{btn.disabled=!1}}),(_fa=$("stt-tts-stt-backend"))==null||_fa.addEventListener("change",updateSttBackendHelp);function sttTtsSetRecording(on){$("stt-tts-rec-start").disabled=on,$("stt-tts-rec-stop").disabled=!on}function sttTtsStopTracks(){sttTtsRecordStream&&sttTtsRecordStream.getTracks().forEach(t=>t.stop()),sttTtsRecordStream=null}(_ga=$("stt-tts-rec-start"))==null||_ga.addEventListener("click",async()=>{try{sttTtsRecordStream=await requestMicrophoneStream(),sttTtsRecordChunks=[],sttTtsRecordSecs=0,$("stt-tts-rec-time").textContent="0:00",$("stt-tts-source-status").textContent="Recording...",sttTtsSetRecording(!0),sttTtsRecordTimer=setInterval(()=>{sttTtsRecordSecs++,$("stt-tts-rec-time").textContent=Math.floor(sttTtsRecordSecs/60)+":"+String(sttTtsRecordSecs%60).padStart(2,"0")},1e3),sttTtsRecorder=new MediaRecorder(sttTtsRecordStream,{audioBitsPerSecond:256e3}),sttTtsRecorder.ondataavailable=e=>{e.data.size&&sttTtsRecordChunks.push(e.data)},sttTtsRecorder.onstop=async()=>{clearInterval(sttTtsRecordTimer),sttTtsRecordTimer=null,sttTtsSetRecording(!1),sttTtsStopTracks();const mime=sttTtsRecorder.mimeType||"audio/webm",blob=new Blob(sttTtsRecordChunks,{type:mime}),ext=mime.includes("ogg")?".ogg":".webm";if(!blob.size){$("stt-tts-source-status").textContent="Recording was empty.",toast("Recording was empty","error");return}await sttTtsUploadFile(new File([blob],"stt-recording"+ext,{type:mime}))},sttTtsRecorder.start(100),toast("Recording started","success")}catch(e){sttTtsSetRecording(!1),sttTtsStopTracks();const message=await microphoneErrorMessage(e);$("stt-tts-source-status").textContent=message,toast(message,"error")}}),(_ha=$("stt-tts-rec-stop"))==null||_ha.addEventListener("click",()=>{sttTtsRecorder&&sttTtsRecorder.state!=="inactive"&&sttTtsRecorder.stop()}),(_ia=$("stt-tts-transcribe-btn"))==null||_ia.addEventListener("click",async()=>{if(!sttTtsSourceId){toast("Load speech audio first","error");return}const btn=$("stt-tts-transcribe-btn");btn.disabled=!0,$("stt-tts-source-status").textContent="Transcribing...";try{const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:sttTtsSourceId,backend:sttTtsSelectedSttBackend()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("stt-tts-text").value=d.text||"",$("stt-tts-copy-preview-btn").disabled=!(d.text||"").trim();const used=d.backend?" via "+d.backend:"";$("stt-tts-source-status").textContent="Transcription ready"+used+".",typeof updateRefineButtonState=="function"&&updateRefineButtonState(),toast("Transcription ready","success")}catch(e){$("stt-tts-source-status").textContent="Transcription failed.",toast("STT failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ja=$("stt-tts-copy-preview-btn"))==null||_ja.addEventListener("click",()=>{const text=$("stt-tts-text").value.trim();text&&($("preview-text-area").value=text,switchTab("generation"),toast("Copied transcription to TTS Generation","success"))}),(_ka=$("stt-tts-backend-select"))==null||_ka.addEventListener("change",()=>{$("stt-tts-voice-select").innerHTML='',sttTtsOutputBlob=null,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0,updateBackendHelp()}),(_la=$("stt-tts-fetch-voices-btn"))==null||_la.addEventListener("click",async()=>{const btn=$("stt-tts-fetch-voices-btn");btn.disabled=!0;try{const count=await sttTtsFetchVoices();toast("Fetched "+count+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ma=$("stt-tts-generate-btn"))==null||_ma.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Transcribe or enter text first","error");return}const btn=$("stt-tts-generate-btn");btn.disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;try{const source=await createTtsAudioSource(voice,text,backend,$("stt-tts-playback-mode").value,instruct);sttTtsOutputBlob=source.blob;const audio=$("stt-tts-output-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("stt-tts-save-mp3-btn").disabled=!1,$("stt-tts-save-wav-btn").disabled=source.streaming,toast(source.streaming?"Streaming synthesized speech":"Synthesized speech ready","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_na=$("stt-tts-save-mp3-btn"))==null||_na.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend||!voice||!text)return;const btn=$("stt-tts-save-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend);sttTtsDownload(blob,(voice||"stt_tts")+"_stt_tts.mp3"),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_oa=$("stt-tts-save-wav-btn"))==null||_oa.addEventListener("click",()=>{sttTtsOutputBlob&&sttTtsDownload(sttTtsOutputBlob,($("stt-tts-voice-select").value||"stt_tts")+"_stt_tts.wav")});function parseScript(text){const rawLines=text.split(` -`),result=[];let state="action",currentSpeaker=null,dialogBuffer=[],actionBuffer=[];const FADE_IN_RE=/^FADE\s+IN[\s:.\-]*$/i,FIRST_SCENE_RE=/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?(INT\.|EXT\.|INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.)/i;let scanLines=rawLines;const firstIdx=rawLines.findIndex(l=>{const s=l.trim();return FADE_IN_RE.test(s)||FIRST_SCENE_RE.test(s)});firstIdx>0&&(scanLines=rawLines.slice(firstIdx));function flushDialog(){if(currentSpeaker&&dialogBuffer.length){const t=dialogBuffer.join(" ").trim();t&&result.push({type:"dialog",speaker:currentSpeaker,text:t,isDirection:!1,emotion:""})}dialogBuffer=[]}function flushAction(){if(actionBuffer.length){const t=actionBuffer.join(" ").trim();t&&result.push({type:"action",speaker:"",text:t,isDirection:!0}),actionBuffer=[]}}for(const rawLine of scanLines){const isPageBreak=rawLine.startsWith("\f"),line=isPageBreak?rawLine.slice(1).trim():rawLine.trim();if(isPageBreak){flushDialog(),flushAction();const pageNum=line&&/^\d+$/.test(line)?parseInt(line,10):null;result.push({type:"pagebreak",speaker:"",text:"",page:pageNum,isDirection:!0}),state="action",currentSpeaker=null;continue}if(!line){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(/\d{1,2}\/\d{1,2}\/\d{2,4}/.test(line)&&line.length<=60||/^[A-Z]{0,3}\d{1,4}[A-Z]?$/.test(line)||/^\(?CONTINUED\)?:?$/i.test(line)||/^[A-Z]{0,3}\d{1,4}[A-Z]?\s+CONTINUED:?(\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)||/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?OMITTED(?:\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(line.startsWith("#")){flushDialog(),flushAction();const dir=line.slice(1).trim();dir&&result.push({type:"direction",speaker:"",text:dir,isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^\[.*\]$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line.slice(1,-1),isDirection:!0});continue}{const m=line.match(/^(?:([A-Z]{0,3}\d{1,4}[A-Z]?)\s+)?((?:INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.|INT\.|EXT\.).*)$/i);if(m){flushDialog(),flushAction();let head=m[2].trim();m[1]&&head.toUpperCase().endsWith(m[1].toUpperCase())&&(head=head.slice(0,head.length-m[1].length).trim()),result.push({type:"scene",speaker:"",text:head.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}}if(/^ACT\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"act",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^SCENE\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"scene",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^(FADE\s+(IN|OUT|TO)|CUT\s+TO|SMASH\s+CUT|MATCH\s+CUT|DISSOLVE\s+TO|BLACKOUT|LIGHTS\s+(UP|DOWN|FADE)|CURTAIN|INTERMISSION|END\s+OF\s+(PLAY|ACT))[.:]?\s*$/i.test(line)){flushDialog(),flushAction(),result.push({type:"transition",speaker:"",text:line,isDirection:!0}),state="action",currentSpeaker=null;continue}const colonMatch=line.match(/^([\p{Lu}][\p{Lu}0-9 _\-ß]{0,39}):\s+(.+)$/u);if(colonMatch&&colonMatch[1].trim().length<=24&&colonMatch[1].trim().split(/\s+/).length<=3){flushDialog(),flushAction(),currentSpeaker=colonMatch[1].trim(),dialogBuffer=[colonMatch[2].trim()],state="dialog";continue}if(/^\(.*\)$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line,isDirection:!0});continue}const nameRaw=line.replace(/\s*\([^)]*\)\s*$/,"").trim();if(nameRaw.length>=2&&nameRaw.length<=42&&nameRaw===nameRaw.toUpperCase()&&/^[\p{Lu}][\p{Lu}0-9 '.\-ß]+$/u.test(nameRaw)&&!/^\d+$/.test(nameRaw)&&!/\.$/.test(nameRaw)){flushDialog(),flushAction(),currentSpeaker=nameRaw,state="character";continue}if(state==="character"){dialogBuffer=[line],state="dialog";continue}if(state==="dialog"){dialogBuffer.push(line);continue}actionBuffer.push(line),state="action"}return flushDialog(),flushAction(),result}function detectCharacters(lines){const speakers=[...new Set(lines.filter(l=>l.type==="dialog").map(l=>l.speaker))],cast={};return speakers.forEach((sp,i)=>{cast[sp]={voice:"",color:SPEAKER_COLORS[i%SPEAKER_COLORS.length],instruct:"",voiceData:null}}),cast}const SPEAKER_COLORS=["#89b4fa","#a6e3a1","#f38ba8","#fab387","#f9e2af","#cba6f7","#89dceb","#74c7ec"],REH_EMOTIONS=[{value:"",emoji:"\u{1F610}",label:"Neutral"},{value:"happy, cheerful and upbeat",emoji:"\u{1F60A}",label:"Happy"},{value:"sad, melancholy, somber",emoji:"\u{1F622}",label:"Sad"},{value:"angry, forceful, aggressive",emoji:"\u{1F620}",label:"Angry"},{value:"whisper, hushed and intimate",emoji:"\u{1F92B}",label:"Whisper"},{value:"excited, enthusiastic, energetic",emoji:"\u{1F929}",label:"Excited"},{value:"scared, nervous, trembling voice",emoji:"\u{1F628}",label:"Scared"},{value:"sarcastic, dry, ironic delivery",emoji:"\u{1F60F}",label:"Sarcastic"},{value:"dramatic, theatrical, intense",emoji:"\u{1F3AD}",label:"Dramatic"},{value:"gentle, warm, tender",emoji:"\u{1F970}",label:"Gentle"},{value:"confused, uncertain, hesitant",emoji:"\u{1F615}",label:"Confused"},{value:"bored, flat, disinterested",emoji:"\u{1F611}",label:"Bored"},{value:"surprised, shocked, astonished",emoji:"\u{1F632}",label:"Surprised"},{value:"confident, authoritative, bold",emoji:"\u{1F4AA}",label:"Confident"},{value:"mysterious, dark, ominous",emoji:"\u{1F311}",label:"Mysterious"},{value:"romantic, loving, passionate",emoji:"\u2764\uFE0F",label:"Romantic"},{value:"playful, teasing, mischievous",emoji:"\u{1F608}",label:"Playful"},{value:"calm, composed, measured",emoji:"\u{1F9D8}",label:"Calm"},{value:"commanding, authoritative, military",emoji:"\u2694\uFE0F",label:"Commanding"},{value:"grieving, tearful, broken",emoji:"\u{1F62D}",label:"Grieving"}];window.REH_EMOTIONS=REH_EMOTIONS;let rehCustomEmotions=[];try{rehCustomEmotions=JSON.parse(localStorage.getItem("reh-custom-emotions")||"[]")}catch{}function getEmotionInfo(value){if(!value)return{emoji:"",label:"Pick tone"};const found=[...REH_EMOTIONS,...rehCustomEmotions].find(e=>e.value===value);return found?{emoji:found.emoji,label:found.label}:{emoji:"\u2728",label:value.length>14?value.slice(0,13)+"\u2026":value}}function renderMarkdownInline(text){let s=escHtml(text);return s=s.replace(/\*\*([^*\n]+?)\*\*/g,"$1"),s=s.replace(/\*([^*\n]+?)\*/g,"$1"),s=s.replace(/__([^_\n]+?)__/g,"$1"),s=s.replace(/~~([^~\n]+?)~~/g,"$1"),s=s.replace(/==([^=\n]+?)==/g,'$1'),s}function stripMarkdown(text){return text.replace(/\*\*([^*\n]+?)\*\*/g,"$1").replace(/\*([^*\n]+?)\*/g,"$1").replace(/__([^_\n]+?)__/g,"$1").replace(/~~([^~\n]+?)~~/g,"$1").replace(/==([^=\n]+?)==/g,"$1")}const rehState={lines:[],cast:{},lineIndex:0,clips:[],voices:[],backend:"",playing:!1,repeat:!1,savedId:null,synthCache:new Map,staleLines:new Set,synthCancelled:!1,synthRunning:!1,skipDescriptions:!1,narratorVoice:"",practiceStart:null,practiceEnd:null,bulkMode:!1,bulkSel:new Set,bulkAnchor:null,showHidden:!1,recStream:null,recAudioCtx:null,recAnalyser:null,recSourceNode:null,recGainNode:null,recDestStream:null,recMeterRaf:null,recWaveRing:null,mediaRec:null,recChunks:[],recTimer:null,recSecs:0,lastRecBlob:null};window.rehState=rehState;async function rehDbGetAll(){const r=await fetch("/api/rehearsals");if(!r.ok)throw new Error("rehDbGetAll failed: "+r.status);return(await r.json()).rehearsals||[]}async function rehDbGetById(id){const r=await fetch("/api/rehearsals/"+id);if(r.status!==404){if(!r.ok)throw new Error("rehDbGetById failed: "+r.status);return r.json()}}async function rehDbAdd(record){const r=await fetch("/api/rehearsals",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbAdd failed: "+r.status);return(await r.json()).id}async function rehDbPut(record){if(!record.id)return rehDbAdd(record);const r=await fetch("/api/rehearsals/"+record.id,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbPut failed: "+r.status)}async function rehDbDelete(id){const r=await fetch("/api/rehearsals/"+id,{method:"DELETE"});if(!r.ok)throw new Error("rehDbDelete failed: "+r.status)}function _rehSanitize(rec){const out={...rec};return out.clips&&(out.clips=(out.clips||[]).map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type}))),out}(async function(){try{if((await rehDbGetAll()).length>0)return;const idbRecs=await _rehIdbGetAll().catch(()=>[]);if(!idbRecs.length)return;const r=await fetch("/api/rehearsals/migrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(idbRecs.map(_rehSanitize))});if(r.ok){const d=await r.json();console.log(`[rehearser] migrated ${d.imported} records from IndexedDB \u2192 SQLite`)}}catch(e){console.warn("[rehearser] migration skipped:",e)}})();function _rehIdbGetAll(){return new Promise(resolve=>{const req=indexedDB.open("reh-library",1);req.onerror=()=>resolve([]),req.onsuccess=e=>{const db=e.target.result;if(!db.objectStoreNames.contains("rehearsals")){db.close(),resolve([]);return}const all=db.transaction("rehearsals","readonly").objectStore("rehearsals").getAll();all.onsuccess=ev=>{db.close(),resolve(ev.target.result||[])},all.onerror=()=>{db.close(),resolve([])}}})}window.rehDbGetById=rehDbGetById,window.rehLoadRecord=loadRecord;async function clipsToJson(clips){return Promise.all(clips.map(async c=>{if(!c.blob)return{lineIndex:c.lineIndex,speaker:c.speaker,type:c.type};const ab=await c.blob.arrayBuffer(),u8=new Uint8Array(ab);let bin="";const CHUNK=8192;for(let i=0;i{if(!c.b64)return c;const bin=atob(c.b64),u8=new Uint8Array(bin.length);for(let i=0;i{cast[sp]={voice:c.voice,color:c.color,instruct:c.instruct||"",lang:c.lang||"",gender:c.gender||"",tags:c.tags||"",soul:c.soul||"",ignored:!!c.ignored,hidden:!!c.hidden}});const emotions={},notes={},ignored={},hidden={};return rehState.lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions[i]=l.emotion),l.type==="dialog"&&l.note&&(notes[i]=l.note),l.ignored&&(ignored[i]=!0),l.hidden&&(hidden[i]=!0)}),{title:((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||((_b2=$("reh-page-title"))==null?void 0:_b2.textContent)||"Untitled",script:rehState.lines.length?linesToScriptText():((_c2=$("reh-script-text"))==null?void 0:_c2.value.trim())||"",cast,emotions,notes,ignored,hidden,backend:rehState.backend,narratorVoice:rehState.narratorVoice,lineIndex:rehState.lineIndex,clips:rehState.clips.map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type,blob:c.blob||null})),updated:new Date}}function linesToScriptText(){return rehState.lines.map(line=>{switch(line.type){case"act":case"transition":return` +`),result=[];let state="action",currentSpeaker=null,dialogBuffer=[],actionBuffer=[];const FADE_IN_RE=/^FADE\s+IN[\s:.\-]*$/i,FIRST_SCENE_RE=/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?(INT\.|EXT\.|INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.)/i;let scanLines=rawLines;const firstIdx=rawLines.findIndex(l=>{const s=l.trim();return FADE_IN_RE.test(s)||FIRST_SCENE_RE.test(s)});firstIdx>0&&(scanLines=rawLines.slice(firstIdx));function flushDialog(){if(currentSpeaker&&dialogBuffer.length){const t=dialogBuffer.join(" ").trim();t&&result.push({type:"dialog",speaker:currentSpeaker,text:t,isDirection:!1,emotion:""})}dialogBuffer=[]}function flushAction(){if(actionBuffer.length){const t=actionBuffer.join(" ").trim();t&&result.push({type:"action",speaker:"",text:t,isDirection:!0}),actionBuffer=[]}}for(const rawLine of scanLines){const isPageBreak=rawLine.startsWith("\f"),line=isPageBreak?rawLine.slice(1).trim():rawLine.trim();if(isPageBreak){flushDialog(),flushAction();const pageNum=line&&/^\d+$/.test(line)?parseInt(line,10):null;result.push({type:"pagebreak",speaker:"",text:"",page:pageNum,isDirection:!0}),state="action",currentSpeaker=null;continue}if(!line){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(/\d{1,2}\/\d{1,2}\/\d{2,4}/.test(line)&&line.length<=60||/^[A-Z]{0,3}\d{1,4}[A-Z]?$/.test(line)||/^\(?CONTINUED\)?:?$/i.test(line)||/^[A-Z]{0,3}\d{1,4}[A-Z]?\s+CONTINUED:?(\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)||/^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?OMITTED(?:\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line)){flushDialog(),flushAction(),state==="dialog"&&(state="action",currentSpeaker=null);continue}if(line.startsWith("#")){flushDialog(),flushAction();const dir=line.slice(1).trim();dir&&result.push({type:"direction",speaker:"",text:dir,isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^\[.*\]$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line.slice(1,-1),isDirection:!0});continue}{const m=line.match(/^(?:([A-Z]{0,3}\d{1,4}[A-Z]?)\s+)?((?:INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.|INT\.|EXT\.).*)$/i);if(m){flushDialog(),flushAction();let head=m[2].trim();m[1]&&head.toUpperCase().endsWith(m[1].toUpperCase())&&(head=head.slice(0,head.length-m[1].length).trim()),result.push({type:"scene",speaker:"",text:head.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}}if(/^ACT\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"act",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^SCENE\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)){flushDialog(),flushAction(),result.push({type:"scene",speaker:"",text:line.toUpperCase(),isDirection:!0}),state="action",currentSpeaker=null;continue}if(/^(FADE\s+(IN|OUT|TO)|CUT\s+TO|SMASH\s+CUT|MATCH\s+CUT|DISSOLVE\s+TO|BLACKOUT|LIGHTS\s+(UP|DOWN|FADE)|CURTAIN|INTERMISSION|END\s+OF\s+(PLAY|ACT))[.:]?\s*$/i.test(line)){flushDialog(),flushAction(),result.push({type:"transition",speaker:"",text:line,isDirection:!0}),state="action",currentSpeaker=null;continue}const colonMatch=line.match(/^([\p{Lu}][\p{Lu}0-9 _\-ß]{0,39}):\s+(.+)$/u);if(colonMatch&&colonMatch[1].trim().length<=24&&colonMatch[1].trim().split(/\s+/).length<=3){flushDialog(),flushAction(),currentSpeaker=colonMatch[1].trim(),dialogBuffer=[colonMatch[2].trim()],state="dialog";continue}if(/^\(.*\)$/.test(line)){state==="dialog"&&(flushDialog(),state="character"),result.push({type:"direction",speaker:currentSpeaker||"",text:line,isDirection:!0});continue}const nameRaw=line.replace(/\s*\([^)]*\)\s*$/,"").trim();if(nameRaw.length>=2&&nameRaw.length<=42&&nameRaw===nameRaw.toUpperCase()&&/^[\p{Lu}][\p{Lu}0-9 '.\-ß]+$/u.test(nameRaw)&&!/^\d+$/.test(nameRaw)&&!/\.$/.test(nameRaw)){flushDialog(),flushAction(),currentSpeaker=nameRaw,state="character";continue}if(state==="character"){dialogBuffer=[line],state="dialog";continue}if(state==="dialog"){dialogBuffer.push(line);continue}actionBuffer.push(line),state="action"}return flushDialog(),flushAction(),result}function detectCharacters(lines){const speakers=[...new Set(lines.filter(l=>l.type==="dialog").map(l=>l.speaker))],cast={};return speakers.forEach((sp,i)=>{cast[sp]={voice:"",color:SPEAKER_COLORS[i%SPEAKER_COLORS.length],instruct:"",voiceData:null}}),cast}const SPEAKER_COLORS=["#89b4fa","#a6e3a1","#f38ba8","#fab387","#f9e2af","#cba6f7","#89dceb","#74c7ec"],REH_EMOTIONS=[{value:"",emoji:"\u{1F610}",label:"Neutral"},{value:"happy, cheerful and upbeat",emoji:"\u{1F60A}",label:"Happy"},{value:"sad, melancholy, somber",emoji:"\u{1F622}",label:"Sad"},{value:"angry, forceful, aggressive",emoji:"\u{1F620}",label:"Angry"},{value:"whisper, hushed and intimate",emoji:"\u{1F92B}",label:"Whisper"},{value:"excited, enthusiastic, energetic",emoji:"\u{1F929}",label:"Excited"},{value:"scared, nervous, trembling voice",emoji:"\u{1F628}",label:"Scared"},{value:"sarcastic, dry, ironic delivery",emoji:"\u{1F60F}",label:"Sarcastic"},{value:"dramatic, theatrical, intense",emoji:"\u{1F3AD}",label:"Dramatic"},{value:"gentle, warm, tender",emoji:"\u{1F970}",label:"Gentle"},{value:"confused, uncertain, hesitant",emoji:"\u{1F615}",label:"Confused"},{value:"bored, flat, disinterested",emoji:"\u{1F611}",label:"Bored"},{value:"surprised, shocked, astonished",emoji:"\u{1F632}",label:"Surprised"},{value:"confident, authoritative, bold",emoji:"\u{1F4AA}",label:"Confident"},{value:"mysterious, dark, ominous",emoji:"\u{1F311}",label:"Mysterious"},{value:"romantic, loving, passionate",emoji:"\u2764\uFE0F",label:"Romantic"},{value:"playful, teasing, mischievous",emoji:"\u{1F608}",label:"Playful"},{value:"calm, composed, measured",emoji:"\u{1F9D8}",label:"Calm"},{value:"commanding, authoritative, military",emoji:"\u2694\uFE0F",label:"Commanding"},{value:"grieving, tearful, broken",emoji:"\u{1F62D}",label:"Grieving"}];window.REH_EMOTIONS=REH_EMOTIONS;let rehCustomEmotions=[];try{rehCustomEmotions=JSON.parse(localStorage.getItem("reh-custom-emotions")||"[]")}catch{}function getEmotionInfo(value){if(!value)return{emoji:"",label:"Pick tone"};const found=[...REH_EMOTIONS,...rehCustomEmotions].find(e=>e.value===value);return found?{emoji:found.emoji,label:found.label}:{emoji:"\u2728",label:value.length>14?value.slice(0,13)+"\u2026":value}}function renderMarkdownInline(text){let s=escHtml(text);return s=s.replace(/\*\*([^*\n]+?)\*\*/g,"$1"),s=s.replace(/\*([^*\n]+?)\*/g,"$1"),s=s.replace(/__([^_\n]+?)__/g,"$1"),s=s.replace(/~~([^~\n]+?)~~/g,"$1"),s=s.replace(/==([^=\n]+?)==/g,'$1'),s}function stripMarkdown(text){return text.replace(/\*\*([^*\n]+?)\*\*/g,"$1").replace(/\*([^*\n]+?)\*/g,"$1").replace(/__([^_\n]+?)__/g,"$1").replace(/~~([^~\n]+?)~~/g,"$1").replace(/==([^=\n]+?)==/g,"$1")}const _RehAudioCache=class _RehAudioCache extends Map{get(key){if(!super.has(key))return;const value=super.get(key);return super.delete(key),super.set(key,value),value}set(key,value){for(super.delete(key),super.set(key,value);this.size>_RehAudioCache.MAX_ENTRIES;){let oldest=this.keys().next().value;const current=typeof rehState!="undefined"?rehState.lineIndex:null;if(oldest===current&&this.size>1){const iterator=this.keys();iterator.next(),oldest=iterator.next().value}super.delete(oldest)}return this}};__publicField(_RehAudioCache,"MAX_ENTRIES",128);let RehAudioCache=_RehAudioCache;const rehState={lines:[],cast:{},lineIndex:0,clips:[],voices:[],backend:"",playing:!1,repeat:!1,savedId:null,synthCache:new RehAudioCache,staleLines:new Set,synthCancelled:!1,synthRunning:!1,skipDescriptions:!1,narratorVoice:"",practiceStart:null,practiceEnd:null,bulkMode:!1,bulkSel:new Set,bulkAnchor:null,showHidden:!1,recStream:null,recAudioCtx:null,recAnalyser:null,recSourceNode:null,recGainNode:null,recDestStream:null,recMeterRaf:null,recWaveRing:null,mediaRec:null,recChunks:[],recTimer:null,recSecs:0,lastRecBlob:null};window.rehState=rehState;async function rehDbGetAll(){const r=await fetch("/api/rehearsals");if(!r.ok)throw new Error("rehDbGetAll failed: "+r.status);return(await r.json()).rehearsals||[]}async function rehDbGetById(id){const r=await fetch("/api/rehearsals/"+id);if(r.status!==404){if(!r.ok)throw new Error("rehDbGetById failed: "+r.status);return r.json()}}async function rehDbAdd(record){const r=await fetch("/api/rehearsals",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbAdd failed: "+r.status);return(await r.json()).id}async function rehDbPut(record){if(!record.id)return rehDbAdd(record);const r=await fetch("/api/rehearsals/"+record.id,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(_rehSanitize(record))});if(!r.ok)throw new Error("rehDbPut failed: "+r.status)}async function rehDbDelete(id){const r=await fetch("/api/rehearsals/"+id,{method:"DELETE"});if(!r.ok)throw new Error("rehDbDelete failed: "+r.status)}function _rehSanitize(rec){const out={...rec};return out.clips&&(out.clips=(out.clips||[]).map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type}))),out}(async function(){try{if((await rehDbGetAll()).length>0)return;const idbRecs=await _rehIdbGetAll().catch(()=>[]);if(!idbRecs.length)return;const r=await fetch("/api/rehearsals/migrate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(idbRecs.map(_rehSanitize))});if(r.ok){const d=await r.json();console.log(`[rehearser] migrated ${d.imported} records from IndexedDB \u2192 SQLite`)}}catch(e){console.warn("[rehearser] migration skipped:",e)}})();function _rehIdbGetAll(){return new Promise(resolve=>{const req=indexedDB.open("reh-library",1);req.onerror=()=>resolve([]),req.onsuccess=e=>{const db=e.target.result;if(!db.objectStoreNames.contains("rehearsals")){db.close(),resolve([]);return}const all=db.transaction("rehearsals","readonly").objectStore("rehearsals").getAll();all.onsuccess=ev=>{db.close(),resolve(ev.target.result||[])},all.onerror=()=>{db.close(),resolve([])}}})}window.rehDbGetById=rehDbGetById,window.rehLoadRecord=loadRecord;async function clipsToJson(clips){return Promise.all(clips.map(async c=>{if(!c.blob)return{lineIndex:c.lineIndex,speaker:c.speaker,type:c.type};const ab=await c.blob.arrayBuffer(),u8=new Uint8Array(ab);let bin="";const CHUNK=8192;for(let i=0;i{if(!c.b64)return c;const bin=atob(c.b64),u8=new Uint8Array(bin.length);for(let i=0;i{cast[sp]={voice:c.voice,color:c.color,instruct:c.instruct||"",lang:c.lang||"",gender:c.gender||"",tags:c.tags||"",soul:c.soul||"",ignored:!!c.ignored,hidden:!!c.hidden}});const emotions={},notes={},ignored={},hidden={};return rehState.lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions[i]=l.emotion),l.type==="dialog"&&l.note&&(notes[i]=l.note),l.ignored&&(ignored[i]=!0),l.hidden&&(hidden[i]=!0)}),{title:((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||((_b2=$("reh-page-title"))==null?void 0:_b2.textContent)||"Untitled",script:rehState.lines.length?linesToScriptText():((_c2=$("reh-script-text"))==null?void 0:_c2.value.trim())||"",cast,emotions,notes,ignored,hidden,backend:rehState.backend,narratorVoice:rehState.narratorVoice,lineIndex:rehState.lineIndex,clips:rehState.clips.map(c=>({lineIndex:c.lineIndex,speaker:c.speaker,type:c.type,blob:c.blob||null})),updated:new Date}}function linesToScriptText(){return rehState.lines.map(line=>{switch(line.type){case"act":case"transition":return` `+line.text+` `;case"scene":return` `+line.text+` @@ -1076,7 +1076,7 @@ This warms each voice so the engine caches its .pt and first playback is instant
${renderMarkdownInline(line.text)}
${noteArea} -
`}default:return""}}).join(""),linesEl.querySelectorAll(".reh-emo-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),openEmoPicker(parseInt(btn.dataset.index),btn)})}),linesEl.querySelectorAll(".reh-edit-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),startInlineEdit(parseInt(btn.dataset.index))})}),linesEl.querySelectorAll(".reh-block-dialog").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-action-text").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-line-play-avatar").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);if(rehState.playing&&rehState.lineIndex===idx){pausePlay();return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()})}),linesEl.querySelectorAll(".reh-gutter-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);e.shiftKey?rehState.practiceEnd=rehState.practiceEnd===idx?null:idx:(stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()),updatePracticeRange()})}),linesEl.querySelectorAll("[data-index]").forEach(el=>{el.addEventListener("click",e=>{if(e.target.closest(".reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root"))return;const idx=parseInt(el.dataset.index);if(rehState.bulkMode){_toggleBulkSel(idx,e.shiftKey);return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine()})}),linesEl.querySelectorAll(".reh-resynth-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),synthOneLine(parseInt(btn.id.replace("reh-rsb-","")))})}),linesEl.querySelectorAll(".reh-note-area").forEach(area=>{const idx=parseInt(area.dataset.index),btn=area.querySelector(".reh-note-btn"),ta=area.querySelector(".reh-note-ta");!btn||!ta||(ta.value.trim()&&ta.classList.add("open"),btn.addEventListener("click",e=>{e.stopPropagation(),ta.classList.toggle("open"),ta.classList.contains("open")&&ta.focus()}),ta.addEventListener("input",()=>{rehState.lines[idx].note=ta.value,btn.classList.toggle("has-note",!!ta.value.trim())}),ta.addEventListener("click",e=>e.stopPropagation()))}),linesEl.classList.toggle("reh-bulk-mode",rehState.bulkMode),(_b2=document.querySelector(".reh-page-wrap"))==null||_b2.classList.toggle("reh-bulk-mode",rehState.bulkMode),rehState.bulkMode&&linesEl.querySelectorAll(".reh-bulk-check").forEach(cb=>{cb.addEventListener("click",e=>{e.stopPropagation(),_toggleBulkSel(parseInt(cb.dataset.bulk),e.shiftKey)})}),applyPageMode(),_checkToneStyleSupport())}function _toggleBulkSel(i,range=!1){if(Number.isFinite(i)){if(range&&rehState.bulkAnchor!==null&&Number.isFinite(rehState.bulkAnchor)){const a=Math.min(rehState.bulkAnchor,i),b=Math.max(rehState.bulkAnchor,i),indices=[];for(let n=a;n<=b;n++)rehState.lines[n]&&!rehState.lines[n].hidden&&indices.push(n);const deselect=indices.length&&indices.every(n=>rehState.bulkSel.has(n));indices.forEach(n=>{deselect?rehState.bulkSel.delete(n):rehState.bulkSel.add(n),_refreshBulkLine(n)}),rehState.bulkAnchor=i}else rehState.bulkSel.has(i)?rehState.bulkSel.delete(i):rehState.bulkSel.add(i),rehState.bulkAnchor=i,_refreshBulkLine(i);_updateBulkCount()}}function _refreshBulkLine(i){const sel=rehState.bulkSel.has(i);document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb=>{cb.classList.toggle("checked",sel);const icon=cb.querySelector(".mdi");icon&&(icon.className="mdi "+(sel?"mdi-checkbox-marked":"mdi-checkbox-blank-outline"))}),document.querySelectorAll(`[data-index="${i}"]`).forEach(el=>el.classList.toggle("reh-selected",sel))}function _updateBulkCount(){const el=$("reh-bulk-count");el&&(el.textContent=`${rehState.bulkSel.size} selected`)}function setBulkMode(on){rehState.bulkMode=on,on||(rehState.bulkSel.clear(),rehState.bulkAnchor=null);const bar=$("reh-bulk-bar");bar&&(bar.hidden=!on);const btn=$("reh-bulk-toggle");btn&&btn.classList.toggle("active",on),_updateBulkCount(),rehState.lines.length&&buildScriptPage()}function _bulkApply(fn,{keepSelection=!1}={}){if(!rehState.bulkSel.size){toast("No lines selected","error");return}[...rehState.bulkSel].forEach(i=>{const l=rehState.lines[i];l&&fn(l,i)}),keepSelection||rehState.bulkSel.clear(),_updateBulkCount(),buildScriptPage()}function _bulkDelete(){if(!rehState.bulkSel.size){toast("No lines selected","error");return}const victims=[...rehState.bulkSel].sort((a,b)=>b-a);victims.forEach(i=>{rehState.lines.splice(i,1),_reindexLineState(i)}),rehState.bulkSel.clear(),rehState.lineIndex>=rehState.lines.length&&(rehState.lineIndex=Math.max(0,rehState.lines.length-1)),_updateBulkCount(),buildScriptPage(),highlightCurrentLine(),toast(`Deleted ${victims.length} line${victims.length!==1?"s":""}`,"success")}function _reindexLineState(d){const shift=(collection,isMap)=>{const out=isMap?new Map:new Set;for(const entry of collection){const k=isMap?entry[0]:entry;if(k===d)continue;const nk=k>d?k-1:k;isMap?out.set(nk,entry[1]):out.add(nk)}return out};rehState.synthCache=shift(rehState.synthCache,!0),rehState.staleLines=shift(rehState.staleLines,!1);const sel=shift(rehState.bulkSel,!1);rehState.bulkSel.clear(),sel.forEach(v=>rehState.bulkSel.add(v)),rehState.practiceStart!=null&&rehState.practiceStart>d&&rehState.practiceStart--,rehState.practiceEnd!=null&&rehState.practiceEnd>d&&rehState.practiceEnd--,rehState.lineIndex>d&&rehState.lineIndex--}const PAGE_MODES=["auto","scroll","pdf"];let _pageMode=localStorage.getItem("reh-page-mode")||"pdf";function applyPageMode(){const wrap=document.querySelector(".reh-page-wrap"),a4=$("reh-a4-page");wrap&&(_pageMode==="scroll"?(wrap.querySelectorAll(".reh-paper").forEach(p=>{[...p.children].forEach(c=>{c.classList.contains("reh-paper-num")||a4==null||a4.appendChild(c)}),p.remove()}),wrap.classList.remove("paginated"),a4&&(a4.style.display=""),document.querySelectorAll(".reh-block-pagebreak").forEach(el=>el.style.display="none")):paginateScript(_pageMode==="pdf"?{respectBreaks:!0}:{respectBreaks:!1}),_syncPageModeBtn())}function _syncPageModeBtn(){const btn=$("reh-page-mode-btn");if(!btn)return;const icons={auto:"mdi-file-document-outline",scroll:"mdi-format-align-justify",pdf:"mdi-book-open-page-variant"},labels={auto:"A4 pages",scroll:"Scroll",pdf:"PDF pages"},nextMode=PAGE_MODES[(PAGE_MODES.indexOf(_pageMode)+1)%PAGE_MODES.length];btn.innerHTML=` ${labels[nextMode]||labels.auto}`,btn.title=`Currently: ${labels[_pageMode]} \u2014 click to switch to ${labels[nextMode]}`}function cyclePageMode(){const idx=PAGE_MODES.indexOf(_pageMode);_pageMode=PAGE_MODES[(idx+1)%PAGE_MODES.length],localStorage.setItem("reh-page-mode",_pageMode),applyPageMode(),rehState.lines.length&&buildScriptPage()}function paginateScript({respectBreaks=!1}={}){const wrap=document.querySelector(".reh-page-wrap"),linesEl=$("reh-script-lines"),titleEl=$("reh-page-title");if(!wrap||!linesEl)return;const blocks=[...linesEl.children];if(!blocks.length)return;wrap.classList.add("paginated");const a4=$("reh-a4-page");a4&&(a4.style.display="none"),wrap.querySelectorAll(".reh-paper").forEach(p=>p.remove());const PAGE_CONTENT=1027,TITLE_SPACE=64;let pageNum=0,page=null,used=0,isFirstPage=!0;if(respectBreaks){for(const b of blocks)if(b.classList.contains("reh-block-pagebreak")){const i=parseInt(b.dataset.index),l=rehState.lines[i];l&&l.page&&(pageNum=Math.max(0,l.page-(b===blocks[0]?0:1)));break}}const newPage=forcedNum=>{forcedNum!==void 0?pageNum=forcedNum:pageNum++,page=document.createElement("div"),page.className="reh-paper";const num=document.createElement("div");if(num.className="reh-paper-num",num.textContent=pageNum+".",page.appendChild(num),used=0,isFirstPage&&titleEl){const t=titleEl.cloneNode(!0);t.style.display="",page.appendChild(t),used+=TITLE_SPACE}isFirstPage=!1,wrap.appendChild(page)};newPage();for(const block of blocks){if(respectBreaks&&block.classList.contains("reh-block-pagebreak")){const i=parseInt(block.dataset.index),l=rehState.lines[i];newPage(l&&l.page?l.page:void 0);continue}page.appendChild(block);const cs=getComputedStyle(block),h=block.offsetHeight+(parseFloat(cs.marginTop)||0)+(parseFloat(cs.marginBottom)||0);!respectBreaks&&used+h>PAGE_CONTENT&&used>(isFirstPage?TITLE_SPACE:0)&&(newPage(),page.appendChild(block)),used+=h}}function isPracticeRange(idx){const s=rehState.practiceStart,e=rehState.practiceEnd;return s===null?!1:idx>=s&&(e===null||idx<=e)}function startInlineEdit(idx){const line=rehState.lines[idx];if(!line)return;const dialogEl=document.getElementById("reh-diag-"+idx),actionEl=document.querySelector(`[data-index="${idx}"] .reh-action-text`),sceneEl=document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`),targetEl=dialogEl||actionEl||sceneEl;if(!targetEl||targetEl.tagName==="TEXTAREA")return;const orig=line.text,ta=document.createElement("textarea");ta.value=orig,ta.style.cssText="width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;";const toolbar=document.createElement("div");toolbar.className="reh-fmt-toolbar",toolbar.innerHTML=` + `}default:return""}}).join(""),linesEl.querySelectorAll(".reh-emo-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),openEmoPicker(parseInt(btn.dataset.index),btn)})}),linesEl.querySelectorAll(".reh-edit-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),startInlineEdit(parseInt(btn.dataset.index))})}),linesEl.querySelectorAll(".reh-block-dialog").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-action-text").forEach(el=>{el.addEventListener("dblclick",e=>{e.stopPropagation(),startInlineEdit(parseInt(el.closest("[data-index]").dataset.index))})}),linesEl.querySelectorAll(".reh-line-play-avatar").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);if(rehState.playing&&rehState.lineIndex===idx){pausePlay();return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()})}),linesEl.querySelectorAll(".reh-gutter-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation();const idx=parseInt(btn.dataset.index);e.shiftKey?rehState.practiceEnd=rehState.practiceEnd===idx?null:idx:(stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine(),startPlay()),updatePracticeRange()})}),linesEl.querySelectorAll("[data-index]").forEach(el=>{el.addEventListener("click",e=>{if(e.target.closest(".reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root"))return;const idx=parseInt(el.dataset.index);if(rehState.bulkMode){_toggleBulkSel(idx,e.shiftKey);return}stopPlay(),hideRecOverlay(),rehState.lineIndex=idx,highlightCurrentLine()})}),linesEl.querySelectorAll(".reh-resynth-btn").forEach(btn=>{btn.addEventListener("click",e=>{e.stopPropagation(),synthOneLine(parseInt(btn.id.replace("reh-rsb-","")))})}),linesEl.querySelectorAll(".reh-note-area").forEach(area=>{const idx=parseInt(area.dataset.index),btn=area.querySelector(".reh-note-btn"),ta=area.querySelector(".reh-note-ta");!btn||!ta||(ta.value.trim()&&ta.classList.add("open"),btn.addEventListener("click",e=>{e.stopPropagation(),ta.classList.toggle("open"),ta.classList.contains("open")&&ta.focus()}),ta.addEventListener("input",()=>{rehState.lines[idx].note=ta.value,btn.classList.toggle("has-note",!!ta.value.trim())}),ta.addEventListener("click",e=>e.stopPropagation()))}),linesEl.classList.toggle("reh-bulk-mode",rehState.bulkMode),(_b2=document.querySelector(".reh-page-wrap"))==null||_b2.classList.toggle("reh-bulk-mode",rehState.bulkMode),rehState.bulkMode&&linesEl.querySelectorAll(".reh-bulk-check").forEach(cb=>{cb.addEventListener("click",e=>{e.stopPropagation(),_toggleBulkSel(parseInt(cb.dataset.bulk),e.shiftKey)})}),applyPageMode(),_checkToneStyleSupport())}function _toggleBulkSel(i,range=!1){if(Number.isFinite(i)){if(range&&rehState.bulkAnchor!==null&&Number.isFinite(rehState.bulkAnchor)){const a=Math.min(rehState.bulkAnchor,i),b=Math.max(rehState.bulkAnchor,i),indices=[];for(let n=a;n<=b;n++)rehState.lines[n]&&!rehState.lines[n].hidden&&indices.push(n);const deselect=indices.length&&indices.every(n=>rehState.bulkSel.has(n));indices.forEach(n=>{deselect?rehState.bulkSel.delete(n):rehState.bulkSel.add(n),_refreshBulkLine(n)}),rehState.bulkAnchor=i}else rehState.bulkSel.has(i)?rehState.bulkSel.delete(i):rehState.bulkSel.add(i),rehState.bulkAnchor=i,_refreshBulkLine(i);_updateBulkCount()}}function _refreshBulkLine(i){const sel=rehState.bulkSel.has(i);document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb=>{cb.classList.toggle("checked",sel);const icon=cb.querySelector(".mdi");icon&&(icon.className="mdi "+(sel?"mdi-checkbox-marked":"mdi-checkbox-blank-outline"))}),document.querySelectorAll(`[data-index="${i}"]`).forEach(el=>el.classList.toggle("reh-selected",sel))}function _updateBulkCount(){const el=$("reh-bulk-count");el&&(el.textContent=`${rehState.bulkSel.size} selected`)}function setBulkMode(on){rehState.bulkMode=on,on||(rehState.bulkSel.clear(),rehState.bulkAnchor=null);const bar=$("reh-bulk-bar");bar&&(bar.hidden=!on);const btn=$("reh-bulk-toggle");btn&&btn.classList.toggle("active",on),_updateBulkCount(),rehState.lines.length&&buildScriptPage()}function _bulkApply(fn,{keepSelection=!1}={}){if(!rehState.bulkSel.size){toast("No lines selected","error");return}[...rehState.bulkSel].forEach(i=>{const l=rehState.lines[i];l&&fn(l,i)}),keepSelection||rehState.bulkSel.clear(),_updateBulkCount(),buildScriptPage()}function _bulkDelete(){if(!rehState.bulkSel.size){toast("No lines selected","error");return}const victims=[...rehState.bulkSel].sort((a,b)=>b-a);victims.forEach(i=>{rehState.lines.splice(i,1),_reindexLineState(i)}),rehState.bulkSel.clear(),rehState.lineIndex>=rehState.lines.length&&(rehState.lineIndex=Math.max(0,rehState.lines.length-1)),_updateBulkCount(),buildScriptPage(),highlightCurrentLine(),toast(`Deleted ${victims.length} line${victims.length!==1?"s":""}`,"success")}function _reindexLineState(d){const shift=(collection,isMap)=>{const out=isMap?new Map:new Set;for(const entry of collection){const k=isMap?entry[0]:entry;if(k===d)continue;const nk=k>d?k-1:k;isMap?out.set(nk,entry[1]):out.add(nk)}return out};rehState.synthCache=new RehAudioCache(shift(rehState.synthCache,!0)),rehState.staleLines=shift(rehState.staleLines,!1);const sel=shift(rehState.bulkSel,!1);rehState.bulkSel.clear(),sel.forEach(v=>rehState.bulkSel.add(v)),rehState.practiceStart!=null&&rehState.practiceStart>d&&rehState.practiceStart--,rehState.practiceEnd!=null&&rehState.practiceEnd>d&&rehState.practiceEnd--,rehState.lineIndex>d&&rehState.lineIndex--}const PAGE_MODES=["auto","scroll","pdf"];let _pageMode=localStorage.getItem("reh-page-mode")||"pdf";function applyPageMode(){const wrap=document.querySelector(".reh-page-wrap"),a4=$("reh-a4-page");wrap&&(_pageMode==="scroll"?(wrap.querySelectorAll(".reh-paper").forEach(p=>{[...p.children].forEach(c=>{c.classList.contains("reh-paper-num")||a4==null||a4.appendChild(c)}),p.remove()}),wrap.classList.remove("paginated"),a4&&(a4.style.display=""),document.querySelectorAll(".reh-block-pagebreak").forEach(el=>el.style.display="none")):paginateScript(_pageMode==="pdf"?{respectBreaks:!0}:{respectBreaks:!1}),_syncPageModeBtn())}function _syncPageModeBtn(){const btn=$("reh-page-mode-btn");if(!btn)return;const icons={auto:"mdi-file-document-outline",scroll:"mdi-format-align-justify",pdf:"mdi-book-open-page-variant"},labels={auto:"A4 pages",scroll:"Scroll",pdf:"PDF pages"},nextMode=PAGE_MODES[(PAGE_MODES.indexOf(_pageMode)+1)%PAGE_MODES.length];btn.innerHTML=` ${labels[nextMode]||labels.auto}`,btn.title=`Currently: ${labels[_pageMode]} \u2014 click to switch to ${labels[nextMode]}`}function cyclePageMode(){const idx=PAGE_MODES.indexOf(_pageMode);_pageMode=PAGE_MODES[(idx+1)%PAGE_MODES.length],localStorage.setItem("reh-page-mode",_pageMode),applyPageMode(),rehState.lines.length&&buildScriptPage()}function paginateScript({respectBreaks=!1}={}){const wrap=document.querySelector(".reh-page-wrap"),linesEl=$("reh-script-lines"),titleEl=$("reh-page-title");if(!wrap||!linesEl)return;const blocks=[...linesEl.children];if(!blocks.length)return;wrap.classList.add("paginated");const a4=$("reh-a4-page");a4&&(a4.style.display="none"),wrap.querySelectorAll(".reh-paper").forEach(p=>p.remove());const PAGE_CONTENT=1027,TITLE_SPACE=64;let pageNum=0,page=null,used=0,isFirstPage=!0;if(respectBreaks){for(const b of blocks)if(b.classList.contains("reh-block-pagebreak")){const i=parseInt(b.dataset.index),l=rehState.lines[i];l&&l.page&&(pageNum=Math.max(0,l.page-(b===blocks[0]?0:1)));break}}const newPage=forcedNum=>{forcedNum!==void 0?pageNum=forcedNum:pageNum++,page=document.createElement("div"),page.className="reh-paper";const num=document.createElement("div");if(num.className="reh-paper-num",num.textContent=pageNum+".",page.appendChild(num),used=0,isFirstPage&&titleEl){const t=titleEl.cloneNode(!0);t.style.display="",page.appendChild(t),used+=TITLE_SPACE}isFirstPage=!1,wrap.appendChild(page)};newPage();for(const block of blocks){if(respectBreaks&&block.classList.contains("reh-block-pagebreak")){const i=parseInt(block.dataset.index),l=rehState.lines[i];newPage(l&&l.page?l.page:void 0);continue}page.appendChild(block);const cs=getComputedStyle(block),h=block.offsetHeight+(parseFloat(cs.marginTop)||0)+(parseFloat(cs.marginBottom)||0);!respectBreaks&&used+h>PAGE_CONTENT&&used>(isFirstPage?TITLE_SPACE:0)&&(newPage(),page.appendChild(block)),used+=h}}function isPracticeRange(idx){const s=rehState.practiceStart,e=rehState.practiceEnd;return s===null?!1:idx>=s&&(e===null||idx<=e)}function startInlineEdit(idx){const line=rehState.lines[idx];if(!line)return;const dialogEl=document.getElementById("reh-diag-"+idx),actionEl=document.querySelector(`[data-index="${idx}"] .reh-action-text`),sceneEl=document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`),targetEl=dialogEl||actionEl||sceneEl;if(!targetEl||targetEl.tagName==="TEXTAREA")return;const orig=line.text,ta=document.createElement("textarea");ta.value=orig,ta.style.cssText="width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;";const toolbar=document.createElement("div");toolbar.className="reh-fmt-toolbar",toolbar.innerHTML=` diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 9edd7bc..268c425 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -66,6 +66,31 @@ function stripMarkdown(text) { } // ── State ───────────────────────────────────────────────────────────────── +class RehAudioCache extends Map { + static MAX_ENTRIES = 128; + get(key) { + if (!super.has(key)) return undefined; + const value = super.get(key); + super.delete(key); // Map insertion order is the LRU order. + super.set(key, value); + return value; + } + set(key, value) { + super.delete(key); + super.set(key, value); + while (this.size > RehAudioCache.MAX_ENTRIES) { + let oldest = this.keys().next().value; + const current = typeof rehState !== 'undefined' ? rehState.lineIndex : null; + if (oldest === current && this.size > 1) { + const iterator = this.keys(); + iterator.next(); + oldest = iterator.next().value; + } + super.delete(oldest); + } + return this; + } +} const rehState = { lines: [], cast: {}, @@ -76,7 +101,7 @@ const rehState = { playing: false, repeat: false, savedId: null, - synthCache: new Map(), + synthCache: new RehAudioCache(), staleLines: new Set(), // lines that were cached but had their tone changed synthCancelled: false, synthRunning: false, @@ -2977,7 +3002,7 @@ function _reindexLineState(d) { } return out; }; - rehState.synthCache = shift(rehState.synthCache, true); + rehState.synthCache = new RehAudioCache(shift(rehState.synthCache, true)); rehState.staleLines = shift(rehState.staleLines, false); const sel = shift(rehState.bulkSel, false); rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v)); diff --git a/tests/constrained.spec.js b/tests/constrained.spec.js index 6c581cd..3f407a7 100644 --- a/tests/constrained.spec.js +++ b/tests/constrained.spec.js @@ -85,3 +85,15 @@ test('voice library cache reuses fresh data and honors explicit refresh', async }); expect(result).toBe(2); }); + +test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 }); + const size = await page.evaluate(() => { + const cache = window.rehState.synthCache; + for (let index = 0; index < 180; index += 1) cache.set(index, new Blob(['audio-' + index])); + cache.get(179); + return cache.size; + }); + expect(size).toBeLessThanOrEqual(128); +});