Learn Zig Series (#11) - Mini Project: Building a Step Sequencer

avatar
(Edited)

Learn Zig Series (#11) - Mini Project: Building a Step Sequencer

zig-banner.png

What will I learn

  • You will learn how to combine structs, enums, arrays, error handling, file I/O, and modules into a real program;
  • building a step sequencer -- a grid of notes and timing that plays a pattern in a loop;
  • modeling musical patterns with fixed-size arrays and enums for note types;
  • terminal rendering with ANSI escape codes for a live-updating display;
  • save and load functionality using the file I/O patterns from episode 10;
  • raw terminal input for immediate keypress handling;
  • architecture patterns: separating engine, display, storage, and coordinator into distinct modules;
  • how every concept from episodes 1-10 connects when you build something real.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#11) - Mini Project: Building a Step Sequencer

Welcome back! In episode #10 we learned how to structure a multi-file Zig project with zig init, split code into modules with @import, read and write files with std.fs, handle command-line arguments from std.process.args(), and even link C libraries directly from build.zig. We went from single-file experiments to a proper multi-module program with file I/O. That was the last piece of the foundation.

Ten episodes of focused learning. Now we build something real.

A step sequencer is a classic piece of music tech -- a grid where each column is a beat and each row is a sound. You toggle cells on and off, and the sequencer sweeps through columns left to right, triggering whatever's active on each step. Think of those grid-based drum machines from the 80s, or the sequencer views in modern DAWs like Ableton. It's a perfect mini project because it exercises almost everything we've covered: structs for the data model, enums for note types, arrays for the grid, file I/O for save/load, modules for separation of concerns, error handling for everything that touches the outside world, and even a bit of comptime for default values.

No audio output in this version (that would require linking a C audio library, which is a project in itself). Instead, we'll build the engine, the terminal display, and the pattern editor. The sequencer "plays" by printing which notes fire on each step. The architecture is what matters here -- once the engine works correctly, swapping the print-based output for real audio is just replacing one module.

Here we go!

Solutions to Episode 10 Exercises

Before we start building, here are the solutions to last episode's exercises. If you typed these out and compiled them (and I genuinely hope you did!), compare your approaches:

Exercise 1 -- two-file project:

// src/math_utils.zig
pub fn add(a: i64, b: i64) i64 {
    return a + b;
}

pub fn multiply(a: i64, b: i64) i64 {
    return a * b;
}

pub fn power(base: i64, exp: u32) i64 {
    var result: i64 = 1;
    for (0..exp) |_| {
        result *= base;
    }
    return result;
}

// not pub -- invisible from main.zig
fn internalHelper() void {}
// src/main.zig
const std = @import("std");
const math = @import("math_utils.zig");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("add(3, 4) = {d}\n", .{math.add(3, 4)});
    try stdout.print("multiply(5, 6) = {d}\n", .{math.multiply(5, 6)});
    try stdout.print("power(2, 10) = {d}\n", .{math.power(2, 10)});
    // math.internalHelper(); // compile error: not pub
}

The pub keyword controls the API boundary. Anything without pub stays private to that file -- you get a compile error, not a runtime error.

Exercise 2 -- file stats:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // Write 10 lines
    const file = try std.fs.cwd().createFile("stats_test.txt", .{});
    defer file.close();
    const writer = file.writer();
    for (0..10) |i| {
        try writer.print("Line {d}: some data here\n", .{i + 1});
    }

    // Read back and count
    const read_file = try std.fs.cwd().openFile("stats_test.txt", .{});
    defer read_file.close();
    var buf: [4096]u8 = undefined;
    const n = try read_file.readAll(&buf);
    const content = buf[0..n];

    var lines: u32 = 0;
    for (content) |byte| {
        if (byte == '\n') lines += 1;
    }
    const chars: u32 = @intCast(n);
    const avg = if (lines > 0) chars / lines else 0;
    try stdout.print("Lines: {d}, Chars: {d}, Avg: {d}\n", .{ lines, chars, avg });
}

Counting '\n' gives the line count. readAll works for small files -- for larger ones use readFileAlloc with an allocator.

