Build a Rust Sine-Wave Synth

Build a Rust CLI synth that plays controllable sine waves through Linux audio.

Introduction

30 Second Summary

A musical app can only become useful after it produces one reliable sound. That first sound gives every later control or effect something real to shape.

In this project, you will build a controllable sine-wave synthesizer in Rust with CPAL. It produces a three-second tone whose frequency and volume come from terminal arguments.

What You'll Build

You'll hear rust-tone play through your default Linux audio device while new terminal values change the tone you hear.

By the end of this project, you'll have:

  • An audible synthesizer that plays a sine wave for three seconds through your default output device.
  • Reusable terminal controls for comparing 220 0.1 with 880 0.05. Each run produces the requested pitch and volume without a source-code edit.
  • A tested oscillator that starts at zero and reaches its requested peak at a quarter cycle. You can trace its samples from the audio host to the speaker.
  • Secret Mission: Add a square waveform argument while keeping the oscillator design testable.

Are there any prerequisites?

Basic familiarity with Python-style variables or functions is helpful. You do not need prior Rust or audio-programming experience.

Before We Start

Before you create the first audio sample, take a moment to commit to what you are building. A working oscillator gives rust-tone a solid foundation before you add file playback or mixing.

Set Up Rust Audio on Linux

Your synth can only reach the speaker after its Rust code compiles against Linux's audio system. CPAL uses ALSA as its default Linux audio backend.

This step confirms that your compiler meets CPAL's requirement. You'll also prepare the system files that Cargo needs to build your first audio project.

In this step, get ready to:
  • Confirm that your Rust compiler meets CPAL's minimum version.
  • Install the ALSA development package for your Linux distribution.
  • Build the new rust-tone project with CPAL 0.18.2.
Check your Rust toolchain

The project pins CPAL 0.18.2. Its Linux ALSA backend requires Rust 1.85 or newer.

  • Check your compiler and package manager by running these commands:
rustc --version
cargo --version

What do these commands check?

  • The first command prints the installed Rust compiler version.
  • The second command confirms that Cargo is available for creating and building the project.

✔️ My Rust version meets the requirement

Good work. If the compiler reports Rust 1.85 or newer, your toolchain can build CPAL's Linux ALSA backend.

ⓧ I see an older Rust version

A Rust compiler below 1.85 cannot build this CPAL version. A toolchain managed by rustup can update in place.

  • Update your rustup-managed toolchain by running this command:
rustup update

What does this command do?

The command updates the installed Rust release channels and their components. Leave the terminal open until it returns to the prompt.

  • Check the compiler and Cargo versions again by running:
rustc --version
cargo --version

What should you check?

Confirm that the compiler now reports Rust 1.85 or newer. Cargo should also print its installed version.

Your updated compiler is now ready for the pinned CPAL dependency.

ⓧ A command is unavailable

A missing command means the Rust toolchain is unavailable in your current terminal session. The official installer sets up both the compiler and Cargo.

  • Open the official rustup installation guide in your browser.
  • Complete the Linux installation instructions in the guide.
  • Return to your terminal after the installation finishes.
  • Check the installed toolchain by running:
rustc --version
cargo --version

What should you check?

The compiler should report Rust 1.85 or newer. Cargo should print its installed version.

Both tools are now available for the project build.

Still seeing the wrong Rust version?

Close your current terminal after the update. Start a fresh terminal session so it loads the updated toolchain path.

If the older compiler remains active, help me identify which Rust installation my Linux terminal is using.

Install ALSA development files

CPAL's Linux backend links your Rust program to ALSA. The development package provides the system files required during compilation.

Installing this package needs administrator access because Linux stores it system-wide. Choose the tab that matches your distribution.

Debian, Ubuntu, or Mint

  • Enter your Linux account password if the terminal requests administrator approval.
  • Approve the installation if the package manager asks for confirmation.
  • Install libasound2-dev by running this command:
sudo apt install libasound2-dev

What does this install?

The package supplies the ALSA development files that CPAL needs during compilation. It prepares the Linux audio connection without starting a background service.

The installation succeeds when the package manager finishes without an error and returns you to the shell prompt.

Fedora or CentOS

  • Switch to an administrator shell for the package installation.
  • Approve the installation if the package manager asks for confirmation.
  • Install alsa-lib-devel by running this command:
dnf install alsa-lib-devel

What does this install?

The package supplies the ALSA development files that CPAL needs during compilation. It prepares the Linux audio connection without starting a background service.

The installation succeeds when the package manager finishes without an error and returns you to the shell prompt.

Package installation failed?

Confirm that you selected the command for your Linux distribution. A permissions failure means the package command did not receive administrator access.

A download failure can come from your network connection or package mirrors. Help me troubleshoot the ALSA development package installation.

Create and build the Cargo project

A Cargo binary project gives the synth its package metadata and starter program. The first build also proves that CPAL can compile against your installed ALSA files.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command changes your terminal's current location to the Desktop folder in your Linux home directory. Cargo creates the project there so it is easy to find.

  • Create the rust-tone binary project and enter its directory by running these commands:
cargo new rust-tone --bin --vcs none
cd rust-tone

What does this scaffold create?

  • Cargo creates a binary package containing Cargo.toml and src/main.rs.
  • The --vcs none option prevents Cargo from initializing version control.
  • The second command places your terminal inside the new rust-tone directory.

That is your project shell in place. Cargo has created the starter program that the next step turns into a synth.

  • In your confirmed editor, open Cargo.toml from ~/Desktop/rust-tone.
  • Replace the file's contents with this configuration:
[package]
name = "rust-tone"
version = "0.1.0"
edition = "2024"

[dependencies]
cpal = "=0.18.2"

What does this configuration do?

  • The package section names the binary rust-tone and uses the Rust 2024 edition.
  • The dependency requirement pins CPAL to exactly 0.18.2 so later releases cannot change the project underneath you.
  • Save Cargo.toml in your editor.

The first build compiles CPAL and its dependencies, so it can take longer than later builds. Keep the terminal open until the command returns to the prompt.

Before you build, do you expect Cargo to accept the pinned CPAL dependency and finish without an error?

  • Build the starter project from the rust-tone directory by running:
cargo build

What does this build produce?

  • Cargo resolves the exact CPAL dependency and records the dependency versions in Cargo.lock.
  • Cargo compiles the starter program into target/debug/rust-tone.
  • A successful build confirms that Rust can compile CPAL against the Linux ALSA development files.

You have cleared the setup hurdle. The project now compiles with the pinned audio library and produces its debug binary.

  • Find Cargo.lock beside Cargo.toml in your editor's file tree.
  • Expand target/debug in the file tree to find the rust-tone binary.

Those two generated artifacts prove that dependency resolution and debug compilation both completed.

✔️ Awesome, I've got everything!

Great. Keep your terminal inside rust-tone and confirm that Cargo.toml is saved.

ⓧ I'd like to double check the full code

Compare your complete Cargo.toml with this file.

[package]
name = "rust-tone"
version = "0.1.0"
edition = "2024"

[dependencies]
cpal = "=0.18.2"

What should match?

The package metadata should match exactly. The dependency line should use the exact CPAL version requirement shown above.

Cargo also created src/main.rs. Leave its starter program exactly like this.

fn main() {
    println!("Hello, world!");
}

Why keep the starter program?

This small program gives Cargo a valid binary to compile. The next step replaces it with the fixed-tone audio path.

After a successful build, Cargo generates Cargo.lock and the Linux debug binary at target/debug/rust-tone.

Build still failing?

Confirm that your terminal is inside the rust-tone directory. The folder should contain Cargo.toml and src/main.rs.

A failure mentioning an unavailable system audio dependency usually means the ALSA development package is missing. Return to the distribution tab above and install the matching package.

If the cause remains unclear, help me diagnose my CPAL build failure.

Your Rust audio environment now compiles from end to end. Next up, you'll replace the starter program with a fixed sine wave that reaches your speakers.

Make the Speaker Produce a Fixed Tone

Your rust-tone project already builds with CPAL. That successful build proves your Rust toolchain can compile the audio dependency.

Compilation has not sent a sample to your speaker yet. This step connects the host, device, stream callback, channel frames, and speaker into one working audio path.

In this step, get ready to:
  • Prepare an audio callback that generates one sine sample for each channel frame.
  • Select the default Linux audio output path.
  • Play the output stream for three seconds.
Generate one sample for each channel frame

The callback receives buffers from the audio device while the stream runs. It uses the device's actual sample rate to move through a sine wave at the correct speed.

  • Use your confirmed editor to open src/main.rs inside the rust-tone folder.
  • Place your cursor above the existing fn main() function.
  • Add the standard-library and CPAL imports by pasting this code:
use std::{f32::consts::TAU, thread, time::Duration};

use cpal::{
    traits::{DeviceTrait, HostTrait, StreamTrait},
    Device, Error, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample,
    StreamConfig,
};

What do these imports provide?

  • TAU represents one complete turn in radians.
  • thread keeps the program alive while the speaker plays.
  • DeviceTrait, HostTrait, and StreamTrait provide the audio methods used later.
  • The remaining CPAL types describe devices, stream configurations, sample formats, and callback data.
  • Save src/main.rs.
  • Confirm the imports compile by running this command:
cargo build

What does this build check prove?

Cargo checks these imports against CPAL 0.18.2. The command must exit successfully before you add the callback.

The build exits successfully. Your file can now use the CPAL stream API.

Imports fail to compile?

Compare every imported name with the code block above. Rust treats capitalization as significant.

Confirm that Cargo.toml still contains cpal = "=0.18.2".

Help me diagnose CPAL import errors in this Rust project.

  • Move your cursor below the existing main() function.
  • Begin the typed output function by pasting this first section:
fn run<T>(device: &Device, config: StreamConfig, frequency: f32, volume: f32)
where
    T: SizedSample + FromSample<f32>,
{
    let sample_rate = config.sample_rate as f32;
    let channels = config.channels as usize;
    let mut phase = 0.0;
    let phase_increment = frequency / sample_rate;
    let error_callback = |error: Error| eprintln!("audio stream error: {error}");
    let stream = device
        .build_output_stream(
            config,
            move |data: &mut [T], _: &OutputCallbackInfo| {
                for frame in data.chunks_mut(channels) {
                    let value = T::from_sample((phase * TAU).sin() * volume);
                    phase = (phase + phase_increment) % 1.0;
                    for sample in frame.iter_mut() {
                        *sample = value;
                    }
                }
            },
            error_callback,
            None,
        )
        .expect("failed to build the output stream");

How does the callback generate sound?

  • sample_rate records how many channel frames the device requests each second.
  • phase_increment converts the fixed frequency into movement through one normalized wave cycle.
  • phase advances once per channel frame.
  • T::from_sample converts the generated floating-point value into the device's selected sample type.
  • The inner loop copies the same value into every channel within the frame.
  • Complete the same run() function with this closing section:

    stream.play().expect("failed to start the output stream");
    println!("Playing 440 Hz at volume 0.1 for 3 seconds");
    thread::sleep(Duration::from_secs(3));
}

Why keep the program alive?

play() starts the stream. The three-second sleep keeps the stream in scope while the callback supplies audio.

  • Save src/main.rs.
  • Compile the callback by running this command:
cargo build

What does this build confirm?

Cargo checks the generic sample conversion and callback types. A successful exit proves the stream function matches CPAL's expected callback shape.

Good progress. Your audio callback now compiles with the device-aware sine-wave math in place.

Callback fails to compile?

Check that the first callback section sits directly before the closing section. Missing braces usually mean one section was pasted outside run().

Confirm that phase is declared as mutable. The callback updates it for every frame.

Help me fix the callback compiler error.

Select the default output path

An audio host connects your program to the operating system's audio system. Its default output device represents the speaker or headphones selected by Linux.

The device provides a SupportedStreamConfig with its channel count, sample rate, and sample format. Your program dispatches that format to the matching typed version of run().

  • Find the generated entry-point function near the top of src/main.rs:
fn main() {
    println!("Hello, world!");
}

What does the current entry point do?

This generated function only prints Hello, world!. It never asks CPAL for an audio device.

  • Delete this three-line main() function.
  • Paste the first section of the new main() function in its place:
fn main() {
    let frequency = 440.0;
    let volume = 0.1;
    let host = cpal::default_host();
    let device = host
        .default_output_device()
        .expect("no default output device is available");
    let supported_config = device
        .default_output_config()
        .expect("the default output configuration is unavailable");

    println!("Output device: {device}");
    println!("Default output config: {supported_config:?}");

    let sample_format = supported_config.sample_format();
    let config = supported_config.config();

How does the program choose its device?

  • frequency fixes the tone at 440 Hz.
  • volume keeps the generated samples at 0.1 of the available floating-point range.
  • cpal::default_host() selects the default audio host.
  • default_output_device() obtains the device selected for output.
  • default_output_config() reads a configuration supported by that device.
  • Complete main() with the sample-format dispatch:

    match sample_format {
        SampleFormat::I8 => run::<i8>(&device, config, frequency, volume),
        SampleFormat::I16 => run::<i16>(&device, config, frequency, volume),
        SampleFormat::I24 => run::<I24>(&device, config, frequency, volume),
        SampleFormat::I32 => run::<i32>(&device, config, frequency, volume),
        SampleFormat::I64 => run::<i64>(&device, config, frequency, volume),
        SampleFormat::U8 => run::<u8>(&device, config, frequency, volume),
        SampleFormat::U16 => run::<u16>(&device, config, frequency, volume),
        SampleFormat::U32 => run::<u32>(&device, config, frequency, volume),
        SampleFormat::U64 => run::<u64>(&device, config, frequency, volume),
        SampleFormat::F32 => run::<f32>(&device, config, frequency, volume),
        SampleFormat::F64 => run::<f64>(&device, config, frequency, volume),
        unsupported => panic!("unsupported sample format: {unsupported}"),
    }
}

Why dispatch the sample format?

Each audio device can request a different sample representation. The match sends every verified format to run() with the corresponding Rust type.

The wildcard arm reports unsupported formats clearly. This keeps the compatibility boundary visible.

  • Save src/main.rs.
  • Confirm the complete program compiles by running this command:
cargo build

What does this build confirm?

Cargo now checks the entry point against the typed stream function. A successful exit confirms that the host, device, configuration, dispatch, and callback fit together.

That is the wiring complete. Your binary can now select the Linux output path and prepare a typed stream.

Main function fails to compile?

Check that the sample-format match remains inside main(). Its final brace closes the function.

Confirm that every match arm passes &device, config, frequency, and volume in that order.

Help me compare my main function with the working dispatch.

✔️ Awesome, I've got everything!

Your src/main.rs is saved. The complete fixed-tone program compiles successfully.

ⓧ I'd like to double check the full code

Compare your complete src/main.rs with this reference.

use std::{f32::consts::TAU, thread, time::Duration};

use cpal::{
    traits::{DeviceTrait, HostTrait, StreamTrait},
    Device, Error, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample,
    StreamConfig,
};

fn main() {
    let frequency = 440.0;
    let volume = 0.1;
    let host = cpal::default_host();
    let device = host
        .default_output_device()
        .expect("no default output device is available");
    let supported_config = device
        .default_output_config()
        .expect("the default output configuration is unavailable");

    println!("Output device: {device}");
    println!("Default output config: {supported_config:?}");

    let sample_format = supported_config.sample_format();
    let config = supported_config.config();

    match sample_format {
        SampleFormat::I8 => run::<i8>(&device, config, frequency, volume),
        SampleFormat::I16 => run::<i16>(&device, config, frequency, volume),
        SampleFormat::I24 => run::<I24>(&device, config, frequency, volume),
        SampleFormat::I32 => run::<i32>(&device, config, frequency, volume),
        SampleFormat::I64 => run::<i64>(&device, config, frequency, volume),
        SampleFormat::U8 => run::<u8>(&device, config, frequency, volume),
        SampleFormat::U16 => run::<u16>(&device, config, frequency, volume),
        SampleFormat::U32 => run::<u32>(&device, config, frequency, volume),
        SampleFormat::U64 => run::<u64>(&device, config, frequency, volume),
        SampleFormat::F32 => run::<f32>(&device, config, frequency, volume),
        SampleFormat::F64 => run::<f64>(&device, config, frequency, volume),
        unsupported => panic!("unsupported sample format: {unsupported}"),
    }
}

