Air Theremin
Play music by waving your hand over a sensor: closer makes the sound go higher, farther makes it lower, with a smooth slide and a wobble like a real theremin.
Board: Arduino UNO (Elegoo UNO R3)
What you need
What it is
A theremin is a real instrument you play without touching it. This one uses an ultrasonic distance sensor: move your hand down toward it and the sound slides up, lift your hand and it slides down. Take your hand away and it goes quiet. The playing area is from about 2 inches (highest) to 20 inches (lowest) above the sensor.
It also has an air piano mode with 8 separate notes, if you'd rather play tunes.
How it works
- The sensor sends out a click of sound too high for us to hear, and times how long the echo takes to bounce back off your hand. Sound takes about 148 microseconds to go one inch out and back, so the program divides by 148 to get inches.
- Distance to pitch: 20 inches is 500 Hz and 2 inches is 2500 Hz. The steps in between are spread out the way musical notes are (each inch multiplies the pitch a little), so it feels even to play.
- Smooth slide: the pitch moves a quarter of the way toward your hand's note every 5 milliseconds, so it glides instead of jumping.
- Vibrato: a gentle wobble of about 1% up and down, 5 or 6 times a second, makes it sound like a real theremin.
- The board's L light flickers along with the sound.
Build it
1Upload the code
Upload the code below (see Getting started).
2Wire the sensor and the buzzer
Six female-to-male wires, no breadboard.

