diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 7d2330d..9d23ccb 100644
--- a/web/zebra-spaces.html
+++ b/web/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');