1

Makin' wavs with Zig

 2 years ago
source link: https://blog.jfo.click/makin-wavs-with-zig/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Makin' wavs with Zig

Jul 20, 2022

I've been gearing up to do some audio programming and more generally learn about digital signal processing. As a first exercise, I decided to revisit a simple program I wrote while trying out rust a few years ago. I blogged about that here. There is, of course, a lot of Rust specific information in that post, but if you'd like background on the .wav format it is specified here.

In short, the rationale for writing .wav is this: it's a very flexible but straightforward format whose data section, when the header is configured as I have done, consists of nothing more than a series of byte-wide samples with no compression or interpolation.

Translating the rust program to zig directly was pretty straightforward, naively it looks like this.

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();
}

I've implemented a simple little set of helper functions to write u32 and u64 to the output. I believe there are better ways to do that, but this works fine. Much of this is really pretty close to the rust source, maybe surprisingly so.

A bug story

The hardest part about this was testing it, for reasons almost completely unrelated to the code. I had originally defined PI as:

const PI: f64 = 3.1415

This is more than enough precision for this use case, and will produce a sine wave at the desired frequency when plugged into the function in the example above.

but...

what it will not do is produce a byte by byte facsimile of the rust version's output. I would not expect it to do this and that's fine also,

but...

as I was continually building and running the binary for this program I was trying to open the resulting .wav file in an audio player, and it just wouldn't work! It would just skip over it and not make any noise at all. I found this strange because even if the math was wrong I would expect some awful noise to come out, but I assumed that the file was malformed and something was wrong with the header.

When comparing the rust program's output byte for byte with the zig version's, they seemed very different for inscrutable reasons!

  1. The data section in the zig version was 4x too long (suspicious)
  2. The bytes were all different!
  3. As I said, it wouldn't play!

The first one was a real bug in the code, as I was initially trying to write f32's into the data section instead of u8's... whoops! But that still left the other two problems.

  1. The bytes were all different!
  2. As I said, it wouldn't play!

The fact that the bytes were different made it hard to debug the output, and there was a lot of headscratcing about that, maybe my write functions were wrong, or writing big endian or something? No, tested in isolation they were doing as I expected.

Ah, I'm not using the exact same PI as rust was though!

use std::f64::consts::PI;

What is that exactly?

pub const PI: f32 = 3.14159265358979323846264338327950288_f32;

So, replacing my weak, sad 4 digits of PI PI with that beefy boi solved problem number 2, but the resulting .wav file still wouldn't play, despite being byte for byte identical to the rust program's output. What gives??

You might be surprised to learn that I was trying to play this file in Apple Music. I wouldn't normally have done that, but I was pairing with someone so expediency was key, and I haven't played that many wav files on that computer so I hadn't reset the default program for the format to quicktime or something like that. I mean what's the big deal, Apple Music can play wav's, right?

Yeah, sure it can! You just double click on the file and Music imports the file and... then when you try to play the same file later it just... wait, plays the imported version of the file it had already imported the first time you played it?

Each attempt to write this wav file had one thing in common. The headers were all identical. I thought initially that there was something wrong with the header and that's why the file wouldn't play. I was right, at the time! When I was writing 4x the data (as floats instead of bytes) to the data section, the line that writes the expected data size was mismatched to what was actually in the data section:

    try write_u16(@truncate(u16, (CHANNELS * (BIT_DEPTH / BYTE_SIZE))), file);

So the first time I tried to play the file in music, it cached it but the version it cached was corrupt, or rather, malformed, so the player didn't even attempt to play back the garbage I had written.

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 then with the identical output, it should have played normally, but since the header was identical, Apple Music happily expected the cached version to be the same, so it just kept trying to play that corrupted version.

I played it in quicktime and it worked as expected! Simple, right?

Just, the worst type of bug, you know?

This was like an hour and a half of debugging. I started with the assumption that I was doing something wrong in the translated code, and I was but that was only 1/3 of the issue. We kept staring at the sine function and that gnarly expression that computes the sample value... surely if there's something wrong in a program it must be in the most obviously complicated spot, right? Without being able to hear the output properly, those assumptions just hung on as we poked and prodded.

What's the moral of this dumb story?

Bugs are most insidious when the interact with other bugs and your assumptions around how the program is running. I hesitate to call the byte mismatching a "bug" but it made it harder to discern what was actually problematic, which had nothing to do with the code at all but rather with how it was being invoked and tested! None of these issues in isolation was all that bad or complex, but put them together and it became an infuriating head scratcher!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK