diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index efc8bbe..1c0e4af 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -1627,6 +1627,24 @@ function renderIdentity(){
$('btn-vault').addEventListener('click', () => $('vault-panel').classList.toggle('hidden'));
$('btn-transcribe').addEventListener('click', () => toggleTranscribe().catch(e => logLine('err','transcribe toggle: '+e.message)));
+/* If transcribe was on at the previous session, restore the state
+ * now (UI label, transcript-log visibility, worker pre-warm).
+ * Captures attach lazily as remote speakers arrive and self capture
+ * fires when micStream is available — both paths already check the
+ * transcribeEnabled flag. */
+if (transcribeEnabled){
+ /* call setTranscribe with the SAME value but force-apply UI + spin
+ * the worker. The function's "same-state" branch refreshes UI and
+ * returns without flipping the flag. */
+ applyTranscribeUI();
+ ensureWhisperWorker();
+ /* If captures need to be installed (e.g. listenerAudioNodes
+ * populated before this code ran), kick them off. */
+ for (const [uuid] of listenerAudioNodes){
+ startCaptureForUuid(uuid).catch(()=>{});
+ }
+ startSelfCapture().catch(()=>{});
+}
/* log out — destructive: wipes Ed25519 + handle from localStorage and
* generates a fresh identity. The booted/blocked window keys off the
@@ -2199,7 +2217,13 @@ function transcribeViaWorker(chunk){
});
}
+/* Transcribe state persists in localStorage so refresh restores the
+ * user's preference. Model is cached after first download so subsequent
+ * loads spin the worker up fast. Fox 2026-06-05: "transcribe states
+ * should be saved in localstorage to survive on refresh." */
+const TRANSCRIBE_KEY = 'zspc:transcribe-on';
let transcribeEnabled = false;
+try { if (localStorage.getItem(TRANSCRIBE_KEY) === '1') transcribeEnabled = true; } catch(_){}
/* whisper-tiny.en hallucinations on silence / low-energy chunks. The
* RMS gate in the capture worklet catches dead silence; this set
* catches what gets past it — quiet-room ambient with the same
@@ -2312,7 +2336,19 @@ async function handleWhisperChunk(uuid, chunk){
* the user's handle. */
let _selfCapture = null;
async function startSelfCapture(){
- if (_selfCapture || !audioCtx || !micStream) return;
+ if (_selfCapture || !micStream) return;
+ /* audioCtx may not exist yet for a host alone in the room (no
+ * remote speakers have triggered attachAudioStreamViaWorklet). Make
+ * sure it's up before we try to attach a worklet to it. Resume it
+ * too — first-attach on a browser may leave it suspended until a
+ * user gesture has touched it. */
+ if (!audioCtx){
+ try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
+ catch(e){ logLine('err','self capture audioCtx: '+e.message); return; }
+ }
+ if (audioCtx.state === 'suspended'){
+ try { audioCtx.resume(); } catch(_){}
+ }
const ok = await loadWhisperCaptureWorklet(audioCtx);
if (!ok) return;
try {
@@ -2368,13 +2404,15 @@ function stopCaptureForUuid(uuid){
node.capture = null;
}
-async function toggleTranscribe(){
- transcribeEnabled = !transcribeEnabled;
- const btn = document.getElementById('btn-transcribe');
- const sec = document.getElementById('sec-transcript');
- if (btn) btn.classList.toggle('on', transcribeEnabled);
- if (btn) btn.textContent = transcribeEnabled ? 'transcribe (on)' : 'transcribe (off)';
- if (sec) sec.classList.toggle('hidden', !transcribeEnabled);
+async function setTranscribe(want){
+ if (transcribeEnabled === want) {
+ /* still refresh UI in case it's a load-time restore */
+ applyTranscribeUI();
+ return;
+ }
+ transcribeEnabled = want;
+ try { localStorage.setItem(TRANSCRIBE_KEY, want ? '1' : '0'); } catch(_){}
+ applyTranscribeUI();
if (transcribeEnabled){
/* pre-warm the worker so the first chunk doesn't wait on model load. */
ensureWhisperWorker();
@@ -2384,14 +2422,25 @@ async function toggleTranscribe(){
}
/* Self (local mic). If the user is a listener with no mic yet,
* this is a no-op — startSelfCapture early-returns. When the
- * user gets promoted and gets a mic, that path can also start
- * self capture (see getMic / applyMicMode). */
+ * user gets promoted and gets a mic, getMic re-fires this path. */
startSelfCapture().catch(e => logLine('err', 'self transcribe start: '+e.message));
} else {
for (const [uuid] of listenerAudioNodes) stopCaptureForUuid(uuid);
stopSelfCapture();
}
}
+function applyTranscribeUI(){
+ const btn = document.getElementById('btn-transcribe');
+ const sec = document.getElementById('sec-transcript');
+ if (btn){
+ btn.classList.toggle('on', transcribeEnabled);
+ btn.textContent = transcribeEnabled ? 'transcribe (on)' : 'transcribe (off)';
+ }
+ if (sec) sec.classList.toggle('hidden', !transcribeEnabled);
+}
+async function toggleTranscribe(){
+ return setTranscribe(!transcribeEnabled);
+}
function installJitterBuffer(uuid, node){
if (!node || node.jbuf || !workletReady) return;
const target = node.targetSeconds || RECV_PLAYOUT_DELAY_SEC;
@@ -7364,8 +7413,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');