Plugins in any language
A plugin is a core WebAssembly module. Rust gets the SDK; any other language that builds
wasm32 without WASI implements the ABI below. The contract lives in
crates/wire/src/plugin.rs; this page follows it.
Toolchains
| Language | Build | Notes |
|---|---|---|
| C, C++ | zig cc -target wasm32-freestanding -O2 -nostdlib -Wl,--no-entry -Wl,--export-dynamic -o p.wasm p.c | Or clang --target=wasm32 with the same flags and wasm-ld installed |
| Zig | zig build-exe p.zig -target wasm32-freestanding -fno-entry -rdynamic -O ReleaseSmall | export fn for each export |
| AssemblyScript | asc p.ts -o p.wasm -O --use abort= | Hand out buffers from heap.alloc, which the GC never moves or frees |
| Go | tinygo build -target=wasm-unknown -o p.wasm . | //export name on each function. Standard Go needs WASI or JS imports and is refused |
Module rules
- Export the linear memory as
memory. - Import nothing but the
sdrmmmodule below: no WASI, noenv. An unknown import fails the load. - Numbers in memory are little-endian. Strings are UTF-8 JSON.
- The host opens one instance for control calls and one more per channel, so per-channel state can be global.
- A call that runs past half a second stops its channel.
Exports
| Export | Signature | Does |
|---|---|---|
sdrmm_abi | () -> u32 | ABI version, currently 1 |
sdrmm_alloc | (len: u32) -> ptr | A buffer for the host to write len bytes into |
sdrmm_manifest | () -> i64 | Packed manifest JSON |
sdrmm_call | (op: i32, ptr, len) -> i64 | A control call, JSON in, packed JSON reply out |
sdrmm_open | (rate: f64, ptr, len) -> i32 | Opens a channel at rate Hz with settings JSON; a handle, or -1 |
sdrmm_apply | (handle, ptr, len) -> i32 | New settings JSON; 0, or -1 |
sdrmm_input | (handle, samples: u32) -> ptr | Where the host writes the next block |
sdrmm_process | (handle, samples: u32) -> ptr | Runs the block; where the output header is |
sdrmm_retuned | (handle) | Optional. The channel moved; drop what you accumulated |
sdrmm_close | (handle) | The channel is gone |
sdrmm_error | () -> i64 | Packed message for the last -1 |
ptr, len and handle are u32, u32 and i32. A packed i64 is ptr << 32 | len. Every
buffer you return stays yours and must stay valid until the host's next call into the instance.
A buffer from sdrmm_alloc is yours again once the call it was written for returns.
Control calls
sdrmm_call answers {"ok": value} or {"err": "message"}.
op | Input | ok value |
|---|---|---|
1 layout | Settings | {"input_rate_hz": 48000.0, "band": [-6000.0, 6000.0], "audio_channels": 1} |
2 normalize | Settings, {} for the defaults | The settings with every default filled in |
3 present | {"kind": "burst", "data": {...}} | A Presentation: {"summary": "Burst", "fields": [...]} |
Settings are the JSON object your form produces. Treat absent fields as defaults.
Manifest
{
"abi": 1,
"descriptor": {
"type_id": "burst", "name": "Burst", "summary": "Strong bursts",
"family": "utility", "bandwidth_hz": 12000.0, "input_rate_hz": 48000.0,
"emits": ["burst"]
},
"form": [
{ "key": "threshold_db", "label": "Threshold",
"control": { "type": "number", "min": -60.0, "max": 0.0, "step": 1.0, "unit": "dB" } }
],
"kinds": [
{ "kind": "burst", "name": "Burst", "noun": "Bursts", "caps": ["log"],
"hue": "amber", "lifetime": { "type": "instant" } }
]
}
descriptor, form entries and kinds entries are the ChannelDescriptor, SettingField and
EventKindEntry schemas in openapi.json.
Optional: home_hz, the frequency a new channel starts on, and processor, a ProcessorType.
One block
sdrmm_input(handle, n)returns a pointer; the host writesnsamples there, each anf32I then anf32Q.sdrmm_process(handle, n)returns a pointer to nineu32words:
| Word | Holds |
|---|---|
| 0, 1 | Audio pointer and length in f32 samples, interleaved by audio_channels |
| 2 | Audio rate in Hz |
| 3, 4 | Events: pointer and byte length of a JSON array, 0 for none |
| 5, 6 | Status: pointer and byte length of a JSON object, 0 when unchanged |
| 7 | Frames heard but failing their checks, counted over the channel's life |
| 8 | Blocks dropped, counted over the channel's life |
[{"kind": "burst", "data": {"db": -12.5}}]
{"health": {"lock": true, "snr_db": 21.5, "freq_error_hz": 30.0, "errors_per_s": 0.0}}
A status may also carry readouts, a list of fields like those a presentation has.
Host imports
The sdrmm module runs the heavy kernels natively on your memory. Samples are the same
interleaved f32 pairs. A negative return is a refusal.
| Import | Signature | Does |
|---|---|---|
log | (level: u32, ptr, len) | Writes a UTF-8 line to the server log |
fir_new | (taps_ptr, taps_len, decimation: u32) -> i32 | A complex FIR over real f32 taps |
fir_process | (handle, in_ptr, in_len, out_ptr, out_cap) -> i32 | Samples written |
fft_new | (len: u32, inverse: u32) -> i32 | An FFT of len points |
fft_run | (handle, ptr, len) -> i32 | Transforms len samples in place |
resampler_new | (ratio: f64) -> i32 | A fractional resampler, output rate over input rate |
resampler_process | (handle, in_ptr, in_len, out_ptr, out_cap) -> i32 | Samples written |
kernel_drop | (handle) | Frees a kernel |
Example
A complete plugin in C without libc. It builds as in the table and is tested against the real
host by crates/plugins/tests/c_plugin.rs.
/*
* Burst: logs each block whose power rises above -20 dBFS.
* The sdr-- plugin ABI in plain C, no libc. Build:
* zig cc -target wasm32-freestanding -O2 -nostdlib -Wl,--no-entry -Wl,--export-dynamic \
* -o burst.wasm burst.c
*/
#include <stddef.h>
#include <stdint.h>
#define EXPORT __attribute__((visibility("default")))
#define PAGE 65536
#define THRESHOLD 0.01f
enum { OP_LAYOUT = 1, OP_NORMALIZE = 2, OP_PRESENT = 3 };
static const char MANIFEST[] =
"{\"abi\":1,"
"\"descriptor\":{\"type_id\":\"burst\",\"name\":\"Burst\",\"summary\":\"Strong bursts\","
"\"family\":\"utility\",\"bandwidth_hz\":12000.0,\"input_rate_hz\":48000.0,"
"\"emits\":[\"burst\"]},"
"\"kinds\":[{\"kind\":\"burst\",\"name\":\"Burst\",\"noun\":\"Bursts\",\"caps\":[\"log\"],"
"\"hue\":\"amber\",\"lifetime\":{\"type\":\"instant\"}}]}";
static const char LAYOUT[] =
"{\"ok\":{\"input_rate_hz\":48000.0,\"band\":[-6000.0,6000.0]}}";
static const char NORMALIZED[] = "{\"ok\":{}}";
static const char PRESENTED[] = "{\"ok\":{\"summary\":\"Burst\"}}";
static const char UNKNOWN[] = "{\"err\":\"unknown op\"}";
static const char EVENT[] = "[{\"kind\":\"burst\",\"data\":{}}]";
static const char LOCKED[] = "{\"health\":{\"lock\":true}}";
static const char UNLOCKED[] = "{\"health\":{\"lock\":false}}";
static const char NO_ERROR[] = "";
extern unsigned char __heap_base;
static uintptr_t heap_top = (uintptr_t)&__heap_base;
typedef struct {
void *ptr;
size_t cap;
} Region;
static Region inbox;
static Region input;
static uint32_t header[9];
static int loud;
static void *grab(size_t bytes) {
uintptr_t at = (heap_top + 7) & ~(uintptr_t)7;
uintptr_t end = at + bytes;
size_t have = __builtin_wasm_memory_size(0) * PAGE;
if (end > have && __builtin_wasm_memory_grow(0, (end - have + PAGE - 1) / PAGE) < 0)
__builtin_trap();
heap_top = end;
return (void *)at;
}
static void *fit(Region *region, size_t bytes) {
if (bytes > region->cap) {
region->ptr = grab(bytes);
region->cap = bytes;
}
return region->ptr;
}
static int64_t pack(const void *ptr, size_t len) {
return ((int64_t)(uintptr_t)ptr << 32) | (int64_t)len;
}
EXPORT uint32_t sdrmm_abi(void) { return 1; }
EXPORT uint32_t sdrmm_alloc(uint32_t len) { return (uint32_t)(uintptr_t)fit(&inbox, len); }
EXPORT int64_t sdrmm_manifest(void) { return pack(MANIFEST, sizeof MANIFEST - 1); }
EXPORT int64_t sdrmm_call(int32_t op, uint32_t ptr, uint32_t len) {
switch (op) {
case OP_LAYOUT: return pack(LAYOUT, sizeof LAYOUT - 1);
case OP_NORMALIZE: return pack(NORMALIZED, sizeof NORMALIZED - 1);
case OP_PRESENT: return pack(PRESENTED, sizeof PRESENTED - 1);
default: return pack(UNKNOWN, sizeof UNKNOWN - 1);
}
}
EXPORT int32_t sdrmm_open(double rate, uint32_t ptr, uint32_t len) {
loud = 0;
return 0;
}
EXPORT int32_t sdrmm_apply(int32_t handle, uint32_t ptr, uint32_t len) { return 0; }
EXPORT uint32_t sdrmm_input(int32_t handle, uint32_t samples) {
return (uint32_t)(uintptr_t)fit(&input, (size_t)samples * 2 * sizeof(float));
}
EXPORT uint32_t sdrmm_process(int32_t handle, uint32_t samples) {
const float *iq = input.ptr;
float power = 0.0f;
for (uint32_t n = 0; n < 2 * samples; n++)
power += iq[n] * iq[n];
int now = samples > 0 && power / (float)samples > THRESHOLD;
for (int word = 0; word < 9; word++)
header[word] = 0;
if (now && !loud) {
header[3] = (uint32_t)(uintptr_t)EVENT;
header[4] = sizeof EVENT - 1;
}
if (now != loud) {
const char *status = now ? LOCKED : UNLOCKED;
header[5] = (uint32_t)(uintptr_t)status;
header[6] = now ? sizeof LOCKED - 1 : sizeof UNLOCKED - 1;
}
loud = now;
return (uint32_t)(uintptr_t)header;
}
EXPORT void sdrmm_close(int32_t handle) {}
EXPORT int64_t sdrmm_error(void) { return pack(NO_ERROR, 0); }
Transmit and arrays
A transmitter sets can_transmit and adds the exports in crates/wire/src/plugin/transmit.rs:
| Export | Signature |
|---|---|
sdrmm_tx_open | (rate: f64, ptr, len) -> handle |
sdrmm_tx_apply | (handle, ptr, len) -> i32 |
sdrmm_tx_submit | (handle, kind: u32, ptr, len) -> i32, kind 0 audio as f32, 1 frame bytes |
sdrmm_tx_generate | (handle, max_samples: u32) -> i64, packed pointer and sample count of I/Q |
sdrmm_tx_close | (handle) |
An array processor declares processor in the manifest and adds the exports in
crates/wire/src/plugin/processor.rs. sdrmm_array_input takes every lane's block one after the
other; sdrmm_array_process returns a ten-word header laid out in ArrayHeader there.