Table of Contents

Mini Synth

Introduction

Mini Synth is a portable, battery-powered, polyphonic digital synthesizer with a built-in loop pedal, designed and built from scratch by Da'Michele (Mihai-Darius Brișculescu) on top of a custom KiCad PCB.

General Description

The Mini Synth is built around a Teensy 4.0 (NXP iMXRT1062 ARM Cortex-M7 @ 600 MHz, hardware FPU). It runs the whole audio synthesis engine, scans the keyboard, drives the OLED, and handles all the user input. The entire system sits on a single custom PCB (designed in KiCad, manufactured by Aisler) that mounts all the breakout modules via header sockets and houses the 25 keyswitches, the two octave buttons, the joystick, the two potentiometers, and the audio jacks directly on the board.

Block Diagram

                            ┌──────────────────────────────────────────┐
                            │   POWER SUBSYSTEM                        │
                            │                                          │
                            │   18650 Li-Ion ─── TP4056 (USB-C charge) │
                            │       │                                  │
                            │   Rocker SW (master on/off)              │
                            │       │                                  │
                            │   MT3608 boost ──── +5V ─── Teensy LDO   │
                            │                              │           │
                            │                            +3V3          │
                            └──────────────────────────────────────────┘
                                       │            │
                                       ▼            ▼
                              ┌───────────────────────────┐
   USER INPUTS                │                           │      AUDIO OUTPUT
   ──────────                 │                           │      ─────────────
   ┌─────────────────────┐    │                           │ I2S  ┌──────────────────────────┐
   │ 25× B3F-1000 keys   │    │                           │      │ UDA1334A stereo I2S DAC  │
   │   (C3..C5)          ├───►│                           ├─────►│   │                      │
   │ 4× SN74HC165 SR's   │ SR │                           │      │   ├─► Headphone jack 3.5mm│
   │ (bit-banged GPIO)   │ DA │                           │      │   └─► Guitar amp jack 1/4"│
   └─────────────────────┘    │                           │      └──────────────────────────┘
                              │                           │
   ┌─────────────────────┐    │                           │ I2S  ┌──────────────────────────┐
   │ 2× Octave buttons   ├───►│                           ├─────►│ MAX98357A I2S Class-D #1 │
   │ (polled; combo →    │    │     TEENSY 4.0            │      │   └─► Sony speaker LEFT  │
   │  looper transport)  │    │     ARM Cortex-M7         │      └──────────────────────────┘
   └─────────────────────┘    │     @ 600 MHz, FPU        │
                              │     Teensy Audio Library  │ I2S  ┌──────────────────────────┐
   ┌─────────────────────┐ ADC│     (8-voice polyphony)   ├─────►│ MAX98357A I2S Class-D #2 │
   │ SparkFun joystick   ├───►│                           │      │   └─► Sony speaker RIGHT │
   │ (X / Y + push-btn   │    │                           │      └──────────────────────────┘
   │  on pin-change ISR) │    │                           │              ▲
   └─────────────────────┘    │                           │      ┌───────┴──────────────────┐
                              │                           │ GPIO │ SD_MODE control          │
   ┌─────────────────────┐ ADC│                           ├─────►│ (channel select + mute)  │
   │ Volume pot 10kΩ lin ├───►│                           │      └──────────────────────────┘
   │ FX pot 10kΩ lin     ├───►│                           │
   └─────────────────────┘    │                           │ I2C  ┌──────────────────────────┐
                              │                           ├─────►│ SSD1306 1.3" OLED        │
   ┌─────────────────────┐    │                           │      │   (128×64 menu display)  │
   │ HP jack detect      ├───►│                           │      └──────────────────────────┘
   │ Guitar jack detect  ├───►│                           │
   └─────────────────────┘    │                           │ SPI  ┌──────────────────────────┐
                              │                           ├─────►│ HW-125 micro SD reader   │
   ┌─────────────────────┐ ADC│                           │      │   (loop WAV export only) │
   │ Battery sense (÷2)  ├───►│                           │      └──────────────────────────┘
   └─────────────────────┘    └───────────────────────────┘

The system is organized into the following functional blocks:

The MCU communicates with the audio output devices over a shared I2S bus (BCLK + LRCLK + DIN, all three audio chips listen to the same stream), with the MAX98357A chips using their multi-level SD_MODE pin both for channel selection (L vs R) and dynamic hardware mute. All control inputs (keys, buttons, joystick, pots) feed the MCU, which updates the audio DSP graph and the OLED display in real time.

Hardware Design

Current implementation status

The hardware is fully assembled and working. The custom KiCad PCB was manufactured by Aisler and arrived in time for bring-up. All major peripherals were brought up successfully:

There was one hardware fix I had to make during bring-up: the jack-detect lines didn't work on the first powered-up board because the UDA1334A breakout's onboard 47 µF AC-coupling capacitors leave the jack TIP nodes floating at DC, so the tip switch can't actually drag the detect line low. I fixed it by soldering a 10 kΩ resistor from each jack's TIP to its sleeve (GNDA) on the audio side of the AC-coupling cap — that pins TIP to GNDA at DC, and the tip switch can do its job again. This isn't on the schematic; it's a point-to-point fix on the assembled board.

Mini Synth — top-down 3D PCB render

Schematic (full)

Mini Synth — full schematic (PDF)

Bill of Materials

Core Components

Component Quantity Source Role in the project
Teensy 4.0 1 PJRC / Mouser Main MCU. Runs the whole audio engine in DMA-driven I2S plus the UI in the main loop.
UDA1334A I2S DAC breakout (Adafruit 3678 / clone) 1 eMAG Stereo analog audio output — feeds the headphone jack and (summed to mono) the guitar amp jack.
MAX98357A I2S amp breakout (Adafruit 3006 / clone) 2 Temu One per stereo channel, drives the speakers directly. Their multi-level SD_MODE pin handles both channel selection and dynamic mute. Rework needed: desolder the onboard 1 MΩ SD_MODE pull-up before assembly.
Sony SS-TSF550 speakers (4 Ω) 2 Local Stereo speaker output.
SN74HC165N shift register 4 Mouser 32 parallel-in / serial-out inputs, daisy-chained, bit-banged on 3 GPIO. Scans the 25 keyboard switches.
Bourns 4608X-101-103LF (7× 10 kΩ bussed) 4 Mouser Key pull-up resistor networks (28 resistors total, 25 used for keys, 3 unused).
Omron B3F-1000 tactile switch 27 Mouser 25 piano keys + 2 octave buttons (same part for consistent feel).
SSD1306 1.3” OLED (Adafruit 938 STEMMA QT) 1 Mouser / eMAG I2C menu display (factory-jumpered for I2C, no rework needed).
SparkFun analog joystick (BOB-09110) 1 Temu Pitch bend / modulation in play mode; navigation and numeric editing in menu mode; click activates the menu.
Rotary potentiometer (Alps RK09K1130AU2, 10 kΩ linear) 2 Mouser / Local Master volume and FX (LPF cutoff).
HW-125 micro SD module 1 Temu Only touched when saving a recorded loop to a WAV file on the card.
SJ1-3535NG 3.5 mm stereo jack (switched) 1 Mouser Headphone output. Tip switch is used for plug detection.
Switchcraft 112AX 1/4” mono jack (switched) 1 Mouser Guitar amp output. Tip switch is used for plug detection.
TP4056 USB-C charger module 1 Temu Li-Ion charging with DW01A overcharge/over-discharge protection (~2 A discharge trip).
MT3608 boost converter module 1 Temu 3.7V → 5V DC-DC for the Teensy VIN, both MAX98357A amps, and the SD reader.
Samsung INR18650-35E + holder 1 Local ~3500 mAh Li-Ion power source.
SPDT rocker switch 1 Local / Temu Master power on/off, sits in series with the battery → MT3608 input.
470 µF aluminium electrolytic (ECA-1AHG471 or equivalent) 2 Mouser Local +5V rail reservoir, one near each MAX98357A — absorbs audio-rate transient current that the MT3608 loop can't track.

Passive Components (key values)

Resistors:

Capacitors:

1. Microcontroller — Teensy 4.0

The heart of Mini Synth is a Teensy 4.0 built around the NXP iMXRT1062 ARM Cortex-M7 running at 600 MHz with hardware FPU. It provides 1 MB of RAM, 2 MB of flash, and dedicated I2S audio hardware. The Teensy Audio Library gives me a complete real-time DSP framework — oscillators, filters, mixers, effects, wavetable synthesis — all running on DMA, which leaves the main loop free for UI and control logic.

Critical constraints I had to respect:

  • All Teensy 4.0 GPIO pins are 3.3 V logic and are NOT 5 V tolerant. Every peripheral has been chosen accordingly.
  • The VIN-to-VUSB copper trace on the bottom of the Teensy must be cut when the board is powered from an external battery, otherwise the USB rail back-feeds into the battery path. This trace IS cut on the assembled board.
  • Debug uses USB Serial (12 Mbit/s), so IO 0 and IO 1 — which default to Serial1 RX/TX — are repurposed as GPIO outputs driving the MAX98357A SD_MODE lines. Firmware must therefore never call Serial1.begin().

2. Keyboard — 25 Keys via Shift Registers

The keyboard consists of 25 Omron B3F-1000 tactile switches in a piano-like layout spanning C3 to C5 at zero octave shift (MIDI notes 48..72). The switches are active-LOW: each input is held HIGH by a 10 kΩ pull-up to +3V3 and shorts to GND when pressed. I picked 10 kΩ (rather than 100 kΩ) because mechanical contacts benefit from ~330 µA of “wetting current” to break through any contact oxide — useful for long-term reliability on hand-actuated keys.

The pull-ups are 4× Bourns 4608X-101-103LF bussed resistor networks (RN1–RN4). Each package gives 7× 10 kΩ resistors sharing a common bus pin tied to +3V3 — 28 resistors total, 25 used for the keys, 3 unused. Using bussed networks instead of 25 discrete resistors saves a huge amount of board area.

