Skip to content

1 · Zig primer, part 2 — MIDI, theory & rhythm

Picking up from the primer (part 1). Part 1 taught the machine underneath (memory, pointers, values). MIDI is the perfect next step: it’s mostly small integers and bytes, so there’s no real-time-audio difficulty to fight — which frees us to go deeper into Zig’s type system. We’ll meet packed structs, enums with real values, tagged unions, error sets, iterators, compile-time tables, and the built-in test runner — each mapped to a TypeScript idea you already know, taught through note math, a Euclidean sequencer, and a working .mid writer.

Every snippet was compiled (and the tests run) on Zig 0.16. New here? Read part 1 first.

TypeScriptZig (this chapter)
number with & | << >> (coerced to int32)true u4 / u7 + bit ops on the actual type§2
hand-rolled bit maskingpacked struct + @bitCast (real bitfields)§3
enum / string-unionenum(u4) with chosen integer values§4
discriminated union {kind:'x', …}tagged union union(enum) + payload switch§5
throw / Result<T,E>error set error{…} + !T§6
function* / Symbol.iteratora struct with next() ?T§7
build-time codegen / as constcomptime lookup tables§8
jest / vitestbuilt-in test {} + zig test§9

A MIDI note number maps to a pitch by equal temperament — 69 = A4 = 440 Hz, twelve steps per octave:

