Skip to content

Commit e8cff24

Browse files
authored
Add support for simulcast (#4)
1 parent 82e2f5e commit e8cff24

File tree

6 files changed

+1421
-241
lines changed

6 files changed

+1421
-241
lines changed

.formatter.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Used by "mix format"
22
[
3+
import_deps: [:phoenix],
4+
plugins: [Phoenix.LiveView.HTMLFormatter],
35
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
46
]

assets/player.js

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,46 @@
11
export function createPlayerHook(iceServers = []) {
22
return {
33
async mounted() {
4-
this.pc = new RTCPeerConnection({ iceServers: iceServers });
4+
const view = this;
55

6-
this.pc.onicecandidate = (ev) => {
7-
this.pushEventTo(this.el, "ice", JSON.stringify(ev.candidate));
8-
};
6+
view.handleEvent(
7+
`connect-${view.el.id}`,
8+
async () => await view.connect(view)
9+
);
910

10-
this.pc.ontrack = (ev) => {
11-
if (!this.el.srcObject) {
12-
this.el.srcObject = ev.streams[0];
11+
const eventName = "answer" + "-" + view.el.id;
12+
view.handleEvent(eventName, async (answer) => {
13+
if (view.pc) {
14+
await view.pc.setRemoteDescription(answer);
1315
}
16+
});
17+
18+
view.videoQuality = document.getElementById("lexp-video-quality");
19+
view.videoQuality.onchange = () => {
20+
view.pushEventTo(view.el, "layer", view.videoQuality.value);
1421
};
15-
this.pc.addTransceiver("audio", { direction: "recvonly" });
16-
this.pc.addTransceiver("video", { direction: "recvonly" });
22+
},
1723

18-
const offer = await this.pc.createOffer();
19-
await this.pc.setLocalDescription(offer);
24+
async connect(view) {
25+
view.el.srcObject = undefined;
26+
view.pc = new RTCPeerConnection({ iceServers: iceServers });
2027

21-
const eventName = "answer" + "-" + this.el.id;
22-
this.handleEvent(eventName, async (answer) => {
23-
await this.pc.setRemoteDescription(answer);
24-
});
28+
view.pc.onicecandidate = (ev) => {
29+
view.pushEventTo(view.el, "ice", JSON.stringify(ev.candidate));
30+
};
2531

26-
this.pushEventTo(this.el, "offer", offer);
27-
},
32+
view.pc.ontrack = (ev) => {
33+
if (!view.el.srcObject) {
34+
view.el.srcObject = ev.streams[0];
35+
}
36+
};
37+
view.pc.addTransceiver("audio", { direction: "recvonly" });
38+
view.pc.addTransceiver("video", { direction: "recvonly" });
39+
40+
const offer = await view.pc.createOffer();
41+
await view.pc.setLocalDescription(offer);
42+
43+
view.pushEventTo(view.el, "offer", offer);
44+
}
2845
};
2946
}

assets/publisher.js

Lines changed: 150 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export function createPublisherHook(iceServers = []) {
3232
view.videoApplyButton = document.getElementById("lex-video-apply-button");
3333
view.button = document.getElementById("lex-button");
3434

35+
view.simulcast = document.getElementById("lex-simulcast");
36+
3537
view.audioDevices.onchange = function () {
3638
view.setupStream(view);
3739
};
@@ -95,6 +97,7 @@ export function createPublisherHook(iceServers = []) {
9597
view.audioApplyButton.disabled = true;
9698
view.videoApplyButton.disabled = true;
9799
view.bitrate.disabled = true;
100+
view.simulcast.disabled = true;
98101
// Button present only when Recorder is used
99102
if (view.recordStream) view.recordStream.disabled = true;
100103
},
@@ -111,6 +114,7 @@ export function createPublisherHook(iceServers = []) {
111114
view.audioApplyButton.disabled = false;
112115
view.videoApplyButton.disabled = false;
113116
view.bitrate.disabled = false;
117+
view.simulcast.disabled = false;
114118
// See above
115119
if (view.recordStream) view.recordStream.disabled = false;
116120
},
@@ -204,78 +208,10 @@ export function createPublisherHook(iceServers = []) {
204208
view.time.innerText = view.toHHMMSS(new Date() - view.startTime);
205209

206210
const stats = await view.pc.getStats(null);
207-
let bitrate;
208-
209-
stats.forEach((report) => {
210-
if (report.type === "outbound-rtp" && report.kind === "video") {
211-
if (!view.lastVideoReport) {
212-
bitrate = (report.bytesSent * 8) / 1000;
213-
} else {
214-
const timeDiff =
215-
(report.timestamp - view.lastVideoReport.timestamp) / 1000;
216-
if (timeDiff == 0) {
217-
// this should never happen as we are getting stats every second
218-
bitrate = 0;
219-
} else {
220-
bitrate =
221-
((report.bytesSent - view.lastVideoReport.bytesSent) *
222-
8) /
223-
timeDiff;
224-
}
225-
}
226-
227-
view.videoBitrate.innerText = (bitrate / 1000).toFixed();
228-
view.lastVideoReport = report;
229-
} else if (
230-
report.type === "outbound-rtp" &&
231-
report.kind === "audio"
232-
) {
233-
if (!view.lastAudioReport) {
234-
bitrate = report.bytesSent;
235-
} else {
236-
const timeDiff =
237-
(report.timestamp - view.lastAudioReport.timestamp) / 1000;
238-
if (timeDiff == 0) {
239-
// this should never happen as we are getting stats every second
240-
bitrate = 0;
241-
} else {
242-
bitrate =
243-
((report.bytesSent - view.lastAudioReport.bytesSent) *
244-
8) /
245-
timeDiff;
246-
}
247-
}
248-
249-
view.audioBitrate.innerText = (bitrate / 1000).toFixed();
250-
view.lastAudioReport = report;
251-
}
252-
});
253-
254-
// calculate packet loss
255-
if (!view.lastAudioReport || !view.lastVideoReport) {
256-
view.packetLoss.innerText = 0;
257-
} else {
258-
const packetsSent =
259-
view.lastVideoReport.packetsSent +
260-
view.lastAudioReport.packetsSent;
261-
const rtxPacketsSent =
262-
view.lastVideoReport.retransmittedPacketsSent +
263-
view.lastAudioReport.retransmittedPacketsSent;
264-
const nackReceived =
265-
view.lastVideoReport.nackCount + view.lastAudioReport.nackCount;
266-
267-
if (nackReceived == 0) {
268-
view.packetLoss.innerText = 0;
269-
} else {
270-
view.packetLoss.innerText = (
271-
(nackReceived / (packetsSent - rtxPacketsSent)) *
272-
100
273-
).toFixed();
274-
}
275-
}
211+
view.processStats(view, stats);
276212
}, 1000);
277213
} else if (view.pc.connectionState === "failed") {
278-
view.pushEvent("stop-streaming", {reason: "failed"})
214+
view.pushEvent("stop-streaming", { reason: "failed" });
279215
view.stopStreaming(view);
280216
}
281217
};
@@ -285,6 +221,144 @@ export function createPublisherHook(iceServers = []) {
285221
};
286222

