Skip to content

Instantly share code, notes, and snippets.

@tecteun
Last active October 7, 2025 20:22
Show Gist options
  • Save tecteun/c55ff30f5101a3d9f568004c9877941b to your computer and use it in GitHub Desktop.
Save tecteun/c55ff30f5101a3d9f568004c9877941b to your computer and use it in GitHub Desktop.
Arduino UNO eurorack clock sync multiplier/division tool
// Generated by GPT-5 with some help from humans
// === External clock div/mult generator with per-channel Swing, EEPROM, and LCD menu ===
// - Clock IN: D2 (FALLING edge)
// - Clock OUTs: D3, D11, D12, D13 (avoid D10; common LCD shields use D10 for backlight)
// - Each output: Numerator (Mul: x1..x8), Denominator (Div: /1..16), Swing% (0..75%)
// - Pulses are ~1 ms wide, shaped by TIMER0_COMPA ISR (~1 kHz)
// - EEPROM autosave 1 s after changes; global phase reset with long-press SELECT (~700 ms)
//
// Libraries required:
// SpinTimer: https://github.com/dniklaus/spin-timer
// LcdKeypad: https://github.com/dniklaus/arduino-display-lcdkeypad
// EEPROM: built-in (Arduino)
//
// LCD Keypad controls:
// RIGHT -> next channel
// LEFT -> toggle edit field (Div -> Mul -> Swg)
// UP/DOWN-> change selected field (Div 1..16, Mul 1..8, Swg 0..75)
// SELECT -> short: reset selected channel; long (~700ms): reset ALL channels
#include <Arduino.h>
#include <SpinTimer.h>
#include <LcdKeypad.h>
#include <EEPROM.h>
// ----------- Hardware -----------
const byte CLOCK_IN_PIN = 2; // external clock input
const byte channelPins[] = {12, 3, 11, 13}; // D3 replaces D10
const byte NUM_CHANNELS = sizeof(channelPins);
// Map pin -> user channel ID (per your spec):
// 13 -> 1ch, 11 -> 2ch, 3 -> 3ch, 12 -> 4ch
uint8_t userChIdByPin(byte pin) {
switch (pin) {
case 13: return 4;
case 11: return 3;
case 3: return 2;
case 12: return 1;
default: return 0;
}
}
// ----------- Clock / BPM state -----------
volatile unsigned long lastTickMs = 0;
volatile unsigned long tickIntervalMs = 0; // ms between last two ticks
volatile unsigned long tickCount = 0;
volatile unsigned int bpm = 0;
// ----------- Per-channel parameters -----------
volatile uint8_t denom[NUM_CHANNELS] = {3, 4, 5, 7}; // /Div defaults
volatile uint8_t numer[NUM_CHANNELS] = {1, 2, 1, 3}; // xMul defaults
volatile uint8_t swingPctCh[NUM_CHANNELS] = {0, 0, 0, 0}; // per-channel Swing%
volatile uint8_t divCounter[NUM_CHANNELS] = {0};
volatile uint8_t pulsesRemaining[NUM_CHANNELS] = {0};
volatile uint8_t pulseIndex[NUM_CHANNELS] = {0};
volatile unsigned long groupStartMs[NUM_CHANNELS] = {0};
volatile uint16_t pulseStepMs[NUM_CHANNELS] = {0};
volatile uint16_t swingOffsetMs[NUM_CHANNELS] = {0};
volatile unsigned long nextPulseMs[NUM_CHANNELS] = {0};
volatile bool singleShotNow[NUM_CHANNELS] = {false};
// ----------- LCD / UI -----------
LcdKeypad* lcd = nullptr;
volatile uint8_t selectedCh = 0; // index into channelPins[]
volatile uint8_t editField = 0; // 0 = Div, 1 = Mul, 2 = Swing
volatile bool uiDirty = true;
// Long-press SELECT handling
volatile bool selectDown = false;
volatile unsigned long selectDownAt = 0;
// ----------- EEPROM settings -----------
struct Settings {
char magic[4]; // "CDM2"
uint8_t version; // 2
uint8_t denom[NUM_CHANNELS]; // /1..16
uint8_t numer[NUM_CHANNELS]; // x1..8
uint8_t swingPctCh[NUM_CHANNELS]; // 0..75 per channel
uint8_t reserved[6]; // alignment/future
};
const unsigned long SAVE_DELAY_MS = 1000;
bool settingsDirty = false;
unsigned long lastChangeMs = 0;
void markSettingsDirty() {
settingsDirty = true;
lastChangeMs = millis();
uiDirty = true;
}
void clampSettings() {
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
if (denom[i] < 1) denom[i] = 1;
if (denom[i] > 16) denom[i] = 16;
if (numer[i] < 1) numer[i] = 1;
if (numer[i] > 8) numer[i] = 8;
if (swingPctCh[i] > 75) swingPctCh[i] = 75;
}
}
void globalResetPhases() {
noInterrupts();
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
divCounter[i] = 0;
pulsesRemaining[i] = 0;
pulseIndex[i] = 0;
singleShotNow[i] = false;
}
lastTickMs = 0;
tickIntervalMs = 0;
bpm = 0;
interrupts();
uiDirty = true;
}
bool loadSettings() {
Settings s;
EEPROM.get(0, s);
if (strncmp(s.magic, "CDM2", 4) != 0 || s.version != 2) {
return false;
}
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
denom[i] = s.denom[i];
numer[i] = s.numer[i];
swingPctCh[i] = s.swingPctCh[i];
}
clampSettings();
return true;
}
void saveSettings() {
Settings s;
memcpy(s.magic, "CDM2", 4);
s.version = 2;
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
s.denom[i] = denom[i];
s.numer[i] = numer[i];
s.swingPctCh[i] = swingPctCh[i];
}
memset(s.reserved, 0, sizeof(s.reserved));
EEPROM.put(0, s); // update semantics -> fewer writes
settingsDirty = false;
}
// ----------- External clock ISR -----------
void tickISR() {
++tickCount;
unsigned long now = millis();
if (lastTickMs != 0) {
tickIntervalMs = now - lastTickMs;
if (tickIntervalMs > 0) {
bpm = (unsigned int)(60000UL / tickIntervalMs);
}
}
lastTickMs = now;
// Update channels: Div -> schedule 'numer' subpulses w/ per-channel swing
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
uint8_t d = denom[i]; if (d == 0) d = 1;
if (++divCounter[i] >= d) {
divCounter[i] = 0;
uint8_t n = numer[i]; if (n == 0) n = 1;
pulsesRemaining[i] = n;
pulseIndex[i] = 0;
groupStartMs[i] = now;
uint16_t step = (n > 0) ? (uint16_t)(tickIntervalMs / n) : 0;
if (step == 0) step = 1; // ensure progress
pulseStepMs[i] = step;
uint16_t off = (uint16_t)((step * swingPctCh[i]) / 100);
if (off >= step) off = (step > 1) ? (step - 1) : 0; // avoid overlap
swingOffsetMs[i] = off;
nextPulseMs[i] = groupStartMs[i]; // first pulse immediately
singleShotNow[i] = true;
}
}
}
// ----------- Timer0 Compare A ISR (~1 kHz) -----------
ISR(TIMER0_COMPA_vect) {
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
digitalWrite(channelPins[i], LOW);
}
unsigned long now = millis();
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
bool due = false;
if (singleShotNow[i]) {
due = true;
singleShotNow[i] = false;
} else if (pulsesRemaining[i] > 0 && now >= nextPulseMs[i]) {
due = true;
}
if (due) {
digitalWrite(channelPins[i], HIGH);
if (pulsesRemaining[i] > 0) --pulsesRemaining[i];
if (pulsesRemaining[i] > 0) {
++pulseIndex[i];
unsigned long t = groupStartMs[i] + (unsigned long)pulseIndex[i] * pulseStepMs[i];
if ((pulseIndex[i] & 1) == 1) { // odd subpulses (off-beats)
t += swingOffsetMs[i];
}
nextPulseMs[i] = t;
}
}
}
}
// ----------- UI adapter -----------
class UiAdapter : public LcdKeypadAdapter {
public:
void handleKeyChanged(LcdKeypad::Key key) override {
// Use NO_KEY (not NONE_KEY)
if (key == LcdKeypad::SELECT_KEY) {
selectDown = true;
selectDownAt = millis();
return;
}
if (key == LcdKeypad::NO_KEY && selectDown) {
unsigned long held = millis() - selectDownAt;
selectDown = false;
if (held >= 700) {
globalResetPhases(); // long-press -> global reset
} else {
// short press -> reset selected channel
divCounter[selectedCh] = 0;
pulsesRemaining[selectedCh] = 0;
pulseIndex[selectedCh] = 0;
singleShotNow[selectedCh] = false;
}
uiDirty = true;
return;
}
// Regular editing
switch (key) {
case LcdKeypad::RIGHT_KEY:
selectedCh = (selectedCh + 1) % NUM_CHANNELS;
uiDirty = true;
break;
case LcdKeypad::LEFT_KEY:
editField = (editField + 1) % 3; // Div -> Mul -> Swg -> ...
uiDirty = true;
break;
case LcdKeypad::UP_KEY:
if (editField == 0) { // Div
if (denom[selectedCh] < 16) { ++denom[selectedCh]; markSettingsDirty(); }
} else if (editField == 1) { // Mul
if (numer[selectedCh] < 8) { ++numer[selectedCh]; markSettingsDirty(); }
} else { // Swing per-channel
if (swingPctCh[selectedCh] <= 70) { swingPctCh[selectedCh] += 5; markSettingsDirty(); }
}
break;
case LcdKeypad::DOWN_KEY:
if (editField == 0) { // Div
if (denom[selectedCh] > 1) { --denom[selectedCh]; markSettingsDirty(); }
} else if (editField == 1) { // Mul
if (numer[selectedCh] > 1) { --numer[selectedCh]; markSettingsDirty(); }
} else { // Swing
if (swingPctCh[selectedCh] >= 5) { swingPctCh[selectedCh] -= 5; markSettingsDirty(); }
}
break;
default:
break;
}
}
};
// ----------- Setup -----------
void setup() {
Serial.begin(115200);
// Outputs
for (uint8_t i = 0; i < NUM_CHANNELS; ++i) {
pinMode(channelPins[i], OUTPUT);
digitalWrite(channelPins[i], LOW);
}
// External clock input
pinMode(CLOCK_IN_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(CLOCK_IN_PIN), tickISR, FALLING);
// Timer0 Compare A -> ~1 kHz ISR (Arduino core already configures Timer0 for millis)
OCR0A = 0xAF; // match value ~1ms
TIMSK0 |= _BV(OCIE0A); // enable COMPA interrupt
// LCD
lcd = new LcdKeypad();
lcd->setBacklight(1);
lcd->attachAdapter(new UiAdapter());
// Load settings from EEPROM (or save defaults)
if (!loadSettings()) {
saveSettings(); // commit defaults with magic/version tag
}
clampSettings();
}
// ----------- UI drawing -----------
void drawUI() {
// Snapshot shared values safely
noInterrupts();
unsigned int bpmSnap = bpm;
uint8_t chIdx = selectedCh;
uint8_t d = denom[chIdx];
uint8_t n = numer[chIdx];
uint8_t sw = swingPctCh[chIdx];
byte pin = channelPins[chIdx];
interrupts();
const uint8_t userId = userChIdByPin(pin);
lcd->clear();
// Line 0: "BPM:120 1ch"
lcd->setCursor(0, 0);
lcd->print("BPM:");
lcd->print(bpmSnap);
lcd->print(" ");
lcd->print("CH ");
lcd->print((int)userId);
// Line 1: "D:/4* Mx3 Sw:60"
lcd->setCursor(0, 1);
lcd->print("D:/");
lcd->print((int)d);
lcd->print(editField == 0 ? "*" : " ");
lcd->print(" Mx");
lcd->print((int)n);
lcd->print(editField == 1 ? "*" : " ");
lcd->print(" Sw:");
lcd->print((int)sw);
lcd->print(editField == 2 ? "*" : " ");
}
// ----------- Loop -----------
void loop() {
// Keep key polling alive
scheduleTimers();
// Redraw UI every 250 ms or when dirty
static unsigned long lastDraw = 0;
if (uiDirty || millis() - lastDraw > 250) {
drawUI();
uiDirty = false;
lastDraw = millis();
}
// Deferred EEPROM save
if (settingsDirty && (millis() - lastChangeMs) > SAVE_DELAY_MS) {
saveSettings();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment