First zig project : noize

Hey there. I’ve been working on my first zig project called noize - it aims to be a library to build audio applications. Well, the goal is more to learn zig and have fun …

Anyway the code is here : GitHub - tgirod/noize. I’m following the block based approach of faust.

it’s a very early version, it is not making any noise yet, just printing out values to stdout (stderr even) so I can check the resulting waveforms with gnuplot.

My code is probably reeking code smells, but I would love to have some feedback if you fine folks have time to spare reading this.

7 Likes

I only skimmed through, but so far your code looks fine. Maybe the naming could be improved a bit?
I did find a potential problem though. You are passing your time around as a u64, but often you cast it to a f32. This looses quite a lot of precision and you’ll loose quality when the audio gets longer than about 11 minutes.
Additionally, since you always cast it anyways, why not store the sample rate and the current time as floats directly?

3 Likes

Hey @IntegratedQuantum, thanks for the feedback! you are making a good point about u64/f32 conversions. I declared time as u64 because I thought jackd API was declaring it like this, but I might be wrong here.

EDIT: thinking more about it, turning now to f64 and encoding actual time, I can abstract away samplerate. That’s neat!

4 Likes

Hey, still exploring the subject. I’m looking at how I could use comptime to describe my block diagrams. Here is a bit of code:

const std = @import("std");

/// sinewave generator
fn Sin() type {
    return struct {
        input: struct { freq: f32 } = .{ .freq = 440 },
        output: struct { out: f32 } = .{ .out = 0 },

        fn eval(self: @This()) void {
            _ = self;
            std.debug.print("eval Sin\n", .{});
        }
    };
}

/// connect two blocks as a sequence
fn Seq(comptime P: type, comptime N: type) type {
    return struct {
        prev: P = P{},
        next: N = N{},
        // this does not work, and I don't understand why
        // I can access P.eval, but not P.input
        input: @TypeOf(P.input),
        output: @TypeOf(N.output),

        fn eval(self: @This()) void {
            self.prev.eval();
            self.next.eval();
        }
    };
}

pub fn main() void {
    const Tree = Seq(Sin(), Sin());
    var t = Tree{};
    t.eval();
}

I really like this because I don’t have to declare interfaces, comptime is just checking if T has a eval method, etc.

Except it doesn’t work when I add fields Seq.input and Seq.output, with the following error:

> zig run exp/comptime.zig
exp/comptime.zig:23:25: error: struct 'comptime.Sin()' has no member named 'input'
        input: @TypeOf(P.input),
                       ~^~~~~~