fn run<T>(device: &Device, config: StreamConfig, frequency: f32, volume: f32)
where
    T: SizedSample + FromSample<f32>,
{
    let sample_rate = config.sample_rate as f32;
    let channels = config.channels as usize;
    let mut phase = 0.0;
    let phase_increment = frequency / sample_rate;
    let error_callback = |error: Error| eprintln!("audio stream error: {error}");
    let stream = device
        .build_output_stream(
            config,
            move |data: &mut [T], _: &OutputCallbackInfo| {
                for frame in data.chunks_mut(channels) {
                    let value = T::from_sample((phase * TAU).sin() * volume);
                    phase = (phase + phase_increment) % 1.0;
                    for sample in frame.iter_mut() {
                        *sample = value;
                    }
                }
            },
            error_callback,
            None,
        )
        .expect("failed to build the output stream");

    stream.play().expect("failed to start the output stream");
    println!("Playing 440 Hz at volume 0.1 for 3 seconds");
    thread::sleep(Duration::from_secs(3));
}

This reference contains the complete fixed-tone entry point and output callback for this step.

Play and trace the fixed tone

The output stream begins in a stopped state. Calling play() allows the device to request buffers from your callback.

Before you run it, predict whether both the terminal and speaker will show evidence of the same audio path.

  • Start the fixed-tone program by running this command:
cargo run

How does the tone reach the speaker?

You will see lines beginning with Output device: and Default output config:. You will then see Playing 440 Hz at volume 0.1 for 3 seconds while the tone plays.

  • cpal::default_host() selects the active host.
  • The host provides the default output device.
  • The device provides its supported configuration.
  • SampleFormat selects the matching typed stream function.
  • run() builds the stream around your callback.
  • The callback fills each buffer one channel frame at a time.
  • The three-second sleep keeps the stream alive while the speaker plays.

You have now sent a generated waveform through the complete CPAL audio path. Your speaker produces a 440 Hz sine wave at volume 0.1 for three seconds.

The pitch and volume are fixed inside main(), so every run produces the same tone. That limitation is now audible.

No tone from the speaker?

  • Confirm that Linux can play audio through the currently selected output device.
  • Close another audio application if the terminal mentions DeviceBusy from the ALSA backend.
  • Raise your system speaker volume slightly before running the program again.

Help me troubleshoot silent CPAL output on Linux.

Your fixed oscillator proves the complete signal path works. Next, you will move pitch and volume out of the source code so each run can produce a different sound.

Control Pitch and Volume

Your CPAL stream already carries a sine wave from the callback to your speaker. You can hear the complete audio path working.

The fixed oscillator still behaves like a single preset. Command-line arguments let each run choose its pitch or volume without a source edit.

In this step, get ready to:
  • Read both controls from terminal arguments.
  • Reject control values that cannot produce safe samples.
  • Compare two controlled tones through the existing audio stream.
Read terminal arguments

The env::args() iterator contains the values used to start your program. A helper can read one argument position while preserving a default when that position is empty.

  • Return to src/main.rs in your editor.
  • Replace the opening import section above main with this expanded block:
use std::{env, f32::consts::TAU, thread, time::Duration};

use cpal::{
    traits::{DeviceTrait, HostTrait, StreamTrait},
    Device, Error, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample,
    StreamConfig,
};

fn parse_argument(position: usize, default: f32, label: &str) -> f32 {
    match env::args().nth(position) {
        Some(value) => value
            .parse::<f32>()
            .unwrap_or_else(|_| panic!("{label} must be a number")),
        None => default,
    }
}

What does this code do?

  • The env import gives the program access to its terminal arguments.
  • The position parameter selects which argument to read.
  • The default parameter preserves the original control value when an argument is omitted.
  • The numeric parser turns terminal text into an f32 value for the oscillator.
  • Find let frequency = 440.0; near the start of main.
  • Find let volume = 0.1; directly below it.
  • Replace those two fixed declarations with this block:
    let frequency = parse_argument(1, 440.0, "frequency");
    let volume = parse_argument(2, 0.1, "volume");

How do the positions work?

Argument position 1 becomes the frequency. Position 2 becomes the volume.

The defaults remain 440.0 for frequency and 0.1 for volume. Running the program without control arguments still produces the original tone.

  • Save src/main.rs.

Before you run this check, do you expect the pitch to stay at its original value?

  • Play a lower-frequency tone by running:
cargo run -- 220 0.1

What should you hear?

You should see the selected output device plus its configuration. The playback status still shows a fixed message for now.

You should hear a 220 Hz tone for three seconds. Its pitch is lower than the original 440 Hz tone.

You have the first control working: your terminal now chooses the oscillator's pitch.

Does the program stop before playing?

  • Check that env appears at the start of the first import line.
  • Check that parse_argument appears before main.
  • Check that both argument positions use whole numbers without quotation marks.

Help me fix my Rust argument parsing code.

Validate the controls

A frequency must be finite and greater than zero. Volume must stay within the normalized sample range from 0.0 through 1.0.

  • Insert this function between parse_argument and main:
fn validate_controls(frequency: f32, volume: f32) {
    if !frequency.is_finite() || frequency <= 0.0 {
        panic!("frequency must be finite and greater than zero");
    }

    if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
        panic!("volume must be finite and between 0.0 and 1.0");
    }
}

What does validation protect?

  • The frequency check blocks non-finite values plus values at or below zero.
  • The volume check blocks non-finite values plus values outside the normalized range.
  • Each check stops the program before an invalid control reaches the audio stream.
  • Save src/main.rs.
  • Confirm the new function compiles by running:
