Learn Zig Series (#174) - MIDI Parsing and Generation
What will I learn?
- Why MIDI is just bytes -- a tiny wire protocol where one bit tells you whether you are looking at a command or a number, and how that single bit drives the whole parser;
- How to build a channel-voice parser from scratch, including the sneaky running status trick that lets a stream drop repeated command bytes;
- How Zig's
u4andu7make an illegal MIDI value literally unrepresentable, so "a note number of 200" is a compile-time impossibility in stead of a runtime bug; - How to turn a note number into a frequency and feed parsed notes straight into the mixer from episode 173 -- the moment the data actually makes sound;
- How to generate MIDI (encode messages back to bytes) and read a Standard MIDI File: the
MThd/MTrkchunks, big-endian fields, and the variable-length quantity every delta-time is written in; - How to test all of this with no instrument plugged in, plus the performance shape and where C, Rust and Go land on the very same design.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written against Zig 0.16;
- The
Mixer,Voice,OscillatorandAdsrfrom episodes 171-173, and the PCM buffer from episode 169; - Comfort with tagged unions and enums (episode 6), error unions (episode 4), comptime (episode 9) and testing (episode 12);
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing
- Learn Zig Series (#167) - PNG Decoder in Zig
- Learn Zig Series (#168) - JPEG Decoder Basics
- Learn Zig Series (#169) - Audio Fundamentals: PCM and Buffers
- Learn Zig Series (#170) - Audio Output via C Interop
- Learn Zig Series (#171) - Synthesis: Oscillators
- Learn Zig Series (#172) - Synthesis: Envelopes and Filters
- Learn Zig Series (#173) - Audio Mixing
- Learn Zig Series (#174) - MIDI Parsing and Generation (this post)
Learn Zig Series (#174) - MIDI Parsing and Generation
Last episode we built a mixer, and I ended on a promise: a chord you press by hand is still you pressing keys. The moment you want the machine to play itself -- notes arriving on a clock, from a keyboard, from a file, from another instrument across the room -- you need a way to describe "this note, this loud, right now" as data that flows between devices. That language exists, it has existed since 1983, and every synthesizer, DAW, drum machine and cheap USB keyboard on earth speaks it. It is MIDI, and it is astonishingly small: a note-on is three bytes. Today we parse it, we generate it, we read it out of a .mid file, and we wire it straight into the mixer so the data we decode actually rings out of the speaker. Here we go!
But first, the three loose ends from the mixing episode.
Solutions to Episode 173 Exercises
Exercise 1 -- per-voice gain and stereo pan. A pan is just two gains, one per channel. The naive way (left = 1 - pan, right = pan) has an ugly flaw: a voice panned dead centre comes out -3 dB quieter than the same voice hard left, because 0.5 + 0.5 is less power than 1.0 + 0.0. The fix is the constant-power law: map the pan onto an angle from 0 to pi/2 and take cos for the left gain and sin for the right. Since cos^2 + sin^2 == 1 for every angle, the total power is constant as the voice sweeps across the field:
const std = @import("std");
pub const PannedVoice = struct {
voice: Voice,
gain: f32 = 1.0,
pan: f32 = 0.0, // -1 = full left, 0 = centre, +1 = full right
/// Constant-power pan: cos/sin of an angle in 0..pi/2.
fn panGains(self: PannedVoice) struct { left: f32, right: f32 } {
const theta = (self.pan + 1.0) * 0.25 * std.math.pi; // -1..1 -> 0..pi/2
return .{ .left = @cos(theta), .right = @sin(theta) };
}
};
/// Fill an interleaved stereo buffer: [L0, R0, L1, R1, ...] (episode 169).
pub fn renderStereo(voices: []PannedVoice, out: []f32) void {
std.debug.assert(out.len % 2 == 0);
var i: usize = 0;
while (i < out.len) : (i += 2) {
var left: f32 = 0.0;
var right: f32 = 0.0;
for (voices) |*pv| {
const s = pv.voice.next() * pv.gain;
const g = pv.panGains();
left += s * g.left;
right += s * g.right;
}
out[i] = left;
out[i + 1] = right;
}
}
test "a centred voice puts equal energy in both channels" {
const pv = PannedVoice{ .voice = undefined, .pan = 0.0 };
const g = pv.panGains();
try std.testing.expectApproxEqAbs(g.left, g.right, 1e-6);
// constant power: the two gains square-sum to 1 at every pan position
try std.testing.expectApproxEqAbs(@as(f32, 1.0), g.left * g.left + g.right * g.right, 1e-6);
}
The key insight: pan is not "how much left minus how much right" -- it is an angle, and the trig keeps the loudness steady while the balance shifts.
Exercise 2 -- voice stealing. When the pool is full and a new note arrives, a musician never wants silence -- they want the new note. So in stead of returning PolyphonyExhausted, we steal the quietest voice (the one with the lowest current envelope level, closest to inaudible) and restart it. The player never hears the theft because the stolen voice was already on its way out:
const std = @import("std");
pub fn StealingMixer(comptime max_voices: usize) type {
return struct {
const Self = @This();
voices: [max_voices]Voice = undefined,
active: [max_voices]bool = [_]bool{false} ** max_voices,
/// Never fails: reuse a free slot, or steal the quietest active one.
pub fn noteOn(self: *Self, osc: Oscillator, env: Adsr) usize {
for (&self.active, 0..) |*busy, i| {
if (!busy.*) {
self.voices[i] = .{ .osc = osc, .env = env };
self.voices[i].noteOn();
busy.* = true;
return i;
}
}
// pool full: find the slot with the lowest current envelope level
var quietest: usize = 0;
var lowest: f32 = std.math.floatMax(f32);
for (&self.voices, 0..) |*v, i| {
if (v.env.level < lowest) {
lowest = v.env.level;
quietest = i;
}
}
self.voices[quietest] = .{ .osc = osc, .env = env };
self.voices[quietest].noteOn();
return quietest;
}
};
}
test "a full mixer steals the quietest slot for a new note" {
var mix = StealingMixer(2){};
const osc = Oscillator.init(440.0, 48_000);
const loud = Adsr.init(0.001, 0.1, 0.9, 0.2, 48_000);
const soft = Adsr.init(0.001, 0.1, 0.1, 0.2, 48_000);
_ = mix.noteOn(osc, loud);
const b = mix.noteOn(osc, soft);
for (0..3000) |_| { _ = mix.voices[0].next(); _ = mix.voices[1].next(); } // settle
const c = mix.noteOn(osc, loud); // pool full -> steal
try std.testing.expectEqual(b, c); // the soft (quietest) slot was reused
}
Nota bene: a real engine tracks age too, so it does not steal a note the player is still holding at full sustain. Lowest-level is a fine first heuristic and dead simple to reason about.
Exercise 3 -- a block-based soft-clip limiter. Scan each rendered block for its peak; if the peak is over 1.0, compute the gain that would bring it exactly to 1.0 and apply it. The trick that keeps it clean is fast attack, slow release: when we need to reduce gain we do it instantly across the whole block (so nothing escapes the rails), but when we relax we ramp the gain back up over the block so the change is not audible as a click:
const std = @import("std");
/// Fast-attack, slow-release block limiter. `prev_gain` carries state between
/// blocks so the release ramp is continuous.
pub fn limitBlock(block: []f32, prev_gain: *f32) void {
var peak: f32 = 0.0;
for (block) |s| peak = @max(peak, @abs(s));
const target: f32 = if (peak > 1.0) 1.0 / peak else 1.0;
if (target < prev_gain.*) {
// attack: clamp down instantly so no sample can exceed the rails
for (block) |*s| s.* *= target;
} else {
// release: ramp smoothly from the old gain back up toward 1.0
const start = prev_gain.*;
const n = @as(f32, @floatFromInt(block.len));
for (block, 0..) |*s, i| {
const t = @as(f32, @floatFromInt(i)) / n;
s.* *= start + (target - start) * t;
}
}
prev_gain.* = target;
}
test "no sample survives above 1.0 in an overdriven block" {
var block: [64]f32 = undefined;
for (&block, 0..) |*s, i| s.* = if (i % 2 == 0) @as(f32, 2.5) else -2.5;
var gain: f32 = 1.0;
limitBlock(&block, &gain);
for (block) |s| try std.testing.expect(@abs(s) <= 1.0 + 1e-6);
}
The insight: a limiter that snaps up clicks, and a limiter that snaps down too slowly clips. Reducing fast and recovering slowly is exactly how hardware limiters behave, and it is why they sound transparent. Right -- three loose ends tied. Now let us make the machine play itself.
MIDI is bytes, and one bit rules them all
Here is the whole mental model, and it really is this small. A MIDI stream is a sequence of bytes, and each byte is one of two kinds, decided by its high bit. If bit 7 is set (0x80-0xFF), it is a status byte -- a command, "here comes a note-on". If bit 7 is clear (0x00-0x7F), it is a data byte -- a number, 0..127. That single bit is the entire framing mechanism. No lengths, no delimiters, no escape sequences -- just "is the top bit set?".
A status byte packs two things into its low nibble and high nibble: the high nibble is the message type (0x9 = note-on, 0x8 = note-off, 0xB = control change, and so on), and the low nibble is the channel, 0..15, so one wire can carry sixteen independent instruments. A note-on is therefore 0x90 | channel, then a note number 0..127, then a velocity 0..127. Three bytes. Middle C struck firmly on channel 1 is 0x90, 60, 100. That is it -- that is the message that has been triggering synthesizers for forty years.
Now watch what Zig lets us do that almost no other language does. A data byte is seven bits, always. A channel is four bits, always. So we do not model them as u8 and hope -- we model them as u7 and u4, and an out-of-range value becomes a thing the type system will not even let us construct:
const std = @import("std");
/// A parsed channel-voice message. The wire format is 1-3 bytes; this is the
/// friendly, exhaustive version the rest of your synth actually reads.
pub const Message = union(enum) {
note_on: struct { channel: u4, note: u7, velocity: u7 },
note_off: struct { channel: u4, note: u7, velocity: u7 },
control_change: struct { channel: u4, controller: u7, value: u7 },
program_change: struct { channel: u4, program: u7 },
pitch_bend: struct { channel: u4, value: i16 }, // -8192..8191, 0 = centre
};
A note: u7 cannot hold 200. It cannot hold 128. The largest value it can represent is 127, which is exactly the largest legal MIDI note. We have made the illegal state unrepresentable, at zero runtime cost -- the same philosophy behind the typed PolyphonyExhausted error from last episode, pushed all the way down to the individual field.
Parsing a channel-voice message (and running status)
Now the parser. Walk the bytes, and for each message: read the status byte, split it into type and channel, then read the right number of data bytes for that type. Most messages want two data bytes; program-change wants one. The one wrinkle that trips up every first-time MIDI parser is running status: to save bandwidth, a stream is allowed to omit the status byte when it is the same as the previous message. Play a fast run of notes on one channel and the keyboard sends 0x90, 60, 100, 62, 100, 64, 100, ... -- the 0x90 appears once, and every following pair of data bytes reuses it. So our parser must remember the last status byte and fall back to it when it sees a data byte where a status byte was expected:
pub const ParseError = error{ UnexpectedDataByte, TruncatedMessage, UnsupportedStatus };
pub const Parser = struct {
running_status: ?u8 = null,
/// Decode one message from the front of `bytes`; return it plus bytes used.
pub fn next(self: *Parser, bytes: []const u8) ParseError!struct { msg: Message, len: usize } {
if (bytes.len == 0) return error.TruncatedMessage;
var i: usize = 0;
var status: u8 = undefined;
if (bytes[0] & 0x80 != 0) {
status = bytes[0]; // real status byte present
self.running_status = status;
i = 1;
} else {
// running status: no command byte, reuse the last one
status = self.running_status orelse return error.UnexpectedDataByte;
}
const kind: u4 = @intCast(status >> 4);
const channel: u4 = @intCast(status & 0x0F);
const D = struct {
fn read(b: []const u8, idx: usize) ParseError!u7 {
if (idx >= b.len) return error.TruncatedMessage;
if (b[idx] & 0x80 != 0) return error.UnexpectedDataByte;
return @intCast(b[idx]);
}
};
switch (kind) {
0x8 => return .{ .msg = .{ .note_off = .{
.channel = channel, .note = try D.read(bytes, i), .velocity = try D.read(bytes, i + 1),
} }, .len = i + 2 },
0x9 => {
const note = try D.read(bytes, i);
const vel = try D.read(bytes, i + 1);
// note-on with velocity 0 is, by spec, a note-off
const msg: Message = if (vel == 0)
.{ .note_off = .{ .channel = channel, .note = note, .velocity = 0 } }
else
.{ .note_on = .{ .channel = channel, .note = note, .velocity = vel } };
return .{ .msg = msg, .len = i + 2 };
},
0xB => return .{ .msg = .{ .control_change = .{
.channel = channel, .controller = try D.read(bytes, i), .value = try D.read(bytes, i + 1),
} }, .len = i + 2 },
0xC => return .{ .msg = .{ .program_change = .{
.channel = channel, .program = try D.read(bytes, i),
} }, .len = i + 1 },
0xE => {
const lsb: u16 = try D.read(bytes, i);
const msb: u16 = try D.read(bytes, i + 1);
const raw: i32 = @as(i32, @intCast((msb << 7) | lsb)) - 8192;
return .{ .msg = .{ .pitch_bend = .{ .channel = channel, .value = @intCast(raw) } }, .len = i + 2 };
},
else => return error.UnsupportedStatus,
}
}
};
Two things earn their keep here. First, that note-on-with-velocity-zero rule is real and famous -- keyboards send it so they can lean on running status even harder (a whole song of note-ons and note-offs shares a single 0x90). Getting it wrong means notes that never stop, the classic "stuck note" bug. Second, every ragged edge is a typed error, not a silent misread: a truncated stream, a data byte where a command belonged, an unsupported status -- each is a value the caller must handle. Pitch-bend, notice, reassembles two 7-bit halves into a 14-bit number and re-centres it around zero, so 0 means "no bend".
From note number to frequency, into the mixer
A note number is not a frequency -- it is a piano key, 0..127. The bridge is one line of maths that every synth burns into muscle memory: note 69 is A4, the 440 Hz tuning anchor, and every twelve semitones (one octave) doubles the frequency. So the frequency of any note is 440 * 2^((note - 69) / 12):
const std = @import("std");
/// MIDI note number -> frequency in Hz. Note 69 (A4) anchors at 440 Hz,
/// and each octave (12 semitones) doubles the pitch.
pub fn noteToFreq(note: u7) f32 {
const n = @as(f32, @floatFromInt(@as(i32, note) - 69));
return 440.0 * std.math.pow(f32, 2.0, n / 12.0);
}
/// Velocity 1..127 -> a 0..1 gain. Linear is fine to start; many synths
/// square it so soft playing gets even softer (a more musical response).
pub fn velocityToGain(velocity: u7) f32 {
return @as(f32, @floatFromInt(velocity)) / 127.0;
}
test "A4 is 440 Hz, the octave above is 880, middle C is ~261.6" {
try std.testing.expectApproxEqAbs(@as(f32, 440.0), noteToFreq(69), 1e-3);
try std.testing.expectApproxEqAbs(@as(f32, 880.0), noteToFreq(81), 1e-2);
try std.testing.expectApproxEqAbs(@as(f32, 261.626), noteToFreq(60), 1e-1);
}
And now the satisfying part -- the whole audio arc snapping together. A parsed Message becomes sound by handing it to last episode's mixer. Note-on spins up an oscillator at the note's frequency, keyed to velocity; the rest of the message types live above the mixer (control-change might move a filter cutoff, program-change might swap the patch):
/// The bridge between "notes as data" and "notes as sound".
pub fn dispatch(mixer: *Mixer(16), msg: Message, sample_rate: u32) void {
switch (msg) {
.note_on => |n| {
const osc = Oscillator.init(noteToFreq(n.note), sample_rate);
const sustain = velocityToGain(n.velocity) * 0.7;
const env = Adsr.init(0.005, 0.1, sustain, 0.3, sample_rate);
_ = mixer.noteOn(osc, env) catch {}; // full pool: drop the note for now
},
.note_off => {}, // a real engine maps note -> voice index to release the right one
else => {}, // CC / program change / pitch bend handled above the mixer
}
}
That catch {} is a placeholder -- exercise 2 above already showed the grown-up answer (steal a voice in stead of dropping). The point is that the seam between the protocol layer and the DSP layer is one function, and everything on each side of it stays honest about its own concerns.
Generating MIDI: encoding messages back to bytes
Parsing and generating are the same table read backwards. To emit a message we write its status byte (type nibble OR channel) and then its data bytes. No allocation needed -- the caller hands us a three-byte scratch buffer and we return the slice we filled:
const std = @import("std");
/// Serialise a channel-voice message to its 1-3 wire bytes, into `buf`.
pub fn encode(msg: Message, buf: *[3]u8) []u8 {
switch (msg) {
.note_on => |n| { buf.* = .{ 0x90 | @as(u8, n.channel), n.note, n.velocity }; return buf[0..3]; },
.note_off => |n| { buf.* = .{ 0x80 | @as(u8, n.channel), n.note, n.velocity }; return buf[0..3]; },
.control_change => |c| { buf.* = .{ 0xB0 | @as(u8, c.channel), c.controller, c.value }; return buf[0..3]; },
.program_change => |p| { buf[0] = 0xC0 | @as(u8, p.channel); buf[1] = p.program; return buf[0..2]; },
.pitch_bend => |p| {
const raw: u14 = @intCast(@as(i32, p.value) + 8192);
buf.* = .{ 0xE0 | @as(u8, p.channel), @intCast(raw & 0x7F), @intCast(raw >> 7) };
return buf[0..3];
},
}
}
test "encode then parse round-trips a note-on" {
const original = Message{ .note_on = .{ .channel = 3, .note = 60, .velocity = 100 } };
var buf: [3]u8 = undefined;
const wire = encode(original, &buf);
try std.testing.expectEqual(@as(usize, 3), wire.len);
var parser = Parser{};
const parsed = try parser.next(wire);
try std.testing.expectEqual(@as(u7, 60), parsed.msg.note_on.note);
try std.testing.expectEqual(@as(u4, 3), parsed.msg.note_on.channel);
}
A round-trip test like that one is the cheapest, highest-value test you can write for any serializer: encode, decode, assert you got the original back. It catches endianness slips, off-by-one lengths, and forgotten fields all at once. This is the same discipline episode 90 and 91 leaned on for Protocol Buffers and MessagePack -- the format changes, the round-trip test does not.
Standard MIDI Files: chunks and the variable-length quantity
A live MIDI stream has no notion of time -- events happen when they happen. A Standard MIDI File (.mid) adds time back, and it is built from chunks, exactly like the PNG and RIFF/WAV formats we met in episodes 167 and 169. A header chunk MThd (format, track count, and the division = ticks per quarter note), then one or more track chunks MTrk, each a list of events. Every multi-byte number in the file is big-endian, and every event is prefixed with a delta-time: how many ticks to wait since the previous event.
Those delta-times could be huge or tiny, so MIDI stores them as a variable-length quantity (VLQ): seven bits of value per byte, with the high bit meaning "another byte follows", big-endian. A delta of 0 is one byte; a delta of 0x0FFFFFFF is four. This is the same idea as protobuf's varint from episode 90, just big-endian in stead of little:
const std = @import("std");
/// Read a MIDI variable-length quantity (7 bits/byte, high bit = "more").
pub fn readVarLen(bytes: []const u8) !struct { value: u32, len: usize } {
var value: u32 = 0;
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
value = (value << 7) | (bytes[i] & 0x7F);
if (bytes[i] & 0x80 == 0) return .{ .value = value, .len = i + 1 };
if (i >= 3) return error.VarLenTooLong; // max 4 bytes = 28 bits
}
return error.TruncatedVarLen;
}
/// Write a value (<= 0x0FFFFFFF) as a VLQ into `buf`, returning the slice used.
pub fn writeVarLen(value: u32, buf: *[4]u8) []u8 {
var v = value;
var tmp: [4]u8 = undefined;
var n: usize = 0;
tmp[n] = @intCast(v & 0x7F); // low 7 bits, no continuation flag
n += 1;
v >>= 7;
while (v != 0) : (v >>= 7) {
tmp[n] = @intCast((v & 0x7F) | 0x80); // higher groups get the flag
n += 1;
}
for (0..n) |k| buf[k] = tmp[n - 1 - k]; // emit most-significant group first
return buf[0..n];
}
test "varlen round-trips the classic spec examples" {
const cases = [_]u32{ 0, 0x40, 0x7F, 0x80, 0x2000, 0x3FFF, 0x0010_0000, 0x0FFF_FFFF };
for (cases) |c| {
var buf: [4]u8 = undefined;
const encoded = writeVarLen(c, &buf);
const decoded = try readVarLen(encoded);
try std.testing.expectEqual(c, decoded.value);
try std.testing.expectEqual(encoded.len, decoded.len);
}
}
Those eight test values are lifted straight from the SMF spec's own VLQ table -- 0x80 is the first value that needs two bytes, 0x3FFF is the last that fits in two, and so on. If your VLQ handles those, it handles every delta-time you will ever meet.
Reading a track: meta events and tempo
Inside a track, each event is a delta-time followed by either a channel-voice message (running status applies here too, which is why the Parser is shared) or a meta event: 0xFF, a type byte, a VLQ length, then that many bytes. The two meta events you cannot ignore are tempo (0x51, three bytes of microseconds-per-quarter-note) and end-of-track (0x2F, zero bytes). Here is one event, decoded:
const std = @import("std");
pub const TrackEvent = union(enum) {
midi: Message,
tempo: u32, // microseconds per quarter note (500000 == 120 BPM)
end_of_track,
other_meta: struct { kind: u8, len: usize },
};
/// Read one delta-timed event from a track body.
pub fn readEvent(bytes: []const u8, parser: *Parser) !struct { delta: u32, event: TrackEvent, len: usize } {
const dt = try readVarLen(bytes);
const i = dt.len;
if (i >= bytes.len) return error.TruncatedEvent;
if (bytes[i] == 0xFF) {
const meta_type = bytes[i + 1];
const ml = try readVarLen(bytes[i + 2 ..]);
const data_start = i + 2 + ml.len;
const total = data_start + @as(usize, ml.value);
if (total > bytes.len) return error.TruncatedEvent;
const event: TrackEvent = switch (meta_type) {
0x51 => .{ .tempo = (@as(u32, bytes[data_start]) << 16) |
(@as(u32, bytes[data_start + 1]) << 8) |
@as(u32, bytes[data_start + 2]) },
0x2F => .end_of_track,
else => .{ .other_meta = .{ .kind = meta_type, .len = ml.value } },
};
return .{ .delta = dt.value, .event = event, .len = total };
} else {
const parsed = try parser.next(bytes[i..]); // channel-voice, running status ok
return .{ .delta = dt.value, .event = .{ .midi = parsed.msg }, .len = i + parsed.len };
}
}
Tempo is where files get their wall-clock timing. The division says "there are N ticks per quarter note"; the tempo says "a quarter note lasts M microseconds". Multiply through and one tick is M / N microseconds. A player accumulates delta-times into a running tick count, and whenever the tempo meta event changes M, the seconds-per-tick changes with it. That is the entire clock of a MIDI sequencer, and it is why a .mid file is measured in kilobytes where the rendered audio is megabytes -- the file stores the intent, not the sound.
Where Zig's design quietly pays off
MIDI is a binary format full of little traps, and this is precisely the terrain where Zig's choices stop being aesthetic and start saving you debugging hours. The u4/u7 fields mean a malformed value cannot even be built -- the "note 200" bug is a compile error, not a mystery three hours in. The union(enum) gives an exhaustive switch: add a message type and forget to handle it, and the compiler names the file and line, so a half-finished parser does not silently drop pitch-bends. Typed errors turn every ragged edge of a hostile file (truncated VLQ, over-long VLQ, meta length past the buffer) into a value the caller must address, in stead of a -1 or a segfault when someone feeds you a corrupt .mid. And because none of this needs a heap, the same parser runs unchanged on a workstation or on a microcontroller reading MIDI off a serial pin -- no allocator, no runtime, nothing to strip out.
Testing with no instrument plugged in
Like the oscillator, the envelope and the mixer before it, every piece here is a pure function of bytes -- so we test the numbers and never open a MIDI device. The properties are concrete and cheap: a known three bytes parse to the expected message, running status decodes a second note without a status byte, a note-on at velocity zero becomes a note-off, and a truncated buffer is a typed error and not a crash:
const std = @import("std");
test "running status decodes a second note with no status byte" {
var p = Parser{};
const stream = [_]u8{ 0x90, 60, 100, 62, 100 }; // one 0x90, two note pairs
const first = try p.next(&stream);
try std.testing.expectEqual(@as(u7, 60), first.msg.note_on.note);
const second = try p.next(stream[first.len..]); // no 0x90 here!
try std.testing.expectEqual(@as(u7, 62), second.msg.note_on.note);
}
test "note-on velocity 0 is a note-off, and truncation is a typed error" {
var p = Parser{};
const off = try p.next(&[_]u8{ 0x90, 64, 0 });
try std.testing.expect(off.msg == .note_off);
try std.testing.expectError(error.TruncatedMessage, p.next(&[_]u8{0x90}));
}
These are the bugs people actually ship in MIDI code: a parser that cannot handle running status (so half the notes in a real file vanish), a stuck-note bug from mishandling velocity-zero, and a crash on the first corrupt file a user drags in. All three are caught here, on the CPU, in microseconds, with no hardware and nobody listening.
Performance and the real-world shape
MIDI parsing is almost never a bottleneck -- the data rates are tiny (the original spec runs at 31,250 baud, kilobytes per second, not mega). The performance rule is simply do not allocate on the hot path: our parser writes into a caller buffer and returns slices, so a real-time input handler reading from a USB device does zero heap work per message and can run right next to the audio callback without risking a GC-style pause (the exact hazard we engineered around in episode 170). Where it matters, it matters for latency, not throughput -- a note struck on a controller should reach the mixer in under a handful of milliseconds, so you parse eagerly and dispatch immediately in stead of buffering.
The shape scales without changing. A USB-MIDI keyboard hands your input thread the same three bytes we parsed by hand. A DAW loads a .mid file, walks every track with exactly this event loop, and schedules the messages against its transport clock. A hardware sequencer or a Eurorack module runs the identical decode on a microcontroller with no operating system at all. The protocol is the same forty-year-old handful of bytes at every scale; only the clock and the number of tracks grow.
The same job in C, Rust and Go
The bit-twiddling is identical everywhere -- test the high bit, split the nibbles, read the data bytes. What differs is how much the language helps you keep an illegal value out of a field, and how loudly it complains when you forget a case. In C, a MIDI message is a struct of uint8_t, and nothing stops a note of 200; you mask & 0x7F every single time by hand, and running status is a manual global you hope you updated:
// C: the masks and the running-status global are yours to remember, forever.
typedef struct { uint8_t status, note, velocity; } midi_msg;
int parse_note_on(const uint8_t* b, size_t n, midi_msg* out) {
if (n < 3 || (b[0] & 0xF0) != 0x90) return -1; // caller must check the -1
out->status = b[0];
out->note = b[1] & 0x7F; // forget this mask and a bad byte slips in
out->velocity = b[2] & 0x7F;
return 3;
}
Rust is very close to the Zig design: an enum with data, an exhaustive match, and errors as Result. What it lacks is Zig's u4/u7 -- a note: u8 can still hold 200, so Rust pushes that check to a runtime TryFrom in stead of making it unrepresentable:
// Rust: enum + exhaustive match + Result, but note is still a u8 that can be 200.
enum Message {
NoteOn { channel: u8, note: u8, velocity: u8 },
NoteOff { channel: u8, note: u8, velocity: u8 },
}
fn parse(b: &[u8]) -> Result<Message, ParseError> {
match b.first().map(|s| s >> 4) {
Some(0x9) => Ok(Message::NoteOn { channel: b[0] & 0xF, note: b[1], velocity: b[2] }),
_ => Err(ParseError::Unsupported),
}
}
Go writes cleanly, but the tagged union is faked with a Kind byte (or an interface with a type assertion), so exhaustiveness is a convention you enforce by discipline, not a compiler check, and every data-byte range check is hand-written:
// Go: readable, but no real sum type -- the "kind" tag and range checks are manual.
type Message struct{ Kind, Channel, Note, Velocity byte }
func Parse(b []byte) (Message, error) {
if len(b) < 3 {
return Message{}, errors.New("truncated")
}
return Message{Kind: b[0] >> 4, Channel: b[0] & 0xF, Note: b[1] & 0x7F, Velocity: b[2] & 0x7F}, nil
}
Where does Zig land? The parse loop is as blunt and allocation-free as the C, the sum type and exhaustive switch are as safe as the Rust, and the u4/u7 fields go one step further than both -- the range check is not a runtime TryFrom, it is a property of the type. That is the trade this whole systems-programming series keeps making concrete: C's directness and Rust's safety, without having to choose.
Exercises
A tiny sequencer. Build a struct that holds a fixed array of
(delta_ticks, Message)pairs and a division (ticks per quarter note). Given a tempo in microseconds-per-quarter-note, write arendermethod that walks the events, converts each delta into a sample count (ticks * usPerTick * sampleRate / 1e6), and callsdispatchinto aMixer(16)at the right sample offset -- rendering a whole little tune to a PCM buffer with no hardware. Add a test that a two-note sequence produces sound in both expected time windows and silence between.Write a real
.midfile. UsingencodeandwriteVarLen, emit a valid single-track (format 0) Standard MIDI File to a[]u8: anMThdheader, anMTrkchunk (remember its length is a big-endianu32you can only fill in after writing the events), a tempo meta event, a handful of delta-timed note-on/note-off pairs, and the mandatory end-of-track meta event. Open it in any DAW or media player to confirm it plays. Add a test that parsing your own output withreadEventrecovers every note.Handle the messages we skipped. Extend the
ParserandMessageunion with polyphonic aftertouch (0xA, two data bytes) and channel aftertouch (0xD, one data byte), and extendreadEventto recognise the SysEx boundary bytes (0xF0..0xF7) well enough to skip a system-exclusive block without choking. Add tests that each new message decodes correctly and that a SysEx blob in the middle of a track does not derail the events after it.
What we learned
- MIDI is bytes, framed by one bit: bit 7 set means a status byte (a command plus a 4-bit channel), bit 7 clear means a 7-bit data value
0..127-- that single rule is the entire framing of the protocol; - Running status lets a stream drop repeated command bytes, so a real parser must remember the last status and fall back to it -- and note-on at velocity zero is a note-off, the rule that prevents stuck notes;
- Zig's
u4andu7make an illegal MIDI value unrepresentable, theunion(enum)gives an exhaustive switch that will not let you forget a message type, and typed errors turn every ragged edge of a corrupt file into a value you must handle; - A note number becomes a frequency by
440 * 2^((note-69)/12), and feeding parsed messages into episode 173's mixer is the moment the data finally makes sound -- onedispatchfunction bridges protocol and DSP; - Generating MIDI is the parse table run backwards, and a round-trip test (encode, decode, assert equal) is the cheapest way to trust a serializer;
- A Standard MIDI File is chunks (
MThd,MTrk), big-endian fields, and delta-times stored as variable-length quantities; tempo (microseconds per quarter note) plus division (ticks per quarter) gives you the wall-clock, which is why a.midis kilobytes where the audio is megabytes.
And that closes the loop on the whole audio arc. We started with raw PCM samples, grew an oscillator, shaped it with an envelope and a filter, summed many voices in a mixer, and now the machine can play itself from data -- live off a keyboard or read out of a file -- with not a single library doing the interesting part. An oscillator, an envelope, a filter, a mixer, and a protocol: that is a real, playable, programmable instrument, built from first principles. From here we take everything these systems episodes have drilled -- pixels and buffers, parsers and state, testing the numbers -- and point it at a bigger, hands-on build. Plenty still to make, and it only gets more fun from here.
Thanks for your time, en de groeten -- tot de volgende! ;-)