Multi-protocol SDR signal decoder with real-time streaming
WaveKit connects to Software Defined Radio sources and decodes multiple signal types within the captured frequency window. Different bands require retuning or additional dongles. Aircraft tracking, ship positions, pager messages, digital voice, weather sensors—all decoded in parallel and streamed via WebSocket. It can also expose the internal IQ stream as an RTL-TCP endpoint so SDR++ can tune locally without opening a second upstream connection.
┌─────────────────┐ ┌──────────────────────────────────────┐ ┌─────────────────┐
│ RTL-SDR │ │ WaveKit Container │ │ Your Apps │
│ on Raspberry Pi│────▶│ │────▶│ │
│ rtl_tcp :1234 │ IQ │ 9 Decoders running in parallel: │ WS │ CLI Dashboard │
│ │ │ ✈️ ADS-B 🚢 AIS 📟 Pagers 📻 DMR │ │ Web UI │
└─────────────────┘ └──────────────────────────────────────┘ └─────────────────┘
Run WaveKit and its decoder tools in Docker. From the repository root:
make app-up
make app-status
# Follow logs:
make app-logsThe standalone app runs at http://127.0.0.1:9000 with no hardware required and
no dependency on SDR++. Compose uses wavekit:local-core and builds the
final-core image if it is missing. The image contains Node and the decoder
binaries; no host decoder installation is needed.
See Docker runtime to select the Pi source or a USB
bridge and fresh-card Pi setup for the bundled
SDR host installer. Stop the app with make app-down.
With Raspberry Pi Imager 2 installed and a built WaveKit SD image in
output/pi-image/, run make sdr-host-imager to open the dedicated WaveKit
catalog. Configure Wi-Fi and SSH in Imager, write the card and boot the Pi.
See the fresh-card guide for image build prerequisites and acceptance status.
Native scripts are optional development tools: pnpm install --frozen-lockfile,
pnpm run doctor, and pnpm dev:local. The terminal dashboard can be launched
with make dev-dashboard against the Docker API. See native development.
WaveKit's primary interface is an interactive terminal dashboard built with Ink/React:
┌─ WaveKit Dashboard ─────────────────────────────────────────────────────────┐
│ [1] Dashboard [2] Decoders [3] Output [4] Backpressure [5] Sources [6] Audio [7] Resources [8] Tuner │
├─────────────────────────────────────────────────────────────────────────────┤
│ DECODERS │
│ Running: 5/9 Healthy: 5/5 Total Events: 12,847 │
│ ● dsd-fme ● multimon-ng ● readsb ● ais-catcher │
│ │
│ BACKPRESSURE │
│ Status: All flowing Drop Rate: 0 B/s Flowed: 847 MB Dropped: 0 B │
│ │
│ SOURCES │
│ Connected: 1/1 Fanout Consumers: 5 │
│ ● sdrpp-main @ tcp://192.168.1.69:5555 │
│ │
│ RECENT MESSAGES │
│ 14:23:45 [readsb] aircraft ICAO:A4B2C1 ALT:35000 SPD:450 │
│ 14:23:44 [ais] ship MMSI:123456789 LAT:37.77 LON:-122.41 │
│ 14:23:43 [multimon] message POCSAG1200 ADDR:1234567 "Test message" │
├─────────────────────────────────────────────────────────────────────────────┤
│ [q] Quit [r] Reconnect [1-7] Switch tabs │
└─────────────────────────────────────────────────────────────────────────────┘
Keyboard shortcuts:
1-8— Switch between tabs (Dashboard, Decoders, Output, Backpressure, Sources, Audio, Resources, Tuner)r— Reconnect WebSocketq— Quit
Environment variables:
WAVEKIT_WS_URLS=ws://localhost:9000/ws # WebSocket endpoints (comma-separated)
WAVEKIT_API_URL=http://localhost:9000 # REST API endpoint| Decoder | Signals | Use Case |
|---|---|---|
| readsb | ADS-B 1090 MHz | Aircraft tracking |
| AIS-catcher | AIS 162 MHz | Ship tracking |
| acarsdec | ACARS VHF | Aircraft data link |
| dumpvdl2 | VDL2 136 MHz | Aviation data link |
| dsd-fme | DMR, P25, YSF, D-Star | Digital voice |
| multimon-ng | POCSAG, FLEX, DTMF | Pagers, tones |
| direwolf | APRS 144 MHz | Amateur radio |
| rtl_433 | ISM 433/915 MHz | Weather sensors, IoT |
| lora-meshtastic | LoRa 868/915 MHz | Meshtastic mesh packets |
All decoders are pre-built in the Docker image. Enable/disable via configuration.
SDR Source (rtl_tcp/SDR++)
│
▼
SourceManager ──────────────────────────────────────────┐
│ │
▼ │
FanoutManager ─────┬─────┬─────┬─────┬─────┐ │
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ │
dsd-fme multimon readsb ais acars vdl2 mesh │
│ │ │ │ │ │ │
└────────────┴─────┴─────┴─────┴─────┘ │
│ │
▼ │
DecoderManager ◀──────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
REST API WebSocket Audio TCP Tuner Relay
:9000/api :9000/ws :8080 :1234
Key components:
- SourceManager — TCP connections to SDR sources with auto-reconnect
- FanoutManager — Multiplexes audio to all decoders with backpressure handling
- DecoderManager — Spawns/monitors decoder processes, handles restarts
- API Server — Fastify REST + WebSocket for control and real-time events
- Tuner Controller — Primary RTL-TCP tuner control with internal/external handoff
- Tuner Relay — Optional RTL-TCP server for local tuner clients (SDR++)
# Health check
curl http://localhost:9000/health
# Full system status
curl http://localhost:9000/api/status
# List decoders
curl http://localhost:9000/api/decoders
# Start/stop a decoder
curl -X POST http://localhost:9000/api/decoders/readsb/start
curl -X POST http://localhost:9000/api/decoders/readsb/stop
# List sources
curl http://localhost:9000/api/sources
# List tuner states
curl http://localhost:9000/api/tuner
# Set tuner frequency
curl -X POST http://localhost:9000/api/tuner/rtl-pi/frequency \\
-H "Content-Type: application/json" -d '{"hz":144800000}'Connect to ws://localhost:9000/ws and subscribe to channels:
const ws = new WebSocket("ws://localhost:9000/ws")
ws.send(
JSON.stringify({
type: "subscribe",
channels: [
"decoders",
"sources",
"metrics",
"health",
"fanout",
"live-audio",
],
}),
)
ws.onmessage = event => {
const msg = JSON.parse(event.data)
// msg.type: 'decoder:output', 'decoder:health', 'source:connected', etc.
// msg.data: event payload
}Channels:
decoders— Decoder output, start/stop eventssources— Source connection eventsmetrics— Data rate metrics (~5s intervals)health— Decoder health state changesfanout— Backpressure snapshotslive-audio— Live demod status/config eventsresources— Container, SDR host, and backpressure metricstuner— RTL-TCP tuner state/command events
Decoded audio streams over TCP port 8080:
# Play with sox
nc localhost 8080 | play -t raw -r 48000 -e signed -b 16 -c 1 -
# Play with ffplay
nc localhost 8080 | ffplay -f s16le -ar 48000 -ac 1 -nodisp -WaveKit can directly control RTL-TCP sources without SDR++. Use the CLI Tuner tab or the REST API to tune frequency, gain, and other settings.
CLI keys (Tuner tab):
up/down— Tune frequencyleft/right— Change tuning step (also supports[ ] , . /)g— Toggle gain mode (AGC/manual)+/-— Adjust gain (manual only)s— Edit sample ratep— Edit PPM correctiona— Toggle RTL AGCb— Toggle bias-teed— Cycle direct sampling (off/I/Q)o— Toggle offset tuningc— Release/reclaim control (internal/external)
# List tuner states
curl http://localhost:9000/api/tuner
# Set frequency
curl -X POST http://localhost:9000/api/tuner/rtl-pi/frequency \\
-H "Content-Type: application/json" -d '{\"hz\":144800000}'
# Release control to SDR++
curl -X POST http://localhost:9000/api/tuner/rtl-pi/control-mode \\
-H "Content-Type: application/json" -d '{\"mode\":\"external\"}'
# Reclaim control
curl -X POST http://localhost:9000/api/tuner/rtl-pi/control-mode \\
-H "Content-Type: application/json" -d '{\"mode\":\"internal\"}'Expose the internal IQ stream as an RTL-TCP compatible endpoint for SDR++ (or any RTL-TCP client). Control commands are forwarded upstream so you only keep a single connection to the remote RTL-SDR.
tunerRelay:
enabled: true
host: "0.0.0.0"
port: 1234
sourceId: "rtl-pi"
controlPolicy: "exclusive" # or "shared"
commandHistoryLimit: 200 # 0 disables command historyUsage:
- Start WaveKit with the relay enabled.
- In SDR++, select RTL-TCP and connect to
tcp://<wavekit-host>:1234. - Tune as usual — SDR++ commands are forwarded to the upstream
rtl_tcp/rtlmux.
Notes:
- The relay expects an IQ source in
U8_IQformat (standard RTL-TCP/rtlmux output). - In
exclusivemode, the first client gets control and others are read-only. - The relay streams the primary source (first in
sources), so setsourceIdto match it. - RTL-TCP commands are tracked and available via
GET /api/tuner-relay. - Relay commands update the internal tuner state and will switch control mode to
externalwhile a relay control client is active. When the relay becomes idle, control returns tointernalunless you explicitly released control.
Dynamic Sample Rate:
When SDR++ changes the sample rate, WaveKit automatically:
- Updates source capabilities
- Restarts the LiveDemodulator with new decimation rates
- Restarts affected decoders to maintain optimal decoding
- Broadcasts
source:caps-changedto WebSocket clients
This enables seamless tuning without manual reconfiguration.
Live demodulates IQ in real time and serves mono audio over HTTP. Ideal for quick monitoring with ffplay/VLC without touching decoder configs.
Quick start:
liveDemod:
enabled: true
sourceId: "rtl-pi"
httpPort: 8081
modulation: "nfm"
bandwidth: 12500
squelch: 0
noiseReduction: "off"
lowPass: 0
highPass: 0
gain: 2.0
deEmphasis: false
deEmphasisTau: 50
audioFormat: "s16le"
offsetHz: 0# Start demodulation (if not auto-started)
curl -X POST http://localhost:9000/api/live-audio/start
# Play the self-describing WAV stream (no format flags needed)
ffplay -nodisp -autoexit http://localhost:8081/stream.wav
# Or the raw stream: format and exact rate are in the X-Audio-Format and
# X-Sample-Rate response headers (round the rate for ffplay)
ffplay -nodisp -autoexit -f s16le -ar 24976 -ch_layout mono http://localhost:8081/streamConfiguration reference:
sourceId— IQ source to demodulate (defaults to first source)modulation—nfm|wfm|am|usb|lsb|dsb|cw|rawbandwidth— Target audio bandwidth in Hz (0 allowed only forraw)squelch— channel power threshold before demodulation, dBFS (-160 to 0);0keeps squelch open.channelPowerDbfsin/api/live-audio/statusshows the current level (noise is typically -55 to -65 dBFS, a nearby carrier -10 to -30)noiseReduction—off|voice|noaa-apt|narrow-bandlowPass/highPass— Optional audio filters in Hzgain— Audio gain multiplier (default 2: ±5 kHz FM deviation at 0.8 full scale)offsetHz— Channel offset from the tuned centre; a carrier at centre + offsetHz is shifted to DC before filtering. Tune the dongle a few kHz off the carrier and set this, so the receiver's DC spike stays out of the channeldeEmphasis/deEmphasisTau— FM de-emphasis (50 or 75 microseconds)audioFormat—s16leorf32leiqDcBlock— Deprecated and ignored (it corrupted I/Q); useoffsetHz
API examples:
# Status
curl http://localhost:9000/api/live-audio/status
# Update modulation on the fly
curl -X PATCH http://localhost:9000/api/live-audio/config \
-H "Content-Type: application/json" \
-d '{"modulation":"am","bandwidth":10000}'
# Presets
curl http://localhost:9000/api/live-audio/presetsConfiguration via YAML (config/default.yaml) or environment variables:
# Sources
sources:
- id: "sdrpp-main"
type: "sdrpp-network"
host: "192.168.1.69"
port: 5555
caps:
kind: "audio_pcm"
sampleRate: 48000
format: "FLOAT32LE"
# Decoders
decoders:
- id: "dsd"
type: "dsd-fme"
enabled: true
sourceId: "sdrpp-main"
options:
mode: "auto"
# API
api:
host: "0.0.0.0"
port: 3000
# Audio output
audio:
tcpPort: 8080
format: "S16LE"
sampleRate: 48000
# Tuner relay (RTL-TCP)
tunerRelay:
enabled: true
host: "0.0.0.0"
port: 1234
sourceId: "sdrpp-main"
controlPolicy: "exclusive"Environment overrides:
WAVEKIT_API_PORT=9000
WAVEKIT_LOG_LEVEL=debug
WAVEKIT_SOURCES_0_HOST=192.168.1.100
WAVEKIT_TUNER_RELAY__ENABLED=true
WAVEKIT_TUNER_RELAY__PORT=1234- Node.js 22+ (
.nvmrcpins v25.2.1; Dockerfile usesnode:22-bookworm-slim) - pnpm 10 via Corepack (
corepack enable && corepack prepare pnpm@10.28.0 --activate) - Docker with BuildKit + buildx (run
make docker-initonce) - RTL-SDR dongle (or rtl_tcp server)
# Native dev loop (no Docker — headline iteration loop)
pnpm dev # esbuild watch + node --watch against src/index.ts
make dev-dashboard # Build and launch the Ink/React CLI dashboard
make dev-configs # List configs in config/
# Full container stack (dev profile of compose.yaml)
make dev-stack # Build + start sdrpp-server + wavekit-api
make dev-stack-logs # Follow logs from both services
make dev-stack-down # Stop and remove containers
make dev-shell # Shell into the running wavekit-api container
make dev-status # ps + curl /health
# Monorepo Tasks (pnpm + Turborepo)
pnpm run build # Build all packages + bundle src/
pnpm run typecheck # Type check workspaces + root
pnpm run lint # eslint .
pnpm test # Test all packages + root vitest
pnpm run test:coverage # With coverage (v8)
# Docker Images
make docker-build # Bake the default group (final, final-core, final-sdrpp)
make docker-push # Multi-arch push to GHCR with mode=max cacheWaveKit uses a pnpm monorepo with Turborepo for task orchestration:
wavekit/
├── packages/ # Internal packages
│ ├── shared/ # @wavekit/shared — Logger, errors
│ ├── api-types/ # @wavekit/api-types — Shared API types
│ └── sdr-host/ # @wavekit/sdr-host — Remote dongle host
│
├── src/ # Core WaveKit
│ ├── index.ts # Entry point
│ ├── config.ts # Zod schemas + config loading
│ ├── core/ # Stream infrastructure
│ │ ├── source-manager.ts
│ │ ├── fanout-manager.ts
│ │ └── audio-output.ts
│ ├── decoders/ # Decoder plugin system
│ │ ├── base-decoder.ts
│ │ ├── manager.ts
│ │ └── builtin/ # 9 decoder adapters
│ ├── api/ # Fastify REST/WebSocket
│ └── utils/
│
└── cli/ # @wavekit/cli — Terminal dashboard (Ink/React)
└── source/
├── app.tsx
├── components/
└── hooks/
- Create
src/decoders/builtin/my-decoder.tsextendingBaseDecoder - Implement
getCommand(),getArgs(),parseOutput() - Register in
src/decoders/registry.ts - Add config schema to
src/config.ts
See docs/DECODER-GUIDE.md for detailed instructions.
Three build targets, all produced by a single docker buildx bake invocation:
| Target | Contents | Use Case |
|---|---|---|
wavekit:latest |
SDR++ + API + Decoders | All-in-one |
wavekit:latest-core |
API + Decoders | External SDR++ |
wavekit:latest-sdrpp |
SDR++ only | Dedicated SDR host |
# Build the default group (all three images)
make docker-build
# Single-arch local build (skip multi-arch when you don't need it)
docker buildx bake --file docker/bake.hcl default --set "*.platform=linux/amd64" --load
# Run with external SDR++
docker run -p 9000:3000 -p 8080:8080 \
-p 1234:1234 \
-e WAVEKIT_SOURCES_0_HOST=192.168.1.69 \
-e WAVEKIT_SOURCES_0_PORT=5555 \
-e WAVEKIT_TUNER_RELAY__ENABLED=true \
wavekit:latest-coreSee docs/DOCKER-SETUP.md for the compose profiles (dev, prod-single-host, prod-distributed, demod-test) and the GHCR-backed registry cache.
For optimal reception:
# On Raspberry Pi - start rtl_tcp with AGC
rtl_tcp -a 0.0.0.0 -p 1234 -f 446524920 -s 2048000 -g 0
# Key settings:
# -g 0 AGC mode (critical for weak signals)
# -s 2048000 Sample rate 2.048 Msps (not 2.4!)For multiple clients, use rtlmux:
rtlmux -a 0.0.0.0 -p 5555 -s 5556 127.0.0.1 1234- API Reference — Full REST/WebSocket documentation
- Docker Setup — Container deployment guide
- SDR Host Setup — Remote dongle hosting with rtlmux
- Decoder Guide — Adding new decoders
- Architecture — System design deep dive
- Security — Version pinning, CVE tracking
WaveKit is licensed under AGPL-3.0-or-later (see LICENSE). You may use, modify and run it. If you distribute a modified version, or offer it to users over a network, you must publish your changes under the same license.
Using WaveKit through its REST or WebSocket API from a separate program does not place that program under the AGPL. @wavekit/api-types (packages/api-types) is MIT-licensed so client projects can import the API types freely.
The bundled third-party decoders run as separate processes and keep their own licenses.