Exercise 3 -- CLI file reader:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    var args = std.process.args();
    _ = args.next(); // skip program name

    const path = args.next() orelse {
        try stdout.print("Usage: reader <filename>\n", .{});
        return;
    };

    const file = std.fs.cwd().openFile(path, .{}) catch |err| {
        try stdout.print("Error: cannot open '{s}': {}\n", .{ path, err });
        return;
    };
    defer file.close();

    var buf: [4096]u8 = undefined;
    const n = try file.readAll(&buf);
    const content = buf[0..n];

    var line_num: u32 = 1;
    var iter = std.mem.splitScalar(u8, content, '\n');
    while (iter.next()) |line| {
        if (line.len > 0 or iter.peek() != null) {
            try stdout.print("{d:>4}: {s}\n", .{ line_num, line });
            line_num += 1;
        }
    }
}

Three distinct paths: no argument (usage), file not found (error), success (numbered lines). The catch on openFile handles the missing file without crashing.

Exercise 4 -- Tracker module with anytype writer:

// src/tracker.zig
const std = @import("std");

pub const Entry = struct {
    ticker: []const u8,
    value: f64,
};

pub const Tracker = struct {
    entries: [16]Entry = undefined,
    count: usize = 0,

    pub fn addEntry(self: *Tracker, ticker: []const u8, value: f64) void {
        if (self.count < 16) {
            self.entries[self.count] = .{ .ticker = ticker, .value = value };
            self.count += 1;
        }
    }

    pub fn totalValue(self: *const Tracker) f64 {
        var total: f64 = 0;
        for (self.entries[0..self.count]) |e| total += e.value;
        return total;
    }

    pub fn summary(self: *const Tracker, writer: anytype) !void {
        const total = self.totalValue();
        for (self.entries[0..self.count]) |e| {
            const pct = if (total > 0) e.value / total * 100.0 else 0.0;
            try writer.print("{s:>6}: {d:>10.2}  ({d:.1}%)\n", .{ e.ticker, e.value, pct });
        }
        try writer.print("{'':->30}\nTotal: {d:>10.2}\n", .{total});
    }
};

The anytype parameter is the key -- summary works with both std.io.getStdOut().writer() and file.writer(). Same function, any output target.

Exercise 5 -- file copy tool:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var args = std.process.args();
    _ = args.next();
    const src = args.next() orelse {
        try stdout.print("Usage: copy <source> <destination>\n", .{});
        return;
    };
    const dst = args.next() orelse {
        try stdout.print("Error: missing destination argument\n", .{});
        return;
    };

    const content = std.fs.cwd().readFileAlloc(allocator, src, 10 * 1024 * 1024) catch |err| {
        try stdout.print("Error reading '{s}': {}\n", .{ src, err });
        return;
    };
    defer allocator.free(content);

    const out_file = std.fs.cwd().createFile(dst, .{}) catch |err| {
        try stdout.print("Error creating '{s}': {}\n", .{ dst, err });
        return;
    };
    defer out_file.close();

    try out_file.writeAll(content);
    try stdout.print("Copied {d} bytes: {s} -> {s}\n", .{ content.len, src, dst });
}

The defer allocator.free(content) is critical -- the GPA's deinit() will report leaks if you forget it. Three error paths, each with a clear message.

Exercise 6 -- RingBuffer from file:

const std = @import("std");

fn RingBuffer(comptime T: type, comptime cap: usize) type {
    return struct {
        buf: [cap]T = undefined,
        head: usize = 0,
        count: usize = 0,

        fn push(self: *@This(), val: T) void {
            self.buf[(self.head + self.count) % cap] = val;
            if (self.count < cap) {
                self.count += 1;
            } else {
                self.head = (self.head + 1) % cap;
            }
        }

        fn average(self: *const @This()) f64 {
            if (self.count == 0) return 0;
            var sum: f64 = 0;
            for (0..self.count) |i| {
                sum += self.buf[(self.head + i) % cap];
            }
            return sum / @as(f64, @floatFromInt(self.count));
        }
    };
}

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    const file = try std.fs.cwd().openFile("numbers.txt", .{});
    defer file.close();

    var buf: [4096]u8 = undefined;
    const n = try file.readAll(&buf);
    const content = buf[0..n];

    var ring = RingBuffer(f64, 10){};
    var iter = std.mem.splitScalar(u8, content, '\n');
    while (iter.next()) |line| {
        if (line.len == 0) continue;
        const val = std.fmt.parseFloat(f64, line) catch continue;
        ring.push(val);
    }

    try stdout.print("Average of last {d} values: {d:.2}\n", .{ ring.count, ring.average() });
}

