XK-WORSHOP
This instrument is inspired by the XK instrument designed by Tian Jinqin. tian-xs-workshop is a simplified, single-voice derivative created so that workshop participants can build, understand, and modify every part of it.
The XK-S mini is a single-voice expressive musical controller built around one ESP32-S2-Mini board and two resistive ribbon sensors. A pitch ribbon tells the board where along the “string” the frequency your finger is, and a envelope ribbon defines the volume — it is also the note gate: no touch, no note.
From those two sensor readings the firmware produces three kinds of output at once:
- USB-MIDI — the board shows up as a class-compliant MIDI device: Note On/Off, pitch bend, and CC11 (expression). One MIDI channel per string (1-4), each channel with independent Pitch Bend value.
- Analog CV — two control-voltage outputs for a modular synth: pitch CV (1 V/octave) on GPIO17 and amplitude CV on GPIO18.
- Serial telemetry — a live data stream that drives a browser-based web console (scopes, calibration, settings).
Four capacitive touch pads on the board select settings with two/three/four-finger gestures — amplitude smoothing (“slew”) and the pitch-quantization mode — so the instrument can be played and re-tuned without ever opening the web console.
Bill of materials
| Part | Qty | Role |
|---|---|---|
| ESP32-S2-Mini (LOLIN/WEMOS S2 Mini) | 1 | Brain: native USB-MIDI, 12-bit ADC, two 8-bit DACs, capacitive touch — no extra chips needed |
| Pitch ribbon (carbon strip + metal band) | 1 | Finger shorts the strip to the grounded band at the touch point → position-dependent resistance on ADC 1 (GPIO3) |
| Envelope ribbon (same construction) | 1 | Amplitude + note gate on ADC 2 (GPIO5) |
| Pull-up resistors: 150 kΩ (pitch), 47 kΩ (envelope) | 2 | 3V3 → sensor node; form the divider with the touched strip and define the released (max) reading |
| Resistor 10 kΩ + capacitor 0.33 µF (330 nF) | 2 pairs | RC low-pass filter on each ADC input (fc ≈ 48 Hz) — analog smoothing before the digital filtering |
| Conductive pads + wire | 4 | Capacitive touch pads on GPIO7/9/11/12 — no components, just wire |
| 3.5 mm or 6.35 mm jacks | 2 | Pitch CV and amplitude CV outputs to the modular synth |
| Breadboard / stripboard, jumpers | — | Mounting; a faulty breadboard rail was a real bug during development — swap if readings misbehave |
Wiring schematic
Assembly
The instrument is built in three stages, shown below in photo order: the envelope key, the electronics, and the fingerboard. Click any photo to view it full-size.
1. Envelope key
The envelope key is the contact you press: a brass/metal band mounted on a printed key bar, with a spring-loaded screw that sets how firmly the band meets the ribbon below it.
2. Electronics
The whole electronics fit on the palm-size S2 Mini: a handful of through-hole parts for the two sensor inputs, wires soldered straight to the board’s pads, and the board seated in a pocket in the body.
3. Fingerboard
The fingerboard is the long rail you slide along: the carbon strip is anchored at one end and held under slight tension by a spring at the other, so it lies flat and registers a clean touch anywhere along its length.
Pin map
| Signal | GPIO | Notes |
|---|---|---|
| Pitch ribbon wiper | 3 | ADC1 input, 0–3.3 V |
| Envelope ribbon node | 5 | ADC1 input, 0–3.3 V |
| Pitch CV out | 17 | Built-in DAC ch.0 — fixed pin |
| Amp CV out | 18 | Built-in DAC ch.1 — fixed pin |
| Status LED | 15 | Onboard LED — do not reuse as an input |
| Touch “select” | 7 | Modifier for all touch gestures |
| Touch “0” / “1” / “2” | 9 / 11 / 12 | Slew + pitch-mode combos (below) |
config.h as long as you keep the right
pin type (ADC-capable for sensors, touch-capable for pads).Signal flow
How each stage works — and why
1. The sensors are just variable resistances
Each ribbon is a resistive carbon strip plus a conductive metal band running alongside it — a metal ring (or the finger itself) shorts the strip to the grounded band at the touch point. The strip therefore behaves as a variable resistor whose value depends on where (and how firmly) you touch. A pull-up resistor to 3V3 — 150 kΩ for pitch, 47 kΩ for envelope — forms a divider with that touch resistance, so the sensor node voltage tracks the touch. With no finger, nothing pulls the node down: it floats to the top of the range — which is exactly how the firmware detects “released”.
2. Reading the ADC — noise and crosstalk hygiene
Each ADC input is preceded by a hardware RC low-pass filter — a 10 kΩ resistor in series and a 0.33 µF capacitor from the ADC node to ground — with a cutoff around 48 Hz. Sensor voltages change slowly relative to the noise, so this removes most of the high-frequency junk before the silicon ever sees it.
In firmware there are two more tricks worth teaching: oversampling (averaging N conversions knocks random noise down by √N — the sketch averages 8 per channel) and the dummy read — a real hardware quirk where the ADC’s shared sampling capacitor “remembers” the previous channel’s voltage, so the first conversion after switching channels is discarded.
3. Exponential smoothing with a real time constant
The digital filter is the classic exponential moving
average — a one-pole low-pass: each step the estimate moves a
fraction α toward the new reading, where α = 1 −
exp(−dt/τ). Writing it in terms of elapsed time rather
than loop iterations means the smoothing stays constant no matter how
fast the loop happens to run. τ (the tauMs parameter) is
the time constant: after τ ms the output is ~63% of the way to a step
change. The amplitude filter is even adjustable live from the web
console — a digital twin of the analog RC filter, but under software
control.
4. Amplitude → normalized loudness, with a release detector
Two calibrated points (captured live in the web console) define the
playable range; readings near full scale mean the ribbon is released.
On top of this, gate hysteresis (AMP_GATE_OPEN /
AMP_GATE_CLOSE) turns amplitude into note on/off without
flickering right at the threshold — the open threshold is higher than
the close threshold.
5. Pitch → frequency — a calibration table, not a formula
Ribbon sensors are never perfectly linear end-to-end, so instead of a formula the firmware keeps a 10-point calibration table (one raw ADC reading per note of a G-string tuning) and interpolates logarithmically between points — because pitch perception is logarithmic, equal sensor distances become equal musical intervals.
6. Quantization: four pitch modes
The same note-on moment can be treated four ways, selectable from the web console’s Pitch menu or by touch gestures. Every mode except Continuous starts each note exactly on the nearest equal-tempered semitone:
| Mode | Vibrato | Slides | Touch gesture |
|---|---|---|---|
| Notes only | No | Jump semitone to semitone | select + 0 + 1 |
| Notes + vibrato | Yes | Step semitone to semitone | select + 1 + 2 |
| Notes + vibrato + glide | Yes | Glide, then ease onto a semitone | select + 0 + 2 |
| Continuous | Yes | Free | all four pads |
Pedagogically this is a lovely illustration of 12-TET quantization: the firmware snaps a continuous finger position to the nearest note of an equal-tempered scale — and the difference between “snap the value” and “detune around an average” is exactly the difference between killing vibrato and keeping it.
7. Outputs
- MIDI: a real Note On per semitone (in discretized modes), pitch bend for the in-between (14-bit, centre = 8192; set your synth to ±24 semitones to match), CC11 for amplitude.
- Pitch CV:
log2(freq / CV_BASE_FREQ)volts — true 1 V/octave. - Amp CV: log-mapped over a few octaves of dynamic range, then slew-limited so gates don’t click. The touch pads set the slew amount: select+0 = off, select+1 = medium, select+2 = intense.
8. Real DACs aren’t ideal — measure, don’t trust
The S2’s 8-bit DAC is measurably nonlinear (asking for 0 V gives ~0.09 V; the top is ~2.4 V on this board, not 3.3 V). The firmware stores a measured volts-per-code table per channel and inverse-interpolates: ask for 1.000 V, get the code that actually produces 1.000 V. The web console walks you through measuring it with a multimeter.
9. Capacitive touch as a settings UI
The four pads need no components — touchRead() measures
how much your finger’s capacitance loads each pin. Gestures are
recognized as the union of pads touched between
first-finger-down and all-fingers-up, evaluated on release: a single
accidental brush does nothing, and flickery reads are debounced by the
gesture window itself.
Firmware map (for participants who want to read the code)
| File | What’s inside |
|---|---|
config.h | All the tunable knobs — pins, thresholds, tuning table, filter constants. Start here. |
tian-xs-workshop.ino | setup() + loop(): sensor reads, gate, pitch/amp processing, output writes |
calibration.h | Pitch calibration table → frequency mapping; the StringState struct |
pitch_modes.h | The four quantization modes |
cv_output.h | DAC writes, 1V/oct math, DAC linearization table |
midi_helpers.h | Pitch-bend packing, envelope→amplitude mapping |
touch.h | Capacitive pads + gesture recognition |
console.h | Serial command parser + telemetry stream |
storage.h | Saving calibration/settings to flash (NVS) |
debug.h | Human-readable serial debug line |
cv_player.h | Demo arpeggio for testing CV without playing |
webapp/index.html | The web console (Web Serial, scopes, calibration UI) |
The web console
Open webapp/index.html in Chrome/Edge, click
Connect then Start stream. You get:
- A Pitch scope: raw sensor reading (blue) with the quantized output frequency overlaid (amber), plus Hz, MIDI note, pitch-bend offset and CV volts — so you can literally see quantization happen.
- An Amplitude scope: raw envelope (orange) with the filtered value overlaid (green) — move the Amp filter slider and watch the green trace lag.
- Live touch-pad indicators and the current slew level.
- A Pitch mode menu mirroring the touch gestures.
- Calibrate: click-to-capture pitch and amplitude points, DAC linearization table, Save/Load JSON profiles per board, Save to device.
Serial protocol (cheat sheet)
| Host → board | Board → host |
|---|---|
T 1|0 stream on/off |
S p a pcv acv af pf n pb — telemetry sample |
MODE 0-3 / GET MODE |
PM n — pitch mode |
SET AMPFILT ms / GET AMPFILT |
AMPFILT ms |
GET/SET/LOAD CAL|AMPCAL|DACCAL |
CAL … / AMPCAL … / DACCAL … dumps |
SAVE CAL / RESET CAL |
OK … / ERR … |
TEST 1 [v] / TEST 0 hold CV |
SL n — slew level, TP/TR — pads |
DACRAW 1 P|A code / DACRAW 0 |
FPS n — true loop rate |
Things to try in the workshop
- See quantization: stream, switch pitch modes, and watch the amber “out” trace snap vs. follow on the pitch scope.
- Feel the filter: drag the Amp filter slider and compare the orange (raw) vs green (filtered) traces, then play.
- Measure a real DAC: use the DAC calibration dialog + a multimeter — a great lesson in why datasheets lie.
- Retune the string: change
STRING_FREQS[]/CV_BASE_FREQinconfig.hand re-capture the pitch table. - Tune touch thresholds: watch the pad raw counts
in the header and adjust
TOUCH_THRESHOLD. - Debug like an engineer: stop the stream and read the human-readable debug line — the same numbers, formatted.