Skip to content

Architecture

Doppler is a stack of four layers. Each layer is independently useful; together they take you from raw DSP primitives to a running multi-process signal pipeline in a handful of commands.

Apps & Tools — specan, your own sinks & UIs
Pipeline CLI — doppler compose (YAML + Dopplerfile)
Transport — NATS streaming (PUSH/PULL, PUB/SUB)
DSP Library — C99 core, dozens of modules (NCO, FIR, FFT, DDC, Resampler, AGC, DSSS, tracking loops, and more)
Python (thin ctypes)
Rust FFI (safe wrap)

Layer 1 — DSP Library (C99 core)

The entire algorithm library lives in one portable C99 library, across dozens of modules — NCO, FIR, FFT, DDC, polyphase resampler, ring buffers, AGC, DSSS acquisition/despreading, carrier/symbol tracking loops, and more — each implemented once, tested once, and callable from any language through the dp_* C ABI.

Language bindings are thin wrappers over this ABI. The Python doppler package and the Rust FFI crate both call the same C functions. There is no Python reimplementation of the NCO, no Rust port of the FIR engine — just glue: type conversion, error translation, memory lifetime.

See the API reference for the full C API.


Layer 2 — Transport (NATS streaming)

stream/stream.h adds a NATS-backed wire protocol on top of the DSP library. It defines one header struct (dp_header_t), one magic value (SIGS), and three messaging patterns, each addressed by a nats://host:port/subject endpoint:

Pattern Use
PUSH/PULL Unidirectional pipeline — source → block → sink (NATS JetStream work-queue tier)
PUB/SUB Fan-out — one source, many subscribers (NATS core pub/sub)
REQ/REP Request/response — configuration, queries (NATS request/reply)

Every block speaks the same framing format. A C transmitter can push to a Python subscriber and vice versa. The transport is optional — if you only need the DSP primitives, skip it entirely. It requires a running nats-server (e.g. nats-server -js) to connect to.

See API: Streaming.


Layer 3 — Pipeline CLI (doppler compose)

doppler compose is a process orchestrator that wires blocks into pipelines using the streaming transport. You describe a chain in a YAML file (or generate one with compose init); compose up assigns NATS subjects, spawns each block as an independent OS process, and tracks their state.

doppler compose init tone fir specan --name demo
doppler compose up demo
doppler ps
doppler compose down demo

Custom blocks are defined in a Dopplerfile — a small YAML file that names an entry-point function and its dependencies. No C required; any Python (or compiled binary) that pulls frames from an upstream NATS subject and pushes them to a downstream one qualifies as a block.

See CLI & Pipelines and Dopplerfile.


Layer 4 — Apps & Tools

Spectrum analyzer (doppler-specan) is a first-class app built on the streaming layer. It runs as a pipeline sink, reads IQ frames from a NATS PULL subject, computes FFT magnitude, and serves a live web UI. Because it speaks the same wire format as every other block, it snaps onto any compose pipeline as a final stage — or runs standalone against any dp_pub_t source. Either way it needs a running nats-server (e.g. nats-server -js) to connect to.

# As a compose sink
doppler compose init tone specan --name view

# Or wire it into your own pipeline
doppler-specan --source pull --address nats://127.0.0.1:4222/work

See Spectrum Analyzer.


A complete flow

flowchart LR
    subgraph compose ["doppler compose up demo"]
        CFG["demo.yml"]
    end

    TONE["tone\nsource"]
    FIR["fir\nDSP block"]
    SPECAN["specan\nsink"]

    compose -- "spawns + tracks" --> TONE & FIR & SPECAN
    TONE -- "PUSH nats://…/work.1" --> FIR
    FIR  -- "PUSH nats://…/work.2" --> SPECAN

The compose runner is the only process that knows the full topology. Each block only knows its upstream PULL subject and downstream PUSH subject — the C library does the actual signal processing, and NATS moves the frames between them.