Zig NEWS

Cover image for Re: Makin' wavs with Zig
Felix "xq" Queißner
Felix "xq" Queißner

Posted on

Re: Makin' wavs with Zig

Intro

Today i've read a small article by jfo that was called Makin' wavs with Zig which was about some first-hand experience in Zig, but also about how bugs are not always where you'd expect them.

Looking at the code, i've immediatly saw some things to improve the code to make it more idiomatic. Technically, it only had one flaw here that might also be just lazyness for the sake of learning, so no shame on jfo here.

I've asked jfo if they would be cool with me writing a response on how to improve the code to be more Zig style, and they said yes. So here it is!

The original code

The code is rendering a small wave file that plays a 440 Hz sine wave for three seconds. Nothing special, but actually a quite good task to get a grasp of arithmetics and I/O in a new language:

const std = @import("std");
const File = std.fs.File;
const sin = std.math.sin;

const SAMPLE_RATE: u32 = 44100;
const CHANNELS: u32 = 1;
const HEADER_SIZE: u32 = 36;
const SUBCHUNK1_SIZE: u32 = 16;
const AUDIO_FORMAT: u16 = 1;
const BIT_DEPTH: u32 = 8;
const BYTE_SIZE: u32 = 8;
const PI: f64 = 3.14159265358979323846264338327950288;

fn write_u16(n: u16, file: File) !void {
    const arr = [_]u8{ @truncate(u8, n), @truncate(u8, n >> 8) };
    _ = try file.write(arr[0..]);
}

fn write_u32(n: u32, file: File) !void {
    const arr = [_]u8{ @truncate(u8, n), @truncate(u8, n >> 8), @truncate(u8, n >> 16), @truncate(u8, n >> 24) };
    _ = try file.write(arr[0..]);
}

fn write_header(seconds: u32, file: File) !void {
    const numsamples: u32 = SAMPLE_RATE * seconds;
    _ = try file.write("RIFF");
    try write_u32(HEADER_SIZE + numsamples, file);
    _ = try file.write("WAVEfmt ");
    try write_u32(SUBCHUNK1_SIZE, file);
    try write_u16(AUDIO_FORMAT, file);
    try write_u16(@truncate(u16, CHANNELS), file);
    try write_u32(SAMPLE_RATE, file);
    try write_u32(SAMPLE_RATE * CHANNELS * (BIT_DEPTH / BYTE_SIZE), file);
    try write_u16(@truncate(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE))), file);
    try write_u16(@truncate(u16, BIT_DEPTH), file);
    _ = try file.write("data");
    try write_u32(numsamples * CHANNELS * (BIT_DEPTH / BYTE_SIZE), file);
}

fn sine_wave(seconds: u32, file: File, freq: f64) !void {
    var idx: u32 = 0;
    while (idx < seconds * SAMPLE_RATE) {
        const sample = ((sin(((@intToFloat(f64, idx) * 2.0 * PI) / @intToFloat(f64, SAMPLE_RATE)) * freq) + 1.0) / 2.0) * 255.0;
        const arr = [_]u8{@floatToInt(u8, sample)};
        _ = try file.write(arr[0..]);
        idx += 1;
    }
}

pub fn main() !void {
    const cwd = std.fs.cwd();
    var file = try cwd.createFile("sine.wav", .{});
    try write_header(3, file);
    try sine_wave(3, file, 440.0);
    _ = file.close();
}
Enter fullscreen mode Exit fullscreen mode

Overall i'd say that's pretty solid code, but let's ziggify it!

Improvements

Literal conversion

The first thing that came to my mind when looking at the code was that the constants were typed:

const SAMPLE_RATE: u32 = 44100;
const CHANNELS: u32 = 1;
const HEADER_SIZE: u32 = 36;
const SUBCHUNK1_SIZE: u32 = 16;
const AUDIO_FORMAT: u16 = 1;
const BIT_DEPTH: u32 = 8;
const BYTE_SIZE: u32 = 8;
const PI: f64 = 3.14159265358979323846264338327950288;
Enter fullscreen mode Exit fullscreen mode

