Skip to content

Commit bd0348f

Browse files
committed
Additional adjustments to video pipeline
1 parent 661ca74 commit bd0348f

7 files changed

Lines changed: 400 additions & 33 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ go build -o doubletake ./cmd/doubletake
4747
# Use saved credentials
4848
./doubletake -target 192.168.1.77 -creds airplay-credentials.json
4949

50-
# Adjust stream settings
51-
./doubletake -target 192.168.1.77 -width 1920 -height 1080 -fps 30 -bitrate 10000
50+
# Adjust stream settings (bitrate 0 = auto)
51+
./doubletake -target 192.168.1.77 -width 1920 -height 1080 -fps 30 -bitrate 0
52+
53+
# Force a lower bitrate on weaker Wi-Fi
54+
./doubletake -target 192.168.1.77 -bitrate 4500
5255

5356
# Hardware encoding
5457
./doubletake -target 192.168.1.77 -hwaccel nvenc # NVIDIA
@@ -70,7 +73,7 @@ go build -o doubletake ./cmd/doubletake
7073
| `-width` | 1920 | Stream width |
7174
| `-height` | 1080 | Stream height |
7275
| `-fps` | 30 | Frames per second |
73-
| `-bitrate` | 10000 | Video bitrate (kbps) |
76+
| `-bitrate` | 0 | Video bitrate in kbps (`0` = auto) |
7477
| `-hwaccel` | auto | Hardware accel: `auto`, `nvenc`, `vaapi`, `none` |
7578
| `-test` | false | Use synthetic video source |
7679
| `-debug` | false | Verbose debug logging |

