Most C++ media libraries solve one problem. FFmpeg handles codecs. OpenCV handles vision. libuv handles async IO. WebRTC handles peer-to-peer. Try to combine them and you’re knee-deep in memory ownership fights, callback hell across three different threading models, and build systems that take longer to debug than the actual code.
Icey is the connective tissue. A modular C++20 toolkit that unifies real-time device capture, media encoding, computer vision processing, STUN/TURN/ICE signalling, and network streaming into a single coherent pipeline architecture. Capture from a camera, run it through an encoder, pipe it over WebRTC to a browser; all async, all composable, no glue code.
We built it because we needed a way to ship real-time communication systems without treating every project as a bespoke integration exercise. The library has been running in production for over a decade.
Why Icey
| libWebRTC (Google) | libdatachannel | GStreamer | Icey | |
|---|---|---|---|---|
| Build system | GN/Ninja | CMake | Meson | CMake |
| Build time | Hours | Minutes | 30+ min | Minutes |
| Binary size | 50MB+ | Small | Large | Small |
| SSL | BoringSSL (conflicts) | OpenSSL | OpenSSL | OpenSSL |
| Media codecs | Bundled | None | GObject plugins | FFmpeg (any codec) |
| Capture/encode | Included | No | Plugin pipeline | PacketStream pipeline |
| Signalling | No | No | No | Symple (built-in) |
| TURN server | No | No | No | RFC 5766 (built-in) |
| Language | C++ | C++17 | C/GObject | C++20 |
libdatachannel gives you the WebRTC transport pipe. Icey gives you the pipe, the water, and the faucet.
Architecture
The core abstraction is the PacketStream; a composable pipeline where sources emit packets, processors transform them, and sinks consume them. Everything is polymorphic through the IPacket interface, so a video frame, an audio buffer, and a network message all flow through the same machinery.
┌─────────────────────────────────────────────────────────────────┐
│ 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-client-player)
Auth, presence, rooms, call protocol (init/accept/offer/answer/candidate)
Sources emit. Processors transform. Sinks consume. Priority ordering controls the chain. The stream manages lifecycle; start, stop, pause, resume, error recovery; as a state machine. You don’t manage threads. You don’t manage memory. You describe the flow and the pipeline handles the rest.
PacketStream stream;
stream.attachSource(videoCapture);
stream.attach(new av::MultiplexPacketEncoder(opts), 5);
stream.attach(socket, 10);
stream.start();
This isn’t a toy abstraction. Backpressure propagates through the chain. Write queues have high water marks. Frame dropping kicks in under load. The pipeline handles the ugly parts so application code stays clean.
What you can build
Stream a webcam to any browser
150 lines of C++. Camera capture, H.264 encoding, WebRTC transport, Symple signalling. Open a browser, see video. No plugins, no Google, no pain.
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();
}
};
Record a browser’s camera server-side
Browser sends WebRTC, your C++ server decodes with FFmpeg, writes to any format. Video depositions, telehealth recording, proctoring; server-side recording without cloud vendor lock-in.
Stream any video file to a browser
Feed an MP4 in, get a real-time WebRTC stream out. Data channel for seek commands. Build your own streaming service.
Run your own TURN relay
Production-grade RFC 5766 TURN server with channel binding and TCP support. Stop paying for hosted TURN. ~30% of real-world WebRTC connections need relay through symmetric NATs; this handles them.
Media capture and encoding
The AV module wraps FFmpeg’s codec infrastructure in RAII types that don’t leak. VideoCapture and AudioCapture continuously emit decoded packets from system devices; cameras via V4L2, AVFoundation, or DirectShow depending on platform. MediaCapture handles file input with optional looping.
Encoding chains VideoPacketEncoder and AudioPacketEncoder into the PacketStream as processors. For WebRTC, set encoder options for browser-safe low-latency encoding. For file output, use MultiplexPacketEncoder. Define your format once:
av::EncoderOptions opts;
opts.oformat = av::Format{
"MP4", "mp4",
av::VideoCodec{"H.264", "libx264", 640, 480, 25, "yuv420p"},
av::AudioCodec{"AAC", "aac", 2, 48000, 128000, "fltp"}
};
The encoder handles pixel format conversion (libswscale), PTS tracking, time base conversion, and codec flushing on close. All FFmpeg resources are wrapped in custom deleters; no manual av_frame_free() calls, no leaked contexts.
Compatible with FFmpeg 5, 6, and 7. The API adapts at compile time.
Networking on libuv
Every socket, timer, DNS lookup, and child process runs on libuv’s event loop. TCP, SSL, and UDP sockets share a common interface with chainable adapters for middleware-style extension. The SSL layer enforces TLS 1.2 minimum, supports ALPN negotiation, SNI, hostname verification, and session caching; all through OpenSSL 3.x’s EVP API.
The HTTP module implements client and server with WebSocket upgrade (RFC 6455), multipart forms, cookie management, and streaming responses. Built on llhttp for parsing; the same parser Node.js uses.
Performance
We wanted to know exactly where the overhead lives, so we built a benchmark suite that isolates each layer of the stack. The baseline is a raw libuv + llhttp server: hand-rolled C, no abstractions, no connection management, no header building.
With HTTP/1.1 keep-alive; the realistic production scenario where connections are reused across requests:
| 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 |
Icey delivers 75% of raw libuv throughput with a full HTTP stack: connection pooling, header construction, signal-driven dispatch, WebSocket upgrade, streaming responses. It outperforms Go by 34% and Node.js by 59%.
The difference against Go is architectural. Go’s net/http uses goroutines with an M:N scheduler and garbage collector; Icey talks directly to libuv’s event loop with zero-copy buffer reuse, connection pooling, and no GC pauses. Against Node.js the gap is wider: V8’s JIT compiler and JavaScript bridge add overhead that C++ avoids entirely.
All benchmark code ships with the library. Run benchmark.sh and get a reproducible comparison table on your own hardware.
NAT traversal
Full RFC implementations of STUN (5389) and TURN (5766). Not thin wrappers; actual server and client implementations with:
- STUN: Binding requests for NAT detection, transaction IDs, message integrity, fingerprint validation
- TURN: Relay allocations (UDP and TCP), permission management, channel binding for fast-path data relay, configurable lifetime enforcement
Channel binding is the detail most TURN implementations skip. It replaces 36-byte STUN headers with 4-byte channel headers on the data path; significant when you’re relaying real-time media at scale.
Signal-driven architecture
The event system uses typed Signal<T> templates with attach() for callback registration. Thread-safe, multi-listener, and used everywhere; from socket events to stream state changes to installation progress. No virtual inheritance hierarchies. No observer pattern boilerplate. Just connect a lambda and go.
server.Connection += [](http::ServerConnection::Ptr conn) {
conn->Payload += [](auto&, const MutableBuffer& buf) {
// handle incoming data
};
};
15 modules, zero bloat
Each module is self-contained with explicit dependencies. Enable what you need, skip what you don’t:
| Module | What it does |
|---|---|
| base | Event loop, signals, packet streams, threading, timers, filesystem, logging |
| crypto | HMAC, SHA-256/512, RSA, X509; all OpenSSL 3.x EVP |
| net | TCP/SSL/UDP sockets, DNS, chainable adapters |
| http | Client/server, WebSocket RFC 6455, forms, cookies, streaming, keep-alive |
| json | JSON serialisation (nlohmann/json) |
| av | FFmpeg capture, encode, decode, record, stream (FFmpeg 5/6/7) |
| 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 |
| pacm | Package manager for plugin distribution |
| pluga | Dynamic plugin loading with ABI versioning |
| archo | ZIP extraction with path traversal protection |
| sched | Deferred and periodic task scheduling |
All external dependencies resolve through CMake FetchContent; libuv 1.50, llhttp 9.2.1, OpenSSL 3.x, nlohmann/json 3.11.3, minizip-ng, zlib 1.3.1. No system package requirements beyond a C++20 compiler.
Build and test
git clone https://github.com/sourcey/icey.git
cd icey
cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON
cmake --build build --parallel $(nproc)
ctest --test-dir build --output-on-failure
Modules build automatically when their dependencies are found. Install FFmpeg and OpenCV on your system, and the av module builds with them:
# Install optional deps (Ubuntu/Debian)
sudo apt install libavcodec-dev libavformat-dev libswscale-dev libopencv-dev
# Build - CMake auto-detects everything
cmake -B build -DCMAKE_BUILD_TYPE=Release
Use as a dependency:
include(FetchContent)
FetchContent_Declare(icey
GIT_REPOSITORY https://github.com/sourcey/icey.git
GIT_TAG v2.1.0
)
FetchContent_MakeAvailable(icey)
target_link_libraries(myapp PRIVATE icy_base icy_net icy_http)
CI runs GCC 12+, Clang 15+, AppleClang 15+, and MSVC 2022. ASan, TSan, and UBSan sanitizer builds catch the things unit tests miss.