This is untypical for Zig code, as we can just store integer and float literals in variables:

-const SAMPLE_RATE: u32 = 44100;
-const CHANNELS: u32 = 1;
-const HEADER_SIZE: u32 = 36;
-const SUBCHUNK1_SIZE: u32 = 16;
-const AUDIO_FORMAT: u16 = 1;
-const BIT_DEPTH: u32 = 8;
-const BYTE_SIZE: u32 = 8;
-const PI: f64 = 3.14159265358979323846264338327950288;
+const SAMPLE_RATE = 44100;
+const CHANNELS = 1;
+const HEADER_SIZE = 36;
+const SUBCHUNK1_SIZE = 16;
+const AUDIO_FORMAT = 1;
+const BIT_DEPTH = 8;
+const BYTE_SIZE = 8;
+const PI = 3.14159265358979323846264338327950288
Enter fullscreen mode Exit fullscreen mode

This now allows us to remove one workaround in the code later:

- const sample = ((sin(((@intToFloat(f64, idx) * 2.0 * PI) / @intToFloat(f64, SAMPLE_RATE)) * freq) + 1.0) / 2.0) * 255.0;
+ const sample = ((sin(((@intToFloat(f64, idx) * 2.0 * PI) / SAMPLE_RATE) * freq) + 1.0) / 2.0) * 255.0;
Enter fullscreen mode Exit fullscreen mode

As you can see, there's no need to use @intToFloat anymore for our SAMPLE_RATE, as a comptime_int type can simply coerce to any integer or floating point type, so we removed a potential panic and runtime lossy cast into a compile time cast.

defer

The next thing was in the main function:

 pub fn main() !void {
     const cwd = std.fs.cwd();
     var file = try cwd.createFile("sine.wav", .{});
+    defer file.close();
+
     try write_header(3, file);
     try sine_wave(3, file, 440.0);
-    _ = file.close();
 }
Enter fullscreen mode Exit fullscreen mode

By using defer, we can make sure the file is closed in all cases, even if write_header or sine_wave fail and return earlier. Also close() can't return values in Zig, so we don't have to ignore the return value here. Little bit cleaner, definitly more correct!

Introducing File.Writer

-fn write_u16(n: u16, file: File) !void {
+fn write_u16(n: u16, file: File.Writer) !void {
     const arr = [_]u8{ @truncate(u8, n), @truncate(u8, n >> 8) };
     try file.writeAll(arr[0..]);
 }

-fn write_u32(n: u32, file: File) !void {
+fn write_u32(n: u32, file: File.Writer) !void {
     const arr = [_]u8{ @truncate(u8, n), @truncate(u8, n >> 8), @truncate(u8, n >> 16), @truncate(u8, n >> 24) };
     try file.writeAll(arr[0..]);
 }

-fn write_header(seconds: u32, file: File) !void {
+fn write_header(seconds: u32, file: File.Writer) !void {
     const numsamples: u32 = SAMPLE_RATE * seconds;
-    _ = try file.write("RIFF");
+    try file.writeAll("RIFF");
     try write_u32(HEADER_SIZE + numsamples, file);
-    _ = try file.write("WAVEfmt ");
+    try file.writeAll("WAVEfmt ");
     try write_u32(SUBCHUNK1_SIZE, file);
     try write_u16(AUDIO_FORMAT, file);
     try write_u16(@truncate(u16, CHANNELS), file);
@@ -33,16 +33,16 @@ fn write_header(seconds: u32, file: File) !void {
     try write_u32(SAMPLE_RATE * CHANNELS * (BIT_DEPTH / BYTE_SIZE), file);
     try write_u16(@truncate(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE))), file);
     try write_u16(@truncate(u16, BIT_DEPTH), file);
-    _ = try file.write("data");
+    try file.writeAll("data");
     try write_u32(numsamples * CHANNELS * (BIT_DEPTH / BYTE_SIZE), file);
 }

-fn sine_wave(seconds: u32, file: File, freq: f64) !void {
+fn sine_wave(seconds: u32, file: File.Writer, freq: f64) !void {
     var idx: u32 = 0;
     while (idx < seconds * SAMPLE_RATE) {
         const sample = ((sin(((@intToFloat(f64, idx) * 2.0 * PI) / SAMPLE_RATE) * freq) + 1.0) / 2.0) * 255.0;
         const arr = [_]u8{@floatToInt(u8, sample)};
-        _ = try file.write(arr[0..]);
+        try file.writeAll(arr[0..]);
         idx += 1;
     }
 }
@@ -52,6 +52,6 @@ pub fn main() !void {
     var file = try cwd.createFile("sine.wav", .{});
     defer file.close();

-    try write_header(3, file);
-    try sine_wave(3, file, 440.0);
+    try write_header(3, file.writer());
+    try sine_wave(3, file.writer(), 440.0);
 }
Enter fullscreen mode Exit fullscreen mode

So here we replaced the instances of File in our data rendering code with File.Writer, a instance of the std.io.Writer generic. A Writer (or respectivly Reader for input) is a generic input/output abstraction that implements a lot of common i/o tasks like writing a full sequence of bytes, erroring when a EOF condition happens.

So we replace the calls to file.write with a call to reader.writeAll(). As you can see, writeAll() does not return a length value of the bytes we've written, as the value will call write() as often as necessary to write all bytes, and returning an EndOfStream error when nothing could be written. This makes sure our wave file is actually fully written.

This is the flaw i mentioned earlier, as this is a bug that might be hard to debug later on and is a mistake very often done in other programming languages.

Utilizing File.Writer

A std.io.Writer provides several means to put integers into our data stream. The most commonly used functions in my code are writeIntLittle() and writeByte which can help the code here as well:

-fn write_u16(n: u16, file: File.Writer) !void {
-    const arr = [_]u8{ @truncate(u8, n), @truncate(u8, n >> 8) };
-    _ = try file.write(arr[0..]);
-}
-
-fn write_u32(n: u32, file: File.Writer) !void {
-    const arr = [_]u8{ @truncate(u8, n), @truncate(u8, n >> 8), @truncate(u8, n >> 16), @truncate(u8, n >> 24) };
-    _ = try file.write(arr[0..]);
-}
-
 fn write_header(seconds: u32, file: File.Writer) !void {
     const numsamples: u32 = SAMPLE_RATE * seconds;
     try file.writeAll("RIFF");
-    try write_u32(HEADER_SIZE + numsamples, file);
+    try file.writeIntLittle(u32, HEADER_SIZE + numsamples);
     try file.writeAll("WAVEfmt ");
-    try write_u32(SUBCHUNK1_SIZE, file);
-    try write_u16(AUDIO_FORMAT, file);
-    try write_u16(@truncate(u16, CHANNELS), file);
-    try write_u32(SAMPLE_RATE, file);
-    try write_u32(SAMPLE_RATE * CHANNELS * (BIT_DEPTH / BYTE_SIZE), file);
-    try write_u16(@truncate(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE))), file);
-    try write_u16(@truncate(u16, BIT_DEPTH), file);
+    try file.writeIntLittle(u32, SUBCHUNK1_SIZE);
+    try file.writeIntLittle(u16, AUDIO_FORMAT);
+    try file.writeIntLittle(u16, @truncate(u16, CHANNELS));
+    try file.writeIntLittle(u32, SAMPLE_RATE);
+    try file.writeIntLittle(u32, SAMPLE_RATE * CHANNELS * (BIT_DEPTH / BYTE_SIZE));
+    try file.writeIntLittle(u16, @truncate(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE))));
+    try file.writeIntLittle(u16, @truncate(u16, BIT_DEPTH));
     try file.writeAll("data");
