Open Source
The Archean synthesizer is an open-source project. This means that all schematics and source code are freely available to the public on GitHub. Our goal is to invite the community to explore, modify, and improve the synthesizer together.

Source Code Overview
The source code is written for the Arduino IDE environment and runs on the Teensy 4.0 microcontroller. It controls the core functions of the synthesizer, including oscillator behavior, sensor inputs, MIDI communication, and audio generation. The code is structured into modules that manage different parts of the instrument, such as sound generation, envelope shaping (ADSR), LFO modulation, and user interaction through the capacitive touch keyboard and distance sensor.

How to Access and Edit the Code
You can access the full source code by visiting our GitHub repository. To start modifying the code:
  • Clone or download the repository to your computer.
  • Install the Arduino IDE and Teensy support (Teensyduino).
  • Install the required libraries: Adafruit_MPR121, VL53L0X, TeensyTimerTool, Watchdog_t4, MIDI and ADC. Some of them are already included with Teensyduino; the rest can be installed from the Arduino Library Manager.
  • Open the main project file (Archean.ino) in the Arduino IDE.
  • Familiarize yourself with the key modules and functions through the inline comments and
  • documentation.
  • Customize parameters like waveforms, sensor responsiveness, scale quantization, or MIDI settings to suit your preferences.
  • Upload the modified code to the Teensy 4.0 using a USB connection.

Tips for Working with the Code
  • Begin with small changes and test frequently to understand how your edits affect the sound and behavior.
  • Use comments in the code as a guide — they explain the purpose of each section.
  • Respect timing-critical sections (like the OscillatorUpdate() interrupt) — changes here can cause audio glitches.
  • If you need support or want to share improvements, open an issue on GitHub or write to hello@nakedboards.online.
  • Contributions through pull requests are welcome and help the project grow.
Archean.ino
#include "Adafruit_MPR121.h"
#include "TeensyTimerTool.h"
/*...*/
#include <AnalogBufferDMA.h>
In Arduino programming, #include statements are like importing tools into your workshop. They bring in pre-written code libraries that give your program special abilities. Without these, you'd have to write everything from scratch.

These libraries work together to give the Archean synthesizer its capabilities:
  • Touch sensing for interactive controls
  • Precise timing for accurate audio generation
  • System stability with automatic crash recovery
  • Fast audio generation using wavetables
  • Musical intelligence with MIDI and frequency conversion
  • Communication with other chips and devices
  • Responsive controls by reading knobs and inputs efficiently
  • You'll notice some includes use quotes "Wavetable.h" and others angle brackets <MIDI.h>:
  • Quotes ("...") — the compiler looks in the project folder first, then in the installed libraries.
  • Angle brackets (<...>) — the compiler looks only in the installed libraries.
Both work the same way - they just tell the compiler where to look for the files.
#define OSC_DAC_CS_PIN  1
#define DAC_ADSR_AND_DISTANCE_CS  10
/*...*/
#define creates a constant — a name that represents a specific value throughout your code. Think of it as giving a friendly nickname to a number, so you don't have to remember what "pin 10" does every time you see it.

Why use defines?
  • Makes code easier to read — GATE_PIN is clearer than just seeing 9
  • Makes code easier to change — if you need to move a component to a different pin, you only
  • change it in one place
  • Prevents mistakes — if you type the name wrong, the compiler will warn you

When you see these defines used later in the code, like digitalWrite(GATE_PIN, HIGH); remember that it's the same as writing digitalWrite(9, HIGH); But GATE_PIN tells you what you're controlling, not just which pin you're using.

// Global variables
volatile byte SPIfree = true;
volatile byte I2Cfree = true;
boolean Gate = false;
/*...*/
Global variables are like a whiteboard in a shared workspace — anyone in the program can read from it or write to it at any time. They store information that multiple parts of your code need to access.
Important concept: Variables declared outside of functions are "global" and can be used anywhere in your program.

What does volatile mean? It tells the compiler: "This variable can change at ANY moment, even when you don't expect it — so ALWAYS check its real value, never assume!"

Why is this important? In synthesizers, interrupts can change variables at unpredictable times. Without volatile, the compiler might "optimize" your code by assuming the variable doesn't change, which could cause bugs.

Data Types:
  • boolean — can only be true or false; uses 1 byte of memory; perfect for yes/no questions.
  • byte — can be 0 to 255; uses 1 byte of memory; often used for flags (0 = false, 1 = true, or LOW/HIGH); more flexible than boolean if you need multiple states.
  • short — 16-bit signed integer, from −32,768 to +32,767; often used for MIDI note numbers, scale numbers, counter values and small integer calculations.
// Timers 
IntervalTimer OscillatorTimer; // Oscillator
PeriodicTimer ADSRtimer; // ADSR
PeriodicTimer LFOtimer; // LFO
/*...*/
Imagine the Archean synthesizer is an orchestra. You have different sections: oscillators playing the main note, ADSR shaping the volume of each note, LFO adding wobbles and pulsations and more. If every musician played at their own random speed, it would be chaos! They need a conductor to keep everyone in time. Timers are the conductors of your synthesizer.

What is a Timer? In code, a timer is a special function that says: "Do this specific task, over and over, at a very precise speed." The Teensy 4.0 is incredibly fast, but sound requires perfect timing. If the timing is off, the sound will crackle, warble, or stop altogether. Timers make sure every part of the synth gets the attention it needs, exactly when it needs it.
// Watching dog
WDT_T4<WDT1> WDT;
Imagine you're running a very important, live musical performance with the Archean. The code has to keep running no matter what. But what if a bug in the code, some electrical noise, or a weird knob twist causes the Teensy to get stuck in an infinite loop or just freeze? This is where the Watchdog comes in.

