Real-time is one of the few disciplines in software where the deadline is physics. At 60 fps, a video frame is about sixteen milliseconds. A late frame can become a visible stall or more delay in the queue. Once capture, encoding and transport each have their own idea of time and ownership, finding where that delay entered becomes a job of its own.
RTSP cameras are everywhere: doorways, warehouses, loading bays, street corners. A browser cannot open that stream as native WebRTC. icey connects the two: pull from RTSP, decode and encode through FFmpeg as needed, then send over WebRTC inside your application. The same modules can record the stream or pass decoded frames to native processing.
The quickest way to see it is Icey Server, the runnable application built on the library. Its demo starts with a bundled media source:
docker run --rm --network host 0state/icey-server:latest
Open http://localhost:4500 and click Watch on the icey peer. The video came from a single C++ process that captured, decoded, signalled, and transported it without leaving the runtime.
Connect the libraries once
FFmpeg handles codecs. libdatachannel handles WebRTC transport. libuv handles asynchronous I/O. Your application still has to get data from one to the next, keep it alive for the right amount of time and shut the whole path down without a callback touching freed memory.
icey supplies that integration through PacketStream: a source emits packets, processors transform them, and sinks consume them. A camera, a file and a network stream can enter the same processing model. Signals report the events and state changes around it.
┌─────────────────────────────────────────────────────────────────┐
│ PacketStream │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Source │───▶│ Processor │───▶│ Sink │ │
│ │ │ │ │ │ │ │
│ │ Camera │ │ FFmpeg H.264 │ │ WebRTC Track Sender │ │
│ │ File │ │ Opus encode │ │ Network socket │ │
│ │ Network │ │ OpenCV │ │ File recorder │ │
│ │ Device │ │ Custom │ │ HTTP response │ │
│ └──────────┘ └──────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
WebRTC send path:
MediaCapture → VideoEncoder → WebRtcTrackSender → [libdatachannel]
│
Browser ◀── RTP/SRTP ◀── DTLS ◀── ICE (libjuice) ◀───┘
│
icey TURN server
(relay for symmetric NATs)
WebRTC receive path:
[libdatachannel] → WebRtcTrackReceiver → FFmpeg decode → file/display
│
└─── ICE → DTLS → SRTP decrypt → RTP depacketise → raw frames
Signalling (Symple v4):
C++ server/client ◀──── WebSocket ────▶ Browser (symple-player)
Auth, presence, rooms, call protocol (init/accept/offer/answer/candidate)
Adapters declare whether they borrow, clone or retain a packet. Borrowing keeps the synchronous path cheap; a queued processor needs storage that survives the call. The graph makes that handoff explicit.
PacketStream stream;
stream.attachSource(videoCapture);
stream.attach(new av::MultiplexPacketEncoder(opts), 5);
stream.attach(socket, 10);
stream.start();
Keep detection off the delivery path
A browser needs the next frame. A detector may still be working on the last one. Put both in the same synchronous path and the detector decides how smooth the video looks.
icey lets the application branch from decoded media. One branch encodes and sends; another samples frames for analysis and crosses an explicit queue boundary before slower work. AsyncPacketQueue clones packets for deferred processing, so the capture source can reuse its buffer without invalidating the detector’s input.
capture -> decoded frame -> encoder -> WebRTC sender
|
`-> sample -> queue -> detector -> events
That choice belongs to the application. A detector taking 100 milliseconds per frame cannot inspect every frame of a 60 fps feed without falling behind. Sampling current frames can be more useful than faithfully analysing an ever-older queue.
The work
A webcam to a browser
The same PacketStream graph: camera source, H.264 encoder, WebRTC track sender. Symple handles the call signalling. The excerpt below shows the wiring; setup, source selection and teardown live in the complete sample. The WebRTC walkthrough develops the path step by step.
session.IncomingCall += [&](const std::string& peerId) {
session.accept();
};
session.StateChanged += [&](wrtc::PeerSession::State state) {
if (state == wrtc::PeerSession::State::Active) {
stream.attachSource(capture.get(), false, true);
stream.attach(encoder, 1, true);
stream.attach(&session.media().videoSender(), 5, false);
stream.start();
}
};
A browser to disk, server-side
The browser sends video over WebRTC; the server decodes with FFmpeg and writes to any container the codecs allow. Video depositions, telehealth recording, proctoring; recording that lives on infrastructure you control instead of someone else’s cloud.
av::EncoderOptions opts;
opts.oformat = av::Format{
"MP4", "mp4",
av::VideoCodec{"H.264", "libx264", 1280, 720, 30, "yuv420p"},
av::AudioCodec{"AAC", "aac", 2, 48000, 128000, "fltp"}
};
Any file, real-time, to a browser
MP4 in, WebRTC out, with a data channel available alongside the media for application control. See src/webrtc/samples/file-streamer.
Your own TURN relay
RFC 5766 TURN with channel binding and TCP support. When peers cannot establish a direct path, TURN relays the traffic. Operating the relay yourself gives you control of that part of the deployment. See src/turn/samples/turnserver.
Capture and encode
The repository goes back to LibSourcey in 2012. In 2014, I was trying to mux a live webcam and microphone through FFmpeg. The timestamps looked right, but irregular frame arrival still produced crackly audio and sped-up video. The workaround buffered and repeated video frames when the camera fell behind the audio.
FFmpeg’s codec API is powerful and leaks the moment you stop paying attention. icey wraps it in RAII types with custom deleters, so capture, encode, and mux clean up after themselves. VideoCapture and AudioCapture emit decoded packets straight from system devices: V4L2 on Linux, AVFoundation on macOS, DirectShow on Windows. MediaCapture handles file input, with optional looping. Encoders chain into the same PacketStream as processors, and the encoder owns pixel-format conversion through libswscale, PTS tracking, and time-base conversion, then flushes cleanly on close. The whole AV surface adapts to FFmpeg 5, 6, 7 and 8 at compile time, so you build against whatever the distro ships.
Networking on libuv
Sockets, timers, DNS and process integration use libuv’s event-loop model. TCP, SSL, and UDP share a common interface with chainable adapters for middleware-style extension. The SSL layer enforces a TLS 1.2 floor with ALPN, SNI, hostname verification, and session caching through OpenSSL 3.x EVP. The HTTP module is a full client and server: WebSocket upgrade (RFC 6455), multipart forms, cookies, and streaming responses, built on llhttp, the same parser Node uses.
Everything is wired with typed signals. ThreadSignal<T> is thread-safe and multi-listener, carrying socket events, stream-state changes, and install progress through the same mechanism.
server.Connection += [](http::ServerConnection::Ptr conn) {
conn->Payload += [](auto&, const MutableBuffer& buf) {
// handle incoming data
};
};
Under the budget
The repository includes an HTTP comparison using empty 200 OK responses, one vCPU on an Intel i7-11800H system, and wrk with four threads, 100 connections and ten-second runs. These are the reported HTTP/1.1 keep-alive results:
| Server | Req/sec | Latency |
|---|---|---|
| Raw libuv+llhttp | 96,088 | 1.04ms |
| icey | 72,209 | 1.43ms |
| Go 1.25 net/http | 53,878 | 2.31ms |
| Node.js v20 | 45,514 | 3.56ms |
With a fresh connection for each request, the same harness reports 14,330 requests per second for icey and 14,723 for Go. Connection policy changes the comparison. The harness and methodology are available to rerun on the hardware and workload you care about.
For media, packet borrowing avoids copies along synchronous parts of the graph. Queues, retained adapters, codecs and transport boundaries have their own costs; the HTTP result does not measure those paths.
Modules
icey is sixteen modules, each self-contained, each compiled only when its dependency is present.
| Module | What it does |
|---|---|
| base | Event loop, signals, packet streams, threading, timers, filesystem, logging |
| crypto | HMAC, SHA-256/512, RSA, X509; OpenSSL 3.x EVP |
| net | TCP, SSL, UDP sockets, DNS, chainable adapters |
| http | Client and server, WebSocket RFC 6455, forms, cookies, streaming, keep-alive |
| json | Serialisation via nlohmann/json |
| av | FFmpeg capture, encode, decode, record (FFmpeg 5/6/7/8) |
| speech | Audio intelligence primitives for decoded media |
| vision | Video intelligence primitives for decoded frames |
| symple | Presence, messaging, rooms, WebRTC call signalling |
| stun | RFC 5389 STUN for NAT traversal |
| turn | RFC 5766 relay server with channel binding |
| webrtc | WebRTC via libdatachannel: media bridge, peer sessions, codec negotiation |
| graft | Native plugin ABI and shared-library loading |
| pacm | Package delivery for native extensions |
| archo | ZIP extraction with path traversal protection |
| sched | Deferred and periodic scheduling with restore semantics |
CMake fetches core bundled dependencies such as libuv and llhttp. Media and TLS builds also need their enabled system dependencies, including FFmpeg and OpenSSL development packages. Choose the modules and dependencies for the application you are building.
Build and test
cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON
cmake --build build --parallel
ctest --test-dir build --output-on-failure
For media and WebRTC, install the required development libraries and enable -DWITH_FFMPEG=ON -DWITH_LIBDATACHANNEL=ON. Both options default off; installing FFmpeg alone does not enable the media build. CI runs GCC 12+, Clang 15+, AppleClang 15+, and MSVC 2022, with ASan, TSan, and UBSan builds catching what unit tests miss.
Calling the pipeline from Rust
The developing Rust bindings use Graft’s host C interface. icey-sys exposes the raw ABI; the icey crate wraps the current pipeline handle and its lifecycle. That surface covers RTSP input and WebRTC sender setup without asking Rust to share the layout of a C++ class.
This is a narrow entry into the native runtime. The C++ modules remain the broader integration surface.
Install
# Homebrew
brew install nilstate/tap/icey
# Arch (AUR)
yay -S icey
# Nix
nix build github:nilstate/icey
# CMake FetchContent
FetchContent_Declare(icey
GIT_REPOSITORY https://github.com/nilstate/icey.git
GIT_TAG ${ICEY_RELEASE_TAG}
)
Set ICEY_RELEASE_TAG to an exact tag from the Icey releases when using CMake. Icey is also listed in vcpkg, ConanCenter, MacPorts, and Spack. Check each registry for its currently published version.
For the runnable Icey Server, use the server repository, or install it from a package manager:
# Homebrew
brew install nilstate/tap/icey-server
# Arch (AUR)
yay -S icey-server
# Ubuntu / Debian (signed APT repo)
curl -fsSL https://apt.0state.com/icey/icey-archive-keyring.gpg | sudo tee /usr/share/keyrings/icey-archive-keyring.gpg >/dev/null
curl -fsSL https://apt.0state.com/icey/icey-server.list | sudo tee /etc/apt/sources.list.d/icey-server.list >/dev/null
sudo apt update && sudo apt install icey-server
The complete samples cover webcam streaming, file streaming and recording. Start with the path closest to the application you are building.