-    try write_u32(numsamples * CHANNELS * (BIT_DEPTH / BYTE_SIZE), file);
+    try file.writeIntLittle(u32, numsamples * CHANNELS * (BIT_DEPTH / BYTE_SIZE));
 }

 fn sine_wave(seconds: u32, file: File.Writer, freq: f64) !void {
     var idx: u32 = 0;
     while (idx < seconds * SAMPLE_RATE) {
         const sample = ((sin(((@intToFloat(f64, idx) * 2.0 * PI) / SAMPLE_RATE) * freq) + 1.0) / 2.0) * 255.0;
-        const arr = [_]u8{@floatToInt(u8, sample)};
-        try file.writeAll(arr[0..]);
+        try file.writeByte(@floatToInt(u8, sample));
         idx += 1;
     }
 }
Enter fullscreen mode Exit fullscreen mode

The functions write_u32 and write_u16 were implementing the writeIntLittle function for the types u16 and u32 manually, so we can just delete them and replace all calls to them with writeIntLittle. Removes some code, which is good, and also makes the intent of writing a little-endian integer more clear.

There are also three other integer-writing functions:

  • writeIntBig, which will write a big-endian integer
  • writeIntNative, which will just write the integer as it is represented in memory
  • and writeIntForeign, which will swap the bytes before writing them.

Usually, when writing formats and protocoles, writeIntBig and writeIntLittle should be preferred over just dumping the memory of structures into a file as you make sure that the data is actually loadable on a machine with different endianess.

Killing a silent killer

The code uses a quite "dangerous" function: @truncate. This will always cut excess bits off an integer without checking if those bits were 0. This means it's a lossy cast without safety checks. Sometimes, this is a wanted effect, but often, it's better to use @intCast to narrow integers, as this uses runtime safety.

In our case, we can do even better:

  try file.writeAll("WAVEfmt ");
  try file.writeIntLittle(u32, SUBCHUNK1_SIZE);
  try file.writeIntLittle(u16, AUDIO_FORMAT);
- try file.writeIntLittle(u16, @truncate(u16, CHANNELS));
+ try file.writeIntLittle(u16, CHANNELS);
  try file.writeIntLittle(u32, SAMPLE_RATE);
  try file.writeIntLittle(u32, SAMPLE_RATE * CHANNELS * (BIT_DEPTH / BYTE_SIZE));
- try file.writeIntLittle(u16, @truncate(u16, (CNNELS * (BIT_DEPTH / BYTE_SIZE))));
- try file.writeIntLittle(u16, @truncate(u16, BIT_DEPTH));
+ try file.writeIntLittle(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE)));
+ try file.writeIntLittle(u16, BIT_DEPTH);
  try file.writeAll("data");
  try file.writeIntLittle(u32, numsamples * CHANNELS * (BIT_DEPTH / BYTE_SIZE));
Enter fullscreen mode Exit fullscreen mode

Wait?! Why don't we cast these integers at all? Remember the section above where we removed the types from our constants? This made the constants of type comptime_int, which we can perform computations at compile time with.

So writing 8 / 2 in Zig is the same as writing 4, the compiler backend will never see a division at all. Thus, we can also use computations with our constants and the Zig compiler will handle them the same way as if we'd have the computed number as text. And assigning a number that fits in our integer doesn't need any coercion, casting or truncating at all.

This will also have another very nice benefit: If, for example, (CHANNELS * (BIT_DEPTH / BYTE_SIZE)) will ever be out of range for a u16, we'll get a compile error.

Before this change, if we would've used 256 channels and a bit depth of 256, with a byte size of 1, we would've written a 0 there. Now we get a compile error \o/.

Performance tuning

This is a very unintuitive optimization and there are discussions in the community about the behaviour and intent of these functions:

- const sample = ((sin(((@intToFloat(f64, idx) * 2.0 * PI) / SAMPLE_RATE) * freq) + 1.0) / 2.0) * 255.0;
+ const sample = ((@sin((freq * @intToFloat(f64, idx) * (2.0 * PI / @as(comptime_float, SAMPLE_RATE)))) + 1.0) / 2.0) * 255.0;
Enter fullscreen mode Exit fullscreen mode

Huh, what's that? Why do we replace std.math.sin with the builtin @sin?

The builtin is actually the cooler variant. It can be lowered to a hardware instruction, while std.math.sin is always a software implementation.

