A two-microcontroller guitar pedal: a Daisy Seed (STM32H750) runs the audio effects inside a Hothouse pedal enclosure, and an ESP32-S3 acts as a USB host that bridges the pedal to a WiFi web UI and mirrors every knob, switch, and footswitch on an OLED — live, in both directions.
The Daisy Seed is a general-purpose STM32 board: an STM32H750 (Cortex-M7 @ 480 MHz) system-on-module with 64 MB SDRAM, 8 MB QSPI flash, and a stereo audio codec wired to the MCU's SAI peripheral. Audio here is just a bidirectional DMA stream — the external codec does the analog conversion and clocks 48 kHz stereo samples through the H7's SAI (Serial Audio Interface), and libDaisy hands them to an interrupt-context callback in 1 ms blocks of floats. The "effects" are ordinary fixed-budget math inside that ISR; the knobs are plain STM32 ADC channels and the switches GPIOs, wired out by the Hothouse carrier board. Everything else — USB-OTG device, timers, GPIO — is stock STM32 peripheral work.
The ESP32-S3 (dual-core Xtensa LX7 @ 240 MHz, WiFi) plays the opposite role: no audio at all, but USB host, an embedded web server, a WebSocket broadcaster, and an I²C display. The split is the point — hard-real-time DSP stays on the M7 where a missed deadline is audible, and everything bursty (WiFi, HTTP, USB enumeration) lives on the S3.
┌───────────────────────────┐
guitar ──► in │ Hothouse pedal │ out ──► amp
│ ┌─────────────────────┐ │
│ │ Daisy Seed │ │ overdrive → tremolo → delay
│ │ (STM32H750, 48 kHz) │ │ 6 knobs · 3 toggles · 2 footswitches
│ └──────────┬──────────┘ │
└─────────────┼─────────────┘
USB CDC-ACM
newline ASCII protocol, both ways
┌─────────────┴─────────────┐
│ ESP32-S3 DevKitC │
│ USB host · WiFi · I²C │──── SSD1306 OLED
└─────────────┬─────────────┘ (live control mirror)
WiFi (SoftAP + STA)
WebSocket, same ASCII protocol
┌─────────────┴─────────────┐
│ Browser: web control UI │
└───────────────────────────┘
The same newline-terminated ASCII protocol
(common/stomplink_proto.h) travels over every
hop — USB and WebSocket — so any hop can be debugged with a serial terminal or
the browser dev console. The protocol library is a single dependency-free C
header shared by both firmwares and covered by host-side unit tests.
- USB device (Daisy/libDaisy CDC-ACM) and USB host
(ESP32-S3/esp-idf
usb_host+cdc_acm_host) on the same wire, with hot-plug reconnect on the host side. - Real-time-safe control: the audio callback never takes a lock; parameters cross from USB/main-loop context as single-word volatile writes and are de-zippered per sample.
- Bidirectional state with ownership arbitration: a web write marks a
parameter web-owned; nudging the physical knob reclaims it. The owner mask
travels in every state line, and the web UI tags hardware-owned params
HW. - FreeRTOS task architecture (ESP32): USB event pump and bridge pinned to core 1 away from WiFi, a mutex-guarded state hub with event-group dirty bits fanning out to the OLED task and a coalescing (≤20 Hz) WebSocket broadcaster.
- Embedded web serving: gzipped single-page UI embedded in flash, served
with
Content-Encoding: gzip; WebSocket both ways; WiFi provisioning form writing STA credentials to NVS; always-on SoftAP + mDNS. - I²C display driver: hand-rolled 1-bpp framebuffer + public-domain 8×8
font over
esp_lcd, no LVGL. - Host-tested protocol:
make -C common/tests testruns the round-trip / fuzz-ish suite on your dev machine, compiled both as C99 and as C++. - CI for firmware: GitHub Actions cross-builds both firmwares (arm-none-eabi toolchain + ESP-IDF v5.5 container) and runs the protocol suite on every push.
| Piece | Notes |
|---|---|
| Hothouse pedal kit with Daisy Seed | powered by its own 9 V pedal supply |
| ESP32-S3-DevKitC (two USB ports) | UART port → computer, native USB-OTG port → Daisy |
| SSD1306 128×64 I²C OLED | SCL → GPIO40, SDA → GPIO41, 3V3, GND |
| USB-C → micro-USB cable | ESP32 OTG port → Daisy Seed onboard USB |
| J-Link debugger | flashes/debugs the Daisy (SWD pins) |
Wiring notes:
- The DevKitC's OTG connector does not source 5 V VBUS. The pedal supply
powers the Daisy, and the USB cable carries only D+/D−/GND — but stock
libDaisy enables VBUS sensing on the Seed's onboard port, so the Daisy
ignores a host that supplies no 5 V (symptom on the ESP32:
HUB: Root port reset failed).make -C daisy patch-libdaisyapplies a two-line libDaisy patch disabling VBUS sensing; the Daisy is self-powered here, so nothing is lost. - GPIO19/20 are the S3's fixed internal USB PHY — don't repurpose them.
- While the ESP32 occupies the Daisy's USB port, USB-DFU flashing of the Daisy is unavailable; flash it over SWD with the J-Link (or unplug the ESP32 and use DFU).
common/ stomplink_proto.h — shared protocol + host unit tests
daisy/ Daisy Seed firmware (effects + USB CDC device)
esp32/ ESP-IDF project (USB host bridge, OLED, WiFi, web server)
esp32/web/ web UI source; pack.sh regenerates the embedded .gz
libDaisy/ \ submodules pinned to known-good revisions
DaisySP/ /
make -C common/tests testRequires the ARM GNU toolchain
(arm-none-eabi-gcc) and OpenOCD for J-Link flashing.
git submodule update --init --recursive
make -C daisy patch-libdaisy # disable VBUS sensing (see wiring notes)
make -C libDaisy -j
make -C DaisySP -j
make -C daisy -j
make -C daisy program # flash via J-Link/OpenOCD (SWD)Sanity-check without the ESP32: plug the Daisy's micro-USB into your computer
and screen /dev/tty.usbmodem* 115200 — you'll see S/K/T lines as you
move controls, and you can type b 0, p dl mix 0.8, ? etc.
Requires ESP-IDF v5.5.
cd esp32
. $IDF_PATH/export.sh
idf.py build
idf.py -p /dev/tty.usbmodem* flash monitor # the UART-labelled USB portAfter editing web/index.html, run web/pack.sh and rebuild.
- Power the pedal; connect the ESP32's OTG port to the Daisy's micro-USB.
The OLED shows
USBwhen the CDC link is up. - Join the
StompLinkWiFi network (WPA2, passwordletsstomp) and openhttp://192.168.4.1/— or use the WiFi form to join your home network, then openhttp://stomplink.local/. The OLED shows the STA IP. - Move web sliders → the pedal reacts instantly and the OLED tracks.
Move physical knobs → the web UI bars and sliders follow, and reclaimed
params show the
HWtag.
| Control | Function |
|---|---|
| Knobs 1–6 | OD drive, OD level, trem rate, trem depth, delay time, delay mix |
| Toggle 1 | overdrive on (up) / off |
| Toggle 2 | tremolo waveform: sine / square / ramp |
| Toggle 3 | delay on (up) / off |
| Footswitch 1 | global bypass (LED 1) |
| Footswitch 2 | tap tempo (LED 2 blinks the delay time) |
| Web only | delay feedback, per-effect enable incl. tremolo |
Hold both footswitches ≥ 2 s to reboot the Daisy into DFU mode.
Downstream (browser/ESP32 → Daisy): p <fx> <name> <0..1>, e <fx> <0|1>,
b <0|1>, t (tap), ? (state request), v (version request).
Upstream (Daisy → ESP32/browser): S <bypass> <enable-mask> <owner-mask> <8 params> (on change, ≤30 Hz, plus a 1 Hz heartbeat), K <idx> <val>,
T <idx> <pos>, F <idx> <event>, V <proto> <fw>.
The ESP32 adds bridge-local L lines on the WebSocket only (USB link, IP,
firmware version, footswitch counter). Full details in
common/stomplink_proto.h.
Bring-up notes — each of these passed every desk check and failed only on real hardware, which is rather the point of a two-MCU project.
-
The link was dead because of USB VBUS sensing. The Daisy enumerated fine on a Mac but the ESP32 logged
HUB: Root port reset failedforever. The one electrical difference: the Mac supplies 5 V VBUS and the DevKitC's OTG port doesn't. Stock libDaisy enables VBUS sensing on the Seed's USB-FS port, so the (self-powered) Daisy silently ignored any bus reset from a host that provides no 5 V. Fix: a two-line patch tousbd_conf.cdisabling VBUS sensing, shipped asdaisy/patches/+ amake patch-libdaisytarget so the fix survives a fresh submodule checkout. -
printf("%.3f")prints nothing on the target. State lines arrived with blank parameter fields — only on hardware. The Daisy links against newlib-nano, which omits float formatting by default, and 278 host-side protocol tests can't catch a target libc gap. Fix: the shared header formats floats with integer math (v*1000→"%d.%03d"), so both firmwares are immune by construction. -
Burst writes silently dropped lines. The full-state replay (
?) arrived with most of its 10 lines missing. libDaisy's CDC transmit returns immediately while the IN endpoint is still busy with the previous packet — no queue, no error surfaced up the stack. Fix: a bounded retry-with-delay around the send, which is honest about the constraint (a 1 kHz main loop can afford 1 ms; the audio ISR never touches USB).
- In APSTA mode the SoftAP follows the STA onto its channel when the STA connects, briefly dropping SoftAP clients (esp-idf documented behavior).
- The SoftAP is WPA2 with a published password — fine for a demo pedal, not an access-control boundary.
- Once this firmware runs, the S3's native USB port stops enumerating as USB-Serial-JTAG — the USB host stack owns the PHY. To re-flash over that port, hold BOOT while plugging in (download mode); day-to-day, flash and monitor over the UART-labelled port instead.
daisy/MakefiledefaultsPGM_DEVICEtointerface/jlink.cfg; override it for other probes (make program PGM_DEVICE=interface/stlink.cfg). Note that SEGGER's own tools (JLinkExe) refuse non-matching targets on J-Link-OB probes ("Specific core setup failed") — OpenOCD viamake programdrives the same probe as a generic SWD adapter and works.
GPL-3.0-or-later. daisy/hothouse.{h,cpp} are vendored from the
Hothouse examples
(GPL-3.0-or-later, © Cleveland Music Co.); esp32/components/oled_ui/font8x8_basic.h
is public domain (Daniel Hepper).
