A portable chromatic instrument tuner for the M5Stack StickS3 (ESP32-S3-PICO-1-N8R8), built on ESP-IDF v6.0.1.
mic (ES8311 / I2S) -> FFT autocorrelation pitch -> nearest note + cents -> LCD (LVGL)
The screen shows the nearest note (e.g. A4), a centered cents bar, and the detected
frequency. It turns green when within ±5 cents, amber when off pitch, and shows
-- when no note is present. Works for guitar, bass, ukulele, voice, etc.
Interactive: docs/quick-start.html (this one-pager) ·
docs/user-guide.html (fuller end-user guide). For players — no build details.
No toolchain needed — flash a StickS3 straight from Chrome or Edge over USB with ESP Launchpad:
Plug in the StickS3, click Connect, choose its serial port, then Flash — it
writes the prebuilt firmware over Web Serial. The config and binary are served from
GitHub Pages (the gh-pages
branch), which sends the CORS headers the in-browser flasher needs. See
Publishing a web-flash build to update it.
| File | Role |
|---|---|
main/board.h |
All StickS3 GPIO / I2C / I2S pin assignments |
main/power.c |
M5PM1 PMIC bring-up — enables the L3B rail (codec + LCD backlight) |
main/audio.c |
ES8311 codec + I2S-std RX, audio_read() |
main/pitch.c |
FFT-accelerated autocorrelation pitch estimator (esp-dsp SIMD) |
main/note.c |
Frequency → note name + cents (adjustable A4 reference) |
main/buttons.c |
Front/side buttons → play tone / reference toggle |
main/imu.c |
BMI270 tilt → playback-note selector |
main/ir.c |
IR TX/RX (NEC via RMT) — the prank easter egg |
main/ui.c |
ST7789 + LVGL 9 tuner screen |
main/sticks3_tuner.c |
app_main: power, I2C bus, analysis task |
Managed dependencies (auto-fetched on first build): espressif/esp_codec_dev,
espressif/esp-dsp, espressif/bmi270, lvgl/lvgl.
idf.py set-target esp32s3
idf.py build
idf.py -p COMx flash monitor # replace COMx with the StickS3 portThe browser button flashes a single merged image (bootloader + partition table +
app, written at 0x0), served from GitHub Pages on the
gh-pages branch. (Pages
sends the CORS headers the in-browser flasher needs — GitHub Release asset downloads
don't, so they can't be fetched by esptool-js.) To update the firmware:
idf.py set-target esp32s3
idf.py build
idf.py merge-bin -o sticks3-tuner-esp32s3.bin # one image, flashed at 0x0Replace sticks3-tuner-esp32s3.bin on the gh-pages branch with the new build and
push — the file name must match image.esp32s3 in
launchpad.toml
(hyphen-less chip key, per ESP Launchpad).
One-time setup: in repo Settings → Pages, set the source to Deploy from a
branch → gh-pages / / (root). The site then serves at
https://tanyanquan.github.io/sticks3-tuner/.
These depend on the exact panel/board revision — adjust if needed and rebuild:
- No sound / mic silent — the ES8311 rail (L3B) is enabled by
power.cover the M5PM1 PMIC at boot. The startup I2C scan should list0x18(codec),0x68(IMU),0x6e(PMIC); if0x18is missing, L3B didn't come up. The codec is configured mono / left slot; a stereo config reads all-zeros on this codec. Mic data pin: the mic (codec DOUT) is on GPIO16, speaker (codec DIN) on GPIO14 — the reverse of the M5 web docs (seeboard.h). Reading the wrong one gives exact0x0000. Mic gain:MIC_GAIN_DBinaudio.c(42 dB). - Speaker hiss (optional) — the codec runs full-duplex (
WORK_MODE_BOTH). If the speaker hisses at idle, switch to input-only:WORK_MODE_ADC/DEV_TYPE_INand a RX-only I2S channel inaudio.c. - Display shifted — tweak
BOARD_LCD_X_GAP/BOARD_LCD_Y_GAPinboard.h. - Display mirrored / upside-down — adjust
esp_lcd_panel_mirror/swap_xyinui.c. - Colors wrong — flip
rgb_ele_order(BGR↔RGB) oresp_lcd_panel_invert_colorinui.c. - Backlight inverted — flip
BOARD_LCD_BL_ONinboard.h. - Sensitivity / stability — pitch detection is FFT-accelerated autocorrelation
(esp-dsp SIMD) over a 4096-sample window, sliding by
HOP_SAMPLES(~15 updates/s). To pick up weaker signals, lowerRMS_GATE/CLARITY_GATEinpitch.cor raiseMIC_GAIN_DB. To reject noise blips, raise them.HOLD_FRAMESinsticks3_tuner.ccontrols how long a note lingers as a string decays;PITCH_WINDOW(power of two; zero-padded to a2*PITCH_WINDOWFFT for a linear autocorrelation, so keep2*PITCH_WINDOW <= CONFIG_DSP_MAX_FFT_SIZE) trades latency for low-note SNR.
- KEY1 (front, GPIO11) — play + tilt-bend. Hold to play a reference tone out
the speaker; while held, tilt the stick (Y axis) to slide the note up/down
semitone by semitone (range D4–D6), faster the more you tilt, hold level to
settle. The mic picks up the speaker, so the played note shows on the main
display. Tone synth in
audio_tone_start/feed/stop(); tilt inimu.c(BMI270 accelerometer, only active while KEY1 is held). The AW8737 amp is enabled on the first press — with a shortAMP_SETTLE_MSdelay so its power-on transient settles into silence before the tone — and lazily disabledAMP_HOLD_MSafter the last tone to save power. - KEY2 (side, GPIO12) — toggle the A4 reference between 442 (default,
Chinese-orchestra standard) and 440. Shown top-left as
A=NNN. - Battery level shown top-right (read from the PMIC
VBATregister).
The device always listens on IR (RX GPIO42). Press KEY1 five times quickly while
the tuner is reading F4 to enter a hidden prank mode (small IR shows up top):
- KEY1 transmits an IR signature (TX GPIO46, 38 kHz NEC).
- KEY2 exits.
Any StickS3 running this firmware that receives the signature flashes a big music
note for 2 s — so point it at your orchestra mates' tuners and watch. Signature +
trigger note are tunable in ir.c / buttons.c.
Battery measures:
- Backlight PWM-dimmed to
BL_LEVEL_IDLEafterBL_IDLE_TIMEOUT_MS(20 s) of no activity, full brightness on any activity — the largest saver (tunables inui.c). "Activity" = a button, movement, or sustained close-mic playing (ACTIVITY_FRAMES/ACTIVITY_LEVELinsticks3_tuner.c), not any detected pitch — so stray room noise in a band/orchestra setting doesn't keep it lit (or block the power-off below). - Idle auto-power-off — after
SHUTDOWN_WARN_MS(2 min) of inactivity aSHUTDOWN_GRACE_MS(15 s) on-screen countdown ("Powering off in Ns, tap to keep awake") appears, and the device powers off when it elapses; any activity cancels it. Off is a true hard-off via the M5PM1SYS_CMDpower-off command (power_off()clearsHOLD_CFGfirst so no rail stays latched, with an ESP deep-sleep/KEY1-wake backstop); the device restarts on the power button. Skipped while on USB-C power —power_external_present()reads the PMICPWR_SRCregister (5VIN present), so it stays on whether charging or just powered. Timeouts are tunable inui.c. - CPU at 80 MHz (
sdkconfig.defaults) — halves active CPU power; FFT + LVGL still fit. Peripheral clocks (I2S/SPI) are PLL-sourced and unaffected. - Speaker amp enabled only around tone playback (lazy-disabled after
AMP_HOLD_MS), not held on continuously.
Note spelling uses the common band/orchestra mix — sharps for C#/F#/G#, flats for
Eb/Bb (NAMES[] in note.c).
Tone too loud/quiet or silent? Adjust TONE_VOL/TONE_AMP in audio.c and
AW8737_GAIN_PULSES (1–3) / AW8737_MODE_GPIO in power.c.
MIT — see LICENSE. Use it freely (including commercially); just keep
the copyright notice.
Third-party code keeps its own license: main/ir_nec_encoder.{c,h} is from the
ESP-IDF examples and stays Apache-2.0 (see its SPDX header), and the managed
dependencies are under their own terms (esp_codec_dev / esp-dsp / bmi270:
Apache-2.0; lvgl: MIT).
