Skip to content

Runtime Module

The runtime module provides lock-free primitives, logging, assertions, and scope guards. It is the lowest-level subsystem — everything else depends on it (via platform).

Status: stable Dependencies: platform Headers: pulp/runtime/runtime.hpp (includes all)

When to Use Which Primitive

Pattern Primitive Example
Single value, latest-wins std::atomic<T> Parameter values, flags
Multi-field coherent read SeqLock<T> Transport state (tempo + beat position + time sig)
Large data swap TripleBuffer<T> Wavetables, IR buffers, meter data
Ordered event stream SpscQueue<T> MIDI events, UI commands
Occurrence-only feedback ActivityChannel<N> MIDI pad flashes, clip lights
Budgeted optional work evaluate_runtime_budget() Background analysis, voice/cache refresh, validation helpers

Never use on the audio thread: std::mutex, std::condition_variable, heap allocation, I/O.

SeqLock

Lock-free reader/writer for coherent multi-field snapshots. The writer increments a sequence counter (odd = writing, even = complete). The reader retries if the sequence was odd or changed during the read.

#include <pulp/runtime/seqlock.hpp>

struct TransportState {
    double tempo_bpm;
    double position_beats;
    int time_sig_num;
    int time_sig_denom;
};

pulp::runtime::SeqLock<TransportState> transport;

// Writer (host thread):
transport.write({120.0, 4.0, 4, 4});

// Reader (audio thread — lock-free, never blocks):
auto state = transport.read();
// Guaranteed: all four fields are from the same write

Use SeqLock when you need to read multiple related fields as a consistent snapshot. If you only need a single float, use std::atomic<float> instead.

When NOT to use SeqLock

  • Single values: use std::atomic<T>
  • Large data (>cache line): use TripleBuffer<T> — SeqLock spins on contention, which is fine for small structs but wasteful for large ones

TripleBuffer

Lock-free latest-value publication. Three buffers rotate: writer publishes to the back, atomically swaps back↔middle. Reader swaps middle↔front if newer, reads front. Neither side ever blocks.

#include <pulp/runtime/triple_buffer.hpp>

struct MeterData {
    float peak_left;
    float peak_right;
    float rms_left;
    float rms_right;
};

pulp::runtime::TripleBuffer<MeterData> meters;

// Audio thread (writer):
meters.write({0.8f, 0.7f, 0.5f, 0.4f});

// UI thread (reader):
const auto& data = meters.read();
update_meter_display(data);

Use TripleBuffer for: - Audio → UI meter data - Main thread → audio thread config swaps (wavetables, IR buffers) - Any case where you only care about the latest value, not every value

TripleBuffer vs SpscQueue

  • TripleBuffer: reader gets the latest value, intermediate values are discarded. No overflow.
  • SpscQueue: reader gets every value in order. Can overflow if reader is slow.

SpscQueue

Single-producer, single-consumer lock-free FIFO. Uses CHOC's SingleReaderSingleWriterFIFO under the hood.

#include <pulp/runtime/spsc_queue.hpp>

pulp::runtime::SpscQueue<MidiEvent, 256> midi_queue;

// Producer (audio thread):
if (!midi_queue.try_push(event)) {
    // Queue full; overflow_count() / telemetry() make this visible.
}

// Consumer (UI thread):
while (auto ev = midi_queue.try_pop()) {
    handle_event(*ev);
}

Use SpscQueue for ordered event streams where every event matters (MIDI, parameter automation points, UI commands). If the consumer falls behind, producer-side failed pushes increment overflow_count() and appear in telemetry().

ActivityChannel

Lock-free occurrence counters for realtime-to-UI feedback where the UI needs to know that something happened, but does not need an ordered payload for every event. Multiple signals between UI ticks coalesce into one observation.

#include <pulp/runtime/activity_channel.hpp>

enum Voice : std::size_t { Kick, Snare, Hat, VoiceCount };
auto activity = pulp::runtime::make_activity_channel<VoiceCount>();