The entire ring buffer lives on the stack -- [10]f64 is 80 bytes. No allocator, no heap, no cleanup needed. Push overwrites the oldest value when full. This is the comptime generic pattern from ep009 combined with file I/O from this episode.

Now -- let's build something real!

What We're Building

The program has four parts, each in its own file:

  1. sequencer.zig -- the engine. Holds the pattern grid, the playback position, tempo, and methods to toggle notes, advance steps, and query what's active.
  2. display.zig -- the terminal renderer. Reads the sequencer state and draws the grid with ANSI colors. The playback cursor shows which step is currently active.
  3. storage.zig -- save and load. Writes the pattern to a human-readable text file and reads it back.
  4. main.zig -- the coordinator. Handles keyboard input, dispatches commands, owns the sequencer instance.

Same separation of concerns from ep010's multi-file example, but bigger. The engine doesn't know about the terminal. The display doesn't modify state. Storage doesn't know about the display. Main coordinates.

The Data Model

// src/sequencer.zig
const std = @import("std");

pub const NUM_TRACKS = 4;
pub const NUM_STEPS = 16;

pub const Sound = enum {
    kick,
    snare,
    hihat,
    clap,

    pub fn label(self: Sound) []const u8 {
        return switch (self) {
            .kick => "KICK ",
            .snare => "SNARE",
            .hihat => "HIHAT",
            .clap => "CLAP ",
        };
    }

    pub fn symbol(self: Sound) u8 {
        return switch (self) {
            .kick => 'K',
            .snare => 'S',
            .hihat => 'H',
            .clap => 'C',
        };
    }
};

pub const tracks: [NUM_TRACKS]Sound = .{ .kick, .snare, .hihat, .clap };

pub const Sequencer = struct {
    grid: [NUM_TRACKS][NUM_STEPS]bool = [_][NUM_STEPS]bool{[_]bool{false} ** NUM_STEPS} ** NUM_TRACKS,
    position: u32 = 0,
    playing: bool = false,
    bpm: u32 = 120,

    pub fn toggle(self: *Sequencer, track: usize, step: usize) void {
        if (track >= NUM_TRACKS or step >= NUM_STEPS) return;
        self.grid[track][step] = !self.grid[track][step];
    }

    pub fn advance(self: *Sequencer) void {
        self.position = (self.position + 1) % NUM_STEPS;
    }

    pub fn activeOnStep(self: Sequencer, step: u32) [NUM_TRACKS]bool {
        var result: [NUM_TRACKS]bool = [_]bool{false} ** NUM_TRACKS;
        for (0..NUM_TRACKS) |t| {
            result[t] = self.grid[t][step];
        }
        return result;
    }

    pub fn currentActive(self: Sequencer) [NUM_TRACKS]bool {
        return self.activeOnStep(self.position);
    }

    pub fn clear(self: *Sequencer) void {
        for (0..NUM_TRACKS) |t| {
            for (0..NUM_STEPS) |s| {
                self.grid[t][s] = false;
            }
        }
        self.position = 0;
    }

    pub fn loadPreset(self: *Sequencer) void {
        self.clear();
        // Classic four-on-the-floor kick
        self.grid[0][0] = true;
        self.grid[0][4] = true;
        self.grid[0][8] = true;
        self.grid[0][12] = true;
        // Snare on 2 and 4
        self.grid[1][4] = true;
        self.grid[1][12] = true;
        // Hihat on every other step
        var i: usize = 0;
        while (i < NUM_STEPS) : (i += 2) {
            self.grid[2][i] = true;
        }
        // Clap accents
        self.grid[3][4] = true;
        self.grid[3][12] = true;
    }

    pub fn stepDelayMs(self: Sequencer) u64 {
        // BPM to milliseconds per step (16th notes: 4 steps per beat)
        return @as(u64, 60_000) / @as(u64, self.bpm) / 4;
    }
};

