Skip to content

Module Reference

Pulp is organized into independent subsystems under core/. Each is a separate CMake library (pulp::runtime, pulp::audio, etc.) that you link as needed.

# Link what you need
target_link_libraries(my_plugin PRIVATE pulp::format pulp::signal pulp::view)

platform

Platform detection and operating-system services that higher-level subsystems use without hard-coding macOS, Windows, Linux, iOS, or Web behavior.

Link: pulp::platform · Include prefix: <pulp/platform/...>

Feature Header What It Does
Platform detection detect.hpp Compile-time and runtime platform checks
Native handles native_handle.hpp Typed OS window/device handles for adapters
Clipboard clipboard.hpp Cross-platform text clipboard access where implemented
File dialogs file_dialog.hpp Native open/save panels where implemented
Popup menus popup_menu.hpp Native menu affordances for UI surfaces

runtime

Core utilities — the foundation everything else builds on.

Link: pulp::runtime · Include prefix: <pulp/runtime/...>

SIMD — Portable vectorized math

Hardware-accelerated math via Google Highway. Dispatches to the best instruction set at runtime (SSE2, AVX2, NEON). Use for inner-loop DSP where every cycle counts.

#include <pulp/runtime/simd.hpp>
using namespace pulp::runtime;

float a[256], b[256], result[256];
simd_add(a, b, result, 256);        // result[i] = a[i] + b[i]
simd_scale(a, 0.5f, result, 256);   // result[i] = a[i] * 0.5
float peak = simd_reduce_max(a, 256);

XML — Parse and generate XML

Wraps pugixml (MIT). Parse from string or file, query with XPath, generate documents.

#include <pulp/runtime/xml.hpp>

XmlDocument doc;
doc.parse(R"(<plugin name="MyPlugin"><param>Gain</param></plugin>)");
auto name = doc.root_attribute("name");   // "MyPlugin"
auto params = doc.xpath_strings("//param"); // ["Gain"]
doc.save_file("settings.xml");

Streams — Unified byte I/O

One interface (pulp::runtime::Stream) for files, memory, pipes, TCP, and HTTP. See docs/reference/streams.md for the full API.

#include <pulp/runtime/stream.hpp>
#include <pulp/runtime/async_stream.hpp>

// Synchronous file I/O via the common interface.
FileStream f("preset.bin", FileStream::Mode::Read);
std::uint8_t buf[512]{};
auto r = f.read(buf, sizeof(buf));   // StreamResult{bytes, error}

// Wrap any Stream in AsyncStream for backpressure + cancellation.
AsyncStream async(std::make_unique<FileStream>("large.wav"));
async.on_data([](auto* data, auto n) { /* on worker thread */ });
async.start();

Budget Policy — Graceful degradation

pulp::runtime::evaluate_runtime_budget() gives background analysis, cache refresh, validation helpers, and game-audio-style optional work a shared run/defer/shed/bypass decision. Critical audio work always runs; interactive work can defer to preserve reserve; background and opportunistic work can shed or bypass when overload is active or budget is exhausted.

HTTP — Network requests

GET, POST, and file download via cpp-httplib (MIT). Use for license checks, cloud presets, update notifications.

#include <pulp/runtime/http.hpp>

auto response = http_get("https://api.example.com/presets");
if (response.ok())
    process_presets(response.body);

http_download("https://example.com/ir.wav", "/tmp/impulse.wav");

Crypto — Hashing and encryption

SHA-256, MD5, AES-256-CBC via Mbed TLS (Apache 2.0). Use for license validation, integrity checks, secure preset storage.

#include <pulp/runtime/crypto.hpp>

auto hash = sha256_hex("my data");              // 64-char hex string
auto id = machine_id();                          // Deterministic hardware fingerprint

auto encrypted = aes_encrypt(data, size, key32, iv16);
auto decrypted = aes_decrypt(encrypted->data(), encrypted->size(), key32, iv16);

Licensing — Plugin copy protection

Current license keys are v2 AES-256-GCM payloads validated with a 32-byte shared secret. Legacy v1 RSA-signed keys remain supported for migration and compatibility. Online activation returns the license key string that your plugin then validates locally.

#include <pulp/runtime/license.hpp>

LicenseValidator validator;
validator.set_shared_secret(shared_secret_32, 32);

auto status = validator.validate(license_key_string);
if (status == LicenseStatus::Valid) { /* unlocked */ }
if (status == LicenseStatus::Expired) { /* show renewal dialog */ }

auto info = validator.validate_and_parse(key);
// info->product_id, info->user_email, info->edition

// Legacy v1 keys can still be validated with the RSA public key.
validator.set_public_key(my_rsa_public_key_pem);

i18n — String translation

Load translations from .strings (Apple), .po (gettext), or .json files. Positional argument substitution with {0}, {1}.

#include <pulp/runtime/i18n.hpp>

LocalisedStrings::instance().load_json_file("lang/de.json");
auto text = tr("hello_user", {{"Max"}});  // "Hallo, Max!"
auto locale = LocalisedStrings::system_locale();  // "de"

Expression — Math evaluator

Parse and evaluate math expressions at runtime. Supports variables, 15+ functions, constants (pi, e). Use for user-defined formulas, automation curves.

#include <pulp/runtime/expression.hpp>

auto result = evaluate("sin(pi / 2)");           // 1.0
auto freq = evaluate("base * 2^(note/12)", {{"base", 440.0}, {"note", 7.0}});

ExpressionEvaluator eval;
eval.set("x", 0.5);
eval.evaluate("x * 100 + 10");  // 60.0

Other runtime utilities

Feature Header Description
Analytics analytics.hpp Thread-safe Analytics::instance().log_event("preset_load", {{"name", "Init"}})
Base64 base64.hpp base64_encode(data) / base64_decode(text)
BigInteger big_integer.hpp Arbitrary-precision math for RSA — a.mod_pow(exp, modulus)
Child Process child_process.hpp run_process("/usr/bin/auval", {"-a"}) with stdout capture
Dynamic Library dynamic_library.hpp lib.open("plugin.dylib"); lib.find_symbol("entry")
Identity identity.hpp Uuid::generate(), typed SessionId/ObjectId/RunId
IP Address ip_address.hpp local_ipv4_address(), hostname(), is_valid_ipv4(addr)
IPC Lock inter_process_lock.hpp Cross-process mutex via file locks
Memory Map memory_mapped_file.hpp Zero-copy large file access via mmap
Named Pipes named_pipe.hpp Cross-platform IPC (directional paired POSIX FIFOs / CreateNamedPipe)
Primes primes.hpp is_prime(97), generate_prime(32), sieve_primes(1000)
Range range.hpp Range<float>(0, 1).contains(0.5), intersection, union
Scope Guard scope_guard.hpp PULP_ON_SCOPE_EXIT(file.close())
Sockets socket.hpp TCP/UDP plus credential-bearing OS-local streams; local endpoints require an owner-private parent and expose kernel peer credentials
System Info system.hpp CPU model, core count, RAM, OS, SIMD features (runtime detected)
Temp File temporary_file.hpp Auto-deleting temp file — TemporaryFile tmp(".wav")
Text Diff text_diff.hpp Line-by-line diff with formatted +/- output
Timer high_resolution_timer.hpp Sub-millisecond periodic callback on a dedicated thread
ZIP/GZIP zip.hpp Compress/decompress data and archives (miniz)

events

Event loop, timers, IPC, and process management.

Link: pulp::events · Include prefix: <pulp/events/...>

IPC — Inter-process communication

Length-prefixed messages over named pipes, TCP sockets, or OS-local sockets. Use named pipes for existing worker protocols, TCP only where a network carrier is actually intended, and LocalSocket when a local security boundary needs kernel-observed peer credentials. Local endpoints require an absolute path in an owner-owned 0700 directory with no extended ACL, refuse to replace an existing filesystem object, are created 0600, and are removed with the owning listener. macOS additionally exposes a peer audit-token process generation; unsupported identity-verification platforms fail closed.

#include <pulp/events/interprocess_connection.hpp>

// Server side
InterprocessConnection server;
server.on_text_message = [](std::string_view msg) { handle(msg); };
server.create_server("my_pipe", IpcTransport::NamedPipe);

// Client side
InterprocessConnection client;
client.connect("my_pipe", IpcTransport::NamedPipe);
client.send_message("scan_plugin:/path/to/plugin.vst3");

LocalSocket does not make a peer authorized. It supplies carrier evidence only. The capability-control verifier combines accepted-socket UID, GID, PID, and macOS audit-token PID generation with the live process's validated code signature, identifier, Team ID or per-artifact ad-hoc CDHash. The broker must still exact-match that observation against launcher- or policy-owned expected identity before minting a verified peer. Named-pipe and TCP peers cannot be passed to that verifier. The installed pulp::inspect-control component owns the resulting identity, registration, grant, typed admission, durable receipt, and artifact state in ControlBroker. Installed ControlService and ControlClient types expose the same typed protocol used by the canonical macOS LocalSocket endpoint and ControlClientConnection. A ControlClientTransport represents one authenticated connection and owns its client lineage, including artifact reads. The optional pulp-control-broker executable owns the per-user endpoint and currently serves bounded health requests only; authority-bearing admission remains fail-closed until signed launcher, host registration, consent, and execution adapters are connected. Socket, singleton, and liveness state remain in the temporary owner-private runtime directory. Durable receipts and artifacts live separately under ~/.pulp/state/control-broker/v1; stopping or uninstalling the service does not remove that state. The legacy Inspector session/server is not exposed as a compatibility transport or second authority path. Each service session must negotiate its own protocol version and mandatory receipt support. The broker validates operation input JSON before admission and successful output JSON before terminal completion. A started operation that misses its response deadline remains durably running and occupies its active quota slot until deferred completion settles it, even though the immediate response reports unknown-needs-refresh.

The Phase 3b artifact store is a minimal lineage-bound persistence primitive, not the Phase 7 artifact lifecycle. Broker reads reauthorize the original grant and exact producer/receipt lineage, but only per-blob and per-chunk size limits exist; aggregate quota, retention collection, deletion audit, redaction, and generalized ACL policy are later work. Owner-private filesystem permissions exclude other OS users, not a malicious same-UID process, so they do not claim at-rest secrecy from every local process running as that user.

Child Process Pool — Crash-isolated workers

Launch child processes with IPC channels. If a child crashes (e.g., while loading a broken plugin), the host survives.

#include <pulp/events/child_process_manager.hpp>

ChildProcessManager pool;
auto* worker = pool.launch("/usr/bin/my-scanner", {"--plugin", path});
worker->send_message("scan");
worker->on_message = [](std::string_view result) { update_list(result); };

Other event utilities

Feature Header Description
Action Broadcaster coalesced_updater.hpp broadcaster.send_action("file_open") to all listeners
Async Updater coalesced_updater.hpp Coalesce rapid cross-thread triggers into one callback
Event Loop event_loop.hpp EventLoop loop; loop.dispatch([]{...}); loop.dispatch_after(100ms, []{...})
Service Discovery service_discovery.hpp ServicePublisher / ServiceBrowser use a platform default backend when available; use NetworkServiceDiscovery::install_backend() for custom or test backends
Timer timer.hpp Timer timer(loop, 100ms, []{...}); timer.start(); — periodic or one-shot
Volume Detector volume_detector.hpp Poll for USB drive mount/unmount events

audio

Device I/O, file formats, channel layouts, and offline processing.

Link: pulp::audio · Include prefix: <pulp/audio/...>

Reading and writing audio files

The FormatRegistry handles codec dispatch. Pass a file path — it picks the right decoder from the extension.

#include <pulp/audio/format_registry.hpp>

// Read any supported format
auto data = FormatRegistry::instance().read("drums.flac");
// data->channels[0] = left channel floats, data->sample_rate = 44100

// Write to WAV
FormatRegistry::instance().write("output.wav", *data);

// Read just the metadata (no audio decode)
auto info = FormatRegistry::instance().read_info("song.mp3");
// info->duration_seconds, info->num_channels, info->sample_rate

For an already-resident RIFF/WAVE byte span, inspect_wav() and decode_wav() in wav_decoder.hpp provide a no-file-I/O, no-exceptions path with caller-set frame, channel, and decoded-byte ceilings. RF64, W64, and RIFX containers are outside this byte-span API. Keep parsing and allocation off the audio callback.

Streaming write — large files without loading into memory

#include <pulp/audio/streaming_writer.hpp>

StreamingWriter writer;
writer.open("recording.wav", 48000, 2, 24);  // 48kHz stereo 24-bit

while (recording) {
    writer.write_frames(buffer, num_frames);  // Write in chunks
}
writer.close();  // Finalizes WAV header

Audio device I/O

#include <pulp/audio/device.hpp>

auto system = create_audio_system();  // Platform-appropriate backend
auto devices = system->enumerate_devices();

DeviceConfig config;
config.sample_rate = 48000;
config.buffer_size = 256;

auto device = system->create_device(devices[0].id);
device->open(config);
if (auto timing = query_audio_io_timing(*device)) {
    // Consume the graph's already-computed PDC total; this does not recompute it.
    auto latency = make_latency_snapshot(
        *timing, graph.latency_samples(), device->sample_rate());
    if (latency && latency->output_scheduling_offset_frames) {
        schedule_output_early_by(*latency->output_scheduling_offset_frames);
    }
}
device->start([](const auto& input, auto& output, const auto& ctx) {
    // Real-time audio callback — no allocation, no locks
    process(input, output, ctx.buffer_size);
});

Device backends: CoreAudio (macOS), WASAPI (Windows), ALSA + JACK (Linux), Web Audio (browser)

AudioIoTiming reports present input/output latency, safety-offset, and I/O buffer properties in device-rate sample frames, together with the exact sample rate, timestamp provenance, confidence, and a calibration generation. LatencySnapshot is a control-thread value that composes this with the graph's reported total latency for input placement, monitoring, and output scheduling. Each directional result is optional: an output-only route still supports output scheduling, while live monitoring requires complete input and output properties. Composition refuses a graph/device rate mismatch. CoreAudio publishes this contract today; other desktop and mobile backends currently return no timing value rather than guessed magnitudes.

Snapshot composition fails closed when timestamp domain/source is unspecified or confidence is unavailable; provenance-free values are not scheduling data.

For CoreAudio, a complete directional presentation path is device latency plus the directional safety offset plus one I/O buffer. Input placement and output scheduling each count that buffer once; live monitoring composes both paths and therefore counts it twice.

Audio file format support

Format Read Write Backend
AAC macOS ✓* Read through ExtAudioFile on macOS; optional FDK AAC adds writing (pulp add fdk-aac --accept-license FDK-AAC)
AIFF / AIFF-C Native (8/16/24/32-bit big-endian)
ALAC macOS ✓* Read through ExtAudioFile on macOS; optional Apple ALAC adds writing (pulp add alac)
CAF macOS Read through ExtAudioFile on macOS
FLAC ✓* dr_flac / libflac (pulp add libflac)
MP3 ✓* dr_mp3 / LAME (pulp add lame --accept-license LGPL-2.0)
OGG Vorbis stb_vorbis
WAV CHOC + bounded in-memory decoder + StreamingWriter

*Write via optional pulp add packages. Those packages do not add portable AAC/ALAC readers. Permissive (libflac, ALAC) packages install freely; copyleft (LAME, fdk-aac) packages require --accept-license.

Sampler, looper, and analysis primitives

Reusable low-level pieces for building samplers, generated-audio freeze/loop workflows, waveform displays, and offline/background sample analysis. These are primitives, not a full sampler UI. Callback-safe operations are documented in rt_safety_contract.hpp; import/export, analysis, waveform thumbnail build, publication writes, and materialization stay off the audio callback.

unison_voice_stack.hpp adds the logical-note ownership layer above InstrumentVoiceAllocator. It exclusively owns the allocator, preflights a complete child stack, steals only complete oldest stacks, and requires exact voice IDs when renderer tails finish recycled slots.

For a runnable integration of the asset, streaming, interpolation, starvation, and synthetic-heritage primitives, see the PulpSampler example. Start with the sampler playback chooser: the sequential source, shared page service, and resident publication path have different ownership and traversal contracts.

The Heritage Kit adds a character-processing and profile layer; it does not replace the existing sampler foundation. Keep these boundaries when composing the APIs:

Existing surface Relationship to Sample Heritage Consolidation guidance
SampleAsset, resident publication, stream service, and voice readers Own source storage, generations, page demand, and logical forward/reverse traversal Reuse them unchanged; feed each voice's ordered samples into its prepared Heritage voice engine
LoopPlaybackCursor, LoopReader, and LoopRenderer Own one-shot/loop regions, wraps, reverse traversal, and loop crossfades They do not perform Heritage cyclic resynthesis. Resolve traversal first, then run live cyclic stretch in the per-voice character chain
sample_interpolation.hpp and sinc kernels Reconstruct samples at playback-rate positions and protect high-rate playback Heritage converter/hold blocks model the variable machine frame; Heritage reconstruction is the fixed per-voice frame after return conversion. They are complementary, not alternate names for playback interpolation
sample_asset_io.hpp and edit/bounce metadata Import, export, and describe audio assets Heritage JSON imports/exports profiles; record commit emits its own content-addressed audio/provenance envelope. Do not merge these formats
Onset, slice, key/tempo, transient, and loop-point analyzers Derive content metadata and suggested regions Analysis can inform authoring or zone selection but does not select or mutate Heritage blocks at runtime
signal::OfflineStretch and realtime pitch/time processors Conventional high-quality tempo/pitch processing Use these when transparency or ordinary tempo matching is the goal. Use Heritage fixed/adaptive cyclic stretch only when cyclic resynthesis is the intended character

In short: consolidate on the existing storage, traversal, interpolation, and analysis primitives; let Heritage own only its typed profile, per-voice/bus character path, live cyclic stage, and offline record-commit transaction.

Feature Headers Description
Stream handoff and rolling capture audio_stream_handoff.hpp, planar_audio_ring_buffer.hpp, rolling_audio_capture_buffer.hpp, realtime_sample_recorder.hpp Bridge generated/live/model audio into host-paced processing, keep bounded rolling history, freeze stable windows, and materialize captures off the audio thread
Resident publication and shared paged storage published_sample_store.hpp, sample_slot_bank.hpp, sample_slot_materializer.hpp, sample_pool.hpp, sample_asset.hpp, sample_stream_window.hpp, sample_stream_scheduler.hpp, sample_stream_service.hpp, sample_stream_async_service.hpp, sample_stream_decode_pool.hpp, sample_memory_governor.hpp, sample_preload_contract.hpp, sample_stream_voice_reader.hpp, sample_stream_loop_voice_reader.hpp Publish resident generations or build immutable assets over a shared page cache; bounded commands, fixed-scratch decode, shared memory budgeting, and narrow or loop-aware voice readers keep the two ownership models explicit
Sample interpolation sample_interpolation.hpp, sample_sinc_kernel.hpp Share hold, nearest, linear, Hermite, Lagrange, and prepared ratio-tracking sinc footprints across resident and paged playback; build normalized immutable cutoff tables off the callback
Sampler octave mips sample_mip_builder.hpp, sample_mip_sidecar.hpp Build resident or persisted octave levels with the sampler decimator; authenticate source and payload identities, then publish sidecar manifests transactionally for strict runtime admission
Sample Heritage profiles and processing sample_heritage.hpp, sample_heritage_schema.hpp, sample_heritage_engine.hpp, sample_heritage_pitch.hpp, sample_heritage_live_cyclic.hpp, sample_heritage_bus_dsp.hpp, sample_heritage_record_commit.hpp, sample_heritage_src.hpp, sample_heritage_json.hpp, sample_heritage_runtime_state.hpp Define strict schema-v3 voice/bus/record-commit profiles; prepare per-voice variable machine-frame character and fixed-frame reconstruction/color, pitch families, converter/hold, live cyclic stretch, post-mix bus color, deterministic state, neutral offline cycle estimation, and content-addressed offline commits without claiming emulation of named hardware
Sequential streaming source streaming_sample_source.hpp, streaming_sample_source_file.hpp Play a preload head plus background-filled SPSC tail; WAV and uncompressed AIFF use immutable private mapped snapshots for ranged reads while fallback codec capability remains explicit
Stream starvation envelope sample_starvation_envelope.hpp Supply equal-power fade gains for valid low-water and recovered frames with explicit predicted, insufficient-lead, and emergency telemetry; source-position advancement remains voice-renderer policy
Looping and playback loop_types.hpp, loop_playback_cursor.hpp, sample_interpolation.hpp, loop_reader.hpp, loop_renderer.hpp, loop_point_analyzer.hpp, sample_voice_renderer.hpp, voice_sum_mixer.hpp Share LoopRegion traversal, cursor plans, prepared interpolation footprints, and tap mapping across resident and paged storage; LoopRenderer is the resident rich-loop orchestrator, while SampleVoiceRenderer remains a compatible pool/envelope/fade adapter
Mapping and instrument policy sample_zone_map.hpp, sample_key_map.hpp, instrument_runtime.hpp, instrument_voice_allocator.hpp, instrument_envelope.hpp, voice_modulation_buffer.hpp Represent key/velocity zones, chromatic/fixed-pitch/slice mappings, pool-backed trigger resolution, voice allocation, AHDSR envelopes, and per-voice modulation lanes without requiring products to adopt generic allocator or envelope policy
Editing, import/export, and bounce metadata sample_edit_document.hpp, sample_asset_io.hpp, wav_metadata.hpp Track non-destructive edit intent, import/export policy, drop classification, and WAV metadata/interchange outside realtime paths
Onset, slice, key/tempo, and transient analysis onset_detector.hpp, slice_point_analyzer.hpp, slice_map.hpp, sample_key_map.hpp, analyzer_provider.hpp, built_in_key_tempo_analyzer.hpp, built_in_transient_classifier.hpp Provide package-free fallback analysis, timeline or strongest-confidence slice selection, near-zero or sign-transition snapping, shared note mapping, and neutral provider/provenance metadata
Time/pitch extension point analyzer_provider.hpp, signalsmith_time_pitch_processor.hpp Optional package-backed time-stretch/pitch-shift processor contract; availability and licensing stay explicit
Waveform summaries and render backends waveform_overview.hpp, waveform_gpu_primitives.hpp, waveform_gpu_render_controller.hpp, waveform_headless_render_backend.hpp Build/cache serialized CPU waveform summaries, plan generation-keyed static layer uploads, exercise backend resource lifecycle in CPU/headless paths, and keep future GPU-assisted analysis/rendering off live audio-thread waits
Realtime contract labels rt_safety_contract.hpp Machine-checkable sampler/looper RT-safety labels for representative hot paths and off-thread helpers

Other audio features

Feature Header Description
Buffering Reader buffering_reader.hpp Ring buffer with background read thread for streaming
Channel Sets channel_set.hpp ChannelSet::surround_5_1(), mono through 7.1.4 Atmos
Load Measurer load_measurer.hpp Track CPU usage of your audio callback; evaluate_audio_runtime_overload() classifies process-load/xrun telemetry into nominal, watch, overloaded, or critical validation states with explicit shed/bypass guidance
Memory-Mapped Reader mmap_reader.hpp Zero-copy access for large sample libraries
Offline Processor offline_processor.hpp offline_process(input, callback, 512) for simple batch render; offline_render(input, callback, options) for deterministic block schedules, absolute sample positions, transport timeline, state generation, render-speed hints, render seeds, and explicit tail policy; offline_render_stems() extracts named channel groups; compare_offline_render_audio() reports golden/null residuals; create_offline_render_manifest() records artifact hashes, render-plan hashes, chunk boundaries, staged resource hashes, and cache-reuse metadata for reproducible offline/distributed renders; evaluate_offline_render_compute_policy() keeps GPU-assisted analysis out of live audio-thread scopes and makes CPU fallback explicit
Subsection Reader subsection_reader.hpp Read frame range without copying — reader.sample(ch, frame)
System Volume system_volume.hpp get_system_volume() / set_system_volume(0.8f)

Offline render manifests intentionally separate artifact identity from render plan identity. Equivalent renders with different chunk schedules can have the same audio_sha256 and a zero residual while still carrying different render_plan_sha256 values and chunk metadata for distributed reproduction.


music

Dependency-light 12-tone equal-temperament theory values for sharing musical intent across audio, MIDI, timeline, and product code. The module does not own a clock, sequencer, transform chain, event ledger, or tuning system.

Link: pulp::music · Include prefix: <pulp/music/...>

PitchClassSet is an arbitrary checked 12-bit set. Scale adds a root and supports degree lookup, signed octave-spanning degrees, transposition, and mode rotation. NamedScale retains the existing ten Pulp signal selector values and appends the scale set currently needed by Forge. The explicit kPulpSignalScales, kForgeRuntimeScales, and kForgePrimitiveScales tables carry each existing stored index and spelling; consumers should map through the matching table rather than cast between product enums.

Pulp's existing signal harmonizer scale table and Timeline chord/scale wire codec delegate through these compatibility maps. Their public enum ordinals and stored names remain unchanged while the interval and identity data has one owner.

ChordFormula accepts fixed-capacity ascending semitone formulas. Its with_extension(), with_suspension(), and with_alteration() transforms add the common ninth/eleventh/thirteenth vocabulary without allocating or changing the stored identities of named formulas. kPulpTimelineChordQualities and kForgeChordQualities map the two existing stored identities onto the shared named qualities. Chord::construct() builds bounded MIDI pitches and deterministic inversions, failing when a root, inversion, formula, or resulting pitch is outside its legal domain.

Pitch spelling is policy-driven: callers select prefer_sharps, prefer_flats, or deterministic minimize_accidentals. spell_chord() keeps the formula's diatonic letter roles, so a C-sharp major third is E-sharp while the same pitch-class root under the flat policy is spelled D-flat/F/A-flat. minimize_accidentals chooses a natural spelling when one exists and resolves equal-cost single-accidental ties toward sharps; it is deterministic rather than key-signature contextual.

voice_chord() applies closed, open, drop-2, drop-3, or spread constraints and fits the result into an explicit MIDI range. It returns no value when the spacing or range is impossible. minimum_motion_voice_leading() searches the bounded MIDI domain across every assignment of the formula's fixed tone multiset, including compound and duplicated pitch classes, for the global minimum summed motion without voice crossing. Equal-cost answers use ascending pitch order as the stable tie-break.

diatonic_chord() constructs scale-degree third stacks directly from an arbitrary Scale. recognize_chord() ranks every stable named quality and root by missing and extra pitch classes. best_equivalent_count() and ambiguous() make symmetric or otherwise tied analyses explicit instead of selecting one silently; MIDI-note input additionally reports a recognized inversion when the bass identifies exactly one formula degree. inversion_match_count and inversion_match_mask expose duplicate-degree matches; inversion remains empty when a pitch-class bass cannot distinguish them. This inversion evidence participates in ranking and best-equivalence grouping. The candidate catalog is deliberately the 12 stable named qualities. Extended or altered input is ranked against that catalog by its missing and extra tones; recognition does not invent an extension identity.

The music APIs are pure and deterministic and own no mutable processing state, so they have no prepare() or reset() lifecycle. Pitch-class and scale operations, formula transforms, chord construction and spelling, and diatonic construction are fixed-capacity, allocation-free, bounded value operations that may be used on a real-time path. voice_chord(), minimum_motion_voice_leading(), and recognize_chord() are control/offline algorithms, not audio-callback operations: they perform bounded searches or candidate ranking and use comparatively large temporary result/work tables.

#include <pulp/music/music.hpp>

using namespace pulp::music;
const auto scale = Scale::named(PitchClass::d, NamedScale::dorian);
const auto base = ChordFormula::for_quality(ChordQuality::minor7);
const auto formula = base->with_extension(ChordExtension::ninth);
const auto first_inversion = Chord::construct(62, *formula, 1);
const auto spelling = spell_chord(*first_inversion, AccidentalPolicy::prefer_flats);

VoicingConstraints constraints;
constraints.mode = VoicingMode::drop2;
constraints.range = {48, 84};
const auto voiced = voice_chord(62, *formula, constraints);
const auto analyses = recognize_chord(first_inversion->pitch_classes());

The named collection is a 12-TET compatibility vocabulary, not a claim of microtonal support. More tuning systems belong in the provider-neutral MIDI tuning APIs rather than in this representation.

Generative pattern kernels

BinaryPattern<MaxSteps> defaults to a 64-step capacity and reports overflow instead of truncating. The following operations are bounded, constexpr, and allocation-free during evaluation:

  • euclidean_pattern() returns a deterministic onset-first canonical rotation, accepts a signed rotation, and rejects zero steps, excess pulses, and capacity overflow. Positive rotation delays onsets and negative rotation advances them; for example, E(3,8) changes from 10010010 to 01001001 at rotation +1 and 00100101 at rotation -1. A silent zero-pulse pattern is valid. EuclideanPatternRecipe gives integrations a versioned named-field contract for steps, pulses, and rotation without treating the C++ object bytes as a wire format. Conventional E-notation orders arguments (pulses, steps).
  • PatternWalker supports forward, reverse, ping-pong, and random traversal. reset() restores index zero for forward/ping-pong and the final index for reverse. Random traversal consumes a caller-supplied deterministic random word and has no internal stream to rewind; calling next() without a word in random mode fails explicitly.
  • PreparedMarkovModel<MaxStates> defaults to 16 states. prepare() consumes a row-major table of unsigned integer weights on the control thread and rejects empty, oversized, malformed, or zero-total rows. Its fixed state capacity and 32-bit weights make the 64-bit cumulative row total non-overflowing. next() is a const bounded lookup from a caller-supplied random word, with no allocation or failure except an invalid state.
  • cellular_evolve() applies an elementary 8-bit cellular rule with explicit wrapping or fixed-off edges.
  • looping_shift_register() rotates the last bit to the first and optionally flips the copied bit using an integer numerator/denominator mutation chance. Mutation consumes a caller-supplied random word. Empty input and invalid probabilities fail without changing the input.
  • derive_rhythm_relationship() creates one lane from another using coincident, complementary, or independent candidates. Wrap and proportional length mapping, signed target-grid phase, source-collision filtering, and exact-onset density are explicit policies. Exact-density selection is a pure coordinate-keyed decision over seed, cycle, lane, and step, so evaluation order cannot change the result.

