Skip to content

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

  1. Set up each component. The light sensor is an ADC input, the buzzer is a PWM output, and the button uses the Button class.
  2. 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.
  3. 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.py
# 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() and max() 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:

    LOWEST_NOTE + (reading - darkest) * (HIGHEST_NOTE - LOWEST_NOTE) // span
    

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_NOTE and HIGHEST_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.