Skip to content
🚧 Under active construction — content being developed and verified.

Plugin Architecture

Poly is split into two libraries with a strict isolation boundary: a pure C++ engine (poly_engine) that generates rhythmic patterns, and a VST3 plugin layer (poly_plugin) that bridges the engine to the DAW. This separation is the foundation of the entire architecture — the engine has zero VST3, audio-thread, or platform dependencies, and must compile and pass its full test suite without a DAW.

┌──────────────────────────────────────────────┐
Host (Cubase/VST3) │ poly_engine │
┌───────────────┐ │ (pure C++, no VST3, no audio-thread alloc) │
│ ProcessContext│────►│ Transport/Time → bar/beat/sub/lane-phase │
│ Tempo / PPQ │ │ Lane Generator → hit candidates per lane │
│ Loop / jumps │ │ Dynamic Shaping → velocity/emphasis/env │
└───────────────┘ │ Constraint Layer → anchors / density caps │
┌───────────────┐ │ Output Scheduler → ordered NoteEvent list │
│ Event MIDI out│◄────│ │
└───────────────┘ └──────────────────────────────────────────────┘
▲ ▲
│ │
┌────────┴────────────┐ ┌────────┴────────┐
│ poly_plugin │ │ poly_tests │
│ AudioEffect/Processor│ │ (off-host unit │
│ + EditController │ │ + golden tests) │
│ + Web UI editor │ └─────────────────┘
└──────────────────────┘

The plugin layer feeds the engine two things: the current transport state (TransportContext) and the resolved patch settings (GrooveState). The engine returns a buffer of NoteEvent objects. The plugin writes those events to the VST3 output IEventList. That is the entire interface.

The engine exposes a single entry point — a stateless function from transport plus groove state to a bounded buffer of notes.

class Engine {
public:
    // emissions (optional): if non-null, receives one EmissionEvent per
    // step considered by the render, classifying each as Base/Ghost/Add/Drop.
    // Off-pattern steps that produce no hit are omitted (Silent). Default
    // nullptr preserves the prior contract for callers that don't need
    // classification — audio thread, plugin process(), tests, etc.
    void renderRange(const TransportContext& tc, const GrooveState& state, NoteEventBuffer& out,
                     EmissionEventBuffer* emissions = nullptr);
};
Engine's public surface — one method, three arguments, no shared state
poly/
├── engine/ # poly_engine — pure C++ static library
│ ├── include/ # public headers (types, engine, envelope, scene, etc.)
│ └── src/ # implementation
├── plugin/ # poly_plugin — VST3 instrument
│ ├── source/ # processor, controller, factory, web UI bridge
│ └── resource/ # AU packaging metadata (au-info.plist)
├── webui/ # web UI assets served into the plugin webview
├── tests/ # Google Test suite (runs without VST3 SDK)
│ ├── golden/ # deterministic reference output files
│ └── host/ # off-host plugin + bridge integration tests
└── tools/harness/ # headless CLI engine runner

Poly operates on two threads with no shared mutable state between them.

The DAW calls PolyProcessor::process() on the audio thread at each buffer cycle. This function:

  1. Reads the ProcessContext (PPQ position, tempo, sample rate, loop state)
  2. Reads parameter changes from the input IParameterChanges
  3. Calls Engine::renderRange() to generate note events for the current buffer
  4. Writes NoteEvent objects to the VST3 output IEventList
  5. Updates the MIDI capture buffer

Real-time safety rule: No heap allocation, locks, exceptions, or I/O may occur in process() or renderRange(). All buffers are pre-allocated in initialize(). The engine is a pure function — it reads state and produces events, nothing more.

The plugin editor is a web UI hosted in a webview on the UI thread. It reads parameter values for display (knob positions, lane states, visualization data) and writes parameter changes when the user interacts with controls. Communication between threads uses the VST3 parameter system — no custom locks or message queues.

For visualization (playhead, lane phases) and state read-back, the processor writes into a per-instance UISnapshot. Transport fields are individually atomic; the full SceneState uses a flag-guarded single-copy exchange so the audio thread never blocks and the UI thread reads a consistent snapshot.

// Per-instance UI snapshot shared between the processor (audio thread writer)
// and the controller/web view (UI thread reader). Each plugin instance gets
// its own UISnapshot — no globals, no multi-instance crosstalk.
//
// Transport fields are individually atomic (relaxed reads are fine at 30fps).
// The full SceneState uses a flag-guarded single-copy exchange: the processor
// writes only when the reader has consumed the previous snapshot.
struct UISnapshot {
    // Transport — written by processor every process() call
    std::atomic<double> ppqNorm{0.0};
    std::atomic<bool> playing{false};
    std::atomic<double> lanePhases[kMaxLanes]{};
    // M051 S02: host time signature, populated from ProcessContext every block.
    // Defaults to 4/4 when the host doesn't publish kTimeSigValid.
    std::atomic<int16_t> timeSigNumerator{4};
    std::atomic<int16_t> timeSigDenominator{4};
    // M032 S03: host tempo (BPM), populated from ProcessContext.tempo every
    // block. The offline WebUI export path (renderCurrentPatternSmf) reads this
    // so the exported SMF tempo meta reflects the real host tempo instead of a
    // hardcoded 120.0. Defaults to 120.0 before the first process() call.
    std::atomic<double> tempoBpm{120.0};

    // M051 S08: capture state machine surfaced to the WebUI Cloth timeline so
    // the arm->capture->complete progression is directly observable (the visual
    // is the receipt). All relaxed — single writer (audio thread), 30fps reader.
    //   captureState: 0=idle, 1=armed, 2=capturing, 3=complete.
    std::atomic<int> captureState{0};
    //   captureBars: target window length in bars (mirrors captureLengthBars_ /
    //   kCaptureLength). Default 8 = MidiCaptureBuffer::kDefaultCaptureBars.
    std::atomic<int> captureBars{8};
    //   captureProgressBars: bars elapsed within the window, 0..captureBars.
    //   Drives the Cloth playhead (playhead = captureProgressBars / captureBars).
    std::atomic<double> captureProgressBars{0.0};

    // M073: per-lane emission ring surfaced to the WebUI desk overlay + played
    // timeline. The engine emits a per-block EmissionEventBuffer classifying
    // every step Base/Ghost/Add/Drop with its grid ppq and post-timing-shift
    // onset (shiftedPpqPosition). The audio thread appends each block's
    // emissions into these fixed-cap per-lane rings; the UI thread (30fps)
    // drains them in publish order for getLaneEmissions(li).
    //
    // RT-safety: the writer (audio thread) only stores POD fields into a
    // pre-allocated array and bumps a relaxed atomic head counter — no alloc,
    // lock, throw, or IO. The reader tolerates a slightly torn view (a slot
    // being overwritten as it reads); at 30fps a one-frame-stale emission is
    // imperceptible and the ring self-heals on the next drain. This mirrors the
    // WASM host's per-lane emission ring (webui/wasm-host.js) so both surfaces
    // present the same shape.
    static constexpr int kEmissionRingCap = 64;
    struct EmissionSlot {
        std::atomic<double> ppq{0.0};
        std::atomic<double> shiftedPpq{0.0};
        std::atomic<int> step{0};
        std::atomic<int> kind{0}; // EmissionKind
    };
    // head[lane] is the monotonically-increasing total emission count for the
    // lane; (head-1) mod cap is the newest slot. The reader reads head, then
    // walks back min(head, cap) slots oldest→newest to reconstruct the ring.
    std::atomic<uint64_t> emissionHead[kMaxLanes]{};
    EmissionSlot emissionRing[kMaxLanes][kEmissionRingCap]{};

    // Full state — flag-guarded exchange
    SceneState state{};
    std::atomic<bool> stateReady{false};
};
Per-instance atomic snapshot — no globals, no locks, no multi-instance crosstalk

For the web UI, which reads visualization state 30 times per second without touching the VST3 parameter relay, a smaller lock-free TransportFrameExchange carries just the fields the visualizer needs.

