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
.midwriter.Every snippet was compiled (and the tests run) on Zig 0.16. New here? Read part 1 first.
What part 2 adds (TS → Zig)
Section titled “What part 2 adds (TS → Zig)”| TypeScript | Zig (this chapter) | |
|---|---|---|
number with & | << >> (coerced to int32) | true u4 / u7 + bit ops on the actual type | §2 |
| hand-rolled bit masking | packed struct + @bitCast (real bitfields) | §3 |
enum / string-union | enum(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.iterator | a struct with next() ?T | §7 |
build-time codegen / as const | comptime lookup tables | §8 |
| jest / vitest | built-in test {} + zig test | §9 |
1 · The domain in one function
Section titled “1 · The domain in one function”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_namesis[12][]const u8— an array of byte slices, not GC’dstringobjects. A Zig string is a[]const u8(a view over UTF-8 bytes); there’s no separate string type, no hidden allocation, andnoteNamereturns 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 singlenumberhides 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 yourselffunction 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.
u4andu7aren’t rounded up to a byte; they’re genuine 4- and 7-bit integers. PassnoteOn(0, 200, …)and it won’t compile — 200 doesn’t fit inu7. In TS,& 0x7fis 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 theu4before 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.
3 · packed struct: real bitfields
Section titled “3 · packed struct: real bitfields”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 upwardconst 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 = 3const back: u8 = @bitCast(st); // and straight back to 0x93Zig note —
packed struct(u8)+@bitCast. Apacked structhas a guaranteed bit layout (here exactly one byte), so@bitCastreinterprets 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 whychannel(low nibble) is declared first.@bitCastrequires 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.
4 · Enums with chosen values
Section titled “4 · Enums with chosen values”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 looseenum 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:@enumFromIntasserts validity (use only when you’re certain), whilestd.enums.fromInt(E, n)returns a?E—nullfor 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.
5 · Tagged unions = discriminated unions
Section titled “5 · Tagged unions = discriminated unions”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:
// TypeScripttype 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. Theswitchis exhaustive (forget a variant and it won’t compile, nonevertrick 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_bendvariant and everyswitchthat forgot it fails to build — the same superpower you saw in the ADSR state machine.
↳ Zig reference: union, Tagged union, switch.
6 · Error sets: parsing that can fail
Section titled “6 · Error sets: parsing that can fail”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{…}andblk: { … break :blk v; }.ParseErroris a set of named error values, andParseError!Eventmeans “anEvent, or one of those errors” — the failure modes are in the type signature, so a caller literally cannot ignore them (they musttryorcatch). It’s TypeScript’sResult<Event, ParseError>with terser ergonomics and no library. Theblk: { … break :blk value; }is a labeled block that evaluates to a value — handy when aswitchprong 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 au8to theu7the 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 patternconst 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 equivalentfunction* 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() ?Tprotocol. No language magic, no hidden state machine the compiler builds for you (as JS does forfunction*) — just a struct holding its position and a method that returns the next item ornull.while (cond) |capture|unwraps the optional and binds it, stopping whennext()returnsnull. 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 runtimeZig note —
comptime+@setEvalBranchQuota. Aconstinitialized by a block at container scope is evaluated at compile time, so this whole loop runs in the compiler andfreq_tablebecomes 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 likepowneeds@setEvalBranchQuotato 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.
9 · Tests are built in
Section titled “9 · Tests are built in”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.zig1/2 midi.test.noteOn round-trips through parse... OK2/2 midi.test.parse rejects truncated and unknown... OKAll 2 tests passed.Zig note —
test {},try,std.testing. Tests are a language construct, not a framework: the compiler collects everytest "..." { ... }andzig testruns them. A test “passes” by not returning an error, sotrydoubles as an assertion of success, and helpers likeexpectEqual/expectErrorreturn errors on mismatch.std.testing.allocatoreven 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.
The musical payload
Section titled “The musical payload”With the language tools in hand, here’s the domain content (lighter on Zig commentary now — you’ve seen the moves).
10 · Scales, chords & Euclidean rhythm
Section titled “10 · Scales, chords & Euclidean rhythm”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 methodfn 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. tresilloE(5,8) = x.x.xx.x cinquilloE(5,16) = x..x..x..x.x..x. son-clave feelMath note. “As evenly as possible” means onset gaps differ by at most one step — and distributing
kmarks overncells 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 —
.bigvs.little, one argument. WAV was little-endian; MIDI is big-endian; the only change is the order you pass towriteInt. 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 theMTrksize) is the “measure, then emit” pattern file formats keep demanding. (TS’s nearest tool isDataViewwith an explicitlittleEndianflag.)
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.
Cheatsheet (part 2)
Section titled “Cheatsheet (part 2)”| TypeScript | Zig |
|---|---|
n & 0x7f, n >> 7 on number | bit ops on real u4/u7/u8 |
| manual shift/mask of a bitfield | packed 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..of | struct with next() ?T + while (it.next()) |x| |
build-time JSON / as const | comptime block → const table |
jest expect() | test {} + std.testing.expect*, zig test |
DataView(buf).setUint16(o, v, false) | w.writeInt(u16, v, .big) |
Exercises
Section titled “Exercises”- Pitch-bend. Add a
.pitch_bendvariant toEvent(it carries a 14-bit value split across two 7-bit bytes — reassemble with shifts) and handle it inparse/describe. Watch the exhaustiveswitchforce you to. - A test that leaks. Allocate inside a
testwithstd.testing.allocatorand “forget” to free it; see the test fail on the leak. - Iterator chain. Write a
Stepsiterator that yieldsEvents from a Euclidean pattern + scale, then feed it to both the.midwriter and the audio capstone — one source, two outputs. - comptime scale. Build the C-major MIDI numbers for 8 octaves as a
comptimetable instead of computing them at runtime.
Further reading
Section titled “Further reading”- Godfried Toussaint, The Euclidean Algorithm Generates Traditional Musical Rhythms.
- The Standard MIDI File spec (MThd/MTrk, VLQ, meta events).
- Zig: Language Reference and the searchable Standard Library docs.
↳ 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.