intermernet/theremin

0

stars

2

commits

Go

primary language

Sep 6, 2026

updated

README

MidiTheremin

TinyGo firmware that turns a GaudiLabs OpenTheremin V3 shield into a USB MIDI controller. Built for an Adafruit Metro M4 Express AirLift Lite (ATSAMD51J19), and portable to other Arduino UNO form factor boards.

The shield's analogue front end is used as designed: two antenna oscillators heterodyned against a crystal reference, auto-tuned through the on-board MCP4922. The two difference frequencies become MIDI note, pitch bend and controller messages. No audio is synthesised.

Copyright (c) Mike Hughes <mike AT mikehughes DOT info> 2026


Shield modification

The OpenTheremin V3 is a 5 V design. The SAMD51 is not 5 V tolerant, so on an unmodified shield the two beat signals (D2, D8) and the four pot wipers (A0–A3) all exceed what the MCU may see.

Every active part on the shield is rated well below 3.3 V. The counters and flip-flop are 74HC parts, the oscillator inverter already runs from 3V3, and both DACs take 2.7–5.5 V. Running the whole shield from 3.3 V solves these problems. The fix consists of bending one pin and soldering one short jumper wire.

Do these steps in order! Soldering the jumper wire before disconnecting the 5 V pin will short the board's 5 V rail to its 3.3 V rail and back-feed the regulator. Do step 1 first, and verify it with a multimeter.

  1. With the shield off the board, find the pin on the shield's power header that lines up with the 5V socket. Bend it outwards about 30° so it sits beside the socket instead of entering it.
  2. Seat the shield with USB unplugged and measure resistance between 5V and 3V3. It must read open. A few ohms means the pin is still making contact.
  3. On the top of the shield, solder a short wire between the 5V and 3V3 header pads. They are adjacent pins, so use a wire rather than a solder bridge.
  4. Make sure useExternalAREF = false in config.go.

Power up and check the serial console. Two resting beat frequencies near the target mean the references are running:

autotune pitch: zero beat near DAC 2176
autotune pitch: DAC 2166, resting beat 707 Hz
autotune volume: zero beat near DAC 2304
autotune volume: DAC 2269, resting beat 677 Hz

Building

Requires TinyGo 0.42 or later and a Go toolchain the TinyGo release accepts.

cd src/theremin
tinygo build -target=metro-m4-airlift -size=short -o firmware.uf2 .

Flash by putting the device into BOOTSEL or FLASH mode and copying firmware.uf2 onto it, or:

tinygo flash -target=metro-m4-airlift .

Wrapper scripts do both. On Linux and macOS:

./build.sh             # writes firmware.uf2
./build.sh --flash     # builds and flashes
./build.sh --offline   # GOPROXY=off, for when the module proxy stalls
./build.sh --help      # all options

On Windows:

.\build.ps1            # writes firmware.uf2
.\build.ps1 -Flash     # builds and flashes
.\build.ps1 -Offline   # GOPROXY=off, for when the module proxy stalls

Both take --target / -Target to build for a different board.

The device enumerates as a composite CDC + MIDI device, so one cable carries the MIDI port and a serial console. Set debugSerial = false in config.go to silence the console.


Using it

Button

GestureEffect
TapToggle MIDI output. Red LED on = muted.
Hold 3 sBoth LEDs flash. Keep holding to calibrate.
Hold 8 sEnter calibration. Releasing earlier cancels.

Knobs

Numbered in reading order on the board: P1 top-left, P2 top-right, P3 bottom-left, P4 bottom-right.

KnobFunction
1Pitch sensitivity. Centre reproduces the calibrated range; fully left halves it, fully right doubles it.
2Playing: pitch bend amount. Fully left quantises hard to semitones, fully right is fully continuous; in between, pitch holds over the middle of each semitone and sweeps across the boundary.
Muted: selects what the volume antenna does, 11 detents. Fully left is the default — channel volume, gating notes. The rest assign the antenna to CC 70–79, where notes sound at full volume gated by the pitch hand alone. Muting alone changes nothing; the knob takes over only once it is moved, and the choice is saved to flash when you unmute.
3Transpose in semitones: fully left C2 (MIDI 36), centre C3 (48), fully right C4 (60). Sets the note at the far end of the pitch field.
4MIDI channel, 17 positions. Fully left is all channels; the rest selects 1–16. Changing it releases anything sounding on the old channel.

