0 · Zig for TypeScript developers — a noisy primer
You already know how to program — closures, generics, async, the works. What Zig asks you to learn is the layer underneath the runtime you’ve always had: where memory lives, who frees it, what a pointer is, and why there’s no garbage collector to save you. This primer teaches exactly that, mapped to TypeScript mental models, with the audio examples this course is built on. Targets Zig 0.16 on macOS (Apple Silicon); identical elsewhere.
Every Zig snippet here was compiled and run on 0.16. Type them out —
zig run file.zig— the low-level stuff only clicks when you feel the compiler push back.
The five mindset shifts
Section titled “The five mindset shifts”Coming from TypeScript, these are the things that will feel alien — and they’re the whole point of Zig:
- No garbage collector. In TS, you
newthings and forget them; V8 reclaims memory later. In Zig you decide where every heap byte comes from and you free it. Nothing is automatic. - Values, not references. A TS object/array is a reference — pass it to a function and mutations leak back. A Zig
structis a value — passing it copies it. Sharing requires an explicit pointer. - Nothing is hidden. No implicit number conversions, no hidden allocations, no exceptions thrown from anywhere. If something costs memory or can fail, you see it in the source.
- Errors are values, not control flow. No
throw. A function that can fail returns!Tand you handle it like aResult. comptimeinstead of metaprogramming. Generics,as const, and codegen collapse into one idea: ordinary code that runs at compile time.
Here’s the cross-reference you’ll keep coming back to:
| TypeScript | Zig | note |
|---|---|---|
number (always f64) | u8 i16 u32 usize f32 f64 … | sizes are explicit |
let / const | var / const | const is the default reach |
| object passed by reference | struct passed by value (copied) | use *T to share |
T | null, x?.y, a ?? b | ?T, if (x) |y|, a orelse b | no null floating around |
throw / try/catch / finally | error.X / !T, try/catch / defer | errors are values |
| garbage collector | allocator + defer alloc.free(x) | you own lifetimes |
Array<T> (growable) | [N]T (fixed) · []T (view) · std.ArrayList(T) (growable) | three distinct things |
generics <T>, as const | comptime | one mechanism |
Atomics, SharedArrayBuffer, Workers | std.atomic.Value, std.Thread | real shared-memory threads |
DataView / Buffer | writeInt(.little), std.mem.asBytes | raw bytes, your endianness |
Setup (macOS, Apple Silicon)
Section titled “Setup (macOS, Apple Silicon)”Native arm64 — nothing emulated.
- Easiest: grab the
aarch64-macosarchive of 0.16.0 from https://ziglang.org/download/, unzip, putzigon yourPATH. - Homebrew:
brew install zig— but confirmzig versionis 0.16 (0.16 changed the file-I/O API this course uses). - First launch may hit Gatekeeper (“unidentified developer”); allow it in System Settings → Privacy & Security or
xattr -dr com.apple.quarantine /path/to/zig.
const std = @import("std");
pub fn main() void { std.debug.print("Hello, sound!\n", .{});}zig run hello.zig@import/@-names are builtins (compiler intrinsics). std.debug.print takes a format string and a tuple of args .{ ... } — TypeScript would use template literals; Zig keeps the values in a separate tuple so there’s no string interpolation magic.
↳ Zig reference: Hello World, Builtin Functions.
Part I — The machine underneath
Section titled “Part I — The machine underneath”1 · Where memory lives (there is no GC)
Section titled “1 · Where memory lives (there is no GC)”In TypeScript every value you didn’t inline lives on the heap and the GC frees it “eventually.” Zig has two places:
- The stack — local variables. Created on function entry, destroyed on return, automatically. Fast, scoped, no bookkeeping. (This is the only place TS gives you for free too, but you never think about it.)
- The heap — anything whose size or lifetime isn’t known at compile time, or that must outlive the function. You request it from an allocator and you must give it back.
fn stackExample() void { var samples: [256]f32 = undefined; // 256 floats, ON THE STACK samples[0] = 0.5;} // <- samples vanishes here, automatically. No free needed.A 256-float buffer is fine on the stack; a 5-second 44.1 kHz buffer (220 500 floats ≈ 860 KB) is not — stacks are small (often 8 MB total), so big or dynamically-sized buffers go on the heap (Lab 8).
TS contrast.
const samples = new Float32Array(256)always heap-allocates and is GC’d. In Zig the same-looking fixed array is a stack value that costs nothing to free. Choosing stack vs heap is a decision you now make on purpose.
↳ Zig reference: Memory, Choosing an Allocator, Variables.
2 · Values vs references (the copy trap)
Section titled “2 · Values vs references (the copy trap)”This is the bug TS developers hit first. A TS object is a handle to heap memory; passing it shares it:
function bump(o: { phase: number }) { o.phase += 0.1; } // mutates the caller's objectconst osc = { phase: 0.5 };bump(osc); // osc.phase is now 0.6A Zig struct is a value. Passing it copies it, and — surprise — function parameters are immutable. So this is a compile error:
// fn bump(o: Osc) void { o.phase += 0.1; } // ERROR: cannot assign to constantTo get TS-like behavior you pass a pointer explicitly; to copy, you copy explicitly:
const Osc = struct { phase: f32 = 0, inc: f32 = 0 };
fn bumpCopy(o: Osc) Osc { // o is an immutable copy var local = o; // make a mutable copy local.phase += 0.1; return local; // hand a new value back}fn bumpInPlace(o: *Osc) void { o.phase += 0.1; // o.phase is sugar for o.*.phase — mutate the original}
pub fn main() void { var osc: Osc = .{ .phase = 0.5 }; const copied = bumpCopy(osc); // osc untouched (0.5) bumpInPlace(&osc); // osc now 0.6 std.debug.print("copied={d:.2} osc={d:.2}\n", .{ copied.phase, osc.phase });}Why this matters for audio. Oscillators, filters, and envelopes are structs with state. If you accidentally take them by value in your per-sample loop, each call mutates a throwaway copy and your phase never advances — a silent, confusing bug. Take
*Oscwhen you mean to advance it.
↳ Zig reference: Pass-by-value Parameters, struct.
3 · Pointers, for people who’ve never held one
Section titled “3 · Pointers, for people who’ve never held one”TypeScript has references but no pointers — you can’t take the address of a thing or talk about “the memory at this location.” Zig has a small, safe family:
var x: i32 = 42;const p: *i32 = &x; // &x = "address of x", type *i32 (pointer to one i32)p.* = 7; // p.* = "the value p points at" — read or write it// x is now 7&value— take its address (like nothing in TS).*T— “pointer to oneT”.p.*dereferences it.o.fieldon a pointer auto-derefs:o.phase==o.*.phase.
There are richer pointer shapes you’ll meet in the course, all distinct on purpose:
var buf = [_]f32{ 1, 2, 3, 4 };
const whole: *[4]f32 = &buf; // pointer to the WHOLE array (length known: 4)const view: []f32 = &buf; // SLICE: a fat pointer = address + lengthconst many: [*]f32 = &buf; // MANY-item pointer: address only, NO length
// view.len == 4 ; many has no .len, you must track length yourselfThe one you’ll use constantly is the slice []T — a pointer and a length travelling together. It’s the closest thing to a JS array view, and it’s bounds-checked in safe builds.
No pointer arithmetic on
*T. In C you’d dop + 1; Zig forbids that on single-item pointers. You index a slice (view[i]) or a many-item pointer (many[i]) instead — the intent (“this points at many things”) is in the type.
↳ Zig reference: Pointers, Slices.
4 · Optionals: null you can’t forget
Section titled “4 · Optionals: null you can’t forget”TypeScript bolts null/undefined onto every type and hopes you check. Zig makes “might be absent” a distinct type, ?T, and the compiler won’t let you use it without unwrapping:
// TypeScriptlet osc: Osc | null = null;osc?.phase; // optional chainingconst f = osc?.phase ?? 440; // nullish coalescingvar osc: ?*Osc = null; // optional pointer: either an address or null
if (osc == null) { /* ... */ }
if (osc) |o| { // unwrap: inside the block, o is a real *Osc std.debug.print("phase {d}\n", .{o.phase});}
const f = (osc orelse defaultOsc).phase; // `orelse` is exactly TS `??`if (x) |v| { ... } is the workhorse — it both tests for null and binds the unwrapped value v. orelse supplies a fallback. There’s no way to “accidentally deref null”: a ?T simply doesn’t have the fields of T until you unwrap it. (Pointers themselves are never null unless you wrote ?*T.)
↳ Zig reference: Optionals, Optional Pointers, null.
5 · Numbers are not all number
Section titled “5 · Numbers are not all number”TS has one numeric type (number, an f64). Zig has many, and the width is part of the type: u8 u16 u32 u64 usize (unsigned), i8 … i64 isize (signed), f32 f64. Audio uses them deliberately — i16 samples, usize indices, f32 for DSP math.
Because widths differ, Zig never converts between number types implicitly. You convert with builtins, so every rounding/truncation is visible:
const i: usize = 16384;const f: f32 = @as(f32, @floatFromInt(i)) / 32768.0; // int → floatconst s: i16 = @intFromFloat(f * 32767.0); // float → int (truncates)const b: u8 = @truncate(@as(u32, 300)); // narrow, dropping high bitsAnd overflow is a choice, not silent wraparound or Infinity:
const x: u8 = 250;// const bad = x + 10; // PANICS in safe builds (260 > 255)const wrap = x +% 10; // +% = explicit wrapping → 4const ov = @addWithOverflow(x, 10); // returns .{ value, overflow_bit }// ov[1] == 1 means it overflowedWhy audio cares. A 16-bit sample must land in
i16; a bytebeat track wantsu8wraparound (*%) as its sound. In TS you’d never notice 8-bit overflow because there are no 8-bit numbers. Here it’s both a safety feature and an instrument.
↳ Zig reference: Integers, Floats, @addWithOverflow, Type Coercion.
Part II — The language, by example
Section titled “Part II — The language, by example”6 · Arrays vs slices vs growable lists
Section titled “6 · Arrays vs slices vs growable lists”Three things TypeScript blurs into Array<T>:
const fixed = [_]f32{ 261.63, 329.63, 392.00 }; // [3]f32 — fixed size, a VALUEconst view: []const f32 = &fixed; // slice — a VIEW (ptr+len), no copyconst part = fixed[1..]; // sub-slice from index 1 to endconst zeros = [_]u8{0} ** 4; // {0,0,0,0} — `**` repeats (your calloc)For a genuinely growable list (TS push), use std.ArrayList(T) — but it needs an allocator (next), because growing means heap reallocation, which Zig will not do behind your back.
x.len is the length, x[i] indexes (bounds-checked in Debug/ReleaseSafe), x[a..b] slices. We use sub-slices to envelope one note inside a big buffer without copying.
↳ Zig reference: Arrays, Slices.
7 · Loops & control flow
Section titled “7 · Loops & control flow”// while with a "continue expression" = C's for(;; i++)var i: usize = 0;var sum: f32 = 0;const xs = [_]f32{ 0.1, 0.2, 0.3 };while (i < xs.len) : (i += 1) sum += xs[i];
// for that captures BY POINTER (|*s|) to write, plus an index via `0..`var ramp: [4]f32 = undefined; // `undefined` = "I'll fill it"for (&ramp, 0..) |*s, k| s.* = @as(f32, @floatFromInt(k)) / 4.0;
// multi-sequence for — walk several slices in lockstep (a 50/50 mix)const a = [_]f32{ 0.1, 0.2, 0.3 };const b = [_]f32{ 0.3, 0.2, 0.1 };var mixed: [3]f32 = undefined;for (&mixed, a, b) |*o, p, q| o.* = (p + q) * 0.5;&xs iterates by pointer so s.* writes each slot; multi-sequence for requires equal lengths (checked). undefined is intentional uninitialized memory — a promise to fill it, not TS’s undefined value.
if is an expression (returns a value), and switch must be exhaustive — miss a case and it won’t compile:
const Wave = enum { sine, square, saw }; // like a TS string-union, but a real type
fn shape(w: Wave, phase: f32) f32 { return switch (w) { .sine => std.math.sin(2.0 * std.math.pi * phase), .square => if (phase < 0.5) -1.0 else 1.0, .saw => 2.0 * phase - 1.0, };}TS contrast. A
switchon a string union in TS only catches missing cases if you wire upneverchecks. Zig’s exhaustiveswitchis built in — add a.trianglevariant and everyswitchthat forgot it fails to compile. Gold for state machines (see the ADSR envelope).
↳ Zig reference: while, for, if, switch & Exhaustive Switching, enum, undefined.
8 · Structs & methods (vs classes)
Section titled “8 · Structs & methods (vs classes)”const Osc = struct { phase: f32 = 0.0, // default field values inc: f32 = 0.0,
fn setFreq(self: *Osc, freq: f32, sr: f32) void { self.inc = freq / sr; } fn next(self: *Osc) f32 { const v = std.math.sin(2.0 * std.math.pi * self.phase); self.phase += self.inc; if (self.phase >= 1.0) self.phase -= 1.0; return v; }};
var osc: Osc = .{}; // .{} = "construct with defaults" (no `new`)osc.setFreq(440.0, 44100.0);const s = osc.next();Differences from a TS class: there’s no this keyword — the receiver is an explicit first parameter self: *Osc (and it’s a pointer when you mutate). There’s no constructor magic — .{} is just a struct literal using defaults, and you can write your own init function by convention. No inheritance; Zig favors composition and comptime.
↳ Zig reference: struct, Default Field Values, Anonymous Struct Literals.
9 · Errors are values (no throw)
Section titled “9 · Errors are values (no throw)”A function that can fail returns !T — “T or an error”. It’s TypeScript’s Result<T, E> baked into the language, with terse handling:
fn half(x: f32) !f32 { if (x < 0) return error.Negative; // an error is just a value return x / 2.0;}
pub fn main() !void { // main can propagate errors too const a = try half(0.8); // `try` = unwrap or return the error upward const b = half(-1.0) catch 0.0; // `catch` = provide a fallback value _ = .{ a, b };}Mapping from TS: throw → return error.X; try { } catch (e) { } → x catch |e| { }; rethrow/propagate → try x. The difference: the ! in the return type makes failure part of the signature — you can’t forget a function might fail, because you can’t call it without try/catch.
↳ Zig reference: Errors, Error Union Type, try, catch.
10 · defer and errdefer (vs finally)
Section titled “10 · defer and errdefer (vs finally)”defer schedules cleanup to run when the scope exits — any exit. It’s finally, but you write it right next to the thing it cleans up:
fn loadSamples(alloc: std.mem.Allocator) ![]f32 { const buf = try alloc.alloc(f32, 1024); errdefer alloc.free(buf); // free ONLY if a later step errors try fillFromDisk(buf); // if this fails, errdefer fires, buf is freed return buf; // success: caller now owns buf (errdefer does NOT fire)}errdefer is the half TS has no equivalent for: run cleanup only on the error path. It’s how you avoid leaking a half-built resource when initialization fails partway.
↳ Zig reference: defer, errdefer.
11 · Memory management, properly
Section titled “11 · Memory management, properly”Here’s the heart of it. TS hands you a GC; Zig hands you an allocator interface (std.mem.Allocator) and several implementations to choose per use-case. Anything needing heap memory takes an alloc parameter — so a function’s signature tells you whether it allocates.
const std = @import("std");
fn makeBuffer(alloc: std.mem.Allocator, n: usize) ![]f32 { const buf = try alloc.alloc(f32, n); // ask for n floats on the heap @memset(buf, 0.0); // zero them (no startup noise) return buf; // caller owns this now}
pub fn main() !void { var dbg: std.heap.DebugAllocator(.{}) = .init; defer _ = dbg.deinit(); // at program end, reports any LEAK you forgot to free const alloc = dbg.allocator();
const buf = try makeBuffer(alloc, 220_500); // 5 s @ 44.1k — heap, not stack defer alloc.free(buf); // pair every alloc with a free
buf[0] = 0.5; std.debug.print("{d} samples, buf[0]={d}\n", .{ buf.len, buf[0] });}The allocators you’ll actually pick between:
DebugAllocator— slow, but tracks every allocation and tattles on leaks and double-frees atdeinit. Use it while learning/testing.std.heap.page_allocator— straight from the OS; coarse, simple.std.heap.ArenaAllocator— allocate freely, free everything at once. Perfect for “build a wavetable, use it, drop it” — no per-object frees:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);defer arena.deinit(); // frees ALL of the below in one shotconst a = arena.allocator();const t1 = try a.alloc(f32, 4096); // no individual free neededconst t2 = try a.alloc(f32, 4096);_ = .{ t1, t2 };std.heap.smp_allocator— fast general-purpose allocator for release builds.
The ownership rule of thumb. Whoever creates it frees it, or hands ownership to a caller (who then frees it). Write the
defer freethe instant you write thealloc, and the two never drift apart. This convention — not a GC — is what keeps Zig programs leak-free.
↳ Zig reference: Memory, Choosing an Allocator; std docs: std.mem.Allocator, std.heap.
12 · comptime (generics, as const, and macros — unified)
Section titled “12 · comptime (generics, as const, and macros — unified)”In TS you reach for three different tools — generics for type parameters, as const for compile-time literals, decorators/codegen for metaprogramming. Zig has one: code marked comptime runs during compilation.
// a compile-time constant: dbGain(-6.0) is computed by the compiler, baked into the binaryfn dbGain(comptime db: f32) f32 { return std.math.pow(f32, 10.0, db / 20.0);}
// "generics" are just functions that take a type as a comptime argument:fn Buffer(comptime T: type, comptime n: usize) type { return [n]T; // returns a TYPE}const StereoFrame = Buffer(f32, 2); // == [2]f32, decided at compile timeTypes are first-class at compile time: std.ArrayList(f32) is literally a function call returning a type, the way TS Array<number> is a generic instantiation — except it’s the same mechanism that does constant-folding and conditional compilation. You won’t write much comptime early on, but knowing “a generic is a comptime function over types” demystifies the standard library.
↳ Zig reference: comptime, Generic Data Structures, Compile-Time Parameters.
Part III — Make it beep
Section titled “Part III — Make it beep”13 · Writing a WAV (raw bytes, the I/O interface)
Section titled “13 · Writing a WAV (raw bytes, the I/O interface)”This is where the low-level view pays off: a .wav file is a 44-byte header plus raw 16-bit samples, and you write each field at a chosen byte order. TS would reach for DataView/Buffer; Zig’s writeInt(T, value, .little) is the same idea — you control the exact bytes. Zig 0.16 makes I/O an explicit io value you thread through. Full 1-second 440 Hz tone — zig run beep.zig, then open beep.wav:
const std = @import("std");const sr: u32 = 44100;
fn writeHeader(w: *std.Io.Writer, data_len: u32) !void { try w.writeAll("RIFF"); // ASCII tag, endian-less try w.writeInt(u32, 36 + data_len, .little); try w.writeAll("WAVE"); try w.writeAll("fmt "); try w.writeInt(u32, 16, .little); // fmt chunk size try w.writeInt(u16, 1, .little); // PCM try w.writeInt(u16, 1, .little); // mono try w.writeInt(u32, sr, .little); try w.writeInt(u32, sr * 2, .little); // byte rate try w.writeInt(u16, 2, .little); // block align try w.writeInt(u16, 16, .little); // bits/sample try w.writeAll("data"); try w.writeInt(u32, data_len, .little);}
pub fn main() !void { var dbg: std.heap.DebugAllocator(.{}) = .init; defer _ = dbg.deinit(); var threaded: std.Io.Threaded = .init(dbg.allocator(), .{}); defer threaded.deinit(); const io = threaded.io();
var file = try std.Io.Dir.cwd().createFile(io, "beep.wav", .{}); defer file.close(io); var wbuf: [4096]u8 = undefined; // the writer's buffer (stack) var fw = file.writer(io, &wbuf); const w = &fw.interface; // a *std.Io.Writer
const n: u32 = sr; // one second try writeHeader(w, n * 2);
var i: u32 = 0; while (i < n) : (i += 1) { const t = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(sr)); const s = std.math.sin(2.0 * std.math.pi * 440.0 * t) * 0.3; // 0.3 = headroom try w.writeInt(i16, @intFromFloat(s * 32767.0), .little); } try w.flush(); // ← buffered bytes reach disk only here. Forgetting = empty file.}Turn the volume down before playing. The buffered-writer +
flush()pattern is the one thing TS hides (aWriteStreamflushes itself); here it’s explicit, and forgettingflush()is the classic first bug.asBytes/endianness become real because there’s no runtime serializing for you.
↳ Zig reference: @floatFromInt, @intFromFloat, @as; std docs: std.Io, std.mem.asBytes.
14 · Bonus toys
Section titled “14 · Bonus toys”Bytebeat — wrapping math as an instrument. Unsigned overflow is opt-in (*%, +%); that wraparound is the chiptune sound:
var t: usize = 0;while (t < 8000) : (t += 1) { const x: u8 = @truncate(t *% 5 & t >> 7 | t *% 3 & t >> 10); // write x as one byte of 8-bit audio}Threads & atomics — and yes, this maps to the web. Zig threads share memory directly; std.atomic.Value is the same concept as JS Atomics over a SharedArrayBuffer between Web Workers — just without the structured-clone wall:
const std = @import("std");var counter = std.atomic.Value(u32).init(0);fn bump() void { var i: u32 = 0; while (i < 1000) : (i += 1) _ = counter.fetchAdd(1, .monotonic);}pub fn main() !void { const t1 = try std.Thread.spawn(.{}, bump, .{}); const t2 = try std.Thread.spawn(.{}, bump, .{}); t1.join(); t2.join(); std.debug.print("counter = {d}\n", .{counter.load(.monotonic)}); // exactly 2000}That .monotonic is a memory-ordering choice TS never exposes; the ctrl chapter is the deep dive (real-time audio threads, lock-free ring buffers).
↳ Zig reference: Atomics, @atomicRmw, @atomicStore; std docs: std.Thread, std.atomic.Value.
Building & going faster
Section titled “Building & going faster”- Run:
zig run beep.zig(compiles to a temp binary and runs it). - Fast binary:
zig build-exe beep.zig -O ReleaseFast→./beep. SwapDebugAllocatorforstd.heap.smp_allocatorin release. - Safety modes: Debug & ReleaseSafe keep bounds checks, overflow checks, and
undefinedpoisoning; ReleaseFast/ReleaseSmall drop them for speed. (TS has no equivalent — you’re always in “safe.”) - A real project:
zig init→build.zig+src/main.zig, thenzig build run. Not needed here; single files keep the focus on sound.
Cheatsheet (TypeScript → Zig)
Section titled “Cheatsheet (TypeScript → Zig)”| You want… | TypeScript | Zig |
|---|---|---|
| immutable / mutable binding | const / let | const / var |
| share & mutate an object | pass object (by ref) | pass *T, mutate o.field |
| copy a struct | {...obj} | var c = obj; (assignment copies) |
| maybe-absent value | T | null, ?., ?? | ?T, if (x) |v|, orelse |
| address of / dereference | — | &x / p.* |
| fixed buffer / view / growable | Array<T> | [N]T / []T / std.ArrayList(T) |
| int↔float, narrow | implicit | @floatFromInt,@intFromFloat,@truncate,@as |
| wraparound math | (n/a — all f64) | +%, *%, @addWithOverflow |
| throw / catch / finally | throw/try-catch/finally | error.X & !T / catch / defer,errdefer |
| free memory | GC (automatic) | alloc.alloc + defer alloc.free |
| temporary scratch memory | GC | ArenaAllocator (free all at once) |
| generics / compile-time const | <T>, as const | comptime (functions over types) |
| shared-memory concurrency | Workers + Atomics | std.Thread + std.atomic.Value |
| raw bytes / endianness | DataView | writeInt(.., .little), std.mem.asBytes |
| exhaustive switch | never trick | built-in exhaustive switch |
Everything above compiled and ran on Zig 0.16 (Apple Silicon).
Official Zig references
Section titled “Official Zig references”Each section above links to the exact concept; here are the hubs:
- Language Reference (0.16.0) — the single-page spec: https://ziglang.org/documentation/0.16.0/. Jump to Pointers, Slices, Optionals, Errors, Memory, comptime, Atomics, Builtin Functions.
- Standard Library docs (0.16.0) — searchable API: https://ziglang.org/documentation/0.16.0/std/. Audio-relevant: std.Io, std.heap, std.mem, std.math, std.Thread, std.atomic. (This page is rendered by JavaScript — open it in a browser; the search box is the fastest way in.)
- Getting started & downloads — https://ziglang.org/learn/ and https://ziglang.org/download/.
The Language Reference is pinned to 0.16.0 so the links match the code here. For the very latest, swap
0.16.0→masterin any URL.
Where next
Section titled “Where next”You’ve now seen, in miniature, the whole course: stack/heap and the WAV writer (wave), the oscillator struct and pointers (osc i), enums/switch, slices and allocators for wavetables (osc ii), f32 math and mixing (mix), the envelope state machine (adsr), the ring buffer (delay), and atomics/threads/memory-ordering (ctrl).
The GC is gone, but so is the mystery. Go make some noise. → 2 · wave
Want more Zig before the DSP? Part 2 (chapter 1) goes deeper into the type system — packed structs, tagged unions, error sets, iterators, comptime tables, and the test runner — using MIDI, music theory, and a Euclidean sequencer as the playground.