zebra-spaces: fix lip-sync flap (median + stale-source filter) + transcript autoscroll
Fox 2026-06-05: "the lips are not synced with video for listener" and "when i am at the bottom and new transcriptions flow in, keep scrolling as they arrive unless ive scrolled up." Lip-sync flap diagnosis (per fxhp-phone telemetry): lip-sync pub=25cc delay=2.87s lip-sync pub=25cc delay=6.87s lip-sync pub=25cc delay=2.87s (every ~683ms) lip-sync pub=25cc delay=6.87s Two values exactly 4s apart (4s = worklet target). One source is LIVE (worklet buffer near target → 6.87s = 2.87 native + 4 worklet), the other is a STALE worklet whose source died (buffer drained to 0 → 2.87s = 2.87 native + 0 worklet). Both post bufferedSeconds every 683ms. With LIP_SYNC_HISTORY = 5 odd-length, alternating samples produce alternating medians: [A,B,A,B,A] → A; [B,A,B,A,B] → B. Video target whipsaws ±4s per tick. Three fixes: 1. LIP_SYNC_HISTORY = 10 (was 5). Even-length window. 2. medianOf returns the AVERAGE of the two middle values for even-length arrays. Alternating samples now produce a stable median = (A+B)/2 — at least the value doesn't whip. 3. Stale-source filter in refreshLipSyncForUuid: if node.bufferedSeconds < 0.5 AND nativeJbufSec > 1.0, skip this refresh entirely. A worklet whose buffer is drained while the native audio receiver still reports a healthy jitter buffer is from an OLD subscription that's no longer carrying audio. Its "0" reading would pull the median toward the dead value. Only the LIVE worklet's refreshes update the history; the median stabilizes on the actual audio total delay. Autoscroll fix in appendTranscriptLine: The previous logic computed "near bottom" AFTER appending the line. scrollHeight grew the instant the line was in the DOM, so (scrollTop + clientHeight) became < (scrollHeight - 40) for every append — autoscroll never fired. Now we check BEFORE appending and widen the tolerance to 120px (covers touch-scroll inertia residue).
This commit is contained in:
parent
1c52e09b37
commit
c493626b69
1 changed files with 29 additions and 8 deletions
|
|
@ -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');
|
|||
|
||||
<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> · built <span class="stamp-date">2026-06-05</span><br>
|
||||
md5 <span class="stamp-md5">e2a9c5eb310147cb147f54dc2495f3f6</span><br>
|
||||
sha256 <span class="stamp-sha">076444a5567d418cb48557ee1b1e0c88539534a1532df4d6f6be98a7e40b0343</span><br>
|
||||
md5 <span class="stamp-md5">90ea35ac08110f785509c523db5c720a</span><br>
|
||||
sha256 <span class="stamp-sha">73faa630db78a68abfaf5b70e122b7f9c408bf5f69ccb8086025c28540decfc9</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue