test RTP timestamps
This commit is contained in:
@@ -205,12 +205,8 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindDesktop) {
|
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindDesktop) {
|
||||||
dur := time.Second / time.Duration(fps)
|
|
||||||
if dur <= 0 {
|
|
||||||
dur = 33 * time.Millisecond
|
|
||||||
}
|
|
||||||
sendStart := time.Now()
|
sendStart := time.Now()
|
||||||
if werr := webrtcpub.WriteH264(webrtcpub.KindDesktop, frame.Data, dur); werr != nil {
|
if werr := webrtcpub.WriteH264(webrtcpub.KindDesktop, frame.Data, now); werr != nil {
|
||||||
log.Printf("webrtc: write h264 failed: %v", werr)
|
log.Printf("webrtc: write h264 failed: %v", werr)
|
||||||
}
|
}
|
||||||
sendDur := time.Since(sendStart)
|
sendDur := time.Since(sendStart)
|
||||||
@@ -283,12 +279,8 @@ func sendCompletedFrame(ctx context.Context, env *rt.Env, frame wire.Frame, disp
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindDesktop) {
|
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindDesktop) {
|
||||||
dur := time.Second / time.Duration(fps)
|
|
||||||
if dur <= 0 {
|
|
||||||
dur = 33 * time.Millisecond
|
|
||||||
}
|
|
||||||
sendStart := time.Now()
|
sendStart := time.Now()
|
||||||
if werr := webrtcpub.WriteH264(webrtcpub.KindDesktop, frame.Data, dur); werr != nil {
|
if werr := webrtcpub.WriteH264(webrtcpub.KindDesktop, frame.Data, now); werr != nil {
|
||||||
log.Printf("webrtc: write h264 failed: %v", werr)
|
log.Printf("webrtc: write h264 failed: %v", werr)
|
||||||
}
|
}
|
||||||
sendDur := time.Since(sendStart)
|
sendDur := time.Since(sendStart)
|
||||||
@@ -1929,11 +1921,7 @@ func captureAndSendVirtual(ctx context.Context, env *rt.Env) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.Kindbackstage) {
|
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.Kindbackstage) {
|
||||||
dur := time.Second / time.Duration(fps)
|
if werr := webrtcpub.WriteH264(webrtcpub.Kindbackstage, frame.Data, now); werr != nil {
|
||||||
if dur <= 0 {
|
|
||||||
dur = 33 * time.Millisecond
|
|
||||||
}
|
|
||||||
if werr := webrtcpub.WriteH264(webrtcpub.Kindbackstage, frame.Data, dur); werr != nil {
|
|
||||||
log.Printf("webrtc: write hidden h264 failed: %v", werr)
|
log.Printf("webrtc: write hidden h264 failed: %v", werr)
|
||||||
}
|
}
|
||||||
if slotAcquired {
|
if slotAcquired {
|
||||||
@@ -1976,11 +1964,7 @@ func virtualSendCompletedFrame(ctx context.Context, env *rt.Env, frame wire.Fram
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.Kindbackstage) {
|
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.Kindbackstage) {
|
||||||
dur := time.Second / time.Duration(fps)
|
if werr := webrtcpub.WriteH264(webrtcpub.Kindbackstage, frame.Data, now); werr != nil {
|
||||||
if dur <= 0 {
|
|
||||||
dur = 33 * time.Millisecond
|
|
||||||
}
|
|
||||||
if werr := webrtcpub.WriteH264(webrtcpub.Kindbackstage, frame.Data, dur); werr != nil {
|
|
||||||
log.Printf("webrtc: write hidden h264 failed: %v", werr)
|
log.Printf("webrtc: write hidden h264 failed: %v", werr)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -2079,11 +2063,7 @@ func captureAndSendbackstage(ctx context.Context, env *rt.Env) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.Kindbackstage) {
|
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.Kindbackstage) {
|
||||||
dur := time.Second / time.Duration(fps)
|
if werr := webrtcpub.WriteH264(webrtcpub.Kindbackstage, frame.Data, now); werr != nil {
|
||||||
if dur <= 0 {
|
|
||||||
dur = 33 * time.Millisecond
|
|
||||||
}
|
|
||||||
if werr := webrtcpub.WriteH264(webrtcpub.Kindbackstage, frame.Data, dur); werr != nil {
|
|
||||||
log.Printf("webrtc: write backstage h264 failed: %v", werr)
|
log.Printf("webrtc: write backstage h264 failed: %v", werr)
|
||||||
}
|
}
|
||||||
if slotAcquired {
|
if slotAcquired {
|
||||||
|
|||||||
@@ -187,8 +187,7 @@ func NowWebcam(ctx context.Context, env *rt.Env) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if outFormat == "h264" && webrtcpub.IsActive(webrtcpub.KindWebcam) {
|
if outFormat == "h264" && webrtcpub.IsActive(webrtcpub.KindWebcam) {
|
||||||
dur := time.Second / time.Duration(frameFPS)
|
if werr := webrtcpub.WriteH264(webrtcpub.KindWebcam, frameBytes, time.Now()); werr != nil {
|
||||||
if werr := webrtcpub.WriteH264(webrtcpub.KindWebcam, frameBytes, dur); werr != nil {
|
|
||||||
log.Printf("webrtc: write webcam h264 failed: %v", werr)
|
log.Printf("webrtc: write webcam h264 failed: %v", werr)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -182,6 +183,11 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p
|
|||||||
log.Printf("webrtc: P2P congestion target=%d Mbps applied=%d Mbps", bps/1_000_000, applied/1_000_000)
|
log.Printf("webrtc: P2P congestion target=%d Mbps applied=%d Mbps", bps/1_000_000, applied/1_000_000)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
OnInput: func(data []byte) {
|
||||||
|
if kind == webrtcpub.KindDesktop {
|
||||||
|
handleP2PDesktopInput(env, data)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
answerSDP, err := webrtcpub.StartP2POffer(ctx, kind, sessionID, offerSDP, callbacks, hasVideo, hasAudio)
|
answerSDP, err := webrtcpub.StartP2POffer(ctx, kind, sessionID, offerSDP, callbacks, hasVideo, hasAudio)
|
||||||
@@ -206,6 +212,67 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type p2pDesktopInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
X int32 `json:"x"`
|
||||||
|
Y int32 `json:"y"`
|
||||||
|
Button int `json:"button"`
|
||||||
|
Delta int32 `json:"delta"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleP2PDesktopInput(env *runtime.Env, data []byte) {
|
||||||
|
if env == nil || len(data) == 0 || len(data) > 4096 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input p2pDesktopInput
|
||||||
|
if err := json.Unmarshal(data, &input); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
move := func() {
|
||||||
|
absX, absY := resolveDesktopPoint(env.SelectedDisplay, input.X, input.Y)
|
||||||
|
setCursorPos(absX, absY)
|
||||||
|
}
|
||||||
|
switch input.Type {
|
||||||
|
case "mouse_move":
|
||||||
|
if env.MouseControl {
|
||||||
|
move()
|
||||||
|
}
|
||||||
|
case "mouse_down":
|
||||||
|
if env.MouseControl {
|
||||||
|
move()
|
||||||
|
sendMouseDown(input.Button)
|
||||||
|
}
|
||||||
|
case "mouse_up":
|
||||||
|
if env.MouseControl {
|
||||||
|
move()
|
||||||
|
sendMouseUp(input.Button)
|
||||||
|
}
|
||||||
|
case "mouse_wheel":
|
||||||
|
if env.MouseControl {
|
||||||
|
move()
|
||||||
|
sendMouseWheel(input.Delta)
|
||||||
|
}
|
||||||
|
case "key_down":
|
||||||
|
if env.KeyboardControl {
|
||||||
|
if vk := keyCodeToVK(input.Code); vk != 0 {
|
||||||
|
sendKeyDown(vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "key_up":
|
||||||
|
if env.KeyboardControl {
|
||||||
|
if vk := keyCodeToVK(input.Code); vk != 0 {
|
||||||
|
sendKeyUp(vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "text_input":
|
||||||
|
if env.KeyboardControl && input.Text != "" {
|
||||||
|
sendTextInput(input.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseICEServers(value interface{}) []webrtcpub.ICEServer {
|
func parseICEServers(value interface{}) []webrtcpub.ICEServer {
|
||||||
items, ok := value.([]interface{})
|
items, ok := value.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//go:build overlord_webrtc
|
||||||
|
|
||||||
|
package webrtcpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
"github.com/pion/rtp/codecs"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
h264ClockRate = 90_000
|
||||||
|
h264RTPMTU = 1200
|
||||||
|
)
|
||||||
|
|
||||||
|
type rtpPacketSink interface {
|
||||||
|
WriteRTP(*rtp.Packet) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type h264TrackWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
t rtpPacketSink
|
||||||
|
packetizer rtp.Packetizer
|
||||||
|
timestamp uint32
|
||||||
|
lastCapture time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newH264TrackWriter(track *webrtc.TrackLocalStaticRTP) *h264TrackWriter {
|
||||||
|
return newH264TrackWriterWithSink(track, randomRTPVideoTimestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newH264TrackWriterWithSink(sink rtpPacketSink, initialTimestamp uint32) *h264TrackWriter {
|
||||||
|
return &h264TrackWriter{
|
||||||
|
t: sink,
|
||||||
|
packetizer: rtp.NewPacketizer(
|
||||||
|
h264RTPMTU, 0, initialTimestamp, &codecs.H264Payloader{},
|
||||||
|
rtp.NewRandomSequencer(), h264ClockRate,
|
||||||
|
),
|
||||||
|
timestamp: initialTimestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomRTPVideoTimestamp() uint32 {
|
||||||
|
var value [4]byte
|
||||||
|
if _, err := rand.Read(value[:]); err == nil {
|
||||||
|
return binary.BigEndian.Uint32(value[:])
|
||||||
|
}
|
||||||
|
return uint32(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *h264TrackWriter) WriteH264(nalu []byte, capturedAt time.Time) error {
|
||||||
|
if len(nalu) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if capturedAt.IsZero() {
|
||||||
|
capturedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if !w.lastCapture.IsZero() && capturedAt.After(w.lastCapture) {
|
||||||
|
ticks := uint64(capturedAt.Sub(w.lastCapture).Seconds() * h264ClockRate)
|
||||||
|
if ticks == 0 {
|
||||||
|
ticks = 1
|
||||||
|
}
|
||||||
|
w.timestamp += uint32(ticks)
|
||||||
|
}
|
||||||
|
w.lastCapture = capturedAt
|
||||||
|
|
||||||
|
packets := w.packetizer.Packetize(nalu, 0)
|
||||||
|
var writeErrs []error
|
||||||
|
for _, packet := range packets {
|
||||||
|
packet.Timestamp = w.timestamp
|
||||||
|
if err := w.t.WriteRTP(packet); err != nil {
|
||||||
|
writeErrs = append(writeErrs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(writeErrs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//go:build overlord_webrtc
|
||||||
|
|
||||||
|
package webrtcpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingRTPSink struct {
|
||||||
|
packets []*rtp.Packet
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingRTPSink) WriteRTP(packet *rtp.Packet) error {
|
||||||
|
clone := *packet
|
||||||
|
clone.Payload = append([]byte(nil), packet.Payload...)
|
||||||
|
s.packets = append(s.packets, &clone)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestH264TrackWriterUsesCaptureClockForTimestamps(t *testing.T) {
|
||||||
|
sink := &recordingRTPSink{}
|
||||||
|
writer := newH264TrackWriterWithSink(sink, 1000)
|
||||||
|
start := time.Unix(100, 0)
|
||||||
|
|
||||||
|
if err := writer.WriteH264(annexBKey(1), start); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
firstCount := len(sink.packets)
|
||||||
|
if firstCount == 0 {
|
||||||
|
t.Fatal("first frame produced no RTP packets")
|
||||||
|
}
|
||||||
|
if err := writer.WriteH264(annexBDelta(2), start.Add(250*time.Millisecond)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sink.packets) <= firstCount {
|
||||||
|
t.Fatal("second frame produced no RTP packets")
|
||||||
|
}
|
||||||
|
if got, want := sink.packets[firstCount].Timestamp-sink.packets[0].Timestamp, uint32(22500); got != want {
|
||||||
|
t.Fatalf("capture-clock RTP delta=%d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,12 +126,12 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
videoTrack *webrtc.TrackLocalStaticSample
|
videoTrack *webrtc.TrackLocalStaticRTP
|
||||||
audioTrack *webrtc.TrackLocalStaticSample
|
audioTrack *webrtc.TrackLocalStaticSample
|
||||||
audioWriter AudioWriter
|
audioWriter AudioWriter
|
||||||
)
|
)
|
||||||
if hasVideo {
|
if hasVideo {
|
||||||
videoTrack, err = webrtc.NewTrackLocalStaticSample(
|
videoTrack, err = webrtc.NewTrackLocalStaticRTP(
|
||||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||||
"overlord-video-p2p-"+string(kind), "overlord-"+string(kind),
|
"overlord-video-p2p-"+string(kind), "overlord-"+string(kind),
|
||||||
)
|
)
|
||||||
@@ -214,6 +214,25 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st
|
|||||||
sess.closeAndUnregister()
|
sess.closeAndUnregister()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
pc.OnDataChannel(func(channel *webrtc.DataChannel) {
|
||||||
|
if opts.OnInput == nil || (channel.Label() != "overlord-input-reliable" && channel.Label() != "overlord-input-motion") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if channel.Label() == "overlord-input-reliable" {
|
||||||
|
channel.OnOpen(func() {
|
||||||
|
if err := channel.SendText(`{"type":"input_ready","version":1}`); err != nil {
|
||||||
|
log.Printf("webrtcpub: P2P input readiness send failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
channel.OnMessage(func(message webrtc.DataChannelMessage) {
|
||||||
|
if len(message.Data) == 0 || len(message.Data) > 4096 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := append([]byte(nil), message.Data...)
|
||||||
|
opts.OnInput(payload)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||||
Type: webrtc.SDPTypeOffer,
|
Type: webrtc.SDPTypeOffer,
|
||||||
@@ -238,7 +257,7 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st
|
|||||||
p2pMu.Unlock()
|
p2pMu.Unlock()
|
||||||
writerID := p2pWriterID(kind, sessionID)
|
writerID := p2pWriterID(kind, sessionID)
|
||||||
if videoTrack != nil {
|
if videoTrack != nil {
|
||||||
registerVideoWriter(kind, writerID, &h264TrackWriter{t: videoTrack})
|
registerVideoWriter(kind, writerID, newH264TrackWriter(videoTrack))
|
||||||
}
|
}
|
||||||
if audioTrack != nil {
|
if audioTrack != nil {
|
||||||
registerAudioWriter(kind, writerID, audioWriter)
|
registerAudioWriter(kind, writerID, audioWriter)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type VideoWriter interface {
|
type VideoWriter interface {
|
||||||
WriteH264(nalu []byte, dur time.Duration) error
|
WriteH264(nalu []byte, capturedAt time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioWriter interface {
|
type AudioWriter interface {
|
||||||
@@ -30,11 +30,13 @@ type writerEntry struct {
|
|||||||
audio AudioWriter
|
audio AudioWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxPendingVideoFrames = 6
|
// Keep at most one frame behind the frame currently being packetized. Remote
|
||||||
|
// desktop latency is more important than preserving stale predictive frames.
|
||||||
|
const maxPendingVideoFrames = 1
|
||||||
|
|
||||||
type queuedVideoFrame struct {
|
type queuedVideoFrame struct {
|
||||||
data []byte
|
data []byte
|
||||||
dur time.Duration
|
capturedAt time.Time
|
||||||
key bool
|
key bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ func newLatestVideoWriter(kind Kind, writer VideoWriter) *latestVideoWriter {
|
|||||||
return queued
|
return queued
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *latestVideoWriter) enqueue(frame []byte, dur time.Duration) {
|
func (w *latestVideoWriter) enqueue(frame []byte, capturedAt time.Time) {
|
||||||
isKey := h264util.IsIDR(frame)
|
isKey := h264util.IsIDR(frame)
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
if w.closed {
|
if w.closed {
|
||||||
@@ -69,10 +71,22 @@ func (w *latestVideoWriter) enqueue(frame []byte, dur time.Duration) {
|
|||||||
|
|
||||||
requestKeyframe := false
|
requestKeyframe := false
|
||||||
if len(w.pending) >= maxPendingVideoFrames {
|
if len(w.pending) >= maxPendingVideoFrames {
|
||||||
|
// Never evict a recovery IDR in favor of a dependent P-frame. It is
|
||||||
|
// already the newest independently decodable state available. Since the
|
||||||
|
// discarded P-frame can be referenced later, request another recovery
|
||||||
|
// point before accepting subsequent deltas.
|
||||||
|
if w.pending[len(w.pending)-1].key && !isKey {
|
||||||
|
w.needsKeyframe = true
|
||||||
|
w.mu.Unlock()
|
||||||
|
RequestKeyframe(w.kind)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.pending = nil
|
w.pending = nil
|
||||||
|
if isKey {
|
||||||
|
w.needsKeyframe = false
|
||||||
|
} else {
|
||||||
w.needsKeyframe = true
|
w.needsKeyframe = true
|
||||||
requestKeyframe = true
|
requestKeyframe = true
|
||||||
if !isKey {
|
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
RequestKeyframe(w.kind)
|
RequestKeyframe(w.kind)
|
||||||
return
|
return
|
||||||
@@ -83,7 +97,7 @@ func (w *latestVideoWriter) enqueue(frame []byte, dur time.Duration) {
|
|||||||
w.needsKeyframe = false
|
w.needsKeyframe = false
|
||||||
requestKeyframe = false
|
requestKeyframe = false
|
||||||
}
|
}
|
||||||
w.pending = append(w.pending, queuedVideoFrame{data: frame, dur: dur, key: isKey})
|
w.pending = append(w.pending, queuedVideoFrame{data: frame, capturedAt: capturedAt, key: isKey})
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if requestKeyframe {
|
if requestKeyframe {
|
||||||
RequestKeyframe(w.kind)
|
RequestKeyframe(w.kind)
|
||||||
@@ -121,7 +135,7 @@ func (w *latestVideoWriter) run() {
|
|||||||
w.pending[0] = queuedVideoFrame{}
|
w.pending[0] = queuedVideoFrame{}
|
||||||
w.pending = w.pending[1:]
|
w.pending = w.pending[1:]
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
err := w.writer.WriteH264(frame.data, frame.dur)
|
err := w.writer.WriteH264(frame.data, frame.capturedAt)
|
||||||
requestKeyframe := false
|
requestKeyframe := false
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,7 +243,7 @@ func ConsumeKeyframeRequest(kind Kind) bool {
|
|||||||
return keyframeRequestFlag(kind).Swap(false)
|
return keyframeRequestFlag(kind).Swap(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteH264(kind Kind, nalu []byte, dur time.Duration) error {
|
func WriteH264(kind Kind, nalu []byte, capturedAt time.Time) error {
|
||||||
if len(nalu) == 0 {
|
if len(nalu) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -244,7 +258,7 @@ func WriteH264(kind Kind, nalu []byte, dur time.Duration) error {
|
|||||||
}
|
}
|
||||||
writersMu.RUnlock()
|
writersMu.RUnlock()
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
target.enqueue(frame, dur)
|
target.enqueue(frame, capturedAt)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -302,4 +316,5 @@ type P2POfferCallbacks struct {
|
|||||||
OnICE func(c ICECandidate)
|
OnICE func(c ICECandidate)
|
||||||
OnClose func()
|
OnClose func()
|
||||||
OnBandwidthEstimate func(bps int)
|
OnBandwidthEstimate func(bps int)
|
||||||
|
OnInput func(payload []byte)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type blockingVideoWriter struct {
|
|||||||
frames [][]byte
|
frames [][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *blockingVideoWriter) WriteH264(frame []byte, _ time.Duration) error {
|
func (w *blockingVideoWriter) WriteH264(frame []byte, _ time.Time) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
w.frames = append(w.frames, append([]byte(nil), frame...))
|
w.frames = append(w.frames, append([]byte(nil), frame...))
|
||||||
count := len(w.frames)
|
count := len(w.frames)
|
||||||
@@ -46,7 +46,7 @@ func TestWriteH264DoesNotBlockAndRequestsRecoveryAfterDroppingDelta(t *testing.T
|
|||||||
defer unregisterWriter(kind, id)
|
defer unregisterWriter(kind, id)
|
||||||
_ = ConsumeKeyframeRequest(kind)
|
_ = ConsumeKeyframeRequest(kind)
|
||||||
|
|
||||||
if err := WriteH264(kind, annexBDelta(1), time.Millisecond); err != nil {
|
if err := WriteH264(kind, annexBDelta(1), time.Now()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
@@ -57,7 +57,7 @@ func TestWriteH264DoesNotBlockAndRequestsRecoveryAfterDroppingDelta(t *testing.T
|
|||||||
|
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
for frame := 2; frame <= maxPendingVideoFrames+2; frame++ {
|
for frame := 2; frame <= maxPendingVideoFrames+2; frame++ {
|
||||||
_ = WriteH264(kind, annexBDelta(byte(frame)), time.Millisecond)
|
_ = WriteH264(kind, annexBDelta(byte(frame)), time.Now())
|
||||||
}
|
}
|
||||||
if elapsed := time.Since(started); elapsed > 100*time.Millisecond {
|
if elapsed := time.Since(started); elapsed > 100*time.Millisecond {
|
||||||
t.Fatalf("capture path blocked for %s", elapsed)
|
t.Fatalf("capture path blocked for %s", elapsed)
|
||||||
@@ -65,7 +65,7 @@ func TestWriteH264DoesNotBlockAndRequestsRecoveryAfterDroppingDelta(t *testing.T
|
|||||||
if !ConsumeKeyframeRequest(kind) {
|
if !ConsumeKeyframeRequest(kind) {
|
||||||
t.Fatal("dropping a predictive frame did not request a keyframe")
|
t.Fatal("dropping a predictive frame did not request a keyframe")
|
||||||
}
|
}
|
||||||
_ = WriteH264(kind, annexBKey(9), time.Millisecond)
|
_ = WriteH264(kind, annexBKey(9), time.Now())
|
||||||
close(writer.release)
|
close(writer.release)
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
deadline := time.Now().Add(time.Second)
|
||||||
@@ -84,6 +84,40 @@ func TestWriteH264DoesNotBlockAndRequestsRecoveryAfterDroppingDelta(t *testing.T
|
|||||||
t.Fatal("recovery keyframe was not delivered")
|
t.Fatal("recovery keyframe was not delivered")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLatestFrameQueuePreservesPendingRecoveryFrame(t *testing.T) {
|
||||||
|
kind := Kind("keyframe-queue-test")
|
||||||
|
id := "slow-writer"
|
||||||
|
writer := &blockingVideoWriter{started: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
registerVideoWriter(kind, id, writer)
|
||||||
|
defer unregisterWriter(kind, id)
|
||||||
|
_ = ConsumeKeyframeRequest(kind)
|
||||||
|
|
||||||
|
_ = WriteH264(kind, annexBDelta(1), time.Now())
|
||||||
|
select {
|
||||||
|
case <-writer.started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not receive first frame")
|
||||||
|
}
|
||||||
|
_ = WriteH264(kind, annexBKey(9), time.Now())
|
||||||
|
_ = WriteH264(kind, annexBDelta(3), time.Now())
|
||||||
|
close(writer.release)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
writer.mu.Lock()
|
||||||
|
frames := append([][]byte(nil), writer.frames...)
|
||||||
|
writer.mu.Unlock()
|
||||||
|
if len(frames) >= 2 {
|
||||||
|
if !h264util.IsIDR(frames[1]) {
|
||||||
|
t.Fatalf("pending recovery frame was replaced by a delta: %v", frames)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("pending recovery frame was not delivered")
|
||||||
|
}
|
||||||
|
|
||||||
func TestKeyframeRequestsAreIsolatedByStreamKind(t *testing.T) {
|
func TestKeyframeRequestsAreIsolatedByStreamKind(t *testing.T) {
|
||||||
for _, kind := range []Kind{KindDesktop, Kindbackstage, KindWebcam} {
|
for _, kind := range []Kind{KindDesktop, Kindbackstage, KindWebcam} {
|
||||||
_ = ConsumeKeyframeRequest(kind)
|
_ = ConsumeKeyframeRequest(kind)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
"github.com/pion/interceptor"
|
"github.com/pion/interceptor"
|
||||||
"github.com/pion/rtcp"
|
"github.com/pion/rtcp"
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
"github.com/pion/webrtc/v4/pkg/media"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func drainRTCP(sender *webrtc.RTPSender, kind Kind) {
|
func drainRTCP(sender *webrtc.RTPSender, kind Kind) {
|
||||||
@@ -98,14 +97,6 @@ func honorRTCPKeyframes() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type h264TrackWriter struct {
|
|
||||||
t *webrtc.TrackLocalStaticSample
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *h264TrackWriter) WriteH264(nalu []byte, dur time.Duration) error {
|
|
||||||
return w.t.WriteSample(media.Sample{Data: nalu, Duration: dur})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Publisher struct {
|
type Publisher struct {
|
||||||
kind Kind
|
kind Kind
|
||||||
pc *webrtc.PeerConnection
|
pc *webrtc.PeerConnection
|
||||||
@@ -187,12 +178,12 @@ func Start(ctx context.Context, kind Kind, opts Options) (*Publisher, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
videoTrack *webrtc.TrackLocalStaticSample
|
videoTrack *webrtc.TrackLocalStaticRTP
|
||||||
audioTrack *webrtc.TrackLocalStaticSample
|
audioTrack *webrtc.TrackLocalStaticSample
|
||||||
audioWriter AudioWriter
|
audioWriter AudioWriter
|
||||||
)
|
)
|
||||||
if opts.HasVideo {
|
if opts.HasVideo {
|
||||||
videoTrack, err = webrtc.NewTrackLocalStaticSample(
|
videoTrack, err = webrtc.NewTrackLocalStaticRTP(
|
||||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||||
"overlord-video-"+string(kind), "overlord-"+string(kind),
|
"overlord-video-"+string(kind), "overlord-"+string(kind),
|
||||||
)
|
)
|
||||||
@@ -296,7 +287,7 @@ func Start(ctx context.Context, kind Kind, opts Options) (*Publisher, error) {
|
|||||||
whipByKind[kind] = pub
|
whipByKind[kind] = pub
|
||||||
whipMu.Unlock()
|
whipMu.Unlock()
|
||||||
if videoTrack != nil {
|
if videoTrack != nil {
|
||||||
registerVideoWriter(kind, writerID, &h264TrackWriter{t: videoTrack})
|
registerVideoWriter(kind, writerID, newH264TrackWriter(videoTrack))
|
||||||
}
|
}
|
||||||
if audioTrack != nil {
|
if audioTrack != nil {
|
||||||
registerAudioWriter(kind, writerID, audioWriter)
|
registerAudioWriter(kind, writerID, audioWriter)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ require (
|
|||||||
github.com/jezek/xgb v1.1.1
|
github.com/jezek/xgb v1.1.1
|
||||||
github.com/pion/interceptor v0.1.37
|
github.com/pion/interceptor v0.1.37
|
||||||
github.com/pion/rtcp v1.2.15
|
github.com/pion/rtcp v1.2.15
|
||||||
|
github.com/pion/rtp v1.8.10
|
||||||
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
|
||||||
github.com/thesyncim/gopus v0.1.1
|
github.com/thesyncim/gopus v0.1.1
|
||||||
@@ -38,7 +39,6 @@ require (
|
|||||||
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
|
||||||
github.com/pion/rtp v1.8.10 // indirect
|
|
||||||
github.com/pion/sctp v1.8.35 // indirect
|
github.com/pion/sctp v1.8.35 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
let frameWidth = 0;
|
let frameWidth = 0;
|
||||||
let frameHeight = 0;
|
let frameHeight = 0;
|
||||||
let latencyAvg = null;
|
let latencyAvg = null;
|
||||||
|
let directInputActive = false;
|
||||||
let smoothingPct = 0;
|
let smoothingPct = 0;
|
||||||
let smoothPoint = null;
|
let smoothPoint = null;
|
||||||
let pendingMove = null;
|
let pendingMove = null;
|
||||||
@@ -1173,7 +1174,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
if (ms < 0 || !Number.isFinite(ms)) return;
|
if (ms < 0 || !Number.isFinite(ms)) return;
|
||||||
latencyAvg = latencyAvg == null ? ms : latencyAvg * 0.8 + ms * 0.2;
|
latencyAvg = latencyAvg == null ? ms : latencyAvg * 0.8 + ms * 0.2;
|
||||||
if (inputLatency) {
|
if (inputLatency) {
|
||||||
inputLatency.textContent = `${Math.round(latencyAvg)} ms`;
|
inputLatency.textContent = directInputActive ? "P2P direct" : `${Math.round(latencyAvg)} ms`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1286,7 +1287,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
if (fsHudFps) {
|
if (fsHudFps) {
|
||||||
fsHudFps.textContent = `${snapshot.fps == null ? "--" : Math.round(snapshot.fps)} → ${snapshot.viewerRate == null ? "--" : Math.round(snapshot.viewerRate)} FPS`;
|
fsHudFps.textContent = `${snapshot.fps == null ? "--" : Math.round(snapshot.fps)} → ${snapshot.viewerRate == null ? "--" : Math.round(snapshot.viewerRate)} FPS`;
|
||||||
}
|
}
|
||||||
if (fsHudLatency) fsHudLatency.textContent = `${msText(latencyAvg)} input`;
|
if (fsHudLatency) fsHudLatency.textContent = directInputActive ? "P2P direct input" : `${msText(latencyAvg)} input`;
|
||||||
if (fsHudNetwork) {
|
if (fsHudNetwork) {
|
||||||
const networkParts = [];
|
const networkParts = [];
|
||||||
if (snapshot.bitrate != null) networkParts.push(`${snapshot.bitrate.toFixed(1)} Mbps`);
|
if (snapshot.bitrate != null) networkParts.push(`${snapshot.bitrate.toFixed(1)} Mbps`);
|
||||||
@@ -1395,14 +1396,15 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
} else if ((finite(media.lossPercent) ?? 0) > 3 || (finite(network.rttMs) ?? 0) > 150 || (finite(media.jitterMs) ?? 0) > 35) {
|
} else if ((finite(media.lossPercent) ?? 0) > 3 || (finite(network.rttMs) ?? 0) > 150 || (finite(media.jitterMs) ?? 0) > 35) {
|
||||||
summary = "Network conditions are causing delay";
|
summary = "Network conditions are causing delay";
|
||||||
severity = "bad";
|
severity = "bad";
|
||||||
|
} else if ((finite(media.jitterBufferMs) ?? 0) > Math.max(80, frameBudget * 4)) {
|
||||||
|
summary = `Browser playout buffer is adding ${Math.round(media.jitterBufferMs)} ms`;
|
||||||
|
severity = "bad";
|
||||||
} else if (
|
} else if (
|
||||||
mode === "off" &&
|
|
||||||
fps != null &&
|
fps != null &&
|
||||||
viewerRate != null &&
|
viewerRate != null &&
|
||||||
viewerRate < fps * 0.8 &&
|
viewerRate < fps * 0.8
|
||||||
diagnostics.coalescedFrames > 0
|
|
||||||
) {
|
) {
|
||||||
summary = `Display is presenting ${Math.round(viewerRate)} of ${Math.round(fps)} decoded FPS`;
|
summary = `Viewer is presenting ${Math.round(viewerRate)} of ${Math.round(fps)} FPS`;
|
||||||
severity = "warn";
|
severity = "warn";
|
||||||
} else if ((queue ?? 0) > 2 || (renderMs != null && renderMs > frameBudget)) {
|
} else if ((queue ?? 0) > 2 || (renderMs != null && renderMs > frameBudget)) {
|
||||||
summary = "Viewer render queue is backing up";
|
summary = "Viewer render queue is backing up";
|
||||||
@@ -1432,7 +1434,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
diagnosticEls.Queue.textContent = queue == null ? "managed by browser" : String(queue);
|
diagnosticEls.Queue.textContent = queue == null ? "managed by browser" : String(queue);
|
||||||
diagnosticEls.Dropped.textContent = `${Math.round(dropped)} / ${diagnostics.coalescedFrames}`;
|
diagnosticEls.Dropped.textContent = `${Math.round(dropped)} / ${diagnostics.coalescedFrames}`;
|
||||||
diagnosticEls.Fps.textContent = `${fps == null ? "--" : Math.round(fps)} → ${viewerRate == null ? "--" : Math.round(viewerRate)}`;
|
diagnosticEls.Fps.textContent = `${fps == null ? "--" : Math.round(fps)} → ${viewerRate == null ? "--" : Math.round(viewerRate)}`;
|
||||||
diagnosticEls.Input.textContent = msText(latencyAvg);
|
diagnosticEls.Input.textContent = directInputActive ? "P2P direct" : msText(latencyAvg);
|
||||||
renderFullscreenHud();
|
renderFullscreenHud();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,6 +1704,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = { type, ...payload };
|
const msg = { type, ...payload };
|
||||||
|
if (highFrequencyInputTypes.has(type) && getWebrtcMode() === "p2p" && p2pClient?.sendInput(msg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (type !== "desktop_decode_pressure" && !highFrequencyInputTypes.has(type)) {
|
if (type !== "desktop_decode_pressure" && !highFrequencyInputTypes.has(type)) {
|
||||||
rdDebug("send", {
|
rdDebug("send", {
|
||||||
msg,
|
msg,
|
||||||
@@ -1808,6 +1813,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
if (!webrtcVideo) return;
|
if (!webrtcVideo) return;
|
||||||
p2pClient = new P2PClient({
|
p2pClient = new P2PClient({
|
||||||
videoEl: webrtcVideo,
|
videoEl: webrtcVideo,
|
||||||
|
enableInput: true,
|
||||||
send: (msg) => {
|
send: (msg) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(encodeMsgpack(msg));
|
ws.send(encodeMsgpack(msg));
|
||||||
@@ -1815,6 +1821,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
},
|
},
|
||||||
onState: (s) => onWebrtcState("WebRTC P2P", s),
|
onState: (s) => onWebrtcState("WebRTC P2P", s),
|
||||||
onStats: updateNetworkStats,
|
onStats: updateNetworkStats,
|
||||||
|
onInputState: setDirectInputActive,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await p2pClient.start();
|
await p2pClient.start();
|
||||||
@@ -1830,6 +1837,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stopAllWebrtc() {
|
async function stopAllWebrtc() {
|
||||||
|
setDirectInputActive(false);
|
||||||
stopWebrtcFrameTicker();
|
stopWebrtcFrameTicker();
|
||||||
setWebrtcViewActive(false);
|
setWebrtcViewActive(false);
|
||||||
const w = whepClient;
|
const w = whepClient;
|
||||||
@@ -3407,6 +3415,12 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDirectInputActive(active) {
|
||||||
|
directInputActive = !!active;
|
||||||
|
if (inputLatency) inputLatency.textContent = directInputActive ? "P2P direct" : msText(latencyAvg);
|
||||||
|
renderDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
function getContainedSurfaceRect(surface, sourceWidth, sourceHeight, elementRect = surface.getBoundingClientRect()) {
|
function getContainedSurfaceRect(surface, sourceWidth, sourceHeight, elementRect = surface.getBoundingClientRect()) {
|
||||||
if (!elementRect.width || !elementRect.height || !sourceWidth || !sourceHeight) return elementRect;
|
if (!elementRect.width || !elementRect.height || !sourceWidth || !sourceHeight) return elementRect;
|
||||||
const fit = getComputedStyle(surface).objectFit;
|
const fit = getComputedStyle(surface).objectFit;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { P2PClient } from "./webrtc-p2p.js";
|
||||||
|
import { WebRTCStatsSampler } from "./webrtc-stats.js";
|
||||||
|
|
||||||
|
describe("WebRTC low-latency paths", () => {
|
||||||
|
test("routes motion and reliable input over separate data channels", () => {
|
||||||
|
const client = new P2PClient({ send: () => {}, enableInput: true });
|
||||||
|
const motion: string[] = [];
|
||||||
|
const reliable: string[] = [];
|
||||||
|
client.motionInput = { readyState: "open", bufferedAmount: 0, send: (value: string) => motion.push(value) };
|
||||||
|
client.reliableInput = { readyState: "open", bufferedAmount: 0, send: (value: string) => reliable.push(value) };
|
||||||
|
|
||||||
|
client.inputReady = false;
|
||||||
|
expect(client.sendInput({ type: "key_down", code: "KeyA" })).toBe(false);
|
||||||
|
client.inputReady = true;
|
||||||
|
expect(client.sendInput({ type: "mouse_move", x: 10, y: 20 })).toBe(true);
|
||||||
|
expect(client.sendInput({ type: "key_down", code: "KeyA" })).toBe(true);
|
||||||
|
expect(JSON.parse(motion[0])).toEqual({ type: "mouse_move", x: 10, y: 20 });
|
||||||
|
expect(JSON.parse(reliable[0])).toEqual({ type: "key_down", code: "KeyA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports interval jitter-buffer delay instead of lifetime average", async () => {
|
||||||
|
let reportIndex = 0;
|
||||||
|
const reports = [
|
||||||
|
new Map([["video", {
|
||||||
|
id: "video", type: "inbound-rtp", kind: "video", timestamp: 1000,
|
||||||
|
bytesReceived: 1000, framesDecoded: 10, framesDropped: 0,
|
||||||
|
packetsReceived: 10, packetsLost: 0, totalDecodeTime: 0,
|
||||||
|
totalProcessingDelay: 0, jitterBufferDelay: 3, jitterBufferEmittedCount: 10,
|
||||||
|
}]]),
|
||||||
|
new Map([["video", {
|
||||||
|
id: "video", type: "inbound-rtp", kind: "video", timestamp: 2000,
|
||||||
|
bytesReceived: 2000, framesDecoded: 20, framesDropped: 0,
|
||||||
|
packetsReceived: 20, packetsLost: 0, totalDecodeTime: 0,
|
||||||
|
totalProcessingDelay: 0, jitterBufferDelay: 3.5, jitterBufferEmittedCount: 20,
|
||||||
|
}]]),
|
||||||
|
];
|
||||||
|
const samples: any[] = [];
|
||||||
|
const sampler = new WebRTCStatsSampler({
|
||||||
|
connectionState: "connected",
|
||||||
|
getStats: async () => reports[Math.min(reportIndex++, reports.length - 1)],
|
||||||
|
}, (sample: any) => samples.push(sample));
|
||||||
|
|
||||||
|
await sampler.sample();
|
||||||
|
await sampler.sample();
|
||||||
|
expect(samples[0].video.jitterBufferMs).toBe(300);
|
||||||
|
expect(samples[1].video.jitterBufferMs).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,8 +31,13 @@ export class P2PClient {
|
|||||||
this.send = opts.send;
|
this.send = opts.send;
|
||||||
this.onState = opts.onState || (() => {});
|
this.onState = opts.onState || (() => {});
|
||||||
this.onStats = opts.onStats || (() => {});
|
this.onStats = opts.onStats || (() => {});
|
||||||
|
this.onInputState = opts.onInputState || (() => {});
|
||||||
this.iceServers = Array.isArray(opts.iceServers) ? opts.iceServers : null;
|
this.iceServers = Array.isArray(opts.iceServers) ? opts.iceServers : null;
|
||||||
|
this.enableInput = opts.enableInput === true;
|
||||||
this.pc = null;
|
this.pc = null;
|
||||||
|
this.reliableInput = null;
|
||||||
|
this.motionInput = null;
|
||||||
|
this.inputReady = false;
|
||||||
this.pendingRemoteCandidates = [];
|
this.pendingRemoteCandidates = [];
|
||||||
this.statsSampler = null;
|
this.statsSampler = null;
|
||||||
}
|
}
|
||||||
@@ -49,8 +54,26 @@ export class P2PClient {
|
|||||||
|
|
||||||
if (this.videoEl) pc.addTransceiver("video", { direction: "recvonly" });
|
if (this.videoEl) pc.addTransceiver("video", { direction: "recvonly" });
|
||||||
if (this.audioEl) pc.addTransceiver("audio", { direction: "recvonly" });
|
if (this.audioEl) pc.addTransceiver("audio", { direction: "recvonly" });
|
||||||
|
if (this.enableInput) {
|
||||||
|
this.reliableInput = pc.createDataChannel("overlord-input-reliable", { ordered: true });
|
||||||
|
this.motionInput = pc.createDataChannel("overlord-input-motion", { ordered: false, maxRetransmits: 0 });
|
||||||
|
this.reliableInput.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(String(event.data || ""));
|
||||||
|
if (message?.type === "input_ready" && message?.version === 1) {
|
||||||
|
this.inputReady = true;
|
||||||
|
this.onInputState(true);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
this.reliableInput.onclose = () => {
|
||||||
|
this.inputReady = false;
|
||||||
|
this.onInputState(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pc.ontrack = (ev) => {
|
pc.ontrack = (ev) => {
|
||||||
|
this.applyLowLatencyReceiverHints();
|
||||||
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||||
if (ev.track.kind === "video" && this.videoEl) {
|
if (ev.track.kind === "video" && this.videoEl) {
|
||||||
this.videoEl.srcObject = stream;
|
this.videoEl.srcObject = stream;
|
||||||
@@ -104,6 +127,7 @@ export class P2PClient {
|
|||||||
async onAnswer(sdp) {
|
async onAnswer(sdp) {
|
||||||
if (!this.pc || !sdp) return;
|
if (!this.pc || !sdp) return;
|
||||||
await this.pc.setRemoteDescription({ type: "answer", sdp });
|
await this.pc.setRemoteDescription({ type: "answer", sdp });
|
||||||
|
this.applyLowLatencyReceiverHints();
|
||||||
// Drain any ICE candidates that arrived before the answer.
|
// Drain any ICE candidates that arrived before the answer.
|
||||||
const drain = this.pendingRemoteCandidates;
|
const drain = this.pendingRemoteCandidates;
|
||||||
this.pendingRemoteCandidates = [];
|
this.pendingRemoteCandidates = [];
|
||||||
@@ -112,6 +136,35 @@ export class P2PClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyLowLatencyReceiverHints() {
|
||||||
|
if (!this.pc) return;
|
||||||
|
for (const receiver of this.pc.getReceivers()) {
|
||||||
|
if (receiver.track?.kind !== "video") continue;
|
||||||
|
try {
|
||||||
|
if ("playoutDelayHint" in receiver) receiver.playoutDelayHint = 0;
|
||||||
|
if ("jitterBufferTarget" in receiver) receiver.jitterBufferTarget = 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug("p2p: browser rejected low-latency receiver hint", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInput(message) {
|
||||||
|
if (!this.enableInput || !this.inputReady || !message || typeof message.type !== "string") return false;
|
||||||
|
const motion = message.type === "mouse_move";
|
||||||
|
const channel = motion ? this.motionInput : this.reliableInput;
|
||||||
|
if (!channel || channel.readyState !== "open") return false;
|
||||||
|
// Never build a motion backlog. Reliable key/button events are tiny and
|
||||||
|
// must stay on one ordered path so a key-up cannot overtake a key-down.
|
||||||
|
if (motion && channel.bufferedAmount > 64 * 1024) return true;
|
||||||
|
try {
|
||||||
|
channel.send(JSON.stringify(message));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Add a remote ICE candidate from the agent. */
|
/** Add a remote ICE candidate from the agent. */
|
||||||
async onRemoteCandidate(msg) {
|
async onRemoteCandidate(msg) {
|
||||||
if (!this.pc) return;
|
if (!this.pc) return;
|
||||||
@@ -135,6 +188,10 @@ export class P2PClient {
|
|||||||
this.statsSampler?.stop();
|
this.statsSampler?.stop();
|
||||||
this.statsSampler = null;
|
this.statsSampler = null;
|
||||||
this.pendingRemoteCandidates = [];
|
this.pendingRemoteCandidates = [];
|
||||||
|
this.reliableInput = null;
|
||||||
|
this.motionInput = null;
|
||||||
|
this.inputReady = false;
|
||||||
|
this.onInputState(false);
|
||||||
if (pc) {
|
if (pc) {
|
||||||
try { pc.close(); } catch {}
|
try { pc.close(); } catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export class WebRTCStatsSampler {
|
|||||||
let processingDelayMs = null;
|
let processingDelayMs = null;
|
||||||
let framesDroppedDelta = null;
|
let framesDroppedDelta = null;
|
||||||
let intervalLossPercent = null;
|
let intervalLossPercent = null;
|
||||||
|
let jitterBufferMs = averageJitterBufferMs(item);
|
||||||
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);
|
||||||
@@ -88,6 +89,11 @@ 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 jitterDelayDelta = Number(item.jitterBufferDelay) - previous.jitterBufferDelay;
|
||||||
|
const jitterEmittedDelta = Number(item.jitterBufferEmittedCount) - previous.jitterBufferEmittedCount;
|
||||||
|
if (jitterDelayDelta >= 0 && jitterEmittedDelta > 0) {
|
||||||
|
jitterBufferMs = (jitterDelayDelta * 1000) / jitterEmittedDelta;
|
||||||
|
}
|
||||||
const receivedDelta = (Number(item.packetsReceived) || 0) - previous.packetsReceived;
|
const receivedDelta = (Number(item.packetsReceived) || 0) - previous.packetsReceived;
|
||||||
const lostDelta = (Number(item.packetsLost) || 0) - previous.packetsLost;
|
const lostDelta = (Number(item.packetsLost) || 0) - previous.packetsLost;
|
||||||
const packetDelta = Math.max(0, receivedDelta) + Math.max(0, lostDelta);
|
const packetDelta = Math.max(0, receivedDelta) + Math.max(0, lostDelta);
|
||||||
@@ -102,6 +108,8 @@ export class WebRTCStatsSampler {
|
|||||||
framesDropped: Number(item.framesDropped) || 0,
|
framesDropped: Number(item.framesDropped) || 0,
|
||||||
packetsReceived: Number(item.packetsReceived) || 0,
|
packetsReceived: Number(item.packetsReceived) || 0,
|
||||||
packetsLost: Number(item.packetsLost) || 0,
|
packetsLost: Number(item.packetsLost) || 0,
|
||||||
|
jitterBufferDelay: Number(item.jitterBufferDelay) || 0,
|
||||||
|
jitterBufferEmittedCount: Number(item.jitterBufferEmittedCount) || 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);
|
||||||
@@ -111,7 +119,7 @@ export class WebRTCStatsSampler {
|
|||||||
packetsLost,
|
packetsLost,
|
||||||
lossPercent: intervalLossPercent ?? (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,
|
||||||
decodeMs,
|
decodeMs,
|
||||||
processingDelayMs,
|
processingDelayMs,
|
||||||
framesDropped: Number.isFinite(item.framesDropped) ? item.framesDropped : null,
|
framesDropped: Number.isFinite(item.framesDropped) ? item.framesDropped : null,
|
||||||
|
|||||||
Reference in New Issue
Block a user