Shipping

The C header

What `--emit=export` hands you, and what it costs to link.

Anyone writing the C or C++ that drives a compiled patch.

--emit=export writes an object and a header. The header is everything needed to drive the object: its entry points, its state size, its parameters and its buffer layout.

A four-node effect compiles to something on this scale:

fx.o  1.5 KB, four node functions; undefined symbols: sinf, expf
fx.h   13 KB, everything needed to drive them

That is the whole dependency footprint. The undefined symbols are libm and nothing else — no runtime, no allocator, no compiler.

What the header gives you#

  • A render entry per node, and a fused one for a top-level graph.
  • The state size, so the host allocates the block; Pole never allocates.
  • The seed values, so a fresh instance starts where a fresh instance should.
  • An init entry when the patch has one, called before the first block.
  • The parameter table: name, slot, range, default.

Walk through a single-processor export#

Start with this small processor and save it as gain.pole in tools/pole/. The smoothing gives it a little persistent state, which makes the host's initialization step visible in the example.

Playground
processor Gain
{
    input stream float in;
    param float gain = 0.5f [0.0f, 2.0f] smooth 20.0f;
    output stream float out;

    void main()
    {
        loop { out <- in * gain; advance(); }
    }
}

From tools/pole, export it:

./build/polec gain.pole --emit=export --out=gain

The generated header declares this frame function. The name Gain comes from the processor, so another processor name produces another symbol.

void pole_node_Gain(const float* inputs, float* outputs,
                   double sampleRate, float* state,
                   const pole_buffer* externals);
ArgumentWhat the caller supplies
inputsOne frame of input slots, including parameters; allocate POLE_GAIN_INPUTS floats.
outputsWritable storage for one frame; allocate POLE_GAIN_OUTPUTS floats.
sampleRateThe actual processing rate in Hz, passed as a double by the C API.
statePersistent storage initialized with pole_Gain_init_state; allocate at least POLE_GAIN_STATE floats.
externalsThe buffer bindings declared by the patch. This example declares none, so an unused placeholder suffices.

Returns: nothing. The function writes the output slots and updates state for the next frame. It does not allocate storage. Keep separate state for each instance, and do not reset it on every sample.

A complete C caller#

Save this as host.c beside gain.h:

#include <stdio.h>
#include "gain.h"

int main(void)
{
    float state[POLE_GAIN_STATE ? POLE_GAIN_STATE : 1];
    float inputs[POLE_GAIN_INPUTS] = {0};
    float outputs[POLE_GAIN_OUTPUTS] = {0};
    pole_buffer externals[1] = {0};

    pole_Gain_init_state(state);
    for (int i = 0; i < POLE_GAIN_PARAM_COUNT; ++i)
        inputs[pole_Gain_params[i].slot] = pole_Gain_params[i].init;

    inputs[0] = 0.8f;
    pole_node_Gain(inputs, outputs, 48000.0, state, externals);
    printf("%0.3f\n", outputs[0]);
    return 0;
}

Build and run it on the machine used for the export:

cc host.c gain.o -lm -o gain-host
./gain-host

It prints 0.400: an input of 0.8 multiplied by the default gain of 0.5. The parameter table supplies the slot and initial value, so the caller does not need to guess where a control lives in the input array.

For a processor with an explicit init block, call its generated pole_init_Name(state, NULL, sampleRate, externals) once after state seeding and binding buffers, before rendering. init_state applies the initial values; the separate init entry runs the source's initialization code. This gain processor has no such block.

Graphs render a block#

A generated graph runner owns the per-node state and wiring. Its API has this shape for a graph named FxChain:

void pole_FxChain_render(pole_FxChain* graph, const float* inputs,
                        float* outputs, size_t frames, double sampleRate);

Initialize the graph with pole_FxChain_init(&graph) before the first render. The runner invokes any node init blocks before processing its first block. frames counts audio frames, not individual channel values. The buffers hold consecutive frames, each containing the graph's declared input or output slots. Include parameter values in the input layout; use the generated endpoint and parameter tables to find those slots.

The exact header generated for your patch is the authority for names, sizes and buffer layout. The key distinction is between a node call, which processes one frame, and a graph render, which processes a block.

It sounds the same, and that is checked#

The exported object is the same optimised module the JIT would have run, so an exported plugin is not a port of what you were hearing — it is the same code.

A bit-identity gate renders both and diffs the floats rather than trusting that. The browser runtime is held to the same standard: tools/pole/scripts/check_wasm.py renders every example twice, once through the JIT and once through the browser runtime under node, and 24 of 29 come out bit-identical. The five that do not are named rather than rounded away: poly_events needs note dispatch the gate does not drive yet; feedback_lfo has no fused entry, because a graph with feedback is rendered node by node and only the C header does that; and fft, spectral and wt_osc differ by one or two ulp through libm's transcendentals, macOS against musl.

That last group is the reason --approx-math exists: inline polynomials are the same arithmetic on every target, which libm never was.