ZigRadio blocks are essentially Zig structures that implement a process()
function to convert input samples to output samples. Additionally, blocks may
implement optional hooks for initialization, deinitialization, and sample rate
manipulation, which are automatically called by the ZigRadio framework during
the setup and teardown of a flow graph.
Blocks may also implement arbitrary functions to set or get their state at runtime, or trigger other functionality, which can be called in a thread-safe manner through the flow graph.
const radio = @import("radio");
pub const MultiplyBlock = struct {
block: radio.Block,
pub fn init() MultiplyBlock {
return .{ .block = radio.Block.init(@This()) };
}
pub fn process(_: *MultiplyBlock, x: []const f32, y: []const f32, z: []f32) !radio.ProcessResult {
for (x, 0..) |_, i| {
z[i] = x[i] * y[i];
}
return radio.ProcessResult.init(&[2]usize{ x.len, x.len }, &[1]usize{x.len});
}
};
At a minimum, a ZigRadio block requires a block: radio.Block field and a
process() function.
The block field holds the relevant information about the block, including its
type signature, port names, implemented hooks, etc., which are required by the
framework to connect and run the block. This field is automatically populated
at compile-time with introspection by calling radio.Block.init(@This()). A
reference to the block field for a particular block instance (e.g.
&multiplyblock.block) is the unique handle used by Flowgraph APIs for
connecting blocks or for calling into them.
The process() function is the main function of the block, called by the
framework repeatedly to convert input samples to output samples. The framework
deduces the input and output ports and their data types from the type signature
of the block's process() function. Arguments of a constant slice type map to
input ports, while those of mutable slice type map to output ports. The
process() function may access and manipulate the block's state through the
self argument.
The ZigRadio framework guarantees that process() is only called when there
are a non-zero amount of input samples available across all inputs, and at
least as many output samples available, across all outputs. Blocks that need to
produce more or less output samples relative to input samples are responsible
for managing the available samples, which may require buffering them.
The return value of process() is a ProcessResult, which provides an
accounting of how many samples were consumed and produced, allowing the
framework to acknowledge input samples from upstream blocks and make output
samples available to downstream blocks. This type can be constructed with
ProcessResult.init(consumed: []const usize, produced: []const usize) ProcessResult, where consumed contains the input samples consumed, and
produced contains the output samples produced. The order of inputs in
consumed and outputs in produced follow the order of inputs and outputs in
the process() type signature, respectively.
The process() function may also return an error, which will cause the block
to terminate and the flow graph to collapse.
ZigRadio blocks may implement a few optional hooks that are automatically called by the framework.
pub fn initialize(self: *Self, allocator: std.mem.Allocator) !void { ... }
The initialize() hook is used for memory allocation, I/O initialization, and
sample rate dependent initialization. This function is called by the framework
during flow graph setup, after all blocks are connected and their sample rates
are determined.
The allocator passed to initialize() is the same one that the
Flowgraph was initialized with. Blocks
may call self.block.getRate(comptime T: type) T in initialize() to get
their sample rate in terms of their preferred numeric type (e.g. f32,
usize, etc.).
Blocks may return an error from initialize(), which will cause flow graph
initialization to fail.
pub fn deinitialize(self: *Self, allocator: std.mem.Allocator) void { ... }
The deinitialize() hook is used for memory deallocation, I/O
deinitialization, and other deinitialization. The function is called by the
framework on flow graph teardown. The allocator passed to deinitialize() is
the same as the one passed to initialize(), for convenience.
pub fn setRate(self: *Self, upstream_rate: f64) !f64 { ... }
The setRate() hook is used to override the block's sample rate. By default,
blocks inherit the sample rate of the upstream block connected to their first
input port. Blocks that produce samples at a different sample rate from their
inputs (e.g. downsamplers, upsamplers, etc.), may implement their own
setRate() which returns the modified sample rate. The upstream rate is passed
in the upstream_rate argument.
Blocks may return an error from setRate(), which will cause flow graph
initialization to fail.
ZigRadio blocks use native Zig types for their input and output ports. Common data types include:
std.math.Complex(f32), for complex-valued samplesf32, for real-valued samplesu8, for byte samplesu1, for bit samplesBlocks may also use arbitrary struct and union types for inputs and
outputs, like any other type. Custom types must implement the typeName() []const u8 getter for error reporting and debug logging by the framework.
Blocks can accept parametric types in the typical fashion for Zig: with a function that accepts a comptime type and returns a structure parameterized by that type.
const radio = @import("radio");
pub fn MultiplyBlock(comptime T: type) type {
return struct {
const Self = @This();
block: radio.Block,
pub fn init() Self {
return .{ .block = radio.Block.init(@This()) };
}
pub fn process(_: *Self, x: []const T, y: []const T, z: []T) !radio.ProcessResult {
for (x, 0..) |_, i| {
z[i] = x[i] * y[i];
}
return radio.ProcessResult.init(&[2]usize{ x.len, x.len }, &[1]usize{x.len});
}
};
}
In this case, MultiplyBlock is made parametric by accepting a comptime type
T that supports the multiply operator, which is most numeric types. This
MultiplyBlock can be instantiated as MultiplyBlock(f32),
MultiplyBlock(u32), etc.
Custom types that need resource management, e.g. memory allocation and
deallocation, require the reference counted wrapper type, RefCounted(T). This
wrapper type calls .init(...) on the underlying type once on creation, and
.deinit() on the underlying type when its reference count has decremented to
zero.
This allows blocks to create dynamic samples (e.g. samples with memory or other resource management) that are initialized once, propagated to multiple downstream blocks, and then finally deinitialized after being processed by the final block.
const std = @import("std");
const radio = @import("radio");
pub const MyPacket = struct {
src: u32 = 0,
dst: u32 = 0,
payload: []u8 = &.{},
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) MyPacket {
return .{ .allocator = allocator };
}
pub fn deinit(self: *MyPacket) void {
self.allocator.free(self.payload);
}
pub fn typeName() []const u8 {
return "MyPacket";
}
};
In this example, MyPacket has a dynamically allocated payload field. A block might produce
a RefCounted(MyPacket) as in the example below:
pub fn process(self: *MyPacketDecoderBlock, x: []const u1, z: []RefCounted(MyPacket)) !ProcessResult {
...
z[j] = RefCounted(MyPacket).init(self.allocator);
z[j].value.src = ...;
z[j].value.dst = ...;
z[j].value.payload = try self.allocator.dupe(u8, ...);
j += 1;
...
return ProcessResult.init(&[1]usize{i}, &[1]usize{j});
}
Blocks can provide arbitrary functions to access or modify their state at
runtime. These are normal functions, which are run exclusively of process()
by the block runner thread, and thus require no special locking.
const radio = @import("radio");
pub const MultiplyConstantBlock = struct {
block: radio.Block,
constant: f32,
pub fn init(constant: f32) MultiplyConstantBlock {
return .{ .block = radio.Block.init(@This()), .constant = constant };
}
pub fn process(self: *MultiplyConstantBlock, x: []const f32, z: []f32) !radio.ProcessResult {
for (x, 0..) |_, i| {
z[i] = x[i] * self.constant;
}
return radio.ProcessResult.init(&[1]usize{x.len}, &[1]usize{x.len});
}
pub fn setConstant(self: *MultiplyConstantBlock, constant: f32) !void {
if (constant > 9000) return error.OutOfBounds;
self.constant = constant;
}
pub fn getConstant(self: *MultiplyConstantBlock) f32 {
return self.constant;
}
};
This block exposes a setConstant() function to update its constant, and a
getConstant() function to return it. These block functions can be called in a
thread-safe manner through the flow graph with try flowgraph.call(&multiplyconstant.block, MultiplyConstantBlock.setConstant, .{123}); and const constant = try flowgraph.call(&multiplyconstant.block, MultiplyConstantBlock.getConstant, .{});.
Composite blocks are a composition of blocks with internal connectivity and input/output ports at their boundary.
const radio = @import("radio");
pub const MultiplyConstantAndSquareBlock = struct {
block: radio.CompositeBlock,
b1: radio.blocks.MultiplyConstantBlock,
b2: radio.blocks.MultiplyBlock,
pub fn init(constant: f32) MultiplyConstantAndSquareBlock {
return .{
.block = radio.CompositeBlock.init(@This(), &.{"in1"}, &.{"out1"}),
.b1 = radio.blocks.MultiplyConstantBlock.init(constant),
.b2 = radio.blocks.MultiplyBlock.init(),
};
}
pub fn connect(self: *MultiplyConstantAndSquareBlock, flowgraph: *radio.Flowgraph) !void {
// Internal connections
try flowgraph.connectPort(&self.b1.block, "out1", &self.b2.block, "in1");
try flowgraph.connectPort(&self.b1.block, "out1", &self.b2.block, "in2");
// Alias inputs and outputs
try flowgraph.alias(&self.block, "in1", &self.b1.block, "in1");
try flowgraph.alias(&self.block, "out1", &self.b2.block, "out1");
}
};
At a minimum, a composite block requires a block: radio.CompositeBlock field
and a connect() function.
The block field holds the relevant information about the composite block,
including its input and output port names, implemented hooks, etc., which are
required by the framework to connect the composition. This field is
automatically populated at compile-time by calling
radio.CompositeBlock.init(@This(), &.{ inputs... }, &.{ outputs... }), where
inputs and outputs are the names of the input and output ports, respectively.
The connect() function is responsible for making internal connections and
defining the boundary ports of the composition. These connections are stored
within the parent flow graph. Within connect(), the ordinary flow graph
connect() and connectPort() functions are used to make internal block
connections, while the flow graph alias() function is used to alias a
composite block's input or output port to an internal block's input or output
port.
The example above illustrates a composition of MultiplyConstantBlock and
MultiplyBlock to multiply a signal by a constant and then square it.
Composite blocks may also expose their own functions for asynchronous control.
However, they must call internal blocks through the provided Flowgraph
argument for thread safety:
pub const MultiplyConstantAndSquareBlock = struct {
...
pub fn setConstant(self: *MultiplyConstantAndSquareBlock, flowgraph: *Flowgraph, constant: f32) !void {
try flowgraph.call(&self.b1.block, MultiplyConstantBlock.setConstant, .{constant});
}
};
This composite block function can be called in a thread-safe manner through a
flow graph with try top.call(&multiplyconstantandsquare.block, MultiplyConstantAndSquareBlock.setConstant, .{123});.