Low-latency desktop streaming for the browser, written in Rust. voxl streams an X11 display, a Mac's screen or a Windows desktop as H.264 over a WebSocket to a small web client that decodes with WebCodecs, with input, clipboard, audio, microphone, file and folder transfer and dynamic resolution.
A 16 vCPU AMD EPYC 9334 VM (no GPU), 2560x1600 Xvfb desktop, same workloads, each
stream decoded by a native client that reads a pixel marker drawn into the content.
Cells are FPS (worst second), then latency p50/p99 from the content being drawn to the
frame being decoded. voxl's column is its best run of each test. To reproduce, see
bench/.
| Test | voxl | selkies | KasmVNC | Neko | Neko, tuned | noVNC | Daytona |
|---|---|---|---|---|---|---|---|
| Chromium scroll, LAN | 60.1 (59.7), 55/60 ms | 56.8 (54.0), 85/106 ms | 51.7 (39.5), 99/135 ms | 25.0 (23.7), 156/217 ms | 53.9 (49.9), 128/155 ms | 6.7 (6.3), 198/301 ms | 5.2 (2.5), 306/713 ms |
| Scroll, 10 Mbps / 40 ms RTT | 60.0 (59.4), 82/94 ms | 55.6 (47.7), 152/261 ms | 8.3 (7.4), 229/249 ms | 24.6 (14.9), 221/737 ms | 46.3 (40.3), 606/831 ms | 2.6 (2.4), 426/678 ms | 1.9 (1.6), 617/798 ms |
| Scroll, 5 Mbps / 60 ms RTT | 60.1 (59.7), 99/109 ms | 27.9 (19.6), 2.7/3.7 s | 4.9 (3.6), 306/415 ms | 21.7 (3.8), 1.2/3.6 s | stalls | 1.6 (1.4), 637/937 ms | 1.1 (0.9), 1.1/1.2 s |
| 1080p animation, LAN | 60.1 (59.9), 12/16 ms | 58.7 (55.9), 31/48 ms | 56.7 (51.6), 51/82 ms | 24.8 (23.6), 122/202 ms | 55.6 (50.0), 54/82 ms | 7.7 (7.2), 149/210 ms | 10.5 (8.8), 138/209 ms |
| Animation, 10 Mbps / 40 ms RTT | 60.1 (59.5), 46/71 ms | 39.3 (31.8), 2.3/3.1 s | 52.9 (49.2), 76/115 ms | 25.1 (9.1), 295/962 ms | 33.3 (25.4), 4.0/7.1 s | 5.8 (5.2), 160/226 ms | 7.4 (7.0), 154/213 ms |
| Animation, 5 Mbps / 60 ms RTT | 60.0 (58.8), 66/92 ms | 18.4 (15.3), 2.2/4.3 s | 34.1 (26.4), 104/156 ms | 15.2 (8.6), 5.0/9.4 s | stalls | 5.1 (4.7), 174/240 ms | 5.8 (4.4), 189/252 ms |
| Full-screen animation, LAN | 59.8 (54.0), 18/25 ms | 54.8 (49.8), 54/79 ms | 34.2 (32.5), 83/102 ms | 22.7 (18.0), 2.9/3.3 s | 54.6 (49.7), 54/79 ms | 5.9 (5.2), 184/279 ms | 9.1 (7.2), 154/247 ms |
| Server CPU, X server + streamer (cores) | idle | 1080p animation | scroll | full screen |
|---|---|---|---|---|
| voxl | 0.00 | 1.39 | 1.33 | 1.68 |
| selkies | 1.29 | not measured | 1.54 | 1.80 |
| KasmVNC | 0.00 | 1.97 | 2.07 | 1.82 (at 34 FPS) |
| Neko | 1.63 | 2.50 (at 25 FPS) | 2.20 (at 25 FPS) | 2.46 (at 23 FPS) |
| Neko, tuned | 1.90 | 2.28 | 2.30 | 2.52 |
| noVNC | 0.01 | 0.55 (at 8 FPS) | 0.58 (at 7 FPS) | 0.64 (at 6 FPS) |
| Daytona (1 CPU limit) | 0.02 | 0.82 (at 10 FPS) | 0.53 (at 5 FPS) | 0.81 (at 9 FPS) |
- selkies: selkies with picom running.
- KasmVNC: 1.4.0 with a 60 FPS cap, JPEG/WebP quality 8–9 and video mode, no compositor.
- Neko: m1k1o/neko 3.2 with its defaults (VP8, 25
FPS). Neko, tuned captures at 60 FPS and encodes with x264
ultrafast/zerolatencyat up to 30 Mbps, like voxl. Neither adapts its bitrate (Neko's estimator is off by default), so on slow links frames queue up; "stalls" means no frame arrived in 20 s. - noVNC: x11vnc and noVNC as E2B Desktop
and Daytona ship them (
-forever -wait 50 -shared, Tight encoding). x11vnc polls the screen, which caps it at 6–8 FPS. - Daytona: a default sandbox (1 CPU, x11vnc and noVNC at 2560x1600) streamed through Daytona's signed preview proxy, about 12 ms away, which its latency includes. The workloads run inside the sandbox, sharing its CPU, and slow links are added on top of that internet path.
- Links: a bottleneck with a deep queue: a TCP proxy for the WebSocket servers, and
tc netemfor Neko's WebRTC media (UDP).
Build (needs Rust, pkg-config, clang, x264, libyuv, libopus, libpulse and esbuild;
nix-shell provides them all):
nix-shell
cargo build --releaseOr install it from crates.io with the same
libraries on your system (the crate is voxl-server, the binary is voxl):
cargo install voxl-serverRun it against an X display, then open http://host:6901/ (?stats adds an FPS
overlay):
DISPLAY=:1 ./target/release/voxl --port=6901Start Xvfb with -shmem for zero-copy capture, and use the patched Xvfb
(nix-build -E 'with import <nixpkgs> {}; callPackage ./xvfb.nix {}') for smooth
scrolling. voxl accepts selkies' command-line flags; other selkies flags are ignored.
| Flag | Default | |
|---|---|---|
--addr, --port |
0.0.0.0, 6901 |
listen address |
--framerate |
60 |
max frames per second (60-60 style ranges accepted) |
--preset |
ultrafast |
x264 preset |
--encoder-threads |
min(4, ncpu/4) |
x264 sliced threads |
--crf |
24 |
base quality |
--max-kbps / --video-bitrate |
30000 |
adaptive bitrate ceiling |
--min-kbps |
2000 |
adaptive bitrate floor |
--paintover-crf, --paintover-burst, --paintover-delay-ms |
16, 3, 150 |
sharpening when the screen goes still |
--enable-clipboard |
true |
true, in, out or false |
--audio-enabled, --audio-device-name, --audio-bitrate |
true, output.monitor, 128000 |
desktop audio |
--enable-resize, --resize-scale |
false, 1.5 |
desktop follows the viewer's window; desktop pixels per CSS pixel |
--microphone-enabled, --microphone-fifo |
false, /tmp/voxl-mic.fifo |
browser microphone into the FIFO a PulseAudio pipe source reads |
--round-corners |
0 (off) |
corner radius in pixels for top-level windows |
--round-windows |
none | title:radius,... rounds windows with that title, docks included |
--adaptive-threads |
false |
encode on one thread, fanning out only under sustained heavy motion |
--stripes |
1 |
encode the screen as N independent horizontal streams (experimental) |
--scroll-px-per-notch |
180 |
desktop pixels one wheel notch scrolls (calibrates smooth scrolling) |
--file-transfers |
upload,download |
upload, download, both, or none |
--files-root |
$HOME |
folder the Files panel can see; nothing outside it is reachable |
--upload-dir |
Downloads |
folder (under the root) the panel opens and uploads into; created on first upload |
--max-upload-mb |
0 (no limit) |
largest accepted upload |
--max-record-seconds |
300 |
longest recording (/record) |
--convert-threads, --coalesce-us |
4, 0 |
tuning |
Screenshots and recordings, for agents and scripts:
curl -o shot.png localhost:6901/screenshot # the screen as a PNG
curl -o clip.mp4 "localhost:6901/record?seconds=10" # an MP4 of the next 10 s
id=$(curl -s -X POST localhost:6901/record/start | jq .id) # or start...
curl -o clip.mp4 -X POST "localhost:6901/record/stop?id=$id" # ...and stop when doneA recording is the H.264 voxl already sends to viewers, saved as MP4 without encoding it again, so it costs no extra CPU. It doesn't include the cursor (viewers draw it), and a resize ends it.
/wait returns when the screen settles or changes, so an agent doesn't have to sleep
after every action and hope it was long enough:
curl localhost:6901/wait # once nothing changed for 300 ms
curl "localhost:6901/wait?idle=1000&timeout=30000" # still for 1 s, give up after 30 s
curl "localhost:6901/wait?change®ion=0,0,800,600" # the first change in that rectangle
# {"changed":true,"timed_out":false,"seq":812,"waited_ms":340}idle (ms, default 300) is how long the screen has to stay still, counted from the
request at the earliest so an action just taken has time to show; change returns at
the first change instead. region=x,y,w,h only watches that rectangle (to the 16-pixel
block) and timeout (ms, default 10000) gives up. changed says whether anything
changed while waiting. A change can land before the request does, so to wait for what an
action caused, pass since the seq of an earlier /wait or the X-Voxl-Seq header of
a screenshot: /wait?change&since=812 returns at once if anything changed after it. A
blinking text caret redraws about twice a second, so keep idle under 500 ms or leave
the caret out of region. voxl already knows which parts of the screen change (XDamage,
ScreenCaptureKit and DXGI report them), so waiting needs no screenshots or polling.
voxl also streams a Mac's own screen, with every feature of the Linux version (ScreenCaptureKit capture, CGEvent input, NSPasteboard clipboard, system audio).
brew install x264 opus pkgconf esbuild
cargo build --release
./target/release/voxl --port=6901Open http://localhost:6901/. On a Mac voxl listens on 127.0.0.1 unless --addr
says otherwise, since the stream is your whole screen and there is no login. macOS asks
voxl (or the terminal that starts it) for Screen Recording and Accessibility
permissions. For the microphone, install a loopback device (brew install blackhole-2ch) and pick it as the input in the apps that should hear you.
| macOS flag | Default | |
|---|---|---|
--display |
main display | display to stream: an index (0 is the main display) or a display ID; logged at startup |
--capture-scale |
native, within the budget | streamed pixels per point |
--max-capture-pixels |
4096000 (2560x1600) |
pixel budget for the automatic scale: x264's cost grows with area |
--microphone-device |
first loopback device found | output device (name or prefix) to play the browser microphone into |
--resize-scale |
the capture scale | so a viewer's window maps to the same number of points on the Mac |
The other flags work as on Linux; --audio-device-name, --microphone-fifo and the
rounded-corner flags don't apply.
voxl also streams a Windows desktop (Windows 10 or later), with every feature of the Linux version (DXGI Desktop Duplication capture, SendInput, clipboard text and images, WASAPI system audio). Build it with Rust, vcpkg, LLVM (for bindgen) and esbuild:
vcpkg install x264 libyuv opus --triplet x64-windows-static-md
$env:VCPKGRS_TRIPLET = 'x64-windows-static-md'
npm install -g esbuild
cargo build --release
.\target\release\voxl.exe --port=6901Open http://localhost:6901/. As on a Mac, voxl listens on 127.0.0.1 unless --addr
says otherwise. It streams the desktop of the session it runs in, so start it in the
user's session (not as a service); Windows hides the lock screen and UAC prompts from
it unless it runs as SYSTEM. For the microphone, install a virtual audio cable
(VB-CABLE): voxl plays into "CABLE Input" and apps
record from "CABLE Output".
| Windows flag | Default | |
|---|---|---|
--display |
primary display | display to stream, by index (0 is the primary); logged at startup |
--audio-device-name |
default output device | output device (name or prefix) whose sound to stream |
--microphone-device |
first virtual cable found | output device (name or prefix) to play the browser microphone into |
--resize-scale |
the display's scaling | so a viewer's window maps to the same size on the desktop |
The other flags work as on Linux; --microphone-fifo and the rounded-corner flags don't
apply. Dynamic resolution picks the largest display mode that fits the viewer's window,
and Windows restores the original mode when voxl exits.