6.1 Musical Light Theremin

Build your own magical musical instrument that plays music by waving your hand in the air! No buttons, no keys - just pure hand gestures creating beautiful melodies.

What’s a Theremin? It’s a mysterious electronic instrument played without touching it. Move your hand closer or farther from the sensor, and it plays different musical notes - like conducting an invisible orchestra!

Our version: Instead of using expensive antennas like real theremins, we’ll use a simple light sensor. Cover the sensor with your hand for low notes, move away for high notes. It’s like having a light-controlled piano that responds to shadows and brightness!

The magic: This project combines light sensing, sound generation, and musical scales to create an instrument that’s both educational and incredibly fun to play.

Component List

  • Raspberry Pi Pico W x1

  • MicroUSB cable x1

  • 830 Tie-Points Breadboard x1

  • LED x1

  • Transistor S8050 x1

  • Resistor 220Ω, 1kΩ, 10kΩ x1

  • Passive Buzzer x1

  • Photoresistor x1

  • Jumper Wire Several

How it works - The Science Made Simple:

🔧 The Setup: - Light Sensor (GP28): Detects how much light hits it - like a digital eye! - Buzzer (GP15): Makes musical sounds when powered through the transistor - Status LED (GP16): Shows when the system is calibrating (learning your playing style) - Transistor S8050: Acts like an electronic switch to control the buzzer volume

🎵 The Magic Formula: - Hand closer = Less light hits sensor = Lower musical notes - Hand farther = More light hits sensor = Higher musical notes - No hand = Maximum light = Highest pitch

🎯 Calibration Process: When you first run the program, the LED lights up for 5 seconds. During this time, wave your hand over the sensor from very close to very far. This teaches the system your “playing range” so it can map your movements to the full musical scale.

Connect

../_images/6.1.png

Code

Note

  • Open the 6.1_musical_light_theremin.py file under the path of Ultimate-Starter-Kit-for-Pico-W\Python\1.Project or copy this code into Thonny, then click “Run Current Script” or simply press F5 to run it.

  • Don’t forget to click on the “MicroPython (Raspberry Pi Pico)” interpreter in the bottom right corner.

After running the code, get ready for your musical adventure in 3 easy steps:

🚀 Step 1: Automatic Calibration (5 seconds) - The LED lights up - calibration has started! - Wave your hand over the sensor like you’re conducting an orchestra - Move from very close (almost touching) to very far (arm’s length away) - This teaches your theremin your personal playing style

🎵 Step 2: Listen for the Welcome Melody - After 5 seconds, the LED turns off - You’ll hear a beautiful startup melody - this means your theremin is ready! - The system has learned your hand movement range

🎼 Step 3: Start Playing! - Hover your hand over the light sensor - Move closer for deeper, bass notes 🎶 - Move away for higher, treble notes 🎵 - Experiment with different heights and speeds - Try creating melodies by moving your hand smoothly up and down!

🎯 Pro Playing Tips: - Start with slow, gentle movements to hear the note changes clearly - The system uses a pentatonic scale (like traditional Asian music) - every note sounds good together! - Try “playing” simple songs like “Twinkle Twinkle Little Star” by moving your hand to different heights

The following is the program code:

"""
Musical Light Theremin v2.0
Enhanced with standard musical notes and improved user experience

Based on the tutorial:
https://sg.cytron.io/tutorial/build-a-light-theremin-using-maker-pi-pico-and-circuitpython

Key improvements over original:
- Uses pentatonic scale for more pleasant sounds
- Enhanced with proper exception handling
- Simplified user interface (no buttons required)
- Added startup melody and status feedback
- Safe buzzer shutdown when program stops

Hardware Requirements:
- Raspberry Pi Pico or compatible board
- Photoresistor on ADC pin 28
- Buzzer with PWM support on pin 15
- Status LED on pin 16
"""

import machine
import utime

# Hardware pin definitions
LED_PIN = 16                # Status LED pin
PHOTORESISTOR_PIN = 28      # Light sensor ADC pin
BUZZER_PIN = 15             # PWM buzzer pin

# Calibration timing constants
CALIBRATION_TIME_MS = 5000          # 5 seconds for light range calibration
INITIAL_LIGHT_LOW = 65535           # Maximum ADC value (16-bit) - starting point
INITIAL_LIGHT_HIGH = 0              # Minimum ADC value - starting point