287223
view.pc.addTrack(view.localStream.getAudioTracks()[0], view.localStream);
224+
225+
if (view.simulcast.checked === true) {
226+
view.addSimulcastVideo(view);
227+
} else {
228+
view.addNormalVideo(view);
229+
}
230+
231+
const offer = await view.pc.createOffer();
232+
await view.pc.setLocalDescription(offer);
233+
234+
view.pushEventTo(view.el, "offer", offer);
235+
},
236+
237+
processStats(view, stats) {
238+
let videoBytesSent = 0;
239+
let videoPacketsSent = 0;
240+
let videoNack = 0;
241+
let audioBytesSent = 0;
242+
let audioPacketsSent = 0;
243+
let audioNack = 0;
244+
245+
let statsTimestamp;
246+
stats.forEach((report) => {
247+
if (!statsTimestamp) statsTimestamp = report.timestamp;
248+
249+
if (report.type === "outbound-rtp" && report.kind === "video") {
250+
videoBytesSent += report.bytesSent;
251+
videoPacketsSent += report.packetsSent;
252+
videoNack += report.nackCount;
253+
} else if (report.type === "outbound-rtp" && report.kind === "audio") {
254+
audioBytesSent += report.bytesSent;
255+
audioPacketsSent += report.packetsSent;
256+
audioNack += report.nackCount;
257+
}
258+
});
259+
260+
const timeDiff = (statsTimestamp - view.lastStatsTimestamp) / 1000;
261+
262+
let bitrate;
263+
264+
if (!view.lastVideoBytesSent) {
265+
bitrate = (videoBytesSent * 8) / 1000;
266+
} else {
267+
if (timeDiff == 0) {
268+
// this should never happen as we are getting stats every second
269+
bitrate = 0;
270+
} else {
271+
bitrate = ((videoBytesSent - view.lastVideoBytesSent) * 8) / timeDiff;
272+
}
273+
}
274+
275+
view.videoBitrate.innerText = (bitrate / 1000).toFixed();
276+
277+
if (!view.lastAudioBytesSent) {
278+
bitrate = (audioBytesSent * 8) / 1000;
279+
} else {
280+
if (timeDiff == 0) {
281+
// this should never happen as we are getting stats every second
282+
bitrate = 0;
283+
} else {
284+
bitrate = ((audioBytesSent - view.lastAudioBytesSent) * 8) / timeDiff;
285+
}
286+
}
287+
288+
view.audioBitrate.innerText = (bitrate / 1000).toFixed();
289+
290+
// calculate packet loss
291+
if (!view.lastAudioPacketsSent || !view.lastVideoPacketsSent) {
292+
view.packetLoss.innerText = 0;
293+
} else {
294+
const packetsSent =
295+
videoPacketsSent +
296+
audioPacketsSent -
297+
view.lastAudioPacketsSent -
298+
view.lastVideoPacketsSent;
299+
300+
const nack =
301+
videoNack + audioNack - view.lastVideoNack - view.lastAudioNack;
302+
303+
if (packetsSent == 0 || timeDiff == 0) {
304+
view.packetLoss.innerText = 0;
305+
} else {
306+
view.packetLoss.innerText = (
307+
((nack / packetsSent) * 100) /
308+
timeDiff
309+
).toFixed(2);
310+
}
311+
}
312+
313+
view.lastVideoBytesSent = videoBytesSent;
314+
view.lastVideoPacketsSent = videoPacketsSent;
315+
view.lastVideoNack = videoNack;
316+
view.lastAudioBytesSent = audioBytesSent;
317+
view.lastAudioPacketsSent = audioPacketsSent;
318+
view.lastAudioNack = audioNack;
319+
view.lastStatsTimestamp = statsTimestamp;
320+
},
321+
322+
addSimulcastVideo(view) {
323+
const videoTrack = view.localStream.getVideoTracks()[0];
324+
const settings = videoTrack.getSettings();
325+
const maxTotalBitrate = view.bitrate.value * 1024;
326+
327+
// This is based on:
328+
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/video/config/simulcast.cc;l=79?q=simulcast.cc
329+
let sendEncodings;
330+
if (settings.width >= 960 && settings.height >= 540) {
331+
// we do a very simple calculation: maxTotalBitrate = x + 1/4x + 1/16x
332+
// x - bitrate for base resolution
333+
// 1/4x- bitrate for resolution scaled down by 2 - we decrese total number of pixels by 4 (width/2*height/2)
334+
// 1/16x- bitrate for resolution scaled down by 4 - we decrese total number of pixels by 16 (width/4*height/4)
335+
const maxHBitrate = Math.floor((16 * maxTotalBitrate) / 21);
336+
const maxMBitrate = Math.floor(maxHBitrate / 4);
337+
const maxLBitrate = Math.floor(maxHBitrate / 16);
338+
sendEncodings = [
339+
{ rid: "h", maxBitrate: maxHBitrate },
340+
{ rid: "m", scaleResolutionDownBy: 2, maxBitrate: maxMBitrate },
341+
{ rid: "l", scaleResolutionDownBy: 4, maxBitrate: maxLBitrate },
342+
];
343+
} else if (settings.width >= 480 && settings.height >= 270) {
344+
// maxTotalBitate = x + 1/4x
345+
const maxHBitrate = Math.floor((4 * maxTotalBitrate) / 5);
346+
const maxMBitrate = Math.floor(maxHBitrate / 4);
347+
sendEncodings = [
348+
{ rid: "h", maxBitrate: maxHBitrate },
349+
{ rid: "m", scaleResolutionDownBy: 2, maxBitrate: maxMBitrate },
350+
];
351+
} else {
352+
sendEncodings = [{ rid: "h", maxBitrate: maxTotalBitrate }];
353+
}
354+
355+
view.pc.addTransceiver(view.localStream.getVideoTracks()[0], {
356+
streams: [view.localStream],
357+
sendEncodings: sendEncodings,
358+
});
359+
},
360+
361+
addNormalVideo(view) {
288362
view.pc.addTrack(view.localStream.getVideoTracks()[0], view.localStream);
289363

290364
// set max bitrate
@@ -296,11 +370,6 @@ export function createPublisherHook(iceServers = []) {
296370
params.encodings[0].maxBitrate = view.bitrate.value * 1024;
297371
await sender.setParameters(params);
298372
});
299-
300-
const offer = await view.pc.createOffer();
301-
await view.pc.setLocalDescription(offer);
302-
303-
view.pushEventTo(view.el, "offer", offer);
304373
},
305374

306375
stopStreaming(view) {
@@ -318,6 +387,12 @@ export function createPublisherHook(iceServers = []) {
318387
view.startTime = undefined;
319388
view.lastAudioReport = undefined;
320389
view.lastVideoReport = undefined;
390+
view.lastVideoBytesSent = 0;
391+
view.lastVideoPacketsSent = 0;
392+
view.lastVideoNack = 0;
393+
view.lastAudioBytesSent = 0;
394+
view.lastAudioPacketsSent = 0;
395+
view.lastAudioNack = 0;
321396
view.audioBitrate.innerText = 0;
322397
view.videoBitrate.innerText = 0;
323398
view.packetLoss.innerText = 0;

0 commit comments

Comments
 (0)