From cee13589d7b348ae67bb5e940f7d197592e871f7 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 5 Jun 2026 13:25:45 -0400 Subject: [PATCH] zebra-spaces: persist transcribe state in localStorage + fix self-capture audioCtx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-05: "transcribe states should be saved in localstorage to survive on refresh" and "self transcribe does not seem to work." Two fixes: 1. localStorage persistence: - TRANSCRIBE_KEY = 'zspc:transcribe-on' - Loaded into transcribeEnabled at script top - setTranscribe(want) replaces the old toggleTranscribe body and writes localStorage on every state change - applyTranscribeUI() reusable for both flip and refresh-restore - At click-handler binding time, if transcribeEnabled was already true (restored from storage), apply UI + pre-warm worker + kick captures for any speakers already attached 2. Self-capture fixed: - startSelfCapture used to early-return if audioCtx was null - But audioCtx is only created when the FIRST remote speaker's audio attaches via attachAudioStreamViaWorklet. A host alone in the room (no remote audio yet) had no audioCtx → self capture silently failed. - Now startSelfCapture creates audioCtx if absent and resumes if suspended, same pattern as the remote-attach path. Host-alone captures their own voice immediately. --- web/zebra-spaces.html | 73 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 12 deletions(-) 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');