# Audio configuration constants
BUZZER_DUTY_CYCLE = 32768           # 50% duty cycle for optimal sound quality
DEFAULT_NOTE_DURATION_MS = 100      # Default note length in milliseconds
LOOP_DELAY_MS = 10                  # Main loop delay for responsiveness

# Musical note frequencies (in Hz) - Based on A4 = 440Hz standard tuning
NOTE_FREQUENCIES = {
    'C3': 131, 'D3': 147, 'E3': 165, 'F3': 175, 'G3': 196, 'A3': 220, 'B3': 247,
    'C4': 262, 'D4': 294, 'E4': 330, 'F4': 349, 'G4': 392, 'A4': 440, 'B4': 494,
    'C5': 523, 'D5': 587, 'E5': 659, 'F5': 698, 'G5': 784, 'A5': 880, 'B5': 988,
    'C6': 1047, 'D6': 1175, 'E6': 1319, 'F6': 1397, 'G6': 1568, 'A6': 1760
}

# Musical scale for theremin - Pentatonic + Major scale for pleasant harmonics
THEREMIN_NOTES = [
    # Pentatonic scale (naturally harmonious - avoids dissonant half-steps)
    'C3', 'D3', 'E3', 'G3', 'A3',      # Lower octave pentatonic
    'C4', 'D4', 'E4', 'G4', 'A4',      # Middle octave pentatonic
    'C5', 'D5', 'E5', 'G5', 'A5',      # Higher octave pentatonic
    # Major scale notes for additional variety
    'F3', 'B3', 'F4', 'B4', 'F5', 'B5',
    # Extended high range for full musical expression
    'C6', 'D6', 'E6', 'G6', 'A6'
]

TOTAL_NOTES = len(THEREMIN_NOTES)   # Total number of available musical notes

# Initialize hardware components
led = machine.Pin(LED_PIN, machine.Pin.OUT)        # Status indicator LED
photoresistor = machine.ADC(PHOTORESISTOR_PIN)     # Light level sensor
buzzer = machine.PWM(machine.Pin(BUZZER_PIN))      # PWM-controlled buzzer

# System state variables for operation
light_range_min = INITIAL_LIGHT_LOW     # Minimum light value from calibration
light_range_max = INITIAL_LIGHT_HIGH    # Maximum light value from calibration
note_duration_ms = DEFAULT_NOTE_DURATION_MS  # Current note duration setting

def map_to_note_index(light_value, min_light, max_light):
    """
    Map light sensor value to a discrete note index

    Args:
        light_value: Current ADC reading from photoresistor
        min_light: Minimum light value from calibration
        max_light: Maximum light value from calibration

    Returns:
        Integer index (0 to TOTAL_NOTES-1) for note selection
    """
    if max_light <= min_light:
        return 0

    # Clamp light value to calibrated range to prevent index overflow
    light_value = max(min_light, min(light_value, max_light))

    # Normalize to 0-1 range, then map to discrete note indices
    normalized = (light_value - min_light) / (max_light - min_light)
    note_index = int(normalized * (TOTAL_NOTES - 1))
    return max(0, min(note_index, TOTAL_NOTES - 1))

def play_musical_note(note_name, duration_ms):
    """
    Play a musical note by name for specified duration

    Args:
        note_name: String name of note (e.g., 'C4', 'A5')
        duration_ms: How long to play the note in milliseconds
    """
    try:
        if note_name in NOTE_FREQUENCIES:
            frequency = NOTE_FREQUENCIES[note_name]
            buzzer.freq(frequency)
            buzzer.duty_u16(BUZZER_DUTY_CYCLE)
            utime.sleep_ms(duration_ms)
            buzzer.duty_u16(0)  # Turn off buzzer after note
        else:
            # Silent pause for unknown notes
            utime.sleep_ms(duration_ms)
    except:
        # Ensure buzzer stops on any error to prevent continuous noise
        buzzer.duty_u16(0)

