Skip to main content

Command Palette

Search for a command to run...

Rust Audio Programming: Oscillator – Exploring the waveforms [PART 3]

Updated
13 min read
Rust Audio Programming: Oscillator – Exploring the waveforms [PART 3]
Y

Original Rustafarian from Kyiv, Ukraine 🇺🇦.

Highloaded backend systems by day, audio synthesis by night. Building tools that scale and sound.

So what’s an oscillator?

An oscillator is a digital or analog component that generates a repeating waveform — think of it as a signal source. In audio programming, it’s the piece of code that spits out samples of a sine wave (or other waveform) over time, at a given frequency.

For example:

  • A 440 Hz oscillator emits values that, when played back at 44.1 kHz, recreate the pitch of the musical note A4.

  • You can think of it as a mathematical function that cycles through waveform values at a certain speed.

Amplitude, Frequency, and Phase

By adjusting the oscillator's frequency, amplitude, and waveform shape, we can synthesize anything from a beep to a bass line.

So far in this series, we’ve been implementing oscillator logic manually inside our sample loop — like we did in Part 2. But now, we’re going to wrap that logic into a proper, reusable oscillator — one that behaves more like what you’d find in real audio engines and synthesizers.

Defining our naive oscillator

To begin shaping different waveforms, we’re going to write our first reusable oscillator. This will let us plug in different waveform logic — like square, triangle, and sawtooth — all using the same reusable structure.

If you’re already familiar with how Rust structs and enums work — great!

If not, don’t worry — you can still follow along or check out those links for a quick refresher.

To keep things familiar, we’ll start with just the sine wave — the same waveform we’ve been using throughout Part 1 and Part 2. It’s smooth, simple, and perfect for verifying that our oscillator behaves correctly.

Here’s our minimal Oscillator struct that generates a sine wave:

use std::f32::consts::TAU;

pub struct Oscillator {
    phase: f32,
    phase_increment: f32,
    sample_rate: f32,
}

impl Oscillator {
    pub fn new(sample_rate: f32, frequency: f32) -> Self {
        let phase_increment = frequency / sample_rate;
        Self {
            phase: 0.0,
            phase_increment,
            sample_rate
        }
    }

    pub fn set_frequency(&mut self, frequency: f32) {
        self.phase_increment = frequency / self.sample_rate;
    }

    pub fn next_sample(&mut self) -> f32 {
        self.phase += self.phase_increment;
        if self.phase >= 1.0 {
            self.phase -= 1.0;
        }

        (self.phase * TAU).sin()
    }
}

Let’s compare this new Oscillator implementation to what we did manually back in Part 2.

Here’s a quick reminder of the relevant lines from our previous code:

let phase_increment = 2.0 * PI * freq_hz / sample_rate;
phase = (phase + phase_increment) % (2.0 * PI);
let sample = (amplitude * phase.sin()) as i16;

And here’s what we do now inside our Oscillator::next_sample method:

self.phase += self.phase_increment;

if self.phase >= 1.0 {
    self.phase -= 1.0;
}

(self.phase * TAU).sin()

Let’s break it down:

Amplitude is now external

We removed amplitude scaling from the oscillator itself. Why?

Because in most audio systems, amplitude is handled outside the oscillator — through gain controls, envelopes, or mixing. That’s more modular, and it gives you full control when combining signals later.

Now, instead of embedding it in the waveform, we do:

let sample = oscillator.next_sample() * amplitude;

We changed how we handle the phase

In Part 2, we tracked phase in radians, and wrapped it using: phase = (phase + increment) % (2π).

Now we’re using a normalized phase in the range 0.0..1.0, and converting to radians only when we need the sine value. It’s simpler and matches DSP conventions more closely.

This also lets us write a cheaper phase wrap:

if self.phase >= 1.0 {
    self.phase -= 1.0;
}

This avoids calling .fract() or % every sample, which helps when optimizing for performance (especially at higher sample rates).

Why TAU instead of 2π?

Mathematically, TAU is just — one full circle in radians.

So instead of:

(phase * 2.0 * PI).sin()

We now do:

(phase * TAU).sin()

Same result, cleaner expression — and easier to think of 1.0 as one full waveform cycle.

Updating our melody generator to use the oscillator