cmd/doubletake/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func main() {
2525
width := flag.Int("width", 1920, "Stream width")
2626
height := flag.Int("height", 1080, "Stream height")
2727
fps := flag.Int("fps", 30, "Frames per second")
28-
bitrate := flag.Int("bitrate", 10000, "Video bitrate in kbps (default 10000 = 10 Mbps)")
28+
bitrate := flag.Int("bitrate", 0, "Video bitrate in kbps (0 = auto, default tunes for resolution/FPS)")
2929
hwaccel := flag.String("hwaccel", "auto", "Hardware acceleration: auto, nvenc, vaapi, none")
3030
testMode := flag.Bool("test", false, "Use synthetic video (videotestsrc) instead of screen capture for debugging")
3131
noEncrypt := flag.Bool("no-encrypt", false, "Disable RTSP header encryption (debugging only; video frames are always encrypted)")

internal/airplay/audio.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func StartAudioCapture(ctx context.Context) (*AudioCapture, error) {
8282
"!", "audioconvert",
8383
"!", "audioresample",
8484
"!", "audio/x-raw,rate=44100,channels=2,format=S16LE",
85-
"!", "queue", "max-size-buffers=4", "max-size-bytes=0", "max-size-time=0", "leaky=downstream",
85+
"!", "queue", "max-size-buffers=2", "max-size-bytes=0", "max-size-time=0", "leaky=downstream",
8686
"!",
8787
)
8888
gstArgs = append(gstArgs, encArgs...)

internal/airplay/capture.go

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type CaptureConfig struct {
2424
HWAccel string // "auto", "vaapi", "none"
2525
}
2626

27+
const (
28+
defaultVideoBitrateKbps = 4500
29+
minVideoBitrateKbps = 1800
30+
maxVideoBitrateKbps = 12000
31+
)
32+
2733
// ScreenCapture manages screen capture via GStreamer.
2834
type ScreenCapture struct {
2935
cmd *exec.Cmd // gst-launch-1.0 process
@@ -80,7 +86,7 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
8086
"pipewiresrc", fmt.Sprintf("fd=%d", pwFdNum), fmt.Sprintf("path=%d", nodeID), "do-timestamp=true",
8187
"!", "videoconvert",
8288
"!", "videoscale",
83-
"!", "videorate",
89+
"!", "videorate", "drop-only=true", "skip-to-first=true",
8490
"!", fmt.Sprintf("video/x-raw,width=%d,height=%d,framerate=%d/1", cfg.Width, cfg.Height, fps),
8591
"!", "queue", "max-size-buffers=1", "max-size-bytes=0", "max-size-time=0", "leaky=downstream",
8692
}
@@ -90,7 +96,7 @@ func startWaylandCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture
9096
gstArgs = append(gstArgs, "!")
9197
gstArgs = append(gstArgs, encoderParts.parts...)
9298
gstArgs = append(gstArgs,
93-
"!", "h264parse", "config-interval=-1",
99+
"!", "h264parse", "config-interval=0",
94100
"!", "video/x-h264,stream-format=byte-stream,alignment=au",
95101
"!", "fdsink", "fd=1", "sync=false", "async=false",
96102
)
@@ -179,7 +185,7 @@ func startX11Capture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, er
179185
gstArgs = append(gstArgs, "!")
180186
gstArgs = append(gstArgs, encoder.parts...)
181187
gstArgs = append(gstArgs,
182-
"!", "h264parse", "config-interval=-1",
188+
"!", "h264parse", "config-interval=0",
183189
"!", "video/x-h264,stream-format=byte-stream,alignment=au",
184190
"!", "fdsink", "fd=1", "sync=false", "async=false",
185191
)
@@ -354,7 +360,7 @@ func parseXrandrGeometry(line string) (xOffset, width int, ok bool) {
354360
// encoderResult holds the detected encoder pipeline parts and whether it needs
355361
// a vulkanupload step before the encoder.
356362
type encoderResult struct {
357-
parts []string
363+
parts []string
358364
needsVulkan bool // encoder needs vulkanupload ! before it
359365
}
360366

@@ -366,10 +372,8 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
366372
if fps <= 0 {
367373
fps = 30
368374
}
369-
bitrate := cfg.Bitrate
370-
if bitrate <= 0 {
371-
bitrate = 10000
372-
}
375+
bitrate := captureBitrateKbps(cfg)
376+
keyframeInterval := keyframeIntervalFrames(fps)
373377
hwaccel := cfg.HWAccel
374378

375379
// Try Vulkan H.264 (NVENC via Vulkan API) — lowest latency, no CPU usage
@@ -380,7 +384,7 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
380384
parts: []string{
381385
"vulkanh264enc",
382386
"b-frames=0",
383-
fmt.Sprintf("idr-period=%d", fps*2),
387+
fmt.Sprintf("idr-period=%d", keyframeInterval),
384388
"rate-control=cbr",
385389
fmt.Sprintf("bitrate=%d", bitrate),
386390
},
@@ -396,7 +400,7 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
396400
return encoderResult{parts: []string{
397401
"nvh264enc",
398402
fmt.Sprintf("bitrate=%d", bitrate),
399-
fmt.Sprintf("gop-size=%d", fps*2),
403+
fmt.Sprintf("gop-size=%d", keyframeInterval),
400404
"bframes=0",
401405
"rc-mode=cbr",
402406
"preset=low-latency-hq",
@@ -415,7 +419,7 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
415419
return encoderResult{parts: []string{
416420
"vah264enc",
417421
fmt.Sprintf("bitrate=%d", bitrate),
418-
fmt.Sprintf("key-int-max=%d", fps*2),
422+
fmt.Sprintf("key-int-max=%d", keyframeInterval),
419423
"b-frames=0",
420424
"rate-control=cbr",
421425
}}
@@ -427,16 +431,23 @@ func detectGstEncoder(cfg CaptureConfig) encoderResult {
427431

428432
// Software fallback: x264enc
429433
log.Printf("[CAPTURE] using software encoding (x264enc)")
434+
vbvBuf := vbvBufferKbit(bitrate, fps)
435+
// Use VBR (pass=0) so the encoder can undershoot on simple scenes, saving
436+
// headroom for complex frames. vbv-buf-capacity + vbv-maxrate cap bursts.
437+
maxrate := bitrate + bitrate/4 // allow 25% overshoot on peaks
430438
return encoderResult{parts: []string{
431439
"x264enc",
432440
"tune=zerolatency",
433-
"speed-preset=ultrafast",
441+
"speed-preset=superfast",
434442
fmt.Sprintf("bitrate=%d", bitrate),
435-
fmt.Sprintf("key-int-max=%d", fps*2),
443+
fmt.Sprintf("vbv-buf-capacity=%d", vbvBuf),
444+
fmt.Sprintf("key-int-max=%d", keyframeInterval),
445+
"pass=0",
446+
"option-string=" + fmt.Sprintf("vbv-maxrate=%d", maxrate),
436447
"bframes=0",
448+
"sliced-threads=true",
437449
"byte-stream=true",
438450
"aud=false",
439-
"vbv-buf-capacity=50",
440451
}}
441452
}
442453

@@ -452,10 +463,8 @@ func StartTestCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, e
452463
fps = 30
453464
}
454465

455-
bitrate := cfg.Bitrate
456-
if bitrate <= 0 {
457-
bitrate = 10000
458-
}
466+
bitrate := captureBitrateKbps(cfg)
467+
keyframeInterval := keyframeIntervalFrames(fps)
459468

460469
// GStreamer pipeline: videotestsrc → timeoverlay → x264enc High profile → Annex-B byte stream → stdout
461470
// pattern=18 = ball (bouncing ball with motion); timeoverlay adds a frame counter
@@ -467,9 +476,11 @@ func StartTestCapture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, e
467476
"!", "videoconvert",
468477
"!", "x264enc",
469478
"tune=zerolatency",
479+
"speed-preset=superfast",
470480
fmt.Sprintf("bitrate=%d", bitrate),
471-
fmt.Sprintf("key-int-max=%d", fps*2),
481+
fmt.Sprintf("key-int-max=%d", keyframeInterval),
472482
"threads=1",
483+
"sliced-threads=true",
473484
"byte-stream=true",
474485
"!", "video/x-h264,profile=high,stream-format=byte-stream",
475486
"!", "fdsink", "fd=1",
@@ -525,6 +536,66 @@ func logStderr(prefix string, r io.Reader) {
525536
}
526537
}
527538

539+
func captureBitrateKbps(cfg CaptureConfig) int {
540+
if cfg.Bitrate > 0 {
541+
return cfg.Bitrate
542+
}
543+
544+
fps := cfg.FPS
545+
if fps <= 0 {
546+
fps = 30
547+
}
548+
width := cfg.Width
549+
if width <= 0 {
550+
width = 1920
551+
}
552+
height := cfg.Height
553+
if height <= 0 {
554+
height = 1080
555+
}
556+
557+
bitrate := recommendedBitrateKbps(width, height, fps)
558+
log.Printf("[CAPTURE] auto bitrate selected: %d kbps for %dx%d@%dfps", bitrate, width, height, fps)
559+
return bitrate
560+
}
561+
562+
func recommendedBitrateKbps(width, height, fps int) int {
563+
if width <= 0 || height <= 0 || fps <= 0 {
564+
return defaultVideoBitrateKbps
565+
}
566+
567+
bitrate := (width*height*fps + 7500) / 15000
568+
if bitrate < minVideoBitrateKbps {
569+
return minVideoBitrateKbps
570+
}
571+
if bitrate > maxVideoBitrateKbps {
572+
return maxVideoBitrateKbps
573+
}
574+
return bitrate
575+
}
576+
577+
func keyframeIntervalFrames(fps int) int {
578+
if fps <= 0 {
579+
fps = 30
580+
}
581+
return fps * 4
582+
}
583+
584+
// vbvBufferKbit returns the x264 VBV buffer size in kbit for the given bitrate
585+
// and FPS. Sized at ~2 frames of data — enough headroom for the encoder to
586+
// handle scene changes without severe quality oscillation, but tight enough to
587+
// prevent large burst spikes that choke Wi-Fi links.
588+
func vbvBufferKbit(bitrateKbps, fps int) int {
589+
if bitrateKbps <= 0 || fps <= 0 {
590+
return 300
591+
}
592+
vbv := bitrateKbps * 2 / fps
593+
if vbv < 200 {
594+
return 200
595+
}
596+
return vbv
597+
}
598+
528599
// requestScreencast uses the xdg-desktop-portal D-Bus API to request screen capture
529600
// permission and returns a PipeWire node ID, an fd for the portal's PipeWire remote,
530601
// and the D-Bus connection (which must stay open to keep the screencast session alive).

internal/airplay/capture_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package airplay
2+
3+
import "testing"
4+
5+
func TestRecommendedBitrateKbps(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
width int
9+
height int
10+
fps int
11+
want int
12+
}{
13+
{
14+
name: "defaults when dimensions invalid",
15+
width: 0,
16+
height: 1080,
17+
fps: 30,
18+
want: defaultVideoBitrateKbps,
19+
},
20+
{
21+
name: "low resolution clamps to floor",
22+
width: 640,
23+
height: 360,
24+
fps: 30,
25+
want: minVideoBitrateKbps,
26+
},
27+
{
28+
name: "720p30 stays near wifi target",
29+
width: 1280,
30+
height: 720,
31+
fps: 30,
32+
want: 1843,
33+
},
34+
{
35+
name: "1080p30 uses wifi friendly auto bitrate",
36+
width: 1920,
37+
height: 1080,
38+
fps: 30,
39+
want: 4147,
40+
},
41+
{
42+
name: "high resolutions clamp to max",
43+
width: 3840,
44+
height: 2160,
45+
fps: 60,
46+
want: maxVideoBitrateKbps,
47+
},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
if got := recommendedBitrateKbps(tt.width, tt.height, tt.fps); got != tt.want {
53+
t.Fatalf("recommendedBitrateKbps(%d, %d, %d) = %d, want %d", tt.width, tt.height, tt.fps, got, tt.want)
54+
}
55+
})
56+
}
57+
}
58+
59+
func TestKeyframeIntervalFrames(t *testing.T) {
60+
if got := keyframeIntervalFrames(30); got != 120 {
61+
t.Fatalf("keyframeIntervalFrames(30) = %d, want 120", got)
62+
}
63+
if got := keyframeIntervalFrames(0); got != 120 {
64+
t.Fatalf("keyframeIntervalFrames(0) = %d, want 120", got)
65+
}
66+
}
67+
68+
func TestVbvBufferKbit(t *testing.T) {
69+
tests := []struct {
70+
name string
71+
bitrate int
72+
fps int
73+
want int
74+
}{
75+
{"invalid returns default", 0, 30, 300},
76+
{"low bitrate clamps to floor", 1800, 30, 200},
77+
{"1080p30 auto bitrate", 4147, 30, 276},
78+
{"high bitrate 60fps", 12000, 60, 400},
79+
}
80+
for _, tt := range tests {
81+
t.Run(tt.name, func(t *testing.T) {
82+
if got := vbvBufferKbit(tt.bitrate, tt.fps); got != tt.want {
83+
t.Fatalf("vbvBufferKbit(%d, %d) = %d, want %d", tt.bitrate, tt.fps, got, tt.want)
84+
}
85+
})
86+
}
87+
}

0 commit comments

Comments
 (0)