LEDs

PatternMeaning
Amber follows playA MIDI message was just sent.
Red steadyMuted.
Both flashing, 3 HzButton held past 3 s.
Alternating, 2.5 HzAuto-tune running. Hands away.
Both solidBring a hand close to each antenna, without touching.
Amber blinking, 5 HzCapture window; sweep out to your far point.
Amber slow blinkCalibration saved.
Fast alternationCalibration rejected, or a fatal start-up fault.
Red slow blinkAn antenna oscillator is quiet. Shield unpowered or unplugged.

Amber is D4 on A5, red is D3 on A4. Swap pinLEDRed and pinLEDAmber in config.go if they come out reversed.

Calibration

Hold the button for eight seconds. The sequence takes about fifteen seconds and drives itself from the LEDs:

  1. Both LEDs dark, 1.5 s - release the button, hands away.
  2. LEDs alternating - oscillator auto-tune. Keep your hands off.
  3. Both LEDs solid, 2 s - bring a hand close to each antenna, as near as you would actually play. Do not touch them: contact gives a beat tens of times the resting one and skews the captured range.
  4. Amber blinking, 3 s - sweep both hands out to your furthest playing point.
  5. Amber slow blink - saved. Fast alternation means nothing moved and the previous calibration was kept.

Both extremes of both beat periods are recorded across the whole window, so the sweep can go in either direction. Results are stored in internal flash and survive a power cycle. Auto-tune runs again on every power-up, but a stored tuning still close to target is accepted as-is, so a warm start takes under a second.

Before the first calibration the firmware falls back to a synthetic range, so the device is playable immediately.


How it works

The 74HC74 samples each antenna oscillator with the crystal reference, so F_PITCH and F_VOL are square waves at the difference of the two frequencies: a few hundred Hz with the hands away, rising as a hand approaches. A pin interrupt timestamps every rising edge against a free-running cycle counter, and the mean period across the last few edges is smoothed with an IIR filter.

Pitch position is linear in the period difference between the far point and now. Frequency then rises linearly with that position across playingRangeOctaves, which keeps a real theremin's crowding of the upper register. The note number is the base-2 log of that:

note = transpose + 12 · log₂(1 + x · (2^octaves − 1))

The continuous note is shaped by knob 2, then split into a MIDI note plus pitch bend. The note is held for as long as the bend can cover the deviation, which stops a smooth setting retriggering on every semitone; at bend = 0 the hold window collapses to half a semitone so every step becomes a fresh note. Note changes send the new note before releasing the old one, for legato on a monophonic synth.

The firmware transmits RPN 0 at start-up requesting bendRangeSemitones. Synths that ignore RPN need their bend range set to match by hand.

The volume antenna drives a controller continuously and gates note on/off, with hysteresis around the trigger point. Note-on velocity comes from hand position plus how fast the hand left the antenna.


Porting to another UNO form factor board

Most of the firmware is board-independent. machine.D2, machine.A0 and so on resolve to whichever MCU pin a board places at that header position, so the pin map in config.go needs no change as long as the target defines those names.

These are the parts that do:

FileWhat is specificWhat to do
cycles.goCortex-M DWT cycle counter (CYCCNT)Present on M3/M4/M7, absent on M0+ such as RP2040. On those, provide the same cycles() and cpuHz from a free-running hardware timer.
sensor.goarm.SetPriority with SAMD51 IRQ_EIC_EXTINT_* and IRQ_USB_* numbersOnly sets interrupt priorities so USB cannot delay a beat timestamp. Substitute the target's IRQ numbers, or drop it and accept the jitter.
store.goSAMD51 NVMCTRL registers, 8 KB erase block, address 0x0007E000Rewrite for the target's flash controller and pick a free block at the end of its flash. Or stub loadCalibration/saveCalibration out and recalibrate each power-up.
main.gomachine.NINA_RESETNMetro M4 AirLift only. Set holdNinaInReset = false on boards without an ESP32.
pots.goselectExternalAREF writes SAMD51 ADCn.REFCTRLOnly called when useExternalAREF is true. Leave it false and the function is unused.
build.sh, build.ps1, this file-target=metro-m4-airliftPass --target / -Target, or change the default in the scripts.