// Lock-free transport frame shared between the processor (audio thread writer)
// and the web UI view (UI thread reader). Bypasses the host's output parameter
// relay which some hosts only activate when the editor has VSTGUI bindings.
//
// Each field is independently atomic — the reader may see a slightly stale mix
// across fields (e.g., ppqNorm from one process call, a lane phase from the
// previous), but at 30fps visualization this is imperceptible.
struct TransportFrameExchange {
    std::atomic<double> ppqNorm{0.0};
    std::atomic<bool> playing{false};
    std::atomic<double> lanePhases[kMaxLanes]{};
};
Lock-free transport frame — atomic per field, tolerant of cross-field staleness at 30fps

Unlike some plugin architectures that use ring buffers or lock-free queues between threads, Poly’s engine is stateless — it derives everything from the current transport position and patch state. There is no accumulated state to synchronize. The SceneState is read atomically by the audio thread; parameter changes from the UI thread flow through the VST3 parameter system, which handles thread safety.

Engine::renderRange() is the single entry point for all note generation. For each active lane, it runs a multi-stage pipeline:

Each lane’s rhythm is generated using the Björklund/Bresenham algorithm, which distributes k pulses evenly across n steps. The result is a boolean pattern array where true positions are hit candidates.

Three pattern modes are supported:

  • Euclidean (default) — algorithmically distributed pulses with optional rotation
  • Timeline — fixed patterns immune to macro modulation, used for culturally specific rhythms
  • Kotekan — Balinese interlocking, where the lane generates the complement of a sibling lane’s pattern

The six macro knobs (Complexity, Density, Syncopation, Swing, Tension, Humanize) are resolved into concrete per-lane parameter modifications. Each macro influences multiple parameters coherently — for example, Density scales hit count, probability, and activation envelopes together, not as independent multiplies.

struct MacroValues {
    float complexity = 0.5f;
    float density = 0.5f;
    float syncopation = 0.0f;
    float swing = 0.0f;
    float tension = 0.0f;
    float humanize = 0.0f;
};
Six macro knobs — the human-facing knobs before resolution into per-lane parameters

Macro transitions are smoothed over ~400ms using an exponential moving average to prevent audible discontinuities when knobs are turned.

Per-lane and global envelopes modulate eight targets: Velocity, Density, Probability, AccentBias, NoteLength, TimingLooseness, ActivationWeight, and FillLikelihood. Envelope phase is derived from the absolute PPQ position — never accumulated across blocks — so loop restarts and position jumps reproduce identically.

Envelopes use five shape types (Ramp, Sine, Triangle, Curve, StepList) with configurable period, depth, and phase offset. Multiple envelopes can stack on the same target, and each envelope can have a different period, creating multi-timescale modulation.

Three layers of humanization add natural feel:

  • Swing — delays alternate steps by a configurable amount
  • Micro-timing — per-step timing offsets in milliseconds for style-specific feel (e.g., laid-back snare)
  • Humanize — random timing and velocity variation within configurable bounds

These layers stack additively: the final timing offset is lane offset + micro-timing + humanize jitter.

Post-processing rules enforce musical constraints:

  • Anchor step masking — ensures certain beats always trigger (e.g., kick on beat 1)
  • Backbeat protection — preserves snare placement
  • Density min/max — caps the number of active hits per lane

Hit candidates that survive all pipeline stages become NoteEvent objects with precise PPQ position, pitch, velocity, channel, and duration. Events are written to the NoteEventBuffer (capacity 256 per block) in time-sorted order.

Defines a single rhythmic lane — the fundamental unit of pattern generation. Every knob on a lane strip maps to one field here.