const std = @import("std");
fn midiToFreq(note: u8) f32 {
const n: f32 = @floatFromInt(note);
return 440.0 * std.math.pow(f32, 2.0, (n - 69.0) / 12.0);
}
const note_names = [_][]const u8{ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" };
fn noteName(note: u8) []const u8 {
return note_names[note % 12];
}
fn noteOctave(note: u8) i32 {
return @divTrunc(@as(i32, note), 12) - 1; // signed division is explicit in Zig
}

TS contrast. note_names is [12][]const u8 — an array of byte slices, not GC’d string objects. A Zig string is a []const u8 (a view over UTF-8 bytes); there’s no separate string type, no hidden allocation, and noteName returns a view into the static array, copying nothing.

Zig note — @divTrunc. Zig won’t let you write / on signed integers: rounding toward zero vs −∞ is a real decision, so you choose (@divTrunc, @divFloor, @divExact). Unsigned / is fine. TypeScript’s single number hides this entirely — and hides the bugs.

↳ Zig reference: Integers, @divTrunc, @floatFromInt.

2 · Bits & bytes: arbitrary-width integers

Section titled “2 · Bits & bytes: arbitrary-width integers”

MIDI is a byte protocol: a status byte (high bit set) plus data bytes (high bit clear → only 7 bits). A Note-On is 0x90 | channel (channel is 4 bits), then a 7-bit note and 7-bit velocity. Zig has those exact widths:

const NOTE_ON: u8 = 0x90;
fn noteOn(channel: u4, note: u7, velocity: u7) [3]u8 {
return .{ NOTE_ON | @as(u8, channel), note, velocity };
}
// TypeScript: everything is `number`; you remember the ranges yourself
function noteOn(channel: number, note: number, velocity: number): number[] {
return [0x90 | (channel & 0x0f), note & 0x7f, velocity & 0x7f]; // mask defensively
}

Zig note — the type is the contract. u4 and u7 aren’t rounded up to a byte; they’re genuine 4- and 7-bit integers. Pass noteOn(0, 200, …) and it won’t compile — 200 doesn’t fit in u7. In TS, & 0x7f is a runtime band-aid you must remember everywhere; in Zig the compiler enforces the range once, at the boundary. Widening is explicit too: @as(u8, channel) promotes the u4 before the |, so there’s no accidental sign- or width-surprise. Bit operators (& | ^ << >>) work on the real type — unlike JS, which silently coerces both operands to int32 first.

↳ Zig reference: Integers, @as, Operators.

That status byte is two 4-bit fields packed into one byte (kkkk cccc). In TS you’d shift and mask by hand forever. Zig lets you declare the layout and reinterpret the byte directly:

// packed-struct fields fill from the LEAST-significant bit upward
const Status = packed struct(u8) {
channel: u4, // bits 0..3 (low nibble)
kind: u4, // bits 4..7 (high nibble)
};
const raw: u8 = 0x93;
const st: Status = @bitCast(raw); // reinterpret the bits: kind = 9, channel = 3
const back: u8 = @bitCast(st); // and straight back to 0x93

Zig note — packed struct(u8) + @bitCast. A packed struct has a guaranteed bit layout (here exactly one byte), so @bitCast reinterprets the raw byte’s bits as the struct with zero cost — no masking, no shifting, no copy. Field order is least-significant-bit first, which is why channel (low nibble) is declared first. @bitCast requires both types to be the same size (here 8 bits), and it’s a pure reinterpretation — contrast @intCast (value-preserving, may fail) and @ptrCast (pointers). TypeScript has no bitfields at all; this is a place Zig is genuinely more expressive than a high-level language, not less.

↳ Zig reference: packed struct, @bitCast, @intCast.

The high nibble names the message kind. A Zig enum is a real type whose integer values you pin down:

const Kind = enum(u4) {
note_off = 0x8,
note_on = 0x9,
control_change = 0xB,
pitch_bend = 0xE,
};
const k = Kind.note_on;
const tag: u4 = @intFromEnum(k); // 0x9 (enum → integer)
const maybe = std.enums.fromInt(Kind, 0x9); // ?Kind = .note_on (integer → enum, checked)
// TypeScript: numeric enum — but no control over bit width, and reverse lookup is loose
enum Kind { NoteOff = 0x8, NoteOn = 0x9, ControlChange = 0xB, PitchBend = 0xE }

Zig note — @intFromEnum / std.enums.fromInt. Going enum→int is total and free (@intFromEnum). Going int→enum is the dangerous direction (the integer might not name a variant), so Zig splits it: @enumFromInt asserts validity (use only when you’re certain), while std.enums.fromInt(E, n) returns a ?Enull for an unknown value. That optional is exactly what you want when parsing untrusted bytes off a wire (next section). A Zig enum is also backed by the integer type you choose (enum(u4) here), so it fits the protocol precisely.

↳ Zig reference: enum, @intFromEnum, @enumFromInt.

This is the feature TypeScript developers fall in love with. A MIDI event carries different data per kind — exactly a TS discriminated union, and Zig has a first-class type for it:

// TypeScript
type Event =
| { kind: "noteOn"; channel: number; note: number; velocity: number }
| { kind: "noteOff"; channel: number; note: number }
| { kind: "controlChange"; channel: number; controller: number; value: number };
const Event = union(enum) {
note_on: struct { channel: u4, note: u7, velocity: u7 },
note_off: struct { channel: u4, note: u7 },
control_change: struct { channel: u4, controller: u7, value: u7 },
};
fn describe(ev: Event) void {
switch (ev) {
.note_on => |n| std.debug.print("NoteOn ch{d} note {d} vel {d}\n", .{ n.channel, n.note, n.velocity }),
.note_off => |n| std.debug.print("NoteOff ch{d} note {d}\n", .{ n.channel, n.note }),
.control_change => |c| std.debug.print("CC ch{d} #{d}={d}\n", .{ c.channel, c.controller, c.value }),
}
}

Zig note — union(enum) + payload capture. union(enum) is a union plus an automatic enum tag that says which variant is active — a tagged union. The switch is exhaustive (forget a variant and it won’t compile, no never trick needed), and |n| captures the payload of the active variant with its precise type. Compared to the TS version: TS checks the discriminant at runtime and narrows the type; Zig stores the tag in one byte, switches on it, and the compiler proves you handled every case. Add a .pitch_bend variant and every switch that forgot it fails to build — the same superpower you saw in the ADSR state machine.

↳ Zig reference: union, Tagged union, switch.

Decoding raw bytes can go wrong: empty input, an unknown status, a truncated message. In TS you’d throw or return a Result. Zig has a dedicated error set type and !T:

const ParseError = error{ Empty, UnknownStatus, Truncated };
fn parse(bytes: []const u8) ParseError!Event {
if (bytes.len == 0) return error.Empty;
const st: Status = @bitCast(bytes[0]);
const kind = std.enums.fromInt(Kind, st.kind) orelse return error.UnknownStatus;
return switch (kind) {
.note_on => blk: {
if (bytes.len < 3) return error.Truncated;
break :blk Event{ .note_on = .{ .channel = st.channel, .note = @truncate(bytes[1]), .velocity = @truncate(bytes[2]) } };
},
.note_off => blk: {
if (bytes.len < 3) return error.Truncated;
break :blk Event{ .note_off = .{ .channel = st.channel, .note = @truncate(bytes[1]) } };
},
.control_change => blk: {
if (bytes.len < 3) return error.Truncated;
break :blk Event{ .control_change = .{ .channel = st.channel, .controller = @truncate(bytes[1]), .value = @truncate(bytes[2]) } };
},
.pitch_bend => error.UnknownStatus,
};
}

Zig note — error{…} and blk: { … break :blk v; }. ParseError is a set of named error values, and ParseError!Event means “an Event, or one of those errors” — the failure modes are in the type signature, so a caller literally cannot ignore them (they must try or catch). It’s TypeScript’s Result<Event, ParseError> with terser ergonomics and no library. The blk: { … break :blk value; } is a labeled block that evaluates to a value — handy when a switch prong needs a statement (the length check) before producing its result; it’s the Zig equivalent of an immediately-invoked arrow function returning a value. @truncate(bytes[1]) narrows a u8 to the u7 the field wants (the high bit of a valid data byte is already 0).

↳ Zig reference: Errors, Error Set Type, Blocks, @truncate.

7 · Iterators (Zig’s answer to generators)

Section titled “7 · Iterators (Zig’s answer to generators)”

TypeScript uses function*/Symbol.iterator for lazy sequences. Zig has no generators — the convention is a struct with a next() method returning ?T (null = done), consumed by while (it.next()) |x|:

// yields the onset step-indices of a rhythm pattern
const Onsets = struct {
pattern: []const u8,
i: usize = 0,
fn next(self: *Onsets) ?usize {
while (self.i < self.pattern.len) {
const idx = self.i;
self.i += 1;
if (self.pattern[idx] == 1) return idx;
}
return null;
}
};
// usage:
var it = Onsets{ .pattern = &pattern };
while (it.next()) |step| { /* step is a usize onset index */ }
// TypeScript generator equivalent
function* onsets(pattern: number[]): Generator<number> {
for (let i = 0; i < pattern.length; i++) if (pattern[i] === 1) yield i;
}
for (const step of onsets(pattern)) { /* ... */ }

Zig note — the next() ?T protocol. No language magic, no hidden state machine the compiler builds for you (as JS does for function*) — just a struct holding its position and a method that returns the next item or null. while (cond) |capture| unwraps the optional and binds it, stopping when next() returns null. This same shape is everywhere in the std library (std.mem.splitScalar(...).next(), dir.iterate().next()), so once you see it you can read all of it. It’s lazy and allocation-free.

↳ Zig reference: while (with optionals), Optionals, struct.

8 · comptime: bake the tables into the binary

Section titled “8 · comptime: bake the tables into the binary”

Calling pow 128 times at runtime to map notes to frequencies is wasteful when the answers never change. In TS you’d precompute with a build script and ship a JSON. In Zig, ordinary code marked comptime runs during compilation and the result is embedded in the binary:

const freq_table: [128]f32 = blk: {
@setEvalBranchQuota(100000);
const ratio = std.math.pow(f32, 2.0, 1.0 / 12.0); // one semitone, computed once
var t: [128]f32 = undefined;
t[0] = 440.0 / std.math.pow(f32, 2.0, 69.0 / 12.0); // frequency of MIDI note 0
var n: usize = 1;
while (n < 128) : (n += 1) t[n] = t[n - 1] * ratio; // walk up by semitones
break :blk t;
};
// freq_table[69] == 440.0, freq_table[60] == 261.6 — looked up, never computed at runtime

Zig note — comptime + @setEvalBranchQuota. A const initialized by a block at container scope is evaluated at compile time, so this whole loop runs in the compiler and freq_table becomes a constant array baked into the executable — zero runtime cost, no build step, no JSON. Because the comptime interpreter limits how many branches it will execute (to catch infinite loops), heavy math like pow needs @setEvalBranchQuota to raise the ceiling. This is the same mechanism that powers Zig “generics” (functions over types) from part 1 — one feature, many uses.

↳ Zig reference: comptime, Compile-Time Expressions.

No jest, no config — test blocks live next to the code and run with zig test file.zig. A protocol encode→decode round-trip is the perfect thing to test:

test "noteOn round-trips through parse" {
const bytes = noteOn(3, 60, 100);
const ev = try parse(&bytes);
try std.testing.expectEqual(@as(u7, 60), ev.note_on.note);
try std.testing.expectEqual(@as(u4, 3), ev.note_on.channel);
}
test "parse rejects truncated and unknown" {
try std.testing.expectError(error.Truncated, parse(&[_]u8{0x90}));
try std.testing.expectError(error.Empty, parse(&[_]u8{}));
}
$ zig test midi.zig
1/2 midi.test.noteOn round-trips through parse... OK
2/2 midi.test.parse rejects truncated and unknown... OK
All 2 tests passed.

Zig note — test {}, try, std.testing. Tests are a language construct, not a framework: the compiler collects every test "..." { ... } and zig test runs them. A test “passes” by not returning an error, so try doubles as an assertion of success, and helpers like expectEqual/expectError return errors on mismatch. std.testing.allocator even fails a test that leaks memory — your part 1 allocator discipline, enforced automatically. The closest TS analogue is vitest, but here it’s in the toolchain itself.

↳ Zig reference: Zig Test, Test Declarations.


With the language tools in hand, here’s the domain content (lighter on Zig commentary now — you’ve seen the moves).

Scales are interval patterns; chords stack scale degrees; Euclidean rhythms spread k onsets evenly over n steps (matching traditional rhythms worldwide — E(3,8) is the Cuban tresillo):

const major = [_]u8{ 0, 2, 4, 5, 7, 9, 11 };
fn scaleDegree(root: u8, pattern: []const u8, degree: usize) u8 {
const octave = degree / pattern.len;
return root + @as(u8, @intCast(octave * 12)) + pattern[degree % pattern.len];
}
fn triad(root: u8, pattern: []const u8, degree: usize) [3]u8 {
return .{
scaleDegree(root, pattern, degree),
scaleDegree(root, pattern, degree + 2),
scaleDegree(root, pattern, degree + 4),
};
}
// Euclidean rhythm via the Bresenham/bucket method
fn euclid(k: usize, n: usize, out: []u8) void {
var bucket: usize = n - k; // seed so step 0 is an onset (canonical)
for (out[0..n]) |*step| {
bucket += k;
if (bucket >= n) { bucket -= n; step.* = 1; } else step.* = 0;
}
}
E(3,8) = x..x..x. tresillo
E(5,8) = x.x.xx.x cinquillo
E(5,16) = x..x..x..x.x..x. son-clave feel

Math note. “As evenly as possible” means onset gaps differ by at most one step — and distributing k marks over n cells is literally the integer line-drawing (Bresenham) problem, which is why the bucket loop works. Toussaint showed E(k,n) reproduces rhythms from cultures across the planet.

11 · Writing a .mid file (big-endian, VLQ)

Section titled “11 · Writing a .mid file (big-endian, VLQ)”

A .mid is chunked like WAV but big-endian, with timing in variable-length quantities (7 bits per byte, high bit = “continues”):

const Track = struct {
data: []u8,
len: usize = 0,
fn byte(self: *Track, b: u8) void { self.data[self.len] = b; self.len += 1; }
fn bytes(self: *Track, bs: []const u8) void { for (bs) |b| self.byte(b); }
fn vlq(self: *Track, value: u32) void { // base-128, big-endian, high bit = more
var buffer: u32 = value & 0x7f;
var v = value >> 7;
while (v > 0) { buffer = (buffer << 8) | 0x80 | (v & 0x7f); v >>= 7; }
while (true) {
self.byte(@truncate(buffer & 0xff));
if (buffer & 0x80 != 0) buffer >>= 8 else break;
}
}
};
// ... build a track of noteOn/noteOff pairs, then emit chunks:
// w.writeAll("MThd"); w.writeInt(u32, 6, .big); w.writeInt(u16, 0, .big);
// w.writeInt(u16, 1, .big); w.writeInt(u16, 480, .big); // format, tracks, ppq
// w.writeAll("MTrk"); w.writeInt(u32, @intCast(trk.len), .big); w.writeAll(trk.data[0..trk.len]);

Zig note — .big vs .little, one argument. WAV was little-endian; MIDI is big-endian; the only change is the order you pass to writeInt. Byte order is a parameter, never a property of your machine — the lesson from wave. Building the track in a buffer first (to learn its length before writing the MTrk size) is the “measure, then emit” pattern file formats keep demanding. (TS’s nearest tool is DataView with an explicit littleEndian flag.)

The delta time 480 VLQ-encodes to 83 60, and the header carries 01 E0 (= 480) — exactly per spec. The full, runnable writer is in the verified source for this chapter.

12 · Capstone: a Euclidean groove → audio

Section titled “12 · Capstone: a Euclidean groove → audio”

Everything together: a Euclidean rhythm triggers minor-pentatonic notes, each a short plucked sine, mixed and normalized into a WAV (reusing midiToFreq, scaleDegree, euclid, and the WAV writer from wave):

const minor_pent = [_]u8{ 0, 3, 5, 7, 10 };
// inside main(), after allocating `buf` and computing `pattern = euclid(5, 16, ...)`:
var degree: usize = 0;
for (pattern, 0..) |hit, i| {
if (hit == 0) continue;
const note = scaleDegree(57, &minor_pent, degree); // root A3 = 57
degree += 1;
const freq = midiToFreq(note);
const start = i * step_samples;
var j: usize = 0;
while (j < step_samples * 2 and start + j < total) : (j += 1) {
const t = @as(f32, @floatFromInt(j)) / @as(f32, @floatFromInt(sr));
const env = std.math.exp(-t * 12.0); // ~80 ms plucky decay
buf[start + j] += std.math.sin(2.0 * std.math.pi * freq * t) * 0.4 * env;
}
}
// ... normalize buf to 0.9 peak, then write as i16 little-endian samples ...

Swap euclid(5, …) for euclid(7, …), change the root or scale, and you get a new groove — all from arrays, an enum, a union, and one integer loop.

TypeScriptZig
n & 0x7f, n >> 7 on numberbit ops on real u4/u7/u8
manual shift/mask of a bitfieldpacked struct(u8) { a: u4, b: u4 } + @bitCast
enum E { A = 1 }enum(u4) { a = 1 }, @intFromEnum, std.enums.fromInt
type E = {kind:'a',…} | {kind:'b',…}union(enum) { a: …, b: … } + switch |x|
throw / Result<T,E>error{…} + !T, try/catch
function* / for..ofstruct with next() ?T + while (it.next()) |x|
build-time JSON / as constcomptime block → const table
jest expect()test {} + std.testing.expect*, zig test
DataView(buf).setUint16(o, v, false)w.writeInt(u16, v, .big)
  1. Pitch-bend. Add a .pitch_bend variant to Event (it carries a 14-bit value split across two 7-bit bytes — reassemble with shifts) and handle it in parse/describe. Watch the exhaustive switch force you to.
  2. A test that leaks. Allocate inside a test with std.testing.allocator and “forget” to free it; see the test fail on the leak.
  3. Iterator chain. Write a Steps iterator that yields Events from a Euclidean pattern + scale, then feed it to both the .mid writer and the audio capstone — one source, two outputs.
  4. comptime scale. Build the C-major MIDI numbers for 8 octaves as a comptime table instead of computing them at runtime.

↳ Zig references used here: packed struct · @bitCast · enum · union · Errors · comptime · Zig Test.


Every Zig snippet here was compiled, and the tests run, on Zig 0.16. Back to part 1 · index.