This might also answer this question from the original article:

Once I fixed the issue, first with the bytes writing correctly (which despite not being identical to the rust version was perfectly fine and sounded fine!) and [...]

As the software implementation of the stdlib might yield slightly different results as the hardware impl. sin isn't exact science in floating point world anyways.

Ziggify the loop!

This is a tiny nitpick about the loop iterator variable:

- while (idx < seconds * SAMPLE_RATE) {
+ while (idx < seconds * SAMPLE_RATE) : (idx += 1) {
     const sample = ((@sin((freq * @intToFloat(f64, idx) * (2.0 * PI / @as(comptime_float, SAMPLE_RATE)))) + 1.0) / 2.0) * 255.0;
     try file.writeByte(@floatToInt(u8, sample));
-    idx += 1;
  }
Enter fullscreen mode Exit fullscreen mode

Zigs while loop has a continuation syntax which can be used to trigger effects before the evaluation of the loop condition without putting it to the end of the loop. This is a purely syntactical change, it's just more common to write a loop like this.

Conclusion

This concludes all the functional changes i'd do to the code. Personally, i'd change the function names according to the zig style guide, so using sine and writeHeader, but that's purely personal taste. I've also moved main further to the top so it's easier to find when looking at the code.

But enough talking, let's look at the final code:

const std = @import("std");
const File = std.fs.File;

const SAMPLE_RATE = 44100;
const CHANNELS = 1;
const HEADER_SIZE = 36;
const SUBCHUNK1_SIZE = 16;
const AUDIO_FORMAT = 1;
const BIT_DEPTH = 8;
const BYTE_SIZE = 8;
const PI = 3.14159265358979323846264338327950288;

pub fn main() !void {
    const cwd = std.fs.cwd();
    var file = try cwd.createFile("sine.wav", .{});
    defer file.close();

    try writeHeaders(3, file.writer());
    try renderSineWave(3, file.writer(), 440.0);
}

fn writeHeaders(seconds: u32, file: File.Writer) !void {
    const numsamples: u32 = SAMPLE_RATE * seconds;
    try file.writeAll("RIFF");
    try file.writeIntLittle(u32, HEADER_SIZE + numsamples);
    try file.writeAll("WAVEfmt ");
    try file.writeIntLittle(u32, SUBCHUNK1_SIZE);
    try file.writeIntLittle(u16, AUDIO_FORMAT);
    try file.writeIntLittle(u16, CHANNELS);
    try file.writeIntLittle(u32, SAMPLE_RATE);
    try file.writeIntLittle(u32, SAMPLE_RATE * CHANNELS * (BIT_DEPTH / BYTE_SIZE));
    try file.writeIntLittle(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE)));
    try file.writeIntLittle(u16, BIT_DEPTH);
    try file.writeAll("data");
    try file.writeIntLittle(u32, numsamples * CHANNELS * (BIT_DEPTH / BYTE_SIZE));
}

fn renderSineWave(seconds: u32, file: File.Writer, freq: f64) !void {
    var idx: u32 = 0;
    while (idx < seconds * SAMPLE_RATE) : (idx += 1) {
        const sample = ((@sin((freq * @intToFloat(f64, idx) * (2.0 * PI / @as(comptime_float, SAMPLE_RATE)))) + 1.0) / 2.0) * 255.0;
        try file.writeByte(@floatToInt(u8, sample));
    }
}
Enter fullscreen mode Exit fullscreen mode

And that's it! I hope i could help some people to get started with Zig and might write some more idiomatic Zig code.

And now go, write Zig!

PS.:
As Andrew noted in the comments, using std.math.pi is also the better option than using a self-defined constant. It utilized the full 128 bit float precision from comptime_float, so you're definitly not loosing any precious precision here!

Top comments (7)

Collapse
 
andrewrk profile image
Andrew Kelley

One more thing! std.math.pi

Collapse
 
webermartin profile image
weber-martin