struct LaneConfig {
    int id = 0;
    Role role = Role::Custom;
    int16_t midiNote = 36;
    // M003 S01 (GP07). noteSequenceLength == 0 leaves the lane single-pitched
    // on midiNote above, byte-identically to pre-M003.
    std::array<NoteSequenceEntry, kMaxNoteSequence> noteSequence{};
    int noteSequenceLength = 0;
    int16_t midiChannel = -1; // -1 = auto (lane index); 0-15 = explicit MIDI channel
    Cycle cycle{};
    int hitCount = 4;
    int rotation = 0;
    float probability = 1.0f;
    uint8_t baseVelocity = 100;
    AccentMask accents{};
    float emphasisProb = 0.5f;
    uint8_t ghostFloor = 30;
    float velocitySpread = 0.05f;
    float humanizeMs = 0.0f;
    // M001 S02 (GP02). State-only, and carried in the same kStateVersion bump
    // as swingMode -- one version for the milestone, not one per slice.
    HumanizeMode humanizeMode = HumanizeMode::WhiteNoise;
    float swingAmount = 0.0f;
    // M001 S01 (GP01). State-only, like kotekanMode: the per-lane expression
    // parameter family is full at kParamsPerLane == 16 and the core family is
    // at 14, and a feel-mode is a style choice rather than automation material.
    // Fixed is the default, so every pre-M001 patch is byte-identical.
    SwingMode swingMode = SwingMode::Fixed;
    float noteDuration = 0.0f;
    float phraseLength = 0.0f;      // beats; 0 = continuous (no phrase gating)
    float phraseGap = 0.0f;         // beats; silence between phrases
    float phraseOffset = 0.0f;      // beats; phase offset for this lane's phrase cycle
    float mutationRate = 0.0f;      // 0.0-1.0; per-step mutation probability each cycle
    float driftRate = 0.0f;         // steps per bar; pattern rotation rate from absolute PPQ
    float timingOffsetMs = 0.0f;    // ms; positive = late, negative = early; range [-20, +20]
    float syncopationOffset = 0.0f; // 0.0-1.0; pushes even (strong-beat) steps late
    float tempoMultiplier = 1.0f;   // 0.25-4.0; per-lane tempo scaling (Nancarrow-style)
    int kotekanSourceLane = -1;     // -1=independent, 0-7=complement of source lane's pattern
    // M002 S01 (GP03). The timeline this lane weights its stochastic decisions
    // against -- typically the clave or bell lane. -1 = none, which leaves every
    // weight at exactly 1.0 and the arithmetic byte-identical to pre-M002.
    // Strength is signed: positive attracts adds toward the timeline's onsets
    // and protects them from drops, negative does the reverse.
    int timelineSourceLane = -1;
    float timelineStrength = 0.0f;
    // M002 S02 (GP04). How strongly ghosts favour the approach to an accent.
    // 0 = the flat per-step roll this repo shipped before M002. Scaled by the
    // Complexity macro in macro.cpp, so low Complexity keeps grooves clean.
    float ghostGrammar = 0.0f;
    // M002 S03 (GP05). How sharply fills concentrate toward the end of the
    // phrase cycle. 0 = the position-blind roll this repo shipped before M002.
    float fillPhraseShape = 0.0f;
    // M002 S04 (GP06). The lane this one answers. -1 = independent, which
    // leaves the gate exactly as it was before M002. Lead-in is in beats:
    // positive opens the response early (anticipating the call's end),
    // negative opens it late (dovetailing past it).
    int responseSourceLane = -1;
    float responseLeadIn = 0.0f;
    // M002 S01 (EC06). The interlock style, and how many structural points the
    // pair strikes together. Both are state-only: the per-lane VST3 parameter
    // family is full (kParamsPerLane == 16, kKotekanSource occupies slot 15),
    // and a style choice does not want an automation lane. Defaults reproduce
    // the pre-M002 strict complement exactly.
    KotekanMode kotekanMode = KotekanMode::NyogCag;
    int kotekanOverlap = 0;                 // structural steps struck by both parts; 0 = strict
    int fillEveryNBars = 0;                 // 0 = no bar-gated fill; N>0 = play off-pattern fill on bars whose
                                            // absolute bar index is a multiple of N (deterministic, PPQ-derived)
    int cellCount = 0;                      // 0 = equal cells (standard Euclidean); >0 = additive/aksak
    std::array<int, kMaxSteps> cellSizes{}; // subdivision units per cell; sum = total cycle length
    // M003 S01 (EC08). Non-isochronous subdivision: each entry is a step's
    // duration as a multiple of the base step. profileCount == 0 takes the
    // existing branch untouched, so every pre-M003 patch is byte-identical.
    // The profile is normalised so the cycle keeps the length it would have had
    // evenly -- it states distribution, never length -- and takes precedence
    // over cellSizes, which are structure rather than feel.
    std::array<float, kMaxSteps> subdivisionProfile{};
    int profileCount = 0;
    // M003 S02 (EC09). A grouping over the lane's existing steps, for feel.
    // Distinct from cellCount/cellSizes, which replace the steps with one per
    // cell: a lane with swingCellSizes {2,2,3} still has seven steps, and swing
    // displaces within each cell rather than across the bar. 0 = no grouping,
    // which leaves every swung lane keying off (cycleStep % 2) as before.
    int swingCellCount = 0;
    std::array<int, kMaxSteps> swingCellSizes{};
    bool timeline = false;                      // timeline mode: use fixedPattern, immune to macros
    std::array<bool, kMaxSteps> fixedPattern{}; // per-step on/off for timeline mode
    // timeline mode pattern length: 0 = use cycle.steps; >0 = explicit length that governs both editable slot count
    // AND playback cycle wrap (see prepareLaneContext in engine.cpp; enforced M049 S02 / E2 fix).
    int fixedPatternLength = 0;
    std::array<float, kMaxSteps> microTimingMs{}; // per-step timing offset in ms; range [-20, +20]
    // M034 S03: per-lane seed lock. laneSeed is the preserved RNG seed a locked
    // lane derives from; seedLocked pins it so a global reroll (GrooveState::seed
    // change) leaves this lane's output byte-identical while other lanes re-roll.
    // The WebUI captures the current global seed into laneSeed when the user locks
    // the lane. Defaults (seedLocked=false, laneSeed=0) make laneEffectiveSeed
    // return the global seed unchanged, so pre-change output is byte-identical.
    uint64_t laneSeed = 0;
    bool seedLocked = false;
    bool active = true;
    std::array<EnvelopeAssign, kMaxEnvelopesPerLane> envelopes{};
    int envelopeCount = 0;
    ConstraintConfig constraints{};
};
LaneConfig — the full per-lane parameter set, including Euclidean geometry, humanization, additive cells, timeline mode, envelopes, and constraints