exp/comptime.zig:5:12: note: struct declared here
    return struct {
           ^~~~~~

Here, I don’t understand why I can access P.eval but not P.input

Not 100% sure, but I believe this is due to the difference between methods of the type and variables of the instance - the variables input and output are only accessible on an instance of the type, not the type itself.

A workaround is to make the input and output types themselves public declarations of the struct (also note that since your function Sin() takes no arguments, so you could instead do
pub const Sin = struct { ... } and just use Sin instead of Sin() elsewhere):

const std = @import("std");

/// sinewave generator
pub const Sin = struct {
    pub const InputType = struct {
        freq: u32 = 440,
    };
    pub const OutputType = struct {
        out: u32 = 0,
    };

    input: InputType = InputType{},
    output: OutputType = OutputType{},

    pub fn eval(self: @This()) void {
        _ = self;
        std.debug.print("eval Sin\n", .{});
    }
};

/// connect two blocks as a sequence
fn Seq(comptime P: type, comptime N: type) type {
    return struct {
        prev: P = P{},
        next: N = N{},
        // this does not work, and I don't understand why
        // I can access P.eval, but not P.input
        input: P.InputType = P.InputType{},
        output: P.OutputType = P.OutputType{},

        fn eval(self: @This()) void {
            self.prev.eval();
            self.next.eval();
        }
    };
}

pub fn main() void {
    const Tree = Seq(Sin, Sin);
    var t = Tree{};
    t.eval();
}
2 Likes

The way @JacobCrabill provided above is almost certainly the way to go, but it is possible to access the field types and default values for a type.

fn Seq(comptime P: type, comptime N: type) type {
    return struct {
        const input_fld = std.meta.fieldInfo(P, .input);
        const input_type = input_fld.type;
        const input_default = @as(
            *const input_type,
            @alignCast(@ptrCast(input_fld.default_value.?)),
        ).*;

        const output_fld = std.meta.fieldInfo(N, .output);
        const output_type = output_fld.type;
        const output_default = @as(
            *const output_type,
            @alignCast(@ptrCast(output_fld.default_value.?)),
        ).*;

        prev: P = P{},
        next: N = N{},
        input: input_type = input_default,
        output: output_type = ouptut_default,

        fn eval(self: @This()) void {
            self.prev.eval();
            self.next.eval();
        }
    };
}

I used the fieldInfo helper function from std.meta to make things shorter and more readable, but fieldInfo(P, .input) is essentially just finding and returning the entry corresponding to the field P.input in the slice @typeInfo(P).Struct.fields.

Again, in 99% of cases you probably shouldn’t do stuff like this.

1 Like

@JacobCrabill that’s great, thanks for the crystal clear explanation!

@permutationlock thanks, it’s good to know that I can do that, it will be very useful very soon …

OK, here is another one. I’m building a “parallel” operator, which basically concats inputs and outputs from two blocks. It works as expected, but I have to manage the case where I’m merging two structs with fields having the same name.

At first, I tried something like this:

comptime var input_fields = std.meta.fields(A.Input) ++ std.meta.fields(A.Output);
input_fields[0].name = input_fields[0].name ++ ".a"; // actually in a for loop

But the compiler yelled at me because I can’t modify .name because it is declared const. So instead I had to rebuild a list of fields, like this.

fn Par(comptime A: type, comptime B: type) type {
    // input fields
    const input_a = std.meta.fields(A.Input);
    const input_b = std.meta.fields(B.Input);
    comptime var input_fields: [input_a.len + input_b.len]builtin.Type.StructField = undefined;

    for (input_a, 0..) |f, i| {
        input_fields[i] = .{
            .name = f.name ++ ".a",
            .type = f.type,
            .default_value = f.default_value,
            .is_comptime = f.is_comptime,
            .alignment = f.alignment,
        };
    }
    for (input_b, input_a.len..) |f, i| {
        input_fields[i] = .{
            .name = f.name ++ ".b",
            .type = f.type,
            .default_value = f.default_value,
            .is_comptime = f.is_comptime,
            .alignment = f.alignment,
        };
    }

    // output fields
    const output_a = std.meta.fields(A.Output);
    const output_b = std.meta.fields(B.Output);
    comptime var output_fields: [output_a.len + output_b.len]builtin.Type.StructField = undefined;

    for (output_a, 0..) |f, i| {
        output_fields[i] = .{
            .name = f.name ++ ".a",
            .type = f.type,
            .default_value = f.default_value,
            .is_comptime = f.is_comptime,
            .alignment = f.alignment,
        };
    }
    for (output_b, output_a.len..) |f, i| {
        output_fields[i] = .{
            .name = f.name ++ ".b",
            .type = f.type,
            .default_value = f.default_value,
            .is_comptime = f.is_comptime,
            .alignment = f.alignment,
        };
    }

    return struct {
        a: A = A{},
        b: B = B{},

        pub const Input = @Type(.{
            .Struct = .{
                .layout = .Auto,
                .fields = &input_fields,
                .decls = &[_]builtin.Type.Declaration{},
                .is_tuple = false,
            },
        });

        pub const Output = @Type(.{
            .Struct = .{
                .layout = .Auto,
                .fields = &output_fields,
                .decls = &[_]builtin.Type.Declaration{},
                .is_tuple = false,
            },
        });

        const Self = @This();

        fn eval(self: *Self, input: Self.Input) Self.Output {
            _ = input;
            _ = self;
            // TODO
        }
    };
}

Am I overcomplicating things? Is there a way to patch that field name in place?

Hey folks ! after delving into comptime, overdoing it, I think I found a sweet spot. I completely changed my approach - right now the whole evaluation graph is defined at comptime, there are no memory allocations at runtime.

As usual, this is a request for comments, if you feel inclined to have a peek.

After many experiments, here is noize v0.1.0.

Noize allows you to define a audio processing graph entirely at compile time, and run it without having to allocate memory.

It’s still rather crude and clunky, but I’m happy to say it actually makes noize - at least on my machine! :slight_smile:

2 Likes