diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 84ef5c1..a3fb8b2 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -1254,6 +1254,9 @@ async function sfuSubscribe(){
* delay which is well below noticeable. */
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
const pubHex = sid;
+ /* never play our own mic back to ourselves — the SFU echoes our
+ * publish to every sub including our own (we always subscribe now) */
+ if (myKeys && pubHex === myKeys.pubHex) return;
/* cache stream by publisher pubkey so it survives the member's session
* uuid changing across leave/rejoin — see flushSfuStreams */
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
@@ -1296,21 +1299,35 @@ async function sfuSubscribe(){
if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); }
sfuSubPC = pc; sfuSubPeerID = offer.peer_id;
/* SSE: server pushes renegotiation offers when publisher set changes.
- * We answer each via POST /answer. ping events are keepalive only. */
+ * We answer each via POST /answer. ping events are keepalive only.
+ *
+ * Renegotiation must be SERIALISED. Each offer transitions the PC
+ * through have-remote-offer → stable, and if we kick off the next
+ * setRemoteDescription before the previous has applied its answer,
+ * the second one throws ('failed to set remote offer sdp: Called in
+ * wrong state'). When a speaker publishes mic + screen + camera in
+ * quick succession the SFU fires three offers in a row — without
+ * serialisation we drop the later ones, browser-side track set goes
+ * out of sync with the SFU, and existing screen/camera tiles can
+ * stop receiving RTP. Chain through a single promise queue. */
+ let renegQueue = Promise.resolve();
sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID);
- sfuSubEvents.onmessage = async (ev) => {
+ sfuSubEvents.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch(_){ return; }
if (m.type !== 'offer' || !sfuSubPC) return;
- try {
- await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp });
- const ans = await sfuSubPC.createAnswer();
- await sfuSubPC.setLocalDescription(ans);
- await waitForIceGathering(sfuSubPC);
- await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, {
- method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp })
- });
- } catch(e){ logLine('err','sfu renegotiate: '+e.message); }
+ renegQueue = renegQueue.then(async () => {
+ if (!sfuSubPC) return;
+ try {
+ await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp });
+ const ans = await sfuSubPC.createAnswer();
+ await sfuSubPC.setLocalDescription(ans);
+ await waitForIceGathering(sfuSubPC);
+ await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp })
+ });
+ } catch(e){ logLine('err','sfu renegotiate: '+e.message); }
+ });
};
sfuSubEvents.onerror = () => { /* EventSource auto-reconnects */ };
logLine('', 'sfu: subscribed as '+shortHex(sfuSubPeerID));
@@ -2415,8 +2432,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');