Icey

Icey

The C++ media stack. Modular C++20 toolkit that unifies FFmpeg, libuv, OpenSSL, and libdatachannel into a composable pipeline for building media servers, video pipelines, and real-time communication systems.

C++20libuvFFmpegOpenCVOpenSSL 3.xWebRTC

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)libdatachannelGStreamerIcey
Build systemGN/NinjaCMakeMesonCMake
Build timeHoursMinutes30+ minMinutes
Binary size50MB+SmallLargeSmall
SSLBoringSSL (conflicts)OpenSSLOpenSSLOpenSSL
Media codecsBundledNoneGObject pluginsFFmpeg (any codec)
Capture/encodeIncludedNoPlugin pipelinePacketStream pipeline
SignallingNoNoNoSymple (built-in)
TURN serverNoNoNoRFC 5766 (built-in)
LanguageC++C++17C/GObjectC++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:

ServerReq/secLatency
Raw libuv+llhttp96,0881.04ms
Icey72,2091.43ms
Go 1.25 net/http53,8782.31ms
Node.js v2045,5143.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:

ModuleWhat it does
baseEvent loop, signals, packet streams, threading, timers, filesystem, logging
cryptoHMAC, SHA-256/512, RSA, X509; all OpenSSL 3.x EVP
netTCP/SSL/UDP sockets, DNS, chainable adapters
httpClient/server, WebSocket RFC 6455, forms, cookies, streaming, keep-alive
jsonJSON serialisation (nlohmann/json)
avFFmpeg capture, encode, decode, record, stream (FFmpeg 5/6/7)
symplePresence, messaging, rooms, WebRTC call signalling
stunRFC 5389 STUN for NAT traversal
turnRFC 5766 relay server with channel binding
webrtcWebRTC via libdatachannel: media bridge, peer sessions, codec negotiation
pacmPackage manager for plugin distribution
plugaDynamic plugin loading with ABI versioning
archoZIP extraction with path traversal protection
schedDeferred 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.