zebra-spaces: kill audio chops + clear tile when publisher unshares
Audio robustness: - useinbandfec=1 + usedtx=1 in publisher's Opus fmtp (was only fec; dtx drops silence so the budget goes to audible content + cuts congestion) - ev.receiver.playoutDelayHint = 0.1 (100ms jitter buffer) on every incoming audio receiver — SFU subscribe + mesh peer. Absorbs Wi-Fi micro-bursts without perceptible conversation lag. Tile cleanup on unshare: - when SFU stops a screen/camera transceiver after the publisher unpublishes, the remote track fires 'ended'. Listeners now wire track.onended → removeScreenTile/removeCameraTile so the tile disappears instead of freezing on the last frame. Paired with SFU 60a620f which advertises nack + transport-cc feedback so browsers send the RTCP we depend on for both retransmit and congestion control.
This commit is contained in:
parent
7ddedc9a09
commit
353037bc5d
1 changed files with 24 additions and 3 deletions
|
|
@ -928,19 +928,29 @@ async function sfuSubscribe(){
|
||||||
const kind = sid.slice(colon + 1);
|
const kind = sid.slice(colon + 1);
|
||||||
/* skip echo of our own publish — we already render a local preview */
|
/* skip echo of our own publish — we already render a local preview */
|
||||||
if (myKeys && pubHex === myKeys.pubHex) return;
|
if (myKeys && pubHex === myKeys.pubHex) return;
|
||||||
|
/* video kinds: when the publisher unshares, the SFU stops the
|
||||||
|
* transceiver and the corresponding remote track fires 'ended'.
|
||||||
|
* Wire that to the tile remover so the listener's UI matches the
|
||||||
|
* publisher's state instead of holding a frozen last frame. */
|
||||||
if (kind === 'screen'){
|
if (kind === 'screen'){
|
||||||
screenStreams.set(pubHex, ev.streams[0]);
|
screenStreams.set(pubHex, ev.streams[0]);
|
||||||
renderScreenTile(pubHex, ev.streams[0]);
|
renderScreenTile(pubHex, ev.streams[0]);
|
||||||
|
ev.track.addEventListener('ended', () => removeScreenTile(pubHex));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'camera'){
|
if (kind === 'camera'){
|
||||||
cameraStreams.set(pubHex, ev.streams[0]);
|
cameraStreams.set(pubHex, ev.streams[0]);
|
||||||
renderCameraTile(pubHex, ev.streams[0]);
|
renderCameraTile(pubHex, ev.streams[0]);
|
||||||
|
ev.track.addEventListener('ended', () => removeCameraTile(pubHex));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
|
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* mic audio — set a small jitter-buffer hint so brief network jitter
|
||||||
|
* doesn't cause audible drops. 100ms is enough to absorb typical
|
||||||
|
* Wi-Fi micro-bursts without making conversation feel laggy. */
|
||||||
|
try { ev.receiver.playoutDelayHint = 0.1; } catch(_){}
|
||||||
const pubHex = sid;
|
const pubHex = sid;
|
||||||
/* cache stream by publisher pubkey so it survives the member's session
|
/* cache stream by publisher pubkey so it survives the member's session
|
||||||
* uuid changing across leave/rejoin — see flushSfuStreams */
|
* uuid changing across leave/rejoin — see flushSfuStreams */
|
||||||
|
|
@ -1113,7 +1123,15 @@ async function setSenderMaxBitrate(sender, bps){
|
||||||
* has to be raised explicitly for music to actually use the headroom. */
|
* has to be raised explicitly for music to actually use the headroom. */
|
||||||
function preferStereoOpus(sdp, maxAvgBps){
|
function preferStereoOpus(sdp, maxAvgBps){
|
||||||
return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => {
|
return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => {
|
||||||
const want = { 'stereo': '1', 'sprop-stereo': '1', 'maxaveragebitrate': String(maxAvgBps) };
|
/* useinbandfec=1: forward error correction so a single dropped
|
||||||
|
* packet doesn't audibly chop — Opus reconstructs from FEC.
|
||||||
|
* usedtx=1: discontinuous transmission elides silence so the
|
||||||
|
* bandwidth budget goes to actual audio (cuts congestion). */
|
||||||
|
const want = {
|
||||||
|
'stereo': '1', 'sprop-stereo': '1',
|
||||||
|
'maxaveragebitrate': String(maxAvgBps),
|
||||||
|
'useinbandfec': '1', 'usedtx': '1',
|
||||||
|
};
|
||||||
const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean);
|
const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (let i = 0; i < parts.length; i++){
|
for (let i = 0; i < parts.length; i++){
|
||||||
|
|
@ -1526,6 +1544,9 @@ async function connectToPeer(uuid, weOffer){
|
||||||
let a = remoteAudio.get(uuid);
|
let a = remoteAudio.get(uuid);
|
||||||
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
||||||
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
|
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
|
||||||
|
/* 100ms jitter buffer absorbs typical Wi-Fi micro-bursts without
|
||||||
|
* adding perceptible conversation lag */
|
||||||
|
try { ev.receiver.playoutDelayHint = 0.1; } catch(_){}
|
||||||
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
||||||
};
|
};
|
||||||
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
||||||
|
|
@ -1878,8 +1899,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">
|
<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-02</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-02</span><br>
|
||||||
md5 <span class="stamp-md5">d91602a3c30333c3c88c8fb9d1ce83d6</span><br>
|
md5 <span class="stamp-md5">39992d2506da348cfff78c04a999e409</span><br>
|
||||||
sha256 <span class="stamp-sha">d57f2da4b2958764af64527fdbc7f41a8420c216975f1ec9d55f259d5832c6ed</span><br>
|
sha256 <span class="stamp-sha">0208f19ac63acfd243a6e745aa045fd1d56a206bbff9ca3f058e6c2466aed525</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue