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
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.
5V socket. Bend it outwards about 30° so it sits
beside the socket instead of entering it.5V and
3V3. It must read open. A few ohms means the pin is still making contact.5V and 3V3
header pads. They are adjacent pins, so use a wire rather than a solder
bridge.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
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.
| Gesture | Effect |
|---|---|
| Tap | Toggle MIDI output. Red LED on = muted. |
| Hold 3 s | Both LEDs flash. Keep holding to calibrate. |
| Hold 8 s | Enter calibration. Releasing earlier cancels. |
Numbered in reading order on the board: P1 top-left, P2 top-right, P3 bottom-left, P4 bottom-right.
| Knob | Function |
|---|---|
| 1 | Pitch sensitivity. Centre reproduces the calibrated range; fully left halves it, fully right doubles it. |
| 2 | Playing: 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. |
| 3 | Transpose 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. |
| 4 | MIDI channel, 17 positions. Fully left is all channels; the rest selects 1–16. Changing it releases anything sounding on the old channel. |
| Pattern | Meaning |
|---|---|
| Amber follows play | A MIDI message was just sent. |
| Red steady | Muted. |
| Both flashing, 3 Hz | Button held past 3 s. |
| Alternating, 2.5 Hz | Auto-tune running. Hands away. |
| Both solid | Bring a hand close to each antenna, without touching. |
| Amber blinking, 5 Hz | Capture window; sweep out to your far point. |
| Amber slow blink | Calibration saved. |
| Fast alternation | Calibration rejected, or a fatal start-up fault. |
| Red slow blink | An 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.
Hold the button for eight seconds. The sequence takes about fifteen seconds and drives itself from the LEDs:
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.
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.
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:
| File | What is specific | What to do |
|---|---|---|
cycles.go | Cortex-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.go | arm.SetPriority with SAMD51 IRQ_EIC_EXTINT_* and IRQ_USB_* numbers | Only sets interrupt priorities so USB cannot delay a beat timestamp. Substitute the target's IRQ numbers, or drop it and accept the jitter. |
store.go | SAMD51 NVMCTRL registers, 8 KB erase block, address 0x0007E000 | Rewrite 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.go | machine.NINA_RESETN | Metro M4 AirLift only. Set holdNinaInReset = false on boards without an ESP32. |
pots.go | selectExternalAREF writes SAMD51 ADCn.REFCTRL | Only called when useExternalAREF is true. Leave it false and the function is unused. |
build.sh, build.ps1, this file | -target=metro-m4-airlift | Pass --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).
Everything adjustable is in config.go:
| Constant | Default | Effect |
|---|---|---|
playingRangeOctaves | 4.0 | Octaves spanned by the calibrated hand travel |
bendRangeSemitones | 2.0 | Must match the receiving synth |
sensitivityRange | 2.0 | How far knob 1 can push the span |
beatAverageEdges | 8 | Periods averaged per measurement; raise for a steadier bottom octave, lower for less lag |
beatSmoothing | 0.25 | Output filter; lower is smoother but laggier |
targetBeatHz | 700 | Resting difference frequency auto-tune aims for |
volumeCC | 7 | Controller the volume antenna drives |
volGateOn / volGateOff | 4 / 2 | Note on/off thresholds with hysteresis |
calCaptureTime | 3 s | Length of the calibration sweep window |
midiInterval | 3 ms | How often notes, bend and CC are recomputed |
potInvert | false | Flip knob direction |
useExternalAREF | false | Take the ADC reference from the AREF pin |
holdNinaInReset | true | Hold the on-board ESP32 in reset |
debugSerial | true | Serial console output |
| File | Contents |
|---|---|
config.go | Pin map and tunable constants |
cycles.go | Cycle counter |
sensor.go | Beat edge capture, filtering, gated frequency counting |
dac.go | Bit-banged SPI to the MCP492x pair |
autotune.go | Zero-beat sweep and bisection |
calibrate.go | Button-hold calibration sequence |
voice.go | Beat periods to note and volume; knob 2 shaping |
midiout.go | Note, bend and CC state machine over USB MIDI |
pots.go | ADC round robin |
ui.go | LED patterns and button gestures |
store.go | Calibration persistence in internal flash |
main.go | Start-up and main loop |
build.sh, build.ps1 | Build and flash wrappers |
2 commits
Go
94.7%
Shell
3.3%
PowerShell
2.1%
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
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.
5V socket. Bend it outwards about 30° so it sits
beside the socket instead of entering it.5V and
3V3. It must read open. A few ohms means the pin is still making contact.5V and 3V3
header pads. They are adjacent pins, so use a wire rather than a solder
bridge.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
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.
| Gesture | Effect |
|---|---|
| Tap | Toggle MIDI output. Red LED on = muted. |
| Hold 3 s | Both LEDs flash. Keep holding to calibrate. |
| Hold 8 s | Enter calibration. Releasing earlier cancels. |
Numbered in reading order on the board: P1 top-left, P2 top-right, P3 bottom-left, P4 bottom-right.
| Knob | Function |
|---|---|
| 1 | Pitch sensitivity. Centre reproduces the calibrated range; fully left halves it, fully right doubles it. |
| 2 | Playing: 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. |
| 3 | Transpose 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. |
| 4 | MIDI channel, 17 positions. Fully left is all channels; the rest selects 1–16. Changing it releases anything sounding on the old channel. |
| Pattern | Meaning |
|---|---|
| Amber follows play | A MIDI message was just sent. |
| Red steady | Muted. |
| Both flashing, 3 Hz | Button held past 3 s. |
| Alternating, 2.5 Hz | Auto-tune running. Hands away. |
| Both solid | Bring a hand close to each antenna, without touching. |
| Amber blinking, 5 Hz | Capture window; sweep out to your far point. |
| Amber slow blink | Calibration saved. |
| Fast alternation | Calibration rejected, or a fatal start-up fault. |
| Red slow blink | An 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.
Hold the button for eight seconds. The sequence takes about fifteen seconds and drives itself from the LEDs:
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.
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.
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:
| File | What is specific | What to do |
|---|---|---|
cycles.go | Cortex-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.go | arm.SetPriority with SAMD51 IRQ_EIC_EXTINT_* and IRQ_USB_* numbers | Only sets interrupt priorities so USB cannot delay a beat timestamp. Substitute the target's IRQ numbers, or drop it and accept the jitter. |
store.go | SAMD51 NVMCTRL registers, 8 KB erase block, address 0x0007E000 | Rewrite 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.go | machine.NINA_RESETN | Metro M4 AirLift only. Set holdNinaInReset = false on boards without an ESP32. |
pots.go | selectExternalAREF writes SAMD51 ADCn.REFCTRL | Only called when useExternalAREF is true. Leave it false and the function is unused. |
build.sh, build.ps1, this file | -target=metro-m4-airlift | Pass --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).
Everything adjustable is in config.go:
| Constant | Default | Effect |
|---|---|---|
playingRangeOctaves | 4.0 | Octaves spanned by the calibrated hand travel |
bendRangeSemitones | 2.0 | Must match the receiving synth |
sensitivityRange | 2.0 | How far knob 1 can push the span |
beatAverageEdges | 8 | Periods averaged per measurement; raise for a steadier bottom octave, lower for less lag |
beatSmoothing | 0.25 | Output filter; lower is smoother but laggier |
targetBeatHz | 700 | Resting difference frequency auto-tune aims for |
volumeCC | 7 | Controller the volume antenna drives |
volGateOn / volGateOff | 4 / 2 | Note on/off thresholds with hysteresis |
calCaptureTime | 3 s | Length of the calibration sweep window |
midiInterval | 3 ms | How often notes, bend and CC are recomputed |
potInvert | false | Flip knob direction |
useExternalAREF | false | Take the ADC reference from the AREF pin |
holdNinaInReset | true | Hold the on-board ESP32 in reset |
debugSerial | true | Serial console output |
| File | Contents |
|---|---|
config.go | Pin map and tunable constants |
cycles.go | Cycle counter |
sensor.go | Beat edge capture, filtering, gated frequency counting |
dac.go | Bit-banged SPI to the MCP492x pair |
autotune.go | Zero-beat sweep and bisection |
calibrate.go | Button-hold calibration sequence |
voice.go | Beat periods to note and volume; knob 2 shaping |
midiout.go | Note, bend and CC state machine over USB MIDI |
pots.go | ADC round robin |
ui.go | LED patterns and button gestures |
store.go | Calibration persistence in internal flash |
main.go | Start-up and main loop |
build.sh, build.ps1 | Build and flash wrappers |
2 commits
Go
94.7%
Shell
3.3%
PowerShell
2.1%