3 · osc (part i) — phasors and waveforms
The title of the course (“a modular approach”) finally pays off here. We build oscillators out of two tiny, swappable pieces: a phasor that produces a ramp, and an oscillator shape that maps that ramp to a waveform.
About real-time: the original lecture runs in real time under JACK, where a callback
on_processfills small buffers as the sound card consumes them. We keep that original C so you see it, but the Zig is offline: the same DSP fills a buffer we then write to a WAV (using thewriteWavfrom chapter 2). Conceptually identical — a JACKon_processloop and our offlinewhileloop do the same thing per sample.
1 · The “bad” sine oscillator
Section titled “1 · The “bad” sine oscillator”In chapter 2 we generated a sine directly from time:
for (buf, 0..) |*s, i| { const t: f32 = @floatFromInt(i); s.* = std.math.sin(2.0 * std.math.pi * 440.0 * t / @as(f32, sr)); // same idea, same flaw}for (size_t i = 0; i < NSAMPLES; ++i) buf[i] = lrint(SAMPLE_MAX*sin(2*M_PI * 440 * i/SR));It sounds fine — but no real oscillator is built this way, because it cannot change frequency without clicking. Write the sine in terms of its phase :
Now suppose at we jump the frequency from Hz to Hz. The phase is computed as , so it instantly recomputes to a different value:
sin(2π · 1 · 2.25) = 1.0sin(2π · 1.1 · 2.25) = 0.156...difference = -0.843...That sudden jump in the output is a discontinuity — and a discontinuity sounds like a click. Worse, it is not about the size of the frequency change: even Hz produces a big jump eventually, because the error grows with (a tiny slope difference, integrated over enough time, becomes a large phase difference).
Math note — the real culprit. Phase is what your ear tracks for continuity, and here phase is computed as . When changes, the whole product changes at once — the phase teleports. We need phase to keep moving smoothly and only change its speed. That is the difference between position and velocity: you can change your velocity instantly, but your position must stay continuous.
2 · The fix: integrate frequency
Section titled “2 · The fix: integrate frequency”Frequency is really the rate of change of phase — the slope of the phase ramp:
So “changing frequency” should mean “change the slope of ,” not recompute from scratch. If we accumulate phase step by step, the phase stays continuous and only its speed changes — no click. This structure (time and frequency in, phase out, phase into an oscillator shape) is a numerically controlled oscillator (NCO):
t ─┐ ├──▶ ϕ (phasor) ──▶ osc ──▶ sound ω ─┘Two independent pieces. The phasor turns frequency into a phase ramp; the oscillator maps that phase to a waveform. Swap either freely — that is the modular idea.
3 · Oscillator shapes (phase → sample)
Section titled “3 · Oscillator shapes (phase → sample)”Each oscillator takes a phase and returns a sample. We assume the phasor already wraps into — so every shape only needs to be defined on one cycle.
Math note — why wrap to . All these waveforms are 1-periodic: . So defining them on and repeating is enough. There is a second, very practical reason: an unbounded phase would grow forever and a
f32loses precision as it grows, so the pitch would slowly drift out of tune. Keeping phase in keeps it precise forever.
In JACK the oscillator is itself a tiny client: phase comes in on a phs port, the sample goes out on out. Here are the four shapes — original C beside Zig.
Sine — :
fn sine(phase: f32) f32 { return std.math.sin(2.0 * std.math.pi * phase);}for (i = 0; i < nframes; ++i) out[i] = sinf(2*PI_F*in[i]);Square — for the first half of the cycle, for the second:
fn square(phase: f32) f32 { return if (phase < 0.5) -1.0 else 1.0;}for (i = 0; i < nframes; ++i) out[i] = in[i] < 0.5f ? -1 : 1;Sawtooth — a scaled, shifted copy of the phase ramp, :
fn saw(phase: f32) f32 { return 2.0 * phase - 1.0;}for (i = 0; i < nframes; ++i) out[i] = 2*in[i] - 1;Triangle — up then down, two line segments joined:
fn triangle(phase: f32) f32 { const ramp = if (phase < 0.5) phase else 1.0 - phase; return 4.0 * ramp - 1.0;}for (i = 0; i < nframes; ++i) out[i] = 4*(in[i] < 0.5f ? in[i] : (1-in[i])) - 1;Zig note —
ifis an expression. In Zigif (cond) a else breturns a value, soreturn if (phase < 0.5) -1.0 else 1.0;is the idiomatic translation of C’s ternary?:. No separate ternary operator exists — the normalifdoes the job. Note these are plain functions with no hidden state; all the state lives in the phasor.
Math note — shape is timbre. Same pitch, different shape, completely different sound. The shape determines the harmonic content: a sine has only its fundamental; a saw contains every harmonic; a square contains the odd ones. That is why a saw sounds bright and buzzy while a sine sounds pure.
4 · The phasor
Section titled “4 · The phasor”The phasor produces the rising ramp. Design anything periodic in three steps: make it 1-periodic, make it double-frequency, then make it -periodic.
A 1 Hz ramp must climb from 0 to 1 in one second, i.e. over samples — so each sample it increases by . For frequency , increase by and wrap. The original C, building up to the general case:
In Zig, wrapped up as a reusable struct (state = the current phase and the per-sample increment):
const Phasor = struct { phase: f32 = 0.0, // current position in [0, 1) inc: f32 = 0.0, // phase added per sample = freq / sr
fn setFreq(self: *Phasor, freq: f32, sr: f32) void { self.inc = freq / sr; }
fn next(self: *Phasor) f32 { const out = self.phase; self.phase += self.inc; // wrap into [0, 1) — handles negative and large increments while (self.phase >= 1.0) self.phase -= 1.0; while (self.phase < 0.0) self.phase += 1.0; return out; }};static float phs = 0;static float freq = 400;
for (i = 0; i < nframes; ++i) { out[i] = phs; phs += freq / sr;
// clamp 0 <= phs < 1 while (phs >= 1) phs--; while (phs < 0) phs++;}Zig note —
whilefor the wrap. Zig has nodo/while; a plainwhile (cond) stmt;matches C’s wrapping loops exactly. The two loops handle a frequency that is negative or larger thansr. For the common case (0 ≤ freq < sr) a singleifwould do, andself.phase -= @floor(self.phase)is an even terser alternative — but it only works for the range, so the explicit loops are the safe default.
Math note —
inc = freq / sris the integration step. Remember ? In discrete time the integral becomes a running sum, and each step adds . That is exactlyphase += freq / sr. Changingfreqchanges only the step size, so the ramp bends smoothly instead of teleporting — the click is gone.
Putting it together (offline)
Section titled “Putting it together (offline)”The JACK version wires phs → osc → playback as separate processes. Offline, we just call them in a loop and write to a WAV. Reusing writeWav/writeWavHeader from chapter 2:
fn renderTone(out: []f32, freq: f32, sr: f32) void { var ph: Phasor = .{}; ph.setFreq(freq, sr); for (out) |*s| { const phase = ph.next(); s.* = saw(phase); // swap for sine/square/triangle to change timbre }}Connect phs → sine for a pure tone, phs → saw for a buzzy one — only one function name changes.
5 · Making it interactive (control input)
Section titled “5 · Making it interactive (control input)”The original lecture maps a joystick axis to the phasor’s frequency in real time, using Linux evdev. The reusable trick is a linear range remap:
fn map(val: f32, x0: f32, x1: f32, y0: f32, y1: f32) f32 { return y0 + (y1 - y0) * (val - x0) / (x1 - x0);}float map(float val, float x0, float x1, float y0, float y1){ return y0 + (y1-y0) * (val-x0)/(x1-x0);}Math note — reading the remap. is “how far is from to , as a fraction 0..1.” Multiply that fraction by the output span and add the output start . So a joystick value in becomes a frequency in Hz.
Offline equivalent — automation. With no live input, we drive frequency from a function of time (an automation curve). For example, a glide from 200 Hz to 800 Hz across the buffer:
fn renderGlide(out: []f32, sr: f32) void { var ph: Phasor = .{}; const n: f32 = @floatFromInt(out.len); for (out, 0..) |*s, i| { const frac = @as(f32, @floatFromInt(i)) / n; // 0 → 1 over the buffer const freq = map(frac, 0.0, 1.0, 200.0, 800.0); // 200 → 800 Hz ph.setFreq(freq, sr); s.* = saw(ph.next()); }}Because the phasor accumulates, sweeping freq every sample is smooth and click-free — exactly the payoff from section 2. (Changing frequency too fast still causes “zipper noise”; smoothing that is the subject of mix.)
6 · Bender (waveshaping) — optional
Section titled “6 · Bender (waveshaping) — optional”A fun aside: reshape the phase ramp itself with a movable corner point . Below the corner, one line; above it, another — a piecewise-linear map of phase to phase:
fn bend(phase: f32, x0: f32, y0: f32) f32 { if (phase < x0) return (y0 / x0) * phase else return ((1.0 - y0) / (1.0 - x0)) * (phase - x0) + y0;}for (i = 0; i < nframes; ++i) { if (in[i] < x0) out[i] = (y0/x0)*in[i]; else out[i] = ((1-y0)/(1-x0)) * (in[i] - x0) + y0;}Feed a phasor through bend before the oscillator and you continuously morph the waveform — a simple form of phase-distortion / waveshaping synthesis. Keep inside to avoid dividing by zero.
7 · The end? (aliasing teaser)
Section titled “7 · The end? (aliasing teaser)”The naive square and saw above have a problem: their instantaneous jumps contain frequencies above Nyquist, which alias into audible, inharmonic tones at high pitches. Push a naive saw up past a few kHz and you will hear gritty, metallic artifacts. Fixing this — band-limited oscillators via wavetables and the Fourier series — is exactly chapter 4.
Things to try
Section titled “Things to try”- Besides a joystick, what else could drive
freq? Mouse position, another oscillator (that is FM/vibrato), an envelope. - Render the same 200→800 Hz glide with sine vs. saw and listen to how differently they sweep.
- Modify
bendto distort something other than phase.
Further reading
Section titled “Further reading”Original chapter (with the JACK/joystick wiring and audio demos): mu.krj.st/osc_i. On phase-distortion/waveshaping: Curtis Roads, The Computer Music Tutorial, ch. 6; Miller Puckette, Theory and Technique of Electronic Music, ch. 5.
Next: 4 · osc (part ii) — Fourier series, wavetables, and a saw that does not alias.