Let me walk through the design decisions, because they map directly to what we've learned:

Enums for sound types -- same pattern from ep006. The Sound enum defines what each track represents. Methods on the enum (label, symbol) give us display strings without a separate lookup table. The switch is exhaustive -- the compiler guarantees we handle every variant. If we add a fifth sound later, every switch statement in the program will produce a compile error until we handle it. That's not a burden, it's a safety net.

A 2D boolean grid -- [NUM_TRACKS][NUM_STEPS]bool. Four tracks, sixteen steps. Each cell is either on or off. This lives entirely on the stack. No allocator needed. The [_]bool{false} ** NUM_STEPS initialization pattern fills every cell with false at compile time (remember ** for array repetition from ep005 and comptime defaults from ep009).

Pointer methods for mutation, value methods for read-only -- toggle takes *Sequencer because it modifies state. currentActive takes Sequencer by value because it only reads. Same convention from ep006 and ep008. The compiler enforces this -- if you accidentally try to modify self in a value method, it won't compile.

loadPreset bakes in a classic drum pattern. Four-on-the-floor kick (every 4th step), snare on the backbeat (steps 4 and 12), hi-hats on every even step, claps doubling the snare. If you've ever used a drum machine, you'll recognize this as the "demo" pattern ;-)

The Display

// src/display.zig
const std = @import("std");
const seq = @import("sequencer.zig");

const ESC = "\x1b[";

pub fn render(s: seq.Sequencer, cursor_track: usize, cursor_step: usize) void {
    std.debug.print("{s}2J{s}H", .{ ESC, ESC }); // clear screen, cursor home
    std.debug.print("=== STEP SEQUENCER === BPM: {d}  {s}\n\n", .{
        s.bpm,
        if (s.playing) "[PLAYING]" else "[STOPPED]",
    });

    // Header row with step numbers
    std.debug.print("       ", .{});
    for (0..seq.NUM_STEPS) |step| {
        if (step == s.position and s.playing) {
            std.debug.print("{s}43m{d:>2} {s}0m", .{ ESC, step + 1, ESC }); // yellow bg
        } else {
            std.debug.print("{d:>2} ", .{step + 1});
        }
    }
    std.debug.print("\n", .{});

    // Track rows
    for (0..seq.NUM_TRACKS) |t| {
        const sound = seq.tracks[t];
        std.debug.print("{s}  ", .{sound.label()});

        for (0..seq.NUM_STEPS) |step| {
            const active = s.grid[t][step];
            const is_cursor = (t == cursor_track and step == cursor_step);
            const is_playhead = (step == s.position and s.playing);

            if (is_cursor) {
                // Cursor position -- bright white background
                if (active) {
                    std.debug.print("{s}97;42m {c} {s}0m", .{ ESC, sound.symbol(), ESC });
                } else {
                    std.debug.print("{s}97;47m . {s}0m", .{ ESC, ESC });
                }
            } else if (active) {
                if (is_playhead) {
                    std.debug.print("{s}1;33m [{c}]{s}0m", .{ ESC, sound.symbol(), ESC });
                } else {
                    std.debug.print("{s}32m [{c}]{s}0m", .{ ESC, sound.symbol(), ESC });
                }
            } else {
                if (is_playhead) {
                    std.debug.print("{s}90m . {s}0m", .{ ESC, ESC });
                } else {
                    std.debug.print(" . ", .{});
                }
            }
        }
        std.debug.print("\n", .{});
    }

    // Now-playing indicator
    std.debug.print("\n  Now: ", .{});
    const active = s.currentActive();
    var any = false;
    for (0..seq.NUM_TRACKS) |t| {
        if (active[t]) {
            if (any) std.debug.print(" + ", .{});
            std.debug.print("{s}1m{s}{s}0m", .{ ESC, seq.tracks[t].label(), ESC });
            any = true;
        }
    }
    if (!any) std.debug.print("---", .{});
    std.debug.print("\n", .{});

    // Controls
    std.debug.print("\n  [arrows] move  [space] toggle  [p] play/stop  [+/-] BPM\n", .{});
    std.debug.print("  [l] load preset  [c] clear  [w] save  [r] load  [q] quit\n", .{});
}

