CLER guide

Build and link

CLER needs CMake 3.16 or newer and C++17. Linux and macOS are supported. On Windows, use WSL2.

mkdir build
cd build
cmake ..
make -j4

Release with -O3 is the default. Use -DCMAKE_BUILD_TYPE=Debug for debug symbols. The default build enables GUI and liquid-backed blocks.

Core-only CMake dependency

set(CLER_BUILD_BLOCKS OFF CACHE BOOL "" FORCE)
set(CLER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)

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

target_link_libraries(my_app PRIVATE cler::cler)

Link cler::cler for the header-only core. To use cler::desktop_blocks, leave CLER_BUILD_BLOCKS on and enable only the parts you need.

Desktop blocks library dependencies

The build always needs threads and pkg-config. GUI blocks also need OpenGL and X11 on Linux; GLFW is used from the system or fetched. ImGui and ImPlot are fetched. Enabling liquid-backed blocks fetches liquid-dsp. Hardware and audio libraries are optional and enable their matching blocks when found.

Build switches

OptionDefaultUse
CLER_BUILD_BLOCKSONDesktop block library
CLER_BUILD_BLOCKS_GUIONGUI and plot blocks
CLER_BUILD_BLOCKS_LIQUIDONBlocks backed by liquid-dsp
CLER_BUILD_EXAMPLESONDesktop and embedded examples
CLER_BUILD_PERFORMANCEOFFBenchmarks
CLER_BUILD_TESTSOFFUnit tests

Write a block

A block inherits cler::BlockBase, owns its input channels, and takes output channels in procedure(). A fixed channel count belongs in the type, not in a constructor argument.

struct GainBlock : cler::BlockBase {
    cler::Channel<float> in;

    GainBlock(const char* name, float gain)
        : BlockBase(name),
          in(cler::DOUBLY_MAPPED_MIN_SIZE / sizeof(float)),
          gain_(gain) {}

    cler::Result<cler::Empty, cler::Error>
    procedure(cler::ChannelBase<float>* out) {
        auto [read_ptr, read_size] = in.read_dbf();
        if (read_size == 0) return cler::Error::NotEnoughSamples;

        auto [write_ptr, write_size] = out->write_dbf();
        if (write_size == 0) return cler::Error::NotEnoughSpace;

        const size_t count = std::min(read_size, write_size);
        for (size_t i = 0; i < count; ++i) {
            write_ptr[i] = read_ptr[i] * gain_;
        }

        in.commit_read(count);
        out->commit_write(count);
        return cler::Empty{};
    }

private:
    float gain_;
};

For a fixed number of inputs or outputs, use a template parameter and check packs with static_assert(sizeof...(OChannels) == N). This keeps every call site and buffer shape in sync.

Blocks that wait

If procedure() can block on a device, file, socket, or timer, declare it:

static constexpr bool may_block = true;

The flowgraph gives that block its own thread. It is not placed in a worker pool or pinned island. cler::BlockRunnerMayBlock(...) can mark an older block at the runner instead.

Progress and errors

Success means progress. A successful call must consume or produce at least one item.

If nothing moved, return a retryable error:

  • NotEnoughSamples when input is empty.
  • NotEnoughSpace when an output is full.
  • NotEnoughSpaceOrSamples when either answer is enough.

Schedulers use success to reset idle backoff and wake parked workers. A no-op success can pin a core at full use.

A retryable error means no input was consumed. Check every input and output before committing any of them.

// Check all channels first.
for (size_t i = 0; i < channel_count; ++i) {
    if (in[i].size() < needed) {
        return cler::Error::NotEnoughSamples;
    }
}

// Only then do the work and commit every read.
for (size_t i = 0; i < channel_count; ++i) {
    send(in[i]);
    in[i].commit_read(needed);
}
return cler::Empty{};

If some data already moved, return success and report the shortfall on the next call.

Fatal errors

Errors at or after TERMINATE_FLOWGRAPH stop the graph. Use TERM_ProcedureError for a runtime failure that cannot be retried. On desktop, use cler::panic() for bad setup or a broken invariant.

CLER code does not throw on any normal path; only Result::unwrap() on a misuse does. Catch exceptions only at the edge of a library whose API throws, such as UHD or SoapySDR.

Run a graph

Flowgraph mode

#include "cler.hpp"
#include "task_policies/cler_desktop_tpolicy.hpp"

auto graph = cler::make_desktop_flowgraph(
    cler::BlockRunner(&source, &gain.in),
    cler::BlockRunner(&gain, &sink.in),
    cler::BlockRunner(&sink)
);