cargo run -- 220 0.1

What should happen?

The project should compile successfully. You should hear the controlled tone for three seconds.

The compiler may mention that the new function is unused. The next edit connects it to the parsed controls.

  • Find let volume = parse_argument(2, 0.1, "volume"); inside main.
  • Add this validation call directly below that line:
    validate_controls(frequency, volume);

Why call validation here?

Both controls are available at this point. The validation call checks them before CPAL selects a device or creates a stream.

  • Save src/main.rs.
  • Confirm the valid controls still reach the speaker by running:
cargo run -- 220 0.1

What proves validation works?

The valid frequency plus volume pass the checks. You should hear the 220 Hz tone for three seconds.

Your controls now have a boundary between terminal input and the audio system.

Does a valid value stop playback?

  • Check that the frequency comparison uses <= 0.0.
  • Check that the volume range includes both endpoints with 0.0..=1.0.
  • Check that validate_controls receives frequency first.

Help me debug my control validation.

Compare the controlled tones

The existing run function already calculates phase_increment from the selected frequency. Its callback already scales each sine sample with the selected volume.

The playback status still names the original fixed controls. Updating that line makes each run report the values that actually reach the callback.

  • Find the final println! line in run that contains Playing 440 Hz at volume 0.1 for 3 seconds.
  • Replace that status line with this version:
    println!("Playing {frequency} Hz at volume {volume} for 3 seconds");

Why update the status line?

The placeholders print the same frequency plus volume values passed into run. The terminal now describes the tone you hear.

  • Save src/main.rs.

Before you compare the final runs, which frequency do you expect to produce the higher pitch?

  • Play the first comparison tone by running:
cargo run -- 220 0.1

What should the first run produce?

The playback status should show 220 Hz at volume 0.1. You should hear the lower tone for three seconds.

  • Play the second comparison tone by running:
cargo run -- 880 0.05

What should the second run produce?

The playback status should show 880 Hz at volume 0.05. You should hear a clearly higher tone for three seconds.

The lower volume value should make the second tone quieter. Both controls now travel from the terminal into the audio callback.

That is your synth behaving like an instrument: each run can request a different pitch or loudness.

Do both runs sound the same?

  • Check that each run call receives frequency plus volume.
  • Check that phase_increment divides frequency by sample_rate.
  • Check that the sine calculation multiplies its result by volume.

Help me trace my controls into the audio callback.

✔️ Awesome, I've got everything!

Your saved src/main.rs now accepts frequency plus volume arguments. Both comparison runs produce the requested tone.

ⓧ I'd like to double check the full code

Compare your complete src/main.rs with this version.

use std::{env, f32::consts::TAU, thread, time::Duration};

use cpal::{
    traits::{DeviceTrait, HostTrait, StreamTrait},
    Device, Error, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample,
    StreamConfig,
};

fn parse_argument(position: usize, default: f32, label: &str) -> f32 {
    match env::args().nth(position) {
        Some(value) => value
            .parse::<f32>()
            .unwrap_or_else(|_| panic!("{label} must be a number")),
        None => default,
    }
}

fn validate_controls(frequency: f32, volume: f32) {
    if !frequency.is_finite() || frequency <= 0.0 {
        panic!("frequency must be finite and greater than zero");
    }

    if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
        panic!("volume must be finite and between 0.0 and 1.0");
    }
}

fn main() {
    let frequency = parse_argument(1, 440.0, "frequency");
    let volume = parse_argument(2, 0.1, "volume");
    validate_controls(frequency, volume);

    let host = cpal::default_host();
    let device = host
        .default_output_device()
        .expect("no default output device is available");
    let supported_config = device
        .default_output_config()
        .expect("the default output configuration is unavailable");

    println!("Output device: {device}");
    println!("Default output config: {supported_config:?}");

    let sample_format = supported_config.sample_format();
    let config = supported_config.config();

    match sample_format {
        SampleFormat::I8 => run::<i8>(&device, config, frequency, volume),
        SampleFormat::I16 => run::<i16>(&device, config, frequency, volume),
        SampleFormat::I24 => run::<I24>(&device, config, frequency, volume),
        SampleFormat::I32 => run::<i32>(&device, config, frequency, volume),
        SampleFormat::I64 => run::<i64>(&device, config, frequency, volume),
        SampleFormat::U8 => run::<u8>(&device, config, frequency, volume),
        SampleFormat::U16 => run::<u16>(&device, config, frequency, volume),
        SampleFormat::U32 => run::<u32>(&device, config, frequency, volume),
        SampleFormat::U64 => run::<u64>(&device, config, frequency, volume),
        SampleFormat::F32 => run::<f32>(&device, config, frequency, volume),
        SampleFormat::F64 => run::<f64>(&device, config, frequency, volume),
        unsupported => panic!("unsupported sample format: {unsupported}"),
    }
}

fn run<T>(device: &Device, config: StreamConfig, frequency: f32, volume: f32)
where
    T: SizedSample + FromSample<f32>,
{
    let sample_rate = config.sample_rate as f32;
    let channels = config.channels as usize;
    let mut phase = 0.0;
    let phase_increment = frequency / sample_rate;
    let error_callback = |error: Error| eprintln!("audio stream error: {error}");
    let stream = device
        .build_output_stream(
            config,
            move |data: &mut [T], _: &OutputCallbackInfo| {
                for frame in data.chunks_mut(channels) {
                    let value = T::from_sample((phase * TAU).sin() * volume);
                    phase = (phase + phase_increment) % 1.0;
                    for sample in frame.iter_mut() {
                        *sample = value;
                    }
                }
            },
            error_callback,
            None,
        )
        .expect("failed to build the output stream");

    stream.play().expect("failed to start the output stream");
    println!("Playing {frequency} Hz at volume {volume} for 3 seconds");
    thread::sleep(Duration::from_secs(3));
}

