A tap-tempo BPM counter and beatmatch assistant for vinyl DJing, running on an ideaspark ESP32-WROOM-32. Tap a button in time with a record and it reads out the BPM in big, booth-legible digits — then tells you exactly how much pitch to dial in to match your other deck, and how long you've got before they drift apart.
Roughly £20–30 of off-the-shelf parts, no PCB to make, and it runs with no wiring at all — the board's BOOT button is enough to use it. Supports both ideaspark display boards from one codebase.
Jump to: Build one · Flash it · Controls · Contributing · Changelog · Releases
Full parts list, wiring, GPIO reference and enclosure notes are in docs/HARDWARE.md. The short version:
| Part | Detail |
|---|---|
| Board | ideaspark ESP32-WROOM-32 |
| Display (either) | 0.96" SSD1306 OLED 128×64 I2C 0x3C (SDA=21, SCL=22) — build env oled |
1.14" ST7789 TFT 240×135 SPI colour — build env tft |
|
| Buttons | Onboard buttons and/or external momentary buttons — see below |
The colour TFT is the better choice: colour carries the match state, and you can read green/amber/red across a dark booth far faster than you can read digits.
It works with no wiring at all, and gets nicer once you add buttons.
The BOOT button drives all three actions by press length, so you can flash the board and use it straight away:
| Gesture | Action |
|---|---|
| Short press (< 0.6 s) | TAP |
| Long press (0.6 – 1.6 s) | SWAP |
| Hold (≥ 1.6 s) | MODE |
It's the only onboard button software can read on this board — the others are EN/RST, which reset the chip in hardware and never reach a GPIO.
Each action also accepts several pins at once, so wired buttons and the onboard
one both work — press whichever is to hand. Tapping a beat on a dedicated button
beats gesturing on BOOT. The pin lists are at the top of
src/main.cpp:
static const uint8_t TAP_PINS[] = {27};
static const uint8_t SWAP_PINS[] = {26};
static const uint8_t MODE_PINS[] = {25};
static const uint8_t GESTURE_PIN = 0; // onboard BOOT; set 255 to disableExternal buttons go between the GPIO and GND — no resistors needed, the firmware enables internal pull-ups so each pin idles HIGH and reads LOW when pressed:
GPIO27 ──[ TAP ]── GND
GPIO26 ──[ SWAP ]── GND
GPIO25 ──[ MODE ]── GND
Avoid GPIO 6–11 (flash), 12 (boot strap), 21/22 (OLED I2C) and 34–39 (no internal pull-up). On the TFT board GPIO 2/4/15/18/23/32 belong to the display.
GPIO0 is the onboard BOOT button. It's also a boot strapping pin, so holding it while the board powers up enters the bootloader instead of running the firmware. That's normal — just don't hold it through a reset.
Board revisions differ, so measure rather than guess. Flash the scanner, open the monitor, and press each button:
pio run -e pinscan -t upload
pio device monitorIt prints the GPIO behind each press:
* PRESSED GPIO0
released GPIO0
Put those numbers into the pin lists above and reflash.
Reading the results: a real button gives clean, paired press/release lines. Pins that fire in bursts, change in step with each other, or never settle are floating, not buttons — GPIO34–39 in particular are input-only with no internal pull-up, so they drift. For those the scanner prints a twice-a-second snapshot instead: press and hold the button and watch for a level that stays put while you're holding it.
Tip: make the TAP button physically distinct — bigger, or a different coloured cap — so you find it by feel in the dark without looking.
MODE cycles through three screens: MATCH → LIBRARY → WIFI → MATCH.
| Screen | TAP | SWAP |
|---|---|---|
| MATCH | Tap a beat for the active deck | Lock the deck & switch A ⇄ B |
| LIBRARY | Move cursor to next slot | Store live BPM into slot* |
| WIFI | Toggle the access point | — |
* Storing with no live reading clears the slot.
- Power on — Deck A is active. TAP along with the record that's playing.
- Press SWAP to lock Deck A and switch to Deck B.
- TAP along with the record you're cueing.
- Read the pitch % and nudge, dial it into the deck, and watch the bar centre.
┌────────────────────────────────┐
│ DECK B TAPS 7 ● │
│ │
│ 126.8 │
│ │
│ ├────────────█───┼─────────┤ │ pitch bar, centre = matched
│ │
│ A 128.3 B 126.8 +/-3ms │
│ SPEED UP +1.18% drift 40s │
└────────────────────────────────┘
- Pitch % —
((target / active) - 1) × 100, i.e. how far to move the active deck's pitch fader. Colour-coded green / amber / red on the TFT. - Pitch bar — glanceable deflection from centre; centre means matched.
- Drift timer — "drift 40s" is how long until the two decks slip a full beat
apart at the current error (
60 / ΔBPMseconds). It answers the question you actually care about mid-mix: how long before I need to nudge? 0.5 BPM out gives you two minutes; 0.1 BPM out gives you ten. - ±ms — the spread of your tap intervals. Low means you're tapping consistently, so trust the number. Turns green once the reading is solid.
- Beat dot — flashes on every predicted beat once a tempo is locked: a visual metronome you can check against the record.
x2/1/2— the match was made an octave apart (see below).
- Octave-aware matching. A record at 64 BPM and one at 128 BPM are the same tempo
an octave apart. The match picks whichever of the other deck's tempo, ×2, or ½ needs
the least pitch change, and tags it
x2or1/2so you know what it did. - Pitch-range warning. If matching would need more pitch than your decks actually
have, it shows
OUT OF RANGE— they won't mix straight. Defaults to ±8 % (a Technics 1210) and is configurable from the WiFi page; set it to 16 for most Pioneers. The pitch bar scales with it, so full deflection always means the end of your fader. - Outlier rejection. A missed or double tap is discarded and restarts the averaging window, leaving the previous reading standing rather than lurching to a wrong number; two clean taps re-establish the tempo. A 3-second pause starts a fresh measurement for the next record.
- Screen sleep. Blanks after 2 minutes idle (battery/burn-in); any press wakes it. Never sleeps while the WiFi AP is up.
Three buttons can't type a record name, so the WIFI screen brings up the ESP32's own access point — no router needed, works in a shop or a booth:
| SSID | openBPM |
| Password | beatmatch |
| URL | http://192.168.4.1 |
From a phone you can name each of the 8 slots, clear them, set your decks' pitch range, and download the whole library as CSV. Names and BPMs are stored in the ESP32's flash (NVS), so they survive power-off. Turn the AP off when you're done — it's the biggest power draw.
With PlatformIO (VS Code extension or CLI), pick the env that matches your board:
pio run -e tft -t upload # 1.14" ST7789 colour (default)
pio run -e oled -t upload # 0.96" SSD1306 mono
pio run -e pinscan -t upload # button pin scanner (diagnostic)
pio device monitor # optional, 115200tft is the default env, so a bare pio run -t upload builds it.
The BPM engine has no Arduino dependency — Deck takes the current time as a
parameter rather than calling millis() — so a whole tapping session can be
simulated and the tempo maths tested on a desktop, no board required:
pio test -e native # 30 unit testsThey cover tap averaging, outlier rejection, idle reset, jitter, beat prediction, octave matching, pitch range and the drift timer. CI runs them before it builds any firmware. They earn their keep — they caught a real bug where a single fumbled tap dropped a steady 120 BPM readout to ~43.
Prebuilt firmware.bin for each board is attached to every
release, and CI builds all three
environments on each push.
TFT_eSPI is configured entirely from build_flags in
platformio.ini — the library's User_Setup.h is never edited, so
the build stays reproducible.
src/
app.h / app.cpp BPM engine + beatmatch maths (no Arduino dependency)
library.cpp persisted BPM slots (NVS/flash)
display.h display interface + UiState (what a screen renders)
display_oled.cpp 0.96" SSD1306 front-end (env: oled)
display_tft.cpp 1.14" ST7789 front-end (env: tft)
webui.h / webui.cpp WiFi AP + phone-facing library page
main.cpp buttons, modes, main loop
pinscan.cpp standalone button pin scanner (env: pinscan)
test/test_bpm/ desktop unit tests (env: native)
docs/
HARDWARE.md parts list, wiring, GPIO reference, enclosure
Each display front-end lays the screen out to suit its own size and colour depth; the app logic never knows which screen it's driving.
app.cpp is deliberately platform-free so it can be tested on a desktop; everything
needing the platform is quarantined in library.cpp. That makes the beatmatch maths
the easiest place to contribute without owning any hardware.
Fits a Hammond 1591XXLBK (87 × 57 × 39 mm) nicely — roughly stompbox sized. The board (~55 × 28 mm) leaves room for three 12 mm panel-mount buttons in a row (~48 mm total) and a LiPo underneath. Mount the board to the lid, cut a window for the screen, and leave a side hole for USB so you can charge and reflash without opening it.
- Automatic BPM detection from a microphone module (MAX9814 / INMP441) — the real endgame: no tapping at all
- Load a stored slot back onto a deck to match against it directly
- Battery gauge from the LiPo connector, and deep sleep on long idle
Fancy building one of these? See CONTRIBUTING.md.
Contributions are welcome — bug reports from real hardware especially, since most of this has only been verified on one board. Adding a new display is deliberately straightforward.
- CONTRIBUTING.md — setup, style, how to add a screen
- CODE_OF_CONDUCT.md — be decent to people
- Discussions — built one? Show it off
Built one? Post it in Discussions — which board, which enclosure, how it felt to use. Real-world reports are the most useful thing you can send.
Built on U8g2 by olikraus, TFT_eSPI by Bodmer, and the Arduino core for ESP32.
MIT © Ohmic Labs — do what you like with it, including selling them.