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.
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.
┌──────────────────────────────────────────┐
│ 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.
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.
| 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. |
Resistors:
SD_MODE series resistor for right-channel selection.BAT_SENSE.JACK_GUITAR_DET, JACK_HP_DET).GND–GNDA and GND–GNDPWR).Capacitors:
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.
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().
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.
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):
VOL_POT): Master volume in play mode. Never read in menu mode. Master volume is not even persisted across reboots — every boot it inherits whatever position the knob is in. The knob is the truth.FX_POT): Always-live LPF cutoff (logarithmic 20 Hz – 20 kHz) when the Filter effect is enabled in the Effects menu. While Filter is disabled, the cutoff is pinned at 20 kHz and pot motion is ignored. When the user is actively turning the FX pot, the play-HUD's bottom bar swaps from showing VOL to showing the LPF cutoff position for ~2 s so the sweep is visible.
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.
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.”
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.
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.
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.
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.
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.
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.
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:
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.
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) |
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);
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.
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.
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.
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: GND–GNDA, R3: GND–GNDPWR). 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.
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.
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. |
SD_MODE channel-selection scheme designed (210 kΩ series resistor for right channel, direct GPIO drive for left from the Teensy).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:
firmware/MiniSynth/MiniSynth.ino — the production firmware. Modular, namespaced subsystems (AudioEngine, Keyboard, Controls, Display, Menu, Tempo, Looper, Persistence). This is the one in active development.firmware/MiniSynthTest/MiniSynthTest.ino — a single-file diagnostic sketch used during first power-on to verify each peripheral works in isolation. Both speakers stay hard-muted until the user explicitly arms a test tone by holding Oct+ and Oct− for 2 seconds; the tone amplitude is hard-capped at ~−20 dBFS even after arming, so an assembly fault can't accidentally blow a speaker.AudioEngine.cpp; PJRC's Audio System Design Tool was useful for prototyping individual blocks but cross-translation-unit hooks (the Tempo metronome + Looper record/play queues) had to be hand-coded..sf2) files into C header files with PROGMEM int16_t sample arrays + metadata structs (root note, sample rate, loop region, ADSR defaults). The factory instruments are all compiled in this way.0x3D, falls back to 0x3C if probing 0x3D fails — some clone boards are populated wrong).SdFat (the modern PJRC-recommended SD library), accessed both via the high-level SD.open wrapper and the lower-level SD.sdfs.open(…) / FsFile API for the loop-save path (so I can preAllocate() the whole WAV file before writing — see “Optimizations” below).Why these libraries?
SD.begin() at 16–24 MHz was corrupting writes.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:
masterMix bus. The looper taps the bus's output with an AudioRecordQueue, so what gets captured is exactly what the user is hearing through the speakers (synth + already-laid-down loop), but NOT the metronome (which is summed in further downstream on master.port1) and NOT the master-volume gain. This separation is what makes the metronome audible without being recorded and the master volume non-destructive.DMAMEM int16_t[] array in OCRAM — about 5.1 s of mono PCM. Overdubs are destructive in place: each new overdub writes the (synth + previous loop) mix back into the same buffer, so I never need more than one buffer regardless of how many overdub layers the user piles on.LooperPlayer class (a subclass of AudioStream) seamlessly loops the buffer — when the playhead hits the end it wraps to 0 in the same update() call, with no audible click. Recording length is bar-quantized to the metronome grid (rounded up to the next 4-beat boundary), so the loop always wraps cleanly on a downbeat and overdubs phase-align automatically.This project intentionally exercises every major topic from the PM lab. Here's where each one lives in the firmware:
Keyboard.cpp), the 2 octave buttons (polled in Controls.cpp so the combo detector can defer single shifts), the joystick push-button, the two jack-detect inputs, and the two MAX98357A SD_MODE outputs.millis()-driven beat scheduler in Tempo.cpp.JOYSTICK_X, JOYSTICK_Y), volume pot (VOL_POT), FX pot (FX_POT), battery voltage (BAT_SENSE). 12-bit resolution, 32× hardware averaging.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:
AudioSynthWaveform / AudioSynthNoiseWhite — math-generated waveforms (sine, sawtooth, square, triangle, pulse, noise). Zero storage cost; generated on demand. ADSR envelopes are user-editable and persist in EEPROM.AudioSynthWavetable — factory wavetable instruments stored in firmware flash (PROGMEM headers). ADSR envelopes are read-only — they come baked into the SoundFont's per-sample fields and AudioSynthWavetable doesn't let me override them from outside setInstrument(). The Envelope page still shows them so the user can see what's going to happen.AudioEffectFlange tuned with chorus-style params (long base offset, slow LFO) — the Teensy AudioEffectChorus turned out to be a static comb filter without LFO motion, which sounded dry. The LPF uses AudioFilterBiquad rather than the Chamberlin SVF, because the SVF was numerically unstable when its int32 state saturated on a fast cutoff sweep.SD_MODE — firmware drives IO 0 and IO 1 LOW on plug detect, plus during boot before the I2S DMA pipeline has flushed with valid silent blocks.
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) |
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)
masterMix. No additional buffer needed regardless of layer count./LOOP_NNN.WAV (linear scan from 001), pre-allocates the file with SdFat, writes the 44-byte WAV header in one call, then streams the loop buffer in 64 KB chunks. Each chunk repaints the splash with percent done so the user sees the save progressing instead of a frozen screen. ~440 KB loop saves in about a second at 4 MHz SPI.
A separate Tempo page in the root menu exposes:
* in the UI.master.port1. Decoupled from master volume so you can keep a steady click audible while the synth itself fades in and out.
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.
Settings are stored in the Teensy's emulated EEPROM. There are two independently-versioned blobs:
MNSY, VERSION 4). Holds voice count, concert pitch, scale, root note, effect-enabled bits, last selected instrument, and 24 slots of factory-instrument ADSR envelopes.LPCF, VERSION 2). Holds the tempo BPM, the metronome on/off flag, and the metronome volume.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.
| 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.
On power-up, the OLED shows a splash screen (“Mini Synth / Loading…”) with a progress bar while firmware:
SD_MODE shutdown until ready). Waits 20 ms while the I2S DMA flushes with silence behind the splash.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.
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.
There are several places where the firmware self-calibrates or applies calibration constants:
Controls::begin() averages 16 samples (~30 ms) of the X and Y axes with the joystick at rest, and stores those as the calibrated centre. This compensates for the BOB-09110's mechanical centre not being exactly at ADC 2048. After calibration, joystick math is asymmetric — full deflection in either direction reaches exactly ±1.0 even if the calibrated centre isn't at the midpoint.Controls::tick(), the centred axis value passes through applyDeadzone() which maps |raw| from [JOY_DEADZONE=0.10, JOY_SATURATION=0.95] linearly to [0, 1]. Below the deadzone the axis reads 0 (ignored noise around centre); above the saturation it reads exactly ±1.0 (so the user gets full deflection even if the wiper only reaches ~95 % of Vcc).0.70 × previous + 0.30 × current) and then the displayed 0–100 % integer is updated through a Schmitt-trigger style hysteresis band: the integer bin only changes when the smoothed raw value moves more than 0.30 % past the current bin's natural boundary. Without this, ADC LSB noise made the displayed percent dance between adjacent values. Same approach (with H = 0.40) is applied to the displayed battery voltage and percent, which used to dance between 4.16 V ↔ 4.17 V and 95 % ↔ 96 %.volPot() / fxPot() snap to exactly 0 below 0.005 and exactly 1 above 0.995. This gives the user a hard reachable min/max within the last ~1° of pot travel — without it, ADC dither at the extremes left envelope parameters with non-deterministic minima (decay would settle to 6 ms one time and 20 ms the next on “fully turned left”).BAT_SENSE divider has a ~50 kΩ source impedance, near the upper end of what the iMXRT's S/H cap can settle in one conversion after switching from a much lower-impedance channel (the FX pot). I take a throwaway analogRead(PIN_BAT_SENSE) first to let the S/H cap discharge, then use the second read. The displayed voltage is then IIR-smoothed (3-sample tau) before going through the hysteresis band — initialised from the very first good read so the displayed voltage is correct from boot rather than slowly converging.DIVIDER_RATIO = 0.5f and ADC_REF = 3.30f are constants in Config.h. They could be replaced by a single-point calibration constant set per assembled unit (with a multimeter) to compensate for ±1 % resistor tolerance + LDO reference variation — currently the default values give voltage readings within ±50 mV of a multimeter on my unit, which is fine for the “remaining battery %” use case.Keyboard::srTimingPad() inserts ~50 ns of NOPs between edges — slow enough for the part, still fast enough that scanning all 25 keys takes a tiny fraction of a millisecond.AudioEngine::isReady() boot-settle gate. Amps are physically muted (SD_MODE = LOW) until the I2S DMA pipeline has flushed with at least 5 valid silent audio blocks (~15 ms). Without this gate, the boot-time garbage in the uninitialised DMA buffers would, at 9 dB amp gain into 4 Ω, draw peak current near the TP4056's 2 A trip threshold AND make a loud pop.Persistence::saveIfDirty() delays commits by 2 s after the last mutation. Without this, dragging the joystick to adjust an envelope parameter would burn EEPROM endurance on every step of the joystick auto-repeat.Looper::saveCurrentLoopAsWav() streams in 64 KB chunks and repaints Display::splash(pct, “Saving WAV”) between chunks — without it, the multi-second save looks like a freeze and the user starts pressing buttons.preAllocate() the entire WAV file before writing it. This was the fix to a really annoying bug: building the 44-byte WAV header with ten small dst.write() calls plus then writing bulk audio caused SdFat's internal sector cache to wedge somewhere between sectors. Switching to (a) pre-allocate the full file size, (b) write the header in one 44-byte write(), © stream audio in 64 KB chunks made the bug disappear entirely. Pre-allocation extends the cluster chain BEFORE the data writes, so each subsequent write is just a sector update with no mid-stream FAT entry updates.SD.begin(pin) uses 16–24 MHz which proved unreliable on the specific HW-125 + 8 GB SDHC combo I have. I switched to SD.sdfs.begin(SdSpiConfig(PIN_SD_CS, SHARED_SPI, SD_SCK_MHZ(4))). Slow + reliable beats fast + corrupted.loop(). Moving the in-session buffer to RAM and only touching SD on Save fixed this completely — at the cost of a fixed 5.1 s max loop length, which is fine for the use case.AudioConnection.connect() deferred to begin(). A really subtle static-init-order trap: constructor-time AudioConnections that cross translation units (e.g. the Tempo's metronome → master mixer link, where master lives in AudioEngine.cpp) get silently dropped by the destination AudioStream's constructor running later and resetting its destination_list to NULL. Fix: default-construct the cross-TU connections and call connect(…) from inside the namespace's begin() function, after all globals have constructed. Used in both Tempo.cpp and Looper.cpp.Looper::enterArmed() and enterArmedOverdub() call Tempo::setForceMetronome(true) so the count-in beeps are audible and the overdub gets a tempo reference. Reverted to the user's setting on entering PLAYING.MiniSynthTest.ino (the diagnostic sketch). All 25 keys + 2 octave buttons trigger Serial-Monitor prints. The OLED renders. The SD card mounts and the write-probe succeeds. Battery voltage shown on the HUD matches a multimeter on the BAT+ test point./LOOP_001.WAV off the card, open it in Audacity — the file is a valid 44.1 kHz / mono / 16-bit WAV that plays back identically to what came out of the speakers.
The expected (and at this point largely-achieved) results are:
edit_mode flow.SD_MODE GPIO scheme + the 10 kΩ TIP-pulldown rework) with no audible pop or click on plug events.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:
SD_MODE) for combined channel selection + dynamic mute on a single GPIO.GND, GNDA, GNDPWR) to keep high-current speaker returns out of the analog audio reference.edit_mode + page stack + looper transport overlay) that all needs to coexist without surprising the user.
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.
MiniSynth/ and MiniSynthTest/ sketches).LOOP_NNN.WAV files for the SD card.README and ChangeLog.
SD_MODE channel-selection scheme designed (210 kΩ series resistor for right channel, direct drive for left from Teensy GPIO).MiniSynthTest.ino diagnostic sketch verifies each peripheral works in isolation.MiniSynth.ino): AudioEngine, Keyboard, Controls, Display, Menu subsystems. Math waveforms + first wavetable instruments compiled in. Persistence layer with EEPROM-backed envelope storage.edit_mode for menu numeric values (replacing the earlier pot-driven design).