What does the complete file contain?

The file parses both controls before validating them. It passes the accepted values through sample-format dispatch into the stream callback.

The callback converts frequency into a phase increment. It applies volume to every generated sine sample.

Your synth now responds to terminal controls. Next up, you'll isolate its signal math inside a tested oscillator type.

Extract and Test the Oscillator

Your CPAL audio path now turns terminal controls into an audible sine wave. The synth already responds to different frequencies plus volume levels.

The oscillator math still sits inside the callback. A Rust struct gives that math a clear home. Focused unit tests can then verify the waveform without relying on your speaker.

In this step, get ready to:
  • Move phase tracking plus sample generation into an Oscillator struct.
  • Give the audio callback ownership of the oscillator.
  • Verify the oscillator with two tests plus an audible playback check.
Extract the oscillator state

An Oscillator keeps the current phase beside the values that control its movement. This creates one traceable sound-generation core.

  • In src/main.rs, place your cursor below the CPAL import block.
  • Add the oscillator state by pasting this struct:
#[derive(Debug)]
struct Oscillator {
    phase: f32,
    phase_increment: f32,
    volume: f32,
}

What does this struct hold?

  • The phase field records the current position in one wave cycle.
  • The phase_increment field records the distance moved after each channel frame.
  • The volume field controls the peak size of each sample.
  • Save src/main.rs.
  • Check that the new struct compiles by running:
cargo build

What does this build prove?

Cargo compiles the current project without starting the audio stream. A successful build confirms that the new type fits into src/main.rs.

You should see the build finish successfully. The existing debug binary remains under target/debug/.

Does the struct fail to compile?

Check that the struct sits after the closing brace of the CPAL import block. Confirm that each field ends with a comma.

Compare the field names with the snippet above. Rust treats each identifier as an exact name.

help me fix my Oscillator struct

The struct defines the oscillator's stored state. Its methods now need to initialize that state plus generate each sample.

  • Place your cursor directly below the closing brace of Oscillator.
  • Add the oscillator methods by pasting this implementation:
impl Oscillator {
    fn new(frequency: f32, volume: f32, sample_rate: f32) -> Self {
        Self {
            phase: 0.0,
            phase_increment: frequency / sample_rate,
            volume,
        }
    }

    fn next_sample(&mut self) -> f32 {
        let sample = (self.phase * TAU).sin() * self.volume;
        self.phase = (self.phase + self.phase_increment) % 1.0;
        sample
    }
}

How do these methods work?

  • The new method starts the oscillator at phase 0.0.
  • The constructor divides frequency by sample rate to calculate one frame of phase movement.
  • The next_sample method calculates the current sine value before advancing the phase.
  • The modulo operation keeps the phase inside one normalized cycle.
  • Save src/main.rs.
  • Check the completed oscillator implementation by running:
cargo build

What does this build check?

The compiler checks the constructor plus the sample-generation method against the existing imports. It also confirms that TAU remains available for the sine calculation.

You should see the build complete successfully. The oscillator math now lives behind next_sample.

Seeing a method syntax error?

Check that impl Oscillator begins after the struct's closing brace. Compare every method brace with the snippet.

Confirm that next_sample ends with the plain sample expression. That expression returns the generated value.

help me fix the Oscillator methods

Move the oscillator into the callback

The audio callback receives an output buffer that must be filled one channel frame at a time. A separate write_data function keeps this buffer work away from stream creation.

A move closure takes ownership of the mutable oscillator. The same oscillator can then preserve its phase across repeated buffer requests.

  • Place your cursor below the closing brace of run.
  • Add the buffer-writing helper by pasting this function:
fn write_data<T>(output: &mut [T], channels: usize, oscillator: &mut Oscillator)
where
    T: Sample + FromSample<f32>,
{
    for frame in output.chunks_mut(channels) {
        let value = T::from_sample(oscillator.next_sample());
        for sample in frame.iter_mut() {
            *sample = value;
        }
    }
}

What does this helper do?

  • The output slice contains the samples requested by the audio device.
  • The chunks_mut call divides the buffer into channel frames.
  • The oscillator generates one value for each frame.
  • The inner loop copies the converted value into every channel sample.
  • Save src/main.rs.
  • Check that the new helper compiles by running:
cargo build

What does this build prove?

The compiler checks that write_data accepts the oscillator plus the typed output buffers used by the stream. The successful build confirms that the helper is ready for the callback.

Does the helper fail to compile?

Check that write_data sits outside the closing brace of run.

Confirm that the generic bound includes Sample plus FromSample<f32>.

help me fix the write_data helper

The helper can now fill an output buffer. The next edit creates one oscillator from the requested controls plus the device's sample rate.

  • In run, find the line that defines phase_increment.
  • Add this oscillator declaration directly below that line:
    let mut oscillator = Oscillator::new(frequency, volume, sample_rate);

Why create the oscillator here?

The run function already has the requested frequency plus volume. It also reads the device's sample rate.

Those three values let Oscillator::new calculate the correct phase increment for the active output device.

  • Save src/main.rs.
  • Check the oscillator declaration by running:
cargo build

What does this build establish?

A successful build confirms that run can construct the oscillator from its existing values. The callback still uses the original phase loop until the next edit.

