Project: Light Theremin¶
A theremin is a spooky-sounding electronic instrument that you play without touching it: you just wave your hands near it. Yours will work the same way. Move your hand over a light sensor, and the shadow bends the pitch of the buzzer up and down.
Objective¶
- For the first 5 seconds, the program calibrates: it learns the darkest and brightest light levels while you wave your hand over the sensor.
- After that, more light = higher pitch, and less light = lower pitch.
- Button A mutes and unmutes the sound.
You'll need:
- 1 × photoresistor (light sensor)
- 1 × 10 kΩ resistor
- 1 × passive piezo buzzer
- 1 × push button
- Jumper wires
Before you start
This project uses what you learned in Light Sensors,
Piezo Buzzers and Buttons.
Make sure button.py is saved on your ESP32 (see
Saving Programs to the Board).
Wiring¶
| Part | Pin | Connects to |
|---|---|---|
| Photoresistor | one leg | 3V3 |
| other leg | GPIO 36 (VP) | |
| 10 kΩ resistor | one end | GPIO 36 (same row as the photoresistor) |
| other end | GND | |
| Buzzer | + leg | GPIO 26 |
| – leg | GND | |
| Button A | one leg | GPIO 14, other leg to GND |
Tip
Point the light sensor up towards a ceiling light or a window. The more light there is in the room, the bigger the difference your hand makes.
Plan it out¶
- Set up each component. The light sensor is an ADC input, the buzzer
is a PWM output, and the button uses the
Buttonclass. - Break the job into functions.
calibrate()watches the sensor for 5 seconds and remembers the lowest and highest readings it sees.light_to_pitch()turns a light reading into a frequency, using the calibrated range.toggle_mute()flips the sound on and off.
- Call the functions from the main loop. Calibrate once, then keep reading the light and updating the pitch, while also checking the button.
Code¶
Save this to your ESP32 as main.py so it runs every time the board
powers up. Or just click Run in Thonny to try it out.
# Light Theremin: wave your hand over the light sensor to change the pitch.
# Button A mutes and unmutes the sound.
import time
from machine import Pin, PWM, ADC
from button import Button
LIGHT_PIN = 36 # light sensor (LDR + 10 kΩ divider)
BUZZER_PIN = 26
BUTTON_PIN = 14 # Button A
LOWEST_NOTE = 150 # Hz, when the sensor is at its darkest
HIGHEST_NOTE = 1500 # Hz, when the sensor is at its brightest
VOLUME = 32768 # duty_u16: 0 is silent, 32768 (50%) is loudest
CALIBRATE_MS = 5000 # how long to spend learning the light levels
# Step 1: set up each component
light = ADC(Pin(LIGHT_PIN), atten=ADC.ATTN_11DB)
buzzer = PWM(Pin(BUZZER_PIN), freq=LOWEST_NOTE, duty_u16=0)
button = Button(BUTTON_PIN)
onboard_led = Pin(2, Pin.OUT)
muted = False
# Step 2: break the job into small functions
def calibrate():
# For 5 seconds, record the darkest and brightest readings we see.
# Wave your hand up and down over the sensor while the LED is on!
print("Calibrating: wave your hand over the sensor...")
onboard_led.on()
darkest = 65535
brightest = 0
start = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), start) < CALIBRATE_MS:
reading = light.read_u16()
darkest = min(darkest, reading)
brightest = max(brightest, reading)
time.sleep_ms(5)
onboard_led.off()
if brightest - darkest < 1000:
# hardly any change: fall back to the full range
darkest, brightest = 0, 65535
print("Darkest:", darkest, "Brightest:", brightest)
return darkest, brightest
def light_to_pitch(reading, darkest, brightest):
# keep the reading inside the calibrated range...
reading = max(darkest, min(brightest, reading))
# ...then map it onto our range of notes
span = brightest - darkest
return LOWEST_NOTE + (reading - darkest) * (HIGHEST_NOTE - LOWEST_NOTE) // span
def toggle_mute():
global muted
muted = not muted
print("Muted" if muted else "Playing")
# Step 3: calibrate once, then play forever
darkest, brightest = calibrate()
buzzer.duty_u16(VOLUME)
while True:
if button.was_pressed():
toggle_mute()
if muted:
buzzer.duty_u16(0)
else:
buzzer.freq(light_to_pitch(light.read_u16(), darkest, brightest))
buzzer.duty_u16(VOLUME)
time.sleep_ms(10)
How it works¶
- Why calibrate? Every room is different. At midday by a window your sensor might read 50000; at night under a lamp, maybe 15000. Instead of guessing, the program measures the real range for your room when it starts. The board's built-in LED stays on while it calibrates, so you know when to wave.
min()andmax()keep track of the lowest and highest readings so far. If you forget to wave (so the readings barely change), the program falls back to the full 0–65535 range so it still makes a sound.-
light_to_pitch()first clamps the reading into the calibrated range (a cloud might make the room darker than it was during calibration), then uses the same "map one range onto another" line you've seen in the Music Machine:
Result¶
When the program starts, the blue LED on the board lights up. Wave your hand up and down over the sensor until it goes out. Now the buzzer sings. Hover your hand close to the sensor for a low note, and pull it away for a high one. Press Button A to give everyone's ears a rest.
Make it your own¶
- Flip it: make less light give a higher note by swapping
LOWEST_NOTEandHIGHEST_NOTE. - Real notes: snap the pitch to the nearest note of a scale so it sounds more musical. (The Music Machine challenge shows how.)
- Volume control: add a potentiometer on GPIO 34 and use it to set the duty cycle from 0 to 32768.
- Two-handed: add a second light sensor on GPIO 39 (VN). Use one hand for pitch and the other for volume, like a real theremin.