pub fn printStep(s: seq.Sequencer) void {
    const active = s.currentActive();
    std.debug.print("  Step {d:>2}: ", .{s.position + 1});
    var any = false;
    for (0..seq.NUM_TRACKS) |t| {
        if (active[t]) {
            if (any) std.debug.print(" + ", .{});
            std.debug.print("{s}", .{seq.tracks[t].label()});
            any = true;
        }
    }
    if (!any) std.debug.print("---", .{});
    std.debug.print("\n", .{});
}

ANSI escape codes again -- same approach as the portfolio display from ep010 but more colourful. Green for active notes, yellow background for the playhead, bright white for the editor cursor. The display module only reads sequencer state -- it never modifies it. Clean separation.

The render function takes the sequencer by value (not pointer) plus the cursor position. This means the display code physically cannot mutate the sequencer. If someone accidentally writes s.position = 0 inside render, the compiler will reject it. The type system enforces our architecture.

One thing worth noting: all those std.debug.print calls write to stderr. For a terminal UI that's fine -- we're writing directly to the terminal, not piping output anywhere. If you wanted to make this a "proper" TUI you'd use stdout with buffered writes. For our purposes, debug.print is simpler and works great.

Save and Load

// src/storage.zig
const std = @import("std");
const seq = @import("sequencer.zig");

pub fn save(s: seq.Sequencer, path: []const u8) !void {
    const file = try std.fs.cwd().createFile(path, .{});
    defer file.close();
    const w = file.writer();

    try w.print("STEPSEQ v1\n", .{});
    try w.print("bpm {d}\n", .{s.bpm});
    try w.print("tracks {d}\n", .{seq.NUM_TRACKS});
    try w.print("steps {d}\n", .{seq.NUM_STEPS});

    for (0..seq.NUM_TRACKS) |t| {
        try w.print("track {c} ", .{seq.tracks[t].symbol()});
        for (0..seq.NUM_STEPS) |step| {
            try w.print("{c}", .{if (s.grid[t][step]) @as(u8, 'X') else @as(u8, '.')});
        }
        try w.print("\n", .{});
    }
}

pub fn load(s: *seq.Sequencer, allocator: std.mem.Allocator, path: []const u8) !void {
    const content = try std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024);
    defer allocator.free(content);

    var lines = std.mem.splitScalar(u8, content, '\n');

    // Check header
    const header = lines.next() orelse return error.InvalidFormat;
    if (!std.mem.eql(u8, header, "STEPSEQ v1")) return error.InvalidFormat;

    // Parse metadata
    while (lines.next()) |line| {
        if (line.len == 0) continue;

        if (std.mem.startsWith(u8, line, "bpm ")) {
            const bpm_str = line[4..];
            s.bpm = std.fmt.parseInt(u32, bpm_str, 10) catch continue;
        } else if (std.mem.startsWith(u8, line, "track ")) {
            if (line.len < 8) continue;
            const sym = line[6];
            const pattern = line[8..];

            // Find which track this symbol belongs to
            var track_idx: ?usize = null;
            for (0..seq.NUM_TRACKS) |t| {
                if (seq.tracks[t].symbol() == sym) {
                    track_idx = t;
                    break;
                }
            }

            if (track_idx) |t| {
                const len = @min(pattern.len, seq.NUM_STEPS);
                for (0..len) |step| {
                    s.grid[t][step] = (pattern[step] == 'X');
                }
            }
        }
    }
}

The saved file looks like this:

STEPSEQ v1
bpm 120
tracks 4
steps 16
track K X...X...X...X...
track S ....X.......X...
track H X.X.X.X.X.X.X.X.
track C ....X.......X...

Human-readable, editable in any text editor, versionable with git. Same philosophy as ep010's portfolio file -- text-based formats are debuggable. If your save file ever looks wrong, you can open it in nano and see exactly what happened. Try doing that with a binary format.

