A gated pulse-train / burst generator built on the ESP32 RMT peripheral. The whole waveform (pulses and the inter-burst gate) is generated in RMT hardware at 12.5 ns resolution (clk_div = 1, 80 ticks/µs) and repeated in hardware loop mode — the CPU is completely out of the timing loop, so the output is jitter-free and rock-stable under scope averaging.
Sweet spot: clean, precise output up to ~20 kHz; usable well beyond. (Above ~10 MHz the RMT integer-tick grid makes raw frequency steps coarse — see Precise-frequency mode for how that is worked around.)
The OFFTIME_MOD encoder cycles modes: every click (any direction) advances 1 ↔ 2. Mode params persist across switches, so you can tune in mode 1, flip to mode 2 to ramp them per-pulse, flip back, etc.
- Mode 1 — standard burst. N identical pulses (WIDTH high, SPACING low)
followed by GATE low, looped in RMT hardware. Optionally combined with the
precise-frequency dither (
f<Hz>over serial) — sigma-delta period dither across the burst gives an exact mean frequency well below the raw 12.5 ns tick grid. The OFFSET encoder is the fine-frequency vernier. - Mode 2 — elongation. The on-time and off-time of each successive pulse
in the burst are scaled by per-pulse multipliers
T1andT2. Pulsekhas widthWIDTH × T1^kand spacingSPACING × T2^k, then GATE. WIDTH and SPACING encoders now adjustT1andT2in 1 % steps (clamped 0.5×–2.0×); base widths from mode 1 are preserved. Usemake reset-elongto setT1=T2=1.0. - Mode 3 — randomized gaps. Currently disabled in this build (the randomized timing was glitchy in initial testing). The builder code is retained for a future revision.
- Board: ESP32 (Arduino core 3.x), FQBN
esp32:esp32:esp32 - Output:
GPIO2= Channel 1 (the only active channel in this build).GPIO5(CH2) is wired but disabled — a second RMT stream introduced cross-channel jitter, so it is parked until that is solved cleanly. - Inputs: 7 quadrature encoder modules (KY-040-class, with on-board pull-ups). A hardware PCNT glitch filter and a software debounce reject contact bounce / EMI so resting noise can't disturb the output.
- No WiFi, no web server — serial-only control.
| Encoder (A / B GPIO) | Function | Step / click |
|---|---|---|
| PULSE (14 / 13) | Cycles per burst — also sets precise-mode dither resolution | +1 |
| WIDTH (35 / 34) | Mode 1: pulse HIGH time. Mode 2: T1 elongation (1 %) |
1 µs / 1 % |
| SPACING (19 / 18) | Mode 1: pulse LOW time. Mode 2: T2 elongation (1 %) |
1 µs / 1 % |
| OFFTIME (23 / 22) | Gate / inter-burst off-time | 50 µs |
| WIDTH_MOD (27 / 26) | Width fine-tune (100 ns/click) — or freq vernier in f<Hz> mode |
100 ns |
| SPACING_MOD (15 / 32) | Spacing fine-tune (100 ns/click) | 100 ns |
| OFFTIME_MOD (33 / 4) | MODE cycle — any click advances 1 ↔ 2 | — |
1 RMT tick = 12.5 ns. WIDTH/SPACING = 8 ticks/click, GATE = 80 ticks/click. GPIO34/35 are input-only (no internal pull-up) — the WIDTH encoder relies on its module's on-board pull-ups.
The pulse engine runs on core 0; encoder polling/CLI on core 1 (separated so neither starves the other). The engine builds the burst once, loads it into the RMT 64-slot memory block, enables hardware loop mode, and sleeps. It only stops/rebuilds/restarts when a parameter actually changes (debounced), so a steady setting produces an exact, hardware-repeated waveform with no CPU seam to smear.
Knob mode (default). A burst of PULSE pulses, each WIDTH × widthMult
high then SPACING low, followed by GATE × gateMult low — looped.
Precise-frequency mode (f<Hz>). Generates a ~50 % square wave at an
exact target frequency. Because one RMT period is an integer number of
12.5 ns ticks, a single period can only land on f = 1/(N·12.5 ns); the gap to
the next value grows as f² (≈125 Hz at 100 kHz, ≈12.5 kHz at 1 MHz). To beat
that, the engine uses a sigma-delta period dither: across the cycles in the
burst it spreads N and N+1-tick periods so the average frequency is
exact. The mean is precise; only the instantaneous edge moves ≤12.5 ns — which
a resonant load simply averages out (it does not shift the centre
frequency). Resonances never sit on a whole Hz anyway; what matters is fine
step resolution, which this provides.
Resolution scales with cycles per burst: step ≈ f² × 12.5 ns / cycles.
Turn the PULSE knob up for finer steps; use the WIDTH_MOD (fine-trim)
encoder to walk across in the smallest possible increment — ideal for creeping
onto a resonance peak. Gating is preserved the whole time (GATE / OFFTIME_MOD
still set the inter-burst gap).
| Cmd | Action |
|---|---|
p |
Print state (mode, params, achieved frequency, fine step) |
m |
Advance mode (or m1 / m2 to jump to a specific mode) |
r |
Reset elongation factors T1=T2=1.0 (mode 2 only) |
f<Hz> |
Mode 1: set precise target frequency, e.g. f47325.6. f0 = off |
g1 |
Toggle channel 1 enable |
i1 |
Toggle channel 1 invert |
e |
Dump raw encoder counts |
d |
Toggle the encoder-debug stream |
? |
Help |
Requires arduino-cli, the esp32 core, and libraries ArduinoJson,
ESP32Encoder. A Makefile wraps the workflow:
make flash # compile + upload
make build # compile only
make upload # upload last build
make monitor # open the serial console
make log # background serial logger -> cwg.log (no repeated resets)
make state # ask the running firmware to print its state
make mode1 # switch to mode 1 (standard burst)
make mode2 # switch to mode 2 (per-pulse elongation)
make reset-elong # T1 = T2 = 1.0
make clean
Override the port/board if needed: make flash PORT=/dev/cu.usbserial-XXXX.
- Single channel (GPIO2) in this build; CH2 is parked. The active channel claims all 8 RMT memory blocks (512 items), so long gates fit cleanly in the hardware loop buffer.
- Boot defaults are sparse and safe for a live DUT: 5 pulses × 8 µs at 1 ms gate → ~925 Hz train rate, ~7 % average duty. Crank the OFFTIME knob up for sparser, down for denser.
- Clean/precise to ~20 kHz; the RMT 12.5 ns grid + dither still works far
higher but raw resolution coarsens with
f². - Connecting USB serial resets the board (CH340 auto-reset) — encoder values re-seed to defaults on every boot (normal for incremental encoders).
- Extreme knob settings are clamped, not allowed to overflow/crash.
- OFFSET encoder is the fine-frequency vernier — only active in precise
mode (
f<Hz>). In knob mode it intentionally does nothing.