graph.run();
// Application work...
graph.stop();

The first BlockRunner argument is the block. The rest are its outputs, in the same order as the parameters of procedure(). A sink has no output arguments.

graph.run_for(duration, config) runs and stops the graph for tests and short jobs. stop() joins all worker threads.

Cyclic graphs are allowed. Seed at least one channel before run(), or every block in the cycle can wait forever. See desktop_examples/mass_spring_damper.cpp.

Streamlined mode

while (running) {
    source.procedure(&gain.in);
    gain.procedure(&sink.in);
    sink.procedure();
}

You own the loop, timing, and error handling. There is no task policy or scheduler. This is the bare-metal path and is often best for a short chain.

Channels

cler::Channel<float, 1024> fixed;  // storage is part of the object
cler::Channel<float> dynamic(1024); // storage is allocated at runtime

A channel is SPSC: one producer and one consumer. Do not read from the producer side or write from the consumer side.

Access methods

MethodUse
read_dbf / write_dbfDefault hot path. Direct contiguous access with no copy.
readN / writeNGood when an API already needs a separate buffer.
peek_read / peek_writeTwo ring segments plus manual commit. Easy to get wrong.
push / popSingle control items, not a DSP hot path.

DBF needs a heap channel whose storage is at least cler::DOUBLY_MAPPED_MIN_SIZE bytes, currently 4 KiB. It is not available for fixed channels. Bad DBF use asserts in builds with assertions and returns {nullptr, 0} otherwise; it does not fall back.

readN and writeN commit as they copy. The peek and DBF methods need commit_read() or commit_write(). With peek, handle both returned segments.

Counters and capacity

consumer_thread_cumulative_read_count() is atomic and can be polled from a monitor thread. producer_thread_cumulative_write_count() is safe only on the producer thread.

On a new channel, space() is its real capacity. Use it instead of the requested size because heap channels may round the size up.

Schedulers

SchedulerUseWorkers
ThreadPerBlockSmall graphs and debuggingOne per regular block
FixedThreadPoolUniform work with fewer threadsRequested count, clamped
PinnedIslandsCore limits, uneven work, sparse dataOne per island

Blocks marked may_block always get separate threads and do not count as regular workers.

config.max_calls_per_tick, default 4, limits how many times a worker calls one block before moving on. CLER_DEFAULT_MAX_WORKERS sets the build-time worker cap.

Thread per block

cler::FlowGraphConfig config; // ThreadPerBlock is the default
graph.run(config);

Fixed thread pool

cler::FlowGraphConfig config;
config.scheduler = cler::SchedulerType::FixedThreadPool;
config.num_workers = 4;
config.fixed_thread_pool.pin_workers = false;
graph.run(config);

The requested count is raised to 2, then capped by CLER_DEFAULT_MAX_WORKERS and the regular block count. A graph with one regular block still uses one worker.

Pinned islands

#include "cler_utils.hpp"

auto config = cler::flowgraph_config::pinned_islands(2);
config.pinned_islands.calibration_ms = 500;
config.pinned_islands.repartition_check_ms = 5000; // 0 disables drift checks
config.pinned_islands.cpu_id_offset = 0;
config.pinned_islands.park_after_zero_passes = 4;
config.pinned_islands.report_partition = true;
graph.run(config);

This scheduler starts from topological islands, measures block cost, then changes the split after calibration. Later checks only apply a split that improves the cost score. Idle workers back off and then park.

report_partition prints each island and an estimate of CPU cores used by each block.

Pinning is always attempted on cores cpu_id_offset + worker_id. Affinity works on Linux, desktop or embedded. macOS and the current FreeRTOS, ThreadX, and Zephyr policies run islands without affinity and report the failed attempts.

Set islands yourself

For a rate-critical graph, measure each split and keep the one that meets the rate. A balanced split is not always the fastest.

#include "cler_desktop_utils.hpp"
#include "cler_utils.hpp"

const cler::BlockBase* left[] = {&mix, &filter, &demod};
const cler::BlockBase* right[] = {&decode, &sink};
const cler::IslandList islands[] = {
    cler::island(left),
    cler::island(right)
};

auto config = cler::flowgraph_config::pinned_islands(islands);
auto check = graph.check_islands(config);
if (!check) cler::panic(check.message());
graph.run(config);

List every regular block exactly once. Do not list may_block blocks. Within an island, producers must come before consumers. Manual islands skip calibration and repartition.

