intercom
I server-client architecture for an intercom service
Releases
Intercom
A simple, always-connected voice intercom system in C++. A relay server runs on a Linux box (or anywhere) and keeps every client connected at all times; holding a global hotkey (or a physical button wired to simulate it) transmits your voice to every other connected client.
- Secret-key authentication (challenge/response — the secret never crosses the wire in plaintext)
- Opus-encoded audio, relayed by the server with no mixing/decoding server-side
- Clients stay connected persistently, not just while talking, via periodic heartbeats and auto-reconnect
- Server writes
manifest.jsonon first launch with its port and a randomly generated secret - ImGui debug GUI on the server (connected clients, IPs, last transmission, uptime) with a console fallback when no desktop is present
- Client GUI shows connection/transmit status and lets you pick input/output audio devices, with a console fallback for headless boxes (e.g. a bare Raspberry Pi)
- Global push-to-talk hotkey (
ctrl+alt+shift+Tto start,ctrl+alt+shift+Yto stop) on Windows, macOS, and Linux (X11 or rawevdev, so it also works with no display server) - One CMake project builds both
intercom_serverandintercom_clientfor Windows, macOS, and Linux, x86 or ARM
Screenshots
| Server (debug GUI) | Client |
| --- | --- |
|
|
|
Headless server (no desktop environment detected — e.g. a bare Raspberry Pi over SSH):
$ ./intercom_server
intercom_server - manifest.json loaded (port 45820, secret c59dcfd2...)
Intercom server listening on port 45820
No desktop environment detected - printing connection status to console.
==================== intercom server ====================
uptime: 00:00:00 port: 45820 clients: 0
id name ip last tx
===========================================================
[relay_server] client 1 (pi-frontdoor) authenticated from 127.0.0.1
==================== intercom server ====================
uptime: 00:00:05 port: 45820 clients: 1
id name ip last tx
1 pi-frontdoor 127.0.0.1 never
===========================================================How it works
The server is a plain relay: it does not decode, encode, or mix audio. Each client encodes its own microphone input with Opus and sends it upstream; the server fans that packet out to every other authenticated client untouched; each receiving client decodes it and mixes it with any other active speakers before writing it to the output device. This keeps the server lightweight and puts codec work only on the machines actually producing/consuming audio.
Every client keeps a single persistent TCP connection to the server, authenticated once via a challenge/response handshake (SHA-256(secret_key || server_nonce), so the shared secret itself is never transmitted) and kept alive afterwards with periodic pings — this is what lets "always connected, not just when in use" work: the connection, and the heartbeat that proves it's alive, exist independently of whether anyone is currently talking.
Security note: there is no TLS. The handshake keeps the secret off the wire, but audio and control traffic are otherwise unencrypted. This is designed for a trusted LAN or VPN, not for exposing the server directly to the internet.
Building
Requires CMake 3.21+ and a C++17 compiler. All third-party dependencies (Asio, Opus, PortAudio, SDL2, Dear ImGui, nlohmann/json, PicoSHA2) are fetched automatically via FetchContent — there's nothing to install for those.
System prerequisites (things CMake can't fetch, since they're OS-level drivers/libraries):
- Linux:
libasound2-dev(ALSA, for PortAudio) andlibx11-dev(for the desktop hotkey backend — optional, the client falls back to reading/dev/inputdirectly if X11 isn't available, e.g. on a headless Pi) - Windows / macOS: nothing extra — the platform audio/window APIs are part of the SDK
cmake -B build
cmake --build build --config ReleaseThis produces intercom_server and intercom_client under build/server/ and build/client/.
Raspberry Pi / ARM
The straightforward, tested path is building natively on the target (a Raspberry Pi, an ARM Mac, etc.) — the command above works unchanged. Cross-compiling from a different host architecture is possible but not the supported path, since SDL2/PortAudio/Opus each need matching ARM system libraries; see cmake/toolchains/raspberry-pi.cmake for a best-effort starting point if you want to go that route.
Running the server
./intercom_serverOn first launch this creates manifest.json next to the binary:
{
"listen_port": 45820,
"secret_key": "<64 random hex characters>",
"server_name": "Intercom Server",
"audio": { "sample_rate": 48000, "channels": 1, "frame_ms": 20, "opus_bitrate": 32000 },
"heartbeat_interval_sec": 5,
"heartbeat_timeout_sec": 15
}Share listen_port and secret_key with whoever is setting up clients — those are all a client needs to connect. Edit manifest.json directly (e.g. to change the port) and restart the server to apply changes; delete it to generate a fresh random secret.
If a desktop environment is available (always true on Windows/macOS; on Linux, whenever $DISPLAY or $WAYLAND_DISPLAY is set), a debug window opens showing connected clients, their IPs, last transmission time, and server uptime. Otherwise the same information prints to the console every few seconds.
Running the client
./intercom_clientOn first launch this creates client_config.json with blank server fields:
{
"server_host": "",
"server_port": 45820,
"secret_key": "",
"display_name": "intercom-client",
"input_device": "",
"output_device": ""
}Fill in server_host/secret_key either by editing the file directly or through the GUI's "Server Settings" panel (host, port, secret, display name, then Save & Reconnect). The GUI also lets you pick input/output audio devices from a dropdown; your selection is remembered in client_config.json.
Transmitting: hold ctrl+alt+shift+T to start talking, ctrl+alt+shift+Y to stop — this is a system-wide hotkey, so it works even when the client window isn't focused. There's also a "Hold to Talk" button in the GUI if you'd rather not use the hotkey.
If no desktop is available, the client still runs headless: it stays connected, the hotkey (and any physical button wired to simulate it) still works, and connection status prints to the console instead of a window.
Raspberry Pi client
The intended headless setup: a Pi with a mic, a speaker, and a physical push-to-talk button. Wire the button so that pressing it simulates the ctrl+alt+shift+T / ctrl+alt+shift+Y key combination (e.g. a GPIO-to-uinput bridge, or any script that injects those key events) — the client doesn't need to know about GPIO itself, it just listens for the same global hotkey a desktop user would use. With no display server running, the hotkey listener falls back to reading raw key events from /dev/input/event* directly, which needs the user running the client to be in the input group (or run as root):
sudo usermod -aG input $USER # then log out/inConfiguration reference
| File | Owner | Purpose |
| --- | --- | --- |
| manifest.json | server | Listen port, secret key, audio params, heartbeat timing. Auto-created on first launch. |
| client_config.json | client | Server address/port/secret, display name, chosen audio devices. Auto-created on first launch, edited via the GUI. |
Both are plain JSON in the working directory next to the binary and are gitignored (they contain per-install secrets).
Project layout
common/ shared wire protocol, JSON config helpers, Opus codec wrapper, crypto (used by both binaries)
server/ relay server: networking, client registry, ImGui debug GUI, console fallback
client/ client: networking, audio engine (PortAudio + Opus + per-source jitter buffers), global hotkey backends, GUI, console fallback
cmake/ FetchContent dependency declarations + a best-effort Raspberry Pi toolchain file
docs/demo/ screenshots used in this READMEKnown limitations
- No TLS — see the security note above.
- The Windows (
RegisterHotKey) and Linux X11/evdevhotkey backends are implemented against their documented APIs but have not been hardware-tested in this environment (development happened on macOS, where the Carbon-based backend was verified end-to-end). - The GUI's DPI/monitor-scale detection runs once at startup; dragging a window to a different-DPI monitor won't live-rescale it.
- Cross-compiling for ARM from a different host architecture isn't a turnkey path — see the Raspberry Pi section above.


