zebra-spaces: broadcast-quality mic + screen capture (stereo Opus 256k, 1080p30 @ 6Mbps)
Music mode mic upgrades: - request stereo @ 48kHz from getUserMedia (was mono default) - RTP-level bitrate cap 256kbps (was 160kbps mono — too low for music) - SDP fmtp munged to advertise stereo=1+sprop-stereo=1+maxaveragebitrate=256000 so both ends agree on stereo + room to use the bitrate - music-mode toggle now re-publishes the SFU PC instead of just replaceTrack (replaceTrack alone leaves the negotiated SDP mono — stereo never reaches the SFU even with a stereo track) Screen-share upgrades: - getDisplayMedia asks for 1920×1080 @ 30fps + stereo 48kHz audio - video sender capped at 6Mbps, audio sender at 256kbps - track contentHint 'detail' for video (favour pixels over framerate) and 'music' for audio - offer SDP munged for stereo Opus same as mic Helper added: preferStereoOpus(sdp, bps), setSenderMaxBitrate(sender, bps).
This commit is contained in:
parent
c454164d06
commit
5affebead5
1 changed files with 81 additions and 10 deletions
|
|
@ -667,7 +667,9 @@ async function sfuPublish(){
|
||||||
const pc = new RTCPeerConnection(rtcConfig);
|
const pc = new RTCPeerConnection(rtcConfig);
|
||||||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||||||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
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);
|
await waitForIceGathering(pc);
|
||||||
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
|
@ -686,15 +688,30 @@ async function sfuPublishScreen(){
|
||||||
if (sfuScreenPC || !myKeys || !roomID) return;
|
if (sfuScreenPC || !myKeys || !roomID) return;
|
||||||
let stream;
|
let stream;
|
||||||
try {
|
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; }
|
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
|
||||||
sfuScreenStream = stream;
|
sfuScreenStream = stream;
|
||||||
const pc = new RTCPeerConnection(rtcConfig);
|
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"
|
/* the user can stop the share from the browser's native "stop sharing"
|
||||||
* bar — propagate that into a clean unpublish */
|
* bar — propagate that into a clean unpublish */
|
||||||
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); });
|
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);
|
await waitForIceGathering(pc);
|
||||||
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
||||||
+ '&pub=' + myKeys.pubHex + '&kind=screen';
|
+ '&pub=' + myKeys.pubHex + '&kind=screen';
|
||||||
|
|
@ -707,6 +724,14 @@ async function sfuPublishScreen(){
|
||||||
const ans = await res.json();
|
const ans = await res.json();
|
||||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||||
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
|
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));
|
logLine('', 'sfu: sharing screen as '+shortHex(sfuScreenPeerID));
|
||||||
/* render a muted local preview so the publisher sees what they're
|
/* render a muted local preview so the publisher sees what they're
|
||||||
* sharing — SFU does not echo the publisher's own stream back */
|
* 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 = '';
|
let micStream = null, audioCtx = null, musicMode = false, micDeviceId = '';
|
||||||
function micConstraints(){
|
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
|
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 };
|
: { echoCancellation:true, noiseSuppression:true, autoGainControl:true };
|
||||||
if (micDeviceId) base.deviceId = { exact: micDeviceId };
|
if (micDeviceId) base.deviceId = { exact: micDeviceId };
|
||||||
return base;
|
return base;
|
||||||
|
|
@ -881,10 +910,41 @@ async function setSenderBitrate(sender){
|
||||||
try {
|
try {
|
||||||
const p = sender.getParameters();
|
const p = sender.getParameters();
|
||||||
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
|
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);
|
await sender.setParameters(p);
|
||||||
} catch(_){}
|
} 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(){
|
async function applyMicMode(){
|
||||||
/* re-acquire mic with new constraints, hot-swap onto every live sender
|
/* re-acquire mic with new constraints, hot-swap onto every live sender
|
||||||
* (mesh peers + the SFU publish PC) */
|
* (mesh peers + the SFU publish PC) */
|
||||||
|
|
@ -896,9 +956,18 @@ async function applyMicMode(){
|
||||||
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); }
|
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); }
|
||||||
}
|
}
|
||||||
for (const [_, pc] of peers) await swap(pc);
|
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());
|
if (micStream) micStream.getTracks().forEach(t=>t.stop());
|
||||||
micStream = ns;
|
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 */
|
/* old analyser is now dead — rewire local meter against the fresh stream */
|
||||||
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
|
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
|
||||||
}
|
}
|
||||||
|
|
@ -1284,7 +1353,9 @@ async function connectToPeer(uuid, weOffer){
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (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 waitForIceGathering(pc);
|
||||||
await sendEncSDP(uuid, 'offer', pc.localDescription);
|
await sendEncSDP(uuid, 'offer', pc.localDescription);
|
||||||
}
|
}
|
||||||
|
|
@ -1589,8 +1660,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">051db7e4a0ad45f749f2de691bb1f7c7</span><br>
|
md5 <span class="stamp-md5">0f70c2982b75f949b39a25acdbeed454</span><br>
|
||||||
sha256 <span class="stamp-sha">38861760202e2d6af7bc264bdf5243e2d25fa211af18c9bbd443f8867f9dd7a5</span><br>
|
sha256 <span class="stamp-sha">d746c549cf08f71203763b4e4845b2e1589acfc730185819ff848415d1cce059</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