Built-in project · Arduino Uno R3

Waveform lab on an Arduino Uno

PWM audio on an Uno: Timer 1 and an RC filter turn pulse widths into a sine, triangle, sawtooth or square wave on a scope, while a piezo plays the same pitch. The whole build, an Arduino Uno and 6 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 25 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $33. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Sound
  • Analog
  • Choosing and reading a resistor
  • Reading a button
  • Reading a knob with an analog pin
  • Difficulty: Intermediate
  • Time: About 25 minutes
  • Board: Arduino Uno R3
  • Parts: 7

How it works

PWM audio on an Uno: Timer 1 and an RC filter turn pulse widths into a sine, triangle, sawtooth or square wave on a scope, while a piezo plays the same pitch.

Arduino Uno R3 runs the sketch below. The circuit around it: Resistor, 1k Ω, Capacitor, 100 nF, Pushbutton, Potentiometer, Piezo speaker, Oscilloscope. pinMode sets WAVE_PIN as an input with its pull-up on. digitalRead reads whether WAVE_PIN is HIGH or LOW. analogRead measures the voltage on A1 as a number. Serial.begin opens the serial port at 9600 baud. tone plays a note on PIEZO_PIN.

Parts list

7 parts, plus the jumper wires and a bench instrument. The parts retail for about $33; on Mokxi they're free, and so are the instruments.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Pushbutton pin 2aArduino Uno R3 pin GND
Piezo speaker pin 2Arduino Uno R3 pin GND
Piezo speaker pin 1Arduino Uno R3 pin 10
Resistor, 1k Ω pin 1Arduino Uno R3 pin 9
Oscilloscope pin CH2Arduino Uno R3 pin 9
Pushbutton pin 1aArduino Uno R3 pin 2
Potentiometer pin 3Arduino Uno R3 pin 5V
Capacitor, 100 nF pin 2Arduino Uno R3 pin GND
Potentiometer pin 1Arduino Uno R3 pin GND
Oscilloscope pin GNDArduino Uno R3 pin GND
Potentiometer pin 2Arduino Uno R3 pin A1
Capacitor, 100 nF pin 1Resistor, 1k Ω pin 2
Oscilloscope pin CH1Resistor, 1k Ω pin 2

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Waveform lab: PWM audio. See what a pitch looks like, and hear it.
//
// Wiring:
//   pin 9      -> 1 k ohm -> the scope's channel 1 -> 100 nF to GND   (the filter)
//   wave       pin 2 to GND: picks sine, triangle, sawtooth or square
//   pitch      potentiometer wiper -> A1, ends to 5 V and GND (55 to 400 Hz)
//   piezo      pin 10 -> piezo -> GND: plays the same pitch
//
// A pin is either high or low, so how does a board make a smooth wave? With PWM,
// pulse width modulation: the pin switches on and off very fast, and the fraction
// of the time it spends on (the duty) is the level you want. 25% on averages to
// 1.25 V, 50% on to 2.5 V, 75% on to 3.75 V. A filter does the averaging: the 1 k
// resistor and 100 nF capacitor pass slow changes (the wave, a few hundred times
// a second) and smooth away fast ones (the 62,500 times a second the pin
// switches). That is how a little board plays real sound, without a DAC chip.
//
// Timer 1 makes the fast switching by itself (fast PWM, 8 bit, no prescaler: the
// 16 MHz clock counts 0 to 255 over and over, 62,500 times a second). The sketch
// only sets the duty, OCR1A, once per point of the wave.
//
// The sketch keeps a table of 32 duties for one cycle of each shape:
//
//   sine      the purest tone, one smooth curve
//   triangle  a soft tone, like a flute
//   sawtooth  a bright, buzzy tone, like a bowed string
//   square    a hollow tone, like a clarinet
//
// To play a note it steps through the table at a steady rate of frequency * 32
// points a second, so the whole table goes round `frequency` times a second. The
// piezo plays the same pitch with tone(), which is always a square wave.
//
// Things to try. Watch the scope as you press the wave button. Turn the knob up
// and squeeze more cycles into the scope's window. Change the 100 nF capacitor to
// 1 uF and watch the square wave's corners round off: the filter is slowing the
// wave down too.

const uint8_t WAVE_PIN = 2;
const uint8_t PWM_PIN = 9;     // OC1A: Timer 1's output A
const uint8_t PIEZO_PIN = 10;
const uint8_t POINTS = 32;

enum Wave : uint8_t { SINE, TRIANGLE, SAW, SQUARE, WAVES };
const char *const WAVE_NAME[WAVES] = {"sine", "triangle", "sawtooth", "square"};

// One cycle of a sine, 32 points from 0 to 255.
const uint8_t SINE_TABLE[POINTS] = {
  128, 153, 177, 199, 218, 234, 246, 253,
  255, 253, 246, 234, 218, 199, 177, 153,
  128, 103, 79, 57, 38, 22, 10, 3,
  0, 3, 10, 22, 38, 57, 79, 103,
};

static uint8_t point(uint8_t wave, uint8_t i)
{
  switch (wave) {
    case SINE:
      return SINE_TABLE[i];
    case TRIANGLE:
      // up for 16 points, down for 16
      return i < 16 ? i * 255 / 16 : (32 - i) * 255 / 16;
    case SAW:
      return i * 255 / (POINTS - 1);
    default:
      return i < 16 ? 255 : 0;
  }
}