Carries the DAW’s current transport state into the engine. The engine derives every phase-related quantity from ppqStart/ppqEnd — nothing is accumulated between calls.

struct TransportContext {
    double ppqStart = 0.0;
    double ppqEnd = 0.0;
    double tempo = 120.0;
    double sampleRate = 44100.0;
    int32_t blockSize = 512;
    bool playing = false;
    bool looping = false;
    bool jumped = false;
    // M046 S06 P9: true when this block is the first block after a natural loop
    // wrap (playhead snapped from ~loopEndPpq back to ~loopStartPpq while
    // looping). Callers that clear per-jump state (capture buffer, scene chain,
    // macro smoother) should gate on !wrappedLoop to preserve continuity across
    // repeats. Pending note-offs still flush unconditionally to avoid stuck notes.
    bool wrappedLoop = false;
    double loopStartPpq = 0.0;
    double loopEndPpq = 0.0;
    // M051 S02 E6: host time signature drives bar-unit math where "bar" is a
    // user-facing count — scene chain "advance every N bars," envelope
    // `periodBars`, MIDI capture "last N bars." Lane cycles remain
    // meter-independent: a steps=7/subdivision=8 lane always cycles every 7/8,
    // regardless of host meter (subdivision is notes-per-bar, and 4.0 PPQ stays
    // the reference bar for step math). Plugin populates from
    // Vst::ProcessContext::timeSigNumerator/Denominator under kTimeSigValid;
    // defaults to 4/4 for engine tests and hosts that don't publish it.
    int16_t timeSigNumerator = 4;
    int16_t timeSigDenominator = 4;

    // PPQ per bar = numerator * (4 / denominator). Poly's PPQ unit is one
    // quarter note, so a 7/8 bar is 7 * (4/8) = 3.5 PPQ.
    double ppqPerBar() const {
        return static_cast<double>(timeSigNumerator) * (4.0 / static_cast<double>(timeSigDenominator));
    }
};
TransportContext — what the DAW tells the engine on every process() call

A single MIDI note event produced by the engine. Positions are absolute PPQ; the plugin layer converts to VST3 sample offsets at emission time.

struct NoteEvent {
    double ppqPosition = 0.0;
    int16_t pitch = 0;
    float velocity = 0.0f;
    double duration = 0.0;
    int16_t channel = 0;
    int16_t laneIndex = 0;
};
NoteEvent — engine output, PPQ-timed and DAW-agnostic

