Making noise with zig
- learning the basics of zig
- reimplementing the node diagram approach of faust
- having fun
Noize is composed of nodes. Each node is defined by its inputs, outputs, internal state and eval function.
There are nodes defined for a variety of basic functions, and special nodes called operators.
Operators are used to combine nodes with each other, effectively connecting outputs to inputs in various ways. Inputs and outputs of the resulting nodes are derived from the combined nodes.
Evaluation is done by calling the eval function of the root node with an array storing inputs, and an array to store outputs.
All nodes are defined at compile time. Operators check at comptime that combined nodes are compatible, input/output wise. Also, no memory is allocated during runtime which is kind of cool.
Noize uses jack as a backend to access audio hardware.
- proof of concept, operators and basic nodes, audio backend
- more examples in a separate folder instead of main.zig
- add OSC endpoints to the backend
- #syntax declare constant inputs at comptime
- #syntax FAUST's iterations
- node graph visualization
- more basic nodes
- build a simple reverb
There is something crazy cool to do with tuples.
- define node's input and output as slices of types
- generate corresponding tuple types with
std.meta.Tuple
- redefine eval as
eval(input: InputTuple, output: OutputTuple)
And now I can pass tuples from one eval call to the next. It is also possible to concat tuples together, and if I need to split them, I can use inline for
to iterate over.
Experiment is here, but this approach is so promising, integrating it in the main code is the next step.
The tuple experiment has been merged into the main codebase. Note that it raises a segfault at compile time with zig 0.11.0 but not with master.
Interfacing with C is not easy. I'm almost there with jack - registering a client, opening input and output ports, running a process callback - but the C API is leaking everywhere.
So in the end I'm writing jack bindings - but I guess someone already did the work ? I've just found https://machengine.org/pkg/mach-sysaudio/ and it looks like I could use that ...
It's alive! the jack backend is working!
Experimenting with @Vector. Audio backends are usually asking for samples in frames. Passing vector types as inputs and outputs and adapting the code accordingly would allow to process a whole frame at once and make good use of the SIMD capabilities of the processor.
But to define a vector, I have to know its length at comptime. It means I would need info such as frame size and sample rate defined at comptime, but those informations are usually gathered at runtime (at least for jack).
Also : so far, Sin is the only node that stores a variable internal state (its phase). This information led me to add a step
parameter to the eval
method, in order to pass this information around. Is it the right approach ?
Things are generally working, which is cool. But I think having samplerate and framesize set at comptime would be more convenient.
For example, With delay max length declared at comptime, I have to declare it in samples because samplerate is runtime defined - so no conversion is possible.
Earlier I hesitated to declare those values at comptime because they are usually known at runtime. But worst case scenario, a program could defined a list of valid samplerates, and generate one Noize(srate)
type for each, and use only one. That would only make the program a bit heavier.
I experimented with vector types. Here is what I learned from it:
- my first algorithm to vectorize oscillators was completely wrong. The phase shift is step*freq for each step of the vector, and freq can be variable. So, to compute the phase vector, I need a scan function to accumulate.
- I did some performance testing, comparing using vector types with regular sample per sample approach, and vector types are slower. I'm probably doing something wrong?
I should move back to a more simple implementation with arrays of f64 as inputs and outputs, and reorganize the code in a proper lib.
v0.1.0 released ! The code should be usable as a lib, and values passed around nodes are arrays of f32, like Faust.
What about buffers ?
I could create a node that takes a buffer address at comptime, and write its input in the buffer, looping over when reaching the end. That writer should output its current write index. Other nodes could access this buffer and read it in various ways. A buffer of length=1 is a special case where one can store a node's output at a memory address, so other nodes can access it.
I think decoupling buffers that way is a good move. But comptime known length can be limiting ...
Recently I discovered fluent - a lib to do slice manipulations with function chaining. I found their approach so elegant I had to use it for noize.
Now, operators are methods of node types. For example, if you wanted to build this:
graph LR
A --> C;
B --> C;
With the previous API, it would be:
noize.Seq(noize.Par(A,B), C);
With the new API, it becomes:
A.par(B).seq(C);
To achieve that, you just have to add the following line in your node definition, and it will inherit all the apropriate methods:
pub usingnamespace noize.NodeInterface(@This())