You should see the build finish successfully. The new oscillator is ready to move into the callback.

  • Inside build_output_stream, locate the move closure that contains the sample loop.
  • Replace that complete closure with this callback:
            move |data: &mut [T], _: &OutputCallbackInfo| {
                write_data(data, channels, &mut oscillator)
            },

How does ownership work here?

The move keyword transfers the mutable oscillator into the callback. Every buffer request reuses the same phase state.

The callback passes temporary mutable access into write_data for each output buffer. Stream creation stays separate from sample generation.

  • Save src/main.rs.
  • Check the ownership change by running:
cargo build

What does this ownership check prove?

A successful build proves that the callback owns oscillator. It also proves that write_data can mutate the oscillator for every requested buffer.

You should see the build finish successfully. The original phase variables can now leave run.

  • In src/main.rs, select the opening of run from its function declaration through the oscillator declaration.
  • Replace that selection with this final opening:
fn run<T>(
    device: &Device,
    config: StreamConfig,
    frequency: f32,
    volume: f32,
) where
    T: SizedSample + FromSample<f32>,
{
    let sample_rate = config.sample_rate as f32;
    let channels = config.channels as usize;
    let mut oscillator = Oscillator::new(frequency, volume, sample_rate);

What changed in run?

The standalone phase plus phase_increment variables are gone. The oscillator now owns both values.

The function parameters remain unchanged. Your terminal controls still enter the same audio path.

  • Save src/main.rs.
  • Check the completed callback refactor by running:
cargo build

What does the completed build prove?

The successful build verifies the complete ownership path. run creates the oscillator.

The callback owns that oscillator. The helper advances it once per channel frame.

Seeing an ownership error?

Confirm that the callback includes move before its parameter list. Check that it passes &mut oscillator into write_data.

Make sure the original phase plus phase_increment declarations no longer remain inside run.

help me debug the oscillator ownership

Prove the oscillator with tests

Audio can sound correct while a small math error remains hidden. Deterministic tests inspect exact waveform points without depending on your output device.

  • Place your cursor at the bottom of src/main.rs.
  • Add the oscillator tests by pasting this module:
#[cfg(test)]
mod tests {
    use super::Oscillator;

    #[test]
    fn oscillator_starts_at_zero() {
        let mut oscillator = Oscillator::new(440.0, 0.5, 48_000.0);
        assert!(oscillator.next_sample().abs() < 0.000_001);
    }

    #[test]
    fn quarter_cycle_reaches_requested_volume() {
        let mut oscillator = Oscillator::new(1.0, 0.5, 4.0);
        oscillator.next_sample();
        let sample = oscillator.next_sample();
        assert!((sample - 0.5).abs() < 0.000_001);
    }
}

What do these tests prove?

The first test verifies that a new sine wave begins at its zero crossing. The small tolerance handles floating-point comparisons.

The second test uses four samples for one cycle. Its second generated sample reaches the requested positive peak.

  • Save src/main.rs.

Before you run the tests, do you expect both waveform checkpoints to pass?

  • Compile plus execute the oscillator tests by running:
cargo test

What does this test run check?

Cargo compiles the project in test mode. It executes both functions marked with #[test].

Both tests call next_sample directly. A failure therefore points to the oscillator math.

You should see two passing tests named oscillator_starts_at_zero plus quarter_cycle_reaches_requested_volume.

Does an oscillator test fail?

Check that next_sample calculates the sample before advancing the phase. Reversing that order changes the initial value.

Confirm that the quarter-cycle test uses frequency 1.0. Confirm that it uses volume 0.5 plus sample rate 4.0.

help me debug the oscillator tests

Before the final run, do you expect the refactored synth to preserve its audible behavior?

  • Play the refactored synth at 330 Hz with volume 0.1 by running:
cargo run -- 330 0.1

What does the final run verify?

The command passes frequency 330 plus volume 0.1 through the existing terminal controls. The callback produces that tone through the tested oscillator.

The stream remains active for three seconds. This audible check proves that the refactor preserved the complete audio path.

You should see the output device plus its configuration in the terminal. You should hear a 330 Hz sine wave for three seconds.

Tests pass but the tone is silent?

Check that your Linux output device is available to the program. Confirm that your system volume is audible.

Another sound service can hold the default ALSA device. Close other audio applications before retrying the command.

help me restore the audio output

That is the refactor proven. Your synth now has a tested sound-generation core that still responds to terminal controls.

✔️ Awesome, I've got everything!

Your saved src/main.rs now contains the tested oscillator refactor. The final audio check confirms that it still produces the requested tone.

ⓧ I'd like to double check the full code

Compare your saved src/main.rs with this complete version.

use std::{env, f32::consts::TAU, thread, time::Duration};

use cpal::{
    traits::{DeviceTrait, HostTrait, StreamTrait},
    Device, Error, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample,
    StreamConfig,
};

#[derive(Debug)]
struct Oscillator {
    phase: f32,
    phase_increment: f32,
    volume: f32,
}

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

    fn next_sample(&mut self) -> f32 {
        let sample = (self.phase * TAU).sin() * self.volume;
        self.phase = (self.phase + self.phase_increment) % 1.0;
        sample
    }
}

fn parse_argument(position: usize, default: f32, label: &str) -> f32 {
    match env::args().nth(position) {
        Some(value) => value
            .parse::<f32>()
            .unwrap_or_else(|_| panic!("{label} must be a number")),
        None => default,
    }
}