uint8_t wave = SINE;
uint8_t index = 0;
uint16_t hz = 110;
uint16_t toneHz = 0;
uint32_t nextPoint = 0;
uint32_t lastReport = 0;
uint32_t lastKnob = 0;
bool wasDown = false;
uint32_t changedAt = 0;

void setup()
{
  Serial.begin(9600);
  pinMode(WAVE_PIN, INPUT_PULLUP);
  pinMode(PWM_PIN, OUTPUT);
  pinMode(PIEZO_PIN, OUTPUT);
  // Timer 1: fast PWM, 8 bit (WGM 5), output A cleared on the match, no prescaler
  TCCR1A = (1 << COM1A1) | (1 << WGM10);
  TCCR1B = (1 << WGM12) | (1 << CS10);
  OCR1A = 128;
  Serial.println("Waveform lab ready: sine");
  nextPoint = micros();
}

void loop()
{
  uint32_t ms = millis();

  // the wave button
  bool down = digitalRead(WAVE_PIN) == LOW;
  if (down != wasDown && ms - changedAt > 20) {
    wasDown = down;
    changedAt = ms;
    if (down) {
      wave = (wave + 1) % WAVES;
      Serial.print("Wave ");
      Serial.println(WAVE_NAME[wave]);
    }
  }

  // the pitch knob, 55 to 400 Hz, looked at every 25 ms: an analog reading takes
  // about 0.1 ms and the wave should not wait for it. The piezo is only retuned
  // when the pitch has moved a little, because tone() starts the timer over.
  if (ms - lastKnob >= 25) {
    lastKnob = ms;
    hz = 55 + (uint16_t)((analogRead(A1) * 345UL) / 1023);
    if (hz > toneHz + 3 || hz + 3 < toneHz) {
      toneHz = hz;
      tone(PIEZO_PIN, hz);
    }
  }

  // the next point of the cycle, on time
  uint32_t now = micros();
  if ((int32_t)(now - nextPoint) >= 0) {
    nextPoint += 1000000UL / ((uint32_t)hz * POINTS);
    OCR1A = point(wave, index);
    index = (index + 1) % POINTS;
  }

  if (ms - lastReport >= 1000) {
    lastReport = ms;
    Serial.print(WAVE_NAME[wave]);
    Serial.print(" ");
    Serial.print(hz);
    Serial.println(" Hz");
  }
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup()
{
  Serial.begin(9600);
  pinMode(WAVE_PIN, INPUT_PULLUP);
  pinMode(PWM_PIN, OUTPUT);
  pinMode(PIEZO_PIN, OUTPUT);
  // Timer 1: fast PWM, 8 bit (WGM 5), output A cleared on the match, no prescaler
  TCCR1A = (1 << COM1A1) | (1 << WGM10);
  TCCR1B = (1 << WGM12) | (1 << CS10);
  OCR1A = 128;
  Serial.println("Waveform lab ready: sine");
  nextPoint = micros();
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop()
{
  uint32_t ms = millis();

  // the wave button
  bool down = digitalRead(WAVE_PIN) == LOW;
  if (down != wasDown && ms - changedAt > 20) {
    wasDown = down;
    changedAt = ms;
    if (down) {
      wave = (wave + 1) % WAVES;
      Serial.print("Wave ");
      Serial.println(WAVE_NAME[wave]);
    }
  }

  // the pitch knob, 55 to 400 Hz, looked at every 25 ms: an analog reading takes
  // about 0.1 ms and the wave should not wait for it. The piezo is only retuned
  // when the pitch has moved a little, because tone() starts the timer over.
  if (ms - lastKnob >= 25) {
    lastKnob = ms;
    hz = 55 + (uint16_t)((analogRead(A1) * 345UL) / 1023);
    if (hz > toneHz + 3 || hz + 3 < toneHz) {
      toneHz = hz;
      tone(PIEZO_PIN, hz);
    }
  }

  // ... 16 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
    Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure.
  • A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
    Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground.
  • An electrolytic capacitor heats up, bulges or pops. Capacitor
    Electrolytics have a polarity. The stripe marks the negative leg, which goes toward ground. Also keep the voltage under the rated value, such as 10 V or 16 V.
  • An RC circuit charges much slower or faster than expected. Capacitor
    The time constant is R times C. A 10 k resistor with a 100 uF capacitor gives 1 second, and about 5 seconds to charge fully. Check that you read the value on the part right.
  • The input reads random HIGH and LOW when the button is not pressed. Pushbutton
    A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW.
  • The button seems always pressed, or never changes. Pushbutton
    A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
  • The reading jumps around or only covers part of the range. Potentiometer
    Wire the two outer legs to 5 V and ground and the middle leg (the wiper) to an analog pin. Using the wrong two legs gives a dead or partial range.
  • Turning the knob does nothing, or the value is stuck at 0 or 1023. Potentiometer
    Check that the wiper goes to an analog input (A0 to A5 on an Uno), not a digital pin. Make sure both ends are connected, since a floating end gives noise.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.