Skip to content

Repository files navigation

CLER Logo

a Compile Time DSP Flowgraph for SDRs and Embedded Systems

Ubuntu macOS License

Onboarding page · Try cler-fg in your browser

CLER is a C++ template-based framework for constructing and executing flowgraphs of DSP processing blocks. Its goal is to keep a tiny header only core allowing maximal flexibility:

  • Defining blocks amounts to implementing a struct with a method
  • Channels are type agnostic
  • Blocks are not limited by number of input/output Channels
  • Callbacks can be used for communication between unconnected blocks
  • Flowgraphs and Blocks can be made almost completely static
  • Tailored for Embedded Systems - even MCUs
  • Built for radio, but can also be used for control and dynamic simulations (supports cyclic graphs, and online modifiable params)
  • Cross-Platform
  • Code first, flowgraph GUI second: cler-fg edits the C++ file itself, no project files or generated code

But embedded devices dont need DSP do they? Embedded Linux aside, most embedded devices traditionally relied on dedicated chips — for fusion, filtering, or modulation. But with today’s powerful SoCs and the rise of agentic AI, it’s often faster, cheaper, and more flexible to move DSP into software. CLER aims to fill that gap.

Why reinvent the DSP wheel? Existing frameworks rely heavily on runtime polymorphism to manage blocks and channels. This adds overhead, limits type safety, and complicates deployment on resource-constrained systems. For example, GNU Radio uses void* buffers in its work() calls to achieve flexibility, but that sacrifices clarity and static guarantees. CLER takes a different path: using C++17 features like variadic templates and std::apply, it achieves compile-time safety, zero-cost abstraction, and minimal runtime footprint — making it practical for everything from desktop SDR to bare-metal MCUs.

How does it compare to GNURadio or FutureSDR? CLER takes a hybrid approach — combining the performance optimizations of modern frameworks with embedded-first design. On desktop systems, CLER leverages advanced techniques like doubly-mapped buffers for zero-copy performance that can match or exceed established frameworks. For embedded systems, CLER provide lightweight alternatives, maintaining deterministic behavior even without an MMU. This allows the same codebase to achieve optimal performance across platforms — from high-throughput desktop SDR to resource-constrained MCUs.

Want to try out some examples on a desktop?

mkdir build
cd build
cmake ..
make -j"$(nproc --ignore=1)"   # Use all cores-1
cd desktop_examples
./hello_world #(or mass_spring_damper if you want to see something cool)

⚠️ One thing to watch for: CLER's template-heavy design can produce long compiler errors. cler-fg catches the common mistakes (missing runners, type mismatches) before the compiler does, and any LLM copes well with a codebase this small.

Okay, but how does it write?

Below is desktop_examples/hello_world.cpp

int main() {
    cler::GuiManager gui(800, 400, "Hello World Plot Example");

    const size_t SPS = 1000;
    SourceCWBlock<float> source1("CWSource", 1.0f, 1.0f, SPS); //amplitude, frequency
    SourceCWBlock<float> source2("CWSource2", 1.0f, 20.0f, SPS);
    ThrottleBlock<float> throttle("Throttle", SPS);
    AddBlock<float, 2> adder("Adder"); // 2 inputs

    PlotTimeSeriesBlock plot(
        "Hello World Plot",
        {"Added Sources"},
        SPS,
        3.0f // duration in seconds
    );
    plot.set_initial_window(0.0f, 0.0f, 800.0f, 400.0f); //x,y, width, height

    auto flowgraph = cler::make_desktop_flowgraph(
        cler::BlockRunner(&source1, &adder.in[0]),
        cler::BlockRunner(&source2, &adder.in[1]),
        cler::BlockRunner(&adder, &throttle.in),
        cler::BlockRunner(&throttle, &plot.in[0]),
        cler::BlockRunner(&plot)
    );

    flowgraph.run();

    while (!gui.should_close()) {
        gui.render(flowgraph);
    }
    flowgraph.stop();
    return 0;
}

Cool, I want to use in my own project

We recommend CMake's FetchContent; then link against cler::cler (header-only core) or cler::desktop_blocks (core + GUI/plots/hardware blocks). Both carry their include directories.