Think of the Watchdog as a very loyal, but very hungry, guard dog you have assigned to protect your synthesizer:
  1. You tell the dog: "Watch my program. If I don't check in with you and give you a treat regularly, it means I'm stuck and you need to reboot the entire system."
  2. In your main loop, the code regularly "feeds the dog" by calling WDT.feed();. It tells the watchdog: "Everything is fine! I'm still running normally."
  3. If the code gets stuck (for example, in a while loop that never ends), it can no longer feed the dog.
  4. When the timeout runs out, the watchdog reboots the system. The Teensy restarts, your sketch starts from the beginning, and the synth comes back to life.
Why is this so important for the Archean?
  • Prevents a frozen synth: without a watchdog, a crash could leave a stuck note or silence until you power-cycle.
  • Great for beginners: when you write new code, the watchdog acts as an automatic recovery system.
  • Professional reliability: even after a rare, unexpected glitch, the synth recovers on its own.
void setup(){
    /* .... */
}
Imagine you're a musician setting up your gear on stage before a concert. The setup() function is your synthesizer's "sound check" and setup routine.

What is the setup() function? In the Arduino IDE, every program (called a "sketch") must have two special functions: setup() and loop(). The setup() function runs only one time, immediately when you turn on the synthesizer or press the reset button. Its job is to prepare the Teensy and all the hardware to be ready to make sound and read your controls.

For the Archean, setup() includes pin configuration (for example pinMode(LED_PIN, OUTPUT); — the LED is on pin 8), starting the timers that call the oscillator, ADSR and LFO functions, and initializing MIDI, SPI, I2C, the ADC, the touch keyboard and the distance sensor. After setup() finishes, the loop() function immediately takes over.

// LED blinks once at startup
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH);
delay(10);
digitalWrite(LED_PIN, LOW);
The "Hello, World!" Blink: The Synthesizer's Power-On Signal. This small section of code is the synthesizer's way of saying, "I'm alive and everything is working!" It's like when you turn on a car and all the dashboard lights briefly illuminate.
  1. pinMode(LED_PIN, OUTPUT); — tells the Teensy that the pin connected to the LED will send out a signal, not read one.
  2. digitalWrite(LED_PIN, HIGH); — sets the pin HIGH: the LED lights up.
  3. delay(10); — keeps the LED on for 10 milliseconds: a very short blink.
  4. digitalWrite(LED_PIN, LOW); — sets the pin LOW: the LED turns off.
This blink happens in setup() because it's a one-time startup ritual. For the user, it confirms that the synth has power and the program has started. For the programmer, it's a debugging tool: if the LED doesn't blink after an upload, the program isn't getting past the very beginning of setup().
MIDIinit();
This function initializes (sets up) the MIDI system. It prepares the synthesizer to send and receive MIDI messages.

What is MIDI? MIDI = Musical Instrument Digital Interface — a communication protocol that lets musical instruments, computers, and synthesizers talk to each other. MIDI doesn't send audio — it sends instructions like: "Play note 60 (middle C) at velocity 100", "Stop playing note 60", "Turn controller 7 to position 64", "Bend the pitch up".
SPI.begin();
This function initializes the SPI communication system. It prepares the Teensy to talk to external chips using the SPI protocol.

What is SPI? SPI = Serial Peripheral Interface. A very fast communication protocol that lets the Teensy (master) talk to other chips (slaves) — like DACs. Key characteristics: fast (millions of bits per second), synchronous (uses a clock signal), full duplex (can send and receive simultaneously), master-slave (the Teensy controls the communication).

Why Does the Archean Use SPI? The Archean has three DACs (Digital to Analog Converters) that receive digital data from the Teensy and convert it to analog voltages:
1. Oscillator DAC — generates the main audio signal
2. ADSR & Distance DAC — creates envelope and sensor control voltages
3. LFO & Element DAC — generates modulation signals

All three DACs share one SPI bus. SPI uses four connections (though some devices only need three):
  1. MOSI — Master Out, Slave In. Carries data from the Teensy to the DACs.
  2. MISO — Master In, Slave Out. Carries data from the slaves to the Teensy. The DACs don't send data back, so MISO is not used in Archean: pin 12 serves as the chip select of the LFO & Element DAC.
  3. SCK — Serial Clock. Provides timing pulses so sender and receiver stay synchronized.
  4. CS — Chip Select. Tells a specific chip "I'm talking to YOU now". Multiple devices share MOSI and SCK, so CS selects which one listens.
pinMode(GATE_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(GATE_PIN), GateInterrupt, CHANGE);
/*...*/
What is an Interrupt? An interrupt is like a doorbell for your microcontroller — it immediately stops what it's doing to handle something urgent, then returns to what it was doing before. You're reading a book (main program loop). Suddenly, your phone rings (interrupt). You stop reading and remember your page, answer the phone (interrupt handler), finish the call and return to your book exactly where you left off.

Why interrupts are important in synthesizers: instant response (no waiting for the main loop to check inputs), real-time performance (critical for musical timing), efficiency (the CPU does other work until something needs attention).

Part 1: pinMode() — Configuring the Pin. pinMode(GATE_PIN, INPUT_PULLUP); tells the Teensy how the pin should behave.

Understanding INPUT_PULLUP. When a pin is set to INPUT and nothing is connected, it "floats" — it picks up electrical noise and gives random readings. INPUT_PULLUP switches on a resistor inside the Teensy that connects the pin to +3.3V. This keeps the pin HIGH by default, prevents floating, and the pin goes LOW only when actively grounded. On Archean the Gate input reaches pin 9 through a transistor that pulls it to ground, so a high gate voltage reads as LOW.

Part 2: attachInterrupt() — Setting Up the Interrupt.
attachInterrupt(digitalPinToInterrupt(GATE_PIN), GateInterrupt, CHANGE); tells the Teensy: "When something happens on this pin, immediately run this function!" Syntax: attachInterrupt(interrupt_number, function_to_call, trigger_mode);

Parameter 1: digitalPinToInterrupt(GATE_PIN) converts a pin number to its interrupt number. Some older Arduino boards have different numbers for pins and interrupts; this function makes code portable. On Teensy 4.0 almost every pin can be an interrupt.