The complete serializable patch — everything the engine needs to produce output. Two of these live inside a SceneState (Scene A and Scene B), interpolated by interpolateGrooveState() when the plugin is in Morph mode.

struct GrooveState {
    std::array<LaneConfig, kMaxLanes> lanes{};
    int activeLaneCount = 4;
    std::array<Envelope, kMaxGlobalEnvelopes> globalEnvelopes{};
    int globalEnvelopeCount = 0;
    MacroValues macros{};
    uint64_t seed = 0;
    int globalDensityCeiling = 0;
    // Transient momentary control (NOT serialized): when true, the current
    // render pass forces every bar to render as a fill bar for lanes,
    // independent of each lane's fillEveryNBars. The plugin pulses this for a
    // single render from a manual-fill trigger, then clears it.
    bool fillManualTrigger = false;
};
GrooveState — 8 lanes, up to 8 global envelopes, macro values, a deterministic seed, and a density ceiling

Two complete GrooveState snapshots (Scene A and Scene B) with smooth interpolation via interpolateGrooveState(). The SceneChainState sequencer automates transitions between scenes at bar boundaries using three modes: OneShot, Loop, and PingPong.

A fixed-size circular buffer (2048 events) that continuously records engine output. Supports range extraction and last-N-bars queries for on-demand SMF export. The buffer captures silently during playback with zero allocation.

The engine is a pure function: renderRange(transport, state) → events. Given the same transport context and groove state (including seed), the output is identical every time. This is enforced by:

  • PPQ-absolute phase calculation — no accumulated state across blocks
  • Seeded RNG — all randomness (probability, humanize, velocity spread) derives from a deterministic seed
  • Golden tests — CI runs reference patches through the engine and compares output byte-for-byte against recorded baselines

This determinism means loop restarts, tempo changes, and position jumps all produce correct output without any state reset logic.

PolyProcessor (the VST3 AudioEffect subclass) is deliberately thin: it drains parameter changes, selects (or interpolates) a scene, smooths macros, resolves constraints, and calls the engine. The audio-thread inner loop is only a dozen lines.

GrooveState base;
if (sceneState_.select == SceneSelect::Morph) {
    base = interpolateGrooveState(sceneState_.sceneA, sceneState_.sceneB, sceneState_.morphAmount);
} else if (sceneState_.select == SceneSelect::B) {
    base = sceneState_.sceneB;
} else {
    base = sceneState_.sceneA;
}

macroSmoother_.setTarget(base.macros);
// M046 S06 P9: keep the macro smoother continuous across a loop wrap.
// Snapping mid-groove would defeat the smoother; real scrubs still snap.
if (tc_.jumped && !tc_.wrappedLoop)
    macroSmoother_.snapToTarget();
macroSmoother_.advance(tc_.sampleRate, tc_.blockSize);
base.macros = macroSmoother_.current;

GrooveState resolved = resolveConstraints(base, resolveMacros(base));
// M073: pass the emission buffer so the engine classifies each step
// (Base/Ghost/Add/Drop) with grid + post-timing-shift onset; drained into
// the UISnapshot rings below for the WebUI desk overlay + played timeline.
// Byte-identical NoteEvent output — the emission stream is a display-only
// side channel (the null-buffer contract proves the notes are unchanged).
emissionBuffer_.clear();
// M034 S01: consume the manual-fill latch for exactly one render pass. The
// transient GrooveState field is never serialized; resolved is a fresh copy
// each block so it defaults false unless we pulse it here.
resolved.fillManualTrigger = fillManualTriggered_;
fillManualTriggered_ = false;
engine_.renderRange(tc_, resolved, noteBuffer_, &emissionBuffer_);
publishEmissions();
Audio-thread render pipeline — scene select → macro smoothing → resolve → engine

The engine’s output is a compact buffer of PPQ-timed NoteEvent structs. Converting each one to a VST3 event with the correct sample offset — and scheduling a matching note-off — is the second half of process().