// Audio thread — lock-free, allocation-free:
activity->signal(Kick);

// UI thread — retain one cursor per lane:
pulp::runtime::ActivityChannel<VoiceCount>::Sequence kick_cursor = 0;
if (activity->consume(Kick, kick_cursor)) {
    flash_kick_pad();
}

Give the Processor and editor shared ownership of the channel. That keeps a host-retained editor safe if the host destroys the Processor first. Use an SpscQueue instead when every event and its payload must be delivered; use a TripleBuffer for latest-value meters.

Budget Policy

budget_policy.hpp provides a small, deterministic decision helper for runtime work that may need to degrade under load. Critical audio work always runs. Interactive work can defer to preserve an audio reserve. Background and opportunistic work can bypass or shed when budget is exhausted or overload is active.

#include <pulp/runtime/budget_policy.hpp>

RuntimeBudgetPolicy policy;
policy.critical_audio_reserve = 128;

RuntimeBudgetRequest request;
request.lane = RuntimeWorkLane::Opportunistic;
request.estimated_cost = 64;
request.remaining_budget = 32;

auto decision = evaluate_runtime_budget(request, policy);
if (!decision.should_run()) {
    // Upload telemetry or use a cheaper fallback; do not block process().
}

For a group of optional tasks inside one callback or worker tick, use RuntimeBudgetFrame. It keeps the remaining budget and counts run/defer/shed/ bypass decisions without allocating:

RuntimeBudgetFrame frame(512, policy, overload_active);

if (frame.evaluate(RuntimeWorkLane::Interactive, 64).should_run()) {
    refresh_visible_analysis();
}

if (frame.evaluate(RuntimeWorkLane::Opportunistic, 256).should_run()) {
    rebuild_noncritical_preview();
}

auto stats = frame.stats(); // publish off the audio thread if needed

This is the intended hook for large graph or instrument consumers: critical audio remains on the prepared path, while optional analysis, preview, and background refresh work degrades through the shared policy.

Cost values passed to RuntimeBudgetFrame should be stable work units, not wall-clock timing. Current graph and instrument consumers use deterministic shape counters: graph node/edge/port/block/buffer stats, or voice-pool polyphony/active/releasing counts. That makes CI fixtures portable while still pinning whether optional work runs, defers, sheds, or bypasses at scale.

Degraded Optional Work Contract

The budget policy is a cooperative decision helper. It does not preempt running audio work, measure CPU cycles, move work to another thread, or automatically virtualize graph nodes or instrument voices. Callers must ask before starting optional work and honor the returned action:

Action Caller behavior
Run Execute the optional work inside the caller's prepared budget.
Defer Keep required or interactive work queued for a later frame/tick.
Shed Drop optional work for this frame/tick without producing stale output.
Bypass Use an existing cached/cheap fallback and skip the expensive path.

Critical audio rendering should not be gated through this helper. Use it for noncritical graph previews, diagnostics, per-voice analysis, background refresh, and validation helpers where a degraded result is acceptable and visible.

ScopeGuard

RAII cleanup — runs a callable on scope exit.

#include <pulp/runtime/scope_guard.hpp>

auto guard = pulp::runtime::make_scope_guard([&] {
    cleanup_resource();
});
// cleanup_resource() runs when guard goes out of scope

Logging

#include <pulp/runtime/log.hpp>

pulp::runtime::log_info("Plugin loaded: {}", name);
pulp::runtime::log_warn("Buffer underrun at sample {}", position);
pulp::runtime::log_error("Failed to open device: {}", device_name);

Logging is safe to call from any thread. Output goes to stderr.

Assertions

#include <pulp/runtime/assert.hpp>

PULP_ASSERT(buffer.num_channels() > 0, "Buffer must have channels");
PULP_DBG_ASSERT(sample_rate > 0, "Sample rate must be positive");

PULP_ASSERT is active in all builds. PULP_DBG_ASSERT is active in debug builds and compiles away in release builds.