Parameter 2: GateInterrupt — the name of the function to call when the interrupt happens. Critical rules for interrupt functions: be fast — do minimal work and get out; no delays — never use delay() inside; use volatile variables so the main loop sees changes; set flags and let the main loop do heavy work; no serial prints — too slow for interrupts.

Parameter 3: CHANGE — the trigger mode. Available modes: LOW, HIGH, CHANGE (any change), RISING (LOW→HIGH), FALLING (HIGH→LOW). In Archean, CHANGE is used because both edges of the gate matter: one starts the note, the other ends it. With interrupts, every gate pulse is captured instantly.
Wire1.begin();
Wire1.setClock(3400000);
What is Wire? Wire is the Arduino library name for I2C (Inter-Integrated Circuit) communication. It's called "Wire" because I2C uses only two wires to communicate with multiple devices.

What is I2C? I2C is a communication protocol that lets multiple chips talk to each other using just two wires: SDA (data) and SCL (clock). Many devices can share one bus; each has a unique address. The Teensy is the master, other chips are slaves. It is slower than SPI, but uses fewer wires.

Why "Wire1" and not just "Wire"? The Teensy 4.0 has several I2C buses: Wire (pins 18 and 19), Wire1 (pins 16 and 17), Wire2 (pins 24 and 25). Archean uses Wire1: pin 16 (SCL1) and pin 17 (SDA1). Both touch sensors and the distance sensor share this bus.

Part 1: Begin. Wire1.begin(); initializes I2C bus 1 and prepares it for communication. The bus needs pull-up resistors on SDA and SCL.

Part 2: Set Clock. Wire1.setClock(3400000); requests an I2C speed of 3.4 MHz (High-Speed mode). The keyboard and the distance sensor are read constantly, so a fast bus reduces latency between touch and sound.
void loop(){
    /* ... */
    // Set oscillator frequency
    Pitch();
   
    /* ... */
    // Checking button state
    CheckButtonState();

    /* ... */
    // Feeding the Watchdog Timer to prevent system reset
    WDT.feed();
}
If the setup() function was the "sound check," then the loop() function is the live performance that never ends. It runs continuously from the moment setup() finishes until you turn off the power: it listens to your commands, updates settings and makes decisions.

The Magic of "Fast Enough". Humans perceive things much slower than computers. The loop runs so fast that the synth feels instant: turning a knob changes pitch immediately because Pitch() runs thousands of times per second; a button press is detected almost at once by CheckButtonState().
Summary: loop() is the main program that runs forever. It checks everything repeatedly: knobs, buttons, sensors. It works together with the timers: the loop handles the "slow" stuff (knobs, buttons), while timer interrupts handle the "fast" stuff (actual sound generation).
1V_OCT.h
// Values are in microseconds
double V_OCT[] = { 
  238.891028,
  237.759720,
  236.633769 
  /* ... */
}
What is 1V/OCT? 1V/OCT = 1 Volt per Octave — the standard used in modular and analog synthesizers to control pitch with voltage. Each increase of 1 volt raises the pitch by exactly one octave (the frequency doubles). Example: 0V → C1 (32.70 Hz), 1V → C2 (65.41 Hz), 2V → C3 (130.81 Hz), 3V → C4 (261.63 Hz, middle C).

Universal Standard. Most modern modular synths use 1V/OCT: Eurorack modules, Moog modular, CV/Gate sequencers. Buchla systems use 1.2V/OCT. This means Archean can be controlled by standard modular gear.

What This Array Contains. This is a lookup table that converts ADC readings of the pitch input into timer periods in microseconds. It has 1024 entries — one for every value of the 10-bit ADC. About 146 entries make one octave, so the table covers seven octaves.

Why is the period so short? The oscillator timer fires once per waveform step, and each waveform has 256 steps. So the table stores the period of one step: step period = 1,000,000 / (frequency × 256). The first entry, 238.89 μs, gives 1,000,000 / (238.89 × 256) = 16.35 Hz — the note C0, the lowest note of Archean.

The Complete Signal Flow: Input Voltage → ADC Reading → Array Index → Timer Period → Oscillator Frequency.
  1. Input voltage: the input circuit scales the 1V/Oct jack voltage (plus the Tune knob) into the 0–3.3V range of the ADC.
  2. The ADC converts it to a number, for example 512 (the middle of the 0–1023 range).
  3. Look up the timer period: V_OCT[512] ≈ 21.1 μs.
  4. The timer fires every 21.1 μs — about 47,400 times per second.
  5. Every time it fires, the next waveform step goes to the DAC. 47,400 / 256 ≈ 185 Hz — the note you hear.
Why Store Periods Instead of Frequencies? The Teensy timer works with periods, not frequencies. Division is slow; pre-calculating periods and storing them in a table is much faster for real-time audio.
ADC.ino
ADC *adc = new ADC();

const uint32_t InitialAverageValue = 2;
const uint32_t BufferSize = 10;

DMAMEM static volatile uint16_t __attribute__((aligned(32))) dma_adc_buff1[BufferSize];
DMAMEM static volatile uint16_t __attribute__((aligned(32))) dma_adc_buff2[BufferSize];
AnalogBufferDMA abdma(dma_adc_buff1, BufferSize, dma_adc_buff2, BufferSize);
What is ADC? ADC = Analog to Digital Converter. The real world is analog (continuous voltages), computers are digital — the ADC is the bridge. In the Archean it reads the CV inputs and the positions of the knobs (Tune, Fine, ADSR, etc.).

ADC Resolution. Teensy 4.0 ADC: 12-bit (can be set to 10-bit for speed), range 0V to 3.3V, output 0–4095 (12-bit) or 0–1023 (10-bit). Archean uses 10-bit: 3.3V / 1024 = 3.2 mV per step.

