Skip to content
The tian-xs workshop instrument — 3D-printed body with fingerboard neck, envelope key, CV jacks and USB port
The finished instrument: the pitch fingerboard (neck), the envelope ribbon under the amber window, CV jacks on the front edge, and USB-C on the side.

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.

Files for participants: the STL files for the 3D-printed body and the Arduino firmware are in the shared folder: tamcloud.kunstuni-linz.at/s/JrYLqzgwsDa56Cd

Bill of materials

PartQtyRole
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 + wire4 Capacitive touch pads on GPIO7/9/11/12 — no components, just wire
3.5 mm or 6.35 mm jacks2 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
Board quirk worth knowing: many S2 Mini boards ship with a 10 kΩ pull-up on GPIO18 that stops the amp-CV DAC from going below ~1.65 V. Desolder it (or accept the reduced range) — see the wiki’s Hardware page for the photo guide.

Wiring schematic

ESP32-S2-Mini USB-C GPIO3 ← pitch GPIO5 ← amp GPIO7 T “select” GPIO9 T “0” GPIO11 T “1” GPIO12 T “2” GPIO17 → GPIO18 → 3V3 / GND Pitch ribbon carbon strip 150 kΩ → 3V3 10 kΩ 0.33 µF metal band Envelope ribbon carbon strip 47 kΩ → 3V3 10 kΩ 0.33 µF metal band pad “select” pad “0” pad “1” pad “2” touch pads: bare wire / conductive pad only — no resistor needed CV outputs → modular pitch CV jack (1 V/oct) amp CV jack (0–2.4 V) USB-C → computer USB-MIDI device + serial console
Wiring overview (one channel shown per sensor; both are identical in topology). Each ribbon is a carbon strip read as a variable resistance: a conductive band on the finger shorts the strip to the grounded metal band at the touch point, and a pull-up (150 kΩ pitch / 47 kΩ envelope) forms the divider. With no touch the node floats up to 3V3 → maximum reading = released. A 10 kΩ + 0.33 µF RC low-pass (fc ≈ 48 Hz) sits at each ADC pin.

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.

Envelope key step 1
1 Key housing and parts
Envelope key step 2
2 Spring-loaded contact screw
Envelope key step 3
3 Contact lug soldered to the band end
Envelope key step 4
4 The channel that holds the band
Envelope key step 5
5 Band seated in the channel
Envelope key step 6
6 Cover plate and band strip
Envelope key step 7
7 Band mounted on the plate
Envelope key step 8
8 Plate with the contact point fitted
Envelope key step 9
9 Key bar with solder lug
Envelope key step 10
10 Key mounted on the body

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.

Electronics step 1
1 Board and the few passive parts
Electronics step 2
2 Wires soldered to the pads; RC parts ready
Electronics step 3
3 Board in its pocket, wires routed
Electronics step 4
4 Wiring fanned out to the contacts
Electronics step 5
5 Sensors, band and RC network wired
Electronics step 6
6 Finished electronics 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.

Fingerboard step 1
1 Parts: rail, strip, spring, screws
Fingerboard step 2
2 Strip-anchor end of the rail
Fingerboard step 3
3 Carbon strip along the rail
Fingerboard step 4
4 Securing the strip end
Fingerboard step 5
5 Rail ready for the neck
Fingerboard step 6
6 Neck fitted to the body
Fingerboard step 7
7 Neck joint and wires at its base
Fingerboard step 8
8 Securing the joint
Fingerboard step 9
9 Along the rail
Fingerboard step 10
10 Checking the strip seating
Fingerboard step 11
11 Fingerboard surface
Fingerboard step 12
12 Rail-end detail
Fingerboard step 13
13 Assembly overview
Fingerboard step 14
14 Spring tensioning at the rail end

Pin map

SignalGPIONotes
Pitch ribbon wiper3ADC1 input, 0–3.3 V
Envelope ribbon node5ADC1 input, 0–3.3 V
Pitch CV out17Built-in DAC ch.0 — fixed pin
Amp CV out18Built-in DAC ch.1 — fixed pin
Status LED15Onboard LED — do not reuse as an input
Touch “select”7Modifier for all touch gestures
Touch “0” / “1” / “2”9 / 11 / 12Slew + pitch-mode combos (below)
Fixed pins: GPIO17/18 are the only DAC pins, GPIO19/20 are USB D−/D+, GPIO15 is the LED. Everything else is free to change in config.h as long as you keep the right pin type (ADC-capable for sensors, touch-capable for pads).

Signal flow

pitch ribbon GPIO3 envelope ribbon GPIO5 ADC 12-bit dummy read + 8× avg calibrate + EMA smoothing pitch: freq map + quantize (4 modes) amp: gate + slew on/off hysteresis USB-MIDI note / bend / CC11 DAC CV out GPIO17/18 + cal table serial telemetry → web console scopes touch pads select slew + pitch mode
Every loop iteration reads both sensors, cleans the readings up, turns them into musical control data, and writes all three outputs. The whole path runs at hundreds of iterations per second — fast enough that the instrument feels immediate.

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:

ModeVibratoSlidesTouch gesture
Notes onlyNo Jump semitone to semitoneselect + 0 + 1
Notes + vibratoYes Step semitone to semitoneselect + 1 + 2
Notes + vibrato + glideYes Glide, then ease onto a semitoneselect + 0 + 2
ContinuousYes Freeall 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)

FileWhat’s inside
config.hAll the tunable knobs — pins, thresholds, tuning table, filter constants. Start here.
tian-xs-workshop.inosetup() + loop(): sensor reads, gate, pitch/amp processing, output writes
calibration.hPitch calibration table → frequency mapping; the StringState struct
pitch_modes.hThe four quantization modes
cv_output.hDAC writes, 1V/oct math, DAC linearization table
midi_helpers.hPitch-bend packing, envelope→amplitude mapping
touch.hCapacitive pads + gesture recognition
console.hSerial command parser + telemetry stream
storage.hSaving calibration/settings to flash (NVS)
debug.hHuman-readable serial debug line
cv_player.hDemo arpeggio for testing CV without playing
webapp/index.htmlThe 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 → boardBoard → 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_FREQ in config.h and 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.