The save function takes Sequencer by value (read-only). The load function takes *Sequencer because it modifies the grid. The allocator parameter follows the same pattern from ep007 -- the caller provides the allocator, the function uses it temporarily for reading the file, and cleans up with defer allocator.free(content). No memory leaks.

Error handling uses try for things that should propagate (file creation, writing) and catch continue for things that should be skipped (malformed lines). Same distinction we've been making since ep004 -- propagate when the caller needs to know, handle locally when recovery is possible.

The Main Loop

// src/main.zig
const std = @import("std");
const seq = @import("sequencer.zig");
const display = @import("display.zig");
const storage = @import("storage.zig");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var sequencer = seq.Sequencer{};
    sequencer.loadPreset();

    var cursor_track: usize = 0;
    var cursor_step: usize = 0;

    const original_termios = try enableRawMode();
    defer disableRawMode(original_termios);

    var running = true;
    while (running) {
        display.render(sequencer, cursor_track, cursor_step);

        var buf: [3]u8 = undefined;
        const n = try std.io.getStdIn().read(&buf);
        if (n == 0) continue;

        if (n == 1) {
            switch (buf[0]) {
                'q' => running = false,
                ' ' => sequencer.toggle(cursor_track, cursor_step),
                'p' => {
                    sequencer.playing = !sequencer.playing;
                    if (sequencer.playing) sequencer.position = 0;
                },
                'n' => {
                    sequencer.advance();
                    display.printStep(sequencer);
                },
                '+', '=' => {
                    if (sequencer.bpm < 300) sequencer.bpm += 5;
                },
                '-' => {
                    if (sequencer.bpm > 40) sequencer.bpm -= 5;
                },
                'l' => sequencer.loadPreset(),
                'c' => sequencer.clear(),
                'w' => {
                    storage.save(sequencer, "pattern.seq") catch |err| {
                        std.debug.print("Save failed: {}\n", .{err});
                    };
                },
                'r' => {
                    storage.load(&sequencer, allocator, "pattern.seq") catch |err| {
                        std.debug.print("Load failed: {}\n", .{err});
                    };
                },
                else => {},
            }
        } else if (n == 3 and buf[0] == 27 and buf[1] == '[') {
            // Arrow key escape sequences
            switch (buf[2]) {
                'A' => { // Up
                    if (cursor_track > 0) cursor_track -= 1;
                },
                'B' => { // Down
                    if (cursor_track < seq.NUM_TRACKS - 1) cursor_track += 1;
                },
                'C' => { // Right
                    if (cursor_step < seq.NUM_STEPS - 1) cursor_step += 1;
                },
                'D' => { // Left
                    if (cursor_step > 0) cursor_step -= 1;
                },
                else => {},
            }
        }
    }

    std.debug.print("\x1b[2J\x1b[H", .{});
    std.debug.print("Sequencer closed.\n", .{});
}

fn enableRawMode() !std.posix.termios {
    const fd = std.io.getStdIn().handle;
    const orig = try std.posix.tcgetattr(fd);
    var raw = orig;
    raw.lflag.ECHO = false;
    raw.lflag.ICANON = false;
    raw.lflag.ISIG = false;
    raw.cc[@intFromEnum(std.posix.V.MIN)] = 1;
    raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
    try std.posix.tcsetattr(fd, .FLUSH, raw);
    return orig;
}

fn disableRawMode(orig: std.posix.termios) void {
    std.posix.tcsetattr(std.io.getStdIn().handle, .FLUSH, orig) catch {};
}

The main loop is straightforward: render, read a keypress, dispatch. Arrow keys for moving the cursor across the grid, space to toggle a cell, p to play/stop, n to manually advance one step (useful for building patterns one step at a time), +/- for tempo, w/r for save/load, l for the preset, c to clear, q to quit.

Raw terminal mode is the same tcgetattr/tcsetattr dance from the portfolio example in ep010. By disabling ECHO and ICANON, we get immediate single-keypress input without waiting for Enter. The defer disableRawMode(original_termios) guarantees we restore the terminal even if the program crashes -- same resource cleanup pattern we've been using with defer since ep004. If we didn't restore the terminal, your shell would be in a broken state after quitting. Not fun.