Everything else, dac.go, autotune.go, calibrate.go, voice.go, midiout.go, ui.go, debug.go, uses only the portable machine API.

Two requirements the target must be met: TinyGo USB MIDI support, and two pins that can take edge interrupts (D2 and D8 in the UNO footprint).


Configuration

Everything adjustable is in config.go:

ConstantDefaultEffect
playingRangeOctaves4.0Octaves spanned by the calibrated hand travel
bendRangeSemitones2.0Must match the receiving synth
sensitivityRange2.0How far knob 1 can push the span
beatAverageEdges8Periods averaged per measurement; raise for a steadier bottom octave, lower for less lag
beatSmoothing0.25Output filter; lower is smoother but laggier
targetBeatHz700Resting difference frequency auto-tune aims for
volumeCC7Controller the volume antenna drives
volGateOn / volGateOff4 / 2Note on/off thresholds with hysteresis
calCaptureTime3 sLength of the calibration sweep window
midiInterval3 msHow often notes, bend and CC are recomputed
potInvertfalseFlip knob direction
useExternalAREFfalseTake the ADC reference from the AREF pin
holdNinaInResettrueHold the on-board ESP32 in reset
debugSerialtrueSerial console output

Source layout

FileContents
config.goPin map and tunable constants
cycles.goCycle counter
sensor.goBeat edge capture, filtering, gated frequency counting
dac.goBit-banged SPI to the MCP492x pair
autotune.goZero-beat sweep and bisection
calibrate.goButton-hold calibration sequence
voice.goBeat periods to note and volume; knob 2 shaping
midiout.goNote, bend and CC state machine over USB MIDI
pots.goADC round robin
ui.goLED patterns and button gestures
store.goCalibration persistence in internal flash
main.goStart-up and main loop
build.sh, build.ps1Build and flash wrappers

Contributors

intermernet

2 commits

intermernet/theremin

0

stars

2

commits

Go

primary language

Sep 6, 2026

updated

README

MidiTheremin

TinyGo firmware that turns a GaudiLabs OpenTheremin V3 shield into a USB MIDI controller. Built for an Adafruit Metro M4 Express AirLift Lite (ATSAMD51J19), and portable to other Arduino UNO form factor boards.

The shield's analogue front end is used as designed: two antenna oscillators heterodyned against a crystal reference, auto-tuned through the on-board MCP4922. The two difference frequencies become MIDI note, pitch bend and controller messages. No audio is synthesised.

Copyright (c) Mike Hughes <mike AT mikehughes DOT info> 2026


Shield modification

The OpenTheremin V3 is a 5 V design. The SAMD51 is not 5 V tolerant, so on an unmodified shield the two beat signals (D2, D8) and the four pot wipers (A0–A3) all exceed what the MCU may see.

Every active part on the shield is rated well below 3.3 V. The counters and flip-flop are 74HC parts, the oscillator inverter already runs from 3V3, and both DACs take 2.7–5.5 V. Running the whole shield from 3.3 V solves these problems. The fix consists of bending one pin and soldering one short jumper wire.

Do these steps in order! Soldering the jumper wire before disconnecting the 5 V pin will short the board's 5 V rail to its 3.3 V rail and back-feed the regulator. Do step 1 first, and verify it with a multimeter.

  1. With the shield off the board, find the pin on the shield's power header that lines up with the 5V socket. Bend it outwards about 30° so it sits beside the socket instead of entering it.
  2. Seat the shield with USB unplugged and measure resistance between 5V and 3V3. It must read open. A few ohms means the pin is still making contact.
  3. On the top of the shield, solder a short wire between the 5V and 3V3 header pads. They are adjacent pins, so use a wire rather than a solder bridge.
  4. Make sure useExternalAREF = false in config.go.

Power up and check the serial console. Two resting beat frequencies near the target mean the references are running:

autotune pitch: zero beat near DAC 2176
autotune pitch: DAC 2166, resting beat 707 Hz
autotune volume: zero beat near DAC 2304
autotune volume: DAC 2269, resting beat 677 Hz