Now that we’ve wrapped our sine wave logic into a reusable Oscillator, let’s use it to recreate the melody we built in Part 2, but this time, with much cleaner code.

For simplicity, we’re keeping the Oscillator struct definition in the same file as the melody code. This tutorial isn’t about Rust project structure or module hygiene — we’re here to explore audio concepts.

If you’re curious how this could be packaged more cleanly, you can also check out oscy — a small crate I’m building that includes different oscillators and support for multiple waveforms.

fn main() {
    let spec = hound::WavSpec {
        channels: 1,
        sample_rate: 44100,
        bits_per_sample: 16,
        sample_format: hound::SampleFormat::Int,
    };

    let mut writer = hound::WavWriter::create("melody_struct.wav", spec)
        .expect("Failed to create WAV file");

    let sample_rate = spec.sample_rate as f32;
    let duration_secs = 8.0;
    let amplitude = i16::MAX as f32;
    let total_samples = (sample_rate * duration_secs) as usize;

    let mut osc = Oscillator::new(sample_rate, 440.0);

    for t in 0..total_samples {
        let time = t as f32 / sample_rate;

        let freq_hz = match time.ceil() {
            1.0 => 440.0,  // A4
            2.0 => 494.0,  // B4
            3.0 => 523.25, // C5
            4.0 => 587.33, // D5
            5.0 => 659.25, // E5
            6.0 => 698.46, // F5
            7.0 => 783.99, // G5
            _ => 880.0,    // A5
        };

        osc.set_frequency(freq_hz);

        let sample = (osc.next_sample() * amplitude) as i16;
        writer.write_sample(sample).unwrap();
    }

    writer.finalize().unwrap();
    println!("✅ Melody written to 'melody_struct.wav'");
}

Let’s listen and look

Build and run your code again:

cargo run

This will generate a new WAV file: melody_struct.wav.

If you open both melody_fixed.wav (from Part 2) and melody_struct.wav (this version) in Audacity and zoom in around the 5 to 6 second mark, just like we did before, you’ll notice there’s no visible difference between them — no harsh corners, no sudden jumps, and most importantly: no audible clicks.

That means our new oscillator-based approach is working exactly as intended.

Exploring waveforms

So we built a clean-sounding melody and solved a common audio issue: phase discontinuity (aka clicks). We even wrapped our sine wave logic into a reusable Oscillator.

Now, it’s time to level up.

We’re going to make our oscillator more flexible — not just locked to sine waves, but capable of generating other classic shapes too: square, sawtooth, and triangle.

Each of these waveforms sounds different and has its own harmonic profile — which gives them their character:

  • Sine waves are smooth and pure.

  • Sawtooth waves are bright and buzzy.

  • Square waves have a hollow, woody tone.

  • Triangle waves are soft but richer than sine.

The difference isn’t just in how they look — it’s in how they vibrate.

When you hear a musical note, you’re not just hearing one frequency. You’re actually hearing a blend of frequencies: the main one (called the fundamental) and a bunch of quieter ones stacked on top — called harmonics or overtones.

These harmonics are integer multiples of the fundamental frequency:

  • If the fundamental is 100 Hz, you might also hear 200 Hz, 300 Hz, 400 Hz

  • Each one adds flavor and texture to the sound.

What makes each waveform unique is which harmonics are present, and how strong they are.

  • Sine wave → no harmonics at all — just the fundamental. Pure and tone-like.

  • Square wave → odd harmonics only (3x, 5x, 7x…), creating a hollow, flute-like sound.

  • Sawtooth wave → all harmonics (1x, 2x, 3x, 4x…), giving it a bright, brassy edge.

  • Triangle wave → odd harmonics too, like square, but much softer — their intensity drops off faster.

These harmonic “recipes” are what give each waveform its distinct voice. They don’t just change what we hear — they shape how we feel the sound.

Square wave

Let’s kick things off with the square wave — one of the most recognizable shapes in digital sound synthesis.

It alternates between two levels: high and low, with no gradual transition between them. That sudden flip gives it a bold, buzzy sound full of odd harmonics.

In code, it’s one of the simplest waveforms to implement:

  • If the phase is in the first half of the cycle (0.0 to 0.5), output +1.0

  • Otherwise, output -1.0

