diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index c73c5eb..0790546 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -667,7 +667,9 @@ async function sfuPublish(){ const pc = new RTCPeerConnection(rtcConfig); for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); } setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio')); - await pc.setLocalDescription(await pc.createOffer()); + const offer = await pc.createOffer(); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000); + await pc.setLocalDescription(offer); await waitForIceGathering(pc); const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, { method:'POST', headers:{'Content-Type':'application/json'}, @@ -686,15 +688,30 @@ async function sfuPublishScreen(){ if (sfuScreenPC || !myKeys || !roomID) return; let stream; try { - stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); + /* broadcast-quality capture: 1080p30 video, raw stereo 48kHz audio. + * Browsers treat these as 'ideal' — if a window is smaller it downscales + * gracefully; nothing is rejected. The constraint matters for the audio + * side: without channelCount:2 + sampleRate:48000, getDisplayMedia on + * Chrome can hand back mono 16kHz, which kills music quality. */ + stream = await navigator.mediaDevices.getDisplayMedia({ + video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} }, + audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false, + channelCount:2, sampleRate:48000 } + }); } catch(e){ logLine('err','screen share cancelled: '+e.message); return; } sfuScreenStream = stream; const pc = new RTCPeerConnection(rtcConfig); - for (const tr of stream.getTracks()) pc.addTrack(tr, stream); + for (const tr of stream.getTracks()){ + if (tr.kind === 'video') tr.contentHint = 'detail'; /* favour pixel fidelity over framerate */ + if (tr.kind === 'audio') tr.contentHint = 'music'; + pc.addTrack(tr, stream); + } /* the user can stop the share from the browser's native "stop sharing" * bar — propagate that into a clean unpublish */ stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); }); - await pc.setLocalDescription(await pc.createOffer()); + const offer = await pc.createOffer(); + offer.sdp = preferStereoOpus(offer.sdp, 256000); + await pc.setLocalDescription(offer); await waitForIceGathering(pc); const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex + '&kind=screen'; @@ -707,6 +724,14 @@ async function sfuPublishScreen(){ const ans = await res.json(); await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp }); sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id; + /* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen), + * 256 kbps for audio (transparent stereo Opus). The codec-level cap was + * already raised via preferStereoOpus(). */ + for (const s of pc.getSenders()){ + if (!s.track) continue; + if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000); + if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000); + } logLine('', 'sfu: sharing screen as '+shortHex(sfuScreenPeerID)); /* render a muted local preview so the publisher sees what they're * sharing — SFU does not echo the publisher's own stream back */ @@ -847,8 +872,12 @@ async function refreshTurnCred(){ * ================================================================== */ let micStream = null, audioCtx = null, musicMode = false, micDeviceId = ''; function micConstraints(){ + /* music mode = high-fidelity broadcast: stereo, raw, 48kHz so we can + * push 256kbps Opus and let Opus's stereo modes carry music properly. + * voice mode stays mono + the three cleanups so speech is intelligible. */ const base = musicMode - ? { echoCancellation:false, noiseSuppression:false, autoGainControl:false } + ? { echoCancellation:false, noiseSuppression:false, autoGainControl:false, + channelCount:2, sampleRate:48000, sampleSize:16 } : { echoCancellation:true, noiseSuppression:true, autoGainControl:true }; if (micDeviceId) base.deviceId = { exact: micDeviceId }; return base; @@ -881,10 +910,41 @@ async function setSenderBitrate(sender){ try { const p = sender.getParameters(); if (!p.encodings || !p.encodings.length) p.encodings = [{}]; - p.encodings[0].maxBitrate = musicMode ? 160000 : 40000; + /* 256 kbps stereo Opus is roughly transparent for music; 40 kbps mono is + * plenty for speech */ + p.encodings[0].maxBitrate = musicMode ? 256000 : 40000; await sender.setParameters(p); } catch(_){} } +/* explicit bitrate setter for non-mic senders (screen video, screen audio). + * setSenderBitrate above is locked to the mic's musicMode value. */ +async function setSenderMaxBitrate(sender, bps){ + if (!sender) return; + try { + const p = sender.getParameters(); + if (!p.encodings || !p.encodings.length) p.encodings = [{}]; + p.encodings[0].maxBitrate = bps; + await sender.setParameters(p); + } catch(_){} +} +/* munge the offer SDP so Opus negotiates stereo + a high maxaveragebitrate. + * Browsers omit stereo=1 unless they're sure the track is stereo, and the + * codec-level maxaveragebitrate cap (separate from RTP-level maxBitrate) + * has to be raised explicitly for music to actually use the headroom. */ +function preferStereoOpus(sdp, maxAvgBps){ + 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) }; + const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean); + const seen = new Set(); + for (let i = 0; i < parts.length; i++){ + const k = parts[i].split('=')[0]; + seen.add(k); + if (want[k] !== undefined) parts[i] = k + '=' + want[k]; + } + for (const k of Object.keys(want)) if (!seen.has(k)) parts.push(k + '=' + want[k]); + return 'a=fmtp:' + pt + ' ' + parts.join(';'); + }); +} async function applyMicMode(){ /* re-acquire mic with new constraints, hot-swap onto every live sender * (mesh peers + the SFU publish PC) */ @@ -896,9 +956,18 @@ async function applyMicMode(){ if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); } } for (const [_, pc] of peers) await swap(pc); - if (sfuPubPC) await swap(sfuPubPC); + /* SFU PC needs a full renegotiation — replaceTrack alone doesn't change + * the negotiated Opus fmtp (stereo/maxaveragebitrate), so a mono publish + * keeps emitting mono even after we swap in a stereo track. Tear down + * and re-publish so the new SDP carries the music-mode codec params. */ + if (sfuPubPC){ + await sfuUnpublish(); + } if (micStream) micStream.getTracks().forEach(t=>t.stop()); micStream = ns; + if (myRole && canSpeak(myRole)){ + sfuPublish().catch(e => logLine('err','sfu re-publish: '+e.message)); + } /* old analyser is now dead — rewire local meter against the fresh stream */ if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); } } @@ -1284,7 +1353,9 @@ async function connectToPeer(uuid, weOffer){ } }; if (weOffer){ - await pc.setLocalDescription(await pc.createOffer()); + const offer = await pc.createOffer(); + offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000); + await pc.setLocalDescription(offer); await waitForIceGathering(pc); await sendEncSDP(uuid, 'offer', pc.localDescription); } @@ -1589,8 +1660,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');