for (size_t i = 0; i < noteBuffer_.count; ++i) {
    const auto& note = noteBuffer_.events[i];

    auto& ev = emitScratch_[count++];
    ev = {};
    ev.busIndex = 0;
    ev.sampleOffset = ppqToSampleOffset(note.ppqPosition, tc_.ppqStart, tc_.tempo, tc_.sampleRate, numSamples);
    ev.ppqPosition = note.ppqPosition;
    ev.type = Steinberg::Vst::Event::kNoteOnEvent;
    auto mappedPitch = sceneState_.noteMap.apply(note.pitch);
    ev.noteOn.channel = note.channel;
    ev.noteOn.pitch = mappedPitch;
    ev.noteOn.velocity = note.velocity;
    ev.noteOn.noteId = -1;

    const PendingNoteOff pendingOff = {
        .ppqOff = note.ppqPosition + note.duration,
        .pitch = mappedPitch,
        .channel = note.channel,
    };
    if (!pendingNoteOffs_.push(pendingOff)) {
        // Buffer is full: better a truncated note than a stuck one. Emit
        // an immediate best-effort note-off in this same block and bump
        // the drop counter so the caller (and tests) can observe the
        // pressure.
        noteOffDrops_.fetch_add(1, std::memory_order_relaxed);
        auto& off = emitScratch_[count++];
        off = {};
        off.busIndex = 0;
        off.sampleOffset = ev.sampleOffset;
        off.ppqPosition = pendingOff.ppqOff;
        off.type = Steinberg::Vst::Event::kNoteOffEvent;
        off.noteOff.channel = pendingOff.channel;
        off.noteOff.pitch = pendingOff.pitch;
        off.noteOff.velocity = 0.0f;
        off.noteOff.noteId = -1;
    }
}
VST3 event emission — PPQ → sample offset, note-on now, note-off queued for later

The processor contains no musical logic. It translates between VST3’s data structures and the engine’s domain types, nothing more. This is what makes the engine fully testable without a DAW.

Presets and DAW project state travel through getState() / setState(). The first int32 of every serialized blob is a version number; the reader branches on it so older presets keep loading as the format evolves.

static constexpr int32_t kCurrentStateVersion = 22;
// M068 S03: v16 switched the pattern generator from the retired Bresenham
// distribution (`(i*k) mod n < k`) to Bjorklund. Lanes saved before v16 carry a
// rotation authored against the old generator; readLaneConfig migrates each
// non-timeline lane by euclideanMigrationDelta so playback stays byte-identical.
static constexpr int32_t kBjorklundGeneratorStateVersion = 16;
// M034 S01: v17 appended LaneConfig.fillEveryNBars (bar-gated fill activation).
// writeLaneConfig emits it under `bodyVersion >= 17`; readLaneConfig reads it
// under `version >= 17`, so pre-v17 states load with fillEveryNBars defaulting
// to 0 (fill inert), preserving byte-identical playback.
static constexpr int32_t kFillEveryNBarsStateVersion = 17;
// M034 S03: v18 appended LaneConfig.laneSeed (uint64) + seedLocked (bool) for
// per-lane seed locks. writeLaneConfig emits them under `bodyVersion >= 18`;
// readLaneConfig reads them under `version >= 18`, so pre-v18 states load with
// laneSeed=0 / seedLocked=false — laneEffectiveSeed then returns the global seed
// and playback stays byte-identical to a pre-lock preset.
static constexpr int32_t kLaneSeedLockStateVersion = 18;

// M002 S01 (EC06): kotekanMode and kotekanOverlap. A pre-v19 state carries
// neither byte, and the struct defaults -- NyogCag with no overlap -- are
// exactly the strict complement it played before, so the migration is lossless
// by construction rather than by conversion.
static constexpr int32_t kKotekanModeStateVersion = 19;

// M003 S01 (EC08): subdivisionProfile and profileCount. A pre-v20 state carries
// neither, and profileCount == 0 is exactly the even grid it played before, so
// the migration is lossless by construction rather than by conversion. Only the
// first profileCount entries are written, preceded by the count, so a lane with
// no profile costs four bytes rather than 260.
static constexpr int32_t kSubdivisionProfileStateVersion = 20;

