WebRTC in 150 Lines of C++
Camera to browser in under 150 lines of C++ over WebRTC.
Published
The first time you try to build Google’s libwebrtc, you run gclient sync and watch gigabytes of Chromium toolchain and dependencies download to your machine. Then comes the build. By the time it finishes, you’ve also inherited BoringSSL, Google’s fork of OpenSSL. Your project already links against OpenSSL. Two TLS libraries in the same process, and a linker that has opinions about that.1
It’s easy to come away thinking native WebRTC isn’t worth the pain, and reach for a managed service or a different language.
None of that pain is intrinsic to the protocol.
What follows is a complete WebRTC video streamer in C++20 that captures a camera and streams it to a browser. The whole application fits under 150 lines of code, excluding comments and blank lines. It builds with CMake against system OpenSSL.2
The build is just the entrance fee
The API is where the time goes. Peer connections, track sources, frame adapters, signalling threads, worker threads. You need to understand how the pieces fit and which thread an operation belongs on before you can get a camera frame to the browser. The reference documentation, in practice, is the Chromium source.
The way out turned out to be simpler than the framing suggested: don’t replace the whole thing. Replace the layers.
The Stack
| Layer | Implementation | What it owns |
|---|---|---|
| Capture and encoding | FFmpeg through Icey’s AV module | Camera or file input, decoded frames, H.264 encoding. |
| Media pipeline | Icey’s PacketStream | Connecting the source, processor and sender. |
| WebRTC transport | libdatachannel | ICE, DTLS, SRTP, tracks and RTP/RTCP handling. |
| Signalling | Symple through SympleSignaller | Exchanging the messages that establish the call. |
| Call lifecycle | Icey’s PeerSession | Accepting a call and exposing its state to the application. |
| Relay, when needed | A TURN service, including Icey’s TURN server | A network route when peers cannot connect directly. |
The point is to keep the useful libraries doing their own jobs. FFmpeg already knows how to handle media. libdatachannel supplies an independent WebRTC transport implementation. Icey joins them without asking the application to become a Chromium project. The C++ reference is built from Doxygen, so you can follow the types used here back to their declarations and comments.
The Pipeline
Icey’s core abstraction is PacketStream: a chain of sources, processors, and sinks that data flows through. A source emits packets, processors transform them, and a sink consumes them. You snap components together and call start().
For WebRTC, the pipeline is:
MediaCapture → VideoPacketEncoder → WebRtcTrackSender → browser
camera/file H.264 RTP/SRTP
In code:
stream.attachSource(&capture, false, true);
stream.attach(encoder, 1, true);
stream.attach(&session->media().videoSender(), 5, false);
stream.start();
Four lines. Camera frames flow into the H.264 encoder, then into the WebRTC track sender, which hands the encoded frames to libdatachannel for RTP packetisation and encrypted transport. The browser receives standard WebRTC media and renders it in a <video> element.
The capture belongs to the application; the stream borrows it. The encoder uses the shared-pointer attachment. The sender belongs to the peer session, which stays alive across calls. On shutdown, the application stops and joins capture, closes the pipeline, then destroys the session. That order matters: a capture thread must finish sending before you pull its destination out from under it.3
Start on the call state, not on the first message
A browser seeing the streamer’s presence is not the same thing as a working media connection. Neither is receipt of an initial call message.
The sample opens its input, configures the session, then joins the Symple public room and accepts an incoming call. It starts the media pipeline when the session becomes Active. When the call ends, it stops capture and the stream. The pipelineReady guard keeps repeat calls from attaching the same graph again; the session binds its sender to the new connection.
Signalling and media also travel different routes. Symple carries the call messages. The video travels over the WebRTC connection, directly or through a configured relay. A successful WebSocket connection proves that the peers can exchange messages; it does not prove they can exchange media.
The complete application
This is the complete webcam-streamer.cpp: camera selection, file fallback, signalling, encoding and shutdown. That includes every include, brace and command-line option. Comments and blank lines are left out of the count, not the source.
The default is the bundled test file. Add -camera 1 to request a camera; if none is found, it falls back to the file. No rebuild needed.
// icey — Copyright (c) 2005, icey <https://auscaster.com>
// SPDX-License-Identifier: LGPL-2.1+
// Camera or test file → H.264 → WebRTC → browser.
// Usage: webcam-streamer -host localhost -port 4500 -camera 1
#include "icy/application.h"
#include "icy/av/devicemanager.h"
#include "icy/av/videocapture.h"
#include "icy/av/videopacketencoder.h"
#include "icy/logger.h"
#include "icy/packetstream.h"
#include "icy/symple/client.h"
#include "icy/webrtc/codecnegotiator.h"
#include "icy/webrtc/peersession.h"
#include "icy/webrtc/support/symplesignaller.h"
#include <memory>
#include <optional>
#include <string>
using namespace icy;
class WebcamStreamer
{
smpl::Client client;
wrtc::SympleSignaller signaller;
std::unique_ptr<wrtc::PeerSession> session;
av::VideoCapture capture;
std::shared_ptr<av::VideoPacketEncoder> encoder;
PacketStream stream{"webcam-stream"};
bool pipelineReady = false;
public:
explicit WebcamStreamer(const smpl::Client::Options& opts)
: client(opts), signaller(client)
{
}
void start(bool useCamera)
{
auto camera = useCamera
? av::DeviceManager().negotiateVideoCapture("", 1280, 720, 30.0)
: std::nullopt;
if (camera) {
const auto& [device, cap] = *camera;
capture.openVideo(device.id, cap.width, cap.height, cap.maxFps, cap.pixelFormat);
} else {
if (useCamera)
LWarn("No camera found, falling back to test file");
capture.openFile(ICY_DATA_DIR "/test.mp4");
capture.setLoopInput(true);
capture.setLimitFramerate(true);
}
encoder = std::make_shared<av::VideoPacketEncoder>();
capture.getEncoderVideoCodec(encoder->iparams);
const auto& input = encoder->iparams;
encoder->oparams = wrtc::CodecNegotiator::resolveWebRtcVideoCodec(
av::VideoCodec("H264", "libx264", input.width, input.height, input.fps));
wrtc::PeerSession::Config config;
config.rtcConfig.iceServers.emplace_back("stun:stun.l.google.com:19302");
config.media.videoCodec = encoder->oparams;
config.media.videoDirection = rtc::Description::Direction::SendOnly;
config.enableDataChannel = false;
session = std::make_unique<wrtc::PeerSession>(signaller, config);
session->IncomingCall += [this](const std::string&) { session->accept(); };
session->StateChanged += [this](wrtc::PeerSession::State state) {
if (state == wrtc::PeerSession::State::Active)
startStreaming();
else if (state == wrtc::PeerSession::State::Ended)
stopStreaming();
};
client.Announce += [](const int& status) {
if (status != 200)
LError("Auth failed: ", status);
};
client.StateChange += [this](void*, smpl::ClientState& state, const smpl::ClientState&) {
if (state.id() == smpl::ClientState::Online) {
LInfo("Online as ", client.ourID());
client.joinRoom("public");
}
};
client.CreatePresence += [](smpl::Peer& peer) { peer["agent"] = "icey"; };
client.start();
}
void shutdown()
{
stopStreaming();
stream.close();
session->hangup("shutdown");
session.reset();
client.stop();
}
private:
void startStreaming()
{
if (!session->media().hasVideo())
return;
if (!pipelineReady) {
stream.attachSource(&capture, false, true);
stream.attach(encoder, 1, true);
stream.attach(&session->media().videoSender(), 5, false);
pipelineReady = true;
}
stream.start();
}
void stopStreaming()
{
// Join capture before stream teardown takes the processing lock.
capture.stop();
stream.stop();
}
};
int main(int argc, char** argv)
{
Logger::instance().add(std::make_unique<ConsoleChannel>("console", Level::Info));
smpl::Client::Options opts;
opts.user = "webcam-streamer";
opts.name = "Webcam Streamer";
opts.type = "streamer";
bool useCamera = false;
for (int i = 1; i + 1 < argc; i += 2) {
std::string key = argv[i];
std::string val = argv[i + 1];
if (key == "-host") opts.host = val;
else if (key == "-port") opts.port = static_cast<uint16_t>(std::stoi(val));
else if (key == "-token") opts.token = val;
else if (key == "-user") opts.user = val;
else if (key == "-name") opts.name = val;
else if (key == "-camera") useCamera = val == "1";
}
WebcamStreamer app(opts);
app.start(useCamera);
waitForShutdown([](void* opaque) {
static_cast<WebcamStreamer*>(opaque)->shutdown();
}, &app);
return 0;
}
Build the sample, then connect the browser
You need a C++20 compiler, CMake, the project’s native dependencies, and FFmpeg with the libx264 encoder available. The repository’s dependency setup covers the platform packages. CMake also fetches dependencies, so the first configure needs network access.4
git clone https://github.com/auscaster/icey.git
cd icey
git checkout d45cf654
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SAMPLES=ON -DWITH_FFMPEG=ON -DWITH_LIBDATACHANNEL=ON
cmake --build build --target webcam-streamer --parallel 4
Building that target leaves out unrelated sample executables. If the target is absent, inspect the configure output: the WebRTC module needs its media and transport dependencies, and the sample also needs Symple support.
Start a Symple server, then run the built webcam-streamer executable with its address. For a server listening locally on port 4500:
./build/webrtc/samples/webcam-streamer/webcam-streamer \
-host 127.0.0.1 -port 4500 -user streamer -name Webcam
That executable path is for the single-configuration build layout shown above. Multi-configuration generators can add a configuration directory.
The browser counterpart uses symple-player’s call manager. Connect it to the same signalling server and room, find the streamer through presence, and place the call. An arbitrary WebRTC demo page will not understand the Symple call protocol simply because it has a video element.
Use the test file for the first connection. Once the browser is receiving it, run the executable with -camera 1. That separates device discovery and camera permissions from signalling and media transport. The webcam-to-browser recipe follows the same path.
A working stream has several agreements underneath it
FFmpeg supporting a codec does not mean a browser will accept it over WebRTC. This sample uses H.264 through libx264 and Icey’s codec negotiator. The session description, codec parameters, packetisation and receiver support all have to agree. Low-latency encoder settings reduce one source of delay; they do not remove capture time, network delay or the browser’s receive buffers.
The media bridge exposes BitrateEstimate and KeyframeRequested for encoder feedback. This sample sends at its configured bitrate; it does not implement adaptive bitrate control. If you need it, those signals are where you connect the encoder policy.
The configured ICE server is STUN. It helps discover a reachable address. It is not a TURN relay, and it cannot make every network path work. If your deployment needs relayed connectivity, configure a TURN service and verify that a browser on another network can reach it. Icey’s TURN module is available separately; a server implementation in the repository is not an automatically configured relay in this sample.
The sample auto-accepts calls in a public room because it is demonstrating the media path. An application decides who may see a peer, place a call and view a camera. Put those decisions around the call lifecycle before using the sample as a product.
Go below the session when you need to
PeerSession is the convenient entry point, but the lower layers remain available. Track factories create tracks and their handlers. WebRtcTrackSender and WebRtcTrackReceiver connect media to PacketStream. MediaBridge groups the media pieces; PeerSession adds the call lifecycle.3
If you already have signalling over WebSocket, MQTT or another channel, you do not need to replace it merely to use the media pipeline. You need to exchange the correct SDP and ICE information and connect that exchange to the appropriate layer. The WebRTC module guide shows where those responsibilities sit.
The other samples take the same parts in different directions: file-streamer sends file media, media-recorder receives browser media and writes it through FFmpeg, and data-echo starts with a data channel. The sample directory contains their source and setup.
References
-
WebRTC’s native build instructions describe the Chromium toolchain and multi-gigabyte checkout. BoringSSL’s porting guide describes its relationship to OpenSSL and compatibility constraints. ↩