Stats

partition(), stats(), block_costs(), repartition_count(), total_park_events(), and affinity_failure_count() are exact after stop(). During a run they are best-effort.

block_costs() is filled only by PinnedIslands. Set collect_detailed_stats = true only while profiling; it adds work to the hot path. All scheduler stats reset on each run().

Performance rules

  • Process the whole available span. Do not call an external DSP kernel once per sample or frame.
  • Do not allocate in procedure(). Allocate buffers and state in the constructor.
  • Use DBF for hardware I/O and heap-backed desktop channels.
  • Size the next input channel to at least the upstream driver buffer. A smaller channel can stall a blocking refill.
  • Before comparing CPU use, confirm both runs meet the requested sample rate.

Measure rate without another block

const auto before = block.in.consumer_thread_cumulative_read_count();
const auto start = std::chrono::steady_clock::now();

// Wait for the measurement window.

const auto items = block.in.consumer_thread_cumulative_read_count() - before;
const double seconds = std::chrono::duration<double>(
    std::chrono::steady_clock::now() - start).count();
const double items_per_second = items / seconds;

A pass-through counter adds a thread and a full-rate copy. Polling the channel counter does not.

Resampling and channelizing

Use RationalResamplerBlock<INTERP, DECIM, TAPS_PER_PHASE> for a fixed complex-sample ratio. Use MultiStageResamplerBlock for an arbitrary runtime ratio with float or complex-float samples.

The polyphase channelizer and rational resampler batch their kernels and keep fixed-shape state in std::array. Keep new high-rate blocks on the same path.

Platforms

TargetTask policyFactory
Linux / macOScler_desktop_tpolicy.hppmake_desktop_flowgraph
FreeRTOScler_freertos_tpolicy.hppmake_freertos_flowgraph
ThreadXcler_threadx_tpolicy.hppmake_threadx_flowgraph
Zephyrcler_zephyr_tpolicy.hppmake_zephyr_flowgraph
Bare metalNoneCall procedures in a loop

For MCU targets, use fixed channels such as Channel<float, 128> and avoid desktop-only helpers. The task policies currently allocate task wrappers and stacks; check them against the memory rules of your RTOS port.

Windows has no native target. Build the Linux path under WSL2.

Desktop blocks library

The desktop blocks library, desktop_blocks/, is for quick desktop and embedded-Linux work. It favors broad APIs over the smallest memory use.

GroupIncluded blocks
SourcesCW, chirp, file, audio file, UDP, Pluto, HackRF, CaribouLite, UHD, SoapySDR
SinksNull, file, audio, UDP, HackRF, UHD, SoapySDR
DSPAdd, gain, frequency shift, complex split, noise, FM demod, Kaiser LPF
Rate and channelsMulti-stage and rational resamplers, polyphase channelizer
UtilitiesFanout, fused kernels, throttle, throughput, trigger
Views and protocolsTime plot, spectrum, spectrogram, ADS-B, EZGMSK

Hardware, GUI, audio, and liquid-backed blocks depend on system libraries. CMake builds the matching examples only when those libraries are present.

Start with the source files in desktop_examples. They cover flowgraph and streamlined use, plots, SDR devices, UDP, FM, ADS-B, channelizing, audio, and cyclic control.

Tools and tests

cler-fg, the flowgraph editor

cler-fg opens a flowgraph .cpp as a graph and writes canvas edits back into that same file. It needs Node.js, a Rust toolchain, and Tauri's system libraries (WebKitGTK on Linux). From the repository root, the launcher installs the app packages and starts the editor:

./cler_fg.sh

Files inside desktop_examples/ build through their own CMake target. Any other file builds as a draft against the CLER checkout the app was built from, or the clerRoot set in the app's settings. Block types are still written in C++; the library palette scans desktop_blocks/ and any configured block library, and there is no wizard for new block types.

Flowgraph diagram

cd tools/mermaid
mkdir build
cd build
cmake ..
make -j4
./cler-mermaid ../../../desktop_examples/hello_world.cpp -o graph

Tests and benchmarks

mkdir build
cd build
cmake -DCLER_BUILD_TESTS=ON -DCLER_BUILD_PERFORMANCE=ON ..
make -j4
ctest --output-on-failure

./performance/perf_read_write_techniques
./performance/perf_simple_linear_flow
./performance/perf_fanout_workloads

Scheduler barrier changes also need tests/scheduler/test_repartition_stress.cpp. The test checks for duplicate or reordered samples while islands move.