Let’s update our oscillator so it can generate both sine and square waves based on a selected waveform type.

To support multiple waveform shapes, we’ll define a new enum called Waveform. For now, we’ll keep it simple with just two options:

pub enum Waveform {
    Sine,
    Square,
}

We’ll now store the selected waveform in our oscillator. That way, next_sample() knows what shape to generate.

pub struct Oscillator {
    phase: f32,
    phase_increment: f32,
    sample_rate: f32,
    waveform: Waveform,
}

And we’ll update the constructor to accept the waveform as a parameter:

pub fn new(sample_rate: f32, frequency: f32, waveform: Waveform) -> Self {
    let phase_increment = frequency / sample_rate;
    Self {
        phase: 0.0,
        phase_increment,
        sample_rate,
        waveform,
    }
}

To use the square wave in your melody code, simply update the oscillator construction like this:

let mut osc = Oscillator::new(sample_rate, 440.0, Waveform::Square);

Here’s what happens inside next_sample():

pub fn next_sample(&mut self) -> f32 {
    self.phase += self.phase_increment;
    if self.phase >= 1.0 {
        self.phase -= 1.0;
    }

    match self.waveform {
        Waveform::Sine => (self.phase * TAU).sin(),
        Waveform::Square => {
            if self.phase < 0.5 {
                1.0
            } else {
                -1.0
            }
        }
    }
}

Time to test it out! Run your project to generate the new waveform.

Now open that file in Audacity:

No curves, no slopes — just straight lines up and down. That’s your square wave in action.

It’ll also sound more intense and buzzy than the smooth sine wave from before — thanks to all those odd harmonics baked into the waveform.

Saw wave

Next up is the saw (or sawtooth) wave — a bright, sharp waveform known for its aggressive tone and rich harmonic content.

It gets its name from how it looks: a straight ramp up followed by a sharp drop — just like the teeth of a saw blade.

The formula to generate a saw wave is: output = 2 × phase − 1.

Here’s what that means:

  • The phase always moves from 0.0 to 1.0 in a loop — that’s one full waveform cycle.

  • At the start of the cycle (phase = 0.0), we get:

    2 × 0.0 − 1 = -1.0

  • In the middle (phase = 0.5):

    2 × 0.5 − 1 = 0.0

  • At the end (phase = 1.0):

    2 × 1.0 − 1 = +1.0

Then the phase wraps back to 0, and the pattern repeats.

This formula gives us a linear slope from -1.0 to +1.0 over the full waveform cycle. That’s what makes the sawtooth look like a ramp — and sound bright and buzzy due to all its harmonic content.

So lets update the Waveform enum:

pub enum Waveform {
    Sine,
    Square,
    Saw,
}

Change the oscillator construction to:

let mut osc = Oscillator::new(sample_rate, 440.0, Waveform::Saw);

Update the next_sample() logic:

match self.waveform {
    // ...other variants
    Waveform::Saw => 2.0 * self.phase - 1.0,
}

Once we’ve updated the code and generated the .wav file, lets open it the editor:

By looking at it visually, you’ll immediately spot the sawtooth shape:

  • A straight, linear ramp upward from -1.0 to +1.0

  • Followed by a sudden vertical drop back to -1.0

  • And then the cycle repeats

And of course, it sounds a lot brighter and harsher than sine or square — all thanks to its full spectrum of harmonics.

Why does the sine wave sound quieter?

You might have noticed: the sine wave sounds noticeably quieter than the square and saw waves — even though they all use the same amplitude range (-1.0 to +1.0).

That’s not a bug — it’s how sound perception works.

Here’s why:

  • A square wave holds full volume for half the cycle at +1.0 and the other half at -1.0. That means its average power is very high.

  • A saw wave is constantly moving, but it still spends a lot of time near high (and low) values as it ramps from -1.0 to +1.0. It also contains lots of harmonics, which adds even more perceived loudness.

  • A sine wave, on the other hand, gently eases up and down in a smooth curve — spending much less time near its peak values and containing only the fundamental frequency. So even though the peak values are the same, the harmonic content makes the square and saw waves feel louder and fuller.

In real-world synths and audio engines, this is usually handled by scaling different waveforms so they have similar perceived loudness — not just matching the numbers.