def stop_buzzer():
    """
    Safely stop the buzzer and release PWM resources
    Called when program terminates to ensure clean shutdown
    """
    try:
        buzzer.duty_u16(0)      # Set duty cycle to 0 (silent)
        buzzer.deinit()         # Release PWM hardware resource
    except:
        pass  # Ignore errors during cleanup

def get_current_note(light_value):
    """
    Get the current musical note based on light sensor reading

    Args:
        light_value: Current ADC reading from photoresistor

    Returns:
        String name of the musical note to play
    """
    note_index = map_to_note_index(light_value, light_range_min, light_range_max)
    return THEREMIN_NOTES[note_index]



def read_light_sensor():
    """
    Read current light level from photoresistor

    Returns:
        Integer ADC value (0-65535 for 16-bit ADC)
    """
    return photoresistor.read_u16()

def calibrate_light_sensor():
    """
    Calibrate light sensor by recording min/max light values over calibration period
    User should create varied lighting conditions during this time for optimal range

    Updates global variables:
        light_range_min: Minimum light value detected
        light_range_max: Maximum light value detected
    """
    global light_range_min, light_range_max

    print("=== Starting Light Sensor Calibration ===")
    print("Wave your hand over the sensor for 5 seconds...")
    print("Create bright and dark conditions for best musical range")

    # Reset calibration values to starting extremes
    light_range_min = INITIAL_LIGHT_LOW
    light_range_max = INITIAL_LIGHT_HIGH

    # Run calibration for specified time with visual feedback
    start_time = utime.ticks_ms()

    while utime.ticks_diff(utime.ticks_ms(), start_time) < CALIBRATION_TIME_MS:
        current_light = read_light_sensor()

        # Track minimum and maximum light values encountered
        if current_light > light_range_max:
            light_range_max = current_light
        if current_light < light_range_min:
            light_range_min = current_light

        # Provide visual feedback during calibration (LED blinks every 250ms)
        led.value((utime.ticks_ms() // 250) % 2)
        utime.sleep_ms(50)

    led.value(0)  # Turn off LED after calibration complete

    # Display calibration results
    print(f"Calibration complete!")
    print(f"Light range detected: {light_range_min} - {light_range_max}")
    print(f"Musical notes available: {TOTAL_NOTES} (Pentatonic + Major scale)")
    print("Theremin ready - wave your hand to play beautiful music!")

def play_startup_melody():
    """
    Play a pleasant pentatonic melody to indicate system initialization complete
    Uses ascending pentatonic scale for harmonious startup sound
    """
    startup_notes = ['C4', 'D4', 'E4', 'G4', 'A4', 'C5']  # Rising pentatonic melody
    for note in startup_notes:
        play_musical_note(note, 180)    # Play each note for 180ms
        utime.sleep_ms(50)              # Brief pause between notes

# === System Initialization ===
print("=== Musical Light Theremin v2.0 ===")
print("Enhanced with standard musical notes!")

# Perform light sensor calibration to establish musical range
calibrate_light_sensor()

# Play welcome melody to confirm system ready
play_startup_melody()

print("\n=== Theremin Ready ===")
print("Simply wave your hand over the sensor to play beautiful music!")
print("Each light level corresponds to a different musical note")

# === Main Control Loop ===
# Track current and previous notes to provide smooth playback
current_note = None
last_note = None

try:
    while True:
        # Read current light level and determine corresponding musical note
        current_light = read_light_sensor()
        current_note = get_current_note(current_light)

        # Only play new note if it changed (prevents continuous note restart)
        if current_note != last_note:
            print(f"Playing: {current_note} (Light: {current_light})")
            play_musical_note(current_note, note_duration_ms)
            last_note = current_note
        else:
            # Continue current note with shorter duration for smooth sound
            play_musical_note(current_note, note_duration_ms // 2)

        # Brief delay for system responsiveness and note timing
        utime.sleep_ms(LOOP_DELAY_MS)

except KeyboardInterrupt:
    # Handle Ctrl+C or Stop button gracefully
    print("\nTheremin stopped by user")
except Exception as e:
    # Handle any unexpected errors
    print(f"\nError occurred: {e}")
finally:
    # Critical: Always stop buzzer when program ends to prevent continuous noise
    print("Stopping buzzer...")
    stop_buzzer()
    led.value(0)  # Turn off status LED
    print("Theremin safely stopped")

Phenomenon