Part 1: Creating the ADC Object. ADC *adc = new ADC(); creates the ADC controller object from the ADC library and makes it ready to read analog inputs.

Part 2: Configuration Constants. InitialAverageValue = 2 — average two readings together to smooth noise; more averaging is smoother but slower. BufferSize = 10 — each DMA buffer holds 10 ADC readings: small enough for fast response, large enough for efficient transfers.

Part 3: What is DMA? DMA = Direct Memory Access. Without DMA, the CPU starts a conversion and waits for the result. With DMA, the DMA controller reads the ADC and stores the results in a buffer by itself; the CPU only processes the data when a buffer is full and meanwhile keeps making music.

Part 4: Understanding the Buffer Declarations. DMAMEM places the buffers in the memory region used for DMA. static — the buffers exist for the entire program lifetime at a fixed address. volatile — the DMA hardware changes them independently, so the compiler must always read the real memory. uint16_t — unsigned 16-bit (0 to 65,535), enough for any ADC value. __attribute__((aligned(32))) — aligns the arrays to a 32-byte boundary, as required for DMA and cache operations. Two buffers give double buffering: DMA fills buffer 1 while the CPU processes buffer 2, then they swap — no waiting, no conflicts.

Part 5: AnalogBufferDMA Object. Creates the object that manages the double-buffered reading: DMA channels, ADC triggers, buffer swapping and signaling when data is ready.
class LowPass
{
  private:
    float a[order];
    float b[order+1];
    float omega0;
    /*...*/
}
What is This Code For? This filter cleans up noisy signals from analog inputs like potentiometers and CV inputs. When you read analog inputs with an ADC, the readings jump around: a knob in the middle position should read 512, but the actual readings are 512, 513, 510, 512, 514, 511... Sources of noise: electrical interference, thermal noise, quantization, ground loops.

Without filtering you hear random pitch wobbles even when the knob isn't moving, and abrupt value changes cause "zipper noise". A Low Pass Filter lets slow changes through (you moving a knob) and blocks fast changes (electrical jitter).
/*...*/
adc->adc0->setAveraging(1); // set number of averages
adc->adc0->setResolution(10); // set bits of resolution  
adc->adc0->setConversionSpeed(ADC_CONVERSION_SPEED::VERY_HIGH_SPEED); // fastest conversion
adc->adc0->setSamplingSpeed(ADC_SAMPLING_SPEED::VERY_HIGH_SPEED); // fastest sampling
/*...*/
adc->adc0 — the first of the two ADC modules of the Teensy 4.0 (ADC0 and ADC1).
  1. setAveraging(1) — no hardware averaging: fastest reading. Archean already uses the software low-pass filter, so hardware averaging isn't needed.
  2. setResolution(10) — 10-bit: 3.2 mV steps are more than enough for knobs and CV inputs, conversion is faster, and MIDI CC values (7-bit, 0–127) are easily derived.
  3. setConversionSpeed(VERY_HIGH_SPEED) — how fast the ADC converts the voltage to a number.
  4. setSamplingSpeed(VERY_HIGH_SPEED) — how fast the ADC captures the input voltage before converting it.
ADSR.ino
void ADSRinterrupt(void){
  if(oldEnvelope != Envelope){
    if(SPIfree == true && PIT_CVAL0 > 15){
      SPIfree = false;
      DAC(Envelope, DAC_ADSR_AND_DISTANCE_CS, false);
      SPIfree = true;
      oldEnvelope = Envelope;
    }
  }
  ADSRInterruptState = HIGH;
}
This interrupt function updates the ADSR envelope voltage whenever the envelope value changes. ADSR = Attack, Decay, Sustain, Release — shapes how a sound evolves over time.
  1. if(oldEnvelope != Envelope) — only update the DAC if the value actually changed. Saves CPU time and SPI bandwidth.
  2. if(SPIfree == true && PIT_CVAL0 > 15) — two safety checks: the SPI bus isn't used by another process, and there is enough time before the next oscillator interrupt. Prevents SPI collisions.
  3. Lock the SPI bus, send the value to channel B of the ADSR DAC, release the bus, remember the value.
  4. ADSRInterruptState = HIGH; — signals the main loop that the interrupt has run.
Why Check PIT_CVAL0 > 15? PIT_CVAL0 is the countdown register of the timer that drives the oscillator. The check makes sure there's enough time for the SPI transfer before the next oscillator interrupt fires.
void ADSR(void){  
  // Note on
  if(Gate == true){
    // ATTACK
    if(DecayState == false){
      /*...*/
    }
    // DECAY
    if(Envelope < 95 && DecayState == false){
      /*...*/
    }
    // Sustain
    if(DecayState == true && Envelope <= map(Sust, 0, 255, 4095, 0)){ 
      /*...*/
    }
  }
  
  // Note Off
  if(Gate != true){ 
    // RELEASE
    if(NoteIsActive == true){
      /*...*/
    }
  }
}
Important: the envelope is inverted. The analog stage after the DAC inverts the signal, so an Envelope value of 4095 gives 0V (silence) and a value of 0 gives the maximum, about +10V. That is why the numbers in the code move towards 0 when the envelope rises.
  • Attack — note pressed: the value moves from 4095 towards 0 (the voltage rises). It ends when the value drops below 95 — near the peak.
  • Decay — the value moves back up towards the sustain level (the voltage falls).
  • Sustain — the value holds at the sustain level, mapped from the Sust parameter (0–255) to 4095–0: the higher the knob, the smaller the value and the louder the sustain. Lasts as long as the key is held.
  • Release — key released: the value returns to 4095 (0V). NoteIsActive ensures release runs only if a note was playing.
Button.ino
void CheckButtonState(void){
  ButtonState = !digitalRead(BUTTON_PIN); // HIGH is Pressed!
}
The Button.ino file contains all the code related to the button on the Archean. This function has one job: check if the button is currently pressed. The button connects pin 7 to ground, so a pressed button reads LOW. The ! (NOT operator) inverts it, so ButtonState is HIGH when the button is pressed. Other parts of the code can then check ButtonState without reading the pin again.
void HandleButtonActions(){

  // Create a new landsacape after the button is released
  if(ButtonState == LOW && MenuState == false && CreateFlag == true && millis() - ButtonPressTime > 40){
    CreateNewLandscape();
    CreateFlag = false;
   } 

  // The menu settings are adjusted via the keyboard  
  // Pressing the button skips parameter selection and keeps the current values
  if(ButtonState == LOW && SkipFlag == false && MenuState == true){
    SkipFlag = true;
    Skipped = false;
  }

  // Button is pressed!
  if(ButtonState == HIGH){

    // Create landscape after the button is released is menu is off
    CreateFlag = true;

    // Start debounce filter
    if(ButtonPressTime ==  0) ButtonPressTime = millis();

    // Skip changes if the button is pressed
    if(MenuState == true && MenuKeyPressed < 2 && Skipped == false && millis() - ButtonPressTime > 40){
      /*...*/
      MenuKeyPressed++;
      /*...*/nalogWrite(LED_PIN, 0);
    } 

   // Open menu by holding the button
    if(millis() - ButtonPressTime >  1000 && !MenuState){
      MenuState = true;
      /*...*/
    }

  // When the button is not pressed
  }else{
    /*...*/
    CreateFlag = false;
  }

  if(MenuState == true){
    Menu();
  }

  // Save to EEPROM if settings were changed
  SaveToEEPOMMenuSettings();
}
This function takes the simple ButtonState and decides what should actually happen: quick taps, long holds, and menu navigation.
1. Creating a New Landscape (Short Press). Creates a new random landscape when you press and release the button: the button is released, the menu is off, creation is allowed, and at least 40 ms have passed (prevents accidental triggers).
2. Skipping Menu Changes. In menu mode, lets you skip a parameter and keep the current value.
3. Button Pressed Logic. Records when the press started (ButtonPressTime) and prepares actions for later. In the menu, a press longer than 40 ms (debounce) moves to the next stage.
4. Entering Menu Mode (Long Press). Holding the button for more than 1 second (1000 ms) enters the menu.
5. Menu Management. In menu mode, Menu() handles the settings.
6. Saving Settings. SaveToEEPOMMenuSettings() saves menu changes to EEPROM so they're remembered after power-off.
// Change settings using the keyboard 
void Menu(void){
  // Change Element settings using the keyboard
  if(MenuKeyPressed == 1 && MenuUpdated == LOW){
    /*...*/
    SelectElementFunction = KeybordKey;
    /*...*/
  }
  // Change Scale settings using the keyboard
  if(MenuKeyPressed > 1){
    /*...*/
    ScaleNumber = KeybordKey;
    /*...*/
  }
}
Menu(): The Keyboard Control Center. In menu mode, the touch keys change the settings: first the Element function, then the scale. MenuKeyPressed keeps track of which setting you're adjusting.
DAC.ino
void DAC(int Data, int CSpin, boolean Channel){
  Data |=0xf000;// B15(A/B)=1 B, B14(BUF)=1 on, B13(GAn) 1=x1  B12(SHDNn) 1=off
  if(Channel == true) Data &= ~0x8000; // for A-out
  SPI.beginTransaction(SPISettings(20000000, MSBFIRST, SPI_MODE0));
  digitalWrite(CSpin, LOW);
  SPI.transfer((0xff00 & Data)>>8);
  SPI.transfer(0x00ff & Data);
  digitalWrite(CSpin, HIGH);
  SPI.endTransaction();
}
Sends 12-bit values to the MCP4922 and MCP4921 DAC chips to generate analog voltages. MCP4922 — dual channel (A and B outputs); MCP4921 — single channel.

Function Parameters: Data — 12-bit value (0–4095). The DACs use a 2.5V reference, so 4095 ≈ 2.5V and 2048 ≈ 1.25V at the DAC output. CSpin — which DAC chip to talk to. Channel — which output: false = B, true = A. The single-channel MCP4921 is always written with Channel = true, because for this chip bit 15 must be 0.

Configuration Bits. Data |= 0xf000; sets the top 4 bits: B15 — channel select (A/B), B14 — BUF (buffered reference input), B13 — GA (gain ×1), B12 — SHDN (output active). Bits 11–0 carry the 12-bit value.

SPI Transaction: begin the transaction (20 MHz, MSB first, SPI mode 0); pull CS LOW to select the chip; send the high byte, then the low byte; pull CS HIGH — the DAC latches the value and updates its output; end the transaction and release the bus.

Note: every DAC output is followed by an inverting amplifier. A value of 0 gives the highest voltage at the jack, 4095 the lowest.
Distance.ino
void DistanceSensorInit(void){
  Sensor.setTimeout(500);
  Sensor.init();
  Sensor.setMeasurementTimingBudget(20000);
  Sensor.startContinuous();
}
What is VL53L0X? A Time-of-Flight (ToF) distance sensor by STMicroelectronics. It sends an invisible infrared laser pulse, measures the time for the light to bounce back and calculates the distance (up to about 2 meters). Musical application: use hand gestures to control synthesizer parameters!
1. setTimeout(500) — maximum time to wait for a sensor response (500 ms), so the synth doesn't freeze if the sensor fails.
2. init() — powers up the sensor, loads calibration data and prepares it for measurements.
3. setMeasurementTimingBudget(20000) — each measurement takes 20 ms: about 50
measurements per second, a balance between responsiveness and accuracy.
4. startContinuous() — the sensor measures continuously without being asked.
void ReadDistanceSensor(void){
  if(I2Cfree == true){
    I2Cfree = false;
    Wire1.beginTransmission(Address);
    Wire1.write(0x14 + 10); 
    Wire1.endTransmission();
    Wire1.requestFrom(0x29, 2);
    DistanceValue  = (uint16_t)Wire1.read() << 8;
    DistanceValue |= Wire1.read();
    I2Cfree = true;
  }
}
Reads the current distance from the VL53L0X via I2C. Result: DistanceValue in millimeters.
1. if(I2Cfree == true) — prevents conflicts with the MPR121 touch sensors on the same I2C bus.
2. Wire1.beginTransmission(Address); — begin communication with the sensor (address 0x29).
3. Wire1.write(0x14 + 10); — select the register to read. 0x14 is RESULT_RANGE_STATUS; the 16-bit distance is stored 10 bytes further, at 0x1E.
4. Wire1.endTransmission(); — finish writing the register address.
5. Wire1.requestFrom(0x29, 2); — ask the sensor for 2 bytes: the distance is a 16-bit value. 6. Read the high byte and shift it left 8 bits, then read the low byte and combine them.
7. I2Cfree = true; — release the bus.
void SmoothSensorData(void){
  DistanceValueBuffer[DistanceBufferCounter] = DistanceValue;
  DistanceBufferCounter++;
  if(DistanceBufferCounter > 4) DistanceBufferCounter = 0;
  SmoothedDistance = (DistanceValueBuffer[0] + DistanceValueBuffer[1] + DistanceValueBuffer[2] + DistanceValueBuffer[3] + DistanceValueBuffer[4]) / 5;
  if(SmoothedDistance > 350) SmoothedDistance = 350;
  if(SmoothedDistance < 50) SmoothedDistance = 50;
}
Smooths noisy distance readings with a moving average of the last 5 readings (a circular buffer), then limits the result to 50–350 mm. Closer than 50 mm the readings are unreliable; beyond 350 mm they become less accurate and less useful for gestures.
void DistanceUpdate(void){
  ReadDistanceSensor();
  SmoothSensorData();
  if(oldDistance != SmoothedDistance){
      /*...*/
      DAC(DACmapped, DAC_ADSR_AND_DISTANCE_CS, true);
      /*...*/
    }
    if(!Gate) analogWrite(LED_PIN, map(SmoothedDistance, 50, 350, 255, 0));
   } 
}
Complete distance workflow: read → smooth → update DAC → update LED.
  1. Read the sensor and smooth the data.
  2. Update only when the distance actually changes — avoids unnecessary SPI traffic.
  3. Map 50–350 mm to the DAC range: map(SmoothedDistance, 50, 350, 0, 4095). Because the output is inverted, a hand close to the sensor gives about +5V and a far hand about −5V at PROXIMITY CV OUT.
  4. Safe DAC update: only when SPIfree == true and PIT_CVAL0 > 40 (enough time before the next oscillator interrupt). The value goes to channel A of the ADSR & Distance DAC.
  5. LED feedback when no note is playing: 50 mm → 255 (bright), 350 mm → 0 (dark).
EEPROM.ino
void EEPROMreadSettings(void){
  for(int i = EEPROM_SIZE; i >= 0; i--){
    short data = EEPROM.read(i);
    if(data != 0){
      SelectElementFunction = data - 1;
      int temp = i-1;      
      ScaleNumber = EEPROM.read(temp) - 1;
      EEPROMaddress = i;
      break;
    } 
  }
}

void EEPROMwriteSettings(short ScaleNumber, short SelectElementFunction){
  /*...*/
  EEPROM.write(EEPROMaddress, SelectElementFunction);
  /*...*/
}

void EraseEEPROM(void){
  for(int i = 0; i <= EEPROM_SIZE;){
      EEPROM.write(i, 0);
      i++;
    }
    EEPROMaddress = 0;  
}
What is EEPROM? Memory that keeps data when the power is off. Archean stores two settings: ScaleNumber (the selected scale) and SelectElementFunction (the Element function).

Write strategy. Each save goes to the next free address instead of the same location, to spread wear across the memory. The last non-zero values are the current settings.
1. EEPROMreadSettings() scans the memory backwards and finds the most recent pair of values. They are stored as 1-based numbers, so 1 is subtracted. The scale is stored just before the Element function.
2. EEPROMwriteSettings() saves new settings to the next free addresses.
3. EraseEEPROM() clears the memory when it is full and starts again from address 0.
Element.ino
void ElementUpdate(void){ 
  switch(SelectElementFunction){
    case 0:
      /*...*/
      DigitalRandomNoise();
      /*...*/
      break;
    case 1:
      /*...*/
      break;
    case 2:
      /*...*/
      break;
    case 3:
      /*...*/
      break; 
    default: 
      /*...*/
  }
}

void DigitalRandomNoise(void){
  /*...*/
  DAC(random(0,4095), DAC_LFO_AND_ELEMENT_CS, false);
  /*...*/
}
What is the Element Block? A multi-function block that can perform different roles in your patch. Hardware: CV input, knob and CV output. Software: switchable functions selected by SelectElementFunction, chosen in the menu and saved in EEPROM.
Functions, in the same order as in the menu: case 0 — random voltage; case 1 — envelope repetition; case 2 — linear modulation of the oscillator. New functions can be added as further cases. The switch statement keeps the code organized: easy to add functions, clear which one is active, safe with the default case.
Keyboards.ino
Adafruit_MPR121 Left_MPR121 = Adafruit_MPR121();
Adafruit_MPR121 Right_MPR121 = Adafruit_MPR121();
What is MPR121? A capacitive touch sensor chip by NXP (formerly Freescale). It detects up to 12 touch inputs, communicates via I2C and has built-in noise filtering and adjustable sensitivity. Archean uses two MPR121 boards with 11 keys each: 22 keys. Conductive pads on the front panel change capacitance when touched; the MPR121 detects the change and registers a key press.
Each chip needs a unique address: Left_MPR121.begin(0x5A); and Right_MPR121.begin(0x5B);. The MPR121 has four possible addresses set by the ADDR pin; Archean uses two: 0x5A (ADDR to ground) and 0x5B (ADDR to 3.3V).
void KeyboardRead(void){
  currtouched_left = Left_MPR121.touched();
  currtouched_right = Right_MPR121.touched();

  if(digitalRead(KEYBOARD_INTERRUPT_PIN) == HIGH){
    for(uint8_t i=0; i<11; i++){
      if((currtouched_left & _BV(i)) && !(lasttouched_left & _BV(i))){
        /*...*/
        KeybordKey = i;
        /*...*/
      }
    }
  }
  /*...*/
}
Detects which keys are pressed and identifies new key presses.
  1. .touched() returns a 16-bit value where each bit represents one key. Example: 0b0000000000001010 — keys 1 and 3 are touched.
  2. The interrupt outputs of both MPR121 boards are combined by two transistors into one line on the Teensy (KEYBOARD_INTERRUPT_PIN). The code checks it before scanning the keys.
  3. for(uint8_t i=0; i<11; i++) — scan keys 0–10: each chip uses 11 of its 12 electrodes.
  4. A key is a new press when it is touched now but was not touched before: (currtouched_left &
  5. _BV(i)) && !(lasttouched_left & _BV(i)). _BV(i) = (1 << i) — a bit mask for key i.
  6. KeybordKey = i; — store which key was pressed for further processing.
