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

## 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](https://i.ytimg.com/vi/G5_zul5wrTY/hq720.jpg?sqp=-oaymwE7CK4FEIIDSFryq4qpAy0IARUAAAAAGAElAADIQj0AgKJD8AEB-AH-CYAC0AWKAgwIABABGHIgTShCMA8=&rs=AOn4CLDv0QkEmS876YFmy3_WoGyjlKo-sw align="left")

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](https://blog.paramako.com/rust-audio-programming-oscillator-handle-frequency-changes-smoothly-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.

* [https://doc.rust-lang.org/rust-by-example/custom\_types/structs.html](https://doc.rust-lang.org/rust-by-example/custom_types/structs.html)
    
* [https://doc.rust-lang.org/rust-by-example/custom\_types/enum.html](https://doc.rust-lang.org/rust-by-example/custom_types/enum.html)
    

To keep things familiar, we’ll start with just the **sine wave** — the same waveform we’ve been using throughout [Part 1￼](https://blog.paramako.com/rust-audio-programming-oscillator-build-a-sine-wave-part-1) and [Part 2](https://blog.paramako.com/rust-audio-programming-oscillator-handle-frequency-changes-smoothly-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**:

```rust
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](https://blog.paramako.com/rust-audio-programming-oscillator-handle-frequency-changes-smoothly-part-2).

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

```rust
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:

```rust
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:

```rust
let sample = oscillator.next_sample() * amplitude;
```

#### **We changed how we handle the phase**

In [Part 2](https://blog.paramako.com/rust-audio-programming-oscillator-handle-frequency-changes-smoothly-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:

```rust
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 **2π** — one full circle in **radians**.

So instead of:

```rust
(phase * 2.0 * PI).sin()
```

We now do:

```rust
(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](https://blog.paramako.com/rust-audio-programming-oscillator-handle-frequency-changes-smoothly-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](https://github.com/paramako/oscy) — a small crate I’m building that includes different oscillators and support for multiple waveforms.

```rust
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:

```bash
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1767973511976/387ae4fd-3b28-4e68-9579-f319c49cd622.png align="center")

## **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:

```rust
pub enum Waveform {
    Sine,
    Square,
}
```

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

```rust
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:

```rust
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:

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

Here’s what happens inside `next_sample()`:

```rust
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**:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1767975413105/87afa0e8-3d67-4547-bf70-afd78312469d.png align="center")

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:

```rust
pub enum Waveform {
    Sine,
    Square,
    Saw,
}
```

Change the **oscillator** construction to:

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

Update the `next_sample()` logic:

```rust
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1767976189445/b73ebf8b-46ea-4fd2-ad47-bb232293e8b0.png align="center")

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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768051692241/e4059600-748a-47d6-b36a-7453f05193aa.png align="center")

### **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**:

```rust
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:

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

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

```rust
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:

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

Run your project to generate the .wav file:

```bash
cargo run
```

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768048556510/2017933b-ce72-4f36-99e7-bc4e259cb2f6.png align="center")

* 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](https://github.com/paramako/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 →**](https://github.com/paramako-blog/oscillator/blob/main/examples/part3_different_waveforms.rs)
