← Luke Schreiber

VeloLift — Barbell Velocity Tracker

C++ · Arduino · FastAPI

Velocity-based training uses bar speed as a live proxy for fatigue and effort — but commercial trackers cost several hundred dollars. This is a tethered encoder measuring bar displacement directly, streaming velocity to a logger that records what actually happened on each rep.

Stack

Hardware Arduino Uno/Nano, 600 PPR Rotary Encoder, C++
Frontend React 19, Vite 7, Tailwind 4
Backend FastAPI, Python, pyserial
Mechanical OpenSCAD, 3D Printing
Status Working prototype, in use in the gym

Signal path

  • The encoder is tied to the bar; pin A is on a hardware interrupt and pin B is read inside the ISR to resolve direction, so up and down are signed rather than absolute
  • Pulses convert to distance through a measured spool circumference and 600 pulses/rev, differentiated over a fixed 100 ms window; a deadband under 0.05 m/s keeps a shaking rack from registering as movement
  • Serial at 115200 baud into a FastAPI service, which streams at 50 Hz over WebSocket to the frontend and auto-detects the Arduino port
  • Rep counting is a direction-based state machine on the velocity sign — no manual rep entry
  • 3D-printed enclosure modeled parametrically in OpenSCAD; set history stored locally in IndexedDB, so no accounts, no cloud, and all data stays on the machine it was measured on

Encoder firmware

Interrupt-driven counting with direction resolution.

volatile long pulseCount = 0;

// ISR: fires on every rising edge of channel A.
// Channel B is sampled inside the ISR to resolve direction.
void handleEncoder() {
  if (digitalRead(pinB) == HIGH) pulseCount--;  // bar descending
  else                           pulseCount++;  // bar ascending
}

// 100 ms window -> signed velocity in m/s
noInterrupts();
long currentPulses = pulseCount; pulseCount = 0;
interrupts();
float velocity = (currentPulses * metersPerPulse) / 0.1;
  • Counting in an ISR rather than polling — at 600 PPR and useful bar speeds the pulse rate outruns any loop that also has to talk over serial
  • The pulse counter is copied out with interrupts disabled, then zeroed, so a pulse arriving mid-read can't be double counted or lost
  • Velocity is computed on the Arduino, not the host, so the timing base stays on the device that owns the interrupt. Sign convention puts positive upward, which makes the concentric phase trivially separable for rep detection

Reading the data

In a recorded squat set, rep 1 comes in low — that's the unrack and setup, not a working rep. Reps 2 through 4 hold 0.32–0.33 m/s average with only 2% velocity drop, which says the load was well under the point where bar speed starts to decay.

Source on GitHub · Demo on X