zebra-spaces: deploy 4s speaker buffer + HTTP lip-sync override
This commit is contained in:
parent
01abc88446
commit
52866ed779
1 changed files with 86 additions and 14 deletions
|
|
@ -1976,6 +1976,7 @@ function resetListenerBufferReady(){
|
|||
* last applied value. */
|
||||
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_THRESHOLD = 0.05; /* 50ms — below this is within perception noise */
|
||||
|
||||
|
|
@ -2009,7 +2010,26 @@ async function refreshLipSyncForUuid(uuid){
|
|||
for (const [ph, st] of lipSync){
|
||||
if (st.audioUuid === uuid){ pubHex = ph; e = st; break; }
|
||||
}
|
||||
if (!e || !e.audioReceiver || e.videoReceivers.size === 0) return;
|
||||
if (!e || e.videoReceivers.size === 0) return;
|
||||
/* HTTP /stream override path: when the per-speaker stream toggle is
|
||||
* ON for this publisher, the audio is coming from an <audio>
|
||||
* element with its own deep buffer instead of the worklet. We don't
|
||||
* have a clean way to read that element's effective delay, so we
|
||||
* use a fixed HTTP_STREAM_DELAY_SEC estimate (set by startStream).
|
||||
* Apply it directly with the same threshold/hysteresis as the
|
||||
* worklet path. */
|
||||
const override = httpLipSyncOverride.get(pubHex);
|
||||
if (typeof override === 'number'){
|
||||
if (Math.abs(override - e.lastApplied) < LIP_SYNC_THRESHOLD) return;
|
||||
for (const [kind, rx] of e.videoReceivers){
|
||||
try { rx.playoutDelayHint = override; } catch(_){}
|
||||
try { rx.jitterBufferTarget = override * 1000; } catch(_){}
|
||||
}
|
||||
e.lastApplied = override;
|
||||
logLine('', 'lip-sync pub='+pubHex.slice(0,4)+' delay='+override.toFixed(2)+'s (HTTP /stream override)');
|
||||
return;
|
||||
}
|
||||
if (!e.audioReceiver) return;
|
||||
const node = listenerAudioNodes.get(uuid);
|
||||
if (!node) return;
|
||||
const workletBufferedSec = node.bufferedSeconds || 0;
|
||||
|
|
@ -2040,6 +2060,25 @@ async function refreshLipSyncForUuid(uuid){
|
|||
logLine('', 'lip-sync pub='+pubHex.slice(0,4)+' delay='+med.toFixed(2)+'s ('+e.videoReceivers.size+' video rx, history median of '+e.history.length+')');
|
||||
}
|
||||
|
||||
/* Force a one-shot lip-sync application for a specific publisher.
|
||||
* Bypasses the worklet-driven 'buffered' cadence — used when an
|
||||
* external event (HTTP /stream toggle) changes the audio source and
|
||||
* we want the video target to update immediately rather than wait
|
||||
* for the next worklet message. */
|
||||
function applyLipSyncForPub(pubHex){
|
||||
const e = lipSync.get(pubHex);
|
||||
if (!e || e.videoReceivers.size === 0) return;
|
||||
const override = httpLipSyncOverride.get(pubHex);
|
||||
if (typeof override !== 'number') return;
|
||||
for (const [kind, rx] of e.videoReceivers){
|
||||
try { rx.playoutDelayHint = override; } catch(_){}
|
||||
try { rx.jitterBufferTarget = override * 1000; } catch(_){}
|
||||
}
|
||||
e.lastApplied = override;
|
||||
e.history = [];
|
||||
logLine('', 'lip-sync pub='+pubHex.slice(0,4)+' forced delay='+override.toFixed(2)+'s (HTTP /stream toggle)');
|
||||
}
|
||||
|
||||
/* Shared audio attach path. Every role routes through here now so the
|
||||
* worklet can apply role-appropriate buffer depth uniformly. Listener
|
||||
* gets a fat 4s cushion (lean-back, latency doesn't matter); speakers
|
||||
|
|
@ -2324,12 +2363,28 @@ const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
|
|||
* out wiggle-stalls without a glitch. Big enough to survive any
|
||||
* realistic publisher-side hiccup. */
|
||||
const RECV_PLAYOUT_DELAY_SEC = 4.0;
|
||||
/* Speaker/cohost/host cushion — they're in active conversation, so
|
||||
* the buffer trades a tiny bit of jitter smoothing for sub-second
|
||||
* round-trip. ~500ms feels natural; 4s would make every back-and-forth
|
||||
* impossible. Native receiver jitterBufferTarget honors this for
|
||||
* voice-rate Opus and the worklet matches it for music-rate. */
|
||||
const SPEAKER_PLAYOUT_DELAY_SEC = 0.5;
|
||||
/* Speaker/cohost/host cushion — empirically validated at 4s for
|
||||
* 20-second wiggle absorption. Previous 0.5s was conversation-friendly
|
||||
* but cohost-on-Fedora-Chrome glitched during host's X11 wiggles;
|
||||
* smaller buffers untested. 4s pays a conversation-latency cost but
|
||||
* gives same wiggle-immunity as listeners. Fox 2026-06-04: "we needed
|
||||
* 4 secs before for the wiggle. it was at least 4 secs for 20 sec
|
||||
* wiggles; we didn't test less."
|
||||
*
|
||||
* Future: twin-stream double-dragon controller (mesh + HTTP /stream
|
||||
* running in parallel, adaptive switching based on mesh loss/jitter
|
||||
* stats + hardware/network/feed performance). Until that lands, this
|
||||
* single-source buffer is the floor that keeps everyone glitch-free
|
||||
* under the most common stalls. */
|
||||
const SPEAKER_PLAYOUT_DELAY_SEC = 4.0;
|
||||
/* HTTP /stream estimated end-to-end delay used for lip-sync when the
|
||||
* per-speaker stream toggle is ON for a publisher. The actual delay
|
||||
* varies (~1-3s depending on browser buffer + network), so this is a
|
||||
* conservative middle estimate. We could derive it more precisely
|
||||
* from audio.buffered.end(0) - audio.currentTime + an Ogg-granule
|
||||
* offset, but the spread is small enough relative to lip-sync
|
||||
* tolerance (~80ms) that a fixed value suffices. */
|
||||
const HTTP_STREAM_DELAY_SEC = 2.5;
|
||||
function playoutDelayForRole(role){
|
||||
return role === 'listener' ? RECV_PLAYOUT_DELAY_SEC : SPEAKER_PLAYOUT_DELAY_SEC;
|
||||
}
|
||||
|
|
@ -5485,6 +5540,13 @@ async function startStream(uuid, pubHex){
|
|||
rampWorkletGain(uuid, 0, 100);
|
||||
const meshEl = remoteAudio.get(uuid);
|
||||
if (meshEl) try { meshEl.muted = true; } catch(_){}
|
||||
/* Lip-sync: video target locks to HTTP's deeper buffer while the
|
||||
* stream toggle is ON. applyLipSyncForPub forces an immediate
|
||||
* update so the video catches up to audio rather than drifting
|
||||
* for 4s waiting for the worklet's next 'buffered' message. The
|
||||
* worklet isn't audible during HTTP mode anyway. */
|
||||
httpLipSyncOverride.set(pubHex, HTTP_STREAM_DELAY_SEC);
|
||||
applyLipSyncForPub(pubHex);
|
||||
let a = streamAudio.get(uuid);
|
||||
/* Idempotent: second toggle-on for the same pubHex while we're
|
||||
* already loading/playing is a no-op. Resetting .src abort the
|
||||
|
|
@ -5550,12 +5612,22 @@ function stopStream(uuid){
|
|||
* while DJ mode was active. */
|
||||
const w = remoteAudio.get(uuid);
|
||||
if (w) try { w.muted = false; } catch(_){}
|
||||
/* Ramp the SFU worklet back up (100ms). If mesh is currently active
|
||||
* for this peer, the mesh <audio> element above carries the audible
|
||||
* output and the worklet plays into a destination that nobody hears
|
||||
* — same as before, no behavior change. If mesh isn't active, the
|
||||
* SFU worklet IS the live path and the ramp restores audio. */
|
||||
/* Ramp the worklet back up (100ms). The worklet is the live audio
|
||||
* path now (mesh or SFU stream feeding it via setWorkletStream). */
|
||||
rampWorkletGain(uuid, 1, 100);
|
||||
/* Drop the HTTP lip-sync override so the worklet-driven update
|
||||
* takes over again on the next 'buffered' message. The video
|
||||
* target will retarget back from ~2.5s to whatever audio total
|
||||
* delay the worklet currently has (~4s for a speaker on mesh). */
|
||||
try {
|
||||
const mm = members.get(uuid);
|
||||
const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
|
||||
if (pubHex){
|
||||
httpLipSyncOverride.delete(pubHex);
|
||||
const e = lipSync.get(pubHex);
|
||||
if (e) e.history = []; /* discard stale samples so the next median is fresh */
|
||||
}
|
||||
} catch(_){}
|
||||
}
|
||||
function toggleStreamFor(uuid, pubHex){
|
||||
if (streamMode.has(pubHex)){
|
||||
|
|
@ -6492,8 +6564,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-04</span><br>
|
||||
md5 <span class="stamp-md5">801600028d330022de2e867f4490802d</span><br>
|
||||
sha256 <span class="stamp-sha">abc6ba58d773e53243dab444b1fb5d4ddd75cecdace78ecadfe5a77f6f8eef55</span><br>
|
||||
md5 <span class="stamp-md5">bb1d3911fdc9a91535d0042523fbd5be</span><br>
|
||||
sha256 <span class="stamp-sha">becc93e6ff940bcebf1b3423c945090f971f1889f7a44730448797350b086fc2</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