LFO.ino
void LfoInterrupt(void){
  LFOCounter++;
  /*...*/
  if(LFOPointer > 1023) LFOPointer = 0;
  if (LFOWaveformSelector == true){
    LFOData = SINE[LFOPointer];
  }else{
    LFOData = PULSE[LFOPointer];
  }
  LFOCounter = 0;

  /*...*/
  DAC(LFOData, DAC_LFO_AND_ELEMENT_CS, true); 
  /*...*/
}
What is an LFO? Low Frequency Oscillator — creates slow, repeating modulation: vibrato, tremolo, filter sweeps. This function is called by a timer at regular intervals. The wavetables have 1024 samples (0–1023); at the end the pointer wraps to the start. The LFO slide switch selects Sine or Pulse. The value goes to channel A of the LFO & Element DAC. Pre-calculated waveforms are fast (an array lookup), consistent and smooth.
Landscape.ino
#define SIZE 256 // Size of the landscape array

uint16_t Landscape[SIZE]; // Main landscape height array
/*...*/

int MinValue;
int MaxValue;

int ReboundZoneMin = 30;   // Minimum height before rebound effect
int ReboundZoneMax = 190;  // Maximum height before rebound effect
int ReboundZoneStrength = 5;  // Strength of the rebound effect

int StartingHeight = 50;  // Initial terrain height

int Roughness = 5; // Controls terrain steepness (higher values create steeper slopes)

int CurrentHeight = StartingHeight;  // Current height of the terrain
int HeightIncrement = 0;  // Change in height for each step

extern short OSCsliderSelect;

// Generates three random landscapes for all oscillator modes at startup
void CreateLandsacapes(void){
  CreateLandscape(Landscape, SIZE);
  /*...*/
}

// Wrapper function for generating a new landscape.  
// Calls the parameterized function with predefined settings.  
// Used in the menu and other general contexts.
void CreateNewLandscape(void){
  CreateLandscape(Landscape, 256);
}

// Generates a new random landscape 
void CreateLandscape(uint16_t *Landscape, int size){
  for (int i = 1; i < size; i++) {
    // Adjust height based on rebound zones
    if (CurrentHeight <= ReboundZoneMin) {
        HeightIncrement += random(0, ReboundZoneStrength);
    }
    if (CurrentHeight >= ReboundZoneMax) {
        HeightIncrement -= random(0, ReboundZoneStrength);
    }

    // Apply random variation with respect to roughness
    HeightIncrement += random(-1 - round(HeightIncrement / Roughness), 2 - round(HeightIncrement / Roughness));
    CurrentHeight += HeightIncrement;

    // Store the generated height in the array
    Landscape[i] = CurrentHeight;
  }

  // Find the minimum and maximum height values
  MinValue = 300;
  MaxValue = 0;
  for (int i = 1; i < size; i++) {
    if (Landscape[i] > MaxValue) MaxValue = Landscape[i];
    if (Landscape[i] < MinValue) MinValue = Landscape[i];
  }

  // Normalize the height values to the 0-4095 range
  for (int i = 1; i < size; i++) {
    Landscape[i] = map(Landscape[i], MinValue, MaxValue, 0, 4095);
  }
}
What Are Landscape Waveforms? Random, evolving waveforms that look like mountain terrain. Three oscillator modes: 1. Triangle — the standard waveform; 2. Landscape — a fixed random terrain; 3. Metamorphism — continuously evolving terrain (morphing between LandscapeA and LandscapeB). 256 samples per landscape = one waveform cycle.
CreateLandscape() generates the terrain with a random walk: start at the initial height; for each step, push the height back up or down near the rebound zones, add a random variation limited by Roughness, and store the height. Finally, normalize the values to the full DAC range 0–4095. Every landscape is different: unique sound, evolving timbre, unexpected results.
MIDI.ino

void serialMIDIread(void){
  if (MIDI.read()){
    byte type = MIDI.getType();
    switch (type){
      case midi::NoteOn:
        /*...*/
        RecievedMIDINote = MIDI.getData1();
        MIDIvelocity = MIDI.getData2();
        Channel = MIDI.getChannel();
        /*...*/
      case midi::NoteOff:
        RecievedMIDINote = MIDI.getData1();
        Channel = MIDI.getChannel();
        /*...*/
      default:
        break;
    }
  }
}

/*...*/

void USBMIDISendMessages(void){
  /*...*/
}
serialMIDIread() reads incoming MIDI messages from external devices (keyboards, sequencers) on the MIDI input. MIDI.read() returns true if a new message is available and returns immediately if not. MIDI.getType() identifies the message: Note On, Note Off, Control Change, Pitch Bend, etc.

Note On — getData1(): note number (0–127, 60 = middle C); getData2(): velocity (0–127; 0 is often used as Note Off); getChannel(): MIDI channel (1–16). Note Off — note number and channel.
USBMIDISendMessages() sends MIDI messages from Archean over USB with usbMIDI: notes from the keyboard and Control Change messages from the distance sensor and the Fine, Attack, Decay, Sustain and Release knobs.

Connections: 3.5 mm TRS MIDI input — for hardware synthesizers, sequencers and controllers (with a TRS MIDI cable or adapter); USB — computers, DAWs, iOS/Android apps.
MIDIFreq.h
// Values are in microseconds
double midiNote[] = { 0,
/*...*/
238.89102706071438,
225.48310397275563,
212.82770978361953,
200.8826082916328,
/*
...
*/
}
Lookup table converting MIDI note numbers to oscillator timer periods (in microseconds). MIDI notes are numbered 0–127: note 60 = C4 (261.63 Hz, middle C), note 69 = A4 (440 Hz). Each number = one semitone.

As with the 1V/OCT table, each value is the period of one waveform step, not of the whole wave: step period = 1,000,000 / (frequency × 256). For middle C: 1,000,000 / (261.63 × 256) = 14.93 μs. The value 238.89 μs corresponds to C0 (16.35 Hz). Higher notes → shorter periods. Pre-calculating avoids slow floating-point division at run time: OSC_Timer = midiNote[note];
Oscillator.ino
void OscillatorUpdate(void){
  noInterrupts();
  WaveTablePoint++;
  if(WaveTablePoint > 255) WaveTablePoint = 0;
  if(OSCsliderSelect == 0) OscValue = LandscapeA[WaveTablePoint];
  else if(OSCsliderSelect == 1) OscValue = Triangle[WaveTablePoint];
  else if(OSCsliderSelect == 2) OscValue = Landscape[WaveTablePoint];
  /*...*/
  DAC(OscValue, OSC_DAC_CS_PIN, true);
  /*...*/
  interrupts();
}
This is the heart of sound generation. OscillatorTimer calls this function once per waveform step. The timer period changes with the pitch, so the step rate goes from about 4.2 kHz for the lowest note (16.35 Hz × 256) to about 536 kHz for the highest (2093 Hz × 256).
  1. noInterrupts(); — critical section: no other interrupt can run during the audio output. Prevents glitches and clicks.
  2. Step through the waveform: the index loops 0 → 255 → 0. One complete cycle = one period of the waveform.
  3. Select the waveform with the oscillator slide switch: 0 — LandscapeA (Metamorphism), 1 — Triangle, 2 — Landscape.
  4. Send the sample to the oscillator DAC.
  5. interrupts(); — allow interrupts again.
void Pitch(void){
  /*...*/
  if(MIDInoteRecieved == false){
    /*...*/
    KeyFind = FindTone(KeybordNote, ScaleNumber);
    /*...*/
    
    //int OSCArrayIndex = ADC_Pitch_Filtered + int((KeyFind*12.2));
    int OSCArrayIndex = 0 + int((KeyFind*12.2));
    
    /*...*/
    OSC_Timer = V_OCT[OSCArrayIndex];
  }
  // If a MIDI note is received, sets frequency according to the MIDI pitch. 
  else{
    OSC_Timer = midiNote[RecievedMIDINote];
  }

  /*...*/

  // Updates the oscillator timer based on the calculated pitch frequency.
  OscillatorTimer.update(OSC_Timer);
}
Determines what frequency the oscillator plays. No MIDI input: the pitch comes from the touch keyboard, the 1V/OCT input, the Tune and Fine knobs and the selected scale. FindTone() quantizes the key to the scale; each semitone is 12.2 entries of the V_OCT table (about 146 entries per octave / 12). MIDI input active: MIDI takes priority: OSC_Timer = midiNote[RecievedMIDINote]; Timer update: OscillatorTimer.update(OSC_Timer); — a shorter period means faster calls and a higher pitch.
Scale.ino
int FindTone(short Key, short ScaleNumber){
  short Chrom[] = { 1 }; // 1 
  short Minor[] = { 2, 1, 2, 2, 1, 2, 2 }; // 2
  short Major[] = { 2, 2, 1, 2, 2, 2, 1 }; // 3
  /*...*/

  while(Counter != 0){
    switch(ScaleNumber){
      case 0:
        if (Step == 1) Step = 0;
        Scale = Chrom[Step];
        Tone = Tone + Scale;
        break;
      case 1:
        if (Step == 7) Step = 0;
        Scale = Minor[Step];
        Tone = Tone + Scale;
        break;
      /*...*/
      default: break;
    } 
    Step++;
    Counter--;
  } 
  return Tone;
}
Quantizes keyboard keys to the selected musical scale — makes it impossible to play "wrong" notes! Each scale is stored as a list of intervals in semitones (one semitone = one key on a piano, white or black). The chromatic scale is { 1 }: every semitone. FindTone(Key, ScaleNumber): Key — which key is pressed (0–21); ScaleNumber — which scale (0 = Chromatic, 1 = Minor, 2 = Major, ...). The function starts at 0, adds the intervals of the scale one by one for each key step, wraps around at the end of the pattern and returns the total in semitones.
Wavetable.h
// Pulse for LFO
const int16_t PULSE[] = { 0, /*...*/ 4095 };

// Sine for LFO
const int16_t SINE[] = { 2048, /*...*/ 4095, /*...*/ 0, /*...*/ 2047 };

// Triangle for VCO
const int16_t Triangle[] = { 0, /*...*/ 4095, /*...*/  0 };
What Are Wavetables? Pre-calculated waveform samples stored in arrays for fast audio generation: an instant lookup instead of real-time calculation. int16_t — 16-bit signed integer; used here for 0–4095 (the 12-bit DAC range), 2 bytes per sample. const — the values never change and can't be accidentally modified. On Teensy 4.0, const arrays are copied to fast RAM at startup (add PROGMEM to keep a table in flash only).