Random words are mapped to bounded choices with full-domain multiply-high reduction rather than remainder reduction.

Construction and transformation results carry explicit errors rather than silently truncating or repairing invalid input. None of these APIs owns mutable randomness, transport, event ordering, a transform chain, or a callback accumulator. APIs that consume random words take them from the caller; relationship density instead names its complete stateless draw coordinate, so callback partition never enters these kernels.

The theory surface does not provide pitch spelling, chord recognition, voicing constraints, or minimum-motion voice leading; those are not implied capabilities of ChordFormula.


midi

MIDI I/O, file handling, MIDI 2.0 support, and provider-neutral note tuning.

Link: pulp::midi · Include prefix: <pulp/midi/...>

MIDI message sequence — editing and offline processing

#include <pulp/midi/midi_message_sequence.hpp>

MidiMessageSequence seq;
seq.add_note_on(0.0, 0, 60, 100);   // C4 at time 0
seq.add_note_off(0.5, 0, 60);       // Release after 0.5s
seq.add_cc(0.25, 0, 1, 64);         // Modulation at 0.25s

auto events = seq.events_in_range(0.0, 1.0);  // All events in first second
auto off = seq.find_note_off(0);               // Find matching note-off

MIDI CI — Device discovery and profiles

#include <pulp/midi/midi_ci.hpp>

CiDiscovery ci;
ci.on_device_discovered = [](const CiDeviceInfo& device) {
    log("Found: MUID=" + std::to_string(device.muid.value));
};

auto inquiry = ci.create_discovery_inquiry();
send_sysex(inquiry);  // Send over MIDI port

Other MIDI features

Feature Header Description
Buffer midi_buffer.hpp Timestamped event buffer for process() callbacks
Device I/O platform/ CoreMIDI (macOS), WinMIDI (Windows), ALSA (Linux); Web MIDI scaffold is not wired into the shipped WASM build
Files midi_file.hpp Read/write Standard MIDI Files
Messages via CHOC ShortMessage::noteOn(0, 60, 100)
Tuning tuning.hpp, mts_esp_tuning.hpp, scala_tuning.hpp Provider-neutral note-to-frequency API with 12-TET default, optional MTS-ESP session/SysEx provider, and optional Scala SCL/KBM local-file provider
UMP ump.hpp MIDI 2.0 Universal MIDI Packets, MPE zones
MPE mpe_voice_tracker.hpp, mpe_buffer.hpp, mpe_synth_voice.hpp Per-note pitch bend / pressure / timbre tracking, opt-in sidecar buffer, and voice/allocator helpers. See docs/guides/mpe.md
Utility kernels utility_kernels.hpp umbrella; routing_utility_kernels.hpp, note_utility_kernels.hpp, controller_utility_kernels.hpp Fixed-capacity channel routing, note-range filtering, keyboard splitting, balanced note-length shaping, low/high/last monophonic priority with legato/glide state, CC mapping/smoothing, and scale-aware MPE bend/glide

Every utility kernel publishes a MidiUtilityContract describing maximum event amplification, fixed state capacity, overflow behavior, same-sample ordering, and transport requirements. Stateful note kernels retain release debt when an output buffer fills: stop, seek, loop, reset, and spec replacement call flush()/reset() until complete is true, so capacity pressure cannot strand a downstream note. Routing-kernel flush() and output-bearing replace_spec() close downstream notes while retaining enough input ownership to consume their later physical releases; reset() closes the notes and then discards that input ownership for a lifecycle boundary that also resets the source stream. Scheduling accepts timebase::SamplePosition; the kernels do not own or advance another clock. Realtime calls require output buffers that were reserved for the contract's worst case and pinned with set_realtime_capacity_limit(true); an unpinned output is rejected instead of silently allocating in add() or stable sort(). Inputs and outputs must be distinct buffers (as must both split outputs), including their attached UMP storage; aliased calls are rejected before any block is cleared. Channel routing, note-range filtering, and keyboard splitting apply the same channel/note decisions to native MIDI 1.0 and MIDI 2.0 UMP channel-voice packets. Note-addressed expression (poly pressure plus MIDI 2 per-note controllers, bend, and management) follows the addressed note through a range or split. A split duplicates channel-wide voice messages onto both configured output channels. Other UMP message types and SysEx retain their exact payloads.

audio/midi_voice_modulation_adapter.hpp projects a caller-selected voice slot's note/MPE state into the existing VoiceModulationBuffer. Voice ownership stays with the instrument's allocator, preserving the MIDI-to-audio dependency direction and avoiding a second voice-allocation policy. Expression and release updates must carry a nonzero note_id: event releases match channel, note, and generation, while index-based releases match the generation explicitly. A stale generation therefore cannot mutate or release a reused voice slot. The tracker uses the 64-bit MpeNoteGeneration type, never recycles a generation across reset(), and fails closed after issuing its final nonzero value: further note-ons are consumed but create no voice or callback. Check note_generation_exhausted() and refused_note_on_count() to surface that terminal condition. flush() and reset() are the deliberate identity-free lifecycle clears. Destinations are preflighted for four lanes and the complete frame capacity before any lane is written.

MIDI effects

Pulp's format layer hosts MIDI-only processors. The SDK supplies the bounded utility kernels above, while Forge supplies a bounded, hot-swappable ordered transform chain with 20 transforms, fixed host macros, pattern/chord data, note-balance enforcement, and realtime-safe publication. See the MIDI FX guide for the complete transform parameter reference, structured authoring schema, cookbook, C++ method API, and host-validation contract.


signal

Real-time-safe DSP processors, generators, analysis helpers, and composition primitives. Process methods operate on single samples or buffers and are safe for the audio thread after the helper's documented construction/configuration/prepare() step. Setup methods that allocate storage must run off the audio thread.

Link: pulp::signal · Include prefix: <pulp/signal/...>

For synthesized percussion, including the complete voice API, recipes, provenance, and Forge bake-layer controls, see Percussion synthesis.

Using a processor

Every processor follows the same pattern: configure, set sample rate, process.

#include <pulp/signal/compressor.hpp>

Compressor comp;
comp.set_params({.threshold_db = -20, .ratio = 4, .attack_ms = 5, .release_ms = 100});
comp.set_sample_rate(48000);

for (int i = 0; i < num_samples; ++i)
    buffer[i] = comp.process(buffer[i]);

Advanced DSP guides

The advanced processors are documented by authoring responsibility. Each guide includes selection advice, lifecycle and real-time boundaries, and a focused composition example. The advanced DSP API is the exhaustive public-method inventory across these families.

Guide Algorithms covered
Dynamics processors Feedforward, VCA, diode-bridge, and FET compressors
Nonlinear and tone processors Saturation, circuit clippers, fuzz, tape, and speaker modeling
Modulation effects Phaser, three vibrato mechanisms, chorus, flanger, SSB shifting, rotary, and scanner
Pitch, time, and granular Pitch shifting, YIN tracking, harmony, cyclic stretch, and granular clouds
Synthesis and sequencing Additive synthesis, vocoding, stage/grid/rungler sequencing, scale quantization, and gate utilities
Nonlinear space and convolution Gated/reverse ambience and zero-latency multilevel convolution

Applying a mono processor to stereo

#include <pulp/signal/processor_duplicator.hpp>

ProcessorDuplicator<Compressor> stereo_comp;
stereo_comp.prepare(2, 48000);
stereo_comp.for_each([](Compressor& c) { c.set_params({...}); });
stereo_comp.process(channels, 2, num_samples);

Dry/wet mixing with latency compensation

#include <pulp/signal/dry_wet_mixer.hpp>

DryWetMixer mixer;
mixer.set_mix(0.7f);                    // 70% wet
mixer.set_curve(MixCurve::EqualPower);  // Constant-power crossfade
mixer.set_wet_latency(512);             // Compensate 512 samples of plugin latency
mixer.prepare(2, 1024);

mixer.push_dry(input_channels, 2, num_frames);
// ... run your effect on the wet path ...
mixer.mix_wet(output_channels, 2, num_frames);

Routing and parallel-path alignment

The signal module owns shared routing math so fixed-topology processors and runtime graphs use the same gain and latency contracts:

Primitive Header Contract
Orthonormal mid/side mid_side.hpp Self-inverse, mono-compatible, energy-preserving stereo transform plus mono-safe width
Signed audio matrix audio_matrix_mixer.hpp Fixed 16×16 default capacity, sample-continuous cell automation, explicit raw or peak-normalized headroom
N-way crossfade nway_crossfade.hpp Adjacent-path cosine/sine weights with unit summed power
Click-free path switcher path_switcher.hpp Fixed-capacity, retargetable smoothstep transitions with block-split deterministic weights
Path latency aligner path_latency_aligner.hpp Prepared N-path/channel delay storage with maximum-path latency reporting and impulse-exact alignment

All process paths are allocation-free after preparation. The matrix copies its inputs to bounded scratch and therefore supports input/output aliasing. The path switcher requires disjoint mono source/destination buffers. The latency aligner supports only exact corresponding in-place pairs, not partial or cross-path aliases. Overlap checks cover the full processed byte ranges. Latency-set changes clear alignment history; matrix and switch automation preserve their sample trajectory across host block boundaries.

Convolution — load an impulse response

PartitionedConvolver is partitioned for one fixed block size, and process() must be handed exactly that many samples. load_ir() rounds its block_size argument up to the next power of two (its FFT is radix-2), so the size you must feed is conv.block_size() — not necessarily the value you passed in.

#include <pulp/signal/convolver.hpp>

PartitionedConvolver conv;
conv.load_ir(impulse_response.data(), ir_length, block_size);  // may round up

// In your process callback — num_samples MUST equal conv.block_size():
conv.process(input, output, conv.block_size());

If your host delivers blocks of any other size (variable blocks, or a size that is not a power of two), re-block the audio into conv.block_size() chunks yourself and report the added delay from Processor::latency_samples().

A loaded convolver handed the wrong block size fails closed: it emits silence and increments conv.block_size_violations(). It deliberately does not pass the input through, because a pass-through is audibly indistinguishable from a working convolution and would hide the bug. Assert conv.block_size_violations() == 0 in your tests.

Available processors

Filters

Processor Header Description
Biquad biquad.hpp Second-order IIR filter — low/high/band-pass, notch, shelf, peaking EQ
Six-band EQ six_band_eq.hpp Allocation-free low-shelf/four-peak/high-shelf cascade with optional stable cascade crossfades and endpoint response inspection
SOS Cascade sos_cascade.hpp Fixed-capacity transactional runtime executor for stable normalized biquad cascades
Filter Design filter_design.hpp Generate Butterworth and Chebyshev coefficient sets for arbitrary order
FIR fir_filter.hpp Finite impulse response filter with arbitrary tap count for linear-phase EQ
Analog VCF analog_vcf.hpp / ota_cascade_filter.hpp Four measured Juno, Jupiter-8, Prophet-5, and Minimoog panel voicings over a shared zero-delay nonlinear four-pole cascade
Ladder ladder_filter.hpp Four-pole nonlinear resonant ladder filter with saturation
Linkwitz-Riley linkwitz_riley.hpp Phase-aligned crossover filter for splitting audio into frequency bands
State Variable (TPT) svf.hpp / tpt_filter.hpp Topology-preserving transform filter — simultaneous LP/HP/BP/notch outputs

Effects

Processor Header Description
Character Delay character_delay.hpp Wet-only stereo delay with clean, vintage-digital, tape, BBD, and diffusion feedback-loop characters — see the dedicated guide
Chorus chorus.hpp Modulated delay for stereo widening and detuning effects
Convolver convolver.hpp Partitioned frequency-domain convolution for reverb impulse responses
Delay Line delay_line.hpp Sample-accurate delay with linear, cubic, or sinc interpolation
Fractional Delay fractional_delay.hpp Prepared Thiran-1/Lagrange delay lines plus bounded shared history with stateless multitap Lagrange-3/5 heads, explicit causal ranges, and typed fault recovery
Dither Quantizer dither.hpp Deterministic TPDF dither with opt-in bounded first- or second-order error-feedback noise shaping; zero latency and allocation-free
Lo-Fi Chain lofi_chain.hpp Bit-depth reduction, sample-and-hold rate reduction, and dead-zone saturation; dither/noise shaping are opt-in so the legacy default remains exact
Oversampling oversampling.hpp 2x/4x/8x/16x realtime up/downsampling; minimum-phase IIR and 96/140 dB-prototype linear-phase FIR tiers with exact latency reporting
Phaser phaser.hpp All-pass filter chain with LFO modulation for sweeping comb effects
FDN Reverb fdn_reverb.hpp 16-line feedback delay network with a selectable internal tank sample rate (16-96 kHz), Jot decay law, granular shimmer, and a provably bounded loop gain; wet-only
Reverb reverb.hpp Algorithmic stereo reverb with room size, damping, and width controls
Waveshaper waveshaper.hpp Static nonlinear distortion via transfer function (tanh, soft clip, custom)

Dynamics

Processor Header Description
Envelope Follower dynamics_contract.hpp Exact peak/RMS envelope timing, stereo detector linking, and canonical gain-reduction telemetry; BallisticsFilter retains its legacy nominal timing for render compatibility
Transient Designer transient_designer.hpp Zero-latency fast/slow-envelope shaper with independent attack and sustain gain; positive values enhance and negative values attenuate
Dynamic EQ Band dynamic_eq.hpp Zero-latency internally keyed band with threshold activity, signed boost/cut range, detector telemetry, and fixed-state RT processing
Compressor compressor.hpp Soft-knee downward compressor with threshold, ratio, attack, release
True-peak limiter true_peak_limiter.hpp Stereo look-ahead limiter with 8x intersample detection, a fixed 64-sample gain-scheduling horizon plus optional user lookahead, explicit channel linking, latency, tail, and gain-reduction telemetry; larger channel capacities require an explicit template specialization
DryWetMixer dry_wet_mixer.hpp Parallel mix with latency compensation — equal-power or linear crossfade
Gain gain.hpp Scalar gain stage; pair with smoothed_value.hpp, log_ramped_value.hpp, or audio apply_gain_ramp() when transitions need de-clicking
Noise Gate noise_gate.hpp Silence signals below threshold with hysteresis to avoid chatter

Generators and analysis

