Skip to main content

Command Palette

Search for a command to run...

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

Updated
9 min read
Rust Audio Programming: Oscillator – Handle frequency changes smoothly [PART 2]
Y

Original Rustafarian from Kyiv, Ukraine 🇺🇦.

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

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 feels more musical. But we’re also going to hit a snag — and that snag is going to teach us an important concept in audio programming.

Let’s dive in.

We’ll play an 8-second melody, changing the note once per second. We’ll reuse the core logic from Part 1, but adapt it to loop over an array of frequencies — one for each second.

This is a naive implementation:

use std::f32::consts::PI;

fn main() {
    // WAV file settings
    let spec = hound::WavSpec {
        channels: 1,        // mono
        sample_rate: 44100, // samples per second
        bits_per_sample: 16,
        sample_format: hound::SampleFormat::Int,
    };
    // Create a WAV writer
    let mut writer =
        hound::WavWriter::create("melody.wav", spec).expect("Failed to create WAV file");
    // Sine wave parameters
    let duration_secs = 8.0; // 2 seconds
    let amplitude = i16::MAX as f32; // max volume

    let sample_rate = spec.sample_rate as f32;
    let total_samples = (sample_rate * duration_secs) as usize;

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

        let sample = (amplitude * (2.0 * PI * freq_hz * time).sin()) as i16;
        writer.write_sample(sample).unwrap();
    }

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

Let’s break down what we’ve changed in this version of the code:

In Part 1, we generated a single 2-second tone.

Now we’ve extended the total duration to 8 seconds so we can play eight notes, one per second:

let duration_secs = 8.0;

We keep a single loop running over all the samples:

for t in 0..total_samples {
    let time = t as f32 / sample_rate;
    let freq_hz = match time.ceil() { ... };
}

Instead of generating one note from start to finish, we now check the current time and change the frequency every second.

This lets us build a full melody from just one continuous time stream.

The rest stays the same — we’re still generating samples, writing them to a .wav file.

Let’s play our melody

Run the program with:

cargo run

You’ll see the file melody.wav pop up in your project folder. Go ahead and open it in your favorite audio player — or better yet, drop it into Audacity to listen and inspect the waveform.

At first, it’ll sound like a simple melody — but listen closely 👂 and you might hear something unexpected between the notes…

Those little clicks or pops you’re hearing between notes aren’t a bug in your system — they’re a classic issue in audio programming caused by discontinuities in the waveform.

Remember, we’re using this formula: sample = amplitude * sin(2π × frequency × time)

What that means is:

  • At the end of each second, the sine wave is at a certain phase (e.g., somewhere mid-curve).

  • Then, without warning, we jump to a new sine wave — starting at a totally different frequency, but continuing the same time.

The result? The waveform suddenly jumps from one value to another — and your ear hears that jump as a click.

To be smooth and click-free, a waveform should connect seamlessly — one sample to the next, with no sudden jumps in value.

But when we change the frequency without resetting or adjusting the phase, we break that continuity.

So instead of a curve that flows naturally, we get a sharp corner in the waveform — and your ear hates sharp corners.

See it for yourself (in Audacity)

By default, Audacity might show beats and measures, which isn’t helpful for our waveform timing.

To change that:

  1. Look at the timeline above the waveform.

  2. Click the little drop-down arrow on the left side of the timeline.

  3. Select “Minutes and Seconds” from the list (instead of “Beats and Measures”).

To see the discontinuity clearly:

  1. Zoom in until you’re seeing just a few milliseconds of waveform per screen.

  2. Navigate to the 5 to 6 second mark (between note 6 and 7).

  3. Look closely at the transition point — right around the 6.0s boundary.

🎯 You should see a clean, repeating wave suddenly jump to a new shape — no smooth transition.

Or even closer

This is the exact spot where our phase mismatch causes a discontinuity in the waveform — and that’s what we’ll fix in the next step. You can choose any transition point between notes to inspect — I picked the gap around second 6 just as an example. The same issue will appear wherever the frequency changes abruptly mid-wave.

Let’s smooth it out

We’re going to fix this with as little code change as possible — just enough to demonstrate the concept of phase continuity.

We’ll do that by:

  • Tracking a persistent phase variable across the entire loop

  • Using freq only to adjust how fast phase advances — not where it starts

Quick recap

As we saw earlier, using this formula: sample = amplitude * sin(2π × frequency × time) …works great until the frequency changes. Then the wave jumps because time keeps going, but the math rebuilds a brand new sine wave. The waveform doesn’t connect smoothly — and that sharp transition becomes an audible click.

