Skip to content

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_process fills 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 the writeWav from chapter 2). Conceptually identical — a JACK on_process loop and our offline while loop do the same thing per sample.

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
}

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 ϕ\phi:

x=sin(2πϕ),ϕ(t)=ftx = \sin(2\pi\phi), \qquad \phi(t) = f\cdot t

Now suppose at t=2.25 st = 2.25\text{ s} we jump the frequency from 11 Hz to 1.11.1 Hz. The phase is computed as ftf\cdot t, so it instantly recomputes to a different value:

sin(2π · 1 · 2.25) = 1.0
sin(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 11.0011 \to 1.001 Hz produces a big jump eventually, because the error grows with tt (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 ftf \cdot t. When ff changes, the whole product ftf\cdot t 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.

Frequency is really the rate of change of phase — the slope of the phase ramp:

f=dϕdtϕ(t)=0tf(τ)dτf = \frac{d\phi}{dt} \quad\Longrightarrow\quad \phi(t) = \int_0^t f(\tau)\,d\tau

So “changing frequency” should mean “change the slope of ϕ\phi,” not recompute ϕ\phi 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.

Each oscillator takes a phase ϕ\phi and returns a sample. We assume the phasor already wraps ϕ\phi into [0,1)[0, 1) — so every shape only needs to be defined on one cycle.

Math note — why wrap to [0,1)[0,1). All these waveforms are 1-periodic: osc(ϕ+1)=osc(ϕ)\text{osc}(\phi+1) = \text{osc}(\phi). So defining them on [0,1)[0,1) and repeating is enough. There is a second, very practical reason: an unbounded phase would grow forever and a f32 loses precision as it grows, so the pitch would slowly drift out of tune. Keeping phase in [0,1)[0,1) 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.

Sineosc(ϕ)=sin(2πϕ)\text{osc}(\phi) = \sin(2\pi\phi):

fn sine(phase: f32) f32 {
return std.math.sin(2.0 * std.math.pi * phase);
}

Square1-1 for the first half of the cycle, +1+1 for the second:

fn square(phase: f32) f32 {
return if (phase < 0.5) -1.0 else 1.0;
}

Sawtooth — a scaled, shifted copy of the phase ramp, 2ϕ12\phi - 1:

fn saw(phase: f32) f32 {
return 2.0 * phase - 1.0;
}

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;
}

Zig note — if is an expression. In Zig if (cond) a else b returns a value, so return if (phase < 0.5) -1.0 else 1.0; is the idiomatic translation of C’s ternary ?:. No separate ternary operator exists — the normal if does 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.

The phasor produces the rising ramp. Design anything periodic in three steps: make it 1-periodic, make it double-frequency, then make it ff-periodic.

A 1 Hz ramp must climb from 0 to 1 in one second, i.e. over fsf_s samples — so each sample it increases by 1/fs1/f_s. For frequency ff, increase by f/fsf/f_s 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;
}
};

Zig note — while for the wrap. Zig has no do/while; a plain while (cond) stmt; matches C’s wrapping loops exactly. The two loops handle a frequency that is negative or larger than sr. For the common case (0 ≤ freq < sr) a single if would do, and self.phase -= @floor(self.phase) is an even terser alternative — but it only works for the [0,1)[0,1) range, so the explicit loops are the safe default.

Math note — inc = freq / sr is the integration step. Remember ϕ=fdt\phi = \int f\,dt? In discrete time the integral becomes a running sum, and each step adds fΔt=f(1/fs)f \cdot \Delta t = f \cdot (1/f_s). That is exactly phase += freq / sr. Changing freq changes only the step size, so the ramp bends smoothly instead of teleporting — the click is gone.

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:

map(v)=y0+(y1y0)vx0x1x0\text{map}(v) = y_0 + (y_1 - y_0)\,\frac{v - x_0}{x_1 - x_0}

fn map(val: f32, x0: f32, x1: f32, y0: f32, y1: f32) f32 {
return y0 + (y1 - y0) * (val - x0) / (x1 - x0);
}

Math note — reading the remap. vx0x1x0\frac{v - x_0}{x_1 - x_0} is “how far is vv from x0x_0 to x1x_1, as a fraction 0..1.” Multiply that fraction by the output span (y1y0)(y_1 - y_0) and add the output start y0y_0. So a joystick value in [32767,32767][-32767, 32767] becomes a frequency in [0,400][0, 400] 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.)

A fun aside: reshape the phase ramp itself with a movable corner point (x0,y0)(x_0, y_0). 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;
}

Feed a phasor through bend before the oscillator and you continuously morph the waveform — a simple form of phase-distortion / waveshaping synthesis. Keep x0,y0x_0, y_0 inside [0.05,0.95][0.05, 0.95] to avoid dividing by zero.

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.

  1. Besides a joystick, what else could drive freq? Mouse position, another oscillator (that is FM/vibrato), an envelope.
  2. Render the same 200→800 Hz glide with sine vs. saw and listen to how differently they sweep.
  3. Modify bend to distort something other than phase.

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.