zebra-spaces: persist transcribe state in localStorage + fix self-capture audioCtx

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.
This commit is contained in:
Russell Ballestrini 2026-06-05 13:25:45 -04:00
parent e23808d410
commit cee13589d7
No known key found for this signature in database

View file

@ -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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-05</span><br>
md5 <span class="stamp-md5">6f6e30026fe83b275be830b2e135d1aa</span><br>
sha256 <span class="stamp-sha">ab24f0b7071a56ed9b14fa3b096f1f4d2345847ffeb5d8ac5e2d64dd45d38e9e</span><br>
md5 <span class="stamp-md5">f21110e1e23d66443bea1dc35f0f5fdc</span><br>
sha256 <span class="stamp-sha">08d96f30022dda20daa30401ceda2912abc2f3889a28e7a8b52de4bfcc3684e3</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>