The 25 keys are scanned via 4× SN74HC165N 8-bit parallel-in/serial-out shift registers (IC4–IC7) daisy-chained. The shift registers are bit-banged on three GPIO pins — DATA on IO 5, CLOCK on IO 8, LATCH on IO 9 — rather than using the hardware SPI peripheral. This was intentional: the hardware SPI bus is reserved for the SD card module. Bit-banging the 74HC165 chain at 600 MHz takes negligible CPU time and avoids any contention. (I do insert a 32-NOP burst between SR clock edges because the Teensy 4.0 can toggle GPIO faster than the SN74HC165's 20 ns minimum pulse width.)

Each shift register has a 0.1 µF X7R bypass capacitor at its VCC pin (C5–C8). Worst-case rail load from the keyboard is ~10 mA — negligible against the Teensy's 250 mA 3.3 V budget.

3. User Controls

Two octave buttons (Omron B3F-1000, SW1 and SW3):

They use the Teensy's internal INPUT_PULLUP resistors. Originally I had them on pin-change interrupts, but I had to move them to polled input in Controls::tick() once the combo gesture was added — a single-button press is deferred by 60 ms so the combo detector gets first claim, and that kind of deferral is impossible to do cleanly from an ISR.

SparkFun analog joystick (BOB-09110 breakout, 2-axis + push button) — the primary menu interaction device. Its role changes based on context:

Context X-axis (IO 15 / A1) Y-axis (IO 16 / A2) Push button (IO 4)
Play mode Pitch bend (±2 semitones) Modulation depth (vibrato) Activates the menu
Menu mode, navigating Right = enter submenu / left = go back / left-hold >500 ms = exit menu Up / down between rows Enter edit_mode on a numeric row; toggle on/off on an effect; enter a submenu on a category
Menu mode, edit_mode active Left = decrement / Right = increment, auto-repeat with acceleration Up / Down also exit edit_mode Confirm and exit edit_mode

This split — “the joystick navigates and edits, the volume pot only ever touches master volume” — is on purpose. An earlier version used the VOL pot to edit menu values, which meant tweaking ADSR in the menu also faded out master volume in the background. The current scheme keeps the two cleanly separated.

The joystick is powered from 3.3 V (never 5 V) so the analog wiper output stays inside the Teensy's ADC range. The pushbutton is held HIGH by the internal INPUT_PULLUP, and unlike the octave buttons it's still on a pin-change ISR — single clicks are timing-critical and benefit from the immediate latch.

Two rotary potentiometers (Alps RK09K1130AU2, 10 kΩ linear):

Both pots are powered from +3V3 (high) and GND (low). The RK09K series has two metal anchor tabs tied to the threaded bushing — both are soldered to digital GND pads for mechanical anchoring, finger-capacitance shielding, and ESD protection.

4. Audio Output — Triple I2S Architecture

Mini Synth uses a shared I2S bus with three audio devices receiving the same digital stereo stream from the Teensy's I2S1 port:

Teensy 4.0 I2S1 Outputs:
  IO 7  (OUT1A / DIN)   ──┬──▶ UDA1334A DIN
                           ├──▶ MAX98357A-LEFT DIN
                           └──▶ MAX98357A-RIGHT DIN

  IO 20 (LRCLK1 / WSEL) ──┬──▶ UDA1334A WSEL
                           ├──▶ MAX98357A-LEFT LRCLK
                           └──▶ MAX98357A-RIGHT LRCLK

  IO 21 (BCLK1)         ──┬──▶ UDA1334A BCLK
                           ├──▶ MAX98357A-LEFT BCLK
                           └──▶ MAX98357A-RIGHT BCLK

No MCLK connection is needed: the UDA1334A has an internal PLL that regenerates the system clock from the WSEL signal, and the MAX98357A datasheet explicitly states “No MCLK Required.”

UDA1334A I2S Stereo DAC (Adafruit 3678 breakout / IC3)

Converts the digital I2S stream to analog line-level stereo, feeding the headphone jack and the guitar-amp output path. Powered from +3V3 (the breakout's onboard LDO ends up in dropout, acting as a clean pass-through). The breakout already includes 47 µF AC-coupling capacitors on LOUT/ROUT, so no external coupling caps are added. Configured for I2S format, audio mode (PLL0 = LOW), 44.1 kHz.

Critical ground separation for the UDA1334A. The breakout's AGND pin (pin 8) connects to the analog ground GNDA, while the GND pin (pin 3) connects to digital ground GND. This routes the chip's analog reference current away from any digital switching noise. See §8 for the full three-domain ground architecture.

2× MAX98357A I2S Class-D Amplifiers (Adafruit 3006 breakout / IC1, IC2)

Each breakout includes a complete I2S decoder and 3.2 W Class-D amplifier in a single chip. Powered from +5V and accept 3.3 V logic on all digital inputs. Each amplifier directly drives one speaker — filterless Class-D, no external output capacitors needed.

Gain: 9 dB (default — GAIN pin left floating per Adafruit's factory configuration). Combined with firmware-side limiting of the I2S signal to 70 % of full scale, this caps each amplifier's worst-case output power at ~1.5 W instead of 3.2 W — keeps battery current safely under the TP4056 module's ~2 A discharge protection threshold.

Local bulk capacitance. One 470 µF aluminium electrolytic (C9, C11) is placed locally on the +5V rail at each MAX98357A breakout. These absorb the audio-rate transient current swings (50 Hz – 1 kHz) that the MT3608's regulation loop can't track quickly enough during loud sustained chords.

Channel selection via SD_MODE (datasheet Table 5)

The MAX98357A's SD_MODE pin is not a simple digital shutdown input — it's a multi-level analog sense input that does both shutdown control and channel selection at the same time:

SD_MODE voltage Mode Selected output
Below B0 (typ 0.16 V) Shutdown Output stage Hi-Z, ≈0.6 µA quiescent
Between B0 and B1 (typ 0.16–0.77 V) Active (L + R) / 2 mono mix
Between B1 and B2 (typ 0.77–1.4 V) Active Right channel
Above B2 (typ 1.4 V) Active Left channel

For VDDIO = 3.3 V (Teensy GPIO), the datasheet's formula R_SMALL = 94.0 × VDDIO − 100 gives 210 kΩ — the resistor that puts SD_MODE between B1 and B2 (right channel) when the GPIO is HIGH.

Required breakout rework: The Adafruit 3006 breakout (and clones) includes a 1 MΩ SMD pull-up between SD_MODE and the chip's V_DD (5 V). This must be desoldered from each breakout before installation — otherwise the GPIO LOW state would fight the pull-up and shutdown never engages. Once removed, only the chip's internal 100 kΩ pull-down remains.

Do NOT bridge any breakout channel-selection solder jumpers (L, R, Mono). If bridged, they hard-tie SD_MODE to the chip's 5 V VDD, and driving the GPIO LOW would short 5 V to GND through the 3.3 V GPIO pin and damage the Teensy.

Wiring
LEFT amp:   Teensy IO 0  ────────────────────────▶  MAX98357A-LEFT  SD_MODE
RIGHT amp:  Teensy IO 1  ──[ 210 kΩ (R1) ]───────▶  MAX98357A-RIGHT SD_MODE
IO 0 (LEFT) IO 1 (RIGHT) V_SD_MODE LEFT V_SD_MODE RIGHT Result
HIGH (3.3 V) HIGH (3.3 V) 3.3 V (above B2) 3.3 × 100/(210+100) = 1.06 V (between B1 and B2) Stereo: L on left amp, R on right amp
LOW (0 V) LOW (0 V) 0 V (below B0) 0 V (below B0) Both amps in shutdown — speakers fully muted

This single mechanism handles both channel selection (set at design time by the resistor) and dynamic mute (controlled by the GPIOs from firmware).

Power-on sequencing. During the brief MCU-boot window before setup() runs, both Teensy GPIOs are high-Z. With the breakout's 1 MΩ pull-up removed, only the chip's internal 100 kΩ pull-down remains active — both SD_MODE pins sit at 0 V, so both amps come up in shutdown automatically. The firmware then writes LOW explicitly, configures the I2S chain, and only drives the lines HIGH once AudioEngine::isReady() has confirmed the I2S DMA pipeline has flushed with at least 5 valid silent audio blocks (~15 ms). No audible pop or click at boot.

Hardware mute on jack insertion. When a headphone or guitar-amp plug is detected, the firmware drives both IO 0 and IO 1 LOW — putting both amplifiers into hardware shutdown. This is cleaner than software muting because the MAX98357A handles its own output-stage transition gracefully (no pop), the CPU doesn't have to keep zeroing buffers, and quiescent current per chip drops to ~0.6 µA — extending battery life when a plug is left inserted.

5. Audio Output Paths

Headphone Output (SJ1-3535NG 3.5 mm switched stereo jack, U5)

The UDA1334A's analog L/R outputs (already AC-coupled by the breakout's 47 µF caps) pass through 33 Ω series resistors (R7, R8) to the jack's tip and ring; sleeve connects to GNDA. The 33 Ω resistors give:

  1. Output isolation from headphone-cable capacitance (~100–300 pF/m), preventing potential DAC-output ringing.
  2. Short-circuit protection — if a TS plug is jammed into the TRS jack and shorts ring to sleeve, the resistor limits current within the UDA1334A's 300 mA short-circuit rating.

The tip switch is normally closed to TIP (no plug) and opens when a plug is fully inserted. Detection is on IO 6 (JACK_HP_DET) with a 100 kΩ pull-up (R10) and a first-order RC LPF (R14 = 10 kΩ, C13 = 1 µF — ~16 Hz cutoff) that rejects audio coupling and debounces plug insertion.

Hardware bring-up fix (added by hand after first power-on): I soldered a 10 kΩ resistor from the TIP audio node to the jack's sleeve (GNDA) on the audio side of the UDA1334A's 47 µF AC-coupling cap. Without it, the TIP node has no DC reference and the no-plug tip switch can't drag JACK_HP_DET below 1 V — both detect lines sit HIGH whether a plug is inserted or not. With the pulldown fitted, TIP sits at GNDA at DC and the tip switch can properly do its job. The same fix is on the guitar jack too. With both pulldowns in place I set HW_DISABLE_JACK_DETECT back to 0 in Config.h and the jack-detect mute works as designed.

Plug state Tip switch IO 6 reads
No plug Closed to TIP (held at GNDA by the 10 kΩ pulldown) LOW
Plug inserted Open HIGH (pulled up by R10)

Guitar Amp Output (Switchcraft 112AX 1/4" switched mono jack, U4)

The stereo L and R outputs are summed to mono via 2× 47 kΩ summing resistors (R5, R6) directly into the 1/4” jack tip. The 47 kΩ resistors current-limit the DAC outputs and isolate them from the typical 1 MΩ guitar-amp input. Sleeve connects to GNDA.

The 112AX's tip switch works exactly the same way as the SJ1-3535NG's: normally closed to TIP with no plug, open when a plug is inserted. Detection on IO 23 (JACK_GUITAR_DET) mirrors the headphone circuit: 100 kΩ pull-up (R9), LPF formed by R15 (4.7 kΩ) and C12 (1 µF). Same 10 kΩ TIP-to-sleeve pulldown rework as on the headphone jack — same reason.

Because both jacks have the same polarity, the firmware mute logic is symmetric:

mute = (digitalRead(JACK_HP_DET) == HIGH) || (digitalRead(JACK_GUITAR_DET) == HIGH);

6. Display — SSD1306 1.3" OLED (Adafruit 938 STEMMA QT, DS1)

An Adafruit 938 SSD1306 128×64 monochrome OLED provides visual feedback for the current instrument, octave, effects, battery level, the menu system, the tempo dot, and the looper count-in overlay. The STEMMA QT revision ships factory-configured for I2C (jumpers J1/J2 closed), has onboard auto-reset and onboard 10 kΩ I2C pull-ups, so no external rework or pull-ups are needed.

Connection: I2C on IO 18 (SDA0) and IO 19 (SCL0), default address 0x3D (factory default for the 128×64 variant). Powered from +3V3 with one 0.1 µF X7R bypass cap (C4). CS, DC/A0, Rst, and 3Vo pins are left NC. The OLED is mounted physically upside-down in the enclosure, so Display::begin() calls oled.setRotation(2) to flip the framebuffer in software.

7. Storage — HW-125 Micro SD Card Module (U6)

The HW-125 is used only for exporting recorded loops to WAV files. It connects via the Teensy's hardware SPI bus: CS on IO 10, MOSI on IO 11, MISO on IO 12, SCK on IO 13. Powered from +5V — the onboard regulator and level-shifting buffers output 3.3 V on MISO (safe for the Teensy).

In-session loop content lives in a 440 KB RAM buffer (DMAMEM in OCRAM), not on the card. The SD card is only touched at boot (a one-shot write probe to confirm the Save action will work) and when the user picks Save from the end-of-loop prompt (the loop buffer is dumped to root-level /LOOP_NNN.WAV). This is on purpose — the audio interrupt never has to talk to the SD card, so there's no read/write contention that could glitch the audio.

The SD is initialised at a conservative 4 MHz SPI clock through SdFat:

SD.sdfs.begin(SdSpiConfig(PIN_SD_CS, SHARED_SPI, SD_SCK_MHZ(4)));

The default Arduino SD.begin(pin) uses 16–24 MHz which was unreliable on this HW-125 + 8 GB SDHC combo — read-after-write was coming back corrupted. Slow and reliable beat fast and broken; a typical ~440 KB loop still copies in about a second.

Factory instruments (math waveforms + wavetables) don't require an SD card — they're baked into the firmware.

8. Power System

Worst-case rail current

Each MAX98357A at 9 dB and 70 % full-scale draws ≤ 0.75 A peak from +5V (~1.5 W into 4 Ω at 85 % efficiency). Both amps plus the rest of the +5V load (Teensy ~100 mA, SD ~100 mA peak) gives ~1.7 A peak on +5V. At V_bat = 3.2 V the MT3608 pulls ~3.2 A from the battery — close to the TP4056's 2 A trip threshold but kept manageable by the two local 470 µF reservoirs absorbing transient peaks and the firmware's 70 % full-scale output cap reducing average current.

Three-domain ground architecture

To prevent high-current speaker return currents from contaminating the audio analog reference and the MCU's digital reference, the schematic uses three distinct ground nets that meet at a single physical star point on the PCB:

Ground net Members
GNDA (analog) UDA1334A AGND; headphone jack sleeve; guitar-amp jack sleeve; the two 10 kΩ TIP pulldowns I added during bring-up
GND (digital) Teensy GND; UDA1334A digital GND; 4× SN74HC165 GND; OLED GND; HW-125 SD GND; joystick GND; potentiometer anchor tabs and low-side; all pull-up bottoms; jack-detect LPF cap returns; battery-sense divider bottom
GNDPWR (power return) TP4056 OUT−; MT3608 GND; both MAX98357A GND pins; 470 µF bulk cap negatives (×2); speaker return wires; rocker switch return

The three nets are joined at a single point via two 0 Ω resistors (R2: GNDGNDA, R3: GNDGNDPWR). The PCB uses a single solid back-layer ground pour — the “star” topology is enforced by component placement (analog jacks far from amps, amps far from MCU) and by the net-tie's location, not by carving the copper plane.

Battery monitoring (''BAT_SENSE'' on IO 14 / A0)

A 100 kΩ / 100 kΩ divider (R11, R12) scales V_bat into the ADC's 0–3.3 V window. V_bat = 4.2 V (full) → V_adc = 2.10 V; V_bat = 3.0 V (low cutoff) → 1.50 V. Continuous drain through the divider is ~21 µA — negligible against a 3500 mAh cell.

Teensy 4.0 Pin Assignment — why each pin is used where it is

This is a denser version of the table you'll see anywhere else, with an extra column on why each pin got picked.

Teensy IO Net Peripheral Why this pin
0 AMP_LEFT_SD GPIO output Repurposed from default Serial1 RX. Using USB Serial for debug frees both IO 0 and IO 1 with zero GPIO cost — I needed two GPIOs to drive the two MAX98357A SD_MODE lines and these were the only obvious free pair without breaking another peripheral. HIGH = LEFT channel active, LOW = mute.
1 AMP_RIGHT_SD GPIO output Same story as IO 0. Driven through a 210 kΩ series resistor (R1) so the chip sees a divided voltage between B1 and B2 → right channel.
2 BTN_OCT_UP GPIO (polled) Generic GPIO; nothing special. Originally on pin-change ISR, moved to polled in Controls::tick() once I added the looper combo gesture (needed deterministic 60 ms deferral).
3 BTN_OCT_DOWN GPIO (polled) Same — paired with IO 2 to share the combo state machine.
4 JOYSTICK_SW GPIO + pin-change ISR Single clicks are timing-critical (enter menu, confirm value), so this stays on a pin-change interrupt for the lowest possible latency. INPUT_PULLUP, no external pull-up.
5 SR_DATA GPIO (bit-bang) Generic GPIO for the bit-banged SN74HC165 chain (QH of last chip). Hardware SPI is reserved for the SD card.
6 JACK_HP_DET GPIO input Plain digital input. 100 kΩ pull-up + RC LPF (10 kΩ / 1 µF) — pin chosen mostly for board-layout convenience near the headphone jack.
7 I2S1_DIN I2S (OUT1A) Hard-wired by the Teensy Audio Library's AudioOutputI2S — not a choice. Carries the I2S data stream to the UDA1334A + both MAX98357A.
8 SR_CLK GPIO (bit-bang) Generic GPIO for SN74HC165 clock.
9 SR_LATCH GPIO (bit-bang) Generic GPIO for SN74HC165 SH/LD.
10 SD_CS SPI CS Standard hardware SPI CS pin on the Teensy. The whole SD module uses hardware SPI; that's why the keyboard had to bit-bang.
11 SD_MOSI SPI MOSI Hardware SPI — not a choice.
12 SD_MISO SPI MISO Hardware SPI — not a choice.
13 SD_SCK SPI SCK Hardware SPI — not a choice. Also the Teensy onboard LED, which flashes during SD activity — useful as a free visual debug indicator.
14 / A0 BAT_SENSE ADC First analog pin; 100 kΩ / 100 kΩ divider gives 0.5× ratio (4.2 V → 2.10 V on the ADC).
15 / A1 JOYSTICK_X ADC Joystick X axis — pitch bend in play, navigation/edit in menu. Needs ADC, otherwise pin choice is arbitrary.
16 / A2 JOYSTICK_Y ADC Joystick Y axis — modulation in play, navigation in menu.
17 / A3 VOL_POT ADC Volume pot — needs ADC.
18 / A4 SDA0 I2C Standard hardware I2C bus — not a choice. Talks to the SSD1306 OLED.
19 / A5 SCL0 I2C Standard hardware I2C clock — not a choice.
20 LRCLK1 I2S Hard-wired by AudioOutputI2S — not a choice.
21 BCLK1 I2S Hard-wired by AudioOutputI2S — not a choice.
22 / A8 FX_POT ADC FX pot — needs ADC. Wired up so the Filter effect can sweep the LPF cutoff live.
23 / A9 JACK_GUITAR_DET GPIO input Plain digital input. 100 kΩ pull-up + RC LPF (4.7 kΩ / 1 µF).
24–33 NC Back-side pads, unused.
VIN +5V input Power From MT3608 (VIN-VUSB trace cut on the Teensy).
GND Ground Power Connected to GND (digital) domain.
3.3V 3.3V output Power From Teensy LDO → shift registers, joystick, both pots, OLED, UDA1334A breakout, all pull-ups, battery-sense divider.

Photos / proof-of-life

Mini Synth Demo

Hardware milestones

Software Design

Current implementation status

The firmware is substantially complete — everything described in this section is implemented and working on the assembled board. There are two sketches in the repo:

Development Environment and Libraries

Why these libraries?

Element of novelty

The most novel part of the project, compared to other PM/embedded synth projects I've looked at, is the integrated SD-exporting loop pedal running on the same MCU that does the polyphonic synthesis, on the same audio bus, without any extra DSP chip. The way it works:

Lab-topic functionalities and where they show up

This project intentionally exercises every major topic from the PM lab. Here's where each one lives in the firmware:

Core Audio Engine

The firmware runs the entire audio synthesis on the Teensy Audio Library DSP graph in DMA, leaving the main loop free for UI and control:

Factory Instruments — Wavetable Synthesis

Factory sampled instruments are compiled into the firmware as C header files generated by the Teensy Wavetable Editor from SoundFont (.sf2) files. Each file is a PROGMEM int16_t sample array plus an AudioSynthWavetable::instrument_data metadata struct that maps each sample to a root note, sample rate, loop region, and ADSR defaults. The set currently compiled in:

Category Instruments
Piano Grand Piano, CP-80 EP, Rhodes EP
Strings Violin, Synth Strings
Brass Trumpet, Synth Brass
Mallets Celeste, Vibraphone, Kalimba, Tinkling Bells, Synth Mallet
Synth Bright Saw, Mystery Pad, Solo Vox, Space Voice
Drums TR-808 (kit: Kick, Snare, Clap, Closed Hat, Open Hat, Cymbal — each its own SoundFont sample mapped to a specific MIDI range)

Looper — RAM-Buffered Loop Pedal

The looper is one of the biggest features and the most fun to play with. State machine:

IDLE ─trigger─▶ ARMED ──count-in──▶ RECORDING ─trigger─▶ PLAYING
                                                            │
                                       ┌──trigger──┐        │
                                       │           ▼        │
                              ARMED_OVERDUB ── wrap ──▶  OVERDUB ┘

   long-hold trigger from PLAYING / OVERDUB ─▶  END_PROMPT (Save / Clear / Cancel)

Metronome + Tempo

A separate Tempo page in the root menu exposes:

The metronome path is a tiny audio sub-graph: AudioSynthWaveform (2 kHz on off-beats, 3 kHz on the downbeat of each 4/4 bar) → AudioEffectEnvelope (2 ms attack, 40 ms decay, 0 sustain, 5 ms release) → master.port1. Because master.port1 is downstream of the looper record tap on masterMix, the metronome is audible but NEVER recorded.

Even when the user has the metronome turned off for normal play, the looper force-engages it during ARMED, RECORDING, and OVERDUB so the player always has an audible timing reference during recording.

Persistence — Two-blob EEPROM Layout

Settings are stored in the Teensy's emulated EEPROM. There are two independently-versioned blobs:

The split is on purpose. Bumping a blob's VERSION wipes its contents on the next boot (the firmware detects the mismatch and re-initialises to defaults). By putting tempo / metronome in their own blob, I can add looper-related fields without forcing a VERSION bump on the main blob — which would wipe every user's saved envelopes the first time they install the new firmware. Same idea in reverse: a factory-reset from the Settings menu only wipes the main blob, so a user's BPM / metronome choice is preserved when they hit “Reset defaults”.

Each blob has its own dirty flag + 2-second debounce. Persistence::saveIfDirty() is called every loop iteration and only commits when a blob has been quiet for 2 s, so a sequence of rapid edits (e.g. dragging the joystick to set decay) doesn't write to flash for every step.

Activation and Exit

Action Trigger
Enter menu Single click of the joystick push-button (in play mode)
Exit menu (a) joystick-left until cursor is at the root, then one more left press; OR (b) hold joystick-left for >500 ms from anywhere in the hierarchy (suppressed while a value is in edit_mode)

While in menu mode the keyboard is silenced — but the Oct+ & Oct− combo gesture is still polled, so the looper transport still works from any menu page.

This is the most important UI convention of the firmware:

Item type Click Right Left Left/Right while editing
Category Enter submenu Enter submenu Go to parent
Instrument terminal Select Open Envelope page Go to parent
Effect terminal Toggle ON / OFF (no effect) Go to parent
Numeric / enum row Enter edit_mode (value gets < > brackets) Enter edit_mode Go to parent (only when not editing) Decrement / increment, auto-repeat after 400 ms with acceleration at 800 ms
Read-only row (Battery, wavetable Envelope) (no effect) (no effect) Go to parent

Key principle: the joystick navigates and edits, the VOL pot only ever touches master volume. They are not mixed up. An earlier version used the pot to edit menu values and that caused exactly the surprise it sounds like (tweaking ADSR also faded out the synth in the background).

ROOT
├── Instruments
│   ├── Waveforms (math-generated, in firmware)
│   │   ├── Sine / Sawtooth / Square / Triangle / Pulse / Noise
│   │   ├── (click selects, right opens an editable Envelope page)
│   ├── Piano       (Grand Piano, CP-80 EP, Rhodes EP)
│   ├── Strings     (Violin, Synth Strings)
│   ├── Brass       (Trumpet, Synth Brass)
│   ├── Mallets     (Celeste, Vibraphone, Kalimba, Tinkling Bells, Synth Mallet)
│   ├── Synth       (Bright Saw, Mystery Pad, Solo Vox, Space Voice)
│   └── Drums       (TR-808)
│   │   (click selects, right opens a READ-ONLY Envelope page)
│
├── Envelope         (per-selected-instrument; editable for waveforms,
│                     read-only for wavetable instruments)
│   ├── Attack       (click → edit_mode → joystick L/R 0–5000 ms)
│   ├── Decay
│   ├── Sustain     (0–100 %)
│   └── Release
│
├── Effects (each row toggles on click)
│   ├── Reverb
│   ├── Delay
│   ├── Chorus
│   ├── Flanger
│   ├── Bitcrush
│   ├── Distortion
│   └── Filter   (FX-pot LPF; enabling routes the pot to LPF cutoff)
│
├── Tempo
│   ├── BPM         (joystick L/R 40–240; locked while looper active)
│   ├── Metronome   (click toggles ON / OFF)
│   └── Volume      (joystick L/R 0–100 %; independent of master volume)
│
└── Settings
    ├── Voice Count   (joystick L/R 1–8)
    ├── Tuning
    │   ├── Concert Pitch (joystick L/R 432–448 Hz)
    │   ├── Scale         (joystick L/R: Chromatic, Major, Minor, Pentatonic, Blues, Dorian)
    │   └── Root          (joystick L/R: C, C#, D, D#, E, F, F#, G, G#, A, A#, B)
    ├── Battery     (read-only: voltage + estimated %)
    └── Reset       (factory reset — opens a Cancel / Reset confirm prompt)

Master Volume is intentionally not in the menu — the VOL pot is the only way to change it, and it's not persisted. Putting it in the menu as well caused exactly the kind of “menu fights the knob” UX bug it was supposed to avoid.

Boot Sequence

On power-up, the OLED shows a splash screen (“Mini Synth / Loading…”) with a progress bar while firmware:

  1. Brings up the Display and shows a brand-only splash for 1.5 s so the user actually sees the device name.
  2. Initialises the I2S audio chain (with both amps held in SD_MODE shutdown until ready). Waits 20 ms while the I2S DMA flushes with silence behind the splash.
  3. Initialises Controls (button polling + joystick centre calibration + ADC averaging + initial pot smoothing).
  4. Initialises the Keyboard scanner.
  5. Loads persisted settings + restores the last-selected instrument from the main EEPROM blob.
  6. Initialises Tempo (loads BPM, metronome enable, metronome volume from the looper-config EEPROM blob).
  7. Initialises the Looper (mounts the SD card via SdFat at 4 MHz, runs a write-probe to confirm Save will work).
  8. Hands off to Menu, which raises the SD_MODE lines HIGH once AudioEngine::isReady() returns true and switches the OLED to play-mode HUD.

Boot time is typically under 2 s. The splash hides the I2S settle window and the SD probe; nothing audible can happen until the speakers are intentionally un-muted.

Runtime model — how all the subsystems fit together

loop() is non-blocking with fixed-cadence ticks:

What Cadence What it does
Controls::tick() every 5 ms ADC smoothing + button polling + combo state machine + joystick edges + hold detection
Keyboard::scan() every 2 ms Bit-bang the SN74HC165 chain + 2-pass debounce + stuck-key watchdog
Menu::tick() every loop UI mode dispatch + Display refresh + amp mute decision + looper trigger dispatch
AudioEngine::tick() every 5 ms Vibrato modulation + filter cutoff push + boot-ready gate
Tempo::tick() every loop millis() beat scheduler; fires the metronome envelope on each beat
Looper::tick() every loop Drains the record queue into the RAM loop buffer; advances state on count-in beats + loop wraps
Persistence::saveIfDirty() every loop No-op until 2 s after the last mutation (covers BOTH EEPROM blobs)

Actual audio playback is DMA-driven by the Teensy Audio Library and runs independently of loop(). Anything in loop() that blocks for more than a few hundred microseconds will starve the menu/keyboard responsiveness — I'm very careful about this. The only intentionally-blocking operation is the SD write in Looper::endChoice(END_SAVE) (the user explicitly asked to save and wants it to finish); even there, the splash is repainted every 64 KB so the UI doesn't look frozen.

Calibration

There are several places where the firmware self-calibrates or applies calibration constants:

Optimizations (and why I needed them)

Validation — how I know it works

Demo video

A demo video covering: boot splash, playing a few notes on each instrument category, sweeping the FX pot, recording + overdubbing a loop, saving it to SD, plugging in headphones and confirming the speakers mute — will be uploaded here before the final deadline.

Results

The expected (and at this point largely-achieved) results are:

Conclusions

The Mini Synth project brings together pretty much every major topic of the Microprocessor Architecture lab (GPIO, UART, interrupts, timers/PWM, ADC, SPI, I2C) into a single integrated embedded system, plus a bunch of real-world engineering problems I hadn't seen in the lab handouts:

If I were to do it again, I'd probably move the metronome's audio path into a dedicated I2S channel rather than master.port1 so the click is even more obviously separated from the looper record bus. And I'd add a real Concert-Pitch fine-tuner (cents) on top of the existing 1 Hz step. But for a one-semester project I'm pretty happy with where it landed.

Download

Project archive(s) will be uploaded here at the end of the semester, containing:

  • Firmware source code (Arduino / Teensyduino project — both MiniSynth/ and MiniSynthTest/ sketches).
  • KiCad schematic and PCB source files.
  • Gerber files for PCB fabrication.
  • Bill of Materials (BOM) in CSV format.
  • 3D STL files for the enclosure (when ready).
  • Example exported LOOP_NNN.WAV files for the SD card.
  • A README and ChangeLog.

Journal

A running log so the project assistant can track progress.

Bibliography / Resources

Hardware Resources

Software Resources

Export to PDF