diff --git a/CHANGELOG.md b/CHANGELOG.md
index d211c59..1d3d207 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Fixed
- Async routes no longer block the event loop on synchronous HTTP: seed proxy/samples, text refine/persona rewrite, voice matching (its retry back-off is now `asyncio.sleep`), character deep analysis, character image-from-URL, LLM/engine model listing, source audio import/proxy and ElevenLabs shared voices now run `requests` through `asyncio.to_thread` (same timeouts and responses).
+- Silently swallowed errors now surface: failed saves of the audiobook prompt default, generated character sheets and character colours show an error toast, and ~20 other hidden failures (rehearsal/cast write-back, reader progress and audio fetches, seed-finder DB writes, settings/model fetches, export server save) are logged with `logErr` instead of being dropped.
+
### Changed
- Conversation Playground: the Language Model base URL is now picked from a dropdown of the language models configured under Engines (Docker LLM containers incl. saved URL overrides, and custom LLM cards; running/stopped marked), with a "Custom…" option that reveals the free-text URL. Selecting an entry saves `conv_llm_url` and fetches the models.
- Playwright specs now import `test` from `tests/_guard.js`, whose auto fixture aborts and fails any unmocked non-GET request to `/api/**` so a spec can no longer modify live settings. See `architecture/qa.md`.
diff --git a/static/dist/main.min.js b/static/dist/main.min.js
index 3f11399..959f056 100644
--- a/static/dist/main.min.js
+++ b/static/dist/main.min.js
@@ -90,7 +90,7 @@
-
`,body.appendChild(panel);const q=sel=>panel.querySelector(sel),status2=q(".insp-persona-status"),say=(msg,err)=>{status2.textContent=msg,status2.style.color=err?"var(--danger, #dc2626)":""};q(".insp-prov-save").addEventListener("click",async()=>{try{const r=await fetch(`/api/voices/${encodeURIComponent(voiceId)}/provenance`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({basis:q(`#${id}-basis`).value,source:q(`#${id}-source`).value,licence:q(`#${id}-licence`).value,note:q(`#${id}-note`).value})}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);v.provenance=d.provenance,typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),say("Saved.")}catch(e){say("Save failed: "+e.message,!0)}});const btn=q(".insp-prov-rec-btn");let rec=null;btn.addEventListener("click",async()=>{if(rec){rec.stop();return}if(!navigator.mediaDevices||!window.MediaRecorder){say("Recording is not supported in this browser. Type the consent statement in the note field instead.",!0);return}let stream;try{stream=await navigator.mediaDevices.getUserMedia({audio:!0})}catch(e){say(e&&e.name==="NotAllowedError"?"Microphone permission was denied. Allow it in the browser, or type the consent statement in the note field.":"No usable microphone found. Type the consent statement in the note field instead.",!0);return}const chunks=[];rec=new MediaRecorder(stream),rec.ondataavailable=ev=>{ev.data.size&&chunks.push(ev.data)},rec.onstop=async()=>{stream.getTracks().forEach(t=>t.stop()),rec=null,btn.textContent="Record spoken consent",btn.setAttribute("aria-pressed","false");try{const fd=new FormData;fd.append("file",new Blob(chunks,{type:"audio/webm"}),"consent.webm");const r=await fetch(`/api/voices/${encodeURIComponent(voiceId)}/consent-recording`,{method:"POST",body:fd}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);v.provenance=d.provenance;const a=q(".insp-prov-audio");a.src=`/api/voices/${encodeURIComponent(voiceId)}/consent-recording?t=${Date.now()}`,a.hidden=!1,say("Consent recording saved.")}catch(e){say("Upload failed: "+e.message,!0)}},rec.start(),btn.textContent="Stop recording",btn.setAttribute("aria-pressed","true"),say("Recording... say who you are, that you consent to this voice being cloned, and for what use.")})}root.selectVoice=selectVoice2,root.TTVC=root.TTVC||{},root.TTVC.voiceInspector=root.TTVC.voiceInspector||{},Object.assign(root.TTVC.voiceInspector,{select:selectVoice2})}(window),function(root){var _a;const SEED_FINDER_TEXT_DE="Die dreitausendf\xFCnfhundertsiebenundsechzig neuen Ger\xE4te f\xFCr das Update ben\xF6tigten eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Wartung, um die Leistung bei gro\xDFer Last zu gew\xE4hrleisten. Notiere dir den einundzwanzigsten Juni um vierzehn Uhr. Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Tr\xE4ume beginnen mit einem positiven Mindset.",SEED_FINDER_TEXT_EN="The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.",SEED_FINDER_TEXT_TORTURE="Die 3.567 neuen High-End Ger\xE4te f\xFCr das Server-Update ben\xF6tigen eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Ma\xDFnahmen, um die Performance bei gro\xDFer Last zu gew\xE4hrleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Sch\xF6ne Gr\xFC\xDFe! \u2014 Madonna - Tr\xE4ume beginnt mit einem positiven Mindset.",_sfT=s=>typeof window.t=="function"?window.t(s):s;function _seedFinderDefaultText(voiceId){if(window._appSettings&&window._appSettings.seed_finder_text)return window._appSettings.seed_finder_text;const v=(window._voices||[]).find(x=>x.id===voiceId);if(v&&v.transcript&&v.transcript.trim())return v.transcript.trim();const lc=(voiceId||"").toLowerCase();return lc.startsWith("de_")?SEED_FINDER_TEXT_DE:lc.startsWith("en_")||lc.startsWith("gb_")?SEED_FINDER_TEXT_EN:SEED_FINDER_TEXT_DE}const SF_DB="seed-finder",SF_STORE="samples",SF_CACHE_VERSION="wav-seed-v2";function _sfHash(s){let h=0;for(let i=0;i>>0;return h.toString(36)}function _sfDbOpen(){return new Promise((res,rej)=>{const r=indexedDB.open(SF_DB,1);r.onupgradeneeded=e=>{const db=e.target.result;db.objectStoreNames.contains(SF_STORE)||db.createObjectStore(SF_STORE,{keyPath:"key"})},r.onsuccess=e=>res(e.target.result),r.onerror=e=>rej(e.target.error)})}async function _sfDbGet(key){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).get(key);r.onsuccess=e=>res(e.target.result||null),r.onerror=e=>rej(e.target.error)})}catch{return null}}async function _sfDbPut(rec){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE).put(rec);r.onsuccess=()=>res(),r.onerror=e=>rej(e.target.error)})}catch{}}async function _sfDbAllForVoice(voiceId){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const out=[],cur=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&out.push(c.value),c.continue()):res(out)},cur.onerror=e=>rej(e.target.error)})}catch{return[]}}async function _sfDbClearVoice(voiceId){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const store=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE),cur=store.openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&store.delete(c.primaryKey),c.continue()):res()},cur.onerror=e=>rej(e.target.error)})}catch{}}function attachSeedFinder2(voiceId,body){const panel=document.createElement("div");panel.className="opt-group seed-finder-group",panel.innerHTML=`
+ `,body.appendChild(panel);const q=sel=>panel.querySelector(sel),status2=q(".insp-persona-status"),say=(msg,err)=>{status2.textContent=msg,status2.style.color=err?"var(--danger, #dc2626)":""};q(".insp-prov-save").addEventListener("click",async()=>{try{const r=await fetch(`/api/voices/${encodeURIComponent(voiceId)}/provenance`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({basis:q(`#${id}-basis`).value,source:q(`#${id}-source`).value,licence:q(`#${id}-licence`).value,note:q(`#${id}-note`).value})}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);v.provenance=d.provenance,typeof window.invalidateVoiceLibraryCache=="function"&&window.invalidateVoiceLibraryCache(),say("Saved.")}catch(e){say("Save failed: "+e.message,!0)}});const btn=q(".insp-prov-rec-btn");let rec=null;btn.addEventListener("click",async()=>{if(rec){rec.stop();return}if(!navigator.mediaDevices||!window.MediaRecorder){say("Recording is not supported in this browser. Type the consent statement in the note field instead.",!0);return}let stream;try{stream=await navigator.mediaDevices.getUserMedia({audio:!0})}catch(e){say(e&&e.name==="NotAllowedError"?"Microphone permission was denied. Allow it in the browser, or type the consent statement in the note field.":"No usable microphone found. Type the consent statement in the note field instead.",!0);return}const chunks=[];rec=new MediaRecorder(stream),rec.ondataavailable=ev=>{ev.data.size&&chunks.push(ev.data)},rec.onstop=async()=>{stream.getTracks().forEach(t=>t.stop()),rec=null,btn.textContent="Record spoken consent",btn.setAttribute("aria-pressed","false");try{const fd=new FormData;fd.append("file",new Blob(chunks,{type:"audio/webm"}),"consent.webm");const r=await fetch(`/api/voices/${encodeURIComponent(voiceId)}/consent-recording`,{method:"POST",body:fd}),d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.detail||r.statusText);v.provenance=d.provenance;const a=q(".insp-prov-audio");a.src=`/api/voices/${encodeURIComponent(voiceId)}/consent-recording?t=${Date.now()}`,a.hidden=!1,say("Consent recording saved.")}catch(e){say("Upload failed: "+e.message,!0)}},rec.start(),btn.textContent="Stop recording",btn.setAttribute("aria-pressed","true"),say("Recording... say who you are, that you consent to this voice being cloned, and for what use.")})}root.selectVoice=selectVoice2,root.TTVC=root.TTVC||{},root.TTVC.voiceInspector=root.TTVC.voiceInspector||{},Object.assign(root.TTVC.voiceInspector,{select:selectVoice2})}(window),function(root){var _a;const SEED_FINDER_TEXT_DE="Die dreitausendf\xFCnfhundertsiebenundsechzig neuen Ger\xE4te f\xFCr das Update ben\xF6tigten eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Wartung, um die Leistung bei gro\xDFer Last zu gew\xE4hrleisten. Notiere dir den einundzwanzigsten Juni um vierzehn Uhr. Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Tr\xE4ume beginnen mit einem positiven Mindset.",SEED_FINDER_TEXT_EN="The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.",SEED_FINDER_TEXT_TORTURE="Die 3.567 neuen High-End Ger\xE4te f\xFCr das Server-Update ben\xF6tigen eine au\xDFergew\xF6hnlich starke K\xFChlung und regelm\xE4\xDFige Ma\xDFnahmen, um die Performance bei gro\xDFer Last zu gew\xE4hrleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelm\xE4\xDFiges zu tun! Sch\xF6ne Gr\xFC\xDFe! \u2014 Madonna - Tr\xE4ume beginnt mit einem positiven Mindset.",_sfT=s=>typeof window.t=="function"?window.t(s):s;function _seedFinderDefaultText(voiceId){if(window._appSettings&&window._appSettings.seed_finder_text)return window._appSettings.seed_finder_text;const v=(window._voices||[]).find(x=>x.id===voiceId);if(v&&v.transcript&&v.transcript.trim())return v.transcript.trim();const lc=(voiceId||"").toLowerCase();return lc.startsWith("de_")?SEED_FINDER_TEXT_DE:lc.startsWith("en_")||lc.startsWith("gb_")?SEED_FINDER_TEXT_EN:SEED_FINDER_TEXT_DE}const SF_DB="seed-finder",SF_STORE="samples",SF_CACHE_VERSION="wav-seed-v2";function _sfHash(s){let h=0;for(let i=0;i>>0;return h.toString(36)}function _sfDbOpen(){return new Promise((res,rej)=>{const r=indexedDB.open(SF_DB,1);r.onupgradeneeded=e=>{const db=e.target.result;db.objectStoreNames.contains(SF_STORE)||db.createObjectStore(SF_STORE,{keyPath:"key"})},r.onsuccess=e=>res(e.target.result),r.onerror=e=>rej(e.target.error)})}async function _sfDbGet(key){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).get(key);r.onsuccess=e=>res(e.target.result||null),r.onerror=e=>rej(e.target.error)})}catch{return null}}async function _sfDbPut(rec){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const r=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE).put(rec);r.onsuccess=()=>res(),r.onerror=e=>rej(e.target.error)})}catch(_){logErr("seed-db-put",_)}}async function _sfDbAllForVoice(voiceId){try{const db=await _sfDbOpen();return await new Promise((res,rej)=>{const out=[],cur=db.transaction(SF_STORE,"readonly").objectStore(SF_STORE).openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&out.push(c.value),c.continue()):res(out)},cur.onerror=e=>rej(e.target.error)})}catch{return[]}}async function _sfDbClearVoice(voiceId){try{const db=await _sfDbOpen();await new Promise((res,rej)=>{const store=db.transaction(SF_STORE,"readwrite").objectStore(SF_STORE),cur=store.openCursor();cur.onsuccess=e=>{const c=e.target.result;c?(c.value.voiceId===voiceId&&store.delete(c.primaryKey),c.continue()):res()},cur.onerror=e=>rej(e.target.error)})}catch(_){logErr("seed-db-delete",_)}}function attachSeedFinder2(voiceId,body){const panel=document.createElement("div");panel.className="opt-group seed-finder-group",panel.innerHTML=`
\u{1F3B2} Seed Finder
@@ -159,7 +159,7 @@
- `;let _cachedBlob=null;row.querySelector(".sfr-play-btn").addEventListener("click",async()=>{if(!_cachedBlob){const r=await fetch(`/api/seed-sample/${encodeURIComponent(voiceId)}/${seed}`);if(!r.ok){typeof toast=="function"&&toast("Failed to load sample","error");return}_cachedBlob=await r.blob()}_playBlob(_cachedBlob)}),row.querySelector(".sfr-use-btn").addEventListener("click",async()=>{try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}),resultsEl.appendChild(row)}}catch{}}async function _saveSeed(seed){const resp=await fetch("/api/tts-voice-seed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice:voiceId,seed})});if(!resp.ok){const e=await resp.json().catch(()=>({}));throw new Error(e.detail||resp.statusText)}const v=(window._voices||[]).find(vv=>vv.id===voiceId);v&&(v.seed=seed);const pinnedSeedEl=document.querySelector(".insp-pinned-seed");pinnedSeedEl&&(seed!=null?(pinnedSeedEl.textContent=`Pin Seed # ${seed}`,pinnedSeedEl.style.display=""):(pinnedSeedEl.style.display="none",pinnedSeedEl.textContent=""))}async function _fetchWithRetry(fn,tries=2,delay=1200){let lastErr;for(let t=0;t<=tries;t++){if(_cancelled)throw new Error("cancelled");try{return await fn()}catch(e){lastErr=e,tsetTimeout(r,delay))}}throw lastErr}function _playBlob(blob){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);const url=URL.createObjectURL(blob),audio=new Audio(url);_currentAudio=audio,audio.play().catch(()=>{}),audio.addEventListener("ended",()=>{URL.revokeObjectURL(url),_currentAudio=null})}const seedRows=new Map,_keyFor=(seed,text,backend)=>`${SF_CACHE_VERSION}|${voiceId}|${backend}|${_sfHash(text)}|${seed}`;async function _useSeed(seed,row){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}function _seedMetricText(data){const parts=[];return data.dur&&parts.push(`audio ${data.dur.toFixed(1)}s${data.clipped?" clipped":""}`),data.genSec&&parts.push(`gen ${data.genSec.toFixed(1)}s`),data.rtf&&parts.push(`${data.rtf.toFixed(2)}x`),data.cached&&parts.push("saved"),data.note&&parts.push(data.note),parts.join(" \xB7 ")}function _renderSeed(seed,data){const row=document.createElement("div");if(row.className="seed-finder-result-row"+(data.err?" seed-finder-result-error":""),row.dataset.seed=seed,data.err)row.innerHTML=`Seed ${seed}
+ `;let _cachedBlob=null;row.querySelector(".sfr-play-btn").addEventListener("click",async()=>{if(!_cachedBlob){const r=await fetch(`/api/seed-sample/${encodeURIComponent(voiceId)}/${seed}`);if(!r.ok){typeof toast=="function"&&toast("Failed to load sample","error");return}_cachedBlob=await r.blob()}_playBlob(_cachedBlob)}),row.querySelector(".sfr-use-btn").addEventListener("click",async()=>{try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}),resultsEl.appendChild(row)}}catch(_){logErr("seed-results-render",_)}}async function _saveSeed(seed){const resp=await fetch("/api/tts-voice-seed",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice:voiceId,seed})});if(!resp.ok){const e=await resp.json().catch(()=>({}));throw new Error(e.detail||resp.statusText)}const v=(window._voices||[]).find(vv=>vv.id===voiceId);v&&(v.seed=seed);const pinnedSeedEl=document.querySelector(".insp-pinned-seed");pinnedSeedEl&&(seed!=null?(pinnedSeedEl.textContent=`Pin Seed # ${seed}`,pinnedSeedEl.style.display=""):(pinnedSeedEl.style.display="none",pinnedSeedEl.textContent=""))}async function _fetchWithRetry(fn,tries=2,delay=1200){let lastErr;for(let t=0;t<=tries;t++){if(_cancelled)throw new Error("cancelled");try{return await fn()}catch(e){lastErr=e,tsetTimeout(r,delay))}}throw lastErr}function _playBlob(blob){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);const url=URL.createObjectURL(blob),audio=new Audio(url);_currentAudio=audio,audio.play().catch(()=>{}),audio.addEventListener("ended",()=>{URL.revokeObjectURL(url),_currentAudio=null})}const seedRows=new Map,_keyFor=(seed,text,backend)=>`${SF_CACHE_VERSION}|${voiceId}|${backend}|${_sfHash(text)}|${seed}`;async function _useSeed(seed,row){_currentAudio&&(_currentAudio.pause(),_currentAudio=null);try{await _saveSeed(seed),resultsEl.querySelectorAll(".sfr-saved").forEach(el=>el.textContent=""),resultsEl.querySelectorAll(".seed-finder-result-row").forEach(r=>r.classList.remove("sfr-active")),row.classList.add("sfr-active"),row.querySelector(".sfr-saved").textContent="\u2713 Saved",pinInput&&(pinInput.value=seed),typeof toast=="function"&&toast(`Seed ${seed} saved for ${voiceId}`,"success")}catch(e){typeof toast=="function"&&toast("Save failed: "+e.message,"error")}}function _seedMetricText(data){const parts=[];return data.dur&&parts.push(`audio ${data.dur.toFixed(1)}s${data.clipped?" clipped":""}`),data.genSec&&parts.push(`gen ${data.genSec.toFixed(1)}s`),data.rtf&&parts.push(`${data.rtf.toFixed(2)}x`),data.cached&&parts.push("saved"),data.note&&parts.push(data.note),parts.join(" \xB7 ")}function _renderSeed(seed,data){const row=document.createElement("div");if(row.className="seed-finder-result-row"+(data.err?" seed-finder-result-error":""),row.dataset.seed=seed,data.err)row.innerHTML=`Seed ${seed}Failed: ${escHtml(String(data.err))}
`,row.querySelector(".sfr-retry-btn").addEventListener("click",()=>_retrySeed(seed));else{const durStr=_seedMetricText(data);row.innerHTML=`Seed ${seed}${escHtml(durStr)}
@@ -476,7 +476,7 @@ Mia:Only if you promise not to spill coffee on my notes again... though I guess
This warms each voice so the engine caches its .pt and first playback is instant.`))return;const btn=$("precompute-embeddings-btn");btn&&(btn.disabled=!0);const ov=document.createElement("div");ov.className="audiobook-overlay",ov.id="precompute-overlay",ov.innerHTML=`