Building

Requires TinyGo 0.42 or later and a Go toolchain the TinyGo release accepts.

cd src/theremin
tinygo build -target=metro-m4-airlift -size=short -o firmware.uf2 .

Flash by putting the device into BOOTSEL or FLASH mode and copying firmware.uf2 onto it, or:

tinygo flash -target=metro-m4-airlift .

Wrapper scripts do both. On Linux and macOS:

./build.sh             # writes firmware.uf2
./build.sh --flash     # builds and flashes
./build.sh --offline   # GOPROXY=off, for when the module proxy stalls
./build.sh --help      # all options

On Windows:

.\build.ps1            # writes firmware.uf2
.\build.ps1 -Flash     # builds and flashes
.\build.ps1 -Offline   # GOPROXY=off, for when the module proxy stalls

Both take --target / -Target to build for a different board.

The device enumerates as a composite CDC + MIDI device, so one cable carries the MIDI port and a serial console. Set debugSerial = false in config.go to silence the console.


Using it

Button

GestureEffect
TapToggle MIDI output. Red LED on = muted.
Hold 3 sBoth LEDs flash. Keep holding to calibrate.
Hold 8 sEnter calibration. Releasing earlier cancels.

Knobs

Numbered in reading order on the board: P1 top-left, P2 top-right, P3 bottom-left, P4 bottom-right.

KnobFunction
1Pitch sensitivity. Centre reproduces the calibrated range; fully left halves it, fully right doubles it.
2Playing: pitch bend amount. Fully left quantises hard to semitones, fully right is fully continuous; in between, pitch holds over the middle of each semitone and sweeps across the boundary.
Muted: selects what the volume antenna does, 11 detents. Fully left is the default — channel volume, gating notes. The rest assign the antenna to CC 70–79, where notes sound at full volume gated by the pitch hand alone. Muting alone changes nothing; the knob takes over only once it is moved, and the choice is saved to flash when you unmute.
3Transpose in semitones: fully left C2 (MIDI 36), centre C3 (48), fully right C4 (60). Sets the note at the far end of the pitch field.
4MIDI channel, 17 positions. Fully left is all channels; the rest selects 1–16. Changing it releases anything sounding on the old channel.

LEDs

PatternMeaning
Amber follows playA MIDI message was just sent.
Red steadyMuted.
Both flashing, 3 HzButton held past 3 s.
Alternating, 2.5 HzAuto-tune running. Hands away.
Both solidBring a hand close to each antenna, without touching.
Amber blinking, 5 HzCapture window; sweep out to your far point.
Amber slow blinkCalibration saved.
Fast alternationCalibration rejected, or a fatal start-up fault.
Red slow blinkAn antenna oscillator is quiet. Shield unpowered or unplugged.

Amber is D4 on A5, red is D3 on A4. Swap pinLEDRed and pinLEDAmber in config.go if they come out reversed.

Calibration

Hold the button for eight seconds. The sequence takes about fifteen seconds and drives itself from the LEDs:

  1. Both LEDs dark, 1.5 s - release the button, hands away.
  2. LEDs alternating - oscillator auto-tune. Keep your hands off.
  3. Both LEDs solid, 2 s - bring a hand close to each antenna, as near as you would actually play. Do not touch them: contact gives a beat tens of times the resting one and skews the captured range.
  4. Amber blinking, 3 s - sweep both hands out to your furthest playing point.
  5. Amber slow blink - saved. Fast alternation means nothing moved and the previous calibration was kept.

Both extremes of both beat periods are recorded across the whole window, so the sweep can go in either direction. Results are stored in internal flash and survive a power cycle. Auto-tune runs again on every power-up, but a stored tuning still close to target is accepted as-is, so a warm start takes under a second.

Before the first calibration the firmware falls back to a synthetic range, so the device is playable immediately.


How it works

The 74HC74 samples each antenna oscillator with the crystal reference, so F_PITCH and F_VOL are square waves at the difference of the two frequencies: a few hundred Hz with the hands away, rising as a hand approaches. A pin interrupt timestamps every rising edge against a free-running cycle counter, and the mean period across the last few edges is smoothed with an IIR filter.

