test
This commit is contained in:
@@ -3,11 +3,56 @@ package capture
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxH264Bitrate = 50_000_000
|
const maxH264Bitrate = 50_000_000
|
||||||
|
|
||||||
var h264TargetBitrate atomic.Int64
|
var h264TargetBitrate atomic.Int64
|
||||||
|
var h264NetworkAdaptive atomic.Bool
|
||||||
|
var lastH264NetworkAdjustment atomic.Int64
|
||||||
|
|
||||||
|
const h264NetworkAdjustmentInterval = 3 * time.Second
|
||||||
|
|
||||||
|
func SetH264NetworkAdaptive(enabled bool) {
|
||||||
|
h264NetworkAdaptive.Store(enabled)
|
||||||
|
if !enabled {
|
||||||
|
lastH264NetworkAdjustment.Store(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyWebRTCBandwidthEstimate(bps int) int {
|
||||||
|
if !h264NetworkAdaptive.Load() || bps <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
target := bps * 85 / 100
|
||||||
|
if target < 2_000_000 {
|
||||||
|
target = 2_000_000
|
||||||
|
}
|
||||||
|
if target > maxH264Bitrate {
|
||||||
|
target = maxH264Bitrate
|
||||||
|
}
|
||||||
|
target = ((target + 500_000) / 1_000_000) * 1_000_000
|
||||||
|
current := configuredH264Bitrate()
|
||||||
|
if current > 0 {
|
||||||
|
delta := current - target
|
||||||
|
if delta < 0 {
|
||||||
|
delta = -delta
|
||||||
|
}
|
||||||
|
if delta < 1_000_000 || float64(delta)/float64(current) < 0.15 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
last := lastH264NetworkAdjustment.Load()
|
||||||
|
if last > 0 && time.Duration(now-last) < h264NetworkAdjustmentInterval {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if !lastH264NetworkAdjustment.CompareAndSwap(last, now) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return SetH264TargetBitrate(target)
|
||||||
|
}
|
||||||
|
|
||||||
func SetH264TargetBitrate(bps int) int {
|
func SetH264TargetBitrate(bps int) int {
|
||||||
if bps < 0 {
|
if bps < 0 {
|
||||||
|
|||||||
@@ -36,3 +36,25 @@ func TestH264TargetCRFTracksManualRate(t *testing.T) {
|
|||||||
t.Fatalf("higher bitrate CRF = %.2f, want below automatic %.2f", higherRate, auto)
|
t.Fatalf("higher bitrate CRF = %.2f, want below automatic %.2f", higherRate, auto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebRTCBandwidthEstimateOnlyAppliesInAdaptiveMode(t *testing.T) {
|
||||||
|
original := configuredH264Bitrate()
|
||||||
|
defer func() {
|
||||||
|
SetH264NetworkAdaptive(false)
|
||||||
|
SetH264TargetBitrate(original)
|
||||||
|
}()
|
||||||
|
|
||||||
|
SetH264TargetBitrate(18_000_000)
|
||||||
|
SetH264NetworkAdaptive(false)
|
||||||
|
if got := ApplyWebRTCBandwidthEstimate(10_000_000); got != 0 {
|
||||||
|
t.Fatalf("disabled estimate applied %d, want 0", got)
|
||||||
|
}
|
||||||
|
SetH264NetworkAdaptive(true)
|
||||||
|
lastH264NetworkAdjustment.Store(0)
|
||||||
|
if got := ApplyWebRTCBandwidthEstimate(10_000_000); got != 9_000_000 {
|
||||||
|
t.Fatalf("adaptive estimate applied %d, want 9000000", got)
|
||||||
|
}
|
||||||
|
if got := ApplyWebRTCBandwidthEstimate(2_000_000); got != 0 {
|
||||||
|
t.Fatalf("unthrottled second estimate applied %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -912,10 +912,14 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
case "desktop_set_bitrate":
|
case "desktop_set_bitrate":
|
||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
bitrateMbps := 0
|
bitrateMbps := 0
|
||||||
|
adaptive := false
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
if v, ok := payloadInt(payload, "bitrateMbps"); ok {
|
if v, ok := payloadInt(payload, "bitrateMbps"); ok {
|
||||||
bitrateMbps = v
|
bitrateMbps = v
|
||||||
}
|
}
|
||||||
|
if v, ok := payload["adaptive"].(bool); ok {
|
||||||
|
adaptive = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if bitrateMbps < 0 {
|
if bitrateMbps < 0 {
|
||||||
bitrateMbps = 0
|
bitrateMbps = 0
|
||||||
@@ -923,8 +927,9 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
if bitrateMbps > 50 {
|
if bitrateMbps > 50 {
|
||||||
bitrateMbps = 50
|
bitrateMbps = 50
|
||||||
}
|
}
|
||||||
|
capture.SetH264NetworkAdaptive(adaptive)
|
||||||
bps := capture.SetH264TargetBitrate(bitrateMbps * 1_000_000)
|
bps := capture.SetH264TargetBitrate(bitrateMbps * 1_000_000)
|
||||||
log.Printf("desktop: set target bitrate=%d Mbps (0=auto)", bps/1_000_000)
|
log.Printf("desktop: set target bitrate=%d Mbps (0=auto) adaptive=%v", bps/1_000_000, adaptive)
|
||||||
sendCommandResultSafe(env, cmdID, true, "")
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
return nil
|
return nil
|
||||||
case "clipboard_sync_start":
|
case "clipboard_sync_start":
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p
|
|||||||
OnClose: func() {
|
OnClose: func() {
|
||||||
log.Printf("webrtc: P2P[%s/%s] session closed", kind, sessionID)
|
log.Printf("webrtc: P2P[%s/%s] session closed", kind, sessionID)
|
||||||
},
|
},
|
||||||
|
OnBandwidthEstimate: func(bps int) {
|
||||||
|
if kind != webrtcpub.KindDesktop {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if applied := capture.ApplyWebRTCBandwidthEstimate(bps); applied > 0 {
|
||||||
|
log.Printf("webrtc: P2P congestion target=%d Mbps applied=%d Mbps", bps/1_000_000, applied/1_000_000)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
answerSDP, err := webrtcpub.StartP2POffer(ctx, kind, sessionID, offerSDP, callbacks, hasVideo, hasAudio)
|
answerSDP, err := webrtcpub.StartP2POffer(ctx, kind, sessionID, offerSDP, callbacks, hasVideo, hasAudio)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/interceptor"
|
||||||
|
"github.com/pion/interceptor/pkg/cc"
|
||||||
|
"github.com/pion/interceptor/pkg/gcc"
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +85,33 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
|
interceptorRegistry := &interceptor.Registry{}
|
||||||
|
if hasVideo && opts.OnBandwidthEstimate != nil {
|
||||||
|
congestionController, ccErr := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
|
||||||
|
return gcc.NewSendSideBWE(
|
||||||
|
gcc.SendSideBWEInitialBitrate(18_000_000),
|
||||||
|
gcc.SendSideBWEMinBitrate(2_000_000),
|
||||||
|
gcc.SendSideBWEMaxBitrate(50_000_000),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if ccErr != nil {
|
||||||
|
return "", fmt.Errorf("create congestion controller: %w", ccErr)
|
||||||
|
}
|
||||||
|
congestionController.OnNewPeerConnection(func(_ string, estimator cc.BandwidthEstimator) {
|
||||||
|
estimator.OnTargetBitrateChange(opts.OnBandwidthEstimate)
|
||||||
|
})
|
||||||
|
interceptorRegistry.Add(congestionController)
|
||||||
|
if err := webrtc.ConfigureTWCCHeaderExtensionSender(mediaEngine, interceptorRegistry); err != nil {
|
||||||
|
return "", fmt.Errorf("configure TWCC sender: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
|
||||||
|
return "", fmt.Errorf("register WebRTC interceptors: %w", err)
|
||||||
|
}
|
||||||
|
api := webrtc.NewAPI(
|
||||||
|
webrtc.WithMediaEngine(mediaEngine),
|
||||||
|
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
||||||
|
)
|
||||||
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||||
ICEServers: []webrtc.ICEServer{
|
ICEServers: []webrtc.ICEServer{
|
||||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
type Kind string
|
type Kind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
KindDesktop Kind = "desktop"
|
KindDesktop Kind = "desktop"
|
||||||
Kindbackstage Kind = "backstage"
|
Kindbackstage Kind = "backstage"
|
||||||
KindWebcam Kind = "webcam"
|
KindWebcam Kind = "webcam"
|
||||||
KindAudio Kind = "audio"
|
KindAudio Kind = "audio"
|
||||||
)
|
)
|
||||||
|
|
||||||
type VideoWriter interface {
|
type VideoWriter interface {
|
||||||
@@ -225,6 +225,7 @@ type ICECandidate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type P2POfferCallbacks struct {
|
type P2POfferCallbacks struct {
|
||||||
OnICE func(c ICECandidate)
|
OnICE func(c ICECandidate)
|
||||||
OnClose func()
|
OnClose func()
|
||||||
|
OnBandwidthEstimate func(bps int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/gen2brain/malgo v0.11.24
|
github.com/gen2brain/malgo v0.11.24
|
||||||
github.com/gen2brain/shm v0.1.0
|
github.com/gen2brain/shm v0.1.0
|
||||||
github.com/jezek/xgb v1.1.1
|
github.com/jezek/xgb v1.1.1
|
||||||
|
github.com/pion/interceptor v0.1.37
|
||||||
github.com/pion/rtcp v1.2.15
|
github.com/pion/rtcp v1.2.15
|
||||||
github.com/pion/webrtc/v4 v4.0.7
|
github.com/pion/webrtc/v4 v4.0.7
|
||||||
github.com/tetratelabs/wazero v1.12.0
|
github.com/tetratelabs/wazero v1.12.0
|
||||||
@@ -34,7 +35,6 @@ require (
|
|||||||
github.com/pion/datachannel v1.5.10 // indirect
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.4 // indirect
|
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||||
github.com/pion/ice/v4 v4.0.3 // indirect
|
github.com/pion/ice/v4 v4.0.3 // indirect
|
||||||
github.com/pion/interceptor v0.1.37 // indirect
|
|
||||||
github.com/pion/logging v0.2.2 // indirect
|
github.com/pion/logging v0.2.2 // indirect
|
||||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
const MIN_BITRATE_MBPS = 2;
|
||||||
|
const MAX_BITRATE_MBPS = 50;
|
||||||
|
|
||||||
|
function finite(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAdaptiveProfiles(profiles) {
|
||||||
|
const seen = new Set();
|
||||||
|
const normalized = [];
|
||||||
|
for (const raw of Array.isArray(profiles) ? profiles : []) {
|
||||||
|
const width = Math.max(0, Math.floor(Number(raw?.width) || 0));
|
||||||
|
const height = Math.max(0, Math.floor(Number(raw?.height) || 0));
|
||||||
|
const maxHeight = Math.floor(Number(raw?.maxHeight) || 0);
|
||||||
|
const fps = Math.max(1, Math.min(240, Math.floor(Number(raw?.fps) || 0)));
|
||||||
|
if (!height || !fps) continue;
|
||||||
|
const key = `${width}x${height}@${fps}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
normalized.push({ ...raw, width, height, maxHeight, fps });
|
||||||
|
}
|
||||||
|
normalized.sort((a, b) => {
|
||||||
|
const pixelDelta = (b.width * b.height) - (a.width * a.height);
|
||||||
|
if (pixelDelta) return pixelDelta;
|
||||||
|
if (b.height !== a.height) return b.height - a.height;
|
||||||
|
return b.fps - a.fps;
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function automaticBitrateMbps(profile) {
|
||||||
|
const pixelsPerSecond = Math.max(1, Number(profile?.width) || 0) *
|
||||||
|
Math.max(1, Number(profile?.height) || 0) *
|
||||||
|
Math.max(1, Number(profile?.fps) || 60);
|
||||||
|
return Math.max(MIN_BITRATE_MBPS, Math.min(18, Math.ceil((pixelsPerSecond * 0.08) / 1_000_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdaptiveDesktopQuality {
|
||||||
|
constructor(onTarget, options = {}) {
|
||||||
|
this.onTarget = typeof onTarget === "function" ? onTarget : () => {};
|
||||||
|
this.cooldownMs = Number(options.cooldownMs) || 5000;
|
||||||
|
this.badSamplesRequired = Number(options.badSamplesRequired) || 2;
|
||||||
|
this.strainedSamplesRequired = Number(options.strainedSamplesRequired) || 3;
|
||||||
|
this.stableSamplesRequired = Number(options.stableSamplesRequired) || 10;
|
||||||
|
this.profiles = [];
|
||||||
|
this.enabled = false;
|
||||||
|
this.profileIndex = 0;
|
||||||
|
this.targetBitrateMbps = 0;
|
||||||
|
this.badSamples = 0;
|
||||||
|
this.strainedSamples = 0;
|
||||||
|
this.stableSamples = 0;
|
||||||
|
this.bitrateReductionsAtProfile = 0;
|
||||||
|
this.lastChangeAt = Number.NEGATIVE_INFINITY;
|
||||||
|
this.lastReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfiles(profiles) {
|
||||||
|
this.profiles = normalizeAdaptiveProfiles(profiles);
|
||||||
|
this.resetState();
|
||||||
|
return this.profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(now = 0) {
|
||||||
|
this.enabled = this.profiles.length > 0;
|
||||||
|
this.resetState();
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
this.lastChangeAt = now;
|
||||||
|
return this.emit("Auto started at best profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.enabled = false;
|
||||||
|
this.resetCounters();
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
const profile = this.profiles[this.profileIndex] || null;
|
||||||
|
if (!profile) return null;
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
profileIndex: this.profileIndex,
|
||||||
|
profileCount: this.profiles.length,
|
||||||
|
bitrateMbps: this.targetBitrateMbps || automaticBitrateMbps(profile),
|
||||||
|
reason: this.lastReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sample(stats, agentStats, now = Date.now()) {
|
||||||
|
if (!this.enabled || !this.profiles.length) return null;
|
||||||
|
const media = stats?.video;
|
||||||
|
if (!media) return null;
|
||||||
|
|
||||||
|
const loss = finite(media.lossPercent) ?? 0;
|
||||||
|
const rtt = finite(stats?.rttMs);
|
||||||
|
const jitter = finite(media.jitterMs);
|
||||||
|
const jitterBuffer = finite(media.jitterBufferMs);
|
||||||
|
const dropped = finite(media.framesDroppedDelta) ?? 0;
|
||||||
|
const viewerFps = finite(media.framesPerSecond);
|
||||||
|
const agentFps = finite(agentStats?.fps);
|
||||||
|
const available = finite(stats?.availableIncomingMbps);
|
||||||
|
const fpsRatio = viewerFps != null && agentFps != null && agentFps > 0 ? viewerFps / agentFps : null;
|
||||||
|
const capacityTight = available != null && available > 0 && this.targetBitrateMbps > available * 0.9;
|
||||||
|
|
||||||
|
const bad = loss >= 5 || (rtt != null && rtt >= 250) ||
|
||||||
|
(jitterBuffer != null && jitterBuffer >= 80) || dropped >= 3 ||
|
||||||
|
(fpsRatio != null && fpsRatio < 0.75) || (capacityTight && available < this.targetBitrateMbps * 0.7);
|
||||||
|
const strained = !bad && (loss >= 2 || (rtt != null && rtt >= 160) ||
|
||||||
|
(jitter != null && jitter >= 35) || (jitterBuffer != null && jitterBuffer >= 40) ||
|
||||||
|
dropped > 0 || (fpsRatio != null && fpsRatio < 0.9) || capacityTight);
|
||||||
|
const stable = !bad && !strained && loss < 1 && dropped === 0 &&
|
||||||
|
(rtt == null || rtt < 120) && (jitterBuffer == null || jitterBuffer < 25) &&
|
||||||
|
(fpsRatio == null || fpsRatio >= 0.95);
|
||||||
|
|
||||||
|
this.badSamples = bad ? this.badSamples + 1 : 0;
|
||||||
|
this.strainedSamples = strained ? this.strainedSamples + 1 : 0;
|
||||||
|
this.stableSamples = stable ? this.stableSamples + 1 : 0;
|
||||||
|
if (now - this.lastChangeAt < this.cooldownMs) return null;
|
||||||
|
|
||||||
|
if (this.badSamples >= this.badSamplesRequired) {
|
||||||
|
return this.reduce("Severe WebRTC congestion", now, 0.72);
|
||||||
|
}
|
||||||
|
if (this.strainedSamples >= this.strainedSamplesRequired) {
|
||||||
|
return this.reduce("Sustained WebRTC congestion", now, 0.84);
|
||||||
|
}
|
||||||
|
if (this.stableSamples >= this.stableSamplesRequired) {
|
||||||
|
return this.increase("WebRTC connection stable", now);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetState() {
|
||||||
|
this.profileIndex = 0;
|
||||||
|
this.targetBitrateMbps = this.profiles[0] ? automaticBitrateMbps(this.profiles[0]) : 0;
|
||||||
|
this.bitrateReductionsAtProfile = 0;
|
||||||
|
this.lastReason = "";
|
||||||
|
this.resetCounters();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCounters() {
|
||||||
|
this.badSamples = 0;
|
||||||
|
this.strainedSamples = 0;
|
||||||
|
this.stableSamples = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
reduce(reason, now, factor) {
|
||||||
|
const profile = this.profiles[this.profileIndex];
|
||||||
|
const floor = Math.max(MIN_BITRATE_MBPS, Math.ceil(automaticBitrateMbps(profile) * 0.45));
|
||||||
|
const reduced = Math.max(floor, Math.floor(this.targetBitrateMbps * factor));
|
||||||
|
if (reduced < this.targetBitrateMbps && this.bitrateReductionsAtProfile < 1) {
|
||||||
|
this.targetBitrateMbps = reduced;
|
||||||
|
this.bitrateReductionsAtProfile += 1;
|
||||||
|
} else if (this.profileIndex < this.profiles.length - 1) {
|
||||||
|
this.profileIndex += 1;
|
||||||
|
this.bitrateReductionsAtProfile = 0;
|
||||||
|
this.targetBitrateMbps = Math.min(
|
||||||
|
Math.max(MIN_BITRATE_MBPS, this.targetBitrateMbps),
|
||||||
|
automaticBitrateMbps(this.profiles[this.profileIndex]),
|
||||||
|
);
|
||||||
|
} else if (reduced < this.targetBitrateMbps) {
|
||||||
|
this.targetBitrateMbps = reduced;
|
||||||
|
} else {
|
||||||
|
this.resetCounters();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.lastChangeAt = now;
|
||||||
|
this.resetCounters();
|
||||||
|
return this.emit(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
increase(reason, now) {
|
||||||
|
const nominal = automaticBitrateMbps(this.profiles[this.profileIndex]);
|
||||||
|
if (this.targetBitrateMbps < nominal) {
|
||||||
|
this.targetBitrateMbps = Math.min(nominal, Math.max(this.targetBitrateMbps + 1, Math.ceil(this.targetBitrateMbps * 1.12)));
|
||||||
|
} else if (this.profileIndex > 0) {
|
||||||
|
this.profileIndex -= 1;
|
||||||
|
this.bitrateReductionsAtProfile = 0;
|
||||||
|
this.targetBitrateMbps = Math.min(this.targetBitrateMbps, automaticBitrateMbps(this.profiles[this.profileIndex]));
|
||||||
|
} else {
|
||||||
|
this.resetCounters();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.lastChangeAt = now;
|
||||||
|
this.resetCounters();
|
||||||
|
return this.emit(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(reason) {
|
||||||
|
this.lastReason = reason;
|
||||||
|
const current = this.current();
|
||||||
|
if (!current) return null;
|
||||||
|
const target = {
|
||||||
|
maxHeight: current.profile.maxHeight,
|
||||||
|
fps: current.profile.fps,
|
||||||
|
width: current.profile.width,
|
||||||
|
height: current.profile.height,
|
||||||
|
bitrateMbps: Math.max(MIN_BITRATE_MBPS, Math.min(MAX_BITRATE_MBPS, Math.round(current.bitrateMbps))),
|
||||||
|
profileIndex: current.profileIndex,
|
||||||
|
profileCount: current.profileCount,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
this.onTarget(target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { AdaptiveDesktopQuality, automaticBitrateMbps, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.js";
|
||||||
|
|
||||||
|
const profiles = [
|
||||||
|
{ maxHeight: 720, width: 1280, height: 720, fps: 60 },
|
||||||
|
{ maxHeight: 1080, width: 1920, height: 1080, fps: 60 },
|
||||||
|
{ maxHeight: -1, width: 2560, height: 1440, fps: 144 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("adaptive remote desktop quality", () => {
|
||||||
|
test("ranks native resolution first and starts at the best profile", () => {
|
||||||
|
const ranked = normalizeAdaptiveProfiles(profiles);
|
||||||
|
expect(ranked.map((profile) => profile.height)).toEqual([1440, 1080, 720]);
|
||||||
|
const targets: any[] = [];
|
||||||
|
const controller = new AdaptiveDesktopQuality((target) => targets.push(target));
|
||||||
|
controller.setProfiles(profiles);
|
||||||
|
controller.start(0);
|
||||||
|
expect(targets[0].height).toBe(1440);
|
||||||
|
expect(targets[0].fps).toBe(144);
|
||||||
|
expect(targets[0].bitrateMbps).toBe(automaticBitrateMbps(ranked[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires sustained congestion before reducing bitrate and then profile", () => {
|
||||||
|
const targets: any[] = [];
|
||||||
|
const controller = new AdaptiveDesktopQuality((target) => targets.push(target), { cooldownMs: 1 });
|
||||||
|
controller.setProfiles(profiles);
|
||||||
|
controller.start(0);
|
||||||
|
const bad = { rttMs: 300, video: { lossPercent: 7, framesDroppedDelta: 4, framesPerSecond: 40 } };
|
||||||
|
expect(controller.sample(bad, { fps: 144 }, 10)).toBeNull();
|
||||||
|
expect(controller.sample(bad, { fps: 144 }, 11)?.height).toBe(1440);
|
||||||
|
expect(controller.sample(bad, { fps: 144 }, 20)).toBeNull();
|
||||||
|
expect(controller.sample(bad, { fps: 144 }, 21)?.height).toBe(1080);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slowly recovers after stable feedback", () => {
|
||||||
|
const controller = new AdaptiveDesktopQuality(() => {}, {
|
||||||
|
cooldownMs: 1,
|
||||||
|
badSamplesRequired: 1,
|
||||||
|
stableSamplesRequired: 2,
|
||||||
|
});
|
||||||
|
controller.setProfiles(profiles);
|
||||||
|
controller.start(0);
|
||||||
|
const bad = { rttMs: 300, video: { lossPercent: 8, framesDroppedDelta: 5, framesPerSecond: 20 } };
|
||||||
|
controller.sample(bad, { fps: 144 }, 2);
|
||||||
|
controller.sample(bad, { fps: 144 }, 4);
|
||||||
|
expect(controller.current()?.profile.height).toBe(1080);
|
||||||
|
const stable = { rttMs: 30, video: { lossPercent: 0, framesDroppedDelta: 0, framesPerSecond: 60, jitterBufferMs: 5 } };
|
||||||
|
expect(controller.sample(stable, { fps: 60 }, 6)).toBeNull();
|
||||||
|
const recovered = controller.sample(stable, { fps: 60 }, 7);
|
||||||
|
expect(recovered?.height).toBe(1440);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
import { checkFeatureAccess } from "./feature-gate.js";
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
import { WhepClient } from "./whep.js";
|
import { WhepClient } from "./whep.js";
|
||||||
import { P2PClient } from "./webrtc-p2p.js";
|
import { P2PClient } from "./webrtc-p2p.js";
|
||||||
|
import { AdaptiveDesktopQuality, normalizeAdaptiveProfiles } from "./rd-adaptive-quality.js";
|
||||||
import { createKeyboardCapture } from "./keyboard-capture.js";
|
import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||||
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js";
|
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js";
|
||||||
|
|
||||||
@@ -158,6 +159,25 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
};
|
};
|
||||||
|
let encoderProfiles = [];
|
||||||
|
let adaptiveProfileRunning = false;
|
||||||
|
const adaptiveQuality = new AdaptiveDesktopQuality((target) => {
|
||||||
|
sendCmd("desktop_set_profile", {
|
||||||
|
maxHeight: target.maxHeight,
|
||||||
|
fps: target.fps,
|
||||||
|
source: "webrtc_auto",
|
||||||
|
reason: target.reason,
|
||||||
|
});
|
||||||
|
sendCmd("desktop_set_bitrate", {
|
||||||
|
bitrateMbps: target.bitrateMbps,
|
||||||
|
adaptive: true,
|
||||||
|
source: "webrtc_auto",
|
||||||
|
reason: target.reason,
|
||||||
|
});
|
||||||
|
if (streamProfileDetail) {
|
||||||
|
streamProfileDetail.textContent = `Auto: ${target.width}×${target.height} @ ${target.fps} FPS, ${target.bitrateMbps} Mbps. ${target.reason}.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let clipboardSyncTimer = null;
|
let clipboardSyncTimer = null;
|
||||||
let lastClipboardText = "";
|
let lastClipboardText = "";
|
||||||
@@ -404,7 +424,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
}
|
}
|
||||||
|
|
||||||
let savedDisplay = null;
|
let savedDisplay = null;
|
||||||
let savedStreamProfile = null;
|
let savedStreamProfile = "auto";
|
||||||
|
|
||||||
function setSelectValue(select, value) {
|
function setSelectValue(select, value) {
|
||||||
if (!select || value === undefined || value === null) return false;
|
if (!select || value === undefined || value === null) return false;
|
||||||
@@ -455,7 +475,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
const profile = selectedStreamProfile();
|
const profile = selectedStreamProfile();
|
||||||
return {
|
return {
|
||||||
display: Number(displaySelect?.value || 0),
|
display: Number(displaySelect?.value || 0),
|
||||||
streamProfile: streamProfileSelect?.value || "1080:60",
|
streamProfile: streamProfileSelect?.value || "auto",
|
||||||
// Keep the legacy fields so backstage and older remote-desktop builds that
|
// Keep the legacy fields so backstage and older remote-desktop builds that
|
||||||
// share these preferences continue to receive equivalent settings.
|
// share these preferences continue to receive equivalent settings.
|
||||||
resolution: String(profile.maxHeight),
|
resolution: String(profile.maxHeight),
|
||||||
@@ -977,6 +997,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
if (Number.isFinite(media?.framesDropped)) details.push(`Frames dropped: ${media.framesDropped}`);
|
if (Number.isFinite(media?.framesDropped)) details.push(`Frames dropped: ${media.framesDropped}`);
|
||||||
if (Number.isFinite(stats.availableIncomingMbps)) details.push(`Available: ${stats.availableIncomingMbps.toFixed(1)} Mbps`);
|
if (Number.isFinite(stats.availableIncomingMbps)) details.push(`Available: ${stats.availableIncomingMbps.toFixed(1)} Mbps`);
|
||||||
networkStats.title = details.join("\n");
|
networkStats.title = details.join("\n");
|
||||||
|
if (adaptiveProfileRunning) {
|
||||||
|
adaptiveQuality.sample(stats, diagnostics.agent, Date.now());
|
||||||
|
}
|
||||||
renderDiagnostics();
|
renderDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1479,7 +1502,11 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectedStreamProfile() {
|
function selectedStreamProfile() {
|
||||||
const [heightValue, fpsValue] = String(streamProfileSelect?.value || "1080:60").split(":");
|
if (streamProfileSelect?.value === "auto") {
|
||||||
|
const best = encoderProfiles[0];
|
||||||
|
return best ? { maxHeight: best.maxHeight, fps: best.fps } : { maxHeight: -1, fps: 120 };
|
||||||
|
}
|
||||||
|
const [heightValue, fpsValue] = String(streamProfileSelect?.value || "auto").split(":");
|
||||||
const maxHeight = Number.parseInt(heightValue, 10);
|
const maxHeight = Number.parseInt(heightValue, 10);
|
||||||
const fps = Number.parseInt(fpsValue, 10);
|
const fps = Number.parseInt(fpsValue, 10);
|
||||||
return {
|
return {
|
||||||
@@ -1489,6 +1516,29 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushStreamProfile() {
|
function pushStreamProfile() {
|
||||||
|
if (streamProfileSelect?.value === "auto") {
|
||||||
|
const adaptiveWebrtc = getWebrtcMode() !== "off" && Number(bitrateSelect?.value || 0) === 0;
|
||||||
|
if (adaptiveWebrtc && encoderProfiles.length) {
|
||||||
|
adaptiveQuality.setProfiles(encoderProfiles);
|
||||||
|
adaptiveProfileRunning = true;
|
||||||
|
adaptiveQuality.start(Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adaptiveProfileRunning = false;
|
||||||
|
adaptiveQuality.stop();
|
||||||
|
const best = selectedStreamProfile();
|
||||||
|
console.debug("rd: pushStreamProfile auto best", best);
|
||||||
|
sendCmd("desktop_set_profile", { ...best, source: "auto_best" });
|
||||||
|
if (streamProfileDetail) {
|
||||||
|
const profile = encoderProfiles[0];
|
||||||
|
streamProfileDetail.textContent = profile
|
||||||
|
? `Auto selected ${profile.width}×${profile.height} @ ${profile.fps} FPS (best available).`
|
||||||
|
: "Auto selected native resolution while capabilities are loading.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adaptiveProfileRunning = false;
|
||||||
|
adaptiveQuality.stop();
|
||||||
const profile = selectedStreamProfile();
|
const profile = selectedStreamProfile();
|
||||||
console.debug("rd: pushStreamProfile", profile);
|
console.debug("rd: pushStreamProfile", profile);
|
||||||
sendCmd("desktop_set_profile", profile);
|
sendCmd("desktop_set_profile", profile);
|
||||||
@@ -1496,11 +1546,13 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
|
|
||||||
function pushBitrate() {
|
function pushBitrate() {
|
||||||
const bitrateMbps = Math.max(0, Math.min(50, Number.parseInt(bitrateSelect?.value || "0", 10) || 0));
|
const bitrateMbps = Math.max(0, Math.min(50, Number.parseInt(bitrateSelect?.value || "0", 10) || 0));
|
||||||
sendCmd("desktop_set_bitrate", { bitrateMbps });
|
if (adaptiveProfileRunning && bitrateMbps === 0) return;
|
||||||
|
sendCmd("desktop_set_bitrate", { bitrateMbps, adaptive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitrateSelect) {
|
if (bitrateSelect) {
|
||||||
bitrateSelect.addEventListener("change", function () {
|
bitrateSelect.addEventListener("change", function () {
|
||||||
|
pushStreamProfile();
|
||||||
pushBitrate();
|
pushBitrate();
|
||||||
sharedSettingsSaver.scheduleSave();
|
sharedSettingsSaver.scheduleSave();
|
||||||
});
|
});
|
||||||
@@ -1518,8 +1570,14 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
const selectedDisplay = Number.parseInt(displaySelect?.value || "0", 10) || 0;
|
const selectedDisplay = Number.parseInt(displaySelect?.value || "0", 10) || 0;
|
||||||
if (Number.isFinite(Number(msg.display)) && Number(msg.display) !== selectedDisplay) return;
|
if (Number.isFinite(Number(msg.display)) && Number(msg.display) !== selectedDisplay) return;
|
||||||
const previous = streamProfileSelect.value;
|
const previous = streamProfileSelect.value;
|
||||||
|
encoderProfiles = normalizeAdaptiveProfiles(msg.profiles);
|
||||||
streamProfileSelect.innerHTML = "";
|
streamProfileSelect.innerHTML = "";
|
||||||
for (const profile of msg.profiles) {
|
const autoOption = document.createElement("option");
|
||||||
|
autoOption.value = "auto";
|
||||||
|
autoOption.textContent = "Auto (best)";
|
||||||
|
autoOption.title = "Starts at the best supported profile and adapts to WebRTC congestion";
|
||||||
|
streamProfileSelect.appendChild(autoOption);
|
||||||
|
for (const profile of encoderProfiles) {
|
||||||
const maxHeight = Number(profile.maxHeight);
|
const maxHeight = Number(profile.maxHeight);
|
||||||
const fps = Number(profile.fps);
|
const fps = Number(profile.fps);
|
||||||
if (!Number.isFinite(maxHeight) || !Number.isFinite(fps)) continue;
|
if (!Number.isFinite(maxHeight) || !Number.isFinite(fps)) continue;
|
||||||
@@ -1533,22 +1591,25 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
}
|
}
|
||||||
streamProfileSelect.appendChild(option);
|
streamProfileSelect.appendChild(option);
|
||||||
}
|
}
|
||||||
if (!streamProfileSelect.options.length) {
|
if (streamProfileSelect.options.length === 1) {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = "1080:60";
|
option.value = "1080:60";
|
||||||
option.textContent = "60 FPS - 1080p";
|
option.textContent = "60 FPS - 1080p";
|
||||||
streamProfileSelect.appendChild(option);
|
streamProfileSelect.appendChild(option);
|
||||||
}
|
}
|
||||||
if (!setSelectValue(streamProfileSelect, savedStreamProfile) &&
|
if (!setSelectValue(streamProfileSelect, savedStreamProfile || "auto") &&
|
||||||
!setSelectValue(streamProfileSelect, previous) &&
|
!setSelectValue(streamProfileSelect, previous) &&
|
||||||
!setSelectValue(streamProfileSelect, "1080:60")) {
|
!setSelectValue(streamProfileSelect, "auto")) {
|
||||||
streamProfileSelect.selectedIndex = 0;
|
streamProfileSelect.value = "auto";
|
||||||
}
|
}
|
||||||
savedStreamProfile = streamProfileSelect.value;
|
savedStreamProfile = streamProfileSelect.value;
|
||||||
if (streamProfileDetail) {
|
if (streamProfileDetail) {
|
||||||
const providers = streamProfileSelect.selectedOptions[0]?.dataset?.providers || "";
|
const providers = streamProfileSelect.selectedOptions[0]?.dataset?.providers || "";
|
||||||
streamProfileDetail.textContent = providers ? `Available through ${providers}.` :
|
const best = encoderProfiles[0];
|
||||||
(msg.detail || (msg.probed ? "Profiles tested on this display adapter." : "Safe fallback profiles shown."));
|
streamProfileDetail.textContent = streamProfileSelect.value === "auto" && best
|
||||||
|
? `Auto starts at ${best.width}×${best.height} @ ${best.fps} FPS and adapts on WebRTC.`
|
||||||
|
: (providers ? `Available through ${providers}.` :
|
||||||
|
(msg.detail || (msg.probed ? "Profiles tested on this display adapter." : "Safe fallback profiles shown.")));
|
||||||
}
|
}
|
||||||
if (desiredStreaming) pushStreamProfile();
|
if (desiredStreaming) pushStreamProfile();
|
||||||
}
|
}
|
||||||
@@ -1559,6 +1620,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
const providers = streamProfileSelect.selectedOptions[0]?.dataset?.providers || "";
|
const providers = streamProfileSelect.selectedOptions[0]?.dataset?.providers || "";
|
||||||
if (streamProfileDetail && providers) streamProfileDetail.textContent = `Available through ${providers}.`;
|
if (streamProfileDetail && providers) streamProfileDetail.textContent = `Available through ${providers}.`;
|
||||||
pushStreamProfile();
|
pushStreamProfile();
|
||||||
|
pushBitrate();
|
||||||
sharedSettingsSaver.scheduleSave();
|
sharedSettingsSaver.scheduleSave();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1642,6 +1704,8 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
|||||||
stopBtn.addEventListener("click", function () {
|
stopBtn.addEventListener("click", function () {
|
||||||
if (isRecording()) stopRecording();
|
if (isRecording()) stopRecording();
|
||||||
desiredStreaming = false;
|
desiredStreaming = false;
|
||||||
|
adaptiveProfileRunning = false;
|
||||||
|
adaptiveQuality.stop();
|
||||||
lastFrameAt = 0;
|
lastFrameAt = 0;
|
||||||
setStreamState("stopping", "Stopping stream");
|
setStreamState("stopping", "Stopping stream");
|
||||||
disablePrivacyIfActive();
|
disablePrivacyIfActive();
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export class WebRTCStatsSampler {
|
|||||||
let decodeMs = null;
|
let decodeMs = null;
|
||||||
let processingDelayMs = null;
|
let processingDelayMs = null;
|
||||||
let framesDroppedDelta = null;
|
let framesDroppedDelta = null;
|
||||||
|
let intervalLossPercent = null;
|
||||||
if (previous && timestamp > previous.timestamp && Number(item.bytesReceived) >= previous.bytesReceived) {
|
if (previous && timestamp > previous.timestamp && Number(item.bytesReceived) >= previous.bytesReceived) {
|
||||||
bitrateMbps = ((Number(item.bytesReceived) - previous.bytesReceived) * 8) /
|
bitrateMbps = ((Number(item.bytesReceived) - previous.bytesReceived) * 8) /
|
||||||
((timestamp - previous.timestamp) * 1000);
|
((timestamp - previous.timestamp) * 1000);
|
||||||
@@ -87,6 +88,10 @@ export class WebRTCStatsSampler {
|
|||||||
}
|
}
|
||||||
const droppedDelta = (Number(item.framesDropped) || 0) - previous.framesDropped;
|
const droppedDelta = (Number(item.framesDropped) || 0) - previous.framesDropped;
|
||||||
if (droppedDelta >= 0) framesDroppedDelta = droppedDelta;
|
if (droppedDelta >= 0) framesDroppedDelta = droppedDelta;
|
||||||
|
const receivedDelta = (Number(item.packetsReceived) || 0) - previous.packetsReceived;
|
||||||
|
const lostDelta = (Number(item.packetsLost) || 0) - previous.packetsLost;
|
||||||
|
const packetDelta = Math.max(0, receivedDelta) + Math.max(0, lostDelta);
|
||||||
|
if (packetDelta > 0) intervalLossPercent = (Math.max(0, lostDelta) * 100) / packetDelta;
|
||||||
}
|
}
|
||||||
this.previous.set(item.id, {
|
this.previous.set(item.id, {
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -95,6 +100,8 @@ export class WebRTCStatsSampler {
|
|||||||
totalDecodeTime: Number(item.totalDecodeTime) || 0,
|
totalDecodeTime: Number(item.totalDecodeTime) || 0,
|
||||||
totalProcessingDelay: Number(item.totalProcessingDelay) || 0,
|
totalProcessingDelay: Number(item.totalProcessingDelay) || 0,
|
||||||
framesDropped: Number(item.framesDropped) || 0,
|
framesDropped: Number(item.framesDropped) || 0,
|
||||||
|
packetsReceived: Number(item.packetsReceived) || 0,
|
||||||
|
packetsLost: Number(item.packetsLost) || 0,
|
||||||
});
|
});
|
||||||
const packetsReceived = Number(item.packetsReceived) || 0;
|
const packetsReceived = Number(item.packetsReceived) || 0;
|
||||||
const packetsLost = Math.max(0, Number(item.packetsLost) || 0);
|
const packetsLost = Math.max(0, Number(item.packetsLost) || 0);
|
||||||
@@ -102,7 +109,7 @@ export class WebRTCStatsSampler {
|
|||||||
result[kind] = {
|
result[kind] = {
|
||||||
bitrateMbps,
|
bitrateMbps,
|
||||||
packetsLost,
|
packetsLost,
|
||||||
lossPercent: packetTotal > 0 ? (packetsLost * 100) / packetTotal : 0,
|
lossPercent: intervalLossPercent ?? (packetTotal > 0 ? (packetsLost * 100) / packetTotal : 0),
|
||||||
jitterMs: Number.isFinite(item.jitter) ? item.jitter * 1000 : null,
|
jitterMs: Number.isFinite(item.jitter) ? item.jitter * 1000 : null,
|
||||||
jitterBufferMs: averageJitterBufferMs(item),
|
jitterBufferMs: averageJitterBufferMs(item),
|
||||||
decodeMs,
|
decodeMs,
|
||||||
|
|||||||
@@ -180,18 +180,19 @@
|
|||||||
<select
|
<select
|
||||||
id="streamProfileSelect"
|
id="streamProfileSelect"
|
||||||
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
|
||||||
title="Resolution and frame-rate combinations supported by this computer"
|
title="Auto starts with the best supported resolution and adapts to WebRTC network conditions"
|
||||||
>
|
>
|
||||||
|
<option value="auto" selected>Auto (best)</option>
|
||||||
<option value="720:30">30 FPS - 720p</option>
|
<option value="720:30">30 FPS - 720p</option>
|
||||||
<option value="720:60">60 FPS - 720p</option>
|
<option value="720:60">60 FPS - 720p</option>
|
||||||
<option value="1080:30">30 FPS - 1080p</option>
|
<option value="1080:30">30 FPS - 1080p</option>
|
||||||
<option value="1080:60" selected>60 FPS - 1080p</option>
|
<option value="1080:60">60 FPS - 1080p</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="streamProfileDetail" class="mb-2 text-xs text-slate-500">Checking hardware encoder profiles…</div>
|
<div id="streamProfileDetail" class="mb-2 text-xs text-slate-500">Checking hardware encoder profiles…</div>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<label class="text-sm text-slate-300 w-20" for="bitrateSelect">Bitrate</label>
|
<label class="text-sm text-slate-300 w-20" for="bitrateSelect">Bitrate</label>
|
||||||
<select id="bitrateSelect" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100" title="H.264 target bitrate; Auto derives it from resolution and FPS">
|
<select id="bitrateSelect" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100" title="Auto adapts bitrate from receiver feedback when the stream profile is also Auto">
|
||||||
<option value="0" selected>Auto</option>
|
<option value="0" selected>Auto</option>
|
||||||
<option value="5">5 Mbps</option>
|
<option value="5">5 Mbps</option>
|
||||||
<option value="10">10 Mbps</option>
|
<option value="10">10 Mbps</option>
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ describe("remote desktop viewer control", () => {
|
|||||||
maxHeight: 1080,
|
maxHeight: 1080,
|
||||||
maxFps: 120,
|
maxFps: 120,
|
||||||
bitrateMbps: 0,
|
bitrateMbps: 0,
|
||||||
|
bitrateAdaptive: false,
|
||||||
lastFps: 1,
|
lastFps: 1,
|
||||||
lastFrameAt: 0,
|
lastFrameAt: 0,
|
||||||
startedAt: Date.now() - 5000,
|
startedAt: Date.now() - 5000,
|
||||||
@@ -175,6 +176,23 @@ describe("remote desktop viewer control", () => {
|
|||||||
expect(rdStreamingState.get(clientId)?.bitrateMbps).toBe(50);
|
expect(rdStreamingState.get(clientId)?.bitrateMbps).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("forwards adaptive bitrate mode to the agent", () => {
|
||||||
|
const clientId = `rd-adaptive-bitrate-${Date.now().toString(36)}`;
|
||||||
|
const { agentWs } = createClient(clientId);
|
||||||
|
const viewer = createMockWs({ clientId });
|
||||||
|
|
||||||
|
handleRemoteDesktopViewerOpen(viewer as any);
|
||||||
|
handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({
|
||||||
|
type: "desktop_set_bitrate",
|
||||||
|
bitrateMbps: 18,
|
||||||
|
adaptive: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const command = agentCommands(agentWs).find((msg) => msg.commandType === "desktop_set_bitrate");
|
||||||
|
expect(command?.payload).toMatchObject({ bitrateMbps: 18, adaptive: true });
|
||||||
|
expect(rdStreamingState.get(clientId)?.bitrateAdaptive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("forwards agent pipeline telemetry to every remote desktop viewer", () => {
|
test("forwards agent pipeline telemetry to every remote desktop viewer", () => {
|
||||||
const clientId = `rd-stats-${Date.now().toString(36)}`;
|
const clientId = `rd-stats-${Date.now().toString(36)}`;
|
||||||
createClient(clientId);
|
createClient(clientId);
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const rdStreamingState = new Map<string, {
|
|||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
maxFps: number;
|
maxFps: number;
|
||||||
bitrateMbps: number;
|
bitrateMbps: number;
|
||||||
|
bitrateAdaptive: boolean;
|
||||||
lastFps: number;
|
lastFps: number;
|
||||||
lastFrameAt: number;
|
lastFrameAt: number;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
@@ -212,6 +213,7 @@ function defaultRdStreamingState() {
|
|||||||
maxHeight: 0,
|
maxHeight: 0,
|
||||||
maxFps: 120,
|
maxFps: 120,
|
||||||
bitrateMbps: 0,
|
bitrateMbps: 0,
|
||||||
|
bitrateAdaptive: false,
|
||||||
lastFps: 0,
|
lastFps: 0,
|
||||||
lastFrameAt: 0,
|
lastFrameAt: 0,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
@@ -420,7 +422,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
}
|
}
|
||||||
safeSendViewer(ws, { type: "status", status: "starting" });
|
safeSendViewer(ws, { type: "status", status: "starting" });
|
||||||
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
|
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
|
||||||
sendDesktopCommand(target, "desktop_set_bitrate", { bitrateMbps: state.bitrateMbps || 0 });
|
sendDesktopCommand(target, "desktop_set_bitrate", { bitrateMbps: state.bitrateMbps || 0, adaptive: state.bitrateAdaptive });
|
||||||
sendDesktopCommand(target, "desktop_start", {});
|
sendDesktopCommand(target, "desktop_start", {});
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
state.startedAt = Date.now();
|
state.startedAt = Date.now();
|
||||||
@@ -432,7 +434,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
if (lastFrameAgeMs > 3000 && startAgeMs > 3000) {
|
if (lastFrameAgeMs > 3000 && startAgeMs > 3000) {
|
||||||
logger.info(`[rd-debug] desktop_start reasserting stale stream client=${clientId} lastFrameAgeMs=${Number.isFinite(lastFrameAgeMs) ? lastFrameAgeMs : -1} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
|
logger.info(`[rd-debug] desktop_start reasserting stale stream client=${clientId} lastFrameAgeMs=${Number.isFinite(lastFrameAgeMs) ? lastFrameAgeMs : -1} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
|
||||||
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
|
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
|
||||||
sendDesktopCommand(target, "desktop_set_bitrate", { bitrateMbps: state.bitrateMbps || 0 });
|
sendDesktopCommand(target, "desktop_set_bitrate", { bitrateMbps: state.bitrateMbps || 0, adaptive: state.bitrateAdaptive });
|
||||||
sendDesktopCommand(target, "desktop_start", {});
|
sendDesktopCommand(target, "desktop_start", {});
|
||||||
safeSendViewer(ws, { type: "status", status: "starting" });
|
safeSendViewer(ws, { type: "status", status: "starting" });
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
@@ -603,11 +605,13 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
}
|
}
|
||||||
case "desktop_set_bitrate": {
|
case "desktop_set_bitrate": {
|
||||||
const newBitrateMbps = Math.max(0, Math.min(50, Math.floor(Number((payload as any).bitrateMbps) || 0)));
|
const newBitrateMbps = Math.max(0, Math.min(50, Math.floor(Number((payload as any).bitrateMbps) || 0)));
|
||||||
if (state.bitrateMbps !== newBitrateMbps) {
|
const adaptive = (payload as any).adaptive === true;
|
||||||
sendDesktopCommand(target, "desktop_set_bitrate", { bitrateMbps: newBitrateMbps });
|
if (state.bitrateMbps !== newBitrateMbps || state.bitrateAdaptive !== adaptive) {
|
||||||
|
sendDesktopCommand(target, "desktop_set_bitrate", { bitrateMbps: newBitrateMbps, adaptive });
|
||||||
state.bitrateMbps = newBitrateMbps;
|
state.bitrateMbps = newBitrateMbps;
|
||||||
|
state.bitrateAdaptive = adaptive;
|
||||||
rdStreamingState.set(clientId, state);
|
rdStreamingState.set(clientId, state);
|
||||||
logger.debug(`[rd] set target bitrate=${newBitrateMbps || "auto"} Mbps`);
|
logger.debug(`[rd] set target bitrate=${newBitrateMbps || "auto"} Mbps adaptive=${adaptive}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,25 @@ To verify the selected route, open `chrome://webrtc-internals` during a relayed
|
|||||||
|
|
||||||
Automatic public-IP discovery is possible with STUN, but it is not a complete replacement for this setting: it can require random UDP ports, does not discover your intended domain, and can fail behind restrictive or symmetric NAT. For predictable production deployments, explicitly setting the reachable IP/domain and forwarding fixed UDP port `8189` is recommended.
|
Automatic public-IP discovery is possible with STUN, but it is not a complete replacement for this setting: it can require random UDP ports, does not discover your intended domain, and can fail behind restrictive or symmetric NAT. For predictable production deployments, explicitly setting the reachable IP/domain and forwarding fixed UDP port `8189` is recommended.
|
||||||
|
|
||||||
|
### Remote desktop network impairment tests
|
||||||
|
|
||||||
|
On a Linux test host, `scripts/rd-network-impairment.sh` applies repeatable bandwidth, latency, jitter, and packet-loss profiles with `tc netem`. Give it the exact interface carrying the WebRTC traffic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inspect interfaces first; do not guess the target.
|
||||||
|
ip link show
|
||||||
|
|
||||||
|
sudo bash scripts/rd-network-impairment.sh apply eth0 wan
|
||||||
|
bash scripts/rd-network-impairment.sh status eth0
|
||||||
|
|
||||||
|
# Always restore the interface after the test.
|
||||||
|
sudo bash scripts/rd-network-impairment.sh clear eth0
|
||||||
|
```
|
||||||
|
|
||||||
|
Available profiles are `wan` (20 Mbps), `constrained` (8 Mbps), and `harsh` (3 Mbps), with progressively higher delay, jitter, and loss. The rule affects all egress traffic on the selected interface, not only Overlord. The script refuses to touch loopback unless `OVERLORD_NETEM_ALLOW_LOOPBACK=1` is explicitly set.
|
||||||
|
|
||||||
|
For each profile, start Remote Desktop with both **Profile: Auto (best)** and **Bitrate: Auto**, then watch the Stats HUD. Verify that bitrate falls before the profile steps down, decode/processing queues remain bounded, and the profile slowly recovers after clearing the impairment.
|
||||||
|
|
||||||
### Advanced MediaMTX customization
|
### Advanced MediaMTX customization
|
||||||
|
|
||||||
The compose file passes a minimal set of `MTX_*` environment variables to MediaMTX — enough for Overlord to work. To change anything else (codecs, paths, ICE servers, etc.), either:
|
The compose file passes a minimal set of `MTX_*` environment variables to MediaMTX — enough for Overlord to work. To change anything else (codecs, paths, ICE servers, etc.), either:
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 apply <interface> <wan|constrained|harsh>"
|
||||||
|
echo " $0 status <interface>"
|
||||||
|
echo " $0 clear <interface>"
|
||||||
|
echo
|
||||||
|
echo "Profiles:"
|
||||||
|
echo " wan 20 mbit, 40 ms ± 10 ms, 0.5% loss"
|
||||||
|
echo " constrained 8 mbit, 80 ms ± 25 ms, 2.0% loss"
|
||||||
|
echo " harsh 3 mbit, 150 ms ± 50 ms, 5.0% loss"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
command_name=$1
|
||||||
|
interface_name=$2
|
||||||
|
|
||||||
|
if [[ ! $interface_name =~ ^[a-zA-Z0-9_.:-]+$ ]] || [[ ! -d "/sys/class/net/$interface_name" ]]; then
|
||||||
|
echo "Unknown or invalid network interface: $interface_name" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $interface_name == "lo" && ${OVERLORD_NETEM_ALLOW_LOOPBACK:-0} != "1" ]]; then
|
||||||
|
echo "Refusing to modify loopback without OVERLORD_NETEM_ALLOW_LOOPBACK=1" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v tc >/dev/null 2>&1; then
|
||||||
|
echo "tc was not found. Install the iproute2 package." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $command_name in
|
||||||
|
status)
|
||||||
|
tc qdisc show dev "$interface_name"
|
||||||
|
;;
|
||||||
|
clear)
|
||||||
|
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||||
|
echo "clear requires root (run with sudo)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tc qdisc del dev "$interface_name" root 2>/dev/null || true
|
||||||
|
echo "Cleared network impairment from $interface_name"
|
||||||
|
;;
|
||||||
|
apply)
|
||||||
|
if [[ $# -ne 3 ]]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||||
|
echo "apply requires root (run with sudo)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
profile_name=$3
|
||||||
|
case $profile_name in
|
||||||
|
wan)
|
||||||
|
rate=20mbit; delay=40ms; jitter=10ms; loss=0.5%
|
||||||
|
;;
|
||||||
|
constrained)
|
||||||
|
rate=8mbit; delay=80ms; jitter=25ms; loss=2%
|
||||||
|
;;
|
||||||
|
harsh)
|
||||||
|
rate=3mbit; delay=150ms; jitter=50ms; loss=5%
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown impairment profile: $profile_name" >&2
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
tc qdisc replace dev "$interface_name" root netem \
|
||||||
|
delay "$delay" "$jitter" distribution normal loss random "$loss" rate "$rate"
|
||||||
|
echo "Applied $profile_name impairment to $interface_name"
|
||||||
|
tc qdisc show dev "$interface_name"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user