diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 7d2330d..9d23ccb 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -2298,11 +2298,15 @@ function appendTranscriptLine(uuid, text){ line.appendChild(tsEl); line.append(' '); line.appendChild(nameEl); line.append(' '); line.appendChild(txtEl); + /* Check "near bottom" BEFORE appending — scrollHeight grows the + * moment the line is in the DOM, which makes a post-append check + * report "not at bottom" even when the user was. ~120px tolerance + * also covers touch-scroll inertia leaving a small gap. Fox + * 2026-06-05: "when i am at the bottom and new transcriptions + * flow in, keep scrolling as they arrive unless ive scrolled up." */ + const wasAtBottom = (log.scrollTop + log.clientHeight) >= (log.scrollHeight - 120); log.appendChild(line); - /* auto-scroll only if user is already at the bottom (let them scroll - * up to read older transcript without being yanked back). */ - const nearBottom = (log.scrollTop + log.clientHeight) >= (log.scrollHeight - 40); - if (nearBottom) log.scrollTop = log.scrollHeight; + if (wasAtBottom) log.scrollTop = log.scrollHeight; } /* Shared handler — used by both remote-speaker capture (one per uuid @@ -2590,7 +2594,7 @@ function resetListenerBufferReady(){ const lipSync = new Map(); /* pubHex → state */ const sfuAudioReceivers = new Map(); /* pubHex → RTCRtpReceiver (SFU audio) — cached so we can restore as the lip-sync source after mesh fail */ const httpLipSyncOverride = new Map(); /* pubHex → fixed delay (sec). When set, lip-sync uses this directly and skips worklet-derived measurement — for HTTP /stream toggle ON case where the audio source isn't the worklet anymore. */ -const LIP_SYNC_HISTORY = 5; +const LIP_SYNC_HISTORY = 10; const LIP_SYNC_THRESHOLD = 0.05; /* 50ms — below this is within perception noise */ function lipSyncEntry(pubHex){ @@ -2615,7 +2619,13 @@ function registerLipSyncVideo(pubHex, kind, receiver){ function medianOf(arr){ if (arr.length === 0) return 0; const s = arr.slice().sort((a,b) => a-b); - return s[Math.floor(s.length/2)]; + const mid = Math.floor(s.length / 2); + /* For even-length windows return the AVERAGE of the two middle + * values. With odd length and alternating samples [A,B,A,B,A] the + * median is A; next push flips it to B → the lip-sync target + * whipsaws each tick. Even length + average makes the median + * stable at (A+B)/2 even when two sources alternate. */ + return (s.length % 2 === 0) ? (s[mid - 1] + s[mid]) / 2 : s[mid]; } async function refreshLipSyncForUuid(uuid){ /* find which publisher this audio uuid belongs to */ @@ -2646,6 +2656,17 @@ async function refreshLipSyncForUuid(uuid){ const node = listenerAudioNodes.get(uuid); if (!node) return; const workletBufferedSec = node.bufferedSeconds || 0; + /* Stale-source filter. If the worklet's buffer has drained to + * essentially zero AND we know the publisher's actual native + * receiver still has audio coming in (e.notable audioReceiver + * jbuf > 1s), this worklet is from an OLD subscription that no + * longer carries audio — refresh-with-zero would pollute the + * lipSync history and the median would alternate between this + * dead value and the live worklet's value. Skip. The live + * worklet's refresh continues to drive the lip-sync target. */ + if (workletBufferedSec < 0.5 && e.nativeJbufSec > 1.0){ + return; + } /* refresh native jbuf via getStats; this is a slow path but we * only do it on the ~683ms cadence the worklet posts, and getStats * is cheap (~1-2ms on Firefox Android). */ @@ -7456,8 +7477,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');