Arrow key handling -- arrow keys send a 3-byte escape sequence: ESC [ A (up), ESC [ B (down), ESC [ C (right), ESC [ D (left). We read up to 3 bytes at a time and check if it's an escape sequence. If it's a single byte, it's a regular key. If it's 3 bytes starting with 27, '[', it's an arrow key. This is how terminal input works on Unix -- no special library needed.

Notice that main.zig doesn't contain any rendering logic or file format knowledge. It owns the Sequencer, reads input, and calls functions from the other modules. If you wanted to replace the terminal display with a web UI, you'd change display.zig and nothing else. If you wanted to switch from text files to JSON, you'd change storage.zig and nothing else. Main just coordinates.

Every Concept in Action

Let me map out exactly where each episode's concepts show up:

EpisodeWhere it's used
ep002: Variables & Typesconst, var, u32, u8, bool, usize, f64, format strings
ep003: Functions & Control FlowAll methods, switch on keypresses, while loop, if/else
ep004: Error Handlingtry/catch on file ops, defer for cleanup, error unions
ep005: Arrays & Slices2D grid array, string slices in labels, [_]bool{false} ** N
ep006: Structs & EnumsSequencer struct, Sound enum with methods
ep007: AllocatorsGPA in main, allocator passed to storage.load
ep008: Pointers*Sequencer for mutation, *Self methods
ep009: ComptimeDefault array values via **, compile-time enum methods
ep010: Modules & I/OFour-file architecture, @import, file save/load, splitScalar

This is the payoff for building foundations properly. None of the code above should feel mysterious. Every pattern, every keyword, every built-in function has been covered in a previous episode. The only new thing is combining them into a larger program.

The Architecture

Let me be explicit about the design, because this pattern scales to much larger programs:

main.zig (coordinator)
  |-- owns Sequencer instance
  |-- reads keyboard input
  |-- dispatches to modules
  |
  +-- sequencer.zig (engine)
  |     pure data + logic
  |     no I/O, no display
  |
  +-- display.zig (renderer)
  |     reads Sequencer state
  |     writes to terminal
  |     never modifies state
  |
  +-- storage.zig (persistence)
        reads/writes files
        uses Sequencer types
        doesn't know about display

Each module has one job. The dependencies flow in one direction: display.zig and storage.zig both @import("sequencer.zig") for the types, but they don't import each other. main.zig imports all three and wires them together. No circular dependencies. No module importing something that eventually imports it back.

This isn't Zig-specific architecture -- it's good software engineering. But Zig makes it natural. Every file is a module. pub controls what's visible. The compiler catches you if you try to access something private. And because @import is a comptime operation, there's zero runtime overhead to this modularity. The compiled binary is exactly the same as if everything were in one file. You get organizational clearity for free.

Running the Sequencer

The full project structure:

step-sequencer/
  build.zig
  build.zig.zon
  src/
    main.zig
    sequencer.zig
    display.zig
    storage.zig

Build and run:

zig build run

You'll see the grid rendered in your terminal. Arrow keys to move the cursor, space to toggle notes on/off, p to start "playback" (the position marker sweeps through the grid), n to step through manually. Press w to save your pattern to pattern.seq, r to load it back. l loads the demo preset. q to quit.

Try building a pattern by hand: toggle on kick on steps 1, 5, 9, 13 (four-on-the-floor), snare on steps 5 and 13 (backbeat), hihats everywhere. Then step through it with n and watch the "Now:" line show which instruments fire on each beat. That's your sequencer working.

What Stays on the Stack

One thing I want to highlight: the entire program allocates almost nothing on the heap. The Sequencer struct with its 4x16 boolean grid, the cursor position, the BPM -- all on the stack. The only heap allocation happens when storage.load reads a file with readFileAlloc, and that's freed immediately after parsing with defer.

In Python, every boolean in that grid would be a separate heap object. Every string would be a heap object. The grid itself would be a list of lists of heap-allocated booleans. In Zig, it's a flat [4][16]bool = 64 bytes, contigious in memory, zero indirection. When the program exits, there's nothing to garbage collect because there's almost nothing on the heap to begin with.

This isn't premature optimization -- it's the natural result of using value types and fixed-size arrays. We didn't go out of our way to avoid allocation. We just used the simplest data structures that fit the problem, and those happen to live on the stack. That's Zig's design philosophy at work.

Extending the Sequencer

The version above is deliberately minimal. Here are some directions you could take it:

More tracks: Change NUM_TRACKS to 8 and add more sounds to the Sound enum. Because the grid size is based on constants, the compiler will adjust everything automatically. Try it -- the only code you need to change is the enum and the tracks array.

Real audio: Link a C audio library (like portaudio or miniaudio) through build.zig, exactly as we discussed in ep010's C interop section. The engine already tells you which sounds to trigger on each step -- you just need to play the actual samples.

Pattern chaining: Instead of one pattern, have an array of patterns and switch between them. A [4]Sequencer would give you four patterns to chain into a song.

Swing: Instead of perfectly even step timing, alternate between slightly longer and shorter intervals. A single swing: f64 field (0.0 = straight, 0.5 = max swing) and a small modification to stepDelayMs would do it.

The point isn't to build all of these now. The point is that the architecture supports them. Because the modules are cleanly separated, each extension touches one or two files, not the entire codebase. That's the value of good architecture -- it makes future work easier.

Exercises

These exercises build on the step sequencer. You should have the complete project working before attempting them.

  1. Add a fifth track (e.g. cowbell or bass) to the sequencer. This requires changing the Sound enum, the tracks array, and NUM_TRACKS. Verify that the display adjusts automatically.

  2. Add a "swing" feature: a swing field (0.0 to 0.5) on the Sequencer struct, and modify stepDelayMs to return alternating long/short delays. Odd steps should be base_delay * (1.0 + swing) and even steps base_delay * (1.0 - swing). Add keyboard controls to adjust swing up/down.

  3. Add pattern export to a different format: write a function in storage.zig that exports the pattern as a simple ASCII art display (the grid with X and . characters, no metadata headers). The output should be printable -- pipe it to > pattern.txt and you get a human-readable diagram.

  4. Implement undo: keep a "previous grid state" copy in the sequencer. Before every toggle call, copy the current grid to the backup. Add a u key binding that swaps current and backup. One level of undo is enough -- no need for a full history stack.

  5. Add a mute feature per track: a [NUM_TRACKS]bool array for mute state. Muted tracks still show their pattern in the grid but don't appear in the "Now:" display. Add m + arrow key to toggle mute on the current track. Display muted tracks in a dimmer color.

  6. Implement pattern copy: add a second Sequencer as a clipboard. Key y copies the current pattern to the clipboard, key v pastes the clipboard pattern into the current sequencer. This exercises struct assignment -- in Zig, clipboard = sequencer copies all fields by value (remember value semantics from ep006?).

Exercises 1-2 modify the engine. Exercise 3 extends storage. Exercises 4-6 add features that require coordinating multiple modules. All of them practice the architecture patterns from this episode.

What's Ahead

We've now covered all the core language features AND built a real multi-file project that uses them together. That's a solid foundation. But there's more to Zig than what we've seen so far -- testing (Zig has built-in test support, no external framework needed), interfaces via type erasure, generics with comptime parameters, the build system in depth, and quite some patterns for working with the standard library that become important as your programs grow.

Having said that, the biggest takeaway from this episode isn't any specific technique. It's this: the ten concepts we covered in eps 1-10 are enough to build real software. Not toy examples. Not "hello world" variations. Actual programs that read files, process data, render output, and handle errors. Zig's power comes from a small number of orthogonal features that compose well. You don't need to learn 50 different things -- you need to learn 10 things deeply and combine them.

Build something. Anything. A todo list. A file converter. A simple game. A data processor. The best way to internalize these patterns is to use them on a problem you care about. The compiler will teach you the rest ;-)

Thanks for reading, and tot de volgende keer! ;-)

@scipio



0
0
0.000
0 comments