You can experiment by adjusting the amplitude multiplier when writing samples, to make each waveform feel more balanced in loudness. Don’t stress about finding a perfect value — just adjust it until it feels right.

But keep in mind, a 16-bit WAV file cannot natively contain amplitude values greater than the 16-bit range.

If your signal goes beyond -1.0..1.0 before converting to i16, it will be clamped to the max value (32767 or -32768) — and this flattens the waveform’s peaks, making our sine wave look and sound more like a square wave.

It ends up looking like this:

Triangle wave

The triangle wave is a gentler cousin of the square wave.

Like the square wave, it only contains odd harmonics, but it rolls them off much faster — making it sound smoother, rounder, and less harsh.

Visually, it’s a perfect zig-zag:

  • It rises steadily from -1.0 to +1.0

  • Then falls back down just as steadily

To generate a triangle wave digitally, we once again rely on the phase value — which linearly moves from 0.0 to 1.0 every cycle.

The trick is to split the waveform into two halves:

  • First half (0.0..0.5): the wave ramps up

  • Second half (0.5..1.0): the wave ramps down

Here’s the piecewise formula:

if phase < 0.5 {
    4.0 * phase - 1.0
} else {
    -4.0 * phase + 3.0
}

In the first half, we want a linear rise from -1.0 → +1.0:

  • When phase = 0.0: 4.0 * 0.0 - 1.0 = -1.0

  • When phase = 0.5: 4.0 * 0.5 - 1.0 = +1.0

In the second half, we want to fall from +1.0 → -1.0:

  • When phase = 0.5: -4.0 * 0.5 + 3.0 = +1.0

  • When phase = 1.0: -4.0 * 1.0 + 3.0 = -1.0

The slope of +/-4.0 gives us just the right speed to move between those values in half a cycle.

So lets extend the Waveform enum again:

pub enum Waveform {
    Sine,
    Square,
    Saw,
    Triangle,
}

Then update the next_sample() method of your Oscillator struct to support it:

match self.waveform {
    // ...other variants
    Waveform::Triangle => {
        if self.phase < 0.5 {
            4.0 * self.phase - 1.0
        } else {
            -4.0 * self.phase + 3.0
        }
    }
}

And change the Oscillator construction to:

let mut osc = Oscillator::new(sample_rate, 440.0, Waveform::Triangle);

Run your project to generate the .wav file:

cargo run

Once the file is generated, open it in Audacity and you’ll see:

  • A clean up–down ramp

  • Peaks at +1.0 and -1.0

  • Sharp corners, but not the harsh harmonics of saw/square

The result is a soft, muted tone.

🎬 Wrapping up [PART 3]

In this post, we built a reusable oscillator in Rust, explored multiple waveform shapes, and visualized how their structure affects tone and loudness. Along the way, we touched on harmonics, amplitude scaling, and how even simple math shapes sound.

But as you’ve probably guessed, our oscillator is still kind of naive, and there’s much more to explore beneath the surface.

Our waveforms are generated using simple math, which means sharp corners in square and saw waves. That leads to a common problem in digital synthesis: aliasing.

Aliasing happens when high-frequency content folds back into the audible range, creating unwanted artifacts — especially at higher pitches. It can make sounds harsher, noisier, and less realistic.

If you’re ready to go deeper, check out my crate oscy. It includes alternate oscillator implementations (like PolyBLEP) that reduce aliasing while staying CPU-friendly.

🚧 What’s next?

That wraps up our Oscillator Series!

Next up? A new series where we:

  • Combine oscillators

  • Add envelopes and modulation

  • And build toward real-time Rust-powered synths

Stay tuned!

💾 Source code from this article:

View on GitHub →

Rust Audio Programming

Part 1 of 3

Ever wondered what sound looks like in Rust? This is a series where we make Rust sing — from raw wave math to real-time audio and beyond. Build oscillators, shape waveforms, and learn DSP the hands-on way, one line of code at a time.

Up next

Rust Audio Programming: Oscillator – Handle frequency changes smoothly [PART 2]

In Part 1, we built a simple sine wave generator and saved it as a .wav file. Now it’s time to make it sing — or at least play a short melody. We’ll modify our existing code to switch between different notes over time, building up something that feel...