Processor Header Description
ADSR adsr.hpp Attack-decay-sustain-release envelope generator for amplitude or filter modulation
FFT fft.hpp Fast Fourier Transform — uses vDSP on Apple, fallback on other platforms
Multi-Channel Meter multi_channel_meter.hpp Sample peak, RMS, stereo correlation, and channel-based BS.1770-5 K-weighted momentary plus gated integrated loudness; not true-peak, short-term, LRA, or a complete EBU Mode meter
Oscillator oscillator.hpp Legacy polyBLEP oscillator with sine, saw, square, triangle waveforms (float phase, integrated triangle)
Oscillator suite (osc/) osc/va.hpp, osc/vco.hpp, osc/dco.hpp, osc/wt.hpp, osc/wt_lofi.hpp, osc/minblep.hpp Newer VA/VCO/DCO/wavetable family plus shared phase, polynomial BLEP/BLAMP, and fixed-capacity causal minBLEP primitives — see the oscillators guide
Velvet Noise Grid velvet_noise.hpp Coordinate-keyed jitter/sign draws for sparse velvet-noise tap grids; full and incremental builders produce identical draws
Spectrogram spectrogram.hpp Rolling time-frequency analysis for visual display of spectral content
STFT stft.hpp Short-time Fourier Transform for visualization (analysis-only; for processing use spectral_frame_engine.hpp)

Physical modeling

Processor Header Description
Modal Bank modal_bank.hpp SIMD-friendly bank of coupled-form modes with contact-pulse excitation, independent strike/pickup weights, amplitude-preserving retuning, and up to eight pickup outputs
Modal Specification modal_spec.hpp Versioned JSON interchange for modal frequencies, T60 values, amplitudes, and optional shapes, with bounded validation before allocation
Bridged-T Resonator bridged_t_resonator.hpp Trapezoidally integrated two-state model of the published TR-808 bridged-T network, exposing physical component values and circuit nodes; it is a resonator primitive, not a complete drum voice
Square Oscillator Bank square_osc_bank.hpp Allocation-free-after-prepare bank of independently tunable, weighted, band-limited square oscillators for inharmonic metallic excitation and other clustered sources

Modulation and utility toolkit

The library layer complex DSP composes from instead of re-implementing inline — sources, wires, events, envelopes, and the routing between them. Full guide with worked patches: the modulation toolkit.

Processor Header Description
Deterministic randomness rng.hpp Xorshift32 with a Box-Muller Gaussian, a stateless purpose-keyed hash, and the Ornstein-Uhlenbeck walk (OuWalkT, DriftT) behind every analog-drift effect
LFO lfo.hpp Seven waveforms plus a continuous shape morph, pulse width, triangle bias, a random blend, stereo and quadrature output, and a delay/fade-in/repeat/fade-out lifecycle
Control-signal tools mod_tools.hpp SlewLimiterT, SampleHoldT, AttenuverterT, RectifierT, ComparatorT, QuantizerT, CurveT, and the shared stage-curve law
Trigger and gate kit trigger.hpp TriggerDetectT, GateGenT, ClockDividerT, ClockMultT, BurstGenT, TrigDelayT — the event domain the modular world calls triggers and gates
Envelope family envelope.hpp ArT, AdT, AhdT, DahdsrT, ModEnvT with per-stage curves and looping, plus the level-independent TransientDetectorT
VCA vca.hpp Control-driven gain with linear or ~40 dB exponential response and a built-in de-clicking control lag; exactly unity at full
Low-pass gate lpg.hpp Vactrol-modelled Buchla gate — loudness and brightness move together, and a re-strike mid-decay accumulates the way a real roll does
Mod matrix mod_matrix.hpp Fixed-capacity source-to-destination routing with depth and a via slot; trivially copyable, so it hot-swaps through a TripleBuffer
Unit conversions units.hpp dB, MIDI pitch, cents, one-pole and T60 coefficients, tapers, and compatibility spellings for the canonical timebase musical divisions (straight, dotted, triplet)
Chaos chaos.hpp LogisticMapT — one control-rate source that runs from periodic to chaotic on a single knob

ModalBank::prepare() allocates its fixed-capacity storage and therefore runs off the audio thread. After preparation, process_add(), reset(), and pickup updates are allocation-free; set_modes() is allocation-free but evaluates transcendentals and should remain control-rate for large banks. Load and validate modal-spec JSON away from the audio callback, then pass the resulting mode span into a prepared bank. Link pulp::signal-modal-spec in addition to pulp::signal when calling parse_modal_spec() or to_json(). BridgedTResonator::prepare() and process() allocate nothing.

Spectral processing

Processor Header Description
Spectral Frame Engine spectral_frame_engine.hpp Streaming STFT analysis + overlap-add synthesis with coherent multichannel frame groups and variable synthesis hop
Realtime Pitch/Time realtime_pitch_time_processor.hpp Phase-vocoder pitch shifting (fixed duration, exact reported latency) and independent time stretching, with transient preservation, formant follow/preserve, and freeze
Phase Coordinator multichannel_phase_coordinator.hpp Laroche-Dolson phase propagation with identity peak locking, applied as one rotation per bin across a channel group — preserves inter-channel phase exactly
Source-filter Analysis source_filter_analysis.hpp Prepared cepstral and true-envelope analysis plus safely scaled autocorrelation LPC, reflection coefficients, Schur stability, and all-pole response; formant extraction remains explicitly unsupported
Envelope Shifter spectral_envelope_shifter.hpp Cepstral spectral-envelope estimation (true-envelope refinement) and formant warping with exact unity bypass
Transient Policy transient_phase_policy.hpp Spectral-flux transient detection (median + energy-relative gates) driving phase reset at onsets
Freeze Hold freeze_hold.hpp Spectral freeze / infinite hold with de-looped phase evolution, click-free engage/release, and a no-mute latch policy
Pitched Feedback Delay pitched_feedback_delay.hpp Delay with a latency-bearing processor inside the feedback loop, tempo sync, freeze-aware feedback gating, and a computed minimum delay
Control Smoother latency_aware_control_smoother.hpp Closed-form one-pole smoothing with attack/release asymmetry, semitone/ratio domains, block-size-independent trajectories
Windowing windowing.hpp Hann, Hamming, Blackman, Blackman-Harris, Blackman-Nuttall, flat-top, and Kaiser windows for FFT analysis

Math and utilities

Processor Header Description
Bias bias.hpp Shift a signal's DC offset — useful for asymmetric waveshaping
Fast Math fast_math.hpp Scalar DSP math helpers; approximations are explicitly labeled, while exp2 follows standard float edge semantics and exact representable integer powers under the ambient FP mode (audio-callback FTZ/FZ may flush subnormals)
Interpolator interpolator.hpp Lagrange and Hermite interpolation for fractional-sample delay and resampling
Log Ramped Value log_ramped_value.hpp Logarithmic smoothing for perceptually linear parameter transitions
Lookup Table lookup_table.hpp Pre-computed function table for fast repeated evaluation of expensive functions
Matrix matrix.hpp 2×2 through 4×4 matrix math for mid/side encoding, rotation, spatial processing
Routing matrix audio_matrix_mixer.hpp Fixed-capacity signed audio routing with continuous gain automation and explicit headroom policy
Mid/side mid_side.hpp Orthonormal stereo encode/decode and mono-safe width
N-way routing nway_crossfade.hpp, path_switcher.hpp, path_latency_aligner.hpp Constant-power path morphing, click-free selection, and exact latency alignment
Mirrored History Buffer mirrored_history_buffer.hpp Fixed-capacity single-thread sample history with a contiguous oldest-to-newest view and deterministic wrap cost
Panner panner.hpp Stereo and surround panning with equal-power or linear law
Polynomial Math poly_math.hpp Polynomial evaluation and Horner's method for waveshaper transfer functions
Processor Chain processor_chain.hpp Connect multiple processors in series — automatic prepare/process forwarding
SIMD Buffer simd_buffer.hpp Aligned memory buffer for SIMD-safe block processing
Smoothed Value smoothed_value.hpp Linear parameter ramps for zipper-noise reduction; use log_ramped_value.hpp for multiplicative/log smoothing
Special Functions special_functions.hpp sinc, Bessel, dB↔linear, MIDI note↔frequency conversions

dsl

External DSP language lanes that generate or adapt source into ordinary Pulp processors while keeping the third-party toolchain developer-supplied.

Link: pulp::dsl · Include prefix: <pulp/dsl/...>

Lane What It Does Guide
FAUST Offline code generation into checked-in C++ headers FAUST guide
Cmajor External Cmajor toolchain validation and generation Cmajor guide
JSFX Bounded source-only JSFX subset parsing and validation JSFX guide

All DSL lanes use the same contract: Pulp owns the processor wrapper and build integration; the external compiler/runtime remains opt-in and outside the public repository unless its license allows redistribution.


state

Parameters, state trees, presets, and settings.

Link: pulp::state · Include prefix: <pulp/state/...>

Plugin parameters — the core state system

#include <pulp/state/store.hpp>

StateStore store;
constexpr ParamID kGainId = 1;
constexpr ParamID kMixId = 2;

store.add_parameter({.id = kGainId, .name = "Gain", .unit = "dB",
                     .range = {-60.0f, 12.0f, 0.0f}});
store.add_parameter({.id = kMixId, .name = "Mix",
                     .range = {0.0f, 1.0f, 1.0f}});

// Audio thread reads atomically (no locks)
float gain = store.get_value(kGainId);

// UI thread writes with gesture grouping for undo
store.begin_gesture(kGainId);
store.set_value(kGainId, -6.0f);
store.end_gesture(kGainId);

StateTree — reactive hierarchical state

Like a JSON document that notifies you when anything changes. Use for complex plugin state beyond flat parameters.

#include <pulp/state/state_tree.hpp>

auto root = StateTree::create("synth");
root->set("name", std::string("My Patch"));
root->set("polyphony", int64_t(8));

auto osc = StateTree::create("oscillator");
osc->set("waveform", std::string("saw"));
osc->set("detune", 7.0);
root->add_child(osc);

root->add_listener([](StateTree& node, std::string_view prop, auto&, auto& new_val) {
    log(std::string(prop) + " changed");
});

std::string json = root->to_json();           // Serialize
auto restored = StateTree::from_json(json);   // Deserialize

PropertyValue supports scalar values (std::monostate, bool, int64_t, double, std::string) plus provider-neutral structured leaves:

root->set("macroState", make_property_object({
    {"name", std::string("Brightness")},
    {"points", make_property_array({0.0, 0.5, 1.0})},
    {"metadata", make_property_object({{"enabled", true}, {"revision", int64_t(2)}})},
}));

Use arrays/objects for structured leaf data that belongs to a property (JSON-like arrays, dictionaries, custom state records, imported var-style values). Use child StateTree nodes for owned tree structure: a ValueTree-like record with child records should become a StateTree parent with child nodes, while each node's non-structural payload can use scalar/array/object properties. Pulp intentionally does not store StateTree nodes inside PropertyValue: node ownership lives in the parent/child graph, which keeps deep_copy(), clone_synced(), and StateTreeSynchroniser free from hidden aliasing and cycles.

Existing scalar typed getters and std::get_if checks keep working; exhaustive std::visit handlers over PropertyValue should add Array/Object cases.

Persistent settings

#include <pulp/state/properties_file.hpp>

PropertiesFile settings;
settings.load("~/.config/MyPlugin/settings.json");
settings.set_string("theme", "dark");
settings.set_int("buffer_size", 512);
settings.save();

// Or use platform-standard paths automatically:
ApplicationProperties app("MyPlugin");
app.load();
app.user_settings().set_bool("first_run", false);
app.save();

Other state features

Feature Header Description
Binding binding.hpp Connect UI widget ↔ parameter with undo gesture grouping
Cached Property cached_property.hpp CachedProperty<double> freq(tree, "freq", 440.0) — auto-updates
Preset Manager preset_manager.hpp Factory/user presets, next/prev navigation, import/export
StateTree Sync state_tree_sync.hpp Binary delta sync over IPC for multi-process state
Undo Manager undo_manager.hpp undo_mgr.perform(action) / undo_mgr.undo()

timebase

Musical and media time primitives for scheduling without accumulated floating-point drift. CompiledTempoMap maps strong integer tick and sample positions across constant tempos and BPM-linear-in-tick ramps. Tempo-point boundaries use exact integer sample anchors; samples_to_ticks() returns a canonical tick so sample-to-tick-to-sample conversion is exact while the tick grid is at least as dense as the sample grid. Arbitrary ticks map monotonically to the integer sample grid and may canonicalize to a neighboring tick. On a sparser grid, resolve_sample() returns the nearest representable tick plus its exact sample error; for example, at 48 kHz and 1 BPM, ticks 0 and 1 map to samples 0 and 4, so sample 2 cannot have an exact integer-tick inverse.

The installed design-time capability timebase.tempo-map exposes exactly the CompiledTempoMap type, its validating compile() factory, ticks_to_samples(), and resolve_sample(). Compilation is a control-thread operation that owns and validates the authored map. The two published lookup operations are allocation-free and may run on the audio thread while the immutable compiled value remains alive. Cursor and fractional interpolation APIs remain available C++ APIs but are intentionally outside this capability's v1.0 contract. TempoCursor::tempo_at_tick() is observational: inspecting a future tempo does not change the sample-streaming position used by the next advance() or advance_fractional() call.

The module depends only on pulp::runtime for typed results. Tick and sample positions use their full signed 64-bit ranges; tick-position, duration, and MonotonicBeat arithmetic saturates at the nearest endpoint rather than overflowing. ticks_to_samples() likewise saturates when its mathematical result exceeds the sample domain. When a requested sample lies outside the image of the tick domain, resolve_sample() reports exact == false, a nearest canonical edge representation, and the actual sample error.

project_ratchet_interval() subdivides two adjacent clock boundaries into a bounded number of exact integer-tick hits. Its hit count includes the onset and the later boundary is excluded, so adjacent intervals neither duplicate nor orphan a clock-edge event. A count that would collapse multiple hits onto one integer tick is rejected. Projection into half-open windows is allocation-free and callback-partition invariant, including across non-divisible spans and the full signed tick domain.

Link: pulp::timebase · Include prefix: <pulp/timebase/...>

#include <pulp/timebase/compiled_tempo_map.hpp>

