Sweep — Automated Track Selection for Vinyl Records
Presentation Slide
Presentation Video
About the Project
Week 1 — Initial Sketch
Early sketch of the concept before the design was refined.
View Week 1 documentation →
Week 2 — Possible Design
Early design direction explored during Week 2.
View Week 2 documentation →
Project Documentation
System Integration
User flow, mechanism and PCB integration, overall system architecture.
Open documentation →
Applications and Implications
Inspiration, sources, what's planned, what's done.
Open documentation →
Project Development
Design progression, electronics integration, prototyping and iterative development.
Open documentation →
Features
PCB
Web App Screenshot
Web app: fabsweep.web.app
Final Design Render
Final Build
Firmware
/*
* Vinyl Record Selector — ALL-IN-ONE
* Seeed XIAO ESP32-C6
*
* Wiring:
* Encoder A → D1 (GPIO1)
* Encoder B → D2 (GPIO2)
* Encoder SW → D3 (GPIO21)
* OLED SDA → D4 (GPIO22)
* OLED SCL → D5 (GPIO23)
* Solenoid → D6 (GPIO16)
* Servo Move → D7 (GPIO17)
* Servo Lift → D8 (GPIO19)
*
* Libraries needed:
* - Adafruit SSD1306
* - Adafruit GFX Library
* - ESP32Servo
*
* ENCODER: CCW = increment track
*
* SONG TABLE:
* Song 1 → 82° Song 2 → 79° Song 3 → 76°
* Song 4 → 72° Song 5 → 68° Song 6 → 65°
*/
#include
#include
#include
#include
// =============================================================================
// PINS
// =============================================================================
#define ENC_A 1 // D1
#define ENC_B 2 // D2
#define ENC_SW 21 // D3
// SDA = 22 (D4), SCL = 23 (D5) — handled by Wire.begin()
#define SOLENOID_PIN 16 // D6
#define SERVO_MOVE 17 // D7
#define SERVO_LIFT 19 // D8
// =============================================================================
// DISPLAY
// =============================================================================
#define SCREEN_W 128
#define SCREEN_H 64
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, OLED_RESET);
// =============================================================================
// SERVOS
// =============================================================================
Servo servoLift;
Servo servoMove;
// =============================================================================
// MOTION LIMITS
// =============================================================================
#define LIFT_UP 135.0f
#define LIFT_DOWN 100.0f
#define MOVE_MIN 50
#define MOVE_MAX 120
#define MOVE_MIN_DEGREE 57 // arm at vinyl record start
#define MOVE_MAX_DEGREE 82 // arm at vinyl record end
#define PARK 100.0f
#define SEARCH_MIN 52
#define SERVO_MIN_US 500
#define SERVO_MAX_US 2500
#define STEP_DELAY 20
float liftPos = LIFT_DOWN;
float movePos = PARK;
// =============================================================================
// SONG TABLE — 6 songs, angle in degrees
// =============================================================================
#define NUM_TRACKS 6
const char* trackNames[NUM_TRACKS] = {
"Side A-1",
"Side A-2",
"Side A-3",
"Side B-1",
"Side B-2",
"Side B-3",
};
const float trackAngles[NUM_TRACKS] = { 82.0f, 79.0f, 76.0f, 72.0f, 68.0f, 65.0f };
// =============================================================================
// VINYL DISPLAY GEOMETRY
// =============================================================================
#define VX 36
#define VY 30
#define LABEL_R 8
#define OUTER_R 28
#define RING_GAP 3
// =============================================================================
// UI STATE
// =============================================================================
enum UIState { UI_IDLE, UI_CONFIRM, UI_PLAYING };
UIState uiState = UI_IDLE;
uint32_t confirmMs = 0;
#define CONFIRM_TIMEOUT 3000 // ms before confirm auto-cancels
int currentTrack = 0;
int playingTrack = -1;
// =============================================================================
// SPIN ANIMATION
// =============================================================================
float spinAngle = 0.0f;
float spinSpeed = 0.8f;
bool isSpinning = false;
uint32_t spinStartMs = 0;
#define SPIN_DURATION_MS 1200
#define SPIN_MAX_SPEED 8.0f
// =============================================================================
// ENCODER — polled, rising edge only (matches working test sketch)
// CCW = increment, CW = decrement
// Sensitivity: only act every ENC_STEP pulses to reduce over-sensitivity
// =============================================================================
int lastEncA = HIGH;
int encPulses = 0; // accumulated pulses before registering a step
#define ENC_STEP 2 // raise this (3,4…) if still too sensitive
uint32_t lastBtnMs = 0;
bool lastBtnState = HIGH;
#define BTN_DEBOUNCE 200
// =============================================================================
// SERVO HELPERS
// =============================================================================
void writeServoFloat(Servo &s, float angle) {
angle = constrain(angle, 0.0f, 180.0f);
int us = map((int)(angle * 100), 0, 18000, SERVO_MIN_US, SERVO_MAX_US);
s.writeMicroseconds(us);
}
void moveLift(bool up) {
float target = up ? LIFT_UP : LIFT_DOWN;
while (abs(liftPos - target) > 0.1f) {
liftPos += (liftPos < target) ? 0.2f : -0.2f;
servoLift.write((int)liftPos);
delay(STEP_DELAY);
}
liftPos = target;
servoLift.write((int)liftPos);
}
void moveArmRaw(float target) {
target = constrain(target, (float)MOVE_MIN, (float)MOVE_MAX);
while (abs(movePos - target) > 0.1f) {
movePos += (movePos < target) ? 0.2f : -0.2f;
writeServoFloat(servoMove, movePos);
delay(STEP_DELAY);
}
movePos = target;
writeServoFloat(servoMove, movePos);
}
// =============================================================================
// MOTION SEQUENCES
// =============================================================================
void restSequence() {
Serial.println("REST");
digitalWrite(SOLENOID_PIN, HIGH);
moveArmRaw(PARK);
digitalWrite(SOLENOID_PIN, LOW);
Serial.println("REST DONE");
}
void searchSequence() {
Serial.println("SEARCH START");
digitalWrite(SOLENOID_PIN, HIGH);
moveArmRaw(PARK);
digitalWrite(SOLENOID_PIN, LOW);
moveLift(true);
moveArmRaw(SEARCH_MIN);
moveArmRaw(PARK);
digitalWrite(SOLENOID_PIN, LOW);
moveLift(false);
Serial.println("SEARCH DONE");
}
// Lift → move to groove angle → drop. Needle stays on record.
void seekToAngle(float target) {
target = constrain(target, (float)MOVE_MIN_DEGREE, (float)MOVE_MAX_DEGREE);
Serial.print("SEEK → "); Serial.println(target);
moveLift(true);
while (abs(movePos - target) > 0.1f) {
movePos += (movePos < target) ? 0.2f : -0.2f;
writeServoFloat(servoMove, movePos);
delay(STEP_DELAY);
}
movePos = target;
writeServoFloat(servoMove, movePos);
delay(150);
moveLift(false);
Serial.println("SEEK DONE");
}
// Full play: search → settle → seek
void playTrack(int trackIndex) {
float angle = trackAngles[trackIndex];
Serial.printf("PLAY track %d at %.0f deg\n", trackIndex + 1, angle);
searchSequence();
delay(300);
seekToAngle(angle);
Serial.println("PLAY COMPLETE");
}
// =============================================================================
// OLED DRAW HELPERS
// =============================================================================
void drawThickCircle(int cx, int cy, int r, int thickness, uint16_t colour) {
for (int t = 0; t < thickness; t++)
display.drawCircle(cx, cy, r - t, colour);
}
void drawGrooveTick(int cx, int cy, float angleDeg, int r1, int r2) {
float rad = angleDeg * PI / 180.0f;
display.drawLine(
cx + (int)(r1 * cos(rad)), cy + (int)(r1 * sin(rad)),
cx + (int)(r2 * cos(rad)), cy + (int)(r2 * sin(rad)),
SSD1306_WHITE
);
}
void drawVinyl() {
// Solid disc
for (int r = OUTER_R; r >= 0; r--)
display.drawCircle(VX, VY, r, SSD1306_WHITE);
// Erase inner then re-draw faint grooves
for (int r = LABEL_R + 1; r <= OUTER_R - 1; r++)
display.drawCircle(VX, VY, r, SSD1306_BLACK);
for (int r = LABEL_R + 2; r < OUTER_R - 1; r += 2)
display.drawCircle(VX, VY, r, SSD1306_WHITE);
// Track rings — highlighted ring = current selection
for (int t = 0; t < NUM_TRACKS; t++) {
int r = OUTER_R - t * RING_GAP;
if (r <= LABEL_R) break;
if (t == currentTrack) {
drawThickCircle(VX, VY, r, 2, SSD1306_WHITE);
drawThickCircle(VX, VY, r - 3, 2, SSD1306_WHITE);
} else {
display.drawCircle(VX, VY, r, SSD1306_WHITE);
}
}
// Centre label
for (int r = LABEL_R; r >= 0; r--)
display.drawCircle(VX, VY, r, (r == LABEL_R) ? SSD1306_WHITE : SSD1306_BLACK);
display.fillCircle(VX, VY, 1, SSD1306_WHITE);
// Spin tick
drawGrooveTick(VX, VY, spinAngle, LABEL_R + 1, LABEL_R + 3);
}
void drawTrackInfo() {
display.drawFastVLine(74, 0, SCREEN_H, SSD1306_WHITE);
// Track number
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(78, 2);
display.print("TRACK");
display.setTextSize(2);
display.setCursor(80, 13);
char buf[4];
snprintf(buf, sizeof(buf), "%02d", currentTrack + 1);
display.print(buf);
// Track name (small)
display.setTextSize(1);
display.setCursor(78, 36);
// Strip leading "XX " numbering if present, clip to 7 chars
const char* name = trackNames[currentTrack];
char clip[8];
strncpy(clip, name, 7);
clip[7] = '\0';
display.print(clip);
// Status line (bottom)
display.setCursor(76, 52);
switch (uiState) {
case UI_IDLE:
display.print("SEL=PLAY");
break;
case UI_CONFIRM:
// Blink to draw attention
if ((millis() / 350) % 2 == 0)
display.print("> PLAY? <");
break;
case UI_PLAYING:
if ((millis() / 500) % 2 == 0)
display.print("PLAYING ");
else
display.print(" ");
break;
}
}
// =============================================================================
// SETUP
// =============================================================================
void setup() {
Serial.begin(115200);
delay(500);
// I2C for OLED
Wire.begin(22, 23);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OLED not found — check wiring");
while (true) delay(100);
}
display.setRotation(2); // 180° flip
display.clearDisplay();
display.display();
// Solenoid
pinMode(SOLENOID_PIN, OUTPUT);
digitalWrite(SOLENOID_PIN, LOW);
// Servos
servoLift.attach(SERVO_LIFT, SERVO_MIN_US, SERVO_MAX_US);
servoMove.attach(SERVO_MOVE);
servoLift.write((int)liftPos);
writeServoFloat(servoMove, movePos);
// Encoder — polled, no interrupt needed
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
lastEncA = digitalRead(ENC_A);
Serial.println("=== VINYL SELECTOR READY ===");
for (int i = 0; i < NUM_TRACKS; i++)
Serial.printf(" Track %d: %s %.0f deg\n", i+1, trackNames[i], trackAngles[i]);
}
// =============================================================================
// LOOP
// =============================================================================
void loop() {
uint32_t now = millis();
// ── Encoder (polled, rising edge only) ────────────────────────────────────
int currentA = digitalRead(ENC_A);
if (currentA != lastEncA) {
if (currentA == HIGH) { // rising edge only
if (digitalRead(ENC_B) != currentA)
encPulses++; // CW pulse
else
encPulses--; // CCW pulse
}
lastEncA = currentA;
}
// Only register a track step every ENC_STEP pulses (reduces sensitivity)
int delta = 0;
if (encPulses >= ENC_STEP) { delta = +1; encPulses = 0; } // CW = next
if (encPulses <= -ENC_STEP) { delta = -1; encPulses = 0; } // CCW = prev
if (delta != 0) {
currentTrack = (currentTrack + delta + NUM_TRACKS) % NUM_TRACKS;
if (uiState == UI_CONFIRM) uiState = UI_IDLE;
Serial.printf("Track %d: %s\n", currentTrack+1, trackNames[currentTrack]);
}
// ── Confirm timeout ────────────────────────────────────────────────────────
if (uiState == UI_CONFIRM && (now - confirmMs) > CONFIRM_TIMEOUT) {
uiState = UI_IDLE;
Serial.println("Confirm timed out");
}
// ── Button ─────────────────────────────────────────────────────────────────
if (digitalRead(ENC_SW) == LOW && (now - lastBtnMs) > BTN_DEBOUNCE) {
lastBtnMs = now;
if (uiState == UI_IDLE) {
uiState = UI_CONFIRM;
confirmMs = now;
Serial.printf("Confirm: play track %d?\n", currentTrack+1);
} else if (uiState == UI_CONFIRM) {
// Confirmed — run the full play sequence
uiState = UI_PLAYING;
playingTrack = currentTrack;
isSpinning = true;
spinStartMs = now;
// Redraw "PLAYING" before the blocking motion starts
display.clearDisplay();
drawVinyl();
drawTrackInfo();
display.display();
playTrack(playingTrack); // blocking — servos move here
uiState = UI_IDLE; // return to idle when done
} else if (uiState == UI_PLAYING) {
// Shouldn't normally be reachable (playTrack is blocking)
// but safety fallback
uiState = UI_IDLE;
}
}
// ── Spin animation ─────────────────────────────────────────────────────────
if (isSpinning) {
uint32_t elapsed = now - spinStartMs;
if (elapsed < SPIN_DURATION_MS) {
float t = (float)elapsed / SPIN_DURATION_MS;
float e = (t < 0.5f) ? 2.0f*t*t : 1.0f - 2.0f*(1.0f-t)*(1.0f-t);
spinSpeed = SPIN_MAX_SPEED * (1.0f - e) + 0.8f;
} else {
isSpinning = false;
spinSpeed = 0.8f;
}
}
spinAngle += spinSpeed;
if (spinAngle >= 360.0f) spinAngle -= 360.0f;
// ── Render (skipped while motion is blocking) ──────────────────────────────
if (uiState != UI_PLAYING) {
display.clearDisplay();
drawVinyl();
drawTrackInfo();
display.display();
}
delay(30);
}
Download Firmware (.ino) →
Bill of Materials (BOM)
Total Project Cost
| Total Estimated Cost |
₹631.50 |
Mechanical Materials
-
Plywood
Used to construct the layered enclosure. Each layer is CNC machined using the
ShopBot and laminated together to form the complete body.
-
Transparent Acrylic
Laser cut to form the top cover. The transparent surface allows the user to
see the record while protecting the internal mechanism.
-
PLA Filament
Used to 3D print the OLED display housing, rotary encoder knob and custom
mechanical parts used by the sweeping mechanism.
Design Files