I don't understand the benefit of leaving off the types of the const's. Fine, now they are comptime; you drop one cast (SAMPLE_RATE; saying a comptime_int can be treated as comptime_float and comptime_int), but then you have to re-add the cast in another place (to make sure the SAMPLE_RATE is indeed being treated as float). It is a bit non-obvious to me why the 2nd place asked for the cast while you could drop it in the first place. Both places have the comptime_int as divisor with the dividend being a float already, aren't they?

Also I like to state expected bit-widths surrounding my I/O - involved data types for binary I/O. If you have a u32 and write a single byte because that's all the value needs for the given value (e.g., write a u32: 127 as u7: 127 aka 1 byte), nobody but your attention will catch it. Similarly with the repetitive explicit type in writeIntLittle: at places you convert, at others you don't. It is non-obvious from the language where you are using a conversion and where you aren't.

Finally, I disagree about the usage of @truncate -- this is used to select a single byte from the multi-byte constant; a perfectly valid use-case that shall happily ignore non-zero bits in the rest of the bit-field. What is your actual problem with the usage of @truncate in this use-case?

TIA for enlightenment.

Collapse
 
xq profile image
Felix "xq" Queißner

@truncate will silently remove upper bits, which means that when you do arithmetic like in try file.writeIntLittle(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE))); will just compile, even if it tries to put 65536 into the var (the program will silently emit 0) whereas without a truncate and no explicit types, the computation would still yield 65536, but with this error:

demo.zig:6:31: error: type 'u16' cannot represent integer value '102400'
    const foo: u16 = CHANNELS * (BIT_DEPTH / BYTE_SIZE);
                     ~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~
Enter fullscreen mode Exit fullscreen mode

Which is unarguably better than having the program compile, and emit invalid files for weeks before someone notices it. Also computing values with comptime_int is way easier than using fixed types. For example, with (3 * a / 4) you can compute a precise 75% value of that integer a, but if you're using a: u16 and a is 40000, it will overflow:

demo2.zig:3:14: error: overflow of integer type 'u16' with value '120000'
const b = (3 * a / 4);
           ~~^~~
Enter fullscreen mode Exit fullscreen mode

whereas using a comptime_int value, it will just work.

Collapse
 
webermartin profile image
weber-martin

Thanks for your explanations!

The thing I still don't get is the following: comptime_int will allow you to do computations with results or intermediate results out of scope of the ultimate goal type (in our case, u16). You will notice that at the point where you finally leave comptime_int realm and try to fit the value into u16. You will not notice otherwise.

What is the harm in trying to provoke the error as early as possible instead of deferring it to the latest possible point (when you finally explicitly coerce to u16)?

Isn't it advisable to trigger an error as early as possible, hence decorate your const's with the target type? You've given an example of scaling down by 3/4, what if I need/want to store the intermediate result as well and drive further (comptime) logic with it? In the end, I suppose, things wouldn't compile; I just don't see the benefit of not stating explicitly the domain of values I intend a const (or comptime var, for that matter) to take.

You seem to "sell" this with "I can go temporarily out of domain with comptime_int" where I wouldn't "buy" this exactly because of that "feature".

Again, I think in the end it doesn't matter this way or another, because sooner or later (without @truncate) you'll run into an error - but you say it is more idiomatic to allow temporary values to be out of domain, which is the thing that confuses me.

Thread Thread
 
xq profile image
Felix "xq" Queißner

Just to clarify my thinking: Imho constants should be untyped as their implicit type is actually a integer that only allows a single value (which is the value of the constant) and thus can be stored in 0 bits. I guess when Zig gets the @Int(min,max) type, a constant x will just be of the type @Int(x,x), which can be stored in 0 bits, but has a value that can exceed any reasonably storable number. This allows assigning a constant x=7 to both a @Int(0,7) as well as to a @Int(7,14)

Collapse
 
inkryption profile image
InKryption

I can imagine why you wouldn't want to go any deeper into the std.io.Writer interface, and how it's generally used, but might be good to add a small note that you usually don't accept specific instantiations like File.Writer.

Collapse
 
rofrol profile image
Roman Frołow

Great, thanks.

Also softare -> software