using namespace pulp::timebase;
const TempoPoint points[] = {
    {{0}, 120.0, TempoCurve::LinearInTicks},
    {{8 * kTicksPerQuarter}, 160.0, TempoCurve::Constant},
};
const auto compiled = CompiledTempoMap::compile(points, {48'000, 1});
if (!compiled)
    return handle_tempo_map_error(compiled.error());
const CompiledTempoMap& tempo = compiled.value();
const auto sample = tempo.ticks_to_samples({4 * kTicksPerQuarter});

MonotonicBeat is the strong type for the transport's non-looping musical clock; the transport owns advancement while timeline positions may seek or wrap. CompiledMeterMap provides the corresponding validated meter lookup.

BeatDivision is an append-only persisted vocabulary for straight, dotted, and triplet values from whole notes through sixty-fourth notes. beat_fraction() returns the reduced rational quarter-note value and division_ticks() converts it to the exact document lattice, failing explicitly if a future division is invalid, out of range, or not exactly representable. The older signal::units::Division spellings retain their public names and persisted ordinals, but convert to BeatDivision and derive beat values from this table; new divisions must be appended to both vocabularies with exhaustive parity coverage.

project_grid() projects those divisions through explicit GridProjectionRange values. Each range carries its document sample/tick anchors and its independent monotonic anchors, matching the clock domains a transport publishes without introducing a dependency on playback. A pre-loop range uses its ordinary document interval; every loop pass reuses the loop's document sample interval and advances only the monotonic anchor; a seek may replace the document anchor without resetting the monotonic clock. Timeline-anchored grids retain global phase and bar-anchored grids restart at exact bar boundaries. A stopped request emits no points. Callers provide output storage; insufficient capacity reports the required count without modifying it, and malformed ranges or sample/tick overflow fail explicitly. A block is bounded to 65,536 candidate and projected points, with an overflow-safe preflight before enumeration. Host-beat-mapped ranges retain their precise fractional tick endpoints and project ticks proportionally into output frames, so session tempo may differ from the document tempo without silently falling back to the document sample map. Their HostGridAnchor names one normalized source tick at one absolute output frame plus the source-ticks-per-frame slope; loop ranges add their document-to-source pass offset. Callers initialize that coordinate from the first resolved range in a normalization epoch, not from an absolute host beat that the transport has already wrapped, and reset it when the epoch or slope changes. Reusing that anchor across callbacks keeps a rounded loop split from moving a grid tick by one frame when callback partitioning changes. For document-clock ranges the rounded tick end is inclusive only as a candidate search bound; the half-open document sample interval is authoritative. This preserves a grid point in a one-frame range even when a sparse tick map rounds both range endpoints to the same tick, without duplicating it in the next range.

OrderPreservingGrooveKernel is not the canonical, named, sequence-owned timeline::GrooveTemplate. It is a fixed-capacity realtime projection kernel for the stricter non-reordering subset of that model. Its independent swing and table grids, 0..1000 strengths, 0..4000 velocity accents, and 1024-step ceiling match the timeline value domains so callers can adapt existing authored values without inventing another format. Timing strength scales both swing and table, making zero a complete identity. Construction checks the combined configured transform over a bounded joint period and rejects reorder or a period too large to validate. Application reports range failure rather than saturating a document-visible tick.

coordinate_random() and coordinate_chance() derive deterministic values from seed, tick, lane, loop cycle, and stream, rather than callback-local mutable RNG state.

LoopRegion is the loop bounds a transport honours, in document ticks. It lives here rather than beside either consumer because that is all it is — two document positions and whether they are in force — so the rung that runs the transport (playback::LoopRegion) and the rung that draws the ruler (timeline_editor::UiPlayhead::loop) name one type instead of two structurally identical ones. A disabled loop keeps its bounds, so turning looping off and back on returns the user to the region they set up and a view keeps drawing it meanwhile.

TriggerGrid is the allocation-free authored rhythm counterpart: fixed-capacity track×step cells carry velocity, exact rational probability, and bounded microtiming. Projection is a pure mapping of one caller-supplied cycle into a half-open tick window. The caller also supplies one stable random word per grid coordinate, so probability decisions do not depend on audio callback partition. The grid deliberately does not own transport advancement, groove or swing, mutable RNG state, generative pattern algorithms, or note lifetime.

#include <pulp/timebase/trigger_grid.hpp>

#include <array>
#include <cstdint>

pulp::timebase::TriggerGrid<8, 16> grid;
grid.configure(2, 16, {pulp::timebase::kTicksPerQuarter / 4});
grid.set_cell(0, 0, {.enabled = true, .velocity = 112});

std::array<std::uint64_t, 32> coordinate_draws{};
std::array<pulp::timebase::TriggerEvent, 32> events{};
const auto projected = grid.project_window({0}, {0},
                                            {pulp::timebase::kTicksPerQuarter},
                                            coordinate_draws, events);

timeline

Immutable document-model foundations and a bounded typed editing core for musical timelines. Project, Sequence, Track, and Clip are cheap copyable snapshots whose construction factories validate identities, ranges, references, and non-overlapping sparse arrangement lanes. Tracks retain persistent AVL indexes for both (anchor, start, ItemId) timeline order and ItemId lookup. replace_clip() path-copies only affected search paths while older snapshots share untouched subtrees.

Clip insertion, removal, movement, playback-property changes, note-velocity edits, and tempo/meter map replacement apply only through atomic transactions. DocumentSession serializes multiple control-thread writers, publishes pinned immutable snapshots, rejects stale revisions and typed precondition failures without partial application, and records a precise dirty set. Its bounded journal rejects when full rather than losing replay history; inverse-command undo/redo append ordinary new transactions.

Link: pulp::timeline · Include prefix: <pulp/timeline/...>

For subsystem contracts and ownership, see the Creative Timeline Engine SDK guide. For compile-backed project, transaction, persistence, playback, capture, launch, interchange, CLI, and MCP tasks, use the Timeline cookbook.

#include <pulp/timeline/model.hpp>

using namespace pulp::timeline;
auto empty = Clip::create({3}, {0}, {705'600}, EmptyContent{});
if (!empty)
    return;
auto track = Track::create({2}, "Notes", {std::move(empty).value()});

Every owned object uses a nonzero monotonic ItemId; a Project stores the next never-used value; UINT64_MAX is the explicit exhausted allocator state and is valid project state after ownership reaches UINT64_MAX - 1. Project::locate() returns an ItemLocation whose kind and immediate parent_id are the canonical ownership key. Its sequence, track, and clip IDs are ancestor-navigation caches, not additional ownership axes. Snapshot decode derives parent_id for older identity records that predate the field, while canonical output writes it explicitly. ClipTimeAnchor distinguishes tempo-following musical tick ranges from fixed absolute ranges expressed as SamplePosition, integer sample count, and a normalized RationalRate. ClipPlaybackProperties carries nonnegative linear gain plus fade-in and fade-out lengths in the clip anchor's native unit: canonical ticks for musical clips and timeline samples for absolute clips. Construction checks both fades against clip duration; compilation maps musical fades through the tempo map to exact frame counts. Mixed-anchor clips within one Track are rejected until a context-owned projection can compare those domains; a Sequence can still contain separate musical and absolute Tracks and bound both domains. remap_ids() performs two passes: it allocates every destination identity before rebuilding the immutable hierarchy. Clip, Track, and Sequence subtree overloads distinguish owned IDs from external media-asset references and accept an atomic ExternalIdFixup; closure-wide duplicate owned IDs are rejected before allocator state changes. MidiContent is a flat POD array sorted by (start, ItemId), alongside a sparse companion array of per-note playback modifiers sorted by note ID and an authored seed. A modifier carries a probability, a loop-pass condition, and a ratchet count; notes that play unconditionally and once carry no entry. Evaluation is a pure function of the seed, the note identity, and the loop-pass index, so an identical document and transport trace always reproduces the same sounding decisions. The same content carries the clip's controller and expression lanes, one lane per addressed stream, ordered by address with each lane's points ordered by (position, ItemId); a lane address is the MIDI wire's own group, channel, status nibble, controller bank, and controller index, and a point value is the 32-bit channel-voice data width. Clips authoring no controllers carry no lanes. Lane storage is complete, but playback does not yet emit lane values: compiling a clip that carries lanes fails with a named error rather than producing a program that plays the notes and drops the controllers. Fallible construction uses pulp::runtime::Result and reports ModelError without exceptions.

automation_curve.hpp provides immutable, position-ordered automation points with stable IDs, Hold or Continuous interpolation, and bounded monotonic curvature. Its random-access evaluator is a control/compile-time API. Timeline lane targeting remains document-model state, while audio-thread cursors and per-block event coalescing belong to pulp::playback; neither concern is folded into the curve container.

automation_lane.hpp provides an immutable binding from one curve to a format-neutral device-placement identity and opaque 32-bit parameter ID. Standalone construction validates the lane and placement IDs via ItemId::valid() (neither zero nor the exhausted UINT64_MAX sentinel); Track attachment additionally proves the placement exists, enforces unique placement/parameter targets, and registers lane and point identities in the Project. Lanes persist in snapshots and are reachable through typed commands and DocumentSession. pulp::playback compiles attached lanes into immutable cursor programs, while host-graph parameter delivery remains outside Timeline.

parameter_target.hpp holds the format-neutral vocabulary for naming a document parameter — a placed device parameter, or one of the owning track's own mixer controls. One vocabulary serves every consumer that addresses a parameter, because "which parameter" is addressing rather than a property of what writes there. AutomationTarget and ModulationTarget are both names for it.

modulation.hpp provides modulators, macro controls, and modulation routes as Track-owned document entities distinct from automation. The difference is what they write: an automation lane authors a parameter's base value over time, while a modulation route contributes a depth-scaled relative offset on top of whatever base is in force, which is CLAP's param_value/param_mod separation. Two consequences the document preserves: several routes may reach one parameter and their offsets sum, where two automation lanes on one parameter is a contradiction the model rejects; and depth belongs to the connection rather than to the source, so one macro reaches many parameters with a different amount for each. A route names its source by identity and kind, so a macro can never stand in for a modulator that shared its ID. Track attachment proves the source is a modulator or macro of the matching kind on the same track and that any referenced placement exists in that track's chain. No modulator runtime ships yet; the schema exists so routing authored now survives to the phase that adds one.

device_placement.hpp defines the durable identity of one logical placement in a Track-owned device chain. The chain preserves authored processing order through immutable clip edits, persistence, and ID remapping. A placement is identity/order-only: runtime instances, graph nodes, plugin formats, paths, and platform metadata stay outside Timeline. Project snapshots persist the placement identity and authored order, but do not persist a runtime device definition or configuration payload.

Take and TakeLane keep recorded source identity and comp intent in the document. Takes reference sealed assets in absolute sample time; comp segments select normalized, non-overlapping ranges from those takes. Tracks retain all lanes but select at most one active lane for playback, with zero meaning the original arrangement. InsertTakeLane, InsertTake, SetRecordArm, SetActiveTakeLane, and SetTakeComp use the same transactional precondition/journal/undo machinery as clip and automation edits. Active lanes and selected takes cannot be removed until their references are cleared.

TrackFreeze is an optional immutable selection of a sealed audio asset and the content hash of the render plan that produced it. It supersedes arrangement or comp playback without deleting authored clips, takes, automation, or device placements. Publish CreateAsset before SetTrackFreeze in one transaction; clear the freeze before removing the asset. A journal replay selects the sealed artifact and never performs a hidden render.

TrackMixer is the track's own level and stereo placement: a linear gain_linear and a pan balance in [-1, 1], edited with SetTrackMixer. AutomationTarget accordingly names either a placed device parameter or one of these mixer controls, so volume and pan automation is expressible without inventing an out-of-band convention for which device is the fader. Sends, mute, solo, and routing are not modeled. Playback applies the mixer where the track's audio is accumulated; the graph binding uses a stable post-device node so instrument output, effects, and tails are governed too. Hosted chains with a nontransparent mixer must identify their post-device source and post-mixer destination in TimelineTrackGraphRoute; the binding replaces and later restores the exact direct edge. A lane supersedes the authored constant rather than multiplying with it, and pan attenuates the opposite side without ever boosting.

assets.hpp separates durable SHA-256 content identity from optional resolution hints and alternate representations. An audio asset may also carry typed AudioLoopInfo: musical length and meter, one-shot intent, MIDI root note, half-open in/out markers, manual or analyzer-suggested loop points, and tags. Tempo is intentionally derived from musical length, frame count, and sample rate instead of being stored as a second value that can drift. Loop metadata is canonicalized at project construction and remains asset-description state; it does not make Timeline responsible for sample traversal or rendering. schema_registry.hpp provides an explicit immutable registry with typed extension codecs and bounded per-version migrations. schema_release.hpp exposes release-labeled structural version maps, and serialize_project_for_release() uses them to produce canonical snapshots for v0.736.0, v0.744.0, v0.748.0, or v0.750.0. The latter records the historical Track-v4 schema set; it predates SequenceRef content and sequence mutation commands. Release export fails when it would discard populated device, automation, take, audio-loop, or extension state, including nested-sequence content unsupported by the target. It removes inactive identity tombstones for kinds the target release cannot name while preserving next_item_id as the durable no-reuse boundary. serialize.hpp reads and writes deterministic JSON snapshots: 64-bit values are canonical decimal strings, malformed or oversized input is rejected under DecodeLimits, and unknown extension envelopes retain their exact validated bytes for lossless ordinary re-save. SerializedSnapshot flags those opaque objects so callers can surface compatibility risk. peek_project_summary() uses the caller's load SchemaRegistry, validates the complete structural envelope, and reports project identity, name, root, and supported-object counts without constructing a Project or resolving references; it is the bounded project-browser and load-admission tier. This is snapshot JSON only; it does not read or write ZIP/package containers.

journal.hpp defines the optional JournalSink persistence seam. A session publishes a transaction only after the sink reports its complete batch durable, and installs a checkpoint before discarding the covered in-memory entries. Because a failed write can have reached storage before its error is observable, any sink error poisons that session for subsequent durable writes. Sink callbacks run under the session writer lock and must not call lock-taking APIs on the originating DocumentSession.

file_journal.hpp provides the native crash-consistent implementation. It writes canonical snapshots as versioned, checksummed frames and completes each append only after a platform durability fence. Recovery accepts only a valid frame prefix, discards and reports a torn trailing frame, and fails closed on earlier corruption. A checkpoint at the current durable revision replaces the file through a synced temporary sibling and atomic rename; checkpointing an older prefix preserves the newer durable frames already on disk. Recovered sessions resume at their stored nonzero DocumentRevision only after the sink validates an exact canonical-serialization/revision match without mutating durable state. Symlink paths share one canonical lock identity. Multiply linked journal files are rejected because atomic checkpoint replacement cannot preserve hard-link identity. Package containers remain outside this module surface.

SequenceRef clips place another project-owned sequence without transferring ownership. Project construction rejects missing references, cycles, and depth greater than eight. Sequence mutation, eager copy-on-edit divergence, and reference retargeting use typed commands and the ordinary journal/undo path. Playback expands supported child note/audio content into immutable root-track programs before publication and fans child dirtiness out through every transitive placement; unsupported child processing state fails compilation closed.

This surface intentionally excludes package I/O, playback delivery, runtime launch arbitration, device implementation and routing, and UI. Authored scenes and launch slots are durable document state. The compiler accepts Arrangement only; the embedding application owns runtime launcher interpretation and scene-to-track arbitration.

project_package

Crash-consistent publication for stable project-package roots and generic files or directories. AtomicPublisher::create() creates a private sibling directory stage, accepts only safe package-relative paths through write(), and publishes it with commit_directory(). File publication instead uses create_file(), which returns one pre-created staging_file() that an external producer may truncate and fill before commit_file(). Both modes publish a previously absent destination without replacing it. PackageWriter instead maintains one stable package root: stage_blob() hash-verifies and fences content-addressed blobs before publishing them no-replace, while publish() validates every package-relative asset reference before atomically replacing the root's project.json generation and fencing the root directory. An interrupted stage remains unreachable and cannot expose a durable reference to unfenced content; package-wide abandoned-stage recovery and reachability GC belong to the follow-on recovery layer.

Writer exclusion is cooperative. All package writers must honor the package lock, and callers must not concurrently rename or replace package or private staging entries out of band from another process running as the same account. Any external producer using staging_directory() or staging_file() must finish and release the stage before commit or cancellation begins. On Windows, staged objects retain a private DACL until the rename succeeds; the still-open published handle then adopts the destination parent's inheritance. A crash before or during that adoption can leave the published object or some descendants owner-private instead of broadly inherited; callers must treat final permissions as incomplete. The implementation pins and revalidates identities to reject detected rebinding, but POSIX does not provide a portable operation that renames an already-open directory by identity.

Link: pulp::project-package · Include prefix: <pulp/project_package/...>

#include <pulp/project_package/atomic_publisher.hpp>

using namespace pulp::project_package;

auto publisher = AtomicPublisher::create(destination);
if (!publisher)
    return;
auto staged = publisher->write("render-manifest.json", manifest_json);
if (!staged || !staged.value())
    return;

auto outcome = publisher->commit_directory();
if (!outcome || outcome.value() != AtomicPublishOutcome::PublishedDurably)
    return;

Use PackageWriter when the destination is a stable project-package root. Stage each referenced blob first, then publish the Project that references it; opening the package revalidates both the generation and its references:

#include <pulp/project_package/project_package.hpp>

using namespace pulp::project_package;
using pulp::timeline::make_builtin_timeline_registry;

auto registry = make_builtin_timeline_registry();
if (!registry)
    return;
auto writer = PackageWriter::create(package_root, registry.value());
if (!writer)
    return;

auto blob = writer.value().stage_blob(BlobStore::Media, media_hash, media_bytes);
if (!blob)
    return;
// `project` contains the matching package-relative asset reference.
auto outcome = writer.value().publish(project);
if (!outcome || outcome.value() != AtomicPublishOutcome::PublishedDurably)
    return;

auto opened = open_package(package_root, registry.value());
if (!opened)
    return;

PublishedDurabilityUncertain means the new project.json may already be visible even though final permission adoption or the directory fence did not complete; callers must not report that outcome as a definite rollback.

Source builds may set PULP_ENABLE_PROJECT_PACKAGE=OFF to omit this component and the dependent Timeline authoring tools. The resulting installed SDK does not export Pulp::project-package, so requesting the project-package component with find_package(Pulp REQUIRED COMPONENTS ...) fails.

The module owns publication and bounded exact-prefix cleanup of its private staging files, not package-wide recovery, reachability GC, a project schema, or an archive format. Timeline remains the authority for canonical project JSON; DAWproject ZIP admission and interchange loss accounting stay in their format and tooling layers rather than entering this dependency floor.

Depends on: pulp::timeline, pulp::runtime

interchange

Format-neutral interchange machinery shared by every exporter and importer. A capability table declares, per concept, what a given format can represent; plan_export() walks a Project against that table and returns an ExportPlan carrying a LossManifest of everything the format cannot hold.

The seam is deliberately consent-based and fail-closed: run_export() refuses a plan whose losses the caller has not accepted concept by concept. There is no force flag, so an export cannot silently discard authored material. New adapters plug in as format-bound FormatBoundExportWriter handles whose callable is private to run_export(). The released callable ExportWriter alias remains available for source compatibility. The plan owns the exact immutable Project snapshot the writer receives, so consent measured on one revision cannot authorize bytes captured from another. run_export() centrally appends the versioned pulp-loss-manifest.json artifact and reserves that name from adapters.

A census pass records which concepts a project actually uses, so a document that never uses a lossy concept is reported lossless rather than being flagged on the format's theoretical limits.

Depends on: timeline, timebase, runtime

dawproject

Reader and writer for the DAWproject interchange format, implemented directly from the published specification over pugixml. Ships as two libraries so a consumer can take only what it needs: pulp::dawproject-import and pulp::dawproject-export.

Both sides cover the same bounded linear subset — flat tracks, beats-timed clips, inline notes, referenced audio, one tempo and one meter — and both fail closed outside it. The importer refuses nested groups, warps, seconds-timed lanes, and unknown elements rather than dropping them. The exporter routes every unrepresentable concept through the interchange consent seam and writes an in-band loss manifest into the package alongside project.xml, so the losses travel with the file instead of scrolling past in a console.

A track's <Channel> is admitted on import only when neutral, and refused by its own concept when it states a volume or pan. On export a neutral channel is written so a receiving DAW registers the track at all; that is a structural requirement of the format, not an export of authored mixer state, which the loss manifest still reports as dropped.

Media identity is sealed by content hash, and package-relative asset paths are confined to the package at two layers — a lexical check in the model and a canonicalizing beneath-the-base check in the loader, which is what catches a symlink that points outside.

Depends on: interchange, timeline, audio, runtime

smf

Standard MIDI File import and export, plus a consent-gated interchange adapter.

Links: pulp::smf-interop / pulp::smf-interchange · Include prefixes: <pulp/timeline/smf.hpp> / <pulp/smf/interchange.hpp>

The raw codec library is separate so a consumer can leave SMF out, but that raw API lives with the timeline surface it operates on. The consent adapter has its own <pulp/smf/...> include prefix.

Import accepts format 0 and 1 with a metrical division, note on/off, and the tempo, time-signature, track-name, and end-of-track meta events. SMPTE divisions, format 2, and any other event fail the import unless the caller explicitly opts into ignoring non-note events.

The raw export API strictly rejects unsupported clip/event/time-grid shapes it visits, but it does not census unrelated project-wide state. The separate pulp::smf-interchange adapter binds SMF to plan_export() / run_export(). With exact per-concept consent it can omit unsupported concepts, strip note modifiers, and approximate tempo ramps as discrete authored-point steps; the central loss manifest records every such decision. Tick rounding remains under the raw SMF export option and is never implied by concept consent.

Interchange planning is concept-level. representable() says that a concept kind has format support in isolation; it is not an instance survivor list. For example, notes inside an accepted dropped absolute clip remain a supported note concept even though the container loss explains why those instances are omitted. Only the census-backed adapter provides project-wide loss consent; the raw codec is not a full-project loss audit.

Depends on: pulp::timeline, pulp::runtime; adapter additionally depends on pulp::interchange

graph

Graph runtime scaffolding shared by the host binding and the sequence adapter.

Link: pulp::graph · Include prefix: <pulp/graph/...>

Levelization orders nodes into dependency levels, buffer assignment allocates and reuses the minimum set of intermediate buffers, and the runtime queue drives execution. The plan is computed on the control thread and consumed as an immutable snapshot by the audio thread, so the render path performs no allocation or graph traversal decisions of its own.

Depends on: pulp::midi, pulp::runtime

scene

Optional 3D scene surface (Scene3D).

Link: pulp::scene · Include prefix: <pulp/scene/...>

Loads glTF 2.0 assets, resolves material keys, and emits render packets for the GPU path. Bake preflight validates a scene before it reaches the renderer. This module is independent of the 2D view/canvas stack.

Depends on: pulp::runtime

sample_bank_manifest

Sample-bank manifest parsing, split out so a consumer can link it without the whole audio module.

Link: pulp::sample-bank-manifest · Include prefix: <pulp/audio/...>

It has no sources of its own — it compiles core/audio/src/sample_bank.cpp and exposes the matching core/audio/include headers. pulp-audio PUBLIC-links it, so every consumer of the audio module already has it in their link closure; the separate target exists for consumers that want manifest handling alone.

Depends on: pulp::runtime

timeline_agent_view

Bounded, versioned read projections for agents and other context-limited consumers. AgentView pins an immutable timeline::DocumentView; every read requires the caller's expected revision and refuses mismatches. The outline is project/sequence/track/clip sized. Rows carry Merkle content commitments, while each omitted count and SHA-256 covers only the directly omitted authored rows, so omission counts form a non-overlapping partition of the structural census.

Region pages select clip starts in a half-open window and order them by (start, id). A cursor is accepted only when its version, revision, sequence, anchor, exact window bounds, and key identify a member of that same window. DirtySet projection additionally requires an adjacent before/after revision range ending at the pinned view. That range rejects stale and multi-commit projections, but it cannot authenticate DirtySet origin because the public set type carries no session-issued provenance token; callers must pair it with the CommitResult that produced it. Removed identities map to their tombstoned nearest outline owner.

Link: pulp::timeline-agent-view · Include prefix: <pulp/timeline_agent_view/...>

Depends on: pulp::timeline, pulp::runtime

timeline_editor

Interfaces for building a timeline editor over the document model, plus the edit vocabulary a gesture speaks.

The module exists so an editor view can show a moving playhead, let a user hear what they are editing, and hand out the edits a gesture produced — without linking playback. SequencerUiHost is that entire coupling: a view holds one, and whoever owns audio implements it. A plugin that draws a piano roll over its own engine implements it itself and never acquires a transport.

Two properties make the split hold, and both are enforced rather than asserted. Everything crosses the interface by value, so a playhead reading a view is holding cannot be invalidated when the engine adopts a different compiled program — it goes stale, never dangling, and UiPlayhead::program_generation is how a view tells the difference. UiPlayhead::continuity_epoch answers the separate question of whether the position moved continuously between two readings: a loop wrap, a seek, and a scrub anchor all break continuity without recompiling anything, so a view that smooths motion between readings must hold the newer position outright across a change in this value rather than interpolating into it. And the vocabulary is document-side: positions are timebase ticks, subjects are timeline::ItemIds. Nothing here describes how audio is produced. tools/scripts/timeline_engine_dependency_floor_check.py holds the module to that closure over both its includes and its CMake links.

Link: pulp::timeline-editor · Include prefix: <pulp/timeline_editor/...>

#include <pulp/timeline_editor/sequencer_ui_host.hpp>

using namespace pulp::timeline_editor;

// A view repaints its ruler from a value it owns outright.
const UiPlayhead reading = host.playhead();
if (reading.moving())
    draw_playhead_at(reading.position);

// Smoothing between publishes stops at a continuity break, so a loop wrap
// never draws the playhead sliding backwards through the whole timeline.
if (reading.continuity_epoch == previous.continuity_epoch)
    draw_playhead_at(interpolate(previous.position, reading.position, alpha));
else
    draw_playhead_at(reading.position);

// Clicking a note asks to hear it, in document terms only.
AuditionRequest request;
request.track = track_id;
request.pitch = 60;  // pitch, velocity, channel as timeline::NoteEvent spells them
const AuditionResult result = host.begin_audition(request);
if (result.handle.valid())
    host.end_audition(result.handle);  // on mouse-up

Edit intents are the editor's own vocabulary, so intent submission lives on SequencerUiHostT<Intent>, a thin templated shim over the same interface. The non-template base carries the two duties that do not depend on how edits are expressed.

EditIntent is that vocabulary, and lower_edit_intent turns one intent into the ordinary timeline::Transaction that performs it — Draw to InsertClip, Erase to RemoveClip, Move and Resize both to MoveClip, since a resize is a move whose replacement range changes extent. An intent carries no coordinates, no button, and no pointer id: a front-end resolves those against its hit metrics first, so mouse, touch, and pen produce identical transactions by construction. EditIntentHost names the concrete SequencerUiHostT<EditIntent> a front-end submits to. The verbs live at this rung rather than in the document model so the floor check can reject a reducer or serializer that reaches for one; the model's floor excludes this module, which is the only direction in which the two rungs differ.

TrackEditIntent is a second channel beside it, for arranging tracks rather than editing clips, and lower_track_edit_intent turns one into the MoveTrack that performs it. It is a separate type rather than added fields on EditIntent because the two name different subjects: a clip intent names a clip inside a track and carries clip time ranges, while a track intent names a track inside a sequence and carries an insertion point. Folded together, every clip intent would carry track-destination fields that are always empty and vice versa, with nothing in the type able to say which combination is meaningful. TrackEditIntentHost is the matching SequencerUiHostT<TrackEditIntent>, so a view that only rearranges tracks never acquires the clip vocabulary.

Insertion is expressed as "before this track", matching the command, so a front-end that resolved a drop position to a neighbour need not convert it to an index. A std::nullopt destination means last position — a request, not an omission, and deliberately not a separate append verb.

#include <pulp/timeline_editor/track_edit_intent.hpp>

TrackEditIntent intent;                       // drag `moved` above `neighbour`
intent.sequence_id = sequence_id;
intent.track_id = moved;
intent.expected_before_track_id = current_neighbour_of(moved);
intent.replacement_before_track_id = neighbour;
auto transaction = lower_track_edit_intent(intent, identity);

Piano-roll gestures use the sibling NoteEditIntent vocabulary. Insert carries only a replacement note, erase carries only the expected note, and move, resize, and velocity edits carry both snapshots with the same note identity. ValidatedNoteEditIntent::create checks that shape and the note domain, and NoteEditIntentHost accepts only the validated wrapper. Note intents deliberately have no transaction lowerer yet: granular note commands own that later boundary, so this editor API does not disguise an O(clip) ReplaceNoteContent rewrite as an interactive note edit.

undo_gesture_budget answers how long a gesture can stream before the document refuses it, which an editor needs before it opens one. A gesture coalesces every Update into a single undo group, that group stays open until its End, and only closed groups are evictable — so the charge accumulates with nothing able to reclaim it, and past UndoLimits::max_retained_bytes the next step comes back ConflictCode::UndoFull: a drag that stops responding rather than one that degrades. The answer is max_retained_bytes / step_bytes and does not depend on what the undo stack already holds, because a session has one open gesture at a time, so every group present when one opens is closed and evictable.

#include <pulp/timeline_editor/gesture_budget.hpp>

// Priced before the gesture opens, from the step the drag is about to repeat.
const auto budget = undo_gesture_budget(limits.undo, forward, inverse);
if (budget.steps < expected_frames)
    hold_the_edit_locally_and_commit_one_single_on_release();
else
    stream_updates_into_one_open_group();

A GesturePhase::Single edit is closed on admission and therefore immediately evictable, so the fallback branch has no step ceiling at all — only undo depth. This is the undo ceiling alone: JournalLimits bounds the same gesture independently, has no automatic eviction, and can bind first.

ScriptedUiHost<Intent> is a host whose playhead is written by the caller and which keeps what a view emitted, so an editor is testable with no audio and no mocking framework. It is also a legitimate deployment: an editor embedded in a tool that only writes files gets a stopped playhead and Unsupported auditions, and stays fully usable.

Depends on: pulp::timeline, pulp::timebase

timeline_view

Views that draw the document and turn a gesture into an edit intent — the first consumer of the editor rung rather than another declaration of it.

The rung exists so a view can be built, tested and reasoned about without an engine. Its floor row admits timeline_editor, timeline, timebase, view, canvas, platform and runtime, and deliberately omits playback: that omission is the contract, not an oversight. A view's only coupling toward audio stays the SequencerUiHost interface, so an arranger drawn over somebody else's engine acquires no transport. Wanting to widen the row to reach playback means wanting a host that implements the seam. It also omits project_package, which keeps storage a sibling rung rather than a base: an editor is proven against a serialize_project round trip, and re-hosting it on a package protocol later is adapter work above the row.

A view takes resolved values — an origin tick, a px_per_tick, a resolved tolerance_px, or a TickProjection/PitchProjection that is already a pair of scalars with a name. What it does not take is viewport policy: an object owning DPI, zoom or scroll state that the view must keep in sync. That line is what lets a viewport slice and a view slice proceed concurrently without racing, and it keeps a view testable headlessly with plain numbers.

Link: pulp::timeline-view · Include prefix: <pulp/timeline_view/...>

playback

The master timeline transport publishes integer-authoritative block snapshots. Normal blocks contain one TransportRange; a block crossing a loop boundary contains exactly two, with the second range marked as a discontinuity. Timeline ticks wrap while MonotonicBeat remains continuous, so launch and scheduling intent can use a clock that does not repeat. Forward, backward, and stopped seeks reanchor only the timeline; they never jump the monotonic clock.

Control-thread changes are published as one coherent SeqLock state. The audio thread consumes that state through allocation-free begin_block() calls. A stopped block still covers the callback's frames while holding both musical clocks. Loops shorter than the configured maximum block are rejected, which guarantees one block can cross at most one loop boundary.

Snapshots carry first-block-safe transport and meter change flags, while each range carries its own tempo and tempo_changed flag. This keeps a loop split across tempo regions faithful when projected into per-range ProcessContexts.

Link: pulp::playback · Include prefix: <pulp/playback/...>

#include <pulp/playback/transport.hpp>

pulp::playback::MasterTransport transport;
transport.prepare(tempo, {
    .max_buffer_size = 1024,
    .initially_playing = true,
});

pulp::playback::TransportSnapshot block;
transport.begin_block(512, block);
for (std::uint8_t i = 0; i < block.range_count; ++i) {
    schedule(block.ranges[i]);
}

Plugin/host adapters include <pulp/format/playback_context_projection.hpp> to project a range into the public ProcessContext. That header is the only lossy tick-to-double boundary; core/playback does not depend on format, host, or view code.

The optional backend-neutral TempoSyncSource lets MasterTransport consume a session tempo/beat mapping at the output boundary without putting device state in the timeline document. Its host-time overload accepts an opaque timestamp tagged by the producing source, preserves precise fractional ticks, and fails closed when the token belongs to another clock domain or the source is unavailable. The desktop adapter's realtime-safe output_host_time() reads Link's platform clock and adds device/output latency before producing that token. A developer can compile the desktop pulp::ableton-link adapter from source with PULP_ENABLE_ABLETON_LINK=ON and an out-of-tree PULP_ABLETON_LINK_SDK_DIR; that SDK-backed target is never installed or exported.

The module also compiles immutable PlaybackProgram snapshots off the audio thread. A request carries one immutable Project snapshot, its external document revision, a precompiled tempo map, and an explicit dirty-track set. Clean TrackProgram objects retain shared-pointer identity while dirty tracks receive a newer generation; revisions skipped by request coalescing are valid. Sparse per-track policy deltas select an available provider and whether a stable shell carries state by ItemId or resets it on stateless adoption. Omitted tracks retain their published policy, and coalescing merges deltas with latest-track wins before publication. The compiler currently accepts arrangement payloads only: launcher/external-input selections and availability bits are rejected until those provider programs exist. DeferredCompileExecutor advances bounded slices from an idle/UI pump and WorkerCompileExecutor supplies the native background lane, with an explicit unsupported stub in threadless builds.

AutomationProgram separately compiles one immutable automation lane against the exact shared tempo map. It retains tick endpoints so curved values stay in musical time across tempo ramps while also storing sample-domain knots for cursor traversal. A nonzero compile-time instance token distinguishes immutable programs even when a caller accidentally reuses a generation. AutomationCursor is an allocation-free, single-audio-thread renderer over the transport's one or two half-open ranges. It emits immediate or linear-ramp plain-domain control points into a caller-owned span. Selection work and output are bounded by that explicit capacity. Range seeds and unique in-range authored knots are mandatory; remaining capacity deterministically refines continuous spans without erasing authored topology. The cursor reseeds on loop/seek/adoption and rejects tempo-map or monotonic-generation mismatches. TrackAutomationProgram validates one compiler-supplied track grouping and retains its exact tempo-map and lane-program owners in lane-ItemId order. The grouping rejects duplicate lane identities and duplicate device-parameter targets while allowing unchanged lanes to retain older generations and instance tokens. It is compiled playback data, not proof of Timeline document attachment; PlaybackProgramCompiler owns authored-lane dirty tracking and reuse decisions. This layer deliberately does not know graph nodes or ParameterEventQueue; a host binding must aggregate all lanes for a device, apply one global queue budget, and inject one batch.

MediaRef clips use an immutable DecodedAudioAssetPool. Complete WAV bytes can be decoded into this pool through the bounded no-file-I/O decoder, then the ordinary incremental compiler lowers source ranges, musical or absolute clip placement, gain, and fades into each TrackProgram. The renderer uses bounded 64-tap, 512-phase Kaiser-windowed sinc sample-rate conversion so media retains its native wall-clock speed while strongly suppressing out-of-band content before downsampling. Program compilation builds and shares one immutable kernel per source/target rate pair; equal-rate media keeps an exact bypass. Musical anchoring uses the tempo map for placement and extent but does not silently introduce warp or time-stretch. Missing media, metadata mismatches, invalid ranges, and capacity excesses reject compilation.

ArrangementAudioRenderer::process() consumes the same pinned PlaybackProgram and the transport's complete zero/one-wrap snapshot. It clears output, mixes arrangement-selected tracks in deterministic ItemId order, zero-fills stops and source EOF, applies gain and linear fades sample-exactly, and performs no allocation or lock on the audio thread. Mono sources duplicate to wider output; multichannel sources average to mono or map like-numbered channels. Float sums are not clipped or normalized by the engine.

At an audio callback boundary, one PlaybackProgramBlockLatch pins the whole program for the block. Every StableRendererShell consults that same pin, adopts only a newer generation for the same ItemId, and carries its small cursor snapshot through a SeqLock.

Dirty arrangement tracks also compile their note clips into immutable, tempo-map-resolved event streams. ArrangementNoteRenderer consumes the same block pin plus the transport's one or two half-open ranges and emits sample-offset MIDI without a graph audio node. Call prepare() off the audio thread to reserve its bounded output buffer; process() is guarded by ScopedNoAlloc. The output preserves authored 16-bit note velocity in a native MIDI-2 UMP sidecar and provides a MIDI-1 compatibility mirror; capacity is preflighted so a physical event appears in both lanes or neither. Note-offs precede note-ons at equal samples. Program adoption, seek, loop wrap, and stop release active notes and reset the cursor; Phase 1 does not chase a note whose onset precedes the new range. The transport snapshot and program must name the same compiled tempo-map identity, and overlapping logical notes on one channel/pitch are reference-counted so the physical note-off waits for the last overlap. Timeline commands and persistence preserve the clip gain and fade properties consumed by the audio compiler.

capture_engine.hpp is the bounded realtime recording boundary. prepare() allocates fixed take slots, audio storage, MIDI storage, and command/event queues from caller-supplied limits; process() only mutates those prepared buffers. Completed handles stay immutable until an explicit ReleaseTake. Control-side code drains events, copies captured data, and surfaces queue or capacity failures from CaptureEngineStats.

recording_commit.hpp seals captured or retrospective audio into WAV bytes, a content-hashed MediaAsset, a Take, and ordered ordinary Timeline commands. midi_capture_materializer.hpp converts captured MIDI frames through the exact capture-rate CompiledTempoMap into note content while retaining MPE expression as a sidecar. Applications submit the resulting commands through DocumentSession and publish media bytes separately; the callback never edits the project or journal.

sequence

Format-facing integration for publishing an immutable timeline playback program as a Pulp processor.

Link: pulp::sequence · Include prefix: <pulp/sequence/...>

SequenceProcessor adapts a caller-owned PlaybackProgramStore to pulp::format::Processor. It projects host transport into timeline ticks and executes the compiled track graph for VST3, AU, or CLAP adapters. It does not own project editing, playback compilation, media resolution, a plugin host, device I/O, or an editor.

#include <pulp/sequence/sequence_processor.hpp>

pulp::playback::PlaybackProgramStore programs;
pulp::sequence::SequenceProcessor processor(programs);

This is deliberately a heavier boundary than the engine-only pulp::timebase/pulp::timeline/pulp::playback stack: linking it also brings the format, graph, and state integration needed by a plugin processor. Publish compatible immutable programs from a control or worker thread; the audio callback only consumes the current program and host transport.

format

Plugin format adapters — write your plugin once, deploy to 9 formats.

Link: pulp::format · Include prefix: <pulp/format/...>

Writing a plugin

#include <pulp/format/processor.hpp>

class MyPlugin : public Processor {
public:
    PluginDescriptor descriptor() const override {
        return {
            .name = "MyGain",
            .manufacturer = "MyCompany",
            .bundle_id = "com.myco.gain",
            .version = "1.0.0",
            .category = PluginCategory::Effect,
        };
    }

    void define_parameters(state::StateStore& store) override {
        store.add_parameter({.id = gain_id_, .name = "Gain", .unit = "dB",
                             .range = {-60.0f, 12.0f, 0.0f}});
    }

    void prepare(const PrepareContext& context) override {}

    void process(audio::BufferView<float>& audio_output,
                 const audio::BufferView<const float>& audio_input,
                 midi::MidiBuffer& midi_in, midi::MidiBuffer& midi_out,
                 const ProcessContext& ctx) override {
        float gain = db_to_linear(state().get_value(gain_id_));
        for (std::size_t ch = 0;
             ch < audio_output.num_channels() && ch < audio_input.num_channels();
             ++ch) {
            auto in = audio_input.channel(ch);
            auto out = audio_output.channel(ch);
            for (std::size_t i = 0; i < out.size(); ++i) {
                out[i] = in[i] * gain;
            }
        }
    }

private:
    state::ParamID gain_id_ = 1;
};

This single class automatically works as VST3, AU, CLAP, LV2, AAX, Standalone, WAM, and WCLAP.

Supported formats

Format Status Notes
AAX ✓ Usable Requires developer-supplied Avid SDK
AU v2 ✓ Usable macOS only, via AudioUnitSDK
AU v3 ✓ Usable macOS + iOS (always runs sandboxed as .appex extension)
CLAP ✓ Stable First-class — modulation, WebView, note expressions
LV2 ✓ Usable Linux plugin format
Standalone ✓ Stable Desktop app with audio settings, test signal
VST3 ✓ Stable Full parameter sync, state, editor resize
WAM ✓ Experimental Web Audio Module (browser)
WCLAP ✓ Experimental Web CLAP (browser)

Other format features

Feature Header Description
ARA ara.hpp Audio Random Access document-controller scaffold and SDK-gated companion-factory hooks
Host Detection host_type.hpp detect_host_type() → Logic, Reaper, Ableton, etc.
Settings Panel settings_panel.hpp Audio/MIDI device selector with test signal and meters
ViewBridge view_bridge.hpp Editor-view lifecycle: create_view(), on_view_{opened,closed,resized}, multi-view attach (editor + inspector + remote). See docs/guides/view-bridge.md.

The SDK-facing Processor and host-side PluginSlot surfaces share the node ABI policy: virtual methods are append-only within a node ABI generation, and new optional behavior should prefer additive descriptor capabilities over new virtual methods.


host

Plugin hosting — the mirror of format. Load VST3 / AU / CLAP / LV2 plug-ins, wire them into a DAG, and process audio through the chain.

Feature Header Description
Scanner pulp/host/scanner.hpp Walk system plug-in paths; return PluginInfo
PluginSlot pulp/host/plugin_slot.hpp Uniform load/prepare/process interface over every format
SignalGraph pulp/host/signal_graph.hpp DAG topology + topological sort
Bake pulp/host/baked_graph_processor.hpp Freeze a lowerable SignalGraph into an optimized BakedGraphProcessor (bit-identical to the live graph). bake() is the in-process (trusted) path
Baked codec pulp/host/baked_codec.hpp Signed on-disk .pulpbake artifact: write_baked_signed + verify-before-parse load_baked (Ed25519 trust-set, bounded parse). See signal-graph

All four format loaders (CLAP, VST3, AU, LV2) are implemented in core/host/src/plugin_slot_*: each opens the bundle, resolves the format's factory/descriptor, and wires the host-side PluginSlot onto the format's real processing entry point. Feature coverage varies per format (parameter automation, MIDI routing, editor views, and state serialization are each at different stages); see the per-format source files for the current scope. If an SDK is not compiled in at configure time (for example, AU on Linux), the matching case in PluginSlot::load() logs a warning and returns nullptr. See hosting guide and signal-graph reference.


canvas

2D drawing with GPU acceleration and smart text layout.

Link: pulp::canvas · Include prefix: <pulp/canvas/...>

Drawing

void MyWidget::paint(Canvas& canvas) {
    // Background
    canvas.set_fill_color(Color::rgba8(30, 30, 40));
    canvas.fill_rounded_rect(0, 0, width, height, 8);

    // Gradient fill
    const Color colors[] = {Color::rgba8(80, 120, 255), Color::rgba8(40, 60, 180)};
    const float positions[] = {0.0f, 1.0f};
    canvas.set_fill_gradient_linear(0, 0, 0, height, colors, positions, 2);
    canvas.fill_rect(10, 10, width - 20, 4);
    canvas.clear_fill_gradient();

    // Text
    canvas.set_fill_color(Color::rgba8(220, 220, 230));
    canvas.set_font("Inter", 14);
    canvas.fill_text("Hello Pulp", 10, 30);

    // Image
    canvas.draw_image_from_file("icon.png", x, y, 32, 32);
}

Backends: Skia Graphite (GPU — Metal/Vulkan/D3D12) or CoreGraphics (macOS/iOS native).

TextShaper — measure once, reflow forever

Inspired by Cheng Lou's PreText. The expensive text measurement runs once; resizing uses just arithmetic.

#include <pulp/canvas/text_shaper.hpp>

TextShaper shaper;
auto prepared = shaper.prepare("Long text that wraps...", "Inter", 14);

// On every resize — pure arithmetic, no font calls:
auto layout = shaper.layout(prepared, container_width);
// layout.line_count, layout.total_height, layout.lines[i].text

float height = shaper.measure_height(prepared, 300.0f);  // Fast

CMake option: PULP_TEXT_SHAPING — ON with GPU (real HarfBuzz metrics via Skia), OFF without (character-width estimation). Same API either way.

Other canvas features

Feature Header Description
Attributed String attributed_string.hpp Rich text spans — mixed font, color, weight per range
View effects view_effect.hpp Per-view GPU post-processing: blur, bloom, vignette, chromatic aberration, EffectChain
Image Convolution image_convolution.hpp ImageConvolutionKernel::gaussian_blur_5().apply(pixels, w, h)
Rectangle List rectangle_list.hpp Clip regions with add/subtract/intersect for dirty tracking
SVG svg.hpp Load and render SVG vector graphics via nanosvg
SDF text sdf_atlas.hpp Single-channel signed distance field glyph atlas for resolution-independent GPU text. See docs/reference/sdf-text.md.
MSDF text msdf_atlas.hpp Multi-channel SDF atlas scaffold with median(r,g,b) sampler plumbing; current generator emits equal RGB placeholder distances until msdfgen is wired.
PSDF text psdf_atlas.hpp Pseudo-SDF variant with vector-fallback helper for extreme zoom.
SDF effects sdf_effects.hpp Host-side design-token presets for outline / shadow / glow / bevel; visible rendering waits for the SkSL-backed SDF text draw path.
SDF atlas cache sdf_atlas_cache.hpp Frame-based LRU glyph sharing across fill_text_sdf call-sites with dirty-rect upload hints.
Path → SDF path_to_sdf.hpp Runtime EDT of a binary mask to produce an SDF for procedural shapes.

view

Full widget toolkit with CSS-inspired layout and JS scripting.

Link: pulp::view · Include prefix: <pulp/view/...>

pulp::view is the full compatibility target and links both native widgets and the JS runtime bridge. Baked/native UI code that constructs View trees directly and does not evaluate JS can link pulp::view-core; code that uses ScriptEngine, WidgetBridge, scripted UIs, or runtime import should link pulp::view-script or the full pulp::view target.

Creating a UI

#include <pulp/view/widgets.hpp>

auto root = std::make_unique<Panel>();
root->set_background_token("bg.surface");

auto knob = std::make_unique<Knob>();
knob->set_label("Gain");
knob->set_value(0.5f);
knob->on_change = [&](float v) { store.set_value(gain_id, v); };
root->add_child(std::move(knob));

auto meter = std::make_unique<Meter>();
meter->set_orientation(Meter::Orientation::vertical);
root->add_child(std::move(meter));

Available widgets (30+)

Controls

Widget Description
Checkbox Boolean on/off control rendered as a checkmark box
ComboBox Drop-down menu for selecting one option from a list
Fader Vertical or horizontal slider for continuous parameter control
Knob Rotary control for parameters like gain, frequency, resonance
TextButton Clickable button with a text label — supports toggle mode
TextEditor Single or multi-line text input with native keyboard movement, selection, copy/paste, undo, IME, and grapheme-safe UTF-8 editing
Toggle Two-state switch control for enabling/disabling features
TextEditor Behavior

TextEditor is the SDK-level text-entry control used by native views, imported HTML <input>, and imported <textarea> controls. It implements platform-style caret movement and selection by default: character, word, line, document, page, and Shift-selection variants; word/line delete shortcuts; double-click word selection with word-granular drag extension; triple-click line selection in multi-line mode; standard Cut/Copy/Paste/Select All context menus; and mouse/trackpad scrolling for multi-line fields.

Text positions are stored as UTF-8 byte offsets for host/IME compatibility, but editing commands snap those offsets to grapheme-cluster boundaries. This keeps emoji, combining marks, flags, and ZWJ sequences from being split by arrow keys, Backspace/Delete, hit testing, or selection expansion.

Applications can tune text-field policy without forking key handling: read_only allows focus, navigation, selection, and copy while blocking mutation; View::set_enabled(false) disables interaction entirely; tab_behavior chooses focus traversal, literal tab insertion, commit callback, or consume/ignore behavior; multi_line_return_behavior chooses Return/Shift-Return behavior; max_length counts grapheme clusters; paste_sanitizer handles paste-only cleanup; input_filter sanitizes typed and pasted insertion text; and validator accepts or rejects a whole-buffer candidate before an edit lands. line_ending_policy normalizes, strips, or preserves inserted line endings where the control shape allows it. clipboard_policy can disable clipboard traffic entirely or explicitly allow password contents to leave the field. Password fields mask display text and disable selected-text export, copy, and cut by default unless allow_password_clipboard or ClipboardPolicy::allow_password_contents is enabled.

Programmatic set_text() is a host/state-sync operation, so it clears the editor undo stack instead of recording a user-edit undo entry. Use set_caret_pos(), set_selection(), selection_anchor(), selection_active(), and selection_range() when a host, importer, IME, or test needs explicit caret/selection control; all public offsets are clamped to grapheme boundaries.

Containers

Widget Description
ConcertinaPanel Accordion-style stacked panels — expand one section, collapse others
Panel Basic container with optional background, border, and layout
ScrollView Scrollable viewport for content larger than the visible area
SplitView Resizable split between two child views with a draggable divider
TabPanel Tabbed container — switch between child views via tab bar
Toolbar Horizontal or vertical bar of buttons, toggles, separators, and custom views

Data display

Widget Description
Breadcrumb Navigation trail showing the current location in a hierarchy
FileBrowser File system browser with navigation, filtering, and selection
FileTree Hierarchical file/folder tree with expand/collapse
Label Static text display with font, color, and alignment options
ListBox Scrollable list of selectable items with virtual rendering for large datasets
PropertyList Two-column key/value editor for inspector-style property panels
TableListBox Sortable, scrollable table with column headers and row selection
TreeView Hierarchical data display with expand/collapse and lazy loading

Audio visualization

Widget Description
CorrelationMeter Displays stereo phase correlation from -1 (out of phase) to +1 (mono)
EqCurveView Interactive frequency response curve for parametric EQ — drag handles to edit bands
Meter Peak/RMS level meter with configurable ballistics and clip indicators
MultiMeter Multiple level meters side-by-side for multi-channel monitoring
SpectrogramView Rolling time-frequency heatmap showing spectral content over time
SpectrumView Real-time frequency spectrum analyzer with configurable FFT size
WaveformView Audio waveform display with zoom, selection, and playhead
XYPad Two-dimensional control surface for parameters like pan/width or filter freq/res

Specialized

Widget Description
CanvasWidget Custom-drawn view — override paint() to draw anything with the Canvas API
CodeEditor Syntax-highlighted text editor with line numbers, designed for script editing
ColorPicker HSV color selector with hue ring, saturation/value square, and hex input
FileDropZone Drop target that accepts files dragged from the OS file manager
ImageView Display raster images (PNG, JPEG) with optional scaling and aspect ratio
LassoComponent Rubber-band marquee selection tool for selecting multiple items by dragging
LiveConstantEditor In-app slider overlay for tweaking magic numbers during development
MidiKeyboard Interactive piano keyboard — click to play notes, highlight active voices
PresetBrowser Factory/user preset list with next/prev navigation and search
SplashScreen Timed overlay window for branding or loading screens on app startup
SpriteStrip Filmstrip-based control rendered from a sprite sheet image
WaveformEditor Editable waveform display for drawing custom oscillator shapes

Layout — Yoga flexbox + CSS Grid

// Flexbox
root->style().set_flex_direction(FlexDirection::Row);
root->style().set_gap(8);

knob->style().set_flex_grow(1);
meter->style().set_width(40);
meter->style().set_height_percent(100);

Theming — design tokens

Theme theme;
theme.colors["bg.surface"] = Color::rgba8(25, 25, 35);
theme.colors["control.accent"] = Color::rgba8(80, 130, 255);
theme.colors["text.primary"] = Color::rgba8(220, 220, 230);

// Widgets resolve tokens automatically:
canvas.set_fill_color(resolve_color("bg.surface"));

Design import

Per-source parsers under core/view/src/design_import_*.cpp translate external designs into Pulp's import IR for pulp import-design. design_import_designmd.cpp parses Google's DESIGN.md format (YAML-frontmatter + Markdown body) into a DTCG tokens.json; yaml-cpp (MIT, vendored via CMake FetchContent) provides the frontmatter parse. See reference/imports/designmd.md.

JS-scripted UI

Write your plugin UI in JavaScript. Live hot reload is validated in the macOS standalone development host; plugin targets can load the same UI_SCRIPT, but live reload is not yet guaranteed across hosts:

// plugin-ui.js
const knob = new Knob({ label: "Gain", param: "gain" });
const meter = new Meter({ orientation: "vertical" });
document.body.append(knob, meter);

Engines: QuickJS (default, lightweight), JavaScriptCore (Apple, fast JIT), V8 (full ES2024)

Accessibility

VoiceOver (macOS + iOS), UIA (Windows), AT-SPI (Linux). Widgets declare their role and Pulp maps to platform accessibility APIs.

knob->set_access_role(AccessRole::slider);
knob->set_access_label("Gain");
knob->set_access_value("-6 dB");

osc

Open Sound Control messaging for networked audio control.

Link: pulp::osc · Include prefix: <pulp/osc/...>

#include <pulp/osc/osc.hpp>
#include <pulp/osc/bundle.hpp>

// Send
pulp::osc::Sender sender;
sender.connect("192.168.1.100", 9000);
sender.send(pulp::osc::Message("/synth/freq").add(440.0f));
sender.send(pulp::osc::Message("/synth/gate").add(1));

// Receive
pulp::osc::Receiver receiver;
receiver.listen(9000, [](const pulp::osc::Message& msg) {
    if (msg.address == "/synth/freq")
        set_frequency(msg.get_float(0, 440.0f));
});

// Bundle
pulp::osc::Bundle bundle;
bundle.add(pulp::osc::Message("/synth/freq").add(880.0f));
sender.send(bundle);

Supports bundles with timetags, the optional OSC RGBA colour tag (r), raw datagram sends, and address pattern matching (*, ?, [...], {a,b}) through receiver routes.

Binding OSC addresses to parameters

OscParameterMap binds incoming OSC addresses to plugin parameters, with a learn mode — the OSC counterpart of pulp::state::MidiParameterMap. A mapping targets a pulp::state::ParamID, the same id a timeline DeviceParameterTarget carries, so OSC, MIDI, and authored automation address one parameter space.

#include <pulp/osc/osc_parameter_map.hpp>

pulp::osc::OscParameterMap map;
map.set_mapping("/track/1/fader", kGain);                       // literal address
map.set_mapping("/track/*/mix", kMix, {0.0f, 1.0f, 0.25f, 0.75f});  // pattern + window
map.arm_learn(kCutoff);   // the next incoming address binds to kCutoff

// On the thread dispatching OSC messages:
map.pump();
map.handle_message(store, msg);

An OscMapScale declares both the range the surface sends (in_min..in_max) and the normalized [0, 1] window it drives (out_min..out_max); input is clamped to the incoming range and out_min > out_max inverts. Mappings live in fixed-capacity storage, and a literal address dispatches by byte compare without touching the heap — wildcard patterns route through address_matches, which carries no such guarantee and must not be driven from the audio thread.

Because the binding lives in the interface of pulp::osc, the module links pulp::state publicly. The dependency runs protocol → parameter model, keeping pulp::state free of socket code.


native-components

The language-neutral C ABI for opt-in native-language audio components (Rust first, also C / Zig / generated DSP). A C++ Processor adapter owns a source-built native DSP core through this private, C-shaped FFI.

Link: pulp::native-components · Include prefix: <pulp/native_components/...>

#include <pulp/native_components/native_core.h>   // the canonical C contract
#include <pulp/native_components/native_core.hpp>  // optional C++ sugar (hash, asserts)

The header carries POD structs with leading size/abi_version, opaque instance handles, status codes, host-owned borrowed buffers, a sorted parameter-event view, an opaque versioned state span, and explicit suspend/resume/reset lifecycle — embodying the twelve forward-compatibility decisions in native-components reference. It has no Rust dependency: the module builds on every platform, and the opt-in Rust staticlib lane lives behind the PULP_BUILD_NATIVE_COMPONENT_RUST_TESTS CMake option (OFF by default).

This is the Processor-level FFI, deliberately independent of SignalGraph. The module also ships the public pulp_node_v1 node ABI (pulp_node_v1.h) for custom graph nodes; its NativeCoreProcessor adapter lives in pulp::format, and signed dynamic node-pack loading lives in pulp::host (node_pack.hpp). See node-abi for the pulp_node_v1 contract and node packs.


render

GPU surface management — you rarely use this directly. Canvas and View handle it.

Feature What It Does
Dawn/WebGPU Cross-platform GPU abstraction (Metal, Vulkan, D3D12, OpenGL)
GPU Compute Experimental batch processing for >64K element workloads
Skia Graphite 2D rendering on top of Dawn — what Canvas uses internally

gpu_audio

Experimental. A fixed-latency bridge that lets a real-time process() block offload heavy DSP to the GPU without blocking the audio thread. GpuAudioTransport owns lock-free input/output rings and a polling worker; a GpuAudioNode submits whole blocks to render::GpuCompute and reads results back one block later, so the audio thread never waits on the device. When the GPU can't keep up (an offline bounce running faster than real time, or a cold pipeline), a configurable miss policy falls back to CPU or silence rather than glitching.

Link: pulp::gpu-audio · Include prefix: <pulp/gpu_audio/...> · Depends on: audio, runtime, signal (always) · render (GPU builds only)

Node / primitive What It Does
GpuAudioTransport Fixed-latency RT↔non-RT bridge: lock-free rings + polling worker over GpuCompute
GpuConvolver GPU-resident fused / batched partitioned convolution (long IRs, many instances)
GpuMultiConvolver Batched multi-IR / multi-room convolution — one GPU submit per block across N IRs
GpuStft GPU STFT / ISTFT primitive — the spectral toolkit's analysis/synthesis stage
GpuSpectralFreeze / GpuSpectralMorph / GpuSpectralStack Capture-and-render spectral engines (single freeze, A/B morph, N-layer stack/cloud)

The node boundary is not real-time-safe at the device level by design — the GPU round-trip is amortized across a block of fixed latency, not paid per sample. Only the GPU node implementations are gated on pulp::render; the GpuAudioTransport bridge and the public node classes still compile and link in a build without the GPU stack, report gpu_available() == false, and route the signal::* CPU fallback.

Example plugins built on it (in-tree, examples/):

Example Uses Docs
SuperConvolver GpuConvolver (single IR) / GpuMultiConvolver (many rooms) — convolution reverb with live IR swap; GPU carries very long IRs / many rooms super-convolver
Spectral Lab GPU spectral stack — N-layer freeze / morph "cloud" spectral-lab
GPU NAM render::GpuCompute::wavenet_forward neural-inference primitive (own repo) gpu-nam

SDK guide: GPU Audio SDK — building nodes, the transport contract, and the validation checklist.


ship

Packaging and distribution — from code signing to installer to update feed.

Link: pulp::ship · Include prefix: <pulp/ship/...>

# Sign all plugin bundles
pulp ship sign --identity "Developer ID Application: My Company"

# Package — creates .pkg/.dmg (macOS), NSIS (Windows), or .deb/.tar.gz (Linux)
pulp ship package --version 1.2.0

# Check signing status
pulp ship check
Feature What It Does
Code Signing macOS codesign + Windows signtool
DMG / PKG macOS installer creation
Linux Packaging .deb and .tar.gz
Notarization macOS notarization with notarytool
Signing Check Verify signing status of all built plugin bundles
Windows Installer NSIS-based with optional Authenticode