zebra-report: broadcast-quality mic + screen — stereo Opus 256k, 1080p30 @ 6Mbps
Mirror source 5affebe — high-fidelity audio + video. Needs SFU proxy.unturf.com#26913a8 deployed for end-to-end stereo (subscriber decoders need stereo=1 in the SFU's outgoing fmtp).
This commit is contained in:
parent
4ad776b9d5
commit
9b3504b163
1 changed files with 81 additions and 10 deletions
|
|
@ -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');
|
|||
|
||||
<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>
|
||||
md5 <span class="stamp-md5">051db7e4a0ad45f749f2de691bb1f7c7</span><br>
|
||||
sha256 <span class="stamp-sha">38861760202e2d6af7bc264bdf5243e2d25fa211af18c9bbd443f8867f9dd7a5</span><br>
|
||||
md5 <span class="stamp-md5">0f70c2982b75f949b39a25acdbeed454</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">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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue