Last active
October 7, 2025 20:22
-
-
Save tecteun/c55ff30f5101a3d9f568004c9877941b to your computer and use it in GitHub Desktop.
Arduino UNO eurorack clock sync multiplier/division tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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