set(CLER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(CLER_DEFAULT_MAX_WORKERS 8 CACHE STRING "" FORCE)

include(FetchContent)
FetchContent_Declare(
  cler
  GIT_REPOSITORY https://github.com/cariboulabs/cler
  GIT_TAG main
)
FetchContent_MakeAvailable(cler)

target_link_libraries(my_executable PRIVATE cler::desktop_blocks)

Things to Know

  • Cross-Platform Support
    CLER is fully supported on Linux and macOS. Windows users should use Windows Subsystem for Linux (WSL2) for the best experience.

  • Why WSL2 for Windows?

    • Full POSIX Compatibility: Get true Linux APIs and system calls instead of Windows API emulation
    • No Special Toolchain: Use the exact same build commands as on Linux
    • Better Performance: Direct filesystem access and native system calls
    • Recommended: This is the same approach used by most cross-platform C++ projects
    • Simple Setup: WSL2 takes minutes to install and integrates seamlessly with Windows
  • Schedulers
    CLER includes three schedulers: ThreadPerBlock (default, simple, debuggable), FixedThreadPool (round-robin workers, good for uniform fan-out) and PinnedIslands (core-pinned, cost-partitioned islands whose idle workers park instead of spinning — the pick for 2-core targets). Per-block statistics are opt-in (collect_detailed_stats) so the hot path stays clean.

  • Flowgraph vs Streamlined
    CLER supports two architectural styles:

    • flowgraph
      In flowgraph mode, you define each block and the channels that connect them, then hand the wiring to a flowgraph that runs the blocks through a scheduler (by default one thread per block calling its procedure(); a retryable error backs off before trying again).

    • streamlined
      In streamlined mode, you are in charge of writing the loop, and you are in charge of passing samples from one block to the other.

    When the blocks are simple, the streamlined approach is faster than the flowgraph because of the thread overhead. As a compromise, you can create superblocks which combine multiple small blocks. See streamlined and flowgraph as examples for the two architectural styles, and polyphase_channelizer for a superblock implementation.

  • Desktop Blocks:
    desktop_blocks is a library of useful blocks for quick "plug and play". Its soft dependencies are liquid-dsp and Dear ImGui (with OpenGL/GLFW). Link against cler::desktop_blocks with CMake. In CLER, it is rather easy to create blocks for specific use cases. As such, the library blocks were decided to be exactly the opposite - broad and general. There, we don't optimize minimal work sizes, and we dont template where we dont have to. Everything that can go on the heap - goes on the heap. These blocks should be GENERAL for quick mockup tests.

  • Buffers & Performance
    Our buffers are modified version of https://github.com/drogalis/SPSC-Queue. They allow for static or heap allocation. See the gain block in streamlined.cpp for an example.
    CLER supports four buffer access patterns with dramatically different performance characteristics:

    • Push/Pop (AVOID)
      For single values. there is also a try push/pop you can use if you dont inspect size() beforehand. Remember though, after you have poped a value, you must not put it back! CLER channels are lock-free SPSC that ASSUME that one thread is a writer while another is a reader. No mixin' it up.
      ⚠️ This is EXTREMELY SLOW - Orders of magnitude slower than optimal patterns. Only use for control/configuration data.

    • ReadN/WriteN (Good baseline)
      Provides bulk access to buffer data with automatic pointer management. You copy data to a temporary buffer for processing. ReadN/WriteN automatically advances the ring buffer pointers — no manual commit needed. Good general-purpose pattern with single memory copy.

    • Peek/Commit (Zero-copy read/write)
      Allows you to inspect (peek) data in the buffer without removing it, then explicitly commit the number of items you've processed. The downside is that you can only access data up to the physical end of the ring buffer at a time — so if your logical window wraps, you may need to handle two chunks.

    • read_dbf/write_dbf (the default)
      Doubly-mapped buffers provide true zero-copy access: virtual memory tricks present the ring buffer as a contiguous array, so there is no wrap-around handling. Requires a heap channel of at least 4 KB; on a too-small channel they assert in debug and return {nullptr, 0} in release.

    Prefer dbf by default; fall back to ReadN/WriteN when an external API needs its own contiguous buffer anyway.

cler-fg: the flowgraph editor

cler-fg opens a C++ flowgraph file directly: place blocks from the library, wire them, edit parameters, check, build and run — every gesture is a validated edit of the .cpp itself, so there is no project file and no generated code to keep in sync. Ask the built-in AI agent (Ctrl+J) and it proposes the same validated edits.

Try it in the browser at https://cariboulabs.github.io/cler/cler-fg/try/, or run the desktop app:

./cler_fg.sh

What is missing?

Below is a wish-list for this library, sorted by importance.

  • Comparing to GnuRadio / FutureSDR:
    It is important that we know where we stand. We need to measure our performance against the best in the business and produce a report.

  • Testing / CI / Profiling:
    If we are already producing a report, we might as well build a benchmark for the core patterns so performance does not regress with updates.

  • Blocks for Embedded Systems: Our /desktop_blocks library is built as a broad, general-purpose toolkit for desktop experiments and quick testing — but for real end-node applications, we need an /EmbeddedBlocks.

  • Hardware Support:
    If we are serious about this, we need to support real hardware: FPGAs, SDRs, DAC/ADC boards, RF transceivers, and similar peripherals. For this, we must ensure support for commodity hardware by introducing source/sink blocks for them.

Contributing

We welcome any contribution — and constructive criticism too!
There’s a lot we don’t know yet and plenty to learn from the SDR community.

How to add code:

  • Modern C++ (C++17) — but always mindful of embedded constraints.
  • 🌲 Improve existing blocks — solo-developing also means one pair of eyes. Fresh looks are always welcome.
  • Prefer templates and function pointers — avoid std::function and use lambdas only if required.
  • 🧩 Avoid std::any — to keep type safety explicit and predictable.
  • 🔗 Favor composition over inheritance — except for simple interfaces.
  • 🔒 No try/catch for flow control — use cler::Result for recoverable errors; throw only for unrecoverable states. assert is fine for startup guarantees.
  • 🗒️ Metadata inline — no separate tag streams; encode what you need in the channel type or pass via callbacks.
  • 🛠️ Implementation guidelines — Keep heavy implementations in .cpp files when possible (for example, when dealing with a single data type). Templated libraries already add compile-time cost, so we want to reduce that load wherever possible.

Acknowledgements

Special thanks to:

  • Andrew Drogalis — for the excellent SPSC-Queue implementation used as our channels.
  • Bastian Bloessl and the FutureSDR community — Your design choices inspired some of ours.
  • Joseph D. Gaeddert and the liquid-dsp community — In our opinion, the best DSP library out there by a margin.
  • Omar Ocornut and The Dear ImGui community — A fast "batteries included" GUI library that meets all of our needs.
  • The GNU Radio community — The benchmark to beat for open-source SDR frameworks.

About

No description, website, or topics provided.

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages