30 Commits

Author SHA1 Message Date
26f65eed10 bump versions 2026-03-10 22:27:19 -07:00
9be88be1f4 Merge pull request 'Introduce Dual Window Comparators with cv calibration' (#30) from comparator into main
Reviewed-on: #30
2026-03-11 05:12:09 +00:00
10d19a5e58 perf: Unroll display update loop to prevent blocking the main logic. 2026-03-09 22:18:07 -07:00
a2ad5d244e feat: Add CV input indicators to the display, optimize redraws based on CV value changes, and disable outputs when entering calibration mode. 2026-03-09 19:49:49 -07:00
dc1a6ff5c3 fix: add hysteresis to comparator gate logic to prevent noise bouncing. 2026-03-09 18:56:00 -07:00
3f31780deb Fix flip-flop behavior. 2026-03-08 10:09:01 -07:00
24d981886a Introduce Comparator firmware application with dual voltage comparators, calibration, and UI for parameter control. 2026-03-07 23:34:12 -08:00
f88f52c4ee add link to web installer 2026-03-07 20:24:58 -08:00
fbf8bd94c6 Rollback to v2.0.0beta3 - reverting changes from v2.0.0 2026-03-07 20:09:57 -08:00
acd028846c Full repo version bump to v2.0.0 2025-08-17 11:04:25 -07:00
ed625e75fc Merge pull request 'Fix Save/Load State' (#28) from fix-save-state into main
Reviewed-on: #28
2025-08-17 17:18:56 +00:00
b60dcc0e68 one more 2025-08-16 11:00:31 -07:00
909d589609 make version consistent 2025-08-16 10:58:42 -07:00
330f5e6ceb improve docstring comments 2025-08-16 10:47:06 -07:00
87dacd869b improve the usage of disabling interrupts to avoid a potential race condition with isr being called between private method execution. 2025-08-16 10:06:11 -07:00
64f467d6ac Add missing metadata field in _loadMetadata 2025-08-16 09:55:43 -07:00
84cafe2387 Fix bug in metadata save/load state.
The sketch_name char array was to short, causing a buffer overflow.
2025-08-16 09:51:05 -07:00
8bb89a5f4b formatting 2025-08-14 07:31:43 -07:00
499bc7a643 Added more details explaining the structure of the repo 2025-08-14 07:29:02 -07:00
3f670fa9f7 Update docs and example firmware 2025-08-13 07:42:02 -07:00
b5029bde88 add skeleton app to examples 2025-08-13 07:19:06 -07:00
4bcd618073 Add skeleton app to examples 2025-08-13 07:18:45 -07:00
6ada2aba30 Add option to rotate the display (#27)
I needed to cut the bootsplash to make room for adding this features.

Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/27
2025-08-10 02:47:59 +00:00
c5965aa1f7 bug fix - need to recalculate pulses when mod duty and swing are changed. 2025-08-09 18:45:21 -07:00
7c02628403 Add more EXT clock source options (#23)
Fixes https://github.com/awonak/alt-gravity/issues/12

Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/23
2025-08-10 00:26:20 +00:00
1161da38c1 Add menu options for using cv input as Clock Run/Reset (#25)
Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/25
2025-08-10 00:25:06 +00:00
872af30fbc Refactor CV Mod (#24)
Move cv mod calculation to processClockTick. This is less ideas because it is an ISR, but it saves a significant amount of memory. Performance doesn't seem to take much of a hit.

Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/24
2025-08-09 23:59:24 +00:00
fc17afc9a1 Remove Reset State (#26)
This feature is essentially overlapping with loading default save slots. I need the few bytes it affords me.

Reviewed-on: https://git.pinkduck.xyz/awonak/libGravity/pulls/26
2025-08-09 23:57:10 +00:00
b6402380c0 fixed bug in cv mod of clock multiplication upper range. 2025-07-26 18:51:18 -07:00
19473db67e bump version in code 2025-07-24 18:38:34 -07:00
7 changed files with 461 additions and 8 deletions

View File

@ -2,6 +2,12 @@
This library helps make writing firmware easier by abstracting away the initialization and peripheral interactions. Now your firmware code can just focus on the logic and behavior of the app, and keep the low level code neatly tucked away in this library.
## Quick Start
You can flash the firmware to your module using the [Web Installer](https://awonak.github.io/alt-gravity/). This website also provides demo videos and documentation for each firmware version.
https://awonak.github.io/alt-gravity/
## Installation
Download or git clone this repository into your Arduino > libraries folder.

View File

@ -0,0 +1,435 @@
#include <EEPROM.h>
#include <libGravity.h>
// EEPROM addrs
const int EEPROM_INIT_ADDR = 0;
const int EEPROM_CV1_LOW = 1;
const int EEPROM_CV1_HIGH = 3;
const int EEPROM_CV1_OFFSET = 5;
const int EEPROM_CV2_LOW = 7;
const int EEPROM_CV2_HIGH = 9;
const int EEPROM_CV2_OFFSET = 11;
const int EEPROM_COMP1_SHIFT = 13;
const int EEPROM_COMP1_SIZE = 15;
const int EEPROM_COMP2_SHIFT = 17;
const int EEPROM_COMP2_SIZE = 19;
const byte EEPROM_INIT_FLAG = 0xAB; // Update flag to re-init
// EEPROM Delay Save
const unsigned long SAVE_DELAY_MS = 5000;
bool eeprom_needs_save = false;
unsigned long last_param_change = 0;
enum AppMode { MODE_COMPARATOR, MODE_CALIBRATION };
AppMode current_mode = MODE_COMPARATOR;
byte cal_selected_param = 0; // 0=CV1 Low, 1=CV1 Offset, 2=CV1 High, 3=CV2 Low,
// 4=CV2 Offset, 5=CV2 High
// UI Parameters
enum Parameter { COMP1_SHIFT, COMP1_SIZE, COMP2_SHIFT, COMP2_SIZE };
Parameter selected_param = COMP1_SHIFT;
int comp1_shift = 0; // Range: -512 to 512
int comp1_size = 512; // Range: 0 to 1024
int comp2_shift = 0; // Range: -512 to 512
int comp2_size = 512; // Range: 0 to 1024
bool prev_gate1 = false;
bool prev_gate2 = false;
bool ff_state = false;
bool needs_redraw = true;
int last_cv1_draw = -1000;
int last_cv2_draw = -1000;
unsigned long last_redraw = 0;
bool prev_both_buttons = false;
// Calibration Methods
void LoadCalibration() {
if (EEPROM.read(EEPROM_INIT_ADDR) == EEPROM_INIT_FLAG) {
int val = 0;
EEPROM.get(EEPROM_CV1_LOW, val);
gravity.cv1.SetCalibrationLow(val);
EEPROM.get(EEPROM_CV1_HIGH, val);
gravity.cv1.SetCalibrationHigh(val);
EEPROM.get(EEPROM_CV1_OFFSET, val);
gravity.cv1.AdjustOffset(val - gravity.cv1.GetOffset());
EEPROM.get(EEPROM_CV2_LOW, val);
gravity.cv2.SetCalibrationLow(val);
EEPROM.get(EEPROM_CV2_HIGH, val);
gravity.cv2.SetCalibrationHigh(val);
EEPROM.get(EEPROM_CV2_OFFSET, val);
gravity.cv2.AdjustOffset(val - gravity.cv2.GetOffset());
EEPROM.get(EEPROM_COMP1_SHIFT, comp1_shift);
EEPROM.get(EEPROM_COMP1_SIZE, comp1_size);
EEPROM.get(EEPROM_COMP2_SHIFT, comp2_shift);
EEPROM.get(EEPROM_COMP2_SIZE, comp2_size);
}
}
void SaveCalibration() {
EEPROM.update(EEPROM_INIT_ADDR, EEPROM_INIT_FLAG);
int val;
val = gravity.cv1.GetCalibrationLow();
EEPROM.put(EEPROM_CV1_LOW, val);
val = gravity.cv1.GetCalibrationHigh();
EEPROM.put(EEPROM_CV1_HIGH, val);
val = gravity.cv1.GetOffset();
EEPROM.put(EEPROM_CV1_OFFSET, val);
val = gravity.cv2.GetCalibrationLow();
EEPROM.put(EEPROM_CV2_LOW, val);
val = gravity.cv2.GetCalibrationHigh();
EEPROM.put(EEPROM_CV2_HIGH, val);
val = gravity.cv2.GetOffset();
EEPROM.put(EEPROM_CV2_OFFSET, val);
EEPROM.put(EEPROM_COMP1_SHIFT, comp1_shift);
EEPROM.put(EEPROM_COMP1_SIZE, comp1_size);
EEPROM.put(EEPROM_COMP2_SHIFT, comp2_shift);
EEPROM.put(EEPROM_COMP2_SIZE, comp2_size);
}
// Handlers
void OnPlayPress() {
if (gravity.shift_button.On())
return; // ignore if holding both
if (current_mode == MODE_CALIBRATION) {
cal_selected_param = (cal_selected_param < 3) ? cal_selected_param + 3
: cal_selected_param - 3;
needs_redraw = true;
return;
}
if (selected_param == COMP1_SHIFT)
selected_param = COMP2_SHIFT;
else if (selected_param == COMP1_SIZE)
selected_param = COMP2_SIZE;
else if (selected_param == COMP2_SHIFT)
selected_param = COMP1_SHIFT;
else if (selected_param == COMP2_SIZE)
selected_param = COMP1_SIZE;
needs_redraw = true;
}
void OnShiftPress() {
if (gravity.play_button.On())
return; // ignore if holding both
if (current_mode == MODE_CALIBRATION) {
cal_selected_param =
(cal_selected_param / 3) * 3 + ((cal_selected_param + 1) % 3);
needs_redraw = true;
return;
}
if (selected_param == COMP1_SHIFT)
selected_param = COMP1_SIZE;
else if (selected_param == COMP1_SIZE)
selected_param = COMP1_SHIFT;
else if (selected_param == COMP2_SHIFT)
selected_param = COMP2_SIZE;
else if (selected_param == COMP2_SIZE)
selected_param = COMP2_SHIFT;
needs_redraw = true;
}
void OnEncoderRotate(int val) {
if (current_mode == MODE_CALIBRATION) {
AnalogInput *cv = (cal_selected_param > 2) ? &gravity.cv2 : &gravity.cv1;
// Scale val up so tuning is practical without excessive encoder interrupts
int cal_adj = val * 8;
switch (cal_selected_param % 3) {
case 0:
cv->AdjustCalibrationLow(cal_adj);
break;
case 1:
cv->AdjustOffset(cal_adj);
break;
case 2:
cv->AdjustCalibrationHigh(cal_adj);
break;
}
needs_redraw = true;
return;
}
int amount = val * 16;
switch (selected_param) {
case COMP1_SHIFT:
comp1_shift = constrain(comp1_shift + amount, -512, 512);
break;
case COMP1_SIZE:
comp1_size = constrain(comp1_size + amount, 0, 1024);
break;
case COMP2_SHIFT:
comp2_shift = constrain(comp2_shift + amount, -512, 512);
break;
case COMP2_SIZE:
comp2_size = constrain(comp2_size + amount, 0, 1024);
break;
}
eeprom_needs_save = true;
last_param_change = millis();
needs_redraw = true;
}
void DisplayCalibrationPoint(AnalogInput *cv, const char *title, int index) {
int barWidth = 100, barHeight = 10, textHeight = 10;
int half = barWidth / 2;
int offsetX = 16, offsetY = (32 * index);
gravity.display.setDrawColor(1);
int value = cv->Read();
gravity.display.setCursor(0, offsetY + textHeight);
gravity.display.print(title);
if (value >= 0)
gravity.display.print(" ");
gravity.display.print(value);
gravity.display.setCursor(92, offsetY + textHeight);
if (cv->Voltage() >= 0)
gravity.display.print(" ");
gravity.display.print(cv->Voltage(), 1);
gravity.display.print(F("V"));
gravity.display.drawFrame(offsetX, textHeight + offsetY + 2, barWidth,
barHeight);
if (value > 0) {
int x = constrain(map(value, 0, 512, 0, half), 0, half);
gravity.display.drawBox(half + offsetX, textHeight + offsetY + 2, x,
barHeight);
} else {
int x = constrain(map(abs(value), 0, 512, 0, half), 0, half);
gravity.display.drawBox((half + offsetX) - x, textHeight + offsetY + 2, x,
barHeight);
}
if (cal_selected_param / 3 == index) {
int left = offsetX + (half * (cal_selected_param % 3) - 2);
int top = barHeight + textHeight + offsetY + 12;
gravity.display.drawStr(left, top, "^");
}
}
void UpdateCalibrationDisplay() {
gravity.display.setFontMode(0);
gravity.display.setDrawColor(1);
gravity.display.setFont(u8g2_font_profont11_tf);
DisplayCalibrationPoint(&gravity.cv1, "CV1: ", 0);
DisplayCalibrationPoint(&gravity.cv2, "CV2: ", 1);
}
// UpdateDisplay
void UpdateDisplay(int cv1_val, int cv2_val) {
// Comp 1 graphics (Left)
int c1_h = max((comp1_size * 3) / 64, 1);
int c1_center = 26 - ((comp1_shift * 3) / 64);
int c1_y = c1_center - (c1_h / 2);
gravity.display.drawFrame(20, c1_y, 44, c1_h);
// CV 1 Indicator (Filled Box, 50% width, from 0V center)
int cv1_y = constrain(26 - ((cv1_val * 3) / 64), 2, 50);
if (cv1_val >= 0) {
gravity.display.drawBox(31, cv1_y, 22, 26 - cv1_y);
} else {
gravity.display.drawBox(31, 26, 22, cv1_y - 26);
}
// Comp 2 graphics (Right)
int c2_h = max((comp2_size * 3) / 64, 1);
int c2_center = 26 - ((comp2_shift * 3) / 64);
int c2_y = c2_center - (c2_h / 2);
gravity.display.drawFrame(74, c2_y, 44, c2_h);
// CV 2 Indicator (Filled Box, 50% width, from 0V center)
int cv2_y = constrain(26 - ((cv2_val * 3) / 64), 2, 50);
if (cv2_val >= 0) {
gravity.display.drawBox(85, cv2_y, 22, 26 - cv2_y);
} else {
gravity.display.drawBox(85, 26, 22, cv2_y - 26);
}
// Restore solid drawing for labels
gravity.display.setDrawColor(1);
gravity.display.setFont(u8g2_font_5x7_tf);
gravity.display.setCursor(0, 7);
gravity.display.print("+5V");
gravity.display.setCursor(6, 29);
gravity.display.print("0V");
gravity.display.setCursor(0, 51);
gravity.display.print("-5V");
gravity.display.setDrawColor(2); // XOR mode
// Draw center divider and dotted lines in XOR
for (int x = 20; x < 128; x += 4) {
gravity.display.drawPixel(x, 2); // +5V
gravity.display.drawPixel(x, 26); // 0V
gravity.display.drawPixel(x, 50); // -5V
}
for (int y = 0; y <= 50; y += 4) {
gravity.display.drawPixel(69, y); // Center divider
}
// Restore draw color to default (solid)
gravity.display.setDrawColor(1);
// Bottom text area
gravity.display.setDrawColor(0);
gravity.display.drawBox(0, 52, 128, 12);
gravity.display.setDrawColor(1);
gravity.display.drawHLine(0, 52, 128);
gravity.display.setFont(u8g2_font_6x10_tf);
gravity.display.setCursor(2, 62);
char text[32];
switch (selected_param) {
case COMP1_SHIFT:
snprintf(text, sizeof(text), "> Comp 1 Shift: %d", comp1_shift);
break;
case COMP1_SIZE:
snprintf(text, sizeof(text), "> Comp 1 Size: %d", comp1_size);
break;
case COMP2_SHIFT:
snprintf(text, sizeof(text), "> Comp 2 Shift: %d", comp2_shift);
break;
case COMP2_SIZE:
snprintf(text, sizeof(text), "> Comp 2 Size: %d", comp2_size);
break;
}
gravity.display.print(text);
}
void setup() {
gravity.Init();
LoadCalibration();
// Speed up ADC conversions
ADCSRA &= ~(bit(ADPS2) | bit(ADPS1) | bit(ADPS0));
ADCSRA |= bit(ADPS2);
gravity.play_button.AttachPressHandler(OnPlayPress);
gravity.shift_button.AttachPressHandler(OnShiftPress);
gravity.encoder.AttachRotateHandler(OnEncoderRotate);
}
void loop() {
gravity.Process();
bool both_pressed = gravity.play_button.On() && gravity.shift_button.On();
if (both_pressed && !prev_both_buttons) {
if (current_mode == MODE_COMPARATOR) {
current_mode = MODE_CALIBRATION;
cal_selected_param = 0;
// Turn off all outputs to prevent phantom gates while tuning
for (int i = 0; i < 6; i++) {
gravity.outputs[i].Update(LOW);
}
} else {
SaveCalibration();
current_mode = MODE_COMPARATOR;
}
needs_redraw = true;
}
prev_both_buttons = both_pressed;
int cv1_val = gravity.cv1.Read();
int cv2_val = gravity.cv2.Read();
if (current_mode == MODE_COMPARATOR) {
int c1_lower = comp1_shift - (comp1_size / 2);
int c1_upper = comp1_shift + (comp1_size / 2);
int c2_lower = comp2_shift - (comp2_size / 2);
int c2_upper = comp2_shift + (comp2_size / 2);
const int HYSTERESIS = 4; // Margin to prevent noise bouncing at threshold
bool gate1 = prev_gate1;
if (gate1) {
if (cv1_val < c1_lower - HYSTERESIS || cv1_val > c1_upper + HYSTERESIS) {
gate1 = false;
}
} else {
if (cv1_val >= c1_lower + HYSTERESIS &&
cv1_val <= c1_upper - HYSTERESIS) {
gate1 = true;
}
}
bool gate2 = prev_gate2;
if (gate2) {
if (cv2_val < c2_lower - HYSTERESIS || cv2_val > c2_upper + HYSTERESIS) {
gate2 = false;
}
} else {
if (cv2_val >= c2_lower + HYSTERESIS &&
cv2_val <= c2_upper - HYSTERESIS) {
gate2 = true;
}
}
bool logic_and = gate1 && gate2;
bool logic_or = gate1 || gate2;
bool logic_xor = gate1 ^ gate2;
static bool prev_logic_xor = false;
if (logic_xor && !prev_logic_xor) {
ff_state = !ff_state;
}
prev_logic_xor = logic_xor;
gravity.outputs[0].Update(gate1 ? HIGH : LOW);
gravity.outputs[1].Update(gate2 ? HIGH : LOW);
gravity.outputs[2].Update(logic_and ? HIGH : LOW);
gravity.outputs[3].Update(logic_or ? HIGH : LOW);
gravity.outputs[4].Update(logic_xor ? HIGH : LOW);
gravity.outputs[5].Update(ff_state ? HIGH : LOW);
prev_gate1 = gate1;
prev_gate2 = gate2;
}
if (eeprom_needs_save && (millis() - last_param_change > SAVE_DELAY_MS)) {
SaveCalibration();
eeprom_needs_save = false;
}
if (current_mode == MODE_COMPARATOR) {
if (abs(cv1_val - last_cv1_draw) > 12 ||
abs(cv2_val - last_cv2_draw) > 12) {
needs_redraw = true;
}
} else if (current_mode == MODE_CALIBRATION) {
// Need frequent redraws in calibration to see the live target input
if (abs(cv1_val - last_cv1_draw) >= 2 ||
abs(cv2_val - last_cv2_draw) >= 2) {
needs_redraw = true;
}
}
// Unroll the display loop so it doesn't block the logic loop
static bool is_drawing = false;
if (needs_redraw && !is_drawing && (millis() - last_redraw >= 30)) {
needs_redraw = false;
is_drawing = true;
last_redraw = millis();
gravity.display.firstPage();
}
if (is_drawing) {
if (current_mode == MODE_COMPARATOR) {
last_cv1_draw = cv1_val;
last_cv2_draw = cv2_val;
UpdateDisplay(cv1_val, cv2_val);
} else {
UpdateCalibrationDisplay();
}
is_drawing = gravity.display.nextPage();
}
}

View File

@ -505,8 +505,7 @@ void Bootsplash() {
gravity.display.setFont(TEXT_FONT);
textWidth = gravity.display.getStrWidth(StateManager::SKETCH_NAME);
gravity.display.drawStr(16 + (textWidth / 2), 20,
StateManager::SKETCH_NAME);
gravity.display.drawStr(4 + (textWidth / 2), 22, StateManager::SKETCH_NAME);
textWidth = gravity.display.getStrWidth(StateManager::SEMANTIC_VERSION);
gravity.display.drawStr(16 + (textWidth / 2), 32,

View File

@ -18,8 +18,8 @@
// Define the constants for the current firmware.
const char StateManager::SKETCH_NAME[] = "ALT EUCLIDEAN";
const char StateManager::SEMANTIC_VERSION[] =
"V2.0.1BETA1"; // NOTE: This should match the version in the
// library.properties file.
"V2.0.1"; // NOTE: This should match the version in the
// library.properties file.
// Number of available save slots.
const byte StateManager::MAX_SAVE_SLOTS = 10;

View File

@ -18,8 +18,8 @@
// Define the constants for the current firmware.
const char StateManager::SKETCH_NAME[] = "ALT GRAVITY";
const char StateManager::SEMANTIC_VERSION[] =
"V2.0.1BETA1"; // NOTE: This should match the version in the
// library.properties file.
"V2.0.1"; // NOTE: This should match the version in the
// library.properties file.
// Number of available save slots.
const byte StateManager::MAX_SAVE_SLOTS = 10;

View File

@ -1,4 +1,4 @@
version=2.0.1beta1
version=2.0.1
author=Adam Wonak
maintainer=awonak <github.com/awonak>
sentence=Hardware abstraction library for Sitka Instruments Gravity eurorack module

View File

@ -42,7 +42,8 @@ public:
old_read_ = read_;
int raw = analogRead(pin_);
read_ = map(raw, 0, MAX_INPUT, low_, high_);
read_ = constrain(read_ - offset_, -512, 512);
// Cast to long to avoid AVR 16-bit integer overflow prior to constraining
read_ = constrain((long)read_ - (long)offset_, -512, 512);
if (inverted_)
read_ = -read_;
}
@ -53,8 +54,20 @@ public:
void AdjustCalibrationHigh(int amount) { high_ += amount; }
void SetCalibrationLow(int low) { low_ = low; }
void SetCalibrationHigh(int high) { high_ = high; }
int GetCalibrationLow() const { return low_; }
int GetCalibrationHigh() const { return high_; }
void SetOffset(float percent) { offset_ = -(percent) * 512; }
void AdjustOffset(int amount) { offset_ += amount; }
int GetOffset() const { return offset_; }
void SetAttenuation(float percent) {
low_ = abs(percent) * CALIBRATED_LOW;
high_ = abs(percent) * CALIBRATED_HIGH;