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');