Ultrasonic PWM tone generation for passive buzzers using the RP2040 PIO coprocessor.
The standard Arduino tone() function on the RP2040 has several problems:
| Problem | tone() / PWM |
BuzzerPIO v2.5 |
|---|---|---|
| Timing jitter | 50–200 ms when loop is busy | < 10 µs (hardware alarm) |
| Blocking | Some implementations block | 100% non-blocking |
| PWM conflicts | Uses PWM slices shared with servos/LEDs | Uses PIO (independent) |
| Volume control | Not supported | 32 levels via ultrasonic PWM duty cycle |
| Volume perception | N/A | Perceptual curve (quadratic mapping) |
| Melody playback | Requires polling in loop() | Hardware alarm chain |
| Melody events | N/A | Completion callback (IRQ-driven) |
| Melody control | N/A | Pause/resume support |
| CPU usage during tone | Periodic ISR or busy-wait | Zero (PIO runs alone) |
| Volume distortion | N/A | None (ultrasonic carrier, not audible-freq duty) |
| Multi-core safety | Not specified | Spinlock-protected (safe from both cores) |
SM1 (ultrasonic PWM) SM2 (tone gate)
┌─────────────────┐ ┌───────────────────┐
│ set pins,1 [dH] │ │ set pindirs,1[31] │
clkdiv=40 │ set pins,0 [dL] │ clkdiv=var │ set pindirs,0[31] │
~95 kHz │ (wraps [0..1]) │ =tone freq │ (wraps [2..3]) │
└────────┬────────┘ └────────┬──────────┘
│ VALUE │ OE
│ │
PIO combines: value = SM1_val, OE = SM2_oe
│ │
└──────────── GPIO pin ──────────┘
│
pull-down R
│
GND
OE=1 → pin outputs SM1 PWM (buzzer hears ultrasonic carrier)
OE=0 → pin hi-Z → pulled LOW (silence)
Result: output = SM1_pwm AND SM2_gate
-
SM1 (PWM carrier) — 2 instructions wrapping
[0..1]. Generates a ~95 kHz ultrasonic PWM square wave viaset pins. The duty cycle controls the equivalent amplitude (volume). The buzzer's mechanical inertia low-pass filters the carrier, so only the duty-cycle envelope is perceived as loudness. -
SM2 (tone gate) — 2 instructions wrapping
[2..3]. Square wave at the audible frequency viaset pindirs(output enable). When the gate is open (OE=1), the buzzer hears SM1's PWM. When closed (OE=0), the GPIO pull-down holds the pin LOW. -
AND gate via hardware — SM1 controls the pin VALUE but never touches OE. SM2 controls OE but never touches VALUE. The RP2040 PIO ORs per-SM outputs within a block:
final_value = SM1_val,final_OE = SM2_oe. With the GPIO pull-down, the result isoutput = SM1_pwm AND SM2_gate. -
Volume — Patching SM1's instruction delays changes the PWM duty cycle from 3% (barely audible) to 97% (maximum). v2.5 uses a quadratic mapping curve so that perceived loudness increases evenly across the 0–100 range (matching human hearing's logarithmic response). The carrier frequency stays constant at ~95 kHz regardless of volume — no harmonic distortion, no frequency shift.
-
Melody sequencing — Hardware alarm chain (same proven mechanism as v1.0). Each note transition sets SM2's clock divider for the new frequency. All note transitions happen with < 10 µs jitter, completely independent of the main loop.
- Download the latest release as a
.zipfile - In Arduino IDE: Sketch → Include Library → Add .ZIP Library...
- Select the downloaded
.zipfile
Add to your platformio.ini:
lib_deps =
https://github.com/angeloINTJ/BuzzerPIO_RP2040.gitSearch for BuzzerPIO_RP2040 in the Library Manager (Tools → Manage Libraries...).
#include <BuzzerPIO_RP2040.h>
BuzzerPIO buzzer(22); // GPIO 22, auto-probes pio0 then pio1
void setup() {
buzzer.begin();
buzzer.setVolume(80);
buzzer.tone(1000, 500); // 1 kHz for 500 ms — non-blocking!
}
void loop() {
// CPU is free — dual PIO state machines handle everything
}BuzzerPIO(uint8_t pin, PIO pio = pio0);| Parameter | Description |
|---|---|
pin |
GPIO number (0–29) connected to the passive buzzer |
pio |
Preferred PIO block: pio0 (default) or pio1. If unavailable, begin() automatically tries the other block. |
PIO auto-probe: The library needs 4 instruction slots + 2 state machines in the same PIO block (the AND gate requires per-block OR of SM outputs). If the preferred block doesn't have enough resources, begin() transparently falls back to the other.
bool begin(); // Initialize dual-SM PIO. Returns false if resources unavailable.
void end(); // Release all resources. Called automatically by destructor.
bool isReady(); // Check if begin() succeeded.
PIO getActivePio(); // Returns which PIO block was actually allocated.void tone(uint32_t freqHz); // Continuous tone
void tone(uint32_t freqHz, uint16_t durationMs); // Timed tone (auto-stop)
void noTone(); // Silence immediatelyAll calls are non-blocking. A timed tone uses a hardware alarm for auto-shutoff — no CPU involvement.
Frequency range: 15 Hz – 976 kHz (uint32_t). Audible sweet spot: 200 Hz – 8 kHz.
Note:
BuzzerNote::freqHzisuint16_t(max 65535 Hz), which is more than sufficient for audible melodies. Thetone()method acceptsuint32_tto cover the full hardware-supported range for ultrasonic or special-purpose applications.
void setVolume(uint8_t volume); // 0 (silent) to 100 (max)
uint8_t getVolume();Volume controls the ultrasonic PWM duty cycle. Can be changed while a tone is playing — takes effect immediately via PIO instruction patching.
v2.5 uses a quadratic mapping curve for perceptually linear volume steps. This means the difference between volume 10→20 sounds roughly the same as 50→60, matching how human hearing works.
| Volume | Duty (v2.5 quadratic) | Carrier frequency | Perceived effect |
|---|---|---|---|
| 100% | 97% | ~95 kHz | Maximum amplitude |
| 50% | ~25% | ~95 kHz | Perceptually "half" |
| 10% | ~3% | ~95 kHz | Very quiet |
| 0% | — | — | Silent (gate disabled) |
The carrier frequency is constant regardless of volume — no harmonic distortion.
void playMelody(const BuzzerNote* notes, uint16_t len); // Play once
void playMelodyLoop(const BuzzerNote* notes, uint16_t len); // Play forever
void stopMelody(); // Stop immediately
void pauseMelody(); // Pause (v2.5)
void resumeMelody(); // Resume (v2.5)
bool isPlaying(); // Check status
bool isLooping(); // Check loop mode
bool isPaused(); // Check pause state (v2.5)BuzzerNote structure:
struct BuzzerNote {
uint16_t freqHz; // Frequency in Hz (0 = silent pause, max 65535)
uint16_t durationMs; // Duration in milliseconds (max 65535 ≈ 65.5 s)
};Example — defining a melody:
const BuzzerNote myMelody[] = {
{ 523, 200 }, // C5 for 200 ms
{ 659, 200 }, // E5 for 200 ms
{ 0, 100 }, // 100 ms silence
{ 784, 400 } // G5 for 400 ms
};
buzzer.playMelody(myMelody, 4);Important: The
notesarray must remain valid (in memory) for the entire duration of playback. Useconstarrays at global/file scope orstaticarrays inside functions. Do not pass a local array that goes out of scope before the melody finishes.
void setMelodyDoneCallback(MelodyDoneCallback cb, void* userData = nullptr);Register a callback that fires when a one-shot melody finishes its last note. The callback does not fire for looping melodies or when stopMelody() is called manually.
volatile bool melodyDone = false;
void onDone(void* /* userData */) {
melodyDone = true; // Set flag — don't do heavy work here!
}
buzzer.setMelodyDoneCallback(onDone);
buzzer.playMelody(notes, len);
// In loop():
if (melodyDone) {
melodyDone = false;
// React to melody completion
}Warning: The callback fires from hardware alarm IRQ context. Keep it minimal — set a flag, nothing more. No Serial, no delay(), no heap allocation.
buzzer.pauseMelody(); // Silences buzzer, preserves position
buzzer.resumeMelody(); // Continues from the next noteUseful for temporary mute during alarm scenarios. The current note is considered consumed when paused — resume starts from the next note.
All public methods are multi-core safe. Shared state between the application thread and hardware alarm callbacks is protected by a pico-sdk critical_section_t (hardware spinlock + local interrupt disable). You can safely call tone(), stopMelody(), etc. from either RP2040 core.
isPlaying(), isLooping(), isPaused(), and getVolume() return point-in-time snapshots — the melody may finish immediately after the call returns true. This is the expected behavior for lock-free status queries.
v2.5 additionally protects begin() and end() with the critical section, closing race windows that existed in v2.4 when these methods were called from different cores.
RP2040 GPIO 22 ──── (+) Passive Buzzer (–) ──── GND
│
└── (Optional) 100Ω resistor for current limiting
Passive buzzer only. Active buzzers have a built-in oscillator and produce a fixed tone regardless of the input frequency — they won't work with this library.
The library enables the internal GPIO pull-down automatically. No external pull-down resistor is needed.
Override this define before including the library header, or via build flags:
// Higher carrier frequency (~190 kHz) — less audible on some buzzers
#define BUZZER_PWM_CLKDIV 20
#include <BuzzerPIO_RP2040.h>| Define | Default | Description |
|---|---|---|
BUZZER_PWM_CLKDIV |
40 | SM1 clock divider. Carrier ≈ 125 MHz / (CLKDIV × 33) |
| Resource | Usage |
|---|---|
| PIO state machines | 2 (auto-claimed, same block) |
| PIO instruction memory | 4 slots (of 32 per block) |
| DMA channels | 0 |
| IRQ handlers | 0 |
| Hardware alarms | 1 (from Pico SDK alarm pool) |
| Hardware spinlocks | 1 (via critical_section_t) |
| RAM | ~56 bytes per instance |
| Flash | ~2.8 KB (code) |
| CPU | 0% during tone/melody (only on API calls) |
The dual-SM architecture was specifically designed to fit alongside other PIO-intensive libraries on the Pico W:
| PIO block | Library | Instructions | SMs |
|---|---|---|---|
| pio0 | OneWirePIO_RP2040 (DS18B20) | 27/32 | 1/4 |
| pio0 | BuzzerPIO (if auto-probed here) | 4/32 | 2/4 |
| pio1 | CYW43 WiFi SPI (Pico W) | ~10/32 | 1/4 |
| pio1 | DHT22PIO_RP2040 | 17/32 | 1/4 |
| pio1 | BuzzerPIO (if auto-probed here) | 4/32 | 2/4 |
Both blocks have room for BuzzerPIO (4 instructions + 2 SMs). The auto-probe tries the preferred block first, then falls back. If neither block has enough resources, begin() returns false.
No code changes required. The public API is backward compatible. Just update the library and recompile.
Behavioral differences:
- Volume quality: Volume changes are now distortion-free. In v1.0, low volume settings distorted the waveform by making it asymmetric. In v2.x, volume controls the ultrasonic carrier duty cycle — the audible waveform stays symmetric at all levels.
- Volume curve (v2.5): Volume mapping is now quadratic (perceptually linear). The same
setVolume(50)call will sound quieter than in v2.4 because it now represents ~25% duty instead of ~50%. If you relied on specific volume→duty mappings, adjust your volume values. - Resource usage: Uses 1 additional state machine (2 total vs 1 in v1.0) + 1 spinlock. No DMA channels needed.
- Auto-probe: If the preferred PIO block is full,
begin()transparently tries the other. In v1.0, it would just fail.
tone()parameter changed fromuint16_ttouint32_t(v2.3). Existing code compiles without changes (implicit widening).- Internal locking upgraded from interrupt-disable to
critical_section_t(v2.3). No API changes required. - Volume curve changed (v2.5): See note above under v1.x migration.
- New methods (v2.5):
pauseMelody(),resumeMelody(),isPaused(),setMelodyDoneCallback(). All additive — existing code is unaffected.
Q: Can I use this with NeoPixels / WS2812?
A: Yes. NeoPixel libraries typically use pio0 SM0. BuzzerPIO auto-claims the next free SMs. If pio0 is full, pass pio1: BuzzerPIO buzzer(22, pio1);
Q: Can I have two buzzers? A: Only one per PIO block (each instance needs 2 SMs + 4 instruction slots). With both PIO blocks, you could have two buzzers on different blocks.
Q: Does this work on the Pico W?
A: Yes. The Pico W uses pio1 SM0 for the CYW43 Wi-Fi driver. BuzzerPIO on pio1 auto-claims SM1+SM2 (leaving SM0 for Wi-Fi). On pio0, there's no conflict at all.
Q: What about the Pico 2 (RP2350)? A: The RP2350 has PIO v2 with the same instruction set and 3 PIO blocks. This library should work without changes, but has not been tested yet.
Q: Why ultrasonic PWM instead of direct frequency like v1.0? A: In v1.0, volume was controlled by making the square wave asymmetric (e.g., 10% HIGH / 90% LOW). This changes the harmonic content — the tone sounds different at different volumes. In v2.x, the audible waveform is always a clean 50% duty square wave. Volume is controlled by the amplitude of an ultrasonic carrier that the buzzer's mechanical inertia filters out. The result: volume changes only change loudness, not timbre.
Q: Why two state machines instead of one? A: The AND gate trick requires two independent signals on the same GPIO — one for value and one for output enable. A single SM can't toggle both independently at different frequencies. The dual-SM approach achieves this with zero DMA and zero IRQ handlers, using only PIO-internal hardware.
Q: Is it safe to call tone() from Core 1 while a melody plays on Core 0?
A: Yes (v2.3+). All shared state is protected by a hardware spinlock. Calling tone(), stopMelody(), setVolume(), etc. from either core is safe. See the DualCore example.
| Example | Description |
|---|---|
| BasicTone | Continuous/timed tones, volume sweep, frequency sweep |
| MelodyPlayer | Melodies, completion callback, pause/resume demo |
| AlarmLoop | Temperature alarm with looping siren, button dismiss, pause/mute |
| DualCore | Multi-core safety: Core 0 runs melodies, Core 1 adjusts volume |
Contributions are welcome! Please read CONTRIBUTING.md before submitting a pull request.
- Fork this repository
- Create a feature branch:
git checkout -b feature/my-improvement - Commit your changes:
git commit -m "Add: description of change" - Push to the branch:
git push origin feature/my-improvement - Open a Pull Request
MIT License — see LICENSE.
- Raspberry Pi Foundation for the RP2040 PIO architecture
- The Arduino-Pico community for the RP2040 Arduino core (Earle Philhower)
- The embedded community for feedback on PIO-based audio generation techniques