// M001 S01/S02 (GP01, GP02, guide-parity): swingMode and humanizeMode. One
// version for both, because they ship in the same milestone and no state a
// reader could hold carries one without the other. A pre-v21 state carries
// neither byte, and the defaults -- Fixed swing, white-noise humanize -- are
// exactly the behaviour it played, so the migration is lossless by construction
// rather than by conversion.
static constexpr int32_t kFeelModeStateVersion = 21;

// M003 S01 (GP07, guide-parity): noteSequence and noteSequenceLength. A pre-v22
// state carries neither, and length 0 is the single-pitch behaviour it played,
// so the migration is lossless by construction rather than by conversion. Only
// the first noteSequenceLength entries are written, preceded by the count, so a
// lane without a sequence costs four bytes rather than sixty-four.
static constexpr int32_t kNoteSequenceStateVersion = 22;
The version prefix that every serialized SceneState starts with
// Invariant: stateSnapshot_ is publishable after setActive(true) — see processor.cpp setActive; audit trail M046 S01
// T03
Steinberg::tresult PLUGIN_API PolyProcessor::getState(Steinberg::IBStream* state) {
    if (!state)
        return Steinberg::kInvalidArgument;

    auto write = [state](const void* data, size_t size) -> bool {
        Steinberg::int32 written;
        return state->write(const_cast<void*>(data), static_cast<Steinberg::int32>(size), &written) ==
               Steinberg::kResultOk;
    };

    if (!snapshotReady_.load(std::memory_order_acquire))
        return Steinberg::kResultFalse;
    auto result = writeSceneState(write, stateSnapshot_) ? Steinberg::kResultOk : Steinberg::kResultFalse;
    snapshotReady_.store(false, std::memory_order_release);
    return result;
}

Steinberg::tresult PLUGIN_API PolyProcessor::setState(Steinberg::IBStream* state) {
    if (!state)
        return Steinberg::kInvalidArgument;

    auto read = [state](void* data, size_t size) -> bool {
        Steinberg::int32 bytesRead;
        return state->read(data, static_cast<Steinberg::int32>(size), &bytesRead) == Steinberg::kResultOk;
    };

    // M046 S03 P4: read into the reserved (safe) slot and atomically publish.
    // Two back-to-back setState calls with no process() between now count the
    // displaced publish via handshakeDrops_.state instead of silently overwriting.
    const int32_t idx = stateSlot_.writeSlot();
    if (!readSceneState(read, stateSlot_.slots[idx]))
        return Steinberg::kResultFalse;
    if (stateSlot_.commit(idx))
        handshakeDrops_.state.fetch_add(1, std::memory_order_relaxed);
    return Steinberg::kResultOk;
}
getState/setState — writeSceneState prepends the version; readSceneState validates it before dispatching to a version-specific body reader

setState() never mutates sceneState_ directly — it writes into pendingState_ and flips an atomic flag. The audio thread picks up the new state at the top of the next process() call, so preset loads never race with rendering.

The web UI runs in a webview inside the plugin editor. State flows to the webview as JSON — the bridge serializes the current GrooveState + SceneState and posts it to the JS layer, which drives every knob, lane strip, and visualization.

using LaneNameFn = const std::string& (*)(int lane, void* ctx);

std::string grooveStateToJson(const GrooveState& gs, const SceneState& ss, LaneNameFn nameFunc, void* nameCtx,
                              const std::string& presetName);
Bridge entry point — full groove/scene snapshot to a single JSON string

Serialization runs on the UI thread against the flag-guarded UISnapshot, never against the live audio-thread state. Parameter edits flow the other way through the VST3 parameter system, which handles thread safety.

The test suite runs entirely off-host using Google Test:

  • Unit tests — individual components (Euclidean generator, envelope evaluation, macro resolution, constraints, scene interpolation)
  • Golden tests — full engine rendering compared against recorded reference output
  • Fuzz tests — randomized GrooveState inputs fed to renderRange() checking for crashes
  • Bridge contract tests — the web UI ↔ C++ JSON bridge serialization round-trips (tests/bridge_contract_tests.cpp), with the web UI’s own interaction and visual coverage in the Playwright suite (webui/tests/)

All engine and bridge tests are deterministic and platform-independent. The website and testing appendices go deeper into how each layer is wired up.

Preview audio uses CC0 and CC-BY drum samples. Every sample is credited on theCredits & Licenses page.