Pitch position is linear in the period difference between the far point and now. Frequency then rises linearly with that position across playingRangeOctaves, which keeps a real theremin's crowding of the upper register. The note number is the base-2 log of that:

note = transpose + 12 · log₂(1 + x · (2^octaves − 1))

The continuous note is shaped by knob 2, then split into a MIDI note plus pitch bend. The note is held for as long as the bend can cover the deviation, which stops a smooth setting retriggering on every semitone; at bend = 0 the hold window collapses to half a semitone so every step becomes a fresh note. Note changes send the new note before releasing the old one, for legato on a monophonic synth.

The firmware transmits RPN 0 at start-up requesting bendRangeSemitones. Synths that ignore RPN need their bend range set to match by hand.

The volume antenna drives a controller continuously and gates note on/off, with hysteresis around the trigger point. Note-on velocity comes from hand position plus how fast the hand left the antenna.


Porting to another UNO form factor board

Most of the firmware is board-independent. machine.D2, machine.A0 and so on resolve to whichever MCU pin a board places at that header position, so the pin map in config.go needs no change as long as the target defines those names.

These are the parts that do:

FileWhat is specificWhat to do
cycles.goCortex-M DWT cycle counter (CYCCNT)Present on M3/M4/M7, absent on M0+ such as RP2040. On those, provide the same cycles() and cpuHz from a free-running hardware timer.
sensor.goarm.SetPriority with SAMD51 IRQ_EIC_EXTINT_* and IRQ_USB_* numbersOnly sets interrupt priorities so USB cannot delay a beat timestamp. Substitute the target's IRQ numbers, or drop it and accept the jitter.
store.goSAMD51 NVMCTRL registers, 8 KB erase block, address 0x0007E000Rewrite for the target's flash controller and pick a free block at the end of its flash. Or stub loadCalibration/saveCalibration out and recalibrate each power-up.
main.gomachine.NINA_RESETNMetro M4 AirLift only. Set holdNinaInReset = false on boards without an ESP32.
pots.goselectExternalAREF writes SAMD51 ADCn.REFCTRLOnly called when useExternalAREF is true. Leave it false and the function is unused.
build.sh, build.ps1, this file-target=metro-m4-airliftPass --target / -Target, or change the default in the scripts.

Everything else, dac.go, autotune.go, calibrate.go, voice.go, midiout.go, ui.go, debug.go, uses only the portable machine API.

Two requirements the target must be met: TinyGo USB MIDI support, and two pins that can take edge interrupts (D2 and D8 in the UNO footprint).


Configuration

Everything adjustable is in config.go:

ConstantDefaultEffect
playingRangeOctaves4.0Octaves spanned by the calibrated hand travel
bendRangeSemitones2.0Must match the receiving synth
sensitivityRange2.0How far knob 1 can push the span
beatAverageEdges8Periods averaged per measurement; raise for a steadier bottom octave, lower for less lag
beatSmoothing0.25Output filter; lower is smoother but laggier
targetBeatHz700Resting difference frequency auto-tune aims for
volumeCC7Controller the volume antenna drives
volGateOn / volGateOff4 / 2Note on/off thresholds with hysteresis
calCaptureTime3 sLength of the calibration sweep window
midiInterval3 msHow often notes, bend and CC are recomputed
potInvertfalseFlip knob direction
useExternalAREFfalseTake the ADC reference from the AREF pin
holdNinaInResettrueHold the on-board ESP32 in reset
debugSerialtrueSerial console output

Source layout

FileContents
config.goPin map and tunable constants
cycles.goCycle counter
sensor.goBeat edge capture, filtering, gated frequency counting
dac.goBit-banged SPI to the MCP492x pair
autotune.goZero-beat sweep and bisection
calibrate.goButton-hold calibration sequence
voice.goBeat periods to note and volume; knob 2 shaping
midiout.goNote, bend and CC state machine over USB MIDI
pots.goADC round robin
ui.goLED patterns and button gestures
store.goCalibration persistence in internal flash
main.goStart-up and main loop
build.sh, build.ps1Build and flash wrappers

Contributors

intermernet

2 commits

Languages

Go

94.7%

Shell

3.3%

PowerShell

2.1%