| Part | Pin | Arduino |
|---|---|---|
| Sensor | Vcc | 5V |
| Sensor | Trig | 8 |
| Sensor | Echo | 7 |
| Sensor | Gnd | GND |
| Buzzer | one leg | 13 |
| Buzzer | other leg | the GND right next to pin 13 |
Tip: Trig and Echo can go in 7 and 8 either way round, and the buzzer works either way round too. The program checks which pin is which.
3Play
Hold your hand flat over the sensor, about a foot up, and move it slowly up and down. Keep your hand flat and level: a flat palm makes a good echo.
Commands
Type these in the Serial Monitor (9600 baud, Newline). Mode, mute and the screen settings are saved.
| Type | What it does |
|---|---|
t | Theremin mode (smooth slide) |
p | Air piano mode (8 notes) |
m | Mute / unmute |
? | Is the sensor connected, and what does it see |
f | Blink the L light 3 times, to find which board this is |
c 40, b 160 | Screen contrast and backlight (if you add the screen) |
Add a screen (optional)
With an LCD1602 wired the same way as the Distance Meter, the screen shows the pitch in Hz and a bar that moves with your hand.
If it doesn't work
- No sound at all: type
?. If it says the sensor isn't found, check the four sensor wires: "both HIGH" means the wires aren't connected, "both LOW" means the sensor has no power. - Only clicks, or one flat tone: you probably have the active buzzer (the one with a white sticker). This needs the passive one with no sticker.
- It jumps around: keep other things (and your other hand) out of the sensor's way. It hears echoes in a cone, so the closest thing in front of it wins.
The code
Copy it into the Arduino app, or download the file. New to this? Start with Getting started.
// Air theremin: move your hand over the ultrasonic sensor and the sound slides// smoothly up and down, like a real theremin. Closer = higher, farther = lower,// with a gentle wobble (vibrato). Take your hand away and it goes quiet.// Playing area: about 2 inches (highest) to 20 inches (lowest) from the sensor.//// It also has the old air-piano mode (8 separate notes, C to high C, 2 inches// each). Switch from the computer with "t" (theremin) or "p" (piano).//// Wiring (same as the air piano):// Ultrasonic sensor (HC-SR04), 4 female-to-male wires:// Vcc -> 5V Trig -> pin 8 Echo -> pin 7 Gnd -> GND// (Trig and Echo can go in 7 and 8 either way round, the program checks.)// Buzzer (the starter kit's passive one, no sticker), 2 female-to-male wires:// one leg -> pin 13, the other leg -> the GND right next to pin 13.// The board's "L" light flickers along with the sound.// Screen (LCD1602), optional, same pinout as the distance meter:// 1 VSS -> GND 2 VDD -> A0 3 V0 -> pin 9 4 RS -> pin 12// 5 RW -> A1 6 E -> pin 11 7 to 10 -> nothing// 11 D4 -> pin 5 12 D5 -> pin 4 13 D6 -> pin 3 14 D7 -> pin 2// 15 A -> pin 10 16 K -> A2//// From the computer:// "t" theremin mode "p" piano mode// "m" mute / unmute "?" sensor status// "f" blink the "L" light 3 times (to find this board)// "c 40" screen contrast "b 160" screen backlight// Mode, mute, contrast and backlight are saved.#include <EEPROM.h>// ---------- Pins ----------const byte SENSOR_PINS[2] = {7, 8};byte trigPin = 8;byte echoPin = 7;const byte BUZZER = 13;const byte LCD_POWER = A0;const byte LCD_RW = A1;const byte LCD_GROUND = A2; // the backlight's K pinconst byte LCD_RS = 12;const byte LCD_E = 11;const byte LCD_DATA[4] = {5, 4, 3, 2}; // the screen's D4, D5, D6, D7const byte LCD_CONTRAST = 9;const byte LCD_LIGHT = 10;// ---------- Theremin sound ----------const float NEAREST_IN = 2.0; // hand this close = highest pitchconst float FARTHEST_IN = 20.0; // hand this far = lowest pitch (past it = quiet)const float LOWEST_HZ = 500.0; // the little buzzer gets very quiet below thisconst float HIGHEST_HZ = 2500.0; // about 2 1/3 octaves above the lowestconst float GLIDE = 0.25; // how quickly the pitch slides to where your hand isconst float VIBRATO_DEPTH = 0.012; // wobble of about 1.2% up and downconst float VIBRATO_PER_SEC = 5.5; // wobbles per secondconst unsigned long SOUND_STEP_MS = 5; // update the pitch 200 times a second// ---------- Piano notes ----------const byte NOTE_COUNT = 8;const char NOTE_NAMES[NOTE_COUNT] = {'C', 'D', 'E', 'F', 'G', 'A', 'B', 'C'};const unsigned int NOTE_HZ[NOTE_COUNT] = {1047, 1175, 1319, 1397, 1568, 1760, 1976, 2093};const float FIRST_NOTE_AT = 2.0; // inches where the first C startsconst float NOTE_WIDTH = 2.0; // inches per noteconst float WOBBLE = 0.4; // a hand has to move this far past a line to change noteconst int NO_NOTE = -1;// ---------- Measuring ----------const float MICROS_PER_INCH = 148.0; // sound out and back, per inchconst unsigned long LISTEN_MICROS = 4500; // stop listening after ~30 inchesconst unsigned long PING_GAP_MS = 28; // short rest so old echoes die awayconst unsigned long QUIET_AFTER_MS = 150; // hand gone this long = quiet// ---------- Saved settings ----------const byte MODE_THEREMIN = 0;const byte MODE_PIANO = 1;struct Settings { byte check; byte contrast; byte backlight; byte muted; byte mode;};const byte SETTINGS_CHECK = 0xA9; // same as the air piano, so its screen settings carry overSettings settings;// ---------- What's happening now ----------float recent[3] = {0, 0, 0}; // last 3 readings, so one stray echo can't jump the soundbyte recentNext = 0;byte recentCount = 0;float hand = 0; // the hand's distance (middle of the last 3), 0 = no handfloat lastHandInches = 0; // the last real distance, for the computer readoutunsigned long lastHandSeen = 0;unsigned long lastPing = 0;bool sounding = false; // theremin: is it making soundfloat targetHz = 0; // theremin: where the pitch is headingfloat currentHz = 0; // theremin: where the pitch is nowunsigned long lastSoundStep = 0;unsigned long lastScreen = 0;unsigned long lastReport = 0;int playing = NO_NOTE; // piano: the note being playedvoid setup() { Serial.begin(9600); Serial.println("Theremin starting"); EEPROM.get(0, settings); if (settings.check != SETTINGS_CHECK) { settings = {SETTINGS_CHECK, 40, 160, 0, MODE_THEREMIN}; } if (settings.mode > MODE_PIANO) settings.mode = MODE_THEREMIN; // first time after the air piano if (settings.muted > 1) settings.muted = 0; EEPROM.put(0, settings); // Pins 9 and 10 flicker very fast (31,000 times a second) to set the screen's // contrast and brightness without it shimmering. TCCR1B = (TCCR1B & 0b11111000) | 0b001; pinMode(LCD_CONTRAST, OUTPUT); pinMode(LCD_LIGHT, OUTPUT); analogWrite(LCD_CONTRAST, settings.contrast); analogWrite(LCD_LIGHT, settings.backlight); pinMode(LCD_RW, OUTPUT); digitalWrite(LCD_RW, LOW); pinMode(LCD_GROUND, OUTPUT); digitalWrite(LCD_GROUND, LOW); pinMode(LCD_POWER, OUTPUT); digitalWrite(LCD_POWER, HIGH); delay(100); lcdBegin(); makeNoteSymbol(); pinMode(BUZZER, OUTPUT); findSensorPins(); playStartSound(); showIdle(); printMode();}void loop() { readComputer(); unsigned long now = millis(); if (now - lastPing >= PING_GAP_MS) { lastPing = now; measureHand(); if (settings.mode == MODE_PIANO) pianoAfterPing(); else thereminAfterPing(); } if (settings.mode == MODE_THEREMIN && now - lastSoundStep >= SOUND_STEP_MS) { lastSoundStep = now; thereminSoundStep(now); }}// ---------- Measuring ----------// One ping, then "hand" = the middle of the last 3 good readings (or 0).void measureHand() { float inches = readInches(); if (inches > 0) { recent[recentNext] = inches; recentNext = (recentNext + 1) % 3; if (recentCount < 3) recentCount++; } else { findSensorPins(); // in case the wires were swapped or plugged in late } hand = (inches > 0 && recentCount >= 3) ? middleOf3() : 0;}// Works out which of pins 7 and 8 is Echo: with a pull-up on, the Echo pin// still reads LOW because the sensor holds it there; the Trig pin reads HIGH.void findSensorPins() { pinMode(SENSOR_PINS[0], INPUT_PULLUP); pinMode(SENSOR_PINS[1], INPUT_PULLUP); delayMicroseconds(500); bool low0 = digitalRead(SENSOR_PINS[0]) == LOW; bool low1 = digitalRead(SENSOR_PINS[1]) == LOW; if (low0 != low1) { echoPin = low0 ? SENSOR_PINS[0] : SENSOR_PINS[1]; trigPin = low0 ? SENSOR_PINS[1] : SENSOR_PINS[0]; } pinMode(echoPin, INPUT); pinMode(trigPin, OUTPUT); digitalWrite(trigPin, LOW);}// One ping. Returns the distance in inches, or 0 if nothing answered within ~30 in.float readInches() { digitalWrite(trigPin, LOW); delayMicroseconds(2); digitalWrite(trigPin, HIGH); delayMicroseconds(10); digitalWrite(trigPin, LOW); unsigned long microseconds = pulseIn(echoPin, HIGH, LISTEN_MICROS); return microseconds == 0 ? 0 : microseconds / MICROS_PER_INCH;}float middleOf3() { float a = recent[0], b = recent[1], c = recent[2]; if ((a <= b && b <= c) || (c <= b && b <= a)) return b; if ((b <= a && a <= c) || (c <= a && a <= b)) return a; return c;}// ---------- Theremin ----------// Turns a hand distance into a pitch. Equal hand movements give equal musical// steps, so the whole range feels even (like a real theremin).float pitchFor(float inches) { float t = (FARTHEST_IN - inches) / (FARTHEST_IN - NEAREST_IN); // 0 far ... 1 near t = constrain(t, 0.0, 1.0); return LOWEST_HZ * pow(HIGHEST_HZ / LOWEST_HZ, t);}void thereminAfterPing() { if (hand > 0 && hand <= FARTHEST_IN) { lastHandSeen = millis(); lastHandInches = hand; targetHz = pitchFor(hand); if (!sounding) { sounding = true; currentHz = targetHz; // start right where the hand is, no swoop from the last note } } else if (sounding && millis() - lastHandSeen > QUIET_AFTER_MS) { sounding = false; noTone(BUZZER); digitalWrite(BUZZER, LOW); recentCount = 0; showIdle(); Serial.println("Quiet"); }}// 200 times a second: slide toward the hand's pitch, add the wobble, play it.void thereminSoundStep(unsigned long now) { if (!sounding) return; currentHz += (targetHz - currentHz) * GLIDE; float wobble = 1.0 + VIBRATO_DEPTH * sin(TWO_PI * VIBRATO_PER_SEC * now / 1000.0); if (!settings.muted) tone(BUZZER, (unsigned int)(currentHz * wobble)); if (now - lastScreen >= 150) { lastScreen = now; showPitch(currentHz); } if (now - lastReport >= 400) { lastReport = now; Serial.print((int)currentHz); Serial.print(" Hz at "); Serial.print(lastHandInches, 1); Serial.println(" in"); }}// ---------- Piano ----------void pianoAfterPing() { int note = (hand > 0) ? pickNote(hand) : NO_NOTE; if (note != NO_NOTE) { lastHandSeen = millis(); if (note != playing) startNote(note); } else if (playing != NO_NOTE && millis() - lastHandSeen > QUIET_AFTER_MS) { playing = NO_NOTE; noTone(BUZZER); digitalWrite(BUZZER, LOW); recentCount = 0; showIdle(); Serial.println("Quiet"); }}// Distance -> note number (0 = low C ... 7 = high C), or NO_NOTE. Near a line// between two notes it keeps the current note, so it doesn't flutter.int pickNote(float inches) { if (playing != NO_NOTE) { float low = FIRST_NOTE_AT + playing * NOTE_WIDTH - WOBBLE; float high = FIRST_NOTE_AT + (playing + 1) * NOTE_WIDTH + WOBBLE; if (inches >= low && inches < high) return playing; } if (inches < FIRST_NOTE_AT) return NO_NOTE; int note = (int)((inches - FIRST_NOTE_AT) / NOTE_WIDTH); return note < NOTE_COUNT ? note : NO_NOTE;}void startNote(int note) { playing = note; if (!settings.muted) tone(BUZZER, NOTE_HZ[note]); showNote(note); Serial.print("Note "); Serial.print(NOTE_NAMES[note]); if (note == NOTE_COUNT - 1) Serial.print(" (high)"); Serial.print(" at "); Serial.print(hand, 1); Serial.println(" in");}// ---------- Sounds ----------void playStartSound() { if (settings.muted) return; if (settings.mode == MODE_PIANO) { const byte tune[] = {0, 2, 4, 7}; for (byte i = 0; i < sizeof(tune); i++) { tone(BUZZER, NOTE_HZ[tune[i]], 110); delay(140); } } else { // A spooky swoop up and back down. for (int i = 0; i <= 60; i++) { tone(BUZZER, (unsigned int)pitchFor(FARTHEST_IN - i * 0.3)); delay(8); } for (int i = 60; i >= 0; i--) { tone(BUZZER, (unsigned int)pitchFor(FARTHEST_IN - i * 0.3)); delay(5); } } noTone(BUZZER); digitalWrite(BUZZER, LOW);}void stopAllSound() { noTone(BUZZER); digitalWrite(BUZZER, LOW); sounding = false; playing = NO_NOTE; recentCount = 0;}void printMode() { Serial.print(settings.mode == MODE_PIANO ? "Ready: piano mode" : "Ready: theremin mode"); Serial.println(settings.muted ? " (muted)" : "");}// ---------- Screen ----------// The music note lives in the screen's custom character slot 0. The screen// shows slot 0 for code 0 or code 8; using 8 keeps it from ending the text.const byte NOTE_SYMBOL_SLOT = 0;const char NOTE_SYMBOL = 8;void showIdle() { lcdWake(); if (settings.mode == MODE_PIANO) { lcdPrintRow(0, settings.muted ? " Air Piano mute" : " Air Piano"); lcdPrintRow(1, "Hand 2-18in away"); } else { lcdPrintRow(0, settings.muted ? " Theremin mute" : " Theremin"); lcdPrintRow(1, "Hand 2-20in away"); }}// top row: ♪ 1234 Hz ♪// bottom row: a bar that grows as the pitch goes upvoid showPitch(float hz) { char top[17]; snprintf(top, sizeof(top), " %c %4d Hz %c", NOTE_SYMBOL, (int)hz, NOTE_SYMBOL); char bar[17]; float t = log(hz / LOWEST_HZ) / log(HIGHEST_HZ / LOWEST_HZ); // 0 lowest ... 1 highest int blocks = constrain((int)(t * 16 + 0.5), 1, 16); for (byte i = 0; i < 16; i++) bar[i] = (i < blocks) ? (char)0xFF : ' '; bar[16] = '\0'; lcdWake(); lcdPrintRow(0, top); lcdPrintRow(1, bar);}// top row: ♪ E ♪// bottom row: C D E F G A B C with a solid block on the note being playedvoid showNote(int note) { char top[17] = " "; top[4] = NOTE_SYMBOL; top[11] = NOTE_SYMBOL; top[7] = NOTE_NAMES[note]; if (note == NOTE_COUNT - 1) top[8] = '\''; // high C char row[17]; for (byte i = 0; i < NOTE_COUNT; i++) { row[i * 2] = ' '; row[i * 2 + 1] = (i == note) ? (char)0xFF : NOTE_NAMES[i]; } row[16] = '\0'; lcdWake(); lcdPrintRow(0, top); lcdPrintRow(1, row);}// A little music note, drawn dot by dot (5 wide, 8 tall).void makeNoteSymbol() { const byte rows[8] = { 0b00100, 0b00110, 0b00101, 0b00100, 0b00100, 0b01100, 0b11100, 0b01000, }; lcdSend(0x40 | (NOTE_SYMBOL_SLOT << 3), false); for (byte i = 0; i < 8; i++) lcdSend(rows[i], true); lcdSend(0x80, false); // back to writing letters}// ---------- Commands from the computer ----------void readComputer() { static char line[20]; static byte length = 0; while (Serial.available()) { char ch = Serial.read(); if (ch == '\n' || ch == '\r') { line[length] = '\0'; if (length > 0) handleCommand(line); length = 0; } else if (length < sizeof(line) - 1) { line[length++] = ch; } }}void handleCommand(const char *line) { int value = constrain(atoi(line + 1), 0, 255); switch (line[0]) { case 't': case 'p': stopAllSound(); settings.mode = (line[0] == 'p') ? MODE_PIANO : MODE_THEREMIN; playStartSound(); showIdle(); break; case 'm': settings.muted = !settings.muted; stopAllSound(); showIdle(); break; case 'c': settings.contrast = value; analogWrite(LCD_CONTRAST, settings.contrast); break; case 'b': settings.backlight = value; analogWrite(LCD_LIGHT, settings.backlight); break; case '?': reportStatus(); return; case 'f': stopAllSound(); for (byte i = 0; i < 3; i++) { digitalWrite(BUZZER, HIGH); delay(150); digitalWrite(BUZZER, LOW); delay(150); } Serial.println("Blinked"); return; default: return; } EEPROM.put(0, settings); printMode();}// "?": is the sensor there, and what does it see right now? With a pull-up on,// a powered sensor holds its Echo pin LOW; with no sensor both pins read HIGH,// and with the wires in but no power both read LOW.void reportStatus() { pinMode(SENSOR_PINS[0], INPUT_PULLUP); pinMode(SENSOR_PINS[1], INPUT_PULLUP); delay(2); bool low0 = digitalRead(SENSOR_PINS[0]) == LOW; bool low1 = digitalRead(SENSOR_PINS[1]) == LOW; findSensorPins(); Serial.print("Sensor: "); if (low0 != low1) { Serial.print("found (Trig "); Serial.print(trigPin); Serial.print(", Echo "); Serial.print(echoPin); Serial.print(")"); } else if (low0 && low1) { Serial.print("NOT found: pins 7 and 8 both LOW (no power? check 5V/GND)"); } else { Serial.print("NOT found: pins 7 and 8 both HIGH (Trig/Echo wires not connected?)"); } delay(60); float inches = readInches(); Serial.print(" sees: "); if (inches > 0) { Serial.print(inches, 1); Serial.print(" in"); } else { Serial.print("nothing within 30 in"); } Serial.print(settings.mode == MODE_PIANO ? " mode: piano" : " mode: theremin"); Serial.println(settings.muted ? " sound: muted" : " sound: on");}// ---------- Screen driver ----------// The screen takes letters 4 bits at a time on its D4-D7 pins. RS picks// between a command (LOW) and a letter (HIGH), and a pulse on E sends it.void lcdBegin() { pinMode(LCD_RS, OUTPUT); pinMode(LCD_E, OUTPUT); for (byte i = 0; i < 4; i++) pinMode(LCD_DATA[i], OUTPUT); digitalWrite(LCD_E, LOW); digitalWrite(LCD_RS, LOW); delay(50); lcdWake(); lcdSend(0x01, false); // clear the screen delay(2);}// The screen's start-up routine. Repeating it before each update gets a// screen that had a glitch back in step, without clearing it.void lcdWake() { digitalWrite(LCD_RS, LOW); lcdNibble(0x3); delay(5); lcdNibble(0x3); delayMicroseconds(150); lcdNibble(0x3); delayMicroseconds(150); lcdNibble(0x2); delayMicroseconds(150); lcdSend(0x28, false); // 2 lines of text lcdSend(0x0C, false); // screen on, no cursor lcdSend(0x06, false); // move right after each letter}void lcdNibble(byte bits) { for (byte i = 0; i < 4; i++) digitalWrite(LCD_DATA[i], (bits >> i) & 1); digitalWrite(LCD_E, HIGH); delayMicroseconds(1); digitalWrite(LCD_E, LOW); delayMicroseconds(100);}void lcdSend(byte value, bool isLetter) { digitalWrite(LCD_RS, isLetter ? HIGH : LOW); lcdNibble(value >> 4); lcdNibble(value & 0x0F);}// Writes a whole row: the text, then spaces to fill all 16 places.void lcdPrintRow(byte row, const char *text) { lcdSend(0x80 | (row ? 0x40 : 0), false); bool ended = false; for (byte i = 0; i < 16; i++) { if (text[i] == '\0') ended = true; lcdSend(ended ? ' ' : text[i], true); }}