The fix

Here’s our original melody code, with just a few crucial lines added:

use std::f32::consts::PI;

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_fixed.wav", spec).expect("Failed to create WAV file");

    let duration_secs = 8.0;
    let amplitude = i16::MAX as f32;

    let sample_rate = spec.sample_rate as f32;
    let total_samples = (sample_rate * duration_secs) as usize;

    // NEW: keep track of waveform position
    let mut phase = 0.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
        };

        // NEW: advance phase smoothly based on current frequency
        let phase_increment = 2.0 * PI * freq_hz / sample_rate;
        phase = (phase + phase_increment) % (2.0 * PI);

        // sample now comes from continuous phase
        let sample = (amplitude * phase.sin()) as i16;
        writer.write_sample(sample).unwrap();
    }

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

If you open the new .wav file in Audacity, you’ll notice something important:

✅ No more sudden jumps in the waveform.

Even when the frequency changes, the wave connects naturally.

If you zoom in to the same transition point as before (say, between second 5 and 6), you’ll see that the waveform now flows without a visible click.

That’s phase continuity in action.

What changed?

Instead of recalculating the sine wave angle from scratch every time, we now:

  • Keep a phase that tracks our position inside the waveform

  • Move it forward a tiny bit for each sample, based on the current frequency

  • Use sin(phase) to get the sample value

  • Wrap it around once it reaches to avoid overflow

What is a radian?

A radian is just a unit for measuring angles — like degrees, but more mathematically convenient.

  • A full circle is 360°, right?

  • In radians, a full circle is 2π radians (≈ 6.283)

So when we talk about , we’re talking about one full rotation — or in waveform terms, one full sine wave cycle.

Let’s say we want to generate a 440 Hz tone (A4). That means we need to complete 440 full sine wave cycles every second.

Since one full cycle is 2π radians, we need to sweep through: 2π × 440 = 2764.6 radians per second.

That’s the total angular distance our sine wave needs to travel per second to hit that pitch.

How much to move per sample

But we don’t generate one sample per second — we generate 44100 samples per second. So we have to split that angular distance across all 44100 samples.

Here’s the math:

let phase_increment = (2.0 * PI * frequency) / sample_rate;

This gives us the tiny step we take through the wave with each sample.

Using our 440 Hz example: phase_increment ≈ 0.0627 radians per sample.

That means every sample moves us forward about 0.0627 radians through the sine wave.

And since one full wave cycle is 2π radians, we can divide: 2π / 0.0627 ≈ 100 samples.

So it takes around 100 samples to complete one full sine wave cycle at 440 Hz.

By adjusting the phase increment, we control how many samples it takes to go from 0 → back to 0.

Why % (2π)

In our code, we update the phase like this:

phase = (phase + phase_increment) % (2.0 * PI);

So why are we using modulo 2π here?

Because the sine wave is a loop.

A full sine wave cycle is exactly 2π radians — and after that, it just repeats.

That means:

  • sin(0) = sin(2π) = sin(4π)

  • sin(π/2) = sin(2π + π/2) = sin(4π + π/2)

  • …and so on

So whether the phase is 6.3, 12.6, 18.9, etc — the result of sin(phase) will be the same shape, just wrapped around.

By keeping phase in the range 0.0..2π, we:

  • Keep everything tidy

  • Prevent precision drift

  • Avoid weird edge cases down the line

🏁 That’s a wrap for [PART 2]

In this post, we turned our naive melody generator into something smarter and smoother.

We uncovered and explained a common problem in audio programming (clicks from phase discontinuity), and we fixed it using one of the most fundamental DSP concepts: tracking phase over time.

This technique isn’t just a trick — it’s the foundation of how digital sound generators work under the hood.

And even though we haven’t formally built an oscillator yet, we’re already using one in spirit — walking through the waveform cycle sample by sample.

🔜 What’s next?

In Part 3, we’ll finally write our first true oscillator and take things further:

  • Generate different waveforms

  • Compare how they sound and look

  • Learn how waveform shape affects tone and harmonics

▶️ Continue to Part 3:

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

💾 Source code from this article:

View on GitHub →

Rust Audio Programming

Part 2 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 – Build a sine wave [PART 1]

Hey there 👋! Whether you’re a seasoned rustacean, a curious audio hacker, or just someone who thinks synths are cool — welcome aboard! This is the Rust Audio Programming series, and today we’re diving into the basics of audio programming by building...