fn validate_controls(frequency: f32, volume: f32) {
    if !frequency.is_finite() || frequency <= 0.0 {
        panic!("frequency must be finite and greater than zero");
    }

    if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
        panic!("volume must be finite and between 0.0 and 1.0");
    }
}

fn main() {
    let frequency = parse_argument(1, 440.0, "frequency");
    let volume = parse_argument(2, 0.1, "volume");
    validate_controls(frequency, volume);

    let host = cpal::default_host();
    let device = host
        .default_output_device()
        .expect("no default output device is available");
    let supported_config = device
        .default_output_config()
        .expect("the default output configuration is unavailable");

    println!("Output device: {device}");
    println!("Default output config: {supported_config:?}");

    let sample_format = supported_config.sample_format();
    let config = supported_config.config();

    match sample_format {
        SampleFormat::I8 => run::<i8>(&device, config, frequency, volume),
        SampleFormat::I16 => run::<i16>(&device, config, frequency, volume),
        SampleFormat::I24 => run::<I24>(&device, config, frequency, volume),
        SampleFormat::I32 => run::<i32>(&device, config, frequency, volume),
        SampleFormat::I64 => run::<i64>(&device, config, frequency, volume),
        SampleFormat::U8 => run::<u8>(&device, config, frequency, volume),
        SampleFormat::U16 => run::<u16>(&device, config, frequency, volume),
        SampleFormat::U32 => run::<u32>(&device, config, frequency, volume),
        SampleFormat::U64 => run::<u64>(&device, config, frequency, volume),
        SampleFormat::F32 => run::<f32>(&device, config, frequency, volume),
        SampleFormat::F64 => run::<f64>(&device, config, frequency, volume),
        unsupported => panic!("unsupported sample format: {unsupported}"),
    }
}

fn run<T>(
    device: &Device,
    config: StreamConfig,
    frequency: f32,
    volume: f32,
) where
    T: SizedSample + FromSample<f32>,
{
    let sample_rate = config.sample_rate as f32;
    let channels = config.channels as usize;
    let mut oscillator = Oscillator::new(frequency, volume, sample_rate);

    let error_callback = |error: Error| eprintln!("audio stream error: {error}");
    let stream = device
        .build_output_stream(
            config,
            move |data: &mut [T], _: &OutputCallbackInfo| {
                write_data(data, channels, &mut oscillator)
            },
            error_callback,
            None,
        )
        .expect("failed to build the output stream");

    stream.play().expect("failed to start the output stream");
    println!("Playing {frequency} Hz at volume {volume} for 3 seconds");
    thread::sleep(Duration::from_secs(3));
}

fn write_data<T>(output: &mut [T], channels: usize, oscillator: &mut Oscillator)
where
    T: Sample + FromSample<f32>,
{
    for frame in output.chunks_mut(channels) {
        let value = T::from_sample(oscillator.next_sample());
        for sample in frame.iter_mut() {
            *sample = value;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::Oscillator;

    #[test]
    fn oscillator_starts_at_zero() {
        let mut oscillator = Oscillator::new(440.0, 0.5, 48_000.0);
        assert!(oscillator.next_sample().abs() < 0.000_001);
    }

    #[test]
    fn quarter_cycle_reaches_requested_volume() {
        let mut oscillator = Oscillator::new(1.0, 0.5, 4.0);
        oscillator.next_sample();
        let sample = oscillator.next_sample();
        assert!((sample - 0.5).abs() < 0.000_001);
    }
}

Secret mission

Add a Square Wave

Your oscillator already produces a tested sine wave. In this mission, you will add a selectable square wave while keeping the audio callback unchanged.

Clean Up Your Resources

Clean Up Your Resources

Your synth runs entirely on your Linux machine with no ongoing cost. Choose one cleanup option below.

Resources you used:

  • The local rust-tone directory containing Cargo.toml, Cargo.lock, src/main.rs, target/, and target/debug/rust-tone.

Keep everything running

No action needed. Choose this if you want to keep experimenting with the oscillator.

  • Your rust-tone directory remains available for future audio projects.
  • Your synth leaves no background service running after playback ends.
  • Your sine-wave tests remain ready for future changes to Oscillator.

Pause - I'll come back to this later

Pausing keeps every project file ready for your next session. The synth ends automatically after each three-second run.

  • Close the terminal you used for rust-tone.
  • Close your editor.
  • Return to the rust-tone directory when you are ready to continue.

Delete - I don't want to use this again

Deleting the project can feel final. Only the local rust-tone directory is affected.

  • Use your Linux file manager to locate the rust-tone directory in the folder where you created it.
  • Make a copy of the rust-tone directory if you may reuse the oscillator.
  • Delete the rust-tone directory through your file manager.
  • Confirm that rust-tone no longer appears in its previous folder.

That single deletion removes Cargo.toml, Cargo.lock, src/main.rs, target/, and the compiled target/debug/rust-tone binary.

Nice Work!

Nice Work!

You turned Rust code into sound! Your rust-tone synthesizer now turns terminal controls into tested sine or square audio through the default Linux output device.

You've learned how to:

  • Build a low-level CPAL audio path from the default host to the device configuration. Feed each channel frame through an output stream callback.
  • Generate sine waves with phase accumulation based on the device's actual sample rate. Control frequency and volume through terminal arguments.
  • Move signal generation into a tested Oscillator struct. Use a move closure to give the audio callback ownership. Verify the initial zero crossing with a unit test. Verify the quarter-cycle peak with another unit test.
  • Secret Mission: Extended Oscillator with selectable sine or square waveforms through the third terminal argument. Kept sine as the default. Added a test for the square wave's sign change.

Ready to quiz yourself?